《30天自制操作系統》筆記(12)——多任務入門
進度回顧
上一篇介紹了設置顯示器高分辨率的方法。本篇講一下操作系統實現多任務的方法。
什么是多任務
對程序員來說,也許這是廢話,不過還是說清楚比較好。
多任務就是讓電腦同時運行多個程序(如一邊寫代碼一邊聽音樂一邊下載電影)。
電腦的CPU只有固定有限的那么一個或幾個,不可能真的同時運行多個程序。所以就用近似的方式,讓多個程序輪換着運行。當輪換速度夠快(0.01秒),給人的感覺就是"同時"運行了。
多任務之不實用版
我們首先從最基本的想法開始,做一個不實用版的多任務作為例子。在學習這個例子的過程中引入真正的多任務必須的TSS、TR、far模式JMP的概念,為后續內容打基礎。
當你向CPU發出任務切換的指令時,CPU會先把寄存器中的值全部寫入內存某處;然后,從內存另一位置把所有寄存器的值讀取出來。這就完成了一次任務切換。
任務切換消耗的時間就是讀寫內存消耗的時間,大概為0.0001秒。
任務狀態段TSS
存取全部寄存器的值這件事,當然需要有一個數據結構,這就是"任務狀態段"(Task Status Segment)簡稱TSS。
1 struct TSS32 2 { 3 int backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3; 4 int eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi; 5 int es, cs, ss, ds, fs, gs; 6 int ldtr, iomap; 7 };
TSS32中第一行(從backlink到cr3)暫時不用理會。
第二、三行(從eip到gs)都是寄存器。其中EIP是CPU用來記錄下一條需要執行的指令位於內存中的地址的寄存器,因此被稱為"指令指針"。實際上JMP指令就是修改了EIP的值。
第四行也不用理會。
TSS中的信息會存儲到內存某處(記為X),而X的地址會注冊到GDT中。(不知道什么是GDT?請查看這里)
寄存器TR
寄存器TR是作用是讓CPU記住當前在運行哪個任務。其存儲的值是"當前任務所在的段號*8"。只需在操作系統啟動時對其賦值一次,以后進行任務切換時,CPU會自動調整TR的值。給TR賦值只能用匯編實現。
1 _load_tr: ; void load_tr(int tr); 2 LTR [ESP+4] ; tr 3 RET
LTR指令只是改變TR的值,不會發生任務切換。所以我感覺TR像是一個標識變量。正是由於這一點我才有了后文的猜想。
切換任務就是執行JMP指令
JMP指令分兩種,即"只改寫EIP的near模式"與"同時改寫EIP和CS的far模式"。CS是代碼段寄存器(code segment)。
平時使用的都是near模式。
在asmhead.nas中跳轉到bootpack.c中的主函數用的是far模式,即
這條指令在向EIP寫入0x1b時,也向CS寫入2*8(即16)。
像這樣在JMP目標地址中帶冒號(:)的,就是far模式。
切換任務時,我們使用far模式的JMP指令。
CPU執行far模式的JMP指令前,會根據GDT中注冊的TSS情況,判斷JMP的目標地址是可執行代碼還是TSS。如果是可執行代碼,那么CPU就認為這只是一個普通的far模式的JMP;如果是TSS,則認為這是一個任務切換指令,會切換到目標地址指定的TSS所記錄的任務中,也就是JMP到另一個任務那里去了。
所以普通的far模式的JMP和任務切換的JMP指令,其機器碼是同一個。
Demo:兩個任務切換
我們把操作系統啟動時運行的程序記作任務A,即如下代碼。

1 void HariMain(void) 2 { 3 /* 略 */ 4 timer_ts = timer_alloc(); 5 timer_init(timer_ts, &fifo, 2); 6 timer_settime(timer_ts, 2); 7 /* 略 */ 8 for (;;) { 9 io_cli(); 10 if (fifo32_status(&fifo) == 0) { 11 io_stihlt(); 12 } else { 13 i = fifo32_get(&fifo); 14 io_sti(); 15 if (i == 2) { 16 farjmp(0, 4 * 8); 17 timer_settime(timer_ts, 2); 18 } else if (256 <= i && i <= 511) { /* 鍵盤數據 */ 19 /* 略 */ 20 } else if (512 <= i && i <= 767) { /* 鼠標數據 */ 21 /* 略 */ 22 } else if (i == 10) { /* 10秒計時器 */ 23 putfonts8_asc_sht(sht_back, 0, 64, COL8_FFFFFF, COL8_008484, "10[sec]", 7); 24 } else if (i == 3) { /* 3秒計時器 */ 25 putfonts8_asc_sht(sht_back, 0, 80, COL8_FFFFFF, COL8_008484, "3[sec]", 6); 26 } else if (i <= 1) { /* 光標用計時器 */ 27 /* 略 */ 28 } 29 } 30 } 31 }
下面是任務B執行的函數。

