把 20 个第三方库的交叉编译,从手工活做成框架
一、问题是什么
入职某电视厂商智能电视操作系统团队的第二个月,被分到一个新人任务:把某流媒体平台设备认证框架(NRDP 6.0)所需的约 20 个第三方开源库,全部交叉编译到嵌入式 Linux 平台上去。
这件事在团队里此前没人系统做过。原因很简单——它不属于业务功能,属于"基础设施"。每个人遇到都是临时手工解决一次,然后扔在自己工位上,下一个人接手又从头来一遍。
20 个库,每个库都有自己的:
- 构建系统(Autotools / CMake / Meson / 自定义 Makefile 四种都有)
- 依赖关系(libpng 依赖 zlib,curl 依赖 openssl 和 c-ares)
- 配置参数(host / target / sysroot / 工具链路径)
- 产物形态(
.so命名规则不统一)
如果按"一个库一次手工"的方式做,做完 20 个,相当于把同样的痛重复经历 20 次,并且这份知识无法沉淀——下次工具链升级、库版本更替、新平台适配,全部要再走一遍。
我的判断是:这件事必须做成一个框架,而不是 20 段独立的脚本。
二、Shell 不是零基础,但缺关键特性
此前在视频云平台做过一键部署,Shell 写过不少。所以这次不是从 if/then 开始学,而是发现要做的事情,需要几个之前没碰过的特性:
- 关联数组
declare -A:要把"库名 → 路径 + 产物模式"的映射写清楚,普通索引数组撑不住 printf格式化 +tput终端控制:20 个库的编译输出需要分色、对齐,否则失败定位是个噩梦xargs -I {}+export -f:选择性编译、并行编译需要这个组合IFS切分、read多模式:把关联数组里"path;pattern"这种打包字符串拆开
所以我做了一件略反直觉的事——先写两个纯练习脚本,把这些特性吃透,再去写编译框架本身。
| 日期 | 文件 | 性质 |
|---|---|---|
| 2021-11-29 | printf.sh | 特性练习:关联数组、printf、tput、IFS、xargs、函数返回值、子 shell、read |
| 2021-12-08 | printf2.sh | 进阶练习:export -f、xargs -I {}、brace expansion、for/until、字符串/文件测试 |
| 2021-12-16 | build.sh | 编译框架成型 |
从特性练习到框架产出,17 天。回头看,这种"边学边产出"的方式有个好处:练习脚本里写过的每一个用法,几乎都直接进了 build.sh。学习目标和产出目标重合,没有"学了不知道用在哪"的浪费。
三、框架的核心抽象
最终的 build.sh 有几个关键设计。
3.1 用关联数组把"库元数据"集中描述
declare -A cmds
cmds[zlib]="sdk/3rd_lib/zlib-1.2.8;libz.so*"
cmds[c-ares]="sdk/3rd_lib/c-ares-1.12.0;libcares.so*"
cmds[openssl]="sdk/3rd_lib/openssl-1.0.2h;libssl.so*|libcrypto.so*"
# ... 共 20 个库
每一行回答两个问题:这个库在哪里编、它编出来的产物长什么样。
这是整个框架的"数据底座"。后续所有动作(编译、聚合、过滤)都从这张表派生,不再有第二处库元数据。
3.2 每个库一个独立 build.sh,根脚本只做编排
20 个库的差异(构建系统、特定参数、补丁需求)封装在各自目录下的 build.sh 里,根脚本不关心。
根脚本只做四件事:
- 解析命令行参数(编译哪些库)
- 按依赖顺序遍历关联数组
- 进入每个库的目录,调用
./build.sh <toolchain> <output> - 把产物聚合到统一目录
这是典型的"接口与实现分离"——根脚本是接口(编排),子 build.sh 是实现(适配各自构建系统的差异)。
3.3 产物聚合 copy_so2libs()
20 个库散落在不同的源码目录里编出来,但消费方(设备认证框架)只期望从一个目录拿 .so。copy_so2libs() 按照关联数组里登记的产物模式(libz.so*、libssl.so*|libcrypto.so* 等),把所有 .so 收拢到 3rd_libs/。
这一步把"源码组织方式"和"二进制消费方式"解耦——源码可以按各自习惯组织,二进制对外只有一个目录契约。
3.4 选择性编译 can_compile()
20 个库的全量编译耗时不短,日常开发只动其中 1~2 个。命令行参数支持指定库名白名单,can_compile(lib_name) 在遍历时做过滤。
3.5 根路径定位 get_parant N
每个库的目录嵌套深度不一样。get_parant 5 表示从当前位置向上 5 级定位 SDK 根目录,让脚本可以从任意子目录调用而不出错——这是个小细节,但在多人协作时省了非常多的"我在错的目录跑了你的脚本"事故。
四、覆盖的 20 个库
zlib, c-ares, Little-CMS, tremor, fdk-aac, libelf, libdwarf, icu, openssl, ogg, expat, netlink(libnl), libjpeg-turbo, libunwind, openh264, libpng, freetype, libwebp, curl, openjpeg
按领域分类:
| 领域 | 库 |
|---|---|
| 音频编解码 | ogg, tremor, fdk-aac |
| 视频编解码 | openh264 |
| 图像处理 | libpng, libjpeg-turbo, libwebp, Little-CMS, openjpeg |
| 网络 | curl, c-ares, openssl, netlink (libnl) |
| 文本/国际化 | icu, expat |
| 字体 | freetype |
| 压缩 | zlib |
| 调试 | libelf, libdwarf, libunwind |
五、异构构建系统的适配难点
20 个库使用了四种不同的构建系统,每种都要单独适配交叉编译:
- Autotools(zlib / openssl / curl / libpng / freetype 等):
./configure --host=<triple> --prefix=<sysroot> CC=<cross-gcc> - CMake(libjpeg-turbo / openh264 等):通过
CMAKE_TOOLCHAIN_FILE注入工具链定义 - Meson:cross file 描述目标平台
- 手写 Makefile(tremor / fdk-aac 等):直接改 Makefile 里的
CC/AR/LD等变量,必要时打补丁
虽然底层千差万别,但对外暴露的接口被统一为同一个签名:
build.sh <toolchain_path> <output_dir>
根脚本对所有库一视同仁地调这个签名。具体怎么把它桥接到 Autotools 还是 CMake,是每个库 build.sh 自己的事。
这个统一抽象是整个框架最重要的设计选择——它让"加一个新库"的成本变成:在关联数组里加一行 + 在该库目录下写一个适配 build.sh,不需要动根脚本。
六、收获
6.1 工程层面
- 把"20 次手工编译"变成"一次性的编译框架",团队后续新人接手不再重复踩坑
- 后续工具链升级、库版本更替时,改动局限在单个库的 build.sh,根脚本不动
6.2 思维层面
让我真正体会到几件事:
先确定抽象,再写第一行实现代码。这件事如果一开始就上手写"先把 zlib 编出来再说",会一路被实现细节带走,最后写出 20 个互不相干的脚本,框架永远诞生不了。先写关联数组那一张表(哪怕是空的),就强制自己思考"统一接口是什么"。
学习与产出可以同步发生。printf.sh 和 printf2.sh 这两份练习脚本,今天看是博客的"学习曲线证据",但当时它们是真实需要的——关联数组怎么遍历、xargs -I {} 的引号转义怎么写,必须在主战场之外的安全沙盒里先确认清楚,否则在 build.sh 里调试会被构建错误的噪声淹没。
新人任务里藏着支点。第三方库编译这种"边角料",做下来反而让我吃透了整套 SDK 的依赖关系全貌——后续深入设备认证框架对接层代码、做架构重构,入口就是从这里来的。
七、延伸:同样思路在另一视频平台的复用
之后接手另一视频平台的对接,遇到类似问题:该平台的依赖库与系统库高度耦合,全量编译又慢又占地方。
把"依赖库与系统库解耦"这条原则套上去,做了一次拆分:
- 编译时间减少 30%
- 存储占用减少 80%
抽象一旦立住,下一次类似场景不再需要从零思考。这才是把手工活做成框架的真正回报——不是省了一次工时,而是省了所有未来类似场景的工时。
造工具的工具,把手工劳作变成可复用流程。
浙公网安备 33010602011771号