加载与启动:从 ELF 入口到 main

本页目录

main 是 C 运行时调用的应用入口,内核认识的是 ELF 入口地址。通常链接进程序的启动对象定义 _start,但名字本身没有魔法:链接器可以指定其他入口,不使用 libc 的程序也可以自行实现启动。

execve 成功时替换当前进程的程序映像,不创建一个新 PID,也不会返回到旧程序下一条指令。理解启动路径,首先要分清内核建立映像和用户态运行初始化代码这两个阶段。execve(2)

从 execve 到 main:控制权经过谁 内核建立新进程映像 读取 ELF program headers,映射主程序并构造初始栈 1 若有 PT_INTERP,先进入解释器 映射依赖、执行重定位,完成相应早期初始化 2 跳到主程序的 ELF 入口 通常是工具链提供的 _start,而不是 main 3 C 运行时完成启动 建立调用 main 的参数与初始化约定 4 main 返回,进入正常退出流程 运行注册的退出处理,并刷新 stdio 5 没有解释器的程序从内核直接到主入口;运行时仍可能需要初始化和自重定位。

内核把控制权交给谁

内核依据 PT_LOAD 建立主映像的映射,设置初始栈并提供参数、环境与 auxv。若存在 PT_INTERP,还加载指定的 ELF 解释器并首先进入解释器入口;否则从主映像的入口开始。入口运行时地址还要结合加载偏移,不能把 e_entry 当作文件偏移。

动态解释器通常是 glibc 的 ld-linux。它自身需要完成受限的自举及相应重定位,随后处理主程序依赖、TLS、符号解析和初始化等。它是特殊启动对象,不能因为自身是 ET_DYN 就把它描述成“还得先依赖另一份 ld.so 才能运行”。

映射并不意味着把每一页都立即读入物理内存。首次访问仍可能触发缺页;main 前后看到的缺页和 I/O 不能只归因于源代码所在阶段。具体加载实现可对照 Linux v6.18 binfmt_elf.c。

exec 保留进程身份,却不保留旧 C 程序的现场

把 exec 理解成“调用另一个 main”会误判资源与控制流。旧地址空间里的堆对象、函数指针和 atexit 注册不再是新程序的运行状态;新程序从自己的启动路径开始,不会返回旧 main。Linux 对各类属性的处理并不相同。execve 的属性变化

状态成功 exec 后对程序设计的影响
PID保持同一进程身份可经历不同程序映像
旧代码、堆、栈与映射被替换旧地址不能当作新程序可用指针传递
文件描述符通常保留,FD_CLOEXEC 的关闭需要明确哪些句柄属于交接接口
信号处理函数已捕获信号的 disposition 重置为默认旧函数地址不能继续作处理入口
信号屏蔽字保留新程序可能继承被屏蔽的信号
其他线程被销毁exec 不是让旧线程与新 main 并行
旧 atexit 注册不保留,也不由 exec 执行不能依靠它完成 exec 前的业务清理

下面用 execv 加载同一实验目录的目标程序,继承环境但重新指定 argv。保存为 exec-launch.c;成功时看不到“exec failed”,失败时旧程序通常仍能报告错误。Linux 极少数跨过映像替换关键点的失败不能恢复旧程序,不应把“失败必然返回”写成绝对保证。

#include <stdio.h>
#include <unistd.h>
int main(void) {
    char *args[] = {"chosen-name", "hello", NULL};
    printf("launcher pid=%ld\n", (long)getpid());
    if (fflush(stdout) == EOF) return 1;
    execv("./exec-target", args);
    perror("exec failed");
    return 1;
}

exec-target.c:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char **argv) {
    printf("target pid=%ld argc=%d argv0=%s arg1=%s\n",
           (long)getpid(), argc, argc > 0 ? argv[0] : "<none>",
           argc > 1 ? argv[1] : "<none>");
    return 0;
}
cc -Wall -Wextra exec-launch.c -o exec-launch
cc -Wall -Wextra exec-target.c -o exec-target
./exec-launch

两行的 PID 应相同,目标收到 argc=2、argv0=chosen-name、arg1=hello。argv[0] 由调用方设置,并不可靠地证明可执行文件的实际路径。启动器先 fflush 是为了让旧 stdio 缓冲中的输出落到文件描述符上;exec 本身不会替旧 libc 刷新输出。

