Linux进程调度与管理:(二)进程的加载与启动

《Linux6.5源码分析:进程管理与调度系列文章》

本系列文章将对进程管理与调度进行知识梳理与源码分析,重点放在linux源码分析上,并结合eBPF程序对内核中进程调度机制进行数据实时拿取与分析。

在进行正式介绍之前,有必要对文章引用进行提前说明。本系列文章参考了大量的博客、文章以及书籍:

Linux 进程调度与管理:(二)进程的加载与启动

在前面的文章Linux 进程管理与调度:(一)进程的创建与销毁中,我们对进程的创建有了一定的了解,进程可以通过fork、vfork、clone等方式被创建出来,新进程被创建成功后,会被放入父进程所在的运行队列等待运行;但以这种方式创建出来的子进程所使用到的代码、数据和其父进程完全一致,那系统中的进程岂不是完全一样了?本篇文章将围绕进程在被创建出来之后,如何加载新的程序展开,避免遇到上面的问题。

1.可执行文件ELF

既然要执行程序,就要对所执行的程序有一定的了解,可执行文件就是我们所写的源代码经过编译器编译后所生成的一个二进制文件;

ELF是可执行文件中的一种格式,接下来将通过一张图对ELF格式进行详细的介绍:

在这里插入图片描述

2.Linux 可执行文件加载器

Linux中支持的可执行文件格式不止ELF一种, 在系统启动时, 会把自己支持的所有可执行文件的解析器都加载上,并使用一个formats双向链表保存所有的解析器。

/*formats全局双向链表,挂载着系统中所有类型的加载器*/
static LIST_HEAD(formats);

Linux所支持的可执行文件格式有如下几种:

  • ELF:linux最常见的可执行文件格式;
  • aout:主要是为了和以前兼容;
  • EM86:在Alpha的主机上运行intel的linux二进制文件;

在这里插入图片描述

Linux中每一个加载器都用一个结构体linux_binfmt来表示,该结构体中声明了负责加载可执行文件的回调函数* load_binary , 处理共享库加载的回调函数* load_shlib, 以及负责加载崩溃文件的* core_dump函数等,每个类型的加载器都需要实现这些钩子函数,并将地址放在该结构体中;

/*
 * 定义 Linux 内核中的二进制格式加载器
 */
struct linux_binfmt {
	/*位于linux加载器链表的位置*/
	struct list_head lh;

	/*指向加载该 binfmt 处理器的内核模块,
	 *如果该加载器是通过内核模块(如 binfmt_misc)动态加载的,
	 *则这个字段指向其对应的 struct module 结构体
	 */
	struct module *module;
	/*负责加载可执行文件的核心函数,每个 binfmt 需要实现这个回调函数*/
	int (*load_binary)(struct linux_binprm *);
	/*处理共享库(Shared Library)的加载,适用于动态链接库的格式加载器(如 ELF)*/
	int (*load_shlib)(struct file *);
#ifdef CONFIG_COREDUMP
	/*负责生成 core dump(核心转储)文件,即程序崩溃时的调试信息*/
	int (*core_dump)(struct coredump_params *cprm);
	unsigned long min_coredump;	/* minimal dump size */
#endif
} __randomize_layout;

我们以ELF的加载器elf_format为例,介绍该加载器在初始化时是如何通过register_binfmt进行注册的;

/*定义elf加载器*/
static struct linux_binfmt elf_format = {
	.module		= THIS_MODULE,
	.load_binary	= load_elf_binary,
	.load_shlib	= load_elf_library,
#ifdef CONFIG_COREDUMP
	.core_dump	= elf_core_dump,
	.min_coredump	= ELF_EXEC_PAGESIZE,
#endif
};

elf_format加载器是由内核模块形式动态加载的,该内核模块的入口函数是init_elf_binfmt(),该函数会调用register_binfmt()进行注册工作,而register_binfmt()函数的工作是将加载器挂载到format全局链表;

/*elf_format加载器内核模块入口*/
static int __init init_elf_binfmt(void)
{
	register_binfmt(&elf_format);
	return 0;
}
/*init_elf_binfmt -> register_binfmt*/
/*include/linux/binfmts.h*/
static inline void register_binfmt(struct linux_binfmt *fmt)
{
	__register_binfmt(fmt, 0);
}

init_elf_binfmt() -> register_binfmt() -> __register_binfmt()

/*注册加载器到formats全局链表中*/
void __register_binfmt(struct linux_binfmt * fmt, int insert)
{
	/*1. 写锁申请*/
	write_lock(&binfmt_lock);
	/*2. 将目标加载器插入formats全局链表中*/
	insert ? list_add(&fmt->lh, &formats) :
		 list_add_tail(&fmt->lh, &formats);
	/*3. 写锁释放*/
	write_unlock(&binfmt_lock);
}c

3. shell启动用户进程

shell进程是负责通过 fork() + execve()的组合来创建、加载、运行新进程的,一个简单的shell的核心逻辑如下:

