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

分层状态机与故障优先级:
把“设备现在怎么了”讲清楚

一台设备出问题时,最先被问到的永远是同一句话:“它现在到底在干什么?” 这句话答不上来,后面所有排查都是猜。这篇笔记整理我在一台液体加注控制器上做分层状态机的过程: 状态怎么划、为什么用“一个状态一个 FreeRTOS 任务”、切换为什么必须是原子的、 任务创建失败为什么必须处理返回值,以及故障码为什么要有优先级和可清除等级。

分层状态机 FreeRTOS 故障分级 HC32F460 已发布

一、状态机的第一作用是“让设备的状态可被外部观察”

我一开始做状态机,动机很功利:代码太乱了,想理一理。 那时候一个主循环里散着一堆标志位: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_INIT0上电初始化:外设、参数、显示自检上电,或故障恢复后需要重新初始化外设初始化完成 → SYS_WAIT
SYS_WAIT1空闲待命,可以接受请求初始化完成 / 加注结束 / 故障被清除有请求 → SYS_RUN 或 SYS_QUE;按键 → SYS_SET
SYS_RUN2正在执行加注从等待或排队进入加注完成 → SYS_WAIT;异常 → SYS_ERR
SYS_ERR3故障态:停在安全输出,显示故障信息任意状态下 set_err() 生效故障被清除 → SYS_WAIT
SYS_SET4设置菜单(在几个参数项之间导航)待命状态下按键进入退出 → SYS_WAIT;选中某项 → SYS_SET_CONTENT
SYS_QUE5排队:设备已被占用,本请求在等设备正在为别的请求服务轮到自己 → SYS_RUN
SYS_SET_CONTENT6设置内容:正在编辑某一个参数的值从设置菜单里选中一项保存或返回 → SYS_SET

为什么第一个成员要显式写 = 0

C 语言里枚举首成员不写值就是 0,所以 SYS_INIT = 0 看起来是废话。 但我现在会坚持写上,理由是:这个枚举值不是只在 RAM 里活着的。 系统状态变量会被写进 Flash 保存、会通过通信寄存器发给上位机、会出现在日志里。 只要它离开过编译单元,0 就成了一份对外契约, 而“隐式等于 0”只是一条语言规则。

风险在于改动:如果以后有人在枚举最前面插一个新状态、又忘了给它显式赋值, 那么所有后续成员的值会整体后移一位。这时候 Flash 里存着的老值含义就全变了—— 老设备升级固件后读到“2”,本来以为是在等待,实际却被解释成加注。 显式写出 = 0 至少能让改动者在插入新成员时多看一眼这里。 同样的道理,状态变量我用了固定的 u8 而不是枚举类型,就是为了让它的宽度是确定的。 至于这些值具体怎么写进 Flash、写坏了怎么恢复,我整理在 《嵌入式参数存储设计:Flash 分区、双区备份与 CRC 校验》里。

顺带说一个我一开始没想明白、后来才接受的点:SYS_SET 和 SYS_SET_CONTENT 被我拆成了两个状态。拆开的好处是两套界面、两套按键处理各自独立, 菜单导航的代码不会和数值编辑的代码搅在一起;代价是这两者之间会频繁来回切换, 而每次切换都要走一遍“创建任务”的流程(后面会讲,这不是免费的)。

六个状态圆框:初始化、等待、运行、故障、设置、排队,箭头标注迁移条件与方向,形成一张连通的状态图
图 1 · 应用层状态迁移图

三、“一个状态一个任务”:好在哪、坑在哪

这是这套架构里最有辨识度的一个决定:每个状态对应一个独立的 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 两个变量之后, 切换就不再是“改一个变量”,而是一个三步流程:

  1. 请求:某处调用 set_next_sys_state(SYS_WAIT),只改 next;
  2. 创建:事件管理器发现 current != next,按目标状态去创建对应的任务;
  3. 提交:任务创建成功后,才把 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;
}

顺带说一句并发问题。这套状态变量是 static u8, 读写它们的只有事件管理器任务:切换动作是在事件管理器上下文里发起的, 中断里不碰这两个变量。这是一个单写者模型,所以不需要临界区, 也不需要 volatile。但这份“不需要”是有前提的—— 哪天有人在中断里也去 set_next_sys_state(),这个前提就破了, 那时候要考虑的就不只是 volatile,还有“读-改-写”被打断的问题。

五、任务创建失败怎么办:一个容易被忽略的返回值

xTaskCreate() 是有返回值的:成功返回 pdPASS,失败返回 pdFAIL。 这个返回值特别容易顺手忽略掉,因为它 99% 的时间都是成功。 而忽略它的后果很隐蔽:

