Linux 系統下 init 程式的前世今生

發表於2017-06-29

Linux系統中的init程式(pid=1)是除了idle程式(pid=0,也就是init_task)之外另一個比較特殊的程式,它是Linux核心開始建立起程式概念時第一個通過kernel_thread產生的程式,其開始在核心態執行,然後通過一個系統呼叫,開始執行使用者空間的/sbin/init程式,期間Linux核心也經歷了從核心態到使用者態的特權級轉變,/sbin/init極有可能產生出了shell,然後所有的使用者程式都有該程式派生出來(目前尚未閱讀過/sbin/init的原始碼)…

目前我們至少知道在核心空間執行使用者空間的一段應用程式有兩種方法:
1. call_usermodehelper
2. kernel_execve

它們最終都通過int $0x80在核心空間發起一個系統呼叫來完成,這個過程我在《深入Linux裝置驅動程式核心機制》第9章有過詳細的描述,對它的討論最終結束在 sys_execve函式那裡,後者被用來執行一個新的程式。現在一個有趣的問題是,在核心空間發起的系統呼叫,最終通過sys_execve來執行使用者 空間的一個程式,比如/sbin/myhotplug,那麼該應用程式執行時是在核心態呢還是使用者態呢?直覺上肯定是使用者態,不過因為cpu在執行 sys_execve時cs暫存器還是__KERNEL_CS,如果前面我們的猜測是真的話,必然會有個cs暫存器的值從__KERNEL_CS到 __USER_CS的轉變過程,這個過程是如何發生的呢?下面我以kernel_execve為例,來具體討論一下其間所發生的一些有趣的事情。

start_kernel在其最後一個函式rest_init的呼叫中,會通過kernel_thread來生成一個核心程式,後者則會在新程式環境下調 用kernel_init函式,kernel_init一個讓人感興趣的地方在於它會呼叫run_init_process來執行根檔案系統下的 /sbin/init等程式:

run_init_process的核心呼叫就是kernel_execve,後者的實現程式碼是:

裡面是段內嵌的彙編程式碼,程式碼相對比較簡單,核心程式碼是int $0x80,執行系統呼叫,系統呼叫號__NR_execve放在AX裡,當然系統呼叫的返回值也是在AX中,要執行的使用者空間應用程式路徑名稱儲存在 BX中。int $0x80的執行導致程式碼向__KERNEL_CS:system_call轉移(具體過程可參考x86處理器中的特權級檢查及Linux系統呼叫的實現一帖). 此處用bx,cx以及dx來儲存filename, argv以及envp引數是有講究的,它對應著struct pt_regs中暫存器在棧中的佈局,因為接下來就會涉及從彙編到呼叫C函式過程,所以彙編程式在呼叫C之前,應該把要傳遞給C的引數在棧中準備好。

system_call是一段純彙編程式碼:

system_call首先會為後續的C函式的呼叫在當前堆疊中建立引數傳遞的環境(x86_64的實現要相對複雜一點,它會將系統呼叫切換到核心棧 movq PER_CPU_VAR(kernel_stack),%rsp),尤其是接下來對C函式sys_execve呼叫中的struct pt_regs *regs引數,我在上面程式碼中同時列出了系統呼叫之後的後續操作syscall_exit,從程式碼中可以看到系統呼叫int $0x80最終通過iret指令返回,而後者會從當前棧中彈出cs與ip,然後跳轉到cs:ip處執行程式碼。正常情況下,x86架構上的int n指 令會將其下條指令的cs:ip壓入堆疊,所以當通過iret指令返回時,原來的程式碼將從int n的下條指令繼續執行,不過如果我們能在後續的C程式碼中改變regs->cs與regs->ip(也就是int n執行時壓入棧中的cs與ip),那麼就可以控制下一步程式碼執行的走向,而 sys_execve函式的呼叫鏈正好利用了這一點,接下來我們很快就會看到。SAVE_ALL巨集的最後為將ds, es, fs都設定為__USER_DS,但是此時cs還是__KERNEL_CS.

核心的呼叫發生在call *sys_call_table(,%eax,4)這條指令上,sys_call_table是個系統呼叫表,本質上就是一個函式指標陣列,我們這裡的系 統呼叫號是__NR_execve=11, 所以在sys_call_table中對應的函式為:

