刘畅 原创作品转载请注明出处 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000
写在前面
本次实验着力分析Linux内核装载和启动一个可执行程序的过程,其中包括可执行文件格式的分析、可执行文件的装载和链接的过程,并通过GDB跟踪execve系统调用来梳理Linux系统加载可执行程序的过程。
可执行文件的格式分析
相对于其它文件类型,可执行文件可能是一个操作系统中最重要的文件类型,因为它们是完成操作的真正执行者。可执行文件的大小、运行速度、资源占用情况以及可扩展性、可移植性等与文件格式的定义和文件加载过程紧密相关。UNIX/LINUX 平台下三种主要的可执行文件格式:a.out(assembler and link editor output 汇编器和链接编辑器的输出)、COFF(Common Object File Format 通用对象文件格式)、ELF(Executable and Linking Format 可执行和链接格式。现在Linux中绝大部分的可执行文件格式都为ELF,这里只对ELF进行展开描述。
ELF文件格式结构体描述:
/* ELF文件头部 */
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* 魔数和相关信息 */
Elf32_Half e_type; /* 目标文件类型 */
Elf32_Half e_machine; /* 硬件体系 */
Elf32_Word e_version; /* 目标文件版本 */
Elf32_Addr e_entry; /* 程序进入点 */
Elf32_Off e_phoff; /* 程序头部偏移量 */
Elf32_Off e_shoff; /* 节头部偏移量 */
Elf32_Word e_flags; /* 处理器特定标志 */
Elf32_Half e_ehsize; /* ELF头部长度 */
Elf32_Half e_phentsize; /* 程序头部中一个条目的长度 */
Elf32_Half e_phnum; /* 程序头部条目个数 */
Elf32_Half e_shentsize; /* 节头部中一个条目的长度 */
Elf32_Half e_shnum; /* 节头部条目个数 */
Elf32_Half e_shstrndx; /* 节头部字符表索引 */
} Elf32_Ehdr;
在Linux中可以使用readelf -h 来查看一个可执行文件的头部,如图所示:
下一个是ELF头部的程序头表,它是一个结构数组,包含了ELF头表中字段e_phnum定义的条目,结构描述一个段或其他系统准备执行该程序所需要的信息。
typedef struct {
Elf32_Word p_type; /* 段类型 */
Elf32_Off p_offset; /* 段位置相对于文件开始处的偏移量 */
Elf32_Addr p_vaddr; /* 段在内存中的地址 */
Elf32_Addr p_paddr; /* 段的物理地址 */
Elf32_Word p_filesz; /* 段在文件中的长度 */
Elf32_Word p_memsz; /* 段在内存中的长度 */
Elf32_Word p_flags; /* 段的标记 */
Elf32_Word p_align; /* 段在内存中对齐标记 */
} Elf32_Phdr;
用readelf查看的结果如下:
可执行文件的链接
可执行文件的链接一般分为静态链接和动态链接,
静态链接
静态库的核心思想是:将不同的可重定位模块打包成一个文件,在链接的时候会自动从这个文件中抽取出用到的模块。这样就无需手动列出需要用的模块,方便了链接的过程。优点是程序执行快,缺点是程序体大、不易维护。
动态链接
动态链接的核心思想是:代码共享和延迟绑定。 代码共享依靠虚拟存储器实现,延迟绑定的核心在于两张表:PLT(Procedure Linkage Table)和GOT(Global Offset Table)。基于虚拟存储器的代码共享使得在内存中只存在某个模块的唯一一份代码,通过虚拟存储器的内存映射机制将物理地址空间中的代码映射到不同进程的虚拟地址空间中。
动态链接节约了内存,并且易于维护。但不够安全,容易遭到劫持。
可执行文件的装载
在Linux中是通过execve系统调用来实现一个可执行文件的装载的,在内核中execve的定义如下:
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);
}
#ifdef CONFIG_COMPAT
COMPAT_SYSCALL_DEFINE3(execve, const char __user *, filename,
const compat_uptr_t __user *, argv,
const compat_uptr_t __user *, envp)
{
return compat_do_execve(getname(filename), argv, envp);
}
#endif
可以看出来execve系统调用要通过调用do_execve或者compat_do_execve函数的,其中do_execve中的实现如下:
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_execve_common(filename, argv, envp);
}
首先将argv和envp赋值给用户空间的argv和envp结构体,然后调用do_execve_common函数。do_execve_common的主要实现如下:
static int do_execve_common(struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp)
{
struct linux_binprm *bprm;
...
retval = prepare_bprm_creds(bprm);
check_unsafe_exec(bprm);
file = do_open_exec(filename);
sched_exec();
retval = bprm_mm_init(bprm);
retval = prepare_binprm(bprm);
retval = exec_binprm(bprm);
...
}
初始化Linux可执行程序的结构体,为了能够执行它做了一系列的准备工作,最后调用exec_binprm函数执行这个可执行程序。
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函数,内核要先注册各个不同类型可执行程序的处理模块,在解析一个新程序时查找该用哪个模块处理,最终完成可执行程序的加载。找到该可执行文件对应的处理函数,然后调用load_binary函数,实际上是load_elf_binary函数,即在search_binary_handler时,把load_binary函数指针指向了load_elf_binary,最后把整个可执行文件加载到内存中。
static int load_elf_binary(struct linux_binprm *bprm)
{
...
if (elf_interpreter) {
unsigned long interp_map_addr = 0;
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias);
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_binary会判断该可执行是否包含重定位段,即该可执行是否采用了动态链接,如果有的话会加载链接器,并把elf_entry的地址赋值为链接器的地址,否则地址就为ELF文件中的e_entry字段的地址。
总结
Linux中一个可执行文件的装载执行需要execve系统调用的支持,这个系统调用主要做的工作包括可执行文件的解析、查找相对应的处理程序比如ELF文件调用load_elf_binary、最后判断该文件是是否包含重定位段,进而为程序设置不同的入口地址,这就完成了整个可执行文件的加载运行,其中文件加载到内存中使用mmap函数实现的,它可以把文件映射到指定位置的内存中。