2023-06-11  阅读(3)
原文作者:奇小葩 原文地址:https://blog.csdn.net/u012489236/category_10946851.html

我们常见的一个应用场景是,在shell中输入命令,然后等待命令返回。如果以进程创建和终止的角度来看,shell首先会读取命令,解析命令,创建自建成并执行命令,然后父进程在等待子进程终止,其如下图示

202306111304074401.png

对于用户空间的一个进程,首先我们需要编写对应的.c/.h文件,然后经过编译器编译成二进制的可执行文件,装载到硬盘上开始执行,最终生成用户进程,这里面涉及到很多细节,本章主要针对这些内容进行深入学习,主要包括以下内容

  • 对于程序员如何从文本文件到可执行的程序
  • 操作系统如何完成对于可执行文件加载

1 程序的编译

当我们把程序写完了,是否就万事大吉了,可是CPU是不能执行文本文件里的指令,CPU需要能够执行机制指令,比如"0101"这种,所以这些需要能够翻译成机器识别的二进制文件,这个过程就叫编译。

在Linux下面,二进制的程序需要有严格的格式,这种格式被成为ELF(Executeable and Linkable Format,可执行与可链接格式),这种格式根据编译的结果不同,分为不同的格式。我们对于编译的整个过程如下图所示

202306111304081422.png

在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o 文件,这就是 ELF 的第一种类型, 可重定位文件 (Relocatable File)。

这个文件的格式是这样的:

202306111304091353.png

ELF 文件的头是用于描述整个文件的。这个文件格式在内核中有定义,分别为 struct elf32_hdr 和 struct elf64_hdr。

  • .text:放编译好的二进制可执行代码
  • .data:已经初始化好的全局变量
  • .rodata:只读数据,例如字符串常量、const 的变量
  • .bss:未初始化全局变量,运行时会置 0
  • .symtab:符号表,记录的则是函数和变量
  • .strtab:字符串表、字符串常量和变量名

2 ELF文件格式

Linux下标准的可执行文件格式是ELF.ELF(Executable and Linking Format)是一种对象文件的格式,用于定义不同类型的对象文件(Object files)中都放了什么东西、以及都以什么样的格式去放这些东西。它自最早在 System V 系统上出现后,被 UNIX 世界所广泛接受,作为缺省的二进制文件格式来使用。

但是linux也支持其他不同的可执行程序格式, 各个可执行程序的执行方式不尽相同, 因此linux内核每种被注册的可执行程序格式都用linux_bin_fmt来存储, 其中记录了可执行程序的加载和执行函数。同时我们需要一种方法来保存可执行程序的信息, 比如可执行文件的路径, 运行的参数和环境变量等信息,即linux_bin_prm结构

    struct linux_binprm {
    	char buf[BINPRM_BUF_SIZE];                      // 保存可执行文件的头128字节
    #ifdef CONFIG_MMU
    	struct vm_area_struct *vma;                     //内存相关vm_area_structc初始化
    	unsigned long vma_pages;
    #else
    # define MAX_ARG_PAGES	32
    	struct page *page[MAX_ARG_PAGES];
    #endif
    	struct mm_struct *mm;							//内存相关mm_struct初始化
    	unsigned long p; /* current top of mem */
    	unsigned int
    		cred_prepared:1,/* true if creds already prepared (multiple
    				 * preps happen for interpreters) */
    		cap_effective:1;/* true if has elevated effective capabilities,
    				 * false if not; except for init which inherits
    				 * its parent's caps anyway */
    #ifdef __alpha__
    	unsigned int taso:1;
    #endif
    	unsigned int recursion_depth; /* only for search_binary_handler() */
    	struct file * file;							// 要执行的文件
    	struct cred *cred;	/* new credentials */
    	int unsafe;		/* how unsafe this exec is (mask of LSM_UNSAFE_*) */
    	unsigned int per_clear;	/* bits to clear in current->personality */
    	int argc, envc;
    	const char * filename;	/* Name of binary as seen by procps,要执行的文件的名称*/
    	const char * interp;	/* Name of the binary really executed. Most
    				   of the time same as filename, but could be
    		different for binfmt_{misc,script} *///要执行的文件的真实名称,通常和filename相同
    	unsigned interp_flags;
    	unsigned interp_data;
    	unsigned long loader, exec;
    }

