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

本文详细介绍了Linux系统中可执行程序的生成过程及其格式,包括目标文件的种类与ELF格式的解析,并深入探讨了动态链接的工作原理及sys_execve系统调用如何实现程序的加载。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

今天我们来看一下,Linux内核是如何装载和启动一个可执行程序的。

Linux下如何生成一个可执行程序

首先我们可以看一下,linux如何生成一个可执行程序。

chenxu@ ~/Code/kernel/lab7$ gcc -E -o test.cpp test.c -m32  //生成预处理中间文件,宏定义替换
chenxu@ ~/Code/kernel/lab7$ gcc -x cpp-output -S -o test.s test.cpp -m32 //编译过程,生成汇编代码
chenxu@ ~/Code/kernel/lab7$ gcc -x assembler -c test.s -o test.o -m32 //汇编器,将汇编文件生成目标文件
chenxu@ ~/Code/kernel/lab7$ gcc -o test test.o -m32  //链接成可执行文件,使用共享库
chenxu@ ~/Code/kernel/lab7$ gcc -o test.static test.o -m32 -static //执行静态链接 (libC)
chenxu@ ~/Code/kernel/lab7$ ls -lh
total 784K
-rwxrwxr-x 1 chenxu chenxu 7.3K  410 22:45 test
-rw-rw-r-- 1 chenxu chenxu  231  410 15:26 test.c
-rw-rw-r-- 1 chenxu chenxu  38K  410 22:43 test.cpp
-rw-rw-r-- 1 chenxu chenxu 1.3K  410 22:45 test.o
-rw-rw-r-- 1 chenxu chenxu 1.1K  410 22:44 test.s
-rwxrwxr-x 1 chenxu chenxu 723K  410 22:46 test.static

我们这里可以看到,在静态链接中,我们由于将libC的相关内容直接加入了可执行程序中,所以test.static的大小要远大于动态链接的可执行程序。

生成的可执行文件如果要运行,则需要操作系统将其加载到内存中去,为了搞明白其中的过程,我们可以先了解一下可执行文件的格式,可执行文件是一种目标文件,我们从目标文件开始分析。

目标文件的格式

目标代码(objectcode)指计算机科学中编译器或汇编器处理源代码后所生成的代码,它一般由机器代码或接近于机器语言的代码组成。目标文件(objectfile)即存放目标代码的计算机文件,它常被称作二进制文件(binaries),这种文件是体系结构相关的。Linux下的目标文件为ELF(Excutable and Linking Format)格式文件。格式如下:
ELF格式

Linux下存在三种类型的目标文件

1.可重定位目标文件(relocatable *.o)包含代码节和数据节。此文件适合与其他可重定位目标文件链接,从而创建动态可执行文件、共享目标文件或其他可重定位目标文件。

chenxu@ ~/Code/kernel/lab7$ file test.o // 可重定位目标文件不能直接执行
test.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

2.动态可执行文件(executable)包含可随时执行的程序。此文件指定了 exec(2) 创建程序的进程映像的方式。此文件通常在运行时绑定到共享目标文件以创建进程映像。

chenxu@ ~/Code/kernel/lab7$ file test
test: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=8a7447dfa418772abf8f1935ceb4578c92769230, not stripped

3.共享目标文件文件(shared object *.so)包含适用于进行其他链接的代码和数据。链接编辑器可将此文件与其他可重定位目标文件和共享目标文件一起处理,以创建其他目标文件。运行时链接程序会将此文件与动态可执行文件和其他共享目标文件合并,以创建进程映像。

chenxu@ ~/Code/kernel/lab7$ gcc -shared shlibexample.c -o libshlibexample.so -m32
chenxu@ ~/Code/kernel/lab7$ file libshlibexample.so 
libshlibexample.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked, BuildID[sha1]=40d94a7e998a3d529d5f7e0c40ecc99630dd1a21, not stripped
chenxu@ ~/Code/kernel/lab7$ gcc -shared dllibexample.c -o libdllibexample.so -m32
chenxu@ ~/Code/kernel/lab7$ file libdllibexample.so 
libdllibexample.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked, BuildID[sha1]=65bac540b6848ca4ee86a27750ef8870fed3c06a, not stripped

我们看一下ELF头部的结构,这里存储了可执行文件的元数据。

typedef struct
{
        unsigned char e_ident[EI_NIDENT];     /* 魔数和相关信息 */
        Elf32_Half    e_type;                 /* 目标文件类型 */
        Elf32_Half    e_machine;              /* 硬件体系 */
        Elf32_Word    e_version;              /* 目标文件版本 */
        Elf32_Addr    e_entry;                /* 程序进入点 */
        Elf32_Off     e_phoff;                /* 程序头部偏移量 */
        Elf32_Off     e_shoff;                /* 节头部偏移量 */
        Elf32_Word    e_flags;                /* 处理器特定标志 */
        Elf32_Half    e_ehsize;               /* ELF头部长度 */
        Elf32_Half    e_phentsize;            /* 程序头部中一个条目的长度 */
        Elf32_Half    e_phnum;                /* 程序头部条目个数  */
        Elf32_Half    e_shentsize;            /* 节头部中一个条目的长度 */
        Elf32_Half    e_shnum;                /* 节头部条目个数 */
        Elf32_Half    e_shstrndx;             /* 节头部字符表索引 */
} Elf32_Ehdr;

