CSAPP-Chapter 4: 链接


CSAPP

Chapter 4: 链接

编译和汇编

  1. 预处理:展开头文件、宏替换、删注释,(.c -> .i),还是 C 语言文件
  2. 编译:.c -> .s,将 C 翻译为汇编
  3. 汇编:.x -> .o,汇编转换为机器码

可执行目标文件的生成

每个 .c 文件生成一个 .o 文件,再通过链接合并为一个可执行目标文件

  1. 符号解析

    • 符号:全局静态变量名、函数名

    作用是将每一个符号的引用与其定义建立关联

  2. 重定位

    将不同的 .o 文件的相同 section 合并

目标文件格式

可重定位目标文件

文件位置 节 (Section) 名称 说明 (链接器关注的信息)
头部 ELF Header 包含魔数、机器类型、字节序。 关键点: 入口点地址 (Entry point) 为 0。
中间 .text 已编译的机器代码。 注意: 这里的指令地址都是从 0 开始的相对偏移。
.rodata 只读数据(如 printf 里的格式化字符串、switch 跳转表)。
.data 已初始化的全局变量和静态变量 (int x = 1;)。
.bss 未初始化的全局变量 (int x;)。 注:在文件中仅占位标记,不占实际磁盘空间。
重定位信息 .rel.text 代码重定位表。 记录了 .text 中哪些指令的地址(如 call 后的地址)需要修改。
.rel.data 数据重定位表。 记录了 .data 中哪些指针的值需要修改。
符号信息 .symtab 符号表。 存放函数和全局变量的名字、大小、类型。
.strtab 字符串表。 存放 .symtab 中用到的符号名称的字符串实体。
尾部 Section Header Table **节头表。**目录索引,记录了上面所有节的偏移量、大小和属性。 链接器通过它来查找各个节。

可执行目标文件

文件位置 结构名称 说明 (加载器/OS 关注的信息)
头部 ELF Header 关键点: 入口点地址不再是 0,而是 _start 的虚拟地址 (e.g. 0x8048000)。
核心索引 Program Header Table (程序头表) 段的地图。 告诉操作系统:把文件的哪一部分映射到内存的哪个区域。 它是可执行文件的标志。
代码段 (Read-Only) .init 程序初始化代码(在 main 之前运行)。
.text 合并后的机器代码。地址已修正为绝对虚拟地址。
.rodata 合并后的只读数据。
数据段 (Read/Write) .data 合并后的已初始化数据。
.bss 合并后的未初始化数据。
调试信息 (可选) .symtab 符号表(通常保留用于 gdb 调试,但程序运行不需要它)。
.strtab 字符串表。
尾部 Section Header Table 节头表,虽然还在,但在程序加载运行时不再关键,主要供调试工具使用。

符号表和符号解析

包含在符号表中的符号:

  1. 全局符号:非静态函数名和非静态全局变量名
  2. 外部符号:外部函数名和外部变量名
  3. 本地符号:静态函数名和静态变量名

符号类型:

类型宏名 中文含义 详细描述
NOTYPE 未指定 符号类型未指定
OBJECT 变量 表示该符号是一个数据对象(变量)
FUNC 函数 表示该符号是一个函数
SECTION 主要用于重定位

符号绑定属性:

属性名称 可见性范围 冲突与优先级规则
LOCAL (本地) 不可见:在定义它的目标文件外部不可见 互不干扰:名称相同的本地符号可存在于多个文件中
GLOBAL (全局) 可见:对合并的所有目标文件都可见 (通常作为强符号参与链接)
WEAK (弱) 可见:与全局符号类似 低优先级:定义具有较低的优先级(会被同名 GLOBAL 覆盖)

符号的强弱特性:

  • 强符号:函数名、已初始化的全部变量名
  • 弱符号:未初始化的全局变量名

当链接器在多个文件中发现同名符号时,它遵循以下逻辑:

  1. 强强冲突:若存在多个同名的强符号,链接器将无法判定,直接报“多重定义”错误
  2. 强弱共存:若一个强符号与多个弱符号同名,链接器以强符号为准,所有引用皆指向该强符号的地址。
  3. 弱弱共存:若只有多个同名弱符号而无强符号,链接器将在其中任意选择一个作为最终定义。

重定位

重定位类型:

  1. R_386_PC32 采用相对寻址,计算的是目标位置与当前指令的相对位置,主要用于函数调用和代码跳转
  2. R_386_32 采用绝对寻址,直接填入目标在内存中的绝对位置,主要用于全局变量引用和指针赋值(如 int *p = &var)。

main.c

int sum = 1;

int add(int x, int y) {
    sum = x + y;
}

int main() {
    add(1, 2);
    return 0;
}

main.o -> main.s

