3、任务调度优化:任务划分原则,抢占式调度策略,临界区保护
各位同学,咱们接着聊。上一章讲了任务状态机,这一章咱们深入一下任务调度。说实话,任务调度这块,是很多嵌入式工程师容易翻车的地方。我见过不少项目,代码写得挺漂亮,一上RTOS就各种卡死、数据错乱。问题出在哪?说白了,就是任务没分好,调度策略没选对,临界区保护没做到位。
3.1 任务划分原则:别把鸡蛋放在一个篮子里
任务怎么划分?我个人的习惯是:按功能边界切,按时间要求分。什么意思?你想想看,窗帘电机系统里,有电机控制、有传感器采集、有通信处理、有用户按键响应。这些功能如果揉在一个任务里,那这个任务就太胖了,调度起来很麻烦。
我给大家总结几个原则:
- 功能内聚:一个任务只做一件事。比如电机控制任务只管发PWM、读编码器;通信任务只管收发包。别混在一起。
- 时间解耦:实时性要求高的,单独拎出来。比如电机堵转检测,必须在几毫秒内响应,那就给它高优先级。按键消抖这种,几十毫秒响应就行,放低优先级任务里。
- 资源隔离:共享资源多的功能,尽量放同一个任务里。比如读写同一个I2C总线的传感器,放一起能减少互斥开销。
避坑指南:我曾经在一个项目里,把电机控制和WiFi通信放同一个任务里。结果WiFi发包时卡住了,电机控制也跟着遭殃,窗帘开到一半停在那。嗯,从那以后我坚决按功能边界切任务。
举个例子,窗帘电机系统的任务划分可以这样:
// 任务划分示例
Task1: 电机控制任务 (优先级高,周期5ms)
- 读取编码器位置
- 计算PID输出
- 更新PWM占空比
Task2: 传感器采集任务 (优先级中,周期20ms)
- 读取光照传感器
- 读取温度传感器
- 更新环境状态
Task3: 通信处理任务 (优先级中,事件触发)
- 解析串口/蓝牙命令
- 更新目标位置
Task4: 按键扫描任务 (优先级低,周期50ms)
- 检测按键状态
- 处理用户输入
3.2 抢占式调度策略:谁急谁先跑
抢占式调度,说白了就是高优先级任务可以打断低优先级任务。这个机制很强大,但用不好就是灾难。我见过有人把所有任务优先级都设成一样,那跟裸机轮询有什么区别?
我个人建议的优先级分配策略:
| 任务类型 | 优先级 | 典型周期 | 说明 |
|---|---|---|---|
| 电机控制 | 最高 | 1-5ms | 实时性要求最高,丢一个周期可能抖动 |
| 堵转检测 | 高 | 事件触发 | 发现堵转必须立即停止电机 |
| 传感器采集 | 中 | 10-50ms | 允许少量延迟 |
| 通信处理 | 中低 | 事件触发 | 缓冲区够大就不怕延迟 |
| 按键扫描 | 低 | 50-100ms | 人机交互,慢点无所谓 |
这里有个关键点:优先级反转。低优先级任务拿着高优先级任务需要的资源,结果高优先级任务被阻塞了。我遇到过这种情况:电机控制任务要读一个传感器数据,但传感器采集任务正拿着I2C总线锁,而传感器采集任务又被按键扫描任务抢占了...嗯,电机控制任务就在那干等。
注意:解决优先级反转,可以用优先级继承协议。FreeRTOS里可以开启互斥量(Mutex)的优先级继承功能。我建议你在所有可能发生资源竞争的地方,都用互斥量而不是二值信号量。
3.3 临界区保护:别让数据打架
临界区,就是那些不能被打断的代码段。比如你正在更新一个全局变量,突然被中断打断,中断服务程序也改了同一个变量,那数据就乱了。
保护临界区有三种常用方法:
- 关中断:最简单粗暴,但关中断时间不能太长,否则影响实时性。我一般控制在几十微秒以内。
- 使用互斥量:适合任务之间的资源保护。注意不要死锁,我曾经在项目里两个任务互相等对方的锁,结果系统卡死。排查了半天才发现。
- 使用信号量:适合生产者-消费者模式。比如传感器采集任务往队列里放数据,通信任务从队列里取数据。
给大家看个实际例子:
// 错误示范:没有保护临界区
int motor_position;
void motor_control_task(void) {
motor_position = read_encoder(); // 可能被中断打断
// 中断里也改了motor_position,数据就乱了
}
void encoder_isr(void) {
motor_position++; // 中断里修改同一个变量
}
// 正确做法:关中断保护
void motor_control_task(void) {
taskENTER_CRITICAL();
motor_position = read_encoder();
taskEXIT_CRITICAL();
}
// 或者用互斥量
SemaphoreHandle_t pos_mutex;
void motor_control_task(void) {
xSemaphoreTake(pos_mutex, portMAX_DELAY);
motor_position = read_encoder();
xSemaphoreGive(pos_mutex);
}
小技巧:我习惯在临界区代码前后加注释,标注「临界区开始」和「临界区结束」。这样代码审查时一眼就能看出来。另外,临界区里别做耗时操作,比如打印日志、延时等待,这些都应该放在外面。
最后说一句,任务调度优化没有银弹。每个系统都有自己的特点,你得根据实际需求来调。我建议你在开发初期就把任务划分好,优先级定好,临界区保护好。等系统跑起来了,再用逻辑分析仪看看任务的实际执行时间,微调优先级。嗯,这样基本不会出大问题。