Linux x86-64 静态链接程序的运行过程

本文主要讲述在Linux x86-64中,静态链接程序(即不使用任何共享库(shared libaray)的程序)的运行过程。

序章

C/C++程序员在写程序时,总是默认程序是从main函数开始的,我们会认为这理所当然,但事实上,当程序在执行到main函数时,很多事情已经完成了。我们可以看看一下几个例子:

#include 
int a = 10;
int main(int argc, const char * argv[]) {
    printf("%d\n", argc);
    printf("%d\n", a);
    return 0;
}

在运行main函数的时候,全局变量a已经初始化完成,并且main的两个参数argcargv已经被传了进来。

#include 
__attribute((constructor)) void before_main()
{
    printf("%s\n",__FUNCTION__);
}

__attribute((destructor)) void after_main()
{
    printf("%s\n",__FUNCTION__);
}
int main(int argc, const char * argv[]) {
    return 0;
}

执行结果:

before_main
main
after_main

构造函数before_main会在main函数开始之前被调用,析构函数after_main会在main函数结束之后被调用。

而C++中,main函数之前所能执行的代码还会更多。所以,main函数既不是一个程序的开始,也不是一个程序的结束。那么,一个程序到底是怎样开始和结束的呢,main函数前后到底发生了那些事呢?这就是我们要讨论的话题。

Linux内核装载ELF过程

当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,bash进程会调用fork系统调用创建一个新的进程,然后新的进程会调用exec系统调用执行指定的ELF文件。

ELF结构

在进入exec系统调用之后会触发Linux中的sys_execve,它会进入内核为新进程的运行做一些准备工作,然后调用do_execvedo_execve会首先查找被执行的文件,然后将文件的前128个字符读入内存中,判断文件的格式。之后会调用search_binary_handler去搜索和匹配合适的可执行文件装载处理过程。所以ELF可执行文件的装载处理过程load_elf_binary被调用。

内核读取ELF的头部检查文件格式的有效性,之后寻找动态链接的.interp段,由于我们只讨论静态链接,所以没有这个段。然后内核根据ELF可执行文件的程序头表的描述,将程序的段映射到内存中。调用create_elf_tables初始化环境变量,并把参数放入栈中。最后系统调用返回的地址被修改成ELF可执行文件的入口点,对于静态链接的ELF文件,这个程序入口就是ELF文件的文件头中e_entry所知的地址。

load_elf_binary执行完毕,返回至do_execve再返回至sys_execve时,系统调用返回的地址已经被改成了被装载的ELF程序的入口地址了。所以当sys_execve从内核返回到用户态是,rsp寄存器直接跳转到了ELF程序的入口地址,于是新的程序喀什执行,ELF可执行文件装载完成。

入口函数

Linux中的glibc的子目录csu/中有关于程序启动的代码。

一般情况下,系统链接器程序ld会将编译器产生的可重定位目标文件构建成完全链接的可执行目标文件。而ld链接器的链接脚本默认地指定_start为程序的入口。

以下面这个简单的程序为例,我们来探查一下该程序编译后的入口函数:

int main() {
    return 0;
}

编译:

$ gcc main.c

查看产生的可执行文件的ELF头部信息:

$ readelf -h a.out

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  ...
  Entry point address:               0x4004d0
  ...

我们只关心它的入口地址,但是这个入口地址指的是哪里呢?查看a.out的反汇编代码:

$ objdump -d a.out

  ...
  
00000000004004d0 <_start>:
  4004d0:   31 ed                   xor    %ebp,%ebp
  4004d2:   49 89 d1                mov    %rdx,%r9
  4004d5:   5e                      pop    %rsi
  4004d6:   48 89 e2                mov    %rsp,%rdx
  4004d9:   48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
  4004dd:   50                      push   %rax
  4004de:   54                      push   %rsp
  4004df:   49 c7 c0 90 06 40 00    mov    $0x400690,%r8
  4004e6:   48 c7 c1 20 06 40 00    mov    $0x400620,%rcx
  4004ed:   48 c7 c7 04 06 40 00    mov    $0x400604,%rdi
  4004f4:   e8 a7 ff ff ff          callq  4004a0 <__libc_start_main@plt>
  4004f9:   f4                      hlt    
  4004fa:   66 0f 1f 44 00 00       nopw   0x0(%rax,%rax,1)
  
  ...

