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

前言:在 Linux 的广袤世界中,Kernel 源码犹如一座神秘而宏伟的城堡,等待着我们去探索和解读。今天,让我们聚焦于 x86-64 架构下的 Linux Kernel,深入剖析其中系统调用的实现细节。系统调用作为连接用户空间与内核空间的关键桥梁,在整个操作系统的运行中起着至关重要的作用。它不仅决定了应用程序如何与内核进行交互,更影响着系统的性能、稳定性和安全性。那么,在 x86-64 架构下,Linux Kernel 是如何实现系统调用的呢?让我们一同揭开这神秘的面纱。

一、X86-64系统调用概述

在 Linux Kernel 中,x86-64 系统调用占据着至关重要的地位。系统调用是用户空间程序与内核进行交互的主要机制。与普通函数调用相比,存在着显著的区别。

首先,普通函数调用是直接调用,比如在代码中一个函数调用另一个函数,对应的机器指令是 call 指令,直接跳转到被调函数的第一条机器指令所在的内存地址继续执行。而系统调用是间接调用,使用 syscall 指令时,需将系统调用的序号写入 rax 寄存器,CPU 通过读取 rax 寄存器的值确定调用哪个内核函数。

其次,在执行指令时模式切换不同。普通函数调用时,CPU 运行在用户态,在这种模式下不能执行某些特权指令,程序受到一定限制。而当执行系统调用时,CPU 会切换到内核态,可以执行任何特权指令,不受限制,此时操作系统成为真正管理计算机的 “大 boss”。

另外,普通函数调用所使用的栈全部位于进程的栈区。而系统调用时,当 CPU 开始执行操作系统的代码,会跳转到操作系统某个特定内存区域,即内核栈,每个进程在内核中都有自己的内核栈。

系统调用在 Linux 中有着重要用途。它可以作为硬件资源和用户空间的抽象接口,控制硬件,如读写文件等操作;可以设置系统状态或读取内核数据,是用户空间和内核的唯一通讯手段;还能进行进程管理,保证系统中进程能以多任务在虚拟内存环境下得以运行。

图片

二、基础知识铺垫

2.1系统调用简介

系统调用在操作系统中扮演着关键的角色,它是用户空间程序与内核交互的主要机制。当用户空间的程序需要执行一些特定的操作,如访问硬件设备、进行文件操作或者进行进程管理等,这些操作不能直接由用户空间的程序执行,而是需要通过系统调用来请求内核代为完成。

在执行系统调用时,需要特殊的指令以使处理器的权限从用户态转换到内核态。在 x86-64 架构下,通常使用 syscall 指令来触发系统调用。另外,被调用的内核代码不是通过函数地址来标识,而是由系统调用号来确定具体要执行的内核函数。

系统调用的这种设计为操作系统提供了一种安全、高效的方式来管理和控制计算机资源,同时也为用户空间的程序提供了一种统一的接口来访问这些资源。

2.2从 Hello World 说起

我们以一个简单的 Hello World 程序为例,来深入理解系统调用的实际运用。下面是用汇编代码写的一个简单的程序:

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

编译并运行这个程序:

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

这段代码背后的原理依据是,通过将特定的参数放入特定的寄存器中,然后使用 syscall 指令触发系统调用。例如,将文件描述符放入 %rdi 寄存器,将缓冲区地址放入 %rsi 寄存器,将数据长度放入 %rdx 寄存器,最后将系统调用号放入 %rax 寄存器。当执行 syscall 指令时,CPU 会根据 %rax 寄存器中的系统调用号,找到对应的内核函数并执行。

2.3系统调用入参

(1)参数顺序

当使用 syscall进行系统调用时,参数与寄存器的对应关系如下图所示:

图片

参数顺序:当使用 syscall 进行系统调用时,参数与寄存器的对应关系为参数 1 对应 %rdi,参数 2 对应 %rsi,参数 3 对应 %rdx,参数 4 对应 %r10,参数 5 对应 %r8,参数 6 对应 %r9。

该对应关系也可以从arch/x86/entry/entry_64.S里找到

