CS:APP 한글 노트
제3장 · 프로그램의 기계 수준 표현
CHAPTER 03

프로그램의 기계 수준 표현

Machine-Level Representation of Programs

이번 장은 책 전체에서 가장 두꺼운 산이다. 우리가 평소에 쓰던 if, for, 함수 호출, 배열 같은 친숙한 친구들이 사실은 CPU 입장에서 보면 어떻게 해체·재조립되어 한 줄짜리 어셈블리 명령어들로 흩어지는지를 다룬다. C 코드가 어떻게 x86-64 어셈블리로 풀리고, 함수가 호출될 때 스택 프레임이 어떻게 차곡차곡 쌓였다 사라지며, 왜 옛날부터 gets() 같은 함수가 그렇게 욕을 먹었는지(=버퍼 오버플로)까지. 이 장만 제대로 씹으면 디버거, 보안, 성능 튜닝, 컴파일러를 보는 눈이 한 단계 올라간다.

3.1역사적 관점

x86 계열은 좀 가족력이 복잡한 친구다. 1978년의 8086에서 시작해서 286, 386, 486, 펜티엄, 코어 i 시리즈를 거쳐 오늘날의 x86-64까지 왔는데, 매 세대마다 이전 세대 코드를 그대로 돌릴 수 있어야 한다는 호환성 강박이 있었다. 덕분에 새로운 명령어가 추가될 뿐 옛 명령어가 사라지지 않았고, 결과적으로 명령어 집합이 거대한 지층처럼 누적되었다.

32비트 시절을 IA-32라고 부르고, AMD가 64비트로 확장한 것을 처음에 x86-64라고 했다. 인텔이 처음에 밀던 IA-64(Itanium)는 호환성을 버린 다른 아키텍처였는데 시장에서 거의 망했고, 결국 인텔도 AMD가 디자인한 64비트 확장을 따라가게 됐다. 그래서 지금 우리가 보는 64비트 데스크톱·서버 CPU는 Intel/AMD 공통의 x86-64다.

이 장은 GCC가 x86-64로 컴파일한 결과를 기준으로 설명한다. 오래된 32비트 IA-32나 ARM, RISC-V도 큰 그림은 비슷하지만, 레지스터 개수·이름·호출 규약(calling convention)이 다르므로 세부는 갈린다.

선배의 한마디

한 번 어셈블리를 익혀두면 다른 ISA(instruction set architecture)로 옮겨갈 때 학습 곡선이 훨씬 완만해진다. "레지스터에 값 넣고, 명령어로 연산하고, 메모리/스택을 만진다"는 골격은 어디서나 같으니까.

3.2프로그램 인코딩

C 소스 파일 foo.c가 어떻게 실행 파일이 되는지 잠깐 떠올려보자. 컴파일러(GCC)는 보통 다음 네 단계를 거친다.

  1. 전처리(Preprocess)#include, #define 같은 매크로 펼치기. 결과는 여전히 C 소스.
  2. 컴파일(Compile) — C → 어셈블리 텍스트(.s). 우리가 이 장에서 읽는 그 결과물이다.
  3. 어셈블(Assemble) — 어셈블리 → 기계어(.o, 오브젝트 파일).
  4. 링크(Link) — 여러 .o와 라이브러리를 묶어 실행 파일을 만든다.

중간 산출물을 보고 싶으면 gcc -Og -S foo.c를 치면 된다. -Og는 디버깅하기 좋게 최소한만 최적화하라는 옵션이라 어셈블리가 비교적 사람이 읽기 좋게 나온다. -O2로 올리면 더 빠르지만 코드가 마구 재배치되어 처음엔 매칭이 잘 안 보인다.

3.2.1기계 수준 코드

기계 수준 프로그래밍에서 우리가 신경 쓰는 추상화는 두 종류다.

  • ISA(instruction set architecture): CPU의 명령어 집합과 레지스터, 그 동작 의미. 프로그램 입장에서 보는 "CPU의 모양"이다. 명령어가 마치 한 번에 하나씩 차례대로 실행되는 것처럼 가정한다.
  • 가상 메모리(virtual memory): 모든 프로세스가 마치 거대한 연속된 바이트 배열을 혼자 쓰는 것처럼 느끼게 해 주는 OS+하드웨어의 합작품. 9장에서 깊이 다룬다.

중요한 것은 어셈블리 입장에서 평소 C에선 안 보이던 게 보인다는 점이다. 가령 프로그램 카운터(PC, x86-64에서는 %rip) — 다음에 실행할 명령어 주소. 16개의 범용 레지스터 — 정수와 포인터를 담는 64비트 저장소들. 그리고 조건 코드 — 직전 산술 연산 결과의 부산물 비트들. 마지막으로 부동소수점·SIMD 연산용 벡터 레지스터(XMM/YMM).

3.2.2코드 예제

아주 간단한 함수부터 보자.

// mstore.c
long mult2(long, long);

void multstore(long x, long y, long *dest) {
    long t = mult2(x, y);
    *dest = t;
}

gcc -Og -S로 컴파일하면 대략 이런 어셈블리가 나온다.

multstore:
    pushq   %rbx              # 콜리-세이브 레지스터 백업
    movq    %rdx, %rbx        # dest를 안전한 곳에 보관
    call    mult2             # t = mult2(x, y)
    movq    %rax, (%rbx)      # *dest = t
    popq    %rbx              # 백업한 값 복구
    ret                       # 호출자에게 돌아가기

