Skip to content

Commit 0c910b8

Browse files
committed
docs: polish task 2
1 parent a3bfe02 commit 0c910b8

File tree

1 file changed

+79
-25
lines changed

1 file changed

+79
-25
lines changed

README.md

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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
370372
Symbol {
@@ -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
384386
FLESection {
@@ -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
409412
Relocation {
@@ -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` 符号,获取符号地址
442468
2. 将这个地址加上 `addend`(0)得到最终值
443469
3. 根据重定位类型(`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

Comments
 (0)