一、两个版本:主程序版与加密版
我第一次在量产工具里看到“同一个固件要生成两个配置”的时候,是有点意外的。 当时那个项目用芯片厂商提供的配置工具生成了两份工程配置,名字分别是 “主程序”和“加密”,两次生成的时间只差两分钟, 说明它们是被一起打包、分别烧录的。
为什么要有两个版本?因为“给研发和产线用的版本”与“给最终用户设备用的版本”, 需求本来就是相反的:
| 维度 | 主程序版(调试/产线) | 加密版(出货) |
|---|---|---|
| Flash 读保护 | 关闭,方便读回与对比 | 开启 |
| 调试口 | 保留 SWD,可随时挂调试器 | 按需关闭或复用为普通 IO |
| 串口打印 | 打开,能看到内部状态 | 关闭,避免占用带宽与泄漏信息 |
| 看门狗 | 可暂时关闭以便单步调试 | 必须开启 |
| 产测入口 | 可通过特定条件进入 | 关闭或隐藏 |
关键点不在于“要分两个版本”,而在于这两个版本必须来自同一份源码。 如果为了省事,产线烧的是 A 分支、出货烧的是 B 分支,那么“产线测过的固件” 和“用户手里的固件”就不是同一个东西了——产测的全部意义会被抵消掉。 正确的做法是:同一份源码,通过编译宏或配置工具切换这些选项, 并且把“这次生成的是哪个版本”记录在案。
① 在开发阶段明显碍事,② 忘记做的后果不会在实验室暴露,③ 一旦漏掉就是批量性的问题。
二、软硬件版本必须能互相确认
这一条我在两个项目里都改过,改动记录里的原话是“修改了软件版本与硬件版本的对应关系”和 “修改了硬件版本与软件版本的对应逻辑”。听起来是件小事,但它解决的是一个很实际的困境。
2.1 问题是什么
硬件改版之后,同一款产品会同时存在几批不同版本的板子:换了传感器的、改了分压电阻的、 换了显示驱动的。如果固件不区分硬件版本,就会出现两种糟糕的情况:
- 老板子烧了新固件——按新硬件参数去算,读数全错,但设备看起来在正常工作;
- 新板子烧了老固件——同样读数错,而且因为“能跑”,现场很难判断是固件问题还是硬件问题。
2.2 做法
解决办法很直接:在参数区里放两个只读寄存器——软件版本号和硬件版本号, 两个都由固件写入、都能通过总线读出来。硬件版本号通常来自几个硬件版本识别引脚的电平组合, 或者在产测时写入参数区;软件版本号直接由编译期常量给出。
/* 参数区里预留只读的版本寄存器,任何时候都能通过总线读出来 */
#define RW_REG_SOFTWARE_VERSION 58 /* 只读:软件版本 */
#define RW_REG_HARDWARE_VERSION 60 /* 只读:硬件版本 */
/* 上电时把编译期版本号写进寄存器映射区,供上位机读取 */
static void version_regs_init(void)
{
rw_regs[RW_REG_SOFTWARE_VERSION] = (uint16_t)(SOFT_VERSION & 0xFFFF);
rw_regs[RW_REG_SOFTWARE_VERSION + 1] = (uint16_t)(SOFT_VERSION >> 16);
rw_regs[RW_REG_HARDWARE_VERSION] = (uint16_t)(read_hardware_id() & 0xFFFF);
rw_regs[RW_REG_HARDWARE_VERSION + 1] = (uint16_t)(read_hardware_id() >> 16);
}这样做的收益是:不用拆机、不用连调试器,就能确认“这台设备现在跑的到底是哪一版”。 现场只要有一条通信链路,版本确认就是一次读寄存器的事。 更进一步,固件在启动时可以自己检查“我这一版固件是否支持这个硬件版本”, 不匹配就置一个明确的故障码,而不是默默用错误的参数继续跑。
三、出厂状态:怎么判断这是一块新板子
新板子第一次上电时,参数区是空的(擦除后的状态通常是全 0xFF),
固件需要识别出这一点并写入出厂默认值。这个判断看起来很简单,但做法有讲究。
3.1 常见的两种判定方式
- 判空片:读参数区首地址,如果是
0xFFFFFFFF, 就认为没写过,写入默认值。这个做法我见过多次,实现只需一行。 - 判魔数:在参数区头部放一个固定魔数(例如某个特定值), 读出来不等于这个值就认为未初始化。
3.2 这两种做法的共同问题
它们只能区分“完全没写过”和“写过”,不能区分“写过了但内容不对”。 而后者恰恰是最常见的:结构体增删成员导致布局变化、写入中途掉电导致半新半旧、 参数区被误擦除后残留了非 0xFF 的内容。
我自己就踩过这个坑的相邻版本:一个参数的结构体整体校验是通过的(CRC 对得上), 但其中某个成员从来没有被赋过默认值,结果是 0。设备能开机、能通信、CRC 也过, 只有那个功能不正常。“CRC 通过”只证明数据没被破坏,不证明数据是对的。
3.3 我会用的做法
- 参数区头部放 魔数 + 结构体长度 + 参数版本号,三者任一不符即视为不可用;
- 不可用时不直接信任原内容,而是整体写入出厂默认值;
- 默认值来源必须覆盖每一个成员,并且在代码里用编译期断言或 “结构体长度 = 各成员长度之和”这类检查防止漏掉;
- 加载完成后逐项做范围检查,超出范围的一律替换为默认值并记录一条事件。
四、把产测流程做进固件:好处与代价
产测有两种做法:一种是固件里烧一版专门的测试程序,测完再烧正式固件; 另一种是把测试逻辑做进正式固件,通过某个条件进入测试模式。
4.1 我见过的做法
有一个项目里,产测是做进固件的——源码里有独立的自动产测模块和出厂状态模块, 产线不需要额外的工具链,上电后按特定条件就进入产测流程。另一个项目则做了一套 独立的检测工装:工装作为主机,通过总线对被测板执行一整套检测工序, 并把每一步结果显示在自己的屏幕上。
还有一种更极端的形态:发卡/建卡类设备,它的“出厂流程”本身就是固件的核心功能, 里面内置了一整套诊断码,从格式化、建密钥文件、装各类密钥、建应用文件、 到读回验证,每一步都有独立的诊断码。这套流程的成熟度,其实就代表了这类产品的成熟度。
4.2 两种做法的取舍
| 维度 | 产测做进固件 | 独立检测工装 |
|---|---|---|
| 产线所需设备 | 少,只需要上位机 | 多,需要工装本身 |
| 与固件版本的关系 | 天然绑定,不会错配 | 需要工装与被测固件协议对齐 |
| 测试逻辑修改成本 | 要重新出固件 | 改工装即可 |
| 占用固件空间 | 会占用,且必须与正式逻辑严格隔离 | 不占用 |
| 误触发风险 | 有,条件设计不当会被用户误入 | 无 |
所以我现在对任何检测工装的最低要求是: 必须能构造出一个必然失败的情形,并确认工装会报失败。 测不出失败的测试,等于没测。
五、参数版本与默认值:设备必须永远能开机
这一条的底线只有一句话:任何情况下,设备都要能开机。 参数坏了可以恢复默认值,但绝不能因为参数问题卡在启动阶段。
为了做到这一点,有几件事是必须的:
- 加载失败要有明确的兜底路径,而且兜底路径本身的代码必须极简、不依赖任何可能出错的模块;
- 不要在启动早期把参数校验失败做成死循环。这一点我在 另一篇笔记里专门写过: 死循环会让现场完全无法判断故障原因,也会让售后无法远程恢复;
- 参数结构体要固定布局,避免因为对齐填充、成员顺序变化导致老参数被错位解读;
- 参数版本号要为将来留路。当参数结构体需要变更时, 先读版本号再决定用哪套解析规则,而不是假设“参数结构永远不会变”。
六、看门狗:为什么它总是排到最后
这是一个我在多个项目里反复观察到的现象,而且我自己也犯过。
6.1 现象
- 有两个项目的源码里,完全找不到看门狗的初始化代码;
- 其中一个项目的开发记录里,最后一条注意事项写的正是“程序基本开发完成的时候, 需要开启看门狗并测试”——而这条一直没被完成;
- 另有两个项目,看门狗是在开发后期的同一天补上的,补的时候还牵出了别的问题: 休眠时没人喂狗,导致唤醒后立刻被复位,最后不得不把定时器从 “计时”改成“周期性唤醒喂狗”,原来的计时功能再迁到另一个定时器上。
6.2 为什么会这样
因为看门狗在开发阶段是纯粹的阻力:单步调试时会超时复位、 停在断点时会复位、写 Flash 时间长一点也会复位。所以很自然地就被关掉、然后被遗忘。
但它又是那种“不加就一定会出问题”的东西:现场受到干扰、某个外设卡死、 某个 while 等待条件永远不满足——没有看门狗,设备就是死在那里等人来断电重启。
6.3 我现在的建议
- 把看门狗的加入时间提前到“通信打通之后立刻做”, 而不是临近发布。这样后面所有调试过程都天然在“有看门狗”的条件下进行, 不会在最后阶段引入一个可能到处触发复位的新变量;
- 先算清楚看门狗的超时时间。用实际的分频与计数上限反算, 例如分频后约 244 Hz、计数上限 65536 时超时约 2.68 秒, 然后逐个检查主循环里最长的操作有没有超过它;
- 喂狗点要放在“所有关键任务都还在跑”的证据上, 而不是随便找个循环。理想做法是每个任务分别置一个标志位, 喂狗前检查所有标志都置位;
- 休眠场景要单独处理:要么用定时器周期性唤醒喂狗, 要么在休眠等待循环里按标志喂狗。
七、调试代码进发布版:两个真实事故
这两件事都来自我在读代码时的发现,而且都属于“平时没事,一出事就是大范围”的类型。
7.1 留在主循环里的调试死循环
有一个项目的当前代码里,主任务的开头插入了一段调试用的代码:先连续调用两个
算法函数,然后进入一个 while(1) 里只做延时。
这意味着它后面的所有业务代码在运行时根本不会被执行——
包括指示灯、参数保存和各种控制分支。
/* 调试残留的危险形态:看起来只是"多了一段测试",
实际上把后面所有业务逻辑都屏蔽了 */
static void main_task(void *argument)
{
need_balance_module();
balance_module();
while (1) { /* ← 调试用,忘记删除 */
osDelay(10);
}
/* ↓↓↓ 以下代码永远不会被执行 ↓↓↓ */
led_blink_task();
soc_calculate();
special_control();
}这类代码的危险在于它不会报错,也不会崩溃: 编译通过、能下载、能启动,只是大部分功能静悄悄地不工作了。 如果这份代码被当作发布版本烧出去,问题会在现场以“某些功能没反应”的形式出现。
防的办法有几个,关键是自动化而不是靠记性:
在提交前做一次检索(搜 while(1)、while (1)、
for(;;) 的出现位置并逐个确认);或者用编译宏把调试代码包起来,
让它在发布配置下根本不存在。
7.2 注释里写着“会死机”的代码
另一个项目的存储模块里,有一处调试代码旁边的注释直接写着“调试用,写有死循环,会死机”。 注释本身说明作者是清醒的,但代码还留在那里。同一类问题还有: 某处用标准库的格式化输出导致内存泄漏(注释里记录了“原来的写法会导致内存泄露”)。
我的结论:不要依赖注释来标记危险代码。
注释是给人看的,而真正需要被拦住的是编译器。
能用 #if 排除的就排除,能用编译期错误拦住的就拦住。
八、可观测性:现场出问题时你能拿到什么
这一条是我觉得最被低估的。设备卖出去之后,你和它之间只剩下一件事: 它能不能把“我现在怎么了”告诉你。
8.1 至少要能拿到的东西
| 信息 | 为什么需要 | 常见的缺失方式 |
|---|---|---|
| 复位原因 | 区分“上电复位”“看门狗复位”“软件复位”“掉电复位” | 从不清除复位标志,永远只看到第一次的原因 |
| 当前故障码 | 定位到具体功能模块 | 只在屏幕上显示,没有通信接口暴露 |
| 软件与硬件版本 | 确认现场设备到底是哪一版 | 只写在文档里,设备自身不报 |
| 参数的校验结果 | 判断是不是参数区出了问题 | 校验失败后静默恢复默认值,不留下痕迹 |
| 关键计数与运行时长 | 推断故障发生前设备跑了多久、做了什么 | 完全不记录 |
| 通信超时与重试次数 | 判断是链路问题还是对端问题 | 只在调试串口打印,发布版关闭 |
8.2 故障码本身也需要设计
我在一个项目里用到的做法是用数值区间划分故障等级: 小于某个值的故障可以手动清除;中间一段需要输入密码才能清除; 再往上的一段完全不能手动清除,只能等故障原因消失后由程序自行恢复。 这样设计的好处是:新增故障码时只要落在对的区间里,就自动具备对应的清除策略, 不用改清除逻辑。
另一个项目里用了“数字越大优先级越高”的规则:新故障只有比当前故障更严重时才能覆盖它。 这样保证现场看到的永远是最严重的那个,代价是轻微故障会被淹没,必须显式清除才能恢复。
九、量产前检查表
把上面所有条目汇总成一张可勾选的表。这是我现在的版本,还在继续补。
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 发布版本配置 | 芯片保护已开启、看门狗已开启、调试打印已关闭 |
| 2 | 版本可读 | 软件版本与硬件版本都能通过总线读出 |
| 3 | 版本匹配检查 | 固件启动时校验硬件版本,不匹配置明确故障码 |
| 4 | 参数区头部 | 魔数 + 长度 + 参数版本号齐备且被校验 |
| 5 | 默认值覆盖 | 每个成员都有明确默认值,加载后做范围检查 |
| 6 | 参数保存 | 写完立刻读回校验,失败要能上报 |
| 7 | 看门狗超时核算 | 最长操作时间小于超时时间,或该操作内部插喂狗 |
| 8 | 休眠喂狗 | 休眠唤醒路径上确实有喂狗动作 |
| 9 | 复位原因可读 | 能区分上电/看门狗/软件/掉电复位,且读后清除 |
| 10 | 故障码语义完整 | 没有“预留但未定义含义”的故障位 |
| 11 | 调试代码清理 | 检索死循环与调试打印,确认发布版中不存在 |
| 12 | 产测可失败 | 能构造必然失败的样本,工装确实报失败 |
| 13 | 产测记录 | 每块板的测试结果、固件版本、时间都可追溯 |
| 14 | 工程文件一致性 | 确认实际参与编译的源文件清单,清理未编译的残留代码 |
| 15 | 掉电与升级中断 | 已按可靠性测试项验证通过,设备永远能开机 |
第 14 项值得单独说一句。我在整理几个项目时发现,“文件夹里有这个文件” 和“这个文件参与编译”完全是两件事:有一个工程里,一个传感器驱动文件 好好地躺在源码目录里,但它根本不在工程的编译清单中;另一个工程里, 一整套其他产品的业务代码和当前工装混在同一个目录下,看着像是功能的一部分, 实际上从未被调用。清理发布版的第一步,是先搞清楚到底编译了哪些文件。
十、几条我反复用到的经验
- 把“不重要的事”变成检查表。这些环节之所以总是被拖,是因为它们不紧急; 检查表的作用就是把它们从“靠记性”变成“靠流程”。
- 凡是诊断需要的信息,都走通信接口暴露。不要只显示在本地屏幕上。
- 版本号要能从设备本身读出来。不拆机就能确认版本,是省时间最多的一条。
- 校验通过不等于内容正确。结构性校验、范围检查、默认值,三层都要有。
- 看门狗要早加,不要晚加。晚加会让它在最后阶段变成一个到处触发复位的新变量。
- 不要在发布版里留调试死循环。用编译宏排除,不要靠注释提醒。
- 测不出失败的测试等于没测。每个检测项都要能构造出必然失败的情形。
- 先搞清楚编译了哪些文件,再谈清理。目录里有文件不代表它在固件里。
参考资料与说明
- 所用 MCU 数据手册中关于 Flash 擦写寿命、读保护(读写保护位)与看门狗的章节。
- 所用 RTOS 官方文档中关于任务、栈与看门狗配合使用的说明。
- 本文提到的项目记录、提交信息与代码片段均来自个人学习项目; 示例代码为按理解重写的最小片段,其中的版本号、寄存器地址与常量均为示意值, 不代表任何产品或交付代码。
- 文中芯片与器件型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
- 本文讨论的是个人实践中的工程经验,不构成任何产品标准、质量体系或认证依据。