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融合的核心结构。你可以把它当作一个模板,以后设计系统时直接套用。

状态机与RTOS融合架构 硬件层(中断、定时器、外设) 事件生成层(ISR → 消息队列 / 信号量) 状态机任务层(每个任务一个状态机) 任务A:状态机A | 任务B:状态机B | 任务C:状态机C 同步与通信层(信号量、互斥量、消息队列) 数据流方向:硬件中断 → 事件 → 状态机 → 同步通信

嗯,这张图其实就讲了三层意思:事件从哪里来、状态机怎么跑、任务之间怎么同步。你设计系统时,先画这张图,把每个模块填进去,结构就清晰了。

11.6 避坑指南:我踩过的几个坑

最后分享几个实战中容易翻车的地方,都是我用头发换来的教训。

  • 信号量死锁:两个状态机互相等待对方释放信号量,结果双双卡死。解决办法是统一信号量获取顺序,或者用带超时的获取函数。
  • 消息队列溢出:中断里发消息太快,状态机任务处理不过来,队列满了丢事件。我建议队列长度至少是最大事件数的2倍,并且加一个溢出计数器用于调试。
  • 状态机在中断里运行:千万别把状态机的状态切换放在ISR里。ISR应该只发事件,状态切换交给任务。否则中断嵌套一多,状态机逻辑全乱。
  • 信号量忘记释放:我曾经在某个分支路径里忘了释放信号量,结果那个任务再也没拿到过锁。后来我养成了一个习惯:获取信号量后,立刻在下一行写释放代码,再在中间填业务逻辑。

总结一句话:状态机管逻辑,RTOS管调度,信号量管同步。三者各司其职,系统才能稳定。别试图让状态机去管调度,也别让信号量去管逻辑——那是灾难的开始。

好了,这一章的内容就这些。下一章我们会聊状态机的代码生成工具,到时候我会分享几个我常用的模板和脚本,帮你把重复劳动降到最低。


公众号:蓝海资料掘金营,微信deep3321