intmain(int argc, char * argv[]){
	...
	/*1.创建一个子进程*/
	pid = fork();
	if(pid == 0){
	/*2.使用execve函数加载并运行helloworld可执行文件*/
		execve("helloworld",argv,envp);
	}
	...
}

我们可以看出来,fork()系统调用可以为新进程克隆出一份和父进程一样的躯体, 但execve()系统调用才真正向新进程注入与众不同用的灵魂; 即新进程要执行自己的程序逻辑, 必须通过execve读取可执行文件并将其载入新进程的地址空间并运行;

所以我们把重点放在,如何将可执行文件加载到进程的地址空间中。

4. execve 加载用户程序

在前面我们介绍了要创建一个新进程并执行新程序, 需要使用到fork + exec组合, 前者为新进程创建躯体 (task_struct结构体), 后者为新进程注入灵魂 (要执行的程序); 本小节我们主要研究exec系列系统调用是如何将读取可执行文件并将其载入进程地址空间的。

4.1 execve系统调用

exec系列系统调用会读取用户输入的可执行文件名(filename)、参数列表(argv)、环境变量等参数(envp),开始加载并运行用户指定的可执行文件。

既然是系统调用,我们就从系统调用入口开始分析,当用户通过execve用户态程序去加载可执行文件时,会调用内核中execve系统调用,该系统调用会通过do_execve() -> do_execveat_common():

/*一、execve系统调用入口*/
/**
 * @brief execve系统调用入口
 * @param filename:可执行文件名;
 * @param argv:传参,参数列表;
 * @param envp:环境变量;
 */
SYSCALL_DEFINE3(execve,
		const char __user *, filename,
		const char __user *const __user *, argv,
		const char __user *const __user *, envp)
{
	/*1. 调用do_execve函数*/
	return do_execve(getname(filename), argv, envp);
}
/*二、调用do_execve函数*/
static int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	/*1. 设置传参与环境变量*/
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	/*2. 调用do_execveat_common去执行加载程序*/
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

4.2 do_execveat_common

do_execveat_common()函数会加载一个新的可执行文件,并用它来替换当前进程的代码和数据,从而执行新程序;该函数首先通过alloc_bprm()函数申请并初始化struct linux_binprm 结构体,该结构体用于存储可执行文件的信息、新进程的参数以及环境变量等;其次将文件名filename, 参数argv以及环境变量envp复制到bprm结构体中;最后,会调用函数bprm_execve执行加载工作,该函数会遍历formats全局链表,找到一个符合当前可执行文件格式的加载器,并通过该加载器中的fmt->load_binary()函数执行加载操作;

【一张流程图】

/*三、do_execveat_common 加载程序*/
/** @brief 加载一个新的可执行文件,并用它来替换当前进程的代码和数据,从而执行新程序
 *   1.通过alloc_bprm申请并初始化bprm
 *   2.通过bprm_execve执行加载工作
 */
static int do_execveat_common(int fd, struct filename *filename,
			      struct user_arg_ptr argv,
			      struct user_arg_ptr envp,
			      int flags)
{
    ...
    /*2. 申请并初始化bprm
     *   分配 struct linux_binprm 结构体,并初始化它
     */
    bprm = alloc_bprm(fd, filename);
    ...
    /*8. 执行新程序,遍历所有加载器,
     *   并使用对应的load_elf_binary函数执行加载操作;
     */
	retval = bprm_execve(bprm, fd, filename, flags);
    ...
}

关于alloc_bprm()以及bprm_execve函数的具体实现逻辑及过程,会在下面小节中详细分析;

4.3 struct linux_binprm结构体

内核对象struct linux_binprm 结构体是进程加载过程中的一个结构,在Linux 进程管理 和 execve() 进程替换过程中起着关键作用。它用于存储 可执行文件的信息、参数、环境变量、安全凭证、进程资源限制等,确保新的进程可以正确加载并执行。可以把它理解为一个临时对象。在加载的时候,该内核对象用来保存加载二进制文件时使用的参数等信息、为新进程申请的虚拟地址空间,以及分配的内存也会临时在这里放一会儿。等到新进程加载完毕,这个对象就没有什么用了。

下表详细列出了 struct linux_binprm 的每个字段的作用:

