一、基础概念
链接(Link):编译器把目标文件 .o + 库文件整合,解析符号引用,生成可执行文件。 Linux 两种链接方式:
补充知识

_start 是 glibc 提供的汇编启动代码,链接器默认把它设为 ELF 文件入口点。 你写代码只实现 main(),_start 的代码由 libc 自动链接进来。
_start 要完成一系列工作(图中做了极大简化):


进入程序的地址和_start的地址是一致的

1. 指令集:CPU 原生能力边界
CPU 硬件内置一套指令集,只认识固定的基础动作:加减、数据搬运、跳转、读写内存等。 CPU 本身不认识循环、函数、字符串、结构体这类高层概念。
2. 汇编语言 = 指令集的人性化映射
机器码是二进制,人类难以阅读; 汇编为每一条硬件指令提供文字别名(助记符),一条汇编指令一对一映射一条机器指令。 汇编只是一层翻译外皮,没有新增任何 CPU 能力。
3. 编译完整链路
C/C++ 高级语言 → 编译器优化翻译 → 汇编代码 → 汇编器 → 二进制机器码(ELF 目标文件) 本质:把人类容易编写的高层逻辑,拆解为 CPU 能识别的基础指令序列。
4. 指令集架构隔离(重点)
x86_64、ARM、RISC-V 各自拥有独立指令集,二进制互不兼容。
- x86 程序不能直接在 ARM 平台运行;
- 想要跨平台运行,必须重新编译源码;
- 这也是 Windows exe 不能直接在 Linux 运行、安卓 APP 无法在电脑直接执行的底层原因。
链接过程(静态链接)
本质是将库中代码拷贝到你的程序中
// hello.c
#include<stdio.h>
void run();
int main() {
printf("hello world!\\n");
run();
return 0;
}
// code.c
#include<stdio.h>
void run() {
printf("running…\\n");
}
//

对其进行编译链接形成可执行程序

再call的时候我们发现这个地址是不明确的
我们可以看到这里的call指令,它们分别对应之前调用的printf和run函数,但是你会发现他们的跳转地 址都被设成了0。那这是为什么呢??
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,比如他们 位于内存的哪个区块,代码长什么样都是不知道的。因此,编译器只能将这两个函数的跳转地址先暂 时设为0。 这个地址会在哪个时候被修正?
链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正 的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接的时候,就会根据表里记录的 地址将其修正。
我们查看.o文件发现里面的put->printf ,run->run,都是UND,未知的,正好验证了上面的说法

接下来我们来看一下


我们发现main,run不在是UND,他们都有了地址,但是put还是没有,这是因为put是动态链接的
我们现在采用静态链接

run
puts

main

链接的过程

链接核心逻辑
- 查找所有 UND 符号,寻找其他.o中GLOBAL 定义的同名符号
- 找到 code.o 内实现的 run,进行符号解析


run、main 都拥有虚拟地址,归属 .text 代码段; 两个目标文件的.text 段被合并为最终可执行文件唯一的.text 段。
验证
反汇编找text ->main ->run



并且call后面不在是全0现在都有数字了,找到了函数对应的地址
总结
静态链接就是把库中的.o进行合并,和上述过程一样 所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立 的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我 们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这 其实就是静态链接的过程。
静态库是没有加载过程,编译器进行链接的时候,在加载之前就已经全部完成
加载过程(动态库)


我们可以看出来puts虽然在这里是UND但是
编译动态链接程序时,gcc 会去本机 libc.so.6 查找puts,读取它默认绑定的版本标签 GLIBC_2.2.5,写入 ELF 符号表
动态链接器 ld.so 加载 libc 之后:

1. 拷贝动态库到系统默认目录 /lib64 / /usr/lib64
sudo cp libxxx.so /usr/lib64/
gcc main.c -lmystdio
2.头文件和库文件有自己的独立路径
gcc main.c -I头文件路径 -L库文件路径 -lmymath
3.头文件和库文件和我们自己的源文件在同一个路径下
gcc main.c -L. -lmymath
4. 运行环境变量 LD_LIBRARY_PATH
export LD_LIBRARY_PATH=./lib:$LD_LIBRARY_PATH
gcc main.c -o app -L./lib -lmydemo


- 每个进程由task_struct描述,mm_struct管理它独立的虚拟地址空间;
- 虚拟地址空间划分为多个区域:代码段、数据段、堆、栈、mmap 共享映射区;动态库就映射在共享区;
- 页表完成「虚拟地址 → 物理地址」转换;
- 动态库文件存放在磁盘,不会一次性全部载入内存;访问对应代码时触发缺页中断加载;
- 动态库只读代码段物理内存可以被多个进程共享,节省内存资源。

库函数的调用本质是在进程的虚拟地址空间进行函数跳转

动态库也叫做共享库,多个进程用到的资源只要在内存中存在一份

那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
动态链接到底是如何工作的??
首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。
当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转 地址了。

