瞪羚科技程序啟停分析與進程常用API的使用

2024年2月6日 18点热度 0人点赞

空間佈局

拿c程序來說,其空間佈局包括如下幾個部分:

數據段(初始化的數據段):例如在函數外的聲明,int a = 1

block started by symbol(未初始化的數據段):例如在函數外的聲明,int b[10]

棧:保存局部作用域的變量、函數調用需要保存的信息。例如調用一個函數,保存函數的返回地址、調用者的環境信息,給臨時變量分配空間

堆:動態內存分配

正文段:CPU執行的指令,通常是隻讀並共享的,例如同時打開多個文本編輯器進程,隻需要讀這一份正文段即可

命令行參數和環境變量

進程啟動和停止

進程啟動

用strace命令來追一個c的hello world:

root@yielde:~/workspace/code-container/cpp# strace ./test1
execve("./test1", ["./test1"], 0xfffffedb4960 /* 25 vars */) = 0

man一下execve,概括來說,execve()初始化棧、堆、bss、初始化數據段、並且將命令行參數、環境變量放到內存中。可以使用
https://elixir.bootlin.com/去追一下源碼。

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來執行,do_execve又通過do_execveat_common()來做具體的事情,

is_rlimit_overlimit()檢查資源使用是否超過限制,struct linux_binprm *bprm;是一個結構體,用於記錄命令參數、環境變量、要讀入ELF程序的入口地址、rlimit等信息。

bprm = alloc_bprm(fd, filename);為該結構分配內存,然後將bprm需要的內容copy進來。

構建好bprm後執行bprm_execve函數,函數註釋sys_execve() executes a new program.該函數會做一些安全性的檢查,然後do_open_execat(fd, filename, flags);打開我們的ELF程序(編譯好的test1),執行exec_binprm函數來運行新進程

exec_binprm()->search_binary_handler(),看下該函數的關鍵部分

static int search_binary_handler(struct linux_binprm *bprm){
...
//cycle the list of binary formats handler, until one recognizes the image
list_for_each_entry(fmt, &formats, lh) {
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);
retval = fmt->load_binary(bprm);
read_lock(&binfmt_lock);
put_binfmt(fmt);
if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
read_unlock(&binfmt_lock);
return retval;
}
}
...
}

// binfmt_elf.c &formats參數
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary, // 匹配到的handler
.load_shlib = load_elf_library,
#ifdef CONFIG_COREDUMP
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
#endif
};

search_binary_handler()會從&formats參數中為識別到的二進制文件匹配一個handler,即load_elf_binary(),該函數將ELF文件(test)的部分內容讀入內存,然後為新的進程設置獨立的信息

static int load_elf_binary(struct linux_binprm *bprm){
...
retval = begin_new_exec(bprm); // 清理之前程序的相關信息,設置私有信號表,設置線程組等。。
...
setup_new_exec(bprm); // 為新程序設置內核相關的狀態(例如進程名)
...
/* 我們的test使用的是動態鏈接的解釋器,objdump -s test可以看到
.interp
/lib/ld-linux-aarch64.so.1,加載解釋器,返回值elf_entry為解釋器的入口地址,

內核準備工作完成後交給用戶空間,用戶空間的入口即elf_entry
*/

if (interpreter) {
elf_entry = load_elf_interp(interp_elf_ex,
interpreter,
load_bias, interp_elf_phdata,
&arch_state);
...
}
// 放入新程序的命令行參數、環境列表等內容到新進程內存中,構建bss和初始化數據段等進程空間的內容
...
retval = create_elf_tables(bprm, elf_ex, interp_load_addr,
e_entry, phdr_addr);
...
// 內核控制交給用戶空間,進入用戶空間後會直接進入解釋器的入口elf_entry,由解釋器加載動態鏈接庫
// 最後開始運行用戶程序
START_THREAD(elf_ex, regs, elf_entry, bprm->p);
}

現在我們的程序已經交給動態解釋器了,解釋器將依賴的二進制庫鏈接給test,然後進入test的entry。通過objdump -d test看一下是通過_start函數開始執行test

Disassembly of section .text:
0000000000000600 <_start>:
...
62c: 97ffffe5 bl 5c0 <__libc_start_main@plt>
630: 97fffff0 bl 5f0 <abort@plt>