여기서 보이는 패턴 몇 가지를 미리 머리에 넣어두자.

  • 인자는 레지스터로 들어온다. 첫 번째는 %rdi, 두 번째는 %rsi, 세 번째는 %rdx.
  • 반환값은 %rax로 나온다.
  • call은 함수 호출, ret은 반환. 둘 다 스택을 살짝 건드린다.
  • %rbx는 함수 호출이 끝나도 보존돼야 하는 레지스터(callee-saved)라서 처음에 push하고 끝에 pop한다.

3.2.3포맷 메모

이 노트는 GCC가 출력하는 AT&T 문법을 쓴다. 인텔 매뉴얼이나 인텔 문법(mov rax, rbx 같은)과는 표기가 좀 다르다. AT&T 규칙 정리.

  • 레지스터 이름 앞에 % — 예: %rax.
  • 즉시값(상수) 앞에 $ — 예: $0x10.
  • 오퍼랜드 순서는 소스 → 목적지. movq %rax, %rbx는 "rax에서 rbx로 복사".
  • 명령어 끝의 한 글자 접미사는 크기다. b(1바이트), w(2), l(4), q(8). 그래서 movq는 8바이트 이동.
  • 메모리 참조는 D(Rb, Ri, S) 형식 — M[Rb + Ri*S + D]를 의미한다.

3.3데이터 형식

C 타입과 x86-64에서의 크기·접미사 매핑은 다음과 같다. 자주 보는 표라서 외워두면 어셈블리 읽는 속도가 두 배는 된다.

C 타입크기(바이트)어셈블리 접미사
char1b (byte)
short2w (word)
int4l (long word)
long, 모든 포인터8q (quad word)
float4s (single)
double8l 또는 d (double)

여기서 헷갈릴 만한 포인트. C에서 int가 4바이트인데, 옛날 32비트 워드 시절의 잔재 때문에 어셈블리 접미사는 l(long)이다. 그래서 movl은 4바이트 이동이고, movq가 8바이트 이동이다. long이 사실 8바이트라서 q(quad)인 거고. 부동소수점도 floats(movss), doubled(movsd)로 표기된다.

3.4정보 접근

x86-64는 16개의 64비트 범용 레지스터를 갖는다. 이름이 좀 복잡한데, 같은 레지스터를 4가지 너비로 부를 수 있기 때문이다.

64비트32비트16비트8비트(low)주된 용도
%rax%eax%ax%al반환값
%rbx%ebx%bx%blcallee-saved
%rcx%ecx%cx%cl4번째 인자
%rdx%edx%dx%dl3번째 인자
%rsi%esi%si%sil2번째 인자
%rdi%edi%di%dil1번째 인자
%rbp%ebp%bp%bplcallee-saved (베이스 포인터)
%rsp%esp%sp%spl스택 포인터
%r8 ~ %r15%r8d ~%r8w ~%r8b ~r8/r9는 5·6번 인자, r10·r11은 caller-saved, r12~r15는 callee-saved
중요

32비트 연산을 64비트 레지스터에 쓰면 상위 32비트가 자동으로 0으로 채워진다. 즉 movl $1, %eax는 사실상 %rax 전체를 1로 만든다. 컴파일러가 이 트릭을 매우 자주 쓰니 놀라지 말 것. 반면 1바이트나 2바이트 쓰기는 상위가 보존된다.

3.4.1오퍼랜드 지정자 (Operand Specifiers)

명령어가 데이터를 가져올 수 있는 곳은 세 종류다.

  • 즉시값(immediate): $0x10처럼 명령어 자체에 박힌 상수.
  • 레지스터(register): %rax 같은 16개 중 하나.
  • 메모리(memory): 어떤 주소를 계산해서 그 자리의 바이트들.

메모리 주소 계산이 핵심이다. 일반형은 D(Rb, Ri, S) 이고, 이건 다음 식과 같다.

주소 = Rb + Ri * S + D

여기서 Rb는 베이스 레지스터, Ri는 인덱스 레지스터, S는 스케일(1/2/4/8 중 하나), D는 상수 변위(displacement). 빠진 칸은 0/1로 친다. 예시.

표기실제 주소의미
(%rax)R[%rax]rax가 가리키는 곳
8(%rbp)R[%rbp]+8구조체 필드/지역변수 접근에 흔함
(%rdi,%rsi,4)R[%rdi]+R[%rsi]*4int 배열 a[i] 패턴
16(%rdi,%rsi,8)R[%rdi]+R[%rsi]*8+16구조체 안의 long 배열의 i번째

이 한 줄짜리 표기로 배열 인덱싱·구조체 필드 접근·포인터 산술이 거의 다 처리된다. x86이 좋아하는 이유 중 하나다.

3.4.2데이터 이동 명령 (mov 계열)

가장 많이 쓰는 명령어가 단연 mov다. 너비별로 movb/movw/movl/movq가 있다.

movq  $0x10, %rax        # rax = 0x10  (즉시값 → 레지스터)
movq  %rcx, %rdx         # rdx = rcx   (레지스터 → 레지스터)
movq  (%rdi), %rax       # rax = *rdi  (메모리 → 레지스터, 즉 역참조)
movq  %rax, (%rdi)       # *rdi = rax  (레지스터 → 메모리, 저장)
movq  $0, 8(%rsp)        # *(rsp+8) = 0 (즉시값 → 메모리)

주의: x86-64에서는 메모리에서 메모리로 직접 옮기는 명령은 없다. 반드시 한 번 레지스터를 거쳐야 한다.

크기를 늘려 옮길 때는 movz(영 확장)와 movs(부호 확장)가 있다.

