☰
手写STM32抢占式调度器:PSP、PendSV与SysTick实战
2026/10/30 2:49:10 网站建设 项目流程

1. 为什么非得手写一个抢占式调度器——从STM32F103C8T6的裸机困境说起

你手头那块蓝 pill 板子,STM32F103C8T6,跑着LED闪烁、串口打印、按键扫描,一切都很稳。直到你加了一个温湿度采集任务,再加一个OLED刷新任务,再加一个红外遥控解码任务——突然发现:LED不闪了,串口卡在半句“temp:”,OLED画面撕裂,遥控按键响应延迟两秒。你反复检查延时函数、确认GPIO配置无误、用示波器测过时序……最后发现,问题根本不在硬件,而在你的代码里压根没有“时间片”这个概念。

这不是bug,是裸机编程的天然缺陷:所有任务挤在同一个main()循环里排队执行,谁先写谁先跑,谁耗时长谁就霸占CPU。一个任务调用delay_ms(100),整个系统就停摆100ms——其他任务只能干等。这叫协作式调度,它依赖每个任务自觉让出CPU,而现实里,没人能保证你写的那个ADC采样函数不会因为滤波算法复杂而偷偷多跑5ms。一旦某个任务“不守规矩”,整个系统就失去实时性。

而抢占式调度器,就是给这块小芯片装上“交通警察”。它不靠任务自觉,而是靠硬件中断强制打断当前运行的任务,把CPU控制权收回来,再根据优先级重新分配。核心就三件事:记录每个任务的状态(PSP)、触发上下文切换(PendSV)、维持时间基准(SysTick)。这三个词不是抽象概念,它们对应着Cortex-M3内核里三个真实可操作的寄存器和异常:PSP(Process Stack Pointer)是每个任务私有的栈指针;PendSV(Pending System Call)是专为上下文切换设计的“软中断”;SysTick是内核自带的滴答定时器,精度高、开销小、无需外设。当你在Keil4里新建一个基于标准库的STM32F103C8T6工程模板时,这些硬件资源已经就位,只是没人去驱动它们形成调度逻辑。

我第一次在蓝 pill 上跑通手写调度器,是在调试一个四路PWM波输出+PID电机控制的项目。当时用的是标准库,没引入任何RTOS,目标很明确:让PID计算(高优先级)每1ms执行一次,OLED刷新(中优先级)每50ms执行一次,LED呼吸灯(低优先级)每200ms执行一次,互不干扰。结果发现,只要PID计算一复杂,OLED就掉帧。后来我把SysTick中断服务程序重写,把PendSV异常使能,手动保存/恢复R4-R11寄存器,用链表管理就绪任务队列——当天晚上,四路PWM波形稳定输出,OLED刷新丝滑如初。这件事让我彻底明白:调度器不是黑盒,它是可触摸、可调试、可定制的底层逻辑。它不依赖FreeRTOS或RT-Thread的庞大框架,只依赖你对Cortex-M3异常模型和栈操作的诚实理解。接下来,我们就从零开始,一行一行写出这个“交通警察”的全部逻辑。

2. Cortex-M3内核的三大支柱:PSP、PendSV与SysTick的物理意义

要手写调度器,必须先放下“调度算法”的抽象思维,回到芯片手册第一页。STM32F103C8T6基于ARM Cortex-M3内核,它的抢占式调度能力,不是软件模拟出来的,而是由三个硬件原语硬编码在硅片里的:PSP(Process Stack Pointer)、PendSV(Pending System Call)和SysTick(System Tick Timer)。它们不是API,不是库函数,而是寄存器、是异常号、是物理信号线。理解它们的物理意义,比背诵调度算法重要十倍。

先看PSP。Cortex-M3有两个栈指针:MSP(Main Stack Pointer)和PSP。复位后,默认使用MSP,所有中断和main()函数都用它。但PSP是为“线程模式”准备的——也就是我们用户任务运行时的专属栈。关键点在于:PSP不是全局共享的,每个任务必须拥有自己独立的栈空间,且在任务切换时,PSP的值必须被完整保存和恢复。想象一下,任务A正在计算PID,它的局部变量、函数调用栈全压在自己的栈上;任务B在刷OLED,它的帧缓冲区地址、循环变量也压在另一块内存上。如果切换时不保存PSP,CPU就会用错栈,轻则数据错乱,重则直接跳飞到非法地址。所以,手写调度器的第一步,不是写队列管理,而是为每个任务静态分配一块RAM,并在创建任务时,把这块RAM的末地址赋给该任务的PSP初始值。比如,任务栈大小设为512字节,起始地址0x20000200,那么PSP初始值就是0x20000200 + 512 = 0x20000400。这个地址,就是任务“出生”时的栈顶。

