本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

从样机走到量产,
真正缺的是什么

样机跑通那天通常是最开心的一天,但那天离“能稳定出货”还差很远。 差的不是技术难度——那些环节单拎出来都不难,难的是它们容易被认为“不重要”而一直往后排。 这篇把我在几个项目里补过的窟窿逐条列出来,作为一份自查清单。

工程方法 量产 产测 版本管理 已发布

一、两个版本:主程序版与加密版

我第一次在量产工具里看到“同一个固件要生成两个配置”的时候,是有点意外的。 当时那个项目用芯片厂商提供的配置工具生成了两份工程配置,名字分别是 “主程序”和“加密”,两次生成的时间只差两分钟, 说明它们是被一起打包、分别烧录的。

为什么要有两个版本?因为“给研发和产线用的版本”与“给最终用户设备用的版本”, 需求本来就是相反的:

维度主程序版(调试/产线)加密版(出货)
Flash 读保护关闭,方便读回与对比开启
调试口保留 SWD,可随时挂调试器按需关闭或复用为普通 IO
串口打印打开,能看到内部状态关闭,避免占用带宽与泄漏信息
看门狗可暂时关闭以便单步调试必须开启
产测入口可通过特定条件进入关闭或隐藏

关键点不在于“要分两个版本”,而在于这两个版本必须来自同一份源码。 如果为了省事,产线烧的是 A 分支、出货烧的是 B 分支,那么“产线测过的固件” 和“用户手里的固件”就不是同一个东西了——产测的全部意义会被抵消掉。 正确的做法是:同一份源码,通过编译宏或配置工具切换这些选项, 并且把“这次生成的是哪个版本”记录在案。

左右两列对比“主程序版”与“加密版”,按五项逐行对照:读保护、调试口、串口打印、看门狗、产测入口,用勾叉表示开关状态
图 1 · 两个发布版本的差异对比图

二、软硬件版本必须能互相确认

这一条我在两个项目里都改过,改动记录里的原话是“修改了软件版本与硬件版本的对应关系”和 “修改了硬件版本与软件版本的对应逻辑”。听起来是件小事,但它解决的是一个很实际的困境。

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 我会用的做法

  1. 参数区头部放 魔数 + 结构体长度 + 参数版本号,三者任一不符即视为不可用;
  2. 不可用时不直接信任原内容,而是整体写入出厂默认值;
  3. 默认值来源必须覆盖每一个成员,并且在代码里用编译期断言或 “结构体长度 = 各成员长度之和”这类检查防止漏掉;
  4. 加载完成后逐项做范围检查,超出范围的一律替换为默认值并记录一条事件。

四、把产测流程做进固件:好处与代价

产测有两种做法:一种是固件里烧一版专门的测试程序,测完再烧正式固件; 另一种是把测试逻辑做进正式固件,通过某个条件进入测试模式。

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 项值得单独说一句。我在整理几个项目时发现,“文件夹里有这个文件” 和“这个文件参与编译”完全是两件事:有一个工程里,一个传感器驱动文件 好好地躺在源码目录里,但它根本不在工程的编译清单中;另一个工程里, 一整套其他产品的业务代码和当前工装混在同一个目录下,看着像是功能的一部分, 实际上从未被调用。清理发布版的第一步,是先搞清楚到底编译了哪些文件。

一条横向流程:样机跑通 → 各环节补齐 → 出货,流程下方标出最常被跳过的四个环节:版本对应、产测流程、默认值覆盖、看门
图 2 · 从样机到量产的环节清单图

十、几条我反复用到的经验

  1. 把“不重要的事”变成检查表。这些环节之所以总是被拖,是因为它们不紧急; 检查表的作用就是把它们从“靠记性”变成“靠流程”。
  2. 凡是诊断需要的信息,都走通信接口暴露。不要只显示在本地屏幕上。
  3. 版本号要能从设备本身读出来。不拆机就能确认版本,是省时间最多的一条。
  4. 校验通过不等于内容正确。结构性校验、范围检查、默认值,三层都要有。
  5. 看门狗要早加,不要晚加。晚加会让它在最后阶段变成一个到处触发复位的新变量。
  6. 不要在发布版里留调试死循环。用编译宏排除,不要靠注释提醒。
  7. 测不出失败的测试等于没测。每个检测项都要能构造出必然失败的情形。
  8. 先搞清楚编译了哪些文件,再谈清理。目录里有文件不代表它在固件里。

参考资料与说明

  • 所用 MCU 数据手册中关于 Flash 擦写寿命、读保护(读写保护位)与看门狗的章节。
  • 所用 RTOS 官方文档中关于任务、栈与看门狗配合使用的说明。
  • 本文提到的项目记录、提交信息与代码片段均来自个人学习项目; 示例代码为按理解重写的最小片段,其中的版本号、寄存器地址与常量均为示意值, 不代表任何产品或交付代码。
  • 文中芯片与器件型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
  • 本文讨论的是个人实践中的工程经验,不构成任何产品标准、质量体系或认证依据。