/*
 * 64-bit SYSCALL instruction entry. Up to 6 arguments in registers.
 *
 * This is the only entry point used for 64-bit system calls.  The
 * hardware interface is reasonably well designed and the register to
 * argument mapping Linux uses fits well with the registers that are
 * available when SYSCALL is used.
 *
 * SYSCALL instructions can be found inlined in libc implementations as
 * well as some other programs and libraries.  There are also a handful
 * of SYSCALL instructions in the vDSO used, for example, as a
 * clock_gettimeofday fallback.
 *
 * 64-bit SYSCALL saves rip to rcx, clears rflags.RF, then saves rflags to r11,
 * then loads new ss, cs, and rip from previously programmed MSRs.
 * rflags gets masked by a value from another MSR (so CLD and CLAC
 * are not needed). SYSCALL does not save anything on the stack
 * and does not change rsp.
 *
 * Registers on entry:
 * rax  system call number
 * rcx  return address
 * r11  saved rflags (note: r11 is callee-clobbered register in C ABI)
 * rdi  arg0
 * rsi  arg1
 * rdx  arg2
 * r10  arg3 (needs to be moved to rcx to conform to C ABI)
 * r8   arg4
 * r9   arg5
 * (note: r12-r15, rbp, rbx are callee-preserved in C ABI)
 *
 * Only called from user space.
 *
 * When user can change pt_regs->foo 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.
 */
  • 参数数量:系统调用参数限制为 6 个。

  • 参数类型:参数类型限制为 INTEGER 和 MEMORY。其中 INTEGER 类型由可以放入通用寄存器的整数组成;MEMORY 类型则是通过栈在内存中传递和返回的数据类型。

  • 返回值及错误码:当从系统调用返回时,%rax 里保存着系统调用结果。如果是 -4095 至 -1 之间的值,表示调用过程中发生了错误。

  • 系统调用号:系统调用号通过 %rax 传递。

  • 系统调用指令:系统调用通过指令 syscall 来执行。执行 syscall 指令时,会把 syscall 指令的下一条指令(也就是返回地址)存入 %rcx 寄存,然后把指令指针寄存器 %rip 替换成 IA32_LSTAR MSR 寄存器里的值。

在汇编语言实现系统调用中,以 mkdir 函数为例,使用 ebx 和 ecx 作为 mkdir 的入参,mkdir 的输出参数没有接收。eax 作为系统函数的函数号码等于十进制的 39,最后执行系统调用 int 0x80 即可。执行后,可生成 test 目录。

在 Linux Kernel 源码阅读中,以一个 Hello world 程序为例,用汇编代码写的程序中,系统调用的入参有特定的规定。比如 “ssize_t write (int fd, const void *buf, size_t count)” 这个函数调用中,mov $1, %rdi 表示将 fd 设置为 1,mov $msg, % rsi 将 buffer 设置为 msg,mov $len, % rdx 将 count 设置为 len。这些操作展示了在系统调用中如何设置入参。

系统调用的实现实质是软中断,不是简单的函数调用。它把用户从底层的硬件编程中解放出来,极大地提高了系统的安全性,使用户程序具有可移植性。三层皮中,第一层是应用层的 libc 提供的 API 函数,这些 API 函数虽然在应用层,但对外接口函数形式一旦确定就不会随意修改。第二层是 system_call 系统调用函数,与 0x80 对应,是通过查表找到的系统调用函数。第三层是软件中断实现函数。系统调用和一般的函数调用不同,从汇编语言实现的角度看,系统调用必须生成一个发送给内核的中断,如 int 0x80,具体是哪一个系统调用函数使用 eax 来传递,函数的入参还可使用 ebx、ecx、edi 等六个寄存器来使用。

⑴系统调用与普通函数调用的区别

系统调用与普通函数调用存在多方面的显著区别。首先,系统调用通常使用 INT 和 IRET 指令,内核和应用程序使用不同的堆栈,这就导致了堆栈的切换,从用户态切换到内核态,在这个过程中可以使用特权指令操控设备。而普通函数调用使用 CALL 和 RET 指令,调用时没有堆栈切换。其次,系统调用依赖于内核,不保证移植性;普通函数调用平台移植性好。再者,系统调用在用户空间和内核上下文环境间切换,开销较大,它是操作系统的一个入口点;普通函数调用属于过程调用,调用开销较小,只是一个普通功能函数的调用。例如,在使用库函数 API 和 C 代码中嵌入汇编代码两种方式使用同一个系统调用时,可以明显感受到两者在实现过程和效果上的不同。系统调用通常由高级语言编写(如 C 或 C++),程序访问通常通过高层次的 API 接口(C 标准库的库函数)而不是直接进行系统调用,每个系统调用对应一个系统调用编号。

