程序启停分析与进程常用API的使用

进程是程序运行的实例,操作系统为进程分配独立的资源,使之拥有独立的空间,互不干扰。

空间布局

拿c程序来说,其空间布局包括如下几个部分:

  1. 数据段(初始化的数据段):例如在函数外的声明,int a = 1
  2. block started by symbol(未初始化的数据段):例如在函数外的声明,int b[10]
  3. 栈:保存局部作用域的变量、函数调用需要保存的信息。例如调用一个函数,保存函数的返回地址、调用者的环境信息,给临时变量分配空间
  4. 堆:动态内存分配
  5. 正文段:CPU执行的指令,通常是只读并共享的,例如同时打开多个文本编辑器进程,只需要读这一份正文段即可
  6. 命令行参数和环境变量

进程启动和停止

进程启动

strace命令来追一个c的hello world:

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

man一下execve,概括来说,execve()初始化栈、堆、bss、初始化数据段、并且将命令行参数、环境变量放到内存中。可以使用https://elixir.bootlin.com/去追一下源码。

cpp 复制代码
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()来做具体的事情,

  1. is_rlimit_overlimit()检查资源使用是否超过限制,struct linux_binprm *bprm;是一个结构体,用于记录命令参数、环境变量、要读入ELF程序的入口地址、rlimit等信息。
  2. bprm = alloc_bprm(fd, filename);为该结构分配内存,然后将bprm需要的内容copy进来。
  3. 构建好bprm后执行bprm_execve函数,函数注释sys_execve() executes a new program.该函数会做一些安全性的检查,然后do_open_execat(fd, filename, flags);打开我们的ELF程序(编译好的test1),执行exec_binprm函数来运行新进程
  4. exec_binprm()->search_binary_handler(),看下该函数的关键部分
cpp 复制代码
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)的部分内容读入内存,然后为新的进程设置独立的信息

cpp 复制代码
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);

    }
  1. 现在我们的程序已经交给动态解释器了,解释器将依赖的二进制库链接给test,然后进入test的entry。通过objdump -d test看一下是通过_start函数开始执行test
cpp 复制代码
Disassembly of section .text:

0000000000000600 <_start>:
...
 62c:   97ffffe5        bl      5c0 <__libc_start_main@plt>
 630:   97fffff0        bl      5f0 <abort@plt>
  1. 我们继续寻找用户空间程序的入口点,可以通过gdb调试来看Entry point 为 0xaaaaaaaa0600,在此处打断点
cpp 复制代码
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()

  1. __libc_start_main()初始化线程子系统,注册rtld_finifini来做程序退出后的清理工作,将。然后运行main(),最后在main return后调用exit(return值)来处理退出

进程退出

如果进程正常退出,调用glibc的exit(),如果异常崩溃或kill -9杀死,那么不经过用户程序,直接由内核的do_group_exit()做处理

cpp 复制代码
// 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)

cpp 复制代码
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返回两次,我们会用父子进程执行不同的代码分支。

cpp 复制代码
pid_t fork(void);

// 成功:向子进程返回0,向父进程返回子进程的pid。
// 失败:返回-1,设置errno
// errno:
// 		EAGAIN 超出用户或系统进程数上线
// 		ENOMEM 无法为该进程分配足够的内存空间
// 		ENOSYS 不支持fork调用

demo

cpp 复制代码
#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技术解决该问题:

  1. 将子进程的页表项指向与父进程相同的物理内存页,然后复制父进程的页表项,这样父子进程共用一份物理内存,并且将共用的页表标记为只读。
  2. 如果父子进程中任何一方需要修改页表项,会触发缺页异常,内核会为该页分配物理内存,并复制该内存页,此时父子进程各自拥有了独立的物理页,将两个页表设置为可写。

文件描述符

父子进程的文件描述符被子进程复制,并且父子进程共享文件表项,自然会共享文件偏移量,所以父子进程对文件的读写会互相影响。通过open调用时设置FD_CLOSEXEC标志,子进程在执行exec家族函数的时候会先关闭该文件描述符