linux内核对所支持的每种可执行的程序类型都有个struct linux_binfmt的数据结构,定义如下

    struct linux_binfmt {
    	struct list_head lh;
    	struct module *module;
    	int (*load_binary)(struct linux_binprm *);
    	int (*load_shlib)(struct file *);
    	int (*core_dump)(struct coredump_params *cprm);
    	unsigned long min_coredump;	/* minimal dump size */
    };
成员 描述
load_binary 通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境,普通程序加载
load_shlib 用于动态的把一个共享库捆绑到一个已经在运行的进程,这是由uselib()系统调用激活的,主要用于动态加载,即动态库
core_dump 主要用于程序错误的情况下输出共享转储,该转存储随后可以通过调试器(gdb)分析,以便解决问题

所有的linux_binfmt对象都处于一个链表中, 第一个元素的地址存放在formats变量中, 可以通过调用register_binfmt()和unregister_binfmt()函数在链表中插入和删除元素, 在系统启动期间, 为每个编译进内核的可执行格式都执行registre_fmt()函数. 当实现了一个新的可执行格式的模块正被装载时, 也执行这个函数, 当模块被卸载时, 执行unregister_binfmt()函数.

3 程序启动

我们启动程序一般都是在命令行中,其实是在与shell打交道,然后shell帮我们启动程序,并传递相关参数。strace工具能够追踪一个程序执行的系统调用,因而我们构造一个简单的空程序,并在命令行执行: strace ./a.out -a -b

202306111304097634.png

可见,shell启动程序时执行的第一个系统调用为execve,在glibc库函数中的exec函数族: execl, execlp, execle, execv, execvp, execvpe最终即是调用得该系统调用。

    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);
    }

