1. 链接
链接的主要任务可以概括为两点:
-
符号解析 (Symbol Resolution):确保每个模块中引用的外部符号(如函数名、全局变量名)都能在其他模块中找到唯一定义。
-
重定位 (Relocation):将各个模块的代码段、数据段合并,并修正代码中对内存地址的引用,使其指向正确的最终地址。
2. 原理详解
目标模块 (Object Module) 是什么?
错误认知纠正:目标模块不是库函数的一小部分。这是一个非常普遍的误解。
-
准确定义:目标模块 (Object Module) 是编译器或汇编器处理单个源文件(如
.c
或.s
文件)后生成的中间产物。在Windows中通常是.obj
文件,在Linux/Unix中是.o
文件。它包含了该源文件转换成的机器指令和数据。 -
内容剖析:一个目标模块(比如
main.o
)就像一个“半成品”,它内部包含了:-
代码段 (
.text
):编译好的机器指令。 -
数据段 (
.data
,.bss
):已初始化的全局变量和静态变量、未初始化的全局/静态变量占位符。 -
符号表 (Symbol Table):记录了本模块能提供给其他模块使用的符号(比如函数
main
),以及本模块需要从其他模块引用的符号(比如函数printf
)。 -
重定位信息 (Relocation Information):一个列表,记录了哪些指令的地址部分需要-在链接时进行修正。
-
-
库函数与目标模块的关系:一个标准库(如C语言的
libc.a
)可以看作是一个**“目标模块压缩包”**。它里面包含了成百上千个已经编译好的目标模块,例如printf.c
编译成了printf.o
,scanf.c
编译成了scanf.o
,然后把这些.o
文件打包在一起,就成了libc.a
。链接器在工作时,会根据你的需要,从这个“压缩包”里“解压”出它需要的那个.o
文件(比如printf.o
),而不是整个库或者库的一部分。
可重定位 (Relocatable)
核心目的:为了实现模块化编程和代码复用。
我们写的程序通常不是一个巨大的单文件,而是由多个源文件(模块)构成。比如 a.c
, b.c
, main.c
。我们可以独立地编译它们:
-
gcc -c a.c
->a.o
-
gcc -c b.c
->b.o
-
gcc -c main.c
->main.o
在编译 main.c
的时候,编译器知道 main
函数调用了 a.c
里的函数 func_a
,但它根本不知道 func_a
将来会被放在内存的哪个地址。它怎么做呢?
它只能先生成一条“不完整”的 call
指令,比如 call 0x00000000
,同时在 main.o
的重定位信息里记下一笔:“嘿,链接器,我这里第N个字节的 call
指令,它的目标地址需要你以后填上 func_a
的真正地址”。
这就是“可重定位”的含义:目标模块中的代码和数据地址都是相对的、未确定的,像是一块块可以随意搬运的积木,其内部的地址引用都只是占位符,等待链接器来赋予它们最终的、绝对的内存地址。
如果没有可重定位机制,那我们每次修改任何一个 .c
文件,都必须把所有源文件重新一起编译,因为任何一个文件的改变都可能影响其他所有函数在内存中的布局,这在大型项目中是不可想象的。
合并过程
现在我们用一个完整的例子来串起所有知识点。
场景:我们有两个源文件 main.c
和 math.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
) 的工作步骤:
-
空间合并与地址分配 (合并同类项)
-
链接器收集所有输入的
.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
。
-
-
-
符号解析与重定位 (填空)
-
链接器遍历所有模块的重定位信息。
-
处理
main.o
:-
它看到
main.o
中有一条call add
指令,其地址是空的。链接器在符号表中查到add
的最终地址是0x401050
,于是把这个地址填入call
指令中。 -
它看到
main.o
中有一条访问global_var
的指令。查表得知global_var
的地址是0x602010
,于是把这个地址填进去。 -
它看到
main.o
中有一条call printf
指令。查表得知printf
的地址是0x401080
,于是把这个地址填进去。
-
-
处理
math.o
和printf.o
:这些文件内部可能也有需要重定位的地方,链接器会用同样的方法处理。
-
经过这两个步骤,所有“半成品”.o
模块就被完美地拼接成了一个单一、完整、地址确定、可以直接被操作系统加载到内存运行的可执行文件。
3.
-
**错误类型
-
编译时错误:语法错误、类型不匹配等。
int a = "hello";
-
链接时错误:最经典的就是 “Undefined reference to/Undefined symbol” (未定义的引用/符号)。比如你声明了
extern int func();
但忘了把定义func
的那个.c
文件一起链接,或者库链接漏了。另一个是 “Multiple definition of” (多重定义),即两个模块定义了同名的全局函数或变量。 -
运行时错误:逻辑错误、除以零、空指针解引用、数组越界等。
-
-
静态链接 vs. 动态链接
-
我们上面讲的整个过程叫 静态链接 (Static Linking)。库代码被实实在在地复制进了最终的可执行文件。
-
优点:程序不依赖外部库文件,移植性好。
-
缺点:可执行文件体积大;如果库更新了,整个程序需要重新链接。
-
-
动态链接 (Dynamic Linking):链接时,只在可执行文件中记录一个“需要
libc.so
库中的printf
函数”的标记。程序运行时,操作系统才会去加载共享库 (.so
或.dll
),并完成最终的地址修正。-
优点:可执行文件体积小,多个程序可共享内存中的同一份库,节省资源;库更新方便。
-
缺点:依赖外部库文件,可能出现库版本不兼容问题。
-
-
考点:题目可能会问静态/动态链接的优缺点对比,或者给出场景判断哪种链接方式更合适。408主要考察静态链接的概念。
-
-
重定位与装入的区别
-
重定位 (Relocation):是链接器在生成可执行文件时做的工作,它使用的是虚拟地址。
-
装入 (Loading):是操作系统的加载器 (Loader) 在程序运行时做的工作。加载器读取可执行文件,将其内容放入物理内存,并可能进行地址转换(如果采用动态地址映射)。
-