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

自助加注设备主控:
四种流量、三层存储与一套计费逻辑

这台设备同时是“小作坊”和“售货机”:前半段把原液配成成品液,后半段按升卖出去。 主控要同时管四路流量计、两条售卖区、多种支付方式、一块大面积段码屏和一套 4G 上报。 这篇记录写的是我在这块主控上学到的东西:计量到结算这条链怎么切、任务怎么分、 数据为什么要分三层放,以及六个我确实踩过的坑和至今没做完的部分。

Cortex-M4 FreeRTOS 计量与计费 三层存储 已发布
已发布 · 个人学习项目

自助加注设备主控(Cortex-M4 + FreeRTOS)

做这台设备的出发点是:我想完整地走一遍“从计量到收钱”这条路。 主控是一颗带 FPU 的 Cortex-M4,跑 FreeRTOS,对外接 4G 模组和多种支付方式, 对内驱动段码屏、语音播报、24 路继电器阵列和四路流量计脉冲输入。 结论是:功能全部实现并在现场连续跑过,其中我认为最值得写下来的是三件事—— 计量到结算这条链必须一次把单位定死、 参数、累计、明细要分三层放、 发布镜像与调试镜像必须从一开始就分开。 也留下了几个我知道迟早要返工的地方,最后一节如实列出。

PLATFORMCortex-M4 + FPU(200 MHz 档,256 KB Flash / 192 KB SRAM)
STACKFreeRTOS + 多路 RS485 + 段码显示
TOOLSKeil MDK + 逻辑分析仪 + 示波器
STATUS个人学习项目 · 功能已实现

一、这台设备要做什么

需求本身可以一句话说清:把原液配成成品液,再按升卖给用户。 但把它拆成固件要做的事,就变成四件事同时活着——配液流程、加注计量、收钱记账、联网上报。 任何一件单独看都不难,难的是它们共用同一台设备、同一块屏幕、同一颗 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 路输出打包成几个字节,一次把整帧移出去,这样任何时刻继电器阵列的状态都是自洽的, 不会出现“改到一半被打断、一半新一半旧”的中间态。第二个是 显示和键盘用不同的芯片分开:方屏要的是面积和刷新速度, 小屏加按键要的是省事,两者的诉求不一样,硬凑成一颗芯片反而两头都不合适。

主控居中,外接:四路流量计、段码显示屏、键盘、支付模块、三层存储、无线上报模块
图 1 · 系统框图

三、计量与计费:从脉冲到一次结算

这是整台设备里我最在意的一段,因为它直接对应钱。 我在这段链路上一共改过三次,最后固定成五步,每一步都只做一件事:

  1. 计数:流量计输出的脉冲进 GPIO,累计的只是“脉冲个数”。 这一步不换单位、不乘系数——因为它是唯一不能丢的数据,越简单越好。
  2. 换算:按本路标定得到的系数,把脉冲数换成最小体积单位的整数。 每个加注口的系数是各自标定出来的,属于现场标定值,本文不列出。 我在这里坚持用整数最小单位,不用浮点:加注过程是“每来一批脉冲就累加一次”, 浮点误差会随着加注量慢慢积累,而整数不会。
  3. 计价:单价由现场设定,金额 = 量 × 单价,同样全程整数运算。 单价是设置项,不是常量。
  4. 取整:按最小货币单位取整,整条链路上只做一次, 而且固定放在最末端。这一步的位置是踩过坑之后才定下来的,原因见第七节。
  5. 结算:写明细、更新日累/班累/月累、上报平台、语音播报, 最后统一落盘。落盘成功之后这笔交易才算结束。

把链路切干净之后,再看“预置加注”就简单了:预置只是把第 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 放整定参数、EEPROM 放索引与累计值、外挂 Flash 放明细流水与固件镜像
图 2 · 三层存储分工图

四、三层存储与参数体系

这台设备要保存的数据分三类,它们的写入频率和“丢了会怎样”完全不同, 所以我从一开始就没有放在一个地方:

层介质放什么写入节奏我的取舍
第一层 片内 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 与语音播报相关命令),用于确认状态机中每一条指令的应答格式。
  • 文中出现的芯片内核与器件类别仅用于说明技术方案,与相关厂商无隶属或授权关系。
  • 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 其中单价、标定系数、时间参数、权限密码、分区基址均不在本文范围内。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。