【Linux】库制作与链接原理:从静态库、动态库到 ELF、重定位、GOT 与 PLT

🔥 本文定位 :面向已经掌握 GCC、Makefile 和 Linux 基础系统编程的同学,从"怎样制作一个库"一路深入到"链接器和动态加载器到底做了什么"。

💡 学习目标 :不仅会使用 ar、-fPIC、-shared、-L 和 -l,还要能解释静态库为什么只是目标文件归档、Section 与 Segment 为什么不能混用、未定义符号如何通过重定位修正、动态库如何映射进每个进程,以及 GOT/PLT 的第一次调用和后续调用有何区别。


文章目录


前言:从一条 gcc 命令拆开整个工具链

我们平时可能只执行一条命令:

bash 复制代码
gcc main.c -o app

但这条命令背后至少包含:

text 复制代码
main.c
  ↓  预处理
main.i
  ↓  编译
main.s
  ↓  汇编
main.o(ELF 可重定位目标文件)
  ↓  链接:目标文件 + 库 + 启动文件
app(ELF 可执行文件或 PIE)
  ↓  execve + ELF loader + 动态链接器(动态程序)
进程地址空间

这里存在两组最容易混淆的动作:

  • 编译 负责把每个翻译单元变成 .o;
  • 链接负责组合 Section、解析符号并应用重定位;
  • 装载负责根据 Program Header 建立进程映射;
  • 动态链接负责装载依赖库、解析动态符号并处理动态重定位。

理解库的原理,本质上是在回答:

  1. 可复用代码以什么文件形式存在?
  2. 链接器怎样从库中选择代码?
  3. 目标文件里的"未知地址"记录在哪里?
  4. 文件中的 Section 怎样变成内存中的 Segment?
  5. 动态库地址不固定,调用者为什么仍能找到函数?

一、库到底是什么

1.1 库解决的是二进制级代码复用

现实项目不会从零重写字符串、网络、压缩、加密和终端控制。开发者把稳定接口与实现组织成库,让其他程序通过头文件声明调用已经编译好的实现。

一套 C 库通常包含两部分:

text 复制代码
include/
└── mymath.h       # API、类型和宏

lib/
├── libmymath.a    # 静态库
└── libmymath.so   # 动态库 / 共享对象

头文件给编译器看,库文件主要给链接器和动态加载器看:

文件 保存内容 主要使用者
.h 函数声明、类型、宏、内联代码 预处理器、编译器
.o 机器码、数据、符号与重定位信息 链接器
.a 多个 .o 成员及符号索引的归档 静态链接器
.so 可映射的 ELF 共享对象 链接器、动态链接器

1.2 静态库不是一个"可直接执行的 ELF"

GNU ar 创建的普通静态库本质上是 archive:一个文件内部保存若干目标文件成员,并附带可帮助链接器快速查找定义的符号索引。

bash 复制代码
ar t libmymath.a
nm -s libmymath.a

归档中的成员常常是 ELF .o,但 .a 容器本身不等于可由内核直接装载执行的 ELF 可执行文件。

动态库 .so 则通常是 ELF ET_DYN 共享对象,包含 Program Header、动态节、导出符号和动态重定位等装载/链接信息。

1.3 静态库与动态库的第一层差异

对比项 静态库 .a 动态库 .so
文件本质 目标文件归档 ELF 共享对象
代码进入程序的时机 最终静态链接时抽取所需成员 程序启动或 dlopen() 时映射
可执行文件体积 通常更大 通常更小
运行时依赖 被选中的代码已进入程序 需要找到 ABI 兼容的共享库
跨进程共享 每个进程使用自身映像中的代码页 文件支持的只读页可由页缓存/物理页共享
更新影响 通常需要重新链接程序 ABI 兼容时可替换共享库

二、从源码到目标文件

2.1 准备两个源文件

c 复制代码
/* add.c */
int add(int lhs, int rhs)
{
    return lhs + rhs;
}
c 复制代码
/* sub.c */
int sub(int lhs, int rhs)
{
    return lhs - rhs;
}

头文件:

c 复制代码
/* mymath.h */
#ifndef MYMATH_H
#define MYMATH_H

int add(int lhs, int rhs);
int sub(int lhs, int rhs);

#endif

分别编译:

bash 复制代码
gcc -Wall -Wextra -O2 -c add.c -o add.o
gcc -Wall -Wextra -O2 -c sub.c -o sub.o

