编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
        • 目录
        • 1. 案例引入
          • 1.1 一个 glibc 版本引发的血案
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 两种库的生存空间
          • 2.2 选型决策矩阵
        • 3. .a 编译期嵌入
          • 3.1 .a 的物理结构
          • 3.2 全量嵌入裁剪
          • 3.3 静态链接的膨胀问题
          • 3.4 静态链接红利
        • 4. .so 运行时动态加载
          • 4.1 .so 的 ELF 结构差异
          • 4.2 加载过程
          • 4.3 搜索路径
          • 4.4 LD_PRELOAD 劫持机制
        • 5. PLT/GOT 延迟绑定
          • 5.1 为什么需要 PLT/GOT
          • 5.2 首次调用五步
          • 5.3 后续调用的快速路径
          • 5.4 LDBINDNOW 全量解析的取舍
        • 6. dlopen 插件加载
          • 6.1 dlopen/dlsym/dlclose 三步曲
          • 6.2 插件架构的设计模式
          • 6.3 RTLDLOCAL vs RTLDGLOBAL 符号隔离
          • 6.4 热加载边界
        • 7. 符号版本管理
          • 7.1 全符号导出代价
          • 7.2 visibility
          • 7.3 SONAME 与 .symver 版本机制
          • 7.4 ABI地狱
        • 8. 综合案例串讲
          • 8.1 案例真相揭晓
          • 8.2 so一生
          • 8.3 面试高频问题清单
          • 8.4 库管理速查卡
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

静态库与动态库对比

# 15.静态库与动态库对比

.a 编译期嵌入 vs .so 运行时动态加载、PLT/GOT 延迟绑定 call → PLT → GOT → .so 五步流程、dlopen/dlsym 运行时加载插件、LD_PRELOAD 劫持、RPATH/RUNPATH 搜索路径、符号可见性 -fvisibility=hidden、.so 版本管理 .symver、静态链接膨胀 vs 动态链接 LD_LIBRARY_PATH 地狱

# 目录

  • 1. 案例引入
    • 1.1 一个 glibc 版本引发的血案
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 两种库的生存空间
    • 2.2 选型决策矩阵
  • 3. .a 编译期嵌入
    • 3.1 .a 的物理结构
    • 3.2 全量嵌入裁剪
    • 3.3 静态链接的膨胀问题
    • 3.4 静态链接红利
  • 4. .so 运行时动态加载
    • 4.1 .so 的 ELF 结构差异
    • 4.2 加载过程
    • 4.3 搜索路径
    • 4.4 LD_PRELOAD 劫持机制
  • 5. PLT/GOT 延迟绑定
    • 5.1 为什么需要 PLT/GOT
    • 5.2 首次调用五步
    • 5.3 后续调用的快速路径
    • 5.4 LD_BIND_NOW 全量解析的取舍
  • 6. dlopen 插件加载
    • 6.1 dlopen/dlsym/dlclose 三步曲
    • 6.2 插件架构的设计模式
    • 6.3 RTLD_LOCAL vs RTLD_GLOBAL 符号隔离
    • 6.4 热加载边界
  • 7. 符号版本管理
    • 7.1 全符号导出代价
    • 7.2 visibility
    • 7.3 SONAME 与 .symver 版本机制
    • 7.4 ABI地狱
  • 8. 综合案例串讲
    • 8.1 案例真相揭晓
    • 8.2 so一生
    • 8.3 面试高频问题清单
    • 8.4 库管理速查卡

# 1. 案例引入

# 1.1 一个 glibc 版本引发的血案

某视频处理服务在 Docker 容器里跑了一年,某天运维升级了基础镜像从 ubuntu:20.04 到 ubuntu:22.04,重新构建后上线——结果所有进程启动即崩:

$ ./video_processor
./video_processor: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
(required by ./video_processor)
1
2
3

现象:

  • 旧镜像(Ubuntu 20.04, glibc 2.31):正常运行
  • 新镜像(Ubuntu 22.04, glibc 2.35):构建成功,但部署到生产(CentOS 7, glibc 2.17)崩溃
  • 本地开发机(macOS)用交叉编译:运行正常

直觉怀疑:是不是动态库路径没设对?ldd 看看:

$ ldd video_processor
        linux-vdso.so.1 (0x00007ffd00000000)
        libavcodec.so.58 => /usr/local/lib/libavcodec.so.58
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
        /lib64/ld-linux-x86-64.so.2 (0x00007f...)
1
2
3
4
5

三个关键信息:

  1. libc.so.6 是动态链接的(.so),不是静态嵌入的(.a)
  2. 构建机器上的 glibc 是 2.35,但目标机器上是 2.17
  3. 动态库在运行时才加载——构建时的版本不能高于运行时的版本

# 1.2 顺藤摸到根因

追查:

  • 假设 1:能不能让目标机器装高版本 glibc?—— CentOS 7 的生命周期里 glibc 就停在 2.17 了。升级 glibc 等于给操作系统换心脏(所有程序都依赖它),风险极高。
  • 假设 2:能不能把 glibc 静态链接进去?—— 技术上可行(-static),但 glibc 不建议静态链接(NSS 模块、iconv 插件等依赖动态加载)。而且二进制膨胀严重——一个 helloworld 静态链接后从 16KB 变成 800KB。musl libc 是更好的静态链接选择。
  • 假设 3:能不能把依赖的 .so 一起打包进容器?—— 但问题在于 ld-linux-x86-64.so.2(动态链接器本身)是内核加载的,它的版本必须与 glibc 兼容。把新版 libc.so 放进容器,但内核用的还是老版 ld-linux ——glibc 和 ld-linux 必须匹配。
  • 假设 4:为什么构建时没报错?—— 因为 gcc 默认用动态链接(.so),只要构建环境里装了对应的 .so,编译和链接都能过。运行时才去找 .so——编译成功不代表能运行。

真正的凶手:动态链接的"版本耦合"——构建环境和运行环境必须 ABI 兼容。当你的代码依赖 libc.so 的函数时,可执行文件里记录了"我依赖 GLIBC_2.34 版本符号"——运行时 ld-linux 检查加载的 libc.so 是否提供了这些版本符号,没有就报错退出。

这个事故里藏着至少 8 个原理点:

