Linux 编译产物详解:ELF、.o、.a、.so / Linux Build Artifacts: ELF, Object Files, Static Archives, and Shared Objects
📅 创建时间:2026-07-13 🏷️ 标签:#Linux #C++ #ELF #动态库 #静态库 #soname 📚 前置知识:cpp project fundamentals, windows build outputs
📋 本章目标
- 理解 ELF 格式的基本结构和四种类型
- 掌握 .o(目标文件)、.a(静态库)、.so(共享对象)的生成方式与内部结构
- 理解 soname 机制——为什么 Linux 上动态库有三层软链接
- 明白 PIC(位置无关代码)为什么对 .so 至关重要
- 掌握动态链接器搜索路径的优先级:LD_LIBRARY_PATH vs RPATH vs RUNPATH
- 熟悉 GCC 工具链:gcc/g++、ar、ld、objdump、readelf、nm、strip、ldd、patchelf
第1部分:Linux 编译产物全景图
1.1 与 Windows 的对应关系
在深入之前,先建立 Linux 和 Windows 编译产物的对应关系:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Windows vs Linux 编译产物对照 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Windows Linux │
│ ───────── ───────── │
│ .obj (COFF 目标文件) .o (ELF 可重定位文件) │
│ .lib (静态库) .a (Archive 静态库) │
│ .lib (导入库) —— (Linux 不需要导入库!) │
│ .dll (动态链接库) .so (Shared Object 共享对象) │
│ .exe (PE 可执行文件) (无后缀) (ELF 可执行文件) │
│ .pdb (调试符号) (嵌入在 ELF 中, 或用 .debug 分离) │
│ │
│ ⚠️ 关键差异: │
│ • Linux 没有"导入库"概念——链接 .so 时直接读 .so 就够了 │
│ • Linux 可执行文件通常无后缀名 │
│ • Linux 动态库有一套 soname 版本管理机制 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第2部分:ELF 格式速览
2.1 什么是 ELF?
ELF(Executable and Linkable Format)是 Linux 系统的标准二进制格式,就像 Windows 的 PE(Portable Executable)。Linux 上所有的 .o、.a(内部也是 ELF)、.so、可执行文件,甚至是 coredump,都是 ELF 格式。
┌─────────────────────────────────────────────────────────────────────────────┐
│ ELF 文件的内部结构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ ELF Header │ ← 魔数 0x7f ELF、位数(32/64)、端序、 │
│ │ (文件类型、入口点、...) │ Section/Program Header 的位置 │
│ ├─────────────────────────────┤ │
│ │ Program Header Table │ ← 告诉内核/加载器:怎么把文件映射到内存 │
│ │ (运行时需要) │ (哪个段加载到哪个地址、权限 rwx) │
│ ├─────────────────────────────┤ │
│ │ │ │
│ │ .text 代码段 │ ← 可执行指令(通常是 r-x) │
│ │ .rodata 只读数据 │ ← 字符串常量、const 变量(r--) │
│ │ .data 已初始化数据 │ ← 已初始化的全局变量(rw-) │
│ │ .bss 未初始化数据 │ ← 未初始化的全局变量(rw-),文件里不占空间 │
│ │ .symtab 符号表 │ ← 函数名、变量名 → 地址的映射 │
│ │ .strtab 字符串表 │ ← 符号名称的字符串 │
│ │ .rel.text 重定位表 │ ← .text 中需要重定位的位置 │
│ │ .dynamic 动态链接信息 │ ← 需要哪些 .so、GOT/PLT 在哪 │
│ │ .got / .got.plt │ ← 全局偏移表 / 过程链接表 │
│ │ ... │ │
│ │ │ │
│ ├─────────────────────────────┤ │
│ │ Section Header Table │ ← 告诉链接器:有哪些段、各段在哪 │
│ │ (链接时需要) │ (可执行文件可以没有这个表,但 .o 必须有) │
│ └─────────────────────────────┘ │
│ │
│ Program Header = 给运行时看的(mmap 映射指南) │
│ Section Header = 给链接器看的(符号解析和重定位) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘2.2 ELF 的四种类型
# 查看 ELF 类型
readelf -h file | grep Type
# 四种类型:
# ET_REL - 可重定位文件 (.o) —— 还没链接,地址都是占位符
# ET_EXEC - 可执行文件 —— 地址固定,可以直接 exec()
# ET_DYN - 共享对象 (.so) —— 可以被动态加载,地址可以在加载时重定位
# ET_CORE - 核心转储 (core dump) —— 程序崩溃时的内存快照一个重要的细节:现代 Linux 系统上,可执行文件也可能是 ET_DYN 类型(PIE - Position Independent Executable),这是安全加固(ASLR)的要求。
第3部分:.o —— 编译的最小产物
3.1 .o 文件的内部
# 编译但不链接
g++ -c math.cpp -o math.o
# 查看 .o 文件
file math.o # ELF 64-bit LSB relocatable
readelf -h math.o # Type: REL (Relocatable file)
nm math.o # 查看符号表
objdump -d math.o # 反汇编
objdump -t math.o # 完整符号表
readelf -r math.o # 重定位表(哪些地址还需要链接器来填).o 文件的符号表中有三种关键标记:
┌─────────────────────────────────────────────────────────────────────────────┐
│ .o 文件符号表中的标记含义 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ nm math.o 的输出: │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 0000000000000000 T add(int, int) │ │
│ │ U printf │ │
│ │ 0000000000000000 D global_counter │ │
│ │ U _ZSt4cout │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ T (Text) = 在本文件中定义,在代码段中(函数代码) │
│ U (Undef) = 本文件用到但没定义,需要链接器去其他 .o 或库里找 │
│ D (Data) = 在本文件中定义,在已初始化数据段中 │
│ B (BSS) = 在本文件中定义,在未初始化数据段中 │
│ R (ReadOnly)= 在本文件中定义,在只读数据段中 │
│ t/d/b (小写) = 局部符号(static 函数/变量),不对外导出 │
│ W (Weak) = 弱符号,可以被同名的强符号覆盖 │
│ V (Weak Obj)= 弱对象符号 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第4部分:.a —— 静态库
4.1 .a 的生成与使用
# 生成静态库
g++ -c add.cpp -o add.o
g++ -c multiply.cpp -o multiply.o
ar rcs libmath.a add.o multiply.o # ar = archiver, rcs = replace/create/sort
# 查看静态库内容
ar t libmath.a # 列出库里有哪些 .o
nm libmath.a # 查看所有 .o 的符号
objdump -d libmath.a # 反汇编(可以看到所有代码)
# 使用静态库
g++ main.cpp -L./lib -lmath -o my_app
# -L./lib = 在 ./lib 目录下找库
# -lmath = 找 libmath.a(优先)或 libmath.so.a 文件本质上就是 .o 的 tar 包(ar 格式),不含任何链接信息——就是一堆目标文件的集合。链接时,链接器只会从 .a 中提取出实际被引用了的 .o 文件,未引用的代码不会被链接进去。
第5部分:.so —— 共享对象
5.1 .so 的生成
# 编译 .so:两步
g++ -c -fPIC add.cpp -o add.o # 必须加 -fPIC!
g++ -shared -o libmath.so add.o multiply.o
# 或者一步到位
g++ -fPIC -shared add.cpp multiply.cpp -o libmath.so
# 关键选项:
# -fPIC = Position Independent Code(位置无关代码),.so 必须!
# -shared = 生成共享对象(而不是可执行文件)5.2 soname 机制 —— 为什么有三级软链接?
┌─────────────────────────────────────────────────────────────────────────────┐
│ Linux .so 的版本管理机制 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 你看到的三个文件: │
│ │
│ libmath.so ────→ libmath.so.1 ────→ libmath.so.1.0.0 │
│ (链接名) (soname) (real name) │
│ │
│ 各自的用途: │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ libmath.so.1.0.0 — 真实文件(real name) │ │
│ │ • 真正的 .so 文件,包含所有代码 │ │
│ │ • 命名格式:lib<name>.so.<major>.<minor>.<patch> │ │
│ │ • 1.0.0 = 主版本.次版本.补丁版本 │ │
│ │ │ │
│ │ libmath.so.1 — soname 链接 │ │
│ │ • 软链接到 real name │ │
│ │ • 命名格式:lib<name>.so.<major> │ │
│ │ • soname 编码在 .so 内部的 ELF .dynamic 段中 │ │
│ │ • 链接时:链接器读取 soname,把 "libmath.so.1" 写到程序里 │ │
│ │ • 运行时:动态链接器去找 "libmath.so.1" │ │
│ │ │ │
│ │ libmath.so — 链接名(linker name) │ │
│ │ • 软链接到 soname(也可能直接到 real name) │ │
│ │ • 编译时:g++ -lmath → 链接器找 libmath.so │ │
│ │ • 通常由 -dev 包提供(如 libmath-dev) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 为什么这么设计? │
│ │
│ 场景演示: │
│ 1. 你编译程序时链接 libmath.so.1(soname),程序里记录的依赖是 libmath.so.1 │
│ 2. 系统升级 math 库:libmath.so.1.0.0 → libmath.so.1.0.1 │
│ 3. soname libmath.so.1 软链接更新到 1.0.1 │
│ 4. 你的程序不用重新编译,自动使用新版本(因为 soname 没变) │
│ 5. 如果升级到 libmath.so.2(主版本变了,ABI 不兼容) │
│ → 你的程序仍然可以链接老的 libmath.so.1(并行安装) │
│ │
│ 这就是 Linux 解决 "DLL Hell" 的方式! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘5.3 查看 .so 的 soname
# 查看 .so 内部记录的 soname
objdump -p libmath.so | grep SONAME
# 输出:SONAME libmath.so.1
readelf -d libmath.so | grep SONAME
# 输出:0x000000000000000e (SONAME) Library soname: [libmath.so.1]
# 没有设置 soname 的 .so 就是"野生"的,只能靠 LD_LIBRARY_PATH 找到5.4 编译时设置 soname
g++ -fPIC -shared -Wl,-soname,libmath.so.1 \
add.cpp multiply.cpp -o libmath.so.1.0.0
# -Wl,xxx = 把 xxx 传给链接器 ld
# -soname = 设置 ELF 内 SONAME 字段
# 然后手动创建软链接:
ln -sf libmath.so.1.0.0 libmath.so.1 # soname → real name
ln -sf libmath.so.1 libmath.so # linker name → sonameCMake 自动处理了这一切(第 05 篇会详细讲)。
第6部分:PIC —— 为什么 .so 必须用 -fPIC?
6.1 问题:绝对地址在共享库中行不通
┌─────────────────────────────────────────────────────────────────────────────┐
│ 为什么需要 PIC? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 不加 -fPIC 编译的代码里有绝对地址: │
│ │
│ mov eax, [0x601000] ← 硬编码地址,假设变量在 0x601000 │
│ │
│ 问题:多个程序加载同一个 .so,它被映射到每个进程的不同地址 │
│ │
│ 进程A:.so 映射到 0x7f0000000000 │
│ 进程B:.so 映射到 0x7f1234560000 │
│ │
│ 如果代码里写了 0x601000(相对 .so 内部的偏移),在进程A里实际地址是 │
│ 0x7f0000000000 + 0x601000,在进程B里是 0x7f1234560000 + 0x601000。 │
│ mov 里的绝对地址 0x601000 在映射时已经不对了 │
│ │
│ 解决方案:加 -fPIC │
│ │
│ PIC 版本: │
│ mov rax, [rip + offset] ← 相对指令指针寻址,无论映射到哪都对 │
│ (rip = 当前指令地址,offset = 变量相对当前指令的偏移量) │
│ │
│ 或者通过 GOT(Global Offset Table)间接访问: │
│ mov rax, [rip + got_offset] → 从 GOT 中取变量的实际地址 │
│ mov eax, [rax] → 再解引用访问变量 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘6.2 不加 -fPIC 会怎样?
# 如果你尝试不加 -fPIC 编译 .so
g++ -shared add.cpp -o libadd.so # 忘了 -fPIC
# x86-64 上可能成功但警告:
# /usr/bin/ld: add.o: warning: relocation against `global_var' in read-only section `.text'
# /usr/bin/ld: warning: creating a DT_TEXTREL in a shared object.
# 这意味着 .so 里存在 TEXTREL(代码段中的重定位)——
# 动态链接器需要修改代码段(.text),而这违反安全策略:
# 1. .text 段不能被共享(每个进程要独立修改 = 浪费内存)
# 2. SELinux 等安全机制禁止可写的可执行内存(W^X 原则)第7部分:动态链接器的搜索机制
7.1 .so 的搜索路径与优先级
┌─────────────────────────────────────────────────────────────────────────────┐
│ 运行时 .so 搜索路径优先级(从高到低) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. RPATH —— 编码在 ELF DT_RPATH 中的路径 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 链接时通过 -Wl,-rpath,/path/to/libs 写入 ELF │ │
│ │ readelf -d my_app | grep RPATH │ │
│ │ 优先级最高,但被认为过时(被 RUNPATH 取代) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 2. LD_LIBRARY_PATH —— 环境变量 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ export LD_LIBRARY_PATH=/my/custom/libs:$LD_LIBRARY_PATH │ │
│ │ 临时覆盖,调试时最常用 │ │
│ │ ⚠️ 不要在生产环境中全局设置! │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 3. RUNPATH —— 编码在 ELF DT_RUNPATH 中的路径 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 比 RPATH 优先级低(被 LD_LIBRARY_PATH 覆盖) │ │
│ │ 现代项目推荐使用 RUNPATH │ │
│ │ CMake 默认使用 RUNPATH │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 4. /etc/ld.so.cache —— 系统库缓存 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ sudo ldconfig 更新此缓存 │ │
│ │ 包含 /lib、/usr/lib、/etc/ld.so.conf.d/ 下的所有路径 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 5. 默认系统路径:/lib 和 /usr/lib(以及 /lib64、/usr/lib64) │
│ │
│ ⚠️ RPATH vs RUNPATH 的关键区别: │
│ RPATH:在 LD_LIBRARY_PATH 之前搜索(用户无法覆盖) │
│ RUNPATH:在 LD_LIBRARY_PATH 之后搜索(用户可以用环境变量覆盖) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘7.2 ldd —— 诊断依赖问题
# 查看一个程序依赖哪些 .so
ldd my_app
# 输出:
# linux-vdso.so.1 (0x00007ffe3f5e0000) ← 内核虚拟动态共享对象
# libmath.so.1 => /usr/local/lib/libmath.so.1 (0x00007f8a3c000000)
# libstdc++.so.6 => /usr/lib/libstdc++.so.6 (0x00007f8a3bd00000)
# libc.so.6 => /usr/lib/libc.so.6 (0x00007f8a3ba00000)
# libm.so.6 => /usr/lib/libm.so.6 (0x00007f8a3b900000)
# /lib64/ld-linux-x86-64.so.2 (0x00007f8a3c300000) ← 动态链接器本身
# 如果某个库显示 "not found":
# libmissing.so => not found
# → 需要在 LD_LIBRARY_PATH 或 RPATH/RUNPATH 中添加路径,或者用 ldconfig 注册7.3 修改 RPATH/RUNPATH
# 查看当前 RPATH/RUNPATH
readelf -d my_app | grep -E 'RPATH|RUNPATH'
# 修改 RUNPATH(patchelf 工具)
patchelf --set-rpath '/my/custom/lib:$ORIGIN/../lib' my_app
# $ORIGIN 是一个非常实用的变量——指可执行文件所在的目录
# 例如:$ORIGIN/../lib = 可执行文件所在目录的上级目录下的 lib/
# 这样重定位整个程序目录时不需要重新编译
# (Windows 上不需要这个,因为 Windows 默认就在 .exe 所在目录搜索 .dll)第8部分:ldconfig 与系统库注册
# 当你把 .so 放到 /usr/local/lib 后,需要注册
sudo ldconfig
# ldconfig 做的事:
# 1. 扫描 /lib, /usr/lib, /etc/ld.so.conf.d/*.conf 中列出的所有目录
# 2. 找到所有 .so 文件
# 3. 根据 .so 内部的 soname 创建正确的软链接
# 4. 生成 /etc/ld.so.cache(二进制缓存,加速运行时查找)
# 如果你的库在非标准路径(如 /opt/mylib/lib),可以:
echo "/opt/mylib/lib" | sudo tee /etc/ld.so.conf.d/mylib.conf
sudo ldconfig第9部分:GCC/Linux 工具链速览
| 工具 | 做什么 | 常用示例 |
|---|---|---|
gcc/g++ | 编译 + 链接驱动(调用 cc1/as/ld) | g++ -c foo.cpp -o foo.o |
as | GNU 汇编器 | (通常由 gcc 自动调用) |
ld | GNU 链接器 | (通常由 gcc 自动调用,手动用 -Wl,xxx 传参) |
ar | 创建/管理 .a 静态库 | ar rcs libfoo.a foo.o bar.o |
objdump | 查看二进制内容 | objdump -d foo.o(反汇编) |
readelf | 查看 ELF 信息(比 objdump 更详细) | readelf -d foo.so |
nm | 列出符号表 | nm foo.o |
strip | 去掉调试符号 | strip foo.so(瘦身) |
ldd | 查看动态库依赖 | ldd my_app |
patchelf | 修改 ELF 属性 | patchelf --set-rpath ... |
chrpath | 修改 RPATH/RUNPATH | chrpath -r '/new/path' my_app |
strings | 提取二进制中的可读字符串 | `strings foo.so |
核心总结
┌─────────────────────────────────────────────────────────────────────────────┐
│ Linux 编译产物核心速查 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ .o ─── ELF 可重定位文件,包含代码+符号表+重定位表 │
│ .a ─── .o 的 ar 打包(静态库),链接时提取所需 .o 嵌入目标文件 │
│ .so ── ELF 共享对象,运行时加载到进程地址空间 │
│ │
│ soname 机制: │
│ libfoo.so → libfoo.so.1 → libfoo.so.1.0.0 │
│ (链接名) (soname) (real name) │
│ 编译时用链接名,运行时按 soname 找,主版本不变就可以兼容升级 │
│ │
│ -fPIC 是 .so 的必需品:让代码可以加载到任意地址执行 │
│ │
│ .so 搜索优先级:RPATH > LD_LIBRARY_PATH > RUNPATH > ld.so.cache > 默认路径 │
│ │
│ Linux 没有导入库概念——链接时直接读 .so │
│ │
└─────────────────────────────────────────────────────────────────────────────┘章节测试
测试1:ELF 类型
readelf -h 报告的四种 ELF 类型分别是什么?一个 .o 文件是什么类型?一个 .so 文件是什么类型?
测试2:.a 的本质
.a 静态库的本质是什么?为什么链接 .a 时只会包含"被引用了的"目标文件?
测试3:soname 场景
你编译一个程序时链接了 libfoo.so.1(soname),运行程序的机器上 libfoo 被升级到了 libfoo.so.1.0.2(原来的 real name 是 libfoo.so.1.0.0)。你的程序能正常运行吗?为什么?如果升级到 libfoo.so.2 呢?
测试4:PIC
为什么编译 .so 时必须加 -fPIC?不加可能会有什么后果?
测试5:搜索路径
一个程序运行时提示 "error while loading shared libraries: libfoo.so: cannot open shared object file"。请列出至少三种解决这个问题的方法。
测试6:工具使用
你怀疑某个 .so 文件是用 Debug 模式编译的,在二进制中有 "_DEBUG" 相关字符串。用什么命令可以快速确认?
参考答案
测试1答案
答案:
ET_REL— 可重定位文件(.o 文件)ET_EXEC— 可执行文件ET_DYN— 共享对象(.so),现代 PIE 可执行文件也是这个类型ET_CORE— 核心转储(core dump)
.o 文件是 ET_REL,.so 文件是 ET_DYN。
测试2答案
答案:.a 的本质是 .o 文件的 ar 格式归档包(类似 tar)。链接器在链接 .a 时只提取其中被引用的 .o 文件,未引用的不链接。这是链接器的默认行为——只把需要的代码包含进来,减小最终二进制体积。如果需要全量链接(某些静态初始化场景),可用 --whole-archive 选项。
测试3答案
答案:
- 升级到 1.0.2:✅ 能正常运行。因为 soname 依旧是
libfoo.so.1,软链接libfoo.so.1 → libfoo.so.1.0.2更新后,动态链接器按 soname 能找到新版本文件。主版本号没变意味着 ABI 兼容。 - 升级到 2.x:❌ 不能运行(除非并行安装了两个主版本)。因为 soname 变成了
libfoo.so.2,而程序里记录的是libfoo.so.1,动态链接器找不到。这就是 soname 设计的目的——保护程序不被不兼容的升级破坏。
测试4答案
答案:.so 可能被加载到不同进程的不同地址。如果不用 -fPIC,代码中包含绝对地址,当 .so 加载到预期地址之外时,绝对地址全部失效。即使 x86-64 上碰巧能编译通过(因为有 TEXTREL 机制),也会导致:(1) .text 段不可共享,每个进程独立修改 → 浪费内存;(2) 违反 W^X 安全原则;(3) 某些平台(如 ARM64)上完全禁止。
测试5答案
答案(任选三种):
export LD_LIBRARY_PATH=/path/to/lib:$LD_LIBRARY_PATH临时添加路径- 编译时设置 RPATH/RUNPATH:
g++ -Wl,-rpath,/path/to/lib ... - 将 .so 放到 /usr/local/lib 并运行
sudo ldconfig - 将 .so 放到可执行文件同目录(配合
$ORIGINRPATH) - 添加配置文件
/etc/ld.so.conf.d/myapp.conf并运行sudo ldconfig
测试6答案
答案:strings foo.so | grep -i debug。strings 会提取二进制中所有可打印字符串,如果编译时包含 Debug 相关的条件编译内容、调试路径或调试宏定义,它们会以字符串形式出现在二进制中。
相关笔记
- cpp project fundamentals - C++ 项目工作机制全景
- windows build outputs - Windows 编译产物详解
- static vs dynamic linking - 静态链接 vs 动态链接深度对比
下一步学习
- [ ] 阅读 03 - 项目结构、第三方库集成与 CMake 实战
学习状态:🟡 开始学习