前言:为什么 MCU 也需要可信启动链

过去做单片机项目,很多团队把安全问题理解成“通信加个 TLS”“升级包做个 CRC”或者“调试口量产时关掉”。这些措施当然有价值,但如果设备会联网、会远程升级、会保存业务密钥,真正的风险往往出现在更早的启动阶段:攻击者能不能替换 Bootloader?能不能刷入旧版本固件重新打开已经修复的漏洞?能不能通过普通应用区读出密钥?能不能在升级断电后把设备卡成砖?

ARM Cortex-M23、Cortex-M33、Cortex-M35P、Cortex-M55 等内核引入的 TrustZone-M,正是为这些问题准备的一套硬件隔离基础设施。它不像服务器上的虚拟化那样厚重,也不是简单的软件权限判断,而是把整个地址空间、外设访问、中断入口和函数调用边界都划分成 Secure 与 Non-secure 两个世界。安全世界负责根信任、密钥、签名校验、回滚计数和少量可信服务;普通世界继续运行原有业务逻辑,例如传感器采集、协议栈、UI、云端连接和控制算法。

这篇文章不追求把 ARM 架构手册逐页复述,而是从一个真实产品视角出发,设计一条可落地的 TrustZone-M 安全启动与 OTA 链路。目标设备可以是带 Cortex-M33 的无线 MCU,也可以是安全要求较高的工业控制板。我们会讨论分区怎么切、密钥放在哪里、启动时校验什么、升级包如何设计、回滚防护如何做,以及调试和量产阶段最容易踩的坑。

TrustZone-M 安全启动与 OTA 架构

一、TrustZone-M 的基本模型

TrustZone-M 的核心是“安全属性”。CPU 取指、读写内存、访问外设、响应中断时,硬件都会判断当前访问属于 Secure 还是 Non-secure。这个属性不是单靠软件变量维护,而是由 SAU(Security Attribution Unit)、IDAU(Implementation Defined Attribution Unit)以及厂商外设安全控制器共同决定。简单说,SAU 更像内核侧的安全地址表,IDAU 则是芯片厂商预先定义的安全属性,例如某些系统寄存器或 OTP 区域天然只能由 Secure 访问。

典型工程会把 Flash 分成 Secure Bootloader、Secure Service、Non-secure App、OTA Slot、Scratch 或 Trailer 几类区域。Secure Bootloader 最先运行,负责配置时钟、最小外设、安全属性和镜像验证;Secure Service 提供少量可被普通应用调用的安全接口,例如读取设备证书摘要、发起签名验签、获取随机数、更新回滚计数;Non-secure App 是业务主程序,绝大部分代码都放在这里;OTA Slot 保存待升级镜像;Scratch 或 Trailer 用来记录升级状态、断电恢复信息和镜像确认标志。

与普通 MPU 的区别在于,TrustZone-M 不只是“禁止某段代码访问某段内存”。它还定义了跨世界调用方式。Secure 代码可以主动跳转到 Non-secure;Non-secure 如果要调用安全服务,只能进入被标记为 Non-secure Callable 的小入口,也就是 NSC veneer。这个入口通常非常薄,只做参数检查和转发,真正的密钥操作仍在 Secure 内部完成。这样做的好处是普通应用拿不到安全函数的任意入口地址,也无法随意跳进安全代码中间执行。

一个最小化设计原则是:Secure 世界越小越好。很多项目一开始把协议栈、文件系统甚至业务算法都塞进 Secure,结果不仅调试困难,还扩大了可信计算基。更稳妥的做法是把 Secure 当成“保险柜和门卫”:保险柜保存密钥与安全计数器,门卫判断镜像是否可信、请求是否合法。至于复杂业务,仍然放在 Non-secure 中,出问题也不应影响根信任。

二、启动链路:从 ROM 到业务应用

安全启动不是在应用入口前做一次哈希这么简单。它是一条链:芯片上电后先执行片上 ROM,ROM 根据厂商规则加载一级 Bootloader;一级 Bootloader 校验二级 Bootloader 或安全固件;安全固件再校验普通业务镜像;业务镜像运行后仍要通过“确认”机制告诉 Bootloader 新版本健康。链上任意一环没有验证,攻击者都可能在那里插入代码。

在 MCU 项目里,ROM 阶段通常由芯片厂商实现,我们能配置的内容包括启动源、调试权限、读保护等级、密钥哈希、生命周期状态和安全启动开关。有些芯片支持把公钥哈希写入 OTP,有些只能把根公钥放在安全 Flash,再用读保护保护。前者安全性更强,因为 OTP 一旦锁定,攻击者很难替换根公钥;后者实现更灵活,但必须认真处理安全区域擦写权限。

