关于RTOS操作系统的那些事
一、Cortex‑M3 异常与栈机制(RTOS 根基,必须吃透)
1. 两个栈指针 MSP / PSP
- MSP 主栈指针:复位默认使用,
main、中断服务函数全部默认跑在 MSP 上。 - PSP 进程栈指针:专门给任务(线程)使用。所有任务跑在 PSP。
RTOS 核心设计:中断用 MSP,任务用 PSP。切换任务本质就是切换 PSP 的值。
2. 异常进入时硬件自动压栈(非常关键)
当触发异常(SysTick / PendSV),CPU 硬件自动把下面 8 个寄存器压入当前栈: xPSR, PC, LR, R12, R3, R2, R1, R0
这一步不需要写任何汇编,硬件自动完成,8 个寄存器,占 8 个 32bit 字。
剩下寄存器 R4~R11,硬件不会自动保存,必须我们手动存入任务栈。
所以 PendSV 上下文切换流程一句话总结: 进入 PendSV:硬件压 R0-R3,R12,LR,PC,xPSR;我们手动把 R4~R11 压栈;保存 PSP 到 TCB。 退出 PendSV:从新 TCB 取出 PSP,手动恢复 R4~R11;退出异常,硬件自动恢复 R0-R3,R12,LR,PC,xPSR。
3. PendSV 是什么?为什么选 PendSV 做任务切换
- PendSV:可挂起系统调用异常,可以通过写
SCB->ICSR寄存器手动置位触发。 - 特点:等到所有更高优先级中断全部执行完毕之后,PendSV 才会执行。
这就是为什么任务切换必须用 PendSV: 如果在某个中断里面触发任务切换,PendSV 不会立刻抢占当前中断;等中断返回后才执行任务切换,不会破坏中断嵌套
永远记住:PendSV 必须是最低优先级异常!
4. SVC 异常
SVC 是系统服务调用,通过
svc #0汇编指令触发。 我们用 SVC 来第一次启动调度器,从 main(MSP)切入第一个任务(PSP)。FreeRTOS 原版也是 SVC 启动第一个任务。
二、TCB 任务控制块详细拆解
// 任务状态 typedef enum { TASK_READY, // 就绪,可以被调度运行 TASK_BLOCKED // 阻塞,延时等待,不参与调度 } TaskState_t; // 任务函数原型,和FreeRTOS保持一致 typedef void (*TaskFunction_t)(void *pvParam); typedef struct TCB { uint32_t *pxTopOfStack; // 【最重要】栈顶指针,PSP的值就存在这里 struct TCB *pxNext; // 链表指针,把所有TCB串成单向链表 TaskState_t eState; // 任务状态:就绪 / 阻塞 uint32_t ulDelayWakeTick; // 阻塞任务唤醒的tick计数 uint8_t uxPriority; // 任务优先级(后面做抢占调度用) } TCB_t;每个字段解释
pxTopOfStack:指向任务栈最后一个保存寄存器的位置。每次切换任务,PSP 的值直接存 / 取这个指针。pxNext:单向链表,把所有创建的任务串起来。简易版本用轮询调度,遍历链表找就绪任务。eState:任务就绪 / 阻塞。阻塞的任务调度器会跳过。ulDelayWakeTick:调用vTaskDelay(n)时,ulDelayWakeTick = xTickCount + n;每次 SysTick 中断检查,当全局 tick >= 这个值,任务恢复就绪。uxPriority:预留,简易版先不用,后面升级抢占调度才用到。全局变量:
TCB_t *pxCurrentTCB; // 当前正在运行的任务TCB指针 TCB_t *pxReadyTasksList;// 任务链表头 uint32_t xTickCount = 0;// 系统节拍计数器,SysTick每进一次中断+1任务栈设计
每个任务拥有独立栈,静态全局数组,不使用 malloc。
GD32F103RC RAM 总共 48KB,栈开太大很容易栈溢出 HardFault。
#define TASK_STACK_SIZE 128U uint32_t Task1Stack[TASK_STACK_SIZE]; uint32_t Task2Stack[TASK_STACK_SIZE];栈是从高地址向低地址增长(ARM Cortex-M 满递减栈 FD)。
Task1Stack[127]是栈最高地址,创建任务初始化栈帧,从栈顶(数组末尾)往低地址填充。三、任务栈初始化函数
prvInitialiseTaskStack逐行详解static uint32_t *prvInitialiseTaskStack(uint32_t *pxTopOfStack, TaskFunction_t pxTaskCode) { // 【硬件自动压栈的8个寄存器,先填充】 *(--pxTopOfStack) = 0x01000000UL; // xPSR:bit24=1,Thumb模式,M3必须开启,否则死机 *(--pxTopOfStack) = (uint32_t)pxTaskCode; // PC:任务入口地址,异常返回就跳到这里执行任务 *(--pxTopOfStack) = 0xEFFFFFFFUL; // LR:任务返回值,0xEFFFFFFF含义:返回时自动切回PSP- 这三个值是手动伪造一个异常退出时的栈帧。 Cortex‑M3 从异常返回时,硬件会自动从栈里取出:
xPSR, PC, LR, R12,R3,R2,R1,R0。 我们在创建任务的时候,预先在任务栈里把这 8 个字填好;等第一次 PendSV/SVC 异常返回,CPU 就以为刚刚发生过一次中断,直接从栈里读取这几个寄存器,跳去执行任务函数*(--pxTopOfStack) = 0x00000000UL; // R12*(--pxTopOfStack) = 0x00000000UL; // R3 *(--pxTopOfStack) = 0x00000000UL; // R2 *(--pxTopOfStack) = 0x00000000UL; // R1 *(--pxTopOfStack) = 0x00000000UL; // R0 // 【软件手动保存寄存器 R4~R11】 *(--pxTopOfStack) = 0x00000000UL; // R11 *(--pxTopOfStack) = 0x00000000UL; // R10 *(--pxTopOfStack) = 0x00000000UL; // R9 *(--pxTopOfStack) = 0x00000000UL; // R8 *(--pxTopOfStack) = 0x00000000UL; // R7 *(--pxTopOfStack) = 0x00000000UL; // R6 *(--pxTopOfStack) = 0x00000000UL; // R5 *(--pxTopOfStack) = 0x00000000UL; // R4 return pxTopOfStack; }核心理解:
我们手动在任务栈里面伪造一套异常栈帧,模拟任务曾经被 PendSV 中断保存过现场。 当第一次恢复这个任务的时候,PendSV 退出异常,硬件会直接从栈帧读取 PC,跳转到任务入口函数。
重点:
0xEFFFFFFF,Cortex-M 特殊 LR 值。 当异常返回,LR=0xEFFFFFFF,CPU 会自动使用 PSP 作为栈指针,完美切到任务栈。 如果填错这个值,会继续使用 MSP,栈混乱直接 HardFault。1.
*(--pxTopOfStack) = 0x01000000UL;→ xPSR出处:ARMv7‑M 架构手册(Cortex‑M3 内核参考手册)
xPSR 是组合程序状态寄存器。
0x01000000就是 bit24 = 1,这个位名字叫T位(Thumb bit)。为什么必须是 1?
Cortex‑M3 硬件上只支持 Thumb 指令集,不支持 ARM 32 位指令集。 CPU 在执行代码时,会读取当前
T位:
- T=1:取 Thumb(16/32 位混合指令)
- T=0:尝试取 ARM 指令 → M3 没有 ARM 指令解码器 → 立即 HardFault
正常运行 C 代码的时候,编译器输出全部是 Thumb 指令,硬件自动保证 T=1。 但是我们是手动构造栈帧,CPU 不会自动帮你设置 T 位! 如果栈帧里 xPSR 的 T 位写 0,异常返回瞬间,T=0 → HardFault。
能不能写别的值?
bit24 必须 = 1;其他状态位(进位、零标志等)任务刚启动无所谓,填 0 即可。 所以固定写
0x01000000。因果:任务第一次被恢复运行,靠异常返回,异常返回会把栈里的 xPSR 加载进真实 xPSR 寄存器 → T 位决定指令集模式。
2.
*(--pxTopOfStack) = (uint32_t)pxTaskCode;→ PC(程序计数器)出处:ARMv7-M 异常返回机制
异常退出时,硬件会把栈里这个值加载到
PC,也就是接下来要执行的第一条指令地址。为什么填任务函数地址 pxTaskCode?
我们伪造栈帧的目标:异常返回之后,CPU 直接跑任务函数。 所以 PC 字段直接填任务入口函数地址。
注意一个细节:
因为 T 位在 xPSR 里已经置 1,所以
pxTaskCode地址最低位可以不用手动置 1(老很多教程会写|1,那是旧习惯)。但有个小知识点:ARM 函数指针,地址 bit0=1 表示 Thumb 入口;但在栈帧 PC 字段,硬件不会用这个 bit 判断 Thumb 模式,栈帧里是看 xPSR 的 T 位。 所以栈帧 PC 直接填函数地址即可。
因果:
当 PendSV/SVC 异常返回,硬件把栈里这个值送入 PC → 跳转到任务函数,任务开始执行。
3.
*(--pxTopOfStack) = 0xEFFFFFFFUL;→ LR(异常返回码,EXC_RETURN)【最容易混淆!这个不是普通函数返回地址!!】
重点:这个 LR 不是普通 C 语言函数调用的 LR! 这是 EXC_RETURN 异常返回特殊码,ARMv7-M 架构定义的特殊魔术值。
出处:ARMv7-M Architecture Reference Manual B1.5.8 Exception return
当进入异常时,CPU 会自动把一个 32bit 的特殊值保存到 LR,这个值叫
EXC_RETURN。 退出异常的时候,把这个值放到 PC,触发异常返回逻辑;这个值的高 4 位固定是0xE,代表这是异常返回码,不是普通地址。
0xEFFFFFFF拆解(二进制高位):Bit [31:28] = 0xE → 标记:这是EXC_RETURN异常返回码 Bit 2 = 0 → 返回后使用 PSP(进程栈指针) Bit 1 = 1 → 返回后保持Thumb模式 Bit 0 = 1 → 返回到线程模式(不是异常模式)完整含义:
当执行
BX LR,LR=0xEFFFFFFF → 触发异常返回,返回线程模式,使用 PSP 栈。还有另外几个常见 EXC_RETURN 码,对比一下你就懂区别:
0xFFFFFFF1:返回线程模式,使用 MSP(主栈)0xFFFFFFF9:返回异常模式,使用 MSP0xFFFFFFFD:返回异常模式,使用 PSP0xEFFFFFFF:返回线程模式,PSP,正是我们 RTOS 任务需要!为什么必须写这个值?因果关系
我们伪造栈帧,模拟 “刚刚从线程模式进入异常”。 当我们做完上下文恢复,执行
BX LR,LR 等于这个魔术值。 CPU 识别到高 4 位是 0xE,就知道:这不是普通函数跳转,要执行异常返回流程。 异常返回流程:
- 从 PSP 指向的栈,弹出 8 个寄存器(xPSR,PC,LR,R12,R3,R2,R1,R0)
- 切换到线程模式,使用 PSP(任务栈)运行代码
👉 如果这里写错 EXC_RETURN 值: 比如写 0xFFFFFFF1,返回时使用 MSP,任务栈完全不会被使用,直接栈错乱 HardFault。
❗千万不要误以为:这个 LR 是任务函数 return 之后跳转到哪里。 任务函数绝对不能 return! 任务是死循环,如果任务函数 return,会把这个
0xEFFFFFFF当成普通地址去跳转,直接死机。 EXC_RETURN 只在异常返回场景才有特殊含义,在普通代码里它就是非法地址。
三者前后关联:栈的填充顺序(栈是满递减 FD,重点!)
GD32F103 Cortex‑M3:栈从高地址向低地址增长。
--pxTopOfStack:先把指针减 1(指向更低地址),再写入数值。我们构造栈帧顺序:
高地址 → 低地址 xPSR ← 最先写入 *(--pxTopOfStack) PC ← 第二个写入 EXC_RETURN ← 第三个写入 R12 R3 R2 R1 R0 R4~R11(我们软件手动压在更下面)真实硬件发生中断时,硬件压栈顺序: 进入异常时,硬件自动依次压入栈(从高地址往低地址):
xPSR, PC, LR(EXC_RETURN), R12, R3, R2, R1, R0✅ 我们手动构造栈帧,严格复刻硬件压栈顺序! 顺序一点不能颠倒!颠倒之后异常返回读到的寄存器全部错位,HardFault。
因果链条完整串起来:
- 创建任务,在任务栈从高到低,按硬件压栈顺序,写入 xPSR、PC、EXC_RETURN、R12~R0,再压 R4-R11。
- 第一次调度触发 SVC 异常。
- SVC 汇编恢复 R4-R11,执行 BX LR,LR=0xEFFFFFFF,触发异常返回。
- CPU 识别 EXC_RETURN=0xEFFFFFFF:切换线程模式,使用 PSP。
- 硬件从 PSP 指向的栈,弹出 8 个寄存器:xPSR (T=1)、PC (任务函数地址)、EXC_RETURN...
- PC 被加载成任务函数地址,xPSR.T=1 保持 Thumb 模式。
- CPU 开始执行任务代码。
常见误区汇总
- ❌ 误区:
0xEFFFFFFF是任务函数返回地址。 ✅ 纠正:不是地址,是 ARM 架构规定的异常返回魔术码,只有 BX 这个值的时候触发异常返回。任务不能 return。- ❌ 误区:xPSR 随便写,只要 T=1 就行。 ✅ 纠正:是的,bit24 必须 = 1,其余状态位初始 0 没问题,所以固定 0x01000000。
- ❌ 误区:PC 地址要 | 1。 ✅ 纠正:栈帧内 PC 不需要 | 1;栈帧里由 xPSR 的 T 位控制 Thumb 模式。(普通函数指针才需要 | 1)
- ❌ 误区:顺序可以随便调换。 ✅ 纠正:栈帧顺序必须和硬件自动压栈顺序完全一致,一旦错位,异常返回读错寄存器,直接 HardFault。
四、任务创建函数 xTaskCreate
嵌入式尽量不要 malloc,这里改成静态 TCB 数组版本,工程更稳定。
// 静态TCB池,最多创建4个任务,可根据需求改 #define MAX_TASK_NUM 4 TCB_t TCB_Pool[MAX_TASK_NUM]; uint8_t TCB_Count = 0; TCB_t* xTaskCreate(TaskFunction_t pxTaskCode, uint32_t *puxStackBuffer, uint8_t uxPriority) { if(TCB_Count >= MAX_TASK_NUM) { return NULL; // 任务池满 } TCB_t *pxNewTCB = &TCB_Pool[TCB_Count++]; // 栈顶 = 栈数组最后一个元素地址 uint32_t *pxStackTop = &puxStackBuffer[TASK_STACK_SIZE - 1U]; pxNewTCB->pxTopOfStack = prvInitialiseTaskStack(pxStackTop, pxTaskCode); pxNewTCB->eState = TASK_READY; pxNewTCB->uxPriority = uxPriority; // 头插法插入任务链表 pxNewTCB->pxNext = pxReadyTasksList; pxReadyTasksList = pxNewTCB; return pxNewTCB; }头插法:新任务插到链表头部,
pxReadyTasksList永远指向最新任务。五、汇编文件 portasm.s(Keil AC5 语法,逐行解释)
.syntax unified .thumb ; 声明全局函数,供C代码调用 .global vPortPendSVHandler .global vPortSVCHandler .global pxCurrentTCB vPortPendSVHandler: ; 1. MRS R0, PSP:读取当前进程栈指针PSP到R0 MRS R0, PSP ; 2. STMDB R0!, {R4-R11} ; STMDB:Store Multiple Decrement Before,满递减栈,把R4~R11存入PSP指向栈,R0自动更新 STMDB R0!, {R4-R11} ; 3. 取出pxCurrentTCB地址,把更新后的R0存入TCB->pxTopOfStack LDR R1, =pxCurrentTCB LDR R2, [R1] STR R0, [R2] ; 4. 调用C函数 vTaskSwitchContext,挑选下一个要运行的任务 BL vTaskSwitchContext ; 5. 读取新任务TCB,取出新任务栈顶 LDR R1, =pxCurrentTCB LDR R2, [R1] LDR R0, [R2] ; 6. LDMIA R0!, {R4-R11}:从栈恢复R4~R11 LDMIA R0!, {R4-R11} ; 7. MSR PSP, R0:把新栈顶写入PSP MSR PSP, R0 ; 8. BX LR,退出异常,硬件自动恢复R0-R3、R12、LR、PC、xPSR BX LR ; SVC中断服务函数:调度器第一次启动,用来切换到第一个任务 vPortSVCHandler: LDR R0, =pxCurrentTCB LDR R1, [R0] LDR R2, [R1] LDMIA R2!, {R4-R11} MSR PSP, R2 MOV R0, #0 MSR CONTROL, R0 ISB BX LR
CONTROL寄存器 bit0:0=MSP,1=PSP。ISB指令:指令同步屏障,刷新流水线,必须写,否则行为不稳定。六、调度器核心:vTaskSwitchContext
功能:遍历任务链表,选出下一个就绪任务。简易版本是轮询调度(时间片轮转,不支持抢占)。
void vTaskSwitchContext(void) { TCB_t *pxNextTCB = pxCurrentTCB->pxNext; // 循环遍历链表找就绪任务 while(1) { if(pxNextTCB->eState == TASK_READY) { pxCurrentTCB = pxNextTCB; break; } // 当前任务阻塞,取下一个 pxNextTCB = pxNextTCB->pxNext; // 链表循环到头,回到链表头部 if(pxNextTCB == NULL) { pxNextTCB = pxReadyTasksList; } } }逻辑: 从当前任务的下一个任务开始遍历,遇到
TASK_READY就选中为新任务。 如果任务是阻塞状态,直接跳过。七、SysTick 中断(系统节拍 tick)
SysTick:系统定时器,配置为 1ms 中断一次,也就是 1 tick = 1ms。
void SysTick_Handler(void) { uint32_t ulOldIntState = __get_PRIMASK(); __disable_irq(); // 关中断,保护TCB链表访问 xTickCount++; // 遍历所有任务,检查阻塞任务是否到期唤醒 TCB_t *pxTCB = pxReadyTasksList; do { if( (pxTCB->eState == TASK_BLOCKED) && (xTickCount >= pxTCB->ulDelayWakeTick) ) { pxTCB->eState = TASK_READY; } pxTCB = pxTCB->pxNext; }while(pxTCB != NULL); __set_PRIMASK(ulOldIntState); // 恢复中断状态 // 置位PendSV,触发任务切换 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; }为什么要关中断?TCB 链表是全局共享数据,SysTick 中断访问链表时,如果同时有代码修改链表会造成链表损坏、死机。 使用
__get_PRIMASK()保存中断掩码,不要直接无脑enable_irq(),避免把原本关闭的中断强行打开。八、阻塞延时函数 vTaskDelay
void vTaskDelay(uint32_t ticks) { if(ticks == 0) { return; } uint32_t ulOldIntState = __get_PRIMASK(); __disable_irq(); // 当前任务标记阻塞,计算唤醒tick pxCurrentTCB->eState = TASK_BLOCKED; pxCurrentTCB->ulDelayWakeTick = xTickCount + ticks; __set_PRIMASK(ulOldIntState); // 立刻触发PendSV,马上切换别的任务 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; }注意:这是阻塞延时,不是忙等。任务进入阻塞状态,调度器不会再运行它,CPU 去跑其他就绪任务。FreeRTOS 的
vTaskDelay原理一模一样。九、调度器启动函数 vTaskStartScheduler
void vTaskStartScheduler(void) { // 初始当前任务指向链表头 pxCurrentTCB = pxReadyTasksList; // SysTick初始化,1ms中断,GD32F103默认系统时钟72MHz SysTick_Config(SystemCoreClock / 1000UL); // 设置NVIC优先级 NVIC_SetPriority(SysTick_IRQn, 1); NVIC_SetPriority(PendSV_IRQn, 15); // SVC触发,第一次上下文切换,进入第一个任务 __asm volatile ("svc 0"); // svc触发后永远不会回到这里 while(1); }GD32F103RC 默认 HSE 72MHz,
SystemCoreClock=72000000,72000000 /1000 =72000,SysTick 每 72000 个时钟脉冲触发一次中断 = 1ms。十、任务示例代码 + main 函数
void Task1(void *pvParam) { while(1) { gpio_bit_set(GPIOA, GPIO_PIN_5); vTaskDelay(500); gpio_bit_reset(GPIOA, GPIO_PIN_5); vTaskDelay(500); } } void Task2(void *pvParam) { while(1) { gpio_bit_set(GPIOB, GPIO_PIN_5); vTaskDelay(1000); gpio_bit_reset(GPIOB, GPIO_PIN_5); vTaskDelay(1000); } } int main(void) { // GD32标准库初始化:时钟、GPIO rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5); gpio_init(GPIOB, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5); gpio_bit_reset(GPIOA, GPIO_PIN_5); gpio_bit_reset(GPIOB, GPIO_PIN_5); // 创建两个任务 xTaskCreate(Task1, Task1Stack, 1); xTaskCreate(Task2, Task2Stack, 1); // 启动调度器,不再返回main vTaskStartScheduler(); while(1) { // 永远不会执行到这里 } }任务函数必须死循环,不能 return。一旦 return,PC 跳转到非法地址,HardFault。
十一、调试 HardFault 常见原因(GD32F103RC 高频踩坑)
- ❌ xPSR 没有设置 bit24=1:Thumb 位丢失,进入 ARM 模式,M3 不支持 ARM 指令,直接 HardFault。
- ❌ PendSV 优先级不是最低:中断嵌套时触发任务切换,破坏栈帧。
- ❌ 栈开太小:任务内部局部变量太多,栈溢出,覆盖全局变量 / TCB。
- ❌ 混淆 MSP 和 PSP:LR 值不是 0xEFFFFFFF,异常返回栈指针错误。
- ❌ 访问 TCB 全局变量没有关中断:SysTick 和任务同时修改链表,链表指针错乱。
- ❌ SysTick 时钟配置错误:tick 不准或者根本不进中断。
- ❌ 栈数组定义局部变量:栈放在栈段而不是全局静态区,直接被覆盖。任务栈必须全局 static。
十二、功能迭代路线(一步步升级,贴近 FreeRTOS)
- 当前版本:轮询调度、阻塞延时、上下文切换(时间片轮转,无抢占)
- 增加:抢占式调度:SysTick 中断发现存在更高优先级就绪任务,触发 PendSV 抢占低优先级任务。
- 增加:空闲任务:所有任务都阻塞时,自动运行空闲任务。
- 增加:队列 Queue,任务间数据传递。
- 增加:二进制信号量、计数信号量、互斥锁(注意优先级反转问题)。
- 增加:任务删除、栈溢出检测(栈水印)。
- 增加:事件组、软件定时器。
- AI生成用作记录
浙公网安备 33010602011771号