可以看到0x4004d0就是这个程序中_start的地址。xor是异或运算,ebp指向栈底,所以与自身做异或运算会把ebp设置为零,表面当前是程序的最外层函数。rdx中其实存放的是rtld_fini的函数指针,并将其存入r9popargc存入rsi中,然后将站指针指向argv,再通过mov将该地址存入rdx中。栈指针寄存器与掩码进行and运算来重置自己。接下来,我们通过查看反编译的代码,会发现0x400690__libc_csu_fini的地址,被存入r8中,0x400620__libc_csu_init的地址,被存入rcx中,0x400604main的地址,被存入rdi中。最后代码调用__libc_start_main,这个函数才是实际执行代码的函数,而之前的那些对寄存器的设置其实就是对__libc_start_main的函数参数的设置。

x86-64的_start的实现在sysdeps/x86_86/Start.S中,我们其中的参数与相应地址和寄存器匹配:

    main:       %rdi <-- 0x400604
    argc:       %rsi <-- [RSP]
    argv:       %rdx <-- [RSP + 0x8]
    init:       %rcx <-- 0x400620
    fini:       %r8  <-- 0x400690
    rtld_fini:  %r9 <-- rdx on entry
    stack_end:  stack <-- rsp

__libc_start_main

csu/libc-start.c中定义了__libc_start_main函数:

int LIBC_START_MAIN
    (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
    int argc,
    char **argv,
    __typeof (main) init,
    void (*fini) (void),
    void (*rtld_fini) (void),
    void *stack_end)

这里可以看到其实main函数有三个参数,除了argcargv外还有一个环境变量表,这个环境表参数是由exec传入的。事实上,三个参数的main函数的出现只是历史遗留问题,我们可以直接使用全局变量environ来获取这个环境表的数组指针。

__libc_start_main函数的具体实现中关于函数指针参数的使用顺序是:

1. __cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);
2. __cxa_atexit ((void (*) (void *)) fini, NULL, NULL);
3. (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM);
4. result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);
5. exit (result);

__cxa_atexit函数是glibc的内部函数,用于将注册的函数在main结束之后,即被exit函数调用,注册顺序与调用顺序相反。在静态链接程序中__cxa_atexit(func, NULL, NULL)atexit(func)作用相同。所以,函数的执行顺序为init -> main -> fini -> rtld_fini。我们甚至可以猜出这些函数的作用:

  • initmain调用前的初始化工作。
  • finimain结束后的收尾工作。
  • rtld_fini:和动态加载有关的收尾工作,rtldruntime loader的缩写。

我们再回过头看看之前的反汇编代码,就会发现init就是代码中的__libc_csu_init,而fini__libc_csu_fini。至于rtld_fini的函数指针,是通过rdx寄存器传入_start中的。

所以__libc_start_main函数进行了如下的操作:

  1. 确定环境变量在栈的何处。
  2. 如果需要,准备辅助向量(auxiliary vector)。
  3. 初始化线程相关的功能(比如pthread,TLS等)。
  4. 记录某些安全相关的信息(这不是一个独立的步骤,而是遍及整个函数)。
  5. 初始化libc
  6. 为执行的退出函数注册finirtld_fini
  7. 通过传入的指针(init)调用程序初始化函数。
  8. 调用main (argc, argv, envp)
  9. main的结果作为退出码调用exit

exit相关