初始栈不是普通函数调用栈

初始用户栈:值、指针数组与辅助向量 argc 参数数量 argv[0…argc-1],NULL 指向参数字符串 envp[0…],NULL 指向环境字符串 auxv:type/value 对,AT_NULL 内核传递的辅助信息 图示逻辑顺序;字符串、随机字节、对齐填充的位置不按方框大小表达。

_start 不是通过普通 C call 从 main 的调用者进入的,不存在一个可以随意 ret 返回的上层函数。启动代码应按目标 ABI 读取初始栈并满足调用 C 函数所需的栈对齐。

以原生 x86-64 glibc 为例,_start 准备 __libc_start_main 所需的参数:

参数System V AMD64 位置来源与作用
mainrdi应用 main 的地址
argcrsi初始栈中的参数数量
argvrdx初始栈中的参数指针数组
initrcx新版启动代码传 NULL,由 libc 查找相应初始化信息
finir8新版启动代码传 NULL,不是“第八个内部标志参数”
rtld_finir9保存加载器传来的清理函数
stack_end栈上传参初始栈相关边界信息

因此,仅抄 pop argc 和对齐 rsp,却漏掉设置 main 地址与 rcx 的汇编,不能作为正确启动示例。glibc 的 x86-64 start.S 还包含 CFI、PIC 与不同数据模型的处理;直接查看目标版本比混合几代源码更可靠。glibc start.S

初始化工作分散在加载器和 libc 中

动态程序的 TLS 等基础条件需要在相应用户代码依赖它们之前就绪,不能把全部初始化都排进 __libc_start_main 的一份伪 C 函数。主程序与依赖库的初始化也不是一个无条件的“遍历所有库,再调用 init 指针”流程。

在当前 glibc 路径中,加载器处理相应依赖与 preinit 工作;libc 启动代码再处理主程序的 DT_INIT/DT_INIT_ARRAY 等。旧启动对象可能传入初始化函数,新启动对象的 init 参数则为 NULL。依赖循环和不同对象之间的构造函数顺序,不能靠文件名或链接命令表面顺序随意假设。glibc libc-start.c

.init/.fini 通常包含指令,而 .init_array/.fini_array 是函数指针数组。C++ 非局部对象初始化和编译器 constructor 属性可以通过这些机制进入 main 前的执行阶段,但 constructor 属性及优先级属于工具链扩展,不是 ISO C 的统一规则。

auxv:内核交给运行时的辅助信息

键值的含义
AT_PHDR、AT_PHENT、AT_PHNUM主程序 program header 的运行时位置、表项大小和数量
AT_ENTRY主程序入口地址;即使先执行解释器,也仍描述主程序
AT_BASEELF 解释器的加载基址,不能当成主程序基址
AT_PAGESZ系统页大小
AT_SYSINFO_EHDR可用时的 vDSO ELF 头地址
AT_RANDOM指向 16 字节随机数据,可供运行时初始化保护值
AT_SECURE加载器是否应按安全执行条件处理环境等
AT_HWCAP、AT_HWCAP2架构相关的用户态硬件能力位

AT_RANDOM 不是控制 ASLR 开关的字段。AT_SECURE 也不仅可由 setuid 一种原因触发。读取辅助向量应优先使用 getauxval;查找不到与合法零值可通过按接口要求检查 errno 区分。getauxval(3)

初始环境指针数组后确实有 auxv,但运行中的 environ 可能经过 setenv 等操作迁移,不应总沿当前 environ 尾部继续扫描内存。读取自身 /proc/self/auxv 或调用 getauxval 更适合运行时查询。

第一条指令与 AT_ENTRY 是同一个地址吗?

把“CPU 现在跳到哪里”和“auxv 描述哪个对象”分开看。改变解释器基址会改变第一条指令的位置,却不改变 AT_ENTRY;这正是动态程序中 starti 与 main 断点落在不同阶段的原因。

正在呈现知识画面
第一条指令与 AT_ENTRY 是同一个地址吗?

有 PT_INTERP 时先进入解释器,AT_ENTRY 仍是主程序入口,AT_BASE 描述解释器加载基址。main 由用户态启动代码稍后调用。

观察 main 前后,而不是只画箭头

下面使用 GCC/Clang 的 constructor 与 destructor 扩展,观察一个主程序中的执行顺序:

#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>

