ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写Cortex-M抢占式调度器:任务切换与实现详解

从零手写Cortex-M抢占式调度器:任务切换与实现详解

1. 先搞清楚:抢占式调度器到底在“抢”什么

很多人一听到抢占式调度器,第一反应是“系统里同时跑多个任务,一会儿切这个一会儿切那个”,这个理解不算错,但漏掉了最关键的字眼——“抢”。

这个“抢”指的是:内核有强制打断正在执行任务的能力,而且是硬件级别的强制打断,不是靠任务自己主动让位。写协作式调度器时,一个任务如果写了个死循环却不调用yield,整个系统就永远卡在那里;但抢占式调度器不一样,定时器中断每隔一段时间就会响一次,内核在中断里看一眼“当前这个任务凭什么继续占用CPU?还有没有更该跑的任务?”,然后强制换人。任务自己完全没有抵抗能力,它甚至感知不到自己被切走又被切回来,所有现场被完完整整保存好,恢复后继续往下跑,像中间什么事情都没发生。

我这次要分享的是从零手写一个可运行的抢占式调度器。平台选的是Cortex-M3/M4加GCC工具链,裸机环境。之所以选Cortex-M,是因为它的异常模型非常完善,SysTick定时器、PendSV异常、双栈机制都已经固化在硬件里,手势完整,适合一次性把调度器的骨架和细节看个透彻。选的这套实现思路也可以平移到RISC-V或者通用操作系统课程里做的线程调度实验上,只不过寄存器保存的范围和异常入口要按各自架构调整。

我会按一条从简到繁的路径来写:先讲清楚任务长什么样、栈怎么初始化,再深入到上下文切换的汇编,然后看SysTick和PendSV如何配合完成“强制换人”,最后给出调度策略和一组实测排错经验。目标是让你读完以后,面对一个全新的平台,也能自己动手把这个调度器复现出来。

1.1 协作式与抢占式的差别,一句话说透

协作式调度器里,切换调度点完全由任务自己控制。任务主动调用yield、sleep或者等待某个事件,调度器才可能选择下一个任务。这种模型的优点是逻辑简单、竞争少,但代价是:任何一个“不老实”的任务,比如一个纯while(1)循环里忘记让出CPU,就能把整个系统冻住。

抢占式调度器把控制权从任务手里收走了。定时器中断是常态化的“心跳”,无论任务运行到哪一行代码,只要心跳到来,CPU就会暂时停下当前任务,进入内核代码。内核在中断上下文里做一次“要不要换人”的判断,如果发现另一个任务优先级更高,或者当前任务的时间片已经耗尽,就触发上下文切换。

这两者最本质的差别不是“快”,而是“权力来源”。协作式是任务之间互相商量着让位,抢占式是内核凭中断强制接管。抢占式看起来像多任务并行,但这里没有一个任务是真的同时在跑,它只是“看起来”一直在跑,因为所有任务都在毫秒级的时间尺度上被切开、轮换,一个任务被打断时根本来不及察觉。

1.2 一次不让被察觉的“换人”是怎么发生的

假设系统里有TaskA和TaskB两个任务,TaskA优先级低,正在执行一个耗时较长的for循环;TaskB优先级高,因为某个事件被唤醒进入就绪状态。此时SysTick中断到来,CPU自动压栈保存TaskA的现场,跳进SysTick异常处理函数。处理函数先更新系统计数,然后发现“TaskB已经就绪,而且优先级比TaskA高”,于是把PendSV异常挂起。

SysTick退出后,PendSV才真正开始执行。因为PendSV被配置成最低优先级,它必须等其他所有中断处理完才有机会运行,这就是它“延迟换人”的妙处。PendSV执行上下文切换,把TaskA的R4-R11等寄存器压进TaskA自己的栈,从TaskB的TCB里取出栈指针,弹栈恢复TaskB的现场,然后从异常返回到线程模式,CPU跳进TaskB继续执行。

整个过程对TaskB来说,只是“我上一次被切走时停在这里,现在从这里继续”。而对TaskA来说,它甚至不知道自己在后台被晾了那么久。这种隐蔽性,正是抢占式调度要提供的最基本保证。

1.3 手写之前先把边界划清楚