-c 表示只完成到目标文件,不运行最终链接器。

2.2 .o 中已经有机器码,但还不能独立运行

bash 复制代码
file add.o
readelf -h add.o
readelf -S add.o
readelf -s add.o
readelf -r add.o

.o 通常是 ELF ET_REL 可重定位文件,内部可能包含:

  • .text:指令;
  • .rodata:只读常量;
  • .data:已初始化可写数据;
  • .bss:未初始化或零初始化数据的空间需求;
  • .symtab / .strtab:静态符号表与字符串;
  • .rela.text、.rela.data 等:重定位项;
  • 调试、异常展开、注释等辅助 Section。

它已经有局部机器码,却可能仍包含外部未定义符号:

bash 复制代码
nm -C add.o

符号前常见字母:

  • T:定义在代码 Section;
  • D:定义在已初始化数据 Section;
  • B:定义在 BSS;
  • U:本文件引用,但尚未定义。

2.3 重定位信息不在 .data

如果 main.o 调用了另一个目标文件定义的 add,编译器无法知道最终地址。它会生成需要链接器修正的位置,并在架构相应的重定位 Section 中保存"修正哪里、使用哪个符号、采用哪种重定位类型、附加值是什么"等信息。

在 x86-64 RELA 形式中,一个重定位项概念上包含:

text 复制代码
r_offset  # 要修正的位置
r_info    # 符号索引 + 重定位类型
r_addend  # 显式附加值

用下面的命令观察:

bash 复制代码
readelf -rW main.o
objdump -dr main.o

不能把反汇编中临时显示的 0 简单理解为"最终调用地址就是 0",也不能说"重定位表位于 .data"。准确说法是:指令或数据中留有待修正字段,独立重定位 Section 描述如何修正。


三、制作并使用静态库

3.1 创建静态库

bash 复制代码
ar rcs libmymath.a add.o sub.o

常用参数含义:

  • r:插入或替换成员;
  • c:不存在时创建归档;
  • s:建立或更新符号索引。

查看成员与符号:

bash 复制代码
ar t libmymath.a
nm -s libmymath.a

3.2 链接静态库

测试程序:

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

int main(void)
{
    printf("add=%d, sub=%d\n", add(7, 5), sub(7, 5));
    return 0;
}

显式指定归档:

bash 复制代码
gcc main.c ./libmymath.a -o app-static-lib

或使用库名规则:

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

-lmymath 会请求链接器寻找 libmymath.so 或 libmymath.a。在典型 GNU 链接环境中,如果两者都可用,通常优先共享库;如果要仅在这一小段切到静态版本,可使用:

bash 复制代码
gcc main.o -Wl,-Bstatic -lmymath -Wl,-Bdynamic -o app

gcc -static 的范围更大,它要求最终链接尽可能全部使用静态库;系统缺少相应静态运行库时可能失败。

3.3 静态库不是把所有成员无条件塞进程序

普通静态链接会根据当前未解析符号,从归档中抽取能提供定义的成员。没有被需要的成员通常不会进入输出文件。

这带来一个重要规则:

目标文件和库通常按命令行从左到右处理,提供定义的库应放在引用它的对象之后。

例如:

bash 复制代码
# 推荐
gcc main.o -L. -lmymath -o app

# 在某些链接场景中可能产生未定义引用
gcc -L. -lmymath main.o -o app

如果静态库之间存在循环依赖,可以先重新设计依赖;确实需要时再使用链接组:

bash 复制代码
gcc main.o -Wl,--start-group -lfoo -lbar -Wl,--end-group -o app

链接组会反复扫描归档,代价是更长的链接时间。


四、制作并使用动态库

4.1 使用 PIC 编译对象

bash 复制代码
gcc -Wall -Wextra -O2 -fPIC -c add.c -o add.pic.o
gcc -Wall -Wextra -O2 -fPIC -c sub.c -o sub.pic.o

-fPIC 让编译器生成适合共享库的地址无关代码。不同架构的具体指令与限制不同,不应把 PIC 简化成某一条固定的 x86-64 指令模板。

4.2 生成带 SONAME 的共享库

bash 复制代码
gcc -shared \
    -Wl,-soname,libmymath.so.1 \
    -o libmymath.so.1.0.0 \
    add.pic.o sub.pic.o

ln -s libmymath.so.1.0.0 libmymath.so.1
ln -s libmymath.so.1 libmymath.so

