← SEARCHABLE ARCHIVE
Assembly · 2026-08-01

C 언어는 어떻게 어셈블리어가 될까?

전처리부터 링크까지, x86-64 호출 규약과 stdio.h 전체 흐름

INTERACTIVE READING MODE →

C 언어는 어떻게 어셈블리어가 될까?

본 블로그 작성 글들은 강의를 목표로 제작되었습니다. 또한 컴파일러 버전, 운영체제, 최적화 옵션에 따라 실제 결과가 달라질 수 있으니 주의해주세요.

C 코드를 어셈블리어로 보고 싶을 때 가장 먼저 접하는 명령은 gcc -S file.c다. 명령은 짧지만 처음 만들어진 .s 파일을 열어 보면 생각보다 낯선 내용이 많다. #include <stdio.h>는 보이지 않고, printf의 긴 구현 대신 call 한 줄만 남는다. 같은 C 코드를 컴파일했는데도 Linux 예제와 Windows 예제에서 사용하는 레지스터까지 다르다.

이 차이를 이해하려면 어셈블리 명령어만 외우는 것보다 C 소스가 실행 파일이 되는 순서를 먼저 보는 편이 낫다. 전처리부터 링크까지 차례대로 확인한 뒤 GCC, Clang, MSVC에서 결과를 만드는 방법을 살펴보겠다. 그다음 x86-64 호출 규약과 최적화를 읽고, 마지막에는 stdio.h가 제공하는 타입과 매크로, 표준 핵심 함수 45개를 실제 호출 과정과 함께 정리한다.

예제 어셈블리는 주로 x86-64 Linux, System V ABI, Intel 문법을 기준으로 설명한다. Windows x64 차이는 별도 절에서 다룬다. C 표준은 특정 CPU나 어셈블리 문법을 정하지 않으므로 실제 출력은 컴파일러와 대상 환경에 따라 달라진다.

C 소스가 실행 파일이 되는 다섯 단계

C 프로그램이 바로 CPU 명령어로 변하는 것은 아니다. 보통 다섯 단계를 거친다.

  1. 전처리기는 hello.c의 #include, #define, #if 같은 지시문을 처리하고 전처리된 hello.i를 만든다.
  2. 컴파일러는 전처리된 C 코드를 분석하고 대상 CPU에 맞는 hello.s 어셈블리 소스를 만든다.
  3. 어셈블러는 명령어와 지시어를 기계어와 메타데이터가 든 hello.o 목적 파일로 바꾼다.
  4. 링커는 목적 파일과 필요한 라이브러리의 심벌을 연결해 실행 파일을 만든다.
  5. 프로그램을 실행하면 운영체제 로더가 실행 파일과 공유 라이브러리를 메모리에 배치한다.

우리가 보통 “C를 어셈블리로 바꾼다”고 말할 때는 두 번째 단계의 결과인 .s 또는 .asm 파일을 얻는 것을 뜻한다.

가장 작은 예제부터 시작한다

다음 코드를 stdio_trace.c로 저장했다고 하자.

#include <stdio.h>

static int add(int left, int right)
{
    return left + right;
}

int main(void)
{
    int value = add(20, 22);
    printf("value = %d\n", value);
    return 0;
}

이 코드는 덧셈 함수, 지역 변수, 문자열 상수, 가변 인자 함수 printf를 모두 포함한다. C와 어셈블리의 관계를 관찰하기에 충분히 작으면서도 중요한 요소가 들어 있다.

GCC로 각 단계를 직접 멈춰 보기

전처리 결과만 만들려면 -E를 사용한다.

gcc -std=c17 -E stdio_trace.c -o stdio_trace.i

stdio_trace.i를 열면 #include <stdio.h> 한 줄 대신 헤더가 제공한 타입 선언, 함수 원형, 매크로 확장 결과가 들어 있다. 시스템 헤더가 연쇄적으로 포함되므로 파일은 원본보다 훨씬 커질 수 있다.

어셈블리에서 멈추려면 -S를 사용한다.

gcc -std=c17 -Wall -Wextra -O0 -fno-omit-frame-pointer -S -masm=intel -fverbose-asm stdio_trace.c -o stdio_trace.s
  • -std=c17: C17 문법과 라이브러리 규칙을 기준으로 컴파일한다.
  • -Wall -Wextra: 흔한 실수와 형식 문자열 오류를 경고한다.
  • -O0: 최적화를 최소화해 원본 구조를 비교하기 쉽게 만든다.
  • -fno-omit-frame-pointer: 프레임 포인터를 유지해 스택을 읽기 쉽게 한다.
  • -S: 목적 파일을 만들기 전에 멈추고 어셈블리 파일을 만든다.
  • -masm=intel: x86 어셈블리를 Intel 문법으로 출력한다.
  • -fverbose-asm: 가능하면 소스와 관련된 설명 주석을 추가한다.

목적 파일까지만 만들려면 -c를 사용한다.

gcc -std=c17 -O0 -c stdio_trace.c -o stdio_trace.o

링크까지 실행하면 실행 파일이 만들어진다.

gcc stdio_trace.o -o stdio_trace

GCC 드라이버가 실제로 어떤 전처리기, 컴파일러, 어셈블러, 링커를 호출하는지 보고 싶다면 -v를 추가한다. 실행하지 않고 명령 구성만 보고 싶다면 -###을 사용할 수 있다.

Clang에서는 무엇이 다를까?

Clang도 단계 선택 옵션은 거의 같다.

clang -std=c17 -Wall -Wextra -O0 -S -masm=intel stdio_trace.c -o stdio_trace.s

Clang의 중간 표현인 LLVM IR을 보고 싶다면 다음 명령을 사용한다.

clang -std=c17 -O0 -S -emit-llvm stdio_trace.c -o stdio_trace.ll

LLVM IR은 C 소스와 실제 CPU 어셈블리 사이에서 최적화가 어떻게 표현되는지 공부할 때 유용하다. 그러나 .ll은 x86-64 CPU가 직접 실행하는 어셈블리어가 아니다.

MSVC에서 .asm 목록 만들기

Windows의 Visual Studio Developer PowerShell 또는 Developer Command Prompt에서는 cl을 사용할 수 있다.

cl /nologo /TC /std:c17 /Od /FAs /Fastdio_trace.asm stdio_trace.c
  • /TC: 입력 파일을 C로 처리한다.
  • /std:c17: MSVC가 지원하는 C17 모드를 선택한다.
  • /Od: 최적화를 끈다.
  • /FA: 어셈블리 목록을 만든다.
  • /FAs: 어셈블리와 원본 소스 줄을 함께 기록한다.
  • /Fa파일명: 목록 파일의 이름을 지정한다.

