前情提要
- 这片博客纯属为自己做个笔记,其中还有很多解释不到位的地方,特别是后面对底层源代码的分析,可能会有解释不恰当甚至不合理的地方。但我会在后面对源码有了更深层次的研究之后对这篇博客里的源码分析进行修改。还是一样,这篇博客还有很多的知识部分来自《程序员的自我修养》
Linux内核中的ELF文件装载过程之详解execve
———部分取材自《程序员的自我修养》
一:知识储备
- 从用户态到内核态的切换过程
从宏观上来看,Linux操作系统的体系架构分为用户态和内核态(或者用户空间和内核)。内核从本质上看是一种软件——控制计算机的硬件资源,并提供上层应用程序运行的环境。用户态即上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
以上摘自博客:
https://www.cnblogs.com/bakari/p/5520860.html
知道了用户态和内核态的概念,我们来看一下这两种状态的转换:
- 为什么要进行这种转换:
原因在于,运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。所以在执行某些特权级别较高的操作的时就会涉及到这种转换。 - 两种模式转换的情景:
系统调用,异常事件,外围设备的中断。这里重要对系统调用做一个说明。 - 系统调用的过程:
在系统需要调用一个系统调用函数时,例如在执行函数execve这个函数时,第一步需要调用sys_exece函数,这个过程可以用一张图来解释:(这里是从宏观角度做大概流程的示意)
二:ELF的装载过程
- 我们以shell中的bash来进行这个过程的串联。
首先在用户层面,在用户输入一的命令后,bash进程首先会调用fork();系统调用来创建一个新的进程。然后新的进程会调用execve();系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才的新进程结束,然后继续等待用户输入命令。
一: execve();底层执行的过程
execve();被定义在unistd.h 函数原型为:
int execve(cosnt char *filename,char *const argv[ ],char *const envp[ ]);
它的三个参数分别是被执行的程序文件名,执行参数和环境变量。在进入execve();系统调用之后,Linux内核就开始进行真正的装载工作。
- 在内核中,execve();系统调用的相应入口是sys_execve();他被定义在arch\i386\kerne\Process.c,其函数原型为:
int sys_execve(char *filenamei,
char **argv,
char **envp,
struct pt_regs *regs);
其功能是进行一个参数的检查复制后调用do_execve();
int do_execve(char * filename,
char __user *__user *argv,
char __user *__user *envp,
struct pt_regs * regs) ;
而 do_execve();会分8步执行。
1)动态分配linux_binprm 结构体并用新的可执行文件的数据填充这个结构体:
bprm = kmalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_ret;
//对bprm进行一个检查之后使用memset函数将bprm全置为0。
memset(bprm, 0, sizeof(*bprm));
对于bprm这个结构体在函数前面有这样的定义:
struct linux_binprm *bprm;
那么我们再深入 linux_binprm这个结构体看看:
struct linux_binprm{
char buf[BINPRM_BUF_SIZE];
struct page *page[MAX_ARG_PAGES];
struct mm_struct *mm;
unsigned long p; /* current top of mem */
int sh_bang;
struct file * file;
int e_uid, e_gid;
kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
void *security;
int argc, envc;
char * filename;
char * interp;
unsigned interp_flags;
unsigned interp_data;
unsigned long loader, exec;
};
这个结构体虽然成员很多,但我们只需要知道装载流程,所以只需要知道第一个成员为:
char buf[BINPRM_BUF_SIZE];
这里定义了一个字符数组,里面是一个宏:
#define BINPRM_BUF_SIZE 128
大小为128。这个会在后面魔数的传入时会用到。
2)现在我们回到bprm结构的动态分配。对bprm结构体初始化为0后,do_execve();会调用 open_exec();这个函数会调用path_lookup(),dentry_open(),path_release()以获得与可执行文件相关的目录项对象、文件对象和索引结点对象:
file = open_exec(filename);
retval = PTR_ERR(file);
下面这一步将对file进行检查,file也可能是错误码,所以调用IS_ERR(file)进行检查。
if (IS_ERR(file))
goto out_kfree;
3)在多处理器中,调用sched_exec以确定最小负载CPU以执行 新程序,并把上面获得的部分信息转移到bprm结构体中去。
sched_exec();//进行检查
//以下部分将获得的部分信息填入bprm结构体
bprm->p = PAGE_SIZE*MAX_ARG_PAGES - sizeof(void *);
bprm->file = file;
bprm->filename = filename;
bprm->interp = filename;
bprm->mm = mm_alloc();
retval = -ENOMEM;
//参数检查
if (!bprm->mm)
goto out_file;
4)调用prepare_binprm();填充linux_binprm定义的brpm结构体。
这个函数很长所以我们只看一下这个函数的函数原型:
int prepare_binprm(struct linux_binprm *bprm)
这个函数大致上会干三件事:
1.检测可执行文件是否可执行
源代码:
if (!(mode & 0111))
return -EACCES;
if (bprm->file->f_op == NULL)
return -EACCES;
2.初始化e_uid 和 e_gid字段。后面会根据这两个值检测用户权限。
源代码:
bprm->e_uid = current->euid;
bprm->e_gid = current->egid;
3.用可执行文件的前128个字节填充linux_binprm结构的buf字段。这些字节包含的是适合于识别可执行文件的一个魔数和其他信息。
源代码:
memset(bprm->buf,0,BINPRM_BUF_SIZE);//先初始化为0
return kernel_read(bprm->file,0,bprm->buf,BINPRM_BUF_SIZE);
/* 返回时调用kernel_read();用可执行文件文件文档bprm->file
** 的前BINPRM_BUF_SIZE(128)个字节去填充bprm->buf,这就是
** 我们前面为什么要将这个buf的定义给出的原因了。
**/
5)把路径名,命令行参数,环境串复制到一个或者多个新分配的页框中,最终,它们会被分配给用户态地址空间。
源代码:
//复制路径名
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;
6)用search_binary_handler();对formats链表进行扫描,并尽力应用每个元素的load_binary方法,把inxu_binprm数据结构传递给这个函数。只要load_binary方法成功应答了文件的可执行格式。对formats的扫描就终止。
- 简单来说就是对链表进行一个扫描去搜索和匹配合适的可执行文件装载处理过程。这个装载过程的类型函数有三种:
1.ELF装载处理过程 :load_elf_binary();
2.a.out装载处理过程:load_aout_binary();
3.可执行脚本程序装载处理过程:load_script();
search_binary_handler();函数还会通过判断文件头部的 buf里面的魔数确定文件的格式。
7)对于装载处理过程,这里对ELF说明:
ELF装载处理过程叫做load_elf_binary();其被定义在fs/Binfmt_elf.c,由于这个函数代码叫长这里只给出原型:
static int load_elf_binary
( struct linux_binprm * bprm,
struct pt_regs * regs );
其执行主要分为5步:
1.检查ELF可执行文件的有效性,比如魔数,程序头表中断(Segmemt)的数量。
2.寻找动态链接的“.interp”段设置动态链接器路径。
3.根据ELF可执行文件的程序头表的表述,对ELF文件进行映射,比如代码,数据,只读数据。
4.初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址。
5.将系统调用的返回地址修改为ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对静态链接的ELF可执行文件,这个程序入口点就是文件的文件头中e_entry所指地址。对于动态链接的ELF可执行文件,程序入口点是动态链接器。
8)在load_elf_binary();执行完毕,返回至do_execve();再返回至sys_execve();时上面的第5步中已经把系统调用的返回地址改为了被装载ELF程序的入口地址了,所以当sys_execve();系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始,ELF可执行程序装载完成。
三:对于魔数的一点了解
——以下摘自《程序员的自我修养》
概念
使用readelf -h 命令可查看ELF的文件头,在文件头中有一行16个字节叫做Magic的数据,这就是所谓的魔数,这16个字节被ELF标准规定用来表示ELF文件的平台属性。详解
魔数的基本格式为:
7f 45 4c 46 01 01 01 00
其最开始的4个字节是所有的ELF 文件都必须相同的标识码,分别为:0x7F 0x45 0x3C 0x46,第一个字节对应的ASC||码里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASC||码,其实这4个字节才是真正的魔数,而后面的只是标识其他信息的。
第5个字节是用来标识ELF的文件类的,0x01为32位,0x02为64位。第6个字节是 字节序 即规定这个ELF文件是大端还是小端(有兴趣可参看《程序员的自我修养》附录A)第7个自己是规定ELF文件的主版本号。后面的字节ELF标准并没有定义。查看魔数的方法
1)直接用readelf -h 方法查看文件头,Magic一行就是魔数
2)也可为了验证魔数是否是一个ELF文件最开头的几个字节,也可自己写一个读取字符的代码来验证。
给一个小代码:(随手写,不要介意)
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
int main()
{
FILE *fp = NULL;
if ((fp = fopen("./a", "r")) == NULL)
{
perror("fopen error");
exit(0);
}
char buff[128] = { 0 };
if (fread(buff, 1, 127, fp) == 0)
{
perror("fread error");
exit(0);
}
int i;
for (i = 0; i < 16; i++)
{
printf("%x ", buff[i]);
}
printf("\n");
exit(0);
}
总结
对于execve();这一类涉及底层源码系统调用的函数,只有在能看懂源码的基础上才能进行解析,但源码这个东西不好驾驭,很可能看的云里雾里,比如说我。所以我分析得很浅。只是对整个流程做了一个概述。