movzbl  %al, %ecx        # 1바이트를 4바이트로, 상위는 0으로 채움
movsbq  %al, %rcx        # 1바이트를 8바이트로, 부호 비트 복제로 채움
movslq  %eax, %rcx       # 32→64 부호 확장

한편 32비트 → 64비트 영 확장은 별도 명령이 필요 없다. movl이 자동으로 상위를 0으로 채우니까.

3.4.3데이터 이동 예제

포인터 다루는 작은 예제를 보자.

// xchg.c
long exchange(long *xp, long y) {
    long x = *xp;
    *xp = y;
    return x;
}
exchange:
    movq    (%rdi), %rax     # x = *xp;       반환값 자리(rax)에 바로 담는다
    movq    %rsi, (%rdi)     # *xp = y;
    ret                      # rax 그대로 반환

포인터는 그냥 8바이트 정수다. 별 게 없다. (%rdi)는 "rdi가 가리키는 메모리"라는 뜻이고, C로 치면 *xp다.

3.4.4스택 push/pop

스택은 실행 중에 함수 호출, 지역변수 보관, 레지스터 백업 등에 쓰는 메모리 영역이다. x86-64에서 스택은 아래로 자란다(주소가 작아진다). %rsp가 항상 스택의 꼭대기를 가리킨다.

pushq  %rax     # rsp -= 8;  *rsp = rax     (8바이트 push)
popq   %rax     # rax = *rsp; rsp += 8     (8바이트 pop)

마찬가지로 메모리에 직접 push/pop도 가능하다. 보존해야 할 값을 잠깐 보관하거나, 함수 호출 직전·직후에 자주 등장한다.

"스택이 아래로 자란다"가 처음엔 직관에 어긋나 보이지만, 그림에서는 위쪽이 낮은 주소로 그리는 관습이라 헷갈린다. pushq는 그림의 위로 올라가지만, 메모리 주소상으로는 작아진다.

3.5산술과 논리 연산

3.5.1LEA (Load Effective Address)

leaq는 이름과 달리 메모리를 만지지 않는다. 메모리 오퍼랜드 표기 D(Rb,Ri,S)가 의미하는 주소를 계산만 해서 목적 레지스터에 넣는다. 즉 (...)는 산술식의 가면일 뿐이고, 메모리에 가지 않는다.

leaq  (%rdi,%rdi,2), %rax     # rax = rdi + rdi*2 = rdi * 3
leaq  7(%rdi,%rsi,4), %rax    # rax = rdi + rsi*4 + 7
leaq  (,%rdi,8), %rax         # rax = rdi * 8

왜 컴파일러는 이걸 사랑할까? 한 명령어로 곱셈+덧셈을 동시에, 게다가 조건 코드도 안 건드리고 끝낼 수 있기 때문이다. x*3 + 5 같은 작은 다항식은 imul+add 두 줄 대신 leaq 한 줄로 끝난다.

3.5.2단항/이항 연산

대표적인 산술·논리 명령. 모두 너비 접미사가 붙는다(addq, addl 등).

명령의미
inc DD += 1
dec DD -= 1
neg DD = -D
not DD = ~D
add S, DD += S
sub S, DD -= S
imul S, DD *= S (부호 곱셈)
xor S, DD ^= S
and S, DD &= S
or S, DD |= S

이항 명령은 모두 "두 번째 오퍼랜드가 곧 목적지"인 것에 주의. subq %rcx, %rdxrdx -= rcx이지 rcx -= rdx가 아니다. 인텔 문법 보던 사람들이 자주 헷갈리는 지점.

3.5.3시프트 연산

salq  $3, %rax     # rax <<= 3   (산술/논리 왼쪽 시프트, 같은 동작)
shrq  $1, %rax     # rax >>= 1   (논리 오른쪽 시프트, 0으로 채움)
sarq  $1, %rax     # rax >>= 1   (산술 오른쪽 시프트, 부호 비트로 채움)

시프트 카운트는 즉시값으로 박거나 %cl(rcx의 하위 1바이트)에 넣어 간접으로 줘야 한다. 다른 레지스터로는 안 된다 — 이건 x86-64의 역사적 제약이다.

3.5.4토론

왜 이렇게 명령어가 많고 변종이 다양한가? 한마디로 흔한 패턴을 한 명령어로 처리하기 위해서다. 배열 인덱싱(D(Rb,Ri,S)), 작은 곱셈(leaq), 부호/영 확장(movs/movz) 같은 것들. CISC 계열이 RISC 계열보다 명령어 인코딩이 복잡한 대신 한 줄당 더 많은 일을 한다.

3.5.5특수 산술 연산

곱셈/나눗셈에서 64×64 = 128비트 결과가 필요한 경우엔 imulq(부호) / mulq(무부호) 단일 오퍼랜드 형식을 쓴다. 결과 상위 64비트는 %rdx, 하위 64비트는 %rax에 들어간다.

movq    %rdi, %rax
mulq    %rsi          # %rdx:%rax = %rax * %rsi  (무부호 128비트)

나눗셈도 마찬가지로 %rdx:%rax를 피제수로 쓴다. 부호 나눗셈 전엔 보통 cqto(또는 cqo)로 %rax의 부호 비트를 %rdx로 확장한 뒤 idivq를 부른다.

movq    %rdi, %rax
cqto                  # rdx:rax = rax를 부호확장
idivq   %rsi          # rax = quotient, rdx = remainder

3.6제어

여기서부터가 본게임이다. if, while, for, switch가 어떻게 어셈블리로 풀리는가?

3.6.1조건 코드