전처리 결과는 /P로 파일에 저장할 수 있다.

cl /nologo /TC /P /Fistdio_trace.i stdio_trace.c

MSVC가 만든 목적 파일이나 실행 파일의 실제 기계어를 다시 해석하려면 Visual Studio 도구의 dumpbin /DISASM을 사용할 수 있다.

어셈블리 소스와 역어셈블 결과는 다르다

컴파일러의 -S가 만드는 파일은 어셈블리 소스다. 여기에는 명령어뿐 아니라 어셈블러가 목적 파일을 만드는 데 필요한 지시어도 들어 있다.

.text
.globl main
.section .rodata
.LC0:
    .string "value = %d\n"
  • .text: 실행할 코드가 들어가는 섹션을 선택한다.
  • .globl main: main 심벌을 다른 파일에서도 찾을 수 있게 공개한다.
  • .section .rodata: 읽기 전용 데이터 섹션을 선택한다.
  • .LC0: 컴파일러가 만든 지역 레이블이다.
  • .string: 널 문자로 끝나는 문자열 바이트를 배치한다.
  • .p2align: 다음 코드나 데이터를 특정 경계에 맞춘다.
  • .cfi_*: 예외 처리와 디버깅에 쓰이는 스택 되감기 정보를 기록한다.

이 점으로 시작하는 항목들은 대부분 CPU가 실행하는 명령어가 아니다. 어셈블러에게 섹션, 심벌, 데이터와 메타데이터를 어떻게 만들지 알려주는 명령이다.

반대로 objdump나 dumpbin으로 목적 파일을 열면 이미 만들어진 기계어 바이트를 다시 어셈블리 표기로 해석한 역어셈블 결과를 본다.

objdump -dr -Mintel stdio_trace.o
  • -d: 실행 가능한 코드 섹션을 역어셈블한다.
  • -r: 아직 링커가 해결해야 하는 재배치 정보를 함께 표시한다.
  • -M intel: x86 명령을 Intel 문법으로 표시한다.

링크 전 목적 파일에서는 printf의 최종 주소를 알 수 없다. 따라서 call 위치에 임시 값과 printf를 가리키는 재배치 항목이 보일 수 있다.

Intel 문법의 최소 읽기법

Intel 문법은 대체로 명령 목적지, 원본 순서다.

mov eax, 42
add eax, 8
mov DWORD PTR [rbp-4], eax
  • mov eax, 42: 정수 42를 eax에 넣는다.
  • add eax, 8: eax에 8을 더한다.
  • [rbp-4]: rbp - 4 주소에 있는 메모리를 뜻한다.
  • DWORD PTR: 그 메모리를 4바이트 값으로 다룬다는 뜻이다.

x86-64의 자주 보는 레지스터 관계도 알아두면 좋다.

  • rax: 64비트 전체
  • eax: rax의 아래 32비트
  • ax: 아래 16비트
  • al: 아래 8비트

x86-64에서는 eax 같은 32비트 레지스터에 값을 쓰면 같은 64비트 레지스터의 위쪽 32비트가 0으로 정리된다. 그래서 mov eax, 0이나 xor eax, eax는 rax 전체를 0으로 만드는 효과가 있다.

자주 보는 명령은 다음과 같다.

  • mov: 값을 복사한다.
  • lea: 메모리를 읽지 않고 주소 계산 결과를 만든다.
  • add, sub, imul: 정수 산술을 수행한다.
  • cmp, test: 분기에 사용할 플래그를 설정한다.
  • je, jne, jl, jg: 조건에 따라 점프한다.
  • jmp: 무조건 점프한다.
  • call: 돌아올 주소를 저장하고 함수를 호출한다.
  • ret: 호출한 위치로 돌아간다.
  • push, pop: 스택에 값을 넣거나 꺼낸다.

최적화하지 않은 add 함수 읽기

GCC가 최적화하지 않은 코드에서는 다음과 비슷한 결과가 나올 수 있다. 정확한 레이블과 스택 배치는 컴파일러 버전에 따라 달라진다.

add:
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR [rbp-4], edi
    mov  DWORD PTR [rbp-8], esi
    mov  edx, DWORD PTR [rbp-4]
    mov  eax, DWORD PTR [rbp-8]
    add  eax, edx
    pop  rbp
    ret

System V x86-64 호출 규약에서는 첫 번째 정수 인자가 edi, 두 번째 정수 인자가 esi에 들어온다. 반환할 int 값은 eax에 둔다.

  1. push rbp는 호출자의 프레임 포인터를 보관한다.
  2. mov rbp, rsp는 현재 함수의 스택 프레임 기준점을 만든다.
  3. 두 인자를 스택의 지역 공간으로 복사한다.
  4. 값을 다시 레지스터로 읽어 더한다.
  5. 결과를 eax에 남긴다.
  6. 프레임 포인터를 복구하고 ret으로 돌아간다.

-O0에서는 디버깅하기 쉽도록 불필요해 보이는 메모리 복사가 남을 수 있다. CPU가 C 변수라는 개념을 요구해서가 아니라, 컴파일러가 각 소스 변수를 추적하기 쉬운 형태를 유지했기 때문이다.

main에서 printf를 호출하는 과정

같은 프로그램의 main은 다음과 비슷하게 보일 수 있다.

main:
    push rbp
    mov  rbp, rsp
    sub  rsp, 16

    mov  esi, 22
    mov  edi, 20
    call add
    mov  DWORD PTR [rbp-4], eax

    mov  eax, DWORD PTR [rbp-4]
    mov  esi, eax
    lea  rdi, [rip+.LC0]
    xor  eax, eax
    call printf@PLT

    mov  eax, 0
    leave
    ret

add(20, 22)에서는 20과 22가 각각 edi, esi로 전달되고 결과가 eax로 돌아온다.

printf("value = %d\n", value)는 다음처럼 내려간다.

  • 첫 번째 인자인 형식 문자열 주소는 rdi에 들어간다.
  • 두 번째 인자인 int value는 esi에 들어간다.
  • lea rdi, [rip+.LC0]는 명령 포인터 기준으로 문자열 주소를 계산한다.
  • xor eax, eax는 가변 인자 호출에서 사용한 벡터 레지스터 개수가 0임을 al로 알린다.
  • call printf@PLT는 동적 라이브러리의 printf로 연결될 진입점을 호출한다.
  • printf의 반환값은 출력한 문자 수이며 eax로 돌아오지만, 이 코드는 그 값을 사용하지 않는다.

