🐱作者:一只大喵咪1201
🐱专栏:《RTOS学习》
🔥格言:你只管努力,剩下的交给时间!
同步与互斥 | 队列
🍉同步与互斥
FreeRTOS是一个实时操作系统,是一个多任务系统,任务之间存在同步 关系,如协调工作进度(同步),还有互斥关系,就像争用会议室(互斥)。
到底什么是同步什么是互斥呢?拿一个生活中上厕所的例子来说:"等我用完厕所,你再用厕所"。
- 同步:我正在用厕所,你得等会儿再用。
- 互斥:我正在用厕所,你不能进来。
只看我和你,必须得等我用完厕所你才能用,我和你之间有必然的先后顺序,存在协同关系 ,这就是同步。
只看厕所,同一时刻只能进去一个人,我和你之间就是竞争关系,这就是互斥。
🍦同步
举一个同步关系的代码例子:有两个任务,任务1进行计算,任务2打印计算结果,但是必须等任务1计算完成才能打印,这是同步关系。
第一种方式:
如上图,创建两个任务,优先级都是1,在任务1中进行5000000次加法运算,完成后将完成标志位flagCalcEnd
置一,并且自杀(为了方便实验)。
在任务2中,不断检测计算完成标志位flagCalcEnd
,当变成1以后,打印五百万次求和的结果。
如上图结果所示,在耗时3秒钟左右时,计算完成标志位从0变成了1,说明此时计算完成,而且通过串口打印出了运算结果。
在这个计算过程中,任务1和任务2按照时间片轮转的方式在执行,任务1在进行计算,任务2只是判断一下falgCalcEnd
是否为1,但是它执行的时间和任务1是一样的,都是一个时间片。
- 此时计算完成耗时3秒左右。
第二种方式:
如上图,先只创建任务1,然后进行计算,当计算完成以后,在任务1中创建任务2用来打印计算结果,并且将flagCalcEnd
标志位置一。
如上图,这次运行结果和前面相比,其他都相同,只有耗费时间不同,变成了1.5秒左右,缩短了一倍。
- 此时计算完成耗时1.5秒左右。
虽然上面两种方式都能实现同步的目的,但是显然第二种方式效率更高,也就是在任务1进行计算的时候,任务2应该处于阻塞状态,不应该和任务1竞争CPU资源。
当任务1计算完成以后,唤醒任务2来打印结果即可,这样的同步关系才是高效合理的。
但是第二种方式在代码结构上就让任务1和任务2在串行执行,而在FreeRTOS中,多任务之间应该"并行"执行,所以还是要用第一种方式,但是要让任务2在任务1计算期间处于阻塞状态,把CPU资源完全让出来。
该用什么样的方式实现同步呢?后面本喵详细讲解。
🍦互斥
再举一个互斥关系的例子:两个任务使用一个串口打印信息,这两个串口就是互斥关系。
如上图,创建一个函数TaskGenericFunction
,里面使用串口打印启动任务时传入的参数,创建两个新任务都调用这一个函数,任务的优先级都是1,两个任务打印各自的运行信息。
如上图,此时看到的结果中,打印出来的数据非常混乱,一句话中既有属于任务3打印的内容,也有任务4打印的内容,这是因为两个任务没有互斥的使用串口。
如上图,增加一个串口使用标志flagUARTused
,只有标志位是0的时候,任务才能去使用串口,在使用之前将标志位置一,表示有任务在使用,另一个任务就无法使用了。
使用完毕以后,再将标志位清0,另一个任务才可以使用,此时就实现了两个任务之间的互斥。
如上图,此时任务3和任务4打印出的信息就是独立的,没有混杂在一起,主要就是因为两个任务之间实现了互斥。
- 每个任务使用完串口以后,主动延时一个
Tcik
,这是为了削弱当前任务的竞争力。
如果不延时的话,当前任务使用完串口,将标志位置0后,它仍然在占用CPU资源,另一个任务还没有被调度,当前任务就再次将标志位置一使用串口了,这样就会导致任务4一直在使用串口,有兴趣的小伙伴自己实验一下,本喵就不演示了。
上面的方式真的实现互斥了吗?
如上图,考虑一个极端一点的情况,任务3完成if判断以后,但是还没有将标志位置一,如上图红色线条所在位置,此时任务3被切走了。
CPU开始执行任务4了,由于此时标志位并没有被置1,所以任务4也可以使用串口,此时任务3和任务4的互斥关系就不存在了,它两都可以使用串口。
- 这样的方式运行时间长了以后,上面的这种情况就会成为必然。
除此之外,一个任务在使用串口的时候,另一个任务也会被调度,但是它只是检测一下标志位是否为0,和同步例子中的问题一样,也会浪费CPU资源。
所以在一个任务使用串口的时候,另一个任务也应该处于阻塞状态,当前任务使用完毕以后,唤醒另一个任务。
到底该用什么样的方式实现互斥呢?后面本喵详细讲解。
同步与互斥经常放在一起讲,是因为它们之间的关系很大,"互斥"操作可以使用"同步"来实现,我"等"你用完厕所,我再用厕所。
串口例子中,一个任务在使用串口,另一个任务必须等当前任务使用完毕后才能使用。
- 这就是用"同步"来实现"互斥"。
🍉队列
FreeRTOS有一套实现同步与互斥的方案,同时也解决了前面本喵所说的问题,第一种方案就是使用队列结构:
如上图所示队列模型,这是一个先进先出的结构,左边的任务A和任务B是生产者,也可以是更多任务,生产者负责生成数据。
右边的任务C和任务D是消费者,也可以是更多任务,消费者负责消费(读取)队列中的数据。
- 只要队列中有空位,生产者就可以生成数据。
- 只要队列中有数据,消费者就可以消费数据。
- 生产任务和消费任务访问队列时,所有任务之间都是互斥的,只能有一个任务访问队列。
队列是如何实现同步与互斥的呢?接下来就来看一下它的结构:
如上图代码所示,在queue.c
中定义了一个队列结构体,类型重命名为xQUEUE
,它有一个环形缓冲区,还有两个链表,一个存放生产者任务的TCB节点,一个存放消费者任务的TCB节点,还有队列长度,以及队列中每个数据的大小等属性。
🧊环形缓冲区
写:
如上图所示,队列中的环形缓冲区是通过指针pcHead
和pcWriteTo
来维护的,所以这两个指针就代表着环形缓冲区。
当队列刚创建的时候,环形缓冲区是空的,假设创建的队列长度是N
,队列中每个元素的大小是sizeof(Item)
,头指针pcHead
和写指针pcWiteTo
指向环形队列的头部。
头指针pcHead
永远指向头部不发生变化,当有任务要向环形队列中写入数据时,写入pcWriteTo
指针指向的位置,然后pcWriteTo
指针向后移动,指向下一个要写入的位置。
当向队列中写入第N个数据时,pcWriteTo
指针就会通过取模运算重新指向pcHead
指向的队列头部,向该位置写入数据。
读:
如上图所示,读取队列时,通过读指针pcReadFrom
来控制读取的位置,但是从队列的结构体中并没有看到读指针pcReadFrom
,因为它在联合体union u
中:
如上图,在队列中的联合体成员中,QueuePointers_t
成员中存在读指针pcReadFrom
成员变量。
和pcWriteTo
不同的是,读指针pcReadFrom
的起始位置在下标为N-1
处,读指针pcReadFrom
指向的是上一次读取数据的位置。
有任务来读取数据时,pcReadFrom
先向后移动,如果移动前指向最后一个位置,那么同样通过取模运行指向环形队列头部,然后将该位置的数据取出。
有了读指针和写指针后,就可以判断环形缓冲区是否满了,在写数据时,当pcWriteTo == pcReadFrom
的时候,就说明队列满了,不能继续写数据了。
在读数据时,当pcReadFrom == pcWriteTo
的时候,说明队列空了,无法继续读取数据了。
🧊读写任务链表
队列有满或者空的情况,此时FreeRTOS
是怎么处理生产者任务或者消费者任务的呢?
如上图,在队列结构体中有两个链表,分别是写任务链表xTaskWaitingToSend
和读任务链表xTaskWaitingToReceive
,这是两个阻塞链表,管理处于阻塞状态的读写任务。
当生产者任务向队列中写数据时发现pcWriteTo==pcReadFrom
,FreeRTOS
就将这个生产者任务设置成阻塞状态并且放入xTaskWaitingToSend
链表中,当队列中有空闲位置了,就从xTaskWaitingToSend
中唤醒一个任务向队列中写数据
当消费者任务从队列中读取数据时发现pcReadFrom==pcWriteTo
,FreeRTOS
就将这个消费者任务设置成阻塞状态并放入xTaskWaitingToReceive
阻塞链表中,当队列中有数据了,就从xTaskWaitingToReceive
唤醒一个任务从队列中读数据。
此时就做到了生产者任务和消费者任务之间的同步。
如上图,生产者任务向队列中写数据时,需要调用xQueueSend
函数,该函数内部会先将所有中断都关闭,此时Tcik
就无法产生中断,也就不会调度其他任务,这段时间CPU就只能执行这个任务,数据写入完毕后,再打开所有中断,恢复Tick
对其他任务的调度。
消费者任务从队列中读取任务时也一样,也会先关闭所有中断禁止调度其他任务,数据读取完毕后再打开中断恢复调度,本喵这里就不给大家看源码了。
此时就做到了生产者任务和消费者任务之间的互斥,同一时刻,只能有一个任务来访问队列。
既然读取和写入数据的任务个数没有限制,那么当多个任务读取空队列或者多个任务向满队列中写入数据时,这些任务都会进入阻塞状态,此时就有多个任务在同一个链表中。
当队列中有数据时或者有空位时,哪个任务会进入就绪态状态?
- 优先级最高的任务
- 如果大家优先级相同,那么等待时间最久的任务会进入就绪状态。
🧊操作队列的函数
使用队列的流程:创建队列、写队列、读队列、删除队列。
创建:
- 动态分配内存:队列的内存在函数内部使用
malloc
动态分配。
cpp
QueueHandle_t xQueueCreate(UBaseType_t uxQueueLength, UBaseType_t uxItemSize );
- uxQueueLength:队列长度,最多能存放多少个数据(Item)。
- uxItemSize:每个数据(Item)的大小,以字节为单位。
- 返回值:成功返回队列句柄,失败返回NULL,一般都是因为内存不足。
- 静态分配内存:队列的内存要用户事先分配好。
cpp
QueueHandle_t xQueueCreateStatic(
UBaseType_t uxQueueLength,
UBaseType_t uxItemSize,
uint8_t *pucQueueStorageBuffer,
StaticQueue_t *pxQueueBuffer
);
- uxQueueLength:队列长度,最多能存放多少个数据(Item)。
- uxItemSize:每个数据(Item)的大小,以字节为单位。
- pucQueueStorageBuffer:如果uxItemSize非0,pucQueueStorageBuffer必须指向一个uint8_t数组,此数组大小至少为
uxQueueLength * uxItemSize
。- pxQueueBuffer:必须创建一个
StaticQueue_t
结构体,用来保存队列的数据结构。- 返回值:成功返回队列句柄,失败返回NULL,一般都是因为内存不足。
写队列:
cpp
/* 向队列尾部写入数据 */
BaseType_t xQueueSend(
QueueHandle_t xQueue,
const void *pvItemToQueue,
TickType_t xTicksToWait
);
/* 向队列尾部写入数据 */
BaseType_t xQueueSendToBack(
QueueHandle_t xQueue,
const void *pvItemToQueue,
TickType_t xTicksToWait
);
虽然这两个函数名称不一样,但是作用是一样的,都是向队列尾部写入数据。
cpp
/* 往队列头部写入数据 */
BaseType_t xQueueSendToFront(
QueueHandle_t xQueue,
const void *pvItemToQueue,
TickType_t xTicksToWait
);
这些函数用到的参数都是相同的:
- xQueue:队列句柄,要写哪个队列。
- pvItemToQueue:数据指针,这个数据会被拷贝到队列中。
- xTicksToWait:如果队列满则无法写入新数据,可以让任务进入阻塞状态,
xTicksToWait
表示阻塞的最大时间(Tick Count)。如果被设为0,无法写入数据时函数会立刻返回;如果被设为portMAX_DELAY
,则会一直阻塞直到有空间可写。- 返回值:
pdPASS
:从队列读出数据入,errQUEUE_EMPTY
:读取失败,因为队列空了。
- 写数据时,是将用户传入指针所指数据拷贝到队列中,所以必须传指针。
读队列:
cpp
BaseType_t xQueueReceive( QueueHandle_t xQueue,
void * const pvBuffer,
TickType_t xTicksToWait );
- xQueue:队列句柄,要读哪个队列。
- pvBuffer:bufer指针,队列的数据会被复制到这个buffer。
- xTicksToWait :等待时间,和写队列时一样。
- 返回值:
pdPASS
:从队列读出数据入,errQUEUE_EMPTY
:读取失败,因为队列空了。
删除:
cpp
void vQueueDelete( QueueHandle_t xQueue );
只能删除使用动态方法创建的队列,它会释放内存。
复位:
cpp
BaseType_t xQueueReset( QueueHandle_t pxQueue);
队列刚被创建时,里面没有数据;使用过程中可以调用该函数把队列恢复为初始状态,此函的返回值是pdPASS
,必定复位成功。
查询:
cpp
/* 返回队列中可用数据的个数 */
UBaseType_t uxQueueMessagesWaiting( const QueueHandle_t xQueue );
/* 返回队列中可用空间的个数 */
UBaseType_t uxQueueSpacesAvailable( const QueueHandle_t xQueue );
覆盖:
cpp
BaseType_t xQueueOverwrite(
QueueHandle_t xQueue,
const void * pvItemToQueue
);
当队列长度为1时,可以使用该函数来覆盖数据。注意,队列长度必须为1。当队列满时,该函数会覆盖里面的数据,这也意味着该函数不会被阻塞。
偷看:
cpp
BaseType_t xQueuePeek(
QueueHandle_t xQueue,
void * const pvBuffer,
TickType_t xTicksToWait
);
如果想让队列中的数据供多方读取,也就是说读取时不要移除数据,要留给后来人。那么可以使用该函数来"窥视",该函数会从队列中复制出数据,但是不移除数据。这也意味着,如果队列中没有数据,那么"偷看"时会导致阻塞;一旦队列中有数据,以后每次"偷看"都会成功。
后面几个函数的参数和前面几个一样,本喵没有再介绍,照猫画虎即可。
🧊使用队列
基本使用:
现在来解决开篇时同步例子中的缺陷:
如上图,创建一个队列,使用一个全局队列句柄xQueueCalcHandle
来接收返回值,再创建两个任务,优先级都是1。
如上图,任务1进行五百万次运算,计算完成后将结果放入队列中,任务2从队列中读取任务1的计算结果。
如上图,可以看到,整个计算过程花费1.5秒左右,和我们前面串行方式耗时一样,但是此时是同时创建的两个任务,两个任务在并行执行。
- 读取数据的任务,在队列中没有数据时处于阻塞状态,不会竞争CPU资源,当队列中有数据时才会唤醒它。
分辨数据源:
当有多个发送任务,通过同一个队列发出数据,接收任务如何分辨数据来源?数据本身带有"来源"信息,比如写入队列的数据是一个结构体,结构体中的IDataSouceID
用来表示数据来源:
cpp
typedef struct {
ID_t eDataID;
int32_t lDataValue;
}Data_t;
不同的发送任务,先构造好结构体,填入自己的 eDataID
,再写队列;接收任务读出数据后,根据eDataID
就可以知道数据来源了,如下图所示:
- CAN任务发送的数据:eDataID=eMotorSpeed
- HMI任务发送的数据:eDataID=eSpeedSetPoint
如上图,CAN任务和HMI任务将结构体数据写入到队列中后,Controller任务在读取到数据时,就可以通过结构体中的eDataID
成员知道是哪个任务发送来的数据。
如上图,枚举数据来源,定义传输数据的结构体,将两个发送任务要发送的数据放在一个数组中并初始化。
如上图,创建俩个任务用来发送数据,分别是CAN Task
和HMI Task
,优先级都是2,再创建一个接收数据的任务ReceiverTask
,优先级是1,只有两个发送任务将队列写满阻塞后,接收任务才能从队列中读取任务。
如上图所示,两个发送任务分别发送自己的结构体数据到队列中,接收任务从队列中读取结构体数据,通过eDataID
成员确定数据源并且打印数据。
如上图运行结果所示,两个发送任务的优先级都是2,所以它两先执行,又因为HMI
任务是后创建的,所以先运行,瞬间就将队列写满了,这个过程中CAN
任务还没有来得及被调度就因为队列满而被阻塞了。
接收任务开始读取后,先读取的是队列中的HMI
任务的数据,此时队列出现空位,所以唤醒了CAN
任务写队列,该数据排在最后,有空位后再唤醒HMI
任务,如此往复。
所以读取的前五个都是HMI
任务写的数据,之后就是CAN
任务和HMI
任务写的数据交替出现。
传输大块数据:
FreeRTOS的队列使用拷贝传输,也就是要传输uint32_t时,把4字节的数据拷贝进队列;要传输一个8字节的结构体时,把8字节的数据拷贝进队列。
如果要传输1000字节的结构体呢?写队列时拷贝1000字节,读队列时再拷贝1000字节?先不说浪不浪费内存,效率必然会很低!
这时候,我们要传输的是这个巨大结构体的地址或者是一个字符串的地址,把它的地址写入队列,对方从队列得到这个地址,使用地址去访问那1000字节的数据。
使用地址来间接传输数据时,这些数据放在RAM里,对于这块RAM,要保证这几点:
- RAM的所有者、操作者,必须清晰明了。
-
- 这块内存,就被称为"共享内存"。要确保不能同时修改RAM。比如,在写队列之前只能由发送者修改这块RAM,在读队列之后只能由接收者访问这块RAM。
- RAM要保持可用
-
- 这块RAM应该是全局变量,或者是动态分配的内存。对于动然分配的内存,要确保它不能提前释放:要等到接收者用完后再释放。另外,不能是局部变量。
如上图代码,创建一个队列,长度为1,元素大小是一个char*
类型指针变量,再创建一个写队列任务,优先级是1,再创建一个读队列任务,优先级是2。
如上图,发送任务中,将指向字符串指针的地址(二级指针)写入到队列中,读取任务中,从队列的二级指针中将字符串地址拷贝到buffer
中。
- 队列中存放的并不是整个字符串,而是指向字符串的指针。
如上图,读取任务成功从队列中拿到了字符串所在的地址,通过该指针找到了字符串并且打印出来。
这个程序故意设置接收任务的优先级更高,在它访问数组的过程中,发送任务无法执行、无法写这个数组,从而保证这个数组中数据的安全,使得接收任务读取到的肯定是正确的值,不会发生"读取到一半被切换下去,让写队列任务向队列中写数据"的情况。
邮箱:
FreeRTOS的邮箱概念跟别的RTOS不一样,这里的邮箱称为"橱窗"也许更恰当:
- 它是一个队列,队列长度只有1。
- 写邮箱:新数据覆盖旧数据,在任务中使用
xQueueOverwrite()
,既然是覆盖,那么无论邮箱中是否有数据,该函数总能成功写入数据。 - 读邮箱:读数据时,数据不会被移除;在任务中使用
xQueuePeek()
,这意味着,第一次调用时会因为无数据而阻塞,一旦曾经写入数据,以后读邮箱时总能成功。
如上图代码,main函数就不贴图了,创建了写队列任务,优先级是2,创建了读队列任务,优先级是1。在写队列任务中,先延时5个Tick
,然后再覆盖式的向队列中写入数据。
在读队列任务中,使用xQueuePeek
窥视队列中的数据,不延时,偷看成功则打印数据,不成功则打印不能接收数据的字符串。
如上图所示,写队列任务先开始执行,一上来就延时进入阻塞状态,此时队列中还没有数据,所以此时正在运行的读队列任务无法从队列中读取数据,所以打印无法接收数据的字符串。
5个Tick
之后,写队列任务被唤醒后抢占读队列任务,向队列中写入数据,然后再次进入延时,此时队列中已经有数据了,所以读取队列的任务可以从队列中偷看到数据,由于写数据任务处于阻塞状态,所以在一段时间内都没有覆盖数据,所以读到的数据是相同的。
5个Tick
之后,写队列任务又被唤醒,用新数据覆盖队列,然后再进入延时阻塞状态,读队列任务读取数据,如此反复。
- 除了第一次,读队列任务每次都能偷看到数据,无论是否写入新的数据。
🍉总结
要理解同步和互斥的概念,认识到同步和互斥是相互依赖的。要明白队列是如果实现同步和互斥的,大概清楚队列的运行机制。除此之外还要掌握不同情况下队列的使用。