三个名字常分别承担:

名称 典型用途
libmymath.so.1.0.0 真实共享对象文件
libmymath.so.1 SONAME / 运行时 ABI 主版本名
libmymath.so 开发链接名,供 -lmymath 使用

查看动态元数据:

bash 复制代码
readelf -dW libmymath.so.1.0.0
readelf --dyn-syms -W libmymath.so.1.0.0

如果共享对象含 DT_SONAME,链接器通常把 SONAME 写入消费者的 DT_NEEDED,而不是把构建时使用的完整文件路径直接写进去。

4.3 链接消费者

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

这里的 -I、-L、-l 分别解决不同问题:

text 复制代码
-I  编译期:头文件到哪里找
-L  链接期:库文件到哪里找
-l  链接期:按 libNAME.so / libNAME.a 选择 NAME

它们并不会自动告诉运行时动态链接器去 ./lib 找库。


五、编译期找库与运行期找库

5.1 两套搜索系统必须分开

构建时:

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

运行时:

bash 复制代码
./app
# error while loading shared libraries: libmymath.so.1: cannot open ...

链接成功只证明链接器当时找到了库,不证明动态链接器运行时能找到 SONAME 对应文件。

5.2 动态链接器的主要搜索顺序

对于不含斜杠的依赖名,glibc 动态链接器在一般情况下按下列主线搜索:

  1. DT_RPATH,仅当没有 DT_RUNPATH;
  2. LD_LIBRARY_PATH,安全执行模式下会被忽略;
  3. DT_RUNPATH,只用于当前对象的直接依赖;
  4. /etc/ld.so.cache;
  5. 默认路径,如 /lib、/usr/lib,部分 64 位平台还使用 /lib64、/usr/lib64。

若 DT_NEEDED 字符串本身包含斜杠,它会被当成路径处理。-z nodefaultlib、硬件能力目录和 secure-execution 还会影响细节。

5.3 开发阶段的临时方案

bash 复制代码
LD_LIBRARY_PATH="$PWD/lib" ./app

它适合临时测试,不适合作为生产部署的默认设计,也不应依赖当前目录的空路径条目。对 set-user-ID、带能力等安全执行程序,动态链接器会忽略或限制许多 LD_* 变量。

5.4 可搬运程序更适合 $ORIGIN RUNPATH

假设目录结构是:

text 复制代码
package/
├── bin/app
└── lib/libmymath.so.1

链接时写入相对于可执行文件所在目录的 RUNPATH:

bash 复制代码
gcc main.c -L./lib -lmymath \
    -Wl,-rpath,'$ORIGIN/../lib' \
    -o bin/app

必须用单引号保护 $ORIGIN,避免被当前 shell 提前展开。

查看:

bash 复制代码
readelf -dW bin/app | grep -E 'NEEDED|RPATH|RUNPATH'

5.5 系统级安装

将库放入受管理目录,并通过配置文件与缓存提供给动态链接器:

bash 复制代码
printf '%s\n' '/opt/mymath/lib' | sudo tee /etc/ld.so.conf.d/mymath.conf
sudo ldconfig
ldconfig -p | grep mymath

这是系统状态修改,生产环境需要遵循发行版打包和变更管理流程。普通项目优先使用包管理器、标准安装前缀或 $ORIGIN 布局。


六、ELF 的四类文件与两种视图

6.1 常见 ELF 类型

ELF 类型 e_type 常见文件
Relocatable ET_REL .o
Executable ET_EXEC 传统非 PIE 可执行文件
Shared Object ET_DYN .so,现代 PIE 可执行文件也常为 ET_DYN
Core ET_CORE core dump

所以不能仅凭 ET_DYN 就断言"这一定是共享库"。还要结合 Program Header、PT_INTERP、入口点和动态节等信息判断。

6.2 ELF 文件的大致组成

text 复制代码
ELF Header
Program Header Table(可选,装载视图关键)
Section contents
Section Header Table(可选,链接/分析视图关键)

ELF Header 中的 e_phoff、e_shoff、条目大小与数量等字段充当"路线图"。表项和 Section 内容的物理顺序由具体文件决定,Section Header Table 不保证总在文件尾,也不是运行一个 ELF 必然需要的所有信息。

6.3 Section:链接器的组织单位

常见 Section:

