系统调用
现代操作系统的进程空间分为用户空间(user space)与内核空间(kernel space)。通常程序运行在用户空间中,当涉及一些敏感指令执行的时候,比如与硬件交互的操作,需要切换到内核空间,相关指令执行完毕后再返回用户空间继续执行。系统调用(syscall)在此过程中作为沟通用户空间与内核空间的桥梁存在。1
具体到x86架构,80286引入保护模式之后,有了特权等级(privilege level, or rings)的概念,它分为0-3 4个等级。2Linux下用户空间代码运行在ring 3,内核空间代码运行在ring 0,ring 3与ring 0的互相切换便是通过系统调用进行的。3
Note:
- 更准确地说,自x86支持virtualization(Intel VT-x,AMD-V)后rings有5个等级,第五个是ring -1,用于虚拟化模式下。
- 系统调用并非唯一一种特权等级切换的方式,比如Linux中的vDSO也可以使ring 0的代码执行在ring 3下。
涉及到的CPU指令,x86下面有三对:
int 0x80
/iret
- 32 bit下引入的fast system call:
sysenter
/sysexit
(自Intel Pentium II始),syscall
/sysret
(自AMD K6始)4 - 64 bit下的
syscall
/sysret
Linux提供的系统调用函数可以通过$ man 2 syscalls
来查看。自用户空间的程序视角来看,系统调用的执行分为用户态与内核态两部分代码。对于系统调用在内核态执行过程的技术细节,LWN的两篇文章Anatomy of a system call, part 1与Anatomy of a system call, part 2,还有Linux系统调用过程分析与Linux Inside: System calls in the Linux kernel. Part 1.、[译] Linux 系统调用权威指南,对此有很好的描述。需要明确的是,多数情况下在用户态使用的系统调用是glibc的封装,但它也提供了syscall()
这种很接近手写汇编语言的函数。至于glibc背后隐藏的细节,需要了解一下vDSO。
总结一下系统调用在用户空间与内核空间的执行过程,即:
- glibc对绝大多数系统调用进行了封装,未被封装的系统调用需要使用
syscall()
使用 - x86下通过执行三类指令之一进行跳转;进入入口函数执行之前,系统调用号以及相关参数会依照约定的规则入栈或存在寄存器中
int 0x80
走中断流程,跳转地址的初始化在中断初始化过程中- fast system call指令的跳转地址存储在MSR(Model-specific register)中
- trap内核空间;因为函数调用的时候存在栈结构的变化,因此在调用函数之前需要保存现场、设置
- 使用
call
指令跳转进入入口函数,根据系统调用号在syscall table中查找对应的系统调用并执行 - 系统调用执行结束后返回,恢复所保存的现场,使用配对的返回指令返回用户空间
Note:
一个普遍的观点是系统调用要比函数调用慢,而采用中断方式的系统调用又比另外两种指令慢33。背后的原因是?
直觉告诉我一种可能是系统调用替换了CS、IP寄存器,导致了CPU的流水线中断,从而带来了额外的开销。但实际上,函数调用call
指令也分为near call、far call(扫了一眼指令描述——看不懂),总之做的事情也会导致流水线中断。看了一下这里(系统调用真正的效率瓶颈在哪里?)的回答,的确在执行系统调用的代码中,CPU做了很多的事情(保存、调整了许多寄存器),软件做了许多事情(条件判断),还有一些同步操作(比如开中断内联汇编"sti": : :"memory"
会阻止编译器层次的乱序执行21)等,这些事情是函数调用没有的。int 0x80
对比fast system call, 它做了更多的工作,所以速度最慢也不奇怪了。
vsyscall与vDSO
Overview
对于vsyscall(virtual syscall)与vDSO(virtual dynamic shared object),Linux的man page、slides: The vDSO on arm64、LWN.net: On vsyscalls and the vDSO、Linux Inside: System calls in the Linux kernel. Part 3.提供了不少有用的信息。总结一下:
- vsyscall(virtual system call)提供了一种在用户空间下快速执行系统调用的方法
- 加速原理是对特定的系统调用使用函数调用代替
- map的起始地址固定(0xffffffffff600000),有潜在的安全风险
- 为了改善vsyscall的局限性,设计了vDSO
- 可以利用ASLR(address space layout randomization)增强安全性
- 用户无需在用户空间的代码中考虑CPU的差异性
- 比如用户空间代码无需考虑IA-32下的两类fast system call指令
- 出于兼容性的考虑保留了vsyscall
- vDSO是一个动态链接库,它
- 由内核提供
- map至每一个进程
终端上执行一系列操作后,64 bit的Linux v4.12会输出下面的内容:
1 | $ sudo echo 0 > /proc/sys/kernel/randomize_va_space # disable ASLR |
可以看到:
linux-vdso.so.1
的确不存在对应的文件- vsyscall映射一页:[vsyscall]
- vDSO会映射两块内存区域:代码段[vdso],只读变量区[vvar]
- vDSO在AMD64下暴露出4种系统调用
Note:无前缀__vdso_
的函数名是相应函数的弱符号别名(GCC属性:__attribute__ ((weak, alias ("<alias name>")));
)。
使用
vsyscall的使用
glibc在源码中定义了三个地址,他们由__vsyscall_page + <vsyscall address offset>
得到,可赋值给函数指针后使用。
1 | $ grep -rn VSYSCALL_ADDR_ # in glibc v2.21 |
Note:glibc在v2.22移除了对vsyscall的支持,commit 7cbeabac0fb28e24c99aaa5085e613ea543a2346。
一个使用vsyscall time()
function的例子:
1 |
|
vDSO的使用
一些背景知识:
- 辅助向量(auxiliary vector)
- 动态链接库的加载
- ELF(Executable and Linkable Format)格式
LWN.net: getauxval() and the auxiliary vector与About ELF Auxiliary Vectors给出了许多有关辅助向量的信息。大意是:辅助向量是内核向用户空间传递信息的一种机制,大多数类UNIX系统都提供了这项特性。它是可执行文件载入进程时构建的键值对,位于传递了一些运行时程序自身的信息,比如ELF文件相关、权限等,当然包括了vDSO的起始地址,这些信息的主要使用者是linker。GLIBC提供了getauxval()
函数查找辅助向量的值。
在Linux下的终端中打印辅助向量:
1 | $ od -t d8 /proc/self/auxv |
除了隐式运行时由linker自动加载动态链接库之外,glibc提供了dl*
系列API手动解析。只是dlopen()
的参数之一要求文件地址,而vDSO是由内核map至内存,并不存在对应的文件地址,所以也就无法使用上述API了。在glibc下,可以使用参数AT_SYSINFO_EHDR
通过getauxval()
获取vDSO map后的内存起始地址,再通过解析ELF 符号表得到对应的函数指针。还有一个辅助向量AT_SYSINFO
,它是系统调用函数在vDSO的入口地址,用于解决CPU的差异导致的系统调用指令不同的问题。
ELF很复杂,Wikipedia上有它的介绍,这里是AMD64的specification。
Linux提供了关于使用vDSO的demo,vdso_test.c依赖标准库,不依赖标准库的版本是vdso_standalone_test_x86.c。
在内核中的实现
Linux version 5.0,AMD64 architecture。
vsyscall的实现
与vsyscall有关的源码在arch/x86/entry/vsyscall/目录下。
固定映射区间的代码:
1 | /** |
v4.16之前,flags标志位可以是PAGE_KERNEL_VSYSCALL
(configuration: LEGACY_VSYSCALL_NATIVE
)或PAGE_KERNEL_VVAR
(configuration LEGACY_VSYSCALL_EMULATE
),他们的区别只是RX(可读可执行)与RO(只读)的区别,commit: 076ca272a14cea558b1092ec85cea08510283f2a后只有EMULATE了。对用户空间程序的影响是使用emulate_vsyscall()
函数还是vsyscall_emu_64.S中的代码进行vsyscall。
vsyscall_emu_64.S定义了vsyscall page大小与vsyscall允许的系统调用入口地址(三个:gettimeofday() / time() / getcpu()
。对其的字节数是1024(即0x400),所以那些系统调用之间会有0x400的固定偏移。
使用time()
这类系统调用获取的信息是动态变化的,vsyscall_gtod.c实现了更新这些信息的函数。大致工作过程是一个tick到来后,timekeeper模块会维护一些信息,更新的过程中便会调用他们。不过,这些信息在v5.0下不再提供给vsyscall,而是提供给vDSO使用(vsyscall_gtod_data
是被维护的信息,我在vsyscall相关的源码中并没有找到使用该变量的地方)。
1 | /** |
Note: 有关Linux下的时钟介绍:Linux 下的时钟。
实际上,v5.0在AMD64架构上对vsyscall的实现只有emulat。当产生缺页中段的时候,会检测地址是否位于vsyscall中,被模拟的系统调用选择,是根据内存地址决定的:
1 | /** |
vDSO的实现
与vDSO有关的源码在arch/x86/entry/vdso目录下,实际上在编译的过程中会生成若干文件,最终被编译进内核的是vdso-image-*.c、vma.c、vdso32-setup.c。vdso-image-*.c是使用程序vdso2c(由vdso2c.c编译)生成的,输入是vdso*.so*,其中有struct vdso_image
,它描述了vDSO的镜像内容。vdso.so的link script即vdso-layout.lds.S,vdso.lds.S,vdsox32.lds.S,vdso32.lds.S,第一个描述了vdso的layout,后三个是LD Version Scripts。这里给出了一些关于Linker Script的介绍。
在exec载入ELF的过程中,会调用arch_setup_additional_pages()
,在map_vdso()
中完成对[vdso]、[vvar]的map(因此vdso会在/proc/self/maps中出现两处map)。自v4.5起Linux采用了缺页中断的方式加载vdso的内容,commit: f872f5400cc01373d8e29d9c7a5296ccfaf4ccf3。map vdso后,会设置ELF的辅助向量。
vDSO中加速系统调用的原理与vsyscall一样,复用了vsyscall中获取、动态更新信息的代码(关键变量vsyscall_gtod_data
)。
借用一张ARM64下的vDSO原理图:
LWN.net: Implementing virtual system calls描述了如何在vDSO中实现virtual system call。
系统调用追踪
这张图给出了一些系统调用追踪工具(或者说在system libraries、system call interfaces层面的dynamic trace tools):
在dynamic trace方面了解很浅,与系统调用追踪相关的知识是从这两篇文章看到的:
总结一下:
- 用户空间追踪工具:
- 内核空间追踪工具:
Reference
- Wikipedia
- x86 and amd64 instruction reference
- The Linux man-pages project
- LWN.net
- Linux系统调用过程分析
- 64位Linux下的系统调用
- 知乎:系统调用真正的效率瓶颈在哪里?
- Stack Overflow: Working of __asm__ __volatile__ (“” : : : “memory”)
- slides: The vDSO on arm64
- Linux Inside
- GNU compilers documents: 6.30 Declaring Attributes of Functions
- About ELF Auxiliary Vectors
- LKML.org: Intel P6 vs P7 system call performance
- GNU Gnulib: 16.3 LD Version Scripts
- Category: Linker Script
- [译] Linux 系统调用权威指南
- [译] ltrace 是如何工作的
- [译] strace 是如何工作的
- 泰晓科技:源码分析:动态分析 Linux 内核函数调用关系
- ftrace: trace your kernel functions!
- Valgrind Documentation: 6. Callgrind: a call-graph generating cache and branch prediction profiler
- brendangregg.com: Linux Performance
结语
依稀记得,大三下学期,那时在上嵌入式程序设计这门课(其实就是Linux环境编程),调研了一下程序是如何运行的这个问题。那时写的Linux如何执行程序——内核态篇(废弃)与Linux如何执行程序——用户态篇(废弃)两篇,不仅烂尾了,而且内容是有不少问题的。
这半年多来,一直在阅读Linux源码与相关的文档,也做了不少与系统调用相关的工作。便用了半周,重新回顾了一下之前的工作、补充下未曾探究过的内容,围绕系统调用这一主题,写下了这篇文章。虽不及过去两篇那般涉及的范围广,但更加详尽、准确,也忽略了绝大部分细节。可惜的是,尽管17年年中的时候与Linux tracing技术有了第一次接触,现在仍然只能复制一下命令来使用。
在此过程中,意外地发现自己可以独立地分析部分内核源码了,许多之前不曾看懂的内容豁然开朗,也算这半年来的一个收获~