3030
3131[ ![ GitHub Issues] ( https://img.shields.io/github/issues/RUCICS/LinkLab-2024-Assignment?style=for-the-badge&logo=github )] ( https://github.com/RUCICS/LinkLab-2024-Assignment/issues )
3232
33+ ## 什么是链接?
34+
35+ 链接是将多个目标文件组合成一个可执行程序的过程。在现代软件开发中,我们很少会把所有代码都写在一个文件里 —— 这样既不利于代码复用,也不便于团队协作。相反,我们会将程序分解成多个源文件,分别编译成目标文件,再通过链接器将它们"拼接"在一起。
36+
37+ 链接器的主要工作包括:
38+
39+ 1 . 符号解析:将代码中的符号(如函数调用、全局变量)与它们的定义对应起来
40+ 2 . 重定位:调整符号的地址,确保程序在内存中正确运行
41+ 3 . 布局规划:合理安排各个部分在内存中的位置,并设置适当的访问权限
42+
43+ 这个过程看似简单,但实际上涉及许多复杂的细节:如何处理符号冲突?如何保护代码不被篡改?如何优化内存布局?在这个实验中,你将亲手实现这些功能,深入理解现代程序是如何被组装起来的。
44+
3345## 环境要求
3446
3547- 操作系统:Linux(推荐 Ubuntu 22.04 或更高版本)
@@ -172,21 +184,22 @@ FLE 使用表情符号来标记不同类型的信息:
172184
173185``` cpp
174186struct FLEObject {
175- std::string type; // 文件类型(.obj/.exe)
176- std::map<std::string, FLESection> sections; // 各个节
177- std::vector<Symbol > symbols; // 符号表
178- std::vector<ProgramHeader > phdrs; // 程序头
179- std::vector<SectionHeader > shdrs; // 节头
180- size_t entry = 0; // 入口点(仅可执行文件)
187+ std::string name; // Object name
188+ std::string type; // ".obj" or ".exe"
189+ std::map<std::string, FLESection> sections; // Section name -> section data
190+ std::vector<Symbol > symbols; // Global symbol table
191+ std::vector<ProgramHeader > phdrs; // Program headers (for .exe)
192+ std::vector<SectionHeader > shdrs; // Section headers
193+ size_t entry = 0; // Entry point (for .exe)
181194};
182195
183196struct FLESection {
184- std::vector<uint8_t> data; // 节数据(按字节存储)
185- std::vector<Relocation > relocs; // 重定位表
186- bool has_symbols; // 是否包含符号
197+ std::vector<uint8_t> data; // Section data (stored as bytes)
198+ std::vector<Relocation > relocs; // Relocation table for this section
199+ bool has_symbols; // Whether section contains symbols
187200};
188201
189- // 其他结构体定义请参考 include/fle.hpp,此处略
202+ // ……
190203```
191204
192205我们的实验框架会自动将 FLE 文件加载为内存中的数据结构 `FLEObject`。
@@ -214,15 +227,16 @@ struct FLESection {
2142273. 在生成可执行文件时确定最终的内存布局
215228
216229> [!NOTE]
217- > 在 ELF 格式中,有两种不同的视图:
218- > - 链接视图:由节头表(Section Headers)描述,包含了链接时需要的详细信息,如 `.text`、`.data`、`.bss` 等节
219- > - 运行视图:由程序头表(Program Headers)描述,定义了程序运行时的内存段(segment)
230+ > ELF格式中存在两种重要的视图:
231+ >
232+ > - **链接视图** 由节头(Section Headers)表描述,包含链接时需要的详细信息,如 `.text`、`.data`、`.bss` 等节。这些信息主要用于链接和调试,在最终的可执行文件中并非必需。
233+ > - **运行视图** 由程序头(Program Headers)表描述,定义了程序运行时的内存段(segment)。它描述如何将文件映射到内存,是加载程序(loader)必需的信息,在可执行文件中不可或缺。
220234>
221- > 在标准的 ELF 中 ,一个内存段通常会包含多个具有相同权限需求的节。例如,可读写的数据段通常同时包含 `.data` 和 `.bss` 节,它们会被映射到同一块连续的内存区域中。这种设计可以减少内存碎片,简化程序的加载过程。
235+ > 在标准的ELF中 ,一个内存段通常会包含多个具有相同权限需求的节。例如,可读写的数据段通常同时包含 `.data` 和 `.bss` 节,它们会被映射到同一块连续的内存区域中。这种设计可以减少内存碎片,简化程序的加载过程。
222236>
223- > 为了让同学们专注于理解链接的基本概念,我们的 FLE 格式采用了简化的设计 :
237+ > 为了让同学们专注于理解链接的基本概念,我们的FLE格式采用了简化的设计 :
224238> - 在任务六之前,所有内容都放在一个名为 `.load` 的节中
225- > - 在任务六中,我们将代码和数据分开放入不同的节( `.text`、`.data` 等)
239+ > - 在任务六中,我们将代码和数据分开放入不同的节( `.text`、`.data` 等)
226240> - 但始终保持节和段的一对一映射:每个节都被单独映射为一个内存段
227241>
228242> 这种简化虽然不够优化,但更容易理解和实现。在完成实验后,你会对这两种视图都有深入的认识。
@@ -285,9 +299,20 @@ void count() { // 全局函数
285299
286300提示:
287301
288- - 使用 ` std::setw ` 和 ` std::setfill ` 格式化输出
289- - 根据 section 字段判断符号位置
290- - 未定义符号的 section 为空
302+ - 格式化输出可以使用:
303+ ``` c
304+ printf ("%016lx", addr); // C 风格,输出16位的十六进制数,左侧补0
305+ ```
306+ 或
307+ ```cpp
308+ std::cout << std::setw(16) << std::setfill('0') << std::hex << addr; // C++ 风格
309+ ```
310+ - 根据符号的 section 字段判断其位置:
311+ - ".text" - 代码段
312+ - ".data" - 数据段
313+ - ".bss" - BSS段
314+ - 空字符串 - 未定义符号
315+ - 未定义符号的 section 为空字符串
291316
292317### 验证
293318
@@ -415,20 +440,15 @@ Relocation {
415440- 验证截断后的值是否合法
416441- 将 0x401000 写入重定位位置
417442
418- 最后,生成可执行文件。在内存中,我们需要创建一个新的 `FLEObject`,设置其 `type` 为 `.exe`,并生成正确的程序头 (`ProgramHeader` 结构)以便加载器能够正确地加载程序。
443+ 最后,生成可执行文件。在内存中,我们需要创建一个新的 ` FLEObject ` 。 ` name ` 可以设置为任意字符串(如 "a.out"),但其 ` type ` 必须为 ` .exe ` ,并且需要生成正确的程序头 (` ProgramHeader ` 结构)以便加载器能够正确地加载程序。
419444
420445在这个阶段,我们只生成一个程序头来描述 ` .load ` 段。它需要指定:
421446- 段名(name):` .load `
422447- 加载地址(vaddr):我们使用 0x400000
423448- 段的大小(size):合并后的总大小
424449- 权限标志(flags):在这个阶段,我们简单地赋予 RWX(可读、可写、可执行)权限(0b111,即十进制的 7)
425450
426- > [!IMPORTANT]
427- > 注意程序头(Program Headers)和节头(Section Headers)的区别:
428- > - 节头(Section Headers)描述了文件中各个节的属性,主要用于链接和调试。
429- > - 程序头(Program Headers)描述了程序运行时如何将文件映射到内存,是加载程序(loader)必需的信息。
430- >
431- > 在最终的可执行文件中,节头并不是必须的,程序头是必须的。
451+ > [ !NOTE] 由于在最终的可执行文件中,节头并不是必须的,所以在这里你可以不生成节头。在这里我们只介绍程序头怎么填。
432452
433453此外,可执行文件必须指定一个入口点(entry point),也就是程序开始执行的位置。在 x86-64 程序中,这个入口点通常是名为 ` _start ` 的函数。链接器需要在合并后的符号表中找到 ` _start ` 符号,并将其地址(基地址 + 符号偏移)设置为程序的入口点。如果找不到 ` _start ` 符号,应该报错,因为这意味着程序缺少必需的入口点。
434454
@@ -437,14 +457,19 @@ Relocation {
437457``` json5
438458{
439459 " type" : " .exe" , // 表明这是一个可执行文件
440- "phdrs": [{ // 程序头
441- "name": ".load",
442- "vaddr": 0x400000, // 固定的加载地址
443- "size": <总大小>, // 合并后的总大小
444- "flags": 7 // 可读、可写、可执行
445- }],
446- ".load": [/* ... */], // 合并后的 .load 节内容,应该只包含机器码
447- "entry": <入口地址> // 程序的入口点
460+ " phdrs" : [
461+ {
462+ " name" : " .text" , // 程序头
463+ " vaddr" : 4194304 , // 固定的加载地址 0x400000
464+ " size" : 19 , // 合并后的总大小
465+ " flags" : 5 // 可读、可执行
466+ }
467+ ],
468+ " .text" : [ // 合并后的节内容,只包含机器码
469+ " 🔢: 55 48 89 e5 bf 64 00 00 00 b8 3c 00 00 00 0f 05 " ,
470+ " 🔢: 90 5d c3 "
471+ ],
472+ " entry" : 4194304 // 程序的入口点 0x400000
448473}
449474```
450475
@@ -524,7 +549,7 @@ Relocation {
5245491 . 代码可以加载到内存的任何位置,因为跳转距离是相对的
5255502 . 指令更短,因为偏移量只需要 32 位(而不是完整的 64 位地址)
526551
527- 计算公式很简单:
552+ 计算公式���简单���
528553
529554```
530555偏移量 = 目标地址 + 附加值 - 重定位位置
@@ -842,11 +867,13 @@ void hack() {
842867 - 合理安排节的顺序以减少内存碎片
843868
844869> [ !IMPORTANT]
845- > ` .bss ` 节在链接过程中需要特殊处理。在目标文件中, ` .bss ` 节并不实际存储数据,而只在节头中记录所需的大小。这是因为 ` .bss ` 节专门用于存放未初始化或初始化为 0 的全局变量,没有必要在文件中实际存储这些 0 值 。
870+ > ` .bss ` 节的处理相对特殊。由于它专门用于存放未初始化或初始化为 0 的全局变量,目标文件中只需在节头( ` shdrs ` )记录所需的总大小,而无需存储实际数据 。
846871>
847- > 链接器在处理 ` .bss ` 节时,主要工作是计算符号的新位置。由于符号在 ` .bss ` 节中的排列可能并不是连续的,因此每个输入文件的符号表都会显式记录该符号在其所属 ` .bss ` 节中的位置(即偏移量 ` offset ` )。链接器在合并所有输入文件的节时,需要根据各个节在最终文件中的位置以及每个符号的偏移量,计算符号的新地址 。
872+ > 链接器在处理 ` .bss ` 节时,主要是计算符号的新位置。可以直接使用符号在原文件中的偏移量( ` offset ` ),配合节在最终文件中的位置来计算符号的新地址。这些地址会用于后续的重定位计算,但 ` .bss ` 节本身在最终的可执行文件中仍然不包含实际数据 。
848873>
849- > 这些新计算出的位置会被用于重定位时的地址计算,但 ` .bss ` 节本身在最终的可执行文件中仍然不包含实际数据。操作系统会在加载程序时,自动将 ` .bss ` 节对应的内存区域初始化为 0。
874+ > 计算节的最终位置很简单:由于 ` .bss ` 节只关心总大小,我们只需要从节头获取 size 信息,然后在布局时为其分配相应大小的空间即可。最终每个符号的绝对地址就是:节的基地址 + 符号在节内的偏移量。
875+ >
876+ > 操作系统会在加载程序时,自动将 ` .bss ` 节对应的内存区域初始化为 0。
850877
851878完成后,你的链接器就能生成具有正确内存布局和访问权限的可执行文件了。运行测试来验证:
852879
954981 - 打印中间过程的重要信息
955982 - 分步骤实现,先确保基本功能正确
956983
984+ ### 手动测试
985+ 每个测试用例目录下都有 ` config.toml ` 文件描述了测试的执行步骤。建议的手动测试方法是:
986+
987+ 1 . 进入测试用例目录(如 ` tests/cases/2-single-file/ ` )
988+ 2 . 先执行 ` make test ` 生成所需的 ` .fle ` 文件(在 ` build ` 目录下)
989+ 3 . 然后就可以直接运行你的链接器:` ./ld build/a.fle build/b.fle -o build/out.fle `
990+ 4 . 使用 ` readfle ` 检查输出文件:` ./readfle build/out.fle `
991+
992+ 这样可以更方便地调试单个测试用例。如果需要完整的测试流程,可以参考对应测试目录下的 ` config.toml ` 文件。
993+
957994## 进阶内容
958995
959996完成了基本任务后,你可以尝试:
0 commit comments