(轉)Linux進程切換以及內核線程的返回值

原文:http://blog.csdn.net/dog250/article/details/5303541

linux中的進程是個最基本的概念,進程從運行隊列到開始運行有兩個開始的地方,一個就是switch_to宏中的標號1:"1:/t",另 一個就是ret_form_fork,只要不是新創建的進程,幾乎都是從上面的那個標號1開始的,而switch_to宏則是除了內核本身,所有的進程要 想運行都要經過的地方,這樣看來,雖然linux的進程體系以及進程調度非常複雜,但是總體看來就是一個沙漏狀,而switch_to宏就是沙漏中間那個 最細的地方,想從一端到另一端,必然要經過那個地方,在非新創建的進程的情況下,所有進程都是從標號1開始,讓我們先看一下這是怎麼回事:

#define switch_to(prev,next,last) do {                            /
         unsigned long esi,edi;                                          /
         asm volatile("pushfl/n/t"                                      /
                      "pushl %%ebp/n/t"                                 /
                      "movl %%esp,%0/n/t"        /* save ESP */          /
                      "movl %5,%%esp/n/t"        /* restore ESP */       /   //注意這裏已經切換到了新的內核棧,故原來的棧中的局部變量全部失效,因而想得到其值就必須想辦法保存它們,爲了效率,這裏將prev保存在寄存器中, 以便善後使用
                      "movl $1f,%1/n/t"          /* save EIP */          /   //這裏,只要是曾經在這裏被切換出去的進程都會將標號1作爲再回來時的eip
                      "pushl %6/n/t"             /* restore EIP */       /   //將新進程的eip壓入棧中,因爲下面是個jmp,而且jmp到的函數最後有一個return,那麼按照return的語義,就可以從棧取出eip載入 eip寄存器了,實際上這個對__switch_to的jmp調用就是一個手動的call調用,很巧妙
                      "jmp __switch_to/n"                                /   //__switch_to是個FASTCALL的函數,eax/ebx寄存器傳參數
                      "1:/t"                                             /   //標號1的指令,很簡單,但是就是這個簡單成全了整體架構的簡單
                      "popl %%ebp/n/t"                                   /
                      "popfl"                                            /
                      :"=m" (prev->thread.esp),"=m" (prev->thread.eip),  /
                       "=a" (last),"=S" (esi),"=D" (edi)                 /
                      :"m" (next->thread.esp),"m" (next->thread.eip),    /
                       "2" (prev), "d" (next));                          /
} while (0)
linux 之所以實現上述的單點切換就是爲了降低複雜度,其實很多操作系統內核都是這麼做的,這裏的單點並不是指switch_to這個單點,而是保存/恢復eip 這個寄存器從而保證所有切換回來的進程都從一個地方開始,但是有點美中不足的就是linux並沒有將所有的進程從就緒到開始執行都從標號1開始,看看 do_fork的實現就知道,其實新創建的進程是不這麼做的,新創建的進程的eip是ret_from_fork而不是標號1,這個原因是什麼?新創建進 程的時候要手工指定一個開始的地址,畢竟它要開始就要有個起點,那麼起點在哪裏好呢(千萬不要和regs.eip相混淆,那個是正常執行時的eip,屬於 進程的,創建進程是一個系統調用,系統調用的話就是請求系統內核幫忙做事,然而做事之前要保存自己的當前狀態,regs.eip就是屬於這個狀態的,而這 裏的起點是操作系統內核管理進程用的,和進程或者內核線程沒有關係的)?最好是模擬該進程和別的已有進程一樣是重新開始運行的,這樣比較統一又便於管理, 然後將這個開始地址也指爲標號1,但是這時標號1在哪裏,是標號1在嵌入式彙編宏中導致標號1的地址不好取到嗎,如果真的因爲這的話,完全可以將標號1分 離出來放到一個地方,然後不管是已經有的進程還是新創建的進程都從這個固定的分離出來標號1的地址處取指令不就可以了嗎?內核的設計者不可能還沒有我聰 明,那樣的話會浪費取指令的時間和空間的,來個間接引用肯定沒有嵌入式彙編標號直接,而且還有一個原因,用ret_from_fork完全可以做到和既有 進程的標號1一樣的好,我們看看進程切換函數的設計,既有進程的切換都是在schedule裏面進入switch_to從而找到標號1的,而在 switch_to之後就剩下一個finish_task_switch和判斷重新調度標誌了,我們看看ret_from_fork:
ENTRY(ret_from_fork)
         pushl %eax      //注意剛從switch_to調用的__switch_to中ret回來,正好ret到了ret_from_fork(注意switch_to中jmp 指令前的push),而那個函數返回的就是prev,將其放到了eax中,故這裏schedule_tail的參數就是prev,也就是切換出去的進程。
         call schedule_tail
         GET_THREAD_INFO(%ebp)
         popl %eax
         jmp syscall_exit
