【RTOS学习】信号量 | 互斥量 | 递归锁

🐱作者:一只大喵咪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到一半时被切换走,因为获取过程中会关闭所有中断,不会调度其他任务。
  • 这种要么不做,要做就做完的性质称为原子性

信号量的TakeGive操作是具有原子性的。

计数型信号量:

如上图,在使用计数型信号量之前,必须先定义宏开关configUSE_COUNTING_SEMAPHORES

如上图代码,先创建计数型信号量,计数上限是3,初始值是0,然后创建两个任务,发送任务vSender用来增加信号量,优先级是2,接收任务vReceiver用来减少信号量,优先级是1。

如上图代码所示,任务vSender的优先级高,所以先执行,它连续四次增加信号量,增加成功打印成功对应的信息,成功计数值加一,失败的话打印失败信息,失败计数值加一,然后进入延时阻塞状态。

任务vReceiver的此时才有机会运行,它不断减少信号量,减少成功就打印成功信息,成功计数值加一,失败的话就打印失败信息,失败计数值加一,等vSender延时结束后抢占CPU,继续增加信号量,如此反复。

如上图所示运行结果,vSender任务连续四次Giver信号量中,只有前三次是成功的,因为信号量的上限是3。

vReceiverTake信号量时,也值能成功三次,因为信号量的计数值只到3,由于它是阻塞式Take,所以没有信号量后就处于阻塞状态了,故而没有答应错误信息。

🍺互斥量

拿最开始(上篇文章)中上厕所的例子来说,怎么独享厕所?你当然可以进去后,让别人帮你把门:但是,命运就掌握在别人手上了。

使用队列、信号量,都可以实现互斥访问,以信号量为例:

  • 信号量初始值为1
  • 任务A想上厕所,"take"信号量成功,它进入厕所
  • 任务B也想上厕所,"take"信号量不成功,等待
  • 任务A用完厕所,"give"信号量;轮到任务B使用

这需要两个前提才能保证安全:

  • 任务B很老实,不撬门(不"give"信号量)。
  • 没有坏人:别的任务不会"give"信号量。

前面信号量的介绍中也可以看到,负责givetake信号量的任务并不是同一个。

所以说,使用信号量确实可以实现互斥访问,但是并不完美,最完美的方式是:自己开门上锁,完事了自己开锁。

使用互斥量就可以解决这个问题:

如上图,互斥量的值只能为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才会被唤醒去申请锁。

  • 为了独立理解互斥量,就认为它是一把锁,只有上锁和开锁两种状态,申请锁后就上锁了,归还后就开锁了。

既然是互斥量的本质就是一个二进制型的信号量,那么申请锁和归还锁用的也是信号量的TakeGive方式。


互斥量也被称为互斥锁,使用过程如下:

  • 互斥量初始值为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次,要GiveN次,这锁才会释放。
  • 递归锁实现了由谁上锁就必须由谁解锁,其他人不能解锁。

🥤大概原理

如上图,递归锁的结构大概如上图所示,它虽然也是一个互斥量,但是和互斥量不同的是,它还有一个专门用来记录任务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的递归锁也失败了。

  • 谁申请的递归锁,必须由谁释放。
  • 递归锁只能由一个任务申请。

解决死锁问题:

如上图,仍然是前面产生死锁的例子,只是这里将普通互斥锁换成了递归锁,将申请锁和释放锁的方式换成对应递归锁的方式,其他没有变。

如上图,此时就不再有死锁的情况了,两个函数都可以执行,OtherFunctionTaskFunction申请递归锁还没有释放的情况下又再次申请成功。

🍺总结

信号量是基于队列构建的,只负责管理数据的余量,不管理数据本身,互斥量是基于二进制型信号量构建的,它的计数值只有0和1,并且增加了一个优先级继承功能来解决优先级反转的问题。又衍生出了递归锁解决了死锁问题和监守自盗的问题。

这三种结构,最底层还是队列,所以它们实现同步与互斥的原理和队列是相同的。

相关推荐
XH华3 小时前
初识C语言之二维数组(下)
c语言·算法
南宫生4 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
sanguine__4 小时前
Web APIs学习 (操作DOM BOM)
学习
数据的世界016 小时前
.NET开发人员学习书籍推荐
学习·.net
四口鲸鱼爱吃盐6 小时前
CVPR2024 | 通过集成渐近正态分布学习实现强可迁移对抗攻击
学习
Uu_05kkq6 小时前
【C语言1】C语言常见概念(总结复习篇)——库函数、ASCII码、转义字符
c语言·数据结构·算法
枯无穷肉8 小时前
stm32制作CAN适配器4--WinUsb的使用
stm32·单片机·嵌入式硬件
OopspoO8 小时前
qcow2镜像大小压缩
学习·性能优化
不过四级不改名6778 小时前
基于HAL库的stm32的can收发实验
stm32·单片机·嵌入式硬件
嵌入式科普9 小时前
十一、从0开始卷出一个新项目之瑞萨RA6M5串口DTC接收不定长
c语言·stm32·cubeide·e2studio·ra6m5·dma接收不定长