分享

Linux Kernel源码阅读:x86-64系统调用实现原理

 深度Linux 2024-04-25 发布于湖南

系统调用(system calls)是用户空间程序与Linux内核进行交互的主要机制。由于其重要性,内核包含了各种机制,以确保系统调用可以在不同体系结构上进行通用实现,并以高效且一致的方式提供给用户空间。

Linux 操作系统,为了避免用户程序非法操作设备资源,需要限制进程的操作权限,这样内核为用户程序提供了一组交互的接口,用户程序通过这组接口进行 系统调用,本文将会通过调试方式,从用户程序到内核,理解一下系统调用的工作流程。

一、系统调用

系统调用与常规函数调用不同,因为被调用的代码位于内核中。需要特殊指令来使处理器执行从用户态切换到特权态(ring 0)。此外,调用的内核代码通过系统调用号来标识,而不是函数地址。

当用户空间程序需要执行一个系统调用时,它会使用特定的指令(例如x86架构中的syscall指令)触发从用户态到内核态的切换。在进行切换时,处理器会将当前的上下文保存起来,包括寄存器状态和程序计数器等。然后,处理器会跳转到预定义的系统调用入口点,该入口点由系统调用号标识。

在内核中,系统调用表(system call table)维护了系统调用号与相应内核函数的映射关系。当处理器进入内核态并跳转到系统调用入口点时,内核会根据系统调用号找到对应的内核函数来执行相应的操作。内核函数完成后,处理器将恢复之前保存的上下文,并返回到用户空间程序继续执行。

通过使用系统调用号而不是函数地址,内核能够提供一种标准化的、跨平台的系统调用接口。不同的系统调用由唯一的系统调用号进行标识,这样用户空间程序可以使用相同的系统调用号在不同的操作系统上进行系统调用,而无需关心具体的内核实现。

Linux 应用程序要与内核通信,需要通过系统调用。系统调用,相当于用户空间和内核空间之间添加了一个中间层。

因此,系统调用的机制涉及从用户态到内核态的切换、系统调用号的标识和匹配,以及内核中相应的处理逻辑,以实现用户空间程序与内核的交互。

系统调用作用:

  1. 内核将复杂困难的逻辑封装起来,用户程序通过系统来操作硬件,极大简化了用户程序开发。

  2. 降低用户程序非法操作的风险,保证操作系统能安全,稳定地工作。

  3. 系统有效地分离了用户程序和内核开发。

  4. 通过接口访问黑盒操作,使得程序有更好的移植性。

二、 用户空间

我们以一个 Hello world 程序开始,逐步进入系统调用的学习。下面是用汇编代码写的一个简单的程序:

.section .datamsg:    .ascii "Hello World!\n"len = . - msg
.section .text.globl mainmain:
# ssize_t write(int fd, const void *buf, size_t count) mov $1, %rdi # fd mov $msg, %rsi # buffer mov $len, %rdx # count mov $1, %rax # write(2)系统调用号,64位系统为1 syscall
# exit(status) mov $0, %rdi # status mov $60, %rax # exit(2)系统调用号,64位系统为60 syscall

编译并运行:

$ gcc -o helloworld helloworld.s $ ./helloworldHello world!$ echo $?0

上面这段代码,是直接从我的一篇文章 使用 GNU 汇编语法编写 Hello World 程序的三种方法拷贝过来的。那篇文章里还提到了使用int 0x80软中断和printf函数实现输出的方法,有兴趣的可以去看下。

三、内核空间

用户空间通过 syscall 指令,从用户空间进入内核空间。

3.1内核调试

设置断点。在内核 write 函数名下断点,调试跟踪函数的调用堆栈。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>

static ssize_t my_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset)
{
/* 在这里设置断点 */

/* 打印调用堆栈 */
dump_stack();

/* 写入操作的具体实现 */
// ...

return len;
}

static struct file_operations fops = {
.write = my_write,
};

static int __init my_init(void)
{
/* 注册字符设备驱动程序 */
// ...

return 0;
}

static void __exit my_exit(void)
{
/* 注销字符设备驱动程序 */
// ...
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");

调试触发断点。查看函数调用堆栈,可以发现 syscall 指令触发 entry_SYSCALL_64 处理函数。

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
pid_t pid = getpid();

// 触发系统调用
syscall(39, pid, NULL, NULL);

return 0;
}

以上代码是一个简单的C程序,在执行期间会通过syscall函数触发系统调用。你可以将代码保存为test.c,然后使用gcc进行编译:gcc -o test test.c。