Section 典型内容
.text 指令
.rodata 只读常量、字符串
.data 已初始化可写数据
.bss 零初始化数据的内存需求,通常为 SHT_NOBITS
.symtab / .strtab 完整符号与字符串,strip 后可能被移除
.dynsym / .dynstr 动态链接需要的符号与字符串
.rela.* / .rel.* 重定位项
.dynamic DT_NEEDED、SONAME、RUNPATH 等动态标签
.got / .got.plt 动态地址槽位,具体划分依赖架构/工具链
.plt / .plt.sec 外部函数调用桩,具体形式依赖架构/选项

6.4 Segment:加载器的映射单位

内核和动态链接器主要看 Program Header。典型 PT_LOAD Segment 按页映射,并携带:

  • 文件偏移 p_offset;
  • 虚拟地址 p_vaddr;
  • 文件大小 p_filesz;
  • 内存大小 p_memsz;
  • 权限 p_flags;
  • 对齐 p_align。

同一 Segment 可以包含多个属性兼容的 Section。例如只读常量是否与可执行代码同属一个加载 Segment,取决于链接器脚本和 -z separate-code 等策略,不能断言 .rodata 必然位于"text 段"。

6.5 .bss 为什么几乎不占文件空间

.bss 常为 SHT_NOBITS,记录大小与地址需求,却不在文件中保存同等数量的零字节。对应可写 PT_LOAD 常出现:

text 复制代码
p_memsz > p_filesz

加载器为差额部分提供零初始化内存。这能减小磁盘文件,却不代表 .bss 变量不占进程虚拟内存。


七、静态链接的核心:符号解析与重定位

7.1 输入 Section 先变成输出 Section

链接器不会只做"把文件首尾拼起来"。它按照链接脚本完成:

  1. 读取输入目标文件和库;
  2. 解析强/弱、局部/全局、已定义/未定义符号;
  3. 从归档按需抽取成员;
  4. 把输入 Section 映射到输出 Section;
  5. 决定输出地址和文件偏移;
  6. 应用重定位,修正指令和数据;
  7. 生成 ELF Header、Program Header、动态元数据等。

真实链接中还会有 COMDAT/Section Group、垃圾回收、合并字符串、对齐填充、链接时优化、符号版本和 linker relaxation 等行为。图中展示的是主干模型。

7.2 符号解析

假设:

text 复制代码
main.o  引用 add(UND)
add.o   定义 add(GLOBAL FUNC)

链接器在全局符号解析中把引用绑定到定义。如果所有输入处理完仍存在必须解析的强未定义符号,就产生常见错误:

text 复制代码
undefined reference to `add'

若出现多个不允许共存的强定义,则可能得到:

text 复制代码
multiple definition of `add'

7.3 重定位不是只修函数调用

重定位可能作用于:

  • 外部函数调用;
  • 全局变量地址;
  • 只读常量引用;
  • vtable、函数指针、跳转表;
  • TLS;
  • 动态链接所需 GOT/PLT 槽位。

不同 ISA 有不同重定位类型。x86-64 中常见 R_X86_64_PC32、R_X86_64_PLT32、R_X86_64_GLOB_DAT、R_X86_64_JUMP_SLOT、R_X86_64_RELATIVE 等,语义不能只靠名字猜测,应结合 ABI 与 readelf -rW 输出分析。


八、ELF 如何被加载成进程

8.1 execve() 不会创建一个新 PID

成功的 execve() 用新程序映像替换当前进程映像,PID 仍然是原来的。它不是"创建一个全新进程",而是让已有进程开始执行新程序。

8.2 内核主要根据 Program Header 建立映射

简化主线:

text 复制代码
execve(path, argv, envp)
  ↓
读取 ELF Header,确认格式、架构和入口
  ↓
遍历 Program Header
  ↓
按 PT_LOAD 建立文件映射 / 零填充区域和权限
  ↓
若存在 PT_INTERP,装载指定动态链接器
  ↓
建立初始栈:argc、argv、envp、auxv
  ↓
控制权进入动态链接器或程序入口 _start

现代 Linux 常采用按需调页。说"加载可执行文件"不意味着启动瞬间把所有代码字节都主动复制到物理内存;内核先建立虚拟内存映射,实际页可在缺页时从页缓存/文件读取。

8.3 Section Header 不是内核运行时装载的主索引

readelf -S 面向 Section;readelf -l 面向 Segment。运行时判断某段文件内容映射到哪个虚拟地址,应把 /proc/<pid>/maps 的文件偏移与 readelf -lW 的 p_offset 对照,而不是只看 Section Address。

