RTOS內功修煉記(二)—— 優先級搶佔調度到底是怎麼回事?

內容導讀:

本文從任務如何切換開始講起,引出RTOS內核中的就緒列表、優先級表,一層一層爲你揭開RTOS內核優先級搶佔式調度方法的神祕面紗,只有對內核的深入瞭解,才能創造出更好的應用。


1.知識點回顧

1.1. 上文回顧

上篇文章講述了任務的三大元素:任務控制塊、任務棧、任務入口函數,並講述了編寫RTOS任務入口函數時三個重要的注意點。

如果你還沒有閱讀上一篇文章,請先閱讀,這有助於對本文的理解:

1.2. 雙向循環鏈表

雙向鏈表是鏈表的一種,區別在於每個節點除了後繼指針外,還有一個前驅指針,雙向鏈表的節點長下面這樣:

如果你對雙向循環列表的實現及使用還不熟悉,請一定要先閱讀這篇文章:

2. 任務是如何切換的

在RTOS內核中,一個任務切換到下一個任務的原理是:手動觸發PendSV異常,在PendSV異常服務函數中實現任務切換

2.1. 如何觸發PendSV異常

stm32中,將中斷及狀態控制寄存器 Interrupt control and state register (ICSR) 的第28位置1,即可手動觸發 PendSV 異常,如圖:

tos中觸發異常的底層函數爲port_context_switch,實現在 arch\arm\arm-v7m\cortex-m4\armcc\port_s.S中,如下:

    GLOBAL port_context_switch
port_context_switch
    LDR     R0, =NVIC_INT_CTRL
    LDR     R1, =NVIC_PENDSVSET
    STR     R1, [R0]
    BX      LR

上面這段彙編猛一看有點難,再看看兩個值具體是多少:

NVIC_INT_CTRL   EQU     0xE000ED04
NVIC_PENDSVSET  EQU     0x10000000

所以上面這段彙編代碼,不正是完成了將寄存器 ICSR(代碼中是NVIC_INT_CTRL) 的第28位置1的操作嗎?

2.2. 異常服務中實現任務切換

在 stm32 中 PendSV 的異常服務函數名爲 PendSV_Handler,默認在stm32l4xx_it.c中提供了一個弱定義,所以tos中的實現直接重定義此函數即可,源碼在 arch\arm\arm-v7m\cortex-m4\armcc\port_s.S中,主要步驟有四個:

關閉全局中斷(NMI 和 HardFault 除外),防止任務切換過程被中斷:

CPSID   I

保存上文環境:保存當前CPU寄存器組的值、PSP棧頂指針的值到任務棧中;

加載下文環境:加載當前任務棧中的值到CPU寄存器組、PSP棧頂指針中;

打開全局中斷,實時響應系統所有中斷:

CPSIE   I

記住任務切換的這四個過程即可,深入研究每行彙編指令是什麼意思,沒有太大的作用和幫助。

2.3. CPU何時響應PendSV異常

我們都知道,高優先級的中斷會打斷低優先級的中斷,這也是系統實時性的一個重要保障,所以就引入了一個問題:

相比起GPIO中斷、定時器中斷、串口中斷這些外部中斷,PendSV異常的優先級更高呢?還是更低呢?

想象這樣一種情況:

① CPU正在開心的運行着任務1……

② 此時你按下了按鍵,產生了一個GPIO中斷,CPU收到後馬上跑去執行中斷處理函數……

③ 處理過程中,此時系統產生了一個PendSV異常,CPU收到後,嘲諷了一句:“我就是從普通任務跑來處理中斷的,還沒處理完,現在又讓我執行下一個普通任務,腦子抽風了?”,說完繼續處理中斷……

所以說,無論任務的優先級有多高,它都沒有中斷高,系統的PendSV異常優先級必須設爲最低的,以避免在外部中斷服務函數中產生任務切換。

設置PendSV異常優先級的寄存器如下,值可以爲0-255:

tos中在啓動調度時設定pendsv異常的優先級,源碼如下:

NVIC_SYSPRI14   EQU     0xE000ED22
NVIC_PENDSV_PRI EQU     0xFF

同樣,設置pendSV異常優先級爲最低的彙編代碼如下:

; set pendsv priority lowest
; otherwise trigger pendsv in port_irq_context_switch will cause a context switch in irq
; that would be a disaster
MOV32   R0, NVIC_SYSPRI14
MOV32   R1, NVIC_PENDSV_PRI
STRB    R1, [R0]

3. 就緒列表

3.1. 就緒列表長啥樣

就緒列表其實就是好多條雙向鏈表+一張優先級表,它的類型定義在tos_sched.h,如下:

typedef struct readyqueue_st
{
    k_list_t    task_list_head[TOS_CFG_TASK_PRIO_MAX];
    uint32_t    prio_mask[K_PRIO_TBL_SIZE];
    k_prio_t    highest_prio;
} readyqueue_t;

給每個優先級都分配了一個雙向鏈表的首節點,用於掛載該優先級的任務

