【Linux 内核源码分析笔记】系统调用

发布时间:2024年01月10日

在Linux内核中,系统调用是用户空间程序与内核之间的接口,它允许用户空间程序请求内核执行特权操作或访问受保护的内核资源。系统调用提供了一种安全可控的方式,使用户程序能够利用内核功能而不直接访问底层硬件。

系统调用:

  1. 通过系统调用,用户程序可以请求内核访问底层硬件设备,如磁盘、网络设备等。
  2. 系统调用允许用户程序创建、打开、读写和关闭文件,并进行进程管理操作,如创建新进程、发送信号等。
    3.通过系统调用,用户程序可以使用网络套接字进行网络通信操作,如建立连接、发送和接收数据等。
  3. 系统调用能够进行身份验证和权限检查,确保只有经过授权的进程才能执行特权操作。

特点:

  1. 当应用程序发起系统调用时,会导致从用户态切换到内核态执行相应的操作。这涉及到CPU状态的转换以及堆栈的切换。
  2. 由于所有对底层资源的访问都是通过系统调用进行的,内核可以对这些请求进行验证和控制,确保只有合法且经过权限检查的操作被执行。
    3.系统调用涉及到用户态到内核态的切换,这会引入一定的性能开销。因此,在设计应用程序时需要权衡系统调用的次数和性能需求。
  3. 不同架构和硬件平台上的用户程序可以使用相同的系统调用接口来与内核进行交互,这提供了跨平台兼容性。

与内核通信

Linux内核系统调用在用户空间进程和硬件设备之间添加了一个中间层。在Linux操作系统中,用户空间和内核空间是分离的。用户空间是指应用程序运行的环境,而内核空间是指操作系统内核运行的环境。

当用户空间进程需要进行一些需要操作系统的特权级别才能执行的操作时(例如访问硬件设备、创建新进程等),它必须通过系统调用来向内核发出请求。系统调用提供了一组接口,允许用户空间进程以标准化的方式与内核进行通信,并请求内核执行特定的操作。

当用户空间进程调用系统调用时,处理过程如下:

  • 用户空间进程调用系统调用函数,将请求传递给内核。
  • 内核根据系统调用的类型和参数,执行相应的操作。
  • 内核完成操作后,将结果返回给用户空间进程。
  • 用户空间进程继续执行后续的指令。

API、POSIX 和 C 库

printf函数实际上是一个用户空间的C库函数。当应用程序调用printf函数时,它会通过标准输入输出(stdio)库将数据发送给内核。然后,stdio库使用系统调用write将数据传递给内核。

在这个过程中,应用程序和C库之间有一个用户态到内核态的切换。应用程序通过软件中断(例如int 0x80或sysenter指令)触发系统调用,并将参数传递给相应的寄存器。然后,内核根据系统调用号找到相应的处理函数,并执行所需的操作。

对于printf函数而言,在内部它会使用一系列的写入操作来将数据写入stdout文件描述符(通常对应终端或标准输出)。接着,C库会调用write系统调用来向内核传递数据。
在这里插入图片描述

应用编程接口(API)

应用编程接口(API)是一组定义、协议和工具的集合,用于构建软件和应用程序。API允许不同的软件应用相互交互,是实现应用程序之间通信和数据共享的一种方式。API可以分为几种不同类型,包括操作系统级API、远程API、Web API等。

操作系统级API,如前面提到的Linux系统调用,提供了应用程序访问操作系统服务的方式。这些API使应用程序能够执行文件操作、进程控制、内存管理等功能。例如,当一个程序需要读取文件时,它会使用操作系统提供的API来执行这个操作。

POSIX 标准

POSIX(可移植操作系统接口,Portable Operating System Interface)是一系列 IEEE 标准,旨在促进应用程序与多个操作系统之间的兼容性。POSIX 定义了一套标准的操作系统 API,包括文件系统、设备、进程控制、信号、线程和网络通信等方面。这些标准通过提供一致的接口来帮助开发者编写在不同UNIX风格操作系统上都能运行的软件。

