2. 固件构建系统与工具链

固件安全 CI/CD 流水线的第一道工程基础,不是扫描器,也不是签名服务,而是“构建”。如果同一份源码在不同机器、不同时间、不同开发者手里能产出不同的二进制,那么后续的哈希比对、SBOM 生成、漏洞定位、签名验证都会失去可信锚点。因此本节的核心目标,是理解固件构建系统与交叉编译工具链如何共同决定构建产物,并搭建一个可复现的固件构建环境。

构建系统:Make、CMake、Bazel 的定位差异

Make 是最基础的构建工具,它围绕“目标—依赖—命令”三要素工作。一个典型的固件 Makefile 片段如下:

CC      := arm-none-eabi-gcc
CFLAGS  := -mcpu=cortex-m4 -mthumb -Os -ffunction-sections -fdata-sections
LDFLAGS := -T linker.ld -Wl,--gc-sections -Wl,-Map=firmware.map

OBJS := main.o uart.o crypto.o
firmware.elf: $(OBJS)
	$(CC) $(LDFLAGS) -o $@ $^
%.o: %.c
	$(CC) $(CFLAGS) -c -o $@ $<

Make 的优点是透明、可控、依赖工具链简单;缺点是跨平台、跨目录、跨模块的依赖管理需要手工维护,容易因环境变量、时间戳、并行顺序产生非确定性。

CMake 不直接编译,而是生成 Makefile 或 Ninja 构建文件。它更适合中大型固件项目,尤其是需要同时支持多种 MCU、多种工具链、多种构建类型(Debug/Release)的场景。CMake 的 toolchain file 是交叉编译的关键入口:

set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR cortex-m4)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_FIND_ROOT_PATH /opt/gcc-arm-none-eabi)

Bazel 则强调“封闭性”和“可复现性”。它通过 WORKSPACE/MODULE.bazel 声明外部依赖,通过 BUILD 文件描述目标,并在沙箱中执行构建。Bazel 对固件项目的价值在于:依赖版本可锁定、构建动作可缓存、输出路径可预测。但它对工具链的接入要求更高,通常需要自定义 toolchain 规则。

三者的选择可以概括为:小型裸机项目用 Make;多平台、多配置、需要 IDE 集成的项目用 CMake;大型多模块、强依赖治理、追求远程缓存与可复现的项目用 Bazel。实际固件工程中,也常见 CMake 作为顶层构建系统、内部调用 Make 或 Ninja 的混合模式。

固件构建系统与交叉编译工具链关系图 源码与配置 main.c / uart.c linker.ld / CMakeLists.txt 构建系统 Make 目标/依赖/命令 CMake 生成器+工具链文件 Bazel 沙箱+依赖锁定 交叉编译工具链 arm-none-eabi-gcc ld / objcopy / size sysroot + 版本锁定 构建产物结构 firmware.elf 符号+调试信息 firmware.bin 烧录镜像 firmware.map 内存布局 可复现固件构建环境的关键控制点 工具链版本固定 容器镜像 + SHA256 构建路径与时间戳 -ffile-prefix-map 依赖与源码锁定 submodule / lockfile 产物哈希与签名 sha256 + SBOM

交叉编译工具链:从宿主机到目标机

固件构建与普通应用构建最大的区别,是“在 x86_64 宿主机上生成 ARM/RISC-V 目标代码”。这要求工具链具备三部分能力:编译器前端能识别目标架构、链接器能按目标内存布局链接、运行时库能匹配目标 ABI。以 arm-none-eabi-gcc 为例,arm 是目标架构,none 表示无操作系统,eabi 是嵌入式应用二进制接口。

交叉编译中常见的参数包括:-mcpu 指定内核,-mthumb 指定指令集,-mfpu 指定浮点单元,-mfloat-abi 指定浮点 ABI。这些参数一旦在编译单元之间不一致,链接阶段就会出现 ABI 不匹配错误。因此,工具链参数应集中定义在工具链文件或顶层构建配置中,而不是散落在各个子目录。

工具链的可复现性还要求锁定版本。例如,在 CI 中使用固定镜像:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
    gcc-arm-none-eabi=15:10.3-2021.10 \
    binutils-arm-none-eabi=2.38-1
ENV CROSS_COMPILE=arm-none-eabi-

镜像标签应使用 digest 而非 latest,例如 registry.local/fw-build@sha256:...,这样每次流水线拉取到的工具链完全一致。

构建产物结构:ELF、BIN、MAP 与 SBOM

一次完整的固件构建通常产出以下文件:firmware.elf 包含符号表、调试信息和段布局,是后续分析的主要对象;firmware.bin 是 objcopy -O binary 得到的纯二进制镜像,用于烧录;firmware.map 记录各段、各符号的地址与大小,是排查内存溢出和确认链接脚本是否生效的关键依据;firmware.hex 则常用于产线烧录工具。

在安全 CI/CD 中,还应额外生成 SBOM(软件物料清单)和构建元数据。SBOM 至少应包含组件名称、版本、来源、许可证和哈希;构建元数据应包含源码 commit、工具链版本、构建时间、构建主机标识和产物 SHA256。这些信息与 ELF 中的 .comment、.note.gnu.build-id 段相互印证,才能形成可追溯的构建证据链。

搭建可复现的固件构建环境

可复现构建的目标是:相同源码、相同工具链、相同构建参数,在任何符合条件的主机上产出逐字节一致的二进制。实现路径通常包括以下控制点:

  • 工具链固定:使用容器镜像或 Nix 固定编译器、链接器、库版本,并记录镜像 digest。
  • 路径去敏:使用 -ffile-prefix-map=$(pwd)=/build 消除绝对路径差异;避免在源码中使用 __FILE__ 或 __DATE__。
  • 时间戳归零:设置 SOURCE_DATE_EPOCH,使构建系统使用固定时间;对 .comment 段可做后处理。
  • 并行与排序:固定 make -j 的并行度,或使用 Ninja 的确定性调度;归档工具使用 ar D 去除时间戳。
  • 依赖锁定:Git submodule 固定 commit,包管理器使用 lockfile,Bazel 使用 MODULE.bazel.lock。
  • 产物验证:在 CI 中对同一 commit 执行两次构建,比较 sha256sum firmware.bin;若不一致,用 diffoscope 定位差异段。
建议在流水线中增加“可复现性门禁”:同一源码在独立构建节点上重复构建两次,若 firmware.bin 的 SHA256 不一致,则阻断后续签名与发布流程。这比事后审计更早暴露环境漂移。

一个可参考的构建环境检查清单

检查项期望状态验证方式
工具链版本与基线一致arm-none-eabi-gcc --version
容器镜像使用 digest 固定docker inspect --format='{{index .RepoDigests 0}}'
源码路径无绝对路径泄漏strings firmware.elf | grep $(pwd)
构建时间由 SOURCE_DATE_EPOCH 控制检查 .comment 与构建日志
产物哈希两次构建一致sha256sum firmware.bin
SBOM组件与版本完整与源码依赖清单比对

构建系统与工具链不是 CI/CD 的“前置杂事”,而是安全流水线的信任起点。只有把 Make/CMake/Bazel 的职责边界、交叉编译参数、产物结构和可复现控制点都落到工程配置中,后续的静态扫描、漏洞关联、签名验证和固件发布才有稳定的比较基准。实际落地时,建议先在一个最小固件工程上跑通“两次构建哈希一致”,再逐步扩展到多模块、多平台项目。