CPU는 직전 산술 연산의 부산물로 한 글자짜리 플래그 비트들을 갱신한다. 핵심은 네 개.

  • ZF (Zero Flag): 결과가 0이면 1.
  • SF (Sign Flag): 결과가 음수(최상위 비트=1)면 1.
  • CF (Carry Flag): 무부호 연산에서 자리올림/자리내림이 발생하면 1. 즉 무부호 오버플로 감지.
  • OF (Overflow Flag): 2의 보수 부호 오버플로가 발생하면 1.

add, sub, and 같은 ALU 명령이 자동으로 갱신한다. 단, leaq는 갱신하지 않는다(주소 계산용이라). 그리고 명시적으로 비교만 하고 결과를 버리는 두 명령이 있다.

cmpq   S1, S2     # S2 - S1 처럼 빼되, 결과는 버리고 플래그만 갱신
testq  S1, S2     # S2 & S1 처럼 AND하고, 플래그만 갱신

testq %rax, %rax는 "rax가 0인가?"를 묻는 가장 흔한 관용구다.

3.6.2조건 코드 접근

플래그를 직접 한 바이트로 끄집어내는 명령들이 SET cc 패밀리다. cc는 조건 코드 — e(equal), ne(not equal), l(less, 부호), le, g, ge, b(below, 무부호), a(above) 등.

cmpq   %rsi, %rdi
setl   %al             # rdi < rsi (부호) 이면 1, 아니면 0
movzbl %al, %eax       # 1바이트 결과를 4바이트로 영확장 (반환 준비)

이게 곧 C의 비교 연산자가 컴파일된 결과다. x < y ? 1 : 0이 두세 줄이면 끝.

3.6.3점프 명령

jmp는 무조건 점프, 그 외에는 모두 조건 점프다.

명령조건의미
je / jzZF=1같으면 / 0이면
jne / jnzZF=0다르면
jl / jngeSF^OF=1부호 <
jle(SF^OF) | ZF부호 ≤
jg~(SF^OF) & ~ZF부호 >
jbCF=1무부호 <
ja~CF & ~ZF무부호 >

3.6.4점프 명령 인코딩

점프 명령은 절대 주소가 아니라 PC 상대 주소(현재 명령어 다음 주소로부터의 오프셋)로 인코딩된다. 덕분에 코드를 메모리 어디에 로드해도 같은 바이너리가 돈다(위치 독립). 어셈블리 텍스트에선 보통 라벨로 보이고, 우리는 라벨이 어디로 향하는지만 신경쓰면 된다.

3.6.5조건 분기를 조건 제어로 구현

전통적 방법: cmp + 조건 점프로 두 갈래 길을 만든다.

// abs(x)
long absdiff(long x, long y) {
    long r;
    if (x < y) r = y - x;
    else       r = x - y;
    return r;
}
absdiff:
    cmpq    %rsi, %rdi          # x vs y
    jge     .L_ge               # x >= y 면 else 가지로
    movq    %rsi, %rax
    subq    %rdi, %rax          # rax = y - x
    ret
.L_ge:
    movq    %rdi, %rax
    subq    %rsi, %rax          # rax = x - y
    ret

3.6.6조건 분기를 조건 이동으로 구현

같은 함수를 cmovcc(conditional move) 패턴으로 컴파일하면 분기가 사라진다.

absdiff:
    movq    %rsi, %rax
    subq    %rdi, %rax          # ta = y - x
    movq    %rdi, %rdx
    subq    %rsi, %rdx          # tb = x - y
    cmpq    %rsi, %rdi
    cmovge  %rdx, %rax          # x >= y 이면 rax = tb
    ret

두 결과를 모두 미리 계산해두고, 조건에 따라 둘 중 하나만 채택한다. 왜 이게 빠르냐? 분기 예측이 틀리면 파이프라인이 통째로 비워지면서 수십 사이클을 손해 보는데, cmovcc는 그런 비용 없이 ALU 한 사이클로 끝난다.

단, 함정

두 후보 모두 안전하게 평가 가능해야 한다. 조건이 p != NULL 같은 NULL 체크라면, cmov로 바꾸면 NULL 역참조가 일어나서 폭발한다. 그래서 컴파일러는 부작용/예외 가능성 있는 분기엔 cmov를 안 쓴다.

3.6.7루프

x86-64에는 "루프"라는 직접적 명령이 없다. 컴파일러는 do-while, while, for를 각각 자기 패턴으로 풀어낸다. 가장 단순한 게 do-while이다.

// do-while 패턴 (가장 자연스러운 컴파일)
long fact_do(long n) {
    long r = 1;
    do { r *= n; n--; } while (n > 1);
    return r;
}
fact_do:
    movl    $1, %eax            # r = 1
.L_loop:
    imulq   %rdi, %rax          # r *= n
    decq    %rdi                # n--
    cmpq    $1, %rdi
    jg      .L_loop             # n > 1 이면 계속
    ret

while이나 for는 보통 "조건 먼저 점프해서 본문 내려가기"(jump-to-middle) 또는 "guarded do-while"(guarded-do) 둘 중 하나로 풀어낸다. 최적화가 강하면 후자를 선호한다 — 한 번 조건 검사한 뒤 do-while로 들어가면 본문 안에서 점프가 한 번씩만 일어나니까.

3.6.8switch 문

switch가 if-else 사슬보다 빠른 진짜 이유는 점프 테이블(jump table)이다. 케이스 값들이 좁은 범위에 모여 있으면, 컴파일러는 인덱스로 바로 점프 대상을 룩업한다.

switch_eg:
    cmpq    $6, %rdi
    ja      .L_default
    jmpq    *.LJTI(,%rdi,8)     # 점프 테이블 룩업해서 점프