二级 Bootloader 可以选择 MCUboot、TF-M Bootloader,也可以自研。除非业务有非常特殊的镜像格式,建议优先基于成熟实现改造。成熟 Bootloader 已经处理了镜像头、签名 TLV、版本号、Swap 状态、断电恢复等细节,自研看起来不难,实际最容易在异常路径出问题。

下面是一段启动流程伪代码,重点不是语法,而是顺序:

int secure_boot_main(void) {
    early_clock_init();
    configure_sau_and_mpc();
    lock_debug_if_production();

    boot_state_t st = read_boot_state();
    image_t candidate = select_candidate_image(st);

    if (!verify_header(candidate)) {
        goto boot_recovery;
    }
    if (!verify_signature(candidate, root_public_key_hash())) {
        goto boot_recovery;
    }
    if (!check_security_counter(candidate.version)) {
        goto boot_recovery;
    }
    if (!verify_image_hash(candidate)) {
        goto boot_recovery;
    }

    configure_non_secure_vector(candidate.vector_table);
    jump_to_non_secure(candidate.reset_handler);

boot_recovery:
    enter_recovery_mode_or_previous_image();
}

这里有两个容易被忽略的点。第一,签名校验和哈希校验都要做。签名证明镜像来自可信发布者,哈希证明镜像内容未被修改。很多镜像格式会把哈希放进被签名的元数据里,Bootloader 校验签名后再计算整包哈希。第二,版本检查必须在启动阶段完成,而不是应用自己检查。应用已经被攻击者替换时,它当然可以谎报版本。

(第一部分完,约 2100 字)

三、Flash 分区:先画边界,再写代码

TrustZone-M 项目的成败,很大程度取决于分区设计。分区不是简单把 Flash 平均切两半,而是要同时考虑擦除粒度、升级策略、镜像大小增长、安全属性、写保护、日志磨损和量产工具链。一个常见的 2MB Flash 设备可以这样规划:前 128KB 放 Secure Bootloader,接着 256KB 放 Secure Firmware 与 NSC veneer,之后 640KB 放 Non-secure Active Image,再放 640KB 的 Non-secure OTA Slot,剩余空间用于 NVM、升级状态、证书和工厂参数。

如果安全固件也需要 OTA,分区会更复杂。安全世界升级风险更高,因为升级失败可能直接破坏启动链。工程上通常有三种策略:第一,Secure Firmware 很小且极少升级,只允许工厂刷写;第二,为 Secure 与 Non-secure 分别设置双槽,Bootloader 同时验证两者兼容性;第三,把安全服务做成可追加的版本表,Bootloader 固定不变,服务接口保持向后兼容。消费级设备常采用第一种,工业网关和长期维护设备更适合第二种或第三种。

分区表最好由单一配置文件生成,避免链接脚本、Bootloader 配置、烧录脚本和文档各写一份。下面是一个 YAML 风格的示例:

flash:
  base: 0x08000000
  size: 0x00200000
  erase_size: 0x00002000
regions:
  secure_boot:
    offset: 0x00000000
    size: 0x00020000
    attr: secure_rx
    write: factory_only
  secure_fw:
    offset: 0x00020000
    size: 0x00040000
    attr: secure_rx
    write: bootloader
  nsc_veneer:
    offset: 0x0005F000
    size: 0x00001000
    attr: nsc_rx
  ns_app:
    offset: 0x00060000
    size: 0x000A0000
    attr: non_secure_rx
  ota_slot:
    offset: 0x00100000
    size: 0x000A0000
    attr: non_secure_rw
  kv_store:
    offset: 0x001A0000
    size: 0x00020000
    attr: secure_rw

这类配置可以生成 linker script 片段、C 头文件、Python 打包脚本参数和产线烧录描述。团队里只要有一处手工同步,就迟早会出现“Bootloader 以为镜像从 A 地址开始,链接脚本却把向量表放在 B 地址”的事故。

四、密钥体系:设备密钥和发布密钥不要混用

安全启动至少涉及两类密钥:发布密钥和设备密钥。发布密钥用于签名固件,私钥必须保存在离线环境、HSM 或严格受控的 CI 机密系统中;设备端只保存公钥或公钥哈希。设备密钥用于设备身份、会话加密、数据封装或云端认证,它的私钥属于单台设备,不能出厂后被读取。二者绝对不要混用。把同一把密钥既用于固件签名又用于 TLS 客户端证书,是非常危险的设计。