兼容性:Linux 作为一个类 UNIX 系统,大部分遵循了 POSIX 标准。这意味着编写符合 POSIX 的程序在 Linux 系统上通常可以不做修改或者只需很少修改就能运行。

系统调用实现:虽然 POSIX 定义了应用程序应该怎样与操作系统交互,但具体的实现细节由操作系统决定。Linux 内核提供的系统调用实现了 POSIX 标准中的许多功能。

非POSIX扩展:Linux 内核包含了一些非 POSIX 标准的系统调用和特性,这些通常为了利用 Linux 特有的功能或者为了提高性能等考虑。

C 库

Linux 系统中的标准 C 库(如 glibc)提供了 POSIX API 的实现。这些库函数通常会封装一层或多层系统调用,使得应用程序可以通过标准的 POSIX 接口与 Linux 内核通信。

系统调用

  • 调用库函数:通常情况下,程序员不会直接使用系统调用,而是会调用C标准库(如glibc)提供的封装函数,这些函数在内部会执行相应的系统调用。

  • 指定系统调用编号:每个系统调用都有一个唯一的编号,这个编号会告诉内核需要执行哪一个系统调用。

  • 设置参数:如果系统调用需要参数,这些参数必须按照规定的方式放置在寄存器中,以便内核能够读取。

  • 触发陷阱(中断):在x86架构中,这通常是通过执行int 0x80指令或者syscall指令实现的,在ARM架构中,这可能是通过svc指令。这个步骤将触发一个软件中断,将CPU从用户模式切换到内核模式。

  • 系统调用处理:内核中的系统调用处理程序接收到中断后,根据传递过来的系统调用编号找到对应的服务例程。

  • 执行系统调用:内核执行相应的服务例程,处理用户请求。

  • 返回结果:服务例程完成后,结果通过寄存器返回给用户空间,CPU切换回用户模式,应用程序继续执行。

如果一个程序想要读取文件,它可能会调用C库中的read()函数。read()函数内部会设置适当的参数(如文件描述符、缓冲区的指针、要读取的字节数等),然后执行syscall指令,并传递read系统调用的编号。内核接管控制权,执行文件读取操作,并将结果返回给用户空间的应用程序。

当系统调用发生错误时,大多数系统调用会返回一个特定的错误码。在C语言中,这些错误码通常通过一个名为errno的全局变量来报告。errno是一个由C标准库提供的、在发生错误时由系统设置的整数变量。通过调用 perror()库函数,可以把该变量翻译成用户可以理解的错误字符串。

asmlinkage 是一个用于定义内核中系统调用函数的宏。它告诉编译器和链接器使用特定的调用约定来处理这些函数。

在大多数体系结构上,用户空间代码和内核空间代码之间的函数调用有所不同。一般情况下,用户空间函数使用标准的C调用约定进行参数传递和返回值处理,而内核空间函数需要使用特殊的调用约定。

asmlinkage 宏通常与内核中的系统调用函数一起使用。它会告知编译器以及链接器使用适当的调用约定,以确保正确地传递参数和返回值。

系统调用号

系统调用号是操作系统内核提供的一组接口函数,用户程序可以通过这些接口函数来请求操作系统执行特定的功能。每个系统调用都有一个唯一的数字标识符,即系统调用号。

在Linux中,系统调用号被定义为一个整数,并通过汇编指令 int 0x80 或 syscall 来触发执行相应的系统调用。不同的操作系统和体系结构可能具有不同的方式来实现和管理系统调用。

通常,用户程序需要使用库函数(如C语言中的libc库)来封装底层的系统调用,以方便使用和处理错误。库函数会将高级语言风格的参数传递转换为底层内核接口所需的形式,并负责处理返回结果和错误码。

在Linux中,可以通过查看头文件 <sys/syscall.h> 或者参考文档来获取各个系统调用对应的编号。

在编译完用户程序中不保存系统调用函数的地址,而是保存一个系统调用号,内核通过该系统调用号查找对应的系统调用函数的地址。用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。

系统调用表

Linux内核中维护了一个称为系统调用表的数据结构,它是一个数组或者类似的结构,用于存储系统调用的函数指针。系统调用表将系统调用的编号与对应的处理函数联系起来。