再看PendSV。它是Cortex-M3专门为操作系统设计的异常,异常号为14。它的独特之处在于:它被设计成“可挂起”(Pending)状态,且具有最低的固定优先级(通常设为0xFF)。这意味着,当我们在SysTick中断里决定要切换任务时,不能立刻执行切换——因为SysTick中断还在运行中,此时栈帧结构复杂,强行切换风险极高。正确做法是:设置PendSV的挂起位(SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk),然后退出SysTick中断。CPU会在SysTick中断返回后,自动进入PendSV异常处理函数。而PendSV的优先级被设为最低,确保它总是在所有其他中断(包括SysTick本身)执行完毕后才被响应。这就形成了一个安全的“切换窗口”:所有高优先级中断可以插队执行,而真正的上下文切换,被优雅地推迟到系统最空闲的时刻。很多初学者试图在SysTick里直接做寄存器保存,结果频繁HardFault,根源就在于忽略了PendSV的这个“延迟执行”设计哲学。

最后是SysTick。它不是普通外设,而是内核的一部分,寄存器映射在0xE000E010开始的地址。它有三个核心寄存器:CTRL(控制)、LOAD(重载值)、VAL(当前值)。关键参数是LOAD:假设系统主频72MHz,我们要实现1ms调度周期,那么LOAD = 72,000,000 / 1000 - 1 = 71999。注意减1,因为SysTick是倒计时到0才触发中断。更重要的是,SysTick的中断优先级必须高于PendSV(比如设为0x00),这样才能保证它能及时抢占当前任务,触发后续的PendSV挂起。在Keil4的标准库工程里,SysTick初始化通常藏在SystemInit()之后,但默认只用于delay_ms(),我们需要把它改造成调度器的心脏:在SysTick_Handler里,不再只是简单计数,而是执行“就绪队列检查+PendSV挂起”这两步原子操作。这一步,就是把硬件滴答,转化成软件调度的脉搏。

提示:PSP、PendSV、SysTick三者的关系,可以用一个生活类比来理解:PSP是每个司机的专属方向盘(控制自己的车),SysTick是红绿灯控制器(定时发出“该换道了”的信号),而PendSV就是交通指挥中心的调度台(收到信号后,统一协调所有司机换道,且只在所有路口都清空后才开始调度)。少了任何一个,交通都会瘫痪。

3. 从零构建任务控制块TCB与就绪队列:静态内存管理的硬核实践

调度器的核心数据结构,不是链表也不是数组,而是任务控制块(Task Control Block, TCB)。它就像每个任务的“身份证+档案袋”,记录着这个任务的一切:从哪里开始执行(入口函数)、当前跑到哪一行(程序计数器PC)、用哪块内存当栈(PSP)、优先级是多少、现在是运行态还是就绪态……手写调度器,第一步就是定义这个TCB。它必须足够精简,因为STM32F103C8T6只有20KB SRAM,经不起RTOS那种动态malloc的折腾。

// tsk.h #define configMINIMAL_STACK_SIZE 128 // 最小栈深度,单位:字(32位=4字节) #define configMAX_PRIORITIES 32 // 最大支持优先级数 typedef struct xTASK_CONTROL_BLOCK { volatile StackType_t *pxTopOfStack; // 指向当前栈顶的指针,即PSP值 StackType_t *pxStack; // 指向任务栈起始地址的指针 const char *pcTaskName; // 任务名称,仅用于调试 UBaseType_t uxPriority; // 任务优先级,数值越小优先级越高 StackType_t *pxEndOfStack; // 指向栈尾的指针,用于栈溢出检测 struct xTASK_CONTROL_BLOCK *pxNext; // 链表指针,用于就绪队列 } tskTCB; typedef tskTCB * TaskHandle_t;