字段名类型作用
(1) 参数与环境变量存储
vma (启用 MMU)struct vm_area_struct *argvenvp 所在的 VMA(仅在 MMU 设备上使用)。
vma_pagesunsigned longvma 所占的页数。
page[MAX_ARG_PAGES] (无 MMU)struct page *用于存储 argvenvp(仅在无 MMU 设备上使用)。
(2) 进程地址空间
mmstruct mm_struct *进程的内存描述符,管理虚拟地址空间。
(3) 进程堆栈与参数管理
punsigned longargvenvp 在进程栈中的存储位置。
argminunsigned longRLIMIT_STACK 限制标记,用于 copy_strings() 检查是否超限。
(4) 执行标志
have_execfdunsigned int (1-bit)execve() 是否使用 execfd 进行执行。
execfd_credsunsigned int (1-bit)binfmt_misc 解释器是否使用 execfd 进行权限管理。
secureexecunsigned int (1-bit)setuid 进程是否发生了权限提升(影响 glibc AT_SECURE 变量)。
point_of_no_returnunsigned int (1-bit)execve() 进入不可逆状态,错误时必须终止进程。
(5) 可执行文件与解释器
executablestruct file *进程执行的可执行文件(ELF、脚本等)。
interpreterstruct file *若可执行文件是脚本,该字段存储解释器(如 /bin/bash)。
filestruct file *进程最初打开的文件(可能是 executableinterpreter)。
(6) 进程安全凭证
credstruct cred *新的进程凭证(用户 ID、权限信息)。
unsafeint进程的安全级别(如 LSM 相关的 LSM_UNSAFE_*)。
per_clearunsigned intexecve() 过程中需要清除的 personality 标志位。
(7) 文件名信息
filenameconst char *进程的 proc 目录中的名称。
interpconst char *实际执行的可执行文件(如果 binfmt_miscbinfmt_script 处理,可能不同于 filename)。
fdpathconst char *通过 execveat() 解析出的文件路径。
(8) 解释器标志
interp_flagsunsigned解释器的额外标志(binfmt_misc 可能设置)。
execfdint如果使用 execveat(fd, NULL, ...),这里存储 fd
(9) 进程资源限制
rlim_stackstruct rlimitexecve() 过程中的 RLIMIT_STACK 限制。
(10) 临时缓冲区
buf[BINPRM_BUF_SIZE]char 数组(通常 128 字节)用于存储可执行文件头信息(如 ELF 头)。

4.4 alloc_bprm() 申请bprm

alloc_bprm函数用于申请一个struct linux_binprm结构体 、对该结构体进行填充、并为新进程申请一个全新的地址空间mm_struct,主要包括:

  • 分配bprm结构体;

  • 解析filename文件路径;

  • 为新进程申请全新的地址空间mm_struct;

  • 为新进程的栈申请一页虚拟内存空间,并将栈指针记录下来;

【一张流程图】

/**
 * @brief 申请linux_bprm结构体,
 * @brief 为新进程分配地址空间mm_struct;
 * @brief 为新进程栈分配一页虚拟地址;
 * 
 * @param fd 文件描述符
 * @param filename 要执行的可执行文件
 */
static struct linux_binprm *alloc_bprm(int fd, struct filename *filename)
{
	/*1.为bprm申请内存·*/
	struct linux_binprm *bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
	
	/*2. 解析filename文件路径 并将其存入bprm结构体中*/
	if (fd == AT_FDCWD || filename->name[0] == '/') {
		bprm->filename = filename->name;
	} else {
		if (filename->name[0] == '\0')
			bprm->fdpath = kasprintf(GFP_KERNEL, "/dev/fd/%d", fd);
		else
			bprm->fdpath = kasprintf(GFP_KERNEL, "/dev/fd/%d/%s",
						  fd, filename->name);
		if (!bprm->fdpath)
			goto out_free;

		bprm->filename = bprm->fdpath;
	}
	bprm->interp = bprm->filename;

	/*3.为进程申请一个全新的地址空间mm_struct*/
	retval = bprm_mm_init(bprm);
	...
}

4.4.1 bprm_mm_init 申请地址空间

该函数用于为新进程分配 mm_struct(地址空间)并初始化栈,具体流程如下:

  • 首先该函数会通过mm_alloc()申请mm_struct地址空间, 并将申请好的地址空间暂存在bprm中;
  • 其次会读取当前进程的最大栈空间大小限制,并填充到bprm结构体中;
  • 最后会调用__bprm_mm_init()为进程栈分配一页虚拟地址;

[一张图]

/*
 * 创建一个新的 mm_struct 并用临时栈 vm_area_struct 对其进行填充;
 * 此时我们没有足够的上下文来设置栈标志、权限和偏移量,所以使用临时值;
 * 稍后在 setup_arg_pages() 中会更新它们;
 * 
 * 创建一个新的mm_struct,并申请一页栈;
 */
static int bprm_mm_init(struct linux_binprm *bprm)
{
	int err;
	struct mm_struct *mm = NULL;
	/*1. 申请一个全新的mm_struct 地址空间,
	 *   execve中,当前进程不会继承旧的 mm_struct,
	 *   而是创建一个新的
	 */
	bprm->mm = mm = mm_alloc();
	...
	
	/*2. 保存最大栈空间限制*/
	task_lock(current->group_leader);
	bprm->rlim_stack = current->signal->rlim[RLIMIT_STACK];
	task_unlock(current->group_leader);
	
	/*3. 给新进程的栈申请一页虚拟地址空间,并将栈指针记录下来*/
	err = __bprm_mm_init(bprm);
	...
}

