库的制作和使用

1. 基础概念:什么是库?

在嵌入式开发中,库(Library) 本质上是已编译好的二进制目标文件(*.o)的集合。

它将可复用的代码模块打包,方便其他程序链接和使用。

库分为两种:静态库和动态库。

  • 编译过程回顾:一个 .c 文件经过 预处理 -> 编译 -> 汇编 最终生成 .o 文件。

    链接 步骤就是将一个或多个 .o 文件(以及它们所依赖的库)捆绑成一个最终的可执行文件。

  • 静态库 (.a)

    • 原理:在链接阶段,将库中被调用到的代码完整地复制到最终的可执行文件中。
    • 优点:程序不依赖外部库文件即可运行,执行效率只高一点点。
    • 缺点:生成的可执行文件体积较大,库升级时需要重新编译整个程序。
  • 动态库 (.so)

    • 原理:在链接阶段,只记录所需函数的位置信息。程序运行时,才动态地加载库代码到内存中。
    • 优点:节省磁盘和内存空间,多个程序可共享同一份库。库升级时,只需替换库文件,程序无需重新编译即可获益。
    • 缺点:程序运行依赖外部库文件,存在潜在的运行时链接问题。

命名规范:库文件名通常遵循 lib<库名>.后缀 的格式。例如 libmath.alibcustom.so。链接时使用 -l<库名>,如 -lmath-lcustom


2. 静态库的制作与使用

2.1 制作静态库

假设有功能模块 a.cb.c

  1. 生成目标文件 (.o)

    1
    2
    3
    # -c 选项表示只编译不链接
    gcc a.c -o a.o -c
    gcc b.c -o b.o -c
  2. 打包成静态库 (.a)archive
    使用 ar (archiver) 命令。

    1
    2
    # c: 创建库 r: 替换(或添加)文件 s: 创建索引
    ar crs libx.a a.o b.o

2.2 使用静态库

假设主程序 main.c 调用了 libx.a 中的函数。

  1. 编译链接
    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 制作动态库

  1. 生成位置无关的目标文件 (.o)
    动态库的代码必须是位置无关代码 (PIC),因为它在内存中的加载地址是不固定的

    不能包含主函数main,不可能对一个主函数引用另外一个有主函数的库

    1
    2
    3
    # -fPIC: 生成位置无关代码
    gcc a.c -o a.o -c -fPIC
    gcc b.c -o b.o -c -fPIC
  2. 打包成动态库 (.so) share object

    1
    2
    # -shared: 生成共享库(动态库)
    gcc -shared -fPIC -o libx.so a.o b.o

3.2 使用动态库与运行时链接

  1. 编译链接
    命令与静态库完全相同。

    1
    gcc main.c -o main -L. -lx
  2. 解决运行时找不到库的问题
    直接运行 ./main 会报错,因为操作系统不知道去哪里找 libx.so。有多种种解决方法:

    • 方法一:编译时指定(推荐)
      使用 -rpath 选项将库路径硬编码到可执行文件中。

      1
      2
      3
      # -Wl, 表示将后续参数传递给链接器(ld)
      # 这里指定rpath为程序所在的相对路径
      gcc main.c -o main -L. -lx -Wl,-rpath=.
    • 方法二:设置环境变量
      临时将库路径添加到 LD_LIBRARY_PATH 环境变量中。

      1
      2
      export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.
      ./main

​ 其他方法不推荐


4. 动态库的升级与版本管理

动态库的核心优势在于升级维护。一个标准的动态库版本管理流程如下:

  1. 版本号命名lib<库名>.so.<主版本号>.<次版本号>.<修订号>,例如 liba.so.1.0.0

    • 主版本号:发生不向前兼容的重大变更时修改。
    • 次版本号:增加新功能,但保持向前兼容。
    • 修订版本号:修复Bug。
  2. SONAME: 这是库的一个别名,格式为 lib<库名>.so.<主版本号>。程序在运行时是根据 SONAME 来查找库的。

  3. 标准制作流程

    • (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
      2
      ln -s liba.so.1 liba.so
      # 执行后会生成: liba.so -> liba.so.1
    • (4) 编译主程序

      1
      gcc main.c -o main -L. -la
  4. 无缝升级
    当库升级到 liba.so.1.0.1 时,只需替换旧文件,然后重新执行 ldconfig -n . 更新 liba.so.1 的指向即可。main 程序无需重新编译,下次运行时就会自动加载新版库。


5. 动态库的动态加载 (实现插件化与条件加载)

除了在编译时链接,我们还可以在程序运行时,根据需要手动加载和卸载动态库。这种技术是实现高级软件架构的基石,它允许程序在不重新启动的情况下扩展功能,或根据特定条件选择不同的代码路径。

这主要应用于两大场景:

  1. 插件化架构:主程序提供一个框架,具体功能由第三方或后续开发的“插件”(即动态库)提供。
  2. 条件加载:主程序根据运行时的数据、用户输入或环境来决定加载哪个功能模块。

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
2
# 将libbase.so与主程序一起编译,并链接-ldl
gcc main.c -o main -L . -lbase -ldl

设计原则:尽量让插件(被动态加载的库)自包含,不依赖或少依赖其他非系统标准库。这能从根本上避免此类问题,让插件更加独立和健壮


5.3 应用场景一:基于配置的插件系统 (系统扩展)

需求:开发一个检测系统,它能根据配置文件加载不同的检测模块,而无需重新编译主程序。

  1. 约定接口:所有检测插件都必须实现一个同名函数,如 void detection(void);

  2. 制作插件库

    1
    2
    3
    4
    5
    6
    7
    // color.c
    #include <stdio.h>
    void detection() { printf("正在检测颜色是否均匀...\n"); }

    // shape.c
    #include <stdio.h>
    void detection() { printf("正在检测外观是否破损...\n"); }
    1
    2
    gcc color.c -fPIC -shared -o libcolor.so
    gcc shape.c -fPIC -shared -o libshape.so
  3. 编写配置文件 config,指定要加载的插件:

    1
    libcolor.so
  4. 主程序实现动态加载

    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
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <dlfcn.h>

    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. 约定接口:所有图片解码库都必须提供一个统一的显示函数

    1
    2
    3
    4
    5
    // 在一个公共头文件(如 display.h)中定义
    #include <stdint.h>
    #include "./src/lcd.h" // 假设lcd.h定义了LCD_info和显示模式

    void display(const char *pathname, const struct LCD_info *plcd, uint8_t mode);
  2. 制作不同格式的解码库

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // libjpg.c: 负责解码和显示JPG图片
    #include <stdio.h>
    #include "display.h"
    void display(const char *pathname, const struct LCD_info *plcd, uint8_t mode) {
    printf("使用JPG解码库显示图片: %s\n", pathname);
    // ... 此处是真实的JPG解码和LCD显示代码 ...
    }

    // libbmp.c: 负责解码和显示BMP图片
    #include <stdio.h>
    #include "display.h"
    void display(const char *pathname, const struct LCD_info *plcd, uint8_t mode) {
    printf("使用BMP解码库显示图片: %s\n", pathname);
    // ... 此处是真实的BMP解码和LCD显示代码 ...
    }
    1
    2
    gcc libjpg.c -fPIC -shared -o libjpg.so
    gcc libbmp.c -fPIC -shared -o libbmp.so
  3. 主程序根据输入条件动态加载

    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
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <dlfcn.h>
    #include "display.h" // 包含统一接口和所需结构体定义

    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 并更新主程序的判断逻辑即可,无需改动现有代码,极大地增强了程序的灵活性和可扩展性