在前面对ARM架构以及C语言在单片机中的表现有一个简单的认识后,现在开始正式进入RTOS的学习。
如上图所示,在百度上搜索freertos
进入官网,然后点击红色框中的Download
下载最新的完整FreeRTOS源码,该源码中包含使用示例,下面黑色的下载选项下载的源码不包含实例,我们一般都不下载这个。
如上图所示,可以看到整个源码中包含许多文件,因为它是支持多种芯片以及多种工具的(Keil5等等),所以我们只保留需要的,其他的统统删掉。
以本喵使用的STM32F103xxx
系列芯片为例,最终只留下上图所示的目录,此时源码文件的精简工作就完成了。
如上图,此时Keil5
中工程文件只保留这些,main
函数中也只保留红色框中的内容,之后就在这个基础上增加我们所需要的内容。
然后就是编译工程,此时必然会有很多错误和警告,接下来就是考验大家的时候了,需要挨个将报错和警告全部解决。
如上图所示编译结果,本喵这里就不讲解了,接下来就可以写我们自己的代码了。
为了后面调试程序方便,本喵这里增加一个串口,用来打印一些信息:
如上图,在serial.c
文件中,在官网源文件的基础上进行修改,删除一些内容,按照我们平常初始化串口的方式初始化串口,然后再定义fputc
函数,实现串口的重定向,串口及重定向在本喵的文章(点我)中有过详细讲解,有兴趣的小伙伴移步查看。
如上图,在main.c
中的prvSetupHardware
函数中最后位置增加串口初始化,此时我们就可以使用printf
函数来向串口打印信息了。
如上图所示,在main函数中使用printf
打印Hello, world!
,然后点击Debug
按钮进行软件仿真,再从工具栏的View
选项中的Serial Windows
中选择UART #1
,此时会出现串口1的显示框,然后点击全速运行按钮,就会在显示框中显示打印的语句。
- 按照图中步骤1~5进行操作,就可以像在
VSCode
上写代码一用printf
打印信息一样,只是这里是在UART #1
的显示框中显示。
在上图中,这位母亲有两件事要做,一边要给小孩喂饭,一边要加班跟同事微信交流。
对于单线条的人,不能分心、不能同时做事,她只能给小孩喂一口饭,瞄一眼电脑,有信息就去回复,再回来给小孩喂一口饭,如果小孩吃这口饭太慢,她回复同事的信息也就慢了,被同事催:你半天都不回我?如果回复同事的信息要写一大堆,小孩就着急得大哭起来。
这种做法,在软件开发上就是一般的单片机开发,没有用操作系统。
对于眼明手快的人,她可以一心多用,她左手拿勺子,给小孩喂饭,右手敲键盘,回复同事两不耽误,小孩“以为”妈妈在专心喂饭,同事“以为”她在专心聊天",但是脑子只有一个啊,虽然说“一心多用”,但是谁能同时思考两件事?只是她反应快,上一秒钟在考虑夹哪个菜给小孩,下一秒钟考虑给同事回复什么信息。
这种做法,在软件开发上就是使用操作系统,在单片机里叫做使用RTOS。
- RTOS的意思是:Real-time operating system,实时操作系统。
我们使用的Windows也是操作系统,被称为通用操作系统。使用Windows时,我们经常碰到程序卡死、停顿的现象,日常生活中这可以忍受。但是在电梯系统中,你按住开门键时如果没有即刻反应,即使只是慢个1秒,也会夹住人。
- 在专用的电子设备中,实时性很重要。
将喂饭和回复信息写成两个函数放在代码中:
void main()
{
while(1)
{
喂一口饭();
回复信息();
}
}
假设喂一口饭需要的时间是t1~t5
,回复一个信息需要的时间是ta~te
,在经典裸机单片机程序中,会先花费t1~t5
一段完整的时间去喂一口饭,再花费ta~te
一段完整的时间去回复一个信息,如此往复循环。
站在小孩的角度,他觉得自己被喂了一口饭后有ta~te
这么长时间母亲没有搭理他,然后又被喂了一口又没人理他,此时他就感觉这顿饭吃的的断断续续的,吃一口后等一会儿才能吃上下一口。
站在同事的角度,他感觉有t1~t5
这么长时间没人理他,然后收到一个信息,如此反复,此时他感觉这次的交流很不流畅,也是断断续续的。
/* 喂饭任务 */
喂饭()
{
while(1)
{
喂一口饭();
}
}
/* 回信息任务 */
回信息()
{
while(1)
{
回一个信息();
}
}
void main()
{
create_task(喂饭);
create_task(回信息);
while(1)
{
sleep();
}
}
此时喂饭和回信息就在穿插执行,喂饭任务和回复信息任务中的两个while(1)
死循环都在执行。花费t1
时间喂一下饭(没有喂完一口),再花费ta
时间回复一下信息(没有回复完一条信息)。
就这样将喂饭和回复信息穿插执行,当执行了t1~t5 + ta~tb
以后,喂一口饭和回复一个信息两个任务就都完成了,和裸机程序相比,耗费的时间是一样的。
但是不一样的是,此时小孩感觉一直在被喂饭,这顿饭吃的很香,同时感觉一直收到消息,这次交流也非常愉快。
- 由于只有一个CPU,所以采用时间片轮转的方式执行多个任务,即每个任务被CPU执行的时间是固定的,到了就换另一个任务。
- 因为CPU执行速度非常快,所以给我们的感觉就是两个任务甚至多个任务在同时执行。
如上图所示,在main
函数中使用xTaskCreate
创建两个任务Task1
和Task2
,两个任务中都有一个死循环,在死循环中分别打印数组1和2。
如上图,串口中输入的数字有1也有2,如果是裸机程序,两个任务Task1Function
和Task2Function
只能按顺序执行,此时只会执行1个死循环,另一个就无法执行。
但是通过现象发现两个死循环都在执行,因为有各自循环中的数字被打印了出来,此时就是前面例子中所说的,两个任务在穿插执行,每个任务执行固定的时间就换另一个,如此往复。
- 创建任务和Linx系统中创建线程类似,有兴趣的小伙伴可以看一下本喵的文章线程概念 | 线程理解。
数据类型:
如上图,每个移植的版本都含有自己的 portmacro.h 头文件,里面定义了 2 个非常重要的数据类型:
FreeRTOS配置了一个周期性的时钟中断Tick Interrupt
,每发生一次中断,中断次数就会累加一次,这个变量是tick_count
,它的类型就是TickType_t
,它可以是16位的变量类型也可以是32位的。
如上图,如果在FreeRTOSConfig.h
中定义了configUSE_16_BIT_TICKS
时,TickType_t
就是uint16_t
类型,否则就是uint32_t
类型,对于32位的架构,最好要把TickType_t
设置成uint32_t
。
这个该架构中最高效的数据类型,32位架构中,它就是uint32_t
,16位架构中它就是uint16_t
,8位架构中,它就是uint8_t
,BaseType_t
通常用作简单的返回值类型,还有逻辑值。
变量名:
FreeRTOS
中习惯在变量名前加前缀:
变量名前缀 | 含义 |
---|---|
c | char |
s | int16_t,short |
l | int32_t,long |
x | BaseType,其他非标准类型:结构体,task handle,queue handle等 |
u | unsigned |
p | 指针 |
uc | uint8_t,unsigned char |
pc | char指针 |
函数名:
函数名同样习惯加前缀,它前缀有两个含义:
函数名 | 含义 |
---|---|
vTaskPriority | 返回值类型:void,该函数在task.c中定义 |
xQueueReceive | 返回值类型:BaseType_t,在queue.c中定义 |
pvTimerGetTimerID | 返回值类型:void*, 在timer.c中定义 |
宏名:
宏的名是大写,前缀是小写,前缀表示在哪个头文件中定义:
宏的前缀 | 含义 |
---|---|
portMAX_DELAY | 在portable.h或portmacro.h中定义 |
taskENTER_CRITICAL | 在task.h中定义 |
pdTURE | 在projdefs.h中定义 |
configUSE_PREEMPTION | 在FreeRTOSConfig.h中定义 |
errQUEUE_FULL | 在projdefs.h中定义 |
通用宏定义:
宏 | 值 |
---|---|
pdTRUE | 1 |
pdFALSE | 0 |
pdPASS | 1 |
pdFAIL | 0 |
动态创建新任务使用的函数是xTaskCreate
:
如上图,该函数在task.c
中定义,返回类型是BaseType_t
,作用就是创建新任务。
如上图,该函数有6个参数,下面本喵来各自讲解一下:
可以看到,TaskFunction_t
是一个返回类型是void
,参数类型是void*
的函数指针,在projdefs.h
中使用typedef
重命名了。
在调用xTaskCreate
创建新任务时,这个参数就是新任务要执行的函数:
这两个新任务执行的函数是由我们用户自己定义的,返回类型必须是void
,参数类型必须是void*
的,函数名无所谓。
这个参数是用来指定新任务的名字的,以后可以通过名字找到对应的任务,这个参数可以说是最不重要的了。
该参数是用来指定栈大小的,我们知道,每一个函数在调用的时候都会开辟一个栈用来存放返回地址以及临时变量。
在RTOS中,每一个任务也会开辟一个栈,用来存放该任务函数执行过程中的返回地址以及临时变量的。
每一个任务都拥有一个独立的栈,不同任务之间的栈不会互相影响,在创建任务之前,用户需要预估该任务需要多大的栈空间,从而在创建任务的时候指定栈的大小。
- 单位是word,比如传入 100,表示栈大小为 100 word,也就是 400 字节。
该参数就是新任务函数的那个形参,在使用xTaskCreate
创建任务时传入该参数,新任务函数在执行时就会使用这个形参,这也是为什么我们在定义任务函数的时候,形参类型必须的void*
类型的原因。
如上图,该参数的类型本质上就是一个无符号整形,用来表示任务的优先级,优先级范围:0~(configMAX_PRIORITIES – 1)
,数值越小优先级越低,如果传入的值过大,xTaskCreate
会把它调整位 (configMAX_PRIORITIES – 1)
。
- 优先级高的任务会优先得到CPU的执行。
该参数的本质是一个二级指针,TaskHandle_t
是代表着tskTaskControlBlock*
,所以TaskHandle_t*
就是一个二级指针,最终指向的是tskTasKcontrolBlock
,也就是任务控制块。
如上图所示,任务控制块中包含该任务的一些属性,如优先级,起始栈地址,任务名字啊等等信息(结构体中内容有删减)。
所以说,该参数用来保存xTaskCreate
的输出结果:task handle
,以后如果想操作这个任务,比如修改它的优先级,就需要这个handle
。
- 该
handle
就是一个任务句柄,在创建任务的时候需要传入一个创建好的句柄地址。- 如果不想使用该
handle
,可以传入NULL
。
- 成功:pdPASS
- 失败:errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY(失败原因只有内存不足),本质是就是返回-1。
xTaskCreate
的使用方法现在清楚了,那么动态创建的动态又体现在哪里呢?
如上图,通过宏开关configSUPPORT_DYNAMIC_ALLOCATTION
来控制使用xTaksCreate
来动态创建任务,在函数内部,会使用pvPortMalloc
在堆区上动态开辟一块空间来存放任务控制块pxNewTCB
,然后再使用pvPortMallocStack
在堆区上开辟一块独立空间用来当作该任务的独立栈。
如上图,pvPortMalloc
等函数都在task.c
中定义,其都是对malloc
函数的封装。
- 动态开辟的动态主要体现在,任务控制块
TCB
和任务的独立栈结构都存放在堆上,是动态分配的。
静态创建任务使用的是在task.c
文件中定义的xTaskCreateStatic
函数,该函数的用法和xTaskCreate
的用法有点区别:
如上图,首先需要一个宏开关,该函数才会被编译,我们才能使用它静态创建任务。
如上图,在FreeRTOSConfig.h
中定义宏开关,当然也可以在别的位置定义,但是习惯上在这个头文件中增加一些宏定义。此时该函数就会被编译了。
在调用该函数的时候,会传入七个参数,其中新任务执行的函数,任务名字,任务的栈深度,执行函数的参数,优先级等和动态创建任务的xTaskCreate
一模一样。
但是这里需要用户给新任务指定栈,不再是动态分配了:
如上图,可以看到StackType_t
类型的本质就是一个uint32_t
类型,也就是四个字节大小。
所以在调用该函数之前需要提前创建一个数组StackType_t xTask3Stack[100]
来作为静态任务的栈。
如上图,调用该函数同样需要传入一个任务句柄,但是此时的任务句柄类型变成了StaticTask_t
类型,所以需要创建一个任务3的句柄StaticTask_t xTask3TCB
。
此时该函数所需要的就都齐全了,可以调用了,但是它仍然会报错无法运行,这是因为:
如上图,在任务调度函数执行时,如果支持了静态创建任务,就需要调用vApplicationGetIdleTaskMemory
函数来获取空闲任务内存。
- 空闲任务是FreeRTOS默认会执行的一个任务,它的优先级是0,主要用来回收用户任务等。
但是此时这个函数没有被定义,所以我们需要自己定义出该函数,并且需要提供空闲任务的栈和空闲任务的TCB句柄。
如上图所示,此时才可以创建静态任务并运行了。
如上图所示,此时3个任务就同时开始执行了,任务1和任务2是动态创建的任务,任务3是静态创建的任务。
- 静态任务的静态就体现在,任务所需要的栈是由用户指定的,而不是自动分配的。
显而易见,创建静态任务要比动态任务繁琐的多,所以我们一般情况下创建的都是动态任务。
如上图,调用task.c
文件中定义的vTaskDelete
函数可以删除指定的任务。
如上图,该函数只有一个参数TaskHandle_t xTaskToDelete
,用来指定要删除的任务,在该函数内部会先根据该任务的句柄去查找,找到该任务后删除。
- 如果传入的是
NULL
则删除调用该函数的任务本身,也就是自杀。
如上图所示,增加三个任务执行标志位,执行哪个任务的时候哪个执行标志位就被置一。
- 多个任务只是看起来是同时在执行,实际上CPU某一时刻只能执行一个任务。
任务1在执行一段实际后将任务2删除,然再过一段时间后将自己删除:
如上图,将三个任务的执行标志添加到逻辑分析仪中,显示高电平状态表明任务在执行。
可以看到,最开始时,三个任务轮替着在执行,当任务1将任务2删除以后,只剩下任务1和任务3在轮替执行,当任务1自杀以后,只剩下任务3在执行(一直都是高电平)。
创建的多个任务都在哪?删除任务时又是在哪里删除的呢?
如上图所示,新创建的任务会被添加到就绪链表中,该链表中存在多个任务的TCB,调度器每隔一定的时间就从该链表中取出一个任务让CPU去执行,上一个被切换下来的任务会放在链表的末尾。
- 每创建一个新任务,该任务就会插入到就绪链表的末尾。
- 调度器让CPU先执行链表末尾的任务,然后链表头开始执行其他任务。
包括删除一个任务的时候,也是根据任务的句柄从该队列中查找,如果存在则将对应TCB从链表中删除,这样一来CPU就不再执行该任务了。
关于队列的问题,本喵今天不详细讲解,只是让大家知道有这么个东西存在。
这篇文章中,最重要的是要掌握如何创建新的任务,动态创建和静态创建的区别,以及如何删除一个任务。