产线校准数据注入与防篡改实战:200 台里第 37 台用着上一台的系数——WRP 锁得再死也没用,它锁不住”主语写错”
某条 10kV 专线的月线损从 2.8% 跳到 5.9%。这条线上只有一个计量点——三个月前刚换的那台表。
现场用 0.05 级标准表串接比对:B 相电流偏低 3.0%,A、C 两相正常。拆回来我第一件事是读校准区——记录完好,CRC 对,签名对,时间戳是出厂那天,一切合法。
第二件事才是看 sn 字段。上面写着 B2606-0036。这台表的机壳、条码、芯片 UID,全是 0037。
这批一共 200 台,它是第 37 台。工装把上一台的记录写进了这一台。而设备的验签,用的是记录里那个 sn——它拿上一台的身份证,验上一台的签名,当然通过。签名没有失效,它只是验错了人。
这件事我查了三天,最后发现修复点不在工装(提高读 SN 可靠性),在设备(验签时必须绑定自己的 UID)。工装侧要改握手协议(两块固件、两周),设备侧改三行验签(半天)——成本差 20 倍,效果差一个数量级。
一、先把 WRP 的边界划清楚
WRP(Write Protection)是 STM32 选项字节里的写保护位。工程上关于它有三个常见误解,必须先拆掉,后面的账才算得对。
| 误解 | 实际 | 后果 |
|---|---|---|
| WRP 保护数据不被读出 | 只禁止编程/擦除,读出照旧 | 想保密必须叠加 RDP,两件事 |
| WRP 按页保护 | 按保护组,一组 4KB(2×2KB 页) | 160B 的记录要吃掉 4096B |
| 锁了就是安全的 | 只保护你指定的组 | 擦除上界写错,等于没锁 |
第三条最要命,第九节会单独算。
还有一条不算误解、但几乎没人算过的:解锁 WRP 的代价不是”改一个位”,是整片擦除。在 RDP level 1 下清除写保护会触发 mass erase,固件、参数、密钥、校准一起没。返修这件事从”接上标准源重调一遍”变成”重建整台设备”,第十二节有账。
所以 WRP 的定位要摆正:它不是”数据保险箱”,是”一次性写入信封”。信封一旦封上,里面的东西就定了;要改,得把信封撕了——而撕信封的时候,里面的东西也一起没了。
二、种子页放哪:4KB 保护组的税
我先说结论:4KB 里只用了 160B,利用率 3.9%,空耗 3936B。这是 WRP 的税,必须交。
| 方案 | 记录长度 | 保护组 | 利用率 | 空耗 |
|---|---|---|---|---|
| Ed25519 签名 | 160B | 4096B | 3.9% | 3936B |
| HMAC-SHA256 截断 16B | 128B | 4096B | 3.1% | 3968B |
512KB 全片 256 页(2KB/页),共 128 个保护组,种子页占 1 个——0.78% 的芯片面积换”出厂后不可改”。这个价我认为值,但它得从别的地方挪出 4KB 来。
补一句:不同容量档的 WRP 位数不一样,能不能精确保护到最后一组,得翻手册里那张对应表——这一步别猜,猜错了就是”以为锁了”。
《Flash 上跑 littlefs 落地实测》 那篇定的分区是 BL 16 + meta 2 + slotA 198 + slotB 198 + FS 96 + 预留 2 = 512KB,里面没有种子页的位置。当时校准参数是塞在参数区里的,靠 scope 白名单保护——那是软件保护,不是物理保护。
现在要挪:
| 区 | 旧 | 新 | 变化 |
|---|---|---|---|
| BL | 16KB | 16KB | 0 |
| meta | 2KB | 2KB | 0 |
| slotA | 198KB | 198KB | 0 |
| slotB | 198KB | 194KB | −4KB |
| seed | 0 | 4KB | +4KB |
| FS | 96KB | 96KB | 0 |
| 预留 | 2KB | 2KB | 0 |
| 合计 | 512KB | 512KB | 0 |
从 slotB 切,App 176KB,槽余量从 22KB 降到 18KB。18KB 还够一次功能迭代,可以接受。
为什么不在 FS 里切?littlefs 的元数据块是成对的(一对 block 互为备份),切 4KB 等于拆掉一对里的一半,实际必须切 8KB(96→88KB,块数 24→22),代价翻倍。从 app 槽切是 4KB,从 FS 切是 8KB,选前者。
也不建议和 BL 共用一组:BL 那 16KB 正好是 4 个完整组,边界对齐,插一个种子页进去会把 BL 的升级粒度搞乱。
三、160 字节:记录该长什么样
记录格式我改了三版,最后定成这样:
| 偏移 | 字段 | 长度 | 内容 |
|---|---|---|---|
| 0 | magic | 4 | CAL1,用于区分”从未写过”和”被擦过” |
| 4 | ver | 2 | 记录格式版本,迁移用 |
| 6 | flags | 2 | bit0=已闭环,bit1=已锁 WRP |
| 8 | sn | 16 | 芯片 96-bit UID 派生,不是手写字符串 |
| 24 | batch | 2 | 批次号 |
| 26 | n_coef | 1 | 系数个数,=12 |
| 27 | pad | 1 | 对齐 |
| 28 | ts | 4 | 校准时刻,UTC 秒 |
| 32 | coef | 48 | 12×float32 |
| 80 | crc32 | 4 | 覆盖 0..79 |
| 84 | sig | 64 | Ed25519,覆盖 0..83 |
| — | 合计 | 148 | 对齐 32B → 160B |
12 个系数是:三相电压增益 g_u[3]、三相电流增益 g_i[3]、三相相位补偿 φ[3](度)、三相角差补偿 k[3]。g_i 的正常分散范围 0.90~1.10,φ 在 ±0.5° 内。第 37 台需要 g_i[B]=1.000,拿到的是 1.031——差 3.1%,正好对上那条专线的线损缺口。
一条 160B 的记录,2KB 页能放 12 条(1920B)。但校准一辈子只写一次,这个密度毫无意义——记录长度在这里不是容量问题,是”能不能装进一个保护组”的问题。
四、串台:一行容错代码 = 18% 批次风险
工装那段代码长这样(很多家的工装都长这样):
sn = read_sn(retry=1, timeout=300ms)
if sn is None:
sn = last_sn # 容错:用上一次的
log.warning("SN read failed, fallback")
那台 0037 刚上电 1.2 秒,MCU 还在初始化外设,485 不应答。工装重试间隔 50ms,两次都撞在启动窗口里,超时。然后它很体贴地用了上一次的 0036。
日志里确实有一条 WARNING。产线每天 20 多条 WARNING,混着”夹具未夹紧””条码枪重复扫描””气压低”,没人会为一条 SN 告警停线。
这个 p(单台读 SN 失败且回落到缓存)取 1e-3 不算高估:
| p | n=50 | n=200 | n=1000 |
|---|---|---|---|
| 1e-4 | 0.50% | 1.98% | 9.52% |
| 3e-4 | 1.49% | 5.82% | 25.92% |
| 1e-3 | 4.88% | 18.14% | 63.23% |
| 3e-3 | 13.95% | 45.17% | 95.04% |
| 1e-2 | 39.50% | 86.60% | 100.00% |
p=1e-3、n=200 时,18.14% 的批次至少有一台串台,平均 0.2 台/批。每 5.5 批撞一次。
注意这张表的形状:风险与 p 成正比,p 降一个数量级,批次风险也降一个数量级——但永远不是零。你把读 SN 的可靠性做到 1e-6,n=1000 时仍有 0.1% 的批次中招。靠”提高可靠性”这条路,永远走不到 0。
走到 0 的方法是取消缓存路径:读不到就停线,人工补扫。p_escape 恒为 0,与 p 无关。但停线有成本,产线主管不会同意——所以真正的解法在设备端,第五节。
五、”写成功”不等于”写对”:四层验证各抓什么
| 层 | 手段 | 检出率 | 抓什么 | 漏什么 |
|---|---|---|---|---|
| L1 | 编程后回读比对 | 99.9% | 位没写进去、写坏 | 值本身是错的 |
| L2 | CRC32 | 99.999% | 随机位翻转 | 系统性错值 |
| L3 | Ed25519 验签 | 100% | 任何未授权修改 | 合法的错记录 |
| L4 | 标准源闭环 | 100% | 配错台、配错相 | 缓慢漂移 |
关键在 L3 和 L4 之间那道缝:L1~L3 全部通过,仍然可能是”上一台的正确记录”。因为它是合法签名的、合法的、CRC 对的记录,只是主语错了。
所以 L3 的验签必须这么写:
msg = my_uid_sn() || rec.ver || rec.coef || rec.ts // 用我自己的 UID
ok = ed25519_verify(pub, rec.sig, msg)
ok = ok && (rec.sn == my_uid_sn()) // 再比一次,防实现写错
而不是:
msg = rec.sn || rec.ver || rec.coef || rec.ts // ← 用记录自带的
差别只有一个词:主语。前者是”这条记录是不是给我签的”,后者是”这条记录有没有被改过”。后者叫完整性,前者叫授权——两个不同的安全属性,工程上极容易被合并成一个。
my_uid_sn() 从 0x1FFFF7E8 的 96-bit 唯一 ID 派生,取 8 字节转 hex,再补批内序号。不手工录入、不写死、不从外部读。手写字符串的 SN 是这一切的漏洞源头。
改完之后的行为变化:0037 收到 0036 的记录 → 用自己的 UID 算 msg → 验签失败 → 设备报 CAL_INVALID,拒绝进入计量模式 → 产线工位红灯。
故障从”出厂三个月后客户投诉”提前到”产线那台红灯”。 这就是全部的收益。
六、闭环验证:唯一能证明”这一台的系数是这一台的”
L1~L3 都是”数据层面”的验证,只有 L4 是”物理层面”的:接标准源,加已知功率,看设备读回多少。
工位是这么搭的:标准源输出 3×220V、3×5A、φ=0(纯有功),设备读 100 个周波(2 秒),算 P_read,比 P_ref。
- 标准源准确度 0.05 级
- 设备重复性实测 0.05%(100 周波平均后)
- 合成不确定度 √(0.05² + 0.05²) = 0.071%
- 判据 |e| ≤ 0.2%,等于 2.8 倍合成不确定度
- 给现场老化留 0.3%(0.5S 级限值 0.5% − 产线 0.2%)
判据 0.2% 不是拍脑袋:卡到 0.1% 的话,2.8σ 变 1.4σ,误判率从 0.5% 涨到 16%,产线一天废十几台好表;卡到 0.3% 又吃掉了现场余量。0.2% 是这两头之间唯一舒服的位置。
第 37 台如果做了 L4:0036 的 g_i[B]=1.031 用在这台上,闭环误差 3.1% ≫ 0.2%,当场判废。不需要任何”更聪明的校验”,只要把物理闭环接上。
12 个系数够不够:线性模型的边界
上面这套是线性模型——一个增益、一个相位,覆盖全量程。实际增益在 5% 和 100% 额定点上会差 0.1%~0.3%,不多,但对 0.5S 级是实打实的余量消耗。
三点校准(5%/20%/100%)每相多两组系数,记录从 12 系数涨到 24 系数 = 196B,对齐后 224B。仍是一页,保护组的税一点都不用多交——4KB 的保护组早就买下了,160B 还是 224B 都是 4KB。这是”按保护组付费”的唯一好处:记录可以随便长,只要不超过 4096B。
我暂时没上三点,因为现场这类 3% 的错跟线性度无关。但接口留着:n_coef=12 走线性,n_coef=24 走分段插值,靠 ver 区分——那两个字节就是为这个留的。
七、平衡负载会把证据擦掉
这里有个检定现场的反直觉细节,值得单独说。
单相增益错 3%,在平衡负载下总误差只体现 1/3:
| 工况 | 单相增益误差 | 总误差 | 超 0.5S 限值 | 年电量偏差 | 年金额 |
|---|---|---|---|---|---|
| 单相满载(不平衡) | 3% | 3.0% | 6.0× | 7,920 kWh | 4,752 元 |
| 平衡负载,一相错 3% | 3% | 1.0% | 2.0× | 2,640 kWh | 1,584 元 |
| 平衡负载,一相错 1% | 1% | 0.33% | 0.7× | 880 kWh | 528 元 |
(按 66kW、4,000h/年、0.6 元/kWh 估)
检定台默认跑平衡负载。1.0% 的误差,超 0.5S 限值 2 倍——检定员的判断是”这台需要修调”,不是”这台装错了系数”。于是他重写一遍系数,复测合格,出具报告。
串台的证据,被”修调”这个动作覆盖了。 记录里留下的是那次返工重烧之后的合法重校准,签名齐全,时间戳合理。
这次能查到,靠的是运气:它是 10kV 专线上唯一的计量点,3% 全暴露在线损上。同样一台表如果装在 200 台分户表里,3% 摊到总电量上只剩 0.015%——比线损的正常波动还小两个数量级,永远查不出来。
所以顺序必须是:产线闭环(L4)→ 锁 WRP → 出厂检定。检定不能当兜底,它会把证据洗掉。
八、什么时候锁:锁前锁后的顺序
写 160B
→ 回读逐字节比对 (L1)
→ CRC32 (L2)
→ 验签(用自己的 UID) (L3)
→ 标准源闭环 (L4) ← 不合格在这里停,别往下走
→ 锁 WRP(写选项字节)
→ 复位
→ 读回选项字节,逐位比对
→ 再验签 + 再闭环一次
三个容易漏的点:
一、锁之前必须闭环完。 锁完再发现系数错,就得解锁,而解锁 = 整片擦除(第十二节)。顺序反了,返修成本从 5 分钟变 23.4 分钟。
二、改选项字节是整页擦除再写。 选项字节那一页里同时住着 RDP、WRP、用户选项字节。你只想改 WRP,但硬件是”擦掉整页,再写回全部”。所以必须:先读出全部选项字节 → 只改 WRP 位 → 整体写回 → 读回逐位比对。只写一个字节的实现,会把 RDP 顺手改成 0(无保护)。
三、锁完必须再验一次,而且要比对选项字节。 比对的是”读回的 WRP 位图 == 期望位图 && RDP == 期望值”。不相等就判废,不要”再锁一次”——第二次写可能把第一次的错掩盖成另一个错,而你失去了判断依据。
九、锁了之后怎么升级:擦除白名单
WRP 锁的是种子页,升级擦的是 app 槽,两者不重叠——理论上是这样。
实际上《STM32 Bootloader 与 IAP 远程升级实战》 里那个擦除循环,上界写的是 FLASH_END:
for (addr = APP_ADDR; addr < FLASH_END; addr += PAGE) flash_erase(addr);
APP_ADDR= 0x08004000SEED_ADDR= 0x0807F000FLASH_END= 0x08080000
正确上界是 SEED_ADDR,擦 246 页。写成 FLASH_END 就多擦 2 页——正好是种子页。
而且 WRP 这时候也救不了:WRP 锁的是”禁止编程/擦除”,擦除操作会直接失败返回错误码。如果 bootloader 不检查返回值(绝大多数不检查),它会以为擦成功了,继续往下走,最后跳进 app。
症状:升级后校准区全 0xFF → app 用默认系数(g=1.0、φ=0)→ 误差 0.5%~2% → 不报错、不告警、不重启,就是慢慢算不准。
怎么区分”被擦过”和”从未写过”:
| 现象 | 被擦过 | 从未写过 |
|---|---|---|
| magic | 0xFFFFFFFF | 0xFFFFFFFF |
| ts | 0xFFFFFFFF | 0xFFFFFFFF |
| 同批其他设备 | 有正常记录 | 全都没有 |
| 工单记录 | 有校准时间 | 无 |
单看这台设备分不出来,得靠批内比对 + 工单。这也是我坚持 magic 用 CAL1 而不是 0xFFFFFFFF 的原因——出厂就该有值。
硬一点的做法:擦除函数只接受白名单区间,越界直接 assert。编译期用 static_assert(SEED_ADDR % PAGE == 0),运行时用 if (addr >= SEED_ADDR) return ERR_RANGE。别指望下一个人记得改常量。
十、HMAC 还是签名:密钥放在谁手里
137 篇 结尾留的坑是”HMAC token 的密钥怎么在产线和现场之间分配”。我算完账,结论是这个分配问题不存在,因为不该用 HMAC。
| 维度 | HMAC-SHA256 | Ed25519 |
|---|---|---|
| 验证成本 | 62µs | 38.9ms(625×) |
| 记录长度 | 128B | 160B(都是 1 页) |
| 密钥持有终端 | 12 台工装 | 1 台签发机 |
| 5 年内至少一次泄露 | 70.2% | 2.5%(28.4×) |
(验证成本按 M3@72MHz 估:HMAC 128B×35 cycles/B;Ed25519 验签 2.8M cycles。泄露面按 12 台工装、单台年泄露 2%、服务器年 0.5% 估,都是假设值,换参数改三个常量重跑。)
HMAC 的致命问题不是慢,是它要求验证方持有密钥。设备要验 HMAC,就得把密钥烧进固件;工装要签发,也得持有同一个密钥。12 台工装 × 5 年,70% 概率至少泄露一台。密钥一旦泄露,伪造能力就是全局的。
签名把这件事翻过来了:工装不持有任何长期密钥。它只是个搬运工——按 SN 向签发服务器要 160B 记录,拿到什么就写什么,写完不知道里面是什么。
那工装被逆向、被偷、被员工拷走,能拿到什么?公钥 + 一批已经签好的 160B 记录。伪造不出新记录。
断网怎么办?按工单预签发 200 条,工装本地缓存。这里就回到第四节那个”缓存”——但这次的缓存是安全的:每条记录都绑了设备 UID,错配必然验签失败。同一个”缓存”动作,在 HMAC 方案里是系统性缺陷,在签名方案里是无害优化。
这就是”绑定主体”的复利:它不只修了一个 bug,它让一整类优化变得安全。
十一、私钥不可撤销:泄露半径与两个公钥槽
上一节把签名说得太好,得补一句反面:签名私钥泄露的后果比 HMAC 严重。
- HMAC 密钥泄露:12 台工装重刷,4 小时,完事。
- 签名私钥泄露:在役设备全部要换公钥——而公钥在固件里,换公钥 = 一次全量固件升级。
所以私钥的管理标准要高一个等级:
| 密钥 | 存放 | 轮换 | 泄露影响 |
|---|---|---|---|
| K_master(签名私钥) | 离线 HSM / 不联网 | 永不 | 全部设备可被签发假校准 |
| Key_pub | 设备固件(只读) | 随固件 | 仅验证,无风险 |
工程上留两条缝:
一、设备保留 2 个公钥槽 + key_id。 记录里带 key_id,设备按 id 选公钥验证。第二槽的用途是预置下一把公钥:先随一次常规固件升级把新公钥塞进去,等现场铺开了再启用新私钥——而不是换钥那天才发现所有在役设备都验不过。
二、威胁模型要写清楚,别自己吓自己。 假校准的收益是什么?不是”控制设备”,是让电表少计 3%。这是个商业欺诈模型,攻击者是有动机改系数的人(用电大户、想省检定费的集成商),不是远程黑客。他拿不到你的私钥,他能做的是:现场拆机改 flash(WRP 挡住)、伪造一条记录(签名挡住)、或者买通产线(这条挡不住,任何技术都挡不住)。
把威胁模型写成”防国家级攻击者”,会导致过度设计(白盒密码、反调试壳),而《固件防逆向与混淆实战》 已经算过:Chow 白盒 704KB 是 flash 的 138%,裸机上根本装不下。
校准这条线上,WRP + 签名 + UID 绑定,已经覆盖了全部有经济动机的攻击面。
复检到期:有效期不能在设备里判
0.5S 级工业表一般 5~6 年一检(按 6 年估,具体看当地规程)。记录里只有 ts,没有”有效期”字段——到期时间 = ts + 6 年,算在上位机,不算在设备。
原因很直接:设备判到期就得读 RTC,而 RTC 会被改、会掉电、会在换电池时归零。让设备用自己的时钟判断自己该不该被检定,等于让被告当法官。
设备的职责只有一条:把 ts 如实报上去,且这个 ts 不可改(改就要重签,重签要私钥)。判定、告警、停结算,全在上位机。
到期后的行为也要定死:不锁表(不能停用户的电),置”需复检”标志上报 EMS,同时标记该表数据不用于结算。这三件事分开做,别合成一个”过期即停”。
十二、返修悖论:解锁 = 整片擦除 = RDP 归零
WRP 最贵的账不在产线,在返修。
| 步骤 | 耗时 | 说明 |
|---|---|---|
| 解锁 → 等效整片擦除 | 7.7s | RDP 归零,固件与密钥一起没 |
| 重烧固件(双槽) | 15s | 130 篇 的双槽布局 |
| 重新注入密钥 | 10min | 130 篇 的产线注入工装 |
| 重校准(标准源闭环) | 5min | 同产线工位 |
| 拆装与复测 | 8min | 开壳、灌胶、老化复测 |
| 合计 | 23.4min | 不锁 WRP 只要 5min,4.7× |
擦除本身只占 7.7 秒(1%)。贵的是擦完之后要重建的一切——这是所有”安全换便利”决策里最容易被低估的一项。
而这里有个真正的悖论:
解锁 WRP 触发 mass erase,RDP 一起归零。所以返修过的设备,比出厂设备更容易被读出固件。
出厂时 RDP=1、WRP=锁;返修后如果流程漏了”擦完立即恢复 RDP+WRP”,这台设备就是 RDP=0 的裸片。而这个”立即恢复”的动作,通常发生在产线之外的维修点——安全链上最薄的一环,恰好是最没纪律的一环。
三种处理方式,我选第三种:
- 现场解锁返修 —— 不选,安全链断在最不可控的地方。
- 维修点解锁 —— 还是不选,除非维修点有产线级工装和密钥。
- 板级替换:维修点只换板,坏板回厂统一解锁、重烧、重校准。
第三种把”解锁”这个高危动作收敛回产线,代价是备件板库存。一块 4 层板成本 30 元,一次安全事故的成本不是 30 元能比的。
软件封印不能自证
铅封拆了能看出来,WRP 解了看不出来。
设备端的现实:解锁 → 改系数 → 重锁,读回来的选项字节和记录跟原厂一模一样。WRP 位还是 1、ts 还是出厂时间(如果没人动它)、签名还是原厂那份(如果系数没变)——没有任何一个字节能证明”这中间被打开过”。
这是 WRP 方案的根本局限,绕不过去。能补的只有三条:
| 手段 | 能证明什么 | 代价 |
|---|---|---|
| 一次性计数页(每次解锁 +1) | 解锁次数 | 解锁触发整片擦除,计数页也没了 |
| 上位机台账(SN → 工单 → 复检时间) | 谁、何时、做了什么 | 依赖流程纪律,不进设备 |
| 铅封号绑定进记录 | 物理封印未动 | 需人工录入,多一道错源 |
第一条看着最像技术解,实际是死路——整片擦除会把计数器一起擦掉,计数器活不过它要记录的那个事件。用被保护对象记录对它的攻击,这个套娃没有出口。
第二条才是真解,但它不在设备里,在流程里。工程上必须接受一件事:WRP 保证”出厂后没被改”,保证不了”从来没被改”。前者是技术问题,后者是管理问题。
这也是我坚持 ts 不可改的原因:改 ts 就要重签,重签要私钥,而新 ts 会跟工单对不上。用签名把台账逼回流程——技术做不到的事,让流程的痕迹留在技术上。
十三、产线节拍:瓶颈从来不在 Flash
单台最坏路径实测:
| 步骤 | 耗时 | 占比 |
|---|---|---|
| 读 SN(含 1 次重试) | 600.0ms | 10.2% |
| 擦种子页(最坏 40ms) | 40.0ms | 0.7% |
| 编程 160B(80 半字) | 5.6ms | 0.1% |
| 回读比对 + CRC | 2.0ms | 0.0% |
| 锁 WRP + 选项字节 + 复位 | 160.1ms | 2.7% |
| 锁后自检(验签一次) | 38.9ms | 0.7% |
| 闭环:标准源稳定 | 3000.0ms | 51.2% |
| 闭环:100 周波 @50Hz | 2000.0ms | 34.1% |
| 闭环:计算与写工单 | 10.0ms | 0.2% |
| 合计 | 5.86s | 100% |
200 台 19.5 分钟。单台 5.86s,占 30 秒节拍的 19.5%——放得进产线。
看占比:闭环验证 86%,Flash 操作 1%。为了省 40ms 的擦除去换更快的编程算法,是把 1% 的项优化 30%——0.3% 的收益,风险和调试成本远大于此。
另一个反直觉的点:验签 38.9ms 只付一次。WRP 保证了种子页之后不会被改,所以验签结果缓存在 RAM 里,运行期不再验。如果没锁 WRP,你就得每次启动都验(启动预算从 180.4ms 涨到 219.3ms,130 篇 的账),或者接受”运行中可能被改”的风险。
WRP 的价值不只是防篡改,它还把”每次验签”变成”一次验签” ——38.9ms 从启动路径上消失了。这是很少被提到的协同效应。
十四、triage:现场八个症状
| 症状 | 判据 | 动作 |
|---|---|---|
| 验签失败 | 签名对不上 | 拒绝使用,进服务模式;不要静默回落默认值 |
| 缺记录(全 0xFF) | magic 与 ts 全 FF | 查 bootloader 擦除上界,不是现场故障 |
| 平衡负载误差 1% | 超 0.5S 两倍 | 先查台号绑定,可能是上一台的系数 |
| 单相误差 3%、其余正常 | 增益单相偏 | 同上 + 查相序映射 |
| 所有设备 CRC 相同 | 记录逐字节一致 | 工装用了固定值,批次召回 |
| ts 晚于出厂日期 | 时间戳倒挂 | WRP 未生效或被解锁过 |
| 校准在但误差随时间漂 | 闭环复测超差 | 器件老化,走返修流程(板级替换) |
| WRP 位=0 但记录完好 | 选项字节被清 | 解锁过一次,按返修设备处理 |
第一行那条”不要静默回落默认值”,值得展开一句:默认系数(g=1.0、φ=0)能让设备继续跑,误差 0.5%~2%,用户看不出来。看起来很”友好”,实际上是把一次可检测的故障变成了一次不可检测的计量偏差。校准失效必须是硬故障,不能是降级。
十五、清单
- ☐ 验签的 msg 用设备自己的 UID 派生 sn,不用记录里的 sn;验完再比一次字段
- ☐ sn 从 96-bit UID 派生,不手工录入、不写死常量
- ☐ 校准记录独占一个 4KB WRP 保护组;从 app 槽切 4KB,不从 littlefs 切 8KB
- ☐ 顺序:写 → 回读 → CRC → 验签 → 闭环 → 锁 → 复位 → 读回选项字节逐位比对 → 再验
- ☐ 改选项字节必须”读全部 → 改一位 → 写全部”,不能单字节写
- ☐ 擦除上界是常量 SEED_ADDR,不是 FLASH_END;擦除函数加越界 assert
- ☐ 工装不持有任何长期密钥,只搬运服务器签好的 160B 记录
- ☐ 私钥离线/HSM;设备留 2 个公钥槽 + key_id
- ☐ 校准缺失进服务模式,不静默回落默认值
- ☐ 维修点只换板不解锁,坏板回厂统一处理
这篇的所有数字都在 drafts/_tools/cal_injection_wrp_calc.py 里(rec/wrp/layout/cross/err/prog/line/ver/hmac/key/repair/erase/triage/table 十四种模式,selftest 34 项全过)。换平台、换页大小、换批量,改开头几个常量重跑就有新账。
(文中平台参数按 STM32F103 高容量 2KB 页、72MHz 推演;Ed25519 验签 2.8M 周期、HMAC 35 cycles/B、工装与服务器泄露率均为工程假设,换器件请以实测为准;动 WRP 之前先备份选项字节,解锁会连固件一起擦。)
下一篇预告:《相序、CT 极性与缺相:接线错误的自诊断》——校准系数对、签名对、闭环也过,但现场 CT 装反了,负荷为 0 时读数是负的;这类错误不会让任何一级校验失败,只会让电量悄悄变少或者变多。校准这条线往下走一步,就到接线了。