2.2 shell 启动用户进程
在上⼀节中我们已经了解到了 ELF ⽂件的格式,它是由四个部分组成的,分别是 ELF ⽂件头(ELF header)、Program header table、Section 和 Section header table。每⼀个由开发同学开发出来的程序,不管它有多复杂,最后都会编译成⼀个 ELF 格式的可执⾏⽂件。
在我们编写的代码编译完⽣成可执⾏程序之后,下⼀步就是使⽤ shell 把它加载起来并运⾏之。⼀般来说 shell 进程是通过 fork + execve 来加载并运⾏新进程的。⼀个简单的 shell 加载 ls 命令的核⼼逻辑是如下这个过程。
shell 进程先通过 fork 系统调⽤创建⼀个进程出来。然后在⼦进程中调⽤ execve 将执⾏的程序⽂件加载起来,然后就可以调到程序⽂件的运⾏⼊⼝处运⾏这个程序了。
// shell 代码示例
int main(int argc, char * argv[])
{
...
pid = fork();
if (pid==0){ // 如果是在⼦进程中
//使⽤ exec 系列函数加载并运⾏可执⾏⽂件
execve("helloworld", argv, envp);
} else {
...
}
...
}在上⼀节,我们详细介绍过了 fork 的⼯作过程。这⾥我们再简单过⼀下。这个 fork 系统调⽤在内核⼊⼝是在 kernel/fork.c 下。
在 kernel_clone 的实现中,核⼼是⼀个 copy_process 函数,它以拷⻉⽗进程(线程)的⽅式来⽣成⼀个新的 task_struct 出来。
在 copy_process 函数中为新进程申请 task_struct,并⽤当前进程⾃⼰的地址空间、命名空间等对新进程进⾏初始化,并为其申请进程 pid。
// file:kernel/fork.c
SYSCALL_DEFINE0(fork)
{
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
// file:kernel/fork.c
pid_t kernel_clone(struct kernel_clone_args *args)
{
// 复制⼀个 task_struct 出来
struct task_struct *p;
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
// ⼦任务加⼊到就绪队列中去,等待调度器调度
wake_up_new_task(p);
...
}
//file:kernel/fork.c
static struct task_struct *copy_process(...)
{
//复制进程 task_struct 结构体
struct task_struct *p;
p = dup_task_struct(current);
...
//进程核⼼元素初始化
retval = copy_files(clone_flags, p);
retval = copy_fs(clone_flags, p);
retval = copy_mm(clone_flags, p);
retval = copy_namespaces(clone_flags, p);
...
//申请 pid && 设置进程号
pid = alloc_pid(p->nsproxy->pid_ns);
p->pid = pid_nr(pid);执⾏完后,就创建出来了⼀个新的进程。该进程内所有的资源都是从 shell ⽗进程上通过拷⻉的⽅式获得的。然后这个新进程会进⼊ wake_up_new_task 等待调度器调度。
NOTE
不过
fork系统调⽤只能是根据 shell 进程复制⼀个新的进程出来。这个新进程⾥的代码、数据都还是和原来的 shell 进程的内容⼀模⼀样。要想实现加载并运⾏另外⼀个程序,⽐如我们编译出来的helloworld程序,那还需要使⽤到execve系统调⽤。
2.3 Linux 的可执⾏⽂件加载器
其实 Linux 不是写死只能加载 ELF ⼀种可执⾏⽂件格式的。它在启动的时候,会把⾃⼰⽀持的所有可执⾏⽂件的解析器都加载上。并使⽤⼀个 formats 双向链表来保存所有的解析器。 其中 formats 双向链表在内存中的结构如下⾯的图所示。
p->tgid = p->pid;
......
}Linux 中⽀持的可执⾏⽂件格式有如下⼏种:
- ELF:Executable and Linkable Format,是 Linux 上最常⽤的可执⾏⽂件格式
- aout:主要为了和以前兼容,由于不⽀持动态链接,所以被 ELF 取代
- EM86:主要作⽤是在 Alpha 的主机上运⾏ Intel 的 Linux ⼆进制⽂件
其中 ELF 是 Linux 上的主流可执⾏⽂件格式。在本书中,我们以 ELF 的加载器 elf_format 为例,来看看这个加载器是如何注册的。在 Linux 中每⼀个加载器都⽤⼀个 linux_binfmt 结构来表示。其中规定了加载⼆进制可执⾏⽂件的 load_binary 函数指针,以及加载崩溃⽂件的 core_dump 函数等。其完整定义如下:
// file:include/linux/binfmts.h
struct linux_binfmt {
...
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
};其中 ELF 的加载器 elf_format 中规定了具体的加载函数,例如 load_binary 成员指向的就是具体的 load_elf_binary 函数。这就是 ELF 加载的⼊⼝。
// file: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_format 会在初始化的时候通过 register_binfmt 进⾏注册。
// file:fs/binfmt_elf.c
static int __init init_elf_binfmt(void)
{
register_binfmt(&elf_format);
return 0;
}⽽ register_binfmt 就是将加载器挂到全局加载器列表 - formats 全局链表中。
// file:fs/exec.c
static LIST_HEAD(formats);
void __register_binfmt(struct linux_binfmt * fmt, int insert)
{
...
insert ? list_add(&fmt->lh, &formats) :
list_add_tail(&fmt->lh, &formats);
}在源码⽬录中搜索 register_binfmt,可以搜索到所有 Linux 操作系统⽀持的格式的加载程序。
# grep -r "register_binfmt" *
fs/binfmt_flat.c: register_binfmt(&flat_format);
fs/binfmt_elf_fdpic.c: register_binfmt(&elf_fdpic_format);
fs/binfmt_elf.c: register_binfmt(&elf_format);
fs/binfmt_em86.c: register_binfmt(&em86_format);
将来在 Linux 在加载⼆进制⽂件时会遍历 formats 链表,根据要加载的⽂件格式来查询合适的加载器。
2.4 execve 加载用户程序
shell 程序使⽤ fork 创建出来新进程后,下⼀步加载可执⾏⽂件的⼯作是由 execve 系统调⽤来完成的。该系统调⽤会读取⽤户输⼊的可执⾏⽂件名,参数列表以及环境变量等开始加载并运⾏⽤户指定的可执⾏⽂件。该系统调⽤的位置在 fs/exec.c ⽂件中。
// file:fs/exec.c
SYSCALL_DEFINE3(execve, const char __user *, filename, ...)
{
return do_execve(getname(filename), argv, envp);
}
int do_execve(...)
{
...
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}上⾯代码中的 path->name 表示的是可执⾏⽂件名,argv 是参数列表,envp 是环境变量。execve 系统调⽤通过 do_execve 进⼊到了 do_execveat_common 函数。我们来看这个函数的实现。
// file:fs/exec.c
static int do_execveat_common(int fd, struct filename *filename, ...)
{
// linux_binprm 结构⽤于保存加载⼆进制⽂件时使⽤的参数
struct linux_binprm *bprm;
// 申请并初始化 brm 对象值
bprm = alloc_bprm(fd, filename);
bprm->argc = count(argv, MAX_ARG_STRINGS);
bprm->envc = count(envp, MAX_ARG_STRINGS);
...
// 执⾏加载
bprm_execve(bprm, fd, filename, flags);
}这个函数中申请并初始化 bprm 对象的具体⼯作可以⽤下图来表示。
flowchart TD A["do_execveat_common"] --> B["alloc_bprm: 申请 linux_binprm 对象并初始化"] A --> C["bprm_execve: 读取文件头 128 字节,选择加载器"] B --> D["bprm_mm_init: 申请新的 mm_struct"] B --> E["__bprm_mm_init: 为新进程栈申请一页虚拟内存"]
在这个函数中,完成了两块⼯作:
- 调⽤
alloc_bprm申请内核对象linux_binprm,并初始化。 - 调⽤
bprm_execve读取可执⾏⽂件的⽂件头 128 字节,并选择加载器。
接下来我们分⼏个⼩节来展开看看这两块⼯作。
2.4.1 alloc_bprm 初始化 linux_binprm 对象
在 alloc_bprm 中会做以下⼏件重要的事情:
- 申请
linux_binprm内核对象。 - 调⽤
bprm_mm_init申请⼀个全新的地址空间mm_struct对象,准备留着给新进程使⽤。 - 调⽤
__bprm_mm_init给新进程的栈申请⼀⻚的虚拟内存空间,并将栈指针记录下来。
该函数执⾏完后,会申请到如下图中的各种启动新进程所需的对象。
flowchart LR subgraph bprm [linux_binprm] direction TB bprm_obj["bprm 对象 (通过 kzalloc 分配)"] mm_obj["mm (新 mm_struct, 通过 mm_alloc 分配)"] vma_obj["vma (新栈的 VMA, 通过 __bprm_mm_init 分配)"] stack_ptr["p (栈指针, 指向栈顶)"] end
接下来我们展开了看下 alloc_bprm 的详细过程。在 alloc_bprm 中会申请 linux_binprm 内核对象,申请过程是调⽤内核的 kzalloc 来分配内存。内核对象 linux_binprm 是进程加载过程中的⼀个结构,你可以把它理解成⼀个临时对象。在加载的时候,该内核对象⽤来保存加载⼆进制⽂件时使⽤的参数等信息,为新进程申请的虚拟地址空间,以及分配的栈内存也会临时在这⾥放⼀会⼉。等到新进程加载就绪的时候,这个对象就没有什么⽤了。
申请完 linux_binprm 后,调⽤ bprm_mm_init 对新申请出来的对象进⾏初始化。
// file:fs/exec.c
static struct linux_binprm *alloc_bprm(int fd, struct filename *filename)
{
struct linux_binprm *bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
bprm->filename = ...
bprm_mm_init(bprm);
...
return bprm
}mm_alloc 函数是申请了⼀个虚拟地址空间对象,这是为新进程准备的,但是先临时放在 bprm 这⾥保管⼀会⼉。接下来会在虚拟地址空间中分配进程运⾏所必须的进程栈内存。我们来看下 __bprm_mm_init 中是如何为新进程申请栈内存并初始化的。
// file:fs/exec.c
static int bprm_mm_init(struct linux_binprm *bprm)
{
// 申请个全新的地址空间 mm_struct 对象
bprm->mm = mm = mm_alloc();
__bprm_mm_init(bprm);
}
// file:fs/exec.c
static int __bprm_mm_init(struct linux_binprm *bprm)
{
struct mm_struct *mm = bprm->mm;
...
// 申请占⽤⼀段地址范围
bprm->vma = vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
vma->vm_end = STACK_TOP_MAX;
vma->vm_start = vma->vm_end - PAGE_SIZE;
// 将这段地址范围放⼊虚拟地址空间对象中管理
insert_vm_struct(mm, vma);
...
bprm->p = vma->vm_end - sizeof(void *);
}在虚拟地址空间中,每⼀段地址范围都是⽤⼀个 vma 对象来表示的。vma 的 vm_start、vm_end 两个成员共同声明了当前 vma 所占⽤的地址空间的范围。
在 __bprm_mm_init 函数中申请了⼀个 vma 对象(表示虚拟地址空间⾥的⼀段范围),vm_end 指向了 STACK_TOP_MAX(地址空间的顶部附近的位置),vm_start 和 vm_end 之间留了⼀个 Page ⼤⼩。 也就是说默认给栈申请了 4KB 的⼤⼩。最后把栈的指针记录到 bprm->p 中。
WARNING
注意,这⾥对于栈内存的申请仅仅只是申请了⼀个表示虚拟地址空间中占⽤的范围段的对象。真正的内存还并没有分配,要等到访问时触发缺⻚中断来实际分配这段虚拟地址范围对应的物理内存。
2.4.2 bprm_execve 执行加载
接下来的⼯作是调⽤ bprm_execve 来完成可执⾏⽂件的⽂件头的读取,以⽤作判断要加载的可执⾏⽂件的格式。之后就是寻找合适的加载器,尝试进⾏加载。我们来看下具体的实现,bprm_execve 的调⽤会进⼊到 search_binary_handler 这个核⼼函数中。
// file:fs/exec.c
static int search_binary_handler(struct linux_binprm *bprm)
{
// 读取可执⾏⽂件头,判断⽂件格式
prepare_binprm(bprm)
// 尝试启动加载
...
}在 prepare_binprm 这个函数中,从⽂件头部读取了 128 字节。之所以这么⼲,是为了读取⼆进制⽂件头为了⽅便后⾯判断其⽂件类型。
// file:include/uapi/linux/binfmts.h
#define BINPRM_BUF_SIZE 256
// file:fs/exec.c
int prepare_binprm(struct linux_binprm *bprm)
{
......
memset(bprm->buf, 0, BINPRM_BUF_SIZE);
return kernel_read(bprm->file, 0, bprm->buf,```c
// file:fs/exec.c
static int search_binary_handler(struct linux_binprm *bprm)
{
// 读取可执⾏⽂件头,判断⽂件格式
prepare_binprm(bprm)
...
// 尝试启动加载
retry:
list_for_each_entry(fmt, &formats, lh) {
retval = fmt->load_binary(bprm);
...
// 加载成功返回
if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
return retval;
}
...
// 否则继续尝试
goto retry;
}
}在上述代码中的 list_for_each_entry 是在遍历 formats 这个全局链表,遍历时判断每⼀个链表元素是否有 load_binary 函数.有的话就调⽤它尝试加载.如果要加载的可执⾏程序是⼀个 ELF 格式的⽂件,那就会调⽤ ELF 加载器来进⾏加载,⻅ 3.5 节.
2.5 ELF 文件加载过程
回忆⼀下 3.3 注册可执⾏⽂件加载程序,对于 ELF ⽂件加载器 elf_format 来说, load_binary 函数指针指向的是 load_elf_binary.
那么加载⼯作就会进⼊到 load_elf_binary 函数中来进⾏.这个函数很⻓,可以说所有的程序加载逻辑都在这个函数中体现了.这个函数的主要⼯作包括如下⼏个部分.
- ELF 文件头读取
- Program Header 读取,读取出所有的 Segment
- 清空⽗进程继承来的虚拟地址空间等资源
- 执⾏ Segment 加载
- 数据段内存申请,堆初始化
- 跳转到程序⼊⼝点执⾏
可⻅,⾸先就是对 ELF 可执⾏⽂件的解析,这也是我为什么在本章开头先介绍 ELF 格式的原因.解析完 ELF 后,读取所有的 Segment.给新进程准备好数据段、堆等内存,然后就跳转到⼊⼝点开始执⾏了.
TIP
注意,这⾥说的⼊⼝点并不是我们开发同学⽇常所⻅的
main函数,不同语⾔会有不同的⼊⼝点,但这些⼊⼝点最终会执⾏到我们熟悉的main.
接下来我们展开详细介绍下 load_elf_binary 所做的这些⼯作,每⼀块⼯作我都⽤⼀个⼩节来介绍.在介绍的过程中,为了表达清晰,我会稍微调⼀下源码的位置,可能和内核源码顺序会有所不同.
2.5.1 ELF 文件头读取
在 load_elf_binary 中⾸先会读取 ELF ⽂件头.
// file:fs/binfmt_elf.c
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
......
};前⾯已经 execve 函数开头调⽤的 do_execve_common 中,已经将 ELF ⽂件头读取到 bprm->buf 了,所以这⾥直接拷⻉访问这段内存就可以.
// file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
// 3.5.1 ELF⽂件头解析
// 获取ELF⽂件头
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;
struct elfhdr *interp_elf_ex = NULL;
// 对头部进⾏⼀系列的合法性判断,不合法则直接退出
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
goto out;
...
// 申请interp_elf_ex对象
interp_elf_ex = kmalloc(sizeof(*interp_elf_ex), GFP_KERNEL);
...
}先将 ELF 文件头拷⻉保存起来.文件头中包含一些当前文件格式类型等数据,在读取完文件头后会进行一些合法性判断.如果不合法,则退出返回.
2.5.2 Program Header 读取
在 ELF 文件头中记录着 Program Header 的数量,而且在 ELF 头之后紧接着就是 Program Header Tables.所以内核接下来可以将所有的 Program Header 都读取出来.
我们到源码中看下 Program Header 是如何读取的.
// file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
// 3.5.1 ELF ⽂件头解析
// 3.5.2 Program Header 读取
elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
if (!elf_phdata)
goto out;
}其中 Program Header 的读取是在 load_elf_phdrs 中完成的.
// file:fs/binfmt_elf.c
static struct elf_phdr *load_elf_phdrs(const struct elfhdr *elf_ex,
struct file *elf_file)
{
// elf_ex.e_phnum 中保存的是 Programe Header 数量
// 再根据 Program Header ⼤⼩ sizeof(struct elf_phdr)
// ⼀起计算出所有的 Program Header ⼤⼩
size = sizeof(struct elf_phdr) * elf_ex->e_phnum;
// 申请内存并读取进来
elf_phdata = kmalloc(size, GFP_KERNEL);
elf_read(elf_file, elf_phdata, size, elf_ex->e_phoff);
...
return elf_phdata;
}⾸先是计算出来需要多⼤的内存,Programe Header 数量在 ELF 文件头中提供的,每个 Programe Header 所需要的内存对象 struct elf_phdr ⼤⼩也是知道的,乘⼀下就⾏了.
接着调⽤ kmalloc 来分配好内存,然后将可执⾏⽂件在磁盘上保存的内容读取到内存中.
NOTE
内核在内存的使⽤上和⽤户进程中的虚拟地址空间是不⼀样的,
kmalloc系列的函数都是直接在伙伴系统所管理的物理内存中分配,不需要触发缺⻚中断.
2.5.3 清空⽗进程继承来的资源
在 fork 系统调⽤创建出来的进程中,包含了不少原进程的信息,如⽼的地址空间,信号表等等.这些在新的程序运⾏时并没有什么⽤,所以需要清空处理⼀下.
// file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
// 3.5.1 ELF ⽂件头解析
// 3.5.2 Program Header 读取
// 3.5.3 清空⽗进程继承来的资源
begin_new_exec(bprm);
...
//使⽤新栈
setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
...
}具体⼯作包括初始化新进程的信号表,应⽤新的虚拟地址空间对象等.
在 begin_new_exec 中会对从⽗进程中继承过来的地址空间、信号表等资源进⾏释放.最后再使⽤上前⾯⼩节中在 linux_binprm 临时变量中保存的新的地址空间.这之后,直接将前⾯准备的进程栈的地址空间指针设置到了 mm 对象上.这样将来栈就可以被使⽤了.
// file:fs/exec.c
int begin_new_exec(struct linux_binprm * bprm)
{
// 确保⽂件表不共享
unshare_files();
// 释放所有的旧的mmap
exec_mmap(bprm->mm);
// 确保信号表不共享
unshare_sighand(me)
...
}// file:fs/exec.c
static int exec_mmap(struct mm_struct *mm)
{
struct task_struct *tsk;
struct mm_struct *old_mm, *active_mm;
tsk = current;
old_mm = current->mm;
// 释放旧的地址空间
exec_mm_release(tsk, old_mm);
// 使⽤bprm中保存的新的地址空间
tsk->mm = mm;
...
}// file:fs/exec.c
int setup_arg_pages(struct linux_binprm *bprm,
unsigned long stack_top,
int executable_stack)
{
...
current->mm->start_stack = bprm->p;
...
}在清空完⽗进程继承来的虚拟地址空间后,将前⾯在临时变量 bprm 中保存的新的地址空间拿过来⽤上.这样新进程的虚拟内存就准备好了.
接下来再调⽤ setup_arg_pages,为新进程也设置上新的栈准备使⽤.
2.5.4 执行 Segment 加载
接下来,加载器会将 ELF 文件中的 LOAD 类型的 Segment 都加载到内存里来.使用 elf_map 在虚拟地址空间中为其分配虚拟内存.最后合适地设置虚拟地址空间 mm_struct 中的 start_code、end_code、start_data、end_data 等各个地址空间相关指针.
只有 LOAD 类型的 Segment 是需要被映射到内存的.
我们来看下加载 Segment 的具体代码:
// file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
// 3.5.1 ELF 文件头解析
// 3.5.2 Program Header 读取
// 3.5.3 清空父进程继承来的资源
// 3.5.4 执行 Segment 加载过程其中 load_bias 是 Segment 要加载到内存里的基地址.这个参数有这么几种可能:
- 值为 0,就是直接按照 ELF 文件中的地址在内存中进行映射.
- 值为对齐到整数页的开始,物理文件中可能为了可执行文件的大小足够紧凑,而不考虑对齐的问题.但是操作系统在加载的时候为了运行效率,需要将 Segment 加载到整数页的开始位置处.
计算好内存地址后,调用 elf_map 将磁盘文件中的内容和虚拟地址空间建立映射,等到访问的时候发生缺页中断加载磁盘文件中的代码或数据.最后设置虚拟地址空间中的代码段、数据段相关的指针——start_code、end_code、start_data、end_data.
2.5.5 数据内存申请 & 堆初始化
现在虚拟地址空间中的代码段、数据段、栈都已经就绪了,还缺一个堆内存需要初始化.接下来就使用 set_brk 系统调用专门为数据段申请虚拟内存.
// 遍历可执行文件的 Program Header
for(i = 0, elf_ppnt = elf_phdata;
i < elf_ex->e_phnum; i++, elf_ppnt++) {
// 只加载类型为 LOAD 的 Segment,否则跳过
if (elf_ppnt->p_type != PT_LOAD)
continue;
...
// 为 Segment 建立内存 mmap, 将程序文件中的内容映射到虚拟内存空间中
// 这样将来程序中的代码、数据就都可以被访问了
elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
// 计算 mm_struct 所需要的各个成员地址
start_code = ...;
start_data = ...
end_code = ...;
end_data = ...;
...
}
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;
...
}在 set_brk 函数中做了两件事情:第一是为数据段申请虚拟内存,第二是将进程堆的开始指针和结束指针初始化一下.
// file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
// 3.5.1 ELF 文件头解析
// 3.5.2 Program Header 读取
// 3.5.3 清空父进程继承来的资源
// 3.5.4 执行 Segment 加载过程
// 3.5.5 数据内存申请&堆初始化
retval = set_brk(elf_bss, elf_brk, bss_prot);
if (retval)
goto out_free_dentry;
......
}// file:fs/binfmt_elf.c
static int set_brk(unsigned long start, unsigned long end)
{
// 1.为数据段申请虚拟内存
start = ELF_PAGEALIGN(start);
end = ELF_PAGEALIGN(end);
if (end > start) {
vm_brk_flags(start, end - start,
prot & PROT_EXEC ? VM_EXEC : 0);
}因为程序初始化的时候,堆上还是空的.所以堆指针初始化的时候,堆的开始地址 start_brk 和结束地址 brk 都设置成了同一个值.我们代码中所常用的 malloc 就是修改 brk 相关的指针来实现内存申请的.
2.5.6 跳转到程序入口执行
在 ELF 文件头中记录了程序的入口地址.如果是非动态链接加载的情况,入口地址就是这个. 但是如果是动态链接,也就是说存在 INTERP 类型的 Segment,由这个动态链接器先来加载运行,然后再调回到程序的代码入口地址.
对于是动态加载器类型的,需要先将动态加载器(本文示例中是 ld-linux-x86-64.so.2 文件)加载到地址空间中来.
// 2.初始化堆的指针
current->mm->start_brk = current->mm->brk = end;
return 0;
}# readelf --program-headers helloworld
......
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]加载完成后再计算动态加载器的入口地址.这段代码我展示在下面了,没有耐心的同学可以跳过.反正只要知道这里是计算了一个程序的入口地址就可以了.
// file:fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
// 3.5.1 ELF 文件头解析
// 3.5.2 Program Header 读取
// 3.5.3 清空父进程继承来的资源
// 3.5.4 执行 Segment 加载
// 3.5.5 数据内存申请&堆初始化
// 3.5.6 跳转到程序入口执行
// 第一次遍历 program header table
// 只针对 PT_INTERP 类型的 segment 做个预处理
// 这个 segment 中保存着动态加载器在文件系统中的路径信息
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
...
}
// 第二次遍历 program header table, 做些特殊处理
elf_ppnt = elf_phdata;
for (i = 0; i < loc->elf_ex.e_phnum; i++, elf_ppnt++){
...
}
// 如果程序中指定了动态链接器,就把动态链接器程序读出来
if (elf_interpreter) {
// 加载并返回动态链接器代码段地址
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias);
// 计算动态链接器入口地址
elf_entry += loc->interp_elf_ex.e_entry;
} else {
elf_entry = loc->elf_ex.e_entry;
}
// 跳转到入口开始执行
START_THREAD(elf_ex, regs, elf_entry, bprm->p);
...
}前面我们说过,入口函数是 _start,之后经过 __libc_start_main 会调用到我们所熟悉的 main 函数。
2.6 本章总结
看起来简简单单的一行 helloworld 代码,但是要想把它运行过程理解清楚却需要非常深厚的内功的。我们来总结一下。
本章首先带领大家认识和理解了二进制可运行 ELF 文件格式。然后又带领大家深入地了解一下 Linux 加载程序的过程。好了,回到我们开篇的问题:
-
编译链接后生成的可执行程序是长什么样子的? 编译器链接器的工作是将你的代码转变成 ELF 格式可执行文件的过程。ELF 文件由四部分组成,分别是 ELF 文件头(ELF header)、Program header table、Section 和 Section header table。 ELF 文件头记录了整个文件的属性信息。Program header table 和 Section header table 中是描述 ELF 中各种 Section 的方式,只不过是使用目的不同,一个针对加载,一个针对链接。 Section 是 ELF 文件中比较基础和重要的单位。我们平时写的源代码,以及依赖的各种基础库都会在编译链接后放进合适的 Section。其中函数会被放进
.text,全局变量数据会被放进.bss、.data中。 -
操作系统是如何加载这些程序的? (注:原文此处没有继续展开,但上文已详细描述了加载过程。)