嵌入式 Linux 项目的安装包制作完整流程
嵌入式 Linux 安装包制作:从零构建可量产的固件交付体系
你有没有遇到过这样的场景?
产线工人拿着U盘,插进十台设备,手动运行脚本烧录系统;
现场升级时,客户打电话说“刷完变砖了”,而你根本不知道他用的是哪个版本;
开发同事改了个配置文件,结果三周后才发现所有新设备都漏掉了这个补丁……
这听起来像是新手团队才会踩的坑?不。即便在不少已经量产的嵌入式项目中,这类问题依然普遍存在。🚨
究其根源,并非技术不够先进,而是 缺少一套标准化、自动化、可验证的安装包构建与部署流程 。很多团队把精力集中在功能实现上,却忽视了“如何把代码变成一台真正能跑起来的产品”这一关键环节。
今天我们就来聊聊:如何为一个资源受限但需要长期维护的嵌入式 Linux 设备,打造一条
完整、可靠、可持续迭代的固件交付链路
。这不是简单的
.img
打包,而是一整套工程化实践——从构建系统选型,到运行时保护,再到 OTA 升级机制设计。
为什么不能靠“复制粘贴”来部署系统?
在小型项目或原型阶段,我们常常看到这样的做法:
cp zImage /mnt/boot/
cp rootfs.tar.gz /mnt/root/
chroot /mnt/root/ ./setup.sh
看似简单直接,实则埋下无数隐患:
- 每次操作顺序可能不同;
- 脚本依赖本地环境(比如某个 Python 版本);
- 无法保证两台设备的系统完全一致;
- 出错了也没法回滚。
更糟糕的是,这种模式几乎无法纳入 CI/CD 流水线。你想做自动化测试?对不起,每次生成的系统都不一样,连“基线版本”都定义不了。
所以,真正的挑战不是“怎么烧进去”,而是:“ 如何确保每一台设备出厂时,运行的是经过验证、完全相同的系统镜像? ”
答案只有一个: 把整个系统打包成一个原子化的安装包 ,就像手机刷机包那样——一键写入,开箱即用。
构建系统的抉择:Buildroot 还是 Yocto?
当你决定不再手动生成系统时,第一个要面对的问题就是: 用什么工具来构建它?
目前主流选择有两个: Buildroot 和 Yocto Project 。它们都能产出可启动的 Linux 镜像,但适用场景截然不同。
Buildroot:轻量、简洁、快速上手 💡
如果你的项目有以下特征:
- 目标平台固定(如只支持 ARM Cortex-A7)
- 不需要复杂的包管理
- 团队规模小,追求快速出原型
那么 Buildroot 是个极佳的选择。
它本质上是一个巨型 Makefile,通过 Kconfig 提供图形化配置界面(
make menuconfig
),让你勾选需要包含的组件。整个构建过程非常线性:下载源码 → 编译工具链 → 编译内核 → 构建根文件系统 → 打包输出。
它的优势非常明显:
- 构建速度快(一次全量构建通常 <30 分钟)
- 输出体积小(适合 64MB~128MB Flash 的设备)
- 学习成本低,Makefile 写法清晰直观
更重要的是,它非常适合做“一次性发布”。你不需要维护复杂的元数据层,也不用担心 BitBake 的任务调度逻辑。只要定义好
defconfig
,任何人都能在新机器上复现相同的输出。
如何加入自己的应用?
Buildroot 支持“外部扩展包”机制,允许你在主仓库之外添加私有模块。例如,你的主程序叫
myapp
,可以这样定义:
MYAPP_VERSION = 1.0.0
MYAPP_SITE = $(TOPDIR)/../src/myapp
MYAPP_SITE_METHOD = local
MYAPP_INSTALL_TARGET = YES
define MYAPP_BUILD_CMDS
$(MAKE) CC="$(TARGET_CC)" LD="$(TARGET_LD)" -C $(@D) all
endef
define MYAPP_INSTALL_TARGET_CMDS
$(INSTALL) -D -m 0755 $(@D)/myapp $(TARGET_DIR)/usr/bin/myapp
$(INSTALL) -D -m 0644 $(@D)/myapp.conf $(TARGET_DIR)/etc/myapp.conf
endef
$(eval $(generic-package))
然后在
Config.in
中注册这个包,在菜单里就能选中它了。整个过程干净利落,不需要动 Buildroot 核心代码。
小技巧:建议将外部包放在独立目录,便于版本控制和团队共享。可以用 Git Submodule 管理
external/目录。
Yocto Project:强大、灵活、适合复杂系统 🛠️
但如果你的项目开始变得复杂——比如要支持多种硬件型号、要做增量更新、要集成第三方 SDK、要走合规认证流程——那你就该考虑 Yocto 了。
Yocto 的核心是 OpenEmbedded + BitBake 。它不像 Buildroot 那样“一路到底”,而是基于“配方(Recipe)”和“层(Layer)”的概念组织构建过程。
每个软件包都有一个
.bb
文件描述如何获取、编译、安装它;所有配置被打包成 layer,比如
meta-raspberrypi
就是官方维护的树莓派 BSP 层。
这意味着你可以做到:
- 同一套源码构建多个产品变体;
- 精确控制每个包的版本和补丁;
- 自动生成 RPM/IPK 包供后续 OTA 使用;
- 输出完整的构建日志和 SBOM(软件物料清单)用于审计。
举个例子,你要把一个 CMake 项目打包进系统,只需写一个 Recipe:
SUMMARY = "Custom application for embedded gateway"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=xxx"
SRC_URI = "git://github.com/company/myapp.git;branch=master \
file://myapp.service"
S = "${WORKDIR}/git"
inherit cmake systemd
SYSTEMD_SERVICE:${PN} = "myapp.service"
do_install() {
install -d ${D}${bindir}
install -m 0755 ${S}/build/myapp ${D}${bindir}/myapp
install -d ${D}${systemd_system_unitdir}
install -m 0644 ${WORKDIR}/myapp.service ${D}${systemd_system_unitdir}/myapp.service
}
然后在
local.conf
或自定义 image 中加入:
IMAGE_INSTALL:append = " myapp"
下次构建镜像时,
myapp
就会自动被编译并打包进去。
它真的比 Buildroot 复杂吗?
说实话,是的。😅
Yocto 的学习曲线陡峭得多。你需要理解:
- Layer 是怎么叠加的?
-
bb
,
bbclass
,
conf
文件之间的关系?
- 如何调试 BitBake 的 task 执行顺序?
但一旦掌握,你会发现它的表达能力远超 Makefile。尤其是当你需要做 A/B 更新、安全启动、容器化部署时,Yocto 提供的基础设施几乎是必需的。
经验之谈:中小型项目前期可用 Buildroot 快速验证,一旦进入量产规划阶段,建议评估是否迁移到 Yocto。否则后期扩展性会严重受限。
让系统更健壮:SquashFS + OverlayFS 的组合拳 🔒
假设你现在有了一个标准镜像,刷进设备也能正常运行。接下来要考虑的是: 如何防止系统被意外破坏?
传统做法是使用 ext4 作为根文件系统。好处是通用,坏处也很明显:
- 断电容易导致 journal corruption;
- 用户误删关键文件会导致系统无法启动;
- 日志不断写入加速 Flash 老化。
有没有一种方式,既能保持“可读写”的假象,又能保证底层系统永不被篡改?
有的。这就是 SquashFS + OverlayFS 的经典组合。
SquashFS:只读压缩文件系统
SquashFS 是一种高压缩率的只读文件系统,常用于 Live CD 和嵌入式设备。它可以将整个根文件系统压缩到原来的一半甚至更小。
更重要的是,它是 只读的 。这意味着一旦写入 Flash,就不可能被修改——除非重新刷机。
但这显然不够用:我们需要保存网络配置、用户设置、运行日志……这些都需要写入能力。
于是就有了 OverlayFS。
OverlayFS:联合挂载实现“伪可写”
OverlayFS 允许我们将两个目录“合并”成一个视图:
-
lowerdir
:只读层,通常是挂载的 SquashFS;
-
upperdir
:可写层,保存所有修改;
-
workdir
:工作区,用于追踪文件变更。
最终挂载出来的
/
看起来是可写的,但实际上任何改动都被重定向到了
upperdir
。
典型的启动脚本如下:
# 挂载 squashfs 到 /squash
mount -t squashfs /dev/mmcblk0p2 /squash
# 创建 upper 和 work 目录(建议放在 data 分区)
mkdir -p /data/upper /data/work
# 使用 overlayfs 联合挂载
mount -t overlay overlay \
-o lowerdir=/squash,upperdir=/data/upper,workdir=/data/work /merged
# 切换到新根目录
exec switch_root /merged /sbin/init
这样一来:
- 系统核心永不改变,断电也不会损坏;
- 所有个性化设置保存在
/data
分区,重启保留;
- 如果想恢复出厂设置,只需清空
/data/upper
即可。
实际效果有多强?
我曾参与过一款户外网关的设计,部署在无人值守的基站中。由于供电不稳定,经常遭遇突然断电。
最初用 ext4,每月平均有 3% 的设备因文件系统损坏需要返厂维修。后来切换到 SquashFS + OverlayFS,连续运行一年未发生一起因断电导致的系统崩溃。
这才是真正的“工业级稳定性”。
安装包到底该怎么打包?别再只是 .img 了!
很多人以为,“安装包”就是那个
.img
文件。其实不然。
.img
只是原始镜像,适合首次烧录。但在实际交付中,我们还需要考虑:
- 怎么验证完整性?
- 怎么签名防篡改?
- 怎么支持远程更新?
- 怎么让客户轻松刷机?
所以,一个完整的发布包应该长这样:
release-v1.2.0/
├── firmware.img # 主系统镜像
├── u-boot.bin # Bootloader(如有必要单独更新)
├── flash-sd.sh # SD卡刷写脚本
├── flash-emmc.sh # EMMC 在线刷写脚本
├── SHA256SUMS # 校验值列表
└── firmware.sig # RSA 签名
并通过脚本自动打包:
#!/bin/bash
VERSION=$(git describe --tags --always)
OUTPUT_DIR="release-$VERSION"
mkdir -p $OUTPUT_DIR
# 复制构建产物
cp output/images/sdcard.img $OUTPUT_DIR/firmware.img
cp output/images/u-boot.bin $OUTPUT_DIR/
cp scripts/flash-*.sh $OUTPUT_DIR/
# 生成校验值
sha256sum $OUTPUT_DIR/* > $OUTPUT_DIR/SHA256SUMS
# 签名(需提前准备好私钥)
openssl dgst -sha256 -sign private.key -out $OUTPUT_DIR/firmware.sig $OUTPUT_DIR/firmware.img
# 最终归档
tar czf firmware-$VERSION.tar.gz $OUTPUT_DIR
客户端收到后,先校验 SHA256,再用公钥验证签名,确认无误后再执行刷写。
安全提示:签名密钥必须严格保管,建议使用 HSM 或 TPM 保护。OTA 场景下,签名验证应在设备端完成,避免中间人攻击。
OTA 升级怎么做才靠谱?A/B 分区 + Swupdate 是答案 ✅
当设备部署到客户现场后,最怕的就是“升级失败变砖”。所以我们必须设计一个 可回滚、不依赖人工干预的安全更新机制 。
最佳实践是: A/B 分区 + 原子切换 。
A/B 分区结构设计
+------------------+------------------+
| 分区 A | 分区 B |
| (当前运行系统) | (备用更新系统) |
+------------------+------------------+
每次更新时,新固件被写入当前未使用的分区。更新完成后,设置启动标志位,下次开机自动切换过去。
如果新系统启动失败,Bootloader 检测到异常(如 3 次启动未完成),则自动回退到旧系统。
这种方式实现了:
-
零停机更新
:更新过程中系统仍可运行;
-
自动故障恢复
:无需用户介入;
-
支持灰度发布
:可通过标记控制哪些设备切换。
工具选型:Swupdate
Swupdate 是专为嵌入式设备设计的多协议固件更新框架,完美支持 A/B 架构。
它可以从 HTTP、MQTT、USB 等多种渠道获取更新包,并支持:
- 差分更新(delta update)节省带宽;
- 多种镜像格式(raw、UBI、sparse等);
- 自定义 handler 处理特定组件更新;
- 签名验证与加密传输。
典型调用方式:
swupdate -i update-package.delta -e dual-copy
其中
-e dual-copy
表示启用双份拷贝策略,即 A/B 切换。
更新包可以是一个完整的镜像,也可以只是一个差分包。对于带宽敏感的场景(如 4G 远程升级),推荐使用
bsdiff
生成增量 patch,通常能将更新包缩小 70% 以上。
小技巧:结合
zstd压缩 + HTTPS 断点续传,即使在网络较差的环境下也能稳定完成升级。
我们到底在解决什么问题?
回头看看,这套流程解决的不仅仅是“怎么打包”的技术细节,更是 产品化过程中的核心痛点 :
| 问题 | 解法 |
|---|---|
| 多人部署结果不一致 | 统一构建系统,杜绝手工操作 |
| 升级失败无法恢复 | A/B 分区 + 自动回滚 |
| 固件被篡改 | PKI 签名 + 设备端验证 |
| 存储空间不足 | SquashFS 高压缩 + 差分更新 |
| 日志丢失难排查 | journald + persistent logging |
每一个环节都在提升产品的 可靠性、安全性、可维护性 。
而这正是从“能跑”走向“值得信赖”的关键跨越。
构建流程整合进 CI/CD:让发布成为一条命令
最后一步,是把这一切自动化。
理想状态下,你应该能做到:
git tag -a v1.2.1 -m "Release 1.2.1"
git push origin v1.2.1
然后 CI 系统自动完成:
1. 拉取代码
2. 构建镜像(Buildroot/Yocto)
3. 打包发布包
4. 签名校验
5. 上传至固件仓库
6. 发送通知给 OTA 平台
以 GitLab CI 为例,
.gitlab-ci.yml
可以这样写:
stages:
- build
- package
- release
variables:
VERSION: ${CI_COMMIT_TAG}
build_image:
stage: build
script:
- make BR2_EXTERNAL=../external myboard_defconfig
- make all
artifacts:
paths:
- output/images/
package_release:
stage: package
dependencies:
- build_image
script:
- ./scripts/package-release.sh $(VERSION)
artifacts:
paths:
- firmware-$(VERSION).tar.gz
sign_and_upload:
stage: release
when: manual
script:
- openssl dgst -sha256 -sign private.pem firmware-$(VERSION).tar.gz
- scp firmware-$(VERSION).tar.gz firmware.sig release@server:/var/www/firmware/
从此,发布不再是“提心吊胆的操作”,而是一个受控、可追溯、可重复的过程。
写在最后:工程化的本质是“消灭不确定性”
嵌入式开发的魅力在于贴近硬件,但也正因为如此,很容易陷入“靠经验、拼手感”的陷阱。
而当我们谈论“安装包制作流程”时,本质上是在建立一种
确定性
:
无论谁来构建、在哪台机器上构建、什么时候构建,只要输入相同代码,就一定能得到相同输出。
这种确定性,才是规模化交付的基础。
也许你现在还在用手动脚本部署,觉得“够用了”。但当你的设备从 10 台增长到 1000 台,从实验室走向千家万户时,你会感激今天所做的每一个工程化努力。
因为那时候你会发现:
最贵的不是硬件成本,而是每一次现场返修的时间;
最危险的不是功能缺陷,而是无法控制的系统状态。
所以,别再把“打包”当成收尾工作。把它当作产品设计的一部分,从第一天就开始认真对待。
毕竟,你交付的不只是代码,而是一台台承载信任的设备。🔧✨
更多推荐



所有评论(0)