上 面看到ret_from_fork調用的schedule_tail參數是切換出去的進程,而後者馬上調用finish_task_switch,這樣就 和schedule中的switch_to之後的邏輯對上了,而且參數也沒有什麼問題,那麼finish_task_switch之後的邏輯呢,比如判斷 重新調度標誌怎麼辦?那就看看ret_from_fork中的syscall_exit吧,那裏面做了判斷,如果需要調度,那就會進入正常的 schedule流程,十分正確。其實就是這個finish_task_switch善後惹的禍,不過它的設計也是一個很巧妙的看點,它主要判斷原先的進 程是否還有存在的必要,如果已經dead了,那麼就是在這裏徹底釋放其task_struct的,因此必須保存prev的值,因爲prev是 schedule的局部變量在prev的內核棧中,在切換到新的內核棧後(schedule函數用到了兩個內核棧),prev失效,因此纔要保存的。在 do_exit中,即使exit的進程已經沒有引用了其task_struct也不能釋放,因爲linux中沒有專門的調度管理器可以發現這一點然後自動 切換到別的進程,最終必須靠退出的進程自己schedule掉才行,而它自己調用schedule的時候它就是current,current最終成了 prev,整個切換過程都需要退出進程的task_struct結構,只有在switch_to到新進程了,纔可以將再也沒有用的退出進程的 task_struct釋放掉。可見進程退出也設計的很好,linux中沒有專門的調度管理線程雖然咋一看很不美觀,但是它畢竟不是微內核結構,大內核的 優點就是高效,直接讓需要切換的進程自己調用切換代碼另外別的進程就緒後告訴正運行的進程有切換需要然後着手調度,這種方式肯定最高效,如果設置了調度管 理線程,需要調度時還要通知這個管理器,很多切換很低效,但是卻很美觀。這一點上,linux中的調度是和諧自發的搶佔式協作,而帶有調度管理器的內核對 於調度則是強行的管制。
asmlinkage void schedule_tail(task_t *prev)
{
         finish_task_switch(prev);
         if (current->set_child_tid)
                 put_user(current->pid, current->set_child_tid);
}
到 此爲止,linux的進程切換還是在內核進程管理的代碼下,還沒有開始和用戶進程相關的行爲,也就是說regs保存的寄存器還沒有起作用,只有內核判斷內 核的事情已經完了,沒有什麼遺漏了纔會開始進程本身的工作,也就是RESTORE_ALL的邏輯了。新創建的進程用鬆散的代碼和緊湊的schedule邏 輯達成了一致,然後只要這個新進程開始了,那麼它就會進入那個巨大的linux進程切換沙漏從而進入了正常的單點切換流程。
最後我們看一下內核線程的返回值,也就是kernel_thread的返回值問題。其實可以這麼理解:根本就不應該這麼設計內核線程。 kernel_thread的實現可以看出,內核主要借用了用戶進程的創建方式創建了內核線程。在用戶空間,進程創建是copy-on-write的,但 是內核線程卻沒有這一說,而且unix的進程創建就是複製父進程的地址空間而沒有任何子進程的特殊策略,特殊行爲策略需要以後的exec或者別的方式設 置,而且子進程如何運行需要在父進程的程序源代碼中判斷fork函數的返回值後加以指定。之所以用戶進程創建函數fork的返回值那麼重要就是因爲父子共 享一個地址空間然後copy-on-write,通過fork的返回值區別父子進程。而內核線程雖然也是do_fork實現,但是它一開始就指定了子進程 的運行函數也就是子進程的行爲策略,如此一來do_fork的返回值就顯得不是那麼重要了,其實在內核中創建內核線程時,根本就不返回0,也沒有什麼返回 0就是子進程之說,實際上即使在用戶空間fork函數調用時,返回的0也不是內核的do_fork返回的,do_fork只會返回新進程的pid,而 fork的0返回值是內核在ret_from_fork之後進入用戶空間前RESTORE_ALL的時候pop到eax中的,然後庫實現的fork將 eax作爲返回值,實際上,fork的子進程在進入用戶空間前從來不經過do_fork這條路,可以看看它的thread的eip是 ret_from_fork,也就是隻要開始運行子進程,就在switch_to中會執行ret_from_fork,而從ret_from_fork順 着看,一直就到了RESTORE_ALL從而返回用戶空間。對於內核線程,根本就沒有子進程返回這一說,子進程也就是新創建的內核線程直接運行,完事了就 直接退出,這樣的原因就是在創建子進程時就已經給了它運行策略,如此一來就不需要返回原點來靠返回值分辨父子進程,但是內核線程確實是按照用戶進程的那一 套機制創建的啊,子進程也在copy_process複製父進程,這沒有什麼不同啊,如此怎麼能不返回原點呢?其實linux使用了一個騙術,它在創建內 核線程時僞造了一個父進程的現場:
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
{
         struct pt_regs regs;                //僞造的父進程現場,下面按照內核的機制填充
         memset(®s, 0, sizeof(regs));
         regs.ebx = (unsigned long) fn;      //子進程也就是內核線程要執行的行爲策略
         regs.edx = (unsigned long) arg;     //參數
...
         regs.eip = (unsigned long) kernel_thread_helper;     //這個函數管理了子進程的運行到退出
...
         return do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0, ®s, 0, NULL, NULL);  //實際創建子進程
}
__asm__(".section .text/n"
         ".align 4/n"
         "kernel_thread_helper:/n/t"  //這個標號函數管理了內核子進程
         "movl %edx,%eax/n/t"     //這裏事實上衝掉了在copy_thread中被置爲0的eax,如此看出eax根本就沒有像用戶進程創建那樣保持爲0
         "pushl %edx/n/t"   //edx裏面是內核線程函數的參數
         "call *%ebx/n/t"   //ebx裏面就是內核線程函數指針
         "pushl %eax/n/t"   //內核線程函數的返回值
         "call do_exit/n"   //以內核函數返回值作爲參數調用do_exit
         ".previous");