接下来我们看一下alloc_bprm是如何通过mm_alloc申请mm_struct结构体的,又是如何通过__bprm_mm_init为进程栈申请一页虚拟地址空间的:

A:mm_alloc() 申请并初始化 mm_struct

/*
 * 申请并初始化一个 mm_struct.
 */
struct mm_struct *mm_alloc(void)
{
	struct mm_struct *mm;
	/*1. 从slab中为mm_struct申请地址空间*/
	mm = allocate_mm();
	if (!mm)
		return NULL;

	/*2. 全部填充为0*/
	memset(mm, 0, sizeof(*mm));

	/*3.初始化mm_struct*/
	return mm_init(mm, current, current_user_ns());
}

我们可以看到,mm_alloc函数首先通过调用slab机制的接口为mm_struct结构体申请地址空间的,该接口具体是如何申请物理页面的,可参考这篇文章:Linux内存管理:(四)物理页面分配之slab机制分配小内存 及 Linux6.5源码分析

#define allocate_mm()	(kmem_cache_alloc(mm_cachep, GFP_KERNEL))

紧接着通过mm_init函数对新申请的mm_struct进行初始化, 设置其中的相关字段,具体细节这里暂不展开;

/*初始化mm_struct结构体*/
static struct mm_struct *mm_init(struct mm_struct *mm, struct task_struct *p,
	struct user_namespace *user_ns)
{
	/*1. 初始化基本数据结构*/
	/*2. 初始化CPU相关信息*/
	/*3. 继承当前进程的flags*/
	/*4. 分配pgd页表*/
	/*5. CPU 架构相关初始化*/
	/*6. 为进程分配 Context ID,
	 *   用于进程 TLB 识别
	 */
    /*7. 初始化 rss_stat*/
    /*8. 设置 user_namespace*/
}

B: __bprm_mm_init() 为新进程的栈申请地址空间

mm_alloc()函数中, 从slab机制中申请了mm_struct结构体,并对其进行了初始化, 将申请得到的mm_struct结构体暂存在bprm->mm中; 紧接着就是通过 __bprm_mm_init()函数, 为新进程分配栈(stack)并初始化 VMA(虚拟内存区域),值得注意的是,这里仅仅为进程的栈分配了虚拟地址空间,并没有分配实际物理地址,需要等到访问这部分内存时触发缺页中断来实际分配这段虚拟地址对应的物理地址;该函数的主要步骤如下:

  • 为新进程的栈分配一个vma虚拟地址空间,并将其设置为匿名映射类型;
  • 初始化栈的vma,设置vma的起始与终止地址;
  • 将vma插入mm_struct中;
  • 设置栈指针bprm->p;

【一张图】

__bprm_mm_init() 通过 分配和初始化 VMA(虚拟内存区域)为进程的栈申请地址空间。首先,它调用 vm_area_alloc(mm) 为栈创建一个新的 VMA,并将其标记为 匿名映射(vma_set_anonymous(vma))。随后,它 设置栈的地址范围,将 栈顶设为架构支持的最大地址 (STACK_TOP_MAX),并为栈预留一页大小 (PAGE_SIZE)的空间。然后,vm_flags_init() 配置 VMA 标志,vm_get_page_prot() 设定访问权限,确保栈区域允许向下扩展。接着,insert_vm_struct(mm, vma) 将 VMA 插入进程的 mm_struct->mmap,并更新 mm->stack_vm 统计信息,表明栈占用了一页内存。最后,bprm->p 被设置为栈顶指针 (vm_end - sizeof(void *)),为后续 argvenvp 复制提供存储空间。

/*为新进程的栈分配申请内存并初始化*/
static int __bprm_mm_init(struct linux_binprm *bprm)
{
	int err;
	struct vm_area_struct *vma = NULL;
	struct mm_struct *mm = bprm->mm;
	/*1. 分配新的虚拟内存区域 (VMA) 用于堆栈*/
	bprm->vma = vma = vm_area_alloc(mm);
	if (!vma)
		return -ENOMEM;
	/*2. 设置 VMA 为匿名类型(没有关联到具体文件)*/
	vma_set_anonymous(vma);

	/*3. 加锁,防止并发修改 mm_struct*/
	if (mmap_write_lock_killable(mm)) {
		err = -EINTR;
		goto err_free;
	}

	/*
	 *4. 初始化堆栈的虚拟内存区域vma,暂时将堆栈放置在架构支持的最大地址空间
	 *   堆栈最终会被移到适当的位置,但此时尚未配置相关属性
	 */
	BUILD_BUG_ON(VM_STACK_FLAGS & VM_STACK_INCOMPLETE_SETUP);
	vma->vm_end = STACK_TOP_MAX;// 将堆栈顶端设为架构支持的最大堆栈地址
	vma->vm_start = vma->vm_end - PAGE_SIZE;// 堆栈从堆栈顶往下扩展,初始大小为一页
	vm_flags_init(vma, VM_SOFTDIRTY | VM_STACK_FLAGS | VM_STACK_INCOMPLETE_SETUP);// 初始化堆栈区域的标志
	vma->vm_page_prot = vm_get_page_prot(vma->vm_flags);// 设置页面的访问权限
	/*5.将vma插入mm中*/
	err = insert_vm_struct(mm, vma);
	if (err)
		goto err;

	mm->stack_vm = mm->total_vm = 1;
	mmap_write_unlock(mm);
	/*6.设置堆栈指针到堆栈顶部,并返回成功*/
	bprm->p = vma->vm_end - sizeof(void *);
	return 0;
err:
	mmap_write_unlock(mm);
err_free:
	bprm->vma = NULL;
	vm_area_free(vma);
	return err;
}

