写ELF解析写到第四十八篇,按理说常规的节已经翻来覆去讲得差不多了。但每次我拿 readelf -SW 扫一个真实的可执行文件,在符号表后面总能看到一个不太起眼的 .gcc_except_table,很多同学第一反应是不知道它干什么,第二反应是觉得它跟调试信息有关,第三反应就是直接忽略。偏偏C++异常相关的疑难杂症,十有八九最后都要回到这张表上来。
简单说,.gcc_except_table 是GCC为C++异常处理生成的一张“只读动作表”,它记录了每个可能抛异常的函数内部,哪些PC范围对应哪个处理入口,以及这条路径上需要执行哪些析构、匹配哪个catch类型。它和 .eh_frame 这一对搭档,共同支撑起了你在C++里写的 try/catch/throw,属于所谓的“零成本异常”模型中最核心的数据结构之一。这篇文章适合已经读得懂 .eh_frame、想进一步追查异常崩溃栈的程序员,也适合在嵌入式里折腾C++异常、用EIDE这类插件调试ELF/AXF却总对不准符号的开发者。看完你应该能自己动手解析一个异常表的原始字节,也知道遇到诡异崩溃时该往哪个方向查。
1. 先搞清 .gcc_except_table 在异常处理链路里的位置
1.1 异常处理不是“编译器魔法”,是一套查表机制
很多人以为C++代码里的 throw 是直接跳到 catch 的,代码生成阶段编译器就把跳转关系定死了。真不是这么回事。C++异常在主流ABI里走的是一套运行时查表流程:throw 本质上调用的是 __cxa_throw,它会交给 libgcc 的 unwinder 去逐帧回溯栈,每回溯到一帧,就要判断“当前这一帧有没有对应的处理逻辑”。
判断依据是什么呢?就是 .eh_frame 里的 FDE 记录。FDE 描述了当前函数的栈布局、寄存器恢复规则、调用者位置等信息,但只凭这些还不够——FDE 能告诉你栈怎么展开,却不能告诉你展开到这里之后,要不要执行某些析构、要不要停下来匹配 catch。于是FDE里有一个增广数据字段,里面可以放一个指针,指向当前函数专属的“语言特定数据区”,也就是LSDA。而这个LSDA实际存放的位置,就是 .gcc_except_table 节。
所以链路是这样:PC在某个函数里触发异常回溯,unwinder从 .eh_frame 中找到当前函数的FDE,从FDE的增广字段里读出LSDA地址,再由personality函数解析LSDA,返回“这个PC应该去哪个landing pad继续跑”。LSDA就是被personality函数按约定格式解析的那段数据,它的家就在 .gcc_except_table 节里。
1.2 谁生成它、谁消费它、谁来销毁它
实际生成 .gcc_except_table 的是GCC编译器本身。你在编译一个带 try/catch、或者带自动变量析构的函数时,编译器会把需要异常处理的函数拆成若干“调用点区域”,然后为每个区域生成一条LSDA记录。这些记录被集中放到目标文件的 .gcc_except_table 节里,链接之后再和其他同节数据合并,最终出现在可执行文件的只读数据段中。
消费它的是personality函数。C++环境下通常就是 __gxx_personality_v0,这个符号在 libstdc++ 里。它在 unwinder 的两阶段查找中都会被调用:第一阶段在栈上寻找能处理当前异常的handler,第二阶段负责真正执行清理动作或跳入catch代码。它手里没有别的资料,只能依靠 FDE 传进来的 LSDA 指针去解析字节。
至于“谁来销毁它”——它只是个节,程序启动时不需要构造,退出时不需要释放。真正要关心的是这把表在动态库里怎么定位、在链接脚本里怎么保留,以及在strip的时候别被误删。不少人以为它跟调试节一样可以随便剥离,这会埋下大雷:strip --strip-debug 默认不会删它,但如果你手动 --remove-section 删了它,或者链接脚本没把它保留在可读段里,那么运行时一旦异常触发,unwinder能找到FDE却找不到LSDA,轻则异常匹配不上,重则直接崩溃。
1.3 它不是ELF规范强制的标准节
需要特别说明,ELF标准本身并没有规定必须有一个叫 .gcc_except_table 的节。你在System V ABI里翻不到这个节名的强制性定义,它属于GCC工具链在实践中的扩展约定。正因如此,Clang/LLVM 工具链为了兼容GCC的异常处理模型,在Linux等平台上也生成了同名节,字节布局基本一致,因为它们都要遵守同一套Itanium C++ ABI的LSDA约定。
这种“非标准但事实上标准”的节,往往才是最值得钻研的。.eh_frame 有自己对应的 PT_GNU_EH_FRAME 程序头,加载器会特别关照;而 .gcc_except_table 没有独立程序头,被当作普通只读数据放在某个 PT_LOAD 段里。这意味着如果链接脚本写得不严谨,异常表完全可能被放到一个不合适的地址区域,或者被错误合并,最终导致personality函数拿到一段错乱的数据。这里先留个印象,后面编译链接部分我会专门展开坑点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 逐字节拆解:从 LSDA 头到 Call Site Table
2.1 先看三个类型编码字节,它们决定整张表怎么读
在所有理论讲解之前,得先建立一种“手撕字节”的视角。你可以把 .gcc_except_table 看成一条由很多段LSDA拼接起来的字符串,一段LSDA就对应一个需要异常处理的函数。虽然每段LSDA的长度不同,但前三个字节有相对固定的语义,理解好这三个字节,后面解析就有抓手了。
下面是一段典型的 x86-64 平台GCC 12编译结果里能看到的原始字节序列:
code复制ff 9b 1b ...
这三个字节的含义分别是LPStart编码、TType编码和CallSite编码。第一个字节 0xff 表示LPStart编码被省略;第二个字节 0x9b 表示类型表条目使用 indirect | pcrel | sdata4 方式编码,这在共享库里尤其常见;第三个字节 0x1b 表示调用点表里的字段使用 pcrel | sdata4 编码。不同的架构、不同的编译选项会产生不同编码值,但解析原理一样。
编码字节是整个LSDA的“自描述头”。personality函数拿到LSDA后,必须先读懂这几个编码,才能知道后续字段的长度与基准值。这点很像你打开一个压缩文件先看magic和版本号,或者读网络协议先看header里的长度字段。如果你在十六进制编辑器里定位到一段以 ff 9b 1b 开头的连续数据,那多半就是某个函数的异常表开头了。
为了便于对照,我整理了一个常见的编码值速查表,平时看字节时用得上:
| 编码字节 | 含义 | 在LSDA中常见用途 |
|---|---|---|
| 0xff | DW_EH_PE_omit | 表示LPStart区域省略 |
| 0x9b | indirect + pcrel + sdata4 | TType表条目编码 |
| 0x1b | pcrel + sdata4 | CallSite表字段编码 |
| 0x01 | uleb128 | 某些架构下的CallSite字段编码 |
| 0x03 | udata4 | 非PC相对场景的定长字段 |
2.2 Call Site Table 的四元组:区间、跳板与动作
在三个头部编码字节之后,GCC会安排一段被称作 Call Site Table 的区域。先是一个变长编码的长度字段,标识整张调用点表占多少字节,然后就是若干条定长记录。对于上面 0x1b 的编码,每条记录是16字节,一条记录对应代码中的一个“危险区间”。
一条四元组记录大概是这样的:
code复制调用点起始偏移, 调用点长度, landing pad偏移, action偏移
“调用点起始偏移”和“调用点长度”合在一起,描述的是当前函数代码里某个可能抛出异常的范围。范围往往就是一条 call 指令,也可能是一段连续指令。假设异常发生在该范围内,personality就用这条记录去引导后续流程。“landing pad偏移”指向的是编译器为这个调用点生成的清理或处理入口,也叫着陆垫。如果值是0,表示这里没有着陆垫,不需要特殊处理。“action偏移”则指向动作表,动作用于完成析构对象的筛选和清理调用,这个后面单独说。
注意这几个偏移量大多数是相对函数起始地址的,而不是LSDA自身的地址。所以解析时一旦拿到函数起始地址,就能把表里的相对偏移换算成真实虚拟地址。很多人在逆向或崩溃分析时看异常表看不懂,是因为忘了把偏移加回函数基址。
为方便理解,可以画一条对应关系在心里:代码里有一个调用 may_throw() 的位置,它可能在函数0x1200偏移处占据5字节;那么这条CallSite记录大致就会写成起始偏移0x1200、长度5、landing pad指向0x1800、action指向动作表中的某条记录。当回溯的PC落在0x1200到0x1205之间时,personality会认为这一帧有处理异常的可能。
2.3 Action 与 TType:catch类型匹配不能只看名称
在Call Site Table后面紧跟着的是动作表,也就是Action Table。动作表中的一条记录由两部分组成:一个是next action偏移,用来把多个动作串成链;另一个是filter值,用来对应异常类型表中的某一项。别小看这两块,C++里同一个try块后面挂多个catch子句时,编译器不是生成多个独立landing pad,而是通过动作链把候选类型串起来。
举个例子,假设代码里写的是 catch (A&) catch (B&) catch (...),编译器很可能只生成一个landing pad,然后用一条动作链表示“先匹配A,不匹配再匹配B,最后走catch-all”。personality会沿着动作链逐个检查当前异常类型是否匹配。匹配成功才进入landing pad,匹配失败就继续回溯上一层函数。
TType表存的就是这些待匹配类型的信息。表里的每个条目不一定直接放 std::type_info 的地址,为了支持动态库、支持地址随机化,它往往存的是一种相对间接的编码,例如上面说的 0x9b。这也是为什么很多简单解析器读到类型表就停住——不能直接把字节强转指针,要先按DWARF异常编码规则做一次基址换算,然后通过间接指针取到真正的type_info对象。
实际调试中我最常遇到的情况是,类型表和动作表在多个动态库之间互相引用时,如果某个库的type_info被折叠或重复,就会发生“明明在同一个程序里catch不到另一个模块抛出的异常”这种诡异问题。这时候单看 .gcc_except_table 原始字节帮不了太多,但配合 gdb 在 __cxa_throw 和 __gxx_personality_v0 上打断点,就能很容易看到personality返回了继续回溯而不是handler found。
3. 实操解析:readelf、objdump 和一个小脚本
3.1 先查节表,再按节名直接看原始字节
很多同学的调试习惯是把ELF文件丢到IDA里看反汇编,遇到异常表就不知所措。其实命令行工具已经够用了,第一步永远是确认这个节是否存在、大小多少、权限如何。用readelf看一眼最直观:
bash复制readelf -SW a.out | grep gcc_except_table
输出大致会看到类似这样的行:
code复制 [29] .gcc_except_table PROGBITS 0000000000004c00 00004c00 00000230 00 A 0 0 4
其中 A 标志说明这个节在进程运行时要分配内存,也就是会被映射进可读的只读段;后面的 AL 对齐值为4字节,这是因为每段LSDA内部字段是按4字节或变长编码组织的。如果你的程序里完全没有C++异常相关代码,这个节可能不存在,或者大小是0,这都正常。
接下来就是原始内容:
bash复制objdump -s -j .gcc_except_table a.out
或者:
bash复制readelf -x .gcc_except_table a.out
你会看到一长串十六进制,其中每段LSDA都以一个省略LPStart的编码字节开头,随后是两个编码字节,再后面是变长长度字段。如果对某个函数特别好奇,可以先用 objdump -d 定位到该函数起始地址,再到异常表里找对应的LSDA块。不同函数对应哪段LSDA,并不是顺序严格对应的,更可靠的方法是从 .eh_frame 的FDE增广数据里反过来找。
3.2 用 Python 写一个只读不写的简易解析器
光看十六进制还是不太直观,我建议自己写一个二三十行的小解析脚本。下面这段脚本只负责解析LSDA头部和CallSite四元组,足够让你对每个函数的异常区间有个整体印象:
python复制import struct
def read_uleb128(data, pos):
result = 0
shift = 0
while True:
b = data[pos]
pos += 1
result |= (b & 0x7f) << shift
if not (b & 0x80):
return result, pos
shift += 7
def parse_lsda_blob(blob, func_addr=0):
pos = 0
lp_start_enc = blob[pos]; pos += 1
ttype_enc = blob[pos]; pos += 1
cs_enc = blob[pos]; pos += 1
cs_len, pos = read_uleb128(blob, pos)
cs_start = pos
cs_end = cs_start + cs_len
print(f"LPStartEnc=0x{lp_start_enc:02x} TTypeEnc=0x{ttype_enc:02x} CallSiteEnc=0x{cs_enc:02x}")
print(f"CallSiteTableLen={cs_len}")
while pos < cs_end and pos + 16 <= cs_end:
start, length, landing, action = struct.unpack_from("<iiii", blob, pos)
start_va = func_addr + start if func_addr else start
landing_va = func_addr + landing if landing else 0
print(f" start=0x{start_va:08x} range={length} landing=0x{landing_va:08x