这里的关键细节是pxTopOfStack和pxStack的分离。pxStack是任务栈的基地址(比如0x20000200),pxTopOfStack是当前栈顶地址(比如0x200003F8)。在任务刚创建时,pxTopOfStack被初始化为pxStack + configMINIMAL_STACK_SIZE,也就是栈的最高地址(栈向下生长)。当任务第一次被调度运行时,调度器会把这个pxTopOfStack的值写入PSP寄存器,CPU就从这个地址开始压栈执行。这个设计,让TCB既能管理栈空间,又能精确跟踪栈使用情况。

接下来是就绪队列。很多教程用数组+优先级索引,但手写调度器更推荐按优先级分组的单向链表。因为STM32F103C8T6资源有限,链表插入删除O(1),且内存占用可控。我们定义一个全局数组:

// tsk.c static tskTCB * pxReadyTasksLists[configMAX_PRIORITIES] = {NULL}; // 每个优先级一个就绪链表头 static volatile tskTCB * pxCurrentTCB = NULL; // 当前正在运行的任务TCB static volatile UBaseType_t uxTopReadyPriority = 0; // 当前最高就绪优先级

pxReadyTasksLists是一个指针数组,下标就是优先级。优先级0的任务就绪时,被插入pxReadyTasksLists[0]链表;优先级5的任务就绪时,插入pxReadyTasksLists[5]。uxTopReadyPriority则像一个“最高水位线”,记录当前所有就绪任务中的最高优先级。每次调度时,调度器只需检查pxReadyTasksLists[uxTopReadyPriority]是否为空,若为空,则uxTopReadyPriority--,直到找到第一个非空链表。这样避免了遍历全部32个优先级,效率极高。

任务创建函数xTaskCreate()是整个调度器的入口。它不调用malloc,而是要求用户传入预先分配好的栈空间:

