尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32上从零移植QP状态机框架:事件驱动开发实战指南

STM32上从零移植QP状态机框架:事件驱动开发实战指南 1. 为什么要在STM32上折腾QP框架1.1 从裸机轮询到事件驱动的思维转变大多数刚接触STM32的朋友第一个项目基本都是跑马灯、按键扫描、串口收发这类裸机程序。主循环里放一堆if-else或者用HAL_Delay做简单的时间片轮转项目小的时候确实够用。但一旦系统里同时存在按键、串口协议解析、定时任务、状态切换、报警逻辑代码就会迅速膨胀成一团乱麻。我见过不少项目main.c里塞了上千行while(1)里面嵌套了七八层判断改一个功能要翻半天代码还容易引入新的bug。这种“超级循环标志位”的架构本质上是一种隐式的状态机。所有状态都散落在各个全局变量和条件判断里没有明确的状态边界也没有统一的事件分发机制。当系统复杂度上升到一定程度维护成本会呈指数级增长。QP框架Quantum Platform就是为解决这类问题而生的。它把活动对象Active Object、层次式状态机HSM、事件队列这几个概念组合在一起让嵌入式软件也能拥有清晰的架构。QP框架的核心思想其实不复杂每个功能模块是一个独立的活动对象拥有自己的事件队列和状态机对象之间不直接调用函数而是通过发送事件来通信。这样做的好处是模块解耦、逻辑清晰、可测试性强。对于STM32这类资源有限的MCU来说QP提供了QK、QV、QXK等多种内核其中QV和QK对RAM和Flash的占用相当克制在STM32F103这类芯片上跑起来毫无压力。1.2 QP框架适合什么样的STM32项目不是所有项目都值得上QP。如果你的项目就是读个传感器、控制个继电器裸机足够了。但如果你遇到下面这些情况QP的价值就会非常明显系统有多个独立的功能模块模块之间有复杂的交互逻辑存在明显的状态切换比如设备从待机到运行到故障到恢复需要处理异步事件比如串口中断、定时器超时、按键触发项目需要长期维护和迭代代码结构必须清晰有毕业设计或产品级开发的需求需要体现架构设计能力我个人的经验是当你的while(1)里开始出现超过三个层级的嵌套判断或者全局标志位超过十个就该考虑引入状态机框架了。QP的学习曲线前期确实比裸机陡一些但一旦跑通第一个例子后面就是复制粘贴加改状态图的事。1.3 本文能带你实现什么这篇内容会从零开始带你在STM32上把QP框架跑起来。具体包括开发环境搭建、QP源码移植、板级支持包编写、第一个活动对象的创建、层次式状态机的实现、事件发送与处理、以及实际调试中会遇到的各种坑。最终你会得到一个可以直接编译下载的完整工程包含一个用状态机实现的LED闪烁加按键控制示例。代码我会尽量给全关键配置会解释清楚为什么这么设。需要说明的是QP框架有官方版本和多种移植方式我这里采用的是QP/C最新稳定版配合STM32CubeMX生成的HAL库工程开发环境用Keil MDK。这套组合在STM32F103C8T6最小系统板上验证过其他F1、F4系列只需调整时钟和引脚配置即可。2. 开发环境准备与QP源码获取2.1 工具链选型与版本说明工欲善其事必先利其器。STM32开发环境的选择很多IAR、Keil、STM32CubeIDE、PlatformIO各有拥趸。我这里选Keil MDK 5.x原因是它在国内资料最全芯片包安装方便调试器兼容性好。如果你习惯用STM32CubeIDE流程基本一致只是工程配置界面不同。具体版本建议工具推荐版本说明Keil MDK5.36以上需要安装STM32F1xx_DFP芯片包STM32CubeMX6.x用于生成HAL初始化代码QP/C7.x官方GitHub仓库下载调试器ST-Link V2配合Keil直接下载调试QP/C的源码可以从官方仓库获取下载后解压目录结构大概是这样的qp/c下面是核心源码ports下面是各种编译器和平台的移植文件examples下面是示例工程。我们主要关注src目录下的qf、qv、qk、qs等文件夹以及ports/arm-cm/下面的Cortex-M移植文件。2.2 STM32CubeMX工程创建要点先用CubeMX建一个基础工程。芯片选STM32F103C8T6时钟配置成72MHz调试接口开SWD。GPIO配置两个引脚一个推挽输出接LED一个上拉输入接按键。串口开一个USART1用于打印调试信息波特率115200。定时器开一个基本定时器比如TIM2配置成1ms中断给QP提供系统时基。这里有个关键点QP需要一个周期性的时钟节拍来驱动时间事件。官方移植通常用SysTick但SysTick已经被HAL库占用做延时了。我的做法是用一个独立的基本定时器产生1ms中断在中断里调用QF_TICK()。这样既不影响HAL_Delay又能给QP提供稳定的时基。CubeMX生成代码时工具链选MDK-ARM V5勾选“Generate peripheral initialization as a pair of .c/.h files”。这样生成的代码结构清晰方便后续把QP源码加进来。2.3 QP源码目录的精简与移植官方QP源码包含很多用不到的东西直接全加进工程会让编译变慢。我的做法是只保留必要文件qp/src/qf/下的所有.c文件这是框架核心qp/src/qv/下的qv.c这是QV内核qp/src/qk/下的qk.c这是QK内核二选一qp/ports/arm-cm/qv/或qk/下的移植文件qp/src/qs/下的qs.c和qs_fp.c用于软件追踪调试把这些文件复制到工程的Middlewares/QP目录下然后在Keil里新建分组添加进去。头文件路径也要相应配置把qp/include和各个模块的目录都加进Include Paths。注意QP源码里有一些配置文件如qf_port.h、qep_port.h这些在ports目录下有模板需要根据你的平台修改。不要直接改官方源码而是复制一份到你的工程目录再改。3. QP框架核心概念与移植细节3.1 活动对象与事件队列的工作机制活动对象是QP的核心概念。你可以把它理解为一个“带邮箱的独立任务”。每个活动对象有自己的状态机、自己的事件队列、自己的优先级。外部想让它做事不是直接调函数而是往它的队列里塞一个事件。它在自己的线程上下文里从队列取事件然后交给状态机处理。这种设计的好处是天然线程安全。因为所有状态变量都是活动对象私有的只有它自己能改不存在多个任务同时写一个变量的问题。事件队列充当了缓冲区发送方和接收方解耦发送方不需要等待接收方处理完。在QV内核下活动对象是“运行到完成”的。也就是说一个事件的处理函数必须一口气执行完不能阻塞。如果你在状态机里调用了HAL_Delay整个系统就卡住了。这是新手最容易犯的错误。正确的做法是把耗时操作拆成多个步骤用时间事件或者状态切换来驱动。3.2 层次式状态机的优势与实现方式普通的状态机是扁平的所有状态平级。层次式状态机允许状态嵌套子状态可以继承父状态的行为。举个例子一个设备有“运行”和“停止”两个父状态“运行”下面又分“正常”和“报警”两个子状态。当设备在“报警”状态时收到“停止”事件如果“报警”状态没有处理这个事件它会自动冒泡到“运行”父状态父状态再冒泡到顶层。这样你就不需要在每个子状态里重复写相同的处理逻辑。QP用宏来实现状态机写起来有点像汇编。一个典型的状态函数长这样QState Blinky_led_on(Blinky * const me, QEvt const * const e) { switch (e-sig) { case Q_ENTRY_SIG: { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); return Q_HANDLED(); } case Q_EXIT_SIG: { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); return Q_HANDLED(); } case TIMEOUT_SIG: { return Q_TRAN(Blinky_led_off); } } return Q_SUPER(QHsm_top); }Q_ENTRY_SIG和Q_EXIT_SIG是状态进入和退出时自动触发的事件用来做初始化和清理。Q_TRAN表示状态迁移Q_SUPER指定父状态。这种写法初看别扭但写多了会发现它把状态迁移的路径表达得非常精确。3.3 板级支持包与系统时基配置QP移植到STM32最关键的是提供两个东西一个是临界区保护一个是系统时基。临界区保护用QF_INT_LOCK()和QF_INT_UNLOCK()宏实现通常就是关中断和开中断。在Cortex-M上可以用__disable_irq()和__enable_irq()但更好的做法是保存和恢复PRIMASK寄存器支持嵌套。系统时基就是前面说的1ms定时器中断。在中断服务函数里调用QF_TICK()QP会根据这个节拍来触发时间事件。比如你调用QTimeEvt_armX(me-timeEvt, 100, 0)意思就是100个tick之后触发一次超时事件。如果tick是1ms那就是100ms。void TIM2_IRQHandler(void) { HAL_TIM_IRQHandler(htim2); QF_TICK_X(0U, l_tickHook); }QF_TICK_X的第一个参数是tick速率第二个是回调钩子一般传NULL或者一个空函数。注意这个函数要在中断里调用所以它必须是可重入的。4. 从零搭建第一个QP工程4.1 工程目录结构与文件组织一个清晰的目录结构能让后续维护省很多事。我的工程目录是这样组织的Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── app.h │ │ └── bsp.h │ └── Src/ │ ├── main.c │ ├── app.c │ └── bsp.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ ├── Middlewares/ │ └── QP/ │ ├── src/ │ └── ports/ └── MDK-ARM/ └── Project.uvprojxapp.c里放活动对象的实现bsp.c里放板级支持代码main.c只负责初始化和启动QP。这样分层之后换一块板子只需要改bsp.c业务逻辑完全不用动。4.2 活动对象类的定义与实例化在QP里一个活动对象类通常包含三部分继承自QActive的基类、事件队列、时间事件。定义在头文件里typedef struct { QActive super; QTimeEvt timeEvt; uint8_t led_state; } Blinky; QActive * const AO_Blinky;super必须是第一个成员这样Blinky*可以安全地转换成QActive*。timeEvt是时间事件用来做周期性超时。led_state是自定义的状态变量。在.c文件里定义状态机函数和构造函数static QState Blinky_initial(Blinky * const me, QEvt const * const e); static QState Blinky_led_on(Blinky * const me, QEvt const * const e); static QState Blinky_led_off(Blinky * const me, QEvt const * const e); void Blinky_ctor(Blinky * const me) { QActive_ctor(me-super, Q_STATE_CAST(Blinky_initial)); QTimeEvt_ctorX(me-timeEvt, me-super, TIMEOUT_SIG, 0U); }构造函数里初始化基类和定时器事件。QTimeEvt_ctorX的第三个参数是超时事件的信号第四个参数是tick速率0表示使用默认。4.3 状态机的初始化与事件分发初始状态函数负责做初始迁移static QState Blinky_initial(Blinky * const me, QEvt const * const e) { return Q_TRAN(Blinky_led_off); }Blinky_led_off状态在进入时点亮LED并启动定时器static QState Blinky_led_off(Blinky * const me, QEvt const * const e) { switch (e-sig) { case Q_ENTRY_SIG: { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); QTimeEvt_armX(me-timeEvt, 500U, 0U); return Q_HANDLED(); } case TIMEOUT_SIG: { return Q_TRAN(Blinky_led_on); } } return Q_SUPER(QHsm_top); }QTimeEvt_armX的第二个参数是超时时间500个tick就是500ms。第三个参数是周期0表示单次触发。每次超时后迁移到另一个状态另一个状态再启动定时器如此往复LED就闪烁起来了。4.4 主函数与QP启动流程main.c里的流程很固定int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM2_Init(); static Blinky blinky; Blinky_ctor(blinky); AO_Blinky blinky.super; QActive_start(AO_Blinky, 1U, blinkyQueueSto, Q_DIM(blinkyQueueSto), (void *)0, 0U, (QEvt *)0); QF_run(); return 0; }QActive_start的参数依次是活动对象指针、优先级、队列存储数组、队列长度、栈存储QV内核不用、栈大小、初始化事件。QV内核下栈参数传0即可因为所有活动对象共用一个栈。QF_run()是个死循环永远不会返回。它从就绪队列里取出优先级最高的活动对象调用它的状态机处理事件。5. 事件驱动开发的实操要点5.1 自定义事件的发布与订阅QP内置的信号只有几个实际项目里需要定义自己的事件。推荐用枚举定义信号enum { BUTTON_PRESSED_SIG Q_USER_SIG, TIMEOUT_SIG, UART_RX_SIG, MAX_SIG };如果事件需要携带数据就定义一个继承自QEvt的结构体typedef struct { QEvt super; uint8_t key_id; uint32_t timestamp; } ButtonEvt;发布事件有两种方式直接投递和发布订阅。直接投递用QActive_postX()指定目标活动对象。发布订阅用QF_publish()所有订阅了该信号的活动对象都会收到。订阅用QActive_subscribe()。注意事件对象在投递时不会被复制所以不能用栈上的局部变量作为事件。正确做法是用静态变量或者内存池分配。QP提供了Q_NEW宏从事件池里分配事件。5.2 时间事件的灵活运用时间事件是QP里非常实用的功能。除了做周期性任务还可以做超时检测、延时迁移、软件定时器。QTimeEvt_armX的参数是时间事件指针超时tick数周期tick数。周期为0就是单次非0就是自动重载。有个细节要注意时间事件在超时后如果不再需要应该调用QTimeEvt_disarm()取消否则会一直占用。另外时间事件的精度取决于tick速率1ms的tick下精度就是1ms做毫秒级定时没问题微秒级就不行了。我常用时间事件做按键消抖按键中断里发一个事件给活动对象活动对象启动一个20ms的单次定时器定时器超时后再去读按键电平确认按下才触发业务逻辑。这样比在中断里延时消抖优雅得多。5.3 中断与活动对象的协作方式中断服务函数里不能直接调用活动对象的状态机因为状态机运行在任务上下文。正确的做法是在中断里投递事件void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); static ButtonEvt evt; evt.super.sig BUTTON_PRESSED_SIG; evt.key_id 0; QActive_postX(AO_Blinky, evt.super); }QActive_postX是中断安全的版本它不会阻塞。如果队列满了事件会被丢弃所以队列长度要设计合理。一般来说队列长度取最大突发事件数的两倍比较稳妥。如果中断里需要发布事件给多个订阅者用QF_publish()它也是中断安全的。但要注意发布的事件必须是从事件池分配的不能用静态变量因为多个订阅者可能同时持有同一个事件指针。6. 常见问题与调试技巧实录6.1 编译链接常见错误排查移植QP时最容易遇到的编译错误是头文件路径不对。QP的头文件引用关系比较复杂qf_port.h会引用qep_port.hqep_port.h又会引用qep.h。如果Include Paths没配全就会报找不到文件的错误。我的建议是把qp/include、qp/src/qf、qp/ports/arm-cm/qv这几个目录都加进去。另一个常见问题是重复定义。QP的移植文件里有一些全局变量比如QF_active[]数组如果多个.c文件都包含了定义就会冲突。解决办法是确保这些变量只在移植文件里定义一次其他文件用extern声明。链接错误里比较隐蔽的是栈溢出。QV内核下所有活动对象共用一个栈如果某个状态处理函数里定义了很大的局部数组就可能把栈撑爆。Keil里可以在启动文件里把栈大小改大一些比如从0x400改成0x800。6.2 运行时死机与事件丢失分析QP跑起来后如果死机最常见的原因是状态机里执行了阻塞操作。比如在状态处理函数里调用了HAL_Delay或者等待某个标志位。QV内核是运行到完成的一个活动对象阻塞了整个系统就卡住了。排查方法是在状态函数入口和出口加打印看是哪个状态卡住了。事件丢失通常是队列满了。可以在QActive_postX返回false的地方加个计数器统计丢了多少事件。如果丢得频繁要么加大队列长度要么优化事件处理速度。还有一种可能是事件池耗尽了Q_NEW返回NULL这个也要检查。我遇到过一次比较诡异的问题系统跑几分钟就死机查了半天发现是时间事件没有disarm导致超时事件不断累积最后队列爆了。所以每次状态迁移时如果旧状态启动了定时器一定要在退出时取消。6.3 软件追踪与断言的使用QP自带的QS软件追踪功能非常强大可以记录所有事件的投递、状态迁移、时间事件。启用QS后通过串口输出追踪数据用官方提供的QSPY工具解析能看到完整的事件时序图。对于调试复杂的状态机逻辑这个功能能省下大量时间。断言也是QP的一大特色。Q_ASSERT和Q_REQUIRE宏在调试版本里会检查参数合法性一旦不满足就死循环。虽然看起来粗暴但能第一时间暴露问题。比如事件指针为NULL、信号超出范围、状态函数返回非法值都会被断言拦住。提示发布版本里要把断言关掉否则会影响性能和代码体积。在qep_port.h里把Q_NASSERT定义上即可。6.4 常见问题速查表现象可能原因排查方法解决方式编译报找不到qep.hInclude路径不全检查Keil的Include Paths添加qp/include目录链接报重复定义全局变量多处定义查看map文件确保移植文件只编译一次系统跑飞状态机里阻塞加打印定位移除HAL_Delay等阻塞调用事件丢失队列满或事件池空加计数器统计加大队列或优化处理速度时间事件不触发tick中断没调用QF_TICK检查定时器中断确认中断里调用了QF_TICK_X状态迁移异常父状态链配置错误用QS追踪检查Q_SUPER返回值中断里发事件死机用了非中断安全API检查调用函数改用QActive_postX或QF_publish栈溢出局部变量过大查看栈使用减小局部数组或加大栈7. 从示例到项目的扩展思路7.1 多活动对象协作的架构设计单个活动对象跑通后下一步就是多个活动对象协作。比如一个温控系统可以有按键处理对象、温度采集对象、加热控制对象、显示对象。按键对象检测到设置温度后发布一个SET_TEMP_SIG事件温控对象订阅这个事件更新目标温度。温度采集对象定时读取传感器发布TEMP_UPDATE_SIG显示对象和加热控制对象都订阅各自处理。这种架构下每个对象只关心自己订阅的事件模块之间零耦合。增加一个功能就是增加一个活动对象修改现有功能只影响对应的对象。对于毕业设计或者产品原型来说这种架构能体现出明显的设计能力。7.2 状态机层次化设计的实战案例层次式状态机在设备控制类项目里特别有用。以我之前做的一个鱼缸控制器为例顶层状态分“待机”、“运行”、“维护”三个。运行状态下又分“正常”、“加热”、“换水”、“报警”四个子状态。报警状态可以被子状态继承任何子状态检测到异常都可以迁移到报警报警处理完再返回原来的状态。用QP实现时报警状态作为运行状态的子状态其他子状态通过Q_SUPER指向运行状态。这样在报警状态里处理“恢复”事件后可以迁移回运行状态由运行状态决定下一步去哪个子状态。这种设计比扁平状态机少写很多重复代码。7.3 资源占用优化与性能调优QP在STM32F103上的资源占用大概是Flash 8-12KBRAM 1-2KB取决于活动对象数量和队列长度。对于64KB Flash、20KB RAM的C8T6来说完全够用。如果资源紧张可以关掉QS追踪、减小队列长度、使用QV内核而不是QK内核。性能方面QV内核的事件处理是O(1)的从就绪队列取最高优先级对象然后调用状态机。状态机的执行时间取决于状态函数的复杂度。我实测过一个简单的状态迁移在72MHz的F103上大概几微秒完全能满足大多数实时性要求不高的场景。如果需要更快的响应可以用QK内核它支持抢占式调度每个活动对象有独立的栈。但QK的RAM占用会大一些每个对象至少需要256字节的栈空间。7.4 后续学习路径与资料推荐QP框架的官方文档是最权威的学习资料特别是那本《Practical UML Statecharts in C/C》虽然厚但值得啃。官方示例工程也很有参考价值从简单的Blinky到复杂的DPP dining philosophers problem都有。我个人的学习路径是先跑通Blinky理解活动对象和状态机的基本用法然后做按键消抖理解时间事件和中断协作接着做多对象通信理解事件发布订阅最后做一个完整的项目把状态机层次化设计用起来。每一步都动手写代码不要只看不练。调试工具方面QSPY配合串口是必备的。如果条件允许用逻辑分析仪抓一下事件投递的时序能更直观地理解QP的运行机制。Keil的Event Recorder也可以用来做软件追踪但配置起来比QSPY麻烦一些。我在实际项目里踩过最大的坑是在状态机里做浮点运算和字符串格式化。这些操作耗时较长会阻塞其他活动对象。后来改成在状态机里只做状态迁移把耗时操作放到一个低优先级的活动对象里异步执行系统响应就流畅多了。这个经验分享给正在用QP做项目的朋友希望对你有帮助。
返回列表