【Linux指南】动静态库系列(七):符号表与重定位:链接器如何把函数调用接起来

文章目录

上一篇我们认识了 ELF,知道 .o、.so、可执行文件都不是普通文件,而是带有结构的二进制文件。ELF 中有代码、有数据、有 section、有 segment,还有一个对链接非常重要的东西:符号表。

这篇文章要解决的问题是:当 main.c 调用 code.c 里的 run 函数时,编译器单独编译 main.c 时明明看不到 run 的实现,为什么最后程序还能正确跳转到 run?

答案就在符号解析和重定位中。

一、先看一个跨文件调用

准备两个文件。

hello.c:

c 复制代码
#include <stdio.h>

extern void run(void);

int main()
{
    printf("hello\n");
    run();
    return 0;
}

code.c:

c 复制代码
#include <stdio.h>

void run(void)
{
    printf("run\n");
}

单独编译:

bash 复制代码
gcc -c hello.c -o hello.o
gcc -c code.c -o code.o

此时 hello.o 中调用了 run,但 run 的实现并不在 hello.o 里。

问题来了:

text 复制代码
hello.o 如何知道 run 最终在哪里?

答案是:它暂时不知道。它只是留下一个"未解决的符号引用",等链接阶段再解决。

二、什么是符号

在链接视角下,函数名和全局变量名都可以看作符号。

例如:

c 复制代码
void run(void)
{
}

int g_val = 10;

这里的 run 和 g_val 都是符号。

符号大致可以分为两类:

text 复制代码
定义符号:当前文件提供了实现或实体
未定义符号:当前文件用到了它,但实现不在当前文件

在上面的例子中:

text 复制代码
hello.o 中:run 是未定义符号
code.o 中:run 是已定义符号

链接器的工作之一,就是把这些引用和定义匹配起来。

三、用 nm 查看符号

nm 可以查看目标文件中的符号。

查看 hello.o:

bash 复制代码
nm hello.o

可看到:

text 复制代码
0000000000000000 T main
                 U printf
                 U run

这里的关键是:

text 复制代码
T main
U run

T 表示符号定义在代码段 .text 中,说明 main 在 hello.o 里有实现。

U 表示 undefined,说明 run 在 hello.o 中只是被引用,还没有定义。

再看 code.o:

bash 复制代码
nm code.o

可能看到:

text 复制代码
0000000000000000 T run
                 U printf

这里 run 是 T,说明 code.o 提供了 run 的函数实现。

链接器看到:

text 复制代码
hello.o 需要 run
code.o 提供 run

于是就可以把它们连接起来。

四、用 readelf -s 查看符号表

readelf -s 也可以查看符号表:

bash 复制代码
readelf -s hello.o

你会看到符号的更多信息,例如:

text 复制代码
Num:    Value          Size Type    Bind   Vis      Ndx Name
...
FUNC    GLOBAL DEFAULT UND run
FUNC    GLOBAL DEFAULT ... main

其中 UND 表示 undefined,也就是未定义符号。

符号表告诉链接器:

text 复制代码
这个目标文件定义了哪些符号;
引用了哪些外部符号;
每个符号大致属于什么类型;
符号将来需要如何参与链接。

五、undefined reference 的本质

如果只链接 hello.o:

bash 复制代码
gcc hello.o -o main

会报错:

