找回密码
立即注册
搜索
发回帖 发新帖

5350

积分

0

好友

690

主题
发表于 1 小时前 | 查看: 7| 回复: 0

从 Shell 到内核,再回到用户态,串起程序启动的完整路径。

你以为输入 ./hello 后,程序就从 main() 开始运行了?其实,在它之前,内核和运行库已经忙了一轮。

前面讲了进程如何创建,也认识了 ELF 文件。现在把它们接起来:在终端输入 ./hello,Shell 怎样启动它?内核什么时候把控制权交给程序?为什么还没进入 main(),就可能报“找不到共享库”?

这次先看一张加载全景图,再沿图中的三个区域,拆解程序走到 main 之前发生的事。

本文阅读路线

加载全景 → Shell 与 execve → 内核加载 ELF → 动态链接与启动代码 → 动手验证。

范围:Linux、带 MMU 的常见环境,以动态链接的 glibc C 程序为例;内核函数名对照 Linux 6.1,启动代码对照 glibc 2.36 的 x86-64 实现。其他版本、架构与 C 库的细节可能不同。

01 · 一张图看懂进程加载

Linux进程加载机制全景图:Shell与execve、内核加载ELF、用户态走向main完整流程

图:上部区分父子进程,中部展开内核加载,下部串起动态链接器、_start 与 main。以带 PT_INTERP 的 glibc C 程序为例;PID 为示意,省略错误分支。

先沿粗箭头从上往下看,再回头读旁边的 ELF 与用户栈。图中的三个区域各回答一个问题:

图中区域 抓住的问题
01 · Shell 与 execve 谁创建子进程,谁换成新程序?
02 · 内核加载 ELF 文件怎样变成新的地址空间与执行现场?
03 · 用户态启动 动态链接器、_start 怎样把执行带到 main?

看图时先分清三份工作:内核建立新程序的执行环境;动态链接器装入依赖并处理链接;程序的启动代码与运行库把执行流程带到 main。

02 · 看图上半部:fork 创建,execve 装入

对应图中 01 区,先看典型的交互式 Shell:它创建一个子进程,让子进程执行外部命令,自己则等待前台命令结束。可以把主线理解成 fork + execve + wait。

子进程刚创建时还延续着 Shell 的程序映像。调用 execve() 成功后,它才换成 hello 的代码、数据和用户栈。创建进程和装入程序,是两个动作。

实际 Shell 可能使用 clone、vfork 或其他优化;内建命令也不必启动外部程序。这里讲的是执行普通外部命令的典型模型。

execve 成功后,原调用点不会继续执行。 进程开始运行新的程序映像;只有调用返回失败时,旧程序才有机会检查返回值与 errno。普通单线程场景中,前后 PID 保持不变。

语义参考 execve(2)。极少数错误发生在旧映像已拆除之后,此时内核可能终止进程,而无法返回旧程序。

03 · 进入内核:execve 带上哪些信息?

/* 接口声明;不是内核实现 */
int execve(const char *path,
           char *const argv[],
           char *const envp[]);

path 指向要执行的文件;argv 是参数字符串指针数组,约定 argv[0] 表示程序名;envp 是环境变量数组,常见形式为 KEY=value。两个数组都以空指针结尾。execve 本身不按 PATH 搜索文件。

内核检查文件的执行条件、读取开头内容,并准备参数和环境。随后根据文件格式寻找处理器:ELF 使用 ELF 加载器,带 #! 的脚本则需要解释器处理。本文继续沿 ELF 路径往下走。

对照 Linux 6.1 的 fs/exec.c,可以按下面的入口阅读。这里只保留主要调用关系,省略包装函数、检查和错误分支:

execve 系统调用
  → do_execve → do_execveat_common
  → bprm_execve → exec_binprm
  → search_binary_handler
  → load_elf_binary(ELF 格式处理器)

先抓住 linux_binprm:它是本次执行过程的工作对象,携带待执行文件、文件头缓冲区、参数数量等信息,把通用执行流程与格式加载器连接起来。

04 · 看图中部:内核如何加载 ELF?

对应图中 02 区,上一篇认识的程序头表,现在派上用场。Linux 6.1 的 load_elf_binary() 会检查 ELF 头和程序头表,并在检查、资源准备与映像替换之间逐步推进。理解时抓住四件事即可:

  1. 确认文件可装载。 核对格式、架构等信息,读取程序头;存在 PT_INTERP 时,打开指定解释器并检查它。
  2. 切换到新地址空间。 在提交执行的阶段替换旧用户映像,旧程序的代码、数据和用户栈不再延续。
  3. 建立主程序和解释器的映射。 按 PT_LOAD 设置虚拟地址范围、文件偏移和权限,并处理内存中需要清零的尾部。
  4. 准备用户态起点。 布置初始用户栈,设置入口地址与栈指针,让后续执行进入新程序的启动路径。

这里的“加载”主要是建立虚拟内存映射。文件映射中的很多页面,要等程序实际访问时才通过缺页处理提供;不必先把整个可执行文件读入物理内存。

源码定位: 普通 ELF 执行的主入口是 load_elf_binary()。同一文件中的 load_elf_library() 服务于旧式 uselib 路径;动态依赖库通常由用户态动态链接器装入。

初始用户栈上有什么?

除了参数和环境字符串,还有 argc、argv/envp 指针数组,以及辅助向量(auxv)。辅助向量把内核已知的启动信息交给用户态,例如主程序入口、程序头位置和页大小。

初始用户栈内容(概念示意,非精确地址布局)
    argc
    argv 指针数组 + NULL
    envp 指针数组 + NULL
    辅助向量 auxv(以 AT_NULL 结束)
    参数、环境字符串及其他启动数据

