前面三十五篇文章,我们写了很多函数模块——排序、链表、文件工具。但每次在新项目里想用它们,都得把 .c 文件复制来复制去,或者手动添加到编译命令里。当代码积累到几十个模块、被多个项目共用时,这种方式就太原始了。
成熟的软件工程会把常用的代码打包成库(Library)。库就是预先编译好的目标代码集合,你的程序可以直接链接它,不用每次都重新编译那些源文件。C 语言的库分为两种:静态库(.a / .lib)和动态库(.so / .dylib / .dll)。今天我们就来学会怎么制作它们、使用它们,以及动态库独有的运行时加载技术。
一、库是什么?为什么需要它?
库的本质就是可复用的编译后代码包。使用库的好处:
- 不用重复编译:常用的功能(数学计算、加密、压缩)预先编译好,链接时直接拿来用。
- 隐藏实现:你可以只提供库文件和头文件,别人看不到源码。
- 便于分发和更新:特别是动态库,可以独立更新而不需要重新编译整个程序。
C 标准库本身就是最典型的例子——你每天都在用 libc.a(静态)或 libc.so(动态),却从没编译过 printf 的源码。
二、静态库:.a 文件(Linux/macOS)或 .lib(Windows)
静态库在链接阶段会被完整复制到最终的可执行文件中。程序运行时不再需要库文件。
1. 制作静态库
假设我们有两个工具函数模块:
math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
math_utils.c
#include "math_utils.h"
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
string_utils.h
#ifndef STRING_UTILS_H
#define STRING_UTILS_H
int str_length(const char *s);
void str_toupper(char *s);
#endif
string_utils.c
#include "string_utils.h"
#include <ctype.h>
int str_length(const char *s) {
int len = 0;
while (*s++) len++;
return len;
}
void str_toupper(char *s) {
while (*s) {
*s = toupper((unsigned char)*s);
s++;
}
}
步骤一:把每个 .c 编译成目标文件 .o
gcc -c math_utils.c -o math_utils.o
gcc -c string_utils.c -o string_utils.o
-c 表示只编译、汇编,不链接。
步骤二:用 ar 工具打包成静态库
ar rcs libutils.a math_utils.o string_utils.o
r:插入目标文件到归档(若已存在则替换)。c:创建归档文件(如果不存在)。s:生成索引,供链接器快速查找符号。
静态库的命名惯例是 lib 开头,后缀 .a(archive)。库名叫 utils。
Windows:
rcs libutils.lib math_utils.o string_utils.o
2. 使用静态库
写一个主程序:
main.c
#include <stdio.h>
#include "math_utils.h"
#include "string_utils.h"
int main(void) {
printf("10 + 5 = %d\n", add(10, 5));
printf("6 * 7 = %d\n", multiply(6, 7));
char text[] = "hello, world";
printf("长度: %d\n", str_length(text));
str_toupper(text);
printf("大写: %s\n", text);
return 0;
}
编译并链接静态库:
gcc main.c -L. -lutils -o app
-L.:告诉链接器在当前目录搜索库文件。-lutils:链接名为libutils.a的库(lib和.a省略,只写中间的名字)。
也可以直接把库文件全名当普通目标文件传入:
gcc main.c libutils.a -o app
运行:
./app
app 是一个完全独立的可执行文件,不需要 libutils.a 就能运行。因为它的代码已经被完整复制进去了。
Windows环境:
# 编译成目标文件
gcc -c math_utils.c -o math_utils.o
gcc -c string_utils.c -o string_utils.o# 用 ar 工具打包成静态库
ar rcs libutils.lib math_utils.o string_utils.o# 编译并链接静态库
gcc main.c libutils.lib -o app.exe# 运行
./app.exe
三、动态库:.so(Linux)、.dylib(macOS)、.dll(Windows)
动态库在运行时才加载,可执行文件里只记录“我需要哪个库的哪个符号”。优点:
- 节省磁盘和内存:多个程序可以共享同一个动态库,内存中只加载一份。
- 更新方便:修了库的 bug,替换
.so文件就行,不需要重新编译程序(只要接口不变)。 - 插件机制:可以在程序运行时按需加载库。
代价:程序启动时需要动态链接器解析符号,有微小开销;库文件丢失则程序无法运行。
1. 制作动态库
用同样的源文件:
gcc -c -fPIC math_utils.c -o math_utils.o
gcc -c -fPIC string_utils.c -o string_utils.o
-fPIC(Position Independent Code,位置无关代码)是必须的。动态库会被加载到内存的任意地址,代码必须能够在该地址正确运行,不能依赖绝对地址。
然后把 .o 链接成动态库:
gcc -shared -o libutils.so math_utils.o string_utils.o
-shared:生成共享库。- 输出文件名
libutils.so(Linux)。macOS 用.dylib,Windows 用.dll(编译命令不同,见文末附录)。
也可以一步到位:
gcc -shared -fPIC math_utils.c string_utils.c -o libutils.so
2. 使用动态库
gcc main.c -L. -lutils -o app
链接命令和静态库完全一样。链接器发现 libutils.so 存在时,会优先使用动态库(除非用 -static 强制静态)。
但运行时,系统需要能找到 libutils.so。默认只搜索系统目录(/lib、/usr/lib 等)。临时解决方案:
LD_LIBRARY_PATH=. ./app # Linux
DYLD_LIBRARY_PATH=. ./app # macOS
永久方案是将库安装到标准路径,或在编译时把搜索路径写进可执行文件:
gcc main.c -L. -lutils -Wl,-rpath,'$ORIGIN' -o app
-Wl,-rpath,'$ORIGIN' 告诉链接器:在可执行文件里写入一个额外的动态库搜索路径——$ORIGIN 指可执行文件所在目录。这样只要 .so 和 app 在同一文件夹,就能找到。
3. 验证依赖
ldd(Linux)或 otool -L(macOS)可以查看可执行文件依赖的动态库:
ldd ./app
# 输出中会看到 libutils.so => ...
四、动态加载:在程序运行时才加载库
上面的动态库是程序启动时自动加载的。还有一种更灵活的方式:在程序运行到一半时手动加载库,获取函数地址,调用它。这就是动态加载(Dynamic Loading),是实现插件系统的基础。
需要 #include <dlfcn.h>,并在链接时加 -ldl。
核心函数
void *dlopen(const char *filename, int flag); // 打开动态库,返回句柄
void *dlsym(void *handle, const char *symbol); // 查找符号(函数或变量)地址
int dlclose(void *handle); // 关闭句柄
char *dlerror(void); // 返回最近的错误描述
示例:运行时加载 libutils.so 并调用函数
#include <stdio.h>
#include <dlfcn.h>
int main(void) {
// 1. 打开动态库
void *handle = dlopen("./libutils.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "dlopen 失败: %s\n", dlerror());
return 1;
}
// 2. 查找函数符号
int (*add_func)(int, int) = (int (*)(int, int))dlsym(handle, "add");
if (!add_func) {
fprintf(stderr, "找不到 add 函数: %s\n", dlerror());
dlclose(handle);
return 1;
}
// 3. 调用
printf("通过动态加载调用 add(10, 20) = %d\n", add_func(10, 20));
// 4. 关闭句柄
dlclose(handle);
return 0;
}
编译这个程序需要链接 libdl:
gcc dynamic_main.c -ldl -o dynamic_app
./dynamic_app
dlsym 返回的是 void*,必须强制转换成与函数签名匹配的函数指针。这就是函数指针(第十八篇)的最强应用场景之一——连函数本身都是在运行时才确定的。
何时使用动态加载?
- 插件系统:程序启动时扫描
plugins/目录下的.so文件,加载并注册。 - 可选功能:某些功能依赖大型库,用户不一定需要,按需加载减少启动开销。
- 热更新:长期运行的服务,不重启替换某个模块的实现。
五、静态库 vs 动态库:选择指南
| 静态库 | 动态库 | |
|---|---|---|
| 链接方式 | 编译时复制进可执行文件 | 运行时加载 |
| 文件体积 | 可执行文件变大 | 可执行文件小 |
| 内存占用 | 每个程序独立一份 | 多程序共享一份 |
| 更新 | 需重新编译程序 | 替换库文件即可 |
| 部署 | 简单(单个文件) | 需附带库或确保系统有 |
| 启动速度 | 稍快(无动态解析) | 稍慢(需加载和符号解析) |
| 适用场景 | 嵌入式、单文件分发 | 桌面应用、服务端、插件 |
对于个人练习和小工具,静态链接省事。对于大型系统、需要支持插件机制、或者库会被多个程序共享的场景,动态库是更好的选择。
六、附录:各平台命令对照
| 操作 | Linux | macOS | Windows (MinGW) |
|---|---|---|---|
| 编译目标文件 | gcc -c file.c | 同左 | 同左 |
| 创建静态库 | ar rcs libx.a file.o | 同左 | ar rcs libx.lib file.o |
| 创建动态库 | gcc -shared -fPIC -o libx.so file.c | gcc -shared -fPIC -o libx.dylib file.c | gcc -shared -o x.dll file.c -Wl,--out-implib,libx.a |
| 查看依赖 | ldd ./app | otool -L ./app | `objdump -p app.exe |
| 运行时库搜索路径 | LD_LIBRARY_PATH | DYLD_LIBRARY_PATH | PATH(或放在 exe 同目录) |
七、小结
今天你掌握了如何把代码打包成库,以及静态库和动态库的完整生命周期:
- 静态库(
.a):ar rcs制作,链接时完整复制,独立运行,适合小工具和嵌入式。 - 动态库(
.so/.dylib/.dll):gcc -shared -fPIC制作,运行时加载,节省内存,支持插件和独立更新。 - 动态加载(
dlopen/dlsym/dlclose):在程序运行时按需加载库,是插件系统的基石。 - 动态库的搜索路径可以通过
LD_LIBRARY_PATH或-rpath控制。
学会库的使用,你的 C 语言项目就可以从“一堆源文件”进化到“有组织的软件架构”。这也是理解大型 C/C++ 项目(如 Python 解释器、数据库引擎、游戏引擎)的编译和运行机制的关键基础。
至此,系统编程的“文件与存储”部分基本覆盖完毕——从标准 I/O 到系统调用,从文本到二进制,从静态链接到动态加载。下一篇,我们要进入一个全新的领域:并发编程。学会创建多线程,用互斥锁保护共享数据,让你的程序真正“一心多用”。
课后小练习
- 将之前实现的链表模块打包为静态库
liblinkedlist.a,编写一个主程序链接并使用它。 - 将同一链表模块编译为动态库
liblinkedlist.so,用同一个主程序链接动态库运行。用ldd(或对应工具)查看程序依赖。 - 写一个程序,使用
dlopen和dlsym动态加载libm.so(数学库),在运行时获取sqrt函数的地址并调用它计算平方根。 - (小挑战)设计一个简单的插件接口:规定每个插件必须实现
void plugin_init(void)和void plugin_run(void)。创建两个“插件”动态库(plugin_a.so、plugin_b.so),主程序在运行时依次加载plugins/目录下的所有.so文件并执行。体验插件系统的设计思路。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。

3498

被折叠的 条评论
为什么被折叠?