我在这份实现里给自己定了三条边界。第一,只做最小内核:任务创建、抢占式调度、时间片轮转、阻塞和唤醒,不做信号量、不做消息队列,更不做内存堆管理,那些东西是后续锦上添花的内容。第二,不引入任何RTOS封装,直接操作寄存器、异常向量和汇编,每一条关键路径都必须亲眼看到。第三,代码量足够少,核心切换部分控制在几十行汇编以内,方便单步追踪。

平台相关的依赖只集中在三小块:SysTick的初始化与中断入口、PendSV异常入口、进入临界区时屏蔽中断的寄存器操作。其余代码,包括TCB管理、就绪队列、时间片计算、任务栈初始化,都是纯C逻辑。这样分层的意义在于:想要移植到其他架构,只需要重新实现这三小块,调度核心完全不用动。如果你还没接触过RTOS,用一个现成系统当然省力,但亲手写出这个“强制换人”的机制之后,你对任务调度、中断延迟、临界区的理解,会比看十篇原理文章都来得深刻。

2. 任务模型设计:TCB、任务栈与第一次“假现场”

调度器管理的最小单位是任务,而任务在代码里不是函数,不是进程,而是一个结构体加一块独立的栈。先把这两个东西设计明白,后面所有代码才有了落脚点。

2.1 TCB的字段,每一个都不是白给的

我用的TCB结构体如下:

typedef struct tcb { uint32_t *sp; /* 任务当前栈指针,切换现场时保存/恢复 */ uint32_t *stack_base; /* 栈底(高地址) */ uint32_t *stack_limit; /* 栈顶(低地址),用于溢出检测 */ uint8_t priority; /* 优先级,数字越小优先级越高 */ uint8_t state; /* 任务状态:READY / RUNNING / BLOCKED */ uint32_t slice_remain; /* 剩余时间片,单位是tick */ uint32_t slice_reset; /* 时间片初始值,切回来时重新装载 */ void (*entry)(void *param); /* 任务入口函数 */ void *param; /* 入口函数参数 */ uint32_t magic; /* 魔数,用来校验TCB完整性 */ } tcb_t;

sp是绝对核心,调度器本质上就是“保存当前sp,换入下一个sp”。为什么还需要stack_base和stack_limit?因为任务栈溢出是手写调度器最常见的崩溃元凶,没有这两个边界值,你只能在HardFault里猜测现场。priority决定抢占时的优先顺序,state是任务状态机的基础。slice_remain和slice_reset是时间片轮转的关键:任务被切走时剩余时间片要保留,被重新选中后也不能整段清零,只有在完整用掉一个时间片之后才重置,否则很容易出现“高优先级任务抢占频繁,低优先级任务的剩余时间片被反复累积”的问题。

magic字段放一个固定值,比如0xA5A5A5A5,在每次调度前检查。一旦被改写,说明某处越界把TCB冲了,远比崩在角落里好定位。任务数量少的时候可以直接用一个静态数组保存所有TCB,例如static tcb_t task_pool[MAX_TASKS],简单可靠,不涉及动态内存管理。

2.2 任务栈初始化:伪造一个“被打断过的现场”

新任务从来没有运行过,没有所谓“保存好的现场”,但调度器不管这些,它在第一次切换时只会机械地“恢复现场”。所以我们要在任务栈里预先伪造一份现场,让调度器以为这个任务之前已经运行过,正停在一个特定位置。

在Cortex-M上,一次上下文切换恢复时需要从栈里依次弹出R4-R11,再弹出硬件异常压栈的R0-R3、R12、LR、PC、xPSR。因此初始化任务栈时,要在栈顶按这个顺序反向压入初始值:

static uint32_t *task_stack_init(void (*entry)(void *param), void *param, uint32_t *stack_top, uint32_t *stack_limit) { uint32_t *sp = stack_top; uint32_t *p = stack_limit; uint32_t pattern = 0xA5A5A5A5; while (p < sp) { *p++ = pattern; /* 对整个栈区填充魔数,便于溢出检测 */ } sp -= 8; /* 先给R0-R3、R12、LR、PC、xPSR留出位置 */ sp[0] = 0x01000000; /* xPSR:必须置Thumb位 */ sp[1] = (uint32_t)entry; /* PC:任务入口地址 */ sp[2] = (uint32_t)task_exit_cleanup; /* LR:入口函数异常返回时的清理函数 */ sp[3] = 0; /* R12 */ sp[4] = 0; /* R3 */ sp[5] = 0; /* R2 */ sp[6] = 0; /* R1 */ sp[7] = (uint32_t)param; /* R0:入口函数的第一个参数 */ sp -= 8; /* 再给R11-R4留出位置 */ for (int i = 0; i < 8; i++) { sp[i] = 0; } return sp; }

