一、开篇
在《【linux kernel】linux内核如何启动用户空间进程-01》一文中,分析了start_kernel函数启动用户空间进程的入口点,本文将分析余下部分的启动过程。
对于在linux内核启动主线start_kernel()
函数中调用的run_init_process()
函数,或者是execveat
系统调用,这两个操作到最后都将会调用到do_execveat_common()
这个重磅函数。该函数用于linux内核执行一个新的程序。do_execveat_common()
函数原型如下(定义在/fs/exec.c文件中):
static int do_execveat_common(int fd, struct filename *filename,struct user_arg_ptr argv, struct user_arg_ptr envp,int flags)
该函数有5个参数。第一个参数fd
表示应用程序目录的文件描述符,AT_FDCWD表示给定的路径名是相对于调用进程的当前工作目录的。第五个参数是flags标志。
下文,就来详细分析一下该函数。
在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()
函数,解引出当前任务的struct file_struct
,并检查该函数结果。内核此处这样做,是为了调用unshare_files函数来消除被执行二进制文件的文件描述符的潜在泄漏。如下代码所示:
retval = unshare_files(&displaced);
if (retval)
goto out_ret;
接着,将设置由struct linux_binprm
结构表示的bprm变量的数据。linux_binprm结构用于保存linux内核加载用户空间二进制文件时使用的参数(定义在include/linux/binfmt .h头文件中),定义如下:
struct linux_binprm {
//linux_binprm 缓存区
char buf[BINPRM_BUF_SIZE];
#ifdef CONFIG_MMU
struct vm_area_struct *vma;
unsigned long vma_pages;
#else
# define MAX_ARG_PAGES 32
struct page *page[MAX_ARG_PAGES];
#endif
//内存管理结构
struct mm_struct *mm;
//当前内存顶部
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
//只有search_binary_handler ()
unsigned int recursion_depth;
//file描述结构
struct file * file;
//表示新的cred结构
struct cred *cred; /* new credentials */
//表示这个exec有多不安全(掩码为LSM_UNSAFE_*)
int unsafe;
unsigned int per_clear; /* bits to clear in current->personality */
int argc, envc;
//procps看到的二进制文件的名称
const char * filename;
//实际执行的二进制文件的名称。
const char * interp;
unsigned interp_flags;
unsigned interp_data;
unsigned long loader, exec;
};
在do_execveat_common()
函数中,会使用用kzalloc()
函数为bprm
结构分配内存,并检查分配结果,如下代码所示:
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_files;
在此之后,通过调用prepare_bprm_creds()
函数来设置binprm凭据,如下代码:
retval = prepare_bprm_creds(bprm);
if (retval)
goto out_free;
check_unsafe_exec(bprm);
current->in_execve = 1;
可见,binprm凭据初始化的本质就是存储在linux_binprm结构中的cred结构的初始化。cred
结构包含任务的安全上下文,例如:任务的真实uid、任务的真实guid、虚拟文件系统操作的uid和guid等。紧接着,将调用check_unsafe_exec()
函数检查现在是否可以安全地执行程序,并将当前进程设置为in_execve
状态。如下代码片段:
check_unsafe_exec(bprm);
current->in_execve = 1;
在以上操作之后,会调用do_open_execat()
函数,该函数将进行以下几个步骤的操作:
(1)检查传递给do_execveat_common函数的标志
(2)搜索并打开磁盘上的可执行文件
(3)检查将从noexec
挂载点加载的二进制文件(需要避免从不包含可执行二进制文件(如proc或sysfs)的文件系统中执行二进制文件)。
(4)初始化文件结构并返回指向该结构的指针。
如下代码片段:
file = do_open_execat(fd, filename, flags);
retval = PTR_ERR(file);
if (IS_ERR(file))
goto out_unmark;
接着将调用sched_exec()
函数,该函数用于确定能够执行新程序且负载最小的处理器,并将当前进程迁移到该处理器上。在此之后,需要检查给出可执行二进制文件的文件描述符。尝试检查二进制文件的名称是从/符号开始的,或者给定可执行二进制文件的路径是相对于调用进程的当前工作目录解析的(文件描述符fd
参数是AT_FDCWD)。如果这些检查中有一个操作成功,将设置二进制参数filename,如下代码片段:
bprm->file = file;
if (fd == AT_FDCWD || filename->name[0] == '/') {
bprm->filename = filename->name;
}
否则,如果文件名为空,则将二进制参数filename设置为/dev/fd/%d或/dev/fd/%d/%s,这一点取决于给定的可执行二进制文件的文件名,如下代码片段:
} 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参数。
下一步将初始化linux_binprm结构变量bprm
的其他字段。首先调用bprm_mm_init函数并将bprm传递给它,如以下代码:
retval = bprm_mm_init(bprm);
if (retval)
goto out_unmark;
bprm_mm_init函数用于初始化bprm结构中的内存描述符,换言之,bprm_mm_init函数初始化mm_struct结构体,并用一个临时堆栈vm_area_struct填充它。mm_struct定义在(/include/linux/mm_types.h)文件中,用于表示进程的地址空间。
接着,将计算传递给可执行二进制文件的命令行参数的计数,以及环境变量的计数,并分别将其设置为到bprm->argc和bprm->envc中,如下代码所示:
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是一个宏,定义在include/uapi/linux/binfmt .h头文件中,该宏表示传递给execve系统调用的最大字符串数量。MAX_ARG_STRINGS的值定义如下:
#define MAX_ARG_STRINGS 0x7FFFFFFF
计算了命令行参数和环境变量的数量之后,会调用prepare_binprm()
函数,如下代码:
retval = prepare_binprm(bprm);
if (retval < 0)
goto out;
使用来自inode的uid填充linux_binprm结构,并从二进制可执行文件读取128字节。为什么只从可执行文件中读取前128个字节呢?因为需要靠这128个字节来检查可执行文件的类型,然后将在后续步骤中读取可执行文件的剩余部分。在准备好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字段中。
到目前为止,内核已经填充了linux_bprm结构,接下来将调用exec_binprm函数,如下代码:
retval = exec_binprm(bprm);
if (retval < 0)
goto out;
exec_binprm()
函数是linux内核启动用户空间进程的核心关键函数,接下来,我们就来看一下exec_binprm()
函数的内部实现(同样定义在/fs/exec.c文件中):
首先,将当前任务命名空间中的pid和pid存储在exec_binprm中,如下代码所示:
old_pid = current->pid;
rcu_read_lock();
old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
rcu_read_unlock();
接着,调用search_binary_handler(bprm)
函数,这个函数将遍历包含不同二进制格式的处理程序列表。目前linux内核支持以下几种二进制格式:
-
binfmt_script
:支持从#!开始的解释脚本。 -
binfmt_misc
:根据linux内核的运行时配置,支持不同的二进制格式。 -
binfmt_elf
:支持elf格式。 -
binfmt_aout
:支持a.out格式。 -
binfmt_flat
:支持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;
从以上代码可知,fmt->load本质是一个函数指针,在linux内核中,对于不同的二进制格式的程序,内核都定义并实现了对应的加载函数,本文以elf格式为例。其linux_binfmt定义如下(/fs/binfmt_elf.c):
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,
};
从以上代码可知,对于elf格式的文件的load_binary加载函数是load_elf_binary()
,下文讲分析该函数。
在load_elf_binary()函数中会在linux_bprm缓冲区中检查magic号(每个elf二进制文件的头中都包含magic 号)(注:从前文可知,内核已经从可执行二进制文件中读取了前128字节);如果不是elf二进制格式,则退出。如下代码片段:
static int load_elf_binary(struct linux_binprm *bprm)
{
/* 省略大部分内容 */
loc->elf_ex = *((struct elfhdr *)bprm->buf);
if (memcmp(elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
goto out;
如果给定的可执行文件是elf格式,load_elf_binary()
将为执行可执行程序文件做以下准备工作:
(1)检查可执行文件的架构和类型,如下代码:
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;
如果有错误的架构和可执行文件非可执行、非共享,则退出。
(2)如果第(1)步操作成功,将继续执行后续操作,接下来将尝试加载程序头表。如下代码片段:
elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
if (!elf_phdata)
goto out;
(3)从磁盘读取与可执行二进制文件相链接的程序解释器和库,并将其加载到内存中。
(4)在load_elf_binary()
函数的最后,会调用start_thread()
,这是一个架构相关函数或宏定义(本文以ARM架构为例)。其定义如下(/arch/arm/include/asm/processor.h):
#define start_thread(regs,pc,sp) \
({ \
memset(regs->uregs, 0, sizeof(regs->uregs)); \
if (current->personality & ADDR_LIMIT_32BIT) \
regs->ARM_cpsr = USR_MODE; \
else \
regs->ARM_cpsr = USR26_MODE; \
if (elf_hwcap & HWCAP_THUMB && pc & 1) \
regs->ARM_cpsr |= PSR_T_BIT; \
regs->ARM_cpsr |= PSR_ENDSTATE; \
regs->ARM_pc = pc & ~1; /* pc */ \
regs->ARM_sp = sp; /* sp */ \
nommu_start_thread(regs); \
})
调用该函数时传递了三个参数给该宏定义,如下代码片段:
start_thread(regs, elf_entry, bprm->p);
retval = 0;
out:
kfree(loc);
out_ret:
return retval;
(1)regs:表示新任务的寄存器集。
(2)elf_entry:表示新任务的入口点的地址。
(3)bprm->p:表示新任务的堆栈顶部的地址。
从以上过程可见,新任务的入口地址elf_entry
通过start_thread(),传递给了reg->ARM_pc
,从而指定了内核待启动的用户空间进程(此处内核不会立即执行用户空间进程)。
当exec_binprm执行完后,将回到do_execveat_common()
函数中,继续进行后续的操作:释放之前分配的内存空间并返回。如下代码片段:
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;
至此,当从execveat
系统调用处理程序返回后,内核将开始执行用户空间的程序。execve系统调用并不会将控制权返回给进程,但是调用进程的代码、数据和其他段覆盖了程序段。应用程序的退出将通过exit系统调用实现。
搜索/关注【嵌入式小生】vx公众号,获取更多精彩内容>>>>