我們繼續尋找用戶空間程序的入口點,可以通過gdb調試來看Entry point 為 0xaaaaaaaa0600,在此處打斷點

root@yielde:~/workspace/code-container/cpp# gdb test
(gdb) i file
Symbols from "/root/workspace/code-container/cpp/test".
Native process:
Using the running image of child process 336143.
While running this, GDB does not access memory from...
Local exec file:
`/root/workspace/code-container/cpp/test', file type elf64-littleaarch64.
Entry point: 0xaaaaaaaa0600
(gdb) b *0xaaaaaaaa0600
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /root/workspace/code-container/cpp/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".
Breakpoint 2, 0x0000aaaaaaaa0600 in _start ()
(gdb) bt
#0 0x0000aaaaaaaa05c0 in __libc_start_main@plt ()
#1 0x0000aaaaaaaa0630 in _start ()

不出所料,入口點並不是main,而是_start()將main運行需要的agc,argv傳遞給__libc_start_main()

__libc_start_main()初始化線程子系統,註冊rtld_fini和fini來做程序退出後的清理工作,將。然後運行main(),最後在main return後調用exit(return值)來處理退出

進程退出

如果進程正常退出,調用glibc的exit(),如果異常崩潰或kill -9殺死,那麼不經過用戶程序,直接由內核的do_group_exit()做處理

// main函數return 5;
// 繼續strace部分內容
exit_group(5) = ?
exited with 5

exit()->__run_exit_handlers():會執行我們使用atexit()註冊的函數(順序為先註冊的後執行)->_exit(int status) -> INLINE_SYSCALL (exit_group, 1, status);最終就是我們通過strace看到的系統調用exit_group(status)。

SYSCALL_DEFINE1(exit_group, int, error_code)
{
do_group_exit((error_code & 0xff) << 8);
/* NOTREACHED */
return 0;
}
// do_group_exit做真正的退出工作
void __noreturn
do_group_exit(int exit_code){
...
do_exit(exit_code);
}
// do_exit會釋放一系列進程使用的資源
https://elixir.bootlin.com/linux/latest/C/ident/switch_count

void __noreturn do_exit(long code)
{
...
exit_mm();
if (group_dead)
acct_process();
trace_sched_process_exit(tsk);
exit_sem(tsk);
exit_shm(tsk);
exit_files(tsk);
exit_fs(tsk);
if (group_dead)
disassociate_ctty(1);
exit_task_namespaces(tsk);
exit_task_work(tsk);
exit_thread(tsk);
...
cgroup_exit(tsk);
...
// 給父進程發出SIGCHLD信號
exit_notify(tsk, group_dead);
...
do_task_dead();
}

do_task_dead()調用set_special_state(TASK_DEAD);將進程標記為TASK_DEAD狀態,並調用__schedule(SM_NONE);發起調度讓出CPU,進程完全退出。

進程正常退出與異常終止最終都是通過do_group_exit(),但是正常退出會通過__run_exit_handlers()處理exitat()註冊的清理工作,異常終止則直接內核接管退出。

常用系統API

fork

fork可以創建新的進程,我們追蹤test啟動的時候就是通過shell fork出的子進程。fork返回兩次,我們會用父子進程執行不同的代碼分支。

pid_t fork(void);
// 成功:向子進程返回0,向父進程返回子進程的pid。
// 失敗:返回-1,設置errno
// errno:
// EAGAIN 超出用戶或系統進程數上線
// ENOMEM 無法為該進程分配足夠的內存空間
// ENOSYS 不支持fork調用

demo

#include <stdio.h>
#include <unistd.h>
int main() {
int ret = fork();
if (ret == 0) {
printf("i'm parent\n");
} else if (ret > 0) {
printf("i'm child\n");
} else {
printf("error handle\n");
}
return 0;
}
// -------輸出---------
root@
yielde:~/workspace/code-container/cpp# ./test

i'm child
i'm parent

fork之後

內存的拷貝(copy-on-write)

我們追蹤test時,執行execve之後,會釋放掉原有的內存結構,並為新進程準備新的內存空間用來映射ELF的信息。fork之後如果拷貝原有進程的堆、棧、數據段,那麼緊接著大部分使用場景就是釋放這些內容,這使得fork性能不佳,linux使用copy-on-write技術解決該問題:

將子進程的頁表項指向與父進程相同的物理內存頁,然後復制父進程的頁表項,這樣父子進程共用一份物理內存,並且將共用的頁表標記為隻讀。

如果父子進程中任何一方需要修改頁表項,會觸發缺頁異常,內核會為該頁分配物理內存,並復制該內存頁,此時父子進程各自擁有了獨立的物理頁,將兩個頁表設置為可寫。

文件描述符

父子進程的文件描述符被子進程復制,並且父子進程共享文件表項,自然會共享文件偏移量,所以父子進程對文件的讀寫會互相影響。通過open調用時設置FD_CLOSEXEC標志,子進程在執行exec傢族函數的時候會先關閉該文件描述符

其他復制

userid,groupid,有效userid,有效groupid

進程組id、會話id、tty

工作目錄、根目錄、sig_mask、FD_CLOSEXEC

env、共享內存段、rlimit

不復制

未處理的信號集會被清空

父進程設置的文件鎖

未處理的alarm會被清除

wait、waitpid、waittid

wait系列函數用於等待子進程的狀態改變(包括子進程終止、子進程收到信號停止、已經停止的子進程被信號喚醒)。如果子進程終止,子進程的pid、內核棧等並不會被釋放,但是子進程運行的內存空間已經被釋放,此時子進程無法運行,變為僵屍狀態,父進程調用wait系函數來獲取子進程的退出狀態,內核也可以釋放子進程相關信息,子進程完全消失。

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
// 成功返回退出子進程的ID
// 失敗返回-1設置errno:ECHLD表示沒有子進程需要等待。EINTR:被信號中斷

wait

demo

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <wait.h>
pid_t r_wait(int *stat) {
int ret;
while (((ret = wait(stat)) == -1) && (errno == EINTR))
;
return ret;
}
int main() {
int stat;
pid_t pid = fork();
if (pid > 0) {
pid_t child_pid;
int ret = r_wait(&stat);
printf("child pid %d exit with code %d\n", ret,
(stat >> 8) & 0xff); // 獲取子進程的返回值
} else if (pid == 0) {
pid_t child_pid = getpid();
sleep(3);
printf("i'm child, pid: %d\n", child_pid);
exit(10);
} else {
printf("fork failure\n");
}
return 0;
}
// ------------------------
root@
yielde:~/workspace/code-container/cpp# ./test

child: i'm child, pid: 398918
parent: child pid 398918 exit with code 10
parent: no child need to wait

使用wait存在以下幾個問題:

無法wait特定的子進程,隻能wait所有子進程,然後通過返回值來判斷特定的子進程

如果沒有子進程退出,則wait阻塞

wait函數隻能等待終止的子進程,如果子進程是停止狀態或者從停止狀態恢復運行,wait是無法探知的。

waitpid

pid_t waitpid(pid_t pid, int *wstatus, int options);
// pid可以指定等待哪一個子進程的退出,
// pid=0等待進程組內任意子進程狀態改變
// pid=-1與wait()等價
// pid<-1,等待進程組為[pid]的所有子進程
// options是一個位掩碼,有如下標志
// 0:等待終止的子進程
// WUNTRACE:可以等待因信號停止的子進程
// WCONTINUED:可以等待收到信號恢復運行的子進程
// WNOHANG:立即返回0,如果沒有與pid匹配的進程,則返回-1並設置errno為ECHILD

直接返回的status值是不可用的(wait也一樣),可以通過相關的宏來支持作業控制、子進程正常終止、被信號終止,獲取退出狀態也是通過宏。man wait查看

waitpid有個問題就是子進程終止和子進程停止無法獨立監控,想要隻關心停止而忽略終止是不行的。

waittid

解決了上面兩種wait函數的問題

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
// idtype:P_PID探測id進程,P_PGID探測進程組為id的進程,P_ALL等待任意子進程忽略id
// infop:保存子進程退出的相關信息
// options:WEXITED等待子進程終止
// WSTOPPED等待子進程停止
// WCONTINUED等待停止的子進程被信號喚醒運行
// WNOHANG與waitpid相同
// WNOWAIT,wait和waitpid會將子進程的僵屍狀態改變為TASK_DEAD,該標志位隻獲取信息而不改變子進程狀態

demo

設置WNOWAIT觀察子進程的狀態

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <wait.h>
int main() {
int stat;
pid_t pid = fork();
if (pid > 0) {
siginfo_t info;
int ret;
memset(&info, '\0', sizeof(info));
ret = waitid(P_PGID, getpid(), &info, WEXITED | WNOWAIT);
if ((ret == 0) && (info.si_pid == pid)) {
printf("child %d exit, exit event: %d, exit status: %d\n", pid,
info.si_code, info.si_status);
}
} else if (pid == 0) {
sleep(3);
printf("i'm child, pid: %d\n", getpid());
return 10;
} else {
printf("fork failure\n");
}
sleep(15);
return 0;
}
// ---------------
root@
yielde:~/workspace/code-container/cpp/blog_demo# ./test

i'm child, pid: 401845
child 401845 exit, exit event: 1, exit status: 10
sleep ....
// 父進程獲取到子進程退出信息後,子進程仍然為僵屍狀態
root 401844 0.0 0.0 2184 776 pts/3 S 23:01 0:00 ./test
root 401845 0.0 0.0 0 0 pts/3 Z 23:01 0:00 [test] <defunct>

system

system相當於我們fork出子進程->子進程執行exec執行命令->父進程waitpid等待子進程返回,隻不過使用system時,system會fork出一個shell,然後shell創建子進程來執行命令,因此調用system的返回值如下:

如果system內部fork失敗或waitpid返回了除EINTR之外的錯誤,system返回-1設置errno。如果SIGCHILD被設置為SIG_IGN,那麼system返回-1並設置errno為ECHLD,無法判斷命令是否執行成功

如果exec失敗,返回127(shell執行失敗的指令,可以在shell寫一個不存在的命令,然後echo $?看下)

如果system執行成功,會返回shell的終止狀態,即最後一條命令的退出狀態

system(NULL)探測shell是否可用,如果返回0表示shell不可用,返回1表示shell可用

demo

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main() {
// int ret = system("lss -l"); //執行錯誤的命令
// int ret = system("ls -l"); // 正常執行命令
int ret = system("sleep 50"); // 執行命令進程被信號殺死
if (ret == -1) {
printf("system return -1, errno is: %s", strerror(errno));
} else if (WIFEXITED(ret) && WEXITSTATUS(ret) == 127) {
// WIFEXITED(wstatus) returns true if the child terminated normally(在 man wait中)
// WEXITSTATUS(wstatus) returns the exit status of the child
printf("shell can't exec the command\n");
} else {
if(WIFEXITED(ret)){
printf("normal termination, exit code = %d\n", WEXITSTATUS(ret));
}else if(WIFSIGNALED(ret)){
// WIFSIGNALED(wstatus) returns true if the child process was terminated by a signal.
printf("abnormal termination, signal number = %d\n", WTERMSIG(ret));
}
}
}

分別編譯測試三種情況:

讓system執行一個錯誤的命令,運行如下

root@yielde:~/workspace/code-container/cpp/blog_demo# ./test
sh: 1: lss: not found
shell can't exec the command

讓system正常執行命令

root@yielde:~/workspace/code-container/cpp/blog_demo# ./test
total 40
-rw-r--r-- 1 root root 4107 Jan 19 21:16 epoll_oneshot.cc
-rw-r--r-- 1 root root 2642 Jan 18 19:44 oob_recv_select.cc
-rw-r--r-- 1 root root 1659 Jan 18 22:11 poll.cc
-rw-r--r-- 1 root root 739 Jan 25 23:34 system_test.cc
-rwxr-xr-x 1 root root 9064 Jan 25 23:34 test
-rw-r--r-- 1 root root 795 Jan 25 22:24 wait_test.cc
-rw-r--r-- 1 root root 651 Jan 25 23:01 waittid_test.cc
normal termination, exit code = 0

給system執行的命令發送kill -9

//kill
root@
yielde:~/workspace/code-container/cpp
# ps aux|grep sleep
root 403568 0.0 0.0 2304 836 pts/3 S 23:42 0:00 sh -c sleep 50
root 403569 0.0 0.0 5180 788 pts/3 S 23:42 0:00 sleep 50
root 403613 0.0 0.0 5888 2008 pts/1 S 23:42 0:00 grep --color=auto sleep
root@
yielde:~/workspace/code-container/cpp
#
root@
yielde:~/workspace/code-container/cpp# kill -9 403568

// 結果
root@
yielde:~/workspace/code-container/cpp/blog_demo# ./test

abnormal termination, signal number = 9

來源:IT清水之傢