接下来,你可以使用GDB连接到生成的可执行文件并设置断点以及跟踪函数调用堆栈。在终端中输入gdb ./test启动GDB调试器。然后按照以下步骤进行操作:

  1. 在GDB提示符下输入命令:break main,设置一个断点在程序的main函数处。

  2. 输入命令: run ,运行程序。

  3. 当程序运行到syscall指令时,会进入内核并跳转到相应的系统调用处理函数(例如entry_SYSCALL_64)。

  4. 在entry_SYSCALL_64处理函数处会自动停下,此时你可以使用命令: bt(backtrace) 或者 where 来查看函数调用堆栈信息。

3.2系统调用入口

entry_SYSCALL_64 是 64 位 syscall 指令 入口函数,这个函数通常是由操作系统提供并负责处理所有来自用户空间发起的系统调用请求。具体实现可能因不同的操作系统而有所差异,但其作用都是为了协调用户空间和内核空间之间的交互。在不同的架构或操作系统上,对于syscall指令和相应处理函数名称可能会有所不同。例如,在32位x86架构上使用entry_INT80_32来处理syscall指令。因此,请根据目标平台和操作系统环境选择正确的符号名称和相关文档来进行调试和理解

初始化系统调用。当 linux 内核启动时,MSR 特殊模块寄存器会存储 syscall 指令的入口函数地址;当 syscall 指令执行后,系统从特殊模块寄存器中取出入口函数地址进行调用。

#include <linux/kernel.h>
#include <linux/module.h>

MODULE_LICENSE("GPL");

// 声明一个简单的系统调用函数
asmlinkage long my_syscall(void)
{
printk(KERN_INFO "Hello from custom syscall!\n");
return 0;
}

// 初始化系统调用表
static void init_syscall_table(void)
{
// 获取syscall table地址
unsigned long *syscall_table = (unsigned long *)kallsyms_lookup_name("sys_call_table");

// 替换对应系统调用函数指针
write_cr0(read_cr0() & (~0x10000)); // 关闭写保护

syscall_table[__NR_my_syscall] = (unsigned long)my_syscall; // 将自定义系统调用函数指针存储在syscall table中

write_cr0(read_cr0() | 0x10000); // 开启写保护
}

static int __init my_module_init(void)
{
init_syscall_table();

printk(KERN_INFO "Custom syscall module loaded\n");

return 0;
}