为什么xPSR必须置为0x01000000?因为Cortex-M只支持Thumb指令,bit24如果没置位,第一跳进任务就会触发UsageFault。为什么要把LR初始化成task_exit_cleanup而不是直接设为0?任务入口函数理论上不该return,但万一它真的通过某种错误路径返回,CPU会跳回LR指向的地址。如果LR是0,直接进HardFault,排查起来还得翻反汇编;如果LR指向一个清理函数,就可以在清理函数里把自己的状态置为完成态并让出CPU,行为可预期得多。R4-R11这些寄存器是汇编切换代码手动压栈的区域,初始化为0即可。

需要注意,这里的“栈顶”通常指栈区最高地址。Cortex-M的栈是向下增长的,任务栈初始化时从高地址往下写,最终sp落在较低地址,切换汇编后续使用STMDB/LDMIA等指令继续往下压栈或从低往高弹栈,顺序才能完全对上。

2.3 就绪队列与优先级:先做朴素版,再谈优化

就绪队列的设计决定了调度器选下一个任务的成本。最朴素的做法是:任务池里维护一个按优先级排列的ready数组,每次调度时从头到尾扫描一遍,找第一个state为READY且优先级数值最小的任务。对任务数量在十个以内的调度器来说,O(n)遍历完全够用,而且代码可读性极强,非常推荐第一版先这么写。

进一步优化的时候,可以给每个优先级准备一个就绪链表,再配一个32位位图记录哪些优先级下有待调度的任务,配合Cortex-M的CLZ指令,能在常数时间内找到最高优先级的非空链表。这个方案在任务数量多、优先级层次多的场景会明显提升确定性,但代码复杂度也会上一个台阶。我个人的建议是先跑通朴素版,用示波器观测任务切换周期之后,再决定要不要优化。调度器的正确性永远比调度速度更重要,先看效果再谈性能。

3. 上下文切换:整个调度器的心脏

前面所有数据结构、所有策略,最终都要落实到“保存一个任务的现场,恢复另一个任务的现场”这几十条指令上。这一步做对了,调度器成了大半;做错了,所有任务调度逻辑再好看也无济于事。

3.1 上下文切换到底要保存什么

现代CPU的寄存器数量都不少。Cortex-M3/M4里,任务运行时的寄存器包括R0-R12、SP、LR、PC和xPSR。如果只靠软件一个寄存器一个寄存器地保存,切换函数会非常冗长。但Cortex-M有一个非常体贴的机制:进入异常处理时,硬件会自动把xPSR、PC、LR、R12、R0-R3压栈到当前使用的栈指针上,退出异常时硬件再自动弹栈恢复,这正好覆盖了函数调用约定的易失寄存器部分。

于是软件需要额外保存的寄存器就只剩下R4-R11这8个,外加任务自己的栈指针SP。整个过程可以在PendSV异常入口用不到十行汇编完成。这也是Cortex-M上写调度器比x86省心得多的地方,x86需要软件保存通用寄存器、段寄存器、标志寄存器等一大堆内容,保存和恢复的顺序稍微写错一个,现场就全毁了。

有一个类比很贴切:硬件自动压栈相当于先把演员手里最常用的几件道具放进储物柜,软件要做的是把剩下的道具也归档,并且记住“钥匙”放在哪一把。任务切换时,先把手上的钥匙挂到当前任务的储物格,再从目标任务的储物格取出另一把钥匙,把道具一件件放回演员手里。

3.2 PendSV:为什么要这个“可挂起的异常”来干活

既然是抢占式调度,换人动作必然发生在中断上下文。最直接的想法是:在SysTick中断里直接做上下文切换,不就行了吗?但这里有个隐患。SysTick的优先级只要不是最低,它就能打断正在执行的其他中断,比如UART接收中断。如果在定时器中断里直接切换了任务,很可能出现“上一个中断还没处理完,任务就已经换了”的混乱局面,现场保护很难做完整。

PendSV被设计出来就是专门干这事的。它是一个软件可以主动挂起的异常,优先级可以配置到全系统最低。SysTick在心跳处理里发现需要调度时,不直接切换,而是把PendSV挂起;PendSV因为优先级最低,必须等到所有正在服务的高优先级中断退出后才能执行。这样一来,上下文切换永远排在所有紧急中断之后,既完成了抢占调度,又不打扰实时性要求高的中断处理。