⑵汇编语言中 mkdir 函数系统调用入参

在汇编语言中,mkdir 函数使用的是 39 号系统调用。它有两个主要参数,一个是目录的名字,通过将其地址存入 ebx 寄存器中传递;另外一个参数是创建目录的权限,存入 ecx 寄存器中传递。例如,在使用 mkdir 函数创建一个新目录时,可以使用如下的汇编代码实现:“asm volatile "movl $39,%%eax\r\n"/使用 eax 传递 39 号系统调用号/"movl %1,%%ebx\r\n"/将第一个参数文件名的地址存入 ebx 寄存器中/"movl %2,%%ecx\r\n"/将第二个参数文件权限存入 ecx 寄存器中/"int $0x80\r\n"/执行 $0x80 中断进入内核态/"movl %%eax, %0"/最后将返回值放入 return_Value 中/”。通过这种方式,明确了 mkdir 函数在汇编语言中的系统调用入参传递方式。

⑶系统调用实现的实质

系统调用实现的实质是软中断。系统调用把用户从底层的硬件编程中解放出来,极大地提高了系统的安全性,同时使用户程序具有可移植性。具体来说,系统的调用的意义在于为用户态进程与硬件设备进行交互提供了一组接口。例如,当用户程序需要进行设备管理、文件管理、进程控制、进程通信、内存管理等操作时,就必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。在实现过程中,如果是普通的函数调用,对应的机器指令是 call 指令,当 CPU 执行到这条指令时直接跳转到对应的地址继续执行指令。而如果是程序的函数调用操作系统的函数就不允许使用 call 指令了,而是使用 syscall 机器指令(如 x86_64 架构下)。使用 syscall 指令调用操作系统的函数时,不是像 call 指令那样直接将被调函数的地址放到指令后,而是将相应函数的序号写入 rax 寄存器,CPU 在执行 syscall 指令时通过读取 rax 寄存器的值就能知道到底该调用操作系统中的哪个函数了。

⑷系统调用的三层皮结构

系统调用具有 “三层皮” 结构。第一层是指 Libc 中定义的 API,这些 API 封装了系统调用,使用 int 0x80 触发一个系统调用中断。并非所有的 API 都使用了系统调用,比如完成数学加减运算的 API 就没有使用系统调用;也有可能某个 API 使用了多个系统调用。这一层存在的价值就是为应用程序员提供易于使用的 API 来调用系统调用。第二层是 system_call 系统调用函数,与特定的中断相关联,是中断服务程序在做系统调用。一个中断服务程序里会用很多系统调用。第三层是软件中断实现函数,例如内核中的具体系统调用服务例程,如 sys_fork 函数等。从一个具体的例子来看,当程序调用一个函数时,如 xyz () 函数,这个函数就是一个 API。然后执行 int 0x80 中断指令,进入中断处理程序,对应的就是 system_call。在这个中断服务程序中,会根据具体的功能调用相应的内核服务程序,如 sys_xyz () 将会做 xyz () 的具体功能。整个事情做完之后中断返回,这样就完成了 API 的功能。

三、系统调用初始化

系统调用初始化是操作系统启动过程中的一个重要环节。在加电启动 BootLoader 运行后,内核接过系统控制权开始运行,其中内核运行的第一个函数入口是 start_kernel,它会进行一系列初始化,包括系统调用初始化。例如在 start_kernel 众多初始化中,有一项初始化 tarp_init()即系统调用初始化,涉及到一些初始化中断向量,会在 set_intr_gate 设置到很多的中断门,包括一个系统陷阱门进行系统调用,之后还有 idt_setup_tarps()即初始化中断描述表初始化。

