网易云课堂 Linux内核分析(七)

寇亚飞 + 原创作品转载请注明出处 + 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000

实验内容

Linux内核如何装载和启动一个可执行程序

  • 可执行程序是如何生成的?
  • ELF文件格式
  • 静态链接和动态链接
  • exec函数系统调用处理过程

    可执行程序是如何生成的?

    以helloworld.c为例

#include 
int main(void)
{
    printf("hello, world!\n");
    return 0;
}
  • 预处理,处理代码中的宏定义和 include 文件,并做语法检查
gcc -E helloworld.c -o helloworld.i
  • 编译,生成汇编代码
gcc -S helloworld.i -o helloworld.s
  • 汇编,生成 ELF 格式的目标代码
gcc -c helloworld.s -o helloworld.o
  • 链接,生成可执行代码
gcc helloworld.o -o helloworld
  • 执行程序
./helloworld hello, world!

ELF文件格式

目标文件格式最早在unix上是.out格式,后来发展成COFF。现在用的最多的是PE(主要用在window)和elf(主要用在linux),ELF格式主要有三种文件格式,可重定位文件(relocatable),可执行文件(execable),一个共享的object文件。

  • 可重定位文件,如:.o 文件,包含代码和数据,可以被链接成可执行文件或共享目标文件,静态链接库属于这类
  • 可执行文件,如:/bin/bash 文件,包含可直接执行的程序,没有扩展名
  • 共享目标文件,如:.so 文件,包含代码和数据,可以跟其他可重定位文件和共享目标文件链接产生新的目标文件,也可以跟可执行文件结合作为进程映像的一部分

静态链接和动态链接

链接是收集和组织程序所需的不同代码和数据的过程,以便程序能被装入内存并被执行。主要完成两部分,一部分是是完成空间与地址的分配,另一部分是符号解析与重定位。链接通常可分为静态链接和动态链接。

  • 静态链接:在程序运行之前完成所有的组装工作,生成一个可执行的目标文件
  • 动态链接:在程序已经为了执行被装载入内存之后完成链接工作,并且在内存中一般只保留该编译单元的一份拷贝

静态链接库与动态链接库都是共享代码的方式。如果采用静态链接库,则程序把所有执行需要所依赖的东西都加载到可执行文件中了。但若是使用动态库,则不必被包含在最终执行的文件中,可执行文件执行时可以“动态”的引用和卸载这个与可执行程序独立的动态库文件。

  • 静态链接库:代码的装载速度快,执行速度也快,因为编译时它只会把你所需要的那部分链接进去,应用程序相对较大。但是如果没有多个应用程序的话,会被装载多次,浪费内存
  • 动态链接库:多个应用程序可以使用同一个动态库,启动多个应用程序的时候,只需要将动态库加载到内存一次就好

    动态链接库的两种链接方式

  • 装载时动态链接(Load-time Dynamic Linking):这种方法的前提是在编译之前已经明确知道要调用的动态库的哪些函数,编译时在目标文件中只保留必要的链接信息,而不含动态库函数代码;当程序执行时,调用函数的时候利用链接信息加载动态库函数代码并在内存中将其链接入调用程序的执行空间中(全部函数加载进内存),其主要目的是便于代码共享。(动态加载程序,处在加载阶段,主要为了共享代码,共享代码内存)

  • 运行时动态链接(Run-time Dynamic Linking):这种方式是指在编译之前并不知道将会调用哪些动态库函数,完全是在运行过程中根据需要决定应调用哪个函数,将其加载到内存中(只加载调用的函数进内存);并标识内存地址,其他程序也可以使用该程序,并获得动态库函数的入口地址。(动态库在内存中只存在一份,处在运行阶段)

exec函数系统调用处理过程

在sys_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);
}

进入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);
}

然后进入do_execve_common,然后到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);

execve 是比较特殊的系统调用,exec_binprm 在保存了 bprm 后调用该函数来进一步操作,execve 加载的可执行文件会把当前的进程覆盖掉,返回之后就不是原来的程序而是新的可执行程序起点。这个函数除了保存 pid 以外,还执行了 search_binary_handler 来查询能够处理相应可执行文件格式的处理器,并调用相应的load_binary 方法以启动新进程。
search_binary_handler:

int search_binary_handler(struct linux_binprm *bprm)
{
    ...
    retry:
        read_lock(&binfmt_lock);
        //循环查找 linux_binfmt
        list_for_each_entry(fmt, &formats, lh) {
            if (!try_module_get(fmt->module))
                continue;
            read_unlock(&binfmt_lock);
            bprm->recursion_depth++;

            //对于 elf 文件,实际上执行的就是 load_elf_binary
            retval = fmt->load_binary(bprm);
            read_lock(&binfmt_lock);
            ...
}

load_elf_binary:

static int load_elf_binary(struct linux_binprm *bprm)
{
    ...

    //获取头
    loc->elf_ex = *((struct elfhdr *)bprm->buf);
    //读取头信息
    if (loc->elf_ex.e_phentsize != sizeof(struct elf_phdr))
        goto out;
    if (loc->elf_ex.e_phnum < 1 ||
        loc->elf_ex.e_phnum > 65536U / sizeof(struct elf_phdr))
        goto out;
    size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr);
    retval = -ENOMEM;
    elf_phdata = kmalloc(size, GFP_KERNEL);
    if (!elf_phdata)
        goto out;
        ...
    //读取可执行文件的解析器
    for (i = 0; i < loc->elf_ex.e_phnum; i++) {
        if (elf_ppnt->p_type == PT_INTERP) {
            ...
    }
    ...
    //如果需要装入解释器,并且解释器的映像是ELF格式的,就通过load_elf_interp()装入其映像,并把将来进入用户空间时的入口地址设置成load_elf_interp()的返回值,那显然是解释器的程序入口。而若不装入解释器,那么这个地址就是目标映像本身的程序入口。
    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)) {

            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 调用 start_thread 函数。修改 int 0x80 压入内核堆栈的 EIP,当 load_elf_binary 执行完毕,返回至 do_execve 再返回至 sys_execve 时,系统调用的返回地址,即 EIP 寄存器,已经被改写成了被装载的 ELF 程序的入口地址了。

总结

Linux 系统通过用户态 execve 函数调用内核态 sys_execve 系统调用,负责将新的程序代码和数据替换到新的进程中,打开可执行文件,载入依赖的库文件,申请新的内存空间,最后执行 start_thread 函数设置 new_ip 和 new_sp,完成新进程的代码和数据替换,然后返回并执行新的进程代码。 具体的执行流程即sys_execve -> do_execve -> do_execve_common -> exec_binprm -> search_binary_handler -> load_binary ->(对于我们这里的ELF,会跳转到)load_elf_binary (也执行了elf_format)-> start_thread

实验截图参考实验报告

你可能感兴趣的:(linux)