很多学过 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)

看这三行,正好对应三层东西:

  1. linux-vdso.so.1:kernel 提供的虚拟动态共享对象,用来快速让用户程序调用某些内核功能(比如 gettimeofday),可以看到它没有 "=>" 指向的实体路径,因为它不存在于磁盘文件里,由内核直接映射;
  2. libc.so.6:C 运行时库,这里显示它实际解析到 /lib/x86_64-linux-gnu/libc.so.6,括号里是它被映射进的虚拟地址;
  3. /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 不在任何运行期搜索目录里。

动态链接器在运行期按什么顺序找库? 大致是这样的(从高优先到低优先):

  1. LD_LIBRARY_PATH 环境变量里写的一组路径;
  2. 缓存文件 /etc/ld.so.cache 里记录的路径(这个缓存由 ldconfig 命令生成);
  3. 默认系统库目录:/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                   retq

objdump -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)下四类二进制文件的统一格式:

  1. 可重定位文件(Relocatable file):即 xxx.o。包含适合与其他目标文件链接、以创建可执行文件或共享库的代码和数据;
  2. 可执行文件(Executable file):就是我们的可执行程序,比如 a.out;
  3. 共享目标文件(Shared object file):即 xxx.so 动态库;
  4. 核心转储(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——地址被修对了!于是我们总结出静态链接的两大成果:

  1. 多个 .o 的代码段被合并,并统一编址。
  2. 链接时按重定位表,把 .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 或链接器提供的特殊函数。它的启动流水大致是:

  1. 建立初始栈环境;
  2. 初始化数据段;
  3. 动态链接:调用动态链接器(如 ld-linux.so)解析并加载程序依赖的库,完成所有符号解析和重定位;
  4. 调用 __libc_start_main:做额外初始化(信号处理、线程库等);
  5. 调用 main:控制权终于交给用户代码;
  6. 处理 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表(可写、每进程一份) --跳-->  真正的库函数

关键推论:

  1. 代码段只读不可改,但通过 GOT 可以让代码被所有进程共享;GOT 各进程独立,所以各进程的库地址各自适配,互不干扰;
  2. 在同一个 .so 内部,GOT 与 .text 的相对位置固定,CPU 用相对寻址(%rip 相对)就能找到自己的 GOT;
  3. 调用函数先查 GOT 表,再从表项取出地址跳转;这些表项在库加载完成后被动态链接器填成真地址;
  4. 这种"相对寻址 + 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;
}

单例模式对应过来的本质是三条纪律:

  1. 状态收归库内:全部用 static,编译成"只有本 .c 可见的外部符号」,彻底隔绝外部误操作;
  2. 只提供一个访问入口:调用方没有别的路拿到那份状态,只能通过库接口;
  3. 并发安全:多线程下的"只初始化一次"必须用锁(或 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。

三条铁律:

  1. 被依赖者放后面:谁的符号解决了别人的需求,谁就尽量靠右。通用的表达是"库参数写在 .o 和更前面的库之后"。
  2. 互相依赖的库要成对给:如果 libA 依赖 libB、libB 又依赖 libA,正确的写法是 -lA -lB -lA(来回再扫一遍);
  3. 实在理不清就循环多遍/或直接把重复写: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 的资源)。

改动方案,二选一:

  1. 按"需求链从右到左"摆放:gcc main.o -lB -lA -o prog——让 libA(最基础、被依赖方)靠右,libB、main.o 往前。前提是 libB 需要的 A 符号在 A 里,且 A 不再回头依赖 B;
  2. 如果 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 逼疯的小白",变成了"能解释每条链接报错、能亲手造库、能设计方案"的人。剩下的,就是把上面的命令一遍遍敲透——代码多碰几次、坑多踩几个,这张地图就成了你自己的本能。