1. 程式人生 > >TQ2440開發板學習紀實(10)--- 實現多工處理,最簡單OS模型

TQ2440開發板學習紀實(10)--- 實現多工處理,最簡單OS模型

Keywords: Mutitasking,Context Switch,Thread

0 多工(多執行緒,多程序)基本概念

0.1 CPU與多工

對於“多工(Multitasking)”,不同的應用領域有不同術語。在作業系統領域,一般稱為“多工”;在應用程式設計領域,一般稱為“多執行緒”;而在Unix領域,更多的人喜歡用“多程序”來表示相同的意思。本文著眼於OS層,所以使用“多工”這個術語。

所謂的“多工”,就是讓一個CPU能“同時”執行多個程式。注意這裡的“同時”表示的是使用者(人)的感覺,實際上是多個程式以很高的頻率交替執行,給人一種同時執行的感覺。目前大多數作業系統都是支援多工的,而且大多數CPU都從硬體級別對多工進行了特別的支援。

在我們的PC上,邊瀏覽網頁,邊聽歌,同時還可以開著QQ等眾多軟體,這就是最直觀的“多工”,早就被人們認為是理所當然的事情了。

與“多工”相對應的是“單任務”。最典型的單任務系統就是DOS了,在DOS下,只能一個任務獨佔CPU,直到其執行結束為止。

0.2 多工的必要性

一是提高CPU使用效率。對於大多數應用程式而言,它不可能始終利用CPU進行不斷的運算,而是需要消耗很多時間在等待I/O完成。在等待I/O的時候,CPU只能做一些無用的迴圈,白白浪費了CPU運算能力。而“多工”則可以完美解決這個問題。

二是實時響應的需要。在多工普及之前,讓CPU執行多個程式通常是通過中斷處理程式的方式實現。這種方式也可以實現多個程式的排程,只是程式程式碼在中斷處理的環境下執行,為避免中斷巢狀導致的複雜性,執行時同類型中斷往往是關閉的。這就會造成執行一箇中斷處理程式期間,中斷不再響應,無法滿足實時要求。在嵌入式領域,實時性要求普遍存在,為此出現了一大批實時OS,例如uC/OS,RT-Linux,FreeRTOS等等。

三是簡化應用程式軟體開發。通過多工的底層支援,應用層軟體可以彼此完全獨立的進行開發,大大提高了開發效率。

0.3 何時進行任務切換

“多工”的根本就是任務切換。這是所有OS所使用的的方式。因為CPU在執行一條指令時(軟中斷指令)或者執行後(外部中斷訊號),總會檢測中斷標誌位,並據此跳轉到中斷處理程式。所以在中斷上下文中執行任務切換是最小粒度的切換。虛擬碼:

MOV r0, r2
/* 這裡可能會跳轉到中斷處理程式,然後進行任務切換 */
LDR r1, [r1]
/* 這裡可能會跳轉到中斷處理程式,然後進行任務切換 */

任務切換時,需要儲存當前任務的執行環境(CPU的各個暫存器的當前值),然後把下一個要執行的任務的執行環境恢復。

1 ARMv4核心任務切換原理

1.1 ARMv4核對任務切換的支援

ARMv4支援7種執行模式,任務的執行都是在User模式下。其他模式都是“特權模式”,用於異常處理與系統管理。在發生中斷時,自動進入相應的特權模式。

這裡寫圖片描述

本文我們以IRQ中斷模式為例,說明任務切換的過程。

1.2 上下文的儲存與恢復

使用者模式程式執行時,發生IRQ中斷後,CPU自動完成:
(1)把當前CPSR複製到SPSR_irq
(2)關IRQ中斷
(3)進入IRQ模式
(4)把PC-4存入R14_irq
(5)跳轉到中斷處理程式

任務上下文環境通常存放到一個稱為PCB的區域,PCB裡包含了R0-R15以及CPSR的當前值。雖然PCB可以存放到任何記憶體中,但是為了處理方便,通常把PCB存放到每個任務的Stack上。

1.2.1 PCB的儲存

因為涉及到CPU暫存器的直接操作,所以C語言無能為力,必須使用匯編來完成。現在是在IRQ模式下,我們對任務執行環境的每一個暫存器進行分析:

  • R0-R12,這個是User和IRQ模式共享的,所以可以直接讀取儲存。
  • R13,這個在IRQ模式下不可見。
  • R14,這個同樣在IRQ模式下不可見。
  • R15,我們關心的是被中斷任務的下一條指令地址,CPU已經自動把下一條指令的地址存入了R14_irq。所以,我們實際要儲存的是R14_irq的值。
  • CPSR,CPU已經自動存入了SPSR_irq,所以我們需要儲存的是SPSR_irq的值。

