11、状态机与多任务:状态机在RTOS中的应用、任务间通信、状态机与信号量
说实话,很多工程师把状态机和RTOS当成两套独立的东西来学。我早期做项目时也这么干过——状态机画得飞起,RTOS任务也建了一堆,结果联调的时候发现,状态机跑在任务里,任务之间要传数据,信号量怎么用、消息队列怎么接,全乱套了。
这一章,咱们就把这三样东西拧在一起讲。你想想看,状态机是逻辑骨架,RTOS是执行骨架,信号量和消息队列是血管。缺了哪个,系统都跑不顺畅。
11.1 状态机在RTOS中的典型角色
在裸机系统里,状态机通常跑在主循环里,一个while(1)轮询所有状态。但在RTOS环境下,状态机一般寄生在某个任务中。我个人习惯的做法是:一个状态机对应一个任务,状态机的每次状态切换,就是任务的一次执行周期。
核心原则:状态机负责“做什么”,RTOS负责“什么时候做”。状态机不关心延时、不关心阻塞,它只关心当前状态和输入事件。
我在项目中遇到过一种典型场景:一个通信协议栈,底层收包、解析、组包、发送,每个环节都是一个独立的状态机,分别跑在不同的任务里。这样做的好处是——每个状态机逻辑简单,任务之间通过队列解耦,改一个模块不影响其他模块。
11.2 任务间通信:状态机的“输入源”
状态机需要事件才能运转。在RTOS里,事件从哪里来?
- 硬件中断:比如UART收到一个字节,中断服务程序里发一个消息给状态机任务。
- 定时器超时:比如超时重传,定时器回调里发一个超时事件。
- 其他任务:比如按键扫描任务检测到按键按下,发一个按键事件给主控状态机。
说白了,状态机的输入事件,就是RTOS的消息队列或邮箱。我建议你养成一个习惯:状态机的事件结构体里,除了事件ID,最好带一个数据指针或数据缓冲区。这样事件本身就能携带数据,省得再搞全局变量。
// 事件结构体定义
typedef struct {
uint32_t event_id; // 事件ID
void *data_ptr; // 数据指针
uint32_t data_len; // 数据长度
} StateMachineEvent_t;
// 状态机任务
void state_machine_task(void *param) {
StateMachineEvent_t event;
while(1) {
// 阻塞等待事件,超时设为100ms
if (xQueueReceive(state_machine_queue, &event, pdMS_TO_TICKS(100)) == pdPASS) {
// 将事件喂给状态机
state_machine_dispatch(&event);
} else {
// 超时处理,比如喂狗、心跳等
state_machine_dispatch(NULL);
}
}
}
小技巧:我习惯把超时也当作一种“空事件”喂给状态机。这样状态机可以处理周期性任务,比如LED闪烁、心跳上报,不需要额外再开一个定时器任务。
11.3 信号量:状态机里的“同步锁”
信号量在状态机里最常见的用法,就是保护共享资源。比如两个状态机任务都要操作同一个外设——SPI Flash,不加信号量的话,数据就乱套了。
我曾经踩过一个坑:一个状态机在写Flash,另一个状态机在读Flash,两个任务没有加锁。结果读出来的数据全是0xFF,排查了两天才发现是信号量没加。从那以后,我定了一条铁律:任何跨任务访问的全局变量或外设,必须用信号量保护。
// 共享SPI Flash操作函数
void spi_flash_write(uint32_t addr, uint8_t *data, uint32_t len) {
// 获取信号量,等待时间设为最大
if (xSemaphoreTake(spi_mutex, portMAX_DELAY) == pdTRUE) {
// 实际写操作
HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY);
// 释放信号量
xSemaphoreGive(spi_mutex);
}
}
// 状态机中的使用
void state_write_flash(StateMachine_t *sm, StateMachineEvent_t *event) {
// 状态机进入写Flash状态
spi_flash_write(event->data_ptr, event->data_len);
// 写完后切换到下一个状态
sm->next_state = STATE_IDLE;
}
注意:信号量的获取和释放必须在同一个任务中完成。不要在中断里释放信号量然后让任务去获取——虽然技术上可行,但容易造成优先级反转。我建议中断里只发消息队列,信号量操作全部放在任务上下文中。
11.4 状态机与信号量的高级用法:事件驱动同步
除了保护共享资源,信号量还可以用来做任务间同步。比如一个状态机需要等待另一个状态机完成某个操作,才能继续往下走。这时候可以用二值信号量做“门闩”。
我做过一个双状态机协作的例子:状态机A负责采集数据,状态机B负责处理数据。A采集完一帧数据后,释放一个信号量,B获取到信号量后才开始处理。这样两个状态机天然同步,不需要轮询标志位。
// 状态机A:采集任务
void采集任务(void *param) {
while(1) {
// 采集一帧数据
采集数据();
// 通知状态机B:数据准备好了
xSemaphoreGive(data_ready_sem);
// 继续采集下一帧
}
}
// 状态机B:处理任务
void处理任务(void *param) {
while(1) {
// 等待数据就绪信号量
if (xSemaphoreTake(data_ready_sem, pdMS_TO_TICKS(1000)) == pdTRUE) {
// 处理数据
process_data();
// 状态机切换到下一个状态
state_machine_transition(&sm, STATE_PROCESS_DONE);
} else {
// 超时处理
state_machine_transition(&sm, STATE_TIMEOUT);
}
}
}
你看,这里信号量起到了“事件触发器”的作用。状态机B不需要轮询状态机A,也不需要全局标志位。信号量本身就是事件。
11.5 知识体系图:状态机与RTOS的融合
下面这张图,是我自己总结的状态机与RTOS融合的核心结构。你可以把它当作一个模板,以后设计系统时直接套用。
嗯,这张图其实就讲了三层意思:事件从哪里来、状态机怎么跑、任务之间怎么同步。你设计系统时,先画这张图,把每个模块填进去,结构就清晰了。
11.6 避坑指南:我踩过的几个坑
最后分享几个实战中容易翻车的地方,都是我用头发换来的教训。
- 信号量死锁:两个状态机互相等待对方释放信号量,结果双双卡死。解决办法是统一信号量获取顺序,或者用带超时的获取函数。
- 消息队列溢出:中断里发消息太快,状态机任务处理不过来,队列满了丢事件。我建议队列长度至少是最大事件数的2倍,并且加一个溢出计数器用于调试。
- 状态机在中断里运行:千万别把状态机的状态切换放在ISR里。ISR应该只发事件,状态切换交给任务。否则中断嵌套一多,状态机逻辑全乱。
- 信号量忘记释放:我曾经在某个分支路径里忘了释放信号量,结果那个任务再也没拿到过锁。后来我养成了一个习惯:获取信号量后,立刻在下一行写释放代码,再在中间填业务逻辑。
总结一句话:状态机管逻辑,RTOS管调度,信号量管同步。三者各司其职,系统才能稳定。别试图让状态机去管调度,也别让信号量去管逻辑——那是灾难的开始。
好了,这一章的内容就这些。下一章我们会聊状态机的代码生成工具,到时候我会分享几个我常用的模板和脚本,帮你把重复劳动降到最低。
公众号:蓝海资料掘金营,微信deep3321