在现代软件开发中,库(Library)是代码复用的核心。与静态库不同,动态库(也称共享库)在程序运行时才被加载和链接,这带来了诸多优势,例如节省磁盘和内存空间、便于更新模块等。本文将带你一步步实践在Linux环境下,如何使用GCC编译器从源代码制作动态库,并详细讲解两种使用动态库的方法:静态链接时加载和运行时动态加载。
从源码到动态库:GCC编译步骤
假设我们有四个源文件:file1.c、file1.h、file2.c、file2.h,我们的目标是将它们制作成一个名为 libmylib.so 的动态库。
整个过程主要分为两步:
- 编译生成位置无关的目标文件:使用
-fPIC 选项。
- 链接目标文件创建共享库:使用
-shared 选项。
详细操作过程
首先,查看当前目录下的文件,然后进行编译:
weimingze@mzstudio:~$ ls
file1.c file1.h file2.c file2.h
weimingze@mzstudio:~$ gcc -fPIC -c file1.c file2.c
weimingze@mzstudio:~$ ls
file1.c file1.h file1.o file2.c file2.h file2.o
执行上述命令后,我们得到了两个目标文件 file1.o 和 file2.o。接下来,使用这两个目标文件创建动态库:
weimingze@mzstudio:~$ gcc -shared -o libmylib.so file1.o file2.o
weimingze@mzstudio:~$ ls
file1.c file1.h file1.o file2.c file2.h file2.o libmylib.so
至此,动态库 libmylib.so 已经成功生成。核心命令可以简化为一行:
gcc -shared -o libmylib.so file1.o file2.o
GCC 关键选项说明
-fPIC:代表“生成位置无关代码”。这是因为动态库在运行时被加载到内存的地址是不固定的,库内的所有函数和全局变量都必须通过相对地址来访问,此选项确保了这一点。
-shared:告诉链接器创建一个共享库(即动态库),而不是最终的可执行文件。
动态库文件制作完成后,你需要将 libmylib.so 以及对应的头文件(file1.h, file2.h)提供给使用者,他们才能进行后续的开发和编译工作。
如何使用动态库?
主程序使用动态库主要有两种方式,它们决定了库在何时被加载到内存中。
- 静态链接时加载:程序启动时,由系统自动加载所需的动态库。
- 运行时动态加载:程序在运行过程中,根据需要手动调用API(如
dlopen)来加载库。
下面我们分别探讨这两种方法。
方法一:动态库的静态链接时加载
这种方式最像使用静态库,但在编译链接阶段并不将库代码复制进去,而是记录下依赖关系。假设库文件和头文件位于 mylib2 目录下,目录结构如下:
.
├── main.c
└── mylib2
├── file1.h
├── file2.h
└── libmylib.so
编译和链接主程序 main.c 的过程如下:
weimingze@mzstudio:~$ gcc -c main.c -I mylib2
weimingze@mzstudio:~$ gcc -o myapp main.o -L mylib2 -l mylib
命令说明:
-I mylib2:指定头文件的搜索路径。
-L mylib2:指定库文件的搜索路径。
-l mylib:链接名为 libmylib.so 的库(链接器会自动添加 lib 前缀和 .so 后缀)。
编译链接成功后,尝试运行程序:
weimingze@mzstudio:~$ ./myapp
./myapp: error while loading shared libraries: libmylib.so: cannot open shared object file: No such file or directory
运行出错了!这是因为在程序运行时,系统找不到 libmylib.so 这个库文件。编译时指定的 -L 路径只在链接阶段有效。我们需要通过环境变量 LD_LIBRARY_PATH 来告诉系统运行时去哪里寻找动态库:
weimingze@mzstudio:~$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./mylib2
weimingze@mzstudio:~$ ./myapp
库函数 myfunc1 被调用
库函数 myfunc2 被调用
设置好库路径后,程序就能正常运行了。这正是动态库“运行时依赖”特性的体现。如果你对GCC编译器的更多选项和原理感兴趣,可以深入探索相关的编译与链接知识。
方法二:动态库的运行时动态加载
这种方式赋予了程序更大的灵活性,可以在需要时才加载库,用完即可卸载。它依赖于一组 dl 系列的API函数。
核心 API 函数
| 函数 |
说明 |
void *dlopen(const char *filename, int flags); |
加载指定路径的动态库,返回操作句柄。成功返回非空句柄,失败返回 NULL。 |
int dlclose(void *handle); |
减少库的引用计数,计数为0时真正卸载库。成功返回0,失败返回非零。 |
void *dlsym(void *handle, const char *symbol); |
根据符号名(如函数名)查找地址并返回。失败返回 NULL。 |
char *dlerror(void); |
获取最近一次 dl 系列函数调用的错误信息。 |
示例:修改 main.c 实现动态加载
// filename: main.c
#include <stdio.h>
#include <dlfcn.h>
int main() {
void *handle; // 保存动态库的打开句柄
void (*fn1)(void); // 用于指向动态库内的函数 myfunc1
void (*fn2)(void); // 用于指向动态库内的函数 myfunc2
// 1. 打开动态库
handle = dlopen("./mylib2/libmylib.so", RTLD_LAZY);
if (NULL == handle) {
fprintf(stderr, "%s\n", dlerror());
return 1;
}
// 2. 获取函数地址
fn1 = dlsym(handle, "myfunc1");
if (NULL == fn1) {
printf("动态库内没有找到 myfunc1函数");
goto exit_main;
}
fn2 = dlsym(handle, "myfunc2");
if (NULL == fn2) {
printf("动态库内没有找到 myfunc2函数");
goto exit_main;
}
// 3. 使用函数指针调用动态库中的函数
fn1();
fn2();
exit_main:
// 4. 关闭动态库
dlclose(handle);
return 0;
}
此时,目录结构变得更简单,主程序甚至不需要库的头文件:
.
├── main.c
└── mylib2
└── libmylib.so
编译和运行。注意,因为使用了 dlopen 等函数,编译时需要链接 libdl 库(使用 -ldl 选项):
weimingze@mzstudio:~$ gcc -o myapp main.c -ldl
weimingze@mzstudio:~$ ./myapp
库函数 myfunc1 被调用
库函数 myfunc2 被调用
可以看到,使用动态加载方式时,编译命令不再需要 -I 和 -L 选项来指定头文件和库路径,对库的依赖完全在代码中通过 dlopen 的路径参数来管理。这种方式常见于插件系统、模块热更新等场景,是系统编程中一项高级且强大的技术。
静态库 vs. 动态库:核心对比
为了帮助你更好地理解两者区别,以下是它们的特性对比:
| 特性 |
静态库 |
动态库 |
| 链接时机 |
编译时 |
运行时 |
| 文件大小 |
可执行文件较大(库代码被复制进去) |
可执行文件较小(仅记录引用) |
| 内存占用 |
每个程序独立占用库代码内存 |
多个程序可共享同一份库代码内存 |
| 更新 |
需重新编译整个程序 |
只需替换库文件,程序下次运行时生效 |
| 依赖 |
无运行时依赖,部署简单 |
需要库文件存在于目标系统 |
动态库因其在资源利用和模块化方面的优势,已成为现代软件开发,特别是大型系统中的更常见选择。理解静态库和动态库在编译与链接阶段的差异,是掌握程序构建过程的重要一环。
动手实验
建议你按照本文的步骤,在自己的Linux开发环境中尝试制作一个简单的动态库,并分别用两种方式去使用它。实践中遇到的环境问题(如路径设置)和编译错误,是加深理解的最好途径。
希望这篇实战指南能帮助你掌握Linux动态库的制作与使用。如果你在实践过程中有任何心得或疑问,欢迎在云栈社区与其他开发者交流讨论。