ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

手把手教你实现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数,这样上层代码移植的时候才不会出错。

RELATED READING

延伸阅读

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