.LJTI:
    .quad   .L_case0
    .quad   .L_case1
    .quad   .L_default          # 비어있는 케이스는 default로
    ...

케이스 수가 많고 값이 흩어져 있으면 점프 테이블이 너무 커지므로, 그땐 if-else 트리나 이진 검색으로 바꾼다. 컴파일러가 알아서 판단한다.

3.7프로시저

함수 호출이 어떻게 이뤄지는가는 어셈블리 학습의 절반쯤 된다. 핵심은 호출자(caller)와 피호출자(callee)가 같은 규칙으로 약속을 지킨다는 것이다. 이 약속이 바로 호출 규약(calling convention)이고, x86-64 리눅스/맥에선 System V AMD64 ABI를 쓴다.

3.7.1런타임 스택

각 함수 호출은 자기만의 스택 프레임(stack frame)을 갖는다. 인자 일부, 지역변수, 보존해야 할 레지스터, 반환 주소 등이 여기 들어간다. 새 함수가 호출되면 프레임이 위로 쌓이고, 반환하면 깔끔히 사라진다.

높은 주소
+----------------------+
| 호출자(caller)의 프레임 |
+----------------------+   ← 호출 시점의 %rsp
| 반환 주소 (8B)        |   ← call 명령이 자동으로 push
+----------------------+
| 보존된 레지스터들       |
| 지역 변수들            |
| ...                  |
+----------------------+   ← 현재 %rsp (callee 안에서)
낮은 주소

3.7.2제어 전이 (call/ret)

함수 호출의 핵심은 두 명령이다.

  • call F: 다음 명령의 주소를 스택에 push하고, F로 점프.
  • ret: 스택에서 8바이트를 pop해 그 주소로 점프.

둘이 짝을 맞추기만 하면 함수가 깔끔하게 돌아온다. 하지만 짝이 안 맞으면(예: 스택을 망가뜨려서 반환 주소를 덮어쓰면) 임의의 주소로 점프하게 된다 — 이게 곧 버퍼 오버플로 공격의 핵심이다(3.10에서 다시).

3.7.3데이터 전이 (인자/반환값)

System V AMD64 ABI는 정수·포인터 인자를 다음 순서의 레지스터로 전달한다.

  1. %rdi
  2. %rsi
  3. %rdx
  4. %rcx
  5. %r8
  6. %r9

7번째 인자부터는 스택에 푸시. 부동소수점 인자는 별도로 %xmm0~%xmm7에 들어간다. 반환값은 정수면 %rax, 부동소수점이면 %xmm0.

그리고 레지스터들은 caller-saved(호출자 보관)와 callee-saved(피호출자 보관)로 나뉜다.

구분레지스터의미
caller-saved%rax, %rdi, %rsi, %rdx, %rcx, %r8, %r9, %r10, %r11함수 호출이 자유롭게 망가뜨릴 수 있다. 호출자가 필요하면 미리 지킨다.
callee-saved%rbx, %rbp, %r12, %r13, %r14, %r15피호출자는 처음에 push해서 백업하고 끝에 pop해서 복구해야 한다.
특수%rsp스택 포인터. 모두가 깔끔히 유지해야 함.

3.7.4스택의 지역 저장

지역변수가 레지스터에 다 못 들어가거나, 그 변수의 주소를 누군가 가져갔거나(&v), 배열이거나, 너무 커서 레지스터에 안 맞으면 스택에 잡힌다.

// stack_eg.c
long sum_array(long *arr, long n);

long caller(void) {
    long buf[3] = {1, 2, 3};
    return sum_array(buf, 3);
}
caller:
    subq    $24, %rsp           # buf[3] 3*8B = 24B 자리 만들기
    movq    $1, (%rsp)
    movq    $2, 8(%rsp)
    movq    $3, 16(%rsp)
    movq    %rsp, %rdi          # 첫 인자 = buf 시작주소
    movl    $3, %esi            # 두 번째 인자 = 3
    call    sum_array
    addq    $24, %rsp           # 스택 정리
    ret

3.7.5레지스터의 지역 저장

함수가 callee-saved 레지스터(%rbx 등)를 쓰고 싶다면 처음에 push해서 백업한 뒤 마지막에 pop으로 복구해야 한다. 안 그러면 호출자가 갖고 있던 값이 사라져서 디버깅 지옥이 펼쳐진다.

foo:
    pushq   %rbx                # callee-saved 백업
    movq    %rdi, %rbx          # rbx를 자유롭게 사용 시작
    ...
    popq    %rbx                # 복구
    ret

3.7.6재귀 프로시저

재귀가 문제없이 동작하는 이유는 각 호출이 자기 스택 프레임을 갖기 때문이다. 같은 함수를 100번 부르든, 100개의 다른 프레임이 쌓일 뿐이다. 어셈블리적으로 특별한 점은 없다.

long fact(long n) {
    if (n <= 1) return 1;
    return n * fact(n - 1);
}
fact:
    movl    $1, %eax            # 기본값 1
    cmpq    $1, %rdi
    jle     .L_done
    pushq   %rbx                # n을 보존하기 위해 callee-saved 사용
    movq    %rdi, %rbx          # rbx = n
    leaq    -1(%rdi), %rdi      # 인자 = n - 1
    call    fact                # 재귀 호출 → rax = fact(n-1)
    imulq   %rbx, %rax          # rax = n * fact(n-1)
    popq    %rbx
.L_done:
    ret

n=4를 호출하면 스택은 이렇게 자라났다 줄어든다.

