[작성자:] insam16

  • [python]


    2026.08.20, 16:23


    참고자료: 최신 파이썬 코딩 무료 강의 | 2024 점프 투 파이썬 통합본

    >>> REPL

    Read Evaluate Print Loop

    파이썬 구문을 입력하면 바로 결과를 보여주고 다시 입력할 수 있는 도구

    자료형

    • 숫자, 문자열, 불
    • 변수, 리스트, 튜플, 딕셔너리, 집합

    숫자형

    • 정수형 int (1, 2, -2)
    • 실수 float (1.24, -34.56)
    • 컴퓨터식 지수 표현 방식 (4.24e10, 4.24e-10)
    • 8진수 (0o37)
    • 16진수 (0x7A)

    사칙연산 + – * /

    제곱 연산자 **

    나머지 연산자 %

    몫 연산자 //

    문자열 str

    따옴표로 감싸면 문자열

    "Hi"
    'I'
    """am"""
    '''insam16'''

    여러 줄인 문자열

    • 이스케이프 코드 \n 삽입
    • “””큰따옴표로 감싸기“””
    • ”’작은따옴표로 감싸기”’

    이스케이프 코드

    프로그래밍에 사용하는 미리 정의된 문자 조합

    주로 출력물을 보기 좋게 정렬하는 용도

    • \n 줄바꿈
    • \t
    • \\ \’ \” 두 번째 글자를 그대로 표현

    문자열 연산하기

  • 면접 복습


    2026.08.19, 21:51


    제가 발전해볼게요…

    js ts tsx 차이

    JS (.js)자바스크립트. 타입 검사 없음
    JSX (.jsx) JavaScript XML자바스크립트 안에서 <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 테스트

    사용자가 실제로 경험하는 흐름 전체를 처음부터 끝까지 검증하는 테스트

    전체 시나리오를 한 번에 검
    예: 회원가입 -> 로그인 -> 상품 장바구니 담기 -> 결제

    크롤링 (Crawling)

    웹을 자동으로 돌아다니며 페이지를 수집하는 행위

    검색엔진(구글 봇)이 웹 전체를 인덱싱하는 것이 대표적 예

    페이지 목록/URL 자체가 목적인 경우가 많음

    스크래핑 (Scraping)

    특정 페이지에서 원하는 데이터를 추출하는 행위

    예: 쇼핑몰 상품 페이지에서 가격, 재고 정보만 뽑아내기

    크롤링이 탐색 엔진이라면 스크래핑은 데이터 파서에 가깝다

    하네스 엔지니어링이란 (Harness Engineering)

    참고자료: 하네스 엔지니어링 15분 만에 이해시켜드립니다 | 개념, 핵심 원칙, AI 에이전트

    하네스

    원래 뜻은 (말의) 마구(말을 타거나 부리는 데 쓰는 기구; 굴레·안장·재갈 등)

    말(AI 모델)은 힘이 세지만 통제 장치(하네스)가 없으면 엉뚱한 방향으로 간다

    하네스 엔지니어링의 정의

    하네스를 체계적으로 설계하고 개선하는 기술

    하네스 엔지니어링이 부상한 이유

    • AI 모델의 상향 평준화 -> 모델을 둘러싼 시스템이 차이를 만든다
    • 에이전트가 데모에서 실무로 이동
    • 개념에 이름이 붙음

    하네스 엔지니어링이라는 이름

    미쉘 하시모토(하시코프 공동창업자, 테라폼 제작자)가 2026년 2월 블로그에서 사용

    오픈AI가 100만 줄 실험을 같은 제목으로 발표하여 업계 전체로 용어가 퍼진다.

    프롬프트 엔지니어링, 컨텍스트 엔지니어링, 하네스 엔지니어링

    프롬프트 엔지니어링

    • 뭘 말할까
    • (이메일 비유) 기깔나는 메일을 쓰기
    • 한계: 단발성 질의응답에 유효하나 장시간 복잡한 작업에 한계가 있다

    컨텍스트 엔지니어링

    • 뭘 보여줄까
    • (이메일 비유) 첨부파일 붙이기
    • 안드레아 카파시
    • 한계: 여전히 한번의 작업·한 번의 세션 단위에 머문다

    하네스 엔지니어링

    • 어떤 환경에서 일하게 할까
    • (이메일 비유) 그 사람이 일하는 사무실 전체(책상 배치·업무 규칙·보고 체계·교정 프로세스)를 설계
    • 하네스가 앞의 둘을 포함한다

    하네스를 이루는 네 기둥

    • 컨텍스트 엔지니어링: 컨텍스트에 없는 정보는 존재하지 않는 것과 같다
    • 아키텍처 제약: 좋은 코드를 ‘프롬프트(말)로 부탁’하는 대신 ‘기계적으로 강제’한다
    • 피드백 루프: AI는 자기 결과물을 스스로 제대로 평가하지 못한다
    • 엔트로피 관리: 정리 전담 에이전트를 주기적으로 돌린다

    1000페이지짜리 매뉴얼이 아니라 지도를 줘라

    오픈AI가 거대한 지시 파일 실패 후 내린 결론

    규칙을 한 파일에 몰아넣지 말고 분리한 뒤 그 위치만 안내한다

    마틴 파울러의 두 장치 – 가이드와 센서

    가이드: 에이전트가 행동하기 전에 방향을 잡아주는 장치

    센서: 행동한 후에 결과를 체크해 교정하는 장치

    둘 다 있어야 제대로 된 하네스

    피드백 루프 (Feedback Loop)

    에이전트의 판단을 자연어 주장이 아니라 실행 가능한 증거로 검증

    규칙을 시스템으로 강제할 때 사용할 수 있는 도구

    린터 (Linter): 코드 실행 없이 문법 오류, 스타일 위반, 잠재적 버그를 미리 잡아주는 정적 분석 도구

    테스트 (Test): 코드가 의도한대로 동작하는지 검증하는 코드

    CI (Continuous Integration, 지속적 통합): 코드를 push/PR할 때마다 자동으로 빌드·린트·테스트를 실행해주는 자동화 시스템

  • [ASM] 42 Libasm 과제를 해보아요~(9) ft_read


    2026.08.16, 18:46


    아무도 안 궁금했던 근황: 보조강사 알바, 8/7 정보처리기사 필기 시험, 8/9 독학사 컴공 3단계 시험, 포트폴리오 만들어서 정규직으로 써달라고 메일 보내기

    (바빴다는 뜻)

    아무튼 ft_read를 만들어볼거예요~~

    ft_read입니다

    ft_read는 ft_write와 거의 대칭이지만, 함정이 하나 다릅니다.

    구분fdbufcountsyscall 번호
    ft_writerdirsirdx1
    ft_readrdirsirdx0

    syscall 번호와 에러 처리(push raxcall __errno_location wrt ..pltpop 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 세팅이랑 테스트 코드만 다르잖아. 테스트코드 짜보라는 건가?

    section .text
    global ft_read
    
    ft_read:
        mov rax, 0
        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

    write 함수 테스트는 어떻게 하는지 알겠는데 read 함수 테스트는 어떻게 해야해? 파일 열고 fd 받아서 거기서 읽어보면 되나? 안열린 fd로도 errno랑 -1 리턴 확인하고?

    네, 맞습니다. 기본 방향은 맞고, 몇 가지 케이스를 더 추가하시면 완성도가 올라갑니다.

    1. 정상 케이스 (파일 fd)

    • open()으로 임시 파일 열고 알려진 내용을 미리 write
    • ft_read(fd, buf, size) 호출 → 실제 libc read()와 리턴값·버퍼 내용 비교
    • size보다 파일 내용이 작을 때 (부분 읽기), size가 0일 때도 케이스 추가

    2. 잘못된 fd

    • 안 연 fd (예: 임의의 큰 정수) → -1, errno == EBADF
    • 이미 close()한 fd로 read 시도 → 동일하게 -1, EBADF 확인

    3. EOF 케이스

    • 파일을 끝까지 읽은 후 추가로 read → 리턴값 0 (에러 아님, errno 건드리지 않아야 함)

    4. NULL buf

    • ft_write 때처럼 fork + waitpid + WIFSIGNALED로 SIGSEGV 확인
    • 단, fd는 유효한 값으로 줘야 커널이 버퍼 검증 단계까지 진입합니다 (fd가 먼저 걸리면 buf 검증 전에 EBADF로 리턴되는 케이스도 있으니, 정상 fd + NULL buf 조합으로 명확히 분리)

    5. 쓰기 전용 fd로 read

    • O_WRONLY로 연 fd에 read 시도 → -1, errno == EBADF
    • write 테스트에서 읽기전용 fd로 write 했던 것과 대칭되는 케이스입니다

    6. 파이프 fd (fork/pipe)

    • ft_write 테스트 인프라 재사용 가능: 파이프 write 쪽에 데이터 넣고 read 쪽 fd로 ft_read 검증
    • 특히 파이프가 비어있고 write 쪽이 열려 있을 때 read가 블로킹되는지까지 확인하려면 별도 자식 프로세스로 타이밍 제어 필요 (필수는 아니고, 시간 되시면 추가)

    이 중 4번(NULL buf)과 1번(정상 케이스)이 핵심이고, 2·5번은 errno 정확성 검증 차원에서 중요합니다. 6번은 write 테스트 인프라를 그대로 재사용하실 수 있으니 시간 대비 효율이 좋습니다.

    // test_read.c
    
    #include <unistd.h>
    #include <stdio.h>
    #include <string.h>
    #include <sys/wait.h>
    #include <signal.h>
    #include <errno.h>
    
    extern ssize_t ft_read(int fd, const void *buf, size_t count);
    
    int test_read(char *func_name, ssize_t (*func)(int, void *, size_t), int fd, char *buf, size_t count, 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)
    {
    	int mine;
    	int libc;
    
    	unsigned int fd;
    
    	char buf_mine[100];
    	char buf_libc[100];
    
    	size_t count;
    
    	int errno_mine;
    	int errno_libc;
    
    	int ret_cmp;
    	int fail = 0;
    
    	{
    		fd = open("test_strlen.c", O_RDONLY);
    		count = 42;
    	
    		mine = test_read("ft_read", ft_read, fd, buf_mine, count, &errno_mine);
    		libc = test_read("   read",    read, fd, buf_libc, count, &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");
    	}
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    	for (int fd = -1; fd <= 3; ++fd)
    	{
    		i = 0;
    		for (; i < 9; ++i)
    		{
    			mine = test_read("ft_read", ft_read, fd, buf_mine, count, &errno_mine);
    			libc = test_read("   read",    read, fd, buf_libc, count, &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");
    		}
    	
    		for (; i < 10; ++i)
    		{
    			printf("str: %s\n", str[i]);
    			mine = test_null_case("ft_read", ft_read, fd, &errno_mine);
    			libc = test_null_case("   read",    read, 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_read(char *func_name, ssize_t (*func)(int, void *, size_t), int fd, char *buf, size_t count, int *errno_ptr)
    {
    	int ret;
    
    	ret = func(fd, buf, count);
    	*errno_ptr = errno;
    	printf(": %s(%d, %s, %lu) -> %d\n", func_name, fd, buf, (unsigned long)count, 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_read(func_name, func, fd, NULL, errno_ptr);
    		int payload[2] = {ret, *errno_ptr};
    		read(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;
    	}
    }
    
  • [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;
    	}
    }

  • [ASM] 42 Libasm 과제를 해보아요~(7) gdb


    2026.07.04, 17:15


    디버깅을 해보자

    nasm 에서는 디버깅 툴로 무엇을 쓰나요?

    NASM으로 작성한 어셈블리 코드 디버깅에는 보통 다음 도구들을 사용합니다.

    • 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_strcmpb ft_strcmp함수 진입점에 브레이크포인트
    runr실행 시작
    stepisi어셈블리 명령어 1개 단위 진행 (call 안으로 진입)
    nextini어셈블리 명령어 1개 단위 진행 (call 건너뜀)
    continuec다음 브레이크포인트까지 실행
    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 어셈블리 코드를 보여주시면, 정확한 주소/조건으로 명령어를 짚어드릴 수 있습니다. 코드 공유 가능하신가요?

  • [ASM] 42 Libasm 과제를 해보아요~(6) ft_strcmp


    2026.07.04, 11:57


    다음 함수 진행하고 싶습니다.

    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인가

    정정된 종료 조건

    1. a[i] != b[i] → 다르면 즉시 종료, 반환값은 a[i] - b[i]
    2. (같다면) a[i] == 0 → 둘 다 끝났다는 뜻이므로 종료, 반환값은 0
    3. 둘 다 아니면 인덱스 증가 후 반복

    이 순서로 검사 개수를 3개에서 2개로 줄일 수 있다는 점을 반영해서 NASM 코드를 작성해보세요. 인자는 rdi = s1, rsi = s2이고, 반환값은 eax(32비트, int 반환이므로) 또는 관례상 movsx로 부호 확장한 rax를 씁니다 — 이 부분은 직접 코드 짜면서 판단해보시기 바랍니다.

    이렇게 쓰면 왜 안돼?

    1. cmp [rdi+r8], [rsi+r8]
    2. cmp [rdi+r8], 0
    3. 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번과 동일한 이유

    보완 권고

    1. 두 메모리 값을 비교/연산하려면 한쪽을 먼저 레지스터에 로드해야 합니다.

    nasm

       mov al, [rdi+r8]
       cmp al, [rsi+r8]
    1. 즉치값과 비교 시 항상 크기 지정자를 붙이세요. (ft_strcmp는 보통 byte 단위 비교이므로 byte 사용)
    2. 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와 다르다.

    디버깅을 해보자

    mat, map에서 m 부분에서 갑자기 .break로 이동하더라.

    디버깅하는 방법 알려달랬더니 냅다 코드리뷰 당해버렸다.

    코드 리뷰: ft_strcmp

    항목문제근거
    mov r9, [rdi+r8]8바이트(qword) 단위로 메모리를 읽고 있음. 문자열 비교는 1바이트(char) 단위여야 함r9는 64비트 레지스터이므로 니모닉 크기가 자동으로 8바이트로 결정됨. s1[i] 한 글자가 아니라 s1[i]~s1[i+7] 8글자를 한 번에 로드하게 됨
    cmp byte r9, 0NASM 어셈블 자체가 실패함 (문법 오류)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바이트를 읽는 순간 세그폴트 가능

    보완 권고

    1. movzx + byte 레지스터로 1바이트씩 로드하도록 수정 (r9b, r10b 사용, 상위 비트는 movzx로 제로 확장)
    2. cmp byte r9, 0test r9, r9 로 대체 (이미 movzx로 제로 확장된 상태이므로 전체 레지스터 test로 0 여부 판단 가능)
    3. 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비트)로 cmptest를 하면 상위 비트의 쓰레기값 때문에 잘못된 비교 결과가 나올 수 있습니다.
    • movzx는 이 문제를 원천 차단해서, 항상 “순수한 바이트 값”만 레지스터에 들어있도록 보장합니다.

    참고로 부호 있는 값(예: signed char)을 확장할 때는 상위 비트를 부호에 맞춰 채우는 movsx(sign-extend)를 씁니다. ft_strcmp처럼 unsigned char 기준 비교를 요구하는 경우엔 movzx가 맞습니다.

    test는 두 오퍼랜드를 AND 연산하되 결과를 저장하지 않고 플래그 레지스터만 갱신하는 명령어입니다.

    • test r9, r9r9 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일 때 동작합니다. 사실 jejz(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은 이미 출력되나 스크롤이 길어질 경우 한눈에 전체 결과를 확인하기 어려움

    보완 권고

    1. (선택) 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은 읽기 전용 함수라 버퍼 문제 없음)

    보완 권고

    1. s1/s2 배열에 0x80 이상 바이트를 포함한 문자열 쌍을 추가하세요. 예: "\x80\x01" vs "\x7f\x01"ft_strcmp이 양수를 반환해야 정상(0x80 > 0x7f as unsigned char)이고, 만약 movsx를 잘못 썼다면 부호가 반대로 나와 버그가 드러납니다.
    2. 30~50바이트 이상의 동일한 긴 문자열 쌍을 추가하세요.
    3. 마지막 한 글자만 다른 동일 길이 문자열 쌍("hello" vs "hellp")을 추가하세요.

    1번이 가장 중요합니다 — 지금 어셈블리 구현 자체는 맞게 되어 있지만, 이 특성을 검증하는 테스트가 없으면 나중에 코드를 고치다 실수로 부호 확장(movsx)으로 바꿔도 테스트가 계속 통과할 위험이 있습니다.

    좋습니다, ft_strcmp는 검증 완료로 처리하겠습니다.

  • [ASM] 42 Libasm 과제를 해보아요~(5) 번외 – 레지스터의 이름


    2026.07.03, 05:21


    레지스터의 이름은 왜 이렇게 붙여졌을까?

    알아보자.

    System V AMD64 호출 규약에 대해 간략하게 알려줘. 각 레지스터의 이름이 무슨 뜻인지도 알려줘

    System V AMD64 호출 규약 개요

    리눅스/macOS 등 유닉스 계열 64비트 시스템에서 사용하는 함수 호출 규약입니다. 유나님이 진행 중이신 libasm 프로젝트(NASM, Intel 문법)의 ft_write, ft_read 등에서 시스템 콜 호출 시 직접 사용하시게 됩니다.

    정수/포인터 인자 전달 순서 (레지스터)

    순서레지스터용도
    1번째 인자RDIDestination Index
    2번째 인자RSISource Index
    3번째 인자RDXData (원래는 Data 레지스터)
    4번째 인자RCXCounter
    5번째 인자R8범용 확장 레지스터 8번
    6번째 인자R9범용 확장 레지스터 9번
    • 7개 이상의 인자는 스택으로 전달됩니다.
    • 반환값은 RAX(Accumulator)에 담깁니다. 128비트 반환 시 RAX:RDX 조합 사용.

    레지스터 이름의 유래 (8086 시절 잔재)

    레지스터원래 이름 뜻
    RAXAccumulator — 연산 누적용
    RBXBase — 데이터 접근용 베이스 주소
    RCXCounter — 반복문(LOOP, REP) 카운터
    RDXData — 보조 데이터, 확장 연산(나눗셈 등)
    RSISource Index — 문자열 연산 시 소스 주소
    RDIDestination Index — 문자열 연산 시 목적지 주소
    RBPBase Pointer — 스택 프레임 기준점
    RSPStack Pointer — 스택 최상단 위치
    R8~R15이름 없이 번호만 (x86-64에서 신규 추가)

    시스템 콜 호출 시 차이점

    일반 함수 호출과 달리 시스템 콜은 규약이 약간 다릅니다.

    구분함수 호출시스템 콜
    호출 방식CALLSYSCALL
    콜 번호 지정없음RAX에 시스템 콜 번호
    4번째 인자RCXR10 (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는 반환값 저장 용도로 많이 쓰입니다.

  • [ASM] 42 Libasm 과제를 해보아요~(4) ft_strcpy


    2026.07.03, 03:15


    잘 된다. ft_strcpy 해보자

    ft_strcpy 핵심 함정

    이번엔 반환값 처리가 다릅니다. ft_strlen은 계산한 값(rax)을 그대로 반환했지만, ft_strcpy원본 dest 포인터를 반환해야 합니다 (char *strcpy(char *dest, const char *src)의 표준 동작). 즉 rdi를 인덱스 증가에 써버리면 원래 dest 주소를 잃어버립니다.

    필요 레지스터용도
    rdidest 포인터 (인자 1) — 원본을 보존해야 함
    rsisrc 포인터 (인자 2)
    rax인덱스 겸 최종 반환값

    전략은 두 가지가 있습니다:

    1. xor rax, rax로 인덱스를 만들고 [rdi+rax], [rsi+rax]로 접근 → 루프 끝나면 mov rax, rdi로 반환값 세팅
    2. rdi를 직접 증가시키되, 함수 시작 시 mov r10, rdi로 원본을 별도 레지스터에 백업

    질문: 두 전략 중 어느 쪽이 더 안전하다고 생각하십니까? 그리고 그 이유는 무엇입니까?

    (참고로 널 종료 문자 \0도 반드시 복사해야 합니다 — 이걸 빠뜨리면 ft_strlen으로 검증했을 때 src보다 한 글자 짧게 나오는 게 아니라, dest 문자열이 끝나지 않아 쓰레기 메모리까지 읽는 훨씬 위험한 버그가 됩니다.)

    위 질문에 답하신 뒤, 직접 NASM 코드를 작성해보세요.

    카페인 알약을 반 알 먹고 왔어요.

    눈이 슬슬 감겨요

    잠이 와요

    머리가 기울어요

    안되겠다 30분만 자고올게요 3:32

    그리고 5:06이 되었던 건에 대하여….

    번외 – 레지스터의 이름은 왜 이렇게 붙여졌을까?

    히히 딴짓 재밌다..
    아니오?? 공부입니다만.

    먼저, 질문이 있습니다.

    1. 1번의 경우 rdi를 그대로 반환하면 되는데 mov rax, rdi를 왜 해야 합니까? rdi는 보존되어야 하니까 보존하되, return 값은 항상 rax에 있어야 하기 때문인가요?
    2. 하필 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 뒤로 밀려 실질적으로 검사가 무력화됨위 항목과 연결됨. 복사 → 판단 → (필요시) 증가+루프, 순서를 명확히 분리해야 함

    보완 권고

    1. 메모리 간 이동은 al(또는 다른 8비트 레지스터)을 경유하도록 수정하세요.
    2. cmp 다음에 바로 je .end로 분기하고, inc raxjmp .loop는 그 이후(복사가 계속되는 경로)에만 놓으세요.
    3. 종료 후 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에 쓰는 단계 자체가 빠져버립니다.

    보완 권고

    1. 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

    __restrictnonnull은 서로 다른 별개의 속성입니다.

    • __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) 시스템도 존재하지 않으므로:

    1. ft_strcpy를 NASM으로 구현할 때 이런 속성을 선언할 방법 자체가 없습니다 — 해당 없음.
    2. 실제 glibc strcpy도 런타임에 NULL 체크를 하지 않으므로, ft_strcpy원본과 동일한 동작(NULL 전달 시 UB/세그폴트) 을 따르는 것이 일반적인 42 과제 기준입니다. 굳이 방어적으로 NULL 체크 로직을 추가할 필요는 없습니다(과제 subject에서 명시적으로 요구하지 않는 한).
    3. 다만 restrict가 암시하는 “두 버퍼가 겹치지 않는다”는 가정 자체는 asm 구현에서도 유효한 전제 조건입니다 — 즉 destsrc가 겹치는 경우(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) 테스트입니다.

    권고

    1. src 배열의 각 문자열 길이보다 넉넉한 버퍼로 교체하세요. 가장 간단한 수정:

    c

    char dest_mine[32];
    char dest_libc[32];
    1. 더 엄밀하게 검증하고 싶으시면 반복문 안에서 strlen(src[i]) + 1 만큼 malloc해서 매번 정확한 크기로 비교하는 것도 방법입니다.
    2. 오버플로우 자체를 테스트하고 싶으신 거라면(예: dest가 작을 때 어떻게 동작해야 하는지) — 사실 strcpy류 함수는 원래 dest 크기를 보장하지 않는 함수라 “오버플로우 시 정상 동작”이라는 개념 자체가 없습니다. 그건 별도로 ft_strncpy 같은 안전 버전에서 다룰 문제입니다.
    3. 버퍼 키운 뒤에도 FAIL이 나온다면, 그때는 진짜 ft_strcpy 로직 문제일 가능성이 높으니 그 결과를 다시 가져와 주세요.

    지금까지의 추론 과정(오버플로우 의심 → printf 버퍼링 의심 → temp 변수로 격리 시도)은 방향 자체는 나쁘지 않았습니다. 다만 “버퍼 크기가 애초에 부족하다”는 가장 근본적인 지점을 먼저 확인하지 않고 우회로부터 시작하신 게 시간을 더 쓰게 만든 지점입니다.

    버퍼 크기가 부족할 때 동작이 같은지 비교하고 싶어서 의도적으로 부족하게 설정한 건데, 애초에 UB라면 이 테스트는 건너뛰어도 될까?

    10:39 AM

    건너뛰는 게 맞습니다. 근거는 다음과 같습니다.

    1. libc strcpy 자체가 dest 크기를 검사하지 않는 함수이므로, “버퍼 부족 시 동작 비교”라는 테스트 목표 자체가 성립하지 않습니다. libc도 오버플로우 시 정의되지 않은 동작을 하므로 비교 기준(oracle)이 없습니다.
    2. 42 서브젝트의 ft_strcpy 요구사항은 대개 “strcpy와 동일하게 동작”이며, 이는 정상 크기의 dest를 전제로 한 계약입니다. 부족한 버퍼는 애초에 함수 계약 위반(호출자 책임)이지 구현체가 책임질 범위가 아닙니다.
    3. 오버플로우 자체를 검증하고 싶다면, 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")

    raxcldest에 쓴 값cmp 결과분기
    0‘a’‘a’‘a’ ≠ 0inc, jmp .loop
    1000 = 0je .break

    널 종료 문자가 dest에 정확히 복사된 후 종료됩니다. 로직 자체는 통과입니다.

    보완 권고

    1. (선택) cmp byte [rsi+rax], 0test cl, cl로 바꿔 메모리 재접근을 줄이세요. 필수는 아닙니다.

    test cl, cl은 무슨 뜻인가요? cl은 무슨 레지스터인가요?

    11:23 AM

    cl 레지스터

    cl은 rcx의 하위 8비트입니다. x86-64는 하나의 물리적 레지스터를 크기별로 여러 이름으로 부를 수 있게 해줍니다.

    이름크기rcx와의 관계
    rcx64비트전체
    ecx32비트rcx의 하위 32비트
    cx16비트rcx의 하위 16비트
    cl8비트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, libcstrcmp로 내용만 비교하고, 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 *)로 쓰는 것이 관례이며 가독성이 더 좋습니다. 버그는 아니고 스타일 문제입니다

    보완 권고

    1. 메인 루프에 int ret_ok = (mine == dest_mine);를 추가하고, fail 판정에 strcmp 결과와 함께 반영하세요.
    2. test_null_case의 반환값을 int r_mine = test_null_case(...), int r_libc = test_null_case(...)처럼 저장한 뒤 r_mine == r_libc인지 비교해서 fail에 반영하세요. (둘 다 세그폴트하거나 둘 다 안 하거나가 “정답”입니다.)
    3. dest_mine, dest_libc 크기를 12 → 32 정도로 늘려 향후 테스트 케이스 추가에 대비하세요.
    4. 출력 문구를 "%s: 시그널 %d로 종료됨 (예상: SIGSEGV=%d, 일치=%d)\n"처럼 실제 일치 여부를 boolean으로 명시하도록 바꾸세요.
    5. (선택) 함수 포인터 파라미터를 명시적 포인터 문법으로 바꾸세요.

    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부터 시작해서 바깥으로 읽습니다.

    1. *func → func는 포인터다
    2. (*func)(char *, const char *) → 그 포인터는 (char *, const char *)를 인자로 받는 함수를 가리킨다
    3. 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;
    	}
    }
  • [ASM] 42 Libasm 과제를 해보아요~(3) strlen 검증


    2026.07.03, 02:00


    전편의 test_strlen.c를 조금 손봤다.
    null 방어 해야하나 확인하고 싶어서

    한줄씩 주석 풀어서 테스트하면 됩니다~

    #include <stdio.h>
    #include <string.h>
    
    extern unsigned long ft_strlen(const char *str);
    
    int main(void)
    {
    	char *tests[] = {"", "a", "hello", "42gyeongsan", "yuna", NULL};
    	int fail = 0;
    
    	int i = 0;
    	for (; i < 5; ++i) {
    		unsigned long mine = ft_strlen(tests[i]);	
    		unsigned long libc = strlen(tests[i]);	
    		printf("%15s -> mine=%2lu libc=%2lu %s\n", tests[i], mine, libc, mine == libc ? "OK" : "FAIL");
    		if (mine != libc) fail = 1;
    	}
    	// printf("mine=%lu ", ft_strlen(tests[i]));
    	// printf("libc=%lu ", strlen(tests[i]));
    	return (fail);
    }
    

    자. 근데? 실행하려니까 좀 길어.

    gcc -Wall -Wextra -Werror -c test_strlen.c -o test_strlen.o
    gcc test_strlen.o ../libasm.a -o test_strlen          
    ./test_strlen

    오, Makefile을 만들면 되겠구나!!

    tests/ 폴더를 만들고, 그 안에 Makefile을 하나 더 만들어줍니다.

    근데 fclean을 어떻게 써야 할까요?
    test_*.c가 아닌 모든 test_*를 삭제하고 싶어. 어떻게 작성해?
    이건 아니잖아.

    rm -rf test_*

    이렇다네요

    find . -maxdepth 1 -name 'test_*' ! -name '*.c' -delete

    -maxdepth 1: 현재 디렉토리만 (하위 디렉토리까지 뒤지지 않도록)
    -name 'test_*': test_로 시작하는 것만 대상
    ! -name '*.c': .c 확장자는 제외
    -delete: find가 찾은 것 바로 삭제

    .

    작성했는데, 너무 더러운 것 같아. 누더기 골렘이잔아…

    이 Makefile을 더 Makefile 스럽게 수정해줘. Makefile로 하는 게 맞을까?

    all: test_strlen
    
    strlen:
        gcc -Wall -Wextra -Werror -c test_strlen.c -o test_strlen.o
        gcc test_strlen.o ../libasm.a -o test_strlen          
        ./test_strlen
    
    strcpy:
        gcc -Wall -Wextra -Werror -c test_strcpy.c -o test_strcpy.o
        gcc test_strcpy.o ../libasm.a -o test_strcpy          
        ./test_strcpy
    
    strcmp:
        gcc -Wall -Wextra -Werror -c test_strcmp.c -o test_strcmp.o
        gcc test_strcmp.o ../libasm.a -o test_strcmp          
        ./test_strcmp
    
    write:
        gcc -Wall -Wextra -Werror -c test_write.c -o test_write.o
        gcc test_write.o ../libasm.a -o test_write          
        ./test_write
    
    read:
        gcc -Wall -Wextra -Werror -c test_read.c -o test_read.o
        gcc test_read.o ../libasm.a -o test_read          
        ./test_read
    
    strdup:
        gcc -Wall -Wextra -Werror -c test_strdup.c -o test_strdup.o
        gcc test_strdup.o ../libasm.a -o test_strdup          
        ./test_strdup
    
    clean:
        rm -rf test_*.o
    
    fclean: clean
        find . -maxdepth 1 -name 'test_*' ! -name '*.c' -delete

    검토: Makefile 스럽지 않은 지점

    항목문제근거
    all 의존성all: test_strlen이 실행파일 test_strlen을 의존성으로 잡음. 룰 이름은 strlen인데 파일명 test_strlen을 걸었으므로, 해당 파일이 이미 존재하면 make가 “up to date”로 판단해 재빌드를 건너뜀make의 파일 기반 의존성 해석
    .PHONY 부재strlen, strcpy 등 룰이 실제 파일이 아닌데도 선언이 없음. 우연히 동일 이름 파일이 없어서 지금은 동작하지만, 안전장치가 아님GNU Make Manual §4.6
    반복 코드6개 룰이 함수 이름만 다를 뿐 완전히 동일한 패턴 → 정적 패턴 룰(static pattern rule) 하나로 대체 가능DRY 원칙
    하드코딩된 gccCC 변수 없이 컴파일러 고정 → 크로스컴파일/툴체인 교체 불가Make 컨벤션(암묵적 변수)
    CFLAGS 부재-Wall -Wextra -Werror가 각 룰에 중복 하드코딩변수화 미비
    ../libasm.a 미의존링크 대상이지만 prerequisite로 선언 안 됨 → libasm.a가 갱신돼도 make가 감지 못함의존성 그래프 불완전
    실행 파일 생성 흔적각 룰이 .o, 실행파일을 만들지만 make의 자동 변수($@, $<) 미사용 → 오타 위험, 유지보수 비용 증가Make 관용구
    clean/fclean 역할clean.o만, fcleanfind로 나머지 정리 — 방식 자체는 42 관례상 무방하나 .ofind 기반으로 통일하면 더 일관적스타일
    후행 공백일부 커맨드 줄 끝에 트레일링 스페이스 존재가독성/코드 품질

    질문: “Makefile로 하는 게 맞을까?”

    네, 맞습니다. Make의 원래 목적(파일 갱신 시각 기반 재빌드)에는 딱 맞지 않지만, 42 계열 프로젝트에서 make strlen처럼 개별 테스트를 실행하는 phony target 패턴은 표준 관례입니다. 다만 그렇다면 .PHONY를 명시해서 “이건 파일이 아니라 커맨드 묶음”이라는 의도를 make에게 정확히 알려주는 것이 필수입니다. 지금 코드는 이 의도가 우연히 맞아떨어지는 상태입니다.

    개선안

    makefile

    CC       = gcc
    CFLAGS   = -Wall -Wextra -Werror
    LIBASM   = ../libasm.a
    FUNCS    = strlen strcpy strcmp write read strdup
    
    .PHONY: all clean fclean re $(FUNCS)
    
    all: $(FUNCS)
    
    $(FUNCS): %: test_%.o $(LIBASM)
    	$(CC) $< $(LIBASM) -o test_$@
    	./test_$@
    
    test_%.o: test_%.c
    	$(CC) $(CFLAGS) -c $< -o $@
    
    clean:
    	find . -maxdepth 1 -name 'test_*.o' -delete
    
    fclean: clean
    	find . -maxdepth 1 -name 'test_*' ! -name '*.c' -delete
    
    re: fclean all

    변경 근거 (번호별)

    1. FUNCS 리스트 하나로 6개 중복 룰을 정적 패턴 룰($(FUNCS): %: test_%.o $(LIBASM)) 하나로 축소.
    2. test_%.o: test_%.c 패턴 룰로 오브젝트 파일 생성 로직 분리 → $<, $@ 자동 변수로 오타 여지 제거.
    3. $(LIBASM)을 prerequisite에 포함시켜 libasm.a 갱신 시 재링크되도록 수정.
    4. .PHONY에 함수명 전체 등록 → all 관련 버그(위 표 첫 행) 동시 해결.
    5. CC, CFLAGS를 변수로 분리해 컴파일러/플래그 교체 지점을 한 곳으로 통일.
    6. re 타겟 추가(42 프로젝트 관례상 거의 필수).

    확인 필요: $@가 함수명(strlen 등)과 겹치는지 여부 — 정적 패턴 룰 안에서 $@는 타겟 이름(strlen)을 가리키므로 test_$@로 실행파일명을 만드는 구조입니다. 디렉토리 구조가 test_strlen.c, test_strlen.o, 실행파일 test_strlen이 맞는지만 확인해 주시면 됩니다.

    나도 Makefile을 잘 쓰고 싶구나..

  • [ASM] 42 Libasm 과제를 해보아요~(2) strlen


    2026.07.02, 23:48


    당신은 나와 단 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 raxret으로 쓸 계획인지 확인 필요확인 필요: System V 규약상 rax에 값이 이미 들어있으면 ret만 실행하면 됩니다. 별도 mov 불필요

    보완 권고

    1. cmp 앞에 byte 크기 지정자를 붙이세요.
    2. mov rax, 0 대신 xor rax, rax로 바꾸세요.
    3. 라벨은 .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가 맞음

    보완 권고

    1. je breakje .break로 수정하세요. (지역 라벨은 점을 빠뜨리면 안 됩니다.)
    2. jne .loopjmp .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비트가 잘려 큰 문자열에서 오검증 발생 가능
    빈 문자열 "" 포함루프 진입 즉시 종료 조건을 만족하는 경계값 — 어셈블리 초기 구현에서 가장 자주 놓치는 케이스

    3. 컴파일·링크

    bash

    gcc -Wall -Wextra -c test_strlen.c -o test_strlen.o
    gcc test_strlen.o libasm.a -o test_strlen

    -no-pie 없이도 문제없습니다. ft_strlen.asm이 절대주소를 쓰지 않고 rdi 상대 접근만 하므로 PIE 바이너리에 링크되어도 정상 동작합니다.

    4. 실행

    bash

    ./test_strlen

    전부 OK가 나오면 통과입니다. 하나라도 FAIL이면 어느 문자열에서 틀렸는지, 특히 빈 문자열이나 긴 문자열 쪽을 먼저 의심하세요.

    실행 결과 알려주시면, 나오는 값을 보고 다음 단계(ft_strcpy) 넘어갈지 여기서 더 검증할지 판단하겠습니다.

    7/3, 00:35, 잠시 쉬는시간.. 집중력의 한계가 느껴진다

    그리고 마침내 7/3, 02:00, 다시 앉다.

    (다음 편에 이어서…)