2019-2020-1 20199320《Linux内核原理与分析》第八周作业

第七章 可执行程序的工作原理

一、本章知识点

1.1 ELF目标文件格式

  • ELF(Executable and Linking Format)是一种对象文件的格式,用于定义不同类型的对象文件(Object files)中都放了什么东西、以及都以什么样的格式去放这些东西。它自最早在 System V 系统上出现后,被 xNIX 世界所广泛接受,作为缺省的二进制文件格式来使用。
    所谓对象文件(Object files)有三个种类:

1) 可重定位的对象文件(Relocatable file)

这是由汇编器汇编生成的 .o 文件。后面的链接器(link editor)拿一个或一些 Relocatable object files 作为输入,经链接处理后,生成一个可执行的对象文件 (Executable file) 或者一个可被共享的对象文件(Shared object file)。我们可以使用 ar 工具将众多的 .o Relocatable object files 归档(archive)成 .a 静态库文件。

2) 可执行的对象文件(Executable file)

这我们见的多了。文本编辑器vi、调式用的工具gdb、播放mp3歌曲的软件mplayer等等都是Executable object file。

3) 可被共享的对象文件(Shared object file)

这些就是所谓的动态库文件,也即 .so 文件。如果拿前面的静态库来生成可执行程序,那每个生成的可执行程序中都会有一份库代码的拷贝。如果在磁盘中存储这些可执行程序,那就会占用额外的磁盘空间;另外如果拿它们放到Linux系统上一起运行,也会浪费掉宝贵的物理内存。如果将静态库换成动态库,那么这些问题都不会出现。动态库在发挥作用的过程中,必须经过两个步骤:

a) 链接编辑器(link editor)拿它和其他Relocatable object file以及其他shared object file作为输入,经链接处理后,生存另外的 shared object file 或者 executable file。

b) 在运行时,动态链接器(dynamic linker)拿它和一个Executable file以及另外一些 Shared object file 来一起处理,在Linux系统里面创建一个进程映像。

ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且他们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息有ELF头中的各项值来决定。

  • ELF文件:ELF(Excutable and Linking Format)是一个文件格式的标准。通过readelf-h hello查看可执行文件hello的头部(-a查看全部信息,-h只查看头部信息),头部里面注明了目标文件类型ELF32。Entry point address是程序入口,地址为0x400440,如下图所示:

具体结构定义如下:

 1    #define EI_NIDENT       16
 2 
 3   typedef struct {
 4       unsigned char       e_ident[EI_NIDENT];
 5       Elf32_Half          e_type;
 6       Elf32_Half          e_machine;
 7       Elf32_Word          e_version;
 8       Elf32_Addr          e_entry;
 9       Elf32_Off           e_phoff;
10       Elf32_Off           e_shoff;
11       Elf32_Word          e_flags;
12       Elf32_Half          e_ehsize;
13       Elf32_Half          e_phentsize;
14       Elf32_Half          e_phnum;
15       Elf32_Half          e_shentsize;
16       Elf32_Half          e_shnum;
17       Elf32_Half          e_shstrndx;
18   } Elf32_Ehdr;

e_type 它标识的是该文件的类型。
e_machine 表明运行该程序需要的体系结构。
e_version 表示文件的版本。
e_entry 程序的入口地址。
e_phoff 表示Program header table 在文件中的偏移量(以字节计数)。
e_shoff 表示Section header table 在文件中的偏移量(以字节计数)。
e_flags 对IA32而言,此项为0。
e_ehsize 表示ELF header大小(以字节计数)。
e_phentsize 表示Program header table中每一个条目的大小。
e_phnum 表示Program header table中有多少个条目。
e_shentsize 表示Section header table中的每一个条目的大小。
e_shnum 表示Section header table中有多少个条目。
e_shstrndx 包含节名称的字符串是第几个节(从零开始计数)。

而exec函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件,如果不是可以执行的文件,那么就解释成为一个shell文件,shell执行。

1.2 可执行程序的装载

