【FreeRTOS源码学习笔记】任务切换
- 前言
- 一、实现调度器
-
- [1.1 启动调度器](#1.1 启动调度器)
- [1.2 任务切换](#1.2 任务切换)
- 二、实验现象
-
- [2.1 main函数](#2.1 main函数)
- [2.2 实验现象](#2.2 实验现象)
- 三、易错点与关键设计
前言
任务切换是 FreeRTOS 内核中唯一由硬件异常机制直接承载的核心操作。它不是在 C 代码里"跳转"到另一个任务,而是一次完整的"冻结---决策---解冻":在 PendSV 中把当前任务的寄存器现场写入其栈并回写 TCB,在 C 函数中选出下一个 TCB,再按新 TCB 的栈顶恢复现场,由硬件异常返回"无缝"回到新任务。
一、实现调度器
调度器是操作系统的核心,其主要功能就是实现任务的切换,即从就绪列表里面找到优先级最高的任务,然后执行该任务。从代码上看,调度器是由几个全局变量和一些可以实现任务切换的函数组成,全部在 task.c 文件中实现。
1.1 启动调度器
调度器的启动由 vTaskStartScheduler() 函数完成,该函数在 task.c 中定义.
1.vTaskStartScheduler() 函数
c
1 void vTaskStartScheduler( void )
2 {
3 /* 手动指定第一个运行的任务 */
4 pxCurrentTCB = &Task1TCB; (1)
5
6 /* 启动调度器 */
7 if ( xPortStartScheduler() != pdFALSE )
8 {
9 /* 调度器启动成功,则不会返回,即不会来到这里 */ (2)
10 }
11 }
(1):pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将运行的任务的任务控制块。目前我们还不支持优先级,所以这里手动指定第一个要运行的任务。
(2):调用函数 xPortStartScheduler() 启动调度器,调度器启动成功,则不会返回。该函数在 port.c 中实现。
2. xPortStartScheduler() 函数
c
1 #define portNVIC_SYSPRI2_REG (*( ( volatile uint32_t * ) 0xe000ed20))
2
3 #define portNVIC_PENDSV_PRI(((uint32_t) configKERNEL_INTERRUPT_PRIORITY ) << 16UL)
4 #define portNVIC_SYSTICK_PRI(((uint32_t) configKERNEL_INTERRUPT_PRIORITY ) << 24UL )
5
6 BaseType_t xPortStartScheduler( void )
7 {
8 /* 配置 PendSV 和 SysTick 的中断优先级为最低 */ (1)
9 portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
10 portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
11
12 /* 启动第一个任务,不再返回 */ (2)
13 prvStartFirstTask();
14
15 /* 不应该运行到这里 */
16 return 0;
17 }
(1):配置 PendSV 和 SysTick 的中断优先级为最低。SysTick 和 PendSV 都会涉及系统调度,系统调度的优先级要低于系统的其他硬件中断优先级,即优先响应系统中的外部硬件中断,所以 SysTick 和 PendSV 的中断优先级配置为最低。
(2):调用 prvStartFirstTask() 函数启动第一个任务,启动成功后,则不再返回,该函数用汇编语言编写,在 port.c 中实现。
PendSV,SYSTICK,是什么?
SysTick:操作系统的"心跳"节拍,是时间的基准,负责周期性地"告知"系统时间在流逝
核心作用:
SysTick 以固定频率产生中断,每次中断都会让 FreeRTOS 的全局节拍计数器 xTickCount 加一。所有与时间相关的功能,比如 vTaskDelay() 延时、任务阻塞超时、时间片轮转的计时,都依赖于这个不断累加的节拍。
调度触发:
在 SysTick 的中断服务函数中,FreeRTOS 会调用 xTaskIncrementTick()。这个函数在更新节拍的同时,会检查是否有阻塞任务到期需要唤醒,或者同优先级任务的时间片是否用完。如果它判断需要进行任务切换,就会挂起(Pend)PendSV 异常,将实际的切换工作移交出去。
PendSV:专职的"上下文切换器"
PendSV:是 Cortex-M 内核的一个特殊异常,它"可挂起"的特性使其成为实现操作系统上下文切换的理想选择。
唯一职责:
在 FreeRTOS 的 Cortex-M 移植版中,PendSV 中断服务程序是唯一执行任务上下文切换的地方。当调度器决定要切换任务时,它不会立即执行,而是先"挂起"一个 PendSV 异常。
具体工作:
当 PendSV 异常最终被响应时,它的处理函数会保存当前任务的现场(寄存器等上下文),然后从就绪列表中选出最高优先级的任务,并恢复新任务的现场,从而完成切换。
为什么优先级必须设为最低?对 PendSV 而言:
这确保了任务切换永远不会打断任何应用程序的中断服务程序。它必须"耐心等待"所有更高优先级的中断都处理完毕,然后才执行切换,从而保证了中断响应的实时性和系统稳定性。
对 SysTick 而言:
将其设为低优先级,是为了避免"心跳"本身抢占更紧急的硬件中断。SysTick 中断的处理内容(更新节拍、检查列表)即使被短暂延迟,只要在下一个节拍到来前完成,就不会造成"丢拍"或时间计算错误。
systick优先级低,时间不走,不是系统就乱了吗?当时问出这个问题是因为我把sysytick和系统时钟搞混了。systick是系统滴答定时器,它只是一个定时器,从系统时钟取时钟信号,是内核自带的一个24位倒计时定时器,常用来做RTOS的系统节拍。不是时钟源。系统时钟:是MCU芯片的主时钟(HCLK,SYSCLK),给各个外设,内核提供时钟信号的。
SysTick 的硬件计数器并没有停,停的只是"中断服务程序被延迟响应"。FreeRTOS 的 tick 计数是在 SysTick ISR 里 xTaskIncrementTick() 累加的,所以延迟响应会让系统时间"稍微晚一点",但不会凭空乱掉。
3. prvStartFirstTask() 函数
prvStartFirstTask() 函数用于开始第一个任务,主要进行了两个操作,一是更新 MSP 的值,二是产生 SVC 系统调用,然后到 SVC 的中断服务函数中真正切换到第一个任务。
c
1 __asm void prvStartFirstTask( void )
2 {
3 PRESERVE8 (1)
4
5 /* 在 Cortex-M 中,0xE000ED08 是 SCB_VTOR 这个寄存器的地址, (2)
6 里面存放的是向量表的起始地址,即 msp 的地址 */
7 ldr r0, =0xE000ED08 (3)
8 ldr r0, [r0] (4)
9 ldr r0, [r0] (5)
10
11 /* 设置主栈指针 msp 的值 */ (6)
12 msr msp, r0
13
14 /* 启用全局中断 */ (7)
15 cpsie i
16 cpsie f
17 dsb
18 isb
19
20 /* 调用 SVC 启动第一个任务 */ (8)
21 svc 0
22 nop
23 nop
24 }
一句话总结它在干什么 :
重置主栈指针(MSP),开启全局中断,然后通过 SVC 0 指令主动触发一个异常,利用这个异常把 CPU 的控制权强行交给第一个任务。
- 重置主栈指针(MSP):
第7行:r0 = 0xE000ED08 (这是 VTOR 寄存器地址,即向量表偏移寄存器)。
第8行:r0 = r0 (读出 VTOR 里的值,通常也是向量表的起始地址(FLASH的起始地址),比如 0x08000000)。
第9行:r0 = r0 (去向量表的最开头,把第一个字读出来。向量表的第一个字,恰好就是系统复位后的初始 MSP 值)。
第12行:msr msp, r0(把这个初始的主栈顶地址赋给 MSP)。
(ldr:意思是Load,加载读内存。msr:把通用寄存器的值,写入内核特殊寄存器)为什么这么做? 因为之前配置调度器、创建任务时,可能把 MSP 弄脏了,这里直接重新把主栈指针(MSP)恢复到最干净、最原始的状态。
- 开启全局中断:
cpsie i 和 cpsie f 是开中断指令。
FreeRTOS 整个初始化(创建任务、初始化链表)的过程中,为了安全,通常是关中断的。现在一切就绪,马上就要运行第一个任务了,必须把中断打开,否则 SysTick 无法产生,系统就没有了"心跳"。- 为什么要用 svc 0 启动第一个任务?(第28行)
这是整个启动过程的灵魂!
在 FreeRTOS 中,任务使用的是 PSP(进程栈指针),而中断和内核使用的是 MSP(主栈指针)。
裸机启动时,CPU 默认处于线程模式+特权级,使用的是 MSP。
如果直接 跳转 到第一个任务,CPU 还是用 MSP,就会乱套。因此,FreeRTOS 巧妙地借用了一条 CPU 指令:svc 0(触发 SVC 异常)。
一旦触发 SVC 异常,CPU 会自动进入 Handler 模式,强制使用 MSP。此时,代码会跳入 vPortSVCHandler 汇编函数(由 FreeRTOS 编写)。后面会重点讲这个函数。
4. vPortSVCHandler() 函数
SVC 中断要想被成功响应,其函数名必须与向量表注册的名称一致,在启动文件的向量表中,SVC 的中断服务函数注册的名称是 SVC_Handler,所以 SVC 中断服务函数的名称应该写成 SVC_Handler,但是在 FreeRTOS 的官方版本中写为 vPortSVCHandler(),为了能够顺利响应 SVC 中断,我们有两个选择------修改中断向量表中 SVC 的注册的函数名称,或者修改 FreeRTOS 中 SVC 的中断服务名称。这里采取第二种方法,即在 FreeRTOSConfig.h 中添加宏定义的方法来修改,同时把 PendSV 和 SysTick 的中断服务函数名也改成与向量表中的一致。
c
//修改 FreeRTOS 中 SVC、PendSV 和 SysTick 中断服务函数的名称
1 #define xPortPendSVHandler PendSV_Handler
2 #define xPortSysTickHandler SysTick_Handler
3 #define vPortSVCHandler SVC_Handler
vPortSVCHandler() 函数开始真正启动第一个任务,不再返回
c
1 __asm void vPortSVCHandler( void )
2 {
3 extern pxCurrentTCB; (1)
4
5 PRESERVE8
6
7 ldr r3, =pxCurrentTCB (2)
8 ldr r1, [r3] (3)
9 ldr r0, [r1] (4)
10 ldmia r0!, {r4-r11} (5)
11 msr psp, r0 (6)
12 isb //指令同步屏障,确保 PSP 修改立刻生效
13 mov r0, #0 (7)
14 msr basepri, r0 (8)
15 orr r14, #0xd //orr 按位或 (9)
16
17 bx r14 //bx 跳转到指定的寄存器地址 (10)
18 }
( 1 ):声明外部变量 pxCurrentTCB,pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将运行的任务的任务控制块。
( 2 ):加载 pxCurrentTCB 的地址到 r3。
( 3 ):加载 pxCurrentTCB 到 r1。
( 4 ):加载 pxCurrentTCB 指向的任务控制块到 r0,任务控制块的第一个成员就是栈顶指针,所以此时 r0 等于栈顶指针。一个刚刚被创建但尚未运行过的任务的栈空间分布具体如图 5-8 所示,即 r0 等于图 5-8 中的 pxTopOfStack。
( 5 ):以 r0 为基地址,将栈中向上增长的 8 个字的内容加载到 CPU 寄存器 r4 ~ r11,同时 r0 也会随之自增。
( 6 ):将新的栈顶指针 r0 更新到 psp,任务执行时使用的栈指针是 psp。
( 7 ):将寄存器 r0 清零。
( 8 ):设置 BASEPRI 寄存器的值为 0,即打开所有中断。BASEPRI 是一个中断屏蔽寄存器,大于等于此寄存器值的中断都将被屏蔽。
( 9 ):在 CPU 刚进入 SVC 异常时,硬件会自动把 LR (也就是 r14) 设置为 0xFFFFFFF9。这个值代表:异常返回后,进入 Thread 模式,继续使用 MSP。0xFFFFFFF9 的低四位是 1001(十六进制的 9),#0xd 是 1101。按位或之后(1001 | 1101 = 1101),r14 变成了 0xFFFFFFFD!
( 10 ):0xFFFFFFFD 告诉 CPU:"返回时,进入 Thread 模式,使用 PSP"。bx r14。CPU 一看 r14 是 0xFFFFFFFD,立刻执行异常返回动作:从刚刚设置好的 PSP 指向的栈里,自动弹出剩下的 8 个寄存器(包含 PC 指针)。
vPortSVCHandler 的作用是:它把新任务的栈指针装载进 PSP,开启全局中断,并篡改 LR 寄存器,逼迫 CPU 通过"异常返回"的机制,从 MSP 切换到 PSP,正式跑起第一个任务。
MSP 与 PSP:
- 它们的本质是什么?
物理实体 :它们是 CPU 内核里真实存在的两个 32 位寄存器。整个芯片就只有一个 MSP 和 一个 PSP。
作用:它们里面存的是内存地址。谁的值被装载到了栈指针(SP)上,CPU 压栈/出栈时就会去谁指向的那个内存区域操作。- 它们分别管什么?
Cortex-M 架构在设计时,就把"操作系统内核"和"普通应用程序"的安全隔离开了。
MSP主栈指针
谁在用 :所有的中断服务程序(ISR)(比如按键中断、串口中断)、所有的异常处理程序(比如 SysTick、PendSV、SVC)、以及系统刚复位启动时的代码。
在哪用 :CPU 处于 Handler 模式时,硬件强制只能使用 MSP。或者 CPU 处于 Thread 模式但 CONTROL1 = 0 时,也用 MSP。
PSP进程栈指针
谁在用 :只有普通任务(Thread 模式)才会使用。
在哪用:CPU 处于 Thread 模式且 CONTROL1 = 1 时,才使用 PSP。- 它们在系统运行中是怎么交替的?
- 任务运行(Thread 模式):当前 SP = PSP,指向任务A的栈。
- 中断突发(切入 Handler 模式):CPU 硬件自动把现场压入 PSP 指向的任务A栈里,然后强行把 SP 切换成 MSP。
- 执行中断:中断代码里如果用局部变量,全压在 MSP 栈里。
- 中断返回(退回到 Thread 模式):CPU 根据 LR(EXC_RETURN)的魔法值,强行把 SP 切换回 >PSP,弹出任务A的现场,任务继续跑。
- 为什么要用双栈模式?
- 任务如果不小心栈溢出了,直接就把主栈里的中断返回地址或者内核数据踩烂了,系统直接死机,且极难排查。
- 如果只有一个栈,每次任务切换时,为了腾出空间给新任务,你不得不把老任务栈里的数据全部"搬"到别的地方去,这非常消耗 CPU 周期。
CONTROL 是 Cortex-M 内核里的一个专门的系统控制寄存器(特殊寄存器)其中 CONTROL1(也叫 SPSEL 位) 专门用来决定在 Thread 模式下用哪个栈:
CONTROL1 = 0:Thread 模式使用 MSP。
CONTROL1 = 1:Thread 模式使用 PSP。
(注:在 Handler 模式下,硬件强制使用 MSP,此时 CONTROL1 无效)。
在 vPortSVCHandler 里只要修改了 LR(orr r14, #0xd),就能让系统跑在 PSP 上的原因。 硬件看到 LR 的改变,自动帮你把 CONTROL1 改成了 1。
简单说说Thread 模式 和 Handler 模式
- Thread 模式(线程模式)
在干嘛:执行你写的普通业务代码。在 FreeRTOS 里,所有的任务(Task)都跑在这个模式下。
用哪个栈:看情况。可以用 MSP(主栈),也可以用 PSP(进程栈)。在 FreeRTOS 里,任务通常被强制使用 PSP。- Handler 模式(处理模式)
在干嘛:处理突发事件。只要是中断服务函数(ISR)或者异常处理(比如 SysTick、PendSV、SVC、外部按键中断),CPU 就会进入这个模式。
用哪个栈:绝对强制只能使用 MSP(主栈)。硬件规定,在这个模式下不能碰 PSP。
1.2 任务切换
任务切换就是在就绪列表中寻找优先级最高的就绪任务,然后执行该任务。但是目前我们还不支持优先级,仅实现两个任务轮流切换。
1. taskYIELD()
c
1 /* 在 task.h 中定义 */
2 #define taskYIELD() portYIELD()
3
4
5 /* 在 portmacro.h 中定义 */
6 /* 中断控制状态寄存器:0xe000ed04
7 * Bit 28 PENDSVSET: PendSV 悬起位
8 */
9 #define portNVIC_INT_CTRL_REG (*(( volatile uint32_t *) 0xe000ed04))
10 #define portNVIC_PENDSVSET_BIT ( 1UL << 28UL )
11
12 #define portSY_FULL_READ_WRITE ( 15 )
13
14 #define portYIELD()\ ( 1 )\
15 { \
16 /* 触发 PendSV,产生上下文切换 */\ \
17 portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; (1)\
18 __dsb( portSY_FULL_READ_WRITE );\
19 __isb( portSY_FULL_READ_WRITE );\
20 }
( 1 ):portYIELD() 函数的实现很简单,实际就是将 PendSV 的悬起位置 1,当没有其他中断运行时响应 PendSV 中断,去执行我们写好的 PendSV 中断服务函数,在里面实现任务切换。
2. xPortPendSVHandler() 函数
PendSV 中断服务函数 xPortPendSVHandler() 是真正实现任务切换的地方
任务切换的本质其实非常简单,就三步:
保存老任务(任务 A)的现场。
找出新任务(任务 B)。
恢复新任务的现场。
c
1 __asm void xPortPendSVHandler( void )
2 {
3 extern pxCurrentTCB; (1)
4 extern vTaskSwitchContext; (2)
5
6 PRESERVE8 (3)
7
8 mrs r0, psp (4)
9 isb
10
11 ldr r3, =pxCurrentTCB (5)
12 ldr r2, [r3] (6)
13
14 stmdb r0!, {r4-r11} (7)
15 str r0, [r2] (8)
16
17 stmdb sp!, {r3, r14} (9)
18 mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY (10)
19 msr basepri, r0 (11)
20 dsb
21 isb
22 bl vTaskSwitchContext (12)
23 mov r0, #0 (13)
24 msr basepri, r0
25 ldmia sp!, {r3, r14} (14)
26
27 ldr r1, [r3] (15)
28 ldr r0, [r1] (16)
29 ldmia r0!, {r4-r11} (17)
30 msr psp, r0 (18)
31 isb
32 bx r14 (19)
33 nop
34 }
( 1 ):声明外部变量 pxCurrentTCB。pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将运行的任务的任务控制块。
( 2 ):声明外部函数 vTaskSwitchContext,稍后会用到。
( 3 ):当前栈需要按照 8 字节对齐,如果都是 32 位的操作,则 4 个字节对齐即可。在 Cortex-M 中浮点运算是 8 字节的。
( 4 ):把当前 PSP 的值(也就是老任务栈顶的指针)读出来放到 r0 里。
在 CPU 决定"马上要跳到 PendSV 的函数入口"的这一秒钟,CPU 硬件自动触发了异常进入(Exception Entry)机制:1.自动压栈:CPU 硬件强行把当前任务的 8 个寄存器(R0-R3, R12, LR, PC, xPSR)压入当前正在使用的栈(此时是 PSP,也就是老任务的私有栈)。2.更新 PSP:PSP 指针因为压栈,自动向下移动了 32 个字节。 3.强制换栈:CPU 硬件把当前的栈指针(SP)强行切换到 MSP(因为接下来要进入 Handler 模式了)。4.提取入口地址:CPU 去向量表里查到 xPortPendSVHandler 的地址,并把 PC(程序计数器)指向那里。
所以此时读出来的 psp,已经是硬件把那 8 个寄存器压栈之后的新栈顶了。剩下的 r4 ~ r11 需要手动保存。此时 psp 的具体指向如图 5-10 所示。

( 5 ):加载 pxCurrentTCB 的地址到 r3。
( 6 ):加载 r3 指向的内容到 r2,即 r2 等于 pxCurrentTCB。
( 7 ):以 r0 作为基址(指针先递减,再操作,STMDB 的 DB 表示 Decrease Before),将 CPU 寄存器 r4 ~ r11 的值存储到任务栈,同时更新 r0 的值,此时 r0 的指向如图 5-11 所示。

( 8 ):将 r0 的值存储到 r2 指向的内容,r2 等于 pxCurrentTCB。具体为将 r0 的值存储到上一个任务的栈顶指针 pxTopOfStack,具体指向与图 5-11 中的 r0 指向一样。(因为刚刚在压栈的时候没有更新 pxTopOfStack,现在一次性更新了)到此,上下文切换中的上文保存就完成了。
( 9 ):注意这里的 sp 是 MSP(因为我们在 Handler 模式)。所以必须暂存到 MSP 上。将 r3 和 r14 临时压入栈(在整个系统中,中断使用的是主栈,栈指针使用的是 msp),因为接下来要调用函数 vTaskSwitchContext(),调用函数时,返回地址自动保存到 r14 中,所以一旦调用发生,r14 的值会被覆盖(PendSV 中断服务函数执行完毕后,返回时需要根据 r14 的值来决定返回处理器模式还是任务模式,出栈时使用的是 psp 还是 msp),因此需要入栈保护。r3 保存的是当前正在运行的任务(准确来说是上文,因为接下来即将切换到新的任务)的 TCB 指针(pxCurrentTCB)地址,函数调用后 pxCurrentTCB 的值会被更新,后面还需要通过 r3 来操作 pxCurrentTCB,但是运行函数 vTaskSwitchContext() 时不确定会不会使用 r3 寄存器作为中间变量,所以为了保险,将 r3 也入栈并保护起来。
( 10 ):将 configMAX_SYSCALL_INTERRUPT_PRIORITY 的值存储到 r0,该宏在 FreeRTOSConfig.h 中定义,用来配置中断屏蔽寄存器 basepri 的值,高 4 位有效。目前配置为 191,因为是高 4 位有效,所以实际值等于 11,即优先级高于或者等于 11 的中断都将被屏蔽。在关中断方面,FreeRTOS 与其他 RTOS 关中断不同,是操作 BASEPRI 寄存器来预留一部分中断,并不像 µC/OS 或者 RT-Thread 那样直接操作 PRIMASK 把所有中断都关闭(除了硬 FAULT)。
( 11 ):关中断,进入临界段,因为接下来要更新全局指针 pxCurrentTCB 的值。
3. vTaskSwitchContext() 函数
( 12 ):调用函数 vTaskSwitchContext()。该函数在 task.c 中定义,作用只有一个------选择优先级最高的任务,然后更新 pxCurrentTCB。目前我们还不支持优先级,则手动切换,不是任务 1 就是任务 2
c
1 void vTaskSwitchContext( void )
2 {
3 /* 两个任务轮流切换 */
4 if ( pxCurrentTCB == &Task1TCB )
5 {
6 pxCurrentTCB = &Task2TCB;
7 }
8 else
9 {
10 pxCurrentTCB = &Task1TCB;
11 }
12 }
( 13 ):退出临界段,开中断,直接向 basepri 中写 0。
( 14 ):把刚才暂存在 MSP 里的 r3 和 r14 恢复回来。此时 r3 里的 pxCurrentTCB 已经被 C 函数更新了,指向了新任务!
( 15 ):r3 是 pxCurrentTCB 的地址,这句把新任务的 TCB 地址读到了 r1
( 16 ):加载 r1 指向的内容到 r0,即下一个要运行的任务的栈顶指针。
( 17 ):以 r0 作为基地址(先取值,再递增指针,ldmia 的 IA 表示 IncreaseAfter),将下一个要运行的任务的任务栈内容加载到 CPU 寄存器 r4 ~ r11。
( 18 ):更新 psp 的值,等异常退出时,会以 psp 作为基地址,将任务栈中剩下的内容自动加载到 CPU 寄存器。
( 19 ):异常发生时,r14 中保存异常返回标志,包括返回后进入任务模式还是处理器模式、使用 psp 栈指针还是 msp 栈指针。此时的 r14 等于 0xfffffffd,表示异常返回后进入任务模式,sp 以 psp 作为栈指针出栈,出栈完毕后 psp 指向任务栈的栈顶。当调用 bx r14 指令后,系统以 psp 作为 sp 指针出栈,把接下来要运行的新任务的任务栈中剩下的内容加载到 CPU 寄存器:r0 (任务形参)、r1、r2、r3、r12、r14 (LR)、r15 (PC) 和 xPSR,从而切换到新的任务。
二、实验现象
2.1 main函数
任务的创建、就绪列表的实现以及调度器的实现均已介绍完毕,现在我们把全部的测试代码都放到 main.c 中。
c
22 *****************************************************************************************
23 * 包含的头文件
24 *****************************************************************************************
25 */
26 #include "FreeRTOS.h"
27 #include "task.h"
28
29 /*
30 *****************************************************************************************
31 * 全局变量
32 *****************************************************************************************
33 */
34 portCHAR flag1;
35 portCHAR flag2;
36
37 extern List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
38
39
40 /*
41 *****************************************************************************************
42 * 任务控制块和 STACK
43 *****************************************************************************************
44 */
45 TaskHandle_t Task1_Handle;
46 #define TASK1_STACK_SIZE 128
47 StackType_t Task1Stack[TASK1_STACK_SIZE];
48 TCB_t Task1TCB;
49
50 TaskHandle_t Task2_Handle;
51 #define TASK2_STACK_SIZE 128
52 StackType_t Task2Stack[TASK2_STACK_SIZE];
53 TCB_t Task2TCB;
54
55
56 /*
57 *****************************************************************************************
58 * 函数声明
59 *****************************************************************************************
60 */
61 void delay (uint32_t count);
62 void Task1_Entry( void *p_arg );
63 void Task2_Entry( void *p_arg );
64
65 /*
66 *****************************************************************************************
67 * main() 函数
68 *****************************************************************************************
69 */
70 /*
71 * 注意事项:1) 该工程使用软件仿真,debug 需要选择为 Ude Simulator。
72 * 2) 在 Target 选项卡里面把晶振 Xtal(MHz) 的值改为 25,默认是 12,
73 * 改成 25 是为了与 system_ARMCM3.c 中定义的 __SYSTEM_CLOCK 相同,
74 * 确保仿真时钟一致
75 */
76 int main(void)
77 {
78 /* 硬件初始化 */
79 /* 将硬件相关的初始化放在这里,如果是软件仿真,则没有相关初始化代码 */
80
81 /* 初始化与任务相关的列表,如就绪列表 */
82 prvInitialiseTaskLists();
83
84 /* 创建任务 */
85 Task1_Handle =
86 xTaskCreateStatic( (TaskFunction_t)Task1_Entry, /* 任务入口 */
87 (char *)"Task1", /* 任务名称,字符串形式 */
88 (uint32_t)TASK1_STACK_SIZE , /* 任务栈大小,单位为字 */
89 (void *) NULL, /* 任务形参 */
90 (StackType_t *)Task1Stack, /* 任务栈起始地址 */
91 (TCB_t *)&Task1TCB ); /* 任务控制块 */
92 /* 将任务添加到就绪列表 */
93 vListInsertEnd( &( pxReadyTasksLists[1] ),
94 &( ((TCB_t *)(&Task1TCB))->xStateListItem ) );
95
96 Task2_Handle =
97 xTaskCreateStatic( (TaskFunction_t)Task2_Entry, /* 任务入口 */
98 (char *)"Task2", /* 任务名称,字符串形式 */
99 (uint32_t)TASK2_STACK_SIZE , /* 任务栈大小,单位为字 */
100 (void *) NULL, /* 任务形参 */
101 (StackType_t *)Task2Stack, /* 任务栈起始地址 */
102 (TCB_t *)&Task2TCB ); /* 任务控制块 */
103 /* 将任务添加到就绪列表 */
104 vListInsertEnd( &( pxReadyTasksLists[2] ),
105 &( ((TCB_t *)(&Task2TCB))->xStateListItem ) );
106
107 /* 启动调度器,开始多任务调度,启动成功则不返回 */
108 vTaskStartScheduler();
109
110 for (;;)
111 {
112 /* 系统启动成功则不会到达这里 */
113 }
114 }
115
116 /*
117 *****************************************************************************************
118 * 函数实现
119 *****************************************************************************************
120 */
121 /* 软件延时 */
122 void delay (uint32_t count)
123 {
124 for ( ; count!=0; count--);
125 }
126 /* 任务 1 */
127 void Task1_Entry( void *p_arg )
128 {
129 for ( ; ; )
130 {
131 flag1 = 1;
132 delay( 100 );
133 flag1 = 0;
134 delay( 100 );
135
136 /* 任务切换,这里是手动切换 */
137 taskYIELD(); ( 注意 )
138 }
139 }
140
141 /* 任务 2 */
142 void Task2_Entry( void *p_arg )
143 {
144 for ( ; ; )
145 {
146 flag2 = 1;
147 delay( 100 );
148 flag2 = 0;
149 delay( 100 );
150
151 /* 任务切换,这里是手动切换 */
152 taskYIELD(); ( 注意 )
153 }
154 }
- 每个局部的代码均已经讲解过,参见代码注释即可。
- ( 注意 ):因为目前还不支持优先级,所以每个任务执行完毕之后都主动调用任务切换函数 taskYIELD() 来实现任务的切换。
2.2 实验现象
软件仿真查看波形

三、易错点与关键设计
-
pxTopOfStack 必须是 TCB 首成员。 PendSV 汇编以偏移 0 直接读写它;任何改变其在结构体中位置的"优化"都会让切换从第一行汇编开始崩溃。
-
PendSV 最低优先级不可随意提高。 提高它会让切换嵌套进更高优先级 ISR 中执行,破坏"所有中断完成后只切换一次"的保证,导致栈或 pxCurrentTCB 状态不一致。
-
basepri 临界区保护的是链表操作,而非汇编本身的寄存器保存。 R4-R11 的保存发生在关中断之前是安全的,因为它只操作当前任务自己的栈和 TCB;但 vTaskSwitchContext() 要遍历/修改就绪链表,必须处于临界区中。
-
SVC 只负责启动第一个任务,不负责常规切换。 vPortSVCHandler 在调度器启动时从第一个任务的伪造栈帧中恢复现场并切换到 PSP,之后的切换全部经由 PendSV。
-
任务运行后,MSP(主栈)不会就停止了,只要进了中断或异常(哪怕是最普通的串口中断),CPU 硬件就会立刻切回 MSP。MSP 是内核和所有中断的专属通道,系统运行期间,MSP 的使用频率极高。
-
bx r14 是触发硬件异常返回的开关。因为 r14 (LR) 里被塞了特殊值(如 0xFFFFFFFD),CPU 看到它,才会自动去 PSP 弹栈,并切回 Thread 模式。
-
SysTick 和 PendSV 的优先级必须最低。防止任务切换(PendSV)打断紧急的硬件中断(ISR)。等所有中断都处理完了,它才出来执行换人操作。SysTick 设低是为了不抢占更紧急的硬件中断,它只是负责打个节拍。
-
Thread 模式与 Handler 模式的严格隔离
Thread 模式:跑普通任务,默认用 PSP。
Handler 模式:跑中断/异常,强制用 MSP。
作用:保护现场互不干扰,任务写烂了也不影响中断服务程序。