@PLT는 ELF 동적 연결 환경에서 흔히 보이는 표기다. 정적 링크, 다른 링커, Windows PE 환경에서는 이름과 연결 방식이 다르다.

System V x86-64 호출 규약

Linux, 여러 BSD 계열, 기타 ELF 기반 x86-64 환경에서 흔히 쓰는 System V ABI의 정수·포인터 인자 순서는 다음과 같다.

1번째  RDI
2번째  RSI
3번째  RDX
4번째  RCX
5번째  R8
6번째  R9
나머지  스택
반환값  RAX

부동소수점 인자는 보통 xmm0부터 xmm7까지 사용한다. rbx, rbp, r12부터 r15처럼 호출받은 함수가 보존해야 하는 레지스터도 있고, rax, rcx, rdx, rsi, rdi, r8부터 r11처럼 호출자가 필요하면 미리 보관해야 하는 레지스터도 있다.

함수를 호출하기 전에는 ABI가 요구하는 스택 정렬도 맞춰야 한다. 이 규칙을 어기면 일반 정수 코드는 우연히 동작해도 SIMD 명령이나 라이브러리 함수에서 문제가 생길 수 있다.

Windows x64 호출 규약은 레지스터가 다르다

Windows x64에서는 처음 네 개의 정수·포인터 인자를 다음 레지스터로 전달한다.

1번째  RCX
2번째  RDX
3번째  R8
4번째  R9
나머지  스택
반환값  RAX

호출자는 호출 전에 네 개 레지스터 인자를 저장할 수 있는 32바이트의 shadow space를 스택에 확보해야 한다. 부동소수점 인자는 xmm0부터 xmm3을 사용하며, 가변 인자 호출에서는 부동소수점 값을 대응하는 일반 레지스터에도 복제해야 하는 규칙이 있다.

따라서 Windows에서 같은 printf 호출은 대략 다음과 같은 모양이 된다.

    sub  rsp, 40
    mov  edx, 42
    lea  rcx, format_string
    call printf
    xor  eax, eax
    add  rsp, 40
    ret

여기서 40바이트는 32바이트 shadow space와 호출 시점의 정렬을 맞추기 위한 공간을 합친 전형적인 값이다. 함수가 이미 다른 스택 프레임을 만들었다면 배치가 달라질 수 있다.

같은 x86-64 명령어 집합을 사용하더라도 운영체제 ABI가 다르면 인자 레지스터, 심벌 이름, 동적 연결 방식과 함수 프롤로그가 달라진다.

최적화를 켜면 C 코드의 모양이 사라진다

이번에는 -O2로 어셈블리를 만든다.

gcc -std=c17 -O2 -S -masm=intel stdio_trace.c -o stdio_trace_O2.s

컴파일러는 add가 현재 파일 안에서만 사용되고 결과를 컴파일 시간에 계산할 수 있다는 사실을 안다. 따라서 다음 변화가 일어날 수 있다.

  • add를 main 안으로 인라인한다.
  • 20 + 22를 실행 시간에 더하지 않고 상수 42로 바꾼다.
  • 지역 변수 value를 위한 스택 공간을 없앤다.
  • 사용되지 않는 중간 이동 명령을 제거한다.
  • 프레임 포인터가 필요 없다면 만들지 않는다.

결과는 다음처럼 짧아질 수 있다.

main:
    sub  rsp, 8
    mov  esi, 42
    lea  rdi, [rip+.LC0]
    xor  eax, eax
    call printf@PLT
    xor  eax, eax
    add  rsp, 8
    ret

최적화된 어셈블리에서 C의 각 줄을 하나씩 찾으려 하면 안 된다. 컴파일러가 지켜야 하는 것은 C 소스의 줄 모양이 아니라, 정의된 동작과 관찰 가능한 부수 효과다.

printf가 puts로 바뀔 수도 있다

다음 코드를 보자.

printf("hello\n");

형식 변환이 필요 없는 고정 문자열이므로 컴파일러는 같은 관찰 결과를 내는 puts("hello")로 바꿀 수 있다. puts는 마지막 줄바꿈을 스스로 추가하기 때문이다.

라이브러리 내장 함수 최적화를 억제해 호출 형태를 관찰하고 싶다면 GCC와 Clang에서 다음 옵션을 시험할 수 있다.

gcc -O2 -fno-builtin-printf -S -masm=intel example.c -o example.s

모든 내장 함수 인식을 막는 -fno-builtin도 있지만, 실제 제품 코드의 성능 특성을 바꾸므로 학습과 진단 목적으로 신중하게 사용해야 한다.

stdio.h는 무엇을 하는가?

#include <stdio.h>는 printf 구현을 현재 C 파일 안으로 복사하는 명령이 아니다.

전처리 후 현재 번역 단위에는 주로 다음 정보가 들어온다.

  • FILE, fpos_t, size_t 같은 타입 선언
  • printf, fopen, fread 같은 함수 원형
  • EOF, BUFSIZ, SEEK_SET 같은 매크로
  • stdin, stdout, stderr에 접근하기 위한 선언 또는 매크로

컴파일러는 함수 원형을 보고 인자의 타입과 개수, 반환형을 검사하고 ABI에 맞는 호출 코드를 만든다. 실제 printf나 fopen의 구현은 보통 C 런타임 라이브러리에 있다.

현재 목적 파일: printf라는 심벌을 호출해야 한다는 정보
링커:          C 라이브러리에서 printf를 찾거나 동적 연결 정보를 생성
로더:          실행 시 공유 라이브러리를 메모리에 배치하고 주소를 연결

Linux 계열에서는 흔히 glibc나 다른 libc 구현을 사용하고, Windows MSVC 환경에서는 UCRT 같은 런타임을 사용한다. 따라서 stdio.h 선언은 비슷해도 내부 구현과 어셈블리는 달라질 수 있다.

스트림과 FILE *

표준 입출력은 스트림이라는 추상화를 사용한다. 스트림은 콘솔, 일반 파일, 파이프, 장치 등과 연결될 수 있지만 C 코드는 공통된 FILE * 인터페이스로 다룬다.

  • stdin: 표준 입력
  • stdout: 표준 출력
  • stderr: 표준 오류

FILE은 구현 세부사항을 숨기는 타입이다. 내부에 파일 디스크립터, 버퍼 포인터, 현재 위치, EOF 상태, 오류 상태, 잠금 같은 정보가 들어갈 수 있지만, 프로그램이 멤버를 직접 만져서는 안 된다. FILE 객체를 복사하거나 내부 레이아웃에 의존하면 이식성이 사라진다.