如果使用readelf可以查看elf文件的各种信息,你可以使用-a查看全部信息,也可以使用-h只查看头部信息,我们可以对照上面的结构体分析一下刚才用来做测试的test文件的头部信息。

chenxu@ ~/Code/kernel/lab7$ readelf -h test
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 /* 魔数和相关信息 */
  Class:                             ELF32 /* 目标文件类型 */
  Data:                              2's complement, little endian 
  Version:                           1 (current) 
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386 /* 硬件体系 */
  Version:                           0x1 /* 目标文件版本 */
  Entry point address:               0x80483a0 /* 程序进入点 */
  Start of program headers:          52 (bytes into file) /* 程序头部偏移量 */
  Start of section headers:          6188 (bytes into file) /* 节头部偏移量 */
  Flags:                             0x0 /* 处理器特定标志 */
  Size of this header:               52 (bytes) /* ELF头部长度 */
  Size of program headers:           32 (bytes) /* 程序头部中一个条目的长度 */
  Number of program headers:         9 /* 程序头部条目个数  */
  Size of section headers:           40 (bytes) /* 节头部中一个条目的长度 */
  Number of section headers:         30 /* 节头部条目个数 */
  Section header string table index: 27 /* 节头部字符表索引 */

对32位x86来说,进程的可用地址空间为4G,最上面的1G是内核空间,下面3G是用户空间(从0xc0000000开始),
当程序要加载到内存中运行时,将ELF文件的段(代码段、数据段)加载到进程的地址空间,ELF文件与进程虚拟空间有一个映射关系。默认从0x8048000开始加载,刚开始的地方为头部信息,头部大小略有不同。对于启动一个刚加载过可执行文件的进程来说,开始执行的入口点即为ELF头文件中的Entry point address。

对于ELF头更加详细的分析可以参考这里

动态链接的可执行程序编译方法

我们在前面仔细分析了目标文件的格式及类型,接下来我们就看一下Linux下是如何编译和使用shared object的。

动态链接分为可执行程序装载时动态链接(共享库)和运行时动态链接(动态加载共享库)。
这里我们用孟老师做的一个小例子来演示一下。编译共享库和动态加载共享库的方法在之前介绍shared object已经说过,不再赘述。

#include <stdio.h>

#include "shlibexample.h" 

#include <dlfcn.h>

/*
 * Main program
 * input    : none
 * output   : none
 * return   : SUCCESS(0)/FAILURE(-1)
 */
int main()
{
    printf("This is a Main program!\n");
    /* Use Shared Lib */
    printf("Calling SharedLibApi() function of libshlibexample.so!\n");
    SharedLibApi();   //使用共享库里面的函数
    /* Use Dynamical Loading Lib */
    void * handle = dlopen("libdllibexample.so",RTLD_NOW); //使用动态装载库的函数
    if(handle == NULL)
    {   
        printf("Open Lib libdllibexample.so Error:%s\n",dlerror());
        return   FAILURE;
    }   
    int (*func)(void);
    char * error;
    func = dlsym(handle,"DynamicalLoadingLibApi");
    if((error = dlerror()) != NULL)
    {   
        printf("DynamicalLoadingLibApi not found:%s\n",error);
        return   FAILURE;
    }    
    printf("Calling DynamicalLoadingLibApi() function of libdllibexample.so!\n");
    func();  
    dlclose(handle);    
    return SUCCESS;
}

我们看到,main函数中调用共享库的方法如下:

  1. 加入库的头文件
  2. 代码中调用该函数
  3. 编译时加入-L/path/to/your/dir -lshlibexample
  4. 运行之前指定路径 export LD_LIBRARY_PATH=$PWD 如果库文件不在当前路径,这里换成存放库文件的路径

而调用动态加载共享库时,方法则稍微复杂一些。

  1. 加入头文件dlfcn.h
  2. 编译时加入-ldl
  3. 需要搭配以下四个函数:
    1. dlopen函数,通过库名加载动态库,该函数返回一个动态库的句柄
    2. dlsym函数,该函数接受动态库句柄以及符号名称,返回装载入内存的符号地址。
    3. dlclose函数,该函数将会减少动态库的引用计数值,如果引用计数降到0的话且没有其他的动态库使用该库的符号,该动态库则被卸载
    4. dlerror函数,该函数返回一个可读字符串,用来描述最近一次调用其余三个函数所发生的错误。如果返回NULL则表示迄今没有错误发生
  4. 运行之前指定路径 export LD_LIBRARY_PATH=$PWD 如果库文件不在当前路径,这里换成存放库文件的路径

