1. 为什么要在STM32上折腾QP框架
第一次接触QP框架是在做一个多路传感器采集的项目上,当时用裸机的前后台架构写了一个状态机,代码里全是switch-case嵌套,一个按键短按、长按、双击的逻辑就写了三百多行,后来加了个低功耗模式,整个逻辑直接崩了,改一处坏三处。那段时间我就在想,有没有一种方式能让嵌入式代码像PC端那样,用“事件”来驱动,而不是靠一堆标志位和轮询硬撑。
QP框架就是干这个的。它是一套轻量级的、专门为嵌入式设计的事件驱动框架,核心思想是把每个独立的功能模块抽象成一个活动对象,每个活动对象内部维护一个层次式状态机,对象之间通过事件进行异步通信。听起来有点抽象,我换个说法:你可以把它理解成一个微型的“操作系统”,但它不调度任务,而是调度事件。每个活动对象就像一个独立的小程序,平时处于某个状态,收到事件后根据当前状态决定怎么响应,响应完继续等下一个事件。
这套东西在STM32上跑非常合适。STM32的RAM和Flash虽然比不上PC,但QP框架的内核非常小,裁剪之后ROM占用大概几KB,RAM占用取决于你创建多少个活动对象和事件池的大小。我用STM32F103C8T6(64KB Flash,20KB RAM)跑过三个活动对象的小项目,内核加应用总共占了不到12KB Flash,RAM用了大概3KB,剩下的资源完全够用。
这篇文章适合谁看?如果你已经会用STM32点灯、串口收发、定时器中断,但每次写稍微复杂一点的逻辑就觉得代码乱成一团,那这篇内容就是给你准备的。我会从零开始,把QP框架的移植、活动对象的创建、状态机的编写、事件的发送和接收,全部走一遍,代码直接给全,你拿去就能编译。
注意:QP框架有QP/C、QP/C++、QP-nano几个版本,STM32上常用的是QP/C,本文所有内容基于QP/C 6.x版本,用Keil MDK和STM32CubeMX生成的HAL库工程作为基础。
2. QP框架的核心概念拆解
2.1 活动对象到底是个什么东西
活动对象是QP框架里最基本的执行单元。一个活动对象包含三样东西:一个状态机、一个事件队列、一个优先级。状态机决定了它收到事件后怎么反应,事件队列用来缓存还没处理的事件,优先级决定了多个活动对象同时有事件时谁先处理。
我刚开始学的时候,总觉得活动对象和RTOS里的任务很像,后来发现区别很大。RTOS的任务是一个死循环,里面通常有阻塞式的延时或等待;而活动对象没有自己的栈,它就是一个状态机函数,事件来了就调用一次,处理完就返回。这意味着活动对象的RAM开销极小,不需要为每个对象分配独立的栈空间。
创建活动对象的代码大概长这样:
typedef struct { QActive super; /* 继承QP的活动对象基类 */ uint8_t sensorValue; /* 自己的成员变量 */ } SensorAO; static SensorAO l_sensorAO; /* 静态分配,避免动态内存 */ QActive * const AO_Sensor = &l_sensorAO.super;这里QActive是QP提供的基类,里面包含了事件队列、优先级等基础设施。你只需要在结构体里加上自己需要的成员变量就行。静态分配是我强烈推荐的方式,嵌入式里能不用malloc就不用,避免内存碎片。
2.2 层次式状态机为什么比switch-case好用
层次式状态机是QP框架的另一个核心。普通的有限状态机,每个状态是平级的,状态之间的转换全靠switch-case里的条件判断。层次式状态机允许状态之间有“父子”关系,子状态可以继承父状态的行为。
举个例子:一个电机控制的状态机,有“停止”、“运行”、“故障”三个顶层状态。“运行”下面又分“正转”、“反转”、“加速”、“减速”四个子状态。如果用普通状态机,从“正转”切到“停止”需要写一遍停止逻辑,从“反转”切到“停止”又要写一遍。用层次式状态机,你只需要在“运行”这个父状态里写一次停止逻辑,所有子状态都能继承。
QP里写状态机的语法是这样的:
QState Motor_run(MotorAO * const me, QEvt const * const e) { switch (e->sig) { case Q_ENTRY_SIG: { /* 进入运行状态时的动作 */ return Q_HANDLED(); } case Q_EXIT_SIG: { /* 退出运行状态时的动作 */ return Q_HANDLED(); } case STOP_SIG: { /* 收到停止事件,切换到停止状态 */ return Q_TRAN(&Motor_stop); } } return Q_SUPER(&QHsm_top); /* 父状态是顶层 */ }Q_ENTRY_SIG和Q_EXIT_SIG是QP内置的进入和退出信号,不需要你自己定义。Q_TRAN表示状态转换,Q_SUPER指定父状态。这种写法比一堆if-else清晰太多了,而且状态之间的层次关系一目了然。
2.3 事件驱动和轮询的本质区别
轮询是“我每隔一段时间去看看有没有事情要做”,事件驱动是“事情来了会通知我”。在裸机开发里,轮询通常放在主循环里,比如while(1) { if (flag) { ... } }。这种方式的问题是,如果逻辑复杂了,主循环会变得非常臃肿,而且响应时间取决于循环周期。
事件驱动的方式是,中断服务程序或者定时器回调里只负责“发送事件”,具体怎么处理由活动对象的状态机决定。这样中断里做的事情极少,不会阻塞其他中断,系统的实时性更好。
QP里发送事件的代码:
/* 在定时器中断里发送一个周期事件 */ static QEvt const tickEvt = QEVT_INITIALIZER(TICK_SIG); QACTIVE_POST(AO_Sensor, &tickEvt, 0);QACTIVE_POST把事件投递到活动对象的事件队列里,如果队列满了会返回失败。最后一个参数是发送者指针,不需要的话传0就行。
提示:QP的事件可以是静态分配的(全局const变量),也可以是动态分配的(从事件池里取)。静态事件适合周期性的、内容不变的事件,动态事件适合携带数据的、一次性的事件。
3. 在STM32上移植QP框架的完整步骤
3.1 准备工作:工程模板和源码获取
我用的工程模板是STM32CubeMX生成的,芯片选STM32F103C8T6,时钟配置成72MHz,开启了USART1用于打印调试信息,开启了TIM2用于产生周期事件。HAL库版本是1.8.x,Keil MDK版本是5.36。
QP/C的源码可以从官网下载,下载下来是一个压缩包,解压后主要关注这几个文件夹:
qpc/inc:头文件目录qpc/src:源文件目录qpc/ports:移植层目录,里面有各种编译器和平台的移植文件
STM32用的是ARM Cortex-M内核,所以移植层选qpc/ports/arm-cm下面的文件。具体来说,需要这几个文件:
qk_port.c:QK内核的移植文件,QK是QP的一种内核,支持抢占式调度qv_port.c:QV内核的移植文件,QV是另一种内核,只支持合作式调度qassert.h:断言相关的宏定义
我一般用QV内核,因为它的逻辑更简单,不需要在中断里做复杂的上下文切换,适合大多数STM32项目。QK内核适合对实时性要求极高的场景,但移植起来稍微麻烦一点。
3.2 把QP源码加入Keil工程
在Keil里新建几个分组,把QP的源文件加进去。我习惯这样分组:
QP/inc:存放QP的头文件QP/src:存放QP的核心源文件,包括qep.c、qf.c、qfsm.c、qhc.c等QP/port:存放移植层的文件,比如qv_port.c
然后在Keil的“Options for Target”里,把qpc/inc和qpc/ports/arm-cm/qv/gnu(或者iar、keil,取决于你的编译器)添加到Include Paths里。
这里有个坑要注意:QP的源码里有一些文件是互斥的,比如qk.c和qv.c不能同时编译,qep.c和qepn.c也不能同时编译。我一开始没注意,把所有源文件都加进去了,结果编译报了一堆重复定义的错误。后来仔细看了QP的文档,才知道要根据选择的内核来添加文件。
用QV内核的话,需要添加的源文件是:
qep.c:层次式状态机引擎qf.c:框架核心,事件队列、活动对象管理qv.c:QV内核qfsm.c:有限状态机(如果不用层次式状态机可以不添加)qhc.c:层次式状态机的辅助函数
移植层只需要添加qv_port.c。
3.3 配置QP的编译选项
QP有一个配置文件叫qf_port.h,里面定义了很多编译时的选项,比如事件队列的最大长度、活动对象的最大数量、是否支持动态事件分配等。这个文件需要你自己创建一个,放在你的工程目录里,然后在Keil的Include Paths里把这个目录也加进去。
我的qf_port.h大概长这样:
#ifndef QF_PORT_H #define QF_PORT_H #define QF_MAX_ACTIVE 8 /* 最多8个活动对象 */ #define QF_EVENT_SIZ_SIZE 2 /* 事件池大小用2字节表示 */ #define QF_EQUEUE_CTR_SIZE 1 /* 事件队列计数器用1字节 */ #define QF_MPOOL_SIZ_SIZE 2 /* 内存池大小用2字节 */ #define QF_MPOOL_CTR_SIZE 2 /* 内存池计数器用2字节 */ #define QF_TIMEEVT_CTR_SIZE 2 /* 时间事件计数器用2字节 */ #define QF_ISR_NESTING 1 /* 允许中断嵌套 */ #endif这些宏定义决定了QP内部数据结构的位宽,根据你的项目规模来调整。活动对象不多的话,QF_MAX_ACTIVE设成8就够了,设大了浪费RAM。
3.4 编写板级支持包
QP需要一个“板级支持包”来和硬件打交道,主要做两件事:一是提供系统时钟节拍,二是提供临界区保护。
系统时钟节拍我用的是SysTick,在SysTick_Handler里调用QF_TICK():
void SysTick_Handler(void) { HAL_IncTick(); QF_TICK(); /* 通知QP框架,一个时钟节拍过去了 */ }临界区保护需要实现两个宏:QF_INT_DISABLE()和QF_INT_ENABLE()。在Cortex-M上,可以用__disable_irq()和__enable_irq(),也可以用PRIMASK寄存器操作。我一般用CMSIS提供的内联函数:
#define QF_INT_DISABLE() __disable_irq() #define QF_INT_ENABLE() __enable_irq()这两个宏定义在qf_port.h里,或者单独放在一个头文件里。
注意:QP在进入临界区时会保存当前的中断状态,退出时恢复。如果你在中断里调用QP的API,要确保中断优先级配置正确,避免在临界区里被高优先级中断打断导致状态不一致。
4. 创建第一个活动对象和状态机
4.1 定义事件信号
事件信号是活动对象之间通信的“语言”。每个事件都有一个信号值,通常用枚举来定义:
enum { TICK_SIG = Q_USER_SIG, /* 从Q_USER_SIG开始,避免和QP内置信号冲突 */ BUTTON_PRESS_SIG, SENSOR_DATA_SIG, MAX_SIG };Q_USER_SIG是QP定义的第一个用户信号值,所有自定义信号都从它开始往后排。MAX_SIG用来标记信号的最大值,方便调试时检查信号是否合法。
如果需要事件携带数据,可以定义一个继承自QEvt的结构体:
typedef struct { QEvt super; /* 继承QEvt基类 */ uint16_t value; /* 传感器数据 */ uint8_t channel; /* 通道号 */ } SensorEvt;发送带数据的事件时,需要从事件池里动态分配:
SensorEvt *e = Q_NEW(SensorEvt, SENSOR_DATA_SIG); e->value = 1234; e->channel = 1; QACTIVE_POST(AO_Display, &e->super, 0);Q_NEW宏从事件池里分配一块内存,并自动设置信号值。事件池的大小在qf_port.h里通过QF_MPOOL_SIZ_SIZE等宏来配置。
4.2 编写状态机函数
状态机函数是活动对象的核心。我以一个简单的“按键控制LED”为例,按键按下时LED翻转,长按3秒进入低功耗模式。
/* LED活动对象的结构体 */ typedef struct { QActive super; uint8_t ledState; uint16_t pressCount; } LedAO; static LedAO l_ledAO; /* 状态机的前向声明 */ static QState Led_initial(LedAO * const me, QEvt const * const e); static QState Led_on(LedAO * const me, QEvt const * const e); static QState Led_off(LedAO * const me, QEvt const * const e); static QState Led_lowPower(LedAO * const me, QEvt const * const e);Led_initial是初始伪状态,活动对象启动时会先进入这个状态,然后立即转换到真正的初始状态。这是QP的约定,每个状态机都必须有一个初始伪状态。
static QState Led_initial(LedAO * const me, QEvt const * const e) { return Q_TRAN(&Led_off); /* 初始状态是LED灭 */ } static QState Led_off(LedAO * const me, QEvt const * const e) { switch (e->sig) { case Q_ENTRY_SIG: { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); me->ledState = 0; return Q_HANDLED(); } case BUTTON_PRESS_SIG: { return Q_TRAN(&Led_on); } } return Q_SUPER(&QHsm_top); } static QState Led_on(LedAO * const me, QEvt const * const e) { switch (e->sig) { case Q_ENTRY_SIG: { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); me->ledState = 1; me->pressCount = 0; return Q_HANDLED(); } case TICK_SIG: { me->pressCount++; if (me->pressCount >= 300) { /* 3秒,假设10ms一个tick */ return Q_TRAN(&Led_lowPower); } return Q_HANDLED(); } case BUTTON_PRESS_SIG: { return Q_TRAN(&Led_off); } } return Q_SUPER(&QHsm_top); } static QState Led_lowPower(LedAO * const me, QEvt const * const e) { switch (e->sig) { case Q_ENTRY_SIG: { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); /* 进入低功耗模式,关闭外设时钟等 */ return Q_HANDLED(); } case BUTTON_PRESS_SIG: { return Q_TRAN(&Led_off); } } return Q_SUPER(&QHsm_top); }这段代码里,Led_off和Led_on是平级状态,Led_lowPower也是平级状态。TICK_SIG事件每10ms发送一次,在Led_on状态里累加计数,超过300次就切换到低功耗状态。
4.3 初始化和启动活动对象
活动对象写好了,需要在main函数里初始化并启动:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM2_Init(); /* 初始化QP框架 */ QF_init(); /* 初始化活动对象 */ LedAO_ctor(&l_ledAO); QACTIVE_START(AO_Led, 1, 0, 0, 0); /* 启动定时器,每10ms产生一个中断 */ HAL_TIM_Base_Start_IT(&htim2); /* 进入QP的事件循环 */ return QF_run(); }QF_init()初始化框架内部的数据结构。LedAO_ctor()是活动对象的构造函数,里面会调用QActive_ctor()来初始化基类,并设置初始状态机。QACTIVE_START启动活动对象,第一个参数是活动对象指针,第二个参数是优先级(数字越小优先级越高),后面三个参数是事件队列的存储区和长度,传0表示使用QP内部的默认队列。
QF_run()是QP的事件循环,它永远不会返回。在这个循环里,QP会检查所有活动对象的事件队列,按照优先级顺序分发事件。
提示:
QACTIVE_START的优先级参数,数字越小优先级越高。QP的调度器是严格按照优先级来分发的,高优先级活动对象的事件队列不为空时,低优先级活动对象的事件不会被处理。
5. 事件发送与接收的实战细节
5.1 从定时器中断发送周期事件
定时器中断是嵌入式系统里最常用的事件源。在STM32的HAL库里,定时器中断的回调函数是HAL_TIM_PeriodElapsedCallback:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { static QEvt const tickEvt = QEVT_INITIALIZER(TICK_SIG); QACTIVE_POST(AO_Led, &tickEvt, 0); } }这里用了一个静态的QEvt变量,因为TICK_SIG事件不携带数据,每次发送的内容都一样,没必要动态分配。QACTIVE_POST的第三个参数是发送者指针,传0表示不关心发送者。
有个细节要注意:QACTIVE_POST在中断里调用是安全的,QP内部会做临界区保护。但如果事件队列满了,QACTIVE_POST会返回false,事件会被丢弃。对于周期事件,丢一两个通常没关系,但如果你的逻辑依赖每个tick,就需要把队列长度设大一点,或者在队列满时做特殊处理。
5.2 从GPIO中断发送按键事件
按键中断的处理和定时器类似,但需要做消抖。我一般不在中断里做消抖,而是发送一个事件,在活动对象的状态机里用软件定时器做消抖:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { static QEvt const pressEvt = QEVT_INITIALIZER(BUTTON_PRESS_SIG); QACTIVE_POST(AO_Led, &pressEvt, 0); } }在状态机里,收到BUTTON_PRESS_SIG后不立即响应,而是启动一个一次性定时器,20ms后再检查按键状态。QP提供了QTimeEvt来实现软件定时器:
static QTimeEvt l_debounceTimeEvt; /* 在构造函数里初始化 */ QTimeEvt_ctorX(&l_debounceTimeEvt, AO_Led, DEBOUNCE_SIG, 0); /* 在状态机里启动定时器 */ case BUTTON_PRESS_SIG: { QTimeEvt_armX(&l_debounceTimeEvt, 2, 0); /* 2个tick后触发,不重复 */ return Q_HANDLED(); } case DEBOUNCE_SIG: { /* 检查按键是否仍然按下 */ if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { return Q_TRAN(&Led_on); } return Q_HANDLED(); }QTimeEvt_armX的第二个参数是延时多少个tick,第三个参数是重复周期,传0表示只触发一次。DEBOUNCE_SIG是自定义的信号,需要在信号枚举里定义。
5.3 活动对象之间的通信
活动对象之间不能直接调用对方的函数,只能通过发送事件来通信。这是QP框架的核心原则,目的是解耦。比如一个传感器采集活动对象,采集到数据后发送给显示活动对象:
/* 在传感器活动对象的状态机里 */ case SENSOR_READ_SIG: { uint16_t value = read_sensor(); SensorEvt *e = Q_NEW(SensorEvt, SENSOR_DATA_SIG); e->value = value; e->channel = 1; QACTIVE_POST(AO_Display, &e->super, me); return Q_HANDLED(); }Q_NEW从事件池里分配内存,如果事件池空了会触发断言。事件池的大小在qf_port.h里配置,我一般设成10到20个事件的大小,具体取决于系统的并发程度。
接收方在状态机里处理:
case SENSOR_DATA_SIG: { SensorEvt *e = (SensorEvt *)e; char buf[32]; sprintf(buf, "CH%d: %d\n", e->channel, e->value); HAL_UART_Transmit(&huart1, (uint8_t *)buf, strlen(buf), 100); return Q_HANDLED(); }注意这里把QEvt const *强制转换成了SensorEvt *,因为事件池分配的内存是可写的。如果事件是静态分配的,就不能这样转换,只能读取。
注意:动态分配的事件在使用完毕后,QP会自动回收吗?不会。QP不会自动回收事件内存,你需要手动调用
QF_gc()来回收。或者,如果你确定事件不再被使用,可以在处理完后调用QF_gc(e)。我一般是在事件池快满的时候调用QF_gc(),或者在空闲循环里定期调用。
6. 常见问题与排查技巧实录
6.1 编译报错“undefined symbol QF_init”
这个问题通常是因为QP的源文件没有全部加入工程,或者Include Paths没有配置正确。检查一下qpc/src目录下的qf.c、qep.c、qv.c是否都加入了工程,以及qpc/inc目录是否在Include Paths里。
还有一个可能的原因是,你用的QP版本和移植层文件不匹配。比如QP 6.x的qf_port.h里需要定义QF_MAX_ACTIVE等宏,而QP 5.x的宏定义方式不同。建议从官网下载最新版本的QP,并仔细阅读ports目录下的说明文件。
6.2 活动对象收不到事件
如果活动对象启动后收不到事件,先检查这几个地方:
第一,QACTIVE_START的优先级参数是否合法。QP的优先级范围是1到QF_MAX_ACTIVE,传0或者超过最大值都会导致断言失败。如果你在qf_port.h里把QF_MAX_ACTIVE设成了8,优先级就只能填1到8。
第二,事件队列的长度是否够用。QACTIVE_START的第四个参数是队列存储区,第五个参数是队列长度。如果传0,QP会使用默认的队列长度,默认值在qf_port.h里通过QF_EQUEUE_CTR_SIZE等宏来配置。如果队列太短,事件会被丢弃。
第三,发送事件的代码是否真的执行到了。可以在发送事件的地方加一个GPIO翻转,用示波器或者逻辑分析仪看一下。我遇到过一个问题,定时器中断没有正确配置,中断根本没触发,导致事件一直没发送。
6.3 状态机进入错误状态
层次式状态机的转换逻辑比较复杂,如果状态转换写错了,可能会进入一个意料之外的状态。QP提供了一个调试工具叫QSpy,可以通过串口输出状态机的运行轨迹。不过QSpy的配置稍微麻烦一点,我一般用简单粗暴的方法:在每个状态的Q_ENTRY_SIG里加一句串口打印。
case Q_ENTRY_SIG: { printf("Enter Led_on\n"); return Q_HANDLED(); }这样从串口输出就能看到状态机的运行轨迹,非常直观。等调试完了再把打印去掉。
还有一个常见问题是,状态转换后没有返回Q_HANDLED(),而是返回了Q_SUPER()。Q_TRAN宏本身会返回一个状态指针,不需要再包一层return。正确的写法是return Q_TRAN(&Led_on);,而不是Q_TRAN(&Led_on); return Q_HANDLED();。
6.4 事件池耗尽导致断言失败
如果系统运行一段时间后触发断言,提示事件池为空,说明动态事件分配得太多了,或者回收不及时。解决方法有几个:
一是增大事件池的大小。在qf_port.h里,事件池的大小由QF_MPOOL_SIZ_SIZE和QF_MPOOL_CTR_SIZE决定,但实际的事件池数组是在qf_init()里初始化的,你需要修改QP的源码或者提供一个自定义的事件池。
二是及时回收事件。在事件处理完后调用QF_gc(e),把事件内存还给事件池。但要注意,如果事件被多个活动对象引用,不能提前回收。
三是尽量使用静态事件。对于不携带数据的事件,用静态分配的方式,不占用事件池。只有确实需要携带数据的事件才用动态分配。
6.5 中断优先级配置错误导致系统卡死
Cortex-M的中断优先级配置很重要。QP在临界区里会关闭中断,如果中断优先级配置不当,可能会导致中断嵌套问题。我一般把SysTick的优先级设成最低,把其他外设中断的优先级设成比SysTick高。这样SysTick不会打断其他中断的处理。
在STM32CubeMX里,NVIC的优先级分组默认是4位抢占优先级,0位子优先级。我一般把SysTick的抢占优先级设成15(最低),把串口、定时器等外设的抢占优先级设成5到10之间。
提示:QP的
QF_ISR_NESTING宏如果设成1,表示允许中断嵌套。如果你的系统里中断嵌套比较复杂,建议设成0,禁止中断嵌套,简化临界区的管理。
7. 从裸机到QP的思维转变
7.1 不要再用全局变量传递状态
裸机开发里,我们习惯用全局变量来传递状态,比如volatile uint8_t flag。在QP里,全局变量应该尽量避免,因为活动对象之间的通信应该通过事件来完成。全局变量会破坏活动对象的封装性,让代码重新变得耦合。
我刚开始用QP的时候,总是忍不住在活动对象里直接读写全局变量,结果发现状态机的逻辑变得很混乱。后来强迫自己把所有跨模块的数据都封装成事件,代码结构立刻清晰了很多。
7.2 状态机不是万能的
QP框架适合事件驱动、状态复杂的场景,比如用户界面、通信协议、电机控制等。但如果你的逻辑很简单,比如只是定时采集数据并通过串口发送,用QP反而会增加复杂度。我一般是在项目有超过三个状态、或者状态之间有复杂的转换关系时,才考虑用QP。
另外,QP的状态机是同步执行的,一个活动对象在处理事件时,其他活动对象不能抢占它(除非用QK内核)。这意味着如果一个活动对象处理事件的时间太长,会影响其他活动对象的响应。所以状态机里的处理逻辑要尽量短小,耗时的操作应该拆分成多个事件来处理。
7.3 调试QP程序的实用技巧
QP程序调试起来比裸机程序稍微麻烦一点,因为事件是异步的,状态转换是隐式的。我总结了几条实用的调试技巧:
第一,给每个活动对象分配一个独立的串口或者用同一个串口加前缀,方便区分输出。比如[LED] Enter Led_on、[SENSOR] Read value 1234。
第二,用GPIO翻转来标记关键事件。比如在状态机的Q_ENTRY_SIG里翻转一个GPIO,用逻辑分析仪抓波形,可以直观地看到状态切换的时序。
第三,定期打印事件队列的长度。如果队列长度经常接近最大值,说明事件产生得太快或者处理得太慢,需要调整优先级或者优化处理逻辑。
第四,用QP自带的断言机制。QP的断言比标准C的assert更强大,它会打印出文件名、行号和表达式,方便定位问题。确保在qf_port.h里定义了Q_ASSERT宏,指向一个有效的断言处理函数。
7.4 性能优化的几个方向
QP框架的性能瓶颈通常在事件队列和状态机的查找上。如果系统的事件吞吐量很大,可以考虑这几个优化方向:
一是减少动态事件的使用。动态事件需要从事件池分配内存,涉及临界区保护,开销比静态事件大。能用静态事件的地方就用静态事件。
二是合并事件。如果多个事件总是同时发生,可以合并成一个事件,减少事件队列的操作次数。
三是调整活动对象的优先级。把响应时间要求高的活动对象设成高优先级,把不紧急的设成低优先级。QP的调度器是严格按优先级分发的,高优先级活动对象的事件队列不为空时,低优先级活动对象的事件不会被处理。
四是使用QK内核代替QV内核。QK内核支持抢占式调度,高优先级活动对象可以打断低优先级活动对象的处理,响应时间更短。但QK内核的移植和调试更复杂,需要仔细配置中断优先级。
我在实际项目里,用QV内核跑过10ms周期的控制循环,活动对象有5个,事件队列长度设成16,运行很稳定。后来换到QK内核,响应时间从毫秒级降到了微秒级,但调试花了不少时间。所以我的建议是,先用QV内核把功能跑通,如果性能不够再考虑QK内核。
最后再分享一个小技巧:QP的源码里有一个qassert.h文件,里面的断言宏可以自定义。我一般会把断言处理函数改成打印错误信息后进入死循环,而不是直接复位。这样调试的时候可以看到错误信息,方便定位问题。等产品发布的时候再改成复位,保证系统的可靠性。