바이너리
Source: Hacker News
번역할 텍스트가 제공되지 않았습니다. 번역하고 싶은 내용을 알려주시면 도와드리겠습니다.
Source: …
2 GiB “재배치 장벽” – 왜 대형 바이너리는 x86‑64에서 깨지는가
내가 박사 과정을 진행하고 학술 논문을 제출하면서 겪은 문제는 극적인 규모가 필요하다는 이유로 해결책을 만들었지만, 그 규모가 없으면 의미가 없다는 것이었다.
논문 제출에 대한 리뷰어들은 그런 문제는 존재하지 않는다며 부정했지만, 나는 산업 현장(예: 구글)에서 실제로 그런 사례를 목격했으며, 인용할 수 없었다!
이러한 초대형 코드베이스에서만 나타나는 문제 중 하나가 거대한 바이너리이다.
가장 큰 ELF 바이너리를 본 적이 있나요? 나는 25 GiB가 넘는 바이너리(디버그 심볼 포함)를 본 적이 있다. 어떻게 이런 일이 가능한가?
이들 기업은 정적 링크를 선호한다. 정적 링크는 서비스 시작 시간을 단축하고 배포를 단순화한다. 세계에서 가장 큰 코드베이스에 모든 코드를 정적으로 포함시키는 것은 거대한 바이너리를 만들게 된다.
음속 장벽과 비슷하게, 코드 크기가 문제가 되는 지점이 존재하고 우리는 링크와 빌드 방식을 재고해야 한다. x86‑64에서는 그 지점이 **2 GiB “재배치 장벽”**이다.
왜 2 GiB인가? 🤔
위치 독립 코드를 어떻게 구성하는지 살펴보자.
간단한 예제
/* simple-relocation.c */
extern void far_function();
int main(void) {
far_function();
return 0;
}
컴파일:
gcc -c simple-relocation.c -o simple-relocation.o
objdump로 객체 파일을 살펴보기:
> objdump -dr simple-relocation.o
0000000000000000 :
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: b8 00 00 00 00 mov $0x0,%eax
9: e8 00 00 00 00 call e
a: R_X86_64_PLT32 far_function-0x4
e: b8 00 00 00 00 mov $0x0,%eax
13: 5d pop %rbp
14: c3 ret
e8은 CALL opcode이며 32‑비트 부호 있는 상대 오프셋을 사용한다.- 현재 피연산자는
00 00 00 00인데, 실제 주소가 아직 알려지지 않았기 때문이다. objdump는 링커가 나중에 수정해야 할 재배치 항목을 보여준다.
Note
-0x4가 필요한 이유는 오프셋이 명령 포인터가 4‑바이트 피연산자를 지나고 난 뒤에 상대하기 때문이다.
readelf로 재배치를 확인:
readelf -r simple-relocation.o -d
Relocation section '.rela.text' at offset 0x170 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000a 000400000004 R_X86_64_PLT32 0000000000000000 far_function - 4
이 항목은 링커에게 오프셋 0x0a(CALL 즉시값 시작) 에 있는 4‑바이트 피연산자를 far_function 주소로 패치하라고 알려준다.
호출 대상 추가
/* far-function.c */
void far_function(void) {
}
컴파일 및 링크:
gcc -c far-function.c -o far-function.o
gcc simple-relocation.o far-function.o -o simple-relocation
최종 실행 파일을 살펴보기:
> objdump -dr simple-relocation
0000000000401106 :
401106: 55 push %rbp
401107: 48 89 e5 mov %rsp,%rbp
40110a: b8 00 00 00 00 mov $0x0,%eax
40110f: e8 07 00 00 00 call 40111b
401114: b8 00 00 00 00 mov $0x0,%eax
401119: 5d pop %rbp
40111a: c3 ret
000000000040111b :
40111b: 55 push %rbp
40111c: 48 89 e5 mov %rsp,%rbp
40111f: 90 nop
401120: 5d pop %rbp
401121: c3 ret
링커가 상대 오프셋(0x07)을 계산해 CALL 명령을 패치했다.
2 GiB 장벽
CALL opcode(e8)는 32‑비트 부호 변위를 사용한다. 즉 ±2 GiB(‑2³¹ … +2³¹‑1)까지 도달할 수 있다.
따라서 호출 지점은 최대 약 2 GiB 앞이나 뒤로만 점프할 수 있다. 이 제한이 바로 **“2 GiB 장벽”**이다.
목표가 더 멀리 떨어져 있으면 어떻게 되는가?
우리는 far_function을 main으로부터 멀리 배치하도록 링커를 강제할 수 있다. (이후 예시가 이어집니다.)
Source: https://maskray.me/blog/2023-05-14-relocation-overflow-and-code-models
/* overflow.lds */
SECTIONS
{
/* 1. Standard low‑address sections */
. = 0x400000;
.text : {
simple-relocation.o(.text.*)
}
.rodata : { *(.rodata .rodata.*) }
.data : { *(.data .data.*) }
.bss : { *(.bss .bss.*) }
/* 2. Move the location counter far away for the “far” island */
. = 0x120000000; /* ≈ 4.5 GiB */
.text.far : {
far-function.o(.text*)
}
}
이제 LLVM의 lld로 링크합니다(오류 메시지가 좀 더 명확합니다):
gcc simple-relocation.o far-function.o -T overflow.lds -o simple-relocation-overflow -fuse-ld=lld
결과:
ld.lld: error: :(.eh_frame+0x6c):
relocation R_X86_64_PC32 out of range:
5364513724 is not in [-2147483648, 2147483647]; references section '.text'
ld.lld: error: simple-relocation.o:(function main: .text+0xa):
relocation R_X86_64_PLT32 out of range:
5364514572 is not in [-2147483648, 2147483647]; references 'far_function'
>>> referenced by simple-relocation.c
>>> defined in far-function.o
링커는 리로케이션 오버플로가 발생했음을 보고합니다. 이는 필요한 변위가 부호 있는 32‑비트 필드에 들어가지 않기 때문입니다.
장벽을 어떻게 처리할까?
이것은 코드 모델(small, kernel, medium, large)과 코드와 데이터 참조의 구분을 포함하는 완전히 다른 주제입니다.
요약하면:
| 상황 | 일반적인 해결책 |
|---|---|
| 2 GiB를 초과하는 호출 또는 점프 | 간접 호출/점프(레지스터나 PLT 엔트리를 통한) 사용하거나 large 코드 모델(-mcmodel=large)로 컴파일 |
| 2 GiB를 초과하는 정적 데이터 접근 | large 코드 모델과 함께 RIP‑relative 주소 지정 사용하거나, 먼저 주소를 레지스터에 로드 |
| 정적 링크와 동적 링크를 혼합 | 동적 링커의 PLT/GOT 메커니즘에 의존하면 자동으로 간접 참조가 생성됩니다. |
이 주제에 대한 깊이 있는 논의는 @maskray가 작성한 블로그 포스트 **“Relocation overflow and code models”**에서 확인할 수 있습니다:
https://maskray.me/blog/2023-05-14-relocation-overflow-and-code-models
(링크가 깨졌다면 제목과 저자를 검색해 보세요.)
요약
- Static linking를 거대한 코드베이스에 적용하면 단일 상대 점프가 도달할 수 있는 2 GiB 범위를 초과하는 바이너리를 쉽게 만들 수 있습니다.
- x86-64 CALL/JMP 명령은 부호 있는 32‑bit displacement를 사용하므로 직접 점프가 ±2 GiB 로 제한됩니다.
- 링커가 변위를 그 범위에 맞출 수 없을 때 relocation overflow가 발생합니다.
- 일반적인 해결책은 indirect calls/jumps, different code models, 혹은 dynamic linking(PLT/GOT)입니다.
이 “relocation barrier”를 이해하는 것은 mega‑binaries(수십 기가바이트)용 빌드 시스템을 설계할 때와 정적 링크가 해당 조직에 실제로 적합한 선택인지 판단할 때 필수적입니다.
-mcmodel=large 를 사용하여 재배치 오버플로 방지
가장 간단한 해결책은 -mcmodel=large 로 컴파일하는 것입니다. 이 옵션은 모든 상대 CALL 명령을 절대 점프(JMP)로 바꿉니다.
# 실행 파일 빌드
gcc simple-relocation.o far-function.o -T overflow.lds -o simple-relocation-overflow
# large 코드 모델로 컴파일
gcc -c simple-relocation.c -o simple-relocation.o -mcmodel=large -fno-asynchronous-unwind-tables
# 다시 링크 (앞과 동일)
gcc simple-relocation.o far-function.o -T overflow.lds -o simple-relocation-overflow
# 실행
./simple-relocation-overflow
Note
-fno-asynchronous-unwind-tables옵션은 이 데모에서 오버플로를 일으킬 수 있는 추가 unwind‑table 데이터를 비활성화하기 위해 필요합니다.
-mcmodel=large 로 전환한 후 디스어셈블
objdump -dr simple-relocation-overflow
0000000120000000 :
120000000: 55 push %rbp
120000001: 48 89 e5 mov %rsp,%rbp
120000004: 90 nop
120000005: 5d pop %rbp
120000006: c3 ret
00000000004000e6 :
4000e6: 55 push %rbp
4000e7: 48 89 e5 mov %rsp,%rbp
4000ea: b8 00 00 00 00 mov $0x0,%eax
4000ef: 48 ba 00 00 00 20 01 movabs $0x120000000,%rdx
4000f6: 00 00 00
4000f9: ff d2 call *%rdx
4000fb: b8 00 00 00 00 mov $0x0,%eax
400100: 5d pop %rbp
400101: c3 ret
단일 CALL 명령이 MOVABS 뒤에 CALL 로 교체되었습니다 — 명령 크기가 5 바이트(opcode + 4‑바이트 상대 오프셋)에서 12 바이트(2‑바이트 MOVABS opcode + 8‑바이트 절대 주소 + 2‑바이트 CALL)로 늘어났습니다.
Large 코드 모델의 단점
- 명령 부피 증가 – 각 호출이 이제 5 바이트가 아니라 12 바이트를 차지합니다. 호출 지점이 많은 바이너리에서는 코드 크기가 눈에 띄게 증가할 수 있습니다.
- 레지스터 압박 – 절대 주소를 저장하기 위해 추가 일반 레지스터(
예제에서는 %rdx)가 사용됩니다.
Caution
Largemcmodel에서 IPC(사이클당 명령 수)의 측정 가능한 감소를 보여주는 벤치마크를 만들기가 어려웠습니다. 영향이 무시할 수 없을 수 있다는 점만은 기억해 주세요. 🤷
Small 코드 모델 유지하기
Small 코드 모델을 유지하고 싶다면 다른 전략을 탐색해야 합니다(예: 섹션 재배치, 트램펄린 사용, 바이너리 분할 등). 더 많은 아이디어는 추후 포스트에서 다룰 예정입니다.