在SysTick里做切换和在PendSV里做切换,是两种经典设计路线,我把它们的取舍列一下:

方案实现复杂度中断延迟影响适合场景
直接在SysTick中切换简单,少一段异常启动逻辑SysTick会打断其他中断,切换可能夹杂在中断服务中间原型验证、单优先级轮转
SysTick挂起PendSV,PendSV中切换多一次异常进入/退出开销所有高优先级中断先跑完,切换最干净正式工程、支持优先级抢占

3.3 切换汇编:每一行代码都踩在栈上

首先要强调一个程序员容易踩的坑:PendSV异常处理函数不能用普通的C函数写,必须用naked属性或独立汇编文件。因为C编译器会在函数入口自动生成压栈和弹栈指令,把这些指令混进现场保存流程里,现场会直接被二次污染。

我采用的做法是:异常入口的现场保存与恢复用汇编完成,调度策略选择“下一个该跑哪个任务”则用C函数完成。C函数返回值为选中任务的TCB指针,这个返回值放在R0寄存器里,正好可以被汇编继续使用。

下面是一段去除冗余注释后的核心切换逻辑:

__attribute__((naked)) void PendSV_Handler(void) { __asm volatile( " MRS R0, PSP\n" /* R0 = 当前任务栈指针 */ " STMDB R0!, {R4-R11}\n" /* 先保存R4-R11到任务栈 */ " LDR R1, =current_tcb\n" " LDR R1, [R1]\n" /* R1 = current_tcb内容 */ " STR R0, [R1]\n" /* current_tcb->sp = R0 */ " PUSH {LR}\n" /* 保存异常返回的EXC_RETURN */ " BL scheduler_pick\n" /* R0 = 下一个要运行的任务TCB */ " POP {LR}\n" " LDR R1, =current_tcb\n" " LDR R2, [R1]\n" /* R2 = current_tcb */ " CMP R0, R2\n" " BEQ restore_only\n" /* 选中的还是当前任务,跳过保存逻辑 */ " STR R0, [R1]\n" /* current_tcb = 新任务 */ " LDR R0, [R0, #0]\n" /* R0 = 新任务->sp */ " LDMIA R0!, {R4-R11}\n" /* 恢复R4-R11 */ " MSR PSP, R0\n" /* 更新PSP,硬件弹栈区域已对准 */ " ORR LR, LR, #0x04\n" /* EXC_RETURN:返回后切回PSP */ " BX LR\n" " restore_only:\n" " LDR R0, [R0, #0]\n" " LDMIA R0!, {R4-R11}\n" " MSR PSP, R0\n" " ORR LR, LR, #0x04\n" " BX LR\n" ); }

很多初次接触这段代码的人会问:为什么恢复现场时只恢复R4-R11,R0-R3和PC、xPSR去哪了?答案是它们已经躺在任务栈顶部了,MSR PSP, R0把硬件栈指针指向那8个字之后,BX LR触发异常返回,硬件会自动完成剩余弹栈。所以栈指针的对齐位置至关重要:R4-R11从栈里弹出8个字之后,剩余的栈内容必须恰好是xPSR、PC、LR、R12、R0-R3的顺序。这就是第2章里初始化任务栈时严格按这个顺序反向压值的原因。

3.4 双栈机制:为什么要让任务和中断分开用栈

Cortex-M硬件上天然支持两个栈指针:MSP和PSP。我在这份实现里让所有任务都运行在PSP上,中断服务程序统一跑在MSP上。这样做的第一个好处是中断嵌套不会大量消耗任务栈,每个任务栈的大小只需要估算业务代码和切换现场所需深度,内核栈单独规划。第二个好处是安全性:某个任务栈溢出了,最多坏掉自己,不至于把内核的中断处理环境一起冲毁。

如果你的目标平台只有单一栈指针,比如早期RISC-V或很多教学用模拟CPU,就必须自己规划“中断栈”和“任务栈”的边界,进入中断时先切换到一块固定的内核栈,再允许嵌套。总之,中断入口不能图省事直接复用当前任务栈,否则一旦出现两层以上的中断嵌套,任务栈栈底很快就会被啃穿。这也是我建议手写调度器时,无论如何都要给中断单独留一块栈的原因,成本很低,收益是长期稳定的。