① .a 静态库和 .so 动态库的本质区别是什么?                  → 第 2/3/4 章
② PLT/GOT 是什么?call printf 怎么找到 libc 里的 printf?  → 第 5 章
③ dlopen/dlsym 怎么运行时加载插件?                       → 第 6 章
④ LD_PRELOAD 怎么劫持函数调用?                           → 第 4.4 节
⑤ .so 的搜索路径 RPATH/RUNPATH/LD_LIBRARY_PATH 是什么?  → 第 4.3 节
⑥ -fvisibility=hidden 为什么能提速?                      → 第 7.1/7.2 节
⑦ libfoo.so.1.2.3 这种版本号是什么?ABI 兼容性怎么保证?    → 第 7.3/7.4 节
⑧ 静态链接什么时候应该选?动态链接的坑有哪些?              → 第 3/8 章
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这一个 glibc 因版本不兼容崩盘的案例,折射出动态链接的核心矛盾:它带来了代码共享、热更新、插件化等巨大红利,但也带来了"A 机构建的二进制在 B 机上跑不了"的脆弱性。

本篇路线:

架构总图 (第 2 章)
   ↓
.a 静态库 (第 3 章) ─→ 解开"编译期嵌入,二进制自包含"
   ↓
.so 动态库 (第 4 章) ─→ 解开"运行时加载,多进程共享"
   ↓
PLT/GOT 延迟绑定 (第 5 章) ─→ 解开"call printf 到底跳到了哪里"
   ↓
dlopen 插件 (第 6 章) ─→ 解开"运行时加载 .so 实现热插拔"
   ↓
符号可见性 & 版本管理 (第 7 章) ─→ 解开"怎么藏住内部符号、怎么管理 ABI 兼容性"
   ↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
1
2
3
4
5
6
7
8
9
10
11
12
13

📌 本篇定位:这是编译和链接知识链的最后一环——从 .c 编译成 .o,.o 链接成可执行文件/库,库再在编译期或运行期被加载。理解 .a 和 .so 的区别,是"能写出正确 Makefile/CMakeLists"和"能解决生产环境 glibc 地狱"的分水岭。

# 2. 架构概览

# 2.1 两种库的生存空间

静态库(.a)和动态库(.so)在进程地址空间中的位置:

进程地址空间 (x86-64 Linux)

高地址
  ┌─────────────────────────────────┐
  │           内核空间                │
  ├─────────────────────────────────┤
  │  栈 (stack)                      │
  ├─────────────────────────────────┤
  │  ↓ 未分配空间 (可向上下扩展)       │
  ├─────────────────────────────────┤
  │  共享库映射区 (mmap area)         │  ← .so 住在这!
  │  ┌─ libavcodec.so         ─┐    │     运行时由 ld-linux mmap 进来
  │  │  libc.so.6              │    │     物理内存多进程共享 (COW)
  │  │  ld-linux-x86-64.so.2  │    │
  │  └─────────────────────────┘    │
  ├─────────────────────────────────┤
  │  ↑ 堆 (heap)                     │
  ├─────────────────────────────────┤
  │  bss / data / rodata            │  ← .a 的内容编译期合并到这
  ├─────────────────────────────────┤    静态库:代码段与主程序合并
  │  text (主程序 + .a 嵌入的代码)    │    动态库:不在这里,在共享库映射区
  ├─────────────────────────────────┤
  │  保留区 (NULL 保护)              │
  └─────────────────────────────────┘
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

核心区别:

维度 .a 静态库 .so 动态库
何时绑定 链接时 (compile-time) 加载时 (load-time) 或运行时 (runtime, dlopen)
如何嵌入 代码段直接合并进可执行文件 独立文件,运行时 mmap 到进程空间
磁盘占用 每个可执行文件一份副本 一份 .so 供上百个进程共享
内存占用 每个进程一份物理页 代码段物理页多进程共享 (COW)
版本兼容 无运行时依赖 必须 ABI 兼容
更新方式 重新编译 + 重新部署 替换 .so 文件 + 重启进程
二进制大小 大(所有依赖嵌入) 小(只记录引用)

# 2.2 选型决策矩阵

疑惑:什么时候选 .a,什么时候选 .so?

论证:

选 .a (静态库) 的场景:
  ✅ 容器化部署(最终只部署一个 binary,无依赖地狱)
  ✅ 嵌入式系统(无动态链接器)
  ✅ 对启动速度敏感的 CLI 工具
  ✅ 不想暴露内部符号
  ✅ 需要确定性(不受 LD_LIBRARY_PATH 污染)

选 .so (动态库) 的场景:
  ✅ 被多个程序共享的公共库 (libc, libssl, libffmpeg...)
  ✅ 需要热更新/插件化 (dlopen)
  ✅ LGPL 许可(动态链接不触发"传染"条款)
  ✅ 需要节约内存(一个 .so 的代码段全局共享)
  ✅ 项目由多个独立团队维护,独立发布
1
2
3
4
5
6
7
8
9
10
11
12
13

生产建议:

  • 应用层:容器化时代倾向静态链接(Go 的哲学),或静态打包 .so 随 binary 一起发布
  • 系统层:公共库必须走 .so,否则一个安全补丁需要重编整个系统
  • 插件层:必然 .so + dlopen

# 3. .a 编译期嵌入

# 3.1 .a 的物理结构

静态库 .a 本质是 .o 文件的归档:

# 创建一个静态库
$ gcc -c foo.c bar.c
$ ar rcs libmylib.a foo.o bar.o

# 看看里面有什么
$ ar -t libmylib.a
foo.o
bar.o

$ nm -s libmylib.a              # 看符号索引
Archive index:
  foo in foo.o
  bar in bar.o

foo.o:
0000000000000000 T foo
                 U printf

bar.o:
0000000000000000 T bar
0000000000000000 D global_config
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

.a 文件格式:

┌──────────────────────────────┐
│  "!<arch>\n" (ar 魔数, 8字节) │
├──────────────────────────────┤
│  ar_hdr + foo.o 内容         │  ← 每个成员: 60字节头 + 文件内容
├──────────────────────────────┤
│  ar_hdr + bar.o 内容         │
├──────────────────────────────┤
│  // (符号索引, ranlib 生成)   │  ← __SYMDEF: 符号 → .o 成员的映射
├──────────────────────────────┤
│  /  (长文件名表)              │
└──────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11

关键:.a 中的 .o 成员保持独立——每个成员有自己的 .text/.data/.symtab。链接时,链接器以成员 .o 为单位决定是否提取。