00000000 <add>:
   0: 55                      push   %ebp
   1: 89 e5                   mov    %esp,%ebp
   3: 8b 55 08                mov    0x8(%ebp),%edx  # EDX = x
   6: 8b 45 0c                mov    0xc(%ebp),%eax  # EAX = y
   9: 01 d0                   add    %edx,%eax       # EAX = EAX + EDX

   # --- sum = x + y; ---
   # 这是一个写内存操作,需要知道 sum 的确切绝对地址。
   # 当前机器码中地址是 0x00000000 (占位符)。
   # 重定位类型: R_386_32
   b: a3 00 00 00 00          mov    %eax,0x0        
   # [解析]: 链接器将来会把这个 0x0 修改为 sum 变量在数据段中的真实物理地址 (例如 0x0804a018)。

  18: 5d                      pop    %ebp          # 恢复旧的栈底指针
  19: c3                      ret                  # 返回主调函数
  
0000001a <main>:
   # --- 函数序言 ---
  1a: 55                      push   %ebp
  1b: 89 e5                   mov    %esp,%ebp
  1d: 6a 02                   push   $0x2          # 压入第二个参数 (y = 2)
  1f: 6a 01                   push   $0x1          # 压入第一个参数 (x = 1)

   # --- add(1, 2); ---
   # call 指令的操作码是 e8,后面跟着 4 字节的“相对偏移量”。
   # 当前机器码 fc ff ff ff 是十进制的 -4 (指向 call 指令自己的下一条指令之前)。
   # 重定位类型: R_386_PC32
  21: e8 fc ff ff ff          call   22 <main+0x8> 
   # [解析]: 链接器会计算 (add函数的最终地址 - 下一条指令的地址),算出一个偏移量填在这里。
   # 比如,如果 add 在 main 前面很远,这个值就是个负数;在后面,就是正数。

  26: 83 c4 08                add    $0x8,%esp     # 清理堆栈
	29: b8 00 00 00 00          mov    $0x0,%eax     # return 0;
  2e: c9                      leave
  2f: c3                      ret

动态链接

注意到例如printf这样的操作十分常用,如果使用静态链接的方式,每个程序都需要在链接的时候打包一份完整的printf,同样加载到内存中时也会有多份 printf 程序。这样显然不是最优。

通过动态链接的方法,运行静态链接好的程序之后,加载到内存之前,再调用动态链接器进行一次动态链接,就可以节省硬盘和内存资源。

动态链接主要分为两张表、三个过程:

GOT 表:用于记录动态链接的全局变量;

  1. 编译时:记录需要动态链接的资源
  2. 启动时:动态链接器加载程序和依赖库,通过 GOT 表记录全局变量位置
  3. 运行时:计算函数地址供调用(PLT表)

位置无关代码:将跳转和符号的地址都使用相对地址的代码

上面的代码开启位置无关代码生成之后:

00000000 <add>:
   0:	55                   	push   %ebp
   1:	89 e5                	mov    %esp,%ebp
   3:	e8 fc ff ff ff       	call   4 <add+0x4>       # 调用 Thunk 函数,获取当前 PC 值存入 EAX
   8:	05 01 00 00 00       	add    $0x1,%eax         # EAX += 偏移量 (R_386_GOTPC),计算出 GOT 表基地址
   d:	8b 4d 08             	mov    0x8(%ebp),%ecx
  10:	8b 55 0c             	mov    0xc(%ebp),%edx
  13:	01 ca                	add    %ecx,%edx
  15:	89 90 00 00 00 00    	mov    %edx,0x0(%eax)    # 访问全局变量:使用 GOT基址 + 变量偏移 (R_386_GOTOFF)
  1b:	90                   	nop
  1c:	5d                   	pop    %ebp
  1d:	c3                   	ret    

0000001e <main>:
  1e:	55                   	push   %ebp
  1f:	89 e5                	mov    %esp,%ebp
  21:	e8 fc ff ff ff       	call   22 <main+0x4>     # main 函数也需要获取 PC 来定位 GOT 表
  26:	05 01 00 00 00       	add    $0x1,%eax         # 计算 GOT 表基地址 (EAX 现在指向 GOT)
  2b:	6a 02                	push   $0x2
  2d:	6a 01                	push   $0x1
  2f:	e8 fc ff ff ff       	call   30 <main+0x12>    # 调用函数:通常通过 PLT 表间接跳转 (R_386_PLT32)
  34:	83 c4 08             	add    $0x8,%esp
  37:	b8 00 00 00 00       	mov    $0x0,%eax
  3c:	c9                   	leave  
  3d:	c3                   	ret    

Disassembly of section .text.__x86.get_pc_thunk.ax:

00000000 <__x86.get_pc_thunk.ax>:
   0:	8b 04 24             	mov    (%esp),%eax       # 读取栈顶值 (即 Call 之后那条指令的地址) 到 EAX
   3:	c3                   	ret

声明:Blog|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - CSAPP-Chapter 4: 链接