【FreeRTOS互斥量】
在多任务系统中,如果任务开始访问某个资源,但在完成访问之前被切换出运行状态,那么就有可能发生错误。如果任务使资源处于不一致的状态,那么其他任务或中断对同一资源的访问可能会导致数据损坏或其他类似问题。访问外设为例两个任务尝试向液晶显示器(LCD)写入内容。任务A执行,开始将字符串“Hello world”写入到LCD。任务A在输出字符串的开头部分“Hello w”后,被任务B抢占了。任务B在进入阻塞
FreeRTOS资源管理
资源的访问
在多任务系统中,如果任务开始访问某个资源,但在完成访问之前被切换出运行状态,那么就有可能发生错误。如果任务使资源处于不一致的状态,那么其他任务或中断对同一资源的访问可能会导致数据损坏或其他类似问题。
访问外设为例
两个任务尝试向液晶显示器(LCD)写入内容。
任务A执行,开始将字符串“Hello world”写入到LCD。
任务A在输出字符串的开头部分“Hello w”后,被任务B抢占了。
任务B在进入阻塞状态之前,向LCD写入“Abort, Retry, Fail?”。
任务A从被抢占的位置继续执行,并继续输出其字符串—剩余字符“orld”。
现在,LCD上显示的是损坏的字符串“Hello wAbort, Retry, Fail?orld”。
变量的非原子访问
更新结构体中的多个成员,或者更新大于架构字大小的变量(例如,在16位机器上更新32位的变量),都是非原子操作的例子。如果这些操作被中断,可能会导致数据丢失或损坏。
函数重入性
如果函数可以安全地在多个任务中调用,或者既可以从任务中又可以从中断中安全地调用,那么该函数就是“可重入的”。可重入函数被认为是“线程安全”的,因为它们可以从多个执行线程中访问,而不会导致数据或逻辑操作损坏的风险。
核心本质:函数的执行不依赖全局 / 静态变量、共享资源,也不修改自身执行环境的状态(除非是栈上的局部状态),每个调用实例的上下文完全独立。
每个任务都维护着自己的栈和一套处理器(硬件)寄存器值。如果函数只访问存储在栈上或保存在寄存器中的数据,而不访问其他数据,那么该函数就是可重入的,并且是线程安全的。
1. 不可重入函数(Non-reentrant)
特征:依赖 / 修改共享的可变状态,重入时会破坏数据一致性或导致逻辑错误。常见原因:
- 使用静态 / 全局变量;
- 修改全局 / 共享数据结构(如全局链表、硬件寄存器);
- 调用其他不可重入函数(如
strtok); - 依赖非原子操作的共享资源(如未加保护的堆内存);
- 使用非可重入的同步机制(如普通互斥锁,易导致死锁)。
示例:不可重入函数
// 不可重入:静态变量count被所有调用共享
int count_calls() {
static int count = 0; // 静态变量,存储在全局区
count++; // 非原子操作,中断时可能执行到一半
return count;
}
问题场景:嵌入式系统中,主线程调用 count_calls() 执行到 count++ 中途(如 count=5 未写回),硬件中断触发,中断服务函数(ISR)也调用 count_calls(),导致 count 被覆盖为 6;中断返回后,主线程的 count++ 完成,最终 count=6(而非预期的 7),结果错误。
2. 可重入函数(Reentrant)
特征:仅依赖参数和栈上的局部变量,无共享可变状态,重入时上下文独立。核心条件:
- 不使用静态 / 全局变量,所有可变状态由调用者通过参数传入;
- 不修改传入的参数(除非设计允许且参数独立);
- 仅调用可重入函数;
- 局部变量均为栈分配(每个调用有独立栈帧)。
示例:可重入函数
// 可重入:计数变量由调用者传入(栈/调用者管理的内存)
int count_calls_reentrant(int *count) {
if (count == NULL) return -1;
(*count)++; // 操作调用者提供的独立变量
return *count;
}
// 纯局部变量的可重入函数(无状态)
int add(int a, int b) {
int temp = a + b; // 栈上局部变量,每个调用独立
return temp;
}
临界区
临界区与暂停调度器
临界区是一个访问共用资源(例如共用设备或共用存储器)的程序片段,这些资源无法同时被多个任务访问。当任务进入临界区时,其他任务必须等待,以确保这些共用资源是互斥获得的。
暂停调度器通常与临界区操作相关。当需要确保某个代码段在执行时不会被其他任务或中断打断时,可以暂停调度器。这样做的好处是,代码段在执行期间不会被高优先级的任务抢占,但需要注意的是,暂停调度器并不关闭任何中断,所以中断仍然可以正常执行。
在临界区中使用暂停调度器时,需要特别注意代码段的长度。临界区中的代码应该尽可能短小,否则可能会影响系统的实时性。
FreeRTOS实现临界区操作有临界段操作和调度器操作两种方法。
基本的临界区
基本临界区是指分别由调用taskENTER_CRITICAL() 宏和taskEXIT_CRITICAL()宏所包围起来的代码区域。临界区也被称为关键区域。taskENTER_CRITICAL() 宏和taskEXIT_CRITICAL() 宏不接受任何参数,也没有返回值。
基本的临界区工作方式是禁用中断,这取决于FreeRTOS移植,可以是完全禁用中断,或者禁用到由configMAX_SYSCALL_INTERRUPT_PRIORITY设定的中断优先级。
原理:抢占式的上下文切换只能在中断内部发生,因此,只要将中断保持在禁用状态,那么调用taskENTER_CRITICAL()的任务就保证会保持在运行状态,直到退出临界区。使用临界区的注意事项:
基本临界区必须保持短小,否则将对中断响应时间产生不利影响。每次调用taskENTER_CRITICAL()都必须与taskEXIT_CRITICAL()的调用紧密配对。
临界区可以嵌套,内核会跟踪嵌套的深度。只有当嵌套深度返回到零时,临界区才会退出,这意味着对于之前的每个taskENTER_CRITICAL()调用,都执行了一个taskEXIT_CRITICAL()调用。
调用taskENTER_CRITICAL()宏和taskEXIT_CRITICAL()宏是任务改变处理器中断使能状态的唯一合法方式。通过其他方式改变中断使能状态将导致宏的嵌套计数失效。
taskENTER_CRITICAL()宏和taskEXIT_CRITICAL()宏没有以FromISR结尾,因此不能在中断服务程序中调用。taskENTER_CRITICAL_FROM_ISR()宏是taskENTER_CRITICAL()宏的中断安全版本,而taskEXIT_CRITICAL_FROM_ISR()宏是taskEXIT_CRITICAL()宏的中断安全版本。
taskENTER_CRITICAL_FROM_ISR()宏返回一个值,该值必须传递给与之匹配的taskEXIT_CRITICAL_FROM_ISR()调用。
void vAnInterruptServiceRoutine(void)
{
/* 声明变量,用于保存taskENTER_CRITICAL_FROM_ISR()的返回值。 */
UBaseType_t uxSavedInterruptStatus;
/* ISR的这一部分可以被任何更高优先级的中断打断。 */
/* 使用taskENTER_CRITICAL_FROM_ISR()来保护ISR的一个区域,并保存其返回值,以便在匹配的
taskEXIT_CRITICAL_FROM_ISR()调用中传递。 */
uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();
/* ISR的这一部分位于taskENTER_CRITICAL_FROM_ISR()和taskEXIT_CRITICAL_FROM_ISR()
的调用之间,因此只能被优先级高于由configMAX_SYSCALL_INTERRUPT_PRIORITY常量设置的中断
打断。 */
/* 通过调用taskEXIT_CRITICAL_FROM_ISR()并传入与taskENTER_CRITICAL_FROM_ISR()
匹配调用返回的值,再次退出临界区。 */
taskEXIT_CRITICAL_FROM_ISR(uxSavedInterruptStatus);
/* ISR的这一部分可以被任何更高优先级的中断打断。 */
}
如果执行进入临界区并随后退出临界区的代码所消耗的处理时间,比实际受临界区保护的代码执行时间还要多,那么这就是一种浪费。基本临界区的进入和退出速度非常快,并且总是确定的,因此当受保护的代码区域非常短时,使用它们是非常理想的。
暂停(或锁定)调度器
可以通过暂停调度器来创建临界区。暂停调度器有时也被称为“锁定”调度器。
基本的临界区保护代码不被其他任务和中断访问。通过暂停调度器实现的临界区,仅保护代码区域不被其他任务访问,因为中断是保持使能的。
如果临界区太长,无法通过简单的禁用中断来实现,那么可以通过暂停调度器来实现。然而,当调度器被暂停时,中断活动可能使恢复(或“取消暂停”)调度器成为一个相对较长的操作,因此必须考虑在各种情况下使用哪种方法是最佳的。
函数vTaskSuspendAll()
vTaskSuspendAll() API 函数的原型如下所示:
void vTaskSuspendAll( void );
通过调用vTaskSuspendAll() API函数可以暂停调度器。暂停调度器可以阻止进行上下文切换,但会让中断保持使能状态。如果中断在调度器暂停时请求上下文切换,则该请求将被保持待定,直到调度器恢复(取消暂停)时才会执行。在调度器被暂停时,不得调用FreeRTOS API函数。
函数xTaskResumeAll()
xTaskResumeAll () API 函数的原型如下所示:
BaseType_t xTaskResumeAll( void );
通过调用xTaskResumeAll() API函数,可以恢复(取消暂停)调度器。
xTaskResumeAll () API函数的返回值说明如下所示:
| 返回值 | 返回值说明 |
|---|---|
| 返回值 | 用在调度器暂停时请求的上下文切换将被保持待定,只有在恢复调度器时才会执行。如果在xTaskResumeAll() API函数返回之前执行了一个待定的上下文切换,那么将返回pdTRUE。否则,将返回pdFALSE。 |
嵌套调用vTaskSuspendAll()和xTaskResumeAll() API函数是安全的,因为内核会记录嵌套的深度。只有当嵌套深度返回到零时,调度器才会恢复—也就是每次先调用vTaskSuspendAll() API函数,接着就会调用xTaskResumeAll() API函数。
下面的代码示例显示了vPrintString()的实际实现,它会挂起调度器以保护对终端输出的访问。
void vPrintString( const char *pcString )
{
/* 将字符串写入标准输出,通过挂起调度器实现互斥。 */
vTaskSuspendScheduler();
{
printf( "%s", pcString );
fflush( stdout );
}
xTaskResumeScheduler();
}
互斥量(和二进制信号量)
互斥量(Mutex)是一种特殊类型的二进制信号量,用于控制两个或多个任务之间共享资源的访问。互斥量(MUTEX)这个词源自“MUTual EXclusion”(相互排除)。为了能够使用互斥量,必须在FreeRTOSConfig.h文件中将configUSE_MUTEXES设置为1。
在相互排斥的场景中使用互斥量时,互斥量可以被视为与共享资源相关联的令牌。任务想要合法地访问资源,首先必须成功地“获取”该令牌(成为令牌持有者)。当令牌持有者完成对资源的操作后,必须“归还”令牌。只有在令牌被归还后,另一个任务才能成功地获取令牌,然后安全地访问相同的共享资源。除非持有令牌,否则任务不允许访问共享资源。
尽管互斥量和二进制信号量具有许多共同特性,但与采用二进制信号量使任务与中断同步完全不同。获得信号量之后发生的最主要区别如下:
- 用于互斥的信号量必须始终被归还。
- 用于同步的信号量通常会被丢弃,而不是归还。
函数xSemaphoreCreateMutex()
FreeRTOS还包含xSemaphoreCreateMutexStatic() API函数,该函数在编译时静态地分配创建互斥量所需的内存。互斥量是一种类型的信号量。所有不同类型的FreeRTOS信号量的句柄都存储在类型为SemaphoreHandle_t的变量中。
在使用互斥量之前,必须先创建。要创建一个互斥量类型的信号量,请使用xSemaphoreCreateMutex() API函数。xSemaphoreCreateMutex() API 函数的原型如下所示:
SemaphoreHandle_t xSemaphoreCreateMutex( void );
xSemaphoreCreateMutex() API函数的返回值说明如下所示:
| 返回值 | 返回值说明 |
|---|---|
| 返回值 | 如果返回NULL,则无法创建互斥量,因为FreeRTOS没有足够的堆内存用于分配互斥量数据结构。如果返回值非NULL,则表示互斥量已成功创建。返回的值应存储为已创建的互斥量的句柄。 |
使用信号量重写vPrintString()函数
示例中创建了名为prvNewPrintString()的新版本函数,该函数基于vPrintString(),然后从多个任务中调用这个新函数。prvNewPrintString()在功能上与vPrintString()相同,但它使用互斥量(mutex)来控制对标准输出的访问,而不是通过锁定调度器来实现
void prvNewPrintString( const char *pcString )
{
/* 尝试获取互斥锁,如果互斥锁不可用,则无限期地阻塞等待。 */
xSemaphoreTake( xMutexHandle, portMAX_DELAY );
{
/* 只有当互斥锁成功获取后,下面的代码行才会执行。因为现在一次只有一个任务可以持有互斥
锁,所以此时可以自由访问UART输出。 */
printf( "%s", pcString );
}
/* 归还互斥锁,使其他任务可以访问UART输出。 */
xSemaphoreGive( xMutexHandle );
}
FreeRTOS的任务优先级反转或死锁
优先级反转
下图显示的执行顺序表明,高优先级的Task2 必须等待低优先级的Task1 放弃对互斥量的控制。这种高优先级任务被低优先级任务延迟的情况被称为“优先级反转”。如果高优先级任务在等待信号量时,一个中优先级的任务开始执行,这种不理想的行为将被进一步加剧—结果是高优先级任务等待低优先级任务,而低优先级任务甚至无法执行。