顯然這裡的主要問題就是如何讀取儲存被中斷時,使用者模式下的R13,R14。有兩種方式:
(1)修改CPSR,讓CPU重新進入User模式,從而可以操作R13,R14。
這種方式看起來簡單,其實非常繁瑣。

(a) IRQ模式下入棧儲存r0,因為r0接下來用於修改cpsr。

stmfd sp!, {r0}

(b)進入System模式(System模式和User模式共享所有暫存器,如果進入User模式則無法返回到IRQ模式了)

 mrs r0, cpsr
 bic r0, 0x1F
 orr r0, 0x1F
 msr cpsr, r0

(c)儲存r1-r14入棧

stmfd sp, {r1-r14}
sub sp, sp, #56

(d)返回IRQ模式

mrs r0, cpsr
bic r0, r0, 0x1F
orr r0, r0, 0x12
msr cpsr, r0

(e)恢復中斷前r0的值,並把r14_irq和SPSR_irq存入r2,r3備用

ldmfd sp!, {r0}
mov r2, r14
mov r3, spsr

(f)再次進入System模式,這次我們用r1完成

 mrs r1, cpsr
 bic r1, 0x1F
 orr r1, 0x1F
 msr cpsr, r1

(g)把r0,r2(此時為R14_irq的值),r3(此時為SPSR_irq的值)入棧儲存

stmfd sp!, {r0,r2,r3}

可以看出這要涉及到多次模式切換,非常繁瑣低效。為此,ARMv4提供了專門的指令,用來避免模式切換。

(2)ARMv4提供了專門的指令用於在特權模式下操作使用者模式下的暫存器。那就是stm和ldm,例如:

ldm sp, {r13,r14}^ /* 此時操作的是r13, r14 */
ldm sp, {r13,r14} /* 此時操作的是r13_irq, r14_irq */

這裡起作用的就是指令中最後的^符號。
這個^符號作用很大,除了上述功能外,如果暫存器列表裡有R15,那麼^符號還會自動從SPSR_irq中還原CPSR。

ldm sp, {pc}^   /* 這將導致SPSR_irq複製到CPSR */

這樣就會非常的簡單:

stmfd sp!, {r0} /* r0將來存sp_usr,所以先儲存 */

stmfd sp, {sp}^ /* 讀取r13_user入棧 */
nop
ldmfd sp, {r0}  /* 此時r0代表了PCB */

stmfd r0!, {lr} /* PCB存入被中斷任務的下一條指令地址 */
mov lr, r0      /* 改用lr代表PCB */
ldmfd sp!, {r0} /* 恢復r0 */
stmfd lr, {r0-r14}^ /* PCB存入Usermode的r0-r14 */
nop
sub lr, lr, #60

mrs r0, spsr
stmfd lr!, {r0}  /* PCB存入SPSR_irq */

ldr r0, =cur_pcb /* 更新PCB指標 */
str lr, [r0]

儲存PCB後,把PCB新指標儲存到任務結構中,然後把下一個要執行的任務的PCB寫入cur_pcb處,然後執行恢復。

1.2.2 PCB的恢復

通常會把PCB指標存放到一個變數中,假設為cur_pcb。

    .data
cur_pcb:
    .word 0x0000000
    .text
    ldr r0, =cur_pcb
    ldr r0, [r0]  /* r0代表了PCB */
    ldmfd r0!, {r1} /* r1代表了SPSR_irq */
    msr spsr, r1  /* 恢復SPSR_irq */
    mov lr, r0    /* 改用lr_irq代表PCB */

    ldmfd lr, {r0-r14}^ /* 恢復r0-r14 */
    nop
    add lr, lr, #60

    ldmfd lr, {pc}^   /* 恢復PC,同時恢復CPSR(進入User模式),執行新任務 */

2 任務管理及排程策略

2.1 任務管理

除PCB外,每個任務一般還有自身的額外屬性,如優先順序,執行時間等。所以一般使用一個結構來表示任務:

struct Task {
    void* PCB;
    int priority;  /* 任務優先順序 */
};

