스텐실과 재배치 홀을 만드는 규칙의 이유를 설명하고, 호출 규약, 테일 콜, 재배치와 코드 모델을 활용해 JIT 시 복사-패치 기법이 어떻게 동작하는지 자세히 다룬다.
튜토리얼에서 여러분은 왜 그런 규칙이 존재하는지에 대한 설명 없이, 제시된 규칙을 따라 스텐실과 재배치 홀을 만들어 왔습니다. 이제 그 이유를 파고들며, clang 플래그와 재배치 홀 매크로에 대한 가이드가 정확히 어떻게 나온 것인지 살펴보겠습니다.
스텐실을 만드는 모든 관용구는, 우리가 원하는 정확한 명령어 시퀀스만으로 함수가 생성되도록 clang의 기능을 최대한 “악용”하는 데 초점을 둡니다. 여기에 몇 가지 요령이 있습니다.
첫째, 호출 규약을 이용해 값을 알려진 레지스터에 강제로 배치합니다. 우리의 목표는 스텐실들을 이어 붙여 프로그램을 구성하는 것입니다. 따라서 한 스텐실의 출력이 다음 스텐실의 입력과 맞아야 합니다. 스텐실의 입력을 함수 인자로 만들고, 각 함수의 끝을 다른 함수에 대한 (테일)콜로 마무리하면, 호출 규약 덕분에 입력과 출력 값이 일관된 레지스터에 놓이게 됩니다. 이 끝부분의 호출은 쉽게 식별되어 스텐실에서 잘라낼 수 있습니다. 작은 최적화로서, 가능한 한 많은 인자를 레지스터로 전달하려고 시도하는 GHC / preserve_none 호출 규약을 사용합니다. 이렇게 하면 값을 레지스터에 유지할 수 있는 가능성을 극대화하고, 스택에 인자를 푸시하지 않으므로 컴파일러가 스택 프레임을 만들려는 시도를 최소화할 수 있습니다.
둘째, 컴파일러 최적화를 활용해 스택 프레임 프롤로그/에필로그를 생략하고, 끝부분의 호출을 테일 콜로 바꾸게 합니다. 스택 프레임을 설정하고 해제하는 것은 작은 스텐실 함수에서는 유의미한 오버헤드이며, 설정에는 항상 짝이 되는 해제가 필요합니다. 스텐실을 테일 콜로 끝내면 점프 명령을 간단히 제거하고 다음으로 이어 붙인 스텐실로 폴스루(fall-through)할 수 있으며, 점프 전에 모든 스택 연산이 원복되도록 하는 데에도 도움이 됩니다.
셋째, 동적 재배치를 적극적으로 활용해 스텐실이 JIT 컴파일 시점에 값을 채울 수 있는 홀을 선언하도록 합니다. 스텐실을 컴파일할 때 C 컴파일러는 자신이 생성한 코드에서 상수나 주소를 어디에 어떻게 패치할지 알려줍니다. 정수 상수를 패치하고 싶다면 extern int some_constant를 선언하고, 그 변수의 주소를 정수로 캐스팅하면 됩니다. 참조하는 extern 심볼의 이름을 주의 깊게 정하면 의도를 더 지능적으로 구분하여 특정 참조를 특별 취급할 수 있습니다. 머신 코드 모델은 생성되는 재배치에 큰 영향을 주며, 이에 대해서는 뒤에서 더 논의합니다.
이 모든 것이 어떻게 맞물리는지 보여주기 위해, 두 인자의 순서를 입출력 사이에서 교환하고, 패치 가능한 상수로 둘을 곱하는 스텐실을 생각해 봅시다.
#include <stdint.h>
extern void hole_fn(void) __attribute__((preserve_none));
extern int hole_for_int;
__attribute__((preserve_none))
void swap_and_multiply(int a, int b) {
const int hole_value = (int)((uintptr_t)&hole_for_int);
int c = a * hole_value;
a = b * hole_value;
b = c;
typedef void(*outfn_type)(int, int) __attribute__((preserve_none));
outfn_type stencil_output = (outfn_type)&hole_fn;
stencil_output(a, b);
}
이를 clang -mcmodel=medium -O3 -c swap_and_multiply.c로 컴파일하고, objdump -d -Mintel,x86-64 --disassemble --reloc swap_and_multiply.o로 생성된 코드를 살펴봅니다.
0000000000000000 <swap_and_multiply>:
0: 44 89 e0 mov eax,r12d
3: 41 bc 00 00 00 00 mov r12d,0x0
5: R_X86_64_32 hole_for_int
9: 41 0f af c4 imul eax,r12d
d: 45 0f af e5 imul r12d,r13d
11: 41 89 c5 mov r13d,eax
14: e9 00 00 00 00 jmp 19 <swap_and_multiply+0x19>
15: R_X86_64_PLT32 hole_fn-0x4
이렇게 스텐실 생성에서의 정확한 목표를 달성했습니다. 함수 본문에는 우리가 노린 명령만 있습니다. 스택 프레임 설정/해제도 없습니다. 재배치 정보는 JIT 컴파일 시점에 정수 상수를 어디에 어떻게 패치할지 정확히 알려줍니다. 또한 고유 심볼 hole_fn을 사용했기 때문에 테일 콜 점프를 식별하고 생성된 코드에서 쉽게 떼어낼 수 있습니다. 고유한 포인터를 얻게 되므로 더욱 용이합니다.
이제 여기에 포함된 각각의 기법이 생성 코드에 어떤 영향을 주는지 하나씩 풀어보겠습니다.
표준 x86_64 호출 규약은 처음 여섯 개 인자를 레지스터(순서: rdi, rsi, rdx, rcx, r8, r9)에 넣고, 나머지는 스택에 둡니다. x86_64의 표준(cdecl) 호출 규약에 대한 훌륭한 개요는 The 64 bit x86 C Calling Convention에서 볼 수 있습니다. 그러나 copy-and-patch 스텐실에 대한 가이드는 preserve_none 호출 규약을 선택하라고 권합니다. Clang/LLVM은 preserve_none을 x86_64와 AArch64에서만 지원하며, GCC는 아직 지원하지 않습니다(하지만 작업이 진행 중입니다).
cdecl과 preserve_none의 차이를, 입력의 순서만 바꾸는 작은 스텐실을 만들어 살펴보겠습니다.
| cdecl 호출 규약 | preserve_none 호출 규약 |
|---|---|
| ``` | |
| #include <stdint.h> | |
| extern void hole_fn(void) attribute((cdecl)); |
attribute((cdecl))
void swap_ints(int a, int b) {
typedef void(*outfn_type)(int, int) attribute((cdecl));
outfn_type stencil_output = (outfn_type)&hole_fn;
stencil_output(b, a);
}
|
#include <stdint.h>
extern void hole_fn(void) attribute((preserve_none));
attribute((preserve_none)) void swap_ints(int a, int b) { typedef void(*outfn_type)(int, int) attribute((preserve_none)); outfn_type stencil_output = (outfn_type)&hole_fn; stencil_output(b, a); }
| ```
; <swap_ints>:
mov eax,edi
mov edi,esi
mov esi,eax
jmp b <swap_ints+0xb>
;; R_X86_64_PLT32 hole_fn-0x4
``` | ```
; <swap_and_multiply>:
mov eax,r12d
mov r12d,r13d
mov r13d,eax
jmp e <swap_and_multiply+0xe>
;; R_X86_64_PLT32 hole_fn-0x4
``` |
별 차이가 없어 보입니다. 하지만 인자 수가 늘어날수록 `preserve_none`의 유용성이 드러납니다. 앞서 언급했듯 x86_64는 인자 전달에 6개의 레지스터를 제공하므로, swap_ints를 8개 파라미터로 확장해 차이를 더 잘 보여봅시다.
#include <stdint.h> extern void hole_fn(void) attribute((CALLING_CONVENTION));
attribute((CALLING_CONVENTION)) void swap_ints(int a, int b, int c, int d, int e, int f, int g, int h) { typedef void(*outfn_type)(int, int, int, int, int, int, int, int) attribute((CALLING_CONVENTION)); outfn_type stencil_output = (outfn_type)&hole_fn; stencil_output(h, g, f, e, d, c, b, a); }
// clang -DCALLING_CONVENTION=cdecl -O3 -c // clang -DCALLING_CONVENTION=preserve_none -O3 -c
| cdecl 호출 규약 | preserve_none 호출 규약 |
| --- | --- |
| ```
; <swap_ints>:
push rbx
mov eax,ecx
mov r10d,edx
mov r11d,esi
mov ebx,edi
mov edi,DWORD PTR [rsp+0x18]
mov esi,DWORD PTR [rsp+0x10]
mov edx,r9d
mov ecx,r8d
mov r8d,eax
mov r9d,r10d
push rbx
push r11
call 27 <swap_ints+0x27>
;; R_X86_64_PLT32 hole_fn-0x4
add rsp,0x10
pop rbx
ret
``` | ```
; <swap_ints>:
mov eax,r15d
mov ebx,r14d
mov r8d,r13d
mov r9d,r12d
mov r12d,ecx
mov r13d,edx
mov r14d,esi
mov r15d,edi
mov edi,eax
mov esi,ebx
mov edx,r8d
mov ecx,r9d
jmp 27 <swap_ints+0x27>
;; R_X86_64_PLT32 hole_fn-0x4
``` |
즉, 필요한 경우에 유익합니다. `preserve_none`을 쓰면 6개의 입출력만 다룰 수 있던 스텐실을 12개의 입출력까지 확장할 수 있습니다. 그 이후에는 `preserve_none`도 레지스터가 바닥나 스택 프레임을 만들기 시작합니다. 다만 레지스터에는 여러 범주가 있습니다. 부동소수점 값과 SSE 연산은 `xmm` 레지스터를, AVX는 `ymm`를, AVX-512는 `zmm`를 사용합니다. 호출 규약은 이들에 대한 동작도 규정합니다.
| | 부동소수점 | SIMD |
| --- | --- | --- |
| | ```
STENCIL_FUNCTION
void float_passthrough(float a) {
DECLARE_STENCIL_OUTPUT(float);
return stencil_output(a);
}
``` | ```
#include <immintrin.h>
STENCIL_FUNCTION
void simd_passthrough(__m512 a) {
DECLARE_STENCIL_OUTPUT(__m512);
return stencil_output(a);
}
``` |
| cdecl | ```
; <float_passthrough>:
push r15
push r14
push r13
push r12
push rbx
call 10e <float_passthrough+0xe>
;; R_X86_64_PLT32
pop rbx
pop r12
pop r13
pop r14
pop r15
ret
``` | ```
; <simd_passthrough>:
push r15
push r14
push r13
push r12
push rbx
call 12e <simd_passthrough+0xe>
;; R_X86_64_PLT32
pop rbx
pop r12
pop r13
pop r14
pop r15
vzeroupper
ret
``` |
| preserve_none | ```
; <float_passthrough>:
jmp 105 <float_passthrough+0x5>
;; R_X86_64_PLT32
``` | ```
; <simd_passthrough>:
jmp 115 <simd_passthrough+0x5>
;; R_X86_64_PLT32
``` |
부동소수점이나 SIMD 레지스터가 하나라도 있으면 cdecl에서는 스택 프레임이 생성됩니다. 따라서 스텐실에서 이를 사용하려면 `preserve_none`을 써야 합니다. 그러면 8개의 함수 인자/레지스터까지 사용할 수 있고, 그 이후부터는 스택으로 인자를 전달하기 시작합니다.
SIMD에 대해서는, 다양한 SIMD 기능 집합별 코드를 생성하기 위해 `attribute(target("arch"))`를 사용할 수 있으며, 런타임에 스텐실로 사용할 코드를 선택할 수 있습니다.
attribute((preserve_none,target("avx"))) void fused_multiply_add_avx(__m512 a, __m512 b, __m512 c) { DECLARE_STENCIL_OUTPUT(__m512); return stencil_output(a * b + c); }
attribute((preserve_none,target("no-avx"))) void fused_multiply_add_sse2(__m512 a, __m512 b, __m512 c) { DECLARE_STENCIL_OUTPUT(__m512); return stencil_output(a * b + c); }
0000000000000100 <fused_multiply_add_avx>: 100: 62 f2 75 48 a8 c2 vfmadd213ps zmm0,zmm1,zmm2 106: e9 00 00 00 00 jmp 10b <fused_multiply_add_avx+0xb> 107: R_X86_64_PLT32 cnp_stencil_output-0x4 10b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
0000000000000110 <fused_multiply_add_sse2>: 110: 0f 59 c4 mulps xmm0,xmm4 113: 0f 58 44 24 08 addps xmm0,XMMWORD PTR [rsp+0x8] 118: 0f 59 cd mulps xmm1,xmm5 11b: 0f 58 4c 24 18 addps xmm1,XMMWORD PTR [rsp+0x18] 120: 0f 59 d6 mulps xmm2,xmm6 123: 0f 58 54 24 28 addps xmm2,XMMWORD PTR [rsp+0x28] 128: 0f 59 df mulps xmm3,xmm7 12b: 0f 58 5c 24 38 addps xmm3,XMMWORD PTR [rsp+0x38] 130: e9 00 00 00 00 jmp 135 <fused_multiply_add_sse2+0x25> 131: R_X86_64_PLT32 cnp_stencil_output-0x4
테일 콜
-------
앞서 언급했듯, 우리는 주로 clang의 최적화에 의존해 `stencil_output` 호출을 테일 콜로 변환합니다. 이는 필요 없는 경우 스택 프레임 프롤로그/에필로그를 생략하는 데에도 필요합니다. `swap_and_multiply` 예제로 돌아가 봅시다.
#include <stdint.h> extern void hole_fn(void) attribute((preserve_none)); extern int hole_for_int;
attribute((preserve_none)) void swap_and_multiply(int a, int b) { const int hole_value = (int)((uintptr_t)&hole_for_int); int c = a * hole_value; a = b * hole_value; b = c;
typedef void(*outfn_type)(int, int) attribute((preserve_none)); outfn_type stencil_output = (outfn_type)&hole_fn; stencil_output(a, b); }
최적화 없이(`-O0`)와 최적화와 함께(`-O3`)의 결과 코드를 비교해봅니다.
| clang -O0 | clang -O3 |
| --- | --- |
| ```
; <swap_and_multiply>:
push rbp (1)
mov rbp,rsp
sub rsp,0x20
mov DWORD PTR [rbp-0x4],r12d
mov DWORD PTR [rbp-0x8],r13d
mov eax,0x0 ;; R_X86_64_32 hole_for_int
mov DWORD PTR [rbp-0xc],eax
mov eax,DWORD PTR [rbp-0x4]
mov ecx,DWORD PTR [rbp-0xc]
imul eax,ecx
mov DWORD PTR [rbp-0x10],eax
mov eax,DWORD PTR [rbp-0x8]
mov ecx,DWORD PTR [rbp-0xc]
imul eax,ecx
mov DWORD PTR [rbp-0x4],eax
mov eax,DWORD PTR [rbp-0x10]
mov DWORD PTR [rbp-0x8],eax
mov QWORD PTR [rbp-0x18],0x0 ;; R_X86_64_32S hole_fn
mov rax,QWORD PTR [rbp-0x18]
mov r12d,DWORD PTR [rbp-0x4]
mov r13d,DWORD PTR [rbp-0x8]
call rax (3)
add rsp,0x20
pop rbp (2)
ret
``` | ```
; <swap_and_multiply>:
mov eax,r12d
mov r12d,0x0 ;; R_X86_64_32 hole_for_int
imul eax,r12d
imul r12d,r13d
mov r13d,eax
jmp 19 <swap_and_multiply+0x19> (3)
;; R_X86_64_PLT32 hole_fn-0x4
``` |
보시다시피 clang이 많은 일을 대신해줍니다. (1)과 (2)는 비최적화 버전에서 스택 프레임의 설정/해제이고, 최적화 버전에서는 생략되었습니다. (3) 위치의 호출은 테일 콜 점프로 대체되었습니다.
필요 없을 때 스택 프레임을 내지 않도록 clang에 더 구체적으로 지시하는 방법은 잘 모르겠습니다. `-fomit-frame-pointer -momit-leaf-frame-pointer`를 주면 `push rbp`/`pop rbp`는 사라지지만, 비최적화 코드는 로컬 변수를 위해 스택을 사용하므로 `sub rsp,0x20`과 `add rsp,0x20`는 남습니다. 아마 mem2reg만 돌려도 충분할 수 있지만, 여기의 요지는 어차피 스텐실 내부에서 LLVM의 모든 최적화를 “공짜로” 얻는 것입니다.
Clang은 테일 콜 생성을 강제하는 [musttail](https://clang.llvm.org/docs/AttributeReference.html#musttail) 속성을 지원합니다. 하지만 입력과 출력 타입이 _완전히_ 같아야 해서, 스텐실 생성 요구와 맞지 않습니다.
extern void hole_fn(void) attribute((preserve_none));
attribute((preserve_none)) void add_two_ints(int a, int b) { typedef void(*outfn_type)(int) attribute((preserve_none)); outfn_type stencil_output = (outfn_type)&hole_fn; // 반환문에 속성을 붙여 테일 콜을 강제한다. attribute((musttail)) return stencil_output(a + b); }
$ clang -O3 -c example.c
example.c:12:29: error: cannot perform a tail call to function 'stencil_output'
because its signature is incompatible with the calling function
12 | __attribute__((musttail)) return stencil_output(a + b);
| ^
example.c:11:3: note: target function has different number of parameters
(expected 2 but has 1)
11 | outfn_type stencil_output = (outfn_type)&hole_fn;
| ^
example.c:12:18: note: tail call required by 'musttail' attribute here
12 | __attribute__((musttail)) return stencil_output(a + b);
| ^
따라서 향후 바뀌지 않는 한, 우리는 `-O3`가 “마법처럼” 올바른 처리를 해주길 기대해야 합니다.
재배치
------
지금까지는 copy-and-patch의 “copy” 부분을 살펴봤습니다. 이제 “patch” 부분에 집중해 봅시다.
재배치(relocation)는 외부 심볼을 참조할 때, 프로그램이 실행되어 실행 파일과 여러 라이브러리가 메모리의 임의 주소에 적재될 때 동적 로더가 필요한 심볼들의 올바른 주소를 실행 파일에 패치할 수 있도록, clang이 남겨두는 정보 조각입니다. copy-and-patch에서는 스텐실에 홀을 삽입하고 싶을 때마다 외부 심볼을 참조하도록 “악용”하고, 컴파일 후 생성된 재배치 정보를 읽어 JIT 컴파일 타임에 해당 코드의 어떤 오프셋을 패치해야 할지 알아냅니다.
우리는 medium 코드 모델에 크게 의존합니다. 이 모델은 코드 참조가 ±2GB(32비트 값) 범위에 있을 것을 기대하고, 큰 데이터는 전체 64비트 값으로 참조해야 합니다. 코드 모델과 재배치에 대해서는 이미 많은 글이 있으니, 배경 지식은 [Understanding the x64 code models](https://eli.thegreenplace.net/2012/01/03/understanding-the-x64-code-models)이나 [Relocation Overflow and Code Models](https://maskray.me/blog/2023-05-14-relocation-overflow-and-code-models)를 참고하세요. [공식 AMD64 ABI 문서](https://docs.google.com/viewer?url=https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf)도 이례적으로 명확하고 유용합니다. small 모델은 코드와 데이터를 모두 32비트로, large 모델은 모두 64비트로 봅니다. medium을 사용하면, 코드인지 데이터인지에 따라 32비트 또는 64비트 홀을 만들 수 있습니다.
아래에 홀을 만드는 데 알아야 할 모든 것을 하나의 프로그램으로 요약했습니다.
#include <stdint.h>
extern uint8_t cnp_small_data_array[8]; extern uint8_t cnp_large_data_array[1000000]; extern void cnp_function_near(uint32_t, uint64_t); extern uint8_t cnp_function_far[1000000];
void stencil_example(void) { uint32_t small = (uint32_t)((uintptr_t)&cnp_small_data_array); uint64_t large = (uint64_t)((uintptr_t)&cnp_large_data_array); typedef void(*fn_ptr_t)(uint32_t, uint64_t); fn_ptr_t near_ptr = &cnp_function_near; near_ptr(small, large);
uint64_t largefn = (uint64_t)((uintptr_t)&cnp_function_far); asm volatile("" : "+r" (largefn) : : "memory"); fn_ptr_t far_ptr = (fn_ptr_t)largefn; far_ptr(small, largefn); }
가장 중요한 점은, 다시 한 번 강조하자면, 심볼이 가리키는 실제 데이터를 완전히 무시한다는 것입니다. 우리는 항상 심볼의 주소를 취해, 필요한 타입으로 캐스팅합니다. 위에서 이를 편하게 하려고 몇 가지 매크로를 사용한 이유입니다.
이를 `clang -O3 -mcmodel=medium -c example.c`로 컴파일합니다(`-mcmodel=medium`은 기본값이기도 합니다). 그리고 평소처럼 `objdump -d -Mintel,x86-64 --disassemble --reloc example.o`로 생성된 코드와 재배치를 봅니다.
0000000000000000 <stencil_example>: 0: 50 push rax 1: 48 be 00 00 00 00 00 movabs rsi,0x0 8: 00 00 00 3: R_X86_64_64 cnp_large_data_array b: bf 00 00 00 00 mov edi,0x0 c: R_X86_64_32 cnp_small_data_array 10: e8 00 00 00 00 call 15 <stencil_example+0x15> 11: R_X86_64_PLT32 cnp_function_near-0x4 15: 48 be 00 00 00 00 00 movabs rsi,0x0 1c: 00 00 00 17: R_X86_64_64 cnp_function_far 1f: bf 00 00 00 00 mov edi,0x0 20: R_X86_64_32 cnp_small_data_array 24: 58 pop rax 25: ff e6 jmp rsi
작은 데이터를 참조하면 32비트 홀이 생깁니다. 이는 `cnp_small_data_array`에 대한 재배치가 `R_X86_64_32`인 것으로 확인할 수 있습니다. 큰 데이터를 참조하면 64비트 홀이 생깁니다. `cnp_large_data_array`에는 `R_X86_64_64`가 할당되었고, 채워야 할 00 바이트가 더 많습니다. 어느 크기부터 “큰 데이터”로 보고 64비트 주소 지정을 적용할지는 `-mlarge-data-threshold=threshold`로 제어합니다. 하지만 어차피 배열은 실제로 존재하지 않으니, 굳이 필요 이상으로 큰 extern 배열을 선언해두어도 안전합니다.
함수를 호출할 때에는 코드 모델에 따라 함수가 ±2GB 범위에 있을 것으로 기대하므로, `cnp_function_near` 호출은 `R_X86_64_PLT32`인 32비트 홀로 변합니다. 스텐실 간 참조를 패치할 때는, 오프셋이 상대(relative) 기준이므로 소스 jmp/call의 정확한 위치와 목적지를 추적하는 것이 중요합니다. JIT 컴파일러 런타임의 함수로 다시 호출하고 싶다면, 그 함수는 ±2GB 범위에 있지 않을 가능성이 큽니다. 전체 64비트 주소로 call/jmp를 내보낼 수 있어야 합니다. 그런데 이것이 놀라울 정도로 어렵습니다.
void stencil_example(void) { typedef void(*fn_ptr_t)(uint64_t); fn_ptr_t direct_assign = (fn_ptr_t)((uintptr_t)&cnp_function_far); direct_assign(0);
uint64_t far_as_int = (uint64_t)((uintptr_t)&cnp_function_far); fn_ptr_t indirect_assign = (fn_ptr_t)far_as_int; indirect_assign(far_as_int);
uint64_t far_forgettable = (uint64_t)((uintptr_t)&cnp_function_far); // 빈 asm volatile을 악용해, clang이 값의 출처를 // 이해하지 못하게 만든다. asm volatile("" : "+r" (far_forgettable) : : "memory"); fn_ptr_t forgotten = (fn_ptr_t)far_forgettable; forgotten(far_forgettable); }
0000000000000000 <stencil_example>: 0: 53 push rbx 1: 31 ff xor edi,edi 3: e8 00 00 00 00 call 8 <stencil_example+0x8> 4: R_X86_64_PLT32 cnp_function_far-0x4 8: 48 bb 00 00 00 00 00 movabs rbx,0x0 f: 00 00 00 a: R_X86_64_64 cnp_function_far 12: 48 89 df mov rdi,rbx 15: e8 00 00 00 00 call 1a <stencil_example+0x1a> 16: R_X86_64_PLT32 cnp_function_far-0x4 1a: 48 89 df mov rdi,rbx 1d: 5b pop rbx 1e: ff e7 jmp rdi
여기에서 32비트 재배치(`R_X86_64_PLT32`)가 두 개, 64비트 재배치(`R_X86_64_64`)가 하나 있음을 볼 수 있습니다. 32비트 재배치가 생기는 이유는, 외부 심볼을 함수 포인터로 바꿨기 때문입니다. 코드 모델에 따르면 코드는 ±2GB 범위에 있어야 하므로 32비트면 충분합니다. 또한 clang은 이 사실을 변수 대입을 거쳐서도 추적합니다. 인자로는 전체 64비트 주소를 레지스터에 로드하더라도, 여전히 주소가 심볼 정의에서 왔다는 사실을 알고 있으므로 실제 호출에는 32비트 재배치를 내보냅니다. 함수 포인터 값의 출처를 clang이 “잊게” 만드는 유일한 방법은 빈 `asm volatile`을 거쳐, 더 이상 어떤 가정도 유효하지 않다고 생각하게 만드는 것이었습니다. 그러고 나서야 비로소 레지스터의 64비트 값으로 점프하는 코드를 내보내 주었습니다.
* * *