固件签名建议使用 ECDSA P-256 或 Ed25519。许多 MCU 安全库和 PSA Crypto 对 P-256 支持更成熟,硬件加速也更常见;Ed25519 实现简洁,但要确认 Bootloader 和安全库的支持状态。哈希一般用 SHA-256。对资源很紧的设备,不要盲目追求复杂算法,验证路径的可靠性比算法名词更重要。

根公钥的更新也是一个实际问题。如果根公钥永远不能换,一旦私钥泄露,产品线就会非常被动;如果根公钥可以随意更新,攻击者又可能把自己的公钥写进去。常见做法是预置多个公钥哈希,或者用根密钥签发中间发布密钥。Bootloader 信任根公钥,日常发布使用中间密钥;需要轮换时发布一个被根密钥签名的密钥更新包。根密钥只在极少数情况下使用,放在更严格的离线环境。

设备密钥最好由芯片硬件唯一密钥派生,或者在安全产线注入后写入只读安全存储。对于支持 PSA Protected Storage 或 Internal Trusted Storage 的平台,可以把证书、私钥句柄和安全计数器放入安全存储,由 Secure Service 封装访问。普通应用只拿到句柄,不能读出明文私钥。

五、OTA 包格式:让 Bootloader 能独立判断

OTA 包不能依赖云端“说它合法”。设备离线、代理缓存、网络被劫持、升级包被拷贝到本地调试工具时,Bootloader 仍必须能独立判断镜像是否可信。因此升级包需要自描述:目标硬件、镜像类型、版本号、安全计数、长度、哈希、签名、压缩方式、加密信息和兼容性范围都应该在包里。

一个实用的镜像头可以包含如下字段:

typedef struct {
    uint32_t magic;
    uint16_t header_version;
    uint16_t image_type;      // secure, non-secure, radio, data
    uint32_t load_address;
    uint32_t image_size;
    uint32_t version_major;
    uint32_t version_minor;
    uint32_t security_counter;
    uint8_t  hw_model[16];
    uint8_t  dependency[32];
    uint8_t  image_hash[32];
    uint32_t tlv_offset;
} image_header_t;

签名覆盖范围应包括镜像头中除签名本身以外的关键字段以及镜像内容哈希。硬件型号字段可以阻止把 A 型号固件刷到 B 型号;dependency 可以声明“这个 Non-secure App 至少需要 Secure Service 2.3”。安全计数 security_counter 用于回滚防护,它不一定等于语义化版本号,但必须单调递增。

如果 OTA 包需要加密,建议采用“先签名后加密”还是“先加密后签名”要根据威胁模型定。很多 MCU 项目采用外层签名、内层加密或签名元数据加密载荷的组合。重点是 Bootloader 必须能在不泄露明文的情况下确认包来自可信发布者,并且只有目标设备或目标产品族能解密。加密不是安全启动的替代品,签名才是防止恶意固件的关键。

六、Non-secure Callable 接口:少即是多

NSC 接口是 Non-secure 世界进入 Secure 世界的门。接口越多,攻击面越大;参数越复杂,漏洞越难查。建议一开始只提供非常克制的一组能力:获取随机数、读取设备身份摘要、执行签名或解密操作、查询安全版本、提交升级确认、读取受保护配置。不要把“任意读安全存储”“任意写安全计数器”“传入函数指针回调”这类接口暴露出去。

下面是一个安全服务接口示例。注意它不返回私钥,也不允许调用方指定任意密钥地址:

__attribute__((cmse_nonsecure_entry))
int secure_sign_digest(uint32_t key_id,
                       const uint8_t *ns_digest,
                       size_t digest_len,
                       uint8_t *ns_sig,
                       size_t *ns_sig_len) {
    if (!cmse_check_address_range(ns_digest, digest_len, CMSE_NONSECURE)) {
        return PSA_ERROR_INVALID_ARGUMENT;
    }
    if (!cmse_check_address_range(ns_sig, *ns_sig_len, CMSE_NONSECURE)) {
        return PSA_ERROR_INVALID_ARGUMENT;
    }
    if (!is_key_allowed_for_non_secure(key_id)) {
        return PSA_ERROR_NOT_PERMITTED;
    }
    uint8_t local_digest[32];
    memcpy(local_digest, ns_digest, 32);
    return psa_sign_hash(key_handle_from_id(key_id), PSA_ALG_ECDSA(PSA_ALG_SHA_256),
                         local_digest, 32, ns_sig, *ns_sig_len, ns_sig_len);
}

