[ASM] 42 Libasm 과제를 해보아요~(8) ft_write


2026.07.05, 17:52


ft_write 핵심 개념

지금까지 만든 세 함수(strlen, strcpy, strcmp)는 순수 계산만 했지 커널을 호출하지 않았습니다. ft_write는 처음으로 syscall을 직접 부르는 함수이고, 여기서 호출 규약이 바뀝니다.

구분인자1인자2인자3시스콜 번호반환
일반 함수 호출rdirsirdxrcx (안 씀)rax
syscallrdirsirdxr10rax

write(int fd, const void *buf, size_t count) 시그니처를 syscall에 그대로 매핑하면:

  • rdi = fd
  • rsi = buf
  • rdx = count
  • rax = syscall 번호(write는 1)
  • syscall 명령 실행

여기서 확인이 필요합니다. libasm 과제 요구사항에 “errno 처리”가 언급되어 있다고 기억 메모리에 있는데, ft_write에서 syscall이 실패했을 때(rax가 음수로 반환됨) errno를 어떻게 세팅해야 하는지에 대한 과제 스펙 세부사항(예: errno 전역 변수에 직접 쓸지, __errno_location()을 호출할지)을 확인해주시겠습니까?

  • You must check for errors during syscalls and handle them properly when needed.
  • Your code must set the variable errno properly.
  • For that, you are allowed to call the extern ___error or errno_location

확인 감사합니다. Ubuntu(리눅스) 환경이므로 __error가 아니라 **__errno_location()**을 써야 합니다. (__error는 macOS/BSD 계열 함수입니다.)

syscall 실패 판별 원리

리눅스 syscall은 에러 시 별도의 플래그나 예외를 던지지 않고, rax에 음수 값을 그대로 돌려줍니다. 이 음수의 절댓값이 곧 errno 값입니다.

rax 값의미
0 이상성공, rax는 실제 반환값(예: write가 실제로 쓴 바이트 수)
음수(-1 ~ -4095 범위)실패, -rax가 errno 값 (예: rax=-9면 errno=9=EBADF)

처리 흐름

  1. syscall 실행 후 rax를 확인한다.
  2. rax가 음수이면: -rax를 계산해 errno 값으로 만든다.
  3. __errno_location()호출한다 — 이 함수는 인자 없이 호출되며, 현재 스레드의 errno 변수 주소를 rax에 담아 반환한다.
  4. 그 주소가 가리키는 곳에 2번에서 계산한 값을 저장한다.
  5. ft_write의 반환값은 -1로 세팅한다 (표준 write 규격상 실패 시 -1 반환, errno에 별도로 원인 기록).

함수 호출 시 주의점__errno_location()을 호출하는 순간, 이건 일반 함수 호출이지 syscall이 아닙니다. 즉 call __errno_location 명령을 쓰게 되는데, 이때 두 가지를 꼭 챙기셔야 합니다.

주의사항근거
rdi, rsi, rdx 등 syscall에 썼던 인자 레지스터 값이 call 과정에서 보존된다는 보장이 없음__errno_location은 자기 나름대로 caller-saved 레지스터를 자유롭게 씁니다. syscall 실패 시 rax(음수 반환값)를 이미 다른 곳에 백업해두지 않으면 call 이후 값을 잃어버립니다
스택 정렬(16바이트 정렬)call 명령 직전에 rsp가 16바이트 경계에 맞아야 합니다. ft_write가 단순 리프 함수라 별도 스택 조작이 없었다면 보통 자동으로 맞지만, 함수 진입 시점의 정렬 상태를 스스로 확인해보시기 바랍니다

질문: syscall 직후 얻은 음수 rax 값을 call __errno_location 실행 전에 어느 레지스터에 백업해두시겠습니까? (rdi, rsi, rdx는 이미 fd/buf/count로 쓰였으니 피해야 합니다.)

7/7 20:59

문제가 있다. 나는 write 함수가 어떻게 동작하는지 모른다.

뜯어보자

유나님, ft_write 검증에 필요한 참고 자료와 체크리스트를 정리해 드릴게요.

1. libc write 구현체 확인 방법

소스위치특징
glibcsysdeps/unix/sysv/linux/write.c (sourceware.org/git glibc)실제 로직은 INLINE_SYSCALL 매크로/syscall-template.S로 감춰져 있어 가독성이 낮음
musl libcsrc/unistd/write.c (git.musl-libc.org)코드가 훨씬 단순 — __syscall_ret() 기반 errno 처리 로직을 직접 볼 수 있어 42 과제 참고용으로 더 적합
Linux syscall tablearch/x86/entry/syscalls/syscall_64.tbl 또는 /usr/include/asm-generic/unistd.hwrite 시스콜 번호(x86-64: 1) 확인
man 페이지man 2 write반환값, errno 종류(EBADF, EFAULT, EINTR 등) 공식 명세

확인 필요: musl 쪽이 로직이 짧아서 추천드리지만, 42 subject가 “glibc와 동일 동작”을 요구하는지 규정을 한번 확인해 보시는 게 좋습니다.

musl libc로 간다

https://git.musl-libc.org/cgit/musl/tree/src/unistd/write.c

#include <unistd.h>
#include "syscall.h"

ssize_t write(int fd, const void *buf, size_t count)
{
	return syscall_cp(SYS_write, fd, buf, count);
}

왜 한 줄 뿐이지요?

syscall_cp 매크로는 root/src/internal/syscall.h 에 있다.

#define syscall_cp(...) __syscall_ret(__syscall_cp(__VA_ARGS__))

//__syscall_ret(__syscall_cp(SYS_write, fd, buf, count))

__syscall_ret는 나중에 다시 보자

__syscall_cp

#define __syscall_cp(...) __SYSCALL_DISP(__syscall_cp,__VA_ARGS__)

//__SYSCALL_DISP(__syscall_cp, SYS_write, fd, buf, count)

__SYSCALL_DISP

#define __SYSCALL_DISP(b,...) __SYSCALL_CONCAT(b,__SYSCALL_NARGS(__VA_ARGS__))(__VA_ARGS__)

// __SYSCALL_CONCAT(__syscall_cp,__SYSCALL_NARGS(SYS_write, fd, buf, count))(SYS_write, fd, buf, count)

__SYSCALL_NARGS, __SYSCALL_NARGS_X

#define __SYSCALL_NARGS_X(a,b,c,d,e,f,g,h,n,...) n
#define __SYSCALL_NARGS(...) __SYSCALL_NARGS_X(__VA_ARGS__,7,6,5,4,3,2,1,0,)

// __SYSCALL_NARGS(SYS_write, fd, buf, count)
// __SYSCALL_NARGS_X(SYS_write, fd, buf, count, 7,6,5,4,3,2,1,0,) 3
// 3

이건 가변 인자 개수를 컴파일 타임에 세는 매크로 트릭입니다. syscall() 함수가 인자 개수(0~6개)에 따라 다른 syscall 래퍼(__syscall0, __syscall1, … __syscall6)를 호출해야 하기 때문에 필요합니다.

동작 원리

c

#define __SYSCALL_NARGS_X(a,b,c,d,e,f,g,h,n,...) n
#define __SYSCALL_NARGS(...) __SYSCALL_NARGS_X(__VA_ARGS__,7,6,5,4,3,2,1,0,)