第一个参数是要被执行的程序的路径,第二个参数则向程序传递了命令行参数,第三个参数则向程序传递环境变量[1]。当进入sys_execve()系统调用时,在中断处理程序中调用了do_execve()[2]:(路径:fs/exec.c)

    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中的参数之外,还有额外的两个参数。

    static int do_execveat_common(int fd, struct filename *filename,
    			      struct user_arg_ptr argv,
    			      struct user_arg_ptr envp,
    			      int flags)
    {
    	char *pathbuf = NULL;
    	struct linux_binprm *bprm;
    	struct file *file;
    	struct files_struct *displaced;
    	int retval;
        //1. 检查文件名指针释放为空,如果为空,就直接返回
    	if (IS_ERR(filename))                 
    		return PTR_ERR(filename);
    	//2. 检查当前进程的标志,表明未超出正在运行的进程的限制
    	if ((current->flags & PF_NPROC_EXCEEDED) &&
    	    atomic_read(&current_user()->processes) > rlimit(RLIMIT_NPROC)) {
    		retval = -EAGAIN;
    		goto out_ret;
    	}
    	//3.如果两项检查成功,我们将当前进程的标志取消设置PF_NPROC_EXCEEDED,以防止程序执行失败
    	current->flags &= ~PF_NPROC_EXCEEDED;
    	//4. 取消共享当前任务的文件,并检查此函数结果
    	retval = unshare_files(&displaced);
    	if (retval)
    		goto out_ret;
    
    	retval = -ENOMEM;
        //5, 二进制参数准备,内核申请struct linux_binprm结构
    	bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
    	if (!bprm)
    		goto out_files;
        //6. 准备工作,初始化化linux_binprm的cred结构变量,该结构变量中包含任务的实际uid,任务的实际guid,虚拟文件系统操作的uid和gudid
    	retval = prepare_bprm_creds(bprm);
    	if (retval)
    		goto out_free;
        //7. 将当前进程设置为in_execve状态
    	check_unsafe_exec(bprm);
    	current->in_execve = 1;
    	//8. 核心函数,打开可执行文件
    	file = do_open_execat(fd, filename, flags);
    	retval = PTR_ERR(file);
    	if (IS_ERR(file))
    		goto out_unmark;
    	//9. 用于确定可以执行新程序的最小负载处理器,并将当前进程迁移到该处理器
    	sched_exec();
    	//10. 检查给出的二进制文件的文件描述符
    	bprm->file = file;
        //我们尝试检查二进制文件的名称是否从/符号开始,或者给定的可执行二进制文件的路径是否相对于调用进程	  //的当前工作目录进行了解释,或者文件描述符为AT_FDCWD。 如果这些检查之一成功,我们将设置二进制参数     //文件名
    	if (fd == AT_FDCWD || filename->name[0] == '/') {
    		bprm->filename = filename->name;
    	} else {
         //否则,如果文件名称为空,则将文件名设置为/dev/fd/%d (即/dev/fd/文件描述符),否则将文件名重新设      //置为/dev/fd/%d/文件名(其中,fd指向可执行文件的文件描述符)
    		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;
    		}
    		//11. 根据获取的信息,填充structlinux_binprm结构体中的file、filename、interp成员
    		if (close_on_exec(fd, rcu_dereference_raw(current->files->fdt)))
    			bprm->interp_flags |= BINPRM_FLAGS_PATH_INACCESSIBLE;
    		bprm->filename = pathbuf;
    	}
    	bprm->interp = bprm->filename;
    	//12. 调用bprm_mm_init()创建进程的内存地址空间,为新程序初始化内存管理.
    	retval = bprm_mm_init(bprm);
    	if (retval)
    		goto out_unmark;
    	//计算命令行参数和环境变量
    	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;
    	//13. 读取二进制文件,调用prepare_binprm函数将inode的uid填充到linux_binprm结构中,并从二进制可		//执行文件中读取128个字节, 我们只从可执行文件中读取前128个,因为我们需要检查可执行文件的类型
    	retval = prepare_binprm(bprm);
    	if (retval < 0)
    		goto out;
    	//14. 准备好linux_bprm结构后,我们通过调用copy_strings_kernel函数将可执行二进制文件的文件名,命	 //令行参数和环境变量从内核复制到linux_bprm
    	retval = copy_strings_kernel(1, &bprm->filename, bprm);
    	if (retval < 0)
    		goto out;
    
    	bprm->exec = bprm->p;
    	retval = copy_strings(bprm->envc, envp, bprm);
    	if (retval < 0)
    		goto out;
    
    	retval = copy_strings(bprm->argc, argv, bprm);
    	if (retval < 0)
    		goto out;
    
    	would_dump(bprm, bprm->file);
    	//15. 通过调用exec_binprm函数来存储当前当前任务所在进程的pid
    	retval = exec_binprm(bprm);
    	if (retval < 0)
    		goto out;
    
    	//16. 执行成功,做一些清理工作
    	current->fs->in_exec = 0;
    	current->in_execve = 0;
    	acct_update_integrals(current);
    	task_numa_free(current);
    	free_bprm(bprm);
    	kfree(pathbuf);
    	putname(filename);
    	if (displaced)
    		put_files_struct(displaced);
    	return retval;
    
    out:
    	if (bprm->mm) {
    		acct_arg_size(bprm, 0);
    		mmput(bprm->mm);
    	}
    
    out_unmark:
    	current->fs->in_exec = 0;
    	current->in_execve = 0;
    
    out_free:
    	free_bprm(bprm);
    	kfree(pathbuf);
    
    out_files:
    	if (displaced)
    		reset_files_struct(displaced);
    out_ret:
    	putname(filename);
    	return retval;
    }
  • 调用kzalloc()分配一份structlinux_binprm结构体
  • 调用open_exec()查找并打开二进制文件
  • 调用sched_exec()找到最小负载的CPU,用来执行该二进制文件
  • 根据获取的信息,填充structlinux_binprm结构体中的file、filename、interp成员
  • 调用bprm_mm_init()创建进程的内存地址空间,为新程序初始化内存管理.
  • 填充structlinux_binprm结构体中的argc、envc成员
  • 调用prepare_binprm()检查该二进制文件的可执行权限;最后,kernel_read()读取二进制文件的头128字节
  • 调用copy_strings_kernel()从内核空间获取二进制文件的路径名称
  • 调用copy_string()从用户空间拷贝环境变量和命令行参数
  • 至此,二进制文件已经被打开,struct linux_binprm结构体中也记录了重要信息, 内核开始调用exec_binprm执行可执行程序

