项目
本文详细介绍了独立实现freertos内核的流程,以及linux应用编程的简单项目
项目
参考书籍:《FreeRTOS 内核实现与应用开发实战指南》
一个工程如果没有 main 函数是编译不成功的,会出错。因为系统在开始执行的时候先执行启动文件里面的复位程序,复位程序里面会调用 C 库函数__main,__main 的作用是初始化好系统变量,如全局变量,只读的,可读可写的等等。__main 最后会调用__rtentry,再由__rtentry 调用 main 函数,从而由汇编跳入到 C 的世界,这里面的 main 函数就需要我们手动编写,如果没有编写 main 函数,就会出现 main 函数没有定义的错误。
FreeRTOS内核实现
生成的startup_ARMCM3.s负责启动startup_ARMCM3.c负责时钟配置,本项目默认的时钟为25M
1 | // Define clocks |
对于这种多行宏定义,每行结尾要加 \ 表示该行未结束
::: alert-danger
若使用 \ 表示该行未完结务必注意 \ 后不能加任何字符,尤其是空格或者Tab。报错如下
:::
左值不能进行类型转换,类型转换本质上是在寄存器内对原值进行位操作,得到的结果不放入内存,而左值是需要放进内存的,因此类型转换与左值冲突,若要类型转换,则需要对右值进行操作
:::alert-danger
当一个a.c文件需要b.h,而b.h包含了c.h,且c.h也包含了b.h时,会发生编译冲突。表现为有未定义的类型或变量,详情参考博客园,解决办法是理清编译关系,去除重复包含的头文件
:::
栈由高地址向低地址增长,栈顶是第一个进栈的元素,栈底是最后一个进栈的元素
因为32位机一般指令都是32位的,栈顶指针只需4字节对齐即可,但是考虑兼容浮点运算的64位操作则需要8字节。对齐完成后,栈顶指针即可确定位置,而后开辟空间
项目的.c 与 .h文件可以不重名,位置可以不同,例如port.c文件放在\freertos\Source\portable\RVDS\ARM_CM3,但是引用port.c内容的portable.h放在\freertos\Source\include
1 | /* 这行代码的意思是定义了TaskFunction_t类型的函数指针,参数和返回值都是void,这样就可以进行函数“赋值”,进而从Task1,Task2中抽象出TaskFunction_t这一类型了,并且使用起来很方便 */ |
实现就绪链表
1 | typedef void (*TaskFunction_t)( void * );//将TaskFunction_t函数指针重定义为void*类型 |
::: alert-danger
在FreeRTOS里TaskHandle_t是个TCB_t的指针
在toyFreeRTOS里TaskHandle_t是个void*类型的指针,使用时需要类型强转
:::
设置任务栈时栈顶指针的移动
首先由外部函数prvInitialiseNewTask构造出的pxTopOfStack指针传入pxPortInitialiseStack函数,此时pxTopOfStack指针指向A位置,而后移动指针至B,C点从而将xPSR,PC,LR的值依次写入栈顶,方便之后寄存器读取。配置好自动加载到寄存器的内容后再将指针下移至D并返回,从而使任务得到空闲堆栈的指针
实现调度器
向量表最前面是MSP的地址
配置寄存器:
1 |
|
开启第一个任务:
1 |
|
- 为什么需要PendSV?
- 为了保证外部中断能够马上执行,防止出现类似“优先级翻转”的情况
- 调用svc(请求管理调用)的原因
- 用户与内核进行操作,但如需使用内核中资源时,需要通过SVC异常来触发内核异常,从而来获得特权模式,这才能执行内核代码
- 为什么需要SVC启动第一个任务?
- 使用了os后,任务调度交给内核,因此不能同裸机一样使用任务函数来启动,必须通过内核的服务才能启动任务
:::alert-danger
for循环无循环体时末尾加分号
:::
实现调度器总结
初始化任务步骤
调用创建静态任务函数
- 设置TCB指针和栈指针
- 调用创建新任务函数,传入Handle,函数名称,参数,栈的深度等参数
- 返回Handle
创建新任务函数操作:
- 获取栈顶地址并对齐
- 将任务名称复制到TCB中
- 设置container与owner(container指的是处于哪个链表,owner是自身的TCB)
- 调用初始化任务栈函数,并返回一个栈顶指针
- 将任务的自身地址传给Handle,这样可以通过Handle控制任务
初始化任务栈函数操作:
- 对栈指针之前的16位进行设置以便加载到CPU寄存器中
- 返回空闲堆栈的栈指针
开启第一个任务步骤(汇编):
- 设置堆栈按8字节对齐
- 从SCB_VTOR取出向量表地址,进而获得msp的内容(msp中的第一条指令是哪来的?)
- 开中断
- 调用svc指令去获取硬件权限,从而执行svc中断服务程序,并启动第一个任务(svc替代了以前的swi也就是软中断指令)
svc中断服务程序的操作:
- 将第一个任务的参数加载到寄存器,包括第一个函数的地址,形参,返回值
- 开中断,使用psp寄存器,返回到任务堆栈,这样第一个任务就执行完了,CPU等待执行下一个任务
上下文切换的操作:
- 总体与svc中断服务程序的操作类似,但是加上了将优先级载入到basepri的操作
- 设置好优先级后直接运行至跳转上下文 c 函数
- 最后开中断,使用psp寄存器,返回到任务堆栈,CPU等待执行下一个任务,调度器功能就实现了
临界段保护
临界段就是在执行时不能被中断的代码段,典型的就是全局变量,系统时基
中断管理
FreeRTOS中的中断管理通过汇编完成,对于关中断而言,其内部实现了两个中断函数,分别是能保存当前中断有返回值的函数,可以在中断中使用。另一个是不能保存当前中断无返回值的函数,不能在中断使用。 本质是操作basepri寄存器,大于等于basepri寄存器的值的中断会被屏蔽,小于则不会。但当basepri为0时,则不会屏蔽任何中断
关中断
1 | /* 不带返回值的关中断函数,不能嵌套,不能在中断里面使用 */ |
inline关键字与内联函数
inline关键字用于C++,__inline,__forceinline既可用于C,也可以用于C++
在程序中,如果在关键代码频繁调用某个函数,则会频繁的压栈出栈。为了提高效率,C语言提供了inline关键字来优化代码,例如在开关中断时需要inline关键字
inline的原理是,将某个函数内容原封不动的放入引用处,这样就不会频繁的入栈出栈了。inline减少了函数调用的开销,但使代码膨胀。
1 |
|
更详细用法参考CSND
inline,__inline,__forceinline等用法参考51CTO
volatile作为左值时,即使类型相同右值也需要类型强转么?
不需要,Keil编译器问题,但是可能会报warning甚至error,最好类型强转一下
空闲任务与阻塞延时的实现
为了能够自动进行任务调度,需要:
- 设置CPU重装器,设置主频并调用系统中断向量表提供的SysTic中断服务函数
- 提供一个函数,内部能够完成时基自增和任务延时自减**(后期会取消自减的设置,转而使用“闹钟”的思想)**,并将这个函数放入上一步的SysTick服务函数中,这样能够定时触发从而进行时基自增,在放入SysTick中时,还需要注意此函数前后需要开关中断以保证时基的实时性
- 将任务调度器函数
vTaskSwitchContext
重写,调度方式需要判定TCB中的任务延时,值为零,则触发SysTic中断服务函数,而后将任务放入就绪链表
为了加入IdleTask支持,需要:
- 在启动调度器函数 vTaskStartScheduler中加入空闲任务的启动,这需要设置IdleTask的TCB,栈,函数名称等参数,但不设置延时,因为CPU空闲时长不确定,设置完成后将其挂载到就绪列表
:::alert-danger
注意不要在IdleTask中加入任何阻塞或者死循环,否则由于IdleTask没有设置延时,会将同一优先级的所有任务阻塞!!!
:::
支持多优先级
CM内核有个计算前导零的指令,以此可以优化寻找最高优先级任务的方法
主要原理是:找到一个32位变量的最高非零位,此位就是最高的有任务的链表的优先级
支持多优先级实现过程如下
- 将
uxPriority
添加到TCB及其相关的函数内使其支持优先级 - 之后在
prvInitialiseNewTask
函数内添加初始化优先级,并做判断使任务初始化优先级大于等于configMAX_PRIORITIES
的退化成configMAX_PRIORITIES-1
- 在
prvInitialiseTaskLists
中初始化5个就绪链表 - 在
prvAddTaskToReadyList
宏函数中完成将任务移就绪入链表的操作- 记录当前优先级并将当前任务插入到获得的那个优先级链表的尾部
- 在
prvAddNewTaskToReadyList
函数中完成具体操作- 如果
pxCurrentTCB
为空,意味着可能是第一次创建任务,则将传进来的pxNewTCB
赋值给pxCurrentTCB
,并且调用prvInitialiseTaskLists
函数以创建任务链表 - 如果
pxCurrentTCB
不为空,则根据优先级将pxCurrentTCB
设置为优先级最高的那个任务,可能是pxNewTCB
也可能是pxCurrentTCB
,这需要做好判定再赋值 - 最后调用
prvAddTaskToReadyList
- 如果
任务延时列表的实现
首先初始化两条链表
&xDelayedTaskList1
与&xDelayedTaskList2
,并将其赋址给pxDelayedTaskList
和pxOverflowDelayedTaskList
在
vTaskStartScheduler
中初始化全局变量xNextTaskUnblockTime
为最大值,这个变量表示下一次任务被唤醒的时刻,也就是所提到的“闹钟”在
vTaskDelay
函数中插入prvAddCurrentTaskToDelayedList
函数,prvAddCurrentTaskToDelayedList
函数实现如下- 将当前任务从就绪链表中移除,并检查移除任务后,就绪链表是否为空,若为空则将优先级位图上对应的位清除
- 记录
xTimeToWake
的值,它等于当前时钟加上vTaskDelay
的参数,也就是闹钟值
,与xNextTaskUnblockTime
相等,但是为局部变量,并将此值设置为链表节点的排序值 - 比较
xTimeToWake
与xConstTickCount
大小以判断是否闹钟溢出,溢出了就将当前任务移至pxOverflowDelayedTaskList
链表,否则移至pxDelayedTaskList
链表 - 然后更新
xNextTaskUnblockTime
使其等于xTimeToWake
在
xTaskIncrementTick
函数中判断延时任务是否到期,若到期且延时链表为空,则将xNextTaskUnblockTime
设为最大值。若到期但延时链表不为空,则将延时链表中的每个节点的值xItemValue
取出并与当前时刻做对比,若xItemValue
大于当前时刻,则将xNextTaskUnblockTime
更新为xItemValue
,然后将任务从延时链表移入就绪链表判断链表为空的方式:
- 调用
uxListRemove
时会返回pxList->uxNumberOfItems
,或者调用宏函数
- 调用
FreeRTOS内部有两个延时链表,当系统时基计数器xTickCount没有溢出时,用一条链表(pxDelayedTaskList
),当xTickCount 溢出后,用另外一条链表(pxOverflowDelayedTaskList
)。
1 |
|
支持时间片
- 抢占式调度(configUSE_PREEMPTION):高优先级任务可以打断低优先级任务
- 时间片流转(configUSE_TIME_SLICING):同优先级任务之间每隔一定时间片进行任务切换
- 空闲任务让步(configIDLE_SHOULD_YIELD):空闲任务与用户任务处于同一优先级时,空闲任务等待用户任务使用完CPU后才能获取资源
- 时间片流转(configUSE_TIME_SLICING):同优先级任务之间每隔一定时间片进行任务切换
默认情况,FreeRTOS上面三个选项均开启
- 支持时间片的操作非常简单
- 分别在
FreeRTOSConfig.h
与FreeRTOS.h
文件中引入configUSE_PREEMPTION
和configUSE_TIME_SLICING
两个宏,默认为1 - 修改
xPortSysTickHandler
函数,使得当xTaskIncrementTick
返回值为pdTrue
时才进行任务切换 - 修改
xTaskIncrementTick
函数,使得在延时链表中有任务被唤醒时,判断被唤醒的延时任务优先级与当前任务优先级,若被唤醒的延时任务优先级高则返回pdTrue
,意味着进行任务切换 - 如果当就绪链表中任务数大于1,那么每进入
xTaskIncrementTick
函数就意味着过去了一个时间片,因此需要进行任务切换。注意在修改该函数时还需要判断上面两个宏是否为1
- 分别在
自己实现
信号量
- 初始化Semaphore链表并设置Semaphore结构体的值
- 完成Take函数
- 检测当前Semaphore个数是否大于0,若大于0则关中断,Semaphore数量–,不大于零则说明没有Semaphore可供Take,所以需要进行任务切换
- 完成Give函数
- Give函数简单很多,只需要归还Semaphore然后开中断,将任务管理权归还给调度器即可
队列
- 为了保证数据能在不同函数间传递,静态创建资源时需要在创建资源的函数内传入在main函数中预设的结构体或数组的地址,对于Queue来说,官方使用
xQueueCreateStatic( UBaseType_t uxQueueLength, UBaseType_t uxItemSize, uint8_t *pucQueueStorageBuffer, StaticQueue_t *pxQueueBuffer );
函数来传参 - 在创建函数内还需要初始化Queue链表和结构体及其参数,最后返回一个void*类型的handle
- 创建环形缓存区来保存数据,做好数据发送和接收的准备工作
- Buffer实际上是有一个head标志变量和一个tail标志变量的数组,发送数据时head++,接收数据时tail++,当head或tail等于数组结尾时需要把他们设置为数组开头
- Buffer还要有检测是否为空的功能
- 队列发送函数
QueueSend
中,在发送数据前需要关中断,发送数据后开中断 - 队列接收函数
QueueReceive
中,也需要同QueueSend
开关中断
静态创建和动态创建的区别
静态创建因为需要防止函数退出时销毁数据和栈,因此需要传入指针,所需的内存大小以及需要保存相关结构的地址等条件
1 |
|
动态存储使用malloc函数因此不需要传入指针,但是程序速度运行比静态分配慢还需要对内存进行管理
1 | BaseType_t QueueReceive(QueueHandle_t QueueHandle, |
遇到的困难与学到的经验
编译关系复杂,各种头文件相互包含导致类型重定义或者定义冲突
- 理清编译关系,在项目之前做好文件规划,划分各文件的职责
- 注意头文件引用顺序
- 待补充
Keil编译器有问题,有时候类型符合的赋值编译器不通过,必须类型强转才可编译通过。再或者,虽然已经定义了条件编译但还是对循环引用的头文件报错,这时就需要考虑编译器的问题了,Keil失效的通常的现象和解决办法:
- 一般出现编译器问题的条件是:当一个错误卡住了很长时间,并且确定这段代码没有错误,而且当按照编译器的提示将这段代码彻底的进行修改后会爆出更多error,这时就可以考虑是编译器的问题了
- 所有可能的解决办法都失效了,可以考虑是Keil的问题
- 待补充
::: alert-danger
1 |
|
:::alert-info
堆栈太小可能会导致程序停止在HardFault
:::
int (*array)[20] 与 int *arrary[20]的不同
前者代表一个指向具有20个整型元素数组的指针,后者代表一个具有20个指针元素的数组
宏定义函数
- 为什么要使用宏定义函数?
- 宏定义函数可以在预编译阶段直接展开,省下了压栈出栈的资源
- 那他与内联函数的区别是什么?
- 宏定义函数只做展开和替换,不检查参数类型。而内联函数需要检查参数类型
C99特性
在keil中可以在“魔术棒”的C/C++设置C99模式,指定后可以在非全局作用域下定义不定长数组
1 |
|
电子产品量产工具
调试经验
:::alert-info
善用printf和printk,尤其利用好 __FILE__,__FUNCTION__,__LINE__这三个宏
:::
不要忽略编译器的警告,否则可能出现逻辑问题,在下图中,编译器的警告是“变量未初始化”,这是因为在错误的那行得到的是地址而不是值
1 | /* 中间层,只进行数据的上报和汇总 */ |
头文件交叉包含解决办法
解决办法:将引起交叉包含的那部分内容提取出来,统一放在common.h的文件中,然后再包含common.h即可
sscanf可以处理复杂字符串
1 |
|