[TOC]

uC/OS-Ⅲ实时操作系统内核原理总结

学习uC/OS-Ⅲ时做的一些记录,整理了一下,结合自己的理解,做一篇总结(本总结适合有一定的基础的同学食用,主要还是自己看)

注意:文章插图有些不清晰,有需要可以私信我找我要哈

参考书籍:

  • uC/OS-III 内核实现与应用开发实战指南
  • uC/OS-III 中文翻译(屈环宇译)
  • uC/OS-III 技术内幕
  • uC/OS-III 源码
  • uC-OS-III 3.06.01 API Reference

一、为什么要用RTOS?

玩单片机上RTOS前肯定有个疑惑,为什么要上RTOS?使用裸机编程不可以吗?

首先RTOS和裸机并不是谁绝对的好或者不好,更多情况下我们要根据实际情况来选择使用使用他,裸机编程方式适合代码量小,逻辑复杂度低的情景,其编程简单,开发速度快,而RTOS则适合代码量较大,逻辑复杂,适合稳定性要求高的场景中,缺点就是编程复杂,学习周期较长。

其次对于想要走上嵌入式驱动开发的工程师来说,其一般的路线如下:

学习路线 (1)

可以看到RTOS的学习可以作为我么向Linux学习的跳板,为后面的进阶打下基础,好了以上就是我个人对RTOS学习必要性的一些小理解

二、内核探索篇

2.1 任务定义与切换

​ uCOS的运行与裸机一个很明显的区别就是uCOS将要运行的程序拆分成各个任务快,任务块间进行切换运行,切换时间很短,每个子任务执行时间也很短,所以当系统跑起来之后,就像并行运行一样,这样我们设计程序的时候就可以把程序设计成多个部分独立执行,大大降低了程序的设计难度,方便程序的维护与管理

​ 任务的定义呢就是定义一个函数,里面放上一个死循环作为任务运行的实体,除了任务实体以外,任务还有许多其他的属性,比如给任务分配的堆栈大小,任务的执行优先级别等等,这些任务的属性通过一个结构体:TASK_TCB(任务控制块)进行关联,通过访问对应任务的任务控制块的结构体的成员,我们就可以访问他的实体函数入口,优先级,堆栈大小等等信息;

​ 任务的切换就是切换上下文的与运行环境,任务运行时后的环境我们叫他上下文运行环境,当任务需要切换程序的时候,会触发PendSV异常,触发PendSV 异常后,就会进入PendSV 异常服务函数,然后在里面进行任务切换,PendSV 异常服务函数其实就是保存上一个任务运行时的环境变量,自动或手动将数据压栈,然后切换堆栈指针,将下一个任务数据弹出来,完成任务切换

叫上下文而不叫运行环境大概是因为最开始context是编译原理中的概念,context-free grammar译为“上下文无关语法”是很贴切的。后来context应用在编程中,翻译的时候还是照搬了编译原理中的译法,虽然意思已经变了

下图是我学习野火《uC/OS-III 内核实现与应用开发实战指南》时记录的定义任务的流程,原文链接:uCOS-Ⅲ学习笔记:任务定义与切换

任务定义

2.2 系统时基

​ RTOS 必须要一个时基来驱动,该时基本质上是一个定时器,每次计数到位后会进行一次中断,中断程序对时基进行校准,更新时基计数值,然后在系统任务(时基任务)中处理系统的运行状态,比如扫描任务阻塞延时的等待列表,看哪个任务等待结束了,把他脱离等待列表,或者看看其他内核对象的等待列表有没有可以脱离的任务,将他们加入到任务就绪列表里面去,可以说系统时基是系统的心脏,操作系统没有时基,根本运行不起来,同时系统任务调度的频率等于该时基的频率,通常该时基由一个定时器来提供,也可以从其它周期性的信号源获得,每个时钟运行周期称为一个TICK

