Chapter 07

호출 규약과 ABI

ISA는 명령어 하나하나의 뜻을 정할 뿐, 함수끼리 어떻게 대화할지는 정하지 않는다. 인자는 어느 레지스터에 넣는지, 어떤 레지스터는 호출 뒤에도 값이 남아 있어야 하는지, 스택은 어떻게 쓰는지, long은 몇 바이트인지. 이런 약속이 ABI(Application Binary Interface)이고, RISC-V에서는 psABI 규격이 정한다. 이 장에서는 재귀 함수의 스택이 자라고 줄어드는 모습을 직접 보고, 컴파일러와 링커가 주소를 만들고 줄이는 방법까지 따라간다.

ISA만으로는 부족하다

3장에서 본 것처럼 하드웨어에게 x10과 x18은 똑같다. jal은 복귀 주소를 아무 레지스터에나 남길 수 있고, 스택 포인터라는 하드웨어 레지스터도 없다. 그런데 GCC로 컴파일한 main.o가 LLVM으로 컴파일한 libm.so의 sqrt를 부르려면, 두 컴파일러가 같은 약속을 지켜야 한다. 그 약속의 묶음이 ABI(Application Binary Interface)다. 특정 프로세서에 맞춘 ABI라서 psABI(processor-specific ABI)라고 부른다.

main.o GCC로 컴파일 call sqrt libm.so LLVM으로 컴파일 sqrt: … ret 서로의 내부를 모른 채 연결된다 psABI — 둘이 함께 지키는 약속 레지스터 역할 스택 프레임 · 정렬 인자·반환 전달 자료형 크기·정렬 ELF·재배치·DWARF ISA — 명령어 하나하나의 뜻 x10과 x18은 똑같은 레지스터, jal은 아무 레지스터에나 복귀 주소를 남긴다
그림 7-1. ISA 위에 놓인 ABI. ISA가 “무엇을 할 수 있는가”를 정한다면, ABI는 “서로 어떻게 하기로 했는가”를 정한다. psABI는 호출 규약, ELF 형식, DWARF 디버그 정보의 세 부분으로 되어 있다.

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)).

call ─── 호출 경계 ─── ret 호출 뒤에도 그대로 (피호출자 책임) sp s0–s11 fs0–fs11 (하드웨어 FP 규약일 때) gp · tp 아무도 바꾸지 않는고정 레지스터 쓰려면 프롤로그에서 저장, 에필로그에서 복원 호출 뒤 바뀌어 있을 수 있음 (호출자 책임) ra t0–t6 a0–a7 ft0–ft11 · fa0–fa7 v0–v31 · vl · vtype · vxrm · vxsat 필요하면 호출 전에 저장, 호출 뒤 복원
그림 7-2. 호출 경계를 넘을 때. 왼쪽은 피호출자가 반드시 원래 값으로 돌려놓는 레지스터, 오른쪽은 호출 뒤 값이 바뀌어 있어도 되는 레지스터다. 벡터 레지스터와 벡터 설정 CSR은 모두 보존되지 않는다. 부동소수점 s 레지스터도 ABI_FLEN보다 넓지 않은 값만 보존된다.
이름ABI 이름뜻호출 뒤 보존?
x0zeroZero— (불변)
x1raReturn address아니오
x2spStack pointer예
x3gpGlobal pointer— (할당 대상 아님)
x4tpThread pointer— (할당 대상 아님)
x5–x7t0–t2Temporary아니오
x8–x9s0–s1Callee-saved (s0는 프레임 포인터를 쓸 때 fp)예
x10–x17a0–a7Argument (a0·a1은 반환값)아니오
x18–x27s2–s11Callee-saved예
x28–x31t3–t6Temporary아니오

왜 둘로 나눌까? 모든 레지스터를 호출자가 저장하면 호출할 때마다 살아 있는 값을 전부 저장해야 하고, 모두 피호출자가 저장하면 잎(leaf) 함수조차 쓰는 레지스터를 전부 저장해야 한다. 둘을 섞어 두면 컴파일러가 호출을 건너 오래 살아야 하는 값은 s 레지스터에, 짧게 쓰고 버릴 값은 t·a 레지스터에 배치해 저장·복원 횟수를 줄일 수 있다. 아래 시뮬레이터에서 약속을 어기면 무슨 일이 생기는지 직접 보자.