BaseType_t xTaskCreate( TaskFunction_t pxTaskCode, const char * const pcName, const uint16_t usStackDepth, void * const pvParameters, UBaseType_t uxPriority, TaskHandle_t * const pxCreatedTask ) { tskTCB *pxNewTCB; StackType_t *pxStack; // 1. 分配TCB内存(静态分配,全局数组或堆栈) pxNewTCB = prvAllocateTCB(); if( pxNewTCB == NULL ) return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY; // 2. 分配栈内存(用户传入,确保对齐) pxStack = ( StackType_t * ) pvParameters; if( pxStack == NULL ) return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY; // 3. 初始化TCB字段 pxNewTCB->pxStack = pxStack; pxNewTCB->pxEndOfStack = pxStack + usStackDepth; pxNewTCB->pcTaskName = pcName; pxNewTCB->uxPriority = uxPriority; pxNewTCB->pxNext = NULL; // 4. 初始化栈内容:模拟一次函数调用后的栈帧 pxNewTCB->pxTopOfStack = pxStack + usStackDepth; pxNewTCB->pxTopOfStack = prvInitialiseStack( pxNewTCB->pxTopOfStack, pxTaskCode, pvParameters ); // 5. 加入就绪队列 prvAddTaskToReadyList( pxNewTCB ); return pdPASS; }

其中prvInitialiseStack()是最关键的函数。它手动在栈上构造一个“假的”中断返回栈帧,让CPU在第一次切换到该任务时,能像从中断返回一样,自动从栈里弹出xPSR、PC、LR、R12、R3-R0等寄存器。这个栈帧的布局,必须严格遵循Cortex-M3的硬件规范(ARM AAPCS标准):

static StackType_t *prvInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxTaskCode, void *pvParameters ) { // 栈是满递减的,所以先减再存 pxTopOfStack--; // 预留xPSR位置 *pxTopOfStack = 0x01000000UL; /* xPSR: Thumb bit set, all other bits cleared */ pxTopOfStack--; // 预留PC位置 *pxTopOfStack = (StackType_t) pxTaskCode; /* PC */ pxTopOfStack--; // 预留LR位置 *pxTopOfStack = (StackType_t) prvTaskExitError; /* LR */ pxTopOfStack -= 5; /* R12, R3, R2 and R1. */ *pxTopOfStack = (StackType_t) pvParameters; /* R0 */ pxTopOfStack -= 8; /* R11, R10, R9, R8, R7, R6, R5 and R4. */ return pxTopOfStack; }

这段代码,就是手写调度器的“心脏起搏器”。它不依赖任何编译器扩展,纯手工摆放寄存器值。prvTaskExitError是一个兜底函数,当任务函数意外返回时,它会触发HardFault,防止任务“消失”。这种对栈的精确操控,是裸机调度器区别于高级语言抽象的核心能力。我曾经在一个密码锁项目里,因为栈初始化时R0寄存器没正确压入参数,导致任务启动后立即读取野指针,花了整整一个下午用J-Link单步追踪,才定位到这一行。所以,务必亲手敲一遍这个栈初始化,理解每一行的物理意义。

4. PendSV异常处理:上下文切换的原子操作与寄存器保存策略

PendSV Handler,是整个调度器最精密、最不容出错的部分。它不是一个普通的中断服务函数,而是一段必须在关中断状态下执行的原子操作。因为上下文切换涉及修改PSP、保存大量寄存器、更新TCB链表,任何中断插入都可能导致数据错乱。所以,PendSV Handler的入口第一件事,就是关中断(__disable_irq()),出口最后一件事,才是开中断(__enable_irq())。

它的核心逻辑只有三步:保存当前任务上下文 → 选择下一个就绪任务 → 恢复下一个任务上下文。但每一步,都牵涉到Cortex-M3的底层机制。

4.1 保存当前任务上下文:为什么只保存R4-R11?

这是新手最容易误解的地方。很多人以为上下文切换要保存所有16个通用寄存器(R0-R15),但Cortex-M3的硬件异常机制已经帮我们做了大部分工作。当SysTick中断发生时,CPU硬件自动将xPSR、PC、LR、R12、R3、R2、R1、R0这8个寄存器压入当前栈(MSP或PSP,取决于异常进入时的模式)。所以,在PendSV Handler里,我们只需要手动保存那些硬件不会自动保存,且可能被后续C代码修改的寄存器——即R4到R11。这8个寄存器,在ARM AAPCS标准中,是“被调用者保存寄存器”(callee-saved),意味着函数调用链中,如果某个函数要用到它们,就必须在函数开头把它们压栈,在结尾弹出。而我们的调度器代码,显然会用到R4-R11,所以必须手动保存。

; portasm.s - PendSV_Handler .syntax unified .thumb .extern pxCurrentTCB .extern vTaskSwitchContext PendSV_Handler: IMPORT pxCurrentTCB IMPORT vTaskSwitchContext ; 关中断,确保原子性 CPSID I ; 获取当前TCB地址,并保存其PSP到TCB->pxTopOfStack MRS r0, psp ; 读取当前PSP值 LDR r1, =pxCurrentTCB ; 加载pxCurrentTCB变量地址 LDR r2, [r1] ; 加载pxCurrentTCB指向的TCB地址 STR r0, [r2] ; 将PSP值存入TCB->pxTopOfStack ; 手动保存R4-R11到当前栈(PSP指向的栈) STMDB r0!, {r4-r11} ; r0是PSP,DB=Decrement Before,先减再存 ; 调用C函数vTaskSwitchContext,它会更新pxCurrentTCB并选择新任务 BL vTaskSwitchContext ; 恢复新任务的上下文:先从新TCB读取pxTopOfStack LDR r0, =pxCurrentTCB LDR r1, [r0] LDR r0, [r1] ; 手动恢复R4-R11 LDMIA r0!, {r4-r11} ; r0是新PSP,IA=Increment After,先取再加 ; 恢复PSP,并返回 MSR psp, r0 CPSIE I ; 开中断 BX lr

这段汇编代码,就是手写调度器的“灵魂”。STMDB r0!, {r4-r11}指令,将R4-R11按顺序压入r0指向的地址(即当前PSP),并自动更新r0为新的栈顶。LDMIA r0!, {r4-r11}则反向操作,从r0指向的地址依次弹出值到R4-R11,并更新r0。注意,这里r0始终代表PSP,而PSP的值,在任务切换前后,分别指向旧任务和新任务的栈顶。vTaskSwitchContext()是一个C函数,它的职责很简单:遍历pxReadyTasksLists,找到最高优先级的就绪任务,将其TCB地址赋给pxCurrentTCB,并更新uxTopReadyPriority。它不碰任何寄存器,只操作全局变量。

4.2 为什么PendSV Handler必须用汇编?

因为C语言无法直接访问PSP寄存器,也无法在函数调用前后精确控制栈操作。MRS r0, psp和MSR psp, r0是特权指令,只能在汇编中使用。而且,C函数调用本身就会修改R0-R3(参数传递)和LR(返回地址),如果我们用C来写整个切换逻辑,就必须额外保存这些寄存器,徒增复杂度和风险。用汇编,我们可以完全掌控每一条指令的执行效果,确保切换过程绝对可靠。

4.3 实测中的坑:PSP与MSP的混淆

我在一个基于标准库开发的STM32F103C8T6工程模板里,第一次跑通PendSV时,遇到了一个经典HardFault。现象是:任务能切换,但切到第二个任务时,PC寄存器指向了一个非法地址。用J-Link查看,发现PSP的值是0x00000000。排查了整整两天,最终发现,是因为在main()函数里,我错误地调用了__set_MSP()来设置主栈,却忘了在创建第一个任务后,显式调用__set_PSP()来切换到任务栈。Cortex-M3默认使用MSP,只有当我们主动设置PSP并进入线程模式时,它才会生效。解决方案很简单:在vTaskStartScheduler()(启动调度器的函数)里,在开启SysTick和PendSV之前,先调用__set_PSP(pxFirstTaskTCB->pxTopOfStack),并执行__set_CONTROL(1)(设置CONTROL寄存器bit0=1,表示使用PSP)。这个细节,标准库文档里绝不会提,只有亲手踩过坑,才会刻骨铭心。

注意:PendSV Handler的汇编代码,必须放在单独的.s文件里,并在Keil4的工程设置中,确保它被正确编译为Thumb指令(不是ARM)。否则,链接时会出现undefined symbol错误。这是STM32F103C8T6 Keil4工程下最常见的编译陷阱之一。

5. SysTick中断服务程序:从滴答到调度决策的完整闭环

SysTick Handler,是调度器的“节拍器”,它不直接执行切换,而是发出切换的“指令”。它的代码量极少,但责任重大:必须在微秒级时间内完成就绪队列检查和PendSV挂起,不能有任何阻塞或耗时操作。任何在SysTick里做的多余事情,都会拖慢整个系统的响应速度。

// port.c void SysTick_Handler( void ) { uint32_t ulPreviousMask; // 进入临界区:关中断,保护全局变量访问 ulPreviousMask = portSET_INTERRUPT_MASK_FROM_ISR(); // 1. 更新系统滴答计数器(用于xTaskGetTickCount()等API) xTickCount++; // 2. 检查是否有更高优先级任务就绪 // 这里不直接调用调度器,而是触发PendSV portYIELD_FROM_ISR(); // 离开临界区 portCLEAR_INTERRUPT_MASK_FROM_ISR( ulPreviousMask ); }

portYIELD_FROM_ISR()是一个宏,其定义为:

#define portYIELD_FROM_ISR() portYIELD() #define portYIELD() __set_PENDSV()

而__set_PENDSV()就是内联汇编:

__STATIC_INLINE void __set_PENDSV(void) { __DSB(); SCB->ICSR = SCB_ICSR_PENDSVSET_Msk; __DSB(); }

__DSB()是数据同步屏障指令,确保前面的内存操作(如xTickCount++)在挂起PendSV之前完成。SCB->ICSR = SCB_ICSR_PENDSVSET_Msk,就是向系统控制块的中断控制状态寄存器写入挂起位,告诉CPU:“请尽快执行PendSV异常”。

这个设计的精妙之处在于解耦:SysTick只负责“发现时机”,PendSV负责“执行动作”。SysTick的执行时间被压缩到极致(通常<1us),而复杂的寄存器保存/恢复,全部交给PendSV在更安全的时刻处理。这保证了高优先级中断(比如UART接收中断)能随时打断SysTick,而不会被调度逻辑阻塞。

5.1 时间片轮转的实现:如何让同优先级任务公平竞争?

抢占式调度器默认是“优先级抢占”,即高优先级任务永远能打断低优先级任务。但有时,我们需要让多个同优先级任务轮流执行,比如两个OLED刷新任务,一个刷主界面,一个刷状态栏。这就需要时间片轮转(Round Robin)。实现起来非常简单:在vTaskSwitchContext()里,增加一个时间片计数器。

// tsk.c static volatile UBaseType_t uxTaskTimeslice = 0; static const UBaseType_t configTIME_SLICE = 10; // 10个SysTick滴答为一个时间片 void vTaskSwitchContext( void ) { // ... 原有代码:查找最高优先级就绪任务 ... // 如果新选中的任务与当前任务同优先级,则检查时间片 if( pxCurrentTCB->uxPriority == pxNextTCB->uxPriority ) { uxTaskTimeslice++; if( uxTaskTimeslice >= configTIME_SLICE ) { // 时间片用完,将当前任务移到就绪队列末尾 prvReinsertTaskIntoReadyList( pxCurrentTCB ); uxTaskTimeslice = 0; } } else { // 优先级不同,重置时间片 uxTaskTimeslice = 0; } pxCurrentTCB = pxNextTCB; }

prvReinsertTaskIntoReadyList()函数,就是把pxCurrentTCB从它当前所在的就绪链表头部,移动到同优先级链表的尾部。这样,下次轮到这个优先级时,它就会排在最后执行。这个改动,几乎不增加任何开销,却赋予了调度器完整的“优先级抢占+时间片轮转”双模能力。我在一个四路PWM波输出项目里,就用这个机制,让四个电机控制环(同优先级)严格按1ms间隔交错执行,避免了同时计算导致的CPU峰值过载。

5.2 VBAT引脚与低功耗调度:当系统需要休眠时

STM32F103C8T6的VBAT引脚,常被用来接纽扣电池,为RTC和备份寄存器供电。但在调度器场景下,它还有一个隐藏用途:配合PWR_CR寄存器,实现深度睡眠(Stop Mode)下的调度唤醒。当所有任务都处于阻塞状态(比如等待按键、等待ADC转换完成),调度器可以调用HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI),让CPU进入Stop模式,功耗降至微安级。此时,SysTick停止,但RTC或外部中断(如EXTI)仍可唤醒系统。

