两年前在一个设备端项目里,我遇到过一个让整个团队都很头疼的问题:主循环里挂了一个耗时两三秒的传感器轮询任务,结果紧急报警信号来了,要等这个轮询跑完才能响应。说白了,裸机顺序执行根本没有优先级概念,高优先级事件被低优先级任务“绑架”了。后来我做了个决定:不直接套用现成的RTOS,而是自己手写一个抢占式调度器,从零把任务的切换逻辑搭起来。这篇文章就记录整个从设计到踩坑再到验证的过程,涉及的原理和代码都可以直接移植,适合正在做嵌入式开发、想深入理解操作系统底层的朋友参考。
1. 为什么非抢不可——前后台、协作式与抢占式的真实差距
1.1 裸机循环里那个“低频却致命”的问题
先还原一下当时的场景。设备要做三件事:周期采集传感器数据、响应外部报警中断、每隔一段时间上报历史数据。最初的方案很简单,一个while(1)大循环,采集函数放在最前面,然后处理报警标志,最后做上报。采集函数里为了保证数据稳定,加了延时等待传感器稳定,算下来单次要花掉一百多毫秒。报警信号通过外部中断置一个全局标志,主循环处理到“处理报警标志”那一步才能响应。如果刚好卡在采集函数中间,报警就要等一百多毫秒才能被处理。对于这个报警场景来说,一百毫秒已经足够出问题了。
这个问题的本质,不是某个函数写得慢,而是整个执行模型里没有“打断”的机制。前台中断服务程序只负责置标志,后台主循环轮询所有标志,实时性取决于主循环的周期,而主循环的周期又被最耗时的任务拖住了。你当然可以把报警处理挪进中断服务程序里,但中断服务程序里做复杂业务逻辑是大忌,会拖垮整个系统的中断响应。
1.2 三种调度模型:协作式看起来简单,但风险全部压在任务身上
把常见的执行模型放在一起对比一下就清楚了。
| 调度模型 | 任务切换时机 | 高优先级任务最坏响应时间 | 实现复杂度 | 主要风险 |
|---|---|---|---|---|
| 前后台(超循环) | 主循环轮询到 | 受主循环最坏执行时间影响 | 最低 | 实时性无法保证 |
| 协作式(非抢占) | 任务主动让出CPU | 受当前任务主动让出时机影响 | 中 | 某个任务不主动让出就死锁 |
| 抢占式 | 定时器中断随时切换 | 可量化,受中断延迟约束 | 高 | 需要仔细处理临界区和栈 |
协作式调度器要求每个任务都“自觉”,定期调用类似task_yield()的接口让出CPU。如果某个任务写了个死循环忘记让出,整个系统就挂掉了。这在开发早期还可以接受,一旦任务数量变多、任务内部逻辑变复杂,人为保证每个任务都及时让出CPU几乎是不可能的。抢占式调度器把“何时让出CPU”的决定权从任务手里收回到内核手里,任务只管自己的一亩三分地,到了时间片或者高优先级任务就绪,内核直接切换。这就是抢占式最核心的价值。
我当时选抢占式还有一个很现实的原因:项目后期要加的功能越来越多,我不想每次加一个任务都要重新评估主循环周期会不会被拖垮。抢占式调度器把任务间的耦合关系降到最低,每个任务只需要关心自己的优先级和栈空间,系统整体响应时间可控。
2. 任务控制块与任务栈:地基打牢比堆功能重要
2.1 TCB里到底该放什么字段
手写调度器的第一步,不是写切换代码,而是先设计任务控制块(TCB)。TCB就是内核为每个任务维护的“档案袋”,所有跟任务运行状态相关的信息都放在里面。我的第一版TCB长这样:
typedef struct tcb { uint32_t *sp; // 任务栈指针,必须放在第一个字段 uint8_t state; // 任务状态:就绪、运行、阻塞 uint8_t priority; // 任务优先级,数值越大优先级越高 uint16_t time_slice; // 时间片剩余计数 void (*entry)(void *arg); // 任务入口函数 void *arg; // 入口参数 struct tcb *next; // 链表指针,用于就绪队列 } tcb_t;为什么sp必须放在第一个字段?因为后面写上下文切换时,经常会用“以TCB首地址作为基址访问栈指针”的技巧,比如tcb->sp对应内存起始位置。把sp放在第一位,有的架构上可以直接用一个基址寄存器加载TCB地址再偏移0来访问,少一条指令就是实打实的性能收益。虽然现代编译器优化已经很厉害,但这个习惯能减少出错的概率,也方便调试时直接查看内存。
2.2 任务栈大小:用“最坏情况估算”而不是拍脑袋
任务栈大小是新手最容易忽略的问题。栈给小了会溢出踩坏相邻内存,给大了浪费RAM。一个设备端系统RAM总共才几十KB,不能随便给每个任务分配4KB。
我的经验是按三层来估算:
- 任务自身的调用深度:任务入口调用函数,函数再调用函数,每层函数需要的栈空间大概几十到几百字节,取决于局部变量大小。
- 异常栈帧:使用Cortex-M内核时,发生中断/异常会自动压入8个寄存器(xPSR、PC、LR、R12、R3-R0),一共32字节。如果启用FPU且用到浮点运算,额外压入16个FPU寄存器,共72字节。
- 中断嵌套栈帧:Cortex-M的中断服务程序使用的是主栈(MSP),不是任务栈,所以中断栈帧不需要算进任务栈。但如果你的内核是在中断里切换任务,需要给任务栈预留切换代码执行时的少量栈开销。
我当时的经验公式是:任务自身调用链总字节数 + 32字节异常栈帧 + 64字节余量,再按16字节对齐,最后向上取整到至少256字节。如果任务里有比较大的局部数组,就把数组改成static或者提升为全局数组,减少栈压力。注意,static局部变量不占栈空间,这是很多人没意识到的优化点。
2.3 初始栈帧的铺设:让新任务“假装被打断过”
内核创建任务时,需要往任务栈里手动铺一份初始的“异常返回栈帧”,这样第一次切换到新任务时,从栈里恢复寄存器,看起来就像这个任务是“从异常打断中恢复”一样。以Cortex-M为例,创建任务时要手动构造出异常返回时的栈布局:
void task_stack_init(tcb_t *tcb) { uint32_t *sp = (uint32_t *)((uint8_t *)tcb->stack_base + stack_size); // 模拟中断压栈后的栈指针位置 *(--sp) = 0x01000000; // xPSR,表示使用Thumb指令集 *(--sp) = (uint32_t)tcb->entry; // PC,任务入口地址 *(--sp) = 0xFFFFFFFD; // LR,异常返回时切到线程模式并使用PSP *(--sp) = 0x00000000; // R12 *(--sp) = 0x00000000; // R3 *(--sp) = 0x00000000; // R2 *(--sp) = 0x00000000; // R1 *(--sp) = 0x00000000; // R0 tcb->sp = sp; }这里有三个关键点。第一,xPSR的bit24必须置1,否则CPU从任务入口取指时会因为不是Thumb模式而触发硬错误。第二,LR要填0xFFFFFFFD,这是Cortex-M架构规定的特殊返回值,告诉处理器恢复任务时使用进程栈指针(PSP),这样任务就跑在用户栈上,而不是和内核共用主栈。第三,如果在main函数里直接调用调度器,而不是通过异常进入,上下文切换用SVC软中断来触发,这里铺好的栈帧就会被切换逻辑使用起来。刚开始实现时如果不理解,可以把任务入口函数写成void task_entry(void *arg),arg放在R0位置,正好作为第一个参数传进去。
3. 抢占的扳机:SysTick时钟源与PendSV延迟切换
3.1 为什么抢占必须有一个稳定的“心跳”
抢占式调度器需要一个周期性的时钟中断来当“心跳”,没有心跳就没有“到时间了,强制切换”的机制。Cortex-M内核自带SysTick定时器,几乎每个基于该内核的芯片都支持,配置起来只要设置重载值和时钟源就行。
void systick_init(uint32_t tick_hz) { uint32_t reload = SystemCoreClock / tick_hz - 1; SysTick->LOAD = reload; SysTick->VAL = 0; SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk; }SysTick重载值的计算公式很简单:重载值 = 系统时钟频率 / 期望节拍频率 - 1。比如系统时钟是72MHz,想要1000Hz的节拍,重载值就是72000000 / 1000 - 1 = 71999。节拍频率的选择直接影响系统实时性:100Hz的节拍意味着任务最多100毫秒才能被打断一次,实时性很差;1000Hz节拍下任务切换粒度是1毫秒,但代价是上下文切换更频繁,CPU用在切换上的开销变大。
我测过一个数据:纯栈切换(保存寄存器+恢复寄存器+调度函数)大约需要几十微秒到上百微秒,视优化等级和内核频率而定。如果系统节拍设为1000Hz,每秒调度1000次,调度CPU开销大约在1%到5%之间,对大多数应用来说可以接受。但如果节拍频率提到10kHz,调度开销就到了10%以上,这时候就要认真考虑是否合算了。
SysTick中断服务程序里要做的事很简单:递减当前任务的时间片计数,如果时间片耗尽或者有更高优先级任务就绪,就触发一次调度。
3.2 为什么不在SysTick中断里直接切换,而是用PendSV
刚开始我图省事,直接在SysTick中断里写了切换函数,跑起来发现一个问题:任何中断服务程序执行期间,如果又被SysTick抢占,SysTick里做了上下文切换,等外层中断恢复时栈状态就乱了。更麻烦的是,如果在某个外部中断服务程序执行到一半,SysTick抢进来做了切换,把当前任务切走了,外层中断的恢复现场就找不到正确的栈了。
Cortex-M设计了PendSV,也就是“可挂起的系统调用”。它的优先级可以被设置成最低,SysTick中断里只把PendSV的挂起位置1,真正切换到PendSV中断里执行。这样做的核心逻辑是:PendSV排在所有其他中断后面,直到所有中断服务程序都执行完,它才被响应。这就保证了“切换发生在一个所有中断都静止的干净时刻”。
配置方法:把PendSV优先级设置为最低数值(在Cortex-M里数值越大优先级越低),然后把SysTick中断优先级设置为高于PendSV。SysTick中断里只需要一个操作:
void SysTick_Handler(void) { // 递减时间片等操作... SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 置位PendSV }PendSV中断服务程序里才做真正的上下文切换。这个间接层的设计非常优雅,它把“触发切换”和“执行切换”解耦了,即便有嵌套中断也不会破坏任务栈的完整性。这也是现成RTOS普遍采用的方式,我手写时直接沿用了这套机制。
3.3 上下文切换:保存R4-R11就够了吗
Cortex-M内核的异常入口会自动压栈一部分寄存器,硬件已经处理了R0-R3、R12、LR、PC、xPSR,剩下的R4-R11是“被调用者保存”寄存器,需要软件手动保存。PendSV切换的核心代码,本质上就是把当前任务的R4-R11存到旧任务栈里,再从新任务栈里恢复R5-R11(R4暂存用),最后触发异常返回。
我整理了一个精简版,原理和完整版一致:
__asm void PendSV_Handler(void) { MRS r0, PSP // 获取当前任务栈指针 STMDB r0!, {r4-r11} // 保存R4-R11到任务栈 LDR r1, =current_tcb // 更新当前任务TCB中的sp STR r0, [r1] ; 调用调度函数,选下一个任务并更新current_tcb LDR r1, =current_tcb LDR r0, [r1] // 拿到新任务的TCB LDR r0, [r0] // 新任务的sp LDMIA r0!, {r4-r11} // 恢复R4-R11 MSR PSP, r0 // 更新PSP ORR lr, lr, #0x04 // 确保返回线程模式并使用PSP BX lr // 异常返回 }值得注意的两点。第一,PendSV里用PSP还是MSP要看当前处于什么模式,上面这段假定切换时CPU处于线程模式使用PSP,此时PSP就是当前任务栈指针,所以用MRS r0, PSP。第二,current_tcb作为全局变量,在调度函数里更新,但这个更新动作必须保证原子性。最简单的做法是把“找下一个任务+更新current_tcb”放在PendSV里的关中断区间里做,避免其他中断打断导致指向不一致。实际工程里我还在PendSV入口加了一个cpsid i(关中断)的保护,确保整个切换过程不被更高优先级中断插足。
4. 决定“谁先跑”的就绪表与时间片策略
4.1 用位图实现就绪表:为什么不是链表
任务多了以后,调度器需要快速找到“当前最高优先级的就绪任务”。最直观的想法是维护一个就绪队列链表,按优先级排序,插入时从头遍历找到合适位置。这个做法的问题在于,插入和删除的耗时和队列长度成正比,任务多时性能不稳定。而且查找最高优先级任务时,最坏情况要遍历到队尾。
我当时用的是位图法。每个优先级对应一个bit,置1表示该优先级下有任务就绪。使用Cortex-M内核的CLZ指令(Count Leading Zeros),一条指令就能从最高位开始找到第一个置1的bit,时间复杂度是O(1)。以支持32个优先级为例:
uint32_t ready_bitmap; // bit n置1表示优先级n下有任务就绪 uint8_t get_highest_ready_priority(void) { return 31 - __CLZ(ready_bitmap); }调度函数就是从最高就绪优先级下取一个任务,设置为运行态。如果优先级相同,则按时间片轮转。位图法在32优先级时表现最好,如果超过32优先级,可以用多级位图,每一级再套一层,但绝大多数场景32个优先级完全够用。
4.2 时间片:怎么算,怎么分配,耗尽之后怎么办
时间片是抢占式调度器实现“同一优先级任务轮流执行”的手段。时间片的长度 = 时间片计数值 × 单次节拍时间。比如时间片计数值设为5,节拍为1毫秒,那么每个任务一次可以运行5毫秒。时间片的值不能设置太小,否则频繁切换的任务会消耗大量CPU在调度上;也不能设置太大,否则同优先级任务的响应延迟过大。嵌入式场景常用5到20毫秒,我习惯从10毫秒起步。
SysTick每个节拍里做这样几件事:
void tick_handler(void) { tcb_t *current = current_tcb; if (current->time_slice > 0) { current->time_slice--; } if (current->time_slice == 0) { // 当前任务时间片用完,重新分配并触发调 current->time_slice = default_time_slice; schedule(); // 置位PendSV } }注意一个细节:时间片耗尽时要给当前任务重新分配默认时间片,否则下次切换到它时time_slice还是0,一运行又被切换走,任务永远得不到完整的执行时间。刚开始写调度器时我在这里踩过坑,任务看起来在跑,但每次只执行几个指令就被踢出去,整体进度极慢。
如果系统有更高优先级任务就绪,SysTick节拍里通过调度函数把当前任务先保存到就绪表,等最高优先级任务执行完再回来接着运行。这是“抢占”的关键语义:高优先级任务一旦就绪,不需要等低优先级任务用完时间片,立刻打断。
4.3 空闲任务:所有任务都阻塞时,CPU总得有点事干
内核还必须要有一个最低优先级的空闲任务。所有正经任务都执行完或者都阻塞时,调度器会选中空闲任务。有人问:没事干的时候让我CPU空转不就行了?问题在于,如果你在空闲任务里写一个空的while(1),有些编译器可能会把它优化掉导致行为异常,而且你没有机会利用这段时间做低优先级的事情,比如统计CPU占用率、进入低功耗模式。
我的空闲任务和大部分人写的空闲任务不太一样,里面放了一个计数器,每次空闲循环自增,然后在调试接口里读取计数器的值评估CPU空闲率:
volatile uint32_t idle_count = 0; void idle_task(void *arg) { while (1) { idle_count++; // 可选的:WFI进入低功耗等待事件 } }CPU利用率可以这样估算:固定运行一个已知的“忙等1秒”任务,在这个过程中统计idle_count的增长幅度,反过来推导全速空转时每秒的计数增量,再算出实际利用率。这个粗糙的办法在项目早期非常有用,能直观看到调度器和业务任务的负载情况。另外,把idle_count声明成volatile很重要,否则优化器可能把它塞进寄存器里一直自增而根本不写内存,你读到的永远是旧值。
5. 实测踩坑实录:调度器跑挂后的三次复盘
5.1 坑一:任务栈溢出引发偶发性死机
调度器写完,跑一个简单的双任务Demo一切正常。加了第三个任务以后,系统开始随机死机,有时候是半小时,有时候是两小时,毫无规律。这种偶发问题最难查。我花了半天时间逐段注释代码,最后锁定一个嫌疑:某个任务里定义了一个uint8_t buf[512]的局部数组,而这个任务运行时还要经过好几层函数调用,栈深度需求远超我当初分配的任务栈大小。当任务执行到那个数组初始化时,栈指针直接冲到TCB的内存区域,把next指针和state字段全踩坏了。
排查栈溢出有一个高效办法:创建任务时,把整个任务栈都填充成固定魔术字(比如0xA5A5A5A5),然后周期性扫描栈空间的尾部,如果发现魔术字被改写,说明栈已经溢出或即将溢出。正常情况下栈从高地址向低地址生长,低地址端的魔术字被破坏意味着栈深度逼近极限。这种检查在生产环境可以关闭,但开发阶段一定要开着。
修复方案分两步:一是把局部大数组改成static,彻底移出栈;二是把估算后的任务栈再额外加128字节作为安全余量。改完以后跑了48小时没再复现。这件事给我最大的教训是:栈大小不是越大约好,而是必须经过估算并留有余量,同时必须配检测手段,否则出问题的时候你根本无从下手。
5.2 坑二:临界区保护不足,共享标志读到“半新半旧”的值
接着测,我又发现一个诡异现象:两个任务共享一个全局结构体,一个写、一个读,读到的数据一会儿对一会儿不对,而且不对的时候数据里有一部分是新的、一部分是旧的。原因不复杂:结构体有多个字段,写任务给字段逐个赋值的过程中被打断,读任务读到一个中间状态。
如果只是对单个全局变量赋值,在Cortex-M上因为对齐和原子性保证,读写本身是原子的。但结构体是多字节操作,就没有这个保证。解决办法是引入临界区保护,关中断执行不可分割代码段,执行完再开中断:
uint32_t enter_critical(void) { uint32_t primask; __disable_irq(); return primask; } void exit_critical(uint32_t primask) { __enable_irq(); }注意,临界区里不能做耗时的操作,关中断时间过长会直接拉高系统中断响应延迟。如果你的调度器有任务阻塞机制,不能让任务在临界区里调用阻塞接口,否则整个系统会在那个地方卡死。我第一次写的时候在一个临界区里放了打印调试信息的串口发送循环,结果发送几百字节的时间比别人整个周期还长,系统中断响应直接被打爆。后来我把打印挪到临界区外,先用本地变量缓存数据,退出临界区后再发送。
说到临界区还有一种更细粒度的做法:调度锁。只关SysTick中断,不关别的中断,适合保护“只跟调度器内部数据结构相关”的关键区域。因为SysTick不触发调度,任务就不会被切换。这个方案对普通中断响应的影响最小,写调度器内部模块时我更推荐。
5.3 坑三:PendSV优先级没设对,系统卡顿且中断响应异常
第三个坑出在中断优先级的配置上。我刚开始没有正确设置NVIC里PendSV的优先级,导致PendSV的值比某些外部中断还高。这样一旦SysTick里挂起PendSV,PendSV可能在一段中断服务程序还没执行完的时候就抢进去做任务切换。任务切换时如果正好把当前“被中断的现场”给拆散了,恢复的时候就乱了套。表现就是系统反应变慢,甚至偶尔死机。
Cortex-M有一个硬性要求:PendSV和SysTick必须设置为最低优先级,并且PendSV的优先级要低于所有可屏蔽中断。注意是所有,不只是低于SysTick。如果你的系统里有一个外部中断优先级为3,PendSV优先级必须比3还低(数值更大)。否则外部中断里刚保存了现场,PendSV切换任务,外部中断恢复时现场已经被改写了。
设置方法很简单,一个寄存器一行代码:
NVIC_SetPriority(PendSV_IRQn, 0xFF); // 最低优先级 NVIC_SetPriority(SysTick_IRQn, 0xF0); // 足够高但不高于其他重要中断这里的0xFF和0xF0具体值要看芯片实现的支持位数,一般是高4位有效。设完之后,我又加了一个防守策略:在PendSV入口处再关一次中断,用cpsid i确保切换过程绝对不受干扰,直到新任务的现场恢复完毕再开中断。这套组合下来,系统运行的稳定性明显上了一个台阶。
6. 验证“真的在抢占”:四层测试与最后收尾经验
6.1 第一层:GPIO翻转,用逻辑分析仪看任务时序
调度器最基础的验证,是确认“任务确实按照预定优先级和时间片在跑”。我的做法非常简单:每个任务入口处翻转一个GPIO,逻辑分析仪抓波形。比如高优先级任务每1毫秒翻转一次,低优先级任务每5毫秒翻转一次,看波形就能确认优先级是否生效,时间片是否准确。如果高优先级任务的波形周期稳定,说明调度节拍稳定;如果波形抖动大,说明有中断延迟或任务内部逻辑阻塞了调度。
这一层测试的成本极低,但信息量很大。我还额外用了一个GPIO专门给调度器“插桩”——在PendSV切换入口拉高、出口拉低,这样逻辑分析仪上就能直接看到每次上下文切换的耗时。后来优化切换代码时,就是靠这个波形对比改前改后的效果。
6.2 第二层:调度计数与CPU空闲率,验证负载
单纯看波形只能验证“有没有在跑”,不能验证“负载是否健康”。我在调度器里加了计数器,每个任务被选中时计数加1,然后在调试串口里周期性打印。通过每个任务的运行次数,能评估出任务占比是否合理。比如低优先级任务运行次数远低于预期,就要怀疑它是否被高优先级任务饿死了。
空闲计数器的用法前面提过,这里补充一个计算公式的落地过程:先测出空闲任务在100%空转时每100毫秒的增长量作为基线,再带业务负载运行时读取实时值,用实时值/基线值估算空闲率,1 - 空闲率就是CPU占用率。这个指标在加新功能时特别有用,能提前暴露负载瓶颈。
6.3 第三层:压测互斥场景,验证临界区有效
验证临界区是否有效,我的方法是用两个任务竞争递增同一个计数器。一个任务每100毫秒对全局计数器做10000次自增,另一个任务每100毫秒读一次值。不加工时发现计数一直丢失,加上临界区后计数严格等于两个任务各自递增的总和。这个实验简单粗暴,但能直接证明临界区的保护是否完备。
还可以做压力测试:人为在任务里调用一个比较长的临界区操作,然后通过外部中断触发测量中断响应延迟。具体做法是GPIO触发一个外部中断,中断里翻转另一个GPIO,用逻辑分析仪测量从触发到翻转的时间差。在中低负荷下这个时间差应该是稳定的,如果波动范围突然变大,说明临界区或者调度逻辑有问题。
6.4 第四层:长时间运行与看门狗兜底,确认稳定性
调度的稳定性最终要落实到长时间运行上。我让系统跑了一个“7×24小时压力测试”,任务里包含了大量随机延迟、随机优先级变更、随机打印,同时开着看门狗。看门狗喂狗策略从“在主循环里喂”改成“在空闲任务里喂”,如果系统发生死锁,空闲任务得不到执行,看门狗就会复位。测试结果理想:48小时内无复位,96小时无复位,最终跑了整整一周才主动停止。
说回我现在的习惯:每次写调度器,我都会把编译器的优化等级先设为O0,确保第一版逻辑正确后再慢慢升优化等级。因为优化等级越高,编译器行为越复杂,出现问题时很难判断是逻辑问题还是优化问题。另外,任务入口函数不要返回,如果任务跑完返回了,要么让任务自己进入死循环,要么在内核里加上“任务退出即删除”的逻辑,否则等任务函数返回,PC会跑到栈里的垃圾数据上,直接硬错误。
手写一个抢占式调度器并没有想象中那么神秘,核心就是任务控制块、任务栈、SysTick心跳、PendSV延迟切换、就绪表、时间片这几个模块。真正有价值的不是把代码跑通,而是那些跑通过程中踩出来的细节。如果你也打算自己写一个,我建议先从双任务、固定优先级、不开时间片轮转开始,一步一步加功能,每加一个功能就做一次波形观察。内核代码看着不长,但每行都需要对底层机制有清楚的理解,这种理解,比直接调用现成RTOS API要值钱得多。