4.5 bprm_execve 执行加载

经过alloc_bprm申请并初始化linux_binprm结构体、为新进程申请mm_struct地址空间并为进程栈申请vma后,接下来的工作就是调用bprm_execve函数来完成可执行文件的文件读取与加载;该函数首先通过check_unsafe_exec(bprm)对bprm进行安全检查,防止execve执行不安全行为;紧接着通过do_open_execat函数打开可执行文件; 最后通过exec_binprm()函数, 加载并执行可执行文件; 我们把重点放在exec_binprm()是如何加载可执行文件的;

/*实际执行新程序
 *   读取可执行文件;
 *   遍历所有 linux_binfmt 处理器,
 *   找到能够加载该二进制文件的格式处理器,如 load_elf_binary()
 *   解析 ELF 或脚本格式,加载可执行文件,初始化进程地址空间;
 */
static int bprm_execve(struct linux_binprm *bprm,
		       int fd, struct filename *filename, int flags)
{
	...
	/*2. 进行安全检查*/
	check_unsafe_exec(bprm);
	
	/*3. 标记当前进程处于 execve() 状态,防止其他操作干扰*/
	current->in_execve = 1;

	/*4. 打开filename文件*/
	file = do_open_execat(fd, filename, flags);
	
	/*5. 执行程序*/
	retval = exec_binprm(bprm);
	...
}

exec_binprm() 负责 调用二进制格式处理器(binfmt)来解析和加载可执行文件。它会遍历已注册的 binfmt 处理器(如 ELF、Script),执行 search_binary_handler() 来寻找合适的加载器,并在成功时调用 begin_new_exec() 进行进程切换。由此,我们可以将加载可执行文件的函数调用关系梳理出来:

execve() -> do_execve_common() -> bprm_execve() -> exec_binprm() ->search_binary_handler()

我们将重点放在search_binary_handler()函数上;

4.5.1 search_binary_handler()

search_binary_handler() 函数会 在formats全局链表中遍历所有已注册的 binfmt 处理器,调用当前加载器对应的 load_binary() 尝试解析并加载可执行文件。如果当前 binfmt 解析失败并返回 -ENOEXEC,则继续尝试下一个 binfmt;

  • 首先调用prepare_binprm()函数读取可执行文件头,并判断文件格式;
  • 其次会进行相关的安全检查;
  • 在formats全局链表中遍历所有已经注册的加载器, 如果该加载器包含load_binary() 函数,则通过当前加载器对应的加载函数load_binary() 进行解析并加载;解析不成功则重试;

[一张图]

/**
 * 遍历所有已注册的 binfmt 处理器,调用 load_binary() 尝试解析并加载可执行文件。
 * 如果当前 binfmt 解析失败并返回 -ENOEXEC,则继续尝试下一个 binfmt。
 */
static int search_binary_handler(struct linux_binprm *bprm)
{
	...
	/*1. 读取可执行文件头,判断文件格式*/
	retval = prepare_binprm(bprm);

	/*2.  运行安全检查(SELinux / AppArmor)*/
	retval = security_bprm_check(bprm);

 retry:
	read_lock(&binfmt_lock);
	/*3. 遍历所有已注册的加载器,并尝试通过当前加载器执行load_binary回调函数*/
	list_for_each_entry(fmt, &formats, lh) {
		/*3.1  确保binfmt有效*/
		if (!try_module_get(fmt->module))
			continue;
		read_unlock(&binfmt_lock);
		/*3.2 尝试通过当前加载器的load_binary解析可执行文件*/
		retval = fmt->load_binary(bprm);

		read_lock(&binfmt_lock);
		put_binfmt(fmt);
		/*3.3 如果 `binfmt` 解析成功或不可回退,则直接返回*/
		if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
			read_unlock(&binfmt_lock);
			return retval;
		}
	}
	read_unlock(&binfmt_lock);
	/*4. 如果 `binfmt` 解析失败,并且 `CONFIG_MODULES` 允许动态加载 `binfmt`*/
	if (need_retry) {
		...
		goto retry;
	}

	return retval;
}

5. ELF文件加载过程

