1. 从一次"线程不切换"的现场说起:调度器到底在什么时候接管CPU
手上有块板子在跑 RT-Thread,两个线程,一个负责采样串口数据,一个是主处理逻辑,采样线程优先级设成 10,处理线程设成 12。按理说数据一到,采样线程该立刻抢上去,但实际抓波形的时候发现,处理线程正在跑一段两百多行的计算,采样线程硬是等它跑完才被调度,串口 FIFO 直接溢出,丢了四五个字节。第一反应是中断没响应?不是,中断进得去,数据也存到了 ringbuffer,问题是"存完之后回到线程态,本该切到高优先级线程去消费,结果没切"。
这类现象在 RT-Thread 上非常典型,而且九成以上不是调度器本身的 bug,而是你对"调度发生的时机"理解得不够细。很多刚上手的人对任务调度的印象停留在"优先级高的先跑",这没错,但不完整。RT-Thread 的调度器不是时时刻刻在做决策的,它是一套事件驱动的机制:只有在特定的时刻调用rt_schedule(),内核才会重新比较所有就绪线程的优先级,决定要不要换人。其余时间,CPU 就老老实实跑当前线程,谁也不会来打扰它。
所以真正要搞清楚的问题是:谁在什么时候调用了rt_schedule()。把这份清单记牢,前面那种"高优先级线程被拖住"的现象基本就能自己解释了。
1.1 触发调度决策的几个入口
我按调用频次从高到低排一下,这份清单比翻手册管用:
| 触发场景 | 具体函数路径 | 是否立即切换 |
|---|---|---|
| 系统节拍到来 | rt_tick_increase()→ 时间片耗尽 →rt_thread_yield() | 是(在中断退出时完成) |
| 线程主动延时 | rt_thread_delay()/rt_thread_sleep()→rt_thread_suspend()→rt_schedule() | 是 |
| 等待 IPC 资源 | rt_sem_take()/rt_mutex_take()等挂起当前线程 | 是 |
| 释放 IPC 资源唤醒他线程 | rt_sem_release()等内部判断是否需要抢占 | 是 |
| 新建线程且优先级更高 | rt_thread_startup()→rt_thread_resume() | 是 |
| 线程优先级被动态修改 | rt_thread_control(RT_THREAD_CTRL_CHANGE_PRIO) | 是 |
| 线程主动让出 | rt_thread_yield() | 是 |
| 退出临界区 | rt_exit_critical()且rt_scheduler_lock_nest归零 | 是 |
反过来看,不触发调度的操作同样值得记住:普通的变量赋值、内存拷贝、纯计算函数、rt_kprintf(除非它内部等串口设备)、以及在调度器加锁期间的任何阻塞 API——最后这条是重灾区,后文单独讲。
回到开头那个现场。处理线程在跑两百多行计算,中间没调用任何阻塞 API,也没让出,系统节拍中断进来只做了一件事:把当前线程的remaining_tick减一。减完还是大于零,rt_tick_increase()就直接返回了,根本不会调用rt_schedule()。所以采样线程再急也没用,内核压根没做过"要不要换人"这个判断。
1.2 一个容易忽略的事实:调度发生在中断退出,而不是中断里
scheduler在中断上下文里被调用时,它并不会真的在中断服务程序里做寄存器搬运。RT-Thread 的做法是把"要切换到哪个线程"这个结论记在rt_interrupt_to_thread里,然后挂起一个 PendSV 异常。PendSV 的优先级被设成最低,所以它会等所有其它中断都处理完了才执行,真正的寄存器保存和栈指针交换在那一刻完成。
这个设计带来一个很实用的推论:你在中断服务程序里看到的"当前线程",已经不是中断退出后的当前线程了。所以千万别在 ISR 里缓存rt_thread_self()的返回值然后在别处用,那个指针很可能已经过期,虽然内存还在,但语义上完全错了。我见过有人把线程指针存到一个全局变量里做日志,结果日志全串了行。
还有一点,如果你的应用里出现了"高优先级线程响应延迟",先别急着怀疑调度器。拿逻辑分析仪在rt_schedule()里翻转一个 GPIO(只在调试版本里加,发布版务必删掉),看看节拍中断到底有没有进来、进来了有没有走到调度那一步。大部分情况下波形会告诉你:中断被关了太久,或者高优先级线程压根就没被唤醒——它在等一个信号量,而信号量是在那个低优先级线程的计算跑完之后才释放的。
2. 就绪表与优先级位图:32级优先级的查找是怎么做到常数时间的
调度的核心动作其实就一句话:在就绪线程里找出优先级最高的那个。听起来是个查找问题,最朴素的实现是遍历一遍就绪链表,比较优先级。但 RT-Thread 显然不会在每次调度时都做线性扫描——节拍中断每秒进来几百上千次,每次都扫一遍链表的话,光是调度开销就能吃掉可观的 CPU。
它用的是位图加速。这套东西不复杂,但理解了之后,你在配优先级、算线程数量的时候心里会有一杆秤。
2.1rt_thread_ready_table和rt_thread_ready_priority_group的分工
先说数据结构。系统默认支持 32 个优先级(由RT_THREAD_PRIORITY_MAX决定,最大可以配到 256),这 32 个优先级被分成 4 组,每组 8 个。rt_thread_ready_table是一个字节数组,每个字节的 8 个 bit 分别代表组内 8 个优先级上"有没有就绪线程";rt_thread_ready_priority_group是一个 32 位整数,它的第 n 位代表"第 n 组里有没有任何一个就绪线程"。
两层结构,像一本书的目录加索引:先查目录(group)看哪一章有内容,再翻到那一页(table)找具体位置。这么做的好处是查找被拆成了两步,每步都是常数时间。
线程就绪时,内核做的事就是置位:
/* 简化示意,实际宏定义在 scheduler.c 中 */ rt_thread_ready_table[thread->number] |= thread->number_mask; rt_thread_ready_priority_group |= thread->number_mask_high;其中number是优先级除以 8 的组号(优先级右移 3 位),number_mask是组内位掩码(1 << (优先级 & 7))。注意这两个值在rt_thread_init()的时候就算好了,存在线程控制块里。这是个很细节但很聪明的优化:调度路径上不做除法、不做移位运算,直接查现成的字段。
2.2__rt_ffs:一条指令定位最高优先级
真正的技术活在这里。拿到rt_thread_ready_priority_group之后,怎么快速找到最低位的那个 1?因为优先级数值越小代表越紧急,所以"最低位的 1"就是"最高优先级的组"。
ARM Cortex-M 提供了CLZ(Count Leading Zeros,前导零计数)指令,配合RBIT(位反转)就能一步到位:
__asm int __rt_ffs(int value) { CMP r0, #0x00 BEQ exit RBIT r0, r0 ; 位序整体反转 CLZ r0, r0 ; 数前导零 ADDS r0, r0, #0x01 exit BX LR }在 Cortex-M3/M4/M7 上,RBIT和CLZ都是单周期指令。也就是说,从一个 32 位整数里找出最紧急的那一组,代价是两条指令加一次加法。这个效率下,调度的查找开销几乎可以忽略不计。
我第一次读到这段汇编的时候有点惊讶——原来所谓"实时操作系统"的实时性,很大一部分是靠这种抠到指令级的细节撑起来的。你要是把它换成for循环扫描,功能上完全一样,但中断响应时间会明显不一样。
再看完整的查找逻辑,就是一个两级的 ffs:
highest_ready_priority = __rt_ffs(rt_thread_ready_priority_group) - 1; /* 得到组号后,再查组内的位图 */ highest_ready_priority = (highest_ready_priority << 3) + __rt_ffs(rt_thread_ready_table[highest_ready_priority]) - 1;先找组,再找组内偏移,两步都是常数时间,跟就绪线程有多少个完全无关。这就是位图方案的价值。
2.3 优先级数值越小越先跑,这条规则带来的连锁设计
RT-Thread 里优先级 0 是最高,数值越大优先级越低。这个和某些系统(比如用大数值表示高优先级)是反过来的,新手第一次配优先级经常搞混,把"要紧的线程"配成 30,然后疑惑为什么它老抢不到 CPU。
这条规则的设计理由其实挺自然的:位图从低位往高位找,低位对应小数值,正好对应高优先级。如果反过来定规则,__rt_ffs就得换成找最高位的 1,指令序列会更绕一点。设计者选了个让硬件更省事的方向。
顺着这个规则,几条实用约束就出来了:
- 空闲线程的优先级固定是
RT_THREAD_PRIORITY_MAX - 1,也就是最低,它永远抢不到别人的 CPU,只能捡漏。 - 定时器线程的优先级默认比大部分应用线程高(可配),你如果把自己的线程优先级配到比定时器线程还高,而且不阻塞,那软件定时器就永远得不到执行——这不是 bug,是设计使然。
- 优先级相同的线程之间不存在抢占关系,只能靠时间片轮转或者主动让出,这是第 4 节要展开的内容。
提示:
RT_THREAD_PRIORITY_MAX在配置里改成 256 之后,rt_thread_ready_table会变成 32 字节,rt_thread_ready_priority_group变成rt_uint32_t,查找逻辑相应变成两级以上的处理。如果你的线程数量根本用不到 256 级,默认 32 级是更好的选择——省 RAM,也让优先级分配更清晰。
实际项目里我给的建议是:优先级不要连着用,留间隔。比如关键采样线程用 5,通信用 10,业务逻辑用 15,日志用 20,中间留空方便后期插需求。我踩过的坑就是早期把 0 到 6 全占满了,后来要插入一个"必须在业务之前、但在采样之后"的线程,只能重排所有优先级,改了三个模块的宏定义,还引发了一次回归测试。
3. 上下文切换的完整链路:从PendSV到一次栈指针交换
知道"该切给谁"之后,剩下的问题是怎么切。这一步是整个调度机制里最不透明、也最容易出问题的地方,因为它涉及汇编、涉及栈、涉及异常优先级,出问题时现象往往很诡异——不是崩溃,而是"数据偶尔不对",特别难查。
我按 Cortex-M 平台把这条链路走一遍,其它架构(比如 RISC-V)思路类似,具体指令不同。
3.1 三个线程指针变量撑起了整个切换流程
内核里维护着三个关键变量:rt_current_thread(当前跑的)、rt_interrupt_from_thread(要切走的)、rt_interrupt_to_thread(要切过去的)。rt_hw_context_switch(from, to)的工作就是给后两个赋值,然后触发 PendSV。
Cortex-M 上的实现大概是这样:
void rt_hw_context_switch(rt_uint32_t from, rt_uint32_t to) { /* 记录源线程和目标线程 */ rt_interrupt_from_thread = from; rt_interrupt_to_thread = to; /* 置位切换标志 */ rt_thread_switch_interrupt_flag = 1; /* 挂起 PendSV 异常 */ SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; }注意这里做的全是"记录意图",没碰任何寄存器。为什么不在函数里直接切?因为在中断上下文里切换寄存器,会把其它中断的现场搞乱。把脏活丢给一个优先级最低的异常,等所有中断都处理完,系统回到最"干净"的状态再切,这才是安全做法。
rt_hw_context_switch_to(to)第一次启动调度器时用的稍有不同:它没有源线程,所以rt_interrupt_from_thread置 0,PendSV 里判断到这个值就直接跳过保存步骤,只做恢复。
3.2 栈上到底保存了什么,为什么少存一个寄存器就会炸
Cortex-M 的异常机制有个特性:进入异常时硬件会自动把R0-R3、R12、LR、PC、xPSR压到当前使用的栈上。所以 PendSV 里软件只需要保存R4-R11这 8 个寄存器就够了,其余的硬件已经代劳。
PendSV 处理函数的核心就这么几行:
PendSV_Handler: MRS r2, PRIMASK CPSID I ; 关中断,切换过程中不能被打算 LDR r0, =rt_thread_switch_interrupt_flag LDR r1, [r0] CBZ r1, pendsv_exit ; 标志为0,无需切换 MOV r1, #0x00 STR r1, [r0] ; 清标志 LDR r0, =rt_interrupt_from_thread LDR r1, [r0] CBZ r1, switch_to_thread ; 源线程为0,首次启动 MRS r1, PSP ; 取当前线程栈指针 STMFD r1!, {r4 - r11} ; 压入 R4-R11 LDR r0, [r0] STR r1, [r0] ; 保存回线程控制块的 sp switch_to_thread: LDR r1, =rt_interrupt_to_thread LDR r1, [r1] LDR r1, [r1] ; 取目标线程的 sp LDMFD r1!, {r4 - r11} ; 恢复 R4-R11 MSR PSP, r1 ; 更新栈指针 pendsv_exit: MSR PRIMASK, r2 BX LR ; 异常返回,硬件自动恢复剩余寄存器关键在于最后那个BX LR。异常返回时,硬件会从新的 PSP 指向的位置弹出R0-R3、R12、LR、PC、xPSR,PC 一装填,CPU 就跳到目标线程上次停下的地方继续跑。整个过程对线程来说是透明的——它只知道自己的R0到R15都还在。
3.3 栈溢出为什么表现成"随机崩溃"而不是"编译报错"
从上面的流程能看出来一件事:线程的栈是切换的载体。如果某个线程的栈不够用,压进去的R4-R11或者更早的局部变量就会覆盖到相邻内存——可能是另一个线程的栈,也可能是线程控制块,甚至是就绪位图。
这就解释了为什么栈溢出几乎不表现为"栈溢出"这个明确的错误,而是五花八门的症状:某个线程的优先级突然变了(位图被踩)、某个信号量计数莫名其妙变成负数(TCB 被踩)、切换到一个不存在的地方直接进 HardFault(栈里的返回地址被踩)。
我的处理方式是:开发阶段给每个线程的栈末尾填0xCC或者0xDEADBEEF之类的魔数,然后定期扫描魔数有没有被破坏。RT-Thread 提供了rt_thread_stack_check相关的钩子,也可以在空闲线程里自己写循环扫描。这个开销在调试阶段完全可以接受,量产版本关掉。
再补一个细节:STMFD r1!, {r4 - r11}这个指令里有个!,表示写回。它的含义是压栈后r1会自减到新的栈顶位置,最后STR r1, [r0]存的就是切换点之后的真实栈指针。如果有人改这段汇编时漏掉写回符号,栈指针就会永远指向切换前的位置,第一次切换没事,第二次开始栈就乱了。这类问题定位起来极其痛苦,所以——别改这段汇编,除非你真的非常清楚每个寄存器的用途。
4. 时间片轮转:抢占式调度之外的第二个维度
到这儿为止讲的都是"不同优先级之间"的规则:高的抢低的。但现实中经常有多个线程优先级相同的情况,比如三个优先级都是 15 的协议解析线程,它们之间怎么分 CPU?这就轮到时间片上场了。
很多资料把时间片和抢占混在一起讲,容易让人以为"时间片用完就抢占"。准确的说法是:时间片只在同优先级的线程之间起作用。一个低优先级线程的时间片耗尽,不会让它被高优先级线程抢——高优先级线程本来就该抢它,跟时间片无关。
4.1remaining_tick的递减与重置逻辑
每个线程控制块里有两个和时间片相关的字段:init_tick(初始时间片,创建时由参数指定)和remaining_tick(剩余时间片)。初始状态两者相等。
系统节拍中断rt_tick_increase()里做的事很干脆:
void rt_tick_increase(void) { struct rt_thread *thread; ++rt_tick; /* 全局节拍计数 */ thread = rt_thread_self(); --thread->remaining_tick; /* 当前线程时间片减一 */ if (thread->remaining_tick == 0) { thread->remaining_tick = thread->init_tick; /* 重置 */ rt_thread_yield(); /* 让出 CPU */ } rt_timer_check(); /* 处理定时器 */ }注意rt_thread_yield()里面做了什么:把当前线程从就绪队列的队头挪到队尾,然后把状态标成"被让出",再调rt_schedule()。调度器一看,当前线程还是最高优先级,但已经标记为让出,那就从就绪链表里取下一个同优先级的线程。这样就实现了同优先级线程的轮流执行。
有个地方值得强调:remaining_tick的重置发生在时间片耗尽的那一刻,而不是发生在"线程被重新调度上 CPU 的那一刻"。这意味着如果线程在时间片没用完时主动阻塞了,被唤醒后remaining_tick还是上次剩下的值,不会重置。这个行为是合理的——否则一个频繁阻塞的线程可以靠"每次阻塞后重置"来变相获得更多 CPU,那就不公平了。
4.2 同优先级线程的入场顺序和"新线程不抢占"规则
有个细节容易踩坑:同优先级的线程创建并启动后,不会立即抢占正在运行的同优先级线程,而是排到就绪链表尾部等着。这个设计是对的,否则每创建一个同优先级线程都会打断当前工作,调度开销反而上去了。
但如果新线程优先级更高,rt_thread_startup()内部会走rt_thread_resume(),检测到优先级高于当前线程,立刻调rt_schedule()完成抢占。所以"创建一个高优先级线程"这个操作本身可能引起一次上下文切换,这一点在初始化阶段要留意——我见过有人在初始化流程里依次创建五六个线程,每个都比前一个优先级高,结果初始化代码被切得七零八落,某个还没配好参数的全局变量被高优先级线程提前访问了。
解决办法很简单:初始化阶段用rt_enter_critical()包起来,或者先把所有线程创建成挂起状态,最后统一rt_thread_startup()。
4.3 时间片配多大合适:我踩过的两个极端
init_tick的单位是系统节拍(tick)。假设 tick 是 1ms,那么init_tick = 10就意味着 10ms 一轮。
配得太小:比如设成 1 个 tick,那每次节拍中断都会触发一次切换。切换本身有开销(保存 R4-R11、查就绪表、可能还有缓存失效),如果节拍频率是 1kHz,那每秒要做 1000 次切换,CPU 时间相当可观。我实测过一个极端案例:把三个同优先级线程都设成 1 tick,CPU 占用率比设成 10 tick 高了接近 8%,而任务本身并没有变多变少。
配得太大:比如设成 1000 个 tick,也就是 1 秒。那就等着交互体验崩掉吧——一个线程出问题卡住了,同优先级的另一个线程要等一整秒才能接手。
我的经验值是:让单个时间片落在 5ms 到 20ms 之间,具体看任务的实时性要求。对响应敏感的用 5,对吞吐敏感的用 20。而且要注意,如果两个同优先级线程一个计算量大、一个只是偶尔跑一下,时间片设小了对计算量大的那个不太公平(频繁切换打断它),这时候更好的做法是干脆拆成不同优先级,而不是靠时间片去硬调。
另外有一个隐藏限制:init_tick是rt_uint32_t,但节拍计数的溢出处理需要留意。RT-Thread 在rt_tick_increase()里对rt_tick做了回绕处理,但你自己在业务代码里如果直接比较两个 tick 值的大小(if (now > last)),在回绕点会出问题。正确写法永远是用差值:
/* 错误:tick 回绕后会失灵 */ if (rt_tick_get() > deadline) { ... } /* 正确:用无符号差值,回绕也能正常工作 */ if ((rt_tick_get() - deadline) < 0x80000000UL) { ... }这类问题在设备连续跑几十天之后才会暴露,测试阶段根本发现不了,属于那种"上线三个月后才来投诉"的坑,值得提前防住。
5. 调度器锁、关中断、挂起线程:三种保护手段别用混
刚开始写 RT-Thread 驱动的时候,我把这三样东西当成了可以互换的工具:反正都是"不让别人打扰我"。后来一次偶发的死机把我教育了——问题出在我用rt_enter_critical()保护一段需要等硬件的代码,结果在临界区里调用了会阻塞的 API,整个系统卡死。
这三者的作用范围是完全不同的,混淆使用会出各种诡异问题。
5.1 调度器锁到底锁住了什么
rt_enter_critical()做的事很简单:给rt_scheduler_lock_nest加一。rt_schedule()进来第一件事就是检查这个值,非零就直接返回,什么都不干。
也就是说,调度器锁只阻止"切换线程",不阻止中断。定时器中断照进,串口中断照进,中断里调rt_sem_release()照常唤醒线程——只是唤醒完了,rt_schedule()被拦下来,被唤醒的线程要等到rt_exit_critical()才会真正被调度。
这个特性很实用:如果你有一段代码必须在同一个线程里连续执行完(比如 A/B 两段必须原子),但中间又不想长时间关中断(会影响中断响应),那用临界区是对的。
但它有个致命陷阱:在临界区里调用阻塞 API 就是自寻死路。比如你在临界区里rt_sem_take(sem, RT_WAITING_FOREVER),结果信号量没被释放,当前线程被挂起,然后调rt_schedule()——被锁拦住了,函数直接返回。于是线程状态变成了"挂起",但 CPU 还在它身上,接着往下执行。这个状态在系统里就是一颗定时炸弹,后续行为完全不可预测。
我那次死机的具体表现是:串口打印到一半停了,看门狗没触发(因为 CPU 还在跑),断点打进去发现当前线程状态是RT_THREAD_SUSPEND,但它却在执行代码。这个现象我当时看了很久才反应过来。
5.2 关中断的预算:不是越短越好,是有明确上限
rt_hw_interrupt_disable()关的是全局中断(Cortex-M 上操作PRIMASK)。这是最强的保护手段,也是最贵的手段——关多久,中断就延迟多久。
对于实时系统来说,有一个隐含的约束:最长的关中断时间必须小于系统的最坏中断响应时间要求。举个例子,串口波特率 115200,一个字节的传输时间是 87 微秒。如果硬件 FIFO 深度是 1 字节,那关中断时间超过 87 微秒就会丢数据。
所以我的做法是:写完一段关中断代码,心里默数一下里面有多少条指令、有没有循环、循环次数上界是多少。比如一个 for 循环清 32 个元素的数组,每次循环大约 5 条指令,在 100MHz 的核上大概 1.6 微秒,可以接受。但如果是个清 4096 元素的memset,那就是 200 微秒左右,必须挪到中断外面做。
RT-Thread 内部大量使用关中断,但每次都很短——基本都是几条指令级别的操作(置位、清零、链表的头插)。如果你在追踪调度延迟问题,可以在rt_hw_interrupt_disable和rt_hw_interrupt_enable里加 GPIO 翻转,跑一段时间看最大高电平宽度,这个数值就是系统的中断延迟上界,很有参考价值。
5.3 中断服务程序里能调什么,不能调什么
这个界限一定要清楚,我在代码 review 里见过太多次这个问题。
能调用的:rt_sem_release()、rt_mutex_release()(有条件)、rt_event_send()、rt_mb_send()的非阻塞版本、rt_timer_start()、rt_thread_resume()。这些函数的共同点是不会让调用者阻塞。
不能调用的:任何带超时参数且可能挂起的函数,比如rt_sem_take(sem, RT_WAITING_FOREVER)、rt_thread_delay()、rt_mb_recv()的阻塞版本。在 ISR 里调用这些,轻则触发断言,重则直接把中断上下文当成线程上下文挂起,系统彻底乱套。
RT-Thread 提供了一组以_isr结尾的接口来明确这个边界,比如rt_sem_release本身可以在 ISR 里用,而rt_mb_send有rt_mb_send_wait这种会等待的版本,就不能在 ISR 里用。写驱动时养成习惯:中断里的代码只做"扔消息"的动作,所有处理逻辑丢给线程。这既是正确性问题,也是实时性问题——ISR 越短,系统的整体响应越好。
/* 推荐的中断处理模式 */ void UART_IRQHandler(void) { rt_uint8_t ch; /* 硬件操作:读数据、清标志 */ while (uart_hw_read(&ch) == 0) { /* 只做投递,不做解析 */ rt_mb_send(&uart_mb, ch); } } /* 解析逻辑放在线程里 */ void uart_parse_thread(void *param) { rt_uint8_t ch; while (1) { if (rt_mb_recv(&uart_mb, &ch, RT_WAITING_FOREVER) == RT_EOK) { /* 这里可以随便阻塞、随便耗时 */ protocol_parse(ch); } } }这段代码看着平淡,但它把"中断上下文"和"线程上下文"彻底隔离了,后面无论解析逻辑多复杂,都不会影响中断响应。我后来所有驱动都按这个模式写,出问题的概率直线下降。
6. 实测踩坑:几类看着像调度故障、其实不是的问题
前面讲的都是机制,这节讲实战。下面这几个案例都是我或者身边同行真实遇到过的,症状都指向"调度出问题了",但根因各不相同。掌握这套排查思路,比记住任何结论都有用。
6.1 低优先级线程几个月没跑过:空闲线程钩子救场
现象:一个负责写 Flash 日志的线程,优先级设得最低(除了空闲线程),跑着跑着就不写日志了。加打印发现它压根没被调度过。
原因很常见:系统里有一堆中高优先级线程,全都不是阻塞型,而是"干完活就rt_thread_delay(1)"这种写法。1 个 tick 的延时,意味着每个 tick 它们都会醒一次,醒来的瞬间又把 CPU 抢走,低优先级线程永远排不上。
这种叫"优先级饥饿",不是 bug,是调度策略的必然结果。解决办法有几种:
- 把日志线程的优先级提上去,哪怕只比那些线程低一级,也能保证它有机会跑。
- 用
rt_thread_yield()替代rt_thread_delay(1),让同优先级线程轮转,但不影响低优先级。 - 把日志写入放到空闲线程钩子里做——这是我最推荐的方式,因为空闲线程的调度是"系统真的没事干"的信号,此时干脏活最合适。
static void idle_hook(void) { /* 只在有活干的时候写,避免每次都进 */ if (log_pending) { flash_write_pending_logs(); } } /* 初始化阶段注册 */ rt_thread_idle_sethook(idle_hook);但要注意,空闲钩子运行在中断关闭的环境下(不同版本实现有差异,务必查对应版本的源码),里面不能调用任何可能阻塞的 API,也不能耗时太长,否则会拖累整个系统的空闲时间统计。写 Flash 这种操作如果耗时几百微秒以上,还是老老实实放线程里做,钩子里只置个标志。
6.2 优先级反转:一个互斥量没用好,整个系统卡了半秒
这个案例是经典问题,但因为太隐蔽,还是经常有人中招。
场景:三个线程——高优先级 H(20)、中优先级 M(15)、低优先级 L(10)。L 先拿到了一个互斥量 mutex,正在执行临界区里的操作(比如写一块共享配置)。此时 H 醒了,要拿同一个 mutex,被挂起。L 还没来得及释放,M 醒了(它不涉及这个 mutex),因为优先级比 L 高,M 抢占了 L。于是 L 迟迟不能执行完、不能释放 mutex,H 就只能一直等,等到 M 干完。
结果就是 H 的等待时间被 M 的执行时间拉长了,优先级形同虚设。
RT-Thread 的rt_mutex实现了优先级继承:当 H 因为等 mutex 被挂起时,mutex 的持有者 L 的优先级会被临时提升到 H 的级别。这样 M 就抢不过 L 了,L 能尽快跑完释放 mutex,然后优先级恢复原值。
但这个机制有几个前提条件,很多人不知道:
| 前提 | 说明 |
|---|---|
必须使用rt_mutex,不能用rt_sem | 信号量没有优先级继承机制 |
| 必须是同一个 mutex 的等待关系 | 跨多个 mutex 的循环等待它管不了 |
| 继承只在等待期间有效 | 一旦释放立刻恢复,不会累积 |
我遇到的具体问题是:代码里用的是rt_sem当互斥量用(初始值 1,take/release),功能上确实能互斥,但没有优先级继承。改成rt_mutex之后,那个 500ms 的异常延迟直接消失了。
顺便说一句,rt_mutex的 release 必须由持有者调用,不能跨线程释放。这一点和信号量不同,设计时要留意。
6.3 用调度器钩子抓时序:比 printf 靠谱一百倍
排查调度问题时,rt_kprintf是最不该用的工具。原因很简单:它本身要占串口、要等待、可能阻塞,一旦加进去就把时序改了,观测行为影响观测对象,典型的观察者效应。
RT-Thread 提供了rt_scheduler_sethook(),可以在每次线程切换时回调:
static void sched_hook(struct rt_thread *from, struct rt_thread *to) { /* 用 GPIO 翻转代替打印,配合逻辑分析仪看波形 */ if (from == thread_a && to == thread_b) gpio_set(PIN_A); else if (from == thread_b) gpio_clear(PIN_A); } rt_scheduler_sethook(sched_hook);这个钩子在调度决策完成、切换之前调用,开销极小(前提是你的钩子本身够轻)。接上逻辑分析仪,你能直接看到每个线程实际占用 CPU 的时间片、切换的频次、切换的触发点。我之前排查一个"偶尔丢包"的问题,就是靠这个发现某个中断里关中断时间过长,导致节拍丢失了 3 个 tick。
钩子里面的代码要极其克制:不能调rt_kprintf,不能调任何可能阻塞的 API,不要做浮点运算。最安全的做法就是翻转 GPIO 或者往一个环形缓冲区里写几个字节。缓冲区满了就丢弃,不要等待。
6.4 一个反直觉的现象:线程优先级改高了反而更慢
最后讲个我至今觉得有意思的案例。有个同事把某个线程的优先级从 20 调到 5,想让它响应更快,结果整体吞吐反而下降了 15%。
原因是那个线程里有一段"取数据 → 处理 → 写回"的逻辑,中间要等一个硬件标志位(轮询等待,没有用中断)。优先级调高之后,它频繁抢占那个负责填充数据的线程,导致每次去取的时候缓冲区都是空的,只能空转等待。反而是优先级低的时候,它每次被调度时数据都准备好了,一次就能处理完。
这个案例说明一个道理:优先级不是越高越好,它描述的是"紧急程度",不是"重要程度"。一个 CPU 密集型的线程配高优先级,只会把其它线程饿死,自己也不会更快——因为 CPU 总共就那么多。
我现在的做法是,把线程按性质分三类再定优先级:
- 硬实时类(响应外部事件,必须在确定时间内完成):优先级高,且必须有明确的阻塞点,不能空转。
- 计算类(数据处理、协议解析):优先级中等,靠事件驱动,不主动轮询。
- 后台类(日志、统计、清理):优先级最低,甚至可以放进空闲钩子。
按这个分类配完,再回头看看有没有两个线程挤在同一优先级上,如果有,要么合并,要么拆开——同优先级的线程越多,时间片配置的调参成本越高,而且行为更难预测。
最后分享一个我一直在用的小技巧:在rtconfig.h里把RT_DEBUG_SCHEDULER相关的检查打开,尤其是"在中断里调用可能挂起的 API"这一项。这个检查会在运行时直接断言,比事后调试省太多时间。代价是多一点代码体积和一点点判断开销,开发阶段绝对值得,等到发货版本再关掉。我踩过的最深的一个坑,就是关掉这个检查之后,一个 ISR 里误用了阻塞 API,现象是系统隔几小时死一次,查了整整两周。