在系统启动时,会在 sched_init (void) 函数中调用 set_system_gate (0x80,&system_call),设置中断向量号 0x80 的中断描述符。对于 x86 系统,当调用 int $0x80 和 syscall 指令时会触发系统调用。C 库函数内部使用了系统调用的封装例程,其主要目的是发布系统调用,使程序员在写代码时不需要用汇编指令和寄存器传递参数来触发系统调用。一般每个系统调用对应一个系统调用的封装例程,函数库再用这些封装例程定义出给程序员调用的 API,这样把系统调用终封装成方便程序员使用的 C 库函数。

系统调用执行的流程如下:应用程序代码调用包装系统调用的库函数,库函数负责准备向内核传递的参数,并触发软中断以切换到内核;CPU 被软中断打断后,执行中断处理函数,即系统调用处理函数(system_call);系统调用处理函数调用系统调用服务例程,真正开始处理该系统调用。在用户空间和内核空间之间,有一个叫做 Syscall 的中间层,是连接用户态和内核态的桥梁。

为了方便执行系统调用,在 include/unistd.h 中定义了宏函数_syscalln (),其中 n 代表携带的参数个数,可以使 0~3,因此最多可以直接传递三个参数。如果某个系统调用需要多于 3 个参数,那么内核通常采用的方法是直接把这些参数作为一个参数缓冲块,并把这个缓冲块的指针作为一个参数传递给内核。当进入内核中的系统调用处理程序 kernel/sys_call.S 后,system_call 的代码会首先检查 eax 中的系统调用功能号是否在有效系统调用号范围内,然后根据 sys_call_table () 函数指针表调用执行相应的系统调用处理程序。

3.1start_kernel 如何进行系统调用初始化

在 Linux 系统中,start_kernel 函数在系统初始化过程中起着关键作用。start_kernel 会依次调用一系列的初始化函数来完成系统的各种初始化工作,其中也包括系统调用的初始化。

start_kernel --> trap_init --> cpu_init --> syscall_init,syscall_init 函数实现了系统调用的初始化,将中断向量与服务例程进行绑定。除此之外,还要进行系统调用表的初始化。在初始化过程中,系统会为不同的系统调用分配唯一的编号,并建立起系统调用号与对应的内核处理函数之间的映射关系。这样,当用户态程序发起系统调用时,系统能够根据系统调用号快速找到相应的内核处理函数并执行。例如,在 x86-64 Linux 系统中,当用户态进程调用一个系统调用时,CPU 会切换到内核态并开始执行 system_call (entry_INT80_32 或 entry_SYSCALL_64) 汇编代码,其中根据系统调用号调用对应的内核处理函数。

3.2系统调用初始化中断向量的作用

中断向量在系统调用初始化过程中起着至关重要的作用。中断向量是用于处理中断的一种机制,它的作用是将特定的中断类型与相应的中断处理程序关联起来。在系统调用初始化时,中断向量被用来实现从用户态到内核态的切换。

当出现系统调用时,硬件接收到信号会立刻保存现场,并查找中断向量表,将 CPU 控制权交给系统调用总入口程序。对于系统调用总入口程序,也要先保存现场,将参数保存在内核的堆栈中,然后查找系统调用库,将 CPU 控制权交给对应的系统调用处理程序或者内核程序。执行系统调用处理程序后,再恢复现场,返回用户程序。

例如,在 x86 架构中,中断向量表通常存储在内存地址 0x0000 处,其中前 32 个中断向量为预留向量,用于系统级别的中断处理,后 224 个中断向量为用户可用的向量,用于应用程序级别的中断处理。当一个中断事件发生时,CPU 会读取中断向量表中相应中断向量的地址,并跳转到该地址执行中断处理程序。

3.3系统调用封装例程的目的

系统调用封装例程的主要目的是发布系统调用,使程序员在写代码时不需要用汇编指令和寄存器传递参数来触发系统调用。

Libc 函数库定义的一些 API 内部使用了系统调用的封装例程。一个 API 可能只对应一个系统调用,亦可能内部由多个系统调用实现,一个系统调用也可能被多个 API 调用。例如,C 库函数内部使用了系统调用的封装例程,其主要目的是发布系统调用,使程序员在写代码时不需要用汇编指令和寄存器传递参数来触发系统调用。一般每个系统调用对应一个系统调用的封装例程,函数库再用这些封装例程定义出给程序员调用的 API,这样把系统调用最终封装成方便程序员使用的 C 库函数。

