乐于分享
好东西不私藏

【QF状态机·下篇】6 万字 PDF + 全套源码 + 22 题作业答案

【QF状态机·下篇】6 万字 PDF + 全套源码 + 22 题作业答案

【QF状态机·下篇】6 万字 PDF + 全套源码 + 22 题作业答案

【摘要】拆解一个真实 QP 项目:1 个 AO 装下 13 个状态 + 8 个定时器,2000 行代码搞定双通道传感器采集 + 网络上报。文末有完整 6 万字 PDF + 配套源码包领取。


写完前两篇,我自己也复盘了一下:

中篇发完那个周末,我重新打开公司那个 QP 项目,居然第一次完全看懂了。之前看一头雾水的状态层次、定时器编排、跨 AO 通信,全部说得通。

但我也清楚地意识到一件事:学完 QP 又回去写 if-else 的人,我身边见过太多

不是 QP 不好,而是状态机思维需要刻意练习,跟你学了一周 Vim 回去还是用方向键一回事。

真正的飞跃发生在亲手解剖一个真实工程的时候:

  • 1 个活动对象 NetMgr
  • 13 个状态 / 3 层嵌套
  • 8 个定时器 / 18 个静态事件
  • 2000 行 C 代码搞定双通道传感器采集 + 网络上报 + 错误恢复

这种规模用 FreeRTOS + switch case 写大概要 6000 行,且没人维护得了。

这是「QF 状态机」下篇,拆解一个真实 QP 项目,提炼单 AO 大 HSM的设计方法论,带你从「看懂」走到「会写」。文末有完整 6 万字 PDF + 配套源码包领取方式,内容比公众号 3 篇详细得多。


01 · 工程定位:这是个什么项目

demo_qfnet_v1.0 是一个双通道传感器数据采集 demo

维度
配置
MCU
ARM Cortex-M 系列
网络
lwIP TCP/IP 协议栈 + 自定义 UDP
业务
双通道(CH1/CH2)传感器信号采集 + 处理 + 上报
框架
QP/C 4.5.04 + Vanilla 协作式内核
AO 数量
1 个(NetMgr,优先级 1)
总信号数
~30 个(8 个定时器 + 18 个静态事件 + 中断回调)

启动后做的事:

上电 → 初始化 PHY、加载 Flash 配置、获取 IP        ↓待机(Standby)        ↓收到上位机 UDP 命令 → 切到对应通道处理状态(Ch1Process / Ch2Process)        ↓周期采集传感器 → 打包成 UDP 帧上报        ↓出错 → 进入错误处理状态尝试自动恢复        ↓心跳 LED 持续闪烁、看门狗持续喂

🎯简单的业务,但用 QP 写出来的代码我服气


02 · 一个直击灵魂的问题:为什么只用 1 个 AO?

第一次看 QP 工程的人最常问:

「为什么不拆成 4~5 个 AO?网络一个、采集一个、协议一个,职责清晰啊。」

答案是架构选择单 AO + 大 HSMvs多 AO的取舍:

维度
单 AO + 大 HSM(本工程)
多 AO 设计
状态机规模
大(13 个状态 3 层)
每个 AO 小且聚焦
跨模块通信
全在状态机内部,零事件传递
需要 POST 跨 AO
并发问题
没有(只有 1 个 AO)
跨 AO 共享数据要小心
调试难度
状态层次深,但事件流单一
事件流跨 AO 跳转,trace 困难
适合场景
业务紧密耦合、单核小 MCU
业务可分解、有强实时模块

🔧这个工程的业务(采集+协议+通信)逻辑紧密耦合,切到 Ch1Process 后通信、采集、定时器全部联动。拆开 AO 反而要做大量同步。所以选了单 AO。

⚠️这是一个真正「看人下菜」的设计决策,不是 QP 教条说「必须 N 个 AO」,而是业务该用几个就用几个

顺便聊一句:QP vs FreeRTOS 是不是直接竞争?

很多人会问「QP 和 FreeRTOS 到底谁强」,其实它们不在一个赛道上

  • FreeRTOS 是主流,MIT 协议完全免费,生态成熟,中文资料海量,国内嵌入式岗位几乎默认要求
  • QP 是小众但深用,双协议(GPL+商业),专攻「状态多 + 嵌套深 + 并发杂」的复杂场景,主要在医疗、工业、航天

简单说:FreeRTOS 占领「任务调度」市场,QP 占领「复杂状态机+事件驱动」市场

