[链接器的世界-原理篇08] 栈展开:沿调用栈找到回去的路
链接器和动态链接器已经把跨文件、跨共享库的调用接通:main 可以调用库里的 bump,bump 再调用 helper。但这条路还需要反着走。helper 抛出的 C++ 异常若只能由 main 中的 catch 处理,运行时就必须越过 helper 和 bump,找到接手异常的位置,并执行途中需要的对象清理。
普通函数返回时,函数自己的收尾指令知道怎样恢复栈。异常却可能出现在函数执行的中途,无法把剩下的函数体正常跑完再返回。运行时手里只有此刻的指令地址、寄存器和内存,必须由这些信息恢复调用者的状态,再继续恢复上一层。这种沿调用关系恢复状态的过程叫栈展开(stack unwinding)。
崩溃回溯和性能采样也需要找出调用者,但通常只是在复制的状态或观察到的状态上遍历调用栈;它们不应执行 C++ 析构函数,也不把控制权转移到 catch。这些用途可以共用恢复调用者的规则,之后要做的事则不同。
这些规则由编译器和汇编器写进调用帧信息(Call Frame Information,CFI1),在本章的 ELF2 示例中保存在 .eh_frame 节里。第 7 章出现过的 PT_GNU_EH_FRAME 程序头与查找这些信息有关;并不是每个 ELF 文件都必须包含它们。规则要记录什么,取决于函数执行时怎样改变栈。
返回地址在栈上,但"栈上哪里"每个函数都不一样
x86-64 的 call 指令做两件事:把下一条指令的地址(返回地址)压到栈上,然后跳到目标函数。ret 反过来,从栈顶弹出一个地址并跳过去。栈从高地址往低地址长,栈顶由 %rsp 寄存器指着。
刚进入一个函数、还没执行它的第一条指令时,栈是这样的:
高地址 | 调用者自己的东西 | +--------------------+ <- 调用前的 %rsp(= 当前 %rsp + 8) | 返回地址 | +--------------------+ <- 当前 %rsp低地址这个时刻最简单,返回地址就在 %rsp 指向的位置。但函数一开始干活,就会往栈上压东西:保存要用的寄存器、给局部变量腾地方。几条指令之后,返回地址离 %rsp 可能是 16 字节,也可能是 200 字节,取决于这个函数自己的写法,甚至取决于此刻执行到函数的哪一条指令。
要退回调用者,至少要知道两件事:返回地址在哪(于是知道调用者执行到了哪),以及调用者当时的 %rsp 是多少(于是能在调用者那一层继续往上找)。如果是抛异常,还多一件:调用者存在某些寄存器里的值被当前函数临时挪到了栈上,退回去之前要把它们恢复原样,否则调用者那一层的 catch 代码拿到的寄存器就是错的。
帧指针链:一个简单的约定,和它的代价
一种直接的办法是让每个函数都遵守同一个开场白:
pushq %rbp # 把调用者的 %rbp 存到栈上movq %rsp, %rbp # 让 %rbp 指向刚存的那个位置%rbp 在这里被叫作帧指针(frame pointer)。执行完这两条后,%rbp 指向的位置存着调用者的 %rbp,再往上 8 字节就是返回地址。调用者的 %rbp 又指向调用者的调用者存下的 %rbp……于是栈上串起了一条链表。回溯时只要反复执行"返回地址 = *(rbp+8),rbp = *rbp",一路走到链表尽头。
这个办法便宜、直观,至今仍是很多性能分析工具首选的回溯方式。代价是帧指针一直占着 %rbp,x86-64 的 16 个通用寄存器除去 %rsp 和它只剩 14 个可以自由分配,开场白和收尾也各多一两条指令。所以 GCC3 和 Clang4 在 x86-64 上开启优化后,默认都会省略帧指针(对应选项 -fomit-frame-pointer),把 %rbp 当普通寄存器用。
本章统一在 x86-64 Linux 上完成:Ubuntu 26.04,Clang/LLD 21.1.8,GCC 15.2、GNU5 binutils6 2.46、glibc7 2.43。本章数值来自这一环境;读者换版本后应从自己的 ELF 重新取值。下面的地址属于这一轮构建;读者换版本后应从自己的 ELF 重新取值。
下面这个函数调用了两次外部函数:
// leaf.cint ext(int);int twice(int x) { return ext(x) + ext(x + 1); }用 clang -O1 -S leaf.c 生成汇编(Clang 21,下同),开头是这样的:
twice: pushq %rbp pushq %rbx pushq %rax movl %edi, %ebx callq ext@PLT movl %eax, %ebp # %rbp 被拿来存第一次调用的结果 ...%rbp 确实被压栈了,但只是因为它在 x86-64 调用约定里属于"被调用者保存"的寄存器(函数要用它,就得先存起来、返回前恢复),随后它被当作普通寄存器装了 ext(x) 的返回值。这时 %rbp 里是一个整数,顺着它"走链表"会走到一个莫名其妙的地址。末尾那个 pushq %rax 的用意也与 %rax 无关,它只是让栈再下移 8 字节,凑够调用 ext 前要求的 16 字节对齐。
加上 -fno-omit-frame-pointer 重新生成,开头就变回了熟悉的 pushq %rbp; movq %rsp, %rbp。近几年一些发行版因为性能分析的需要,又在自己的软件包里把帧指针打开了,例如 Fedora 38 和 Ubuntu 24.04。
即使所有函数都保留帧指针,它也不够用。第一,开场白执行到一半(push 之后、mov 之前)或收尾时链表是断的,性能分析器恰好在这里打断程序,回溯就会少一层或错一层。第二,帧指针不告诉你 %rbx、%r12 这些被调用者保存的寄存器存在哪里,而异常处理需要把它们恢复回去。
缺的是一份更完整的说明:对函数里的每一条指令,都写清楚"此刻调用者的 %rsp 怎么算、返回地址在哪、哪些寄存器被存到了哪里"。编译器在生成代码时对这些了如指掌,只要把它记下来就行。
一张表:CFA 与"每个 PC 一行"
DWARF8 把这份说明设计成一张概念上的大表(DWARF 5 §6.4)。表的每一行对应函数里的一段指令地址,每一列对应一个寄存器,格子里写的是"在这一段指令里,调用者的这个寄存器的值怎么找回来"。
为了描述一个调用帧,需要一个不随函数临时调整栈顶而改变的参考地址,称为 CFA(Canonical Frame Address,规范帧地址)。DWARF 将调用点的栈指针列为它的典型取值,而不是所有体系结构必须遵守的唯一定义(DWARF 5,§6.4,第 171 页)。在这里的 x86-64 示例中,它就是执行 call 之前的 %rsp。看前面那张栈图,刚进入函数时 CFA = %rsp + 8,返回地址在 CFA − 8。
在这个例子中,CFA 在整个函数执行期间不会变:不管函数又压了多少东西,调用前的 %rsp 都是那个值。变的只是"用当前哪个寄存器加多少偏移能算出它"。于是表的第一列写 CFA 的计算规则,其余各列都以 CFA 为基准,写成"存在 CFA − 16 处""存在 CFA − 24 处"这样的形式。
表里的列需要给寄存器编号。DWARF 本身不规定编号,由各架构的 ABI9 定。x86-64 psABI 的 "DWARF Register Number Mapping" 表规定:0 = %rax,1 = %rdx,2 = %rcx,3 = %rbx,4 = %rsi,5 = %rdi,6 = %rbp,7 = %rsp,8~15 = %r8~%r15。这个顺序和机器码里的寄存器编码不同(机器码里 %rcx 是 1、%rdx 是 2)。16 对应返回地址列,关联的是指令指针 %rip:恢复调用者时,这一列给出它继续执行的地址。x86-64 有真实的 %rip,但没有由 call 保存返回地址的专用 link register;普通调用把返回地址保存到栈上,所以这一列的恢复规则通常描述一个栈内存位置。
有了编号,twice 执行到 pushq %rax 之后、第一次 call 之前的那一行可以这样写:
CFA = reg7 + 32 # 调用前的 %rsp = 当前 %rsp + 32reg16 = [CFA - 8] # 返回地址存在 CFA-8reg6 = [CFA - 16] # 调用者的 %rbp 存在 CFA-16reg3 = [CFA - 24] # 调用者的 %rbx 存在 CFA-24展开一层的过程就是查表、照做:用当前 PC 找到这一行;按第一列算出 CFA;按其余各列从栈上读回返回地址和调用者的 %rbp、%rbx;调用者的 %rsp 就等于 CFA(在这个 x86-64 调用约定示例中,CFA 选为调用前的 %rsp)。现在手里有了调用者的 PC 和一组寄存器,可以对调用者再查一次表,一直退到最外层。
还剩一个细节。用返回地址去查调用者那一行时,展开器通常查的是"返回地址 − 1"。返回地址是 call 的下一条指令,如果 call 恰好是函数的最后一条指令(调用一个不会返回的函数,比如抛异常用的 __cxa_throw),返回地址就已经落在下一个函数里了,减 1 才能落回 call 本身所在的函数。有两种帧不减:最内层那一帧,它的 PC 就是正在执行或刚被打断的那条指令,本来就落在函数内部;被信号(内核打断进程、通知它发生了某个事件的机制,比如非法访问内存会收到 SIGSEGV)打断的那一层也一样,后面讲 CIE10 的 S 标记时会说到。
把表压缩成一串指令
如果真的为每条指令存一整行,表会比代码本身还大。观察一下就会发现,相邻两行之间通常只差一个格子:push 一次,CFA 的偏移加 8,外加可能多一个寄存器被保存。DWARF 于是只存一段"生成这张表的程序":从函数入口的初始状态出发,一条指令说"往后推进若干字节",下一条说"CFA 的偏移改成 16",再下一条说"3 号寄存器存在 CFA − 24"。展开器从头解释这段程序,推进到目标 PC 为止,手里的状态就是要找的那一行。这些指令叫 CFI 指令,名字都以 DW_CFA_ 开头(DWARF 5 §6.4.2,编码见 §7.24)。
用一个手写的汇编函数看最清楚:
# hand.s .text .globl f .type f,@functionf: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp call g popq %rbp .cfi_def_cfa %rsp, 8 ret .cfi_endproc .size f, .-f .section .note.GNU-stack,"",@progbits以 .cfi_ 开头的都是汇编器伪指令(第 2 章讲过伪指令:它们不生成机器指令,只指挥汇编器做事)。.cfi_startproc 和 .cfi_endproc 圈出一个函数;中间每一条 .cfi_* 描述"从这个位置起,表的哪一格变了"。汇编器负责记下每条伪指令所在的代码偏移,算出相邻两条之间隔了多少字节,再把它们翻译成二进制的 CFI 指令,写进 .eh_frame 节。
用 clang -c hand.s -o hand.o 汇编,再用 llvm-objdump --dwarf=frames hand.o 解码 CFI。以下摘出该函数的 FDE 和解码后的规则表:
00000018 0000001c 0000001c FDE cie=00000000 pc=00000000...0000000b Format: DWARF32 DW_CFA_advance_loc: 1 to 0x1 DW_CFA_def_cfa_offset: +16 DW_CFA_offset: reg6 -16 DW_CFA_advance_loc: 3 to 0x4 DW_CFA_def_cfa_register: reg6 DW_CFA_advance_loc: 6 to 0xa DW_CFA_def_cfa: reg7 +8 DW_CFA_nop: DW_CFA_nop: DW_CFA_nop:
0x0: CFA=reg7+8: reg16=[CFA-8] 0x1: CFA=reg7+16: reg6=[CFA-16], reg16=[CFA-8] 0x4: CFA=reg6+16: reg6=[CFA-16], reg16=[CFA-8] 0xa: CFA=reg7+8: reg6=[CFA-16], reg16=[CFA-8]上半部分是指令序列,下半部分是 llvm-objdump 执行这段 CFI 程序后得到的表。每行规则从该代码偏移开始生效,延续到下一行;不是只对那个偏移的一个字节生效。对照反汇编(llvm-objdump -d hand.o):
0000000000000000 <f>: 0: 55 pushq %rbp 1: 48 89 e5 movq %rsp, %rbp 4: e8 00 00 00 00 callq 0x9 <f+0x9> 9: 5d popq %rbp a: c3 retqpushq %rbp 占 1 字节,执行完后 %rsp 下移 8,所以从偏移 0x1 起 CFA = %rsp + 16,调用者的 %rbp 存在 CFA − 16。movq %rsp, %rbp 占 3 字节,从 0x4 起 %rbp 和 %rsp 相等,汇编代码用 .cfi_def_cfa_register %rbp 把 CFA 的基准换成 6 号寄存器:之后函数里不管 %rsp 怎么动,CFA 都等于 %rbp + 16。popq %rbp 之后,0xa 处只剩返回地址在栈顶,CFA 又回到 %rsp + 8。
为什么 0xa 那一行仍写着 reg6=[CFA-16]?popq 改变了 CPU 的寄存器和栈顶,后面的 .cfi_def_cfa 只修改 CFA 的计算规则,没有修改 reg6 的恢复规则。这个短函数在 ret 前没有覆盖原来保存 %rbp 的内存槽,所以沿旧规则仍能读回调用者的值。若希望把恢复规则也切回 CIE 的初始状态,可以在这里增加 .cfi_restore %rbp;它生成 DW_CFA_restore,不会生成另一条 CPU 恢复指令。机器执行与展开元数据是两套需要保持一致的描述。
这里用到了几条最常见的 CFI 指令。DW_CFA_def_cfa 同时设定 CFA 的基准寄存器和偏移;DW_CFA_def_cfa_offset 只改偏移;DW_CFA_def_cfa_register 只改寄存器;DW_CFA_offset 说某个寄存器存在 CFA 加某偏移处;DW_CFA_advance_loc 把当前位置往后推若干字节,之后的规则从推进后的位置开始生效。此外还有 DW_CFA_restore(某寄存器恢复成 CIE 的初始规则)、DW_CFA_remember_state / DW_CFA_restore_state(把整行状态压栈、弹栈:函数中间有一条提前返回的路径时,先存下当前状态,描述完那条路径的收尾,再恢复回来接着描述后面的代码)。
字节怎么拆
再用 objdump -s -j .eh_frame hand.o 看原始字节:
Contents of section .eh_frame: 0000 14000000 00000000 017a5200 01781001 .........zR..x.. 0010 1b0c0708 90010000 1c000000 1c000000 ................ 0020 00000000 0b000000 00410e10 8602430d .........A....C. 0030 06460c07 08000000 .F......先跳过前面的头部,从偏移 0x29 开始是 f 的 CFI 指令:41 0e 10 86 02 43 0d 06 46 0c 07 08 00 00 00。
CFI 指令的第一个字节分两种格式。最常用的三条指令把操作码压在字节的高 2 位,低 6 位直接装操作数:高 2 位为 01 是 DW_CFA_advance_loc,低 6 位是推进量;10 是 DW_CFA_offset,低 6 位是寄存器号,后面再跟一个 ULEB12811 编码的偏移(LEB12812 是第 2 章讲过的变长整数编码);11 是 DW_CFA_restore。高 2 位为 00 时,低 6 位才是一个完整的"扩展操作码",操作数跟在后面。
按这个规则拆:
0x41= 二进制01 000001:advance_loc,推进 1。0x0e 0x10:0x0e高 2 位是00,扩展操作码 0x0e 即DW_CFA_def_cfa_offset,操作数 ULEB1280x10= 16。0x86 0x02:10 000110,offset,寄存器 6;后面的02是"因子化偏移",要乘上数据对齐因子才是真正的偏移(马上解释)。0x43:01 000011,推进 3。0x0d 0x06:DW_CFA_def_cfa_register,寄存器 6。0x46:推进 6。0x0c 0x07 0x08:DW_CFA_def_cfa,寄存器 7,偏移 8。- 最后三个
0x00是DW_CFA_nop,用来把整条记录补齐到 8 字节对齐。
0x86 0x02 里的 02 为什么表示 −16?这是为了省字节做的缩放。每个函数共享一个"数据对齐因子"(data alignment factor),x86-64 上是 −8:栈上的保存位置都是 8 的倍数、都在 CFA 下方,存"−16 ÷ −8 = 2"只要一个字节。同理还有一个"代码对齐因子"(code alignment factor),advance_loc 的推进量要乘以它。x86-64 指令长度不定,所以它是 1。两个因子写在公共信息条目 CIE里。具体地址范围的规则则写在帧描述条目 FDE13里,每条 FDE 指向它使用的 CIE。这样多个函数可以共用初始规则和编码参数,而各自保留随指令位置变化的规则。
谁来写这些指令
hand.s 是手写的,Clang 编译 C 代码时会自己插这些伪指令。回到 leaf.c,-S 输出里其实是这样的:
twice: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 pushq %rbx .cfi_def_cfa_offset 24 pushq %rax .cfi_def_cfa_offset 32 .cfi_offset %rbx, -24 .cfi_offset %rbp, -16 ... addq $8, %rsp .cfi_def_cfa_offset 24 popq %rbx .cfi_def_cfa_offset 16 popq %rbp .cfi_def_cfa_offset 8 retq .cfi_endproc收尾部分每弹一次栈都更新一次 CFA 偏移,这张表精确到了每一条指令。GCC(gcc -O1 -S leaf.c)则直接把 DWARF 寄存器号写进汇编,如 .cfi_offset 3, -24,3 就是 %rbx。
"精确到每条指令"和"只在调用点精确"是两个不同的级别。如果只为了 C++ 异常,展开器只会在 call 指令的返回地址处查表(异常只能从调用里冒出来),表只要在调用点正确即可,GCC 的 -funwind-tables 对应这个级别(同步)。性能分析器、调试器和信号却可能在任何一条指令处打断程序,这需要 -fasynchronous-unwind-tables(异步)。不过在 x86-64 上两档的输出没有区别:GCC 和 Clang 以 -fno-asynchronous-unwind-tables -funwind-tables 和默认选项编译 leaf.c,汇编逐字节相同。
C 代码明明没有异常,为什么也要这张表?x86-64 psABI14 的 Unwinding Through Assembler Code 一节(§6.3)开头写道:"For successful unwinding on AMD64 every function must provide a valid debug information in the DWARF Debugging Information Format"。编译器自动生成,手写汇编由作者用 .cfi_* 伪指令补上:展开是一层层接力的,中间有一个函数没有表,接力就在那里断掉。一个 C++ 异常从回调函数里抛出、穿过 C 库的 qsort、要被外面的 catch 接住,qsort 那一层就必须有 .eh_frame。所以 GCC 和 Clang 在 x86-64 上默认打开 -fasynchronous-unwind-tables:上面的 leaf.o 里就有 .eh_frame,加上 -fno-asynchronous-unwind-tables 重新编译,objdump -h 里这个节就没有了。
同一张表的两个版本:.debug_frame 与 .eh_frame
第 1 章讲过这段历史:DWARF 2 的 CFI 本是调试信息,放在 .debug_frame 里,不装进内存,可以被 strip 掉;C++ 异常要在运行中途查表,GCC 于是把这份格式稍作改动,放进一个会被装进内存的新节 .eh_frame。两者内容几乎一样,差别集中在"运行时要不要用、能不能被链接器重新排布"上(差异整理见 MaskRay 的 Stack unwinding)。
用 -g -fno-asynchronous-unwind-tables 编译 leaf.c,Clang 就会改为生成 .debug_frame,记为 dbg.o。用 Linux 上的 readelf -SW 对比两种目标文件的节头:
[ 6] .eh_frame X86_64_UNWIND 0000000000000000 000090 000040 00 A 0 0 8 [15] .debug_frame PROGBITS 0000000000000000 000238 000048 00 0 0 8.eh_frame 带着 A 标志,即 SHF_ALLOC(第 2 章讲过:带这个标志的节会被装进内存),.debug_frame 没有。.eh_frame 的节类型是 X86_64_UNWIND(SHT_X86_64_UNWIND,psABI 为它专门定义的类型);同一个文件用 GCC 编译,类型则是普通的 PROGBITS,链接器靠节名识别它。
再看 objdump --dwarf=frames dbg.o 里 .debug_frame 的公共头部:
00000000 00000014 ffffffff CIE Format: DWARF32 Version: 4 Augmentation: "" Address size: 8 Segment desc size: 0 Code alignment factor: 1 Data alignment factor: -8 Return address column: 16 ...00000018 0000002c 00000000 FDE cie=00000000 pc=00000000...0000001e和 hand.o 的 .eh_frame 对比,能看出几处差别:.debug_frame 的 CIE 标识是 0xffffffff,.eh_frame 是 0;版本号分别是 4 和 1;.eh_frame 多了一个叫 augmentation 的扩展字段。还有一处差别藏在 FDE 里:.debug_frame 的 FDE 用"节内偏移"指向它的 CIE(FDE 那一行第三列是 00000000),.eh_frame 用的是相对距离(hand.o 里是 0000001c)。objdump -r dbg.o 里能看到这个偏移的代价:
RELOCATION RECORDS FOR [.debug_frame]:OFFSET TYPE VALUE000000000000001c R_X86_64_32 .debug_frame0000000000000020 R_X86_64_64 .text0x1c 正是 FDE 里的 CIE 偏移字段,它挂着一条指向 .debug_frame 自身的重定位:多个目标文件的 .debug_frame 拼起来后,每个文件的 CIE 在输出节里的偏移都变了,要靠这条重定位改写。0x20 是 FDE 覆盖的起始地址,用的是 8 字节绝对地址 R_X86_64_64。下面把 .eh_frame 的记录逐字节拆开,这些差别的用意也就清楚了。
CIE 与 FDE:逐字节拆开
.eh_frame 节由一条接一条的记录组成,记录分两种。一条 FDE 记录一个地址范围及其 CFI 指令。常见情形是一个函数对应一条 FDE,但函数拆成多个范围时可以有多条,也可能根本没有生成展开信息。很多函数共有的部分,比如对齐因子、返回地址列、函数入口处的初始规则,抽出来放进 CIE(Common Information Entry,公共信息条目),每条 FDE 指向一条 CIE。字段定义见 LSB(Linux Standard Base,Linux 标准基础规范,和第 2 章说的"最低有效字节"不是一回事)的 Exception Frames 一章。
下图覆盖 hand.o 的整个 .eh_frame 节,而不是整个目标文件。它采用普通四字节记录长度和 zR 扩展;ELF64 并不意味着记录里的每个字段都占八字节。图中偏移相对于 .eh_frame 起点,格下数字表示字段自身的宽度。
回到 hand.o 的前 0x18 字节,这是一条 CIE:
0000 14000000 00000000 017a5200 01781001 0010 1b0c0708 9001000014000000:长度 0x14 = 20,小端序。它不包括长度字段自身的 4 字节,所以整条 CIE 占 24 字节,下一条记录从 0x18 开始。长度为0xffffffff时表示后面还有一个 8 字节的扩展长度;长度为 0 的记录是终止标记。00000000:CIE 标识,在.eh_frame里固定为 0,表示"这是 CIE"。FDE 在同一位置放的是非零的 CIE pointer,展开器靠这一个字段区分两种记录。01:版本号,LSB 规定为 1。7a 52 00:augmentation 字符串"zR",以 0 结尾。01:代码对齐因子 1(ULEB128)。78:数据对齐因子,SLEB128 的0x78是 −8(最高位为 0 表示结束,第 6 位为 1 表示负数,0x78 − 0x80 = −8)。10:返回地址列 16。01:augmentation 数据长度 1 字节。1b:augmentation 数据,即R对应的 FDE 指针编码,下面解释。0c 07 08:初始指令DW_CFA_def_cfa: reg7 +8,即 CFA =%rsp + 8。90 01:10 010000,DW_CFA_offset寄存器 16,偏移 1 × (−8) = −8,即返回地址在 CFA − 8。00 00:补齐用的nop。
这两条初始指令对应本章开头的普通调用入口:CFA = %rsp + 8,返回地址在 CFA − 8。引用这个 CIE 的 FDE 共用这一初始状态,再各自描述代码执行后的规则变化。使用不同入口约定或恢复规则的记录可以引用其他 CIE。
接着从 0x18 开始的是 f 的 FDE,对应上面输出中第二行后半段起的字节(1c000000 1c000000 00000000 0b000000 00 41 0e ...):
1c000000:长度 0x1c,整条 FDE 占 0x20 字节,到 0x38 结束。1c000000:CIE pointer。LSB 的定义是:用"当前 FDE 中 CIE pointer 字段自身的偏移"减去这个值,得到所属 CIE 的起点。这个字段位于 0x1c,0x1c − 0x1c = 0,指向偏移 0 处的 CIE。00000000:pc_begin,即这个 FDE 覆盖的第一条指令的地址,此刻是 0。0b000000:pc_range,覆盖 0xb 字节,正好是f的长度。它使用地址编码中的数值表示部分,却不应用pcrel基址:这是长度,不是第二个地址。00:augmentation 数据长度 0(因为 CIE 的字符串里有z,每个 FDE 都要带这个长度字段,哪怕为 0)。- 其后就是前面拆过的 CFI 指令。
CIE pointer 是一个 4 字节无符号数,而且定义成"往回减",这意味着 CIE 必须出现在引用它的 FDE 前面,这就是 .eh_frame 里总是先 CIE、后 FDE 的原因。和 .debug_frame 的节内偏移相比,相对距离有两个好处。一是省掉重定位:链接器把许多目标文件的 .eh_frame 首尾相接拼成一个输出节时,每个输入文件的记录整体平移,FDE 和它的 CIE 原本在同一个输入文件里,距离不变,用不着 dbg.o 里那条 R_X86_64_32。二是方便运行时:展开器手里只有 FDE 的地址,往回减就找到 CIE,不需要知道运行时看不到的节头。至于链接器删掉了某些记录、距离变了怎么办,后面讲链接器时再看。
pc_begin 为什么是 0:重定位与指针编码
pc_begin 字段里是 0,因为目标文件里 .text 还没有最终地址。objdump -r hand.o 能看到它对应的重定位(第 4 章讲过的"便条"):
RELOCATION RECORDS FOR [.eh_frame]:OFFSET TYPE VALUE0000000000000020 R_X86_64_PC32 .text偏移 0x20 正是 pc_begin 字段。类型是 R_X86_64_PC32,按第 4 章的公式 S + A − P,链接器会在这里填".text 的地址减去这个字段自身的地址",一个 32 位的 PC 相对值。
为什么是 PC 相对、为什么是 32 位,是由 CIE 里那个 1b 决定的。R 这个 augmentation 告诉展开器,本 CIE 下所有 FDE 的 pc_begin 采用哪种编码。编码是一个字节,取值统称 DW_EH_PE_*(pointer encoding),按位分成三部分:
- 低 4 位说"怎么存":
0x00absptr(机器字长的绝对地址)、0x01uleb128、0x02udata2、0x03udata4、0x04udata8,以及有符号的0x09sleb128、0x0asdata2、0x0bsdata4、0x0csdata8; - 中间 3 位(掩码
0x70)说"相对谁":0x00不相对、0x10pcrel(相对字段自身地址)、0x20textrel(相对代码段基址)、0x30datarel(相对某个数据基址,.eh_frame_hdr里用)、0x40funcrel(相对所在函数的起点)、0x50aligned(按指针大小对齐后存放);后几种在 x86-64 Linux 上很少用到; - 最高位
0x80单独表示 indirect:算出来的地址处存的才是真正的指针,还要再读一次; - 整个字节为
0xff是 omit,表示这个字段不存在。
0x1b = 0x10 | 0x0b,即 pcrel | sdata4:一个有符号 4 字节、相对字段自身地址的偏移。当代码与这份展开信息位于同一加载图像时,基址变化会同时作用于两者,字段与函数之间的距离不变,所以这个 pcrel 引用不需要运行时的基址重定位(第 5、7 章讲过 PIE15 与基址)。这不代表所有 .eh_frame 字段都没有动态重定位;其他指针还要按各自编码和符号绑定方式处理。这也是 .eh_frame 和 .debug_frame 的另一个区别:后者的地址字段是绝对地址(前面 dbg.o 里那条 R_X86_64_64),靠的是调试器自己知道加载基址。
augmentation 字符串里的其他字母同样各自在 CIE 和 FDE 里加字段:z 表示存在 augmentation 数据长度,必须是第一个字母,有了它,展开器即使不认识后面的字母,也能整段跳过;P 表示有 personality 函数;L 表示 FDE 里带 LSDA16 指针;还有 S 表示这是一个信号处理帧。进程可以为信号注册处理函数,内核递送信号时,在被打断处的栈上压一份寄存器现场,再让处理函数像被调用一样开始执行,返回时落到 C 库里一小段负责通知内核恢复现场的代码上;这一层伪造出来的帧就叫信号处理帧。退过它之后得到的 PC 是被打断的那条指令本身,不是某个 call 的下一条,所以展开器看到 S 就不再对下一层做"减 1"。P 和 L 要到 C++ 才会出现。
异常:展开器之外还要一个"懂语言的人"
到这里为止,CFI 只回答了"怎么退回调用者"。打印调用栈,这就够了。抛异常还要回答另外两个问题:退到哪一层停下?停下之前,路过的每一层要做什么清理?
这两个问题和语言有关。C++ 要按类型匹配 catch,要在退出作用域时调用局部对象的析构函数;其他语言的规则又各不相同。DWARF 的展开机制不懂这些,于是第 1 章提过的 Itanium C++ ABI 异常处理规范 把工作拆成两层:语言无关的展开库(接口函数以 _Unwind_ 开头,GCC 的实现在 libgcc 里,LLVM17 有 libunwind),和每种语言自己提供的 personality 函数。展开库负责一层层退栈,每退到一层,就把这一层交给 personality 函数问一句"你这里要处理吗"。C++ 的 personality 函数叫 __gxx_personality_v0,在 libstdc++ 或 libc++abi 里。
personality 函数怎么知道某一层里有哪些 try、哪些对象要析构?编译器为每个函数额外生成一张表,叫 LSDA(Language-Specific Data Area,语言相关数据区),放在 .gcc_except_table 节。CIE 里的 P 指向 personality 函数,FDE 里的 L 指向本函数的 LSDA,两者就这样挂到了 .eh_frame 上。
写一个只包含外部声明、局部对象和 catch 的 C++ 文件,便于把观察集中到展开元数据:
// thrower.ccextern "C" int may_fail(int);struct Guard { ~Guard(); };int run(int x) { Guard g; try { return may_fail(x); } catch (int e) { return -e; }}void boom() { throw 42; }clang -O1 -fexceptions -S thrower.cc 输出的 run 开头多了两条伪指令:
_Z3runi:.Lfunc_begin0: .cfi_startproc .cfi_personality 155, DW.ref.__gxx_personality_v0 .cfi_lsda 27, .Lexception0 pushq %rbx ...155 = 0x9b,27 = 0x1b,分别是 personality 指针和 LSDA 指针的编码。编出目标文件后看 objdump --dwarf=frames thrower.o,.eh_frame 里出现了两条 CIE:
00000000 00000014 00000000 CIE Augmentation: "zR" ...00000018 00000010 0000001c FDE cie=00000000 pc=00000000...00000022 ...0000002c 0000001c 00000000 CIE Version: 1 Augmentation: "zPLR" Code alignment factor: 1 Data alignment factor: -8 Return address column: 16 Augmentation data: 9b c1 ff ff ff 1b 1b ...0000004c 00000028 00000024 FDE cie=0000002c pc=00000000...0000004b Augmentation data: a3 ff ff ff ...boom 里没有 try、也没有需要析构的局部对象,用不着 personality,所以它的 FDE 挂在普通的 "zR" CIE 下(Clang 判断只会抛异常的 boom 很少执行,把它放进了第 5 章见过的冷代码节 .text.unlikely.,所以它的 FDE 在前)。run 的 FDE 挂在 "zPLR" CIE 下,它的 CIE pointer 是 0x24,字段位于 0x50,0x50 − 0x24 = 0x2c,正是第二条 CIE。
第二条 CIE 的 augmentation 数据 9B 00 00 00 00 1B 1B 依次是:P 的编码 0x9b、4 字节 personality 指针、L 的编码 0x1b、R 的编码 0x1b,顺序与字符串 "zPLR" 中字母的顺序一致。objdump --dwarf=frames 为显示目的应用了可解析的节内重定位,因此这里显示 c1 ff ff ff 与 a3 ff ff ff;原始 dump 中对应字段仍为零,尚不是运行时地址,真正的值要等链接器处理 objdump -r thrower.o 里的这几条重定位后才算得出:
RELOCATION RECORDS FOR [.eh_frame]:OFFSET TYPE VALUE0000000000000020 R_X86_64_PC32 .text.unlikely.000000000000003f R_X86_64_PC32 DW.ref.__gxx_personality_v00000000000000054 R_X86_64_PC32 .text000000000000005d R_X86_64_PC32 .gcc_except_table0x3f 是 CIE 里的 personality 字段,0x54 是 run 的 pc_begin,0x5d 是 run 的 LSDA 指针。
0x9b:多绕一道的 personality 指针
0x9b = 0x80 | 0x10 | 0x0b,比 0x1b 多了 indirect 位。重定位指向的符号也换成了 DW.ref.__gxx_personality_v0。汇编输出里能看到它的定义:
.hidden DW.ref.__gxx_personality_v0 .weak DW.ref.__gxx_personality_v0 .section .data.DW.ref.__gxx_personality_v0,"awG",@progbits,DW.ref.__gxx_personality_v0,comdatDW.ref.__gxx_personality_v0: .quad __gxx_personality_v0它是一个 8 字节的可写数据槽,里面存 __gxx_personality_v0 的绝对地址。.eh_frame 里的 pcrel 字段指向这个槽,展开器算出槽的地址后再读一次,才拿到函数地址。
多绕这一道,是因为 __gxx_personality_v0 通常在另一个模块 libstdc++.so 里,链接时不知道它的地址。如果 .eh_frame 直接存它的 pcrel 偏移,就得在只读的 .eh_frame 上打动态重定位。改为指向本模块里的一个数据槽,重定位就落在这个可写的槽上:objdump -r thrower.o 里,.data.DW.ref.__gxx_personality_v0 挂着一条 R_X86_64_64 __gxx_personality_v0,链接成 PIE 或共享库后它变成同类型的动态重定位,由 ld.so 填入(第 7 章),.eh_frame 自身保持只读、与基址无关。这个槽被声明成 hidden(第 3 章讲过的可见性:只在本模块内可见,不进动态符号表)的 weak 符号,放在 COMDAT18 组里(第 3 章讲过 COMDAT:同名的组在链接时只保留一份),于是每个目标文件都可以自带一份,链接后全模块共用一个。
LSDA 用的是 0x1b,没有 indirect:它和函数在同一个目标文件里,PC 相对距离在链接时就能算出来。
LSDA:调用点表
LSDA 是编译器为带 try 或需要析构的函数生成的一张表,放在 .gcc_except_table。它的核心是调用点表(call-site table):函数里每一段可能抛出异常的调用,记下起止范围、着陆点(landing pad,异常停在这一层时跳去执行的代码)和一个动作值;动作值指向后面的动作表和类型表,说明这里能接住哪些类型、要不要清理。run 里调用 may_fail 的那一段,着陆点先看异常是不是 int,是就进入 catch,否则析构 g,再调用 _Unwind_Resume 继续往上抛。
personality 函数拿当前 PC 在调用点表里找到所在的那一段,据此判断这一层有没有能接住的 catch、有没有要做的清理。PC 不在任何一段里,说明异常从编译器认为不会抛出的地方冒了出来,它直接调用 std::terminate(C++ 运行时在异常无法继续处理时调用的函数,默认结束进程)。Itanium C++ ABI 只规定 personality 怎样经展开库拿到 LSDA 的地址,表的格式由编译器和配套的 personality 自己约定。链接器不解释这张表,只把 .gcc_except_table 当普通节搬运,处理其中的重定位,比如类型表里指向 int 类型信息对象 _ZTIi 的那一项。
把一条调用记录连到类型匹配
沿用上面的 thrower.cc,直接执行本节命令。用同一条 clang -O1 -fexceptions -S 命令查看 .gcc_except_table,就能把抽象的“动作值”落实到字段。以下是 Linux Clang 21.1.8 的实际汇编片段,标签名只在这次编译中有意义:
.Lcst_begin0: .uleb128 .Ltmp0-.Lfunc_begin0 # 调用范围的起点 .uleb128 .Ltmp1-.Ltmp0 # 范围长度 .uleb128 .Ltmp2-.Lfunc_begin0 # 着陆点偏移 .byte 3 # 动作表内偏移 + 1前三项以函数起点为参考描述代码位置;第四项不指向代码。这里的 3 表示从动作表开头跳过 2 字节。动作表由两个两字节记录组成,原始字节是 00 00 01 7d。第二个记录从偏移 2 开始,所以它是本调用首先检查的动作。
每条动作有两个 SLEB128 字段:类型过滤值和下一动作的相对位移。01 7d 中,1 选择第一个类型项,7d 按有符号 LEB128 解码为 −3;从第二个字段的位置退三字节,就到前一条记录。前一条的过滤值为 0,表示清理;它的下一位移为 0,链到此结束。不能把动作值 3 当成“第 3 个类型”,也不能把 7d 当成无符号整数 125。
这一输出的类型表编码是 0x9b,即间接的 PC 相对四字节有符号偏移;类型索引从类型表基准向前定位。第一个类型项最终指向 int 的类型信息。搜索阶段用它判断当前异常是否匹配 catch (int);清理阶段再依据已选中的处理帧和动作链决定如何进入着陆点。着陆点负责执行编译器生成的控制流,展开库不会自己解释 C++ 析构语句。
这组表的字段约定可对照 LLVM 21.1.8 libc++abi 的 personality 实现。不同编译器可能合并动作、采用其他指针编码或生成不同标签,读表时应先解码头部,再按该产物的字段推进;上面的字节不是固定模板。
两阶段展开
throw 42 编译出来是 __cxa_allocate_exception 分配异常对象,再调用 __cxa_throw。__cxa_throw 填好异常头部,调用展开库的 _Unwind_RaiseException。Itanium ABI 规定,展开分两个阶段进行。
第一阶段叫搜索(search phase)。展开库从抛出点开始,用 CFI 一层层"虚拟地"往回走:只在内存里的一份寄存器副本上计算,并不真正改动栈。每到一层,如果它的 FDE 带 personality,就以 _UA_SEARCH_PHASE 调用 personality 函数。personality 函数读这一层的 LSDA,用当前 PC 在调用点表里查找,看有没有能接住这个异常类型的 catch。有,就返回 _URC_HANDLER_FOUND,第一阶段结束;一直走到栈底都没有,_Unwind_RaiseException 就返回 _URC_END_OF_STACK,ABI 文档特意写明此时展开库没有改动过栈,__cxa_throw 随即调用 std::terminate。
第二阶段叫清理(cleanup phase)。展开库从抛出点重新出发,这次以 _UA_CLEANUP_PHASE 调用每一层的 personality 函数。路过的层如果有析构函数要跑,personality 函数返回 _URC_INSTALL_CONTEXT,展开库把寄存器真正恢复成这一层的状态、跳到着陆点;着陆点做完清理,调用 _Unwind_Resume 回到展开库,继续下一层。到达第一阶段找到的那一层时,展开库还会带上 _UA_HANDLER_FRAME 标志,告诉 personality 这就是搜索阶段已选中的处理帧。personality 不再改选处理位置,而是让展开库跳进着陆点,这次进入的是 catch 块,展开结束。
为什么要先搜一遍?C++ 标准([except.terminate],见 eel.is 上的草案)允许实现自行决定:异常没有被任何 catch 接住时,调用 std::terminate 之前是否展开栈。两阶段让实现可以选择"不展开":搜索阶段发现无人接手时栈还停在抛出点,core dump(进程崩溃时内核写到磁盘上的内存和寄存器快照)里看到的就是抛出的位置。Itanium ABI 文档还给了一个理由:第一阶段让异常可以在展开开始之前被"驳回",这使"可恢复"的异常处理(修正错误后从抛出点继续执行)成为可能,C++ 没有这种语义,别的语言有。
无论哪个阶段,"怎么退回上一层"用的都是同一份 CFI。如果路过的某一层没有 FDE,展开库在那一层就走不下去,结果同样是 std::terminate。这也是前面那句 psABI 原文要求"每个函数都提供"的原因。
运行时怎么找到 PC 对应的 FDE
上面每一步都以"用 PC 找到所在函数的 FDE"开头。一个大程序的 .eh_frame 里可能有几十万条 FDE,分布在主程序和几十个共享库里,从头线性扫描显然太慢。
早期 GCC 的做法是让每个模块在启动时调用 __register_frame_info,把自己的 .eh_frame 登记给运行时,运行时第一次需要时再把所有 FDE 排序。这既拖慢启动,又要求每个模块都带上登记代码。2001 年底 binutils 加入了另一个办法(Ian Lance Taylor 的介绍):让链接器在链接时就把排好序的查找表做好,放进新的节 .eh_frame_hdr,再用新的程序头 PT_GNU_EH_FRAME 指向它。
用 ld.lld(LLVM 的 ELF 链接器)真正链接出一个 x86-64 Linux 可执行文件,看看这张表长什么样。下面这个程序不依赖 C 库,自己提供入口 _start(第 5 章讲过入口):
// gc.cint ext_counter;__attribute__((noinline)) int live(int x) { ext_counter += x; return ext_counter * 3; }__attribute__((noinline)) int dead(int x) { ext_counter -= x; return ext_counter * 5; }void _start(void) { live(1); for (;;) {} }clang -O1 -ffreestanding -fno-stack-protector \ -ffunction-sections -fasynchronous-unwind-tables -c gc.c -o gc.old.lld -static --eh-frame-hdr --gc-sections --print-gc-sections -e _start gc.o -o withgc-ffreestanding 告诉编译器不要假设有宿主 C 库,此时这个 Clang 默认不生成 .eh_frame,所以显式加上 -fasynchronous-unwind-tables;栈保护失败时要调用 C 库的 __stack_chk_fail,没有 C 库,只好用 -fno-stack-protector 关掉;-ffunction-sections 让每个函数单独占一个节,供 GC19 按函数删除(第 5 章)。
dead 没有被任何人调用,--gc-sections 会把它删掉,这一点下一节再说。先用 Linux 上的 readelf -lW withgc 看程序头:
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000200040 0x0000000000200040 0x000150 0x000150 R 0x8 LOAD 0x000000 0x0000000000200000 0x0000000000200000 0x0001f8 0x0001f8 R 0x1000 LOAD 0x000200 0x0000000000201200 0x0000000000201200 0x000022 0x000022 R E 0x1000 LOAD 0x000224 0x0000000000202224 0x0000000000202224 0x000000 0x000004 RW 0x1000 GNU_EH_FRAME 0x000190 0x0000000000200190 0x0000000000200190 0x00001c 0x00001c R 0x4 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0
Section to Segment mapping: Segment Sections... 00 01 .eh_frame_hdr .eh_frame 02 .text 03 .bss 04 .eh_frame_hdr.eh_frame_hdr 和 .eh_frame 都在第一个只读的 LOAD 段里,会被装进内存;GNU_EH_FRAME 这个程序头恰好圈住 .eh_frame_hdr,地址 0x200190,大小 0x1c。它的内容(objdump -s -j .eh_frame_hdr withgc):
Contents of section .eh_frame_hdr: 200190 011b033b 1c000000 02000000 70100000 ...;........p... 2001a0 38000000 80100000 4c000000 8.......L...按 LSB 的定义逐个字段拆:
01:version,规定为 1。1b:eh_frame_ptr 的编码,pcrel | sdata4。03:fde_count 的编码,udata4。3b:查找表项的编码,0x30 | 0x0b,datarel | sdata4。在.eh_frame_hdr里,datarel 指相对.eh_frame_hdr起点。1c000000:eh_frame_ptr。字段在 0x200194,0x200194 + 0x1c = 0x2001b0,正是.eh_frame的起点。02000000:fde_count = 2。- 之后是 2 对 (initial_location, FDE 地址),按 initial_location 从小到大排序:
- (0x1070, 0x38):0x200190 + 0x1070 = 0x201200,是
live的地址;0x200190 + 0x38 = 0x2001c8,是.eh_frame里偏移 0x18 处的 FDE。 - (0x1080, 0x4c):0x201210 是
_start,0x2001dc 是偏移 0x2c 处的 FDE。
- (0x1070, 0x38):0x200190 + 0x1070 = 0x201200,是
objdump -t withgc 给出的符号地址可以对上:live 在 0x201200,_start 在 0x201210。
换成 GNU ld(ld.bfd -static --eh-frame-hdr --gc-sections -e _start gc.o -o bfdgc)链接同一个目标文件,表的结构一样,只是布局不同。readelf -x .eh_frame_hdr bfdgc 的输出:
Hex dump of section '.eh_frame_hdr': 0x00402000 011b033b 1c000000 02000000 00f0ffff ...;............ 0x00402010 38000000 10f0ffff 4c000000 8.......L...GNU ld 把 .text 放在 0x401000,.eh_frame_hdr 放在它后面的 0x402000,第一项 initial_location 是 0xfffff000,按 sdata4 读是 −0x1000,0x402000 − 0x1000 = 0x401000。函数可能在表的前面,所以编码要用有符号的 sdata4。另一处差别是 GNU ld 会去掉 FDE 末尾补齐用的 DW_CFA_nop,它输出的 .eh_frame 是 0x40 字节,lld 原样保留,是 0x48 字节。
有了这张表,查找就变成二分:找 initial_location 不大于 PC 的最后一项,跳到它的 FDE,再用 FDE 的 pc_range 确认 PC 确实落在这个函数里。
运行时又怎么找到每个模块的 .eh_frame_hdr?传统的办法是 dl_iterate_phdr。这是 glibc、musl20 等 C 库提供的函数,它依次回调每个已加载的模块(主程序、每个共享库),交给回调函数这个模块的加载基址和程序头表。libgcc 的展开库(源码在 libgcc/unwind-dw2-fde-dip.c,dip 即 dl_iterate_phdr)在回调里先看哪个 PT_LOAD 段包含目标 PC,再找这个模块的 PT_GNU_EH_FRAME,加上基址得到 .eh_frame_hdr 的运行时地址,然后二分查找。开头那个异常从 libvec.so.1 退到 main 时,就是在这一步换成了另一个模块的表。模块不需要在启动时登记,dlopen 进来的库也会自动出现在遍历里。
每次查找都遍历一遍模块并不便宜。glibc 2.35 新增了 _dl_find_object(glibc NEWS):ld.so 自己维护一份按地址排好的模块表,给一个 PC,直接返回所在模块的 .eh_frame_hdr 地址。GCC 12 起的 libgcc 在构建时如果发现 C 库提供了它(源码里以 DLFO_STRUCT_HAS_EH_DBASE 宏为标志),就改用这个接口(libgcc 提交,2022-01);dl_iterate_phdr 那条路径留给 musl、旧版 glibc 这些没有它的 C 库。无论走哪条路,用到的都是程序头:节头表给链接器看,程序头给加载器和运行时看(第 2 章)。
开头说的"段错误时打印调用栈"也是这一套。glibc 的 backtrace() 内部调用 libgcc 的 _Unwind_Backtrace,它用同样的 CFI 一层层往回走,只是每层只记下 PC,不调用 personality。从信号处理函数里回溯时,中间隔着那层信号处理帧:处理函数返回时落到 glibc 的 __restore_rt(movq $15, %rax 加 syscall,即 rt_sigreturn),glibc 为它手写了 augmentation 为 "zRS" 的 CFI,用 DWARF 表达式从内核压在栈上的寄存器现场里取回被打断时的寄存器(sysdeps/unix/sysv/linux/x86_64/libc_sigaction.c);查不到 FDE 时,libgcc 还会比对返回地址处是不是这两条指令(libgcc/config/i386/linux-unwind.h)。
链接器要为 .eh_frame 做的事
回到 .eh_frame 本身。链接器没法把它当作一块数据原样拼接。它是记录组成的序列,记录之间有指针,记录里有指向函数的重定位,运行时还需要一张链接器做的索引。链接器至少要做下面几件事。
第一件是解析记录边界。普通的节,链接器只需整块复制、按重定位改字节。.eh_frame 要按长度字段把它切成一条条 CIE 和 FDE,区分两者(看第二个字段是否为 0),弄清每个 FDE 属于哪条 CIE、覆盖哪个函数,以及每条重定位落在哪条记录里。后面几件事都建立在这一步上。
第二件是配合 GC。第 5 章讲过,--gc-sections 从入口等根出发,顺着重定位标记所有被引用的节,没被标记的删掉。可每个函数的 FDE 里都有一条指向它的重定位,如果把 .eh_frame 当作普通节一起标记,所有函数都会因为"被 .eh_frame 引用"而活下来,GC 就失效了。所以链接器在标记阶段要特殊对待 .eh_frame:FDE 不算作对函数的引用,反过来,函数活着,它的 FDE 才保留,FDE 引用的 LSDA 才跟着被标记;函数被删掉,它的 FDE 也一起删掉。保留下来的展开记录还需要可用的 personality 函数;它是否因其他引用继续存活,要由可达关系和具体实现决定,不能因为位于 CIE 中就把所有 personality 无条件视作根。LSDA 能不能真的随函数一起删,还要看它所在的节:Clang 在 -ffunction-sections 下把每个函数的 LSDA 放进单独的 .gcc_except_table.<函数名> 节,函数在 COMDAT 组里时 LSDA 也进同一个组,有的工具链还用 SHF_LINK_ORDER 标志把它挂到函数的节上;如果一个文件所有函数的 LSDA 都挤在同一个 .gcc_except_table 里,只要有一个函数活着,整个节就得留下。
前面 gc.c 那条命令打印出了被删掉的节:
removing unused section gc.o:(.text)removing unused section gc.o:(.text.dead).text 是 -ffunction-sections 下留空的默认代码节,.text.dead 就是 dead。对比不加 --gc-sections 的链接结果(objdump --dwarf=frames 中 FDE 那一行,三列依次是偏移、长度、CIE pointer):
# 不加 --gc-sections00000018 00000010 0000001c FDE cie=00000000 pc=00201220...002012300000002c 00000010 00000030 FDE cie=00000000 pc=00201230...0020124200000040 00000014 00000044 FDE cie=00000000 pc=00201250...00201262
# 加 --gc-sections00000018 00000010 0000001c FDE cie=00000000 pc=00201200...002012100000002c 00000014 00000030 FDE cie=00000000 pc=00201210...00201222dead 的 FDE(不加 GC 时的第二条)不见了,_start 的 FDE 从偏移 0x40 前移到 0x2c。
第三件是改写指针。删掉记录、拼接多个输入文件之后,每条记录在输出节里的位置都变了。pc_begin 由重定位处理:上面 _start 的 FDE 里,链接器按 R_X86_64_PC32 算出的值让它指向 0x201210。用 objdump -s -j .eh_frame withgc 可以验证:这条 FDE 从 0x2001dc 开始,pc_begin 字段在 0x2001e4,里面存的是 2c100000,0x2001e4 + 0x102c = 0x201210。CIE pointer 则没有重定位,必须由链接器自己重算:同一条 FDE,删 GC 前它的 CIE pointer 是 0x44,删后是 0x30,两者都正好等于"字段位置 − CIE 起点"(0x44 − 0x44 = 0,0x30 − 0x30 = 0)。链接器还要丢弃落在已删除 FDE 里的那些重定位,否则它们会去改写别的记录的字节。
第四件是生成 .eh_frame_hdr。链接器要读懂每条 FDE 的 pc_begin(按 CIE 里 R 给出的编码解码),算出最终地址,排序,写成上面那张表,并生成 PT_GNU_EH_FRAME 程序头。GNU ld 和 lld 都要用 --eh-frame-hdr 选项打开这一步,平时由编译器驱动替你传。在这里的 Linux ELF 工具链中,Clang 的默认驱动规则会传入这一选项;GCC 则看链接方式,gcc -dumpspecs 的链接规则里写的是 %{!static|static-pie:--eh-frame-hdr},即动态链接和 -static-pie 时传,普通 -static 不传。不传时静态程序换用老办法:gcc -static 链接的启动文件是 crtbeginT.o,它弱引用 __register_frame_info,启动时把自己的 .eh_frame 登记给 libgcc。
还有几类情况也落在链接器头上。一是链接器自己生成的代码:第 7 章的 PLT21 桩不来自任何目标文件,没有 FDE,GNU ld 在 x86-64 上默认替它生成一条(--ld-generated-unwind-info),lld 不生成。二是 -r 可重定位链接:输出仍是目标文件,.eh_frame 要保留成后续链接还能处理的形式,也不生成 .eh_frame_hdr。三是 FDE 所指的函数位于一个被丢弃的 COMDAT 组里(第 3 章:同名的组只留一份):这条 FDE 指向的代码已经不在输出里,链接器要像对待被 GC 删掉的函数一样,把它连同它的重定位一起丢掉。
怎样检验展开信息的处理
实现链接器时,展开信息必须和其他节的保留决策一起验证。带 SHF_MERGE 标志的节允许内容合并(第 2、5 章),COMDAT 组选择与 --gc-sections 则可能让函数消失(第 3、5 章)。确定哪些函数存活后,才能确定对应 FDE 的去留,并重建指针和索引;只检查最终节大小无法证明这些关系正确。
一种验证方式是在 C 中调用 _Unwind_Backtrace,观察是否能沿调用链返回,再检查索引和记录的对应关系,并对照 LLD 的保留函数、FDE 数量及 GC 报告。静态产物可以生成 .eh_frame_hdr,也可以使用练习三展示的 crtbeginT.o 登记路径;运行时必须实际找到记录。
C 回溯通过后,还不能据此宣布 C++ 异常可用。后者增加了清理、处理器选择和线程状态等要求,其中线程局部状态正是下一章要处理的存储问题。
重排记录时,三个引用使用三个基点
假设一条存活 FDE 原来从输入 .eh_frame 偏移 0x40 开始。删除前面的死记录后,它在输出 .eh_frame 中移到 0x2c;所用 CIE 仍位于输出节偏移 0。以下数值是坐标推导示例,采用本章的 32 位记录和 pcrel | sdata4 编码。
| 字段 | 输出位置 | 编码依据 |
|---|---|---|
| FDE 的 CIE pointer | FDE 起点后四字节,即节内 0x30 | 从该字段向后退到 CIE:0x30 − 0 = 0x30 |
| FDE 的 initial_location | FDE 起点后八字节,即节内 0x34 | 函数地址减去这个字段自身的地址 |
.eh_frame_hdr 搜索表的 FDE 地址 | 索引中的一个四字节字段 | 使用 `datarel |
若 .eh_frame 的输出地址为 0x402000、函数地址为 0x401100,initial_location 字段位于 0x402034,它应存 0x401100 − 0x402034 = −0xf34,小端字节为 cc f0 ff ff。若 header 起点 H=0x403000,索引中的 FDE 地址项则存 0x40202c − H = −0xfd4,小端字节为 2c f0 ff ff。CIE pointer 仍存正数 0x30,解码时做减法;不能因这三项都与地址有关,就统一用一种相对地址公式。
还有一个需要同步移动的位置:原来针对 FDE 初始位置的重定位位于输入节偏移 0x48。FDE 移到输出偏移 0x2c 后,对应补丁位置是 0x34。记录内相对位置仍为八字节,变化的是记录的输出起点。属于被删除记录的重定位没有输出位置,应当一起丢弃;直接在旧 r_offset 上写入,会修改另一条记录的内容。
从地址到函数名和行号
backtrace() 交出来的只是一串地址。要打印出人能读的调用栈,还得把地址翻译成函数名,最好再带上源文件和行号。这一步查的是另外几张表。
这一节直接运行 Linux glibc 提供的 backtrace 和 backtrace_symbols_fd。musl 不提供这组 execinfo.h 接口,因此此处使用系统 GCC/glibc。源文件 bt.c 保持三个可见调用层级:main → middle → leaf → report,其中 report 是 static 函数。构建时禁止尾调用优化,避免观察用的中间栈帧被消除。
gcc -O1 -g -fno-optimize-sibling-calls bt.c -o btgcc -O1 -g -fno-optimize-sibling-calls -rdynamic bt.c -o bt_rd./bt./bt_rd普通程序输出如下。方括号中的绝对地址受 ASLR22 影响,这里统一写成占位符,模块内偏移来自实际输出:
./bt(+0x11b1) [运行时地址]./bt(+0x11ee) [运行时地址]./bt(+0x1200) [运行时地址]./bt(+0x1212) [运行时地址]/usr/lib/x86_64-linux-gnu/libc.so.6(+0x2a601) [运行时地址]/usr/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x88) [运行时地址]./bt(+0x10c5) [运行时地址]加 -rdynamic 后:
./bt_rd(+0x11b1) [运行时地址]./bt_rd(leaf+0xd) [运行时地址]./bt_rd(middle+0xd) [运行时地址]./bt_rd(main+0xd) [运行时地址]/usr/lib/x86_64-linux-gnu/libc.so.6(+0x2a601) [运行时地址]/usr/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x88) [运行时地址]./bt_rd(_start+0x25) [运行时地址]区别在查的是哪张表。backtrace_symbols_fd 通过 glibc 的 _dl_addr 在已加载模块中定位地址,再查询该模块的 .dynsym。普通构建中 leaf、middle、main 不需要参与动态符号解析,通常不会被导出;-rdynamic 向链接器传入 --export-dynamic,把可导出的全局定义加入动态符号表。
$ readelf --dyn-syms -W bt_rd | grep -E "leaf|middle|main" 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34 (3) 11: 00000000000011f3 18 FUNC GLOBAL DEFAULT 14 middle 17: 0000000000001205 23 FUNC GLOBAL DEFAULT 14 main 18: 00000000000011e1 18 FUNC GLOBAL DEFAULT 14 leaf本次 leaf 从 0x11e1 开始,回溯的返回地址是 0x11ee,差值正好为 0xd。report 是 LOCAL 符号,即使加了 -rdynamic 也不会进入 .dynsym,因此第一层仍只有 +0x11b1。libc 中没有导出名覆盖的地址也以模块内偏移表示。
离线的 addr2line 还能利用完整 .symtab 和 DWARF 行号表。PIE 的模块内偏移就是链接时的虚拟地址,可以直接交给工具;普通进程的绝对运行时地址则必须先减去加载基址。
$ addr2line -f -e bt 0x11b0 0x11b1 0x11edreport/src/bt.c:7report/src/bt.c:7leaf/src/bt.c:10脚本用 -fdebug-prefix-map 把实验目录映射成 /src。0x11b1 是调用 backtrace 后的返回地址,取 0x11b0 则落在调用指令内部。本次 GCC 的行号表把这两个地址都归到第 7 行;“返回地址一定指向下一行源码”并不成立,行号映射取决于编译器生成的 DWARF。需要解释调用位置时仍应以返回地址减 1 为准。
strip --strip-debug 删除调试信息但保留 .symtab,对 report 的查询仍能给出函数名,行号变为 ??:?;普通 strip 再删除 .symtab,没有动态导出名可用时只剩 ??。.dynsym 是运行时所需的表,不能随调试信息一起删掉,所以 bt_rd_strip 仍打印 leaf+0xd 等名称。ASLR 会改变绝对地址,“剥离前后相同”指的是栈层级、名称和模块内偏移。三份文件 bt、bt_nodebug、bt_strip 的本次大小依次为 18096、16008、14464 字节。
发行版利用的正是这种分工:.symtab 和 .debug_* 挪进单独的调试文件,崩溃时只上报地址,由收集端离线翻译,第 11 章讲分离调试文件和 build-id 时再细说。
教学系统常让程序在运行时自己拿到完整信息。CMU 15-410 的 Project 0 要写一个打印符号化调用栈的函数(15-410 第 2 讲讲义),课程提供的 symtabgen.py 在链接之后从可执行文件的 .symtab 取出所有函数符号,再解析 DWARF 得到参数,写回程序里预留的一个全局数组(据 2014 版支持代码的镜像 hgye/p0-traceback)。MIT 6.828 的 JOS 实验 1 Exercise 12 里,内核函数 debuginfo_eip 用 stab_binsearch 在 stabs(DWARF 之前的一种调试信息格式)里二分查找,查出函数名、文件名和行号,学生要补的是查行号那一步;内核链接器脚本在 .stab 两端用 PROVIDE 定义 __STAB_BEGIN__ 等符号,内核靠它们找到这张表(6.828 lab 1)。两者都把通常只留在磁盘上的表放到了运行时看得见的地方。
SFrame:只为回溯的精简格式
再回到性能分析器。.eh_frame 的解释成本不低:每次回溯都要从 CIE 的初始指令开始执行 FDE 的 CFI 程序,还可能遇到 DWARF 表达式(DW_CFA_def_cfa_expression 这类指令携带的小型栈机程序,前面信号帧的 CFI 就用到了)。perf record --call-graph dwarf 因此改为每次采样拷贝一段用户栈(默认 8 KB),事后离线展开;x86-64 的 Linux 内核自 2017 年起改用 ORC 格式回溯,它由 objtool(内核构建时逐条分析指令对栈有什么影响的工具)生成(内核文档)。
第 1 章提到过的 SFrame 是同一思路在用户态的版本:binutils 2.40(2023 年 1 月)加入,由 Oracle 的 Indu Bhagat 和 Weimin Pan 开发(MaskRay 的评述)。它只记录每段指令处 CFA、帧指针和返回地址怎么恢复,函数和每一行(FRE,Frame Row Entry)都按地址排好序,直接二分查表,不执行指令;binutils 2.41 引入第 2 版格式,本文此处讨论这一版及其 x86-64、AArch64 场景;格式与架构支持需要按所用工具版本确认。它表达不了 personality 和 LSDA,也不恢复其他被调用者保存的寄存器,只能用于打印调用栈。对链接器来说,这是又一种需要解析、随 GC 裁剪、拼接后重建索引的节。
结尾:每个线程一份的东西
回头看这一章,栈展开之所以能工作,靠的是编译器和汇编器在每个函数旁边写下一张"如何退回调用者"的表,链接器把这些表拼好、修好指针、建好索引,运行时再通过程序头找到它们。所有的表都是只读的,所有线程共用:两个线程同时抛异常,查的是同一份 .eh_frame,各自退的是自己的栈,因为每个线程有自己的栈和寄存器。
异常处理用到的不只有栈和表。catch 块里写一句 throw;,运行时要知道"当前正在处理的是哪个异常";std::uncaught_exceptions() 要回答"此刻有几个异常已经抛出、还没被接住"。这些状态记在 C++ 运行时的结构 __cxa_eh_globals 里,其中有已捕获异常的链表和未捕获异常的计数,由 __cxa_get_globals 返回:__cxa_begin_catch 把异常挂上"已捕获"链表,throw; 对应的 __cxa_rethrow 从链表头取出要重抛的那个,std::uncaught_exceptions 读那个计数。它不能全进程只有一份:线程 A 正在 catch 块里,线程 B 抛出的异常不该被 A 的 throw; 重抛出去。所以 libstdc++ 的 eh_globals.cc 把它声明成 static __thread abi::__cxa_eh_globals global;(__thread 是 GCC 的扩展关键字),libc++abi 的 cxa_exception_storage.cpp 则用 C++11 的 thread_local:名字只有一个,每个线程各有一份。这样的变量不在栈上,也不能放进普通的 .data 或 .bss,因为那些全进程只有一份。那么编译器为它生成什么样的访问代码,链接器要为它准备什么样的段和重定位,ld.so 和线程库又要在每次创建线程时做什么?这是下一章的问题。
练习
答案在章末。四组练习使用同一台 x86-64 Linux 的 bt.c,可用 前述命令 重建输入。独立检查工具只用于核对索引与记录关系。
练习一,观察。bt_rd 第一层是 +0x11b1,为什么 -rdynamic 没给它名称?用 readelf23 和 addr2line 定位;再预测 bt_rd_strip 的离线查询结果。
练习二,手算。下面是本次 bt 的完整展开表 dump:
bt: file format elf64-x86-64
Contents of section .eh_frame_hdr: 2004 011b033b 48000000 08000000 1cf0ffff ...;H........... 2014 7c000000 5cf0ffff a4000000 6cf0ffff |...\.......l... 2024 bc000000 9cf0ffff 64000000 85f1ffff ........d....... 2034 d4000000 ddf1ffff f8000000 eff1ffff ................ 2044 10010000 01f2ffff 28010000 ........(...Contents of section .eh_frame: 2050 14000000 00000000 017a5200 01781001 .........zR..x.. 2060 1b0c0708 90010000 14000000 1c000000 ................ 2070 30f0ffff 26000000 00440710 00000000 0...&....D...... 2080 24000000 34000000 98efffff 40000000 $...4.......@... 2090 000e1046 0e184a0f 0b770880 003f1a39 ...F..J..w...?.9 20a0 2a332422 00000000 14000000 5c000000 *3$"........\... 20b0 b0efffff 10000000 00000000 00000000 ................ 20c0 14000000 74000000 a8efffff 30000000 ....t.......0... 20d0 00000000 00000000 20000000 8c000000 ........ ....... 20e0 a9f0ffff 58000000 00410e10 8302470e ....X....A....G. 20f0 a0010249 0a0e1041 0e08410b 14000000 ...I...A..A..... 2100 b0000000 ddf0ffff 12000000 00480e10 .............H.. 2110 490e0800 14000000 c8000000 d7f0ffff I............... 2120 12000000 00480e10 490e0800 14000000 .....H..I....... 2130 e0000000 d1f0ffff 17000000 00480e10 .............H.. 2140 4e0e0800 00000000 N.......(a) 解码 .eh_frame 起点与 FDE 数量。(b) leaf 的返回地址是 0x11ee,查询 PC = 0x11ed;用半开区间核对产物中是否存在索引。
答案
练习一
readelf -sW bt_rd 中 report 是 LOCAL,地址 0x1189,大小 88 字节;readelf --dyn-syms -W bt_rd 没有它。addr2line -f -e bt_rd 0x11b0 得到 report 与 /src/bt.c:7,这才是调用 backtrace 的位置。
剥离后的 addr2line -f -e bt_rd_strip 0x11b0 在本次 binutils 2.46 中给出 _start 和 ??:?。工具退回 .dynsym 后使用前面的符号作近似,并不代表该地址真的属于 _start。运行时 _dl_addr 会检查符号范围,所以同一个地址依然显示为没有名字的模块偏移。不要把“工具返回一个名称”直接当成成功恢复了源码身份。
练习二
(a) 表头为 01 1b 03 3b:版本 1,帧指针为 pcrel|sdata4,数量为 udata4,表项为 datarel|sdata4。指针字段位于 0x2008,存储 0x48,所以 .eh_frame = 0x2008 + 0x48 = 0x2050;数量字段为 8。
(b) 表项都以 .eh_frame_hdr 起点 0x2004 为基准。八个 initial_location 依次是 0x1020、0x1060、0x1070、0x10a0、0x1189、0x11e1、0x11f3、0x1205。
lo=0 hi=8 mid=4 [4]=0x1189 <= 0x11ed lo=5lo=5 hi=8 mid=6 [6]=0x11f3 > 0x11ed hi=6lo=5 hi=6 mid=5 [5]=0x11e1 <= 0x11ed lo=6lo=hi=6,取 lo-1=5第 5 项的 FDE 字段为 0xf8,记录地址是 0x2004 + 0xf8 = 0x20fc。
(c) 0x20fc 的长度是 0x14,记录到 0x2114 结束;CIE pointer 位于 0x2100,值 0xb0,因此 CIE 位于 0x2100 - 0xb0 = 0x2050。pc_begin 字段位于 0x2104,存储 0xfffff0dd,作为 sdata4 是 −0xf23:0x2104 - 0xf23 = 0x11e1。pc_range 为 0x12,覆盖 [0x11e1, 0x11f3),查询 PC 确实在其中。
(d) CIE 的代码对齐因子为 1,数据对齐因子为 −8,返回地址列为 16;初始规则是 CFA=rsp+8,rip 存在 CFA−8。FDE 的 00 是 augmentation 长度;48 推进 8 字节到 0x11e9;0e 10 把 CFA 偏移改成 16;49 再推进 9 字节到 0x11f2;0e 08 把偏移改回 8。PC=0x11ed 落在中间区间,所以 CFA=rsp+16,返回地址位于 CFA−8,即当前 rsp+8。工具解码与手算相符:
000000ac 0000000000000014 000000b0 FDE cie=00000000 pc=00000000000011e1..00000000000011f3 DW_CFA_advance_loc: 8 to 00000000000011e9 DW_CFA_def_cfa_offset: 16 DW_CFA_advance_loc: 9 to 00000000000011f2 DW_CFA_def_cfa_offset: 8 DW_CFA_nop练习三
$ ./bt_static[0x40186d][0x4018aa][0x4018bc][0x4018ce][0x401ec1][0x4044ad][0x401745]本次七层都能展开,但没有模块名与函数名;静态程序没有供动态符号化使用的导出表。readelf -lW bt_static 中没有 GNU_EH_FRAME。普通 GCC 静态链接采用 crtbeginT.o 与帧登记机制,展开库可以从登记的信息找到 FDE,并非所有程序都必须依赖程序头中的索引。离线 addr2line 仍可以读文件里的 .symtab 和 DWARF。这个现象与动态程序简单删除索引不是同一条查找路径。
练习四
$ ./bt_nohdr./bt_nohdr(+0x11b1) [运行时地址]本次只剩第一层。readelf -lW bt_nohdr 中没有 GNU_EH_FRAME,虽然磁盘上的 .eh_frame 仍在,动态运行时不会为每次回溯重新扫描整个文件的节头。这个动态程序也没有静态启动文件的帧登记路径,因此无法继续找到主程序的 FDE。去掉 --no-eh-frame-hdr 后七层恢复。关闭编译器的展开表选项也可能只剩一层,但那是函数根本没有 FDE,不能与“表存在却找不到”混为一谈。
参考
- CMU 15-410:《Stack Discipline》,Project 0 部分:从栈中的地址恢复函数名和参数。本章的符号化练习借用了这一问题;2026 秋季版本的相关提问位于幻灯片编号 50,即 PDF 第 44 页。
- MIT 6.828:JOS Lab 1,Exercise 12:通过链接器定义的边界找到调试表,再用
stab_binsearch从指令地址查源码行号,对应本章最后的运行时符号化讨论。
附录:术语与工具
-
CFI — 在栈展开语境中,CFI 指 Call Frame Information,描述怎样从当前帧恢复调用者状态。安全加固语境中的 Control-Flow Integrity 也缩写为 CFI,两者需要按上下文区分。 官方文档。 ↩
-
ELF — ELF(Executable and Linkable Format)规定目标文件、可执行文件与共享对象的结构。通用规则见 gABI,架构相关的调用约定和重定位规则见对应 psABI。 官方文档。 ↩
-
GCC — GCC(GNU Compiler Collection)是一组语言编译器。命令
gcc是驱动入口,会组织编译、汇编和链接;在终端调用它,并不意味着后续工作都在同一个进程里完成。 官方文档。 ↩ -
Clang — Clang 是 LLVM 项目中的 C、C++ 等语言前端及驱动程序。它通常使用集成汇编器,但仍需调用链接器;最终使用哪个链接器取决于目标平台和配置。 官方文档。 ↩
-
GNU — GNU 是 “GNU’s Not Unix” 的递归缩写,指自由软件操作系统项目。GCC、binutils 和 glibc 都属于 GNU 项目,但分别承担编译、二进制处理和 C 运行库职责。 官方文档。 ↩
-
binutils — GNU binutils 是一组处理目标文件的工具,包含汇编器
as、链接器ld,以及readelf、nm、objdump、ar等检查与归档工具。 官方文档。 ↩ -
glibc — glibc(GNU C Library)是许多 Linux 发行版默认使用的 C 库。库的启动文件、共享库和动态链接器共同参与程序构建与运行。 官方文档。 ↩
-
DWARF — DWARF 是调试信息格式,描述源码行、类型、变量与机器位置的关系。它可以随 ELF 保存,但不是 ELF 符号表的别名。 官方文档。 ↩
-
ABI — ABI(Application Binary Interface)规定二进制组件如何协作,包括调用约定、数据布局和文件格式等。它约束编译结果之间的交接,比源码层面的 API 更靠近机器。 官方文档。 ↩
-
CIE — CIE(Common Information Entry)保存一组栈展开记录共用的规则和编码信息。FDE 引用它,再描述特定函数地址范围内的规则变化。 官方文档。 ↩
-
ULEB128 — ULEB128 是无符号整数的变长编码:每字节低七位承载数值,最高位表示是否继续。SLEB128 是相应的有符号形式;解析时需要限制长度并检查溢出。 官方文档。 ↩
-
LEB128 — LEB128(Little Endian Base 128)逐组保存整数的七个有效位,常用于 DWARF 与 WebAssembly。它有无符号和有符号两种形式,不是固定宽度的小端整数。 官方文档。 ↩
-
FDE — FDE(Frame Description Entry)关联一段代码的地址范围与栈展开指令。移动代码或重新组织
.eh_frame时,链接器必须同步更新相关地址和记录间的引用。 官方文档。 ↩ -
psABI — psABI(processor-specific ABI)是特定处理器架构的二进制接口约定。不同架构可以共用 ELF 文件结构,同时拥有不同的指令、调用约定和重定位公式。 官方文档。 ↩
-
PIE — PIE(position-independent executable)是可以在不同加载基址运行的可执行文件。生成 PIE 需要编译与链接选项配合;static-PIE 还需要自身的启动路径完成必要重定位。 官方文档。 ↩
-
LSDA — LSDA(Language-Specific Data Area)保存语言异常处理所需的额外信息,例如异常区域和处理动作。展开器与语言 personality 函数分工使用这些数据。 官方文档。 ↩
-
LLVM — LLVM 是一组编译器与工具链项目的名称,包括优化基础设施、目标代码生成和相关工具。Clang、LLD 与 LLVM IR 各有职责,不能互作同义词。 官方文档。 ↩
-
COMDAT — COMDAT 让工具链表示可供择一保留的重复定义组。ELF 通过 section group 与签名表达相关关系;选择副本时,组内关联内容需要一致处理。 官方文档。 ↩
-
GC — 本文 GC 指 section garbage collection,即链接器从入口和其他根出发保留可达节、删除无用节。它发生在构建阶段,与运行时堆内存的垃圾回收不同。 官方文档。 ↩
-
musl — musl 是 Linux 的一种 C 标准库实现,提供
printf等库函数及运行时支持。本系列在需要分析或链接较小的静态运行库时使用它;普通 Linux 服务器不一定预装 musl。 官方文档。 ↩ -
PLT — PLT(Procedure Linkage Table)是一组函数调用跳板,常与 GOT 和动态符号绑定配合。PLT 存放指令,不能简单理解成另一张纯地址表。 官方文档。 ↩
-
ASLR — ASLR(Address Space Layout Randomization)随机化进程中某些区域的位置。它是操作系统的加载策略,PIE 等产物形式为相应地址变化提供条件。 官方文档。 ↩
-
readelf —
readelf检查 ELF 头、节、段、符号及重定位等结构;它读取文件而不执行其中的程序。GNUreadelf与 LLVMllvm-readelf的显示格式可能不同。 官方文档。 ↩