스트림에는 현재 파일 위치뿐 아니라 EOF 표시기와 오류 표시기가 따로 있다. 그래서 fread가 0을 반환했을 때 feof와 ferror를 구분해야 한다.

stdio.h가 제공하는 타입

ISO C17의 stdio.h가 정의하는 핵심 타입은 다음과 같다.

  • size_t: 객체 크기와 바이트 수를 표현하는 부호 없는 정수 타입
  • FILE: 스트림을 표현하는 객체 타입
  • fpos_t: 파일 위치를 저장하고 복원하기 위한 타입

vprintf 계열에서 사용하는 va_list는 가변 인자 상태를 나타내며 기본 정의는 stdarg.h의 영역이다.

stdio.h의 핵심 매크로

표준 헤더에는 구현에 따라 실제 숫자가 다른 여러 매크로가 있다.

  • _IOFBF: 완전 버퍼링 모드
  • _IOLBF: 줄 버퍼링 모드
  • _IONBF: 버퍼링하지 않는 모드
  • BUFSIZ: setbuf에서 사용하는 기본 버퍼 크기
  • EOF: 입력 종료 또는 일부 함수의 실패를 나타내는 음수 정수 상수
  • FOPEN_MAX: 동시에 열 수 있다고 보장되는 최소 스트림 수와 관련된 값
  • FILENAME_MAX: 구현이 다룰 수 있는 파일명 문자열 크기와 관련된 값
  • L_tmpnam: tmpnam이 사용할 문자열 배열의 크기
  • TMP_MAX: tmpnam이 생성하도록 요구되는 서로 다른 이름의 최소 개수와 관련된 값
  • SEEK_SET: 파일 시작을 기준으로 이동
  • SEEK_CUR: 현재 위치를 기준으로 이동
  • SEEK_END: 파일 끝을 기준으로 이동
  • stdin, stdout, stderr: 세 표준 스트림에 접근하는 표현

이 값들을 특정 숫자로 가정하면 안 된다. 예를 들어 EOF는 보통 -1이지만, 이식 가능한 코드가 의존해야 하는 조건은 음수 정수 상수라는 점이다.

stdio.h 핵심 함수 45개 전체 목록

여기서는 ISO C17 stdio.h의 핵심 함수 45개를 기능별로 정리한다. 선택 사항인 Annex K의 _s 계열과 POSIX·GNU·MSVC 고유 확장은 뒤에서 따로 구분한다.

1. 파일 자체를 다루는 함수 4개

int remove(const char *filename);
int rename(const char *old_name, const char *new_name);
FILE *tmpfile(void);
char *tmpnam(char *s);
  • remove: 이름으로 지정한 파일을 삭제한다. 구현에 따라 빈 디렉터리에도 적용될 수 있다.
  • rename: 파일 이름이나 경로를 바꾼다. 대상이 이미 있을 때의 세부 동작은 구현 차이가 있다.
  • tmpfile: 임시 바이너리 스트림을 열고 FILE *를 반환한다. 정상적으로 닫히거나 프로그램이 정상 종료되면 임시 파일이 제거된다.
  • tmpnam: 기존 파일과 겹치지 않는 임시 파일명 문자열을 만든다. 이름 생성과 실제 파일 생성 사이에 경쟁 조건이 생길 수 있어 보안에 민감한 코드에서는 피하는 편이 좋다.

tmpnam(NULL)은 구현 내부의 정적 버퍼를 사용할 수 있으므로 다음 호출에서 값이 바뀔 수 있다. 임시 파일이 필요하면 운영체제와 라이브러리가 제공하는 더 안전한 원자적 생성 기능도 검토해야 한다.

2. 스트림 열기·닫기·버퍼 설정 함수 6개

int fclose(FILE *stream);
int fflush(FILE *stream);
FILE *fopen(const char *restrict filename, const char *restrict mode);
FILE *freopen(const char *restrict filename, const char *restrict mode,
              FILE *restrict stream);
void setbuf(FILE *restrict stream, char *restrict buffer);
int setvbuf(FILE *restrict stream, char *restrict buffer,
            int mode, size_t size);
  • fclose: 버퍼를 비우고 스트림을 닫는다. 출력 스트림은 실제 쓰기가 이 시점에 실패할 수도 있으므로 반환값을 확인해야 한다.
  • fflush: 출력 스트림의 아직 전달되지 않은 데이터를 호스트 환경으로 보낸다. NULL을 전달하면 열린 모든 출력 스트림을 처리한다.
  • fopen: 파일을 열고 새 스트림을 만든다. 실패하면 널 포인터를 반환한다.
  • freopen: 기존 스트림을 닫고 다른 파일이나 모드에 다시 연결한다. stdin, stdout, stderr를 파일로 재지정할 때도 사용할 수 있다.
  • setbuf: 완전 버퍼링 또는 무버퍼링을 간단히 설정한다.
  • setvbuf: 버퍼링 방식, 버퍼 주소, 크기를 더 세밀하게 지정한다.

ISO C에서 fflush(stdin)으로 입력 버퍼를 지우는 코드는 이식 가능하지 않다. fflush의 표준 용도는 출력 스트림과, 마지막 동작이 출력인 업데이트 스트림이다. 일부 구현이 입력 스트림 동작을 확장으로 제공하더라도 다른 환경에서는 같은 결과를 기대할 수 없다.

setbuf와 setvbuf는 스트림을 연 뒤 다른 입출력을 하기 전에 호출해야 한다. 전달한 사용자 버퍼는 스트림이 닫힐 때까지 살아 있어야 한다.

fopen 모드 문자열

핵심 모드는 다음과 같다.

  • "r": 기존 파일을 읽기 전용으로 연다.
  • "w": 쓰기 전용으로 열고 기존 파일을 비우며, 없으면 만든다.
  • "a": 쓰기 전용으로 열고 모든 쓰기를 파일 끝에 추가한다.
  • "r+": 기존 파일을 읽기와 쓰기로 연다.
  • "w+": 읽기와 쓰기로 열고 기존 파일을 비우며, 없으면 만든다.
  • "a+": 읽기와 추가 쓰기로 연다.
  • "b": 바이너리 모드를 나타내며 "rb", "wb", "r+b"처럼 결합한다.
  • "x": C11 이후 w 계열과 결합해 파일이 이미 있으면 실패하는 배타적 생성을 요청할 수 있다.

POSIX 계열에서는 텍스트와 바이너리 모드 차이가 거의 없지만 Windows에서는 줄바꿈 변환 등 차이가 생길 수 있다. 바이너리 데이터를 다룰 때는 b를 명시하는 습관이 좋다.

