自助加注设备主控(Cortex-M4 + FreeRTOS)
做这台设备的出发点是:我想完整地走一遍“从计量到收钱”这条路。 主控是一颗带 FPU 的 Cortex-M4,跑 FreeRTOS,对外接 4G 模组和多种支付方式, 对内驱动段码屏、语音播报、24 路继电器阵列和四路流量计脉冲输入。 结论是:功能全部实现并在现场连续跑过,其中我认为最值得写下来的是三件事—— 计量到结算这条链必须一次把单位定死、 参数、累计、明细要分三层放、 发布镜像与调试镜像必须从一开始就分开。 也留下了几个我知道迟早要返工的地方,最后一节如实列出。
一、这台设备要做什么
需求本身可以一句话说清:把原液配成成品液,再按升卖给用户。 但把它拆成固件要做的事,就变成四件事同时活着——配液流程、加注计量、收钱记账、联网上报。 任何一件单独看都不难,难的是它们共用同一台设备、同一块屏幕、同一颗 MCU, 而且用户随时可能按取消、拔卡、断电。
我把这台设备的工作拆成下面几块:
- 多路加注:一共四路流量计输入,分属两条售卖区(洗车液区和玻璃水区)。每路一个脉冲计量通道,对应一组加注阀与泵。
- 制液(生产):把原液、纯水、防冻液、乳化液按流程依次送进混合与储存环节,有自动和手动两种模式;自动模式下一段流程走完才允许进入下一段。
- 按量计费:单价由现场设定,金额由加注量换算得到,再按最小货币单位取整。加注可以是自由加注,也可以是“先设定要加多少”的预置加注。
- 多种支付方式:IC 卡、电子卡、两种扫码支付,以及由平台下发的启动指令。启动方式也分按键、提枪、自动、网络几类。
- 无人值守:每一笔加注都必须有明确的开始与结束,并且记录“为什么停”。我把停止原因分成几大类:正常到量、被限制拦住、用户中断、被异常打断。
- 对外呈现:大面积段码方屏显示价格与升数、语音播报提示、按键与提示音;再通过 4G 把数据报到平台,接收平台下发的指令与升级包。
这台设备里没有复杂算法。它真正的复杂度在“四件事同时活着”: 加注过程中要能显示、要能上报、要能响应按键、要能随时把那笔账记下来。 所以我在这个项目上花时间最多的不是某一项功能,而是决定哪些事可以等、哪些事不能等—— 这件事直接决定了后面的任务划分和存储方案。
二、硬件构成:主控、显示驱动、继电器阵列、键盘扩展、存储、时钟、无线
主控选的是带 FPU 的 Cortex-M4,200 MHz 档,片内 256 KB Flash、192 KB SRAM。 选它的原因不复杂:任务多、缓冲多、显示数据量大,SRAM 小了会很局促; 而这个项目里没有需要 DSP 的运算,FPU 更多是“顺手有”。 下面是这块板子上的主要接口与器件——比起“用了什么芯片”, 我更想说明每个接口在系统里承担什么角色。
| 接口类别 | 器件与接法 | 在系统里的角色 |
|---|---|---|
| 主控与调度 | 带 FPU 的 Cortex-M4,片内 Flash / SRAM | 跑 FreeRTOS,所有任务、协议栈、缓冲都在这里 |
| 调试打印口 | 一路 UART,高速率 8N1 | 打印日志;发布镜像里这个口会被关掉(见第六节) |
| 4G 物联网 | 一路 UART 接 4G 模组,走 AT 指令 | 数据上报、平台指令、语音播报文本、升级包下载 |
| 预留串口 | 两路 UART,其中一路有两组可选脚位 | 接继电器扩展板、键盘显示板一类从站,属于总线式扩展 |
| 本地显示与按键 | 2 线串行接口的显示加键盘一体芯片(4 位 7 段) | 自带扫描与亮度、睡眠控制,负责小屏与按键 |
| 段码方屏 | 18 片 74HC595 级联,软件 SPI 移位 | 大面积段码显示:单价、升数、金额、提示语 |
| 继电器阵列 | 3 片 74HC595 级联,按位掩码控制 | 24 路输出:进水阀、RO 模组、原液泵、各加注阀、乳化模组等 |
| 键盘扩展 | 74HC165 并转串,两组输入 | 8 键加 16 键的扩展键盘,按键扫描有独立任务 |
| 明细与日累 | 片外 SPI Flash,按页写入 | 存明细与日累计:量最大、可以慢,但不能丢 |
| 参数与累计 | 板载 I2C EEPROM | 设置项的日常读写、累计值、设置日志、异常日志 |
| 片内参数区 | MCU 片内 Flash 里单独划出的一段 | 参数本体与出厂值兜底;与代码区严格分开,不会被程序本身占用 |
| 实时时钟 | I2C RTC | 给每一笔记录提供时间戳,由周期任务定期同步 |
| 计量输入 | 4 路 GPIO 流量计脉冲 | 加注量的唯一来源,也是最不能丢的输入 |
| 提示与语音 | 蜂鸣器 + 语音播报(由 4G 模组承担) | 按键音、故障报警、语音提示 |
| 数据加密 | MCU 内置 AES 模块 | 物联网数据加解密 |
有两个设计选择我想单独说一下。第一个是继电器用位掩码而不是逐路开关: 24 路输出打包成几个字节,一次把整帧移出去,这样任何时刻继电器阵列的状态都是自洽的, 不会出现“改到一半被打断、一半新一半旧”的中间态。第二个是 显示和键盘用不同的芯片分开:方屏要的是面积和刷新速度, 小屏加按键要的是省事,两者的诉求不一样,硬凑成一颗芯片反而两头都不合适。
三、计量与计费:从脉冲到一次结算
这是整台设备里我最在意的一段,因为它直接对应钱。 我在这段链路上一共改过三次,最后固定成五步,每一步都只做一件事:
- 计数:流量计输出的脉冲进 GPIO,累计的只是“脉冲个数”。 这一步不换单位、不乘系数——因为它是唯一不能丢的数据,越简单越好。
- 换算:按本路标定得到的系数,把脉冲数换成最小体积单位的整数。 每个加注口的系数是各自标定出来的,属于现场标定值,本文不列出。 我在这里坚持用整数最小单位,不用浮点:加注过程是“每来一批脉冲就累加一次”, 浮点误差会随着加注量慢慢积累,而整数不会。
- 计价:单价由现场设定,金额 = 量 × 单价,同样全程整数运算。 单价是设置项,不是常量。
- 取整:按最小货币单位取整,整条链路上只做一次, 而且固定放在最末端。这一步的位置是踩过坑之后才定下来的,原因见第七节。
- 结算:写明细、更新日累/班累/月累、上报平台、语音播报, 最后统一落盘。落盘成功之后这笔交易才算结束。
把链路切干净之后,再看“预置加注”就简单了:预置只是把第 3 步的输入从“用户按停”换成 “达到预置量”。同样地,“大阀提前关”也只是在第 2 步和第 3 步之间插一个比较—— 剩余量还差多少时先关大阀、用小说阀收尾。这里有个必须守住的前提: 提前量、预置量、当前量必须在同一个单位下比较, 否则量一大就会差出一个提前量的误差,这个坑我踩过。
| 环节 | 输入 | 输出 | 我的取舍 |
|---|---|---|---|
| 计数 | 流量计脉冲 | 脉冲个数 | 只累加、不解释,尽量放在最靠近硬件的一层 |
| 换算 | 脉冲个数 + 本路标定系数 | 最小体积单位的整数 | 整数运算,系数来自标定,不进代码常量 |
| 计价 | 体积整数 + 现场单价 | 金额(放大后的整数) | 单价是设置项,可由有权限的人修改 |
| 取整 | 放大后的金额 | 最小货币单位的金额 | 只做一次、放在末端,用整数四舍五入 |
| 结算 | 本笔的量、金额、时间、停止原因 | 明细 + 各级累计 | 先写明细再改累计,落盘成功才算结束 |
取整我从来不用浮点,也不用 round()。做法是整数版的四舍五入:
先放大到更小的单位,加上半个单位,再整除回来。
这个写法本身是通用惯用法,可以直接给出来;但放大倍数和实际单价取决于标定与现场设定,
下面这段只保留形状,不含本项目的任何数值:
/* 整数四舍五入的通用骨架(按个人理解重写的最小片段)。
放大倍数 SCALE 与实际单价都由标定和现场设定决定,这里只保留形状。 */
static uint32_t round_div(uint64_t value, uint32_t scale)
{
uint64_t scaled = value * scale; /* 放大:把要保留的那几位小数提上来 */
scaled += scale / 2; /* 加上半个单位:这一项就是"四舍五入"的那一半 */
return (uint32_t)(scaled / scale); /* 再整除回来,小数部分自然被舍掉 */
}为什么值得为一个取整单独写一段代码?因为取整是唯一会让“设备算出来的钱” 和“用户算出来的钱”不一致的地方。把它写成一个纯函数、只有一个入口、只调一次, 出问题时才能一眼看出是哪一步偏了一个最小单位。
四、三层存储与参数体系
这台设备要保存的数据分三类,它们的写入频率和“丢了会怎样”完全不同, 所以我从一开始就没有放在一个地方:
| 层 | 介质 | 放什么 | 写入节奏 | 我的取舍 |
|---|---|---|---|---|
| 第一层 | 片内 Flash(参数区) | 参数本体、出厂值兜底、首次上电标记 | 改一次写一次,频率最低 | 参数是设备的“记忆”,必须随时可读、可回退;坏掉时设备也要能开起来 |
| 第二层 | 板载 I2C EEPROM | 设置项的日常读写、各级累计、设置日志、异常日志 | 一天几次到几十次 | 累计值要能抗掉电,日志要能回答“谁在什么时候改了什么”; EEPROM 按字节改写比 Flash 方便 |
| 第三层 | 外挂 SPI Flash | 加注明细与日累计 | 每笔一次,量最大 | 量大、可以慢、但要能翻很久以前的账;单独介质便于按容量规划与整片擦除 |
三层存储的分区方式、环形索引怎么算、写坏了怎么发现, 我在《嵌入式存储的三层分工:片上 Flash、EEPROM 与外挂 Flash》 里做了完整整理,这里只说两个当时吃了亏的决定。
不做对半划分,就要接受“偏移量靠人维护”
这块板子的分区没有做对半划分,也没有统一的分区表: 每一类数据各自从一个固定基址开始,基址写在头文件里,注释里还留着一句提醒我 “注意偏移量”的话。好处是简单、直观、一看就懂; 代价是每加一类数据,都要人工挑一个不会撞车的基址,改一处要连带核对好几处。 我在这个结构上犯过一次错,从此把它列为必须返工的项目之一(见第八节)。
环形索引与首次上电判定
明细、日累、月累、班累都是环形存储:各自对“本区能放多少条”取余, 容量是按“要保留多久”反推出来的,具体条数属于工程规划,本文不列。 索引统一用 32 位无符号整数保存——这样就不用再考虑回绕溢出的边界, 代价只是多占几个字节,非常划算。
首次上电的判定我用的是最朴素的一种:读一个约定单元,如果不等于“已初始化”标记, 就遍历把全部出厂值写一遍,最后再把标记写回去。 这个做法和出厂状态判定、参数版本一起,我在 《从样机走到量产,真正缺的是什么》 里写过更完整的取舍,这里不重复。
参数表的四元组
所有设置项放在一张表里,每项四个字段:最小值、最大值、出厂值、类型。 “类型”里同时编码了两件事——这一项保留几位小数,以及改它需要哪一级权限。 这样一来,范围校验、显示格式化、恢复出厂、权限分级这四件事都不需要额外的代码, 全部由这张表推出来。参数表怎么设计、哪些东西不该放进去,我在 《参数表设计:用一张四元组表撑起校验、格式化与权限》 里单独写了一篇,这里只强调一句:参数表里最值钱的不是值,是“这一项归谁管”。
异步存储服务
EEPROM 和 Flash 各有一个存储服务任务,每个任务配一个浅队列, 队列元素就是“地址 + 长度 + 数据”。业务任务只负责投递,不等待写入完成。 这么做是被迫的:EEPROM 跨页写要按页对齐切分,页与页之间必须留出器件写周期所需的时间, 如果在加注流程里直接写,加注会被硬生生卡住。 把“慢介质”包成服务,是这块板子上我最满意的一个结构决定。 具体的页大小和间隔时间按器件手册与实测确定,本文不列。
五、任务划分与实时性
系统跑 FreeRTOS,节拍 1 kHz。任务不是按“模块”分的,而是按 “这件事被谁挡住、挡多久可以接受”分的。下面这张表是分工, 其中优先级与栈深度属于工程整定值,本文不列出数值:
| 任务(按职责分组) | 做什么 | 触发与节奏 |
|---|---|---|
| 键盘底层扫描 | 把 74HC165 的输入移进来,做最原始的扫描 | 周期轮询 |
| 键盘解析 | 把扫描结果变成键值、处理组合键与连按 | 由扫描结果驱动 |
| 生产流程管理 | 制液流程的分段推进、模组与阀的次序控制 | 流程状态驱动 |
| 系统状态与事件管理 | 顶层状态切换、把事件分派给当前状态 | 事件驱动 |
| 显示刷新 | 把显示缓冲整帧移进 18 片级联的 595 | 周期刷新 |
| EEPROM 服务 / Flash 服务 | 从队列取请求,完成跨页切分与实际写入 | 队列驱动,投递即返回 |
| 物联网(三个任务) | AT 指令状态机、事件处理、业务子功能(上报与下发) | 状态推进,长过程分步执行 |
| 提示音 | 按键音、故障音、报警音 | 事件驱动 |
| 周期任务 | 读 RTC、翻转运行指示灯、喂给需要周期性的东西 | 固定周期 |
| 启动任务 | 创建完其余任务后自删 | 仅上电一次 |
关于实时性,我想说清楚一件事:这台设备没有硬实时要求。 它没有微秒级的控制环,最“急”的事情只有一个——脉冲不能丢。 所以我把脉冲计量放在最靠硬件的一层,业务层按几十毫秒的节奏跑完全够用; 显示慢一帧、上报晚一秒,用户都感觉不到。 真正需要小心的反而是相反的方向:别让某件事把别的都挡住, 这也是存储要做成任务加队列、AT 指令要做成状态机的原因—— 它们都很慢,但都不能阻塞别人。
加注过程中还有一个“无脉冲保护”,用的是软件定时器封装出来的倒计时: 只要加注量有变化就复位,长时间没有新脉冲就判定异常并停机。 这里有一个 FreeRTOS 的坑值得记一笔:定时器创建时的周期不能为 0, 所以只能先给一个占位周期,真正运行前再用修改周期的方式改成实际值。 我第一次写的时候想“创建时先不给周期、用的时候再说”,结果直接创建失败。
排查实时性问题,我在这块板子上最常用的工具不是示波器,而是 FreeRTOS 的栈高水位:它能把每个任务“历史上最少剩下多少栈”打出来。 这个工具是踩了第七节第一条那个坑之后才配上的,装上之后就成了常备手段。
六、升级与版本:为什么镜像要分“主程序版”和“加密版”
这个项目从第一天起就编两个镜像:主程序版和加密版。 一开始我以为这只是“发布前多一步”,后来才明白它们解决的是两个不同的问题。
- 主程序版:用来开发、调试和产线验证。保留高速率打印口, 保留各种可以单独打开的调试开关,参数可以随便改,出问题能看见。
- 加密版:用来发布。打开芯片保护,关掉打印与调试口, 参数与授权校验生效。它的目标不是“跑得更快”,而是 让“读出内部状态”这件事变成一个需要授权的动作。
为什么必须分开,而不是“编一个版本、发之前记得关掉打印”? 因为人会忘。留一个调试口在现场,除了占资源,更实际的风险是 任何人接上串口就能看到设备的内部状态。 分成两个镜像之后,“发的是哪一版”变成了一个不会靠记忆保证的事实。 这件事的完整清单——版本号要能被读出来、出厂状态怎么判、参数要保证设备永远能开机、 看门狗为什么总排到最后——我写在 《从样机走到量产,真正缺的是什么》 里,这里不再展开。
还有一条经验:软件版本必须能通过显示或通信读出来。 现场那台设备究竟烧的哪一版,如果只能拆机看,那么“版本管理”实际上等于没有。 版本号本身属于工程记录,本文不列具体取值。
七、问题与解决
下面六条是这台设备上我确实踩过的坑,按“现象 → 原因 → 办法”记。 它们有一个共同点:现象看起来都像别的问题。
| 现象 | 原因 | 办法 |
|---|---|---|
| 运行中偶发异常、跑飞,重启后又能跑一段时间, 且和操作没有明显关联 | 启动文件里给主栈留的尺寸偏小;而它是按十六进制计数的, 我一开始按十进制去理解,以为还剩很多余量 | 把主栈尺寸从十六进制 0x400 档提高到 0x1000 档(1 KB → 4 KB), 打开栈溢出检测钩子,并固定打印各任务的栈高水位; 新任务上线前先看一遍剩余栈,不再凭感觉 |
| 加注时间的记录错得离谱:明明加了几十秒, 记下来却是一个大到不可能的数 | 时间基准被多除了一次 1000,于是“1 个单位”实际代表 1000 秒。 更麻烦的是它不报错——只是账目里的一个数不对 | 去掉那次多余的除法,直接以秒为单位记录; 同时把所有跟时间单位有关的地方列成一张核对表, 逐个回到定时器配置里复算一遍(详见下方踩坑记录) |
| 采集到的电源电压比万用表实测低一截, 而且每次都低同样的量,不像噪声 | ADC 通道存在固定的系统偏差(分压网络与实际参考带来的), 是系统性误差,不是随机误差,所以滤波去不掉 | 显示与上报用加过偏移的修正值,判定仍用未修正值—— 避免把偏差叠进保护阈值里;偏移量属于标定值,本文不列; 同时把欠压判定点做成可设置的参数,不再写死在代码里 |
| 掉电误判:供电只是瞬时跌了一下,设备却自己重启了。 这条我一共迭代了三次 | 第一版是“检测脚一变低就重启”,把瞬时跌落当真掉电; 第二版加了滤波和一个保持窗口,现场仍偶发误判; 根因是判据用错了维度——该看持续时间,而不是瞬时电平 | 第三版把滤波时间加长,并要求“电压已恢复且两把枪都不在加注态” 才允许复位;把“瞬时跌落”和“真的没了”用时间长度区分开 |
| 先预置、后按键取整时,加注金额变得特别大 | 取整与预置两个换算叠在一起: 预置量被当成另一种单位又参与了一次换算,而且取整发生的时机不固定。 同一个单位错位还导致预置量偏大时“大阀提前关”算错 | 把链路固定成“先统一到最小单位整数 → 再计价 → 最后取整一次”, 并明确规定预置量、提前量、当前量三者必须是同一个单位; 单位含义写进参数表说明,而不是留在代码注释里 |
| 设置菜单最初没有任何保护,任何人都能进去改单价和标定相关项 | 参数表一开始只有“值”,没有“权限”这个概念, 所以设置流程里也没有校验这一步 | 给参数表的每一项加上访问等级(不可查看 / 基础密码 / 高级密码), 进入设置前按等级校验;并且规定故障或锁机状态下 只允许解锁,不允许改参数 |
另外还有三条小一些、但同样值得记的:
- EEPROM 连续写必须留间隔。跨页写时,如果一页接一页地写下去, 后面那些页会写失败。原因是器件的内部写周期还没结束就不再响应。 我的做法是按页对齐切分,页与页之间、整笔写完之后都留出间隔 (具体时长按器件手册与实测定),并在存储服务里用阻塞延时兜底。
- 语音播报和平台数据会抢同一路串口。这个问题我至今没根治, 只是用一个小的播放请求队列做了缓解(见第八节)。
- 看门狗一直没有真正启用。喂狗调用是存在的,但被注释掉了, 所以掉电或死机时的保护实际上依赖“记录停止原因”,而不是硬件复位。 这是一件我知道该做、但必须先把所有长耗时路径都理一遍才敢做的事。
八、局限与还没做完的部分
功能是实现了,但我不认为它到了“可以放心交给别人用”的程度。 下面每一条都是我知道问题在哪、只是还没动手改的:
| 问题 | 现状 | 我打算怎么改 |
|---|---|---|
| 语音播报与平台数据抢串口 | 未解决。已知的设计缺口:正在播报时如果平台数据到达, 两边会互相影响;目前只靠一个小的播放请求队列缓解 | 把“播报”也变成统一发送队列里的一类请求, 按“平台应答优先、播报可打断”的规则排队, 而不是各自直接操作串口 |
| SIM 卡状态判定 | 判定逻辑不完善:靠匹配模组返回文本里的关键字来判断“未插卡”“忙”等情况, 换一版模组固件就可能失效 | 改成按 AT 命令的结构化返回码判定, 并把模组差异挡在一个适配层里;下游只认一个稳定的状态编码 |
| 手动生产模式下的一段模组控制 | 源码里我自己留了“此处有问题”的注释;另有一处超时后没有产生故障码 | 重新梳理那一段的时序与超时,把“超时”也当成一种要报出来的故障, 而不是静默地停在原地 |
| 三层存储的分区 | 没有统一分区表,各类数据的基址靠人工维护偏移量, 加一类数据要连带核对好几处 | 定义一张分区表,让基址与容量由它生成; 参数区加版本号与校验,并做双份备份与默认值回退 |
| 看门狗 | 喂狗调用被注释掉,当前没有硬件复位兜底 | 先把所有可能长时间不返回的路径找出来,确认它们都能喂狗,再打开 |
| 验证的深度 | 我只做了“改一处、看一处”的对照观察, 没有长时间老化,也没有系统地做电源波动与断线测试 | 补一份测试清单:连续加注、断电恢复、传感器断线、 总线占用、显示满负荷刷新,逐项记录现象 |
把“没做完”写出来并不舒服,但我觉得这比含糊地说一句“运行稳定”要诚实得多。 如果只能先解决一件,我会选看门狗:它不优雅,但它决定了设备在异常时 能不能自己回来,而这比存储分区是否规整重要得多。
参考资料与说明
- FreeRTOS 官方文档中关于任务、软件定时器、栈溢出检测与栈高水位的章节,用于确认任务划分与排查手段。
- 所用 MCU 的公开用户手册(时钟、USART、SPI、I2C、AES 章节),用于确认外设配置与外设资源。
- 24C 系列 I2C EEPROM 与 GD25Qxx 类 SPI Flash 的公开数据手册,用于确认页大小与器件写周期。
- 74HC595 / 74HC165 公开数据手册,用于确认串行级联与并转串的时序。
- PCF8563 类实时时钟芯片的公开数据手册,用于确认 I2C 读写方式。
- 4G 模组的公开 AT 指令手册(TCP/MQTT 与语音播报相关命令),用于确认状态机中每一条指令的应答格式。
- 文中出现的芯片内核与器件类别仅用于说明技术方案,与相关厂商无隶属或授权关系。
- 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 其中单价、标定系数、时间参数、权限密码、分区基址均不在本文范围内。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。