Cortex-M 内核中有一个系统定时器SysTick,它内嵌在NVIC 中,是一个24 位的递减的计数器,计数器每计数一次的时间为1/SYSCLK。当重装载数值寄存器的值递减到0 的时候,系统定时器就产生一次中断,以此循环往复。因为SysTick 是嵌套在内核中的,所以使得OS 在Cortex-M 器件中编写的定时器代码不必修改,使移植工作一下子变得简单很多,SysTick 是最适合给操作系统提供时基,用于维护系统心跳的定时器。

学习野火《uC/OS-III 内核实现与应用开发实战指南》时记录的定义任务的流程,原文链接:uCOS-Ⅲ学习笔记-时基列表

2.3 阻塞延时与空闲任务

​ 裸机编程里面的延时,通常使用的是软件延时,即还是让CPU 空等来达到延时的效果,比如两个for循环嵌套,空耗CPU,这样太浪费CPU性能了,使用RTOS 的很大优势就是榨干CPU 的性能,永远不能让它闲着,如果要延时绝不能让 CPU 空等来实现延时的效果,那么RTOS中的延时是怎么实现的呢?

​ 在RTOS 中的延时叫阻塞延时,即任务需要延时的时候,任务会放弃CPU 的使用权,进入到等待列表里面凉快去,CPU趁着这个功夫可以去干其它的事情,当任务延时时间到,对应的任务重新获取CPU 使用权,脱离等待列表,回到就绪列表,任务继续运行,这样就充分地利用了CPU 的资源,榨干CPU

​ 但上面的延时会有一个问题,如果所有的任务都在等待状态,那CPU 又去干什么事情了?此时就引入了空闲任务,如果没有其它任务可以运行,RTOS 都会为CPU创建一个空闲任务,这个时候CPU 就运行空闲任务。在uC/OS-III中,空闲任务是系统在初始化的时候创建的优先级最低的任务,空闲任务主体很简单,只是对一个全局变量进行计数。鉴于空闲任务的这种特性,在实际应用中,当系统进入空闲任务的时候,可在空闲任务中让单片机进入休眠或者低功耗等操作

2.4 系统时间戳

​ 在RTOS里面经常需要精准计算一段时间的长度,因此引入时间戳的概念,时间戳实际上就是一个时间点,记录了一个随着程序运行不断自加的运行值,或许有同学会以为是几个TIM定时器,但实际上不是,定时器的时间精度一般是us级别,但程序运行可不是us级别的,比如主频72M的单片机,时钟周期才1/72M秒,执行一条指令也就几个时钟周期也就是几ns,精度特别高,所以这里用来记录时间戳的外设肯定不是定时器,而是一个叫 DWT 的外设,,该外设有一个32 位的寄存器叫 CYCCNT,它是一个向上的 计数器,记录的是内核时钟HCLK(高速时钟)的运行的个数,当CYCCNT溢出之后,会清0 重新开始向上计数,因此刚好可以用它来做时间戳!内核代码使用他很简单,关掉中断直接读寄存器就能获取到时间戳

2.5 系统临界段

​ 临界段是一个代码段,这一段代码段有着特殊的性质,不可分割,相当于原子操作,严格禁止被打断,所以在进入临界段的时候要进行中断屏蔽,防止系统的调度中断打断了临界段的执行。临界段的内核代码实现挺简单,因为Cortex-M 内核专门设置了一条 CPS 指令有 4 种用法,可以用来控制开关中断和开关异常,使用的时候直接调用就行

1
2
3
4
1 CPSID I ;PRIMASK=1   ;关中断
2 CPSIE I ;PRIMASK=0 ;开中断
3 CPSID F ;FAULTMASK=1 ;关异常
4 CPSIE F ;FAULTMASK=0 ;开异常

2.6 任务就绪列表

​ 准备运行的任务被放置于就绪列表中。就绪列表包括2 个部分:一个表示任务优先级的优先级表,一个存储任务TCB 的双向链表;优先级表本质上就是一个32位整形,表示32 个优先级,如果要扩展优先级的话,在增加几个32位整形来扩展优先级列表就行,但一般32位足够使用,优先级列表中越低位代表的优先级越高,当对应优先级有任务就绪的时候,会把优先级表中的对应位置1,表示该优先级至少有一个任务已经就绪了,在优先级表中遍历找0的方法主要有两种:一种是前导0(从高位向低位遍历),另外一种是后导0(从低位向高位遍历)

