库的制作和使用
1. 基础概念:什么是库?
在嵌入式开发中,库(Library) 本质上是已编译好的二进制目标文件(*.o
)的集合。
它将可复用的代码模块打包,方便其他程序链接和使用。
库分为两种:静态库和动态库。
编译过程回顾:一个
.c
文件经过预处理 -> 编译 -> 汇编
最终生成.o
文件。链接 步骤就是将一个或多个
.o
文件(以及它们所依赖的库)捆绑成一个最终的可执行文件。静态库 (
.a
)- 原理:在链接阶段,将库中被调用到的代码完整地复制到最终的可执行文件中。
- 优点:程序不依赖外部库文件即可运行,执行效率只高一点点。
- 缺点:生成的可执行文件体积较大,库升级时需要重新编译整个程序。
动态库 (
.so
)- 原理:在链接阶段,只记录所需函数的位置信息。程序运行时,才动态地加载库代码到内存中。
- 优点:节省磁盘和内存空间,多个程序可共享同一份库。库升级时,只需替换库文件,程序无需重新编译即可获益。
- 缺点:程序运行依赖外部库文件,存在潜在的运行时链接问题。
命名规范:库文件名通常遵循
lib<库名>.后缀
的格式。例如libmath.a
或libcustom.so
。链接时使用-l<库名>
,如-lmath
、-lcustom
。
2. 静态库的制作与使用
2.1 制作静态库
假设有功能模块 a.c
和 b.c
。
生成目标文件 (
.o
):1
2
3# -c 选项表示只编译不链接
gcc a.c -o a.o -c
gcc b.c -o b.o -c打包成静态库 (
.a
)archive
:
使用ar
(archiver) 命令。1
2# c: 创建库 r: 替换(或添加)文件 s: 创建索引
ar crs libx.a a.o b.o
2.2 使用静态库
假设主程序 main.c
调用了 libx.a
中的函数。
- 编译链接:
1
2
3# -L<路径>: 指定库文件所在的目录
# -l<库名>: 指定要链接的库
gcc main.c -o main -L. -lx-L.
表示在当前目录查找库文件。-lx
表示链接libx.a
,链接时去掉lib
和.a
这种通用记号。
重点:库的依赖顺序
如果库
libb.a
依赖于liba.a
,那么在链接时,被依赖的库最好放在后面,不然链接器找不到定义
1
2
3
4
5 # 正确: -la 在 -lb 之后
gcc main.c -o main -L. -lb -la
# 错误: 链接器找不到 fa 的定义
gcc main.c -o main -L. -la -lb
3. 动态库的制作与应用
3.1 制作动态库
生成位置无关的目标文件 (
.o
):
动态库的代码必须是位置无关代码 (PIC),因为它在内存中的加载地址是不固定的且不能包含主函数
main
,不可能对一个主函数引用另外一个有主函数的库1
2
3# -fPIC: 生成位置无关代码
gcc a.c -o a.o -c -fPIC
gcc b.c -o b.o -c -fPIC打包成动态库 (
.so
)share object
:1
2# -shared: 生成共享库(动态库)
gcc -shared -fPIC -o libx.so a.o b.o
3.2 使用动态库与运行时链接
编译链接:
命令与静态库完全相同。1
gcc main.c -o main -L. -lx
解决运行时找不到库的问题:
直接运行./main
会报错,因为操作系统不知道去哪里找libx.so
。有多种种解决方法:方法一:编译时指定(推荐)
使用-rpath
选项将库路径硬编码到可执行文件中。1
2
3# -Wl, 表示将后续参数传递给链接器(ld)
# 这里指定rpath为程序所在的相对路径
gcc main.c -o main -L. -lx -Wl,-rpath=.方法二:设置环境变量
临时将库路径添加到LD_LIBRARY_PATH
环境变量中。1
2export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.
./main
其他方法不推荐
4. 动态库的升级与版本管理
动态库的核心优势在于升级维护。一个标准的动态库版本管理流程如下:
版本号命名:
lib<库名>.so.<主版本号>.<次版本号>.<修订号>
,例如liba.so.1.0.0
。- 主版本号:发生不向前兼容的重大变更时修改。
- 次版本号:增加新功能,但保持向前兼容。
- 修订版本号:修复Bug。
SONAME: 这是库的一个别名,格式为
lib<库名>.so.<主版本号>
。程序在运行时是根据SONAME
来查找库的。标准制作流程:
(1) 编译库并指定
SONAME
:1
2# -Wl,-soname,<你的SONAME>
gcc -shared -fPIC a.c -o liba.so.1.0.0 -Wl,-soname,liba.so.1(2) 创建
SONAME
软链接:ldconfig
命令可以自动扫描当前目录的库文件,并根据其SONAME
创建软链接。1
2
3# -n .: 在当前目录操作
ldconfig -n .
# 执行后会生成: liba.so.1 -> liba.so.1.0.0(3) 创建编译器链接名:
编译器在链接时查找的是不带版本号的名字,如liba.so
。1
2ln -s liba.so.1 liba.so
# 执行后会生成: liba.so -> liba.so.1(4) 编译主程序:
1
gcc main.c -o main -L. -la
无缝升级:
当库升级到liba.so.1.0.1
时,只需替换旧文件,然后重新执行ldconfig -n .
更新liba.so.1
的指向即可。main
程序无需重新编译,下次运行时就会自动加载新版库。
5. 动态库的动态加载 (实现插件化与条件加载)
除了在编译时链接,我们还可以在程序运行时,根据需要手动加载和卸载动态库。这种技术是实现高级软件架构的基石,它允许程序在不重新启动的情况下扩展功能,或根据特定条件选择不同的代码路径。
这主要应用于两大场景:
- 插件化架构:主程序提供一个框架,具体功能由第三方或后续开发的“插件”(即动态库)提供。
- 条件加载:主程序根据运行时的数据、用户输入或环境来决定加载哪个功能模块。
5.1 核心API
要使用动态加载功能,必须包含头文件 <dlfcn.h>
,并掌握以下几个核心函数:
void *dlopen(const char *filename, int flag);
- 功能:打开并加载一个动态库。
filename
:库文件的路径,如"./libcolor.so"
。若设为NULL
,则在当前进程的全局符号表中搜索。flag
:加载标志。常用RTLD_LAZY
(延迟解析,用到函数时才解析地址)或RTLD_NOW
(立即解析所有符号)。- 返回值:成功返回一个库句柄(handle),失败返回
NULL
。
void *dlsym(void *handle, const char *symbol);
- 功能:在已加载的库中查找符号(通常是函数名)的地址。
handle
:由dlopen
返回的库句柄。symbol
:要查找的符号名称字符串,如"detection"
。- 返回值:成功返回符号的地址,失败返回
NULL
。
char *dlerror(void);
- 功能:返回一个描述最后一次
dlopen
,dlsym
,dlclose
调用错误的字符串。每次调用后,错误状态会被清除。这是调试动态加载问题的首选工具。
- 功能:返回一个描述最后一次
int dlclose(void *handle);
- 功能:卸载一个动态库,减少其引用计数。当引用计数降为0时,库被从内存中移除。
5.2 重要提醒:处理插件的间接依赖
这是一个在使用 dlopen
时极易遇到的陷阱。
场景:假设你的主程序 main
动态加载一个插件 libplugin.so
,而这个插件本身又依赖于另一个基础库 libbase.so
main
— (dlopen) —> libplugin.so
— (依赖) —> libbase.so
问题:在编译主程序 main
时,编译器只知道 main
的直接依赖,它对 libplugin.so
的内部依赖一无所知。因此,即使不链接 -lbase
,编译也能通过。然而,当程序运行并成功 dlopen("libplugin.so")
后,一旦调用到 libplugin.so
中使用了 libbase.so
功能的函数时,动态链接器会因为找不到 libbase.so
中的符号而报错,导致程序崩溃。
解决方案:
将插件的间接依赖库与主程序一起链接。这样,在 dlopen
加载插件之前,所需的符号已经存在于主程序的全局符号表中。编译主程序时,不要忘记链接-ldl
1 | # 将libbase.so与主程序一起编译,并链接-ldl |
设计原则:尽量让插件(被动态加载的库)自包含,不依赖或少依赖其他非系统标准库。这能从根本上避免此类问题,让插件更加独立和健壮
5.3 应用场景一:基于配置的插件系统 (系统扩展)
需求:开发一个检测系统,它能根据配置文件加载不同的检测模块,而无需重新编译主程序。
约定接口:所有检测插件都必须实现一个同名函数,如
void detection(void);
制作插件库:
1
2
3
4
5
6
7// color.c
void detection() { printf("正在检测颜色是否均匀...\n"); }
// shape.c
void detection() { printf("正在检测外观是否破损...\n"); }1
2gcc color.c -fPIC -shared -o libcolor.so
gcc shape.c -fPIC -shared -o libshape.so编写配置文件
config
,指定要加载的插件:1
libcolor.so
主程序实现动态加载:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int main(void)
{
// 1. 从配置文件读取要加载的库名
FILE *fp = fopen("config", "r");
if (fp == NULL) {
perror("打开配置文件失败");
exit(1);
}
char lib_name[50];
fgets(lib_name, sizeof(lib_name), fp);
fclose(fp);
strtok(lib_name, "\n"); // 移除 fgets 读取到的末尾换行符
char path[60];
snprintf(path, sizeof(path), "./%s", lib_name);
// 2. 使用 dlopen 加载指定的库
void *handle = dlopen(path, RTLD_NOW);
if (handle == NULL) {
fprintf(stderr, "加载动态库 [%s] 失败: %s\n", path, dlerror());
exit(1);
}
// 3. 使用 dlsym 查找约定的函数接口
void (*detect_func)(void);
detect_func = dlsym(handle, "detection");
if (detect_func == NULL) {
fprintf(stderr, "查找符号 [detection] 失败: %s\n", dlerror());
dlclose(handle);
exit(1);
}
// 4. 调用函数
printf("开始执行检测...\n");
detect_func();
printf("检测完成。\n");
// 5. 关闭句柄,释放资源
dlclose(handle);
return 0;
}
通过修改 config
文件的内容(例如改成 libshape.so
),主程序就能在不重新编译的情况下调用不同的检测功能,实现了真正的插件化
5.4 应用场景二:基于输入的条件加载 (行为选择)
需求:开发一个图片查看器,能根据用户输入的图片文件后缀(如 .jpg
或 .bmp
),自动加载对应的解码库来显示图片
约定接口:所有图片解码库都必须提供一个统一的显示函数
1
2
3
4
5// 在一个公共头文件(如 display.h)中定义
void display(const char *pathname, const struct LCD_info *plcd, uint8_t mode);制作不同格式的解码库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// libjpg.c: 负责解码和显示JPG图片
void display(const char *pathname, const struct LCD_info *plcd, uint8_t mode) {
printf("使用JPG解码库显示图片: %s\n", pathname);
// ... 此处是真实的JPG解码和LCD显示代码 ...
}
// libbmp.c: 负责解码和显示BMP图片
void display(const char *pathname, const struct LCD_info *plcd, uint8_t mode) {
printf("使用BMP解码库显示图片: %s\n", pathname);
// ... 此处是真实的BMP解码和LCD显示代码 ...
}1
2gcc libjpg.c -fPIC -shared -o libjpg.so
gcc libbmp.c -fPIC -shared -o libbmp.so主程序根据输入条件动态加载:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
int main(int argc, char const *argv[]) {
if (argc < 2) {
printf("用法: %s <图片文件名>\n", argv[0]);
return -1;
}
void *handle = NULL;
const char *lib_path = NULL;
// 1. 根据输入的文件名后缀,决定加载哪个库
if (strstr(argv[1], ".jpg") != NULL) {
lib_path = "./libjpg.so";
} else if (strstr(argv[1], ".bmp") != NULL) {
lib_path = "./libbmp.so";
} else {
printf("错误: 不支持的文件格式,目前仅支持 .jpg 和 .bmp\n");
return -1;
}
// 2. 加载选定的动态库
handle = dlopen(lib_path, RTLD_NOW);
if (handle == NULL) {
fprintf(stderr, "打开动态库 [%s] 失败: %s\n", lib_path, dlerror());
return -1;
}
printf("成功加载库: %s\n", lib_path);
// 3. 查找统一的显示函数 `display`
void (*display_func)(const char *, const struct LCD_info *, uint8_t);
display_func = dlsym(handle, "display");
if (display_func == NULL) {
fprintf(stderr, "从动态库获取函数 [display] 失败: %s\n", dlerror());
dlclose(handle);
return -1;
}
// 4. 准备环境并调用函数
struct LCD_info lcdinfo;
init_LCD(&lcdinfo); // 假设这是初始化LCD屏幕的函数
printf("调用显示函数...\n");
// 调用从动态库中获取的函数
display_func(argv[1], &lcdinfo, CENTER | ZOOM); // 假设CENTER和ZOOM是定义好的模式
relese_LCD(&lcdinfo); // 假设这是释放LCD资源的函数
dlclose(handle); // 关闭库句柄
return 0;
}这样,程序就能根据用户输入的文件名,在运行时才决定加载哪个解码库
未来如果需要支持
.png
格式,只需新增一个libpng.so
并更新主程序的判断逻辑即可,无需改动现有代码,极大地增强了程序的灵活性和可扩展性