
1. 从一个最小系统板说起为什么要自己写调度器手头这块STM32F103C8T6最小系统板估计是很多人抽屉里都躺着的一块板子。72MHz主频、64KB Flash、20KB SRAM价格便宜到可以当耗材用。大部分人拿它跑裸机程序一个while(1)大循环加上几个中断项目也就做完了。但当你需要同时处理按键扫描、OLED刷新、串口收发、传感器采样这些任务时裸机轮询的弊端就暴露出来了——某个任务稍微耗时其他任务全部卡住响应变得一塌糊涂。这时候你可能会想到上RTOSFreeRTOS、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上。如果中断服务程序里触发了PendSVPendSV处理程序会手动保存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必须置1Thumb状态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环境基于标准库建立工程模板。如果你习惯用寄存器操作直接操作寄存器也可以但标准库能省去很多查手册的时间。首先配置SysTickvoid 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参数是两次中断之间的时钟数。系统时钟72MHz1ms中断就是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 常见问题速查表现象可能原因排查方法第一次调度就HardFaultxPSR 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能不能手动触发最后测试完整的任务切换。分步调试比一次性写完再调效率高得多。