系統需要管理多個任務,如果數量固定或者不大,可以用陣列管理。如果數量很大且變化,則用連結串列較為合適。如果數量極大,則可以考慮使用樹、雜湊表等其他容器來提高管理效率。

本文的測試環境,尚不具備動態記憶體分配功能,所以採用了陣列了管理。

struct Task[TASK_MAX+1];

2.2 任務初始化

2.2.1 尚未執行的任務的初始化

一個任務執行之前,必須進行初始化,也就是為PCB賦值。這樣在首次執行時,才能從PCB中進行恢復。

必須要正確賦值的暫存器是:
* R13,這是新任務的Stack指標,必須給予正確賦值,保證每個任務的堆疊不會重疊。
* R15,這是第一條要執行的指令地址。
* SPSR_irq,這會被恢復到CPSR。
對於其他的暫存器,可以全部賦值為0。

int task_add(void(*start)(void*), void* state, void* sp, int priority)
{
    asm("push {r0-r3} \n");
    asm("mrs r0, cpsr \n");
    asm("bic r0, r0, #0x1F \n");
    asm("orr r0, r0, #0x10 \n");
    asm("str r0, [r2, #-68] \n"); /* r2 = sp */
    asm("pop {r0-r3} \n");

    void** pcb = ((void**)sp)-17;
    *(pcb+1) = state;   /* R0 */
    *(pcb+2) = 0;
    *(pcb+3) = 0;
    *(pcb+4) = 0;
    *(pcb+5) = 0;
    *(pcb+6) = 0;
    *(pcb+7) = 0;
    *(pcb+8) = 0;
    *(pcb+9) = 0;
    *(pcb+10) = 0;
    *(pcb+11) = 0;
    *(pcb+12) = 0;
    *(pcb+13) = 0;
    *(pcb+14) = sp; /* R13 */
    *(pcb+15) = 0;  /* R14 */
    *(pcb+16) = start; /* R15 */

    static int i = 1;
    if(i>9) {
        return -1;
    }
    task_array[i].PCB = pcb;
    task_array[i].priority = priority;
    task_count++;
    i++;
    return (i-1);
}

2.2.2 任務0的初始化

系統啟動以後進入SVC模式,初始化完畢後,切換自身到User模式,成為第一個任務,也叫作任務0。這是一個特殊的任務:

  • 它一開始就存在,由啟動程式碼進化而來,是第一個執行的任務
  • 它無需初始化,因為其堆疊已在啟動時設定好了
  • 系統第一次任務切換時,會把其當前狀態恢復到PCB
 /*--------------- task 0 ----------*/
    uart_send_str("Enter User mode as task 0...");
    asm("mrs r0, cpsr");
    asm("bic r0, r0, #0x1F");
    asm("orr r0, r0, #0x10");
    asm("msr cpsr_cxsf, r0");
    uart_send_str("[OK]\x0A\x0D");


    while(1) {
        puts("hello from task 0\n");
        delay(100000);
    }

除此之外,任務0和其他任務並無區別。

2.3 排程策略

如何確定下一個要執行的任務?這是一個策略問題。不同的作業系統支援不同的任務排程策略,同一個作業系統也可能支援不同的排程策略,例如Linux就允許使用者動態改變執行緒排程演算法。排程策略大概分為:
(1)按照時間平均排程
也就是輪流執行所有任務,目標是所有任務平等的佔用CPU。
(2)按照優先順序排程
優先順序高的任務先執行,優先順序低的會被優先順序高的任務搶佔CPU。
(3)綜合排程
綜合考慮優先順序、時間片等因素,行程較為複雜的排程策略。

作為小小實驗,本文僅對輪流排程進行測試。也就是每接收到一個IRQ中斷,就會切換到下一個任務,迴圈執行。

   task = cur_task+1;
    if (task >= task_count) {
        task -= task_count;
    }

    if (task != cur_task) {
        task_array[cur_task].PCB = cur_pcb; /* save old pcb */
        cur_pcb = task_array[task].PCB;     /* set new pcb */
        cur_task = task;
    }

3 測試結果與結論

測試程式碼中,我們添加了5個任務,這樣加上任務0,一共是6個任務了。

 /*-------------- add tasks ---------*/
    task_init();
    for(int i=0; i<5; i++) {
        task_add(task, (void*)(i+1), (void*)(0x33000000+0x1000*i), i);
    }

第一個任務執行後,通過外部中斷(按鍵或者UART0)輪流切換下一個任務執行。

4 完整原始碼及注意