4. 任务调度架构:裸机前后台系统、RTOS任务划分、任务间通信机制
任务调度,说白了就是决定CPU先干谁、后干谁。很多刚入行的朋友觉得这玩意儿是RTOS才需要考虑的事,裸机随便写写就行。嗯,我当年也这么想,直到在一个车载项目中,因为中断和主循环的时序冲突,导致按键响应偶尔卡顿,排查了整整三天……从那以后,我对任务调度架构再也不敢马虎了。
这一章,咱们就聊聊三种常见的调度形态:裸机前后台、轻量级协作调度、以及RTOS的任务划分与通信。你想想看,搞懂了这些,你就能根据项目资源,选最合适的方案。
4.1 裸机前后台系统:最朴素的调度
裸机前后台,也叫超级循环(Super Loop)。前台是中断,后台是主循环。中断处理紧急事件,主循环处理常规任务。
核心原则:中断里只做标记,主循环里做处理。千万别在中断里干耗时活。
我见过有人直接在中断里跑延时、打印日志,结果系统响应一塌糊涂。记住,中断服务函数要快进快出。
一个典型的前后台框架长这样:
// 标志位定义
volatile uint8_t g_flag_key_scan = 0;
volatile uint8_t g_flag_display_update = 0;
// 定时器中断服务函数 (1ms触发一次)
void TIM_IRQHandler(void) {
static uint16_t tick_counter = 0;
tick_counter++;
if (tick_counter % 10 == 0) { // 10ms扫描一次按键
g_flag_key_scan = 1;
}
if (tick_counter % 100 == 0) { // 100ms刷新一次显示
g_flag_display_update = 1;
}
// 清除中断标志
}
// 主循环
void main(void) {
// 初始化硬件、外设、变量
while(1) {
if (g_flag_key_scan) {
g_flag_key_scan = 0;
key_scan_and_process(); // 按键扫描与处理
}
if (g_flag_display_update) {
g_flag_display_update = 0;
display_update(); // 显示刷新
}
// 其他任务...
}
}
这种架构的优点是简单、零开销。缺点也很明显:任务之间是顺序执行的,一个任务卡住,后面的全得等。我曾经在一个项目中,因为某个传感器读取函数加了阻塞等待,导致显示刷新延迟,用户体验极差。
| 特性 | 裸机前后台 | RTOS |
|---|---|---|
| 资源开销 | 极低(几乎为零) | 需要RAM/ROM(几KB到几十KB) |
| 实时性 | 依赖中断优先级,任务级无抢占 | 支持任务优先级抢占 |
| 任务耦合度 | 高(都在一个循环里) | 低(独立任务栈) |
| 调试难度 | 低(单线程思维) | 中等(需处理同步、竞争) |
| 适用场景 | 简单控制、传感器采集、小家电 | 复杂多任务、通信协议栈、人机交互 |
我的建议:如果项目功能少于5个,且对实时性要求不高,裸机前后台完全够用。别为了用RTOS而用RTOS,那会增加复杂度。
4.2 RTOS任务划分:怎么切才合理?
引入RTOS后,最大的变化是任务可以独立运行、互相抢占。但任务怎么划分,是个经验活。我见过有人把每个函数都搞成一个任务,结果系统切换开销比干活还多。
任务划分的几个原则,我总结了一下:
- 按功能边界划分:一个任务只做一件事。比如按键扫描、LED控制、通信处理,各归各的。
- 按实时性要求划分:紧急的任务给高优先级,不紧急的给低优先级。比如电机控制要1ms响应,日志打印100ms一次就行。
- 按触发源划分:周期性任务(如数据采集)用定时器触发;事件驱动任务(如收到报文)用信号量或消息队列唤醒。
- 控制任务数量:任务不是越多越好。我个人习惯,一个项目任务数控制在8~15个以内,超过这个数,调度开销和调试复杂度会急剧上升。
举个例子,一个智能家居网关的任务划分:
| 任务名称 | 优先级 | 周期/触发 | 功能描述 |
|---|---|---|---|
| wifi_task | 高 | 事件驱动(数据到达) | WiFi数据收发与解析 |
| sensor_task | 中 | 100ms周期 | 读取温湿度、光照传感器 |
| display_task | 中 | 200ms周期 | 刷新OLED屏幕 |
| key_task | 中 | 50ms周期 | 按键扫描与消抖 |
| cloud_task | 低 | 事件驱动(定时上报) | 与云端同步状态 |
| log_task | 低 | 1s周期 | 日志输出与存储 |
避坑指南:我曾经把一个高优先级的任务里加了延时等待,结果低优先级的任务一直得不到CPU,系统直接“假死”。记住,高优先级任务不能阻塞等待低优先级任务释放资源,否则会死锁。
4.3 任务间通信机制:让任务好好说话
任务之间不通信,那就是孤岛。RTOS提供了几种标准的通信方式,咱们一个一个看。
4.3.1 信号量(Semaphore)
信号量就是个计数器,用来做同步或资源管理。二值信号量像一把钥匙,互斥信号量用来保护共享资源。
// 创建二值信号量
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();
// 中断中释放信号量(通知任务)
void TIM_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务中等待信号量
void vTaskFunction(void *pvParameters) {
while(1) {
if (xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
// 收到信号,执行处理
process_data();
}
}
}
4.3.2 消息队列(Message Queue)
队列用来传递数据,支持多个发送者、一个接收者。我习惯用队列来解耦生产者和消费者。
// 创建队列,每个元素10字节,队列深度5
QueueHandle_t xQueue = xQueueCreate(5, 10);
// 发送任务
void vSenderTask(void *pvParameters) {
char data[10] = "hello";
while(1) {
xQueueSend(xQueue, data, 0);
vTaskDelay(100);
}
}
// 接收任务
void vReceiverTask(void *pvParameters) {
char recv_data[10];
while(1) {
if (xQueueReceive(xQueue, recv_data, portMAX_DELAY) == pdTRUE) {
// 处理接收到的数据
handle_data(recv_data);
}
}
}
4.3.3 事件标志组(Event Group)
当一个任务需要等待多个条件同时满足时,事件标志组就派上用场了。比如等待按键按下且网络就绪,才执行某个操作。
EventGroupHandle_t xEventGroup = xEventGroupCreate();
#define BIT_KEY_PRESSED (1 << 0)
#define BIT_NET_READY (1 << 1)
// 任务等待两个事件同时发生
void vTask(void *pvParameters) {
EventBits_t uxBits;
while(1) {
uxBits = xEventGroupWaitBits(
xEventGroup,
BIT_KEY_PRESSED | BIT_NET_READY,
pdTRUE, // 退出时清除标志
pdTRUE, // 等待所有标志
portMAX_DELAY
);
// 两个条件都满足,执行操作
execute_action();
}
}
选型建议:传递数据用队列,同步信号用信号量,等待多个条件用事件标志组。别混用,也别滥用。我见过有人用队列传递一个空结构体当信号量用,那纯粹是浪费内存。
4.4 三种调度架构的对比
为了让你看得更清楚,我画了一张对比图。这张图展示了从裸机到RTOS的演进关系,以及各自的核心特点。
4.5 实战中的选择与权衡
说了这么多,到底怎么选?我个人的经验是:
- 先评估资源:MCU的RAM、ROM、主频。如果RAM只有1KB,别想RTOS了,老老实实前后台。
- 再评估复杂度:任务之间有没有复杂的依赖?有没有多个外设需要同时响应?如果有,RTOS能帮你理清逻辑。
- 最后评估团队:如果团队里没人用过RTOS,学习成本也是成本。别为了炫技引入不必要的复杂度。
核心观点:架构没有好坏,只有合不合适。裸机前后台用好了,一样稳定可靠;RTOS用不好,照样出bug。关键是理解每种架构的适用边界。