如果对这一点感兴趣的同学,可以参考man page继续了解(man 3 dlopen)。

实验截图如下所示。
编译运行

装载和启动一个可执行程序

我们一般是通过一个Shell程序来启动一个可执行程序的,一般来说Shell先fork出一个子进程,然后会调用execve将命令行参数和环境参数传递给可执行程序的main函数,库函数exec*都是execve的封装例程。

int execve(const char * filename,char * const argv[ ],char * const envp[ ]); //这里可以加入命令行参数和环境变量,但具体是否使用环境变量和参数,取决于main函数的写法。

比如ls -l列出当前目录下的文件,Shell本身不限制命令行参数的个数,命令行参数的个数受限于命令自身,也就是我们写的main函数是否愿意接收参数

int main(int argc, char *argv[], char *envp[])

可执行程序的装载过程

我们知道,创建子进程时,我们调用fork,实际上完全复制了父进程,然后在调用exec时,要加载的可执行程序把原来的进程给覆盖掉了,原来的用户态堆栈也被清空了,命令行和环境变量会压栈。这些其实是sys_execve内核处理函数帮我们做的工作,在创建一个新的用户态堆栈时,我们是将命令行参数和环境变量通过指针传递到内核处理函数,函数在创建新的用户态堆栈时,会帮我们把这些参数压到用户态堆栈中去。对于动态链接的情形,还要稍微复杂一些。我们在下面会继续分析。

动态链接的可执行程序的装载

我们在之前做了很多的铺垫工作,现在应该深入内核看一看sys_execve是如何加载可执行文件的。我们知道,程序装载exec*其实也是一个系统调用,只是这个系统调用有点特殊,进入系统调用前和出系统调用后是两个完全不同的进程。那么sys_execve一定做了一些修改工作,比如EIP和ESP,用户态堆栈等等。废话少说,let’s RTFSC.
首先sys_execve调用了do_execve,该函数将参数和环境变量的数据结构进行修改后调用了do_execve_common。

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

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

在我们继续深入之前,我们先暂时休息一下,观察两个和被装载文件紧密关联的数据结构:
struct linux_binprm:parameter 用来在装载目标文件时存放参数,如文件名,文件信息,内存描述符信息,以及课上提到的interp(真正被执行的二进制文件的名字)

struct linux_binfmt:format 定义了一些函数指针,这些函数用来装载Linux可接受的目标文件格式,如load_binary,load_shlib,core_dump。注意:不同的格式对应了不同的结构体实例,这些结构体实例会通过注册的方式加入到一个链表中去,不同的格式采用不同的函数来进行目标文件的装载。如下图所示:

formats  -> |      lh      | -> |     lh       |
         <- |   module     | <- |    module    |
            | load_binary  |    | load_binary  |
            |   ........   |    |  ..........  |

有了这些铺垫,我们继续看,do_execve_common函数可以抽象为下面的结构:

int do_execve_common()
{
    file = do_open_exec(filename); //打开要加载的可执行文件
    ...
    各种初始化bprm
    ...
    exec_binprm(bprm); //加载程序
}

其中加载程序的exec_binprm函数中,调用了关键的search_binary_handler(bprm),该函数遍历链表来尝试加载目标文件,找到了则执行load_binary.

    list_for_each_entry(fmt, &formats, lh) {
        if (!try_module_get(fmt->module))
            continue;
        read_unlock(&binfmt_lock);
        bprm->recursion_depth++;
        retval = fmt->load_binary(bprm);
        read_lock(&binfmt_lock);
        put_binfmt(fmt);
        bprm->recursion_depth--;
        ...

这里我们知道,目标文件的格式应该是ELF,所以相应的load_binary实为load_elf_binary,该函数可以抽象如下:

load_elf_binary() 
{
    ...
    解析ELF文件
    ...
    elf_map(bprm->file, load_bias + vaddr,...) //把目标文件映射到地址空间中
    ...
    if (elf_interpreter) {把elf_entry设置为动态链接器ld的起点}
    else {目标文件的入口赋值给elf_entry}
    ...
    start_thread(..., elf_entry, ...);
}

可以看出,该函数的核心工作一是解析ELF文件,二是把目标文件映射到进程空间中,三是调用start_thread。

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;
    /*
     * force it to the iret return path by making it look as if there was
     * some work pending.
     */
    set_thread_flag(TIF_NOTIFY_RESUME);
}

start_thread实际上在修改了内核堆栈后调用iret返回用户态,把我们返回用户态的位置从int 0x80的下一条指令的位置变成新加载的可执行文件的entry位置(new_ip)。

GDB调试

分析了这么多,基本上我们基本上把sys_execve的来龙去脉搞清楚了,我们用mykernel来看一下
debug1
我们看下图可以知道,因为hello是个静态程序,start_thread中的new_ip正是我们在程序中设置的hello的入口地址。
debug2

这次就分析到这里。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值