4. SysTick节拍、中断优先级与临界区:谁在背后踢一脚、谁负责踩刹车

抢占式调度的“抢占”不会凭空发生,必须有一个周期性的硬件中断在背后踢脚。这个角色通常由系统的节拍定时器扮演,在Cortex-M上就是SysTick。

4.1 SysTick的节拍怎么算,选多大合适

SysTick是一个24位递减计数器,时钟源通常是系统主频。配置方法是先设定重装载值,计数器从该值递减到0时产生中断,然后自动从重装载值重新开始。通过CMSIS函数SysTick_Config可以直接完成配置,它的参数是两次中断之间的时钟周期数。

uint32_t tick_hz = 1000; /* 1ms一个节拍 */ SysTick_Config(SystemCoreClock / tick_hz);

比如主频72MHz,SystemCoreClock / 1000算出来是72000,也就是SysTick每72000个周期中断一次,正好1ms。这里有一个细节:重装载值的计算公式实际是reload = SystemCoreClock / tick_hz - 1,但SysTick_Config内部自己减了1,调用时传SystemCoreClock / tick_hz即可。

节拍大小直接影响调度开销。如果tick设成1ms,72MHz下就是72000个时钟周期,而一次上下文切换只需要几十到一百个周期,占用比例几乎可以忽略。如果又想把抢占粒度做细,比如改成0.1ms一个tick,那每个tick之间只有7200个周期,来回切换的开销占比立刻飙升。我的建议是:第一版先用1ms或10ms,跑通之后再按系统实时性需求调整,不要在原型阶段盲目追求细粒度。tick越小,调度次数越多,系统在“切换”上花的时间越多,真正干活的时间反而少了。

4.2 中断优先级怎么配:SysTick、PendSV和外设中断的三角关系

Cortex-M的中断优先级是一个强排队的系统,数字越小优先级越高。手写调度器时要同时考虑三类异常:

  • 普通外设中断(UART、定时器、GPIO外部中断),处理紧急硬件事件;
  • SysTick异常,负责周期性产生系统心跳;
  • PendSV异常,负责真正执行任务切换。

我的配置原则是:外设中断优先级最高,SysTick居中,PendSV最低。外设中断优先级最高,是为了保证中断响应延迟不会因为调度器而变差;SysTick居中,是为了确保心跳本身不会被普通外设中断频繁打乱,否则时间片计数会出现不可控的漂移;PendSV最低,是为了让上下文切换永远在所有中断处理完之后进行。这里PendSV最低优先级非常关键,如果PendSV的优先级高于某个外设中断,那么这个外设中断还没服务完,任务切换就抢先发生了,被中断的外设代码会卡在上文切换的乱局里。

具体的优先级数值取决于硬件优先级位宽。假设系统有4位优先级,典型配置可以是:

NVIC_SetPriority(SysTick_IRQn, 7); NVIC_SetPriority(PendSV_IRQn, 15);

实际工程里,SysTick的优先级还可以再细调。如果你的中段处理程序本身很短,也可以把SysTick设得比部分外设中断更低,让外设事件优先处理,代价是心跳本身会被外设中断抖动。这是一个“响应延迟”和“节拍稳定”之间的权衡,没有绝对答案,但PendSV永远要留到最低,这一点不会有争议。

4.3 临界区:防止换人发生在换人过程中的关键

抢占式调度引入了一个新问题:内核本身也是在一段一段代码间隙被各种中断打断的。如果刚好在修改任务队列、修改TCB链表的中间被打断,又发生了一次调度,两个执行流同时操作同一批数据,队列结构会立刻损坏。

所以调度器内部必须有一组“临界区”保护。进入临界区之前关中断,退出以后开中断,保证这段代码不可能被任何中断插进来。我的实现是最简单也最稳妥的PRIMASK方案:

static inline uint32_t enter_critical(void) { uint32_t primask = __get_PRIMASK(); __disable_irq(); return primask; } static inline void exit_critical(uint32_t primask) { __set_PRIMASK(primask); }

很多人会随手写成“进入时关中断,退出时开中断”,但这里有个隐蔽的坑:如果临界区函数被嵌套调用,或者在一个原本中断就是关闭状态的环境里被调用,直接开中断会让中断提前打开,临界区实际失效。正确做法是先把进入前的PRIMASK保存下来,退出时恢复它。这是我在排错时碰到过的一个“间歇性bug源头”,表面上看代码逻辑完全正确,但偶尔在某个中断场景下就崩一次,根源就在这个保存与恢复的细节上。