常用命令:

bash 复制代码
readelf -hW ./app
readelf -lW ./app
readelf -SW ./app
cat /proc/$$/maps

8.4 PT_INTERP 指向动态链接器

动态链接 ELF 的 PT_INTERP 通常指向类似:

text 复制代码
/lib64/ld-linux-x86-64.so.2

实际路径取决于架构、ABI 和发行版。内核映射主程序和解释器后,把控制权交给动态链接器;后者再查找 DT_NEEDED、装载共享对象、处理重定位并运行初始化函数,最终进入程序启动代码。


九、动态库如何映射并被多个进程共享

9.1 每个进程拥有自己的虚拟地址视图

一个 .so 文件可以被映射进多个进程。受 ASLR、PIE 和各进程布局影响,同一个共享库在不同进程中不保证拥有相同虚拟地址。

bash 复制代码
grep -F '.so' /proc/<pid>/maps

maps 中每行包含:

text 复制代码
address  perms  offset  dev  inode  pathname

同一个 .so 往往产生多个权限不同的映射,如 r--p、r-xp、rw-p,对应不同 PT_LOAD Segment 和 RELRO 区域。

9.2 共享的是只读文件页,不是整片地址空间

内核可以让多个进程的页表项指向同一份文件支持的物理页或页缓存页,典型对象是共享库的只读代码和只读数据。

但以下内容通常仍是进程私有的:

  • 可写数据页;
  • GOT 中需要按进程/装载实例修改的槽位;
  • copy-on-write 后的页;
  • TLS;
  • 每个进程自己的页表和 VMA 元数据。

因此,"动态库在物理内存中永远只有一份"过于绝对。更准确的说法是:同一文件的可共享只读页可以复用,私有可写状态仍按进程隔离。

9.3 不一定由操作系统启动前一次性复制

动态库装载通常通过文件映射建立虚拟地址关系,随后由按需调页把实际页面带入内存。动态链接器还会对需要处理的页面做重定位;写入私有映射可能触发 COW。不要把全过程简化成"操作系统把整个 .so 从磁盘复制到一块物理内存"。


十、PIC、GOT 与动态重定位

10.1 为什么共享代码不希望写时修改

如果每个进程装载 .so 后都要直接改写 .text 中大量绝对地址:

  • 代码页需要可写,破坏 W^X 与共享;
  • 每个进程都会得到私有修改副本;
  • 装载成本和安全风险上升;
  • ASLR 与任意装载地址更难支持。

PIC 的目标是让代码无论映射到何处,都能通过 PC-relative 寻址、GOT 等机制访问外部或可抢占符号,而不必对只读代码做大量 text relocation。

10.2 GOT 是"地址槽位表",但不等于 .data

Global Offset Table 保存运行时需要填充或使用的地址槽位。现代 ELF 中常见独立的 .got、.got.plt Section;它们可能与其他可写 Section 一起位于某个 PT_LOAD,并可能部分落入 PT_GNU_RELRO。

所以更准确的表述是:

GOT 位于可重定位的数据区域,由链接器脚本和目标 ABI 决定具体 Section/Segment 布局,而不是"GOT 就是 .data 中随便预留的一块"。

10.3 PIC 的基本寻址链

在一个装载实例中,代码与本模块 GOT 的相对位置已由链接器确定。编译器可以用 PC-relative 方式找到 GOT 槽位:

text 复制代码
当前指令地址
   + 已知相对位移
        ↓
      GOT 槽位
        ↓  动态链接器写入运行时地址
   目标变量 / 函数

并不是每个调用或本地符号访问都必须经过 GOT。具体路径受架构、符号可见性、可抢占性、优化、-Bsymbolic、-fno-plt 和链接模式等影响。

10.4 重定位写入与 RELRO

动态链接器完成相关重定位后,可以把 PT_GNU_RELRO 覆盖的区域改为只读。链接选项:

bash 复制代码
-Wl,-z,relro

要求生成 RELRO Segment;再配合:

bash 复制代码
-Wl,-z,now

要求启动或 dlopen() 时立即解析函数符号,而不是保留延迟绑定需要后续写入的跳转槽位。常把两者组合称为 full RELRO,但具体默认值与实现仍取决于发行版和工具链。


十一、PLT 与延迟绑定

11.1 为什么不一定启动时解析所有函数