在上文中,我们知道被fork出的新进程会经过execve()等系统调用将要执行的程序加载进新进程的地址空间;execve()系统调用会调用do_execve_common()函数真正执行加载程序工作;do_execve_common()会首先申请一个临时结构体linux_binprm,从slab机制中为新进程申请一个mm_struct结构体用于描述进程的地址空间,并为新进程的栈申请一页的虚拟地址,将这些信息暂存在申请的linux_binprm中;在为进程申请了地址空间后,do_execve_common()便可以将要加载的程序通过可执行文件对应的加载器加载进进程地址空间中,这里是调用bprm_execve()函数执行的加载工作,循环遍历formats全局链表,找到符合当前可执行文件格式的加载器,通过该加载器定义的钩子函数load_binary()去执行加载工作;

本小节会接着前面的工作,以ELF文件为例,分析elf_format是如何通过load_binary()函数执行加载操作的;

/*定义elf加载器*/
static struct linux_binfmt elf_format = {
	.module		= THIS_MODULE,
	.load_binary	= load_elf_binary,
	.load_shlib	= load_elf_library,
#ifdef CONFIG_COREDUMP
	.core_dump	= elf_core_dump,
	.min_coredump	= ELF_EXEC_PAGESIZE,
#endif
};

我们可以看到elf_format加载器对应的load_binary()函数为load_elf_binary(),该函数很长,可以说所有的程序加载逻辑都在这个函数中体现了。该函数的主要工作包括:

  • ELF文件头读取;
  • Program Header读取,并读取所有的segment。
  • 清空父进程继承来的虚拟地址空间等资源;
  • 执行segment加载;
  • 数据段内存申请,堆初始化;
  • 跳转到程序的入口点执行;

我们对该函数进行深度分析:

/*elf_format加载器执行程序加载工作*/
static int load_elf_binary(struct linux_binprm *bprm)
{
	/*1. ELF文件头读取*/
	/*2. Program Header读取,并读取所有的segment*/
	/*3. 清空父进程继承来的虚拟地址空间等资源*/
	/*4. 执行segment加载*/
	/*5. 数据段内存申请,堆初始化*/
	/*6. 跳转到程序的入口点执行*/
}

5.1 读取ELF文件头

由于在execve -> do_execve_common() 时,已经将ELF可执行文件头读取到了bprm->buf中,所以这里我们直接复制就行;首先将ELF文件头保存起来,文件头中包含当前文件格式类型等数据,在读取完文件头后,会进行合法性判断,如果不合法,则退出返回;

/*elf_format加载器执行程序加载工作*/
static int load_elf_binary(struct linux_binprm *bprm)
{
	...
	/*1. ELF文件头解析*/
	
	/*1.1 从bprm中获取elf文件头*/
	struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;
	struct elfhdr *interp_elf_ex = NULL;
	struct arch_elf_state arch_state = INIT_ARCH_ELF_STATE;
	
	...
	
	/*1.2 对头部进行一系列的合法性判断,不合法则直接退出*/
	if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
		goto out;   
        
	...
	
	/*1.3 申请 interp_elf_ex 对象*/
	interp_elf_ex = kmalloc(sizeof(*interp_elf_ex), GFP_KERNEL);
	if (!interp_elf_ex) {
		retval = -ENOMEM;
		goto out_free_file;
	}
	...
}

5.2 读取Program Header

在ELF文件头中记录着Program Header的数量,而且在ELF头之后紧接着就是Program Header Table。所以内核接下来可以将所有Program Header都读取出来

load_elf_binary函数是通过调用load_elf_phdrs函数来读取Program Header的;

/*elf_format加载器执行程序加载工作*/
static int load_elf_binary(struct linux_binprm *bprm)
{
	...
	/*1. ELF文件头解析*/
	
	/*2. 读取Program Header*/
	elf_phdata = load_elf_phdrs(elf_ex, bprm->file)
	...
}

load_elf_binary -> load_elf_phdrs:

/*读取Program Header*/
static struct elf_phdr *load_elf_phdrs(const struct elfhdr *elf_ex,
				       struct file *elf_file)
{
	/*1. elf_ex.e_phnum中保存的是program Header的数量
	 *   再根据 Program Header的大小sizeof(struct elf_phdr)
	 *   一起计算出所有的Program Header的大小
	 */
	size = sizeof(struct elf_phdr) * elf_ex->e_phnum;
       
	/*2. 申请内存并读取*/
	elf_phdata = kmalloc(size, GFP_KERNEL);
    retval = elf_read(elf_file, elf_phdata, size, elf_ex->e_phoff);
    ...
}

首先计算需要多大的内存,Program Header的数量是在ELF文件头中提供的,每个Program Header所需要的内存对象struct elf_phdr的大小也是知道的,乘一下即可。接着调用kmalloc来分配好内存,然后将可执行文件在磁盘上保存的内容读取到内存中。

5.3 清空父进程继承来的资源