gcc –e –o hello.cpp hello.c   //预处理
gcc -x cpp-output -S -o hello.s hello.cpp //编译   
gcc -x assembler -c hello.s -o hello.o-m32  //汇编
gcc -o hello hello.o   //链接成可执行文件,使用共享库

输入到终端截图如下:

可以看到hello只有8K,hello.static有大概800K。

1.3 静态链接可执行文件的调试

1先把menu删掉,在克隆一个,用test_exec.c覆盖掉test.c。
2打开test.c。发现增加了一句MenuConfig。
3打开Makefile,首先静态编译了hello.c,生成根文件系统时把init和hello都放入rootfs image里面,这样执行exec的时候就自动的帮我们加载hello这个文件。
4执行结果hello world! 是新加载的一个可执行程序输出的。

输入到终端截图如下:

-S -s单步调试,窗口被冻结;设置三个断点:sys_execve,load_elf_binary,start_thread。list列出来跟踪;输入s可以进入do_execve的内部。按c继续执行,跑到load_elf_binary;list查看代码,输入n一句一句跟踪,nnnc,追踪到start_thread。

观察hello这个可执行程序的入口,发现也是0x8048d0a,和new_ip的位置一样。new_ip是返回到用户态第一条指令的地址。

1.4 exec系统调用的执行过程

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。

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; }

它的运行逻辑是依次遍历formats中得每种格式,然后根据不同的格式调用响应的load函数。例如,对于elf文件执行load_elf_bianry,对于a.out文件执行load_aout_binary函数

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;
    // 
}

上面程序的大致流程就是:

1. 分析头部
2. 查看是否需要动态链接。如果是静态链接的elf文件,那么直接加载文件即可。如果是动态链接的可执行文件,那么需要加载的是动态链接器。
3. 装载文件,为其准备进程映像。
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);

我们可以看到上面的程序主要是: 寄存器清空,设定寄存器的值,尤其是eip和esp的值。

二、本章总结

1. 新的可执行程序是从哪里开始执行的?
当execve()系统调用终止且进程重新恢复它在用户态执行时,执行上下文被大幅度改变,要执行的新程序已被映射到进程空间,从elf头中的程序入口点开始执行新程序。
如果这个新程序是静态链接的,那么这个程序就可以独立运行,elf头中的这个入口地址就是本程序的入口地址。
如果这个新程序是动态链接的,那么此时还需要装载共享库,elf头中的这个入口地址是动态链接器ld的入口地址。

2. 对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
execve系统调用会调用sys_execve,然后sys_execve调用do_execve,然后do_execve调用do_execve_common,然后do_execve_common调用exec_binprm,在exec_binprm中:
对于ELF文件格式,fmt函数指针实际会执行load_elf_binary,load_elf_binary会调用start_thread,在start_thread中通过修改内核堆栈中EIP的值,使其指向elf_entry,跳转到elf_entry执行。
对于静态链接的可执行程序,elf_entry是新程序的执行起点。对于动态链接的可执行程序,需要先加载链接器ld,
elf_entry = load_elf_interp(…)
将CPU控制权交给ld来加载依赖库,再由ld在完成加载工作后将CPU控制权还给新进程。

3.总结
可执行文件是一个普通的文件,它描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。可执行文件类别有很多,在内核中有一个链表,在init的时候会将支持的可执行程序解析程序注册添加到链表中,那么在对可执行文件进行解析时,就从链表头开始找,找到匹配的处理函数就可以对其进行解析。
在shell中启动一个可执行程序时,会创建一个新进程,它通过覆盖父进程(也就是shell进程)的进程环境,并将用户态堆栈清空,获得需要的执行上下文环境。
命令行参数和环境变量会通过shell传递给execve,excve通过系统调用参数传递,传递给sys_execve,最后sys_execve在初始化新进程堆栈的时候拷贝进去。
load_elf_binary->start_thread(…)通过修改内核堆栈中EIP的值作为新程序的起点

猜你喜欢

转载自www.cnblogs.com/liangxu111/p/11828153.html