2026.07.03, 03:15
잘 된다. ft_strcpy 해보자
ft_strcpy 핵심 함정
이번엔 반환값 처리가 다릅니다.
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 문자열이 끝나지 않아 쓰레기 메모리까지 읽는 훨씬 위험한 버그가 됩니다.)위 질문에 답하신 뒤, 직접 NASM 코드를 작성해보세요.
카페인 알약을 반 알 먹고 왔어요.
눈이 슬슬 감겨요
잠이 와요
머리가 기울어요
안되겠다 30분만 자고올게요 3:32
그리고 5:06이 되었던 건에 대하여….
히히 딴짓 재밌다..
아니오?? 공부입니다만.
먼저, 질문이 있습니다.
- 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 .loopcmp의 판단 결과가 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 돌려서 직접 디버깅을 해보기로 했어요.
argument 1 null where non-null expected [-Werror=nonnull] 32 | char *libc = strcpy(NULL, NULL); extern char *strcpy (char *__restrict __dest, const char *__restrict __src)
매개변수 접두어로 __restrict __를 사용하면 이 매개변수는 nonnull이라고 선언하는 건가요? asm에서는 어떻게 사용하나요?
8:28 AM
__restrict와nonnull은 서로 다른 별개의 속성입니다.
__restrict(C99restrict한정자): 순수히 컴파일러 최적화 힌트입니다. “이 포인터가 가리키는 메모리 영역에는 다른 포인터로 접근하지 않는다”는 것을 컴파일러에게 알려줘서 앨리어싱(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 레벨에는 대응 개념이 없어 신경 쓰실 필요가 없습니다.
아하! -Werror를 끄고 테스트하면 되겠구나!!
테스트 파일을 조금 수정했다.
// test_strcpy.c
#include <stdio.h>
#include <string.h>
extern char *ft_strcpy(char *dest, const char *src);
int main(void)
{
char *src[] = {"", "a", "hello", "42gyeongsan", "yuna", NULL};
char dest_mine[10];
char dest_libc[10];
int fail = 0;
char *mine;
char *libc;
int i = 0;
for (; i < 5; ++i) {
mine = ft_strcpy(dest_mine, src[i]);
libc = strcpy(dest_libc, src[i]);
printf("src: %s\n", src[i]);
printf("dest_mine: %s, mine: %s\n", dest_mine, mine);
printf("dest_libc: %s, libc: %s\n", dest_libc, libc);
printf(strcmp(dest_mine, dest_libc) ? "FAIL" : "OK");
printf("\n\n");
if (strcmp(dest_mine, dest_libc)) fail = 1;
}
mine = ft_strcpy(NULL, src[i-1]);
libc = strcpy(NULL, src[i-1]);
printf("src: %s\n", src[i]);
printf("dest_mine: %s\n, mine: %s", dest_mine, mine);
printf("dest_libc: %s\n, libc: %s", dest_libc, libc);
printf(strcmp(dest_mine, dest_libc) ? "FAIL" : "OK");
printf("\n\n");
if (strcmp(dest_mine, dest_libc)) fail = 1;
mine = ft_strcpy(NULL, NULL);
libc = strcpy(NULL, NULL);
printf("src: %s\n", src[i]);
printf("dest_mine: %s\n, mine: %s", dest_mine, mine);
printf("dest_libc: %s\n, libc: %s", dest_libc, libc);
printf(strcmp(dest_mine, dest_libc) ? "FAIL" : "OK");
printf("\n\n");
if (strcmp(dest_mine, dest_libc)) fail = 1;
return (fail);
}
결과
./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)??
문제를 찾았다.
printf("src: %s\n", src[i]);
mine = ft_strcpy(dest_mine, src[i]);
fputs("dest_mine: ", stdout);
fputs(dest_mine, stdout);
fputs(", mine: ", stdout);
fputs(mine, stdout);
fputs("\n", stdout);
libc = strcpy(dest_libc, src[i]);
fputs("dest_libc: ", stdout);
fputs(dest_libc, stdout);
fputs(", libc: ", stdout);
fputs(libc, stdout);
fputs("\n", stdout);
이렇게 고치니까 결과는 똑같이 나온다.
src: 42gyeongsan
dest_mine: 42gyeongsan, mine: 42gyeongsan
dest_libc: 42gyeongsan, libc: 42gyeongsan
FAIL
다만 FAIL이 뜨는 건 dest_libc에 strcpy할 때 dest_mine에 복사된 ‘\0’이 지워져서 그렇다.
printf 보다는 dest size가 src size보다 작을 때 strcpy를 하면 생길 수 있는 문제라고 생각한다.
굳이굳이 OK라는 결과를 보고 싶다면,
char dest_mine[10];
char temp1[2];
char dest_libc[10];
char temp2[2];
변수 선언부에 temp1, 2를 추가했는데 변한게 없다. 가설이 틀린 듯 하다.
src: 42gyeongsan
dest_mine: 42gyeongsa42gyeongsan, mine: 42gyeongsa42gyeongsan
dest_libc: 42gyeongsan, libc: 42gyeongsan
FAIL
혹시나 해서 추가해봤는데 아니다
char dest_mine[10];
char temp1[2];
char dest_libc[10];
char temp2[2];
temp1[0] = '\0';
temp1[1] = '\0';
temp2[0] = '\0';
temp2[1] = '\0';
나 이정도면 많이 한 거 같아… 클로드님 도와주세요..
유나님, 원인을 찾으셨네요. 결론부터 말씀드리면 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()로 감싸는 걸 고려해보세요.
7/3 10부터 계속 잔사람..
7/4 10:30
최종 test_strcpy.c
// test_strcpy.c
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
extern char *ft_strcpy(char *dest, const char *src);
int test_null_case(char *func_name, char *func(char *, const char *), char *dest, char *src);
int main(void)
{
char *src[] = {"", "a", "hello", "42gyeongsan", "yuna"};
char dest_mine[12];
char dest_libc[12];
int fail = 0;
char *mine;
char *libc;
int i = 0;
for (; i < 5; ++i) {
mine = ft_strcpy(dest_mine, src[i]);
libc = strcpy(dest_libc, src[i]);
printf("src: %s\n", src[i]);
printf("dest_mine: %s, mine: %s\n", dest_mine, mine);
printf("dest_libc: %s, libc: %s\n", dest_libc, libc);
printf(strcmp(dest_mine, dest_libc) ? "FAIL" : "OK");
printf("\n\n");
if (strcmp(dest_mine, dest_libc)) fail = 1;
}
//fork() SEGFAULT tests
//(NULL, "yuna")
printf("(NULL, \"yuna\")\n");
test_null_case("ft_strcpy", ft_strcpy, NULL, "yuna");
test_null_case("strcpy", strcpy, NULL, "yuna");
printf("\n\n");
//(dest, NULL)
printf("(dest, NULL)\n");
test_null_case("ft_strcpy", ft_strcpy, dest_mine, NULL);
test_null_case("strcpy", strcpy, dest_libc, NULL);
printf("\n\n");
//(NULL, NULL)
printf("(NULL, NULL)\n");
test_null_case("ft_strcpy", ft_strcpy, NULL, NULL);
test_null_case("strcpy", strcpy, NULL, NULL);
printf("\n\n");
return (fail);
}
int test_null_case(char *func_name, char *func(char *, const char *), char *dest, char *src)
{
pid_t pid = fork();
if (pid == 0) {
func(dest, src);
_exit(0);
} else {
int status;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
printf("%s: 자식이 시그널 %d로 종료됨 (SIGSEGV==%d)\n",
func_name, WTERMSIG(status), SIGSEGV);
return 1; // 세그폴트
}
return 0;
}
}
최종 ft_strcpy.s
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 *)는 암묵적 함수-포인터 decay에 의존한 표기문법적으로 유효하고 정상 동작하지만, 명시적으로 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)는 적절합니다. 이전 권고는 철회합니다.
함수 포인터 명시적 문법
현재 코드:
c
int test_null_case(char *func_name, char *func(char *, const char *), char *dest, char *src);명시적 문법으로 바꾸면:
c
int test_null_case(char *func_name, char *(*func)(char *, const char *), char *dest, char *src);
차이점 설명 char *func(...)C 문법상 함수 파라미터 자리에서 “함수 타입”을 쓰면 컴파일러가 자동으로 “함수 포인터 타입”으로 변환(decay)해줍니다. 동작은 동일하지만, 선언만 보면 “함수를 값으로 받는다”는 착각을 줄 수 있습니다 char *(*func)(...)괄호 (*func)가 “func는 포인터다”를 먼저 명시하고, 그 포인터가 가리키는 대상이char *(...)를 반환하는 함수임을 나타냅니다. 실제 메모리 상에서 무엇이 전달되는지(함수의 시작 주소) 그대로 드러나는 표기입니다읽는 순서 팁:
char *(*func)(char *, const char *)는 안쪽*func부터 시작해서 바깥으로 읽습니다.
*func→ func는 포인터다(*func)(char *, const char *)→ 그 포인터는(char *, const char *)를 인자로 받는 함수를 가리킨다char *(*func)(...)→ 그 함수는char *를 반환한다→ “func는, (char*, const char*)를 받아 char*를 반환하는 함수를 가리키는 포인터다”
함수 호출부(
test_null_case("ft_strcpy", ft_strcpy, NULL, "yuna"))는 이 변경과 무관하게 그대로 두시면 됩니다. 함수 이름은 어차피 배열처럼 함수 포인터로 decay되어 전달되기 때문입니다.
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_strcpy.c
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <signal.h>
extern char *ft_strcpy(char *dest, const char *src);
int test_null_case(char *func_name, char *(*func)(char *, const char *), char *dest, char *src);
int main(void)
{
char *src[] = {"", "a", "hello", "42gyeongsan", "yuna"};
char dest_mine[12];
char dest_libc[12];
int fail = 0;
char *mine;
char *libc;
int ret_cmp;
int ret_ok;
int i = 0;
for (; i < 5; ++i) {
mine = ft_strcpy(dest_mine, src[i]);
libc = strcpy(dest_libc, src[i]);
printf("src: %s\n", src[i]);
printf("dest_mine: %s, mine: %s\n", dest_mine, mine);
printf("dest_libc: %s, libc: %s\n", dest_libc, libc);
ret_cmp = strcmp(dest_mine, dest_libc);
ret_ok = (mine == dest_mine);
printf("strcmp(dest_mine, dest_libc): ");
printf(ret_cmp ? "FAIL" : "OK");
printf("\n(mine == dest_mine): ");
printf(ret_ok ? "OK" : "FAIL");
printf("\n\n");
if (ret_cmp || !ret_ok) fail = 1;
}
//fork() SEGFAULT tests
int r_mine;
int r_libc;
//(NULL, "yuna")
printf("(NULL, \"yuna\")\n");
r_mine = test_null_case("ft_strcpy", ft_strcpy, NULL, "yuna");
r_libc = test_null_case("strcpy", strcpy, NULL, "yuna");
printf(r_mine == r_libc ? "OK" : "FAIL");
if (r_mine != r_libc) fail = 1;
printf("\n\n");
//(dest, NULL)
printf("(dest, NULL)\n");
r_mine = test_null_case("ft_strcpy", ft_strcpy, dest_mine, NULL);
r_libc = test_null_case("strcpy", strcpy, dest_libc, NULL);
printf(r_mine == r_libc ? "OK" : "FAIL");
if (r_mine != r_libc) fail = 1;
printf("\n\n");
//(NULL, NULL)
printf("(NULL, NULL)\n");
r_mine = test_null_case("ft_strcpy", ft_strcpy, NULL, NULL);
r_libc = test_null_case("strcpy", strcpy, NULL, NULL);
printf(r_mine == r_libc ? "OK" : "FAIL");
if (r_mine != r_libc) fail = 1;
printf("\n\n");
printf("=== ");
printf(fail ? "FAIL" : "OK");
printf(" ===\n");
return (fail);
}
int test_null_case(char *func_name, char *(*func)(char *, const char *), char *dest, char *src)
{
pid_t pid = fork();
if (pid == 0) {
func(dest, src);
_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;
}
}
답글 남기기