对于返回值,大部分系统调用的封装例程返回一个整数,其值的含义依赖于对应的系统调用,返回值 -1 在多数情况下表示内核不能满足进程的请求,C 库函数中进一步定义的 errno 变量包含特定的出错码。

3.4系统调用执行的具体流程

系统调用的执行过程较为复杂,具体如下:

①执行前的准备工作:用户态程序首先传递系统调用参数,由陷入(trap)指令负责将用户态转换为核心态,并将返回地址压栈备用。例如,在用户态:首先先找到系统调用号,我们来看 unistd.h 头文件中这样一段代码,定义了一系列的宏,这些宏就是系统调用对应的系统调用号。然后将系统调用的调用号和参数进行传递,可能会将参数传入寄存器,若参数超过 6 个,则把某一个寄存器作为一个指针,指向某一块内存。

②执行处理程序(处理函数):硬件接收到信号立刻保存现场,并查找中断向量表,将 CPU 控制权交给系统调用总入口程序。对于系统调用总入口程序,亦要先保存现场,将参数保存在内核的堆栈中,然后查找系统调用库,将 CPU 控制权交给对应的系统调用处理程序或者内核程序。例如,在 Linux 中通 过执行 int $0x80 或 syscall 指令来触发系统调用的执行,其中这条 int $0x80 汇编指令是产生中断向量为 128 的编程异常(trap)。

③执行后的善后工作:执行系统调用处理程序后,恢复现场,返回用户程序。例如,系统调用命令执行完毕后恢复用户程序执行的现场信息,同时把系统调用命令的返回参数或参数区首址放入指定的寄存器中,供用户程序使用。

系统调用初始化是 Linux 系统启动过程中的重要环节,它为用户态程序与硬件设备进行交互提供了接口,极大地提高了系统的安全性和可移植性。通过 start_kernel 函数的一系列初始化操作,包括系统调用的初始化,中断向量的设置以及封装例程的使用,使得系统能够高效、稳定地运行。系统调用的执行流程涉及用户态与内核态的切换、参数传递、中断处理等多个步骤,确保了系统能够正确地响应各种请求。

四、关系要素剖析

4.1系统调用编号

在 Linux 中,32 位系统和 64 位系统有着不同的系统调用编号定义文件。32 位系统调用号定义在 arch/x86/syscalls/syscall_32.tbl 文件;64 位系统调用号定义在 arch/x86/syscalls/syscall_64.tbl 文件。

下面列出部分 64 位系统的系统调用及编号示例:例如,write() 的系统调用编号为 1,exit() 系统调用编号为 60。像这样的系统调用编号分配,有助于内核准确识别和执行不同的系统调用功能。从这些编号的分配中可以看出,不同的系统调用有着各自独立的标识,确保在系统运行过程中能够准确无误地被调用。

在示例程序中,我们使用了writeexit系统调用,并通过%rax传递了系统调用号。在Linux中,32位系统和64位系统有不同的系统调用编号。32位系统调用号定义在arch/x86/syscalls/syscall_32.tbl文件;64位系统调用号定义在arch/x86/syscalls/syscall_64.tbl文件。下面列出了64位系统的部分系统调用及编号,可以看到,write()的系统调用编号为 1 ,exit()系统调用编号为 60:

0   common  read            sys_read
1   common  write           sys_write           # write 系统调用
2   common  open            sys_open
3   common  close           sys_close

......

59  64  execve          sys_execve
60  common  exit            sys_exit           # exit 系统调用
61  common  wait4           sys_wait4
62  common  kill            sys_kill
......

4.2系统调用表及其初始化

Linux 内核中包含一个被称为系统调用表的数据结构。64 位系统调用表定义在 arch/x86/kernel/syscall_64.c 文件中。这个系统调用表是一个包含 __NR_syscall_max + 1 个元素的数组,其中 __NR_syscall_max 是一个宏,在 64 位模式下其值为 542,该宏定义于 include/generated/asm-offsets.h 文件,这个文件是 Kbuild 编译后生成的。

