嵌入式 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 台,从实验室走向千家万户时,你会感激今天所做的每一个工程化努力。

因为那时候你会发现:
最贵的不是硬件成本,而是每一次现场返修的时间;
最危险的不是功能缺陷,而是无法控制的系统状态。

所以,别再把“打包”当成收尾工作。把它当作产品设计的一部分,从第一天就开始认真对待。

毕竟,你交付的不只是代码,而是一台台承载信任的设备。🔧✨

更多推荐