当用户程序发起系统调用时,通过软中断或其他机制触发内核执行相应的操作。内核根据用户提供的系统调用编号在系统调用表中查找对应的处理函数,并跳转到该函数进行相应操作。这样可以实现用户空间与内核空间之间的交互和通信。

具体实现上,Linux使用一个名为sys_call_table的全局数组来表示系统调用表。每个数组元素都是一个函数指针,对应不同的系统调用处理函数。不过需要注意的是,由于安全性和稳定性等原因,在最新版本的Linux内核中,并没有直接暴露sys_call_table给用户态程序使用,而是通过特定方式进行访问。

系统调用处理程序

系统调用处理程序位于内核的特定部分,被称为系统调用表或者系统调用向量表。这个表中存储了每个系统调用对应的处理函数的地址。

当用户程序发起一个系统调用请求时,Linux内核会根据系统调用号来索引系统调用表,并执行相应的处理函数。具体步骤如下:

  • 用户程序使用int 0x80指令、syscall指令或者软件中断等方式触发一个中断。
  • 中断处理程序将控制权交给内核,并获取寄存器中保存的系统调用号和参数。
  • 根据系统调用号,查找系统调用表中对应的处理函数地址。
  • 执行对应的处理函数,在处理函数内部进行具体的操作,可能涉及到进程管理、文件操作、网络通信等。
  • 处理完成后,将返回值写入到适当的寄存器中,并返回到用户程序继续执行。

在x86架构上,常见的是使用int 0x80指令进行软中断;而在x86_64架构上,则多数情况下使用syscall指令。

通知内核的机制是靠软件中断实现的:

在x86架构上,用户程序可以使用int 0x80指令触发一个软中断。

当用户程序执行int 0x80指令时,CPU会暂停当前任务的执行,并将控制权交给内核中相应的软中断处理程序。内核根据传递给它的参数(例如系统调用号和参数)进行相应的操作,并返回结果给用户程序。

在x86_64架构上,通常使用syscall指令来触发软中断。

  • 用户程序触发异常,将系统调用号保存到寄存器
  • 进入中断,系统切换到内核态
  • 执行中断号 128(int 0x80) 的中断处理程序(注意软件中断和硬件中断入口不同),也就是系统调用处理程序system_call(),参数(系统调用号)从寄存器获取

执行系统调用处理程序

每个系统调用都有一个唯一的系统调用号(syscall number),用户程序通过传递这个系统调用号和相关参数来发起系统调用。当内核接收到系统调用请求时,它会根据系统调用号来确定要执行的具体操作,并从用户程序传递的参数中获取必要的数据。

一旦确定了要执行的操作,内核会进入特权模式,在内核空间中进行操作。例如,如果是文件读取操作,内核会打开对应的文件描述符,并从文件中读取数据;如果是网络通信操作,内核会管理套接字和网络协议栈等等。

完成所需的操作后,内核将结果返回给用户程序,并将控制权交还给用户程序继续执行。

根据寄存器内保存的系统调用号查系统调用表获取系统调用函数地址

要获取系统调用函数地址,可以按照以下步骤进行:

  1. 在内核代码中找到与系统调用相关的头文件(例如unistd.h),该头文件定义了系统调用号。
  2. 通过宏定义或常量获取对应平台和架构下的系统调用号变量(如 __NR_read)。
  3. 使用寄存器值(EAX或RAX)与该变量进行比较以确定具体的系统调用号。
  4. 通过索引方式访问系统调用表,在x86架构下,可使用 sys_call_table 符号来访问。
  5. 根据确定的索引,在表中找到相应位置上的函数指针,即为所需的系统调用函数地址。
call *sys_call_table(,%rax,8)

在x86-64架构下,系统调用表的地址通常存储在一个名为 sys_call_table 的全局变量中。

该行代码使用了 call 汇编指令和 *sys_call_table(,%rax,8) 表达式进行了间接寻址。这里使用了 %rax 寄存器作为索引,每个元素的大小为 8 字节(因此乘以 8)。然后使用 call 指令将对应索引位置上的函数地址作为目标进行调用。

参数传递