如果不管返回值、直接把 current_sys_state 赋成 next_sys_state, 程序就会认为“我已经切到等待状态了”,但实际根本没有对应的任务在跑。 设备停在一个既不采集、也不响应、也不报警的地方, 而状态变量还显示一切正常。这是最难查的一类问题。

工程里的做法是:只有创建成功才提交新状态;创建失败就记住“本来要切到哪”,并返回失败。 这段逻辑我没有把实现贴出来,因为它正好是这个项目里最不愿意公开的那一段; 但它其实只有四步,而每一步的顺序都有理由:

  1. 先把目标状态对应的那个任务建出来。 此时一个字都不动当前状态——创建过程本身可能失败,失败之前不要让状态变量有任何变化。
  2. 建失败就记住“本来要切到哪”。 把目标状态存进一个“待重试”变量,然后返回一个失败值,让调用方知道这次没切成。
  3. 只有建成功,才提交当前状态。 提交这个动作本身只有一行,但它必须发生在创建成功之后,不能提前。
  4. 提交之后,再让旧状态退出。 从提交那一刻起,旧状态才被允许停止下发控制动作、释放自己占用的资源。

下面这张表是我把这四步写下来之后才理清的——每一步都不能和相邻的步骤换位置:

顺序这一步做什么为什么必须排在这个位置
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 除了页面数据和字库,注释里还提到用来存“记录”)。 不过这一点我没有实际验证过故障历史到底存了哪些字段,所以只能算一个方向,不算结论。

第二层设计:用数值区间做“可清除等级”

光有优先级还不够,还得回答“这个故障该怎么清”。工程里用了一个我觉得很聪明的办法: 不维护一张“哪个故障属于哪一级”的列表,而是直接按数值区间划分。

故障码落在哪一段区间可清除性处理方式
数值最小的一段区间 可手动清除 按“取消/切换”键即可清零,并走一次状态切换回到等待状态
紧接其后的中间一段区间 需密码清除 进入密码输入流程,密码正确才把故障码清零
数值最大的一段区间 不可手动清除 只能等故障原因消失后由程序自己恢复(例如温度降下来、液位到位)

这个划分的三档,其实对应三种不同的现实语义: “操作者知道怎么处理”“需要授权才能处理”“处理不了,只能等条件恢复”。 区间法的好处是:以后新增故障码,只要落在对的区间里,就自动具备对应的清除策略, 清除逻辑一行都不用改。这在故障码从十几个涨到几十个的时候,省下的是成倍的心智负担。

坏处同样明显:区间边界变成了隐式契约。 它不写在类型里,也不写在函数签名里,只存在于“大家都知道中间那一段是从哪里起算”的默契中。 后来人在分段边界上新增一个故障码(在他眼里只是“又一个故障码”), 就会莫名要求现场输密码才能清——而他自己完全不知道发生了什么。 所以这套设计必须配一份写清楚的文档:区间表要写进注释、写进说明文档,并且在新故障码评审时被当成硬约束。 我现在的做法是在头文件里把这三档写成宏和注释,让它至少离定义近一点。

故障显示也走同一套区间判断

故障显示的分派逻辑不另起一套标准,而是复用同样的三段区间:

  • 第一段(可手动清除):直接把故障码显示出来;
  • 第二段(需密码清除):切到密码输入界面,尝试额度用完就清空该行, 同时在固定的一行上实时显示剩余额度;
  • 第三段(不可手动清除):只显示一个故障位置提示, 不给任何可操作入口。

我最认同的是最后一条:对“不可手动清除”的故障,界面就应该干脆不提供入口, 而不是给一个按了没反应的键。给一个无效入口,操作者会反复按、以为是自己按错了, 最后把设备拆开;不给入口,至少信息是明确的——“这个不是你能清的”。

一条横向数值轴,从左到右分成三段,每段标注清除方式:按键手动清除 / 需输入密码 / 不可手动清除只能等条件消失
图 2 · 故障码三级可清除性分区图

七、故障现场的保护密码:设置类操作该不该有门槛

上表中间那一档需要密码才能清除。这套流程的实现我不贴代码, 但它用到的几项记录本身就说明了设计意图——位数是固定的,尝试额度是有限的:

记录什么含义作用
已输入的每一位按位存放,一位一格凑满固定位数之后才开始一次比对
已输入的位数从零开始累加判断“够不够一次比对”的唯一依据
允许尝试的额度一个有限的次数额度额度实时显示给操作者,用完即失效

