자바스크립트 안에서 <div>, <button> 같은 HTML 유사 마크업 문법을 쓸 수 있게 해주는 확장 문법. React가 만든 개념. UI 구조를 코드로 표현할 때 사용.
TS (.ts)
타입스크립트. JS + 정적 타입 시스템. 컴파일 시 타입 오류 검출
TSX (.tsx)
TS + JSX 문법 허용. React에서 <div>..</div> 같은 마크업을 TS 코드 안에 쓸 때 사용. TSX는 TS의 특수 확장자로 컴포넌트 파일에서만 필요.
// jsx
// 브라우저가 직접 이해하는 문법이 아님
// Babel 같은 컴파일러가 순수 JS로 변환
const element = <h1>안녕하세요</h1>;
// 내부적으로 다음과 같은 형태로 변환
React.createElement('h1', null, '안녕하세요')
순수 로직 파일(유틸 함수 등)은 .ts, UI 렌더링이 들어간 파일은 .tsx
Selenium이란
웹 브라우저를 코드로 제어해 UI 자동화 테스트를 수행하는 오픈소스 프레임워크
실제 브라우저(Chrome, Firefox 등)를 구동해 클릭, 입력, 페이지 이동 등을 시뮬레이션
WebDriver API를 통해 여러 언어(Java, Python, JS 등)에서 제어 가능
E2E(End-to-End) 테스트, 크롤링/스크래핑에도 활용
단점: 실행 속도 느림, 유지보수 비용 높음 (요소 선택자 변경에 취약) -> 최근엔 Playwright, Cypress가 대안으로 많이 쓰임
Playwright
Microsoft가 개발한 오픈소스 브라우저 자동화 라이브러리
Selenium과 목적은 같지만(브라우저 제어), 설계 철학과 아키텍처가 훨씬 최신
E2E 테스트 프레임워크
Selenium과 달리 auto-wait가 내장되어 대기 로직을 직접 짤 필요가 없다
Locator 기반 설계로 요소를 동작 시점에 평가해 타이밍 이슈에 강하다
시맨틱 셀렉터를 지원하여 Selenium의 셀렉터 취약성 문제를 완화
WebDriver API
브라우저를 외부에서 프로그래밍적으로 제어할 수 있게 해주는 표준 인터페이스(프로토콜)
E2E 테스트
End-to-End 테스트
사용자가 실제로 경험하는 흐름 전체를 처음부터 끝까지 검증하는 테스트
전체 시나리오를 한 번에 검 예: 회원가입 -> 로그인 -> 상품 장바구니 담기 -> 결제
아무도 안 궁금했던 근황: 보조강사 알바, 8/7 정보처리기사 필기 시험, 8/9 독학사 컴공 3단계 시험, 포트폴리오 만들어서 정규직으로 써달라고 메일 보내기
(바빴다는 뜻)
아무튼 ft_read를 만들어볼거예요~~
ft_read입니다
ft_read는 ft_write와 거의 대칭이지만, 함정이 하나 다릅니다.
구분
fd
buf
count
syscall 번호
ft_write
rdi
rsi
rdx
1
ft_read
rdi
rsi
rdx
0
syscall 번호와 에러 처리(push rax → call __errno_location wrt ..plt → pop qword [rax] → mov rax, -1) 구조는 ft_write에서 만든 걸 그대로 재사용할 수 있습니다.
다른 점 하나: write는 buf를 커널이 “읽기만” 하지만, read는 커널이 buf에 “쓰기”를 합니다. 이게 코드 자체에는 영향 없지만(어차피 syscall이 알아서 처리), 테스트 설계에는 영향을 줍니다 — NULL buf를 넘겼을 때 write는 EFAULT가 나는 지점이 “읽으려는 시도”였다면, read는 “쓰려는 시도”에서 EFAULT가 납니다. 결과적으로 에러 코드는 똑같이 EFAULT(14)이지만, count=0일 때는 write와 마찬가지로 커널이 buf 검사를 생략하고 0을 반환한다는 점도 동일합니다.
직접 NASM 코드를 작성해보시겠습니까? ft_write 코드를 골격으로 삼아 syscall 번호와 함수 이름만 바꾸는 방식으로 시작하시면 됩니다.
시스콜 넘버부터 찾아봤어요: sys_read는 0입니다.
mov rax, 0
syscall
아니 세상에 클로드가 번호 친절하게 알려줬는데 코앞에 두고 못 봤네;;; 이거 뭐지요??
근데 일단?? read 함수가 어떻게 동작할까?요??
write: fd(rdi)에 buf(rsi)의 내용을 count(rdx)만큼 쓸 것이다, syscall(rax) 1
read: fd(rdi)을 buf(rsi)에 count(rdx)만큼 읽을 것이다, syscall(rax) 0
이거잖아
그러니까 시스콜이 알아서 처리한 다음에, rax를 반환하는거지
rax >= 0, 쓰기 성공한 바이트 rax < 0, -errno
그러면 그냥 write 재사용하면 되는거잖아요…. 날 속였구나(아님)
그러면 ft_read를 구현하라는 게 무슨 의미가 있지? 시스콜 부를 때 rax 0 세팅이랑 테스트 코드만 다르잖아. 테스트코드 짜보라는 건가?
여기서 확인이 필요합니다. 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)
처리 흐름
syscall 실행 후 rax를 확인한다.
rax가 음수이면: -rax를 계산해 errno 값으로 만든다.
__errno_location()을 호출한다 — 이 함수는 인자 없이 호출되며, 현재 스레드의 errno 변수 주소를 rax에 담아 반환한다.
그 주소가 가리키는 곳에 2번에서 계산한 값을 저장한다.
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로 쓰였으니 피해야 합니다.)
이건 가변 인자 개수를 컴파일 타임에 세는 매크로 트릭입니다. 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)를 호출하면:
먼저 __VA_ARGS__가 x, y, z로 치환됨
그 뒤에 7,6,5,4,3,2,1,0,가 자동으로 덧붙여져서 최종적으로 이렇게 확장됨:
c
__SYSCALL_NARGS_X(x, y, z, 7,6,5,4,3,2,1,0,)
__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
매크로는 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에서 이 로직이 왜 쓰이는지 이해하는 참고용으로만 보시면 됩니다.
리눅스 커널의 errno 값 범위는 관례상 1 ~ 4095이므로, 실패 시 rax는 -4095 ~ -1 범위의 값(unsigned로 보면 2^64-4095 ~ 2^64-1)만 가집니다.
따라서 r이 2^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 어셈블리 구현에 실제로 적용할 부분
이전 대화에서 파악하신 흐름과 연결하면:
syscall 실행 → rax에 결과
rax를 test rax, rax 또는 cmp rax, 0 / js(sign flag) 로 음수 여부 판정
musl처럼 unsigned 오버플로우 비교(-4096UL)까지 갈 필요는 없습니다.write는 성공 시 항상 0 이상이므로 단순 부호 검사로 충분합니다.
음수면: 값을 부호 반전(neg) → __errno_location() 호출로 얻은 포인터에 저장 → rax를 -1로 세팅
양수면: 그대로 반환
이 4단계가 지난번에 유나님이 정리하신 “syscall 후 음수 반환값 백업 → __errno_location 호출 → errno 저장 → rax=-1″ 흐름과 정확히 일치합니다. 이제 이게 왜 그렇게 되는지 musl 소스 레벨의 근거까지 확보되신 상태입니다.
지금까지 뜯어본 코드를 요약하자면, 매개변수 갯수를 찾고, 나머지는 갯수 맞춰서 0 넣기다.
SYS_write가 중요한 부분 같은데.
SYS_write는 write 시스템콜의 번호를 나타내는 매크로 상수입니다.
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
-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 저장까지 한 번에 처리한 방식은 깔끔합니다. 이번 에러는 코드 로직 문제가 아니라 빌드 설정 문제입니다. 두 가지를 각각 짚겠습니다.
NASM으로 만든 오브젝트 파일은 기본적으로 “이 스택이 실행 가능한지 아닌지”를 명시하는 섹션을 만들지 않습니다. 링커는 이 정보가 없으면 안전하게 “실행 가능 스택”으로 간주하는데, 이건 보안상 바람직하지 않아 경고를 냅니다
파일 끝에 스택을 비실행으로 명시하는 섹션을 추가하면 사라집니다
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 추가: 이 섹션 자체는 코드를 담지 않고, 링커에게 “이 오브젝트는 실행 스택이 필요 없다”고 알리는 메타데이터입니다.
// 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 rax → call ... wrt ..plt → pop qword [rax] 흐름도 이전에 논의한 대로 정확히 구현되었습니다
테스트 코드 (test_write.c)
항목
문제
근거
NULL 버퍼 테스트가 실제로 ft_write를 검증하지 못함
test_write 함수가 func(fd, str, strlen(str))를 무조건 호출하는데, str이 NULL인 케이스(test_null_case가 호출하는 경로)에서는 ft_write나 write가 호출되기도 전에 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 구현에서는 실제로 문제없이 동작하지만, 엄밀히는 정의되지 않은 동작에 의존하는 구조입니다)
보완 권고
test_null_case에서 str이 NULL일 때는 strlen(str) 대신 고정된 count(예: 5)를 넘기도록 별도 분기를 추가하세요. 이렇게 해야 진짜로 “buf=NULL을 write에 넘겼을 때 ft_write가 커널과 동일하게 반응하는지”를 검증할 수 있습니다.
(선택, 우선순위 낮음) 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 동작이 같아서가 아닙니다.
이렇게 고치면 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 쪽입니다:
이건 출력 문구에서 보여줄 길이를 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에도 그대로 보여줘야 헷갈리지 않습니다.
아닙니다, 여전히 필요합니다. 이번 케이스(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상 WTERMSIG는 WIFSIGNALED(status)가 참일 때만 결과가 유효합니다. else 분기(정상 종료)에서 호출하면 값이 의미 없거나 구현별로 다르게 나올 수 있습니다. “시그널 0으로 종료됨”이라는 출력 자체가 부정확한 표현입니다
결론: ret=-1이 나온 것 자체는 자식 프로세스 내부에서는 맞게 계산됐을 가능성이 높습니다(로그의 -> -1이 그 증거). 하지만 fork를 쓰는 순간 자식의 errno나 반환값을 부모가 직접 읽을 방법이 없다는 구조적 한계에 부딩혔습니다. int *errno_ptr 인자로 넘기는 방식 자체가 fork와 상성이 안 맞습니다.
해결 방향 두 가지 — 어느 쪽을 시도해보시겠습니까?
exit code로 인코딩: 자식이 _exit(0) 대신 _exit(ret == -1 ? errno : 0)처럼 종료 코드에 errno를 실어 보내고, 부모는 WEXITSTATUS(status)로 꺼내 읽는 방법. 간단하지만 exit code가 0~255 범위로 제한됩니다 (EFAULT=14라 문제없음).
pipe로 전달: pipe()를 만들어 자식이 계산한 ret, errno를 write로 부모에게 보내고 부모가 read로 받는 방법. 더 일반적이고 값 범위 제한이 없지만 코드가 늘어남.
지금 케이스(errno 값이 항상 255 이하)엔 1번으로 충분합니다. 1번으로 수정해보시겠습니까?
아쉽지만 아닙니다. 파이프로 값을 전달한다는 방향은 맞았지만, 정작 무엇을 테스트하고 있는지 자체가 바뀌어버렸습니다. 하나씩 짚겠습니다.
항목
문제
근거
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 전에 파이프를 만들어야 부모/자식이 같은 파이프를 공유합니다. 원래 코드도 이 순서였는데, 다른 문제에 가려 언급 못 드렸던 부분입니다 — 사실 이 순서 자체는 맞았습니다
ret과 errno를 함께 배열로 전송
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를 보고했음에도 main의 errno: 9 9: OK 줄은 여전히 9로 찍혀 있습니다. 즉 test_null_case가 애써 파이프로 넘겨준 정확한 errno 값을 main이 매번 자기 자신의 errno로 덮어써서 버리고 있습니다
WTERMSIG(status)를 정상 종료 경로에서 계속 호출
UB 여전히 존재
else 분기(WIFSIGNALED가 거짓)에서 “시그널 %d로 정상 종료됨”이라는 문구에 WTERMSIG(status)를 씁니다. 정상 종료 시 WTERMSIG의 결과는 정의되지 않으므로, 우연히 0이 찍히고 있을 뿐 표준이 보장하는 값이 아닙니다
보완 권고
main의 errno_mine = errno; / errno_libc = errno; 두 줄을 삭제하세요. test_write와 test_null_case가 이미 errno_ptr을 통해 정확한 값을 세팅해서 반환하므로, 이 값을 그대로 쓰면 됩니다. (일반 케이스에서 쓰는 test_write는 부모 프로세스 안에서 직접 호출되니 fork 문제가 없어 지금까지는 우연히 문제가 안 드러났을 뿐, 구조적으로는 이 두 줄이 애초에 불필요했습니다.)
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 등)로 이어서 진행하시겠습니까, 아니면 오늘은 여기서 마무리하시겠습니까?
GDB: 가장 표준적인 선택입니다. nasm -f elf64 -g -F dwarf 옵션으로 디버그 심볼을 포함해 어셈블해야 소스 레벨 디버깅이 가능합니다. layout asm, layout regs 명령으로 어셈블리 명령어와 레지스터를 동시에 보면서 si(step instruction) 단위로 추적하기 편합니다.
objdump: objdump -d 로 컴파일된 오브젝트 파일의 디스어셈블 결과를 확인해 실제 생성된 명령어를 검증할 때 씁니다.
ndisasm: NASM 패키지에 포함된 디스어셈블러로, 바이너리를 다시 어셈블리로 역변환해 확인할 때 유용합니다.
Valgrind: 세그폴트나 메모리 접근 오류(특히 문자열 함수 구현 시 버퍼 오버런) 잡을 때 필수적입니다.
libasm 프로젝트라면 특히 GDB에서 x/10xb $rdi 같은 메모리 덤프 명령과 레지스터 상태를 함께 보면서, System V AMD64 호출 규약대로 인자가 rdi, rsi, rdx 등에 제대로 들어가는지 확인하는 워크플로우가 실용적입니다.
GDB 키는방법부터 실제 디버깅에 필요한 것들 알려주세요
4:48 PM
GDB로 NASM 어셈블리 코드를 디버깅하는 데 필요한 핵심 사항을 정리해 드리겠습니다.
1. 준비: 디버그 심볼 포함 어셈블
bash
nasm -f elf64 -g -F dwarf ft_strcmp.s -o ft_strcmp.o
ld -g -o test test.o ft_strcmp.o
# 또는 C 테스트 하네스와 함께라면
gcc -g -no-pie -o test main.c ft_strcmp.o
-g 플래그 없으면 소스 라인 매칭이 안 되고 순수 어셈블리 명령어만 보입니다.
2. GDB 실행 및 레이아웃
bash
gdb ./test
GDB 진입 후:
layout asm — 어셈블리 명령어 창 표시
layout regs — 레지스터 상태 창 표시 (asm과 동시 사용 시 layout split 유용)
Ctrl+L — 화면 깨졌을 때 리프레시
Ctrl+X, A — TUI 모드 토글 (레이아웃 진입/해제)
3. 실행 제어 (키 바인딩 겸 명령어)
명령
단축키
용도
break ft_strcmp
b ft_strcmp
함수 진입점에 브레이크포인트
run
r
실행 시작
stepi
si
어셈블리 명령어 1개 단위 진행 (call 안으로 진입)
nexti
ni
어셈블리 명령어 1개 단위 진행 (call 건너뜀)
continue
c
다음 브레이크포인트까지 실행
Enter (빈 입력)
–
직전 명령 반복 — si/ni 연타 대신 사용
4. 상태 확인 명령
info registers (i r) — 전체 레지스터 값 확인
print $rdi, print $rsi — 개별 레지스터 확인 (System V AMD64 호출 규약상 정수/포인터 인자 1,2번째)
x/10xb $rdi — rdi가 가리키는 메모리 10바이트를 16진수로 덤프
x/s $rdi — rdi를 문자열로 해석해 출력 (strlen/strcpy류 검증에 유용)
x/10i $rip — 현재 위치부터 10개 명령어 디스어셈블 출력
info frame — 현재 스택 프레임 정보 (rbp, 리턴 주소 등)
5. libasm 컨텍스트에서 실전 팁
ft_strcmp 검증 시: 진입 직후 print $rdi, print $rsi로 두 문자열 포인터가 제대로 들어왔는지 먼저 확인 → x/s $rdi, x/s $rsi로 실제 내용 대조 → si로 한 줄씩 진행하며 rax(비교 결과 반환값) 변화 추적.
Segfault 발생 시 bt(backtrace)로 어디서 죽었는지 먼저 확인 후, 위 레지스터/메모리 명령으로 원인 추적.
조건부 브레이크포인트가 필요하면 break *0x<주소> if $rcx==0 형태로 루프 특정 시점만 잡을 수 있습니다.
확인 필요: 현재 ft_strcmp 구현체와 테스트 하네스 코드가 있다면, 실제 어떤 지점에서 막히는지 보고 구체적인 브레이크포인트 위치를 짚어드릴 수 있습니다. 코드 공유하시겠습니까?
ft_strcmp 구현에서 문자 비교 루프를 도는 중 불일치 지점(예: .break, .diff 같은 레이블)으로 한 번에 점프하고 싶으신 걸로 이해했습니다. si/ni로 한 명령씩 추적하는 대신 아래 방법을 쓰면 됩니다.
1. 레이블에 직접 브레이크포인트 걸기
gdb
break ft_strcmp
run
break *ft_strcmp+0x2a # .break 레이블의 실제 주소
continue
심볼 정보가 살아있다면 주소 대신 레이블명으로도 걸립니다:
gdb
break .break
단, NASM 로컬 레이블(.break처럼 점으로 시작)은 GDB가 그대로 인식 못 할 수 있어 함수명.레이블명 형태가 필요할 수 있습니다:
gdb
break ft_strcmp.break
2. 조건부 브레이크포인트로 “불일치 나는 순간”만 잡기 루프 안에서 비교하는 시점(cmp 명령 다음 줄)에 브레이크를 걸고, 두 레지스터가 다를 때만 멈추게:
gdb
break *0x<cmp_다음_주소>
condition 1 $al != $bl # 예: al, bl에 각 바이트를 로드하는 경우
continue
3. 루프를 통째로 건너뛰고 결과만 확인 불일치 지점 자체보다 최종 결과(rax)만 필요하면:
gdb
tbreak ft_strcmp
run
finish # 함수 리턴까지 실행 후 반환값(rax) 표시
확인 필요: 정확한 레이블명(.break가 실제 코드에 있는 이름인지)과 현재 작성하신 ft_strcmp 어셈블리 코드를 보여주시면, 정확한 주소/조건으로 명령어를 짚어드릴 수 있습니다. 코드 공유 가능하신가요?
ft_strlen, ft_strcpy와 달리 이번엔 반환값 자체가 정보를 담습니다. 표준 strcmp(s1, s2)는 단순히 같다/다르다(1/0)를 반환하는 게 아니라, 다른 지점에서 두 문자열의 문자 값 차이를 반환합니다. 보통 s1[i] - s2[i]로 구현합니다.
케이스
반환값 예시
근거
완전히 같음
0
모든 바이트가 같고 둘 다 같은 위치에서 \0을 만남
s1 > s2
양수
다른 첫 바이트에서 s1 쪽 문자 코드가 더 큼
s1 < s2
음수
다른 첫 바이트에서 s1 쪽 문자 코드가 더 작음
루프 종료 조건이 이전 두 함수와 다릅니다. ft_strlen, ft_strcpy는 “한쪽 문자열이 끝나면” 종료했지만, ft_strcmp는 “두 문자가 다르거나, 어느 한쪽이라도 \0이면” 종료해야 합니다. 이 조건을 놓치면 두 문자열이 완전히 같을 때 무한루프에 빠지거나, \0을 넘어서 메모리를 읽는 버그가 생깁니다.
질문: 루프 안에서 매 반복마다 검사해야 할 조건이 정확히 몇 가지이고, 각각 무엇입니까? (여기에 정확히 답한 뒤 NASM 코드를 작성해보세요.)
3가지입니다. a[i] == 0 b[i] == 0 a[i] != b[i]
항목
문제
근거
a[i] == 0, b[i] == 0 두 조건 모두 필요하다고 판단
논리적으로는 맞지만 최적화 관점에서 중복
a[i] != b[i]를 먼저 검사해서 통과했다는 것은 이미 a[i] == b[i]가 확정된 상태입니다. 이 상태에서 a[i] == 0을 검사해 참이면, b[i]도 자동으로 0입니다(둘이 같으니까). 따라서 b[i] == 0을 별도로 검사할 필요가 없습니다. 즉 실질적으로 필요한 검사는 2가지입니다: (1) 두 바이트가 다른가, (2) (같다면) 그 값이 0인가
정정된 종료 조건
a[i] != b[i] → 다르면 즉시 종료, 반환값은 a[i] - b[i]
(같다면) a[i] == 0 → 둘 다 끝났다는 뜻이므로 종료, 반환값은 0
둘 다 아니면 인덱스 증가 후 반복
이 순서로 검사 개수를 3개에서 2개로 줄일 수 있다는 점을 반영해서 NASM 코드를 작성해보세요. 인자는 rdi = s1, rsi = s2이고, 반환값은 eax(32비트, int 반환이므로) 또는 관례상 movsx로 부호 확장한 rax를 씁니다 — 이 부분은 직접 코드 짜면서 판단해보시기 바랍니다.
이렇게 쓰면 왜 안돼?
cmp [rdi+r8], [rsi+r8]
cmp [rdi+r8], 0
sub [rdi+r8], [rsi+r8]
4:28 PM
***님, 이 세 줄 모두 x86-64 명령어 인코딩 규칙 위반입니다.
항목
문제
근거
1. cmp [rdi+r8], [rsi+r8]
오퍼랜드 둘 다 메모리
x86 명령어는 ModRM 바이트로 인코딩되는데, 이 구조상 피연산자 중 하나는 반드시 레지스터 또는 즉치값이어야 함. 메모리-메모리 연산은 인코딩 자체가 존재하지 않음 (예외: MOVSB/CMPSB 같은 문자열 명령어만 RSI/RDI를 암묵적으로 사용해서 가능)
2. cmp [rdi+r8], 0
크기 미지정
메모리 오퍼랜드 + 즉치값 조합에서 NASM은 비교할 크기(byte/word/dword/qword)를 추론할 수 없음 → “operation size not specified” 에러. cmp byte [rdi+r8], 0 처럼 명시 필요
3. sub [rdi+r8], [rsi+r8]
오퍼랜드 둘 다 메모리
1번과 동일한 이유
보완 권고
두 메모리 값을 비교/연산하려면 한쪽을 먼저 레지스터에 로드해야 합니다.
nasm
mov al, [rdi+r8]
cmp al, [rsi+r8]
즉치값과 비교 시 항상 크기 지정자를 붙이세요. (ft_strcmp는 보통 byte 단위 비교이므로 byte 사용)
sub도 동일하게 레지스터 경유가 필요합니다.
nasm
mov al, [rdi+r8]
sub al, [rsi+r8]
ft_strcmp 구현하실 때 이 패턴(로드 → 비교/연산)을 루프 안에서 반복하시게 될 겁니다.
section .text
global ft_strcmp
ft_strcmp:
xor r8, r8
.loop:
mov r9, [rdi+r8]
cmp r9, [rsi+r8]
jne .calc
cmp byte r9, 0
je .calc
inc r8
jmp .loop
.calc:
mov rax, r9
sub rax, [rsi+r8]
ret
// test_strcmp.c
#include <stdio.h>
#include <string.h>
extern int ft_strcmp(const char *s1, const char *s2);
int test_cmp(char *func_name, int (*func)(const char *, const char *), const char *s1, const char *s2);
int main(void)
{
char *s1[] = {"", "a", "abc", "mat", "cat", "hello"};
char *s2[] = {"", "a", "abc", "map", "rat", "helloWorld"};
int i = 0;
int mine;
int libc;
int ret_cmp;
int fail = 0;
for (; i < 6; ++i)
{
printf("s1: %s, s2: %s\n", s1[i], s2[i]);
mine = test_cmp("ft_strcmp", ft_strcmp, s1[i], s2[i]);
libc = test_cmp(" strcmp", strcmp, s1[i], s2[i]);
ret_cmp = mine == libc;
printf(ret_cmp ? "OK" : "FAIL");
if (!ret_cmp) fail = 1;
printf("\n\n");
}
return (fail);
}
int test_cmp(char *func_name, int (*func)(const char *, const char *), const char *s1, const char *s2)
{
int ret;
ret = func(s1, s2);
printf("%s(%s, %s) -> %d\n", func_name, s1, s2, ret);
return (ret);
}
ft_strcmp(“mat”, “map”), ft_strcmp(“hello”, “helloWorld”)의 결과가 strcmp와 다르다.
8바이트(qword) 단위로 메모리를 읽고 있음. 문자열 비교는 1바이트(char) 단위여야 함
r9는 64비트 레지스터이므로 니모닉 크기가 자동으로 8바이트로 결정됨. s1[i] 한 글자가 아니라 s1[i]~s1[i+7] 8글자를 한 번에 로드하게 됨
cmp byte r9, 0
NASM 어셈블 자체가 실패함 (문법 오류)
size directive(byte/word/dword/qword)는 메모리 오퍼랜드에만 붙일 수 있음. 레지스터는 이름 자체가 크기를 결정하므로 byte r9는 상충되는 크기 지정으로 처리되어 컴파일 에러 발생
cmp r9, [rsi+r8] 분기 로직
8바이트 단위 비교로는 “첫 번째로 다른 문자”의 정확한 위치를 특정할 수 없음
두 8바이트 블록이 다르다고 판단해 .calc로 가더라도, 그 8바이트 중 몇 번째 바이트에서 실제로 값이 갈렸는지, 혹은 그 사이에 널 종료 문자가 끼어있는지 알 수 없음
mov rax, r9 / sub rax, [rsi+r8]
반환값이 “8바이트 정수끼리의 차”가 되어 표준 strcmp 규약과 어긋남
libasm/libft 표준은 통상 (unsigned char)s1[i] - (unsigned char)s2[i] 형태의 단일 바이트 차이를 요구함
메모리 오버런 위험
짧은 문자열에서 8바이트를 한 번에 읽으면 문자열 경계 밖의 매핑되지 않은 페이지를 건드릴 수 있음
예: 길이 3인 문자열이 페이지 끝에 위치하면 나머지 5바이트를 읽는 순간 세그폴트 가능
보완 권고
movzx + byte 레지스터로 1바이트씩 로드하도록 수정 (r9b, r10b 사용, 상위 비트는 movzx로 제로 확장)
cmp byte r9, 0 → test r9, r9 로 대체 (이미 movzx로 제로 확장된 상태이므로 전체 레지스터 test로 0 여부 판단 가능)
8바이트 단위 비교 로직 전면 제거, 바이트 단위 비교로 재작성
수정안:
asm
section .text
global ft_strcmp
ft_strcmp:
xor r8, r8
.loop:
movzx r9, byte [rdi+r8]
movzx r10, byte [rsi+r8]
cmp r9, r10
jne .calc
test r9, r9
je .calc
inc r8
jmp .loop
.calc:
mov rax, r9
sub rax, r10
ret
로컬 레이블(.loop, .calc) 사용 방식은 문제없습니다. GDB에서 확인할 때는 이전에 안내드린 대로 break ft_strcmp.calc로 접근 가능합니다.
movzx (Move with Zero-Extend)는 작은 크기의 값을 더 큰 레지스터로 옮기면서 남는 상위 비트를 0으로 채우는 명령어입니다.
movzx r9, byte [rdi+r8] → 메모리에서 1바이트만 읽어와 r9(64비트)에 넣고, 나머지 상위 7바이트는 전부 0으로 채웁니다.
이게 필요한 이유: 만약 그냥 mov r9b, [rdi+r8]로 하위 8비트만 채우면 r9의 상위 56비트는 이전에 남아있던 쓰레기값이 그대로 남습니다. 그 상태에서 r9 전체(64비트)로 cmp나 test를 하면 상위 비트의 쓰레기값 때문에 잘못된 비교 결과가 나올 수 있습니다.
movzx는 이 문제를 원천 차단해서, 항상 “순수한 바이트 값”만 레지스터에 들어있도록 보장합니다.
참고로 부호 있는 값(예: signed char)을 확장할 때는 상위 비트를 부호에 맞춰 채우는 movsx(sign-extend)를 씁니다. ft_strcmp처럼 unsigned char 기준 비교를 요구하는 경우엔 movzx가 맞습니다.
test는 두 오퍼랜드를 AND 연산하되 결과를 저장하지 않고 플래그 레지스터만 갱신하는 명령어입니다.
test r9, r9는 r9 AND r9를 계산해 ZF(Zero Flag) 등을 설정하지만, r9 값 자체는 그대로 유지합니다.
r9가 0이면 r9 AND r9 = 0이 되어 ZF가 1로 설정되고, 이어지는 je(jump if equal, 즉 ZF=1일 때 점프)가 동작합니다.
즉 test r9, r9 + je는 “r9가 0인지 검사해서 0이면 점프”하는 관용적 패턴이며, cmp r9, 0보다 명령어가 짧고 실행 속도가 미세하게 빠릅니다.
je는 zf가 1일때 동작하나요? cmp 후 무엇이 설정되어야 je가 동작하나요?
네, 맞습니다. je(Jump if Equal)는 ZF(Zero Flag)가 1일 때 동작합니다. 사실 je는 jz(Jump if Zero)와 완전히 동일한 명령어의 다른 니모닉일 뿐이며, 둘 다 내부적으로 같은 opcode를 사용합니다.
cmp a, b는 내부적으로 a - b를 계산하되 결과는 버리고 플래그만 설정합니다. 이때 설정되는 주요 플래그는:
ZF (Zero Flag): a - b == 0, 즉 a == b일 때 1로 설정 → je/jz가 이 플래그를 보고 점프
SF (Sign Flag): 결과가 음수일 때 1
CF (Carry Flag): 부호 없는 뺄셈에서 빌림(borrow) 발생 시 1 → jb, ja 등이 사용
OF (Overflow Flag): 부호 있는 연산에서 오버플로 발생 시 1 → jg, jl 등이 사용
즉 cmp r9, r10 다음에 je를 쓰면 “r9와 r10이 같으면 점프”가 되고, 이건 test r9, r9 다음에 쓴 je(“r9가 0이면 점프”)와는 검사 대상이 다르지만 둘 다 ZF=1 여부를 확인한다는 메커니즘은 동일합니다.
asm 코드와 test 코드입니다. 검토 부탁드립니다.
section .text
global ft_strcmp
ft_strcmp:
xor r8, r8
.loop:
movzx r9, byte [rdi+r8]
movzx r10, byte [rsi+r8]
cmp r9, r10
jne .calc
test r9, r9
je .calc
inc r8
jmp .loop
.calc:
mov rax, r9
sub rax, r10
ret
어셈블리 코드 (ft_strcmp.asm)
항목
문제
근거
없음
—
인덱스에 r8, 임시값에 r9/r10을 사용해 rax를 아예 건드리지 않다가 마지막에만 반환값을 채우는 구조입니다. 이전에 논의했던 “인덱스로 rax를 쓰다가 마지막에 덮어쓰기” 방식보다 실수 여지가 적은 설계입니다. movzx로 8비트를 64비트로 제로 확장한 뒤 그대로 sub한 것도 유효합니다 — 이후 mov rax, r9 / sub rax, r10 계산 결과가 truncation 없이 정확합니다
트레이싱 검증 ("hello" vs "helloWorld", index=5): r9=0(s1 종료), r10='W'=87. cmp r9,r10에서 이미 다르므로 jne .calc로 분기 — test r9,r9 경로를 거치지 않아도 동일한 값(0-87=-87)이 계산되어 두 종료 경로가 결과적으로 일치합니다. 로직 결함 없음.
테스트 코드 (test_strcmp.c)
항목
문제
근거
없음
—
일반 케이스 6종(빈 문자열, 완전 일치, 중간 문자 차이, 첫 문자 차이, prefix 관계의 길이 차)과 NULL 케이스 3종(첫 인자 NULL, 둘째 인자 NULL, 둘 다 NULL) 조합이 누락 없이 구성되어 있습니다
(선택) 최종 요약 라인 부재
test_strcpy.c에서 썼던 === OK/FAIL === 형태의 전체 요약 출력이 없음
기능상 문제는 아니고, 각 항목별 OK/FAIL은 이미 출력되나 스크롤이 길어질 경우 한눈에 전체 결과를 확인하기 어려움
보완 권고
(선택) main 마지막에 printf("=== %s ===\n", fail ? "FAIL" : "OK");를 추가해 이전 테스트 파일과 형식을 통일하세요.
로직 결함은 없으므로 바로 빌드·실행하셔도 됩니다.
항목
문제
근거
8비트 초과 값 비교 미검증
바이트 값이 0x80 이상(예: 128~255 범위, 이른바 “음수처럼 보이는” 문자)인 케이스가 테스트에 없음
POSIX 표준상 strcmp은 문자를 unsigned char로 해석하여 비교해야 합니다. ft_strcmp.asm에서 movzx(zero-extend)를 쓴 것은 이 규격에 맞는 정확한 선택이지만, 이걸 증명하는 테스트 케이스가 없습니다. 만약 나중에 실수로 movsx(sign-extend)를 썼다면 0x80 이상 바이트에서 결과가 반대로 뒤집히는데, 지금 테스트 세트(전부 ASCII 알파벳)로는 이 버그를 절대 못 잡습니다
완전 동일하지만 긴 문자열 부재
가장 긴 케이스가 "helloWorld"(10자)뿐이고, 수십~수백 바이트급 긴 문자열에서 두 문자열이 끝까지 동일한 케이스가 없음
루프가 반복적으로 잘 도는지, 인덱스(r8) 오버플로우나 예상치 못한 조기 종료가 없는지는 문자열 길이가 짧으면 우연히 통과할 수 있음
마지막 한 글자만 다른 케이스 부재
예: "hello" vs "hellp"처럼 문자열 끝부분(널 문자 직전)에서만 값이 갈리는 경우가 없음
종료조건 두 가지(다름/둘다 0)의 경합이 문자열 맨 끝에서 정확히 처리되는지 확인하는 경계값 테스트
dest/src 버퍼 재사용 없음
(해당 사항 없음 — ft_strcmp은 읽기 전용 함수라 버퍼 문제 없음)
—
보완 권고
s1/s2 배열에 0x80 이상 바이트를 포함한 문자열 쌍을 추가하세요. 예: "\x80\x01" vs "\x7f\x01" — ft_strcmp이 양수를 반환해야 정상(0x80 > 0x7f as unsigned char)이고, 만약 movsx를 잘못 썼다면 부호가 반대로 나와 버그가 드러납니다.
30~50바이트 이상의 동일한 긴 문자열 쌍을 추가하세요.
마지막 한 글자만 다른 동일 길이 문자열 쌍("hello" vs "hellp")을 추가하세요.
1번이 가장 중요합니다 — 지금 어셈블리 구현 자체는 맞게 되어 있지만, 이 특성을 검증하는 테스트가 없으면 나중에 코드를 고치다 실수로 부호 확장(movsx)으로 바꿔도 테스트가 계속 통과할 위험이 있습니다.
System V AMD64 호출 규약에 대해 간략하게 알려줘. 각 레지스터의 이름이 무슨 뜻인지도 알려줘
System V AMD64 호출 규약 개요
리눅스/macOS 등 유닉스 계열 64비트 시스템에서 사용하는 함수 호출 규약입니다. 유나님이 진행 중이신 libasm 프로젝트(NASM, Intel 문법)의 ft_write, ft_read 등에서 시스템 콜 호출 시 직접 사용하시게 됩니다.
정수/포인터 인자 전달 순서 (레지스터)
순서
레지스터
용도
1번째 인자
RDI
Destination Index
2번째 인자
RSI
Source Index
3번째 인자
RDX
Data (원래는 Data 레지스터)
4번째 인자
RCX
Counter
5번째 인자
R8
범용 확장 레지스터 8번
6번째 인자
R9
범용 확장 레지스터 9번
7개 이상의 인자는 스택으로 전달됩니다.
반환값은 RAX(Accumulator)에 담깁니다. 128비트 반환 시 RAX:RDX 조합 사용.
레지스터 이름의 유래 (8086 시절 잔재)
레지스터
원래 이름 뜻
RAX
Accumulator — 연산 누적용
RBX
Base — 데이터 접근용 베이스 주소
RCX
Counter — 반복문(LOOP, REP) 카운터
RDX
Data — 보조 데이터, 확장 연산(나눗셈 등)
RSI
Source Index — 문자열 연산 시 소스 주소
RDI
Destination Index — 문자열 연산 시 목적지 주소
RBP
Base Pointer — 스택 프레임 기준점
RSP
Stack Pointer — 스택 최상단 위치
R8~R15
이름 없이 번호만 (x86-64에서 신규 추가)
시스템 콜 호출 시 차이점
일반 함수 호출과 달리 시스템 콜은 규약이 약간 다릅니다.
구분
함수 호출
시스템 콜
호출 방식
CALL
SYSCALL
콜 번호 지정
없음
RAX에 시스템 콜 번호
4번째 인자
RCX
R10 (RCX는 SYSCALL 명령어가 리턴 주소 저장용으로 덮어씀)
이름 앞의 R은 레지스터야? RDX의 X는 무슨 뜻이야? 3자리를 맞추기 위한 패딩이야?
(클로드 답변 생략)
8086에서 8비트 레지스터(AL/AH, BL/BH, CL/CH, DL/DH) 이름 뜻 알려줘. 알파벳 순서 + Low High 야? 16비트 AX, BX, CX, DX. 앞은 알파벳 순서고 뒤는 eXtended의 X인거야? 32비트 확장되며 E 접두사: EAX 등 64비트 확장되며 E접두사 대신 R접두사. R은 관례적으로 Register로 해석 이거 맞을까?
5:16 AM
***님, 정리해 주신 내용을 검토했습니다.
항목
문제
근거
AL/AH 등 L/H
맞음
Low/High 8비트를 지칭
A,B,C,D가 “알파벳 순서”
부정확한 프레이밍
A/B/C/D는 각각 Accumulator, Base, Counter, Data의 머리글자이며, 기능명 기준으로 이름 붙인 것이지 알파벳 순서 자체를 설계 원칙으로 삼은 게 아닙니다. 우연히 그 머리글자들이 알파벳 순서로 나열될 뿐입니다.
AX/BX/CX/DX의 X
맞음
eXtended(8비트→16비트 확장)의 X
E 접두사(32비트)
맞음
Extended
R 접두사(64비트) = Register
맞음(추정)
공식 정의는 없으나 관례적 해석
요약: L/H, X, E, R에 대한 이해는 정확합니다. 다만 “A,B,C,D가 알파벳 순서”라는 표현은 인과관계가 반대입니다 — 알파벳 순서를 의도한 게 아니라, 각 레지스터의 기능(Accumulator/Base/Counter/Data)을 나타내는 머리글자를 선택한 결과가 우연히 알파벳 순으로 보이는 것입니다.
Accumulator, Base, Counter, Data에 대해 설명해줘
각 레지스터명이 원래 8086에서 담당했던 역할입니다.
레지스터
의미
원래 용도
A (Accumulator)
누산기
산술/논리 연산 결과가 누적되는 레지스터. ADD, MUL 등의 기본 대상
B (Base)
베이스
메모리 접근 시 베이스 주소를 담는 용도 (예: 배열의 시작 주소)
C (Counter)
카운터
LOOP, REP 등 반복 명령어에서 반복 횟수를 자동으로 감소시키며 세는 용도
D (Data)
데이터
입출력 연산이나 MUL/DIV의 확장 데이터 저장(예: DX:AX로 32비트 값 표현)
x86-64에서는 범용 레지스터로 쓰여 원래 용도에 얽매이지 않지만, 관습적으로 RCX는 여전히 반복 카운터로, RAX는 반환값 저장 용도로 많이 쓰입니다.
이번엔 반환값 처리가 다릅니다. ft_strlen은 계산한 값(rax)을 그대로 반환했지만, ft_strcpy는 원본 dest 포인터를 반환해야 합니다 (char *strcpy(char *dest, const char *src)의 표준 동작). 즉 rdi를 인덱스 증가에 써버리면 원래 dest 주소를 잃어버립니다.
필요 레지스터
용도
rdi
dest 포인터 (인자 1) — 원본을 보존해야 함
rsi
src 포인터 (인자 2)
rax
인덱스 겸 최종 반환값
전략은 두 가지가 있습니다:
xor rax, rax로 인덱스를 만들고 [rdi+rax], [rsi+rax]로 접근 → 루프 끝나면 mov rax, rdi로 반환값 세팅
rdi를 직접 증가시키되, 함수 시작 시 mov r10, rdi로 원본을 별도 레지스터에 백업
질문: 두 전략 중 어느 쪽이 더 안전하다고 생각하십니까? 그리고 그 이유는 무엇입니까?
(참고로 널 종료 문자 \0도 반드시 복사해야 합니다 — 이걸 빠뜨리면 ft_strlen으로 검증했을 때 src보다 한 글자 짧게 나오는 게 아니라, dest 문자열이 끝나지 않아 쓰레기 메모리까지 읽는 훨씬 위험한 버그가 됩니다.)
1번의 경우 rdi를 그대로 반환하면 되는데 mov rax, rdi를 왜 해야 합니까? rdi는 보존되어야 하니까 보존하되, return 값은 항상 rax에 있어야 하기 때문인가요?
하필 r10 레지스터에 백업하는 이유가 있습니까?
1번 질문에 대한 답변
정확합니다. System V AMD64 호출 규약상 반환값은 항상 rax를 통해 전달되어야 합니다 — 이건 CPU가 강제하는 규칙이 아니라 호출자(caller)와 피호출자(callee) 사이의 약속입니다. test_strcpy.c에서 ft_strcpy()를 호출한 쪽은 결과를 확인할 때 무조건 rax를 들여다봅니다. rdi에 dest 주소가 여전히 남아있더라도, 호출자 입장에서는 그걸 볼 방법이 없습니다. 따라서 전략 1을 쓸 경우, 루프에서 rdi는 건드리지 않고 인덱스만 rax로 관리했으니, 함수를 끝내기 직전에 mov rax, rdi로 “이 값이 반환값이다”라고 명시적으로 옮겨줘야 합니다.
2번 질문에 대한 답변
r10에 특별한 의미는 없습니다. 이유는 단 하나, 호출자-저장(caller-saved) 레지스터이기 때문입니다.
레지스터 그룹
예시
함수 안에서 값을 바꿔도 되는가
Caller-saved (volatile)
rax, rcx, rdx, rsi, rdi, r8~r11
됨 — 호출자가 필요하면 알아서 저장해둠
Callee-saved (non-volatile)
rbx, rbp, r12~r15
안 됨 — 건드리려면 함수 시작에 push, 끝에 pop 필수
r10 대신 r11이나 r9를 써도 무방합니다. 다만 rbx나 r12 같은 callee-saved 레지스터를 임시 백업용으로 쓰면, 함수 끝나기 전에 push/pop으로 원래 값을 복원해줘야 하는 추가 부담이 생깁니다. 지금 만드는 ft_strcpy는 그럴 이유가 없으니, 아무 caller-saved 레지스터 중 아직 안 쓰고 있는 걸 골라 쓰면 됩니다.
이제 전략 선택하셨습니까? 결정하신 뒤 NASM 코드 작성해보세요.
1번의 경우, 간단하고 쉬워보입니다. 2번의 경우, rdi가 caller-saved 레지스터라지만(그래서 호출자가 필요하다면 따로 저장해 두겠지만), 변경하는 것이 위험해 보입니다. 따라서 저는 1번이 더 안전하다고 생각합니다.
히히.. 나만의 작은 교수님.. 영원히 함께야… (매달 22달러를 결제하며)
항목
문제
근거
“rdi가 caller-saved라서 위험”
논리적 연결이 부정확함
caller-saved라는 성질은 “함수 안에서 값을 바꿔도 함수 밖(caller)에는 영향 없다”는 뜻입니다. 즉 rdi를 건드려도 안전성 문제는 없습니다. 오히려 caller-saved이기 때문에 자유롭게 써도 되는 레지스터라는 뜻입니다. 진짜 위험 요인은 성질이 아니라, 전략 2에서는 “백업했다는 사실을 잊고 그냥 rdi를 반환에 써버리는” 실수 가능성이 늘어난다는 데 있습니다. 전략 1은 애초에 rdi를 건드릴 일이 없어 그런 실수 자체가 발생할 여지가 없습니다
결론(전략 1이 더 안전하다)은 맞지만, 그 근거는 “caller-saved라서 위험”이 아니라 **”불필요한 이동/백업 단계를 아예 제거해 실수 지점을 줄인다”**는 데 있다는 점을 정정해두겠습니다.
이제 전략 1로 NASM 코드를 작성해보세요. 인자는 rdi = dest, rsi = src이고, 널 종료 문자까지 복사한 뒤 rax에 dest 주소를 넣고 반환해야 합니다.
이번에는 수도코드 작성 안해도 됩니까?
5:41 AM
네, 생략하셔도 됩니다. ft_strlen에서 이미 “루프 진입 전 인덱스 초기화 → 종료조건 비교 → 증가 → 복귀”라는 패턴을 직접 설계하고 검증하셨으므로, 같은 구조에 소스 포인터 하나만 추가되는 ft_strcpy는 굳이 자연어 의사코드 단계를 거치지 않고 바로 NASM으로 작성해도 실수 위험이 크지 않습니다.
다만 한 가지는 스스로 먼저 정하고 시작하세요: 루프 종료 조건을 어디서 검사할 것인지 — [rsi+rax]가 0인지 검사한 뒤 복사할지, 복사부터 하고 방금 복사한 바이트가 0이었는지 검사할지. 이 순서에 따라 코드 구조가 달라집니다.
바로 코드 작성해보세요.
section .text
global ft_strcpy
ft_strcpy:
xor rax, rax
.loop:
mov [rdi+rax], [rsi+rax]
cmp byte [rsi+rax], 0
inc rax
jne .loop
mov rax, rdi
ret
항목
문제
근거
mov [rdi+rax], [rsi+rax]
메모리-메모리 간 직접 이동은 x86-64에서 허용되지 않는 연산
NASM/CPU 모두 피연산자 두 개가 동시에 메모리일 수 없습니다. 반드시 레지스터를 경유해야 합니다 (mov cl, [rsi+rax] → mov [rdi+rax], cl). 이 상태로는 어셈블 자체가 되지 않습니다
inc rax 이후 jne .loop
cmp의 판단 결과가 inc에 의해 덮어써짐
ft_strlen에서 이미 지적했던 것과 동일한 실수입니다. cmp 직후 곧바로 je/jne로 판단해야 하며, inc 다음에 남는 플래그는 cmp와 무관합니다. 지금 코드는 “방금 복사한 문자가 0인지”가 아니라 “rax를 증가시킨 결과가 0인지(사실상 절대 발생 안 함)”를 검사하고 있어 사실상 무한루프이거나 우연히 멈추는 구조입니다
종료 조건 순서
\0 복사 자체는 이루어지지만, 복사 직후 종료 판단이 inc rax 뒤로 밀려 실질적으로 검사가 무력화됨
위 항목과 연결됨. 복사 → 판단 → (필요시) 증가+루프, 순서를 명확히 분리해야 함
보완 권고
메모리 간 이동은 al(또는 다른 8비트 레지스터)을 경유하도록 수정하세요.
cmp 다음에 바로 je .end로 분기하고, inc rax와 jmp .loop는 그 이후(복사가 계속되는 경로)에만 놓으세요.
종료 후 mov rax, rdi로 반환값을 세팅하는 부분은 올바릅니다 — 그대로 두시면 됩니다.
이 두 가지(메모리 간 이동 불가, cmp 직후 즉시 분기)를 반영해서 다시 작성해보세요.
section .text
global ft_strcpy
ft_strcpy:
xor rax, rax
.loop:
mov cl, [rsi+rax]
mov [rdi+rax], cl
inc rax
cmp byte [rsi+rax], 0
jne .loop
mov rax, rdi
ret
항목
문제
근거
널 종료 문자 미복사
루프가 \0을 실제로 dest에 쓰기 전에 종료됨
트레이싱: src = "a\0"일 때 rax=0에서 'a'를 복사하고 inc rax로 rax=1이 됨. 그다음 cmp byte [rsi+1], 0은 참(0)이므로 jne가 실행되지 않고 루프를 빠져나갑니다. 이 시점에서 dest[1](널 문자 위치)에는 아무것도 쓰이지 않은 채 함수가 종료됩니다. 즉 dest는 \0으로 끝나지 않는 문자열이 되어, 이후 ft_strlen(dest)를 호출하면 쓰레기 메모리를 읽다가 크래시하거나 엉뚱한 길이를 반환할 위험이 있습니다
메모리 간 이동 문제와 cmp 직후 즉시 분기 문제는 정확히 해결하셨습니다. 남은 문제는 하나, **”검사 시점”**입니다. 지금 코드는 “다음에 복사할 바이트가 0인가”를 복사하기 전에 미리 엿본 뒤 판단하는 구조인데, 그 결과 정작 0을 dest에 쓰는 단계 자체가 빠져버립니다.
보완 권고
cmp 검사는 방금 cl에 읽어온 값(=이미 복사된 값)을 기준으로 판단하도록 순서를 바꾸세요. 즉 “복사 → 그 값이 0인지 검사 → 0이면 종료, 아니면 인덱스 증가 후 반복” 순서가 되어야 합니다.
이 순서로 다시 짜보시겠습니까?
또 또 무지성 ai님 다 해주세요 하고 있지. 그래서 Makefile 돌려서 직접 디버깅을 해보기로 했어요.
매개변수 접두어로 __restrict __를 사용하면 이 매개변수는 nonnull이라고 선언하는 건가요? asm에서는 어떻게 사용하나요?
8:28 AM
__restrict와 nonnull은 서로 다른 별개의 속성입니다.
__restrict (C99 restrict 한정자): 순수히 컴파일러 최적화 힌트입니다. “이 포인터가 가리키는 메모리 영역에는 다른 포인터로 접근하지 않는다”는 것을 컴파일러에게 알려줘서 앨리어싱(aliasing) 관련 최적화(레지스터 캐싱, 재정렬 등)를 가능하게 하는 용도입니다. 널 여부와는 무관합니다.
nonnull 경고의 실제 원인: glibc의 string.h에서 strcpy 선언에 __restrict와는 별개로 __attribute__((__nonnull__(1, 2))) 속성이 추가로 붙어 있기 때문입니다. 두 속성이 같은 선언에 같이 등장해서 헷갈리기 쉽지만, 논리적으로는 독립적입니다.
c
extern char *strcpy (char *__restrict __dest, const char *__restrict __src)
__THROW __nonnull ((1, 2)); // 이 부분이 컴파일 시 NULL 리터럴 전달을 경고
-Werror=nonnull은 정적 분석 수준의 계약(contract) 위반 경고이지, 런타임에 glibc가 실제로 NULL 체크를 한다는 뜻은 아닙니다. strcpy(NULL, NULL)을 실행하면 glibc는 검사 없이 그대로 역참조(dereference)해서 세그폴트가 납니다.
asm(NASM)에서는 어떻게 되나요?
__restrict, __nonnull 같은 속성은 GCC/Clang 프론트엔드(C 컴파일러) 수준의 개념입니다. 어셈블리어에는 타입 시스템도, 속성(attribute) 시스템도 존재하지 않으므로:
ft_strcpy를 NASM으로 구현할 때 이런 속성을 선언할 방법 자체가 없습니다 — 해당 없음.
실제 glibc strcpy도 런타임에 NULL 체크를 하지 않으므로, ft_strcpy도 원본과 동일한 동작(NULL 전달 시 UB/세그폴트) 을 따르는 것이 일반적인 42 과제 기준입니다. 굳이 방어적으로 NULL 체크 로직을 추가할 필요는 없습니다(과제 subject에서 명시적으로 요구하지 않는 한).
다만 restrict가 암시하는 “두 버퍼가 겹치지 않는다”는 가정 자체는 asm 구현에서도 유효한 전제 조건입니다 — 즉 dest와 src가 겹치는 경우(overlap)의 동작은 정의되지 않은 채로 두고, memcpy처럼 전방향으로 단순 복사하는 구현이면 충분합니다(overlap 처리가 필요한 건 memmove 쪽입니다).
정리하면: 경고의 원인은 nonnull 속성이지 restrict가 아니며, 두 속성 모두 asm 레벨에는 대응 개념이 없어 신경 쓰실 필요가 없습니다.
./test_strcpy
src:
dest_mine: , mine:
dest_libc: , libc:
OK
src: a
dest_mine: a, mine: a
dest_libc: a, libc: a
OK
src: hello
dest_mine: hello, mine: hello
dest_libc: hello, libc: hello
OK
src: 42gyeongsan
dest_mine: 42gyeongsa42gyeongsan, mine: 42gyeongsa42gyeongsan
dest_libc: 42gyeongsan, libc: 42gyeongsan
FAIL
src: yuna
dest_mine: yuna, mine: yuna
dest_libc: yuna, libc: yuna
OK
make: *** [Makefile:12: strcpy] Segmentation fault (core dumped)
세그폴트는 예상하고 있었으니, FAIL이 뜨는 “42gyeongsan” 케이스를 보자.
“42gyeongsan”은 11글자, ‘\0’ 포함 12글자인데, dest_mine은 10칸이다.
일단, dest의 길이를 알 방법이 없으니, ‘\0’이 나올 때까지 복사해야 된다는 사실은 알겠다.
짐작가는 바가 없는 건 아니다.
메모리 상에 dest_mine[10] dest_libc[10]이 순서대로 있다. 직전에 양쪽 모두 “hello\0″를 채웠고, 나머지는 모두 ‘\0’으로 초기화 되어 있다고 가정했을 때, dest_mine[0]부터 “42gyeongsan”을 순서대로 채워 “42gyeongsa”까지 채우고, src가 끝나지 않았잖아. 그러니까 “n\0″까지 채워야 하지 않나?
그럼 말이 안되지 않나….
아니면 printf 버퍼링 때문에, dest_mine [0]부터 범위초과하여 “n\0″까지 “42gyeongsan\0″을 잘 채웠으나, dest_libc [0]부터 범위초과하여 “n\0″까지 “42gyeongsan\0″을 채운 후, dest_mine의 printf 출력, dest_libc의 printf 출력이 되었다면?
그렇다면 dest_mine에 적었으나 dest_libc 구역에 적힌 “n\0″이, “42”로 덮어써졌을 수 있지.
검증을 어떻게 해야할 지 모르겠다.
당장 클로드한테 먹이고 싶은데, 조금만 참아보자.
printf 대신 write 내지는 puts(자동 줄바꿈), fputs(자동 줄바꿈 x)??
유나님, 원인을 찾으셨네요. 결론부터 말씀드리면 ft_strcpy 자체의 버그가 아니라 테스트 코드의 버퍼 크기 설계 문제입니다.
항목
문제
근거
버퍼 크기 부족
dest_mine[10], dest_libc[10]인데 "42gyeongsan"은 11글자 + '\0' = 12바이트 필요
10 < 12 → 두 버퍼 모두 스택 오버플로우(UB) 발생. 이건 ft_strcpy와 libc strcpy 둘 다 마찬가지입니다
UB 결과 비교의 무의미함
strcmp 결과가 매번 다르게 나오는 이유
정의되지 않은 동작(UB)이라 컴파일러의 스택 배치, 정렬(padding), 두 함수의 실제 쓰기 방식 차이에 따라 “어느 쪽이 어느 쪽을 덮어쓰는지”가 갈립니다. glibc strcpy는 SIMD/워드 단위로 최적화되어 있어 바이트 단위로 도는 어셈블리 구현과 오버플로우 패턴 자체가 다릅니다
temp1/temp2 삽입이 무효했던 이유
변수를 선언 순서대로 추가했는데도 결과 불변
컴파일러는 지역변수를 소스 선언 순서대로 스택에 배치한다는 보장이 없습니다(정렬/최적화로 재배치 가능). 이 방식으로는 “완충지대”를 안정적으로 만들 수 없습니다
세그폴트(NULL 전달)
예상하신 대로 정상
strcpy(NULL, ...)은 UB이며 glibc는 NULL 역참조로 크래시하는 게 일반적인 동작이라 문제 없습니다
결론: 지금 나오는 FAIL은 ft_strcpy 구현 오류를 증명하는 신호가 아니라, 애초에 비교 자체가 성립하지 않는(UB) 테스트입니다.
권고
src 배열의 각 문자열 길이보다 넉넉한 버퍼로 교체하세요. 가장 간단한 수정:
c
char dest_mine[32];
char dest_libc[32];
더 엄밀하게 검증하고 싶으시면 반복문 안에서 strlen(src[i]) + 1 만큼 malloc해서 매번 정확한 크기로 비교하는 것도 방법입니다.
오버플로우 자체를 테스트하고 싶으신 거라면(예: dest가 작을 때 어떻게 동작해야 하는지) — 사실 strcpy류 함수는 원래 dest 크기를 보장하지 않는 함수라 “오버플로우 시 정상 동작”이라는 개념 자체가 없습니다. 그건 별도로 ft_strncpy 같은 안전 버전에서 다룰 문제입니다.
버퍼 키운 뒤에도 FAIL이 나온다면, 그때는 진짜 ft_strcpy 로직 문제일 가능성이 높으니 그 결과를 다시 가져와 주세요.
지금까지의 추론 과정(오버플로우 의심 → printf 버퍼링 의심 → temp 변수로 격리 시도)은 방향 자체는 나쁘지 않았습니다. 다만 “버퍼 크기가 애초에 부족하다”는 가장 근본적인 지점을 먼저 확인하지 않고 우회로부터 시작하신 게 시간을 더 쓰게 만든 지점입니다.
버퍼 크기가 부족할 때 동작이 같은지 비교하고 싶어서 의도적으로 부족하게 설정한 건데, 애초에 UB라면 이 테스트는 건너뛰어도 될까?
10:39 AM
건너뛰는 게 맞습니다. 근거는 다음과 같습니다.
libc strcpy 자체가 dest 크기를 검사하지 않는 함수이므로, “버퍼 부족 시 동작 비교”라는 테스트 목표 자체가 성립하지 않습니다. libc도 오버플로우 시 정의되지 않은 동작을 하므로 비교 기준(oracle)이 없습니다.
42 서브젝트의 ft_strcpy 요구사항은 대개 “strcpy와 동일하게 동작”이며, 이는 정상 크기의 dest를 전제로 한 계약입니다. 부족한 버퍼는 애초에 함수 계약 위반(호출자 책임)이지 구현체가 책임질 범위가 아닙니다.
오버플로우 자체를 검증하고 싶다면, strcpy/ft_strcpy가 아니라 길이 제한이 있는 함수(strncpy 등) 로 테스트 설계를 바꿔야 의미 있는 비교가 됩니다.
대안: 이 테스트 케이스는 제거하고, 대신 아래 두 가지로 대체하는 걸 권합니다.
목적
방법
정상 동작 검증
dest 크기를 strlen(src)+1 이상으로 넉넉히 확보
edge case 검증
빈 문자열(""), 1글자, NULL 처리(단, NULL은 세그폴트 예상되므로 별도 분리 실행 또는 fork/exec로 격리 테스트)
NULL 케이스도 지금처럼 한 바이너리 안에서 연속 실행하면 세그폴트로 나머지 테스트가 다 날아가니, 별도 실행 파일로 분리하거나 fork()로 감싸는 걸 고려해보세요.
section .text
global ft_strcpy
ft_strcpy:
xor rax, rax
.loop:
mov cl, [rsi+rax]
mov [rdi+rax], cl
cmp byte [rsi+rax], 0
je .break
inc rax
jmp .loop
.break:
mov rax, rdi
ret
항목
문제
근거
cmp byte [rsi+rax], 0
치명적 오류는 아니나 비효율
cl에 이미 읽어온 값과 [rsi+rax]가 동일한데 메모리를 다시 읽습니다. cmp cl, 0 또는 test cl, cl로 대체하면 메모리 접근 한 번을 줄일 수 있습니다. 다만 libasm 채점 기준상 정답 여부에는 영향 없음
검증 트레이싱 (src = "a\0")
rax
cl
dest에 쓴 값
cmp 결과
분기
0
‘a’
‘a’
‘a’ ≠ 0
inc, jmp .loop
1
0
0
0 = 0
je .break
널 종료 문자가 dest에 정확히 복사된 후 종료됩니다. 로직 자체는 통과입니다.
보완 권고
(선택) cmp byte [rsi+rax], 0 → test cl, cl로 바꿔 메모리 재접근을 줄이세요. 필수는 아닙니다.
test cl, cl은 무슨 뜻인가요? cl은 무슨 레지스터인가요?
11:23 AM
cl 레지스터
cl은 rcx의 하위 8비트입니다. x86-64는 하나의 물리적 레지스터를 크기별로 여러 이름으로 부를 수 있게 해줍니다.
이름
크기
rcx와의 관계
rcx
64비트
전체
ecx
32비트
rcx의 하위 32비트
cx
16비트
rcx의 하위 16비트
cl
8비트
rcx의 하위 8비트
문자열 한 글자(char)는 8비트이므로, [rsi+rax]에서 읽어온 한 바이트를 담을 때 mov cl, [rsi+rax]처럼 cl을 쓴 것입니다. rcx 전체(64비트)를 쓸 이유가 없습니다.
test cl, cl
test는 두 피연산자를 AND 연산한 뒤 결과를 버리고, 플래그(특히 ZF, 제로 플래그)만 갱신하는 명령입니다. test cl, cl은 cl과 자기 자신을 AND하는 것이므로, cl이 0이면 결과도 0 → ZF=1이 되고, cl이 0이 아니면 ZF=0이 됩니다. 즉 “cl이 0인가?”를 검사하는 관용적인 방법입니다.
cmp cl, 0도 같은 결과(ZF 갱신)를 냅니다. 차이는 바이트코드 크기와 실행 사이클인데, test가 즉시값 0을 인코딩할 필요가 없어 미세하게 더 짧습니다. 실질적 동작 차이는 없으니, 지금 단계에서는 cmp cl, 0을 써도 무방합니다 — 다만 어셈블리 코드를 읽다 보면 test reg, reg 패턴을 “0인지 검사”로 즉시 알아보는 게 앞으로 도움이 될 것입니다.
section .text
global ft_strcpy
ft_strcpy:
xor rax, rax
.loop:
mov cl, [rsi+rax]
mov [rdi+rax], cl
test cl, cl
je .break
inc rax
jmp .loop
.break:
mov rax, rdi
ret
항목
문제
근거
없음
—
test cl, cl로 이전 지적사항을 정확히 반영했습니다. 널 종료 문자 복사, 반환값 처리, 라벨 스코프 모두 이전 검증에서 확인된 대로 유지되어 있습니다
로직 결함 없음. 어셈블·링크·테스트 단계로 진행하시면 됩니다.
테스트 코드 작성했습니다 (생략)
항목
문제
근거
반환값 검증 누락
메인 루프에서 mine, libc를 strcmp로 내용만 비교하고, mine == dest_mine인지(즉 dest 포인터 자체를 반환하는지)는 검증하지 않음
표준 strcpy는 dest 포인터 반환이 계약의 일부입니다. 내용이 맞아도 반환값이 dest가 아니면 규격 위반인데, 현재 테스트는 이 결함을 통과시킬 수 있습니다
NULL 케이스 결과가 fail 플래그에 미반영
test_null_case의 반환값(세그폴트 여부)을 변수에 저장하지 않고 그냥 호출만 함
함수는 int를 반환하도록 설계했지만 호출부에서 버려지므로, ft_strcpy와 strcpy가 같은 조건에서 세그폴트 여부가 다르더라도(예: 한쪽만 크래시) 프로그램 종료 코드(fail)에는 전혀 반영되지 않습니다. 사람이 화면 출력을 눈으로 대조해야만 알 수 있는 구조입니다
버퍼 크기 여유 없음
dest_mine[12], dest_libc[12]가 "42gyeongsan"(11자+널=12) 기준으로 정확히 맞춤
지금은 오버플로우가 없지만 여유가 0바이트입니다. 이후 테스트 케이스에 12자 이상 문자열이 하나라도 추가되면 즉시 스택 버퍼 오버플로우가 발생하며, 이런 종류의 실수는 나중에 디버깅하기 매우 어렵습니다
SIGSEGV==%d 출력 메시지
실제 비교가 아니라 SIGSEGV 매크로의 값만 그대로 출력
WTERMSIG(status)와 SIGSEGV를 비교한 결과가 아니라 SIGSEGV 상수 값(보통 11)을 단순 출력하는 것이므로, 다른 시그널(예: SIGBUS)로 죽었을 때도 이 줄만 보면 SIGSEGV로 착각할 수 있는 표현입니다
문법적으로 유효하고 정상 동작하지만, 명시적으로 char *(*func)(char *, const char *)로 쓰는 것이 관례이며 가독성이 더 좋습니다. 버그는 아니고 스타일 문제입니다
보완 권고
메인 루프에 int ret_ok = (mine == dest_mine);를 추가하고, fail 판정에 strcmp 결과와 함께 반영하세요.
test_null_case의 반환값을 int r_mine = test_null_case(...), int r_libc = test_null_case(...)처럼 저장한 뒤 r_mine == r_libc인지 비교해서 fail에 반영하세요. (둘 다 세그폴트하거나 둘 다 안 하거나가 “정답”입니다.)
dest_mine, dest_libc 크기를 12 → 32 정도로 늘려 향후 테스트 케이스 추가에 대비하세요.
출력 문구를 "%s: 시그널 %d로 종료됨 (예상: SIGSEGV=%d, 일치=%d)\n"처럼 실제 일치 여부를 boolean으로 명시하도록 바꾸세요.
(선택) 함수 포인터 파라미터를 명시적 포인터 문법으로 바꾸세요.
1~2번은 이 테스트 파일의 목적(ft_strcpy와 libc strcpy의 동작 대칭성 검증)을 실제로 자동화하는 데 필수적인 부분이니 우선 반영을 권장합니다.
3번은 버퍼 크기 여유 없을 시 테스트를 위함입니다. 함수 포인터 파라미터를 명시적 포인터 문법으로 바꾸는 방법에 대해 알려주세요
11:44 AM
3번에 대한 정정
의도를 확인했습니다. 여유 없는 버퍼 크기가 실수가 아니라 오버플로우 상황을 의도적으로 재현하기 위한 설계라면, 현재 크기(12)는 적절합니다. 이전 권고는 철회합니다.
각 룰이 .o, 실행파일을 만들지만 make의 자동 변수($@, $<) 미사용 → 오타 위험, 유지보수 비용 증가
Make 관용구
clean/fclean 역할
clean은 .o만, fclean은 find로 나머지 정리 — 방식 자체는 42 관례상 무방하나 .o도 find 기반으로 통일하면 더 일관적
스타일
후행 공백
일부 커맨드 줄 끝에 트레일링 스페이스 존재
가독성/코드 품질
질문: “Makefile로 하는 게 맞을까?”
네, 맞습니다. Make의 원래 목적(파일 갱신 시각 기반 재빌드)에는 딱 맞지 않지만, 42 계열 프로젝트에서 make strlen처럼 개별 테스트를 실행하는 phony target 패턴은 표준 관례입니다. 다만 그렇다면 .PHONY를 명시해서 “이건 파일이 아니라 커맨드 묶음”이라는 의도를 make에게 정확히 알려주는 것이 필수입니다. 지금 코드는 이 의도가 우연히 맞아떨어지는 상태입니다.
당신은 나와 단 4시간만 함께할 수 있고, 다시는 나를 보지 못하는 선생님입니다. 당신의 유일한 목표는 시간이 끝나기 전에 내가 [nasm으로 컴파일되는 64비트 인텔문법 어셈블리]을 실제로 사용할 수 있게 만드는 것입니다. 실용성이 없는 이론은 주지 마세요. 단순한 목록도 주지 마세요. 내게 알려주세요. 무엇을 가장 먼저 배워야 하는지, 무엇은 완전히 무시해야 하는지, 그리고 단 한 번만 해도 몇 달 동안 공부한 사람들 70%보다 앞서게 만드는 연습은 무엇인지
10:55 PM
유나님, 4시간이면 충분합니다. 대신 딴 길로 새지 않는다는 조건에서요.
가장 먼저, 그리고 유일하게 외워야 할 것은 System V AMD64 호출 규약입니다. 함수 인자는 순서대로 rdi, rsi, rdx, rcx, r8, r9에 담기고, 반환값은 rax에 담깁니다. 이것 하나만 몸에 배면 ft_strlen이든 ft_strcpy든 뭘 구현하든 “지금 이 레지스터에 뭐가 들어와야 하지”가 자동으로 떠오릅니다. syscall을 직접 부를 때는 이 규약이 살짝 바뀝니다 — rcx 대신 r10을 씁니다, 그리고 rax에는 인자가 아니라 syscall 번호가 들어갑니다. ft_write, ft_read를 만들 때 헷갈리는 지점이 바로 여기니까, “함수 호출용 rcx”와 “syscall용 r10″을 지금 확실히 분리해두세요.
완전히 무시해도 됩니다: SSE/AVX 같은 벡터 명령어, 부동소수점, 세그먼트 레지스터, 보호모드/리얼모드 이론, 그리고 NASM 매크로 시스템. libasm 여섯 개 함수엔 하나도 안 씁니다. mov, add, sub, cmp, jmp 계열 조건분기(je, jne, jl 등), inc/dec, 그리고 [rdi], [rdi+rax], byte [rdi] 정도의 메모리 주소 지정 방식만 있으면 여섯 함수 전부 끝납니다. “이 명령어도 배워야 하나?” 싶은 게 나오면 십중팔구 안 배워도 됩니다.
단 한 번으로 몇 달 공부한 사람 70%를 앞서는 연습은 이겁니다: ft_strlen을 지금 이 자리에서, 아무 참고 없이, 손으로 레지스터 상태를 종이에 추적하면서 짜보세요. rdi에 문자열 포인터가 들어온다, rax를 0으로 초기화한다, [rdi+rax] 바이트를 읽어서 0인지 비교한다, 아니면 rax를 증가시키고 루프, 0이면 rax를 반환한다 — 이 흐름을 코드로 옮기기 전에 레지스터 값이 매 루프마다 어떻게 바뀌는지 손으로 세 번 트레이싱하세요. 대부분의 사람은 어셈블리를 “C를 번역하는 언어”로 생각하고 코드부터 씁니다. 그러면 디버깅 지옥에 빠집니다. 반대로 레지스터 상태를 먼저 손으로 추적하는 사람은 애초에 버그를 안 만듭니다. 이게 진짜 격차입니다.
이제 ft_strlen부터 시작해볼까요? 유나님이 먼저 레지스터 흐름을 말로 설명해보시고, 제가 바로 잡아드리겠습니다.
다음에서 왜 rax를 쓰는거야? rdi는 함수 인자니까 쓰는 거 ok. 근데 왜 다른 변수 말고 rax인 게 아니라 반환값이라 rax구나. 맞아?
네, 정확히 맞습니다. (생략)
rdi에 인자로 char *str이 들어옴
rax를 0으로 초기화 (int i 를 리턴할 예정이니 편의상 반환값에 쓰는 레지스터 rax를 사용)
레이블: [rdi+rax]가 0이면 rax리턴
++rax
goto 레이블
return rax
11:17 PM
논리 흐름 자체는 정확합니다. 다만 이걸 실제 NASM 코드로 옮기기 전에, 지금 단계에서 이미 보이는 문제점들을 짚고 넘어가겠습니다.
항목
문제
근거
메모리 비교 크기
[rdi+rax]가 0이면을 코드로 옮길 때 cmp byte [rdi+rax], 0처럼 크기 지정자(byte)를 반드시 명시해야 함
NASM은 메모리 피연산자와 즉시값을 비교할 때 피연산자 크기를 유추할 수 없어 cmp [rdi+rax], 0만 쓰면 어셈블 에러가 남
rax 초기화 방식
“rax를 0으로 초기화”를 mov rax, 0으로 쓸 계획이라면 비효율적
xor rax, rax가 같은 결과를 더 짧은 바이트코드로 냄. 관례상 레지스터 0 초기화는 xor을 씀
함수 진입/종료
스택 프레임(push rbp / mov rbp, rsp) 언급이 없음
ft_strlen은 리프 함수(다른 함수를 호출하지 않음)이므로 스택 프레임이 아예 필요 없음. 오히려 안 만드는 게 맞음 — 이 판단 자체는 맞게 하신 것
라벨 스코프
“레이블”이라고만 했는데 실제 코드에서 지역 라벨(.loop:)로 쓸지 전역 라벨로 쓸지 결정 안 됨
함수 하나짜리 파일에서도 .loop:처럼 점(.)으로 시작하는 지역 라벨 관례를 들이는 게 나중에 6개 함수를 한 파일에 모을 때 이름 충돌을 막음
반환
return rax를 ret으로 쓸 계획인지 확인 필요
확인 필요: System V 규약상 rax에 값이 이미 들어있으면 ret만 실행하면 됩니다. 별도 mov 불필요
보완 권고
cmp 앞에 byte 크기 지정자를 붙이세요.
mov rax, 0 대신 xor rax, rax로 바꾸세요.
라벨은 .loop:처럼 점으로 시작하는 지역 라벨을 쓰세요.
이제 이 다섯 가지를 반영해서, 위 의사코드를 실제 NASM 문법으로 직접 써보세요. global ft_strlen, section .text까지 포함해서요. 제가 한 줄씩 검토하겠습니다.
나 asm 짜본 적 한번도 없는데ㅜㅜㅜㅜㅜ 너무 막막하도다 하지만 해볼게? 틀리면 알려주겠지 모.
section .text
global ft_strlen
ft_strlen:
xor rax, rax
.loop:
cmp byte [rdi+rax], 0
je break
inc rax
jne .loop
.break:
ret
이걸 진짜 알려주네;;;
항목
문제
근거
je break
정의되지 않은 라벨 break를 참조함
실제 정의된 라벨은 .break:(점 포함, ft_strlen 스코프의 지역 라벨)인데 점 없이 break라고 쓰면 NASM은 이를 별개의 전역 라벨로 인식하고, 해당 이름이 어디에도 정의되어 있지 않으므로 어셈블 에러가 발생함
jne .loop
의도한 “무조건 루프로 복귀”가 아니라 inc rax가 남긴 ZF 플래그에 의존하는 조건 분기가 됨
cmp 다음에 inc rax가 실행되면서 CMP의 플래그는 이미 덮어써짐. jne는 그 직전 inc rax의 결과가 0이 아닐 때만 점프하는데, inc가 0을 만드는 경우는 rax가 0xFFFFFFFFFFFFFFFF에서 오버플로될 때뿐임. 즉 현재 코드는 “문자열이 끝났는지”와 무관한 조건으로 루프를 돌고 있고, 우연히 정상 동작하는 것처럼 보일 뿐 논리적으로는 틀림. 의도는 조건 없는 복귀이므로 jmp가 맞음
보완 권고
je break → je .break로 수정하세요. (지역 라벨은 점을 빠뜨리면 안 됩니다.)
jne .loop → jmp .loop로 수정하세요. cmp의 판단은 이미 je .break에서 끝났고, 그 뒤는 무조건 복귀입니다.
수정하면 이렇게 됩니다: (생략)
이 두 가지 실수 — 점 빠뜨린 라벨 참조, jmp 자리에 조건 분기 재사용 — 는 어셈블리 초보자가 가장 흔하게 저지르는 실수 유형입니다. 지금 잡아두면 나머지 다섯 함수에서 똑같은 패턴을 반복하지 않게 됩니다.
이제 이 코드를 WSL2에서 직접 어셈블·링크해서 실행까지 해보시겠습니까? 아니면 바로 ft_strcpy로 넘어가시겠습니까?
고친 코드
section .text
global ft_strlen
ft_strlen:
xor rax, rax
.loop:
cmp byte [rdi+rax], 0
je .break
inc rax
jmp .loop
.break:
ret
(내 pc에서 내가 직접) (ubuntu에서 실행. wsl 아님) (-no-pie 플래그 금지) (ar rcs libasm.a objs/ft_strlen.o 실행한 뒤 테스트 방법 알려줘) 어셈블 링크해서 실행해보자
괄호1: 안 붙이니까 자기가 실행하더라구..
(생략)
2. 테스트용 C 하네스 작성
ft_strlen이 어셈블리로만 있고 검증 기준이 없으면 신뢰할 수 없습니다. libc의 strlen과 대조하는 방식이 가장 확실합니다.
// test_strlen.c
#include <stdio.h>
#include <string.h>
extern unsigned long ft_strlen(const char *str);
int main(void)
{
char *tests[] = {"", "a", "hello", "42 gyeongsan", "yuna"};
int fail = 0;
for (int i = 0; i < 5; i++) {
unsigned long mine = ft_strlen(tests[i]);
unsigned long libc = strlen(tests[i]);
printf("\"%s\" -> mine=%lu libc=%lu %s\n",
tests[i], mine, libc, mine == libc ? "OK" : "FAIL");
if (mine != libc) fail = 1;
}
return fail;
}
항목
근거
반환 타입을 unsigned long로 선언
rax 64비트 전체를 그대로 받기 위함. int로 선언하면 상위 32비트가 잘려 큰 문자열에서 오검증 발생 가능
빈 문자열 "" 포함
루프 진입 즉시 종료 조건을 만족하는 경계값 — 어셈블리 초기 구현에서 가장 자주 놓치는 케이스