在嵌入式开发中我们会将相关代码封装成库,核心目的是:复用、解耦、保密、简化维护、加快编译、稳定可靠。库本质是把通用、稳定、独立的代码编译成二进制/静态文件,给多个项目直接调用,不用重复写源码,如相关驱动外设、通信协议栈、算法模块、安全/保密代码及中间件组件等,在跨平台或跨产品业务中将代码封装成库做的好处包括:
- 代码复用:一次编写,N 个项目使用
- 编译更快:库不参与每次编译,大型项目提速明显
- 降低耦合:底层和上层分离,架构更清晰
- 保护源码:核心算法 / 安全代码不泄露
- 稳定可靠:成熟代码封装后不易被破坏
- 团队协作:分工明确,底层开发 vs 业务开发
在代码封装成库时,往往选择将其封装为静态链接库(.a/.lib)或者动态链接库(.so/.dll),为了详细对比分析嵌入式开发中动态库与静态库的差异,接下来将从以下四个核心维度深入展开讨论,将以清晰的对比结构输出结论,重点结合嵌入式资源受限特性,阐述两者在实际开发与系统架构中的性能及维护优势:
1、内存与磁盘资源占用
1.1、内存与磁盘资源占用
静态库:编译时会将库的所有代码完整拷贝到每个可执行文件中。
动态库:只在可执行文件中记录引用地址,实际代码只存一份在系统中。
| 对比项 | 静态库 | 动态库 |
| 链接方式 | 编译时将代码完整复制到可执行文件中 | 运行时按需才加载到内存 |
| 可执行文件大小 | 包含完整库代码,体积大 | 仅包含符号引用,体积小 |
| 磁盘占用 | 每个应用独立加载库代码 | 多个应用共享同一份库文件 |
| 内存占用 | 每个进程独立加载完整代码,N 个进程 = N 份代码副本(线性增长) | 多进程共享同一份内存代码,N 个进程 ≈ 1 份物理代码 + N 个映射表 |
| 运行时内存 | 启动 10 个相同程序 → 代码段占用 10 倍内存 | 启动 10 个相同程序 → 代码段仅占 1 倍内存 |
这里简单举一个例子展示二进制资源的区别,假设有 3 个音频处理任务,都使用同一个音频算法库,IRAM对固件镜像和运行时内存占用都有影响:
| 静态库方案 | 动态库方案 |
Task1: [代码+Lib] ≈ 50KB IRAM + 10KB DRAM Task2: [代码+Lib] ≈ 50KB IRAM + 10KB DRAM Task3: [代码+Lib] ≈ 50KB IRAM + 10KB DRAM | Task1: [代码] + [共享Lib] ≈ 20KB IRAM + 10KB DRAM Task2: [代码] + [共享Lib] ≈ 20KB IRAM + 10KB DRAM Task3: [代码] + [共享Lib] ≈ 20KB IRAM + 10KB DRAM |
| 总计:150KB IRAM + 30KB DRAM | 实际:60KB IRAM + 30KB DRAM(Lib代码共享) IRAM 节省:约 60% |
1.2、多进程共享机制
| 静态库模式:每个进程会独立加载一份 |
| 进程 A → [app_a + lib_code] → 内存:lib_code × 1 份(进程内) 进程 B → [app_b + lib_code] → 内存:lib_code × 1 份(进程内) 进程 C → [app_c + lib_code] → 内存:lib_code × 1 份(进程内) |
| 总内存 = 3 份 lib_code,占用 3 × Size(lib) |
| 动态库模式:多个进程/应用使用同一个动态库时,操作系统只需将库代码加载到内存一次,所有进程共享这一份代码: |
| 进程 A → [app_a] → 引用共享库 → 内存:PLT/GOT 表 + lib_code × 1 份(全局共享) 进程 B → [app_b] → 引用共享库 → 内存:PLT/GOT 表 + [共享同一份lib_code] 进程 C → [app_c] → 引用共享库 → 内存:PLT/GOT 表 + [共享同一份lib_code] |
| 总内存 ≈ 1 份 lib_code + 3 × 页表开销,节省约 66% 内存占用,(PLT/GOT 表:用于动态链接的跳转表) |
1.3、链接及引用地址
静态库链接方式:可执行文件中直接包含库的机器码,链接器会把静态库里被调用到的目标代码段、数据段完整拷贝,嵌入最终可执行文件内部。 程序运行时不再依赖外部库文件,函数调用写死真实内存地址,在代码中静态库调用情况如下:
main(): |
lib_porocess():库代码直接嵌入 // 实际处理逻辑 |
- 0x12345678:库函数编译后固定的物理偏移地址
- 链接完成后地址永久固化,运行时无需重定位、无需加载外部文件
动态库链接方式:使用引用地址即可执行文件中只记录符号引用,而不是实际的代码或数据地址,动态库调用不会写死真实地址,是通过PLT/GOT间接跳转,运行时才查找库函数地址,在代码中动态库调用情况如下:
PLT:过程链接表,存放跳转指令
GOT:全局偏移量表,存放实际地址
main(): CALL PLT[0] ← 调用 PLT 表中的跳转指令 | |
PLT[0]: jmp *GOT[1] ← 跳转到 GOT 表中的地址 |
GOT 表(运行前): GOT[1] = 0x00000000 ← 引用地址(待填充) |
GOT 表(运行后): GOT[1] = 0x12345678 ← 实际地址(库lib_porocess加载后) |
静态库被调用的实际地址和动态库被调用的引用地址对比如下:
| 对比项 | 实际地址 | 引用地址 |
| 存储位置 | 运行时确定 | 可执行文件中 |
| 值 | 库函数的真实内存地址 | 初始为0或占位符 |
| 确定时机 | 程序启动时 | 编译/链接时 |
| 作用 | 实际执行时的跳转目标 | 标记需要解析的符号 |
虽然引用地址可以减少可执行文件的体积,但是动态库会引入一些额外开销:
| 开销类型 | 说明 |
| PLT/GOT 表 | 用于动态链接的跳转表,占用少量DRAM |
| 运行时链接 | 启动时解析符号的时间开销 |
| 库句柄 | 每个加载的库需要维护句柄信息 |
2、程序编译与更新灵活性
2.1、编译流程对比
静态库编译流程:
源码修改 → 重新编译库 → 重新编译所有使用该库的应用 → 重新链接 → 发布
↑ ↑
└──────────── 牵一发而动全身 ───────────────────┘
动态库编译流程:
库源码修改 → 单独编译库 → 发布新库文件 → 无需重新编译/链接应用
↑
主程序应用完全不受影响
2.2、更新机制对比
| 场景 | 静态库更新 | 动态库更新 |
| ABI兼容性 | 需维护 ABI 稳定,否则调用方需重新编译 | 无 ABI 约束(直接嵌入二进制) |
| 修复 Bug | 必须重新编译整个主程序并重新分发 | 只需替换 .so/.dll 文件,主程序零改动 |
| 功能增强 | 全量重新编译、测试、部署,影响所有依赖方 | 独立发布新版动态库,即插即用,模块独立演进 |
| 修复/HOTFIX | 用户需下载完整安装包 | 推送一个小补丁文件即可 |
| 版本管理难度 | 高(每次变更都产生新的二进制) | 低(语义化版本控制:major.minor.patch) |
| 回滚成本 | 需重新打包旧版本 | 替换回旧版 .so 即可 |
| 多 SKU 维护 | 每个 SKU 独立构建流水线,重复工作多 | 基础库只维护一份,差异模块独立构建发布 |
2.3、OTA应用场景
假设需要修复一款音频产品中的libaudio库中的一个音频bug:
| 静态库方案 | 动态库方案 |
1. 修改 audio.c 源码 5.全量回归测试 (因为二进制全变了) 7.用户端下载完整包并刷机 | 1. 修改 audio.c 源码 3.针对性测试 库修改功能即可 |
| 时间成本高,用户带宽成本高 | 时间成本低,用户带宽成本低 |