临界区保护的对象主要是调度器状态,比如current_tcb指针的修改、当前任务放回就绪队列的动作,以及“选中下一个任务”的决策过程。放在PendSV里执行的任务选择函数scheduler_pick同样需要在临界区下跑完,否则在选取过程中任务状态一变,选出来的next可能已经不是最优解了。

5. 调度策略落代码:优先级抢占、时间片轮转与三大调度点

有了数据结构、有了切换机制、有了定时器心跳和临界区保护,现在可以把“策略”写成代码了。调度器的策略通常是一组规则:什么情况下必须换人、换谁、时间片怎么算、任务状态怎么流转。

5.1 优先级抢占的规则:数字小的优先,但不是绝对

我实现的调度规则可以概括为三条:

  • 只要就绪队列中存在比当前任务优先级更高的任务,下一次调度就切换到那个高优先级任务;
  • 同优先级任务之间轮流运行,每人一个完整时间片;
  • 没有任务就绪时就跑空闲任务。

优先级抢占的关键不在“调度函数里怎么选”,而在“高优先级任务醒来时怎么让内核知道”。以睡眠唤醒为例:一个任务调用task_sleep(50)进入BLOCKED状态,50个tick之后被唤醒并重新进入就绪队列。此时SysTick中断还在执行,如果在SysTick里发现“有更高优先级任务刚刚就绪”,就必须主动挂起PendSV,否则当前任务会一直独占CPU,高优先级任务的“抢占”就成了空话。

规则里还有一个容易被忽略的点:时间片耗尽不等于必须换人。当前任务的slice归零后,把它重新放回就绪队列尾部,但如果此时队列里没有另一个同优先级任务,也没有更高优先级任务被唤醒,那么scheduler_pick选出来的还是它自己。这种情况下PendSV可以直接跳过现场恢复,不必做一次无效切换。这种“选出来还是自己”的路径,很多初版调度器都会漏掉,结果表现为无谓的切换导致系统运行变慢。

5.2 时间片分配:以tick为单位,而不是以“毫秒”为单位

SysTick每来一次,就代表一个tick过去。时间片通常设成tick的整数倍,比如tick是1ms,时间片设10个tick,那么每个任务轮流获得10毫秒的CPU时间。实现上只需要在每个tick里做一次递减:

void scheduler_tick(void) { if (current_tcb->state == RUNNING && current_tcb != idle_tcb) { current_tcb->slice_remain--; if (current_tcb->slice_remain == 0) { current_tcb->slice_remain = current_tcb->slice_reset; current_tcb->state = READY; enqueue_ready_tail(current_tcb); /* 放到同级队尾 */ trigger_pend_sv(); /* 请求一次调度 */ } } update_sleeping_tasks(); /* 递减所有阻塞任务的剩余时间 */ }

把当前任务放回队列队尾、时间片重置为初值,这个动作要在临界区里完成。因为如果正在操作队列时PendSV抢先执行,上下文切换代码会读到半个队列状态。再强调一遍:放回队尾只表示“它已经用完这一个时间片,重新排队”,并不意味着“下一个运行的一定不是它”。调度器的选择函数会对所有就绪任务做统一裁决。

一个比较谨慎的设计是:高优先级任务正在运行时,低优先级任务的剩余时间片保持原样,不要被tick递减。否则低优先级任务每次被抢占都白白损失时间片,等它终于重新获得CPU时,可能只剩下短短几个tick,很快又被切走。这会造成低优先级任务“饿死”一样的行为。正确的语义是:时间片只对自己运行的时间计数。

5.3 调度点不止tick:主动让出和阻塞也是入口

抢占式调度虽然核心是tick中断,但一个完整的调度器不可能只靠tick驱动。至少还有三个调度点需要处理:

第一个是主动让出,即task_yield。调用它表示当前任务主动放弃剩余的CPU时间,把自己放回就绪队列尾部,然后触发一次调度。这个接口在协作式调度器里是“必须调用”,在抢占式调度器里更多是“优化选择”:任务发现自己已经完成了当前阶段的工作,没必要占用剩余时间片,主动让位能让同优先级任务更快跑起来。

