静态库、共享库与运行时插件——三种「复用代码」的手艺,加上一把能解剖任何二进制的工具刀
核心方法论:工欲善其事,必先利其器
「上一章,福尔摩斯带你们弄清了链接器在幕后怎么工作——符号解析、重定位,规矩都讲明白了。这一章轮到我上场:光懂规矩不够,还得趁手。静态库、共享库、运行时插件,是三件不同的家伙什;ar、nm、readelf、strace,是一整套解剖台。别被命令的数量吓到,跟着我把每一件都亲手抡一遍——这一章结束,你会拥有把任何 .a / .so 拆开看个底朝天的本事。」
先给「库」这个词说句人话:库(library)就是别人——或者三个月前的你自己——写好的、打包好的现成代码。你写程序天天要用的 printf、malloc,实现它们的那一大坨代码就躺在系统的库里。库存在的意义只有一句话:把「经常要用的代码」写一遍、编一遍、存起来,谁要用谁来取。
库有两种打包方式,这一节先讲第一种。静态库(static library,.a 文件)的本质,就是一堆 .o 目标文件的「压缩袋」——还记得第 3 章吗?gcc -c 编出来的 .o 叫可重定位目标文件,里面是还没定型的代码和数据。ar 工具(archiver,归档器)把这些 .o 一个个塞进一个 .a 文件里,链接的时候,链接器就像按需取货一样,从袋子里抽出需要的成员,把代码直接复制并进你的可执行文件——所以叫「静态」:链接完就固定了,跟库再无关系。
动手做一个。还是用素材里的老朋友 addvec(对两个整型数组逐元素求和):
// addvec.c —— 待打包的库源码
#include <stdio.h>
void addvec(int* x, int* y, int* z, int n)
{
int i;
for (i = 0; i < n; i++)
{
z[i] = x[i] + y[i];
}
}
# 第一步:把源码编译成目标文件(回顾第 3 章:.o 就是可重定位目标文件)
gcc -c addvec.c -o addvec.o
# 第二步:用 ar 打包。三个字母各有用意:
# r = replace,插入成员,同名则替换
# c = create,库不存在就新建(否则会报错)
# s = 建立符号索引,加快链接时的查找(相当于自动跑了一遍 ranlib)
ar rcs libvector.a addvec.o
# 看看袋子里装了什么(t = table,列成员目录)
ar t libvector.a
# addvec.o
库的命名有讲究:必须叫 lib<名字>.a。链接时你不需要写全名,用 -l<名字>(letter L 的小写)让链接器自己去搜索路径里找 lib<名字>.a,-L. 是告诉它「顺便看看当前目录」。下面两种写法等价,第一种更直白:
# 用法一:把 .a 当成一个目标文件直接交给链接器(本机实测输出)
gcc main.c libvector.a -o app
./app
# z = [4 6]
# 用法二:-L. 指定搜索目录,-lvector 自动匹配 libvector.a
gcc main.c -L. -lvector -o app
这里藏着一个第 4 章留下的伏笔——链接顺序问题。链接器做符号解析时是从左到右扫一遍的,而静态库是「按需提取」:扫到某个引用时,如果这个符号还没被定义,才去库里找;一旦翻过某个库,就再也不会回头。所以铁律是:被依赖的库,必须放在依赖它的目标文件后面。顺序一错,立刻翻车:
# 错误示范:-lvector 放在了 main.c 前面
gcc -L. -lvector main.c -o app
# ld: undefined reference to `addvec' ← 经典报错!
# 原因:扫到 -lvector 时还没人引用 addvec,库被白白翻过;
# 等扫到 main.o 发现 undefined reference,回头找——晚了。
记住这个口诀:「谁用谁在前,被用的靠后站」。真实工程里库依赖链很长(A 依赖 B,B 依赖 C),顺序就得写成 -lA -lB -lC。这也是为什么大型项目后来都改用 CMake 之类的构建系统——它会自动帮你排好顺序,但理解原理永远不过时,毕竟报错信息还是这一句。
| ar 子命令 | 作用 |
|---|---|
r | 把目标文件插入库中,已有同名成员则替换 |
c | 创建库(不存在时不报错) |
s | 为库建立符号索引(等价于 ranlib),加快链接查找 |
t | 列出库中的成员(目录) |
x | 把成员提取成独立的 .o 文件 |
d | 从库中删除成员 |
静态库的优点和缺点一样明显。优点是部署省心:链接完就是一个自包含的可执行文件,拷到任何一台机器都能跑,不担心缺库;缺点是体积和更新:每个用它的程序都复制一份完整代码,磁盘和内存都浪费,而且库一更新,所有依赖它的程序都得重新链接一遍。下一节的主角——共享库——就是冲着这两个缺点来的。
共享库(shared library,.so 文件)的思路完全相反:不「复印」,办「借书证」。链接的时候,链接器只在你可执行文件的某个角落里登记一行:「这程序要用 libaddvec.so」;真正把库代码装进内存,是程序启动时由系统的动态链接器(dynamic linker,Linux 上是 ld-linux.so,就是第 3 章提到过的那个家伙)来干的。而且这份代码在内存里只有一份,所有用到它的进程共享同一块——这就是「共享」二字的来历。
这就引出一个关键技术点:库要被「塞」进每个进程不同的内存地址,所以库里的代码不能写死任何绝对地址,必须写成相对寻址——这叫位置无关代码(PIC,Position Independent Code)。编译时加 -fPIC 就是让编译器按这个规矩生成代码,再用 -shared 把它打包成共享目标文件:
# 一条命令生成共享库:
# -fPIC = Position Independent Code,位置无关代码(能被塞进任意地址)
# -shared = 生成共享目标文件(ELF 里的 DSO:Dynamic Shared Object)
gcc -shared -fPIC -o libaddvec.so addvec.c
# 看一眼它的真面目(Linux 上的输出):
file libaddvec.so
# libaddvec.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
# dynamically linked, not stripped
链接使用的时候,命令和静态库几乎一样,但链接器做的事完全不同——它不复制代码,只在可执行文件里登记依赖(ELF 的 DT_NEEDED 条目)。这种链接方式叫加载时链接:程序一启动,动态链接器看到登记表,自动把 .so 加载进来,把符号地址填好,程序才开始跑。
# 链接:登记依赖(不是复制代码)
gcc main.c -L. -laddvec -o app_dyn
# ldd = List Dynamic Dependencies,列出运行时依赖(Linux)
ldd ./app_dyn
# linux-vdso.so.1 => (0x00007fff5f5f3000)
# libaddvec.so => ./libaddvec.so
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# /lib64/ld-linux-x86-64.so.2 (0x00007f4a2e0e6000)
macOS 上对应的工具是 otool -L(.so 换成了 .dylib),Windows 上是依赖分析工具,但原理完全一样。还有个小细节:如果编译时忘了 -fPIC,链接通常不会立刻报错,等运行时才炸——所以看到 relocation R_X86_64_PC32 ... can not be used when making a shared object; recompile with -fPIC 这种报错,第一反应就是补上 -fPIC。
| 维度 | 静态库 .a | 共享库 .so |
|---|---|---|
| 链接时机 | 编译时(链接器直接把代码并进去) | 加载时(程序启动,动态链接器加载)或运行时(dlopen,下节讲) |
| 代码去向 | 复制进可执行文件 | 留在磁盘,运行时映射进内存,多进程共享一份 |
| 可执行文件体积 | 大(每份都复制) | 小(只登记依赖) |
| 更新库 | 所有使用者重新链接 | 直接替换 .so 文件(注意接口兼容) |
| 部署 | 单文件,拷走就能跑 | 目标机器上必须有对应 .so(这就是传说中的「缺 .so 跑不起来」) |
| 链接顺序坑 | 有(5.1 刚踩过) | 基本无(只登记,不提取) |
到这里为止,加载 .so 的时机还是「程序启动时」,由动态链接器代劳。但共享库最迷人的用法还在后面:让程序自己、在运行到一半的时候、按需加载一个库——这就是 5.3 节的主角,也是「插件化」的地基。
想象一个场景:你的程序已经上线跑着,客户突然要一个新功能。常规做法:改代码、重新编译、停服务、重启——客户骂娘。插件化(plugin)的做法:程序本来就把「加载插件」这个口子留着,你写好一个新 .so 丢进插件目录,程序下次扫描时自动就用了——不重启、不改主程序一行代码。浏览器装扩展、IDE 装插件、游戏打 Mod,背后全是这套机制。
Linux 为动态链接器开了一扇后门:<dlfcn.h> 头文件里的四个函数(动态链接家族,dl = dynamic link)。它们管「在运行时加载和链接任意共享库」这件事,签名如下——注意这是标准签名,网上流传的版本经常抄错,以这里为准:
#include <dlfcn.h>
void* dlopen(const char* filename, int flag); // 加载共享库,返回句柄;失败返回 NULL
void* dlsym(void* handle, const char* symbol); // 按符号名查地址;失败返回 NULL
int dlclose(void* handle); // 卸载库;成功返回 0,失败返回 -1
char* dlerror(void); // 取最近一次错误的描述;无错误返回 NULL
逐个说人话。 dlopen 是「开门」:按文件名把共享库加载进内存,返回一个句柄(handle)——你可以把它理解成这把锁的钥匙,后面三个函数都得拿它办事;第二个参数 flag 控制解析时机,最常用的三个取值:
| flag 取值 | 含义 |
|---|---|
RTLD_LAZY | 先不解析,等代码真正执行到那个符号时再解析(默认,启动快) |
RTLD_NOW | 立即解析库里的所有符号引用,有解析不了的当场报错 |
RTLD_GLOBAL | 库的符号对之后 dlopen 的库可见(默认 RTLD_LOCAL,只对自己可见) |
dlsym 是「点名」:给定句柄和符号名字符串,返回这个符号(函数或全局变量)的地址——拿到地址后转成函数指针就能直接调用。 dlclose 是「关门」:卸载库,系统会等所有引用它的地方都关了才真正卸(引用计数归零才动手)。 dlerror 是「看诊」:返回最近一次调用失败的描述字符串,看完病它还会清空状态,所以错误信息要立即保存,别等用的时候再调。
光说不练假把式。下面这套 addvec 示例我完整跑过——先是库源码(和 5.1 同一个 addvec.c,原样保留):
// addvec.c —— 库源码,原样保留
#include <stdio.h>
void addvec(int* x, int* y, int* z, int n)
{
int i;
for (i = 0; i < n; i++)
{
z[i] = x[i] + y[i];
}
}
# 编译成共享库(-shared -fPIC 一条龙,本机实测通过)
gcc -shared -fPIC -o libaddvec.so addvec.c
然后是主角 test.c——程序运行起来之后才去加载 libaddvec.so、点名要 addvec 函数、调用它、最后卸载。素材原版有几处抄写错误(缺 stdlib.h、dlerror 被调了两次、dlclose 签名被写错),这里给出修正后的正确版本:
// test.c —— 在运行时动态加载 libaddvec.so 并调用 addvec()
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h> // 提供 exit(),原版漏了
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
void* handle;
void (*addvec)(int*, int*, int*, int); // 函数指针:addvec 的替身
char* error;
// 1. 开门:动态加载共享库。RTLD_LAZY = 用到符号时才解析
handle = dlopen("./libaddvec.so", RTLD_LAZY);
if (handle == NULL)
{
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
// 2. 点名:从库里取出 addvec 函数的地址,转成函数指针
addvec = dlsym(handle, "addvec");
if ((error = dlerror()) != NULL)
{
fprintf(stderr, "%s\n", error);
exit(1);
}
// 3. 调用:拿到了地址,addvec 用起来和普通函数一模一样
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
// 4. 关门:卸载共享库(引用计数归零才真正卸载)
if (dlclose(handle) != 0)
{
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
return 0;
}
# 编译:-rdynamic 让可执行文件自己的全局符号对动态链接器可见(插件回调主程序的函数时必需)
# -ldl 链接动态链接器接口;glibc 2.34+ 已并入 libc,老系统需要,macOS 不需要
gcc -rdynamic -O2 -o test test.c -ldl
# 运行 —— 本机实测输出:
./test
# z = [4 6]
插件系统里最常见的两个「魔法属性」(GCC / Clang 都支持),能让库在加载/卸载时自动执行初始化与清理,不用主程序显式调用:
// 插件里写这两个函数,加载/卸载时自动触发
__attribute__((constructor)) void plugin_init(void) { /* 注册自己 */ }
__attribute__((destructor)) void plugin_fini(void) { /* 注销自己 */ }
注意两个经典陷阱:第一,dlsym 返回 NULL 不一定是失败——符号的值可能本来就是 NULL!所以判断必须用 dlerror()(而且先调一次清空旧状态,再 dlsym,再检查),不能只判断返回值。第二,dlclose 之后句柄立即失效,再拿它调 dlsym 是未定义行为,程序可能当场段错误(segfault,访问了非法内存地址导致崩溃)。养成习惯:关了就忘掉那把钥匙。
看到这里你应该有个感觉:.o、.a、.so、可执行文件,都是些「装二进制的大盒子」,黑乎乎的。接下来的任务就是把它们打开看个明白——鲁班造东西讲究「器」,这一节就是发工具。Linux 上有一整套 GNU binutils(binary utilities,二进制工具集),个个都是解剖目标文件的放大镜。第 3 章我们提过一句,这节来认全:
| 工具 | 一句话用途(说人话) | 真实命令示例 |
|---|---|---|
ar | 打包 / 管理静态库(.o 的「压缩袋」) | ar rcs libvector.a addvec.o |
strings | 列出文件里所有可打印的字符串(找暗藏的文本信息) | strings libaddvec.so |
strip | 删掉符号表,给可执行文件「瘦身」 | strip test |
nm | 列出符号表:谁定义了哪些符号(函数 / 全局变量) | nm libaddvec.so |
size | 看各节(text / data / bss)各占多大 | size test |
readelf | 解剖 ELF 完整结构:ELF 头、节、段,最权威(含 size 和 nm 的功能) | readelf -h test |
objdump | 所有二进制工具之母:能显示一切信息,招牌技能是反汇编 | objdump -d test |
ldd | 列出可执行文件的运行时共享库依赖 | ldd ./test |
来一次实战解剖——对象就是 5.3 节刚生成的 libaddvec.so 和 test(下面输出是 Linux 上的标准格式):
# file:先看这是什么文件
file libaddvec.so
# libaddvec.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
# dynamically linked, not stripped
# nm:符号表。T 表示 Text(代码段)里定义了一个全局函数 addvec
nm libaddvec.so
# 0000000000001109 T addvec
# size:各节大小(text 代码 / data 已初始化数据 / bss 未初始化数据)
size libaddvec.so
# text data bss dec hex filename
# 216 0 0 216 d8 libaddvec.so
# objdump -d:反汇编,把 .text 里的机器码翻译回汇编(head 只取前几行)
objdump -d libaddvec.so | head -20
# 0000000000001109 <addvec>:
# 1109: f3 0f 1e fa endbr64
# 110d: 55 push %rbp
# 110e: 48 89 e5 mov %rsp,%rbp
两个平台差异值得记在小本本上:第一,macOS 的符号名带前导下划线(本机实测 nm libaddvec.so 输出的是 T _addvec),Linux 的 ELF 不带——这是 Mach-O 与 ELF 的命名约定差异;第二,readelf 是 Linux 专属,macOS 上没有(可用 otool -L 看依赖、nm 看符号)。写跨平台脚本时这些差异全是坑。
再看两个「小工具大用场」的:strings 是找文本线索的利器——库里的错误消息、版本号、路径,全都能捞出来(做安全分析时它第一个上);strip 是发布前的最后一步——删掉符号表后文件更小、逆向更难,但调试信息也没了,所以「带 -g 调试的版本」和「strip 过的发布版本」要分开管理:
# strings:把文件里所有可打印字符串捞出来(-n 3 只要 3 字符以上的)
strings -n 3 libaddvec.so
# __cxa_finalize
# _ITM_deregisterTMCloneTable
# ...
# strip:删符号表瘦身。之后 nm 就查不到符号了
strip test
nm test
# nm: test: no symbols ← 瘦身成功,也「失忆」了
库的事告一段落。程序跑起来之后叫进程(process)——一个正在执行的程序实例。这一节认识几件「看进程」的家伙什:程序跑得好不好、卡在哪、谁在偷偷摸鱼,全靠它们。素材里给了五个,一个个来,重点在第一个。
strace:系统调用跟踪器——程序每向内核提一个请求(打开文件、读数据、写屏幕、申请内存),它都记一笔。这是满足好奇心的神器,也是排查「程序到底卡在哪」的第一把刀。编译时用 -static(静态链接)能拿到干净的轨迹,不会被一大堆加载共享库的调用淹没。在 Linux 上跟踪 5.3 那个 ./test,节选如下(中间加载 libc 的冗长部分省略了):
# Linux 上:strace ./test(节选,真实输出格式)
$ strace ./test
execve("./test", ["./test"], 0x7ffd43d1d1e0 /* 43 vars */) = 0
brk(NULL) = 0x55f0a0c00000
openat(AT_FDCWD, "./libaddvec.so", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 16784, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f4a2e0e6000
...(加载 libc、设置线程栈等大量调用,省略)...
write(1, "z = [4 6]\n", 10) = 10
exit_group(0) = ?
+++ exited with 0 +++
三个亮点请对号入座:openat(..., "./libaddvec.so", ...) 就是 dlopen 在背后的真身——它最终要发起一次打开文件的系统调用;write(1, "z = [4 6]\n", 10) 是 printf 落地的样子(1 是标准输出的文件描述符);exit_group(0) 是进程退出的方式。strace 是 Linux 专属工具,macOS 上的近亲是 dtruss(需要管理员权限)。
剩下四件是老朋友了,但值得把「说人话」版记牢。ps(process status):给当前系统里的进程拍一张快照——包括僵尸进程(zombie,已经死了但还没被父进程「收尸」的进程,ps 里显示为 <defunct>)。top:动态刷新的资源监视器,CPU、内存、谁在吃资源一目了然,按 q 退出。kill:给进程发信号——它不止能「杀」,信号是一套进程间通信的暗号。 /proc:一个虚拟文件系统(不占磁盘),把内核的一大堆数据结构以 ASCII 文本形式暴露出来,用户程序可以直接 cat 着读:
# ps:全部进程快照(a 所有用户,u 显示用户与资源,x 含无终端的进程)
ps aux
# USER PID %CPU %MEM VSZ RSS ... COMMAND
# root 1 0.0 0.3 168960 13568 ... /sbin/init
# top:动态刷新资源占用,按 q 退出
top
# kill:发信号。先礼貌(TERM),再不听话才强杀(KILL)
kill -l # 列出所有信号
kill -TERM 1234 # 请进程自行退出(默认信号,可被程序拦截处理)
kill -9 1234 # SIGKILL:内核直接处决,程序没机会反抗
# /proc:内核的「裸奔日记」,全是文本,随便读
cat /proc/cpuinfo # CPU 型号、核心数
cat /proc/meminfo # 内存总量与使用情况
cat /proc/self/maps # 当前进程的虚拟内存布局——第 6 章的主角
ls /proc/1234/ # 每个进程一个目录,名字就是 PID
注意:kill -9 是最后手段,不是第一选择。SIGKILL 不给进程任何清理机会(文件写到一半、数据库事务没提交,全都会烂尾)。排查问题时先 ps 看进程,再 strace -p <PID> 挂上去看它卡在哪个系统调用,最后才谈得上 kill——而且默认的 kill <PID>(SIGTERM)往往就够了。
至此,第 4 章的链接器理论,这一章的三种「打包手艺」加上两套观察工具,你已经能完整回答「一个程序从 .c 变成进程,中间每一站都发生了什么」。第 6 章我们就要带着这些工具,钻进进程的内部去看虚拟内存那张地图了。
把三种链接时机和对应的场景配对:① 编译时链接;② 加载时链接;③ 运行时链接。场景:A. 程序启动时,动态链接器把 libc.so 装进内存并填好符号地址;B. ar 打包的 libvector.a 被链接器直接并入可执行文件;C. 程序运行到一半,自己调用 dlopen 加载了一个插件 .so。
回顾三个小节的标题:5.1 静态库(编译时)、5.2 共享库(加载时)、5.3 dlopen(运行时)。关键区分点是「谁在什么时机做这件事」。
① 编译时链接 → B(链接器把 .a 的成员复制并进可执行文件);② 加载时链接 → A(程序启动时动态链接器干活,程序自己毫不知情);③ 运行时链接 → C(程序自己调用 dlopen,主动权在程序手里)。一句话记忆:静态库是「买断」,共享库是「启动时办借书证」,dlopen 是「用的时候才去借」。
现有源码 addvec.c 和 main.c(main 调用 addvec)。请依次写出四条命令:(a) 把 addvec.c 编译成目标文件 addvec.o;(b) 用 ar 把 addvec.o 打包成静态库 libvector.a;(c) 用 -l 方式(注意搜索目录与顺序)把 main.c 和 libvector.a 链接成可执行文件 app;(d) 把 addvec.c 编译成共享库 libaddvec.so,并写出查看 app 运行时依赖的命令。
5.1 的创建三步(gcc -c → ar rcs)、链接顺序口诀「谁用谁在前,被用的靠后站」、-L. 与 -lvector 的配合;5.2 的 -shared -fPIC 与 ldd。
(a) gcc -c addvec.c -o addvec.o;(b) ar rcs libvector.a addvec.o;(c) gcc main.c -L. -lvector -o app(注意 main.c 必须在 -lvector 前面,否则 undefined reference);(d) gcc -shared -fPIC -o libaddvec.so addvec.c,查看依赖用 ldd ./app(macOS 用 otool -L)。
有同学从网上抄了一段 dlopen 代码,编译都过不去。请找出至少三处错误并说明后果:void* handle = dlopen("./libaddvec.so", RELD_LAZY);;错误处理里写 fprintf(stderr, "%s\n", dlerror()) 前没有 #include <stdlib.h> 就调用 exit(1);if ((error = dlerror()) != NULL) { fprintf(stderr, "%s\n", dlerror()); }。
对照 5.3 的四个签名和 test.c 修正版:标志名的正确拼写是什么?exit 需要哪个头文件?dlerror 的返回值为什么必须保存下来用?
① RELD_LAZY 拼写错误,正确是 RTLD_LAZY(素材原版就写错了,本章已修正);② 缺 #include <stdlib.h> 却调用 exit(),编译会警告/报错;③ dlerror() 被调了两次:第一次调用已经把错误状态清掉了,第二次调用返回的可能是 NULL,打印出来是空的——正确做法是先保存 (error = dlerror()) != NULL 再用 error。这三处正好是 5.3 里「修正版」与素材原版的差异。
动手造一个「可插拔」的小程序:写两个共享库 plugin_a.so 和 plugin_b.so,各自导出一个 int run(void) 函数(a 返回 1,b 返回 2);主程序 loader.c 读命令行参数(./loader a 或 ./loader b),用 dlopen + dlsym 加载对应的 .so,调用 run() 并打印返回值,最后 dlclose。提示:库名可以拼接成 "./plugin_" + 参数 + ".so";别忘了 dlerror 的检查套路。跑通后回答:如果新增一个 plugin_c.so,主程序需要重新编译吗?
骨架:main 里 snprintf(name, sizeof(name), "./plugin_%s.so", argv[1]),然后照 5.3 test.c 的四步走(dlopen → dlsym → 调用 → dlclose)。两个 .so 用 gcc -shared -fPIC -o plugin_a.so plugin_a.c 分别编译。编译 loader 记得 -ldl(老系统)。
核心答案:不需要重新编译——这正是插件化的全部意义:主程序只认「接口」(函数名 run),不认「实现」;新增一个实现了同一接口的 .so,丢进目录就能用,主程序一行代码不用动。只要保证:所有插件导出完全相同的符号(这里是 run),签名一致。练习 4 跑通后,试着把 dlopen 的 flag 换成 RTLD_NOW 再跑一遍,体会两者的差别。