两个检查很重要:第一,所有来自 Non-secure 的指针都要用 CMSE 辅助函数验证,防止普通应用传入 Secure 地址诱导安全服务读写;第二,参数复制到 Secure 本地缓冲区后再使用,避免校验后调用前被 Non-secure 中断修改,也就是常说的 TOCTOU 问题。对较长数据可以分块处理,但状态机必须保存在 Secure 侧。

(第二部分完,约 2300 字)

七、回滚防护:版本号不可信,单调计数才可信

回滚攻击很常见:攻击者不需要破解最新固件,只要把设备刷回存在漏洞的旧版本,就可能绕过认证、恢复默认口令或重新打开调试接口。解决办法是在安全存储中维护单调递增的安全计数器。Bootloader 只启动 security_counter 不小于已记录值的镜像;当新镜像通过试运行并确认健康后,再把计数器提升到新值。

计数器保存位置要抗篡改。理想情况是 OTP、 eFuse 单调计数器或安全元件;如果只有 Flash,就要用安全区域、冗余记录、写入序号和校验码降低掉电损坏概率。Flash 计数器不能无限擦写,因此不要每次启动都写,只在版本确认时写。对于有频繁灰度回退需求的产品,可以把“业务版本回退”和“安全计数回退”区分开:允许同一安全计数范围内的小版本切换,但不允许退到已经废弃的安全基线之前。

升级确认机制也不能省。新固件第一次启动时处于 pending 状态,应用完成自检、联网、关键外设初始化后调用安全服务 confirm_image()。如果设备在确认前复位多次,Bootloader 应自动回退到旧镜像或进入恢复模式。这样可以避免升级包能通过签名校验,却因为驱动 bug、配置不兼容或电源异常导致大面积变砖。

八、调试口、生命周期与量产

安全启动在实验室跑通不代表量产安全。很多事故发生在生命周期配置上:开发板调试口全开,量产脚本忘了切换保护级别;固件签名私钥放在工程目录;产线为了方便保留了全擦写权限;返修模式没有认证就允许刷机。这些问题不属于算法漏洞,却更容易被利用。

建议把设备生命周期分成 Development、Provisioning、Production、RMA 四个状态。Development 允许调试和未签名固件,但只能用于样机;Provisioning 注入设备证书、写入公钥哈希、配置安全区域;Production 关闭普通调试入口,只保留经过认证的维护能力;RMA 需要云端授权或挑战响应,进入后可以导出诊断日志,但不能导出密钥。

量产脚本要可审计。每台设备应生成一份简短的生产记录:芯片唯一 ID、公钥哈希版本、证书序列号、安全启动开关状态、调试锁状态、固件版本和写入时间。记录不需要包含敏感密钥,但要能在售后定位“这台设备到底有没有完成安全配置”。如果条件允许,产线工具应先读回关键锁定位确认,再打印合格标签。

九、断电恢复与双槽策略

OTA 最怕升级到一半断电。双槽 A/B 是最容易解释也最稳的策略:当前运行镜像在 Active Slot,下载的新镜像写入 Secondary Slot;写完后校验哈希和签名,设置 pending 标志;下次启动由 Bootloader 执行 swap 或直接从新槽启动;应用确认后标记 permanent。A/B 的代价是 Flash 占用翻倍,但对联网设备非常值得。

如果 Flash 不够,可以选择覆盖式升级加小 Scratch 区,但实现复杂度明显上升。Bootloader 需要按擦除块搬移数据,并在每一步写入状态,确保任意时刻断电都能继续或回退。不要只在 RAM 中保存升级进度,掉电后 RAM 没了,设备就不知道哪些块有效。

升级状态记录建议采用“追加写 + 魔数 + CRC + 序号”的小日志,而不是反复覆盖同一个结构体。启动时扫描最后一条有效记录即可恢复状态。这样既能提升掉电可靠性,也能减少单个 Flash 字段磨损。

十、在 Zephyr / TF-M 项目中的落地路径

如果项目基于 Zephyr,TrustZone-M 通常与 MCUboot、TF-M、Partition Manager、devicetree 和 Kconfig 一起使用。开发顺序建议是:先跑通官方安全样例,确认 Secure 与 Non-secure 镜像能启动;再固定分区和链接地址;然后引入 MCUboot 签名;接着增加自定义 NSC 服务;最后才接入云端 OTA。不要一上来同时改 Bootloader、TF-M、应用和云端协议,否则排查会非常痛苦。

常用检查命令包括查看镜像头、确认签名 TLV、检查 map 文件和反汇编向量表:

