进程的创建——fork函数
1. 進程的信息
- 進程的結構
在Linux中,一切皆文件,進程也是保存在內存中的一個實例,下圖描述了進程的結構:
- 堆棧:保存局部變量
- 數據段:一般存放全局變量和靜態變量
- 代碼段:存儲進程的代碼文件
- TSS狀態段:進程做切換時,需要保存進程現場以便恢復,一般存儲一些寄存器的值。
- task_struct : 進程的結構體描述符,用來描述一個進程的屬性,這是一種面向對象的編程思想,task_struct的結構大致如下:
通過管理進程對應的task_struct,可以完成進程的相關操作。
- GDT和LDT
操作系統在保護模式下,內存管理分為分段模式和分頁模式。分段模式下內存的尋址為「段基址:偏移地址」。對一個段的描述包括以下三個方面:【Base Address,Limit,Access】,他們加在一起被放在一個64bit長的數據結構中,被稱為段描述符。因此需要用64bit的寄存器去存儲段描述符,但是操作系統的段基址寄存器只能存儲16bit的數據,因此無法直接存儲64bit的段描述符,為了解決這個問題,操作系統將段描述符存放在一個全局的數組中,而段寄存器直接存儲對應的段描述符對應的下標,這個全局的數組叫做GDT
由于GDT也需要直接存在內存中,所以操作系統用GDTR寄存器來存儲GDT的基地址,因此尋 址的過程為:
1. 通過GDTR寄存器找到GDT的基地址 2. 通過段寄存器找到段描述符的索引 3. 通過GDT基地址+索引從GDT數組中找到段描述符 4. 通過段描述符基地址+偏移地址找到線性地址至于LDT,本質上和GDT是類似的,但也有不一樣的地方,LDT本身也是一段內存,因此需要段描述符去描述它,它的段描述符存在GDT中,而LDT有LDTR寄存器,LDTR并不存儲LDT的段基址,而是一個段選擇子,是LDT的索引。
用一張圖來詮釋GDT和LDT的尋址過程:
- 進程的狀態
進程一共有五種狀態,分別是:
-
TASK_RUNNING 運行狀態
運行狀態表示正在運行,只有這個狀態的進程才能被執行
-
TASK_INTERRUPTIBLE 可中斷睡眠狀態
當一個進程處于可中斷睡眠狀態時,它不會占用cpu資源,但是它可以響應中斷或者信號。如socket等待連接建立時,它是睡眠的,但是連接一旦建立就會被喚醒。這種狀態就是阻塞狀態。
-
TASK_UNINTERRUPTIBLE 不可中斷睡眠狀態
和TASK_INTERRUPTIBLE不同,處于TASK_UNINTERRUPTIBLE狀態的進程無法被中斷或者信號喚醒,假設一個進程是TASK_UNINTERRUPTIBLE狀態,你會驚奇的發現通過kill -9無法殺死該進程,因為它無法響應異步信號。這種狀態很少見,一般發生在內核態程序中,如讀取某個設備的文件,需要通過read系統調用通過驅動操作硬件設備讀取,這個過程是無法被中斷的。
-
TASK_ZOMBIE 僵死狀態
處于TASK_ZOMBIE狀態的進程并不代表著進程已經被銷毀,此時除了task_struct,進程占有的所有資源將被釋放,之所以不釋放task_struct是因為task_struct保存著一些統計信息,其父進程可能需要這些信息。
-
TASK_STOPPED
不保留task_struct,進程資源全部被釋放
2. 系統初始化——main函數
上面大致介紹了和進程相關的一些信息說明,本節將從kernel的main.c方法開始,分析進程的創建過程.
Linux的main.c文件,是Linux開機時內核初始化函數,在初始化的過程中,內核將創建系統的第一個進程:0號進程,0號進程不做任何操作,也不能被終止(除非系統異常或者關機),以后創建的每一個進程都是0號進程的子孫進程。
main.c的入口是main()函數,main()函數的主要實現:
void main(void){ROOT_DEV = ORIG_ROOT_DEV;drive_info = DRIVE_INFO; //省略了一段內存初始化操作mem_init(main_memory_start,memory_end); // 主內存區初始化trap_init(); // 陷阱門(硬件中斷向量)初始化blk_dev_init(); // 塊設備初始化chr_dev_init(); // 字符設備初始化tty_init(); // tty初始化time_init(); // 設置開機啟動時間 startup_timesched_init(); // 調度程序初始化(加載任務0的tr,ldtr)buffer_init(buffer_memory_end); // 緩沖管理初始化,建內存鏈表等。hd_init(); // 硬盤初始化floppy_init(); // 軟驅初始化sti(); // 所有初始化工作都做完了,開啟中斷// 下面過程通過在堆棧中設置的參數,利用中斷返回指令啟動任務0執行。move_to_user_mode(); // 移到用戶模式下執行if (!fork()) { init(); // 在新建的子進程(任務1)中執行。}for(;;) pause(); }我們來看一看和進程管理相關的兩個初始化過程:
time_init()函數主要從CMOS管中讀取一個實時時鐘的年月日時分秒等信息并保存起來,并且通過kernel_mktime()函數來計算一個startup_time作為系統的開機時間,而kernel_time()函數就是根據從CMOS讀出的信息計算出1970年1月1日到現在的一個時間。
分析這個函數主要想介紹一下內核一個很重要的時間概念:jiffies(系統滴答)
jiffies是系統的脈搏,或者說是系統的節拍。在內核中,系統會以一定的頻率發生定時中斷,也就是說,某個進程正在運行,運行一段時間后系統會暫停這個進程運行,然后切換到另一個進程運行,jiffies決定了系統發生中斷的頻率,因此它和進程的調度息息相關。
首先來看一個變量task[],它的定義為:
struct task_struct * task[NR_TASKS] = {&(init_task.task), }在sched.c文件中,定義了一個task數組,這個數組的類型為task_struct結構體,它的最大容量NR_TASKS=64,它的初始值也就是task[0] = init_task.task。
這個task數組的意義是:
task是一個保存進程結構體的數組,最大容量為64,task[0]的位置保存了0號進程task_struct
再回到sched_init()的代碼,首先定義了一個desc_struct指針,然后為0號進程設置了它的ldt段和tss段,ldt段是由數據段和代碼段構成的。然后從1開始遍歷task數組,將每個槽設置為null,并將其gdt設置為空,由于是從1開始遍歷,因此處于index=0的0號進程不會被置空。可見,sched_init()函數創建了0號進程。
3. 進程的創建——fork()函數
進行一系列初始化后,執行了這句代碼:
move_to_user_mode();這個函數是將當前模式由內核態轉為用戶態。
內核態:不可搶占的
用戶態:可搶占,可以進行調度的
也就是說,上述所有的初始化操作都是在內核態執行的,這么做的目的是,內核初始化過程是不能被中斷的,在內核態運行可以保證這一點。
切換到用戶態以后,便開始創建進程了:
if (!fork()) { init(); }這里的fork()函數,就是linux創建進程的函數,進入這個函數,它的聲明為:
static inline _syscall0(int,fork)syscall是系統調用函數,就是內核自己實現的一些函數,如 read,open,chmod等,這些函數可以直接提供給開發人員調用,而調用的過程需要切換到內核態進行,因為函數調用過程中不允許被中斷。這個調用過程稱為系統調用。
_syscall0的函數定義如下:
#define _syscall0(type,name) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80"\: "=a" (__res) \: "0" (__NR_##name)); \ if (__res >= 0) \return (type) __res; \ errno = -__res; \ return -1; \ }把參數替換成fork以后,是這個樣子:
#define _syscall0(type,fork) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80"\: "=a" (__res) \: "0" (__NR_FORK)); \ if (__res >= 0) \return (type) __res; \ errno = -__res; \ return -1; \ }這是一段匯編代碼,這段代碼的執行過程是這樣的:
set_system_gate(0x80,&system_call),因此產生0x80中斷以后會調用system_call,system_call在system_call.s中定義,
首先將各寄存器入棧,然后調用了關鍵的一個函數:
call sys_call_table(,%eax,4)sys_call_table是一個數組,在sys.h中定義,它保存著所有系統調用的函數名,%eax就是eax寄存器的值,前面提到過0x80中斷產生之前將_NR_FORK=2加入到了eax寄存器中,因此調用的就是sys_call_table[2],也就是sys_fork函數:
sys_fork:call find_empty_processtestl %eax,%eax js 1fpush %gspushl %esipushl %edipushl %ebppushl %eaxcall copy_processaddl $20,%esp 1: ret這是一段匯編代碼,首先調用了fork.c文件中的find_empty_process函數,目的在于從task進程數組中找到一個空的槽用于保存要創建的進程的task_struct,這個函數會返回進程的pid。
testl %eax,%eax 指令作用是將call find_empty_process函數的返回值保存到eax寄存器中。隨后進行了一系列寄存器數的壓棧,最后調用了copy_process()函數:
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,long ebx,long ecx,long edx,long fs,long es,long ds,long eip,long cs,long eflags,long esp,long ss) {struct task_struct *p;int i;struct file *f;p = (struct task_struct *) get_free_page();if (!p)return -EAGAIN;task[nr] = p;*p = *current; p->state = TASK_UNINTERRUPTIBLE;p->pid = last_pid; // 新進程號。也由find_empty_process()得到。p->father = current->pid; // 設置父進程p->counter = p->priority; // 運行時間片值p->signal = 0; // 信號位圖置0p->alarm = 0; // 報警定時值(滴答數)p->leader = 0; p->utime = p->stime = 0; // 用戶態時間和和心態運行時間p->cutime = p->cstime = 0; // 子進程用戶態和和心態運行時間p->start_time = jiffies; // 進程開始運行時間(當前時間滴答數)p->tss.back_link = 0;p->tss.esp0 = PAGE_SIZE + (long) p; // 任務內核態棧指針。p->tss.ss0 = 0x10; // 內核態棧的段選擇符(與內核數據段相同)p->tss.eip = eip; // 指令代碼指針p->tss.eflags = eflags; // 標志寄存器p->tss.eax = 0; // 這是當fork()返回時新進程會返回0的原因所在p->tss.es = es & 0xffff; // 段寄存器僅16位有效p->tss.ldt = _LDT(nr); // 任務局部表描述符的選擇符(LDT描述符在GDT中)p->tss.trace_bitmap = 0x80000000; // 高16位有效if (copy_mem(nr,p)) {task[nr] = NULL;free_page((long) p);return -EAGAIN;}set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));p->state = TASK_RUNNING; /* do this last, just in case */return last_pid; }這個函數非常長,省略了一些無關代碼,主要有以下幾個重要步驟:
*current指向當前進程,也就是調用fork函數的進程,本過程中就是0號進程,*p指向要創建的進程,本過程中就是1號進程。*p=*current,這不就是將0號進程的task_struct直接賦值給了1號進程嗎?原來進程的創建第一步都是先把它的父進程拿來拷貝一份。
這么做的目的是當前進程既不能處理信號,也無法參與調度。
要創建的進程p是通過拷貝父進程task_struct而來,但是作為一個進程,它需要有自己特定的屬性,因此需要對其特定的屬性進行賦值:
大部分屬性都容易看懂,但是有兩個地方卻暗藏玄機:
p->tss.eax = 0; p->tss.eip = eip;看似很常規的兩行代碼: 將子進程的eax寄存器設置為0,將子進程eip寄存器設置為父進程的eip寄存器值。
eax寄存器存儲著函數的返回值,而eip寄存器,存儲著cpu要去讀取的下一行指令代碼的位置,那尋根溯源一下,父進程下一行代碼是哪一行呢?
事實上,我們正在分析的fork系統調用的代碼并不屬于父進程執行的代碼,它屬于內核態程序,真正父進程執行的代碼應該是它產生軟中斷而調用fork系統調用的下一行,也就是這個:
#define _syscall0(type,fork) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80"\: "=a" (__res) \: "0" (__NR_FORK)); \ if (__res >= 0) \return (type) __res; \ errno = -__res; \ return -1; \中的這一行
if (__res >= 0)這也就意味著,當子進程開始運行的時候,會從這一行開始執行。
進程創建到這里,已經為要創建的進程創建了task_struct,并完成了task_struct初始化,也設置了進程的ldt段和tss段,那么這個進程已經可以開始運行并可以參與調度了,因此將進程設置為就緒狀態。
就是返回子進程的id,這里返回的是一號進程的id 1。
copy_process()函數返回了,sys_fork也就返回了,返回的值就是copy_process函數的返回值,這里就是一號進程的id=1,然后就返回到system_call執行,將各寄存器值出棧,然后0x80中斷就返回了,將切換到用戶態繼續執行0號進程的代碼,_syscall0將繼續往下執行
#define _syscall0(type,fork) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80"\: "=a" (__res) \: "0" (__NR_FORK)); \ if (__res >= 0) \return (type) __res; \ errno = -__res; \ return -1; \ }前面提到過_res的值就是eax,當前0號進程eax值就是sys_fork調用的copy_process()的返回值,前面提到了copy_process()返回的是子進程1號進程的進程id,也就是1。因此if條件符合,_syscall0返回1。
到這里fork()函數執行完畢并返回了1,回到fork調用的地方
if (!fork()) {init(); } for(;;) pause();由于fork()返回了1,所以這里if條件不符合,往下走到一段死循環,循環里調用了pause()函數,點開pause函數的聲明:
static inline _syscall0(int,pause)啊這。。又是這玩意,看到這里立馬就懂了,pause()函數也是一個系統調用,省去中間系統調用的過程,直接來到pause調用的函數:
int sys_pause(void) {current->state = TASK_INTERRUPTIBLE;schedule();return 0; }與前面fork系統調用不同的是,pause系統調用是c語言實現的。pause先將進程狀態設置為可中斷睡眠狀態,然后進行了一次schedule()也就是進行了一次進程調度,由于當前只有0號和1號兩個進程,所以進程調度的結果肯定是由0號進程切換到1號進程,至于進程的調度和進程的切換,后面會有詳細介紹這里就不展開了。
現在正在運行的是1號進程,cpu就會找到gdt找到1號進程的ldt,就會讀取1號進程的eip寄存器去讀取指令。現在重點來了,我們在介紹0號進程fork1號進程的時候提示過,當時0號進程將自己的eip寄存器值賦給了1號進程,所以1號進程eip寄存器存儲的下一行代碼是:
if (__res >= 0)_res是eax寄存器的值,而fork的時候0號進程將1號進程的eax寄存器值得設置為了0
p->tss.eax = 0;因此if條件也是符合的,就將_res 返回了,返回到哪里了呢?返回的肯定是fork()函數被調用的地方,也就是:
if (!fork()) {init(); } for(;;) pause();看到這你可能有點懵逼了,怎么又到這來了,0號進程不是已經執行過一次了嗎,又來。。
但是和之前不同的是,這里的fork()返回的_res值是0,是符合if條件的,然后會執行init()。。
這就是fork()函數很神秘的地方,它實現了一個函數"return了兩次",一次是父進程返回,一次是子進程返回。
至于init()函數,里面涉及了一些shell初始化,tty0初始化,輸入輸出設備初始化操作,跟進程的創建沒有太大的關系,就不繼續展開了。
4. 總結
本文以0號進程創建1號進程為例,分析了fork()函數詳細的過程,有以下:
總結
以上是生活随笔為你收集整理的进程的创建——fork函数的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: TCP协议如何保证可靠传输
- 下一篇: 【数学建模】第一讲-层次分析法