Linux 执行可执行文件的底层原理分析

First Post:

Last Update:

Word Count:
6.3k

Read Time:
28 min

声明:本实验纯粹出于兴趣,高度参考该视频,作者非OS专业研究者,文章内容仅供参考,不保证完全准确。如有错误,欢迎指正交流。

概述

程序加载与执行是操作系统最核心的功能之一。当用户在shell中输入命令时,操作系统需要完成一系列复杂操作:读取可执行文件、解析文件格式、分配内存空间、建立虚拟地址映射、设置执行环境,最终将控制权转移至用户程序。这一过程涉及文件系统、内存管理、进程调度等多个子系统的协同工作,是理解操作系统运行机制的重要切入点。

本实验通过动态调试方法,深度剖析Linux内核执行ELF格式可执行文件的完整流程。实验基于自编译的Linux内核,使用GDB跟踪从execve系统调用到用户程序首条指令执行的全过程,观察关键数据结构变化,分析汇编代码执行逻辑,揭示操作系统程序加载的底层机制。

实验目的

在Linux系统中,执行./demo即可运行一个ELF可执行文件,但这一简单操作的背后,操作系统底层究竟发生了什么?本实验旨在回答以下问题:

  1. 文件定位与加载:操作系统如何根据路径找到可执行文件并将其加载到内存?
  2. 格式识别与解析:内核如何识别ELF文件格式并解析其结构?
  3. 执行环境初始化:系统如何设置寄存器状态和内存布局,为程序执行做准备?
  4. 控制权转移:内核如何从系统调用返回并跳转到用户程序入口?

通过GDB动态调试内核执行流程,本实验将深入剖析从execve系统调用到用户程序首条指令执行的完整过程,揭示Linux程序加载机制的底层原理。

实验设计

技术路线

本实验采用动态内核调试方法,通过在关键执行路径设置断点,跟踪程序执行流程,观察数据结构变化。实验核心思路是编写一个最小化的测试程序,利用GDB从用户态execve系统调用入手,逐步深入内核,通过源码分析、汇编代码追踪和寄存器状态检查,完整还原OS对ELF文件的处理流程。

为便于调试分析,实验采用自编译的定制化Linux内核,关闭地址空间布局随机化(KASLR)等安全特性,启用完整的内核调试支持。使用QEMU虚拟机提供隔离的实验环境,通过GDB远程调试协议实现对内核的精确控制。

系统调用机制分析

execve系统调用是程序执行的入口点,其C标准库接口定义如下:

1
int execve(const char *pathname, char *const argv[], char *const envp[]);

其中,pathname指定要执行的可执行文件路径,argv是传递给新程序的参数数组,envp是环境变量数组。当用户程序调用这个函数时,控制流程会通过系统调用机制转入内核态,内核负责完成实际的程序加载和执行工作。

当用户程序调用该函数时,控制流通过系统调用机制切换至内核态,由内核完成实际的程序加载工作。

使用strace可观察execve的执行细节,以whoami为例:

1
2
3
4
% strace -e execve whoami
execve("/usr/bin/whoami", ["whoami"], 0x7ffe8dc557d0 /* 92 vars */) = 0
username
+++ exited with 0 +++

输出显示了完整的系统调用参数:可执行文件路径、参数数组以及包含92个条目的环境变量数组。

实验环境说明

QEMU

QEMU是一个功能强大的开源虚拟化和硬件仿真平台,支持多种CPU架构。本实验利用QEMU的GDB调试功能,通过-s参数在1234端口开启GDB远程调试服务器,实现对虚拟机内核的实时调试。

GDB

GDB(GNU Debugger)是Linux平台强大的调试工具。通过远程调试协议连接到QEMU,可实现对内核代码的精确控制。配合内核编译时生成的符号表(vmlinux),GDB能够将汇编指令映射到源代码,实现源码级内核调试,极大提升调试效率。

init与initramfs

Linux启动过程中,内核完成硬件初始化后,需要启动第一个用户态进程——init进程(PID=1)。init进程负责用户空间初始化,包括挂载文件系统、启动系统服务等。

initramfs(initial RAM filesystem)是Linux启动早期阶段的临时根文件系统。本实验构建了仅包含bash(作为init进程)和测试程序的极简initramfs,排除其他干扰因素,便于专注于程序加载机制的分析。

实验流程

编译 Linux 内核

为便于调试分析,需要编译定制化的Linux内核,关闭影响调试的安全特性(如KASLR)和无关功能,启用完整的调试支持。实验使用Linux 6.18-rc5版本,从源代码构建。

