レジスタ、フレーム、そして 16 バイト — 呼び出し規約
한국어 원문으로 표시합니다.
한 줄 요약
네이티브 코드 생성은 트리를 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)에 줄·칸과 함께 넘깁니다. idiv 는 INT64_MIN / -1 에서 CPU 예외(SIGFPE)를 내므로 그대로 쓰면 인터프리터와 뜻이 갈립니다.
현장에서 만나는 모습
gcc -O0 -S의 모양. 최적화하지 않은 gcc 출력도 이 방식과 닮았습니다 — 모든 지역 변수가-8(%rbp)같은 스택 칸에 있고, 쓸 때마다 읽고 씁니다. 10모듈에서-O2출력과 나란히 보면 레지스터 할당과 최적화가 무엇을 걷어 내는지 보입니다.- 정렬 사고. 손으로 쓴 어셈블리나 JIT 가 만든 코드가 C 함수를 부를 때 정렬을 어겨
movaps에서 죽는 일은 실제로 흔합니다. 스택 정렬은 버그 보고서에 "어떤 입력에서만 printf 안에서 세그먼트 오류" 로 나타납니다. - 다른 ABI. Windows x64 는 인자 레지스터가
%rcx %rdx %r8 %r9이고 부르는 쪽이 32바이트의 '그림자 공간' 을 마련해야 합니다. 같은 CPU 라도 운영체제마다 약속이 다르다는 것이 크로스 컴파일에서 자주 부딪히는 지점입니다.
다음 실습에서 할 것
codegen.py 에 main 과 출력, 산술·비교·런타임 도우미 호출, 전역 변수(.data), 블록 지역 변수(프레임 슬롯), if·while·단락 평가(이름표와 점프), 함수와 인자 레지스터를 차례로 만들고, 계산 도중의 호출에서 스택 정렬을 지키는지 확인한 뒤, 프로그램 전체를 어셈블리로 옮겨 gcc 로 굽는 build 까지 만듭니다. 채점기는 매 단계 여러분의 어셈블리를 실제로 구워 실행하고 인터프리터와 같은 줄을 찍는지 봅니다.