🎯 选哪个,决定了你这个项目 5 年后是好维护、还是被骂屎山


03 · HSM 层次速览

QHsm_top└── NetMgr_Initial            (一次性初始化伪状态)    └── NetMgr_Running        (★ 父状态:管理 8 个 QTimeEvt)        ├── NetMgr_Standby        ├── NetMgr_Ch1Process (父:通道 1 共享逻辑)        │   ├── NetMgr_Ch1Idle        │   ├── NetMgr_Ch1Busy        │   ├── NetMgr_Ch1SeekZero        │   └── NetMgr_Ch1BuildPac        ├── NetMgr_Ch2Process (父:通道 2 共享逻辑)        │   ├── NetMgr_Ch2Idle        │   ├── NetMgr_Ch2Busy        │   ├── NetMgr_Ch2SeekZero        │   └── NetMgr_Ch2BuildPac        ├── NetMgr_Ch1ErrHnd        └── NetMgr_Ch2ErrHnd

13 个状态、3 层嵌套,这种规模用平坦 FSM 写是灾难。光是「错误处理任意时刻都能进入」这一条,就要在 12 个状态各写一次。HSM 用一个父状态 Running 一招摆平。


04 · 怎么读这个工程?三步法

第一次接手 QP 工程别想着「从 main 一行行往下读」,你会迷失在 lwIP 的 1000 行回调里。正确顺序

第 1 步:看 HSM 层次图        → 理解「状态机长什么样」,不看任何代码第 2 步:看 AO 结构体 + ctor + 静态事件清单        → 理解「AO 有哪些字段、哪些信号、哪些定时器」第 3 步:从 Initial 伪状态开始,沿「启动路径」读        → Initial → Running.ENTRY → 默认子状态 → 等事件

📌这套读法对任何 QP 工程都适用,把它记下来,以后接手老项目少走 80% 的弯路。


05 · NetMgr 数据结构