下面来重点看看内存相关信息的处理接口bprm_mm_init

    static int bprm_mm_init(struct linux_binprm *bprm)
    {
    	int err;
    	struct mm_struct *mm = NULL;
    	//申请mm_struct
    	bprm->mm = mm = mm_alloc();
    	err = -ENOMEM;
    	if (!mm)
    		goto err;
    	//初始化mm_struct 和 vm_area_struct结构
    	err = __bprm_mm_init(bprm);
    	if (err)
    		goto err;
    
    	return 0;
    
    err:
    	if (mm) {
    		bprm->mm = NULL;
    		mmdrop(mm);
    	}
    
    	return err;
    }

内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干(当前Linux内核中是128)字节(实际上就是填充ELF文件头,下面的分析可以看到),然后调用另一个函数exec_binprm()

4 处理参数结构

通过调用exec_binprm函数来存储当前当前任务所在进程的pid,下面需要识别该二进制文件的格式并最终运行该文件

    static int exec_binprm(struct linux_binprm *bprm)
    {
    	pid_t old_pid, old_vpid;
    	int ret;
    
    	/* Need to fetch pid before load_binary changes it */
    	old_pid = current->pid;
    	rcu_read_lock();
    	old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
    	rcu_read_unlock();
    
    	ret = search_binary_handler(bprm);
    	if (ret >= 0) {
    		audit_bprm(bprm);
    		trace_sched_process_exec(current, old_pid, bprm);
    		ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
    		proc_exec_connector(current);
    	}
    
    	return ret;
    }

调用函数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;
        }
    }

在load_binary中检查linux_bprm缓冲区中的魔数(每个elf二进制文件的头中都包含魔数,我们从可执行二进制文件中读取了前128个字节),如果不是elf二进制,则退出。根据可执行文件的类型(如shell,a.out,ELF等),查找到相应的处理函数(系统为每种文件类型创建了一个struct linux_binfmt,并把其串在一个链表上,执行时遍历这个链表,找到相应类型的结构。如果要自己定义一种可执行文件格式,也需要实现这么一个handler)。然后执行相应的load_binary()函数开始加载可执行文件。

5 加载ELF

inux内核启动时将ELF格式注册到内核可支持的文件格式链表中,也就是通过register_binfmt 函数将定义的elf_format结构体添加到链表中。该结构体如下:

    static struct linux_binfmt elf_format = {
    	.module		= THIS_MODULE,
    	.load_binary	= load_elf_binary,
    	.load_shlib	= load_elf_library,
    	.core_dump	= elf_core_dump,
    	.min_coredump	= ELF_EXEC_PAGESIZE,
    };

当我们执行一个可执行程序的时候, 内核会list_for_each_entry遍历所有注册的linux_binfmt对象, 对其调load_binrary方法来尝试加载, 直到加载成功为止。上面代码可以看倒,ELF中加载程序即为load_elf_binary,内核中已经注册的可运行文件结构linux_binfmt会让其所属的加载程序load_binary逐一前来认领需要运行的程序binary,如果某个格式的处理程序发现相符后,便执行该格式映像的装入和启动。
**第一步,填充并且检查目标程序ELF头部**

    	/* Get the exec-header */
    //1. 获取到exec文件的'elf header'保存到loc->elf_ex bprm->buf[]中实现读取了exec文件头256字节的内容
    	loc->elf_ex = *((struct elfhdr *)bprm->buf);
    
    	retval = -ENOEXEC;
    	/* First of all, some simple consistency checks */
    //2. 检查elf header中的magic number是否合法
    	if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
    		goto out;
    //3. 检查文件类型是否是可以执行的exe或者so
    	if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
    		goto out;
    //4. 检查文件的架构类型和当前环境是否符合
    	if (!elf_check_arch(&loc->elf_ex))
    		goto out;
    	if (!bprm->file->f_op->mmap)
    		goto out;
    //5. 根据exec文件'elf header'中的信息,读出 'program header table' 保存到elf_phdata
    	elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
    	if (!elf_phdata)
    		goto out;

首先是填充文件头,使用内核之前对bprm->buf填充的128个字节信息。然后比较了文件头的前4个字节,查看是否是标准的ELF文件魔数(”\177ELF”),然后还需要确认该文件是可执行文件还是动态链接库文件,也就是代码中的ET_EXEC和ET_DYN。