+가 있는 업데이트 스트림에서 읽기와 쓰기를 번갈아 수행할 때는 규칙이 있다. 쓰기 뒤 읽기로 바꾸려면 fflush 또는 파일 위치 지정 함수를 사용해야 하고, 읽기 뒤 쓰기로 바꿀 때도 읽기가 EOF에 도달한 경우가 아니라면 위치 지정 함수가 필요하다.

3. 형식 출력 함수 8개

int fprintf(FILE *restrict stream, const char *restrict format, ...);
int printf(const char *restrict format, ...);
int sprintf(char *restrict s, const char *restrict format, ...);
int snprintf(char *restrict s, size_t n,
             const char *restrict format, ...);

int vfprintf(FILE *restrict stream, const char *restrict format, va_list arg);
int vprintf(const char *restrict format, va_list arg);
int vsprintf(char *restrict s, const char *restrict format, va_list arg);
int vsnprintf(char *restrict s, size_t n,
              const char *restrict format, va_list arg);
  • printf: 형식화한 결과를 stdout에 쓴다.
  • fprintf: 지정한 스트림에 쓴다. 오류 메시지는 fprintf(stderr, ...) 형태로 자주 출력한다.
  • sprintf: 결과를 문자 배열에 쓴다. 크기 제한이 없어 버퍼 오버플로 위험이 크다.
  • snprintf: 최대 n바이트 범위 안에서 결과를 쓴다. n이 0보다 크면 가능한 범위에서 마지막 널 문자도 보장한다.
  • vprintf, vfprintf, vsprintf, vsnprintf: ... 대신 이미 구성된 va_list를 받는다. 로그 함수처럼 가변 인자를 다시 전달할 때 사용한다.

snprintf의 반환값은 공간이 충분했다면 기록되었을 문자 수를 뜻하며 마지막 널 문자는 세지 않는다. 반환값이 음수면 인코딩 오류이고, 반환값이 n 이상이면 결과가 잘렸다는 뜻이다.

printf 형식 문자열 읽기

변환 명세는 대체로 다음 구조다.

% [플래그] [너비] [.정밀도] [길이] 변환문자

주요 변환 문자는 다음과 같다.

  • %d, %i: 부호 있는 10진 정수
  • %u: 부호 없는 10진 정수
  • %o: 8진 정수
  • %x, %X: 16진 정수
  • %f, %F: 고정소수점 부동소수점
  • %e, %E: 지수 표기
  • %g, %G: 값에 따라 고정소수점 또는 지수 표기
  • %a, %A: 16진 부동소수점 표기
  • %c: 문자
  • %s: 널 문자로 끝나는 문자열
  • %p: void * 포인터
  • %n: 지금까지 출력한 문자 수를 포인터가 가리키는 정수 객체에 저장
  • %%: 퍼센트 문자 자체

플래그에는 왼쪽 정렬 -, 부호 표시 +, 공백, 대체 형식 #, 0 채우기가 있다. 너비와 정밀도는 숫자 또는 인자에서 가져오는 *로 정할 수 있다.

길이 수정자에는 hh, h, l, ll, j, z, t, L이 있다. 예를 들어 size_t는 %zu, long long은 %lld, long double은 %Lf를 사용한다.

형식 문자열과 실제 인자 타입이 맞지 않으면 정의되지 않은 동작이 발생할 수 있다. printf("%d", 3.14)처럼 작성해도 가변 인자 영역에는 자동 타입 정보가 없으므로 printf가 스스로 잘못을 복구할 수 없다.

가변 인자와 기본 인자 승격

printf의 ...로 전달되는 값에는 기본 인자 승격이 적용된다.

  • char, signed char, unsigned char, short는 조건에 따라 int 또는 unsigned int로 승격된다.
  • float는 double로 승격된다.

그래서 printf에서 %f는 double 인자를 받는다. 반면 뒤에서 볼 scanf의 %f는 float *, %lf는 double *를 요구한다. 출력과 입력의 규칙을 섞으면 안 된다.

4. 형식 입력 함수 6개

int fscanf(FILE *restrict stream, const char *restrict format, ...);
int scanf(const char *restrict format, ...);
int sscanf(const char *restrict s, const char *restrict format, ...);

int vfscanf(FILE *restrict stream, const char *restrict format, va_list arg);
int vscanf(const char *restrict format, va_list arg);
int vsscanf(const char *restrict s, const char *restrict format, va_list arg);
  • scanf: stdin에서 형식에 맞는 값을 읽는다.
  • fscanf: 지정한 스트림에서 읽는다.
  • sscanf: 메모리의 문자열에서 읽는다.
  • vscanf, vfscanf, vsscanf: 목적지 포인터 목록을 va_list로 받는 대응 함수다.

반환값은 성공적으로 값을 대입한 항목 수다. 첫 변환을 수행하기 전에 입력 실패가 발생하면 EOF를 반환할 수 있다. 따라서 다음처럼 기대한 개수와 비교해야 한다.

int value;

if (scanf("%d", &value) != 1)
{
    fprintf(stderr, "정수를 읽지 못했습니다.\n");
    return 1;
}

scanf에 넘기는 것은 값을 저장할 객체의 주소다. &value가 빠지면 scanf는 정수 값을 주소로 해석해 잘못된 메모리에 쓰려고 한다.

scanf가 어셈블리 호출이 되는 모습

System V 환경에서 위 코드는 대략 다음 구조가 된다.

    lea  rsi, [rbp-4]
    lea  rdi, [rip+.LC_input]
    xor  eax, eax
    call __isoc99_scanf@PLT
    cmp  eax, 1
    jne  .L_input_failed
  • lea rsi, [rbp-4]는 지역 변수 value의 주소를 두 번째 인자로 만든다.
  • rdi에는 "%d" 문자열 주소가 들어간다.
  • 가변 인자 호출 규칙을 위해 eax를 0으로 만든다.
  • 반환된 대입 성공 개수를 1과 비교한다.

glibc 환경에서는 표준 호환 처리를 위해 scanf 대신 __isoc99_scanf 같은 내부 심벌이 보일 수 있다. 다른 C 라이브러리에서는 이름이 다르다.

scanf를 안전하게 사용하려면

%s는 공백 전까지 문자를 계속 읽지만 목적지 배열의 크기를 자동으로 모른다.

char name[32];
scanf("%31s", name);

너비 31은 마지막 널 문자를 위한 1바이트를 남긴다. 배열 크기가 바뀌면 형식 문자열도 함께 바꿔야 한다는 단점이 있다.

복잡한 사용자 입력은 fgets로 한 줄을 받은 뒤 strtol, strtod 같은 변환 함수로 해석하는 편이 오류 처리와 범위 검사에 유리하다.