fact(4) 호출 ┐
            ↓ ret-addr-1, n=4 (rbx 백업)
   fact(3) 호출 ┐
               ↓ ret-addr-2, n=3
      fact(2) 호출 ┐
                  ↓ ret-addr-3, n=2
         fact(1)  → return 1
      fact(2) ← 2*1 = 2
   fact(3) ← 3*2 = 6
fact(4) ← 4*6 = 24

3.8배열 할당과 접근

3.8.1기본 원리

C에서 T A[N]sizeof(T) * N 바이트의 연속 메모리다. 그리고 A는 그냥 첫 원소 주소를 의미한다. int a[5]면 20바이트 연속 공간, a&a[0]과 같다.

인덱싱 a[i]의 어셈블리는 곧 D(Rb,Ri,S) 그 자체다.

// int a[N] 안에서 a[i]에 접근
movl  (%rdi,%rsi,4), %eax    # rdi = a, rsi = i, 스케일 4 (int 크기)

3.8.2포인터 산술

C에서 p + i는 항상 요소 단위다. int *p이고 p + 3이면 실제 주소는 p + 3*4 = p + 12다. 어셈블리 입장에선 곱셈이 들어가지만, 그 곱셈은 보통 D(Rb,Ri,S)의 스케일이 흡수해버린다.

C 표현타입어셈블리
a[i]int*(a+i)movl (%rdi,%rsi,4),%eax
&a[i]int*a+ileaq (%rdi,%rsi,4),%rax
a + iint*a+i위와 같음
*(a+i)inta[i]위와 같음

3.8.3중첩 배열

2차원 배열 int A[R][C]는 메모리상 행 우선(row-major)으로 펼쳐진다. A[i][j]의 주소는 &A[0][0] + (i*C + j)*4.

// int A[3][5];   A[i][j] 접근, %rdi=&A, %rsi=i, %rdx=j
movq   %rsi, %rax
imulq  $5, %rax              # rax = i*5
addq   %rdx, %rax             # rax = i*5 + j
movl   (%rdi,%rax,4), %eax    # A[i][j]

3.8.4고정 크기 배열

차원 길이가 컴파일 타임에 알려져 있으면 컴파일러는 곱셈을 시프트나 leaq 트릭으로 바꾼다. 예를 들어 i*5(i<<2)+i로, leaq (%rsi,%rsi,4), %rax 한 줄.

3.8.5가변 크기 배열

C99의 VLA(int A[n][n])는 행 길이가 런타임 변수라서 곱셈을 진짜로 한다. 그래도 인덱싱 패턴 자체는 같고, 다만 스케일이 imulq로 들어간다.

3.9이종 데이터 구조

3.9.1구조체

구조체는 필드가 선언 순서대로 한 덩어리 메모리에 자리 잡는 형태다. 어셈블리에선 그냥 "베이스 주소 + 오프셋"으로 접근한다.

struct rec {
    int  i;
    int  j;
    long arr[2];
} *r;

오프셋은 i=0, j=4, arr=8(여기서 8바이트 정렬 시작), 끝은 24. 그래서 r->arr[1] = r->i는 다음과 비슷하게 컴파일된다.

movl    (%rdi), %eax           # eax = r->i
movq    %rax, 16(%rdi)         # r->arr[1] = ... (오프셋 8 + 1*8 = 16)

3.9.2공용체

공용체(union)는 모든 필드가 같은 자리를 공유한다. 크기는 가장 큰 필드의 크기. "이 메모리를 두 가지 타입 중 하나로 본다"는 의미라서, 어셈블리 차원에선 그냥 한 덩어리 바이트일 뿐이다. 비트 패턴 재해석(예: float의 비트를 int로 보기)에 자주 쓴다.

3.9.3데이터 정렬 (alignment)

x86-64는 정렬되지 않은 접근도 동작은 하지만, 성능과 호환성을 위해 컴파일러는 다음 규칙을 강제한다.

  • char(1B): 정렬 1
  • short(2B): 정렬 2
  • int, float(4B): 정렬 4
  • long, double, 포인터(8B): 정렬 8

그래서 다음 구조체는 패딩이 들어간다.

struct s {
    char  c;     // 오프셋 0
    // padding 3바이트
    int   i;     // 오프셋 4
    char  d;     // 오프셋 8
    // padding 7바이트
    long  l;     // 오프셋 16
};               // 총 크기 24바이트, 8 정렬

구조체 자체의 크기도 가장 큰 필드의 정렬 배수로 끝나야 한다(배열에서 다음 원소도 정렬을 만족해야 하므로). 필드 순서를 큰 것부터 → 작은 것 순으로 재배치하면 패딩이 줄어든다. 메모리 절약하고 싶으면 의식적으로 신경 쓸 부분.

"왜 내 구조체는 9바이트가 아니라 16바이트지?"라는 질문의 답이 보통 정렬과 패딩이다. sizeof로 찍어보면 바로 보인다.

3.10기계 수준에서 제어와 데이터 결합

3.10.1포인터 이해하기

몇 가지 짚어둘 만한 사실.

  • 포인터마다 타입이 있지만, 기계 수준에선 모두 그저 8바이트 주소다. 타입은 컴파일러가 인덱싱 스케일을 정할 때만 의미가 있다.
  • NULL은 보통 0번지지만, 그 자체가 매핑되지 않은 페이지라 역참조하면 SIGSEGV가 난다.
  • 함수 포인터는 코드 영역의 주소다. fp()는 결국 call *%rax 같은 간접 호출로 컴파일된다.
  • void *도 8바이트 주소. 산술이 정의되지 않을 뿐.