禁用功能

  • Randomize the address of the kernel image (KASLR)
  • Virtualization
  • Enable loadable module support
  • Networking support

启用功能

  • Debug information > Rely on the toolchain’s implicit default DWARF version
  • Provide GDB scripts for kernel debugging
  • KGDB: kernel debugger

配置完成后执行编译:

1
2
make -j16
make scripts_gdb

编译生成两个关键文件:

  • vmlinux:包含完整调试符号的未压缩内核镜像,供GDB加载符号表使用
  • bzImage:压缩的可启动内核镜像,供QEMU加载运行

编译 bash

本实验使用静态编译的bash 5.3作为init进程,静态链接可避免动态库依赖,便于分析ELF加载过程。

1
2
./configure --enable-static-link
make -j16

验证编译结果:

1
2
3
4
% file bash
bash: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, with debug_info, not stripped
% ldd bash
not a dynamic executable

输出确认bash已静态链接,无动态库依赖。

编写测试程序

为便于后续调试,编写一个调用系统调用的最小化测试程序。该程序使用标准库的write函数向标准输出写入字符串。

write函数手册说明摘录:

NAME
write - write to a file descriptor

SYNOPSIS
#include <unistd.h>
ssize_t write(int fd, const void buf[.count], size_t count);

DESCRIPTION
write() writes up to count bytes from the buffer starting at buf to the file referred to by the file descriptor fd.

源文件demo.c(退出状态码设为0xcc以便反汇编识别):

1
2
3
4
5
6
7
#include <unistd.h>

void main()
{
write(1, "hello world!\n", 13);
_exit(0xcc);
}

编译并测试:

1
gcc -static demo.c -o demo

验证:

1
2
3
4
5
6
7
8
9
10
% ./demo          
hello world!
% ldd demo
not a dynamic executable
% file demo
demo: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, not stripped
% strace -e execve ./demo
execve("./demo", ["./demo"], 0x7ffdfd177ad0 /* 92 vars */) = 0
hello world!
+++ exited with 204 +++

程序成功执行,退出码204(0xcc)符合预期。

构建 initramfs

使用cpio工具创建包含bash和测试程序的initramfs镜像:

1
echo "init\ndemo" | cpio -o -H newc > init.cpio

此命令将init(bash)和demo打包为newc格式的cpio归档,作为初始RAM文件系统。

启动 QEMU

使用以下命令启动虚拟机:

1
2
3
qemu-system-x86_64 -enable-kvm -cpu host \
-kernel ./linux-6.18-rc5/arch/x86/boot/bzImage \
-initrd init.cpio -s

参数说明:

  • -enable-kvm -cpu host:启用KVM加速,使用宿主机CPU特性
  • -kernel:指定内核镜像
  • -initrd:指定initramfs镜像
  • -s:在1234端口开启GDB调试服务器

系统成功启动后进入bash命令行,执行./demo可验证测试程序正常运行:

qemu

建立调试连接

vmlinux文件包含完整的符号表和调试信息,是内核级调试的基础。与压缩的bzImage不同,vmlinux保留了所有函数名、变量名、源码行号等信息,使GDB能够实现源码级调试。

启动GDB并连接到QEMU:

1
2
3
4
5
gdb vmlinux
(gdb) target remote :1234
Remote debugging using :1234
0xffffffff81d244ef in pv_native_safe_halt () at arch/x86/kernel/paravirt.c:82
82 }

连接成功后,可见内核当前正在执行pv_native_safe_halt函数,该函数用于CPU空闲时的暂停执行(HLT指令)。

调试与分析

系统调用入口点分析

execve系统调用的实现

通过分析Linux内核源代码fs/exec.c,可以找到execve系统调用的定义:

1
2
3
4
5
6
7
SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
return do_execve(getname(filename), argv, envp);
}

SYSCALL_DEFINE3并非函数,而是一个宏,表示这是一个接受3个参数的系统调用。该宏会自动生成必要的包装代码,处理用户态到内核态的参数传递、权限检查、错误处理等。宏展开后生成实际的系统调用函数__x64_sys_execve及相关的参数验证和类型转换代码。

Linux内核定义了一系列SYSCALL_DEFINE宏以支持不同参数数量的系统调用:

1
2
3
#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

开始调试

do_execve处设置断点,在QEMU中执行测试程序,观察断点命中情况:

