获取更多技术资讯?点击页面右上角【注册】,加入开发者社区吧
前言:一个最常见的“新手崩溃”场景
很多人在第一次把代码从裸机改成用 FreeRTOS 或 RT-Thread 的时候,都会遇到几乎一模一样的崩溃现象:
把两个任务的优先级调换一下,或者干脆把其中一个任务里的延时(vTaskDelay / rt_thread_delay)删掉,然后就发现:
- LED闪烁任务突然不亮了,或者亮得极慢、极不规律。
- 低优先级任务仿佛人间蒸发。
- 高优先级任务却像打了鸡血一样一直跑。
最经典的组合是:
- 一个任务负责翻转LED(低优先级)。
- 另一个任务负责刷新OLED / 轮询串口 / 不断printf(高优先级)。
- 去掉延时 → LED直接“死”了。
void LEDTask(void *para) // 低优先级
{
while (1)
{
LED_Toggle();
delay_ms(100);
}
}
void OLEDTask(void *para) // 高优先级
{
OLED_Refresh_Something();
while (1)
{
//delay_ms(10)// 延时被注释掉了
}
}
很多人第一反应是“RTOS坏了”“调度器有bug”“时间片没起作用”,但其实这正是 RTOS最标准、最符合设计预期的行为。
这篇文章试图把这个现象背后的全部逻辑链条讲清楚,从最基础的调度原理,到实际工程中应该怎么组织任务、怎么分配优先级、怎么避免饿死,一步一步把“为什么例子都要写延时”这件事彻底说透。
一、RTOS调度器的两种基本承诺
现代主流 抢占式RTOS(FreeRTOS、RT-Thread、μC/OS、Zephyr等)对开发者做出了两个最核心的承诺:
- 优先级更高的任务一旦就绪,应当尽可能快地抢占当前正在运行的低优先级任务
(这就是“抢占式”的含义)
- 相同优先级的任务之间,应当尽量公平地分享CPU时间
(这就是时间片轮转的意义)
请特别注意这两句话的顺序:优先级抢占 排在 时间片轮转之前。

换句话说:
- 不同优先级之间 → 完全不讲公平,只讲优先级。
- 只有落在 同一个优先级桶里 的任务,才会进入时间片轮转的公平竞争。
这就直接引出了最致命的 “饿死”(Starvation) 场景:
只要有一个高优先级任务一直不阻塞、不睡眠、不等待任何事件,它就会永久霸占CPU,低优先级任务永远得不到调度机会。
二、为什么“while(1)”在裸机里没问题,在RTOS里会死?
裸机 超级循环(super loop) 写法通常是这样的:
while(1)
{
LED_Toggle();
delay_ms(500); // 软件延时 or 硬件定时器计数等待
OLED_Refresh_Something();
Check_Button();
// ……
}
这里的 delay_ms() 本质上是 忙等待(busy-wait),CPU一直在跑空循环,但整个系统只有一个控制流,所以所有功能都能按序执行。
但一旦进入RTOS,多任务并发后情况完全变了:
void LED_Task(void *p)
{
while(1)
{
LED_Toggle();
// 这里什么都不写 ← 没有延时、没有yield、没有阻塞
}
}
void OLED_Task(void *p)
{
while(1)
{
OLED_Show_Number(...);
// 同样什么都不写
}
}
假设 OLED_Task 优先级更高(数值更小或更大视RTOS定义),那么一旦调度器把它唤醒(或者它压根没sleep过),它就会:
- 抢占
LED_Task
- 进入
while(1) 无限循环
- 不主动让出CPU
- 调度器除非有更高优先级的中断或任务就绪,否则 永远 不会再调度
LED_Task
这就是最经典的 优先级倒挂 导致的无限饥饿(虽然严格意义上Priority Inversion是指低优先级持有资源阻塞高优先级,但这里表现为更直接的“高优先级贪婪”)。
三、时间片(timeslice)到底救得了谁?
很多初学者对时间片抱有严重误解,以为“设置了时间片 = 所有任务都会轮流跑”。
但实际情况是:

