目录
前言:
先回顾一下编译过程:
.c/.cpp 源文件
↓【预处理】
.i/.ii 预处理文件
↓【编译】
.s 汇编代码
↓【汇编】
.o 目标文件(object)
↓【链接】
可执行文件 / 静态库 / 动态库
我们看到编译的过程了解到,在目标文件形成可执行文件的时候,往往还要链接动静态库。那么库到底是什么?为什么会有动静之分呢?库又是怎么形成的?接下来一一解答。
一.库的认识
**1.定义:**库就是别人提前写好、打包好的一堆可复用代码,你不用从零造轮子,可以直接拿来调用。
2.分类(按代码形态)
源码库 直接给
.h/.cpp、.py等源代码,编译时和你的程序一起编译。二进制库(编译好的) 已经提前编译成机器码,不用重新编译源码:
- 静态库 (后缀:Windows
.lib/ Linux.a)- 动态库 / 共享库 (后缀:Windows
.dll/ Linux.so/ macOS.dylib)
三种库的形式都有不同的使用场景,我们平常的编译通常是动态库链接,只有找不到动态库的时候,才会链接静态库,我们也可以自己设置静态库链接。
二.静态库
1.本质
静态库 = 使用
ar工具把一堆.o目标文件打包压缩 得到的归档包。 它不是可执行文件,也不是独立程序,仅仅是目标文件集合。
2.生成静态库
ar工具
模板:
ar 参数 库名.a 目标文件1.o 目标文件2.o ...
rreplace:插入 / 替换归档内的.o文件;库不存在时自动新增文件ccreate:创建归档文件,不会打印 "新建库" 的提示信息

3.链接使用:
// 场景1:头文件和库文件安装到系统路径下
gcc main.c -lmyc
// 场景2:头文件和库文件和我们自己的源文件在同一个路径下
gcc main.c -L. -lmyc
// 场景3:头文件和库文件有自己的独立路径
gcc main.c -I头文件路径 -L库文件路径 -lmyc
• -L: 指定库路径
• -I: 指定头文件搜索路径
• -l: 指定库名------链接
libxxx.a/libxxx.so,只写中间名字,省略lib和后缀

三.动态库
1.本质:
- 不会在链接时把代码拷贝进可执行文件;
- 程序运行时由系统动态链接器加载到内存;
- 多个程序可共用同一份库文件,节省磁盘与内存。
2.生成动态库

-fPIC:生成位置无关代码,动态库强制要求,代码可加载到内存任意地址-shared:告诉 gcc 生成共享库(.so),而非可执行程序- 库名:libxxx.so
3.使用动态库
命令:与静态库一样

有错误?
原因:
编译时
-L.只作用于链接阶段 ,告诉编译器去哪找库; 程序运行阶段 由系统动态链接器ld-linux.so查找.so,它不会自动识别当前目录,只会去系统默认库路径(/lib、/usr/lib)搜索。 虽然目录里存在libmyc.so,但运行时系统检索不到。
解决方案:
• 拷贝 .so 文件到系统共享库路径下, 一般指 /usr/lib、/usr/local/lib、/lib64 或者开 篇指明的库路径等
• 向系统共享库路径下建立同名软连接
• 更改环境变量: LD_LIBRARY_PATH
• ldconfig方案:配置/ etc/ld.so.conf.d/ ,ldconfig更新
四.ELF文件
1.定义:
ELF(Executable and Linkable Format),Linux 下目标文件、可执行程序、动态库 、静态库内部单元统一使用的格式。
2.讲解意义:
我们通过了解ELF,可以知道可执行文件是如何进入内存的。
3.ELF格式:
四大部分:
ELF 头(ELF header) 位于文件开头,记录文件基础属性,用来找到程序头表、节头表位置。
程序头表(Program header table)Segment 供内核加载使用,描述各个内存段在文件中的偏移、大小、权限,指导 mmap 映射到进程虚拟内存。
节头表(Section header table)Section 供链接器 ld使用,记录所有节的信息,用于链接、符号解析、重定位。
节(Section) ELF 内部数据载体,代码、常量、符号表、GOT、重定位信息等分别存放在不同节中。
各种节的介绍:
代码与数据类
1. .text存放程序编译后的机器指令(函数代码),只读。主程序、库的业务代码都在这里。
2. .rodata只读常量数据:字符串常量、const常量;不可修改。
3. .data已初始化全局变量、静态局部变量,可读可写。
4. .bss未初始化全局 / 静态变量。文件内不占用空间,加载时在内存开辟零初始化区域。
动态链接核心
5. .got(全局偏移表)Global Offset Table。存放外部全局变量运行时虚拟地址。
6. .got.pltGOT 的子集,配合 PLT,专门存放外部函数地址,用于延迟绑定。
7. .plt(过程链接表)Procedure Linkage Table一小段跳板汇编代码。第一次调用外部函数,经由 PLT 进入动态链接器解析符号。
8. .dynamic动态链接核心配置块。记录依赖哪些.so、重定位表位置、符号表位置。ld.so 启动首先读取此节。 9. .rela.dyn普通重定位项,用于 .got、数据重定位;程序启动阶段处理。
10. .rela.pltPLT 对应的重定位项,对应 .got.plt,默认延迟绑定,第一次调用函数时解析。
符号与字符串
11. .dynsym动态符号表。ld.so 运行时用来查找外部函数 / 变量(只保留动态链接需要的符号)。
12. .dynstr动态符号字符串表,保存 .dynsym 用到的符号名字符串(printf、malloc 等)。
13. .symtab完整符号表,包含所有符号;运行时 ld.so 默认不用,调试、链接阶段使用;发布程序常 strip 删掉。
14. .strtab配合 .symtab 的字符串表。
其他辅助节
15. .rel / .rela重定位节(.o 目标文件中大量存在);链接器依据它修正地址、构建 GOT。 rela = 带加数项,x86_64 使用 .rela。
16. .init_array / .fini_array共享库 / 程序构造函数、析构函数数组;加载完毕自动执行。
17. .comment编译器版本、编译参数等注释信息。
18. .shstrtab节名字符串表,保存各个 section 名称(.text、.got这类字符串)。

