1. 从一个最小系统板说起:为什么要自己写调度器
手头这块STM32F103C8T6最小系统板,估计是很多人抽屉里都躺着的一块板子。72MHz主频、64KB Flash、20KB SRAM,价格便宜到可以当耗材用。大部分人拿它跑裸机程序,一个while(1)大循环加上几个中断,项目也就做完了。但当你需要同时处理按键扫描、OLED刷新、串口收发、传感器采样这些任务时,裸机轮询的弊端就暴露出来了——某个任务稍微耗时,其他任务全部卡住,响应变得一塌糊涂。
这时候你可能会想到上RTOS,FreeRTOS、RT-Thread确实成熟稳定。但有没有想过,自己动手写一个抢占式调度器?不是为了替代RTOS,而是为了真正搞明白Cortex-M3内核的任务切换到底是怎么发生的。PSP、MSP、PendSV、SysTick这几个关键词,在FreeRTOS的移植文件里天天见,但真正理解它们协同工作的人并不多。
这篇文章就是记录我从零开始,在STM32F103C8T6上手写一个抢占式调度器的完整过程。核心思路很清晰:用SysTick做时间基准,用PendSV做任务切换,用PSP给任务提供独立的栈空间,用MSP给内核和中断服务程序使用。最终实现的效果是——多个任务各自拥有独立的栈,SysTick中断触发调度,PendSV完成上下文切换,任务之间互不干扰,高优先级任务可以抢占低优先级任务。
适合谁来参考?如果你已经能用标准库或者寄存器操作点亮LED、配置中断,对Cortex-M3的寄存器有基本了解,那这篇文章就是写给你的。如果你连NVIC是什么都还不清楚,建议先把中断那部分补一补再来看。
2. 调度器的整体设计思路与关键选型
2.1 为什么选Cortex-M3的硬件机制而不是纯软件模拟
抢占式调度器的核心难点在于“上下文切换”——保存当前任务的运行状态,恢复下一个任务的运行状态。纯软件模拟的方式需要手动保存所有通用寄存器,不仅代码量大,而且容易出错。Cortex-M3内核在设计时就考虑到了RTOS的需求,提供了几个关键的硬件机制:
- 双栈指针:MSP(主栈指针)和PSP(进程栈指针)。中断和异常处理默认使用MSP,任务代码使用PSP。这样任务栈和内核栈天然隔离,任务栈溢出不会直接冲掉内核数据。
- PendSV异常:可挂起的系统服务异常,优先级可编程。它最大的特点是“如果正在处理更高优先级的异常,PendSV会延迟执行”,这正好满足上下文切换的需求——不能在中断嵌套中间切换任务。
- SysTick定时器:24位递减计数器,专门为RTOS提供时间基准。配置成固定周期中断,每次中断检查是否需要调度。
这三个机制配合起来,上下文切换的代码可以精简到几十行汇编。如果纯软件模拟,光是保存R0-R12、LR、PC、xPSR这些寄存器就要写一大堆,而且还要处理中断嵌套的情况,复杂度成倍增加。
2.2 任务栈的分配策略:PSP怎么用
每个任务需要独立的栈空间。在Cortex-M3上,任务运行时使用PSP,中断处理时自动切换到MSP。这意味着任务栈只需要考虑任务本身的局部变量、函数调用开销,以及任务被中断时硬件自动压栈的那部分内容。
具体来说,当一个任务正在运行,SysTick中断触发,硬件会自动把xPSR、PC、LR、R12、R3、R2、R1、R0这8个寄存器压入当前使用的栈——也就是PSP指向的任务栈。然后中断服务程序运行在MSP上。如果中断服务程序里触发了PendSV,PendSV处理程序会手动保存R4-R11这8个寄存器到任务栈,然后切换PSP到下一个任务的栈,再手动恢复R4-R11,最后异常返回时硬件自动弹出之前压入的8个寄存器。
所以每个任务的栈大小至少需要能容纳:8个硬件自动压栈的寄存器 + 8个手动保存的寄存器 + 任务本身的局部变量和函数调用深度。对于STM32F103C8T6只有20KB SRAM的情况,我一般给每个任务分配256字节到512字节的栈空间,具体看任务里有没有大数组或者深递归。
2.3 优先级与调度策略的取舍
抢占式调度器的核心是“高优先级任务就绪时立即抢占低优先级任务”。实现方式有两种:一种是每个SysTick中断都检查所有任务,找到最高优先级的就绪任务;另一种是维护一个就绪表,用位图或者链表快速定位最高优先级任务。
考虑到STM32F103C8T6的资源有限,我选择了固定优先级加就绪表的方案。最多支持8个任务,优先级0-7,数值越小优先级越高。就绪表用一个字节表示,每一位对应一个任务,1表示就绪。调度时用CLZ指令(Count Leading Zeros)快速找到最高优先级任务,不需要循环遍历。
这种方案的优点是调度时间恒定,不受任务数量影响。缺点是优先级数量有限,但对于最小系统板上的应用来说,8个任务已经绰绰有余了。
3. 核心细节解析与实操要点
3.1 任务控制块的设计
每个任务需要一个任务控制块(TCB)来保存关键信息。我定义的结构体如下:
typedef struct { uint32_t *stack_ptr; // 当前栈指针 uint32_t stack_base; // 栈底地址(用于栈溢出检测) uint32_t stack_size; // 栈大小 uint8_t priority; // 优先级 0-7 uint8_t state; // 任务状态:就绪、运行、阻塞 uint32_t delay_ticks; // 延时计数器 char name[8]; // 任务名 } tcb_t;stack_ptr是核心字段,它始终指向任务栈中保存的上下文位置。当任务被切换出去时,PendSV会把当前PSP保存到这个字段;当任务被切换进来时,PendSV从这个字段恢复PSP。
stack_base和stack_size用于栈溢出检测。在任务栈的底部填充一个魔术数(比如0xDEADBEEF),调度时检查这个值是否被改写,就能判断是否发生了栈溢出。这个技巧在调试阶段非常有用,我后面会详细说。
3.2 任务栈的初始化
创建一个任务时,需要手动构造它的初始栈帧,让第一次调度到这个任务时,硬件自动弹出的寄存器值正好能让它从指定的函数开始执行。
具体操作是:在任务栈的顶部预留出硬件自动压栈的8个寄存器和手动保存的8个寄存器空间,然后填入初始值。关键点在于:
- PC填入任务函数的入口地址
- xPSR的bit24必须置1(Thumb状态)
- LR填入一个“任务退出”函数的地址,防止任务函数返回后跑飞
- R12、R3、R2、R1、R0可以填0,或者作为任务函数的参数传递
初始化完成后,stack_ptr指向保存R4-R11的位置。这样第一次PendSV切换到这个任务时,手动恢复R4-R11,然后异常返回,硬件自动弹出R0-R3、R12、LR、PC、xPSR,任务就开始执行了。
3.3 PendSV处理程序的编写要点
PendSV处理程序是整个调度器最核心的部分,必须用汇编编写。流程如下:
- 读取当前PSP值(因为任务运行在PSP上)
- 手动压栈R4-R11到当前任务栈
- 把当前PSP保存到当前任务的TCB
- 调用C函数选择下一个任务
- 从下一个任务的TCB恢复PSP
- 手动出栈R4-R11
- 设置PSP为恢复后的值
- 异常返回
这里有几个容易踩坑的地方:
注意:PendSV处理程序必须设置为最低优先级。如果PendSV优先级高于某个中断,那么在那个中断处理过程中触发的PendSV会立即执行,导致上下文切换发生在中断嵌套中间,栈状态会混乱。
注意:在PendSV中调用C函数选择下一个任务时,要确保这个C函数不会使用浮点运算或者调用其他可能触发异常的函数。最安全的做法是把选择逻辑写得极其简单,只操作全局变量。
3.4 SysTick中断的处理
SysTick中断负责两件事:一是给延时任务递减计数器,二是触发PendSV进行调度。
void SysTick_Handler(void) { // 递减所有延时任务的计数器 for (int i = 0; i < MAX_TASKS; i++) { if (task_table[i].state == TASK_DELAY && task_table[i].delay_ticks > 0) { task_table[i].delay_ticks--; if (task_table[i].delay_ticks == 0) { task_table[i].state = TASK_READY; ready_bitmap |= (1 << i); } } } // 触发PendSV SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; }SysTick的周期我设置为1ms,这样延时精度就是1ms。对于大多数应用足够了。如果需要更精细的时间管理,可以把SysTick周期改小,但中断频率会相应提高,CPU开销也会增加。
4. 实操过程与核心环节实现
4.1 工程搭建与关键寄存器配置
我使用的是Keil MDK环境,基于标准库建立工程模板。如果你习惯用寄存器操作,直接操作寄存器也可以,但标准库能省去很多查手册的时间。
首先配置SysTick:
void systick_init(uint32_t ticks) { SysTick->LOAD = ticks - 1; SysTick->VAL = 0; SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk; }ticks参数是两次中断之间的时钟数。系统时钟72MHz,1ms中断就是72000。
然后配置PendSV和SysTick的优先级。在Cortex-M3中,优先级数值越大,实际优先级越低。所以PendSV要设置为最低优先级:
NVIC_SetPriority(PendSV_IRQn, 0xFF); NVIC_SetPriority(SysTick_IRQn, 0x00);这里SysTick设置为最高优先级,保证时间基准的准确性。PendSV设置为最低,确保所有中断处理完成后才进行任务切换。
4.2 第一个任务的创建与启动
创建任务的函数需要完成以下工作:
- 从任务栈数组里分配一块空间
- 初始化栈帧
- 设置TCB的各个字段
- 把任务加入就绪表
int task_create(void (*task_func)(void), uint8_t priority, uint32_t *stack, uint32_t stack_size, const char *name) { // 找到空闲的TCB int id = find_free_tcb(); if (id < 0) return -1; // 栈顶对齐到8字节 uint32_t *top = (uint32_t *)((uint32_t)(stack + stack_size) & ~0x7); // 预留硬件自动压栈的8个寄存器 top -= 8; top[0] = 0x01000000; // xPSR, Thumb位 top[1] = (uint32_t)task_func; // PC top[2] = (uint32_t)task_exit; // LR top[3] = 0; // R12 top[4] = 0; // R3 top[5] = 0; // R2 top[6] = 0; // R1 top[7] = 0; // R0 // 预留手动保存的R4-R11 top -= 8; for (int i = 0; i < 8; i++) top[i] = 0; // 初始化TCB task_table[id].stack_ptr = top; task_table[id].stack_base = (uint32_t)stack; task_table[id].stack_size = stack_size; task_table[id].priority = priority; task_table[id].state = TASK_READY; task_table[id].delay_ticks = 0; strncpy(task_table[id].name, name, 7); ready_bitmap |= (1 << id); return id; }启动调度器的函数需要做几件事:设置PSP为第一个任务的栈指针,切换到PSP模式,然后触发PendSV。
void scheduler_start(void) { // 找到最高优先级任务 int first = find_highest_priority(); current_task = first; // 设置PSP为第一个任务的栈指针 __set_PSP((uint32_t)task_table[first].stack_ptr); // 切换到使用PSP __set_CONTROL(0x02); __ISB(); // 触发PendSV,开始第一次任务切换 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 开中断 __enable_irq(); // 调度器启动后,主函数变成空闲任务 while (1) { __WFI(); } }4.3 PendSV汇编实现细节
PendSV处理程序用汇编编写,放在启动文件或者单独的汇编文件里:
PendSV_Handler: MRS R0, PSP CBZ R0, PendSV_NoSave STMDB R0!, {R4-R11} LDR R1, =current_task LDR R1, [R1] LDR R2, =task_table STR R0, [R2, R1, LSL #2] PendSV_NoSave: PUSH {LR} BL schedule_next POP {LR} LDR R1, =current_task LDR R1, [R1] LDR R2, =task_table LDR R0, [R2, R1, LSL #2] LDMIA R0!, {R4-R11} MSR PSP, R0 ORR LR, LR, #0x04 BX LR这段代码的关键点:
MRS R0, PSP读取当前任务栈指针CBZ R0, PendSV_NoSave处理第一次调度时PSP为0的情况STMDB R0!, {R4-R11}手动保存R4-R11到任务栈schedule_next是C函数,选择下一个任务并更新current_taskLDMIA R0!, {R4-R11}从新任务栈恢复R4-R11MSR PSP, R0更新PSPORR LR, LR, #0x04确保异常返回后使用PSP
实操心得:
task_table的索引方式我用了[R2, R1, LSL #2],因为每个TCB结构体指针占4字节。如果你的TCB结构体更大,需要调整这个偏移量。我建议把stack_ptr放在TCB结构体的第一个字段,这样索引计算最简单。
4.4 任务延时与阻塞的实现
任务延时不能简单地用循环等待,那样会浪费CPU。正确的做法是把任务状态设为阻塞,从就绪表移除,然后触发调度。
void task_delay(uint32_t ticks) { __disable_irq(); task_table[current_task].delay_ticks = ticks; task_table[current_task].state = TASK_DELAY; ready_bitmap &= ~(1 << current_task); __enable_irq(); // 触发PendSV,切换到其他任务 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; __DSB(); __ISB(); }SysTick中断里递减delay_ticks,减到0时把任务重新加入就绪表。这样延时期间CPU可以运行其他任务,不会空转。
5. 常见问题与排查技巧实录
5.1 任务第一次运行就HardFault
这是最常见的问题,十有八九是栈帧初始化错了。重点检查三个地方:
- xPSR的Thumb位:必须确保bit24为1,否则异常返回时CPU会认为要切换到ARM状态,直接HardFault。
- 栈对齐:Cortex-M3要求栈8字节对齐。如果栈顶没有对齐到8字节边界,硬件压栈时可能触发对齐错误。
- PC地址:任务函数的地址必须是奇数(Thumb模式),如果写成偶数,同样会HardFault。
我当时的排查方法是:在HardFault_Handler里把LR、PC、xPSR打印出来,对照手册看是哪一步出了问题。后来发现是栈顶没有对齐,stack + stack_size算出来的地址是4字节对齐但不是8字节对齐,导致硬件压栈时出错。
5.2 任务切换后跑飞或者卡死
如果任务能启动但切换几次后就跑飞,大概率是PendSV处理程序有问题。检查以下几点:
- PSP保存和恢复是否配对:每次保存R4-R11后,PSP应该指向保存后的位置;恢复时从保存的位置读取,然后更新PSP。
- current_task变量是否被正确更新:
schedule_next函数必须更新current_task,否则PendSV会恢复错误的栈。 - 中断嵌套时PendSV是否被延迟:如果PendSV优先级不是最低,可能在中断处理中间执行,导致栈状态混乱。
我遇到过一次问题是schedule_next函数里用了浮点运算,编译器生成了需要保存FPU寄存器的代码,但STM32F103没有FPU,直接HardFault。后来把schedule_next改成纯整数运算就好了。
5.3 栈溢出导致数据被改写
STM32F103C8T6只有20KB SRAM,如果任务栈分配不当,很容易溢出。栈溢出最隐蔽的问题是:任务A的栈溢出后改写了任务B的栈数据,但任务B可能过很久才运行,到时候才发现数据不对,排查起来非常困难。
我的做法是在每个任务栈的底部填充魔术数:
#define STACK_MAGIC 0xDEADBEEF // 创建任务时 for (int i = 0; i < 4; i++) { stack[i] = STACK_MAGIC; } // 调度时检查 void check_stack_overflow(void) { for (int i = 0; i < MAX_TASKS; i++) { if (task_table[i].state != TASK_UNUSED) { uint32_t *base = (uint32_t *)task_table[i].stack_base; if (base[0] != STACK_MAGIC || base[1] != STACK_MAGIC) { // 栈溢出,点亮错误LED或者打印任务名 printf("Stack overflow: %s\n", task_table[i].name); } } } }这个检查可以放在SysTick中断里,每100ms检查一次,开销很小但能及早发现问题。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 第一次调度就HardFault | xPSR Thumb位未置1 | 检查栈帧中xPSR的值 |
| 任务切换几次后卡死 | PendSV优先级不是最低 | 检查NVIC优先级配置 |
| 任务运行结果不对 | 栈溢出改写了其他任务数据 | 检查栈底魔术数是否被改写 |
| 延时函数不生效 | SysTick中断未使能 | 检查SysTick->CTRL寄存器 |
| 串口打印乱码 | 任务栈太小导致printf溢出 | 增大栈空间或改用轻量打印 |
| 中断响应变慢 | SysTick优先级太低 | 提高SysTick优先级 |
避坑技巧:调试调度器时,建议先用两个最简单的任务——一个翻转LED,一个串口打印。确认这两个任务能正常切换后,再逐步增加任务数量和复杂度。不要一上来就创建五六个任务,出了问题根本不知道是哪个环节的错。
6. 调度器的扩展与优化方向
6.1 支持任务优先级动态调整
目前的实现是固定优先级,任务创建后优先级不能改。如果需要动态调整,可以在TCB里增加一个base_priority字段,然后实现优先级继承或者优先级天花板协议。不过对于STM32F103C8T6这种资源受限的平台,固定优先级已经能满足大多数场景,动态调整反而会增加调度器的复杂度和不确定性。
6.2 增加信号量和互斥量
任务之间需要同步时,信号量是必不可少的。实现一个简单的信号量只需要一个计数器和一个等待队列。获取信号量时如果计数器为0,就把当前任务加入等待队列并阻塞;释放信号量时如果有任务在等待,就把最高优先级的等待任务唤醒。
typedef struct { int count; uint8_t wait_list; // 等待任务位图 } sem_t; void sem_wait(sem_t *sem) { __disable_irq(); if (sem->count > 0) { sem->count--; __enable_irq(); } else { sem->wait_list |= (1 << current_task); task_table[current_task].state = TASK_BLOCKED; ready_bitmap &= ~(1 << current_task); __enable_irq(); SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; } }这个实现虽然简单,但已经能满足基本的同步需求。需要注意的是,信号量操作必须关中断保护,否则在判断count和修改count之间可能被SysTick中断打断,导致竞态条件。
6.3 空闲任务的低功耗处理
当所有任务都阻塞时,调度器会切换到空闲任务。空闲任务里可以执行WFI指令让CPU进入睡眠模式,等SysTick中断唤醒。这样在任务空闲时功耗可以降到很低。
void idle_task(void) { while (1) { __WFI(); } }不过要注意,如果SysTick中断频率是1ms,那CPU每1ms就会被唤醒一次,平均功耗还是有的。如果对功耗要求极高,可以把SysTick周期改长,或者用RTC唤醒。
6.4 栈使用量的精确统计
调试阶段可以用栈填充法统计每个任务实际用了多少栈空间。创建任务时把整个栈填充为0xAA,运行一段时间后从栈底往上数,看多少个0xAA被改写了,就知道栈的实际使用峰值。
uint32_t stack_usage(int task_id) { uint32_t *base = (uint32_t *)task_table[task_id].stack_base; uint32_t size = task_table[task_id].stack_size / 4; uint32_t used = 0; for (uint32_t i = 0; i < size; i++) { if (base[i] != 0xAAAAAAAA) { used = size - i; break; } } return used * 4; }这个数据对优化栈大小非常有用。我实测下来,一个只做LED翻转和简单运算的任务,128字节栈就够了;带printf的任务需要至少256字节;带浮点运算或者大数组的任务需要512字节以上。
7. 实测效果与性能数据
在STM32F103C8T6上,72MHz主频,SysTick周期1ms,我创建了4个任务:LED闪烁、串口打印、按键扫描、传感器模拟。实测数据如下:
- 上下文切换时间:约1.2微秒(从PendSV触发到新任务开始执行)
- SysTick中断处理时间:约0.8微秒(4个任务遍历)
- 最大任务数:8个(受就绪表位图限制)
- RAM占用:调度器本身约200字节,每个任务TCB约32字节
- Flash占用:调度器代码约1.5KB
这个性能对于STM32F103C8T6来说完全够用。1.2微秒的切换时间意味着每秒可以切换80万次,实际应用中每秒切换几百次就很多了,CPU开销不到0.1%。
对比FreeRTOS,自己写的调度器在功能上肯定不如,但代码量小、可读性强、没有黑盒。对于学习Cortex-M3的任务切换机制来说,自己动手写一遍比看十遍FreeRTOS源码都管用。
8. 几个容易忽略的细节
第一个细节是中断中的任务切换。如果在中断服务程序里调用了task_delay或者释放了信号量,需要确保PendSV在中断返回后执行。Cortex-M3的机制是:如果PendSV被挂起,且当前没有更高优先级的异常在处理,PendSV会在中断返回后立即执行。所以只要PendSV优先级最低,这个行为是自动保证的。
第二个细节是临界区的嵌套。__disable_irq()和__enable_irq()不能嵌套使用,如果在一个临界区里又调用了另一个临界区,内层的__enable_irq()会提前打开中断。正确的做法是用一个全局变量记录嵌套深度,只有深度为0时才真正开中断。
第三个细节是任务退出后的处理。如果任务函数返回了,会跳到task_exit函数。这个函数应该把任务状态设为TASK_UNUSED,从就绪表移除,然后触发调度。如果不处理,任务返回后会执行栈上的随机数据,必死无疑。
第四个细节是PSP的初始值。在scheduler_start之前,PSP的值是未定义的。第一次PendSV处理时,MRS R0, PSP读到的可能是0或者随机值。所以PendSV处理程序里必须有CBZ R0, PendSV_NoSave这样的判断,第一次调度时跳过保存步骤。
这些细节在写代码的时候很容易忽略,但每一个都可能导致系统跑飞。我的建议是每写一个模块就单独测试,确认没问题再集成。比如先测试SysTick中断能不能正常触发,再测试PendSV能不能手动触发,最后测试完整的任务切换。分步调试比一次性写完再调效率高得多。