# 3.2 全量嵌入裁剪

静态链接的完整流程:

$ gcc main.c -L. -lmylib -o app
# 等价于:
$ ld main.o libmylib.a -lc -o app

# 链接器做的事:
# 1. 扫描 main.o: 引入 U 符号 {foo, printf}
# 2. 扫描 libmylib.a: foo 能解析 U:foo → 提取 foo.o
#    foo.o 引入新 U: {printf}
# 3. 扫描 libc.a: printf 能解析 → 提取 printf.o
#    递归提取 printf.o 依赖的其他 .o...
# 4. 合并所有提取的 .o 的 .text/.data/.bss → 可执行文件
1
2
3
4
5
6
7
8
9
10
11

静态链接只提取用到的 .o——不是把整个 .a 嵌进去:

$ ls -lh
-rw-r--r-- 1 user user  48K libmylib.a      # 静态库: 48KB
-rwxr-xr-x 1 user user 832K app              # 可执行文件: 832KB
# 只嵌入了 foo.o (几百字节), 大头是 glibc 的 printf.o 链
1
2
3
4

但 glibc 的 printf 链会引入大量依赖:

printf.o → vfprintf.o → _IO_file_jumps → ...
         → malloc.o  → brk.o
         → locale.o  → ...
每个看似简单的调用,静态链接都可能引入数十个 .o
1
2
3
4

# 3.3 静态链接的膨胀问题

# 对比: 同一个程序的动态 vs 静态
$ cat hello.c
#include <stdio.h>
int main() { printf("hello\n"); }

$ gcc -O2 hello.c -o hello_dyn           # 动态链接
$ gcc -O2 -static hello.c -o hello_static # 静态链接

$ ls -lh hello_dyn hello_static
-rwxr-xr-x  16K  hello_dyn               # 动态: 16 KB
-rwxr-xr-x 800K  hello_static            # 静态: 800 KB ← 50倍!
1
2
3
4
5
6
7
8
9
10
11

为什么膨胀这么多?

  • printf 的功能需要格式化解析、IO 缓冲、locale 处理、宽字符转换...
  • 每个子功能在 glibc 中都有独立的 .o,静态链接全部拉进来
  • 如果 100 个程序都静态链接 → 100 × 800KB = 80MB 磁盘,而动态链接只有一份 libc 15MB + 100 × 16KB

静态链接的优化手段:

  • 用 musl libc(比 glibc 更精简)替代 glibc 做静态链接:hello world 从 800KB → 50KB
  • -ffunction-sections -Wl,--gc-sections:丢弃未引用的函数节
  • -Wl,--strip-all:去除符号表和调试信息

# 3.4 静态链接红利

疑惑:既然静态链接又大又笨,为什么 Go/Rust 默认静态链接?为什么 Docker 时代反而推荐静态链接?

论证:

  1. 自包含部署:
# 动态链接的部署噩梦
$ ldd my_app | wc -l
47    # 依赖 47 个 .so!
      # libssl.so.1.1 ≠ libssl.so.3 (ABI 不兼容)
      # libffmpeg.so.58 (Ubuntu 22.04) vs libffmpeg.so.56 (CentOS 7)
      # → 必须把所有 .so 打进 Docker 镜像,且要保证路径/版本匹配

# 静态链接的部署极简
$ scp my_app_static user@prod-server:/opt/app/
$ ssh prod-server /opt/app/my_app_static
# 不需要装任何依赖,不需要容器
1
2
3
4
5
6
7
8
9
10
11
  1. 安全审计:
# 动态链接:恶意 .so 可以 LD_PRELOAD 劫持任何函数
$ LD_PRELOAD=./evil.so ./my_app
# evil.so 可以拦截 fopen/connect/write 等系统调用

# 静态链接:没有 PLT/GOT,没有动态加载,无法被 LD_PRELOAD 劫持
# → 符号绑定在链接时已经全部解析,运行时不需要 ld-linux
1
2
3
4
5
6
  1. 确定性:
# 动态链接:程序的行为依赖于运行时加载的 .so 版本
# → 同一份 binary 在 glibc 2.31 和 2.35 下行为不同

# 静态链接:所有依赖在编译时锁定
# → 同一份 binary 在所有机器上行为一致
1
2
3
4
5

结论:在容器化/云原生时代,静态链接的"自包含"优势压倒了以前的"磁盘/内存浪费"劣势。

# 4. .so 运行时动态加载

# 4.1 .so 的 ELF 结构差异

.so 与普通可执行文件在 ELF 结构上的关键差异:

$ readelf -h libfoo.so
ELF Header:
  Type:   DYN (Shared object file)   # ← .so 的类型是 ET_DYN
                                       # 可执行文件是 ET_EXEC 或 ET_DYN (PIE)

$ readelf -l libfoo.so               # 看程序头 (加载视图)
Program Headers:
  LOAD     ...   r--    (只读段)
  LOAD     ...   r-x    (代码段)
  LOAD     ...   r--    (只读数据)
  LOAD     ...   rw-    (可读写数据)
  DYNAMIC  ...   rw-    (动态链接信息: 依赖哪些 .so, 需要哪些符号...)
  GNU_EH_FRAME ...  r-- (异常处理信息)
  GNU_RELRO ...      r-- (只读重定位保护)

$ readelf -d libfoo.so               # 看动态段 (.dynamic)
Dynamic section at offset ... contains 28 entries:
  NEEDED   libbar.so                  # 依赖的其他 .so
  NEEDED   libc.so.6
  SONAME   libfoo.so.1                # 自己的"正式名" (版本化)
  RPATH    /opt/myapp/lib             # 运行时搜索路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

关键差异:

ELF 字段 可执行文件 .so
e_type ET_EXEC / ET_DYN (PIE) ET_DYN
.interp 必须有(指向 ld-linux) 无
.dynamic 有(动态链接信息) 有
SONAME 无 有(用于版本管理)
入口点 _start 有 无(但从 init/fini 函数有入口)

# 4.2 加载过程

当你在 shell 中键入 ./app 时,内核和动态链接器做了这些事:

