바이너리

발행: (2025년 12월 29일 오후 02:35 GMT+9)
11 min read

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
  • e8CALL 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_functionmain으로부터 멀리 배치하도록 링커를 강제할 수 있다. (이후 예시가 이어집니다.)

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
Large mcmodel 에서 IPC(사이클당 명령 수)의 측정 가능한 감소를 보여주는 벤치마크를 만들기가 어려웠습니다. 영향이 무시할 수 없을 수 있다는 점만은 기억해 주세요. 🤷

Small 코드 모델 유지하기

Small 코드 모델을 유지하고 싶다면 다른 전략을 탐색해야 합니다(예: 섹션 재배치, 트램펄린 사용, 바이너리 분할 등). 더 많은 아이디어는 추후 포스트에서 다룰 예정입니다.

Back to Blog

관련 글

더 보기 »

계산 게임 앱 배포

나는 내 계산 게임 앱을 Vercel에 배포했어—수학을 별로 좋아하지 않는 일본 아이들을 위해 특별히 설계했지! 하하! https://flush-calc.vercel.app/https://flush-...