2026/10/21 10:28:10

手把手教你实现MCU上的最小抢占式调度器

手把手教你实现MCU上的最小抢占式调度器 很多人写裸机程序都是从一个大循环开始的读传感器、处理按键、刷新屏幕、发送数据全塞在while(1)里。功能少的时候倒还好但只要功能一多代码就开始互相拖后腿——按键按下要等半秒才响应通信数据偶尔就超时丢包。这时候你就会想能不能让系统自己决定先跑哪个任务、跑多久、什么时候换下一个抢占式调度器解决的就是这件事。它负责在多个任务都处于“可运行”状态时决定谁先上CPU、运行多长时间、被谁打断、以及如何切回。这篇文章我会把一个最小可运行的抢占式调度器从头到尾拆开讲包括任务控制块怎么设计、上下文切换到底在切什么、SysTick和PendSV如何配合、以及我在实测中踩过的各种坑。适合刚接触RTOS、或者想深入理解操作系统调度机制的开发者看完你完全有能力自己手写一个跑在MCU上的调度器。1. 先想清楚抢占式调度器的设计逻辑1.1 为什么必须要“抢占”而不是让任务自己让位调度器有两种典型形态协作式和抢占式。协作式调度的核心逻辑是“每个任务主动放弃CPU系统才切到下一个任务”。听起来简单但工程上非常脆弱只要有一个任务里存在长循环、阻塞等待或者意外死循环整个系统就卡在那个任务里其他任务完全没有翻身机会。我早年做的一个数据采集模块就吃过这种亏一个串口等待应答的循环没加超时结果整机其他功能全部挂起只能靠看门狗复位。抢占式调度则是把“何时切换”的控制权从任务手里收回来交给调度器本身。系统通过定时器中断强行打断当前正在运行的任务保存现场然后按优先级策略决定下一个该跑谁。特别典型的一个场景就是高优先级任务在等待某个外部事件低优先级任务正在做批量计算这时候外部事件到了中断里把高优先级任务唤醒调度器立刻暂停低优先级任务先让高优先级任务把关键动作做完再回来继续算。这件事用生活中的场景类比就是医院急诊分诊。协作式像普通门诊排队前面的人聊了二十分钟后面的人就只能干等抢占式像急诊分诊救护车送来的危重病人一到立刻插到最前面处理。嵌入式系统里“危重病人”就是那些有实时性要求的任务比如电机控制、协议超时处理、安全保护逻辑。1.2 调度器要管的三大核心结构一个典型的抢占式内核无论手写还是商用RTOS核心都离不开三个部分任务控制块数组、就绪队列、时基中断源。任务控制块叫TCB每创建一个任务就分配一个TCB它用来存放这个任务的所有“身份信息”任务当前的栈指针、优先级、状态、入口函数、私有栈底地址等等。可以把它理解成每个任务的档案袋调度器做任何决策之前必须先翻档案袋。就绪队列负责表达“当前有哪些任务是能跑的”。最简单的设计就是按优先级分成多个队列同优先级的任务链在一起。调度时只需要找“最高优先级里排在最前面的任务”即可。就绪队列的更新时机很讲究任务创建时、任务延时结束时、任务被信号量唤醒时都要把对应TCB挂到就绪队列上。时基中断是抢占的“节拍器”。没有外部中断周期性打断抢占就没有触发源。常用的是定时器中断比如配置一个1ms的周期性中断每次中断都触发一次调度检查当前任务时间片到没到有没有更高优先级任务就绪任务状态机也很重要一个任务只会有三种基本状态运行态、就绪态、阻塞态。跑着的叫运行态被抢占的其实回退成就绪态等待延时、信号量、事件的时候叫阻塞态。调度器干的事说白了就是不断在这三个状态之间做搬运工。1.3 一个任务切换的完整足迹我画不出流程图直接用文字描述把整个切换足迹走一遍。某时刻低优先级任务B正在运行。外部设备触发中断在中断服务函数里把一个高优先级任务A等待的事件置位。中断服务函数退出前发现“A任务已经就绪而且A的优先级比B高”于是设置一个切换请求标志。退出中断后系统进入PendSV异常处理PendSV是专门用来做上下文切换的异常在PendSV里把B的当前寄存器现场全部压入B自己的栈保存好B的栈指针到B的TCB里然后从A的TCB里取出A的栈指针把A之前保存的现场全部弹出到寄存器里最后退出异常CPU接着跑的就是A任务的代码。这里有一个容易想歪的点所谓的抢占不是中断服务函数里直接跳去执行任务A的代码而是“中断退出之后顺着异常返回机制自然切到另一个任务的上下文”。理解了这个后面看汇编代码就不会懵。2. 四个关键设计点TCB、上下文切换、就绪队列与临界区2.1 任务控制块TCB里的字段不是随便定的我写调度器的时候TCB结构体的字段顺序都是刻意安排的。第一版我把栈指针放在第一个字段理由很实际上下文切换的汇编代码需要频繁访问当前TCB的sp如果sp在结构体偏移为0的位置汇编里只用一个基地址加零偏移就可以读写代码最紧凑也最不容易错。后面加字段也只是往后追加前面的偏移不会变。一个直接可用的TCB设计大概长这样typedef struct tcb { uint32_t *sp; // 当前任务的栈指针必须位于偏移0 uint8_t priority; // 优先级数值越小优先级越高 uint8_t state; // 任务状态 uint8_t delay_ticks; // 剩余延时tick数0表示就绪 void (*entry)(void *arg); // 任务入口函数 void *arg; // 任务参数 uint32_t *stack_bottom; // 栈底地址用于溢出检测 uint32_t stack_size; // 栈大小 struct tcb *next; // 指向同优先级队列的下一个任务 } tcb_t;每个字段背后都有坑。delay_ticks用uint8_t意味着最大延时255个tick如果你把tick配置成1ms那一个任务最多只能延时255ms不够用用uint16_t或者uint32_t才稳妥。stack_bottom和stack_size是我后来加上的原因后面排查章节会说——任务栈溢出是跑调度器最常遇到的硬故障有这两个字段可以在每次切换时做一次边界检查。任务状态字段我习惯定义成枚举运行、就绪、阻塞各一个值。运行态其实可以并入就绪态的语义中因为一个CPU上永远只有一个任务在跑其他就绪任务都处于“随时可以接手”的状态。2.2 上下文切换到底在“切”什么上下文切换是整个调度器的灵魂。所谓上下文指的就是CPU在一瞬间执行任务所需的全部状态——通用寄存器、栈指针、程序计数器、程序状态寄存器。切换的本质就是把当前任务的所有寄存器从一个地方搬到它自己的栈里再把它对手任务的所有寄存器从对方的栈里搬回来。在很多通用MCU上这个搬运动作是由硬件和软件配合完成的。以Cortex-M为例一个任务正在被中断打断时CPU硬件会自动把一部分寄存器压栈包括R0到R3、R12、返回地址LR、程序状态寄存器xPSR和PC这会占用栈里的24字节。但R4到R11这八个寄存器硬件不压栈必须由软件在PendSV里自己保存和恢复。这正是手写调度器最容易出错的地方漏保存任何一个寄存器任务恢复运行后寄存器值被改掉轻则运行出错重则直接HardFault。在Cortex-M上做上下文切换有一个极好的工具叫PendSV这是设计者专门留给操作系统做上下文切换的异常。它的优先级可以在NVIC里配置成最低优先级这样任何中断抢占它之后都会先完成自己的处理最后才轮到PendSV做切换。为什么一定要用PendSV而不是直接在SysTick中断里切换因为如果SysTick正在跑的时候又被其他高优先级中断打断打断了就得服务于那个中断之后还要返回SysTick处理完才能轮得到别的任务换任务的操作如果塞在SysTick里会把整个中断时序搞乱。PendSV的“低优先级”特性恰好把切换动作推迟到所有中断处理完之后避免嵌套地狱。2.3 就绪队列的检索位图比链表快一个数量级找“当前最高优先级的就绪任务”这个操作在每次调度时都会执行必须尽量快。我见过最简单粗暴的写法是从0到最大优先级数遍历一遍找到第一个非空队列。如果优先级总数就8个、16个那这种线性扫描完全够用。但如果你想做一个稍微像样的内核位图法是更专业的做法。思路是这样的为每个优先级维护一个非空的标志位用一个32位整数就可以覆盖32个优先级。当某个优先级对应的队列有任务时对应位置1。检索最高优先级就绪任务时只需要把整个就绪位图取出来然后数出最低位为1的位置或者直接查一张预计算的表。数前导零的指令在Cortex-M上是一条指令搞定的比遍历快得多。我实际在项目里用一种拆分位图的方式第一层是优先级组位图32位对应32个优先级组每个组内部还有第二层位图表示该组内有任务的具体任务编号。这样两层位图配合可以应对大量任务而不拖慢检索速度。优先级数量不多时不需要做两层一层就够。就绪队列的插入与删除有个细节在任务切换时当前任务如果只是被抢占它的状态应该回退到就绪态并放回就绪队列如果是主动延时退出运行则放到阻塞态不进就绪队列等延时结束再插入。这个区分判断错了调度器就会把已经阻塞的任务又调度上去跑白白消耗CPU。2.4 临界区保护别让调度器自己打断自己调度器内部修改就绪队列、TCB链表这些共享数据结构时必须保证操作的原子性。问题在于中断随时可能触发一旦中断里也去操作同一个就绪队列就绪链表就可能被撕成两截状态错乱任务就再也切不回来了。最常见的保护手段是关中断。进入临界区前调用关闭中断的指令操作完再打开。在Cortex-M上最粗暴的写法就是直接操作PRIMASK寄存器把全局中断全关了。这种写法保护效果最好但关中断的时间一长系统的实时性就会被摧毁关键外部中断响应会延迟几个微秒都正常。更优雅的做法是BASEPRI寄存器操作只屏蔽优先级低于某个阈值的中断高优先级的中断比如电机控制FOC采样依然可以响应。操作系统的内核源码里经常提到“锁调度器”而不是“锁全局中断”指的也是类似思想——中断还能进但它进不去会改动内核数据的API从而保持内核数据安全。另外有一个必须提醒的点中断服务函数里不应该调用带阻塞性质的调度API。中断上下文里没有任务的概念调用类似“任务延时”的函数会把当前任务挂起但实际上当前执行流并非任务最终结果大概率是内核自己卡死。后面排查章节我会给一个具体场景。3. 从0到1实现一个最小抢占式调度器3.1 创建任务时的“伪现场”设计任务创建是整个调度器里最巧妙的一步。一个新任务从来没有运行过它没有“现场”可以保存但我们希望调度器第一次切换到它的时候像切换老任务一样直接“恢复现场”就好。思路是创建任务时在它的私有栈里人为构造一个跟“刚被中断打断”一模一样的假现场把栈指针指向这个假现场之后调度器用通用的恢复流程就能顺理成章地把新任务跑起来。在Cortex-M上任务入口函数的地址放在假现场的PC位置程序状态寄存器xPSR里要预置Thumb模式位这一个bit忘掉就是灾难CPU恢复这个任务时默认为ARM模式很多MCU并不支持从异常返回直接进ARM模式于是硬故障立刻触发。R0位置放任务参数LR位置放一个用于任务结束的兜底函数地址或者直接放一个死循环函数保证任务哪怕跑完了也不会失控往下乱跳。核心代码类似这样void task_stack_init(tcb_t *tcb, void (*entry)(void *arg), void *arg) { uint32_t *p (uint32_t *)((uint32_t)tcb-stack_bottom tcb-stack_size); *--p (uint32_t)0x01000000; // xPSR: 必须置Thumb位 *--p (uint32_t)entry; // PC: 入口地址 *--p (uint32_t)task_exit_handler; // LR: 返回兜底 *--p (uint32_t)0xFFFFFFFD; // 异常返回使用的EXC_RETURN *--p (uint32_t)arg; // R0: 参数 *--p 0x00000000; // R3 *--p 0x00000000; // R2 *--p 0x00000000; // R1 *--p 0x12121212; // R12 // 下面是PendSV里需要软件保存的寄存器 *--p 0x11111111; // R11 *--p 0x10101010; // R10 *--p 0x09090909; // R9 *--p 0x08080808; // R8 *--p 0x07070707; // R7 *--p 0x06060606; // R6 *--p 0x05050505; // R5 *--p 0x04040404; // R4 tcb-sp p; }有几个值不是随便写的。EXC_RETURN的值必须是0xFFFFFFFD它告诉CPU“返回线程模式并且使用PSP进程栈指针”这是用MSP和PSP分离的典型RTOS设计。每个任务跑自己的私有栈而中断服务函数统一用主栈这样任务栈互相隔离一个任务栈溢出不会立刻污染另一个任务的现场。3.2 SysTick与PendSV的配置优先级不能搞反时基我习惯选SysTick因为它本来就是设计给操作系统用的周期定时器。Cortex-M内核里SysTick定时器有独立的寄存器配置周期可以这样算假设系统时钟72MHz要1ms中断一次那么重装载值就是72000000除以1000再减1即71999。如果系统时钟更高比如168MHz那重载值是167999。这个计算是纯数学但很多人会忽略SysTick的时钟源可能不同于系统时钟在部分芯片上SysTick用的时钟可能是系统时钟的分频值必须查对应芯片手册确定否则你配置出来的tick周期跟预期差好几倍。NVIC里PendSV和SysTick的优先级配置决定调度是否正常。我的配置习惯是SysTick优先级中等偏下PendSV优先级设为最低数值最大。这样做的好处是SysTick中断即使发生时CPU正在跑更紧急的中断服务也不会立刻打断而PendSV永远最后一个执行保证切换动作在所有中断服务都处理完毕之后干净开展。SysTick中断服务函数要做的事情很简单就三件全局tick计数加一遍历所有阻塞态任务把它们的delay_ticks减一减到零就移入就绪队列然后检查当前运行任务是否还有时间片、是否有更高优先级就绪任务有则置PendSV挂起位触发切换。3.3 任务延时与主动切换的链路任务延时是最基础的阻塞操作。它把当前任务从就绪队列摘下来状态改成阻塞态给delay_ticks赋一个目标值然后立即触发一次调度。注意这里必须触发调度因为当前任务自己阻塞自己之后CPU不能停留在任务里继续往下跑必须马上切到别的任务。对接下来的SysTick中断来说每次tick都对阻塞任务统一做减法操作减到零就恢复就绪。这种实现方式简单可靠但有个效能问题如果任务数量很多每个tick都遍历全部任务列表并且延时是比较稀疏的事件那大部分遍历都是无效的。更高效的办法是维护一个按延时到期时间排序的定时器队列每个tick只检查队首的最近到期任务。不过对学习用的最小调度器来说线性遍历足够清晰方便你观察整个调度链路的运转。任务主动让出CPU也就是时间片轮转的同优先级切换实现上比抢占更简单直接把当前任务放回就绪队列尾部然后触发调度即可。这样的结果是同优先级任务轮流跑每个任务跑一个时间片。这个功能在代码里通常叫task_yield。3.4 一个可以直接抄作业的完整用例写一个最小系统的初始化与调度启动流程用伪代码描述更直观int main(void) { // 初始化硬件时钟、串口、LED引脚等 board_init(); // 创建两个任务 task_create(led_flash_task, NULL, 2, 256, tcb_led); task_create(uart_report_task, NULL, 1, 512, tcb_uart); // 设置SysTick 1ms中断PendSV最低优先级 systick_config(1000); nvic_set_priority(PendSV_IRQn, 0xFF); // 启动调度器永远不会返回 scheduler_start(); }scheduler_start做的事情是找到当前最高优先级就绪任务把它的栈指针装载到PSP然后把CONTROL寄存器的bit1置位从线程模式切换到使用PSP最后触发一次异常返回指令让CPU跑起来。这之后CPU就不会再按main里的顺序往下走了而是完全由任务与中断驱动。两个任务的内容可以非常简单void led_flash_task(void *arg) { while (1) { led_toggle(); task_sleep(500); // 阻塞500个tick即500ms } } void uart_report_task(void *arg) { uint32_t count 0; while (1) { printf(count: %lu\n, count); task_sleep(1000); } }串口任务优先级比LED任务高所以调试时你能看到的现象是两个任务都能按各自周期运行即使其中一个任务内部有长时间计算另一个任务也不会被拖垮因为SysTick每1ms就会检查一次是否需要抢占。4. 实测中的常见问题与排查方法4.1 任务跑飞或进入HardFault这是新人写调度器最常撞的墙没有之一。我印象最深的一次所有任务建好以后调度器一启动就进HardFault跟着调试器看了半天才意识到任务初始栈里xPSR的Thumb位没置位。调这种问题没有捷径基本是靠调试器和细心。排查步骤我建议固定顺序。先用调试器打开HardFault Handler查看异常的出入栈内容特别是压栈保存的PC值这个PC大概率就是错误发生前的执行位置。如果PC指向一个很离谱的地址优先怀疑栈指针错乱如果PC指向0xFFFFFFFF那基本是异常返回指令的EXC_RETURN设置错了如果PC指向内存里的随机区域就要去检查当前任务栈底有没有被踩穿过。栈溢出检测也很实用。在每次上下文切换保存现场之后检查当前任务栈指针sp有没有越过stack_bottom边界如果越界就给一个错误标志并停下来。我实际用下来这个方案能有很高的捕获率特别是配合在每个任务栈首尾填充固定魔数比如0xDEADBEEF在切换时检查魔数有没有被改写立刻就能知道谁在偷吃邻居的栈空间。4.2 调度不切换或者切换时机错乱表现是任务A一直在跑任务B永远轮不到或者任务每次切换都比预期慢很多。出现这种情况先查NVIC的优先级配置。一个典型错误是SysTick和PendSV优先级配反了——PendSV优先级比SysTick高那么SysTick中断触发后还没完成它该做的标志量更新PendSV就抢着切换了最终切换到的可能是还没完全就绪的任务整个系统逻辑乱套。还有可能是调度请求根本没有被触发。我见过一种情况任务延时函数里确实调用了调度触发但忘了把当前任务从就绪队列移除结果当前任务延时期间依然被调度器选中运行于是它自己切给自己看起来就像调度死了一样。排查时可以在PendSV入口和SysTick入口各放一个全局变量计数器每次进入都自增。用调试器观察这两个值如果SysTick一直在加但PendSV一直零说明挂了切换请求但PendSV没响应如果两个都在加但任务就是不切换去查TCB的sp更新是否成功。4.3 优先级反转问题优先级反转是抢占式调度里一个经典的副作用。场景是三优先级的任务低优先级任务C拿到了一个互斥信号量正在执行临界区代码高优先级任务A等着这个信号量被阻塞中优先级任务B不需要信号量持续就绪。结果A虽然优先级最高却只能等C跑完释放信号量而C被B抢占得不到CPU整个系统看起来就像“中优先级任务把高优先级任务堵死了”。实际调试中优先级反转的直接表现是高优先级任务的关键动作周期性超时。解决办法的主流方案是优先级继承当低优先级任务持有信号量且高优先级任务在等待时把低优先级任务的优先级临时提升到等待者的优先级执行完临界区再恢复。更简单粗暴的方案是优先级天花板进入临界区直接把任务优先级提到一个预设的天花板值。这两个方案我建议作为调度器的后续扩展编写它们能让你加深对“优先级只是相对概念”的认知。4.4 中断里调用阻塞API导致内核挂死场景是这样的外部中断服务函数里为了等待某个条件直接调用了任务延时函数。视觉上是中断服务函数正常执行但内部它试图把“当前任务”从就绪队列移除并触发调度。中断上下文里所谓的当前任务还是被打断前那个任务把无辜的任务移到阻塞队列之后调度器回头发现那个任务在延时中就彻底不知道该运行谁了。我自己踩过这个坑以后给工程定了一条规矩中断服务函数里的处理逻辑越短越好宁可只做“置标志、丢事件、唤醒任务”把业务逻辑全部搬到任务里做。唤醒操作本身只是把等待信号量的任务放入就绪队列这个操作必须做到原子性同时它不会阻塞中断是中断与任务通信的唯一正确姿势。这类问题还有一个很隐蔽的变种在关中断的临界区里调用调度API。临界区已经关了全局中断调度触发要求PendSV立即挂起但PendSV的中断优先级此时也被屏蔽了切换请求只能干瞪眼等着。解决这类问题的思路是临界区里只做对内核数据的快速修改离开临界区之后再统一处理调度请求。最后分享一个调试小技巧手写调度器看代码不如看波形。我在全局搞一个调试用的GPIO在任务切换函数里让这个引脚翻转一次用示波器或逻辑分析仪挂着每个方波下降沿就是一次切换事件。这样你不用打断程序运行就能直观看到调度器是不是在稳定工作切换频率有没有异常比单步调试省太多时间。还有一个值得注意的细节是任务延时函数的参数如果按tick数算不同芯片、不同系统时钟下同样的参数实际延时时间不一样所以设计API时最好按毫秒为单位传入内部再换算成tick数这样上层代码移植的时候才不会出错。