__libc_start_main函数的实现可以看出,程序在执行完main函数后都会执行exit函数(具体实现在stdlib/exit.c中)。所以,在main函数中返回一个整型数值与在main末尾用该值调用exit函数是等价的。exit会执行通过atexit注册过的函数,然后调用_exit来直接结束进程。进程正常结束有两种情况:

  1. main正常返回,由__libc_start_main来调用exit函数。
  2. 程序中直接使用exit退出。

这样一来,进程在__libc_start_main函数末尾直接通过exit结束,所以在main函数中,我们可以不用return

\\ main1.c
int main() {

}
\\ main2.c
int main() {
    return 0;
}

我们先编译这两个文件并运行,然后通过echo $?来打印终止状态。查看结果会得到main1.c的终止状态是一个随机值,而main2.c的终止状态是0。为了进一步了解其中的差异,我们再使用objdump分别进行反汇编,对比汇编代码,我们会发现在main函数的汇编代码里,main2.c多了一个操作:

    mov    $0x0, %eax

所以,return语句只是为eax寄存器赋值,而终止状态就存在这个寄存器中,在main1.c中,由于没有return语句,所以最终由eax寄存器得到的值是一个默认值。

c99规定,如果main函数最后没有使用return语句,那么编译器要在生成的目标文件中自动加入return 0C++编译器也同样如此。

还有一个问题,之前提到的反汇编代码中为什么在调用__libc_start_main后还会有一个hlt操作呢?

事实上,在Linux里,进程必须使用exit系统调用结束。一旦exit被调用,程序的运行就会终止,因此__libc_start_main永远不会返回,所以_start末尾的hlt不会执行。但是如果__libc_start_main没有调用exit,即函数返回了,那么hlt指令就可以发挥作用强行把程序给停下来。

补充

在本文开头提到的在main函数前执行的那些特殊代码,会由链接器负责插入到__libc_csu_init函数中,然后由它调用。__libc_csu_fini也是如此。然而这两个函数在程序以_start为入口时会被调用,但是我们将程序入口换成其他函数,会发生什么呢?

我们先看个例子:

#include 
#include 
int foo() {
    printf("%s\n",__FUNCTION__);
    return 0;
}

void bar() {
    printf("%s\n",__FUNCTION__);
    exit(0);
}

__attribute((constructor)) void before_main()
{
    printf("%s\n",__FUNCTION__);
}

__attribute((destructor)) void after_main()
{
    printf("%s\n",__FUNCTION__);
}

int main() {
    printf("%s\n",__FUNCTION__);
}

强行把foo函数换成程序的入口,并读取a.out的ELF文件头部,然后进行反编译:

$  gcc -e foo main.c
$  readelf -h a.out

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
...
  Entry point address:               0x4005d7
...

$  objdump -d a.out

...
00000000004005d7 :
...

此时foo函数已经代替_start成为程序入口,执行程序:

$  ./a.out

foo
Segmentation fault (core dumped)

可以看到,没有了_start函数,也就不会执行__libc_start_main,所以构造函数和析构函数并没有被执行,而且由于程序没有用exit来结束,所以产生了一个段错误。

再将程序入口换为bar

$  gcc -e bar main.c
$  ./a.out

bar

程序成功运行,而且成功退出,但是同样没有调用构造函数和析构函数。至此,我们就明白了main函数执行前的初始化工作和结束后的收尾工作到底是谁在执行了。

结尾

最后,我们通过一张图来总结一下Linux中一个C/C++程序的运行过程:

Linux x86-64 静态链接程序的运行过程_第1张图片
C程序的开始和结束

内核通过exec运行一个进程,在C start-up routine中,系统会自动调用一些初始化函数,再执行main函数,然后通过调用exit,先执行那些通过atexit注册的函数,再进行一些收尾工作,最后使用_exit结束进程。


要想进一步了解,可以阅读以下资料:

  1. How statically linked programs run on Linux
  2. Linux x86 Program Start Up
  3. 《深入理解计算机系统》
  4. 《程序员的自我修养》
  5. 《UNIX环境高级编程》

你可能感兴趣的:(Linux x86-64 静态链接程序的运行过程)