0、思考與回答 0.1、思考一 對於 Cortex-M4 內核的 MCU 在發生異常/中斷時,哪些寄存器會自動入棧,哪些需要手動入棧? 會自動入棧的寄存器如下 R0 - R3:通用寄存器 R12:通用寄存器 LR (Link Register):鏈接寄存器,保存返回地址 PC (Program Co ...
0、思考與回答
0.1、思考一
對於 Cortex-M4 內核的 MCU 在發生異常/中斷時,哪些寄存器會自動入棧,哪些需要手動入棧?
會自動入棧的寄存器如下
- R0 - R3:通用寄存器
- R12:通用寄存器
- LR (Link Register):鏈接寄存器,保存返回地址
- PC (Program Counter):程式計數器,保存當前執行指令的地址
- xPSR (Program Status Register):程式狀態寄存器
需要手動入棧的寄存器如下
- R4 - R11:其他通用寄存器
0.2、思考二
這些入棧的寄存器是怎麼找到要入棧的地址的呢?
依靠堆棧指針來確定入棧的地址,Cortex-M4 的堆棧指針寄存器 SP 在同一物理位置上有 MSP 和 PSP 兩個堆棧指針,預設情況下會使用主堆棧指針 MSP
0.3、思考三
自動入棧的寄存器什麼時候入棧,什麼時候出棧恢複原來的處理器狀態?
進入異常處理時會發生自動入棧,當異常處理完成並執行異常返回指令(bx r14)時,處理器會自動從堆棧中彈出這些寄存器的值
1、任務控制塊
通常使用名為任務控制塊(TCB)的結構體來對每個任務進行管理,該結構體中包含了任務管理所需要的一些重要成員,其中棧頂指針 pxTopOfStack
作為結構體的第一個成員,其地址也即任務控制塊的地址,具體如下所示
/* task.h */
// 任務控制塊
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; // 棧頂
ListItem_t xStateListItem; // 任務節點
StackType_t *pxStack; // 任務棧起始地址
char pcTaskName[configMAX_TASK_NAME_LEN]; // 任務名稱
}tskTCB;
typedef tskTCB TCB_t;
configMAX_TASK_NAME_LEN
是一個巨集,用於設置任務名稱長度,具體定義如下
/* FreeRTOSConfig.h */
// 任務名稱字元串長度
#define configMAX_TASK_NAME_LEN 24
2、創建任務
2.1、xTaskCreateStatic( )
靜態創建任務時需要指定任務棧(地址和大小)、任務函數、任務控制塊等參數,該函數就是負責將這些分散的變數聯繫在一起,方便後續對任務進行管理,但是其並不是真正的創建任務的函數,具體如下所示
/* task.c */
// 靜態創建任務函數
#if (configSUPPORT_STATIC_ALLOCATION == 1)
TaskHandle_t xTaskCreateStatic(TaskFunction_t pxTaskCode, // 任務函數
const char* const pcName, // 任務名稱
const uint32_t ulStackDepth, // 任務棧深度
void* const pvParameters, // 任務參數
StackType_t* const puxTaskBuffer, // 任務棧起始指針
TCB_t* const pxTaskBuffer) // 任務棧控制指針
{
TCB_t* pxNewTCB;
TaskHandle_t xReturn;
// 任務棧控制指針和任務棧起始指針不為空
if((pxTaskBuffer != NULL) && (puxTaskBuffer != NULL))
{
pxNewTCB = (TCB_t*)pxTaskBuffer;
// 將任務控制塊的 pxStack 指針指向任務棧起始地址
pxNewTCB->pxStack = (StackType_t*)puxTaskBuffer;
// 真正的創建任務函數
prvInitialiseNewTask(pxTaskCode,
pcName,
ulStackDepth,
pvParameters,
&xReturn,
pxNewTCB);
}
else
{
xReturn = NULL;
}
// 任務創建成功後應該返回任務句柄,否則返回 NULL
return xReturn;
}
#endif
/* task.h */
// 函數聲明
TaskHandle_t xTaskCreateStatic(TaskFunction_t pxTaskCode,
const char* const pcName,
const uint32_t ulStackDepth,
void* const pvParameters,
StackType_t* const puxTaskBuffer,
TCB_t* const pxTaskBuffer);
configSUPPORT_STATIC_ALLOCATION
是一個巨集,用於配置和裁剪 RTOS 靜態創建任務的功能,具體定義如下所示
/* FreeRTOSConfig.h */
// 是否支持靜態方式創建任務
#define configSUPPORT_STATIC_ALLOCATION 1
TaskHandle_t
是一個 void *
類型的變數,用於表示任務句柄,TaskFunction_t
是一個函數指針,用於表示任務函數,具體定義如下所示
/* task.h */
// 任務句柄指針
typedef void* TaskHandle_t;
// 任務函數指針
typedef void (*TaskFunction_t)(void *);
2.2、prvInitialiseNewTask( )
函數 xTaskCreateStatic()
最終調用了真正的創建任務函數 prvInitialiseNewTask()
,該函數主要是對任務棧記憶體、任務控制塊成員等進行初始化,具體如下所示
/* task.c */
// 使用的外部函數聲明
extern StackType_t* pxPortInitialiseStack(StackType_t* pxTopOfStack,
TaskFunction_t pxCode,
void* pvParameters);
// 真正的創建任務函數
static void prvInitialiseNewTask(TaskFunction_t pxTaskCode, // 任務函數
const char* const pcName, // 任務名稱
const uint32_t ulStackDepth, // 任務棧深度
void* const pvParameters, // 任務參數
TaskHandle_t* const pxCreatedTask, // 任務句柄
TCB_t* pxNewTCB) // 任務棧控制指針
{
StackType_t *pxTopOfStack;
UBaseType_t x;
// 棧頂指針,用於指向分配的任務棧空間的最高地址
pxTopOfStack = pxNewTCB->pxStack + (ulStackDepth - (uint32_t)1);
// 8 位元組對齊
pxTopOfStack = (StackType_t*)(((uint32_t)pxTopOfStack)
& (~((uint32_t)0x0007)));
// 保存任務名稱到TCB中
for(x = (UBaseType_t)0;x < (UBaseType_t)configMAX_TASK_NAME_LEN;x++)
{
pxNewTCB->pcTaskName[x] = pcName[x];
if(pcName[x] == 0x00)
break;
}
pxNewTCB->pcTaskName[configMAX_TASK_NAME_LEN-1] = '\0';
// 初始化鏈表項
vListInitialiseItem(&(pxNewTCB->xStateListItem));
// 設置該鏈表項的擁有者為 pxNewTCB
listSET_LIST_ITEM_OWNER(&(pxNewTCB->xStateListItem), pxNewTCB);
// 初始化任務棧
pxNewTCB->pxTopOfStack =
pxPortInitialiseStack(pxTopOfStack, pxTaskCode, pvParameters);
if((void*)pxCreatedTask != NULL)
{
*pxCreatedTask = (TaskHandle_t)pxNewTCB;
}
}
2.3、pxPortInitialiseStack( )
初始化任務棧函數,記憶體具體被初始化為什麼樣可以閱讀 "2.4 任務記憶體詳解" 小節
/* port.c */
// 錯誤出口
static void prvTaskExitError(void)
{
for(;;);
}
// 初始化棧記憶體
StackType_t* pxPortInitialiseStack(StackType_t* pxTopOfStack,
TaskFunction_t pxCode,
void* pvParameters)
{
// 異常發生時,自動載入到CPU的內容
pxTopOfStack --;
*pxTopOfStack = portINITIAL_XPSR;
pxTopOfStack --;
*pxTopOfStack = ((StackType_t)pxCode) & portSTART_ADDRESS_MASK;
pxTopOfStack --;
*pxTopOfStack = (StackType_t)prvTaskExitError;
// r12、r3、r2 和 r1 預設初始化為 0
pxTopOfStack -= 5;
*pxTopOfStack = (StackType_t)pvParameters;
// 異常發生時,手動載入到 CPU 的內容
pxTopOfStack -= 8;
// 返回棧頂指針,此時 pxTopOfStack 指向空閑棧
return pxTopOfStack;
}
/* portMacro.h */
#define portINITIAL_XPSR (0x01000000)
#define portSTART_ADDRESS_MASK ((StackType_t) 0xFFFFFFFEUL)
2.4、任務記憶體詳解
使用靜態方式創建任務之前需要提前定義好任務句柄、任務棧空間、任務控制塊和任務函數,具體如下程式所示
// 任務句柄
TaskHandle_t Task_Handle;
// 任務棧大小
#define TASK_STACK_SIZE 128
// 任務棧空間
StackType_t TaskStack[TASK_STACK_SIZE];
// 任務控制塊
TCB_t TaskTCB;
// 任務函數
void TaskFunction(void *parg)
{
for(;;)
{
}
}
定義好這些變數之後都會在 RAM 中占用一定的空間,其中任務控制塊 TaskTCB
的地址和結構體第一個成員 pxTopOfStack
是同一個地址,假設這些變數在空間中的占用情況如下圖所示
將這些定義好的變數作為參數傳遞給靜態創建任務函數 xTaskCreateStatic()
,具體如下程式所示
// 靜態方式創建任務
Task_Handle = xTaskCreateStatic(TaskFunction,
"Task",
128,
TaskParameters,
TaskStack,
TaskTCB);
該函數執行完畢之後,最終這些變數在空間中的占用情況如下圖所示
為什麼要按照順序這麼存放?
因為在 ARM Cortex-M 處理器中,當異常發生時硬體會自動按照上圖的順序將寄存器推入當前的棧中,同時退出異常時也會按照其相反的順序從當前棧中取值載入到 MCU 寄存器中
為什麼任務棧頂 PSR 的值為 0x01000000 ?
PSR
是程式狀態寄存器,對於異常來說具體的寄存器為 EPSR
,其第 24 位為 Thumb 狀態位(T位),在 ARM Cortex-M 架構中,所有代碼都必須在 Thumb 模式下執行,寄存器定義可以在 Cortex-M4 Devices Generic User Guide 手冊中找到,具體如下圖所示
可以發現當執行 BX
指令退出異常時(後面啟動任務 / 任務調度會頻繁使用到異常退出指令),會將 T 位清零,而一旦清零並嘗試執行指令就會導致 MCU 故障或鎖定,因此必須在異常退出自動恢復 MCU 寄存器時將 T 位重新置 1
為什麼任務棧存放任務函數入口地址的內容為 pxCode & portSTART_ADDRESS_MASK?
簡單來說這步操作其實是為了記憶體對齊,portSTART_ADDRESS_MASK
是一個巨集,pxCode & portSTART_ADDRESS_MASK
只是為了確保 pxCode 任務函數入口地址最低位置為 0
3、就緒鏈表
3.1、定義
為什麼要定義鏈表?
使用鏈表可以將各個獨立的任務鏈接起來,對於多任務時會方便任務管理
/* task.c */
// 就緒鏈表
List_t pxReadyTasksLists;
3.2、prvInitialiseTaskLists( )
鏈表創建後不能直接使用,需要調用 vListInitialise()
函數對鏈表進行初始化,由於後續會創建多個鏈表,因此將鏈表初始化操作包裝為一個函數,具體如下所示
/* task.c */
// 就緒列表初始化函數
void prvInitialiseTaskLists(void)
{
vListInitialise(&pxReadyTasksLists);
}
/* task.h */
// 函數聲明
void prvInitialiseTaskLists(void);
4、任務調度器
任務調度流程如下所示
- 啟動調度器
- vTaskStartScheduler( )
- xPortStartScheduler( )
- 啟動第一個任務
- prvStartFirstTask( )
- vPortSVCHandler( )
- 產生任務調度
- taskYIELD( )
- xPortPendSVHandler( )
- vTaskSwitchContext( )
4.1、vTaskStartScheduler( )
任務創建好了怎麼執行呢?
這就是啟動調度器函數的工作,選擇一個任務然後啟動第一個任務的執行
怎麼選擇任務?
通過一個名為 pxCurrentTCB
的 TCB_t *
類型的指針選擇要執行的任務,該指針始終指向需要運行的任務的任務控制塊
選擇哪一個?
在創建多個任務時,可以通過一些方法(比如根據任務優先順序)來選擇先執行的任務,但是這裡我們的 RTOS 內核還不支持優先順序,因此可以先手動指定要執行的第一個任務
/* task.c */
// 當前 TCB 指針
TCB_t volatile *pxCurrentTCB = NULL;
// 使用的外部函數聲明
extern BaseType_t xPortStartScheduler(void);
// 在 main.c 中定義的兩個任務聲明
extern TCB_t Task1TCB;
extern TCB_t Task2TCB;
// 啟動任務調度器
void vTaskStartScheduler(void)
{
// 手動指定第一個運行的任務
pxCurrentTCB = &Task1TCB;
// 啟動調度器
if(xPortStartScheduler() != pdFALSE)
{
// 調度器啟動成功則不會到這裡
}
}
/* task.h */
// 函數聲明
void vTaskStartScheduler(void);
4.2、xPortStartScheduler( )
vTaskStartScheduler()
函數選擇了要執行的任務,啟動第一個任務執行的重任交給了 4.2 ~ 4.4 小節的三個函數,xPortStartScheduler()
函數主要設置 PendSV 和 SysTick 的中斷優先順序為最低,然後調用了 prvStartFirstTask()
函數
/* port.c */
// 啟動調度器
BaseType_t xPortStartScheduler(void)
{
// 設置 PendSV 和 SysTick 中斷優先順序為最低
portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
// 初始化滴答定時器
// 啟動第一個任務,不再返回
prvStartFirstTask();
// 正常不會運行到這裡
return 0;
}
為什麼要設置 PendSV 和 SysTick 中斷優先順序最低?
在 RTOS 中,PendSV 和 SysTick 中斷服務函數均與任務調度有關,而任務調度優先順序需要小於外部硬體中斷的優先順序,所以要將其設置為最低優先順序
怎麼設置 PendSV 和 SysTick 中斷優先順序為最低?
這裡是直接操作寄存器的方法來設置 Cortex-M4 的 PendSV 和 SysTick 中斷優先順序的,可以在 Cortex-M4 Devices Generic User Guide 手冊中找到有關 SHPR3 寄存器的地址與其每一位的含義,具體如下圖所示
根據上圖應該很好理解程式是怎麼將 PendSV 和 SysTick 優先順序設置為 15 的(NVIC 為 4 位搶占優先順序,最低優先順序就是 15)
/* portMacro.h */
#define portNVIC_SYSPRI2_REG (*((volatile uint32_t *) 0xE000ED20))
#define portNVIC_PENDSV_PRI (((uint32_t)configKERNEL_INTERRUPT_PRIORITY) << 16UL)
#define portNVIC_SYSTICK_PRI (((uint32_t)configKERNEL_INTERRUPT_PRIORITY) << 24UL)
/* FreeRTOSConfig.h */
// 設置內核中斷優先順序(最低優先順序)
#define configKERNEL_INTERRUPT_PRIORITY 15
4.3、prvStartFirstTask( )
prvStartFirstTask
是使用彙編語言編寫的函數,我們要實現的 RTOS 內核中一共有三個函數是由彙編語言編寫的,除了這個外還有 4.4 和 4.5 兩個小節的函數,該函數實際上仍然沒有真正的啟動第一個任務,實質上是觸發了 SVC 中斷
Keil 版本
/* port.c */
// 啟動第一個任務,實際上是觸發 SVC 中斷
__asm void prvStartFirstTask(void)
{
// 8 位元組對齊
PRESERVE8
// 在 cortex-M 中,0xE000ED08 是 SCB_VTOR 這個寄存器的地址,
// 裡面放的是向量表的起始地址 ,其第一項為 __initial_sp,也即 msp 的初始地址
ldr r0,=0xE000ED08
ldr r0,[r0]
ldr r0,[r0]
// 設置主棧指針 msp 的值
msr msp,r0
// 啟用全局中斷
cpsie i
cpsie f
dsb
isb
// 調用 SVC 啟動第一個任務
svc 0
nop
nop
}
// 函數聲明
void prvStartFirstTask(void);
CLion 版本
void prvStartFirstTask(void)
{
__asm volatile
(
".align 8 \n"
"ldr r0,=0xE000ED08 \n"
"ldr r0,[r0] \n"
"ldr r0,[r0] \n"
"msr msp,r0 \n"
"cpsie i \n"
"cpsie f \n"
"dsb \n"
"isb \n"
"svc 0 \n"
"nop \n"
"nop \n"
);
}
4.4、vPortSVCHandler( )
SVC 中斷服務函數,真正執行第一個任務的函數
Keil 版本
/* port.c */
// SVC 中斷服務函數
__asm void vPortSVCHandler(void)
{
extern pxCurrentTCB;
// 8 位元組對齊
PRESERVE8
// 將 pxCurrentTCB 指針的地址載入到寄存器 r3
ldr r3,=pxCurrentTCB
// 將 pxCurrentTCB 指針指向的地址(存儲 TCB 的地址)載入到寄存器 r1
ldr r1,[r3]
// 將 pxCurrentTCB 指針指向的地址里的值(當前運行的 TCB 結構體)載入到寄存器 r0
// 也即寄存器 r0 中存儲了當前運行的任務的棧頂指針地址
ldr r0,[r1]
// 以寄存器 r0 為基地址,將任務棧中存儲的值載入到寄存器 r4 ~ r11 中
// 同時寄存器 r0 的值會自動更新
ldmia r0!,{r4-r11}
// 將更新後的寄存器 r0 的值載入給 psp
msr psp,r0
isb
// 開中斷
mov r0,#0
msr basepri,r0
// 指示處理器在異常返回時使用 psp 作為堆棧指針
orr r14,#0xd
// 異常返回,自動載入剩下的 xPSR、PC、R14、R12 和 R3 ~ R0 寄存器
bx r14
}
CLion 版本
void vPortSVCHandler(void)
{
extern uint32_t pxCurrentTCB;
__asm volatile
(
".align 8 \n"
"ldr r3, =pxCurrentTCB \n"
"ldr r1, [r3] \n"
"ldr r0, [r1] \n"
"ldmia r0!, {r4-r11} \n"
"msr psp, r0 \n"
"isb \n"
"mov r0, #0 \n"
"msr basepri, r0 \n"
"orr lr, lr, #0x0d \n" // 筆者未驗證,可能有誤,請註意分辨
"bx lr \n"
:
:
: "r0", "r1", "r3", "memory"
);
}
熟悉 STM32 的讀者應該瞭解,NVIC 中所有中斷服務函數都有一個固定的函數名,該函數名可以在 startup_stm32f407xx.s
文件中找到,這裡 vPortSVCHandler
其實是 SVC_Handler
的別名,具體巨集定義如下所示
/* portMacro.h */
#define vPortSVCHandler SVC_Handler
註意:由於我們重新實現了 SVC 中斷服務函數,因此在 stm32f4xx_it.c
中自動生成的 SVC_Handler
函數需要註釋或者直接刪除
4.5、xPortPendSVHandler( )
PendSV 中斷服務函數,實現任務切換的函數
Keil 版本
/* port.c */
// PendSV 中斷服務函數,真正實現任務切換的函數
__asm void xPortPendSVHandler(void)
{
// 只要觸發 PendSV 中斷,寄存器 xPSR、PC、R14、R12 和 R3 ~ R0,
// 這 8 個寄存器的值會自動從 psp 指針指向的地址開始載入到任務棧中
// 並且 psp 指針也會自動更新
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
// 將 psp 的值存儲到寄存器 r0 中
mrs r0,psp
isb
// 將 pxCurrentTCB 的地址存儲到寄存器 r3 中
ldr r3,=pxCurrentTCB
// 將寄存器 r3 存儲的地址里的內容存儲到寄存器 r2 中
// 此時寄存器 r2 中存儲了當前執行任務的 TCB 地址
ldr r2,[r3]
// 以寄存器 r0 為基地址(r0 中現在存儲了 psp 指針的值)
// 將寄存器 r11 ~ r4 中存儲的值存儲到任務棧
// 並且 r0 中存儲的地址指針也會自動更新
stmdb r0!,{r4-r11}
// 將寄存器 r0 中的內容存儲到上一個執行任務的 TCB 地址
// 也即上一個任務的棧頂指針 pxTopOfStack 指向寄存器 r0 現在存儲的地址
str r0,[r2]
// 上文保存完成
// 開始載入下文
// 將寄存器 r3 和 r14 壓入棧
stmdb sp!,{r3,r14}
// 屏蔽 優先順序值 高於或者等於 11 的中斷
mov r0,#configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri,r0
dsb
isb
// 跳轉到 vTaskSwitchContext 函數,得到下一個要執行任務的指針 pxCurrentTCB
bl vTaskSwitchContext
// 開中斷
mov r0,#0
msr basepri,r0
// 恢復寄存器 r3 和 r14
ldmia sp!,{r3,r14}
// 將 pxCurrentTCB 載入到寄存器 r1
ldr r1,[r3]
// 將下一個要執行任務的棧頂指針載入到寄存器 r0
ldr r0,[r1]
// 以寄存器 r0 為基地址(r0 中現在存儲了任務的棧頂指針)
// 將任務棧中存儲的值載入到寄存器 r4 ~ r11 中
// 同時 r0 中的值會自動更新
ldmia r0!,{r4-r11}
// 將更新後的 r0 的值載入到 psp
msr psp,r0
isb
// 異常退出,自動載入剩下的 xPSR、PC、R14、R12 和 R3 ~ R0 寄存器
bx r14
nop
}
CLion 版本
void xPortPendSVHandler(void)
{
extern uint32_t pxCurrentTCB;
extern void vTaskSwitchContext(void);
uint32_t temp;
__asm volatile
(
".align 8 \n"
"mrs r0, psp \n"
"isb \n"
"ldr r3, =pxCurrentTCB \n"
"ldr r2, [r3] \n"
"stmdb r0!, {r4-r11} \n"
"str r0, [r2] \n"
"stmdb sp!, {r3, lr} \n"
"mov r0, %[max_syscall_prio] \n"
"msr basepri, r0 \n"
"dsb \n"
"isb \n"
"bl vTaskSwitchContext \n"
"mov r0, #0 \n"
"msr basepri, r0 \n"
"ldmia sp!, {r3, lr} \n"
"ldr r1, [r3] \n"
"ldr r0, [r1] \n"
"ldmia r0!, {r4-r11} \n"
"msr psp, r0 \n"
"isb \n"
"bx lr \n"
"nop \n"
:
: [max_syscall_prio] "i" (configMAX_SYSCALL_INTERRUPT_PRIORITY)
: "r0", "r1", "r2", "r3", "memory"
);
}
xPortPendSVHandler
是 PendSV_Handler
的別名,用於表示 PendSV 中斷服務函數入口地址
/* portMacro.h */
#define xPortPendSVHandler PendSV_Handler
關於如何屏蔽中斷的請讀者閱讀後續 FreeRTOS簡單內核實現4 臨界段 文章中 ”思考三“ ,FreeRTOS 的中斷管理策略是將所有的搶占優先順序分為兩組,一組可以通過 RTOS 的臨界區進行屏蔽,另外一組不受 RTOS 的影響,這兩組的邊界則由 configMAX_SYSCALL_INTERRUPT_PRIORITY
巨集確定,如下所示
/* FreeRTOSConfig.h */
// 設置 RTOS 可管理的最大中斷優先順序
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 11<<4
註意:由於我們重新實現了 PendSV 中斷服務函數,因此在 stm32f4xx_it.c
中自動生成的 PendSV_Handler
函數需要註釋或者直接刪除
4.6、vTaskSwitchContext( )
任務調度函數中通常應該使用某一調度策略來選擇下一個要執行的任務(比如始終保證最高優先順序的任務得到執行),但這裡我們只簡單的進行兩個任務的交替,後面會對該函數完善,具體如下所示
/* task.c */
// 任務調度函數
void vTaskSwitchContext(void)
{
// 兩個任務輪流切換
if(pxCurrentTCB == &Task1TCB)
{
pxCurrentTCB = &Task2TCB;
}
else
{
pxCurrentTCB = &Task1TCB;
}
}
/* task.h */
// 函數聲明
void vTaskSwitchContext(void);
4.7、taskYIELD( )
taskYIELD()
是主動產生任務調度的巨集函數,其實質上是觸發了 PendSV 中斷,然後在 PendSV 中斷服務函數中產生了任務調度,具體如下所示
/* task.h */
// 主動產生任務調度
#define taskYIELD() portYIELD()
/* portMacro.h */
#define portNVIC_INT_CTRL_REG (*((volatile uint32_t*)0xE000ED04))
#define portNVIC_PENDSVSET_BIT (1UL << 28UL)
// 觸發 PendSV,產生上下文切換
#define portYIELD() \
{ \
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
__DSB(); \
__ISB(); \
};
同樣這裡是直接操作 Cortex-M4 內核 ICSR 寄存器的 PENDSVSET 位置 1 來產生 PendSV 中斷的,關於該寄存器的相關內容仍然可以在 Cortex-M4 Devices Generic User Guide 手冊中找到,具體的原理如下圖所示
5、兩個重要問題
5.1、啟動第一個任務流程
通過上面的學習讀者應初步瞭解到啟動第一個任務的函數調用流程如下
vTaskStartScheduler()
- ->
xPortStartScheduler()
- ->
prvStartFirstTask()
- ->
vPortSVCHandler()
已知 prvStartFirstTask()
函數實質是觸發了 SVC 中斷,然後在 vPortSVCHandler()
這個 SVC 中斷服務函數中啟動了第一個任務,於是有如下問題
問題 1 :為什麼在觸發 SVC 中斷前進行了設置主堆棧指針 msp 值的操作?
問題 2 :vPortSVCHandler()
函數究竟是怎麼把第一個任務載入到 MCU 的寄存器中運行的?
5.1.1、問題 1 解答
這個問題的答案很簡單,就是因為之後的程式將不再使用主堆棧指針 msp,因此將其初始化為預設狀態,設置主棧指針的程式如下所示
ldr r0,=0xE000ED08
ldr r0,[r0]
ldr r0,[r0]
msr msp,r0
0xE000ED08
是 Cortex-M4 內核 VTOR
(中斷向量偏移寄存器)的地址,這個寄存器是不是聽起來很熟悉?
讀者可以在 STM32 工程的啟動文件中找到其中斷向量偏移表,可以發現中斷向量偏移表中第一項為 __initial_sp
,也即主堆棧指針 msp 的初始值,以 startup_stm32f407xx.s
為例,具體如下圖所示
5.1.2、問題 2 解答
當一個任務創建好之後,應該滿足 &TaskTCB == &TaskTCB.pxTopOfStack
,並且他們都指向了任務棧中用於存放寄存器 r4
記憶體的地址,具體可以閱讀 “2.4、任務記憶體詳解” 小節內容
同時由於我們手動指定了 pxCurrentTCB
指針指向任務控制塊,因此經過下麵幾句程式後,寄存器 r0
中值實際上已經是任務棧中用於存放寄存器 r4
記憶體的地址
ldr r3,=pxCurrentTCB
ldr r1,[r3]
ldr r0,[r1]
然後經過下麵的程式,此時保存在任務棧中的 r4 ~ r11
的值就會載入到 MCU 的寄存器中,同時 r0
中的值也會自動更新,最終指向任務棧中用於存放寄存器 r0
記憶體的地址
ldmia r0!,{r4-r11}
接著將 r0
的值載入給 psp
指針,這時候 psp
指針指向了任務棧中用於存放寄存器 r0
記憶體的地址
msr psp,r0
isb
下一步在打開中斷後執行了很重要的一步程式
orr r14,#0xd
執行完這條語句後,SVC 中斷服務函數返回時就不再使用 msp
作為堆棧指針了,而是切換到了 psp
堆棧指針,這樣 MCU 會從 psp
堆棧指針處將可以自動載入的寄存器進行載入,同時 psp
指針也會更新,最終 psp
指針會指向任務棧的棧頂
值得提醒的是由於任務創建時已經提前在任務棧用於存放寄存器 PC
記憶體處存儲了任務函數的入口地址,因此第一個任務函數便會開始運行
5.2、任務調度原理
已知 RTOS 中發生任務調度其實質為觸發 PendSV 中斷,而 xPortPendSVHandler()
是 PendSV 中斷服務函數,因此任務調度時上文的保存和下文的載入都是在該函數中發生的,於是有如下問題
問題 1 :上文是如何保存的?
問題 2 :下文是如何載入到 MCU 寄存器中的?
5.2.1、問題 1 解答
首先需要知道的是啟動第一個任務後 psp
堆棧指針在哪裡?它現在正指向任務棧的棧頂
其次需要知道當發生任務調度產生 PendSV 中斷時,MCU 會從 psp
堆棧指針處自動保存那些可以自動保存的寄存器,並且 psp
也會跟著更新
於是當進入 PendSV 中斷服務函數時,psp
指針已經指向了任務棧中用於存放寄存器 r0
記憶體的地址,然後將 psp
指針的值載入到 r0
寄存器中,並執行以下程式從 MCU 保存 r4 ~ r11
寄存器的值到任務棧對應位置,同時 r0
中的值也會更新,執行完之後 r0
此時應該指向任務棧中用於存放寄存器 r4
記憶體的地址
mrs r0,psp
isb
stmdb r0!,{r4-r11}
如下所示的幾條程式則是將當前任務的任務控制塊指向了 r0
中的值描述的地址處,也即當前任務的任務控制塊指向了任務棧中用於存放寄存器 r4
記憶體的地址,可以發現和剛創建完成任務時一樣,這樣當前任務的上文就保存好了
ldr r3,=pxCurrentTCB
ldr r2,[r3]
str r0,[r2]
5.2.2、問題 2 解答
在保存好上文後載入下文前,中間的程式是為了找到下一個需要執行的任務,當找到下一個要執行的任務之後,載入下文的過程其實和啟動第一個任務時大致相同,不再詳細分析
6、實驗
6.1、測試
使用 xTaskCreateStatic()
函數創建兩個任務
- Task1 負責每隔 100ms 翻轉一次 GREEN_LED 燈引腳的電平
- Task2 負責每隔 500ms 翻轉一次 ORANGE_LED 燈引腳的電平
任務創建好之後將其添加到就緒鏈表中,然後啟動任務調度器即可,具體代碼如下所示
/* main.c */
/* USER CODE BEGIN Includes */
#include "FreeRTOS.h"
/* USER CODE END Includes */
/* USER CODE BEGIN PV */
extern List_t pxReadyTasksLists;
TaskHandle_t Task1_Handle;
#define TASK1_STACK_SIZE 128
StackType_t Task1Stack[TASK1_STACK_SIZE];
TCB_t Task1TCB;
TaskHandle_t Task2_Handle;
#define TASK2_STACK_SIZE 128
StackType_t Task2Stack[TASK2_STACK_SIZE];
TCB_t Task2TCB;
// 任務 1 入口函數
void Task1_Entry(void *parg)
{
for(;;)
{
HAL_GPIO_TogglePin(GREEN_LED_GPIO_Port, GREEN_LED_Pin);
HAL_Delay(100);
taskYIELD();
}
}
// 任務 2 入口函數
void Task2_Entry(void *parg)
{
for(;;)
{
HAL_GPIO_TogglePin(ORANGE_LED_GPIO_Port, ORANGE_LED_Pin);
HAL_Delay(500);
taskYIELD();
}
}
/* USER CODE END PV */
/* USER CODE BEGIN 2 */
// 使用鏈表前手動初始化
prvInitialiseTaskLists();
// 創建任務 1 和 2
Task1_Handle = xTaskCreateStatic((TaskFunction_t)Task1_Entry,
(char *)"Task1",
(uint32_t)TASK1_STACK_SIZE,
(void *)NULL,
(StackType_t *)Task1Stack,
(TCB_t *)&Task1TCB);
Task2_Handle = xTaskCreateStatic((TaskFunction_t)Task2_Entry,
(char *)"Task2",
(uint32_t)TASK2_STACK_SIZE,
(void *) NULL,
(StackType_t *)Task2Stack,
(TCB_t *)&Task2TCB );
// 將兩個任務插入到就緒鏈表中
vListInsertEnd(&(pxReadyTasksLists),&(((TCB_t *)(&Task1TCB))->xStateListItem));
vListInsertEnd(&(pxReadyTasksLists),&(((TCB_t *)(&Task2TCB))->xStateListItem));
// 啟動任務調度器,永不返回
vTaskStartScheduler();
/* USER CODE END 2 */
啟動任務調度器後的程式執行流程如下所示,第一次執行完步驟 6 後會不斷重覆步驟 3 ~ 6
vTaskStartScheduler()
->pxCurrentTCB = &Task1TCB;
->xPortStartScheduler()
- ->
prvStartFirstTask()
->vPortSVCHandler()
->Task1_Entry()
- ->
taskYIELD()
->xPortPendSVHandler()
->vTaskSwitchContext()
- ->
Task2_Entry()
- ->
taskYIELD()
->xPortPendSVHandler()
->vTaskSwitchContext()
- ->
Task1_Entry()
使用邏輯分析儀捕獲 GREEN_LED 和 ORANGE_LED 兩個引腳的電平變化,具體如下圖所示
可以發現兩個任務不是並行運行的,而是一個任務執行完,第二個任務才會得到執行,所以兩個 LED 燈引腳電平都是每隔 600ms 翻轉一次,不是我們期待的 Task1 引腳電平每隔 100 ms 翻轉一次,Task2 引腳電平每隔 500ms 翻轉一次,這樣的效果其實可被如下簡單代碼取代
while(1)
{
HAL_GPIO_TogglePin(GREEN_LED_GPIO_Port, GREEN_LED_Pin);
HAL_Delay(100);
HAL_GPIO_TogglePin(ORANGE_LED_GPIO_Port, ORANGE_LED_Pin);
HAL_Delay(500);
}
6.2、待改進
當前 RTOS 簡單內核已實現的功能有
- 靜態方式創建任務
- 手動切換任務
當前 RTOS 簡單內核存在的缺點有
- 不支持任務優先順序
- 任務不能並行運行
- 無中斷臨界段保護