一个程序可能依赖大型库,却只调用其中少数函数。延迟绑定把某些函数符号解析推迟到第一次调用,减少未使用函数的启动解析工作。

典型 x86-64 ELF 概念路径是:

text 复制代码
调用点 → foo@plt → .got.plt 槽位

11.2 第一次调用

延迟绑定模式下,槽位初始指向 PLT 的解析路径:

text 复制代码
call foo@plt
  ↓
读取 foo 对应跳转槽位
  ↓  尚未绑定
PLT resolver stub
  ↓
动态链接器查找 foo 定义并计算地址
  ↓
更新跳转槽位
  ↓
进入 foo 真正实现

11.3 后续调用

text 复制代码
call foo@plt
  ↓
读取已更新的槽位
  ↓
直接跳转到 foo 实现

11.4 并非所有现代程序都会走经典懒绑定路径

以下情况会改变行为:

  • LD_BIND_NOW=1;
  • 链接使用 -Wl,-z,now;
  • 编译使用 -fno-plt,外部调用可改成从 GOT 取地址并间接调用;
  • 架构、CET/IBT、链接器版本可能产生 .plt、.plt.got、.plt.sec 等不同布局;
  • 本地绑定、隐藏符号或链接器优化可能绕开 PLT。

因此不要死记某一份 objdump 的固定字节序列,应观察当前二进制:

bash 复制代码
objdump -d -j .plt -j .plt.sec ./app
readelf -rW ./app
readelf -dW ./app | grep -E 'BIND_NOW|FLAGS'

十二、静态链接与动态链接如何选择

维度 静态链接 动态链接
部署独立性 高,但仍可能有内核、配置、NSS 等外部因素 需要运行时依赖
单文件体积 通常较大 通常较小
多进程内存复用 较弱 共享只读文件页更自然
安全修复 通常需重新构建/发布程序 ABI 兼容时更新共享库即可覆盖多个程序
ABI 风险 依赖在构建时固化 运行时可能版本不兼容或找错库
启动成本 无共享库符号绑定开销 需要装载、重定位、初始化
插件能力 较弱 可通过 dlopen() 扩展

常见选择:

  • 系统组件和桌面应用通常大量使用共享库;
  • 追求单文件部署的工具可能静态链接部分或大部分依赖;
  • glibc 全静态链接存在 NSS、名称解析、ABI 和动态模块等现实限制;
  • 可以只静态链接某个库,而不是使用全局 -static;
  • C++ 跨共享库抛接异常等场景还需统一运行时与 ABI 策略。

真正要管理的不是"哪种一定更好",而是:

text 复制代码
依赖闭包 + ABI 兼容 + 安全更新 + 部署方式 + 许可要求

十三、完整实战:制作一套可安装的 C 库

13.1 目录结构

text 复制代码
mymath/
├── Makefile
├── include/
│   └── mymath.h
├── src/
│   ├── add.c
│   └── sub.c
└── example/
    └── main.c

13.2 Makefile

makefile 复制代码
CC      := gcc
AR      := ar
CFLAGS  := -Wall -Wextra -O2 -Iinclude
PICFLAGS := -fPIC
BUILD   := build
LIBDIR  := lib

SRC := src/add.c src/sub.c
OBJ := $(SRC:src/%.c=$(BUILD)/%.o)
PICOBJ := $(SRC:src/%.c=$(BUILD)/%.pic.o)

.PHONY: all clean example
all: $(LIBDIR)/libmymath.a $(LIBDIR)/libmymath.so.1.0.0

$(BUILD) $(LIBDIR):
	mkdir -p $@

$(BUILD)/%.o: src/%.c include/mymath.h | $(BUILD)
	$(CC) $(CFLAGS) -c $< -o $@

$(BUILD)/%.pic.o: src/%.c include/mymath.h | $(BUILD)
	$(CC) $(CFLAGS) $(PICFLAGS) -c $< -o $@

$(LIBDIR)/libmymath.a: $(OBJ) | $(LIBDIR)
	$(AR) rcs $@ $^

$(LIBDIR)/libmymath.so.1.0.0: $(PICOBJ) | $(LIBDIR)
	$(CC) -shared -Wl,-soname,libmymath.so.1 -o $@ $^
	ln -sfn libmymath.so.1.0.0 $(LIBDIR)/libmymath.so.1
	ln -sfn libmymath.so.1 $(LIBDIR)/libmymath.so

