一、状态机的第一作用是“让设备的状态可被外部观察”
我一开始做状态机,动机很功利:代码太乱了,想理一理。
那时候一个主循环里散着一堆标志位:filling、waiting、
error_flag、setting、queued……
写的时候觉得挺灵活,出问题的时候就知道错了。
标志位最大的麻烦不是“乱”,而是组合出来的状态大多没有意义。 五个布尔量有 25 = 32 种组合,而一台加注设备真正合法的状态只有七八种。 剩下的二十几种组合在逻辑上都是非法的,但程序跑到那一步时不会报错, 它只会安静地执行一段你没想过的分支。现场看到的现象就是“设备不动了,但灯是亮的”。
换成枚举之后,我得到的第一件事不是“代码变整齐”,而是 设备终于能回答“我现在在干什么”了:
- 数码管上有一行专门显示当前状态(或者故障码)
- 当前状态同时被放进一个通信寄存器,上位机读一次就知道设备在等待、在加注、还是在故障
- 现场打电话问“什么现象”,对方只要念一下屏幕上的字,我大致就能判断问题出在哪一层
所以我现在给状态机的定位是:它首先是给人和上位机看的观测窗口,其次才是代码组织方式。 一个状态如果没有对外暴露,它对这个项目的价值就少了一半。 这一点在远程场景里更明显——设备装在够不着的地方,你唯一能依赖的就是它自己报出来的状态。
状态要描述“在做什么”,不是“满足什么条件”
划分状态时我给自己定了一条线:状态回答的是“正在做什么”, 而“液位低”“温度高”“门开着”这类是条件,不是状态。 条件可以任意组合、随时变化;状态必须是有限、互斥、可枚举的。 我早期犯过的错就是把“液位低”做成一个状态,结果“液位低的同时正在加注”没法表达, 只能再加标志位补丁,很快就绕回标志位那套老路上去了。
二、我的状态划分:一台加注设备需要几个状态
我在这台控制器上最终收敛到 7 个状态,用一个 SYS_STATE_t 枚举表示
(枚举成员名和注释就是工程里的原文):
typedef enum{
SYS_INIT = 0, /* 初始化 */
SYS_WAIT, /* 等待状态 */
SYS_RUN, /* 运行(加注执行) */
SYS_ERR, /* 故障 */
SYS_SET, /* 设置 */
SYS_QUE, /* 排队 */
SYS_SET_CONTENT, /* 设置内容 */
}SYS_STATE_t;对应的含义和典型进出条件整理成表(枚举值是我按 C 语言“首成员显式赋值、后续成员依次加一”的规则推出来的):
| 状态 | 枚举值 | 设备在做什么 | 典型进入条件 | 典型退出方向 |
|---|---|---|---|---|
SYS_INIT | 0 | 上电初始化:外设、参数、显示自检 | 上电,或故障恢复后需要重新初始化外设 | 初始化完成 → SYS_WAIT |
SYS_WAIT | 1 | 空闲待命,可以接受请求 | 初始化完成 / 加注结束 / 故障被清除 | 有请求 → SYS_RUN 或 SYS_QUE;按键 → SYS_SET |
SYS_RUN | 2 | 正在执行加注 | 从等待或排队进入 | 加注完成 → SYS_WAIT;异常 → SYS_ERR |
SYS_ERR | 3 | 故障态:停在安全输出,显示故障信息 | 任意状态下 set_err() 生效 | 故障被清除 → SYS_WAIT |
SYS_SET | 4 | 设置菜单(在几个参数项之间导航) | 待命状态下按键进入 | 退出 → SYS_WAIT;选中某项 → SYS_SET_CONTENT |
SYS_QUE | 5 | 排队:设备已被占用,本请求在等 | 设备正在为别的请求服务 | 轮到自己 → SYS_RUN |
SYS_SET_CONTENT | 6 | 设置内容:正在编辑某一个参数的值 | 从设置菜单里选中一项 | 保存或返回 → SYS_SET |
为什么第一个成员要显式写 = 0
C 语言里枚举首成员不写值就是 0,所以 SYS_INIT = 0 看起来是废话。
但我现在会坚持写上,理由是:这个枚举值不是只在 RAM 里活着的。
系统状态变量会被写进 Flash 保存、会通过通信寄存器发给上位机、会出现在日志里。
只要它离开过编译单元,0 就成了一份对外契约,
而“隐式等于 0”只是一条语言规则。
风险在于改动:如果以后有人在枚举最前面插一个新状态、又忘了给它显式赋值,
那么所有后续成员的值会整体后移一位。这时候 Flash 里存着的老值含义就全变了——
老设备升级固件后读到“2”,本来以为是在等待,实际却被解释成加注。
显式写出 = 0 至少能让改动者在插入新成员时多看一眼这里。
同样的道理,状态变量我用了固定的 u8 而不是枚举类型,就是为了让它的宽度是确定的。
至于这些值具体怎么写进 Flash、写坏了怎么恢复,我整理在
《嵌入式参数存储设计:Flash 分区、双区备份与 CRC 校验》里。
顺带说一个我一开始没想明白、后来才接受的点:SYS_SET 和 SYS_SET_CONTENT
被我拆成了两个状态。拆开的好处是两套界面、两套按键处理各自独立,
菜单导航的代码不会和数值编辑的代码搅在一起;代价是这两者之间会频繁来回切换,
而每次切换都要走一遍“创建任务”的流程(后面会讲,这不是免费的)。
三、“一个状态一个任务”:好在哪、坑在哪
这是这套架构里最有辨识度的一个决定:每个状态对应一个独立的 FreeRTOS 任务。 切换状态时,负责切换的那个函数按目标状态去创建对应的任务函数。 下面这张表只讲“谁负责什么”,不复述创建参数:
| 状态 | 这个状态的任务负责什么 | 靠什么结束自己 |
|---|---|---|
SYS_INIT | 外设、参数、显示自检 | 自检项走完,主动请求切走 |
SYS_WAIT | 空闲待命,解析请求与按键 | 收到请求或按键,请求切走 |
SYS_RUN | 推进一次加注,刷新输出 | 加注结束或出现异常,请求切走 |
SYS_ERR | 停在安全输出,显示故障信息 | 故障被清除,请求切走 |
SYS_SET | 参数菜单的导航与显示 | 退出菜单,或选中某一项 |
SYS_QUE | 排队等待,盯着轮到自己 | 轮到自己,请求切走 |
创建任务时的栈深度与优先级,我给每个状态任务留了独立栈,大小按最坏路径估算: 把该状态里最深的一条调用链,加上中断与 RTOS 自身的开销一起算进去,再留一段余量; 状态任务之间的优先级关系是固定的——彼此同级,谁也不会饿死谁。 具体的栈深与优先级数值属于工程里的整定值,本文不列出,只讲它们是怎么定出来的。
关于“栈深度”这个参数,我踩过一次概念上的坑:FreeRTOS 的栈深度参数单位是
字(StackType_t 个数)而不是字节。在 32 位核上,栈的实际字节数
是它乘上一个字的大小,而不是表面上那个数字。
我一开始按“字节”去理解,觉得小得离谱,后来查了 FreeRTOS 官方关于任务创建的说明才对上。
不过这个单位最终取决于移植层的定义,稳妥做法是打开工程里的移植头文件确认一下,
再用 uxTaskGetStackHighWaterMark() 看实际余量。
好在哪
- 状态之间物理隔离。每个状态有自己的栈,某个状态里写深了递归、 或者定义了几个大数组,不会挤到别的状态。
- 阻塞是安全的。等待状态里可以放心
vTaskDelay()、 等队列、等信号量,不用像裸机轮询那样担心“阻塞了别的功能”。 - 加状态不动老代码。新增一个状态,就是写一个新的任务函数, 再在切换函数里加一条“这个状态对应哪个任务”的分支, 原有状态的代码一行都不用改。
- 调试器里能直接看到“现在是哪个任务在跑”。 RTOS 感知调试窗口里的任务列表,本身就是最好的状态观测器。
坑在哪
- RAM 是按状态数乘出来的。每个任务至少要一份栈加一个 TCB, 常驻一个任务就是“单份栈 + 单份 TCB”的固定开销,状态越多这笔账越大。 所以从代码结构看,切换时旧任务应该是被删掉的(否则切几次堆就没了)—— 这一点我是从“创建任务失败基本都是堆不够”这条经验反推的, 说明堆确实在被释放,但我没有逐行确认删除发生在哪一步。
- 创建和删除不是免费的。堆分配、TCB 初始化、就绪表插入都要花时间。 所以这套结构适合“状态变化不频繁”的场合:人按了键、出现了故障、一次加注结束。 如果指望它每个控制周期切一次,那是在拿调度器当 if-else 用。
- 状态任务之间是平等的。它们彼此同级,谁也不会饿死谁; 但只要有一个任务里写了不主动让出的死循环,同优先级的其它任务全都会被拖住。
- “状态”被拆到了多个文件里。好处是解耦,坏处是想看全一趟流程, 得在六七个文件之间来回跳。这也是我后来特别想要一张状态迁移图的原因。
另外补一句事件管理器的位置:它比所有状态任务都高一档优先级, 栈按它自己最坏的那条路径估算后留了余量; 并且用的是静态分配——一块编译期就定下来的栈数组 加一个静态 TCB,完全没有走堆。 “高一档”意味着事件管理器总是能及时抢占状态任务去处理事件。 让最关键的那个任务不依赖堆,我认为是个很对的决定: 堆会被状态任务切来切去,而事件管理器不能因为“堆恰好不够”而起不来。
四、状态切换的原子性:切换过程中不能有旧状态继续跑
有了 current_sys_state 和 next_sys_state 两个变量之后,
切换就不再是“改一个变量”,而是一个三步流程:
- 请求:某处调用
set_next_sys_state(SYS_WAIT),只改next; - 创建:事件管理器发现
current != next,按目标状态去创建对应的任务; - 提交:任务创建成功后,才把
current_sys_state更新为next_sys_state。
这三步之间有一个窗口期:请求已经发出,但目标任务还没建好。
此时 current 还是旧状态、next 已经是新状态。
如果这期间旧状态的事件函数还在跑,会发生什么?——旧状态会继续下发控制动作
(比如还在加注),而目标状态可能已经开始初始化,两边同时操作同一个外设。
所以 state_event() 里有一个看起来很不起眼、但我认为是整段代码里最重要的判断:
void state_event(void)
{
/* 切换完任务以后就不能再跑原来的事件管理器了 */
if (current_sys_state == next_sys_state)
{
switch (current_sys_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;
case SYS_SET: set_event(); break;
case SYS_QUE: que_event(); break;
case SYS_SET_CONTENT: set_content_event(); break;
default: break;
}
}
}
写成一句话就是:只有当切换已经完成(两个变量相等)时,才允许执行当前状态的事件函数。
这三行 if 的价值在于,它把“切换中”这个中间态显式地变成了“谁都不许动”。
删掉它会怎样?大概率一切正常。因为切换本身很快, 只有在“切换的那一瞬间恰好来了一个事件”时才会撞上。 这类问题的现象是偶发的、复现不了的,而且往往表现为硬件层面的异常(输出乱动、通信断开), 查起来会往电源、干扰、芯片质量上想,很难想到是软件的时序窗口。
一个值得单独说的宏:KEEP_STATE
工程里有一个用得很顺手的宏:
#define KEEP_STATE get_current_sys_state() != get_next_sys_state() ? 0 : 1
语义是“当前状态和下一个状态相同(也就是已经切换完成)时为 1”。
名字叫 KEEP_STATE,读起来是“保持状态中”,写起来也确实短。
但它的写法有两个坑,而且第二个坑我后来真的被它咬了一次。
第一个坑是优先级。这个宏没有加外层括号,
而三目运算符 ?: 的优先级低于 &&、||、
== 这些常见运算符。这意味着它一旦出现在一个更大的表达式里,
展开后的结合方式就不由它的名字决定了。举个最典型的例子:
/* 本意:处于保持状态 且 到了上报时间 */
if (is_time_to_report && KEEP_STATE)
{
report_state();
}
/* 展开后实际是:
if ( (is_time_to_report && (current != next)) ? 0 : 1 )
两个状态相等时,整个条件恒为 1,和 is_time_to_report 一点关系都没有 */
我当时的本意是“处于保持状态并且到上报时间才上报”,实际写成了恒真, 结果设备在状态切换的过程中也上报了一次状态,上位机看到的序列里多了一条不该有的记录。 编译器不会报任何警告,因为语法完全合法。
第二个坑是可读性。这种把判断藏进宏里的写法,
读代码的人必须点进头文件才能知道它到底是什么,
而“KEEP_STATE 为真”这句话在中文里可以被理解成两种意思
(“正在保持状态”还是“可以保持状态”)。名字越像自然语言,歧义越多。
现在的我会这样写:
/* 写法一:至少把整个表达式括起来 */
#define KEEP_STATE (get_current_sys_state() == get_next_sys_state())
/* 写法二(我更推荐):用 static inline 函数,编译器一样会内联,还多了类型检查 */
static inline u8 keep_state(void)
{
return (get_current_sys_state() == get_next_sys_state()) ? 1 : 0;
}&& 的 if 里,条件变成恒真,
设备多上报了一次状态才暴露出来。宏的危险不在于写错,而在于它用错的时候不报错。
从那以后我给自己加了一条规矩:凡是宏体里出现运算符,整个宏体必须用括号包起来;
包不起来的(比如带 ?: 的),就改写成函数。
顺带说一句并发问题。这套状态变量是 static u8,
读写它们的只有事件管理器任务:切换动作是在事件管理器上下文里发起的,
中断里不碰这两个变量。这是一个单写者模型,所以不需要临界区,
也不需要 volatile。但这份“不需要”是有前提的——
哪天有人在中断里也去 set_next_sys_state(),这个前提就破了,
那时候要考虑的就不只是 volatile,还有“读-改-写”被打断的问题。
五、任务创建失败怎么办:一个容易被忽略的返回值
xTaskCreate() 是有返回值的:成功返回 pdPASS,失败返回 pdFAIL。
这个返回值特别容易顺手忽略掉,因为它 99% 的时间都是成功。
而忽略它的后果很隐蔽:
如果不管返回值、直接把
current_sys_state赋成next_sys_state, 程序就会认为“我已经切到等待状态了”,但实际根本没有对应的任务在跑。 设备停在一个既不采集、也不响应、也不报警的地方, 而状态变量还显示一切正常。这是最难查的一类问题。
工程里的做法是:只有创建成功才提交新状态;创建失败就记住“本来要切到哪”,并返回失败。 这段逻辑我没有把实现贴出来,因为它正好是这个项目里最不愿意公开的那一段; 但它其实只有四步,而每一步的顺序都有理由:
- 先把目标状态对应的那个任务建出来。 此时一个字都不动当前状态——创建过程本身可能失败,失败之前不要让状态变量有任何变化。
- 建失败就记住“本来要切到哪”。 把目标状态存进一个“待重试”变量,然后返回一个失败值,让调用方知道这次没切成。
- 只有建成功,才提交当前状态。 提交这个动作本身只有一行,但它必须发生在创建成功之后,不能提前。
- 提交之后,再让旧状态退出。 从提交那一刻起,旧状态才被允许停止下发控制动作、释放自己占用的资源。
下面这张表是我把这四步写下来之后才理清的——每一步都不能和相邻的步骤换位置:
| 顺序 | 这一步做什么 | 为什么必须排在这个位置 |
|---|---|---|
| 1 | 先创建目标任务 | 先改状态再创建任务,一旦创建失败,状态变量就会显示“已经在新状态”,而实际没有任何任务在跑 |
| 2 | 创建失败就记住待重试的目标状态,并返回失败 | 调用方必须能区分“切换完成”和“切换失败”;把目标状态记下来,下一次才有东西可重试 |
| 3 | 只有创建成功才提交当前状态 | 提交是“切换完成”的唯一标志。它和创建之间夹进任何一步,都会出现状态已变、任务却还没建成的窗口期 |
| 4 | 提交之后旧状态才退出 | 旧状态退得太早,新任务可能还没开始跑;两边同时操作同一个外设,现象就是输出乱动 |
待重试的那个变量要给一个“不可能出现的状态值”当哨兵,表示当前没有待重试的目标。 重试由另一个接口按固定间隔调用,它的注释写得很明确: 创建任务失败则重新创建,无需调用太频繁,每 1 秒调用一次。 重试的动作就是把上面那四步原样再走一遍。
为什么是固定间隔重试,而不是“一失败就立刻重试”
原因很实在:任务创建失败基本都是堆不够, 而堆是会被释放的(旧任务退出、缓冲释放),所以隔一段时间再试是有可能成功的。 但如果不加间隔地疯狂重试,就变成了“一个任务在死循环里分配内存”, 白白消耗 CPU,而且会把真正能让出堆的那个任务饿着——越急越拿不到内存。 秒级的间隔,对“人手操作触发一次状态切换”的节奏来说完全够用。
这件事让我改了一个习惯:凡是返回状态码的 API,先想清楚“失败了会怎样”,再决定要不要忽略它。 像任务创建、队列发送、I²C 读写这类调用, 成功路径是默认的,失败路径才是代码质量的体现。
更进一步的想法是:与其失败后重试,不如别让它失败。
事件管理器任务本身就是静态分配的(StackType_t 数组 + StaticTask_t),
完全不依赖堆。但状态任务是按需创建/删除的,要做到静态分配,
就得为每个状态预留常驻 RAM——那就等于放弃了“按需”带来的内存节省。
这是一个确定性换 RAM 占用的取舍,我目前还没有结论:
在小 RAM 的板子上按需创建更划算,在要求长期稳定运行的场合静态分配更放心。
六、故障分级:为什么故障码要有“优先级”和“可清除等级”
故障处理的第一版我想得很简单:一个全局变量 err_num,出故障就赋值,清故障就清零。
很快遇到两个问题:
- 两个故障前后脚发生,后发生的把先发生的覆盖了,现场看到的是轻的那个;
- 有些故障是“原因消失了就该自己好”(比如温度降下来), 有些必须人工确认过才能恢复,代码里分不清。
第一层设计:只升不降的故障锁存
工程里的做法是让故障码带上优先级:数字越大优先级越高,新故障只有比当前故障更严重时才能覆盖。 设置接口本身非常短:
/* 故障锁存的骨架:故障码越大优先级越高,只升不降(示意片段) */
u8 set_err(u8 num)
{
if (num > err_num) { /* 只有比当前故障更严重,才允许覆盖 */
err_num = num;
} else {
return 1; /* 当前故障更严重:本次设置失败 */
}
return 0;
}
这段骨架里唯一保留的判定就是“比当前故障更严重才允许覆盖”,
与具体哪个故障码对应什么业务无关;故障码的取值与含义属于项目内部的整定值,本文不列出。
配套的读取接口是 u8 get_err(void);。
这个“只升不降”的设计好处很直接:严重故障不会被后续的轻微故障刷掉,
现场一定停在最严重的那一个上,不会被一串小故障冲掉关键信息。
代价也很明确:轻微故障会被淹没——它确实发生过,但因为没有当前故障严重,
连记录都没留下;而且这个变量只能被显式清除,不会自己恢复。
至于被淹没的那些故障怎么办,我的想法是走外部 NOR Flash 存故障记录 (从源码命名看,那颗 Flash 除了页面数据和字库,注释里还提到用来存“记录”)。 不过这一点我没有实际验证过故障历史到底存了哪些字段,所以只能算一个方向,不算结论。
第二层设计:用数值区间做“可清除等级”
光有优先级还不够,还得回答“这个故障该怎么清”。工程里用了一个我觉得很聪明的办法: 不维护一张“哪个故障属于哪一级”的列表,而是直接按数值区间划分。
| 故障码落在哪一段区间 | 可清除性 | 处理方式 |
|---|---|---|
| 数值最小的一段区间 | 可手动清除 | 按“取消/切换”键即可清零,并走一次状态切换回到等待状态 |
| 紧接其后的中间一段区间 | 需密码清除 | 进入密码输入流程,密码正确才把故障码清零 |
| 数值最大的一段区间 | 不可手动清除 | 只能等故障原因消失后由程序自己恢复(例如温度降下来、液位到位) |
这个划分的三档,其实对应三种不同的现实语义: “操作者知道怎么处理”“需要授权才能处理”“处理不了,只能等条件恢复”。 区间法的好处是:以后新增故障码,只要落在对的区间里,就自动具备对应的清除策略, 清除逻辑一行都不用改。这在故障码从十几个涨到几十个的时候,省下的是成倍的心智负担。
坏处同样明显:区间边界变成了隐式契约。 它不写在类型里,也不写在函数签名里,只存在于“大家都知道中间那一段是从哪里起算”的默契中。 后来人在分段边界上新增一个故障码(在他眼里只是“又一个故障码”), 就会莫名要求现场输密码才能清——而他自己完全不知道发生了什么。 所以这套设计必须配一份写清楚的文档:区间表要写进注释、写进说明文档,并且在新故障码评审时被当成硬约束。 我现在的做法是在头文件里把这三档写成宏和注释,让它至少离定义近一点。
故障显示也走同一套区间判断
故障显示的分派逻辑不另起一套标准,而是复用同样的三段区间:
- 第一段(可手动清除):直接把故障码显示出来;
- 第二段(需密码清除):切到密码输入界面,尝试额度用完就清空该行, 同时在固定的一行上实时显示剩余额度;
- 第三段(不可手动清除):只显示一个故障位置提示, 不给任何可操作入口。
我最认同的是最后一条:对“不可手动清除”的故障,界面就应该干脆不提供入口, 而不是给一个按了没反应的键。给一个无效入口,操作者会反复按、以为是自己按错了, 最后把设备拆开;不给入口,至少信息是明确的——“这个不是你能清的”。
七、故障现场的保护密码:设置类操作该不该有门槛
上表中间那一档需要密码才能清除。这套流程的实现我不贴代码, 但它用到的几项记录本身就说明了设计意图——位数是固定的,尝试额度是有限的:
| 记录什么 | 含义 | 作用 |
|---|---|---|
| 已输入的每一位 | 按位存放,一位一格 | 凑满固定位数之后才开始一次比对 |
| 已输入的位数 | 从零开始累加 | 判断“够不够一次比对”的唯一依据 |
| 允许尝试的额度 | 一个有限的次数额度 | 额度实时显示给操作者,用完即失效 |
处理流程本身不长,但每一步都有它的理由。下面是我按理解整理的步骤与顺序, 没有保留任何可直接使用的实现:
- 每按下一个数字键,先把这一位存进按位数组。 存放位置由“已输入的位数”决定,一位一格。
- 存完立刻把按键值清掉。 按键值是全局共享的,不清掉的话,同一个按键事件可能在下一轮循环里被再消费一次, 表现为“按一下出两位”。凡是已经消费掉的按键事件,都要马上复位它。
- 只有还剩尝试额度,才允许位数继续累加。 额度用完之后输入直接失效——位数不再增长,也就永远凑不够一次比对的机会。 这比“先凑够位数、再判断还能不能比对”更干净,因为它从源头切断了继续尝试的可能。
- 位数凑满之后,按各位的位权拼成一个整数,只做一次比对。 一次比较就够,逻辑清楚;前提是位权必须严格对齐,中间错一位,比的就不是同一个数了。
- 比对结果决定两件互斥的事。 正确:把故障码清零,并且走一次状态切换回到等待状态, 而不是就地改一个标志位;错误:把位数计数清零重新输入,同时把尝试额度减一, 并把剩余额度显示给操作者。
安全评估:这是操作门槛,不是安全边界
这一点我想写清楚,因为它最容易被误解。这套密码是本地物理按键上的防护, 不是网络认证:它没有账号、没有会话、没有加密传输,就是几个数字键加一段比较逻辑。我评估下来:
- 它要防的是“无关人员误操作”——比如有人路过随手按了几下, 把关键参数改了或者把故障清掉了。在这个目标上它是有效的:它把操作门槛从“随手按一下” 提到了“知道密码才按得下去”。
- 它挡不住有意尝试的人。固定的纯数字位数、有限的尝试额度, 而且没有锁定延时、没有错误累积惩罚, 理论上存在被逐个试出的空间——只要试的次数够多,穷举总是能走完。 就算加了锁定延时,本地物理接触本身就是一个很强的前提 ——能摸到设备的人,通常也有别的办法。
- 所以正确的定位是:它是操作门槛,不是安全边界。 真正需要防未授权操作的功能,应该靠物理手段(钥匙开关、机柜上锁)或者 带认证的远程通道来保证,而不是指望这段按键逻辑。
还有一个设计细节值得记下来:故障清除被实现成了一次状态切换, 而不是就地改标志位。密码正确后请求切回等待状态, 走一遍正常的切换流程:创建目标任务、等切换完成、旧状态停止下发动作。 这样做的价值是所有外设都会回到一个已知的初始状态, 不会出现“故障标志清了,但某个继电器还停在故障前的输出上”这种情况。 这也是我后来形成的一条习惯:恢复也走正常路径,不要走捷径。
八、几条我反复用到的经验
- 先让状态可观察,再谈代码组织。状态要能显示、能上传, 否则它在排查现场的价值等于零。
- 枚举的第一个成员显式写
= 0。 只要这个值会进 Flash 或者走通信,它就是对外契约,不能靠语言默认值兜着。 - 状态描述“在做什么”,条件描述“满不满足”。 把条件塞进状态,早晚要绕回标志位那套写法。
- 切换必须是原子的:请求 → 创建 → 提交,三步走完才算切换完成。 中间态要显式禁止旧状态继续动作,这段判断删掉在实验室看不出来,在现场会要命。
- 不要忽略“创建任务”这个动作的返回值。 创建失败却提交了状态,设备会停在“显示正常但什么都不做”的状态,最难查。
- 重试要有间隔。内存类失败靠“隔一会儿再试”,不是靠“更快地试”。
- 宏体里有运算符就整体加括号;加不明白的,写成
static inline函数。 这是被KEEP_STATE咬过之后立的规矩。 - 故障码要带优先级和可清除等级,而且区间边界一定要写进文档—— 隐式契约是留给后来人的坑。
- 恢复走正常路径。清故障 = 一次状态切换,让外设回到已知初始状态, 不要就地改标志位。
- 区分“操作门槛”和“安全边界”。按键密码属于前者, 在文档里就要如实这么写,不要让它承担它承担不了的责任。
参考资料与说明
- FreeRTOS 官方文档中关于任务创建(
xTaskCreate)与栈深度参数的说明, 特别是“栈深度以字为单位而不是字节”这一条,属于公开文档内容。 - HC32F460 系列用户手册(GPIO / UART / SPI / I²C 等外设章节),用于理解主控外设的配置方式。
- 74HC595 与 74HC165 数据手册,用于理解串行移位扩展 IO 的时序。
- 本文涉及的 MCU 型号、状态划分与代码,均来自个人学习项目中的实现, 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 项目相关的单位、客户与业务信息已做脱敏处理。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
这台设备完整的硬件构成、驱动清单和一些我踩过的坑,整理在个人实验页 《液体加注控制器:从状态机到故障分级》里; 如果关心设备对外通信那一段,可以看 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》, 那篇讲的是状态和故障怎么变成一张寄存器表被上位机读走。