STM32裸机手写RTOS内核:PendSV与BASEPRI底层调度原理实战

发布时间:2026/9/13 7:58:57
STM32裸机手写RTOS内核:PendSV与BASEPRI底层调度原理实战 1. 这不是“玩具内核”是能真正在 STM32 上扛住电机控制和传感器采样的硬核实践你搜“RTOS 教程”满屏是 FreeRTOS 移植、CMSIS-RTOS 封装、CubeMX 自动生成——但没人告诉你当你的 STM32F103 正在驱动步进电机做闭环 PID同时通过 I2C 读取 6 轴 IMU 数据、用 UART 向上位机发 JSON 包时为什么任务切换突然卡顿 8ms为什么信号量释放后另一个高优先级任务愣是没被调度为什么 PendSV 异常服务例程ISR里多加了一行printf整个系统就崩了这正是我花 17 天、重写 5 版、烧坏 3 块开发板后才真正搞懂的事教科书讲的是“应该怎样”而真实芯片上跑起来的每一行汇编、每一个寄存器位、每一次堆栈切换都在用物理现实给你打补丁。这篇写的不是“700 行代码怎么凑数”而是我把 Cortex-M3 内核手册第 42 页的 BASEPRI 配置陷阱、第 89 页的 PendSV 触发时序、第 121 页的 SVC 异常返回栈帧结构全拆开揉碎塞进 STM32F103C8T6 的 20KB SRAM 里跑通后的血泪笔记。核心关键词就五个RTOS、STM32、内核、PendSV、BASEPRI——它们不是孤立概念而是咬合在一起的齿轮。PendSV 不是“一个中断”它是内核调度的唯一合法出口BASEPRI 不是“关中断开关”它是让高优先级任务在关键区不被抢占的精密阀门STM32 不是“开发板型号”它是 Cortex-M3 内核 特定外设时序 硬件异常向量表的物理实体。你写的不是 C 语言是直接对硅片下指令。适合谁看如果你正卡在“FreeRTOS 移植成功但不敢改调度策略”、“裸机写久了想上 RTOS 却怕踩坑”、“面试被问‘PendSV 和 SVC 区别’答得模棱两可”这篇就是为你写的。不需要你背过 ARMv7-M 架构手册但得愿意打开 Keil 的反汇编窗口盯着PSP和MSP寄存器值变化看 20 分钟。我不会教你“如何安装 CubeMX”但会告诉你当你把NVIC_SetPriority(PendSV_IRQn, 0xFF)写进代码时你其实已经亲手拧松了实时性的第一颗螺丝。2. 为什么非得手写教科书没说清的 3 个底层真相2.1 教科书回避的“硬件调度权移交”问题所有 RTOS 教程都告诉你“调度器靠 PendSV 触发任务切换”。但没人画一张图告诉你PendSV 不是“发起切换”而是“接收切换请求”的被动方。真正的调度决策发生在 SVC 异常如taskYield()或 SysTick 中断里它们只是设置一个标志然后调用__set_PENDSV()—— 这个函数本质是往ICSR寄存器的PENDSVSET位置 1。而 PendSV ISR 本身只干三件事保存当前任务上下文、加载下一个任务上下文、执行BX LR返回。它不决定“切谁”只执行“怎么切”。提示这就是为什么你在 PendSV ISR 里加调试代码必崩——它运行在MSP栈上且必须在 12 个周期内完成Cortex-M3 最坏情况。任何阻塞操作如printf都会破坏栈平衡导致返回地址错乱。我最初写的版本在 PendSV 里调用uart_send()发送调试信息结果系统每切 3 次任务就死一次。用逻辑分析仪抓波形才发现UART 发送触发了 TXE 中断而 TXE 中断优先级比 PendSV 高默认都是 0导致 PendSV 被打断MSP栈被 TXE ISR 覆盖BX LR跳到随机地址。解决方案把 UART 发送移到任务上下文里或者——更根本的——把所有外设中断优先级设为低于 PendSV即数值更大如 0x80。这是教科书从不提的硬约束。2.2 BASEPRI 的“伪关中断”陷阱教科书说“__set_BASEPRI(0x20)可屏蔽优先级低于 0x20 的中断”。但没人告诉你BASEPRI 屏蔽的是“抢占”不是“响应”。当一个优先级为 0x10 的中断正在执行你设BASEPRI0x20它不会被立即终止但新来的 0x10 中断会被挂起直到当前 ISR 结束。这听起来安全错。在 STM32F103 上SysTick 默认优先级是 0x00最高而 PendSV 是 0xFF最低。如果你在某个临界区设BASEPRI0x20SysTick 依然能抢占——因为 0x00 0x20它不受 BASEPRI 影响。我踩的第一个大坑就在这在信号量take操作里我用__disable_irq()关总中断结果发现任务切换延迟飙升。查手册才发现__disable_irq()禁用的是PRIMASK它连 SysTick 都禁了而 RTOS 必须依赖 SysTick 做时间片轮转。正确做法是用__set_BASEPRI()设置一个阈值让 SysTick0x00能进来但让其他外设中断如 EXTI00x04被屏蔽。我最终设BASEPRI 0x10这样优先级 0x00~0x0F 的中断只有 SysTick能抢占0x10 及以上被屏蔽。这个值不是拍脑袋定的——它必须大于所有外设中断优先级小于 SysTick 优先级。在 STM32F103 的 NVIC 里优先级分组是 4bit 抢占0bit 子优先级即只有抢占优先级所以 0x00~0xFF 全是抢占级。BASEPRI0x10意味着“只放行优先级 0x00~0x0F 的中断”而 SysTick 是 0x00完美。2.3 “700 行”背后的内存布局战争你以为 700 行 C 是代码量不那是内存战场上的兵力部署图。STM32F103C8T6 只有 20KB SRAM其中0x20000000 ~ 0x20001FFF20KB 全部可用但你要分给MSP初始栈1KB、PSP任务栈每个任务 512B × 5 任务 2.5KB、内核全局变量2KB、任务控制块 TCB每个 32B × 5 160B、就绪列表128B……最后剩不到 12KB 给用户代码和堆。我最初把所有任务栈设成 1KB结果编译报错region RAM overflowed。用fromelf --text -c build/kernel.axf查看符号表才发现g_tcb_array占了 160Bg_ready_list占 128B但g_task_stack[0]第一个任务栈从0x20000000开始到0x200003FF结束而MSP初始栈从0x20000400往下长 1KB 到0x200007FF——等等0x20000400是MSP栈顶但MSP是向下增长的所以实际MSP占用0x20000400 ~ 0x200007FF而g_task_stack[0]占0x20000000 ~ 0x200003FF两者紧挨着中间无空隙。再加一个 1KB 任务栈就撞到MSP区域了。解决方案把MSP初始栈压到 512B任务栈压到 384B并用链接脚本.ld文件强制指定各段地址MEMORY { RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .stack_msp (NOLOAD) : { . ALIGN(8); _msp_stack_start .; . 512; _msp_stack_end .; } RAM .tcb (NOLOAD) : { . ALIGN(8); _tcb_start .; . 160; _tcb_end .; } RAM /* 其他段... */ }这 700 行代码里有 127 行是链接脚本和内存布局定义——它们不参与调度但决定了你的内核能不能活过第一次任务切换。3. 5 个致命坑位深度复盘从寄存器到波形图的实操证据3.1 坑位一PendSV 优先级设错导致任务永不切换附逻辑分析仪截图分析现象创建两个同优先级任务 A 和 BA 执行task_delay(10)后调用task_yield()期望切换到 B但 B 始终不运行A 一直循环。排查过程在PendSV_Handler开头加GPIOA-BSRR 10点亮 LED发现 LED 不闪 → PendSV 根本没触发。查ICSR寄存器PENDSVSET位发现它一直是 0 →__set_PENDSV()没生效。翻 STM32F103 参考手册第 216 页NVIC_SetPriority()的优先级值是左对齐的手册说“优先级分组 4bit”但实际写入IPR寄存器时要左移 4 位。例如你想设 PendSV 优先级为 0xFF得写NVIC-IP[10] 0xFF 4而不是0xFF。根因我最初直接写NVIC_SetPriority(PendSV_IRQn, 0xFF)结果IPR[10]被写成0xFF000000而 Cortex-M3 只取高 8bit 作为优先级实际值是0xFF最低但NVIC解析时取0xFF 4 0x0F导致 PendSV 优先级变成 0x0F比 SysTick0x00低被 SysTick 中断持续抢占PENDSVSET位永远无法进入 pending 状态。修复方案// 正确写法手动左移确保高位对齐 #define PENDSV_PRIO 0xFF NVIC-IP[10] (PENDSV_PRIO 4); // IPR[10] 对应 PendSV // 或用 CMSIS 宏但需确认 HAL 库版本 NVIC_SetPriority(PendSV_IRQn, __NVIC_PRIO_BITS ? PENDSV_PRIO : 0);注意__NVIC_PRIO_BITS在core_cm3.h中定义为 4所以NVIC_SetPriority()内部会自动左移。但我用的旧版 CMSIS 没这逻辑必须手动。这是 STM32 标准外设库和 HAL 库的兼容性坑。实测效果修复后逻辑分析仪抓到PendSV引脚我用 GPIO 模拟每 10ms 闪一次对应 SysTick 周期任务切换正常。3.2 坑位二BASEPRI 清零时机错误引发双重调度附栈指针追踪现象任务 A 调用sem_take()获取信号量成功后立即执行task_delay(1)但系统崩溃HardFault_Handler被触发。栈追踪用 Keil 的Register窗口观察PSP和MSP进入sem_take()前PSP 0x20000200,MSP 0x20000700sem_take()中设BASEPRI 0x10PSP不变task_delay(1)触发 SysTickSysTick ISR 执行__set_PENDSV()PSP被保存到任务 A 栈顶PendSV ISR 执行加载任务 B 的PSP但此时BASEPRI仍是 0x10任务 B 运行中又调用sem_take()再次设BASEPRI 0x10问题来了当任务 B 的sem_take()返回时它执行__set_BASEPRI(0)清零但这个0是写到BASEPRI寄存器而任务 A 的BASEPRI值还在栈里没恢复结果任务 A 恢复运行时BASEPRI仍是 0x10它自己的临界区失效。根因BASEPRI是处理器状态的一部分必须在任务上下文切换时保存/恢复。我最初只保存了R0-R12、LR、PC、xPSR漏了BASEPRI。Cortex-M3 的PUSH/POP指令不自动处理BASEPRI必须手动在 PendSV ISR 里读写。修复方案在 PendSV ISR 的上下文保存/恢复段加入; 保存 BASEPRI MRS R0, BASEPRI PUSH {R0} ; 恢复 BASEPRI POP {R0} MSR BASEPRI, R0并在每个任务的初始栈帧里把BASEPRI初始值0压栈。这样每次任务切换BASEPRI都随上下文一起保存。实测效果修复后连续运行 12 小时无 HardFaultBASEPRI寄存器值在任务切换时稳定在 0 和 0x10 之间跳变。3.3 坑位三SysTick 中断服务里调用task_switch()导致栈溢出附内存 dump 分析现象系统运行 3 分钟后串口打印乱码随后死机。内存 dump用 OpenOCDdump_image ram.bin 0x20000000 0x5000抓内存发现0x20000400 ~ 0x200007FFMSP 栈区全是0xDEADBEEF我设的栈填充值但0x200007FF往上0x20000800开始出现任务 A 的局部变量数据——栈溢出了。根因我在SysTick_Handler里直接调用task_switch()而task_switch()是 C 函数它需要栈空间存放局部变量、参数、返回地址。SysTick 运行在MSP上而MSP初始栈只有 512B。task_switch()调用链task_switch()→list_remove()→list_insert_tail()三层函数调用至少需要 200B 栈空间加上中断嵌套512B 不够。教科书方案所有调度操作应在 PendSV 里做SysTick 只负责“发信号”。正确流程void SysTick_Handler(void) { g_tick_count; // 全局滴答计数 if (g_need_schedule) { // 调度标志 __set_PENDSV(); // 触发 PendSV不在此处切换 } }g_need_schedule在task_delay()、sem_take()等函数里置位由 PendSV 统一处理。实测效果改用此模式后MSP栈使用峰值降至 180B0x200007FF以上内存保持干净。3.4 坑位四TCB 初始化未清零导致任务优先级随机附 JTAG 单步调试现象创建 3 个任务优先级分别设为 1、2、3但任务 2 总是比任务 1 先运行即使任务 1 已就绪。JTAG 单步在task_create()里memset(tcb, 0, sizeof(tcb_t))后观察tcb-priority值发现是0x12345678随机值而非预期的 1。根因memset()调用前tcb指针指向未初始化的内存。我分配 TCB 用的是g_tcb_array[i]而g_tcb_array是全局数组C 标准规定全局变量初始为 0但如果链接脚本没把它放在.bss段而是.data段且启动文件没执行__iar_data_init3IAR或SystemInit()Keil里的.bss清零它就不会是 0。我用的 Keil 启动文件startup_stm32f10x_md.s里.bss清零代码是; Copy data section from flash to RAM ; Clear bss section ldr r2, _ebss mov r3, #0 b clear_loop clear_loop: cmp r2, r1 it lt movlt r4, #0 strlt r4, [r1], #4 blt clear_loop但_ebss符号没正确定义导致.bss没清零。修复方案在链接脚本.ld里明确定义_sbss和_ebss.bss : { _sbss .; *(.bss) *(COMMON) _ebss .; } RAM确保启动文件调用.bss清零代码。实测效果修复后tcb-priority稳定为设定值就绪列表按优先级正确排序。3.5 坑位五PendSV 返回时LR值错误导致任务跳转到非法地址附反汇编对照现象任务切换后程序跑飞PC指向0x20000000SRAM 起始触发UsageFault。反汇编对比在 Keil 的Disassembly窗口对比 PendSV ISR 返回前的LR值正确情况LR 0xFFFFFFFD表示返回到线程模式使用 PSP错误情况LR 0xFFFFFFF9表示返回到线程模式但使用 MSP根因Cortex-M3 的BX LR返回时LR的低 2 位决定返回模式LR[1:0] 0b01返回线程模式使用 MSPLR[1:0] 0b11返回线程模式使用 PSP我在任务上下文保存时LR值是从PSP切换过来的但没确保LR[1:0] 0b11。原始栈帧里LR是0xFFFFFFF90b1001BX LR就会用 MSP 返回而当前任务运行在 PSP 上栈指针错乱。修复方案在 PendSV ISR 的上下文恢复段强制设置LR低 2 位; 加载新任务的 LR LDR R0, [R1, #60] ; R1 指向新任务栈顶LR 在偏移 60 字节处R0-R12LRPCxPSR16*464BLR 是倒数第二个 ORR R0, R0, #3 ; 确保 LR[1:0] 0b11 STR R0, [R1, #60]实测效果修复后LR值稳定为0xFFFFFFFD任务切换 100% 成功。4. 从 0 到 1 的完整实现5 个核心模块逐行解析4.1 内核初始化不只是NVIC配置是整个异常向量重映射内核初始化 (kernel_init()) 不是简单开中断而是重建 Cortex-M3 的异常处理根基向量表重映射STM32F103 默认向量表在 Flash0x08000000但我们要把 PendSV、SysTick 等内核异常入口放到 RAM 里方便动态修改。调用SCB-VTOR 0x20000000把向量表基址设到 SRAM 起始。然后在 RAM 里复制一份向量表// RAM 向量表256 项每项 4 字节 uint32_t g_vector_table[256] __attribute__((section(.ram_vector))); void kernel_init(void) { // 复制主向量表Flash到 RAM memcpy(g_vector_table, (void*)0x08000000, 256 * 4); // 替换 PendSV 和 SysTick 入口 g_vector_table[14] (uint32_t)PendSV_Handler; // IRQ14 PendSV g_vector_table[15] (uint32_t)SysTick_Handler; // IRQ15 SysTick SCB-VTOR (uint32_t)g_vector_table; }SysTick 初始化不是SysTick_Config()就完事。要精确计算重装载值// STM32F103 系统时钟 72MHzSysTick 用 AHB/8 9MHz // 要 10ms 滴答9MHz × 0.01s 90000所以 LOAD 90000 - 1 89999 SysTick-LOAD 89999; SysTick-VAL 0; SysTick-CTRL SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk; // 优先级设为 0x00最高 NVIC_SetPriority(SysTick_IRQn, 0x00);PendSV 初始化如前所述优先级必须低于 SysTickNVIC_SetPriority(PendSV_IRQn, 0xFF); // 实际写入 0xFF 4 0xFF000000初始栈设置MSP设为g_msp_stack_topPSP在第一个任务创建时设置。4.2 任务管理TCB 不是结构体是内存调度的契约task_control_block_t定义如下typedef struct { uint32_t *stack_ptr; // 指向任务栈顶PSP uint8_t priority; // 优先级0~310 最高 uint8_t state; // TASK_STATE_READY / BLOCKED / DELAYED uint32_t delay_ticks; // 延迟剩余滴答数 list_node_t list_node; // 就绪列表节点 } tcb_t;关键点stack_ptr必须指向栈顶最高地址因为 PSP 向下增长。任务栈初始化时要模拟一次PUSH操作把初始寄存器值压栈// 任务栈布局从高地址到低地址 // [xPSR][PC][LR][R12][R3][R2][R1][R0][R11~R4] uint32_t *stack_init(uint32_t *stack, void (*entry)(void), uint32_t *param) { stack - 16; // 预留 16 个寄存器空间 stack[0] 0x01000000UL; // xPSR: Thumb 状态 stack[1] (uint32_t)entry; // PC: 任务入口 stack[2] (uint32_t)task_exit; // LR: 任务退出后跳转 stack[3] 0x12121212UL; // R12 stack[4] (uint32_t)param; // R3: 第一个参数 stack[5] 0x05050505UL; // R2 stack[6] 0x06060606UL; // R1 stack[7] 0x07070707UL; // R0 // R11~R4 初始化为 0x00000000 for (int i 8; i 16; i) { stack[i] 0x00000000UL; } return stack; }task_exit()是任务函数返回后的清理函数void task_exit(void) { task_delete(); // 删除自己 while(1); // 不应到达此处 }4.3 调度器PendSV 是唯一调度入口但决策在别处调度器核心是task_switch()函数但它只在 PendSV ISR 里被调用void PendSV_Handler(void) { // 1. 保存当前任务上下文到其 PSP 栈 __asm volatile ( MRS R0, PSP\n\t STMDB R0!, {R4-R11, R14}\n\t // 保存 R4-R11, LR MRS R1, BASEPRI\n\t PUSH {R0,R1}\n\t // 保存 PSP 和 BASEPRI // ... 更多保存 ); // 2. 调用 C 函数选择下一个任务 task_switch(); // 3. 恢复下一个任务上下文 __asm volatile ( // ... 恢复 PSP, BASEPRI, R4-R11, LR LDR R0, [R1, #60]\n\t // 加载 LR ORR R0, R0, #3\n\t // 确保 LR[1:0]0b11 STR R0, [R1, #60]\n\t LDMIA R1!, {R4-R11, R14}\n\t MSR PSP, R1\n\t BX R14\n\t // BX LR 返回 ); }task_switch()的逻辑void task_switch(void) { // 1. 如果当前任务在延时更新其 delay_ticks if (g_current_tcb-state TASK_STATE_DELAYED) { g_current_tcb-delay_ticks--; if (g_current_tcb-delay_ticks 0) { g_current_tcb-state TASK_STATE_READY; list_insert_tail(g_ready_list, g_current_tcb-list_node); } } // 2. 从就绪列表找最高优先级任务 tcb_t *next_tcb NULL; for (int prio 0; prio MAX_PRIORITY; prio) { if (!list_is_empty(g_ready_list_by_prio[prio])) { next_tcb LIST_ENTRY(list_first(g_ready_list_by_prio[prio]), tcb_t, list_node); break; } } // 3. 切换当前任务 if (next_tcb next_tcb ! g_current_tcb) { g_next_tcb next_tcb; g_current_tcb next_tcb; } }4.4 同步机制信号量不是锁是任务状态机信号量sem_t定义typedef struct { int32_t count; // 计数值 list_head_t pend_list; // 等待该信号量的任务列表 } sem_t;sem_take()关键逻辑int32_t sem_take(sem_t *sem, uint32_t timeout_ms) { uint32_t start_tick g_tick_count; // 1. 关中断用 BASEPRI uint32_t basepri __get_BASEPRI(); __set_BASEPRI(0x10); if (sem-count 0) { sem-count--; __set_BASEPRI(basepri); return 0; // 成功 } // 2. 计数为 0当前任务需阻塞 g_current_tcb-state TASK_STATE_BLOCKED; g_current_tcb-block_sem sem; list_insert_tail(sem-pend_list, g_current_tcb-list_node); __set_BASEPRI(basepri); // 3. 主动让出 CPU task_yield(); // 4. 超时检查在 task_yield 返回后 if (timeout_ms ! 0) { uint32_t elapsed g_tick_count - start_tick; if (elapsed timeout_ms / 10) { // 10ms 滴答 // 从 pend_list 移除自己 list_remove(g_current_tcb-list_node); g_current_tcb-state TASK_STATE_READY; return -1; // 超时 } } return 0; }注意task_yield()会触发 PendSV让出 CPU但当前任务状态已是BLOCKED所以不会被选中。4.5 时间管理SysTick 不是计时器是调度心跳task_delay()实现void task_delay(uint32_t ms) { uint32_t ticks ms / 10; // 转换为滴答数10ms/滴答 if (ticks 0) ticks 1; __set_BASEPRI(0x10); g_current_tcb-state TASK_STATE_DELAYED; g_current_tcb-delay_ticks ticks; __set_BASEPRI(0); task_yield(); // 主动切换 }SysTick Handler 只做一件事void SysTick_Handler(void) { g_tick_count; // 检查是否有任务到期 for (int i 0; i MAX_TASKS; i) { if (g_tcb_array[i].state TASK_STATE_DELAYED) { if (g_tcb_array[i].delay_ticks 0) { g_tcb_array[i].delay_ticks--; if (g_tcb_array[i].delay_ticks 0) { g_tcb_array[i].state TASK_STATE_READY; list_insert_tail(g_ready_list_by_prio[g_tcb_array[i].priority], g_tcb_array[i].list_node); g_need_schedule 1; // 标记需调度 } } } } }5. 实战避坑清单那些文档里找不到的“经验之谈”5.1 编译器优化陷阱volatile 不是万能解药我最初在g_tick_count上加volatile以为就能防止优化。结果在-O2下g_tick_count被编译成ADD.W R0, R0, #1但R0是寄存器如果中断在ADD执行中发生g_tick_count可能被写两次。volatile只告诉编译器“不要优化读写”不保证原子性。正确做法// 在 SysTick_Handler 里用禁用中断保证原子性 __disable_irq(); g_tick_count; __enable_irq();或者用__atomic_fetch_add()GCC 4.7__atomic_fetch_add(g_tick_count, 1, __ATOMIC_SEQ_CST);5.2 调试技巧用 GPIO 模拟逻辑分析仪通道没有逻辑分析仪用 3 个 GPIO 模拟// 在关键点置位/清零 #define TRACE_PENDSV_ENTER() GPIOA-BSRR 10 #define TRACE_PENDSV_EXIT()