20210815222642

​ 就绪列表的另外一个部分就是一个用于存储任务TCB 的双向链表,准确来说是一个数组,数组长度和优先级的长度一致,数组每个成员指向一个链表的首尾,就像下图:

20210815225014

​ 每个数组成员指向的链表里面的每个节点都是同一个优先级的任务TCB,因为和优先级表对应,所以内核根据优先级表可以很快的索引到就绪任务的TCB,进行调度操作

2.8 任务时间片运行

​ 刚刚了解了就绪列表的概念,有的同学可能有疑惑,OSRdyList[]每个数组成员指向的链表里面的每个节点都是同一个优先级的任务TCB,那这些同优先级的任务如何运行呢?这就引入了任务时间片运行的概念

​ 时间片运行就是相同优先级的任务可以分配不同的时间片(TiCK数量),任务每运行一次心跳时钟(TICK)消耗一个时间片,当任务时间片用完的时候,任务会从同优先级链表的头部移动到尾部,让下一个任务共享时间片,以此循环,内核实现的方式也挺简单,就是TCB增加一个时间片计数成员变量,每次调度时候计算在统计一下,为0就进行一次链表的节点移动操作,就像下图的Task2和Task3优先级都是2,TCB中有TimeQuanta 表示任务需要多少个时间片,TimeQuantaCtr 表示任务还剩下多少个时间片,当它为0时,任务2和任务3进行切换

20210815230941

这里有一个我以前写的时间片切换流程图参考

时间片

2.9 系统时基列表

​ 时基列表我在前面写阻塞延时的时候有提到,由名字我们可以看出时基列表是跟时间相关的,处于延时的任务和等待事件有超时限制的任务都会从就绪列表中移除,然后插入到时基列表这个小黑屋里面,每次TICK都对他们单独计时,任务延时完成后从小黑屋出来插回到就绪列表去,而等待超时了之后还要在进行一个小判断,看任务是进行恢复还是挂起等其他操作,时基列表的结构很简单,在代码层面上由全局数组OSCfg_TickWheel[]和全局变量OSTickCtr 构成

全局数组OSCfg_TickWheel[]的每个成员都包含一条单向链表,被插入到该条链表的TCB 会按照延时时间做升序排列,其中FirstPtr 用于指向这条单向链表的第一个节点。

20210815232449

全局变量OSTickCtr 则记录了系统启动后经过了多少个TICK周期

当然每个任务的TCB里面还要有时基列表的计算单元,用来统计任务的延时时间和超时等待时间

1
2
3
4
5
6
7
/*时基列表相关字段*/
OS_TCB *TickNextPtr; (1)
OS_TCB *TickPrevPtr; (2)
OS_TICK_SPOKE *TickSpokePtr; (5)

OS_TICK TickCtrMatch; (4)
OS_TICK TickRemain; (3)

其中TickCtrMatch 的值等于时基计数器OSTickCtr 的值加上TickRemain 的值,当TickCtrMatch 的值等于OSTickCtr 的值的时候,表示等待到期,TCB会从链表中删除;每个被插入到链表的TCB 都包含一个字段TickSpokePtr,用于回指到链表的根部,方便快速定位,因为时基列表操作很频繁,牺牲一点小内存换取速度,就像下图,有三个TCB在就绪列表里面等待

20210815233337

下图是我以前记录的uCos时基列表运行流程图

时基列表

2.10 任务挂起与恢复

uCOS-Ⅲ 的任务支持挂起和恢复的功能,挂起就相当于按下暂停键,任务停止,暂停后的任务从就绪列表中移除,任务恢复即重新将任务插入到就绪列表,一个任务挂起多少次就要被恢复多少次才能重新运行,一般情况下任务等待信号量、mutex、事件标志组、消息队列时,该任务会被放入挂起队列,但也有手动挂起的API接口,由用户进行挂起;uCOS的任务有多种状态,具体如下