5. 문자·문자열 입출력 함수 10개

int fgetc(FILE *stream);
char *fgets(char *restrict s, int n, FILE *restrict stream);
int fputc(int c, FILE *stream);
int fputs(const char *restrict s, FILE *restrict stream);

int getc(FILE *stream);
int getchar(void);
int putc(int c, FILE *stream);
int putchar(int c);
int puts(const char *s);
int ungetc(int c, FILE *stream);
  • fgetc: 스트림에서 다음 바이트를 unsigned char 값으로 읽어 int로 반환하거나 EOF를 반환한다.
  • fgets: 최대 n - 1개 문자를 읽고 널 문자를 붙인다. 줄바꿈을 읽었다면 배열에 포함한다.
  • fputc: 한 문자를 스트림에 쓴다.
  • fputs: 문자열을 스트림에 쓰지만 자동으로 줄바꿈을 추가하지 않는다.
  • getc: 의미는 fgetc와 비슷하지만 매크로로 구현될 수 있다.
  • getchar: getc(stdin)과 같은 목적의 함수다.
  • putc: 의미는 fputc와 비슷하지만 매크로로 구현될 수 있다.
  • putchar: putc(c, stdout)과 같은 목적의 함수다.
  • puts: 문자열을 stdout에 쓰고 줄바꿈을 추가한다.
  • ungetc: 한 문자를 입력 스트림으로 되돌려 다음 읽기에서 다시 보이게 한다.

문자 입력 함수가 char가 아니라 int를 반환하는 이유는 모든 unsigned char 값과 별도의 EOF를 동시에 표현해야 하기 때문이다.

int ch;

while ((ch = fgetc(stdin)) != EOF)
{
    putchar(ch);
}

반환값을 바로 char에 저장하면 어떤 환경에서는 실제 바이트 값과 EOF를 구분하지 못할 수 있다.

getc와 putc가 매크로라면 인자로 전달한 식을 한 번 이상 평가할 가능성을 고려해야 한다. 부수 효과가 있는 복잡한 식 대신 단순한 FILE * 변수를 넘기는 것이 안전하다. 함수 주소가 필요할 때는 구현이 제공하는 실제 함수 형태를 사용할 수 있지만, 내부 최적화 방식에 의존해서는 안 된다.

6. 블록 단위 바이너리 입출력 함수 2개

size_t fread(void *restrict ptr, size_t size, size_t nmemb,
             FILE *restrict stream);
size_t fwrite(const void *restrict ptr, size_t size, size_t nmemb,
              FILE *restrict stream);
  • fread: 크기가 size인 항목을 최대 nmemb개 읽는다.
  • fwrite: 크기가 size인 항목을 nmemb개 쓴다.

반환값은 바이트 수가 아니라 완전히 처리한 항목 수다. 바이트 배열을 다룰 때 size를 1로 주면 반환값을 바이트 수처럼 사용할 수 있다.

unsigned char buffer[4096];
size_t count = fread(buffer, 1, sizeof buffer, stream);

구조체를 fwrite(&object, sizeof object, 1, file)로 그대로 저장할 수는 있지만, 그 파일 형식은 구조체 패딩, 엔디안, 정수 크기, 부동소수점 표현에 의존한다. 다른 컴파일러나 CPU에서도 읽어야 하는 파일이라면 필드를 명시적인 바이트 형식으로 직렬화해야 한다.

7. 파일 위치 함수 5개

int fgetpos(FILE *restrict stream, fpos_t *restrict pos);
int fseek(FILE *stream, long offset, int origin);
int fsetpos(FILE *stream, const fpos_t *pos);
long ftell(FILE *stream);
void rewind(FILE *stream);
  • fgetpos: 현재 위치 정보를 fpos_t 객체에 저장한다.
  • fseek: SEEK_SET, SEEK_CUR, SEEK_END 중 하나를 기준으로 위치를 이동한다.
  • fsetpos: fgetpos로 저장한 위치를 복원한다.
  • ftell: 현재 위치를 long으로 반환하고 실패하면 -1L을 반환한다.
  • rewind: 스트림을 파일 시작으로 이동하고 오류 표시기를 지운다.

텍스트 스트림에서는 임의의 바이트 오프셋으로 자유롭게 이동할 수 있다고 가정하면 안 된다. ftell이 돌려준 값을 같은 스트림의 fseek에 사용하는 방식이 가장 이식 가능하다.

매우 큰 파일에서는 운영체제나 POSIX가 제공하는 fseeko, ftello 같은 확장이 필요할 수 있지만, 이 함수들은 ISO C17 핵심 stdio.h 목록에는 포함되지 않는다.

8. EOF와 오류 상태 함수 4개

void clearerr(FILE *stream);
int feof(FILE *stream);
int ferror(FILE *stream);
void perror(const char *s);
  • clearerr: EOF 표시기와 오류 표시기를 모두 지운다.
  • feof: 이전 읽기가 파일 끝을 만났는지 확인한다.
  • ferror: 스트림 오류가 기록되었는지 확인한다.
  • perror: 문자열, 구분 기호, 현재 errno에 대응하는 오류 설명을 stderr에 출력한다.

feof는 앞으로 읽을 데이터가 있는지 미리 알려주는 함수가 아니다. 읽기 함수가 실패한 뒤에 그 원인이 EOF인지 확인한다.

int ch;

while ((ch = fgetc(file)) != EOF)
{
    process(ch);
}

if (ferror(file))
{
    perror("fgetc");
}

다음 패턴은 잘못되었다.

while (!feof(file))
{
    ch = fgetc(file);
    process(ch);
}

마지막 읽기가 실패하기 전까지 EOF 표시기는 설정되지 않으므로 잘못된 값을 한 번 처리할 수 있다. 항상 읽기 함수의 반환값을 반복 조건으로 삼아야 한다.

45개 함수 개수 확인

지금까지의 핵심 함수를 합치면 다음과 같다.

파일 조작                  4
스트림 열기·닫기·버퍼      6
형식 출력                  8
형식 입력                  6
문자·문자열 입출력        10
블록 입출력                2
파일 위치                  5
오류 상태                  4
합계                      45

C11에서 위험한 gets는 표준에서 제거되었기 때문에 이 목록에 없다. 오래된 코드에서 gets를 발견하면 fgets처럼 최대 길이를 지정할 수 있는 방식으로 바꿔야 한다.

Annex K의 _s 함수와 구현 확장

일부 문서나 MSVC 헤더에서는 다음과 같은 이름을 볼 수 있다.