在 創建內核線程的時候將僞造的regs的eip設置爲kernel_thread_helper而不是直接設置爲要執行的函數是有原因的,總的來 說,kernel_thread_helper爲內核子進程提供了一個完整的進程環境,和流程,包括最後退出,如果直接設置爲要調用的函數的話,那麼就要 該函數自己處理退出,進程創建和退出應該是進程運行的機制,機制就不應該讓創建者負責,創建者只管策略,機制應該由內核框架提供。另外可以看到在 do_fork中有一個CLONE_VM標誌,難道內核線程還有地址空間不成,其實是沒有的,設置那個標誌就是爲了效率,看過代碼就知道,linux在切 換task_struct時,共享VM的不用切換cr3寄存器(當然是在x86上),而內核線程因爲沒有mm_struct,因此爲了使用這個高效的策 略,它就使用一個active_mm字段,本質上就是借用上一個進程的mm,所有mm映射的內核部分都是一樣的,而內核線程使用的就是隻有內核部分,這樣 就不用切換cr3了,然後處理器進入懶惰模式,只有到了那個被借用進程的tlb被刷新的時候纔將cr3切換到swapper_pg_dir的物理地址,其 實這個swapper_pg_dir就是所有內核線程們本來應該使用的頁目錄,這個意義上可以將整個內核線程看作是屬於一個內核進程,這個內核進程就是以 swapper_pg_dir爲頁目錄的進程,事實上進程就是一個擁有獨立的頁目錄的執行緒,爲了效率纔有了借用用戶空間進程的mm_struct這件 事,除了swapper_pg_dir是內核線程標準的頁目錄外不應該爲之再新分配任何新的pgd,因此就用了CLONE_VM標誌,這樣就不會再分配 mm_struct從而也不會分配pgd了,而和原來父進程共享mm的問題可以通過釋放掉老mm切換到init_mm來解決,另外正如下面要說的,由於內 核的頁映射一樣,完全可以借用任何進程的mm從而使內核工作更高效。
     tlb懶惰模式可以參考我的《tlb刷新的懶惰模式》一文,大致意思就是,在單cpu下,刷新tlb是一個主動的過程,因此沒有什麼要說的,主動的過程往往行爲很確定,但是smp下就複雜多了,可以簡單看一下:
