3、固件签名与验证:非对称加密基础(RSA/ECC),哈希算法(SHA-256)的应用,固件签名的生成与验证流程。

说到固件安全,签名验证是绕不开的一环。我刚开始做电机控制时,总觉得这玩意儿是「大厂才需要考虑的事」。直到有一次,客户现场反馈说电机启动时偶尔会「抽风」,查了三天才发现是有人用烧录器刷了个来路不明的固件进去。嗯,从那以后,我再也不敢跳过签名验证了。

说白了,固件签名就是给你的固件打上一个「防伪标签」。别人想伪造?门儿都没有。今天我们就来拆解一下,这个标签到底是怎么打上去的,又是怎么被验证的。

3.1 非对称加密:公钥与私钥的「锁与钥匙」

非对称加密,你可以想象成一把特殊的锁和两把钥匙。一把叫私钥,你藏好,谁都不给看。另一把叫公钥,你可以随便发给别人,甚至贴在墙上。

有意思的地方在于:用私钥加密的东西,只有公钥能解开;用公钥加密的东西,只有私钥能解开。这个特性,恰好可以用来做签名。

目前主流的非对称加密算法有两种:RSA 和 ECC(椭圆曲线密码学)。我个人的习惯是,在资源受限的 MCU 上优先选 ECC,因为它密钥更短,算力消耗更小。

对比项 RSA ECC
密钥长度(安全等价) 2048 位 256 位
签名速度 较慢 较快
验证速度 较快 较慢
存储占用 大 小
典型应用 PC/服务器端 嵌入式/IoT 设备
我的经验:在 Cortex-M4 级别的 MCU 上,ECC-256 的签名验证大约需要 50-100ms,而 RSA-2048 可能需要 200ms 以上。如果你的电机启动有严格的时间要求,ECC 是更稳妥的选择。

3.2 哈希算法:给固件「算个指纹」

你想想看,固件可能几百 KB 甚至几 MB,直接用非对称加密去签名整个固件,计算量太大了。这时候就需要哈希算法出场。

哈希算法,说白了就是给固件算一个「数字指纹」。不管你的固件有多大,算出来的哈希值都是固定长度。而且,只要固件里有一个 bit 变了,哈希值就会变得面目全非。

我们这里用的是 SHA-256,它输出 256 位(32 字节)的哈希值。这个长度在安全性和效率之间取得了很好的平衡。

核心逻辑:

  • 对固件做 SHA-256 哈希,得到 digest(摘要)
  • 用私钥对 digest 加密,得到 signature(签名)
  • 验证时,用公钥解密 signature,得到 digest1
  • 再对固件做 SHA-256,得到 digest2
  • 如果 digest1 == digest2,固件就是可信的

3.3 签名生成流程:在 PC 端完成

签名生成通常是在你的开发 PC 上完成的,MCU 只负责验证。流程大概是这样的:

  1. 编译固件:生成 .bin 或 .hex 文件
  2. 计算哈希:对固件文件做 SHA-256
  3. 私钥签名:用 RSA 或 ECC 私钥加密哈希值
  4. 打包:将原始固件 + 签名 + 公钥(可选)打包成一个文件

这里有个细节要注意:打包的时候,签名通常放在固件的末尾或者头部。我习惯放在头部,这样 Bootloader 读取起来更方便。

# 伪代码示例:签名生成
firmware_data = read_file("motor_ctrl.bin")
digest = SHA256(firmware_data)
signature = ECC_sign(private_key, digest)
packed = firmware_data + signature + public_key
write_file("motor_ctrl_signed.bin", packed)

3.4 签名验证流程:在 MCU 上完成

MCU 端的验证,是电机安全启动的核心。每次上电,Bootloader 都会执行以下步骤:

  1. 读取打包固件:从 Flash 中读取固件数据、签名和公钥
  2. 计算哈希:对固件数据做 SHA-256
  3. 公钥解密:用预置的公钥解密签名,得到原始哈希
  4. 比对:如果两个哈希一致,跳转到固件入口;否则,进入错误处理
我曾经踩过的坑:公钥的存储位置一定要小心。我见过有人把公钥放在固件里一起烧录,那签名还有什么意义?攻击者完全可以替换掉公钥。正确的做法是:公钥烧录在 MCU 的 OTP(一次性可编程)区域,或者由 Bootloader 硬编码在只读区域。
// 伪代码示例:MCU 端验证
uint8_t* fw_data = (uint8_t*)FLASH_APP_ADDR;
uint32_t fw_len = get_firmware_length();
uint8_t* signature = fw_data + fw_len;
uint8_t* public_key = signature + SIGNATURE_LEN;

uint8_t digest1[32];
SHA256(fw_data, fw_len, digest1);

uint8_t digest2[32];
ECC_verify(public_key, signature, digest2);

if (memcmp(digest1, digest2, 32) == 0) {
    jump_to_app();
} else {
    error_handler();  // 固件被篡改,停止启动
}

3.5 整体流程可视化

为了让你看得更清楚,我画了一张流程图。它把签名生成和验证两条线放在一起对比,一目了然。

固件签名与验证流程 PC 端:签名生成 ① 编译固件(.bin) ② SHA-256 计算哈希 ③ 私钥签名(ECC/RSA) ④ 打包:固件 + 签名 + 公钥 ⑤ 烧录到 MCU Flash MCU 端:签名验证 ① 读取固件 + 签名 + 公钥 ② SHA-256 计算哈希(digest1) ③ 公钥解密签名(digest2) ④ 比对 digest1 == digest2? ✅ 一致:启动 ❌ 不一致:报错 烧录/传输 💡 关键点: • 私钥永远不出 PC,公钥烧录在 MCU 的 OTP 区域 • 哈希比对在 MCU 内部完成,不依赖外部输入 • 一旦验证失败,电机绝不启动,进入安全锁定状态

3.6 避坑指南:我踩过的几个雷

  • 公钥存储位置:前面说过了,别放固件里。OTP 或者 Bootloader 硬编码是最安全的。
  • 签名长度固定:ECC-256 的签名是 64 字节,RSA-2048 是 256 字节。解析打包固件时,别把长度算错了。
  • 哈希计算范围:SHA-256 只对固件代码段计算,不要把签名本身也算进去。否则你永远验证不过。
  • Bootloader 自更新:如果 Bootloader 本身也需要更新,那它的签名验证逻辑谁来保证?这是个鸡生蛋蛋生鸡的问题。我一般把 Bootloader 放在写保护的 Flash 区域,永远不更新。
一个小技巧:调试阶段,可以在 MCU 上留一个「测试模式」的跳线。跳线短接时跳过签名验证,方便开发。但量产时一定要把这个功能去掉,或者用 OTP 位永久关闭。我曾经见过有人忘了去掉,结果客户自己刷了个盗版固件,把电机烧了。