現在我們開始skelix內核的多任務支持工作了。首先要澄清的是:在單處理器上面,進程只能并發,而不是并行。就是說同一時刻只能有一個任務在運行,cpu會劃分很多很小的時間片來運行各個任務,這樣看起來就像多個任務在同時運行一樣。i386從硬件上就支持多任務了,但是也可以通過編程來實現,本文就是一個例子,每個任務輪流運行一個小的時間片。我們知道,單個cpu只有1組寄存器,這些可以用于所有任務。當由一個任務切換到另一個任務時,必須先保存原來任務的運行環境(就是一堆寄存器和其他信息),通常稱這個運行環境叫‘上下文’。i386使用一個叫TSS的段來存儲這些信息,這個TSS段至少104字節(不包括IOPL位圖的話),對應它的是一個TSS描述符。TSS描述符只能在GDT表中,這意味著任務不能自己通過LDT來切換到其他任務。當時鐘中斷到來切換任務時,CPU會自動保存相關信息到TSS中。
TSS定義了任務的上下文環境,結構如下:
(圖-1)
31
16,15 0
+------------------------+
|
| NO use | Back
Link
|
| 高地址
+------------------------+
|
| ESP0 |
|
+------------------------+
|
| NO use |
SS0 |
|
+------------------------+
|
|
ESP1 |
|
+------------------------+
|
| NO use |
SS1 |
| 低地址
+------------------------+ \|/
|
ESP2 |
+------------------------+
| NO use |
SS2 |
+------------------------+
| CR3 |
+------------------------+
| EIP |
+------------------------+
| EFLAGS |
+------------------------+
| EAX |
+------------------------+
| ECX |
+------------------------+
| EDX |
+------------------------+
| EBX |
+------------------------+
| ESP |
+------------------------+
| EBP |
+------------------------+
| ESI |
+------------------------+
| EDI |
+------------------------+
| NO use | ES
|
+------------------------+
| NO use | CS
|
+------------------------+
| NO use | SS
|
+------------------------+
| NO use | DS
|
+------------------------+
| NO use | FS
|
+------------------------+
| NO use | GS
|
+------------------------+
| NO use
| LDT |
+------------------------+
| I/O 位圖 | NO use | T |
+------------------------+
i386處理器可以使用嵌套任務,就是說當任務1切換到任務2時,任務2 的 Back Link 域被設置成任務1的選擇子,且任務2的EFLAGS中的NT(嵌套任務標識)置位,這樣任務2返回時,cpu就知道切換回任務1。我們知道,i386有4中特權級,所以可以在TSS中設置這四種特權級下的堆棧。比如一個任務運行在ring3特權級下,它使用用戶態堆棧,使用系統調用可以讓這個任務切換到ring0的內核態,這是堆棧也必須切換到內核態堆棧。這些堆棧指針就是存放在TSS中的。現在我們來看看TSS描述符(它只能放在GDT中):
圖-2:TSS描述符定義
63_______________56__55__54__53__52__51_____________48_
| 基地址(31到24位) | G | 0 | 0 |AVL| 長度(19到16位) |
|_______________________________________________________|
_47__46__45__44________41__40____39_________________32_
| P | DPL | 0 | 1 | 0 |
B | 1 | 基地址(23到16位) |
|_______________________________________________________|
31____________________________________________________16
|
基地址(15到0位)
|
|_______________________________________________________|
16_____________________________________________________
|
長度(15到0位)
|
|_______________________________________________________|
*********************************************************
圖-3:通用描述符定義
63_______________56__55__54__53__52__51_____________48_
| 基地址(31到24位) | G |D/B| X | U | 長度(19到16位) |
|_______________________________________________________|
_47__46__45__44____41______40____39_________________32_
| P | DPL | 類型
| A | 基地址(23到16位) |
|_______________________________________________________|
31____________________________________________________16
|
基地址(15到0位)
|
|_______________________________________________________|
16_____________________________________________________
|
長度(15到0位)
|
|_______________________________________________________|
我們現在來比較一下通用描述符和TSS描述符,在TSS描述符中,我們看到D位(數據還是代碼段)和X位(未使用)都被置為0,而且AVL位有效。類型type是010B,第41位,及B位是TSS描述符的忙標識,因為一個進程只能有一個TSS,所以當進程執行時,B位將被設置,表示這個TSS正在被使用,除了進程調度外,不能再使用它了。A位被置為1,即便A位被置為可寫,TSS也不能被進程讀或寫。TSS的其他域和通用描述符一樣,只是要注意一點:就是TSS的長度界限至少要有104字節。另外需要注意的是,skelix暫時不支持fpu,mmx和sse,如果讀者想擴展的話需要注意每個進程都有自己的fpu,sse環境。
在本課中,TSS描述符的DPL將被置為0,所以只有內核可以進程任務切換。和GDTR和LDTR一樣,TSS描述符也有自己的用于切換任務的寄存器,就是TR。可以用LTR指令來加載這個寄存器。GDT中的TSS描述符不能被加載到任何其他寄存器,否則會發生一個異常。
切換任務有多種方法,我們的做法是使用jmp指令跳轉到TSS描述符。(還有其他方法,如從中斷返回,使用任務門(起始也相當于jmp到TSS上))。趙博的linux內核完全剖析上講到了切換任務的幾種方法,并詳細討論了,不清楚的同學可以購買該書。
在切換任務時,CPU首先保存當前進程的上下文環境到當前TSS中,然后加載新任務的TSS到TR寄存器中,然后從新任務的TSS中加載上下文到寄存器中,最后跳轉到新任務的第一條指令并開始執行。不知道這樣有沒有說清楚,這個是比較關鍵的地方,歡迎和我討論:jinglexy at yahoo dot com dot cn(MSN)。
OK,讓程序來說明一起吧。先看看TSS數據結構的定義:
06/include/task.h
struct TSS_STRUCT {
int back_link;
int esp0, ss0;
int esp1, ss1;
int esp2, ss2;
int cr3;
int eip;
int eflags;
int eax,ecx,edx,ebx;
int esp, ebp;
int esi, edi;
int es, cs, ss, ds, fs, gs;
int ldt;
int trace_bitmap;
};
沒有pad的數據結構是104字節,如果你使用或模擬 IA64 平臺的話,就需要查找相關的資料了。
對于一個正在運行的OS,上面的信息顯然是不夠的,所有我定義了另外一個包裝TSS的數據結構,
即進程信息(process control block),以后簡稱為pcb:
#define TS_RUNNING
0 //
任務的三種狀態
#define TS_RUNABLE 1
#define TS_STOPPED 2
struct TASK_STRUCT
{
// pcb定義
struct TSS_STRUCT tss;
unsigned long long tss_entry;
// tss描述符在gdt中的8字節入口項
unsigned long long ldt[2];
unsigned long long ldt_entry;
int state;
int priority;
struct TASK_STRUCT *next;
};
#define DEFAULT_LDT_CODE 0x00cffa000000ffffULL
#define DEFAULT_LDT_DATA 0x00cff2000000ffffULL
#define INITIAL_PRIO 200
priority 表示任務優先級,新建任務默認的優先級是下面的 INITIAL_PRIO。skelix內核中所有的任務都用一個單向鏈表來表示,這樣做起來比較簡單。我們現在來看一個任務的例子,就是任務0:內核初始化完成后,就執行任務0。
06/task.c
static unsigned long TASK0_STACK[256] = {0xf};
上面數據是任務0在特權級0下使用的堆棧,0xf值如果不設置的話,這塊內存就會發生一些錯誤,我也不清楚是為什么,所以第一個元素隨便給了一個非0值。
struct TASK_STRUCT TASK0 = {
/* tss */
{
0,
/*
esp0
ss0 */
(unsigned)&TASK0_STACK+sizeof
TASK0_STACK, DATA_SEL,
上面定義會使任務的esp0執行棧底,使用內核數據段選擇子就可以了
/* esp1 ss1 esp2 ss2 */
0, 0, 0, 0,
/* cr3 */
0,
/* eip eflags */
0, 0,
/* eax ecx edx ebx */
0, 0, 0, 0,
/* esp ebp */
0, 0,
/* esi edi */
0, 0,
/*
es
cs ds
*/
USER_DATA_SEL, USER_CODE_SEL,
USER_DATA_SEL,
/*
ss
fs gs
*/
USER_DATA_SEL, USER_DATA_SEL,
USER_DATA_SEL,
/* ldt:見后面說明 */
0x20,
/* trace_bitmap */
0x00000000},
/* tss_entry */
0,
/* ldt[2] */
{DEFAULT_LDT_CODE, DEFAULT_LDT_DATA},
/* ldt_entry */
0,
/* state */
TS_RUNNING,
/* priority */
INITIAL_PRIO,
/* next */
0,
};
現在,我們定義了一個TSS,再在GDT中加入描述符索引:
06/bootsect.s
gdt:
.quad 0x0000000000000000
# null descriptor
.quad 0x00cf9a000000ffff # cs
.quad
0x00cf92000000ffff # ds
.quad
0x0000000000000000 # reserved for further use
.quad
0x0000000000000000 # reserved for further use
第四項(0x3)用于存放該任務的TSS,所以定義了一個宏: CURR_TASK_TSS = 3來索引GDT中的這個TSS。當任務釋放控制權后,會加載自己的TSS描述符索引到pcb的tss_entry域。不管多少任務,都可以只使用兩個描述符,切換任務前更新GDT中的描述符即可。由于GDT有長度限制,只能存放8096個描述符,linux操作系統限制了任務的數量,我不知道為什么要這樣做,因為突破進程數限制看起來如此的簡單。
06/task.c
unsigned long long set_tss(unsigned long long tss)
{ // 參數為tss在內存中的地址
unsigned long long __tss_entry = 0x0080890000000067ULL;
__tss_entry |= ((tss)<<16) &
0xffffff0000ULL; //
基地址低24位
__tss_entry |= ((tss)<<32) & 0xff00
0000 0000 0000ULL; // 基地址高8位
return gdt[CURR_TASK_TSS] = __tss_entry;
}
這個函數產生TSS描述符,并存放它到GDT中,可以看到,描述符的DPL設置為0,所以只有內核可以用這個描述符。
屬性0080,8900,0000,0067分析:0x67是長度103(從0開始計算,即長104),89即p位為1,dpl位為0,b位為1,80是粒度G位為1
unsigned long long get_tss(void) {
return gdt[CURR_TASK_TSS];
}
LDT
我們看了這么多關于GDT和LDT的代碼后,LDT非常容易理解了。它和GDT差不多,區別是具有局部特性,每個任務都可以有自己的LDT,我們在skelix內核中為每個任務在LDT中設置兩個描述符項,第一項是代碼段,第二項是數據段和堆棧段,描述符選擇子格式如下:
圖-4
15______________________________3___2____1___0__
|
Index |
TI | RPL
|
|_______________________________________________|
我們設置所有任務的特權級為3,即RPL = 11b,且TI = 1(表示使用LDT而不是GDT),和GDT不同的是,LDT的第一項可以使用而不是保留,所以代碼段的選擇子為0x7,而數據段和堆棧段選擇子為0xf。
TSS數據結構中,有一個LDT域,保存的是GDT表中的描述符。聽起來可能昏菜,我們現在來搞明白她。首先,每個進程都有自己的LDT表,而且這個表可以在內存的任何地方(暫時不考慮虛擬內存),所以需要一個描述符來索引這個內存地址(即LDT表),這個描述符就放在GDT中,并且在TSS中存放一份該描述符的選擇子。
畫個圖來說明好了:
圖-5
________________
________________
|
TASK
| | GDT
|
|________________|
|
|
| TSS __________|/ 選擇子存于此 |________________|
| | LDT
field|--------------------------| 描述符 |
|
----------|\
/|________________|
|________________| /
| |
|
| /
|
|
|
| 描述符索引該
LDT /
|
|
|
| -------------
|
|
|
|
/
|________________|
|
| /
|
| /
|
| /
|
| /
|
| /
|
| /
|________________| /
|
LDT |/
|________________|
|________________|
GDT中的第三項用于任務TSS,第四項用于任務LDT。通過選擇子0x18和0x20分包可以索引到它們。和設置TSS一樣,對應也寫有兩個LDT操作的函數。
06/task.c
unsigned long long
set_ldt(unsigned long long ldt) {
unsigned long long ldt_entry =
0x008082000000000fULL; // DPL 是3而不是0
ldt_entry |= ((ldt)<<16) &
0xffffff0000ULL;
ldt_entry |= ((ldt)<<32) & 0xff00000000000000ULL;
return gdt[CURR_TASK_LDT] = ldt_entry;
}
unsigned long long
get_ldt(void) {
return gdt[CURR_TASK_LDT];
}
現在,我們將設置所有任務使用相同的LDT,這些任務共享相同的內存空間。如果你要設計字節的OS,這不會是個好主意。但是后面會為這些任務通過虛擬內存機制來設置不同的內存空間,后續課程會講到。
06/include/task.h
#define DEFAULT_LDT_CODE
0x00cffa000000ffffULL
#define DEFAULT_LDT_DATA 0x00cff2000000ffffULL
上面就是任務的LDT描述符定義,注意DPL(descriptor priority level)值為3。
上面已經提到,所有任務使用一個單向鏈表連接起來,其中有兩個重要指針:一個是任務0(TASK0)的next指針,它是所有任務鏈表的頭指針;另一個是current指針,指向當前正在運行的任務。
創建任務并調度
首先,我們定義有:
06/task.c
struct TASK_STRUCT *current = &TASK0;
現在我們來看看新任務是如何創建的:
06/init.c
static void
new_task(struct TASK_STRUCT *task, unsigned int eip,
unsigned int stack0, unsigned int stack3) {
// 這個函數有4個參數:第一個參數是任務數據結構的內存地址
// 第二個參數是任務入口地址
// 第三個和第四個參數是0環和3環特權級下堆棧地址
// 由于堆棧地址的描述符選擇子是固定的,所以就不用傳進來了
memcpy(task, &TASK0, sizeof(struct
TASK_STRUCT)); // TASK0 作為任務模板
task->tss.esp0 = stack0;
task->tss.eip = eip;
task->tss.eflags =
0x3202; // 合適的狀態標識
task->tss.esp = stack3;
task->priority = INITIAL_PRIO; // 新任務默認優先級
task->state = TS_STOPPED;
task->next = current->next;
current->next = task;
task->state = TS_RUNABLE;
}
extern void task1_run(void);
// 任務入口函數
extern void task2_run(void);
static long task1_stack0[1024] = {0xf, };
static long task1_stack3[1024] = {0xf, };
static long task2_stack0[1024] = {0xf, };
static long task2_stack3[1024] = {0xf, };
// 因為沒有內核中沒有實現內存管理,所以固定設置一些任務和她們使用的堆棧。
// 任務0運行在內核態,任務1和任務2運行在ring3
void
init(void) {
char wheel[] = {'\\', '|', '/', '-'};
int i = 0;
struct TASK_STRUCT
task1;
// 移到全局定義較合適
struct TASK_STRUCT task2;
idt_install();
pic_install();
kb_install();
timer_install(100);
set_tss((unsigned long
long)&TASK0.tss);
// 讓任務0先跑起來
set_ldt((unsigned long long)&TASK0.ldt);
__asm__ ("ltrw
%%ax\n\t"::"a"(TSS_SEL)); // 加載任務0使用的TR和LDTR寄存器
__asm__ ("lldt %%ax\n\t"::"a"(LDT_SEL)); //
使用ltrw和lldt指令
sti();
// 從現在開始捕獲中斷或異常
new_task(&task1, //
注意:創建任務時,中斷是使能的
(unsigned
int)task1_run,
(unsigned
int)task1_stack0+sizeof task1_stack0,
(unsigned
int)task1_stack3+sizeof task1_stack3);
new_task(&task2,
(unsigned
int)task2_run,
(unsigned
int)task2_stack0+sizeof task2_stack0,
(unsigned
int)task1_stack3+sizeof task2_stack3);
__asm__ ("movl
%%esp,%%eax\n\t" \
"pushl
%%ecx\n\t" \ // 任務0內核棧ss
"pushl %%eax\n\t" \
// 任務0內核棧esp0
"pushfl\n\t"
\
// eflags
"pushl
%%ebx\n\t" \ // 任務0代碼段選擇子cs
"pushl
$1f\n\t"
\ // 任務0函數地址eip:就是下面第2行匯編
"iret\n"
\
"1:\tmovw
%%cx,%%ds\n\t" \
"movw %%cx,%%es\n\t" \
"movw %%cx,%%fs\n\t" \
"movw %%cx,%%gs" \
::"b"(USER_CODE_SEL),"c"(USER_DATA_SEL));
// 注意:現在開始內核運行在任務0上了,通過iret指令長跳轉到任務0上
// (先加載正確的EIP,CS,SS,ESP,圖如下:)
+--------------------+
| LDT stack selector |
+--------------------+
|
ESP |
+--------------------+
|
EFLAGS |
+--------------------+
| LDT code selector |
+--------------------+
|
EIP |
+--------------------+
for (;;) {
__asm__ ("movb
%%al, 0xb8000+160*24"::"a"(wheel[i]));
if (i == sizeof wheel)
i = 0;
else
++i;
}
}
現在,任務已經有了,萬事具備只欠東風了,下面加入時鐘中斷代碼進行任務調度。
06/timer.c
void do_timer(void)
{ // 時鐘中斷處理函數
struct TASK_STRUCT *v = &TASK0;
int x, y;
++timer_ticks;
get_cursor(&x, &y);
set_cursor(71, 24);
kprintf(KPL_DUMP, "%x", timer_ticks);
set_cursor(x, y);
outb(0x20, 0x20);
cli();
for (; v; v=v->next) { // 遍歷鏈表,調整任務優先級
if (v->state == TS_RUNNING) {
if
((v->priority+=30) <= 0)
v->priority = 0xffffffff;
} else
v->priority -=
10; // 值越低,優先級越高(等待的任務優先級會變高)
// *nix內核通常這樣做:較小的值優先級較高
}
if (! (timer_ticks%1))
scheduler();
sti();
}
調度函數實現:
06/task.c
void scheduler(void) {
struct TASK_STRUCT *v = &TASK0, *tmp = 0;
int cp = current->priority;
for (; v; v = v->next) {
if ((v->state==TS_RUNABLE) &&
(cp>v->priority)) {
tmp = v;
cp = v->priority;
}
} // 遍歷鏈表,找尋優先級最高的任務(即值最小的任務)
if (tmp && (tmp!=current))
{ // tmp是遍歷的結果
current->tss_entry =
get_tss(); // 置TSS 和 LDT描述符
current->ldt_entry = get_ldt();
tmp->tss_entry =
set_tss((unsigned long long)((unsigned int)&tmp->tss));
tmp->ldt_entry = set_ldt((unsigned
long long)((unsigned int)&tmp->ldt));
current->state = TS_RUNABLE;
tmp->state = TS_RUNNING;
current = tmp;
__asm__
__volatile__("ljmp $" TSS_SEL_STR
", $0\n\t");
// skelix通過長跳轉到TSS描述符上(偏移應為0)切換任務
}
}
下面是任務
A & B
06/isr.s
task1_run:
call do_task1
jmp task1_run
task2_run:
call do_task2
jmp task2_run
這里使用匯編而不是c語言來寫這兩個任務的入口函數的原因,
是不想在跳轉前后改變內核棧。
我們現在看看能不能在任務1和任務2中使用kprintf:
06/init.c
void
do_task1(void) {
unsigned int cs;
__asm__ ("movl
%%cs, %%eax":"=a"(cs));
kprintf(KPL_DUMP, "%x", cs);
for (;;)
;
}
void
do_task2(void) {
unsigned int cs;
__asm__ ("movl
%%cs, %%eax":"=a"(cs));
kprintf(KPL_PANIC, "%x", cs);
for (;;)
;
}
修改Makefile 中的 KERNEL_OBJS:
06/MakefileKERNEL_OBJS= load.o init.o isr.o timer.o libcc.o scr.o kb.o task.o
kprintf.o exceptions.o
編譯,運行一把。截圖就不貼出來了(結果是看不到任務1和任務2的打印:ring3任務當然不能使用ring0函數)
ok,現在改一下這兩個任務函數:
06/init.cvoid
do_task1(void) {
print_c('A', BLUE, WHITE);
}
void
do_task2(void) {
print_c('B', GRAY, BROWN);
}
編譯,運行,正是我們想要的結果。