这些可执行程序都会加载这个库–/lib64/ld-linux-x86-64.so.2 动态链接器(加载器)。由它负责寻找、加载所有依赖 .so、解析符号(动态链接核心)。所有动态可执行文件都会依赖它。
在 C/C++ 程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是 _start,这是一个由 C 运行时库(通常是 glibc)或链接器(如 ld)提供的特殊函数。
在 _start 函数中,会执行一系列初始化操作,这些操作包括:
动态库是没有main函数,他包含大量的方法,每个方法都要有自己的地址空间

找这个方法的时候可以认为时候起始地址+偏移量

程序调用共享库函数,本质依靠 共享库基地址 + 函数偏移 得到目标虚拟地址,全程在同一进程虚拟地址空间完成跳转。
怎么进行库函数调用
- 库已经被我们映射到了当前进程的地址空间中
- 库的虚拟起始地址我们也已经知道了
- 库中每一个方法的偏移量地址我们也知道
- 所有:访问库中任意方法,只需要知道库的起始虚拟地址 + 方法偏移量即可定位库中的方法
- 而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的
动态链接过程

加载的时候,修改call后面的000…0000,变成动态库的起始虚拟地址+方法在库中的偏移量
在程序真正执行之前,操作系统会把动态库加载并映射到虚拟地址空间,拿到各个库的基地址,再对库函数调用做地址重定位,更新函数调用地址。
但这里遇到一个矛盾:.text代码段是只读的,不能直接修改机器指令里的地址。
动态链接的解决思路: 不在只读的代码段修改地址,而是在可读写的.data 段开辟一块专门区域,也就是GOT 全局偏移量表。 GOT 的每一条表项,保存一个外部函数 / 全局变量的虚拟地址。 .data 段支持读写,运行时就可以随时改写表内的地址值,完成重定位。

.got是数据区的,内容可以发生修改


编译阶段:编译器生成机器指令 call .got地址 + 表内偏移。 函数在 GOT 表中的偏移编译时就已经确定,代码段不去保存函数完整虚拟地址,只保存 GOT 表的基地址加上表项偏移。
程序启动,动态链接器把libc.so动态库加载、映射到进程虚拟地址空间,拿到 libc 的加载基地址。
修改 GOT 表(编辑.got): .got属于可读写的数据段,链接器改写 GOT 表项。 表项原本只记录:puts(0x112233)@libc.so,代表 puts 函数在 libc 库内部的偏移0x112233。 假设 libc 映射后的基地址是0x44332211,把库基地址 + 库内偏移计算出 puts 真实虚拟地址,回填进 GOT 表项,更新为:puts(0x112233)@libc.so(0x44332211)。
执行函数调用: 代码执行call .got地址+表中偏移地址,直接读取 GOT 表里已经填好的真实函数地址,跳转执行 puts,完成查表调用。
GOT 与 PIC
代码段具备只读属性,无法直接修改。引入 GOT 表之后,动态库的代码段就可以在多个进程之间共享。 但不同进程的虚拟地址空间中,动态库加载的基地址各不相同,GOT 表存放的是进程内真实虚拟地址,每个进程都拥有自己独立的 GOT 表,因此 GOT 表无法跨进程共享。
对于单个动态库.so,GOT 表和.text代码段之间的相对偏移是编译时就固定不变的。CPU 依靠相对寻址,就可以从代码段定位到 GOT 表的位置,不需要依赖绝对地址。
调用外部库函数时,先读取 GOT 表中存储的地址,再依据该地址完成跳转。GOT 表内的地址,会在动态库加载阶段被改写为函数真正的虚拟地址。
基于这套机制实现的动态链接,叫做 PIC 地址无关代码。 动态库本身不需要做任何修改,无论被加载到虚拟地址空间的哪个位置,都可以正常工作,库的代码段能够被多个进程共享。 编译动态库时编译器参数 -fPIC,就是用来生成地址无关代码。
PIC = 相对寻址 + GOT



PLT 是一小段汇编跳板代码,存放在 .plt 段,属于代码段,只读。 GOT 存真实函数地址;PLT 负责跳转逻辑,配合 GOT 实现延迟绑定(lazy binding)。
延迟绑定:函数第一次调用才去解析符号,不是程序启动就把所有库函数全部解析,加快程序启动速度。
很多人只知道可执行程序调用动态库,实际上动态库之间也会互相调用,存在复杂依赖链。
要让库内部调用其他库也做到地址无关,方案很简单:动态库本身也是 ELF 格式,它内部同样具备.GOT、.got.plt、PLT 表。
库内调用外部函数时,复用同一套「PIC 相对寻址 + PLT/GOT + 延迟绑定」机制,和可执行程序逻辑完全相同。统一的 ELF 文件格式,让可执行文件与动态库可以使用同一套链接加载逻辑。
网硕互联帮助中心






评论前必须登录!
注册