static void __exit my_module_exit(void)
{
printk(KERN_INFO "Custom syscall module unloaded\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

入口函数工作流程:

  1. 程序从用户空间进入内核空间,保存用户态现场,载入内核态的信息,程序工作状态从用户态转变为内核态。

  2. 根据系统调用号,从系统跳转表中,调用对应的系统调用函数。

  3. 系统调用函数完成逻辑后,需要从内核空间回到用户空间,程序内核态转变为用户态,需要把之前保存的用户态现场进行恢复。

ENTRY(entry_SYSCALL_64)
TRACE_IRQS_OFF
subq $FRAME_SIZE, %rsp /* Reserve space for pt_regs */
MOV_LDX(regs, %rsp) /* Save user stack pointer */
cmpl $(nr_syscalls),%eax /* syscall number valid? */
jae badsys

/*
* Load the syscall table pointer into r10 from a global variable.
* We stash it in memory at boot time to workaround boot loader
* address randomization.
*
* movl sys_call_table(,%rax,8),%r10
*
* can be replaced with this:
*
* leal sys_call_table(%rip),%r10
* movq (%r10,%rax,8),%r10
*/

.section ".data", "a"

sys_call_table:
.quad __x64_sys_call_table- sys_call_table

.section ".text", "ax"

leaq sys_call_table(%rip),%r10 /* Get the syscall table address into r10 */
movq (%r10,%rax,8), %r10 /* Load the corresponding system call handler */

在这段代码中,我们可以看到以下几个关键步骤:

  1. 首先,通过 subq 指令为 pt_regs 结构体在用户栈上分配空间,用于保存系统调用的参数和返回值。

  2. 然后,将用户栈指针 %rsp 的值保存到 regs 寄存器中,以便在系统调用处理函数中可以访问到用户栈上的参数。

  3. 接下来,通过 cmpl 指令检查系统调用号是否有效。如果系统调用号大于等于 nr_syscalls(即 sys_call_table 数组的长度),则跳转到 badsys 标签处进行错误处理。

  4. 紧接着,使用 leaq 和 movq 指令加载 syscall table 的地址,并从表中获取对应的系统调用处理函数地址,存储在寄存器 %r10 中。这里有两种不同的实现方式,一种是直接使用全局变量 sys_call_table 获取 syscall table 的地址;另一种是先通过 RIP 相对寻址获取 sys_call_table 地址,并再从表中获取对应的系统调用处理函数地址。

然后,在代码中还有其他一些逻辑和错误处理部分,在此就不一一列举了。

gdb 反汇编查看 entry_SYSCALL_64 函数功能

(1)编译内核并启动调试模式:

make menuconfig  # 配置内核选项(可根据需要进行配置)
make -j$(nproc) # 编译内核
sudo gdb vmlinux # 启动 gdb,并加载编译好的内核文件

(2)在gdb中设置断点:

break entry_SYSCALL_64  # 在 entry_SYSCALL_64 函数处设置断点

(3)启动内核调试:

target remote :1234  # 连接到 QEMU 调试服务器(如果使用 QEMU 进行内核调试)
continue # 继续执行,使程序运行到设置的断点处

(4)反汇编查看代码:

disassemble /m entry_SYSCALL_64  # 使用 disassemble 命令反汇编 entry_SYSCALL_64 函数

struct pt_regs。程序在系统调用后,从用户空间进入内核空间,保存用户态现场,保存用户态传入参数。

/* arch/x86/include/asm/ptrace.h */
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10; /* 程序传递到内核的第 4 个参数。 */
unsigned long r9; /* 程序传递到内核的第 6 个参数。 */
unsigned long r8; /* 程序传递到内核的第 5 个参数。 */
unsigned long ax; /* 程序传递到内核的系统调用号。 */
unsigned long cx; /* 程序传递到内核的 syscall 的下一条指令地址。 */
unsigned long dx; /* 程序传递到内核的第 3 个参数。 */
unsigned long si; /* 程序传递到内核的第 2 个参数。 */
unsigned long di; /* 程序传递到内核的第 1 个参数。 */
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax; /* 系统调用号。 */
/* Return frame for iretq
* 内核态返回用户态需要恢复现场的数据。*/
unsigned long ip; /* 保存程序调用 syscall 的下一条指令地址。 */
unsigned long cs; /* 用户态代码起始段地址。 */
unsigned long flags; /* 用户态的 CPU 标志。 */
unsigned long sp; /* 用户态的栈顶地址(栈内存是向下增长的)。 */
unsigned long ss; /* 用户态的数据段地址。 */
/* top of stack page */
};

3.3do_syscall_64

do_syscall_64 函数是 Linux 内核中的关键函数之一,它的主要功能是处理 64 位系统调用。当用户程序通过软件中断(syscall)发起系统调用请求时,内核会将控制转移到 do_syscall_64 函数来执行相应的操作。

具体而言,do_syscall_64 函数完成以下主要功能:

获取系统调用号:从当前进程的 CPU 寄存器或栈中获取系统调用号,以确定用户程序请求执行哪个特定的系统调用。

参数传递:根据系统调用约定,从当前进程的寄存器或堆栈中提取相应数量和类型的参数,并将这些参数传递给相应的系统调用处理函数。

权限检查:验证当前进程是否有足够权限执行所请求的系统调用。这可能涉及访问权限、资源配额、权限级别等方面的检查。

系统调用执行:将控制权转移给与所请求系统调用对应的内核函数,以便在内核模式下执行特定操作。

结果返回:如果需要,将系统调用执行结果返回给用户空间,并更新相应寄存器或内存位置以供用户程序读取结果。

ENTRY(entry_SYSCALL_64)
...
call do_syscall_64 /* returns with IRQs disabled */
...
END(entry_SYSCALL_64)
/* arch/x86/entry/common.c */
#ifdef CONFIG_X86_64
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs) {
struct thread_info *ti;
...
/*
* NB: Native and x32 syscalls are dispatched from the same
* table. The only functional difference is the x32 bit in
* regs->orig_ax, which changes the behavior of some syscalls.
*/
nr &= __SYSCALL_MASK;
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
/* 通过系统调用跳转表,调用系统调用号对应的函数。
* 函数返回值保存在 regs->ax 里,最后将这个值,保存到 rax 寄存器传递到用户空间。 */
regs->ax = sys_call_table[nr](regs);
}

syscall_return_slowpath(regs);
}
#endif

3.4系统调用表

系统调用表 syscall_64.tbl,建立了系统调用号与系统调用函数名的映射关系。脚本会根据这个表,自动生成相关的映射源码。

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

