1. 背景
進程的建立過程無疑是最重要的作業系統處理過程之一,很多書和教材上說的最多的還是一些原理的部分,忽略了很多細節。比如,子進程複製父進程所擁有的資源,或者子進程和父進程共用相同的物理頁面,擁有自己的地址空間,子進程建立後接受統一調度執行等等。
原 理性的書籍更多地關注了進程建立過程中各個關鍵區段的功能,但由於過於抽象,很難理解,因此如果自己能夠實際操作,實踐這個過程就很重要,可以讓那些看起 來抽象的概念變的現實而容易理解,比如所謂的父進程的資源,父進程所擁有的物理頁面,甚至父進程的地址空間等等,這些抽象的概念其實只要實際操作一次就更 能有感性的認識。本人蔘考Linux0.11原始碼實踐了建立進程和調度,這個過程獲益匪淺,這裡把主要的學習成果結合實踐總結一下。
2. 0號進程
子進程的建立是基於父進程的,因此一直追溯上去,總有一個進程是原始的,即沒有父進程的。這個進程在Linux中的進程號是0,也就是傳說中的0號進程(可惜很多理論書上對這個重要的進程隻字不提)。
如果說子進程可以通過規範的建立進程的函數(如:fork())基於父進程複製建立,那麼0號進程並沒有可以複製和參考的對象,也就是說0號進程擁有的所有資訊和資源都是強制設定的,不是複製的,這個過程我稱為手工設定,也就是說0號進程是“純手工打造”,這是作業系統中“最原始”的一個進程,它是一個模子,後面的任何進程都是基於0號進程產生的。
手工打造0號進程最主要包括兩個部分:建立進程0運行時所需的所有資訊,即填充0號進程,讓它充滿“血肉”;二是調度0號進程的執行,即讓它“動”起來,只有動起來,才是真正意義上的進程,因為進程本身實際上是個動態概念。
不同的作業系統或者同一個作業系統的不同版本進程資訊的內涵可能會有些細微的差距,但大體上關鍵的部分和邏輯是沒有什麼不同的,我這裡只是基於Linux0.11的實現來描述進程建立的關鍵步驟和關鍵細節。
1)填充0號進程資訊
進程包括的內容非常複雜,但總的來說進程的資訊都是由進程的描述符引導標識的,因此填充0號進程的過程邏輯上是以填充其描述符為牽引完成的(也有書將進程描述符稱為進程式控制制塊)。下面是Linux0.11版進程的描述符資訊結構體:
struct task_struct {
long state,counter,priority, signal;
struct sigaction sigaction[32];
long blocked;
int exit_code;
unsigned long start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid,gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
int tty;
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
struct desc_struct ldt[3];
struct tss_struct tss;
};
可以看到進程描述符裡的資訊很多,大體上有幾部分:
a. 進程的運行資訊,如進程的目前狀態(state),進程的各種時間片消耗記錄(utime、stime等),進程的訊號(signal)和優先順序(priority)等。
b. 進程的基本建立資訊,如進程號(pid),進程的建立使用者(uid)等。
c. 進程的資源類資訊,如使用的tty自裝置號(tty),檔案根目錄i節點結構(root)等。
d. 進程執行和切換CPU需要使用的關鍵資訊:局部描述符表(LDT)、任務狀態段(TSS)資訊。
這些資訊並不是在進程建立的時候就全部確定的,大部分只是暫時賦一個初值,在啟動並執行時候會動態更改,也有一些是要在進程運行前設定好的,才能保證進程被正確地執行起來。實際上,我們最需要填充的資訊是那些使得作業系統可以順利切換到0號進程的資訊,最重要的顯然是進程的LDT和TSS資訊。TSS是CPU在切換任務時需要使用的資訊,而LDT是局部描述符表,0號進程是第一個運行在使用者態的進程,需要使用自己的LDT。TSS和LDT是保證不同進程之間相互隔離的重要機制。
實際上還有一個重要的資訊不是放在進程本身的描述符裡的,而是放在通用描述元表GDT中,因為所有的進程是由作業系統統一管理的,因此作業系統至少要保持對它們的索引,這種索引性質的資訊放在作業系統核心的GDT中。對於Linux0.11來說,每個進程都有一個LDT和一個TSS描述符,而Linux2.4之後是每個CPU一個TSS描述符並儲存在GDT中,而不是每個進程一個。當然這種區別會造成進程建立和切換過程中一些細節上的差異,但本質的部分和任務的切換過程並沒有任何不同。
下面是Linux0.11手動填充進程0的進程描述符資訊的宏:
#define INIT_TASK /
/* state etc */ { 0,15,15, /
/* signals */ 0,{{},},0, /
/* ec,brk... */ 0,0,0,0,0,0, /
/* pid etc.. */ 0,-1,0,0,0, /
/* uid etc */ 0,0,0,0,0,0, /
/* alarm */ 0,0,0,0,0,0, /
/* math */ 0, /
/* fs info */ -1,0022,NULL,NULL,NULL,0, /
/* filp */ {NULL,}, /
/* ldt */ { /
{0,0}, /
{0x9f,0xc0fa00}, /
{0x9f,0xc0f200}, /
}, /
/*tss*/ { 0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,/
0,0,0,0,0,0,0,0, /
0,0,0x17,0x17,0x17,0x17,0x17,0x17, /
_LDT(0),0x80000000, {} /
}, /
}
除了填充進程描述符的資訊外,還需要在GDT中設定相關的項,即進程0的LDT和TSS選擇符,這個工作是在sched_init()裡完成的:
void sched_init(void){
...
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
...
ltr(0);
lldt(0);
}
可以看到,在進程0的TSS和LDT描述符資訊設定到GDT中後,立刻設定了TR寄存器和LDTR寄存器,為即將運行0號進程作準備。
2)運行0號進程
進程0是運行在使用者態下的進程,因此就意味著進程0的運行過程實際上是一個從0級特權級到3級特權級切換的過程,使用的是CPU指令iret,類比了中斷調用的返回過程,具體執行過程由move_to_user_mode完成:
#define move_to_user_mode() /
__asm__ ("movl %%esp,%%eax/n/t" /
"pushl $0x17/n/t" /
"pushl %%eax/n/t" /
"pushfl/n/t" /
"pushl $0x0f/n/t" /
"pushl $1f/n/t" /
"iret/n" /
"1:/tmovl $0x17,%%eax/n/t" /
...)
這個宏將進程0執行時的ss,esp,eflags.cs,eip資訊全部壓棧,待到執行iret指令時,CPU將這幾項資訊從棧中彈出載入到相應的寄存器中,這樣就實現了進程0的啟動執行。從這裡也可以看出,進程0剛開始執行時幾個關鍵寄存器的資訊也是在其運行前事先設定好的,從進程描述符資訊到執行資訊均是人為設定,因此我稱之為“純手工打造的進程”。
3. 子進程的建立
有了0號進程這個原始的進程,再來看子進程的建立就比較容易理解一些。除了0號進程外,其餘的進程均使用系統調用fork()完成,其具體工作由核心態的_sys_fork實現:
_sys_fork:
call _find_empty_process
testl %eax,%eax
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process
addl $20,%esp
1: ret
可以看到,一個進程的建立主要有兩個步驟:一是找到一個空閑進程資源(find_empty_process),Linux0.11來說可以同時啟動並執行進程數目是64個,是有限的,因此需要先得到一個閒置進程表中的一項用來索引即將建立的進程資訊;第二個主要步驟就是複製(copy_process),這個函數具體來實現子進程基於父進程的複製建立。
主要包括的步驟和內容是:
1) 為新進程在記憶體中分配一個物理頁,將新進程的描述符資訊填充在該頁的開頭,並設定新進程的描述符裡各項資訊;
2) 拷貝父進程的頁表,使得它們共同指向相同的物理頁,同時將父進程的各個頁表屬性改為唯讀,這樣將來可以使用寫時複製機制。
3) 在GDT中設定該進程項的TSS和LDT選擇符。
Linux0.11版本子進程內容的設定主要內容就是這些,當然不同版本會有不同,在改進執行效能上也會有改進,但這個版本體現出來的最基本建立過程基本上反映了作業系統建立進程的主要過程。
4. 子進程的運行
子進程在建立好後並不能立即執行,至少需要一次調度,而這個調度到子進程的運行過程就完全不需要像進程0那樣人為在棧上設定資訊然後用iret方式,而是執行的任務的切換過程。不考慮進程調度的各個演算法和選擇細節,最終負責完成切換操作的函數如下:
#define switch_to(n) {/
struct {long a,b;} __tmp; /
__asm__("cmpl %%ecx,_current/n/t" /
"je 1f/n/t" /
"movw %%dx,%1/n/t" /
"xchgl %%ecx,_current/n/t" /
"ljmp %0/n/t" /
"cmpl %%ecx,_last_task_used_math/n/t" /
"jne 1f/n/t" /
"clts/n" /
"1:" /
::"m" (*&__tmp.a),"m" (*&__tmp.b), /
"d" (_TSS(n)),"c" ((long) task[n])); /
}
最終的切換執行了一個ljmp操作,它的運算元是一個任務描述符,這會導致CPU執行一次任務切換,根據新進程的TSS資訊將相關資訊載入進cs,eip,eflags,ss,esp寄存器開始執行新的代碼。當然由於先前拷貝的父進程的相關頁面被設定為唯讀,子進程第一次執行到該頁面時會觸發頁保護的異常,這時會觸發寫時複製操作,為子進程分配自己的相應頁面。
符:任務(task)和進程(process)的區別
任務和進程很容易被人混淆,甚至在Linux中進程描述符結構體也是用task_struct表示,而不是process,這更讓人有的時候搞不清楚。我個人認為,其實任務的概念更底層,可以認為是基於CPU的角度來考慮的,進程所處的層次更高一些,應當可以認為是作業系統一級的概念。
任務關注點是一組程式操作,這組操作實現了某個功能,它最終會涉及到指令層級,我們說任務的切換最終需要關注的還是CPU的相關指令。
進程的概念通常是指程式的執行,是動態過程。進程除了包含其要啟動並執行程式之外,還包括運行時的諸多資訊,如已耗用時間,訊號等等。