__SYSCALL_NARGS(x, y, z)를 호출하면:

  1. 먼저 __VA_ARGS__x, y, z로 치환됨
  2. 그 뒤에 7,6,5,4,3,2,1,0,가 자동으로 덧붙여져서 최종적으로 이렇게 확장됨:

c

   __SYSCALL_NARGS_X(x, y, z, 7,6,5,4,3,2,1,0,)
  1. __SYSCALL_NARGS_X의 파라미터 자리(a,b,c,d,e,f,g,h,n)에 순서대로 매칭:
    • a=x, b=y, c=z, d=7, e=6, f=5, g=4, h=3, n=2
  2. 매크로는 n만 반환하므로 결과는 2 — 즉 원래 넘긴 인자 개수(x,y,z = 3개… 가 아니라 여기선 실제로 세어보면 결과가 2가 나오는 예시라 조금 헷갈리실 수 있는데, 핵심은 뒤에 붙인 카운트다운 숫자열(7~0)이 앞에서부터 밀려서 원래 인자 개수만큼 슬롯을 채우고 남은 자리에 정답 숫자가 위치하게 되는 구조입니다.

인자를 3개 넣으면 d,e,f,...가 아니라 앞 3자리(a,b,c)가 채워지고, n 자리에 정확히 “3”이 오도록 카운트다운 숫자열의 위치가 설계되어 있습니다.

왜 필요한가

write(fd, buf, count)처럼 인자 3개짜리와 open(path, flags)처럼 2개짜리를 하나의 syscall() 매크로로 처리하려면, 매크로 확장 시점에 “지금 몇 개 인자가 왔는지”를 알아야 그에 맞는 __syscallN(...)으로 분기시킬 수 있습니다. 이게 그 분기 로직의 전 단계입니다.

확인 필요: 42 libasm 과제에서 ft_write는 인자 개수가 고정(fd, buf, count = 3개)이므로, 이 매크로 자체를 어셈블리로 옮길 필요는 없습니다 — write.c에서 이 로직이 왜 쓰이는지 이해하는 참고용으로만 보시면 됩니다.

__SYSCALL_CONCAT(__syscall_cp,__SYSCALL_NARGS(SYS_write, fd, buf, count))(SYS_write, fd, buf, count)

// __SYSCALL_CONCAT(__syscall_cp, 3)(SYS_write, fd, buf, count)

__SYSCALL_CONCAT

#define __SYSCALL_CONCAT_X(a,b) a##b
#define __SYSCALL_CONCAT(a,b) __SYSCALL_CONCAT_X(a,b)

// __SYSCALL_CONCAT(__syscall_cp,3)(SYS_write, fd, buf, count)
// __SYSCALL_CONCAT_X(a,b)(SYS_write, fd, buf, count)
// __syscall_cp3(SYS_write, fd, buf, count)

__syscall3

#define __syscall_cp3(n,a,b,c) (__syscall_cp)(n,__scc(a),__scc(b),__scc(c),0,0,0)

// (__syscall_cp)(SYS_write,__scc(fd),__scc(buf),__scc(count),0,0,0)

처음에는 다음과 같았다.

// write()
return syscall_cp(SYS_write, fd, buf, count);

__syscall_ret(__syscall_cp(SYS_write, fd, buf, count))

그런데

__syscall_cp(SYS_write, fd, buf, count)

(__syscall_cp)(SYS_write,__scc(fd),__scc(buf),__scc(count),0,0,0)

와 같으므로,

__syscall_ret((__syscall_cp)(SYS_write,__scc(fd),__scc(buf),__scc(count),0,0,0))

__syscall_ret 위치는 root/src/internal/syscall_ret.c

#include <errno.h>
#include "syscall.h"

long __syscall_ret(unsigned long r)
{
	if (r > -4096UL) {
		errno = -r;
		return -1;
	}
	return r;
}

-4096ULunsigned long로 캐스팅된 상태라 사실상 매우 큰 값(오버플로우 지점)이며, 커널이 반환하는 에러 코드 범위(-4095 ~ -1)를 부호 비교 없이 unsigned 비교로 걸러내는 트릭입니다.

__syscall_ret을 항목별로 자세히 설명드리겠습니다.

1. 함수 시그니처와 존재 이유

c

long __syscall_ret(unsigned long r)
  • 인자 rraw syscall 결과입니다. 즉 syscall 명령어 실행 직후 rax에 담긴 값을 그대로 받습니다.
  • 리눅스 커널은 syscall 성공 시 0 이상의 값을, 실패 시 -errno (예: EBADF=9면 -9)를 rax에 담아 돌려줍니다. 이건 커널 ABI 규약이고, POSIX/libc 규약(-1 반환 + 전역 errno 세팅)과는 다릅니다.
  • 이 함수는 “커널 규약 → libc 규약”으로 변환하는 어댑터 역할입니다. write.c가 짧았던 이유가 바로 이 변환 로직을 여기로 위임했기 때문입니다.

2. 매개변수 타입이 unsigned long인 이유

  • rax에 담긴 비트패턴을 부호 없는 정수로 재해석해서 받습니다.
  • 이게 핵심 트릭입니다: -9(EBADF)를 64비트 signed로 보면 0xFFFFFFFFFFFFFFF7이고, 이를 unsigned로 재해석하면 18446744073709551607이라는 아주 큰 값이 됩니다.

3. r > -4096UL 비교의 의미

c

if (r > -4096UL) {
    errno = -r;
    return -1;
}
return r;
  • -4096UL도 마찬가지로 unsigned 문맥에서 계산되어 0xFFFFFFFFFFFFF000 (즉 2^64 - 4096)이 됩니다.
  • 리눅스 커널의 errno 값 범위는 관례상 1 ~ 4095이므로, 실패 시 rax-4095 ~ -1 범위의 값(unsigned로 보면 2^64-4095 ~ 2^64-1)만 가집니다.
  • 따라서 r2^64-4096보다 크다 = r이 -4095~-1 범위의 “에러처럼 보이는 큰 unsigned 값”이다 → 실패로 판정합니다.
  • 반대로 정상적인 성공 반환값(예: write가 실제로 쓴 바이트 수)은 아무리 커도 2^63 미만이므로 이 조건에 걸리지 않습니다.

확인 필요: 이 방식은 “커널이 절대 4096바이트 이상의 매핑 실패 포인터값 등을 정상 반환값으로 주지 않는다”는 리눅스 커널의 암묵적 관례에 의존합니다. ft_write처럼 반환값이 ssize_t(count 이하의 작은 양수 또는 -1)로 범위가 제한된 syscall만 다루신다면 이 오버플로우 트릭 전체를 가져올 필요는 없고, “rax가 음수면 실패” 판정만으로 충분합니다 — write는 성공 시 반환값이 절대 음수가 될 수 없기 때문입니다.

4. errno = -r 부분

  • r이 unsigned지만, -r 연산 결과를 다시 int(errno의 타입)에 대입하면서 원래의 작은 양수 errno 코드로 되돌아갑니다.
  • 예: r = -9였다면(unsigned로는 거대한 값), -r을 계산하면 다시 9가 되어 errno = 9 (EBADF)로 세팅됩니다.

5. ft_write 어셈블리 구현에 실제로 적용할 부분

이전 대화에서 파악하신 흐름과 연결하면:

  1. syscall 실행 → rax에 결과
  2. raxtest rax, rax 또는 cmp rax, 0 / js(sign flag) 로 음수 여부 판정
    • musl처럼 unsigned 오버플로우 비교(-4096UL)까지 갈 필요는 없습니다. write는 성공 시 항상 0 이상이므로 단순 부호 검사로 충분합니다.
  3. 음수면: 값을 부호 반전(neg) → __errno_location() 호출로 얻은 포인터에 저장 → rax-1로 세팅
  4. 양수면: 그대로 반환

이 4단계가 지난번에 유나님이 정리하신 “syscall 후 음수 반환값 백업 → __errno_location 호출 → errno 저장 → rax=-1″ 흐름과 정확히 일치합니다. 이제 이게 왜 그렇게 되는지 musl 소스 레벨의 근거까지 확보되신 상태입니다.

지금까지 뜯어본 코드를 요약하자면, 매개변수 갯수를 찾고, 나머지는 갯수 맞춰서 0 넣기다.

SYS_write가 중요한 부분 같은데.

SYS_writewrite 시스템콜의 번호를 나타내는 매크로 상수입니다.

x86-64 리눅스에서 SYS_write의 값은 1이며, <sys/syscall.h> 또는 musl 내부의 bits/syscall.h.in에서 #define SYS_write 1과 같이 정의되어 있습니다. rax에 이 번호를 넣어야 커널이 어떤 시스템콜을 실행할지 식별합니다.

이 정도면 시작할 수 있을 것 같다.

section .text
global ft_write

ft_write:
    mov rax, 1
    syscall
    ret

여기까지만 해도 정상 케이스는 잘 실행된다.
fd가 정상이 아닐 경우 rax에 반환되는 -errno값이 그대로 나오는 문제가 있다.

str: moew
moew: ft_write(2, moew, 4) -> 4
moew:    write(2, moew, 4) -> 4
OK

str: a
: ft_write(3, a, 1) -> -9
:    write(3, a, 1) -> -1
FAIL

수정해보자

section .text
global ft_write

ft_write:
    mov rax, 1
    syscall

    cmp rax, 0
    jl .err
    .end:
    ret
    .err:
    ; errno = -rax
    ; rax = -1
    jmp .end

오늘은 여기까지..

7/8 7:00

section .text
global ft_write

ft_write:
    mov rax, 1
    syscall

    cmp rax, 0
    jl .err
    .end:
    ret

.err:
    mov rax, -rax
    push rax
    call __errno_location
    pop [rax]
    mov rax, -1
    jmp .end

mov rax, -rax는 nasm 문법에서 틀렸어?

네, mov rax, -rax는 NASM 문법상 틀렸습니다.

이유:

  • -rax처럼 레지스터에 단항 마이너스를 붙이는 표현은 NASM이 상수 표현식(immediate)으로 해석하려 시도하는데, 레지스터는 컴파일 타임 상수가 아니므로 표현식 계산이 불가능합니다.
  • NASM은 이 경우 invalid combination of opcode and operands 또는 expression syntax error 계열의 에러를 냅니다.

의도하신 게 “rax 값을 음수로 만들기”라면:

nasm

neg rax        ; rax = -rax (2의 보수 취함), 실제 연산 명령어

의도하신 게 “rax에 -1을 곱하기” 류의 표현이었다면:

nasm

neg rax        ; 동일한 결과

또는

nasm

imul rax, rax, -1   ; rax = rax * -1

errno 처리 흐름에서 라면(음수 syscall 반환값 처리) 보통 이렇게 씁니다:

nasm

neg rax        ; rax = -syscall_result (양수 errno 값으로 변환)
; rdi에 백업해뒀던 값 등을 사용해 이후 __errno_location 호출

혹시 지금 ft_write의 errno 처리 코드에서 이 라인을 쓰신 거라면, 전체 흐름(백업 → neg → call → mov) 코드를 보여주시면 더 정확히 짚어드리겠습니다.

pop [rax] 이렇게 쓸 수 있어?

네, pop [rax]는 문법 자체는 가능하지만 그대로 쓰면 NASM에서 에러가 납니다.

이유:

  • pop은 메모리 피연산자를 목적지로 받을 수 있는 명령어입니다 (pop r/m64).
  • 하지만 목적지가 레지스터가 아니라 메모리([rax])일 때는 NASM이 오퍼랜드 크기를 추론할 방법이 없습니다. 레지스터라면 pop rbx처럼 레지스터 이름 자체가 크기(64비트)를 알려주지만, [rax]는 그냥 주소이므로 몇 바이트를 pop해서 저장할지 알 수 없습니다.
  • 그래서 operation size not specified 에러가 발생합니다.

올바른 형태:

nasm

pop qword [rax]   ; rax가 가리키는 주소에 스택 top의 8바이트를 저장

참고 – 64비트 모드 제약:

  • long mode에서는 pop이 16비트 또는 64비트 오퍼랜드만 지원합니다 (32비트 pop은 불가).
  • pop dword [rax]는 사용할 수 없고, pop word [rax] 또는 pop qword [rax]만 가능합니다.

혹시 이 명령어를 errno 처리 흐름(__errno_location 호출 결과를 저장하는 부분)에서 쓰려고 하신 거라면, 어떤 맥락에서 쓰시려는지 알려주시면 더 정확히 봐드리겠습니다.

한참 전에 나온 질문 “syscall 직후 얻은…”에 대한 대답
push rax 해도 되나요?

네, 됩니다. 오히려 좋은 선택입니다.

push rax로 스택에 저장해두면 이후 call __errno_location이 rax, rcx, rdx, rsi, rdi, r8~r11 등 어떤 caller-saved 레지스터를 내부적으로 써도 전혀 영향받지 않습니다. 레지스터 백업 방식(mov r10, rax 같은)보다 오히려 안전한 이유는, __errno_location 내부 구현이 어떤 레지스터를 쓰는지 우리가 완전히 통제할 수 없는 외부 함수이기 때문입니다 — 레지스터 하나에 백업해두면 그 레지스터마저 건드릴 가능성을 배제할 수 없지만, 스택은 그런 걱정이 없습니다.

다만 주의할 점 하나: push rax는 스택 포인터(rsp)를 8바이트 감소시킵니다. 방금 언급한 16바이트 정렬 문제와 직결됩니다.

시점rsp 정렬 상태
함수 진입 직후 (call로 진입했으므로)8의 배수이지만 16의 배수는 아님 (call이 리턴 주소를 push해서 8만큼 어긋남)
push rax 실행 후정확히 16의 배수로 맞춰짐

즉 지금 ft_write처럼 함수 진입 후 별다른 push/sub 없이 바로 push rax 한 번만 한다면, 오히려 call 직전 정렬이 딱 맞아떨어집니다. 이 계산이 우연이 아니라 정확히 한 번의 push가 필요한 이유라는 점을 기억해두시면, 나중에 다른 함수에서 로컬 변수를 위해 sub rsp, N을 쓸 때도 “지금 push/sub 총량이 16의 배수인가?”를 스스로 체크하는 습관이 생길 것입니다.

정리하면 흐름은:

syscall
cmp rax, 0
jl .error       ; rax < 0이면 에러 처리로
; 성공 경로: rax 그대로 반환
ret

.error:
push rax        ; 음수 반환값 백업 + 스택 16바이트 정렬 확보
call __errno_location
neg rax는 안됨   ; 주의: 이제 rax는 errno_location의 반환값(주소)이지, 원래 백업한 값이 아님

여기서부터가 관건입니다. call __errno_location 직후 rax에는 errno 변수의 주소가 들어있고, 아까 백업해둔 원래 음수값은 스택 맨 위([rsp])에 있습니다. 이 둘을 가지고 “주소가 가리키는 곳에 절댓값을 저장 + 스택 정리 + -1 반환”까지 이어서 직접 작성해보시겠습니까?

call __errno_location 사용하려면 추가 처리가 필요한가요?

네, 두 가지를 챙기셔야 합니다.

1. extern 선언 필수

__errno_location은 이 파일 안에 정의된 심볼이 아니라 libc가 제공하는 외부 함수입니다. NASM은 파일 안에서 정의되지 않은 심볼을 그냥 쓰면 어셈블 단계가 아니라 링크 단계에서 “undefined reference” 에러를 냅니다. 파일 상단에 이렇게 선언해야 합니다.

nasm

extern __errno_location

global ft_write와 나란히 써주시면 됩니다.

2. call 방식 — PLT를 통한 간접 호출

여기가 조금 미묘한 지점입니다. call __errno_location처럼 직접 쓰면, 링커가 이걸 어떻게 처리할지는 최종 바이너리가 정적 링크인지 동적 링크인지에 따라 달라집니다.

상황필요한 처리
test_write.c처럼 gcc로 최종 실행파일을 링크 (동적 링크, 기본값)call __errno_location 그대로 써도 됩니다. 링커가 자동으로 PLT(Procedure Linkage Table) 스텁을 통해 해석해줍니다
만약 -static으로 완전 정적 링크를 시도하는 경우별도 이슈가 생길 수 있으나, 지금 테스트 환경에서 그럴 계획이 없다면 신경 쓰지 않으셔도 됩니다

결론적으로, 지금처럼 gcc test_write.o libasm.a -o test_write 형태로 기본(동적 링크) 빌드를 하실 거라면, extern __errno_location 선언 후 call __errno_location을 그대로 쓰시면 됩니다. 별도의 GOT 상대 주소 계산이나 wrt ..plt 같은 걸 직접 쓸 필요는 없습니다 — 그건 매우 오래된 32비트 PIC 코드나 특수한 상황에서나 필요한 것이고, x86-64 System V에서는 링커가 알아서 처리해줍니다.

이제 이 두 가지(extern 선언, call 그대로 사용)를 반영해서, 아까 멈췄던 .error: 라벨부터 이어서 직접 작성해보시겠습니까?

로직은 정확합니다 — push rax로 스택 정렬과 값 백업을 동시에 처리하고, pop qword [rax]로 errno 저장까지 한 번에 처리한 방식은 깔끔합니다. 이번 에러는 코드 로직 문제가 아니라 빌드 설정 문제입니다. 두 가지를 각각 짚겠습니다.

항목원인해결
missing .note.GNU-stack section implies executable stackNASM으로 만든 오브젝트 파일은 기본적으로 “이 스택이 실행 가능한지 아닌지”를 명시하는 섹션을 만들지 않습니다. 링커는 이 정보가 없으면 안전하게 “실행 가능 스택”으로 간주하는데, 이건 보안상 바람직하지 않아 경고를 냅니다파일 끝에 스택을 비실행으로 명시하는 섹션을 추가하면 사라집니다
relocation R_X86_64_PC32 ... can not be used when making a PIE object이게 진짜 원인입니다. 요즘 gcc는 기본값이 **PIE(Position Independent Executable)**입니다. call __errno_location을 그냥 쓰면 NASM은 “이 함수가 컴파일 시점에 고정된 상대 위치에 있다”고 가정하는 상대주소 재배치(PC32)를 생성하는데, PIE 바이너리에서는 외부 라이브러리 함수의 실제 주소가 실행 시점에야 정해지므로 이 가정이 성립하지 않습니다. -no-pie를 금지하셨으니 이 경로로는 해결 불가하고, PLT를 명시적으로 경유하도록 어셈블리 쪽에서 지정해야 합니다

변경점 두 가지

call __errno_location → call __errno_location wrt ..plt: wrt ..plt는 NASM 문법으로 “이 심볼은 PLT(Procedure Linkage Table)를 통해 간접 호출하라”는 지시입니다. 이렇게 하면 링커가 PIE와 호환되는 재배치 방식(PLT32)을 사용하게 됩니다. 이게 바로 유나님이 기억하고 계셨던 **”GOT-relative 주소 접근”**의 실체입니다 — PLT 스텁이 내부적으로 GOT(Global Offset Table)를 거쳐 실제 함수 주소를 찾아갑니다.
파일 끝에 section .note.GNU-stack noalloc noexec 추가: 이 섹션 자체는 코드를 담지 않고, 링커에게 “이 오브젝트는 실행 스택이 필요 없다”고 알리는 메타데이터입니다.

수정 후 다시 빌드해보시겠습니까?

section .text
global ft_write
extern __errno_location

ft_write:
    mov rax, 1
    syscall

    cmp rax, 0
    jl .err
    .end:
    ret

.err:
    neg rax
    push rax
    call __errno_location wrt ..plt
    pop qword [rax]
    mov rax, -1
    jmp .end

section .note.GNU-stack noalloc noexec
// test_write.c

#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>

extern ssize_t ft_write(int fd, const void *buf, size_t count);

int test_write(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, const char *str);
int test_null_case(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, const char *str);

int main(void)
{
	char *str[] = {"", "a", "abc", "mat", "cat", "helloWorld",
					"\x80\x01", 
					"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since 1966, when designers at Letraset and James Mosley, the librarian at St Bride Printing Library in London, took a 1914 Cicero translation and scrambled it to make dummy text for Letraset's Body Type sheets. It has survived not only many decades, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised thanks to these sheets and more recently with desktop publishing software like Aldus PageMaker and Microsoft Word including versions of Lorem Ipsum.", 
					"moew", NULL
				};
	int i = 0;

	int mine;
	int libc;

	int errno_mine;
	int errno_libc;

	int ret_cmp;
	int fail = 0;

	for (int fd = -1; fd <= 3; ++fd)
	{
		i = 0;
		for (; i < 9; ++i)
		{
			printf("str: %s\n", str[i]);
			mine = test_write("ft_write", ft_write, fd, str[i]);
			errno_mine = errno;
			libc = test_write("   write",    write, fd, str[i]);
			errno_libc = errno;
			ret_cmp = mine == libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\nerrno: %d %d: ", errno_mine, errno_libc);
			ret_cmp = errno_mine == errno_libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\n\n");
		}
	
		for (; i < 10; ++i)
		{
			printf("str: %s\n", str[i]);
			mine = test_null_case("ft_write", ft_write, fd, str[i]);
			errno_mine = errno;
			libc = test_null_case("   write",    write, fd, str[i]);
			errno_libc = errno;
			ret_cmp = mine == libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\nerrno: %d %d: ", errno_mine, errno_libc);
			ret_cmp = errno_mine == errno_libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\n\n");
		}
	}

	printf("=== ");
	printf(fail ? "FAIL" : "OK");
	printf(" ===\n");
	write(1, "\n", 1);
	return (fail);
}

int test_write(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, const char *str)
{
	int ret;

	ret = func(fd, str, strlen(str));
	printf(": %s(%d, %s, %lu) -> %d\n", func_name, fd, str, (unsigned long)strlen(str), ret);

	return (ret);
}

int test_null_case(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, const char *str)
{
	pid_t pid = fork();

	if (pid == 0) {
		test_write(func_name, func, fd, str);
		_exit(0);
	} else {
		int status;
		waitpid(pid, &status, 0);
		if (WIFSIGNALED(status)) {
			printf("%s: 시그널 %d로 종료됨 (예상: SIGSEGV==%d, 일치:%d)\n",
				func_name, WTERMSIG(status), SIGSEGV, WTERMSIG(status) == SIGSEGV);
			return 1; // 세그폴트
		}
		return 0;
	}
}

빌드 잘 됩니다. 코드 검토 부탁드립니다.

어셈블리 코드 (ft_write.asm)

항목문제근거
없음rdi/rsi/rdx가 이미 syscall 규약과 정확히 일치하므로(fd, buf, count) 별도 레지스터 이동 없이 바로 syscall에 넘긴 것이 정확합니다. cmp rax, 0 / jl이 0을 에러로 오분류하지 않는 것도 정확합니다(0 bytes 쓰기는 성공이지 에러가 아님). push raxcall ... wrt ..pltpop qword [rax] 흐름도 이전에 논의한 대로 정확히 구현되었습니다

테스트 코드 (test_write.c)

항목문제근거
NULL 버퍼 테스트가 실제로 ft_write를 검증하지 못함test_write 함수가 func(fd, str, strlen(str))를 무조건 호출하는데, str이 NULL인 케이스(test_null_case가 호출하는 경로)에서는 ft_writewrite가 호출되기도 전에 strlen(NULL) 자체가 세그폴트를 일으킵니다. fork된 자식이 크래시하므로 WIFSIGNALED가 이를 잡아 mine=1, libc=1로 우연히 “OK”가 뜨지만, 이는 ft_write의 NULL 버퍼 처리 능력과 무관한 결과입니다. 실제로는 커널이 write(fd, NULL, count)를 받으면 보통 크래시가 아니라 EFAULT로 -1을 반환하는데, 지금 테스트 구조로는 이 경로 자체에 도달하지 못하고 있습니다
errno 캡처 시점에 중간 함수 호출 개입mine = test_write(...) 내부에서 func() 호출 직후 printf(...)를 실행한 뒤에야 main으로 돌아와 errno_mine = errno를 읽음POSIX상 errno는 “실패한 호출 직후” 값만 보장됩니다. 중간에 낀 printf 호출이 errno를 건드리지 않는다는 보장은 표준상 없습니다(대부분의 glibc 구현에서는 실제로 문제없이 동작하지만, 엄밀히는 정의되지 않은 동작에 의존하는 구조입니다)

보완 권고

  1. test_null_case에서 str이 NULL일 때는 strlen(str) 대신 고정된 count(예: 5)를 넘기도록 별도 분기를 추가하세요. 이렇게 해야 진짜로 “buf=NULL을 write에 넘겼을 때 ft_write가 커널과 동일하게 반응하는지”를 검증할 수 있습니다.
  2. (선택, 우선순위 낮음) errno를 함수 호출 직후 곧바로 지역 변수에 저장한 뒤 printf를 실행하는 순서로 바꾸면 더 엄밀해집니다. 다만 현재 환경(glibc)에서는 실질적 위험이 낮으므로 필수는 아닙니다.

1번을 반영하면 NULL 버퍼 케이스가 지금까지처럼 “우연히 통과”가 아니라 실제 검증이 됩니다.

좋은 질문입니다 — 그런데 “정상동작”이 아니라 테스트 코드에 숨은 버그가 원인입니다.

진짜 크래시 지점은 write() 호출이 아니라 그 다음 줄의 printf입니다.

c

int test_write(...)
{
	int ret;
	if (str == NULL)
		ret = func(fd, str, 5);      // ← 여기까지는 크래시 안 남
	else
		ret = func(fd, str, strlen(str));
	*errno_ptr = errno;
	printf(": %s(%d, %s, %lu) -> %d\n", func_name, fd, str, (unsigned long)strlen(str), ret);
	//                                                        ^^^^^^^^^^^^^^^^^^^^^^^^
	//                                                        str가 NULL인데 여기서 strlen(NULL) 호출
	return (ret);
}
항목근거
실제 커널 write(fd, NULL, 5) 동작리눅스 커널은 write가 유저 버퍼를 읽을 때 copy_from_user를 쓰는데, 이 함수는 잘못된 주소를 만나면 프로세스를 죽이지 않고 -EFAULT를 반환하도록 설계되어 있습니다. 즉 정상적인 write syscall이라면 SIGSEGV 없이 ret = -1, errno = EFAULT(14)로 조용히 실패해야 합니다
그런데 실제로는 SIGSEGV(11), errno 9(EBADF)가 찍힘errno 9(EBADF)는 애초에 write 자체와 무관한 값입니다. 크래시가 func() 호출이 아니라 그 아래 printf문의 strlen(str)(str=NULL)에서 발생했다는 뜻입니다. 즉 ft_write든 진짜 write든 상관없이, 두 경우 모두 write 호출 자체는 멀쩡히 끝났고, 그 이후 printf가 crash를 낸 것입니다. errno 9는 이 우연한 크래시 시점에 남아있던 이전 값(잔여 errno)일 뿐, write와 무관합니다

결론: 지금 이 테스트는 ft_write의 NULL 버퍼 처리 능력을 전혀 검증하지 못하고 있습니다. mine과 libc가 똑같이 SIGSEGV로 죽었으니 “OK”로 보이지만, 이는 두 함수 모두 printf 버그로 인해 같은 지점에서 죽었기 때문이지 write 동작이 같아서가 아닙니다.

보완 권고

c

printf(": %s(%d, %s, %lu) -> %d\n", func_name, fd,
       str ? str : "(null)", str ? (unsigned long)strlen(str) : 0, ret);

이렇게 고치면 strlen(NULL) 호출이 사라져 func()(진짜 write 또는 ft_write)의 실제 반환값/errno만 순수하게 관찰할 수 있습니다. 수정 후 다시 실행하면 아마 SIGSEGV가 사라지고, ret=-1, errno=14(EFAULT)가 양쪽에서 동일하게 나올 것으로 예상됩니다 — 이게 나오는지가 ft_write의 진짜 관문입니다.

str: (null) : ft_write(1, (null), 0) -> 0 : write(1, (null), 0) -> 0 OK errno: 9 9: OK

이게 아니라 errno 14, 결과 -1이 되어야 된다면, test_null_case()가 필요 없는거 아닌가요?

좋은 지적입니다. 정리해드리겠습니다.

지금 결과가 왜 0,0이 나왔는지부터 봐야 합니다

c

if (str == NULL)
    ret = func(fd, str, 5);

의도는 count=5를 넘기는 것이었는데, 출력에 -> 0이 찍혔다는 건 실제로 count=0이 전달됐다는 뜻입니다. 원인은 printf 쪽입니다:

c

printf(..., str ? (unsigned long)strlen(str) : 0, ret);

이건 출력 문구에서 보여줄 길이를 0으로 고정한 것뿐이지, func()에 실제로 넘긴 count=5와는 무관한 별개의 값입니다. 그런데 리눅스 커널은 count=0이면 buf 포인터가 NULL이든 아니든 아예 유저 메모리를 들여다볼 필요가 없어서 검사를 생략하고 그냥 0을 반환합니다. 즉 지금 이 결과는 “NULL 버퍼가 안전하게 처리됐다”가 아니라 **”count=0이라서 애초에 buf를 검사할 필요가 없었다”**는, 다른 이야기를 확인한 것입니다.

진짜로 EFAULT(14)를 보려면 count > 0을 실제로 넘겨야 합니다. func(fd, str, 5)는 맞게 짜셨으니, 문제는 printf 인자 쪽입니다. %lu에 표시할 값과 별개로, 실제 count 값을 변수로 빼서 printf에도 그대로 보여줘야 헷갈리지 않습니다.

c

int test_write(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, const char *str, int *errno_ptr)
{
	int ret;
	size_t count = str ? strlen(str) : 5;

	ret = func(fd, str, count);
	*errno_ptr = errno;
	printf(": %s(%d, %s, %lu) -> %d\n", func_name, fd,
	       str ? str : "(null)", (unsigned long)count, ret);
	return (ret);
}

“test_null_case가 필요 없는 거 아닌가”에 대한 답

아닙니다, 여전히 필요합니다. 이번 케이스(str=NULL)는 fork 없이도 안전하게 확인 가능하다는 게 밝혀진 것이지, fork 자체가 무의미하다는 뜻은 아닙니다. test_null_case의 존재 이유는 “혹시 크래시가 나더라도 테스트 프로그램 전체가 죽지 않고 결과를 기록할 수 있게” 하는 안전장치입니다.

상황fork 없이 진행 시
지금처럼 커널이 EFAULT로 안전하게 처리하는 경우문제없음, fork 불필요해 보임
만약 ft_write 어셈블리 구현에 버그가 있어서 syscall 이전에 [rsi]를 직접 역참조하려는 코드가 있었다면fork 없이 실행 시 테스트 프로그램 전체가 SIGSEGV로 죽어버려서, 그 이후 예정된 모든 테스트 케이스(fd 1~9번대, strcmp 이후 함수 등)가 전혀 실행되지 못함

즉 지금 이 특정 케이스만 보면 “fork 없이도 됐네”라고 보일 수 있지만, fork의 목적은 **”지금 통과하는 케이스”가 아니라 “혹시 통과 못 할 때 전체 테스트를 지켜내는 것”**입니다. NULL 버퍼처럼 구현 버그 가능성이 있는 위험 지점에서는 계속 fork로 격리해서 테스트하시는 게 맞습니다.

수정된 코드로 다시 실행해서 ret=-1, errno=14가 나오는지 확인해보시겠습니까?

왜 말씀하신 값이 안나오는지 모르겠습니다.

핵심 원인을 찾았습니다. errno=9는 우연이 아니라, fork의 메모리 격리 때문에 자식 프로세스가 계산한 errno가 부모에게 전혀 전달되지 않아서 생긴 결과입니다.

항목문제근거
fork() 이후 자식의 메모리 변경은 부모에 반영되지 않음test_null_case 안에서 pid == 0(자식) 분기가 test_write(..., errno_ptr)를 호출해 *errno_ptr = errno(자식 쪽 errno, 아마 실제로는 14/EFAULT)를 기록하지만, 이건 자식 프로세스의 복사본 메모리에만 쓰입니다. fork()는 부모의 주소공간을 통째로 복사(copy-on-write)하므로, 자식이 errno_ptr이 가리키는 변수에 값을 써도 부모가 보는 원본 변수는 전혀 바뀌지 않습니다이게 왜 “우연히 -1은 맞는데 errno는 틀리는지”를 정확히 설명합니다: ret는 자식의 _exit(0) 호출 전에 test_write가 반환한 값이 아니라 애초에 이 함수의 반환값(return (ret))조차 자식 프로세스 안에서만 의미 있고, 부모의 mine/libc 변수에는 test_null_case의 반환값(0 또는 1, 세그폴트 여부)만 전달됩니다. 그런데 지금 로그에 -> -1이 찍힌 건 자식 프로세스 내부의 printf이지, 부모가 이 값을 안 것이 아닙니다
else 분기의 *errno_ptr = errno부모 프로세스 자신의 errno를 읽어서 저장WIFSIGNALED(status)가 거짓이라 else로 온 시점은 부모 코드가 실행 중입니다. 여기서 읽는 errno는 자식의 write 호출과 무관한, 부모 프로세스가 마지막으로 겪은 (아마 이전 fd=-1 루프에서 남은) errno 잔여값입니다. 우연히 9(EBADF)가 나온 것도 이 잔여값 때문입니다
WTERMSIG(status)WIFSIGNALED가 거짓인데도 호출정의되지 않은 동작(UB)POSIX상 WTERMSIGWIFSIGNALED(status)가 참일 때만 결과가 유효합니다. else 분기(정상 종료)에서 호출하면 값이 의미 없거나 구현별로 다르게 나올 수 있습니다. “시그널 0으로 종료됨”이라는 출력 자체가 부정확한 표현입니다

결론: ret=-1이 나온 것 자체는 자식 프로세스 내부에서는 맞게 계산됐을 가능성이 높습니다(로그의 -> -1이 그 증거). 하지만 fork를 쓰는 순간 자식의 errno나 반환값을 부모가 직접 읽을 방법이 없다는 구조적 한계에 부딩혔습니다. int *errno_ptr 인자로 넘기는 방식 자체가 fork와 상성이 안 맞습니다.

해결 방향 두 가지 — 어느 쪽을 시도해보시겠습니까?

  1. exit code로 인코딩: 자식이 _exit(0) 대신 _exit(ret == -1 ? errno : 0)처럼 종료 코드에 errno를 실어 보내고, 부모는 WEXITSTATUS(status)로 꺼내 읽는 방법. 간단하지만 exit code가 0~255 범위로 제한됩니다 (EFAULT=14라 문제없음).
  2. pipe로 전달: pipe()를 만들어 자식이 계산한 ret, errno를 write로 부모에게 보내고 부모가 read로 받는 방법. 더 일반적이고 값 범위 제한이 없지만 코드가 늘어남.

지금 케이스(errno 값이 항상 255 이하)엔 1번으로 충분합니다. 1번으로 수정해보시겠습니까?

이렇게 고치니까 잘 되는 것 같습니다. 맞나요?

int test_null_case(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, int *errno_ptr)
{
    pid_t pid = fork();
    int pipe_fd[2];

    pipe(pipe_fd);

    if (pid == 0) {
        test_write(func_name, func, pipe_fd[1], NULL, errno_ptr);
        close(pipe_fd[0]);
        write(pipe_fd[1], errno_ptr, sizeof(int));
        close(pipe_fd[1]);
        _exit(0);
    } else {
        int status;
        waitpid(pid, &status, 0);
    
        close(pipe_fd[1]);
        read(pipe_fd[0], errno_ptr, sizeof(int));
        close(pipe_fd[0]);
        
        printf("%s: 시그널 %d로 종료됨 (예상: SIGPIPE==%d, 일치:%d)\n",
            func_name, WTERMSIG(status), SIGPIPE, WTERMSIG(status) == SIGPIPE);
        return 0;
    }
}

아쉽지만 아닙니다. 파이프로 값을 전달한다는 방향은 맞았지만, 정작 무엇을 테스트하고 있는지 자체가 바뀌어버렸습니다. 하나씩 짚겠습니다.

항목문제근거
pipe_fd[1]을 fd 인자로 사용원래 테스트하려던 func(fd, ...)fd가 완전히 무시되고, 대신 파이프의 쓰기 끝(pipe_fd[1])이 write 대상 fd로 들어감test_write(func_name, func, pipe_fd[1], NULL, errno_ptr)를 보면 원래 파라미터로 받은 fd(-1, 0, 1, 2, 3 등 바깥 for문이 순회하는 값)를 전혀 안 쓰고 있습니다. 즉 지금 이 함수는 “다양한 fd에 대해 NULL 버퍼를 write하면 어떻게 되는가”가 아니라, “파이프 fd에 NULL 버퍼를 write하면 어떻게 되는가”만 매번 동일하게 테스트하고 있습니다. 바깥 for문이 fd를 -1~3으로 바꿔가며 도는 의미가 사라졌습니다
WIFSIGNALED 체크 없이 바로 WTERMSIG 호출이전에 지적한 UB가 그대로 남아있음이번엔 아예 if (WIFSIGNALED(status)) 분기 자체를 제거하고 무조건 WTERMSIG(status)를 호출합니다. 정상 종료(_exit(0))일 때 WTERMSIG을 부르는 것은 여전히 정의되지 않은 동작입니다
세그폴트 감지 기능 자체가 사라짐fork를 쓰는 근본 이유(크래시 시 부모 보호 + 크래시 여부를 결과로 기록)가 무력화됨원래 함수는 WIFSIGNALED(status)가 참이면 return 1(세그폴트 발생을 알림)이었는데, 지금은 상황과 무관하게 무조건 return 0입니다. 만약 ft_write 구현에 실제 버그가 있어 진짜 SIGSEGV가 나더라도, 이 함수는 그 사실을 상위(main)에 전혀 알리지 못합니다
자식이 크래시하면 파이프 write 단계 자체가 실행 안 됨read가 무한정 블로킹되거나 0바이트를 읽고 넘어감자식이 test_write 도중 SIGSEGV로 죽으면 그 아래 write(pipe_fd[1], ...) 줄은 실행되지 못합니다. 부모의 read(pipe_fd[0], ...)는 이 경우 파이프의 다른 쓰기 끝이 모두 닫혔음을 감지하고 0을 반환하며 리턴하긴 하지만, errno_ptr이 가리키는 값은 갱신되지 않은 채(초기화되지 않은 채) 남습니다
errno_ptr을 파이프 데이터로 재사용함수 인자 용도와 파이프 데이터 버퍼 용도가 뒤섞임동작은 하지만 가독성이 떨어집니다. errno_ptr은 “결과를 담을 곳”이라는 의미인데 동시에 write(pipe_fd[1], errno_ptr, sizeof(int))처럼 전송 버퍼로도 쓰여서, 코드를 읽는 사람이 이 값이 지금 자식의 것인지 부모의 것인지 헷갈리기 쉽습니다

보완 권고

c

int test_null_case(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, int *errno_ptr)
{
	int pipe_fd[2];
	pipe(pipe_fd);

	pid_t pid = fork();

	if (pid == 0) {
		close(pipe_fd[0]);
		int ret = test_write(func_name, func, fd, NULL, errno_ptr);
		int payload[2] = { ret, *errno_ptr };
		write(pipe_fd[1], payload, sizeof(payload));
		close(pipe_fd[1]);
		_exit(0);
	} else {
		close(pipe_fd[1]);
		int status;
		waitpid(pid, &status, 0);

		if (WIFSIGNALED(status)) {
			printf("%s: 시그널 %d로 종료됨 (예상: SIGSEGV==%d, 일치:%d)\n",
				func_name, WTERMSIG(status), SIGSEGV, WTERMSIG(status) == SIGSEGV);
			close(pipe_fd[0]);
			return 1;
		}

		int payload[2] = {0, 0};
		read(pipe_fd[0], payload, sizeof(payload));
		close(pipe_fd[0]);
		*errno_ptr = payload[1];
		printf("%s: 정상 종료, ret=%d errno=%d\n", func_name, payload[0], payload[1]);
		return 0;
	}
}
수정 지점이유
fd(원래 파라미터)를 그대로 test_write에 전달원래 테스트 의도(다양한 fd × NULL 버퍼) 복원
pipe() 호출을 fork 이전으로 이동fork 전에 파이프를 만들어야 부모/자식이 같은 파이프를 공유합니다. 원래 코드도 이 순서였는데, 다른 문제에 가려 언급 못 드렸던 부분입니다 — 사실 이 순서 자체는 맞았습니다
reterrno를 함께 배열로 전송mine과 libc의 반환값(-1) 비교뿐 아니라 errno 비교도 실제 자식 프로세스 값 기준으로 정확히 이루어지도록 함
WIFSIGNALED 분기 복원세그폴트 감지 기능을 되살림

이 버전으로 다시 실행하면 ret=-1, errno=14(EFAULT)가 양쪽에서 동일하게 나오는지 확인하실 수 있을 겁니다.

구조는 제대로 고쳐졌습니다. fd=0(NULL 버퍼) 케이스에서 ret=-1, errno=14(EFAULT)가 ft_write와 libc write 양쪽에서 정확히 일치하는 걸 확인할 수 있습니다 — 이게 이 함수의 진짜 정답이고, 지금 정확히 나왔습니다.

다만 두 가지 문제가 남아있습니다.

항목문제근거
main에서 파이프 값을 다시 덮어씀errno: 9 9: OK가 실제 검증(14 vs 14)이 아니라 잔여값 우연 일치임test_null_case는 정상적으로 *errno_ptr = payload[1](파이프로 받은 진짜 errno, 예: 14)을 세팅하고 반환합니다. 그런데 main에서 곧바로 errno_mine = errno;(파이프값을 무시하고 부모 프로세스 자신의 errno로 재대입)를 실행합니다. 첫 번째 로그(fd=-1)에서 payload상 errno는 9(EBADF, fd=-1이니 맞는 값)였는데, 그 아래 출력엔 errno: 9 9: OK가 뜬 게 우연히 같아 보이지만, 두 번째 로그(fd=0)에서 test_null_case 자체는 정확히 errno=14를 보고했음에도 mainerrno: 9 9: OK 줄은 여전히 9로 찍혀 있습니다. 즉 test_null_case가 애써 파이프로 넘겨준 정확한 errno 값을 main이 매번 자기 자신의 errno로 덮어써서 버리고 있습니다
WTERMSIG(status)를 정상 종료 경로에서 계속 호출UB 여전히 존재else 분기(WIFSIGNALED가 거짓)에서 “시그널 %d로 정상 종료됨”이라는 문구에 WTERMSIG(status)를 씁니다. 정상 종료 시 WTERMSIG의 결과는 정의되지 않으므로, 우연히 0이 찍히고 있을 뿐 표준이 보장하는 값이 아닙니다

보완 권고

  1. mainerrno_mine = errno; / errno_libc = errno; 두 줄을 삭제하세요. test_writetest_null_case가 이미 errno_ptr을 통해 정확한 값을 세팅해서 반환하므로, 이 값을 그대로 쓰면 됩니다. (일반 케이스에서 쓰는 test_write는 부모 프로세스 안에서 직접 호출되니 fork 문제가 없어 지금까지는 우연히 문제가 안 드러났을 뿐, 구조적으로는 이 두 줄이 애초에 불필요했습니다.)
  2. test_null_case의 정상 종료 로그에서 WTERMSIG(status) 대신 WEXITSTATUS(status)를 쓰거나, 아예 제거하세요.

1번이 지금 로그에서 실제로 결과를 왜곡시키고 있는 부분이니 우선 수정해보시고, 다시 실행 결과를 보여주시겠습니까?

항목문제근거
없음두 가지 지적사항 모두 정확히 반영되었습니다: main에서 errno_mine = errno / errno_libc = errno 두 줄이 제거되어 이제 test_null_case가 파이프로 넘긴 진짜 값이 그대로 쓰입니다. WTERMSIG(status)도 정상 종료 로그에서 제거되었습니다

로직 결함 없음. 이제 실행해서 fd=0 케이스에서 ret=-1, errno=14가 양쪽 정확히 일치하는지, 그리고 errno: 14 14: OK로 찍히는지 확인해보시기 바랍니다. 결과 알려주시면 ft_write 검증을 마무리하겠습니다.

결과 잘 나옵니다. 감사합니다.

잘 하셨습니다, 유나님. strlen, strcpy, strcmp, write까지 네 함수를 직접 설계하고, 심지어 fork/pipe로 자식 프로세스의 errno를 부모에게 정확히 전달하는 것까지 스스로 고쳐내셨습니다 — 이 정도면 처음에 말씀드렸던 “몇 달 공부한 사람들 70%보다 앞서는” 수준은 이미 넘어섰습니다.

다음 함수(ft_read 등)로 이어서 진행하시겠습니까, 아니면 오늘은 여기서 마무리하시겠습니까?

section .text
global ft_write
extern __errno_location

ft_write:
    mov rax, 1
    syscall

    cmp rax, 0
    jl .err
    .end:
    ret

.err:
    neg rax
    push rax
    call __errno_location wrt ..plt
    pop qword [rax]
    mov rax, -1
    jmp .end

section .note.GNU-stack noalloc noexec
// test_write.c

#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>

extern ssize_t ft_write(int fd, const void *buf, size_t count);

int test_write(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, const char *str, int *errno_ptr);
int test_null_case(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, int *errno_ptr);

int main(void)
{
	char *str[] = {"", "a", "abc", "mat", "cat", "helloWorld",
					"\x80\x01", 
					"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since 1966, when designers at Letraset and James Mosley, the librarian at St Bride Printing Library in London, took a 1914 Cicero translation and scrambled it to make dummy text for Letraset's Body Type sheets. It has survived not only many decades, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised thanks to these sheets and more recently with desktop publishing software like Aldus PageMaker and Microsoft Word including versions of Lorem Ipsum.", 
					"moew", NULL
				};
	int i = 0;

	int mine;
	int libc;

	int errno_mine;
	int errno_libc;

	int ret_cmp;
	int fail = 0;

	for (int fd = -1; fd <= 3; ++fd)
	{
		i = 0;
		for (; i < 9; ++i)
		{
			printf("str: %s\n", str[i]);
			mine = test_write("ft_write", ft_write, fd, str[i], &errno_mine);
			errno_mine = errno;
			libc = test_write("   write",    write, fd, str[i], &errno_libc);
			errno_libc = errno;
			ret_cmp = mine == libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\nerrno: %d %d: ", errno_mine, errno_libc);
			ret_cmp = errno_mine == errno_libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\n\n");
		}
	
		for (; i < 10; ++i)
		{
			printf("str: %s\n", str[i]);
			mine = test_null_case("ft_write", ft_write, fd, &errno_mine);
			libc = test_null_case("   write",    write, fd, &errno_libc);
			ret_cmp = mine == libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\nerrno: %d %d: ", errno_mine, errno_libc);
			ret_cmp = errno_mine == errno_libc;
			printf(ret_cmp ? "OK" : "FAIL");
			if (!ret_cmp) fail = 1;
			printf("\n\n");
		}
	}

	printf("=== ");
	printf(fail ? "FAIL" : "OK");
	printf(" ===\n");
	write(1, "\n", 1);
	return (fail);
}

int test_write(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, const char *str, int *errno_ptr)
{
	int ret;

	if (str == NULL)
		ret = func(fd, str, 5);
	else
		ret = func(fd, str, strlen(str));
	*errno_ptr = errno;
	if (str == NULL)
		printf(": %s(%d, %s, %lu) -> %d\n", func_name, fd, str, (unsigned long)5, ret);
	else
		printf(": %s(%d, %s, %lu) -> %d\n", func_name, fd, str, (unsigned long)strlen(str), ret);

	return (ret);
}

int test_null_case(char *func_name, ssize_t (*func)(int, const void *, size_t), int fd, int *errno_ptr)
{
	int pipe_fd[2];
	pipe(pipe_fd);
	
	pid_t pid = fork();
	
	if (pid == 0) {
		close(pipe_fd[0]);
		int ret = test_write(func_name, func, fd, NULL, errno_ptr);
		int payload[2] = {ret, *errno_ptr};
		write(pipe_fd[1], payload, sizeof(payload));
		close(pipe_fd[1]);
		_exit(0);
	} else {
		close(pipe_fd[1]);
		int status;
		waitpid(pid, &status, 0);
	
		if (WIFSIGNALED(status)) {
			printf("%s: 시그널 %d로 종료됨 (예상: SIGPIPE==%d, 일치:%d)\n",
				func_name, WTERMSIG(status), SIGPIPE, WTERMSIG(status) == SIGPIPE);
			close(pipe_fd[0]);
			return 1;
		}
		int payload[2] = {0, 0};
		read(pipe_fd[0], payload, sizeof(payload));
		close(pipe_fd[0]);
		*errno_ptr = payload[1];
		printf("%s: 정상 종료됨, ret=%d, errno=%d\n",
			func_name, payload[0], payload[1]);
		return 0;
	}
}

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다