文章目录
- 摘要
-
- 1.3、同步互斥与通信
-
- 1.3.1、此同步非彼同步
- 1.3.2、互斥
- 1.3.3、队列
- 1.3.4、队列的应用
-
- 写入队列数据:
- 读队列:
- 互斥锁实现
- 1.3.5、队列集和邮箱
摘要
体会FreeRTOS中的开发思维与裸机开发思维的不一样之处。
1.3、同步互斥与通信
1.3.1、此同步非彼同步
在分析同步与互斥的时候,一定要区别这个同步和我们日常生活中的同步是有本质区别的。
首先是生活中的同步,我们认为是同时一起干一件事,强调并行或同时发生,而非任务间的条件依赖。
所以说我们在理解的时候,一定要避免认为字面的同步,和生活中的同步混淆。
在FreeRTOS中的同步,指任务间基于依赖关系的协调机制,确保任务按特定顺序执行或等待条件满足后才能继续运行。依赖性与等待机制:一个任务需等待另一任务完成操作(如数据准备、资源释放)后才能执行。
-
任务A需加载数据后,任务B才能处理数据。
-
生产者任务生成数据后,消费者任务才能消费。
互斥就是两者不能同时进行。
但是不管怎么实现,都需要注意CPU的资源消耗。
1.3.2、互斥
void TaskGenericFunction(void * param)
{
while (1)
{
if (!flagUARTused)
{
flagUARTused = 1;
printf("%s\\r\\n", (char *)param);
flagUARTused = 0;
}
}
}
xTaskCreate(TaskGenericFunction, "Task3", 100, "Task 3 is running", 1, NULL);
xTaskCreate(TaskGenericFunction, "Task4", 100, "Task 4 is running", 1, NULL);
在上述任务执行过程中,一直会执行任务4,任务3并没有被执行。
这是因为两个任务出现共享资源竞争,通过全局变量。但是实际情况是任务3一直没有被输出。
也就是说任务3在执行的时候,刚好是flagUARTused = 1;就导致无法运行,然后时间到了又切换成任务4中断的地方,然后又继续执行。
任务被抢占的时候,恢复的位置就是上次被抢占的地方。当任务被抢占后恢复时,系统会精确恢复到任务被抢占时的代码位置继续执行。
void TaskGenericFunction(void * param)
{
while (1)
{
if (!flagUARTused)
{
flagUARTused = 1;
printf("%s\\r\\n", (char *)param);
flagUARTused = 0;
vTaskDelay(1);
}
}
}
虽然增加了一个延时函数,相当于是阻塞了任务4,给任务3一个执行的空间,但是还是会有风险,
存在一种情况,当任务3执行到flagUARTused = 1;发生切换到任务4,然后任务4开始打印,但是任务4在打印过程中被切换至任务3,然后任务3继续开始打印,这样打印的顺序就会错乱,当然这种假设可能不会发生在这个函数,但是这个思想是在的。其实这个不是很理解是因为到现在还不知道任务执行的时间应该是多少,也就是任务执行切换时间分析。但是不管怎么样,肯定会存在这种现象,那么就要避免。也就是某些程序会偶然出现Bug,不是必现的Bug。
问题的原因是 判断和设置的时间太长了,导致可能任务没有执行完,就备切换出去,然后又是全局变量导致下一个任务也在使用这个变量,这个全局变量又不是专属的,程序还以为是现在这个任务的标志位,因此就出现了错误。既然知道了原因,那么就会有解决办法。
就是解决FreeRTOS中任务之间的同步和互斥。基于一下原则:
正确性
效率性:等待着要进入阻塞状态
多种解决方案
以上这几种思路都是实现,任务之间的通知。
**==后续逐一分析。
1.3.3、队列
对于队列的基础知识前面已经详细的分析以及队列的一些数学原理,也进行了拆解。
循环队列分析及应用-CSDN博客
嵌入式知识日常问题记录及用法总结(一)-CSDN博客
本文主要分析一些实际的应用,基础的知识不在分析。
队列就是先进先出,并且尾部数据就是写数据的,头部是读数据。
但是为了理解面向对象的思想以及后续理解在项目中如何实现或者说什么时候应该封装一些结构体,还是应该先体验思想,通过流程图的方式或者是思维导图实现。项目中能用到的最多的,并且什么时候该用或者用哪些,在专栏有相对应的文章。
嵌入式软件分层架构的设计原理与实践验证_慈悲不渡自绝人的博客-CSDN博客
这里需要注意的是在FreeRTOS中并没有创建之前使用的头索引和尾索引,而是使用的是指针,但是结果是没有影响的。
int8_t * pcHead; /*< Points to the beginning of the queue storage area. */
int8_t * pcWriteTo; /*< Points to the free next place in the storage area. */
这是因为使用指针是一样的实现。
int8_t buffer[QUEUE_SIZE]; // 存储区
pcHead = &buffer[0]; // 指向存储区开头
pcWriteTo = pcHead; // 初始写位置
*pcWriteTo = new_data; // 写入数据
pcWriteTo = (pcWriteTo + 1) % QUEUE_SIZE; // 循环移动[4,7](@ref)
data = *pcHead; // 读取数据
pcHead = (pcHead + 1) % QUEUE_SIZE; // 循环移动[3,4](@ref)
这里可能有一个疑问,为什么指针变量为什么可以直接求模运算?这个是因为:取模运算不是直接作用于物理地址,而是用于控制索引的循环逻辑。
指针移动的本质:偏移量计算,而非物理地址
在C/C++中,指针的加减操作本质是基于数据类型大小的偏移:
-
例如 int8_t *pcHead + 1 实际增加的是 sizeof(int8_t) = 1字节,指向下一个字节地址。
-
但环形队列中,我们更关心的是数组索引的循环,而非物理地址的连续性。
编译器执行的并不是我们看到的,而是两步操作,
1.计算偏移后的新地址:pcHead + 1 → 新地址 = 原地址 + 1字节。
2.转换为索引并取模:
-
隐含公式:新索引 = (当前索引 + 偏移量) % QUEUE_SIZE
-
最终指针 = 数组首地址 + 新索引 × 元素大小
取模作用的是索引值,而非物理地址本身。指针参与取模:实际是“地址 → 索引 → 取模 → 新地址”的隐式转换过程。
以 int 类型(4字节)的队列为例:
初始状态
-
基地址 base_addr = 0x1000,QUEUE_SIZE = 5(最多存5个 int 元素)。
-
pcWriteTo 指向 0x1000(索引 0)。
**执行 pcWriteTo + 1
-
物理地址变为 0x1004(偏移 sizeof(int)=4 字节)。
-
对应索引值:(0x1004 – 0x1000) / 4 = 1。
结果上是跟索引的结果是一样的。
BaseType_t xQueueSend(
QueueHandle_t xQueue,
const void *pvItemToQueue,
TickType_t xTicksToWait);
跟之前队列不一样的地方是,我们还引入了一个xTicksToWait表示队列满了以后,可以在在等待一会再写,如果不等待就是赋值为0,可以返回状态是满。
List_t xTasksWaitingToSend; /*< List of tasks that are blocked waiting to post onto this queue. Stored in priority order. */
List_t xTasksWaitingToReceive; /*< List of tasks that are blocked waiting to read from this queue. Stored in priority order. */
pcHead指向的位置一直是数组的首地址。
**==当多个任务在等待读取数据的时候,最先被唤醒的一定是优先级最高的,或者是同优先级下等待时间最长的。
1.3.4、队列的应用
存在一个任务1进行累加,然后完成对应累加以后产生一个标志位,任务2根据任务1产生的标志位进行相关逻辑。那么最理想的状态就是在任务1执行期间,那么任务2就是阻塞状态,这样就不会抢占CPU资源,然后就能很好的执行的累加运算,提升CPU的运算效率。实现这种高效率的一个办法就是使用队列,下面就详细分析如何使用队列。
任务1计算完成以后将这个标志位或者内容写入队列,
任务2就读取这个队列,有数据的时候就打印出来。如果没有数据就进行阻塞状态。
1、首先是创建队列
xQueueCalcHandle = xQueueCreate(2, sizeof(int));
写入队列数据:
xQueueSend(xQueueCalcHandle, &sum, portMAX_DELAY);
portMAX_DELAY表示:如果队列空则无法读出数据,可以让任务进入阻塞状态,xTicksToWait 表示阻塞的最大时间(Tick Count)。如果被设为 0,无法读出数据时函数会立刻返回;如果被设为 portMAX_DELAY,则会一直阻塞直到有数据可写。
读队列:
while (1)
{
//if (flagCalcEnd)
flagCalcEnd = 0;
xQueueReceive(xQueueCalcHandle, &val, portMAX_DELAY);
flagCalcEnd = 1;
printf("sum = %d\\r\\n", val);
}
这个里面有一个关键问题,就是使用队列以后任务调度的思路。 目前只是相当于是使用一个黑盒子,并不知道具体执行的细节。
while (1)
{
GetUARTLock();
printf("%s\\r\\n", (char *)param);
// task 3 is waiting
PutUARTLock(); /* task 3 ==> ready, task 4 is running */
//vTaskDelay(1);
}
如果不适用这个延时函数,虽然任务4在执行的时候会将这个队列数据进行释放或者怎么,但是任务4一直处于运行状态,而任务3只是出于就绪状态,任务3还是抢不过任务4,。所以还是需要主动的引入延时函数。
其实还是对这个任务调度不理解,因为按照正常思路,优先级是相同的,难道不是应该循环执行吗?
互斥锁实现
int InitUARTLock(void)
{
int val;
xQueueUARTcHandle = xQueueCreate(1, sizeof(int));
if(xQueueUARTcHandle == NULL){
printf("can not create queue\\r\\n");
return –1;
}
xQueueSend(xQueueUARTcHandle, &val, portMAX_DELAY);
return 0;
}
void GetUARTLock(void)
{
int val;
xQueueReceive(xQueueUARTcHandle, &val, portMAX_DELAY);
}
void PutUARTLock(void)
{
int val;
xQueueSend(xQueueUARTcHandle, &val, portMAX_DELAY);
}
void TaskGenericFunction(void * param)
{
while (1)
{
GetUARTLock();
printf("%s\\r\\n", (char *)param);
// task 3 is waiting
PutUARTLock(); /* task 3 ==> ready, task 4 is running */
vTaskDelay(1);
}
}
xTaskCreate(TaskGenericFunction, "Task3", 100, "Task 3 is running", 1, NULL);
xTaskCreate(TaskGenericFunction, "Task4", 100, "Task 4 is running", 1, NULL);
根据上述使用队列,实现了互斥锁。具体怎么理解?
首先是初始化,我们创建一个长度为1的队列,初始化的时候我们放入任意一个整数val作为令牌,此时队列是满状态,表示锁可以用。注意我们在创建的时候引入了参数portMAX_DELAY,这个参数是实现互斥锁的核心参数,是FreeRTOS中实现任务无限阻塞的核心机制。使用在读队列和写队列函数:
xQueueSend();
BaseType_t xQueueSend(
QueueHandle_t xQueue,
const void *pvItemToQueue,
TickType_t xTicksToWait);
xQueueReceive();
BaseType_t xQueueReceive(
QueueHandle_t xQueue,
void * const pvBuffer,
TickType_t xTicksToWait );
xTicksToWait参数:
如果被设为 0,无法写入数据时函数会立刻返回;如果被设为 portMAX_DELAY,则会一直阻塞直到有空间可写。
如果被设为 0,无法读出数据时函数会立刻返回;如果被设为 portMAX_DELAY,则会一直阻塞直到有数据可读。
所以说,根据这个队列读和写的互斥锁,同时配合vTaskDelay(1);,就实现了任务是否阻塞。
具体来说,
void TaskGenericFunction(void * param)
{
while (1)
{
GetUARTLock();
printf("%s\\r\\n", (char *)param);
// task 3 is waiting
PutUARTLock(); /* task 3 ==> ready, task 4 is running */
vTaskDelay(1);
}
}
由于初始化我们已经写入了数据次数队列数据是满的,那么在执行任务4的时候先获取这个数据,那么此时队列是空了,就表示被占用,相当于是单独获取串口的状态。如果这个时候任务3执行了,那么看到队列是空的,就会被变成阻塞状态。接着任务4执行,然后主动进行阻塞,释放CPU,并且还需要重新填写队列,使队列是满状态,这样任务3过来的时候,就会执行。如果我们不填充队列,说明队列的数据一直空的,那么任务3和4都是处于阻塞状态,因为队列是空的,我们又设置了参数portMAX_DELAY,则会一直阻塞直到有数据可读。还是那句话,我们不知道RTOS的核心调度,使得我们在理解的时候总是差一点。目前只能从表面关系进行理解。先学会如何调用API吧。核心就是队列的满与空,如何和任务的执行挂钩? 并没有知其所以然。
阻塞唤醒机制
-
无令牌时主动阻塞: 当任务调用 GetUARTLock 时,若队列为空(锁被占用),任务会挂起并移入等待接收链表(xTasksWaitingToReceive),不占用CPU时间。
-
释放锁时唤醒任务: PutUARTLock 中的 xQueueSend 会检查等待接收链表,若有任务阻塞,则唤醒其中优先级最高的任务,使其获得令牌并继续执行。
令牌内容无关紧要
代码中的 int val 仅占位,实际值无意义,队列状态(空/满)才是锁状态的标志。可将数据类型简化为单字节(如 char)以减少内存占用。
1.3.5、队列集和邮箱
存在一个系统有以下几个输入:触摸、按键、鼠标、红外,但是也不拘泥于这几个输入,都可以唤醒某个任务,并且每一个输入都是一个队列,这样就构成了一个队列集。
Task1和Task2写入数据,Task3去检测数据。 注意检测数据的时候就先不用考虑最底层的调度,就按照函数的说明进行使用,就当现在使用的是一个黑盒子,反正后续还需要详细理解源码以及底层原理,因为如果不理解底层原理,个人是很难受的,总感觉少一点东西,不知其所以然。
/* 1. 创建2个queue */
只需要按照库函数的方法进行使用Queue创建对应 的队列。
xQueueHandle1 = xQueueCreate(2, sizeof(int));
if (xQueueHandle1 == NULL)
{
printf("can not create queue\\r\\n");
}
xQueueHandle2 = xQueueCreate(2, sizeof(int));
if (xQueueHandle2 == NULL)
{
printf("can not create queue\\r\\n");
}
/* 2. 创建queue set */
队列集的长度就是队列1和队列2的长度之和,所以队列集的长度就是4。
xQueueSet = xQueueCreateSet(4);
/* 3. 把2个queue添加进queue set */
直接调用库函数将我们创建的队列放入队列集。
xQueueAddToSet(xQueueHandle1, xQueueSet);
xQueueAddToSet(xQueueHandle2, xQueueSet);
/* 4. 创建3个任务 */
xTaskCreate(Task1Function, "Task1", 100, NULL, 1, &xHandleTask1);
xTaskCreate(Task2Function, "Task2", 100, NULL, 1, NULL);
xTaskCreate(Task3Function, "Task3", 100, NULL, 1, NULL);
总体来说就是使用库函数进行使用,熟悉相关的库函数使用。
void Task1Function(void * param)
{
int i = 0;
while (1)
{
xQueueSend(xQueueHandle1, &i, portMAX_DELAY);
i++;
vTaskDelay(10);
}
}
void Task2Function(void * param)
{
int i = –1;
while (1)
{
xQueueSend(xQueueHandle2, &i, portMAX_DELAY);
i—;
vTaskDelay(20);
}
}
通过这里可以明显看出,我们其实在RTOS开发中,一定是要熟悉这些API的使用,暂时先不需要理解具体的实现,一定是先知道每个库函数的作用是什么。上面这两个任务函数已经说明了这一切,只需要规定好怎么写数据,然后直接使用库函数写入数据,然后怎么调度是需要告诉内核就行,至于具体的微观层面的内容,暂时先不考虑,之所以一直的重复的说明,就是为了给自己增加这种开发印象,开发思维,要接受RTOS这种开发思维,先不要想这微观层面的实现,先学会用,就像当初学习深度学习一样,先不用管具体怎么实现,一定要有这种思维,这是最关键的。还有就是我们在写的时候都会主动的让任务进行一定程度的阻塞,目的个人就先认为是释放出CPU。至于说这个步骤是不是约定俗称的,后续在深究。
任务3:
void Task3Function(void * param)
{
QueueSetMemberHandle_t handle;
int i;
while (1)
{
/* 1. read queue set: which queue has data */
handle = xQueueSelectFromSet(xQueueSet, portMAX_DELAY);
/* 2. read queue */
xQueueReceive(handle, &i, 0);
/* 3. print */
printf("get data : %d\\r\\n", i);
}
}
任务3的含义是,检测我们的队列集,查看哪个队列有新数据,然后返回一个handle,这个handle就是我们的队列集中的一个队列。
具体还是那句话,使用库函数,我们只需要将这个队列数据集传入这个函数,然后传入核心参数portMAX_DELAY,一直等待。如果没有就相当于是阻塞?
然后读取这个队列数据,并未直接不需要等待,直接读这个数据,被设为 0,无法读出数据时函数会立刻返回; 这是因为我们上面已经有了一层前提,只有有队列有新数据才能返回相应队列的Handle,不然都不会返回,甚至都是挂起状态。
任务3的目的就是等待队列数据的写入,然后输出。还有一个问题就是,如果读完了,任务3就会主动进入到阻塞状态。
阻塞的核心机制: 当任务调用 xQueueSelectFromSet(xQueueSet, portMAX_DELAY) 时,如果队列集 xQueueSet 中所有成员(队列或信号量)均无可用数据或事件,任务会立即进入阻塞状态(Blocked State)。这是由 portMAX_DELAY 参数决定的,表示无限期等待直到事件发生。
阻塞时的任务行为:
- 调度器行为:任务被移出就绪列表(Ready List),不再参与调度。
- 唤醒条件:队列集中任意一个成员收到数据(如 xQueueSend 被调用)或信号量被释放(如 xSemaphoreGive 被调用)。
- 资源占用:阻塞期间任务不消耗 CPU 时间片,系统自动切换至其他就绪任务运行。
但是可能会出现事件遗漏风险,这是后期需要考虑的,当前就是理解这个任务的执行思维。
此设计完全依赖事件驱动,通过阻塞机制高效协调多源数据监听,避免 CPU 空转浪费资源。
还有就是,个人感觉在FreeRTOS中的函数执行似乎并不是裸机中的顺序执行?或者说轮询?
有点交叉的味道。这也是FreeRTOS中的抢占调度的原因吧。就是虽然知道是这样,但是在分析执行的时候还是有一点混乱,只能说还需要加强锻炼。
此外还需要说明的是:
FreeRTOS 中的任务执行机制与裸机(无操作系统)的顺序执行或轮询有本质区别。其核心在于 多任务并发调度,通过抢占式调度器和时间片轮转实现任务的动态切换,而非裸机的线性轮询。 这个地方在实际分析中还是有点绕的,还是应该掌握一套具体的分析方法,这是需要加强的地方。
并且在使用过程中可能还需要进行一些相关配置。 也就是打开FreeRTOS中一些功能的开关。就理解成一个电脑,我们有时候是不需要一些功能的,所以就没有打开,而是用FreeRTOS就是在使用一个系统,思维是一样的。
如果觉得我的内容对您有帮助,希望不要吝啬您的赞和关注,您的赞和关注是我更新优质内容的最大动力。
专栏介绍
《嵌入式通信协议解析专栏》 《PID算法专栏》 《C语言指针专栏》 《单片机嵌入式软件相关知识》 《FreeRTOS源码理解专栏》 《嵌入式软件分层架构的设计原理与实践验证》
文章源码获取方式: 如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】 本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件: 署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。 相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言,笔者一定知无不言,言无不尽。
评论前必须登录!
注册