作者:于波
原創作品轉載請註明出處
參考:《Linux內核分析》MOOC課程http://mooc.study.163.com/course/USTC-1000029000
這篇博客是Linux內核分析課程第二週課程的作業,要求是完成一個簡單的時間片輪轉多道程序系統內核,並分析理解操作系統是如何完成進程啓動和進程切換的。
一、實驗環境介紹
實驗環境的搭建過程可以從mykernel的說明中找到,也可以使用課程實驗樓提供的虛擬機來完成。鏈接中的說明文檔已經介紹的很清楚,只要點擊鏈接打開,然後按照上面的步驟一步步做就能很容易把環境建好了。
建好的環境中,有兩個已經給我們準備好的文件 mymain.c和myinterrupt.c
他們的內容分別如下所示,這裏省略了包含頭文件的內容,而只關注函數體部分。
- mymain.c:
- myinterrupt.c:
mymain.c中,my_start_kernel()函數是內核的入口,它執行了一個無限的循環,每次循環中都在一個整數值i上自增1,每增加十萬,就輸出一行提示來,同時輸出i的值;
myinterrupt.c中,my_timer_handler()是系統的計時器的回調函數,每次計時器引發了中斷,這個函數就會被調用,現在這個中斷處理指示輸出了一行文字表明中斷處理函數被執行了。
我們來運行一下這個內核來直觀地感受下他的執行結果。編譯這份內核代碼,然後在模擬器下運行,運行界面將如下圖所示:
這個簡單的程序只有內核入口函數的主循環和中斷處理程序在工作。從執行結果也可以看到我們的內核主循環和時鐘中斷處理程序交替產生的輸出。而一個操作系統應該能夠同時允許多個進程在其上執行,而我們今天的任務,就是實現一個簡單的按照時間片輪轉規則,來同時執行多個進程的系統內核。
二、進程描述數據結構
精簡的進程描述數據結構定義在頭文件mypcb.h中,內容如下所示:
/*
* File: mykernel/mypcb.h
*/
#define MAX_TASK_NUM 10
#define KERNEL_STACK_SIZE (16*1024)
// CPU Register data of a task
struct Thread {
unsigned long eip; // Point to CPU run address
unsigned long esp; // Point to the task stack's top address
// TODO: other attribute of system thread
};
// PCB struct
typedef struct PCB {
int pid; // Task ID
volatile long state; // -1:unrunnable, 0: runnable, >0 stoped
char stack[KERNEL_STACK_SIZE]; // Process stack
struct Thread thread;
unsigned long task_entry; // Task execute entry memory address
struct PCB * next; // Next task, all tasks linked in circle
// TODO: other attribute of process control block
}tPCB;
void my_schedule(void);
首先定義了一個宏MAX_TASK_NUM來控制我們的內核交替執行的進程數量,KERNEL_STACK_SIZE是每個進程在內核中分配的棧空間的大小,這裏我們指定了16K的大小。
結構體Thread定義了一些變量來保存進程被掛起時的現場,我們的這一版的內核只保存程序計數器EIP和棧頂指針ESP。然後結構體tPCB定義了控制進程運行需要的一些屬性。
最後是進程調度函數的函數聲明。
因爲這個頭文件完全是C程序,註釋也比較全面,很容易理解。所以就不多說了。
三、內核入口函數 (mymain.c)
mymain.c函數的內容比較長,爲了能看得更清楚,我們一段一段來解釋,黃底部分是我們的代碼內容。
#include "mypcb.h"
tPCB task[MAX_TASK_NUM];
tPCB * g_current_task = NULL;
volatile int g_need_sched = 0;
這裏定義了全局的結構保存系統中的所有進程控制信息,g_current_task是當前正在運行的進程控制塊指針,另外定義了全局的變量g_need_sched來表示當前系統的調度狀態,當該值爲1是表示要求內核執行一次進程的切換。
void my_process(void); // 進程入口函數聲明
void __init my_start_kernel(void)
{
int i, pid = 0;
task[pid].pid = pid;
task[pid].state = 0;
task[pid].task_entry = (unsigned long)my_process;
task[pid].thread.eip = (unsigned long)my_process;
task[pid].thread.esp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1];
task[pid].next = &task[pid];
初始化進程控制塊數組的第1塊,進程ID設置爲0,進程狀態設置爲0,因爲這是我們馬上要運行的第一個進程;進程入口task_entry設置爲函數my_process,同時程序計數器EIP也設置爲函數my_process的入口地址;進程棧頂指針設置爲進程控制塊數組中預留的棧空間的最高地址,因爲棧的增長是向下的;最後將下一個內存控制塊指針指向自己,構成一個只有一個進程控制塊的環。
for(i = 1; i < MAX_TASK_NUM; i++)
{
memcpy(&task[i], &task[0], sizeof(tPCB));
task[i].pid = i;
task[i].state = -1;
task[i].thread.esp=(unsigned long)&task[i].stack[KERNEL_STACK_SIZE-1];
task[i-1].next = &task[i];
}
task[MAX_TASK_NUM-1].next = &task[0];
這一部分是初始化更多的內存控制塊,將後續的其他內存控制塊依次初始化,運行狀態設置爲-1(未準備運行),入口地址也是my_process函數地址,棧頂地址設置爲各自的棧空間的最高地址;將上一塊控制塊的next指針指向當前塊,最後在循環外,將最後一個控制塊的next指針指向第一塊內存控制塊,從而使所有的內存控制塊構成一個單向的循環列表。
pid = 0;
g_current_task = &task[pid];
當前運行的進程指向第一個進程。
asm volatile(
"movl %1,%%esp\n\t"
"pushl %1\n\t"
"pushl %0\n\t"
"ret\n\t"
"popl %%ebp\n\t"
:
: "c" (task[pid].thread.eip), "d" (task[pid].thread.esp)
);
}
這塊彙編代碼將使CPU轉向第一個進程控制塊指定的入口函數去執行,也就是完成了程序啓動的功能。下面來分析一下它是如何做到的。分析之前,有必要先簡單介紹一下嵌入式彙編的基礎知識.
C嵌入彙編的基本格式是asm("彙編語句":輸出參數列表:輸入參數列表:影響的寄存器)。
上面的代碼中,輸出列表爲空,輸入參數有兩個,分別是thread.eip和thread.esp。彙編語句中%0表示引用參數列表的第0個參數,即thread.eip,而%1就是引用第一個參數,即thread.esp。而參數列表中的"c"和"d"分別表示將引用變量的值加載進ECX和EDX寄存器。更詳細的列表可以通過關鍵字”C嵌入式彙編“很容易在網絡上獲得。
有了這些知識就可以回頭來看我們的彙編代碼了。
movl %1,%%esp 將內存控制塊中的ESP的值賦給ESP寄存器,結合之前的PCB初始化代碼,這句實際是用進程的棧空間的最高地址初始化ESP寄存器;
pushl %1 再次訪問我們的棧頂地址值,並將這個值保存到棧上;
後面的兩句彙編應該合在一起看,
pushl %0
ret
首先把內存控制塊中的EIP壓入棧,然後用ret指令將該值從棧上彈出並賦值給EIP寄存器,由此間接實現了改變程序計數器EIP的功能,讓我們的CPU跳轉到指定的函數地址去執行。而這裏我們修改的EIP的值,是在初始化階段設置的函數my_process函數入口地址。因此我們的內核通過上面的四句代碼就可以實現進程棧指針初始化,並跳轉到該進程的入口函數執行的功能了。
void my_process(void)
{
printk(KERN_NOTICE ">>>> Within task: %d <<<<\n", g_current_task->pid);
int i = 0;
while(1)
{
i++;
if ( i % 10000 == 0 )
{
if(g_need_sched == 1)
{
g_need_sched = 0;
my_schedule();
}
}
}
}
這是我們進程的入口函數,在進程剛開始的時候會輸出當前進程的進程ID,隨後就進入一個無限循環,每循環1000次,檢查一下全局的調度標誌,如果發現系統指示要進行進程調度,則執行一次調度函數my_schedule(),同時清空系統的進程調度標誌。
這個調度函數實現在myinterrupt.c中,後面會解釋。
四、中斷處理程序 (myinterrupt.c)
#include "mypcb.h"
extern tPCB task[MAX_TASK_NUM];
extern tPCB * g_current_task;
extern volatile int g_need_sched;
引用的外部進程控制塊數組和當前運行的進程指針,以及全局的調度標誌。
volatile int time_count = 0;
// Called by timer interrupt.
void my_timer_handler(void)
{
if(time_count % 1000 == 0 && g_need_sched != 1)
{
printk(KERN_NOTICE ">>>> Timer handler set schedule flag here <<<<\n");
g_need_sched = 1;
}
time_count++;
return;
}
這個中斷處理函數會被系統時鐘週期性的調用,每次被調用,就增加一個計數器的值,每增加1000,就檢查一次全局調度標誌,如果標誌還沒有被設置,就設置該標誌位1,並輸出一些信息來標識時鐘終端處理函數設置了調度標誌。 一旦這個全局調度標誌被設置,在mymain.c的my_process函數中就會檢測到,然後下面的my_schedule函數就將被調用。
void my_schedule(void)
{
tPCB * next;
tPCB * prev;
if( g_current_task == NULL || g_current_task->next == NULL)
{
// No Running task or only have one running task, don't need schedule
return;
}
printk(KERN_NOTICE ">>>> Schedule tasks here <<<<\n");
next = g_current_task->next;
prev = g_current_task;
if( next->state == 0 ) // Task state is Runnable
{
printk(KERN_NOTICE ">>>>>> Next task state is Runnable <<<<<<\n");
asm volatile (
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
"1:\t"
"popl %%ebp\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
g_current_task = next;
printk(KERN_NOTICE ">>>> Switch from task %d to %d <<<<\n",
prev->pid,next->pid);
}
else
{
next->state = 0;
g_current_task = next;
printk(KERN_NOTICE ">>>> Switch from task %d to new task %d <<<<\n",
prev->pid, next->pid);
asm volatile(
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl %2,%%ebp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
}
return;
}
這個函數將全局的當前運行進程指針指向鏈表中的下一個進程,然後查看新進程的運行狀態,如果是可運行的(state==0),則執行下面一段彙編,並把進程的可運行狀態設置爲可運行狀態(next->state = 0;):asm volatile (
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
"1:\t"
"popl %%ebp\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
而如果新進程的可運行狀態不是可運行的,表示進程是第一次被啓動,就執行下面一段彙編代碼:
asm volatile(
"pushl %%ebp\n\t"
"movl %%esp,%0\n\t"
"movl %2,%%esp\n\t"
"movl %2,%%ebp\n\t"
"movl $1f,%1\n\t"
"pushl %3\n\t"
"ret\n\t"
: "=m" (prev->thread.esp), "=m" (prev->thread.eip)
: "m" (next->thread.esp), "m" (next->thread.eip)
);
這兩段代碼其實很相似,差別只在最後兩行上。我們只從第一段分析來入手,第一段代碼看懂之後,第二段也會很容易理解了。
代碼的第一行將當前的EBP壓棧;然後第二行將當前的ESP值存入被換出的進程的現場狀態變量thread.esp中;第三行是將被換入進程的esp值加載到當前的ESP寄存器中,也就是切換到被換入的進程的棧空間上去;第四行比較特殊,這個常數1f比較特殊,它其實是一條彙編指令,表示跳轉到標號爲1的行去繼續執行,也就是這段代碼的最後兩行處,而這條彙編是保存到了被換出進程的保存現場的內存空間thread.eip中,也就是說,這條指令就是下一次已經執行過的進程別換入是將跳轉到的代碼;第五行和第六行前面已經分析過,他們合起來實現了間接修改EIP寄存器的目的,而被修改的值,就是保存在進程的現場中的EIP值,由此實現了將程序跳轉到被換入的程序去執行的功能。
對被換入的進程,當第一次被換入時,thread.eip的值等於函數my_process的入口地址;如果一個進程之前被調用過,那麼thread.eip就已經被修改成了1f,所以下次再次被換入的時候就會執行這段代碼的最後兩行,也就是“popl %%ebp”,即將棧上的值出棧並賦值給寄存器EBP,而這正是第一行中保存在棧中的EBP值。
這些代碼執行完之後去了哪裏呢?別忘了,我們現在是在my_schedule中,而現在已經是這個函數的末尾了,所以下面我們退出my_shedule函數了,回到了my_process繼續執行計數器加一,繼續檢測全局調度標誌的循環中了。
五、中斷處理程序的切換
上面我們的代碼實現的只是在我們自定義的10個用戶進程中切換的功能,而中斷處理程序和內核入口函數所在的進程是如何切換的呢?其實原理是一樣的,無非也是保存當前的現場,將CPU狀態設置爲被換入的進程的信息,其中最主要的信息是EIP和ESP,像上面我們的切換程序所示的;而更復雜的程序還需要保存其他可能會被修改的寄存器,而這些Linux的中斷處理程序都已經給我們處理好了。
關於這個簡單的時間片輪轉切換進程的內核就解釋這麼多了,鄙人才疏學淺,有理解錯誤之處望多多指教。