第二步,通过load_elf_phdrs加载目标程序的程序头表

    static struct elf_phdr *load_elf_phdrs(struct elfhdr *elf_ex,
    				       struct file *elf_file)
    {
    	struct elf_phdr *elf_phdata = NULL;
    	int retval, size, err = -1;
    
    	/*
    	 * If the size of this structure has changed, then punt, since
    	 * we will be doing the wrong thing.
    	 */
    	if (elf_ex->e_phentsize != sizeof(struct elf_phdr))
    		goto out;
    
    	/* Sanity check the number of program headers... */
    	if (elf_ex->e_phnum < 1 ||
    		elf_ex->e_phnum > 65536U / sizeof(struct elf_phdr))
    		goto out;
    
    	/* ...and their total size. */
    	size = sizeof(struct elf_phdr) * elf_ex->e_phnum;
    	if (size > ELF_MIN_ALIGN)
    		goto out;
    
    	elf_phdata = kmalloc(size, GFP_KERNEL);
    	if (!elf_phdata)
    		goto out;
    
    	/* Read in the program headers */
    	retval = kernel_read(elf_file, elf_ex->e_phoff,
    			     (char *)elf_phdata, size);
    	if (retval != size) {
    		err = (retval < 0) ? retval : -EIO;
    		goto out;
    	}
    
    	/* Success! */
    	err = 0;
    out:
    	if (err) {
    		kfree(elf_phdata);
    		elf_phdata = NULL;
    	}
    	return elf_phdata;
    }

该函数有两个参数,elf_ex表示需要程序头表需要被加载的二进制映像的ELF头部;elf_file表示这个打开的ELF二进制映像文件。函数首先检查该文件是否包含至少一个段,且所有段的大小之和是否超过64k。如果符合条件,调用kernel_read读入程序头表。

第三步,处理解释器段