一句话总结:
时间片只解决“同一优先级内的公平调度”,对跨优先级的抢占完全无效。
所以即使你给OLED任务和LED任务都设了20个tick的时间片,只要OLED优先级更高,它仍然可以:
- 每次只跑1ms就自己让出(delay(1))。
- 立刻被重新调度回来(因为优先级最高)。
- 继续霸占下一个时间片。
低优先级任务的实际运行时间会被压缩到极致,甚至一个tick都拿不到。
四、延时函数到底在“帮”谁?
vTaskDelay() / rt_thread_delay() / osDelay() 这类函数本质上是做了两件事:
- 把自己挂到 延时列表(或定时器列表)里。
- 立刻调用一次调度器(yield)。
关键是第二步:它 主动让出CPU。
这相当于在代码里写了一句很隐晦的:
“亲爱的调度器,我现在愿意暂时放弃CPU使用权,你可以看看有没有其他任务想跑。”
如果当前没有更高优先级的就绪任务,调度器就会挑选下一个最高优先级的就绪任务运行。
所以最常见的几种“健康”的写法对比:
| 写法 | 是否主动让出CPU | 高优先级饿死低优先级的风险 | 推荐场景 |
while(1){ 干活; } | 否 | 极高 | 最高优先级、极短确定性任务 |
while(1){ 干活; vTaskDelay(1); } | 是(频繁) | 中 | 中等优先级、需要较平滑响应 |
while(1){ 干活; vTaskDelay(100); } | 是 | 低 | 大多数非实时任务 |
xSemaphoreTake() / xQueueReceive() | 是(阻塞式) | 极低 | 最推荐的写法 |
vTaskDelayUntil() | 是(精确定时) | 低 | 周期性硬实时任务 |

五、真实案例复现与分析(三种经典饿死形态)
形态1:高优先级无阻塞无限循环
void HighPrio_Task(void *p)
{
while(1)
{
GPIO_Toggle(DEBUG_PIN);
// 没有任何delay、yield、阻塞
}
}
结果: 只要这个任务启动,低优先级任务基本全饿死。

形态2:高优先级频繁短sleep,但sleep时间极短
while(1)
{
Do_Something_Quick(); // 200μs
vTaskDelay(1); // 1 tick,通常1~10ms
}
即使sleep了1tick,如果tick=1ms,而低优先级任务一次循环需要2ms,那么它 永远 也跑不完一次循环。
形态3:高优先级阻塞在极高频的外部事件上
最典型的是:
while(1)
{
if (USART_GetFlagStatus(USART_FLAG_RXNE))
{
putchar(USART_ReceiveData());
}
}
只要串口没有数据,它就在 while(1) 空轮询,CPU占用率接近100%,低优先级任务同样被饿死。
解决办法只有两条路:
- 改成阻塞式等待(推荐)
xQueueReceive() / rt_mb_recv() / rt_sem_take() 等
- FreeRTOS的uart用中断+队列,rt-thread用串口设备框架
- 强制插入延时(次选)
六、工程中优先级与阻塞策略的推荐分层
根据实际项目踩坑经验,更推荐使用下面这套优先级与阻塞策略:
| 优先级区间(数值越小越高) | 典型任务类型 | 阻塞策略强烈推荐顺序 | 是否允许while(1)不阻塞 |
| 0 ~ 3 | 最高实时性(电机FOC、编码器采样、安全关断) | vTaskDelayUntil() > 硬中断 | 允许(但要极短) |
| 4 ~ 8 | 通信协议栈、USB、CAN收发、Modbus | 阻塞队列/信号量 > Delay(15) | 尽量不允许 |
| 9 ~ 15 | 人机交互、OLED/LCD刷新、日志、文件系统 | 阻塞事件/队列 > Delay(550) | 强烈不推荐 |
| 16 ~ 24 | 后台监测、看门狗喂狗、心跳LED、参数存储 | Delay(501000) 最常见 | 可以,但不鼓励 |
| 25 ~ MAX | 空闲任务钩子、CPU占用统计 | while(1){} 就行 | 允许 |
RTOS 任务健康设计原则图解:

七、如何判断一个任务“是否需要主动让出CPU”?
问自己三个问题(按顺序):
- 这个任务的优先级是不是当前系统中最高的那几个之一?
- → 是 → 可以考虑不阻塞(但要确保最坏情况执行时间可控)。
- 这个任务是否必须以极高的频率、极低的抖动运行?
- → 是 → 考虑用定时器中断或
vTaskDelayUntil(),而不是普通delay。
- 这个任务一次循环的执行时间是否明显长于12个tick?
- → 是 → 必须插入阻塞或延时,否则必然饿死更低优先级的任务。
绝大多数业务逻辑任务,答案都是“否-否-是”,所以必须主动让出。
八、总结:一句话设计原则
“高优先级慎贪婪,低优先级必谦让”。
更口语一点的工程版口诀:
- 优先级最高的几个任务,可以写得“霸道”一点(但要真的实时)。
- 中间层通信、显示、日志任务,强烈建议用事件驱动阻塞等待。
- 低优先级“花瓶”任务(LED、蜂鸣器、心跳包、周期性存储),直接写延时最保险。
最后送给大家一句我在无数次debug饿死问题后最有感触的话:
RTOS里最贵的是浪费在排查“为什么不运行”上的时间。
多写一点delay、多用一点队列/信号量,代码看起来丑一点,但能一次过、稳定跑半年不死机,才是真正的漂亮代码。