ptregs_execve其實就是sys_execve函式:

而sys_execve函式的程式碼實現則是:

注意這裡的引數傳遞機制!其中的核心呼叫是do_execve,後者呼叫do_execve_common來幹執行一個新程式的活,在我們這個例子中要執 行的新程式來自/sbin/init,如果用file命令看一下會發現它其實是個ELF格式的動態連結庫,而不是那種普通的可執行檔案,所以 do_execve_common會負責開啟、解析這個檔案並找到其可執行入口點,這個過程相當繁瑣,我們不妨直接看那些跟我們問題密切相關的代 碼,do_execve_common會呼叫search_binary_handler去查詢所謂的binary formats handler,ELF顯然是最常見的一種格式:

程式碼中針對ELF格式的 fmt->load_binary即為load_elf_binary, 所以fn=load_elf_binary, 後續對fn的呼叫即是呼叫load_elf_binary,這是個非常長的函式,直到其最後,我們才找到所需要的答案:

上述程式碼中的elf_entry即為/sbin/init中的執行入口點, bprm->p為應用程式新棧(應該已經在使用者空間了),start_thread的實現為:

在這裡,我們看到了__USER_CS的身影,在x86 64位系統架構下,該值為0x33. start_thread函式最關鍵的地方在於修改了regs->cs= __USER_CS, regs->ip= new_ip,其實就是人為地改變了系統呼叫int $0x80指令壓入堆疊的下條指令的地址,這樣當系統呼叫結束通過iret指令返回時,程式碼將從這裡的__USER_CS:elf_entry處開始執 行,也就是/sbin/init中的入口點。start_thread的程式碼與kernel_thread非常神似,不過它不需要象 kernel_thread那樣在最後呼叫do_fork來產生一個task_struct例項出來了,因為目前只需要在當前程式上下文中執行程式碼,而不是建立一個新程式。關於kernel_thread,我在本版曾有一篇帖子分析過,當時基於的是ARM架構。

所以我們看到,start_kernel在最後呼叫rest_init,而後者通過對kernel_thread的呼叫產生一個新程式(pid=1),新程式在其kernel_init()–>init_post()呼叫鏈中將通過run_init_process來執行使用者空間的/sbin /init,run_init_process的核心是個系統呼叫,當系統呼叫返回時程式碼將從/sbin/init的入口點處開始執行,所以雖然我們知道 post_init中有如下幾個run_init_process的呼叫:

但是隻要比如/sbin/init被成功呼叫,run_init_process中的kernel_execve函式將無法返回,因為它執行int $0x80時壓入堆疊中回家的路徑被後續的C函式呼叫鏈給改寫了,這樣4個run_init_process只會有一個有機會被成功執行,如果這4個函式都失敗 了,那麼核心將會panic. 所以核心設計時必須確保用來改寫int $0x80壓入棧中的cs和ip的start_thread函式之後不會再有其他額外的程式碼導致整個呼叫鏈的失敗,否則程式碼將執行非預期的指令,核心進入不穩定狀態。

最後,我們來驗證一下,所謂眼見為實,耳聽為虛。再者,如果驗證達到預期,也是很鼓舞人好奇心的極佳方法。驗證的方法我打算採用“Linux裝置驅動模型中的熱插拔機制及實驗” 中的路線,通過call_usermodehelper來做,因為它和kernel_execve本質上都是一樣的。我們自己寫個應用程式,在這個應用程式裡讀取cs暫存器的值,程式很簡單:

然後把這個程式打到/sys/kernel/uevent_help上面(參照Linux裝置驅動模型中的熱插拔機制及實驗一文),之後我們往電腦裡插個U盤,然後到/var/log/syslog檔案裡看輸出(在某些distribution上,syslog的輸出可能會到/var/log/messages中):

Mar 10 14:20:23 build-server main: ucs = 0x33

0x33正好就是x86 64位系統(我實驗用的環境)下的__USER_CS.

所以第一個核心程式(pid=1)通過執行使用者空間程式,期間通過cs的轉變(從__KERNEL_CS到__USER_CS)來達到特權級的更替。

相關文章