第二个是阻塞等待。比如task_sleep(100)把任务设为BLOCKED,记录它期望被唤醒的时间点,然后触发一次调度。由于当前任务已经处于阻塞状态,scheduler_pick不会选中它,CPU被让给其他任务。这个阻塞列表的管理,简单实现可以每次tick遍历所有TCB检查剩余时间,代价是O(n);任务多了以后,可以改成按唤醒时间排序的定时器链表,复杂度会显著下降。

第三个是唤醒,通常是其他任务调用task_wakeup或者某个事件触发。一旦BLOCKED任务被置回READY并进入就绪队列,就必须马上判断它的优先级是否高于当前任务;如果是,就触发PendSV。这个“唤醒后立刻抢占”的动作,是所有优先级抢占语义的关键路径。

这三大调度点加上tick中断,调度逻辑才算闭环。

5.4 为什么必须有一个空闲任务

最后一个调度策略问题是:如果就绪队列空了,调度器该选谁?总不能一个任务都不选,让系统停在一个“无上下文可切换”的尴尬状态。所以我们必须静态创建一个优先级最低的空闲任务,它的入口函数是一个死循环:

static void idle_task_entry(void *param) { while (1) { __WFI(); /* 等待中断,降低功耗 */ } }

空闲任务平时不占用CPU,tick来了就醒来,发现还是没有其他任务就绪,又睡回去。但它存在的意义是保证调度器在任何时刻都有一个合法的“当前任务”可以指向,PendSV永远能把现场保存到某个确定的TCB里,不需要特殊分支处理“无任务可切”的情况。很多轻量级调度器也把这个任务称为background task,后续还可以在它身上统计CPU空闲率、执行栈回收等后台工作。

5.5 任务状态机:一张表理清所有转换

前面提到了READY、RUNNING、BLOCKED三种状态,把它们之间的关系整理成一张表,写代码时就不容易漏状态转换:

当前状态触发事件新状态调度器动作
RUNNINGtick中断且时间片耗尽READY放回就绪队列尾,触发PendSV
RUNNING主动yieldREADY放回就绪队列尾,触发PendSV
RUNNING阻塞等待/sleepBLOCKED注册唤醒时间,触发PendSV
BLOCKED唤醒时间到/事件触发READY放入就绪队列,若优先级更高则触发PendSV
READY被调度器选中RUNNING执行上下文切换
任何状态系统创建的任务结束待销毁调用清理函数,置为完成态

状态转换动作务必放到临界区里执行,尤其是“BLOCKED到READY”和“RUNNING到READY”这两个路径,任何一步遗漏,都可能出现一个任务被重复放进就绪队列、或者一个任务永远卡在阻塞态的诡异行为。

6. 实测与排错:手写调度器最容易翻车的几个典型现场

代码写完了,编译也能过,不代表调度器就能跑。实际上我第一次在Cortex-M模拟器里跑这套东西时,几乎把所有典型雷都踩了一遍。这里把踩坑过程整理成几条排查链路,按“现象—怀疑—定位—解决”的顺序写,真正排错时可以按这个思路快速收敛。

6.1 现象一:任务一启动就HardFault,整个系统复位循环

第一版调度器最常见的死法就是启动后立刻硬故障。排查时先别急着怀疑“是不是汇编写错了”,按照下面几步走:

第一步,调试器里暂停,看HardFault发生时的PC值。如果PC指向了某个任务入口地址附近,说明栈初始化里的入口地址被正确装载了,但任务入口函数本身有问题,最常见的是入口函数返回了。Cortex-M任务入口正常情况下应当是一个永不返回的死循环;一旦函数执行到末尾,CPU会尝试跳回LR指向的地址。如果LR还是0,直接进HardFault。所以我在栈初始化时把LR指向清理函数,即task_exit_cleanup,即使发生异常返回,也能走到一个明显的地方停下来。

第二步,检查xPSR的bit24。如果初始xPSR没置Thumb位,CPU会在第一次跳入任务时报告UsageFault。这是新手最容易犯的错,因为调试器默认会在反汇编里自动切换Thumb模式,不细看很难发现。

第三步,查看SP值在HardFault时是否指向有效地址。Cortex-M要求栈指针在异常入口处保持8字节对齐,如果初始化栈顶时从某个非对齐地址开始压栈,异常压栈行为会直接触发总线错误。一个简单的做法是确保传入的stack_top本身就是8字节对齐的地址。

排查完这三个点,第一版调度器的启动崩溃问题基本都能解决。

6.2 现象二:任务之间“串数据”,同一个变量被神秘改写