Linux 6.1 中可结合 create_elf_tables() 查看这部分。不要把“为新程序建立了栈”理解成“已经调用了 main”:此时用户态启动代码还没完成。

05 · 看图下半部:从动态链接器到 main

对应图中 03 区,如果 ELF 有 PT_INTERP,内核会映射它指定的解释器,并把用户态执行起点设在解释器入口。在常见 x86-64 glibc 环境中,路径常形如 /lib64/ld-linux-x86-64.so.2,以 readelf 输出为准。

这个解释器就是动态链接器。它读取动态链接信息,查找并映射所需共享库,处理必要的符号解析和重定位,然后把控制权交给主程序入口。启用延迟绑定时,部分函数符号的解析会推迟到首次调用。

这里必须分清两个入口:e_entry 描述主 ELF 的入口;存在解释器时,内核最先进入的是解释器入口。对于 PIE,主程序入口的运行时地址还需结合加载基址计算。

进入主程序常见的 _start 后,启动代码整理参数,调用 glibc 的 __libc_start_main,由运行库启动流程完成相应初始化并调用 main。程序构造函数等也在 main 之前执行;具体分工因链接方式和版本而异。

动态链接的 glibc C 程序(简化)
内核设置用户态起点
  → 动态链接器入口
  → 主程序 _start
  → __libc_start_main 等运行库启动流程
  → main(argc, argv)
  → main 返回后进入运行库退出流程

没有 PT_INTERP 的普通静态可执行文件,内核直接选择程序自身入口,仍通常需要启动代码再调用 main。静态 PIE 等形式还可能有自重定位步骤。

参考 ld.so(8)、glibc 2.36 x86-64 的 _start 与 libc-start.c。

06 · 动手看:文件、系统调用与内存映射

把下面代码保存为本目录的 hello.c。它打印身份信息后等待 30 秒,方便观察。需要 Linux、GCC、binutils 和 strace;示例面向动态链接的 glibc 环境。

#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    printf("pid=%ld, argc=%d, argv[0]=%s\n",
           (long)getpid(), argc, argv[0]);
    fflush(stdout);
    sleep(30);
    return 0;
}
gcc -Wall -Wextra -O0 -g -fPIE -pie hello.c -o /tmp/exec_hello
readelf -h /tmp/exec_hello
readelf -W -l /tmp/exec_hello
nm -n /tmp/exec_hello | rg '_start|main'
strace -o /tmp/exec_hello.trace -e trace=execve,openat,mmap /tmp/exec_hello demo

没有 rg 时,可用 grep -E '_start|main' 替代。readelf 的 -h 给出主程序入口,-l 中的 INTERP 给出解释器,LOAD 给出装载范围;nm 能看到 _start 和 main 是不同符号。PIE 的文件内数值不能直接当作运行时绝对地址。

strace 的程序也会等待 30 秒;结束后查看记录:

cat /tmp/exec_hello.trace

先找到 execve("/tmp/exec_hello", ...),再观察后续打开共享库、建立映射的调用。execve 内部由内核建立的主程序与解释器映射,不会额外显示为该进程发起的 mmap 系统调用。 后面看到的 mmap 则是新用户态程序路径发起的调用,其中包括动态链接器的工作。

再单独后台运行一次,在它等待期间查看映射:

/tmp/exec_hello demo &
hello_pid=$!
cat /proc/"$hello_pid"/maps
readlink /proc/"$hello_pid"/exe
wait "$hello_pid"

maps 中通常能同时看到主程序、libc 和动态链接器;主程序会按权限拆成多个映射,不能用“一行等于一个 ELF 文件”来理解。ASLR 会使不同运行的地址变化。观察这些文件需要相应的 /proc 访问权限。

再验证:exec 前后 PID 是否相同?

下面让一个 Bash 进程先报告 PID,再通过 Shell 内建命令 exec 替换自己。比较两次打印的数字:

bash -c 'printf "before exec: pid=%s\n" "$$"; exec /tmp/exec_hello demo'

你会看到 before exec 与 hello 打印的 pid 相同。这直接说明:exec 替换的是这个进程正在运行的程序。

07 · 启动失败时,先判断卡在哪一层

现象 优先检查
Permission denied 执行权限、路径目录权限、挂载点是否 noexec
文件存在,却提示 No such file or directory 除目标路径外,检查 PT_INTERP 指定的解释器是否存在;脚本则检查 #! 路径
Exec format error 文件格式、架构是否受支持
error while loading shared libraries 通常已进入动态链接器;检查依赖库和库搜索路径

“程序没启动起来”可能发生在不同阶段。先用 readelf 确认解释器,再用 strace 看 execve 是否成功、后续打开了哪些路径,通常比只盯着 main 更有效。

写在最后

fork 解决“创建谁”,execve 解决“让它运行什么”。

内核建立新映像 → 动态链接器准备依赖 → 启动代码与运行库把流程带到 main。

回到这张图,只需沿着“Shell → 内核 → 用户态启动”再走一遍,就能把进程创建、ELF 文件和 main 串成一条完整的链路。后续再沿着其中的“建立映射”往下看:程序如何使用虚拟地址,虚拟内存究竟怎样关联到物理内存?

LINUX KERNEL · 从一张图开始理解内核 · Linux 6.1 / glibc 2.36 源码对照 · 环境差异以实际构建为准




上一篇:Linux进程创建机制图解:从fork到copy_process的完整路径
下一篇:MySQL 慢查询优化实录:慢日志、执行计划与复合索引改造闭环
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-10-23 12:37 , Processed in 0.073382 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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