浅析Freertos内核
前言
作为一名学习时间一年左右的大学生,本文肯定对rtos的解释不如一些老师和一些博主,对一些解释可能不是很科学,希望各位读者大佬能够指正,本文是以记录学习过程为出发点,将自己学习过程中的理解,通过更通俗的解释分享出来,让读者初步的了解rots内核。
一、Freertos最最最最核心在哪?
通过这段时间的学习,以及自己实现的rtos内核来看,Freertos实时操作的核心在于任务调度,就是其中的sp指针的切换入栈以及上下文的切换
那么本文就以三个部分来讲解rtos的核心、分别是创建任务、任务调度、任务切换。其中更是以任务调度为重,剩下的进入临界区、支持优先级、时间片需要根据读者的反馈进行文章更新读者也可以读完本文之后自己涉及。
首先知道,Freertos和裸机的最大区别是什么?如果我们使用裸机开发,是不是轮询查找?(在主函数中的死循环里按照顺序依次执行)
充其量在主函数中添加标志位,让其在中断进行处理。这固然是一种响应和处理分开的方法。但是今天我们来讲解一下实时操作系统、主打的一个实时、我们在中断进行响应,处理在任务中执行。那么引入一个知识点、什么是任务? 所谓任务就是一个个在主函数中的死循环,根据任务的优先级来处理优先级最高的任务,再通过任务切换去另外一个任务.
此时大家脑海里有没有思路了?
在江湖中,强者为尊实力为大、每天都是在厮杀,但是大家都规定好了,谁是大哥谁先享受、如果期间谁成为新的大哥,那么新的大哥才有资格享受资源
这里面的“大家”就是任务、资源就是“CPU的使用”、大哥就是“优先级高的任务”
1、创建任务
创建任务可以分为动态创建、静态创建。本文以最简单的静态创建为例、读者可以阅读完本文之后去看看rtos动态创建的源码比较一下区别。
1、任务栈?
首先问大家一个问题、如果使用裸机开发,我们定义的全局变量、函数中的局部变量都放在哪里?是不是在栈中?栈就是单片机RAM里面一段连续的内存空间,大小一般在启动文件里面或者连接脚本里面定义,通过外在的调用初始化。
那么和任务栈有什么关系?在江湖中、各路豪杰英雌距地分割。每个人都有自己的地盘。那我内部的吃喝开销不是我自己负责?
同样,栈也是一个个独立的,每个栈都有自己的独立栈空间,只是这个栈空间需要提前定义好,就是一个全局数组
我们此时不管函数的参数(其实就是宏定义uint32_t 类型)
2、任务函数
其实在上面的时候大家都对任务函数脑中有了初步的构想,就是一个死循环函数
3、任务也有身份证?
你没听错,人在江湖混,别人怎么知道你叫啥籍贯哪里,那肯定需要一个身份证来证明这个是我。同乡的出去也能说我和英雄一个村从小玩到大的 (说过头了)、那么任务的身份证叫什么呢?TCB
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; /* 栈顶 */
ListItem_t xStateListItem; /* 任务节点 */
StackType_t *pxStack; /* 任务栈起始地址 */
char pcTaskName[ configMAX_TASK_NAME_LEN ]; /* 任务名称,字符串形式 */
TickType_t xTicksToDelay; /* 用于延时 */
UBaseType_t uxPriority;
}tskTCB;
typedef tskTCB TCB_t;
可以看到TCB就是结构体、里面包含了栈顶指针、任务节点(其实就是将该节点放入链表中,从而实现任务挂到链表上,因为TCB就是任务的身份证,挂TCB就是挂任务)、任务栈起始地址、任务名字~~、延时、优先级~~ (暂且不谈)
4、创建任务的函数
不是哥们,我看了这么久你就给我上理论啊?能不能来点实际的操作啊?
可以的读者,量大管饱,我们上干活,看看任务创建的内部是怎么实现
1、xTaskCreateStatic
TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode,
const char * const pcName,
const uint32_t ulStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
StackType_t * const puxStackBuffer,
TCB_t * const pxTaskBuffer )
{
TCB_t *pxNewTCB; //创建tcb、创建身份证
TaskHandle_t xReturn; //创建函数返回值
if ( ( pxTaskBuffer != NULL ) && ( puxStackBuffer != NULL ) )
{
pxNewTCB = ( TCB_t * ) pxTaskBuffer;
pxNewTCB->pxStack = ( StackType_t * ) puxStackBuffer;
prvInitialiseNewTask( pxTaskCode,
pcName,
ulStackDepth,
pvParameters,
uxPriority,
&xReturn,
pxNewTCB);
/* 将任务添加到就绪列表 */
prvAddNewTaskToReadyList( pxNewTCB ); //先不管
}
else
{
xReturn = NULL;
}
/* 返回任务句柄,如果任务创建成功,此时 xReturn 应该指向任务控制块 */
return xReturn;
}
2、prvInitialiseNewTask
首先就是获取任务的栈顶指针、然后做8字节对齐(因为包含浮点操作,所以需要8字节对齐),剩下可以看到,函数内部也是在为TCB服务,给TCB名字、通过设置节点的拥有者去拥有节点本身的TCB、通过初始化任务栈更新任务指针。
好好好,哥们,你给我套娃呢?怎么一个又一个?核心到底是哪个?
各位读者,接下来就有点抽象,需要准备好纸和笔,那么我们开始发车了
3、重中之重之重中之重pxPortInitialiseStack
#define portINITIAL_XPSR ( 0x01000000 )
#define portSTART_ADDRESS_MASK ( ( StackType_t ) 0xfffffffeUL )
StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack,
TaskFunction_t pxCode,
void *pvParameters )
{
/* 自动加载到 CPU 寄存器*/
pxTopOfStack--;
*pxTopOfStack = portINITIAL_XPSR;
pxTopOfStack--;
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) prvTaskExitError;
pxTopOfStack -= 5; /* R12, R3, R2 and R1 默认初始化为 0 */
*pxTopOfStack = ( StackType_t ) pvParameters;
/* 手动加载到 CPU 寄存器 */
pxTopOfStack -= 8;
/* 返回栈顶指针,此时 pxTopOfStack 指向空闲栈 */
return pxTopOfStack;
}
首先,我给你们看一看任务栈初始化之后栈空间分布图,在对着初始化后的图对代码进行分析。
首先我们不要被CPU寄存器误导,这里只是我们手动的现场,暂时和CPU寄存器无关、首先xPSR的第24位需要置为1,这是表示当前使用的指令集(这里不做强调,可以了解一下ARM指令和Thumb指令)、然后我们再减,PC中存放的是任务的入口地址、R14(LR)任务的返回地址(该函数是一个死循环函数,通常任务是不会返回的)、然后将R12、R3、R2、R1空出并且默认初始化为0、R0中保存任务形参、然后再将需要手动加载的寄存器空间空出。返回栈顶指针,此时栈顶指针就指向了R4。(拿出本子记住,后面有大用)。
此时我们整理一下思路,该函数就是将任务栈进行划分区域,其中一些寄存器被充当自动加载CPU寄存器、一些寄存器需要手动加载CPU寄存器(这里不需要深究,只需要知道有这么两个东西。)
可能有读者会疑惑,为什么我学内核的时候只听过高位寄存器、地位寄存器怎么这里不按这个套路来,是不是这几个寄存器有特殊含义?
下面引用M3权威指南中的一段话,供读者参考
2、任务调度
我们重整思路,回顾一下创建任务
我们看在江湖中一个豪杰是如何产生、首先某个豪杰(任务)需要一块可以发育的地方(任务栈),光有地盘不行还要把自己的名声打出去(TCB)、要发展外部同时也要发展内部。内部建立了各个部门(一些“寄存器”)各司其职这样一个任务就算创建成功了。
读者好奇了,既然各个任务都是独立的死循环的,那怎么去切换任务呢,怎么去享受资源呢?不会是任务里面break吧?
恭喜你宿主,系统激活
现实就是残酷的,系统可不等你主动退让、
1、vTaskStartScheduler
void vTaskStartScheduler( void )
{
pxCurrentTCB = &TaskfirstTCB; //指定第一个任务
/* 启动调度器 */
if ( xPortStartScheduler() != pdFALSE )
{
/* Now Error...... */
}
}
BaseType_t xPortStartScheduler( void )
{
/* 配置 PendSV 和 SysTick 的中断优先级为最低 */
portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
/* 第一个任务 */
prvStartFirstTask();
/* Now Error....... */
return 0;
}
__asm void prvStartFirstTask(void)
{
PRESERVE8
/* 在 Cortex-M 中, 0xE000ED08 是 SCB_VTOR 这个寄存器的地址,
里面存放的是向量表的起始地址,即 MSP 的地址 */
ldr r0, =0xE000ED08
ldr r0, [r0]
ldr r0, [r0]
/* 设置 msp */
msr msp, r0
/* 全局中断 */
cpsie i
cpsie f
dsb
isb
/* 启动*/
svc 0
nop
nop
}
认真的读者可以看到,怎么又是套娃模式?没关系,我们一层一层的剖析。~如果你愿意一层一层一层地剥开我的心~
因为我们主要讲解任务调度,所以不涉及优先级、那么我们需要手动的指向第一个任务、然后进入第二层函数。
配置PendSV 和 SysTick 的优先级,因为这两个设计调度,所以需要设置成最低、进入第三层。
哇,汇编!!!汇编?遇事不要慌,兵来将挡水来土掩,我们对汇编进行分析。
-
8字节对齐不需要过多的解释、读者可以向上观看。
-
然后前面一堆都是为了将 0xE000ED08 这个立即数加载到寄存器 R0,此时 R0等于 SCB_VTOR 寄存器的值, 等于 0x00000000,即 memory 的起始地址。
-
再将 0x00000000 这个地址指向的内容加载到 R0,此时 R0 等于0x200008DB
-
然后将 R0 的值存储到 MSP,此时 MSP 等于 0x200008DB。
有读者说了,WTF?搞了这么半天就是为了让MSP等于 0x200008DB,就是让MSP指向主堆栈指针,那我启动的时候响应的Reset_Handler,向量表初始化的时候这个不就指向了主队栈指针嘛?嘿嘿,我也觉得有点多余 -
开启全局中断,这里读者可以丰富一下自己的知识了解一些M内核的CPS指令
CPSID I ;PRIMASK=1 ;关中断 CPSIE I ;PRIMASK=0 ;开中断 CPSID F ;FAULTMASK=1 ;关异常 CPSIE F ;FAULTMASK=0 ;开异常
读者可以自己去了解一下M内核中的中断屏蔽寄存器组的描述。
最后!END!最激动人心的时候来了,他来了。- 原神,启动!
SV启动!
2、vPortSVCHandler
__asm void vPortSVCHandler( void )
{
extern pxCurrentTCB;
PRESERVE8
ldr r3, =pxCurrentTCB
ldr r1, [r3]
ldr r0, [r1]
ldmia r0!, {r4-r11}
msr psp, r0
isb
mov r0, #0
msr basepri, r0
orr r14, #0xd
bx r14
}
准备好纸笔,发车!这里的r都是CPU的寄存器
-
引入外部变量pxCurrentTCB,用来表示当前正在运行或者即将运行的任务控制块,为接下来做准备。
-
八字节对齐,无需多言,
懂得都懂(设计浮点操作) -
将pxCurrentTCB的地址加载到r3
-
将r3指向内容的地址加载给r1
-
将r1指向内容的地址加载给r0。
我们缓一缓,pxCurrentTCB给3,3给1,1又给0,不就是pxCurrentTCB给0嘛。对!没错此时r0是不是就是pxCurrentTCB指向的任务块控制块?哎?那任务快TCB第一个成员就是栈顶指针,那这个时候r0不就是等于栈顶指针了?
那我们还记得我们刚初始化之后的任务栈顶指针指向哪里嘛?大家可以看看本子上是不是有笔记,没错此时任务的栈顶指针指向的就是R4 -
以r0为基地址从栈向上增长八个字的内容加载到CPU寄存器r4~r11中,这个时候大家是不是懂了,之前为啥要那样伪造人工现场?此时r0的内容也会变化,是不是指向了R0?
-
这个时候来了,又是一个骚操作我把更新之后的栈顶指针r0更新到psp,这个时候就切换使用堆栈psp指针用来执行任务。
-
然后我又将r0清0
-
设置basepri的值为0,大家可以参考M3权威指南,为啥需要配置成0,(因为可以屏蔽所有优先级不高于0的中断,反而言之,不就是开启所有中断嘛?)
-
给r14的最后四位或上0x0D(1101)、返回后进入线程模式、返回任务模式并且做出栈操作返回后使用PSP、返回Thumb状态
-
最后异常返回,这时使用的是PSP指针,自动将任务栈中剩下的内容(R0上面那一坨)加载到CPU寄存器,此时PSP的值也更新,指向了任务栈的栈顶
此时此刻,犹如彼时彼刻。这样的一波操作就将人工现场加载到CPU寄存器中,从而开始任务。
3、任务切换
在江湖中,没有谁能一直强大,强如李淳罡、王仙芝也只是历史中的一个过客,同样再Freertos中多任务时,CPU的享用也不是只为一个任务服务,这就涉及到任务的切换
1、portYIELD
该函数其实很简单,就是PendSV 的悬起位置 1。没有其他中断的时候响应PendSV中断。
2、xPortPendSVHandler
就是SV中断,我们看看源码
__asm void xPortPendSVHandler( void )
{
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
mrs r0, psp
isb
ldr r3, =pxCurrentTCB
ldr r2, [r3]
stmdb r0!, {r4-r11}
str r0, [r2]
stmdb sp!, {r3, r14}
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri, r0
dsb
isb
bl vTaskSwitchContext
mov r0, #0
msr basepri, r0
ldmia sp!, {r3, r14}
ldr r1, [r3]
ldr r0, [r1]
ldmia r0!, {r4-r11}
msr psp, r0
isb
bx r14
nop
}
遇事不要慌,我们一个个来。主要就是上文的保存,下文的切换
1、上文保存
前面不需要多言,以各位彭于晏、刘亦菲的聪明才智肯定是能够读懂的,好吧我再讲一遍
- 运行该中断的时候,会将上一个任务的运行环境加载到CPU对应的寄存器中,将psp的值存储到r0
- r2等于pxCurrentTCB
- 以r0为基地址,先减再操作、将r4~r11的值存储到任务栈中,此时r0的值也会更新,此时r0就指向任务栈的R4
- 将r0的值存储到r2指向的内容,各位读者,我们细看第二点,既然r2等于pxCurrentTCB,那么这个操作不就是将r0 的值存储到上一个任务的栈顶指针 pxTopOfStack嘛?这样一家不久团聚了?上文不就保存了?全身而退、皆大欢喜。那下一个任务怎么上位?
2、下文切换
- 将R3、R14临时压入堆栈中(读者好奇了?为啥要将这两个也压进去?因为接下来我们需要调用一个中断函数,等我们看到最后就知道为什么需要压入R14)(R3中保存的是上文的TCB指针,如果在执行接下来的中断函数时充当临时变量被更新了,那怎么找回,所以需要留一手给他入栈)。
- 将该宏存储到r0中(用来配置中断屏蔽寄存器 BASEPRI 的值、读者可以自行补充)
- 关闭中断,进入临界区开始更新pxCurrentTCB(**
新皇交接都是偷偷摸摸的**) - 调用该函数。(此时我们不需要管,就是选择优先级高的(从众多英雄豪杰中挑选出最有实力的一个))
- 退出临界区,开启中断
- 将主堆栈中压入的R3、R14恢复。
- 将r3指向的内容加载到r1,看第五点,r3指向的时TCB指针,在第7点的时候就偷偷摸摸的更新了,那这个pxCurrentTCB不就应该指向新的了嘛?
- 加载r1指向的内容到r0,就是下一个任务的栈顶指针。
- 以r0为基地址将下一个任务的内容加载到r4~r11中
- 同样的参看上面的解释
- 最后异常发生,又把任务栈中剩下的内容加载到CPU寄存器中、这样就开始新的任务。(此时发现,哎?这里需要用到R14,如果那时候没有入栈会发生啥?读者自行思考)
现在我再给位读者捋一下思路。通过一个故事来讲解
在一个月黑风高的晚上,江湖第一高手知道自己位置不保,所以他就已经把自己的一些核心家当提前带上了,他需要想办法把自己的势力保留下来再东山再起、这时候他叫来了pxCurrentTCB,(pxCurrentTCB时江湖老字号他说谁是第一高手那谁就是第一高手),第一高手说:“pxCurrentTCB老弟,我现在把手下全部叫过来,你里面不是有一个成员pxTopOfStack嘛?我把这些信息都以他为头,只要到时候我东山再起,你给我他的位置,我去找他,他有我们全部的信息,这样一来我们一群原班人马人又回来了”,pxCurrentTCB说:“好的,老大哥,你走吧,我知道了,我要去认新大哥了”,没多久,一批人杀气十足冲进庭院砰的一声给门关上“换位换位、江湖第一人的位置现在是我来了”pxCurrentTCB说“好,你就是江湖第一人了”第二天吱嘎一声,大门打开,当清晨的第一缕阳光照进庭院的时候走过的人赫然发现,之前的江湖第一人的亲信全部换掉了,就连外面的牌匾也换成了其他名号,至此,大家才知道原来第一人换人了。
通过这个故事,想必大家对上面这段中断函数有了新的认识,其实就是将之前执行的任务保存下来,然后更新pxCurrentTCB、再将新的任务加载到寄存器中。至于临界段中发生了什么就需要大家自己补充了。
总结
至此,整篇文章结束,想必大家能够对Freertos有新的认识,作为一名学生,文章可能欠缺专业性,里面所设计的知识可能存在误导,希望各位大佬能够指正。学习一门操作系统不光只是为了学了他的AP接口,更是学习他内部的核心。freertos的核心在我看来就是指针的切换,任务的入栈和出栈、创建多个任务,在调度中进行任务的切换。
如果文章反应较大,大家觉得我写作有趣通俗易懂,我愿意分享后期rtos的应用开发笔记供大家学习参考。