在多任务系统中,如果一个任务开始访问资源,但是在转换为正在运行状态之前没有完成对资源的访问,则可能会出错。 如果任务使资源处于不一致状态,则任何其他任务或中断对同一资源的访问都可能导致数据损坏或其他类似问题。
以下是一些示例:
访问外围设备
请考虑以下情形,其中两个任务试图写入液晶显示器(LCD)。
1.任务A执行并开始将字符串“ Hello world”写入LCD。
2.在仅输出字符串的开头“ Hello w”之后,任务B抢占了任务A。
3.任务B写下“Abort,Retry,Fail?” 进入“阻塞”状态之前,请先将其连接至LCD。
4.任务A从被抢占的位置继续,并完成输出其字符串的其余字符“ orld”。
LCD现在显示损坏的字符串“ Hello Abort,Retry,Fail?world”。
读取,修改,写入操作
清单111显示了一行C代码,以及一个通常如何将C代码转换为汇编代码的示例。 可以看出,PORTA的值首先从内存中读取到一个寄存器中,在该寄存器中进行修改,然后再写回到内存中。 这称为读取,修改,写入操作。
这是一项“非原子”操作,因为它需要完成一条以上的指令,并且可以被中断。 考虑以下情形,其中两个任务试图更新称为PORTA的存储器映射寄存器。
1.任务A将PORTA的值加载到寄存器(操作的读取部分)中。
2.在完成同一操作的修改和写入部分之前,任务B抢占了任务A。
3.任务B更新PORTA的值,然后进入“阻止”状态。
4.任务A从被抢占的位置继续。 在将更新后的值写回到PORTA之前,它会修改已保存在寄存器中的PORTA值的副本。
在这种情况下,任务A更新并写回PORTA的过期值。 在任务A获取PORTA值的副本之后,以及在任务A将其修改后的值写回到PORTA寄存器之前,任务B修改PORTA。 当任务A写入PORTA时,它将覆盖任务B已经执行的修改,从而有效地破坏了PORTA寄存器的值。
本示例使用外设寄存器,但是对变量执行读取,修改和写入操作时,将应用相同的原理。
非原子访问变量
非原子操作的示例包括更新结构的多个成员,或更新大于体系结构的自然字大小的变量(例如,更新16位计算机上的32位变量)。 如果它们被打断,则可能导致数据丢失或损坏。
功能重入
如果可以安全地从多个任务或从任务和中断中调用该函数,则该函数为“可重入”。 可重入函数被称为“线程安全”,因为可以从多个执行线程中访问它们,而不会造成数据或逻辑操作损坏的风险。
每个任务都维护自己的堆栈和处理器(硬件)寄存器值集。 如果某个函数除了存储在堆栈中或保存在寄存器中的数据之外,不访问其他任何数据,则该函数是可重入的,并且是线程安全的。 清单112是可重入函数的示例。 清单113是一个不可重入的函数示例。
为了确保始终保持数据一致性,必须使用“互斥”技术来管理对任务之间或任务与中断之间共享的资源的访问。 目的是确保一旦任务开始访问不是可重入且也不是线程安全的共享资源,则同一任务将对该资源具有独占访问权限,直到该资源返回到一致状态为止。
FreeRTOS提供了一些可用于实现互斥的功能,但是
最好的互斥方法是(在可能的情况下,通常是不实际的)以不共享资源的方式设计应用程序,并且仅从单个任务访问每个资源。
本章旨在使读者更好地理解:
基本的临界区是代码区域,分别被对宏taskENTER_CRITICAL()和taskEXIT_CRITICAL()的调用所包围。 临界的部分也被称为临界区域。
taskENTER_CRITICAL()和taskEXIT_CRITICAL()不采用任何参数,也不返回值1。 清单114演示了它们的用法。
本书随附的示例项目使用名为vPrintString()的函数将字符串写到标准输出中-这是使用FreeRTOS Windows端口时的终端窗口。 从许多不同的任务中调用vPrintString()。 因此,从理论上讲,它的实现可以使用临界区来保护对标准输出的访问,如清单115所示。
以这种方式实现的临界区是提供互斥的非常粗糙的方法。 它们可以完全禁用中断,或者完全禁用configMAX_SYSCALL_INTERRUPT_PRIORITY设置的中断优先级(取决于所使用的FreeRTOS端口)来工作。 抢占式上下文切换只能在中断中发生,因此,只要中断保持禁用状态,就可以保证调用taskENTER_CRITICAL()的任务保持运行状态,直到退出关键部分为止。
基本临界区必须保持很短,否则将对中断响应时间产生不利影响。 每次对taskENTER_CRITICAL()的调用都必须与对taskEXIT_CRITICAL()的调用紧密配对。 因此,不应使用临界区(如清单115所示)来保护标准输出(stdout或计算机写入其输出数据的流),因为写入终端可能是一个相对较长的操作。 本章中的示例探讨了替代解决方案。
临界区嵌套是安全的,因为内核会保留嵌套深度的计数。 仅当嵌套深度返回零时(即对先前每次对taskENTER_CRITICAL()的调用都执行一次对taskEXIT_CRITICAL()的调用时,才会退出临界区。
调用taskENTER_CRITICAL()和taskEXIT_CRITICAL()是任务更改运行FreeRTOS的处理器的中断允许状态的唯一合法方法。 通过任何其他方式更改中断允许状态都会使宏的嵌套计数无效。
taskENTER_CRITICAL()和taskEXIT_CRITICAL()不以“ FromISR”结尾,因此不能从中断服务程序中调用。 taskENTER_CRITICAL_FROM_ISR()是taskENTER_CRITICAL()的中断安全版本,而taskEXIT_CRITICAL_FROM_ISR()是taskEXIT_CRITICAL()的中断安全版本。 仅为允许中断嵌套的FreeRTOS端口提供了中断安全版本-在不允许中断嵌套的端口中它们将过时。
taskENTER_CRITICAL_FROM_ISR()返回一个值,该值必须传递到对taskEXIT_CRITICAL_FROM_ISR()的匹配调用中。 清单116演示了这一点。
与执行由临界区保护的代码相比,会浪费更多的处理时间来执行进入和退出临界区的代码。 基本临界区的输入速度非常快,退出速度非常快,并且始终具有确定性,因此在受保护的代码区域非常短的情况下,它们的使用非常理想。
也可以通过挂起调度器来创建临界区。 挂起调度器有时也称为“锁定”调度器。
基本的临界区可以保护代码区域,使其免受其他任务和中断的访问。 通过挂起调度器实现的临界区仅保护代码区域仅不被其他任务访问,因为中断保持启用状态。
相反,关键时间段太长而无法通过简单地禁用中断来实现,
通过挂起调度器实现。 但是,在挂起调度器时中断活动会使恢复(或“取消挂起”)调度程序的工作时间相对较长,因此必须考虑在每种情况下哪种方法是最佳的使用方法。
通过调用vTaskSuspendAll()挂起调度器。 挂起调度器可以防止发生上下文切换,但是可以使中断保持启用状态。 如果在调度器挂起时中断请求上下文切换,则该请求将保持挂起状态,并且仅在恢复调度器(未挂起)时才执行该请求。
调度器挂起时,不得调用FreeRTOS API函数。
通过调用xTaskResumeAll(),可以恢复(未挂起)调度器。
表40. xTaskResumeAll()返回值
Returned Value | Description |
---|---|
Returned value | 挂起调度器时请求的上下文切换将保持挂起状态,并且仅在恢复调度器才执行。 如果在xTaskResumeAll()返回之前执行了挂起的上下文切换,则返回pdTRUE。 否则,返回pdFALSE。 |
调用vTaskSuspendAll()和xTaskResumeAll()嵌套是安全的,因为内核会保留嵌套深度的计数。 仅当嵌套深度返回零时(也就是之前对vTaskSuspendAll()的每个调用都已执行一次对xTaskResumeAll()的调用时,才会恢复调度器。
清单119显示了vPrintString()的实际实现,该实现将调度器挂起以保护对终端输出的访问。
Mutex是一种特殊类型的二进制信号量,用于控制对两个或多个任务之间共享的资源的访问。 MUTEX这个词源于“互斥”? 必须在FreeRTOSConfig.h中将configUSE_MUTEXES设置为1,互斥量才可用。
当在互斥方案中使用时,互斥锁可以被视为与共享资源关联的令牌。 为了使任务合法地访问资源,它必须首先成功“获取”令牌(成为令牌持有者)。 令牌持有者完成资源使用后,必须“返还”令牌。 只有返回令牌后,其他任务才能成功获取令牌,然后安全地访问相同的共享资源。 除非任务持有令牌,否则不允许该任务访问共享资源。 此机制如图63所示。
即使互斥量和二进制信号量具有许多特征,图63所示的情况(互斥量用于互斥)与图53的情况(二进制信号量用于同步)完全不同。 主要区别是在获得信号量之后会发生什么:
FreeRTOS V9.0.0还包括xSemaphoreCreateMutexStatic()函数,该函数分配所需的内存在编译时静态创建互斥锁:互斥锁是一种信号量。 所有各种FreeRTOS信号量的句柄都存储在SemaphoreHandle_t类型的变量中。
必须先创建互斥锁,然后才能使用它。 若要创建互斥锁类型的信号量,请使用xSemaphoreCreateMutex()API函数。
表41. xSemaphoreCreateMutex()返回值
Parameter Name/ Returned Value | Description |
---|---|
Returned value | 如果返回NULL,则无法创建互斥锁,因为FreeRTOS没有足够的堆内存来分配互斥锁数据结构。 第2章提供了有关堆内存管理的更多信息。返回值非NULL表示该互斥锁已成功创建。 返回的值应存储为创建的互斥锁的句柄。 |
本示例创建名为prvNewPrintString()的vPrintString()的新版本,然后从多个任务中调用新函数。 prvNewPrintString()在功能上与vPrintString()相同,但是使用互斥锁而不是通过锁定调度程序来控制对标准输出的访问。 清单121显示了prvNewPrintString()的实现。
由prvPrintTask()实现的任务的两个实例重复调用prvNewPrintString()。 每个呼叫之间使用随机延迟时间。 task参数用于将唯一的字符串传递到任务的每个实例中。 清单122显示了prvPrintTask()的实现。
通常,main()只是创建互斥体,创建任务,然后启动调度程序。 清单123中显示了该实现。
prvPrintTask()的两个实例是在不同的优先级下创建的,因此,优先级较低的任务有时会被优先级较高的任务抢占。 由于使用了互斥锁来确保每个任务都能互斥访问终端,即使发生抢占时,显示的字符串也将正确无误。 可以通过减少任务在“已阻止”状态下花费的最长时间(由xMaxBlockTimeTicks常数设置)来增加抢占的频率。
有关将示例20与FreeRTOS Windows端口一起使用的注意事项:
调用printf()会生成Windows系统调用。 Windows系统调用不在FreeRTOS的控制范围之内,并可能导致不稳定。
Windows系统调用的执行方式意味着即使不使用互斥锁,也很少看到损坏的字符串。
执行示例20时产生的输出如图64所示。可能的执行顺序如图65所示。
图64显示,正如预期的那样,终端上显示的字符串中没有损坏。 随机排序是任务使用的随机延迟时间的结果。
图65展示了使用互斥锁提供互斥的潜在陷阱之一。 所描绘的执行顺序显示,较高优先级的任务2必须等待较低优先级的任务1放弃对互斥锁的控制。 以这种方式被较低优先级任务延迟的较高优先级任务称为“优先级倒置”。 如果在高优先级任务正在等待信号量时开始执行中优先级任务,则这种不期望的行为将进一步被夸大-结果将是高优先级任务在等待低优先级任务,而没有低优先级任务甚至能够 执行。 这种最坏的情况如图66所示。
优先级倒置可能是一个重要的问题,但是在小型嵌入式系统中,通常可以通过考虑如何访问资源来避免在系统设计时出现这种情况。
FreeRTOS互斥量和二进制信号量非常相似-区别在于互斥量包括基本的“优先级继承”机制,而二进制信号量则不。 优先级继承是一种使优先级反转的负面影响最小化的方案。 它不会“修复”优先级反转,而只是通过确保反转始终受时间限制来减轻其影响。 但是,优先级继承使系统时序分析复杂化,并且依靠它来正确地进行系统操作不是一个好习惯。
优先级继承是通过将互斥量持有者的优先级暂时提高到尝试获取同一互斥量的最高优先级任务的优先级来进行的。 保持互斥锁的低优先级任务“继承”等待互斥锁的任务的优先级。 如图67所示。互斥量持有者的优先级在将互斥量还给他人时自动重设为其原始值。
如前所述,优先级继承功能会影响使用互斥锁的任务的优先级。 因此,不得在中断服务程序中使用互斥锁。
“死锁”是使用互斥互斥的另一个潜在陷阱。 死锁有时也以更具戏剧性的名称“致命拥抱”而闻名。
当两个任务因为它们都在等待另一个任务所占用的资源而无法继续进行时,就会发生死锁。 请考虑以下情形,其中任务A和任务B都需要获取互斥量X和互斥量Y才能执行操作:
在这种情况下,任务A等待任务B持有的互斥锁,任务B等待任务A持有的互斥锁。由于没有任务可以继续进行,因此发生了死锁。
与优先级反转一样,避免死锁的最佳方法是在设计时考虑其潜在可能性,并设计系统以确保不会发生死锁。 特别是,正如本书先前所述,对于任务而言,无限期地等待(没有超时)以获得互斥体通常是一种不好的做法。 相反,请使用比等待互斥锁所需的最大时间稍长的超时时间,然后在该时间内无法获取互斥锁将是设计错误的征兆,这可能是死锁。
实际上,死锁在小型嵌入式系统中不是大问题,因为系统设计人员可以很好地了解整个应用程序,因此可以识别并消除可能发生死锁的区域。
任务本身也可能死锁。 如果任务尝试多次使用同一互斥对象而没有先返回互斥对象,则会发生这种情况。 请考虑以下情形:
在这种情况下,任务处于“已阻止”状态以等待互斥量返回,但是该任务已经是互斥量持有者。 由于任务处于“已阻止”状态以等待其自身而发生了死锁。
可以通过使用递归互斥锁代替标准互斥锁来避免这种类型的死锁。 递归互斥锁可以由同一任务多次“获取”,并且仅在对“获取”递归互斥锁的每次调用都执行了一次“给定”递归互斥锁调用之后,才会返回。
标准互斥锁和递归互斥锁以类似的方式创建和使用:
清单124展示了如何创建和使用递归互斥体。
如果两个优先级不同的任务使用相同的互斥锁,则FreeRTOS调度策略将使任务执行的顺序清晰明了。 能够运行的优先级最高的任务将被选择为进入“运行”状态的任务。 例如,如果高优先级任务处于“阻止”状态以等待低优先级任务持有的互斥量,则低优先级任务返回互斥量后,高优先级任务将抢占低优先级任务 。 高优先级的任务将成为互斥体持有者。 在图67中已经看到了这种情况。
但是,通常会在任务具有相同优先级时对任务执行的顺序做出错误的假设。 如果任务1和任务2具有相同的优先级,并且任务1处于“已阻止”状态以等待任务2持有的互斥量,则当任务2“给予”互斥量时,任务1不会抢占任务2。 而是,任务2将保持在“运行”状态,而任务1将仅从“已阻止”状态转换为“就绪”状态。 图68中显示了这种情况,其中垂直线标记了滴答中断发生的时间。
在图68所示的场景中,一旦互斥锁可用,FreeRTOS调度程序就不会使任务1成为运行状态任务,因为:
任务1和任务2具有相同的优先级,因此,除非任务2进入阻止状态,否则在下一个滴答中断(假设FreeRTOSConfig.h中configUSE_TIME_SLICING设置为1)之前,不应切换到任务1。
如果任务在紧密循环中使用互斥锁,并且每次任务“放弃”互斥锁时都发生了上下文切换,则该任务只会在很短的时间内保持运行状态。 如果两个或多个任务在紧密循环中使用相同的互斥锁,则通过在任务之间快速切换会浪费处理时间。
如果一个互斥锁在多个任务中使用紧密循环,并且使用该互斥锁的任务具有相同的优先级,则必须注意确保任务获得大约相等的处理时间。 图69演示了任务可能无法获得相等处理时间的原因,该图显示了如果在相同优先级下创建清单125所示的任务的两个实例,则可能会发生一系列执行。
清单125中的注释指出,创建字符串是一种快速的操作,而更新显示内容是一种缓慢的操作。 因此,由于在更新显示时保持互斥锁,因此该任务将在其大部分运行时间中保持该互斥锁。
在图69中,垂直线标记了滴答中断发生的时间。
图69中的步骤7显示了任务1重新进入Blocked状态,该状态发生在xSemaphoreTake()API函数内部。
图69表明,直到时间片的开始与任务2不是互斥体持有者的较短时间之一一致之前,将阻止任务1获得互斥体。
通过在对xSemaphoreGive()的调用之后添加对taskYIELD()的调用,可以避免出现图69所示的情况。 清单126演示了这一点,其中如果在任务保持互斥锁的同时滴答计数发生变化,则将调用taskYIELD()。
守门人任务提供了一种实现相互排斥的干净方法,而没有优先级倒置或死锁的风险。
守门人任务是唯一拥有资源所有权的任务。 仅允许守门人任务直接访问资源,其他任何需要访问资源的任务都只能通过使用守门人的服务间接进行访问。
示例21提供了vPrintString()的另一种替代实现。 这次,使用守门人任务来管理对标准输出的访问。 当任务想要将消息写成标准输出时,它不会直接调用打印功能,而是将消息发送到守门人。
守门人任务使用FreeRTOS队列来序列化对标准输出的访问。 任务的内部实现不必考虑互斥,因为它是唯一允许直接访问标准的任务。
守门人任务将其大部分时间花费在“阻塞”状态,等待消息到达队列。 当消息到达时,守门人仅将消息写为标准输出,然后返回“阻止”状态以等待下一条消息。 清单128显示了守门人任务的实现。
中断可以发送到队列,因此中断服务例程也可以安全地使用守门人的服务来向终端写入消息。 在此示例中,使用滴答钩函数每200个滴答写出一条消息。
滴答钩子(或滴答回调)是内核在每个滴答中断期间调用的函数。 要使用滴答钩子功能:
滴答钩函数在滴答中断的上下文中执行,因此必须保持非常短,必须仅使用适量的堆栈空间,并且不得调用任何不以“ FromISR()”结尾的FreeRTOS API函数。
调度程序将始终在滴答钩函数之后立即执行,因此从滴答钩子调用的中断安全FreeRTOS API函数无需使用其pxHigherPriorityTaskWoken参数,并且可以将该参数设置为NULL。
清单129中显示了写入队列的任务。像以前一样,创建了任务的两个单独的实例,并且使用task参数将任务写入队列的字符串传递给任务。
滴答钩函数会对调用的次数进行计数,每次计数达到200时都会将其消息发送到守门人任务。仅出于演示目的,滴答钩会写到队列的前面,而任务会写回后面 的队列。 清单130中显示了滴答钩实现。
通常,main()创建运行示例所需的队列和任务,然后启动调度程序。 清单131显示了main()的实现。
执行示例21时产生的输出如图70所示。可以看出,源自任务的字符串和源自中断的字符串都可以正确打印而不会损坏。
给守门人任务分配的优先级比打印任务的优先级低,因此发送到守门人的消息将保留在队列中,直到两个打印任务都处于“阻止”状态。 在某些情况下,应该为守门人分配更高的优先级,以便立即处理消息,但是这样做的代价是守门人延迟了低优先级的任务,直到它完成对受保护资源的访问为止。
像宏这样的函数并没有像真正函数那样真正地“返回值”。 当最简单地将宏视为函数时,这本书将“返回值”一词应用于宏。 ↩︎