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

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 等程序:

static noinline int init_post(void)

...

run_init_process("/sbin/init");

run_init_process("/etc/init");

run_init_process("/bin/init");

run_init_process("/bin/sh");

panic("No init found. Try passing init= option to kernel."

"See Linux Documentation/init.txt for guidance.");

}

run_init_process 的核心調用就是 kernel_execve,後者的實現代碼是:

int kernel_execve(const char *filename,

const char *const argv[],

const char *const envp[])

{

long __res;

asm volatile ("int $0x80"

: "=a" (__res)

: "0" (__NR_execve), "b" (filename), "c" (argv), "d" (envp) : "memory");

return __res;

}

裡面是段內嵌的彙編代碼,代碼相對比較簡單,核心代碼是 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 是一段純彙編代碼:

ENTRY(system_call)

RING0_INT_FRAME # can't unwind into user space anyway

pushl_cfi %eax # save orig_eax

SAVE_ALL

GET_THREAD_INFO(%ebp)

# system call tracing in operation / emulation

testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)

jnz syscall_trace_entry

cmpl $(nr_syscalls), %eax

jae syscall_badsys

syscall_call:

call *sys_call_table(,%eax,4)

movl %eax,PT_EAX(%esp) # store the return value

syscall_exit:

...

restore_nocheck:

RESTORE_REGS 4 # skip orig_eax/error_code

irq_return:

INTERRUPT_RETURN #iret instruction for x86_32

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 中對應的函數為:

ENTRY(sys_call_table)

.long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */

.long sys_exit

.long ptregs_fork

.long sys_read

.long sys_write

.long sys_open /* 5 */

.long sys_close

...

.long sys_unlink /* 10 */

.long ptregs_execve //__NR_execve

...

ptregs_execve 其實就是 sys_execve 函數:

#define ptregs_execve sys_execve

#define ptregs_execve sys_execve

而 sys_execve 函數的代碼實現則是:

/*

* sys_execve() executes a new program.

*/

long sys_execve(const char __user *name,

const char __user *const __user *argv,

const char __user *const __user *envp, struct pt_regs *regs)

{

long error;

char *filename;

filename = getname(name);

error = PTR_ERR(filename);

if (IS_ERR(filename))

return error;

error = do_execve(filename, argv, envp, regs);

#ifdef CONFIG_X86_32

if (error == 0) {

/* Make sure we don't return using sysenter.. */

set_thread_flag(TIF_IRET);

}

#endif

putname(filename);

return error;

}

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

int search_binary_handler(struct linux_binprm *bprm,struct pt_regs *regs)

{

...

for (try=0; try<2; try++) {

read_lock(&binfmt_lock);

list_for_each_entry(fmt, &formats, lh) {

int (*fn)(struct linux_binprm *, struct pt_regs *) = fmt->load_binary;

...

retval = fn(bprm, regs);

...

}

...

}

}

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

static int load_elf_binary(struct linux_binprm *bprm, struct pt_regs *regs)

{

...

start_thread(regs, elf_entry, bprm->p);

...

}

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

void

start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)

{

set_user_gs(regs, 0);

regs->fs = 0;

regs->ds = __USER_DS;

regs->es = __USER_DS;

regs->ss = __USER_DS;

regs->cs = __USER_CS;

regs->ip = new_ip;

regs->sp = new_sp;

/*

* Free the old FP and other extended state

*/

free_thread_xstate(current);

}

在這裡,我們看到了__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 的調用:

run_init_process("/sbin/init");

run_init_process("/etc/init");

run_init_process("/bin/init");

run_init_process("/bin/sh");

但是隻要比如 / 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 寄存器的值,程序很簡單:

#include

#include

#include

#include

int main()

{

unsigned short ucs;

asm(

"movw %%cs, %0\n"

:"=r"(ucs)

::"memory");

syslog(LOG_INFO, "ucs = 0x%x\n", ucs);

return 0;

}

然後把這個程序打到 / 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) 來達到特權級的更替。


分享到:


相關文章: