虽然我们经常谈论Linux内核内部机制,但这次让我们把目光转向用户空间,探究一个基础却又充满细节的过程:当你在Linux系统上运行一个程序时,究竟发生了什么?
可能很多人的第一反应是,C语言程序不是从main函数开始执行的吗?没错,这是我们大学时期学到的知识。每当开始编写一个新程序,我们总是从这样的代码框架入手:
int main(int argc, char* argv[]){
// Entry point is here
}
然而,如果你对底层编程有浓厚的兴趣,或许已经察觉到,main函数并非程序真正的“第一行代码”。这一点,通过一个简单的调试实验就能轻松验证。
从调试器中发现端倪
考虑下面这个极简的C程序:
int main(int argc, char* argv[]){
return 0;
}
让我们编译它并用GDB调试:
$ gcc -ggdb program.c -o program
$ gdb ./program
The target architecture is assumed to be i386:x86-64:intel
Reading symbols from ./program...done.
在GDB中执行info files命令,这个命令会显示可执行文件各段的内存布局和调试目标信息。
(gdb) info files
Symbols from "/home/alex/program".
Local exec file:
`/home/alex/program', file type elf64-x86-64.
Entry point: 0x400430
0x0000000000400238 - 0x0000000000400254 is .interp
0x0000000000400254 - 0x0000000000400274 is .note.ABI-tag
... (其他段信息)
注意Entry point: 0x400430这一行。它明确指出了我们程序入口点的真实地址。让我们在这个地址设置一个断点,然后运行程序看看会发生什么。
(gdb) break *0x400430
Breakpoint 1 at 0x400430
(gdb) run
Starting program: /home/alex/program
Breakpoint 1, 0x0000000000400430 in _start ()
有趣的现象出现了:我们并没有直接进入main函数,而是停在了另一个名为_start的函数里。根据调试器的显示,_start才是我们程序真正的入口点。
那么问题来了:这个_start函数从何而来?又是谁,在何时调用了我们熟悉的main函数?接下来,我们将尝试回答这些问题。
内核如何运行一个新程序
首先,我们来看一个稍复杂的C程序示例:
// program.c
#include <stdlib.h>
#include <stdio.h>
static int x = 1;
int y = 2;
int main(int argc, char* argv[]){
int z = 3;
printf("x + y + z = %d\n", x + y + z);
return EXIT_SUCCESS;
}
编译并运行,它工作正常:
$ gcc -Wall program.c -o sum
$ ./sum
x + y + z = 6
我们知道,有一个特殊的系统调用家族——exec*系列。正如其手册页所述:exec()系列函数会用一个新的进程镜像替换当前进程镜像。
如果你了解Linux内核,就会知道execve系统调用最终会调用do_execve函数。这个函数负责检查文件名有效性、进程数限制等,然后解析ELF格式的可执行文件,为新的可执行文件创建内存描述符,并设置栈、堆等内存区域。
当二进制镜像设置完成后,start_thread函数会初始化这个新进程。这个函数是体系结构相关的。对于x86_64架构,它定义在arch/x86/kernel/process_64.c中。start_thread会设置新的段寄存器值。至此,新进程已准备就绪。一旦发生进程上下文切换,控制权就会返回用户空间,新的可执行文件开始执行。
这就是内核方面所做的工作:它为执行准备二进制镜像,通过上下文切换将控制权交给用户空间。但它并没有解释_start的来源。让我们在下一节从用户空间的角度寻找答案。
用户空间视角的程序启动
从上一节我们知道,用户空间的程序入口点是_start。那么这个函数来自哪里?可能来自某个库。但回想一下,我们在编译示例程序时并没有显式链接任何库:
$ gcc -Wall program.c -o sum
你可能会猜_start来自标准库。这个直觉是正确的。如果你用-v(verbose)选项重新编译程序,会看到一长串输出。我们只关注其中几个关键步骤。
首先,gcc驱动编译器(cc1)将C代码编译成汇编文件(/tmp/ccvUWZkF.s)。
然后,GNU汇编器(as)将汇编文件编译成目标文件(/tmp/cc79wZSU.o)。
最后,链接器(通过collect2调用)将所有部分链接在一起:
/usr/libexec/gcc/x86_64-redhat-linux/6.1.1/collect2 ... -o test /usr/lib/gcc/x86_64-redhat-linux/6.1.1/../../../../lib64/crt1.o /usr/lib/gcc/x86_64-redhat-linux/6.1.1/../../../../lib64/crti.o /usr/lib/gcc/x86_64-redhat-linux/6.1.1/crtbegin.o ... /tmp/cc79wZSU.o -lgcc ... -lc ... /usr/lib/gcc/x86_64-redhat-linux/6.1.1/crtend.o /usr/lib/gcc/x86_64-redhat-linux/6.1.1/../../../../lib64/crtn.o
注意看,链接命令中不仅包含了我们的目标文件(/tmp/cc79wZSU.o)和C库(-lc),还链接了几个额外的目标文件:crt1.o、crti.o、crtbegin.o、crtend.o、crtn.o。
我们的程序依赖于标准库,这可以通过ldd命令验证:
$ ldd program
linux-vdso.so.1 (0x00007ffc9afd2000)
libc.so.6 => /lib64/libc.so.6(0x00007f56b389b000)
/lib64/ld-linux-x86-64.so.2(0x0000556198231000)
如果我们尝试用-nostdlib选项禁止链接标准库,会得到错误提示:
$ gcc -nostdlib program.c -o program
/usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 000000000040017c
/tmp/cc02msGW.o: In function `main':
/home/alex/program.c:11: undefined reference to `printf'
collect2: error: ld returned 1 exit status
除了printf未定义的错误,链接器还警告找不到_start符号。这证实了_start确实来自标准库的“启动文件”。
剖析 crt1.o
第一个被链接的额外目标文件是/lib64/crt1.o。使用objdump查看其内部,我们就能找到_start符号:
$ objdump -d /lib64/crt1.o
/lib64/crt1.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_start>:
0: 31 ed xor %ebp,%ebp
2: 49 89 d1 mov %rdx,%r9
5: 5e pop %rsi
6: 48 89 e2 mov %rsp,%rdx
9: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
d: 50 push %rax
e: 54 push %rsp
f: 49 c7 c0 00 00 00 00 mov $0x0,%r8
16: 48 c7 c1 00 00 00 00 mov $0x0,%rcx
1d: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
24: e8 00 00 00 00 callq 29 <_start+0x29>
29: f4 hlt
由于crt1.o是共享目标文件,我们只看到桩代码(stub)而不是真正的函数调用。_start函数的完整实现是体系结构相关的,对于x86_64,它位于Glibc源码的sysdeps/x86_64/start.S汇编文件中。
_start 函数做了什么?
_start函数的任务是为调用__libc_start_main做准备。根据System V ABI的约定,它执行以下操作:
- 将
ebp寄存器清零(xorl %ebp,%ebp)。
- 将
rdx寄存器的值(包含终止函数的地址)移到r9寄存器(mov %RDX_LP,%R9_LP)。
- 从栈中弹出
argc到rsi寄存器(popq %rsi)。
- 将栈指针(此时指向
argv数组)存入rdx寄存器(mov %RSP_LP,%RDX_LP)。
- 将栈对齐到16字节边界(
and $~15,%RSP_LP),并先后压入rax和rsp。
- 将构造函数(
__libc_csu_init)、析构函数(__libc_csu_fini)的地址和main函数的地址分别放入rcx、r8和rdi寄存器。
- 最后,调用
__libc_start_main。
那么,程序启动时栈的布局是怎样的呢?当_start被调用时,栈顶布局大致如下(高地址在下):
+-----------------+
| argc |
+-----------------+
| argv | <- rsp 指向这里
+-----------------+
| NULL |
+-----------------+
| envp |
+-----------------+
| NULL |
+-----------------+
_start通过popq %rsi获取argc,然后将此时的rsp(指向argv)存入rdx。这样,rsi和rdx就分别准备好了__libc_start_main函数所需的argc和argv参数。
__libc_start_main 的核心角色
__libc_start_main函数定义在Glibc的csu/libc-start.c中。它的函数原型大致如下:
int __libc_start_main (int (*main) (int, char**, char**),
int argc,
char** argv,
__typeof (main) init,
void (*fini) (void),
void (*rtld_fini) (void),
void *stack_end);
它接收以下参数:
main: 程序的main函数地址。
argc, argv: 命令行参数计数和数组。
init: 程序的构造函数(在main之前运行)。
fini: 程序的析构函数(在main之后运行)。
rtld_fini: 动态链接器的终止函数。
stack_end: 指向栈末尾的指针。
这个函数是用户空间程序启动的“总指挥”,它的职责包括:
- 注册构造函数(
init)和析构函数(fini)。
- 初始化线程子系统。
- 执行一些安全相关的设置,例如设置栈溢出保护金丝雀(stack canary)。
- 调用初始化函数(包括
init)。
- 最终,调用我们的
main(argc, argv, __environ)。
- 使用
main的返回值调用exit()结束程序。
构造函数与析构函数:.init 和 .fini
那么,传递给__libc_start_main的构造函数和析构函数是什么呢?根据ELF标准,共享对象(以及可执行文件)可以拥有初始化和终止例程。链接器会创建两个特殊的段来容纳它们:
.init: 存放初始化代码,在main函数之前执行。
.fini: 存放终止代码,在main函数之后执行。
我们可以用readelf工具在可执行文件中找到它们:
$ readelf -e program | grep init
[11] .init PROGBITS 00000000004003c8 000003c8
$ readelf -e program | grep fini
[15] .fini PROGBITS 00000000004005e4 000005e4
这两个段的实际代码定义在/lib64/crti.o(包含序言prolog)和/lib64/crtn.o(包含收尾epilog)目标文件中。这就是为什么如果我们只链接crt1.o和crti.o而忘记crtn.o,程序会因为缺少函数返回指令而导致段错误。
完整的启动链条
现在,让我们梳理一下从内核到main函数的完整调用链:
- 内核:通过
execve系统调用加载ELF二进制文件,准备内存映像,并通过start_thread开始执行用户空间代码。入口地址被设置为ELF头中指定的地址,默认为_start。
- 启动代码 (
_start): 在/lib64/crt1.o中定义。它从栈中获取argc和argv,进行栈对齐等准备工作,然后设置参数调用__libc_start_main。
- 库初始化 (
__libc_start_main): 在Glibc中定义。它负责全面的运行时环境初始化,包括设置线程本地存储、安全特性、调用.init段的构造函数等。
- 用户程序 (
main): 最后,我们编写的main函数被调用。
- 程序结束:
main函数返回后,__libc_start_main会调用.fini段的析构函数,然后以main的返回值作为参数调用exit(),结束整个进程。
所以,main函数确实是“我们代码”的起点,但在它之前,系统已经为我们搭建好了完整的运行时舞台。理解_start到main的这段幕后旅程,有助于我们更深入地洞悉Linux程序的生命周期,在调试复杂问题或进行底层系统编程时,这份认知会显得尤为宝贵。
在云栈社区,我们经常探讨此类系统底层原理,它们构成了我们构建稳定高效应用的基石。