在x86-64架构下,系统调用使用以下约定:

  1. 系统调用号存储在 %rax 寄存器中。
  2. 系统调用的参数依次存储在 %rdi, %rsi, %rdx, %r10, %r8%r9 寄存器中,分别对应第一个、第二个、第三个、第四个、第五个和第六个参数。

例如,如果要执行 write 系统调用,则需要将文件描述符放入 %rdi,缓冲区地址放入 %rsi,以及要写入的字节数放入 %rdx
在这里插入图片描述

系统调用的实现

系统调用设计

  1. 系统调用号:每个系统调用都有一个唯一的标识符,称为系统调用号。这个号码被用户程序使用来指定要执行的具体系统调用。在Linux中,每个系统调用号都对应着一个特定的功能。

  2. 系统调用表:Linux维护了一个称为"系统调用表"的数据结构,其中存储了所有可用的系统调用函数对应的地址。该表以数组或哈希表等形式组织,并且由内核初始化并注册。

  3. 参数传递:当用户程序发起系统调用时,参数需要传递给内核。通常情况下,参数通过寄存器传递给内核函数。不同架构可能会有不同的寄存器约定。

  4. 中断与异常处理:当用户程序发起系统调用时,会触发一次从用户模式切换到内核模式(特权模式)的过程。这通常通过中断或异常来实现,在x86架构上可以使用陷阱门(trap gate)或任务门(task gate)来处理。

  5. 上下文切换:当进入内核空间进行系统调用处理时,会发生一次上下文切换。这包括保存当前进程的状态并加载目标进程的状态等操作。

  6. 返回值和错误码:每个系统调用在完成后都会返回一个结果给用户程序。如果系统调用出现错误,通常会返回一个特定的错误码,以便用户程序可以根据错误码采取相应的处理措施。

参数验证

Linux内核系统调用在处理时通常会对传递的参数进行检查和验证。这是为了确保参数的有效性和合法性,以避免安全漏洞或错误使用导致系统异常或数据损坏。

在内核中,可以使用各种方法来检查参数。一种常见的方法是通过函数调用中的条件判断语句或者条件分支来验证参数。例如,可以检查指针是否为空、长度是否合理、权限是否足够等。

参数可能包含指针类型的数据。这些指针可以用于传递或引用内存区域,从而让内核执行相应的操作。在处理参数中包含的指针时,通常需要进行一些额外的检查和验证,以确保指针的有效性和合法性。

当内核接收一个用户空间的指针时,内核必须确保以下几点:

  1. 检查指针的合法性:内核需要验证指针是否有效,并且指向用户空间。可以使用函数如access_ok()来进行检查。

  2. 权限检查:内核需要确保当前进程具有足够的权限来访问和操作指针所引用的用户空间数据。

  3. 边界检查:在处理用户空间指针时,必须注意边界条件。内核应该验证传递给它的长度参数,以避免越界访问或缓冲区溢出漏洞。

  4. 安全拷贝:如果需要将用户空间数据拷贝到内核空间进行处理,内核应该使用安全可靠的函数(如copy_from_user())来执行拷贝操作,以防止潜在的安全问题。

capable()函数是Linux内核中用于检查权限的一个函数。它的作用是判断当前进程是否有足够的权限来执行指定资源的操作。

capable()函数接受一个参数,表示要进行操作的资源类型或操作标志。如果返回值非零,则表示当前进程有权进行相应操作;如果返回值为0,则表示当前进程无权进行该操作。

系统调用上下文

系统调用上下文是指在执行系统调用时,进程所处的环境和状态。当进程执行一个系统调用时,它会切换到内核模式,并将控制权转移到操作系统内核中执行相应的内核函数来完成请求的操作。这个过程涉及到用户态和内核态之间的切换。

在系统调用上下文中,进程的用户空间堆栈和寄存器状态会被保存,然后切换到内核空间的堆栈和寄存器状态。在内核中处理完相应操作后,再将结果返回给用户空间并恢复用户态的执行。

在系统调用上下文中,进程可以访问一些特定于系统调用的参数、返回值以及其他与系统调用相关的数据结构。此外,在内核模式下,进程可以使用更高权限级别进行更底层的操作。