在fork系统调用创建的进程中,包含了不少原进程的信息,如老的地址空间、信号表等。这些在新的程序运行时并没有什么用,所以需要清空一下。具体工作包括初始化新进程的信号表,应用新的虚拟地址空间对象等。

/*elf_format加载器执行程序加载工作*/
static int load_elf_binary(struct linux_binprm *bprm)
{
	/*1. ELF文件头解析*/
	/*2.读取Program Header*/
	
	/*3.清空父进程继承来的资源*/
	
	/*3.1. 清空父进程继承来的资源*/
	retval = begin_new_exec(bprm);
	...
	/*3.2 使用新栈*/
	retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
				 executable_stack);
    ...
}

begin_new_exec中会对从父进程继承过来的地址空间、信号表等资源进行释放。最后再使用前面在linux_binprm临时变量中保存的新的进程地址空间。这之后,直接将前面准备的进程栈的地址空间指针设置到了mm对象上。这样将来就可以使用栈了。

int begin_new_exec(struct linux_binprm * bprm)
{
	...
	/*1. 确保文件表不共享*/
	retval = unshare_files();
	...
	/*2. 释放所有旧的mmap*/
	acct_arg_size(bprm, 0);
	retval = exec_mmap(bprm->mm);
	if (retval)
		goto out;
	...
	/*3. 确保信号表不共享*/
	retval = unshare_sighand(me);
	if (retval)
		goto out_unlock;
    ...
}

从源码层面看,该函数会通过调用exec_mmap()函数释放地址空间、并使用bprm中保存的新的地址空间:

static int exec_mmap(struct mm_struct *mm)
{
	struct task_struct *tsk;
	struct mm_struct *old_mm, *active_mm;
	int ret;

	/* Notify parent that we're no longer interested in the old VM */
	/*1. 释放旧的地址空间
	 *   调用exec_mm_release()释放从父进程处创建的地址空间;
	 */
	tsk = current;
	old_mm = current->mm;
	exec_mm_release(tsk, old_mm);
	if (old_mm)
		sync_mm_rss(old_mm);
	...
	/*2. 使用bprm中创建的新地址空间
	 *   新的地址空间用于保存要执行的程序;
	 */
	local_irq_disable();
	active_mm = tsk->active_mm;
	tsk->active_mm = mm;
	tsk->mm = mm;
	mm_init_cid(mm);
    ...
}

在清空父进程继承来的虚拟地址空间后,将前面在临时变量bpmm中保存的新的地址空间拿过来用上。这样新进程的虚拟内存就准备好了。

接下来再调用setup_arg_pages,为新进程也设置上新的栈备用。

/*
 *为新进程设置新的栈备用 
 *完成堆栈的 vm_area_struct 设置。更新标志和权限,堆栈可能会被重新定位,并添加一些额外空间。
 */
int setup_arg_pages(struct linux_binprm *bprm,
		    unsigned long stack_top,
		    int executable_stack)
{
	...
	/*1. 收集并整理 TLB(转换后备缓存) */
	tlb_gather_mmu(&tlb, mm);

	/* 调整虚拟内存区域的保护标志 */
	ret = mprotect_fixup(&vmi, &tlb, vma, &prev, vma->vm_start, vma->vm_end,
			vm_flags);
	tlb_finish_mmu(&tlb);  // 完成 TLB 操作
	
	...
	/*2. 移动堆栈页面到内存中 */
	if (stack_shift) {
		ret = shift_arg_pages(vma, stack_shift);
		if (ret)
			goto out_unlock;  // 如果移动堆栈出错,跳转到解锁部分
	}
	...
	
	/*3. 为堆栈扩展分配空间 */
	stack_expand = 131072UL; /* 随机分配32个4K页面(或2个64K页面) */
	stack_size = vma->vm_end - vma->vm_start;  // 当前堆栈大小
	...

	/*4. 设置当前堆栈的位置,即新进程获取到bprm申请的栈信息*/
	current->mm->start_stack = bprm->p;  // 设置当前进程的起始堆栈位置
	...
}

5.4 执行Segment加载

接下来加载器会将ELF文件中的LOAD类型的Segment都加载到内存,使用elf_ map在虚拟地址空间中为其分配虚拟内存。最后恰当地设置虚拟地址空间mm_struct中的start_code、end_code、start_data、end_data等各个地址空间相关指针。

只有LOAD类型的Segment是需要被映射到内存的。我们来看看加载Segment的具体代码。