example: all
	$(CC) $(CFLAGS) example/main.c -L$(LIBDIR) -lmymath \
		-Wl,-rpath,'$$ORIGIN/../lib' -o $(BUILD)/example

clean:
	rm -rf -- $(BUILD) $(LIBDIR)

Makefile 的命令行开头必须是 Tab。'$$ORIGIN/../lib' 先由 Make 把 $$ 变成 $,再由单引号阻止 shell 展开,最终写入 ELF 的是 $ORIGIN/../lib。

13.3 构建与检查

bash 复制代码
make
make example

ar t lib/libmymath.a
readelf -hW lib/libmymath.so.1.0.0
readelf -dW lib/libmymath.so.1.0.0
readelf -dW build/example

运行:

bash 复制代码
./build/example

由于 build/example 的 $ORIGIN/../lib 指向项目的 lib/,无需全局设置 LD_LIBRARY_PATH。

13.4 增加 ABI 约束

如果这是对外发布的库,还应考虑:

  • 导出符号白名单或 version script;
  • ABI 主版本变更规则;
  • 结构体布局、枚举值、调用约定;
  • 隐藏内部符号,例如 -fvisibility=hidden;
  • pkg-config 元数据;
  • 发行包中的开发文件与运行时文件拆分;
  • 测试 SONAME、符号版本与向后兼容。

十四、ELF 与动态链接排查工具箱

14.1 一张命令表