typedef struct NetMgrTag {    QActive super;                   // 继承 QActive    // 8 个时间事件(全部静态嵌入!)    QTimeEvt te_LWIP_SLOW_TICK;      // 250ms:lwIP 协议栈维护    QTimeEvt te_DAQ_CYCLE;           // 周期上报数据    QTimeEvt te_IDLE_STATE;          // 空闲超时    QTimeEvt te_HEART_BEAT;          // 500ms 心跳    QTimeEvt te_POLLING;             // 50ms 轮询通道    QTimeEvt te_ALARM_STATE;         // 1000ms 告警上报    QTimeEvt te_CH1_ERR_RESET;       // 通道 1 错误恢复    QTimeEvt te_CH2_ERR_RESET;       // 通道 2 错误恢复    // 网络资源    struct netif*   netif;    struct udp_pcb* upcb_comm;       // 配置命令收发    struct udp_pcb* upcb_tx;         // 数据发送    struct udp_pcb* upcb_ptp;        // PTP 时钟同步    /* ... */} NetMgr;static NetMgr l_netMgr;                              // 单例QActive* const AO_NetMgr = (QActive*)&l_netMgr;     // 不透明指针

关键设计8 个 QTimeEvt 全部嵌入 AO 结构体,不是 Q_NEW 出来的,跟 AO 同生命周期。这是 QP 工程师标配写法。


06 · 静态事件清单:为什么有这么多 Tran2XXX

static QEvt const evt_Reset            = { RESET_SIG,            00 };static QEvt const evt_Tran2Standby     = { TRAN2STANDBY_SIG,     00 };static QEvt const evt_Tran2Ch1ErrHnd   = { TRAN2CH1ERRHND_SIG,   00 };static QEvt const evt_Tran2Ch1Process  = { TRAN2CH1PROCESS_SIG,  00 };/* ... 共 18 个静态事件 ... */

疑问:为什么不直接在事件分支里 return Q_TRAN(&Standby)?为什么要绕一圈「投递 TRAN2STANDBY_SIG → 在父状态 case 这个信号 → 才 Q_TRAN」?

原因:状态迁移的触发点不在状态函数里,而是在中断lwIP 回调函数里。

回顾中篇的金科玉律:中断里只能 POST 事件,不能调任何状态机 API。所以中断里检测到「该切 Standby 了」时,不能直接 Q_TRAN(那不是状态函数上下文),只能:

// 在 UDP 接收回调里(中断/回调上下文)QACTIVE_POST(AO_NetMgr, &evt_Tran2Standby, sender);// → 框架在 RTC 时把这个事件交给状态函数 → case TRAN2STANDBY_SIGreturn Q_TRAN(&Standby);

🎯Tran2XXX 事件的本质「跨上下文传递的迁移请求」。所有这些都做成静态 const,因为它们不带参数,可反复 POST,零分配开销。


07 · Initial 伪状态:一次性的「开机仪式」

Initial 在 QHsm_init 时被框架调用一次,专门做只在上电时执行的初始化

QState NetMgr_Initial(NetMgr* me, QEvt const* e) {    /* === 硬件层初始化 === */    Phy_Reset();             // 复位以太网 PHY    LoadConfigFromFlash();   // 加载配置    Led_Init();    Timer0Init();            // 启动 1ms tick 定时器(QF_TICK 的源头)    /* === 网络层初始化(lwIP)=== */    me->netif     = eth_driver_init((QActive*)me, macaddr);    me->upcb_comm = udp_new();    udp_bind(me->upcb_comm, &localIp, local_port);    udp_recv(me->upcb_comm, &udp_rx_handler, me);    /* === 进入运行态 === */    return Q_TRAN(&NetMgr_Running);}

为什么把这些放 Initial 而不是 ctor?

放在
时机
适合做什么
NetMgr_ctor()
main 启动早期,QF 还没 run
纯数据初始化(变量赋初值、QTimeEvt_ctor)
NetMgr_Initial
QF_run 后第一次 dispatch 时
依赖中断的硬件初始化、网络栈初始化

📌 ctor 在 QF_run 前跑,那时候中断、调度都没启动,硬件初始化必须等 Initial


08 · Running 父状态:本工程最复杂的一个状态函数

下面把它按职责拆成 3 段,逐段读:

① ENTRY / EXIT:定时器集中管理

case Q_ENTRY_SIG:    QTimeEvt_postEvery(&me->te_LWIP_SLOW_TICK, me, MS_TO_TICK(250));    QTimeEvt_postEvery(&me->te_DAQ_CYCLE,      me, MS_TO_TICK(1000));  // 数据上报周期    QTimeEvt_postEvery(&me->te_HEART_BEAT,     me, MS_TO_TICK(500));    QTimeEvt_postEvery(&me->te_ALARM_STATE,    me, MS_TO_TICK(1000));    return Q_HANDLED();case Q_EXIT_SIG:    QTimeEvt_disarm(&me->te_LWIP_SLOW_TICK);    QTimeEvt_disarm(&me->te_DAQ_CYCLE);    QTimeEvt_disarm(&me->te_HEART_BEAT);    QTimeEvt_disarm(&me->te_ALARM_STATE);    QTimeEvt_disarm(&me->te_POLLING);    return Q_HANDLED();

② 跨状态全局命令:Tran2XXX 一字排开

case Q_INIT_SIG:          return Q_TRAN(&NetMgr_Standby);   // 默认子状态case RESET_SIG:           return Q_TRAN(&NetMgr_Running);   // ★ 自迁移=软重置case TRAN2STANDBY_SIG:    return Q_TRAN(&NetMgr_Standby);case TRAN2CH1PROCESS_SIGreturn Q_TRAN(&NetMgr_Ch1Process);case TRAN2CH2PROCESS_SIGreturn Q_TRAN(&NetMgr_Ch2Process);case TRAN2CH1ERRHND_SIG:  return Q_TRAN(&NetMgr_Ch1ErrHnd);case TRAN2CH2ERRHND_SIG:  return Q_TRAN(&NetMgr_Ch2ErrHnd);

③ 周期任务:所有子状态共享

case LWIP_SLOW_TICK_SIG:    /* lwIP 协议栈周期维护 */    return Q_HANDLED();case HEART_BEAT_TMROUT_SIG:    led_toggle();        // 心跳灯    ewdt_feed();         // 喂外部硬件看门狗    return Q_HANDLED();

末尾必须有一行 return Q_SUPER(&QHsm_top); 兜底,HSM 铁律。

4 个值得学的设计亮点

🎯亮点 1:自迁移 = 软重置

RESET_SIG → Q_TRAN(&Running) 触发 EXIT + ENTRY,所有定时器自动重启。一行代码搞定「重启所有周期任务」,不需要手写一堆 disarm + postEvery。

🎯亮点 2:Tran2XXX 都在父状态处理

无论当前在哪个子状态,收到 TRAN2STANDBY_SIG 时子状态不处理 → 上溯到 Running 处理 → 切到 Standby。这一招让「跨状态全局命令」零重复

🎯亮点 3:心跳灯 / lwIP 维护放在父状态周期处理

子状态切换时定时器不停(因为父状态没退),所以心跳灯永远在闪、lwIP 协议栈永远在跑。子状态只关心自己的业务逻辑。

🎯亮点 4:定时器 EXIT 集中撤销

disarm 全集中在父状态 EXIT 一处,看代码时一目了然,不需要在每个子状态翻找有没有漏撤的定时器。


09 · 中断怎么跟状态机交互

netmgr.c 里大量使用 lwIP 的 UDP 接收回调,这些回调跑在网卡中断上下文:

static void udp_rx_handler(void* arg, struct udp_pcb* upcb,                           struct pbuf* p, ...) {    NetMgr* me = (NetMgr*)arg;    uint8_t cmd = p->payload[0];    // ❌ 绝对不能这样写:    // QHsm_dispatch(&me->super, &evt);   // 中断里直调状态机会破坏 RTC    // ✅ 正确:构造事件 POST 给 AO,等 dispatch 在 RTC 时取出处理    switch (cmd) {        case CMD_GO_STANDBY:  QACTIVE_POST(&me->super, &evt_Tran2Standby,    me); break;        case CMD_START_CH1:   QACTIVE_POST(&me->super, &evt_Tran2Ch1Process, me); break;        /* ... */    }    pbuf_free(p);}

一个完整 UDP 命令到状态切换的链路

① 网卡接收以太网帧(DMA)       ↓② 网卡中断触发,lwIP 读取       ↓③ lwIP 解析到 UDP,调用 udp_rx_handler(仍在中断上下文)       ↓④ 回调里解析包头,识别命令       ↓⑤ QACTIVE_POST(AO_NetMgr, &evt_Tran2XXX, me)       ↓ ★ 边界点:从中断上下文回到主循环上下文⑥ 中断退出,主循环扫描发现 NetMgr 就绪       ↓⑦ 取出 evt_Tran2XXX → dispatch → Running 父状态处理 → Q_TRAN(&XXX)       ↓⑧ 完成状态切换

🎯金科玉律:中断/回调里只 POST 事件,业务逻辑全部在状态函数里执行。这是 QP 工程的纪律线,越是贵的项目越严格


10 · 设计方法论:一个 HSM 怎么从 0 画出来

学完这 3 篇还不够,下次你要自己设计一个状态机时,可能还是不知道从哪下手。

🔧 这套方法是我自己摸出来的,7 步,顺着走基本不会错

Step 1:列状态(不分层)

把系统**所有「工作模式」**列出来。判断标准:「这一刻在做的事,跟下一刻有本质不同吗?」

⚠️陷阱:把「瞬时事件」当状态。「按按钮」不是状态,它是事件。「按下按钮后等 1 秒」才是状态。

Step 2:列事件

  • 外部输入:按键、UART 命令、传感器超阈值、网络包
  • 内部信号:定时器到期、错误检测、子任务完成

Step 3:画初版图(平面,先不分层)

工具:纸笔 / Excalidraw / Visio / QM。矩形=状态,箭头=迁移,先把「状态 X 收到事件 Y 转到哪」全部画完。

Step 4:识别共性 → 抽父状态(HSM 关键转折)

问题
行动
多个状态对同一事件响应都切到同一目标?
把事件提到父状态
多个状态都需要某个定时器/外设资源
父状态 ENTRY 启动、EXIT 撤销
一组状态有共同的退出条件(比如全局错误)?
父状态统一处理

Step 5:识别默认子状态(Q_INIT_SIG)

每个复合状态必须指定一个默认子状态。

Step 6:补全 ENTRY/EXIT 动作

每个状态问两个问题:进入要做什么离开要清理什么

Step 7:写代码,按层次「挖洞」

1. 写顶层 Initial(一行 Q_TRAN(&Top) 即可)2. 写所有父状态空骨架(只写 ENTRY/EXIT,事件分支留空)3. 编译跑通,确认状态层次正确4. 填子状态业务5. 调试

🔧生产经验千万不要一开始就追求一次写完。编译 → 跑空骨架 → 看 LED/log 确认状态对了 → 再填业务,少走 80% 弯路。


11 · 10 个最常踩的坑

按出现频率排序:

反模式
表现
正解
状态函数里 delay_ms
整个 AO 卡住,其他事件全压着
用 QTimeEvt_postIn
中断里直接 QHsm_dispatch
状态机重入,变量被破坏
QACTIVE_POST
 投事件
Q_NEW
 后修改/缓存指针
野指针/脏数据,难复现
立刻 POST,绝不留引用
多个 AO 同优先级
QActive_start
 触发断言
各自唯一 prio
内存池大小颠倒
大事件分到小池,断言失败
严格升序 QF_poolInit
父状态忘了 Q_SUPER(&QHsm_top)
事件被静默吞掉
所有 HSM 状态末尾必须 Q_SUPER
跨状态用全局变量传数据
状态切换后值不一致
数据放 AO 结构体 me->xxx
QTimeEvt 同时挂两种定时
后调用覆盖前调用
用两个 QTimeEvt
Q_EXIT 忘 disarm 定时器
幽灵事件飞到新状态
撤销 + 父状态兜底
Q_ENTRY 里做耗时操作
状态切换变慢
耗时操作拆成下一轮 RTC

📌建议截图存起来,以后写 QP 代码每次自查一遍,bug 减半。


12 · 三篇总收获

QP 作者 Miro Samek 用了二十多年时间,持续输出关于「事件驱动状态机」的教材、讲座、开源代码,他的代表作《Practical UML Statecharts in C/C++》是嵌入式行业讲状态机最系统的一本书,几乎每个 QP 工程师入门都绕不过它。

他的核心观点贯穿整本书:状态机不是一种代码组织技巧,而是一种思维范式,学会之后,看待「系统怎么响应输入」的角度会被永久改变。

我学完 QP 三个月,深以为然。

跟着 3 篇下来,你应该已经具备:

状态机思想:FSM/HSM/LCA/Q_TRAN/Q_SUPER 几个核心概念都讲得清✅QF 框架机制:活动对象、RTC、内存池、发布订阅、时间事件全打通✅真实工程读图能力:看懂任何 QP 项目的 HSM 层次和事件流✅设计方法论:能独立画出新需求的 HSM 草图

但,真正的功夫在写代码

🎯建议拿一个你正在做的项目,重构其中一个最复杂的模块为 QP 状态机。第一次会很痛苦,第二次会很爽,第三次你会再也回不去原来的写法。

🎯最后送一句话「10 年嵌入式经验 ≠ 10 年成长」,很多人是 1 年经验用了 10 次,而不是真的进步了 10 年。学一次 QP,你会被迫重新思考自己写过的所有 if-else 代码,这种「重新思考」才是真正的成长加速器。


13 · 完整 PDF + 源码包领取

3 篇文章是公众号版的精炼版,完整版还有这些内容

📦完整版包括(共 6 万字 + 9 张机制图 + 完整源码):

  • ✅ 每天独立成章 + 导览(跟着学绝不迷路)
  • ✅ 所有作业 + 参考答案(22 道题,从思维到追踪到设计)
  • ✅ QP/C 4.5.04 完整框架源码(开源 GPL 版本)
  • ✅ 附录 A:QP/C API 速查
  • ✅ 附录 B:常见错误排查清单
  • ✅ 附录 C:综合设计大作业(双通道温控器骨架代码)
  • ✅ 进阶内容:QK 抢占式内核 + RTOS 对比 + QM 建模工具

怎么领

关注公众号「胡说有点理」→ 后台回复QF(输入 qf / 状态机 / PDF 也一样触发,不用怕记错)

⏰ 自动回复一个网盘链接,资料无限期有效,不定期补充更新。


互动 & 关注

📌本期评论区话题:你身边有没有还在用 QP 写商业项目的公司?业务领域是什么?评论区聊聊,我会汇总一下做下一篇调研稿。

如果觉得这 3 篇有用,点【在看】+ 转发给一个还在写 if-else 的同事。这是对我最大的支持。


关于作者

我是 Winston,嵌入式开发 8 年。最近在重构公司一个 12 年前的 QP/C 老项目,踩了一堆坑,把过程整理成 3 篇笔记 + 6 万字 PDF 分享出来。

关注公众号「胡说有点理」,聊嵌入式架构、状态机、RTOS 选型,有问题后台留言。


声明:本教程基于 QP/C 4.5.04 开源版本(GPL v3)讲解,用于学习交流。商业项目使用 QP/C 需联系 Quantum Leaps Inc. 获取商业 license。涉及的应用代码已脱敏,不代表任何特定公司项目。