ANIMATION

이 레지스터는 누가 지키나

레지스터
약속
재생
분류—
저장 책임—
호출 뒤 값—
s1을 고르고 “어김”을 누르면 피호출자가 저장 없이 s1을 덮어쓴다. 호출자는 아무 잘못이 없는데도 값을 잃는다. t0로 바꾸면 반대로 호출자가 저장해야 한다. ra에서 약속을 어기면 함수가 자기 자신에게로 돌아가는 무한 반복이 생긴다.

호출과 복귀: jal·jalr·ra

함수 호출은 jal ra, f(또는 먼 거리라면 auipc+jalr)다. jal은 다음 명령의 주소(pc+4)를 ra에 남기고 f로 뛴다. 복귀는 ret, 곧 jalr x0, 0(ra)다. 피호출자가 또 다른 함수를 부를 것이라면, 그 호출이 ra를 덮어쓰기 전에 자기 ra를 스택에 저장해 두어야 한다. ra가 “호출 뒤 보존되지 않는” 레지스터인 이유다.

main 0x1000jal ra, f 0x1004mv s0, a0 0x1008… f (잎 함수) 0x2000addi a0, a0, 1 0x2004slli a0, a0, 1 0x2008… 0x2010ret ① 호출: ra ← 0x1004 ② 복귀: pc ← ra ra = 0x1004 (호출 순간 기록) 잎 함수는 ra를 저장할 필요가 없다
그림 7-3. 호출과 복귀. 금색 점이 제어의 흐름이다. 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바이트 “레드 존” 같은 것이 없다.

psABI 원문에서
“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

높은 주소 ↑ 낮은 주소 ↓ (스택이 자라는 방향) 호출자의 프레임 스택으로 넘긴 인자 (인자 레지스터가 모자랄 때) ra (sp+12) s0 / fp (sp+8) 지역 변수 · 다른 s 레지스터(sp+0 … sp+7) 다음 호출에 넘길 스택 인자 자리 (필요할 때) sp 아래: 보장 없음 (레드 존 없음) 진입 시 sp = 첫 스택 인자 주소 = fp (s0, 쓴다면) 현재 sp 16바이트 정렬 유지 이 함수의 프레임 크기 = 16의 배수 (예: 16 또는 32 바이트)
그림 7-4. RV32 함수 하나의 스택 프레임(위가 높은 주소). 프롤로그가 sp를 프레임 크기만큼 내리고, ra와 쓸 s 레지스터를 저장한다. 프레임 포인터를 쓰면 s0에 진입 시의 sp를 넣어 둔다. psABI는 프레임 포인터를 선택 사항으로 두되, 쓴다면 반드시 s0에 두라고 정한다. 프레임 안의 구체적인 배치(ra를 sp+12에 두는 것 등)는 컴파일러가 정하는 관례다.

s0에 n을 넣어 둔 이유를 눈여겨보자. n은 재귀 호출 뒤에 mul에서 다시 필요하다. a0는 호출에서 망가지므로, 호출 뒤 보존되는 s0에 옮겨 두었다. 대신 s0를 쓰는 대가로 프롤로그에서 호출자의 s0를 저장하고 에필로그에서 복원한다. 프레임에 실제로 쓰는 것은 8바이트뿐이지만 16바이트 정렬을 지키려고 16바이트를 잡았다.

SIMULATOR

재귀 호출과 스택 프레임 (RV32IM 에뮬레이터)

스택 메모리 (위가 높은 주소, 4바이트 칸)