目标 命令
判断文件类型 file app libfoo.so foo.o
ELF Header readelf -hW app
Section Header readelf -SW app
Program Header readelf -lW app
Section → Segment 映射 readelf -lW app 输出末尾
静态符号 readelf -sW app、nm -C app
动态符号 readelf --dyn-syms -W app
重定位 readelf -rW app
动态标签 readelf -dW app
反汇编并显示重定位 objdump -dr app
直接依赖 `objdump -p app
运行时依赖解析 ldd app
加载器调试 LD_DEBUG=libs,reloc,bindings ./app
进程实际映射 cat /proc/<pid>/maps

14.2 不要对不可信程序直接使用 ldd

ldd 的实现通常借助动态链接器跟踪依赖;历史或特殊 ELF 解释器场景可能导致代码执行。对于来源不可信的文件,优先做静态检查:

bash 复制代码
objdump -p ./unknown | grep NEEDED
readelf -dW ./unknown

这只能看到直接 DT_NEEDED,不能替代在隔离环境中进行完整依赖解析。

14.3 ldd 显示 not found 怎么排查

按顺序检查:

bash 复制代码
readelf -dW ./app | grep -E 'NEEDED|RPATH|RUNPATH'
ldconfig -p | grep 'libname'
LD_DEBUG=libs ./app

还要确认:

  • 文件名是否满足 SONAME;
  • CPU 架构和位数是否一致;
  • 库依赖的下一层库是否缺失;
  • RUNPATH 是否只覆盖了直接依赖;
  • secure-execution 是否忽略了环境变量;
  • 挂载命名空间/容器中是否能看到该路径。

14.4 undefined reference 与 symbol lookup error 的阶段不同

text 复制代码
undefined reference
    常见于构建时最终链接失败

undefined symbol / symbol lookup error
    常见于运行时动态符号解析失败

前者重点查链接命令、库顺序、目标文件和导出;后者重点查实际装载的库版本、ABI、符号可见性、版本标记与搜索路径。


十五、常见误区与高频面试题

15.1 .a 是 ELF 文件吗?

普通 .a 是 ar 归档,成员通常是 ELF 可重定位目标文件。归档本身不是内核可直接装载执行的 ELF 映像。

15.2 -I、-L、-l、RPATH 分别解决什么?

  • -I:编译期头文件搜索;
  • -L:链接期库目录;
  • -lfoo:选择 libfoo.so / libfoo.a;
  • RPATH/RUNPATH:记录运行时动态库搜索目录。

15.3 有 .so 和 .a 时一定使用 .so 吗?

典型 GNU 链接环境默认偏好共享库,但会受到 -static、-Bstatic/-Bdynamic、链接器脚本、文件可用性、选项顺序和目标平台影响。

15.4 为什么静态库顺序会影响链接?

链接器通常按从左到右维护未解析符号集,并按需抽取归档成员。库出现在引用者之前时,第一次扫描可能还看不到需求。

15.5 Section 和 Segment 有什么区别?

Section 主要服务链接与分析,Segment 主要服务运行时装载。多个属性兼容 Section 可以映射进一个 PT_LOAD Segment。

15.6 可重定位 .o 一定有 Program Header 吗?

通常没有,也不需要,因为它不是供内核直接装载执行的最终进程映像。以实际 ELF Header 的 e_phnum 为准。

15.7 重定位表在 .data 中吗?

不是这个通用结论。ELF 通常使用 .rel.* 或 .rela.* 重定位 Section 描述待修正位置和规则,修正目标可以位于代码或数据 Section。

15.8 .rodata 一定进入可执行代码 Segment 吗?

不一定。链接器可把它放入独立只读 PT_LOAD,也可能与其他只读内容组合,具体取决于链接脚本和 separate-code 等策略。

15.9 GOT 是否被所有进程共享?

只读共享库代码页可以共享;需要运行时写入、COW 或每个装载实例不同的 GOT 数据通常是进程私有映射。完成重定位后,RELRO 可把部分区域转成只读。

15.10 PIC 是否等于"相对地址 + GOT"?

这是有帮助的入门模型,但不完整。PIC 是一组面向目标架构、符号类型和可抢占语义的代码生成策略;本地跳转、PC-relative 数据访问、GOT、PLT、TLS 等路径各不相同。

15.11 PLT 是否每次都调用动态链接器?

经典 lazy binding 下第一次解析后会更新槽位,后续调用直接到真实函数;使用 -z now、LD_BIND_NOW 或 -fno-plt 时,路径又会变化。

15.12 动态链接是在"编译时"还是"运行时"?

构建时的静态链接器仍会检查共享库、生成 DT_NEEDED、PLT/GOT 和动态重定位;真正装载共享对象并完成运行时绑定的是动态链接器。两阶段都有工作。

15.13 动态库为何能节省内存?

同一文件支持的只读代码页可以在多个进程之间复用,但每个进程仍有自己的虚拟映射、页表、可写数据、TLS 和按需 COW 页面。

15.14 PIE 与共享库为什么都可能是 ET_DYN?

ET_DYN 表示共享对象类型的地址模型。现代 PIE 可执行文件也采用它以支持地址随机化;是否为主程序还需结合 PT_INTERP、入口点和动态元数据判断。

15.15 SONAME 有什么用?

SONAME 是运行时依赖的 ABI 名称。它把"链接时使用的真实文件版本"与"程序记录的 ABI 主版本依赖"分离,便于兼容升级和并存。


总结

把整套知识串起来,可以得到一条完整链路:

text 复制代码
源码
  ↓ 编译
ELF .o:Section + Symbol + Relocation
  ├── ar 归档 → 静态库 .a
  └── -fPIC + -shared → 动态库 .so
              ↓
最终链接:解析符号、合并 Section、应用静态重定位
              ↓
ELF 可执行文件 / PIE:Program Header 描述装载布局
              ↓ execve
内核映射 PT_LOAD,并按 PT_INTERP 启动动态链接器
              ↓
动态链接器搜索 DT_NEEDED、映射共享库、处理动态重定位
              ↓
PIC 通过 PC-relative/GOT/PLT 等机制访问运行时地址
              ↓
程序从 _start 经运行库启动流程进入 main

真正理解库,不是只会背两条命令,而是能始终分清四个阶段:

  1. 编译期:声明是否可见,目标文件生成了什么符号和重定位;
  2. 链接期:库能否找到、顺序是否正确、符号如何绑定;
  3. 装载期:Program Header 如何变成进程映射;
  4. 运行期:动态库在哪里找到、GOT/PLT 如何获得真实地址。

遇到 undefined reference、cannot open shared object file 或 symbol lookup error 时,先判断问题属于哪一个阶段,排查会立刻清晰很多。


参考资料

推荐标签:Linux 静态库 动态库 ELF GOT PLT 链接器 系统编程

相关推荐
律宏阔2 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔2 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴2 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千2 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei372 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei372 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei372 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
judezh2 小时前
api 容器一直 unhealthy?我把一个 Agent 运行时的健康检查逐条拆了,查出四个问题
运维·docker
GeW2 小时前
制造业高端化,为什么必须重估Linux和数据库?
linux
百万蹄蹄向前冲2 小时前
双端同步!云服务器装最新Node.js v26.10全过程追踪
服务器·人工智能·node.js