> 關注公眾號【高性能架構探索】,第一時間獲取干貨;回復【pdf】,免費獲取計算機經典書籍
如何啟動程序
- 雙擊(windows系統下),或者在shell終端上執行./a.out
- 在shell終端上運行可執行程序的標准流程:
- 啟動終端仿真器應用程序
- 輸入可執行文件所在的相對路徑或者絕對路徑
- 如果該可執行程序需要輸入參數的話,還需要輸入參數
比如,我們在終端上輸入
ls --version
就會出現如下結果。ps 在此處,我們可以人為ls為可執行程序的名稱,--version 是該程序需要的參數。
ls (GNU coreutils) 8.4
Copyright (C) 2010 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Written by Richard M. Stallman and David MacKenzie.
進入bash: /dev/tty
完整性檢查
首先,我們從shell的主函數開始,該函數在shell.c文件中。在主函數執行之前,主要做了以下准備工作:
- 檢查並嘗試打開/dev/tty
- 檢查shell是否在調試模式下運行
- 分析命令行參數
- 讀取shell環境
- 加載.bashrc、.profile和其他配置文件等。
構建運行環境
在做完上述完整性檢查之后,最終會執行reader_loop函數,該函數,定義在eval.c中,主要作用是讀取給定的程序名稱和參數。然后從execute_cmd.c調用execute_command函數,依次調用以下函數鏈, 不同的檢查,例如我們是否需要啟動subshell,是否內置bash函數等等。
reader_loop
-> execute_command
--> execute_command_internal
----> execute_simple_command
------> execute_disk_command
--------> shell_execve
眾所周知,Linux的實現語言是c,shell也是其一個應用,也有自己的main函數。 進入main函數后,在基本的初始化操作之后,最終進入reader_loop函數。 reader_loop會調用execute_command來等待用戶輸入命令行參數,在用戶輸入參數之后,將調用execute_command_internal函數。 execute_command_internal函數是shell源碼中執行命令的實際操作函數。他需要對作為操作參數傳入的具體命令結構的value成員進行分析,並針對不同的value類型,再調用具體類型的命令執行函數進行具體命令的解釋執行工作。
具體來說:如果value是simple,則直接調用execute_simple_command函數進行執行,execute_simple_command再根據命令是內部命令或磁盤外部命令分別調用execute_builtin和execute_disk_command來執行,其中,execute_disk_command在執行外部命令的時候調用make_child函數fork子進程執行外部命令。
如果value是其他類型,則調用對應類型的函數進行分支控制。舉例來說,如果是value是for_commmand,即這是一個for循環控制結構命令,則調用execute_for_command函數。在該函數中,將枚舉每一個操作域中的元素,對其再次調用execute_command函數進行分析。即execute_for_command這一類函數實現的是一個命令的展開以及流程控制以及遞歸調用execute_command的功能。 在上述整個調用流程串的最后一步是shell_execve。 該函數最終會調用系統函數execve,其聲明如下:
int execve(const char *filename, char *const argv [], char *const envp[]);
在該函數中,有三個參數,分別是:
- filename可執行文件的名稱
- 可執行文件所需的參數
- 可執行文件所在的環境變量 在該函數中,最終就是運行可執行程序,這一步操作,是在kernel中操作的。
進入內核: execve系統調用
execve系統調用實現
該函數定義在fs/exec.c中,其聲明如下:
SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
return do_execve(getname(filename), argv, envp);
}
execve的實現在這里非常簡單,只調用了do_execve函數,其參數為execve的參數。 而do_execve函數的定義如下:
int do_execve(struct filename *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_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}
通過上述代碼,我們可以看到,在do_execve中,最終調用了do_execveat_common,其除了使用do_execve中的參數之外,還有額外的兩個參數。 下面是do_execveat_common的具體代碼(此處我們去掉了一些不必要放入判斷代碼)
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
struct linux_binprm *bprm;
int retval;
if (IS_ERR(filename))
return PTR_ERR(filename);
...
current->flags &= ~PF_NPROC_EXCEEDED;
bprm = alloc_bprm(fd, filename);
if (IS_ERR(bprm)) {
retval = PTR_ERR(bprm);
goto out_ret;
}
retval = count(argv, MAX_ARG_STRINGS);
bprm->argc = retval;
retval = count(envp, MAX_ARG_STRINGS);
bprm->envc = retval;
retval = bprm_stack_limits(bprm);
retval = copy_string_kernel(bprm->filename, bprm);
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);
retval = copy_strings(bprm->argc, argv, bprm);
retval = bprm_execve(bprm, fd, filename, flags);
putname(filename);
return retval;
}
安全性檢查
第一個參數AT_FDCWD是當前目錄的文件描述符,第五個參數是標志。 我們稍后會看到。 do_execveat_common函數檢查文件名指針並返回它是否為NULL。 在此之后,它檢查當前進程的標志,表明未超出正在運行的進程的限制:
if (IS_ERR(filename))
return PTR_ERR(filename);
if ((current->flags & PF_NPROC_EXCEEDED) &&
atomic_read(¤t_user()->processes) > rlimit(RLIMIT_NPROC)) {
retval = -EAGAIN;
goto out_ret;
}
current->flags &= ~PF_NPROC_EXCEEDED;
如果這兩項檢查成功,我們將在當前進程的標志中取消設置PF_NPROC_EXCEEDED標志,以防止執行程序失敗。 在下一步中,我們調用在kernel/fork.c中定義的unshare_files函數,並取消共享當前任務的文件,並檢查此函數的結果:
retval = unshare_files(&displaced);
if (retval)
goto out_ret;
調用此函數的目的旨在消除執行二進制文件的文件描述符的潛在泄漏。 在下一步中,我們開始准備由struct linux_binprm結構(在include/linux/binfmts.h頭文件中定義)表示的bprm。
二進制參數准備
struct linux_binprm
linux_binprm結構用於保存加載二進制文件時使用的參數。 例如,它包含vm_area_struct,表示將在給定地址空間中連續間隔內的單個內存區域,將在該空間中加載應用程序。mm字段,它是二進制文件的內存描述符,指向內存頂部的指針以及許多其他不同的字段。
分配內存
在do_execveat_common函數中,執行alloc_bprm函數,最終會調用如下:
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_files;
准備工作
retval = prepare_bprm_creds(bprm);
if (retval)
goto out_free;
check_unsafe_exec(bprm);
current->in_execve = 1;
初始化linux_binprm中的cred結構變量,該結構變量中包含任務的實際uid,任務的實際guid,虛擬文件系統操作的uid和guid等。 然后,對check_unsafe_exec函數的調用將當前進程設置為in_execve狀態。
計算命令行參數和環境變量
bprm->argc = count(argv, MAX_ARG_STRINGS);
if ((retval = bprm->argc) < 0)
goto out;
bprm->envc = count(envp, MAX_ARG_STRINGS);
if ((retval = bprm->envc) < 0)
goto out;
在上述代碼中,MAX_ARG_STRINGS是頭文件中定義的上限宏,它表示傳遞給execve系統調用的最大字符串數。 MAX_ARG_STRINGS的值:
`#define MAX_ARG_STRINGS 0x7FFFFFFF`
設置
完成所有這些操作后,我們調用do_open_execat函數,該函數
- 搜索並打開磁盤上的可執行文件並檢查,
- 從noexec掛載點繞過標志0加載二進制文件(我們需要避免從不包含proc或sysfs等可執行二進制文件的文件系統中執行二進制文件),
- 初始化文件結構並返回此結構上的指針。 接下來,我們可以在此之后看到對sched_exec的調用。 sched_exec函數用於確定可以執行新程序的最小負載處理器,並將當前進程遷移到該處理器。
file = do_open_execat(fd, filename, flags);
retval = PTR_ERR(file);
if (IS_ERR(file))
goto out_unmark;
sched_exec();
之后,我們需要檢查給出可執行二進制文件的文件描述符。 我們嘗試檢查二進制文件的名稱是否從/符號開始,或者給定的可執行二進制文件的路徑是否相對於調用進程的當前工作目錄進行了解釋,或者文件描述符為AT_FDCWD。 如果這些檢查之一成功,我們將設置二進制參數文件名:
bprm->file = file;
if (fd == AT_FDCWD || filename->name[0] == '/') {
bprm->filename = filename->name;
}
否則,如果文件名稱為空,則將文件名設置為/dev/fd/%d (即/dev/fd/文件描述符),否則將文件名重新設置為/dev/fd/%d/文件名(其中,fd指向可執行文件的文件描述符)
} else {
if (filename->name[0] == '\0')
pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d", fd);
else
pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d/%s", fd, filename->name);
if (!pathbuf) {
retval = -ENOMEM;
goto out_unmark;
}
bprm->filename = pathbuf;
}
bprm->interp = bprm->filename;
需要注意的是,我們不僅設置了bprm-> filename,還設置了bprm-> interp,它將包含程序解釋器的名稱。 現在,我們只是在此處寫相同的名稱,但是稍后將使用程序解釋器的真實名稱對其進行更新,其具體取決於程序的二進制格式。
准備內存相關信息
retval = bprm_mm_init(bprm);
if (retval)
goto out_unmark;
其中,bprm_mm_init的定義如下:
static int bprm_mm_init(struct linux_binprm *bprm)
{
int err;
struct mm_struct *mm = NULL;
bprm->mm = mm = mm_alloc();
err = -ENOMEM;
if (!mm)
goto err;
/* Save current stack limit for all calculations made during exec. */
task_lock(current->group_leader);
bprm->rlim_stack = current->signal->rlim[RLIMIT_STACK];
task_unlock(current->group_leader);
err = __bprm_mm_init(bprm);
if (err)
goto err;
return 0;
err:
if (mm) {
bprm->mm = NULL;
mmdrop(mm);
}
return err;
}
在函數bprm_mm_init中,其功能主要是初始化mm_struct 和 vm_area_struct結構。
讀取二進制(ELF)文件
調用prepare_binprm函數將inode的uid填充到linux_binprm結構中,並從二進制可執行文件中讀取128個字節。 我們只從可執行文件中讀取前128個,因為我們需要檢查可執行文件的類型。 我們將在后續步驟中閱讀可執行文件的其余部分。
retval = prepare_binprm(bprm);
if (retval < 0)
goto out;
准備好linux_bprm結構后,我們通過調用copy_strings_kernel函數將可執行二進制文件的文件名,命令行參數和環境變量從內核復制到linux_bprm:
retval = copy_strings_kernel(1, &bprm->filename, bprm);
if (retval < 0)
goto out;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out;
並將指針設置為我們在bprm_mm_init函數中設置的新程序堆棧的頂部bprm-> exec = bprm-> p; 堆棧的頂部將包含程序文件名,我們將該文件名存儲到linux_bprm結構的exec字段中。
處理參數結構
通過調用exec_binprm函數來存儲當前當前任務所在進程的pid
retval = exec_binprm(bprm);
if (retval < 0)
goto out;
在exec_binprm函數中,也會調用search_binary_handler。 當前,Linux內核支持以下二進制格式:
- binfmt_script: 支持從#!開始的解釋腳本。 線;
- binfmt_misc: 根據Linux內核的運行時配置,支持不同的二進制格式;
- binfmt_elf: 支持elf格式;
- binfmt_aout: 支持a.out格式;
- binfmt_flat: 支持平面格式;
- binfmt_elf_fdpic: 支持elf FDPIC二進制文件;
- binfmt_em86: 支持在Alpha機器上運行的Intel elf二進制文件。 因此,search_binary_handler嘗試調用load_binary函數並將linux_binprm傳遞給該函數。 如果二進制處理程序支持給定的可執行文件格式,它將開始准備可執行二進制文件的前期工作。該函數定義如下:
int search_binary_handler(struct linux_binprm *bprm)
{
...
...
...
list_for_each_entry(fmt, &formats, lh) {
retval = fmt->load_binary(bprm);
if (retval < 0 && !bprm->mm) {
force_sigsegv(SIGSEGV, current);
return retval;
}
}
return retval;
在load_binary中檢查linux_bprm緩沖區中的魔數(每個elf二進制文件的頭中都包含魔數,我們從可執行二進制文件中讀取了前128個字節),如果不是elf二進制,則退出。
運行
完整性檢測
如果給定的可執行文件為elf格式,則load_elf_binary繼續並檢查可執行文件的體系結構和類型,並在體系結構錯誤且可執行文件不可執行,不可共享時退出:
if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
goto out;
if (!elf_check_arch(&loc->elf_ex))
goto out;
設置地址空間和依賴
嘗試加載描述段的程序頭表。 從磁盤上讀取與我們的可執行二進制文件鏈接的程序解釋器和庫,並將其加載到內存中。
elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
if (!elf_phdata)
goto out;
程序解釋器指定在可執行文件的.interp部分(在大多數情況下,對於x86_64,鏈接器為– /lib64/ld-linux-x86-64.so.2)。 它設置堆棧並將elf二進制文件映射到內存中的正確位置,映射了bss和brk部分,並做了許多其他不同的事情來准備要執行的可執行文件。 在執行load_elf_binary的最后,我們調用start_thread函數並將三個參數傳遞給該函數:
start_thread(regs, elf_entry, bprm->p);
retval = 0;
out:
kfree(loc);
out_ret:
return retval;
這些參數是:
- 新任務的寄存器集
- 新任務入口點的地址
- 新任務的堆棧頂部地址 從函數名稱可以理解,它啟動了一個新線程,但事實並非如此。 start_thread函數只是准備新任務的寄存器以准備運行。下面是定義:
void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
start_thread_common(regs, new_ip, new_sp,
__USER_CS, __USER_DS, 0);
}
通過上面代碼,我們能夠看到,在start_thread函數中,最終還是調用了start_thread_common函數。
開始執行
static void
start_thread_common(struct pt_regs *regs, unsigned long new_ip,
unsigned long new_sp,
unsigned int _cs, unsigned int _ss, unsigned int _ds)
{
loadsegment(fs, 0);
loadsegment(es, _ds);
loadsegment(ds, _ds);
load_gs_index(0);
regs->ip = new_ip;
regs->sp = new_sp;
regs->cs = _cs;
regs->ss = _ss;
regs->flags = X86_EFLAGS_IF;
force_iret();
}
start_thread_common函數將fs段寄存器填充為零,並將es&ds填充數據段寄存器的值。之后,我們將新值設置為指令指針,cs段等。在start_thread_common函數的末尾,我們可以看到force_iret宏,該宏通過iret指令強制返回系統調用。
然后,創建了在用戶空間中運行的新線程,隨后可以從exec_binprm返回,再次處於do_execveat_common中。 exec_binprm完成執行后,釋放之前分配的結構的內存,然后返回。
從execve系統調用處理程序返回后,將開始執行程序。之所以可以這樣做,是因為之前配置了所有與上下文相關的信息。
如我們所見,execve系統調用不會將控制權返回給進程,但是調用者進程的代碼,數據和其他段只是被程序段所覆蓋。 應用程序的退出將通過退出系統調用實現。
至此,整個程序從開始運行到退出,整個流程完。
> 關注公眾號【高性能架構探索】,第一時間獲取干貨;回復【pdf】,免費獲取計算機經典書籍
