液体加注控制器的状态机实现
这是一个加注类控制器的个人学习项目:主控 HC32F460PCTB,跑 FreeRTOS, 应用层用一套分层状态机把设备的运行过程拆成互斥的状态,每个状态对应一个独立任务; 故障用一个带优先级的故障码统一管理,并按数值区间分成三档可清除等级。 我做它的目的很明确——想让设备能自己说清楚“我现在在干什么、我出了什么问题、这个问题谁有权清”。 做完之后的结论是:这套“驱动分层 + 状态机”的框架是可以跨项目复用的, 后来换到另一款加注类设备上,驱动和状态机骨架基本没动,只改了业务参数和流程。
一、这个控制器要做什么
从功能上看,它是一台加注设备的控制板:接受一次加注请求,按设定的量把液体加出去, 全程把当前状态显示在数码管上;操作者可以用按键进入设置界面改参数; 出现异常时停在安全状态并报出故障码;板载无线模组负责把状态传出去。 这类设备的工作节奏是“一次有开始、有结束、中间可能被打断、结束后要留下记录”, 正好是状态机最典型的应用场景。
我在这个项目里主要整理的是应用层:状态怎么划、状态之间怎么切、 故障怎么记、参数怎么存、产测怎么做。硬件部分我只写到能从源码文件名和头文件确认的那一层, 不去猜原理图上的细节。
为什么值得单独写一篇?因为加注类设备的难点从来不在“怎么输出一路 PWM”, 而在“怎么保证设备在任何时刻都处于一个说得清、且可以安全退出的状态”。 一台加注设备最怕的不是不工作,而是在错误的状态下继续工作—— 比如故障已经报出来了,泵还在转。这类问题靠加判断是补不完的, 必须从状态划分上解决。
还有一个动机是复用。同一套代码结构我还用在了另一个“玻璃水加注”的项目上: 两者的驱动层与状态机框架基本一致,差别只在业务参数和加注流程。 这件事让我第一次真切体会到“框架”和“业务”分开的价值—— 第二个项目省下来的工作量,比我预想的多得多。
二、硬件构成:主控、显示、按键、扩展 IO、存储、时钟、无线
下面这张表是我从源码文件名和头文件里反推出来的器件清单。 需要说明的是:器件与接口是确定的,但“用途”一列带有我的推测成分, 实际用法可能和我理解的略有出入。
| 器件 / 模块 | 接口 | 用途 |
|---|---|---|
| HC32F460PCTB | — | 主控(Cortex-M4) |
| CH455 | I²C | 数码管显示 + 按键扫描一体驱动 |
| 74HC595 | 串行移位 | 输出扩展(继电器 / 指示灯等) |
| 74HC165 | 串行移位 | 输入扩展(按键 / 开关量采集) |
| GD25Qxx | SPI | 外部 NOR Flash(页面数据、字库、记录) |
| PCF8563 | I²C | 实时时钟(RTC) |
| EC600M | UART + AT 指令 | 4G LTE 通信模组 |
| 蜂鸣器 | GPIO / PWM | 提示音 |
74HC595 与 74HC165:扩展 IO 的经典组合
这两个芯片我是一起理解的:595 负责串出,165 负责串入, 都是“时钟 + 数据 + 锁存”三根线的结构。595 把 MCU 串行送来的字节展开成 8 路并行输出, 165 反过来把 8 路并行输入收成一个字节串给 MCU。级联的时候更省: 多挂一片只需要多接一根数据线,时钟和锁存是共用的。
它的好处很直接:省 MCU 引脚,而且可以任意级联。 主控的 IO 是有限资源,一旦要驱动十几路继电器和指示灯, 直接并口接法很快就会把引脚吃光,而三线串行理论上可以一直挂下去。
代价有两条,我认为都必须提前知道:
- 速度比并口慢。一个字节要一位一位地移,级联越长越慢。 在需要快速响应的信号上用它是不合适的。
- 必须整帧刷新。595 的输出是整串移位的结果, 没法只改其中一位——你必须把当前所有输出的状态拼成完整的一帧再送出去。 这就要求软件里维护一份“输出镜像”,每次改一位都改镜像、再整帧下发。 如果直接分两处去操作同一个 595 而没有共享镜像,两边的状态就会互相覆盖。
结论是:这类扩展芯片适合状态变化不频繁的场合——指示灯、继电器、按键扫描, 正好就是这个项目里的用法。拿它做高速信号,方向就错了。
CH455:把显示和按键合成一颗芯片
CH455 这类“显示 + 键盘”一体芯片的价值在于:一颗芯片解决两个问题,而且自带扫描。 如果没有它,MCU 就得跑一个动态扫描定时器,定期刷新段码、轮流读行线, 这部分代码既琐碎又占 CPU。交给专用芯片之后, MCU 只需要在内容变化时通过 I²C 把“哪一行显示什么”写过去就行了。
代价也很清楚:显示内容受芯片的字库和段码限制, 你能显示什么是它决定的,不是你想怎么显示就怎么显示; 而且 I²C 读写有失败的可能,写失败时屏幕会停在上一帧的内容上—— 这类“显示和实际状态不一致”的问题很容易误导现场判断,所以我在写显示接口时加了重试, 并且在状态切换时会把整屏重刷一遍,而不是只更新变化的那几位。
EC600M:一个体现了真实取舍的驱动结构
4G 模组的数据结构我觉得很值得单独看,因为它把“省 RAM”和“好维护”之间的矛盾 摆到了明面上:
/* 模组驱动的数据结构:状态标志 + 等待时间 + 两个缓冲 + 回调 */
typedef union modem_FLAG
{
u8 all; /* 一次读写整个字节 */
struct
{
u8 err : 2; /* 错误码 */
u8 busy : 1; /* 1:正在使用中 */
u8 rsv : 5; /* 保留 */
} bit; /* 也可以单独访问某一位 */
} modem_ctrl;
typedef struct modem_DATA_REGISTER
{
modem_ctrl state;
u16 waittime; /* 超时等待时间 */
char readbuf[]; /* 读缓冲:长度按本地定下的上限 */
char writebuf[]; /* 写缓冲:内存紧凑时可改为指向常量的指针 */
void (*callback)(u8 event); /* 回调函数 */
} modem_def;这里有两个设计点:
- 用联合体 + 位域做状态标志。
state.all一次读写整个字节,state.bit.busy单独访问某一位。好处是省 RAM、语义清楚—— 把几个只用几位的标志塞进同一个字节里,比用三个变量清爽得多。 坑在于位域的存储顺序依赖编译器实现: 哪个位是低位、结构体怎么对齐,不同的编译器可以有不同的安排。 所以位域结构体绝不能直接通过通信发出去, 也不能假设换一个编译器之后内存布局还是老样子。 要传出去,就得老老实实按位拼成字节。 waittime+callback的异步模型。 发起一条 AT 指令时记下等待时间,收到期望的响应就触发回调,超时交给上层处理。 这是我认为这个驱动里最重要的一条经验:模组驱动最怕“同步阻塞等响应”。 网络差的时候一次 AT 交互可能几秒甚至几十秒, 如果在一个任务里死等,整个任务连同它负责的功能就全卡住了。
三、应用层架构:一个状态一个任务
应用层的核心决定是:一个状态对应一个 FreeRTOS 任务。 需要切换状态时,由负责切换的函数创建目标状态对应的任务, 创建成功才把当前状态提交过去。事件管理器本身也是一个任务, 优先级比状态任务高一档、栈大小按最坏路径估算后留了余量, 并且用的是静态分配(编译期定下来的栈数组 + 静态 TCB),不依赖堆。 具体的优先级与栈深数值属于工程里的整定值,本文不列出。
为什么要让事件管理器不走堆?因为状态任务是按需创建/删除的, 堆会随着状态切换不断分配和释放;而事件管理器是整套机制的中枢, 它不能因为“堆恰好不够”而起不来。 把最关键的那个任务用静态内存固定下来,是我从这个项目里学到的一个很实用的习惯。
这套结构的好处
- 每个状态有自己的栈,状态之间在内存上就是隔离的;
- 状态内部可以放心阻塞(等队列、等信号量、延时),不会影响别的状态;
- 新增状态不用改动老状态的代码,只在切换函数里加一条分支;
- 调试器里的任务列表,本身就是最好的“当前状态”观测窗口。
这套结构的代价
- RAM 占用随状态数增长,每个任务至少要一份栈加一个 TCB;
- 任务的创建与删除不是免费的,所以它只适合“状态变化不频繁”的场合;
- 状态被拆到多个文件,想看全一趟流程得来回跳——这也是我后来特别想要一张状态迁移图的原因。
关于这套架构更细的讨论(切换原子性、任务创建失败的返回值、
KEEP_STATE 宏的优先级坑),我单独写在
《分层状态机与故障优先级:把“设备现在怎么了”讲清楚》里,
这里只保留骨架代码和这个项目特有的部分。
四、状态机骨架代码
状态枚举是这样的(下面都是按我的理解重写的最小片段,不是原工程代码):
/* 系统状态枚举:这里只留四个示意项,实际状态比这多 */
typedef enum{
SYS_INIT = 0, /* 初始化 */
SYS_WAIT, /* 等待状态 */
SYS_RUN, /* 运行 */
SYS_ERR, /* 故障 */
}SYS_STATE_t;
枚举成员 = 0 那个显式赋值不是废话:这个值会被写进 Flash、会通过通信发出去,
一旦离开编译单元,它就是对外的契约,不能靠语言的默认值兜着。
状态机靠三个变量运转(示意写法):
static u8 cur_state; /* 当前系统状态:已经生效的那一个 */
static u8 next_state; /* 新的系统状态:还只是提出请求 */
static u8 retry_state; /* 待重试的目标状态:用不可能的取值当哨兵 */对外只暴露几个接口:读当前状态、读请求状态、读待重试状态,以及一个“请求切换”。 这套接口的设计思路是:谁都能看状态,但只有通过“请求切换”那个接口才能提出切换, 而且这个请求只是“提出”,真正切不切得动由创建结果决定。
还有一个用得很顺手的宏:
#define KEEP_STATE get_current_sys_state() != get_next_sys_state() ? 0 : 1
语义是“当前状态和下一个状态相同(也就是已经切换完成)时为 1”。
它写起来确实短,但这个宏有优先级和可读性两个坑,我后来真的被它咬过一次
(细节在那篇笔记里),
所以现在我会写成 (get_current_sys_state() == get_next_sys_state())
或者干脆改成一个 static inline 函数。
切换任务:四步顺序,换位置就出事
这块是这个项目的核心,所以我把实现换成步骤说明——每一步的顺序都有理由:
- 先把目标状态对应的那个任务建出来。 此时一个字都不动当前状态:创建本身可能失败,失败前不要让状态变量有任何变化。
- 建失败就记住“本来要切到哪”。 把目标状态存进待重试变量,并返回失败,让调用方知道这次没切成。
- 只有建成功,才提交当前状态。 提交只有一行,但它必须发生在创建成功之后。
- 提交之后,旧状态才停止下发动作、释放自己的资源。 早一步退出,新任务可能还没开始跑,两边会抢同一个外设。
| 顺序 | 这一步做什么 | 为什么必须排在这个位置 |
|---|---|---|
| 1 | 先创建目标任务 | 先改状态再创建任务,一旦创建失败,状态变量会显示“已经在新状态”,而实际没有任务在跑 |
| 2 | 失败就记住待重试的目标状态并返回失败 | 调用方必须能区分“切换完成”和“切换失败”,同时留下下一次重试所需的信息 |
| 3 | 创建成功才提交当前状态 | 提交是“切换完成”的唯一标志;提前提交就会出现状态已变、任务未建成的窗口期 |
| 4 | 提交之后旧状态才退出 | 退出太早会和新任务的操作重叠,现象是输出乱动,而不是报错 |
重试由另一个接口负责,注释写得很明确: “创建任务失败则重新创建,无需调用太频繁,每秒调用一次”。 道理是任务创建失败基本都是堆不够, 而堆是会被释放的,隔一段时间再试就可能成功;试得太快只会白烧 CPU—— 更要紧的是,堆正是被别的任务让出来的,把 CPU 全占住反而等不到那一刻。 重试的动作就是把上面四步原样再走一遍。
至于栈大小与优先级:我给每个状态任务留了独立栈,大小按最坏路径估算—— 取该状态里最深的一条调用链,加上中断与 RTOS 的开销,再留一段余量; 状态任务之间彼此同级,而事件管理器比它们高一档,保证事件总能被及时处理。
事件管理器:三行最重要的判断
事件管理器任务按固定节拍调用 state_event(),
把当前状态的事件分派到对应的处理函数(骨架片段):
void state_event(void)
{
/* 切换完任务以后就不能再跑原来的事件管理器了 */
if (cur_state == next_state)
{
switch (cur_state)
{
case SYS_INIT: init_event(); break; /* 其余状态各自一个分支 */
case SYS_WAIT: wait_event(); break;
case SYS_RUN: fill_event(); break;
case SYS_ERR: err_event(); break;
default: break;
}
}
}
那个 if 是整段代码里我最看重的地方。切换请求发出之后、目标任务还没创建成功之前,
cur 和 next 是不相等的。这时候如果旧状态的事件函数继续跑,
旧状态就会继续下发控制动作(例如还在加注),而目标状态可能已经开始初始化,
两边抢同一个外设。所以“切换中”必须是一个谁都不许动的中间态。
五、故障码设计:优先级与三级可清除性
故障部分放在 Err_State.c / Err_State.h 里,
核心就是一个 u8 变量:static u8 err_num; // 故障码,0:没有故障。
设置接口很短,但每一行都有用意:
/* 设置故障,如果现在有比它更高级的故障,则设置失败。
返回 0 成功,1 失败。故障码数字越大,优先级越高。 */
u8 set_err(u8 num)
{
if (num > err_num) err_num = num;
else return 1;
return 0;
}这是一个“只升不降”的故障锁存:新故障只有比当前故障更严重时才能覆盖。 好处是严重故障不会被后续的轻微故障刷掉,现场看到的永远是最严重的那一个; 代价是轻微故障会被淹没——它确实发生过,但连痕迹都没留下, 而且这个变量只能被显式清除,不会自己恢复。
更巧的是第二层设计:故障码不按“清单”分级,而是按数值区间划分可清除等级。
| 故障码落在哪一段区间 | 可清除性 | 处理方式 |
|---|---|---|
| 数值最小的一段区间 | 可手动清除 | 按“取消/切换”键即可清零,并请求切回等待状态 |
| 紧接其后的中间一段区间 | 需密码清除 | 进入密码输入流程,密码正确才清零;位数是固定的,尝试额度有限并显示给操作者 |
| 数值最大的一段区间 | 不可手动清除 | 只能等故障原因消失后由程序自己恢复,例如温度降下来、液位到位 |
这三档其实对应三种现实语义:“操作者自己就能处理”“需要授权才能处理”“处理不了,只能等条件恢复”。 区间法的好处是新增故障码时,只要落在对的区间里就自动具备对应的清除策略,清除逻辑一行都不用改; 坏处是区间边界成了隐式契约——后来人在分段边界上新增一个故障码, 就会莫名要求现场输密码才能清,而他自己完全不知道发生了什么。 所以这张区间表必须写进文档和注释,当成硬约束来看。
故障显示也复用同一套三段区间判断: 第一段直接显示故障码;第二段显示密码输入界面, 额度用完就清空该行、并在固定的一行上显示剩余额度;第三段则只提示、不给任何操作入口。 最后这条我觉得很对:对一个本来就“不可手动清除”的故障, 界面就不该给一个按了没反应的键。
密码那一段要单独说一句:它是本地物理按键上的防护,不是网络认证。 它要防的是无关人员误操作,把操作门槛从“随手按一下”提到“知道密码才按得下去”; 但固定的纯数字位数、有限的尝试额度、又没有锁定延时, 理论上是可以被逐个试出来的。所以它是操作门槛,不是安全边界—— 真要防未授权操作,得靠钥匙开关、机柜上锁这类物理手段,而不是指望这段按键逻辑。
最后一个我认同的设计:密码正确后不是就地改标志位,而是调用
set_next_sys_state(SYS_WAIT),
让故障清除变成一次正常的状态切换。
这样所有外设都会回到已知的初始状态,
不会出现“故障标志清了,但某个继电器还停在故障前的输出上”这种情况。
恢复也走正常路径,这条我后来在别的项目里也一直沿用。
六、参数与产测:自动产测与出厂状态
这个项目里有两组文件让我印象很深:auto_prod.c/h(自动产测)和
factory_status.c/h(出厂状态)。
它们说明了一件事:产测流程是被做进固件里的,而不是靠外部的工装脚本去跑。
把产测做进固件,好处有两条:
- 产线不需要额外工具链。板子烧完固件,进产测模式就能自检, 不需要在上位机装一套配套软件,也不需要维护脚本与固件的版本对应关系。
- 测试项与固件版本天然绑定。测的是这一版固件真实的行为, 不会出现“脚本测的是老版本的外设初始化顺序”这类错位。
代价同样明确:
- 固件变大。产测代码、测试界面、结果记录都要占空间。
- 产测逻辑必须和正常逻辑严格隔离。 最怕的是产测模式被误触发——设备到了现场,因为某个按键组合或某个标志位残留, 直接进了产测界面,表现为“设备不干活,一直在自检”。
与参数落盘相关的文件是 eeprom_ctrl.c、EEprom.c 和 flash_ctrl.c,
时间处理在 Unix.c,另外还有 fill_status.c/h(加注状态)、
err_status.c/h 与 Err_State.c/h(故障)、
display.c、keyboard.c、storage.c、
modbus.c、pulse.c。
参数落盘这一段我单独整理过:分区怎么划、为什么要留双区备份、 CRC 校验放在哪一步,写在 《嵌入式参数存储设计:Flash 分区、双区备份与 CRC 校验》里。 这也是我在最后一节说“参数版本升级策略还没细化”的原因—— 分区和备份解决了“存坏了能恢复”,但没解决“结构体加字段之后老数据怎么解释”。
从这份文件清单里能看出这个项目的分层思路: 器件驱动一层(595/165/CH455/GD25Qxx/PCF8563/EC600M)、 状态与业务一层(fill_status / err_status / auto_prod / factory_status)、 交互一层(display / keyboard)、对外一层(modbus / storage)。 最上面才是那个把一切串起来的状态机。 关于对外通信那部分(状态和参数怎么变成一张寄存器表被上位机读走), 我整理在《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》里。
我想强调的结论是:把状态机和驱动分层先做出来,第二个同类项目就能省掉大半工作量。 这一点我在“玻璃水加注”那个项目上验证过——驱动和状态机骨架基本照搬, 改的是加注流程、参数含义和显示内容。如果一开始就把业务逻辑写死在驱动里, 第二个项目基本等于重写。
七、我在这个项目里踩的坑
这一段是这篇文章里我最想写实的部分,因为坑比结论更有信息量。
坑一:KEEP_STATE 这个宏真的咬了我一次
那个宏没有加外层括号,而三目运算符的优先级比 && 低。
我写 if (is_time_to_report && KEEP_STATE) 的时候,
本意是“处于保持状态并且到了上报时间”,
展开后实际变成 (is_time_to_report && (current != next)) ? 0 : 1,
在两个状态相等时整个条件恒为 1,和 is_time_to_report 一点关系都没有。
结果是设备在状态切换过程中也上报了一次状态,上位机的序列里多了一条不该有的记录。
编译器一句警告都没有,因为语法完全合法。
这件事之后我给自己立了条规矩:宏体里只要出现运算符,整个宏体必须用括号包起来;
包不明白的,就改写成 static inline 函数。
函数一样会被内联,还多了类型检查,读代码的人也不用跳文件。
坑二:忽略 xTaskCreate 的返回值
我最早写切换逻辑时是“先改状态变量,再去创建任务”。 看起来没问题,但一旦创建失败(基本都是堆不够), 程序就认为“我已经在新状态了”,而实际上根本没有对应任务在跑。 设备停在一个既不采集、也不响应、也不报警的地方, 而状态显示还是正常的——这种“看起来正常的不工作”是最难查的一类问题。 正确的顺序是:创建成功才提交状态;失败就记住要切到哪,隔 1 秒重试。
坑三:故障清了,输出还停在故障前的状态
我一开始清故障就是就地 err_num = 0;,然后继续在原来的状态里跑。
结果是:故障标志确实清了,但故障发生时被打断的那些输出(继电器、泵)
还停在故障前的那一帧上,没有被重新下发过。
现象是“故障显示消失了,但设备的行为还是不对”,必须再按一次键或者断电重启才恢复。
改成走一次状态切换(回到 SYS_WAIT)之后,所有外设都会重新初始化到已知状态,
问题自然就没了。
坑四:位域的存储顺序不能假设
用联合体 + 位域访问状态标志确实清爽,但位域的排列顺序和对齐方式是
编译器实现相关的。同一个结构体,
换个编译器、换个优化等级,内存布局就可能不一样。
所以它只适合在同一个编译单元内部使用;
一旦要把状态发出去(走通信、写 Flash),必须先老老实实按位拼成字节再发,
不能把结构体直接 memcpy 出去。
八、还没做完的部分与后续想法
这一节我写得比较直白,因为这个项目本身就是学习性质,没做完的地方比做完的地方更值得记。
-
缺少一张统一的状态迁移图。
现在“哪个状态能切到哪个状态”是散在各个
*_event()函数里的, 靠if和set_next_sys_state()的调用点隐式表达。 我想做的事是把它收敛成一张显式的迁移表:行是当前状态,列是事件, 表格里填目标状态,非法组合直接拒绝。 这样迁移规则集中在一处,既好审阅,也能顺手做一次“是否可达”的静态检查。 - 故障恢复过度依赖人工确认。 目前需要输密码的那一档,必须到设备跟前才能清。 这在现场是合理的,但如果设备装在够不着的地方就很麻烦——没有远程恢复通道。 我还没想清楚怎么在“远程可清”和“防止误操作”之间取平衡, 可能的思路是让远程只能清除“已确认原因消除”的那部分故障,并且留下记录, 但这需要先把故障原因的判断做得更可靠,现在是做不了的。
- 参数版本升级策略还没细化。 Flash 里存参数的结构体现在还没有一套明确的版本迁移办法: 如果下一版固件给结构体加了一个字段,老设备升级之后读到的老数据该怎么解释、 新字段的默认值从哪来,这些我还没有定论。 我倾向于给参数区加一个版本号和一段迁移函数, 但具体怎么保证“迁移失败也能回到出厂值”,还需要再推敲。
- 被淹没的轻微故障没有留下记录。 故障码“只升不降”的代价就是轻微故障会被覆盖掉。 我打算用外部 NOR Flash 存一份故障历史(那颗 Flash 除了页面数据和字库, 注释里也提到用来存“记录”),但具体存哪些字段、存多少条、怎么翻页, 目前只是想法,还没有落到代码上。
- 产测逻辑与正常逻辑的隔离还可以再收紧。 现在两者在同一个固件里,靠标志位区分。我想改成更硬的隔离方式, 让进入产测模式这件事本身就需要外部条件配合,而不是仅靠内部标志。
- 缺少长跑与异常注入的验证记录。 我有过的验证大多是功能性的:能不能加注、能不能报故障、能不能清故障。 但“连续切换状态几千次之后堆还剩多少”“反复制造任务创建失败会不会漏掉重试”, 这类问题我还没有系统测过。这是我下一步最想补的。
写这一节的另一个原因是提醒自己:“能跑”和“能交付”之间隔着的, 往往就是上面这些没做完的部分。把没做完的写清楚, 比把做完的写得漂亮更有用。
参考资料与说明
- FreeRTOS 官方文档中关于任务创建(
xTaskCreate)与栈深度参数的说明, 特别是“栈深度以字为单位而不是字节”这一条,属于公开文档内容。 - HC32F460 系列用户手册,用于理解主控的 GPIO、UART、SPI、I²C 等外设配置。
- 74HC595 与 74HC165 数据手册,用于理解串行移位扩展 IO 的时序与级联方式。
- CH455、GD25Qxx、PCF8563、EC600M 的公开数据手册,用于理解各器件的接口与寄存器模型。
- 本文涉及的芯片型号、器件清单、状态枚举与代码片段,均来自个人学习项目; 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 项目相关的单位、客户与业务信息已做脱敏处理。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。