1
2
3
4
5
6
7
8
9
10
11
12
13
/* ---------- 任务的状态 -------*/
#define OS_TASK_STATE_BIT_DLY (OS_STATE)(0x01u) /* /-------- 挂起位 */
#define OS_TASK_STATE_BIT_PEND (OS_STATE)(0x02u) /* | /----- 等待位 */
#define OS_TASK_STATE_BIT_SUSPENDED (OS_STATE)(0x04u) /* | | /--- 延时/超时位 */

#define OS_TASK_STATE_RDY (OS_STATE)( 0u) /* 0 0 0 就绪 */
#define OS_TASK_STATE_DLY (OS_STATE)( 1u) /* 0 0 1 延时或者超时 */
#define OS_TASK_STATE_PEND (OS_STATE)( 2u) /* 0 1 0 等待 */
#define OS_TASK_STATE_PEND_TIMEOUT (OS_STATE)( 3u) /* 0 1 1 等待+超时 */
#define OS_TASK_STATE_SUSPENDED (OS_STATE)( 4u) /* 1 0 0 挂起 */
#define OS_TASK_STATE_DLY_SUSPENDED (OS_STATE)( 5u) /* 1 0 1 挂起 + 延时或者超时 */
#define OS_TASK_STATE_PEND_SUSPENDED (OS_STATE)( 6u) /* 1 1 0 挂起 + 等待 */
#define OS_TASK_STATE_PEND_TIMEOUT_SUSPENDED (OS_STATE)( 7u) /* 1 1 1 挂起 + 等待 + 超时 */

任务状态由三位表示,按顺序分别为挂起位、等待位、延时位,三个位组合有8种任务状态,挂起是其中之一

任务挂起的核心单元就是挂起队列,挂起队列类似于就绪队列,挂起队列中放的是等待内核对象的任务。另外,任务在挂起队列中是根据优先级分类的。高优先级任务被放置在队列的头部,低优先级任务被放置在队列的尾部,挂起队列是一个OS_PEND_LIST 类型的数据结构,与前面不同的是挂起队列中不是指向任务的OS_TCB ,而是指向
OS_PEND_DATA链表,该链表结构如下:

20210815234601

结构前三个成员就是指针域不多说了,主要是下面几个:

  1. PendObjPtr 指向任务所等待的内核对象
  2. RdyObjPtr 如果任务等待多个内核对象,该指针指向任务被放入挂起列表前已经被提交的内核对象
  3. 如果任务等待多个内核对象,该指针指向任务被放入挂起列表后被提交的内核对象

每个内核对象会有一个指针指向他的挂起队列,就绪下面的信号量的结构一样,把等待的任务和内核对象关联起来:

20210815235023

​ 在每个TICK也会检查任务 TCB 的挂起值,挂起时会将他+1,恢复时则会减1,为0时会脱离挂起队列恢复到就绪列表中去,参与内核的调度,这里有一个我之前记录的流程图帮助大家理解,但此处的挂起只是简单的手动挂起和恢复,不涉及到内核对象

挂起与恢复

2.11 任务删除

​ 任务的删除就很简单了,断开TCB与所有内核对象的关联,清除自身变量,回收资源,完结撒花,但注意一点空闲任务不能被删除,因为RTOS至少有一个任务在运行,大致的流程可以参考我以前做的Mind流程图

任务删除

三、总结

手码了这么多字还是蛮累了,有许多内容写还不完善,也不清晰,内核这东西还挺复杂麻烦,讲清晰挺难,更多要自己去实践,跟着写一写代码,做做实验,多调调Bug,翻翻底层代码也就熟悉使用了,这篇文章内核原理内容就到这了,主要围绕RTOS运行的基本原理来讲解的,下一份总结则是围绕内核对象来进行讲解,内核对象就是信号量、互斥量、时间、消息队列、内存池、软件定时器还有一些系统任务比如软件定时器任务、中断处理任务、统计任务、时基任务等等

四、文章推荐

你和PID调参大神之间,就差这篇文章!

神器!200元开发板运行神经网络模型,吊打OpenMV!(保姆级教程)

51单片机多线程神器:Tiny-51操作系统

用树莓派做服务器运行博客网页

wechat