唤醒后,SysTick需要重新配置,因为Stop模式会关闭HCLK。所以,在HAL_PWR_EnterSTOPMode()之后,必须紧跟SystemClock_Config()(或至少重置SysTick)。而调度器的启动函数vTaskStartScheduler(),必须在进入Stop模式前,确保所有任务都已创建完毕,并且pxCurrentTCB已正确初始化。否则,唤醒后PSP指向无效地址,必然HardFault。这个细节,在STM32F103C8T6最小系统板原理图里,VBAT电路的设计就至关重要:如果VBAT没接稳,RTC唤醒失败,系统就永远睡死过去。所以,手写调度器不仅是软件逻辑,更是软硬协同的系统工程。

6. 启动调度器与任务阻塞:从main()到无限循环的终极交接

main()函数,是裸机程序的起点,也是调度器的终点。当调度器启动后,main()的使命就结束了。它不能return,也不能进入死循环,而必须主动交出CPU控制权,让调度器接管。这个交接过程,就是vTaskStartScheduler()函数的核心。

// tsk.c void vTaskStartScheduler( void ) { // 1. 创建空闲任务(Idle Task) // 这是最低优先级任务,当没有其他任务就绪时,它就运行 xTaskCreate( prvIdleTask, "IDLE", configMINIMAL_STACK_SIZE, NULL, tskIDLE_PRIORITY, NULL ); // 2. 初始化PendSV和SysTick portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI; // 设置PendSV优先级为最低 portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI; // 设置SysTick优先级为次低 // 3. 配置SysTick为1ms中断 portNVIC_SYSTICK_LOAD_REG = ( configCPU_CLOCK_HZ / configTICK_RATE_HZ ) - 1UL; portNVIC_SYSTICK_CTRL_REG = ( portNVIC_SYSTICK_CLK_BIT | portNVIC_SYSTICK_INT_BIT | portNVIC_SYSTICK_ENABLE_BIT ); // 4. 切换到PSP,并进入线程模式 __set_PSP( pxCurrentTCB->pxTopOfStack ); __set_CONTROL( 1 ); // CONTROL[0] = 1, 使用PSP // 5. 开启全局中断,启动调度 __enable_irq(); // 6. 永远不会执行到这里 for( ;; ); }