系统调用表的元素类型为 sys_call_ptr_t,这是通过 typedef 定义的函数指针。sys_ni_syscall 表示一个未实现的系统调用,它直接返回一个错误码 -ENOSYS,其中 ENOSYS 值为 38,表示调用了一个未实现的函数。

符号 ... 是 GCC 编译器的一个扩展 ——Designated Initializers,该扩展允许我们以任意顺序初始化成员元素。正如我们看到的,系统调用表先用 sys_ni_syscall 进行初始化,然后再用 <asm/syscalls_64.h> 头文件中的内容对数组进行填充。该头文件是使用 arch/x86/syscalls/syscalltbl.sh 脚本读取 syscall_64.tbl 后生成的,它包含一系列宏,最终将各系统调用号关联的函数指针填充到该数组中;其它所有未实现的系统调用号都指向了 sys_ni_syscall 函数,该函数只是简单返回一个错误码 -ENOSYS。

4.3系统调用的定义

下面我们以示例程序中使用的 write 系统调用为例,来看看系统调用是如何定义的。write 系统调用函数原型如下,可以通过 man 2 write 命令查看。ssize_t write(int fd, const void *buf, size_t count);在 Linux 内核中,write 系统调用定义在 fs/read_write.c 文件中。由于 write 有 3 个参数,所以是用 SYSCALL_DEFINE3 宏定义的。

SYSCALL_DEFINE3 宏定义在 include/linux/syscalls.h 中。可以看到,Linux 内核一共定义了 7 个宏,每个宏后面都有一个数字,表示入参数量。SYSCALL_DEFINE3 被扩展成了 SYSCALL_DEFINEx 宏,该宏又扩展成了 SYSCALL_METADATA 和 __SYSCALL_DEFINEx。以 write 为例,扩展过程如下:SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) 扩展成 SYSCALL_DEFINEx(3, _write, unsigned int, fd, const char *, buf, size_t, count),注意,扩展后,函数名前面多了下划线 “_”。继续扩展,SYSCALL_METADATA 宏的实现,由 Kbuild 时配置的选项 CONFIG_FTRACE_SYSCALLS 来决定,只有设置 CONFIG_FTRACE_SYSCALLS 选项时,该宏才有实际意义。

五、系统调用处理程序

5.1系统调用处理程序的入口与结构

入口点:在 x86 - 64 架构的 Linux 内核中,系统调用处理程序的入口点主要是通过entry_SYSCALL_64(使用syscall指令触发系统调用时)和entry_INT80_32(使用int 0x80指令触发系统调用时)。这些入口点的代码通常是用汇编语言编写的,位于arch/x86/entry/entry_64.S等相关文件中。

结构组成:系统调用处理程序的结构包括保存现场、获取系统调用号、参数验证、查找并调用系统调用表中的处理函数、处理返回值和恢复现场等部分。例如,在入口点代码中,首先会使用指令保存当前的寄存器状态,这是为了在系统调用返回后能够恢复到之前的执行状态。

5.2关键指令和宏的作用

ENTRY 和 END 宏:在汇编代码中,ENTRY和END宏用于定义一个函数的开始和结束。以ENTRY(sys_call)为例,它标志着系统调用处理程序的开始,END(sys_call)则表示结束。这些宏帮助编译器正确地识别函数的边界,从而进行正确的代码生成和链接。

GLOBAL 宏:GLOBAL宏用于声明一个全局符号。在系统调用处理程序中,这使得该函数可以在整个内核代码的其他部分被访问。例如,GLOBAL(entry_SYSCALL_64)使得entry_SYSCALL_64这个入口函数可以被其他模块引用,这对于系统调用的正确触发和处理是非常重要的。

swapgs 指令:swapgs指令用于交换通用寄存器组(General - Purpose Registers,GPR)和内核寄存器组(Kernel - Register Set)。在系统调用处理程序中,这个指令的作用是切换处理器的状态,从用户态切换到内核态时,需要将用户态的寄存器上下文保存,并加载内核态的寄存器上下文。例如,在进入内核态后,内核需要使用自己的寄存器组来执行系统调用处理函数,swapgs指令就完成了这个切换的关键一步。

