从源文件到可执行程序,大致会经过预处理、编译、汇编、链接四个阶段。

如果有一组源文件需要反复在多个项目里使用,可以把它们的目标文件(.o)打包成一个库,这样之后链接的时候直接拿这个库就行,不用每次都重新编译那些 .c。所谓的库,本质就是一堆 .o 文件的集合,相当于可执行程序的'半成品'。

要理解库的基本操作,先来看一个简单的例子。main.c 里自己写了一个加法函数,但一般我们更关心 printf 这种 C 标准库提供的功能。用 ldd 看一下生成的可执行文件,能看到它依赖了 libc.so.6,这就是动态版的 C 标准库。
// main.c
#include <stdio.h>
#include <string.h>
int MyAdd(int x, int y) {
return x + y;
}
int MySub(int x, int y) {
return x - y;
}
int main() {
int a = 10;
int b = 20;
printf("%d + %d = %d\n", a, b, MyAdd(a, b));
return 0;
}


在 Linux 下,以 .so 结尾的是动态库,以 .a 结尾的是静态库;Windows 里则是 .dll 和 .lib。去掉前缀 lib、后缀 .so/.a 以及可能跟着的版本号,剩下的就是库的名字,比如 libc.so.6 的名字是 c。
静态库
演示用三个文件:mymath.h、mymath.c、main.c。mymath.h 声明两个函数,mymath.c 提供实现。
// mymath.h
#pragma once
int MyAdd(int, int);
int MySub(int, int);
// mymath.c
int MyAdd(int x, int y) {
return x + y;
}
int MySub(int x, int y) {
return x - y;
}
// main.c
#include <stdio.h>
#include <mymath.h>
int main() {
int a = 10;
int b = 20;
printf("%d + %d = %d\n", a, b, MyAdd(a, b));
return 0;
}
打包
先编译出 mymath.o,然后把 .o 用 ar 归档成静态库 libmy.a。-c 表示创建归档,-r 表示如果库内已有同名文件就替换。


用 ar -tv 可以看到库里面有哪些目标文件。


为了方便分发,通常会把头文件放到一个 include 目录,库文件放到 lib 目录,整体打包成 mylib 目录。

使用
编译 main.c 时需要带上三个选项:-I 指定头文件路径,-L 指定库文件路径,-l 指定库名字(去掉 lib 前缀和 .a 后缀)。
gcc main.c -I ./mylib/include/ -L ./mylib/lib/ -lmy



另一种偷懒的做法是把头文件和库文件直接丢到系统路径下,比如 /usr/include 和 /lib64。不过这种方式可能污染系统,通常不建议。如果非要这么干,即使文件在系统路径里了,链接时依然要手动指定 -lmy,因为 gcc 默认不会链接第三方的静态库。
sudo cp mylib/include/*.h /usr/include
sudo cp mylib/lib/*.a /lib64


动态库
动态库的打包方式和静态库有点区别,但文件组织方式类似。我们继续用 mymath.h、mymath.c、main.c 做演示。
打包
第一步,编译源文件时要加 -fPIC 选项,产生位置无关代码。这样生成的 .so 才能被多个进程在内存中共享。如果不用 -fPIC,加载时会需要对代码段做重定位,多个进程就会各自维护一份不同的代码副本,失去了共享的意义。
gcc -c -fPIC mymath.c -o mymath.o

第二步,用 gcc -shared 将目标文件打包成动态库 libmyc.so。
gcc -shared mymath.o -o libmyc.so

最后把头文件和 .so 组织到 mylib 目录下,和静态库的目录结构一样。

使用
编译阶段和静态库完全一样,-I、-L、-l 照用。
gcc main.c -I mylib/include/ -L mylib/lib/ -lmyc

但是,编译成功了,一运行可能报错,提示找不到 libmyc.so。这是因为编译时指定的路径只是告诉链接器上哪儿找符号,运行的时候动态链接器要从别的地方找 .so 文件。用 ldd 可以看到可执行文件缺了什么库。

解决这个问题的办法有好几种,按场景选就行:
- 把 .so 复制到系统默认的共享库路径,比如
/lib/x86_64-linux-gnu/。
sudo cp mylib/lib/libmyc.so /lib/x86_64-linux-gnu/


- 在系统共享库路径下建一个软链接。
sudo ln -s /path/to/libmyc.so /lib/x86_64-linux-gnu/libmyc.so

- 临时设置环境变量
LD_LIBRARY_PATH,告诉动态链接器上哪里找。
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/lib
dir

- 永久生效的话,可以把 export 那行写进
~/.bashrc。
echo 'export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/libdir' >> ~/.bashrc
source ~/.bashrc

- 更规范的做法是在
/etc/ld.so.conf.d/下新建一个.conf文件,写上库所在的路径,然后执行ldconfig刷新缓存。
sudo sh -c 'echo "/path/to/libdir" > /etc/ld.so.conf.d/mylib.conf'
sudo ldconfig

静态库和动态库的取舍
使用静态库,链接的时候会把库里的代码直接拷贝到可执行文件里,所以生成的文件体积大,但好处是运行时不再依赖外部的库文件,部署简单。多个程序同时运行、且都用了同一个静态库,内存里会有重复的代码。
动态库则是运行时才加载,多个进程可以共享同一份物理内存中的库,节省磁盘和内存。代价是部署时要保证目标机器上有对应的 .so 文件,否则程序跑不起来。

动态库加载的背后
动态库(共享库)在磁盘上就是一个 .so 文件。操作系统会管理所有已经加载的库,原理还是先描述、再组织。
程序启动时,动态链接器先检查依赖,然后去预设的搜索路径里找对应的 .so。如果是第一次被加载,就映射到进程地址空间的共享区;如果其他进程已经加载过同一份库,会直接复用,只需修改新进程的映射关系。

可执行程序在编译后就已经有了虚拟地址,代码里的符号会被编址。编址分为绝对编址和相对编址,动态库一般用相对编址,也就是位置无关代码(PIC)。最终运行的时候,通过'库在地址空间的起始地址 + 偏移量'来访问库里的函数或数据。
整个过程需要 CPU、编译器和操作系统三方协作:编译器生成位置无关的代码和段信息,操作系统负责分配地址空间、重定位和页表管理,CPU 利用 MMU 完成虚拟地址到物理地址的转换。

库被加载之后,进程只需要在地址空间的共享区看到它的映射,调用库函数时,就是拿着这个区域的基地址加上函数在库内的偏移量去找到具体的代码,这一点和调用自己代码没太大区别。