// 定义系统调用号与函数名的映射数组
static const char *syscall_names[] = {
[0] = "sys_read",
[1] = "sys_write",
[2] = "sys_open",
// ...
};

int main() {
int i;

// 遍历系统调用号并打印对应的函数名
for (i = 0; i < sizeof(syscall_names) / sizeof(syscall_names[0]); i++) {
printf("Syscall number %d: %s\n", i, syscall_names[i]);
}

return 0;
}

3.5系统跳转表(sys_call_table)

运行流程。系统调用的执行流程如下,但是系统调用号、系统跳转表,系统调用函数,这三者是如何关联起来的呢?

系统调用的执行流程如下:

  1. 用户程序通过编写系统调用号(或者使用对应的库函数)来请求操作系统提供某项服务。

  2. 当用户程序发起系统调用时,会触发处理器从用户态切换到内核态,进入特权模式。

  3. 处理器将控制权交给操作系统内核,并传递系统调用号以及其他必要的参数。

  4. 操作系统内核根据系统调用号在系统调用表中查找相应的处理函数地址。

  5. 内核跳转到对应的系统调用处理函数,开始执行具体的操作。

  6. 执行完毕后,将结果返回给用户程序,并再次切换回用户态。

关于系统调用号、系统跳转表和系统调用函数之间的关联:

系统调用号:每个系统调用都被赋予一个唯一的编号。例如,在 Linux 中使用 x86_64 架构时,可以在 syscall_64.tbl 文件中找到这些编号定义。它们为每个操作分配了一个特定的数字标识符。

系统跳转表:在内核中,有一个称为“system_call”或类似名称的特殊位置存储着一个指向所有系统调用处理函数地址数组(也称为“sys_call_table”)的指针。该数组包含了所有可能存在的系统调用处理函数地址。

系统调用函数:每个具体的功能对应一个系统调用函数,它们是内核中的实现代码。这些函数通过在系统跳转表中查找与其对应的位置来进行调用。

当用户程序触发系统调用时,操作系统根据系统调用号从系统跳转表中获取对应的处理函数地址,并执行该函数来完成请求的操作。因此,通过系统调用号和系统跳转表,操作系统能够将用户程序的请求路由到正确的系统调用函数上。

syscall's number -> syscall -> entry_SYSCALL_64 -> do_syscall_64 -> sys_call_table -> __x64_sys_write

sys_call_table 的定义。#include <asm/syscalls_64.h> 这行源码对应的文件是在内核编译的时候,通过脚本创建的。

/* include/generated/asm-offsets.h */
#define __NR_syscall_max 547 /* sizeof(syscalls_64) - 1 */

/* arch/x86/entry/syscall_64.c */
#define __SYSCALL_64(nr, sym, qual) [nr] = sym,

/* arch/x86/entry/syscall_64.c */
asmlinkage 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>
};

Makefile。通过执行 syscalltbl.sh 脚本,解析系统调用文件 syscall_64.tbl 数据,自动生成 syscalls_64.h。

# arch/x86/entry/syscalls/Makefile
syscall64 := $(srctree)/$(src)/syscall_64.tbl
systbl := $(srctree)/$(src)/syscalltbl.sh
quiet_cmd_systbl = SYSTBL $@
cmd_systbl = $(CONFIG_SHELL) '$(systbl)' $< $@

syscalltbl.sh

# arch/x86/entry/syscalls/syscalltbl.sh
...
syscall_macro() {
abi="$1"
nr="$2"
entry="$3"

# Entry can be either just a function name or "function/qualifier"
real_entry="${entry%%/*}"
if [ "$entry" = "$real_entry" ]; then
qualifier=
else
qualifier=${entry#*/}
fi

echo "__SYSCALL_${abi}($nr, $real_entry, $qualifier)"
}
...

syscalls_64.h 文件内容

/* arch/x86/include/generated/asm/syscalls_64.h */
...
#ifdef CONFIG_X86
__SYSCALL_64(0, __x64_sys_read, )
#else /* CONFIG_UML */
__SYSCALL_64(0, sys_read, )
#endif
#ifdef CONFIG_X86
__SYSCALL_64(1, __x64_sys_write, )
#else /* CONFIG_UML */
__SYSCALL_64(1, sys_write, )
#endif
...

三者关系。通过上述操作,sys_call_table 的定义与 syscalls_64.h 文件内容结合起来就是一个完整的数组初始化,将系统调用号,系统调用函数,系统跳转表三者结合起来了。

asmlinkage 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,
[0] = __x64_sys_read,
[1] = __x64_sys_write,
...

系统调用函数。现在虽然搞清楚了系统调用的关系,但是还没有发现 __x64_sys_write 这个函数是在哪里定义的。答案就在这个宏 SYSCALL_DEFINE3,将这个宏展开,回头再看上面 gdb 调试断点截断处的那些函数,整个思路就清晰了。

__do_sys_write() (/root/linux-5.0.1/fs/read_write.c:610)
__se_sys_write() (/root/linux-5.0.1/fs/read_write.c:607)
__x64_sys_write(const struct pt_regs * regs) (/root/linux-5.0.1/fs/read_write.c:607)
...

/* fs/read_write.c */
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count) {
return ksys_write(fd, buf, count);
}

