對於內核執行過程的理解
原創作品轉載請註明出處 + https://github.com/mengning/linuxkernel/
一. 實驗要求:從整體上理解進程創建、可執行文件的加載和進程執行進程切換,重點理解分析fork、execve和進程切換
- 閱讀理解task_struct數據結構http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235;
- 分析fork函數對應的內核處理過程do_fork,理解創建一個新進程如何創建和修改task_struct數據結構;
- 使用gdb跟蹤分析一個fork系統調用內核處理函數do_fork ,驗證您對Linux系統創建一個新進程的理解,特別關註新進程是從哪裏開始執行的?為什麽從那裏能順利執行下去?即執行起點與內核堆棧如何保證一致。
- 理解編譯鏈接的過程和ELF可執行文件格式;
- 編程使用exec*庫函數加載一個可執行文件,動態鏈接分為可執行程序裝載時動態鏈接和運行時動態鏈接;
- 使用gdb跟蹤分析一個execve系統調用內核處理函數do_execve ,驗證您對Linux系統加載可執行程序所需處理過程的理解;
- 特別關註新的可執行程序是從哪裏開始執行的?為什麽execve系統調用返回後新的可執行程序能順利執行?對於靜態鏈接的可執行程序和動態鏈接的可執行程序execve系統調用返回時會有什麽不同?
- 理解Linux系統中進程調度的時機,可以在內核代碼中搜索schedule()函數,看都是哪裏調用了schedule(),判斷我們課程內容中的總結是否準確;
- 使用gdb跟蹤分析一個schedule()函數 ,驗證您對Linux系統進程調度與進程切換過程的理解;
- 特別關註並仔細分析switch_to中的匯編代碼,理解進程上下文的切換機制,以及與中斷上下文切換的關系。
二. 實驗內容
1. 創建線程相關系統調用
關於進程的創建,Linux提供了幾個系統調用來創建和終止進程,以及執行新程序,他們分別是fork,vfork,clone和exec,exit;其中clone用來創建輕量級進程,必須制定要共享的資源,exec系統調用執行一個新程序,exit系統調用終止進程。不論是fork,vfork還是clone,在內核中最終都是調用了do_fork來實現進程的創建。
一個進程,包括代碼、數據和分配給進程的資源。fork()函數通過系統調用創建一個與原來進程幾乎完全相同的進程,也就是兩個進程可以做完全相同的事,但如果初始參數或者傳入的變量不同,兩個進程也可以做不同的事。一個進程調用fork()函數後,系統先給新的進程分配資源,例如存儲數據和代碼的空間。然後把原來的進程的所有值都復制到新的新進程中,只有少數值與原來的進程的值不同。相當於克隆了一個自己。在fork函數執行完畢後,如果創建新進程成功,則出現兩個進程,一個是子進程,一個是父進程。在子進程中,fork函數返回0,在父進程中,fork返回新創建子進程的進程ID。我們可以通過fork返回的值來判斷當前進程是子進程還是父進程。
進程創建四要素:
1、 有一段程序供其執行;
2、 有進程專用的系統堆棧空間,即內核棧;
3、 有進程控制塊task_struct結構體;
4、 有獨立的存儲空間,專用的用戶空間,即用於虛存管理的mm_struct結構、下屬vm_area結構,以及相應的頁面目錄項和頁面表,都從屬於task_struct結構。
在include/linux/sched.h中定義了task_struct結構:PCB是進程存在和運行的唯一標識,在進程控制塊task_struct結構中主要包含進程的狀態、性質、資源和組織。
fork()是全部復制,父進程所有資源都通過數據結構的復制“遺傳”給子進程;vfork()除了task_struct結構和系統空間堆棧以外的資源全部通過數據結構指針的復制“遺傳”給子進程;而clone()則將資源有選擇的復制給子進程,沒有復制的數據結構則通過指針的復制讓子進程共享。
下面來看看fork函數的源碼,通過源碼了解do_fork()的執行過程:
1 long do_fork(unsigned long clone_flags, 2 unsigned long stack_start, 3 unsigned long stack_size, 4 int __user *parent_tidptr, 5 int __user *child_tidptr) 6 { 7 struct task_struct *p; //創建進程描述符指針 8 int trace = 0; 9 long nr; //子進程pid 10 11 ... 12 //創建子進程的描述符和執行時所需的其他數據結構 13 p = copy_process(clone_flags, stack_start, stack_size, 14 child_tidptr, NULL, trace); 15 16 if (!IS_ERR(p)) { //copy_process執行成功 17 struct completion vfork; //定義完成量(一個執行單元等待另一個執行單元完成) 18 19 trace_sched_process_fork(current, p); 20 21 nr = task_pid_vnr(p); //獲取pid 22 23 ... 24 //如果clone_flags包含CLONE_VFORK標識,將vfork完成量賦給進程描述符 25 if (clone_flags & CLONE_VFORK) { 26 p->vfork_done = &vfork; 27 init_completion(&vfork); 28 get_task_struct(p); 29 } 30 31 wake_up_new_task(p); //將子進程添加到調度器的隊列 32 33 ... 34 35 //這個函數的作用是在進程創建的最後階段,父進程會將自己設置為不可中斷狀態,然後睡眠在 等待隊列上(init_waitqueue_head()函數 就是將父進程加入到子進程的等待隊列),等待子進程的喚醒。 36 if (clone_flags & CLONE_VFORK) { 37 if (!wait_for_vfork_done(p, &vfork)) 38 ptrace_event(PTRACE_EVENT_VFORK_DONE, nr); 39 } 40 } else { 41 nr = PTR_ERR(p); //錯誤處理 42 } 43 return nr; //返回子進程pid(此處的pid為子進程的pid) 44 }
do_fork()主要完成了調用copy_process()復制父進程的相關信息,調用wake_up_new_task函數將子進程加入到調度器隊列中。do_fork() 生成一個新的進程,大致分為三個步驟 :
1. 建立進程控制結構並賦初值,使其成為進程映像。
2. 為新進程的執行設置跟蹤進程執行情況的相關內核數據結構。包括 任務數組、自由時間列表 tarray_freelist 以及 pidhash[] 數組。
3. 啟動調度程序,使子進程獲得運行的機會。
在第一個步驟中,首先申請一個 task_struct 數據結構,表示即將生成的新進程。通過檢查clone_flags的值,確定接下來需要做的操作。通過copy_process這個函數,把父進程 PCB 直接復制到新進程的 PCB 中。為新進程分配一個唯一的進程標識號 PID 和 user_struct 結構。
在第二個步驟中,首先把新進程加入到進程鏈表中, 把新進程加入到 pidhash 散列表中,並增加任務計數值。通過拷貝父進程的上、下文來初始化硬件的上下文(TSS段、LDT以及 GDT)。
在第三個步驟中,設置新的就緒隊列狀態 TASK_RUNING , 並將新進程掛到就緒隊列中,並重新啟動調度程序使其運行,向父進程返回子進程的 PID,設置子進程從 do_fork() 返回 0 值。
do_fork()
首先調用copy_process()為子進程復制出一份進程信息,如果是vfork()則初始化完成處理信息;
然後調用wake_up_new_task將子進程加入調度器,為之分配CPU,如果是vfork(),則父進程等待子進程完成exec替換自己的地址空間。
copy_process()
首先調用dup_task_struct()復制當前的task_struct,檢查進程數是否超過限制;
接著初始化自旋鎖、掛起信號、CPU 定時器等;
然後調用sched_fork初始化進程數據結構,並把進程狀態設置為TASK_RUNNING,復制所有進程信息,包括文件系統、信號處理函數、信號、內存管理等;
調用copy_thread()初始化子進程內核棧,為新進程分配並設置新的pid。
dup_task_struct()
調用alloc_task_struct_node()分配一個 task_struct 節點;
調用alloc_thread_info_node()分配一個 thread_info 節點,其實是分配了一個thread_union聯合體,將棧底返回給 ti;
最後將棧底的值 ti 賦值給新節點的棧。
copy_thread()
獲取子進程寄存器信息的存放位置
對子進程的thread.sp賦值,將來子進程運行,這就是子進程的esp寄存器的值。
如果是創建內核線程,那麽它的運行位置是ret_from_kernel_thread,將這段代碼的地址賦給thread.ip,之後準備其他寄存器信息,退出
將父進程的寄存器信息復制給子進程。
將子進程的eax寄存器值設置為0,所以fork調用在子進程中的返回值為0.
子進程從ret_from_fork開始執行,所以它的地址賦給thread.ip,也就是將來的eip寄存器。
調用流程圖
2. GDB跟蹤結果:
2. 可執行程序加載過程的理解
一個源碼,需要成為可執行文件之前,需要經歷預處理、編譯、匯編、鏈接等幾個步驟【以hello.c為例】。
預處理:gcc -E hello.c -o hello.i 。
預處理時,編譯器主要的工作有:刪除所有註釋“//”、“/**/”;刪除所有的“#define”,展開所有的宏定義;處理所有的條件預編譯指令;處理“#include”預編譯指令,將被包含的文件插入該預編譯指令的位置;添加行號和標識。處理完之後,hello.i屬於文本文件。
編譯:gcc -S hello.i -o hello.s -m32 。(-S:只編譯,不進行匯編;-m32:生成32位平臺格式文件,與64位平臺使用不同的寄存器名和指令集)
編譯過程中,gcc首先檢查代碼的正確性和規範性,以進一步確定代碼實際要完成的工作。在檢查無誤之後,gcc把代碼翻譯成匯編語言。hello,s任然是文本文件。
匯編:gcc -c hello.s -o hello.o.m32 -m32
匯編結束後生成的.o文件為ELF格式的文件。目標文件至少含有三個3個節區(Section),分別是.text、.data和.bss。.bss段(bss segment)(BlockStarted by Symbol),主要是用來存放程序中未初始化的全局變量的一塊內存區域,屬於靜態內存分配,當程序開始運行時,系統將用0來初始化這片內存區域;.data段(data segment,數據段),通常是指用來存放程序中已經初始化的全局變量的一塊內存區域,屬於靜態分配;.text段(code segment/text segment),通常指用來存放程序執行代碼的一塊內存區域,內存區域大小在程序運行前就已經確定,通常屬於只讀。除了上述的3個主要的節區之外,還有一些其他常見的節:用於存放C中的字符串和#define定義的常量,包含著只讀數據的.rodata段;包含版本控制信息的.comment段;包含動態鏈接信息的.dymanic段;包含動態鏈接符號表的.dynsym段;包含用於初始化進程的可執行代碼.init段,也就是執行main函數之前需要執行的部分程序。
3. 使用gdb跟蹤分析一個execve系統調用內核處理函數do_execve
在Linux中,對於一個可執行文件的執行,可以通過的方式主要有:execl、execlp、execle、execv、exexvp、execve等6個函數,其使用差異主要體現在命令行參數和環境變量參數的不同,這些函數最終都需要調用系統調用函數sys_execve()來實現執行可執行文件的目的。
sys_execve()源碼如下:
SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp) { struct filename *path = getname(filename); int error = PTR_ERR(path); if (!IS_ERR(path)) { error = do_execve(path->name, argv, envp); putname(path); } return error; }
do_execve()函數代碼如下:
int do_execve(const char *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) { struct user_arg_ptr argv = { .ptr.native = __argv }; struct user_arg_ptr envp = { .ptr.native = __envp }; return do_execve_common(filename, argv, envp); }
do_execve_common()函數代碼如下:
/* * sys_execve() executes a new program. */ static int do_execve_common(const char *filename, struct user_arg_ptr argv, struct user_arg_ptr envp) { struct linux_binprm *bprm; struct file *file; struct files_struct *displaced; bool clear_in_exec; int retval; const struct cred *cred = current_cred(); ... file = open_exec(filename); //打開要加載的可執行文件,加載文件頭部,判斷文件類型 ... //創建一個bprm結構體,把環境變量和命令行參數都復制到結構體中 bprm->file = file; bprm->filename = filename; bprm->interp = filename; ... //把傳入的shell上下文復制到bprm中 retval = copy_strings(bprm->envc, envp, bprm); if (retval < 0) goto out; //把傳入的命令行參數復制到bprm中 retval = copy_strings(bprm->argc, argv, bprm); if (retval < 0) goto out; //根據讀入的文件頭部,尋找可執行文件的處理函數 retval = search_binary_handler(bprm); ... }
追蹤sys_execve的過程:
我們開始跟蹤執行過程,在qemu中輸入exec,首先進入斷點是sys_execve,在在中端處理程序中,調用了do_execve(),其中getname從用戶空間獲取filename(也就是hello)的路徑,到內核中。
進入do_execve函數體內發現,實際工作是完成argv envp賦值,然後調用do_execve_common
我們進入do_execve_common函數體內:
我們跟蹤到do_execve_common,do_execve_common完成了一個linux_binprm的結構體 bprm的初始化工作,然後我們跟蹤到exec_binprm,查看函數代碼,這段代碼將父進程的pid保存,獲取新的pid,然後執行search_binary_hander(bprm),用來遍歷format鏈表。
在找到可以解析當前可執行文件的代碼之後,會調用start_kernel()開啟一個新的進程進行可執行文件的執行。
綜合上述的理解,對於系統調用sys_execve的系統調用的主題流程為:sys_execve() -> do_execve() -> do_execve_common() -> search_binary_handler() ->start_thread()。
4. Linux內核中進程調度的時機
-
中斷處理過程(包括時鐘中斷、I/O中斷、系統調用和異常)中,直接調用schedule(),或者返回用戶態時根據need_resched標記調用schedule();
-
內核線程可以直接調用schedule()進行進程切換,也可以在中斷處理過程中進行調度,也就是說內核線程作為一類的特殊的進程可以主動調度,也可以被動調度;
-
用戶態進程無法實現主動調度,僅能通過陷入內核態後的某個時機點進行調度,即在中斷處理過程中進行調度。
每個時鐘中斷(timer interrupt)發生時,由三個函數協同工作,共同完成進程的選擇和切換,它們是:schedule()、do_timer()及ret_form_sys_call()。我們先來解釋一下這三個函數:
schedule():進程調度函數,由它來完成進程的選擇(調度);
do_timer():暫且稱之為時鐘函數,該函數在時鐘中斷服務程序中被調用,是時鐘中斷服務程序的主要組成部分,該函數被調用的頻率就是時鐘中斷的頻率即每秒鐘100次(簡稱100赫茲或100Hz);
ret_from_sys_call():系統調用返回函數。當一個系統調用或中斷完成時,該函數被調用,用於處理一些收尾工作,例如信號處理、核心任務等等。
__schedule()函數源碼如下:
static void __sched __schedule(void) { struct task_struct *prev, *next; unsigned long *switch_count; struct rq *rq; int cpu; need_resched: preempt_disable(); cpu = smp_processor_id(); rq = cpu_rq(cpu); rcu_note_context_switch(cpu); prev = rq->curr; schedule_debug(prev); if (sched_feat(HRTICK)) hrtick_clear(rq); raw_spin_lock_irq(&rq->lock); switch_count = &prev->nivcsw; if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) { if (unlikely(signal_pending_state(prev->state, prev))) { prev->state = TASK_RUNNING; } else { deactivate_task(rq, prev, DEQUEUE_SLEEP); prev->on_rq = 0; /* * If a worker went to sleep, notify and ask workqueue * whether it wants to wake up a task to maintain * concurrency. */ if (prev->flags & PF_WQ_WORKER) { struct task_struct *to_wakeup; to_wakeup = wq_worker_sleeping(prev, cpu); if (to_wakeup) try_to_wake_up_local(to_wakeup); } } switch_count = &prev->nvcsw; } pre_schedule(rq, prev); if (unlikely(!rq->nr_running)) idle_balance(cpu, rq); put_prev_task(rq, prev); next = pick_next_task(rq); clear_tsk_need_resched(prev); rq->skip_clock_update = 0; if (likely(prev != next)) { rq->nr_switches++; rq->curr = next; ++*switch_count; context_switch(rq, prev, next); /* unlocks the rq */ /* * The context switch have flipped the stack from under us * and restored the local variables which were saved when * this task called schedule() in the past. prev == current * is still correct, but it can be moved to another cpu/rq. */ cpu = smp_processor_id(); rq = cpu_rq(cpu); } else raw_spin_unlock_irq(&rq->lock); post_schedule(rq); sched_preempt_enable_no_resched(); if (need_resched()) goto need_resched; }
switch_to中的匯編代碼分析:
schedule()函數選擇一個新的進程來運行,並調用context_switch進行上下文的切換,這個宏調用switch_to來進行關鍵上下文切換 next = pick_next_task(rq, prev);//進程調度算法都封裝這個函數內部 context_switch(rq, prev, next);//進程上下文切換 switch_to利用了prev和next兩個參數:prev指向當前進程,next指向被調度的進程 31#define switch_to(prev, next, last) \ 32do { /* * Context-switching clobbers all registers, so we clobber * them explicitly, via unused output variables. * (EAX and EBP is not listed because EBP is saved/restored * explicitly for wchan access and EAX is the return value of * __switch_to()) */ unsigned long ebx, ecx, edx, esi, edi; asm volatile("pushfl\n\t" /* save flags */ \ "pushl %%ebp\n\t" /* save EBP */ \ 當前進程堆棧基址壓棧 "movl %%esp,%[prev_sp]\n\t" /* save ESP */ \ 將當前進程棧頂保存prev->thread.sp "movl %[next_sp],%%esp\n\t" /* restore ESP */ \ 講下一個進程棧頂保存到esp中 "movl $1f,%[prev_ip]\n\t" /* save EIP */ \ 保存當前進程的eip "pushl %[next_ip]\n\t" /* restore EIP */ \ 將下一個進程的eip壓棧,next進程的棧頂就是他的的起點 __switch_canary "jmp __switch_to\n" /* regparm call */ \ "1:\t" "popl %%ebp\n\t" /* restore EBP */ \ "popfl\n" /* restore flags */ \ 開始執行下一個進程的第一條命令 /* output parameters */ : [prev_sp] "=m" (prev->thread.sp), [prev_ip] "=m" (prev->thread.ip), "=a" (last), /* clobbered output registers: */ "=b" (ebx), "=c" (ecx), "=d" (edx), "=S" (esi), "=D" (edi) __switch_canary_oparam /* input parameters: */ : [next_sp] "m" (next->thread.sp), [next_ip] "m" (next->thread.ip), /* regparm parameters for __switch_to(): */ [prev] "a" (prev), [next] "d" (next) __switch_canary_iparam : /* reloaded segment registers */ "memory"); 77} while (0)
通過系統調用,用戶空間的應用程序就會進入內核空間,由內核代表該進程運行於內核空間,這就涉及到上下文的切換,用戶空間和內核空間具有不同的地址映射,通用或專用的寄存器組,而用戶空間的進程要傳遞很多變量、參數給內核,內核也要保存用戶進程的一些寄存器、變量等,以便系統調用結束後回到用戶空間繼續執行,所謂的進程上下文,就是一個進程在執行的時候,CPU的所有寄存器中的值、進程的狀態以及堆棧中的內容,當內核需要切換到另一個進程時,它需要保存當前進程的所有狀態,即保存當前進程的進程上下文,以便再次執行該進程時,能夠恢復切換時的狀態,繼續執行。
同理,硬件通過觸發信號,導致內核調用中斷處理程序,進入內核空間。這個過程中,硬件的一些變量和參數也要傳遞給內核,內核通過這些參數進行中斷處理,中斷上下文就可以理解為硬件傳遞過來的這些參數和內核需要保存的一些環境,主要是被中斷的進程的環境。
總結
中斷處理過程(包括時鐘中斷、I/O中斷、系統調用和異常)中,直接調用schedule(),或者返回用戶態時根據 need_resched 標記調用schedule()。
內核線程可以直接調用schedule()進行進程切換,也可以在中斷處理過程中進行調度,也就是說內核線程作為一類的特殊的進程可以主動調度,也可以被動調度。
用戶態進程無法實現主動調度,僅能通過陷入內核態後的某個時機點進行調度,即在中斷處理過程中進行調度。
Linux內核工作在進程上下文或者中斷上下文。提供系統調用服務的內核代碼代表發起系統調用的應用程序運行在進程上下文;另一方面,中斷處理程序,異步運行在中斷上下文。中斷上下文和特定進程無關。
運行在進程上下文的內核代碼是可以被搶占的(Linux2.6支持搶占)。但是一個中斷上下文,通常都會始終占有CPU(當然中斷可以嵌套,但我們一般不這樣做),不可以被打斷。正因為如此,運行在中斷上下文的代碼就要受一些限制。
對於內核執行過程的理解