本文主要學習 FreeRTOS 互斥量的相關知識,包括優先順序翻轉問題、優先順序繼承、死鎖現象、創建/刪除互斥量 和 獲取/釋放互斥量等知識 ...
1、準備材料
STM32CubeMX軟體(Version 6.10.0)
Keil µVision5 IDE(MDK-Arm)
2、學習目標
本文主要學習 FreeRTOS 互斥量的相關知識,包括優先順序翻轉問題、優先順序繼承、死鎖現象、創建/刪除互斥量 和 獲取/釋放互斥量等知識
3、前提知識
3.1、優先順序翻轉問題
使用二值信號量用於進程間同步時可能會出現優先順序翻轉的問題,什麼是“優先順序翻轉”問題呢?考慮如下所述的任務運行過程
- 在 t1 時刻,低優先順序的任務 TaskLP 切入運行狀態,並且獲取到了一個二值信號量 Binary Semaphores
- 在 t2 時刻,高優先順序的任務 TaskHP 請求獲取二值信號量 Binary Semaphores ,但是由於 TaskLP 還未釋放該二值信號量,所以在 t3 時刻,任務 TaskHP 進入阻塞狀態等待二值信號量被釋放
- 在 t4 時刻,中等優先順序的任務 TaskMP 進入就緒狀態,由於不需要獲取二值信號量,因此搶占低優先順序任務任務 TaskLP 切入運行狀態
- 在 t5 時刻,任務 TaskMP 運行結束,任務 TaskLP 再次切入運行狀態
- 在 t6 時刻,任務 TaskLP 運行結束,釋放二值信號量 Binary Semaphores,此時任務 TaskHP 從等待二值信號量的阻塞狀態切入運行狀態
- 在t7時刻,任務 TaskHP 運行結束
根據上述流程讀者可以發現一個問題,即在 t4 時刻中等優先順序的任務 TaskMP 先於高優先順序的任務 TaskHP 搶占了處理器,這破壞了 FreeRTOS 基於優先順序搶占式執行的原則,我們將這種情況稱為優先順序翻轉問題,上述描述的任務運行過程具體時刻流程圖如下圖所示
優先順序翻轉可能是一個嚴重的問題,但在小型嵌入式系統中,通常可以在系統設計時通過考慮如何訪問資源來避免該問題
3.2、優先順序繼承
為瞭解決使用二值信號量可能會出現的優先順序翻轉問題,對二值信號量做了改進,增加了一種名為 “優先順序繼承” 的機制,改進後的實例稱為了互斥量,註意雖然互斥量可以減緩優先順序翻轉問題的出現,但是並不能完全杜絕
接下來我們來通過例子介紹什麼是優先順序繼承?
仍然考慮由 “3.1、優先順序翻轉問題” 小節中提出的任務運行過程的例子,具體流程如下所述,讀者可以細心理解其中的不同之處
- 在 t1 時刻,低優先順序的任務 TaskLP 切入運行狀態,並且獲取到了一個互斥量 Mutexes
- 在 t2 時刻,高優先順序的任務 TaskHP 請求獲取互斥量 Mutexes ,但是由於 TaskLP 還未釋放該互斥量,所以在 t3 時刻,任務 TaskHP 進入阻塞狀態等待互斥量被釋放,但是與二值信號量不同的是,此時 FreeRTOS 將任務 TaskLP 的優先順序臨時提高到與任務 TaskHP 一致的優先順序,也即高優先順序
- 在 t4 時刻,中等優先順序的任務 TaskMP 進入就緒狀態發生任務調度,但是由於任務 TaskLP 此時優先順序被提高到了高優先順序,因此任務 TaskMP 仍然保持就緒狀態等待優先順序較高的任務執行完畢
- 在 t5 時刻,任務 TaskLP 執行完畢釋放互斥量 Mutexes,此時任務 TaskHP 搶占處理器切入運行狀態,並恢復任務 TaskLP 原來的優先順序
- 在 t6 時刻,任務 TaskHP 執行完畢,此時輪到任務 TaskMP 執行
- 在 t7 時刻,任務 TaskMP 運行結束
根據互斥量的上述任務流程讀者可以發現與二值信號量的不同之處,上述描述的任務運行過程具體時刻流程圖如下圖所示
3.3、什麼是互斥量?
互斥量/互斥鎖是一種特殊類型的二進位信號量,用於控制對在兩個或多個任務之間共用資源的訪問
互斥鎖可以被視為一個與正在共用的資源相關聯的令牌,對於合法訪問資源的任務,它必須首先成功 “獲取” 令牌,成為資源的持有者,當持有者完成對資源的訪問之後,其需要 ”歸還” 令牌,只有 “歸還” 令牌之後,該令牌才可以再次被其他任務所 “獲取” ,這樣保證了互斥的對共用資源的訪問,上述機制如下圖所示 (註釋1)
3.4、死鎖現象
“死鎖” 是使用互斥鎖進行互斥的另一個潛在陷阱,當兩個任務因為都在等待對方占用的資源而無法繼續進行時,就會發生死鎖,考慮如下所述的情況
- 任務 A 執行併成功獲取互斥量 X
- 任務 A 被任務 B 搶占
- 任務 B 在嘗試獲取互斥量 X 之前成功獲取互斥量 Y,但互斥量 X 由任務 A 持有,因此對任務 B 不可用,任務 B 選擇進入阻塞狀態等待互斥量 X 被釋放
- 任務 A 繼續執行,它嘗試獲取互斥量 Y,但互斥量 Y 由任務 B 持有,所以對於任務 A 來說是不可用的,任務 A 選擇進入阻塞狀態等待待釋放的互斥量 Y
經過上述的這樣一個過程,讀者可以發現任務 A 在等待任務 B 釋放互斥量 Y ,而任務 B 在等待任務 A 釋放互斥量 X ,兩個任務都在阻塞狀態無法執行,從而導致 ”死鎖“ 現象的發生,與優先順序翻轉一樣,避免 “死鎖” 的最佳方法是在設計時考慮其潛在影響,並設計系統以確保不會發生死鎖
3.5、什麼是遞歸互斥量?
任務也有可能與自身發生死鎖,如果任務嘗試多次獲取相同的互斥體而不首先返回互斥體,就會發生這種情況,考慮以下設想:
- 任務成功獲取互斥鎖
- 在持有互斥體的同時,任務調用庫函數
- 庫函數的實現嘗試獲取相同的互斥鎖,併進入阻塞狀態等待互斥鎖變得可用
在此場景結束時,任務處於阻塞狀態以等待互斥體返回,但任務已經是互斥體持有者。 由於任務處於阻塞狀態等待自身,因此發生了死鎖
通過使用遞歸互斥體代替標準互斥體可以避免這種類型的死鎖,同一任務可以多次 “獲取” 遞歸互斥鎖,並且只有在每次 “獲取” 遞歸互斥鎖之後都調用一次 “釋放” 遞歸互斥鎖,才會返回該互斥鎖
因此遞歸互斥量可以視為特殊的互斥量,一個互斥量被一個任務獲取之後就不能再次獲取,其他任務想要獲取該互斥量必須等待這個任務釋放該互斥連,但是遞歸互斥量可以被一個任務重覆獲取多次,當然每次獲取必須與一次釋放配對使用
註意不管是互斥量,還是遞歸互斥量均存在優先順序繼承機制,但是由於 ISR 並不是任務,因此互斥量和遞歸互斥量不能在中斷中使用
3.5、創建互斥量
互斥量在使用之前必須先創建,因為互斥量分為互斥量和遞歸互斥量兩種,所以 FreeRTOS 也提供了不同的 API 函數,具體如下所述
/**
* @brief 動態分配記憶體創建互斥信號量函數
* @retval 創建互斥信號量的句柄
*/
SemaphoreHandle_t xSemaphoreCreateMutex(void);
/**
* @brief 靜態分配記憶體創建互斥信號量函數
* @param pxMutexBuffer:指向StaticSemaphore_t類型的變數,該變數將用於保存互斥鎖型信號量的狀態
* @retval 返回成功創建後的互斥鎖的句柄,如果返回NULL則表示記憶體不足創建失敗
*/
SemaphoreHandle_t xSemaphoreCreateMutexStatic(StaticSemaphore_t *pxMutexBuffer);
/**
* @brief 動態分配記憶體創建遞歸互斥信號量函數
* @retval 創建遞歸互斥信號量的句柄,如果返回NULL則表示記憶體不足創建失敗
*/
SemaphoreHandle_t xSemaphoreCreateRecursiveMutex(void);
/**
* @brief 動態分配記憶體創建二值信號量函數
* @param pxMutexBuffer:指向StaticSemaphore_t類型的變數,該變數將用於保存互斥鎖型信號量的狀態
*/
SemaphoreHandle_t xSemaphoreCreateRecursiveMutex(
StaticSemaphore_t pxMutexBuffer);
3.6、獲取互斥量
獲取互斥量直接使用獲取信號量的函數即可,但對於遞歸互斥量需要專門的獲取函數,具體如下所述
/**
* @brief 獲取信號量函數
* @param xSemaphore:正在獲取的信號量的句柄
* @param xTicksToWait:等待信號量變為可用的時間
* @retval 成功獲取信號量則返回pdTRUE, xTicksToWait過期,信號量不可用,則返回pdFALSE
*/
BaseType_t xSemaphoreTake(SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait);
/**
* @brief 獲取遞歸互斥量
* @param xMutex:正在獲得的互斥鎖的句柄
* @param xTicksToWait:等待信號量變為可用的時間
* @retval 成功獲取信號量則返回pdTRUE, xTicksToWait過期,信號量不可用,則返回pdFALSE
*/
BaseType_t xSemaphoreTakeRecursive(SemaphoreHandle_t xMutex,
TickType_t xTicksToWait);
3.7、釋放互斥量
釋放互斥量直接使用釋放信號量的函數即可,但對於遞歸互斥量需要專門的釋放函數,具體如下所述
/**
* @brief 釋放信號量函數
* @param xSemaphore:要釋放的信號量的句柄
* @retval 成功釋放信號量則返回pdTRUE, 若發生錯誤,則返回pdFALSE
*/
BaseType_t xSemaphoreGive(SemaphoreHandle_t xSemaphore);
/**
* @brief 釋放遞歸互斥量
* @param xMutex:正在釋放或“給出”的互斥鎖的句柄
* @retval 成功釋放遞歸互斥量後返回pdTRUE
*/
BaseType_t xSemaphoreGiveRecursive(SemaphoreHandle_t xMutex);
3.8、刪除互斥量
直接使用信號量的刪除函數即可,具體如下所述
/**
* @brief 獲取信號量函數
* @param xSemaphore:要刪除的信號量的句柄
* @retval None
*/
void vSemaphoreDelete(SemaphoreHandle_t xSemaphore);
4、實驗一:優先順序翻轉問題
4.1、實驗目標
既然實驗是討論優先順序翻轉問題,那麼我們來複現 “3.1、優先順序翻轉問題” 小節中所描述到的任務運行過程,具體如下所述
- 創建一個二值信號量 BinarySem_PI,用於演示優先順序翻轉問題
- 創建一個低優先順序任務 Task_Low ,在該任務中獲取二值信號量 BinarySem_PI ,並通過延時模擬長時間連續運行,運行結束後釋放該二值信號量,整個過程會通過串口輸出提示信息
- 創建一個中等優先順序任務 Task_Middle,該任務負責在 Task_Low 模擬長時間連續運行期間搶占其處理器控制許可權
- 創建一個高優先順序任務 Task_High,該任務總是嘗試獲取二值信號量 BinarySem_PI
4.2、CubeMX相關配置
首先讀者應按照"FreeRTOS教程1 基礎知識"章節配置一個可以正常編譯通過的 FreeRTOS 空工程,然後在此空工程的基礎上增加本實驗所提出的要求
本實驗需要初始化 USART1 作為輸出信息渠道,具體配置步驟請閱讀“STM32CubeMX教程9 USART/UART 非同步通信”,如下圖所示
單擊 Middleware and Software Packs/FREERTOS,在 Configuration 中單擊 Tasks and Queues 選項卡雙擊預設任務修改其參數,然後增加另外兩個不同優先順序的任務,具體如下圖所示
然後在 Configuration 中單擊 Timers and Semaphores ,在 Binary Semaphores 中單擊 Add 按鈕新增加一個名為 BinarySem_PI 的二值信號量,具體如下圖所示
配置 Clock Configuration 和 Project Manager 兩個頁面,接下來直接單擊 GENERATE CODE 按鈕生成工程代碼即可
4.3、添加其他必要代碼
按照 “STM32CubeMX教程9 USART/UART 非同步通信” 實驗 “6、串口printf重定向” 小節增加串口 printf 重定向代碼,具體不再贅述
首先應該在 freertos.c 中添加信號量相關 API 和 printf() 函數的頭文件,如下所述
/*freertos.c中添加頭文件*/
#include "semphr.h"
#include "stdio.h"
然後在該文件中實現三個不同優先順序的任務,主要是一些串口輸出給用戶的提示信息,方便演示實驗目的,具體如下所述
/*低優先順序任務*/
void AppTask_Low(void *argument)
{
/* USER CODE BEGIN AppTask_Low */
/* Infinite loop */
uint8_t str1[]="Task_Low take it\r\n";
uint8_t str2[]="Task_Low give it\r\n";
uint8_t str3[]="return Task_Low\r\n";
for(;;)
{
//獲取信號量
if(xSemaphoreTake(BinarySem_PIHandle, pdMS_TO_TICKS(200))==pdTRUE)
{
printf("%s",str1);
//模擬任務連續運行
HAL_Delay(500);
printf("%s",str3);
HAL_Delay(500);
printf("%s",str2);
//釋放信號量
xSemaphoreGive(BinarySem_PIHandle);
}
}
/* USER CODE END AppTask_Low */
}
/*中等優先順序任務*/
void AppTask_Middle(void *argument)
{
/* USER CODE BEGIN AppTask_Middle */
/* Infinite loop */
uint8_t strMid[]="Task_Middle is running\r\n";
for(;;)
{
printf("%s", strMid);
vTaskDelay(500);
}
/* USER CODE END AppTask_Middle */
}
/*高優先順序任務*/
void AppTask_High(void *argument)
{
/* USER CODE BEGIN AppTask_High */
/* Infinite loop */
uint8_t strHigh1[]="Into Task_High\r\n";
uint8_t strHigh2[]="Task_High get token\r\n";
uint8_t strHigh3[]="Task_High give token\r\n";
for(;;)
{
printf("%s",strHigh1);
//獲取信號量
if(xSemaphoreTake(BinarySem_PIHandle, portMAX_DELAY)==pdTRUE)
{
printf("%s",strHigh2);
printf("%s",strHigh3);
//釋放信號量
xSemaphoreGive(BinarySem_PIHandle);
}
vTaskDelay(500);
}
/* USER CODE END AppTask_High */
}
在 "FreeRTOS教程5 信號量" 文章 ”3.2、創建信號量“ 小節中曾提到,信號量被創建完之後是無效的,但是這裡我們需要讓剛創建的二值信號量有效,否則 Task_High 和 Task_Low 都將無法獲取二值信號量,因此最後修改二值信號量的初始值為 1 即可,具體如下所示
/*將初始值0修改為1*/
BinarySem_PIHandle = osSemaphoreNew(1, 1, &BinarySem_PI_attributes);
4.4、燒錄驗證
燒錄程式,打開串口助手,按住開發板複位按鍵,目的是為了讓串口助手接收程式從最開始輸出的信息,這裡我們只分析第一輪,因為延時、語句執行等微小的時間差異會導致第二輪任務進入阻塞和退出阻塞的時間與第一輪有差異,如下所述為第一輪詳細的任務執行流程
- 當創建完三個不同優先順序的任務後不會立即得到執行,而是進入就緒狀態等待調度器的啟動
- 當調度器啟動之後會按照優先順序從最高優先順序開始執行,因此串口輸出 “Into Task_High” 表示進入高優先順序任務,然後在高優先順序任務 Task_High 中獲得二值信號量,然後立馬釋放二值信號量,最後進入 500ms 的阻塞狀態
- 當高優先順序任務進入阻塞狀態後,接下來會執行就緒狀態的中等優先順序任務 Task_Middle ,該任務無具體功能,僅僅通過串口輸出 “Task_Middle is running”,然後同樣進入 500ms 的阻塞狀態
- 由於高優先順序和中等優先順序任務都進入阻塞狀態,這時才輪到低優先順序任務 Task_Low 執行,低優先順序任務 Task_Low 成功獲取到二值信號量並通過串口輸出 “Task_Low take it” ,然後利用 500ms 的 HAL 庫延時函數模擬連續運行
- 在 Task_Low 連續運行期間,在其即將執行完第一個 HAL_Delay(500); 時,高優先順序任務 Task_High 從 500ms 的阻塞狀態恢復,然後嘗試獲取已經被 Task_Low 獲取的二值信號量,結果就是進入阻塞狀態等待 Task_Low 釋放二值信號量
- 緊接著 Task_Middle 從 500ms 的阻塞狀態恢復,通過串口輸出 “Task_Middle is running”,接著再次進入 500ms 阻塞狀態
- 由於高優先順序和中等優先順序任務再次進入阻塞狀態,因此調度器返回 Task_Low 被搶占時的程式處繼續執行,因此 Task_Low 通過串口輸出 “return Task_Low” ,然後利用第二個 HAL_Delay(500); 繼續模擬長時間運行
- 在 Task_Low 第二個 HAL_Delay(500); 即將執行完畢時,Task_Middle 再次從 500ms 的阻塞狀態恢復,通過串口輸出 “Task_Middle is running” ,然後再次進入 500ms 阻塞狀態(這裡 Task_High 由於不是因為延時進入的阻塞狀態所以未恢復運行狀態)
- 最後返回 Task_Low 任務,釋放二值信號量,一旦 Task_Low 任務釋放二值信號量,等待二值信號量的高優先順序任務 Task_High 會立馬退出阻塞狀態成功獲取到二值信號量,並會通過串口輸出 “Task_High get token“
從上述過程可知,從 Task_Low 獲取二值信號量之後到第一輪結束,Task_High 等待 Task_Low 釋放二值信號量,等待期間中等優先順序的任務 Task_Middle 卻先於高優先順序任務 Task_High 得到了執行,這就是所謂的優先順序翻轉問題,上述過程所述的實際串口輸出如下圖所示
4.5、互斥量的應用
首先在 STM32CubeMX 中單擊 Middleware and Software Packs/FREERTOS,在 Configuration 中單擊 Mutexes 選項卡,單擊 Add 按鈕增加互斥量 Mutex_PI ,具體如下圖所示
然後將上述實驗使用的所有二值信號量句柄 BinarySem_PIHandle 修改為互斥量 Mutex_PIHandle,不需要做其他任何操作,燒錄程式即可
打開串口助手,觀察串口助手的輸出,如下所述為第一輪詳細的任務執行流程
- 前4個步驟與 ”4.4、燒錄驗證“ 小節一致,只不過從二值信號量修改為互斥量
- 在第5步時,高優先順序任務 Task_High 從 500ms 的阻塞狀態恢復,輸出 ”Into Task_High“ ,然後嘗試獲取已經被 Task_Low 獲取的互斥量,結果就是進入阻塞狀態等待 Task_Low 釋放互斥量,同時將 Task_Low 的優先順序臨時提高到和高優先順序任務 Task_High 一樣的優先順序
- 緊接著 Task_Middle 從 500ms 的阻塞狀態恢復,但是由於現在 Task_Low 任務的優先順序要高於中等優先順序任務 Task_Middle ,因此不能搶占 Task_Low 任務,故無法執行任務體輸出 ”Task_Middle is running“ ,所以其狀態變為就緒狀態,它將等待所有高優先順序的任務執行完後才會執行
- 於是優先順序被臨時提高到高優先順序的任務 Task_Low 繼續執行其函數體內容,輸出 ”return Task_Low“ ,然後執行第二個 HAL_Delay(500); ,最後釋放互斥量,通過串口輸出 ”Task_Low give it“
- 一旦互斥量被 Task_Low 釋放,處於阻塞狀態的 Task_High 就會立馬恢復運行狀態獲取到互斥量,所以會通過串口輸出 ”Task_High get token“ 和 ”Task_High give token“ ,同時當互斥量被 Task_High 任務成功獲取之後,會將任務 Task_Low 臨時提高的優先順序恢復到其原來的低優先順序,最後 Task_High 調用延時函數進入 500ms 的阻塞狀態
- 當高優先順序任務 Task_High 進入阻塞狀態後,系統內現在剩餘就緒狀態的中等優先順序任務 Task_Middle 和 低優先順序任務 Task_Low ,所以輪到 Task_Middle 任務執行,其將通過串口輸出 ”Task_Middle is runing“ ,至此一輪結束
讀者可以自行對比將二值信號量更換為互斥量之後的串口輸出結果,可以發現在步驟4中,中等優先順序的任務 Task_Middle 不再先於高優先順序的任務 Task_High 得到執行,上述整個過程串口數據的完整輸出如下圖所示
5、註釋詳解
註釋1:圖片來源於 Mastering_the_FreeRTOS_Real_Time_Kernel-A_Hands-On_Tutorial_Guide.pdf
參考資料
Mastering_the_FreeRTOS_Real_Time_Kernel-A_Hands-On_Tutorial_Guide.pdf