姓名:何伟钦
学号:20135223
( *原创作品转载请注明出处*)
( 学习课程:《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-100002900
学习内容:Linux内核如何装载和启动一个可执行程序
-
理解编译链接的过程和ELF可执行文件格式;
-
编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接,编程练习动态链接库的这两种使用方式;
-
使用gdb跟踪分析一个execve系统调用内核处理函数sys_execve ,验证对Linux系统加载可执行程序所需处理过程的理解
-
特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
一、预处理、编译、链接和目标文件的格式
(一)可执行文件的创建——预处理、编译和链接
-
编译器预处理
gcc -E -o XX.cpp XX.
c (-m32)
-
汇编器编译成汇编代码
gcc -x cpp -
output -S -o hello.s hello.cpp (-m32)
-
汇编代码编译成二进制目标文件
gcc -x assembler -c hello.s -o hello.o (-m32)
-
链接成可执行文件
gcc -o hello.static hello.c (-m32) -static
(二)目标文件格式ELF
ELF:可执行&可链接的文件格式,是一个文件格式的标准
ABI:应用程序二进制接口,目标文件中已经是二进制兼容的格式
目标文件三种形式
-
- 可重定位文件(用来和其他object文件一起创建下面两种文件)——.o文件
- 可执行文件(指出了应该从哪里开始执行)
- 共享文件(主要是.so文件,用来被链接编辑器和动态链接器链接)
查看ELF文件的头部
$ readelf -h hello
注意:
1.entry代表程序的入口地址(头部之后是代码和数据,进程的地址空间是4G,上面的1G是内核用,下面的3G是程序使用)默认的ELF头加载地址是0x8048000,头部大概要到0x48100处或者0x483000,也就是可执行文件加载到内存之后执行的第一条代码地址
2.一般静态链接会将所有代码放在一个代码段;动态链接的进程会有多个代码段
二、可执行文件、共享库和动态链接
(一)装载可执行程序之前的工作
可执行程序的执行环境
(1)一般我们执行一个程序的Shell环境,我们的实验直接使用execve系统调用。
(2)Shell本身不限制命令行参数的个数,命令行参数的个数受限于命令自身
例如,int main(int argc, char *argv[])
又如, int main(int argc, char argv[], char envp[])
(3)Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
库函数exec*都是execve的封装教程
共享库和动态加载共享库相关范例代码
1.#include <stdio.h> 2.#include <stdlib.h> 3.#include <unistd.h> 4.int main(int argc, char * argv[])//这里不是完整的命令函数,没有写命令行参数 5.{ 6. int pid; 7. /* fork another process *///避免原有的shell程序被覆盖掉 8. pid = fork(); 9. if (pid<0) 10. { 11. /* error occurred */ 12. fprintf(stderr,"Fork Failed!"); 13. exit(-1); 14. } 15. else if (pid==0) 16. { 17. /* child process */ 18. execlp("/bin/ls","ls",NULL);//以ls命令为例,在子进程中调用 19. } 20. else 21. { 22. /* parent process */ 23. /* parent will wait for the child to complete*/ 24. wait(NULL); 25. printf("Child Complete!"); 26. exit(0); 27. } 28.}
(二)命令行参数和环境串都放在用户态堆栈中
- fork子进程的时候完全复制了父进程;
- 调用exec的时候,要加载的可执行程序把原来的进程环境覆盖掉,用户态堆栈也被清空
- 命令行参数和环境变量进入新程序的堆栈:把环境变量和命令行参数压栈,也就相当于main函数启动
- shell程序-->execve-->sys_execve,然后在初始化新程序堆栈的时候拷贝进去
-
命令行参数和环境变量是如何进入新程序的堆栈:在创建一个新的用户态堆栈的时候,实际上是把命令行和环境变量参数的内容通过指针的方式传递到系统调用的内核处理函数,函数在创建可执行程序新的堆栈初始化时候再拷贝进去。先函数调用参数传递,再系统调用参数传递。
(三)装载时动态链接和运行时动态链接应用
- 动态链接分为可执行程序装载时动态链接(经常使用)和运行时动态链接;
- 共享库的动态链接:so文件(在Linux下动态链接文件格式,在Windows中是.dll)
-
编译成.so文件
$ gcc -shared shlibexample.c -o libshlibexample.so -m32
编译
1.$ gcc main.c -o main -L/path/to/your/dir -lshlibexample -ldl -m32
#这里只提供shlibexample的-L(库对应的接口头文件所在目录,也就是path to your dir)和-l(库名,如libshlibexample.so去掉lib和.so的部分),并没有提供dllibexample的相关信息,只是指明了-ldl
2.$ export LD_LIBRARY_PATH=$PWD #将当前目录加入默认路径,否则main找不到依赖的库文件,当然也可以将库文件copy到默认路径下。
三、可执行程序的装载
(一)可执行程序装载的关键问题
1.execve与fork是比较特殊的系统调用
execve用它加载的可执行文件把当前的进程覆盖掉,返回之后就不是原来的程序而是新的可执行程序起点;
fork函数的返回点ret_from_fork是用户态起点
2.sys_execve内核处理过程
do_execve -> do_execve_common -> exec_binprm
对于ELF格式的可执行文件fmt->load _ binary(bprm);执行的应该是load _ elf _ binary。其内部是和ELF文件格式解析的部分需要和ELF文件格式标准结合起来阅读(ELF可执行文件默认映射到0x8048000这个地址)
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,
};
static int __iit init_elf_binfmt(void)
{n
register_binfmt(&elf_format);//把变量注册进内核链表,在链表里查找文件的格式
return 0;
}
(1) do_exec函数
int do_execve(struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
return do_execve_common(filename, argv, envp);
}
static int do_execve_common(struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp)
{
// 检查进程的数量限制
// 选择最小负载的CPU,以执行新程序
sched_exec();
// 填充 linux_binprm结构体
retval = prepare_binprm(bprm);
// 拷贝文件名、命令行参数、环境变量
retval = copy_strings_kernel(1, &bprm->filename, bprm);
retval = copy_strings(bprm->envc, envp, bprm);
retval = copy_strings(bprm->argc, argv, bprm);
// 调用里面的 search_binary_handler
retval = exec_binprm(bprm);
// exec执行成功
}
static int exec_binprm(struct linux_binprm *bprm)
{
// 扫描formats链表,根据不同的文本格式,选择不同的load函数
ret = search_binary_handler(bprm);
// ...
return ret;
}
从上面的代码中可以看到,do_execve调用了do_execve_common,而do_execve_common又主要依靠了exec_binprm,在exec_binprm中又有一个至关重要的函数,叫做search_binary_handler。
(2)search_binary_handler
int search_binary_handler(struct linux_binprm *bprm)
{
// 遍历formats链表
list_for_each_entry(fmt, &formats, lh) {
// 应用每种格式的load_binary方法
retval = fmt->load_binary(bprm);
// ...
}
return retval;
}
(3)load_elf_bianry函数
static int load_elf_binary(struct linux_binprm *bprm)
{
// ....
struct pt_regs *regs = current_pt_regs(); // 获取当前进程的寄存器存储位置
// 获取elf前128个字节
loc->elf_ex = *((struct elfhdr *)bprm->buf);
// 检查魔数是否匹配
if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
goto out;
// 如果既不是可执行文件也不是动态链接程序,就错误退出
if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
//
// 读取所有的头部信息
// 读入程序的头部分
retval = kernel_read(bprm->file, loc->elf_ex.e_phoff,
(char *)elf_phdata, size);
// 遍历elf的程序头
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
// 如果存在解释器头部
if (elf_ppnt->p_type == PT_INTERP) {
//
// 读入解释器名
retval = kernel_read(bprm->file, elf_ppnt->p_offset,
elf_interpreter,
elf_ppnt->p_filesz);
// 打开解释器文件
interpreter = open_exec(elf_interpreter);
// 读入解释器文件的头部
retval = kernel_read(interpreter, 0, bprm->buf,
BINPRM_BUF_SIZE);
// 获取解释器的头部
loc->interp_elf_ex = *((struct elfhdr *)bprm->buf);
break;
}
elf_ppnt++;
}
// 释放空间、删除信号、关闭带有CLOSE_ON_EXEC标志的文件
retval = flush_old_exec(bprm);
setup_new_exec(bprm);
// 为进程分配用户态堆栈,并塞入参数和环境变量
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
current->mm->start_stack = bprm->p;
// 将elf文件映射进内存
for(i = 0, elf_ppnt = elf_phdata;
i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {
if (unlikely (elf_brk > elf_bss)) {
unsigned long nbyte;
// 生成BSS
retval = set_brk(elf_bss + load_bias,
elf_brk + load_bias);
// ...
}
// 可执行程序
if (loc->elf_ex.e_type == ET_EXEC || load_addr_set) {
elf_flags |= MAP_FIXED;
} else if (loc->elf_ex.e_type == ET_DYN) { // 动态链接库
// ...
}
// 创建一个新线性区对可执行文件的数据段进行映射
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, 0);
}
}
// 加上偏移量
loc->elf_ex.e_entry += load_bias;// 创建一个新的匿名线性区,来映射程序的bss段
retval = set_brk(elf_bss, elf_brk);
// 如果是动态链接
if (elf_interpreter) {
unsigned long interp_map_addr = 0;
// 调用一个装入动态链接程序的函数 此时elf_entry指向一个动态链接程序的入口
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias);
} else {
// elf_entry是可执行程序的入口
elf_entry = loc->elf_ex.e_entry;
}
// 修改保存在内核堆栈,但属于用户态的eip和esp
start_thread(regs, elf_entry, bprm->p);
retval = 0;
//
}
elf文件的开头是它的文件头,我们通过man elf
可以查看到:
typedef struct {
unsigned char e_ident[EI_NIDENT];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
ElfN_Addr e_entry;
// ....
} ElfN_Ehdr;
这就是elf文件的头部,它规定了许多与二进制兼容性相关的信息。所以在加载elf文件的时候,必须先加载头部,分析elf的具体信息。
所以上面程序的大体流程就是:
1. 分析头部
2. 查看是否需要动态链接。如果是静态链接的elf文件,那么直接加载文件即可。如果是动态链接的可执行文件,那么需要加载的是动态链接器。
3. 装载文件,为其准备进程映像。
4. 为新的代码段设定寄存器以及堆栈信息。
(4)start_thread函数
void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
set_user_gs(regs, 0); // 将用户态的寄存器清空
regs->fs = 0;
regs->ds = __USER_DS;
regs->es = __USER_DS;
regs->ss = __USER_DS;
regs->cs = __USER_CS;
regs->ip = new_ip; // 新进程的运行位置- 动态链接程序的入口处
regs->sp = new_sp; // 用户态的栈顶
regs->flags = X86_EFLAGS_IF;
set_thread_flag(TIF_NOTIFY_RESUME);
}
- start_thread(regs, elf_entry, bprm->p)会将CPU控制权交给ld来加载依赖库并完成动态链接;对于静态链接的文件elf_entry是新程序执行的起点
四、使用gdb跟踪分析一个execve系统调用内核处理函数sys_execve
(1)更新menu内核
rm menu -rf git clone https://github.com/megnning/menu.git cd menu ls mv test_exec.c test.c vi test.c
vi Makefile make rootfs
(2)使用gdb跟踪
qemu -kernel ../linux-3.18.6/arch/x86/boot/bzImage -initrd ../rootfs.img -s -S
gdb
file ../linux-3.18.6/vmlinux
target remote:1234
test.c文件:(快捷方式:shift+G直接到文件尾),新增加了exec系统调用
查看Makefile
设置三个断点
b sys_execve //可以先停在sys_execve然后再设置其他断点
b load_elf_binary
b start_thread
按c一路运行下去直到断点sys_execve,按s跳入函数内单步执行
第三个断点start_thread
po(print object)指令:(new_ip是返回到用户态的第一条指令)
readelf -h hello 找到hello这个可执行程序的入口地址
五、总结
1、进程创建
fork创建一个子进程,父进程在运行中间过程中fork,产生一个子进程,子进程不会从头开始运行代码,而会从fork的开始的后面的代码开始运行。exec的调用即是替换掉子进程,进而替换上有用的想要执行的进程,避免父子进程完全一样的浪费,若是替换成功,则不会返回。
fork和exec系统调用最终都是通过int 0x80软中断 + EAX寄存器(存储对应的系统调用号)进入内核,在内核中fork和exec对应找到sys_fork/do_fork和sys_exec/do_exec。do_fork主要的工作就是创建一个新进程,创建的方法是拷贝当前进程、分配新的进程pid、插入进程相关链表队列中等。do_exec的工作较为复杂,它的主要目标是将一个可执行程序加载到当前进程中来,返回到用户态时EIP指向可执行程序的入口位置(即0x08048000)。
2、可执行程序的加载过程
可执行程序的加载过程可以分为两种情况:一种是加载静态编译的ELF文件,只需要将代码段加载到0x08048000的位置,其他的数据也根据规则加载即可;另一种情况更常见需要动态链接。
用共享库来动态链接的过程,共享库就是为了解决这一问题,共享库是一个目标模块,在运行时可被加载到任意的存储器地址,并和一个在存储器中的程序链接起来。这个过程叫做动态链接,是由动态链接器的程序来完成的。
大致步骤:
(1)源程序文件和头文件等被翻译器生成可重定位的目标文件;
(2)链接器把可重定位目标文件和共享库的重定位和符号表的信息经过链接生成部分链接的可执行目标文件;
(3)加载时,由动态链接器把部分链接的可执行文件和共享库的代码、数据完全链接成完全可执行文件。
体会:
看视频貌似很简单,但是需要花大量的时间来追踪和理解,特别是代码部分,理解很费劲!
参考资料:
- 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000
- 实验楼Linux虚拟机http://www.shiyanlou.com/courses/195
- 《深入理解计算机系统》