실행
pc—
sp—
호출 깊이0
출력—
“한 명령”을 눌러 가며 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인 것짝수 번호에서 시작하는 레지스터 쌍에 (남는 레지스터는 건너뜀)
반환값같은 타입의 첫 번째 인자처럼. 참조로 넘겨야 할 크기라면 호출자가 공간을 마련하고 그 주소를 숨은 첫 인자로 넘김
① RV32 · ilp32: int f(int a, long long b, int c) a0a a1b 하위 a2b 상위 a3c 이름 있는 인자의 쌍은 홀수 번호에서 시작해도 된다 ② RV32: void g(struct big s) // struct big { int x[5]; } = 20바이트 > 2×XLEN a0&복사본 호출자가 만든 20바이트 복사본 참조로 전달 (피호출자가 바꿔도 원본은 안전) ③ RV32: int h(int p1, …, int p10) // 정수 인자 10개 a0 a1 a2 … a7 p9 → sp+0 p10 → sp+4 진입 시 sp 기준, 높은 주소로 ④ RV64 · lp64d: double k(int i, double d, struct { float f; int n; } s) a0i fa0d fa1s.f a1s.n fa0반환 double 실수 + 정수 구조체는 f 하나 + x 하나 파랑 = 정수 인자 레지스터 · 청록 = 부동소수점 인자 레지스터 · 보라 = 스택
그림 7-5. 인자 배치의 네 가지 예. ①의 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가 갈린다.

ABIXLENABI_FLEN요구 확장ELF 플래그psABI 1.0 상태
ilp3232없음 (정수 규약만)—FLOAT_ABI_SOFTRatified
ilp32f3232FFLOAT_ABI_SINGLERatified
ilp32d3264DFLOAT_ABI_DOUBLERatified
ilp32e32없음RV32E용, D와 함께 쓰면 안 됨SOFT + RVEDraft
lp6464없음—FLOAT_ABI_SOFTRatified
lp64f6432FFLOAT_ABI_SINGLERatified
lp64d6464DFLOAT_ABI_DOUBLERatified
lp64q64128QFLOAT_ABI_QUADRatified

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비트다.

ILP32 (RV32) LP64 (RV64) 바이트 → char11 (부호 없음) short22 int44 long48 long long88 void *48 double88 long double1616 (4배정밀도) 금색 = ILP32와 LP64에서 크기가 다른 타입 · max_align_t 정렬은 둘 다 16
그림 7-6. psABI의 C 자료형 크기. 다른 것은 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) 항목을 기록한다. 링커가 주소를 정한 뒤 그 칸을 채운다.

목표 주소 0x12345FFF 0001 0010 0011 0100 0101 │ 1111 1111 1111 상위 20 │ 하위 12 하위 12비트 0xFFF를 addi는 −1로 읽는다 lui 0x12345 → 0x12345000, addi −1 = 0x12344FFF ✕ 틀림 그래서 %hi는 (주소 + 0x800) >> 12 lui 0x12346 → 0x12346000, addi −1 = 0x12345FFF ✓ lui a0, %hi(sym)R_RISCV_HI20 (26) addi a0, a0, %lo(sym)R_RISCV_LO12_I (27) sw a1, %lo(sym)(a0)R_RISCV_LO12_S (28)저장은 S 형식 칸
그림 7-7. 상위·하위 나누기. addi·load의 12비트 즉시값은 부호 확장되므로, 하위 12비트의 최상위 비트가 1이면 상위 값을 1 올려 보정해야 한다. %hi가 0x800을 더해 반올림하는 이유다. 괄호 안 숫자는 psABI 재배치 번호다.

코드 모델

주소를 어떤 명령 조합으로 만들지는 코드 모델이 정한다. psABI는 세 가지를 정의한다.

코드 모델명령닿는 범위재배치
medlow (medium low)lui + addi/load/storeRV32 전체, RV64에서는 주소 0 근처 아래위 약 2 GiB (0x0–0x7FFFF7FF, 0xFFFFFFFF7FFFF800–끝)HI20 + LO12_I/S
medany (medium any)auipc + addi/load/store현재 pc에서 ±2 GiBPCREL_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가 짝지어질 수도 있다.

번호재배치칸쓰임
16R_RISCV_BRANCHB 형식조건 분기, ±4 KiB
17R_RISCV_JALJ 형식jal, −1 MiB ~ +1 MiB−2
18R_RISCV_CALLU+I사용 중단(deprecated), CALL_PLT를 쓸 것
19R_RISCV_CALL_PLTU+Icall·tail의 auipc+jalr 쌍
20R_RISCV_GOT_HI20U%got_pcrel_hi, GOT 항목까지의 pc 상대 거리
23–25R_RISCV_PCREL_HI20 / LO12_I / LO12_SU / I / S%pcrel_hi·%pcrel_lo
26–28R_RISCV_HI20 / LO12_I / LO12_SU / I / S%hi·%lo 절대 주소
29–32R_RISCV_TPREL_HI20 / LO12_I / LO12_S / ADDU / I / S / —TLS Local Exec
51R_RISCV_RELAX—“이 명령은 완화해도 된다”는 표시

