一. 前言
通过前面几篇文章,我们分析了从按下电源键到内核启动、完成初始化的整个过程。在后面的文章中我们将分别深入剖析Linux内核各个重要部分的源码。考虑到后面的部分我们会从用户态的代码开始入手一步一步深入,因此在分析这些之前,我们需要仔细看一看如何实现一个从用户态到内核态再回到用户态的系统调用的全过程,即系统调用的实现。
本文的说明顺序如下:
- 首先从一个简单的例子开始分析
glibc
中对应的调用 - 针对32位和64位中调用的结构不同会分开两部分单独介绍,会介绍整个调用至完成的过程。即用户态->内核态->用户态
- 在整个调用过程中最重要的一步是中间访问系统调用表,该部分为了描述清楚单独拉出来最后介绍
二. GLIBC标准库的调用
让我们从一个简单的程序开始
1 |
|
如上所示的程序主要调用了glibc中的函数,然后在其上进行了封装而成。比如fopen
实际使用的是open
,这里我们就以该函数为例来说明整个调用过程。首先open
函数的系统调用在syscalls.list
表中定义
1 | # File name Caller Syscall name Args Strong name Weak names |
根据此配置文件,glibc会调用脚本make_syscall.sh
将其封装为宏,如SYSCALL_NAME open
的形式。这些宏会通过T_PSEUDO
来调用(位于syscall-template.S
),而实际上使用的则是DO_CALL(syscall_name, args)
1 | T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS) |
2.1 32位系统调用过程
考虑到32位和64位代码结构有一些区别,因此这里需要分开讨论。在32位系统中,DO_CALL()
位于i386 目录下的sysdep.h
文件中
1 | /* Linux takes system call arguments in registers: |
这里,我们将请求参数放在寄存器里面,根据系统调用的名称,得到系统调用号,放在寄存器 eax 里面,然后执行ENTER_KERNEL
。
1 |
ENTER_KERNEL
实际调用的是80软中断,以此陷入内核。这些中断在trap_init()
中被定义并初始化。在前文中对trap_init()
已有一些简单的叙述,后面在中断部分会再详细介绍。
初始化好的中断表会等待到中断触发,触发的时候则调用对应的回调函数,这里的话就是entry_INT80_32
。该中断首先通过push
和SAVE_ALL
保存所有的寄存器,存储在pt_regs
中,然后调用do_syscall_32_irqs_on()
函数。该函数将系统调用号从eax
里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。最后调用INTERRUPT_RETURN
,实际使用的是iret
指令将原来用户保存的现场包含代码段、指令指针寄存器等恢复,并返回至用户态执行。
1 | ENTRY(entry_INT80_32) |
2.2 64位系统调用过程
对于64位系统来说,DO_CALL
位于x86_64 目录下的 sysdep.h
文件中
1 | /* The Linux/x86-64 kernel expects the system call parameters in |
和之前一样,还是将系统调用名称转换为系统调用号,放到寄存器rax
。这里是真正进行调用,不是用中断了,而是改用syscall
指令了。并且,通过注释我们也可以知道,传递参数的寄存器也变了。syscall
指令还使用了一种特殊的寄存器,我们叫特殊模块寄存器(Model Specific Registers,简称 MSR)。这种寄存器是 CPU 为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。
在系统初始化的时候,trap_init()
除了初始化上面的中断模式,这里面还会调用 cpu_init->syscall_init()
。这里面有这样的代码:
1 | wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64); |
rdmsr()
和 wrmsr()
是用来读写特殊模块寄存器的。MSR_LSTAR
就是这样一个特殊的寄存器,当 syscall
指令调用的时候,会从这个寄存器里面拿出函数地址来调用,也就是调用 entry_SYSCALL_64
。在 arch/x86/entry/entry_64.S
中定义了 entry_SYSCALL_64
函数。
该函数开始于一条宏:SWAPGS_UNSAFE_STACK
,其定义如下,主要是交换当前GS基寄存器中的值和特殊模块寄存器中包含的值,即进入内核栈。
1 |
对于旧的栈,我们会将其存于rsp_scratch
,并将栈指针移至当前进程的栈顶。
1 | movq %rsp, PER_CPU_VAR(rsp_scratch) |
下一步,我们将栈段和旧的栈指针压入栈中
1 | pushq $__USER_DS |
接下来,我们需要打开中断并保存很多寄存器到 pt_regs
结构里面,例如用户态的代码段、数据段、保存参数的寄存器,并校验当前线程的信息_TIF_WORK_SYSCALL_ENTRY
,这里涉及到Linux的debugging和tracing技术,会单独在后文中详细分析。该部分代码具体如下所示。
1 | ENTRY(entry_SYSCALL_64) |
各寄存器的作用如下所示:
rax
:系统调用数目rcx
:函数返回的用户空间地址r11
:寄存器标记rdi
:系统调用回调函数的第一个参数rsi
:系统调用回调函数的第二个参数rdx
:系统调用回调函数的第三个参数r10
:系统调用回调函数的第四个参数r8
:系统调用回调函数的第五个参数r9
:系统调用回调函数的第六个参数rbp,rbx,r12-r15
:通用的callee-preserved寄存器
在此之后,其实存在着两个处理分支:entry_SYSCALL64_slow_path
和 entry_SYSCALL64_fast_path
,这里是根据_TIF_WORK_SYSCALL_ENTRY
判断的结果进行选择,这里涉及到ptrace
部分的知识,暂时先不介绍了,会在后面单独开一文详细研究。如果设置了_TIF_ALLWORK_MASK
或者_TIF_WORK_SYSCALL_ENTRY
,则跳转至slow_path
,否则继续运行fast_path
。
1 | #define _TIF_WORK_SYSCALL_ENTRY \ |
2.2.1 fastpath
分支
该分支主要分为以下部分内容
- 再次检测TRACE部分,如果有标记则跳转至
slow_path
- 检测
__SYSCALL_MASK
,如果CONFIG_X86_X32_ABI
未设置我们就比较rax
寄存器的值和最大系统调用数__NR_syscall_max
,否则则标记eax
寄存器为__x32_SYSCALL_BIT
,再进行比较 ja
指令会在CF
和ZF
设置为0时进行跳转,即如果不满足条件则会跳转至-ENOSYS
,否则继续执行- 将第四个参数从
r10
放入rcx
以保持x86_64 C ABI编译 - 执行
sys_call_table
,去系统调用表中查找系统调用
1 | entry_SYSCALL_64_fastpath: |
2.2.2 slow_path
分支
slow_path
部分的源码如下
1 | entry_SYSCALL64_slow_path: |
slow_path
会调用entry_SYSCALL64_slow_pat->do_syscall_64()
,执行完毕后恢复寄存器,最后调用USERGS_SYSRET64
,实际使用sysretq
指令返回。
1 |
|
在 do_syscall_64
里面,从 rax
里面拿出系统调用号,然后根据系统调用号,在系统调用表 sys_call_table 中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。
1 | __visible void do_syscall_64(struct pt_regs *regs) |
至此,32位和64位又回到了同样的位置:查找系统调用表sys_call_table
。
三. 系统调用表的生成
32位和64位的sys_call_table
均位于arch/x86/entry/syscalls/
目录下,分别为syscall_32.tbl
和syscall_64.tbl
。如下所示为32位和64位中open
函数的定义
1 | 5 i386 open sys_open compat_sys_open |
第一列的数字是系统调用号。可以看出,32 位和 64 位的系统调用号是不一样的。第三列是系统调用的名字,第四列是系统调用在内核的实现函数。不过,它们都是以 sys_ 开头。系统调用在内核中的实现函数要有一个声明。声明往往在 include/linux/syscalls.h
文件中。例如 sys_open
是这样声明的:
1 | asmlinkage long sys_open(const char __user *filename, |
真正的实现这个系统调用,一般在一个.c 文件里面,例如 sys_open
的实现在 fs/open.c
里面。其中采用了宏的方式对函数名进行了封装,实际拆开是一样的。
1 | SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode) |
其中SYSCALL_DEFINE3 是一个宏系统调用最多六个参数,根据参数的数目选择宏。具体是这样定义如下所示,首先使用SYSCALL_METADATA()
宏解决syscall_metada
结构体的初始化,该结构体包括了不同的有用区域包括系统调用的名字、系统调用表中对应的序号、系统调用的参数、参数类型链表等。
1 |
|
在编译的过程中,需要根据 syscall_32.tbl
和 syscall_64.tbl
生成自己的syscalls_32.h
和 syscalls_64.h
。生成方式在 arch/x86/entry/syscalls/Makefile
中。这里面会使用两个脚本
第一个脚本
arch/x86/entry/syscalls/syscallhdr.sh
,会在文件中生成#define __NR_open;
第二个脚本
arch/x86/entry/syscalls/syscalltbl.sh
,会在文件中生成__SYSCALL(__NR_open, sys_open)
。这样最终生成
syscalls_32.h
和syscalls_64.h
就保存了系统调用号和系统调用实现函数之间的对应关系,如下所示
1 | __SYSCALL_COMMON(0, sys_read, sys_read) |
其中__SYSCALL_COMMON
宏定义如下,主要是将对应的数字序号和系统调用名对应
1 |
最终形成的表如下
1 | asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = { |
最后,所有的系统调用会存储在arch/x86/entry/
目录下的syscall_32.c
和syscall_64.c
中,里面包含了syscalls_32.h
和 syscalls_64.h
头文件,其形式如下:
1 | __visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = { |
其中__NR_syscall_max
宏定义规定了最大系统调用数量,该数量取决于操作系统的架构,在X86下定义如下
1 |
这里还需要注意sys_call_ptr_t
表示指向系统调用表的指针,定义为函数指针
1 | typedef void (*sys_call_ptr_t)(void); |
系统调用表数组中的每一个系统调用均会指向sys_ni_syscall
,该函数表示一个未实现的系统调用(not-implement),从而系统调用表的初始化。
1 | asmlinkage long sys_ni_syscall(void) |
由此,整个系统调用表的生成过程就全部说明完了,而在实际产生系统调用的时候,过程则刚好相反:
- 用户态调用
syscall
syscall
导致中断,程序由用户态陷入内核态- 内核C函数执行
syscalls_32/64.c
,并由此获得对应关系最终在对应的源码中找到函数实现 - 针对对应的
sys_syscall_name
函数,做好调用准备工作,如初始化系统调用入口、保存寄存器、切换新的栈、构造新的task以备中断回调等。 - 调用函数实现
- 切换寄存器、栈,返回用户态
四. 总结
本文较为深入的分析了系统调用的整个过程,并着重分析了系统调用表的形成和使用原理,如有遗漏错误还请多多指正。
源码资料
[2] linux/arch/x86/kernel/cpu/common.c
[3] linux/include/linux/syscalls.h
[4] linux/arch/x86/include/asm/thread_info.h
参考资料
[1] Linux-insides
[2] 深入理解Linux内核
[3] Linux内核设计的艺术
[4] 极客时间 趣谈Linux操作系统
[5] Intel® 64 and IA-32 Architectures Software Developer Manuals