一、前言
通过本学期《Linux操作系统分析》课程的学习,我对Linux操作系统的方方面面有了全新的认知,完成了从知其然到知其所以然的转变,尤其是对函数调用、系统调用和进程切换有了结构性的理解。此外,通过这门课我不仅学习了相关理论知识还收获了很多意想不到的实践经验,如Linux源码的阅读、编译和调试技巧。
本文将简要介绍和分析(主要是x86架构下)Linux中的系统调用,分享我的收获与思考,以供参考。
二、用户态与内核态
为了充分利用有限的硬件资源和保证系统的安全性与稳定性,linux将系统运行状态分为用户态和内核态以限制不同程序的访问能力(对系统指令、内存、外围设备等的访问限制)。
系统调用在用户态到内核态的切换过程中扮演了重要角色,理解用户态和内核态也有助于理解系统调用的基本原理和执行过程。
2.1 CPU指令执行权限
处于用户态和内核态的程序将具有不同指令执行权限:
- X86架构: RING0~RING3执行权限分级(数字越小,权限越高),RING0对应内核态,RING3对应用户态

- ARM64架构:异常级别EL (Exception Level) 分为四级,EL0对应用户态,EL1对应内核态

2.2 进程地址空间
处于用户态和内核态的程序具有不同的内存访问能力,具体来说,内核态下,可以访问任意的地址范围,而用户态下指令指针寄存器的值只能是受限的内存地址范围。
- 32位系统:每个进程有4GB的进程地址空间,用户态可以访问0x00000000~0xC0000000,其余地址只能在内核态下访问。

-
64位系统:256TB的进程地址空间,高地址的128TB只能在内核态下访问,用户态只可访问剩余地址范围(除开4KB大小的保护空间)。

2.3 用户态与内核态的切换
用户态到内核态的切换通常由中断(X86)或异常(ARM)触发,对应的处理程序完成后系统会返回用户态。在x86架构中,系统调用是一种特殊的中断机制,作为用户态程序主动进入内核态的唯一合法途径(而异常和外部中断属于被动切换方式)。
三、Linux系统调用(以x86架构为例)
3.1 用户程序触发系统调用
用户程序通常借助特定编程语言的库函数API间接使用系统调用,例如C语言的open、read等库函数实质是对系统调用的封装,库函数API的内部实现对应一个或多个操作系统提供的系统调用。
用户程序也可以通过特定汇编指令(如x86中的int $0x80)直接触发系统调用,并通过指定寄存器传递系统调用号、参数和接收返回值。
3.2 x86系统调用历史演变
x86架构的系统调用实现方式经历了多次演进:从早期的int/iret中断机制,发展到Intel专用的sysenter/sysexit指令对,最终演变为现今通用的syscall/sysret(快速系统调用)方案。
3.3 系统调用初始化
系统调用初始化就是要向CPU提供系统调用处理程序的入口地址,int $0x80 通过设置中断描述符表(IDT)来实现,而 sysenter 和 syscall 则是借助CPU内部的特殊寄存器 MSR(Model-Specific Register)实现。后者没有压栈操作和特权级别检查,而是直接读写寄存器,初始化速度更快,因此被称作快速系统调用。
3.4 系统调用执行过程
- 触发:用户态以 int $0x80 或 syscall 等方式触发系统调用进入内核态
- 初始化:将系统调用处理的入口地址设置到 IDT 或 MSR 中,32位系统下入口为 entry_INT80_32 , 64位系统为 entry_SYSCALL_64。
- 保存现场:硬件自动将关键寄存器的值依次压入内核栈,内核手动保存部分通用寄存器的值,此外将内核堆栈栈底的部分值组织为 pt_regs 结构体,用于快速保存和恢复现场。
- 执行内核处理函数:内核源码目录 arch/x86/entry/syacalls 中的 syscall_32.tbl 和 syscall_64.tbl 文件分别定义了32位和64位x86的内核处理函数,这些处理函数按系统调用号依次存入数组;初始化阶段中定义的入口将调用 do_int80_syscall_32 或 do_syscall_64 函数,根据系统调用号执行前文存入数组的内核处理函数,此时才真正处理了系统调用。
- 恢复现场
- 系统调用返回:iret 或 sysret
- 执行用户态下一条指令
四、syscall 触发系统调用示例
下文以time系统调用为例,介绍如何查询系统调用号、传递参数和接收返回值以及c语言内嵌x86汇编(AT&T语法)触发系统调用。
4.1 内核处理函数定义表——syscall_64.tbl
查询syscall_64.tbl文件(位于内核源码 arch/x86/entry/syacalls 目录下),每一行分别对应一个系统调用并记录其调用号、处理函数入口地址等信息,可以发现,time系统调用以syscall方式触发时对应的调用号为201,也即16进制下的0xc9。
#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The __x64_sys_*() stubs are created on-the-fly for sys_*() system calls
#
# The abi is "common", "64" or "x32" for this file.
#
0 common read __x64_sys_read
1 common write __x64_sys_write
2 common open __x64_sys_open
3 common close __x64_sys_close
4 common stat __x64_sys_newstat
5 common fstat __x64_sys_newfstat
6 common lstat __x64_sys_newlstat
7 common poll __x64_sys_poll
8 common lseek __x64_sys_lseek
9 common mmap __x64_sys_mmap
10 common mprotect __x64_sys_mprotect
······
201 common time __x64_sys_time
······
4.2 系统调用号、参数的传递与返回值接收
- 系统调用号: rax 寄存器。在调用 syscall 指令之前,需要将系统调用号放入 rax 寄存器中。
movl $0xc9, %%eax #将time系统调用号传入rax寄存器(低32位为eax)
-
参数传递:按顺序使用 rdi、rsi、rdx、r10(函数调用使用rcx)、r8、r9这6个寄存器,系统调用最多传递6个参数。
-
接收返回值: rax 寄存器。
4.3 64位x86汇编代码调用time系统调用
触发系统调用的过程如下:首先查询系统调用号(如通过源码目录下的syscall_64.tbl文件),再依次传入所需参数到指定寄存器,并传递系统调用号到rax寄存器,执行syscall指令后,最终通过rax寄存器接收返回值。
······
time_t tt;
asm volatile(
"movl $0, %%edi\n\t" //edi为rdi的低32位,对应前文中第一个用于传递参数的寄存器
"movl $0xc9,%%eax\n\t" //传递系统调用号0xc9,对应4.1节中syscall_64.tbl的查询结果
"syscall\n\t" //触发系统调用
"movq %%rax,%0\n\t" //接收返回值,可以发现返回值和系统调用号均通过rax寄存器传递
: "=m"(tt));
······
五、参考资料
1.《庖丁解牛Linux操作系统分析》 https://gitee.com/mengning997/linuxkernel
2214