west build -b your_board_ns app
imgtool verify --key root_pub.pem build/zephyr/zephyr.signed.bin
arm-none-eabi-readelf -S build/tfm/secure_fw.elf
arm-none-eabi-nm build/zephyr/zephyr.elf | grep -E "__Vectors|veneer|cmse"

如果是裸机或 RTOS 自研工程,也可以借鉴同样的层次:Bootloader 单独工程,Secure Service 单独链接,Non-secure App 单独链接,三者通过生成的分区头文件共享地址。CI 中至少做三件事:未签名镜像必须启动失败;低安全计数镜像必须被拒绝;损坏一个字节的镜像必须被拒绝。安全功能没有自动化测试,后期很容易被“临时调试开关”破坏。

十一、常见故障排查清单

第一类问题是跳转 Non-secure 后 HardFault。优先检查 Non-secure 向量表地址是否对齐,MSP_NS 是否落在 Non-secure SRAM,Reset Handler 最低位是否为 1,SAU 是否把应用 Flash 和 SRAM 标成 Non-secure。还要确认 Secure 侧在跳转前关闭或重新归属不必要的中断。

第二类问题是 NSC 调用失败。检查 veneer 区域是否被标记为 NSC,链接脚本是否把 cmse veneer 放到预期地址,Non-secure 工程使用的导入库是否与 Secure 固件同版本。Secure 接口改了参数但 Non-secure 仍用旧头文件,是很常见的隐蔽错误。

第三类问题是签名验证在 Bootloader 里失败,但 PC 工具验证成功。通常是镜像覆盖范围、填充字节、端序、TLV 偏移或压缩后再签名的顺序不一致。建议把 Bootloader 实际计算出的哈希通过安全日志输出一次,与 PC 端对比;确认后关闭这类详细日志,避免泄露内部布局。

第四类问题是量产后无法调试。安全项目必须预留受控诊断方案,例如安全日志缓冲区、错误码持久化、RMA 授权流程和最小恢复固件。完全没有诊断能力的“安全”设备,现场维护成本会非常高。

十二、工程化测试:把攻击样例放进 CI

安全启动和 OTA 最需要自动化测试,因为它们平时很少被业务同事主动触碰,却会在发布脚本、分区调整、编译选项变化时被意外破坏。建议在 CI 中保留一组“坏镜像”:签名字段被清空的镜像、签名正确但内容被改一个字节的镜像、安全计数低于当前设备计数的镜像、目标硬件型号不匹配的镜像、依赖 Secure Service 版本过高的镜像,以及下载过程中截断的镜像。每次 Bootloader 或打包工具变更后,都要确认这些镜像不能启动。

板级自动化也很有必要。用继电器或可控电源在升级写 Flash、swap、首次启动确认前后随机断电,重复几百次,观察设备最终是否能回到旧版本、新版本或恢复模式之一。只要出现“既不是旧版本也不是新版本,串口也没有错误码”的状态,就说明状态机还不够稳。很多 OTA 事故不是验签失败,而是异常路径没有被真正跑过。

日志策略要平衡诊断和泄露。开发阶段可以打印镜像哈希、分区地址和错误细节;量产版本至少要降级为错误码和阶段码,例如 BOOT_E_SIGBOOT_E_COUNTERBOOT_E_SWAP_STATE。这些错误码应写入安全或半安全日志区,方便售后读取,但不要把公钥、证书私钥、解密后的载荷或随机数种子打印出来。

十三、工程化建议与总结

TrustZone-M 的价值不在于把产品宣传成“用了硬件安全”,而在于把几个关键事实变成硬约束:普通应用读不到密钥,未签名固件不能启动,旧漏洞版本不能回滚,升级断电可以恢复,量产状态可以审计。做到这些之后,TLS、设备证书、云端策略和应用权限才有可靠基础。

落地时可以按优先级推进。第一阶段,完成 Bootloader 签名校验和基本 A/B OTA;第二阶段,引入安全计数器与升级确认;第三阶段,把设备私钥迁移到 Secure Service,并收敛 NSC 接口;第四阶段,完善生命周期、产线审计和 RMA 流程。每个阶段都要有自动化测试和失败样例,不要只测试“正常升级成功”。

最后提醒一句:安全设计最怕“差不多”。CRC 差不多能发现传输错误,但不能抵抗恶意修改;读保护差不多能挡住普通读取,但挡不住被替换的 Bootloader;版本字符串差不多能显示新旧,但不能防回滚。TrustZone-M 给了 MCU 一套很好的隔离工具,但它只是一把刀,真正决定产品安全性的,是启动链、密钥体系、升级协议和量产流程能不能闭环。

(全文完,约 7300 字)