背景:在裸机开发上,有时候我们需要等待某个信号或者需要延迟时,CPU的运算是白白浪费掉了的,CPU的利用率并不高,我们希望当一个函数在等待的时候,可以去执行其他内容,提高CPU的效率,同时提高程序运行的效率,因此,我们决定使用实时操作系统。
实时操作系统有很多,这里学用的是freeRTOS,实时操作系统能够实现并发操作,所谓的并发操作就是能够看起来同时在做多件事情,这样就能很好地解决上述问题,当任务在等待时,直接切换到另一个任务执行就好了,等待信号到了,再切换回来执行,这样就很好地提高了CPU的利用率,延迟也是一样的,既然要延迟一定时间,与其让CPU什么都不做,不如让这段时间去做其他的事情。
一、任务
为了提高程序运行的效率,我们可以根据需要,对实现某个功能的函数创建一个任务,然后让任务在等待时,切换到其他任务。当只有一个任务时,程序就像在裸机上开发一样,因此,我们需要讨论的更多是多任务的情况。
1、变量
a、局部变量
在每个任务里创建的局部变量,它们都会分配自己的栈内存。不同任务的局部变量,都拥有不同的副本。因此,我们在不同任务之间进行切换时,这些局部变量不会互相干涉。
b、全局变量,静态变量
对于这些变量,多个任务使用的是同一个副本,也就是说,每个任务都可能操作这个变量,进而影响到其他任务的正确运行。因为任务可能在某个时候被切换走,如果在修改变量的时候被切换了并且被其他任务修改了该变量,可能就会导致出错。
当我们在学习FreeRTOS的时候,更多是学习如何解决多个任务对于使用这些变量之间的冲突,或者让其同步。而某个信息对于多个任务之间互相有影响,我们称之为通信。当然,要保证通信的正确性,后续会采用几种通信方式来解决冲突的问题。
2、任务
a、任务优先级
在上述内容中说明了FreeRTOS实现的是并发操作,说它是看起来像是同时在运行,因为任务之间切换很快,所以对于我们来说,就像是多个任务在同时运行一样。那么在任务切换的时候,我们就需要用到任务优先级了。
不是说像是同时运行一样吗,那优先级高低都无所谓吧?其实“像是同时运行一样”就是我们通过控制任务优先级实现的结果,所以同时运行的前提是我们让每个任务都有机会运行,然后快速切换就能够实现同时运行的效果了。如果我们给一个任务最高的优先级,并且不让其出现等待的情况,那么其他任务就不能执行,也就不会出现多个任务同时运行的情况了。
我们在创建任务的时候,我们都会给任务一个优先级。通常来说,优先级高的任务在准备好的时候优先执行,这个准备好可能是等待到了某个需要的东西或者说延迟到了。如果优先级高的函数不挂起,就是上述说的不出现等待或者其他情况,就会一直执行,然后其他任务不能执行就被饿死了。
现在我们知道优先级高的任务会优先执行了,那么优先级相同的呢?那优先级相同,说明两个任务同等重要,这个时候只要让他们轮流执行就好了。轮流执行,一个任务要执行多久才进行切换呢?FreeRTOS给我们提供了一个tick 的中断,你可以通过设置这个tick来配置切换的时长。
我们知道,我们可以创建任务和删除任务,如果我们只有一个任务呢?而且我们还让这个任务暂停了,这个时候会发生什么,没有任务执行了吗?事实上,FreeRTOS有一个空闲任务Idle task,当没有任务执行时,就会运行这个任务,同时,对于释放内存的行为,也是在Idle任务里进行的,因此如果不加以暂停让Idle运行的话,如果一直创建任务且不释放任务,内存最后会耗尽。(我们之前提到每个任务里的局部变量都有自己的栈还要TCB)因此任务被删除时,应该释放这部分内存。
这个Idle任务的优先级是最低的,为了保证你自己的任务能够正常运行不被打断。
任务的切换是通过调度器实现的,如果我在某个时候不想切换任务,那我就把调度器暂停就好了,等忙完了我的事情再把它恢复。
b、任务状态
在FreeRTOS中,任务有三个状态,分别是阻塞态,就绪态,暂停态。
很多时候,我们的任务不会一直运行而是等到有某个信号的时候才开始运行,然后运行完之后再继续等待。等待着的任务就可以说这个任务处于阻塞态,需要牢记阻塞的概念,在后续会频繁地使用阻塞这个名词,在实现数据的同步和互斥时,都是通过任务间的阻塞来实现的。处于阻塞态的任务是不占用CPU的,这也是我们使用RTOS操作系统的本质。
就绪态就是已经准备好的任务,处于这个状态的任务只要一切换到它就可以运行,比如说有个低级任务也不用等待什么信号才能运行,就只需要高优先级的任务执行完切换到它就可以运行了,那么这个任务就是处于就绪态的。处于就绪态的任务只要一切换到它就能执行。
暂停态顾名思义就是暂停了,可以在任务中自己暂停自己,但是要退出暂停就需要别人来进行操作。那暂停态和阻塞态的区别是什么?暂停态要运行的信号是需要自己给定的,在什么时候调用退出暂停函数就能进入就绪态。而阻塞态是等到什么信号就能进入就绪态,这个信号什么时候来都可以。
但是按照个人的理解,如果在某个信号出现的时候调用退出函数,应该也能让处于暂停态的任务实现和阻塞态任务一样的操作。一般情况下,暂停态使用较少。
3、调度算法
所谓调度算法,就是怎么确定哪个就绪态的任务可以切换为运行状态。我们在上文中知道,准备好了等着运行的任务就是处于就绪态的任务,那有多个任务处于就绪态的时候,如何协调好它们之间的切换就是调度算法要做的事情。
在上文说的优先级高的能够抢占低优先级的任务,同等优先级的任务轮流运行,其实就是调度算法的一种实现,你也可以通过配置来实现同等优先级不轮流运行。
调度算法可配置的三个宏定义:
可抢占:
意味着高优先级的任务准备好了之后可以直接运行,就是会从低优先级手里把CPU“抢”过来。
不可抢占:
不能抢就只能协商或者等待了,就算是低优先级,也得等他主动让出CPU,高优先级的任务才能运行。更高优先级的都不能抢占,那同等优先级的就更不用说了,因此也就不能配置时间片轮转。
时间片轮转:
时间片轮转的前提是配置可抢占,之后同等优先级的任务如果需要轮流运行,就配置为1。
不轮转:
同等优先级的任务不轮流执行,只能等任务主动让出CPU或者被更高级的任务抢占。
时间片轮转调度的一个关键特点是任务可以被其他任务抢占。当一个任务的时间片用完时,调度器会暂停该任务并切换到另一个任务执行。而在不可抢占的任务调度中,一旦一个任务开始执行,它会一直运行直到主动放弃处理器(例如进入阻塞状态等待某个事件)。在这种情况下,无法根据时间片来强制切换任务,因为任务不会被其他任务抢占。所以,不可抢占的任务调度与时间片轮转调度的机制不兼容,一般不能同时配置。
空闲任务让步:
空闲任务Idle是否让步于用户函数,让步意味着每执行一次就看看有没有用户函数要运行,主动让步给用户任务,低人一等。
不让步:
不让步就把空闲任务当作普通任务,大家轮流执行,没有谁更特殊。(但是这种情况出现在有和空闲任务相同优先级的情况,可以配置任务优先级和空闲任务优先级相同)一般情况下,空闲任务还是会被高优先级任务抢占。
适用场景
对处理器资源要求低的系统:在一些资源受限的嵌入式系统中,空闲任务不让步可以确保处理器在没有其他任务运行时不会频繁进行任务切换,从而降低系统开销。
- 例如,在一个简单的传感器节点中,大部分时间系统处于空闲状态,空闲任务不让步可以减少任务切换的开销,节省能源。
特定的实时性要求:在某些实时系统中,如果需要确保在特定情况下处理器不被空闲任务干扰,可以将空闲任务设置为不让步。
- 比如在一个关键任务需要在特定时间内响应的系统中,为了避免空闲任务在关键任务执行前抢占处理器,可将空闲任务设置为不让步。
4、同步和冲突
同步和冲突主要是解决临界资源在多个任务访问的时候出现的冲突问题。什么是临界资源,同一时间只能有一个人使用的资源,被称为临界资源。比如任务A、B都要使用串口来打印,串口就是临界资源。如果A、B同时使用串口,那么打印出来的信息就是A、B混杂,无法分辨。所以使用串口时,应该是这样:A用完,B再用;B用完,A再用。
实现对临界资源的管理,可以有同步和互斥的方式。
能实现同步、互斥的内核方法有:任务通知(task notification)、队列(queue)、事件组(event group)、信号量(semaphoe)、互斥量(mutex)。
a、队列
队列可以实现通信同步,将对数据的访问有序进行。
一方面,队列避免了多个任务对临界资源的直接访问,解决了多个任务同时访问修改出现的冲突问题。
另一方面,队列的写入和读出是基于原子操作的,就是说在写入和读出的时候不会被其他任务中断。
避免直接竞争:通过将临界资源的访问封装在队列的操作中,任务不再直接竞争访问临界资源。例如,如果多个任务需要访问一个共享的内存区域,这些任务可以通过将数据放入队列和从队列中取出数据的方式来间接访问共享内存,而不是直接对共享内存进行读写操作。
- 这样可以避免多个任务同时对临界资源进行读写操作时可能出现的冲突和数据不一致问题。
任务同步:队列的阻塞机制可以确保任务在正确的时间访问临界资源。例如,如果一个任务需要等待另一个任务完成对临界资源的操作后才能继续执行,它可以通过从队列中读取特定的数据项来实现同步。当另一个任务完成对临界资源的操作后,它将数据放入队列,从而通知等待的任务可以继续执行。
- 这种同步方式可以保证任务之间对临界资源的访问顺序,避免冲突。
原子操作:在 FreeRTOS 中,队列的操作通常是原子性的。这意味着在执行队列的写入或读取操作时,不会被其他任务中断。例如,当一个任务正在向队列中写入数据时,其他任务不能同时对该队列进行写入或读取操作。
- 这种原子性可以确保队列操作的完整性,避免在对临界资源进行访问时出现数据不一致的问题。
在读写队列时,如果队列没有数据,或者说数据满了,要进行读写的任务会进入阻塞态(也可以通过函数设置不阻塞,直接不写入和读取)如果队列没有数据,导致多个任务进入了阻塞,数据来的时候应该让谁先读呢?优先级高的先读,同等优先级的等得更久的先读,确实是一种很合理的方式。
b、信号量
信号量分为二进制信号量,顾名思义就是只有两个值,0,1和计数型信号量,可以设置始末值。在信号量里有两个动作,give给,计数加一;take获得,计数减一。
从字面意思来理解,只有给了东西之后才能获得,也就是有了值之后才可以进行减法,变成0。如果是二进制信号量,因为最大值是1,如果多次give给,只有第一次会成功。如果值为0的时候获得take,则会进入阻塞。
在 FreeRTOS 中,当一个任务在获取信号量时,如果没有信号量,会进入阻塞状态。这个阻塞会在以下情况结束:
当另一个任务或者中断服务程序释放(给出)信号量时,被阻塞的任务会被唤醒,结束阻塞状态。而不是等到另一个任务进入阻塞。另一个任务进入阻塞与否与当前被阻塞任务的唤醒条件无关。
于是我们就可以使用上面的机制来对数据同步访问,我可以在一个任务里对某个全局变量进行修改,修改后给出信号量,在另一个任务中则在获得信号量之后才可以对全局变量进行修改,在没有获得信号量时进行阻塞,这样就防止了“同时”修改数据导致的变量出错。
c、互斥锁
互斥量其实和信号量类似,如果把信号量的给和获得类比于钥匙,那么信号量可以看作是我把钥匙给了你,你才能运行。而互斥量则是谁拿到钥匙就只有谁能开门。但是freertos没有实现这个,其他任务也可以打开你的锁,因此互斥锁更多是要求程序员在写代码时根据自己需求实现。
死锁
任务A获得信号量,但是还没有释放,在这之间任务A调用函数B,但是函数B要获得信号量,因为A没有释放,所以B阻塞,然后导致A休眠,但是B等待A释放锁,A休眠无法释放,死锁发生。
递归锁
为了解决死锁的发生,引入了递归锁。递归锁的特性是可以多次获得锁,多次释放。在上面的例子中,因为是在同一个任务中,因此函数B也可以继续获得这个锁,只需要后续对应相同的释放次数即可。
d、事件组
事件组可以简单地认为就是一个整数:
1、该整数的每一位表示一个事件
2、每一位事件的含义由程序员决定,比如:Bit0表示用来串口是否就绪,Bit1表示按键是否被按下
3、这些位,值为1表示事件发生了,值为0表示事件没发生
4、一个或多个任务、ISR都可以去写这些位;一个或多个任务、ISR都可以去读这些位
5、可以等待某一位、某些位中的任意一个,也可以等待多位
理解事件组可以像理解队列,信号量一样,都是要等待到需要的“通知”,不管是队列里有数据了还是信号量为1了,事件组也一样,你可以等待某个bit变为你需要的值,也可以等待多个bit之间进行的与或操作,表示你需要多个通知才触发,否则就进入阻塞。不过有一点要注意的是,队列和信号量都是消耗进行的,队列读了会少,信号量读了会减一,在事件组里,你可以更自由,决定是否修改该bit的值。
事件发生时,可以唤醒所有符合事件的任务,因此具有广播功能。
e、任务通知
和之前的信号量不同,任务通知是有对象的,通知很明显可以通知谁,选择需要通知的任务。我们知道我们在前面实现通信都是引入了某个变量(结构体),比如需要创建一个队列,一个信号量作为通信的中介。而任务通知则不需要,在创建任务时,TCB结构体里就已经包含了。
每个任务都有一个结构体:TCB(Task Control Block),里面有2个成员:
1、一个是uint8_t类型,用来表示通知状态
2、一个是uint32_t类型,用来表示通知值通知状态有3种取值:
1、taskNOT_WAITING_NOTIFICATION:任务没有在等待通知
2、taskWAITING_NOTIFICATION:任务在等待通知
3、taskNOTIFICATION_RECEIVED:任务接收到了通知,也被称为pending(有数据了,待处理)通知值可以有很多种类型:
1、计数值
2、位(类似事件组)
3、任意数值
可以通过发送任务通知函数让通知值加一,并把任务状态置为pending,然后接收任务取出通知值。有点类似计数信号量的用法,但因为通知值通知的是明确的任务,接收任务也只能通过自己的通知值来获取数据。