干货 | FreeRTOS 学习笔记 ——堆栈(任务切换的关键)
本篇中“堆栈”术语(stack)是指计算机(包括MCU)处理器通过堆栈寄存器(stack pointer register)来存取数据的内存区域。常用的访问方式包括 Push/Pop, 以及根据堆栈指针寄存器的间接寻址访问。
先复习一下C语言中局部变量是怎么存放的。
举例,main() 函数调用了 func1(),然后 func1() 又调用了 sub2(),如下图
当CPU执行到 sub2() 函数里面的时候,main() 以及 func1() 中的局部变量不在作用域内,但是它们的存储都保留着。所以通过参数传递指针的办法,sub2() 是可以访问到 main() 的局部变量的数据的。但是反过来,当 sub2() 函数返回以后,sub2() 的局部变量占用的空间就被撤消了,哪怕是通过用指针返回值传递给 func1(),func1() 获得的地址也是无效的内容。因为一个C语言函数被执行的时候,它会在堆栈上保留出一块空间用来存放自己的局部变量,以及保存重要的寄存器。一般在函数入口就会把当前堆栈指针保存,在返回之前则恢复。
上面这个调用关系,堆栈的使用情况一般是这样的:
从左到右,是函数调用(嵌套)的过程;从右到左是函数一级级返回的过程。
此外,中断服务程序(ISR)也是一种函数,稍微特殊一点。当中断发生的时候,CPU会从中断向量表里面去取出中断向量对应的中断服务程序入口地址,然后自动保存关键的寄存器到堆栈上,再跳转到中断服务程序的入口地址去执行。中断服务程序也会在堆栈上保留出一块空间用来存放自己的局部变量,以及保存一些没有被自动保存的寄存器。而主程序以及它调用的子程序是无从知道中断服务程序在什么时候被调用的(仅有使用休眠指令暂停执行是例外)。
怎样来实现多任务呢?
如上面,假设一个多任务的要求是,sub2() 想暂时等待一下,但不返回,而让 func1() 继续执行。这又意味着 func1() 认为是 sub2() 已经返回了?这显然不对,因为 sub2() 一旦返回,堆栈指针就改回去 func1() 执行时的状态,sub2() 所保留的堆栈空间被划归成空余的,随时可能被改写(比如 func1() 调用任何子函数,比如发生中断调用),它不可能再继续执行了。
可以分析出来,要实现多任务,必须让每个任务函数自己占有的堆栈空间在任务期间都是有效的,不能被别的任务覆盖。然后,我们需要一个调度程序来协调堆栈的使用。还有,任务都是平级的,不存在相互之间的调用关系,那就只能是被调度程序调用。改进一下,如果堆栈像下面这样使用:
我们由一个调度器(Scheduler)来创建了 task1() 并运行,然后又创建了 task2() 并切换到 task2() 执行。任何时候可以切换到 task1() 执行也能再随意切换回 task2(). 当一个任务结束时它占据的堆栈空间被划归为空余的。
先不管理调度器的实现细节,上面这样使用堆栈似乎多任务就可以了。直观上有不完美的地方,就是前面的任务结束后会造成“内存碎片”。如果后面新建的任务申请的堆栈……不是刚刚好……
且慢,调度器怎样预先知道一个任务函数要使用多少堆栈空间?C语言函数调用可不需要知道子函数使用多少堆栈——随你用,整个都是你的。但是对于多任务,不允许某一个任务使用全部的堆栈呀。
上面还有问题:左起第三个图,task1() 如果要想调用一个子程序,是不可以的。因为它再修改堆栈指针的话,就破坏了 task2() 的私有数据。若不能调子程序,多任务系统的任务间通信、同步都难以实现了……
所以,为一个任务所保留的堆栈空间,不能只是任务的函数自身占用的堆栈空间的大小。
现在来看看 FreeRTOS 是怎样管理任务的堆栈的吧。
借用Tutorial Guide中的图
FreeRTOS 在内存中申请了一块空间,用来存放任务的堆栈和存放任务的配置(Task Control Block)。这个空间的使用是 FreeRTOS 自己管理的,任务的创建和销毁对应着这块内存里面的分配和释放(注意是独立于C函数 malloc(), free()管理的内存)。
这就是为什么创建任务的时候一定需要指定堆栈大小的原因——申请多少给多少,不够了创建不了任务;给多少不允许用超了,不管任务里面是怎么嵌套调用函数的。此外 FreeRTOS 还有动态内存分配功能,让任务可以使用堆栈之外的剩余内存。但是预先指定的堆栈大小是重要的,因为单片机资源有限,分配过多了就影响其它任务了。好在单片机运行的任务一般不会太复杂,可以分析或者开发阶段通过测试决定一个堆栈用量。
我写的第一个 FreeRTOS 的程序,创建了两个任务分别点两个LED。在 main() 中,以及在任务的函数中分别输出各自一个局部变量的地址,可以判断出堆栈分配的位置:
从串口输出的这个结果可以看到 main() 的局部变量在总的堆栈上,也就是总的程序初始化后堆栈指针所在位置(通常是SRAM末尾)附近。而全局变量在 .data 或者 .bss 段里面,是内存中按顺序安排的。两个任务的堆栈分配在内存的低端,看来是使用 .bss 的位置固定分配的一块内存里面取的。
再用 arm-none-eabi-objdump 工具对编译生成的 ELF 文件进行查看,可以获知内存的静态分配情况:
20040000 g .data 00000000 _sdata
20040000 l O .data 00000004 uxCriticalNesting
20040000 l d .data 00000000 .data
20040004 l O .data 00000004 xFreeBytesRemaining
20040008 g .bss 00000000 __bss_start__
20040008 g .bss 00000000 _sbss
20040008 g .data 00000000 _edata
20040008 l O .bss 00000014 xSuspendedTaskList
20040008 l d .bss 00000000 .bss
2004001c l O .bss 00000014 xPendingReadyList
20040030 l O .bss 00000004 pxDelayedTaskList
20040034 l O .bss 00000004 xNextTaskUnblockTime
20040038 l O .bss 00000004 xTickCount
2004003c g O .bss 00000004 pxCurrentTCB
20040040 l O .bss 00000004 uxTopReadyPriority
20040044 l O .bss 00000004 pxOverflowDelayedTaskList
20040048 l O .bss 00000004 uxCurrentNumberOfTasks
2004004c l O .bss 00000064 pxReadyTasksLists
200400b0 l O .bss 00000014 xDelayedTaskList1
200400c4 l O .bss 00000014 xDelayedTaskList2
200400d8 l O .bss 00000014 xTasksWaitingTermination
200400ec l O .bss 00000004 xSchedulerRunning
200400f0 l O .bss 00000004 uxTaskNumber
200400f4 l O .bss 00000004 uxDeletedTasksWaitingCleanUp
200400f8 l O .bss 00000004 uxSchedulerSuspended
200400fc l O .bss 00000004 xIdleTaskHandle
20040100 l O .bss 00000004 xNumOfOverflows
20040104 l O .bss 00000004 uxPendedTicks
20040108 l O .bss 00000004 xYieldPending
2004010c l O .bss 00000001 ucMaxSysCallPriority
20040110 l O .bss 00000004 ulMaxPRIGROUPValue
20040114 l O .bss 00000008 xStart
2004011c l O .bss 00000004 xHeapHasBeenInitialised.5018
20040120 l O .bss 00012c00 ucHeap
20052d20 l O .bss 00000008 xEnd
20052d28 l O .bss 00000004 xTimerQueue
20052d2c l O .bss 00000014 xActiveTimerList1
20052d40 l O .bss 00000014 xActiveTimerList2
20052d54 l O .bss 00000004 pxCurrentTimerList
20052d58 l O .bss 00000004 pxOverflowTimerList
20052d5c l O .bss 00000004 xTimerTaskHandle
20052d60 l O .bss 00000004 xLastTime.5299
20052d64 g O .bss 00000010 dummy
20052d74 g O .bss 00000040 xQueueRegistry
20052db4 g .bss 00000000 __bss_end__
20052db4 g .bss 00000000 _ebss
可看到固定分配了 0x12c00 字节给 ucHeap,两个任务的堆栈都在这块空间里面。在我使用的 demo 例子的
FreeRTOSConfig.h 头文件中有这么一句
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 75 * 1024 ) )
这个值刚好就是 ucHeap 这个变量占用的内存大小。
研究一下 FreeRTOS 实现的细节。
我用的 port 部分是 CORTEX_M3 目录下的(虽然运行在Cortex-m4 CPU上,没用到浮点处理器),在 FreeRTOS 代码 tasks.c 中定义了一个结构来描述 TCB 数据
根据配置需要,TCB的许多数据域是可选的,这样不需要用的就被条件编译去掉,节省内存。全局变量 pxCurrentTCB 总是指向当前任务的 TCB, 因此可以在调试器中随时查看,例如
根据任务名的字符串,断定这是 FreeRTOS 系统任务 Timer task 的TCB.
TCB 的第一个数据域 pxTopOfStack 是一个指针,指向任务的堆栈顶;pxStack 则应该是给任务分配的堆栈的最低地址。
我用 GDB 跟踪了一下任务的创建过程。在调用 xTaskCreate() 创建任务时,调用了两次 pvPortMalloc() 函数来动态申请内存,一次用于任务的堆栈,另一次用于 TCB. 然后是调用 prvInitializeNewTask() 初始化任务,调用 prvAddNewTaskToReadyList() 将任务添加到 Ready 状态的任务列表当中待执行。
其中,prvInitialiseNewTask() 调用了与平台有关的 port.c 中的 pxPortInitialiseStack() 来初始化堆栈,看一下:
比较有意思,这是把一系列寄存器的初始值,包括执行代码的地址,压入任务的堆栈中。当这个任务被执行时,想必将从堆栈中恢复现场。
继续跟踪代码,在主函数中创建好任务之后,调用 vTaskStartScheduler() 来启动调度器,主函数的使命就完成了。vTaskStartScheduler() 还创建了 Idle task 和 Timer task 两个任务,然后是调用 port.c 中的 xPortStartScheduler() 实现调度。这里就访问了 ARM Cortex 的系统寄存器(跟优先级有关),最后来到 prvPortStartFirstTask() 函数,启动第一个任务。
这里做了几件简单的事情:
(1) 设置系统的堆栈寄存器 MSP, 是从中断向量表中重新装入了 SP 的初值。这就意味着连同 main() 的局部变量都被销毁了,因为这个函数是不会返回的。
(2) 允许中断
(3) 执行系统调用指令 svc
svc 指令将触发一个软件中断,使 SVC_Handler 被执行,看一下实际ISR程序是这个
这里先是从全局变量 pxCurrentTCB 获得当前 TCB 的地址,然后读取TCB第一项数据,也就是任务堆栈的指针。接着从堆栈中弹出 r4 到 r11 这8个通用寄存器的值(和堆栈初始化代码是对应的),再把 PSP 寄存器设成任务堆栈指针(现在 vPortSVCHandler 用的是 MSP 堆栈)。根据堆栈初始化代码,任务堆栈里面的内容依次 pop 的话应该是这些寄存器值:r0, r1, r2, r3, r12, LR, PC, xPSR.
然后,将 BASEPRI 寄存器写成0 (不限制异常处理的优先级),最后用 bx lr 指令从 vPortSVCHandler 返回。疑问来了:返回不就回到 prvPortStartFirstTask() 函数中 svc 指令的下一条指令那里了吗?
不,如果对ARM cortex-m的异常处理机制比较了解的话可以明白,LR寄存器在 Exception handler (包括ISR) 状态下其内容并不是存放的返回地址。这也是为什么可以用普通C函数来书写ARM cortex-m的中断服务程序的原因,像其它平台往往要用 interrupt 关键字之类的,告诉编译器使用中断返回指令。在 vPortSVCHandler 的代码中,bx r14 ( LR 就是 R14 的别名)指令前还有一条 orr r14, #0xd 指令,将 LR 寄存器的低 4 位改写成 0xd,就是表示返回到 Thread mode 执行,并且使用 PSP 堆栈寄存器,于是就切换到任务的堆栈了。