tmpfile_s
tmpnam_s
fopen_s
freopen_s
fprintf_s
fscanf_s
printf_s
scanf_s
snprintf_s
sprintf_s
sscanf_s
vfprintf_s
vfscanf_s
vprintf_s
vscanf_s
vsnprintf_s
vsprintf_s
vsscanf_s
gets_s

이들은 C 표준의 선택적 경계 검사 인터페이스인 Annex K 또는 구현 고유 확장과 관련된다. 모든 C17 구현이 제공해야 하는 핵심 45개 함수는 아니다. 구현마다 제공 범위와 세부 규칙이 다르므로 _s라는 이름만 보고 모든 플랫폼에서 동일하다고 가정하면 안 된다.

다음 함수들도 자주 보이지만 ISO C17 핵심 목록은 아니다.

  • POSIX: getline, getdelim, popen, pclose, fdopen, fileno, fseeko, ftello, fmemopen, open_memstream
  • GNU·BSD 계열: asprintf, vasprintf
  • MSVC: 여러 밑줄 접두사 함수와 콘솔 전용 확장

사용하려는 대상 플랫폼과 기능 검사 매크로를 명확하게 정한 뒤 사용해야 한다.

표준 입출력은 곧바로 시스템 콜이 아니다

printf 한 번이 항상 write 시스템 콜 한 번이 되는 것은 아니다. 표준 입출력 라이브러리는 보통 사용자 공간 버퍼를 둔다. printf나 fwrite가 만든 데이터는 먼저 stdio 스트림의 버퍼에 쌓인다. 버퍼가 가득 차거나 줄바꿈을 만났을 때, 또는 프로그램이 fflush나 fclose를 호출했을 때 C 런타임이 운영체제에 실제 출력을 요청한다. 그 뒤에야 커널이 파일, 터미널 또는 파이프로 데이터를 전달한다.

일반적으로 파일은 완전 버퍼링되고, 터미널에 연결된 stdout은 줄 버퍼링되는 경우가 많다. stderr는 오류를 빨리 보여주기 위해 다른 정책을 사용할 수 있다. 다만 정확한 방식은 C 라이브러리 구현과 실행 환경에 따라 달라진다.

버퍼링 덕분에 작은 문자마다 커널로 전환하는 비용을 줄일 수 있다. 반대로 프로그램이 비정상 종료하면 아직 버퍼에 남은 출력이 사라질 수 있다.

파일 함수 호출은 어셈블리에서 어떻게 보일까?

다음 호출을 System V ABI 기준으로 보자.

FILE *file = fopen("data.bin", "rb");

대략 다음 형태가 된다.

    lea  rsi, [rip+.LC_mode]
    lea  rdi, [rip+.LC_name]
    call fopen@PLT
    test rax, rax
    je   .L_open_failed
  • 첫 번째 포인터 인자 파일명은 rdi에 들어간다.
  • 두 번째 포인터 인자 모드는 rsi에 들어간다.
  • 반환된 FILE *는 rax에 들어온다.
  • test rax, rax는 널 포인터인지 검사한다.

fwrite(buffer, 1, count, file)은 네 개의 인자를 사용한다.

RDI = buffer
RSI = 1
RDX = count
RCX = file
RAX = 반환된 항목 수

Windows x64에서는 같은 네 인자가 RCX, RDX, R8, R9 순서로 이동한다. 함수 이름은 같아도 호출 직전 레지스터 배치가 다른 이유다.

getc가 함수 호출로 보이지 않을 수도 있다

표준 라이브러리 함수는 헤더에서 매크로로도 정의될 수 있다. 특히 getc와 putc는 버퍼에 남은 문자가 있을 때 포인터를 직접 이동하는 빠른 경로를 매크로 또는 인라인 코드로 제공할 수 있다.

그래서 소스에 getc(file)가 있어도 어셈블리에는 다음 중 하나가 나타날 수 있다.

  • 공개 심벌 getc 호출
  • 구현 내부 함수 호출
  • 버퍼 포인터를 검사하고 한 바이트를 읽는 인라인 명령
  • 최적화 과정에서 다른 동등한 함수로 변환된 코드

이 내부 형태는 C 표준의 약속이 아니다. 성능을 분석할 때는 실제 대상의 헤더, 컴파일러 출력, 링크된 C 라이브러리를 함께 확인해야 한다.

실전 예제: stdio.h로 바이너리 파일 복사하기

다음 프로그램은 fopen, fread, fwrite, ferror, fprintf, perror, fclose, BUFSIZ, stderr를 함께 사용한다.

#include <stdio.h>

static int copy_file(const char *source_path, const char *target_path)
{
    FILE *source = fopen(source_path, "rb");
    if (source == NULL)
    {
        perror("source fopen");
        return 1;
    }

    FILE *target = fopen(target_path, "wb");
    if (target == NULL)
    {
        perror("target fopen");
        fclose(source);
        return 1;
    }

    unsigned char buffer[BUFSIZ];
    int status = 0;

    for (;;)
    {
        size_t read_count = fread(buffer, 1, sizeof buffer, source);

        if (read_count > 0)
        {
            size_t write_count = fwrite(buffer, 1, read_count, target);
            if (write_count != read_count)
            {
                perror("fwrite");
                status = 1;
                break;
            }
        }

        if (read_count < sizeof buffer)
        {
            if (ferror(source))
            {
                perror("fread");
                status = 1;
            }
            break;
        }
    }

    if (fclose(target) == EOF)
    {
        perror("target fclose");
        status = 1;
    }

    if (fclose(source) == EOF)
    {
        perror("source fclose");
        status = 1;
    }

    return status;
}

int main(int argc, char **argv)
{
    if (argc != 3)
    {
        fprintf(stderr, "usage: %s <source> <target>\n", argv[0]);
        return 2;
    }

    return copy_file(argv[1], argv[2]);
}

이 예제에서 중요한 점은 다음과 같다.

  • 바이너리 파일이므로 "rb", "wb"를 사용한다.
  • fopen의 널 포인터 반환을 즉시 검사한다.
  • fread와 fwrite의 반환값을 검사한다.
  • 마지막 짧은 읽기가 EOF인지 오류인지 ferror로 구분한다.
  • 출력 버퍼를 실제 파일로 내보내는 fclose(target)의 실패도 검사한다.
  • 오류 메시지는 정상 출력과 분리된 stderr로 보낸다.

어셈블리에서는 FILE 내부를 직접 조작하는 코드보다 fopen, fread, fwrite, fclose 호출과 반환값 비교가 주로 보인다. buffer[BUFSIZ]는 지역 배열이므로 스택 프레임이 커지거나, 컴파일러의 보안 옵션에 따라 스택 보호 코드가 추가될 수 있다.

