《深入理解Linux内核》 第二十章:深入理解 Linux 程序执行机制(Program Execution)
关键词:exec 系列系统调用、可执行文件格式(ELF)、用户地址空间、内存映射、动态链接、栈初始化、入口点、共享库、内核态与用户态切换
一、概述:程序是如何被执行的?
1.1 本质
Linux 中,“程序执行”是指某个已存在的进程调用 exec()
系列系统调用后,由内核将该进程的上下文替换为另一个程序的上下文,从而在相同 PID 下运行新的程序。
与 fork()
创建新进程不同,exec()
并不创建新进程,而是替换现有进程的地址空间。
1.2 系统调用家族
int execl(const char *path, const char *arg, ..., NULL);
int execv(const char *path, char *const argv[]);
int execle(const char *path, const char *arg, ..., NULL, char *const envp[]);
int execve(const char *filename, char *const argv[], char *const envp[]);
内核中的入口点为:
SYSCALL_DEFINE3(execve, const char __user *, filename,const char __user *const __user *, argv,const char __user *const __user *, envp)
二、从用户态到内核态
2.1 用户空间的 execve()
程序调用 exec 函数族(如 execv()
)最终都会转换为 execve()
系统调用。
其传入参数包括:
- 程序路径
filename
- 命令行参数
argv
- 环境变量
envp
2.2 内核入口
系统调用处理流程:
- 用户态调用 execve;
- CPU 切换到内核态;
- 内核从系统调用表中查找
sys_execve()
; - 调用
do_execve()
→do_execveat_common()
; - 进入真正的程序替换逻辑。
三、execve 内核实现流程
程序执行的内核主干逻辑如下:
do_execveat_common()└── exec_binprm()└── search_binary_handler()└── load_elf_binary() 或其他格式
3.1 创建 binprm 结构
struct linux_binprm {char buf[BINPRM_BUF_SIZE];struct file *file;...
};
- 包含传入参数、程序路径;
- 读取前128字节,判断文件格式;
- 设置执行权限、清除信号等。
3.2 判断可执行文件格式
执行 search_binary_handler()
:
- 检查 ELF 标志(前4字节为 0x7f + ELF);
- 若是脚本(以
#!
开头),调用load_script()
; - 若是 ELF 文件,调用
load_elf_binary()
。
四、加载 ELF 可执行文件
4.1 ELF 文件结构
ELF(Executable and Linkable Format)是 Linux 下标准的可执行文件格式。
主要结构:
ELF Header
|
+-- Program Header Table (段表)
|
+-- Section Header Table (仅编译调试时使用)
|
+-- 数据段、代码段、堆、符号表等
- ELF Header:描述整个文件;
- Program Header Table:决定哪些段需要加载;
- 每个段描述:起始地址、偏移、大小、属性(可执行、可写等)。
4.2 ELF 加载流程
load_elf_binary()
实现步骤:
- 验证 ELF 魔数;
- 解析 Program Header Table;
- 使用
do_mmap()
映射段到用户地址空间; - 设置
mm->start_code
、start_data
、brk
; - 初始化用户栈;
- 设置
e_entry
(程序入口点); - 调用
start_thread()
设置寄存器(EIP / RIP); - 切换到用户态开始执行。
五、构建新用户地址空间
5.1 mm_struct 的替换
每个进程都有一个 mm_struct
:
struct mm_struct {struct vm_area_struct *mmap;struct pgd_t *pgd;...
};
当调用 execve 时,原有的地址空间会被释放(mm_release()
),新的 mm_struct
被创建并绑定。
5.2 do_mmap 的作用
调用 do_mmap()
将 ELF 段映射到用户空间:
.text
映射为可执行段;.data
映射为可写段;.bss
用于初始化堆;- 其他段如
.rodata
也被映射;
mmap 映射区域都以 vm_area_struct
记录,最终组成进程虚拟内存布局。
六、用户栈与参数传递
6.1 参数与环境变量的拷贝
exec 调用时会将 argv
与 envp
拷贝到内核缓冲区,再构建用户栈:
- 栈顶:参数数量 argc;
- 紧接着:argv 指针数组;
- 然后:envp 指针数组;
- 最后是 NULL terminator;
6.2 栈构建逻辑
setup_arg_pages()
create_elf_tables()
- 将参数字符串数据拷贝到用户栈;
- 设置
AT_PHDR
、AT_ENTRY
等 auxv; - 创建适配动态链接器的数据结构(如
ld.so
);
七、动态链接与共享库加载
7.1 动态链接器的作用
如果 ELF 是动态链接的,其 PT_INTERP
段指定了动态链接器(如 /lib/ld-linux.so.2
)。
流程:
-
加载主 ELF 文件;
-
加载动态链接器;
-
动态链接器运行在用户态,负责:
- 加载所需
.so
文件; - 执行符号解析与重定位;
- 最终跳转到
main()
函数。
- 加载所需
7.2 预加载库
环境变量:
LD_PRELOAD=/lib/myhook.so ./app
可在程序启动前注入库,进行函数劫持等操作。
八、执行权限与文件检查
8.1 权限验证
- 检查可执行文件是否有执行权限;
- 检查是否可读取;
- 判断 SUID/SGID 是否生效;
- 对脚本文件,检查
#!
指向的解释器;
8.2 setuid 程序执行注意点
当可执行文件具有 SUID 权限时:
- 进程会提升有效 UID 为目标用户;
- 内核需清除大部分内存内容,防止信息泄漏;
- 需要设置
secureexec
标志位,屏蔽某些危险变量(如LD_PRELOAD
);
九、执行结果与返回路径
9.1 执行完成前的最后一步
- 在 execve 完成所有加载后,调用
start_thread()
设置 PC/SP; - 切换到用户态入口点开始执行;
- 若失败,则返回错误码。
9.2 execve 成功永不返回
一旦 execve 成功,旧进程空间完全被新程序替换,除非失败,调用永不返回。
十、文件格式支持机制(binfmt)
Linux 支持多种可执行文件格式(ELF, a.out, scripts, Java 等):
struct linux_binfmt {int (*load_binary)(struct linux_binprm *);int (*load_shlib)(struct file *);...
};
常见格式注册:
register_binfmt(&elf_format);
register_binfmt(&script_format);
这些接口挂载在 search_binary_handler()
中被遍历查找处理程序。
十一、源码路径与调试技巧
路径 | 说明 |
---|---|
fs/exec.c | execve 核心逻辑 |
fs/binfmt_elf.c | ELF 加载实现 |
fs/binfmt_script.c | 脚本执行逻辑 |
arch/x86/kernel/process.c | 架构相关 start_thread() 实现 |
include/linux/binfmts.h | binfmt 接口定义 |
/proc/self/maps | 当前进程地址空间布局查看 |
调试工具:
strace ./a.out
:追踪 execve 执行;readelf -a ./a.out
:查看 ELF 内容;lsof -p PID
:查看进程打开的文件;gdb
:调试执行流程与链接器行为。
十二、小结
- execve 系统调用是 Linux 执行程序的核心;
- 内核根据 ELF 或脚本格式加载目标程序;
- 构建新的用户空间,包括栈、段映射、链接器加载;
- 支持 SUID、环境变量注入、安全过滤;
- 动态链接器负责完成后续 .so 加载与符号重定位;
- 内核中设计清晰,分层合理,是内核与用户交互的关键桥梁。