static inline task_t * context_switch(runqueue_t *rq, task_t *prev, task_t *next)
{
         struct mm_struct *mm = next->mm;
         struct mm_struct *oldmm = prev->active_mm;
         if (unlikely(!mm)) {             //內核線程
                 next->active_mm = oldmm;
                 atomic_inc(&oldmm->mm_count);
                 enter_lazy_tlb(oldmm, next);  //單處理器下什麼也不做
         } else
                 switch_mm(oldmm, mm, next);    //切換
         if (unlikely(!prev->mm)) {
                 prev->active_mm = NULL;
                 WARN_ON(rq->prev_mm);
                 rq->prev_mm = oldmm;
         }
...
}
static inline void enter_lazy_tlb(struct mm_struct *mm, struct task_struct *tsk)
{
#ifdef CONFIG_SMP
         unsigned cpu = smp_processor_id();
         if (per_cpu(cpu_tlbstate, cpu).state == TLBSTATE_OK)
                 per_cpu(cpu_tlbstate, cpu).state = TLBSTATE_LAZY;   //將本cpu的cpu_tlbstate的狀態設置爲lazy狀態
#endif
}
在 smp中,每當要刷新tlb的時候都要往各個處理器上發送處理器間中斷--IPI,一旦是lazy狀態的cpu接收到了刷新tlb的ipi,那麼它就將它 自己從它的cpu_tlbstate的active_mm->cpu_vm_mask掩碼中清除,指示以後不要再向它這個cpu發送刷新tlb的 ipi了,因爲在lazy模式的cpu當前運行的都是內核線程,所有進程的內核空間都是一樣的,因此內核線程用誰的都一樣,但是卻不很合理,比如一個內核 線程正在用着借來的mm,恰在此時,這個進程在別的cpu上被釋放了,當然其mm也被釋放了,即使在進入lazy模式前靠 atomic_inc(&oldmm->mm_count)增加了這個mm的計數,那麼延遲釋放它也是不好的,畢竟有自己的 swapper_pg_dir不用,卻非要用別人的mm,其實內容都一樣,用別人的mm只會佔用內存,因此在一個進入lazy模式的cpu第一次接收到刷 新tlb的ipi時將它的頁目錄加載爲一個安全的值,然後再宣佈不接受刷新tlb的ipi,這個安全的值就是內核線程公用而且任何時候都不會釋放的 swapper_pg_dir。記住,一旦開始執行非內核線程,那麼必須接收刷新tlb的ipi,也就是將cpu的lazy模式清除。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章