環境:lubuntu 13.04 kernel 3.8 gcc 4.7.3
作者: SA12226265
簡介: 本文基於Linux™系統對進程創建與加載進行分析,文中實現了Linux庫函數fork、exec,剖析內核態執行過程,並進一步展示進程創建過程中進程控制塊字段變化信息及ELF文件加載過程。
一、初識Linux進程
進程這個概念是針對系統而不是針對用戶的,對用戶來說,他面對的概念是程序。當用戶敲入命令執行一個程序的時候,對系統而言,它將啟動一個進程。但和程序不同的是,在這個進程中,系統可能需要再啟動一個或多個進程來完成獨立的多個任務。簡單介紹下進程的結構。
1.1 Linux下的進程查看
我們可以使用$ps命令來查詢正在運行的進程,比如$ps -eo pid,comm,cmd,下圖為執行結果:
(-e表示列出全部進程,-o pid,comm,cmd表示我們需要PID,COMMAND,CMD信息)
每一行代表了一個進程。每一行又分為三列。第一列PID(process IDentity)是一個整數,每一個進程都有一個唯一的PID來代表自己的身份,進程也可以根據PID來識別其他的進程。第二列COMMAND是這個進程的簡稱。第三列CMD是進程所對應的程序以及運行時所帶的參數。
(第三列有一些由中括號[]括起來的。它們是kernel的一部分功能,顯示為進程的樣子主要是為了方便操作系統管理。)
我們看第一行,PID為1,名字為init。這個進程是執行/bin/init這一文件(程序)生成的。當Linux啟動的時候,init是系統創建的第一個進程,這一進程會一直存在,直到我們關閉計算機。
1.2 Linux下進程的結構
Linux下一個進程在內存里有三部分的數據,就是"代碼段"、"堆棧段"和"數據段"。其實學過匯編語言的人一定知道,一般的CPU都有上述三種段寄存器,以方便操作系統的運行。這三個部分也是構成一個完整的執行序列的必要的部分。
"代碼段",顧名思義,就是存放了程序代碼的數據,假如機器中有數個進程運行相同的一個程序,那么它們就可以使用相同的代碼段。"堆棧段"存放的就是子程序的返回地址、子程序的參數以及程序的局部變量。而數據段則存放程序的全局變量,常數以及動態數據分配的數據空間(比如用malloc之類的函數取得的空間)。系統如果同時運行數個相同的程序,它們之間就不能使用同一個堆棧段和數據段。
1.3 Linux進程描述符
在Linux中每一個進程都由task_struct 數據結構來定義.task_struct就是我們通常所說的PCB.它是對進程控制的唯一手段也是最有效的手段. 當我們調用fork() 時,系統會為我們產生一個task_struct結構。然后從父進程,那里繼承一些數據, 並把新的進程插入到進程樹中,以待進行進程管理。
以下是進程描述符的源碼:
1 struct task_struct { 2 volatile long state; 3 unsigned long flags; 4 int sigpending; 5 mm_segment_taddr_limit; 6 volatile long need_resched; 7 int lock_depth; 8 long nice; 9 unsigned long policy; 10 struct mm_struct *mm; 11 int processor; 12 unsigned long cpus_runnable, cpus_allowed; 13 struct list_head run_list; 14 unsigned longsleep_time; 15 struct task_struct *next_task, *prev_task; 16 struct mm_struct *active_mm; 17 struct list_headlocal_pages; 18 unsigned int allocation_order, nr_local_pages; 19 struct linux_binfmt *binfmt; 20 int exit_code, exit_signal; 21 int pdeath_signal; 22 unsigned long personality; 23 int did_exec:1; 24 pid_t pid; 25 pid_t pgrp; 26 pid_t tty_old_pgrp; 27 pid_t session; 28 pid_t tgid; 29 int leader; 30 struct task_struct*p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr; 31 struct list_head thread_group; 32 struct task_struct *pid hash_next; 33 struct task_struct **pid hash_pprev; 34 wait_queue_head_t wait_chldexit; 35 struct completion *vfork_done; 36 unsigned long rt_priority; 37 unsigned long it_real_value, it_prof_value, it_virt_value; 38 unsigned long it_real_incr, it_prof_incr, it_virt_value; 39 struct timer_listreal_timer; 40 struct tmstimes; 41 unsigned long start_time; 42 long per_cpu_utime[NR_CPUS],per_cpu_stime[NR_CPUS]; 43 uid_t uid,euid,suid,fsuid; 44 gid_t gid,egid,sgid,fsgid; 45 int ngroups; 46 gid_t groups[NGROUPS]; 47 kernel_cap_tcap_effective, cap_inheritable, cap_permitted; 48 int keep_capabilities:1; 49 struct user_struct *user; 50 struct rlimit rlim[RLIM_NLIMITS]; 51 unsigned shortused_math; 52 charcomm[16]; 53 int link_count, total_link_count; 54 struct tty_struct*tty; 55 unsigned int locks; 56 struct sem_undo*semundo; 57 struct sem_queue *semsleeping; 58 struct thread_struct thread; 59 struct fs_struct *fs; 60 struct files_struct *files; 61 spinlock_t sigmask_lock; 62 struct signal_struct *sig; 63 sigset_t blocked; 64 struct sigpendingpending; 65 unsigned long sas_ss_sp; 66 size_t sas_ss_size; 67 int (*notifier)(void *priv); 68 void *notifier_data; 69 sigset_t *notifier_mask; 70 u32 parent_exec_id; 71 u32 self_exec_id; 72 spinlock_t alloc_lock; 73 void *journal_info; 74 };
主要結構分析:
volatile long state; 說明了該進程是否可以執行,還是可中斷等信息
unsigned long flags; Flage 是進程號,在調用fork()時給出
int sigpending; 進程上是否有待處理的信號
mm_segment_taddr_limit; 進程地址空間,區分內核進程與普通進程在內存存放的位置不同(0-0xBFFFFFFF foruser-thead 0-0xFFFFFFFF forkernel-thread)
volatile long need_resched;調度標志,表示該進程是否需要重新調度,若非0,則當從內核態返回到用戶態,會發生調度
struct mm_struct *mm; 進程內存管理信息
pid_tpid; 進程標識符,用來代表一個進程
pid_tpgrp; 進程組標識,表示進程所屬的進程組
task_struct的數據成員mm指向關於存儲管理的struct mm_struct結構。它包含着進程內存管理的很多重要數據,如進程代碼段、數據段、未未初始化數據段、調用參數區和進程。
二、 如何創建一個進程
2.1 Linux下的進程控制
在傳統的Linux環境下,有兩個基本的操作用於創建和修改進程:函數fork()用來創建一個新的進程,該進程幾乎是當前進程的一個完全拷貝;函數族exec( )用來啟動另外的進程以取代當前運行的進程。
關於fork()與execl(),去年寫過一篇文章對部分源碼進行過分析:system()和execv()函數使用詳解
2.2 fork()
一個進程在運行中,如果使用了fork,就產生了另一個進程。下面就看看如何具體使用fork,這段程序演示了使用fork的基本框架:
#include <stdio.h> void main() { int i; if ( fork() == 0 ) { /* 子進程程序 */ for ( i = 1; i <1000; i ++ ) printf("This is child process\n"); } else { /* 父進程程序*/ for ( i = 1; i <1000; i ++ ) printf("This is origin process\n"); } }
運行結果如下:
從上圖可以看出父進程和子進程並發運行,內核能夠以任意方式交替運行它們,這里是父進程先運行,然后是子進程。但是在另外一個系統上運行時不一定是這個順序。
使用fork函數創建的子進程從父進程的繼承了全部進程的地址空間,包括:進程上下文、進程堆棧、內存信息、打開的文件描述符、信號控制設置、進程優先級、進程組號、當前工作目錄、根目錄、資源限制、控制終端等。
fork創建子進程,首先調用int80中斷,然后將系統調用號保存在eax寄存器中,進入內核態后調用do_fork(),實際上是創建了一份父進程的拷貝,他們的內存空間里包含了完全相同的內容,包括當前打開的資源,數據,當然也包含了程序運行到的位置,也就是說fork后子進程也是從fork函數的位置開始往下執行的,而不是從頭開始。而為了判別當前正在運行的是哪個進程,fork函數返回了一個pid,在父進程里標識了子進程的id,在子進程里其值為0,在我們的程序里就根據這個值來分開父進程的代碼和子進程的代碼。
一旦使用fork創建子進程,則進程地址空間中的任何有效地址都只能位於唯一的區域,這些區域不能相互覆蓋。編寫如下代碼進行測試:
1 #include <stdio.h> 2 #include <sys/types.h> 3 #include <unistd.h> 4 5 struct con { 6 int a; 7 }; 8 9 int main() { 10 pid_t pid; 11 struct con s; 12 s.a = 2; 13 struct con* sp = &s; 14 pid = fork(); 15 if (pid > 0) { 16 printf("parent show %p, %p, a = %d\n", sp, &sp->a, sp->a); 17 sp->a = 1; 18 sleep(10); 19 printf("parent show %p, %p, a = %d\n", sp, &sp->a, sp->a); 20 printf("parent exit\n"); 21 } 22 else { 23 printf("child show %p, %p, a = %d\n", sp, &sp->a, sp->a); 24 sp->a = -1; 25 printf("child change a to %d\n", sp->a); 26 } 27 return 0; 28 }
獲得結果如下:
從上面的分析可以看出進程copy過程中,fork就是基於寫時復制,只讀代碼段是可以同享的,一般CPU都是以"頁"為單位來分配內存空間的,每一個頁都是實際物理內存的一個映像,象INTEL的CPU,其一頁在通常情況下是 4086字節大小,而無論是數據段還是堆棧段都是由許多"頁"構成的,fork函數復制這兩個段,物理空間上兩個進程的數據段和堆棧段都還是共享着的,當有一個進程寫了某個數據時,這時兩個進程之間的數據才有了區別,系統就將有區別的" 頁"從物理上也分開。系統在空間上的開銷就可以達到最小。
2.3 exec( )函數族
下面我們來看看一個進程如何來啟動另一個程序的執行。在Linux中要使用exec函數族。系統調用execve()對當前進程進行替換,替換者為一個指定的程序,其參數包括文件名(filename)、參數列表(argv)以及環境變量(envp)。exec函數族當然不止一個,但它們大致相同,在 Linux中,它們分別是:execl,execlp,execle,execv,execve和execvp,下面以execve為例。
一個進程一旦調用exec類函數,它本身就"死亡"了,execve首先調用int80中斷,然后將系統調用號保存在eax寄存器中,調用sys_exec,將可執行程序加載到當前進程中,系統把代碼段替換成新的程序的代碼,廢棄原有的數據段和堆棧段,並為新程序分配新的數據段與堆棧段,唯一留下的,就是進程號,也就是說,對系統而言,還是同一個進程,不過已經是另一個程序了。(不過exec類函數中有的還允許繼承環境變量之類的信息。)
那么如果我的程序想啟動另一程序的執行但自己仍想繼續運行的話,怎么辦呢?那就是結合fork與exec的使用。下面一段代碼顯示如何啟動運行其它程序:
1 #include <stdio.h> 2 #include <unistd.h> 3 int main(){ 4 if(!fork()) 5 execve("./test",NULL,NULL); 6 else 7 printf("origin process!\n"); 8 return 0; 9 }
輸出結果如下:
原始進程和execve創建的新進程,並發運行,exec函數在當前進程的上下文中加載並運行一個新的程序,並且不返回創建進程的函數。
接下來,我們分析一下execve函數執行過程中,以及可執行程序的加載過程,在內核中execve()系統調用相應的入口是sys_execve(),函數首先通過 pt_regs參數檢查賦值在執行該系統調用時,用戶態下的CPU寄存器在核心態的棧中的保存情況。通過這個參數,sys_execve可以獲得保存在用戶空間的以下信息:可執行文件路徑的指針(regs.ebx中)、命令行參數的指針(regs.ecx中)和環境變量的指針(regs.edx中)。
struct pt_regs { long ebx; long ecx; long edx; long esi; long edi; long ebp; long eax; int xds; int xes; long orig_eax; long eip; int xcs; long eflags; long esp; int xss; }
然后調用do_execve函數,首先查找被執行的文件,讀取前128個字節,確實加載的可執行文件的類型,然后調用search_binary_handle()搜索和匹配合適的可執行文件裝載處理過程,elf調用load_elf_binary();
struct linux_binprm{ char buf[BINPRM_BUF_SIZE]; //保存可執行文件的頭128字節 struct page *page[MAX_ARG_PAGES]; struct mm_struct *mm;
unsigned long p; //當前內存頁最高地址 int sh_bang; struct file * file; //要執行的文件 int e_uid, e_gid; //要執行的進程的有效用戶ID和有效組ID kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
void *security; int argc, envc; //命令行參數和環境變量數目 char * filename; //要執行的文件的名稱 char * interp; //要執行的文件的真實名稱,通常和filename相同 unsigned interp_flags; unsigned interp_data; unsigned long loader, exec; };
load_elf_binary()加載過程如下:
a.檢查ELF可執行文件的有效性,比如魔數(開頭四個字節,elf文件為0x7F),段“Segment”的數量;
b.尋找動態鏈接.interp段,設置動態連接器的路徑;
c.根據elf可執行文件的程序頭表的描述,對elf文件進行映射;
d.初始化elf進程環境,比如啟動時候的edx寄存器地址是DT_FINI的地址;
e.將系統調用的返回地址修改為elf可執行文件的入口點,就是e_entry所存的地址。對於動態鏈接的elf可執行文件就是動態連接器。
加載完成后返回do_execve返回到exeve(),從內核態轉化為用戶態並返回e步所在更改的程序入口地址。即eip存儲器直接跳轉到elf程序的入口地址,新進程執行。
三、 進程虛擬地址空間與可執行程序格式
從操作系統來看,一進程最關鍵的特征是它擁有獨立的虛擬地址空間,一般情況下,創建過程如下:
①創建一個獨立的虛擬空間。
②讀取可執行文件頭,並且簡歷虛擬空間與可執行文件的映射關系。
③將CPU的指令寄存器設置成可執行文件的入口地址,啟動運行。
在討論地址空間,進程描述符以及ELF文件格式的之前,我們先介紹一點預備知識,由於第一節已經介紹了進程描述符的部分信息,在這里介紹下ELF文件格式:
在第二節使用execve時,我們使用了test可執行程序進行測試,代碼如下:
#include <stdio.h> int main(int argc, char const *argv[]) { printf("%s\n","execve the new process!"); return 0; }
描述“Segment”的結構叫程序頭,它描述了ELF文件該如何被操作系統映射到進程的虛擬空間:
上圖共有5個Segment。從裝載的角度看,我們只關心兩個LOAD和DYNAMIC,其他Segment在裝載過程中只具有輔助作用,映射過程中,根據讀寫執行權限映射到不同的虛擬內存區域
第四行LOAD表示代碼段,具有可讀可執行權限,被映射到虛擬地址0x08048000,長度為0x005c4字節的虛擬存儲區域中。
第五行LOAD表示長度為0x100個字節的數據段,具有可讀可寫權限,被映射到開始於虛擬地址0x08049f08處,長度為0x0011c字節的虛擬存儲區域中。
DYNAMIC字段表示的是動態鏈接器所需要的基本信息,具有可讀可寫權限,被映射到開始於虛擬地址0x08049f14處,長度為0x000e8字節的虛擬存儲區域中。
在第二節中執行如下命令后,ELF文件正式開始加載工作,執行第二節中的加載過程:
execve("./test",NULL,NULL);
文件在加載過程中是以elf可執行文件的形式加載,加載過程初始化時,根據elf段頭部表信息,初始化bss段、代碼段和數據段的起始地址和終止地址。
然后調用mm_release釋放掉當前進程所占用的內存(old_mm),並且將當前進程的內存空間替換成bprm->mm所指定的頁面,而這塊空間,便是新進程在初始化時暫時向內核借用的存儲空間,當這段空間讀取到目前進程的mm以后,事實上也就完成了舊進程到新進程的替換。這個時候bprm->mm這塊內核空間也就完成了它的使命,於是被置為NULL予以回收。(bprm為中保存了讀取128字節elf文件頭)。
mm指向關於存儲管理的struct mm_struct結構,其包含在task_struct中。
然后加載段地址到虛擬內存地址,映射如下:
然后另一部分段映射到數據區,關系如下:
到這里,對於elf文件的載入(包括之前對可執行文件運行環境准備工作)的分析基本上可以告一段落了。
四、進程創建中動態鏈接庫的表現形式
動態鏈接的基本思想是把程序按照模塊拆分,運行時才將它們鏈接在一起形成一個完整的程序,而不是像靜態鏈接一樣把所有的程序模塊都鏈接成一個單獨的可執行文件。多個動態鏈接庫均以ELF文件存儲,執行過程中以依賴樹的關系存在,並以深度優先的方式加載動態鏈接庫,最終將可執行程序返回給用戶。
我們通過以下實例來測試動態鏈接庫在虛擬地址及ELF文件的中表現形式:
/* Lib.h */ #ifndef LIB_H #define LIB_H void lab(int i) #endif
/* Lib.c */ #include <stdio.h> void lab(int i){ printf("Printing from lib.so %d\n", i);
sleep(-1); }
/* dyn.c*/ #include "lib.h" int main(){ lab(1); return 0; }
使用gcc編譯生成一個共享對象文件,然后鏈接dyn.c程序,生成可執行文件dyn:
gcc -fPIC -shared -o lib.so lib.c
gcc -o dyn dyn.c ./lib.so
運行並查看進程的虛擬地址空間分布:
整個進程的虛擬地址空間中,多出了幾個文件的映射。dyn與lib.so一樣,都被系統映射到進程的虛擬地址空間,地址與長度均不相同。由第二節可知,在映射完可執行文件之后,操作系統會先啟動一個動態鏈接器。
動態鏈接器的的位置由ELF文件中的“.interp”段決定,而段“.dynamic”為動態鏈接提供了:依賴哪些共享對象、動態鏈接符號表的位置,動態鏈接重定位表的位置、共享對象初始化代碼的地址等。可通過readelf查看".dynamic" 段的內容:
動態鏈接過程需要動態符號表來確定函數的定義和引用關系,還需要重定位表來修正導入符號的引用。初始化完成后堆棧中保存了動態連接器所需要的一些輔助信息數組(其中包括程序入口地址,程序表頭地址,程序表頭項數及大小)。動態鏈接庫最后被映射到進程地址空間的共享庫區域段。
完成重定位和初始化后,所有准備工作結束,所需要的共享對象也都已經裝載並且鏈接完成。最后將進程的控制權轉交給dyn程序的入口並開始執行。
以上內容均為個人理解,由於能力有限,可能會有諸多錯誤,希望能夠和大家一起討論修正。