__attribute__((constructor))
static void before_main(void) { puts("constructor"); }

__attribute__((destructor))
static void after_main(void) { puts("destructor"); }

static void at_exit(void) { puts("atexit"); }

int main(void)
{
    if (atexit(at_exit) != 0)
        return 1;
    printf("main: page size=%lu\n", getauxval(AT_PAGESZ));
    return 0;
}
cc -g -Wall -Wextra startup.c -o startup
./startup
readelf -hW -lW startup
readelf -dW startup
objdump -d --disassemble=_start startup
LD_SHOW_AUXV=1 ./startup

在该 glibc 示例中,constructor 先于 main;main 正常返回后会执行 atexit 回调与相应终结工作。此实验不证明不同共享库之间存在任意可依赖的全局初始化顺序。实际页大小应从输出读取,勿固定写成 4096。

正常返回 main 对应 libc 的 exit 流程,会调用退出处理并刷新 stdio。_Exit/_exit 跳过这些用户态清理,进程崩溃或被强制终止也不会保证完成它们。原始 Linux exit 系统调用与 libc 的进程退出封装还涉及线程范围差异,见 系统调用 ABI。exit(3)

相同文件描述符,不代表 stdio 缓冲会自动提交 fputs("pending", stdout) 字节暂存在用户态缓冲 return / exit 刷新缓冲,执行退出处理 _Exit / 成功 exec 不刷新旧 stdio 缓冲 文件可见 pending 本例还有 atexit 标记 缓冲字节丢失 内核接管不到未提交字节 图示只讨论用户态缓冲;文件描述符关闭与存储设备持久化是另外的机制。

将退出方式与输出缓冲分开验证

保存为 exit-mode.c。显式全缓冲让实验不依赖 stdout 是否连接终端;退出回调用标准错误输出标记,便于与普通输出分别检查。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char output_buffer[1024];
static void finish(void) { fputs("atexit ran\n", stderr); }
int main(int argc, char **argv) {
    if (setvbuf(stdout, output_buffer, _IOFBF, sizeof output_buffer) != 0)
        return 1;
    if (atexit(finish) != 0) return 1;
    fputs("pending", stdout);
    if (argc == 2 && strcmp(argv[1], "immediate") == 0)
        _Exit(7);
    return 7;
}
cc -Wall -Wextra exit-mode.c -o exit-mode
./exit-mode > normal.out 2> normal.err
./exit-mode immediate > immediate.out 2> immediate.err

两次退出状态都应为 7。正常返回时 normal.out 为 pending,normal.err 有 atexit ran;立即退出时两个文件都为空。若脚本启用了 set -e,应显式接收预期的非零状态,否则可能在第一次运行后就停止。

这说明退出码与用户态清理是两条独立信息:相同状态码不能证明执行了相同的收尾动作。正常退出刷新 stdio 也不等于数据已持久化到存储设备;需要持久化语义的程序还要按文件系统与接口约定安排同步。exit(3)、_exit(2)

静态链接仍然有启动工作

构建形式常见入口安排仍需关注
动态 PIEPT_INTERP,解释器先运行依赖映射、重定位、初始化
静态非 PIE通常无 PT_INTERP,直接主入口libc 启动、TLS、构造函数、可能的 IFUNC
静态 PIE通常无 PT_INTERP,直接主入口自重定位及运行时初始化
自定义无 libc 程序自定义入口自己遵守 ABI 与退出协议

静态链接通常从归档抽取需要的成员,不是把整个 libc 无条件塞入文件。静态程序仍可能含 GOT/PLT 相关结构或需要 IFUNC 重定位,静态 PIE 更明确需要处理位置变化。“静态就无重定位、启动必然快十倍”都不能成立;应在同功能、同环境下比较。

main 前崩溃怎样定位

先用 readelf 确认主入口、解释器与依赖,再用 gdb ./startup 的 starti 从第一条用户态指令停下;动态程序此时可能处于解释器。随后可在 main、构造函数或 __libc_start_main 设置断点。内部符号是否可见取决于调试符号和构建方式。

strace -e trace=execve,openat,mmap,mprotect ./startup 能显示加载阶段的系统调用,不能显示所有用户态初始化。若依赖已成功映射而 main 尚未到达,继续检查重定位、IFUNC、构造函数及 TLS 使用,而不只是盯着 main 第一行。