:从 `syscall` 指令到系统调用处理程序的完整旅程)
【免费下载链接】linux-insides-zhLinux 内核揭秘项目地址https://gitcode.com/hust-open-atom-club/linux-insides-zh点击查看免费下载导读本篇文章是《Linux 内核揭秘》中文版linux-insides-zhSysCall 章节 的第二部分聚焦x86_64 架构下 Linux 内核如何响应并处理来自用户空间的系统调用。通过阅读本文你将掌握三件事系统调用表sys_call_table是如何构建与填充的内核启动时如何通过syscall_init把系统调用入口写进 MSR 特殊模式寄存器以及处理器执行syscall指令后entry_SYSCALL_64入口程序如何完成保存现场、查表、调用处理函数并最终sysretq返回用户空间的完整链路。这些内容可直接用于理解strace输出背后的内核机制也是后续阅读 vsyscall 与 vDSO、open 系统调用实现 的基础。从用户空间看系统调用为什么不是直接调用在前一节中我们知道了系统调用的概念并从用户空间角度观察了write系统调用。一个关键事实是用户程序并不会直接调用系统调用。我们不会把Hello World写成这样int main(int argc, char **argv) { ... ... ... sys_write(fd1, buf, strlen(buf)); ... ... }实际的做法是借助 C 标准库glibc提供的封装函数#include unistd.h int main(int argc, char **argv) { ... ... ... write(fd1, buf, strlen(buf)); ... ... }不管怎样write既不是直接的系统调用也不是内核函数。程序必须先把通用目的寄存器按照约定顺序装入正确的值然后执行syscall指令来真正触发系统调用。本节的焦点就是当处理器执行syscall指令时Linux 内核内部发生了什么。系统调用表的初始化内核如何定位处理函数sys_call_table数组系统调用与中断非常相似——本质上系统调用就是软件中断的处理程序。当程序执行syscall指令时该指令引发异常把控制权转移给异常处理程序。但内核如何根据系统调用编号找到对应的处理函数地址答案是系统调用表system call table。系统调用表对应 Linux 内核源码 arch/x86/entry/syscall_64.c 中定义的数组sys_call_tableasmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max1] { [0 ... __NR_syscall_max] sys_ni_syscall, #include asm/syscalls_64.h };这里有两个重要话题数组的类型与元素的初始值。数组类型与大小sys_call_table数组的大小为__NR_syscall_max 1其中__NR_syscall_max宏表示给定架构的系统调用最大数量。本书针对 x86_64 架构因此__NR_syscall_max为547这是本书编写时、当前 Linux 内核版本为5.0.0-rc7时的数字。编译内核时可通过 Kbuild 生成的头文件include/generated/asm-offsets.h查看该宏#define __NR_syscall_max 547对于 x86_64arch/x86/entry/syscalls/syscall_64.tbl 中也记录了相同数量的系统调用。sys_call_ptr_t是指向系统调用处理函数的指针它通过typedef定义为一个返回值为空且无参数的函数指针typedef void (*sys_call_ptr_t)(void);元素的初始值sys_ni_syscall从上面的代码可见数组中所有元素初始都指向sys_ni_syscall——即 not-implemented未实现系统调用的处理函数。这种先全部填充默认处理函数、随后再逐项覆盖的初始化方式是正确的我们只是先为指针预留存储位置稍后再做真正赋值。sys_ni_syscall的实现很简单仅仅返回-ENOSYS错误码asmlinkage long sys_ni_syscall(void) { return -ENOSYS; }-ENOSYS错误码的含义是ENOSYS Function not implemented (POSIX.1)Designated Initializers 与自动生成的头文件初始化代码中的...范围初始化语法来自 GCC 的 Designated Initializers 特性它允许以不固定的顺序对数组元素进行初始化。在数组结尾处引用了asm/syscalls_64.h头文件。该头文件由特殊脚本 arch/x86/entry/syscalls/syscalltbl.sh 从 syscall table 自动生成内容包括一系列宏定义__SYSCALL_COMMON(0, sys_read, sys_read) __SYSCALL_COMMON(1, sys_write, sys_write) __SYSCALL_COMMON(2, sys_open, sys_open) __SYSCALL_COMMON(3, sys_close, sys_close) __SYSCALL_COMMON(5, sys_newfstat, sys_newfstat) ... ... ...宏__SYSCALL_COMMON在同一个源文件 arch/x86/entry/syscall_64.c 中定义作为宏__SYSCALL_64的扩展#define __SYSCALL_COMMON(nr, sym, compat) __SYSCALL_64(nr, sym, compat) #define __SYSCALL_64(nr, sym, compat) [nr] sym,因此展开后的sys_call_table实际是这个样子asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max1] { [0 ... __NR_syscall_max] sys_ni_syscall, [0] sys_read, [1] sys_write, [2] sys_open, ... ... ... };所有未被显式覆盖的条目仍指向sys_ni_syscall即仅返回-ENOSYS而其他条目指向对应的sys_syscall_name函数。至此系统调用表填充完毕Linux 内核知道了每个系统调用处理函数的位置。仓库佐证在系统调用概念简介一节中可以看到write系统调用处理函数由 fs/read_write.c 中的SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)定义宏SYSCALL_DEFINE3最终会生成sys_write函数——正是上表中[1]号槽位指向的目标。系统调用入口初始化把入口地址写进 MSR系统调用表准备好之后Linux 内核并不会在用户程序发起系统调用时直接调用sys_syscall_name函数。回忆中断与中断处理章节当内核获得中断的控制权后在调用中断处理程序之前必须做一些准备工作——保存用户空间寄存器、切换到新的堆栈等。系统调用处理同样如此。而在这些准备开始之前系统调用入口必须完成初始化内核必须知道入口的第一条指令在哪里。从 Intel 手册说起阅读 Intel 手册 64-ia-32-architectures-software-developer-vol-2b-manualSYSCALL 引起操作系统系统调用处理器处于特权级 0其通过加载 IA32_LSTAR MSR 至 RIP 完成。也就是说我们需要把系统调用入口地址写入IA32_LSTAR模型专属寄存器MSR。这一操作在 Linux 内核初始化过程中完成。syscall_init函数如果你读过中断与中断处理第四部分会知道内核初始化过程中会调用trap_init函数。该函数定义在 arch/x86/kernel/setup.c 中负责 non-early 异常处理如除法错误、协处理器错误等的初始化。除了异常处理初始化外它还会调用 arch/x86/kernel/cpu/common.c 中的cpu_init函数进而调用同一源文件中的syscall_init完成 per-cpu 状态初始化。syscall_init无参数首先填充两个特殊 MSRwrmsrl(MSR_STAR, ((u64)__USER32_CS)48 | ((u64)__KERNEL_CS)32); wrmsrl(MSR_LSTAR, entry_SYSCALL_64);各字段含义如下MSR位段内容用途MSR_STAR63:48__USER32_CSsysret返回用户空间时加载到CS/SS段选择符MSR_STAR47:32__KERNEL_CS用户空间执行系统调用时作为CS/SS段选择寄存器的基地址MSR_LSTAR全 64 位entry_SYSCALL_64处理器执行syscall后加载到RIP的系统调用入口其中entry_SYSCALL_64定义在 arch/x86/entry/entry_64.S 汇编文件中包含系统调用执行前的全部准备逻辑后面详细展开。兼容模式相关的 MSR在设置系统调用入口之后还需要设置以下 MSRMSR_CSTAR—— 兼容模式调用者的目标ripMSR_IA32_SYSENTER_CS——sysenter指令的目标csMSR_IA32_SYSENTER_ESP——sysenter指令的目标espMSR_IA32_SYSENTER_EIP——sysenter指令的目标eip。这些 MSR 的值与内核配置选项CONFIG_IA32_EMULATION相关。若开启该选项将允许 64 位内核运行 32 位程序。情况一CONFIG_IA32_EMULATION开启使用兼容模式的系统调用入口填充这些寄存器wrmsrl(MSR_CSTAR, entry_SYSCALL_compat);对内核代码段设置堆栈指针为零并把entry_SYSENTER_compat的地址写入指令指针wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)__KERNEL_CS); wrmsrl_safe(MSR_IA32_SYSENTER_ESP, 0ULL); wrmsrl_safe(MSR_IA32_SYSENTER_EIP, (u64)entry_SYSENTER_compat);情况二CONFIG_IA32_EMULATION未开启将ignore_sysret写入MSR_CSTARwrmsrl(MSR_CSTAR, ignore_sysret);ignore_sysret定义在 arch/x86/entry/entry_64.S 中仅返回-ENOSYS错误码ENTRY(ignore_sysret) mov $-ENOSYS, %eax sysret END(ignore_sysret)同时MSR_IA32_SYSENTER_CS、MSR_IA32_SYSENTER_ESP、MSR_IA32_SYSENTER_EIP的处理也区分两种情况开启时与上文一致填充真实入口未开启时用零填充MSR_IA32_SYSENTER_ESP和MSR_IA32_SYSENTER_EIP并将 全局描述符表 GDT 的无效段加载至MSR_IA32_SYSENTER_CSwrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)GDT_ENTRY_INVALID_SEG); wrmsrl_safe(MSR_IA32_SYSENTER_ESP, 0ULL); wrmsrl_safe(MSR_IA32_SYSENTER_EIP, 0ULL);关于Global Descriptor Table的更多细节可以阅读描述 Linux 内核启动过程的 第二节。屏蔽标志寄存器中的干扰位在syscall_init函数的尾段通过写入MSR_SYSCALL_MASK屏蔽标志寄存器中的一组标志位wrmsrl(MSR_SYSCALL_MASK, X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF| X86_EFLAGS_IOPL|X86_EFLAGS_AC|X86_EFLAGS_NT);这些标志位会在系统调用初始化时被清除。至此syscall_init结束系统调用正式可用。下面开始关注用户程序执行syscall指令后发生的一切。系统调用处理执行前的准备entry_SYSCALL_64与中断/异常一样系统调用处理函数在被内核调用之前需要一系列准备。异常处理用宏idtentry完成准备中断处理用宏interrupt完成准备而系统调用则由entry_SYSCALL_64完成。它定义在 arch/x86/entry/entry_64.S 中。第一步交换 GS 基址entry_SYSCALL_64以宏SWAPGS_UNSAFE_STACK开始SWAPGS_UNSAFE_STACK该宏定义在 arch/x86/include/asm/irqflags.h 头文件中扩展为swapgs指令#define SWAPGS_UNSAFE_STACK swapgsswapgs指令交换 GS 段选择符与MSR_KERNEL_GS_BASE中的值——换言之把 GS 基址切换到内核堆栈。之后把老的堆栈指针存入rsp_scratchper-cpu 变量并把堆栈指针指向当前处理器的栈顶movq %rsp, PER_CPU_VAR(rsp_scratch) movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp仓库佐证Per-cpu 变量一节详细解释了DEFINE_PER_CPU宏如何把变量放入.data..percpu段、每个 CPU 各持一份拷贝这正是PER_CPU_VAR(rsp_scratch)能按 CPU 隔离保存旧堆栈指针的底层机制。第二步保存堆栈段与旧堆栈指针接下来把堆栈段及老的堆栈指针压栈pushq $__USER_DS pushq PER_CPU_VAR(rsp_scratch)第三步开中断并保存现场入口处中断是关闭的此处使能中断然后把通用目的寄存器除bp、bx及r12至r15、标志位、与 not-implemented 系统调用相关的-ENOSYS及代码段寄存器压入堆栈ENABLE_INTERRUPTS(CLBR_NONE) pushq %r11 pushq $__USER_CS pushq %rcx pushq %rax pushq %rdi pushq %rsi pushq %rdx pushq %rcx pushq $-ENOSYS pushq %r8 pushq %r9 pushq %r10 pushq %r11 sub $(6*8), %rsp系统调用发起时寄存器的语义当系统调用由用户空间程序发起时各通用目的寄存器的状态如下寄存器内容rax系统调用编号rcx返回用户空间的返回地址r11寄存器标志位rdi系统调用处理函数的第一个参数rsi系统调用处理函数的第二个参数rdx系统调用处理函数的第三个参数r10系统调用处理函数的第四个参数r8系统调用处理函数的第五个参数r9系统调用处理函数的第六个参数其他通用目的寄存器如rbp、rbx和r12至r15由 C ABI 保留由被调用者保存。上面的压栈序列依次保存了寄存器标志位、用户代码段、用户空间返回地址、系统调用编号、三个参数、占位的错误码以及堆栈中的其他信息。第四步检查跟踪标志下一步检查当前thread_info中的_TIF_WORK_SYSCALL_ENTRYtestl $_TIF_WORK_SYSCALL_ENTRY, ASM_THREAD_INFO(TI_flags, %rsp, SIZEOF_PTREGS) jnz tracesys宏_TIF_WORK_SYSCALL_ENTRY定义在 arch/x86/include/asm/thread_info.h 头文件中汇总了一组与系统调用跟踪有关的进程信息标志#define _TIF_WORK_SYSCALL_ENTRY \ (_TIF_SYSCALL_TRACE | _TIF_SYSCALL_EMU | _TIF_SYSCALL_AUDIT | \ _TIF_SECCOMP | _TIF_SINGLESTEP | _TIF_SYSCALL_TRACEPOINT | \ _TIF_NOHZ)如果对应标志被置位则跳转到tracesys标签走跟踪/调试路径跟踪与调试相关内容将在独立章节讨论此处不展开。第五步校验系统调用编号tracesys标签之后的下一标签是entry_SYSCALL_64_fastpath。在这里检查定义于 arch/x86/include/asm/unistd.h 的__SYSCALL_MASK# ifdef CONFIG_X86_X32_ABI # define __SYSCALL_MASK (~(__X32_SYSCALL_BIT)) # else # define __SYSCALL_MASK (~0) # endif其中__X32_SYSCALL_BIT为#define __X32_SYSCALL_BIT 0x40000000__SYSCALL_MASK与CONFIG_X86_X32_ABI内核配置选项相关它是 64 位内核中 32 位 ABI 的掩码。具体校验逻辑若CONFIG_X86_X32_ABI未启用直接把rax寄存器的值与系统调用最大数量__NR_syscall_max比较若启用则先对eax与X32_SYSCALL_BIT做掩码操作再做同样的比较#if __SYSCALL_MASK ~0 cmpq $__NR_syscall_max, %rax #else andl $__SYSCALL_MASK, %eax cmpl $__NR_syscall_max, %eax #endif随后检查比较结果ja指令在CF和ZF标志均为 0 时执行ja 1f第六步调用系统调用处理函数若系统调用编号有效把第四个参数从r10移动到rcx保持 x86_64 C ABI 的寄存器使用约定——系统调用约定中第四参数在r10而普通 C 函数调用第四参数在rcx然后以系统调用处理函数的地址为参数执行callmovq %r10, %rcx call *sys_call_table(, %rax, 8)注意sys_call_table是一个数组rax通用目的寄存器保存系统调用编号数组每个元素占 8 字节因此*sys_call_table(, %rax, 8)这一寻址方式根据编号计算偏移精确找到对应处理函数在数组中的位置。至此所有准备工作完成系统调用处理函数如SYSCALL_DEFINE[N]宏定义的sys_read、sys_write等被正式调用。退出系统调用恢复现场与sysretq系统调用处理函数完成任务后控制权返回 arch/x86/entry/entry_64.S位置正好在call指令之后call *sys_call_table(, %rax, 8)保存返回值系统调用处理函数把返回值放在rax寄存器中。返回后第一步就是把该值写入堆栈上RAX对应的位置movq %rax, RAX(%rsp)锁调试钩子接着调用定义于 arch/x86/include/asm/irqflags.h 的宏LOCKDEP_SYS_EXITLOCKDEP_SYS_EXIT该宏的实现与CONFIG_DEBUG_LOCK_ALLOC内核配置选项相关允许在退出系统调用时调试锁。这一机制将在单独章节讨论此处不深入。恢复寄存器并执行sysretq在entry_SYSCALL_64的末尾恢复除rcx和r11之外的所有通用寄存器——因为rcx保存的是调用系统调用的应用程序的返回地址r11保存的是老的标志寄存器内容。恢复之后把返回地址装入rcx、标志装入r11、旧堆栈指针装入rspRESTORE_C_REGS_EXCEPT_RCX_R11 movq RIP(%rsp), %rcx movq EFLAGS(%rsp), %r11 movq RSP(%rsp), %rsp USERGS_SYSRET64最后调用宏USERGS_SYSRET64它依次执行swapgs指令交换用户GS与内核GS再用sysretq指令从系统调用处理中返回用户空间#define USERGS_SYSRET64 \ swapgs; \ sysretq;完整流程图解一次系统调用的生命周期现在我们知道用户程序使用系统调用时发生的一切整个流程可以总结为以下步骤用户空间准备参数用户程序中的代码把系统调用编号和系统调用参数装入通用目的寄存器陷入内核处理器从用户模式切换到内核模式开始执行系统调用入口——entry_SYSCALL_64保存现场entry_SYSCALL_64切换到内核堆栈在堆栈中保存通用目的寄存器、老的堆栈、代码段、标志位等查表分发entry_SYSCALL_64检查rax寄存器中的系统调用编号编号正确时在sys_call_table中查找对应处理函数并调用非法编号处理若系统调用编号不正确超出__NR_syscall_max跳至系统调用退出路径返回-ENOSYS恢复与返回系统调用处理函数完成工作后恢复通用寄存器、老的堆栈、标志位及返回地址通过sysretq指令退出entry_SYSCALL_64返回用户空间。用一句话概括整个旅程syscall指令 →swapgs 切栈 → 压栈保存现场 →cmp/ja校验编号 →call *sys_call_table(, %rax, 8)分发 → 处理函数执行 → 保存返回值 →RESTORE_C_REGS_EXCEPT_RCX_R11→swapgssysretq返回。结论与延伸阅读这是《Linux 内核揭秘》SysCall 章节的第二节。在前一节我们从用户应用程序的角度讨论了系统调用概念的原理本节则深入内核完整走查了从syscall指令到系统调用表、MSR 入口初始化、entry_SYSCALL_64现场准备、查表调用与sysretq返回的全过程。本文涉及的核心源码位置汇总可结合 SUMMARY.md 目录在仓库中继续深入关注点仓库内相关文档系统调用概念与write实现系统调用概念简介vsyscall 与 vDSOvsyscall and vDSO程序启动与 open 实现Linux 内核如何运行程序、open 系统调用的实现中断/异常处理的前置知识中断处理per-cpu 机制rsp_scratch的底层支撑Per-cpu 变量GDT 基础在内核安装代码的第一步延伸阅读建议下一节 vsyscall and vDSO 将讨论不需要陷入内核的系统调用加速机制open 系统调用的实现 则会展示sys_open处理函数的完整实现帮助你从入口机制过渡到具体业务实现。赞分享【免费下载链接】linux-insides-zhLinux 内核揭秘项目地址https://gitcode.com/hust-open-atom-club/linux-insides-zh点击查看免费下载相关推荐系统调用后内核做了什么Linux 内核揭秘linux-insides-zh完整解析 syscall 机制、vDSO 与程序运行过程系统调用后内核做了什么Linux 内核揭秘linux insides zh完整解析 syscall 机制、vDSO 与程序运行过程 程序读写文件、建立网络新手如何入门 Linux 内核源码Linux 内核揭秘linux-insides-zh3 步阅读法从引导流程到系统调用新手如何入门 Linux 内核源码Linux 内核揭秘linux insides zh3 步阅读法从引导流程到系统调用 刚接触 Linux 内核源码 时内核如何管理内存Linux 内核揭秘linux-insides-zh内存管理章节从 memblock 到 ioremap 完整拆解内核如何管理内存Linux 内核揭秘linux insides zh内存管理章节从 memblock 到 ioremap 完整拆解 如果你想知道 Lin上一篇10分钟上手Verible从安装到格式化SystemVerilog代码下一篇5分钟搞定yuzu硬件加速告别卡顿的GPU渲染设置指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考