/*elf_format加载器执行程序加载工作*/
static int load_elf_binary(struct linux_binprm *bprm)
{
	/*1. ELF文件头解析*/
	/*2.读取Program Header*/
	
	/*3.清空父进程继承来的资源*/
	
	/*4.执行segment加载
	 *  遍历可执行文件的Program Header
	 */
	for(i = 0, elf_ppnt = elf_phdata;
	    i < elf_ex->e_phnum; i++, elf_ppnt++) {
	    
		/*4.1 只加载类型为LOAD的Segment*/
		if (elf_ppnt->p_type != PT_LOAD)
			continue;
	    ...
	    
		/*4.2 为Segment 建立内存mmap,将程序文件中的内容映射到虚拟内存空间中
		 *    这样将来程序中的代码、数据就都可以被访问了;
		 */
		error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
				elf_prot, elf_flags, total_size);	 
	}
    /*4.3 计算mm_struct所需的各个成员地址*/
	e_entry = elf_ex->e_entry + load_bias;
	phdr_addr += load_bias;
	elf_bss += load_bias;
	elf_brk += load_bias;
	start_code += load_bias;
	end_code += load_bias;
	start_data += load_bias;
	end_data += load_bias;
    ...
	/*4.4 将LOAD类型的Segment映射到mm_struct结构体中*/
	mm = current->mm;
	mm->end_code = end_code;
	mm->start_code = start_code;
	mm->start_data = start_data;
	mm->end_data = end_data;
	mm->start_stack = bprm->p;
	...
}

其中load_bias是Segment要加载到内存的基地址。这个参数有这么几种可能:

  • 值为0,直接按照ELF文件中的地址在内存中进行映射。
  • 值为对齐到整数页的开始,物理文件中可能为了可执行文件的大小足够紧凑,而不考虑对齐的问题。但是操作系统在加载的时候为了运行效率,需要将Segmen加载到整数页的开始位置。

计算好内存地址后,调用elf_map将磁盘文件中的内容和虚拟地址空间建立映射,等到访问的时候发生缺页中断加载磁盘文件中的代码或数据。最后设置虚拟地址空间中的代码段。数据段相关的指针是:start code、end code、start_data、end_data。

一句话总结一下加载Segment的过程:在第一第二阶段,我们对ELF文件头及Program头进行了解析与读取,这为第四阶段加载Segment埋下伏笔;第三阶段,我们将新进程中旧的地址空间(fork时从父进程处继承的地址空间)清理掉,用启用在bprm中新申请的地址空间mm_struct;在本阶段,我们就可以将Segment加载到新进程 新的地址空间中,首先遍历全部Segment,将LOAD类型需要加载的部分,逐一为其建立mmap虚拟内存,也即将磁盘文件中的Segment内容和虚拟地址空间建立映射, 并将这部分内存绑定到进程的地址空间中;但需要注意的是,并没有对这部分内容申请物理内存,当需要访问时,会先访问虚拟内存,通过缺页中断,再为其分配物理内存;

5.5 数据内存申请与堆的初始化

我们在alloc_bprm()申请bprm结构体时,为新进程申请了一个地址空间mm_struct、申请了一页的栈空间,并将其暂存在bprm中;并在第三阶段中,我们清空了进程从父进程继承来的资源,并将新申请的mm_struct从bprm中归还到新进程;整个过程包含了为新进程申请的一页栈空间,但是对于进程的堆空间呢?本小节将展开堆的申请和初始化;

现在虚拟地址空间中的代码段、数据段、栈都已就绪,还有一个堆内存需要初始化。接下来就使用set_brk系统调用专门为数据段申请虚拟内存。

/*elf_format加载器执行程序加载工作*/
static int load_elf_binary(struct linux_binprm *bprm)
{
	/*1. ELF文件头解析*/
	/*2.读取Program Header*/
	
	/*3.清空父进程继承来的资源*/
	
	/*4.执行segment加载
	 *  遍历可执行文件的Program Header
	 */
	for(i = 0, elf_ppnt = elf_phdata;
	    i < elf_ex->e_phnum; i++, elf_ppnt++) {
	        ...
	    	/*5.数据内存申请&堆初始化*/
			retval = set_brk(elf_bss + load_bias,
					 elf_brk + load_bias,
					 bss_prot);
			if (retval)
				goto out_free_dentry;
			...
	}
	...
}

在set_brk函数中做了两件事:

  • 第一件事是为数据段申请虚拟内存
  • 第二件事是将进程堆的开始指针和结束指针初始化
/*数据内存的申请 & 堆初始化
 1.为数据段申请内存;
 2.初始化进程堆的开始与结束指针;
 */
static int set_brk(unsigned long start, unsigned long end, int prot)
{
	/*1. 为数据段申请内存;*/
	start = ELF_PAGEALIGN(start);
	end = ELF_PAGEALIGN(end);
	if (end > start) {
		/*
		 * Map the last of the bss segment.
		 * If the header is requesting these pages to be
		 * executable, honour that (ppc32 needs this).
		 */
		int error = vm_brk_flags(start, end - start,
				prot & PROT_EXEC ? VM_EXEC : 0);
		if (error)
			return error;
	}
	/*2. 初始化进程堆的开始与结束指针*/
	current->mm->start_brk = current->mm->brk = end;
	return 0;
}

因为程序初始化的时候,堆上还是空的,所以堆指针初始化的时候,堆的开始地址start_brk和结束地址brk都设置为同一个值。代码中常用的maloc就是用来修改brk相关的指针来实现内存申请的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值