text 复制代码
undefined reference to `run'

这不是编译错误,而是链接错误。

因为编译 hello.c 时,编译器只需要知道:

c 复制代码
extern void run(void);

它就可以相信 run 这个函数将来会存在。

但链接时,链接器必须真的找到 run 的实现。如果所有输入文件和库里都没有 run,链接器就无法生成最终可执行程序。

所以:

text 复制代码
undefined reference = 链接器找不到某个符号的定义

常见原因:

  1. 忘记链接某个 .o 文件。
  2. 忘记写 -lxxx。
  3. -L 路径不对,库没找到。
  4. 库里根本没有这个函数实现。
  5. C++ 名字修饰导致符号名不匹配。

六、什么是重定位

符号解析解决了"函数在哪里定义"的问题,但还不够。

链接器还要解决一个更具体的问题:

text 复制代码
调用指令中的地址应该填多少?

编译 hello.c 时,编译器看到:

c 复制代码
run();

但它不知道 run 最终在可执行程序中的地址。于是它只能先留下一个需要修正的位置,并记录到重定位表里。

等链接器把 hello.o、code.o 合并成最终程序时,它就知道 run 的最终地址了,于是回头修正调用位置。

这个过程就是重定位。

通俗理解:

text 复制代码
编译阶段:先留坑
链接阶段:填地址

七、查看重定位表

可以使用:

bash 复制代码
readelf -r hello.o

你可能看到类似:

text 复制代码
Relocation section '.rela.text'
Offset          Info           Type           Sym. Name
...
...             ...            ...            run
...             ...            ...            printf

这说明 hello.o 的 .text 代码段中,有一些位置需要在链接时被修正。

重定位表记录的信息大致包括:

text 复制代码
哪里需要修正
修正和哪个符号有关
用什么方式修正

链接器根据这些信息,把未确定的地址修正为最终地址。

八、用 objdump 观察代码里的占位

可以反汇编目标文件:

bash 复制代码
objdump -d hello.o

你会看到 call 指令,但目标地址可能还不是最终地址。

因为在 .o 阶段,run 的最终位置还没确定。

链接后再反汇编最终程序:

bash 复制代码
gcc hello.o code.o -o main
objdump -d main

这时 call run 的地址就已经被链接器修正好了。

这就是静态链接中非常核心的一步。

九、静态链接到底做了什么

现在我们可以重新理解静态链接。

静态链接不是简单地把文件拼接到一起,而是包含多个工作:

  1. 收集所有输入目标文件。
  2. 扫描符号表。
  3. 匹配未定义符号和已定义符号。
  4. 合并同类 section,例如 .text、.data。
  5. 分配最终地址。
  6. 根据重定位表修正代码和数据中的地址。
  7. 生成最终可执行文件。

如果涉及静态库 .a,链接器还会从库中抽取需要的目标文件参与链接。

所以:

text 复制代码
静态库中的 .o,本质上也会参与上述链接过程。

十、静态库为什么更新后程序必须重新链接

假设你用 libmyc.a 生成了程序 main。

静态链接时,库中相关代码已经进入 main。

后来你修改 my_string.c,重新生成新的 libmyc.a,但不重新链接 main。

此时运行旧 main,行为不会变化。

原因是:

text 复制代码
旧 main 里已经包含旧版本库代码;
新 libmyc.a 不会自动影响旧 main。

只有重新链接:

bash 复制代码
gcc main.c -I./include -L./lib -lmyc -o main

新库实现才会进入新的可执行程序。

十一、静态库链接顺序问题

在某些情况下,静态库链接顺序也会影响结果。

一般建议把依赖库放在使用它的目标文件后面:

bash 复制代码
gcc main.o -L. -lmyc -o main

而不要写成:

bash 复制代码
gcc -L. -lmyc main.o -o main

原因是传统链接器通常从左到右扫描输入。当它扫描到库时,如果前面还没有未解决的符号引用,可能不会从库中抽取目标文件。

动态库场景下这个问题不一定表现得同样明显,但学习静态链接时应该养成正确顺序习惯。

十二、符号表、重定位和库的关系

现在把它们串起来:

text 复制代码
.c 源文件
-> 编译成 .o
-> .o 中包含符号表和重定位表
-> 多个 .o 或静态库参与链接
-> 链接器解析符号并修正地址
-> 生成可执行文件

静态库 .a 只是把多个 .o 管理起来。真正起作用的仍然是里面 .o 的符号表和重定位信息。

动态库 .so 的符号解析和重定位更复杂,因为很多工作会推迟到加载和运行阶段。后面讲动态链接时会继续深入。

十三、常用命令总结

查看符号:

bash 复制代码
nm hello.o
readelf -s hello.o

查看重定位信息:

bash 复制代码
readelf -r hello.o

查看反汇编:

bash 复制代码
objdump -d hello.o
objdump -d main

链接目标文件:

bash 复制代码
gcc hello.o code.o -o main

查看未定义符号:

bash 复制代码
nm hello.o | grep ' U '

十四、总结

链接器最核心的工作可以概括为两件事:

text 复制代码
符号解析:找到每个外部符号的定义
重定位:把代码和数据中暂时未知的地址修正为最终地址

这也是 undefined reference、静态库更新必须重新链接、链接顺序可能影响结果的根本原因。

下一篇,我们从链接继续走向加载:一个 ELF 可执行文件还没运行时,为什么里面已经记录了地址?操作系统又是如何根据 ELF 的 segment 初始化进程地址空间的?

相关推荐
子兮曰4 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰4 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
未济4 天前
linux 配置环境变量
linux
傲世仙尊4 天前
目录即文件-Ext文件系统收尾篇
linux·c语言
前端小万4 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝4 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋4 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁4 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
_艾伦 耶格尔.4 天前
进程间通信
linux
李少兄4 天前
JavaScript 隐式全局变量解析
javascript