링커 완화

컴파일러는 함수가 얼마나 멀리 있을지 모르므로 call을 일단 8바이트(auipc+jalr)로 만든다. 그러나 링크해 보면 대부분의 호출은 가까운 곳으로 간다. 링커는 이때 두 명령을 한 명령으로 바꾸고 남는 바이트를 지운다. 이것이 RISC-V 툴체인의 특징인 링커 완화(linker relaxation)다. 링커는 후보 재배치와 같은 자리에 R_RISCV_RELAX가 함께 있을 때만 완화한다.

함수 호출 완화 auipc ra, 0jalr ra, 0(ra)8바이트 · ±2 GiB ±1 MiB jal ra, sym4바이트 · R_RISCV_JAL ±2 KiB c.jal sym (RV32)c.j sym (tail)2바이트 · C 필요 전역 포인터 완화 lui t0, %hi(x)lw t1, %lo(x)(t0)2명령 lw t1, off(gp)1명령 데이터 영역 gp = __global_pointer$ +2 KiB −2 KiB 이 창 안의 변수는 gp 기준 한 명령 조건: gp와 심볼의 거리 ±2 KiB 프로그램 시작 시 gp를 __global_pointer$로 초기화해야 함 (초기화 코드 자체는 .option norelax 로 완화 금지)
그림 7-8. 두 가지 대표 완화. 위: 대상이 ±1 MiB 안이면 auipc+jalr이 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으로 남겨 완화 뒤에 다시 맞춘다.

SIMULATOR

call 완화 실험: 거리에 따라 몇 바이트가 되나

방향
XLEN
C 확장
의사 명령
완화 전8 B
완화 후—
절약—
거리를 2 KiB 근처에서 한 칸씩 움직여 c.jal → jal 경계를, 1 MiB 근처에서 jal → auipc+jalr 경계를 넘어 보자. RV64로 바꾸면 c.jal이 사라지는(그 인코딩이 RV64에서는 c.addiw라서) 반면 꼬리 호출의 c.j는 남는다. 기계어는 riscv.js의 인코더로, c.jal·c.j는 이 장의 CJ 형식 인코더로 만든 것이다.

PIC·GOT·PLT와 TLS

공유 라이브러리는 메모리 어디에 올라갈지 모른다. 그래서 코드 안에 절대 주소를 박지 않는 위치 독립 코드(PIC)로 만든다. 자기 안의 심볼은 auipc 기반 pc 상대 주소로 충분하지만, 다른 모듈에 있는 심볼의 주소는 실행 시에야 정해진다. 이때 쓰는 간접 테이블이 GOT(Global Offset Table)와 PLT(Program Linkage Table)다.

main (실행 파일) call printf@plt R_RISCV_CALL_PLT PLT 항목 (16바이트) auipc t3, %pcrel_hi(printf@.got.plt) l[w|d] t3, %pcrel_lo(1b)(t3) jalr t1, t3 ; nop 주소 읽기 GOT (.got.plt) 칸 &printf (동적 링커가 채움) 점프 libc.so printf: … ret → main _dl_runtime_resolve 첫 호출 때 주소를 찾아 GOT에 기록 • 지연 바인딩: 처음에는 GOT 칸이 PLT 맨 앞의 해석 루틴을 가리키고, 첫 호출에서 실제 주소로 바뀐다(LD_BIND_NOW면 미리 채움). • 다른 모듈의 전역 변수 주소: auipc a0, %got_pcrel_hi(x) ; l[w|d] a0, %pcrel_lo(…)(a0) — GOT에서 주소를 읽는다.
그림 7-9. PLT를 거친 함수 호출. 코드에는 다른 모듈의 절대 주소가 없고, 쓰기 가능한 데이터 영역인 GOT만 실행 시 고쳐진다. 그 덕분에 코드 영역은 여러 프로세스가 그대로 공유할 수 있다. PLT 스텁의 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가 프로시저 사이에서 절대 바뀌면 안 되는 이유다.

