FreeRTOS内核(二)任务的核心机制
一、任务的本质:函数 + 独立栈
函数比较好理解
// 任务函数示例:无限循环
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(Pendable Request for System Service可挂起的服务请求)(重要)
在 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 中断 > 任务。
更多推荐



所有评论(0)