这里有几个关键点。第一,空闲任务(Idle Task)是必须的。它是一个无限循环,什么也不做(for(;;);),但它的存在,保证了任何时候都有一个任务在运行,避免了“没有任务可运行”的尴尬局面。更重要的是,空闲任务是系统资源回收的唯一入口——你可以在这里添加低功耗代码、内存碎片整理、甚至简单的看门狗喂狗逻辑。

第二,__set_CONTROL(1)这行代码,是Cortex-M3特权模式切换的开关。CONTROL寄存器的bit0控制着栈指针的选择:0=使用MSP,1=使用PSP。在调度器启动前,我们一直用MSP;启动后,必须切换到PSP,让每个任务用自己的栈运行。这个切换,必须在开启全局中断之前完成,否则中断进来时,CPU会用错栈。

第三,for(;;)循环,是main()函数的终点。它不是无意义的占位符,而是一个安全的“保险丝”。理论上,调度器启动后,CPU永远不会执行到这里。但如果因为某种极端原因(比如所有任务都被删除,且空闲任务也被禁用),调度器崩溃,这个循环就能捕获异常,防止CPU跑飞。我在一个项目密码锁的调试中,就曾因误删了空闲任务,导致系统在某个分支里意外跳到main()末尾,然后在这个for(;;)里死循环,方便我用J-Link快速定位问题。

