一、任务的本质:函数 + 独立栈
函数比较好理解
// 任务函数示例:无限循环 void Task1(void *pvParameters) { while(1) { // 任务业务逻辑:比如打印、控制LED printf("Task1 Running\n"); vTaskDelay(1000); // 主动放弃CPU } }但是栈是用来干嘛的(注意是独立栈)
- 保存局部变量:函数内的局部变量(如数组、临时变量)存在栈里;
- 保存调用关系:函数嵌套调用时,返回地址(LR)存在栈里;
- 保存任务现场:任务被切换(中断)时,CPU 寄存器的值存在栈里,恢复时从栈里读回。
二、重新理解任务创建
1、任务创建函数与TCB
BaseType_t xTaskCreate( TaskFunction_t pvTaskCode, // 1. 任务函数指针(必传!) const char * const pcTaskName, // 2. 任务名(可选) uint16_t usStackDepth, // 3. 栈大小(字节,如 100*4=400) void *pvParameters, // 4. 函数参数(如 int arg=10) UBaseType_t uxPriority, // 5. 任务优先级(0~configMAX_PRIORITIES-1) TaskHandle_t *pvCreatedTask // 6. 任务句柄(输出,用于操作任务) );结合我们之前学的汇编语言
| 参数 | 作用 | 代码示例 |
|---|---|---|
pvTaskCode | 任务函数地址(PC寄存器指向它) | Task1函数地址 →xTaskCreate(Task1, ...) |
usStackDepth | 栈大小(字节)→ 由vPortMalloc()分配 | usStackDepth = 100→ 100*4=400字节栈 |
pvParameters | 传递给任务函数的参数(存入R0) | pvParameters = &arg→ 任务函数Task1(int *arg) |
uxPriority | 任务优先级(决定调度顺序) | uxPriority = 1(优先级 1 比 0 高) |
TCB任务控制块(精简版本)
TCB = Task Control Block,是一个 C 语言结构体,用来 “描述和管理一个任务”,相当于任务的 “身份证”。
typedef struct tskTaskControlBlock { volatile StackType_t *pxTopOfStack; // 1. 栈顶指针(栈起始位置) #if (configUSE_TRACE_FACILITY == 1) UBaseType_t uxTCBNumber; // 任务编号 #endif UBaseType_t uxPriority; // 2. 任务优先级 ListItem_t xStateListItem; // 3. 用于链表(就绪/阻塞列表) ListItem_t xEventListItem; // 4. 用于事件列表(如队列) #if (configUSE_MUTEXES == 1) UBaseType_t uxBasePriority; // 优先级继承用 #endif char pcTaskName[configMAX_TASK_NAME_LEN]; // 5. 任务名 } tskTCB;| TCB 字段 | 作用 | 代码中如何体现 |
|---|---|---|
pxTopOfStack | 栈顶地址(任务切换时恢复寄存器) | pxStack = (StackType_t *)pvPortMalloc(usStackDepth * sizeof(StackType_t)); |
uxPriority | 优先级(决定调度顺序) | uxPriority = 1→ 优先级 1 |
xStateListItem | 链表节点(连接到就绪列表/阻塞列表) | vListInsertEnd(&xReadyTasksLists[uxPriority], &pxNewTCB->xStateListItem); |
pcTaskName | 任务名(调试用) | pcTaskName = "Task1" |
TCB 里怎么没看到 “函数指针”?
函数指针和函数参数不是直接存在 TCB 里,而是作为 “初始现场” 存在任务栈里!
- 任务刚创建时,会在栈里预先填充:
- PC 寄存器的值 = 任务函数的地址(让任务第一次运行时从函数入口开始);
- R0 寄存器的值 = 任务函数的参数(
pvParameters);
- 任务第一次被调度时,从栈里恢复这些寄存器,就自动跳转到任务函数执行了。
2、任务栈
FreeRTOS 任务栈大小的单位:xTaskCreate的uxStackDepth参数是栈项数(不是字节数)
- 针对 Cortex-M 内核(STM32),栈按4 字节(1 个字)对齐,因此栈项数 ×4 = 实际栈字节数;
- 例:
uxStackDepth=128→ 实际栈大小 = 128×4=512 字节;uxStackDepth=256→ 1024 字节。 - 栈增长方向:Cortex-M 内核栈从高地址向低地址增长,栈溢出会覆盖低地址的内存(触发内存踩踏)。
关于这个内存分配,可以参考一下单片机内存分配管理笔记-CSDN博客
2.1栈的大小怎么确定?
栈的大小取决于两点:
void SubFunction(void) { // 延时循环 int i; // Declare at function beginning (C90 requirement) for(i = 0; i < 100; i++) { // Empty loop body for delay } } void vTask2( void *pvParameters ) { const char *pcTaskName = "T2 run\r\n"; // 定义100个int的数组,占100*4=400字节 volatile int array[100]; // 防止编译器优化,故意使用数组 array[0] = 123; /* 任务函数的主体一般都是无限循环 */ for( ;; ) { /* 打印任务1的信息 */ printf( pcTaskName ); /* 延迟一会(比较简单粗暴) */ SubFunction(); } }(1)局部变量的大小
反汇编验证:编译后会看到
(0x190 是 400 字节,加 4 字节临时变量,共 0x190)。
(2)函数调用的深度
如果任务里嵌套调用子函数,LR(返回地址)会被覆盖,必须把 LR 压入栈,后面才可POP出(全流程编译器自动执行,我们只需要注意会不会栈溢出即可)
2.2实际开发中栈大小的设置原则
估算:局部变量大小 × 2(保险起见,留余量)
之后使用 FreeRTOS 自带工具验证(核心方法),FreeRTOS 提供uxTaskGetStackHighWaterMark()函数(栈高水位标记),能返回任务栈的剩余最小空间(栈项数),是判断栈是否够用的核心工具。
//开启宏定义,注意这个宏定义只适合调试阶段,发布后就不要使用了 #ifndef INCLUDE_uxTaskGetStackHighWaterMark #define INCLUDE_uxTaskGetStackHighWaterMark 1 #endif // 获取栈剩余最小空间(栈项数) UBaseType_t uxHighWaterMark = uxTaskGetStackHighWaterMark(xLedTaskHandle); // 打印:比如返回20,表示任务运行以来,栈最少剩余20个栈项(80字节) printf("LED Task Stack High Water Mark: %u\n", uxHighWaterMark);关键判断规则:
- 若
uxHighWaterMark返回值大于 0 且大于栈大小的 20%:栈大小足够(比如栈大小 128,剩余≥25,说明有足够余量); - 若返回值接近 0(比如≤5):栈大小不足,需增大(比如从 128→256);
- 若返回值等于栈大小:任务几乎没使用栈,可减小(比如从 256→128)。
底层实现逻辑(学习后可以自己手搓):
先把整个任务栈空间填充一个固定的 “魔术字”(默认是tskSTACK_FILL_BYTE 为0x5A,可在源码中修改),这个操作是栈高水位检测的基础
//位于prvInitialiseNewTask函数中,可看到(初始化任务TCB堆栈) /* Avoid dependency on memset() if it is not required. */ #if ( tskSET_NEW_STACKS_TO_KNOWN_VALUE == 1 ) { /* Fill the stack with a known value to assist debugging. */ ( void ) memset( pxNewTCB->pxStack, ( int ) tskSTACK_FILL_BYTE, ( size_t ) ulStackDepth * sizeof( StackType_t ) ); } #endif /* tskSET_NEW_STACKS_TO_KNOWN_VALUE */ //其中宏定义由INCLUDE_uxTaskGetStackHighWaterMark 开启 #if ( ( configCHECK_FOR_STACK_OVERFLOW > 1 ) || ( configUSE_TRACE_FACILITY == 1 ) || ( INCLUDE_uxTaskGetStackHighWaterMark == 1 ) || ( INCLUDE_uxTaskGetStackHighWaterMark2 == 1 ) ) #define tskSET_NEW_STACKS_TO_KNOWN_VALUE 1 #else #define tskSET_NEW_STACKS_TO_KNOWN_VALUE 0 #endif当任务运行后,堆栈会被使用,此时从栈底开始魔术字会被替代,读取剩余魔术字的个数就可知剩余堆栈大小(从栈底读会更方便,因为是从栈顶开始覆盖的)
UBaseType_t uxTaskGetStackHighWaterMark( TaskHandle_t xTask ) { TCB_t * pxTCB; // 定义任务控制块指针 uint8_t * pucEndOfStack;// 栈遍历的起始地址 UBaseType_t uxReturn; // 返回值(剩余栈项数) // 步骤1:获取任务TCB(xTask=NULL时,返回当前运行任务的TCB) // prvGetTCBFromHandle是内部函数,逻辑:xTask为NULL→取当前任务TCB;否则直接用传入的TCB pxTCB = prvGetTCBFromHandle( xTask ); // 步骤2:根据栈增长方向,确定遍历的起始地址(跨架构兼容的核心) #if portSTACK_GROWTH < 0 // 栈从高地址→低地址增长(Cortex-M、ARM) { // 起始地址 = 栈底地址(pxStack) // Cortex-M中,pxStack是栈的最低地址(栈底),栈顶是pxEndOfStack(最高地址) pucEndOfStack = ( uint8_t * ) pxTCB->pxStack; } #else // 栈从低地址→高地址增长(如x86) { // 起始地址 = 栈顶地址(pxEndOfStack) pucEndOfStack = ( uint8_t * ) pxTCB->pxEndOfStack; } #endif // 步骤3:调用底层函数,统计从起始地址开始的连续魔术字数量(剩余栈空间) uxReturn = ( UBaseType_t ) prvTaskCheckFreeStackSpace( pucEndOfStack ); // 步骤4:返回剩余栈项数 return uxReturn; } //起始地址开始的连续魔术字数量 static configSTACK_DEPTH_TYPE prvTaskCheckFreeStackSpace( const uint8_t * pucStackByte ) { uint32_t ulCount = 0U; // 统计连续魔术字的字节数 // 步骤1:循环遍历栈字节,直到找到第一个不是魔术字的字节 // 核心逻辑:只要当前字节等于tskSTACK_FILL_BYTE(0x5A),就继续遍历 while( *pucStackByte == ( uint8_t ) tskSTACK_FILL_BYTE ) { // 步骤2:根据栈增长方向,移动遍历指针(跨架构兼容的关键) // Cortex-M下,portSTACK_GROWTH=-1 → pucStackByte -= (-1) → pucStackByte++(指针+1) // 也就是:从栈底(0x20001000)向栈顶(0x20001400)遍历 pucStackByte -= portSTACK_GROWTH; // 步骤3:统计连续魔术字的字节数 ulCount++; } // 步骤4:将字节数转换为栈项数(Cortex-M下,1栈项=4字节) // 比如ulCount=200字节 → 200/4=50栈项 ulCount /= ( uint32_t ) sizeof( StackType_t ); // 步骤5:返回栈项数(剩余栈空间) return ( configSTACK_DEPTH_TYPE ) ulCount; }其实FreeRTOS提供了两种方法
| 对比维度 | uxTaskGetStackHighWaterMark(v1) | uxTaskGetStackHighWaterMark2(v2) |
|---|---|---|
| 遍历范围 | 从起始地址遍历,遇到第一个非魔术字就停止(仅统计 “连续未被覆盖的魔术字”) | 遍历整个栈空间,统计所有未被覆盖的魔术字(全程遍历) |
| 检测精度 | 低(可能误判,无法反映真实最小剩余空间) | 高(精准统计运行以来栈剩余的最小值,无漏判) |
| CPU 开销 | 小(遍历到第一个非魔术字即终止) | 稍大(遍历整个栈空间,但实际影响可忽略) |
| 设计定位 | 早期版本,性能优先 | 升级版,精度优先(FreeRTOS 推荐使用) |
| 适用场景 | 性能极度敏感、栈使用简单的场景 | 调试 / 排查栈溢出、需要精准检测的场景 |
以 Cortex-M 为例,模拟遍历过程
假设:
- 任务栈总大小 = 1024 字节(256 栈项),栈底
0x20001000,栈顶0x20001400; - 任务运行后,使用了 224 字节(56 栈项),栈指针
PSP指向0x200010E0; - 栈填充的魔术字是
0x5A。
遍历过程:
- 起始地址
pucStackByte = 0x20001000(栈底),*pucStackByte=0x5A→ 进入循环; pucStackByte -= (-1)→ 指针 + 1 →0x20001001,ulCount=1;- 重复步骤,直到指针到
0x200010E0(栈使用的边界),此时*pucStackByte≠0x5A(被任务运行改写)→ 退出循环; - 此时
ulCount=224字节(因为 0x20001000 到 0x200010DF 共 224 字节都是 0x5A); - 转换为栈项数:224/4=56 栈项 → 返回 56,即 “任务运行以来,最少剩余 56 栈项(224 字节)”。
2.3发生内存踩踏怎么办?
关于这个内存分配,可以参考一下单片机内存分配管理笔记-CSDN博客
(1)内存踩踏的核心定义
内存踩踏(也叫内存越界 / 缓冲区溢出)是指程序访问了超出分配给它的内存范围的地址,导致覆盖了其他内存区域的数据(比如任务栈溢出覆盖相邻任务的栈、数组越界写覆盖全局变量)。
嵌入式中内存踩踏分两类:
- 栈踩踏(最常见):任务栈溢出、数组越界写栈内存;
- 堆踩踏:堆内存越界写、free 后重复使用(野指针)、内存碎片导致的越界。
| 成因类型 | 具体例子 |
|---|---|
| 任务栈过小导致栈溢出 | 任务栈大小 128 栈项(512 字节),但局部数组占 600 字节,栈溢出覆盖相邻内存 |
| 数组 / 缓冲区越界读写 | char buf[10]; buf[10] = 0;(下标 10 超出 buf 的 0~9 范围) |
| 指针操作不当 | 野指针(指向已释放内存的指针)、空指针解引用、指针偏移越界(p = p + 100) |
| 中断嵌套过深 | 多级中断嵌套,消耗任务栈超出预期 |
| 递归调用无终止 | 递归函数无退出条件,不断压栈导致溢出 |
| 堆内存管理不当 | pvPortMalloc(10)后,写入 15 字节数据;free 后未置 NULL,重复使用 |
(2)预防方法(核心,从源头避免)
① 合理设置任务栈大小(最关键)
结合前面的 “栈高水位” 方法,确保每个任务的栈有足够余量(≥20%)。
② 开启 FreeRTOS 栈溢出检测
FreeRTOS 提供两种栈溢出检测机制,在FreeRTOSConfig.h中配置:
configCHECK_FOR_STACK_OVERFLOW=1:检测栈指针是否超出栈范围(简单,开销小);configCHECK_FOR_STACK_OVERFLOW=2:检测栈末尾的 “魔术字” 是否被覆盖(更精准,开销稍大)。
//开启栈溢出钩子函数宏定义 #ifndef configCHECK_FOR_STACK_OVERFLOW #define configCHECK_FOR_STACK_OVERFLOW 1 #endif //钩子函数(Hook Function)是一种在程序运行过程中,由系统在特定时 //刻或事件发生时自动调用的函数。它们通常用于拦截系统级事件或消息,允 //许程序在这些事件发生时执行预定义的操作。钩子函数不是由用户直接触发, //而是由系统在满足特定条件时自动执行的。 // 栈溢出钩子函数(检测到溢出时调用)需要自己写函数内容 void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { // 打印溢出的任务名,或触发硬件报警(如LED常亮) printf("Stack Overflow: %s\n", pcTaskName); // 紧急处理:停止任务/重启系统 vTaskDelete(xTask); }③ 禁止数组越界,规范指针使用
- 数组操作前做边界检查:
if(i < sizeof(buf)/sizeof(buf[0])) { buf[i] = 0; }; - 指针初始化:所有指针定义后立即置 NULL,使用前检查是否为 NULL;
- 禁止直接操作裸指针:封装指针操作函数,增加边界检查。
④ 使用 MPU(内存保护单元)
针对 Cortex-M3/M4/M7 内核,FreeRTOS 支持 MPU,可给每个任务分配独立的内存区域,一旦访问越界,立即触发硬件异常(HardFault),精准定位问题。//这个我不太懂,等以后有事件仔细研究研究
⑤ 避免大局部变量,禁止无终止递归
- 大局部变量(如 > 256 字节)改用全局 / 静态变量,或动态分配;
- 递归函数必须有明确的退出条件,且限制递归深度。
(3)排查方法(定位已发生的内存踩踏)
① 内存填充法
在栈 / 内存区域填充 “魔术字”,运行后检查是否被覆盖
② 硬件调试(精准定位)
使用 J-Link/ST-Link 连接 MCU,通过调试器(Keil/IAR/VSCode):
- 查看内存布局(Map 文件),确定任务栈、全局变量的地址范围;
- 给可疑内存地址设置 “写断点”(Write Breakpoint),当该地址被改写时,程序暂停,直接定位到改写的代码行;
- 查看调用栈(Call Stack),分析函数嵌套和栈使用情况。
内存断点
中断产生
查看调用深度使用情况
③ 用 FreeRTOS 工具监控
vTaskList():打印所有任务的状态、栈剩余空间,快速找到栈不足的任务;- Segger SystemView:可视化监控任务运行、栈使用、中断触发,定位栈溢出时机;
xPortGetHeapStats():查看堆内存使用情况,排查堆踩踏。
④ 编译器开启严格警告
2.4FreeRTOS栈的内存从哪里来?
FreeRTOS 的 “取巧” 方法:定义一个巨大的全局数组作为 “内存池”,创建任务时从这个数组里划分一块内存作为任务栈。
创建任务时,用pvPortMalloc从ucHeap里分配栈空间,栈的起始地址和大小保存在 TCB 里
三、任务状态与链表
1.任务状态
| 状态 | 说明 | 例子 |
|---|---|---|
| 运行态 (Running) | 当前正在 CPU 上执行 | 任务 A 正在运行 |
| 就绪态 (Ready) | 已准备好运行,等待调度 | 任务 A 优先级高,排队等待 |
| 阻塞态 (Blocked) | 等待事件(如延时、队列、信号量) | vTaskDelay(10)→ 等待 10ms |
| 挂起态 (Suspended) | 被显式暂停(如vTaskSuspend()) | 任务 B 被暂停,不参与调度 |
- 运行态 → 就绪态:时间片到(同优先级任务切换),或被高优先级任务抢占;
- 运行态 → 阻塞态:调用
vTaskDelay(等待时间)、读队列没数据(等待事件); - 阻塞态 → 就绪态:等待的时间到了,或等待的事件发生了;
- 运行态 → 挂起态:调用
vTaskSuspend; - 挂起态 → 就绪态:调用
vTaskResume。
2.三大链表
| 链表类型 | 作用 | 结构 |
|---|---|---|
| 就绪链表(Ready List) | 管理所有处于“就绪态”的任务,调度器从中选择最高优先级的任务运行。 | 通常是一个数组,数组的每个索引对应一个优先级,每个元素是一个双向链表,存储该优先级下所有就绪的任务。 |
| 延迟链表(Delay List) | 管理所有处于“阻塞态(等待时间)”的任务,用于处理任务延时或超时唤醒。 | 通常是一个或两个按超时时间(唤醒时间)排序的链表。系统时钟节拍中断会检查链表头部,将已超时的任务移回就绪链表。 |
| 挂起链表(Suspended List) | 管理所有处于“挂起态”的任务,这些任务被显式暂停,不参与任何调度直到被恢复。 | 通常是一个简单的双向链表,将所有被挂起的任务链接在一起,不区分优先级或时间顺序。 |
(1)创建任务时:加入就绪链表
(2)任务调用 vTaskDelay 时:从就绪链表移到延迟链表
(3)Tick 中断时执行xTaskIncrementTick:检查延迟链表,时间到了移回就绪链表
3.任务切换
四、调度器的核心
1. 调度的核心规则
优先找最高优先级:从就绪链表的 “最高优先级” 开始往下找(比如先找优先级 4,再找 3,…,最后找 0);
同优先级轮流执行:如果最高优先级的链表有多个任务,取出第一个执行,执行完一个 tick 后放到链表尾部,再取下一个。
A.补充说明PendSV(PendableRequest forSystemService可挂起的服务请求)(重要)
在 ARM Cortex-M 内核中,PendSV 是NVIC (嵌套向量中断控制器)管理的一个异常源 (Exception)。其核心特点如下:
- 可手动触发:无硬件自动触发源,必须通过写 NVIC 的寄存器手动触发;
- 可挂起:触发后若当前有更高优先级的中断 / 异常在执行,会被 NVIC 标记为 “挂起状态”,直到高优先级处理完才执行;
- 优先级可配置:通过 NVIC 的优先级寄存器 配置优先级,FreeRTOS 中会将其设为最低优先级。
// 这行代码不会立即跳转! // 它只是把 NVIC 内部的一个小旗帜 (Pending 位) 竖起来了 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;FreeRTOS利用这个机制,把PendSV用于任务切换。FreeRTOS有两个接口可挂起PendSV:
- xPortSysTickHandler:SysTick 中断服务函数,FreeRTOS 的 “心跳”,定时触发调度(延时到期 / 时间片用完),最终通过置位 PendSV 触发调度;
- portYIELD:宏定义,主动触发调度(任务主动让权),直接置位 PendSV 触发调度;
2. 调度的触发者:Tick 中断
- Tick 中断是 “定时器中断”,每隔固定时间(默认 1ms 可设置)产生一次;
- 硬件触发:SysTick 定时器溢出。
- 入口:
SysTick_Handler(启动文件中) -> 映射到xPortSysTickHandler(port.c)。 - 逻辑判断:
xTaskIncrementTick(tasks.c) -> 发现任务 A 延时结束/时间片用完,任务 B 优先级更高。 - 请求切换:
xPortSysTickHandler设置SCB->ICSR |= SCB_ICSR_PENDSVSET。 - 退出 SysTick:回到主程序或被打断的低优先级中断。
- 进入 PendSV:
xPortPendSVHandler(port.c汇编部分)。(SysTick中断优先级比PendSV高) - 执行切换:保存任务 A 现场 -> 调度器选任务 B -> 恢复任务 B 现场。
- 运行新任务:CPU 开始执行任务 B。
/*-----------------------------------------------------------*/ /* FreeRTOS SysTick 中断处理程序 (ARM Cortex-M) */ /* 文件位置:FreeRTOS/Source/portable/[编译器]/[架构]/port.c */ /* 核心功能:更新系统节拍 + 决策是否需要任务切换 */ /*-----------------------------------------------------------*/ void xPortSysTickHandler( void ) { /* * 【关键背景】 * 在标准的 FreeRTOS Cortex-M 端口中,SysTick 中断的优先级被配置为 * configKERNEL_INTERRUPT_PRIORITY (通常是最低优先级,如 0xFF)。 * * 这意味着: * 1. 当 SysTick 运行时,所有比它优先级高的中断(用户中断)本来就可以打断它。 * 2. 但是,我们需要防止比它优先级【低】或【相等】的中断(实际上没有更低的了,主要是防止逻辑冲突) * 干扰 FreeRTOS 的内部临界区操作(如链表修改)。 * * 【优化策略】 * 因为已知当前中断优先级是最低的,所以不需要保存之前的中断屏蔽状态(因为之前肯定是全开的)。 * 直接使用 vPortRaiseBASEPRI() 快速提升优先级屏蔽阈值,比通用的 portSET_INTERRUPT_MASK_FROM_ISR() 更快。 */ /* 1. 提升 BASEPRI,屏蔽所有 <= configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断 */ /* 这创建了一个短暂的临界区,保护下面的 xTaskIncrementTick 操作 */ vPortRaiseBASEPRI(); { /* 2. 【核心逻辑】更新 RTOS 节拍计数 */ /* 此函数位于 tasks.c,执行以下操作: * a) xTickCount++ (时间加 1) * b) 检查延迟链表 (Delay List),将超时任务移至就绪链表 (Ready List) * c) 检查时间片轮转 (如果开启) * * 返回值含义: * pdFALSE: 没有更高优先级任务就绪,且不需要时间片轮转 -> 无需切换 * pdTRUE : 有更高优先级任务就绪,或时间片到期 -> 需要切换 */ if( xTaskIncrementTick() != pdFALSE ) { /* 3. 【决策】需要上下文切换 */ /* 重要设计:为什么不直接在这里切换? * 因为 SysTick 优先级较低(或者说为了保持中断响应快), * 真正的“保存/恢复寄存器”这种耗时操作应该交给优先级【最低】的 PendSV 中断去做。 * 这样可以让高优先级的硬件中断(如电机保护、通信)随时打断任务切换过程。 */ /* 4. 【触发 PendSV】 * 向 NVIC 的中断控制状态寄存器 (ICSR) 写入 PENDSVSET 位。 * 这不会立即执行 PendSV,而是将其标记为“待决 (Pending)”。 * * 硬件行为: * 当当前中断 (SysTick) 退出后,CPU 会检查是否有 pending 的中断。 * 由于 PendSV 优先级最低,它会等所有其他高优先级中断都处理完后, * 最后才执行 xPortPendSVHandler 进行真正的任务切换。 */ portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; /* 宏定义展开通常是: * #define portNVIC_INT_CTRL_REG ( *( ( volatile uint32_t * ) 0xe000ed04 ) ) * #define portNVIC_PENDSVSET_BIT ( 1UL << 28UL ) */ } } /* 5. 恢复中断优先级屏蔽 */ /* 将 BASEPRI 清零(或恢复到允许所有用户中断的状态) * 注意:这里不是恢复“旧值”,而是直接清除,因为我们在入口处就知道旧值是全开状态。 * 这使得中断嵌套可以立即恢复,高优先级中断可以再次响应。 */ vPortClearBASEPRIFromISR(); /* 函数结束,返回中断向量表指定的返回地址。 * 如果刚才触发了 PendSV,且没有更高优先级中断插入, * CPU 将在返回后立即进入 PendSV_Handler。 */ }3. 任务切换的本质:保存当前任务现场 + 恢复新任务现场(进入 PendSV)
在 ARM Cortex-M 中,异常发生时硬件会自动保存一部分寄存器(r0~r3, r12, LR, PC, xPSR)到当前栈中。因此,PendSV 只需保存剩余的 callee-saved 寄存器(r4~r11)。
/*-----------------------------------------------------------*/ /* FreeRTOS PendSV 中断处理程序 (ARM Cortex-M) */ /* 功能:实现任务切换的核心逻辑 (保存旧任务 -> 调度 -> 恢复新任务) */ /*-----------------------------------------------------------*/ __asm void xPortPendSVHandler( void ) { /* 声明外部变量和函数,链接器会在链接阶段填入实际地址 */ extern uxCriticalNesting; /* 临界区嵌套计数 (本例未直接使用,但在某些版本中会保存) */ extern pxCurrentTCB; /* 全局指针:指向当前运行任务的 TCB (任务控制块) */ extern vTaskSwitchContext; /* C 语言函数:调度器核心,负责选择下一个最高优先级的任务 */ /* *INDENT-OFF* */ PRESERVE8 /* 指示编译器保持堆栈 8 字节对齐 (ARM 架构要求) */ /* ======================================================== */ /* 阶段 1: 保存当前任务 (Current Task) 的上下文 */ /* ======================================================== */ /* 此时 CPU 已经自动将 R0-R3, R12, LR, PC, xPSR 压入栈中。 */ /* 我们需要手动保存剩下的 R4-R11。 */ mrs r0, psp /* [读] 获取当前任务的栈指针 (Process Stack Pointer) */ /* r0 现在指向当前任务栈顶 (即自动压栈后的位置) */ isb /* [同步] 指令同步屏障,确保上面的 MSR 操作完成后再执行后续 */ ldr r3, =pxCurrentTCB /* [读] 将全局变量 pxCurrentTCB 的地址加载到 r3 */ ldr r2, [ r3 ] /* [读] 解引用:r2 = pxCurrentTCB 的值 (即当前任务 TCB 结构体的地址) */ /* 此时 r2 指向当前正在运行的任务控制块 */ stmdb r0 !, { r4 - r11 } /* [写/核心] 保存剩余寄存器 */ /* 含义:将 r4-r11 的值压入 r0 指向的栈内存中 */ /* "db" = Decrement Before (先减地址再存,向下生长栈) */ /* "!" = 写回 (Write-back):更新 r0 为新的栈顶地址 */ /* 此时:当前任务的 R4-R11 已安全存入其私有栈中 */ str r0, [ r2 ] /* [写/核心] 更新 TCB 中的栈指针记录 */ /* 含义:将新的栈顶地址 (r0) 写入 TCB 的第一个成员 (pxTopOfStack) */ /* 这样下次恢复该任务时,就知道从哪里开始弹栈了 */ /* 此时:当前任务现场保存完毕,可以安全挂起 */ /* ======================================================== */ /* 阶段 2: 调用调度器 (Scheduler) 选择新任务 */ /* ======================================================== */ /* 在调用 C 函数前,必须保护可能被 C 函数破坏的寄存器 (r3, r14) */ stmdb sp !, { r3, r14 } /* [写] 保护现场 */ /* 将 r3 (pxCurrentTCB 的地址) 和 r14 (返回地址) 压入中断栈 (MSP) */ /* 防止 vTaskSwitchContext 修改它们 */ mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY /* [配置] 设置中断屏蔽阈值 */ msr basepri, r0 /* [写] 屏蔽所有优先级 <= configMAX... 的中断 */ /* 确保调度过程不被低优先级中断打断 (临界区) */ dsb /* [同步] 数据同步屏障,确保写操作完成 */ isb /* [同步] 指令同步屏障 */ bl vTaskSwitchContext /* [调用] 跳转到 C 语言调度器 */ /* 函数内部逻辑: */ /* 1. 遍历就绪链表 (Ready List) */ /* 2. 找到优先级最高的就绪任务 */ /* 3. 更新全局变量 pxCurrentTCB 指向这个新任务的 TCB */ /* 注意:此时 pxCurrentTCB 的值已经变了! */ mov r0, #0 /* [配置] 准备恢复中断 */ msr basepri, r0 /* [写] 清除 BASEPRI,重新允许所有中断 */ ldmia sp !, { r3, r14 } /* [读] 恢复保护现场 */ /* 从 MSP 弹出 r3 和 r14 */ /* 关键点:r3 依然持有 pxCurrentTCB 的【地址】 */ /* 但 r3 指向的那个内存里的【值】已经是新任务的 TCB 地址了! */ /* ======================================================== */ /* 阶段 3: 恢复新任务 (Next Task) 的上下文 */ /* ======================================================== */ /* 此时 pxCurrentTCB 已经指向了新选出的任务 */ ldr r1, [ r3 ] /* [读] 获取新任务的 TCB 地址 */ /* r1 = *pxCurrentTCB (新任务的结构体指针) */ ldr r0, [ r1 ] /* [读] 获取新任务的栈顶指针 */ /* r0 = *(新任务 TCB) = 新任务上次保存的 pxTopOfStack */ /* 此时 r0 指向新任务栈中保存 R4-R11 的位置 */ ldmia r0 !, { r4 - r11 } /* [读/核心] 恢复剩余寄存器 */ /* 含义:从 r0 指向的栈内存中弹出数据到 r4-r11 */ /* "!" = 更新 r0 为弹出后的新栈顶地址 */ /* 此时:新任务的 R4-R11 已恢复到 CPU 寄存器中 */ msr psp, r0 /* [写/核心] 切换栈指针 */ /* 将 CPU 的 PSP 寄存器更新为新任务的栈顶地址 */ /* 此时:CPU 的栈环境已经完全切换到了新任务 */ isb /* [同步] 确保 PSP 更新生效 */ bx r14 /* [跳转] 中断返回 */ /* 跳转到 r14 (中断入口时的返回地址) */ /* 【硬件魔术时刻】: */ /* CPU 检测到这是异常返回,会自动执行以下操作: */ /* 1. 使用当前的 PSP (新任务的栈) 进行出栈 */ /* 2. 自动弹出 R0-R3, R12, LR, PC, xPSR */ /* 3. PC 被更新为新任务的入口地址 */ /* 4. CPU 模式切回 Thread 模式,开始执行新任务! */ nop /* [填充] 空操作,通常用于对齐或调试断点 */ /* *INDENT-ON* */ } /*-----------------------------------------------------------*/疑问:为什么 PendSV 中需要手动保存r4~r11
(1)在正常中断函数中
如果中断函数内部用到了 r4~r11(被调用者保存寄存器),编译器会自动生成代码,先压栈保存r4~r11,函数返回前自动弹出恢复(注意此时压栈的指针是MSP)整个过程中,r4~r11 只是临时借用中断栈存放,用完就丢弃,不触碰任务自己的 PSP 栈
(2)在FreeRTOS-PendSV切换任务中
与普通中断返回地址不一样,任务切换的 PendSV中,我们故意不返回原来的任务,而是要跳到新任务去执行。所以任务上下文必须完整存在任务自己的 PSP 栈里,不能存在 MSP,需要手动换栈指针。另外,任务栈必须有固定统一的布局,编译器生成的是 “按需保存”、布局不固定
4.特殊任务:空闲任务
- 优先级最低(优先级 0);
- 调度器启动时自动创建;
- 核心工作:做 “清理工作”—— 比如删除任务后,释放任务的栈和 TCB。
5.关键配置
配置项:configUSE_PREEMPTION(抢占式调度)
- 设为 1:开启抢占式(默认),高优先级任务抢占低优先级;
- 设为 0:关闭抢占式,任务只能主动放弃 CPU,高优先级任务不会抢占。
配置项:configUSE_TIME_SLICING(时间片轮转)
- 设为 1:开启时间片轮转(默认),同优先级任务轮流执行一个 tick;
- 设为 0:关闭时间片轮转,同优先级任务中,第一个任务会一直运行,直到主动放弃。
五、总结
任务 = 函数 + 独立栈:函数是业务逻辑,栈是保存局部变量、调用关系、现场的空间;
TCB 是任务的 “身份证”:保存栈顶指针、优先级、链表节点,用来管理任务;
链表用于任务管理:就绪链表、延迟链表、挂起链表,分别管理不同状态的任务;
调度器靠 Tick 中断驱动:每隔一段时间检查延迟链表、切换任务;
核心调度规则:高优先级抢占,同优先级时间片轮转;
空闲任务是 “兜底”:优先级最低,做清理工作,还会礼让同优先级任务;
中断优先级分层:硬件中断(包括Tick) > PendSV 中断 > 任务。