抢占式调度器这个词,很多做单片机的朋友第一次听到会觉得离自己很远,觉得那是RTOS内核开发者才碰的东西。但真把STM32F103C8T6这块最小系统板摆在桌上,拿Keil建好工程,你会发现从零写一个能跑起来的抢占式调度器,核心代码量其实不到两百行。难的不是写代码,难的是搞清楚Cortex-M3内核在异常进出时到底替你做了哪些事、PSP和MSP什么时候切换、PendSV为什么必须设成最低优先级。这几个问题想通了,调度器就是水到渠成的事。
这篇文章面向的是有STM32裸机开发基础、想弄明白RTOS底层调度机制的朋友。我会用STM32F103C8T6加标准库工程做载体,从任务栈的初始化开始,一步步把SysTick、PendSV、PSP这三块拼图拼起来,最终实现一个支持多任务轮转、带优先级抢占的调度器。过程中涉及到的每个寄存器操作我都会解释为什么这么写,以及不这么写会出什么问题。
1. 为什么要在裸机上自己写调度器
1.1 从裸机大循环到多任务的真实痛点
大部分STM32入门项目的代码结构是这样的:main函数里一个while(1),里面依次调用按键扫描、串口处理、LED刷新、传感器读取。这种结构在功能少的时候没问题,但一旦任务多起来,麻烦就来了。串口接收一帧数据要等几十毫秒,这期间按键响应就卡住了;某个传感器I2C读取超时,整个循环都被拖住。你可能会用状态机来拆解,但状态机写多了之后,代码里全是switch-case和静态变量,维护成本急剧上升。
我早期做的一个数据采集项目就吃过这个亏。主循环里要同时处理Modbus通信、ADC采样、LCD刷新三件事,Modbus响应慢的时候LCD就明显卡顿。当时第一反应是上FreeRTOS,但项目用的是STM32F103C8T6,Flash只有64KB、RAM只有20KB,跑完整版FreeRTOS虽然能塞进去,但留给应用的资源就很紧张了。而且当时的需求很简单,就是三个任务按优先级抢占执行,不需要信号量、队列、事件组这些高级功能。这种情况下,自己写一个精简的调度器反而更合适。
自己写调度器的好处很直接:代码完全可控,出问题能定位到每一行;资源占用极小,核心代码编译出来也就1KB左右;理解成本低,不用去啃RTOS那套复杂的API。当然代价也有,你得自己处理栈分配、临界区保护、任务同步这些底层细节。但对于想真正搞懂Cortex-M3任务切换机制的人来说,这个过程的价值远超直接调库。
1.2 抢占式调度和协作式调度的本质区别
调度器分两大类:协作式和抢占式。协作式调度靠任务主动让出CPU,比如每个任务里必须定期调用一个yield函数。这种模式实现简单,但有个致命问题——只要有一个任务忘了让出,或者某个任务执行时间过长,整个系统就卡死了。抢占式调度则不同,它靠硬件定时器产生周期性中断,在中断里强制切换任务,不管当前任务愿不愿意。这就保证了高优先级任务一旦就绪,就能立刻拿到CPU。
抢占式调度的核心依赖是硬件提供的异常机制。Cortex-M3内核有两个关键异常:SysTick和PendSV。SysTick负责产生周期性的时间基准,PendSV负责执行实际的任务上下文切换。为什么切换要放在PendSV里而不是直接在SysTick中断里做?因为SysTick可能和其他中断同时发生,如果直接在SysTick里切换上下文,可能打断一个正在执行的中断服务程序,导致中断嵌套混乱。PendSV被设计成最低优先级异常,它会在所有其他中断处理完之后才执行,这样就保证了上下文切换不会打断任何中断。
1.3 最小系统板的硬件资源盘点
STM32F103C8T6这颗芯片是Cortex-M3内核,主频72MHz,64KB Flash,20KB SRAM。20KB的RAM听起来不多,但对于一个精简调度器来说完全够用。假设我们创建4个任务,每个任务分配512字节的栈空间,总共才占2KB。剩下的18KB留给全局变量和堆,绰绰有余。
这里要特别注意栈的生长方向。Cortex-M3的栈是满递减栈,也就是栈指针先减后存。初始化任务栈的时候,栈顶地址要取高地址,然后手动往栈里压入初始的寄存器值。这个细节如果搞错了,任务第一次运行就会HardFault。另外,STM32F103C8T6的最小系统板上通常有8MHz晶振,经过PLL倍频到72MHz作为系统时钟,SysTick就挂在这个72MHz时钟上。SysTick的重装载值决定了调度的时间片长度,比如重装载值设为72000,就是1ms产生一次中断。
2. 任务栈的初始化:PSP和MSP的分工
2.1 主栈指针和进程栈指针的角色划分
Cortex-M3有两个栈指针:MSP(主栈指针)和PSP(进程栈指针)。复位之后默认使用MSP,所有中断服务程序也都用MSP。PSP则是给用户任务用的。为什么要分两个?因为中断可能在任何时候发生,如果中断和任务共用一个栈,任务栈就必须预留足够空间给中断嵌套,这会造成大量浪费。分开之后,中断用MSP,任务用PSP,各管各的,互不干扰。
调度器的做法是:在任务上下文里使用PSP,在中断上下文里使用MSP。具体怎么切换?靠CONTROL寄存器。CONTROL寄存器的bit1是SPSEL,写0选择MSP,写1选择PSP。在任务里,我们把SPSEL设为1,这样任务用的就是PSP。当异常发生时,硬件自动把SPSEL切回0,使用MSP。异常返回时,硬件又会根据EXC_RETURN的值恢复之前的栈选择。这个机制是硬件自动完成的,不需要软件干预。
2.2 手动构造任务初始栈帧的完整过程
创建一个新任务,最关键的一步是手动构造它的初始栈帧。因为任务第一次被调度时,是通过异常返回的方式“跳”进去的,所以栈里的内容必须符合异常返回时硬件恢复寄存器的格式。Cortex-M3在异常进入时,硬件会自动压入8个寄存器:xPSR、PC、LR、R12、R3、R2、R1、R0。异常返回时,硬件从栈里弹出这8个寄存器,然后跳转到PC指向的地址执行。
所以初始化任务栈的时候,我们要在栈顶往下依次放入这些值。R0到R3、R12可以填0,LR填一个特殊值,PC填任务函数的入口地址,xPSR的bit24必须置1,表示Thumb状态。这个bit24特别容易漏,漏了的话任务第一次执行就会触发HardFault,因为Cortex-M3只支持Thumb指令集,xPSR的T位必须为1。
除了这8个硬件自动保存的寄存器,我们还需要手动保存R4到R11这8个寄存器。为什么?因为在PendSV的上下文切换代码里,我们会手动压栈R4-R11,出栈时也会恢复它们。如果初始栈里没有这8个值,第一次切换时出栈就会读到垃圾数据。所以完整的初始栈帧是16个字:R4-R11、R0-R3、R12、LR、PC、xPSR。
2.3 栈空间分配与对齐的注意事项
栈空间分配有个硬性要求:8字节对齐。Cortex-M3的异常压栈是8字节对齐的,如果栈指针没有8字节对齐,硬件会自动调整,但这样会导致栈里出现空洞,后续出栈时数据就错位了。所以定义任务栈数组的时候,要用__attribute__((aligned(8)))或者手动保证数组起始地址是8的倍数。
另外,栈的大小要留有余量。一个任务如果调用了printf这种重量级函数,栈消耗可能超过1KB。我一般给每个任务分配512字节到1KB,具体看任务里调用了哪些函数。有个简单的估算方法:编译之后看map文件里每个函数的栈使用量,累加一下再乘个1.5的安全系数。如果栈溢出,表现通常是任务跑飞或者HardFault,排查起来很痛苦,所以宁可多给一点。
3. SysTick和PendSV的配合机制
3.1 SysTick中断里到底该做什么
SysTick中断服务程序里只做一件事:触发PendSV异常。具体操作是往ICSR寄存器的bit28写1,也就是SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk。为什么不直接在SysTick里做上下文切换?前面提过,SysTick可能打断一个正在执行的中断,如果此时切换任务,被中断的任务上下文就不完整了。把切换动作推迟到PendSV,等所有中断都处理完了再执行,就安全了。
SysTick的配置也很简单。用标准库的话,调用SysTick_Config(SystemCoreClock / 1000)就是1ms中断一次。这个函数会自动设置重装载值、清计数器、开中断、选时钟源。但要注意,它默认把SysTick优先级设成最低,这其实不太合适。SysTick的优先级应该比PendSV高,但比那些实时性要求高的外设中断低。我一般把SysTick优先级设成1,PendSV设成15(最低),外设中断根据需求设成0到14之间。
3.2 PendSV为什么必须是最低优先级
PendSV设成最低优先级是硬性要求,不是建议。原因在于上下文切换的原子性。假设PendSV优先级不是最低,它可能在某个外设中断执行到一半时抢占进去,然后切换任务。等切换回来再继续执行那个外设中断时,中断服务程序里的局部变量可能已经被新任务改掉了,因为栈指针变了。这种bug极其隐蔽,可能运行几个小时才出现一次,排查难度极大。
把PendSV设成最低优先级后,它只会在所有中断都返回之后才执行。这时候系统处于线程模式,栈指针是PSP,切换上下文不会影响任何中断。这个设计是ARM官方推荐的,也是所有RTOS的标准做法。设置优先级的代码是NVIC_SetPriority(PendSV_IRQn, 15),注意优先级数值越大,实际优先级越低。
3.3 上下文切换的汇编代码逐行拆解
PendSV的中断服务程序是调度器里唯一一段汇编代码,也是整个调度器的核心。这段代码要做的事是:保存当前任务的R4-R11到PSP指向的栈里,把PSP保存到当前任务的控制块,然后调用C函数选择下一个任务,最后从新任务的栈里恢复R4-R11,更新PSP,异常返回。
__asm void PendSV_Handler(void) { IMPORT current_task IMPORT next_task IMPORT task_switch MRS R0, PSP CBZ R0, PendSV_NoSave STMDB R0!, {R4-R11} LDR R1, =current_task LDR R1, [R1] STR R0, [R1] PendSV_NoSave PUSH {LR} BL task_switch POP {LR} LDR R0, =next_task LDR R0, [R0] LDR R0, [R0] LDMIA R0!, {R4-R11} MSR PSP, R0 ORR LR, LR, #0x04 BX LR }逐行解释一下。MRS R0, PSP把当前PSP读到R0。CBZ R0, PendSV_NoSave判断PSP是否为0,为0说明是第一次调度,还没有任务运行过,不需要保存。STMDB R0!, {R4-R11}把R4到R11压入PSP指向的栈,STMDB是满递减存储,R0会先减再存,最后R0指向新的栈顶。然后从current_task指针里取出任务控制块地址,把新的栈顶存进去。
接下来PUSH {LR}保存返回地址,调用task_switch这个C函数。这个函数里做调度决策,选出下一个要运行的任务,把它的栈顶指针赋给next_task。返回后POP {LR}恢复返回地址。然后从next_task里取出栈顶指针,LDMIA R0!, {R4-R11}恢复R4到R11,MSR PSP, R0更新PSP。最后ORR LR, LR, #0x04把EXC_RETURN的bit2置1,表示返回后使用PSP,BX LR触发异常返回。
这里有个细节:ORR LR, LR, #0x04这行。EXC_RETURN的值在异常进入时由硬件自动设置,bit2表示返回后使用哪个栈。置1表示使用PSP,清0表示使用MSP。如果不加这行,异常返回后可能继续用MSP,任务就会破坏主栈。
4. 调度器的核心数据结构与调度策略
4.1 任务控制块里该存哪些信息
任务控制块(TCB)是调度器管理任务的数据结构。最小化的TCB只需要两个字段:栈顶指针和任务状态。栈顶指针在上下文切换时用来保存和恢复PSP,任务状态用来标记任务是就绪、运行还是阻塞。如果要做优先级调度,再加一个优先级字段。如果要做延时功能,加一个延时计数器。
typedef struct { uint32_t *stack_ptr; uint8_t priority; uint8_t state; uint32_t delay_ticks; } tcb_t;栈顶指针的类型是uint32_t *,因为它指向的是栈里的数据,栈里存的是32位寄存器值。优先级用uint8_t就够了,最多256级。状态可以用宏定义:0表示就绪,1表示运行,2表示阻塞。延时计数器用于实现任务延时,SysTick中断里递减这个计数器,减到0就把任务状态改成就绪。
4.2 就绪表的实现与优先级查找
就绪表用来快速找到当前最高优先级的就绪任务。最简单的实现是一个数组,数组下标是优先级,数组元素是任务指针。调度的时候从高优先级往低优先级遍历,找到第一个非空的就返回。这种方法在任务数少的时候完全够用,STM32F103C8T6上跑四五个任务,遍历一次也就几十个时钟周期。
如果要更高效,可以用位图。定义一个32位的变量,每一位对应一个优先级,置1表示该优先级有就绪任务。查找的时候用__CLZ指令(Count Leading Zeros)直接算出最高优先级。这种方法查找时间是常数,跟任务数无关。不过对于F103这种资源有限的芯片,任务数本来就不多,用数组遍历反而更省RAM。
#define MAX_TASKS 8 tcb_t *ready_list[MAX_TASKS]; uint8_t current_priority; tcb_t* scheduler_pick_next(void) { for (int i = 0; i < MAX_TASKS; i++) { if (ready_list[i] != NULL) { return ready_list[i]; } } return idle_task; }4.3 任务切换时的临界区保护
调度器里有几处操作必须保证原子性:修改就绪表、修改当前任务指针、修改任务状态。这些操作如果被中断打断,可能导致调度器状态不一致。比如正在把任务A从就绪表移除,中断来了,中断里又去读就绪表,读到的就是半成品状态。
保护临界区最简单的方法是关中断。Cortex-M3提供了__disable_irq()和__enable_irq()两个内联函数,分别对应CPSID I和CPSIE I指令。但要注意,关中断会影响系统实时性,所以临界区要尽可能短。我一般只在修改就绪表和任务状态的那几行代码前后关中断,其他计算逻辑放在临界区外面。
__disable_irq(); ready_list[old_priority] = NULL; ready_list[new_priority] = next_task; current_task = next_task; __enable_irq();还有个细节:如果在临界区里调用了__enable_irq(),而进入临界区之前中断本来就是关的,那就会意外打开中断。严谨的做法是保存PRIMASK寄存器的值,退出时恢复。不过对于调度器这种场景,进入临界区之前中断肯定是开的,所以直接用__disable_irq()和__enable_irq()配对就行。
5. 从零搭建工程的实操步骤
5.1 Keil工程建立与标准库配置
在Keil4或者Keil5里新建工程,选STM32F103C8器件。然后要把标准库的文件加进来:启动文件startup_stm32f10x_md.s、系统初始化system_stm32f10x.c、以及stm32f10x_gpio.c、stm32f10x_rcc.c这些外设驱动。启动文件必须选md后缀的,因为F103C8T6是中容量产品,选hd或者ld都会出问题。
工程配置里有个关键选项:Target选项卡下的Use MicroLIB。如果勾了这个,printf重定向会简单很多,但MicroLIB对栈的使用和标准库有差异。我建议不勾,自己实现fputc函数来重定向串口输出。另外在C/C++选项卡的Define里加上USE_STDPERIPH_DRIVER和STM32F10X_MD,这两个宏决定了标准库编译哪些代码。
时钟配置直接用标准库的SystemInit函数,它会把系统时钟设成72MHz。但要注意,SystemInit是在启动文件里调用的,发生在main函数之前。如果你在main里又调了一次,可能会出问题。确认一下启动文件里有没有LDR R0, =SystemInit和BLX R0这两行,有的话就不用管了。
5.2 任务创建函数的设计与实现
任务创建函数要做三件事:分配TCB、初始化栈帧、把任务加入就绪表。栈帧初始化是重点,前面讲过要压入16个寄存器值。下面是一个完整的实现:
tcb_t* task_create(void (*task_func)(void), uint8_t priority, uint32_t *stack_base, uint32_t stack_size) { tcb_t *tcb = &tcb_pool[task_count++]; uint32_t *stack_top = stack_base + stack_size; stack_top = (uint32_t *)((uint32_t)stack_top & 0xFFFFFFF8); stack_top -= 16; stack_top[0] = 0x01000000; // xPSR, T-bit set stack_top[1] = (uint32_t)task_func; // PC stack_top[2] = 0xFFFFFFFD; // LR stack_top[3] = 0; // R12 stack_top[4] = 0; // R3 stack_top[5] = 0; // R2 stack_top[6] = 0; // R1 stack_top[7] = 0; // R0 stack_top[8] = 0; // R11 stack_top[9] = 0; // R10 stack_top[10] = 0; // R9 stack_top[11] = 0; // R8 stack_top[12] = 0; // R7 stack_top[13] = 0; // R6 stack_top[14] = 0; // R5 stack_top[15] = 0; // R4 tcb->stack_ptr = stack_top; tcb->priority = priority; tcb->state = TASK_READY; tcb->delay_ticks = 0; ready_list[priority] = tcb; return tcb; }注意stack_top先做了8字节对齐,然后减16个字。这16个字里,低地址是R4,高地址是xPSR。因为栈是满递减的,所以入栈顺序和内存里的排列是反的。xPSR放在最高地址,R4放在最低地址。PendSV里LDMIA R0!, {R4-R11}会从低地址往高地址依次加载R4到R11,正好对应。
5.3 启动第一个任务的完整流程
启动调度器的函数要做几件事:初始化SysTick、设置PendSV优先级、初始化第一个任务的PSP、切换到PSP、触发PendSV。代码如下:
void scheduler_start(void) { __disable_irq(); NVIC_SetPriority(PendSV_IRQn, 15); SysTick_Config(SystemCoreClock / 1000); NVIC_SetPriority(SysTick_IRQn, 1); current_task = scheduler_pick_next(); next_task = current_task; __set_PSP((uint32_t)current_task->stack_ptr + 64); __set_CONTROL(0x02); __ISB(); __enable_irq(); __asm { LDR R0, =next_task LDR R0, [R0] LDR R0, [R0] LDMIA R0!, {R4-R11} MSR PSP, R0 ORR LR, LR, #0x04 BX LR } }__set_PSP设置PSP的值,加64是因为初始栈帧里R4-R11这8个字还没被PendSV恢复,需要跳过它们。__set_CONTROL(0x02)把SPSEL设为1,切换到PSP。__ISB()是指令同步屏障,确保CONTROL寄存器的修改立即生效。最后那段汇编手动触发一次异常返回,让第一个任务跑起来。
这里有个坑:__set_CONTROL(0x02)之后,当前函数的栈就变成PSP了。如果这个函数后面还有局部变量访问,就会出错。所以这段代码必须放在函数最后,而且后面不能再有C代码。我一般把启动逻辑写成一个单独的汇编函数,或者用__attribute__((naked))修饰。
6. 实测中容易踩的坑与排查方法
6.1 任务第一次运行就HardFault的三种原因
第一种原因是xPSR的T位没置1。前面强调过,Cortex-M3只支持Thumb指令,xPSR的bit24必须为1。如果忘了,异常返回时硬件会尝试执行ARM指令,直接HardFault。排查方法是在HardFault_Handler里读LR寄存器的值,如果bit2是1,说明是从PSP返回时出错的,再去看PSP指向的栈里xPSR的值。
第二种原因是栈没有8字节对齐。异常压栈时硬件会检查栈指针的bit2到bit0,如果不是0,会自动调整,但调整后的栈内容和预期不一致。排查方法是打印任务栈的起始地址,看是不是8的倍数。用__attribute__((aligned(8)))修饰栈数组可以避免这个问题。
第三种原因是任务函数地址填错了。PC里填的必须是任务函数的入口地址,而且bit0必须为1(表示Thumb状态)。如果任务函数是C函数,编译器生成的符号地址bit0已经是1了,直接取函数名就行。但如果用了函数指针,要确认指针的值bit0是1。
6.2 任务切换后串口输出乱码的排查
串口乱码通常不是调度器本身的问题,而是串口配置或者时钟的问题。但有一种情况是调度器引起的:任务切换时没有正确保存和恢复R4-R11,导致串口发送函数里的局部变量被破坏。排查方法是把PendSV里的STMDB和LDMIA指令注释掉,看乱码是否消失。如果消失了,说明是上下文保存的问题。
还有一种可能是SysTick中断优先级设得太高,打断了串口中断。串口接收中断里如果有状态机,被SysTick打断后状态机可能进入非法状态。解决办法是把SysTick优先级设成比串口中断低。比如串口中断优先级设0,SysTick设1,PendSV设15。
6.3 栈溢出导致任务跑飞的定位技巧
栈溢出是嵌入式开发里最头疼的问题之一,因为它的表现千奇百怪:有时候任务跑飞,有时候HardFault,有时候数据莫名其妙被改。定位栈溢出有个简单方法:在任务栈的底部填充一个魔术数字,比如0xDEADBEEF,然后定期检查这个数字是否被改写。如果被改写了,说明栈溢出。
#define STACK_CANARY 0xDEADBEEF void stack_check(tcb_t *tcb) { if (tcb->stack_base[0] != STACK_CANARY) { printf("Task %d stack overflow!\r\n", tcb->priority); } }在任务创建时把stack_base[0]设成魔术数字,然后在空闲任务里定期检查。这个方法简单有效,代价是每个任务多占4字节栈空间。如果发现溢出,优先检查任务里有没有大数组局部变量,或者有没有递归调用。把大数组改成static或者全局变量,能省不少栈。
6.4 优先级反转的简单规避方案
优先级反转是指高优先级任务因为等待低优先级任务持有的资源而被阻塞,结果中等优先级任务反而先执行。完整的解决方案是优先级继承或者优先级天花板,但那需要实现互斥量,复杂度较高。对于精简调度器,最简单的规避方法是:尽量让任务之间不共享资源,或者共享资源只用关中断保护,不用互斥量。
如果确实需要共享资源,可以用一个折中方案:在访问共享资源时临时提升当前任务的优先级到最高,访问完再恢复。这样低优先级任务持有资源时,不会被中等优先级任务抢占,高优先级任务等待的时间就可控了。实现上就是在临界区前后修改current_task->priority,然后触发一次调度。
void mutex_lock(void) { __disable_irq(); uint8_t old_prio = current_task->priority; current_task->priority = 0; ready_list[0] = current_task; ready_list[old_prio] = NULL; __enable_irq(); while (mutex_held) { task_yield(); } mutex_held = 1; current_task->priority = old_prio; }这段代码只是示意,实际实现要考虑嵌套锁、超时等问题。但对于大多数裸机项目,共享资源用关中断保护就够了,不需要上互斥量。
7. 调度器的扩展方向与性能优化
7.1 加入任务延时功能的实现思路
任务延时是RTOS的基本功能,实现起来也不复杂。在TCB里加一个delay_ticks字段,任务调用task_delay(ms)时把当前任务状态改成阻塞,设置delay_ticks = ms,然后触发PendSV切换出去。SysTick中断里遍历所有任务,把阻塞任务的delay_ticks减1,减到0就改成就绪状态。
void SysTick_Handler(void) { for (int i = 0; i < task_count; i++) { if (tcb_pool[i].state == TASK_BLOCKED && tcb_pool[i].delay_ticks > 0) { tcb_pool[i].delay_ticks--; if (tcb_pool[i].delay_ticks == 0) { tcb_pool[i].state = TASK_READY; ready_list[tcb_pool[i].priority] = &tcb_pool[i]; } } } SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; }这个实现有个小问题:SysTick中断里遍历所有任务,任务多了会浪费时间。优化方法是把阻塞任务挂到一个按延时排序的链表上,每次只检查链表头部。不过对于四五个任务的系统,遍历的开销可以忽略。
7.2 空闲任务的必要性与低功耗处理
空闲任务是一个优先级最低的任务,当所有其他任务都阻塞时,调度器就运行空闲任务。空闲任务里可以放低功耗指令,比如__WFI(),让CPU进入睡眠模式,等中断唤醒。这样在没有任务需要运行时,功耗能从几十毫安降到几毫安。
void idle_task(void) { while (1) { __WFI(); } }__WFI()是Wait For Interrupt指令,执行后CPU停止时钟,直到任何中断发生才唤醒。SysTick中断会周期性唤醒CPU,检查有没有任务需要运行。如果没有,继续睡。这个机制在电池供电的项目里特别有用。
7.3 调度器代码体积和切换开销的实测数据
在Keil里开最高优化(-O3),调度器核心代码编译出来大概1.2KB Flash。其中PendSV汇编占几十字节,调度函数占两百多字节,任务创建和初始化占三百多字节。RAM占用方面,每个TCB占16字节,每个任务栈占512字节到1KB,4个任务总共约3KB RAM。
上下文切换开销实测:从SysTick触发PendSV到新任务开始执行,在72MHz主频下大约需要1.5微秒。这个时间包括异常进入、压栈、调度决策、出栈、异常返回。相比FreeRTOS的切换开销(大约2微秒),自己写的调度器因为省去了一些安全检查,稍微快一点。但差距不大,实际项目里这点差异可以忽略。
7.4 什么时候该换用成熟的RTOS
自己写的调度器适合任务少、功能简单、资源紧张的项目。但如果项目需要信号量、消息队列、事件标志组、内存管理这些功能,自己实现的工作量就太大了,而且容易出bug。这时候应该换FreeRTOS或者RT-Thread Nano。FreeRTOS在STM32F103C8T6上跑,内核占6KB Flash和1KB RAM左右,加上应用代码,64KB Flash和20KB RAM基本够用。
判断标准很简单:如果任务间同步只用关中断就能解决,自己写调度器没问题;如果需要复杂的同步机制,或者任务数量超过8个,直接上RTOS。我个人的经验是,学习阶段自己写一遍调度器,把Cortex-M3的异常机制、栈操作、汇编交互都搞明白,之后再用RTOS就会觉得非常清晰,遇到问题也能从底层去分析。这个学习过程的价值,比调度器本身能不能用在项目里更重要。