十、进阶 | Linux 内核中的系统调用(4)
Linux内核中的系统调用。第4部分。
Linux内核是如何运行程序的
这是描述Linux内核中系统调用的章节的第四部分,正如我在上一部分的结论中所写的,这部分将是本章的最后一部分。在上一部分中,我们停在了两个与系统调用概念相关且非常相似的新概念上:
vsyscall
;vDSO
;
这部分将是本章的最后一部分,正如您从部分标题中所理解的,我们将看到当我们运行程序时Linux内核发生了什么。那么,让我们开始吧。
我们是如何启动程序的?
从用户的角度来看,启动应用程序有很多不同的方式。例如,我们可以从shell运行程序,或者双击应用程序图标。这并不重要。Linux内核无论我们如何启动应用程序都会处理应用程序的启动。
在这部分中,我们将考虑我们从shell启动应用程序的方式。众所周知,从shell启动应用程序的标准方式是:我们只需启动一个终端仿真器应用程序,然后只需输入程序名称并传递或不传递参数给我们的程序,例如:
让我们考虑一下当我们从shell启动应用程序时会发生什么,shell会做什么,当我们输入程序名称时会发生什么,Linux内核会做什么等等。但在我们开始考虑这些有趣的事物之前,我想提醒一下,这本书是关于Linux内核的。因此,在这部分中,我们将主要看到与Linux内核内部相关的内容。我们不会详细考虑shell做了什么,我们不会考虑复杂的情况,例如子shell等。
我的默认shell是bash,所以我将考虑bash shell如何启动一个程序。那么让我们开始。bash
shell以及任何用C编程语言编写的程序都是从main函数开始的。如果您查看bash
shell的源代码,您将在shell.c源代码文件中找到main
函数。在bash
的主线程循环开始工作之前,这个函数执行了许多不同的操作。例如这个函数:
- 检查并尝试打开
/dev/tty
; - 检查shell是否在调试模式下运行;
- 解析命令行参数;
- 读取shell环境;
- 加载
.bashrc
、.profile
和其他配置文件; - 还有很多很多。
在所有这些操作之后,我们可以看到调用reader_loop
函数。这个函数在eval.c源代码文件中定义,代表主线程循环,或者换句话说,它读取并执行命令。当reader_loop
函数完成了所有检查并读取了给定的程序名称和参数后,它调用execute_cmd.c源代码文件中的execute_command
函数。execute_command
函数通过一系列函数调用:
execute_command
--> execute_command_internal
----> execute_simple_command
------> execute_disk_command
--------> shell_execve
执行不同的检查,例如我们是否需要启动子shell
,它是否是内置的bash
函数等。正如我已经在上面所写的,我们不会考虑所有与Linux内核无关的细节。在这个过程中,shell_execve
函数调用execve
系统调用:
execve (command, args, env);
execve
系统调用具有以下签名:
int execve(const char *filename, char *const argv [], char *const envp[]);
并通过给定的文件名、给定的参数和环境变量执行程序。这个系统调用是我们的情况下的第一个,也是唯一的例子:
$ strace ls
execve("/bin/ls", ["ls"], [/* 62 vars */]) = 0
$ strace echo
execve("/bin/echo", ["echo"], [/* 62 vars */]) = 0
$ strace uname
execve("/bin/uname", ["uname"], [/* 62 vars */]) = 0
所以,用户应用程序(在我们的情况下是bash
)调用系统调用,正如我们已经知道的,下一步是Linux内核。
execve系统调用
我们已经看到了用户应用程序调用系统调用之前的准备工作,以及在本章的第二部分系统调用处理程序完成其工作后的情况。我们在前一段中停在了调用execve
系统调用的地方。这个系统调用定义在fs/exec.c源代码文件中,正如我们已经知道的,它接受三个参数:
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);
}
execve
的实现在这里非常简单,正如我们所看到的,它只是返回do_execve
函数的结果。do_execve
函数在同一个源代码文件中定义,并执行以下操作:
- 使用给定的参数和环境变量初始化两个指针在用户空间数据上;
- 返回
do_execveat_common
的结果。
我们可以看到它的实现:
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
do_execveat_common
函数执行主要工作 - 执行新程序。这个函数接受类似的参数集,但正如您所看到的,它接受五个参数而不是三个。第一个参数是代表我们应用程序所在目录的文件描述符,在这种情况下,AT_FDCWD
意味着给定的路径名是相对于调用进程的当前工作目录解释的。第五个参数是标志。在我们的情况下,我们向do_execveat_common
传递了0
。我们将在下一步中检查,所以稍后会看到它。
首先,do_execveat_common
函数检查filename
指针,并在它是NULL
时返回。之后,我们检查当前进程的标志,以确保运行进程的限制没有超过:
if (IS_ERR(filename))
return PTR_ERR(filename);
if ((current->flags & PF_NPROC_EXCEEDED) &&
atomic_read(¤t_user()->processes) > rlimit(RLIMIT_NPROC)) {
retval = -EAGAIN;
goto out_ret;
}
current->flags &= ~PF_NPROC_EXCEEDED;
如果这两个检查成功,我们取消当前进程标志中的PF_NPROC_EXCEEDED
标志,以防止execve
失败。您可以看到,下一步我们调用定义在kernel/fork.c的unshare_files
函数,并取消共享当前任务的文件,并检查这个函数的结果:
retval = unshare_files(&displaced);
if (retval)
goto out_ret;
我们需要调用这个函数以消除execve'd二进制文件的文件描述符的潜在泄漏。下一步,我们开始准备由struct linux_binprm
结构表示的bprm
。linux_binprm
结构用于保存加载二进制文件时使用的参数。例如,它包含vma
字段,该字段具有vm_area_struct
类型,表示在给定地址空间中的连续区间上的单个内存区域,我们的应用程序将被加载到其中,mm
字段是二进制文件的内存描述符,指向内存顶部的指针等等。
首先,我们使用kzalloc
函数为此结构分配内存,并检查分配的结果:
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_files;
之后,我们开始通过调用prepare_bprm_creds
函数来准备binprm
凭据:
retval = prepare_bprm_creds(bprm);
if (retval)
goto out_free;
check_
unsafe_exec(bprm);
current->in_execve = 1;
初始化binprm
凭据换句话说是初始化存储在linux_binprm
内部的cred
结构。cred
结构包含任务的安全上下文,例如任务的真实uid,任务的真实guid,用于虚拟文件系统操作的uid
和guid
等。下一步,我们已经执行了bprm
凭据的准备,我们通过调用check_unsafe_exec
函数检查我们现在是否可以安全地执行程序,并将当前进程设置为in_execve
状态。
完成所有这些操作后,我们调用do_open_execat
函数,该函数检查我们传递给do_execveat_common
函数的标志(记住我们在flags
中有0
),搜索并打开磁盘上的可执行文件,检查我们将要加载的二进制文件不是来自noexec
挂载点(我们需要避免从不包含可执行二进制文件的文件系统执行二进制文件,如proc或sysfs),初始化file
结构并返回指向此结构的指针。接下来我们可以看到调用sched_exec
:
file = do_open_execat(fd, filename, flags);
retval = PTR_ERR(file);
if (IS_ERR(file))
goto out_unmark;
sched_exec();
sched_exec
函数用于确定可以执行新程序的最不繁忙的处理器,并将当前进程迁移到该处理器。
之后,我们需要检查给定可执行二进制文件的文件描述符。我们尝试检查我们的二进制文件的名称是否以/
符号开头,或者给定可执行二进制文件的路径是否相对于调用进程的当前工作目录解释,换句话说,文件描述符是AT_FDCWD
(上面关于这个的阅读)。
如果这些检查中的一个成功,我们设置二进制参数文件名:
bprm->file = file;
if (fd == AT_FDCWD || filename->name[0] == '/') {
bprm->filename = filename->name;
}
否则,如果文件名为空,我们根据给定可执行二进制文件的文件名将二进制参数文件名设置为/dev/fd/%d
或/dev/fd/%d/%s
,这意味着我们将执行文件描述符所引用的文件:
} else {
if (filename->name[0] == '\0')
pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d", fd);
else
pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d/%s",
fd, filename->name);
if (!pathbuf) {
retval = -ENOMEM;
goto out_unmark;
}
bprm->filename = pathbuf;
}
bprm->interp = bprm->filename;
请注意,我们不仅设置了bprm->filename
,还设置了bprm->interp
,它将包含程序解释器的名称。现在我们只是在那里写入了相同的名称,但稍后它将根据程序的二进制格式用程序解释器的真实名称进行更新。您可以在上面阅读我们已经为linux_binprm
准备了cred
。下一步是初始化linux_binprm
的其他字段。首先我们调用bprm_mm_init
函数并将其传递给bprm
:
retval = bprm_mm_init(bprm);
if (retval)
goto out_unmark;
bprm_mm_init
定义在同一个源代码文件中,正如我们从函数名称中所理解的,它初始化内存描述符,换句话说bprm_mm_init
函数初始化mm_struct
结构。这个结构定义在include/linux/mm_types.h头文件中,代表进程的地址空间。我们不会考虑bprm_mm_init
函数的实现,因为我们不了解与Linux内核内存管理器相关的许多重要内容,但我们需要知道这个函数初始化mm_struct
并用一个临时栈vm_area_struct
填充它。
之后,我们计算传递给我们的可执行二进制文件的命令行参数的数量,环境变量的数量,并将其设置为bprm->argc
和bprm->envc
:
bprm->argc = count(argv, MAX_ARG_STRINGS);
if ((retval = bprm->argc) < 0)
goto out;
bprm->envc = count(envp, MAX_ARG_STRINGS);
if ((retval = bprm->envc) < 0)
goto out;
正如您所看到的,我们使用定义在同一源代码文件中的count
函数来执行此操作,并计算argv
数组中的字符串数量。MAX_ARG_STRINGS
宏定义在include/uapi/linux/binfmts.h头文件中,正如我们从宏名称中所理解的,它表示传递给execve
系统调用的最大字符串数量。MAX_ARG_STRINGS
的值:
#define MAX_ARG_STRINGS 0x7FFFFFFF
计算了命令行参数和环境变量的数量之后,我们调用prepare_binprm
函数。我们在这一刻之前已经调用了一个类似名称的函数。这个函数是prepare_binprm_cred
,我们记得这个函数初始化了linux_bprm
中的cred
结构。现在prepare_binprm
函数:
retval = prepare_binprm(bprm);
if (retval < 0)
goto out;
用i节点中的uid
填充linux_binprm
结构,并从二进制可执行文件中读取128
字节。我们只从可执行文件中读取前128
字节,因为我们需要检查我们的可执行文件的类型。我们将在后续步骤中读取可执行文件的其余部分。在准备了linux_bprm
结构之后,我们通过调用copy_strings_kernel
函数将可执行二进制文件的文件名、命令行参数和环境变量复制到linux_bprm
:
retval = copy_strings_kernel(1, &bprm->filename, bprm);
if (retval < 0)
goto out;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out;
并设置我们在bprm_mm_init
函数中设置的新程序栈顶部的指针:
bprm->exec = bprm->p;
栈顶部将包含程序文件名,我们将此文件名存储在linux_bprm
结构的exec
字段中。
现在我们已经填充了linux_bprm
结构,我们调用exec_binprm
函数:
retval = exec_binprm(bprm);
if (retval < 0)
goto out;
首先,我们在exec_binprm
中存储当前任务的pid和从当前任务的namespace看到的pid
:
old_pid = current->pid;
rcu_read_lock();
old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
rcu_read_unlock();
并调用:
search_binary_handler(bprm);
函数。这个函数遍历包含不同二进制格式的处理程序列表。目前,Linux内核支持以下二进制格式:
binfmt_script
- 支持从#!行开始的解释性脚本;binfmt_misc
- 根据Linux内核的运行时配置,支持不同的二进制格式;binfmt_elf
- 支持elf格式;binfmt_aout
- 支持a.out格式;binfmt_flat
- 支持flat格式;binfmt_elf_fdpic
- 支持elfFDPIC二进制文件; *binfmt_em86
- 支持在Alpha机器上运行的Intel elf二进制文件。
所以,search_binary_handler
尝试调用load_binary
函数并传递linux_binprm
。如果二进制处理程序支持给定的可执行文件格式,它开始准备可执行文件以供执行:
int search_binary_handler(struct linux_binprm *bprm)
{
...
...
...
list_for_each_entry(fmt, &formats, lh) {
retval = fmt->load_binary(bprm);
if (retval < 0 && !bprm->mm) {
force_sigsegv(SIGSEGV, current);
return retval;
}
}
return retval;
其中load_binary
例如对于elf检查linux_bprm
缓冲区中的魔数(每个elf
二进制文件的头部都包含魔数):如果它不是elf
二进制文件,则退出:
static int load_elf_binary(struct linux_binprm *bprm)
{
...
...
...
loc->elf_ex = *((struct elfhdr *)bprm->buf);
if (memcmp(elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
goto out;
如果给定的可执行文件是elf
格式,load_elf_binary
继续执行。load_elf_binary
做了很多事情来准备执行可执行文件。例如,它检查可执行文件的架构和类型:
if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
goto out;
if (!elf_check_arch(&loc->elf_ex))
goto out;
如果架构错误,或者可执行文件不可执行或非共享,则退出。尝试加载程序头表
:
elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
if (!elf_phdata)
goto out;
它描述了段。从磁盘读取与我们的可执行二进制文件链接的程序解释器
和库,并将其加载到内存中。可执行文件的.interp
部分指定了程序解释器
,正如您可以在描述链接器的部分中读取的,对于x86_64
它是/lib64/ld-linux-x86-64.so.2
。它设置了栈,并将elf
二进制文件映射到内存中的正确位置。它映射了bss和brk段,并为准备可执行文件以供执行做了很多很多其他不同的事情。
在执行load_elf_binary
结束时,我们向它传递三个参数调用start_thread
函数:
start_thread(regs, elf_entry, bprm->p);
retval = 0;
out:
kfree(loc);
out_ret:
return retval;
这些参数是:
- 新任务的一组寄存器;
- 新任务的入口点地址;
- 新任务的栈顶部地址。
根据函数的名称,我们可以知道它启动了新线程,但事实并非如此。start_thread
函数只是准备新任务的寄存器以准备运行。让我们看一下这个函数的实现:
void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
start_thread_common(regs, new_ip, new_sp,
__USER_CS, __USER_DS, 0);
}
正如我们所看到的,start_thread
函数只是调用了start_thread_common
函数,该函数将为我们完成所有工作:
static void
start_thread_common(struct pt_regs *regs, unsigned long new_ip,
unsigned long new_sp,
unsigned int _cs, unsigned int _ss, unsigned int _ds)
{
loadsegment(fs, 0);
loadsegment(es, _ds);
loadsegment(ds, _ds);
load_gs_index(0);
regs->ip = new_ip;
regs->sp = new_sp;
regs->cs = _cs;
regs->ss = _ss;
regs->flags = X86_EFLAGS_IF;
force_iret();
}
start_thread_common
函数用零填充fs
段寄存器,用数据段寄存器的值填充es
和ds
。之后,我们为指令指针、cs
段等设置新的值。在start_thread_common
函数的末尾,我们可以看到force_iret
宏,它通过iret
指令强制系统调用返回。好的,我们已经准备好了新线程在用户空间运行,现在我们可以从exec_binprm
返回,现在我们又回到了do_execveat_common
。在exec_binprm
完成其执行后,我们释放之前分配的结构的内存并返回。
我们从execve
系统调用处理程序返回后,我们程序的执行将开始。我们可以这样做,因为所有与上下文相关的信息已经为此目的配置好了。正如我们所看到的,execve
系统调用不会将控制权返回给进程,但是调用者的代码、数据和其他段只是被程序段覆盖。我们应用程序的退出将通过exit
系统调用来实现。
就这些。从这一点开始,我们的程序将被执行。
结论
这是关于Linux内核中系统调用概念的第四部分的结尾。在这四部分中,我们几乎看到了与系统调用
概念相关的所有内容。我们从理解系统调用
概念开始,我们了解了它是什么以及用户应用程序为什么需要这个概念。接下来,我们看到了Linux如何处理用户应用程序的系统调用。我们遇到了两个与系统调用
概念类似的两个概念,它们是vsyscall
和vDSO
,最后我们看到了Linux内核是如何运行用户程序的。
如果您有任何问题或建议,请随时在Twitter上联系我0xAX,给我发电子邮件,或者在这里创建一个问题。
请注意,英语不是我的母语,如果给您带来任何不便,我深表歉意。如果您发现任何错误,请向我发送PR到linux-insides。
链接
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。