호출 규약과 ABI
ISA는 명령어 하나하나의 뜻을 정할 뿐, 함수끼리 어떻게 대화할지는 정하지 않는다. 인자는 어느 레지스터에 넣는지, 어떤 레지스터는 호출 뒤에도 값이 남아 있어야 하는지, 스택은 어떻게 쓰는지, long은 몇 바이트인지. 이런 약속이 ABI(Application Binary Interface)이고, RISC-V에서는 psABI 규격이 정한다. 이 장에서는 재귀 함수의 스택이 자라고 줄어드는 모습을 직접 보고, 컴파일러와 링커가 주소를 만들고 줄이는 방법까지 따라간다.
- ISA와 ABI의 경계를 구분하고, ABI가 왜 따로 필요한지 설명한다.
- a·t·s·ra·sp 레지스터의 호출자·피호출자 저장 책임을 안다.
- 프롤로그·에필로그와 16바이트로 정렬된 스택 프레임을 그릴 수 있다.
- 스칼라·2×XLEN 값·구조체·가변 인자·부동소수점 인자의 전달 규칙을 적용한다.
- ilp32·ilp32f·ilp32d·ilp32e·lp64·lp64f·lp64d·lp64q 데이터 모델을 구별한다.
call·la가 auipc 쌍과 %hi·%lo·%pcrel 재배치로 바뀌고, 링커 완화로 줄어드는 과정을 따라간다.- PIC의 GOT·PLT와 tp 기반 TLS를 개략적으로 이해한다.
ISA만으로는 부족하다
3장에서 본 것처럼 하드웨어에게 x10과 x18은 똑같다. jal은 복귀 주소를 아무 레지스터에나 남길 수 있고, 스택 포인터라는 하드웨어 레지스터도 없다. 그런데 GCC로 컴파일한 main.o가 LLVM으로 컴파일한 libm.so의 sqrt를 부르려면, 두 컴파일러가 같은 약속을 지켜야 한다. 그 약속의 묶음이 ABI(Application Binary Interface)다. 특정 프로세서에 맞춘 ABI라서 psABI(processor-specific ABI)라고 부른다.
RISC-V ABIs Specification 1.0은 2022년 11월에 비준되었다. 서문은 이 문서가 주요 오픈 소스 툴체인·운영체제 커뮤니티와 함께 이미 합의되고 구현된 내용을 기록한 것이며, 호환되지 않는 변경은 ABI를 깨뜨리므로 “꼭 필요한 경우가 아니면 하지 않는다”고 밝힌다. 시스템 호출 규약은 이 문서의 범위가 아니며 운영체제 커널 ABI나 SBI 같은 실행 환경 쪽에서 정한다.
| ISA가 정하는 것 | ABI가 정하는 것 |
|---|---|
jal rd, offset이 rd에 pc+4를 쓴다 | 함수 호출에는 rd = x1(ra)을 쓴다 |
| x2는 평범한 범용 레지스터 | x2(sp)는 스택 포인터이고 항상 16바이트로 정렬 |
lw는 4바이트를 읽는다 | C의 int는 4바이트, long은 RV32에서 4, RV64에서 8바이트 |
auipc가 pc 기준 상위 주소를 만든다 | 링커가 채울 칸을 R_RISCV_PCREL_HI20 재배치로 표시한다 |
레지스터 사용 약속: 누가 저장하나
함수를 부르면 피호출자가 레지스터를 마음대로 쓴다. 그러면 호출자가 넣어 둔 값은 어떻게 되나? psABI는 레지스터를 두 무리로 나눠 책임을 정한다. 호출 뒤 보존되는(preserved across calls) 레지스터는 피호출자가 쓰기 전에 저장했다가 돌아가기 전에 되돌려 놓아야 한다(피호출자 저장(callee-saved)). 보존되지 않는 레지스터는 피호출자가 마음대로 망가뜨려도 되므로, 호출 뒤에도 그 값이 필요하면 호출자가 미리 저장해야 한다(호출자 저장(caller-saved)).
| 이름 | ABI 이름 | 뜻 | 호출 뒤 보존? |
|---|---|---|---|
x0 | zero | Zero | — (불변) |
x1 | ra | Return address | 아니오 |
x2 | sp | Stack pointer | 예 |
x3 | gp | Global pointer | — (할당 대상 아님) |
x4 | tp | Thread pointer | — (할당 대상 아님) |
x5–x7 | t0–t2 | Temporary | 아니오 |
x8–x9 | s0–s1 | Callee-saved (s0는 프레임 포인터를 쓸 때 fp) | 예 |
x10–x17 | a0–a7 | Argument (a0·a1은 반환값) | 아니오 |
x18–x27 | s2–s11 | Callee-saved | 예 |
x28–x31 | t3–t6 | Temporary | 아니오 |
왜 둘로 나눌까? 모든 레지스터를 호출자가 저장하면 호출할 때마다 살아 있는 값을 전부 저장해야 하고, 모두 피호출자가 저장하면 잎(leaf) 함수조차 쓰는 레지스터를 전부 저장해야 한다. 둘을 섞어 두면 컴파일러가 호출을 건너 오래 살아야 하는 값은 s 레지스터에, 짧게 쓰고 버릴 값은 t·a 레지스터에 배치해 저장·복원 횟수를 줄일 수 있다. 아래 시뮬레이터에서 약속을 어기면 무슨 일이 생기는지 직접 보자.
이 레지스터는 누가 지키나
호출과 복귀: jal·jalr·ra
함수 호출은 jal ra, f(또는 먼 거리라면 auipc+jalr)다. jal은 다음 명령의 주소(pc+4)를 ra에 남기고 f로 뛴다. 복귀는 ret, 곧 jalr x0, 0(ra)다. 피호출자가 또 다른 함수를 부를 것이라면, 그 호출이 ra를 덮어쓰기 전에 자기 ra를 스택에 저장해 두어야 한다. ra가 “호출 뒤 보존되지 않는” 레지스터인 이유다.
jal ra, f가 복귀 주소 0x1004를 ra에 남기고, f의 ret이 그 값으로 돌아온다. 다른 함수를 부르지 않는 잎 함수는 ra를 건드리지 않으므로 스택 프레임조차 만들지 않을 수 있다.어셈블러의 call f와 tail f 의사 명령은 먼 거리를 대비해 두 명령으로 펼쳐진다. call은 auipc ra, …; jalr ra, …(ra), 함수의 마지막에서 다른 함수로 넘어가 버리는 꼬리 호출 tail은 auipc t1, …; jalr x0, …(t1)이다(GNU as 기준으로 임시 레지스터 t1을 쓴다). 꼬리 호출은 복귀 주소를 남기지 않으므로 호출된 함수의 ret이 곧바로 원래 호출자에게 돌아간다. 이 두 명령 쌍이 링커에서 한 명령으로 줄어드는 과정은 뒤의 링커 완화 절에서 본다.
스택 프레임: 프롤로그와 에필로그
psABI의 스택 규칙은 짧다. 스택은 아래(낮은 주소)로 자라고, 프로시저에 들어올 때 sp는 128비트(16바이트) 경계에 정렬되어 있어야 한다. 표준 ABI에서는 프로시저가 실행되는 동안 내내 이 정렬을 유지해야 한다. 스택으로 넘긴 첫 번째 인자는 진입 시점의 sp+0에 있다. 그리고 sp보다 아래 주소의 데이터가 유지된다고 기대해서는 안 된다. 즉 x86-64 System V의 128바이트 “레드 존” 같은 것이 없다.
“The stack grows downwards (towards lower addresses) and the stack pointer shall be aligned to a 128-bit boundary upon procedure entry. … Procedures must not rely upon the persistence of stack-allocated data whose addresses lie below the stack pointer.”
RISC-V ABIs Specification v1.0 — 2.1 Integer Calling Convention
s0에 n을 넣어 둔 이유를 눈여겨보자. n은 재귀 호출 뒤에 mul에서 다시 필요하다. a0는 호출에서 망가지므로, 호출 뒤 보존되는 s0에 옮겨 두었다. 대신 s0를 쓰는 대가로 프롤로그에서 호출자의 s0를 저장하고 에필로그에서 복원한다. 프레임에 실제로 쓰는 것은 8바이트뿐이지만 16바이트 정렬을 지키려고 16바이트를 잡았다.
재귀 호출과 스택 프레임 (RV32IM 에뮬레이터)
스택 메모리 (위가 높은 주소, 4바이트 칸)
addi sp, sp, -16마다 sp 표시(금색 테두리)가 16바이트씩 내려가고, 프레임마다 ra와 s0 칸이 채워지는 모습을 보자. 가장 깊은 곳(n = 1)에 이르면 이번에는 lw로 값을 되살리며 sp가 다시 올라간다. 각 프레임의 ra 칸에 담긴 주소가 main으로 가는지, fact 안의 mul로 가는지도 확인하자.인자와 반환값 전달 규칙
기본 정수 호출 규약은 인자 레지스터 여덟 개 a0–a7을 쓰고, 그중 a0·a1을 반환값에도 쓴다. 세부 규칙은 값의 크기에 따라 갈린다.
| 인자 종류 | 전달 방법 (정수 호출 규약) |
|---|---|
| XLEN 이하 스칼라 | 레지스터 하나. 없으면 스택에 값으로. XLEN보다 좁은 정수는 자기 부호에 따라 32비트로 넓힌 뒤 XLEN으로 부호 확장 |
2×XLEN 스칼라 (RV32의 long long, RV64의 __int128) | 레지스터 둘(낮은 번호에 하위 절반). 하나만 남았으면 하위는 레지스터, 상위는 스택 |
| 2×XLEN보다 넓은 스칼라 | 참조로(주소를 대신 넘김) |
| XLEN 이하 구조체 | 메모리 배치 그대로 레지스터 하나에 |
| 2×XLEN 이하 구조체 | 레지스터 둘. 하나만 남았으면 앞부분은 레지스터, 나머지는 스택 |
| 2×XLEN보다 큰 구조체, 비자명 복사 생성자가 있는 C++ 객체 | 참조로 (피호출자가 수정해도 됨) |
| 가변 인자 중 정렬·크기가 2×XLEN인 것 | 짝수 번호에서 시작하는 레지스터 쌍에 (남는 레지스터는 건너뜀) |
| 반환값 | 같은 타입의 첫 번째 인자처럼. 참조로 넘겨야 할 크기라면 호출자가 공간을 마련하고 그 주소를 숨은 첫 인자로 넘김 |
long long은 a1–a2 쌍에 들어간다. 짝수 레지스터에서 시작해야 하는 규칙은 가변 인자에만 적용된다. ③처럼 a0–a7이 차면 나머지는 스택으로 간다. ④는 하드웨어 부동소수점 규약(다음 절)이 적용되어 실수 값이 fa 레지스터로 간다.부동소수점 호출 규약과 데이터 모델
F나 D가 있는 칩이라면 부동소수점 값을 정수 레지스터로 옮겨 넘기는 것은 낭비다. 그래서 psABI는 정수 규약 위에 하드웨어 부동소수점 호출 규약을 덧붙인다. fa0–fa7 여덟 개를 인자로, fa0·fa1을 반환값으로 쓰고, fs0–fs11을 호출 뒤 보존한다. 이때 부동소수점 레지스터로 넘길 수 있는 최대 폭을 ABI_FLEN이라 한다. 정수 규약만 쓸지, ABI_FLEN을 32·64·128 중 무엇으로 할지, 그리고 XLEN이 무엇인지에 따라 이름 붙은 ABI가 갈린다.
| ABI | XLEN | ABI_FLEN | 요구 확장 | ELF 플래그 | psABI 1.0 상태 |
|---|---|---|---|---|---|
ilp32 | 32 | 없음 (정수 규약만) | — | FLOAT_ABI_SOFT | Ratified |
ilp32f | 32 | 32 | F | FLOAT_ABI_SINGLE | Ratified |
ilp32d | 32 | 64 | D | FLOAT_ABI_DOUBLE | Ratified |
ilp32e | 32 | 없음 | RV32E용, D와 함께 쓰면 안 됨 | SOFT + RVE | Draft |
lp64 | 64 | 없음 | — | FLOAT_ABI_SOFT | Ratified |
lp64f | 64 | 32 | F | FLOAT_ABI_SINGLE | Ratified |
lp64d | 64 | 64 | D | FLOAT_ABI_DOUBLE | Ratified |
lp64q | 64 | 128 | Q | FLOAT_ABI_QUAD | Ratified |
psABI는 RV32G에는 ilp32d, RV64G에는 lp64d를 기본 ABI로 강하게 권한다. RV64GQ 칩도 표준 RV64G 소프트웨어와 맞추기 위해 lp64d를 쓰라고 한다. Zfinx처럼 f 레지스터가 없는 칩은 정수 규약만 쓰는 ilp32·ilp32e·lp64를 쓴다. 링커는 ELF 헤더의 부동소수점 ABI 플래그가 다른 오브젝트를 함께 링크하면 오류를 낸다.
이름의 앞부분 ILP32와 LP64는 C 자료형 크기의 관례다. ILP32는 int·long·포인터가 모두 32비트, LP64는 long과 포인터가 64비트다.
long과 포인터뿐이다. char는 RISC-V에서 부호 없는 타입이고, long double은 두 모델 모두 16바이트(128비트 4배정밀도)다. 그래서 Q가 없는 칩에서 long double 연산은 소프트웨어 라이브러리로 처리된다.주소 만들기: %hi·%lo·%pcrel과 재배치
RISC-V 명령 하나에는 32비트 주소가 통째로 들어가지 않는다. 그래서 주소는 상위 20비트(lui 또는 auipc)와 하위 12비트(addi·load·store·jalr의 즉시값)로 나눠 두 명령으로 만든다. 컴파일할 때는 최종 주소를 모르므로, 어셈블러는 빈칸을 남기고 오브젝트 파일에 재배치(relocation) 항목을 기록한다. 링커가 주소를 정한 뒤 그 칸을 채운다.
%hi가 0x800을 더해 반올림하는 이유다. 괄호 안 숫자는 psABI 재배치 번호다.코드 모델
주소를 어떤 명령 조합으로 만들지는 코드 모델이 정한다. psABI는 세 가지를 정의한다.
| 코드 모델 | 명령 | 닿는 범위 | 재배치 |
|---|---|---|---|
| medlow (medium low) | lui + addi/load/store | RV32 전체, RV64에서는 주소 0 근처 아래위 약 2 GiB (0x0–0x7FFFF7FF, 0xFFFFFFFF7FFFF800–끝) | HI20 + LO12_I/S |
| medany (medium any) | auipc + addi/load/store | 현재 pc에서 ±2 GiB | PCREL_HI20 + PCREL_LO12_I/S |
| medium PIC | 지역 심볼은 medany와 같고, 비지역 심볼은 GOT를 거침 | 공유 라이브러리용 | GOT_HI20 + PCREL_LO12 |
%pcrel_lo의 피연산자는 심볼이 아니라 짝이 되는 auipc의 라벨이라는 점이 특이하다. 하위 12비트는 “auipc가 있던 pc를 기준으로 한 거리”의 하위 비트이므로, 링커는 라벨을 따라가 그 auipc의 R_RISCV_PCREL_HI20 항목을 찾아 같은 계산을 한다. 한 auipc에 여러 load/store가 짝지어질 수도 있다.
| 번호 | 재배치 | 칸 | 쓰임 |
|---|---|---|---|
| 16 | R_RISCV_BRANCH | B 형식 | 조건 분기, ±4 KiB |
| 17 | R_RISCV_JAL | J 형식 | jal, −1 MiB ~ +1 MiB−2 |
| 18 | R_RISCV_CALL | U+I | 사용 중단(deprecated), CALL_PLT를 쓸 것 |
| 19 | R_RISCV_CALL_PLT | U+I | call·tail의 auipc+jalr 쌍 |
| 20 | R_RISCV_GOT_HI20 | U | %got_pcrel_hi, GOT 항목까지의 pc 상대 거리 |
| 23–25 | R_RISCV_PCREL_HI20 / LO12_I / LO12_S | U / I / S | %pcrel_hi·%pcrel_lo |
| 26–28 | R_RISCV_HI20 / LO12_I / LO12_S | U / I / S | %hi·%lo 절대 주소 |
| 29–32 | R_RISCV_TPREL_HI20 / LO12_I / LO12_S / ADD | U / I / S / — | TLS Local Exec |
| 51 | R_RISCV_RELAX | — | “이 명령은 완화해도 된다”는 표시 |
링커 완화
컴파일러는 함수가 얼마나 멀리 있을지 모르므로 call을 일단 8바이트(auipc+jalr)로 만든다. 그러나 링크해 보면 대부분의 호출은 가까운 곳으로 간다. 링커는 이때 두 명령을 한 명령으로 바꾸고 남는 바이트를 지운다. 이것이 RISC-V 툴체인의 특징인 링커 완화(linker relaxation)다. 링커는 후보 재배치와 같은 자리에 R_RISCV_RELAX가 함께 있을 때만 완화한다.
jal 하나로, ±2 KiB 안이고 C가 있으면 16비트 c.jal(RV32 전용, RV64에서 그 인코딩은 c.addiw)이나 꼬리 호출용 c.j로 줄어든다. 아래: 전역 변수가 gp 주변 ±2 KiB 안에 있으면 lui를 지우고 gp 기준 접근 한 명령으로 바꾼다. 이 밖에 0번지 근처 주소의 x0 기준 완화, c.lui 완화, tp 기준 TLS 완화가 있다.완화는 바이트를 지우므로 그 뒤의 모든 코드 주소가 당겨진다. 그래서 함수 안의 분기처럼 보통은 어셈블 시점에 끝나는 거리 계산도, 완화가 켜져 있으면 재배치로 남겨 링커가 다시 계산해야 한다. psABI가 “완화를 켜면 코드 생성기는 재배치를 항상 내보내야 한다”고 적는 이유다. 정렬 지시어도 R_RISCV_ALIGN으로 남겨 완화 뒤에 다시 맞춘다.
call 완화 실험: 거리에 따라 몇 바이트가 되나
PIC·GOT·PLT와 TLS
공유 라이브러리는 메모리 어디에 올라갈지 모른다. 그래서 코드 안에 절대 주소를 박지 않는 위치 독립 코드(PIC)로 만든다. 자기 안의 심볼은 auipc 기반 pc 상대 주소로 충분하지만, 다른 모듈에 있는 심볼의 주소는 실행 시에야 정해진다. 이때 쓰는 간접 테이블이 GOT(Global Offset Table)와 PLT(Program Linkage Table)다.
jalr t1, t3는 t1에 자기 다음 주소를 남겨 해석 루틴이 어느 항목에서 왔는지 알 수 있게 한다.tp와 스레드 지역 저장소
C의 __thread나 thread_local 변수는 스레드마다 따로 있다. RISC-V는 ELF TLS의 Variant I을 따르며, tp 레지스터가 스레드 제어 블록(TCB) 바로 다음 주소를 가리킨다. 정적으로 링크한 실행 파일의 TLS 변수는 tp로부터의 고정 오프셋으로 접근하는데(Local Exec 모델), %tprel_hi·%tprel_add·%tprel_lo를 쓴다. tp가 프로시저 사이에서 절대 바뀌면 안 되는 이유다.
오프셋이 tp에서 ±2 KiB 안이면 링커가 lui와 add를 지우고 lw t0, off(tp) 한 명령으로 완화한다(thread-pointer relaxation). 그 밖에 공유 라이브러리용 Initial Exec·Global Dynamic 모델은 GOT를 거친다.
핵심 정리
- ISA는 명령의 뜻을, psABI는 함수 사이의 약속(레지스터 역할, 스택, 자료형, ELF·재배치)을 정한다. psABI 1.0은 2022년에 비준되었다.
- 인자는 a0–a7, 반환값은 a0–a1. sp와 s0–s11은 피호출자가 보존하고, ra·t·a는 호출 뒤 바뀌어 있을 수 있다. gp·tp는 아무도 바꾸지 않는다.
- 스택은 아래로 자라고 sp는 항상 16바이트 정렬, 레드 존은 없다. 프레임 포인터는 선택이며 쓰면 s0다. ILP32E는 4바이트 정렬과 a0–a5만 쓴다.
- 2×XLEN 스칼라는 레지스터 쌍, 더 큰 값과 2×XLEN보다 큰 구조체는 참조로 넘긴다. 짝수 레지스터 쌍 규칙은 가변 인자에만 적용된다.
- ABI 이름은 데이터 모델(ILP32/LP64)과 ABI_FLEN(없음·f·d·q)의 조합이다. RV64G의 권장 기본값은
lp64d, RV32G는ilp32d다. - 주소는 상위 20비트 + 하위 12비트로 나누며
%hi는 0x800을 더해 반올림한다.call은 R_RISCV_CALL_PLT + R_RISCV_RELAX가 붙은 auipc+jalr 쌍이다. - 링커 완화로 call은 jal(±1 MiB)이나 c.jal/c.j(±2 KiB)로, 전역 변수 접근은 gp 기준 한 명령으로 줄어든다.
- PIC는 GOT·PLT로 다른 모듈의 주소를 간접 참조하고, TLS는 tp 기준(Variant I)으로 접근한다.
확인 퀴즈
재귀 호출 뒤에도 필요한 값을 담기에 가장 알맞은 레지스터는?
psABI가 정한 프로시저 진입 시 sp의 정렬은?
RV32 ilp32에서 void f(int a, long long b)의 b는 어디로 가나?
LP64에서 크기가 8바이트가 아닌 타입은?
주소 0x00012800을 lui+addi로 만들 때 %hi 값은?
대상이 300 KiB 떨어진 call을 링커 완화하면?
tp 레지스터의 역할로 옳은 것은?