调度器能够稳定切换了,但开始出现更隐蔽的问题:某个任务里计算的临时变量,到了另一个任务里居然看到被改动过的数值。这种“串数据”现象我倾向于先怀疑寄存器保存不完整,尤其是FPU寄存器。

Cortex-M4/M7如果开启了硬件浮点,任务里的浮点运算会使用S0-S31以及FPSCR。我的切换汇编里只保存了R4-R11,FPU寄存器一个都没动。于是TaskA做了一段浮点计算,切到TaskB后,TaskB再用浮点寄存器里的残留值计算,结果就鬼畜了。Cortex-M的硬件其实支持懒压栈(lazy stacking),进入异常时FPU上下文可以延迟到真正使用FPU时才压栈,但前提是在FPCCR里配好并正确使用FPU的异常返回机制。自己手写调度器时,这部分配置很容易被忘掉。

最省事的解决办法是:第一版不要开启硬件浮点,编译时强制软件浮点,例如GCC的-mfloat-abi=soft。这样所有浮点运算走软浮点库,不碰FPU寄存器,彻底规避这个问题。如果确实想用硬件浮点,那就要在上下文切换里额外保存S0-S31和FPSCR,或者妥善利用懒压栈机制。对刚入门的人来说,前一种方案最稳。

6.3 现象三:某些中断被“神秘延迟”,响应时间忽长忽短

调度器跑起来后,项目里原本一个要求纳秒级响应才够快的外设中断,处理明显变慢了。排查了一会儿发现罪魁祸首是临界区:进入临界区期间所有中断都被屏蔽,而临界区里如果恰好在跑printf调试输出、或者某些耗时的队列操作,屏蔽时间就可能拉到几十微秒到几百微秒。

解决方法是给临界区“瘦身”。临界区里只做必须原子性保护的操作,比如改几个链表指针、切换任务状态,任何与调度无关的耗时代码都应该移出去。尤其是调试用的打印,绝不能让它们出现在临界区里。另一个方案是用BASEPRI寄存器屏蔽低优先级中断而保留高优先级中断,这样高优先级中断依然能响应,临界区就不再是“全关中断”的粗暴模式。不过BASEPRI方案要求对中断优先级层次有清晰规划,换来的好处是系统实时性显著提升。

我还遇到过一个更隐蔽的“延迟”问题:SysTick因为被外设中断频繁打断,节拍本身发生了抖动,导致所有基于tick的时间测量都不准了。如果观察到的“中断延迟”其实是从某个任务里测量另一个中断的间隔,那就要区分是中断响应变慢还是tick漂移。把SysTick优先级调高、把PendSV保持最低之后,这个现象就消失了。

6.4 如何证明抢占真的生效

调度器是个时间相关的系统,代码不崩不代表抢占真的按预期工作。我用的验证方法非常朴素:写两个任务,一个低优先级、一个高优先级,各自翻转一个GPIO引脚,用示波器观察波形。

低优先级任务入口函数是一个死循环,循环里只做GPIO翻转;高优先级任务每10个tick醒来一次,也翻转另一个GPIO。如果调度器没有真正抢占,低优先级任务会独占CPU,高优先级任务永远得不到运行机会,示波器上只能看到一路方波。调度器正常工作后,第二路GPIO会以10ms左右为周期翻转,同时第一路波形会因为被抢占而出现间歇性停顿,频率明显降低。

这个测试最直观,也最能说明抢占的本质:低优先级任务“不愿意”让位,高优先级任务照样按自己的节奏跑。如果示波器上高优先级任务的翻转周期不稳定,下一步就去查tick配置和优先级设置,而不是纠结调度代码的某个分支。

6.5 写在最后的一些经验习惯

自己写完这套调度器之后,我养成了一个排错习惯:任务栈初始化时用固定的0xA5填充整个栈区,跑上一段时间后暂停系统,检查栈区里有多少字节已经从0xA5变成了其他值。这些被改写的字节区域就是任务的栈使用深度。这个检查能让你一眼看出某个任务的实际栈需求,远比自己估算靠谱。另一个习惯是,每加一个新的调度能力,先用两个固定任务反复跑同一组测试,确保上下文切换路径稳定之后再往里面加第三个任务。手写调度器这个项目,最大的敌人从来不是某个具体的bug,而是你在一堆尚未验证的假设上叠加新逻辑,最终让现场无法追踪。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进