FreeRTOS源码分析五:资源访问控制(一)
系列文章目录
FreeRTOS源码分析一:task创建(RISCV架构)
FreeRTOS源码分析二:task启动(RISCV架构)
FreeRTOS源码分析三:列表数据结构
FreeRTOS源码分析四:时钟中断 / 环境调用 处理响应流程
文章目录
- 系列文章目录
- 前言
- taskENTER_CRITICAL 和 taskEXIT_CRITICAL
- vTaskSuspendAll 和 xTaskResumeAll
- vTaskSuspendAll
- 单核下 vTaskSuspendAll 并发访问问题
- xTaskResumeAll
- xCriticalNesting 的任务独立设计
- 如何选择访问控制手段
- 总结
前言
本文介绍一下资源访问控制的两个方法:
宏 taskENTER_CRITICAL
和 taskEXIT_CRITICAL
的临界区控制以及函数 vTaskSuspendAll
和 xTaskResumeAll
的临界区控制。
taskENTER_CRITICAL 和 taskEXIT_CRITICAL
// 禁用中断宏,数值8对应二进制1000,即第3 bit 位
// 使用 RISC-V 汇编指令 csrc (Clear bits in CSR) 清除 mstatus 寄存器的第3位 (MIE位)
// MIE (Machine Interrupt Enable) 位控制机器模式下的中断使能
// 当 MIE=0 时,禁用所有机器模式中断
#define portDISABLE_INTERRUPTS() __asm volatile ( "csrc mstatus, 8" )
#define portENABLE_INTERRUPTS() __asm volatile ( "csrs mstatus, 8" )extern size_t xCriticalNesting;
#define portENTER_CRITICAL() \{ \portDISABLE_INTERRUPTS(); \xCriticalNesting++; \}#define portEXIT_CRITICAL() \{ \xCriticalNesting--; \if( xCriticalNesting == 0 ) \{ \portENABLE_INTERRUPTS(); \} \}#define taskENTER_CRITICAL() portENTER_CRITICAL()
#define taskEXIT_CRITICAL() portEXIT_CRITICAL()
以这种方式实现的临界区是提供互斥的一种粗略方法。它们通过禁用中断来工作。抢占式上下文切换只能在中断内部发生,因此,只要中断保持禁用状态,调用 taskENTER_CRITICAL()
的任务就能保证在退出临界区前一直处于运行状态。
基本临界区必须保持非常短,否则会对中断响应时间产生不利影响。每个 taskENTER_CRITICAL()
调用必须与 taskEXIT_CRITICAL()
调用紧密配对。在 FreeRTOS源码分析一:task创建(RISCV架构)中我们分析过,在新建任务,初始化堆栈等等结束,调用 prvAddNewTaskToReadyList
(将任务加入就绪列表)时会调用 taskENTER_CRITICAL()
保护对数据的访问。
临界区可以安全嵌套,因为内核会记录嵌套深度。只有当嵌套深度回到 0(即每个 taskENTER_CRITICAL()
调用都对应一个taskEXIT_CRITICAL()
调用)时,临界区才会退出。
vTaskSuspendAll 和 xTaskResumeAll
vTaskSuspendAll
调用 vTaskSuspendAll()
可以挂起调度器。挂起调度器会阻止上下文切换,但中断仍然启用。如果在调度器挂起时,某个中断请求上下文切换,该请求会被挂起,直到调度器恢复(解除挂起)后才执行。
void vTaskSuspendAll( void )
{traceENTER_vTaskSuspendAll();#if ( configNUMBER_OF_CORES == 1 ){/* The scheduler is suspended if uxSchedulerSuspended is non-zero. An increment* is used to allow calls to vTaskSuspendAll() to nest. */uxSchedulerSuspended = ( UBaseType_t ) ( uxSchedulerSuspended + 1U );/* Enforces ordering for ports and optimised compilers that may otherwise place* the above increment elsewhere. */portMEMORY_BARRIER();}#endif
}
这里涉及到很多问题。
- 1:全局变量
uxSchedulerSuspended
用于记录调度器是否挂起。非 0 则调度器挂起。 - 2:
uxSchedulerSuspended
加 1 操作中会分为几个步骤:加载到寄存器,寄存器值加 1,寄存器值写回uxSchedulerSuspended
。 - 3:
portMEMORY_BARRIER
被实现为:asm volatile("" ::: "memory");
它的作用是强制“这行代码前后的内存访问不被重排”。它既是“可移植的顺序屏障钩子”,用来约束编译器(必要时也约束CPU)对读写的重排;它不是锁,也不屏蔽中断,和“原子性”无关。 - 4:单核意味着同一时刻只有一个任务在执行指令。
单核下 vTaskSuspendAll 并发访问问题
设 uxSchedulerSuspended
初值为 0,有两个任务 T1、T2 都要执行 vTaskSuspendAll()
:
-
T1:读取
uxSchedulerSuspended=0
到寄存器(但还没写回)。 -
发生一次任务切换到 T2(此时值仍是 0,所以切换合法)。
-
T2:完成“读→加一→写回”,把全局变量写成 1。
- 自此起,调度器已挂起(计数>0),不会再发生真正的任务切换;tick 只积攒挂起的切换请求。
-
T1 仍未运行;它要想再被调度,只能等到别人把计数降回 0。
-
某处执行
xTaskResumeAll()
若把计数从 1 减到 0,此刻才会处理挂起的切换并有机会切回 T1。 -
T1 恢复,寄存器里仍是“之前读到的 0”,执行“+1 → 写回”,写回的是 1。
- 注意:它恢复时全局变量已经是 0(否则不会被切回),因此 T1 的写 不会覆盖成 0,也不会丢增量,而是把 0 写成 1,结果正确。
直观理解:T1 的“滞后写”只有在全局又回到 0 之后才会发生,因此不会把别人已经做过的 +1 覆盖掉;每个调用最终都能把计数从 0 加到 1(或在更一般的嵌套下,把总计数增加 1)。
在 单核下,即便在“+1”的中间点被切换,也不会丢失加一或写坏值;调度器的“>0 禁切换,回到 0 才切回”的语义保证了时序安全。portMEMORY_BARRIER()
只是为防止编译器重排破坏“先挂起后续逻辑”的顺序。
xTaskResumeAll
调用xTaskResumeAll()可以恢复(解除挂起)调度器。
检查挂起就绪队列并恢复任务到就绪队列同时记录优先级决定是否调度
检查是否有挂起的 tick,在 vTaskSuspendAll 期间触发的时钟,有则调用 xTaskIncrementTick 增加 tick 计数
如果xTaskResumeAll()返回前执行了挂起的上下文切换,则返回pdTRUE;否则返回pdFALSE。
BaseType_t xTaskResumeAll( void )
{TCB_t * pxTCB = NULL; // 任务控制块指针BaseType_t xAlreadyYielded = pdFALSE; // 标记是否已经执行了任务切换#if ( configNUMBER_OF_CORES > 1 )// 多核系统:只有在调度器运行时才执行if( xSchedulerRunning != pdFALSE )#endif{/* 在调度器挂起期间,ISR可能会将任务从事件列表中移除。* 如果发生这种情况,被移除的任务会被添加到xPendingReadyList中。* 一旦调度器恢复,就可以安全地将所有挂起的就绪任务从这个列表* 移动到它们相应的就绪列表中。 */taskENTER_CRITICAL(); // 进入临界区{const BaseType_t xCoreID = ( BaseType_t ) portGET_CORE_ID(); // 获取当前核心ID/* 如果uxSchedulerSuspended为零,说明此函数调用与之前的* vTaskSuspendAll()调用不匹配 */configASSERT( uxSchedulerSuspended != 0U );// 调度器挂起计数减1uxSchedulerSuspended = ( UBaseType_t ) ( uxSchedulerSuspended - 1U );// 如果调度器挂起计数归零,说明调度器完全恢复if( uxSchedulerSuspended == ( UBaseType_t ) 0U ){// 如果当前有任务存在if( uxCurrentNumberOfTasks > ( UBaseType_t ) 0U ){/* 将挂起列表中的就绪任务移动到相应的就绪列表中 */while( listLIST_IS_EMPTY( &xPendingReadyList ) == pdFALSE ){// 获取挂起就绪列表头部的任务控制块pxTCB = listGET_OWNER_OF_HEAD_ENTRY( ( &xPendingReadyList ) );// 从事件列表中移除该任务listREMOVE_ITEM( &( pxTCB->xEventListItem ) );portMEMORY_BARRIER(); // 内存屏障,确保内存操作顺序// 从状态列表中移除该任务listREMOVE_ITEM( &( pxTCB->xStateListItem ) );// 将任务添加到就绪列表prvAddTaskToReadyList( pxTCB );#if ( configNUMBER_OF_CORES == 1 ){/* 单核系统:如果移动的任务优先级高于当前任务,* 则必须执行任务切换 */if( pxTCB->uxPriority > pxCurrentTCB->uxPriority ){xYieldPendings[ xCoreID ] = pdTRUE; // 标记需要任务切换}}#else /* #if ( configNUMBER_OF_CORES == 1 ) */{/* 多核系统:当任务被添加到xPendingReadyList时,* 所有适当的任务都会立即让出。如果当前核心让出了,* vTaskSwitchContext()已经被调用,它会将当前核心的* xYieldPendings设置为pdTRUE。 */}#endif /* #if ( configNUMBER_OF_CORES == 1 ) */}// 如果有任务被解除阻塞if( pxTCB != NULL ){/* 在调度器挂起期间有任务被解除阻塞,这可能阻止了* 下一个解除阻塞时间的重新计算,在这种情况下现在重新计算。* 主要对低功耗无滴答实现很重要,可以防止不必要地退出低功耗状态。 */prvResetNextTaskUnblockTime();}/* 如果在调度器挂起期间发生了任何时钟滴答,* 现在应该处理它们。这确保滴答计数不会丢失,* 延迟任务在正确的时间恢复。** 在这里从任何核心调用xTaskIncrementTick应该是安全的,* 因为我们在临界区中,xTaskIncrementTick本身在临界区内保护自己。* 从任何核心挂起调度器都会导致xTaskIncrementTick增加uxPendedCounts。 */{TickType_t xPendedCounts = xPendedTicks; /* 非易失性拷贝 */// 如果有挂起的时钟滴答需要处理if( xPendedCounts > ( TickType_t ) 0U ){do{// 处理一个时钟滴答if( xTaskIncrementTick() != pdFALSE ){/* 其他核心在xTaskIncrementTick()内被中断 */xYieldPendings[ xCoreID ] = pdTRUE; // 标记需要任务切换}--xPendedCounts; // 减少挂起的滴答计数} while( xPendedCounts > ( TickType_t ) 0U );xPendedTicks = 0; // 清零挂起的滴答计数}}// 如果需要任务切换if( xYieldPendings[ xCoreID ] != pdFALSE ){#if ( configUSE_PREEMPTION != 0 ){xAlreadyYielded = pdTRUE; // 标记已经执行了任务切换}#endif /* #if ( configUSE_PREEMPTION != 0 ) */#if ( configNUMBER_OF_CORES == 1 ){// 单核系统:如果使用抢占式调度,执行任务切换taskYIELD_TASK_CORE_IF_USING_PREEMPTION( pxCurrentTCB );}#endif /* #if ( configNUMBER_OF_CORES == 1 ) */}}}}taskEXIT_CRITICAL(); // 退出临界区}return xAlreadyYielded; // 返回是否已经执行了任务切换
}
- 1:调度器挂起计数不为 0,则退出临界区,返回未调度。调度器挂起期间请求的上下文切换会被挂起,仅在调度器恢复时执行。
- 2:回顾 FreeRTOS源码分析四:时钟中断 / 环境调用处理响应流程 中,时钟发生时
xTaskIncrementTick
会被调用。但如果调度器挂起,则只会增加xPendedTicks
计数。 - 3:如果需要任务切换,那么会在临界区直接调用
taskYIELD_TASK_CORE_IF_USING_PREEMPTION( pxCurrentTCB );
它被宏扩展为ecall
#define portYIELD() __asm volatile ( "ecall" );
#define portYIELD_WITHIN_API portYIELD
#define taskYIELD_TASK_CORE_IF_USING_PREEMPTION( pxTCB ) \do { \( void ) ( pxTCB ); \portYIELD_WITHIN_API(); \} while( 0 )
- 4:这里会在临界区内部执行
ecall
执行任务切换。在 FreeRTOS源码分析四:时钟中断 / 环境调用处理响应流程 会选择最高优先级任务并切换。这时候可能会存在疑问:在禁用中断的情况下调度吗?会有问题吗?在 FreeRTOS源码分析一:task创建(RISCV架构) 最后中提到临界区的计数会出错吗?
// 禁用中断宏,数值8对应二进制1000,即第3 bit 位
// 使用 RISC-V 汇编指令 csrc (Clear bits in CSR) 清除 mstatus 寄存器的第3位 (MIE位)
// MIE (Machine Interrupt Enable) 位控制机器模式下的中断使能
// 当 MIE=0 时,禁用所有机器模式中断
#define portDISABLE_INTERRUPTS() __asm volatile ( "csrc mstatus, 8" )
#define portENABLE_INTERRUPTS() __asm volatile ( "csrs mstatus, 8" )extern size_t xCriticalNesting;
#define portENTER_CRITICAL() \{ \portDISABLE_INTERRUPTS(); \xCriticalNesting++; \}#define portEXIT_CRITICAL() \{ \xCriticalNesting--; \if( xCriticalNesting == 0 ) \{ \portENABLE_INTERRUPTS(); \} \}
实际上是不会的。原因在于每一个任务的 xCriticalNesting
是独立的。
xCriticalNesting 的任务独立设计
size_t xCriticalNesting = ( size_t ) 0xaaaaaaaa;
size_t * pxCriticalNesting = &xCriticalNesting;
1:xCriticalNesting
用于记录任务调用 portENTER_CRITICAL
的嵌套层数。内存中只有一个位置存放该值,初始化为 0xaaaaaaaa
,pxCriticalNesting
指针变量存储它的地址。
2:根据 FreeRTOS源码分析一:task创建(RISCV架构) 我们知道,pxPortInitialiseStack
堆栈初始化函数会把任务堆栈中的 xCriticalNesting
的位置也就是栈底初始化为 0。
3:同时根据 FreeRTOS源码分析二:task启动(RISCV架构) 我们知道,调度第一个任务的时候,会从堆栈中加载 xCriticalNesting
并通过 pxCriticalNesting
这个指针变量赋值到 xCriticalNesting
变量中。那么,每一个任务的 xCriticalNesting
初始都是 0 。
pxPortInitialiseStack:/* === 第一部分:为临界区嵌套计数预留空间 === */addi a0, a0, -portWORD_SIZE /* 栈指针下移一个字长,为临界区嵌套计数预留空间 */store_x x0, 0(a0) /* 将临界区嵌套计数初始化为0(每个任务开始时都是0) */......xPortStartFirstTask:....../* 恢复任务的临界区嵌套计数器 */load_x x5, portCRITICAL_NESTING_OFFSET * portWORD_SIZE( sp ) /* 从任务栈中获取该任务的临界嵌套计数值 */load_x x6, pxCriticalNesting /* 将全局临界嵌套变量的地址加载到x6 */store_x x5, 0( x6 ) /* 恢复该任务的临界嵌套计数值到全局变量 */
4:同时根据 FreeRTOS源码分析四:时钟中断 / 环境调用处理响应流程 我们知道,每一个任务在发生时钟或主动 yield --> ecall
调度之前会保存上下文。其中包括当前 xCriticalNesting
的值。另外,任务在被执行之前,恢复上下文,也需要把任务的 xCriticalNesting
值恢复到变量中。
.macro portcontextSAVE_CONTEXT_INTERNAL
addi sp, sp, -portCONTEXT_SIZE
......load_x t0, xCriticalNesting /* Load the value of xCriticalNesting into t0. */
store_x t0, portCRITICAL_NESTING_OFFSET * portWORD_SIZE( sp ) /* Store the critical nesting value to the stack. */.macro portcontextRESTORE_CONTEXTload_x t1, pxCurrentTCB /* 加载当前任务控制块指针 */load_x sp, 0 ( t1 ) /* 从TCB第一个成员读取栈指针 */....../* 恢复临界嵌套计数 */load_x t0, portCRITICAL_NESTING_OFFSET * portWORD_SIZE( sp )load_x t1, pxCriticalNestingstore_x t0, 0 ( t1 )
5:到这里我们其实就知道了,每一个任务的 xCriticalNesting
互相独立,互不影响。在调用 portENTER_CRITICAL
之后进行任务调度不会对 xCriticalNesting
的计数有任何影响,它当前的值只作用于当前的任务。
如何选择访问控制手段
taskENTER_CRITICAL()
/taskEXIT_CRITICAL()
- 通过禁止中断来阻止抢占式上下文切换,保护整个系统级的共享数据。
- 进入临界区会屏蔽所有中断,因此中断响应延迟会增加,实时性下降;适合保护非常短、必须原子完成的关键代
vTaskSuspendAll()
/xTaskResumeAll()
- 挂起调度器,只阻止任务切换,但不影响中断处理;中断依旧可以运行并修改数据,只是上下文切换会延迟到恢复调度器时才发生。
- 通过挂起调度器也可以创建临界区。调度器不运行,则任务不会被调度,其余任务不会执行。
- 不会影响中断响应时间,适合保护涉及多个任务列表操作或较长时间的批量更新操作。
- 支持嵌套调用,挂起期间 tick 中断只会累积计数,不会触发调度。
- 适用场景:需要批量修改任务状态(如批量移动任务列表)。 对实时性要求高,但能容忍调度延迟的场合。执行耗时较长的操作,且不希望中途切换到其他任务。
宏 taskENTER_CRITICAL
和 taskEXIT_CRITICAL
基本临界区保护代码区域不被其他任务和中断访问,而通过挂起调度器实现的临界区仅保护代码区域不被其他任务访问,因为中断仍然启用。
对于太长而无法仅通过禁用中断实现的临界区,可以通过挂起调度器来实现。但是,调度器挂起期间的中断活动可能会使恢复(或 “解除挂起”)调度器成为一个相对较长的操作,因此必须考虑在每种情况下使用哪种方法更合适。
总结
完结撒花!!!