有空就更。
参考资料:振南老师的书籍
本文将由浅入深,逐渐抵达RTOS的本质,既是我对自己学习过程的梳理,也算是交流和分享。
C语言的函数是如何运行的?
C语言函数运行的两个必要条件
1. 函数入口地址。
2. 函数的运行栈。
函数中有局部变量、代码指令等,都存储在其运行栈中。
裸机的主栈
裸机中只有一个栈,就是主栈(MSP)。因此,裸机所有的函数的运行都是依赖主栈。
STM32单片机(其他32位arm架构MCU同理)上电后,第一件事就是告诉系统,主栈在哪里。这里就涉及了STM32的启动流程的知识点了。
STM32的启动流程:
从0x0处取值,赋值给栈顶指针SP。然后取0x4处的值,给PC指针,stm32会根据0x4的这个地址的值跳转到其所对应的地址去执行指令。根据BOOT的引脚,地址的跳转一般有三种情况,主闪存启动,系统存储器启动,SRAM启动。
如果是主闪存启动,也就是Flash memory启动。把0x0000 0000 映射成0x0800 0000。
运行代码的顺序
SystemInit函数是STM32上电后运行的第一个C语言函数。
运行顺序:SystemInit函数、__main函数(大多是汇编)、main函数
多个函数同时运行是如何实现的?
CPU其实并不能直接访问内存,而是借助内核中的若干个寄存器来完成指令的执行。
现场的概念
假设CPU现在正在运行函数A,那么当前CPU内核寄存器组中的值就是函数A相关的值,这些寄存器中的值就被称为 “ 现场 ”。
简单理解就是:
在裸机中,函数运行时,CPU的寄存器组的数据就是现场。保存现场,就是把它们存储在主栈中,简称压栈,PUSH。恢复现场,将主栈的现场数据,恢复到CPU的寄存器中,简称出栈,POP。
函数切换的实质就是“现场“的修改
当一个程序切换到另一个程序,比如说中断响应,其本质就是CPU内核寄存器组中的值被更换为了新程序的相关值。中断服务程序运行完后,还得返回到原来的程序位置继续运行,这就称为”现场的恢复“。
C语言中的函数调用和函数的return,其本质都是把”现场“进行入栈和出栈。因为栈大小有限,所以局部变量不要过多,函数的调用关系不要太深。
一句话总结:C语言函数的切换,实质上就是CPU内核里面一系列寄存器值的改变。
调度器的原理
RTOS中”上下文“的概念
RTOS中的”上下文“,其实就是裸机中的”现场“,只不过是运行环境不同,叫法不同。
RTOS中实现多个函数同时运行的基本原理
将函数的”上下文“,也就是函数运行时所对应的CPU内核中一系列寄存器的值,从栈中恢复到CPU内核寄存器中,这样就实现了函数的”上下文切换“。
只要把上下文切换的频率加快,将各个函数的上下文依次切换,这样就实现了多个函数的”同时运行“。其实在某一时刻,只有一个函数在运行。
RTOS中的任务切换
换个角度来想,RTOS中的任务,其实也相当于是函数。
函数和任务都有以下相同点:函数的运行需要函数的入口地址,以及函数的运行栈。而任务的运行也需要任务栈,以及任务栈的入口地址。
在裸机中,现场的保存和恢复(入栈PUSH和出栈POP)是由硬件来完成的。而RTOS中上下文的保存和恢复则是由调度器来主动完成的。
RTOS中的每个任务都有自己独立的任务栈,都会占用一定的内存资源。因此,使用RTOS开发是比较耗内存的,所以在一些内存比较少的单片机上,不建议使用RTOS开发。主流的RTOS都是采用独立的任务栈方式。(FreeRTOS,RT-Thread)
Tick中断发起调度,PendSV实现任务的上下文切换。
多任务并发执行造成的混乱
临界资源的概念
临界资源就是在多任务环境中,只有一个任务可以在同一时间访问的资源。比如:
共享内存:如果多个任务都可以同时修改某一块内存区域,就可能导致数据丢失或错误。
外设控制器:比如控制一个LED灯的外设,只有一个任务可以操作它,防止多个任务同时改变LED的状态。
硬件资源:如串口、I2C总线等,多个任务同时操作这些硬件资源可能导致通信混乱。
如何解决多任务争抢临界资源的问题?
基本思想就是:一个任务用完再给另一个任务用。
这里要引入临界区的概念。任务在临界区的代码的时候不会被打断。
临界区:被两个宏夹在中间的这部分代码就称为“临界区”
taskENTER_CRITICAL();
/* -----临界区begin------- */
//这里面的代码都不会被打断执行
/* -----临界区end--------- */
taskEXIT_CRITICAL();
临界区的原理:其实就是关闭中断。调度器的Tick中断会被关闭了,因此在执行临界区的代码的时候就不会被打断。为了不影响系统的正常运行,执行完临界区的代码后就会马上退出临界区,也就是恢复中断。
小细节:和RT-Thread类似,FreeRTOS中也是有一个全局变量计算Tick数。
Cortex-M3、M4代码的特权级和用户级
代码级别:特权级和用户级
代码的级别有两种,分别是特权级和用户级。我们通俗点理解,就是特权级代码能做的事情多,因为它有特权,没有限制;而用户级代码,因为少了特权,有些特权级代码能做的事情,它做不了。
特权级代码:可以访问所有的地址。
用户级代码:某些寄存器不能访问。比如NVIC寄存器和SCB相关寄存器。(针对Cortex-M3、M4)
单片机上电后的代码默认就是特权级别。我们平时写的裸机代码,全部默认就是特权级代码,这就是我们上电后就可以进行中断相关配置的原因。
而RTOS中任务里的代码都是用户级。
所以,任务中尽量不要去设置NVIC这些寄存器,这些和中断相关的配置。硬件外设的初始化,尽量放在main函数中去完成。
Cortex-M3、M4的运行模式
运行模式:线程模式和Handler模式
单片机上电后,默认处于特权级下的线程模式。
在裸机里,代码是特权级,运行模式为线程模式。
在裸机触发的中断里,代码是特权级,运行模式为Handler模式。
在RTOS的多任务里,代码是用户级,运行模式为线程模式。
在RTOS触发的中断里,代码是特权级,运行模式为Handler模式。
裸机与RTOS中的运行模式
裸机状态下:
单片机复位后,默认处于特权级的线程模式,使用的栈是主栈
产生中断后,中断程序运行时处于特权级的Handler模式,因为handler模式下程序永远使用主栈,所以中断使用的栈还是主栈。
中断执行完成后,就会变回原来的特权级的线程模式。
可以通过改变CONTROL寄存器的某个位,将特权级线程模式更改为用户级线程模式。
在RTOS状态下:
用户级线程模式,就是RTOS的任务运行时的模式。如果发生了中断,则运行模式则由用户级线程模式变成了特权级Handler模式。中断结束后,特权级Handler模式就会变回用户级线程模式。
如果想在任务中访问某些特权级才能访问的寄存器,则需要通过SVC中断(SVC_Handler),通常称为系统调用。因为中断中,运行模式是特权级Handler模式,所以可以操作所有的寄存器。
RTOS中的第一个任务是如何启动的?就是通过SVC_Handler中断启动。在SVC中,触发SVC 0,启动了第一个任务,把第一个任务的上下文装入了CPU的寄存器中。
操作系统通过SVC系统调用去间接访问某些资源。
无论是裸机还是RTOS中,在中断中永远都是特权级Handler模式,使用的栈是主栈,
RTOS中从中断结束返回到多任务环境,使用的栈就从主栈切换成任务栈。(MSP->PSP)
中断服务函数为什么是叫xxx_Handler呢?
一旦中断产生了,就会进入Handler模式。而Handler模式下运行的代码,一定是特权级别。因此我们可以在中断中操作所有的寄存器,没有限制。
裸机模式开发的时候默认工作在线程模式,使用的是主栈。
Handler模式用的一定是主栈。
CortexM3,M4的双堆栈机制
CortexM3,M4寄存器组介绍
在Cortex M3,M4内核的寄存器组中,R0-12为通用寄存器;R13为栈指针,R14为LR寄存器。
R13作为栈指针。这是一个影子寄存器。影子寄存器是什么?简单来说就是一个寄存器有两个物理地址。
也就是说,R13其实对应了两个寄存器,一个是主堆栈指针MSP,一个是任务栈指针PSP。当运行任务程序时,使用的是PSP,如果是执行中断,则使用MSP。
C语言中return实质:程序跳转到LR地址上运行。
R14:LR。记录子程序返回的位置。就是某一行代码的地址。
其中R14,LR寄存器还负责代码级别,运行模式,栈使用的切换。
IDLE空闲任务
为什么会有空闲任务?
因为RTOS中不允许没有任务运行,任何时候都必须保证至少有一个任务处于运行状态,不然就只有调度器在空转。因此,当我们用户的全部任务都不运行的时候,此时系统就会运行空闲任务。
空闲任务的两个重要作用
1. 如果有任务自杀,IDLE则负责释放它所占的资源。
2. 如果空闲任务的运行时间过长,则说明已经没有其他任务需要运行。这个可以作为是否进入低功耗模式的依据。
RTOS的低功耗的功能实现,就可以通过IDLE实现。通过判断IDLE任务的运行时间,判断是否进入低功耗模式。
TickLess与低功耗相关。
小细节:当IDLE任务与同级任务进行调度时,IDLE会主动让出CPU,让其先执行。
任务间通信(IPC)
PV操作和信号量
一般的裸机前后台程序之间的同步主要通过定义各种全局变量的标志位来实现。某种意义上,裸机中的全局标志位其实就相当于信号量。
反过来想,RTOS的信号量是不是也相当于是裸机中全局变量标志位的升级版?
二值信号量与计数型信号量
二值信号量适合同步频次比较低的场合。计数型信号量适合同步频次比较高的场合