用户空间的程序启动过程
最后更新于
虽然 linux-insides-zh 大多描述的是内核相关的东西,但是我已经决定写一个大多与用户空间相关的部分。
章节的已经描述了当我们想运行一个程序, Linux 内核的行为。这部分我想研究一下从用户空间的角度,当我们在 Linux 系统上运行一个程序,会发生什么。
我不知道你知识储备如何,但是在我的大学时期我学到,一个 C
程序从一个叫做 main 的函数开始执行。而且,这是部分正确的。每时每刻,当我们开始写一个新的程序时,我们从下面的实例代码开始编程:
但是你如何对于底层编程感兴趣的话,可能你已经知道 main
函数并不是程序的真正入口。如果你在调试器中看了下面这个简单程序,就可以很确信这一点:
让我们来编译并且在 中运行这个程序:
让我们在 gdb 中执行 info files
这个指令。这个指令会打印关于被不同段占据的内存和调试目标的信息。
注意 Entry point: 0x400430
这一行。现在我们知道我们程序入口点的真正地址。让我们在这个地址下一个断点,然后运行我们的程序,看看会发生什么:
有趣。我们并没有看见 main
函数的执行,但是我们看见另外一个函数被调用。这个函数是 _start
而且根据调试器展现给我们看的,它是我们程序的真正入口。那么,这个函数是从哪里来的,又是谁调用了这个 main
函数,什么时候调用的。我会在后续部分尝试回答这些问题。
首先,让我们来看一下下面这个简单的 C
程序:
我们可以确定这个程序按照我们预期那样工作。让我们来编译它:
并且执行:
The exec() family of functions replaces the current process image with a new process image.
这就是所有内核方面的内容。Linux 内核为执行准备二进制镜像,而且它的执行从上下文切换开始,结束之后将控制权返回用户空间。但是它并不能回答像 _start
来自哪里这样的问题。让我们在下一段尝试回答这些问题。
在之前的段落汇总,我们看到了内核是如何为可执行文件运行做准备工作的。让我们从用户空间来看这相同的工作。我们已经知道一个程序的入口点是 _start
函数。但是这个函数是从哪里来的呢?它可能来自于一个库。但是如果你记得清楚的话,我们在程序编译过程中并没有链接任何库。
首先,使用 gcc
编译我们的程序:
cc1
编译器将编译我们的 C
代码并且生成 /tmp/ccvUWZkF.s
汇编文件。之后我们可以看见我们的汇编文件被 GNU as
编译器编译为目标文件:
最后我们的目标文件会被 collect2
链接到一起:
是的,我们可以看见一个很长的命令行选项列表被传递给链接器。让我们从另一条路行进。我们知道我们的程序都依赖标准库。
从那里我们会用一些库函数,像 printf
。但是不止如此。这就是为什么当我们给编译器传递 -nostdlib
参数,我们会收到错误报告:
除了这些错误,我们还看见 _start
符号未定义。所以现在我们可以确定 _start
函数来自于标准库。但是即使我们链接标准库,它也无法成功编译:
好的,当我们使用 /usr/lib64/libc.so.6
链接我们的程序,编译器并不报告标准库函数的未定义引用,但是 _start
符号仍然未被解析。让我们重新回到 gcc
的冗长输出,看看 collect2
的参数。我们现在最重要的问题是我们的程序不仅链接了标准库,还有一些目标文件。第一个目标文件是 /lib64/crt1.o
。而且,如果我们使用 objdump
工具去看这个目标文件的内部,我们将看见 _start
符号:
之后,将终止函数的地址放到 r9
寄存器中:
After the dynamic linker has built the process image and performed the relocations, each shared object gets the opportunity to execute some initialization code. ... Similarly, shared objects may have termination functions, which are executed with the atexit (BA_OS) mechanism after the base process begins its termination sequence.
所以我们需要把终止函数的地址放到 r9
寄存器,因为将来它会被当作第六个参数传递给 __libc_start_main
。注意,终止函数的地址初始是存储在 rdx
寄存器。除了 %rdx
和 %rsp
之外的其他寄存器保存未确定的值。_start
函数中真正的重点是调用 __libc_start_main
。所以下一步就是为调用这个函数做准备。
我们可以从栈上获取我们所需的 __libc_start_main
的所有参数。当 _start
被调用的时候,我们的栈如下所示:
当我们清零了 ebp
寄存器,并且将终止函数的地址保存到 r9
寄存器中之后,我们取出栈顶元素,放到 rsi
寄存器中。最终 rsp
指向 argv
数组,rsi
保存传递给程序的命令行参数的数目:
这之后,我们将 argv
数组的地址赋值给 rdx
寄存器中。
在我们查看 __libc_start_main
函数之前,让我们添加 /lib64/crt1.o
文件并且再次尝试编译我们的程序:
After the dynamic linker has built the process image and performed the relocations, each shared object gets the opportunity to execute some initialization code. ... Similarly, shared objects may have termination functions, which are executed with the atexit (BA_OS) mechanism after the base process begins its termination sequence.
所以链接器除了一般的段,如 .text
, .data
之外创建了两个特殊的段:
.init
.fini
We can find it with readelf
util:
我们可以通过 readelf
工具找到它们:
你可能可以从这些函数的名字推测,这两个会在 main
函数之前和之后被调用。.init
和 .fini
段的定义在 /lib64/crti.o
中。如果我们添加这个目标文件:
我们不会收到任何错误报告。但是让我们尝试去运行我们的程序,看看发生什么:
是的,我们收到 segmentation fault
。让我们通过 objdump
看看 lib64/crti.o
的内容:
它包含 .init
段的定义,而且汇编代码设置 16 字节的对齐。之后,如果它不是零,我们调用 PREINIT_FUNCTION
;否则不调用:
如果我们把它加到编译过程中,我们的程序会被成功编译和运行。
现在让我们回到 _start
函数,以及尝试去浏览 main
函数被调用之前的完整调用链。
_start
总是被默认的 ld
脚本链接到程序 .text
段的起始位置:
结束
好的,直到现在所有事情看起来听挺好。你可能已经知道一个特殊的家族 - 系统调用。正如我们从帮助手册中读到的:
如果你已经阅读过章节的,你可能就知道 execve 这个系统调用定义在 文件中,并且如下所示,
它以可执行文件的名字,命令行参数的集合以及环境变量的集合作为参数。正如你猜测的,每一件事都是 do_execve
函数完成的。在这里我将不描述这个函数的实现细节,因为你可以从读到。但是,简而言之,do_execve
函数会检查诸如文件名是否有效,未超出进程数目限制等等。在这些检查之后,这个函数会解析 格式的可执行文件,为新的可执行文件创建内存描述符,并且在栈,堆等内存区域填上适当的值。当二进制镜像设置完成,start_thread
函数会设置一个新的进程。这个函数是框架相关的,而且对于 框架,它的定义是在 文件中。
start_thread
为设置新的值。从这一点开始,新进程已经准备就绪。一旦完成,控制权就会返回到用户空间,并且新的可执行文件将会执行。
你可能会猜 _start
来自于。是的,确实是这样。如果你尝试去重新编译我们的程序,并给 gcc 传递可以开启 verbose mode
的 -v
选项,你会看到下面的长输出。我们并不对整体输出感兴趣,让我们来看一下下面的步骤:
因为 crt1.o
是一个共享目标文件,所以我们只看到桩而不是真正的函数调用。让我们来看一下 _start
函数的源码。因为这个函数是框架相关的,所以 _start
的实现是在 这个汇编文件中。
_start
始于对 ebp
寄存器的清零,正如 所建议的。
正如 标准所述,
__libc_start_main
的实现是在 文件中。让我们来看一下这个函数:
It takes address of the main
function of a program, argc
and argv
. init
and fini
functions are constructor and destructor of the program. The rtld_fini
is termination function which will be called after the program will be exited to terminate and free dynamic section. The last parameter of the __libc_start_main
is the pointer to the stack of the program. Before we can call the __libc_start_main
function, all of these parameters must be prepared and passed to it. Let's return to the assembly file and continue to see what happens before the __libc_start_main
function will be called from there.
该函数以程序 main
函数的地址,argc
和 argv
作为输入。init
和 fini
函数分别是程序的构造函数和析构函数。rtld_fini
是当程序退出时调用的终止函数,用来终止以及释放动态段。__libc_start_main
函数的最后一个参数是一个指向程序栈的指针。在我们调用 __libc_start_main
函数之前,所有的参数都要被准备好,并且传递给它。让我们返回 这个文件,继续看在 __libc_start_main
被调用之前发生了什么。
从这一时刻开始,我们已经有了 argc
和 argv
。我们仍要将构造函数和析构函数的指针放到合适的寄存器,以及传递指向栈的指针。下面汇编代码的前三行按照 中的建议设置栈为 16
字节对齐,并将 rax
压栈:
栈对齐之后,我们压栈栈的地址,并且将构造函数和析构函数的地址放到 r8
和 rcx
寄存器中,同时将 main
函数的地址放到 rdi
寄存器中。从这个时刻开始,我们可以调用 中的 __libc_start_main
函数。
现在我们看见了另外一个错误 - 未找到 __libc_csu_fini
和 __libc_csu_init
。我们知道这两个函数的地址被传递给 __libc_start_main
作为参数,同时这两个函数还是我们程序的构造函数和析构函数。但是在 C
程序中,构造函数和析构函数意味着什么呢?我们已经在 标准中看到:
这两个将被替换为二进制镜像的开始和结尾,包含分别被称为构造函数和析构函数的例程。这些例程的要点是在程序的真正代码执行之前,做一些初始化/终结,像全局变量如 ,为系统例程分配和释放内存等等。
正如上面所写的, /lib64/crti.o
目标文件包含 .init
和 .fini
段的定义,但是我们可以看见这个函数的桩。让我们看一下 文件中的源码:
where the PREINIT_FUNCTION
is the __gmon_start__
which does setup for profiling. You may note that we have no return instruction in the . Actually that's why we got segmentation fault. Prolog of _init
and _fini
is placed in the assembly file:
其中,PREINIT_FUNCTION
是设置简况的 __gmon_start__
。你可能发现,在 中,我们没有 return
指令。事实上,这就是我们获得 segmentation fault
的原因。_init
和 _fini
的序言被放在 汇编文件中:
_start
函数定义在 汇编文件中,并且在 __libc_start_main
被调用之前做一些准备工作,像从栈上获取 argc/argv
,栈准备等。来自于 文件中的 __libc_start_main
函数注册构造函数和析构函数,开启线程,做一些安全相关的操作,比如在有需要的情况下设置 stack canary
,调用初始化,最后调用程序的 main
函数以及返回结果退出。而构造函数和析构函数分别是 main
之前和之后被调用。