【RTOS学习】同步与互斥 | 队列

🐱作者:一只大喵咪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节点,还有队列长度,以及队列中每个数据的大小等属性。

🧊环形缓冲区

写:

如上图所示,队列中的环形缓冲区是通过指针pcHeadpcWriteTo来维护的,所以这两个指针就代表着环形缓冲区。

当队列刚创建的时候,环形缓冲区是空的,假设创建的队列长度是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==pcReadFromFreeRTOS就将这个生产者任务设置成阻塞状态并且放入xTaskWaitingToSend链表中,当队列中有空闲位置了,就从xTaskWaitingToSend中唤醒一个任务向队列中写数据

当消费者任务从队列中读取数据时发现pcReadFrom==pcWriteToFreeRTOS就将这个消费者任务设置成阻塞状态并放入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 TaskHMI 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之后,写队列任务又被唤醒,用新数据覆盖队列,然后再进入延时阻塞状态,读队列任务读取数据,如此反复。

  • 除了第一次,读队列任务每次都能偷看到数据,无论是否写入新的数据。

🍉总结

要理解同步和互斥的概念,认识到同步和互斥是相互依赖的。要明白队列是如果实现同步和互斥的,大概清楚队列的运行机制。除此之外还要掌握不同情况下队列的使用。

相关推荐
iiiiiankor1 小时前
C/C++内存管理 | new的机制 | 重载自己的operator new
java·c语言·c++
小辛学西嘎嘎1 小时前
C/C++精品项目之图床共享云存储(3):网络缓冲区类和main
c语言·开发语言·c++
无敌最俊朗@1 小时前
stm32学习之路——八种GPIO口工作模式
c语言·stm32·单片机·学习
EterNity_TiMe_2 小时前
【论文复现】STM32设计的物联网智能鱼缸
stm32·单片机·嵌入式硬件·物联网·学习·性能优化
L_cl2 小时前
Python学习从0到1 day28 Python 高阶技巧 ⑤ 多线程
学习
前端SkyRain2 小时前
后端Node学习项目-用户管理-增删改查
后端·学习·node.js
提笔惊蚂蚁2 小时前
结构化(经典)软件开发方法: 需求分析阶段+设计阶段
后端·学习·需求分析
DDDiccc2 小时前
JAVA学习日记(十五) 数据结构
数据结构·学习
2301_799084672 小时前
超全排序C语言实现
c语言·数据结构·算法·排序算法
腾科张老师4 小时前
为什么要使用Ansible实现Linux管理自动化?
linux·网络·学习·自动化·ansible