execve("./app", ...)
  │
  ├─ 1. 内核解析 ELF 头
  │     → 发现 .interp 段 → 指向 /lib64/ld-linux-x86-64.so.2
  │
  ├─ 2. 内核 mmap 可执行文件的 LOAD 段 (text, data, bss)
  │
  ├─ 3. 内核 mmap ld-linux (动态链接器本身)
  │
  ├─ 4. 内核把控制权交给 ld-linux 的 _start
  │     (而不是 app 的 _start!)
  │
  ├─ 5. ld-linux 执行初始化:
  │     → 读取 app 的 .dynamic 段 → 拿到 NEEDED 列表
  │     → 例如: NEEDED libc.so.6, NEEDED libm.so.6
  │     → 遍历 NEEDED 列表,按照搜索路径找到每个 .so
  │     → mmap 每个 .so 的 LOAD 段
  │
  ├─ 6. ld-linux 执行重定位:
  │     → 填充 GOT 表 (R_X86_64_GLOB_DAT)
  │     → 如果是 lazy binding: PLT 桩的 GOT 条目先指向 PLT 解析器
  │     → 如果是 BIND_NOW: 立即解析所有 PLT 条目
  │
  ├─ 7. ld-linux 调用各 .so 的初始化函数 (init / .init_array)
  │
  ├─ 8. ld-linux 跳转到 app 的 _start
  │
  └─ 9. __libc_start_main → main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

关键:main() 执行之前,内核 + ld-linux 已经做了大量工作。LD_DEBUG=all 可以观察全过程:

$ LD_DEBUG=all ./app 2>&1 | head -40
# 输出 ld-linux 做的每一步: 搜索路径、加载 .so、符号解析、重定位...
1
2

# 4.3 搜索路径

疑惑:ld-linux 怎么找到 NEEDED 中列出的 .so?

论证——搜索优先级(从高到低):

1. 可执行文件 (.dynamic 段) 中记录的 RPATH
   gcc -Wl,-rpath,/opt/myapp/lib

2. 环境变量 LD_LIBRARY_PATH
   export LD_LIBRARY_PATH=/opt/myapp/lib:$LD_LIBRARY_PATH

3. 可执行文件中记录的 RUNPATH
   gcc -Wl,-rpath,/opt/myapp/lib,--enable-new-dtags
   # --enable-new-dtags 让链接器写 RUNPATH 而不是 RPATH

4. /etc/ld.so.cache (由 ldconfig 维护的缓存)
   ldconfig               # 更新缓存
   ldconfig -p            # 查看缓存内容

5. 系统默认路径
   /lib, /usr/lib, /lib64, /usr/lib64
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

RPATH vs RUNPATH 的关键区别:

# RPATH: LD_LIBRARY_PATH 可以覆盖 RUNPATH,但无法覆盖 RPATH
# RUNPATH: LD_LIBRARY_PATH 可以覆盖 RUNPATH

# 选 RPATH 还是 RUNPATH?
# RPATH: 更适合"你希望固定搜索路径,不受环境变量污染"的场景
# RUNPATH: 更适合"允许用户通过 LD_LIBRARY_PATH 覆盖"的场景
1
2
3
4
5
6

生产最佳实践:

# 把 .so 和 binary 放一起,用 $ORIGIN 相对路径
$ gcc -Wl,-rpath,'$ORIGIN/../lib' main.o -lfoo -o app
# $ORIGIN = app 所在的目录
# → app 在任何目录下都能找到 ../lib/libfoo.so
1
2
3
4

# 4.4 LD_PRELOAD 劫持机制

LD_PRELOAD 在动态链接之前,强制加载指定的 .so:

$ LD_PRELOAD=/path/to/custom.so ./app
# ld-linux 在处理任何 NEEDED .so 之前,先加载 custom.so
# 如果 custom.so 定义了与 libc.so 同名的函数,
# 符号解析时 custom.so 的版本优先(因为它在搜索列表最前面)
1
2
3
4

经典应用——内存分配器替换:

# jemalloc 替代 glibc malloc
$ LD_PRELOAD=/usr/lib/libjemalloc.so ./my_server

# tcmalloc
$ LD_PRELOAD=/usr/lib/libtcmalloc.so ./my_server
1
2
3
4
5

不修改代码、不重新编译——只是运行时加了个环境变量,所有 malloc 调用都被劫持到 jemalloc。

经典应用——函数行为监控:

// hook_malloc.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

void* malloc(size_t size) {
    static void* (*real_malloc)(size_t) = NULL;
    if (!real_malloc)
        real_malloc = dlsym(RTLD_NEXT, "malloc");  // 找"下一个" malloc

    void* p = real_malloc(size);
    fprintf(stderr, "malloc(%zu) = %p\n", size, p);
    return p;
}

// gcc -shared -fPIC hook_malloc.c -o hook_malloc.so -ldl
// LD_PRELOAD=./hook_malloc.so ./app
// 所有 malloc 调用都被"拦截+日志"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

安全风险:LD_PRELOAD 也是攻击者最爱的工具——注入恶意 .so 劫持 fopen/connect/write 等函数。大多数 setuid 程序会忽略 LD_PRELOAD(AT_SECURE 标记)。

# 5. PLT/GOT 延迟绑定

# 5.1 为什么需要 PLT/GOT

疑惑:为什么调用 printf 不能像调用自己的 foo() 那样直接 call?

论证:编译器在生成 .o 时不知道运行时 libc 的基址(ASLR 随机化)。同一个 printf 在不同进程中位于不同的虚拟地址。

解决:间接跳转——不是直接跳到 printf,而是通过一个"跳板(PLT, Procedure Linkage Table)"和"地址表(GOT, Global Offset Table)":

call printf@PLT          ; 用户代码只写这一行
    │
    └──► PLT[printf]:
            jmp *GOT[printf]        ; 间接跳转到 GOT 表中的地址
                │
                ├─ 第一次: GOT[printf] = &PLT解析器
                │     → 解析器找到 libc 中 printf 的真实地址
                │     → 把真实地址写入 GOT[printf]
                │     → 然后跳到真实地址
                │
                └─ 之后: GOT[printf] = libc 中 printf 的真实地址
                      → 直接跳转,不走解析器
1
2
3
4
5
6
7
8
9
10
11
12

# 5.2 首次调用五步

第一次调用 printf 的完整流程:

用户代码:               PLT                     GOT                     ld-linux
────────                ───                     ───                     ───────

1. call printf@PLT ──►
                        2. jmp *GOT[printf]
                            │                  GOT[printf] = &plt_stub
                            │                  ───────────────────────►
                        3. push index               (跳到 PLT 桩)
                           jmp PLT[0]
