[链接器的世界-原理篇07] 动态链接:把最后的地址留给运行时
下文命令中的文件名只表示本次观察产生的输入和输出;目录位置可以由读者自行选择。
静态 PIE1 给自己的地址加上装载基址,就能修好指向本模块的指针。共享库带来了另一件事:目标未必属于本模块,甚至未必由文件中那份同名定义提供。一次函数调用要能成立,既要知道目标被装到了哪里,也要确定这次运行选择了哪个定义。
内核只根据 PT_INTERP 把解释器映射进来,并先把控制权交给它。后续依赖由动态链接器 ld.so 查找、加载和绑定。musl2 将动态链接器与 libc 合在同一个文件里,glibc3 通常把二者分开;因此“libc 是否已经映射”取决于实现,尚未完成的符号绑定才是共同的问题(musl 的设计)。
设想一个提供 bump 函数的小库:多个程序都要调用它,但库在各进程中的基址可以不同。如果每次加载都要改写函数的指令,代码页就很难继续共享。链接器和动态链接器的分工,要同时解决地址未知与代码共享这两个约束。
本章统一在 x86-64 Linux 本机编译、链接和运行。Clang/LLD 21.1.8、GCC4 15.2、GNU5 binutils6 2.46;musl-gcc7 使用 musl 1.2.5,系统 gcc 使用 glibc 2.43。musl 与 glibc 的加载器仍分开讨论:惰性绑定、LD_DEBUG 等 glibc 功能用本机 glibc 程序验证;命令输出只用于说明机制,不把某个系统的库路径当成格式约定。
把代码留到运行时修改,代价是什么
先试最直接的想法:共享库照常按地址 0 链接,加载时 ld.so 知道了基址,再把代码里每一个用到绝对地址的地方都改一遍。这和静态链接时链接器做的事完全一样,只是挪到了运行时。
下面是本章贯穿始终的小库:
// vec.cint counter;
__attribute__((noinline)) int helper(int x) { return x * 2; }
int bump(int x) { counter += helper(x); return counter;}noinline 是为了让 bump 对 helper 的调用一直留在机器码里,方便观察。先不加 -fPIC,按普通可执行文件的方式编译,再试图链接成共享库:
$ clang -fno-pic -O1 -c vec.c -o vec_nopic.o$ llvm-objdump -dr --no-show-raw-insn vec_nopic.o0000000000000010 <bump>: 10: pushq %rax 11: callq 0x16 <bump+0x6> 0000000000000012: R_X86_64_PLT32 helper-0x4 16: addl (%rip), %eax # 0x1c <bump+0xc> 0000000000000018: R_X86_64_PC32 counter-0x4 1c: movl %eax, (%rip) # 0x22 <bump+0x12> 000000000000001e: R_X86_64_PC32 counter-0x4 22: popq %rcx 23: retq$ ld.lld -shared vec_nopic.o -o x.sold.lld: error: relocation R_X86_64_PC32 cannot be used against symbol 'counter'; recompile with -fPIC>>> defined in vec_nopic.o>>> referenced by vec.c>>> vec_nopic.o:(bump)GNU ld 的报错几乎一样:"relocation R_X86_64_PC32 against symbol counter' can not be used when making a shared object; recompile with -fPIC"。这句话很多人都见过。第 4 章讲过 R_X86_64_PC32的公式是S + A − P,它要求链接时就知道 counter 和这条指令之间的距离。可在共享库里,counter` 最终可能并不在这个库里(后面讲 copy relocation 和符号插入时会看到原因),这个距离链接时无从谈起。
换一种写法,让代码直接装一个 64 位绝对地址(-mcmodel=large 会生成 movabsq 加 R_X86_64_64):
$ cat abs.cint counter;int *get(void){ return &counter; }$ clang -fno-pic -mcmodel=large -O1 -c abs.c -o abs.o$ llvm-objdump -dr --no-show-raw-insn abs.oDisassembly of section .ltext:
0000000000000000 <get>: 0: movabsq $0x0, %rax 0000000000000002: R_X86_64_64 counter a: retq$ ld.lld -shared -z notext abs.o -o abs.so$ readelf -d abs.so | grep -E 'TEXTREL|FLAGS' 0x000000000000001e (FLAGS) TEXTREL 0x0000000000000016 (TEXTREL) 0x0$ readelf -r abs.soRelocation section '.rela.dyn' at offset 0x310 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend000000001332 000200000001 R_X86_64_64 0000000000005440 counter + 0不加 -z notext 时 lld 同样会拒绝;加上之后,链接器把这条重定位原样留给 ld.so,并在 .dynamic 里打上 TEXTREL 标记。这种落在代码里、要在运行时修补的重定位叫文本重定位(text relocation)。地址 0x1332 落在代码节里(large 模型下这个节叫 .ltext),ld.so 加载时就要往代码里写 8 个字节。
这条路走得通,glibc 的 ld.so 至今仍能处理文本重定位,但代价很重。共享库以私有映射(MAP_PRIVATE,写入只影响本进程)装入,只读的代码页本来所有进程共用一份物理内存。ld.so 要先用 mprotect(修改内存访问权限的系统调用)把页改成可写再修补,内核随即为本进程复制一份私有拷贝,这叫写时复制(copy-on-write)。各进程基址不同,修补结果也不同,这些页从此无法共享。修补期间页面同时可写可执行,违背 W^X(同一页不同时可写且可执行)这条安全原则;每次启动还要重做一遍。Ulrich Drepper 在《How To Write Shared Libraries》里把文本重定位列为必须避免的东西(dsohowto.pdf)。
问题的根子在于:需要随加载地址变化的值,散落在代码的各个角落。如果能把它们集中到少数几页可写的数据里,代码本身就能保持只读、保持共享。这就是 PIC8 要做的事。
PIC:把要改的地址赶到一处
PIC(position-independent code,位置无关代码)在第 1 章只有一句话的定义。它的做法分两半。
第一半:对确定绑定在本模块内、距离满足编码范围的引用,x86-64 通常可以使用 PC 相对寻址。一个共享库无论被加载到哪里,它内部的代码和数据之间的相对距离都是链接时定下的,S + A − P 里 S 和 P 一起平移,差不变。x86-64 的 lea foo(%rip)、call 指令天然就是这样寻址的。
第二半:凡是链接时确定不了的地址,比如另一个库里的变量,或者可能被替换掉的符号,代码都不直接引用,改为去一张表里取。这张表叫 GOT9(global offset table,全局偏移表),位于可写的数据段,每一项存一个完整的地址。代码用 PC 相对寻址找到 GOT 里的那一项(GOT 和代码同属一个模块,相对距离固定),再从中读出目标地址。这类访问在运行时只需填写 GOT,不必修改引用它的指令。数据里的绝对指针仍需要其他动态重定位。GOT 所在的页会因为被写而变成每个进程私有,但地址通常能比零散的指令修改更集中;具体占用多少页取决于库的符号与引用数量。
把 vec.c 加上 -fPIC 重新编译:
$ clang -fPIC -O1 -c vec.c -o vec.o$ llvm-objdump -d -r --no-show-raw-insn vec.o0000000000000000 <helper>: 0: leal (%rdi,%rdi), %eax 3: retq 4: nopw %cs:(%rax,%rax)
0000000000000010 <bump>: 10: pushq %rax 11: callq 0x16 <bump+0x6> 0000000000000012: R_X86_64_PLT32 helper-0x4 16: movq (%rip), %rcx # 0x1d <bump+0xd> 0000000000000019: R_X86_64_REX_GOTPCRELX counter-0x4 1d: addl (%rcx), %eax 1f: movl %eax, (%rcx) 21: popq %rcx 22: retq和非 PIC 版本对比,访问 counter 从两条直接读写内存的指令,变成了先 movq 从 GOT 里取出 counter 的地址放进 %rcx,再通过 %rcx 读写。第 4 章讲过,R_X86_64_REX_GOTPCRELX 的意思是"这里填 GOT 中 counter 那一项相对当前位置的距离",后缀 X 表示允许链接器在条件满足时把它松弛成直接访问。
奇怪的是 helper 和 counter 明明定义在同一个文件里,为什么一个走 PLT10、一个绕道 GOT?原因是 ELF11 的一条规则:共享库里默认可见的全局符号,可以被别的模块里的同名定义替换掉,这叫符号插入(interposition),第 3 章预告过。编译器不知道运行时它们会不会被替换,只好按最坏情况处理。这条规则的代价和对策后面专门讲。
PIE(第 5 章)就是用 PIC 的方式编译、链接出的可执行文件,和共享库一样可以放在任意基址。区别在于可执行文件里的符号不会被别人替换(全局符号查找总是先找可执行文件自己),编译器在 -fPIE 下对自己定义的符号可以直接用 PC 相对寻址,少走很多 GOT。
链接器给 ld.so 写的说明书
目标文件里只有"这里要用 counter 的 GOT 项"这样的便条,GOT 本身并不存在。建 GOT、给每个需要的符号分一项的是链接器;它还得另外告诉 ld.so:GOT 在哪,哪些项要填、填哪个符号。把 vec.o 链接成共享库:
$ ld.lld -shared -soname libvec.so.1 vec.o -o libvec.so.1ld.so 不读节头表,.dynamic 是通过 PT_DYNAMIC 程序头找到的。.dynamic 是一个由"标签、值"对组成的数组,每一项以 DT_ 开头的常量为标签,告诉 ld.so 去哪里找它需要的各种表(gABI 第 5 章 Dynamic Section):
$ readelf -d libvec.so.1Dynamic section at offset 0x3b0 contains 15 entries: Tag Type Name/Value 0x000000000000000e (SONAME) Library soname: [libvec.so.1] 0x0000000000000007 (RELA) 0x2d8 0x0000000000000008 (RELASZ) 24 (bytes) 0x0000000000000009 (RELAENT) 24 (bytes) 0x0000000000000017 (JMPREL) 0x2f0 0x0000000000000002 (PLTRELSZ) 24 (bytes) 0x0000000000000003 (PLTGOT) 0x34a8 0x0000000000000014 (PLTREL) RELA 0x0000000000000006 (SYMTAB) 0x200 0x000000000000000b (SYMENT) 24 (bytes) 0x0000000000000005 (STRTAB) 0x2b0 0x000000000000000a (STRSZ) 33 (bytes) 0x000000006ffffef5 (GNU_HASH) 0x260 0x0000000000000004 (HASH) 0x288 0x0000000000000000 (NULL) 0x0标签决定第二个字段怎样解释
每个 Elf64_Dyn 占 16 字节:前八字节是有符号标签 d_tag,后八字节是联合字段 d_un。它可以按整数 d_val 或地址 d_ptr 解释;选择依据是标签,不是显示出来的数值大小。DT_NULL 标记数组结束,并不要求数组的所有项都是地址(gABI Dynamic Section)。
| 本例标签 | 值的类别 | 加载偏移为 B 时怎样使用 |
|---|---|---|
DT_RELA = 0x2d8 | 链接时虚拟地址 | 重定位表起点为 B + 0x2d8 |
DT_RELASZ = 24 | 字节数 | 表长仍为 24,不能加 B |
DT_RELAENT = 24 | 单条记录宽度 | 24 / 24 = 1 条记录 |
DT_STRTAB = 0x2b0 | 链接时虚拟地址 | 字符串表起点为 B + 0x2b0 |
DT_SONAME 的原始数值 | 相对于字符串表的偏移 | 在字符串表起点上加该偏移,再读取零结尾名称 |
DT_PLTREL = DT_RELA | 格式标签 | 表示 PLT 重定位使用 RELA,不能当作指针 |
readelf 将 SONAME 的偏移解码成了 [libvec.so.1],将 PLTREL 的数值解码成了 RELA。可读输出隐藏了这两次解释,实际文件中的第二个字段仍然只有八字节。地址项使用本模块的加载偏移转换;大小、条目宽度、字符串偏移和格式标签各有自己的基点与含义。
逐项看:
SYMTAB和STRTAB指向动态符号表.dynsym和它的字符串表.dynstr。第 2 章看过的.symtab是给链接器和调试器用的,可以被 strip(删除符号表和调试信息的工具)删掉;.dynsym只收录运行时需要的符号(本库导出的、本库要从别处引用的),它所在的节带SHF_ALLOC,会被装进内存。GNU_HASH和HASH是两种符号哈希表,让ld.so按名字查符号时不必线性扫描.dynsym。GNU_HASH是 GNU 后来加的格式,带布隆过滤器(一种能快速判断"一定不在集合里"的位图),查找失败时很快。RELA/RELASZ指向.rela.dyn,JMPREL/PLTRELSZ指向.rela.plt,这是两组留给ld.so的动态重定位,下一节细讲。PLTGOT是.got.plt的地址。SONAME是这个库的"正式名字",它会被写进依赖者的DT_NEEDED。
SONAME 要和可执行文件一起看。写一个使用它的主程序(本章的 main.c):
// main.cextern int counter;int bump(int);
int main(void) { bump(1); return counter;}$ ln -sf libvec.so.1 libvec.so$ clang -fPIE -O1 -c main.c -o main_pie.o$ musl-gcc -pie main_pie.o -L. -lvec -Wl,-rpath,'$ORIGIN' -o main_pie$ readelf -d main_pieDynamic section at offset 0x2dd8 contains 26 entries: Tag Type Name/Value 0x0000000000000001 (NEEDED) Shared library: [libvec.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so] 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN] 0x000000000000000c (INIT) 0x1000 0x000000000000000d (FINI) 0x1176 0x0000000000000019 (INIT_ARRAY) 0x3dc8 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes) 0x000000000000001a (FINI_ARRAY) 0x3dd0 0x000000000000001c (FINI_ARRAYSZ) 8 (bytes) 0x000000006ffffef5 (GNU_HASH) 0x258 0x0000000000000005 (STRTAB) 0x360 0x0000000000000006 (SYMTAB) 0x288 0x000000000000000a (STRSZ) 141 (bytes) 0x000000000000000b (SYMENT) 24 (bytes) 0x0000000000000015 (DEBUG) 0x0 0x0000000000000003 (PLTGOT) 0x3fb8 0x0000000000000002 (PLTRELSZ) 48 (bytes) 0x0000000000000014 (PLTREL) RELA 0x0000000000000017 (JMPREL) 0x498 0x0000000000000007 (RELA) 0x3f0 0x0000000000000008 (RELASZ) 168 (bytes) 0x0000000000000009 (RELAENT) 24 (bytes) 0x000000000000001e (FLAGS) BIND_NOW 0x000000006ffffffb (FLAGS_1) Flags: NOW PIE 0x000000006ffffff9 (RELACOUNT) 3 0x0000000000000000 (NULL) 0x0(中间省略的几行与库的那份类似。)命令行里写的是 -lvec,链接器据此找到了 libvec.so(一个符号链接),但写进 DT_NEEDED 的是那个库的 SONAME:libvec.so.1。这就是 Linux 上共享库版本号的约定:不兼容的接口变动要换 SONAME(.so.1 变 .so.2),旧程序继续依赖 .so.1,新旧两个库可以并存。glibc 系统上 DT_NEEDED 里通常会看到 libc.so.6,这里是 musl,所以是 libc.so。
DT_RPATH 和它的后继 DT_RUNPATH 是嵌在文件里的库搜索路径,$ORIGIN 在运行时会被替换成可执行文件所在的目录,这样程序和它的库可以一起搬到任何地方。本机默认已经生成 DT_RUNPATH;--enable-new-dtags 可显式选择它,--disable-new-dtags 则生成旧式 RPATH。下面再显式打开立即绑定(本机驱动的默认值也包含它):
$ musl-gcc -pie main_pie.o -L. -lvec -Wl,--enable-new-dtags,-rpath,'$ORIGIN' -Wl,-z,now -o main_pie2$ readelf -d main_pie2 | grep -E 'RUNPATH|FLAGS' 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN] 0x000000000000001e (FLAGS) BIND_NOW 0x000000006ffffffb (FLAGS_1) Flags: NOW PIE两者的区别在搜索顺序上,留到讲 ld.so 时再说。默认写哪一个,各家链接器不同:
$ ld.lld -shared -rpath '$ORIGIN' vec.o -o r_lld.so$ readelf -d r_lld.so | grep PATH 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]$ ld -shared -rpath '$ORIGIN' vec.o -o r_bfd.so$ readelf -d r_bfd.so | grep PATH 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]本机这两个链接器都默认写 RUNPATH。默认值受版本与发行版构建配置影响:需要固定行为时,显式写 --enable-new-dtags 或 --disable-new-dtags,再以 readelf -d 核对。
INIT 和 FINI 各指向一个函数(通常是启动文件提供的 _init 和 _fini),在模块初始化和卸载时调用,是早期的形式。INIT_ARRAY 是后来的函数指针数组,C++ 全局对象的构造函数和 __attribute__((constructor)) 函数都登记在这里;FINI_ARRAY 对应析构。谁在什么时候调用它们,讲 ld.so 的启动过程时再看。DEBUG 一项在文件里是 0,运行时 ld.so 会把它改成一个描述已加载模块列表的结构的地址,调试器靠它找到所有共享库。
写不写 DT_NEEDED,是链接器决定的。默认情况下,命令行上出现的每个共享库都会被记成依赖,不管有没有用到它的符号。--as-needed 改变这一点:只有解析了某个未定义符号的库才写进去。拿一个和 main 毫无关系的 libother.so(里面只有一个函数 int other(void),用 ld.lld -shared -soname libother.so 链接)试试:
$ musl-gcc -pie main_pie.o -L. -lvec -lother -o m1$ readelf -d m1 | grep NEEDED 0x0000000000000001 (NEEDED) Shared library: [libvec.so.1] 0x0000000000000001 (NEEDED) Shared library: [libother.so] 0x0000000000000001 (NEEDED) Shared library: [libc.so]$ musl-gcc -pie main_pie.o -L. -Wl,--as-needed -lvec -lother -o m2$ readelf -d m2 | grep NEEDED 0x0000000000000001 (NEEDED) Shared library: [libvec.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so]多一个 DT_NEEDED,ld.so 每次启动就要多装一个库。--as-needed 是位置相关的选项,只影响写在它后面的库,所以要放在 -l 之前。
另一个分工在未定义符号上。链接可执行文件时,找不到定义的符号会报 undefined reference;链接共享库时,默认却允许留下未定义符号:
$ cat lib2.cint missing(int);int use(int x) { return missing(x) + 1; }$ clang -fPIC -O1 -c lib2.c -o lib2.o$ ld.lld -shared lib2.o -o lib2.so$ readelf --dyn-syms lib2.so | grep missing 1: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND missing$ ld.lld -shared -z defs lib2.o -o lib2.sold.lld: error: undefined symbol: missing>>> referenced by lib2.c>>> lib2.o:(use)$ ld -shared --no-undefined lib2.o -o lib2.sold: lib2.o: in function `use':lib2.c:(.text+0x2): undefined reference to `missing'这样设计是因为共享库里的未定义符号可能由运行时的其他模块提供,比如插件调用宿主程序导出的函数。代价是拼错的名字、漏写的 -l 也会被静默放过,直到 ld.so 加载时才报 undefined symbol。-z defs(GNU ld 的同义写法是 --no-undefined)要求共享库的每个未定义符号都能在链接时由命令行上的某个库解析,不少项目给自己的库默认加上它。
回到 main_pie,可执行文件还多了一个程序头:
$ readelf -lW main_pieElf file type is DYN (Position-Independent Executable file)Entry point 0x1050There are 9 program headers, starting at offset 64
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0001f8 0x0001f8 R 0x8 INTERP 0x000238 0x0000000000000238 0x0000000000000238 0x000019 0x000019 R 0x1 [Requesting program interpreter: /lib/ld-musl-x86_64.so.1] LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0004c8 0x0004c8 R 0x1000 LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x000179 0x000179 R E 0x1000 LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000074 0x000074 R 0x1000 LOAD 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x000240 0x000248 RW 0x1000 DYNAMIC 0x002dd8 0x0000000000003dd8 0x0000000000003dd8 0x0001e0 0x0001e0 RW 0x8 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10 GNU_RELRO 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x000238 0x000238 R 0x1PT_INTERP 里存着一个路径,叫程序解释器。内核加载可执行文件时看到它,就会把这个文件(也就是 ld.so)一起映射进来,并且先跳到 ld.so 的入口,而不是程序自己的 _start。glibc 系统上这个路径是 /lib64/ld-linux-x86-64.so.2。PT_DYNAMIC 告诉 ld.so .dynamic 在内存里的位置。PT_GNU_RELRO 和 PT_GNU_STACK 后面都会讲到,PT_GNU_EH_FRAME 属于下一章。
这些程序头和 .dynamic 合起来,就是链接器写给 ld.so 的说明书:我依赖谁、我的符号表在哪、哪些地方需要你来填。接下来看"哪些地方需要你来填"。
动态重定位
第 4 章的静态重定位由链接器应用,默认不把那些输入重定位表原样保留在最终可执行文件中。链接器处理不了的那部分,会被翻译成动态重定位,写进 .rela.dyn 和 .rela.plt,由 ld.so 在运行时处理。记录格式和目标文件里的 RELA 一样,只是偏移变成了虚拟地址,类型换成专门给 ld.so 用的一组。x86-64 上常见的有六种:下面先看 GLOB_DAT、JUMP_SLOT、RELATIVE,再看带符号的 R_X86_64_64 和 IRELATIVE,最后单独讲 COPY;TLS12(线程局部存储,每个线程各有一份的变量)相关的几种留到第 9 章。定义都在 x86-64 psABI 的重定位表里。
写入地址与写入值分开计算
这里的 r_offset 与输入 .o 中的同名字段采用不同坐标:对 ET_REL,它相对于被修改的输入节;对可执行文件或共享库,它表示被修改字段的链接时虚拟地址(gABI Relocation)。若包含这条记录的共享对象以加载偏移 B 装入,实际写入位置是 B + r_offset。这个 B 属于存放字段的模块,不一定属于提供符号定义的模块。
以随后出现的 RELATIVE 记录为例,r_offset=0x3338、A=0x3348。假设 B=0x70000000,加载器向地址 0x70003338 写入八字节指针 0x70003348,小端编码为 48 33 00 70 00 00 00 00。前者回答“在哪里写”,后者回答“写什么”。同一模块中的 RELATIVE 把自己的 B 同时用于二者,不需要按名字查找定义。
GLOB_DAT 则有不同的来源:写入位置依然是引用者的 B + r_offset,写入值是符号查找选中的运行时地址 S。若定义位于另一个共享库,其地址要用那个库自己的加载偏移求得;若发生 interposition,更不能用本库同名符号的值代替查找结果。
libvec.so.1 里有两条:
$ readelf -r libvec.so.1Relocation section '.rela.dyn' at offset 0x2d8 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend0000000024a0 000300000006 R_X86_64_GLOB_DAT 00000000000034c8 counter + 0
Relocation section '.rela.plt' at offset 0x2f0 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend0000000034c0 000100000007 R_X86_64_JUMP_SLO 0000000000001360 helper + 0(JUMP_SLO 是 readelf13 把 R_X86_64_JUMP_SLOT 截断了。)
R_X86_64_GLOB_DAT 的意思是:在地址 0x24a0 这个 GOT 项里,填上符号 counter 的运行时地址。ld.so 要先按名字在所有已加载模块里查找 counter,再把找到的地址写进去。vec.o 里那条 GOTPCRELX 引用的正是这个 GOT 项。
R_X86_64_JUMP_SLOT 和它很像,也是往一个 GOT 项(这次在 .got.plt 里)填函数地址,区别是它可以推迟处理,这是下一节 PLT 的内容。
第三种是 R_X86_64_RELATIVE。写一个数据里存着自身地址的例子:
$ cat rel.cstatic int table[4];int *cursor = &table[2];$ clang -fPIC -O1 -c rel.c -o rel.o$ llvm-objdump -r rel.oRELOCATION RECORDS FOR [.data]:OFFSET TYPE VALUE0000000000000000 R_X86_64_64 .bss+0x8$ ld.lld -shared rel.o -o librel.so$ readelf -r librel.soRelocation section '.rela.dyn' at offset 0x270 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend000000003338 000000000008 R_X86_64_RELATIVE 3348cursor 是一个初始化好的指针,它的值必须是 table[2] 的绝对地址,这只能在加载后才知道。不过 table 是本库的 static 变量,不可能被替换,链接器已经算出它相对库开头的位置是 0x3348(.bss 起于 0x3340,加 8 字节)。所以这条重定位不需要符号,公式就是"基址 + 加数":ld.so 把加载基址加上 0x3348,写到运行时地址 B + 0x3338。不查符号表,这是最便宜的一种动态重定位,也往往是数量最多的一种。main_pie 的 .dynamic 里那个 RELACOUNT 3,就是告诉 ld.so:.rela.dyn 开头 3 条都是 RELATIVE,可以用快速路径批量处理。
正因为它们又多又单调,后来有了压缩格式 DT_RELR,放在 .relr.dyn 节里。它只记位置,加数就存在那个位置的 8 个字节里,ld.so 读出来加上基址再写回。表的每一项 8 字节,最低位是 0 的项是一个地址,修正它,并把"当前位置"设为它后面那个字;最低位是 1 的项是位图,其余 63 位依次对应从当前位置起的 63 个字,位为 1 就修正对应的字,然后当前位置前进 63 个字。一串连续的指针,于是只要一个地址加上每 63 个一项位图。链接时加 -z pack-relative-relocs 生成它,GNU ld 2.38 起在 x86 上支持,lld 15 起支持;运行时要 glibc 2.36 以上(glibc NEWS),musl 的 ld.so 和第 6 章的 rcrt1.o 也认它。拿一个 PIE 试,relr.c 里的 ptrs 是 512 个指向 static 数组各元素的指针:
$ gcc -fPIE -O1 -c relr.c -o relr.o$ gcc -pie relr.o -o relr_plain$ gcc -pie -Wl,-z,pack-relative-relocs relr.o -o relr_packed$ readelf -SW relr_plain | grep "rel.\.dyn"$ readelf -SW relr_packed | grep "rel.\.dyn" [ 8] .rela.dyn RELA 0000000000000510 000510 0030c0 18 A 4 0 8 [ 8] .rela.dyn RELA 0000000000000530 000530 000078 18 A 4 0 8 [ 9] .relr.dyn RELR 00000000000005a8 0005a8 000058 08 A 0 0 8
$ readelf -rW relr_packed | grep -A5 relr.dynRelocation section '.relr.dyn' at offset 0x5a8 contains 11 entries which relocate 515 locations:Index: Entry Address Symbolic Address0000: 0000000000003dc0 0000000000003dc0 __frame_dummy_init_array_entry0001: 0000000000000003 0000000000003dc8 __do_global_dtors_aux_fini_array_entry0002: ffffffffffffe401 0000000000004008 __dso_handle 0000000000004020 ptrs515 个位置需要按基址修正:512 个数组指针,加 .init_array、.fini_array 与 __dso_handle。未打包的 .rela.dyn 总共 12480 字节(还包括 5 条 GLOB_DAT);打包后剩 120 字节的 .rela.dyn 和 88 字节、11 项的 .relr.dyn。前两项是地址 0x3dc0 和位图 0x3,后者修正 0x3dc8,随后当前位置前进 63 个字。下一位图 0xffffffffffffe401 的位 10 修正 0x4008,位 13 起覆盖 ptrs,可以逐位按公式核对。
本机 glibc 2.43 和 musl 1.2.5 的打包版本都正常退出 0。glibc 版本的 readelf -V 还显示 GLIBC_ABI_DT_RELR 需求,使不具备这一 ABI14 版本的加载器能明确拒绝文件;本次没有使用旧系统容器来替代主线运行。
能写成 RELATIVE 的前提是目标不可能被替换。指针指向哪种目标,决定了生成哪种重定位,把几种目标放在一起试:
$ cat ptr.cstatic int table[4];static int quiet(int x) { return x; }__attribute__((visibility("hidden"))) int hid(int x) { return x + 1; }int loud(int x) { return x + 2; }
int *cursor = &table[2];const char *msg = "hi";int (*ops[3])(int) = { quiet, hid, loud };$ clang -fPIC -O1 -c ptr.c -o ptr.o$ ld.lld -shared ptr.o -o libptr.so$ readelf -r libptr.soRelocation section '.rela.dyn' at offset 0x2f0 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend0000000034b0 000000000008 R_X86_64_RELATIVE 34e80000000034b8 000000000008 R_X86_64_RELATIVE 3680000000034c0 000000000008 R_X86_64_RELATIVE 13f00000000034c8 000000000008 R_X86_64_RELATIVE 13d00000000034d0 000100000001 R_X86_64_64 00000000000013e0 loud + 0static 的 table 和 quiet、hidden 的 hid、字符串常量 "hi",都只能是本库里的那一份,链接器直接写成 RELATIVE。ops[2] 指向默认可见的 loud,它可能被别的模块替换,链接器只能留下一条带符号的 R_X86_64_64:ld.so 先按名字查到 loud 的定义,再把地址加上加数写进去。C++ 虚函数表也一样,表项指向默认可见的虚函数时就是这种带符号的重定位,所以大型 C++ 库启动时要做大量符号查找。
第五种 R_X86_64_IRELATIVE 服务于 IFUNC(indirect function)。IFUNC 是一种运行时才挑实现的函数:库里不直接给出函数体,而是给一个解析函数(resolver),由它根据 CPU 特性返回要用的实现。glibc 的 memcpy、strlen 就是这样,在支持 AVX2 的机器上拿到 AVX2 版本。
$ cat ifn.cextern int has_avx2;static int add_sse(int a, int b) { return a + b; }static int add_avx(int a, int b) { return b + a; }static void *pick(void) { return has_avx2 ? (void *)add_avx : (void *)add_sse; }__attribute__((visibility("hidden"))) int add(int, int) __attribute__((ifunc("pick")));int call(int a, int b) { return add(a, b); }$ clang -fPIC -O1 -c ifn.c -o ifn.o$ ld.lld -shared ifn.o -o libifn.so$ readelf -r libifn.so | grep IREL000000003448 000000000025 R_X86_64_IRELATIV 1350$ readelf -s libifn.so | grep -E ' (pick|add)$' 2: 0000000000001350 29 FUNC LOCAL DEFAULT 7 pick 5: 0000000000001350 29 IFUNC LOCAL HIDDEN 7 add符号 add 的类型是 IFUNC,它的值就是解析函数 pick 的地址。IRELATIVE 也不带符号,加数 0x1350 指向 pick:ld.so 算出"基址 + 加数"后不直接写入,而是把它当函数调用一次,把返回值写进 0x3448。这个位置是一个 GOT 项,call 对 add 的调用经过链接器生成的一小段跳板,从这里取目标。这里把 add 设成 hidden,是为了让它不可被替换;默认可见的 IFUNC 会像普通函数一样走 JUMP_SLOT,只是 ld.so 查到它时发现类型是 IFUNC,同样先调用解析函数。
最后一种 R_X86_64_COPY 最特别,它来自一个历史包袱。
copy relocation 的来龙去脉
很长一段时间里,Linux 发行版的可执行文件都不是 PIE,是用 -fno-pic 编译、链接到固定地址的。这类代码访问全局变量时直接用 PC 相对或绝对地址,不走 GOT,因为编译器以为所有变量最后都会被静态链接进同一个文件。可如果变量实际定义在共享库里呢?
$ clang -fno-pic -O1 -c main.c -o main_nopic.o$ llvm-objdump -dr --no-show-raw-insn main_nopic.o0000000000000000 <main>: 0: pushq %rax 1: movl $0x1, %edi 6: callq 0xb <main+0xb> 0000000000000007: R_X86_64_PLT32 bump-0x4 b: movl (%rip), %eax # 0x11 <main+0x11> 000000000000000d: R_X86_64_PC32 counter-0x4 11: popq %rcx 12: retqmain 读 counter 用的是 R_X86_64_PC32,要求链接时就知道 counter 离这条指令多远。可 counter 在 libvec.so.1 里,地址要到运行时才知道。链接器面前有两条路:在代码里留文本重定位(上面已经说过它的代价),或者干脆在可执行文件自己的 .bss 里给 counter 留一块同样大小的空间,让 main 的引用指向这里,这个距离链接时就确定了:
$ musl-gcc -no-pie main_nopic.o -L. -lvec -Wl,-rpath,'$ORIGIN' -o main_nopie_musl$ readelf -rW main_nopie_muslRelocation section '.rela.dyn' at offset 0x3f8 contains 4 entries: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000403fd0 0000000500000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 00000000000403fd8 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTable + 00000000000403fe0 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMCloneTable + 00000000000404018 0000000700000005 R_X86_64_COPY 0000000000404018 counter + 0
Relocation section '.rela.plt' at offset 0x458 contains 2 entries: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000404000 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 __libc_start_main + 00000000000404008 0000000400000007 R_X86_64_JUMP_SLOT 0000000000000000 bump + 0$ readelf -sW main_nopie_musl | grep counter 7: 0000000000404018 4 OBJECT GLOBAL DEFAULT 19 counter 23: 0000000000404018 4 OBJECT GLOBAL DEFAULT 19 counter可执行文件里出现了一个"已定义"的 counter,位于第 19 节(.bss),大小 4 字节,正好是库里那个 int 的大小。R_X86_64_COPY 告诉 ld.so:启动时去找共享库里的 counter,把它的初始内容按这个大小复制到 0x404018。
复制之后,进程里就有了两个 counter,必须让所有人只用一个。这就是为什么 libvec.so.1 访问自己的 counter 也要走 GOT:全局符号查找总是先找可执行文件,所以库里那条 GLOB_DAT 最终填进 GOT 的,是可执行文件里那个副本的地址。库里的原件从此没人用了。前面"counter 最终可能并不在这个库里"指的就是这种情况。
copy relocation 用可执行文件中的存储副本支持这类直接寻址,但也把对象的大小固定在调用方的二进制接口中。假如库将 int counter 改为 long counter,在这里的 x86-64 ABI 下,对象由 4 字节变成 8 字节;旧可执行文件却仍然只为它预留 4 字节。更换库并不会自动扩大这块空间。
不同加载器对尺寸不匹配的诊断可能不同。下面专门构建一个由 glibc 加载器启动的 main_nopie_glibc,先链接原有的 4 字节定义,再替换共享库。main_nopie_musl 仍是前面独立的 musl 程序。
$ gcc -fPIC -O1 -shared -Wl,-soname,libvec.so.1 vec.c -o libvec.so.1$ gcc -no-pie main_nopic.o -L. -lvec -Wl,-rpath,'$ORIGIN' -o main_nopie_glibc$ ./main_nopie_glibc; echo exit=$?exit=2$ sed 's/int counter;/long counter;/' vec.c > vec8.c$ gcc -fPIC -O1 -shared -Wl,-soname,libvec.so.1 vec8.c -o libvec.so.1$ ./main_nopie_glibc; echo exit=$?./main_nopie_glibc: Symbol `counter' has different size in shared object, consider re-linkingexit=2$ ld.lld -shared -soname libvec.so.1 vec.o -o libvec.so.1最后一条命令用前面生成的 vec.o 恢复原来的 4 字节库定义。警告说明加载器发现了 ABI 不匹配;退出码仍为 2,并不能说明替换是安全的。新库中的代码按 8 字节访问对象,可能越过旧可执行文件分配的 4 字节空间。copy relocation 的核心代价由此显现:原本属于库的对象布局,成为了可执行文件也必须遵守的 ABI。
到了 PIE 时代,按理说可执行文件也有 GOT 了,可以和共享库一样通过 GOT 访问外部变量,copy relocation 就没必要了。clang 的 -fPIE 正是这样做的,前面 main_pie.o 里的读法是:
$ clang -fPIE -O1 -c main.c -o main_pie.o$ llvm-objdump -dr main_pie.o b: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 12 <main+0x12> e: R_X86_64_REX_GOTPCRELX counter-0x4 12: 8b 00 mov (%rax),%eax$ readelf -r main_pie | grep counter000000003ff0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 counter + 0GCC 却走了另一条路。GCC 5 起,x86-64 上 -fPIE 对外部变量也用直接的 PC 相对访问,靠链接器在 PIE 里生成 copy relocation 来兜底:
$ musl-gcc -fPIE -O1 -c main.c -o main_gcc_pie.o$ llvm-objdump -dr main_gcc_pie.o 12: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 18 <main+0x18> 14: R_X86_64_PC32 counter-0x4$ musl-gcc -pie main_gcc_pie.o -L. -lvec -o main_gccpie$ readelf -r main_gccpie | grep counter000000004008 000700000005 R_X86_64_COPY 0000000000004008 counter + 0一条 6 字节的 mov 代替了 movq 加 movl,少一次内存读取,换来的是 copy relocation 的全部副作用。这是 GCC 构建时的配置选项(HAVE_LD_PIE_COPYRELOC),只要链接器支持就默认开启;clang 默认保持 GOT 访问,想要 GCC 的行为可以加 -fdirect-access-external-data(MaskRay 的梳理)。库升级后程序读到错的值,先看可执行文件里有没有 R_X86_64_COPY。
PLT 与惰性绑定
变量必须在第一次被访问前就有正确地址,函数则多了一个选择:调用可以先跳进一小段跳板代码,由跳板决定去哪。一个程序链接了几百个 libc 函数,一次运行实际调用的可能只有几十个,如果启动时把它们全部查一遍,大部分工夫都白费了。SunOS 的设计因此把函数地址的查找推迟到第一次调用时,这叫惰性绑定(lazy binding)。那段跳板就是 PLT(procedure linkage table,过程链接表)。
vec.o 里 bump 调用 helper 用的是 R_X86_64_PLT32。第 4 章讲过,这个类型的意思是"跳到 helper,必要时经过 PLT"。在共享库里 helper 可能被替换,链接器就为它生成了 PLT 项:
$ llvm-objdump -d --no-show-raw-insn libvec.so.10000000000001370 <bump>: 1370: pushq %rax 1371: callq 0x13a0 <helper@plt> 1376: movq 0x1123(%rip), %rcx # 0x24a0 ...
Disassembly of section .plt:
0000000000001390 <.plt>: 1390: pushq 0x211a(%rip) # 0x34b0 1396: jmpq *0x211c(%rip) # 0x34b8 139c: nopl (%rax)
00000000000013a0 <helper@plt>: 13a0: jmpq *0x211a(%rip) # 0x34c0 13a6: pushq $0x0 13ab: jmp 0x1390 <.plt>再看 PLT 读写的那张表 .got.plt(起于 DT_PLTGOT 指出的 0x34a8)在文件里的初始内容。第 3 章提过的 _GLOBAL_OFFSET_TABLE_ 在 x86-64 上就指这个开头:GNU ld 链接同一个 vec.o 时总会定义这个本地符号,值等于 .got.plt 的起点;lld 只在有代码引用它时才定义,位置相同。
$ llvm-objdump -s -j .got.plt libvec.so.1Contents of section .got.plt: 34a8 b0230000 00000000 00000000 00000000 .#.............. 34b8 00000000 00000000 a6130000 00000000 ................按 8 字节一项、小端序读:
0x34a8(第 0 项)是0x23b0,正是.dynamic的地址,也就是符号_DYNAMIC的值。0x34b0(第 1 项)和0x34b8(第 2 项)是 0,留给ld.so在加载时填:第 1 项填一个代表本模块的指针(glibc 里叫link_map,记录模块名、基址、动态段位置等),第 2 项填解析函数的入口。0x34c0(第 3 项)是0x13a6,也就是helper@plt里第二条指令pushq $0x0的地址。这一项就是.rela.plt里那条JUMP_SLOT指向的位置。
现在可以把第一次调用 helper 的过程走一遍(以 glibc 为例,细节见 psABI15 的 Procedure Linkage Table 一节):
bump执行call helper@plt。helper@plt第一条指令jmp *0x34c0,从 GOT 第 3 项取目标。此时里面存的是0x13a6,于是跳到了自己的下一条指令。pushq $0x0把这个函数在.rela.plt里的序号压栈,再跳到公共的.plt头部。.plt头部把 GOT 第 1 项(本模块的link_map)压栈,然后跳到 GOT 第 2 项,也就是 glibc 的_dl_runtime_resolve(定义在 glibc 的 sysdeps/x86_64/dl-trampoline.h,按 CPU 特性有保存寄存器方式不同的几个变体)。_dl_runtime_resolve保存寄存器,调用_dl_fixup:根据模块和序号找到那条JUMP_SLOT重定位,按名字helper查找定义,把查到的地址写进 GOT 第 3 项,然后恢复寄存器,直接跳到helper。
第二次调用时,第 2 步的 jmp *0x34c0 取到的已经是 helper 的真实地址,一次间接跳转就到了。
惰性绑定留下了几个后果。.got.plt 必须一直可写,因为随时可能有函数第一次被调用,而可写的函数指针表正是攻击者最想改写的东西。符号缺失的错误也被推迟了:库升级后少了一个函数,程序照样启动,直到走到那个调用才报 symbol lookup error。每个函数第一次调用还要付一次查找的开销。
于是有了 BIND_NOW:链接时加 -z now,链接器在 .dynamic 里写上 DT_FLAGS 的 BIND_NOW 位和 DT_FLAGS_1 的 NOW 位,ld.so 看到后就在启动时把所有 JUMP_SLOT 都处理完,与 GLOB_DAT 一视同仁。不重新链接也可以在运行时设置环境变量 LD_BIND_NOW=1 达到同样效果。
$ ld.lld -shared -soname libvec.so.1 -z now vec.o -o libvec_now.so$ readelf -d libvec_now.so | grep FLAGS 0x000000000000001e (FLAGS) BIND_NOW 0x000000006ffffffb (FLAGS_1) Flags: NOW.plt 和 JUMP_SLOT 都还在,只是不再有"第一次"。musl 的实现不支持惰性绑定,因此总是在启动时解析所有符号(musl 与 glibc 的功能差异)。更进一步,GCC 和 clang 的 -fno-plt 会让调用方直接 call *foo@GOTPCREL(%rip),连 PLT 跳板也省掉,前提是放弃惰性绑定。
函数地址的身份:canonical PLT
函数也有一个对应的包袱。C 语言要求同一个函数的地址处处相等,main 里取的 &bump 必须等于库里取的 &bump。非 PIC 的可执行文件取函数地址时同样不走 GOT,地址直接编进代码或数据里,链接时就得有一个确定的值,可 bump 在库里。链接器的办法是拿可执行文件里 bump 的 PLT 项充当它的正式地址:
$ cat fp.cint bump(int);int (*fp)(int) = bump;
int main(void) { return fp(1) + (fp == bump);}$ clang -fno-pic -O1 -c fp.c -o fp.o$ musl-gcc -no-pie fp.o -L. -lvec -o fp_nopie$ readelf --dyn-syms fp_nopie | grep bump 2: 0000000000401030 0 FUNC GLOBAL DEFAULT UND bump$ llvm-objdump -d fp_nopie | grep -A1 'bump@plt>:'0000000000401030 <bump@plt>: 401030: ff 25 d2 2f 00 00 jmp *0x2fd2(%rip) # 404008 <bump>.dynsym 里的 bump 节号是 UND,说明它不在这里定义,st_value 却不是 0,而是 0x401030,正是 bump@plt 的地址。这个 PLT 项叫 canonical PLT(规范 PLT 项)。这里的身份规则与前面 PLT 的首次调用协议是两件事:前者保证取地址的一致性,后者决定何时查找实际函数体。ld.so 查找 bump 时如果请求方是要取地址(GLOB_DAT、R_X86_64_64),会认这个非零值,于是库里取的 &bump 也是 0x401030,两边相等;只有 JUMP_SLOT 会跳过它,去找库里的函数体。改用 -fPIE 编译,&bump 走 GOT,.dynsym 里的 bump 值就回到 0。
canonical PLT 和 copy relocation 是同一个历史包袱的两面:非 PIC 代码假设一切地址链接时可知,链接器就在可执行文件里造一个替身,再让整个进程都认这个替身。它们也有同样的弱点。库如果用 -Bsymbolic 把对 bump 的引用绑死在自己的定义上,库里取到的 &bump 就是函数体的真实地址,和主程序的 0x401030 不再相等,函数指针比较会悄悄出错。
RELRO:填完就锁上
既然 BIND_NOW 让所有 GOT 项在启动时就填好了,那启动之后它们就再也不需要被写。如果能在重定位结束后把这些页改成只读,攻击者就没法再改写 GOT。第 1 章提过的 RELRO16(relocation read-only,重定位后只读)做的就是这件事。
链接器的工作是把"只在重定位期间需要写"的节排在一起,放在可写段的开头,再用一个 PT_GNU_RELRO 程序头圈出这块区域;ld.so 处理完重定位后,对这块区域调用 mprotect 改成只读(glibc 里是 elf/dl-reloc.c 的 _dl_protect_relro)。对比 libvec.so.1 默认链接和加了 -z now 之后的段:
$ readelf -lW libvec.so.1 # 默认 LOAD 0x0003b0 0x00000000000023b0 ... 0x0000f8 0x000c50 RW 0x1000 LOAD 0x0004a8 0x00000000000034a8 ... 0x000020 0x000024 RW 0x1000 GNU_RELRO 0x0003b0 0x00000000000023b0 ... 0x0000f8 0x000c50 R 0x1 03 .dynamic .got .relro_padding 04 .got.plt .bss
$ readelf -lW libvec_now.so # -z now LOAD 0x0003b0 0x00000000000023b0 ... 0x000138 0x000c50 RW 0x1000 LOAD 0x0004e8 0x00000000000034e8 ... 0x000000 0x000004 RW 0x1000 GNU_RELRO 0x0003b0 0x00000000000023b0 ... 0x000138 0x000c50 R 0x1 03 .dynamic .got .got.plt .relro_padding 04 .bss(为了排版删掉了中间几列地址。)默认情况下,.dynamic 和 .got 在 RELRO 区域里,.got.plt 被留在外面,因为惰性绑定还要写它,这叫 partial RELRO。加上 -z now 之后,lld 把 .got.plt 也挪进了 RELRO 区域,这叫 full RELRO。lld 在 RELRO 区域末尾还补了一个 .relro_padding,把它补齐到页边界,因为 mprotect 只能以页为单位改权限。
lld 默认开启 -z relro,多数发行版的 GNU ld 也是;full RELRO 还要加 -z now,Fedora 等发行版的加固选项默认带上它,代价是放弃惰性绑定。
到这里,"运行时才知道的地址"已经有了完整的答案:代码里只有 PC 相对寻址,需要填的地址都集中在 GOT 和数据里的指针中,由几种动态重定位描述,启动时(或第一次调用时)由 ld.so 填好,再锁成只读。但这套机制里一直有个前提没有解释:为什么库里调用自己的函数、访问自己的变量,也要绕道 PLT 和 GOT?
符号插入:一条规则和它的账单
对启动时普通的、可被插入的全局符号引用,查找通常从可执行文件开始,再查 LD_PRELOAD 的库和按依赖关系展开的库,采用首个符合要求的定义。库内对自己 default 可见符号的引用也可能参加这次查找。hidden、protected、版本要求,以及 dlopen 创建的局部查找范围会改变适用条件;它不是对所有引用都无条件生效的一张全局表。
之所以这样设计,是为了让动态链接的程序和静态链接的程序行为一致。静态链接时,如果程序自己定义了 malloc,链接器就不会从 libc.a 里拉出 libc 的 malloc(第 3 章讲过静态库按需拉取),libc 内部对 malloc 的调用也会落到程序的版本上。动态链接要保持这个语义,就必须允许可执行文件里的定义覆盖共享库里的同名定义,连共享库内部的调用也要跟着改变。这就是符号插入。
环境变量 LD_PRELOAD 把这种能力交给了用户:它列出的库会在可执行文件之后、其他所有库之前被搜索,所以它定义的同名符号能盖过任何共享库。jemalloc、tcmalloc 这类内存分配器可以不重新编译程序就替换 malloc,各种性能分析和故障注入工具也靠它挂钩函数。拿 libvec.so.1 试一下:
// fake.cint helper(int x) { return 100; }用"链接器给 ld.so 写的说明书"一节那条 ld.lld -shared -soname libvec.so.1 vec.o -o libvec.so.1 链接出的库和 main_pie,直接在本机运行,main_pie 的解释器是 musl:
$ musl-gcc -shared -fPIC fake.c -o libfake.so$ ./main_pie; echo $?; LD_PRELOAD=./libfake.so ./main_pie; echo $?2100bump 调用的是 helper@plt,libvec.so.1 里那条 JUMP_SLOT 解析时先找到了 libfake.so 里的 helper,于是 bump(1) 让 counter 变成 100,main 把它作为退出码返回。
这条规则也有代价。因为任何默认可见的全局符号都可能被替换,编译 -fPIC 代码时,编译器必须假设:同一个库里的函数调用要经过 PLT,访问同一个库里的全局变量要经过 GOT,而且不能把被调用函数的函数体当作已知的,也就不能内联它,不能根据它的实现做过程间优化(跨函数的分析与优化)。GCC 在 -fPIC 下严格遵守这一点(对没有 inline 关键字的函数);clang 的默认做法宽松一些,在 -fPIC 下仍会内联同一文件里默认可见的函数,见 MaskRay 的说明。绝大多数库从来不打算让人替换内部函数,这些开销却一样不少。
有四种办法去掉这部分开销,各自作用的阶段不同。
第一种是在编译时把符号设成 hidden 可见性(第 3 章讲过可见性的三档)。hidden 符号不进入 .dynsym,别的模块看不见它,自然也替换不了它,编译器可以直接用 PC 相对寻址。最常见的做法是整体加 -fvisibility=hidden,再把要导出的接口单独标成 default:
$ cat vec2.cint counter;
__attribute__((noinline)) int helper(int x) { return x * 2; }
__attribute__((visibility("default"))) int bump(int x) { counter += helper(x); return counter;}$ clang -fPIC -O1 -fvisibility=hidden -c vec2.c -o vec2.o$ llvm-objdump -d -r --no-show-raw-insn vec2.o0000000000000010 <bump>: 10: pushq %rax 11: callq 0x16 <bump+0x6> 0000000000000012: R_X86_64_PLT32 helper-0x4 16: addl (%rip), %eax # 0x1c <bump+0xc> 0000000000000018: R_X86_64_PC32 counter-0x4 1c: movl %eax, (%rip) # 0x22 <bump+0x12> 000000000000001e: R_X86_64_PC32 counter-0x4 22: popq %rcx 23: retq$ ld.lld -shared -soname libvec.so.1 vec2.o -o libvec2.so$ readelf -r libvec2.soThere are no relocations in this file.$ readelf --dyn-syms libvec2.soSymbol table '.dynsym' contains 2 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 00000000000012e0 20 FUNC GLOBAL DEFAULT 6 bump$ llvm-objdump -d --no-show-raw-insn libvec2.so00000000000012e0 <bump>: 12e0: pushq %rax 12e1: callq 0x12d0 <helper> 12e6: addl 0x208c(%rip), %eax # 0x3378 <counter> 12ec: movl %eax, 0x2086(%rip) # 0x3378 <counter>counter 的访问变回了和非 PIC 代码一样的直接读写。helper 在目标文件里仍然是 PLT32,但链接器知道 hidden 符号不可能被替换,直接把它解析成 call helper,没有生成 PLT 项。整个库没有一条动态重定位,.dynsym 里只剩 bump。导出的符号少了,ld.so 要查的哈希表也小了,启动更快。
第二种是编译选项 -fno-semantic-interposition(GCC 5 引入),它告诉编译器"我保证不会有人替换这些函数",于是编译器可以自由内联,并让库内调用直接指向本地定义:
$ clang -fPIC -O1 -fno-semantic-interposition -c vec.c -o vec_n.o$ llvm-objdump -d -r --no-show-raw-insn vec_n.o0000000000000010 <bump>: 10: pushq %rax 11: callq 0x0 <helper> 16: movq (%rip), %rcx # 0x1d <bump+0xd> 0000000000000019: R_X86_64_REX_GOTPCRELX counter-0x4对 helper 的调用在汇编阶段就解析掉了,连重定位都没有(编译器给 helper 生成了一个本地别名,call 指向那个别名)。counter 却还在走 GOT,因为变量要给 copy relocation 留余地:主程序可能已经复制了一份 counter,库必须通过 GOT 用那一份。和 hidden 不同,helper 仍然导出,别的模块照样能调用它,只是替换它不再影响库内部。Fedora 32 给 Python 3.8 的 libpython 加上这个选项后,pyperformance 基准提速 5% 到 27%,代价是不能再用 LD_PRELOAD 替换 libpython 内部的符号(Fedora 变更说明)。
第三种发生在链接时:-Bsymbolic 让链接器把库内对全局符号的引用直接绑定到库内的定义上:
$ ld.lld -shared -Bsymbolic vec.o -o libvec_bs.so$ readelf -r libvec_bs.soThere are no relocations in this file.$ llvm-objdump -d --no-show-raw-insn libvec_bs.so0000000000001330 <bump>: 1330: pushq %rax 1331: callq 0x1320 <helper> 1336: leaq 0x208b(%rip), %rcx # 0x33c8 <counter> 133d: addl (%rcx), %eax用的是没改过的 vec.o。helper 的 PLT 没了;counter 那条 movq 从 GOT 取地址的指令,被链接器改写成了 leaq 直接算地址,这就是第 4 章讲的 GOTPCRELX 松弛。代码还是多绕了一步(编译器已经按 GOT 的方式生成了代码),但 GOT 项和动态重定位都省了。危险也在 counter 上:如果某个非 PIC 主程序对它做了 copy relocation,主程序用副本,库用原件,两边就各说各话了。所以更常用的是 -Bsymbolic-functions,只对函数这样做。
第四种是版本脚本,后面"符号版本"一节细讲。先看效果:只导出 bump 和 counter,其余一律设为本地。
$ cat vec.mapVEC_1.0 { global: bump; counter; local: *;};$ ld.lld -shared -soname libvec.so.1 --version-script=vec.map vec.o -o libvec.so.1$ readelf -r libvec.so.1Relocation section '.rela.dyn' at offset 0x2f0 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend000000002468 000200000006 R_X86_64_GLOB_DAT 0000000000003470 counter@@VEC_1.0 + 0$ llvm-objdump -d --no-show-raw-insn libvec.so.10000000000001370 <bump>: 1370: pushq %rax 1371: callq 0x1360 <helper>helper 被降为本地符号,不再可被替换,它的 JUMP_SLOT 和 PLT 项都消失了,调用变成直接 call。counter 仍然导出,所以它的 GLOB_DAT 留着。
四种办法的取舍可以这样理解:hidden 和版本脚本缩小了"谁能被看见",是最彻底的;-fno-semantic-interposition 和 -Bsymbolic 保留导出,只是不再让外来的定义影响库内部。Drepper 在 dsohowto 里花了大量篇幅讨论导出控制,结论是导出接口应当显式、尽量少。
三种打桩:编译期、链接期、加载期
用 LD_PRELOAD 换掉 helper,只是"让调用落到我写的函数上"的一种做法,编译、链接、加载三个时期各有一个下手的位置。这类方法常称为库打桩(library interpositioning)。用同一个需求比较它们:统计一个程序调用了几次 malloc 和 free。
// hello.c#include <stdio.h>#include <stdlib.h>#include <string.h>#include <malloc.h>
char *greet(void); /* 在静态库 libgreet.a 里 */
int main(void) { char *p = malloc(10); /* 自己的代码 */ char *s = strdup("hi"); /* libc 内部调用 malloc */ char *g = greet(); /* 静态库里调用 malloc */ printf("%s %s\n", s, g); free(p); free(s); free(g); return 0;}greet 就是 malloc(8) 后写入 "hey"。三次分配、三次释放,理想的结果是 malloc=3 free=3。三种桩共用一个计数器,退出时用 write 打印到标准错误。构建和运行都在本机 x86-64 Linux:glibc 2.43 用 gcc,musl 1.2.5 用 musl-gcc。源码与命令如下;编译一律不加 -O,否则 GCC 会把“分配后只被释放”的一对 malloc/free 整个删掉。
编译期打桩靠预处理器。在当前目录放一个自己的 malloc.h,内容是 #define malloc(n) mymalloc(n) 和 #define free(p) myfree(p) 两个宏加上函数声明。hello.c 用 -I. 编译,预处理器先在当前目录找到这个头文件,源码里的 malloc(10) 被展开成 mymalloc(10)。mymalloc 写在另一个没有包含这个头文件的文件里,计数后调用 libc 的 malloc。
链接期打桩就是第 3 章讲过的 --wrap:未定义的 malloc 引用改指 __wrap_malloc,__real_malloc 指回原来的 malloc。hello.c 原样编译,链接时加 -Wl,--wrap=malloc,--wrap=free。这里多做一个 -static 的版本,看静态链接时 C 库会不会被波及。
加载期打桩用上一节的 LD_PRELOAD,桩是一个直接定义 malloc 和 free 的共享库。桩自己也要调用 libc 的 malloc,可两者同名。办法是给 dlsym(运行时按名字查符号的函数,后面 dlopen 一小节再提)传特殊句柄 RTLD_NEXT:从调用者所在模块的下一个模块开始,按全局查找顺序找这个名字。
// mymalloc_r.c(free 的写法相同)#define _GNU_SOURCE#include <dlfcn.h>#include "count.h"static void *(*real_malloc)(size_t);void *malloc(size_t n) { if (!real_malloc) real_malloc = dlsym(RTLD_NEXT, "malloc"); n_malloc++; return real_malloc(n);}在本机 glibc 下运行四个程序(标准输出丢掉,只看计数):
$ ./hello_c >/dev/null # 编译期malloc=1 free=3$ ./hello_l >/dev/null # 链接期,动态链接malloc=2 free=3$ ./hello_ls >/dev/null # 链接期,静态链接malloc=10 free=5$ LD_PRELOAD=./mymalloc.so ./hello_r >/dev/null # 加载期malloc=4 free=3本机 musl 下前两行完全相同,后两行都是 malloc=3 free=3。
宏只改了包含这个头文件的源码:strdup 和 greet 都是早已编译好的机器码,看不见宏,三次 free 却都写在 hello.c 里,分配和释放对不上。
--wrap 改的是这次链接输入里的未定义引用:从静态库拉进来的 greet.o 被改写,所以是 2;strdup 在 libc.so.6 里,它对 malloc 的引用在构建 libc 时就已链好。
静态链接时 libc.a 的成员也是普通目标文件,strdup 一起被改写,musl 正好是 3。glibc 多出的 7 次,用 addr2line(把地址翻译成函数名的工具)查返回地址:4096 字节那次来自 _IO_file_doallocate,是 stdout 的缓冲区;其余 6 次来自 _dl_init_paths(两次)、_dl_get_origin、_dl_find_object_init 和 btree_allocate_node(两次),静态的 glibc 程序为了支持 dlopen 也带着一份精简的动态加载器。musl 的 stdout 缓冲区是静态数组。
LD_PRELOAD 改的是 ld.so 的全局查找,libc 内部的引用同样要查,所以 glibc 是 3 加上 stdout 缓冲区。
加载期的桩有个边界:dlsym 的内部实现可能再次分配内存,因此在 malloc 包装函数里无保护地调用 dlsym,不能作为通用生产实现。配套 probe.c 额外拦截 calloc,在查找期间重入时打印标记。本机 glibc 2.43 与 musl 1.2.5 的这组输入均没有打印重入标记,退出状态都是 0;这只说明本次路径没有触发,不能推出所有版本、线程状态或符号查找都不会分配。需要一般性替代分配器时,应处理引导阶段和重入,并成套实现分配与释放接口。
三种做法的差别在于改的是谁的引用。宏改源码,要能重新编译,静态库和 libc 内部都拦不到。--wrap 改链接输入,要能重新链接,自己的目标文件和静态库都算,静态链接时连 libc.a 也算,但改不到共享库内部,也改不到同一个目标文件里定义并调用的函数(第 3 章)。LD_PRELOAD 改运行时的查找,不用重新编译链接,连 libc 内部都拦得到,前提是程序动态链接(把 hello.c 静态链接后再预加载,计数行不会出现),而且调用要经过可插入的符号:上一节四种办法处理过的库内调用拦不到,setuid 程序(以文件所有者的权限执行的程序)也会忽略它。
符号版本:同一个名字的几代实现
前面版本脚本里的 VEC_1.0 不只是一个分组名。看看链接出来的库和依赖它的程序:
$ readelf --dyn-syms libvec.so.1 1: 0000000000001370 19 FUNC GLOBAL DEFAULT 9 bump@@VEC_1.0 2: 0000000000003470 4 OBJECT GLOBAL DEFAULT 13 counter@@VEC_1.0$ readelf -V libvec.so.1Version symbols section '.gnu.version' contains 3 entries: Addr: 0x0000000000000248 Offset: 0x00000248 Link: 1 (.dynsym) 000: 0 (*local*) 2 (VEC_1.0) 2 (VEC_1.0)
Version definition section '.gnu.version_d' contains 2 entries: Addr: 0x0000000000000250 Offset: 0x00000250 Link: 6 (.dynstr) 000000: Rev: 1 Flags: BASE Index: 1 Cnt: 1 Name: libvec.so.1 0x001c: Rev: 1 Flags: none Index: 2 Cnt: 1 Name: VEC_1.0
$ musl-gcc -pie main_pie.o -L. -lvec -o main_ver$ readelf -V main_verVersion needs section '.gnu.version_r' contains 1 entry: Addr: 0x0000000000000400 Offset: 0x00000400 Link: 4 (.dynstr) 000000: Version: 1 File: libvec.so.1 Cnt: 1 0x0010: Name: VEC_1.0 Flags: none Version: 2$ readelf --dyn-syms main_ver | grep -E 'bump|counter' 1: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND counter@VEC_1.0 (2) 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND bump@VEC_1.0 (2)这里有三个新节。.gnu.version 和 .dynsym 一一对应,每个符号一个 16 位的版本号索引。.gnu.version_d(version definition)列出这个库定义了哪些版本,第一项 BASE 是库自身。.gnu.version_r(version needs)出现在使用者那边:main_ver 声明它需要 libvec.so.1 提供 VEC_1.0 版本,它引用的 bump 和 counter 也都记下了版本号 2,也就是 VEC_1.0。
@@ 和 @ 的区别是:bump@@VEC_1.0 表示这是 bump 的默认版本,新链接的程序会绑定到它;@ 是非默认版本,只服务于以前绑定过它的旧程序。运行时,ld.so 按程序记录的"名字 + 版本"去找定义。Solaris 的链接器本来就把版本挂在每个符号上,GNU 在此基础上扩展的是允许同名符号的多个版本在一个库里并存,靠的就是 @/@@ 和汇编伪指令 .symver(Drepper: ELF Symbol Versioning):
$ cat sv.cint bump_v1(int x) { return x; }int bump_v2(int x) { return x * 2; }__asm__(".symver bump_v1, bump@VEC_1.0");__asm__(".symver bump_v2, bump@@VEC_2.0");$ cat sv.mapVEC_1.0 { global: bump; local: *; };VEC_2.0 { global: bump; } VEC_1.0;$ clang -fPIC -O1 -c sv.c -o sv.o$ ld.lld -shared --version-script=sv.map sv.o -o libsv.so$ readelf --dyn-syms libsv.so | grep bump 1: 0000000000001320 3 FUNC GLOBAL DEFAULT 8 bump@VEC_1.0 2: 0000000000001330 4 FUNC GLOBAL DEFAULT 8 bump@@VEC_2.0一个库里有了两个 bump。早先链接、记下 bump@VEC_1.0 的程序拿到 bump_v1,新链接的程序绑定默认版本 VEC_2.0,拿到 bump_v2,库的 SONAME 不用变。版本脚本里 VEC_2.0 { ... } VEC_1.0; 末尾的名字表示 VEC_2.0 继承自 VEC_1.0。
glibc 是最重的用户。一个经典的例子是 memcpy:2010 年前后 glibc 换了一个更快的 memcpy 实现,源和目的区间重叠时的行为变了,一些错误地用 memcpy 搬运重叠内存的程序(包括当时的 Flash 插件)开始出错(LWN)。最后 glibc 的处理是:新实现挂在 memcpy@@GLIBC_2.14,旧程序绑定的 memcpy@GLIBC_2.2.5 从 2.14 起改配 memmove 的实现(glibc 2.14 起的写法是 compat_symbol (libc, memmove, memcpy, GLIBC_2_2_5)),重叠区间也能搬对。第 13 章会在 x86-64 的 libc 上把这一对版本拆开看。
反方向的问题同样常见。glibc 2.34(2021 年 8 月)把 libpthread、libdl 等库并进了 libc.so.6,为此给许多已有函数加了新的 GLIBC_2.34 版本;为了加固启动代码,连每个程序启动都要调用的 __libc_start_main 也有了 GLIBC_2.34 版本。结果是在 glibc 2.34 上构建的程序,到 2.33 的系统上就启动不了(Florian Weimer, Why glibc 2.34 removed libpthread)。报错形如下面这行(示意,同一格式的真实报错见"出了问题怎么看"一节):
./main: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./main)这也是为什么发布 Linux 二进制时,通常要在你打算支持的最老的 glibc 上构建:版本需求是链接时按当时看到的库记下的,只会向上兼容。musl 不使用符号版本,因此由 musl 链接得到的 PIE 通常没有 .gnu.version_r。
ld.so 的一次启动
链接器的产物到这里都看过了,现在换到 ld.so 这一侧,按时间顺序把一次启动走一遍。以 glibc 为准,细节见 ld.so(8)。
内核这一侧的事第 6 章已经走过:映射可执行文件的 PT_LOAD 段,按 PT_INTERP 把 ld.so 也映射进来,在栈上放好参数、环境变量和辅助向量,然后跳到 ld.so 的入口。对 ld.so 来说,辅助向量里最要紧的是 AT_PHDR、AT_ENTRY 和 AT_BASE:主程序的程序头在哪里、主程序的入口在哪里、自己被放在了哪里。
ld.so 自己也是一个共享库,第一件事是给自己做重定位,和第 6 章 rcrt1.o 里那段 _dlstart_c 是同一件事(musl 的 rcrt1.o 本来就是借用了 ld.so 的这段代码)。
接着,它从辅助向量找到可执行文件的程序头,读出 PT_DYNAMIC,开始装载依赖。先处理 LD_PRELOAD,再从 DT_NEEDED 出发,按广度优先逐个装载依赖库,每装载一个,又把它的 DT_NEEDED 排进队列,已经识别为同一个加载对象的库不重复装;名称与别名查找之外,实现还会比较文件身份,不能把 SONAME 理解成全进程唯一的文件 ID。名字里不带斜杠时,按这个顺序搜索:
- 发起加载的那个模块的
DT_RPATH,前提是它没有DT_RUNPATH; - 环境变量
LD_LIBRARY_PATH; - 发起加载的模块的
DT_RUNPATH; /etc/ld.so.cache,由ldconfig扫描系统库目录生成的缓存;- 默认目录,如
/lib、/usr/lib(64 位系统上常是/lib64等)。
RPATH 排在 LD_LIBRARY_PATH 之前,用户无法覆盖,还对间接依赖起作用;RUNPATH 排在后面,只管该模块的直接依赖,这就是它取代 RPATH 的原因。对 setuid 程序,ld.so 进入安全执行模式,忽略 LD_LIBRARY_PATH 和大部分 LD_PRELOAD,否则任何用户都能让一个 root 权限的程序加载自己的代码。
所有模块装齐后,ld.so 处理动态重定位,顺序是依赖的逆序:被依赖的库先做,主程序最后(elf/rtld.c 从 l_initfini 列表末尾往前走,libc.so.6 还会被单独提到最前面)。按这个顺序,主程序的 COPY 执行时,库里那个变量的初值已经重定位好了,比如一个指针变量的初值要先经过 RELATIVE 修正,复制过来的才是对的值。每条重定位里,RELATIVE 只需基址,GLOB_DAT、R_X86_64_64 和 COPY 要按全局查找顺序查符号并检查版本,IRELATIVE 要调用解析函数,JUMP_SLOT 在惰性绑定时只是把 GOT 项加上基址,在 BIND_NOW 时立即解析。每个模块重定位完,对它的 PT_GNU_RELRO 区域调用 mprotect。
然后是初始化函数。ld.so 按依赖顺序调用各共享库的初始化函数,被依赖者在前,每个模块先调 DT_INIT 再调 DT_INIT_ARRAY 里的函数(elf/dl-init.c 的 _dl_init)。可执行文件在这里被跳过,call_init 遇到名字为空的主程序直接返回。接着 ld.so 跳到 AT_ENTRY,也就是程序的 _start;_start 调用 libc 的启动函数 __libc_start_main,由它执行可执行文件自己的 DT_INIT 和 DT_INIT_ARRAY(csu/libc-start.c 的 call_init),最后调用 main。程序退出时,各模块的 DT_FINI_ARRAY 和 DT_FINI 按相反的顺序执行。
dlopen 与 dlsym
上面说的都发生在 main 之前。插件系统、按需加载的驱动、脚本语言的 C 扩展,则需要在运行中途加载库,这就是 dlopen:
#include <dlfcn.h>#include <stdio.h>
int main(void) { void *h = dlopen("libvec.so.1", RTLD_NOW | RTLD_LOCAL); if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; } int (*bump)(int) = (int (*)(int))dlsym(h, "bump"); printf("%d\n", bump(21)); dlclose(h); return 0;}dlopen 走的是同一套流程:搜索路径、装载依赖、重定位、RELRO、可执行栈检查、初始化函数。RTLD_NOW 和 RTLD_LAZY 对应立即绑定和惰性绑定;RTLD_LOCAL(默认)表示这个库的符号不加入全局查找范围,它及其依赖构成的局部查找范围仍能解析相关引用,也可以通过返回的句柄用 dlsym 查找,RTLD_GLOBAL 则让后来加载的库也能看到它的符号。dlsym 按名字找符号,找的是默认版本,需要指定版本时用 GNU 扩展 dlvsym。dlclose 递减引用计数,但一次成功返回不保证对象立即卸载;其他依赖、RTLD_NODELETE 等条件也会阻止卸载(dlopen(3))。glibc 2.34 之后这些函数都在 libc.so.6 里,链接时不再需要 -ldl。
共享库的栈权限要求
每映射一个模块,ld.so 都会看一次它的 PT_GNU_STACK(glibc 里是 elf/dl-load.c 的 _dl_map_object_from_fd)。主程序的栈权限在 execve 时已经由内核按可执行文件的 PT_GNU_STACK 定好,之后装进来的库如果要求可执行栈而当前栈不可执行,ld.so 就得想办法。第 1 章讲过,链接器根据输入文件里有没有 .note.GNU-stack 节来决定这个程序头。用一个没有声明的手写汇编文件试一下:
$ cat fast.s .text .globl fast_add .type fast_add,@functionfast_add: leal (%rdi,%rsi),%eax ret$ as fast.s -o fast.o$ ld -shared fast.o vec.o -o libmix.so$ readelf -lW libmix.so | grep GNU_STACK GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10$ ld.lld -shared fast.o vec.o -o libmix_lld.so$ readelf -lW libmix_lld.so | grep GNU_STACK GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0vec.o 带有栈声明,fast.o 没有。本机 GNU ld 2.46 与 LLD17 21.1.8 对混合输入均输出 RW;缺少声明时的默认行为受链接器构建配置和目标影响,不是固定 ABI 规则。只链接 fast.o 时,本机 GNU ld 没有输出 PT_GNU_STACK。因此手写汇编应主动声明需求,需要可执行栈则显式使用 -z execstack,不要依赖缺失声明触发的兼容路径。
在这里讨论的默认策略下,程序启动阶段装进来的库要求可执行栈时,glibc 可以在启动期间调整栈权限。难的是运行到一半通过 dlopen 加载的库:以前的 glibc 会把进程里所有线程的栈都改成可执行。2025 年 1 月 30 日发布的 glibc 2.41(libc-announce)不再这么做,dlopen 一个需要可执行栈的库(无论是显式标了 RWE,还是缺少 PT_GNU_STACK 而平台默认可执行),而当前栈又不可执行时,加载直接失败,报错是 "cannot enable executable stack as shared object requires"。第 1 章结尾讲过的那次各种软件打不开,就出在这里。
兼容旧库时,可以在启动程序前设置 GLIBC_TUNABLES;这是 glibc 通过环境变量调整内部参数的机制。glibc.rtld.execstack 的取值 0 禁止可执行栈,1 按程序及启动依赖的要求协商权限,2 则在进程启动时强制启用可执行栈。因而 GLIBC_TUNABLES=glibc.rtld.execstack=2 能让随后通过 dlopen 加载的旧库遇到已经可执行的栈。它没有恢复运行中为新库修改所有线程栈权限的旧行为;兼容的代价是从启动起就让栈可执行。取值与加载边界见 GNU C Library 的动态链接参数手册。
出了问题怎么看
动态链接的故障大多发生在用户的机器上,离构建现场很远,所以先要学会从二进制本身读出它的依赖和期望。前面用过的命令就是基本工具:readelf -d 看 .dynamic,readelf -r 看动态重定位,readelf -V 看版本需求,readelf --dyn-syms 看导入导出。llvm-objdump 也能做同样的事:
$ llvm-objdump -p main_pie | grep -E 'NEEDED|RPATH|RUNPATH' NEEDED libvec.so.1 NEEDED libc.so RUNPATH $ORIGIN$ llvm-objdump -R main_pie | grep RELATIVE | head -20000000000003dc8 R_X86_64_RELATIVE *ABS*+0x11500000000000003dd0 R_X86_64_RELATIVE *ABS*+0x1110llvm-objdump -R 显示动态重定位,是观察地址修补的一条入口;库查找、初始化、权限处理等工作并不在这张表里。这两条 RELATIVE 落在 0x3dc8 和 0x3dd0,正是 .dynamic 里 INIT_ARRAY 和 FINI_ARRAY 的地址:初始化函数指针也是需要按基址修正的绝对地址。
这些命令只读文件。想看依赖在本机解析到哪里,用 ldd。下面使用本机 glibc 的 main,源码还是 vec.c 和 main.c,构建命令见练习一;RUNPATH 为 $ORIGIN:
$ ldd ./main linux-vdso.so.1 (0x00007d997b25c000) libvec.so.1 => <work>/ex1/libvec.so.1 (0x00007d997b24a000) libc.so.6 => /usr/lib/x86_64-linux-gnu/libc.so.6 (0x00007d997b000000) /lib64/ld-linux-x86-64.so.2 (0x00007d997b25e000)(linux-vdso.so.1 是内核映射进每个进程的一小段代码,不对应磁盘文件。)要注意的是,glibc 的 ldd 是一个脚本,它设置环境变量 LD_TRACE_LOADED_OBJECTS=1 后真的让 ld.so 去"加载"这个程序,只是在跳到入口前停下。man 手册因此提醒不要对不受信任的可执行文件使用 ldd,替代方案是 llvm-objdump -p program | grep NEEDED(只能看到直接依赖)(ldd(1))。
ld.so 自己也能讲述它在做什么。glibc 的 ld.so 支持环境变量 LD_DEBUG,取值有 libs(搜索库的过程)、files(每个模块何时被装载、初始化)、bindings(每个符号绑定到了哪里)、versions、reloc、symbols、all 等,LD_DEBUG=help 会列出全部选项,LD_DEBUG_OUTPUT 可以把输出写到文件。这是 glibc 的功能,musl 的 ld.so 不支持 LD_DEBUG。还是上面那个本机 main(开头的数字是进程号):
$ LD_DEBUG=libs ./main 1592030: find library=libvec.so.1 [0]; searching 1592030: search path=<work>/ex1/glibc-hwcaps/x86-64-v3:<work>/ex1/glibc-hwcaps/x86-64-v2:<work>/ex1 (RUNPATH from file ./main) 1592030: trying file=<work>/ex1/glibc-hwcaps/x86-64-v3/libvec.so.1 1592030: (no such file) 1592030: trying file=<work>/ex1/glibc-hwcaps/x86-64-v2/libvec.so.1 1592030: (no such file) 1592030: trying file=<work>/ex1/libvec.so.1 1592030: 1592030: find library=libc.so.6 [0]; searching 1592030: search path=<work>/ex1 (RUNPATH from file ./main) 1592030: trying file=<work>/ex1/libc.so.6 1592030: (no such file) 1592030: search cache=/etc/ld.so.cache 1592030: trying file=/usr/lib/x86_64-linux-gnu/libc.so.6 1592030: 1592030: 1592030: calling init: /lib64/ld-linux-x86-64.so.2 1592030: 1592030: 1592030: calling init: /usr/lib/x86_64-linux-gnu/libc.so.6 1592030: 1592030: 1592030: calling init: <work>/ex1/libvec.so.1 1592030: 1592030: 1592030: initialize program: ./main 1592030: 1592030: 1592030: transferring control: ./main 1592030: 1592030: 1592030: calling fini: [0] 1592030: 1592030: 1592030: calling fini: <work>/ex1/libvec.so.1 [0]libs 的输出直接对应搜索顺序:先试 RUNPATH,没找到再查缓存。当前 glibc 根据 CPU 能力先尝试 glibc-hwcaps/x86-64-v3、x86-64-v2 子目录,再回到普通目录。初始化时依赖库先执行,退出时析构次序相反。bindings 可观察符号究竟绑定到哪个模块,适合检查符号插入和预加载;versions 则用于检查版本需求。
最常见的三类启动错误,基本都能对上本章的某个机制。在本机分别制造出来:把 libvec.so.1 挪走;把库换成一个不定义 bump 的版本;让程序依赖一个库里没有的版本节点(程序按定义了 VEC_2.0 的库链接,运行时换回只有 VEC_1.0 的库):
./main: error while loading shared libraries: libvec.so.1: cannot open shared object file: No such file or directory./main: symbol lookup error: ./main: undefined symbol: bump./main_ver: <work>/ex1/libvec.so.1: version `VEC_2.0' not found (required by ./main_ver)第一种是搜索路径问题,看 DT_NEEDED、RUNPATH 和 LD_DEBUG=libs;第二种是库找到了但缺符号,在惰性绑定下它拖到第一次调用 bump 时才出现;第三种是版本需求没有满足,在旧系统上运行新系统编译的程序时常见的 version `GLIBC_2.34' not found 是同一条报错。前两种的退出状态是 127,第三种是 1。
调用建立以后,怎样沿调用链返回
回头看本章的分工:链接器在链接时把能算的全算完,PC 相对的距离直接填好,hidden 的、被 -Bsymbolic 或版本脚本绑定的引用直接解析,能松弛的 GOT 访问改成直接访问;剩下要等运行时才知道的地址,集中写进 GOT 和少数指针,用 RELATIVE、GLOB_DAT、JUMP_SLOT、COPY 等几类动态重定位描述清楚,再用 .dynamic、.dynsym、版本节和几个程序头,告诉 ld.so 依赖谁、去哪找、哪些区域填完要锁上、栈要不要可执行。ld.so 照着这份说明书装载、查找、填写、锁定,最后把控制权交给程序。
跨模块调用已经能够到达目标,但运行时有时还需要沿调用链反向恢复状态。假设 helper 换成一段 C++ 代码,在深处抛出了一个异常,bump 里没有 catch,main 里有。异常要从 libvec.so.1 的 helper 退回 bump,再跨过库的边界退回可执行文件里的 main。运行时手里只有一个出错时的指令地址和一堆寄存器,栈上也没有一份写明"每一层从哪来"的清单,编译器为了性能甚至省掉了帧指针。它怎么知道这条指令属于哪个模块的哪个函数、这个函数的返回地址存在栈上哪里、要恢复哪些寄存器,然后一层层往回走?前面 readelf -l 输出里一直有两个没解释的东西:每个模块里都有的 .eh_frame 节,和可执行文件里的 PT_GNU_EH_FRAME 程序头。答案就在它们里面。
练习
练习一,观察。在本机新目录放入 vec.c 与 main.c,显式选择惰性绑定。Ubuntu 的 GCC 默认可能传 -z now,若不覆盖,观察不到第一次调用时才绑定的过程:
gcc -O1 -fPIC -shared -Wl,-soname,libvec.so.1 vec.c -o libvec.so.1ln -sf libvec.so.1 libvec.sogcc -O1 main.c -L. -lvec -Wl,-z,lazy -Wl,-rpath,'$ORIGIN' -o mainreadelf -d mainreadelf -rW mainLD_DEBUG=bindings ./mainLD_BIND_NOW=1 LD_DEBUG=bindings ./main记录 NEEDED、RUNPATH、counter 的重定位类型,以及 bump、helper 相对于 transferring control 的绑定时机。
练习二,预测。下面的 cnt.c 用 本机 GCC 15.2(gcc)以 -fPIC -O1 编译,再用 ld.lld -shared 链接成 libcnt.so。先预测 RELATIVE、GLOB_DAT、JUMP_SLOT 和带符号的 R_X86_64_64 各几条、带的是哪些符号,再用 readelf -r 验证。然后加版本脚本 { global: use; local: *; }; 重新链接,再预测一次。换成 clang 21(clang)编译,有一处计数不同,是哪一处?
extern int ext_var; /* 别的模块定义 */extern int ext_fn(int);static int priv[8];int pub_var = 1;static int sfn(int x) { return x; }__attribute__((visibility("hidden"))) int hid_fn(int x) { return x + 2; }int pub_fn(int x) { return x + 1; }
int *p_priv = &priv[3];int *p_pub = &pub_var;int *p_ext = &ext_var;const char *names[] = { "red", "green", "blue" };int (*tbl[])(int) = { sfn, hid_fn, pub_fn, ext_fn };
int use(int x) { return ext_fn(x) + pub_fn(x) + ext_var + pub_var; }练习三,改坏。有人想用一个"更快的 malloc"加速程序,它从一块静态内存里顺序切分,从不回收:
// fastmalloc.c#include <stddef.h>static char arena[1 << 20];static size_t used;void *malloc(size_t n) { void *p = arena + used; used += (n + 15) & ~(size_t)15; return p;}把它编译成 libfast.so,预加载给一个只做 strdup、puts、free 的小程序。glibc 下程序打印 free(): invalid pointer,状态 134;musl 下状态 139。先预测崩在哪个函数里,再用 LD_DEBUG=bindings 或 gdb 找原因,最后修好它。
练习四,观察静态 PIE 中仍然存在的运行时重定位。使用本机 musl 的静态 PIE 启动文件与库,以 musl-gcc -static-pie -fPIE -O1 sp.c -o sp 链接下面的程序,沿用第 6 章的工具链;前文已经列出完整构建命令。先根据 cursor 的初值预测它所需的重定位,再检查 .rela.dyn:除了 cursor,哪些位置也要随加载地址改变?为什么动态表里有重定位信息,却没有 PT_INTERP 和 DT_NEEDED?按上面的命令检查,运行结果应为 30。
static int table[4] = { 10, 20, 30, 40 };int *cursor = &table[2];int main(void) { return *cursor; }答案
练习一
以下为本机输出(进程号和临时目录每次不同):
0x0000000000000001 (NEEDED) Shared library: [libvec.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN] [24] .got PROGBITS 0000000000003fc0 002fc0 000028 08 WA 0 0 8 [25] .got.plt PROGBITS 0000000000003fe8 002fe8 000020 08 WA 0 0 8 [26] .data PROGBITS 0000000000004008 003008 000010 00 WA 0 0 8
Relocation section '.rela.dyn' at offset 0x568 contains 9 entries: Offset Info Type Sym. Value Sym. Name + Addend000000003db0 000000000008 R_X86_64_RELATIVE 1140000000003db8 000000000008 R_X86_64_RELATIVE 1100000000004010 000000000008 R_X86_64_RELATIVE 4010000000003fc0 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0000000003fc8 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTM[...] + 0000000003fd0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0000000003fd8 000400000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCl[...] + 0000000003fe0 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0000000004018 000700000005 R_X86_64_COPY 0000000000004018 counter + 0
Relocation section '.rela.plt' at offset 0x640 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend000000004000 000500000007 R_X86_64_JUMP_SLO 0000000000000000 bump + 0 1591939: binding file <work>/ex1/libvec.so.1 [0] to ./main [0]: normal symbol `counter' 1591939: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `counter' 1591939: transferring control: ./main 1591939: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `bump' 1591939: binding file <work>/ex1/libvec.so.1 [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `helper' 1591941: binding file <work>/ex1/libvec.so.1 [0] to ./main [0]: normal symbol `counter' 1591941: binding file <work>/ex1/libvec.so.1 [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `helper' 1591941: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `counter' 1591941: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `bump' 1591941: transferring control: ./mainNEEDED 是 libvec.so.1 和 libc.so.6,RUNPATH 是 $ORIGIN。本机 x86-64 GCC 的主程序为外部变量生成直接 PC 相对访问,因此 counter 使用 COPY 重定位,在主程序的 0x4018 预留空间;库自己的引用也绑定到这份副本。这与通过 GOT 间接访问的输入不同,必须查看实际机器码和重定位,不能仅凭 PIE 猜测。
普通运行时,bump 与 helper 在 transferring control 之后绑定;LD_BIND_NOW=1 时二者都提前。库内默认可见的 helper 也通过 PLT 查找,找到自身定义不等于绕过符号查找。主程序的 JUMP_SLOT 在 0x4000,位于 .got.plt;0x4010 的 RELATIVE 对应 .data 的 __dso_handle。这两个位置不能混为一谈。
练习二
$ musl-gcc -fPIC -O1 -c cnt.c -o cnt_gcc.o$ ld.lld -shared cnt_gcc.o -o libcnt_gcc.so$ readelf -r libcnt_gcc.so | awk '/R_X86/{print $3, $5}' | sort | uniq -c 1 R_X86_64_64 ext_fn 1 R_X86_64_64 ext_var 1 R_X86_64_64 pub_fn 1 R_X86_64_64 pub_var 1 R_X86_64_GLOB_DAT ext_var 1 R_X86_64_GLOB_DAT pub_var 1 R_X86_64_JUMP_SLO ext_fn 1 R_X86_64_JUMP_SLO pub_fn 6 R_X86_64_RELATIVE$ printf '{ global: use; local: *; };\n' > use.map$ ld.lld -shared --version-script=use.map cnt_gcc.o -o libcnt_v.so$ readelf -r libcnt_v.so | awk '/R_X86/{print $3, $5}' | sort | uniq -c 1 R_X86_64_64 ext_fn 1 R_X86_64_64 ext_var 1 R_X86_64_GLOB_DAT ext_var 1 R_X86_64_JUMP_SLO ext_fn 8 R_X86_64_RELATIVE数法是逐个看每处地址引用,问一句"它的目标能不能被替换"。
数据里的指针:p_priv 指向 static 数组,names 的三项指向字符串常量,tbl 里的 sfn(static)和 hid_fn(hidden),这 6 处都只能是本库里的那一份,写成 RELATIVE。p_pub、p_ext,以及 tbl 里的 pub_fn、ext_fn,目标默认可见或在别的模块,各留一条带符号的 R_X86_64_64,共 4 条。
代码里的引用:use 读 ext_var 和 pub_var 走 GOT,各一条 GLOB_DAT;调用 ext_fn 和 pub_fn 走 PLT,各一条 JUMP_SLOT。同一个符号既被数据指针引用、又被 GOT 引用时,两种重定位各算各的,pub_var 和 ext_var 都出现了两次。
加上版本脚本后,pub_var 和 pub_fn 变成本地符号,不可能再被替换:它们的两条 R_X86_64_64 变成 RELATIVE(6 + 2 = 8),pub_var 的 GOT 访问被链接器松弛成直接访问,GLOB_DAT 消失,pub_fn 的调用直接指向函数体,JUMP_SLOT 也消失。只剩在外部的 ext_var 和 ext_fn。
换成 clang(clang -fPIC -O1),JUMP_SLOT 只有 ext_fn 一条。clang 把默认可见的 pub_fn 内联进了 use,反汇编里 pub_fn(x) 只剩加 x 再加 1 两条指令(addl %ebx, %eax 和 incl %eax),这就是"符号插入"一节说的 clang 默认比 GCC 宽松。其余计数两者相同。用 GNU ld 链接 cnt_gcc.o,计数和 ld.lld 完全相同。Drepper 附录 A 关心的是同一件事:带符号的重定位要查符号表,RELATIVE 不用,导出越少,前者越少。
练习三
崩溃在 free 里,不在 malloc 里。LD_DEBUG=bindings 一眼就能看出来:
$ LD_PRELOAD=./ex3-gcc/libfast.so LD_DEBUG=bindings ./ex3-gcc/app 2>&1 | grep -E "symbol .(malloc|free|strdup)."1593718: binding file /usr/lib/x86_64-linux-gnu/libc.so.6 [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `free' [GLIBC_2.2.5] 1593718: binding file /usr/lib/x86_64-linux-gnu/libc.so.6 [0] to ./ex3-gcc/libfast.so [0]: normal symbol `malloc' [GLIBC_2.2.5] 1593718: binding file ./ex3-gcc/app [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `free' [GLIBC_2.2.5] 1593718: binding file ./ex3-gcc/app [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `strdup' [GLIBC_2.2.5] 1593718: binding file ./ex3-gcc/app [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `free' [GLIBC_2.2.5] 1593718: binding file ./ex3-gcc/app [0] to ./ex3-gcc/libfast.so [0]: normal symbol `malloc' [GLIBC_2.2.5]malloc 被替换成 libfast.so 的版本,libc 的 strdup 也用它;free 却仍是 libc 的实现。glibc 2.43 实测在检查非法堆指针时 abort,shell 状态 134;musl 1.2.5 实测 SIGSEGV,状态 139。程序是否已经写出 hello 还受 stdio 缓冲、输出目标与 libc 实现影响,判定依据应是退出状态与调用路径,不是“有没有看到一行字”。
修法是把这一族函数一起替换。glibc 手册的 Replacing malloc 一节(源码 manual/memory.texi)说,自定义的 malloc 至少要提供 malloc、free、calloc、realloc,通用的替代实现还应提供 aligned_alloc、malloc_usable_size、memalign、posix_memalign 等函数(glibc 手册)。给 fastmalloc.c 补上什么都不做的 free、用 memset 清零的 calloc 和复制旧内容的 realloc 后,两个本机 C 库版本都打印 hello,状态 0。这和正文"三种打桩"一节是同一件事:LD_PRELOAD 按名字逐个替换,替换了一半,进程里就同时活着两套互不认识的分配器。
练习四
下面用本机 musl 的静态 PIE 作为参考。所有启动文件与 libc 都由目标系统的 musl-tools 提供;命令行只用于展示如何检查静态 PIE 的动态重定位:
$ musl-gcc -static-pie -fPIE -O1 sp.c -o sp$ readelf -rW spRelocation section '.rela.dyn' at offset 0x208 contains 5 entries:0000000000003e48 0000000000000008 R_X86_64_RELATIVE 14c00000000000003e50 0000000000000008 R_X86_64_RELATIVE 14800000000000003ff0 0000000000000008 R_X86_64_RELATIVE 3e580000000000004000 0000000000000008 R_X86_64_RELATIVE 40000000000000004020 0000000000000008 R_X86_64_RELATIVE 4018$ nm sp | grep -wE 'cursor|table'0000000000004020 D cursor0000000000004010 d table$ ./sp; echo exit=$?exit=30五条重定位对应初始化数组、析构数组、GOT、__dso_handle 与 cursor。cursor 的加数 0x4018 是 table 的 0x4010 加 8,正好指向第三个 int。加载偏移为 B 时,启动代码向 B+0x4020 写入 B+0x4018,随后 main 才能通过 cursor 读到 30。链接时能确定的是目标在映像中的位置;运行时补上的只是 B,不需要按名字查找另一个库中的定义。
.dynamic 在这里描述重定位表,而不是外部库依赖。rcrt1.o 通过 _DYNAMIC 找到这些描述,在调用普通 C 启动代码前完成自重定位;内核只建立映射,并不执行这张表。初始化和析构数组中的函数指针也有同样的地址需求,所以源代码里只有一个 cursor,产物仍可能包含多条 RELATIVE。记录条数取决于启动文件与运行库的内容,解释每个写入位置比记住“五条”更有用。
参考
- Ulrich Drepper:《How To Write Shared Libraries》,附录 A:重定位统计脚本。本章先预测再统计的练习借用了这个分析思路。
- CMU 15-213:Linking,第 46–52 页:在编译、链接和运行时替换
malloc、free的例子。本章只替换其中一方,用来观察分配与释放不匹配的后果;CS 第 3 版练习 7.13 也讨论了运行时函数替换。
附录:术语与工具
-
PIE — PIE(position-independent executable)是可以在不同加载基址运行的可执行文件。生成 PIE 需要编译与链接选项配合;static-PIE 还需要自身的启动路径完成必要重定位。 官方文档。 ↩
-
musl — musl 是 Linux 的一种 C 标准库实现,提供
printf等库函数及运行时支持。本系列在需要分析或链接较小的静态运行库时使用它;普通 Linux 服务器不一定预装 musl。 官方文档。 ↩ -
glibc — glibc(GNU C Library)是许多 Linux 发行版默认使用的 C 库。库的启动文件、共享库和动态链接器共同参与程序构建与运行。 官方文档。 ↩
-
GCC — GCC(GNU Compiler Collection)是一组语言编译器。命令
gcc是驱动入口,会组织编译、汇编和链接;在终端调用它,并不意味着后续工作都在同一个进程里完成。 官方文档。 ↩ -
GNU — GNU 是 “GNU’s Not Unix” 的递归缩写,指自由软件操作系统项目。GCC、binutils 和 glibc 都属于 GNU 项目,但分别承担编译、二进制处理和 C 运行库职责。 官方文档。 ↩
-
binutils — GNU binutils 是一组处理目标文件的工具,包含汇编器
as、链接器ld,以及readelf、nm、objdump、ar等检查与归档工具。 官方文档。 ↩ -
musl-gcc —
musl-gcc是 GCC 的包装器,为编译和链接选择 musl 头文件、启动文件及库。它本身不意味着交叉编译;是否静态链接由-static等选项决定。 官方文档。 ↩ -
PIC — PIC(position-independent code)使用适合位置变化的寻址方式。它常用于共享库;具体通过 PC 相对寻址还是表项间接访问,取决于架构与符号绑定。 官方文档。 ↩
-
GOT — GOT(Global Offset Table)保存供代码间接访问的地址或相关偏移。它让部分地址修补集中到数据表中;表项的具体用途由重定位类型与 ABI 决定。 官方文档。 ↩
-
PLT — PLT(Procedure Linkage Table)是一组函数调用跳板,常与 GOT 和动态符号绑定配合。PLT 存放指令,不能简单理解成另一张纯地址表。 官方文档。 ↩
-
ELF — ELF(Executable and Linkable Format)规定目标文件、可执行文件与共享对象的结构。通用规则见 gABI,架构相关的调用约定和重定位规则见对应 psABI。 官方文档。 ↩
-
TLS — TLS(Thread-Local Storage)让不同线程拥有同一变量的独立实例。链接器描述初始化模板并处理寻址模型,运行时负责为线程建立实例。这里不是网络协议 Transport Layer Security。 官方文档。 ↩
-
readelf —
readelf检查 ELF 头、节、段、符号及重定位等结构;它读取文件而不执行其中的程序。GNUreadelf与 LLVMllvm-readelf的显示格式可能不同。 官方文档。 ↩ -
ABI — ABI(Application Binary Interface)规定二进制组件如何协作,包括调用约定、数据布局和文件格式等。它约束编译结果之间的交接,比源码层面的 API 更靠近机器。 官方文档。 ↩
-
psABI — psABI(processor-specific ABI)是特定处理器架构的二进制接口约定。不同架构可以共用 ELF 文件结构,同时拥有不同的指令、调用约定和重定位公式。 官方文档。 ↩
-
RELRO — RELRO(RELocation Read-Only)将需要重定位、但之后不应继续写入的区域转为只读。ELF 的
PT_GNU_RELRO描述该范围,实际保护由启动路径实施。 官方文档。 ↩ -
LLD — LLD 是 LLVM 项目的链接器。ELF 平台通常通过
ld.lld调用;lld-link则提供兼容 Windows 工具链的接口。它与负责处理源码的 Clang 是不同组件。 官方文档。 ↩