TOS_CFG_TASK_PRIO_MAX 是最大任務優先級,在tos_config.h中配置,默認是10:

#define TOS_CFG_TASK_PRIO_MAX           10u

節點類型 k_list_t 是一個雙向鏈表節點類型:

typedef struct k_list_node_st
{
    struct k_list_node_st *next;
    struct k_list_node_st *prev;
} k_list_t;

所有雙向鏈表節點初始化完畢之後,每一個雙向結點的next指針指向自己(橙色線),prev指針也指向自己,如圖:

用於指示系統目前所使用優先級的優先級表

優先級表的大小由宏定義 K_PRIO_TBL_SIZE 決定:

#define K_PRIO_TBL_SIZE         ((TOS_CFG_TASK_PRIO_MAX + 31) / 32)

這兒定義的時候比較講究,如果最大優先級不大於32,則該宏的值爲1,使用一個uint32_t類型的變量即可,每個優先級的表示佔一位。

初始化後的優先級表長下面這個樣子:

最高優先級指示成員

就緒列表中的 highest_prio 成員是 k_prio_t 類型,其實就是一個uint8_t類型:

typedef uint8_t             k_prio_t;

該成員表示系統中當前所存在任務的最高優先級,默認是系統定義的最大優先級。

3.2. 系統中的就緒列表

系統中有多少條就緒列表呢?

對了,答案當然是:僅有唯一的一條就緒列表

tos_global.h中聲明,便於在整個內核的所有文件中使用:

/* ready queue of tasks                         */
extern readyqueue_t         k_rdyq;

tos_global.c中定義:

readyqueue_t        k_rdyq;

記住它的名字,它叫k_rdyq,k就是kernel,rdyq就是ready queue的縮寫,後面會經常出現。

3.3. 初始化就緒列表

知道了就緒列表長啥樣,就緒列表的初始化就變得非常簡單了,都是常規操作,在tos_sched.c文件中實現:

__KNL__ void readyqueue_init(void)
{
    uint8_t i;

    k_rdyq.highest_prio = TOS_CFG_TASK_PRIO_MAX;

    for (i = 0; i < TOS_CFG_TASK_PRIO_MAX; ++i) {
        tos_list_init(&k_rdyq.task_list_head[i]);
    }

    for (i = 0; i < K_PRIO_TBL_SIZE; ++i) {
        k_rdyq.prio_mask[i] = 0;
    }
}

第①步是設置最高優先級成員的初始值,爲系統當前配置的最高優先級;

第②步是遍歷初始化每個雙向鏈表節點;

第③步就是初始化優先級表的所有值,爲0。

4. 任務如何掛載到就緒列表

在任務創建API的最後,會調用 readyqueue_add_tail 函數將任務加入到就緒列表中,那麼,任務究竟是被如何掛載上去的呢?

此函數的源碼實現如下:

__KNL__ void readyqueue_add_tail(k_task_t *task)
{
    k_prio_t task_prio;
    k_list_t *task_list;

    task_prio = task->prio;
    task_list = &k_rdyq.task_list_head[task_prio];

    if (tos_list_empty(task_list)) {
        readyqueue_prio_mark(task_prio);
    }

    tos_list_add_tail(&task->pend_list, task_list);
}

獲取該任務的優先級在就緒列表中所對應的首節點

在優先級表中記錄此優先級

判斷系統中該優先級是否第一次出現,如果是,則將優先級表中此優先級的標誌位置1,表示系統中存在此優先級的任務,並重新賦值就緒列表中的最高優先級指示成員(注:優先級值越小,表示優先級越高):

__STATIC_INLINE__ void readyqueue_prio_insert(k_prio_t prio)
{
    k_rdyq.prio_mask[K_PRIO_NDX(prio)] |= K_PRIO_BIT(prio);
}

__STATIC_INLINE__ void readyqueue_prio_mark(k_prio_t prio)
{
    readyqueue_prio_insert(prio);

    if (prio < k_rdyq.highest_prio) {
        k_rdyq.highest_prio = prio;
    }
}

舉個例子,當我們創建了一個優先級爲2的任務後,則優先級表如下:

將該任務的任務控制塊的pendlist節點,掛載到第一步獲取到的首節點所指示的鏈表尾部

任務控制塊中的pend_list成員也是一個雙向鏈表節點:

/**
 * task control block
 */
struct k_task_st {
	//……
	
    k_list_t	pend_list;	/**< when we are ready, our pend_list is in readyqueue; when pend, in a certain pend object's list. */

	//……
};

使用雙向鏈表將此pendlist節點添加到第①步獲取到的鏈表尾部,添加之後如圖:

5. 優先級搶佔式調度

5.1. 基本調度規則

理解了上面的三節內容,再來看優先級搶佔式調度,簡直就是水到渠成。

同樣,先放上優先級搶佔式調度的源碼,在tos_syc.c中:

