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)輪流切換下一個任務執行。