3.10.2실세계: gdb 사용

gdb는 어셈블리 학습의 가장 좋은 친구다. 자주 쓰는 명령 몇 개.

명령설명
disas foo함수 foo 디스어셈블
break *0x40050a특정 주소에 브레이크포인트
stepi / si한 명령어씩 실행
info registers모든 레지스터 보기
x/4xg $rsprsp부터 8바이트 4개를 16진수로
print /x $raxrax를 16진수로 출력

3.10.3범위 밖 메모리 참조와 버퍼 오버플로

고전적 사례를 보자.

// 위험한 함수
void echo(void) {
    char buf[8];
    gets(buf);          // 버퍼 크기 무시하고 줄 끝까지 받음
    puts(buf);
}

스택은 아래로 자라므로 buf[0]이 가장 작은 주소, buf[7]이 그 다음, 그 위로 보존된 레지스터들과 반환 주소가 있다. 사용자가 입력을 8바이트 넘게 주면 buf를 넘쳐 반환 주소를 덮어쓴다. 공격자가 임의 주소로 점프시킬 수 있게 되는 순간이다.

높은 주소
+----------------------+
| ...                  |
| 반환 주소 (8B)        |  ← gets()가 여기까지 덮을 수 있다
+----------------------+
| 보존된 %rbx 등        |
+----------------------+
| buf[7]               |
| buf[6]               |
| ...                  |
| buf[0]               |  ← rsp
+----------------------+
낮은 주소

옛날엔 입력 안에 셸코드(/bin/sh를 띄우는 짧은 기계어)를 넣고, 반환 주소를 그 셸코드로 점프시키는 게 정석이었다. 요즘은 NX(스택은 실행 불가)가 깔려서 셸코드 직접 실행이 막혔지만, 그래서 등장한 게 return-to-libc(이미 메모리에 있는 system() 같은 함수로 점프)와 일반화 버전인 ROP(return-oriented programming)다. ROP는 프로그램에 이미 있는 짧은 명령어 조각들(...; ret로 끝나는 가젯들)을 줄줄이 엮어서 마치 새 코드를 짠 것처럼 동작시킨다.

절대 금지

gets(), strcpy(), sprintf()처럼 길이를 안 받는 함수는 새 코드에 절대 쓰지 말 것. fgets(), strncpy()(또는 strlcpy), snprintf()를 써라. 사실 gets()는 C11에서 표준에서 삭제됐다.

3.10.4버퍼 오버플로 공격 방지

현대 시스템엔 보통 세 가지 방어막이 같이 깔려 있다.

  1. 스택 카나리(stack canary, stack protector): 함수 시작 시 스택의 지역 버퍼와 반환 주소 사이에 무작위 8바이트(카나리)를 심고, 반환 직전에 값이 그대로인지 검사한다. 공격자가 반환 주소를 덮으려면 카나리도 덮어야 하는데, 무작위라 모르고 쓰면 검사에서 걸린다. GCC -fstack-protector.
  2. NX 비트(W^X, no-execute): 메모리 페이지마다 "쓰기 가능"과 "실행 가능"이 동시에 켜질 수 없게 한다. 스택은 쓰기는 되지만 실행은 안 되므로, 거기 박아넣은 셸코드를 점프해도 즉시 SIGSEGV.
  3. ASLR(address space layout randomization): 실행할 때마다 스택, 힙, 라이브러리, 코드 영역의 시작 주소를 무작위화. 공격자가 "system() 주소가 0x7ffff7e1c0c0"이라고 미리 못 박을 수 없다.

이 셋이 함께 있으면 1990년대식 단순 스택 스매싱은 거의 안 통한다. 그래도 ROP, 정보 누설(info leak)을 통한 ASLR 우회, 카나리 누출 등 더 정교한 공격이 계속 나오고 있어서 보안은 여전히 진행형이다.

3.10.5가변 크기 스택 프레임 지원

VLA나 alloca()로 런타임에 크기가 정해지는 지역 영역이 필요하면, 컴파일러는 %rbp프레임 포인터로 같이 쓴다. 함수 입장에서 %rbp를 한 번 고정해두면, %rsp가 도중에 바뀌어도 지역변수를 -N(%rbp)로 안정적으로 접근할 수 있다. 고정 크기 함수에선 보통 %rbp 없이 %rsp 기반으로 다 처리한다(-fomit-frame-pointer).

3.11부동소수점 코드

x86-64에서 float/double 연산은 SSE/AVX 계열의 XMM/YMM 레지스터를 쓴다. 옛날 x87 스택형 FPU는 거의 안 쓰인다. 정수 명령어 세계와 거의 평행한 또 하나의 우주를 갖는 셈이다.

3.11.1부동소수점 이동/변환

핵심 명령어 패밀리.

  • movss: 32비트 float 이동 (Move Scalar Single)
  • movsd: 64비트 double 이동 (Move Scalar Double)
  • cvtsi2ss, cvtsi2sd: 정수 → float/double
  • cvttss2si, cvttsd2si: float/double → 정수 (truncate, 절단)
  • cvtss2sd, cvtsd2ss: float ↔ double
cvtsi2sdq  %rdi, %xmm0       # double d = (double) (long) rdi;
movsd      %xmm0, (%rsi)     # *p = d;

3.11.2프로시저에서의 부동소수점 (XMM 레지스터)

부동소수점 인자는 %xmm0 ~ %xmm7로 들어가고, 반환값은 %xmm0에 담긴다. XMM 레지스터는 모두 caller-saved다. 즉, 함수 호출 후엔 보존된다는 보장이 없으니 호출자가 알아서 챙겨야 한다.