4. PLT[0] 压入
   动态链接信息                                          5. _dl_runtime_resolve:
   跳到 dl_runtime_resolve                                  解析符号"printf"
                                                            找到 libc 中 printf 地址
                                                            写入 GOT[printf]
                                                            跳转到真实 printf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 5.3 后续调用的快速路径

第二次及以后调用:GOT 表已被填充,直接跳转:

call printf@PLT ──► jmp *GOT[printf] ──► libc 中 printf 的真实地址
                    (GOT 已有地址)
1
2

性能对比:

调用次数 额外开销
第一次 符号解析(字符串哈希 + 查找,~几百 ns)+ GOT 写入
之后每次 仅 2 次内存访问(jmp 指令读取 + GOT 表读取),~1 ns

这就是 **lazy binding(延迟绑定)**的核心思想——为使用频率付费:不调用的函数永远不解析,频繁调用的函数只有第一次慢。

# 5.4 LD_BIND_NOW 全量解析的取舍

$ LD_BIND_NOW=1 ./app
# 启动时解析所有 PLT 条目,而不是惰性解析
1
2

对比:

策略 启动速度 首次调用延迟 确定性 安全
Lazy binding (默认) 快 第一次慢 低(不确定何时慢) GOT 可写
LD_BIND_NOW 慢 一致 高(可预测) GOT 只读 (RELRO)

RELRO (RELocation Read-Only) 是最重要的安全增强:

Partial RELRO (默认):
  .got   rw-  (可写,lazy binding 需要写 GOT)
  .got.plt  rw-

Full RELRO (-Wl,-z,relro -Wl,-z,now):
  .got   r--  (只读!)
  .got.plt  r--  (只读!)
  → 必须在启动时解析全部 PLT → 任何 "GOT 覆写攻击" 失效
1
2
3
4
5
6
7
8

生产建议:安全敏感的服务用 Full RELRO:

$ gcc -Wl,-z,relro -Wl,-z,now main.o -lfoo -o app
# -z relro: 把 .got 变为只读
# -z now: BIND_NOW,启动时全量解析
1
2
3

# 6. dlopen 插件加载

# 6.1 dlopen/dlsym/dlclose 三步曲

#include <dlfcn.h>

// 1. 打开 .so
void* handle = dlopen("./libplugin.so", RTLD_LAZY | RTLD_LOCAL);
if (!handle) {
    fprintf(stderr, "dlopen: %s\n", dlerror());
    exit(1);
}

// 2. 查找符号
typedef int (*init_func)(const char*);
init_func plugin_init = (init_func)dlsym(handle, "plugin_init");
if (!plugin_init) {
    fprintf(stderr, "dlsym: %s\n", dlerror());
    dlclose(handle);
    exit(1);
}

// 3. 调用插件
int ret = plugin_init("config.json");

// 4. 关闭插件
dlclose(handle);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

dlopen 的 flag 参数:

Flag 含义
RTLD_LAZY 延迟绑定(类似默认动态链接行为)
RTLD_NOW 立即解析所有符号(失败则 dlopen 返回 NULL)
RTLD_LOCAL 插件内的符号不暴露给后续的 dlopen 加载的 .so
RTLD_GLOBAL 插件内的符号全局可见
RTLD_NODELETE dlclose 时不卸载(防止 dangling 指针)
RTLD_NOLOAD 只查询是否已加载,不实际加载
RTLD_DEEPBIND 先用本 .so 内的符号,再搜索全局符号

# 6.2 插件架构的设计模式

// plugin_interface.h —— 所有插件必须实现这个接口
typedef struct {
    const char* name;
    int  (*init)(const char* config);
    int  (*process)(void* data, size_t len);
    void (*destroy)(void);
} plugin_vtable_t;

typedef plugin_vtable_t* (*plugin_get_vtable_t)(void);
1
2
3
4
5
6
7
8
9
// audio_plugin.c —— 一个具体插件的实现
#include "plugin_interface.h"

static int audio_init(const char* config) { ... }
static int audio_process(void* data, size_t len) { ... }
static void audio_destroy(void) { ... }

static plugin_vtable_t vtable = {
    .name    = "audio_codec",
    .init    = audio_init,
    .process = audio_process,
    .destroy = audio_destroy,
};