5.3系统调用处理的具体流程

  • ①保存现场:当系统调用触发后,首先要做的是保存当前的执行现场。这包括保存寄存器的值,因为在系统调用执行过程中,这些寄存器的值可能会被修改。例如,在entry_SYSCALL_64中,会使用pushq等指令将寄存器的值压入栈中保存。

  • ②获取系统调用号:在保存现场之后,需要获取系统调用号。在 x86 - 64 架构中,系统调用号通常存储在rax寄存器中。通过读取这个寄存器的值,系统调用处理程序可以知道应该执行哪个系统调用。例如,如果rax寄存器的值为 0,表示要执行read系统调用;如果为 1,表示要执行write系统调用等。

  • ③参数验证:获取系统调用号后,需要对系统调用的参数进行验证。这是为了确保参数的合法性,防止错误或恶意的参数导致系统崩溃或安全漏洞。例如,对于一些系统调用,参数可能有取值范围的限制,或者要求某些参数必须是有效的指针等,处理程序会检查这些条件是否满足。

  • ④查找并调用系统调用表中的处理函数:验证参数后,根据系统调用号在系统调用表中查找对应的处理函数。这一步是系统调用处理的核心环节。如前面所述,系统调用表是一个函数指针数组,系统调用处理程序会以系统调用号为索引,在表中找到对应的函数指针,然后调用该函数。例如,如果系统调用号为n,则会调用sys_call_table[n]对应的函数来处理系统调用。

  • ⑤处理返回值:在系统调用处理函数执行完毕后,会得到一个返回值。这个返回值需要进行适当的处理,例如将其存储在合适的寄存器中,以便返回给用户空间程序。在 x86 - 64 架构中,通常会将返回值存储在rax寄存器中。

  • ⑥恢复现场:最后,在系统调用处理完成后,需要恢复之前保存的现场。这包括将保存的寄存器值从栈中弹出,恢复处理器的状态等操作。通过popq等指令将之前压入栈中的寄存器值恢复,使处理器能够继续执行用户空间程序,就好像系统调用从未发生过一样,只是用户空间程序得到了系统调用的结果。

六、案例分析

假设我们要实现一个简单的功能,获取当前进程的 ID。在 Linux 中,可以通过getpid系统调用实现这个功能。

⑴C 语言实现

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = getpid();
    printf("当前进程 ID 为:%d\n", pid);
    return 0;
}

在这个例子中,getpid是一个库函数,它最终会触发系统调用。当程序执行到getpid时,会从用户空间切换到内核空间,执行相应的系统调用处理函数,获取当前进程的 ID,然后再返回用户空间并将结果打印出来。

  1. ⑵汇编语言实现(x86 - 64)

section.text
global _start

_start:
    mov rax, 39  ; 系统调用号为 39,对应 getpid
    syscall

    mov rdi, rax  ; 将进程 ID 存储在 rdi,准备作为参数传递给 write
    mov rax, 1    ; 系统调用号为 1,对应 write
    mov rsi, msg
    mov rdx, msg_len
    syscall

    mov rax, 60   ; 系统调用号为 60,对应 exit
    xor rdi, rdi
    syscall

section.data
msg db '当前进程 ID 为:', 0
msg_len equ $ - msg

在这个汇编代码中,首先将系统调用号39(对应getpid)存入rax寄存器,然后执行syscall指令触发系统调用,获取当前进程的 ID。接着,将进程 ID 存入rdi寄存器,准备作为参数传递给write系统调用,用于打印输出。然后设置write系统调用的其他参数(rsi指向输出消息的地址,rdx为消息长度),再次执行syscall指令打印输出。最后,使用exit系统调用退出程序。

通过这个以上案例可以看到,在 x86 - 64 架构下,可以使用 C 语言库函数或者直接使用汇编语言来触发系统调用。系统调用为用户空间程序提供了一种与内核交互的方式,使得程序能够访问操作系统的各种功能,如文件操作、进程管理、内存管理等。在实际应用中,根据不同的需求,可以选择合适的方式来使用系统调用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值