1. 链接

链接的主要任务可以概括为两点:

  1. 符号解析 (Symbol Resolution):确保每个模块中引用的外部符号(如函数名、全局变量名)都能在其他模块中找到唯一定义。

  2. 重定位 (Relocation):将各个模块的代码段、数据段合并,并修正代码中对内存地址的引用,使其指向正确的最终地址。

2. 原理详解

目标模块 (Object Module) 是什么?

错误认知纠正:目标模块不是库函数的一小部分。这是一个非常普遍的误解。

可重定位 (Relocatable)

核心目的为了实现模块化编程和代码复用。

我们写的程序通常不是一个巨大的单文件,而是由多个源文件(模块)构成。比如 a.c, b.c, main.c。我们可以独立地编译它们:

在编译 main.c 的时候,编译器知道 main 函数调用了 a.c 里的函数 func_a,但它根本不知道 func_a 将来会被放在内存的哪个地址。它怎么做呢?

它只能先生成一条“不完整”的 call 指令,比如 call 0x00000000,同时在 main.o重定位信息里记下一笔:“嘿,链接器,我这里第N个字节的 call 指令,它的目标地址需要你以后填上 func_a 的真正地址”。

这就是“可重定位”的含义:目标模块中的代码和数据地址都是相对的、未确定的,像是一块块可以随意搬运的积木,其内部的地址引用都只是占位符,等待链接器来赋予它们最终的、绝对的内存地址。

如果没有可重定位机制,那我们每次修改任何一个 .c 文件,都必须把所有源文件重新一起编译,因为任何一个文件的改变都可能影响其他所有函数在内存中的布局,这在大型项目中是不可想象的。

合并过程

现在我们用一个完整的例子来串起所有知识点。

场景:我们有两个源文件 main.cmath.c

math.c:

int global_var = 10; // 一个全局变量

int add(int a, int b) {
    return a + b;
}

main.c:

#include <stdio.h>

extern int global_var; // 声明要使用外部的全局变量
extern int add(int, int); // 声明要使用外部的函数

int main() {
    int result = add(5, 3);
    printf("Result is %d, global_var is %d\n", result, global_var);
    return 0;
}

链接过程图解

+------------+     +------------+      +--------------------+
|   main.c   | --> |   main.o   |      |                    |
+------------+     +------------+      |                    |
                  (引用 add,      |      |  可执行文件        |
                   global_var,   |----->|     program.exe    |
                   printf)       |      |                    |
                                 |      |  (所有地址已确定)   |
+------------+     +------------+  |  +->|                    |
|   math.c   | --> |   math.o   |--+  |  +--------------------+
+------------+     +------------+     |
                  (定义 add,      |     |
                   global_var)   |  链接器 |
                                 | (ld)  |
+------------+     +------------+  |     |
|  printf.c  | --> |  printf.o  |--+     |
+------------+     +------------+        |
|  (在libc.a中) |  (定义 printf) |--------+
+------------+     +------------+

链接器 (Linker, 如 ld) 的工作步骤

  1. 空间合并与地址分配 (合并同类项)

    • 链接器收集所有输入的 .o 文件(main.o, math.o 以及从 libc.a 中抽取的 printf.o 等)。

    • 它发现每个 .o 文件都有 .text (代码), .data (数据)等段。

    • 它会把所有 .o 文件的 .text 段合并成一个大的 .text 段,所有 .data 段合并成一个大的 .data 段。

    • 在合并的同时,它为这些新合成的大段落分配虚拟内存地址。比如,它决定:

      • .text 段从地址 0x401000 开始。

      • .data 段从地址 0x602000 开始。

    • 现在,每个函数、每个全局变量在最终的程序里都有了确定的家(内存地址)。

      • add 函数的地址可能是 0x401050

      • global_var 的地址可能是 0x602010

      • printf 函数的地址可能是 0x401080

  2. 符号解析与重定位 (填空)

    • 链接器遍历所有模块的重定位信息。

    • 处理 main.o

      • 它看到 main.o 中有一条 call add 指令,其地址是空的。链接器在符号表中查到 add 的最终地址是 0x401050,于是把这个地址填入 call 指令中。

      • 它看到 main.o 中有一条访问 global_var 的指令。查表得知 global_var 的地址是 0x602010,于是把这个地址填进去。

      • 它看到 main.o 中有一条 call printf 指令。查表得知 printf 的地址是 0x401080,于是把这个地址填进去。

    • 处理 math.oprintf.o:这些文件内部可能也有需要重定位的地方,链接器会用同样的方法处理。

经过这两个步骤,所有“半成品”.o模块就被完美地拼接成了一个单一、完整、地址确定、可以直接被操作系统加载到内存运行的可执行文件。

3.

  1. **错误类型

    • 编译时错误:语法错误、类型不匹配等。int a = "hello";

    • 链接时错误:最经典的就是 “Undefined reference to/Undefined symbol” (未定义的引用/符号)。比如你声明了 extern int func(); 但忘了把定义 func 的那个 .c 文件一起链接,或者库链接漏了。另一个是 “Multiple definition of” (多重定义),即两个模块定义了同名的全局函数或变量。

    • 运行时错误:逻辑错误、除以零、空指针解引用、数组越界等。

  2. 静态链接 vs. 动态链接

    • 我们上面讲的整个过程叫 静态链接 (Static Linking)。库代码被实实在在地复制进了最终的可执行文件。

      • 优点:程序不依赖外部库文件,移植性好。

      • 缺点:可执行文件体积大;如果库更新了,整个程序需要重新链接。

    • 动态链接 (Dynamic Linking):链接时,只在可执行文件中记录一个“需要 libc.so 库中的 printf 函数”的标记。程序运行时,操作系统才会去加载共享库 (.so.dll),并完成最终的地址修正。

      • 优点:可执行文件体积小,多个程序可共享内存中的同一份库,节省资源;库更新方便。

      • 缺点:依赖外部库文件,可能出现库版本不兼容问题。

    • 考点:题目可能会问静态/动态链接的优缺点对比,或者给出场景判断哪种链接方式更合适。408主要考察静态链接的概念。

  3. 重定位与装入的区别

    • 重定位 (Relocation):是链接器生成可执行文件时做的工作,它使用的是虚拟地址

    • 装入 (Loading):是操作系统加载器 (Loader)程序运行时做的工作。加载器读取可执行文件,将其内容放入物理内存,并可能进行地址转换(如果采用动态地址映射)。