/* include/linux/syscalls.h */
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

/* arch/x86/include/asm/syscall_wrapper.h */
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long __x64_sys##name(const struct pt_regs *regs); \
ALLOW_ERROR_INJECTION(__x64_sys##name, ERRNO); \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
asmlinkage long __x64_sys##name(const struct pt_regs *regs) \
{ \
return __se_sys##name(SC_X86_64_REGS_TO_ARGS(x,__VA_ARGS__)); \
} \
__IA32_SYS_STUBx(x, name, __VA_ARGS__) \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))

四、系统调用的定义

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;
/* ... */

4.1SYSCALL_METADATA

其中之一是SYSCALL_METADATA()宏,用于构建关于系统调用的元数据,以便进行跟踪。只有在内核构建时定义了CONFIG_FTRACE_SYSCALLS时才会展开该宏,展开后它会生成描述系统调用及其参数的数据的样板定义。(单独的页面详细描述了这些定义。)

SYSCALL_METADATA()宏主要用于在内核中进行系统调用的跟踪和分析。当启用了CONFIG_FTRACE_SYSCALLS配置选项进行内核构建时,宏会展开,并生成一系列用于描述系统调用及其参数的元数据定义。这些元数据包括系统调用号、参数个数、参数类型等信息,用于记录和分析系统调用的执行情况。

通过使用SYSCALL_METADATA()宏,内核能够在编译时生成系统调用的元数据,以支持跟踪工具对系统调用的监控和分析。这些元数据的定义是一种样板代码,提供了系统调用的相关信息,帮助开发人员和调试工具在系统调用层面进行问题排查和性能优化。

4.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

4.3SYSCALL_ALIAS

SYSCALL_ALIAS宏定义如下:

// file: include/linux/linkage.h
#ifndef SYSCALL_ALIAS
#define SYSCALL_ALIAS(alias, name) asm( \
".globl " VMLINUX_SYMBOL_STR(alias) "\n\t" \
".set " VMLINUX_SYMBOL_STR(alias) "," \
VMLINUX_SYMBOL_STR(name))
#endif

宏VMLINUX_SYMBOL_STR定义如下:

// file: include/linux/export.h
/*
* Export symbols from the kernel to modules. Forked from module.h
* to reduce the amount of pointless cruft we feed to gcc when only
* exporting a simple symbol or two.
*
* Try not to add #includes here. It slows compilation and makes kernel
* hackers place grumpy comments in header files.
*/
/* Indirect, so macros are expanded before pasting. */
#define VMLINUX_SYMBOL(x) __VMLINUX_SYMBOL(x)
#define VMLINUX_SYMBOL_STR(x) __VMLINUX_SYMBOL_STR(x)

#define __VMLINUX_SYMBOL(x) x
#define __VMLINUX_SYMBOL_STR(x) #x

实际效果是给name设置了个别名alias,本例中是给SyS_write设置了别名sys_write。

4.4Syscall 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数据结构。

即由一个 Makefile文件中在编译 Linux 系统内核时调用了一个脚本,这个脚本文件会读取 syscall_64.tbl 文件,根据其中信息生成相应的文件 syscall_64.h。

// arch/x86/syscalls/Makefile

syscall64 := $(srctree)/$(src)/syscall_64.tbl

systbl := $(srctree)/$(src)/syscalltbl.sh

$(out)/syscalls_64.h: $(syscall64) $(systbl)
$(call if_changed,systbl)

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()):

/*
* Non-implemented system calls get redirected here.
*/
asmlinkage long sys_ni_syscall(void)
{
return -ENOSYS;
}
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>
};

sys_call_table 数组,第一次全部初始化为默认系统调用函数 sys_ni_syscall,这个函数什么都不干,这是为了防止数组有些元素中没有函数地址,从而导致调用失败。

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包装器。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多