很多学过 C 语言的同学,第一次被"库"这个概念难住,往往是在链接报错的时候。你在写代码时明明 #include 了头文件,结果一敲 gcc 就给你抛出一句 "undefined reference to `foo'"——你头都大了:我明明写了这个函数啊?其实,头文件只负责让你"看得见"函数,真正让程序"用得上"函数的是库。头文件是"说明书",库才是"真身"。这两者之间的关系,是理解库的第一道坎,我后面会专门用一节把它掰开。
这一篇,我们把 Linux 下的库从头到尾讲清楚:什么是库,静态库 .a 和动态库 .so 各自怎么制作、怎么使用、怎么查找,底层藏在 ELF 文件里的秘密,再到库里那套生动的"设计模式",最后落到工程实战。全文很长,但保证每一个点你都听得懂、敲得动。我们开始。
什么是库
库(library),本质是"写好的、现有的、成熟的、可以复用的一段代码"。仔细想想,现实中没有任何一个程序是完全从零写的——你只要用到 printf、malloc、strcpy,就已经在依赖别人写好的库了。如果每个人都要自己实现一遍标准输入输出,那这个世界就不用写业务代码了。
从形态上说,库是可执行代码的二进制形式,操作系统可以把它载入内存去执行。库分成两种:
- 静态库(static library):Linux 下后缀
.a(archive 的缩写),Windows 下后缀.lib; - 动态库(dynamic library):Linux 下后缀
.so(shared object 的缩写),Windows 下后缀.dll。
"静态"和"动态",说的是"什么时候把库的代码合进你的程序"——是编译链接时,还是程序运行时。这个区别是全文的纲领,我们贯穿始终。
你的 Linux 系统里其实堆满了这两种库。C 语言的标准库 libc 就是一个绝佳的例子:
# Ubuntu / Debian 系列:C 的静态库和动态库并存
$ ls -l /lib/x86_64-linux-gnu/libc-2.31.so
-rwxr-xr-x 1 root root 2029592 May 1 02:20 /lib/x86_64-linux-gnu/libc-2.31.so
$ ls -l /lib/x86_64-linux-gnu/libc.a
-rw-r--r-- 1 root root 5747594 May 1 02:20 /lib/x86_64-linux-gnu/libc.a
# CentOS / RHEL 系列
$ ls -l /lib64/libc-2.17.so
-rwxr-xr-x 1 root root 2156592 Jun 4 23:05 /lib64/libc-2.17.so
$ ls -l /lib64/libc.a
-rw-r--r-- 1 root root 5105516 Jun 4 23:05 /lib64/libc.a注意两个细节:第一,同一个库既有 .a 也有 .so;第二,.so 文件是可执行的(权限里有 x),.a 文件不可执行(只有 rw-)。为什么?因为 .so 在运行时才被加载执行,需要 x 权限;而 .a 只是个"打包箱",它等着链接器把里面的代码挖出来,自己永远不直接执行。这个权限差异背后,正是两种库在生命周期上的本质区别。
C++ 标准库同样如此。看 libstdc++ 的版本:ubuntu 下那行输出的末尾有个 ->,表示它是一个软链接(symlink):
# ubuntu: .so 是个软链接,最终指向真正的带版本号的文件
$ ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so
lrwxrwxrwx 1 root root 40 Oct 24 2022 /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -> ../../../x86_64-linux-gnu/libstdc++.so.6为什么动态库都喜欢"套一层不带版本号的软链接、指向带版本号的真身"?因为这样做,链接时只需要记住 libstdc++.so 这个稳定名字,而真身的版本号可以随意变化,升级后改一下软链接指向即可,不用重新编译所有依赖它的程序。这也是很多库文件的常规布局。这里的布局我们把它当成知识点记住:动态库常用"无版本号软链接 → 带版本号真身"的结构,这是为了升级兼容。后面讲运行时搜索路径时还会再遇到软链接。
第一道思考题:库的本质与形态
问:既然 .a 和 .so 都是"库",那它们的名字、用途、能否自执行各有什么讲究?
答:三者都要搞清楚。
- 命名:Linux 下静态库命名
libxxx.a、动态库命名libxxx.so,都遵循"lib+ 库名 + 后缀"的规则,前缀lib和后缀都是强制的。链接时却不写前缀不写后缀,只有一个库名xxx(-lxxx),编译器会自动补全去找libxxx.a或libxxx.so。 - 用途:
.a在链接期把代码"复制"进可执行文件;.so在运行期被动态链接器"映射"进进程。前者是"合并",后者是"共享"。 - 能否自执行:
.a不可执行(无x权限),因为它只是个供链接器取用的"档案袋";.so可执行(有x权限),因为它要在运行时被加载到进程里执行。
预备工作:我们先写一套自己的库
纸上谈兵永远学不会。我们亲自写一套小的 C 库,回头用它分别演示静态库和动态库的制作与使用。这套库的素材,就用课件里那套历史上封装过的 my_stdio(一个简化版的文件读写流)和 my_string(几个字符串工具)。
先刻一个非常重要的认知:一个典型的库 = 头文件(.h,暴露接口)+ 源文件(.c,隐藏实现)。调用方只能看到头文件,绝不该看到实现细节。所以我们先写头文件。
// my_stdio.h —— 一个简化版"文件流",接口从这里暴露
#pragma once // 防止头文件被重复包含(如果忘了这个,#include 两次就会重复声明报错)
#include <sys/types.h> // 需要 off_t / ssize_t 等类型,其实本文件暂未用到,写上以图完整
#define SIZE 1024 // 缓冲区大小:一次性不管多少,我们都先攒到 1024 字节
#define FLUSH_NONE 0 // 不自动刷新
#define FLUSH_LINE 1 // 遇到 '\n' 就刷新(行缓冲模式)
#define FLUSH_FULL 2 // 缓冲区满才刷新(全缓冲模式)
// 用结构体模拟 FILE:文件描述符 + 缓冲 + 刷新策略
struct IO_FILE
{
int flag; // 刷新策略:FLUSH_NONE / FLUSH_LINE / FLUSH_FULL
int fileno; // 文件描述符(内核返回的整数句柄)
char outbuffer[SIZE]; // 用户态缓冲区,数据先攒在这,刷新时才真正写内核
int cap; // 缓冲区容量,这里恒为 SIZE
int size; // 当前缓冲区里已攒下多少字节
};
typedef struct IO_FILE mFILE; // 给结构体起个别名,调用方写 mFILE* 比写 struct IO_FILE* 短
// 对外接口:打开一个文件流
mFILE *mfopen(const char *filename, const char *mode);
// 对外接口:往流里写 num 个字节
int mfwrite(const void *ptr, int num, mFILE *stream);
// 对外接口:把缓冲区里的数据刷到内核
void mfflush(mFILE *stream);
// 对外接口:关闭流(先刷缓冲,再关闭文件描述符)
void mfclose(mFILE *stream);// my_stdio.c —— 上面接口的具体实现(调用方永远不需要看到这份文件)
#include "my_stdio.h" // 引入自己的头文件,保证实现和声明一致
#include <string.h> // strcmp / memcpy
#include <stdlib.h> // malloc / free
#include <fcntl.h> // O_RDONLY / O_CREAT / O_WRONLY / O_TRUNC / O_APPEND
#include <unistd.h> // open / close / write / fsync
// 打开文件:按 mode 转换为不同的 flag,再 open,最后分配并初始化流结构
mFILE *mfopen(const char *filename, const char *mode)
{
int fd = -1;
// 三种模式:读 "r" / 覆盖写 "w" / 追加写 "a"
if (strcmp(mode, "r") == 0)
fd = open(filename, O_RDONLY);
else if (strcmp(mode, "w") == 0)
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
else if (strcmp(mode, "a") == 0)
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
if (fd < 0) return NULL; // open 失败,返回空指针告诉调用方
mFILE *mf = (mFILE *)malloc(sizeof(mFILE)); // 在堆上分配流结构
if (!mf) // 分配失败,记得把已打开的 fd 关掉,防止泄漏
{
close(fd);
return NULL;
}
// 初始化结构成员
mf->fileno = fd;
mf->flag = FLUSH_LINE; // 默认采用"行缓冲"模式
mf->size = 0; // 初始没有攒任何数据
mf->cap = SIZE;
return mf;
}
// 真正的"刷盘"动作:把缓冲区数据一次性写进内核,再 fsync 落到外设
void mfflush(mFILE *stream)
{
if (stream->size > 0) // 缓冲区有内容才需要刷;没有就什么都不做
{
write(stream->fileno, stream->outbuffer, stream->size); // 写到内核的文件缓冲区
fsync(stream->fileno); // 强制刷到物理磁盘(课件里特意强调)
stream->size = 0; // 刷完清零计数,腾出缓冲区
}
}
// 写 num 字节:先 memcpy 拷进缓冲区,再按刷新策略决定是否立即刷
int mfwrite(const void *ptr, int num, mFILE *stream)
{
// 简化实现:这里假设一次写入不会超出缓冲区剩余空间(真实 stdio 会做缓冲拆分,这里略)
memcpy(stream->outbuffer + stream->size, ptr, num); // 把数据拷到缓冲区末尾
stream->size += num; // 更新已攒字节数
// 行缓冲模式:只要末尾是 '\n' 就立即刷新,保证"打了回车就立刻可见"
if (stream->flag == FLUSH_LINE && stream->size > 0 &&
stream->outbuffer[stream->size - 1] == '\n')
{
mfflush(stream);
}
return num; // 返回实际写入的字节数
}
// 关闭流:先刷干净剩余缓冲,再关闭描述符,最后释放结构内存
void mfclose(mFILE *stream)
{
if (stream->size > 0) // 可能还有没刷出去的数据,先刷
mfflush(stream);
close(stream->fileno); // 关闭内核文件描述符
free(stream); // 释放堆上的结构,这才是它真正能被 free 的原因
}// my_string.h —— 字符串工具接口
#pragma once // 防止重复包含
int my_strlen(const char *s); // 自己实现一个 strlen// my_string.c —— 实现
#include "my_string.h"
// 遍历直到 '\0',返回结束指针和起始指针之差,就是字符串长度
int my_strlen(const char *s)
{
const char *end = s;
while (*end != '\0') end++; // 从头走到 '\0'
return (int)(end - s); // 指针相减得元素个数
}这里有个小提示:课件里 IO_FILE 结构体成员 cap 标注了 // TODO,意思是"先留个字段,后续要支持全缓冲/参数化时再用"。我在实现里已经把它用上了,让代码更健壮。你写自己的库时也这样思考:结构体里预留字段、接口给足参数,是一种为未来留口子的小设计。
静态库:制作与使用
静态链接的本质一句话
静态库 .a:程序在编译链接的时候,把库里的代码"复制"进可执行文件中。程序运行的时候,不再需要这个静态库了。
这话怎么理解?打个比方:静态库像你去宜家买家具——你把整件家具(甚至是要自己拧的零件)一路搬回家装好。从此这家具就长在你家里了,你不需要再回宜家。对应到程序:链接时链接器把静态库里用到的 .o 的机器码合并进可执行文件,这个可执行文件从此"自给自足",把库文件删了照样跑。
对应的特性可以立刻推导出来:
- 可执行文件体积大(每个程序都自带一份完整代码);
- 多个都用这份库的程序,各自体内各带一份代码——静态库各自带一份,不共享;
- 好处是部署简单、不受库版本影响、几乎没有启动时的查找开销。
用 Makefile 生成静态库
要让 .c 变成 .a,先得把每个 .c 单独编译成目标文件 .o,再用 ar 把多个 .o 打包进 .a。我们写个 Makefile 一步到位:
# 目标:libmystdio.a(静态库)依赖两个 .o
libmystdio.a: my_stdio.o my_string.o
@ar -rc $@ $^ # $@ 是目标名 libmystdio.a,$^ 是所有依赖(两个 .o)
@echo "build $^ to $@ ... done"
# 通用规则:任意 .c 编译成同名 .o
%.o:%.c
@gcc -c $< # $< 是第一个依赖(那个 .c 文件),-c 表示只编译不链接
@echo "compling $< to $@ ... done"
.PHONY:clean
clean:
@rm -rf *.a *.o stdc* # 清掉所有中间产物
@echo "clean ... done"
.PHONY:output
output:
@mkdir -p stdc/include # 建目录,专门放头文件
@mkdir -p stdc/lib # 专门放库文件
@cp -f *.h stdc/include
@cp -f *.a stdc/lib
@tar -czf stdc.tgz stdc # 打包成 tar.gz 归档,方便分发
@echo "output stdc ... done"这里的核心是 ar。ar 是 GNU 的归档工具,英文全称 archive,作用是把若干个 .o 目标文件"打包"成一个档案文件。它和 tar 很像,但多做了一件事:为包里的每个符号建立索引,方便链接器快速查找。ar 的参数 rc 是它的经典组合:
r= replace,把同名成员替换进去(重复打包时不报错,直接覆盖);c= create,如果档案不存在则创建。
第二个很有用的参数组合是 t 和 v:
# t 列出静态库里的成员,v 显示详细信息(verbose)
$ ar -tv libmystdio.a
rw-r--r-- 1000/1000 1736 Jan 10 10:20 2025 my_stdio.o
rw-r--r-- 1000/1000 912 Jan 10 10:20 2025 my_string.o补充一个重要概念:ranlib。它的作用是"给归档文件生成符号索引",本质上和 ar -s 等价。当你用 ar rc 创建库时,现代版本的 ar 往往会顺带生成索引(等于内置了 ranlib),所以很多教程里"ar -rc 之后还要跑 ranlib"是历史遗留的稳妥做法。你只要知道:符号索引就相当于这本书的目录页,链接器靠它快速知道"哪个 .o 里定义了哪个函数",不用把整个库从头到尾翻一遍。在 input sec 链接大量 .o 的静态库时,这个索引能显著提升链接速度。
@ 前缀让 make 在执行这条命令时不回显命令本身,只打印我们 echo 的内容,让输出更清爽。
使用静态库的三种场景
做好了库,我们要在别的程序里用它。写一个主程序 main.c:
// main.c —— 使用我们自己的库
#include "my_stdio.h" // 引入库的接口头
#include "my_string.h"
#include <stdio.h> // 用到 printf
int main()
{
const char *s = "abcdefg";
printf("%s: %d\n", s, my_strlen(s)); // 用我们库里的函数算长度
mFILE *fp = mfopen("./log.txt", "a"); // 追加模式打开一个文件
if (fp == NULL) return 1; // 打开失败就退出
// 把字符串连写三遍
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfclose(fp); // 关闭并刷新
return 0;
}编译链接这个程序,根据头文件和库放的位置,分为三种场景:
# 场景1:头文件、库文件都已安装到系统路径下(比如 /usr/include 和 /usr/lib)
# 这时 -I -L 都不用,直接 -l 指定库名即可
$ gcc main.c -lmystdio
# 场景2:头文件、库文件都在当前目录(和 main.c 同目录)
$ gcc main.c -L. -lmystdio
# 场景3:头文件和库在各自己的独立路径
$ gcc main.c -I头文件路径 -L库文件路径 -lmystdio这几个选项的含义必须刻进脑子:
-L:指定库文件的搜索路径(Library path)。后面接到的是目录,比如-L.表示"去当前目录找库",-L/opt/mylib表示去那个目录找。-I:指定头文件的搜索路径(Include path)。-I是大写的 i,别和链接库的-L搞混。-l:指定库名(Library)。注意:-l后面跟的是去掉前缀lib、去掉后缀.so/.a之后的中间名。比如libmystdio.a在命令行写作-lmystdio,libc.so写作-lc。这条"去头去尾保中间"的规则一定要熟记,是新手第一大迷路点。-o:指定输出文件名(比如-o a.out)。上面例子里没写-o,所以默认产出a.out。
验证一下静态链接的效果:程序生成后,把静态库 libmystdio.a 删掉,程序照样能运行。
$ rm -f libmystdio.a
$ ./a.out
abcdefg: 7删掉库文件还能正常运行,就是静态库"把代码长进了程序里"的铁证。因为链接时那段代码已经拷贝进 a.out 了,a.out 完完全全不再需要那个 .a 文件。
关于 -static 选项
课件里留了个悬念:"关于 -static 选项,稍后介绍"。现在来揭晓。
gcc 编译默认的行为是优先链接动态库,只有在一个库名下找不到 .so、只找得到同名 .a 时,才会退而求其次用静态库。也就是说,-l 的默认查找顺序是"先 .so 后 .a"。如果你想无视这个偏好、强制全部用静态链接,就加 -static:
# 强制静态链接全部库(包括 libc)——生成的可执行文件将完全自包含
$ gcc main.c -lmystdio -static-static 会把链接器设置为"只用静态库",于是连 C 运行时库 libc 也会被以 .a 形式用掉。但前面我们看到 libc.a 体积巨大(好几百 KB 到好几 MB),所以一个 -static 出来的程序往往体积暴涨,放大了"静态库体积大"的缺点。这也是为什么生产环境默认不动它。
第二道思考题:静态库的取舍
问:为什么链接器默认"先找动态库、找不到才用静态库"?如果 -L. 目录里 libmystdio.so 和 libmystdio.a 同时在,-lmystdio 会链接哪个?
答:默认链接顺序是"先动态后静态",所以会优先链接 libmystdio.so。这个设计的初衷来自操作系统对资源的珍惜:大家默认都希望程序尽量走共享的动态链接,减少硬盘和内存的浪费,只有在迫不得已时才把手动指定的静态版本作为兜底(比如某函数在动态库里确实不存在,而静态库里有)。如果你今天的实验目的恰恰是想"必须用静态版",怎么办?可以显式给链接器传完整文件名而不是用 -l,比如 gcc main.c libmystdio.a——这样链接器就知道你要的是这个具体文件;也可以用 -static 全局强制(副作用是把 libc 也静态化了)。多数场景你直接用 -l 且依赖"先动态"即可。
动态库:制作与使用
动态链接的本质一句话
动态库 .so:程序在运行的时候才去链接动态库的代码。多个程序共享使用同一份库代码。
回到宜家的比方:动态库像"宜家送货上门 + 拼装好的共享样板间"——不,更贴切的是它像公共图书馆。你看书不需要把书抄回家,你随时去图书馆借阅,而所有读者共享同一批藏书(同一份物理内存里的代码)。对应到程序:
- 可执行文件里只存了一个"函数地址表",并不存放库函数的机器码;
- 机器码由动态链接器在程序启动时,把库从磁盘映射进进程虚拟内存;
- 因为内存中只保留一份库代码,所有用它的进程共享,省内存省磁盘。
发出这两点推论,你就能自然推出它的优点和缺点:体积小、省资源、升级方便(换 .so 文件即可,程序不用重编);但代价是运行时依赖——a.out 跑起来那一刻必须能找到 .so,找不到就启动失败。
用 Makefile 生成动态库
和静态库最大的一点不同:编译 .o 时必须加 -fPIC。为什么?因为动态库要被映射到"每个进程任意位置",它内部的代码必须能"搬家"而不改任何一个字节。这种能力叫地址无关代码(PIC,Position Independent Code),-fPIC 就是让编译器生成它。这个原理我们放到后面 ELF/链接大章节展开,这里先记住"生成动态库必须 -fPIC"这条铁律。
# libstdc 动态库的 Makefile
libmystdio.so: my_stdio.o my_string.o
gcc -o $@ $^ -shared # -shared 表示生成共享库格式(.so)
%.o:%.c
gcc -fPIC -c $< # -fPIC 生成位置无关代码
.PHONY:clean
clean:
@rm -rf *.so *.o stdc*
@echo "clean ... done"
.PHONY:output
output:
@mkdir -p stdc/include
@mkdir -p stdc/lib
@cp -f *.h stdc/include
@cp -f *.so stdc/lib
@tar -czf stdc.tgz stdc
@echo "output stdc ... done"两个新标志:
-shared:告诉链接器"我要生成的不是一个可执行程序,而是一个动态共享库"。-fPIC:生成位置无关代码。动态库被加载到哪、就用到哪,代码里的跳转全部走"相对地址 + 查表"而不是写死绝对地址,这样任意位置都能跑。
库名规则和静态库一样:libxxx.so。
使用动态库(链接期看到的假象)
使用动态库的 -L -I -l 命令和静态库一模一样:
# 场景2:库在本地当前目录
$ gcc main.c -L. -lmystdio -o a.out看起来和静态库的使用"没区别",对吧?但这里藏着一个巨大的反差:-L 指定的路径只对"链接期"生效,对"运行期"无效。 换句话说,gcc 在链接时能顺着 -L. 找到 libmystdio.so,把调用关系写进 a.out 的"待解析函数表";但这份 a.out 一旦运行起来,动它的是动态链接器,它对 -L. 一无所知——运行期找库走的是另一套搜索规则,跟 -L 没关系。这正是后面要讲的"运行搜索路径"的存在意义。
先用 ldd 看看一个可执行程序的依赖。ldd 命令用于打印程序或库文件所依赖的共享库列表:
$ ldd libmystdio.so # 查看库本身依赖谁
linux-vdso.so.1 => (0x00007fffacbbf000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8917335000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)看这三行,正好对应三层东西:
linux-vdso.so.1:kernel 提供的虚拟动态共享对象,用来快速让用户程序调用某些内核功能(比如gettimeofday),可以看到它没有 "=>" 指向的实体路径,因为它不存在于磁盘文件里,由内核直接映射;libc.so.6:C 运行时库,这里显示它实际解析到/lib/x86_64-linux-gnu/libc.so.6,括号里是它被映射进的虚拟地址;/lib64/ld-linux-x86-64.so.2:这行没有 "=>",它就是我们反复念叨的动态链接器(dynamic linker),别名也叫程序解释器(program interpreter)。所有动态程序启动时,是它先接管控制权、把所有依赖库都加载映射好,最后才把控制权交给main。
第三步思考题:ldd 那三行分别是什么
问:ldd 输出里前两行有 "=> 路径 (地址)",第三行没有。这说明什么?
答:ldd 打印的每一行是一个"依赖"。解析规则是"向左看库名,向右看它实际被解析到的绝对路径和加载地址"。
- 前两行(
linux-vdso.so.1和libc.so.6)是有名字的普通动态库,动态链接器会根据搜索规则把它们映射到固定的虚拟地址,所以=>右边给出"真正的文件路径"和"映射地址"; - 第三行
/lib64/ld-linux-x86-64.so.2是动态链接器本身——它是"谁去加载别人"的那个角色,所以它不是被加载的库,=>右边自然没有内容,只有它自己的绝对路径。这条"没有 '=>' 的第三行"在几乎所有动态程序的ldd里都会出现,看到它就知道:这个程序是动态链接的,依赖动态链接器。
动态库运行时的搜索路径:找不到 .so 怎么办
我们辛辛苦苦用 -L. 编出了 a.out,兴冲冲 ./a.out,结果:
$ ldd a.out
linux-vdso.so.1 => (0x00007fff4d396000)
libmystdio.so => not found # 糟了!找不到
libc.so.6 => /lib64/libc.so.6 (0x00007fa2aef30000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)ldd 里那行刺眼的 libmystdio.so => not found,就是运行期查找失败的现场。这正是上一节说的"-L 只在链接期有效"的报应——libc.so.6 能找到是因为它在系统的默认库目录,而我们自己的 libmystdio.so 不在任何运行期搜索目录里。
动态链接器在运行期按什么顺序找库? 大致是这样的(从高优先到低优先):
LD_LIBRARY_PATH环境变量里写的一组路径;- 缓存文件
/etc/ld.so.cache里记录的路径(这个缓存由ldconfig命令生成); - 默认系统库目录:
/lib64、/usr/lib64、/usr/lib、/usr/local/lib等。
所以对应的"找不到 .so"有四种标准解法,我都给你写全:
解法一:把 .so 复制到系统共享库路径下。
# 常见系统库目录:/usr/lib、/usr/local/lib、/lib64 等
$ sudo cp libmystdio.so /usr/local/lib/
$ ./a.out # 现在能跑了,因为系统默认目录里有了代价:污染系统目录、多个项目同名库会互相覆盖。适合"这库确实想装成系统级"的场景。
解法二:在系统共享库目录下建软链接。
$ sudo ln -s /你的路径/libmystdio.so /usr/local/lib/libmystdio.so
$ ./a.out比直接 cp 友好,库本体留在项目里,只挂个"指针"进系统目录;以后升库只换链接目标,不用反复拷。
解法三:改环境变量 LD_LIBRARY_PATH。
# 把当前目录加进动态库搜索路径,再运行
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.
$ ./a.out # 当前 shell 生效LD_LIBRARY_PATH 是环境变量名,动态链接器每次找库都会先翻它。优点:不改系统任何文件、可以精确到只影响当前 shell 或单个程序。缺点:它只影响"当前进程环境",换个终端就失效(要用就得每次 export);而且它是纯靠环境走,有被恶意 LD_PRELOAD 劫持的风险面,生产服务器一般不这么配。
解法四:配置 /etc/ld.so.conf.d/,然后 ldconfig 更新缓存。
这是"正规军"做法。ldconfig 是专门的工具,用来重建 /etc/ld.so.cache。步骤如下:
# 1. 在 /etc/ld.so.conf.d/ 下建一个配置文件,写上你自己的库目录
$ cat /etc/ld.so.conf.d/bit.conf
/root/tools/linux
# 2. 执行 ldconfig,重新扫描并生成缓存(要做完这步才生效)
$ sudo ldconfig
# 3. 再次运行程序,动态链接器就能从新缓存里找到库了
$ ./a.out为什么这步叫"更新缓存"?因为 /etc/ld.so.conf 及其 d 子目录定义了"系统认可哪些库目录",而动态链接器为了启动快,不会运行期反复去这些目录里逐个扫文件,而是依赖一份预先生成好的索引 ld.so.cache。你新增了一个库目录或换了一个库,就得跑一次 ldconfig 让它把新库登记进缓存。
第四道思考题:四种解法怎么选
问:在一个"目标机器禁止随便改系统目录、但程序必须找到我放 /opt/myproj/lib 下的 libfoo.so"的场景,你首选哪种解法?为什么。
答:首选解法三(LD_LIBRARY_PATH)或者结合解法四,取决于稳定性和能否接受永久配置。
- 如果这只是临时调试、或程序由某个脚本拉起:用
LD_LIBRARY_PATH=/opt/myproj/lib前缀或export,最小影响、无需 root、改一行即可。缺点暴露在"当前环境要保留"——如果程序由 systemd 托管,就在 unit 文件里配Environment=LD_LIBRARY_PATH=...。 - 如果希望一劳永逸、这台机器就是专属运行机:在
/etc/ld.so.conf.d/加一行/opt/myproj/lib再ldconfig,这样所有用户、所有脚本都省事。
至于解法一、二(拷进系统目录、软链接),在"不想让所有程序都看到这个私有库"的场景就不是首选——你更希望这个库只被自己的程序使用。总之原则:尽量不去污染全局系统目录,优先用环境变量或专属配置做隔离。附加提醒:LD_LIBRARY_PATH 有优先级高于缓存和默认目录的特性,配置错误或遗留旧版本会导致"能用但用的是旧库"的诡异 bug,排查时先 ldd 看一眼当下解析到哪个路径。
使用外部库:课后尝试 ncurses
讲到这,你可能觉得"我手上只有 libc 一个库"。其实系统里有海量库,它们通常由一组互相关联、用来完成某项常见工作的函数构成。比如专门处理屏幕显示的 ncurses 库,就能让我们写出花哨的字符界面。
先装库:
# CentOS
$ sudo yum install -y ncurses-devel
# Ubuntu
$ sudo apt install -y libncurses-dev然后写个"彩色进度条"小程序(用 <ncurses.h>):
// progress.c —— 用 ncurses 画一个彩色进度条
#include <stdio.h>
#include <string.h>
#include <ncurses.h> // ncurses 库的头
#include <unistd.h> // usleep
#define PROGRESS_BAR_WIDTH 30
#define BORDER_PADDING 2
#define WINDOW_WIDTH (PROGRESS_BAR_WIDTH + 2 * BORDER_PADDING + 2)
#define WINDOW_HEIGHT 5
#define PROGRESS_INCREMENT 3
#define DELAY 300000 // 微秒 = 300 毫秒
int main() {
initscr(); // 进入 ncurses 全屏模式
start_color(); // 开启颜色
init_pair(1, COLOR_GREEN, COLOR_BLACK);// 调色板1:绿字黑底(已完成)
init_pair(2, COLOR_RED, COLOR_BLACK); // 调色板2:红字黑底(剩余)
cbreak(); // 输入不排队,字符即到
noecho(); // 输入不回显
curs_set(FALSE); // 隐藏光标
int max_y, max_x;
getmaxyx(stdscr, max_y, max_x); // 取屏幕长宽
int start_y = (max_y - WINDOW_HEIGHT) / 2;
int start_x = (max_x - WINDOW_WIDTH) / 2;
WINDOW *win = newwin(WINDOW_HEIGHT, WINDOW_WIDTH, start_y, start_x);
box(win, 0, 0); // 画边框
wrefresh(win);
int progress = 0;
int max_progress = PROGRESS_BAR_WIDTH;
while (progress <= max_progress) {
werase(win); // 清空窗口
int completed = progress;
int bar_x = BORDER_PADDING + 1;
int bar_y = 1;
attron(COLOR_PAIR(1)); // 开始用绿色
for (int i = 0; i < completed; i++)
mvwprintw(win, bar_y, bar_x + i, "#");
attroff(COLOR_PAIR(1)); // 停用绿色
attron(A_BOLD | COLOR_PAIR(2)); // 红色填充剩余
for (int i = completed; i < max_progress; i++)
mvwprintw(win, bar_y, bar_x + i, " ");
attroff(A_BOLD | COLOR_PAIR(2));
char percent_str[10];
snprintf(percent_str, sizeof(percent_str), "%d%%",
(progress * 100) / max_progress);
mvwprintw(win, WINDOW_HEIGHT - 1,
(WINDOW_WIDTH - strlen(percent_str)) / 2, percent_str);
wrefresh(win); // 刷新呈现
progress += PROGRESS_INCREMENT;
usleep(DELAY); // 每帧延时
}
delwin(win); // 释放窗口
endwin(); // 退出 ncurses 模式
return 0;
}编译它,用 -l 把 ncurses 链接进来,同时为了字符界面好看加上 -lm(数学库):
$ gcc progress.c -lncurses -lm -o progress
$ ./progress # 全屏显示一个动画进度条,Ctrl+C 退出通过这个例子你体会到了什么?库让你的"一行能力"远超"一行代码":你只写了几个窗口函数调用,背后是 ncurses 一整座代码大厦在支撑。而这栋大厦,正是因为它是现代操作系统里"一堆 .so + 一个 .so=N/A 的加载器"的精妙协作,才得以平稳运行。附带说一句,ncurses 是典型的动态库,它的 .so 会被系统缓存管理,所以你直接用 -l 就能链接上——这正是"库已装入系统目录 + ldconfig 已登记"给我带来的便利。
头文件 vs 库:一次讲透它俩的分工
现在把最开始的疑惑彻底解决。请记住下面这张表的每一行:
| 角色 | 头文件(.h) | 库(.a / .so) |
|---|---|---|
| 本质作用 | 声明:告诉编译器"有什么",函数原型、结构体、宏 | 定义:告诉链接器/加载器"在哪、长什么样",真正的机器码 |
| 谁读它 | 编译器(编译期) | 链接器(静态库)或动态链接器(动态库,运行期) |
| 你 include 了吗 | 是,#include 把它拷进来 | 否,从不 include,用 -l / 运行期查找 |
| 缺失报什么错 | 编译期报"未声明"(implicit declaration / undeclared) | 链接期报 "undefined reference to" / 运行期报 ".so not found" |
| 找它的方式 | -I 指定目录(默认搜系统 include 树) | -L 指定目录(默认搜系统 lib 树)+ 运行器搜索路径 |
一句话:头文件管"能不能编译通过",库管"能不能链接通过、能不能跑起来"。 两者分开设计有巨大好处——我可以只把 .h 发给合作方(接口契约),库我藏着;或者只把库给出去、头文件严格版本锁定,实现"接口稳定、实现可换"。
举个最常见的坑来印证表格:
#include "my_stdio.h" // 只有声明,你把 mfwrite 的"实现"寄希望于库
int main() {
mfwrite(...); // 编译通过(头文件给了声明)
return 0;
}如果编译时只给了头文件路径而没链库:
$ gcc main.c -Imystdio_include # 只给头文件,没给 -L 也没有 -l
/usr/bin/ld: main.o: undefined reference to `mfwrite' # 链接期崩了
collect2: error: ld returned 1 exit status这就是著名的 undefined reference to(未定义符号)报错。它说的是:你的 .o 里调用了 mfwrite 这个符号(symbol),但在这次链接能看见的所有目标文件和库里,没有一个地方"定义"了它。符号名、符号表是理解编译链接的关键词,下一节我会正式介绍。
第五道思考题:头文件 vs 库的分工
问:报错"undefined reference to 'foo'"和报错"implicit declaration of function 'foo'"分别说明你大概率出了什么问题?
答:两类错误发生在不同阶段,原因也完全不同。
implicit declaration of function 'foo'是编译期错误。意思是编译器在翻译这个.c时,从未在任何头文件里见到foo的声明。原因通常是:没#include那个头文件、头文件路径没通过-I给到,或者声明打字打错。这时候你该去查头文件和-I。undefined reference to 'foo'是链接期错误(ld报的)。意思是所有.o和库都编译好了,链接器在合并时发现:你到处调用foo,但没有一个目标文件或库里给出foo的定义。原因通常是:忘了-lfoo、-L路径给错、库名大小写/前缀后缀写错(比如把-lmystdio写成-lmy_stdio)、或者该函数压根没写实现。这时候你该去查-L/-l和库文件本身。
一句话记忆:implicit declaration 查头文件;undefined reference 查库。
目标文件 .o:编译产生的半成品
理解了库,我们把镜头拉近,看看它最小的构件——目标文件(object file),后缀 .o。
先回顾编译的完整流程。我们说"编译",其实是一串步骤:预处理 → 编译 → 汇编 → 链接。其中前三步把源码变成机器码(但这机器码还不能跑,因为里面的函数调用地址全是"待定");最后一步链接把各个部件拼起来变成可执行文件。
用一个小例子看清楚。两个源文件互相调用:
// hello.c
#include <stdio.h>
void run(); // 只声明,run 定义在另一个文件 code.c
int main()
{
printf("hello world!\n"); // 来自标准库
run(); // 来自 code.c
return 0;
}// code.c
#include <stdio.h>
void run()
{
printf("running...\n");
}分别编译成目标文件:
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o关键认知来了:如果你只改了一个源文件,只需单独重新编译它那一个 .o,其余 .o 原样复用,链接一下即可。 这正是大型工程能存活的根基——按模块增量编译,而不是每次全量重来。Makefile 里的"依赖关系"就是为这个服务的。
看下 hello.o 是什么:
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped重点在几个词上:
ELF:可执行可链接格式(Executable and Linkable Format),是 Linux 下目标文件、库、可执行文件的统一二进制格式。后面单独开一节。relocatable:可重定位的。意思是这还是个"半成品",里面有大量待填的地址,要等链接时把它"放到位"才完整。LSB:小端序(Least Significant Byte first),Intel/AMD x86 系列约定。not stripped:没剥符号,还保留着完整的符号表,方便调试。
反汇编看看里面到底什么样:
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 指向 "hello world"
f: e8 00 00 00 00 callq 14 <main+0x14> # 这条 call 的地址是 0!
14: b8 00 00 00 00 mov $0x0,%eax
19: e8 00 00 00 00 callq 1e <main+0x1e> # 这条 call 的地址也是 0!
1e: b8 00 00 00 00 mov $0x0,%eax
23: 5d pop %rbp
24: c3 retqobjdump -d 命令把 .text 代码段反汇编,让人眼能看懂机器码。看两条 callq:
- 第一条
e8 00 00 00 00,按源码顺序本该调用printf(其实编译器优化成了puts); - 第二条同样
e8 00 00 00 00,本该调用run。
它们的跳转地址都是 00 00 00 00,也就是 0。为什么?因为编译 hello.c 的这一刻,编译器根本不知道 printf、run 在内存哪个地址、代码长什么样。 它只能先在每个待调用处留一个"地址坑"(先填 0),等链接时由链接器统一填真。这些"待填的坑"记录在一张重定位表里,链接器就照着这张表挨个把 0 改成真正的地址。所以:
多个
.o之间彼此不知道对方——它们知道"需要调用谁",但不知道"谁在哪"。地址的谜底,要等链接时揭晓。
从符号表也能读出这个"未知感"。**符号表(symbol table)**就是"源程序里函数名、变量名与它们地址/身份的对应清单"。用 readelf -s 读:
$ readelf -s hello.o
Symbol table '.symtab' contains 14 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c
...
10: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 main
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run抓住三个关键:
main那行:FUNC GLOBAL DEFAULT 1,说明main是"函数、全局、定义在 1 号节里",它是这个文件有定义的符号;puts、run那两行:末尾是UND,是undefined(未定义)的缩写。未定义符号,说白了这个.o里没有它们的实现,只能靠外部补——puts补自 libc,run补自 code.o;DEFAULT 1里的数字是节的下标(section index),告诉读表人"这个符号住在哪一节"。
再看 code.o 的表,你会发现它的 run 是"有定义"(DEFAULT 1,GLOBAL),而 puts 是 UND。于是形成完美互补:hello.o 缺 run,code.o 有 run。等到这两个 .o 合并,run 就有了着落。
ELF 文件:可执行文件的统一底盘
要理解链接和加载,绕不开 ELF。**ELF(Executable and Linkable Format)**是 Linux(以及其他类 Unix)下四类二进制文件的统一格式:
- 可重定位文件(Relocatable file):即
xxx.o。包含适合与其他目标文件链接、以创建可执行文件或共享库的代码和数据; - 可执行文件(Executable file):就是我们的可执行程序,比如
a.out; - 共享目标文件(Shared object file):即
xxx.so动态库; - 核心转储(core dump):进程崩溃或被信号 dump 时保存的执行上下文快照,用于事后调试。
一份完整的 ELF 文件由四部分构成(从文件头到尾):
- ELF 头(ELF header):位于文件最开头,描述文件的主要特性,其核心目的是**"定位其他部分"**——告诉你程序头表在哪、节头表在哪、文件是什么类型、入口地址在哪;
- 程序头表(Program header table):列出所有有效的**段(segment)**及其属性,是"执行/加载视图"的依据。它记录每段在文件中的偏移、长度,加载时按它把各段映射进内存;
- 节头表(Section header table):列出所有**节(section)**及其属性,是"链接视图"的依据。节是更细粒度的组织单位,每个节装一种特定数据;
- 节(Section):文件中的基本组成单位,包含特定类型的数据。代码、数据、符号各居其位。
最常打交道的几个节:
.text:代码节,保存机器指令,程序主要执行部分;.data:数据节,保存已初始化的全局变量和局部静态变量;.rodata:只读数据节,比如字符串字面量;.bss:未初始化数据的预留区(不占磁盘、运行期清零);.symtab:符号表节;.dynsym/.dynstr:动态链接用的符号表与字符串表(动态程序才有);.got/.got.plt/.plt:全局偏移表相关节,动态链接的"对照表",后面专门讲。
看一眼 a.out 的节头表(用 readelf -S):
$ readelf -S a.out | grep -E '\.(text|data|bss|rodata|got|plt)'
[12] .init PROGBITS 0000000000001000 00001000 0000001b 00 AX 0 0 4
[13] .plt PROGBITS 0000000000001020 00001020 00000020 00 AX 0 0 16
[16] .text PROGBITS 0000000000001060 00001060 000001a5 00 AX 0 0 16
[18] .rodata PROGBITS 0000000000002000 00002000 0000001c 00 A 0 0 4
[24] .got PROGBITS 0000000000003fb8 00002fb8 00000048 00 WA 0 0 8
[25] .data PROGBITS 0000000000004000 00003000 00000010 00 WA 0 0 8
[26] .bss NOBITS 0000000000004010 00003010 00000008 00 WA 0 0 1注意 .text、.rodata 的 Flag 是 A 和 AX(可分配、可执行/只读),而 .data、.bss、.got 是 WA(可写、可分配)。这些 Flag 在后面"节合并成段"的时候至关重要——同权限的节才有资格合到同一个段里。
file、size 这些命令也能反查:用 size 可以快速看每个文件里 text/data/bss 的分布:
$ size a.out
text data bss dec hex filename
3284 636 ... ...ELF 的双重视图:链接视图 vs 执行视图
这是 ELF 最难缠、也最有价值的一点:同一份 ELF 文件,从两个角度看,是两个世界。
- 链接视图(linking view)——对应节头表。粒度细,把文件按功能划分成一个个
section。链接器做静态链接时看的就是这个表:把各个.o的.text合到一起、.data合到一起,完成地址修正。 - 执行视图(execution view)——对应程序头表。粒度粗,告诉操作系统"启动时要加载哪些 segment、每段什么权限"。操作系统加载程序、做进程内存布局时看的是这个表。
一句话:节头表服务链接,程序头表服务加载。 一"链接"一"运行",各司其职,都在同一文件里并存。
用 readelf -l 看执行视图(程序头表):
$ readelf -l a.out
Elf file type is EXEC (Executable file)
Entry point 0x4003e0
There are 9 program headers ...
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x40 0x400040 0x400040 R E 8
INTERP 0x238 0x400238 0x400238 R 1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0 0x400000 0x400000 R E 0x200000
LOAD 0xe10 0x600e10 0x600e10 RW 0x200000
DYNAMIC 0xe28 0x600e28 0x600e28 RW 8
...
Section to Segment mapping:
Segment Sections...
02 .interp .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version
.version_r .rela.dyn .rela.plt .init .plt .plt.got .text
.fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss几个直接影响理解的口径:
INTERP段写着[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]——这就是前面说的动态链接器,它被烙在可执行文件的头里,启动时内核会先把它载入,由它负责后续的依赖加载。LOAD段是真正要被映射进内存的段。这里恰好两个LOAD:一个R E(只读+可执行,装代码、只读数据),一个RW(可读写,装全局变量)。没有LOAD的节,比如符号表,运行期根本不需要,不会进内存。
**为什么要小心 Segment 映射?下面这个映射表解释了核心。
为什么要把 section 合并成 segment
这部分几乎是 ELF 里最容易被忽略、却最能体现"设计之美"的一环。答案就俩字:省页。
- 一个原则:相同属性的 section 合并成一个大的 segment。可读可写的数据放一堆,只读的代码放一堆,这样每个 segment 能用一套内存权限统一管理。
- 核心动机是减少页面碎片。操作系统加载、管理内存的基本单位是页面(page),x86-64 下典型 4096 字节,物理内存页往往是"整数块"整块分配给你。如果不合并,假设
.text是 4097 字节、.init是 512 字节,散着放就会占用 3 个页面;而把它们按属性拼合成符合对齐的大段,可能只需要 2 个页面。省下来的,就是实打实的内存和磁盘空间。
另一个动机是权限控制。合并成几个属性分明的大段之后,操作系统可以给 "只读可执行" 的区域置 不可写,给可变数据的区域置 不可执行(防止在里面执行注入代码)。现代防御(如 NX、RELRO)都建立在这套"按段施加权限"的机制上。
静态链接:把 .o 合并、把地址修对
前面反复铺垫的"链接时把 0 改成真地址",到底怎么发生的?我们正式走一遍静态链接。
结论先行:无论是自己的 .o,还是静态库里的 .o,本质都是"把 .o 链接进来"的过程。所以研究静态链接,本质就是研究.o 是怎么被链接的。
试试把 hello.o 和 code.o 合并:
$ gcc -c *.c
$ gcc *.o -o main.exe
$ readelf -s main.exe | grep -E 'run|main|puts'
52: 0000000000001149 23 FUNC GLOBAL DEFAULT 16 run
63: 0000000000001160 37 FUNC GLOBAL DEFAULT 16 main
50: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@@GLIBC_2.2.5看点:合并后,run 有了真实地址 0x1149,main 有了 0x1160,而且它俩的"Ndx"都是 16——说明两个 .o 的 .text 被合并成了同一个节(16 号节)。而 puts 仍然 UND,它得等运行期由动态链接器从 libc 里解析。
用反汇编验证 call 的目标地址是不是被填真了:
$ objdump -d main.exe | sed -n '/<main>:/,/retq/p'
0000000000001160 <main>:
1160: f3 0f 1e fa endbr64
1164: 55 push %rbp
1165: 48 89 e5 mov %rsp,%rbp
1168: 48 8d 3d a0 0e 00 00 lea 0xea0(%rip),%rdi # "hello world"
116f: e8 dc fe ff ff callq 1050 <puts@plt> # 调 puts,走 plt 桩
1174: b8 00 00 00 00 mov $0x0,%eax
1179: e8 cb ff ff ff callq 1149 <run> # 调 run,直接跳到 1149!
117e: b8 00 00 00 00 mov $0x0,%eax
1183: 5d pop %rbp
1184: c3 retq对比编译时的 callq 14,现在 callq 1149——地址被修对了!于是我们总结出静态链接的两大成果:
- 多个
.o的代码段被合并,并统一编址。 - 链接时按重定位表,把
.o中"待定"的函数/全局变量地址修正为最终地址。
所以:
链接,就是把编译产生的所有目标文件(连同用到的静态库里的
.o)组合、拼装成一个独立可执行文件的过程。其中包含"地址修正"——链接器依据重定位表找到那些需要重定位的外部符号,逐个把地址改对。静态链接,本质就是对.o的合并与重定位。
顺带回答课件的追问"为什么 call 后是 00 00 00 00 又被填真"——答案就是上面的整段流程:编译期未知地址先置 0,喂给重定位表;链接期链接器查重定位表把它们填成真实地址。
ELF 从形成到加载的轮廓
现在我们把"编译 → 链接 → 加载"整条流水线串起来,建立全景。
轮廓一:ELF 的形成(静态链接段)
- step-1:把多份 C/C++ 源码翻译成目标
.o文件 + 动/静态库(本身也是 ELF); - step-2:把多个
.o的 section 合并(.text并.text,.data并.data……),并完成地址重定位,得到可执行文件或共享库。
轮廓二:ELF 的加载(exec 段)
- step-1:可执行文件里有多个 section,系统加载时按"相同属性"把它们合并成 segment(可执行段、可读写段、只读段等);
- step-2:每个 segment 有自己的起始地址和长度,这些信息来自程序头表,用来初始化进程内核描述结构(
mm_struct、vm_area_struct)里的[start, end]范围,再用更细的地址填充页表; - step-3:映射完成后,CPU 从 ELF 头里
entry字段记录的入口地址开始执行。
虚拟地址:程序没加载时"已经有地址"
一个关键观念:一个 ELF 程序还没被加载到内存时,它自己已经有地址了。
当代计算机采用"平坦模式"(flat model)统一编址。编译器在产出 ELF 时,就对内部代码和数据做了统一地址规划——你在 objdump -S / gcc 链接产物里看到的那一列最左边地址,就是这种规划的结果。严格说它应该叫逻辑地址(起始地址+偏移);不过我们把起点基线设好,通常就称其为虚拟地址。所以:
虚拟地址机制,不光要操作系统支持,编译器/链接器也必须支持。编译时 ELF 就把自己"将来放在哪"的能力写好了,加载时系统按这套蓝图去落地。
readelf -l a.out 里那个 Entry point 0x...,就是 ELF 头里提前写死的入口地址,操作系统按图索骥。
动态链接与动态库加载:GOT/PLT/PIC 全景
静态链接的好处是"独立自包含",代价是"又大又占内存"——每个程序都自带一份 libc。随着软件复杂度爆炸,系统越来越臃肿,大量程序包含相同代码,浪费海量磁盘和内存。于是动态链接登上舞台:
把需要共享的代码单独抽取成
.so,程序运行期才加载它。因为同一份.so在物理内存中只保留一份副本,被多个进程共享。动态链接,本质把"符号查找 + 地址重定位"从编译/链接期推迟到程序运行时。
代价是牺牲了一点性能和启动时间,但换来了磁盘、内存的高效利用、代码的热升级(换 .so 即可)、以及二进制级的代码复用。这笔账非常划算。
动态链接器与程序起点
在 C/C++ 程序里,程序执行后并不会直接跳进 main。真正的起点是 _start,一个由 glibc 或链接器提供的特殊函数。它的启动流水大致是:
- 建立初始栈环境;
- 初始化数据段;
- 动态链接:调用动态链接器(如
ld-linux.so)解析并加载程序依赖的库,完成所有符号解析和重定位; - 调用
__libc_start_main:做额外初始化(信号处理、线程库等); - 调用
main:控制权终于交给用户代码; - 处理
main返回值并_exit。
动态链接器负责在程序运行期加载动态库。它按一套路径去搜索依赖库:环境变量 LD_LIBRARY_PATH、配置文件 /etc/ld.so.conf 及子配置文件、以及 /etc/ld.so.cache 缓存。缓存是 ldconfig 生成的索引文件,动态链接器优先查它,加快查找。
动态库里的相对地址
为了让同一个 .so 能映射到任意进程的任意虚拟地址,动态库内部的函数调用不能写死绝对地址——它采用相对编址。所谓相对编址,就是代码里的跳转"相对于自身当前位置的偏移",而不是"到某个固定地址"。
把任意一个库反汇编(-S 带源码):
$ objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less你会看到 libc 内部调用多用基于 %rip(指令指针,当前指令地址)的相对寻址,例如 call、lea 0x?(%rip),...。这样整个库无论被 mmap 到哪个基址,内部相对位置不变,代码一个字都不用改。这正是 -fPIC 让编译器生成的东西——地址无关代码(PIC)。
GOT:把"运行期才知道的地址"存进可写表
现在到最核心的机制:调用动态库函数,到底怎么调?
难点在于"我们不能改代码段"。因为代码段是只读的、可被所有进程共享,改了一个进程的代码就是改所有人的。但各进程里库的绝对地址又各不相同——这就矛盾了:既要代码写死不能动,又要每个进程用自己的库地址。
全局偏移表(GOT, Global Offset Table) 破解了这个矛盾:我们不把库函数地址写进代码段,而是写进 .data 区域里一块专门的记录区,这块区叫 GOT,表中每一项存放"本模块要引用的某个库函数(或全局变量)的实际地址"。因为 .data 可读可写,动态链接器在加载时就能往里写真实的库地址。
调用一个动态库函数的路径于是变成:
代码段(只读、共享) --查表--> GOT表(可写、每进程一份) --跳--> 真正的库函数
关键推论:
- 代码段只读不可改,但通过 GOT 可以让代码被所有进程共享;GOT 各进程独立,所以各进程的库地址各自适配,互不干扰;
- 在同一个
.so内部,GOT 与.text的相对位置固定,CPU 用相对寻址(%rip相对)就能找到自己的 GOT; - 调用函数先查 GOT 表,再从表项取出地址跳转;这些表项在库加载完成后被动态链接器填成真地址;
- 这种"相对寻址 + GOT"实现的就是 PIC 地址无关代码——动态库被加载到任意地址都能正常运行、可被所有进程共享。
readelf -l 已经告诉我们:.got 和 .data 在加载时会合并进同一个可写 LOAD 段,所以 GOT 天生可写。
PLT 与延迟绑定(lazy binding)
你也许已经想到一个问题:动态程序一启动就要把所有库函数都解析好、填进 GOT 吗?那太慢了——因为绝大多数动态库函数可能运行期一次都不被调用。
于是引入延迟绑定(lazy binding),核心组件是PLT(Procedure Linkage Table,过程连接表)。思路:
- GOT 里每个函数项默认先指向一段叫"桩代码/存根(stub)"的辅助代码,而不是真函数;
- 当函数第一次被调用时,走到桩代码,桩代码负责去找真函数地址、并把这个真地址填回 GOT;
- 以后再次调用同一函数,GOT 已经是真地址,直接一跳而过,不再经过桩代码。
objdump -d a.out 里已经很清楚地展现了这套动作。比如 puts@plt:
0000000000001050 <puts@plt>:
1050: f3 0f 1e fa endbr64
1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) # 跳到 GOT 里记录的地址第一次执行到 jmpq *GOT项:GOT 项还指向下一条"push 一个索引再跳回 .plt 开头的公共代码、最终调动态链接器解析函数"的流程,动态链接器解析出真地址后回填 GOT。之后再来就直接过去了。
PLT 的完整链路可以画成:
代码里 call puts@plt
│
▼
PLT 桩(.plt 段)
│──jmpq *(GOT 项)──┐
│ ▼
│ GOT 项:(首次)指向解析器代码 / (之后)指向真 puts
└──首次路径──► 调用动态链接器 --> 查符号 --> 回填 GOT --> 调真 puts
一句话记忆:PLT 是把门,GOT 是门牌号;第一次进门先找管理员(动态链接器)登记,之后直接按登记地址进门。
库间依赖:库也能调用库
别忘了,动态库本身也依赖其他库——libmystdio.so 就依赖 libc.so.6。库之间怎么互相调用、也做到地址无关?答案和可执行程序一模一样:库里也自带 .GOT,也是完整的 ELF。 因为大家同是 ELF 格式、都遵守同样的 PIC 规则,才得以实现"库调用库"。
课件里说得好:解析依赖关系的过程,就是加载并完善互相之间 GOT 表的过程。 动态链接器按依赖图去把每个库加载映射,各自的 GOT 由它统一填写。
如果有兴趣观察 GOT 表项在加载前后的变化,可以用 gdb 打个断点,对比"加载瞬间"和"解析完成后"那个 GOT 地址对应的真实函数地址。这里我们只求懂原理,细节留给有好奇心的你去实践。
第六道思考题:静态 vs 动态 全维度对比
问:从"体积、共享性、内存占用、部署、升级、启动、错误时机"七个维度对比静态链接和动态链接。
答:列表如下,请对照记忆:
| 维度 | 静态链接(.a) | 动态链接(.so) |
|---|---|---|
| 可执行文件体积 | 大(含库代码) | 小(只含函数地址表) |
| 代码共享 | 不共享,各自带一份 | 共享,物理内存一份、多进程复用 |
| 内存占用 | 高(每个进程各一份) | 低(一份副本大家用) |
| 部署 | 简单(拷贝一个可执行文件即可) | 需连同 .so 一起分发、路径可解析 |
| 升级 | 必须重新链接程序 | 换 .so 即可,程序无需重编 |
| 启动 | 无需解决依赖,较快 | 需动态链接器加载重定位,略慢 |
| 错误时机 | 链接期报 undefined reference | 运行期报 error while loading shared libraries / .so not found |
关键升华:静态链接解决模块化和独立交付,动态链接解决资源共享和二进制复用。现代系统两者并存、各司其职(比如 libc 平时走动态,特殊系统盘才用 -static 静态)。
动态加载:dlopen 显式加载(运行期手撸动态链接)
到目前为止,用 .so 都是靠"编译时 -l + 动态链接器自动加载",这叫隐式(隐式加载/隐式链接):程序还没跑,动态链接器就把依赖库都加载好了。但这只是开了一扇门,还有另一扇门——显式加载(显式链接 / Dynamic Loading)。
显式加载的意思是:程序自己跑到中途,手动把一个 .so 打开、取到里面函数的地址、然后像普通函数一样调用它。 这要用一组库函数,来自 <dlfcn.h>:
dlopen:打开一个动态库,返回句柄;dlsym:按符号名,取库中某个函数/变量的地址;dlclose:关闭动态库;dlerror:取最近一次错误的描述字符串。
这个"把 .so 当插件、运行时按名字找函数"的能力,是做插件架构的经典手法。看一个完整例子:
// plugin.c —— 一个"插件",编译成 libmath.so
#include <stdio.h>
// 插件要导出的函数:加法
int add(int a, int b) { return a + b; }
// 插件要导出的函数:乘法
int mul(int a, int b) { return a * b; }// loader.c —— 主程序,在运行期加载上面那个插件
#include <stdio.h>
#include <dlfcn.h> // dlopen / dlsym / dlclose / dlerror
// 用函数指针承接从库里查出来的函数
typedef int (*BinOp)(int, int);
int main(void)
{
// 1. 打开动态库,返回句柄 handle;RTLD_LAZY 表示"用到才解析符号"
void *handle = dlopen("./libmath.so", RTLD_LAZY);
if (!handle) { // 打开失败
fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
// 2. 按名字查函数地址;用 (BinOp) 把 void* 强转成函数指针类型
BinOp p_add = (BinOp)dlsym(handle, "add");
BinOp p_mul = (BinOp)dlsym(handle, "mul");
if (!p_add || !p_mul) { // 有个符号没找到
fprintf(stderr, "dlsym: %s\n", dlerror());
dlclose(handle);
return 1;
}
// 3. 现在可以把查出来的地址当普通函数用了
printf("add(a+b) = %d\n", p_add(3, 4)); // 7
printf("mul(a*b) = %d\n", p_mul(3, 4)); // 12
// 4. 用完关闭
if (dlclose(handle) != 0) {
fprintf(stderr, "dlclose: %s\n", dlerror());
return 1;
}
return 0;
}编译 libmath.so 和 loader,注意 -ldl 把 libdl 链进来(现代 glibc 2.34+ 已合并 libdl 到 libc,但保留 -ldl 的写法兼容旧系统):
# 先编插件:-shared 生成库,默认需要 -fPIC
$ gcc -fPIC -shared -o libmath.so plugin.c
# 再编主程序:-ldl 链接 dlopen 系列函数
$ gcc -o loader loader.c -ldl
$ ./loader
add(a+b) = 7
mul(a*b) = 12这就是"插件机制"的雏形:主程序根本不知道插件里有什么函数,全靠运行期 dlsym 按字符串名字去取。隐式加载 vs 显式加载的对比要点:
| 维度 | 隐式加载(-l + 动态链接器) | 显式加载(dlopen/dlsym) |
|---|---|---|
| 何时加载 | 程序启动时,动态链接器自动做 | 程序中途,自己手动调 dlopen |
是否 -l | 需要编译时 -l库名 | 不需要,全靠运行时打开 |
| 符号使用 | 像普通函数一样直接用 | 必须 dlsym 拿到地址后再调用 |
| 典型场景 | 绝大多数常规程序 | 插件系统、驱动加载、高级特性开关 |
第七道思考题:为什么"找不到函数"从编译期拖到运行期还是安全的
问:dlopen 显式加载时,dlsym 是在运行期按字符串找函数,编译期完全不检查。这是不是意味着"打错函数名"直到运行才暴露?有没有办法在编译期就约束?
答:是对的,dlsym 传什么字符串就找什么字符串,编译期不查——所以"函数名打字打错"会一直拖到运行期才报错(返回 NULL 或触发 dlerror)。这是显式加载"灵活"与"危险"的一体两面。
要缓解,有几种思路:一是把插件接口收敛到"主程序定义函数指针类型 + 插件头文件里用宏包装 PLUGIN_API(func) 类宏",让字典式的字符串自动生成、杜绝拼错;二是约定插件必须导出某个固定入口(比如统一的 plugin_init/plugin_run),主程序只 dlsym 这一个固定入口,再由入口分发逻辑,把"字符串匹配面"缩到最小;三是借助测试框架在开发期就给符号打标。
没有魔法能让"运行时字符串查表"变成编译期强类型,但通过"接口收敛 + 单一入口"能把出错窗口压到很小。而隐式加载则天然享有编译期的符号检查——因为 -l 链接时链接器已经把所有 undefined reference 查过一遍了。
C 与 C++ 混编:extern "C" 与库
库世界里一个高频但容易懵的点:C 库和 C++ 库互相调用的"名字"问题。它和前面讲静态链接的"符号"是同一根藤上的瓜。
名称修饰(name mangling)是怎么毁掉跨语言调用的
C++ 为了支持函数重载(同名不同参的函数并存),会为每个函数生成一个"加了参数类型、命名空间等信息的符号名",这个过程叫名称修饰(name mangling)。比如 GCC/Itanium ABI 下:
- C++ 的
void foo(int)可能变成_Z3fooi; void foo(double)变成_Z3food;ns::foo(int)变成_ZN2ns3fooi。
你可以用 c++filt 反解:
$ c++filt _Z3fooi
foo(int)问题来了:C 语言没有名称修饰。C 的 foo 在符号表里就叫 foo。所以如果一个 C 程序去链接一个由 C++ 编译的库,它去找的符号是 "foo",而库里导出的符号是 _Z3fooi——永远对不上,于是报 undefined reference to 'foo'。反过来,C++ 程序去链接纯 C 库,C++ 侧生成的调用符号是 _Z3fooi(因为 C++ 编译器默认对一切符号做名称修饰),而 C 库里只有 foo——同样对不上。
解法:extern "C" 关掉 C++ 的名称修饰
为了让 C++ 代码里的某些符号"按 C 的规则"导出/引用,用 extern "C" 告诉编译器"这段里别做名称修饰"。经典写法,是让头文件"既能被 C 包含、又被 C++ 包含"时仍正确——用条件编译夹一层:
// mystic.h —— 想让 C 和 C++ 都能 include
#pragma once
#ifdef __cplusplus
extern "C" { // 如果是 C++ 编译器,进入 C 链接规则
#endif
// 对外接口函数
int add(int a, int b);
int mul(int a, int b);
#ifdef __cplusplus
} // 结束 extern "C" 区
#endif要点:
__cplusplus是"C++ 编译器一定会定义"的宏。C 编译器不定义它。所以这段头文件被 C 程序 include 时,extern "C"那两行不存在,一切照旧;被 C++ 程序 include 时,自动裹上extern "C",让其中的声明不参与名称修饰——C++ 侧就能直接用add这个裸名字去链接。- 一旦对某个函数用了
extern "C",它就不能再参与函数重载了——因为 C 命名没有区分同名不同参的手段,重载的唯一凭据(名称修饰)被关掉了。
用个总括句:能不能跨语言链接,本质是"双方对符号名达成一致"。extern "C" 就是 C++ 主动向 C 靠拢、把名字"拉平"的开关。
第八道思考题:extern "C" 该写在声明还是定义
问:extern "C" 应该放在函数的"声明"处还是"定义"处?如果库实现文件用 .c 写的、头文件声明用了 extern "C",链接能成吗?
答:extern "C" 是一个 链接规范(linkage specification),它修饰的是"这个名字的符号,链接时按哪种命名规则生效"。规范上它写在声明处就足以确立该符号的链接规范(声明能看到,定义自然跟随同一实体)。所以常见做法是把 extern "C" 围在头文件的声明区——所有 include 这个头的地方(包括定义所在的那个 .cpp)都统一按 C 规则链接。
你问的那种场景——实现是 .c 文件写的——其实天然就没问题。因为 .c 文件由 C 编译器处理,它本身就不做名称修饰,库导出的符号就是裸名 add。而头文件里的 extern "C" 是给 C++ 调用方看的:让 C++ 侧也不做修饰、去匹配裸名 add。你是用 C 还是 C++ 写这份库的实现,跟"调用方怎么声明"无关——只要库导出的裸符号名和调用方 dylib 链接时寻找的裸符号名一致即可。反过来,如果库是用 .cpp 写的但你是 C 程序的调用方,那就必须让那门 C++ 库的接口以 extern "C" 形式导出裸名,否则 C 侧死活找不到 _Z... 这种名字。
RTTI 与库:运行时类型信息
库世界里还有一块常常被忽略却会导致"玄学 bug"的地方:RTTI(Run-Time Type Information,运行时类型信息)。
RTTI 是 C++ 在运行期识别"对象真实类型"的机制,主要来自两个关键字:
typeid:返回某表达式的类型信息(std::type_info),可用来比较"是不是同一种类型";dynamic_cast:在运行时做安全的"向下/交叉"转型,失败返回nullptr(对指针)或抛出std::bad_cast(对引用)。
RTTI 之所以和"库"扯上关系,是因为它依赖**虚函数表(vtable)**里的类型信息指针。C++ 只要类里有至少一个虚函数,编译器就会给这个类生成 vtable,vtable 头部藏着该类的 type_info。dynamic_cast、typeid 就是在运行时翻这张表。
为什么它在多库项目里会"出鬼"?因为 vtable 是在编译库的时候生成的。如果编译动态库时大家没统一 -fno-rtti / -frtti,或者不同 .so 用不同的编译器/ABI,指向同一类的方法在两个库里各自生成了"两份 type_info"——两边比较 typeid 时,会因为 type_info 地址不同而判定为"不同类",dynamic_cast 白白返回空。这就是所谓"跨 DSO(动态共享对象)RTTI 不一致"的坑。
规避建议:
- 不同
.so/.o用同一套编译器、同一套 ABI 选项(尤其 RTTI 开关要一致:默认-frtti,别轻易在局部库上-fno-rtti); - 公共的类定义最好只在一个编译单元里具现,避免"各库各编一份 vtable";
- 跨库抛异常(异常也依赖 type_info)时,务必保证两库的 RTTI 一致,否则
catch可能匹配不上。
这一节是"进阶预警"。你今天只需要建立一个印象:动态库之间看似独立,其实在 RTTI、异常、符号解析上牢牢咬合;跨库类型识别对"ABI 一致性"极其敏感。 真踩到再回来深挖。
第九道思考题:动态库"代码共享"的边界
问:既然 .so 是"物理内存共享一份",那 typeid 这种依赖 vtable 的机制,会不会因为共享而"天然一致"?
答:恰恰相反。"共享一份物理内存"不等于"只存在一份类型定义"。 让我们拆开这两个概念:
- 共享的是**
.text机器码那段物理内存页**。假设libfoo.so被 100 个进程用,这 100 个进程的虚拟地址都映射到同一批物理页,代码只有一份。 - 但**
type_info、vtable 属于数据段(.data/.got那类),它们会被 "copy-on-write" 复制到各进程自己的一份**,更重要的是:如果在编译产物层面(而非运行期共享层面)让同一类在不同的.so里各自生成了一份 vtable 和 type_info,那么即便运行期映射到同一物理页,链接器解析到的也是两个不同地址的 type_info 实体。
所以typeid 跨库比较失配,根源是编译/链接期层面的重复具现,与"进程间物理页是否共享"无关。要治本,靠的是保持 ABI/RTTI 设置一致、避免跨库重复具现类定义,而不是指望运行时共享来兜底。这提醒我们:"共享库"共享的是机器码,共享的边界是很清晰的;类型、符号的"唯一性共识"是另一套工程纪律。
库制作的设计模式与工程实战
把机制讲透之后,我们来聊聊"到底该怎么设计一个库"——这部分在真实工程里的价值,往往被忽视。以下三种是教材之外极其常用的库设计模式。我们继续用 libmystdio 当载体说明。
模式一:抽象层(Abstraction Layer)——接口与实现彻底分离
抽象层的核心是:调用方只依赖"稳定接口",不触及"可变实现"。我们已经在 my_stdio 上做对了——调用方 #include "my_stdio.h",手里只有 mFILE* 这个不透明句柄和几个函数原型,永远看不见 struct IO_FILE 里的成员、更不该直接改它。
工程上的获益是巨大的:
- 改实现不动调用方:今天缓冲区用定长数组,明天改成动态增长链表,只要函数签名不变,所有调用方代码一行不用改;
- 隐藏内部状态:
struct IO_FILE的成员是"库自己的秘密"。如果不幸把结构体整个暴露,调用方就可能绕过接口直接fp->size = 999,把库内部状态搅乱——这正是很多库选择**不透明指句柄(opaque handle)**的原因(比如FORWARD_DECLARE+void*); - 测试友好:可以对"抽象层接口"做 mock,分离单元测试。
一个值得学的小技巧:头文件里永远只写声明,把实现藏进 .c。链接时 -l 找到的那个库,本质就是"把抽象层接口背后的真身"递进可执行文件。
模式二:单例(Singleton)——库内部的"唯一全局状态"
单例指的是"整个程序里这个东西只能有一份"。在库的范畴里,很多库需要在进程内维护"唯一一份配置/上下文",并让所有调用共享。经典的例子是:程序全局只有一个 mFILE 的"标准输出流",或者只初始化一次的资源(如某硬件驱动句柄)。
C 里实现单例的朴素做法,是库内一个"隐身在实现文件里的全局状态 + 初始化开关":
// config.c —— 一个"全局唯一配置"的单例
#include <pthread.h>
static int g_inited = 0; // 是否已初始化(文件内 static,外部不可见)
static int g_log_level = 0; // 唯一一份日志等级
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
// 一次性初始化:只有第一次调用才真正干活
int mylib_init(void)
{
pthread_mutex_lock(&g_lock);
if (!g_inited)
{
g_log_level = 2; // 设置默认值
g_inited = 1;
}
pthread_mutex_unlock(&g_lock);
return 0;
}
int mylib_log_level(void) // 读取唯一值
{
return g_log_level;
}单例模式对应过来的本质是三条纪律:
- 状态收归库内:全部用
static,编译成"只有本.c可见的外部符号」,彻底隔绝外部误操作; - 只提供一个访问入口:调用方没有别的路拿到那份状态,只能通过库接口;
- 并发安全:多线程下的"只初始化一次"必须用锁(或
pthread_once)保护,否则多个线程同时首次调用会互相踩。
顺带一提,动态库本身有一个和"单例"高度相关的现实效应:一个 .so 被多个进程共享,但每个进程里它只是一份。所以"进程内唯一全局状态"这个单例需求,天然由动态库的加载模型承载;你若想跨进程共享一份状态,那是共享内存、IPC 的事,不是库的职责。
模式三:注册表(Registry)——让外部代码"自主登记"
注册表是库设计里极简而强大的一个模式:库维护一张"表",允许外部模块往里"注册",库再在合适的时机"遍历使用"。它是 dlopen 插件、驱动框架(如 Linux 设备驱动、GStreamer 插件)、以及现代语言里"显式导出/宏注册"的基础。
一个很常见的实现手法是用 C 的构造函数属性 __attribute__((constructor)):让某个函数在动态库被加载的那一刻自动执行,自动完成"注册"。
// math_plugin.c —— 一个通过"注册表"自报家门的插件
#include "registry.h"
static const char kName[] = "math_ops";
// 注册进去的回调:返回名字+两个算子
static void on_registered(void)
{
ops_register(kName, /* add = */1, /* mul = */2, /* opts = */ 42);
}
// 动态库被 dlopen 的那一刻,这个函数会被自动调用
__attribute__((constructor))
static void math_auto_register(void)
{
printf("[plugin:math] auto-registering...\n");
on_registered();
}
// 库卸载前自动执行,反注册,适合清理资源
__attribute__((destructor))
static void math_auto_unregister(void)
{
printf("[plugin:math] unregistering...\n");
ops_unregister(kName);
}// registry.h —— 注册表外观(只给插件看的那一面)
#pragma once
void ops_register(const char *name, int a, int b, int flags);
void ops_unregister(const char *name);// registry.c —— 注册表的实现:一张静态表
#include <stdio.h>
#include <string.h>
#include "registry.h"
#define MAX_OPS 8
// 表项结构
typedef struct {
char name[32];
int add;
int mul;
} OpsEntry;
static OpsEntry g_table[MAX_OPS]; // 全局唯一的登记表(又是个"进程内单例")
static int g_count = 0;
void ops_register(const char *name, int a, int b, int flags)
{
if (g_count >= MAX_OPS) { // 表满了
printf("registry full, skip %s\n", name);
return;
}
snprintf(g_table[g_count].name, sizeof(g_table[g_count].name), "%s", name);
g_table[g_count].add = a;
g_table[g_count].mul = b;
(void)flags;
g_count++;
}
void ops_unregister(const char *name)
{
for (int i = 0; i < g_count; i++) {
if (strcmp(g_table[i].name, name) == 0) {
// 把最后一项搬过来腾位,保持表紧凑
g_table[i] = g_table[g_count - 1];
g_count--;
return;
}
}
}要点拆解:
- 插件
.c根本没有被主程序直接dlopen之外的任何代码引用——它全靠.ctor(constructor)段里那个自动执行的函数完成自注册; - 主程序只要"扫一遍
.so、dlopen每个",插件就会自己把自己登记进g_table;之后主程序只需遍历g_table就能拿到所有可用算子; - 这个"事件驱动式"的思路和
dlopen + dlsym是互补的:dlsym是主程序主动点名要某个符号;constructor 是模块被加载后自主上报。现实插件系统往往两者兼用。
工程实战:静态库顺序的坑
聊到工程实战,最容易让新手当场崩溃的不是上面这些高大上的模式,而是一个极其朴素的问题——静态库 -l 的参数顺序。
先看现象:
# 假设 a.o 需要 b.o 里的符号,b.o 又在 libfoo.a 里
$ gcc a.o -lfoo # 正常
$ gcc -lfoo a.o # 报错:undefined reference,逼疯人为什么顺序会改变结果?因为 GNU ld 处理静态库是"一遍扫、取我所需"的过程:
- 链接器从左到右处理每个输入;
- 当处理到
.o时,它把.o里未解析的符号记进"待解决清单"; - 当处理到静态库时,它只把库里"能解决当前待解决清单"的那些
.o成员吸进来;一旦这一遍扫完,清单里没被解决的符号,不会回头再扫已处理过的库。
于是 -lfoo a.o 的顺序里,-lfoo 先被打包——但那会儿链接器还不知道它需要 add、mul,所以 libfoo.a 里"定义 add/mul 的成员"根本没被吸进来;等轮到 a.o 才冒出 undefined,可惜 libfoo.a 已经被甩在身后,不会再回去翻,于是报 undefined reference。
三条铁律:
- 被依赖者放后面:谁的符号解决了别人的需求,谁就尽量靠右。通用的表达是"库参数写在
.o和更前面的库之后"。 - 互相依赖的库要成对给:如果
libA依赖libB、libB又依赖libA,正确的写法是-lA -lB -lA(来回再扫一遍); - 实在理不清就循环多遍/或直接把重复写:GNU ld 还提供
--start-group ... --end-group让链接器反复扫一组库,但通常先把 1、2 做到位。
动态库(.so)顺序问题较小,因为动态库的符号解析被推迟到运行期由动态链接器做(GOT 那套),链接期的顺序敏感性远低于静态库。这也算静/动态库差异的又一注脚。
第十道思考题:-l 顺序综合判断
问:编译命令 gcc main.o -lA -lB -o prog,已知 libA 里有的函数被 main.o 和 libB 同时需要,libB 需要的符号有一部分在 libA 里。这样写能不能链接过?若需要改成什么样?
答:这个顺序极可能链接失败,原因层层递进:
main.o先被处理,把 "需要 A 中某些符号、可能也需要 B" 记入待解析清单;- 轮到
-lA:它能解决「当前清单里来自 A 的符号」——如果当时 B 还没被处理,B 需要 A 的那部分符号此刻还不存在于清单里,所以 A 里"专为满足 B 而存在"的成员不会被吸取; - 轮到
-lB:解决 main 需要的 B 符号,但也暴露出 B 需要 A 的那批符号,此刻它们已无处可寻——《libA 已被扫过》; - 结果:
undefined reference to (A 中那部分 B 的资源)。
改动方案,二选一:
- 按"需求链从右到左"摆放:
gcc main.o -lB -lA -o prog——让libA(最基础、被依赖方)靠右,libB、main.o往前。前提是libB需要的 A 符号在 A 里,且 A 不再回头依赖 B; - 如果 A、B 互相依赖成环:用
-lA -lB -lA重复,或更稳妥地-Wl,--start-group -lA -lB -Wl,--end-group,让链接器在这组库里反复多遍吸取,直到没有新需求产生。
一句话记忆:静态库链接是"单向扫描",谁满足谁就站右边;互相依赖的就把整组反复扫。
总结:从库到链接的完整地图
我们一路走来,其实把一棵大树的根、干、枝、叶都摸了一遍。最后用几句话把整张图钉在你脑子里:
库 = 头文件(接口)+ .a/.so(实现)。头文件管"能不能编译",库管"能不能链接、能不能跑"。静态库 .a 把代码在链接期"长进"可执行文件,各自带一份、独立部署,代价是又大又占地方;动态库 .so 把代码在运行期由动态链接器"映射"进进程,多进程共享一份机器码、省资源、可热升级,代价是运行期必须找得到它——于是有了 LD_LIBRARY_PATH、ld.so.cache、ldconfig 那一整套运行期查找体系。
编译把 .c 变成带"地址坑"的 .o(relocatable,UND 符号满天飞);链接把多个 .o(及静态库里的成员)的 section 合并、按重定位表把坑填成真地址——这就是静态链接。而动态链接把麻烦推迟到运行期:可执行文件里只留一个"函数地址表"(GOT),真正地址由动态链接器加载时填入;为了不改只读代码,用"相对地址 + 查表"的 PIC 和延迟绑定的 PLT 打通了一切。
库设计的三大模式——抽象层(接口/实现分离)、单例(进程内唯一状态)、注册表(模块自动上报)——则是把这些底层机制升华为工程组织的钥匙。最后再记住互相纠缠的三组工程纪律:动态库顺序不敏感、静态库顺序敏感(被依赖者靠右);跨语言混编靠 extern "C" 拉平符号名;跨库 RTTI/异常要求 ABI 一致。
看到这里,你已经从"被 undefined reference 逼疯的小白",变成了"能解释每条链接报错、能亲手造库、能设计方案"的人。剩下的,就是把上面的命令一遍遍敲透——代码多碰几次、坑多踩几个,这张地图就成了你自己的本能。
还没有评论 — 第一条由你来留。