1
2
3
4
5
6
7
8
(gdb) b do_execve
Breakpoint 1 at 0xffffffff81502706: do_execve. (2 locations)
(gdb) c
Continuing.

Breakpoint 1.1, do_execve (filename=0xffff888003373000, __argv=0x1f5d0fd0,
__envp=0x1f5cc010) at fs/exec.c:1934
1934 return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);

单步跟踪进入do_execveat_common函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(gdb) s
do_execveat_common (fd=fd@entry=-100, filename=0xffff888003373000, flags=0,
envp=..., argv=...) at ./include/linux/err.h:70
70 return IS_ERR_VALUE((unsigned long)ptr);
(gdb) n
do_execveat_common (fd=fd@entry=-100, filename=0xffff888003373000, flags=0,
envp=..., argv=...) at fs/exec.c:1801
1801 if ((current->flags & PF_NPROC_EXCEEDED) &&
(gdb)
do_execveat_common (fd=fd@entry=-100, filename=0xffff888003373000, flags=0,
envp=..., argv=...) at ./arch/x86/include/asm/current.h:23
23 return this_cpu_read_const(const_current_task);
(gdb)
1811 bprm = alloc_bprm(fd, filename, flags);

可发现该函数进行了一系列检查,包括进程数量限制验证,并为程序分配linux_binprm结构体。该结构体存储可执行文件的相关信息,在GDB中查看其内容:

1
2
3
4
5
6
7
8
9
10
(gdb) p *bprm
$1 = {vma = 0xffff888003d960c0, vma_pages = 0, argmin = 0,
mm = 0xffff888003050b80, p = 140737488351224, have_execfd = 0,
execfd_creds = 0, secureexec = 0, point_of_no_return = 0, comm_from_dentry = 0,
is_check = 0, executable = 0x0, interpreter = 0x0, file = 0xffff88800332e180,
cred = 0x0, unsafe = 0, per_clear = 0, argc = 1, envc = 0,
filename = 0xffff888003373020 "/demo", interp = 0xffff888003373020 "/demo",
fdpath = 0x0, interp_flags = 0, execfd = 0, exec = 0, rlim_stack = {
rlim_cur = 8388608, rlim_max = 18446744073709551615},
buf = '\000' <repeats 255 times>}

接下来的代码进行bprm参数准备工作,包括设置栈空间限制、复制环境变量和参数数组等。完成准备后,进入bprm_execve函数开始实际的程序加载流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(gdb) n
1823 if (retval < 0)
(gdb)
1825 bprm->envc = retval;
(gdb)
1827 retval = bprm_stack_limits(bprm);
(gdb)
1828 if (retval < 0)
(gdb)
1831 retval = copy_string_kernel(bprm->filename, bprm);
(gdb)
1832 if (retval < 0)
(gdb)
1834 bprm->exec = bprm->p;
(gdb)
1836 retval = copy_strings(bprm->envc, envp, bprm);
(gdb)
1837 if (retval < 0)
(gdb)
1840 retval = copy_strings(bprm->argc, argv, bprm);
(gdb)
1841 if (retval < 0)
(gdb)
1850 if (bprm->argc == 0) {
(gdb)
1860 retval = bprm_execve(bprm);
(gdb) s

进入bprm_execve函数后,完成安全凭证准备、执行环境检查和调度优化后,在1754行调用exec_binprm开始实际的二进制文件加载,下面继续进入exec_binprm方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
(gdb) advance bprm_execve
bprm_execve (bprm=0xffff888003d80000) at fs/exec.c:1731
1731 {
(gdb) s
1734 retval = prepare_bprm_creds(bprm);
(gdb) list
1729
1730 static int bprm_execve(struct linux_binprm *bprm)
1731 {
1732 int retval;
1733
1734 retval = prepare_bprm_creds(bprm);
1735 if (retval)
1736 return retval;
1737
1738 /*
(gdb)
1739 * Check for unsafe execution states before exec_binprm(), which
1740 * will call back into begin_new_exec(), into bprm_creds_from_file(),
1741 * where setuid-ness is evaluated.
1742 */
1743 check_unsafe_exec(bprm);
1744 current->in_execve = 1;
1745 sched_mm_cid_before_execve(current);
1746
1747 sched_exec();
1748
(gdb)
1749 /* Set the unchanging part of bprm->cred */
1750 retval = security_bprm_creds_for_exec(bprm);
1751 if (retval || bprm->is_check)
1752 goto out;
1753
1754 retval = exec_binprm(bprm);
1755 if (retval < 0)
1756 goto out;

exec_binprm函数中,关键是1702行的search_binary_handler函数,用于确定文件的处理方式。深入该函数分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(gdb) advance 1754
exec_binprm (bprm=0xffff888003d93000) at fs/exec.c:1691
1691 old_pid = current->pid;
(gdb) list
1686 {
1687 pid_t old_pid, old_vpid;
1688 int ret, depth;
1689
1690 /* Need to fetch pid before load_binary changes it */
1691 old_pid = current->pid;
1692 rcu_read_lock();
1693 old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
1694 rcu_read_unlock();
1695
(gdb)
1696 /* This allows 4 levels of binfmt rewrites before failing hard. */
1697 for (depth = 0;; depth++) {
1698 struct file *exec;
1699 if (depth > 5)
1700 return -ELOOP;
1701
1702 ret = search_binary_handler(bprm);

search_binary_handler函数中,核心逻辑是list_for_each_entry遍历,该函数遍历所有已注册的二进制格式处理器(如ELF、脚本等),尝试使用每个处理器加载可执行文件,直到成功或所有处理器均失败。在1670行设置断点观察遍历过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
(gdb) advance search_binary_handler
search_binary_handler (bprm=0xffff888003d93000) at fs/exec.c:1656
1656 retval = prepare_binprm(bprm);
(gdb) list
1651 static int search_binary_handler(struct linux_binprm *bprm)
1652 {
1653 struct linux_binfmt *fmt;
1654 int retval;
1655
1656 retval = prepare_binprm(bprm);
1657 if (retval < 0)
1658 return retval;
1659
1660 retval = security_bprm_check(bprm);
(gdb)
1661 if (retval)
1662 return retval;
1663
1664 read_lock(&binfmt_lock);
1665 list_for_each_entry(fmt, &formats, lh) {
1666 if (!try_module_get(fmt->module))
1667 continue;
1668 read_unlock(&binfmt_lock);
1669
1670 retval = fmt->load_binary(bprm);
(gdb)
1671
1672 read_lock(&binfmt_lock);
1673 put_binfmt(fmt);
1674 if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
1675 read_unlock(&binfmt_lock);
1676 return retval;
1677 }
1678 }
1679 read_unlock(&binfmt_lock);
1680
(gdb)
1681 return -ENOEXEC;
1682 }

通过以下输出可观察到遍历过程依次尝试了misc_formatscript_formatelf_format三种格式,当匹配到elf_format时步入进一步查看具体逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(gdb) b 1670
Breakpoint 2 at 0xffffffff8150025f: file fs/exec.c, line 1670.
(gdb) c
Continuing.

Breakpoint 2, search_binary_handler (bprm=0xffff888003d93000) at fs/exec.c:1670
1670 retval = fmt->load_binary(bprm);
(gdb) p fmt
$1 = (struct linux_binfmt *) 0xffffffff82579da0 <misc_format>
(gdb) c
Continuing.

Breakpoint 2, search_binary_handler (bprm=0xffff888003d93000) at fs/exec.c:1670
1670 retval = fmt->load_binary(bprm);
(gdb) p fmt
$2 = (struct linux_binfmt *) 0xffffffff82579e40 <script_format>
(gdb) c
Continuing.

Breakpoint 2, search_binary_handler (bprm=0xffff888003d93000) at fs/exec.c:1670
1670 retval = fmt->load_binary(bprm);
(gdb) p fmt
$3 = (struct linux_binfmt *) 0xffffffff82579ea0 <elf_format>

ELF文件加载器

fmtelf_format时,步入load_binary,可以发现先后进入了GEN-for-each-reg.hretpoline.S,这是内核的间接跳转保护机制,用于防御Spectre v2等CPU侧信道攻击。

load_binary最终调用了load_elf_binary函数加载可执行文件。在857行可见使用memcmp检测文件魔数是否匹配ELFMAG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Breakpoint 2, search_binary_handler (bprm=0xffff888003d93000) at fs/exec.c:1670
1670 retval = fmt->load_binary(bprm);
(gdb) s
__x86_indirect_thunk_array () at ./arch/x86/include/asm/GEN-for-each-reg.h:6
6 GEN(rax)
(gdb) s
srso_alias_return_thunk () at arch/x86/lib/retpoline.S:220
220 call srso_alias_safe_ret
(gdb) s
srso_alias_safe_ret () at arch/x86/lib/retpoline.S:210
210 lea 8(%_ASM_SP), %_ASM_SP
(gdb) n
srso_alias_safe_ret () at arch/x86/lib/retpoline.S:213
213 ret
(gdb) n
load_elf_binary (bprm=0xffff888003d93000) at fs/binfmt_elf.c:833
833 {
(gdb) list
828
829 return ret == -ENOENT ? 0 : ret;
830 }
831
832 static int load_elf_binary(struct linux_binprm *bprm)
833 {
...
857 if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
(gdb)
858 goto out;
859
860 if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
861 goto out;
862 if (!elf_check_arch(elf_ex))
863 goto out;
864 if (elf_check_fdpic(elf_ex))
865 goto out;

elf_ex->e_ident字段是ELF文件的标识信息,为16字节数组,位于ELF头部开始位置。该字段在load_elf_binary函数开始时通过读取文件头获得。内核先读取文件前几百字节到缓冲区,再将其解释为ELF头部结构。

通过GDB查看e_ident内容:

1
2
(gdb) p elf_ex.e_ident
$7 = "\177ELF\002\001\001\003\000\000\000\000\000\000\000"

ELF验证流程包括:魔数检查(\x7fELF)、文件类型检查(ET_EXEC/ET_DYN)、架构兼容性验证(elf_check_arch)和mmap支持检查,代码过长此处略去。经过数百行ELF解析逻辑后,在1379行可见最终的线程准备逻辑。START_THREAD是调用start_thread的宏,此处实际设置用户态寄存器,并非启动线程:

1
2
3
4
1377
1378 finalize_exec(bprm);
1379 START_THREAD(elf_ex, regs, elf_entry, bprm->p);
1380 retval = 0;

用户态执行环境初始化

寄存器状态设置

ELF文件解析完成后,内核需要设置CPU寄存器状态,为用户程序执行做准备。这一过程通过start_thread函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
(gdb) advance 1379
load_elf_binary (bprm=<optimized out>) at fs/binfmt_elf.c:1379
1379 START_THREAD(elf_ex, regs, elf_entry, bprm->p);
(gdb) s
start_thread (regs=regs@entry=0xffffc90000173f58, new_ip=new_ip@entry=4204480,
new_sp=140725702259520) at arch/x86/kernel/process_64.c:584
584 {
(gdb) s
585 start_thread_common(regs, new_ip, new_sp,
(gdb) s
start_thread_common (regs=regs@entry=0xffffc90000173f58,
new_ip=new_ip@entry=4204480, new_sp=140725702259520, _cs=_cs@entry=51,
_ds=_ds@entry=0, _ss=43) at arch/x86/kernel/process_64.c:535
535 WARN_ON_ONCE(regs != current_pt_regs());
(gdb) list
530 static void
531 start_thread_common(struct pt_regs *regs, unsigned long new_ip,
532 unsigned long new_sp,
533 u16 _cs, u16 _ss, u16 _ds)
534 {
535 WARN_ON_ONCE(regs != current_pt_regs());
536
537 if (static_cpu_has(X86_BUG_NULL_SEG)) {
538 /* Loading zero below won't clear the base. */
539 loadsegment(fs, __USER_DS);
540 load_gs_index(__USER_DS);
541 }
542
543 reset_thread_features();
544
545 loadsegment(fs, 0);
546 loadsegment(es, _ds);
547 loadsegment(ds, _ds);
548 load_gs_index(0);
549
550 regs->ip = new_ip;
551 regs->sp = new_sp;
552 regs->csx = _cs;
553 regs->ssx = _ss;

在550-553行,代码设置了四个关键寄存器:

  • ip:指令指针寄存器(RIP)
  • sp:栈指针寄存器(RSP)
  • csx:代码段选择器(CS)
  • ssx:栈段选择器(SS)

通过跳转到551行,可以打印IP寄存器的值,该值指向程序入口地址:

1
2
3
4
5
6
7
8
9
(gdb) advance 551
start_thread_common (regs=regs@entry=0xffffc90000173f58,
new_ip=new_ip@entry=4204480, new_sp=140725702259520, _cs=_cs@entry=51,
_ds=_ds@entry=0, _ss=43) at arch/x86/kernel/process_64.c:551
551 regs->sp = new_sp;
(gdb) p regs.ip
$8 = 4204480
(gdb) p/x regs.ip
$9 = 0x4027c0

使用GDB独立检查demo程序的入口地址,发现与IP寄存器值一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
% gdb demo                                  
GNU gdb (Gentoo 16.3 vanilla) 16.3

(gdb) info file
Symbols from "/home/username/Project/LINUX/demo".
Local exec file:
`/home/username/Project/LINUX/demo', file type elf64-x86-64.
Entry point: 0x4027c0
0x0000000000400270 - 0x0000000000400468 is .rela.plt
0x0000000000401000 - 0x000000000040101b is .init
0x0000000000401020 - 0x0000000000401170 is .plt
0x0000000000401180 - 0x000000000046c4f1 is .text
0x000000000046c4f4 - 0x000000000046c501 is .fini
0x000000000046d000 - 0x0000000000488b44 is .rodata

至此,已完整分析了从execve系统调用到ELF文件识别、加载,最终设置用户态寄存器的全过程。内核已完成程序加载任务,并将IP寄存器指向程序入口,接下来将返回用户态开始执行用户程序。下面使用finish命令逐层退出调用栈,追踪系统调用返回路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
(gdb) finish
Run till exit from #0 start_thread_common (regs=regs@entry=0xffffc90000173f58,
new_ip=new_ip@entry=4204480, new_sp=140725702259520, _cs=_cs@entry=51,
_ds=_ds@entry=0, _ss=43) at arch/x86/kernel/process_64.c:551
load_elf_binary (bprm=<optimized out>) at fs/binfmt_elf.c:1382
1382 return retval;
(gdb)
Run till exit from #0 load_elf_binary (bprm=<optimized out>)
at fs/binfmt_elf.c:1382
0xffffffff8150026b in search_binary_handler (bprm=0xffff888003d93000)
at fs/exec.c:1672
1672 read_lock(&binfmt_lock);
Value returned is $10 = 0
(gdb)
Run till exit from #0 0xffffffff8150026b in search_binary_handler (
bprm=0xffff888003d93000) at fs/exec.c:1672
exec_binprm (bprm=0xffff888003d93000) at fs/exec.c:1703
1703 if (ret < 0)
(gdb)
Run till exit from #0 exec_binprm (bprm=0xffff888003d93000) at fs/exec.c:1703
bprm_execve (bprm=0xffff888003d93000) at fs/exec.c:1754
1754 retval = exec_binprm(bprm);
(gdb)
Run till exit from #0 bprm_execve (bprm=0xffff888003d93000) at fs/exec.c:1754
bprm_execve (bprm=0xffff888003d93000) at fs/exec.c:1781
1781 return retval;
(gdb)
Run till exit from #0 bprm_execve (bprm=0xffff888003d93000) at fs/exec.c:1781
0xffffffff8150173c in do_execveat_common (fd=fd@entry=-100,
filename=0xffff888003373000, flags=0, envp=..., argv=...) at fs/exec.c:1860
1860 retval = bprm_execve(bprm);
Value returned is $11 = 0
(gdb)
Run till exit from #0 0xffffffff8150173c in do_execveat_common (
fd=fd@entry=-100, filename=0xffff888003373000, flags=0, envp=..., argv=...)
at fs/exec.c:1860
__x64_sys_execve (regs=<optimized out>) at fs/exec.c:2005
2005 SYSCALL_DEFINE3(execve,
Value returned is $12 = 0
(gdb) list
2000 return;
2001
2002 __mm_flags_set_mask_dumpable(mm, value);
2003 }
2004
2005 SYSCALL_DEFINE3(execve,
2006 const char __user *, filename,
2007 const char __user *const __user *, argv,
2008 const char __user *const __user *, envp)
2009 {
(gdb)
2010 return do_execve(getname(filename), argv, envp);
2011 }

在退回SYSCALL_DEFINE3后使用next命令继续观察命令跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb) 
Run till exit from #0 0xffffffff8150173c in do_execveat_common (
fd=fd@entry=-100, filename=0xffff888003373000, flags=0, envp=..., argv=...)
at fs/exec.c:1860
__x64_sys_execve (regs=<optimized out>) at fs/exec.c:2005
2005 SYSCALL_DEFINE3(execve,
Value returned is $25 = 0
(gdb)
Run till exit from #0 __x64_sys_execve (regs=<optimized out>) at fs/exec.c:2005
0xffffffff81d202e4 in do_syscall_x64 (regs=0xffffc90000173f58, nr=<optimized out>)
at arch/x86/entry/syscall_64.c:63
63 regs->ax = x64_sys_call(regs, unr);
Value returned is $26 = 0

返回SYSCALL_DEFINE3后继续追踪,在100行可见系统调用返回用户态的逻辑。汇编函数common_interrupt_return的659行有iretq指令,该指令用于从中断处理程序返回用户态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
(gdb) n
do_syscall_64 (regs=0xffffc90000173f58, nr=<optimized out>)
at arch/x86/entry/syscall_64.c:99
99 instrumentation_end();
(gdb)
100 syscall_exit_to_user_mode(regs);
(gdb)
113 if (unlikely(regs->cx != regs->ip || regs->r11 != regs->flags))
(gdb)
srso_alias_return_thunk () at arch/x86/lib/retpoline.S:220
220 call srso_alias_safe_ret
(gdb) s
srso_alias_safe_ret () at arch/x86/lib/retpoline.S:210
210 lea 8(%_ASM_SP), %_ASM_SP
(gdb) n
srso_alias_safe_ret () at arch/x86/lib/retpoline.S:213
213 ret
(gdb)
entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:130
130 ALTERNATIVE "testb %al, %al; jz swapgs_restore_regs_and_return_to_usermode", \
Value returned is $5 = false
(gdb)
common_interrupt_return () at arch/x86/entry/entry_64.S:561
561 IBRS_EXIT
(gdb)
570 POP_REGS
(gdb)
common_interrupt_return () at arch/x86/entry/entry_64.S:571
571 add $8, %rsp /* orig_ax */
(gdb)
common_interrupt_return () at arch/x86/entry/entry_64.S:575
575 swapgs
(gdb)
578 testb $3, 8(%rsp)
(gdb)
579 jnz .Lnative_iret
(gdb)
647 testb $4, (SS-RIP)(%rsp)
(gdb)
648 jnz native_irq_return_ldt
(gdb)
659 iretq

按需分页机制

虚拟内存的延迟加载

系统调用返回用户态后,CPU跳转到程序入口地址执行。然而,此时会遇到无法读取指令的问题:

1
2
3
4
5
659             iretq
(gdb)
0x00000000004027c0 in ?? ()
(gdb) x/i $rip
=> 0x4027c0: Cannot access memory at address 0x4027c0

指令指针寄存器虽指向正确的程序入口地址0x4027c0,却无法访问该地址内容。这是因为现代操作系统采用了按需分页(Demand Paging)技术。

在程序加载过程中,内核仅建立了虚拟地址到文件的映射关系,并未将程序代码实际加载到物理内存。当CPU首次访问某虚拟地址时,若对应物理页面不存在,会触发缺页异常(Page Fault)。

缺页中断处理机制

操作系统通过缺页中断在检测到缺页时自动从磁盘加载相关数据。在页错误处理函数设置断点,可观察该机制的运作:

1
2
3
4
5
(gdb) b asm_exc_page_fault
Breakpoint 4 at 0xffffffff81001280: file ./arch/x86/include/asm/idtentry.h, line 618.
(gdb) ni

Breakpoint 4, asm_exc_page_fault () at ./arch/x86/include/asm/idtentry.h, line 618.

CPU尝试执行位于0x4027c0的指令时,由于该地址对应的物理页面未分配,硬件自动触发页错误中断。内核页错误处理程序执行以下步骤:

  1. 权限验证:检查进程是否有权访问该虚拟地址
  2. 物理页面分配:从物理内存中分配空闲页面
  3. 内容加载:从磁盘读取对应数据到物理页面
  4. 页表映射:在页表中建立虚拟地址到物理地址的映射
  5. 重新执行:返回用户态,重新执行引发页错误的指令

在OS加载对应地址的数据后,即可正常访问:

1
2
3
0x00000000004027c0 in ?? ()
(gdb) x/i $rip
=> 0x4027c0: endbr64

C库入口封装

经过缺页处理后,程序开始正式执行。需要注意,最先运行的并非用户编写的代码,而是C标准库封装的入口函数_start。该函数进行栈初始化、参数解析和环境配置等准备工作,完成后才调用用户的main函数。以下省略了_start函数的大部分汇编代码,重点关注与测试程序相关的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
0x00000000004027c0 in ?? ()
(gdb) x/i $rip
=> 0x4027c0: endbr64
...
(gdb)
0x4028f5: endbr64
(gdb)
0x4028f9: push %rbp
(gdb)
0x4028fa: mov %rsp,%rbp
(gdb)
0x4028fd: mov $0xd,%edx
(gdb)
0x402902: lea 0x6a707(%rip),%rax # 0x46d010
(gdb)
0x402909: mov %rax,%rsi
(gdb)
0x40290c: mov $0x1,%edi
(gdb)
0x402911: call 0x410970
(gdb)
0x402916: mov $0xcc,%edi
(gdb)
0x40291b: call 0x410930

0x402916处可见将退出码0xcc赋值给寄存器的指令,0x402902处加载字符串地址。

查看地址0x46d010时,再次遇到缺页错误。需继续执行至0x402916,此时代码已实际访问字符串,字符串已加载至内存,可正常打印0x46d010的值,正是测试程序中的hello world!\n

1
2
3
4
5
6
7
8
9
(gdb) x/s 0x46d010
0x46d010: <error: Cannot access memory at address 0x46d010>
(gdb)

Breakpoint 5, 0x0000000000402916 in ?? ()
(gdb) x/i $rip
=> 0x402916: mov $0xcc,%edi
(gdb) x/s 0x46d010
0x46d010: "hello world!\n"

使用nm查看符号表验证,可以发现在上述代码地址0x402911调用的即为libc库的write方法,地址0x40291b调用的为_exit方法,符合预期:

1
2
3
4
5
6
7
-> % nm demo | grep 410970  
0000000000410970 T __libc_write
0000000000410970 W __write
0000000000410970 W write
-> % nm demo | grep 410930
0000000000410930 T _exit
0000000000410930 W _Exit

使用continue继续执行,程序成功运行并输出预期结果。在QEMU控制台可见程序输出”hello world!”字符串,随后正常退出。

qemu-output

系统调用流程总结

通过本实验的深入调试分析,完整跟踪了Linux系统执行ELF可执行文件的全过程。该流程从用户程序调用execve系统调用开始,经历系统调用处理、文件格式识别、内存映射建立、执行环境初始化等关键阶段,最终将控制权转移给用户程序执行。

程序执行的完整调用链路如下图所示:

文本格式图表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
用户态程序调用 execve()

标准C库包装函数

系统调用接口 (syscall指令)

内核系统调用分发器

__x64_sys_execve()

do_execve(getname(filename), argv, envp)

do_execveat_common()
├── 参数验证和权限检查
├── 分配 linux_binprm 结构
├── 准备参数和环境变量
└── bprm_execve()

exec_binprm()

search_binary_handler()
├── 尝试 misc_format (失败)
├── 尝试 script_format (失败)
└── 尝试 elf_format (成功)

load_elf_binary()
├── ELF头部验证
│ ├── 魔数检查 (\x7fELF)
│ ├── 文件类型检查 (ET_EXEC/ET_DYN)
│ ├── 架构兼容性检查
│ └── 版本兼容性检查
├── 程序头表解析
├── 内存映射建立
├── 虚拟地址空间分配
└── start_thread()

start_thread_common()
├── 段寄存器初始化
├── 线程特性重置
└── 关键寄存器设置
├── RIP = 程序入口地址
├── RSP = 用户栈地址
├── CS = 用户代码段
└── SS = 用户数据段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#f5f5f5','primaryTextColor':'#000','primaryBorderColor':'#666','lineColor':'#666','secondaryColor':'#e8e8e8','tertiaryColor':'#fff'}}}%%
flowchart TD
A[用户态程序调用 execve] -->|系统调用| B[标准C库包装函数]
B -->|syscall指令| C[内核系统调用分发器]
C --> D[__x64_sys_execve]
D --> E[do_execve]
E --> F[do_execveat_common]
F --> G[参数验证和权限检查]
F --> H[分配 linux_binprm 结构]
F --> I[准备参数和环境变量]
I --> J[bprm_execve]
J --> K[exec_binprm]
K --> L[search_binary_handler]
L --> M[尝试 misc_format]
M -->|失败| N[尝试 script_format]
N -->|失败| O[尝试 elf_format]
O -->|成功| P[load_elf_binary]
P --> Q[ELF头部验证]
Q --> Q1[魔数检查 \x7fELF]
Q --> Q2[文件类型检查 ET_EXEC/ET_DYN]
Q --> Q3[架构兼容性检查]
Q --> Q4[版本兼容性检查]
P --> R[程序头表解析]
P --> S[内存映射建立]
P --> T[虚拟地址空间分配]
Q1 --> U[start_thread]
Q2 --> U
Q3 --> U
Q4 --> U
R --> U
S --> U
T --> U
U --> V[start_thread_common]
V --> W[段寄存器初始化]
V --> X[线程特性重置]
V --> Y[关键寄存器设置]
Y --> Y1[RIP = 程序入口地址]
Y --> Y2[RSP = 用户栈地址]
Y --> Y3[CS = 用户代码段]
Y --> Y4[SS = 用户数据段]