优先级反转可能是一个严重的问题,但在小型嵌入式系统中,通过仔细考虑如何访问资源,往往可以在系统设计时避免这个问题。
优先级继承
FreeRTOS 的互斥量(mutexes)和二进制信号量(binary semaphores)非常相似—区别在于,互斥量包含了一个基本的“优先级继承”机制,而二进制信号量则没有。优先级继承是一种最小化优先级反转负面影响的方案。它并没有“解决”优先级反转问题,而是仅仅通过确保反转总是时间有限的来减轻其影响。然而,优先级继承使系统时序分析复杂化,依赖它来实现系统正确操作并不是好的做法。
优先级继承的工作原理是暂时提高互斥量持有者的优先级,使其与尝试获取同一互斥量的最高优先级任务的优先级相同。持有互斥量的低优先级任务会“继承”等待互斥量的任务的优先级。下图演示了这种情况。当互斥量持有者归还互斥量时,其优先级会自动重置为原始值。

优先级继承功能会影响使用互斥量的任务的优先级。因此,互斥量不应从中断服务例程中使用。
死锁
“死锁”是使用互斥量实现互斥访问时可能面临的另一个潜在陷阱。死锁有时也被称为更戏剧性的名字“致命拥抱”。
当两个任务都在等待对方持有的资源,而无法继续执行时,就会发生死锁。考虑以下场景:任务 A 和任务 B 都需要获取互斥量 X 和互斥量 Y 才能执行某个操作:
- 任务 A 执行并成功获取互斥量 X。
- 任务 A 被任务 B 抢占。
- 任务 B 在尝试获取互斥量 X 之前成功获取了互斥量 Y,但互斥量 X 被任务 A 持有,因此任务 B 无法获取。任务 B 选择进入阻塞状态以等待互斥量 X 被归还。
- 任务 A 继续执行。它尝试获取互斥量 Y,但互斥量 Y 被任务 B 持有,因此任务 A 无法获取。任务 A 选择进入阻塞状态以等待互斥量 Y 被归还。
在这个场景中,任务 A 正在等待任务 B 持有的互斥量,而任务 B 正在等待任务 A 持有的互斥量。死锁已经发生,因为两个任务都无法继续执行。
与优先级反转一样,避免死锁的最佳方法是在设计时考虑其潜在问题,并设计系统以确保不会发生死锁。通常情况下,任务无限期地等待(没有超时)以获取互斥量是不好的做法。相反,应该使用一个稍微长于预期等待互斥量的最大时间的超时时间—那么,如果在这个时间内无法获取互斥量,将表明设计存在错误,可能是死锁。
递归互斥量
任务也有可能与自己发生死锁。这种情况会发生在任务尝试多次获取同一个互斥量,而没有先归还该互斥量时。考虑以下场景:
- 任务成功获取了一个互斥量。
- 在持有该互斥量的同时,该任务调用了一个库函数。
- 库函数的实现尝试获取同一个互斥量,并进入阻塞状态以等待互斥量变得可用。
在这个场景的最后,任务进入阻塞状态以等待互斥量被返回,但任务本身已经是该互斥量的持有者。因此,发生了死锁,因为任务在阻塞状态等待自己归还互斥量。
这种死锁可以通过使用递归互斥量代替标准互斥量来避免。递归互斥量可以被同一个任务多次“获取”,并且只有在对每次先执行调用 “获取”递归互斥量,接着执行调用“归还”递归互斥量后,才会被归还。
- 使用 xSemaphoreCreateMutex() 函数创建标准互斥量。使用 xSemaphoreCreateRecursiveMutex() 函数创建递归互斥量。这两个 API 函数具有相同的函数原型。
- 使用 xSemaphoreTake() 函数“获取”标准互斥量。使用 xSemaphoreTakeRecursive() 函数“获取”递归互斥量。这两个 API 函数具有相同的函数原型。
- 使用 xSemaphoreGive() 函数“归还”标准互斥量。使用 xSemaphoreGiveRecursive() 函数“归还”递归互斥量。这两个 API 函数具有相同的函数原型。
下面的代码示例展示了如何创建和使用递归互斥量。
/* 递归互斥量是 SemaphoreHandle_t 类型的变量。 */
SemaphoreHandle_t xRecursiveMutex;
/* 创建一个并使用递归互斥量的任务的实现。 */
void vTaskFunction( void *pvParameters )
{
const TickType_t xMaxBlock20ms = pdMS_TO_TICKS( 20 );
/* 使用递归互斥量之前,必须显式地创建它。 */
xRecursiveMutex = xSemaphoreCreateRecursiveMutex();
/* 检查信号量是否已成功创建。 */
configASSERT( xRecursiveMutex );
/* 与大多数任务一样,此任务以无限循环的方式实现。 */
for( ;; )
{
/* ... */
/* 获取递归互斥量。 */
if( xSemaphoreTakeRecursive( xRecursiveMutex, xMaxBlock20ms ) == pdPASS )
{
/* 递归互斥量已成功获取。现在任务可以访问互斥量所保护的资源。此时,递归调用计数(
即嵌套调用 xSemaphoreTakeRecursive() 的次数)为 1,因为递归互斥量仅被获取了
1次。 */
/* 尽管任务已经持有递归互斥量,但它再次获取该互斥量。在真实的应用程序中,这种情况
只可能发生在由该任务调用的子函数内部,因为没有实际的原因需要故意多次获取同一个互斥
量。调用任务已经是互斥量的持有者,因此第二次调用 xSemaphoreTakeRecursive() 只
是将递归调用计数增加到 2,没有其他作用。 */
xSemaphoreTakeRecursive( xRecursiveMutex, xMaxBlock20ms );
/* ... */
/* 任务在访问完互斥量所保护的资源后返回该互斥量。此时,递归调用计数为 2,因此第一
次调用 xSemaphoreGiveRecursive() 并不会返回互斥量。相反,它只是将递归调用计数
减少到 1。 */
xSemaphoreGiveRecursive( xRecursiveMutex );
/* 下一次调用 xSemaphoreGiveRecursive() 会将递归调用计数减少到 0,因此这次会
返回递归互斥量。 */
xSemaphoreGiveRecursive( xRecursiveMutex );
/* 现在,对于每次对 xSemaphoreTakeRecursive() 的调用,都已经执行了一次
对 xSemaphoreGiveRecursive() 的调用,因此任务不再是互斥量的持有者。 */
}
}
}
守门任务(Gatekeeper)
守门(Gatekeeper)任务提供了一种简洁的方法来实现互斥,并且不用担心优先级反转或死锁的风险。守门(Gatekeeper)任务是对资源拥有唯一所有权的任务。只有守门任务才允许直接访问资源—任何其他需要访问资源的任务只能通过守门的服务来间接访问。
使用守门任务重写vPrintString()函数实验
下面示例提供了 vPrintString() 函数的另一种替代实现方式。这次,使用了一个守门任务(gatekeeper task)来管理对UART输出的访问。当一个任务想要向UART输出写入消息时,它不会直接调用打印函数,而是将消息发送给守门任务。
守门任务使用 FreeRTOS 队列来序列化对UART输出的访问。该任务的内部实现不需要考虑互斥问题,因为它是唯一被允许直接访问UART输出的任务。
守门任务大部分时间都处于阻塞状态,等待队列上的消息到达。当消息到达时,守门任务简单的将消息写入UART输出,然后返回到阻塞状态以等待下一条消息。守门任务的实现如下代码所示。
void prvStdioGatekeeperTask( void * pvParameters )
{
char * pcMessageToPrint;
/* 这是唯一被允许写入UART输出的任务。任何其他想要写入输出的任务都不会直接
访问UART,而是将输出发送给此任务。由于只有一个任务写入标准输出,因此在这个
任务本身内部不需要考虑互斥或序列化问题。 */
for( ; ; )
{
/* 等待消息到达。 */
xQueueReceive( xPrintQueueHandle, &pcMessageToPrint, portMAX_DELAY );
/* 不需要检查返回值,因为任务会无限期地阻塞,并且只有在消息到达时才会再
次运行。当执行下一行代码时,将会有一个消息等待输出。 */
printf( "%s", pcMessageToPrint );
/* 现在只需返回等待下一条消息。 */
}
}
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐


所有评论(0)