事件组也是一种实现同步与互斥的方法,可以简单地认为就是一个整数,每一位表示一个事件,每一位事件的含义由程序员决定,比如:Bit0 表示用来串口是否就绪,Bit1 表示按键是否被按下。
这些位,值为 1 表示事件发生了,值为 0 表示事件没发生,一个或多个任务、都可以去写这些位;一个或多个任务、都可以去读这些位。
如上图所示是事件组的模型,左边的任务A和任务B是生产者任务,任务A完成事件后会将事件组中的bit0置为1,任务B完成事件后会将bit2置为1。
任务C和任务D是消费者任务,它们读取事件组的每一位,任务C和任务D都可以指定等待哪一位置一,当检测到bit0或bit2为1时,说明对应的事件完成了,任务C和任务D就可以进行下一步操作。
事件组用一个整数来表示,其中的高 8 位留给内核使用,只能用其他的位来表示事件:
configUSE_16_BIT_TICKS
是用来表示 Tick Count
的,怎么会影响事件组?这只是基于效率来考虑 :
事件组和队列、信号量等不太一样,主要集中在 2 个地方:
事件发生后要唤醒哪个等待的任务?
队列、信号量:事件发生时,一次只会唤醒一个任务。
事件组:事件发生时,会唤醒所有符合条件的任务,简单地说它有"广播"的作用 。
以上图为列,任务 C、D 等待事件,可以等待某一位、某些位中的任意一个,也可以等待多位。
简单地说就是"或"、"与"的关系。
是否清除事件?
队列、信号量:是消耗型的资源,队列的数据被读走就没了;信号量被获取后就减少了 。
事件组:被唤醒的任务有两个选择,可以让事件保留不动,也可以清除事件。
如上图所示事件组结构体的定义,包含一个uxEventBits
成员,这是一个整数,可以根据宏设置为16位或者32位,本喵这里使用的是32位,该成员每一个比特位就代表一个事件,当该事件发生时,就将对应的比特位置1。
还有一个链表xTaskWaitingForBits
,用来管理等待事件发生而处于阻塞状态的任务,这些任务的TCB节点都在这个链表中,这些任务关心的事件没有发生(比特位为0)时,这些任务就处于阻塞状态。当某个事件发生时,就将对应的任务唤醒,并从该链表中移除,放入到就绪队列中。
如上图所示设置事件组中比特位的函数,可以看到,用户传入要设置的比特位uxBitsToSet
,在函数内部将这个值与事件组结构体中的成员uxEventBits
成员做了或运算。
如上图所示消费者任务等待事件的函数,传参时传入要等待的比特位uxBitsToWaitFor
,在函数内部会先将所有任务都挂起,然后再检查事件组中的比特位情况。
- 检查前挂起所有任务是为了保证此次检查的行为的原子性,在检查过程中不会被切换下去。
然后会获取当前事件组中标的那个整数,将这个整数和用户传入的要等待的比特位进行判断,如果事件组中要等待的比特位已经置1,立刻将该任务仍需等待的时间设置为0,也就是退出阻塞状态,放入到就绪队列中。
如果事件组中要等待的比特位仍然没有置1,那么就继续维持原本该任务的阻塞状态。
这里将事件组中比特位置一的是生产者任务,当该任务完成某个事件后,就将事先约定好的事件组的比特位置一。等待事件组中对应比特位的任务是消费者任务,它根据约定等待某个特定标志位置1,一旦置1了就说明事件发生了,就可以进行下一步动作。
创建事件组:
/* 动态创建 */
EventGroupHandle_t xEventGroupCreate( void );
/* 静态创建 */
EventGroupHandle_t xEventGroupCreateStatic( StaticEventGroup_t *
pxEventGroupBuffer );
- pxEventGroupBuffer:静态创建时,需要用户指定事件组结构的内存空间。
- 返回值:事件组句柄,用来控制事件组,失败返回NULL。
设置事件:
EventBits_t xEventGroupSetBits( EventGroupHandle_t xEventGroup,
const EventBits_t uxBitsToSet );
- xEventGroup:要设置的事件组句柄。
- uxBitsToSet:要设置的比特位,可以是一个,如bit0,也可以一次性设置多个,如(bit0 | bit 2 | bit6)。
- 返回值:返回原来事件组中的事件值(没什么意义,很有可能已经被其他任务修改了)。
设置多个比特位的时候,必须使用或运算,比如要设置bit1和bit5,在传参的时候就传(1 << 1) | (1 << 5)
。
等待事件:
EventBits_t xEventGroupWaitBits( EventGroupHandle_t xEventGroup,
const EventBits_t uxBitsToWaitFor,
const BaseType_t xClearOnExit,
const BaseType_t xWaitForAllBits,
TickType_t xTicksToWait );
- xEventGroup:要等待的事件组句柄。
- uxBitsToWaitFor:要等待事件组中的比特位。
可以是一个,如bit0。
也可以是多个,如(bit0 | bit2 | bit4)。
- xClearOnExit:要等待的比特位就绪后,该函数退出前是否要清除比特位。
pdTRUE
:清除uxBitsToWaitFor指定的位。
pdFALSE
:不清除
- xWaitForAllBits:是等待uxBitsToWaitFor中的所有比特位还是某一个?
pdTRUE
:必须是uxBitsToWaitFor中所有事件都发生,该函数等待成功返回,是AND
的关系。
pdFALSE
:uxBitsToWaitFor中任意一个事件发生,该函数等待成功返回,是OR
的关系。
- xTicksToWait :等待时间,0表示立即返回,
portMAX_DELAY
表示阻塞等待。- 返回值:返回的是事件组中的整数值,如果期待的事件发生了,返回的是“事件发生后”的事件值,如果是超时退出,返回的是超时时刻的事件值。
这个函数的参数比较复杂,下面再举例说明一下:
事件组的值 | uxBitsToWaitFor | xWaitForAllBits | 说明 |
---|---|---|---|
0100 | 0101 | pdTRUE | 任务期望bit0,bit2都为1,当前值只有bit2满足,任务进入阻塞态 |
0100 | 0101 | pdFALSE | 任务期望bit0,bit2某一个为1,当前值bit2满足,任务等待成功并退出 |
我们可以使用xEventGroupWaitBits()
来等待期望的事件发生,然后再使用xEventGroupClearBits()
来清除事件的标志位,但是这两个函数之间可能被其他任务抢占,这些任务可能修改事件组的值。
所以在使用xEventGroupWaitBits()
的时候,将xClearOnExit
设置成pdTRUE
,使得对事件组的检测、清零都在xEventGroupWaitBits()
函数内部完成,这是一个原子操作。
删除事件组:
void vEventGroupDelete( EventGroupHandle_t xEventGroup );
对于动态创建的事件组,不再需要它们时,可以删除它们以回收内存。
有些事情是需要多任务协同的,比如吃一顿饭:
A、B、C做好自己的事后,还要等别人做完,只有大家一起做完,才可开饭,在开饭的这一时刻,这三个任务的起点相同,同时开始执行,也就是位于一个同步点。
EventBits_t xEventGroupSync( EventGroupHandle_t xEventGroup,
const EventBits_t uxBitsToSet,
const EventBits_t uxBitsToWaitFor,
TickType_t xTicksToWait );
- xEventGroup:要操作的事件组句柄。
- uxBitsToSet:要设置哪些事件,调用该函数的任务完成哪些事件就设置哪几个比特位。
如任务A完成了炒菜就将bit0置一,表示炒菜事件完成。
- uxBitsToWaitFor:要等待的事件,也就是协同任务中要同步的事件。
如吃饭例子中,这里的值就是
炒作 | 买酒 | 摆台
,只有三个事件都发生时才实现同步,继续执行。
- xTicksToWait :如果期待的事件未发生,阻塞多久。可以设置为0:判断后即刻返回;可设置为
portMAX_DELAY
:一直等到成功才返回;
等待多个事件:
如上图代码所示,先创建一个事件组,用来通知事件是否发生,再创建一个队列,用来实现数据从生产者任务到消费者任务的传递。最后再创建三个任务,优先级都是1。
如上图所示,任务1进行加法运算,将运算结果放入到队列中,放入成功后再将事件组中的bit0
置一,表示加法运算事件完成。
任务2进行减法运算,将运算结果放入到队列中,放入成功后再将事件组中的bit1
置一,表示减法运算事件完成。
如上图所示,任务3负责检测加法事件和减法事件的发生,事件发生后检测函数退出前要将事件标志位清空,只有加法事件和减法事件同时发生,该检测函数才会退出,任务3才会退出阻塞状态,然后再从队列中读取加法事件和减法事件的结果,通过串口打印出来。
如上图所示,此时加法事件和减法事件的结果就打印出来了,由于三个任务在不断运行,所以不断打印结果。
如上图所示,将加法事件完成后给bit0置位的动作注释掉,此时事件组中就只有减法事件完成后置位的bit1,所以在检测事件的时候,无法检测到加法事件和减法事件同时发生,所以就会阻塞到任务3的xEventGroupWaitBits(xEventGroupCalc, (1<<0)|(1<<1), pdTRUE, pdTRUE, portMAX_DELAY);
处。
如上图所示,将任务3等待事件发生的条件改为多个事件中有一个发生即可,就是将蓝色框中的参数设为pdFALSE
,此时虽然加法事件完成以后没有将bit0
置一,但是减法任务完成后有将bit1
置一,等待函数成功返回。
加法任务虽然没有将事件标志位置位但是仍然是将结果写入到了队列中的,所以任务3也是能从队列中取出加法运算结果的。
同步点:
就拿前面吃一顿饭需要任务A,B,C来协同的例子来些代码。
如上图,首先是将三个事件要置位的比特位宏定义为标识符常量,然后创建一个事件组,再创建三个任务A,B,C,优先级分别是1,2,3,分别负责炒菜,买酒,摆台。
如上图所示三个任务的代码,每个任务都只负责自己的任务,自己的任务做完以后,调用xEventGroupSync
表示自己的任务完成,需要等待另完两人完成任务,如果人没齐则阻塞等待,如果人齐了则开饭。
如上图所示,三个任务中,炒菜任务的优先级最高,所以它最先运行,炒菜最先完成,但是此时另外两个事件还没有完成,所以炒菜任务只能阻塞等待。
买酒任务优先级高于摆台任务,所以买酒任务先运行,买酒先于摆台完成,但是此时还有一个摆台事件没有完成,所以买酒任务也只能阻塞等待。
最后摆台任务运行,摆台任务完成,另完两步也已经完成了,三步都完成就可以开饭了。
此时站在每个人的角度都知道可以开饭了,从优先级最高的炒菜任务开始挨个动筷子,这顿饭就吃起来了。
- 这个过程中,在开饭前每个任务所做的工作都不一样,但是通过事件组的同步功能,让这三个任务在开饭这一点上开始同步执行。
所谓"任务通知",你可以反过来读"通知任务",我们使用队列、信号量、事件组等等方法时,并不知道唤醒的对方是谁。
使用任务通知时,可以明确指定:通知哪个任务。
如上图,使用队列、信号量、事件组时,我们都要事先创建对应的结构体,双方通过中间的结构体通信。
如上图,但是使用任务通知时,被通知的任务结构体TCB中包含一个内部对象,可以直接接收别人发过来的"通知"。
任务通知的优势:
- 效率更高:使用任务通知来发送事件、数据给某个任务时,效率更高。比队列、信号量、事件组都有大的优势。
- 更节省内存:使用其他方法时都要先创建对应的结构体,使用任务通知时无需额外创建结构体。
任务通知的限制:
使用队列、信号量、事件组时,数据保存在这些结构体中,其他任务都可以访问这些数据。使用任务通知时,数据存放入目标任务的TCB结构体中,只有目标任务可以访问这些数据。
在日常使用中,这个限制影响不大。因为很多场合是从多个数据源把数据发给某个任务,而不是把一个数据源的数据发给多个任务。
使用队列时,假设队列长度为N,那么它可以保存N个数据。使用任务通知时,任务结构体中只能保存一个任务通知值,只能保持一个数据。
使用事件组可以同时给多个任务发送事件。使用任务通知,只能发给一个任务。
假设队列已经满了,使用xQueueSendToBack()
给队列发送数据时,任务可以进入阻塞状态等待发送完成。使用任务通知时,即使对方无法接收数据,发送方也无法阻塞等待,只能即刻返回错误。
如上图所示,每个任务都有一个结构体:TCB(Task Control Block),里面有2个成员:
ucNotifyState
,用来表示通知状态。ulNotifiedValue
,用来表示通知值。通知状态有三种取值:
取值 | 说明 |
---|---|
taskNOT_WAITING_NOTIFICATION | 任务没有在等待通知 |
taskWAITING_NOTIFICATION | 任务在等待通知 |
taskNOTIFICATION_RECEIVED | 任务接收到了通知,也被称为pending(有数据了,待处理) |
TCB中ucNotifyState
的初始默认值是taskNOT_WAITING_NOTIFICATION
,表示没有在等待任务通知。
通知值可以有多种类型:
任务通知,操作的核心就是TCB中的ucNotifyState
和ulNotifiedValue
这两个成员。
任务通知有2套函数,简化版、专业版:
发出通知:
/* 简化版 */
BaseType_t xTaskNotifyGive( TaskHandle_t xTaskToNotify );
- xTaskToNotify:要通知的任务句柄。
- 返回值:必定返回
pdPASS
。也就是通知成功。
该函数的作用:
ulNotifiedValue++
。ucNotifyState = taskNOTIFICATION_RECEIVED
,表示有数据了、待处理。/* 专业版 */
BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify,
uint32_t ulValue,
eNotifyAction eAction );
- xTaskToNotify:任务句柄(创建任务时得到),给哪个任务发通知。
- ulValue:怎么使用ulValue,由eAction参数决定
- eAction:使用通知值的方式,后面列表讲解。
- 返回值:pdPASS:成功,大部分调用都会成功。
eAction
参数说明:
eAction取值 | 说明 |
---|---|
eNoAction | 仅仅是更新通知状态为"pending",未使用ulValue。 这个选项相当于轻量级的、更高效的二进制信号量。 |
eSetBits | 通知值 = 原来的通知值 (或等) ulValue,按位或。 相当于轻量级的、更高效的事件组。 |
eIncrement | 通知值 = 原来的通知值 + 1,未使用ulValue。 相当于轻量级的、更高效的二进制信号量、计数型信号量。 相当于 xTaskNotifyGive() 函数。 |
eSetValueWithoutOverwrite | 不覆盖。 如果通知状态为"pending"(表示有数据未读),则此次调用 TaskNotif 不做任何事直接返回pdFAIL |
eSetValueWithOverwrite | 覆盖。 无论如何,不管通知状态是否为"pendng",通知值 = ulValue。 |
只有eAction = eSetValueWithoutOverwrite
时,调用xTaskNotify
才有可能返回pdFAIL
,其他情况都是返回pdPASS
。
等待通知:
/* 简化版 */
uint32_t ulTaskNotifyTake( BaseType_t xClearCountOnExit,
TickType_t xTicksToWait );
- xClearCountOnExit:函数返回前是否清零,
pdTRUE
:把通知值清零。pdFALSE
:如果通知值大于0,则把通知值减一。- xTicksToWait:任务进入阻塞态的超时时间,0表示不等待立刻返回,
portMAX_DELAY
表示一直等待。- 返回值:返回清零或者减一前的通知值
ulNotifiedValue
。
该函数的作用:
ulTaskNotifyTake
返回之前,还可以做些清理工作:把通知值减一,或者把通知值清零。/* 专业版 */
BaseType_t xTaskNotifyWait( uint32_t ulBitsToClearOnEntry,
uint32_t ulBitsToClearOnExit,
uint32_t *pulNotificationValue,
TickType_t xTicksToWait );
- ulBitsToClearOnEntry:在
xTaskNotifyWait
入口处,要清除通知值的哪些位?通知状态不是"pending"的情况下,才会清除。它的本意是:我想等待某些事件发生,所以先把"旧数据"的某些位清零。能清零的话:
通知值 = 通知值 & ~(ulBitsToClearOnEntry)
。比如传入0x01,表示清除通知值的bit0;传入0xffffffff即ULONG_MAX
,表示清除所有位,即把值设置为0。
- ulBitsToClearOnExit:在xTaskNotifyWait出口处,如果不是因为超时退出,而是因为得到了数据而退出时:
通知值 = 通知值 & ~(ulBitsToClearOnExit)
。在清除某些位之前,通知值先被赋给
pulNotificationValue
。比如入0x03,表示清除通知值的bit0、bit1;传入0xffffffff即ULONG_MAX
,表示清除所有位,即把值设置为0。
- pulNotificationValue:用来取出通知值。在函数退出时,使用
ulBitsToClearOnExit
清除之前,把通知值赋给pulNotificationValue
。如果不需要取出通知值,可以设为NULL。- xTicksToWait:任务进入阻塞态的超时时间。
- 返回值:
pdPASS
:成功,这表示xTaskNotifyWait
成功获得了通知;可能是调用函数之前,通知状态就是"pending";也可能是在阻塞期间,通知状态变为了"pending"。pdFAIL
:没有得到通知。
虽然函数很多,而且比较复杂,总得来说只有给出通知和等待通知两类函数,当某个或某些任务完成事件时,给指定任务发出通知,这是多对一的通知形式。等待通知时,只能等待自己TCB中的通知值。
发出通知的任务不会阻塞,都是立刻返回。如果是不覆盖的通知,通知失败会返回pdFAIL
,其他情况下返回的都是pdPASS
。
等待通知时可以设置等待方式,可以立刻返回,也可以设置超时时间,还可以设置成一直阻塞,直到通知到来的方式。
如上图,使用任务通知之前,需要先定义宏开关configUSE_TASK_NOTIFICATIONS
。
轻量级信号量:
任务通知值ulNotifiedValue
作为信号量,申请信号量时减减通知值,归还信号量时加加通知值。
如上图,创建两个任务,优先级都是1,一个任务用来增加通知值,另一个任务用来减小通知值。
如上图所示,任务1进行计算,计算完成后通知任务2,使用的是简化版的通知函数,仅给任务2的通知值加加,连续加十次。
任务2等待到通知以后,读取通知值,然后将通知值减一,并且打印出计算结果,以及减一之前的通知值读取次数。
如上图,可以看到,任务2每读取一次,通知值减一,直到减为0后,阻塞不动。
如上图,将ulTaskNotifyTake
中的第一个参数由pdFALSE
改为pdTRUE
,让任务2读取完一次通知值以后将通知值清零。
如上图所示,虽然任务2将通知值增加了十次,但是任务1取了一次以后就将通知值清零了,此时任务2就不会再次读取了,阻塞不动。
与信号量进行对比:
操作 | 信号量 | 轻量级信号量 |
---|---|---|
创建 | SemaphoreHandle_t xSemaphoreCreateCounting( UBaseType_t uxMaxCount, UBaseType_t uxInitialCount ); |
无 |
Give | xSemaphoreGive( SemaphoreHandle_t xSemaphor ); | BaseType_t xTaskNotifyGive( TaskHandle_t xTaskToNotify); |
Take | xSemaphoreTake( SemaphoreHandle_t xSemaphore, TickType_t xBlockTime); |
uint32_t ulTaskNotifyTake( BaseType_t xClearCountOnExit, TickType_t xTicksToWait); |
使用任务通知实现的轻量级信号量,使用起来更加简便,都不用创建信号量,直接操作即可。
轻量级队列:
使用通知值ulNotifiedValue
来传递数据,只能传送一个值,所以相当于长度为1的队列。
如上图,任务1计算完成后,将结果覆盖式的写入任务2TCB中的通知值中,xTaskNotify
的最后一个参数是eSetValueWithOverwrite
,表示覆盖式写入。每写入一次后将计算结果sum
再加加,一共写入十次。
任务2等待到任务1的通知后,读取自己CTB中的通知值,并且打印出来。
如上图所示,此时任务2只读取一次通知值,而且读取的是任务最后一次覆盖写入的值,所以说用任务通知来实现队列只能传递一个数据。
与队列的对比:
操作 | 队列 | 使用任务通知实现队列 |
---|---|---|
创建 | QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength, UBaseType_t uxItemSize); |
无 |
发送 | BaseType_t xQueueSend( QueueHandle_t xQueue, const void * pvItemToQueue, TickType_t xTicksToWait ); |
BaseType_t xTaskNotify( TaskHandle_t xTaskToNotify, uint32_t ulValue, eNotifyAction eAction ); |
接收 | BaseType_t xQueueReceive( QueueHandle_t xQueue, void * const pvBuffer, TickType_t xTicksToWait); |
BaseType_t xTaskNotifyWait( uint32_t ulBitsToClearOnEntry, uint32_t ulBitsToClearOnExit, uint32_t *pulNotificationValue, TickType_t xTicksToWait); |
使用任务通知实现的轻量级队列,虽然只能传递一个数据,但是在使用上也更加简洁,不用创建队列,操作函数也比较简便。
轻量级事件组:
操作的也是通知值ulNotifiedValue
,每个比特位都代表一个事件。
如上图所示,创建一个队列,用来存放事件值,创建互斥锁,让三个任务独立使用串口,再创建三个任务,优先级都是1,任务1进行加法运行,任务2进行减法运行,任务3检测这两个事件的完成情况。
如上图所示,任务1进行加法运算,将结果写入到队列中,然后将任务3中任务值的bit0置一,表示加法事件完成。任务2继续减法运算,将结果写入到队列中,然后将任务3中任务值的bit1置一,表示减法事件完成。
如上图所示,任务3等待通知值,等待成功后,判断bit0和bit1的情况,如果都为1则表示加法事件和减法事件都完成,否则就只完成一件。
如上图所示,可以看到,此时任务3的通知值中,bit0和bit1都是1,表示两个事件都完成,且打印出了它们的运算结果。
如上图,稍作修改,当任务2完成减法计算时,不会将任务3通知值的bit1置一,只是将计算结果写入到队列。
如上图所示,此时任务3仍然会等待成功,只是因为加法事件完成而等待成功。
- 轻量级事件组和事件组的区别就在于,它不能同时等待多个事件。只要有事件发生它就会等待成功。
与事件组的对比:
操作 | 事件组 | 使用任务通知实现事件组 |
---|---|---|
创建 | EventGroupHandle_t xEventGroupCreate( void ) | 无 |
设置事件 | EventBits_t xEventGroupSetBits( EventGroupHandle_t xEventGroup, const EventBits_t uxBitsToSet); |
BaseType_t xTaskNotify( TaskHandle_t xTaskToNotify, uint32_t ulValue, eNotifyAction eAction); |
等待事件 | EventBits_t xEventGroupWaitBits( EventGroupHandle_t xEventGroup, const EventBits_t uxBitsToWaitFor, const BaseType_t xClearOnExit, const BaseType_t xWaitForAllBits, TickType_t xTicksToWait ); |
BaseType_t xTaskNotifyWait( uint32_t ulBitsToClearOnEntry, uint32_t ulBitsToClearOnExit, uint32_t *pulNotificationValue, TickType_t xTicksToWait); |
使用任务通知实现的轻量级事件组,虽然无法实现同时等待多个事件,但是使用起来同样更简洁。
事件组虽然不能直接存放数据本身,但是它可以同时检测多个事件,同时等待多个事件的发生,而且可以做到让不同事件处于一个同步点。
任务通知是非常方便的一种任务间通信的方式,一般轻量级的通信都可以使用任务通知的方式,操作简单,函数简洁。