其他复制

  • userid,groupid,有效userid,有效groupid
  • 进程组id、会话id、tty
  • 工作目录、根目录、sig_mask、FD_CLOSEXEC
  • env、共享内存段、rlimit

不复制

  • 未处理的信号集会被清空
  • 父进程设置的文件锁
  • 未处理的alarm会被清除

wait、waitpid、waittid

wait系列函数用于等待子进程的状态改变(包括子进程终止、子进程收到信号停止、已经停止的子进程被信号唤醒)。如果子进程终止,子进程的pid、内核栈等并不会被释放,但是子进程运行的内存空间已经被释放,此时子进程无法运行,变为僵尸状态,父进程调用wait系函数来获取子进程的退出状态,内核也可以释放子进程相关信息,子进程完全消失。

cpp 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *wstatus);
// 成功返回退出子进程的ID
// 失败返回-1设置errno:ECHLD表示没有子进程需要等待。EINTR:被信号中断

wait

demo

cpp 复制代码
#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存在以下几个问题:

  1. 无法wait特定的子进程,只能wait所有子进程,然后通过返回值来判断特定的子进程
  2. 如果没有子进程退出,则wait阻塞
  3. wait函数只能等待终止的子进程,如果子进程是停止状态或者从停止状态恢复运行,wait是无法探知的。

waitpid

cpp 复制代码
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
  1. 直接返回的status值是不可用的(wait也一样),可以通过相关的宏来支持作业控制、子进程正常终止、被信号终止,获取退出状态也是通过宏。man wait查看
  2. waitpid有个问题就是子进程终止和子进程停止无法独立监控,想要只关心停止而忽略终止是不行的。

waittid

解决了上面两种wait函数的问题

cpp 复制代码
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观察子进程的状态

cpp 复制代码
#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的返回值如下:

  1. 如果system内部fork失败或waitpid返回了除EINTR之外的错误,system返回-1设置errno。如果SIGCHILD被设置为SIG_IGN,那么system返回-1并设置errno为ECHLD,无法判断命令是否执行成功
  2. 如果exec失败,返回127(shell执行失败的指令,可以在shell写一个不存在的命令,然后echo $?看下)
  3. 如果system执行成功,会返回shell的终止状态,即最后一条命令的退出状态
  4. system(NULL)探测shell是否可用,如果返回0表示shell不可用,返回1表示shell可用

demo

cpp 复制代码
#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));
    }
  }
}

分别编译测试三种情况:

  1. 让system执行一个错误的命令,运行如下
cpp 复制代码
root@yielde:~/workspace/code-container/cpp/blog_demo# ./test 
sh: 1: lss: not found
shell can't exec the command
  1. 让system正常执行命令
cpp 复制代码
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
  1. 给system执行的命令发送kill -9
cpp 复制代码
//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

学习自:
《UNIX环境高级编程》
《Linux环境编程从应用到内核》高峰 李彬 著

相关推荐
xq51486312 分钟前
Linux系统下安装mongodb
linux·mongodb
柒七爱吃麻辣烫12 分钟前
在Linux中安装JDK并且搭建Java环境
java·linux·开发语言
孤寂大仙v1 小时前
【Linux笔记】——进程信号的产生
linux·服务器·笔记
深海蜗牛1 小时前
Jenkins linux安装
linux·jenkins
愚戏师1 小时前
Linux复习笔记(三) 网络服务配置(web)
linux·运维·笔记
JANYI20182 小时前
嵌入式MCU和Linux开发哪个好?
linux·单片机·嵌入式硬件
熊大如如2 小时前
Java NIO 文件处理接口
java·linux·nio
晚秋大魔王2 小时前
OpenHarmony 开源鸿蒙南向开发——linux下使用make交叉编译第三方库——nettle库
linux·开源·harmonyos
农民小飞侠2 小时前
ubuntu 24.04 error: cannot uninstall blinker 1.7.0, record file not found. hint
linux·运维·ubuntu
某不知名網友2 小时前
Linux 软硬连接详解
linux·运维·服务器