文章目录
- 前言
- 一、简介
- 二、Defining a syscall with SYSCALL_DEFINEn()
- 2.1 SYSCALL_METADATA
- 2.2 __SYSCALL_DEFINEx
- 三、Syscall table entries
- 四、x86_64 syscall invocation
- 参考资料
前言
本文来自 https://lwn.net/Articles/604287/
一、简介
系统调用(system calls)是用户空间程序与Linux内核进行交互的主要机制。由于其重要性,内核包含了各种机制,以确保系统调用可以在不同体系结构上进行通用实现,并以高效且一致的方式提供给用户空间。
这篇文章探讨了内核对系统调用(或syscalls)的实现细节。在本文中,我们将重点讨论主流情况下的机制:普通系统调用(read())的机制,以及允许x86_64用户程序调用它的机制。
对于x86(32bit)请参考:https://lwn.net/Articles/604515/
系统调用与常规函数调用不同,因为被调用的代码位于内核中。需要特殊指令来使处理器执行从用户态切换到特权态(ring 0)。此外,调用的内核代码通过系统调用号来标识,而不是函数地址。
当用户空间程序需要执行一个系统调用时,它会使用特定的指令(例如x86架构中的syscall指令)触发从用户态到内核态的切换。在进行切换时,处理器会将当前的上下文保存起来,包括寄存器状态和程序计数器等。然后,处理器会跳转到预定义的系统调用入口点,该入口点由系统调用号标识。
在内核中,系统调用表(system call table)维护了系统调用号与相应内核函数的映射关系。当处理器进入内核态并跳转到系统调用入口点时,内核会根据系统调用号找到对应的内核函数来执行相应的操作。内核函数完成后,处理器将恢复之前保存的上下文,并返回到用户空间程序继续执行。
通过使用系统调用号而不是函数地址,内核能够提供一种标准化的、跨平台的系统调用接口。不同的系统调用由唯一的系统调用号进行标识,这样用户空间程序可以使用相同的系统调用号在不同的操作系统上进行系统调用,而无需关心具体的内核实现。
因此,系统调用的机制涉及从用户态到内核态的切换、系统调用号的标识和匹配,以及内核中相应的处理逻辑,以实现用户空间程序与内核的交互。
二、Defining a syscall with SYSCALL_DEFINEn()
read()系统调用是一个很好的初始示例,可以用来探索内核的系统调用机制。它在fs/read_write.c中作为一个简短的函数实现,大部分工作由vfs_read()函数处理。从调用的角度来看,这段代码最有趣的地方是函数是如何使用SYSCALL_DEFINE3()宏来定义的。实际上,从代码中,甚至并不立即清楚该函数被称为什么。
// linux-3.10/fs/read_write.c
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
struct fd f = fdget(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos = file_pos_read(f.file);
ret = vfs_read(f.file, buf, count, &pos);
file_pos_write(f.file, pos);
fdput(f);
}
return ret;
}
这些SYSCALL_DEFINEn()宏是内核代码定义系统调用的标准方式,其中n后缀表示参数计数。这些宏的定义(在include/linux/syscalls.h中)为每个系统调用提供了两个不同的输出。
// include/linux/syscalls.h
#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)
// include/linux/syscalls.h
#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)
SYSCALL_METADATA(_read, 3, unsigned int, fd, char __user *, buf, size_t, count)
__SYSCALL_DEFINEx(3, _read, unsigned int, fd, char __user *, buf, size_t, count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
/* ... */
2.1 SYSCALL_METADATA
其中之一是SYSCALL_METADATA()宏,用于构建关于系统调用的元数据,以便进行跟踪。只有在内核构建时定义了CONFIG_FTRACE_SYSCALLS时才会展开该宏,展开后它会生成描述系统调用及其参数的数据的样板定义。(单独的页面详细描述了这些定义。)
SYSCALL_METADATA()宏主要用于在内核中进行系统调用的跟踪和分析。当启用了CONFIG_FTRACE_SYSCALLS配置选项进行内核构建时,宏会展开,并生成一系列用于描述系统调用及其参数的元数据定义。这些元数据包括系统调用号、参数个数、参数类型等信息,用于记录和分析系统调用的执行情况。
通过使用SYSCALL_METADATA()宏,内核能够在编译时生成系统调用的元数据,以支持跟踪工具对系统调用的监控和分析。这些元数据的定义是一种样板代码,提供了系统调用的相关信息,帮助开发人员和调试工具在系统调用层面进行问题排查和性能优化。
2.2 __SYSCALL_DEFINEx
__SYSCALL_DEFINEx()部分更加有趣,因为它包含了系统调用的实现。一旦各种宏和GCC类型扩展层层展开,生成的代码包含一些有趣的特性:
#define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
SYSCALL_ALIAS(sys##name, SyS##name); \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__))
asmlinkage long sys_read(unsigned int fd, char __user * buf, size_t count)
__attribute__((alias(__stringify(SyS_read))));
static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count);
asmlinkage long SyS_read(long int fd, long int buf, long int count);
asmlinkage long SyS_read(long int fd, long int buf, long int count)
{
long ret = SYSC_read((unsigned int) fd, (char __user *) buf, (size_t) count);
asmlinkage_protect(3, ret, fd, buf, count);
return ret;
}
static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
/* ... */
[root@localhost ~]# uname -r
3.10.0-693.el7.x86_64
[root@localhost ~]# cat /proc/kallsyms | grep '\<sys_read\>'
ffffffff812019e0 T sys_read
[root@localhost ~]# cat /proc/kallsyms | grep '\<SYSC_read\>'
[root@localhost ~]# cat /proc/kallsyms | grep '\<SyS_read\>'
ffffffff812019e0 T SyS_read
SYSC_read函数是 inline inline ,没有对应的符号。
首先,我们注意到系统调用的实现实际上被命名为SYSC_read(),但它是静态的,因此在该模块之外是无法访问的。相反,有一个封装函数名为SyS_read(),并在外部以sys_read()的别名可见。仔细观察这些别名,我们注意到它们的参数类型存在差异 - sys_read()期望显式声明的类型(例如第二个参数的char __user *),而SyS_read()只期望一组(long)整数。深入研究其历史,我们发现长整型版本确保对于某些64位内核平台,32位值得到了正确的符号扩展,从而避免了一个历史性的漏洞。
在SyS_read()封装函数中,我们还注意到了asmlinkage指令和asmlinkage_protect()函数调用。《Kernel Newbies FAQ》提供了有用的解释,asmlinkage指令表示函数应该从堆栈中获取参数,而不是从寄存器中获取。而asmlinkage_protect()函数的通用定义解释了它的作用,即防止编译器假设可以安全地重用堆栈的这些区域。
除了sys_read()(带有准确类型的变体)的定义外,还在include/linux/syscalls.h中进行了声明。这使得其他内核代码可以直接调用系统调用的实现(在几个地方会这样调用)。通常情况下,不鼓励从内核的其他位置直接调用系统调用,并且这种情况并不常见。
// include/linux/syscalls.h
asmlinkage long sys_read(unsigned int fd, char __user *buf, size_t count);
三、Syscall table entries
寻找调用sys_read()的函数还有助于了解用户空间如何调用该函数。对于没有提供自己覆盖的"通用"架构,include/uapi/asm-generic/unistd.h文件中包含了一个引用sys_read的条目:
// include/uapi/asm-generic/unistd.h
#define __NR_read 63
__SYSCALL(__NR_read, sys_read)
这个定义为read()定义了通用的系统调用号__NR_read(63),并使用__SYSCALL()宏以特定于体系结构的方式将该号码与sys_read()关联起来。例如,arm64使用asm-generic/unistd.h头文件填充一个表格,将系统调用号映射到实现函数指针。
然而,我们将集中讨论x86_64架构,它不使用这个通用表格。相反,x86_64架构在arch/x86/syscalls/syscall_64.tbl中定义了自己的映射,其中包含sys_read()的条目:
// arch/x86/syscalls/syscall_64.tbl
#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The abi is "common", "64" or "x32" for this file.
#
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
4 common stat sys_newstat
......
这表明在x86_64架构上,read()的系统调用号为0(不是63),并且对于x86_64的两种ABI(应用二进制接口),即sys_read(),有一个共同的实现。(关于不同的ABI将在本系列的第二部分中讨论。)syscalltbl.sh脚本从syscall_64.tbl表生成arch/x86/include/generated/asm/syscalls_64.h文件,具体为sys_read()生成对__SYSCALL_COMMON()宏的调用。然后,该头文件用于填充syscall表sys_call_table,这是一个关键的数据结构,将系统调用号映射到sys_name()函数。
// arch/x86/syscalls/syscalltbl.sh
#!/bin/sh
in="$1"
out="$2"
grep '^[0-9]' "$in" | sort -n | (
while read nr abi name entry compat; do
abi=`echo "$abi" | tr '[a-z]' '[A-Z]'`
if [ -n "$compat" ]; then
echo "__SYSCALL_${abi}($nr, $entry, $compat)"
elif [ -n "$entry" ]; then
echo "__SYSCALL_${abi}($nr, $entry, $entry)"
fi
done
) > "$out"
在x86_64架构中,syscalltbl.sh脚本使用syscall_64.tbl表格生成了arch/x86/include/generated/asm/syscalls_64.h文件。其中,对于sys_read()的定义会包含类似以下的代码:
__SYSCALL_COMMON(0, sys_read)
这个宏的调用将系统调用号0和sys_read()函数关联起来。然后,arch/x86/include/generated/asm/syscalls_64.h文件会被其他代码引用,用于填充sys_call_table数据结构。
sys_call_table是一个数组,其中每个元素对应一个系统调用号,它将系统调用号映射到相应的sys_name()函数。在这种情况下,sys_read()函数将与系统调用号0关联起来,以便当用户空间发起sys_read()的系统调用请求时,内核可以根据系统调用号从sys_call_table中找到sys_read()函数并执行。这样,内核就能正确处理用户空间对read()的系统调用请求。
四、x86_64 syscall invocation
现在我们将看一下用户空间程序如何调用系统调用。这是与体系结构相关的,因此在本文的剩余部分,我们将集中讨论x86_64架构(其他x86架构将在本系列的第二篇文章中进行讨论)。调用过程涉及几个步骤,如下图所示:
在前面的部分中,我们发现了一个系统调用函数指针表;对于x86_64,这个表格大致如下(使用GCC的一个数组初始化扩展,确保任何缺少的条目指向sys_ni_syscall()):
typedef void (*sys_call_ptr_t)(void);
extern void sys_ni_syscall(void);
const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
/*
* Smells like a compiler bug -- it doesn't work
* when the & below is removed.
*/
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
[0] = sys_read,
[1] = sys_write,
/*... */
};
对于64位代码,这个表格可以从arch/x86/kernel/entry_64.S中的system_call汇编入口点进行访问;它使用RAX寄存器选择数组中的相关条目,并调用它。
// arch/x86/kernel/entry_64.S
/*
* Register setup:
* rax system call number
* rdi arg0
* rcx return address for syscall/sysret, C arg3
* rsi arg1
* rdx arg2
* r10 arg3 (--> moved to rcx for C)
* r8 arg4
* r9 arg5
* r11 eflags for syscall/sysret, temporary for C
* r12-r15,rbp,rbx saved by C code, not touched.
*
* Interrupts are off on entry.
* Only called from user space.
*
* XXX if we had a free scratch register we could save the RSP into the stack frame
* and report it properly in ps. Unfortunately we haven't.
*
* When user can change the frames always force IRET. That is because
* it deals with uncanonical addresses better. SYSRET has trouble
* with them due to bugs in both AMD and Intel CPUs.
*/
ENTRY(system_call)
......
GLOBAL(system_call_after_swapgs)
movq %rsp,PER_CPU_VAR(old_rsp)
movq PER_CPU_VAR(kernel_stack),%rsp
/*
* No need to follow this irqs off/on section - it's straight
* and short:
*/
ENABLE_INTERRUPTS(CLBR_NONE)
SAVE_ARGS 8,0
......
movq %r10,%rcx
call *sys_call_table(,%rax,8) # XXX: rip relative
movq %rax,RAX-ARGOFFSET(%rsp)
在函数的早期,SAVE_ARGS宏将各种寄存器推入栈中,以匹配之前我们看到的asmlinkage指令。
// arch/x86/include/asm/calling.h
.macro SAVE_ARGS addskip=0, save_rcx=1, save_r891011=1
subq $9*8+\addskip, %rsp
CFI_ADJUST_CFA_OFFSET 9*8+\addskip
movq_cfi rdi, 8*8
movq_cfi rsi, 7*8
movq_cfi rdx, 6*8
.if \save_rcx
movq_cfi rcx, 5*8
.endif
movq_cfi rax, 4*8
.if \save_r891011
movq_cfi r8, 3*8
movq_cfi r9, 2*8
movq_cfi r10, 1*8
movq_cfi r11, 0*8
.endif
.endm
这个宏的目的是将函数调用中的参数保存到内核栈上,以便在函数执行过程中可以访问这些参数。通过在函数调用前使用这个宏,可以将参数保存到指定的寄存器和栈空间中,以便在函数执行过程中使用。
在向外扩展的过程中,system_call入口点本身在syscall_init()函数中被引用,该函数在内核启动序列的早期被调用:
void syscall_init(void)
{
/*
* LSTAR and STAR live in a bit strange symbiosis.
* They both write to the same internal register. STAR allows to
* set CS/DS but only a 32bit target. LSTAR sets the 64bit rip.
*/
wrmsrl(MSR_STAR, ((u64)__USER32_CS)<<48 | ((u64)__KERNEL_CS)<<32);
wrmsrl(MSR_LSTAR, system_call);
wrmsrl(MSR_CSTAR, ignore_sysret);
......
wrmsrl指令用于向特定于模型的寄存器(Model-Specific Register,MSR)写入一个值。在这种情况下,它将通用的system_call系统调用处理函数的地址写入了寄存器MSR_LSTAR(0xc0000082),该寄存器是用于处理x86_64架构中的SYSCALL指令的特定于模型的寄存器。
在x86_64架构中,SYSCALL指令用于从用户空间转移到内核空间,执行系统调用。当发生SYSCALL指令时,处理器会读取MSR_LSTAR寄存器中的地址,并跳转到该地址执行系统调用处理函数。
通过将通用system_call系统调用处理函数的地址写入MSR_LSTAR寄存器,内核告诉处理器将SYSCALL指令的控制权转移到该函数的地址。这样,当用户空间程序发起系统调用时,处理器会跳转到相应的system_call系统调用处理函数,从而执行相应的系统调用操作。
SYSCALL指令:
SYSCALL指令在特权级0下调用操作系统的系统调用处理程序。它通过从IA32_LSTAR MSR(特定于模型的寄存器)加载RIP(指令指针)来实现这一点,同时将SYSCALL指令后面的指令地址保存到RCX寄存器中。(WRMSR指令确保IA32_LSTAR MSR始终包含规范地址)。
MSR_LSTAR寄存器:
这些信息足以将用户空间与内核代码联系起来。在x86_64架构中,用户程序调用系统调用的标准ABI是将系统调用号(例如,读取操作的系统调用号为0)放入RAX寄存器中,将其他参数放入特定寄存器中(第1个参数放入RDI,第2个参数放入RSI,第3个参数放入RDX),然后发出SYSCALL指令。这个指令会导致处理器转换到特权级0,并调用由MSR_LSTAR特定于模型的寄存器引用的代码(即system_call)。system_call代码将寄存器推入内核栈中,并调用sys_call_table表中entry RAX处的函数指针,也就是sys_read()。sys_read()是对真实实现SYSC_read()的asmlinkage包装器。
(1)
在Linux启动之时,会进行一系列的初始化过程。其中,系统调用的初始化在文件:
// arch/x86/kernel/cpu/common.c
void syscall_init(void)
{
......
wrmsrl(MSR_LSTAR, system_call);
......
}
(2)
read() --> userspace
-->SYSCALL指令
-->RIP = IA32_LSTAR MSR = system_call
-->sys_call_table[rax]
-->sys_read()
参考资料
https://lwn.net/Articles/604287/
https://juejin.cn/post/7203681024236355639