6.1 任务阻塞与唤醒:如何让一个任务“睡着”又“醒来”

调度器的价值,不仅在于切换,更在于让任务按需休眠。比如,一个OLED刷新任务,不需要每毫秒都执行,它只需要每50ms刷新一次。我们可以用vTaskDelay()让它阻塞:

void OLED_Task( void *pvParameters ) { for( ;; ) { // 刷新OLED屏幕 OLED_Refresh(); // 阻塞50ms,让出CPU给其他任务 vTaskDelay( 50 / portTICK_PERIOD_MS ); } }

vTaskDelay()的实现,核心是修改TCB的状态,并将其从就绪队列移除,加入一个“延时列表”(xDelayedTaskList)。这个列表按唤醒时间排序,是一个双向链表。调度器在每次SysTick中断里,检查这个列表的头节点,如果其唤醒时间已到,就将其移回就绪队列。portTICK_PERIOD_MS是SysTick的周期(这里是1ms),所以50 / portTICK_PERIOD_MS等于50,表示延时50个滴答。

// tsk.c void vTaskDelay( const TickType_t xTicksToDelay ) { BaseType_t xAlreadyYielded = pdFALSE; TickType_t xTimeToWake; // 计算唤醒绝对时间 xTimeToWake = xTickCount + xTicksToDelay; // 将当前任务TCB加入延时列表 prvAddCurrentTaskToDelayedList( xTimeToWake ); // 触发一次调度,让其他任务运行 portYIELD_WITHIN_API(); }

prvAddCurrentTaskToDelayedList()函数,会遍历xDelayedTaskList,找到合适的位置插入TCB,并更新链表的pxIndex指针。这个过程,必须在临界区(关中断)内完成,以保证链表操作的原子性。而portYIELD_WITHIN_API(),就是再次触发PendSV挂起,强制进行一次上下文切换。

6.2 Rust PSP与Cortex-M3:跨语言调度的启示

网络热词里提到“rust psp”,这其实指向一个更深层的趋势:Rust语言凭借其内存安全特性,正在成为嵌入式调度器的新宠。Rust的core::arch::arm模块,提供了对PSP、PendSV的直接访问,且编译器能静态检查栈操作的安全性。例如,Rust可以定义一个#[repr(C)]的TCB结构体,并用unsafe块精确控制寄存器读写,避免了C语言里指针误用的风险。虽然STM32F103C8T6的资源尚不足以跑Rust full runtime,但其no_std环境下的调度器原型,已经证明了“内存安全的抢占式调度”是可行的。这提醒我们:手写C调度器的价值,不仅在于控制力,更在于它为我们理解Rust、Zephyr等现代嵌入式OS的底层机制,打下了不可替代的基石。当你亲手用汇编保存R4-R11时,你就真正读懂了Rust#[interrupt]属性背后的故事。

7. 调试与验证:用J-Link和逻辑分析仪捕捉每一次上下文切换

写完调度器,不等于它能跑。真正的挑战,在于验证它是否按预期工作。在STM32F103C8T6这种资源受限平台上,没有printf调试,没有GDB图形界面,唯一的办法,是用硬件工具直击本质。

7.1 J-Link Commander:查看PSP与TCB的实时快照

J-Link Commander是调试调度器的利器。连接板子

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询