处理流程本身不长,但每一步都有它的理由。下面是我按理解整理的步骤与顺序, 没有保留任何可直接使用的实现:

  1. 每按下一个数字键,先把这一位存进按位数组。 存放位置由“已输入的位数”决定,一位一格。
  2. 存完立刻把按键值清掉。 按键值是全局共享的,不清掉的话,同一个按键事件可能在下一轮循环里被再消费一次, 表现为“按一下出两位”。凡是已经消费掉的按键事件,都要马上复位它。
  3. 只有还剩尝试额度,才允许位数继续累加。 额度用完之后输入直接失效——位数不再增长,也就永远凑不够一次比对的机会。 这比“先凑够位数、再判断还能不能比对”更干净,因为它从源头切断了继续尝试的可能。
  4. 位数凑满之后,按各位的位权拼成一个整数,只做一次比对。 一次比较就够,逻辑清楚;前提是位权必须严格对齐,中间错一位,比的就不是同一个数了。
  5. 比对结果决定两件互斥的事。 正确:把故障码清零,并且走一次状态切换回到等待状态, 而不是就地改一个标志位;错误:把位数计数清零重新输入,同时把尝试额度减一, 并把剩余额度显示给操作者。

安全评估:这是操作门槛,不是安全边界

这一点我想写清楚,因为它最容易被误解。这套密码是本地物理按键上的防护, 不是网络认证:它没有账号、没有会话、没有加密传输,就是几个数字键加一段比较逻辑。我评估下来:

  • 它要防的是“无关人员误操作”——比如有人路过随手按了几下, 把关键参数改了或者把故障清掉了。在这个目标上它是有效的:它把操作门槛从“随手按一下” 提到了“知道密码才按得下去”。
  • 它挡不住有意尝试的人。固定的纯数字位数、有限的尝试额度, 而且没有锁定延时、没有错误累积惩罚, 理论上存在被逐个试出的空间——只要试的次数够多,穷举总是能走完。 就算加了锁定延时,本地物理接触本身就是一个很强的前提 ——能摸到设备的人,通常也有别的办法。
  • 所以正确的定位是:它是操作门槛,不是安全边界。 真正需要防未授权操作的功能,应该靠物理手段(钥匙开关、机柜上锁)或者 带认证的远程通道来保证,而不是指望这段按键逻辑。

还有一个设计细节值得记下来:故障清除被实现成了一次状态切换, 而不是就地改标志位。密码正确后请求切回等待状态, 走一遍正常的切换流程:创建目标任务、等切换完成、旧状态停止下发动作。 这样做的价值是所有外设都会回到一个已知的初始状态, 不会出现“故障标志清了,但某个继电器还停在故障前的输出上”这种情况。 这也是我后来形成的一条习惯:恢复也走正常路径,不要走捷径。

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

  1. 先让状态可观察,再谈代码组织。状态要能显示、能上传, 否则它在排查现场的价值等于零。
  2. 枚举的第一个成员显式写 = 0。 只要这个值会进 Flash 或者走通信,它就是对外契约,不能靠语言默认值兜着。
  3. 状态描述“在做什么”,条件描述“满不满足”。 把条件塞进状态,早晚要绕回标志位那套写法。
  4. 切换必须是原子的:请求 → 创建 → 提交,三步走完才算切换完成。 中间态要显式禁止旧状态继续动作,这段判断删掉在实验室看不出来,在现场会要命。
  5. 不要忽略“创建任务”这个动作的返回值。 创建失败却提交了状态,设备会停在“显示正常但什么都不做”的状态,最难查。
  6. 重试要有间隔。内存类失败靠“隔一会儿再试”,不是靠“更快地试”。
  7. 宏体里有运算符就整体加括号;加不明白的,写成 static inline 函数。 这是被 KEEP_STATE 咬过之后立的规矩。
  8. 故障码要带优先级和可清除等级,而且区间边界一定要写进文档—— 隐式契约是留给后来人的坑。
  9. 恢复走正常路径。清故障 = 一次状态切换,让外设回到已知初始状态, 不要就地改标志位。
  10. 区分“操作门槛”和“安全边界”。按键密码属于前者, 在文档里就要如实这么写,不要让它承担它承担不了的责任。

参考资料与说明

  • FreeRTOS 官方文档中关于任务创建(xTaskCreate)与栈深度参数的说明, 特别是“栈深度以字为单位而不是字节”这一条,属于公开文档内容。
  • HC32F460 系列用户手册(GPIO / UART / SPI / I²C 等外设章节),用于理解主控外设的配置方式。
  • 74HC595 与 74HC165 数据手册,用于理解串行移位扩展 IO 的时序。
  • 本文涉及的 MCU 型号、状态划分与代码,均来自个人学习项目中的实现, 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 项目相关的单位、客户与业务信息已做脱敏处理。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
  • 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。

这台设备完整的硬件构成、驱动清单和一些我踩过的坑,整理在个人实验页 《液体加注控制器:从状态机到故障分级》里; 如果关心设备对外通信那一段,可以看 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》, 那篇讲的是状态和故障怎么变成一张寄存器表被上位机读走。