[链接器的世界-实验篇10] 不再原样复制:常量合并与展开表重建
本实验处理两类需要改写的输入:可合并常量与栈展开记录。常量合并后,引用必须映射到新位置;GC 删除代码后,相关 FDE 必须退出输出,保留记录的指针和索引也要重建。合并的通用知识见原理篇 05,CIE/FDE 的逐字节解析见原理篇 08。
起点:class10 代码与测试,位于 private 仓库,需要访问权限。本章从 class8 的 ET_EXEC 前端出发,使用 class6 布局与 class5 重定位;没有合入 class9 的静态 PIE。节分类策略和 CLI 已提供。
实现任务
| 任务 | 接口 | 要达到的结果 |
|---|---|---|
| C10.1 | split | 将受支持的 merge section 分成包含完整字节的 Piece |
| C10.2 | merge、locate | 去重整块内容,记录输入 Piece 到输出组的映射,保留片内偏移 |
| C10.3 | frames | 按合同验证 CIE/FDE、变长字段与关联重定位 |
| C10.4 | unwind_sizes、emit_unwind | 预留准确空间,输出共享 CIE、重写后的 FDE 和排序索引 |
| C10.5 | lower_prepared | 处理存活的合并节、筛选 FDE,完成布局与修补,并保留合并偏移映射 |
提供的 link 先准备输入,再调用 lower_prepared,最后由 Lowered::emit 写出 ELF 头与 PT_GNU_EH_FRAME。Lowered 中的 image、entry_address、merged 和 map 属于同一次布局;输入元数据保持不变,后续阶段可以沿 merged 查找合并后的内容位置。
完整类型、支持子集与错误顺序见 class10 README。合并按 flags、entry_size 和规范化 alignment 分组,不按节名;只合并完整 Piece,不做字符串后缀合并。手册的 输入到输出映射例子 逐项计算去重、片内偏移与对齐后的坐标。引用普通符号与 STT_SECTION 时,输入偏移及 addend 的映射方式不同,必须遵循手册。
展开子集采用 32 位记录、指定 CIE 版本与 zR/0x1b 编码,以及每条 FDE 初始位置上的 PC32。手册的 记录重定位与索引构建 用两条 FDE 演算 CIE 共享、字段重写与索引排序。Personality 和 LSDA 不受支持,不能由本章结果推断 C++ 异常可用。展开记录是 annotation,不通过自身的引用把原本已死的代码重新保活。
布局接口 lower_with 还接受已验证的折叠映射与代码顺序,默认入口 lower_prepared 传入空映射和空顺序。它必须同时重定向代码引用、删除折叠副本的 FDE,并保留合并分片的坐标;不能再执行一次独立布局。折叠是否安全由调用阶段判断,相同机器码并不保证展开描述兼容。完整前置条件与处理规则见 README。
一个 locate 算例
假设同一组字符串节依次含有 hello\0、world\0,以及第二个输入节中的 world\0、hi\0、hello\0。输出组会依次得到 hello\0world\0hi\0:第二个输入的 world\0 映射到输出偏移 6,hi\0 映射到 12,hello\0 回到 0。若重定位引用第二个输入节偏移 8,它落在 hi\0 内,结果是输出组偏移 14;若引用等于某个 piece 的结束位置,则归入下一个 piece,而不是前一个 piece 的最后一个字节。这个例子说明 locate 保存的是“piece 起点映射 + 片内偏移”,不能只用整节起点相加。
emit_unwind 的索引也使用最终坐标:保留的 FDE 先按 pc_begin 排序,再把每条记录的起始地址和 .eh_frame 中对应记录的位置编码进 .eh_frame_hdr。被 GC 删除的代码没有 FDE;被合并或折叠的副本也不能留下第二条指向同一地址的记录。
实现时保持的两张映射表
本章同时维护两种输入到输出的关系。merge section 需要 SectionId + input_offset → group + output_offset,展开记录需要 FDE → code section/range 以及 FDE → emitted CIE。前者决定字符串和重定位最终指向哪里,后者决定运行时能否从指令地址找到正确的 CFI;二者都不能用“整节起点加原偏移”代替。
因此实现顺序应是:先验证并切分输入,再建立去重映射;随后只为存活代码筛选 FDE,布局完成后重写 CIE 距离、初始地址和索引。原生测试同时检查共享常量地址、死代码 FDE 的消失和 PT_GNU_EH_FRAME 的运行时查找,三者缺一都不能说明重写正确。
从任务到测试
| 任务 | 关键测试 | 测试所证明的性质 |
|---|---|---|
C10.1 split | split_strings_and_constants、split_rejects_unsupported_shapes | Piece 覆盖完整输入记录,边界和不支持的形状不会被静默接受。 |
C10.2 merge/locate | merge_shares_pieces_in_first_use_order、locate_maps_interior_offsets | 去重顺序稳定,输入 Piece 内部偏移映射到唯一输出坐标。 |
C10.3 frames | frames_decode_cies_and_fdes、frames_reject_malformed_unsupported_and_relocations | CIE/FDE 关联、字段宽度和重定位边界被验证,损坏记录被拒绝。 |
C10.4 unwind_sizes/emit_unwind | unwind_shares_cies_and_sorts_search_table、unwind_rejects_ranges_overlap_and_reservations | 预留大小等于最终编码,索引按 PC 排序且不越过保留区域。 |
C10.5 lower_prepared | dead_merge_sections_and_fdes_contribute_nothing、runtime_finds_live_fdes_through_its_program_headers | GC 后死节和死 FDE 消失,运行时能从程序头找到存活 FDE。 |
局部测试证明映射规则,原生测试证明映射在布局和运行时查找中仍保持一致。
验收及其含义
在克隆后的仓库根目录、原生 x86-64 Linux 上执行:
cargo test --locked -p class10cargo test --locked -p class10 --releasepython3 scripts/grade.py class10合同测试检查分块、去重顺序、片内映射、记录截断和重定位归属,以及 CIE 共享、FDE 范围、索引顺序和保留区域。
原生测试检查跨对象常量共享地址、死合并节和死函数 FDE 的删除。提供的程序从 AT_PHDR 找到 PT_GNU_EH_FRAME,再检查自己的存活函数对应的索引和记录;GNU ld 产物作为独立对照。这检验运行时能找到并解释本题的索引,不是完整异常运行库测试。
没有 split/annotation 输入的样本要求 bytes 和 map 等于 class8;其他测试检查不支持的输入和 CLI 映射。记录数、函数范围、执行行为应一起核对,不能只以合并后文件更小作为通过依据。