找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2385

积分

0

好友

323

主题
发表于 3 小时前 | 查看: 4| 回复: 0

虽然我们经常谈论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.ocrti.ocrtbegin.ocrtend.ocrtn.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的约定,它执行以下操作:

  1. ebp寄存器清零(xorl %ebp,%ebp)。
  2. rdx寄存器的值(包含终止函数的地址)移到r9寄存器(mov %RDX_LP,%R9_LP)。
  3. 从栈中弹出argcrsi寄存器(popq %rsi)。
  4. 将栈指针(此时指向argv数组)存入rdx寄存器(mov %RSP_LP,%RDX_LP)。
  5. 将栈对齐到16字节边界(and $~15,%RSP_LP),并先后压入raxrsp
  6. 将构造函数(__libc_csu_init)、析构函数(__libc_csu_fini)的地址和main函数的地址分别放入rcxr8rdi寄存器。
  7. 最后,调用__libc_start_main

那么,程序启动时栈的布局是怎样的呢?当_start被调用时,栈顶布局大致如下(高地址在下):

+-----------------+
|       argc      |
+-----------------+
|       argv      | <- rsp 指向这里
+-----------------+
|       NULL      |
+-----------------+
|       envp      |
+-----------------+
|       NULL      |
+-----------------+

_start通过popq %rsi获取argc,然后将此时的rsp(指向argv)存入rdx。这样,rsirdx就分别准备好了__libc_start_main函数所需的argcargv参数。

__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.ocrti.o而忘记crtn.o,程序会因为缺少函数返回指令而导致段错误。

完整的启动链条

现在,让我们梳理一下从内核到main函数的完整调用链:

  1. 内核:通过execve系统调用加载ELF二进制文件,准备内存映像,并通过start_thread开始执行用户空间代码。入口地址被设置为ELF头中指定的地址,默认为_start
  2. 启动代码 (_start): 在/lib64/crt1.o中定义。它从栈中获取argcargv,进行栈对齐等准备工作,然后设置参数调用__libc_start_main
  3. 库初始化 (__libc_start_main): 在Glibc中定义。它负责全面的运行时环境初始化,包括设置线程本地存储、安全特性、调用.init段的构造函数等。
  4. 用户程序 (main): 最后,我们编写的main函数被调用。
  5. 程序结束main函数返回后,__libc_start_main会调用.fini段的析构函数,然后以main的返回值作为参数调用exit(),结束整个进程。

所以,main函数确实是“我们代码”的起点,但在它之前,系统已经为我们搭建好了完整的运行时舞台。理解_startmain的这段幕后旅程,有助于我们更深入地洞悉Linux程序的生命周期,在调试复杂问题或进行底层系统编程时,这份认知会显得尤为宝贵。

在云栈社区,我们经常探讨此类系统底层原理,它们构成了我们构建稳定高效应用的基石。




上一篇:从零开始:基于VMware与Ubuntu的Linux驱动/ARM汇编开发环境搭建
下一篇:Linux内核贡献指南:从源码编译到提交第一个补丁
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-17 09:36 , Processed in 0.475806 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表