观察工具(主要的):
1. readelf
# 查看ELF头部
readelf -h ./a.out
# 查看节头表(所有Section:.text .got .got.plt .rela.plt)
readelf -S ./a.out
# 查看程序头表(Segment,内核加载视图)
readelf -l ./a.out
# 查看动态段 .dynamic(ld.so依赖、重定位表地址)
readelf -d ./a.out
# 查看重定位表(重点!.rela.dyn .rela.plt,看见GOT偏移)
readelf -r ./a.out
# 查看动态符号表 .dynsym
readelf --dyn-syms ./a.out
2.objdump(反汇编,看汇编代码、PLT 跳板、GOT 调用指令)
# 完整反汇编,观察 PLT 代码、@GOTPCREL 指令
objdump -d ./a.out
# 只看plt段
objdump -d -j .plt ./a.out
# 查看GOT表内存初始值(文件内的值)
objdump -s -j .got.plt ./a.out
3.ldd(查看程序依赖哪些动态库)
4.ELF两大行为------链接和加载
1.链接(合并文件生成大ELF,将ELF写进磁盘,固定格式,不会变)
输入:.o目标文件、动态库信息
视图:链接视图,操作 Section(节)
合并各个目标文件相同名称的节(.text、.data等)
符号解析:区分本地符号、外部未定义符号
构建 .got / .got.plt,为外部符号分配 GOT 条目,确定条目相对 GOT 表头的偏移,写入 ELF
执行链接期重定位:修正机器指令;确定各个地址
输出最终 ELF(可执行文件 /.so)------>磁盘
关键点:链接只约定「GOT 格子位置」,GOT 内不存放外部函数真实地址;此时不需要运行、不需要打开动态库。(后面详细说明)
2.加载(ELF进内存,内核 + ld.so 配合完成)
触发:execve()
运行程序视图:执行视图,操作 Segment(段)
前期准备:
・一个 ELF 会有多种不同的 Section,在加载到内存的时候,也会进行 Section 合并,形成 segment
・合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等。
意义:
减少虚拟地址碎片、节约页表资源
权限分组天然合理
减少 PT_LOAD 段数量,减少 mmap 系统调用
实现前提:
各种格式地址是在磁盘就确定了,那么怎么"合并"的呢?
答:磁盘对齐粒度 与 内存页权限粒度不一致,催生了 Section / Segment 两套视图。
加载流程:
内核读取 ELF 程序头表(Program Header),按照 PT_LOAD 段,mmap 把主程序映射到进程虚拟地址空间。
判断是动态链接程序,加载 ld-linux,把控制权交给动态链接器 ld.so,进行动态链接。
**总结:**就是把ELF数据按ELF原地址填入内存(原地址就是虚拟地址)
一个程序被加载到内存之前有没有地址?
答案是有的------逻辑地址:链接时规划的理论虚拟地址;
ELF文件------>磁盘------>内存;地址变化过程:逻辑地址------>虚拟地址------>物理地址
链接阶段,生成 ELF 文件的时候确定,固化保存在磁盘 ELF 中。只需一个起始虚拟地址,其它全部为偏移量,就可以确定各个部分的地址了,加载进内存时内核可以在此基础上加载虚拟地址即可。
所以我们得到一个重要信息:ELF在磁盘中就已经确定所有地址了,编译器也有编码的功能。
5.理解动静态库的链接和加载
1.静态库的链接和加载
静态库链接就是ELF文件合并链接的过程:
首先根据链接器知道库文件的inode确定库的位置,根据位置找到库文件
主要作用就是执行链接期重定位:确定各个地址,前面没有确定的外部函数或者外部变量的地址都会确定。
最后面就会形成一个大文件ELF
加载就是大文件进入内存
2.动态库的链接和加载
动态库特性:
不存在于执行文件中;运行时加载;加载于共享内存中;多个进程共享。
动态库加载到内存之前也有统一的编码,方便内核直接加载。
根据动态库的特性,那么主程序文件在加载进磁盘时,内部使用到的动态库的函数和变量是怎么确定地址的?毕竟前面说了ELF在磁盘中已经编码了。动态链接器又是怎么工作的?
动态链接器:
◦ 动态链接器(如 ld-linux.so)负责在程序运行时加载动态库。
◦ 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置文件:
◦ Linux 系统通过环境变量(如 LD_LIBRARY_PATH)和配置文件(如 /etc/ld.so.conf 及其子配置文件)来指定动态库的搜索路径。
◦ 这些路径会被动态链接器在加载动态库时搜索。
缓存文件:
◦ 为了提高动态库的加载效率,Linux 系统会维护一个名为 /etc/ld.so.cache 的缓存文件。
◦ 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
那么之前内部使用到的动态库的函数和变量是怎么确定地址的?
难道按主程序ELF来定外部函数地址,然后使动态库的地址按照位置来修改地址吗?
这是不可能的,因为代码共享区是不可改的,改了会出大问题。
那么该怎么实现二者地址映射呢?
解决方案:GOT表
动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数 的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数 的地址。
• 因为.data区域是可读写的,所以可以支持动态进行修改。
工作:
1.链接器扫描所有目标文件的重定位条目,识别所有外部未定义符号(如`printf`)。
为每一个唯一外部符号,在**当前模块(主程序/so)私有`.got/.got.plt`分配一个条目(格子)。
计算:条目相对于GOT段头部的**相对偏移**,永久固化进ELF。
4.重定向:根据条目相对于GOT段头部的**相对偏移**来确定外部未定义符号的地址(不是真实的地址)
5.运行时,写入真实地址:`libc基址 + printf在libc内部偏移`,形成假地址映射真地址
这样就缓解逻辑冲突了。
动态链接和加载的总过程:
一、开发阶段:链接过程
输入:.o目标文件、依赖动态库信息
链接器扫描所有目标文件的重定位条目,识别所有**外部未定义符号(如`printf`)。
为每一个唯一外部符号,在当前模块(主程序/so)私有`.got/.got.plt`分配一个条目(格子)。
计算:条目相对于GOT段头部的**相对偏移**,永久固化进ELF。
> 重点:此时不需要打开libc、不需要知道函数真实地址,只是预先分配格子编号。
- 处理代码重定位:
填充指令内xxx@GOTPCREL(%rip)`的PC相对偏移,让代码运行时能够找到这个GOT格子。
- 在ELF中写入 .rela.dyn / .rela.plt重定位记录。
重定位项保存:GOT条目相对GOT基址的偏移 + 对应的符号名称。
- 输出ELF可执行文件:
✅ GOT段布局、所有符号对应的格子偏移全部固定
❌ GOT条目内没有存放任何动态库函数地址
二、运行阶段:启动执行 ./a.out
步骤1:内核 execve
内核读取主程序ELF,mmap映射主程序各段到进程虚拟空间。
识别是动态链接程序,加载 `ld-linux-x86-64.so.2`(动态链接器),转交控制权。
步骤2:ld.so 初始化,加载依赖共享库
ld.so读取主程序dynamic段,获取需要加载的动态库(libc.so.6等)。
依次搜寻库路径 → `open()`(内核通过路径查询dentry/inode定位磁盘库文件)
3.将动态库映射到进程虚拟地址空间,得到该库运行时加载基址。
注意:到此为止,GOT偏移依旧沿用链接阶段写死的值,没有任何修改。
步骤3:符号解析 + GOT填充(两种模式)
默认开启【延迟绑定 Lazy Binding】
程序启动时不填充.got.plt。
第一次调用外部函数`printf`:
进入PLT桩代码,跳转到`_dl_runtime_resolve`
动态链接器根据重定位信息,在libc符号表查找`printf`
计算真实地址:`libc基址 + printf在libc内部偏移`
使用链接期固定的GOT偏移,定位主程序GOT对应的格子
将printf真实地址写入GOT条目
- 后续再次调用printf:直接读取GOT内保存的函数地址,跳过解析流程。
延迟绑定 Lazy Binding:调用时按照需要填入真实地址,没有一次性全部填入,可以节省资源消耗
核心:
链接阶段:决定【哪个符号占用GOT中第几个格子(相对偏移固定写入ELF)】
运行阶段:只负责【往预先定好的格子里填入符号真实地址】