FreeRTOS是免费开源的实时操作系统,应用广泛,值得一学,简单概括就是任务调度,也叫线程调度。通过滴答定时器产生节拍,快速地切换任务,宏观上就看起来是多任务同时运行。
Thread,线程。
Task,任务。
Thread,Task,没什么区别,或者说,界限不明显,但注意,进程和线程,一个进程可以有多个线程。
首先看看FreeRTOS里的延时相关函数
第一个是相对延时,参数为节拍数,从调用这个函数开始计时。参数最大0XFFFFFFFF,共32位,因为滴答定时器的计数器就是24位的。
第二个函数是绝对延时如果前面延时了一段时间,再调用这个函数延时,那么延时节拍数将为现在的值减去之前的值,
如下实例,意思是每隔200个节拍延时150个节拍,
第三个函数,这里是获取当前节拍数,比如上面那个绝对延时函数,就要通过这个函数获取当前节拍数,然后计算。
第四个函数,和第三个差不多,不过只能在中断里使用。
代码讲解
下面这个宏定义,在FreeRTOSConfig.h里面,为1表示滴答定时器的计数器数据类型为32位,否则16位。
变量定义风格
函数定义
TCB_T是啥类型,从任务创建函数里找到TCB_t,
跳转到定义。可以看到,TCB_t类型原名是taskTCB,taskTCB类型原名又是tskTaskControlBlock,即任务控制块。总之是结构体类型,里面是任务优先级,任务栈等东西。
.......中间省略一些
TaskHandle_t是啥,句柄,在调用创建任务的函数时传入的最后一个参数,TaskHandle_t其实就是指向TCB_t结构体的指针。
如下可见,TaskHandle_t原类型名是struct tskTaskControlBlock *,即tskTaskControlBlock结构体指针,看不懂的话,将struct tskTaskControlBlock看作整体A,不就是A*吗,对比char *,懂了吗。
句柄有什么用,就是方便操作对应任务。所谓柄不就是这意思吗,比如vTaskSuspend函数,传入对应句柄就能使对应任务进入休息状态,传入空(NULL)就是自己休息。还有vTaskDelete等函数,同理。
静态创建任务
调用xTaskCreateStatic发现没定义,在Tasks.c里搜索函数,是存在的,但是上面有个判断条件,跳转到它的宏定义,发现被设为了0,改为1即可。
不难发现,static创建任务的函数多了一个参数,StackType_t * const puxStackBuffer,StaticType_t *类型,跳转定义,发现使用了两次typedef,总之,就是uint32_t类型,即四字节。
所以要定义一个数组
还有,静态创建任务函数最后一个参数不是普通的TCB_t类型,是StaticTask_t结构体类型,原名是xSTATIC_TCB,所以上面图片也定义了一个xLED1TCB变量。
调用如下,7个参数,多了个第6个参数,最后一个参数与动态创建的最后一个类型不同。
编译发现依然报错
实现如下函数
为什么要实现这个函数呢,如下查看开始调度的函数,里面用到了vApplicationGetIdleTaskMemory函数吗,获取空闲任务内存,何谓静态创建任务,就是在编译的时候就指定好了一块内存作为任务栈,本质上就是通过一个空任务占位(占内存),可以说,静态创建任务本质还是动态创建任务。
还剩一个报错
注释掉timers.c的一行代码,如下
报错解决
多任务实际上是通过抵达定时器产生的信号实现任务快速切换,当然,中断频率不一定等于SysTick的频率,否则太快了。
任务切换就要知道任务状态,有4种状态,Ready准备,Running正在运行,Suspended休息中,Blocked阻塞中。
那freertos如何管理的呢,这么多任务,这么多状态,答案是链表,每种状态都对应一个链表,任务在哪个链表内就是处于哪个状态。如下,当然,这是底层,上层有对应函数直接调用。
FreeRTOS提供了两个系统延时函数:相对延时函数vTaskDelay()和绝对延时vTaskDelayUntil()。vTaskdelay是是对应任务进入阻塞,其他任务这时候可以运行
这两个延时函数和自己实现的延时函数不同,这两个延时函数一旦被调用,当前任务会立马进入阻塞状态,而自己写的延时函数(以for循环等形式实现的软件延时)会被当做有效任务而一直执行。
相对延时是指每次延时都是从任务执行函数vTaskDelay()开始,延时指定的时间结束;
绝对延时是指每隔指定的时间,执行一次调用vTaskDelayUntil()函数的任务。换句话说:任务以固定的频率执行。
vTaskDelete
当一个任务杀死另一个任务,任务栈的内存可以释放,但如果任务自杀,谁来清理尸体(释放内存)呢?答案是空闲任务,当然,是需要自己修改编写代码的,否则就无法释放。
空闲任务和钩子函数
实际上,在task.c的开始任务调度函数里就有创建空闲任务,根据是否支持静态分配内存对应两种空闲任务创建,静态空闲任务和动态空闲任务。
然后跳转到空闲任务对应的函数定义,如下,你可以直接修改,当然,这样容易破坏FreeRTOS系统,所以可以使用钩子函数。将configUSE_IDLE_HOOK宏定义改为1,然后在main函数定义钩子函数。
。。。。。。省略
在main.c实现,函数体为空即可
软件定时器
创建定时器
回调函数
内部机制分析
定时器中断函数
定时器结构体,时间到了就会调用对应回调函数pxCallbackFunction
比如定时100Tick,当Tick数到达CurrentTick+100表示到时间了,在每次Tick中断发生,都会判断是否时间到了
其他操作系统,一般都在嘀嗒中断里处理定时器,但是FreeRTOS不同,他是通过队列间接实现,开始定时器就是通过写队列
定时器开始函数,里面会写队列
中断,在嘀嗒中断触发时,会触发一次任务调度,同时挂起Pendsv,如果调度结果要切换任务,则Pendsv里面将切换上下文,即保存恢复现场
pendsv里。保存现场和恢复现场是指的手动部分,实际上还有硬件自动保存恢复部分寄存器,如上图,在进入中断之前,会自动保存现场(上升沿部分),由于是在中断以外,所以用的都是PSP,即进程堆栈指针。
FreeRTOS里,很多函数都有两套,一个没后缀(给任务使用),一个有FromISR后缀(给中断使用),比如写队列这个函数,中断使用的话,参数就没有超时时间,而变成了一个变量,根据值的不同,决定是否切换任务。
为什么要用这个变量实现统一切换任务,为了效率,比如多次调用,多次切换,但下一个任务马上覆盖前一个,那么前一个切换就没有意义,所以通过这个变量实现最后统一切换,避免无效的中间过程。
两类中断机制
在写队列时,关中断,关的是哪一类中断呢,不是全部的,而是FreeRTOS的,而对于cortex内核的中断,是没有关闭的
大概讲中断分为两类,一类不依赖于FreeRTOS,一类依赖,是为了系统稳定性。
, SysTick和PendSV都属于B类中断,一般优先级数值为最低,对应优先级最高,在PendSV中,会先保存现场,然后切换任务,再恢复新任务现场。
如下是写队列部分代码,红框是判断当前优先级是否在有效区间。
.对应STM32F103 F407来说,中断优先级数值越小,优先级就越高,而FreeRTOS的任务优先级是,任务优先级数值越大,任务优先级越高。最低优先级的中断也比最高优先级的任务优先级更高。
IRQ任务:IRQ任务是指通过中断服务程序(ISR)进行触发的任务,,所有任务中优先级最高
临界资源访问方法
链表
普通链表如下,比如一个人的结构体,里面有一个指针指向下一个人,
这样的链表有个弊端,那就是代码不能通用,操作系统内一般不是这种链表,而是如下
这种类似句柄的实现,就算是有很多不同的结构体,但是他们都有一个同类型的next指针,这样就能实现代码的复用。具体代码如下,每一个不同的结构体都有一个node成员,而node成员是node_t结构体,指向下一个node,也就是说,结构体串连不再是当前节点指向下一个节点的首地址,而是当前节点的node成员指向下一个节点的node成员,不再是首地址。
这样的链表实现了复用,但操作结构体的某个成员变量还是得知道首地址。问题来了,指针不再指向结构体首地址,怎么办呢,想得到首地址,只需要用node绝对地址减去node的地址偏移,那怎么得到地址偏移呢,如果起始地址是0,则node成员的偏移地址就是绝对地址,得到首地址具体如下
通过node成员算出结构体首地址,上述是一种办法,还有三种
第二种
所有结构体,比如person和dog,都将node节点作为第一个成员,这样结构体地址就是node地址。第二种是第一种的特殊情况,即node偏移为0,这与机器大小端存储方式无关。
第三种
第三种是第一种的宏定义,如下
第四种
第四种是在node_t定义里加上一个void *container,即存放结构体首地址,这种实现的话,要求在插入节点到链表时要给将结构体首地址存入node_t的container成员,具体如下
因为container是void *类型,所以要类型转换,第四种这种方法在操作系统是最常用的。
当然,在FreeRTOS里,肯定不是单向链表,是什么呢,其实是双向环形链表,粗略表示如下,双向的话查找节点更快,而环形则可以最快的找到末尾节点。
具体代码结构
xList多了xListEnd成员,是个标志位,表示这是链表的末尾。
xItemValve是在链表需要排序时用到,pvOwner是个void指针,通常指向TCB,pxContainer指向该链表元素的包含者。xList指针类型。
MINI_ITEM里,没有pvOwner和pxContainer,因为不需要,所以没有,
执行上面的初始化后,得到的结构如下
ITEM初始化
插入ITEM到链表末尾呢?直接插?不是的,要讲究公平,如下,index指向ITEM2,调用顺序是231,如果直接插到ITEM3后面,顺序就是2341,这对于1来说是不公平调度,所以应该插到ITEM1和ITEM2之间,即当前ITEM之前。这个在任务调度文章那里也说过。
具体代码如下,就是改变当前ITEM和它前一个和后一个ITEM的pre,next指针。并且把新ITEM的Container修改了,还要把List的ITEM数加1.
按照ITEM里的value排序插入呢,如下两图,for循环里就是在找位置,找到后跳出循环,然后修改相关pre和next指针实现插入
heap堆源码分析
heap是对内存管理的代码,RTOS一般不直接使用c语言标准的malloc,而是自己在此基础上进行更完善的封装,实现更安全,更高效的内存管理。
heap1代码分析
如下图,在keil工程启动文件里,就有堆内存相关代码,包括堆的基地址和大小,而在RTOS里,采用了更巧妙的办法,即定义一个全局数组ucHeap,之后所有malloc的内存都必须在这个数组内,不能越界。
heap1只实现了malloc,没实现free,如下是大致的分配策略,pucAlignedHeap是堆的对齐地址,即不一定是指向ucHeap的起始地址,因为为了实现对不同硬件的适配,采用内存对齐是有必要的,所以ucHeap[0]到pucAlignedHeap的内存都将被舍弃,xNextFreeByte是什么呢,是下一次分配内存的偏移地址,因为可能存在连续分配内存而前面的没释放的情况,为了不覆盖前面的,就要知道此次分配的起始地址,每次分配内存,xNextFreeByte的值都要加上分配的内存大小。
具体代码如下,这是分配内存大小对齐的代码,每一次分配都要对齐,portBYTE_ALIGNMENT是一个关于对齐数的宏,然后分析对齐的代码,比如xWantedSize等于8,xWantedSize是96,即想分配96字节,xWantedSize += 8-(96 & 0x0007),96是0110 0000,与上0x0007后为0000 0000,所以xWantedSize +=8,所以分配96字节内存在portBYTE_ALIGNMENT为8的情况下将会分配104字节。
如下是分配内存的代码,首先关闭所有任务调度,再判断pucAlignedHeap是不是NULL,是就说明这是第一次malloc,要先进行起始地址对齐,比如heap起始地址是0x2000 0001,portBYTE_ALIGNMENT为8,对0x0007取反为0xFFF8,然后计算heap[8]的地址为0x2000 0009,然后0x2000 0009与上0xFFF8,即将末尾3位清零,地址就变成了0x2000 0008,这就是对齐后的地址,pcAlignedHeap就指向0x2000 0008。前面的8字节将被舍弃。
下面就是开始分配内存,首先判断分配的大小有没有超出范围,没超出就开始分配,先得到pvReturn地址,再将xNextFreeByte加上要分配的大小。然后调用traceMALLOC开始分配。这个pvReturn就是当前分配内存的起始地址,可以调用他操作内存或者Free。
adjusted_heap是调整后的堆大小,等于total总大小减去portBYTE_ALIGNMENT
当第二次分配内存,不在进行首地址对齐,只进行分配大小对齐。
上面是heap1的分析,可以发现,每次分配内存都只记录了这段内存的起始地址,并没有记录这段内存的大小或者结束地址,这就是为什么heap1不能free的原因。
heap2分析
heap2在heap1的基础上做了改进,可以free。
首先依旧是定义一个全局静态数组作为堆,第一次malloc时要进行起始地址对齐,后面每次分配都要进行分配大小对齐。不同的是,在每次分配时会额外分配一个头部,头部是一个结构体,里面是一个同类型结构体指针和一个BlockSize,BlockSize表示当前内存块的大小(包含结构体),而这个指针的作用呢,就是如果当前快是空闲的,就把这个结构体放入链表,反之则移出链表,
比如刚开始还没有malloc时,整个堆数组是单纯的数组,进行HeapInit后,堆数组会被构建成一个大的空闲块,这时候的blocksize就等于数组大小,是一整块,并且由于是空闲的,结构体就会被加入到链表,当后面malloc时,就会从链表里找。
比如第一次分配,如果是8字节对齐,并且假设头部占5字节,pvportMalloc(10),由于是第一次,先进行heap初始化,将整个数组放入链表,然后开始分配,将整个数组移出链表,并且从对齐地址开始分配16字节,前面5字节是结构体,后面11字节是可用内存,这时候后面剩余的内存就成了空闲内存,后面剩余的内存会再次构成一个新的空闲块,并且被放入链表。
free时,传入指针,这个指针就是malloc时返回的指针,指向有效内存的起始地址,通过这个指针可以找到前面的结构体,就可以得到blocksize,free完后,这段内存就是空闲的,就要放入链表,
代码分析
如下,第一次malloc会进行heap初始化。
如下是heap初始化,先进行heap起始地址对齐,再把xstart的next指向对齐后的地址,把对齐地址转为结构体指针(firstBlock),然后把firstBlock的next指向xend。
初始化后的结构如下
然后进行分配大小对齐,先把wantSize加上结构体大小,再进行对齐。
对齐后,如果xWantSize大于0且小于configADJUSTED_HEAP_SIZE,就把pxPreviousBlock指针指向xStart,即指向空闲块链表的头结点,并且把pxBlock指向头结点的下一个,这个过程的作用是。在空闲块链表里找到足够大小的块,为什么要pxPreviousBlock,因为找到足够大小的块后,要把这个块的结构体移出链表。这里的while循环就是找的过程。
如果pxBlock不为空,则表示找到了足够大小的空闲块,先把这段可分配的内存起始地址记下,就是pxBlock地址加上结构体大小,再保存在pvReturn,然后就要把这个块的结构体移出链表,只需要把pxPreviousBlock的next指向pxBlock的next即可。
如果找到的这个块比wantSize大一些,那么会有剩余的空间,这段空间要构成一个新的块,,pxBlock加上wantSize就是新块的地址,保存在pxNewBlockLink指针,再修改pxNewBlockLink的xBlockSize,再修改pxBlock的xBlockSize,再把这个新块放入空闲链表。
然后调用traceMALLOC开始malloc。
prvInsertBlockIntoFreeList这个函数是将空闲块插入链表,插入是按从小到大顺序插入的,这也是为什么xEnd的BlockSize设置那么大,是为了方便排序。
上面的Heap2有一个问题,那就是无法将空闲块合并,比如两个相邻的块,300+300=600,600字节合并的话完全可以分配400字节的内存,但Heap2无法合并,所以会失败,这就是内存碎片。
而Heap4在此基础上做了改进,增加了内存合并机制。
Heap4第一次malloc时也先会heapInit,初始化后结构如下,xStart是链表结构体,而xEnd是结构体指针。
代码解释
if里面是起始地址对齐,uxAddress只是个临时变量,最开始保存的是堆数组首地址,对齐后地址保存在pucAlignedHeap,对齐后的堆大小保存在xTotalHeapSize。
下面是xStart的设置,next指向对齐地址,BlockSize设为0。然后把堆数组末尾地址存到uxAdress,uxAdress再自减一个结构体大小,
再让pxEnd指向这个地址,
然后让pxFirstFreeBlock指针指向对齐地址,再设置成员xBlockSize为uxAdress减去pxFirstFreeBlock,设置next为pxEnd。
xBlockAllocatedBit的作用是什么,相当于一种标志位,判断这个块是否已经在空闲链表,比如多次Free同一个块,只有第一次会Free,后续通过这个变量就能知道已经在空闲链表,就提高了效率和健壮性。
初始化链表结束,剩余的malloc部分其实和Heap2没什么区别,主要区别就是Heap2的xStart和xEnd都是结构体,而Heap4的xStart是结构体,xEnd是结构体指针,即xEnd本身会占用一些堆内存。
分析Heap4的Free函数
pv是传入的释放地址,减去结构体大小才是要释放的块地址,保存在puc,把结构体指针指向puc,再判断这个要释放的块是否已经在空闲链表,如果pxLink->xBlockSize的31位为0,则表示该块已经在空闲链表,就不执行括号里的语句。还要判断这个块的next指针是否为NULL,为NULL表示这个块不在空闲链表。这两个if是双重保险。
然后关闭调度,调用traceFREE,free掉这段空间,注意,这个块的头部是不会被释放的。再将这个块加入到空闲链表。
prvInsertBlockIntoFreeList
for循环里是找插入的位置,根据链表元素的的地址按顺序插入,按照地址顺序是为了方便后续的合并,找到位置后,将在pxIterator后插入。把pxIterator保存在puc。
下面是判断要插入的块和前一个块能否合并,前一个块地址加上他的块大小,如果等于新块的起始地址,就能合并,直接把前一个块的xBlockSize自增要插入的块大小。并把新插入的块的结构体指针指向pxIterator地址
下面是新块和后面的块能否合并,注意这里说的新块,如果pxBlockToInsert没有与前一个合并,新块就是pxBlockToInsert,这时候pxBlockToInsert还没有插入链表,如果合并了,新块就是pxBlockToInsert或pxIterator(合并后这两个一样)。
将新块地址保存在puc,判断新块地址加上新块大小是否与后一个快起始地址重合,重合的话,还要看pxIterator的next是不是xEnd,不是才能合并。如果是end,则把pxBlockToInsert的next指向xEnd,如果地址不重合,把pxBlockToInsert的next指向pxIterator的next
如果pxIterator不等于pxBlockToInsert,说明要插入的块没有与前面的块合并,则把pxIterator的next指向pxBlockToInsert。
到这,才算完成。