lui a5, %tprel_hi(i) # R_RISCV_TPREL_HI20 add a5, a5, tp, %tprel_add(i) # R_RISCV_TPREL_ADD lw t0, %tprel_lo(i)(a5) # R_RISCV_TPREL_LO12_I addi t0, t0, 1 sw t0, %tprel_lo(i)(a5) # R_RISCV_TPREL_LO12_S

오프셋이 tp에서 ±2 KiB 안이면 링커가 lui와 add를 지우고 lw t0, off(tp) 한 명령으로 완화한다(thread-pointer relaxation). 그 밖에 공유 라이브러리용 Initial Exec·Global Dynamic 모델은 GOT를 거친다.

핵심 정리

  1. ISA는 명령의 뜻을, psABI는 함수 사이의 약속(레지스터 역할, 스택, 자료형, ELF·재배치)을 정한다. psABI 1.0은 2022년에 비준되었다.
  2. 인자는 a0–a7, 반환값은 a0–a1. sp와 s0–s11은 피호출자가 보존하고, ra·t·a는 호출 뒤 바뀌어 있을 수 있다. gp·tp는 아무도 바꾸지 않는다.
  3. 스택은 아래로 자라고 sp는 항상 16바이트 정렬, 레드 존은 없다. 프레임 포인터는 선택이며 쓰면 s0다. ILP32E는 4바이트 정렬과 a0–a5만 쓴다.
  4. 2×XLEN 스칼라는 레지스터 쌍, 더 큰 값과 2×XLEN보다 큰 구조체는 참조로 넘긴다. 짝수 레지스터 쌍 규칙은 가변 인자에만 적용된다.
  5. ABI 이름은 데이터 모델(ILP32/LP64)과 ABI_FLEN(없음·f·d·q)의 조합이다. RV64G의 권장 기본값은 lp64d, RV32G는 ilp32d다.
  6. 주소는 상위 20비트 + 하위 12비트로 나누며 %hi는 0x800을 더해 반올림한다. call은 R_RISCV_CALL_PLT + R_RISCV_RELAX가 붙은 auipc+jalr 쌍이다.
  7. 링커 완화로 call은 jal(±1 MiB)이나 c.jal/c.j(±2 KiB)로, 전역 변수 접근은 gp 기준 한 명령으로 줄어든다.
  8. PIC는 GOT·PLT로 다른 모듈의 주소를 간접 참조하고, TLS는 tp 기준(Variant I)으로 접근한다.

확인 퀴즈

재귀 호출 뒤에도 필요한 값을 담기에 가장 알맞은 레지스터는?

s 레지스터는 피호출자가 보존하므로 호출을 건너 살아남는다. 대신 그 함수도 프롤로그에서 호출자의 s0를 저장해 두어야 한다.

psABI가 정한 프로시저 진입 시 sp의 정렬은?

표준 ABI에서는 16바이트로 정렬하고 실행 내내 유지한다. ILP32E만 4바이트로 완화한다.

RV32 ilp32에서 void f(int a, long long b)의 b는 어디로 가나?

2×XLEN 스칼라는 다음 레지스터 쌍에 들어간다. 짝수에서 시작해야 한다는 규칙은 가변 인자에만 적용된다.

LP64에서 크기가 8바이트가 아닌 타입은?

LP64는 Long과 Pointer가 64비트라는 뜻이다. int는 여전히 4바이트다.

주소 0x00012800을 lui+addi로 만들 때 %hi 값은?

하위 12비트 0x800은 addi에서 −2048로 읽힌다. 그래서 상위에 1을 더한 0x13을 쓰면 0x13000 − 0x800 = 0x12800이 된다. (0x12800 + 0x800) >> 12 = 0x13.

대상이 300 KiB 떨어진 call을 링커 완화하면?

±1 MiB 안이므로 jal로 줄어든다. c.jal은 ±2 KiB 안이어야 한다.

tp 레지스터의 역할로 옳은 것은?

tp는 TLS의 기준(TCB 다음 주소)이다. 전역 변수 완화에 쓰는 것은 gp다.

더 읽을거리