内容提要
本文介绍如何开发一个最简静态链接器,输入单个 ELF .o 文件,输出可执行文件。以汇编版 Hello World 为例,用 readelf 分析目标文件的节、符号表和重定位,对比 ld 链接结果,说明需解析 ELF、规划节与段布局、计算地址并完成重定位,最后写出可执行文件。作者用 Rust 实现,约 200 行代码。
延伸解读
从最小案例理解链接器核心任务
文章以单个 ELF .o 文件链接为可执行文件为例,剥离了库依赖和动态链接等复杂因素,让读者聚焦链接器最本质的工作:解析输入、规划输出布局、计算地址、完成重定位。这种从最小可行案例入手的方式,有助于建立对链接过程的直观认识,避免一开始就陷入多文件、符号冲突等细节。
重定位与符号表:链接器的关键操作对象
文中通过 readelf 输出展示了 .rela.text 中的 R_X86_64_32S 重定位条目和符号表。链接器需要根据最终确定的地址,将符号引用替换为实际地址,并检查是否溢出。这解释了常见的“relocation truncated to fit”错误的来源,也说明符号表在链接过程中提供符号到地址的映射,是解析和重定位的基础。
可执行文件布局:段与节的区别
对比 .o 和可执行文件,可执行文件多了 program header,用 segment 描述内核如何加载程序。文章指出,链接器需将相同权限的 section 合并到 segment,并按页对齐。这揭示了节(section)用于链接视图,段(segment)用于执行视图,理解这一点有助于分析程序的内存布局和加载行为。
实现建议与工具运用
作者用 Rust 和 object 库约 200 行代码实现了该链接器,并建议在实现过程中灵活使用 readelf 和 objdump 验证输出。对于想动手实践的读者,这是一个可操作的起点。同时,文章提到符号表在简单场景下可以删除,说明链接器实现可以根据需求裁剪功能,降低初期复杂度。
Q&A
开发一个最简单的静态链接器需要完成哪些核心步骤?
核心步骤包括:解析输入的 ELF 文件,提取 section 和重定位信息;规划输出 ELF 的布局,计算各 section 和 symbol 的地址;根据地址完成重定位计算并填入数据;生成 program header 描述 segment;最后将 ELF 内容写入文件。
为什么汇编代码中调用 write 系统调用需要设置 rdi、rsi、rdx 和 rax 寄存器?
在 amd64 Linux 下,系统调用号存放在 rax 中,参数按顺序存放在 rdi、rsi、rdx、r10、r8、r9 中。write 系统调用需要三个参数:文件描述符、缓冲区地址和写入长度,因此分别设置 rdi=1(stdout)、rsi=hello 字符串地址、rdx=13(长度),并将 write 的系统调用号 1 放入 rax。
ELF 目标文件中的重定位条目(如 R_X86_64_32S)有什么作用?
重定位条目用于记录指令中引用了但地址尚未确定的数据或符号。例如 R_X86_64_32S 表示需要将符号的 64 位地址的低 32 位写入指令中,并检查符号扩展后是否等于原地址。链接器在确定地址后,根据重定位条目计算并填入正确地址。
链接器如何规划可执行文件的 segment 布局?
链接器将具有相同访问权限的 section 合并到一个 segment 中,并确保 segment 按页对齐(如 0x1000)。例如,.text 放在可读可执行的 segment,.rodata 放在只读 segment。加载基址通常为 0x400000,然后依次对齐放置各个 segment。
链接后的可执行文件与目标文件在 section 和重定位方面有什么主要区别?
可执行文件删除了未使用的 section(如 .data、.bss),并且不再包含重定位信息,因为所有重定位已在链接时完成。此外,可执行文件增加了 program header 来描述 segment,用于指导内核加载。
实现链接器时,如何确定各个 section 和 symbol 在最终内存中的地址?
首先确定加载基址(如 0x400000),然后按照 ELF 文件布局依次放置 file header、program header 和各个 section,同时保证 segment 按页对齐。通过累加各 section 的大小并考虑对齐,即可计算出每个 section 的虚拟地址,symbol 的地址则根据其所在 section 的地址加上偏移得到。