当进程处于内核空间执行系统调用时,虽然当前上下文属于内核,但仍然可以发生进程休眠和被抢占的情况。在多任务操作系统中,一个进程在执行系统调用期间可能被其他高优先级的进程抢占,并且切换到新的进程去执行。

为了保证可重入性,内核需要采取适当的措施来确保多个进程可以同时安全地调用相同的系统调用。这通常涉及使用锁、原子操作或其他同步机制来避免竞态条件和数据损坏等问题。

当系统调用处理程序(system_call())完成对系统调用的处理后,控制权确实会返回给该用户进程,使其能够继续执行。在处理完系统调用后,系统调用处理程序负责将用户进程的上下文恢复,并将控制权切换回用户空间,让用户进程继续从系统调用之后的位置开始执行。这样,用户进程就可以继续执行其后的指令,完成相应的操作。

系统调用处理程序(system_call())在完成系统调用处理之后,通常会使用一些特殊的机制(如中断机制或返回指令等)将控制权交还给用户进程。

注册系统调用

  1. 在内核源代码中定义系统调用号:在include/linux/syscalls.h文件中,为新的系统调用添加一个对应的宏定义。这个宏定义包含了系统调用号以及函数名。

  2. 实现系统调用函数:在合适的地方编写实现新系统调用功能的代码,并将其添加到内核源码中。

  3. arch/<架构>/kernel/syscall_table.S文件中更新系统调用表:根据所使用的架构,在对应目录下找到syscall_table.S文件,并将新的系统调用函数名添加到相应位置。

  4. 更新头文件和Makefile:根据需要,可能需要更新相关头文件和Makefile来确保新的系统调用能够正确编译和链接。

  5. 重新编译和安装内核:通过编译和安装修改后的内核源码,使之生效。

从用户空间访问系统调用

  1. 包含必要的头文件:首先,需要包含相关的头文件,以便在用户程序中使用系统调用的函数和常量。

  2. 使用系统调用号:每个系统调用都有一个唯一的系统调用号。你可以通过查看相应的文档或头文件来找到所需的系统调用号。

  3. 调用系统调用函数:使用定义好的系统调用号,可以直接通过 C 或 C++ 的库函数(如syscall)来进行系统调用。具体语法可能因编程语言而异。

  4. 处理返回值:在成功完成系统调用后,它将返回一个值表示操作是否成功。根据返回值进行适当的错误处理或继续执行程序逻辑。

通常依赖于C库(如glibc)提供的中间函数。用户程序可以通过这些中间函数来访问底层的系统调用。

用户程序通常使用标准C库(如glibc)提供的API函数进行编程。当用户程序调用这些API函数时,它们实际上会调用相应的中间函数。这些中间函数负责将参数传递给底层的系统调用,并处理与系统调用相关的细节。然后,中间函数通过软件中断或者其他机制将控制权传递给内核空间执行相应的系统调用操作。

通常,系统调用靠 C 库支持。用户程序通过如 glibc 提供的中间函数访问系统调用。

如果需要调用的系统调用未被 glibc 库支持,可以使用以下几种方法:

  1. 直接使用底层的系统调用接口:每个系统调用都有一个对应的编号,在Linux中以整数形式表示。你可以使用syscall函数来直接发起对底层系统调用的请求,传递相应的参数和编号。这样可以绕过glibc提供的中间函数,直接与内核进行交互。但是这种方法需要更深入地了解底层操作系统和硬件架构,并且可移植性较差。

  2. 编写自己的封装函数:如果需要频繁地调用某个未被glibc库支持的系统调用,你可以编写自己的封装函数来实现该功能。通过编写一个简单的C语言或汇编语言代码来包装底层系统调用,使其更易于使用和管理。这样可以提高代码的可读性和可维护性。

  3. 使用其他第三方库或工具:除了glibc之外,还存在一些其他第三方库或工具可以访问未被glibc支持的系统调用。例如,在Linux上可以使用libsyscall库或者直接使用libc子集(如musl libc)等。这些库可能提供对更多、更特定系统调用的支持。

文章来源:https://blog.csdn.net/weixin_52622200/article/details/135490990
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。