@@ -328,6 +328,8 @@ make test_1
328328
329329## 任务二:实现基础链接器
330330
331+ ### 示例:
332+
331333让我们从一个简单的例子开始理解链接器的工作原理。假设有这样一个程序:
332334
333335``` c
@@ -364,7 +366,7 @@ void _start()
364366}
365367```
366368
367- 当这个文件被加载到内存时,它会被解析成一个 ` FLEObject ` 结构。如上文所述,其中的 ` symbols ` 成员是一系列 ` Symbol ` 结构的列表,每个 ` Symbol ` 结构存储着符号及其定义位置的信息。在上例中,这个 ` symbols ` 列表仅包含一个 ` Symbol ` 对象 ` str ` ,内容如下 :
369+ 当这个文件被加载到内存时,它会被解析成一个 ` FLEObject ` 结构。如上文所述,其中的 ` symbols ` 成员是一系列 ` Symbol ` 结构的列表,每个 ` Symbol ` 结构存储着符号及其定义位置的信息。在上例中,假设我们用 ` obj ` 来表示这个 ` FLEObject ` ,那么 ` obj.symbols[0] ` 是 :
368370
369371``` cpp
370372Symbol {
@@ -378,7 +380,7 @@ Symbol {
378380
379381这个对象本质上是一个指针,意思是,符号名字 ` str ` 指向的是 ` .rodata+0 ` 至 ` .rodata+14 ` 的一段空间。
380382
381- 这个 ` FLEObject ` 中,还有个 ` sections ` 成员,是一系列 ` FLESection ` 结构的列表,其中包含了一个对应 ` .rodata ` 节的 ` FLESection ` 对象。这样,我们就看到了, ` Symbol ` 对象 ` str ` 所指向的具体内容:
383+ 这个 ` FLEObject ` 中,还有个 ` sections ` 成员,是一系列 ` FLESection ` 结构的列表,其中包含了一个对应 ` .rodata ` 节的 ` FLESection ` 对象,即 ` obj.sections[".rodata"] ` 。这样,我们就看到了 ` Symbol ` 对象 ` str ` 所指向的具体内容,
382384
383385``` cpp
384386FLESection {
@@ -387,7 +389,7 @@ FLESection {
387389}
388390```
389391
390- 类似地,磁盘上的 ` main.fle ` 格式文件为 :
392+ 以上是 ` foo.fle ` 目标文件的两种表示; 类似地,对于 ` main.fle ` ,其磁盘上的格式文件为 :
391393
392394``` json5
393395{
@@ -403,7 +405,8 @@ FLESection {
403405}
404406```
405407
406- 其中的重定位信息会被解析成 ` Relocation ` 结构:
408+ 同样,在 ` FLEObject ` 中,每个 ` FLESection ` 对象都进一步有一个 ` relocations ` 成员,它是一系列 ` Relocation ` 结构的列表。比如,
409+ ` obj.sections[".text"].relocations[0] ` 是:
407410
408411``` cpp
409412Relocation {
@@ -414,13 +417,27 @@ Relocation {
414417}
415418```
416419
417- 在这个阶段,我们采用最简单的方案:将所有内容合并到一个叫 ` .load ` 的节中。这与真实的可执行文件有所不同 —— 实际的可执行文件通常会将代码(` .text ` )、数据(` .data ` )等放在不同的节中,并赋予它们不同的权限。但为了简化问题,我们先把所有内容放在一个节中。
420+ 这本质上也是一个指针,意思是,` .text+28 ` 至 ` .text+32 ` 这段空间的值需要被修正为 ` str ` 的地址。
421+
422+ ### 链接过程
423+
424+ 链接器只做三件事:节合并!符号解析!重定位!
425+
426+ 最后还要生成可执行文件。
427+
428+ #### 节合并
429+
430+ 首先,链接器将各个目标文件中的相同节(` .text ` 、` .rodata ` 等)合并,形成一个最终的内存布局。
431+
432+ > [ !TIP]
433+ > 在本任务中,你可以先选择简单地将所有目标文件的所有节都按顺序合并到一个 ` .load ` 节中,而不是生成 ` .text ` 、` .rodata ` 等节、并赋予它们不同的权限 —— 节分离是任务三的内容。
418434
419- 链接器需要完成以下工作:
435+ #### 符号解析
420436
421- 首先,将所有目标文件中的节( ` .text ` 、 ` .rodata ` 等)按顺序合并到 ` .load ` 节中。在合并的过程中,需要记录每个符号在合并后的新位置。比如 ` str ` 符号原本在 ` foo.fle ` 的 ` .rodata ` 节开头,合并后它的位置需要加上一个偏移量。这个过程会更新内存中 ` Symbol ` 结构的 ` offset ` 字段 。
437+ 在节合并后,链接器需要找到每个符号的定义,把每个符号关联到合并后的内存布局中。也就是记录下每个符号都去了哪里,这样你在重定位时才能找到它 。
422438
423- 接着,处理所有的重定位项。在上面的例子中,` main.fle ` 中有一个对 ` str ` 的引用需要重定位。
439+ > [ !TIP]
440+ > 你可以边合并节,边做符号解析。比如,你可以合并完某个目标文件的某个节后,找到在这个节中定义的符号,把每个符号的 ` offset ` 字段,设置为它在合并后的新位置,并把每个更新后的 ` Symbol ` 都加入到全局符号表中。
424441
425442> [ !IMPORTANT]
426443> 在处理目标文件的符号表时,你会发现单个目标文件的符号表中可能包含未定义符号(即在当前文件中被引用但尚未定义的符号)。这与标准 ELF 格式的行为是一致的。处理这些符号时:
@@ -430,39 +447,74 @@ Relocation {
430447>
431448> 这种机制允许目标文件之间相互引用,不要求符号定义必须出现在引用之前。
432449
433- 在这个阶段,我们只需要处理 ` R_X86_64_32 ` 和 ` R_X86_64_32S ` 类型的重定位 —— 它们都是将符号的绝对地址填入重定位位置。
450+
451+ #### 重定位
452+
453+ 得到全局符号表后,链接器需要遍历所有的重定位项,根据重定位项的类型,计算要填入的值,更新重定位项所指向的地址引用。
434454
435455> [ !TIP]
456+ > 重定位类型决定了如何计算要填入的值。
457+ > 在本任务中,你只需要处理 ` R_X86_64_32 ` 和 ` R_X86_64_32S ` 类型的重定位 —— 它们都是直接将符号的绝对地址填入重定位位置。
458+
459+ > [ !NOTE]
436460> ` R_X86_64_32 ` 和 ` R_X86_64_32S ` 都会将 64 位地址截断为 32 位,区别在于链接器如何验证这个截断是否合法:
437461> - ` R_X86_64_32 ` :要求截断掉的高 32 位必须为 0,这样通过零扩展可以还原出原始的 64 位值
438462> - ` R_X86_64_32S ` :要求高 32 位的值与截断后的符号位(即第 32 位)相同(全 0 或全 1),这样才能通过符号扩展还原出原始的 64 位值
463+ >
464+ > 你可以简单忽略它们的区别。
439465
440- 链接器需要:
441- 1 . 在合并后的符号表中查找 ` str ` 符号,得到其最终地址(基地址 + 节偏移 + 符号偏移)
466+ 仍以上述的 ` main.fle ` 为例, 链接器需要:
467+ 1 . 在合并后的全局符号表中查找 ` str ` 符号,获取符号地址
4424682 . 将这个地址加上 ` addend ` (0)得到最终值
4434693 . 根据重定位类型(` R_X86_64_32S ` )将这个值截断为 32 位
444- 4 . 将计算得到的值写入到需要重定位的位置(在重定位表中指定的偏移量处)
470+ 4 . 用这个值填入要重定位的位置
445471
446- 例如,如果 ` str ` 最终位于地址 0x401000,那么:
447- - 最终值 = 0x401000 + 0 = 0x401000
448- - 截断为 32 位后 = 0x401000 & 0xFFFFFFFF = 0x401000
472+ 例如,上例中的重定位项 ` ❓: .abs32s( str + 0) ` ,代表 ` text+28 ` 至 ` text+32 ` 这段空间的值需要被修正为 ` str ` 的地址。如果全局符号表中, ` str ` 指向地址 ` 0x401000 ` ,那么:
473+ - $\text{ 最终值} = 0x401000 + 0 = 0x401000$
474+ - $\text{ 截断为 32 位后} = 0x401000 \mathbin{ \& } 0xFFFFFFFF = 0x401000$
449475- 验证截断后的值是否合法
450- - 将 0x401000 写入重定位位置
476+ - 将 ` 0x401000 ` 填入 ` text+28 ` 至 ` text+32 ` 这段空间
477+
478+ #### 可执行文件生成
451479
452- 最后,生成可执行文件。在内存中,我们需要创建一个新的 ` FLEObject ` 。 ` name ` 可以设置为任意字符串(如 "a.out"),但其 ` type ` 必须为 ` .exe ` ,并且需要生成正确的程序头( ` ProgramHeader ` 结构)以便加载器能够正确地加载程序 。
480+ 最后,生成可执行文件,生成文件头、段信息等 。
453481
454- 在这个阶段,我们只生成一个程序头来描述 ` .load ` 段。它需要指定:
455- - 段名(name):` .load `
456- - 加载地址(vaddr):我们使用 0x400000
457- - 段的大小(size):合并后的总大小
458- - 权限标志(flags):在这个阶段,我们简单地赋予 RWX(可读、可写、可执行)权限(0b111,即十进制的 7)
482+ 在我们的框架下,链接器需要创建一个 ` .type == ".exe" ` 的 ` FLEObject ` 。其 ` .name ` 字段可设置为任意字符串(如 "a.out");` .phdrs ` 字段需要设置为一个正确的 ` ProgramHeader ` 结构,以便加载器能够正确地加载程序。
483+
484+ > [ !NOTE]
485+ > 加载器(loader)是操作系统的一部分,负责把可执行文件加载到内存(比如分配空间、建立映射关系),并初始化程序的执行环境(比如初始化栈空间、跳转到程序入口点)。
486+ >
487+ > 我们实现了一个用户态的加载器,模拟操作系统加载器的行为。通过 ` ./exec ` 命令,你可以加载运行一个 FLE 格式的可执行文件。
488+ >
489+ > 你将在下学期 ICS 2 课程中学到有关加载器的更多知识。
490+
491+ > [ !TIP]
492+ > 上面提到,我们在这个任务中可以把所有节一股脑地扔进 ` .load ` 段里。我们需要添加一个程序头来描述这个段,你可以直接参考下面这段代码:
493+ ``` cpp
494+ ProgramHeader header = {
495+ .name = ".load", // 描述的是 .load 段
496+ .vaddr = 0x400000, // 我们使用固定的加载地址 0x400000
497+ .size = size, // 合并后的总大小
498+ .flags = PHF::R | PHF::W | PHF::X // 可读、可写、可执行,简单地赋予所有权限
499+ };
500+ ```
459501
460502> [ !NOTE]
461503> 由于在最终的可执行文件中,节头并不是必须的,所以在这里你可以不生成节头。在这里我们只介绍程序头怎么填。
462504
463- 此外,可执行文件必须指定一个入口点(entry point),也就是程序开始执行的位置。在 x86-64 程序中,这个入口点通常是名为 ` _start ` 的函数。链接器需要在合并后的符号表中找到 ` _start ` 符号,并将其地址(基地址 + 符号偏移)设置为程序的入口点。如果找不到 ` _start ` 符号,应该报错,因为这意味着程序缺少必需的入口点。
505+ 此外,可执行文件必须指定一个入口点(entry point),也就是程序开始执行的位置。
506+ 在基于 C 语言开发的程序中,这个入口点通常是名为 ` _start ` 的函数。链接器需要在合并的全局符号表中找到 ` _start ` 符号,并将其地址(基地址 + 符号偏移)设置为程序的入口点。
464507
465- 在完成这些工作后,我们就得到了一个完整的 ` FLEObject ` 结构体,它描述了整个程序的内存布局和执行流程。在 ` FLE_ld ` 方法返回这个结构体后,我们的实验框架会将其序列化为 JSON 格式的 FLE 文件:
508+ > [ !NOTE]
509+ > 为什么是 ` _start ` 而不是 ` main ` ?
510+ >
511+ > C 与 C++ 编译器在链接时会默认将 ` libc ` ,即 C 语言标准库链接进来,其最常见的实现是 ` glibc ` 。
512+ >
513+ > 这个库中会定义一个 ` _start ` 函数,作为程序的低级入口点。这个函数负责从栈上解析命令行参数、初始化 C++ 全局对象、初始化堆内存管理等,并进一步调用 ` main ` 函数。
514+ >
515+ > 其他语言,如 Go、Rust 等,亦有类似的机制。
516+
517+ 在完成这些工作后,我们就得到了一个完整的 ` FLEObject ` 结构体,为加载器描述了整个程序理应达成的内存布局。` FLE_ld ` 方法只需返回这个结构体,我们的实验框架会将其序列化为 FLE 格式文件:
466518
467519``` json5
468520{
@@ -495,7 +547,9 @@ Relocation {
495547>
496548> 可以思考:为什么要采取这样的扫描顺序?有没有其他更好的方法?
497549
498- 在实现过程中,建议先处理只有一个输入文件的简单情况。你可以使用 ` readfle ` 工具检查输出文件的格式是否正确并打印调试信息(比如节的合并过程、符号的新地址、重定位的处理过程)也会对调试很有帮助。
550+ > [ !TIP]
551+ > Start simple, move fast!
552+ > 在实现过程中,你可以先处理只有一个输入文件的简单情形。使用 ` readfle ` 工具检查输出文件的格式是否正确并打印调试信息(比如节的合并过程、符号的新地址、重定位的处理过程)也会对调试很有帮助。
499553
500554完成之后,你可以用以下命令来测试你的链接器:
501555
0 commit comments