// 这个符号是 dlopen → dlsym 的入口
plugin_vtable_t* get_plugin_vtable(void) {
    return &vtable;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// host.c —— 加载所有插件
void load_plugins(const char* plugin_dir) {
    DIR* dir = opendir(plugin_dir);
    struct dirent* ent;
    while ((ent = readdir(dir)) != NULL) {
        if (!strstr(ent->d_name, ".so")) continue;

        char path[512];
        snprintf(path, sizeof(path), "%s/%s", plugin_dir, ent->d_name);

        void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
        plugin_get_vtable_t get_vt = dlsym(h, "get_plugin_vtable");
        plugin_vtable_t* vt = get_vt();

        register_plugin(vt);  // 加入插件注册表
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 6.3 RTLD_LOCAL vs RTLD_GLOBAL 符号隔离

疑惑:为什么插件加载推荐用 RTLD_LOCAL?

论证——两个插件定义了同名符号的冲突:

// plugin_a.c
int helper(void) { return 1; }
int do_a(void) { return helper(); }

// plugin_b.c
int helper(void) { return 2; }   // 同名!
int do_b(void) { return helper(); }
1
2
3
4
5
6
7
RTLD_GLOBAL 模式:
  dlopen("plugin_a.so", RTLD_GLOBAL)  → helper 全局可见
  dlopen("plugin_b.so", RTLD_GLOBAL)  → helper 被 plugin_a 的版本"影子"了
  → do_b() 实际调用的是 plugin_a 的 helper()!输出 1 而不是 2!

RTLD_LOCAL 模式:
  dlopen("plugin_a.so", RTLD_LOCAL) → helper 仅 plugin_a 内部可见
  dlopen("plugin_b.so", RTLD_LOCAL) → helper 仅 plugin_b 内部可见
  → do_a() 调自己的 helper() → 1
  → do_b() 调自己的 helper() → 2
  → 互不干扰 ✅
1
2
3
4
5
6
7
8
9
10
11

生产原则:除非明确需要插件间符号共享,永远用 RTLD_LOCAL。

# 6.4 热加载边界

void hot_reload_plugin(void** handle, plugin_vtable_t** vt) {
    // 1. 卸载旧版本
    if (*handle) {
        dlclose(*handle);
    }

    // 2. 加载新版本
    *handle = dlopen("./libplugin.so", RTLD_NOW | RTLD_LOCAL);
    if (!*handle) {
        // 加载失败 → 保持旧版本继续运行
        return;
    }

    // 3. 更新函数指针
    plugin_get_vtable_t get_vt = dlsym(*handle, "get_plugin_vtable");
    *vt = get_vt();

    // 4. 调用新版本的初始化
    (*vt)->init("config.json");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

热更新的边界:

  • ✅ 纯函数替换(无状态,或状态在堆上)
  • ✅ 算法升级、格式解析、网络协议处理
  • ⚠️ 全局状态需要迁移(旧版本的数据结构 → 新版本)
  • ❌ C++ 对象的 vtable 运行时重连(内存布局可能不兼容)
  • ❌ 线程正在旧代码中执行时卸载 → SIGSEGV

# 7. 符号版本管理

# 7.1 全符号导出代价

疑惑:.so 默认把所有非 static 符号导出,这有什么问题?

论证——两个生产级问题:

问题 1:符号污染

// liba.so 内部使用了一个排序函数,叫 sort()
void sort(int* arr, int n) { ... }
void api_a(void) { sort(...); }

// libb.so 也有自己的内部排序,也叫 sort()
void sort(int* arr, int n) { ... }
void api_b(void) { sort(...); }
1
2
3
4
5
6
7
$ gcc -shared -fPIC a.c -o liba.so
$ gcc -shared -fPIC b.c -o libb.so
$ gcc main.c -L. -la -lb -o app

$ LD_DEBUG=bindings ./app 2>&1 | grep sort
# 两个 sort 冲突!dl-linux 只能选一个
# libb.so 的 sort() 可能替代了 liba.so 的 sort()
# → liba.so 行为异常
1
2
3
4
5
6
7
8

问题 2:ABI 膨胀

  • 导出了 1000 个符号 → 其中 990 个是内部实现细节
  • 只要有一个内部符号的签名/行为变了 → ABI 破坏
  • → 外部依赖方必须重新编译
  • 但如果只导出 10 个公开 API(Public API)→ 只要这 10 个不变就行

# 7.2 visibility

// libmylib.h —— 公开头文件
#define API_EXPORT __attribute__((visibility("default")))

API_EXPORT int public_init(void);           // 导出
API_EXPORT int public_process(int x);      // 导出

// libmylib.c —— 内部实现
#include "libmylib.h"

int public_init(void) { ... }
int public_process(int x) { return internal_helper(x); }

// 不加 API_EXPORT → 默认 hidden,不导出
int internal_helper(int x) { ... }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ gcc -fvisibility=hidden -shared -fPIC libmylib.c -o libmylib.so

$ nm -D libmylib.so | grep ' T '
0000000000001100 T public_init      # 只有这两个导出!
0000000000001140 T public_process

$ nm -D libmylib.so | grep internal_helper
# 没有输出 —— internal_helper 已被隐藏
1
2
3
4
5
6
7
8

效果量化:

# 无可见性控制
$ gcc -shared -fPIC -O2 libbig.c -o libbig.so
$ nm -D libbig.so | wc -l
1245       # 导出 1245 个符号

# 有可见性控制
$ gcc -fvisibility=hidden -shared -fPIC -O2 libbig.c -o libbig.so
$ nm -D libbig.so | wc -l
12         # 只导出 12 个公开 API
1
2
3
4
5
6
7
8
9

收益:

  1. 加载更快(.dynsym 表小了 100 倍 → 符号解析 O(n) 搜索小了)
  2. 编译器可以做更多优化(内部函数可以被内联,因为"无论如何不会被外部调用")
  3. 二进制更小(.dynsym + .dynstr 减小)
  4. ABI 维护成本降低(只维护公开 API)

# 7.3 SONAME 与 .symver 版本机制

Linux .so 的版本命名约定:

libfoo.so.1.2.3
│       │ │ └─ release (微小修订,ABI 兼容)
│       │ └─── minor (新增 API,ABI 兼容)
│       └───── major (ABI 不兼容的变更)
└───────────── 库名
1
2
3
4
5

SONAME 机制:

# 编译时指定 SONAME
$ gcc -shared -fPIC -Wl,-soname,libfoo.so.1 \
      foo.c -o libfoo.so.1.2.3

$ readelf -d libfoo.so.1.2.3 | grep SONAME
  SONAME     libfoo.so.1           # 记录在 .dynamic 段

# 链接时 -lfoo → ld 去找 libfoo.so (符号链接)
# 运行时 → ld-linux 去加载 libfoo.so.1 (SONAME)
# → 只要 SONAME 不变,minor/patch 更新不需要重连

$ ls -l /usr/lib/libfoo*
lrwxrwxrwx  libfoo.so -> libfoo.so.1          # 编译时链接用
lrwxrwxrwx  libfoo.so.1 -> libfoo.so.1.2.3   # SONAME 链接
-rwxr-xr-x  libfoo.so.1.2.3                   # 实际文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

GNU symbol versioning 更精细的控制:

// 同一个函数,不同版本有不同的实现
#include <string.h>

// v1: 使用了 alloca
__asm__(".symver original_strdup,strdup@LIBFOO_1.0");
char* original_strdup(const char* s) { ... }

// v2: 安全性改进(对 NULL 返回 NULL)
__asm__(".symver safe_strdup,strdup@@LIBFOO_2.0");
char* safe_strdup(const char* s) {
    if (!s) return NULL;
    return original_strdup(s);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

这样升级 libfoo 后,老程序继续用 LIBFOO_1.0 的版本,新程序自动用 LIBFOO_2.0 的版本——一个 .so 同时服务两个 ABI。

# 7.4 ABI地狱

什么是 ABI 兼容:升级 .so 后,现有二进制无需重新编译即可正常运行。

破坏 ABI 的常见操作:

操作 ABI 兼容? 典型错误信息
函数签名加参数 ❌ 栈被破坏,参数错位
struct 中间加字段 ❌ 偏移量全乱,读垃圾值
删除/重命名函数 ❌ undefined symbol
enum 中间插入新值 ❌ 枚举值映射错误
改函数返回类型 ❌ 返回值截断/溢出
函数末尾加参数 ✅ (C 不行, C++ 有时可以) —
struct 末尾加字段 ✅ (如果调用方不分配该 struct) —
新增函数 ✅ —
改实现不改签名 ✅ —

最佳实践:

# 用 abi-compliance-checker 检查 ABI 破坏
$ abi-dumper libfoo.so.1.0 -o libfoo-1.0.abi
$ abi-dumper libfoo.so.2.0 -o libfoo-2.0.abi
$ abi-compliance-checker -l libfoo \
    -old libfoo-1.0.abi -new libfoo-2.0.abi
# 报告: 哪些符号被删了、改了什么、是否 ABI 兼容
1
2
3
4
5
6

# 8. 综合案例串讲

# 8.1 案例真相揭晓

回到第 1 章 glibc 版本崩溃,八个疑问逐条作答:

疑问 答案
① .a 和 .so 的本质区别? 第 2/3/4 章:.a 编译期嵌入(代码合并进二进制),.so 运行时加载(独立文件 mmap)
② PLT/GOT 是什么? 第 5 章:间接跳转机制,call → PLT 桩 → GOT 表 → 真实地址;第一次慢,之后快
③ dlopen/dlsym 怎么加载插件? 第 6 章:dlopen(path, flags) → dlsym(handle, "symbol") → 函数指针 → 调用
④ LD_PRELOAD 怎么劫持? 第 4.4 节:强制加载 .so,符号优先解析,不加代码的 malloc 替换/函数监控
⑤ RPATH/RUNPATH/LD_LIBRARY_PATH 是什么? 第 4.3 节:.so 搜索路径优先级链,$ORIGIN 相对路径最佳实践
⑥ -fvisibility=hidden 为什么提速? 第 7.1/7.2 节:减少 .dynsym 大小,编译器更多优化机会,ABI 维护更简单
⑦ .so 版本号怎么管理? 第 7.3 节:SONAME 锁定主版本,.symver 支持同 .so 多版本共存
⑧ 什么时候选静态链接? 第 3.4/8.3 节:容器化部署、嵌入式、确定性优先

第 1 章案例的完整根因链条:

开发环境: Ubuntu 22.04, glibc 2.35
生产环境: CentOS 7, glibc 2.17

gcc 编译 → 生成 ELF → .dynamic 段记录:
  NEEDED libc.so.6          (依赖 libc)
  ↓
ldd 分析 → 生产机器尝试加载 libc.so.6
  ↓
ld-linux 检查 libc.so.6 的 .gnu.version_r 段:
  生产机器 libc.so.6 提供的最高版本: GLIBC_2.17
  二进制需要的最早版本: GLIBC_2.34  (二进制在链接时自动记录)
  → GLIBC_2.34 > 2.17 → 报错退出
1
2
3
4
5
6
7
8
9
10
11
12

修复方案(按实用性排序):

方案 A:匹配运行环境编译(最稳)

# 在 CentOS 7 的 Docker 容器中编译
$ docker run -v $PWD:/src centos:7 sh -c \
    'cd /src && gcc -O2 main.c -o app'
# → 产出的 binary 兼容 CentOS 7 及所有更新版本
1
2
3
4

方案 B:静态链接 musl libc

$ musl-gcc -static -O2 main.c -o app
# musl 是为静态链接设计的轻量 libc
# → binary 约 50KB,零运行时依赖
1
2
3

方案 C:容器化部署 + 所有 .so 打包进镜像

FROM ubuntu:22.04 AS builder
RUN gcc -O2 main.c -o /app/server

FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /lib/x86_64-linux-gnu/libc.so.6 /lib/
COPY --from=builder /lib64/ld-linux-x86-64.so.2 /lib64/
# 必须精确复制依赖树
# 这就是为什么 Go "静态编译进单一 binary" 的理念赢了
1
2
3
4
5
6
7
8
9

# 8.2 so一生

把 libcalc.so 从源码到运行时串起来:

编译期
  ├─ gcc -fPIC -c calc.c → calc.o (PIC=位置无关代码)
  ├─ gcc -shared -Wl,-soname,libcalc.so.1
  │     calc.o -o libcalc.so.1.2.3
  │
  ├─ .so 的 ELF 结构:
  │   e_type = ET_DYN (共享对象)
  │   .dynamic: SONAME libcalc.so.1, NEEDED libm.so.6
  │   .dynsym: add, sub, mul, div (动态符号表)
  │   .rela.dyn / .rela.plt: GOT 表的重定位条目
  │   .hash / .gnu.hash: 符号哈希表 (加速查找)
  │
  └─ 符号链接:
      ln -s libcalc.so.1.2.3 libcalc.so.1    (SONAME 链接)
      ln -s libcalc.so.1 libcalc.so           (编译用链接)

链接期 (用户程序链接 libcalc)
  ├─ gcc main.c -L. -lcalc -o app
  │   → ld 把 main.o 和 libcalc.so 链接:
  │     → app 的 .dynamic: NEEDED libcalc.so.1
  │     → app 的 PLT: calc_add@PLT, calc_sub@PLT...
  │     → app 的 GOT: 空槽,等运行时填
  │
  └─ app 的 .dynamic 段中还记录了:
      RPATH: $ORIGIN/../lib  (应用自己的 lib 目录)

加载期 (./app)
  ├─ execve → 内核 mmap app 的 LOAD 段
  ├─ 内核 mmap ld-linux → 控制权转交
  ├─ ld-linux 读 app 的 .dynamic:
  │     → NEEDED libcalc.so.1 → 搜索路径找到 → mmap
  │     → NEEDED libc.so.6   → 搜索路径找到 → mmap
  │     → NEEDED libm.so.6   → 搜索路径找到 → mmap
  │
  ├─ ld-linux 做重定位:
  │     → GOT 表全部解析 (BIND_NOW) 或 设置 PLT 桩 (lazy)
  │
  ├─ ld-linux 调用各 .so 的 init 函数
  ├─ 跳到 app 的 _start → __libc_start_main → main
  │
  └─ main 中调 calc_add → call calc_add@PLT
       → PLT 桩 跳转 GOT[calc_add] → libcalc.so 中 calc_add 的真实地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# 8.3 面试高频问题清单

1. .a 和 .so 的区别?何时用哪种?

.a 编译期嵌入(代码合并进二进制,无运行时依赖),.so 运行时加载(独立文件,多进程共享)。容器化/嵌入式选 .a,系统公共库/插件选 .so。详见第 2 章决策矩阵。

2. PLT/GOT 是什么?为什么第一次调用慢?

PLT 是跳板表,GOT 是全局偏移表。call printf@PLT → jmp *GOT[printf]。第一次时 GOT 指向 PLT 解析器(需要符号查找 → 写 GOT),之后 GOT 直接指向 libc 中 printf 的真实地址。第 5 章有完整五步流程图。

3. LD_PRELOAD 的原理?

运行时强制预先加载指定 .so,符号解析时 LD_PRELOAD 加载的 .so 优先于正常 NEEDED 的 .so。可以无侵入地替换 malloc、截获文件操作、注入监控代码。但攻击者也用它做恶意注入。

4. -fvisibility=hidden 做了什么?为什么推荐用?

将 .so 的非公开符号标记为不导出(STV_HIDDEN)。减少 .dynsym 大小 → 加载更快,编译器可以做更多内部优化(内联等),ABI 维护更容易。只显式导出公开 API。

5. SONAME 的作用是什么?

.so 的"正式名称"。程序运行时 ld-linux 按 SONAME 去找 .so,而不是按文件名。SONAME 通常只含主版本号(libfoo.so.1),这样次版本更新可以替换文件而无需重新链接程序。

6. dlopen 的 RTLD_LOCAL 和 RTLD_GLOBAL 有什么区别?

RTLD_LOCAL:本 .so 的符号不暴露给后续 dlopen 的 .so,避免符号冲突。RTLD_GLOBAL:符号全局可见。插件架构默认应该用 RTLD_LOCAL,除非明确需要共享。

7. RPATH 和 RUNPATH 的区别?LD_LIBRARY_PATH 能覆盖谁?

RPATH: LD_LIBRARY_PATH 不能覆盖。RUNPATH: LD_LIBRARY_PATH 可以覆盖。--enable-new-dtags 让链接器生成 RUNPATH。$ORIGIN 是比较好的选择。

8. 什么是 ABI 兼容?怎么检查?

升级 .so 后现有 binary 无需重新编译就能运行。修改函数签名/SIZEOF/偏移量会破坏 ABI。用 abi-compliance-checker 或 abi-dumper 工具检查。第 7.4 节有破坏 ABI 的操作列表。

9. 静态链接为什么让 binary 变大?musl 比 glibc 小在哪?

printf 的 glibc 实现依赖数百个 .o(格式化、IO、locale...),静态链接全部拉入。musl 重新设计以减小体积为目标,移除了 glibc 中许多历史兼容层和庞大 locale 系统。

10. 什么是 Lazy Binding?与 BIND_NOW/Full RELRO 的关系?

Lazy Binding: 第一次调用才解析符号。BIND_NOW: 启动时解析所有符号。Full RELRO = BIND_NOW + GOT 只读,是最安全的选择(防止 GOT 覆写攻击),代价是启动稍慢。安全敏感服务应用 Full RELRO。

# 8.4 库管理速查卡

编译与链接命令:

# 静态库
gcc -c foo.c -o foo.o
ar rcs libfoo.a foo.o bar.o          # 创建静态库
ranlib libfoo.a                       # 更新符号索引
gcc main.c -L. -lfoo -o app          # 链接静态库

# 动态库
gcc -fPIC -c foo.c -o foo.o          # 位置无关代码
gcc -shared -Wl,-soname,libfoo.so.1 \
    foo.o -o libfoo.so.1.2.3         # 创建动态库
ln -s libfoo.so.1.2.3 libfoo.so.1    # SONAME 链接
ln -s libfoo.so.1 libfoo.so          # 编译用链接
gcc main.c -L. -lfoo -o app          # 链接动态库

# 带可见性控制的编译
gcc -fvisibility=hidden -fPIC -c foo.c
gcc -shared -Wl,-soname,... *.o -o libfoo.so
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

查看命令:

# 查看依赖
ldd ./app                             # 看动态库依赖树
readelf -d ./app                      # 看 .dynamic 段 (NEEDED, RPATH...)
objdump -p ./app | grep -E 'NEEDED|RPATH|RUNPATH'

# 查看符号
nm -D libfoo.so                       # 动态符号表 (导出的符号)
nm libfoo.so                          # 完整符号表
objdump -T libfoo.so                  # 动态符号表
readelf -s libfoo.so                  # 符号表 (最完整)

# 查看版本信息
readelf -V libfoo.so                  # 符号版本
strings libfoo.so | grep 'GLIBC\|GCC' # 看版本依赖

# 跟踪动态链接过程
LD_DEBUG=files ./app                  # 看加载了哪些文件
LD_DEBUG=symbols ./app                # 看符号解析过程
LD_DEBUG=bindings ./app               # 看绑定过程
LD_DEBUG=all ./app 2>&1 | less        # 看全部

# 查看静态库内容
ar -t libfoo.a                        # 列出 .o 成员
nm -s libfoo.a                        # 符号索引
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

ABI 兼容性检查:

# abi-compliance-checker
abi-dumper libfoo.so.1.0 -o libfoo-1.0.abi
abi-dumper libfoo.so.2.0 -o libfoo-2.0.abi
abi-compliance-checker -l libfoo \
    -old libfoo-1.0.abi -new libfoo-2.0.abi

# 二进制安全特性检查
checksec --file=./app
# 输出: RELRO, STACK CANARY, NX, PIE, RPATH, RUNPATH, FORTIFY...
1
2
3
4
5
6
7
8
9

生产部署模式对比:

模式 命令 适用
完全静态 gcc -static main.c -o app 单 binary 的 CLI/容器
捆绑 .so gcc -Wl,-rpath,'$ORIGIN/lib' main.c 桌面/服务器应用
系统 .so gcc main.c -lfoo -o app 被系统管理器打包的软件
容器化 COPY --from=builder /app /app Docker/K8s

下一篇:16.内存对齐与访问效率 —— 我们已经知道"程序怎么编译、链接、加载",下一步进入内存层面:为什么 padding 存在?cache line 怎么影响 struct 布局?__attribute__((packed)) 是节省空间还是拖慢性能?

上次更新: 2026/06/11, 09:01:44
链接器符号与重定位
Make与CMake构建

← 链接器符号与重定位 Make与CMake构建→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式