어셈블리를 보기 좋은 학습 순서

한 번에 복잡한 프로그램을 열기보다 같은 짧은 코드를 여러 옵션으로 비교하는 편이 좋다.

  1. -O0으로 원본 제어 흐름과 지역 변수를 찾는다.
  2. -O1 또는 -O2로 인라인, 상수 접기, 죽은 코드 제거를 확인한다.
  3. -fno-omit-frame-pointer를 켰다 껐다 하며 스택 프레임을 비교한다.
  4. -fno-builtin-printf로 라이브러리 내장 최적화 차이를 본다.
  5. .s 파일과 objdump -dr -Mintel 결과를 비교한다.
  6. Linux와 Windows에서 인자 레지스터 차이를 확인한다.
  7. x86-64와 ARM64처럼 대상 CPU를 바꾸어 본다.

ARM64에서는 일반 정수·포인터 인자를 보통 x0부터 x7로 전달하고 반환값도 x0을 사용한다. C 소스가 같아도 대상 아키텍처가 바뀌면 명령어와 레지스터 이름은 완전히 달라진다.

자주 하는 실수

1. 어셈블리는 항상 하나뿐이라고 생각한다

같은 C 코드도 다음 조건에 따라 달라진다.

  • GCC, Clang, MSVC와 컴파일러 버전
  • -O0, -O2, -Os 같은 최적화 수준
  • x86-64, ARM64 같은 대상 CPU
  • Linux System V, Windows x64 같은 ABI
  • 정적 링크와 동적 링크
  • PIC, PIE, LTO, 디버그, 보안 강화 옵션

따라서 어셈블리 한 조각을 C의 유일한 정답으로 외우면 안 된다.

2. -O0을 기계적인 직역이라고 생각한다

최적화를 꺼도 컴파일러는 올바른 명령 선택, 레지스터 사용, 상수 표현, ABI 준수를 수행한다. C 문장 하나가 어셈블리 한 줄로 대응된다는 보장은 없다.

3. 형식 문자열과 타입을 맞추지 않는다

size_t count = 10;
printf("%d\n", count);       // 잘못된 형식
printf("%zu\n", count);      // 올바른 형식

가변 인자 함수에서는 불일치가 정의되지 않은 동작으로 이어질 수 있다. GCC와 Clang에서는 -Wformat=2 같은 경고를 켜는 것이 좋다.

4. 사용자 문자열을 형식 문자열로 사용한다

printf(user_text);           // 위험
printf("%s", user_text);     // 데이터로 출력

사용자가 %n 같은 변환을 넣을 수 있으면 메모리 손상이나 정보 노출로 이어질 수 있다. 형식 문자열은 프로그램이 소유하고, 사용자 입력은 인자로 전달해야 한다.

5. scanf("%s", buffer)에 크기 제한을 두지 않는다

%s는 배열 크기를 알지 못한다. 고정 배열이라면 너비를 제한하거나, fgets와 별도 변환 함수를 사용한다.

6. while (!feof(file))을 사용한다

EOF는 읽기를 시도해 실패한 뒤 설정된다. fgetc, fgets, fread, fscanf 자체의 반환값을 반복 조건으로 사용해야 한다.

7. fflush(stdin)에 의존한다

일부 환경의 확장 동작일 뿐 ISO C의 이식 가능한 입력 정리 방법이 아니다. 남은 입력을 소비하려면 입력 함수를 사용해 명시적으로 읽어야 한다.

8. fclose의 실패를 무시한다

출력 오류는 버퍼가 실제로 기록되는 fflush나 fclose 시점에 처음 발견될 수 있다. 중요한 파일을 쓸 때는 닫기 결과도 검사한다.

9. 구조체를 그대로 저장하면 이식 가능한 파일이라고 생각한다

패딩, 정렬, 엔디안과 타입 크기가 달라질 수 있다. 장기 보관이나 네트워크 전송용 형식은 필드를 명시적으로 직렬화해야 한다.

10. 정의되지 않은 동작의 어셈블리를 일반 규칙으로 해석한다

범위를 벗어난 배열 접근, 잘못된 포인터, 부호 있는 정수 오버플로, 틀린 형식 문자열처럼 C에서 정의되지 않은 동작이 있으면 컴파일러는 정상적인 프로그램에만 성립하는 가정을 사용한다. 그 결과가 예상과 달라도 어셈블리 버그라고 단정할 수 없다.

printf 한 줄에서 실제 출력까지

stdio_trace.c를 전처리하면 stdio.h의 선언과 매크로가 현재 번역 단위에 반영된다. 컴파일러는 그중 printf의 함수 원형을 확인하고, 형식 문자열과 값을 ABI가 정한 레지스터에 배치한다. 어셈블러는 call 명령과 아직 주소가 정해지지 않은 printf 심벌을 목적 파일에 기록한다.

링크 단계에서는 이 심벌이 C 런타임 라이브러리와 연결된다. 동적 링크 프로그램이라면 실행할 때 로더가 라이브러리를 메모리에 올리고 실제 주소를 해결한다. 호출된 printf는 형식 문자열을 해석해 stdio 버퍼에 문자를 기록하고, 버퍼 상태에 따라 런타임이 운영체제에 출력을 요청한다.

결국 stdio.h 전체가 어셈블리로 복사되는 것은 아니다. 헤더는 컴파일에 필요한 계약을 제공하고, 컴파일러는 그 계약에 맞는 호출 코드를 만든다. 실제 입출력은 런타임 라이브러리와 운영체제가 이어서 처리한다.

참고 자료

정리

C를 어셈블리어로 바꾸는 명령 자체는 짧다.

gcc -std=c17 -O0 -S -masm=intel source.c -o source.s

하지만 결과를 제대로 읽으려면 전처리와 링크, 대상 CPU, ABI, 최적화, 라이브러리 호출을 함께 이해해야 한다.

특히 stdio.h는 콘솔 출력 함수 몇 개만 모아 둔 헤더가 아니다. 파일과 스트림, 버퍼, 형식 입출력, 문자 입출력, 블록 입출력, 위치 이동, EOF와 오류 상태를 하나의 이식 가능한 인터페이스로 묶는다.

C 코드를 조금씩 바꾸며 -O0과 -O2 결과를 비교하고, 함수 호출 직전의 레지스터와 호출 뒤 rax 반환값을 추적해보자. 그러면 어셈블리어는 외워야 하는 긴 명령 목록이 아니라, C의 의미가 특정 CPU와 운영체제의 규칙으로 구체화된 결과로 보이기 시작한다.