LabHub
시작하기
배우기 러닝패스 코스

컴파일러 — 작은 언어를 처음부터 끝까지 만든다

레지스터, 프레임, 그리고 16바이트 — 호출 규약

LabHub 에서 이어서 보기

한 줄 요약

네이티브 코드 생성은 트리를 CPU 명령(여기서는 x86-64 의 GNU as 문법)으로 옮기는 일입니다. 식의 값은 레지스터(%rax)에 두고, 모자라면 스택에 잠깐 올리며, 함수는 호출 규약(System V AMD64)이 정한 대로 인자를 레지스터로 넘기고 프레임을 세웁니다. 그 약속 가운데 가장 조용히 어겨지는 것이 call 직전 스택을 16바이트에 맞춘다는 규칙입니다.

왜 이게 필요했나

6모듈의 VM 은 명령 하나를 실행할 때마다 파이썬 반복문이 한 바퀴 돕니다. 네이티브 코드는 그 반복문이 없습니다 — CPU 가 명령을 직접 읽습니다. 대신 VM 이 대신 해 주던 일을 컴파일러가 전부 정해야 합니다. 값을 어느 레지스터에 둘지, 지역 변수를 메모리 어디에 둘지, 함수를 부를 때 인자를 어디로 넘기고 돌아올 자리를 어떻게 지킬지.

이 결정 가운데 일부는 우리 마음대로 정할 수 없습니다. 우리가 만든 코드가 C 라이브러리(printf)나 다른 컴파일러가 만든 코드와 서로 부르려면 같은 약속을 지켜야 합니다. 리눅스 x86-64 에서 그 약속이 System V AMD64 ABI 입니다.

어떻게 동작하나

식은 %rax 에, 모자라면 스택에. 이 코스의 코드 생성기는 가장 단순한 방식을 씁니다. 모든 식의 값은 %rax 에 둡니다. 두 항 연산은 왼쪽을 계산해 push %rax 로 스택에 올려 두고, 오른쪽을 계산해 %rcx 로 옮긴 뒤, 왼쪽을 pop %rax 로 내려 연산합니다. 레지스터를 아끼는 방법(9모듈의 레지스터 할당)은 쓰지 않으므로 느리지만, 어떤 식이든 틀리지 않게 옮길 수 있습니다.

print 1 + 2 * 3;                           main 의 프레임
    movabs $1, %rax                        높은 주소
    push %rax          ← 왼쪽(1)을 올림     │ 돌아갈 주소        │  call 이 넣음
    movabs $2, %rax                        │ 이전 %rbp          │  ← %rbp
    push %rax          ← 왼쪽(2)을 올림     │ 지역 변수 슬롯 0    │  -8(%rbp)
    movabs $3, %rax                        │ 지역 변수 슬롯 1    │  -16(%rbp)
    mov %rax, %rcx                         │ (16의 배수로 맞춤)  │
    pop %rax           ← 2                 │ 식 계산용 push …    │  ← %rsp
    imul %rcx, %rax    ← 6                 낮은 주소
    mov %rax, %rcx
    pop %rax           ← 1
    add %rcx, %rax     ← 7
    mov %rax, %rdi     ← 첫 인자
    call mini_print_int

호출 규약. 정수 인자는 %rdi %rsi %rdx %rcx %r8 %r9 순서로, 반환값은 %rax 로 넘깁니다. 받는 쪽은 push %rbp · mov %rsp, %rbp · sub $N, %rsp 로 프레임을 세우고, 인자를 자기 슬롯(-8(%rbp) …)에 옮겨 둡니다 — 몸체를 계산하는 동안 그 레지스터들이 다른 호출에 쓰이기 때문입니다. 끝나면 leave · ret 로 프레임을 걷고 돌아갑니다. 인자를 왼쪽부터 계산해 차례로 push 해 두었다가, 다 계산한 뒤 거꾸로 pop 해 레지스터에 실으면 인자 계산 중의 호출이 레지스터를 덮어써도 안전합니다.

16바이트 정렬. ABI 는 call 하는 순간 %rsp 가 16의 배수이기를 요구합니다. 함수에 들어오면 돌아갈 주소(8바이트) 때문에 8이 어긋나 있고, push %rbp 로 다시 맞으며, 프레임 크기를 16의 배수로 잡으면 몸체의 처음에는 맞은 상태입니다. 그 뒤로 식 계산용 push 가 하나 늘 때마다 8바이트씩 어긋났다 맞았다 합니다. 그래서 지금 올려 둔 칸 수가 홀수인 채로 call 하면 규칙을 어깁니다 — 1 + f(2) 처럼 왼쪽을 올려 둔 채 오른쪽에서 부르는 경우입니다. 그때는 sub $8, %rsp 로 맞춘 뒤 부르고, 돌아와서 add $8, %rsp 로 되돌립니다.

이 규칙은 어겨도 대개 조용합니다. printf 안에서 16바이트 정렬을 요구하는 SSE 명령(movaps)을 만나는 날에만 세그먼트 오류로 죽습니다 — 어떤 입력에서는 되고 어떤 입력에서는 안 되는, 가장 나쁜 종류의 버그입니다. 이 실습의 런타임 도우미(/opt/fixtures/mini/runtime.c)는 불릴 때마다 정렬을 확인해 곧바로 알려 줍니다.

런타임 도우미. 0 나누기·INT64_MIN / -1·음수 지수 검사는 코드 생성기가 식마다 흩어 쓰는 대신 C 로 쓴 도우미(mini_div·mini_mod·mini_pow)에 줄·칸과 함께 넘깁니다. idivINT64_MIN / -1 에서 CPU 예외(SIGFPE)를 내므로 그대로 쓰면 인터프리터와 뜻이 갈립니다.

현장에서 만나는 모습

다음 실습에서 할 것

codegen.py 에 main 과 출력, 산술·비교·런타임 도우미 호출, 전역 변수(.data), 블록 지역 변수(프레임 슬롯), if·while·단락 평가(이름표와 점프), 함수와 인자 레지스터를 차례로 만들고, 계산 도중의 호출에서 스택 정렬을 지키는지 확인한 뒤, 프로그램 전체를 어셈블리로 옮겨 gcc 로 굽는 build 까지 만듭니다. 채점기는 매 단계 여러분의 어셈블리를 실제로 구워 실행하고 인터프리터와 같은 줄을 찍는지 봅니다.