簡介: 本文基於Linux™系統對進程建立與加載進行分析,文中實現了Linux庫函數fork、exec,剖析內核態執行過程,並進一步展現進程建立過程當中進程控制塊字段變化信息及ELF文件加載過程。 html
1、初識Linux進程 linux
進程這個概念是針對系統而不是針對用戶的,對用戶來講,他面對的概念是程序。當用戶敲入命令執行一個程序的時候,對系統而言,它將啓動一個進程。但和程序不一樣的是,在這個進程中,系統可能須要再啓動一個或多個進程來完成獨立的多個任務。簡單介紹下進程的結構。 數組
1.1 Linux下的進程查看 session
咱們可使用$ps命令來查詢正在運行的進程,好比$ps -eo pid,comm,cmd,下圖爲執行結果: 數據結構
(-e表示列出所有進程,-o pid,comm,cmd表示咱們須要PID,COMMAND,CMD信息) 併發
每一行表明了一個進程。每一行又分爲三列。第一列PID(process IDentity)是一個整數,每個進程都有一個惟一的PID來表明本身的身份,進程也能夠根據PID來識別其餘的進程。第二列COMMAND是這個進程的簡稱。第三列CMD是進程所對應的程序以及運行時所帶的參數。
框架
(第三列有一些由中括號[]括起來的。它們是kernel的一部分功能,顯示爲進程的樣子主要是爲了方便操做系統管理。) xss
咱們看第一行,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、 如何建立一個進程
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程序的入口地址,新進程執行。
3、 進程虛擬地址空間與可執行程序格式
從操做系統來看,一進程最關鍵的特徵是它擁有獨立的虛擬地址空間,通常狀況下,建立過程以下:
①建立一個獨立的虛擬空間。
②讀取可執行文件頭,而且簡歷虛擬空間與可執行文件的映射關係。
③將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文件的載入(包括以前對可執行文件運行環境準備工做)的分析基本上能夠告一段落了。
4、進程建立中動態連接庫的表現形式
動態連接的基本思想是把程序按照模塊拆分,運行時纔將它們連接在一塊兒造成一個完整的程序,而不是像靜態連接同樣把全部的程序模塊都連接成一個單獨的可執行文件。多個動態連接庫均以ELF文件存儲,執行過程當中以依賴樹的關係存在,並以深度優先的方式加載動態連接庫,最終將可執行程序返回給用戶。
咱們經過如下實例來測試動態連接庫在虛擬地址及ELF文件的中表現形式:
/* Lib.c */ #include <stdio.h> void lab(int i){ printf("Printing from lib.so %d\n", i); sleep(-1); }
使用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程序的入口並開始執行。
以上內容均爲我的理解,因爲能力有限,可能會有諸多錯誤,但願可以和你們一塊兒討論修正。