🐱作者:一只大喵咪1201
🐱专栏:《RTOS学习》
🔥格言:你只管努力,剩下的交给时间!
信号量 | 互斥量 | 递归锁
🍺信号量
信号量也是FreeRTOS
实现同步与互斥的方式。
如上图便是信号量的模型,任务A和任务B是生产者任务,负责生产数据,任务C和任务D是消费者任务,负责消费数据。
生产者每生产一个数据,信号量就加1,当增加到用户设定的限定值时直接失败返回。
消费者每消费一个数据,信号量就减一,当信号量减到0的时候,消费者任务就处于阻塞状态,直到新数据到来才被唤醒。
- 被唤醒时,谁的优先级高就唤醒谁,优先级相同就唤醒阻塞时间最长的任务。
从信号量这个名字来看:
- 信号:起通知作用
- 量:还可以用来表示资源的数量
当量 没有限制时,它就是"计数型信号量",当量只有0、1两个取值时,它就是"二进制信号量"。
- 二进制信号量和计数型信号量的唯一差别,就是计数值的最大值被限定为1。
- 支持
give
给出资源,计数值加一,还支持take
获得资源,计数值减一。
- 信号量本身就是一个共享资源。
计数型信号量的典型场景是:
- 计数:数据产生时"give"信号量,让计数值加1;处理数据时要先"take"信号量,就是获得信号量,让计数值减1。
- 资源管理:要想访问资源需要先"take"信号量,让计数值减1;用完资源后"give"信号量,让计数值加1。
- 信号量只表示数据的存在情况,相当于买票时票的余量,它并不管理数据本身。
🥤原理
如上图,在使用信号量的时候需要先调用xSemaphoreCreateCounting
来创建信号量,可以看到,它底层调用的是xQueueCreateCountingSemaphore
函数,从名字就可以看出其实是创建了一个队列。
在函数内部会调用xQueueGenericCreate
创建一个队列,该队列的大小就是指定信号量的计数值。
- 信号量的上限值就是队列的长度。
如上图,使用xSemaphoreGive
增加信号量时,其本质就是向队列中写数据,每增加1就写一个数据。
使用xSemaphoreTake
减少信号量时,其本质就是从队列中读取数据,每减1就读取一个数据。
由于信号量的底层其实是队列,所以它实现同步与互斥的原理是和队列是类似的。
信号量和队列的对比:
队列 | 信号量 |
---|---|
可以容纳多个数据,有两部分内存:队列结构体、存储数据的空间 | 只有计数值,无法容纳其他数据 |
生产者:没有空间存入数据时可以阻塞 | 生产者:不用阻塞,计数值达到最大时失败返回 |
消费者:没有数据时可以阻塞 | 消费者:没有资源时可以阻塞 |
- 除了使用内存上的区别外,最大的区别就是信号量的生产者任务一般不阻塞,失败了就直接返回。
🥤使用信号量的函数
使用信号量时,先创建、然后去添加资源、获得资源。使用句柄来表示一个信号量。
创建二进制信号量:
cpp
/* 动态创建 */
SemaphoreHandle_t xSemaphoreCreateBinary( void );
/* 静态创建 */
SemaphoreHandle_t xSemaphoreCreateBinaryStatic(
StaticSemaphore_t *pxSemaphoreBuffer );
- 动态创建时不用传参,只需要接收返回的信号量句柄。
- 静态创建时,需要用户指定存放信号量的内存空间
pxSemaphoreBuffer
。
创建计数型信号量:
cpp
/* 动态创建 */
SemaphoreHandle_t xSemaphoreCreateCounting(
UBaseType_t uxMaxCount,
UBaseType_t uxInitialCount);
/* 静态创建*/
SemaphoreHandle_t xSemaphoreCreateCountingStatic(
UBaseType_t uxMaxCount,
UBaseType_t uxInitialCount,
StaticSemaphore_t *pxSemaphoreBuffer );
- uxMaxCount:最大计数值
- uxInitialCount:计数初始值
- pxSemaphoreBuffer:静态创建时需要指定存放信号量的内存。
- 返回值:创建成功返回信号量句柄
增加信号量:
cpp
BaseType_t xSemaphoreGive( SemaphoreHandle_t xSemaphore );
- xSemaphore :要增加的信号量句柄。
- 返回值:成功返回
pdTRUE
,失败返回pdFALSE
。
获取信号量:
cpp
BaseType_t xSemaphoreTake(
SemaphoreHandle_t xSemaphore,
TickType_t xTicksToWait);
- xSemaphore:要减少的信号量句柄
- xTicksToWait:无法获得信号量时的阻塞时间,0:不阻塞,马上返回,
portMAX_DELAY
一直阻塞直到被唤醒。- 返回值:成功返回
pdTRUE
,失败返回pdFALSE
。
删除信号量:
对于动态创建的信号量,不再需要它们时,可以删除它们以回收内存。
cpp
void vSemaphoreDelete( SemaphoreHandle_t xSemaphore );
- xSemaphore :要删除的信号量句柄
- 无返回值,必然会成功。
🥤基本使用
二进制信号量:
通过二进制信号量来解决两个任务同时使用一个串口的时的缺陷:
如上图,创建一个二进制信号量,此时信号量的初始值是0,所以需要先使用xSemaphoreGive
给它增加1。
当两个任务在使用串口时,先Take
信号量,如果Take
成功才能使用串口,否则就阻塞等待,使用完毕后需要Give
信号量,以便另一个任务使用串口。
如上图,两个任务在交替使用串口。
这个过程中,两个任务只能有一个任务使用串口,有任务在使用串口,这个二进制信号量就是0,另一个任务就无法Take
,只能阻塞,就实现了互斥的目的。
- 使用信号量不存在
Take
到一半时被切换走,因为获取过程中会关闭所有中断,不会调度其他任务。- 这种要么不做,要做就做完的性质称为原子性。
信号量的Take
和Give
操作是具有原子性的。
计数型信号量:
如上图,在使用计数型信号量之前,必须先定义宏开关configUSE_COUNTING_SEMAPHORES
。
如上图代码,先创建计数型信号量,计数上限是3,初始值是0,然后创建两个任务,发送任务vSender
用来增加信号量,优先级是2,接收任务vReceiver
用来减少信号量,优先级是1。
如上图代码所示,任务vSender
的优先级高,所以先执行,它连续四次增加信号量,增加成功打印成功对应的信息,成功计数值加一,失败的话打印失败信息,失败计数值加一,然后进入延时阻塞状态。
任务vReceiver
的此时才有机会运行,它不断减少信号量,减少成功就打印成功信息,成功计数值加一,失败的话就打印失败信息,失败计数值加一,等vSender
延时结束后抢占CPU,继续增加信号量,如此反复。
如上图所示运行结果,vSender
任务连续四次Giver
信号量中,只有前三次是成功的,因为信号量的上限是3。
vReceiver
在Take
信号量时,也值能成功三次,因为信号量的计数值只到3,由于它是阻塞式Take
,所以没有信号量后就处于阻塞状态了,故而没有答应错误信息。
🍺互斥量
拿最开始(上篇文章)中上厕所的例子来说,怎么独享厕所?你当然可以进去后,让别人帮你把门:但是,命运就掌握在别人手上了。
使用队列、信号量,都可以实现互斥访问,以信号量为例:
- 信号量初始值为1
- 任务A想上厕所,"take"信号量成功,它进入厕所
- 任务B也想上厕所,"take"信号量不成功,等待
- 任务A用完厕所,"give"信号量;轮到任务B使用
这需要两个前提才能保证安全:
- 任务B很老实,不撬门(不"give"信号量)。
- 没有坏人:别的任务不会"give"信号量。
前面信号量的介绍中也可以看到,负责give
和take
信号量的任务并不是同一个。
所以说,使用信号量确实可以实现互斥访问,但是并不完美,最完美的方式是:自己开门上锁,完事了自己开锁。
使用互斥量就可以解决这个问题:
如上图,互斥量的值只能为1或者0,它也经常被称为锁,也就是只有两个状态,上锁和解锁。
任务A上锁后,互斥量就变为0,此时其他任何一个任务都无法再申请到这个锁,只有任务A解锁,互斥量变为1后,其他任务才能申请到锁进行上锁。
- 它的核心在于:谁上锁,就只能由谁开锁。
很奇怪的是,FreeRTOS的互斥锁,并没有在代码上实现这点,甚至是Linux也没有实现:即使任务A获得了互斥锁,任务B竟然也可以释放互斥锁,后面在使用本喵会用代码演示。
- 谁上锁,谁就解锁,这只是一个约定,需要程序员自己去维护。
在多任务系统中,任务A正在使用某个资源,还没用完的情况下任务B也来使用的话,就可能导致问题。比如对于串口,任务A正使用它来打印,在打印过程中任务B也来打印,客户看到的结果就是A、B的信息混杂在一起。
再比如对于同一个变量,比如 int a ,如果有两个任务同时写它就有可能导致问题:
对于变量a的修改,C代码只有一句a = a + 8
,但是实际上它分3步实现:读出原数值,修改数值,写回到内存。
我们想让任务A、B都执行add_a函数,函数执行两次,最终的结果是1 + 8 + 8 = 17
。
现在假设任务A运行完代码①,在执行代码②之前被任务B抢占了:现在任务A的R0等于1。任务B执行完add_a函数,a等于9。
任务A继续运行,在代码②处R0仍然是被抢占前的数值1,执行完②③的代码,a等于9,这跟预期的17不符合。
- 修改变量、设置结构体、在16位的机器上写32位的变量,这些操作都是非原子的。也就是它们的操作过程都可能被打断,如果被打断的过程有其他任务来操作这些变量,就可能导致冲突。
上述问题的解决方法是:任务A访问全局变量、函数代码时,独占它,就是上个锁。这些全局变量、函数代码必须被独占地使用,它们被称为临界资源。
🥤原理
如上图,使用互斥量之前需要先调用xSemaphoreCreateMutex
创建互斥量,其实是在调用xQueueCreateMutex
,它的底层会创建一个队列,长度为1。
从创建互斥量函数的名字中也可以看出,其实就是一个类似二进制型的信号量,底层同样也是用的队列。
所以当锁被任务A申请走以后,任务B就会被阻塞,同样是放在队列的接收阻塞链表中,当任务A归还锁后,任务B才会被唤醒去申请锁。
- 为了独立理解互斥量,就认为它是一把锁,只有上锁和开锁两种状态,申请锁后就上锁了,归还后就开锁了。
既然是互斥量的本质就是一个二进制型的信号量,那么申请锁和归还锁用的也是信号量的Take
和Give
方式。
互斥量也被称为互斥锁,使用过程如下:
- 互斥量初始值为1
- 任务A想访问临界资源,先获得并占有互斥量,然后开始访问
- 任务B也想访问临界资源,也要先获得互斥量:被别人占有了,于是阻塞
- 任务A使用完毕,释放互斥量;任务B被唤醒、得到并占有互斥量,然后开始访问临界资源
- 任务B使用完毕,释放互斥量
正常来说:在任务A占有互斥量的过程中,任务B、任务C等等,都无法释放互斥量。但是FreeRTOS未实现这点:任务A占有互斥量的情况下,任务B也可释放互斥量,后面本喵会演示。
🥤使用互斥量的函数
创建互斥量:
cpp
/* 动态创建 */
SemaphoreHandle_t xSemaphoreCreateMutex( void );
/* 静态创建 */
SemaphoreHandle_t xSemaphoreCreateMutexStatic(
StaticSemaphore_t *pxMutexBuffer );
- pxMutexBuffer :静态创建时需要用户指定存放互斥量的内存空间。
- 返回值:用于控制互斥量的句柄。
申请锁/释放锁:
cpp
/* 申请锁 */
BaseType_t xSemaphoreTake(
SemaphoreHandle_t xSemaphore,
TickType_t xTicksToWait );
/*释放锁*/
BaseType_t xSemaphoreGive( SemaphoreHandle_t xSemaphore );
- xSemaphore:互斥量的句柄。
- xTicksToWait :等待时间
- 返回值:成功返回
pdTRUE
,失败返回pdFALSE
。
- 互斥量本质上就是信号量,所以互斥量的句柄类型也是
SemaphoreHandle_t
。
删除互斥量:
cpp
void vSemaphoreDelete( SemaphoreHandle_t xSemaphore );
🥤互斥量的基本使用
如上图,使用互斥锁之前,需要先定义宏configUSE_MUTEXS
。
一个任务上锁,另一个任务解锁:
如上图,创建一个互斥量,再创建两个任务,申请锁任务优先级是2,释放锁任务优先级是1,优先级为2的任务先执行。
如上图,Take
任务先执行,先申请互斥锁,成功则打印成功信息后阻塞,失败则直接阻塞,让GiveAndTake
任务有机会执行。
GiveAndTake
任务执行时,先尝试申请一下互斥锁,成功则打印成功信息,不成功则打印失败信息,并且将互斥锁私自释放,释放成功则打印相关信息,然后再申请互斥锁,成功则打印成功信息后阻塞,失败后直接阻塞。
-
GiveAndTake
尝试申请互斥锁,如果不成功则监守自盗。
如上图,Take
申请互斥锁成功后,GiveAndTake
任务是无法再次申请到这个互斥锁的,进而私自释放互斥锁,然后再申请互斥锁,从而监守自盗成功。
- 切记切记,我们写代码时不要这么做。
两个任务使用同一个串口:
仍然使用两个任务使用串口的例子,虽然用二进制信号量解决了,但是最恰当的还是信号量:
如上图,创建互斥量后,不用像二进制信号量那样要Give
一下进行初始化,互斥量创建函数中内部已经进行了初始化。
如上图,在使用串口前需要先上锁,使用完毕后再解锁,当任务1上锁了以后,任务2是无法使用串口的。
- 上锁和解锁之间的代码就是临界区,上锁能够保证临界区的安全,让所有任务串行执行临界区代码。
如上图,此时任务1和任务2在交替使用串口,比如不会出现打印信息的混乱。
🥤优先级反转
互斥锁虽然没有实现谁上锁谁解锁,但是它实现了优先级继承,用来解决优先级反转的问题。
如上图,此时有三个任务,任务A的优先级是1,任务B的优先级是2,任务C的优先级是3,它们共同使用一把互斥锁。
任务B和任务C先阻塞一会,让任务A先运行,任务A在运行的过程中申请了互斥锁,当任务B和任务C延时结束以后,最高优先级的任务C开始运行。
任务C在运行的过程中也要申请这把锁,但是锁已经被任务A申请走了,任务A此时已经被切换走了进入阻塞状态,它抱着锁走了。所以任务C无法申请到锁也进入阻塞状态。
此时能运行的就只有任务B了,因为任务B的优先级高于任务A,所以任务A始终得不到运行,也就始终无法释放锁。
- 这就导致,优先级最高的任务C因为无法申请到锁而阻塞无法运行,优先级发生了反转。
如上图代码,创建三个任务,分别执行低优先级任务vLPTask
,中优先级任务vMPTask
,高优先级任务vHPTask
,也就是任务A,任务B和任务C。
- 这里必须创建二进制信号量,不能创建互斥锁,因为二进制信号量没有解决优先级反转的功能,才能看到优先级反转的实验现象。
如上图,低优先级任务中,将对应的标志位置一,然后申请互斥锁,之后进行长时间的运算,运算完毕后归还锁。
二上图,中优先级任务先延时,防止抢占低优先级任务,让低优先级任务先运行。
如上图代码,高优先级任务一开始也进入延时,防止抢占低优先级任务,延时结束后申请互斥锁,然后再释放互斥锁。
如上图所示运行结果,低优先级任务先执行,成功申请互斥锁,但是还没有来得及归还,高优先级任务抢占执行,低优先级任务抱着锁被切走了。
高优先级任务运行后也申请锁,但是申请失败,所以阻塞了,此时中优先级任务得到了机会始终运行。
- 这种现象就是优先级反转。
🥤优先级继承
而互斥锁的成功解决了优先级反转这一问题,它是通过优先级继承解决的。
如上图,前面部分和优先级反转一样,任务C因为无法申请到锁而进入阻塞状态。
但是使用互斥锁时,短暂地将任务C的高优先级3赋给任务A,此时任务A的优先级就比任务B的优先级高,所以此时执行的是任务A而不是B。
直到任务A将锁归还以后,立刻将任务A的高优先级回收,任务A的优先级又变成了1,此时任务C的优先级最高,所以任务C执行。
由于任务A已经将锁归还了,所以任务C可以顺利的申请到锁而继续执行下去,此时就符合最高优先及的任务抢占执行的特性了。
- 优先级继承就是,短暂提高低优先级任务到高优先级,让它有机会执行,归还互斥锁,然后再恢复到低优先级。
如上图代码,只需要将原本创建的二进制型信号量变成创建互斥量,就可以解决优先级反转的问题,其他代码都不用作任何改变。
如上图运行结果,和我们分析的一致,当高优先级任务因为低优先级任务抱着锁被切走而无法申请锁时,短暂的赋予低优先级任务高优先级的权利。
原本的低优先级任务得以执行,释放互斥锁后恢复到了原本的低优先级,此时高优先级任务抢占执行,并且申请锁成功,不再阻塞,从而始终执行。
在优先级继承这个过程中,中优先级任务就没有机会被执行,此时就完美解决了优先级反转的问题。
🍺递归锁
假设这样的场景: 任务 A 获得了互斥锁 M,它又调用了一个函数,这个函数函数也要去获取同一个互斥锁 M,于是它阻塞,此时任务 A 休眠,等待任务 A来释放互斥锁!
- 任务A自己阻塞不动了,还在等着自己释放互斥锁来救自己。
- 此时就产生了死锁!
就像我们找工作的时候,公司只招有工作经验的人,但是我们没有工作经验,又只能去找工作。
如上图代码所示,创建一个互斥锁,再创建一个任务,在该任务中申请锁,申请成功后打印一句话表示自己在运行,然后调用另一个函数,在这个函数中也申请这个锁,申请成功也打印一句话,表示该函数在执行,没有申请成功就阻塞。
如上图,只有新创建的任务在申请锁成功够打印了一句话,它调用的另一个函数并没有打印,说明这个函数申请锁失败了。
但是程序并没有运行下去,此时整个任务处于阻塞状态,因为在两处申请了同一把锁,而且第一次申请完后还没有来得及释放,这就是发生了死锁。
- 普通的互斥锁,在
FreeRTOS
中,发生死锁后,可以由其他任务来释放锁,但是不建议这么做,因为它设计的初衷就是谁申请,谁释放。
这样来说,上面这种情况就不能有吗?如果就是需要这种场景呢?此时就可以使用递归锁(Recursive Mutexes):
- 任务A获得递归锁M后,还可以多次去获得这个锁。
Take
了N次,要Give
N次,这锁才会释放。
- 递归锁实现了由谁上锁就必须由谁解锁,其他人不能解锁。
🥤大概原理
如上图,递归锁的结构大概如上图所示,它虽然也是一个互斥量,但是和互斥量不同的是,它还有一个专门用来记录任务TCB节点的成员,以及一个记录申请锁次数的计数值。
当Task1
申请到递归锁以后,TCB*成员中存放的就是Task1
,此时Task2
再来申请这个递归锁时,由于和记录的TCB节点不符,就会拒绝Task2
申请递归锁。
对于Task1
,它可以多次申请递归锁,每Take
申请一次,递归锁内部的计数值就会加一,每Give
释放一次,该计数值就会减一。
所以Task1
申请了多少次就必须释放多少次,否则这个锁就一直属于Task1
,其他任务无法申请。
🥤使用递归锁的函数
递归锁的函数和普通互斥锁的函数名不一样,但是参数类型一样,所以本喵就不介绍它的参数类型了:
功能 | 递归锁 | 普通互斥锁 |
---|---|---|
创建 | xSemaphoreCreateRecursiveMutex | xSemaphoreCreateMutex |
申请锁 | xSemaphoreTakeRecursive | xSemaphoreTake |
释放锁 | xSemaphoreGiveRecursive | xSemaphoreGive |
可以看到,递归锁的常用操作也是只有这三个,在函数名上比普通互斥锁多了Recursive
表示这是递归锁的函数。
🥤使用
如上图,在使用递归锁之前,先要定义互斥锁的宏开关configUSE_RECURSIVE_MUTEXES
。
一个任务申请递归锁另一个释放:
如上图,创建两个任务,任务1的优先级是2,任务2的优先级1,开始调度后让任务1先执行。
如上图,任务1先开始运行,连续多次申请递归锁,每申请成功一次就打印一次成功信息,失败则打印失败信息,当多次申请完毕后,让自己进入阻塞状态,让出CPU让任务2执行。
任务2开始执行后,先释放 任务1申请的递归锁,如果释放成功则打印成功信息,失败则打印失败信息,之后再申请任务1的递归锁,如果申请成功则打印成功信息,失败打印失败信息。
如上图,任务1五次申请递归锁都成功,此时意味着递归锁中的计数值就成了5。任务2 释放 任务1申请的递归锁失败了,之后 申请 任务1的递归锁也失败了。
- 谁申请的递归锁,必须由谁释放。
- 递归锁只能由一个任务申请。
解决死锁问题:
如上图,仍然是前面产生死锁的例子,只是这里将普通互斥锁换成了递归锁,将申请锁和释放锁的方式换成对应递归锁的方式,其他没有变。
如上图,此时就不再有死锁的情况了,两个函数都可以执行,OtherFunction
在TaskFunction
申请递归锁还没有释放的情况下又再次申请成功。
🍺总结
信号量是基于队列构建的,只负责管理数据的余量,不管理数据本身,互斥量是基于二进制型信号量构建的,它的计数值只有0和1,并且增加了一个优先级继承功能来解决优先级反转的问题。又衍生出了递归锁解决了死锁问题和监守自盗的问题。
这三种结构,最底层还是队列,所以它们实现同步与互斥的原理和队列是相同的。