文章目录
- 一、先看一个跨文件调用
- 二、什么是符号
- 三、用 nm 查看符号
- 四、用 readelf -s 查看符号表
- 五、undefined reference 的本质
- 六、什么是重定位
- 七、查看重定位表
- 八、用 objdump 观察代码里的占位
- 九、静态链接到底做了什么
- 十、静态库为什么更新后程序必须重新链接
- 十一、静态库链接顺序问题
- 十二、符号表、重定位和库的关系
- 十三、常用命令总结
- 十四、总结
上一篇我们认识了 ELF,知道.o、.so、可执行文件都不是普通文件,而是带有结构的二进制文件。ELF 中有代码、有数据、有 section、有 segment,还有一个对链接非常重要的东西:符号表。
这篇文章要解决的问题是:当main.c调用code.c里的run函数时,编译器单独编译main.c时明明看不到run的实现,为什么最后程序还能正确跳转到run?
答案就在符号解析和重定位中。
一、先看一个跨文件调用
准备两个文件。
hello.c:
#include<stdio.h>externvoidrun(void);intmain(){printf("hello\n");run();return0;}code.c:
#include<stdio.h>voidrun(void){printf("run\n");}单独编译:
gcc-chello.c-ohello.o gcc-ccode.c-ocode.o此时hello.o中调用了run,但run的实现并不在hello.o里。
问题来了:
hello.o 如何知道 run 最终在哪里?答案是:它暂时不知道。它只是留下一个“未解决的符号引用”,等链接阶段再解决。
二、什么是符号
在链接视角下,函数名和全局变量名都可以看作符号。
例如:
voidrun(void){}intg_val=10;这里的run和g_val都是符号。
符号大致可以分为两类:
定义符号:当前文件提供了实现或实体 未定义符号:当前文件用到了它,但实现不在当前文件在上面的例子中:
hello.o 中:run 是未定义符号 code.o 中:run 是已定义符号链接器的工作之一,就是把这些引用和定义匹配起来。
三、用 nm 查看符号
nm可以查看目标文件中的符号。
查看hello.o:
nm hello.o可看到:
0000000000000000 T main U printf U run这里的关键是:
T main U runT表示符号定义在代码段.text中,说明main在hello.o里有实现。
U表示 undefined,说明run在hello.o中只是被引用,还没有定义。
再看code.o:
nm code.o可能看到:
0000000000000000 T run U printf这里run是T,说明code.o提供了run的函数实现。
链接器看到:
hello.o 需要 run code.o 提供 run于是就可以把它们连接起来。
四、用 readelf -s 查看符号表
readelf -s也可以查看符号表:
readelf-shello.o你会看到符号的更多信息,例如:
Num: Value Size Type Bind Vis Ndx Name ... FUNC GLOBAL DEFAULT UND run FUNC GLOBAL DEFAULT ... main其中UND表示 undefined,也就是未定义符号。
符号表告诉链接器:
这个目标文件定义了哪些符号; 引用了哪些外部符号; 每个符号大致属于什么类型; 符号将来需要如何参与链接。五、undefined reference 的本质
如果只链接hello.o:
gcc hello.o-omain会报错:
undefined reference to `run'这不是编译错误,而是链接错误。
因为编译hello.c时,编译器只需要知道:
externvoidrun(void);它就可以相信run这个函数将来会存在。
但链接时,链接器必须真的找到run的实现。如果所有输入文件和库里都没有run,链接器就无法生成最终可执行程序。
所以:
undefined reference = 链接器找不到某个符号的定义常见原因:
- 忘记链接某个
.o文件。 - 忘记写
-lxxx。 -L路径不对,库没找到。- 库里根本没有这个函数实现。
- C++ 名字修饰导致符号名不匹配。
六、什么是重定位
符号解析解决了“函数在哪里定义”的问题,但还不够。
链接器还要解决一个更具体的问题:
调用指令中的地址应该填多少?编译hello.c时,编译器看到:
run();但它不知道run最终在可执行程序中的地址。于是它只能先留下一个需要修正的位置,并记录到重定位表里。
等链接器把hello.o、code.o合并成最终程序时,它就知道run的最终地址了,于是回头修正调用位置。
这个过程就是重定位。
通俗理解:
编译阶段:先留坑 链接阶段:填地址七、查看重定位表
可以使用:
readelf-rhello.o你可能看到类似:
Relocation section '.rela.text' Offset Info Type Sym. Name ... ... ... ... run ... ... ... printf这说明hello.o的.text代码段中,有一些位置需要在链接时被修正。
重定位表记录的信息大致包括:
哪里需要修正 修正和哪个符号有关 用什么方式修正链接器根据这些信息,把未确定的地址修正为最终地址。
八、用 objdump 观察代码里的占位
可以反汇编目标文件:
objdump-dhello.o你会看到call指令,但目标地址可能还不是最终地址。
因为在.o阶段,run的最终位置还没确定。
链接后再反汇编最终程序:
gcc hello.o code.o-omain objdump-dmain这时call run的地址就已经被链接器修正好了。
这就是静态链接中非常核心的一步。
九、静态链接到底做了什么
现在我们可以重新理解静态链接。
静态链接不是简单地把文件拼接到一起,而是包含多个工作:
- 收集所有输入目标文件。
- 扫描符号表。
- 匹配未定义符号和已定义符号。
- 合并同类 section,例如
.text、.data。 - 分配最终地址。
- 根据重定位表修正代码和数据中的地址。
- 生成最终可执行文件。
如果涉及静态库.a,链接器还会从库中抽取需要的目标文件参与链接。
所以:
静态库中的 .o,本质上也会参与上述链接过程。十、静态库为什么更新后程序必须重新链接
假设你用libmyc.a生成了程序main。
静态链接时,库中相关代码已经进入main。
后来你修改my_string.c,重新生成新的libmyc.a,但不重新链接main。
此时运行旧main,行为不会变化。
原因是:
旧 main 里已经包含旧版本库代码; 新 libmyc.a 不会自动影响旧 main。只有重新链接:
gcc main.c -I./include -L./lib-lmyc-omain新库实现才会进入新的可执行程序。
十一、静态库链接顺序问题
在某些情况下,静态库链接顺序也会影响结果。
一般建议把依赖库放在使用它的目标文件后面:
gcc main.o -L.-lmyc-omain而不要写成:
gcc -L.-lmycmain.o-omain原因是传统链接器通常从左到右扫描输入。当它扫描到库时,如果前面还没有未解决的符号引用,可能不会从库中抽取目标文件。
动态库场景下这个问题不一定表现得同样明显,但学习静态链接时应该养成正确顺序习惯。
十二、符号表、重定位和库的关系
现在把它们串起来:
.c 源文件 -> 编译成 .o -> .o 中包含符号表和重定位表 -> 多个 .o 或静态库参与链接 -> 链接器解析符号并修正地址 -> 生成可执行文件静态库.a只是把多个.o管理起来。真正起作用的仍然是里面.o的符号表和重定位信息。
动态库.so的符号解析和重定位更复杂,因为很多工作会推迟到加载和运行阶段。后面讲动态链接时会继续深入。
十三、常用命令总结
查看符号:
nm hello.o readelf-shello.o查看重定位信息:
readelf-rhello.o查看反汇编:
objdump-dhello.o objdump-dmain链接目标文件:
gcc hello.o code.o-omain查看未定义符号:
nm hello.o|grep' U '十四、总结
链接器最核心的工作可以概括为两件事:
符号解析:找到每个外部符号的定义 重定位:把代码和数据中暂时未知的地址修正为最终地址这也是undefined reference、静态库更新必须重新链接、链接顺序可能影响结果的根本原因。
下一篇,我们从链接继续走向加载:一个 ELF 可执行文件还没运行时,为什么里面已经记录了地址?操作系统又是如何根据 ELF 的 segment 初始化进程地址空间的?