// 시그니처: double scale(double x, long n);
// %xmm0 = x, %rdi = n
scale:
    cvtsi2sdq  %rdi, %xmm1
    mulsd      %xmm1, %xmm0    # x *= (double)n
    ret                        # 반환값은 %xmm0

3.11.3부동소수점 산술

이름은 단순하다. add/sub/mul/div 뒤에 ss(float)나 sd(double)가 붙는다.

명령의미
addss / addsd덧셈
subss / subsd뺄셈
mulss / mulsd곱셈
divss / divsd나눗셈
sqrtss / sqrtsd제곱근
maxss / minsd최대/최소

3.11.4부동소수점 상수

부동소수점 즉시값은 명령어 안에 못 박는다. 컴파일러는 상수를 데이터 섹션(.rodata)에 두고, movsd .LC0(%rip), %xmm0처럼 RIP 상대 주소로 읽어온다.

3.11.5부동소수점에서 비트 연산 (부호 토글)

"부호 비트만 뒤집기" 같은 작업엔 정수 연산이 더 빠르다. 부호 비트는 IEEE 754에서 최상위 비트인데, 이걸 그냥 XOR해버리면 끝.

// double x를 -x로
movsd   .LC_signmask(%rip), %xmm1   # 부호 비트만 1인 마스크 0x80000...
xorpd   %xmm1, %xmm0                 # 부호 토글

3.11.6부동소수점 비교

비교는 ucomiss(float) / ucomisd(double). 결과는 CF, ZF, PF 플래그로 나온다. PF(parity flag)가 비교 결과 중 하나가 NaN인지 알려준다 — NaN은 어떤 값과 비교해도 모두 false여야 하므로 별도 체크가 필요하다.

3.11.7관찰

요약하면, 부동소수점 코드는 정수 코드와 평행한 명령어 세트와 레지스터 세트를 가진다. 인자/반환 규약, caller/callee-saved 구분, 메모리 접근 방식은 정수 쪽과 거의 같은 패턴이라 한쪽을 익히면 다른 쪽도 빠르게 익힌다.

3.12요약

3장은 길었다. 핵심을 한 번 묶어보자.

  • C 코드는 컴파일러를 거쳐 어셈블리로, 다시 기계어로 풀린다. gcc -Og -S로 직접 보면 학습이 빠르다.
  • x86-64는 16개의 64비트 범용 레지스터, RIP, 조건 코드 비트, XMM 부동소수점 레지스터를 가진다.
  • 모든 메모리 접근은 D(Rb,Ri,S) 한 줄로 처리할 수 있을 만큼 표현력이 좋다. leaq는 그 표현을 산술 트릭으로도 활용한다.
  • 제어 흐름은 cmp/test로 플래그를 세팅하고 조건 점프 또는 조건 이동(cmovcc)으로 분기한다. cmovcc는 분기 예측 미스를 피하는 최적화다.
  • 함수 호출은 System V AMD64 ABI를 따른다. 정수 인자 6개는 %rdi/%rsi/%rdx/%rcx/%r8/%r9, 반환은 %rax. 레지스터는 caller-saved와 callee-saved로 나뉜다.
  • 스택 프레임은 호출마다 새로 쌓인다. 지역 배열, 주소를 가져간 변수, 콜리-세이브 백업 등이 그 안에 들어간다.
  • 배열 인덱싱과 구조체 필드 접근은 모두 "베이스 + 오프셋" 또는 "베이스 + 인덱스*스케일 + 변위" 패턴이다. 정렬 규칙 때문에 구조체엔 패딩이 들어간다.
  • 버퍼 오버플로는 30년 묵은 단골 취약점이다. 스택 카나리, NX 비트(W^X), ASLR 세 가지가 현대 시스템의 1차 방어선이다. 그래도 길이 검사 안 하는 입력 함수는 절대 쓰지 말 것.
  • 부동소수점 코드는 XMM 레지스터와 SSE 명령어로 처리된다. 정수 세계와 별개이지만 호출 규약과 메모리 접근 패턴은 일관적이다.
다음 장 예고

여기까지가 "CPU의 인터페이스(ISA)"를 사람 입장에서 사용하는 법이었다. 다음 장에서는 그 인터페이스 뒤편으로 넘어간다. CPU 내부에서 명령어가 어떻게 실제로 해독되고 실행되며, 파이프라이닝으로 어떻게 동시에 여러 명령어가 진행되는지 — 즉 프로세서 그 자체의 내부 구조를 본다.

연습문제

Q1. 다음 어셈블리를 C로 풀어보자. %rdi=x, %rsi=y로 가정한다.

foo:
    leaq   (%rdi,%rdi,2), %rax    # ?
    addq   %rsi, %rax              # ?
    ret

힌트. leaq가 산술 표현이라는 사실을 떠올려라. 답: return 3*x + y;

연습문제

Q2. 구조체 struct { char c; int i; char d; long l; }sizeof는? 각 필드의 오프셋도 답해라.

답. c=0, i=4(패딩 3), d=8, l=16(패딩 7). 총 24바이트, 끝의 패딩 0바이트(이미 8 정렬).

연습문제

Q3.cmov가 항상 점프보다 빠른 것은 아닐까? 한 가지 시나리오를 들어라.

답. 두 후보 중 한쪽이 부작용/예외 가능성이 있으면 cmov로 못 바꾼다(예: NULL 체크 후 역참조). 또 한쪽 계산이 매우 비싼데 분기 예측이 거의 항상 맞는 경우엔 점프 쪽이 더 빠를 수도 있다.