1 void task_b_main(void) 2 { 3 struct FIFO32 fifo; 4 struct TIMER *timer_ts; 5 int i, fifobuf[128]; 6 7 fifo32_init(&fifo, 128, fifobuf); 8 timer_ts = timer_alloc(); 9 timer_init(timer_ts, &fifo, 1); 10 timer_settime(timer_ts, 2); 11 12 for (;;) { 13 io_cli(); 14 if (fifo32_status(&fifo) == 0) { 15 io_sti(); 16 io_hlt(); 17 } else { 18 i = fifo32_get(&fifo); 19 io_sti(); 20 if (i == 1) { /* 任務切換 */ 21 farjmp(0, 3 * 8); 22 timer_settime(timer_ts, 2); 23 } 24 } 25 } 26 }
任務A執行0.02秒后就進入farjmp(0, 4 * 8);,從而自行切換到任務B,任務B執行0.02秒后就進入farjmp(0, 3 * 8);,從而自行切換到任務A。周而復始。
像這種在應用代碼中編寫任務切換的方式,明顯不實用。不過用於研究多任務還是很方便的。
多任務截圖沒有意義,就此作罷。
真正的多任務
大體上說,實現多任務的方法就是利用前面提到的定時器PIT(Programmable Interval Timer)能夠定時產生中斷的功能,在其中斷處理函數中實現任務切換的目的。
時間片輪轉調度算法是一種最基本的任務調度算法,它讓每個任務依次執行相同的一段時間(如0.01秒)。在此基礎上,可以為任務添加"休眠"、"優先級"等功能和屬性,根據屬性值調整執行時間和執行順序。
1 void inthandler20(int *esp) 2 { 3 /* 略 */ 4 char ts = 0; 5 for (;;) { 6 /* timers的計時器全部在工作中,因此不用確認flags */ 7 if (timer->timeout > timerctl.count) { 8 break; 9 } 10 /* 超時 */ 11 timer->flags = TIMER_FLAGS_ALLOC; 12 if (timer != mt_timer) { 13 fifo32_put(timer->fifo, timer->data); 14 } else { 15 ts = 1; /* mt_timer超時 */ 16 } 17 timer = timer->next; /* 將下一個計時器的地址賦給timer */ 18 } 19 timerctl.t0 = timer; 20 timerctl.next = timer->timeout; 21 if (ts != 0) { 22 mt_taskswitch(); 23 } 24 return; 25 } 26 void mt_taskswitch(void) 27 {/* demo演示只有兩個固定任務的切換過程 */ 28 if (mt_tr == 3 * 8) { 29 mt_tr = 4 * 8; 30 } else { 31 mt_tr = 3 * 8; 32 } 33 timer_settime(mt_timer, 2); 34 farjmp(0, mt_tr); 35 return; 36 }
為了簡化非核心代碼,我用demo版的mt_taskswitch(void)代替了有復雜數據結構的真實版本,這樣方便理解整個代碼的原理。其中的farjmp是用匯編實現的。
1 _farjmp: ; void farjmp(int eip, int cs); 2 JMP FAR [ESP+4] ; eip, cs 3 RET
根據C語言編譯器的規則,調用這個farjmp函數時,在[ESP+4]處存放了EIP的值,在 [ESP+8]處存放了CS的值。給eip賦值0,給cs賦值要切換到的任務所在的段號(乘8),就可以正確調用farjmp。
一般發生JMP后,不會執行后面的RET指令了。但是,執行任務切換的JMP后,再返回這個任務的時候,程序會從JMP指令之后的地方恢復運行,也就是JMP后面這個RET指令會被執行。因此這里的RET必不可少。
任務切換的時機
我提出一個問題,任務切換是在farjmp中執行JMP FAR [ESP+4]時發生的嗎?
從不實用版的代碼看來,答案應該是"是"。因為確實在任務A執行了farjmp中的JMP FAR [ESP+4]指令后切換到了任務B,之后切換回任務A時,又從JMP FAR [ESP+4]指令后面的RET指令開始執行了。
但是在真正的多任務中,CPU調用中斷處理函數,在inthandler20中執行了farjmp中的JMP FAR [ESP+4]。如果答案是"是",那么此時就會從中斷處理函數中切換到另一個任務A中了。
可是,還會不會切換回中斷處理函數inthandler20呢?
如果會,那么中斷處理函數不就也成了一個任務嗎?
如果會,那么中斷處理函數函還沒有return不就中斷了嗎?此時再來一個中斷的話,會怎么樣呢?棧就亂套了。
如果不會,那么中斷處理函數最前面用匯編寫的PUSH各種寄存器的指令就沒有相應的POP指令了呀,時間一長棧就溢出了呀。這明顯不對。
所以,我猜想只有一種情況是可行的。那就是:farjmp中的JMP FAR [ESP+4]指令並沒有完成任務切換,它只是讓CPU記錄了一個標識(比如上文的寄存器TR的作用),標識應該運行的任務是X。然后,當CPU完成中斷處理函數,再次執行某個任務A中的指令時,它會發現"現在應該執行任務X"中的指令了,所以它就切換到任務X中去。任務切換實際上此時才完成。
我查了一些資料,只能暫時作此猜想。
多任務優化
為了提高多任務運行效率,下面就對其進行優化。
休眠和喚醒
"休眠"就是從tasks鏈表中去掉一個任務A,"喚醒"就是把這個任務A重新加入tasks鏈表。
休眠的時機:任務A的消息隊列為空(沒有待處理的消息)時。
喚醒的時機:任務A的消息隊列獲得新的消息時。
任務優先級
把任務分到Level0、Level1、Level2這三個層中的一個,當Level0有活動的任務(非休眠狀態)時,只在Level0的任務間切換。當Level0沒有任務或均處於休眠狀態時,在Level1的任務間切換。Level2同理。
閑置任務
這與"哨兵"的思路相同。就是在Level2中添加一個只HLT的任務。如果操作系統里沒有其他任何任務的話,就會執行這個"哨兵"任務,即HLT掉(直至有中斷發生)。
哨兵的好處就是簡化代碼,使得邏輯處理沒有特殊情況。
總結
操作系統利用CPU的far模式的JMP指令、寄存器TR、GDT、TSS和PIT中斷這些功能實現了多任務,可見CPU在設計時就考慮到了計算機要具有多任務處理的能力。也就是說,CPU、PIC等硬件支持什么功能,操作系統才能實現什么功能。這又肯定了硬件為操作系統提供API的看法。