4、时间片轮询架构:超级循环的缺陷,时间片轮询思想,定时器调度器实现,任务优先级划分
好,咱们今天聊一个嵌入式开发里绕不开的话题——时间片轮询架构。
说实话,我见过太多新手工程师,甚至有些工作两三年的朋友,还在用超级循环(Super Loop)硬扛所有逻辑。不是说超级循环不能用,而是你得知道它什么时候该退休了。
4.1 超级循环的“死穴”
先说说超级循环长什么样。说白了就是 while(1) 里塞一堆函数调用,一个接一个跑。
void main(void)
{
while(1)
{
Key_Scan(); // 按键扫描
LED_Process(); // LED 显示
UART_Process(); // 串口处理
ADC_Process(); // ADC 采集
// ... 还有更多
}
}
这段代码看着挺规整对吧?但实际跑起来,问题就来了。
我曾经踩过的一个坑: 有个项目用超级循环做温控器,按键扫描里加了个去抖延时,结果整个循环周期被拉长到 50ms。ADC 采集频率不够,温度控制直接震荡。查了两天才找到根儿——就是那个延时把时间轴打乱了。
超级循环的核心缺陷,我总结成三点:
- 任务互相阻塞: 一个任务卡住,后面的全得等着。你想想看,按键扫描里如果用了
delay_ms(20),那 LED 刷新就得等 20ms。 - 实时性无法保证: 每个任务的执行周期完全取决于前面任务的总耗时。今天代码少跑得快,明天加个功能就变慢了,不可控。
- 扩展性差: 加一个新功能就得重新算一遍循环周期,改来改去容易出 bug。
所以,当你的项目里同时有按键、显示、通信、采集这些任务时,超级循环基本就撑不住了。
4.2 时间片轮询思想:把时间切成“蛋糕”
时间片轮询的思路其实很简单——把 CPU 的时间切成固定长度的小片,每个任务分一片。谁也别占着不放,大家轮流用。
我个人习惯把这个思想叫做“分时复用”。就像公司开会,每人发言 5 分钟,时间到了就换下一个人。这样谁都有机会说话,不会出现一个人讲半小时的情况。
核心原则: 每个任务必须在自己的时间片内执行完毕,不能阻塞等待。如果任务需要较长时间,就拆成多个小步骤,分多次执行。
时间片轮询和超级循环的对比,我整理了一张表:
| 对比项 | 超级循环 | 时间片轮询 |
|---|---|---|
| 任务调度方式 | 顺序执行,一个接一个 | 分时轮转,每个任务固定时间 |
| 实时性 | 差,受任务耗时影响 | 好,每个任务周期可控 |
| 扩展性 | 差,加功能需重新评估 | 好,新增任务只需分配时间片 |
| 代码复杂度 | 低 | 中等 |
| 适用场景 | 极简系统,任务少 | 多任务,中等复杂度 |
说白了,时间片轮询就是给超级循环加了个“调度器”,让系统变得有序可控。
4.3 定时器调度器实现:从理论到代码
好,理论说完了,咱们直接上代码。我常用的定时器调度器实现,核心就两个东西:一个硬件定时器产生心跳,一个任务链表管理任务。
先看定时器部分。我一般用 SysTick 或者通用定时器,设置 1ms 中断一次。
// 定时器中断服务函数,1ms 触发一次
void Timer_IRQHandler(void)
{
static uint32_t tick = 0;
tick++;
// 这里只做一件事:更新系统心跳
SystemTick = tick;
}
然后定义任务控制块(TCB):
typedef struct {
void (*func)(void); // 任务函数指针
uint32_t period; // 任务周期(单位:心跳数)
uint32_t elapsed; // 已过去的心跳数
uint8_t priority; // 优先级(0最高)
uint8_t running; // 运行标志
} Task_t;
调度器主循环长这样:
void Scheduler_Run(void)
{
uint32_t i;
uint32_t currentTick = SystemTick;
for(i = 0; i < TASK_MAX; i++)
{
// 跳过无效任务
if(taskList[i].func == NULL) continue;
// 计算是否到达执行时间
if((currentTick - taskList[i].elapsed) >= taskList[i].period)
{
taskList[i].elapsed = currentTick;
taskList[i].running = 1;
taskList[i].func(); // 执行任务
taskList[i].running = 0;
}
}
}
一个小技巧: 我用 currentTick - taskList[i].elapsed 而不是直接累加,这样可以避免 tick 溢出带来的问题。32 位无符号数溢出后,减法依然能得到正确结果,这是利用了补码的特性。
注册任务也很简单:
void Task_Register(uint8_t id, void (*func)(void), uint32_t period_ms, uint8_t priority)
{
taskList[id].func = func;
taskList[id].period = period_ms;
taskList[id].elapsed = 0;
taskList[id].priority = priority;
taskList[id].running = 0;
}
嗯,这里要注意一点:任务的执行时间不能超过它的周期。比如一个 10ms 的任务,你执行了 15ms,那下次调度就会出问题。我在项目中遇到过这种情况,最后是通过任务拆分解决的。
4.4 任务优先级划分:谁先谁后?
时间片轮询虽然公平,但有些任务确实更紧急。比如按键响应可以等 50ms,但电机堵转保护必须在 1ms 内处理。这时候就需要优先级了。
我一般把任务分成三个等级:
- 高优先级(0-1): 紧急保护、通信超时检测。这些任务必须在最短时间内响应。
- 中优先级(2-3): 数据采集、状态更新。周期固定,但可以容忍少量延迟。
- 低优先级(4-5): 界面刷新、日志输出。晚几十毫秒无所谓。
调度器里加个优先级判断:
void Scheduler_Run_Priority(void)
{
uint32_t i, j;
uint32_t currentTick = SystemTick;
Task_t *task;
// 按优先级从高到低遍历
for(j = 0; j <= PRIORITY_MAX; j++)
{
for(i = 0; i < TASK_MAX; i++)
{
task = &taskList[i];
if(task->func == NULL) continue;
if(task->priority != j) continue;
if((currentTick - task->elapsed) >= task->period)
{
task->elapsed = currentTick;
task->func();
}
}
}
}
我曾经犯过的错: 一开始我把高优先级任务放在最前面,结果低优先级任务永远得不到执行——因为高优先级任务每次都会抢占 CPU。后来我改成“每个优先级轮询一次,高优先级先执行”,才解决了这个问题。
下面这张图展示了时间片轮询架构的整体流程:
4.5 实际项目中的避坑指南
最后分享几个我在实际项目中积累的经验:
- 任务不要做死等: 比如用
while(!flag)等待某个事件,这会阻塞整个调度器。改成状态机或者回调。 - 心跳频率要合理: 1ms 是常用值,但如果你所有任务周期都是 10ms 以上,用 5ms 心跳可以减少中断开销。
- 任务执行时间要测量: 用 GPIO 翻转 + 示波器看每个任务的执行时间,确保不超过周期的一半。
- 临界区保护: 如果任务和中断共享变量,记得关中断保护。我习惯用
__disable_irq()和__enable_irq()包裹。
总结一下: 时间片轮询架构是超级循环到 RTOS 之间的“黄金过渡方案”。它不需要复杂的任务切换和栈管理,但已经能解决大部分多任务调度问题。我个人的建议是:如果你的项目任务数在 5-15 个之间,周期要求不苛刻(1ms 以上),时间片轮询是最优解。