本帖最后由 tzz1983 于 2024-4-3 19:26 编辑
RTOS 基本原理, 框架模型 讲解@STC全球技术互助论坛
介绍:
很多RTOS初学者在学习RTOS时, 对一大堆的代码感到迷茫, 不知道该从何下手, 本贴力求以简明方式说明RTOS运行的基本框架模型, 让初学者对RTOS整体框架有个概念,继而扫清雾霾,直奔主题. 本贴针对初学者, 不需要有大量的编程经验. 需要有一定的C语言基础和少许的项目经验.
先回顾一下裸机运行的基本框架模型通常下面这样子的: void main(void) { sys_init(); //片上初始化代码 while(1) { //有些变体会在此判断时钟滴答或事件,但是大体形式不变,仍是一个大循环 function1(); //功能代码段1 function2(); //功能代码段2 function3(); //功能代码段3 ..... } } 代码的主体是一个while() 大循环, 在大循环中依次处理各个功能函数. 每个功能函数在一次循环中都能得到一次执行, 只要程序跑得够快, 就好像每个功能函数都在同时执行一样. 祼机其实就是一个单任务系统.
随着代码规模增加, 裸机的局限性逐步显现出来: 1. 响应: 因为是顺序执行结构, 如果一个代码段因处理突发事件而导致运行时间过长, 那么其它代码段的响应就会因此而受到拖累. 此类应用响应比较慢, 并且不可控. 2. 无优先级, 各个代码段在编辑代码时候就已经确定了执行顺序, 无法更改. 3. 状态机. 单任务系统里, 如果一段代码要等待某个事件才能继续往下执行时,通常会使用状态机. 状态机做的事情就是: 保存代码段当前执行的状态, 然后跳出函数, (主动放弃CPU控制权,让别的代码段有机会运行,否则一直等肯定是不行的). 因为之前保存了状态, 下次被调用时可以直接跳转到断点运行. 当断点变得很多的时候, 状态机的逻辑是很复杂的, 让人头疼, 难以维护, 牵一发而动全身.
RTOS的基本框架模型: void main(void) { OSInit(); //OS初始化 OSTaskCreate(task1,...); //创建任务1 OSTaskCreate(task2,...); //创建任务2 OSTaskCreate(task3,...); //创建任务3 .... OSStart(); //启用调度器,开始任务,注意, 调度器根据调度策略循环调度各个任务,此函数永远不会返回 } void task1(void *xxxx) //任务1 { while(1) { function1(); //功能代码段1 } } void task2(void *xxxx) //任务2 { while(1) { function2(); //功能代码段2 } } void task3(void *xxxx) //任务3 { while(1) { function3(); //功能代码段3 } } 是不是有种似曾相识的感觉, 裸机时是一个while循环, 现在变成了3个(可以是更多). OS调度器有很多种类型, 常用的是时间片调度和抢占式调度.下面分别讲解: 如上面代码所示, 调度器会让每个任务执行一段时间, 然后切换至另一个任务去执行, 如此循环往复. 只要切换的够快, 让我们的感觉就是每个任务都在同时运行一样. 这就是伪并行运行的原理. 抢占式调度更霸道一点, 当高优先级的任务就绪时, 能够立即剥夺比自己优先级低的任务的CPU控制权, 让自己优先得到执行. (在创建任务时,我们会给每个任务分配一个优先级)
现在来对比一下裸机和RTOS的性能: 1. 响应, 使用RTOS后, 响应问题行到解决, 只要你优先级够高,你的事件可以立即得到响应. 2. 提升效率,任务优先级够多, OS总是把宝贵的CPU资源分配给最需要执行的任务. 3. 延时或需要等待事件时,不做无意义的原地空循环或循环查询,而是立刻切换到其它任务执行,使宝贵的CPU资源不被浪费(提升效率). 这个功能叫做阻塞. 当事件就绪后RTOS会自动让已阻塞任务继续执行。 4. 可以简化状态机的设计, 使用RTOS后, 合理的利用阻塞功能,使原本需要状态机的代码可设计成为简单的顺序执行结构. 5. 利于代码的结构化,通常把一个复杂的系统分割成多个功能模块,而RTOS的多任务正好与之对应。 6. RTOS还有许多其它优点, RTOS还提供许多实用的服务, 这里暂时不详述.
RTOS常用术语解释: 1. 互斥: 使用RTOS后, 多个任务就好像是在并行运行一样, 这样给我们带来了极大的便利, 但是, 也带来了一些问题, 首当其冲的便是互斥. 典型的是全局变量的访问问题. 举例说明: 有个结构体包含8字节的数据, 现在任务2正在改写这个结构的数据, 但是写到一半时, CPU控制权突然被剥夺了, 此时任务1获得CPU控制权,并且任务1需要读取这个结构体的数据, 很显然, 如果没有特别的防护措施, 任务1将读到一个错误的数据, 因为任务2写数据才写了一半! 解决此类问题的办法便是互斥访问, 即当一个任务访问全局变量在所有操作未完成之前, 不允许其它任务或中断对该变量的访问. 除了变量互斥, 还有资源互斥等, RTOS提供了许多互斥访问的方法. 2. 临界区. 临界区是一段执行流不可被打断的代码段. 在RTOS里, 有许多状态是非此即彼的, 即不存在中间状态. 典型的如任务切换, 当调度器正在切换任务,改写系统核心变量时, 此时执行流是不可以被打断的, 即->调度器现在要调度从任务1切换至任务2, 但是如果操作只执行了一半就被打断, 那么现在运行态的任务到底是任务1呢还是任务2呢, 总不会告诉我现在正在运行任务1.5吧. 所以这个时候执行流是不能被打断的. 临界区还有许多其它的用法, 比如上面说的互斥访问通常就是通过临界区的设定来实现. 小型RTOS一般是使用关总中断的方法来实现临界区功能, 因为关闭总中断以后, 当前的执行流就不会被打断. 3. 可重入性. 在C语言里函数的可重入性通常是指嵌套和递归调用, 但使用RTOS后, 还有一种情况便是一个函数可能被多个任务调用, 如果这个函数是不可重入的, 那么将可能引发问题, 所以: 用RTOS后, 通常默认所有的函数都是可重入函数. 4. 信号量. 在这里, 二值信号量, 计数信号量, 消息邮箱, 队列, 标志集, 等统称为信号量 信号量用于任务和任务之间, 任务和中断之间的通信和同步. 从上面的RTOS框架代码可以看出, 每个任务彼此之间是独立的, 任务和任务之间并没有太多的关联, 但是我们想要使其中一个任务和另一个任务之间发生点什么, 比如同步运行, 比如互通数据,该怎么办呢? 各种信号量就是为此服务的, RTOS提供了一整套完整易用通信和同步的方法. 5. 共享资源: 可以被一个以上任务使用的资源叫共享资源 6. 死锁: 死锁是RTOS运行时,进入了一种类似于死机状态的假死状态. 多任务运行时, 当一个任务要使用互斥资源时, 要先获得并占用互斥资源的使用权, 如果互斥资源正在被其它任务使用, 则当前任务需要阻塞自己并等待资源. 如果以下情况发生,则产生死锁: 任务1运行需要使用资源A和B, 在获得A后, 发现B被占用, 于是阻塞自己等待资源. 任务2也是需要使用资源A和B, 任务2已经获得资源B, 但是无法获得资源A,于是也阻塞了自己等待资源, 如此一来, 死结, 两个任务都无法运行, 同时导致资源A和B也无法使用了. 避免死锁的办法是, 当一个任务需要使用多个互斥资源时, 如果无法获取全部的资源, 则需要放弃已经获取的资源. 另一个办法是使用下面介绍的互斥信号量. 7. 优先级反转, 和死锁类似, 也是任务在抢夺互斥资源时可能会发生的的一种状态, 发生优先级反转后, 高优先级任务需要等待低优先级任务运行完后才能获得CPU控制权, 效果就像优先级被反转一样, 偏离了我们原本的设计目标. 感兴趣的可以去百度一下, 限于篇幅,这里不详述. 8. 互斥信号量, 互斥信号量也是信号量的一种, 但是这里单独分开了来讲, 是因为它相对别的信号量来说有些特殊. 互斥信号量是为了解决死锁和优先级反转的问题而专门推出的一种信号量, 在uC-OSII里, 如果一个互斥信号量被多个任务请求, 已获得信号量的任务将暂时提高自己的任务优先级运行, 做到尽快释放资源, 从而可以解决死锁和优先级反转的问题.
RTOS任务切换过程和原理: 从上面RTOS模型中, 可以看出, RTOS中所谓的任务, 对于编绎器来说, 就是一个独立的函数. 任务切换, 简单的来理解就是暂停当前正在运行的任务函数, 然后跳转到另一个任务函数去运行. 这里涉及到两个问题: 1. 调度器在执行任务跳转前, 需要保存当前任务函数的运行状态, 重点是CPU寄存器组, 这些寄存器是每个任务函数都要用到的, 如果离开时不保存好,当返回的时候, 就无法恢复这些数据, 除此之外还会保存堆栈栈顶和断点位置等, 以 备返回时, 可以找到回家的路. RTOS有个专用的术语, 叫做保存上下文. 当执行返回动作时, 恢复离开时的寄存器数据, 这时候叫恢复上下文 2. 调度器是如何操作从一个函数跳转到另一个函数的.
现在来聊一聊调度器是如何操作从一个函数跳转到另一个函数中去的, 懂C语言的朋友可能第一时间想到到goto跳转指令, 然而实际上goto指令并不能从一个函数跳转到另一个函数, 它只能在函数内完成跳转. 言归正传吧,RTOS最常用的手法是利用中断, 或子程序调用返回. 下面是这个过程的详解:
当我们调用一个函时, 编绎器产生一条调用指令, 比如LCALL, 该指令的效果是保存当前的断点位置(PC+3的值)至堆栈中, 然后跳转到子程序开始执行,最后我们用RET指令返回到原来的断点. 关键点来了, 如果我们把LCALL保存的断点信息偷偷的给替换掉, 那么当执子程序返回指令RET时, 就不是返回到原来的位置, 而是去到我们想要去的任何地方! 这就是利用RET指令实现任务切换的原理. 底层程序员的常规操作啊, 哈哈, 编绎器啥的, 偶尔我们也来欺骗一下它.
任务切换函数的全貌如下: void OSCtxSw ( void ) { //进入函数的第一件事情就是保存寄存器组(入任务栈) //接下来保存堆栈指针SP至任务控制块 //找出已就绪的最高优先级任务 //取出新的任务的SP指针替代现有的堆栈SP //恢复寄存器组, 注意, 此用是用的新的SP, 也就是说恢复的是即将要执行的//任务的寄存器组 //执行RET指令, 注意, 因使SP已被替换, 所以此时执行RET指令不会返回到原来的断点, 而是会切换到新的任务中去. }
STC32G移植的实际代码摘录(函数切换任务)(注:有删减):
void OSCtxSw_Handler( void ) { __asm{ OSCtxSw_Handler_00: //中断入栈格式保存 POP xPCL POP xPCH POP xPCB PUSH PSW1 PUSH xPCB PUSH xPCL PUSH xPCH //寄存器入栈 PUSH DR56 PUSH DR28 PUSH DR24 PUSH DR20 PUSH DR16 PUSH DR12 PUSH DR8 PUSH DR4 PUSH DR0 PUSH PSW //SP保存到任务控制块 MOV DR0,DR60 MOV DR4,OSTCBCur MOV @WR6+0x2,WR2 } __asm{
MOV OSPrioCur,OSPrioHighRdy
MOV DR4,OSTCBHighRdy MOV OSTCBCur,DR4 //读取任务的SP
MOV WR2,@WR6+0x2 MOV DR60,DR0 //寄存器出栈
POP PSW POP DR0 POP DR4 POP DR8 POP DR12 POP DR16 POP DR20 POP DR24 POP DR28 POP DR56 //ERET返回 POP xPCH POP xPCL POP xPCB POP PSW1 PUSH xPCB PUSH xPCH PUSH xPCL MOV IE, _bIE__ DB 0AAH //ERET } }
STC32G移植的实际代码摘录(中断切换任务): void PendSvIsr( void ) { __asm{ PendSvIsr_Entrance: } //寄存器入栈 __asm{ PUSH DR56 } __asm{ PUSH DR28 } __asm{ PUSH DR24 } __asm{ PUSH DR20 } __asm{ PUSH DR16 } __asm{ PUSH DR12 } __asm{ PUSH DR8 } __asm{ PUSH DR4 } __asm{ PUSH DR0 } __asm{ PUSH PSW }
//SP保存到任务控制块 __asm{ MOV DR0,DR60 } __asm{ MOV DR4,OSTCBCur } __asm{ MOV @WR6+0x2,WR2 } //调用切换勾子函数 OSTaskSwHook(); PendSv_ClearFlag();
//OSPrioCur = OSPrioHighRdy; //OSTCBCur = OSTCBHighRdy; __asm{ MOV OSPrioCur,OSPrioHighRdy } __asm{ MOV DR4,OSTCBHighRdy } __asm{ MOV OSTCBCur,DR4 }
//读取任务的SP __asm{ MOV WR2,@WR6+0x2 } __asm{ MOV DR60,DR0 }
//寄存器出栈 __asm{ POP PSW } __asm{ POP DR0 } __asm{ POP DR4 } __asm{ POP DR8 } __asm{ POP DR12 } __asm{ POP DR16 } __asm{ POP DR20 } __asm{ POP DR24 } __asm{ POP DR28 } __asm{ POP DR56 }
//中断返回 __asm{ SETB EA } __asm{ RETI } }
中断切换任务的原理和函数切换任务的原理差不多, 只是细节有少许的不同, 这里就不详述了, 有兴趣的话可以去看移植原代码,加深理解
栈和RTOS中的任务栈
在RTOS领域,堆栈是一个不容忽视的概念,RTOS里的任务栈,是栈的一个分支,正确的理解栈,对学习RTOS有非常大的帮助。
堆栈分为堆和栈,这里只讲栈,为了不混淆概念,以下不管是堆栈还是栈,指的都是栈。
以下是栈概念的描述
1. 简单的来理解,栈就是一片内存,它的访问地址由栈顶指针SP给出。
2. SP是指向内存的一个指针,他永远指向最后写入数据的那个内存的地址.也就是当我们向栈内写入一字节数据后,相应的要操作SP+1,如果是拿出一字节数据,则SP-1.
3. 栈和普通的内存其实没什么区别,但是栈必须是SP指针能够访问的区域,例如,51内核的栈必须是在IDATA区, 251内核的栈必须是在EDATA区
4. 栈的另一个特征是和CPU内核指令,中断等硬件操作息息相关,比如子程序调用指令LCALL暗含栈操作,栈操作指令PUSH,POP在写入数据后同时也会操作SP增减。
栈还有一个重要特征是后入先出,这好比我们往一个小纸箱内不断的堆叠钞票,下次取钞票时,最先取出的是最后叠入的钞票。
用代码举例来说明后入先出特征:
void fc(void)
{
return;
}
void fb(void)
{
fc();
return;
}
void fa(void)
{
fb();
return;
}
上面的代码体现出函数调用链,即函数fa()调用了fb(),函数fb调用了fc(), 我们来分解一下这个调用过程即可体现出先入后出的特征,
执行语句fb()时, 编译器产生的汇编代码是 LCALL fb; 指令的具体操作是把当前(PC+3)的值写入栈,并且SP值自动加2 然后跳转到函数fb()并开始执行。
注意,在这里LCALL指令有隐含PC值入栈的操作。
接下来执行语句 fc(); 同样是把当前(PC+3)的值写入栈,并且SP值自动加2 然后跳转到函数fc()并开始执行。
此处也隐含PC值入栈的操作。
接下来执行fc()函数内return(RET)指令,RET指令从栈中读出两字数据写回PC,并使SP值-2,执行过后程序返回到fb()函数内return处
接下来执行fb()函数内return(RET)指令,RET指令从栈中读出两字数据写回PC,并使SP值-2,执行过后程序返回到fa()函数内return处
此时运行轨迹又回到了fa();
仔细分析上面的例子就可以发现,我们最后存入栈的数据(PC值)是被最先取出来的,这就是后入先出!
栈的几个作用:
1. 发生中断时硬件自动往栈内存储当前PC值(断点位置)和部分寄存器值并调整栈顶指针SP值,也就是自动保存中断发生时的程序运行断点位置,中断返回时,从堆栈中弹出PC值恢复PC , 并调整栈顶指针SP值。
2. 子程序(函数)调用时指令自动往栈内存储当前PC值(断点位置)并调整栈顶指针SP值,子程序返回时,从堆栈中弹出PC值恢复PC , 并调整栈顶指针SP值。
3. 编译器可以用堆栈传递函数形参,定义局部变量。
4. 如果用汇编语言编程,PUSH,POP栈操作指令可以对用户开放,用来存储临时数据。
栈容量大小需求的估算:了解了栈的几个作用以后,就可以大至估算出栈容量大小需求. 主要由中断嵌套层数,子程序调用深度,形参数量以及局部变量的多寡(如果编译器利用栈传递形参,把局部变量放在本内)几个因数决定。然而实际使用时却很难准确的计算出具体栈需求,原因是我们不断的在编辑代码,总不可能每改一次就去计算一次吧,并且这种计算本身也不容易,其次是中断是随机发生的,实际运行时最坏的情况短时间内可能体现不出来。所幸的是几乎所有成熟的RTOS都有堆栈检验功能,在RTOS自动检测的基础上我们只要稍为留些余量就可以轻易解决了。
如果没有使用RTOS,栈顶指针SP的初始化是由编绎器完成的,通常情况下,如果栈是向上增长的,除了我们代码已使用掉的内存外,剩下的内存都会自动成为栈。这也是为什么有些范例程序特别说明要留下多少RAM给堆栈使用的原因,如果你把内存用完了,那就等于没有堆栈了。
RTOS是一个多任务的系统,每个任务是相对独立的,每个任务都需要一个独立的堆栈。也就是说我们在运行这个任务的时候,使用这一片内存作为堆栈,切换到另一个任务后,需要使用另一片内存作为堆栈,只能这么做, 不让它们相互干扰. 切换堆栈很简单,只需改变栈顶指针SP的值就可以了。
RTOS在创建任务的时候,会为每一个任务指定堆栈区域和大小。
任务栈 就是 RTOS任务使用的堆栈
除此之外,RTOS中任务栈还有一个作用是用于保存上下文. 因为每一个任务运行都需要使用到寄存器组这种公共片上资源,当需要切换至另一个任务时,RTOS需要保存当前任务的寄存器组的数据,以备任务切换回来时,可以恢复这些数据,程序才能无差错的继续往后执行。
即兴而写, 欢迎批评指正
|