如果需要动态链接,则需要寻找和处理器解释段

    	for (i = 0; i < loc->elf_ex.e_phnum; i++) {
    //1.  找到PT_INTERP segment
    		if (elf_ppnt->p_type == PT_INTERP) {		
    			/* This is the program interpreter used for
    			 * shared libraries - for now assume that this
    			 * is an a.out format binary
    			 */
    			retval = -ENOEXEC;
    			if (elf_ppnt->p_filesz > PATH_MAX || 
    			    elf_ppnt->p_filesz < 2)
    				goto out_free_ph;
    
    			retval = -ENOMEM;
    			elf_interpreter = kmalloc(elf_ppnt->p_filesz,
    						  GFP_KERNEL);
    			if (!elf_interpreter)
    				goto out_free_ph;
    //2. 读出PT_INTERP segment的内容
    			retval = kernel_read(bprm->file, elf_ppnt->p_offset,
    					     elf_interpreter,
    					     elf_ppnt->p_filesz);
    			if (retval != elf_ppnt->p_filesz) {
    				if (retval >= 0)
    					retval = -EIO;
    				goto out_free_interp;
    			}
    			/* make sure path is NULL terminated */
    			retval = -ENOEXEC;
    			if (elf_interpreter[elf_ppnt->p_filesz - 1] != '\0')
    				goto out_free_interp;
    //3. open interpreter文件,得到操作句柄
    			interpreter = open_exec(elf_interpreter);
    			retval = PTR_ERR(interpreter);
    			if (IS_ERR(interpreter))
    				goto out_free_interp;
    
    			/*
    			 * If the binary is not readable then enforce
    			 * mm->dumpable = 0 regardless of the interpreter's
    			 * permissions.
    			 */
    			would_dump(bprm, interpreter);
    
    			/* Get the exec headers */
    // 4.  读取interpreter文件的'elf header'保存到interp_elf_ex
    			retval = kernel_read(interpreter, 0,
    					     (void *)&loc->interp_elf_ex,
    					     sizeof(loc->interp_elf_ex));
    			if (retval != sizeof(loc->interp_elf_ex)) {
    				if (retval >= 0)
    					retval = -EIO;
    				goto out_free_dentry;
    			}
    
    			break;
    		}

通过遍历每个段,找到PT_INTERP类型的段,也即解释器段,找到就说明运行过程中需要动态链接。同样也是通过kernel_read函数将解释器段的内容读入缓冲区。readelf命令可以查看到程序的解释器段其实就是一个字符串,也就是解释器的文件名,比如“/lib/ld-linux.so.2”。再调用open_exec()函数根据这个文件名打开解释器文件,和前面一样,再读入128个字节,也就是解释器映像的头部。

可以使用readelf -l查看program headers, 其中的INTERP段标识了我们程序所需要的解释器,该过程只是动态链接需要解释器,静态链接不需要解释器。

202306111304110475.png

第四步,检查并读取解释器程序头表

如果需要加载解释器, 前面经过一趟for循环已经找到了需要的解释器信息elf_interpreter, 他也是当作一个ELF文件, 因此跟目标可执行程序一样, 我们需要load_elf_phdrs加载解释器的程序头表program header table

    	if (elf_interpreter) {//检查解释器头的信息
    		retval = -ELIBBAD;
    		/* Not an ELF interpreter */
    		if (memcmp(loc->interp_elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
    			goto out_free_dentry;
    		/* Verify the interpreter has a valid arch */
    		if (!elf_check_arch(&loc->interp_elf_ex))
    			goto out_free_dentry;
    
    		/* 读入解释器的程序头 */
    		interp_elf_phdata = load_elf_phdrs(&loc->interp_elf_ex,
    						   interpreter);
    		if (!interp_elf_phdata)
    			goto out_free_dentry;
    
    		/* Pass PT_LOPROC..PT_HIPROC headers to arch code */
    		elf_ppnt = interp_elf_phdata;
    		for (i = 0; i < loc->interp_elf_ex.e_phnum; i++, elf_ppnt++)
    			switch (elf_ppnt->p_type) {
    			case PT_LOPROC ... PT_HIPROC:
    				retval = arch_elf_pt_proc(&loc->interp_elf_ex,
    							  elf_ppnt, interpreter,
    							  true, &arch_state);
    				if (retval)
    					goto out_free_dentry;
    				break;
    			}
    	}

至此我们已经把目标执行程序和其所需要的解释器都加载初始化, 并且完成检查工作, 也加载了程序头表program header table, 下面开始加载程序的段信息

第五步,装入目标程序段segment

这段代码从目标映像的程序头中搜索类型为PT_LOAD的段(Segment)。在二进制映像中,只有类型为PT_LOAD的段才是需要装入的。当然在装入之前,需要确定装入的地址,只要考虑的就是页面对齐,还有该段的p_vaddr域的值(上面省略这部分内容)。确定了装入地址后,就通过elf_map()建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址。

    for(i = 0, elf_ppnt = elf_phdata;
        i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {
    
        /*  5.1   搜索PT_LOAD的段, 这个是需要装入的 */
        if (elf_ppnt->p_type != PT_LOAD)
            continue;
    
    
            /* 5.2  检查地址和页面的信息  */
            
            // ......
            ///
    
         /*  5.3  虚拟地址空间与目标映像文件的映射
         确定了装入地址后,
         就通过elf_map()建立用户空间虚拟地址空间
         与目标映像文件中某个连续区间之间的映射,
         其返回值就是实际映射的起始地址 */
        error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
                elf_prot, elf_flags, total_size);
    
        }

第七步,填入程序的入口地址

完成了目标程序和解释器的加载, 同时目标程序的各个段也已经加载到内存了, 我们的目标程序已经准备好了要执行了, 但是还缺少一样东西, 就是我们程序的入口地址, 没有入口地址, 操作系统就不知道从哪里开始执行内存中加载好的可执行映像

    	if (elf_interpreter) {
    		unsigned long interp_map_addr = 0;
    		//入口地址是解释器映像的入口地址
    		elf_entry = load_elf_interp(&loc->interp_elf_ex,
    					    interpreter,
    					    &interp_map_addr,
    					    load_bias, interp_elf_phdata);
    		if (!IS_ERR((void *)elf_entry)) {
    			/*
    			 * load_elf_interp() returns relocation
    			 * adjustment
    			 */
    			interp_load_addr = elf_entry;
    			elf_entry += loc->interp_elf_ex.e_entry;
    		}
    		if (BAD_ADDR(elf_entry)) {
    			retval = IS_ERR((void *)elf_entry) ?
    					(int)elf_entry : -EINVAL;
    			goto out_free_dentry;
    		}
    		reloc_func_desc = interp_load_addr;
    
    		allow_write_access(interpreter);
    		fput(interpreter);
    		kfree(elf_interpreter);
    	} else {
            //入口地址是目标程序的入口地址
    		elf_entry = loc->elf_ex.e_entry;
    		if (BAD_ADDR(elf_entry)) {
    			retval = -EINVAL;
    			goto out_free_dentry;
    		}
    	}

如果需要装入解释器,就通过load_elf_interp装入其映像, 并把将来进入用户空间的入口地址设置成load_elf_interp()的返回值,即解释器映像的入口地址。而若不装入解释器,那么这个入口地址就是目标映像本身的入口地址。

第八步,文件参数环境变量等必要信息

在完成装入,启动用户空间的映像运行之前,还需要为目标映像和解释器准备好一些有关的信息,这些信息包括常规的argc、envc等等,还有一些“辅助向量(Auxiliary Vector)”。这些信息需要复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。

    	retval = create_elf_tables(bprm, &loc->elf_ex,
    			  load_addr, interp_load_addr);
    	if (retval < 0)
    		goto out;
    	/* N.B. passed_fileno might not be initialized? */
    	current->mm->end_code = end_code;
    	current->mm->start_code = start_code;
    	current->mm->start_data = start_data;
    	current->mm->end_data = end_data;
    	current->mm->start_stack = bprm->p;

第九步,通过start_thread准备进入新的程序入口

start_thread()这个宏操作会将eip和esp改成新的地址,就使得CPU在返回用户空间时就进入新的程序入口。如果存在解释器映像,那么这就是解释器映像的程序入口,否则就是目标映像的程序入口。那么什么情况下有解释器映像存在,什么情况下没有呢?如果目标映像与各种库的链接是静态链接,因而无需依靠共享库、即动态链接库,那就不需要解释器映像;否则就一定要有解释器映像存在。

    static inline void start_thread_common(struct pt_regs *regs, unsigned long pc)
    {
    	memset(regs, 0, sizeof(*regs));
    	regs->syscallno = ~0UL;
    	regs->pc = pc;
    }

完成这个功能的是start_thread(),start_thread()并不启动一个线程,而只是用来修改了pt_regs中保存的PC等寄存器的值,使其指向加载的应用程序的入口。这样当内核操作结束,返回用户态的时候,接下来执行的就是应用程序了。

6 从ELF入口到main函数

从上面的过程,我们知道了一个新的进程,是如何执行到可执行的入口地址呢?对于这个入口地址,是我们的main函数吗?

我们还是以一个简单的hello world为例

    #include <stdio.h>
    int main() {
        printf("hello, world!\n");
        return 0;
    }

通过gcc编译后,生成了一个ELF可执行文件,通过 readelf 指令,可以实现对ELF文件的分析,这里可以看到ELF文件的入口地址是0x530

202306111304127696.png

随后,我们通过返回表,看一下位于0x530入口地址的地方是什么函数?

202306111304143657.png

可以看到,入口地址是一个_start的函数,并不是我们的main函数,在_start的结尾,调用了__libc_start_main函数,而这个函数位于libc.so中。

其实,在进入main函数之前,还有一个重要的工作要做,这就是: C/C++运行时库的初始化 。上面的**__libc_start_main** 就是在完成这一工作。

在通过GCC进行编译时,编译器将自动完成运行时库的链接,将我们的main函数封装起来,由它来调用。glibc是开源的,我们可以在GitHub上找到这个项目的libc-start.c文件,一窥 __libc_start_main 的真面目,我们的main函数正是被它在调用。

202306111304154268.png

7 总结

简短的说,整个在shell中键入./hello执行应用程序的过程为:当前shell进程fork出一个子进程(子shell),子进程使用execve来脱离和父进程的关系,加载hello文件(ELF格式)到内存中。如果hello使用了动态链接库,就需要加载动态链接器(或者叫程序解释器),进一步加载hello使用到的动态链接库到内存,并重定位以供hello调用,从hello的入口地址开始执行test。

202306111304173949.png


Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。

它的内容包括:

  • 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
  • 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
  • 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
  • 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
  • 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
  • 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
  • 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
  • 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw

目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:

想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询

同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。

阅读全文