__KNL__ void knl_sched(void)
{
    TOS_CPU_CPSR_ALLOC();

    if (unlikely(!tos_knl_is_running())) {
        return;
    }

    if (knl_is_inirq()) {
        return;
    }

    if (knl_is_sched_locked()) {
        return;
    }

    TOS_CPU_INT_DISABLE();
    k_next_task = readyqueue_highest_ready_task_get();
    if (knl_is_self(k_next_task)) {
        TOS_CPU_INT_ENABLE();
        return;
    }

    cpu_context_switch();
    TOS_CPU_INT_ENABLE();
}

在源碼中可以看到,優先級搶佔式調度其實就是兩個步驟:

① 獲取就緒列表中的最高優先級的任務控制塊指針;

② 啓動上下文切換;

總結一下,優先級搶佔式調度的規則就是:每當符合調度條件時時,就切換到就緒列表中優先級最高的任務開始運行

5.2. 如何獲取最高優先級的任務

別忘了就緒列表中有一個成員叫highest_prio,該成員指示出了系統當前存在的最高優先級,可以很方便的獲取到掛載最高優先級的任務鏈表,函數源碼如下:

__KNL__ k_task_t *readyqueue_highest_ready_task_get(void)
{
    k_list_t *task_list;

    task_list = &k_rdyq.task_list_head[k_rdyq.highest_prio];
    return TOS_LIST_FIRST_ENTRY(task_list, k_task_t, pend_list);
}

但是需要注意,在就緒列表上掛載的是任務控制塊中的pend_list節點,如圖:

已知任務控制塊中pend_list節點地址,如何知道它所在任務控制塊的基地址呢?

其實它是通過 TOS_LIST_FIRST_ENTRY 這個宏來獲取的,具體的使用方法,請閱讀我在文章開頭提出的第二篇文章。

6. 優先級表有什麼用?

優先級表的作用是:在將任務從就緒列表中移出時,用來獲取當前就緒列表中的最高優先級

優先級搶佔式調度器可是六親不認的,纔不管任務當前狀態是什麼,反正就是永遠尋找調度列表中最高優先級的任務。

所以當任務調用要主動掛起時,必須要從就緒列表中移出,源碼如下:

__API__ k_err_t tos_task_suspend(k_task_t *task)
{
    TOS_CPU_CPSR_ALLOC();

	//一堆參數判斷,省略了

    TOS_CPU_INT_DISABLE();

    if (task_state_is_ready(task))
    { 
    	// kill the good kid
        readyqueue_remove(task);
    }
    task_state_set_suspended(task);

    TOS_CPU_INT_ENABLE();
    knl_sched();

    return K_ERR_NONE;
}

其中核心的就緒列表移出函數 readyqueue_remove 源碼如下:

__KNL__ void readyqueue_remove(k_task_t *task)
{
    k_prio_t task_prio;
    k_list_t *task_list;

    task_prio = task->prio;
    task_list = &k_rdyq.task_list_head[task_prio];

    tos_list_del(&task->pend_list);

    if (tos_list_empty(task_list)) {
        readyqueue_prio_remove(task_prio);
    }

    if (task_prio == k_rdyq.highest_prio) {
        k_rdyq.highest_prio = readyqueue_prio_highest_get();
    }
}

① 獲取任務當前優先級在就緒列表中的首節點;

② 將該任務控制塊與該條雙向鏈表斷開(並沒有刪除任務);

③ 如果斷開後該鏈表變空,則表示就緒列表中不存在該優先級的任務,在優先級表中將該位清零;

重新獲取就緒列表中的最高優先級

這個時候優先級表的作用就體現出來了,之前講到,優先級表中記錄了當前就緒列表中所存在任務的優先級,所以可以通過遍歷查找優先級表,來獲取到最高優先級,最後賦值給就緒列表中的指示成員。

源碼如下:

__STATIC__ k_prio_t readyqueue_prio_highest_get(void)
{
    uint32_t *tbl;
    k_prio_t prio;

    prio    = 0;
    tbl     = &k_rdyq.prio_mask[0];

    while (*tbl == 0) {
        prio += K_PRIO_TBL_SLOT_SIZE;
        ++tbl;
    }
    prio += tos_cpu_clz(*tbl);
    return prio;
}

7. 總結

講述了這麼多內容,非常有必要來總結出值得注意的點:

RTOS內核中通過手動觸發PendSV異常來啓動一次切換,任務切換在PendSV異常服務函數中實現

RTOS內核中PendSV異常的優先級被設爲最低,避免在外部中斷處理函數中產生任務切換

RTOS內核所謂的優先級搶佔式調度規則就是永遠從就緒隊列中找出最高優先級的任務來運行

當然,有了優先級搶佔式調度規則,才勉強撐起來了一個RTOS內核的肉體,什麼時候進行調度,纔是一個RTOS內核的靈魂,接下來的文章與大家再會,我是Mculover666,一個喜歡玩板子的小碼農。

接收更多精彩文章及資源推送,歡迎訂閱我的微信公衆號:『mculover666』。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章