精通-Linux-内核开发-全-

精通 Linux 内核开发(全)

原文:zh.annas-archive.org/md5/B50238228DC7DE75D9C3CCE2886AAED2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

精通 Linux 内核开发着眼于 Linux 内核,其内部

安排和设计,以及各种核心子系统,帮助您获得

对这个开源奇迹的重要理解。您将了解 Linux 内核,由于其众多贡献者的集体智慧,它仍然如此优雅,这要归功于其出色的设计。

本书还涵盖了所有关键的内核代码、核心数据结构、函数和宏,为您提供了内核核心服务和机制实现细节的全面基础。您还将了解 Linux 内核作为设计良好的软件,这使我们对软件设计有了深入的见解,这种设计容易扩展,但基本上是强大而安全的。

本书内容

第一章,理解进程、地址空间和线程,仔细研究了 Linux 的一个主要抽象称为进程以及整个生态系统,这些都促进了这种抽象。我们还将花时间了解地址空间、进程创建和线程。

第二章,解析进程调度器,解释了进程调度,这是任何操作系统的重要方面。在这里,我们将建立对 Linux 采用的不同调度策略的理解,以实现有效的进程执行。

第三章,信号管理,帮助理解信号使用的所有核心方面,它们的表示、数据结构以及用于信号生成和传递的内核例程。

第四章,内存管理和分配器,带领我们穿越 Linux 内核最关键的方面之一,理解内存表示和分配的各种微妙之处。我们还将评估内核在最大化资源利用方面的效率。

第五章,文件系统和文件 I/O,传授了对典型文件系统、其结构、设计以及使其成为操作系统基本组成部分的理解。我们还将通过 VFS 全面了解抽象,使用常见的分层架构设计。

第六章,进程间通信,涉及内核提供的各种 IPC 机制。我们将探索每种 IPC 机制的布局和关系,以及 SysV 和 POSIX IPC 机制。

第七章,虚拟内存管理,解释了内存管理,详细介绍了虚拟内存管理和页表。我们将研究虚拟内存子系统的各个方面,如进程虚拟地址空间及其段、内存描述符结构、内存映射和 VMA 对象、页缓存和页表的地址转换。

第八章,内核同步和锁定,使我们能够理解内核提供的各种保护和同步机制,并理解这些机制的优点和缺点。我们将尝试欣赏内核解决这些不同同步复杂性的坚韧性。

第九章,中断和延迟工作,讨论了中断,这是任何操作系统完成必要和优先任务的关键方面。我们将了解 Linux 中中断是如何生成、处理和管理的。我们还将研究各种底半部机制。

第十章 时钟和时间管理,揭示了内核如何测量和管理时间。我们将查看所有关键的与时间相关的结构、例程和宏,以帮助我们有效地管理时间。

第十一章,模块管理,快速查看模块,内核在管理模块方面的基础设施以及涉及的所有核心数据结构。这有助于我们理解内核如何融入动态可扩展性。

您需要为本书做好准备

除了深刻理解 Linux 内核及其设计的渴望外,您需要对 Linux 操作系统有一定的了解,并且对开源软件的概念有一定的了解,才能开始阅读本书。但这并不是必需的,任何对获取有关 Linux 系统及其工作的详细信息感兴趣的人都可以阅读本书。

这本书是为谁准备的

  • 这本书是为系统编程爱好者和专业人士准备的,他们希望加深对 Linux 内核及其各种组成部分的理解。

  • 这是一本对从事各种与内核相关项目的开发人员非常有用的书籍。

  • 软件工程的学生可以将其用作理解 Linux 内核及其设计原则的参考指南。

约定

在本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是一些样式的示例及其含义的解释。文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“在 loop() 函数中,我们读取传感器的距离值,然后在串行端口上显示它。”

代码块设置如下:

/* linux-4.9.10/arch/x86/include/asm/thread_info.h */
struct thread_info {
 unsigned long flags; /* low level flags */
};

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“转到 Sketch | Include Library | Manage Libraries,然后会出现一个对话框。”

警告或重要说明会出现在这样。

提示和技巧会出现在这样。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对本书的看法-您喜欢或不喜欢的地方。读者的反馈对我们很重要,因为它有助于我们开发出您真正能够充分利用的标题。要向我们发送一般反馈,只需发送电子邮件至 feedback@packtpub.com,并在主题中提及书名。如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书做出贡献,请参阅我们的作者指南,网址为 www.packtpub.com/authors

客户支持

既然您已经是 Packt 图书的自豪所有者,我们有一些事情可以帮助您充分利用您的购买。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误是难免的。如果您在我们的书中发现错误-可能是文本或代码中的错误-我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata,选择您的书,点击“勘误提交表”链接,并输入您的勘误详情。一旦您的勘误经过验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该标题的勘误列表中。要查看以前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需的信息将出现在“勘误”部分下。

盗版

互联网上盗版受版权保护的材料是跨所有媒体持续存在的问题。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何形式的非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。请通过copyright@packtpub.com与我们联系,并附上涉嫌盗版材料的链接。我们感谢您帮助保护我们的作者和我们为您提供有价值内容的能力。

问题

如果您对本书的任何方面有问题,可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:理解进程、地址空间和线程

当内核服务在当前进程上下文中被调用时,它的布局为更详细地探索内核打开了正确的路径。本章中的努力集中在理解进程和内核为它们提供的基础生态系统上。我们将在本章中探讨以下概念:

  • 程序到进程

  • 进程布局

  • 虚拟地址空间

  • 内核和用户空间

  • 进程 API

  • 进程描述符

  • 内核堆栈管理

  • 线程

  • Linux 线程 API

  • 数据结构

  • 命名空间和 cgroups

进程

从本质上讲,计算系统被设计、开发并经常进行调整,以便有效地运行用户应用程序。计算平台中的每个元素都旨在实现有效和高效地运行应用程序的方式。换句话说,计算系统存在是为了运行各种应用程序。应用程序可以作为专用设备中的固件运行,也可以作为系统软件(操作系统)驱动的系统中的“进程”运行。

在本质上,进程是内存中程序的运行实例。当程序(在磁盘上)被获取到内存中执行时,程序到进程的转换发生。

程序的二进制映像包含代码(带有所有二进制指令)和数据(带有所有全局数据),这些数据被映射到具有适当访问权限(读、写和执行)的内存区域。除了代码和数据,进程还被分配了额外的内存区域,称为堆栈(用于分配带有自动变量和函数参数的函数调用帧)和(用于运行时的动态分配)。

同一程序的多个实例可以存在,它们具有各自的内存分配。例如,对于具有多个打开标签页的网络浏览器(同时运行浏览会话),内核将每个标签页视为一个进程实例,并分配唯一的内存。

以下图表示了内存中进程的布局:

被称为地址空间的幻觉

现代计算平台预期能够有效地处理大量进程。因此,操作系统必须处理为所有竞争进程在物理内存中分配唯一内存,并确保它们可靠地执行。随着多个进程同时竞争和执行(多任务处理),操作系统必须确保每个进程的内存分配受到另一个进程的意外访问的保护。

为了解决这个问题,内核在进程和物理内存之间提供了一层抽象,称为虚拟 地址空间。虚拟地址空间是进程对内存的视图;这是运行程序查看内存的方式。

虚拟地址空间创建了一个幻觉,即每个进程在执行时独占整个内存。这种内存的抽象视图称为虚拟内存,是由内核的内存管理器与 CPU 的 MMU 协调实现的。每个进程都被赋予一个连续的 32 位或 64 位地址空间,由体系结构限制并且对该进程唯一。通过 MMU 将每个进程限制在其虚拟地址空间中,任何进程试图访问其边界之外的地址区域的尝试都将触发硬件故障,使得内存管理器能够检测和终止违规进程,从而确保保护。

以下图描述了为每个竞争进程创建的地址空间的幻觉:

内核和用户空间

现代操作系统不仅防止一个进程访问另一个进程,还防止进程意外访问或操纵内核数据和服务(因为内核被所有进程共享)。

操作系统通过将整个内存分成两个逻辑部分,用户空间和内核空间,来实现这种保护。这种分割确保了所有被分配地址空间的进程都映射到内存的用户空间部分,而内核数据和服务则在内核空间中运行。内核通过与硬件协调实现了这种保护。当应用进程从其代码段执行指令时,CPU 处于用户模式。当一个进程打算调用内核服务时,它需要将 CPU 切换到特权模式(内核模式),这是通过称为 API(应用程序编程接口)的特殊函数实现的。这些 API 使用户进程可以使用特殊的 CPU 指令切换到内核空间,然后通过系统调用执行所需的服务。在完成所请求的服务后,内核执行另一个模式切换,这次是从内核模式切换回用户模式,使用另一组 CPU 指令。

系统调用是内核向应用进程公开其服务的接口;它们也被称为内核入口点。由于系统调用是在内核空间中实现的,相应的处理程序通过用户空间中的 API 提供。API 抽象还使调用相关系统调用更容易和方便。

下图描述了虚拟化内存视图:

进程上下文

当一个进程通过系统调用请求内核服务时,内核将代表调用进程执行。此时内核被称为处于进程上下文中执行。同样,内核也会响应其他硬件实体引发的中断;在这里,内核在中断上下文中执行。在中断上下文中,内核不是代表任何进程运行。

进程描述符

从一个进程诞生到退出,都是内核的进程管理子系统执行各种操作,包括进程创建、分配 CPU 时间、事件通知以及进程终止时的销毁。

除了地址空间外,内存中的一个进程还被分配了一个称为进程描述符的数据结构,内核用它来识别、管理和调度进程。下图描述了内核中进程地址空间及其相应的进程描述符:

在 Linux 中,进程描述符是在<linux/sched.h>中定义的struct task_struct类型的实例,它是一个中心数据结构,包含了进程持有的所有属性、标识细节和资源分配条目。查看struct task_struct就像窥视内核看到或处理进程的窗口。

由于任务结构包含了与各种内核子系统功能相关的广泛数据元素,本章讨论所有元素的目的和范围将超出上下文。我们将考虑一些与进程管理相关的重要元素。

进程属性-关键元素

进程属性定义了进程的所有关键和基本特征。这些元素包含了进程的状态和标识以及其他重要的关键值。

状态

一个进程从产生到退出可能存在于各种状态,称为进程状态,它们定义了进程的当前状态:

  • TASK_RUNNING (0):任务正在执行或在调度器运行队列中争夺 CPU。

  • TASK_INTERRUPTIBLE(1):任务处于可中断的等待状态;它会一直等待,直到等待条件变为真,比如互斥锁的可用性、设备准备好进行 I/O、休眠时间已过或者独占唤醒调用。在这种等待状态下,为进程生成的任何信号都会被传递,导致它在等待条件满足之前被唤醒。

  • TASK_KILLABLE:这类似于TASK_INTERRUPTIBLE,唯一的区别是中断只能发生在致命信号上,这使得它成为TASK_INTERRUPTIBLE的更好替代品。

  • TASK_UNINTERRUTPIBLE(2):任务处于不可中断的等待状态,类似于TASK_INTERRUPTIBLE,只是对于正在睡眠的进程生成的信号不会导致唤醒。当等待的事件发生时,进程转换为TASK_RUNNING。这种进程状态很少被使用。

  • TASK_STOPPED(4):任务已收到 STOP 信号。在收到继续信号(SIGCONT)后将恢复运行。

  • TASK_TRACED(8):当进程正在被梳理时,它被称为处于被跟踪状态,可能是由调试器进行的。

  • EXIT_ZOMBIE(32):进程已终止,但其资源尚未被回收。

  • EXIT_DEAD(16):子进程已终止,并且在父进程使用wait收集子进程的退出状态后,所有其持有的资源都被释放。

以下图示了进程状态:

pid

该字段包含一个称为PID的唯一进程标识符。在 Linux 中,PID 的类型为pid_t(整数)。尽管 PID 是一个整数,但默认的最大 PID 数量是 32,768,通过/proc/sys/kernel/pid_max接口指定。该文件中的值可以设置为最多 2²²(PID_MAX_LIMIT,约 400 万)的任何值。

为了管理 PID,内核使用位图。该位图允许内核跟踪正在使用的 PID 并为新进程分配唯一的 PID。每个 PID 在 PID 位图中由一个位标识;PID 的值是根据其对应位的位置确定的。位图中值为 1 的位表示对应的 PID 正在使用,值为 0 的位表示空闲的 PID。每当内核需要分配一个唯一的 PID 时,它会寻找第一个未设置的位并将其设置为 1,反之,要释放一个 PID,它会将对应的位从 1 切换为 0。

tgid

该字段包含线程组 ID。为了便于理解,可以这样说,当创建一个新进程时,其 PID 和 TGID 是相同的,因为该进程恰好是唯一的线程。当进程生成一个新线程时,新的子线程会获得一个唯一的 PID,但会继承父线程的 TGID,因为它属于同一线程组。TGID 主要用于支持多线程进程。我们将在本章的线程部分详细介绍。

线程信息

该字段包含特定于处理器的状态信息,是任务结构的关键要素。本章的后续部分将详细介绍thread_info的重要性。

标志

标志字段记录与进程对应的各种属性。字段中的每个位对应进程生命周期中的各个阶段。每个进程的标志在<linux/sched.h>中定义:

#define PF_EXITING           /* getting shut down */
#define PF_EXITPIDONE        /* pi exit done on shut down */
#define PF_VCPU              /* I'm a virtual CPU */
#define PF_WQ_WORKER         /* I'm a workqueue worker */
#define PF_FORKNOEXEC        /* forked but didn't exec */
#define PF_MCE_PROCESS       /* process policy on mce errors */
#define PF_SUPERPRIV         /* used super-user privileges */
#define PF_DUMPCORE          /* dumped core */
#define PF_SIGNALED          /* killed by a signal */
#define PF_MEMALLOC          /* Allocating memory */
#define PF_NPROC_EXCEEDED    /* set_user noticed that RLIMIT_NPROC was exceeded */
#define PF_USED_MATH         /* if unset the fpu must be initialized before use */
#define PF_USED_ASYNC        /* used async_schedule*(), used by module init */
#define PF_NOFREEZE          /* this thread should not be frozen */
#define PF_FROZEN            /* frozen for system suspend */
#define PF_FSTRANS           /* inside a filesystem transaction */
#define PF_KSWAPD            /* I am kswapd */
#define PF_MEMALLOC_NOIO0    /* Allocating memory without IO involved */
#define PF_LESS_THROTTLE     /* Throttle me less: I clean memory */
#define PF_KTHREAD           /* I am a kernel thread */
#define PF_RANDOMIZE         /* randomize virtual address space */
#define PF_SWAPWRITE         /* Allowed to write to swap */
#define PF_NO_SETAFFINITY    /* Userland is not allowed to meddle with cpus_allowed */
#define PF_MCE_EARLY         /* Early kill for mce process policy */
#define PF_MUTEX_TESTER      /* Thread belongs to the rt mutex tester */
#define PF_FREEZER_SKIP      /* Freezer should not count it as freezable */
#define PF_SUSPEND_TASK      /* this thread called freeze_processes and should not be frozen */

exit_code 和 exit_signal

这些字段包含任务的退出值和导致终止的信号的详细信息。这些字段在子进程终止时通过wait()由父进程访问。

comm

该字段保存了用于启动进程的可执行二进制文件的名称。

ptrace

该字段在使用ptrace()系统调用将进程置于跟踪模式时启用并设置。

进程关系-关键要素

每个进程都可以与父进程建立父子关系。同样,由同一进程生成的多个进程被称为兄弟进程。这些字段建立了当前进程与另一个进程的关系。

real_parent 和 parent

这些是指向父任务结构的指针。对于正常进程,这两个指针都指向相同的task_struct它们只在使用posix线程实现的多线程进程中有所不同。对于这种情况,real_parent指的是父线程任务结构,而parent指的是将 SIGCHLD 传递给的进程任务结构。

子进程

这是子任务结构列表的指针。

兄弟

这是兄弟任务结构列表的指针。

group_leader

这是指向进程组领导者的任务结构的指针。

调度属性 - 关键元素

所有竞争进程必须获得公平的 CPU 时间,因此需要基于时间片和进程优先级进行调度。这些属性包含调度程序在决定哪个进程获得优先级时使用的必要信息。

prio 和 static_prio

prio有助于确定进程的调度优先级。如果进程被分配了实时调度策略,则此字段在199的范围内保存进程的静态优先级(由sched_setscheduler()指定)。对于正常进程,此字段保存从 nice 值派生的动态优先级。

se、rt 和 dl

每个任务都属于调度实体(任务组),因为调度是在每个实体级别上进行的。se用于所有正常进程,rt用于实时进程,dl用于截止进程。我们将在下一章中更多地讨论这些属性。

策略

此字段包含有关进程调度策略的信息,有助于确定其优先级。

cpus_allowed

此字段指定进程的 CPU 掩码,即进程在多处理器系统中有资格被调度到哪个 CPU。

rt_priority

此字段指定实时调度策略应用的优先级。对于非实时进程,此字段未使用。

进程限制 - 关键元素

内核强加资源限制,以确保系统资源在竞争进程之间公平分配。这些限制保证随机进程不会垄断资源的所有权。有 16 种不同类型的资源限制,task structure指向struct rlimit*类型的数组,其中每个偏移量保存特定资源的当前值和最大值。

/*include/uapi/linux/resource.h*/
struct rlimit {
  __kernel_ulong_t        rlim_cur;
  __kernel_ulong_t        rlim_max;
};
These limits are specified in *include/uapi/asm-generic/resource.h* 
 #define RLIMIT_CPU        0       /* CPU time in sec */
 #define RLIMIT_FSIZE      1       /* Maximum filesize */
 #define RLIMIT_DATA       2       /* max data size */
 #define RLIMIT_STACK      3       /* max stack size */
 #define RLIMIT_CORE       4       /* max core file size */
 #ifndef RLIMIT_RSS
 # define RLIMIT_RSS       5       /* max resident set size */
 #endif
 #ifndef RLIMIT_NPROC
 # define RLIMIT_NPROC     6       /* max number of processes */
 #endif
 #ifndef RLIMIT_NOFILE
 # define RLIMIT_NOFILE    7       /* max number of open files */
 #endif
 #ifndef RLIMIT_MEMLOCK
 # define RLIMIT_MEMLOCK   8       /* max locked-in-memory   
 address space */
 #endif
 #ifndef RLIMIT_AS
 # define RLIMIT_AS        9       /* address space limit */
 #endif
 #define RLIMIT_LOCKS      10      /* maximum file locks held */
 #define RLIMIT_SIGPENDING 11      /* max number of pending signals */
 #define RLIMIT_MSGQUEUE   12      /* maximum bytes in POSIX mqueues */
 #define RLIMIT_NICE       13      /* max nice prio allowed to 
 raise to 0-39 for nice level 19 .. -20 */
 #define RLIMIT_RTPRIO     14      /* maximum realtime priority */
 #define RLIMIT_RTTIME     15      /* timeout for RT tasks in us */
 #define RLIM_NLIMITS      16

文件描述符表 - 关键元素

在进程的生命周期中,它可能访问各种资源文件以完成其任务。这导致进程打开、关闭、读取和写入这些文件。系统必须跟踪这些活动;文件描述符元素帮助系统知道进程持有哪些文件。

fs

文件系统信息存储在此字段中。

文件

文件描述符表包含指向进程打开以执行各种操作的所有文件的指针。文件字段包含一个指针,指向此文件描述符表。

信号描述符 - 关键元素

为了处理信号,任务结构具有各种元素,确定信号的处理方式。

信号

这是struct signal_struct*的类型,其中包含与进程关联的所有信号的信息。

sighand

这是struct sighand_struct*的类型,其中包含与进程关联的所有信号处理程序。

sigset_t blocked, real_blocked

这些元素标识当前由进程屏蔽或阻塞的信号。

待处理

这是struct sigpending*的类型,用于标识生成但尚未传递的信号。

sas_ss_sp

此字段包含指向备用堆栈的指针,用于信号处理。

sas_ss_size

此字段显示备用堆栈的大小,用于信号处理。

内核堆栈

随着当前一代计算平台由能够运行同时应用程序的多核硬件驱动,当请求相同进程时,多个进程同时启动内核模式切换的可能性已经内置。为了能够处理这种情况,内核服务被设计为可重入,允许多个进程参与并使用所需的服务。这要求请求进程维护自己的私有内核栈,以跟踪内核函数调用序列,存储内核函数的本地数据等。

内核栈直接映射到物理内存,要求布局在一个连续的区域内。默认情况下,x86-32 和大多数其他 32 位系统的内核栈为 8kb(在内核构建期间可以配置为 4k 内核栈),在 x86-64 系统上为 16kb。

当内核服务在当前进程上下文中被调用时,它们需要在承诺任何相关操作之前验证进程的特权。为了执行这样的验证,内核服务必须访问当前进程的任务结构并查看相关字段。同样,内核例程可能需要访问当前的“任务结构”来修改各种资源结构,例如信号处理程序表,寻找未决信号,文件描述符表和内存描述符等。为了在运行时访问“任务结构”,当前“任务结构”的地址被加载到处理器寄存器中(所选择的寄存器是特定于架构的),并通过内核全局宏current(在特定于架构的内核头文件asm/current.h中定义)提供:

  /* arch/ia64/include/asm/current.h */
  #ifndef _ASM_IA64_CURRENT_H
  #define _ASM_IA64_CURRENT_H
  /*
  * Modified 1998-2000
  *      David Mosberger-Tang <davidm@hpl.hp.com>, Hewlett-Packard Co
  */
  #include <asm/intrinsics.h>
  /*
  * In kernel mode, thread pointer (r13) is used to point to the 
    current task
  * structure.
  */
 #define current ((struct task_struct *) ia64_getreg(_IA64_REG_TP))
 #endif /* _ASM_IA64_CURRENT_H */
 /* arch/powerpc/include/asm/current.h */
 #ifndef _ASM_POWERPC_CURRENT_H
 #define _ASM_POWERPC_CURRENT_H
 #ifdef __KERNEL__
 /*
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version
 * 2 of the License, or (at your option) any later version.
 */
 struct task_struct;
 #ifdef __powerpc64__
 #include <linux/stddef.h>
 #include <asm/paca.h>
 static inline struct task_struct *get_current(void)
 {
       struct task_struct *task;

       __asm__ __volatile__("ld %0,%1(13)"
       : "=r" (task)
       : "i" (offsetof(struct paca_struct, __current)));
       return task;
 }
 #define current get_current()
 #else
 /*
 * We keep `current' in r2 for speed.
 */
 register struct task_struct *current asm ("r2");
 #endif
 #endif /* __KERNEL__ */
 #endif /* _ASM_POWERPC_CURRENT_H */

然而,在寄存器受限的架构中,如果寄存器很少,那么保留一个寄存器来保存当前任务结构的地址是不可行的。在这样的平台上,当前进程的“任务结构”直接放置在它拥有的内核栈的顶部。这种方法在定位“任务结构”方面具有显著优势,只需屏蔽栈指针的最低有效位即可。

随着内核的演变,任务结构变得越来越大,无法包含在内核栈中,而内核栈在物理内存中已经受限(8Kb)。因此,任务结构被移出内核栈,除了定义进程的 CPU 状态和其他低级处理器特定信息的一些关键字段之外。然后,这些字段被包装在一个新创建的结构体struct thread_info中。这个结构体位于内核栈的顶部,并提供一个指针,指向可以被内核服务使用的当前任务结构

struct thread_info for x86 architecture (kernel 3.10):
/* linux-3.10/arch/x86/include/asm/thread_info.h */ struct thread_info {
 struct task_struct *task; /* main task structure */
 struct exec_domain *exec_domain; /* execution domain */
 __u32 flags; /* low level flags */
 __u32 status; /* thread synchronous flags */
 __u32 cpu; /* current CPU */
 int preempt_count; /* 0 => preemptable, <0 => BUG */
 mm_segment_t addr_limit;
 struct restart_block restart_block;
 void __user *sysenter_return;
 #ifdef CONFIG_X86_32
 unsigned long previous_esp; /* ESP of the previous stack in case of   
 nested (IRQ) stacks */
 __u8 supervisor_stack[0];
 #endif
 unsigned int sig_on_uaccess_error:1;
 unsigned int uaccess_err:1; /* uaccess failed */
};

使用thread_info包含与进程相关的信息,除了任务结构之外,内核对当前进程结构有多个视图:struct task_struct,一个与架构无关的信息块,以及thread_info,一个特定于架构的信息块。以下图示了thread_infotask_struct

对于使用thread_info的架构,当前宏的实现被修改为查看内核栈顶部以获取对当前thread_info和通过它对当前任务结构的引用。以下代码片段显示了 x86-64 平台的当前实现:

  #ifndef __ASM_GENERIC_CURRENT_H
  #define __ASM_GENERIC_CURRENT_H
  #include <linux/thread_info.h>

    __attribute_const__;

  static inline struct thread_info *current_thread_info(void)
  {
        **return (struct thread_info *)**  **                (current_stack_pointer & ~(THREAD_SIZE - 1));**
  }
PER_CPU variable:
#ifndef _ASM_X86_CURRENT_H
#define _ASM_X86_CURRENT_H

#include <linux/compiler.h>
#include <asm/percpu.h>

#ifndef __ASSEMBLY__
struct task_struct;

DECLARE_PER_CPU(struct task_struct *, current_task);

static __always_inline struct task_struct *get_current(void)
{
        return this_cpu_read_stable(current_task);
}

#define current get_current()

#endif /* __ASSEMBLY__ */

#endif /* _ASM_X86_CURRENT_H */
thread_info structure with just one element:
/* linux-4.9.10/arch/x86/include/asm/thread_info.h */
struct thread_info {
 unsigned long flags; /* low level flags */
};

栈溢出问题

与用户模式不同,内核模式堆栈存在于直接映射的内存中。当一个进程调用内核服务时,可能会出现内部嵌套深的情况,有可能会超出立即内存范围。最糟糕的是内核对这种情况毫不知情。内核程序员通常会使用各种调试选项来跟踪堆栈使用情况并检测溢出,但这些方法并不方便在生产系统上防止堆栈溢出。通过使用守护页面进行传统保护在这里也被排除了(因为这会浪费一个实际的内存页面)。

内核程序员倾向于遵循编码标准--最小化使用本地数据,避免递归,避免深度嵌套等--以降低堆栈溢出的概率。然而,实现功能丰富且深度分层的内核子系统可能会带来各种设计挑战和复杂性,特别是在存储子系统中,文件系统、存储驱动程序和网络代码可以堆叠在几个层中,导致深度嵌套的函数调用。

Linux 内核社区一直在思考如何防止这种溢出,为此,决定将内核堆栈扩展到 16kb(x86-64,自内核 3.15 以来)。扩展内核堆栈可能会防止一些溢出,但会占用大量直接映射的内核内存用于每个进程的内核堆栈。然而,为了系统的可靠运行,期望内核能够优雅地处理在生产系统上出现的堆栈溢出。

在 4.9 版本中,内核引入了一种新的系统来设置虚拟映射内核堆栈。由于虚拟地址目前用于映射甚至是直接映射的页面,因此内核堆栈实际上并不需要物理上连续的页面。内核为虚拟映射内存保留了一个单独的地址范围,当调用vmalloc()时,这个范围内的地址被分配。这段内存范围被称为vmalloc 范围。主要用于当程序需要大块虚拟连续但物理分散的内存时。使用这种方法,内核堆栈现在可以分配为单独的页面,映射到 vmalloc 范围。虚拟映射还可以防止溢出,因为可以分配一个无访问守护页面,并且可以通过页表项(而不浪费实际页面)来分配。守护页面将促使内核在内存溢出时弹出一个 oops 消息,并对溢出的进程发起 kill。

目前,带有守护页面的虚拟映射内核堆栈仅适用于 x86-64 架构(对其他架构的支持似乎将会跟进)。这可以通过选择HAVE_ARCH_VMAP_STACKCONFIG_VMAP_STACK构建时选项来启用。

进程创建

在内核引导期间,会生成一个名为init的内核线程,该线程被配置为初始化第一个用户模式进程(具有相同的名称)。然后,init(pid 1)进程被配置为执行通过配置文件指定的各种初始化操作,创建多个进程。进一步创建的每个子进程(可能会创建自己的子进程)都是init进程的后代。因此,这些进程最终形成了一个类似树状结构或单一层次模型的结构。shell,就是这样一个进程,当调用程序执行时,它成为用户创建用户进程的接口。

Fork、vfork、exec、clone、wait 和 exit 是用于创建和控制新进程的核心内核接口。这些操作是通过相应的用户模式 API 调用的。

fork()

Fork()是自* nix 系统的核心“Unix 线程 API”之一,自古老的 Unix 版本问世以来一直可用。恰如其名,它从运行中的进程中分叉出一个新进程。当fork()成功时,通过复制调用者的地址空间任务结构创建新进程(称为子进程)。从fork()返回时,调用者(父进程)和新进程(子进程)都从同一代码段中执行指令,该代码段在写时复制下被复制。Fork()也许是唯一一个以调用者进程的上下文进入内核模式的 API,并在成功时返回到调用者和子进程(新进程)的用户模式上下文。

父进程的任务结构的大多数资源条目,如内存描述符、文件描述符表、信号描述符和调度属性,都被子进程继承,除了一些属性,如内存锁、未决信号、活动定时器和文件记录锁(有关例外的完整列表,请参阅 fork(2)手册页)。子进程被分配一个唯一的pid,并通过其任务结构ppid字段引用其父进程的pid;子进程的资源利用和处理器使用条目被重置为零。

父进程使用wait()系统调用更新自己关于子进程状态的信息,并通常等待子进程的终止。如果没有调用wait(),子进程可能会终止并进入僵尸状态。

写时复制(COW)

父进程的复制以创建子进程需要克隆用户模式地址空间(堆栈数据代码段)和父进程的任务结构,这将导致执行开销,从而导致不确定的进程创建时间。更糟糕的是,如果父进程和子进程都没有对克隆资源进行任何状态更改操作,这种克隆过程将变得毫无意义。

根据 COW,当创建子进程时,它被分配一个唯一的任务结构,其中所有资源条目(包括页表)都指向父进程的任务结构,父子进程都具有只读访问权限。当任一进程启动状态更改操作时,资源才会真正复制,因此称为写时复制(COW 中的意味着状态更改)。COW 确实带来了效率和优化,通过推迟复制进程数据的需求,以及在只读发生时,完全避免复制。这种按需复制还减少了所需的交换页面数量,减少了交换所需的时间,并可能有助于减少需求分页。

exec

有时创建子进程可能没有用,除非它运行一个全新的程序:exec系列调用正好满足这一目的。exec用新的可执行二进制文件替换进程中的现有程序。

#include <unistd.h>
int execve(const char *filename, char *const argv[],
char *const envp[]);

execve是执行作为其第一个参数传递的程序二进制文件的系统调用。第二和第三个参数是以空字符结尾的参数和环境字符串数组,将作为命令行参数传递给新程序。这个系统调用也可以通过各种glibc(库)包装器调用,这些包装器被发现更加方便和灵活。

#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *constargv[]);
int execvp(const char *file, char *constargv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);

命令行用户界面程序如shell使用exec接口启动用户请求的程序二进制文件。

vfork()

fork()不同,vfork()创建一个子进程并阻塞父进程,这意味着子进程作为单个线程运行,不允许并发;换句话说,父进程暂时挂起,直到子进程退出或调用exec()。子进程共享父进程的数据。

Linux 对线程的支持

进程中的执行流被称为线程,这意味着每个进程至少会有一个执行线程。多线程意味着进程中存在多个执行上下文的流。在现代的多核架构中,进程中的多个执行流可以真正并发,实现公平的多任务处理。

线程通常被枚举为进程中纯粹的用户级实体,它们被调度执行;它们共享父进程的虚拟地址空间和系统资源。每个线程都维护其代码、堆栈和线程本地存储。线程由线程库调度和管理,它使用一个称为线程对象的结构来保存唯一的线程标识符,用于调度属性和保存线程上下文。用户级线程应用通常在内存上更轻,是事件驱动应用程序的首选并发模型。另一方面,这种用户级线程模型不适合并行计算,因为它们被绑定到其父进程绑定的同一处理器核心上。

Linux 不直接支持用户级线程;相反,它提出了一个替代的 API 来枚举一个特殊的进程,称为轻量级进程(LWP),它可以与父进程共享一组配置好的资源,如动态内存分配、全局数据、打开的文件、信号处理程序和其他广泛的资源。每个 LWP 都由唯一的 PID 和任务结构标识,并且被内核视为独立的执行上下文。在 Linux 中,术语线程不可避免地指的是 LWP,因为由线程库(Pthreads)初始化的每个线程都被内核枚举为 LWP。

clone()

clone()是一个 Linux 特定的系统调用,用于创建一个新的进程;它被认为是fork()系统调用的通用版本,通过flags参数提供更精细的控制来自定义其功能:

int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);

它提供了超过二十个不同的CLONE_*标志,用于控制clone操作的各个方面,包括父进程和子进程是否共享虚拟内存、打开文件描述符和信号处理程序。子进程使用适当的内存地址(作为第二个参数传递)作为堆栈(用于存储子进程的本地数据)进行创建。子进程使用其启动函数(作为克隆调用的第一个参数)开始执行。

当一个进程尝试通过pthread库创建一个线程时,将使用以下标志调用clone()

/*clone flags for creating threads*/
flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID;

clone()也可以用于创建一个通常使用fork()vfork()生成的常规子进程:

/* clone flags for forking child */
flags = SIGCHLD;
/* clone flags for vfork child */ 
flags = CLONE_VFORK | CLONE_VM | SIGCHLD;

内核线程

为了增加运行后台操作的需求,内核生成线程(类似于进程)。这些内核线程类似于常规进程,它们由任务结构表示,并分配一个 PID。与用户进程不同,它们没有任何映射的地址空间,并且完全在内核模式下运行,这使它们不可交互。各种内核子系统使用kthreads来运行周期性和异步操作。

所有内核线程都是kthreadd(pid 2)的后代,它是在引导期间由kernel(pid 0)生成的。kthreadd枚举其他内核线程;它通过接口例程提供内核服务动态生成其他内核线程的能力。可以使用ps -ef命令从命令行查看内核线程--它们显示在[方括号]中:

UID PID PPID C STIME TTY TIME CMD
root 1 0 0 22:43 ? 00:00:01 /sbin/init splash
root 2 0 0 22:43 ? 00:00:00 [kthreadd]
root 3 2 0 22:43 ? 00:00:00 [ksoftirqd/0]
root 4 2 0 22:43 ? 00:00:00 [kworker/0:0]
root 5 2 0 22:43 ? 00:00:00 [kworker/0:0H]
root 7 2 0 22:43 ? 00:00:01 [rcu_sched]
root 8 2 0 22:43 ? 00:00:00 [rcu_bh]
root 9 2 0 22:43 ? 00:00:00 [migration/0]
root 10 2 0 22:43 ? 00:00:00 [watchdog/0]
root 11 2 0 22:43 ? 00:00:00 [watchdog/1]
root 12 2 0 22:43 ? 00:00:00 [migration/1]
root 13 2 0 22:43 ? 00:00:00 [ksoftirqd/1]
root 15 2 0 22:43 ? 00:00:00 [kworker/1:0H]
root 16 2 0 22:43 ? 00:00:00 [watchdog/2]
root 17 2 0 22:43 ? 00:00:00 [migration/2]
root 18 2 0 22:43 ? 00:00:00 [ksoftirqd/2]
root 20 2 0 22:43 ? 00:00:00 [kworker/2:0H]
root 21 2 0 22:43 ? 00:00:00 [watchdog/3]
root 22 2 0 22:43 ? 00:00:00 [migration/3]
root 23 2 0 22:43 ? 00:00:00 [ksoftirqd/3]
root 25 2 0 22:43 ? 00:00:00 [kworker/3:0H]
root 26 2 0 22:43 ? 00:00:00 [kdevtmpfs]
/*kthreadd creation code (init/main.c) */
static noinline void __ref rest_init(void)
{
 int pid;

 rcu_scheduler_starting();
 /*
 * We need to spawn init first so that it obtains pid 1, however
 * the init task will end up wanting to create kthreads, which, if
 * we schedule it before we create kthreadd, will OOPS.
 */
 kernel_thread(kernel_init, NULL, CLONE_FS);
 numa_default_policy();
 pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
 rcu_read_lock();
 kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
 rcu_read_unlock();
 complete(&kthreadd_done);

 /*
 * The boot idle thread must execute schedule()
 * at least once to get things moving:
 */
 init_idle_bootup_task(current);
 schedule_preempt_disabled();
 /* Call into cpu_idle with preempt disabled */
 cpu_startup_entry(CPUHP_ONLINE);
}

前面的代码显示了内核引导例程rest_init()调用kernel_thread()例程,并使用适当的参数来生成kernel_init线程(然后继续启动用户模式的init进程)和kthreadd

kthread是一个永久运行的线程,它查看一个名为kthread_create_list的列表,以获取要创建的新kthreads的数据:

/*kthreadd routine(kthread.c) */
int kthreadd(void *unused)
{
 struct task_struct *tsk = current;

 /* Setup a clean context for our children to inherit. */
 set_task_comm(tsk, "kthreadd");
 ignore_signals(tsk);
 set_cpus_allowed_ptr(tsk, cpu_all_mask);
 set_mems_allowed(node_states[N_MEMORY]);

 current->flags |= PF_NOFREEZE;

 for (;;) {
 set_current_state(TASK_INTERRUPTIBLE);
 if (list_empty(&kthread_create_list))
 schedule();
 __set_current_state(TASK_RUNNING);

 spin_lock(&kthread_create_lock);
 while (!list_empty(&kthread_create_list)) {
 struct kthread_create_info *create;

 create = list_entry(kthread_create_list.next,
 struct kthread_create_info, list);
 list_del_init(&create->list);
 spin_unlock(&kthread_create_lock);

 create_kthread(create); /* creates kernel threads with attributes enqueued */

 spin_lock(&kthread_create_lock);
 }
 spin_unlock(&kthread_create_lock);
 }

 return 0;
}
kthread_create invoking kthread_create_on_node(), which by default creates threads on the current Numa node:
struct task_struct *kthread_create_on_node(int (*threadfn)(void *data),
 void *data,
 int node,
 const char namefmt[], ...);

/**
 * kthread_create - create a kthread on the current node
 * @threadfn: the function to run in the thread
 * @data: data pointer for @threadfn()
 * @namefmt: printf-style format string for the thread name
 * @...: arguments for @namefmt.
 *
 * This macro will create a kthread on the current node, leaving it in
 * the stopped state. This is just a helper for       
 * kthread_create_on_node();
 * see the documentation there for more details.
 */
#define kthread_create(threadfn, data, namefmt, arg...) 
 kthread_create_on_node(threadfn, data, NUMA_NO_NODE, namefmt, ##arg)

struct task_struct *kthread_create_on_cpu(int (*threadfn)(void *data),
 void *data,
 unsigned int cpu,
 const char *namefmt);

/**
 * kthread_run - create and wake a thread.
 * @threadfn: the function to run until signal_pending(current).
 * @data: data ptr for @threadfn.
 * @namefmt: printf-style name for the thread.
 *
 * Description: Convenient wrapper for kthread_create() followed by
 * wake_up_process(). Returns the kthread or ERR_PTR(-ENOMEM).
 */
#define kthread_run(threadfn, data, namefmt, ...) 
({ 
 struct task_struct *__k 
 = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); 
 if (!IS_ERR(__k)) 
 wake_up_process(__k); 
 __k; 
})

kthread_create_on_node() 将要创建的 kthread 的详细信息(作为参数接收)实例化为 kthread_create_info 类型的结构,并将其排队到 kthread_create_list 的末尾。然后唤醒 kthreadd 并等待线程创建完成:

/* kernel/kthread.c */
static struct task_struct *__kthread_create_on_node(int (*threadfn)(void *data),
 void *data, int node,
 const char namefmt[],
 va_list args)
{
 DECLARE_COMPLETION_ONSTACK(done);
 struct task_struct *task;
 struct kthread_create_info *create = kmalloc(sizeof(*create),
 GFP_KERNEL);

 if (!create)
 return ERR_PTR(-ENOMEM);
 create->threadfn = threadfn;
 create->data = data;
 create->node = node;
 create->done = &done;

 spin_lock(&kthread_create_lock);
 list_add_tail(&create->list, &kthread_create_list);
 spin_unlock(&kthread_create_lock);

 wake_up_process(kthreadd_task);
 /*
 * Wait for completion in killable state, for I might be chosen by
 * the OOM killer while kthreadd is trying to allocate memory for
 * new kernel thread.
 */
 if (unlikely(wait_for_completion_killable(&done))) {
 /*
 * If I was SIGKILLed before kthreadd (or new kernel thread)
 * calls complete(), leave the cleanup of this structure to
 * that thread.
 */
 if (xchg(&create->done, NULL))
 return ERR_PTR(-EINTR);
 /*
 * kthreadd (or new kernel thread) will call complete()
 * shortly.
 */
 wait_for_completion(&done); // wakeup on completion of thread creation.
 }
...
...
...
}

struct task_struct *kthread_create_on_node(int (*threadfn)(void *data),
 void *data, int node,
 const char namefmt[],
 ...)
{
 struct task_struct *task;
 va_list args;

 va_start(args, namefmt);
 task = __kthread_create_on_node(threadfn, data, node, namefmt, args);
 va_end(args);

 return task;
}

回想一下,kthreadd 调用 create_thread() 例程来根据排队到列表中的数据启动内核线程。这个例程创建线程并发出完成信号:

/* kernel/kthread.c */
static void create_kthread(struct kthread_create_info *create)
{
 int pid;

 #ifdef CONFIG_NUMA
 current->pref_node_fork = create->node;
 #endif

 /* We want our own signal handler (we take no signals by default). */
 pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES |  
 SIGCHLD);
 if (pid < 0) {
 /* If user was SIGKILLed, I release the structure. */
 struct completion *done = xchg(&create->done, NULL);

 if (!done) {
 kfree(create);
 return;
 }
 create->result = ERR_PTR(pid);
 complete(done); /* signal completion of thread creation */
 }
}

do_fork() 和 copy_process()

到目前为止讨论的所有进程/线程创建调用都会调用不同的系统调用(除了 create_thread)进入内核模式。所有这些系统调用最终汇聚到通用内核 function _do_fork() 中,该函数以不同的 CLONE_* 标志调用。do_fork() 在内部回退到 copy_process() 完成任务。以下图表总结了进程创建的调用顺序:

/* kernel/fork.c */
/*
 * Create a kernel thread.
 */
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
 return _do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn,
 (unsigned long)arg, NULL, NULL, 0);
}

/* sys_fork: create a child process by duplicating caller */
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
 return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
#else
 /* cannot support in nommu mode */
 return -EINVAL;
#endif
}

/* sys_vfork: create vfork child process */
SYSCALL_DEFINE0(vfork)
{
 return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
 0, NULL, NULL, 0);
}

/* sys_clone: create child process as per clone flags */

#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
 int __user *, parent_tidptr,
 unsigned long, tls,
 int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
 int __user *, parent_tidptr,
 int __user *, child_tidptr,
 unsigned long, tls)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
 int, stack_size,
 int __user *, parent_tidptr,
 int __user *, child_tidptr,
 unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
 int __user *, parent_tidptr,
 int __user *, child_tidptr,
 unsigned long, tls)
#endif
{
 return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}
#endif

进程状态和终止

在进程的生命周期中,它在最终终止之前会遍历许多状态。用户必须具有适当的机制来了解进程在其生命周期中发生的一切。Linux 为此提供了一组函数。

等待

对于由父进程创建的进程和线程,父进程知道子进程/线程的执行状态可能是有用的。可以使用 wait 系列系统调用来实现这一点:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, intoptions);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options)

这些系统调用会更新调用进程的状态,以便通知子进程的状态变化事件。

  • 子进程的终止

  • 被信号停止

  • 被信号恢复

除了报告状态,这些 API 还允许父进程收集终止的子进程。进程在终止时被放入僵尸状态,直到其直接父进程调用 wait 来收集它。

退出

每个进程都必须结束。进程的终止是通过进程调用 exit() 或主函数返回来完成的。进程也可能在接收到强制其终止的信号或异常时被突然终止,例如发送终止进程的 KILL 命令,或引发异常。在终止时,进程被放入退出状态,直到其直接父进程收集它。

exit 调用 sys_exit 系统调用,该系统调用内部调用 do_exit 例程。 do_exit 主要执行以下任务(do_exit 设置许多值,并多次调用相关内核例程以完成其任务):

  • 获取子进程返回给父进程的退出码。

  • 设置 PF_EXITING 标志,表示进程正在退出。

  • 清理和回收进程持有的资源。这包括释放 mm_struct,如果进程正在等待 IPC 信号量,则从队列中移除,释放文件系统数据和文件(如果有的话),并在进程不再可执行时调用 schedule()

do_exit 之后,进程保持僵尸状态,进程描述符仍然完整,父进程可以收集状态,之后系统会回收资源。

命名空间和 cgroups

登录到 Linux 系统的用户可以透明地查看各种系统实体,如全局资源、进程、内核和用户。例如,有效用户可以访问系统上所有运行进程的 PID(无论它们属于哪个用户)。用户可以观察系统上其他用户的存在,并运行命令查看全局系统资源的状态,如内存、文件系统挂载和设备。这些操作不被视为侵入或被视为安全漏洞,因为始终保证一个用户/进程永远不会侵入其他用户/进程。

然而,在一些服务器平台上,这种透明性是不受欢迎的。例如,考虑云服务提供商提供PaaS平台即服务)。他们提供一个环境来托管和部署自定义客户端应用程序。他们管理运行时、存储、操作系统、中间件和网络服务,让客户端管理他们的应用程序和数据。各种电子商务、金融、在线游戏和其他相关企业使用 PaaS 服务。

为了客户端的高效隔离和资源管理,PaaS 服务提供商使用各种工具。他们为每个客户端虚拟化系统环境,以实现安全性、可靠性和健壮性。Linux 内核提供了低级机制,以 cgroups 和命名空间的形式构建各种轻量级工具,可以虚拟化系统环境。Docker 就是一个建立在 cgroups 和命名空间之上的框架。

命名空间基本上是一种机制,用于抽象、隔离和限制一组进程对诸如进程树、网络接口、用户 ID 和文件系统挂载等各种系统实体的可见性。命名空间分为几个组,我们现在将看到。

挂载命名空间

传统上,挂载和卸载操作会改变系统中所有进程所看到的文件系统视图;换句话说,所有进程都能看到一个全局挂载命名空间。挂载命名空间限制了进程命名空间内可见的文件系统挂载点集合,使得一个挂载命名空间中的一个进程组可以对文件系统列表有独占的视图,与另一个进程相比。

UTS 命名空间

这些使得在 uts 命名空间中隔离系统的主机和域名成为可能。这使得初始化和配置脚本能够根据各自的命名空间进行引导。

IPC 命名空间

这些将进程从使用 System V 和 POSIX 消息队列中分隔出来。这样可以防止一个 ipc 命名空间内的进程访问另一个 ipc 命名空间的资源。

PID 命名空间

传统上,*nix 内核(包括 Linux)在系统启动期间使用 PID 1 生成init进程,然后启动其他用户模式进程,并被认为是进程树的根(所有其他进程在树中的这个进程下方启动)。PID 命名空间允许进程在其下方产生一个新的进程树,具有自己的根进程(PID 1 进程)。PID 命名空间隔离进程 ID 号,并允许在不同的 PID 命名空间中复制 PID 号,这意味着不同 PID 命名空间中的进程可以具有相同的进程 ID。PID 命名空间内的进程 ID 是唯一的,并且从 PID 1 开始按顺序分配。

PID 命名空间在容器(轻量级虚拟化解决方案)中用于迁移具有进程树的容器到不同的主机系统,而无需更改 PID。

网络命名空间

这种类型的命名空间提供了网络协议服务和接口的抽象和虚拟化。每个网络命名空间都有自己的网络设备实例,可以配置具有独立网络地址。其他网络服务的隔离也得以实现:路由表、端口号等。

用户命名空间

用户命名空间允许进程在命名空间内外使用唯一的用户和组 ID。这意味着进程可以在用户命名空间内使用特权用户和组 ID(零),并在命名空间外继续使用非零用户和组 ID。

Cgroup 命名空间

Cgroup 命名空间虚拟化了/proc/self/cgroup文件的内容。在 cgroup 命名空间内的进程只能查看相对于其命名空间根的路径。

控制组(cgroups)

Cgroups 是内核机制,用于限制和测量每个进程组的资源分配。使用 cgroups,可以分配 CPU 时间、网络和内存等资源。

与 Linux 中的进程模型类似,每个进程都是父进程的子进程,并相对于init进程而言形成单树结构,cgroups 是分层的,子 cgroups 继承父级的属性,但不同之处在于在单个系统中可以存在多个 cgroup 层次结构,每个层次结构都具有不同的资源特权。

将 cgroups 应用于命名空间会将进程隔离到系统中的“容器”中,资源得到独立管理。每个“容器”都是一个轻量级虚拟机,所有这些虚拟机都作为独立实体运行,并且对系统中的其他实体毫不知情。

以下是 Linux man 页面中描述的命名空间 API:

clone(2)
The clone(2) system call creates a new process. If the flags argument of the call specifies one or more of the CLONE_NEW* flags listed below, then new namespaces are created for each flag, and the child process is made a member of those namespaces.(This system call also implements a number of features unrelated to namespaces.)

setns(2)
The setns(2) system call allows the calling process to join an existing namespace. The namespace to join is specified via a file descriptor that refers to one of the /proc/[pid]/ns files described below.

unshare(2)
The unshare(2) system call moves the calling process to a new namespace. If the flags argument of the call specifies one or more of the CLONE_NEW* flags listed below, then new namespaces are created for each flag, and the calling process is made a member of those namespaces. (This system call also implements a number of features unrelated to namespaces.)
Namespace   Constant          Isolates
Cgroup      CLONE_NEWCGROUP   Cgroup root directory
IPC         CLONE_NEWIPC      System V IPC, POSIX message queues
Network     CLONE_NEWNET      Network devices, stacks, ports, etc.
Mount       CLONE_NEWNS       Mount points
PID         CLONE_NEWPID      Process IDs
User        CLONE_NEWUSER     User and group IDs
UTS         CLONE_NEWUTS      Hostname and NIS domain name

总结

我们了解了 Linux 的一个主要抽象称为进程,并且整个生态系统都在促进这种抽象。现在的挑战在于通过提供公平的 CPU 时间来运行大量的进程。随着多核系统施加了多种策略和优先级的进程,确定性调度的需求变得至关重要。

在我们的下一章中,我们将深入研究进程调度,这是进程管理的另一个关键方面,并了解 Linux 调度程序是如何设计来处理这种多样性的。

第二章:解密进程调度程序

进程调度是任何操作系统的最关键的执行工作之一,Linux 也不例外。调度进程的启发式和效率是使任何操作系统运行并赋予其身份的关键因素,例如通用操作系统、服务器或实时系统。在本章中,我们将深入了解 Linux 调度程序,解密诸如:

  • Linux 调度程序设计

  • 调度类

  • 调度策略和优先级

  • 完全公平调度器

  • 实时调度程序

  • 截止时间调度器

  • 组调度

  • 抢占

进程调度程序

任何操作系统的有效性与其公平调度所有竞争进程的能力成正比。进程调度程序是内核的核心组件,它计算并决定进程何时以及多长时间获得 CPU 时间。理想情况下,进程需要 CPU 的时间片来运行,因此调度程序基本上需要公平地分配处理器时间片给进程。

调度程序通常需要:

  • 避免进程饥饿

  • 管理优先级调度

  • 最大化所有进程的吞吐量

  • 确保低周转时间

  • 确保资源使用均匀

  • 避免 CPU 占用

  • 考虑进程的行为模式进行优先级排序

  • 在重负载下优雅地补贴

  • 有效地处理多核上的调度

Linux 进程调度程序设计

Linux 最初是为桌面系统开发的,但不知不觉地演变成了一个多维操作系统,其使用范围涵盖嵌入式设备、大型机和超级计算机,以及房间大小的服务器。它还无缝地适应了不断发展的多样化计算平台,如 SMP、虚拟化和实时系统。这些平台的多样性是由在这些系统上运行的进程类型带来的。例如,一个高度交互式的桌面系统可能运行 I/O 绑定的进程,而实时系统则依赖确定性进程。因此,每种类型的进程在需要公平调度时都需要不同类型的启发式方法,因为 CPU 密集型进程可能需要比普通进程更多的 CPU 时间,而实时进程则需要确定性执行。因此,Linux 面临着处理这些多样化进程管理时带来的不同调度挑战。

Linux 进程调度程序的内在设计通过采用简单的两层模型,优雅而巧妙地处理了这一挑战。其第一层,通用调度程序,定义了作为调度程序入口函数的抽象操作,而第二层,调度类,实现了实际的调度操作,其中每个类专门处理特定类型进程的调度启发式。这种模型使得通用调度程序能够从每个调度类的实现细节中抽象出来。例如,普通进程(I/O 绑定)可以由一个类处理,而需要确定性执行的进程,如实时进程,可以由另一个类处理。这种架构还能够无缝地添加新的调度类。前面的图示了进程调度程序的分层设计。

通用调度程序通过一个称为sched_class的结构定义了抽象接口:

struct sched_class {
    const struct sched_class *next;

     void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
   void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
   void (*yield_task) (struct rq *rq);
       bool (*yield_to_task) (struct rq *rq, struct task_struct *p, bool preempt);

 void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);

       /*
         * It is the responsibility of the pick_next_task() method that will
       * return the next task to call put_prev_task() on the @prev task or
  * something equivalent.
   *
         * May return RETRY_TASK when it finds a higher prio class has runnable
    * tasks.
  */
       struct task_struct * (*pick_next_task) (struct rq *rq,
                                            struct task_struct *prev,
                                         struct rq_flags *rf);
     void (*put_prev_task) (struct rq *rq, struct task_struct *p);

#ifdef CONFIG_SMP
        int  (*select_task_rq)(struct task_struct *p, int task_cpu, int sd_flag, int flags);
      void (*migrate_task_rq)(struct task_struct *p);

     void (*task_woken) (struct rq *this_rq, struct task_struct *task);

  void (*set_cpus_allowed)(struct task_struct *p,
                            const struct cpumask *newmask);

    void (*rq_online)(struct rq *rq);
 void (*rq_offline)(struct rq *rq);
#endif

      void (*set_curr_task) (struct rq *rq);
    void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
     void (*task_fork) (struct task_struct *p);
        void (*task_dead) (struct task_struct *p);

  /*
         * The switched_from() call is allowed to drop rq->lock, therefore we
   * cannot assume the switched_from/switched_to pair is serialized by
        * rq->lock. They are however serialized by p->pi_lock.
      */
       void (*switched_from) (struct rq *this_rq, struct task_struct *task);
     void (*switched_to) (struct rq *this_rq, struct task_struct *task);
       void (*prio_changed) (struct rq *this_rq, struct task_struct *task,
                            int oldprio);

  unsigned int (*get_rr_interval) (struct rq *rq,
                                    struct task_struct *task);

 void (*update_curr) (struct rq *rq);

#define TASK_SET_GROUP  0
#define TASK_MOVE_GROUP  1

#ifdef CONFIG_FAIR_GROUP_SCHED
       void (*task_change_group) (struct task_struct *p, int type);
#endif
};

每个调度类都实现了sched_class结构中定义的操作。截至 4.12.x 内核,有三个调度类:完全公平调度CFS)类,实时调度类和截止时间调度类,每个类处理具有特定调度要求的进程。以下代码片段显示了每个类如何根据sched_class结构填充其操作。

CFS 类

const struct sched_class fair_sched_class = {
         .next                   = &idle_sched_class,
         .enqueue_task           = enqueue_task_fair,
         .dequeue_task           = dequeue_task_fair,
         .yield_task             = yield_task_fair,
         .yield_to_task          = yield_to_task_fair,

         .check_preempt_curr     = check_preempt_wakeup,

         .pick_next_task         = pick_next_task_fair,
         .put_prev_task          = put_prev_task_fair,
....
}

实时调度类

const struct sched_class rt_sched_class = {
         .next                   = &fair_sched_class,
         .enqueue_task           = enqueue_task_rt,
         .dequeue_task           = dequeue_task_rt,
         .yield_task             = yield_task_rt,

         .check_preempt_curr     = check_preempt_curr_rt,

         .pick_next_task         = pick_next_task_rt,
         .put_prev_task          = put_prev_task_rt,
....
}

截止时间调度类

const struct sched_class dl_sched_class = {
         .next                   = &rt_sched_class,
         .enqueue_task           = enqueue_task_dl,
         .dequeue_task           = dequeue_task_dl,
         .yield_task             = yield_task_dl,

         .check_preempt_curr     = check_preempt_curr_dl,

         .pick_next_task         = pick_next_task_dl,
         .put_prev_task          = put_prev_task_dl,
....
}

运行队列

传统上,运行队列包含了在给定 CPU 核心上争夺 CPU 时间的所有进程(每个 CPU 都有一个运行队列)。通用调度程序被设计为在调度下一个最佳的可运行任务时查看运行队列。由于每个调度类处理特定的调度策略和优先级,维护所有可运行进程的公共运行队列是不可能的。

内核通过将其设计原则引入前台来解决这个问题。每个调度类都定义了其运行队列数据结构的布局,以最适合其策略。通用调度程序层实现了一个抽象的运行队列结构,其中包含作为运行队列接口的公共元素。该结构通过指针扩展,这些指针指向特定类的运行队列。换句话说,所有调度类都将其运行队列嵌入到主运行队列结构中。这是一个经典的设计技巧,它让每个调度程序类选择适合其运行队列数据结构的适当布局。

struct rq (runqueue) will help us comprehend the concept (elements related to SMP have been omitted from the structure to keep our focus on what's relevant):
 struct rq {
        /* runqueue lock: */
        raw_spinlock_t lock;
   /*
    * nr_running and cpu_load should be in the same cacheline because
    * remote CPUs use both these fields when doing load calculation.
    */
         unsigned int nr_running;
    #ifdef CONFIG_NUMA_BALANCING
         unsigned int nr_numa_running;
         unsigned int nr_preferred_running;
    #endif
         #define CPU_LOAD_IDX_MAX 5
         unsigned long cpu_load[CPU_LOAD_IDX_MAX];
 #ifdef CONFIG_NO_HZ_COMMON
 #ifdef CONFIG_SMP
         unsigned long last_load_update_tick;
 #endif /* CONFIG_SMP */
         unsigned long nohz_flags;
 #endif /* CONFIG_NO_HZ_COMMON */
 #ifdef CONFIG_NO_HZ_FULL
         unsigned long last_sched_tick;
 #endif
         /* capture load from *all* tasks on this cpu: */
         struct load_weight load;
         unsigned long nr_load_updates;
         u64 nr_switches;

         struct cfs_rq cfs;
         struct rt_rq rt;
         struct dl_rq dl;

 #ifdef CONFIG_FAIR_GROUP_SCHED
         /* list of leaf cfs_rq on this cpu: */
         struct list_head leaf_cfs_rq_list;
         struct list_head *tmp_alone_branch;
 #endif /* CONFIG_FAIR_GROUP_SCHED */

          unsigned long nr_uninterruptible;

         struct task_struct *curr, *idle, *stop;
         unsigned long next_balance;
         struct mm_struct *prev_mm;

         unsigned int clock_skip_update;
         u64 clock;
         u64 clock_task;

         atomic_t nr_iowait;

 #ifdef CONFIG_IRQ_TIME_ACCOUNTING
         u64 prev_irq_time;
 #endif
 #ifdef CONFIG_PARAVIRT
         u64 prev_steal_time;
 #endif
 #ifdef CONFIG_PARAVIRT_TIME_ACCOUNTING
         u64 prev_steal_time_rq;
 #endif

         /* calc_load related fields */
         unsigned long calc_load_update;
         long calc_load_active;

 #ifdef CONFIG_SCHED_HRTICK
 #ifdef CONFIG_SMP
         int hrtick_csd_pending;
         struct call_single_data hrtick_csd;
 #endif
         struct hrtimer hrtick_timer;
 #endif
 ...
 #ifdef CONFIG_CPU_IDLE
         /* Must be inspected within a rcu lock section */
         struct cpuidle_state *idle_state;
 #endif
};

您可以看到调度类(cfsrtdl)是如何嵌入到运行队列中的。运行队列中其他感兴趣的元素包括:

  • nr_running: 这表示运行队列中的进程数量

  • load: 这表示队列上的当前负载(所有可运行进程)

  • curridle: 这些分别指向当前运行任务的 task_struct 和空闲任务。当没有其他任务要运行时,空闲任务会被调度。

调度程序的入口

调度过程始于对通用调度程序的调用,即 <kernel/sched/core.c> 中定义的 schedule() 函数。这可能是内核中最常调用的例程之一。schedule() 的功能是选择下一个最佳的可运行任务。schedule() 函数的 pick_next_task() 遍历调度类中包含的所有相应函数,并最终选择下一个最佳的任务来运行。每个调度类都使用单链表连接,这使得 pick_next_task() 能够遍历这些类。

考虑到 Linux 主要设计用于高度交互式系统,该函数首先在 CFS 类中查找下一个最佳的可运行任务,如果在其他类中没有更高优先级的可运行任务(通过检查运行队列中可运行任务的总数(nr_running)是否等于 CFS 类子运行队列中可运行任务的总数来实现);否则,它会遍历所有其他类,并选择下一个最佳的可运行任务。最后,如果找不到任务,则调用空闲后台任务(始终返回非空值)。

以下代码块显示了 pick_next_task() 的实现:

/*
 * Pick up the highest-prio task:
 */
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
   const struct sched_class *class;
  struct task_struct *p;

      /*
         * Optimization: we know that if all tasks are in the fair class we can
    * call that function directly, but only if the @prev task wasn't of a
        * higher scheduling class, because otherwise those loose the
      * opportunity to pull in more work from other CPUs.
       */
       if (likely((prev->sched_class == &idle_sched_class ||
                  prev->sched_class == &fair_sched_class) &&
                rq->nr_running == rq->cfs.h_nr_running)) {

         p = fair_sched_class.pick_next_task(rq, prev, rf);
                if (unlikely(p == RETRY_TASK))
                    goto again;

         /* Assumes fair_sched_class->next == idle_sched_class */
               if (unlikely(!p))
                 p = idle_sched_class.pick_next_task(rq, prev, rf);

          return p;
 }

again:
       for_each_class(class) {
           p = class->pick_next_task(rq, prev, rf);
               if (p) {
                  if (unlikely(p == RETRY_TASK))
                            goto again;
                       return p;
         }
 }

   /* The idle class should always have a runnable task: */
  BUG();
}

进程优先级

决定运行哪个进程取决于进程的优先级。每个进程都标有一个优先级值,这使得它在获得 CPU 时间方面有一个即时的位置。在 *nix 系统上,优先级基本上分为 动态静态 优先级。动态优先级 基本上是内核动态地应用于正常进程,考虑到诸如进程的良好值、其历史行为(I/O 绑定或处理器绑定)、已过去的执行和等待时间等各种因素。静态优先级 是由用户应用于实时进程的,内核不会动态地改变它们的优先级。因此,具有静态优先级的进程在调度时会被赋予更高的优先级。

I/O 绑定进程:当进程的执行严重受到 I/O 操作的影响(等待资源或事件),例如文本编辑器几乎在运行和等待按键之间交替时,这样的进程被称为 I/O 绑定。由于这种特性,调度器通常会为 I/O 绑定的进程分配较短的处理器时间片,并将它们与其他进程复用,增加了上下文切换的开销以及计算下一个最佳进程的后续启发式。

处理器绑定进程:这些进程喜欢占用 CPU 时间片,因为它们需要最大限度地利用处理器的计算能力。需要进行复杂科学计算和视频渲染编解码的进程是处理器绑定的。虽然需要更长的 CPU 时间片看起来很理想,但通常不需要在固定时间段内运行它们。交互式操作系统上的调度器倾向于更喜欢 I/O 绑定的进程而不是处理器绑定的进程。Linux 旨在提供良好的交互性能,更倾向于 I/O 绑定的进程,即使处理器绑定的进程运行频率较低,它们通常会被分配更长的时间片来运行。

进程也可以是多面手,I/O 绑定进程需要执行严肃的科学计算,占用 CPU。

任何正常进程的nice值范围在 19(最低优先级)和-20(最高优先级)之间,0 是默认值。较高的 nice 值表示较低的优先级(进程对其他进程更友好)。实时进程的优先级在 0 到 99 之间(静态优先级)。所有这些优先级范围都是从用户的角度来看的。

内核对优先级的看法

然而,Linux 从自己的角度看待进程优先级。它为计算进程的优先级增加了更多的计算。基本上,它将所有优先级在 0 到 139 之间进行缩放,其中 0 到 99 分配给实时进程,100 到 139 代表了 nice 值范围(-20 到 19)。

调度器类

现在让我们深入了解每个调度类,并了解它在管理调度操作时所涉及的操作、政策和启发式。如前所述,每个调度类必须提供struct sched_class的一个实例;让我们看一下该结构中的一些关键元素:

  • enqueue_task:基本上是将新进程添加到运行队列

  • dequeue_task:当进程从运行队列中移除时

  • yield_task:当进程希望自愿放弃 CPU 时

  • pick_next_task:由schedule()调用的pick_next_task的相应函数。它从其类中挑选出下一个最佳的可运行任务。

完全公平调度类(CFS)

所有具有动态优先级的进程都由 CFS 类处理,而大多数通用*nix 系统中的进程都是正常的(非实时),因此 CFS 仍然是内核中最繁忙的调度器类。

CFS 依赖于根据任务分配处理器时间的政策和动态分配的优先级来保持平衡。CFS 下的进程调度是在假设下实现的,即它具有"理想的、精确的多任务 CPU",在其峰值容量下平等地为所有进程提供动力。例如,如果有两个进程,完美的多任务 CPU 确保两个进程同时运行,每个进程利用其 50%的能力。由于这在实际上是不可能的(实现并行性),CFS 通过在所有竞争进程之间保持适当的平衡来为进程分配处理器时间。如果一个进程未能获得公平的时间,它被认为是不平衡的,因此作为最佳可运行进程进入下一个进程。

CFS 不依赖于传统的时间片来分配处理器时间,而是使用虚拟运行时间(vruntime)的概念:它表示进程获得 CPU 时间的数量,这意味着低vruntime值表示进程处理器匮乏,而高vruntime值表示进程获得了相当多的处理器时间。具有低vruntime值的进程在调度时获得最高优先级。CFS 还为理想情况下等待 I/O 请求的进程使用睡眠公平性。睡眠公平性要求等待的进程在最终唤醒后获得相当多的 CPU 时间。根据vruntime值,CFS 决定进程运行的时间。它还使用 nice 值来衡量进程与所有竞争进程的关系:较高值的低优先级进程获得较少的权重,而较低值的高优先级任务获得更多的权重。即使在 Linux 中处理具有不同优先级的进程也是优雅的,因为与较高优先级任务相比,较低优先级任务会有相当大的延迟因素;这使得分配给低优先级任务的时间迅速消失。

在 CFS 下计算优先级和时间片

优先级是基于进程等待时间、进程运行时间、进程的历史行为和其 nice 值来分配的。通常,调度程序使用复杂的算法来找到下一个最佳的要运行的进程。

在计算每个进程获得的时间片时,CFS 不仅依赖于进程的 nice 值,还考虑进程的负载权重。对于进程 nice 值的每次增加 1,CPU 时间片将减少 10%,对于 nice 值的每次减少 1,CPU 时间片将增加 10%,这表明 nice 值对于每次变化都是以 10%的乘法变化。为了计算相应 nice 值的负载权重,内核维护了一个名为prio_to_weight的数组,其中每个 nice 值对应一个权重:

static const int prio_to_weight[40] = {
  /* -20 */     88761,     71755,     56483,     46273,     36291,
  /* -15 */     29154,     23254,     18705,     14949,     11916,
  /* -10 */      9548,      7620,      6100,      4904,      3906,
  /*  -5 */      3121,      2501,      1991,      1586,      1277,
  /*   0 */      1024,       820,       655,       526,       423,
  /*   5 */       335,       272,       215,       172,       137,
  /*  10 */       110,        87,        70,        56,        45,
  /*  15 */        36,        29,        23,        18,        15,
};

进程的负载值存储在struct load_weightweight字段中。

像进程的权重一样,CFS 的运行队列也被分配了一个权重,这是运行队列中所有任务的总权重。现在,时间片是通过考虑实体的负载权重、运行队列的负载权重和sched_period(调度周期)来计算的。

CFS 的运行队列

CFS 摆脱了普通运行队列的需要,而是使用自平衡的红黑树,以便在最短时间内找到下一个最佳的要运行的进程。RB 树保存了所有竞争进程,并便于对进程进行快速插入、删除和搜索。最高优先级的进程被放置在最左边的节点上。pick_next_task()函数现在只是从rb tree中选择最左边的节点进行调度。

分组调度

为了确保调度时的公平性,CFS 被设计为保证每个可运行的进程在一个定义的时间段内至少运行一次,称为调度周期。在调度周期内,CFS 基本上确保公平性,或者换句话说,确保不公平性被最小化,因为每个进程至少运行一次。CFS 将调度周期分成时间片,以避免进程饥饿;然而,想象一下这样的情况,进程 A 生成了 10 个执行线程,进程 B 生成了 5 个执行线程:在这里,CFS 将时间片均匀分配给所有线程,导致进程 A 及其生成的线程获得最大的时间,而进程 B 则受到不公平对待。如果进程 A 继续生成更多的线程,情况可能对进程 B 及其生成的线程变得严重,因为进程 B 将不得不应对最小的调度粒度或时间片(即 1 毫秒)。在这种情况下,公平性要求进程 A 和 B 获得相等的时间片,并且生成的线程在内部共享这些时间片。例如,如果进程 A 和 B 各获得 50%的时间,那么进程 A 将把其 50%的时间分配给其生成的 10 个线程,每个线程在内部获得 5%的时间。

为了解决这个问题并保持公平性,CFS 引入了组调度,其中时间片分配给线程组而不是单个线程。继续上面的例子,在组调度下,进程 A 及其生成的线程属于一组,进程 B 及其生成的线程属于另一组。由于调度粒度是在组级别而不是线程级别上强加的,它给予进程 A 和 B 相等的处理器时间份额,进程 A 和 B 在其组成员内部分配时间片。在这里,生成在进程 A 下的线程会受到影响,因为它因生成更多的执行线程而受到惩罚。为了确保组调度,在配置内核时需要设置CONFIG_FAIR_GROUP_SCHED。CFS 任务组由结构sched_entity*表示,每个组被称为调度实体。以下代码片段显示了调度实体结构的关键元素:

struct sched_entity {
        struct load_weight      load;   /* for load-balancing */
        struct rb_node          run_node;
        struct list_head        group_node;
        unsigned int            on_rq;

        u64                     exec_start;
        u64                     sum_exec_runtime;
        u64                     vruntime;
        u64                     prev_sum_exec_runtime;

        u64                     nr_migrations;

 #ifdef CONFIG_SCHEDSTATS
        struct sched_statistics statistics;
#endif

#ifdef CONFIG_FAIR_GROUP_SCHED
        int depth;
        struct sched_entity *parent;
         /* rq on which this entity is (to be) queued: */
        struct cfs_rq           *cfs_rq;
        /* rq "owned" by this entity/group: */
        struct cfs_rq           *my_q;
#endif

....
};
  • load:表示每个实体对队列总负载的负载量

  • vruntime:表示进程运行的时间

许多核心系统下的调度实体

任务组可以在许多核心系统上的任何 CPU 核心上运行,但为了实现这一点,创建一个调度实体是不够的。因此,组必须为系统上的每个 CPU 核心创建一个调度实体。跨 CPU 的调度实体由struct task_group表示:

/* task group related information */
struct task_group {
       struct cgroup_subsys_state css;

#ifdef CONFIG_FAIR_GROUP_SCHED
 /* schedulable entities of this group on each cpu */
      struct sched_entity **se;
 /* runqueue "owned" by this group on each cpu */
  struct cfs_rq **cfs_rq;
   unsigned long shares;

#ifdef CONFIG_SMP
        /*
         * load_avg can be heavily contended at clock tick time, so put
    * it in its own cacheline separated from the fields above which
   * will also be accessed at each tick.
     */
       atomic_long_t load_avg ____cacheline_aligned;
#endif
#endif

#ifdef CONFIG_RT_GROUP_SCHED
     struct sched_rt_entity **rt_se;
   struct rt_rq **rt_rq;

       struct rt_bandwidth rt_bandwidth;
#endif

       struct rcu_head rcu;
      struct list_head list;

      struct task_group *parent;
        struct list_head siblings;
        struct list_head children;

#ifdef CONFIG_SCHED_AUTOGROUP
       struct autogroup *autogroup;
#endif

    struct cfs_bandwidth cfs_bandwidth;
};

现在,每个任务组都有一个调度实体,每个 CPU 核心都有一个与之关联的 CFS 运行队列。当一个任务从一个任务组迁移到另一个 CPU 核心时,该任务将从 CPU x 的 CFS 运行队列中出列,并入列到 CPU y 的 CFS 运行队列中。

调度策略

调度策略适用于进程,并有助于确定调度决策。如果您回忆一下,在第一章中,理解进程、地址空间和线程,我们描述了task_struct结构的调度属性下的int policy字段。policy字段包含一个值,指示调度时要应用哪种策略。CFS 类使用以下两种策略处理所有普通进程:

  • SCHED_NORMAL (0):用于所有普通进程。所有非实时进程都可以总结为普通进程。由于 Linux 旨在成为一个高度响应和交互式的系统,大部分调度活动和启发式方法都集中在公平调度普通进程上。普通进程根据 POSIX 标准被称为SCHED_OTHER

  • SCHED_BATCH (3): 通常在服务器上,非交互式的 CPU 密集型批处理被使用。这些 CPU 密集型的进程比SCHED_NORMAL进程被赋予更低的优先级,并且它们不会抢占正常进程的调度。

  • CFS 类还处理空闲进程的调度,其指定如下策略:

  • SCHED_IDLE (5): 当没有进程需要运行时,空闲进程(低优先级后台进程)被调度。空闲进程被分配了所有进程中最低的优先级。

实时调度类

Linux 支持软实时任务,并由实时调度类进行调度。rt进程被分配静态优先级,并且不会被内核动态改变。由于实时任务旨在确定性运行并希望控制何时以及多长时间被调度,它们总是优先于正常任务(SCHED_NORMAL)。与 CFS 使用rb 树作为其子运行队列不同,较不复杂的rt调度器使用每个优先级值(1 到 99)的简单链表。Linux 应用了两种实时策略,rrfifo,在调度静态优先级进程时;这些由struct task_struct:policy元素指示。

  • SCHED_FIFO (1): 这使用先进先出的方法来调度软实时进程

  • SCHED_RR (2): 这是用于调度软实时进程的轮转策略

FIFO

FIFO是应用于优先级高于 0 的进程的调度机制(0 分配给正常进程)。FIFO 进程在没有任何时间片分配的情况下运行;换句话说,它们一直运行直到阻塞某个事件或明确让出给另一个进程。当调度器遇到更高优先级的可运行 FIFO、RR 或截止任务时,FIFO 进程也会被抢占。当调度器遇到多个具有相同优先级的 fifo 任务时,它会以轮转的方式运行这些进程,从列表头部的第一个进程开始。在抢占时,进程被添加回列表的尾部。如果更高优先级的进程抢占了 FIFO 进程,它会等待在列表的头部,当所有其他高优先级任务被抢占时,它再次被选中运行。当新的 fifo 进程变为可运行时,它被添加到列表的尾部。

RR

轮转策略类似于 FIFO,唯一的区别是它被分配了一个时间片来运行。这是对 FIFO 的一种增强(因为 FIFO 进程可能一直运行直到让出或等待)。与 FIFO 类似,列表头部的 RR 进程被选中执行(如果没有其他更高优先级的任务可用),并在时间片完成时被抢占,并被添加回列表的尾部。具有相同优先级的 RR 进程会轮流运行,直到被高优先级任务抢占。当高优先级任务抢占 RR 任务时,它会等待在列表的头部,并在恢复时只运行其剩余的时间片。

实时组调度

与 CFS 下的组调度类似,实时进程也可以通过设置CONFIG_RT_GROUP_SCHED来进行分组调度。为了使组调度成功,每个组必须被分配一部分 CPU 时间,并保证时间片足够运行每个实体下的任务,否则会失败。因此,每个组都被分配了一部分“运行时间”(CPU 在一段时间内可以运行的时间)。分配给一个组的运行时间不会被另一个组使用。未分配给实时组的 CPU 时间将被正常优先级任务使用,实时实体未使用的时间也将被正常任务使用。FIFO 和 RR 组由struct sched_rt_entity:表示

struct sched_rt_entity {
 struct list_head                run_list;
 unsigned long                   timeout;
  unsigned long                   watchdog_stamp;
   unsigned int                    time_slice;
       unsigned short                  on_rq;
    unsigned short                  on_list;

    struct sched_rt_entity          *back;
#ifdef CONFIG_RT_GROUP_SCHED
  struct sched_rt_entity          *parent;
  /* rq on which this entity is (to be) queued: */
  struct rt_rq                    *rt_rq;
   /* rq "owned" by this entity/group: */
    struct rt_rq                    *my_q;
#endif
};

截止调度类(间歇任务模型截止调度)

Deadline代表 Linux 上新一代的 RT 进程(自 3.14 内核以来添加)。与 FIFO 和 RR 不同,这些进程可能占用 CPU 或受到时间片的限制,截止进程基于 GEDF(全局最早截止时间优先)和 CBS(恒定带宽服务器)算法,预先确定其运行时需求。间歇性进程内部运行多个任务,每个任务都有一个相对截止时间,必须在其中完成执行,并且有一个计算时间,定义 CPU 需要完成进程执行的时间。为了确保内核成功执行截止进程,内核基于截止时间参数运行准入测试,如果失败则返回错误EBUSY。截止策略的进程优先于所有其他进程。截止进程使用SCHED_DEADLINE(6)作为其策略元素。

与调度程序相关的系统调用

Linux 提供了一整套系统调用,用于管理各种调度程序参数、策略和优先级,并为调用线程检索大量与调度相关的信息。它还允许线程显式地放弃 CPU:

nice(int inc)

nice()接受一个int参数,并将其添加到调用线程的nice值中。成功时返回线程的新nice值。Nice值在范围 19(最低优先级)到-20(最高优先级)内。Nice值只能在此范围内递增:

getpriority(int which, id_t who)

这返回线程、组、用户或一组由其参数指示的特定用户的nice值。它返回任何进程持有的最高优先级:

setpriority(int which, id_t who, int prio)

setpriority.设置由其参数指示的特定用户的线程、组、用户或一组线程的调度优先级。成功时返回零:

sched_setscheduler(pid_t pid, int policy, const struct sched_param *param)

这设置了指定线程的调度策略和参数,由其pid指示。如果pid为零,则设置调用线程的策略。指定调度参数的param参数指向一个sched_param结构,其中包含int sched_priority。对于正常进程,sched_priority必须为零,对于 FIFO 和 RR 策略(在策略参数中提到)的优先级值必须在 1 到 99 的范围内。成功时返回零:

sched_getscheduler(pid_t pid)

它返回线程(pid)的调度策略。如果pid为零,则将检索调用线程的策略:

sched_setparam(pid_t pid, const struct sched_param *param)

它设置与给定线程(pid)的调度策略相关联的调度参数。如果pid为零,则设置调用进程的参数。成功时返回零:

sched_getparam(pid_t pid, struct sched_param *param)

这将为指定的线程(pid)设置调度参数。如果pid为零,则将检索调用线程的调度参数。成功时返回零:

sched_setattr(pid_t pid, struct sched_attr *attr, unsigned int flags)

它为指定的线程(pid)设置调度策略和相关属性。如果pid为零,则设置调用进程的策略和属性。这是一个特定于 Linux 的调用,是sched_setscheduler()sched_setparam()调用提供的功能的超集。成功时返回零。

sched_getattr(pid_t pid, struct sched_attr *attr, unsigned int size, unsigned int flags)

它获取指定线程(pid)的调度策略和相关属性。如果pid为零,则将检索调用线程的调度策略和相关属性。这是一个特定于 Linux 的调用,是sched_getscheduler()sched_getparam()调用提供的功能的超集。成功时返回零。

sched_get_priority_max(int policy) 
sched_get_priority_min(int policy)

这分别返回指定policy的最大和最小优先级。fiforrdeadlinenormalbatchidle是策略的支持值。

sched_rr_get_interval(pid_t pid, struct timespec *tp)

它获取指定线程(pid)的时间量,并将其写入由tp指定的timespec结构。如果pid为零,则将调用进程的时间量获取到tp中。这仅适用于具有*rr*策略的进程。成功时返回零。

sched_yield(void)

这被称为显式地放弃 CPU。线程现在被添加回队列。成功时返回零。

处理器亲和力调用

提供了特定于 Linux 的处理器亲和性调用,帮助线程定义它们想要在哪个 CPU 上运行。默认情况下,每个线程继承其父线程的处理器亲和性,但它可以定义其亲和性掩码以确定其处理器亲和性。在许多核心系统上,CPU 亲和性调用有助于提高性能,通过帮助进程保持在一个核心上(但 Linux 会尝试保持一个线程在一个 CPU 上)。亲和性位掩信息包含在struct task_structcpu_allowed字段中。亲和性调用如下:

sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *mask)

它将线程(pid)的 CPU 亲和性掩码设置为mask指定的值。如果线程(pid)不在指定 CPU 的队列中运行,则迁移到指定的cpu。成功时返回零。

sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask)

这将线程(pid)的亲和性掩码提取到由mask指向的cpusetsize结构中。如果pid为零,则返回调用线程的掩码。成功时返回零。

进程抢占

理解抢占和上下文切换对于完全理解调度以及它对内核在维持低延迟和一致性方面的影响至关重要。每个进程都必须被隐式或显式地抢占,以为另一个进程腾出位置。抢占可能导致上下文切换,这需要一个低级别的特定体系结构操作,由函数context_switch()执行。为了使处理器切换上下文,需要完成两个主要任务:将旧进程的虚拟内存映射切换为新进程的映射,并将处理器状态从旧进程切换为新进程的状态。这两个任务由switch_mm()switch_to()执行。

抢占可能发生的原因有:

当高优先级进程变为可运行状态时。为此,调度程序将不时地检查是否有高优先级的可运行线程。从中断和系统调用返回时,设置TIF_NEED_RESCHEDULE(内核提供的指示需要重新调度的标志),调用调度程序。由于有一个周期性的定时器中断保证定期发生,调用调度程序也是有保证的。当进程进入阻塞调用或发生中断事件时,也会发生抢占。

Linux 内核在历史上一直是非抢占的,这意味着内核模式下的任务在没有中断事件发生或选择显式放弃 CPU 的情况下是不可抢占的。自 2.6 内核以来,已经添加了抢占(需要在内核构建期间启用)。启用内核抢占后,内核模式下的任务因为列出的所有原因都是可抢占的,但内核模式下的任务在执行关键操作时可以禁用内核抢占。这是通过向每个进程的thread_info结构添加了一个抢占计数器(preempt_count)来实现的。任务可以通过内核宏preempt_disable()preempt_enable()来禁用/启用抢占,这会增加和减少preempt_counter。这确保了只有当preempt_counter为零时(表示没有获取锁)内核才是可抢占的。

内核代码中的关键部分是通过禁用抢占来执行的,这是通过在内核锁操作(自旋锁、互斥锁)中调用preempt_disablepreempt_enable来实现的。

使用“抢占 rt”构建的 Linux 内核,支持完全可抢占内核选项,启用后使所有内核代码包括关键部分都是完全可抢占的。

总结

进程调度是内核的一个不断发展的方面,随着 Linux 的发展和进一步多样化到许多计算领域,对进程调度器的微调和更改将是必要的。然而,通过本章建立的理解,深入了解或理解任何新的变化将会很容易。我们现在已经具备了进一步探索作业控制和信号管理的另一个重要方面的能力。我们将简要介绍信号的基础知识,然后进入内核的信号管理数据结构和例程。

第三章:信号管理

信号提供了一个基本的基础设施,任何进程都可以异步地被通知系统事件。它们也可以作为进程之间的通信机制。了解内核如何提供和管理整个信号处理机制的平稳吞吐量,让我们对内核有更深入的了解。在本章中,我们将从进程如何引导信号到内核如何巧妙地管理例程以确保信号事件的发生,深入研究以下主题:

  • 信号概述及其类型

  • 进程级别的信号管理调用

  • 进程描述符中的信号数据结构

  • 内核的信号生成和传递机制

信号

信号是传递给进程或进程组的短消息。内核使用信号通知进程系统事件的发生;信号也用于进程之间的通信。Linux 将信号分为两组,即通用 POSIX(经典 Unix 信号)和实时信号。每个组包含 32 个不同的信号,由唯一的 ID 标识:

#define _NSIG 64
#define _NSIG_BPW __BITS_PER_LONG
#define _NSIG_WORDS (_NSIG / _NSIG_BPW)

#define SIGHUP 1
#define SIGINT 2
#define SIGQUIT 3
#define SIGILL 4
#define SIGTRAP 5
#define SIGABRT 6
#define SIGIOT 6
#define SIGBUS 7
#define SIGFPE 8
#define SIGKILL 9
#define SIGUSR1 10
#define SIGSEGV 11
#define SIGUSR2 12
#define SIGPIPE 13
#define SIGALRM 14
#define SIGTERM 15
#define SIGSTKFLT 16
#define SIGCHLD 17
#define SIGCONT 18
#define SIGSTOP 19
#define SIGTSTP 20
#define SIGTTIN 21
#define SIGTTOU 22
#define SIGURG 23
#define SIGXCPU 24
#define SIGXFSZ 25
#define SIGVTALRM 26
#define SIGPROF 27
#define SIGWINCH 28
#define SIGIO 29
#define SIGPOLL SIGIO
/*
#define SIGLOST 29
*/
#define SIGPWR 30
#define SIGSYS 31
#define SIGUNUSED 31

/* These should not be considered constants from userland. */
#define SIGRTMIN 32
#ifndef SIGRTMAX
#define SIGRTMAX _NSIG
#endif

通用 POSIX 类别中的信号与特定系统事件绑定,并通过宏适当命名。实时类别中的信号不与特定事件绑定,可以自由用于进程通信;内核用通用名称引用它们:SIGRTMINSIGRTMAX

在生成信号时,内核将信号事件传递给目标进程,目标进程可以根据配置的操作(称为信号处理方式)对信号做出响应。

以下是进程可以设置为其信号处理方式的操作列表。进程可以在某个时间点设置任何一个操作为其信号处理方式,但可以在没有任何限制的情况下在这些操作之间任意切换任意次数。

  • 内核处理程序: 内核为每个信号实现了默认处理程序。这些处理程序通过任务结构的信号处理程序表对进程可用。收到信号后,进程可以请求执行适当的信号处理程序。这是默认的处理方式。

  • 进程定义的处理程序: 进程允许实现自己的信号处理程序,并设置它们以响应信号事件的执行。这是通过适当的系统调用接口实现的,允许进程将其处理程序例程与信号绑定。在发生信号时,进程处理程序将被异步调用。

  • 忽略: 进程也可以忽略信号的发生,但需要通过调用适当的系统调用宣布其忽略意图。

内核定义的默认处理程序例程可以执行以下任何操作:

  • Ignore: 什么都不会发生。

  • 终止: 终止进程,即组中的所有线程(类似于 exit_group)。组长(仅)向其父进程报告 WIFSIGNALED 状态。

  • Coredump: 写入描述使用相同 mm 的所有线程的核心转储文件,然后终止所有这些线程

  • 停止: 停止组中的所有线程,即 TASK_STOPPED 状态。

以下是总结表,列出了默认处理程序执行的操作:

 +--------------------+------------------+
 * | POSIX signal     | default action |
 * +------------------+------------------+
 * | SIGHUP           | terminate 
 * | SIGINT           | terminate 
 * | SIGQUIT          | coredump 
 * | SIGILL           | coredump 
 * | SIGTRAP          | coredump 
 * | SIGABRT/SIGIOT   | coredump 
 * | SIGBUS           | coredump 
 * | SIGFPE           | coredump 
 * | SIGKILL          | terminate
 * | SIGUSR1          | terminate 
 * | SIGSEGV          | coredump 
 * | SIGUSR2          | terminate
 * | SIGPIPE          | terminate 
 * | SIGALRM          | terminate 
 * | SIGTERM          | terminate 
 * | SIGCHLD          | ignore 
 * | SIGCONT          | ignore 
 * | SIGSTOP          | stop
 * | SIGTSTP          | stop
 * | SIGTTIN          | stop
 * | SIGTTOU          | stop
 * | SIGURG           | ignore 
 * | SIGXCPU          | coredump 
 * | SIGXFSZ          | coredump 
 * | SIGVTALRM        | terminate 
 * | SIGPROF          | terminate 
 * | SIGPOLL/SIGIO    | terminate 
 * | SIGSYS/SIGUNUSED | coredump 
 * | SIGSTKFLT        | terminate 
 * | SIGWINCH         | ignore 
 * | SIGPWR           | terminate 
 * | SIGRTMIN-SIGRTMAX| terminate 
 * +------------------+------------------+
 * | non-POSIX signal | default action |
 * +------------------+------------------+
 * | SIGEMT           | coredump |
 * +--------------------+------------------+

信号管理 API

应用程序提供了各种 API 用于管理信号;我们将看一下其中一些重要的 API:

  1. Sigaction(): 用户模式进程使用 POSIX API sigaction() 来检查或更改信号的处理方式。该 API 提供了各种属性标志,可以进一步定义信号的行为:
 #include <signal.h>
 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

 The sigaction structure is defined as something like:

 struct sigaction {
 void (*sa_handler)(int);
 void (*sa_sigaction)(int, siginfo_t *, void *);
 sigset_t sa_mask;
 int sa_flags;
 void (*sa_restorer)(void);
 };
  • int signum 是已识别的 signal 的标识号。sigaction() 检查并设置与该信号关联的操作。

  • const struct sigaction *act可以被赋予一个struct sigaction实例的地址。在此结构中指定的操作成为与信号绑定的新操作。当act指针未初始化(NULL)时,当前的处理方式不会改变。

  • struct sigaction *oldact是一个 outparam,需要用未初始化的sigaction实例的地址进行初始化;sigaction()通过此参数返回当前与信号关联的操作。

  • 以下是各种flag选项:

  • SA_NOCLDSTOP:此标志仅在绑定SIGCHLD的处理程序时相关。它用于禁用对子进程停止(SIGSTP)和恢复(SIGCONT)事件的SIGCHLD通知。

  • SA_NOCLDWAIT:此标志仅在绑定SIGCHLD的处理程序或将其设置为SIG_DFL时相关。设置此标志会导致子进程在终止时立即被销毁,而不是处于僵尸状态。

  • SA_NODEFER:设置此标志会导致生成的信号即使相应的处理程序正在执行也会被传递。

  • SA_ONSTACK:此标志仅在绑定信号处理程序时相关。设置此标志会导致信号处理程序使用备用堆栈;备用堆栈必须由调用进程通过sigaltstack()API 设置。如果没有备用堆栈,处理程序将在当前堆栈上被调用。

  • SA_RESETHAND:当与sigaction()一起应用此标志时,它使信号处理程序成为一次性的,也就是说,指定信号的操作对于该信号的后续发生被重置为SIG_DFL

  • SA_RESTART:此标志使系统调用操作被当前信号处理程序中断后重新进入。

  • SA_SIGINFO:此标志用于向系统指示信号处理程序已分配--sigaction结构的sa_sigaction指针而不是sa_handler。分配给sa_sigaction的处理程序接收两个额外的参数:

      void handler_fn(int signo, siginfo_t *info, void *context);

第一个参数是signum,处理程序绑定的信号。第二个参数是一个 outparam,是指向siginfo_t类型对象的指针,提供有关信号来源的附加信息。以下是siginfo_t的完整定义:

 siginfo_t {
 int si_signo; /* Signal number */
 int si_errno; /* An errno value */
 int si_code; /* Signal code */
 int si_trapno; /* Trap number that caused hardware-generated signal (unused on most           architectures) */
 pid_t si_pid; /* Sending process ID */
 uid_t si_uid; /* Real user ID of sending process */
 int si_status; /* Exit value or signal */
 clock_t si_utime; /* User time consumed */
 clock_t si_stime; /* System time consumed */
 sigval_t si_value; /* Signal value */
 int si_int; /* POSIX.1b signal */
 void *si_ptr; /* POSIX.1b signal */
 int si_overrun; /* Timer overrun count; POSIX.1b timers */
 int si_timerid; /* Timer ID; POSIX.1b timers */
 void *si_addr; /* Memory location which caused fault */
 long si_band; /* Band event (was int in glibc 2.3.2 and earlier) */
 int si_fd; /* File descriptor */
 short si_addr_lsb; /* Least significant bit of address (since Linux 2.6.32) */
 void *si_call_addr; /* Address of system call instruction (since Linux 3.5) */
 int si_syscall; /* Number of attempted system call (since Linux 3.5) */
 unsigned int si_arch; /* Architecture of attempted system call (since Linux 3.5) */
 }
  1. Sigprocmask():除了改变信号处理程序外,该处理程序还允许阻止或解除阻止信号传递。应用程序可能需要在执行关键代码块时进行这些操作,以防止被异步信号处理程序抢占。例如,网络通信应用程序可能不希望在进入启动与其对等体连接的代码块时处理信号:
  • sigprocmask()是一个 POSIX API,用于检查、阻塞和解除阻塞信号。
    int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); 

任何被阻止的信号发生都会排队在每个进程的挂起信号列表中。挂起队列设计用于保存一个被阻止的通用信号的发生,同时排队每个实时信号的发生。用户模式进程可以使用sigpending()rt_sigpending()API 来查询挂起信号。这些例程将挂起信号的列表返回到由sigset_t指针指向的实例中。

    int sigpending(sigset_t *set);

这些操作适用于除了SIGKILLSIGSTOP之外的所有信号;换句话说,进程不允许改变默认的处理方式或阻止SIGSTOPSIGKILL信号。

从程序中引发信号

kill()sigqueue()是 POSIX API,通过它们,一个进程可以为另一个进程或进程组引发信号。这些 API 促进了信号作为进程通信机制的利用:

 int kill(pid_t pid, int sig);
 int sigqueue(pid_t pid, int sig, const union sigval value);

 union sigval {
 int sival_int;
 void *sival_ptr;
 };

虽然这两个 API 都提供了参数来指定接收者的PID和要提升的signumsigqueue()通过一个额外的参数(联合信号)提供了数据可以与信号一起发送到接收进程。目标进程可以通过struct siginfo_tsi_value)实例访问数据。Linux 通过本机 API 扩展了这些函数,可以将信号排队到线程组,甚至到线程组中的轻量级进程(LWP):

/* queue signal to specific thread in a thread group */
int tgkill(int tgid, int tid, int sig);

/* queue signal and data to a thread group */
int rt_sigqueueinfo(pid_t tgid, int sig, siginfo_t *uinfo);

/* queue signal and data to specific thread in a thread group */
int rt_tgsigqueueinfo(pid_t tgid, pid_t tid, int sig, siginfo_t *uinfo);

等待排队信号

在应用信号进行进程通信时,对于进程来说,暂停自身直到发生特定信号,然后在来自另一个进程的信号到达时恢复执行可能更合适。POSIX 调用sigsuspend()sigwaitinfo()sigtimedwait()提供了这种功能:

int sigsuspend(const sigset_t *mask);
int sigwaitinfo(const sigset_t *set, siginfo_t *info);
int sigtimedwait(const sigset_t *set, siginfo_t *info, const struct timespec *timeout);

虽然所有这些 API 允许进程等待指定的信号发生,sigwaitinfo()通过info指针返回的siginfo_t实例提供有关信号的附加数据。sigtimedwait()通过提供一个额外的参数扩展了功能,允许操作超时,使其成为一个有界等待调用。Linux 内核提供了一个替代 API,允许进程通过名为signalfd()的特殊文件描述符被通知信号的发生:

 #include <sys/signalfd.h>
 int signalfd(int fd, const sigset_t *mask, int flags);

成功时,signalfd()返回一个文件描述符,进程需要调用read()来阻塞,直到掩码中指定的任何信号发生。

信号数据结构

内核维护每个进程的信号数据结构,以跟踪信号处理阻塞信号待处理信号队列。进程任务结构包含对这些数据结构的适当引用:

struct task_struct {

....
....
....
/* signal handlers */
 struct signal_struct *signal;
 struct sighand_struct *sighand;

 sigset_t blocked, real_blocked;
 sigset_t saved_sigmask; /* restored if set_restore_sigmask() was used */
 struct sigpending pending;

 unsigned long sas_ss_sp;
 size_t sas_ss_size;
 unsigned sas_ss_flags;
  ....
  ....
  ....
  ....

};

信号描述符

回顾一下我们在第一章的早期讨论中提到的,Linux 通过轻量级进程支持多线程应用程序。线程应用程序的所有 LWP 都是进程组的一部分,并共享信号处理程序;每个 LWP(线程)维护自己的待处理和阻塞信号队列。

任务结构的signal指针指向signal_struct类型的实例,这是信号描述符。这个结构被线程组的所有 LWP 共享,并维护诸如共享待处理信号队列(对于排队到线程组的信号)之类的元素,这对进程组中的所有线程都是共同的。

以下图表示维护共享待处理信号所涉及的数据结构:

以下是signal_struct的一些重要字段:

struct signal_struct {
 atomic_t sigcnt;
 atomic_t live;
 int nr_threads;
 struct list_head thread_head;

 wait_queue_head_t wait_chldexit; /* for wait4() */

 /* current thread group signal load-balancing target: */
 struct task_struct *curr_target;

 /* shared signal handling: */
 struct sigpending shared_pending; 
 /* thread group exit support */
 int group_exit_code;
 /* overloaded:
 * - notify group_exit_task when ->count is equal to notify_count
 * - everyone except group_exit_task is stopped during signal delivery
 * of fatal signals, group_exit_task processes the signal.
 */
 int notify_count;
 struct task_struct *group_exit_task;

 /* thread group stop support, overloads group_exit_code too */
 int group_stop_count;
 unsigned int flags; /* see SIGNAL_* flags below */

阻塞和待处理队列

任务结构中的blockedreal_blocked实例是被阻塞信号的位掩码;这些队列是每个进程的。线程组中的每个 LWP 都有自己的阻塞信号掩码。任务结构的pending实例用于排队私有待处理信号;所有排队到普通进程和线程组中特定 LWP 的信号都排队到这个列表中:

struct sigpending {
 struct list_head list; // head to double linked list of struct sigqueue
 sigset_t signal; // bit mask of pending signals
};

以下图表示维护私有待处理信号所涉及的数据结构:

信号处理程序描述符

任务结构的sighand指针指向struct sighand_struct的一个实例,这是线程组中所有进程共享的信号处理程序描述符。这个结构也被所有使用clone()CLONE_SIGHAND标志创建的进程共享。这个结构包含一个k_sigaction实例的数组,每个实例包装一个sigaction的实例,描述了每个信号的当前处理方式:

struct k_sigaction {
 struct sigaction sa;
#ifdef __ARCH_HAS_KA_RESTORER 
 __sigrestore_t ka_restorer;
#endif
};

struct sighand_struct {
 atomic_t count;
 struct k_sigaction action[_NSIG];
 spinlock_t siglock;
 wait_queue_head_t signalfd_wqh;
};

以下图表示信号处理程序描述符:

信号生成和传递

当发生信号时,将其加入到接收进程或进程的任务结构中的挂起信号列表中。信号是在用户模式进程、内核或任何内核服务的请求下生成的(对于进程或组)。当接收进程或进程意识到其发生并被强制执行适当的响应处理程序时,信号被认为是已传递;换句话说,信号传递等同于相应处理程序的初始化。理想情况下,每个生成的信号都被假定立即传递;然而,存在信号生成和最终传递之间的延迟可能性。为了便于可能的延迟传递,内核为信号生成和传递提供了单独的函数。

信号生成调用

内核为信号生成提供了两组不同的函数:一组用于在单个进程上生成信号,另一组用于进程线程组。

  • 以下是生成进程信号的重要函数列表:

send_sig(): 在进程上生成指定信号;这个函数被内核服务广泛使用

  • end_sig_info(): 用额外的siginfo_t实例扩展send_sig()

  • force_sig(): 用于生成无法被忽略或阻止的优先级非可屏蔽信号

  • force_sig_info(): 用额外的siginfo_t实例扩展force_sig()

所有这些例程最终调用核心内核函数send_signal(),该函数被设计用于生成指定的信号。

以下是生成进程组信号的重要函数列表:

  • kill_pgrp(): 在进程组中的所有线程组上生成指定信号

  • kill_pid(): 向由 PID 标识的线程组生成指定信号

  • kill_pid_info(): 用额外的siginfo_t实例扩展kill_pid()

所有这些例程调用一个名为group_send_sig_info()的函数,最终使用适当的参数调用send_signal()

send_signal()函数是核心信号生成函数;它使用适当的参数调用__send_signal()例程:

 static int send_signal(int sig, struct siginfo *info, struct task_struct *t,
 int group)
{
 int from_ancestor_ns = 0;

#ifdef CONFIG_PID_NS
 from_ancestor_ns = si_fromuser(info) &&
 !task_pid_nr_ns(current, task_active_pid_ns(t));
#endif

 return __send_signal(sig, info, t, group, from_ancestor_ns);
}

以下是__send_signal()执行的重要步骤:

  1. info参数中检查信号的来源。如果信号生成是由内核发起的,对于不可屏蔽的SIGKILLSIGSTOP,它立即设置适当的 sigpending 位,设置TIF_SIGPENDING标志,并通过唤醒目标线程启动传递过程:
 /*
 * fast-pathed signals for kernel-internal things like SIGSTOP
 * or SIGKILL.
 */
 if (info == SEND_SIG_FORCED)
 goto out_set;
....
....
....
out_set:
 signalfd_notify(t, sig);
 sigaddset(&pending->signal, sig);
 complete_signal(sig, t, group);

  1. 调用__sigqeueue_alloc()函数,检查接收进程的挂起信号数量是否小于资源限制。如果是,则增加挂起信号计数器并返回struct sigqueue实例的地址:
 q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
 override_rlimit);
  1. sigqueue实例加入到挂起列表中,并将信号信息填入siginfo_t
if (q) {
 list_add_tail(&q->list, &pending->list);
 switch ((unsigned long) info) {
 case (unsigned long) SEND_SIG_NOINFO:
       q->info.si_signo = sig;
       q->info.si_errno = 0;
       q->info.si_code = SI_USER;
       q->info.si_pid = task_tgid_nr_ns(current,
       task_active_pid_ns(t));
       q->info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
       break;
 case (unsigned long) SEND_SIG_PRIV:
       q->info.si_signo = sig;
       q->info.si_errno = 0;
       q->info.si_code = SI_KERNEL;
       q->info.si_pid = 0;
       q->info.si_uid = 0;
       break;
 default:
      copy_siginfo(&q->info, info);
      if (from_ancestor_ns)
      q->info.si_pid = 0;
      break;
 }

  1. 在挂起信号的位掩码中设置适当的信号位,并通过调用complete_signal()尝试信号传递,进而设置TIF_SIGPENDING标志:
 sigaddset(&pending->signal, sig);
 complete_signal(sig, t, group);

信号传递

信号通过更新接收器任务结构中的适当条目生成后,内核进入传递模式。如果接收进程在 CPU 上并且未阻止指定的信号,则立即传递信号。即使接收方不在 CPU 上,也会传递优先级信号SIGSTOPSIGKILL,通过唤醒进程;然而,对于其余的信号,传递将推迟直到进程准备好接收信号。为了便于推迟传递,内核在从中断和系统调用返回时检查进程的非阻塞挂起信号,然后允许进程恢复用户模式执行。当进程调度程序(在从中断和异常返回时调用)发现TIF_SIGPENDING标志设置时,它调用内核函数do_signal()来启动挂起信号的传递,然后恢复进程的用户模式上下文。

进入内核模式时,进程的用户模式寄存器状态存储在称为pt_regs的进程内核堆栈中(特定于体系结构):

 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;
 unsigned long r9;
 unsigned long r8;
 unsigned long rax;
 unsigned long rcx;
 unsigned long rdx;
 unsigned long rsi;
 unsigned long rdi;
/*
 * 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 rip;
 unsigned long cs;
 unsigned long eflags;
 unsigned long rsp;
 unsigned long ss;
/* top of stack page */
};

do_signal()例程在内核堆栈中使用pt_regs的地址调用。虽然do_signal()旨在传递非阻塞的挂起信号,但其实现是特定于体系结构的。

以下是do_signal()的 x86 版本:

void do_signal(struct pt_regs *regs)
{
 struct ksignal ksig;
 if (get_signal(&ksig)) {
 /* Whee! Actually deliver the signal. */
 handle_signal(&ksig, regs);
 return;
 }
 /* Did we come from a system call? */
 if (syscall_get_nr(current, regs) >= 0) {
 /* Restart the system call - no handlers present */
 switch (syscall_get_error(current, regs)) {
 case -ERESTARTNOHAND:
 case -ERESTARTSYS:
 case -ERESTARTNOINTR:
 regs->ax = regs->orig_ax;
 regs->ip -= 2;
 break;
 case -ERESTART_RESTARTBLOCK:
 regs->ax = get_nr_restart_syscall(regs);
 regs->ip -= 2;
 break;
 }
 }
 /*
 * If there's no signal to deliver, we just put the saved sigmask
 * back.
 */
 restore_saved_sigmask();
}

do_signal()使用struct ksignal类型实例的地址调用get_signal()函数(我们将简要考虑此例程的重要步骤,跳过其他细节)。此函数包含一个循环,它调用dequeue_signal()直到从私有和共享挂起列表中取出所有非阻塞的挂起信号。它从最低编号的信号开始查找私有挂起信号队列,然后进入共享队列中的挂起信号,然后更新数据结构以指示该信号不再挂起并返回其编号:

 signr = dequeue_signal(current, &current->blocked, &ksig->info);

对于dequeue_signal()返回的每个挂起信号,get_signal()通过struct ksigaction *ka类型的指针检索当前的信号处理方式:

ka = &sighand->action[signr-1]; 

如果信号处理方式设置为SIG_IGN,则静默忽略当前信号并继续迭代以检索另一个挂起信号:

if (ka->sa.sa_handler == SIG_IGN) /* Do nothing. */
 continue;

如果处理方式不等于SIG_DFL,则检索sigaction的地址并将其初始化为参数ksig->ka,以便进一步执行用户模式处理程序。它进一步检查用户的sigaction中的SA_ONESHOT (SA_RESETHAND)标志,如果设置,则将信号处理方式重置为SIG_DFL,跳出循环并返回给调用者。do_signal()现在调用handle_signal()例程来执行用户模式处理程序(我们将在下一节详细讨论这个)。

  if (ka->sa.sa_handler != SIG_DFL) {
 /* Run the handler. */
 ksig->ka = *ka;

 if (ka->sa.sa_flags & SA_ONESHOT)
 ka->sa.sa_handler = SIG_DFL;

 break; /* will return non-zero "signr" value */
 }

如果处理方式设置为SIG_DFL,则调用一组宏来检查内核处理程序的默认操作。可能的默认操作是:

  • Term:默认操作是终止进程

  • Ign:默认操作是忽略信号

  • Core:默认操作是终止进程并转储核心

  • Stop:默认操作是停止进程

  • Cont:默认操作是如果当前停止则继续进程

get_signal() that initiates the default action as per the set disposition:
/*
 * Now we are doing the default action for this signal.
 */
 if (sig_kernel_ignore(signr)) /* Default is nothing. */
 continue;

 /*
 * Global init gets no signals it doesn't want.
 * Container-init gets no signals it doesn't want from same
 * container.
 *
 * Note that if global/container-init sees a sig_kernel_only()
 * signal here, the signal must have been generated internally
 * or must have come from an ancestor namespace. In either
 * case, the signal cannot be dropped.
 */
 if (unlikely(signal->flags & SIGNAL_UNKILLABLE) &&
 !sig_kernel_only(signr))
 continue;

 if (sig_kernel_stop(signr)) {
 /*
 * The default action is to stop all threads in
 * the thread group. The job control signals
 * do nothing in an orphaned pgrp, but SIGSTOP
 * always works. Note that siglock needs to be
 * dropped during the call to is_orphaned_pgrp()
 * because of lock ordering with tasklist_lock.
 * This allows an intervening SIGCONT to be posted.
 * We need to check for that and bail out if necessary.
 */
 if (signr != SIGSTOP) {
 spin_unlock_irq(&sighand->siglock);

 /* signals can be posted during this window */

 if (is_current_pgrp_orphaned())
 goto relock;

 spin_lock_irq(&sighand->siglock);
 }

 if (likely(do_signal_stop(ksig->info.si_signo))) {
 /* It released the siglock. */
 goto relock;
 }

 /*
 * We didn't actually stop, due to a race
 * with SIGCONT or something like that.
 */
 continue;
 }

 spin_unlock_irq(&sighand->siglock);

 /*
 * Anything else is fatal, maybe with a core dump.
 */
 current->flags |= PF_SIGNALED;

 if (sig_kernel_coredump(signr)) {
 if (print_fatal_signals)
 print_fatal_signal(ksig->info.si_signo);
 proc_coredump_connector(current);
 /*
 * If it was able to dump core, this kills all
 * other threads in the group and synchronizes with
 * their demise. If we lost the race with another
 * thread getting here, it set group_exit_code
 * first and our do_group_exit call below will use
 * that value and ignore the one we pass it.
 */
 do_coredump(&ksig->info);
 }

 /*
 * Death signals, no core dump.
 */
 do_group_exit(ksig->info.si_signo);
 /* NOTREACHED */
 }

首先,宏sig_kernel_ignore检查默认操作是否为忽略。如果为真,则继续循环迭代以查找下一个挂起信号。第二个宏sig_kernel_stop检查默认操作是否为停止;如果为真,则调用do_signal_stop()例程,将进程组中的每个线程置于TASK_STOPPED状态。第三个宏sig_kernel_coredump检查默认操作是否为转储;如果为真,则调用do_coredump()例程,生成转储二进制文件并终止线程组中的所有进程。接下来,对于默认操作为终止的信号,通过调用do_group_exit()例程杀死组中的所有线程。

执行用户模式处理程序

回顾我们在上一节中的讨论,do_signal() 调用 handle_signal() 例程以传递处于用户处理程序状态的挂起信号。用户模式信号处理程序驻留在进程代码段中,并需要访问进程的用户模式堆栈;因此,内核需要切换到用户模式堆栈以执行信号处理程序。成功从信号处理程序返回需要切换回内核堆栈以恢复用户上下文以进行正常的用户模式执行,但这样的操作将失败,因为内核堆栈不再包含用户上下文(struct pt_regs),因为在每次进程从用户模式进入内核模式时都会清空它。

为了确保进程在用户模式下正常执行时的平稳过渡(从信号处理程序返回),handle_signal() 将内核堆栈中的用户模式硬件上下文(struct pt_regs)移动到用户模式堆栈(struct ucontext)中,并设置处理程序帧以在返回时调用 _kernel_rt_sigreturn() 例程;此函数将硬件上下文复制回内核堆栈,并恢复当前进程的用户模式上下文以恢复正常执行。

以下图示了用户模式信号处理程序的执行:

设置用户模式处理程序帧

为了为用户模式处理程序设置堆栈帧,handle_signal() 使用 ksignal 实例的地址调用 setup_rt_frame(),其中包含与信号相关的 k_sigaction 和当前进程内核堆栈中 struct pt_regs 的指针。

以下是 setup_rt_frame() 的 x86 实现:

setup_rt_frame(struct ksignal *ksig, struct pt_regs *regs)
{
 int usig = ksig->sig;
 sigset_t *set = sigmask_to_save();
 compat_sigset_t *cset = (compat_sigset_t *) set;

 /* Set up the stack frame */
 if (is_ia32_frame(ksig)) {
 if (ksig->ka.sa.sa_flags & SA_SIGINFO)
 return ia32_setup_rt_frame(usig, ksig, cset, regs); // for 32bit systems with SA_SIGINFO
 else
 return ia32_setup_frame(usig, ksig, cset, regs); // for 32bit systems without SA_SIGINFO
 } else if (is_x32_frame(ksig)) {
 return x32_setup_rt_frame(ksig, cset, regs);// for systems with x32 ABI
 } else {
 return __setup_rt_frame(ksig->sig, ksig, set, regs);// Other variants of x86
 }
}

它检查 x86 的特定变体,并调用适当的帧设置例程。在进一步讨论中,我们将专注于适用于 x86-64 的 __setup_rt_frame()。此函数使用一个名为 struct rt_sigframe 的结构的实例填充了处理信号所需的信息,设置了一个返回路径(通过 _kernel_rt_sigreturn() 函数),并将其推送到用户模式堆栈中。

/*arch/x86/include/asm/sigframe.h */
#ifdef CONFIG_X86_64

struct rt_sigframe {
 char __user *pretcode;
 struct ucontext uc;
 struct siginfo info;
 /* fp state follows here */
};

-----------------------  

/*arch/x86/kernel/signal.c */
static int __setup_rt_frame(int sig, struct ksignal *ksig,
 sigset_t *set, struct pt_regs *regs)
{
 struct rt_sigframe __user *frame;
 void __user *restorer;
 int err = 0;
 void __user *fpstate = NULL;

 /* setup frame with Floating Point state */
 frame = get_sigframe(&ksig->ka, regs, sizeof(*frame), &fpstate);

 if (!access_ok(VERIFY_WRITE, frame, sizeof(*frame)))
 return -EFAULT;

 put_user_try {
 put_user_ex(sig, &frame->sig);
 put_user_ex(&frame->info, &frame->pinfo);
 put_user_ex(&frame->uc, &frame->puc);

 /* Create the ucontext. */
 if (boot_cpu_has(X86_FEATURE_XSAVE))
 put_user_ex(UC_FP_XSTATE, &frame->uc.uc_flags);
 else 
 put_user_ex(0, &frame->uc.uc_flags);
 put_user_ex(0, &frame->uc.uc_link);
 save_altstack_ex(&frame->uc.uc_stack, regs->sp);

 /* Set up to return from userspace. */
 restorer = current->mm->context.vdso +
 vdso_image_32.sym___kernel_rt_sigreturn;
 if (ksig->ka.sa.sa_flags & SA_RESTORER)
 restorer = ksig->ka.sa.sa_restorer;
 put_user_ex(restorer, &frame->pretcode);

 /*
 * This is movl $__NR_rt_sigreturn, %ax ; int $0x80
 *
 * WE DO NOT USE IT ANY MORE! It's only left here for historical
 * reasons and because gdb uses it as a signature to notice
 * signal handler stack frames.
 */
 put_user_ex(*((u64 *)&rt_retcode), (u64 *)frame->retcode);
 } put_user_catch(err);

 err |= copy_siginfo_to_user(&frame->info, &ksig->info);
 err |= setup_sigcontext(&frame->uc.uc_mcontext, fpstate,
 regs, set->sig[0]);
 err |= __copy_to_user(&frame->uc.uc_sigmask, set, sizeof(*set));

 if (err)
 return -EFAULT;

 /* Set up registers for signal handler */
 regs->sp = (unsigned long)frame;
 regs->ip = (unsigned long)ksig->ka.sa.sa_handler;
 regs->ax = (unsigned long)sig;
 regs->dx = (unsigned long)&frame->info;
 regs->cx = (unsigned long)&frame->uc;

 regs->ds = __USER_DS;
 regs->es = __USER_DS;
 regs->ss = __USER_DS;
 regs->cs = __USER_CS;

 return 0;
}

rt_sigframe 结构的 *pretcode 字段被分配为信号处理程序函数的返回地址,该函数是 _kernel_rt_sigreturn() 例程。 struct ucontext ucsigcontext 初始化,其中包含从内核堆栈的 pt_regs 复制的用户模式上下文,常规阻塞信号的位数组和浮点状态。在设置并将 frame 实例推送到用户模式堆栈后,__setup_rt_frame() 改变了进程的内核堆栈中的 pt_regs,以便在当前进程恢复执行时将控制权交给信号处理程序。指令指针(ip)设置为信号处理程序的基地址,堆栈指针(sp)设置为先前推送的帧的顶部地址;这些更改导致信号处理程序执行。

重新启动中断的系统调用

我们在第一章中了解到,用户模式进程调用 系统调用 以切换到内核模式执行内核服务。当进程进入内核服务例程时,有可能例程被阻塞以等待资源的可用性(例如,等待排他锁)或事件的发生(例如中断)。这些阻塞操作要求调用进程处于 TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLETASK_KILLABLE 状态。所采取的具体状态取决于在系统调用中调用的阻塞调用的选择。

如果调用者任务被置于TASK_UNINTERRUPTIBLE状态,那么在该任务上发生的信号会导致它们进入挂起列表,并且仅在服务例程完成后(返回到用户模式时)才会传递给进程。然而,如果任务被置于TASK_INTERRUPTIBLE状态,那么在该任务上发生的信号会导致其状态被改变为TASK_RUNNING,从而导致任务在阻塞的系统调用上被唤醒,甚至在系统调用完成之前就被唤醒(导致系统调用操作失败)。这种中断通过返回适当的失败代码来指示。在TASK_KILLABLE状态下,信号对任务的影响与TASK_INTERRUPTIBLE类似,只是在发生致命的SIGKILL信号时才会唤醒。

EINTRERESTARTNOHANDERESTART_RESTARTBLOCKERESTARTSYSERESTARTNOINTR是各种内核定义的失败代码;系统调用被编程为在失败时返回适当的错误标志。错误代码的选择决定了在处理中断信号后是否重新启动失败的系统调用操作:

(include/uapi/asm-generic/errno-base.h)
 #define EPERM 1 /* Operation not permitted */
 #define ENOENT 2 /* No such file or directory */
 #define ESRCH 3 /* No such process */
 #define EINTR 4 /* Interrupted system call */
 #define EIO 5 /* I/O error */
 #define ENXIO 6 /* No such device or address */
 #define E2BIG 7 /* Argument list too long */
 #define ENOEXEC 8 /* Exec format error */
 #define EBADF 9 /* Bad file number */
 #define ECHILD 10 /* No child processes */
 #define EAGAIN 11 /* Try again */
 #define ENOMEM 12 /* Out of memory */
 #define EACCES 13 /* Permission denied */
 #define EFAULT 14 /* Bad address */
 #define ENOTBLK 15 /* Block device required */
 #define EBUSY 16 /* Device or resource busy */
 #define EEXIST 17 /* File exists */
 #define EXDEV 18 /* Cross-device link */
 #define ENODEV 19 /* No such device */
 #define ENOTDIR 20 /* Not a directory */
 #define EISDIR 21 /* Is a directory */
 #define EINVAL 22 /* Invalid argument */
 #define ENFILE 23 /* File table overflow */
 #define EMFILE 24 /* Too many open files */
 #define ENOTTY 25 /* Not a typewriter */
 #define ETXTBSY 26 /* Text file busy */
 #define EFBIG 27 /* File too large */
 #define ENOSPC 28 /* No space left on device */
 #define ESPIPE 29 /* Illegal seek */
 #define EROFS 30 /* Read-only file system */
 #define EMLINK 31 /* Too many links */
 #define EPIPE 32 /* Broken pipe */
 #define EDOM 33 /* Math argument out of domain of func */
 #define ERANGE 34 /* Math result not representable */
 linux/errno.h)
 #define ERESTARTSYS 512
 #define ERESTARTNOINTR 513
 #define ERESTARTNOHAND 514 /* restart if no handler.. */
 #define ENOIOCTLCMD 515 /* No ioctl command */
 #define ERESTART_RESTARTBLOCK 516 /* restart by calling sys_restart_syscall */
 #define EPROBE_DEFER 517 /* Driver requests probe retry */
 #define EOPENSTALE 518 /* open found a stale dentry */

从中断的系统调用返回时,用户模式 API 始终返回EINTR错误代码,而不管底层内核服务例程返回的具体错误代码是什么。其余的错误代码由内核的信号传递例程使用,以确定从信号处理程序返回时是否可以重新启动中断的系统调用。以下表格显示了系统调用执行被中断时的错误代码以及对各种信号处理的影响:

这是它们的含义:

  • 不重新启动:系统调用不会被重新启动。进程将从跟随系统调用的指令(int $0x80 或 sysenter)中的用户模式恢复执行。

  • 自动重启:内核强制用户进程通过将相应的系统调用标识符加载到eax中并执行系统调用指令(int $0x80 或 sysenter)来重新启动系统调用操作。

  • 显式重启:只有在进程设置中断信号的处理程序(通过 sigaction)时启用了SA_RESTART标志,系统调用才会被重新启动。

摘要

信号,虽然是进程和内核服务之间进行的一种基本形式的通信,但它们提供了一种简单有效的方式,以便在发生各种事件时从运行中的进程获得异步响应。通过理解信号使用的所有核心方面,它们的表示、数据结构和内核例程用于信号生成和传递,我们现在对内核更加了解,也更有准备在本书的后面部分更深入地研究进程之间更复杂的通信方式。在前三章中讨论了进程及其相关方面之后,我们现在将深入研究内核的其他子系统,以提高我们的可见性。在下一章中,我们将建立对内核的核心方面之一——内存子系统的理解。

在接下来的一章中,我们将逐步理解许多关键的内存管理方面,如内存初始化、分页和保护,以及内核内存分配算法等。

第四章:内存管理和分配器

内存管理的效率广泛地决定了整个内核的效率。随意管理的内存系统可能严重影响其他子系统的性能,使内存成为内核的关键组成部分。这个子系统通过虚拟化物理内存和管理它们发起的所有动态分配请求来启动所有进程和内核服务。内存子系统还处理维持操作效率和优化资源的广泛操作。这些操作既是特定于架构的,也是独立的,这要求整体设计和实现是公正和可调整的。在本章中,我们将密切关注以下方面,以便努力理解这个庞大的子系统:

  • 物理内存表示

  • 节点和区域的概念

  • 页分配器

  • 伙伴系统

  • Kmalloc 分配

  • Slab 高速缓存

  • Vmalloc 分配

  • 连续内存分配

初始化操作

在大多数架构中,在复位时,处理器以正常或物理地址模式(也称为 x86 中的实模式)初始化,并开始执行平台固件指令,这些指令位于复位向量处。这些固件指令(可以是单一二进制或多阶段二进制)被编程来执行各种操作,包括初始化内存控制器,校准物理 RAM,并将二进制内核映像加载到物理内存的特定区域,等等。

在实模式下,处理器不支持虚拟寻址,而 Linux 是为具有保护模式的系统设计和实现的,需要虚拟寻址来启用进程保护和隔离,这是内核提供的关键抽象(回顾第一章,理解进程、地址空间和线程)。这要求处理器在内核启动和子系统初始化之前切换到保护模式并打开虚拟地址支持。切换到保护模式需要初始化 MMU 芯片组,通过设置适当的核心数据结构,从而启用分页。这些操作是特定于架构的,并且在内核源代码树的arch分支中实现。在内核构建期间,这些源代码被编译并链接为保护模式内核映像的头文件;这个头文件被称为内核引导程序实模式内核

以下是 x86 架构引导程序的main()例程;这个函数在实模式下执行,并负责在调用go_to_protected_mode()之前分配适当的资源,然后进入保护模式:

/* arch/x86/boot/main.c */
void main(void)
{
 /* First, copy the boot header into the "zeropage" */
 copy_boot_params();

 /* Initialize the early-boot console */
 console_init();
 if (cmdline_find_option_bool("debug"))
 puts("early console in setup coden");

 /* End of heap check */
 init_heap();

 /* Make sure we have all the proper CPU support */
 if (validate_cpu()) {
 puts("Unable to boot - please use a kernel appropriate "
 "for your CPU.n");
 die();
 }

 /* Tell the BIOS what CPU mode we intend to run in. */
 set_bios_mode();

 /* Detect memory layout */
 detect_memory();

 /* Set keyboard repeat rate (why?) and query the lock flags */
 keyboard_init();

 /* Query Intel SpeedStep (IST) information */
 query_ist();

 /* Query APM information */
#if defined(CONFIG_APM) || defined(CONFIG_APM_MODULE)
 query_apm_bios();
#endif

 /* Query EDD information */
#if defined(CONFIG_EDD) || defined(CONFIG_EDD_MODULE)
 query_edd();
#endif

 /* Set the video mode */
 set_video();

 /* Do the last things and invoke protected mode */
 go_to_protected_mode();
}

实模式内核例程是为了设置 MMU 并处理转换到保护模式而调用的,这些例程是特定于架构的(我们不会在这里涉及这些例程)。不管所涉及的特定于架构的代码是什么,主要目标是通过打开分页来启用对虚拟寻址的支持。启用分页后,系统开始将物理内存(RAM)视为固定大小的块数组,称为页帧。页帧的大小通过适当地编程 MMU 的分页单元来配置;大多数 MMU 支持 4k、8k、16k、64k 直到 4MB 的选项来配置帧大小。然而,Linux 内核对大多数架构的默认构建配置选择 4k 作为其标准页帧大小。

页描述符

页面帧是内存的最小分配单元,内核需要利用它们来满足其所有的内存需求。一些页面帧将被用于将物理内存映射到用户模式进程的虚拟地址空间,一些用于内核代码和其数据结构,一些用于处理进程或内核服务提出的动态分配请求。为了有效地管理这些操作,内核需要区分当前使用的页面帧和空闲可用的页面帧。这个目的通过一个与架构无关的数据结构struct page来实现,该结构被定义为保存与页面帧相关的所有元数据,包括其当前状态。为每个找到的物理页面帧分配一个struct page的实例,并且内核必须始终在主内存中维护页面实例的列表。

页面结构是内核中使用最频繁的数据结构之一,并且在各种内核代码路径中被引用。该结构填充有各种元素,其相关性完全基于物理帧的状态。例如,页面结构的特定成员指定相应的物理页面是否映射到进程或一组进程的虚拟地址空间。当物理页面被保留用于动态分配时,这些字段被认为无效。为了确保内存中的页面实例只分配有关字段,联合体被广泛用于填充成员字段。这是一个明智的选择,因为它使得能够在不增加内存中的页面结构大小的情况下将更多的信息塞入页面结构中:

/*include/linux/mm-types.h */ 
/* The objects in struct page are organized in double word blocks in
 * order to allows us to use atomic double word operations on portions
 * of struct page. That is currently only used by slub but the arrangement
 * allows the use of atomic double word operations on the flags/mapping
 * and lru list pointers also.
 */
struct page {
        /* First double word block */
         unsigned long flags; /* Atomic flags, some possibly updated asynchronously */   union {
          struct address_space *mapping; 
          void *s_mem; /* slab first object */
          atomic_t compound_mapcount; /* first tail page */
          /* page_deferred_list().next -- second tail page */
   };
  ....
  ....

}

以下是页面结构的重要成员的简要描述。请注意,这里的许多细节假定您熟悉我们在本章的后续部分中讨论的内存子系统的其他方面,比如内存分配器、页表等等。我建议新读者跳过并在熟悉必要的先决条件后再回顾本节。

标志

这是一个unsigned long位字段,它保存描述物理页面状态的标志。标志常量是通过内核头文件include/linux/page-flags.h中的enum定义的。以下表列出了重要的标志常量:

标志 描述
PG_locked 用于指示页面是否被锁定;在对页面进行 I/O 操作时设置此位,并在完成时清除。
PG_error 用于指示错误页面。在页面发生 I/O 错误时设置。
PG_referenced 设置以指示页面缓存的页面回收。
PG_uptodate 设置以指示从磁盘读取操作后页面是否有效。
PG_dirty 当文件支持的页面被修改并且与磁盘镜像不同步时设置。
PG_lru 用于指示最近最少使用位被设置,有助于处理页面回收。
PG_active 用于指示页面是否在活动列表中。
PG_slab 用于指示页面由 slab 分配器管理。
PG_reserved 用于指示不可交换的保留页面。
PG_private 用于指示页面被文件系统用于保存其私有数据。
PG_writeback 在对文件支持的页面进行写回操作时设置
PG_head 用于指示复合页面的头页面。
PG_swapcache 用于指示页面是否在 swapcache 中。
PG_mappedtodisk 用于指示页面被映射到存储上的
PG_swapbacked 页面由交换支持。
PG_unevictable 用于指示页面在不可驱逐列表中;通常,此位用于 ramfs 拥有的页面和SHM_LOCKed共享内存页面。
PG_mlocked 用于指示页面上启用了 VMA 锁。

存在许多宏来检查设置清除单个页面位;这些操作被保证是原子的,并且在内核头文件/include/linux/page-flags.h中声明。它们被调用以从各种内核代码路径操纵页面标志:

/*Macros to create function definitions for page flags */
#define TESTPAGEFLAG(uname, lname, policy) \
static __always_inline int Page##uname(struct page *page) \
{ return test_bit(PG_##lname, &policy(page, 0)->flags); }

#define SETPAGEFLAG(uname, lname, policy) \
static __always_inline void SetPage##uname(struct page *page) \
{ set_bit(PG_##lname, &policy(page, 1)->flags); }

#define CLEARPAGEFLAG(uname, lname, policy) \
static __always_inline void ClearPage##uname(struct page *page) \
{ clear_bit(PG_##lname, &policy(page, 1)->flags); }

#define __SETPAGEFLAG(uname, lname, policy) \
static __always_inline void __SetPage##uname(struct page *page) \
{ __set_bit(PG_##lname, &policy(page, 1)->flags); }

#define __CLEARPAGEFLAG(uname, lname, policy) \
static __always_inline void __ClearPage##uname(struct page *page) \
{ __clear_bit(PG_##lname, &policy(page, 1)->flags); }

#define TESTSETFLAG(uname, lname, policy) \
static __always_inline int TestSetPage##uname(struct page *page) \
{ return test_and_set_bit(PG_##lname, &policy(page, 1)->flags); }

#define TESTCLEARFLAG(uname, lname, policy) \
static __always_inline int TestClearPage##uname(struct page *page) \
{ return test_and_clear_bit(PG_##lname, &policy(page, 1)->flags); }

*....
....* 

映射

页面描述符的另一个重要元素是类型为struct address_space的指针*mapping。然而,这是一个棘手的指针,可能是指向struct address_space的一个实例,也可能是指向struct anon_vma的一个实例。在我们深入了解如何实现这一点之前,让我们首先了解这些结构及它们所代表的资源的重要性。

文件系统利用空闲页面(来自页面缓存)来缓存最近访问的磁盘文件的数据。这种机制有助于最小化磁盘 I/O 操作:当缓存中的文件数据被修改时,适当的页面通过设置PG_dirty位被标记为脏;所有脏页面都会在策略性间隔时段通过调度磁盘 I/O 写入相应的磁盘块。struct address_space是一个表示为文件缓存而使用的页面集合的抽象。页面缓存的空闲页面也可以被映射到进程或进程组以进行动态分配,为这种分配映射的页面被称为匿名页面映射。struct anon_vma的一个实例表示使用匿名页面创建的内存块,这些页面被映射到进程或进程的虚拟地址空间(通过 VMA 实例)。

通过位操作实现指针动态初始化为指向这两种数据结构中的任意一种的地址是有技巧的。如果指针*mapping的低位清除,则表示页面映射到inode,指针指向struct address_space。如果低位设置,这表示匿名映射,这意味着指针指向struct anon_vma的一个实例。这是通过确保address_space实例的分配对齐到sizeof(long)来实现的,这使得指向address_space的指针的最低有效位被清除(即设置为 0)。

区域和节点

对于整个内存管理框架至关重要的主要数据结构是区域节点。让我们熟悉一下这些数据结构背后的核心概念。

内存区域

为了有效管理内存分配,物理页面被组织成称为区域的组。每个区域中的页面用于特定需求,如 DMA、高内存和其他常规分配需求。内核头文件mmzone.h中的enum声明了区域常量:

/* include/linux/mmzone.h */
enum zone_type {
#ifdef CONFIG_ZONE_DMA
ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
 ZONE_DMA32,
#endif
#ifdef CONFIG_HIGHMEM
 ZONE_HIGHMEM,
#endif
 ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
 ZONE_DEVICE,
#endif
 __MAX_NR_ZONES
};

ZONE_DMA

这个区域中的页面是为不能在所有可寻址内存上启动 DMA 的设备保留的。这个区域的大小是特定于架构的:

架构 限制
parsic, ia64, sparc <4G
s390 <2G
ARM 可变
alpha 无限制或<16MB
alpha, i386, x86-64 <16MB

ZONE_DMA32:这个区域用于支持可以在<4G 内存上执行 DMA 的 32 位设备。这个区域仅存在于 x86-64 平台上。

ZONE_NORMAL:所有可寻址内存被认为是正常的区域。只要 DMA 设备支持所有可寻址内存,就可以在这些页面上启动 DMA 操作。

ZONE_HIGHMEM:这个区域包含只能通过显式映射到内核地址空间中的内核访问的页面;换句话说,所有超出内核段的物理内存页面都属于这个区域。这个区域仅存在于 3:1 虚拟地址分割(3G 用于用户模式,1G 地址空间用于内核)的 32 位平台上;例如在 i386 上,允许内核访问超过 900MB 的内存将需要为内核需要访问的每个页面设置特殊映射(页表条目)。

ZONE_MOVABLE:内存碎片化是现代操作系统处理的挑战之一,Linux 也不例外。从内核启动的那一刻开始,直到运行时,页面被分配和释放用于一系列任务,导致具有物理连续页面的小内存区域。考虑到 Linux 对虚拟寻址的支持,碎片化可能不会成为各种进程顺利执行的障碍,因为物理上分散的内存总是可以通过页表映射到虚拟连续地址空间。然而,有一些场景,比如 DMA 分配和为内核数据结构设置缓存,对物理连续区域有严格的需求。

多年来,内核开发人员一直在演进各种抗碎片化技术来减轻碎片化。引入ZONE_MOVABLE就是其中之一。这里的核心思想是跟踪每个区域中的可移动页面,并将它们表示为这个伪区域,这有助于防止碎片化(我们将在下一节关于伙伴系统中更多讨论这个问题)。

这个区域的大小将在启动时通过内核参数kernelcore进行配置;请注意,分配的值指定了被视为不可移动的内存量,其余的是可移动的。一般规则是,内存管理器被配置为考虑从最高填充的区域迁移页面到ZONE_MOVABLE,对于 x86 32 位机器来说,这可能是ZONE_HIGHMEM,对于 x86_64 来说,可能是ZONE_DMA32

ZONE_DEVICE:这个区域被划分出来支持热插拔内存,比如大容量的持久内存数组持久内存在许多方面与 DRAM 非常相似;特别是,CPU 可以直接以字节级寻址它们。然而,特性如持久性、性能(写入速度较慢)和大小(通常以 TB 为单位)使它们与普通内存有所区别。为了让内核支持这样的具有 4KB 页面大小的内存,它需要枚举数十亿个页结构,这将消耗主内存的大部分或根本不适合。因此,内核开发人员选择将持久内存视为设备,而不是像内存一样;这意味着内核可以依靠适当的驱动程序来管理这样的内存。

void *devm_memremap_pages(struct device *dev, struct resource *res,
                        struct percpu_ref *ref, struct vmem_altmap *altmap); 

持久内存驱动程序的devm_memremap_pages()例程将持久内存区域映射到内核的地址空间,并在持久设备内存中设置相关的页结构。这些映射下的所有页面都被分组到ZONE_DEVICE下。为这样的页面设置一个独特的区域可以让内存管理器将它们与常规统一内存页面区分开来。

内存节点

Linux 内核长期以来一直实现了对多处理器机器架构的支持。内核实现了各种资源,比如每 CPU 数据缓存、互斥锁和原子操作宏,这些资源在各种 SMP 感知子系统中被使用,比如进程调度器和设备管理等。特别是,内存管理子系统的作用对于内核在这样的架构上运行至关重要,因为它需要将每个处理器所看到的内存虚拟化。多处理器机器架构基于每个处理器的感知和对系统内存的访问延迟,被广泛分类为两种类型。

统一内存访问架构(UMA):这些是多处理器架构的机器,处理器通过互连连接并共享物理内存和 I/O 端口。它们被称为 UMA 系统,因为无论从哪个处理器发起,内存访问延迟都是统一和固定的。大多数对称多处理器系统都是 UMA。

非均匀内存访问架构(NUMA):这些是多处理器机器,设计与 UMA 相反。这些系统为每个处理器设计了专用内存,并具有固定的访问延迟时间。但是,处理器可以通过适当的互连发起对其他处理器本地内存的访问操作,并且这样的操作会产生可变的访问延迟时间。

这种模型的机器由于每个处理器对系统内存的非均匀(非连续)视图而得名为NUMA

为了扩展对 NUMA 机器的支持,内核将每个非均匀内存分区(本地内存)视为一个node。每个节点由type pg_data_t的描述符标识,该描述符根据之前讨论的分区策略引用该节点下的页面。每个区域通过struct zone的实例表示。UMA 机器将包含一个节点描述符,该描述符下表示整个内存,而在 NUMA 机器上,将枚举一系列节点描述符,每个描述一个连续的内存节点。以下图表说明了这些数据结构之间的关系:

我们将继续使用节点区域描述符数据结构定义。请注意,我们不打算描述这些结构的每个元素,因为它们与内存管理的各个方面有关,而这超出了本章的范围。

节点描述符结构

节点描述符结构pg_data_t在内核头文件mmzone.h中声明:

/* include/linux/mmzone.h */typedef struct pglist_data {
  struct zone node_zones[MAX_NR_ZONES];
 struct zonelist node_zonelists[MAX_ZONELISTS];
 int nr_zones;

#ifdef CONFIG_FLAT_NODE_MEM_MAP /* means !SPARSEMEM */
  struct page *node_mem_map;
#ifdef CONFIG_PAGE_EXTENSION
  struct page_ext *node_page_ext;
#endif
#endif

#ifndef CONFIG_NO_BOOTMEM
  struct bootmem_data *bdata;
#endif
#ifdef CONFIG_MEMORY_HOTPLUG
 spinlock_t node_size_lock;
#endif
 unsigned long node_start_pfn;
 unsigned long node_present_pages; /* total number of physical pages */
 unsigned long node_spanned_pages; 
 int node_id;
 wait_queue_head_t kswapd_wait;
 wait_queue_head_t pfmemalloc_wait;
 struct task_struct *kswapd; 
 int kswapd_order;
 enum zone_type kswapd_classzone_idx;

#ifdef CONFIG_COMPACTION
 int kcompactd_max_order;
 enum zone_type kcompactd_classzone_idx;
 wait_queue_head_t kcompactd_wait;
 struct task_struct *kcompactd;
#endif
#ifdef CONFIG_NUMA_BALANCING
 spinlock_t numabalancing_migrate_lock;
 unsigned long numabalancing_migrate_next_window;
 unsigned long numabalancing_migrate_nr_pages;
#endif
 unsigned long totalreserve_pages;

#ifdef CONFIG_NUMA
 unsigned long min_unmapped_pages;
 unsigned long min_slab_pages;
#endif /* CONFIG_NUMA */

 ZONE_PADDING(_pad1_)
 spinlock_t lru_lock;

#ifdef CONFIG_DEFERRED_STRUCT_PAGE_INIT
 unsigned long first_deferred_pfn;
#endif /* CONFIG_DEFERRED_STRUCT_PAGE_INIT */

#ifdef CONFIG_TRANSPARENT_HUGEPAGE
 spinlock_t split_queue_lock;
 struct list_head split_queue;
 unsigned long split_queue_len;
#endif
 unsigned int inactive_ratio;
 unsigned long flags;

 ZONE_PADDING(_pad2_)
 struct per_cpu_nodestat __percpu *per_cpu_nodestats;
 atomic_long_t vm_stat[NR_VM_NODE_STAT_ITEMS];
} pg_data_t;

根据选择的机器类型和内核配置,各种元素被编译到这个结构中。我们将看一些重要的元素:

字段 描述
node_zones 一个包含此节点中页面的区域实例的数组。
node_zonelists 一个指定节点中区域的首选分配顺序的数组。
nr_zones 当前节点中区域的计数。
node_mem_map 指向当前节点中页面描述符列表的指针。
bdata 指向引导内存描述符的指针(在后面的部分中讨论)
node_start_pfn 持有此节点中第一个物理页面的帧编号;对于 UMA 系统,此值将为
node_present_pages 节点中页面的总数
node_spanned_pages 物理页面范围的总大小,包括任何空洞。
node_id 持有唯一节点标识符(节点从零开始编号)
kswapd_wait kswapd内核线程的等待队列
kswapd 指向kswapd内核线程的任务结构的指针
totalreserve_pages 未用于用户空间分配的保留页面的计数。

区域描述符结构

mmzone.h头文件还声明了struct zone,它充当区域描述符。以下是结构定义的代码片段,并且有很好的注释。我们将继续描述一些重要字段:

struct zone {
 /* Read-mostly fields */

 /* zone watermarks, access with *_wmark_pages(zone) macros */
 unsigned long watermark[NR_WMARK];

 unsigned long nr_reserved_highatomic;

 /*
 * We don't know if the memory that we're going to allocate will be
 * freeable or/and it will be released eventually, so to avoid totally
 * wasting several GB of ram we must reserve some of the lower zone
 * memory (otherwise we risk to run OOM on the lower zones despite
 * there being tons of freeable ram on the higher zones). This array is
 * recalculated at runtime if the sysctl_lowmem_reserve_ratio sysctl
 * changes.
 */
 long lowmem_reserve[MAX_NR_ZONES];

#ifdef CONFIG_NUMA
 int node;
#endif
 struct pglist_data *zone_pgdat;
 struct per_cpu_pageset __percpu *pageset;

#ifndef CONFIG_SPARSEMEM
 /*
 * Flags for a pageblock_nr_pages block. See pageblock-flags.h.
 * In SPARSEMEM, this map is stored in struct mem_section
 */
 unsigned long *pageblock_flags;
#endif /* CONFIG_SPARSEMEM */

 /* zone_start_pfn == zone_start_paddr >> PAGE_SHIFT */
 unsigned long zone_start_pfn;

 /*
 * spanned_pages is the total pages spanned by the zone, including
 * holes, which is calculated as:
 * spanned_pages = zone_end_pfn - zone_start_pfn;
 *
 * present_pages is physical pages existing within the zone, which
 * is calculated as:
 * present_pages = spanned_pages - absent_pages(pages in holes);
 *
 * managed_pages is present pages managed by the buddy system, which
 * is calculated as (reserved_pages includes pages allocated by the
 * bootmem allocator):
 * managed_pages = present_pages - reserved_pages;
 *
 * So present_pages may be used by memory hotplug or memory power
 * management logic to figure out unmanaged pages by checking
 * (present_pages - managed_pages). And managed_pages should be used
 * by page allocator and vm scanner to calculate all kinds of watermarks
 * and thresholds.
 *
 * Locking rules:
 *
 * zone_start_pfn and spanned_pages are protected by span_seqlock.
 * It is a seqlock because it has to be read outside of zone->lock,
 * and it is done in the main allocator path. But, it is written
 * quite infrequently.
 *
 * The span_seq lock is declared along with zone->lock because it is
 * frequently read in proximity to zone->lock. It's good to
 * give them a chance of being in the same cacheline.
 *
 * Write access to present_pages at runtime should be protected by
 * mem_hotplug_begin/end(). Any reader who can't tolerant drift of
 * present_pages should get_online_mems() to get a stable value.
 *
 * Read access to managed_pages should be safe because it's unsigned
 * long. Write access to zone->managed_pages and totalram_pages are
 * protected by managed_page_count_lock at runtime. Idealy only
 * adjust_managed_page_count() should be used instead of directly
 * touching zone->managed_pages and totalram_pages.
 */
 unsigned long managed_pages;
 unsigned long spanned_pages;
 unsigned long present_pages;

 const char *name;// name of this zone

#ifdef CONFIG_MEMORY_ISOLATION
 /*
 * Number of isolated pageblock. It is used to solve incorrect
 * freepage counting problem due to racy retrieving migratetype
 * of pageblock. Protected by zone->lock.
 */
 unsigned long nr_isolate_pageblock;
#endif

#ifdef CONFIG_MEMORY_HOTPLUG
 /* see spanned/present_pages for more description */
 seqlock_t span_seqlock;
#endif

 int initialized;

 /* Write-intensive fields used from the page allocator */
 ZONE_PADDING(_pad1_)

 /* free areas of different sizes */
struct free_area free_area[MAX_ORDER];

 /* zone flags, see below */
 unsigned long flags;

 /* Primarily protects free_area */
 spinlock_t lock;

 /* Write-intensive fields used by compaction and vmstats. */
 ZONE_PADDING(_pad2_)

 /*
 * When free pages are below this point, additional steps are taken
 * when reading the number of free pages to avoid per-CPU counter
 * drift allowing watermarks to be breached
 */
 unsigned long percpu_drift_mark;

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
 /* pfn where compaction free scanner should start */
 unsigned long compact_cached_free_pfn;
 /* pfn where async and sync compaction migration scanner should start */
 unsigned long compact_cached_migrate_pfn[2];
#endif

#ifdef CONFIG_COMPACTION
 /*
 * On compaction failure, 1<<compact_defer_shift compactions
 * are skipped before trying again. The number attempted since
 * last failure is tracked with compact_considered.
 */
 unsigned int compact_considered;
 unsigned int compact_defer_shift;
 int compact_order_failed;
#endif

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
 /* Set to true when the PG_migrate_skip bits should be cleared */
 bool compact_blockskip_flush;
#endif

 bool contiguous;

 ZONE_PADDING(_pad3_)
 /* Zone statistics */
 atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;

以下是重要字段的总结表,每个字段都有简短的描述:

字段 描述
watermark 一个无符号长整型数组,具有WRMARK_MINWRMARK_LOWWRMARK_HIGH的偏移量。这些偏移量中的值会影响kswapd内核线程执行的交换操作。
nr_reserved_highatomic 保留高阶原子页面的计数
lowmem_reserve 指定为每个区域保留用于关键分配的页面计数的数组。
zone_pgdat 指向此区域的节点描述符的指针。
pageset 指向每个 CPU 的热和冷页面列表。
free_area 一个struct free_area类型实例的数组,每个实例抽象出为伙伴分配器提供的连续空闲页面。更多关于伙伴分配器的内容将在后面的部分中介绍。
flags 用于存储区域当前状态的无符号长变量。
zone_start_pfn 区域中第一个页面帧的索引
vm_stat 区域的统计信息

内存分配器

在了解了物理内存是如何组织和通过核心数据结构表示之后,我们现在将把注意力转向处理分配和释放请求的物理内存管理。系统中的各种实体,如用户模式进程、驱动程序和文件系统,可以提出内存分配请求。根据提出分配请求的实体和上下文的类型,返回的分配可能需要满足某些特性,例如页面对齐的物理连续大块或物理连续小块、硬件缓存对齐内存,或映射到虚拟连续地址空间的物理碎片化块。

为了有效地管理物理内存,并根据选择的优先级和模式满足内存需求,内核与一组内存分配器进行交互。每个分配器都有一组不同的接口例程,这些例程由专门设计的算法支持,针对特定的分配模式进行了优化。

页面帧分配器

也称为分区页帧分配器,这用作以页面大小的倍数进行物理连续分配的接口。通过查找适当的区域以获取空闲页面来执行分配操作。每个zone中的物理页面由伙伴系统管理,该系统作为页面帧分配器的后端算法:

内核代码可以通过内核头文件linux/include/gfp.h中提供的接口内联函数和宏来启动对该算法的内存分配/释放操作:

static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);

第一个参数gfp_mask用作指定属性的手段,根据这些属性来满足分配的需求;我们将在接下来的部分详细了解属性标志。第二个参数order用于指定分配的大小;分配的值被认为是 2^(order)。成功时,它返回第一个页面结构的地址,失败时返回 NULL。对于单页分配,还提供了一个备用宏,它再次回退到alloc_pages()

#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0);

分配的页面被映射到连续的内核地址空间,通过适当的页表项(用于访问操作期间的分页地址转换)。在页表映射后生成的地址,用于内核代码中的使用,被称为线性地址。通过另一个函数接口page_address(),调用者代码可以检索分配块的起始线性地址。

分配也可以通过一组包装器例程和宏来启动到alloc_pages()的操作,这些例程和宏略微扩展了功能,并返回分配块的起始线性地址,而不是页面结构的指针。以下代码片段显示了一组包装器函数和宏:

/* allocates 2^(order) pages and returns start linear address */ unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
{
struct page *page;
/*
* __get_free_pages() returns a 32-bit address, which cannot represent
* a highmem page
*/
VM_BUG_ON((gfp_mask & __GFP_HIGHMEM) != 0);

page = alloc_pages(gfp_mask, order);
if (!page)
return 0;
return (unsigned long) page_address(page);
}

/* Returns start linear address to zero initialized page */
unsigned long get_zeroed_page(gfp_t gfp_mask)
{
return __get_free_pages(gfp_mask | __GFP_ZERO, 0);
}
 /* Allocates a page */
#define __get_free_page(gfp_mask) \
__get_free_pages((gfp_mask), 0)

/* Allocate page/pages from DMA zone */
#define __get_dma_pages(gfp_mask, order) \
 __get_free_pages((gfp_mask) | GFP_DMA, (order))

以下是释放内存返回到系统的接口。我们需要调用一个与分配例程匹配的适当接口;传递不正确的地址将导致损坏。

void __free_pages(struct page *page, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
void free_page(addr);

伙伴系统

虽然页面分配器用作内存分配的接口(以页面大小的倍数),但伙伴系统在后台运行以管理物理页面管理。该算法管理每个zone的所有物理页面。它经过优化,以最小化外部碎片化,实现大型物理连续块(页面)的分配。让我们探索其操作细节.

zone描述符结构包含一个struct free_area数组,数组的大小是通过内核宏MAX_ORDER定义的,默认值为11

  struct zone {
          ...
          ...
          struct free_area[MAX_ORDER];
          ...
          ...
     };

每个偏移包含一个free_area结构的实例。所有空闲页面被分成 11 个(MAX_ORDER)列表,每个列表包含 2^(order)页的块列表,其中 order 的值在 0 到 11 的范围内(也就是说,2²的列表包含 16KB 大小的块,2³包含 32KB 大小的块,依此类推)。这种策略确保每个块都自然对齐。每个列表中的块大小恰好是低级列表中块大小的两倍,从而实现更快的分配和释放操作。它还为分配器提供了处理连续分配的能力,最多可达 8MB 的块大小(2¹¹列表)。

当针对特定大小的分配请求时,伙伴系统会查找适当的空闲块列表,并返回其地址(如果有的话)。然而,如果找不到空闲块,它会移动到下一个高阶列表中查找更大的块,如果有的话,它会将高阶块分割成称为伙伴的相等部分,返回一个给分配器,并将第二个排入低阶列表。当两个伙伴块在将来某个时间变为空闲时,它们将合并为一个更大的块。算法可以通过它们对齐的地址来识别伙伴块,这使得它们可以合并。

让我们举个例子来更好地理解这一点,假设有一个请求来分配一个 8k 的块(通过页面分配器例程)。伙伴系统在free_pages数组的 8k 列表中查找空闲块(第一个偏移包含 2¹大小的块),如果有的话,返回块的起始线性地址;然而,如果在 8k 列表中没有空闲块,它会移动到下一个更高阶的列表,即 16k 块(free_pages数组的第二个偏移)中查找空闲块。假设在这个列表中也没有空闲块。然后它继续前进到大小为 32k 的下一个高阶列表(free_pages数组的第三个偏移)中查找空闲块;如果有的话,它将 32k 块分成两个相等的 16k 块(伙伴)。第一个 16k 块进一步分成两个 8k 的半块(伙伴),其中一个分配给调用者,另一个放入 8k 列表。第二个 16k 块放入 16k 空闲列表,当低阶(8k)伙伴在将来的某个时间变为空闲时,它们将合并为一个更高阶的 16k 块。当两个 16k 伙伴块都变为空闲时,它们再次合并为一个 32k 块,然后放回空闲列表。

当无法处理来自所需区域的分配请求时,伙伴系统使用回退机制来查找其他区域和节点:

伙伴系统在各种nix 操作系统中有着悠久的历史,并进行了广泛的实现和适当的优化。正如前面讨论的那样,它有助于更快的内存分配和释放,并且在一定程度上最小化了外部碎片化。随着提供了急需的性能优势的大页*的出现,进一步努力以抵制碎片化变得更加重要。为了实现这一点,Linux 内核对伙伴系统的实现配备了通过页面迁移实现抵制碎片化的能力。

页面迁移是将虚拟页面的数据从一个物理内存区域移动到另一个的过程。这种机制有助于创建具有连续页面的更大块。为了实现这一点,页面被归类为以下类型:

1. 不可移动页面:被固定并保留用于特定分配的物理页面被视为不可移动。核心内核固定的页面属于这一类。这些页面是不可回收的。

  1. 可回收页面:映射到动态分配的物理页面可以被驱逐到后备存储器,并且可以重新生成的页面被认为是可回收的。用于文件缓存,匿名页面映射以及内核的 slab 缓存持有的页面都属于这个类别。回收操作以两种模式进行:周期性回收和直接回收,前者通过称为kswapd的 kthread 实现。当系统内存严重不足时,内核进入直接回收

  2. 可移动页面:可以通过页面迁移机制移动到不同区域的物理页面。映射到用户模式进程的虚拟地址空间的页面被认为是可移动的,因为所有 VM 子系统需要做的就是复制数据并更改相关的页表条目。这是有效的,考虑到所有来自用户模式进程的访问操作都经过页表翻译。

伙伴系统根据页面的可移动性将页面分组为独立列表,并将它们用于适当的分配。这是通过将struct free_area中的每个 2^n 列表组织为基于页面移动性的自主列表组实现的。每个free_area实例都持有大小为MIGRATE_TYPES的列表数组。每个偏移量都持有相应页面组的list_head

 struct free_area {
          struct list_head free_list[MIGRATE_TYPES];
          unsigned long nr_free;
    };

nr_free是一个计数器,它保存了此free_area(所有迁移列表放在一起)的空闲页面总数。以下图表描述了每种迁移类型的空闲列表:

以下枚举定义了页面迁移类型:

enum {
 MIGRATE_UNMOVABLE,
 MIGRATE_MOVABLE,
 MIGRATE_RECLAIMABLE,
 MIGRATE_PCPTYPES, /* the number of types on the pcp lists */
 MIGRATE_HIGHATOMIC = MIGRATE_PCPTYPES,
#ifdef CONFIG_CMA
 MIGRATE_CMA,
#endif
#ifdef CONFIG_MEMORY_ISOLATION
 MIGRATE_ISOLATE, /* can't allocate from here */
#endif
 MIGRATE_TYPES
};  

我们已经讨论了关键的迁移类型MIGRATE_MOVABLEMIGRATE_UNMOVABLEMIGRATE_RECLAIMABLE类型。MIGRATE_PCPTYPES是一种特殊类型,用于提高系统性能;每个区域维护一个每 CPU 页缓存中的热缓存页面列表。这些页面用于为本地 CPU 提出的分配请求提供服务。区域描述符结构pageset元素指向每 CPU 缓存中的页面:

/* include/linux/mmzone.h */

struct per_cpu_pages {
 int count; /* number of pages in the list */
 int high; /* high watermark, emptying needed */
 int batch; /* chunk size for buddy add/remove */

 /* Lists of pages, one per migrate type stored on the pcp-lists */
 struct list_head lists[MIGRATE_PCPTYPES];
};

struct per_cpu_pageset {
 struct per_cpu_pages pcp;
#ifdef CONFIG_NUMA
 s8 expire;
#endif
#ifdef CONFIG_SMP
 s8 stat_threshold;
 s8 vm_stat_diff[NR_VM_ZONE_STAT_ITEMS];
#endif
};

struct zone {
 ...
 ...
 struct per_cpu_pageset __percpu *pageset;
 ...
 ...
};

struct per_cpu_pageset是一个表示不可移动可回收可移动页面列表的抽象。MIGRATE_PCPTYPES是按页面移动性排序的每 CPU 页面列表的计数。MIGRATE_CMA是连续内存分配器的页面列表,我们将在后续部分中讨论:

当所需移动性的页面不可用时,伙伴系统实现了回退到备用列表,以处理分配请求。以下数组定义了各种迁移类型的回退顺序;我们不会进一步详细说明,因为它是不言自明的:

static int fallbacks[MIGRATE_TYPES][4] = {
 [MIGRATE_UNMOVABLE] = { MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE, MIGRATE_TYPES },
 [MIGRATE_RECLAIMABLE] = { MIGRATE_UNMOVABLE, MIGRATE_MOVABLE, MIGRATE_TYPES },
 [MIGRATE_MOVABLE] = { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE, MIGRATE_TYPES },
#ifdef CONFIG_CMA
 [MIGRATE_CMA] = { MIGRATE_TYPES }, /* Never used */
#endif
#ifdef CONFIG_MEMORY_ISOLATION
 [MIGRATE_ISOLATE] = { MIGRATE_TYPES }, /* Never used */
#endif
};

GFP 掩码

页面分配器和其他分配器例程(我们将在以下部分讨论)需要gfp_mask标志作为参数,其类型为gfp_t

typedef unsigned __bitwise__ gfp_t;

Gfp 标志用于为分配器函数提供两个重要属性:第一个是分配的模式,它控制分配器函数的行为第二个是分配的来源,它指示可以从中获取内存的区域区域列表内核头文件gfp.h定义了各种标志常量,这些常量被分类为不同的组,称为区域修饰符,移动性和 放置标志,水位标志,回收修饰符操作修饰符。

区域修饰符

以下是用于指定要从中获取内存的区域的修饰符的总结列表。回顾我们在前一节中对区域的讨论;对于每个区域,都定义了一个gfp标志:

#define __GFP_DMA ((__force gfp_t)___GFP_DMA)
#define __GFP_HIGHMEM ((__force gfp_t)___GFP_HIGHMEM)
#define __GFP_DMA32 ((__force gfp_t)___GFP_DMA32)
#define __GFP_MOVABLE ((__force gfp_t)___GFP_MOVABLE) /* ZONE_MOVABLE allowed */

页面移动性和放置

以下代码片段定义了页面移动性和放置标志:

#define __GFP_RECLAIMABLE ((__force gfp_t)___GFP_RECLAIMABLE)
#define __GFP_WRITE ((__force gfp_t)___GFP_WRITE)
#define __GFP_HARDWALL ((__force gfp_t)___GFP_HARDWALL)
#define __GFP_THISNODE ((__force gfp_t)___GFP_THISNODE)
#define __GFP_ACCOUNT ((__force gfp_t)___GFP_ACCOUNT)

以下是页面移动性和放置标志的列表:

  • __GFP_RECLAIMABLE:大多数内核子系统都设计为使用内存缓存来缓存频繁需要的资源,例如数据结构、内存块、持久文件数据等。内存管理器维护这些缓存,并允许它们根据需要动态扩展。但是,不能无限制地扩展这些缓存,否则它们最终会消耗所有内存。内存管理器通过shrinker接口处理此问题,这是一种内存管理器可以在需要时缩小缓存并回收页面的机制。在分配页面(用于缓存)时启用此标志表示向 shrinker 指示页面是可回收的。这个标志由后面的部分讨论的 slab 分配器使用。

  • __GFP_WRITE:当使用此标志时,表示向内核指示调用者打算污染页面。内存管理器根据公平区分配策略分配适当的页面,该策略在节点的本地区域之间轮流分配这些页面,以避免所有脏页面都在一个区域中。

  • __GFP_HARDWALL:此标志确保分配在与调用者绑定的相同节点或节点上进行;换句话说,它强制执行 CPUSET 内存分配策略。

  • __GFP_THISNODE:此标志强制满足分配请求来自请求的节点,没有回退或放置策略的强制执行。

  • __GFP_ACCOUNT:此标志导致分配被 kmem 控制组记录。

水印修饰符

以下代码片段定义了水印修饰符:

#define __GFP_ATOMIC ((__force gfp_t)___GFP_ATOMIC)
#define __GFP_HIGH ((__force gfp_t)___GFP_HIGH)
#define __GFP_MEMALLOC ((__force gfp_t)___GFP_MEMALLOC)
#define __GFP_NOMEMALLOC ((__force gfp_t)___GFP_NOMEMALLOC)

以下是水印修饰符的列表,它们可以控制内存的紧急保留池:

  • __GFP_ATOMIC:此标志表示分配具有高优先级,并且调用者上下文不能被置于等待状态。

  • __GFP_HIGH:此标志表示调用者具有高优先级,并且必须满足分配请求以使系统取得进展。设置此标志将导致分配器访问紧急池。

  • __GFP_MEMALLOC:此标志允许访问所有内存。只有在调用者保证分配很快就会释放更多内存时才应使用,例如,进程退出或交换。

  • __GFP_NOMEMALLOC:此标志用于禁止访问所有保留的紧急池。

页面回收修饰符

随着系统负载的增加,区域中的空闲内存量可能会低于低水位标记,导致内存紧缩,这将严重影响系统的整体性能*。为了处理这种可能性,内存管理器配备了页面回收算法,用于识别和回收页面。当使用适当的 GFP 常量调用内核内存分配器例程时,会启用回收算法,称为页面回收修饰符

#define __GFP_IO ((__force gfp_t)___GFP_IO)
#define __GFP_FS ((__force gfp_t)___GFP_FS)
#define __GFP_DIRECT_RECLAIM ((__force gfp_t)___GFP_DIRECT_RECLAIM) /* Caller can reclaim */
#define __GFP_KSWAPD_RECLAIM ((__force gfp_t)___GFP_KSWAPD_RECLAIM) /* kswapd can wake */
#define __GFP_RECLAIM ((__force gfp_t)(___GFP_DIRECT_RECLAIM|___GFP_KSWAPD_RECLAIM))
#define __GFP_REPEAT ((__force gfp_t)___GFP_REPEAT)
#define __GFP_NOFAIL ((__force gfp_t)___GFP_NOFAIL)
#define __GFP_NORETRY ((__force gfp_t)___GFP_NORETRY)

以下是可以作为参数传递给分配例程的回收修饰符列表;每个标志都可以在特定内存区域上启用回收操作:

  • __GFP_IO:此标志表示分配器可以启动物理 I/O(交换)以回收内存。

  • __GFP_FS:此标志表示分配器可以调用低级 FS 进行回收。

  • __GFP_DIRECT_RECLAIM:此标志表示调用者愿意进行直接回收。这可能会导致调用者阻塞。

  • __GFP_KSWAPD_RECLAIM:此标志表示分配器可以唤醒kswapd内核线程来启动回收,当低水位标记达到时。

  • __GFP_RECLAIM:此标志用于启用直接和kswapd回收。

  • __GFP_REPEAT:此标志表示尝试努力分配内存,但分配尝试可能失败。

  • __GFP_NOFAIL:此标志强制虚拟内存管理器重试,直到分配请求成功。这可能会导致 VM 触发 OOM killer 来回收内存。

  • __GFP_NORETRY:当无法满足请求时,此标志将导致分配器返回适当的失败状态。

动作修饰符

以下代码片段定义了动作修饰符:

#define __GFP_COLD ((__force gfp_t)___GFP_COLD)
#define __GFP_NOWARN ((__force gfp_t)___GFP_NOWARN)
#define __GFP_COMP ((__force gfp_t)___GFP_COMP)
#define __GFP_ZERO ((__force gfp_t)___GFP_ZERO)
#define __GFP_NOTRACK ((__force gfp_t)___GFP_NOTRACK)
#define __GFP_NOTRACK_FALSE_POSITIVE (__GFP_NOTRACK)
#define __GFP_OTHER_NODE ((__force gfp_t)___GFP_OTHER_NODE)

以下是动作修饰符标志的列表;这些标志指定了在处理请求时分配器例程要考虑的附加属性:

  • __GFP_COLD:为了实现快速访问,每个区域中的一些页面被缓存在每个 CPU 的缓存中;缓存中保存的页面被称为,而未缓存的页面被称为。此标志表示分配器应通过缓存冷页面来处理内存请求。

  • __GFP_NOWARN:此标志导致分配器以静默模式运行,导致警告和错误条件不被报告。

  • __GFP_COMP:此标志用于分配带有适当元数据的复合页面。复合页面是两个或更多个物理上连续的页面组成的,被视为单个大页面。元数据使复合页面与其他物理上连续的页面不同。复合页面的第一个物理页面称为头页面,其页面描述符中设置了PG_head标志,其余页面称为尾页面

  • __GFP_ZERO:此标志导致分配器返回填充为零的页面。

  • __GFP_NOTRACK:kmemcheck 是内核中的一个调试器,用于检测和警告未初始化的内存访问。尽管如此,这些检查会导致内存访问操作被延迟。当性能是一个标准时,调用者可能希望分配不被 kmemcheck 跟踪的内存。此标志导致分配器返回这样的内存。

  • __GFP_NOTRACK_FALSE_POSITIVE:此标志是__GFP_NOTRACK的别名。

  • __GFP_OTHER_NODE:此标志用于分配透明巨大页面(THP)。

类型标志

由于有这么多类别的修饰符标志(每个都涉及不同的属性),程序员在选择相应分配的标志时要非常小心。为了使这个过程更容易、更快速,引入了类型标志,使程序员能够快速进行分配选择。类型标志是从各种修饰常量的组合(前面列出的)中派生出来的,用于特定的分配用例。然而,如果需要,程序员可以进一步自定义类型标志:

#define GFP_ATOMIC (__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL (__GFP_RECLAIM | __GFP_IO | __GFP_FS)
#define GFP_KERNEL_ACCOUNT (GFP_KERNEL | __GFP_ACCOUNT)
#define GFP_NOWAIT (__GFP_KSWAPD_RECLAIM)
#define GFP_NOIO (__GFP_RECLAIM)
#define GFP_NOFS (__GFP_RECLAIM | __GFP_IO)
#define GFP_TEMPORARY (__GFP_RECLAIM | __GFP_IO | __GFP_FS | __GFP_RECLAIMABLE)
#define GFP_USER (__GFP_RECLAIM | __GFP_IO | __GFP_FS | __GFP_HARDWALL)
#define GFP_DMA __GFP_DMA
#define GFP_DMA32 __GFP_DMA32
#define GFP_HIGHUSER (GFP_USER | __GFP_HIGHMEM)
#define GFP_HIGHUSER_MOVABLE (GFP_HIGHUSER | __GFP_MOVABLE)
#define GFP_TRANSHUGE_LIGHT ((GFP_HIGHUSER_MOVABLE | __GFP_COMP | __GFP_NOMEMALLOC | \ __GFP_NOWARN) & ~__GFP_RECLAIM)
#define GFP_TRANSHUGE (GFP_TRANSHUGE_LIGHT | __GFP_DIRECT_RECLAIM)

以下是类型标志的列表:

  • GFP_ATOMIC:指定非阻塞分配的标志,这种分配不会失败。此标志将导致从紧急储备中分配。通常在从原子上下文调用分配器时使用。

  • GFP_KERNEL:在为内核使用分配内存时使用此标志。这些请求是从正常区域处理的。此标志可能导致分配器进入直接回收。

  • GFP_KERNEL_ACCOUNT:与GFP_KERNEL相同,但额外增加了由 kmem 控制组跟踪分配的标志

  • GFP_NOWAIT:此标志用于非阻塞的内核分配。

  • GFP_NOIO:此标志允许分配器在不需要物理 I/O(交换)的干净页面上开始直接回收。

  • GFP_NOFS:此标志允许分配器开始直接回收,但阻止调用文件系统接口。

  • GFP_TEMPORARY:在为内核缓存分配页面时使用此标志,通过适当的收缩器接口可以回收这些页面。此标志设置了我们之前讨论过的__GFP_RECLAIMABLE标志。

  • GFP_USER:此标志用于用户空间分配。分配的内存被映射到用户进程,并且也可以被内核服务或硬件访问,用于从设备到缓冲区或反之的 DMA 传输。

  • GFP_DMA:此标志导致从最低的区域ZONE_DMA中分配。这个标志仍然为向后兼容而支持。

  • GFP_DMA32:此标志导致从包含<4G 内存的ZONE_DMA32中处理分配。

  • GFP_HIGHUSER:此标志用于从ZONE_HIGHMEM(仅在 32 位平台上相关)分配用户空间分配。

  • GFP_HIGHUSER_MOVABLE:此标志类似于GFP_HIGHUSER,另外还可以从可移动页面中进行分配,这使得页面迁移和回收成为可能。

  • GFP_TRANSHUGE_LIGHT:这会导致透明巨大分配(THP)的分配,这是复合分配。这种类型的标志设置了__GFP_COMP,我们之前讨论过。

粘土块分配器

如前面的部分所讨论的,页面分配器(与伙伴系统协调)有效地处理了页面大小的多重内存分配请求。然而,内核代码发起的大多数分配请求用于其内部使用的较小块(通常小于一页);为这样的分配请求启用页面分配器会导致内部碎片,导致内存浪费。粘土块分配器正是为了解决这个问题而实现的;它建立在伙伴系统之上,用于分配小内存块,以容纳内核服务使用的结构对象或数据。

粘土块分配器的设计基于对象 缓存的概念。对象缓存的概念非常简单:它涉及保留一组空闲页面帧,将它们分割并组织成独立的空闲列表(每个列表包含一些空闲页面),称为粘土块缓存,并使用每个列表来分配一组固定大小的对象或内存块,称为单元。这样,每个列表被分配一个唯一的单元大小,并包含该大小的对象或内存块的池。当收到对给定大小的内存块的分配请求时,分配器算法会选择一个适当的粘土块缓存,其单元大小最适合请求的大小,并返回一个空闲块的地址。

然而,在低级别上,初始化和管理粘土块缓存涉及相当复杂的问题。算法需要考虑各种问题,如对象跟踪、动态扩展和通过 shrinker 接口进行安全回收。解决所有这些问题,并在增强性能和最佳内存占用之间取得适当的平衡是相当具有挑战性的。我们将在后续部分更多地探讨这些挑战,但现在我们将继续讨论分配器函数接口。

Kmalloc 缓存

粘土块分配器维护一组通用粘土块缓存,以缓存 8 的倍数的单元大小的内存块。它为每个单元大小维护两组粘土块缓存,一组用于维护从ZONE_NORMAL页面分配的内存块池,另一组用于维护从ZONE_DMA页面分配的内存块池。这些缓存是全局的,并由所有内核代码共享。用户可以通过特殊文件/proc/slabinfo跟踪这些缓存的状态。内核服务可以通过kmalloc系列例程从这些缓存中分配和释放内存块。它们被称为kmalloc缓存:

#cat /proc/slabinfo 
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>dma-kmalloc-8192 0 0 8192 4 8 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-4096 0 0 4096 8 8 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-2048 0 0 2048 16 8 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-1024 0 0 1024 16 4 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-512 0 0 512 16 2 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-256 0 0 256 16 1 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-128 0 0 128 32 1 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-64 0 0 64 64 1 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-32 0 0 32 128 1 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-16 0 0 16 256 1 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-8 0 0 8 512 1 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-192 0 0 192 21 1 : tunables 0 0 0 : slabdata 0 0 0
dma-kmalloc-96 0 0 96 42 1 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-8192 156 156 8192 4 8 : tunables 0 0 0 : slabdata 39 39 0
kmalloc-4096 325 352 4096 8 8 : tunables 0 0 0 : slabdata 44 44 0
kmalloc-2048 1105 1184 2048 16 8 : tunables 0 0 0 : slabdata 74 74 0
kmalloc-1024 2374 2448 1024 16 4 : tunables 0 0 0 : slabdata 153 153 0
kmalloc-512 1445 1520 512 16 2 : tunables 0 0 0 : slabdata 95 95 0
kmalloc-256 9988 10400 256 16 1 : tunables 0 0 0 : slabdata 650 650 0
kmalloc-192 3561 4053 192 21 1 : tunables 0 0 0 : slabdata 193 193 0
kmalloc-128 3588 5728 128 32 1 : tunables 0 0 0 : slabdata 179 179 0
kmalloc-96 3402 3402 96 42 1 : tunables 0 0 0 : slabdata 81 81 0
kmalloc-64 42672 45184 64 64 1 : tunables 0 0 0 : slabdata 706 706 0
kmalloc-32 15095 16000 32 128 1 : tunables 0 0 0 : slabdata 125 125 0
kmalloc-16 6400 6400 16 256 1 : tunables 0 0 0 : slabdata 25 25 0
kmalloc-8 6144 6144 8 512 1 : tunables 0 0 0 : slabdata 12 12 0

kmalloc-96kmalloc-192是用于维护与一级硬件缓存对齐的内存块的缓存。对于大于 8k 的分配(大块),粘土块分配器会回退到伙伴系统。

以下是 kmalloc 系列分配器例程;所有这些都需要适当的 GFP 标志:

/**
 * kmalloc - allocate memory. 
 * @size: bytes of memory required.
 * @flags: the type of memory to allocate.
 */
/**
 * kmalloc_array - allocate memory for an array.
 * @n: number of elements.
 * @size: element size.
 * @flags: the type of memory to allocate (see kmalloc).
 */ 
/**
 * kcalloc - allocate memory for an array. The memory is set to zero.
 * @n: number of elements.
 * @size: element size.
 * @flags: the type of memory to allocate (see kmalloc).
 *//**
 * krealloc - reallocate memory. The contents will remain unchanged.
 * @p: object to reallocate memory for.
 * @new_size: bytes of memory are required.
 * @flags: the type of memory to allocate.
 *
 * The contents of the object pointed to are preserved up to the 
 * lesser of the new and old sizes. If @p is %NULL, krealloc()
 * behaves exactly like kmalloc(). If @new_size is 0 and @p is not a
 * %NULL pointer, the object pointed to is freed
 */
 void *krealloc(const void *p, size_t new_size, gfp_t flags) /**
 * kmalloc_node - allocate memory from a particular memory node.
 * @size: bytes of memory are required.
 * @flags: the type of memory to allocate.
 * @node: memory node from which to allocate
 */ void *kmalloc_node(size_t size, gfp_t flags, int node) /**
 * kzalloc_node - allocate zeroed memory from a particular memory node.
 * @size: how many bytes of memory are required.
 * @flags: the type of memory to allocate (see kmalloc).
 * @node: memory node from which to allocate
 */ void *kzalloc_node(size_t size, gfp_t flags, int node)

以下例程将分配的块返回到空闲池中。调用者需要确保作为参数传递的地址是有效的分配块:

/**
 * kfree - free previously allocated memory
 * @objp: pointer returned by kmalloc.
 *
 * If @objp is NULL, no operation is performed.
 *
 * Don't free memory not originally allocated by kmalloc()
 * or you will run into trouble.
 */
void kfree(const void *objp) /**
 * kzfree - like kfree but zero memory
 * @p: object to free memory of
 *
 * The memory of the object @p points to is zeroed before freed.
 * If @p is %NULL, kzfree() does nothing.
 *
 * Note: this function zeroes the whole allocated buffer which can be a good
 * deal bigger than the requested buffer size passed to kmalloc(). So be
 * careful when using this function in performance sensitive code.
 */ void kzfree(const void *p)

对象缓存

slab 分配器提供了用于设置 slab 缓存的函数接口,这些缓存可以由内核服务或子系统拥有。由于这些缓存是局部于内核服务(或内核子系统)的,因此被认为是私有的,例如设备驱动程序、文件系统、进程调度程序等。大多数内核子系统使用此功能来设置对象缓存和池化间歇性需要的数据结构。到目前为止,我们遇到的大多数数据结构(自第一章以来,理解进程、地址空间和线程),包括进程描述符、信号描述符、页面描述符等,都是在这样的对象池中维护的。伪文件/proc/slabinfo显示了对象缓存的状态:

# cat /proc/slabinfo 
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
sigqueue 100 100 160 25 1 : tunables 0 0 0 : slabdata 4 4 0
bdev_cache 76 76 832 19 4 : tunables 0 0 0 : slabdata 4 4 0
kernfs_node_cache 28594 28594 120 34 1 : tunables 0 0 0 : slabdata 841 841 0
mnt_cache 489 588 384 21 2 : tunables 0 0 0 : slabdata 28 28 0
inode_cache 15932 15932 568 28 4 : tunables 0 0 0 : slabdata 569 569 0
dentry 89541 89817 192 21 1 : tunables 0 0 0 : slabdata 4277 4277 0
iint_cache 0 0 72 56 1 : tunables 0 0 0 : slabdata 0 0 0
buffer_head 53079 53430 104 39 1 : tunables 0 0 0 : slabdata 1370 1370 0
vm_area_struct 41287 42400 200 20 1 : tunables 0 0 0 : slabdata 2120 2120 0
files_cache 207 207 704 23 4 : tunables 0 0 0 : slabdata 9 9 0
signal_cache 420 420 1088 30 8 : tunables 0 0 0 : slabdata 14 14 0
sighand_cache 289 315 2112 15 8 : tunables 0 0 0 : slabdata 21 21 0
task_struct 750 801 3584 9 8 : tunables 0 0 0 : slabdata 89 89 0

*kmem_cache_create()*例程根据传递的参数设置一个新的cache。成功后,它将返回*kmem_cache*类型的缓存描述符结构的地址:

/*
 * kmem_cache_create - Create a cache.
 * @name: A string which is used in /proc/slabinfo to identify this cache.
 * @size: The size of objects to be created in this cache.
 * @align: The required alignment for the objects.
 * @flags: SLAB flags
 * @ctor: A constructor for the objects.
 *
 * Returns a ptr to the cache on success, NULL on failure.
 * Cannot be called within a interrupt, but can be interrupted.
 * The @ctor is run when new pages are allocated by the cache.
 *
 */
struct kmem_cache * kmem_cache_create(const char *name, size_t size, size_t align,
                                      unsigned long flags, void (*ctor)(void *))

缓存是通过分配空闲页面帧(来自伙伴系统)创建的,并且指定大小的数据对象(第二个参数)会被填充。尽管每个缓存在创建时都会托管固定数量的数据对象,但在需要时它们可以动态增长以容纳更多的数据对象。数据结构可能会很复杂(我们遇到了一些),并且可能包含各种元素,如列表头、子对象、数组、原子计数器、位字段等。设置每个对象可能需要将其所有字段初始化为默认状态;这可以通过分配给*ctor函数指针(最后一个参数)的初始化程序来实现。初始化程序会在分配每个新对象时调用,无论是在缓存创建时还是在增长以添加更多空闲对象时。然而,对于简单的对象,可以创建一个没有初始化程序的缓存。

kmem_cache_create():
/* net/core/skbuff.c */

struct kmem_cache *skbuff_head_cache;
skbuff_head_cache = kmem_cache_create("skbuff_head_cache",sizeof(struct sk_buff), 0, 
                                       SLAB_HWCACHE_ALIGN|SLAB_PANIC, NULL);                                                                                                   

标志用于启用调试检查,并通过将对象与硬件缓存对齐来增强对缓存的访问操作的性能。支持以下标志常量:

 SLAB_CONSISTENCY_CHECKS /* DEBUG: Perform (expensive) checks o alloc/free */
 SLAB_RED_ZONE /* DEBUG: Red zone objs in a cache */
 SLAB_POISON /* DEBUG: Poison objects */
 SLAB_HWCACHE_ALIGN  /* Align objs on cache lines */
 SLAB_CACHE_DMA  /* Use GFP_DMA memory */
 SLAB_STORE_USER  /* DEBUG: Store the last owner for bug hunting */
 SLAB_PANIC  /* Panic if kmem_cache_create() fails */

随后,对象可以通过相关函数进行分配和释放。释放后,对象将放回到cache的空闲列表中,使其可以重新使用;这可能会带来性能提升,特别是当对象是缓存热点时。

/**
 * kmem_cache_alloc - Allocate an object
 * @cachep: The cache to allocate from.
 * @flags: GFP mask.
 *
 * Allocate an object from this cache. The flags are only relevant
 * if the cache has no available objects.
 */
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);

/**
 * kmem_cache_alloc_node - Allocate an object on the specified node
 * @cachep: The cache to allocate from.
 * @flags: GFP mask.
 * @nodeid: node number of the target node.
 *
 * Identical to kmem_cache_alloc but it will allocate memory on the given
 * node, which can improve the performance for cpu bound structures.
 *
 * Fallback to other node is possible if __GFP_THISNODE is not set.
 */
void *kmem_cache_alloc_node(struct kmem_cache *cachep, gfp_t flags, int nodeid); /**
 * kmem_cache_free - Deallocate an object
 * @cachep: The cache the allocation was from.
 * @objp: The previously allocated object.
 *
 * Free an object which was previously allocated from this
 * cache.
 */ void kmem_cache_free(struct kmem_cache *cachep, void *objp);

当所有托管的数据对象都是free(未使用)时,可以通过调用kmem_cache_destroy()来销毁 kmem 缓存。

缓存管理

所有 slab 缓存都由slab 核心在内部管理,这是一个低级算法。它定义了描述每个缓存列表的物理布局的各种控制结构,并实现了由接口例程调用的核心缓存管理操作。slab 分配器最初是在 Solaris 2.4 内核中实现的,并且被大多数其他*nix 内核使用,基于 Bonwick 的一篇论文。

传统上,Linux 被用于具有中等内存的单处理器桌面和服务器系统,并且内核采用了 Bonwick 的经典模型,并进行了适当的性能改进。多年来,由于 Linux 内核被移植和使用的平台多样性,对于所有需求来说,slab 核心算法的经典实现都是低效的。虽然内存受限的嵌入式平台无法承受分配器的更高占用空间(用于管理元数据和分配器操作的密度),但具有大内存的 SMP 系统需要一致的性能、可伸缩性,并且需要更好的机制来生成分配的跟踪和调试信息。

为了满足这些不同的要求,当前版本的内核提供了 slab 算法的三种不同实现:slob,一个经典的 K&R 类型的链表分配器,设计用于内存稀缺的低内存系统,并且在 Linux 的最初几年(1991-1999)是默认的对象分配器;slab,一个经典的 Solaris 风格的 slab 分配器,自 1999 年以来一直存在于 Linux 中;以及slub,针对当前一代 SMP 硬件和巨大内存进行了改进,并提供了更好的控制和调试机制。大多数架构的默认内核配置都将slub作为默认的 slab 分配器;这可以在内核构建过程中通过内核配置选项进行更改。

CONFIG_SLAB:常规的 slab 分配器在所有环境中都已经建立并且运行良好。它将缓存热对象组织在每个 CPU 和每个节点队列中。

CONFIG_SLUBSLUB是一个最小化缓存行使用而不是管理缓存对象队列(SLAB 方法)的 slab 分配器。使用对象的 slab 而不是对象队列来实现每个 CPU 的缓存。SLUB 可以高效地使用内存,并具有增强的诊断功能。SLUB 是 slab 分配器的默认选择。

CONFIG_SLOBSLOB用一个极其简化的分配器替换了原始的分配器。SLOB 通常更节省空间,但在大型系统上的性能不如原始分配器。

无论选择了哪种分配器,编程接口都保持不变。实际上,在低级别,所有三种分配器共享一些公共代码基础:

我们现在将研究cache的物理布局及其控制结构。

缓存布局 - 通用

每个缓存都由一个缓存描述符结构kmem_cache表示;这个结构包含了缓存的所有关键元数据。它包括一个 slab 描述符列表,每个描述符承载一个页面或一组页面帧。slab 下的页面包含对象或内存块,这些是缓存的分配单元。slab 描述符指向页面中包含的对象列表并跟踪它们的状态。根据它承载的对象的状态,一个 slab 可能处于三种可能的状态之一--满的、部分的或空的。当一个 slab 中的所有对象都被使用并且没有剩余的自由对象可供分配时,slab被认为是full。至少有一个自由对象的 slab 被认为处于partial状态,而所有对象都处于free状态的 slab 被认为是empty

这种安排使得对象分配更快,因为分配器例程可以查找partial slab 以获取一个自由对象,并在需要时可能转移到empty slab。它还有助于通过新的页面帧扩展缓存以容纳更多对象(在需要时),并促进安全和快速的回收(empty状态的 slab 可以被回收)。

Slub 数据结构

在通用级别上查看了缓存的布局和涉及的描述符之后,让我们进一步查看slub分配器使用的特定数据结构,并探索空闲列表的管理。一个slub在内核头文件/include/linux/slub-def.h中定义了它的版本的缓存描述符struct kmem_cache

struct kmem_cache {
 struct kmem_cache_cpu __percpu *cpu_slab;
 /* Used for retriving partial slabs etc */
 unsigned long flags;
 unsigned long min_partial;
 int size; /* The size of an object including meta data */
 int object_size; /* The size of an object without meta data */
 int offset; /* Free pointer offset. */
 int cpu_partial; /* Number of per cpu partial objects to keep around */
 struct kmem_cache_order_objects oo;

 /* Allocation and freeing of slabs */
 struct kmem_cache_order_objects max;
 struct kmem_cache_order_objects min;
 gfp_t allocflags; /* gfp flags to use on each alloc */
 int refcount; /* Refcount for slab cache destroy */
 void (*ctor)(void *);
 int inuse; /* Offset to metadata */
 int align; /* Alignment */
 int reserved; /* Reserved bytes at the end of slabs */
 const char *name; /* Name (only for display!) */
 struct list_head list; /* List of slab caches */
 int red_left_pad; /* Left redzone padding size */
 ...
 ...
 ...
 struct kmem_cache_node *node[MAX_NUMNODES];
};

list元素指的是一个 slab 缓存列表。当分配一个新的 slab 时,它被存储在缓存描述符的列表中,并被认为是empty,因为它的所有对象都是free并且可用的。在分配对象后,slab 变为partial状态。部分 slab 是分配器需要跟踪的唯一类型的 slab,并且在kmem_cache结构内部的列表中连接在一起。SLUB分配器对已分配所有对象的full slabs 或对象都是freeempty slabs 没有兴趣。SLUB通过struct kmem_cache_node[MAX_NUMNODES]类型的指针数组来跟踪每个节点的partial slabs,这个数组封装了partial slabs 的列表。

struct kmem_cache_node {
 spinlock_t list_lock;
 ...
 ...
#ifdef CONFIG_SLUB
 unsigned long nr_partial;
 struct list_head partial;
#ifdef CONFIG_SLUB_DEBUG
 atomic_long_t nr_slabs;
 atomic_long_t total_objects;
 struct list_head full;
#endif
#endif 
};

slab 中的所有free对象形成一个链表;当分配请求到达时,从列表中移除第一个空闲对象,并将其地址返回给调用者。通过链表跟踪空闲对象需要大量的元数据;而传统的SLAB分配器在 slab 头部维护了所有 slab 页面的元数据(导致数据对齐问题),SLUB通过将更多字段塞入页面描述符结构中,从而消除了 slab 头部的元数据,为 slab 中的页面维护每页元数据。SLUB页面描述符中的元数据元素仅在相应页面是 slab 的一部分时才有效。用于 slab 分配的页面已设置PG_slab标志。

以下是与 SLUB 相关的页面描述符的字段:

struct page {
      ...
      ...
     union {
      pgoff_t index; /* Our offset within mapping. */
      void *freelist; /* sl[aou]b first free object */
   };
     ...
     ...
   struct {
          union {
                  ...
                   struct { /* SLUB */
                          unsigned inuse:16;
                          unsigned objects:15;
                          unsigned frozen:1;
                     };
                   ...
                };
               ...
          };
     ...
     ...
       union {
             ...
             ...
             struct kmem_cache *slab_cache; /* SL[AU]B: Pointer to slab */
         };
    ...
    ...
};

freelist指针指向列表中的第一个空闲对象。每个空闲对象由一个包含指向列表中下一个空闲对象的指针的元数据区域组成。index保存到第一个空闲对象的元数据区域的偏移量(包含指向下一个空闲对象的指针)。最后一个空闲对象的元数据区域将包含下一个空闲对象指针设置为 NULL。inuse包含分配对象的总数,objects包含对象的总数。frozen是一个标志,用作页面锁定:如果页面被 CPU 核心冻结,只有该核心才能从页面中检索空闲对象。slab_cache是指向当前使用该页面的 kmem 缓存的指针:

当分配请求到达时,通过freelist指针找到第一个空闲对象,并通过将其地址返回给调用者来从列表中移除它。inuse计数器也会递增,以指示分配对象的数量增加。然后,freelist指针将更新为列表中下一个空闲对象的地址。

为了实现增强的分配效率,每个 CPU 被分配一个私有的活动 slab 列表,其中包括每种对象类型的部分/空闲 slab 列表。这些 slab 被称为 CPU 本地 slab,并由 struct kmem_cache_cpu跟踪:

struct kmem_cache_cpu {
     void **freelist; /* Pointer to next available object */
     unsigned long tid; /* Globally unique transaction id */
     struct page *page; /* The slab from which we are allocating */
     struct page *partial; /* Partially allocated frozen slabs */
     #ifdef CONFIG_SLUB_STATS
        unsigned stat[NR_SLUB_STAT_ITEMS];
     #endif
};

当分配请求到达时,分配器会采用快速路径,并查看每个 CPU 缓存的freelist,然后返回空闲对象。这被称为快速路径,因为分配是通过中断安全的原子指令进行的,不需要锁竞争。当快速路径失败时,分配器会采用慢速路径,依次查看 CPU 缓存的*page**partial*列表。如果找不到空闲对象,分配器会移动到节点的partial列表;这个操作需要分配器争夺适当的排他锁。失败时,分配器从伙伴系统获取一个新的 slab。从节点列表获取或从伙伴系统获取新的 slab 都被认为是非常慢的路径,因为这两个操作都不是确定性的。

以下图表描述了 slub 数据结构和空闲列表之间的关系:

Vmalloc

页面和 slab 分配器都分配物理连续的内存块,映射到连续的内核地址空间。大多数情况下,内核服务和子系统更喜欢分配物理连续的块,以利用缓存、地址转换和其他与性能相关的好处。尽管如此,对于非常大的块的分配请求可能会因为物理内存的碎片化而失败,而且有一些情况需要分配大块,比如支持动态可加载模块、交换管理操作、大文件缓存等等。

作为解决方案,内核提供了vmalloc,这是一种分段内存分配器,通过虚拟连续地址空间将物理分散的内存区域连接起来进行内存分配。内核段内保留了一系列虚拟地址用于 vmalloc 映射,称为 vmalloc 地址空间。通过 vmalloc 接口可以映射的总内存量取决于 vmalloc 地址空间的大小,这由特定于架构的内核宏VMALLOC_STARTVMALLOC_END定义;对于 x86-64 系统,vmalloc 地址空间的总范围达到了惊人的 32 TB。然而,另一方面,这个范围对于大多数 32 位架构来说太小了(只有 120 MB)。最近的内核版本使用 vmalloc 范围来设置虚拟映射的内核栈(仅限 x86-64),这是我们在第一章中讨论过的。

以下是 vmalloc 分配和释放的接口例程:

/**
  * vmalloc  -  allocate virtually contiguous memory
  * @size:   -  allocation size
  * Allocate enough pages to cover @size from the page level
  * allocator and map them into contiguous kernel virtual space.
  *
  */
    void *vmalloc(unsigned long size) 
/**
  * vzalloc - allocate virtually contiguous memory with zero fill
1 * @size:  allocation size
  * Allocate enough pages to cover @size from the page level
  * allocator and map them into contiguous kernel virtual space.
  * The memory allocated is set to zero.
  *
  */
 void *vzalloc(unsigned long size) 
/**
  * vmalloc_user - allocate zeroed virtually contiguous memory for userspace
  * @size: allocation size
  * The resulting memory area is zeroed so it can be mapped to userspace
  * without leaking data.
  */
    void *vmalloc_user(unsigned long size) /**
  * vmalloc_node  -  allocate memory on a specific node
  * @size:          allocation size
  * @node:          numa node
  * Allocate enough pages to cover @size from the page level
  * allocator and map them into contiguous kernel virtual space.
  *
  */
    void *vmalloc_node(unsigned long size, int node) /**
  * vfree  -  release memory allocated by vmalloc()
  * @addr:          memory base address
  * Free the virtually continuous memory area starting at @addr, as
  * obtained from vmalloc(), vmalloc_32() or __vmalloc(). If @addr is
  * NULL, no operation is performed.
  */
 void vfree(const void *addr) /**
  * vfree_atomic  -  release memory allocated by vmalloc()
  * @addr:          memory base address
  * This one is just like vfree() but can be called in any atomic context except NMIs.
  */
    void vfree_atomic(const void *addr)

大多数内核开发人员避免使用 vmalloc 分配,因为分配开销较大(因为它们不是身份映射的,并且需要特定的页表调整,导致 TLB 刷新)并且在访问操作期间涉及性能惩罚。

连续内存分配器(CMA)

尽管存在较大的开销,但虚拟映射的分配在很大程度上解决了大内存分配的问题。然而,有一些情况需要物理连续缓冲区的分配。DMA 传输就是这样一种情况。设备驱动程序经常需要物理连续缓冲区的分配(用于设置 DMA 传输),这是通过之前讨论过的任何一个物理连续分配器来完成的。

然而,处理特定类别设备的驱动程序,如多媒体,经常发现自己在搜索大块连续内存。为了实现这一目标,多年来,这些驱动程序一直通过内核参数mem在系统启动时保留内存,这允许在驱动程序运行时设置足够的连续内存,并且可以在线性地址空间中重新映射。尽管有价值,这种策略也有其局限性:首先,当相应设备未启动访问操作时,这些保留内存暂时未被使用,其次,根据需要支持的设备数量,保留内存的大小可能会大幅增加,这可能会严重影响系统性能,因为物理内存被挤压。

连续内存分配器CMA)是一种内核机制,用于有效管理保留内存。CMA的核心是将保留内存引入分配器算法中,这样的内存被称为CMA 区域。CMA允许从CMA 区域为设备和系统的使用进行分配。这是通过为保留内存中的页面构建页面描述符列表,并将其列入伙伴系统来实现的,这使得可以通过页面分配器为常规需求(内核子系统)和通过 DMA 分配例程为设备驱动程序分配CMA 页面

然而,必须确保 DMA 分配不会因为 CMA 页面用于其他目的而失败,这是通过migratetype属性来处理的,我们之前讨论过。CMA 列举的页面被分配给伙伴系统的MIGRATE_CMA属性,表示页面是可移动的。在为非 DMA 目的分配内存时,页面分配器只能使用 CMA 页面进行可移动分配(回想一下,这样的分配可以通过__GFP_MOVABLE标志进行)。当 DMA 分配请求到达时,内核分配的 CMA 页面会从保留区域中移出(通过页面迁移机制),从而为设备驱动程序的使用提供内存。此外,当为 DMA 分配页面时,它们的migratetypeMIGRATE_CMA更改为MIGRATE_ISOLATE,使它们对伙伴系统不可见。

CMA 区域的大小可以在内核构建过程中通过其配置界面进行选择;可选地,也可以通过内核参数cma=进行传递。

摘要

我们已经穿越了 Linux 内核最关键的一个方面,理解了内存表示和分配的各种微妙之处。通过理解这个子系统,我们也简洁地捕捉到了内核的设计才能和实现效率,更重要的是理解了内核在容纳更精细和更新的启发式和机制以持续增强方面的动态性。除了内存管理的具体细节,我们还评估了内核在最大化资源利用方面的效率,引领了所有经典的代码重用机制和模块化代码结构。

尽管内存管理的具体细节可能会根据底层架构而有所不同,但设计和实现风格的一般性大部分仍然保持一致,以实现代码稳定性和对变化的敏感性。

在下一章中,我们将进一步探讨内核的另一个基本抽象:文件。我们将浏览文件 I/O 并探索其架构和实现细节。

第五章:文件系统和文件 I/O

到目前为止,我们已经遍历了内核的基本资源,如地址空间、处理器时间和物理内存。我们已经建立了对进程管理CPU 调度内存管理的实证理解,以及它们提供的关键抽象。在本章中,我们将继续建立我们的理解,通过查看内核提供的另一个关键抽象,即文件 I/O 架构。我们将详细讨论以下方面:

  • 文件系统实现

  • 文件 I/O

  • 虚拟文件系统(VFS)

  • VFS 数据结构

  • 特殊文件系统

计算系统存在的唯一目的是处理数据。大多数算法都是设计和编程用来从获取的数据中提取所需的信息。驱动这一过程的数据必须持久地存储以便持续访问,这要求存储系统被设计为安全地包含信息以供更长时间的存储。然而,对于用户来说,是操作系统从这些存储设备中获取数据并使其可用于处理。内核的文件系统就是实现这一目的的组件。

文件系统 - 高层视图

文件系统将存储设备的物理视图与用户分离,并通过抽象容器文件和目录在磁盘上为系统的每个有效用户虚拟化存储区域。文件用作用户数据的容器,目录用作一组用户文件的容器。简单来说,操作系统为每个用户虚拟化存储设备的视图,以一组目录和文件的形式呈现。文件系统服务实现了创建、组织、存储和检索文件的例程,这些操作是由用户应用程序通过适当的系统调用接口调用的。

我们将从查看一个简单文件系统的布局开始,该文件系统设计用于管理标准磁存储盘。这个讨论将帮助我们理解与磁盘管理相关的关键术语和概念。然而,典型的文件系统实现涉及适当的数据结构,描述磁盘上文件数据的组织,以及使应用程序执行文件 I/O 的操作。

元数据

存储磁盘通常由相同大小的物理块组成,称为扇区;扇区的大小通常为 512 字节或其倍数,取决于存储类型和容量。扇区是磁盘上的最小 I/O 单元。当磁盘被呈现给文件系统进行管理时,它将存储区域视为一组固定大小的,其中每个块与扇区或扇区大小的倍数相同。典型的默认块大小为 1024 字节,可以根据磁盘容量和文件系统类型而变化。块大小被认为是文件系统的最小 I/O 单元:

索引节点(inode)

文件系统需要维护元数据来识别和跟踪用户创建的每个文件和目录的各种属性。有几个元数据元素描述了一个文件,如文件名、文件类型、最后访问时间戳、所有者、访问权限、最后修改时间戳、创建时间、文件数据大小以及包含文件数据的磁盘块的引用。传统上,文件系统定义了一个称为 inode 的结构来包含文件的所有元数据。inode 中包含的信息的大小和类型是特定于文件系统的,并且根据其支持的功能而大不相同。每个 inode 都由一个称为索引的唯一编号来标识,这被认为是文件的低级名称:

文件系统为存储 inode 实例保留了一些磁盘块,其余用于存储相应的文件数据。为存储 inode 保留的块数取决于磁盘的存储容量。inode 块中保存的节点的磁盘列表称为inode 表。文件系统需要跟踪 inode 和数据块的状态以识别空闲块。这通常通过位图来实现,一个用于跟踪空闲 inode,另一个用于跟踪空闲数据块。以下图表显示了带有位图、inode 和数据块的典型布局:

数据块映射

如前所述,每个 inode 都应记录相应文件数据存储在其中的数据块的位置。根据文件数据的长度,每个文件可能占用n个数据块。有各种方法用于跟踪 inode 中的数据块详细信息;最简单的是直接引用,它涉及 inode 包含指向文件数据块的直接指针。这种直接指针的数量取决于文件系统设计,大多数实现选择使用更少的字节来存储这些指针。这种方法对于跨越几个数据块(通常<16k)的小文件非常有效,但不支持跨越大量数据块的大文件:

为了支持大文件,文件系统采用了一种称为多级索引的替代方法,其中包括间接指针。最简单的实现方式是在 inode 结构中有一个间接指针以及一些直接指针。间接指针指的是一个包含文件数据块的直接指针的块。当文件变得太大而无法通过 inode 的直接指针引用时,会使用一个空闲数据块来存储直接指针,并将 inode 的间接指针引用到它。间接指针引用的数据块称为间接块。间接块中直接指针的数量可以通过块大小除以块地址的大小来确定;例如,在 32 位文件系统上,每个间接块最多可以包含 256 个条目,而在 64 位文件系统上,每个间接块最多可以包含 128 个直接指针:

这种技术可以进一步扩展以支持更大的文件,方法是使用双重间接指针,它指的是一个包含间接指针的块,每个条目都指向一个包含直接指针的块。假设一个 64 位文件系统,块大小为 1024,每个块可以容纳 128 个条目,那么将有 128 个间接指针,每个指向一个包含 128 个直接指针的块;因此,通过这种技术,文件系统可以支持一个跨越多达 16,384(128 x 128)个数据块的文件,即 16 MB。

此外,这种技术可以通过三级间接指针进行扩展,从而需要文件系统管理更多的元数据。然而,尽管存在多级索引,但随着文件系统块大小的增加和块地址大小的减小,这是支持更大文件的最推荐和有效的解决方案。用户在初始化带有文件系统的磁盘时需要选择适当的块大小,以确保对更大文件的正确支持。

一些文件系统使用称为范围的不同方法来存储 inode 中的数据块信息。范围是一个指针,它指向开始数据块(类似于直接指针),并添加长度位,指定存储文件数据的连续块的计数。根据文件大小和磁盘碎片化水平,单个范围可能不足以引用文件的所有数据块,为处理这种情况,文件系统构建范围列表,每个范围引用磁盘上一个连续数据块区域的起始地址和长度。

扩展方法减少了文件系统需要管理的元数据的数量,以存储数据块映射,但这是以文件系统操作的灵活性为代价实现的。例如,考虑要在大文件的特定文件位置执行读取操作:为了定位指定文件偏移位置的数据块,文件系统必须从第一个范围开始,并扫描列表,直到找到覆盖所需文件偏移的范围。

目录

文件系统将目录视为特殊文件。它们用磁盘上的 inode 表示目录或文件夹。它们通过类型字段与普通文件 inode 区分开来,该字段标记为目录。每个目录都分配了数据块,其中包含有关其包含的文件和子目录的信息。目录维护文件的记录,每个记录包括文件名,这是一个名字字符串,不超过文件系统命名策略定义的特定长度,以及与文件关联的 inode 号。为了有效管理,文件系统实现通过适当的数据结构(如二叉树、列表、基数树和哈希表)定义目录中包含的文件记录的布局:

超级块

除了存储捕获各个文件元数据的 inode 之外,文件系统还需要维护有关整个磁盘卷的元数据,例如卷的大小、总块数、文件系统的当前状态、inode 块数、inode 数、数据块数、起始 inode 块号和文件系统签名(魔术数字)以进行身份验证。这些详细信息在一个称为超级块的数据结构中捕获。在磁盘卷上初始化文件系统期间,超级块被组织在磁盘存储的开始处。以下图示了带有超级块的磁盘存储的完整布局:

操作

虽然数据结构构成了文件系统设计的基本组成部分,但对这些数据结构可能进行的操作以实现文件访问和操作操作是核心功能集。支持的操作数量和功能类型是特定于文件系统实现的。以下是大多数文件系统提供的一些常见操作的通用描述。

挂载和卸载操作

挂载是将磁盘上的超级块和元数据枚举到内存中供文件系统使用的操作。此过程创建描述文件元数据的内存数据结构,并向主机操作系统呈现卷中目录和文件布局的视图。挂载操作被实现为检查磁盘卷的一致性。如前所述,超级块包含文件系统的状态;它指示卷是一致还是。如果卷是干净或一致的,挂载操作将成功,如果卷被标记为脏或不一致,它将返回适当的失败状态。

突然的关机会导致文件系统状态变得脏乱,并需要在可以再次标记为可用之前进行一致性检查。用于一致性检查的机制是复杂且耗时的;这些操作是特定于文件系统实现的,并且大多数简单的实现提供了特定的工具来进行一致性检查,而其他现代实现则使用了日志记录。

卸载是将文件系统数据结构的内存状态刷新回磁盘的操作。此操作导致所有元数据和文件缓存与磁盘块同步。卸载将文件系统状态标记为一致,表示优雅的关闭。换句话说,直到执行卸载操作,磁盘上的超级块状态仍然是脏的。

文件创建和删除操作

创建文件是一个需要实例化具有适当属性的新 inode 的操作。用户程序使用选择的属性(如文件名、要创建文件的目录、各种用户的访问权限和文件模式)调用文件创建例程。此例程还初始化 inode 的其他特定字段,如创建时间戳和文件所有权信息。此操作将新的文件记录写入目录块,描述文件名和 inode 号。

当用户应用程序对有效文件启动“删除”操作时,文件系统会从目录中删除相应的文件记录,并检查文件的引用计数以确定当前使用文件的进程数。从目录中删除文件记录会阻止其他进程打开标记为删除的文件。当所有对文件的当前引用都关闭时,通过将其数据块返回到空闲数据块列表和 inode 返回到空闲 inode 列表来释放分配给文件的所有资源。

文件打开和关闭操作

当用户进程尝试打开一个文件时,它使用文件系统的“打开”操作和适当的参数,包括文件的路径和名称。文件系统遍历路径中指定的目录,直到它到达包含所请求文件记录的直接父目录。查找文件记录产生了指定文件的 inode 号。然而,查找操作的具体逻辑和效率取决于特定文件系统实现选择的用于组织目录块中文件记录的数据结构。

一旦文件系统检索到文件的相关 inode 号,它会启动适当的健全性检查来强制执行对调用上下文的访问控制验证。如果调用进程被授权访问文件,文件系统会实例化一个称为“文件描述符”的内存结构,以维护文件访问状态和属性。成功完成后,打开操作将文件描述符结构的引用返回给调用进程,这将作为调用进程启动其他文件操作(如“读取”、“写入”和“关闭”)的句柄。

在启动“关闭”操作时,文件描述符结构被销毁,文件的引用计数被减少。调用进程将无法再启动任何其他文件操作,直到它可以重新打开文件。

文件读写操作

当用户应用程序使用适当的参数启动对文件的读取时,底层文件系统的“读取”例程会被调用。操作从文件的数据块映射中查找适当的数据磁盘扇区以进行读取;然后它从页面缓存中分配一个页面并安排磁盘 I/O。在 I/O 传输完成后,文件系统将请求的数据移入应用程序的缓冲区并更新调用者文件描述符结构中的文件偏移位置。

同样,文件系统的“写”操作从用户缓冲区中检索数据,并将其写入页面缓存中文件缓冲区的适当偏移量,并标记页面为PG*_*dirty标志。然而,当“写”操作被调用以在文件末尾追加数据时,文件可能需要新的数据块来增长。文件系统在磁盘上寻找空闲数据块,并为该文件分配这些数据块,然后进行操作。分配新的数据块需要更改索引节点结构的数据块映射,并分配新页面(从页面缓存映射到新分配的数据块)。

附加功能

尽管文件系统的基本组件保持相似,但数据组织方式和访问数据的启发式方法取决于实现。设计者考虑因素,如可靠性安全性存储容量的类型容量,以及I/O 效率,以识别和支持增强文件系统功能的特性。以下是现代文件系统支持的一些扩展功能。

扩展文件属性

文件系统实现跟踪的一般文件属性保存在索引节点中,并由适当的操作进行解释。扩展文件属性是一项功能,使用户能够为文件定义文件系统不解释的自定义元数据。这些属性通常用于存储各种类型的信息,这些信息取决于文件包含的数据类型。例如,文档文件可以定义作者姓名和联系方式,Web 文件可以指定文件的 URL 和其他安全相关属性,如数字证书和加密哈希密钥。与普通属性类似,每个扩展属性都由名称标识。理想情况下,大多数文件系统不会对此类扩展属性的数量施加限制。

一些文件系统还提供了索引属性的功能,这有助于快速查找所需类型的数据,而无需导航文件层次结构。例如,假设文件被分配了一个名为Keywords*的扩展属性,记录描述文件数据的关键字值。通过索引,用户可以发出查询,通过适当的脚本找到匹配特定关键字的文件列表,而不管文件的位置如何。因此,索引为文件系统提供了一个强大的替代界面。

文件系统的一致性和崩溃恢复

磁盘映像的一致性对文件系统的可靠运行至关重要。当文件系统正在更新其磁盘结构时,很有可能会发生灾难性错误(断电、操作系统崩溃等),导致部分提交的关键更新中断。这会导致磁盘结构损坏,并使文件系统处于不一致状态。通过采用有效的崩溃恢复策略来处理这种情况,是大多数文件系统设计者面临的主要挑战之一。

一些文件系统通过专门设计的文件系统一致性检查工具(如广泛使用的 Unix 工具 fsck)处理崩溃恢复。它在挂载之前在系统启动时运行,并扫描磁盘上的文件系统结构,寻找不一致之处,并在找到时修复它们。完成后,磁盘上的文件系统状态将恢复到一致状态,并且系统将继续进行mount操作,从而使磁盘对用户可访问。该工具在许多阶段执行其操作,密切检查每个磁盘结构的一致性,如超级块、inode 块、空闲块,在每个阶段检查单个 inode 的有效状态、目录检查和坏块检查。尽管它提供了必要的崩溃恢复,但它也有其缺点:这些分阶段的操作可能会消耗大量时间来完成对大容量磁盘的操作,这直接影响系统的启动时间。

日志是大多数现代文件系统实现采用的另一种技术,用于快速和可靠的崩溃恢复。这种方法是通过为崩溃恢复编程适当的文件系统操作来实施的。其思想是准备一个列出要提交到文件系统的磁盘映像的更改的日志(注意),并将日志写入一个称为日志块的特殊磁盘块,然后开始实际的更新操作。这确保在实际更新期间发生崩溃时,文件系统可以轻松地检测到不一致之处,并通过查看日志中记录的信息来修复它们。因此,日志文件系统的实现通过在更新期间边缘地扩展工作来消除了对磁盘扫描的繁琐和昂贵的任务。

访问控制列表(ACL)

默认文件和目录访问权限指定了所有者、所有者所属的组和其他用户的访问权限,但在某些情况下并不能提供所需的细粒度控制。ACL 是一种功能,它可以为各种进程和用户指定文件访问权限的扩展机制。此功能将所有文件和目录视为对象,并允许系统管理员为每个对象定义访问权限列表。ACL 包括对具有访问权限的对象的操作,以及对指定对象上的每个用户和系统进程的限制。

Linux 内核中的文件系统

现在我们熟悉了与文件系统实现相关的基本概念,我们将探讨 Linux 系统支持的文件系统服务。内核的文件系统分支具有许多文件系统服务的实现,支持各种文件类型。根据它们管理的文件类型,内核的文件系统可以被广泛分类为:

  1. 存储文件系统

  2. 特殊文件系统

  3. 分布式文件系统或网络文件系统

我们将在本章的后面部分讨论特殊文件系统。

  • 存储文件系统:内核支持各种持久存储文件系统,可以根据它们设计用于管理的存储设备类型进行广泛分类。

  • 磁盘文件系统:此类别包括内核支持的各种标准存储磁盘文件系统,包括 Linux 本机 ext 系列磁盘文件系统,如 Ext2、Ext3、Ext4、ReiserFS 和 Btrfs;类 Unix 变体,如 sysv 文件系统、UFS 和 MINIX 文件系统;微软文件系统,如 MS-DOS、VFAT 和 NTFS;其他专有文件系统,如 IBM 的 OS/2(HPFS)、基于 Qnx 的文件系统,如 qnx4 和 qnx6,苹果的 Macintosh HFS 和 HFS2,Amiga 的快速文件系统(AFFS)和 Acorn 磁盘文件系统(ADFS);以及 IBM 的 JFS 和 SGI 的 XFS 等日志文件系统。

  • 可移动媒体文件系统:此类别包括为 CD、DVD 和其他可移动存储介质设备设计的文件系统,如 ISO9660 CD-ROM 文件系统和通用磁盘格式(UDF)DVD 文件系统,以及用于 Linux 发行版的 live CD 映像中使用的 squashfs。

  • 半导体存储文件系统:此类别包括为原始闪存和其他需要支持磨损平衡和擦除操作的半导体存储设备设计和实现的文件系统。当前支持的文件系统包括 UBIFS、JFFS2、CRAMFS 等。

我们将简要讨论内核中几种本机磁盘文件系统,这些文件系统在 Linux 的各个发行版中作为默认文件系统使用。

Ext 家族文件系统

Linux 内核的初始版本使用 MINIX 作为默认的本机文件系统,它是为教育目的而设计用于 Minix 内核,因此有许多使用限制。随着内核的成熟,内核开发人员构建了一个用于磁盘管理的新本机文件系统,称为扩展文件系统. ext的设计受到标准 Unix 文件系统 UFS 的重大影响。由于各种实现限制和缺乏效率,原始的 ext 寿命很短,很快被一个改进的、稳定的、高效的版本所取代,名为第二扩展文件系统Ext2. Ext2 文件系统在相当长的一段时间内一直是默认的本机文件系统(直到 2001 年,Linux 内核的 2.4.15 版本)。

随后,磁盘存储技术的快速发展大大增加了存储容量和存储硬件的效率。为了利用存储硬件提供的功能,内核社区发展了ext2的分支,进行了适当的设计改进,并添加了最适合特定存储类别的功能。当前的 Linux 内核版本包含三个扩展文件系统的版本,称为 Ext2、Ext3 和 Ext4。

Ext2

Ext2 文件系统首次出现在内核版本 0.99.7(1993 年)中。它保留了经典 UFS(Unix 文件系统)的核心设计,具有写回缓存,可以实现短的周转时间和改进的性能。尽管它被实现为支持 2 TB 到 32 TB 范围内的磁盘卷和 16 GB 到 2 TB 范围内的文件大小,但由于 2.4 内核中的块设备和应用程序施加的限制,其使用仅限于最多 4 TB 的磁盘卷和最大 2 GB 的文件大小。它还包括对 ACL、文件内存映射和通过一致性检查工具 fsck 进行崩溃恢复的支持。Ext2 将物理磁盘扇区划分为固定大小的块组。为每个块组构建文件系统布局,每个块组都有一个完整的超级块、空闲块位图、inode 位图、inode 和数据块。因此,每个块组都像一个微型文件系统。这种设计有助于fsck在大型磁盘上进行更快的一致性检查。

Ext3

也称为第三扩展文件系统,它通过日志记录扩展了 Ext2 的功能。它保留了 Ext2 的整个结构,包括块组,这使得可以无缝地将 Ext2 分区转换为 Ext3 类型。如前所述,日志记录会导致文件系统将更新操作的详细信息记录到磁盘的特定区域,称为日志块;这些日志有助于加快崩溃恢复,并确保文件系统的一致性和可靠性。然而,在具有日志记录的文件系统上,由于较慢或可变时间的写操作(由于日志记录),磁盘更新操作可能变得昂贵,这将直接影响常规文件 I/O 的性能。作为解决方案,Ext3 提供了日志配置选项,通过这些选项,系统管理员或用户可以选择要记录到日志的特定类型的信息。这些配置选项称为日志模式

  1. 日志模式:此模式导致文件系统将文件数据和元数据更改记录到日志中。这会导致文件系统一致性最大化,但会导致磁盘访问增加,从而导致更新速度变慢。此模式会导致日志消耗额外的磁盘块,是最慢的 Ext3 日志模式。

  2. 有序模式:此模式仅将文件系统元数据记录到日志中,但它保证相关文件数据在提交到日志块之前写入磁盘。这确保文件数据是有效的;如果在执行对文件的写入时发生崩溃,日志将指示附加的数据尚未提交,导致清理过程对此类数据进行清除。这是 Ext3 的默认日志模式。

  3. 写回模式:这类似于有序模式,只进行元数据日志记录,但有一个例外,即相关文件内容可能在提交到日志之前或之后写入磁盘。这可能导致文件数据损坏。例如,考虑正在追加的文件可能在日志中标记为已提交,然后进行实际文件写入:如果在文件追加操作期间发生崩溃,那么日志会建议文件比实际大小要大。这种模式速度最快,但最大程度地减少了文件数据的可靠性。许多其他日志文件系统(如 JFS)使用这种日志模式,但确保任何由于未写入数据而产生的垃圾在重新启动时被清零。

所有这些模式在元数据一致性方面具有类似的效果,但在文件和目录数据的一致性方面有所不同,日志模式确保最大安全性,最小的文件数据损坏风险,而写回模式提供最小的安全性,但存在高风险的损坏。管理员或用户可以在挂载 Ext3 卷时调整适当的模式。

Ext4

作为对具有增强功能的 Ext3 的替代实现,Ext4 首次出现在内核 2.6.28(2008)中。它与 Ext2 和 Ext3 完全向后兼容,可以将任一类型的卷挂载为 Ext4。这是大多数当前 Linux 发行版上的默认 ext 文件系统。它通过日志校验和扩展了 Ext3 的日志功能,增加了其可靠性。它还为文件系统元数据添加了校验和,并支持透明加密,从而增强了文件系统的完整性和安全性。其他功能包括支持范围,有助于减少碎片化,磁盘块的持久性预分配,可以为媒体文件分配连续的块,以及支持存储容量高达 1 艾比特(EiB)和文件大小高达 16 泰比特(TiB)的磁盘卷。

常见文件系统接口

存在多种文件系统和存储分区导致每个文件系统维护其文件树和数据结构,这些结构与其他文件系统不同。在挂载时,每个文件系统将需要独立管理其内存中的文件树,与其他文件系统隔离,从而为系统用户和应用程序提供文件树的不一致视图。这使得内核对各种文件操作(如打开、读取、写入、复制和移动)的支持变得复杂。作为解决方案,Linux 内核(与许多其他 Unix 系统一样)使用了一个称为虚拟文件系统(VFS)的抽象层,它隐藏了所有文件系统实现,并提供了一个通用接口。

VFS 层构建了一个称为rootfs的通用文件树,在此之下,所有文件系统都可以列举其目录和文件。这使得所有特定于文件系统的子树都可以统一并呈现为单个文件系统。系统用户和应用程序对文件树有一致的、统一的视图,从而使内核能够为应用程序提供一组简化的常见系统调用,用于文件 I/O,而不考虑底层文件系统及其表示。这种模型确保了应用程序设计的简单性,因为 API 有限且灵活,并且可以无缝地从一个磁盘分区或文件系统树复制或移动文件到另一个,而不考虑底层的差异。

以下图表描述了虚拟文件系统:

VFS 定义了两组函数:首先是一组通用的与文件系统无关的例程,用作所有文件访问和操作操作的通用入口函数,其次是一组抽象操作接口,这些接口是特定于文件系统的。每个文件系统定义其操作(根据其文件和目录的概念)并将它们映射到提供的抽象接口,并且通过虚拟文件系统,这使得 VFS 能够通过动态切换到底层文件系统特定函数来处理文件 I/O 请求。

VFS 结构和操作

解密 VFS 的关键对象和数据结构让我们清楚地了解 VFS 如何与文件系统内部工作,并实现了至关重要的抽象。以下是围绕整个抽象网络编织的四个基本数据结构:

  • struct super_block--包含已挂载的特定文件系统的信息

  • struct inode--代表特定文件

  • struct dentry--代表目录条目

  • struct file--代表已打开并链接到进程的文件

所有这些数据结构都与由文件系统定义的适当的抽象操作接口绑定。

struct superblock

VFS 通过此结构为超级块定义了通用布局。每个文件系统都需要实例化此结构的对象,在挂载期间填充其超级块详细信息。换句话说,此结构将文件系统特定的超级块与内核的其余部分抽象出来,并帮助 VFS 通过struct super_block列表跟踪所有已挂载的文件系统。没有持久超级块结构的伪文件系统将动态生成超级块。超级块结构(struct super_block)在<linux/fs.h>中定义:

struct super_block {
         struct list_head        s_list;   /* Keep this first */
         dev_t                   s_dev;    /* search index; _not_ kdev_t */
         unsigned char           s_blocksize_bits;
         unsigned long           s_blocksize;
         loff_t                  s_maxbytes;  /* Max file size */
         struct file_system_type *s_type;
         const struct super_operations   *s_op;
         const struct dquot_operations   *dq_op;
         const struct quotactl_ops       *s_qcop;
         const struct export_operations *s_export_op;
         unsigned long           s_flags;
         unsigned long           s_iflags; /* internal SB_I_* flags */
         unsigned long           s_magic;
         struct dentry           *s_root;
         struct rw_semaphore     s_umount;
         int                     s_count;
         atomic_t                s_active;
 #ifdef CONFIG_SECURITY
         void                    *s_security;
 #endif
         const struct xattr_handler **s_xattr;
         const struct fscrypt_operations *s_cop;
         struct hlist_bl_head    s_anon; 
         struct list_head        s_mounts;/*list of mounts;_not_for fs use*/ 
         struct block_device     *s_bdev;
         struct backing_dev_info *s_bdi;
         struct mtd_info         *s_mtd;
         struct hlist_node       s_instances;
         unsigned int   s_quota_types; /*Bitmask of supported quota types */
         struct quota_info  s_dquot;   /* Diskquota specific options */
         struct sb_writers       s_writers;
         char s_id[32];                          /* Informational name */
         u8 s_uuid[16];                          /* UUID */
         void                    *s_fs_info;   /* Filesystem private info */
         unsigned int            s_max_links;
         fmode_t                 s_mode;

         /* Granularity of c/m/atime in ns.
            Cannot be worse than a second */
         u32                s_time_gran;

         struct mutex s_vfs_rename_mutex;        /* Kludge */

         /*
          * Filesystem subtype.  If non-empty the filesystem type field
          * in /proc/mounts will be "type.subtype"
          */
         char *s_subtype;

         /*
          * Saved mount options for lazy filesystems using
          * generic_show_options()
          */
         char __rcu *s_options;
         const struct dentry_operations *s_d_op; /*default op for dentries*/
         /*
          * Saved pool identifier for cleancache (-1 means none)
          */
         int cleancache_poolid;

         struct shrinker s_shrink;       /* per-sb shrinker handle */

         /* Number of inodes with nlink == 0 but still referenced */
         atomic_long_t s_remove_count;

         /* Being remounted read-only */
         int s_readonly_remount;

         /* AIO completions deferred from interrupt context */
         struct workqueue_struct *s_dio_done_wq;
         struct hlist_head s_pins;

         /*
          * Owning user namespace and default context in which to
          * interpret filesystem uids, gids, quotas, device nodes,
          * xattrs and security labels.
          */
         struct user_namespace *s_user_ns;

         struct list_lru         s_dentry_lru ____cacheline_aligned_in_smp;
         struct list_lru         s_inode_lru ____cacheline_aligned_in_smp;
         struct rcu_head         rcu;
         struct work_struct      destroy_work;

         struct mutex            s_sync_lock;  /* sync serialisation lock */

         /*
          * Indicates how deep in a filesystem stack this SB is
          */
         int s_stack_depth;

         /* s_inode_list_lock protects s_inodes */
         spinlock_t              s_inode_list_lock ____cacheline_aligned_in_smp;
         struct list_head        s_inodes;       /* all inodes */

         spinlock_t              s_inode_wblist_lock;
         struct list_head        s_inodes_wb;    /* writeback inodes */
 };

超级块结构包含其他定义和扩展超级块信息和功能的结构。以下是super_block的一些元素:

  • s_liststruct list_head类型的,包含指向已挂载超级块列表的指针

  • s_dev是设备标识符

  • s_maxbytes包含最大文件大小

  • s_typestruct file_system_type类型的指针,描述了文件系统类型

  • s_opstruct super_operations类型的指针,包含对超级块的操作

  • s_export_opstruct export_operations类型的,帮助文件系统可以被远程系统访问,使用网络文件系统进行导出

  • s_rootstruct dentry类型的指针,指向文件系统根目录的 dentry 对象

每个枚举的超级块实例都包含一个指向定义超级块操作接口的函数指针抽象结构的指针。文件系统将需要实现其超级块操作并将其分配给适当的函数指针。这有助于每个文件系统根据其磁盘上超级块的布局实现超级块操作,并将该逻辑隐藏在一个公共接口下。Struct super_operations<linux/fs.h>中定义:

struct super_operations {
         struct inode *(*alloc_inode)(struct super_block *sb);
         void (*destroy_inode)(struct inode *);

         void (*dirty_inode) (struct inode *, int flags);
         int (*write_inode) (struct inode *, struct writeback_control *wbc);
         int (*drop_inode) (struct inode *);
         void (*evict_inode) (struct inode *);
         void (*put_super) (struct super_block *);
         int (*sync_fs)(struct super_block *sb, int wait);
         int (*freeze_super) (struct super_block *);
         int (*freeze_fs) (struct super_block *);
         int (*thaw_super) (struct super_block *);
         int (*unfreeze_fs) (struct super_block *);
         int (*statfs) (struct dentry *, struct kstatfs *);
         int (*remount_fs) (struct super_block *, int *, char *);
         void (*umount_begin) (struct super_block *);

         int (*show_options)(struct seq_file *, struct dentry *);
         int (*show_devname)(struct seq_file *, struct dentry *);
         int (*show_path)(struct seq_file *, struct dentry *);
         int (*show_stats)(struct seq_file *, struct dentry *);
 #ifdef CONFIG_QUOTA
         ssize_t (*quota_read)(struct super_block *, int, char *, size_t, loff_t);
         ssize_t (*quota_write)(struct super_block *, int, const char *, size_t, loff_t);
         struct dquot **(*get_dquots)(struct inode *);
 #endif
         int (*bdev_try_to_free_page)(struct super_block*, struct page*, gfp_t);
         long (*nr_cached_objects)(struct super_block *,
                                   struct shrink_control *);
         long (*free_cached_objects)(struct super_block *,
                                     struct shrink_control *);
 };

此结构中的所有元素都指向对超级块对象进行操作的函数。除非另有说明,否则所有这些操作都仅在进程上下文中调用,且不持有任何锁。让我们来看看这里的一些重要操作:

  • alloc_inode:此方法用于创建和分配新的 inode 对象的空间,并在超级块下初始化它。

  • destroy_inode:销毁给定的 inode 对象并释放为 inode 分配的资源。仅在定义了alloc_inode时使用。

  • dirty_inode:VFS 调用此函数标记脏 inode(当 inode 被修改时)。

  • write_inode:当 VFS 需要将 inode 写入磁盘时,会调用此方法。第二个参数指向struct writeback_control,这是一个告诉写回代码该做什么的结构。

  • put_super:当 VFS 需要释放超级块时调用此函数。

  • sync_fs: 用于将文件系统数据与底层块设备的数据同步。

  • statfs: 用于获取 VFS 的文件系统统计信息。

  • remount_fs: 当文件系统需要重新挂载时调用。

  • umount_begin: 当 VFS 卸载文件系统时调用。

  • show_options: 由 VFS 调用以显示挂载选项。

  • quota_read: 由 VFS 调用以从文件系统配额文件中读取。

结构 inode

每个struct inode实例都代表rootfs中的一个文件。VFS 将此结构定义为特定于文件系统的 inode 的抽象。无论 inode 结构的类型和其在磁盘上的表示如何,每个文件系统都需要将其文件枚举为rootfs中的struct inode,以获得一个通用的文件视图。此结构在<linux/fs.h>中定义:

struct inode {
      umode_t                 i_mode;
   unsigned short          i_opflags;
        kuid_t                  i_uid;
    kgid_t                  i_gid;
    unsigned int            i_flags;
#ifdef CONFIG_FS_POSIX_ACL
  struct posix_acl        *i_acl;
   struct posix_acl        *i_default_acl;
#endif
       const struct inode_operations   *i_op;
    struct super_block      *i_sb;
    struct address_space    *i_mapping;
#ifdef CONFIG_SECURITY
   void                    *i_security;
#endif
  /* Stat data, not accessed from path walking */
   unsigned long           i_ino;
    /*
         * Filesystems may only read i_nlink directly.  They shall use the
         * following functions for modification:
   *
         *    (set|clear|inc|drop)_nlink
   *    inode_(inc|dec)_link_count
   */
       union {
           const unsigned int i_nlink;
               unsigned int __i_nlink;
   };
        dev_t                   i_rdev;
   loff_t                  i_size;
   struct timespec         i_atime;
  struct timespec         i_mtime;
  struct timespec         i_ctime;
  spinlock_t              i_lock; /*i_blocks, i_bytes, maybe i_size*/
       unsigned short          i_bytes;
  unsigned int            i_blkbits;
        blkcnt_t                i_blocks;
#ifdef __NEED_I_SIZE_ORDERED
       seqcount_t              i_size_seqcount;
#endif
      /* Misc */
        unsigned long           i_state;
  struct rw_semaphore     i_rwsem;

    unsigned long           dirtied_when;/*jiffies of first dirtying */
       unsigned long           dirtied_time_when;

  struct hlist_node       i_hash;
   struct list_head        i_io_list;/* backing dev IO list */
#ifdef CONFIG_CGROUP_WRITEBACK
   struct bdi_writeback    *i_wb;  /* the associated cgroup wb */

      /* foreign inode detection, see wbc_detach_inode() */
     int                     i_wb_frn_winner;
  u16                     i_wb_frn_avg_time;
        u16                     i_wb_frn_history;
#endif
     struct list_head        i_lru;  /* inode LRU list */
      struct list_head        i_sb_list;
        struct list_head        i_wb_list;/* backing dev writeback list */
        union {
           struct hlist_head       i_dentry;
         struct rcu_head         i_rcu;
    };
        u64                     i_version;
        atomic_t                i_count;
  atomic_t                i_dio_count;
      atomic_t                i_writecount;
#ifdef CONFIG_IMA
      atomic_t                i_readcount; /* struct files open RO */
#endif
/* former->i_op >default_file_ops */
       const struct file_operations  *i_fop; 
       struct file_lock_context *i_flctx; 
       struct address_space i_data; 
       struct list_head i_devices; 
       union { 
          struct pipe_inode_info *i_pipe; 
          struct block_device *i_bdev; 
          struct cdev *i_cdev; 
          char *i_link; 
          unsigned i_dir_seq; 
       }; 
      __u32 i_generation; 
 #ifdef CONFIG_FSNOTIFY __u32 i_fsnotify_mask; /* all events this inode cares about */ 
     struct hlist_head i_fsnotify_marks; 
#endif 
#if IS_ENABLED(CONFIG_FS_ENCRYPTION) 
    struct fscrypt_info *i_crypt_info; 
#endif 
    void *i_private; /* fs or device private pointer */ 
};

请注意,并非所有字段都是强制性的,并且适用于所有文件系统;它们可以初始化适当的字段,这些字段根据它们对 inode 的定义而相关。每个 inode 都绑定到由底层文件系统定义的两个重要操作组:首先,一组操作来管理 inode 数据。这些通过struct inode_operations的实例表示,并由 inode 的i_op指针引用。其次是一组用于访问和操作 inode 所代表的底层文件数据的操作;这些操作封装在struct file_operations的实例中,并绑定到 inode 实例的i_fop指针。

换句话说,每个 inode 都绑定到由类型为struct inode_operations的实例表示的元数据操作,以及由类型为struct file_operations的实例表示的文件数据操作。但是,用户模式应用程序从用于表示调用方进程的打开文件的有效file对象访问文件数据操作(我们将在下一节中更多讨论文件对象):

struct inode_operations {
 struct dentry * (*lookup) (struct inode *,struct dentry *, unsigned int);
 const char * (*get_link) (struct dentry *, struct inode *, struct delayed_call *);
 int (*permission) (struct inode *, int);
 struct posix_acl * (*get_acl)(struct inode *, int);
 int (*readlink) (struct dentry *, char __user *,int);
 int (*create) (struct inode *,struct dentry *, umode_t, bool);
 int (*link) (struct dentry *,struct inode *,struct dentry *);
 int (*unlink) (struct inode *,struct dentry *);
 int (*symlink) (struct inode *,struct dentry *,const char *);
 int (*mkdir) (struct inode *,struct dentry *,umode_t);
 int (*rmdir) (struct inode *,struct dentry *);
 int (*mknod) (struct inode *,struct dentry *,umode_t,dev_t);
 int (*rename) (struct inode *, struct dentry *,
 struct inode *, struct dentry *, unsigned int);
 int (*setattr) (struct dentry *, struct iattr *);
 int (*getattr) (struct vfsmount *mnt, struct dentry *, struct kstat *);
 ssize_t (*listxattr) (struct dentry *, char *, size_t);
 int (*fiemap)(struct inode *, struct fiemap_extent_info *, u64 start,
 u64 len);
 int (*update_time)(struct inode *, struct timespec *, int);
 int (*atomic_open)(struct inode *, struct dentry *,
 struct file *, unsigned open_flag,
 umode_t create_mode, int *opened);
 int (*tmpfile) (struct inode *, struct dentry *, umode_t);
 int (*set_acl)(struct inode *, struct posix_acl *, int);
} ____cacheline_aligned

以下是一些重要操作的简要描述:

  • 查找: 用于定位指定文件的 inode 实例;此操作返回一个 dentry 实例。

  • create: VFS 调用此例程以为指定的 dentry 构造一个 inode 对象。

  • link: 用于支持硬链接。由link(2)系统调用调用。

  • unlink: 用于支持删除 inode。由unlink(2)系统调用调用。

  • mkdir: 用于支持创建子目录。由mkdir(2)系统调用调用。

  • mknod: 由mknod(2)系统调用调用以创建设备、命名管道、inode 或套接字。

  • listxattr: 由 VFS 调用以列出文件的所有扩展属性。

  • update_time: 由 VFS 调用以更新特定时间或 inode 的i_version

以下是 VFS 定义的struct file_operations,它封装了底层文件数据上的文件系统定义操作。由于这被声明为所有文件系统的通用接口,它包含适合支持各种类型文件系统上操作的函数指针接口,这些文件系统具有不同的文件数据定义。底层文件系统可以选择适当的接口并留下其余部分,这取决于它们对文件和文件数据的概念:

struct file_operations {
 struct module *owner;
 loff_t (*llseek) (struct file *, loff_t, int);
 ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
 ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
 ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
 ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
 int (*iterate) (struct file *, struct dir_context *);
 int (*iterate_shared) (struct file *, struct dir_context *);
 unsigned int (*poll) (struct file *, struct poll_table_struct *);
 long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
 long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
 int (*mmap) (struct file *, struct vm_area_struct *);
 int (*open) (struct inode *, struct file *);
 int (*flush) (struct file *, fl_owner_t id);
 int (*release) (struct inode *, struct file *);
 int (*fsync) (struct file *, loff_t, loff_t, int datasync);
 int (*fasync) (int, struct file *, int);
 int (*lock) (struct file *, int, struct file_lock *);
 ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
 unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
 int (*check_flags)(int);
 int (*flock) (struct file *, int, struct file_lock *);
 ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
 ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
 int (*setlease)(struct file *, long, struct file_lock **, void **);
 long (*fallocate)(struct file *file, int mode, loff_t offset,
 loff_t len);
 void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
 unsigned (*mmap_capabilities)(struct file *);
#endif
 ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
 loff_t, size_t, unsigned int);
 int (*clone_file_range)(struct file *, loff_t, struct file *, loff_t,
 u64);
 ssize_t (*dedupe_file_range)(struct file *, u64, u64, struct file *,
 u64);
};

以下是一些重要操作的简要描述:

  • llseek: 当 VFS 需要移动文件位置索引时调用。

  • read: 由read(2)和其他相关系统调用调用。

  • write: 由write(2)和其他相关系统调用调用。

  • iterate: 当 VFS 需要读取目录内容时调用。

  • poll: 当进程需要检查文件上的活动时,VFS 调用此例程。由select(2)poll(2)系统调用调用。

  • unlocked_ioctl: 当用户模式进程调用文件描述符上的ioctl(2)系统调用时,将调用分配给此指针的操作。此函数用于支持特殊操作。设备驱动程序使用此接口来支持目标设备上的配置操作。

  • compat_ioctl:类似于 ioctl,但有一个例外,它用于将从 32 位进程传递的参数转换为与 64 位内核一起使用。

  • mmap:当用户模式进程调用mmap(2)系统调用时,分配给此指针的例程将被调用。此函数支持的功能取决于底层文件系统。对于常规持久文件,此函数被实现为将文件的调用者指定的数据区域映射到调用者进程的虚拟地址空间。对于支持mmap的设备文件,此例程将底层设备地址空间映射到调用者的虚拟地址空间。

  • open:当用户模式进程启动open(2)系统调用以创建文件描述符时,VFS 将调用分配给此接口的函数。

  • flush:由close(2)系统调用调用以刷新文件。

  • release:当用户模式进程执行close(2)系统调用销毁文件描述符时,VFS 将调用分配给此接口的函数。

  • fasync:当文件启用异步模式时,由fcntl(2)系统调用调用。

  • splice_write:由 VFS 调用以将数据从管道拼接到文件。

  • setlease:由 VFS 调用以设置或释放文件锁定租约。

  • fallocate:由 VFS 调用以预分配一个块。

结构 dentry

在我们之前的讨论中,我们了解了典型磁盘文件系统如何通过inode结构表示每个目录,以及磁盘上的目录块如何表示该目录下文件的信息。当用户模式应用程序发起诸如open()之类的文件访问操作时,需要使用完整路径(例如/root/test/abc),VFS 将需要执行目录查找操作来解码和验证路径中指定的每个组件。

为了高效查找和转换文件路径中的组件,VFS 枚举了一个特殊的数据结构,称为dentry。dentry 对象包含文件或目录的字符串name,指向其inode的指针以及指向父dentry的指针。对于文件查找路径中的每个组件,都会生成一个 dentry 实例;例如,在/root/test/abc的情况下,会为root生成一个 dentry,为test生成另一个 dentry,最后为文件abc生成一个 dentry。

struct dentry在内核头文件</linux/dcache.h>中定义:

struct dentry {
 /* RCU lookup touched fields */
   unsigned int d_flags;           /* protected by d_lock */
 seqcount_t d_seq;               /* per dentry seqlock */
  struct hlist_bl_node d_hash;    /* lookup hash list */
    struct dentry *d_parent;        /* parent directory */
    struct qstr d_name;
       struct inode *d_inode; /* Where the name -NULL is negative */
     unsigned char d_iname[DNAME_INLINE_LEN];        /* small names */

   /* Ref lookup also touches following */
   struct lockref d_lockref;       /* per-dentry lock and refcount */
        const struct dentry_operations *d_op;
     struct super_block *d_sb;       /* The root of the dentry tree */
 unsigned long d_time;           /* used by d_revalidate */
        void *d_fsdata;                 /* fs-specific data */

      union {
           struct list_head d_lru;         /* LRU list */
            wait_queue_head_t *d_wait;      /* in-lookup ones only */
 };
        struct list_head d_child;       /* child of parent list */
        struct list_head d_subdirs;     /* our children */
        /*
         * d_alias and d_rcu can share memory
      */
       union {
           struct hlist_node d_alias;      /* inode alias list */
            struct hlist_bl_node d_in_lookup_hash;  
          struct rcu_head d_rcu;
    } d_u;
};
  • d_parent是指向父 dentry 实例的指针。

  • d_name保存文件的名称。

  • d_inode是文件的 inode 实例的指针。

  • d_flags包含在<include/linux/dcache.h>中定义的几个标志。

  • d_op指向包含 dentry 对象的各种操作的函数指针的结构。

现在让我们看看struct dentry_operations,它描述了文件系统如何重载标准的 dentry 操作:

struct dentry_operations {
 int (*d_revalidate)(struct dentry *, unsigned int);
       int (*d_weak_revalidate)(struct dentry *, unsigned int);
  int (*d_hash)(const struct dentry *, struct qstr *);
      int (*d_compare)(const struct dentry *,
                   unsigned int, const char *, const struct qstr *);
 int (*d_delete)(const struct dentry *);
   int (*d_init)(struct dentry *);
   void (*d_release)(struct dentry *);
       void (*d_prune)(struct dentry *);
 void (*d_iput)(struct dentry *, struct inode *);
  char *(*d_dname)(struct dentry *, char *, int);
   struct vfsmount *(*d_automount)(struct path *);
   int (*d_manage)(const struct path *, bool);
       struct dentry *(*d_real)(struct dentry *, const struct inode *,
                            unsigned int);

} ____ca

以下是一些重要的 dentry 操作的简要描述:

  • d_revalidate:当 VFS 需要重新验证 dentry 时调用。每当名称查找返回 dcache 中的一个 dentry 时,就会调用此操作。

  • d_weak_revalidate:当 VFS 需要重新验证跳转的 dentry 时调用。如果路径遍历结束于在父目录查找中未找到的 dentry,则会调用此操作。

  • d_hash:当 VFS 将 dentry 添加到哈希表时调用。

  • d_compare:用于比较两个 dentry 实例的文件名。它将一个 dentry 名称与给定名称进行比较。

  • d_delete:当最后一个对 dentry 的引用被移除时调用。

  • d_init:当分配 dentry 时调用。

  • d_release:当 dentry 被释放时调用。

  • d_iput:当 inode 从 dentry 中释放时调用。

  • d_dname:当必须生成 dentry 的路径名时调用。对于特殊文件系统来说,延迟路径名生成(每当需要路径时)非常方便。

文件结构

  • struct file的一个实例代表一个打开的文件。当用户进程成功打开一个文件时,将创建这个结构,并包含调用应用程序的文件访问属性,如文件数据的偏移量、访问模式和特殊标志等。这个对象被映射到调用者的文件描述符表,并作为调用者应用程序对文件的处理。这个结构是进程本地的,并且在相关文件关闭之前一直由进程保留。对文件描述符的close操作会销毁file实例。
struct file {
       union {
           struct llist_node       fu_llist;
         struct rcu_head         fu_rcuhead;
       } f_u;
    struct path             f_path;
   struct inode            *f_inode;       /* cached value */
        const struct file_operations    *f_op;

      /*
         * Protects f_ep_links, f_flags.
   * Must not be taken from IRQ context.
     */
       spinlock_t              f_lock;
   atomic_long_t           f_count;
  unsigned int            f_flags;
  fmode_t                 f_mode;
   struct mutex            f_pos_lock;
       loff_t                  f_pos;
    struct fown_struct      f_owner;
  const struct cred       *f_cred;
  struct file_ra_state    f_ra;

       u64                     f_version;
#ifdef CONFIG_SECURITY
    void                    *f_security;
#endif
  /* needed for tty driver, and maybe others */
     void                    *private_data;

#ifdef CONFIG_EPOLL
     /* Used by fs/eventpoll.c to link all the hooks to this file */
   struct list_head        f_ep_links;
       struct list_head        f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */
      struct address_space    *f_mapping;
} __attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
  • f_inode指针指向文件的 inode 实例。当 VFS 构造文件对象时,f_op指针会初始化为与文件的 inode 相关联的struct file_operations的地址,正如我们之前讨论的那样。

- 特殊文件系统

  • 与设计用于管理存储在存储设备上的持久文件数据的常规文件系统不同,内核实现了各种特殊文件系统,用于管理特定类别的内核内核数据结构。由于这些文件系统不处理持久数据,它们不会占用磁盘块,并且整个文件系统结构都保持在内核中。这些文件系统的存在使应用程序开发、调试和错误检测变得更加简化。在这个类别中有许多文件系统,每个都是为特定目的而故意设计和实现的。以下是一些重要文件系统的简要描述。

- Procfs

  • Procfs是一个特殊的文件系统,它将内核数据结构枚举为文件。这个文件系统作为内核程序员的调试资源,因为它允许用户通过虚拟文件接口查看数据结构的状态。Procfs 被挂载到根文件系统的/proc目录(挂载点)上。

  • procfs 文件中的数据不是持久的,而是在运行时构造的;每个文件都是一个接口,用户可以通过它触发相关操作。例如,对 proc 文件的读操作会调用与文件条目绑定的读回调函数,并且该函数被实现为用适当的数据填充用户缓冲区。

  • 枚举的文件数量取决于内核构建的配置和架构。以下是一些重要文件的列表,这些文件在/proc下枚举了有用的数据:

- 文件名 描述
- /proc/cpuinfo:提供低级 CPU 详细信息,如供应商、型号、时钟速度、缓存大小、兄弟姐妹的数量、核心、CPU 标志和 bogomips。
- /proc/meminfo:提供物理内存状态的摘要视图。
- /proc/ioports:提供由 x86 类机器支持的端口 I/O 地址空间的当前使用情况的详细信息。此文件在其他架构上不存在。
- /proc/iomem:显示描述内存地址空间当前使用情况的详细布局。
- /proc/interrupts:显示包含 IRQ 线路和绑定到每个中断处理程序的中断处理程序的详细信息的 IRQ 描述符表的视图。
- /proc/slabinfo:显示 slab 缓存及其当前状态的详细列表。
- /proc/buddyinfo:显示由伙伴系统管理的伙伴列表的当前状态。
- /proc/vmstat:显示虚拟内存管理统计信息。
- /proc/zoneinfo:显示每个节点的内存区域统计信息。
- /proc/cmdline:显示传递给内核的引导参数。
- /proc/timer_list:显示活动挂起定时器的列表,以及时钟源的详细信息。
- /proc/timer_stats:提供有关活动定时器的详细统计信息,用于跟踪定时器的使用和调试。
- /proc/filesystems:呈现当前活动的文件系统服务列表。
- /proc/mounts:显示当前挂载的设备及其挂载点。
- /proc/partitions:呈现检测到的当前存储分区的详细信息,带有相关的/dev 文件枚举。
- /proc/swaps:列出具有状态详细信息的活动交换分区。
/proc/modules 列出当前部署的内核模块的名称和状态。
/proc/uptime 显示自启动以来内核运行的时间长度和空闲模式下的时间。
/proc/kmsg 显示内核消息日志缓冲区的内容。
/proc/kallsyms 显示内核符号表。
/proc/devices 显示已注册的块设备和字符设备及其主要编号的列表。
/proc/misc 显示通过 misc 接口注册的设备及其 misc 标识符的列表。
/proc/stat 显示系统统计信息。
/proc/net 包含各种与网络堆栈相关的伪文件的目录。
/proc/sysvipc 包含伪文件的子目录,显示 System V IPC 对象、消息队列、信号量和共享内存的状态。

/proc还列出了许多子目录,提供了进程 PCB 或任务结构中元素的详细视图。这些文件夹以它们所代表的进程的 PID 命名。以下是一些重要文件的列表,这些文件提供了与进程相关的信息:

文件名 描述
/proc/pid/cmdline 进程的命令行名称。
/proc/pid/exe 可执行文件的符号链接。
/proc/pid/environ 列出进程可访问的环境变量。
/proc/pid/cwd 指向进程当前工作目录的符号链接。
/proc/pid/mem 显示进程的虚拟内存的二进制图像。
/proc/pid/maps 列出进程的虚拟内存映射。
/proc/pid/fdinfo 列出打开文件描述符的当前状态和标志的目录。
/proc/pid/fd 包含指向打开文件描述符的符号链接的目录。
/proc/pid/status 列出进程的当前状态,包括其内存使用情况。
/proc/pid/sched 列出调度统计信息。
/proc/pid/cpuset 列出此进程的 CPU 亲和性掩码。
/proc/pid/cgroup 显示进程的 cgroup 详细信息。
/proc/pid/stack 显示进程拥有的内核堆栈的回溯。
/proc/pid/smaps 显示每个映射到其地址空间的内存消耗。
/proc/pid/pagemap 显示进程每个虚拟页面的物理映射状态。
/proc/pid/syscall 显示当前由进程执行的系统调用的系统调用号和参数。
/proc/pid/task 包含子进程/线程详细信息的目录。

这些列表是为了让您熟悉 proc 文件及其用法。建议您查阅 procfs 的手册页面,详细了解这些文件的每个描述。

到目前为止,我们列出的所有文件都是只读的;procfs 还包含一个名为/proc/sys的分支,其中包含读写文件,这些文件被称为内核参数。/proc/sys下的文件根据其适用的子系统进一步分类。列出所有这些文件超出了范围。

Sysfs

Sysfs是另一个伪文件系统,用于向用户模式导出统一的硬件和驱动程序信息。它通过虚拟文件从内核设备模型的角度向用户空间枚举有关设备和相关设备驱动程序的信息。Sysfs 被挂载到rootfs的/sys 目录(挂载点)。与 procfs 类似,底层驱动程序和内核子系统可以通过 sysfs 的虚拟文件接口进行电源管理和其他功能的配置。Sysfs 还通过适当的守护程序(如udev)使 Linux 发行版能够进行热插拔事件管理,并配置为监听和响应热插拔事件。

以下是 sysfs 的重要子目录的简要描述:

  • 设备:引入 sysfs 的目标之一是提供当前由各自驱动程序子系统枚举和管理的设备的统一列表。设备目录包含全局设备层次结构,其中包含每个由驱动程序子系统发现并注册到内核的物理和虚拟设备的信息。

  • 总线:此目录包含子目录的列表,每个子目录代表内核中支持的物理总线类型。每个总线类型目录包含两个子目录:devicesdriversdevices目录包含当前发现或绑定到该总线类型的设备的列表。列表中的每个文件都是指向全局设备树中设备目录中的设备文件的符号链接。drivers目录包含描述与总线管理器注册的每个设备驱动程序的目录。每个驱动程序目录列出显示驱动程序参数的当前配置的属性,这些参数可以被修改,并且包含指向驱动程序绑定到的物理设备目录的符号链接。

  • class目录包含当前在内核中注册的设备类的表示。设备类描述了设备的功能类型。每个设备类目录包含表示当前分配和注册在该类下的设备的子目录。对于大多数类设备对象,它们的目录包含到与该类对象相关联的全局设备层次结构和总线层次结构中的设备和驱动程序目录的符号链接。

  • 固件firmware目录包含用于查看和操作在开机/复位期间运行的特定于平台的固件的接口,例如 x86 平台上的 BIOS 或 UEFI 和 PPC 平台上的 OpenFirmware。

  • 模块:此目录包含代表当前部署的每个内核模块的子目录。每个目录都用所代表的模块的名称进行枚举。每个模块目录包含有关模块的信息,例如引用计数、模块参数和其核心大小。

Debugfs

与 procfs 和 sysfs 不同,它们是通过虚拟文件接口实现呈现特定信息的,debugfs是一个通用的内存文件系统,允许内核开发人员导出任何被认为对调试有用的任意信息。Debugfs 提供用于枚举虚拟文件的函数接口,并通常挂载到/sys/debug目录。Debugfs 被跟踪机制(如 ftrace)用于呈现函数和中断跟踪。

还有许多其他特殊的文件系统,如 pipefs、mqueue 和 sockfs;我们将在后面的章节中涉及其中的一些。

摘要

通过本章,我们对典型文件系统及其结构和设计有了一般的了解,以及它是操作系统的基本组成部分的原因。本章还强调了抽象的重要性和优雅,使用了内核全面吸收的常见、分层的架构设计。我们还扩展了对 VFS 及其通用文件接口的理解,该接口促进了通用文件 API 及其内部结构。在下一章中,我们将探索内存管理的另一个方面,称为虚拟内存管理器,它处理进程虚拟地址空间和页表。

第六章:进程间通信

复杂的应用程序编程模型可能包括许多进程,每个进程都实现为处理特定的工作,这些工作共同为应用程序的最终功能做出贡献。根据目标、设计和应用程序所托管的环境,所涉及的进程可能是相关的(父子、兄弟)或无关的。通常,这些进程需要各种资源来进行通信、共享数据并同步它们的执行以实现期望的结果。这些资源由操作系统的内核作为称为进程间通信IPC)的服务提供。我们已经讨论了信号作为 IPC 机制的使用;在本章中,我们将开始探索各种其他可用于进程通信和数据共享的资源。

在本章中,我们将涵盖以下主题:

  • 管道和 FIFO 作为消息资源

  • SysV IPC 资源

  • POSX IPC 机制

管道和 FIFO

管道形成了进程之间基本的单向、自同步的通信方式。顾名思义,它们有两端:一个进程写入数据,另一个进程从另一端读取数据。在这种设置中,首先输入的数据将首先被读取。由于管道的有限容量,管道本身会导致通信同步:如果写入进程写入速度比读取进程读取速度快得多,管道的容量将无法容纳多余的数据,并且不可避免地阻塞写入进程,直到读取者读取并释放数据。同样,如果读取者读取数据的速度比写入者快,它将没有数据可供读取,因此会被阻塞,直到数据变得可用。

管道可以用作通信的消息资源,用于相关进程之间和无关进程之间的通信。当应用于相关进程之间时,管道被称为未命名管道,因为它们不被列为rootfs树下的文件。未命名管道可以通过pipe()API 分配。

int pipe2(int pipefd[2], int flags);

API 调用相应的系统调用,分配适当的数据结构并设置管道缓冲区。它映射一对文件描述符,一个用于在管道缓冲区上读取,另一个用于在管道缓冲区上写入。这些描述符将返回给调用者。调用者进程通常会 fork 子进程,子进程会继承可以用于消息传递的管道文件描述符。

以下代码摘录显示了管道系统调用的实现:

SYSCALL_DEFINE2(pipe2, int __user *, fildes, int, flags)
{
        struct file *files[2];
        int fd[2];
        int error;

        error = __do_pipe_flags(fd, files, flags);
        if (!error) {
                if (unlikely(copy_to_user(fildes, fd, sizeof(fd)))) {
                        fput(files[0]);
                        fput(files[1]);
                        put_unused_fd(fd[0]);
                        put_unused_fd(fd[1]);
                        error = -EFAULT;
                 } else {
                        fd_install(fd[0], files[0]);
                        fd_install(fd[1], files[1]);
                }
           }
           return error;
}

无关进程之间的通信需要将管道文件列入rootfs。这种管道通常被称为命名管道,可以通过命令行(mkfifo)或使用mkfifo API 的进程创建。

int mkfifo(const char *pathname, mode_t mode);

命名管道是使用指定的名称和适当的权限创建的,如模式参数所指定的那样。调用mknod系统调用来创建 FIFO,它在内部调用 VFS 例程来设置命名管道。具有访问权限的进程可以通过常见的 VFS 文件 API openreadwriteclose在 FIFO 上启动操作。

pipefs

管道和 FIFO 由一个名为pipefs的特殊文件系统创建和管理。它在 VFS 中注册为特殊文件系统。以下是来自fs/pipe.c的代码摘录:

static struct file_system_type pipe_fs_type = {
           .name = "pipefs",
           .mount = pipefs_mount,
           .kill_sb = kill_anon_super,
};

static int __init init_pipe_fs(void)
{
        int err = register_filesystem(&pipe_fs_type);

        if (!err) {
                pipe_mnt = kern_mount(&pipe_fs_type);
                if (IS_ERR(pipe_mnt)) {
                        err = PTR_ERR(pipe_mnt);
                        unregister_filesystem(&pipe_fs_type);
                }
      }
      return err;
}

fs_initcall(init_pipe_fs);

它通过列举代表每个管道的inode实例将管道文件集成到 VFS 中;这允许应用程序使用常见的文件 API readwriteinode结构包含了一组指针,这些指针与管道和设备文件等特殊文件相关。对于管道文件inodes,其中一个指针i_pipe被初始化为pipefs,定义为pipe_inode_info类型的实例。

struct inode {
        umode_t        i_mode;
        unsigned short i_opflags;
        kuid_t         i_uid;
        kgid_t         i_gid;
        unsigned int   i_flags;
        ...
        ...
        ...
         union {
                 struct pipe_inode_info *i_pipe;
                 struct block_device *i_bdev;
                 struct cdev *i_cdev;
                 char *i_link;
                 unsigned i_dir_seq;
         };
        ...
        ...
        ...
};

struct pipe_inode_info包含由pipefs定义的所有与管道相关的元数据,包括管道缓冲区的信息和其他重要的管理数据。此结构在<linux/pipe_fs_i.h>中定义。

struct pipe_inode_info {
        struct mutex mutex;  
        wait_queue_head_t wait;  
        unsigned int nrbufs, curbuf, buffers;
        unsigned int readers;
        unsigned int writers;
        unsigned int files;
        unsigned int waiting_writers;
        unsigned int r_counter;
        unsigned int w_counter;
        struct page *tmp_page;
        struct fasync_struct *fasync_readers;
        struct fasync_struct *fasync_writers;
        struct pipe_buffer *bufs;
        struct user_struct *user;
};

bufs指针指向管道缓冲区;每个管道默认分配总缓冲区大小为 65,535 字节(64k),排列为 16 页的循环数组。用户进程可以通过管道描述符上的fcntl()操作改变管道缓冲区的总大小。管道缓冲区的默认最大限制为 1,048,576 字节,可以通过特权进程通过/proc/sys/fs/pipe-max-size文件接口进行更改。以下是一个总结表,描述了其他重要元素:

名称 描述
mutex 保护管道的排他锁
wait 读取者和写入者的等待队列
nrbufs 此管道的非空管道缓冲区计数
curbuf 当前管道缓冲区
buffers 缓冲区的总数
readers 当前读取者的数量
writers 当前写入者的数量
files 当前引用此管道的 struct 文件实例的数量
waiting_writers 当前在管道上阻塞的写入者数量
r_coutner 读取者计数器(FIFO 相关)
w_counter 写入者计数器(FIFO 相关)
*fasync_readers 读取者端的 fasync
*fasync_writers 写入者端的 fasync
*bufs 指向管道缓冲区的循环数组的指针
*user 指向表示创建此管道的用户的user_struct实例的指针

对管道缓冲区的每个页面的引用被封装到类型struct pipe_buffer的实例的循环数组中。此结构在<linux/pipe_fs_i.h>中定义。

struct pipe_buffer {
        struct page *page;    
        unsigned int offset, len;
        const struct pipe_buf_operations *ops;
        unsigned int flags;
        unsigned long private;
};

*page是指向页面缓冲区的页面描述符的指针,offsetlen字段包含页面缓冲区中数据的偏移量和长度。*ops是指向pipe_buf_operations类型的结构的指针,它封装了pipefs实现的管道缓冲区操作。它还实现了绑定到管道和 FIFO 索引节点的文件操作:

const struct file_operations pipefifo_fops = {
         .open = fifo_open,
         .llseek = no_llseek,
         .read_iter = pipe_read,
         .write_iter = pipe_write,
         .poll = pipe_poll,
         .unlocked_ioctl = pipe_ioctl,
         .release = pipe_release,
         .fasync = pipe_fasync,
};

消息队列

消息队列是消息缓冲区的列表,通过它可以进行任意数量的进程通信。与管道不同,写入者无需等待读取者打开管道并监听数据。类似于邮箱,写入者可以将包含在缓冲区中的固定长度消息放入队列中,读取者可以在准备好时随时提取。消息队列在读取者提取后不保留消息包,这意味着每个消息包都是进程持久的。Linux 支持两种不同的消息队列实现:经典的 Unix SYSV 消息队列和当代的 POSIX 消息队列。

System V 消息队列

这是经典的 AT&T 消息队列实现,适用于任意数量的不相关进程之间的消息传递。发送进程将每条消息封装成一个包,其中包含消息数据和消息编号。消息队列的实现不定义消息编号的含义,而是由应用程序设计者定义消息编号和程序读者和写者解释相同的适当含义。这种机制为程序员提供了灵活性,可以将消息编号用作消息 ID 或接收者 ID。它使读取进程能够选择性地读取与特定 ID 匹配的消息。但是,具有相同 ID 的消息始终按照 FIFO 顺序(先进先出)读取。

进程可以使用以下命令创建和打开 SysV 消息队列:

 int msgget(key_t key, int msgflg);

key参数是一个唯一的常数,用作魔术数字来标识消息队列。所有需要访问此消息队列的程序都需要使用相同的魔术数字;这个数字通常在编译时硬编码到相关进程中。但是,应用程序需要确保每个消息队列的键值是唯一的,并且有可通过其动态生成唯一键的替代库函数。

如果将唯一键和msgflag参数值设置为IPC_CREATE,将会建立一个新的消息队列。有权访问队列的有效进程可以使用msgsndmsgrcv例程向队列中读取或写入消息(我们这里不会详细讨论它们;请参考 Linux 系统编程手册):

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
               int msgflg);

数据结构

每个消息队列都是通过底层 SysV IPC 子系统枚举一组数据结构来创建的。struct msg_queue是核心数据结构,每个消息队列都会枚举一个该结构的实例:


struct msg_queue {
        struct kern_ipc_perm q_perm;
        time_t q_stime; /* last msgsnd time */
        time_t q_rtime; /* last msgrcv time */
        time_t q_ctime; /* last change time */
        unsigned long q_cbytes; /* current number of bytes on queue */
        unsigned long q_qnum; /* number of messages in queue */
        unsigned long q_qbytes; /* max number of bytes on queue */
        pid_t q_lspid; /* pid of last msgsnd */
        pid_t q_lrpid; /* last receive pid */

       struct list_head q_messages; /* message list */
       struct list_head q_receivers;/* reader process list */
       struct list_head q_senders;  /*writer process list */
};

q_messages字段表示双向循环链表的头节点,该链表包含当前队列中的所有消息。每条消息以标头开头,后跟消息数据;每条消息可以根据消息数据的长度占用一个或多个页面。消息标头始终位于第一页的开头,并由struct msg_msg的一个实例表示:

/* one msg_msg structure for each message */
struct msg_msg {
        struct list_head m_list;
        long m_type;
        size_t m_ts; /* message text size */
        struct msg_msgseg *next;
        void *security;
       /* the actual message follows immediately */
};

m_list字段包含队列中前一条和后一条消息的指针。*next指针指向struct msg_msgseg的一个实例,该实例包含消息数据的下一页的地址。当消息数据超过第一页时,此指针才相关。第二页框架以msg_msgseg描述符开头,该描述符进一步包含指向后续页面的指针,这种顺序一直持续到消息数据的最后一页:

struct msg_msgseg {
        struct msg_msgseg *next;
        /* the next part of the message follows immediately */
};

POSIX 消息队列

POSIX 消息队列实现了按优先级排序的消息。发送进程写入的每条消息都与一个整数相关联,该整数被解释为消息优先级;数字越大的消息被认为优先级越高。消息队列按优先级对当前消息进行排序,并按降序(优先级最高的先)将它们传递给读取进程。该实现还支持更广泛的 API 接口,包括有界等待发送和接收操作,以及通过信号或线程进行异步消息到达通知的接收者。

该实现提供了一个独特的 API 接口来创建、打开、读取、写入和销毁消息队列。以下是 API 的摘要描述(我们这里不会讨论使用语义,请参考系统编程手册了解更多细节):

API 接口 描述
mq_open() 创建或打开一个 POSIX 消息队列
mq_send() 将消息写入队列
mq_timedsend() 类似于mq_send,但具有用于有界操作的超时参数
mq_receive() 从队列中获取消息;这个操作可以在无界阻塞调用上执行
mq_timedreceive() 类似于mq_receive(),但具有限制可能阻塞一段时间的超时参数
mq_close() 关闭消息队列
mq_unlink() 销毁消息队列
mq_notify() 自定义和设置消息到达通知
mq_getattr() 获取与消息队列关联的属性
mq_setattr() 设置消息队列上指定的属性

POSIX 消息队列由一个名为mqueue的特殊文件系统管理。每个消息队列都由文件名标识。每个队列的元数据由mqueue_inode_info结构的一个实例描述,该结构表示与mqueue文件系统中消息队列文件关联的 inode 对象:

struct mqueue_inode_info {
        spinlock_t lock;
        struct inode vfs_inode;
        wait_queue_head_t wait_q;

        struct rb_root msg_tree;
        struct posix_msg_tree_node *node_cache;
        struct mq_attr attr;

        struct sigevent notify;
        struct pid *notify_owner;
        struct user_namespace *notify_user_ns;
        struct user_struct *user; /* user who created, for accounting */
        struct sock *notify_sock;
        struct sk_buff *notify_cookie;

        /* for tasks waiting for free space and messages, respectively */
        struct ext_wait_queue e_wait_q[2];

        unsigned long qsize; /* size of queue in memory (sum of all msgs) */
};

*node_cache指针指向包含消息节点链表头的posix_msg_tree_node描述符,其中每条消息由msg_msg类型的描述符表示:


 struct posix_msg_tree_node {
         struct rb_node rb_node;
         struct list_head msg_list;
         int priority;
};

共享内存

与提供进程持久消息基础设施的消息队列不同,IPC 的共享内存服务提供了可以被任意数量的共享相同数据的进程附加的内核持久内存。共享内存基础设施提供了用于分配、附加、分离和销毁共享内存区域的操作接口。需要访问共享数据的进程将共享内存区域附加映射到其地址空间中;然后它可以通过映射例程返回的地址访问共享内存中的数据。这使得共享内存成为 IPC 的最快手段之一,因为从进程的角度来看,它类似于访问本地内存,不涉及切换到内核模式。

System V 共享内存

Linux 支持 IPC 子系统下的传统 SysV 共享内存实现。与 SysV 消息队列类似,每个共享内存区域都由唯一的 IPC 标识符标识。

操作接口

内核为启动共享内存操作提供了不同的系统调用接口,如下所示:

分配共享内存

进程通过调用shmget()系统调用来获取共享内存区域的 IPC 标识符;如果该区域不存在,则创建一个:

int shmget(key_t key, size_t size, int shmflg);

此函数返回与key参数中包含的值对应的共享内存段的标识符。如果其他进程打算使用现有段,它们可以在查找其标识符时使用段的key值。但是,如果key参数是唯一的或具有值IPC_PRIVATE,则会创建一个新段。size表示需要分配的字节数,因为段是分配为内存页面。要分配的页面数是通过将size值四舍五入到页面大小的最近倍数来获得的。

shmflg标志指定了如何创建段。它可以包含两个值:

  • IPC_CREATE:这表示创建一个新段。如果未使用此标志,则找到与键值关联的段,并且如果用户具有访问权限,则返回段的标识符。

  • IPC_EXCL:此标志始终与IPC_CREAT一起使用,以确保如果key值存在,则调用失败。

附加共享内存

共享内存区域必须附加到其地址空间,进程才能访问它。调用shmat()将共享内存附加到调用进程的地址空间:

void *shmat(int shmid, const void *shmaddr, int shmflg);

此函数附加了由shmid指示的段。shmaddr指定了一个指针,指示了段要映射到的进程地址空间中的位置。第三个参数shmflg是一个标志,可以是以下之一:

  • SHM_RND:当shmaddr不是 NULL 值时指定,表示函数将在地址处附加段,该地址由将shmaddr值四舍五入到页面大小的最近倍数计算得出;否则,用户必须确保shmaddr是页面对齐的,以便正确附加段。

  • SHM_RDONLY:这是指定如果用户具有必要的读权限,则段将仅被读取。否则,为段提供读写访问权限(进程必须具有相应的权限)。

  • SHM_REMAP:这是一个特定于 Linux 的标志,表示在由shmaddr指定的地址处的任何现有映射将被新映射替换。

分离共享内存

同样,要将共享内存从进程地址空间分离出来,会调用shmdt()。由于 IPC 共享内存区域在内核中是持久的,它们在进程分离后仍然存在:

int shmdt(const void *shmaddr);

shmaddr指定的段从调用进程的地址空间中分离出来。

这些接口操作中的每一个都调用了<ipc/shm.c>源文件中实现的相关系统调用。

数据结构

每个共享内存段都由struct shmid_kernel描述符表示。该结构包含了与 SysV 共享内存管理相关的所有元数据:

struct shmid_kernel /* private to the kernel */
{
        struct kern_ipc_perm shm_perm;
        struct file *shm_file; /* pointer to shared memory file */
        unsigned long shm_nattch; /* no of attached process */
        unsigned long shm_segsz; /* index into the segment */
        time_t shm_atim; /* last access time */
        time_t shm_dtim; /* last detach time */
        time_t shm_ctim; /* last change time */
        pid_t shm_cprid; /* pid of creating process */
        pid_t shm_lprid; /* pid of last access */
        struct user_struct *mlock_user;

        /* The task created the shm object. NULL if the task is dead. */
        struct task_struct *shm_creator; 
        struct list_head shm_clist; /* list by creator */
};

为了可靠性和便于管理,内核的 IPC 子系统通过一个名为shmfs的特殊文件系统管理共享内存段。这个文件系统没有挂载到 rootfs 树上;它的操作只能通过 SysV 共享内存系统调用来访问。*shm_file指针指向shmfsstruct file对象,表示一个共享内存块。当一个进程启动附加操作时,底层系统调用会调用do_mmap()来在调用者的地址空间中创建相关映射(通过struct vm_area_struct),并进入*shmfs-*定义的shm_mmap()操作来映射相应的共享内存:

POSIX 共享内存

Linux 内核通过一个名为tmpfs的特殊文件系统支持 POSIX 共享内存,该文件系统挂载到rootfs/dev/shm上。这种实现提供了一个与 Unix 文件模型一致的独特 API,导致每个共享内存分配都由唯一的文件名和 inode 表示。这个接口被应用程序员认为更加灵活,因为它允许使用标准的 POSIX 文件映射例程mmap()unmap()将内存段附加到调用进程的地址空间和分离出来。

以下是接口例程的摘要描述:

API 描述
shm_open() 创建并打开由文件名标识的共享内存段
mmap() POSIX 标准文件映射接口,用于将共享内存附加到调用者的地址空间
sh_unlink() 销毁指定的共享内存块
unmap() 从调用者地址空间分离指定的共享内存映射

底层实现与 SysV 共享内存类似,不同之处在于映射实现由tmpfs文件系统处理。

尽管共享内存是共享常用数据或资源的最简单方式,但它将实现同步的负担转嫁给了进程,因为共享内存基础设施不提供任何数据或资源的同步或保护机制。应用程序设计者必须考虑在竞争进程之间同步共享内存访问,以确保共享数据的可靠性和有效性,例如,防止两个进程同时在同一区域进行可能的写操作,限制读取进程等待直到另一个进程完成写操作等。通常,为了同步这种竞争条件,还会使用另一种 IPC 资源,称为信号量。

信号量

信号量是 IPC 子系统提供的同步原语。它们为多线程环境中的进程提供了对共享数据结构或资源的并发访问的保护机制。在其核心,每个信号量由一个可以被调用进程原子访问的整数计数器组成。信号量实现提供了两种操作,一种用于等待信号量变量,另一种用于发出信号量变量。换句话说,等待信号量会将计数器减 1,发出信号量会将计数器加 1。通常,当一个进程想要访问一个共享资源时,它会尝试减少信号量计数器。然而,内核会处理这个尝试,因为它会阻塞尝试的进程,直到计数器产生一个正值。类似地,当一个进程放弃资源时,它会增加信号量计数器,这会唤醒正在等待资源的任何进程。

信号量版本

传统上所有的 *nix 系统都实现了 System V 信号量机制;然而,POSIX 有自己的信号量实现,旨在实现可移植性并解决 System V 版本存在的一些笨拙问题。让我们先来看看 System V 信号量。

System V 信号量

在 System V 中,信号量不仅仅是一个单一的计数器,而是一组计数器。这意味着一个信号量集合可以包含单个或多个计数器(0 到 n)并具有相同的信号量 ID。集合中的每个计数器可以保护一个共享资源,而单个信号量集合可以保护多个资源。用于创建这种类型信号量的系统调用如下:

int semget(key_t key, int nsems, int semflg)
  • key 用于标识信号量。如果键值为 IPC_PRIVATE,则创建一个新的信号量集合。

  • nsems 表示需要在集合中的信号量数量

  • semflg 指示应该如何创建信号量。它可以包含两个值:

  • IPC_CREATE: 如果键不存在,则创建一个新的信号量

  • IPC_EXCL: 如果键存在,则抛出错误并失败

成功时,调用返回信号量集合标识符(一个正值)。

因此,创建的信号量包含未初始化的值,并需要使用 semctl() 函数进行初始化。初始化后,进程可以使用信号量集合:

int semop(int semid, struct sembuf *sops, unsigned nsops);

Semop() 函数允许进程对信号量集合进行操作。这个函数提供了一种独特的 SysV 信号量实现所特有的 可撤销操作,通过一个名为 SEM_UNDO 的特殊标志。当设置了这个标志时,内核允许在进程在完成相关的共享数据访问操作之前中止时,将信号量恢复到一致的状态。例如,考虑这样一种情况:其中一个进程锁定了信号量并开始对共享数据进行访问操作;在此期间,如果进程在完成共享数据访问之前中止,那么信号量将处于不一致的状态,使其对其他竞争进程不可用。然而,如果进程通过在 semop() 中设置 SEM_UNDO 标志来获取信号量的锁定,那么它的终止将允许内核将信号量恢复到一致的状态(解锁状态),使其对等待的其他竞争进程可用。

数据结构

每个 SysV 信号量集合在内核中由 struct sem_array 类型的描述符表示:

/* One sem_array data structure for each set of semaphores in the system. */
struct sem_array {
        struct kern_ipc_perm ____cacheline_aligned_in_smp sem_perm;                                                                           
        time_t sem_ctime;               /* last change time */
        struct sem *sem_base;           /*ptr to first semaphore in array */
        struct list_head pending_alter; /* pending operations */
                                        /* that alter the array */
        struct list_head pending_const; /* pending complex operations */
                                        /* that do not alter semvals */
        struct list_head list_id;       /* undo requests on this array */
        int sem_nsems;                  /* no. of semaphores in array */
        int complex_count;              /* pending complex operations */
        bool complex_mode;              /* no parallel simple ops */
   };

数组中的每个信号量都被列举为 <ipc/sem.c> 中定义的 struct sem 的实例;*sem_base 指针指向集合中的第一个信号量对象。每个信号量集合包含一个等待队列的挂起队列列表;pending_alter 是这个挂起队列的头节点,类型为 struct sem_queue。每个信号量集合还包含每个信号量可撤销的操作。list_id 是指向 struct sem_undo 实例列表的头节点;列表中每个信号量都有一个实例。以下图表总结了信号量集合数据结构及其列表:

POSIX 信号量

与 System V 相比,POSIX 信号量语义相对简单。每个信号量都是一个简单的计数器,永远不会小于零。实现提供了用于初始化、增加和减少操作的函数接口。它们可以通过在所有线程都可以访问的内存中分配信号量实例来用于同步线程。它们也可以通过将信号量放置在共享内存中来用于同步进程。Linux 对 POSIX 信号量的实现经过优化,以提供更好的性能,用于非竞争同步场景。

POSIX 信号量有两种变体:命名信号量和无名信号量。命名信号量由文件名标识,适用于不相关进程之间的使用。无名信号量只是sem_t类型的全局实例;一般情况下,这种形式更适合在线程之间使用。POSIX 信号量接口操作是 POSIX 线程库实现的一部分。

函数接口 描述
sem_open() 打开现有的命名信号量文件或创建一个新的命名信号量并返回其描述符
sem_init() 无名信号量的初始化程序
sem_post() 增加信号量的操作
sem_wait() 减少信号量的操作,如果在信号量值为零时调用,则会阻塞
sem_timedwait() 用有界等待的超时参数扩展sem_wait()
sem_getvalue() 返回信号量计数器的当前值
sem_unlink() 通过文件标识符移除命名信号量

摘要

在本章中,我们涉及了内核提供的各种 IPC 机制。我们探讨了每种机制的各种数据结构的布局和关系,并且还研究了 SysV 和 POSIX IPC 机制。

在下一章中,我们将进一步讨论锁定和内核同步机制。

第七章:虚拟内存管理

在第一章中,我们简要讨论了一个重要的抽象概念,称为进程。我们已经讨论了进程虚拟地址空间及其隔离,并且已经深入了解了涉及物理内存管理的各种数据结构和算法。在本章中,让我们通过虚拟内存管理和页表的详细信息来扩展我们对内存管理的讨论。我们将研究虚拟内存子系统的以下方面:

  • 进程虚拟地址空间及其段

  • 内存描述符结构

  • 内存映射和 VMA 对象

  • 文件支持的内存映射

  • 页缓存

  • 使用页表进行地址转换

进程地址空间

以下图表描述了 Linux 系统中典型进程地址空间的布局,由一组虚拟内存段组成:

每个段都被物理映射到一个或多个线性内存块(由一个或多个页面组成),并且适当的地址转换记录被放置在进程页表中。在我们深入了解内核如何管理内存映射和构建页表的完整细节之前,让我们简要了解一下地址空间的每个段:

  • 是最顶部的段,向下扩展。它包含栈帧,用于保存局部变量和函数参数;在调用函数时,在栈顶创建一个新的帧,在当前函数返回时销毁。根据函数调用的嵌套级别,栈段始终需要动态扩展以容纳新的帧。这种扩展由虚拟内存管理器通过页错误处理:当进程尝试触及栈顶的未映射地址时,系统触发页错误,由内核处理以检查是否适合扩展栈。如果当前栈利用率在RLIMIT_STACK范围内,则认为适合扩展栈。然而,如果当前利用率已达到最大值,没有进一步扩展的空间,那么会向进程发送段错误信号。

  • Mmap是栈下面的一个段;这个段主要用于将文件数据从页缓存映射到进程地址空间。这个段也用于映射共享对象或动态库。用户模式进程可以通过mmap()API 启动新的映射。Linux 内核还支持通过这个段进行匿名内存映射,这是一种用于存储进程数据的动态内存分配的替代机制。

  • 段提供了动态内存分配的地址空间,允许进程存储运行时数据。内核提供了brk()系列 API,通过它用户模式进程可以在运行时扩展或收缩堆。然而,大多数编程语言特定的标准库实现了堆管理算法,以有效利用堆内存。例如,GNU glibc 实现了堆管理,提供了malloc()系列函数进行分配。

地址空间的较低段--BSSDataText--与进程的二进制映像相关:

  • BSS存储未初始化的静态变量,这些变量的值在程序代码中未初始化。BSS 是通过匿名内存映射设置的。

  • 数据段包含在程序源代码中初始化的全局和静态变量。这个段通过映射包含初始化数据的程序二进制映像的部分来枚举;这种映射是以私有内存映射类型创建的,确保对数据变量内存的更改不会反映在磁盘文件上。

  • 文本段也通过从内存映射程序二进制文件来枚举;这种映射的类型是RDONLY,试图写入此段将触发分段错误。

内核支持地址空间随机化功能,如果在构建过程中启用,允许 VM 子系统为每个新进程随机化堆栈mmap段的起始位置。这为进程提供了免受恶意程序注入故障的安全性。黑客程序通常使用固定的有效进程内存段的起始地址进行硬编码;通过地址空间随机化,这种恶意攻击将失败。然而,从应用程序的二进制文件枚举的文本段被映射到固定地址,根据底层架构的定义,这被配置到链接器脚本中,在构建程序二进制文件时应用。

进程内存描述符

内核在内存描述符结构中维护了有关进程内存段和相应翻译表的所有信息,该结构的类型为struct mm_struct。进程描述符结构task_struct包含指向进程内存描述符的指针*mm。我们将讨论内存描述符结构的一些重要元素:

struct mm_struct {
               struct vm_area_struct *mmap; /* list of VMAs */
               struct rb_root mm_rb;
               u32 vmacache_seqnum; /* per-thread vmacache */
#ifdef CONFIG_MMU
             unsigned long (*get_unmapped_area) (struct file *filp, unsigned long addr, unsigned long len,
                                                                                                    unsigned long pgoff, unsigned long flags);
 #endif
            unsigned long mmap_base;               /* base of mmap area */
            unsigned long mmap_legacy_base;  /* base of mmap area in bottom-up allocations */
            unsigned long task_size;                   /* size of task vm space */
            unsigned long highest_vm_end;      /* highest vma end address */
            pgd_t * pgd;  
            atomic_t mm_users;           /* How many users with user space? */
            atomic_t mm_count;           /* How many references to "struct mm_struct" (users count as 1) */
            atomic_long_t nr_ptes;      /* PTE page table pages */
 #if CONFIG_PGTABLE_LEVELS > 2
           atomic_long_t nr_pmds;      /* PMD page table pages */
 #endif
           int map_count;                           /* number of VMAs */
         spinlock_t page_table_lock;      /* Protects page tables and some counters */
         struct rw_semaphore mmap_sem;

       struct list_head mmlist;      /* List of maybe swapped mm's. These are globally strung
                                                         * together off init_mm.mmlist, and are protected
                                                         * by mmlist_lock
                                                         */
        unsigned long hiwater_rss;     /* High-watermark of RSS usage */
         unsigned long hiwater_vm;     /* High-water virtual memory usage */
        unsigned long total_vm;          /* Total pages mapped */
         unsigned long locked_vm;       /* Pages that have PG_mlocked set */
         unsigned long pinned_vm;      /* Refcount permanently increased */
         unsigned long data_vm;          /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
        unsigned long exec_vm;          /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
         unsigned long stack_vm;         /* VM_STACK */
         unsigned long def_flags;
         unsigned long start_code, end_code, start_data, end_data;
         unsigned long start_brk, brk, start_stack;
         unsigned long arg_start, arg_end, env_start, env_end;
        unsigned long saved_auxv[AT_VECTOR_SIZE];               /* for /proc/PID/auxv */
/*
 * Special counters, in some configurations protected by the
 * page_table_lock, in other configurations by being atomic.
 */
        struct mm_rss_stat rss_stat;
      struct linux_binfmt *binfmt;
      cpumask_var_t cpu_vm_mask_var;
 /* Architecture-specific MM context */
        mm_context_t context;
      unsigned long flags;                   /* Must use atomic bitops to access the bits */
      struct core_state *core_state;   /* core dumping support */
       ...
      ...
      ...
 };

mmap_base指的是虚拟地址空间中 mmap 段的起始位置,task_size包含虚拟内存空间中任务的总大小。mm_users是一个原子计数器,保存共享此内存描述符的 LWP 的计数,mm_count保存当前使用此描述符的进程数,并且 VM 子系统确保只有在mm_count为零时才释放内存描述符结构。start_codeend_code字段包含从程序的二进制文件映射的代码块的起始和结束虚拟地址。类似地,start_dataend_data标记了从程序的二进制文件映射的初始化数据区域的开始和结束。

start_brkbrk字段表示堆段的起始和当前结束地址;虽然start_brk在整个进程生命周期中保持不变,但brk在分配和释放堆内存时会重新定位。因此,在特定时刻活动堆的总大小是start_brkbrk字段之间内存的大小。元素arg_startarg_end包含命令行参数列表的位置,env_startenv_end包含环境变量的起始和结束位置:

在虚拟地址空间中映射到段的每个线性内存区域都通过类型为struct vm_area_struct的描述符表示。每个 VM 区域区域都映射有包含起始和结束虚拟地址以及其他属性的虚拟地址间隔。VM 子系统维护一个表示当前区域的vm_area_struct(VMA)节点的链表;此列表按升序排序,第一个节点表示起始虚拟地址间隔,后面的节点包含下一个地址间隔,依此类推。内存描述符结构包括一个指针*mmap,它指向当前映射的 VM 区域列表。

VM 子系统在执行对 VM 区域的各种操作时需要扫描vm_area列表,例如在映射地址间隔内查找特定地址,或附加表示新映射的新 VMA 实例。这样的操作可能耗时且低效,特别是对于大量区域映射到列表的情况。为了解决这个问题,VM 子系统维护了一个红黑树,用于高效访问vm_area对象。内存描述符结构包括红黑树的根节点mm_rb。通过这种安排,可以通过搜索红黑树来快速附加新的 VM 区域,而无需显式扫描链接列表。

struct vm_area_struct 在内核头文件<linux/mm_types.h>中定义:

/*
  * This struct defines a memory VMM memory area. There is one of these
  * per VM-area/task. A VM area is any part of the process virtual memory
  * space that has a special rule for the page-fault handlers (ie a shared
  * library, the executable area etc).
  */
 struct vm_area_struct {
               /* The first cache line has the info for VMA tree walking. */
              unsigned long vm_start; /* Our start address within vm_mm. */
               unsigned long vm_end; /* The first byte after our end address within vm_mm. */
              /* linked list of VM areas per task, sorted by address */
               struct vm_area_struct *vm_next, *vm_prev;
               struct rb_node vm_rb;
               /*
                 * Largest free memory gap in bytes to the left of this VMA.
                 * Either between this VMA and vma->vm_prev, or between one of the
                 * VMAs below us in the VMA rbtree and its ->vm_prev. This helps
                 * get_unmapped_area find a free area of the right size.
                */
                 unsigned long rb_subtree_gap;
              /* Second cache line starts here. */
               struct mm_struct   *vm_mm; /* The address space we belong to. */
                pgprot_t  vm_page_prot;       /* Access permissions of this VMA. */
                unsigned long vm_flags;        /* Flags, see mm.h. */
              /*
                 * For areas with an address space and backing store,
                 * linkage into the address_space->i_mmap interval tree.
                 */
                struct {
                              struct rb_node rb;
                              unsigned long rb_subtree_last;
                           } shared;
         /*
                 * A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
                 * list, after a COW of one of the file pages. A MAP_SHARED vma
                 * can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
                 * or brk vma (with NULL file) can only be in an anon_vma list.
          */
            struct list_head anon_vma_chain; /* Serialized by mmap_sem & page_table_lock */
           struct anon_vma *anon_vma;        /* Serialized by page_table_lock */
            /* Function pointers to deal with this struct. */
            const struct vm_operations_struct *vm_ops;
            /* Information about our backing store: */
            unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units */
            struct file * vm_file; /* File we map to (can be NULL). */
            void * vm_private_data; /* was vm_pte (shared mem) */
#ifndef CONFIG_MMU
          struct vm_region *vm_region; /* NOMMU mapping region */
 #endif
 #ifdef CONFIG_NUMA
         struct mempolicy *vm_policy; /* NUMA policy for the VMA */
 #endif
        struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
 };

vm_start 包含区域的起始虚拟地址(较低地址),即映射的第一个有效字节的地址,vm_end 包含映射区域之外的第一个字节的虚拟地址(较高地址)。因此,可以通过从vm_start减去vm_end来计算映射内存区域的长度。指针*vm_next*vm_prev 指向下一个和上一个 VMA 列表,而vm_rb 元素用于表示红黑树下的这个 VMA。指针*vm_mm 指回进程内存描述符结构。

vm_page_prot 包含区域中页面的访问权限。vm_flags 是一个位字段,包含映射区域内存的属性。标志位在内核头文件<linux/mm.h>中定义。

标志位 描述
VM_NONE 表示非活动映射。
VM_READ 如果设置,映射区域中的页面是可读的。
VM_WRITE 如果设置,映射区域中的页面是可写的。
VM_EXEC 设置为将内存区域标记为可执行。包含可执行指令的内存块与VM_READ一起设置此标志。
VM_SHARED 如果设置,映射区域中的页面是共享的。
VM_MAYREAD 用于指示当前映射区域可以设置VM_READ。此标志用于mprotect()系统调用。
VM_MAYWRITE 用于指示当前映射区域可以设置VM_WRITE。此标志用于mprotect()系统调用。
VM_MAYEXEC 用于指示当前映射区域可以设置VM_EXEC。此标志用于mprotect()系统调用。
VM_GROWSDOWN 映射可以向下增长;堆栈段被分配了这个标志。
VM_UFFD_MISSING 设置此标志以指示 VM 子系统为此映射启用了userfaultfd,并设置为跟踪页面丢失故障。
VM_PFNMAP 设置此标志以指示内存区域是通过 PFN 跟踪页面映射的,而不是具有页面描述符的常规页面帧。
VM_DENYWRITE 设置以指示当前文件映射不可写。
VM_UFFD_WP 设置此标志以指示 VM 子系统为此映射启用了userfaultfd,并设置为跟踪写保护故障。
VM_LOCKED 当映射内存区域中的相应页面被锁定时设置。
VM_IO 当设备 I/O 区域被映射时设置。
VM_SEQ_READ 当进程声明其意图以顺序方式访问映射区域内的内存区域时设置。
VM_RAND_READ 当进程声明其意图在映射区域内以随机方式访问内存区域时设置。
VM_DONTCOPY 设置以指示 VM 在fork()上禁用复制此 VMA。
VM_DONTEXPAND 设置以指示当前映射在mremap()上不能扩展。
VM_LOCKONFAULT 当进程使用mlock2()系统调用启用MLOCK_ONFAULT时,当页面被故障时锁定内存映射中的页面。设置此标志。
VM_ACCOUNT VM 子系统执行额外的检查,以确保在对具有此标志的 VMA 执行操作时有可用内存。
VM_NORESERVE VM 是否应该抑制记账。
VM_HUGETLB 表示当前映射包含巨大的 TLB 页面。
VM_DONTDUMP 如果设置,当前 VMA 不会包含在核心转储中。
VM_MIXEDMAP 当 VMA 映射包含传统页面帧(通过页面描述符管理)和 PFN 管理的页面时设置。
VM_HUGEPAGE 当 VMA 标记为MADV_HUGEPAGE时设置,以指示 VM 页面在此映射下必须是透明巨大页面(THP)类型。此标志仅适用于私有匿名映射。
VM_NOHUGEPAGE 当 VMA 标记为MADV_NOHUGEPAGE时设置。
VM_MERGEABLE 当 VMA 标记为MADV_MERGEABLE时设置,这使得内核可以进行同页合并(KSM)。
VM_ARCH_1 架构特定的扩展。
VM_ARCH_2 架构特定的扩展。

下图描述了由进程的内存描述符结构指向的vm_area列表的典型布局:

如图所示,映射到地址空间的一些内存区域是文件支持的(代码区域形成应用程序二进制文件,共享库,共享内存映射等)。文件缓冲区由内核的页面缓存框架管理,该框架实现了自己的数据结构来表示和管理文件缓存。页面缓存通过address_space数据结构跟踪对文件区域的映射,通过各种用户模式进程。vm_area_struct对象的shared元素将此 VMA 枚举到与地址空间关联的红黑树中。我们将在下一节中更多地讨论页面缓存和address_space对象。

堆,栈和 mmap 等虚拟地址空间的区域是通过匿名内存映射分配的。VM 子系统将表示进程的所有匿名内存区域的 VMA 实例分组到一个列表中,并通过struct anon_vma类型的描述符表示它们。该结构使得可以快速访问映射匿名页面的所有进程 VMAs;每个匿名 VMA 结构的*anon_vma指针指向anon_vma对象。

然而,当一个进程 fork 一个子进程时,调用者地址空间的所有匿名页面都在写时复制(COW)下与子进程共享。这会导致创建新的 VMAs(对于子进程),它们表示父进程的相同匿名内存区域。内存管理器需要定位和跟踪所有引用相同区域的 VMAs,以便支持取消映射和交换操作。作为解决方案,VM 子系统使用另一个称为struct anon_vma_chain的描述符,它链接进程组的所有anon_vma结构。VMA 结构的anon_vma_chain元素是匿名 VMA 链的列表元素。

每个 VMA 实例都绑定到vm_operations_struct类型的描述符,其中包含对当前 VMA 执行的操作。VMA 实例的*vm_ops指针指向操作对象:

/*
  * These are the virtual MM functions - opening of an area, closing and
  * unmapping it (needed to keep files on disk up-to-date etc), pointer
  * to the functions called when a no-page or a wp-page exception occurs.
  */
 struct vm_operations_struct {
         void (*open)(struct vm_area_struct * area);
         void (*close)(struct vm_area_struct * area);
         int (*mremap)(struct vm_area_struct * area);
         int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
         int (*pmd_fault)(struct vm_area_struct *, unsigned long address,
                                                 pmd_t *, unsigned int flags);
         void (*map_pages)(struct fault_env *fe,
                         pgoff_t start_pgoff, pgoff_t end_pgoff);
         /* notification that a previously read-only page is about to become
          * writable, if an error is returned it will cause a SIGBUS */
         int (*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
    /* same as page_mkwrite when using VM_PFNMAP|VM_MIXEDMAP */
         int (*pfn_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
/* called by access_process_vm when get_user_pages() fails, typically
          * for use by special VMAs that can switch between memory and hardware
          */
         int (*access)(struct vm_area_struct *vma, unsigned long addr,
                       void *buf, int len, int write);
/* Called by the /proc/PID/maps code to ask the vma whether it
          * has a special name. Returning non-NULL will also cause this
          * vma to be dumped unconditionally. */
         const char *(*name)(struct vm_area_struct *vma);
   ...
   ...

*open()函数指针分配的例程在 VMA 枚举到地址空间时被调用。同样,*close()函数指针分配的例程在 VMA 从虚拟地址空间中分离时被调用。*mremap()接口分配的函数在 VMA 映射的内存区域需要调整大小时执行。当 VMA 映射的物理区域处于非活动状态时,系统会触发页面故障异常,并且内核的页面故障处理程序会通过*fault()指针调用分配给 VMA 区域的相应数据。

内核支持对类似于内存的存储设备上的文件进行直接访问操作(DAX),例如 nvrams、闪存存储和其他持久性内存设备。为这类存储设备实现的驱动程序执行所有读写操作,而无需任何缓存。当用户进程尝试从 DAX 存储设备映射文件时,底层磁盘驱动程序直接将相应的文件页面映射到进程的虚拟地址空间。为了获得最佳性能,用户模式进程可以通过启用VM_HUGETLB来从 DAX 存储中映射大文件。由于支持的页面大小较大,无法通过常规页面错误处理程序处理 DAX 文件映射上的页面错误,支持 DAX 的文件系统需要将适当的错误处理程序分配给 VMA 的*pmd_fault()指针。

管理虚拟内存区域

内核的 VM 子系统实现了各种操作,用于操作进程的虚拟内存区域;这些包括创建、插入、修改、定位、合并和删除 VMA 实例的函数。我们将讨论一些重要的例程。

定位 VMA

find_vma()例程定位 VMA 列表中满足给定地址条件的第一个区域(addr < vm_area_struct->vm_end)。

/* Look up the first VMA which satisfies addr < vm_end, NULL if none. */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
        struct rb_node *rb_node;
        struct vm_area_struct *vma;

        /* Check the cache first. */
        vma = vmacache_find(mm, addr);
        if (likely(vma))
               return vma;

       rb_node = mm->mm_rb.rb_node;
       while (rb_node) {
               struct vm_area_struct *tmp;
               tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);
               if (tmp->vm_end > addr) {
                        vma = tmp;
                        if (tmp->vm_start <= addr)
                                 break;
                        rb_node = rb_node->rb_left;
               } else
                        rb_node = rb_node->rb_right;
        }
        if (vma)
               vmacache_update(addr, vma);
        return vma;
}

该函数首先在每个线程的vma缓存中查找最近访问的vma中的请求地址。如果匹配,则返回 VMA 的地址,否则进入红黑树以定位适当的 VMA。树的根节点位于mm->mm_rb.rb_node中。通过辅助函数rb_entry(),验证每个节点是否在 VMA 的虚拟地址间隔内。如果找到了起始地址较低且结束地址较高的目标 VMA,函数将返回 VMA 实例的地址。如果仍然找不到适当的 VMA,则搜索将继续查找rbtree的左侧或右侧子节点。当找到合适的 VMA 时,将其指针更新到vma缓存中(预期下一次调用find_vma()来定位同一区域中相邻的地址),并返回 VMA 实例的地址。

当一个新区域被添加到一个现有区域之前或之后(因此也在两个现有区域之间),内核将涉及的数据结构合并为一个结构——当然,前提是所有涉及的区域的访问权限相同,并且连续的数据从相同的后备存储器中映射。

合并 VMA 区域

当一个新的 VMA 被映射到一个具有相同访问属性和来自文件支持的内存区域的现有 VMA 之前或之后时,将它们合并成一个单独的 VMA 结构更为优化。vma_merge()是一个辅助函数,用于合并具有相同属性的周围的 VMAs:

struct vm_area_struct *vma_merge(struct mm_struct *mm,
                        struct vm_area_struct *prev, unsigned long addr,
                        unsigned long end, unsigned long vm_flags,
                        struct anon_vma *anon_vma, struct file *file,
                        pgoff_t pgoff, struct mempolicy *policy,
                        struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
         pgoff_t pglen = (end - addr) >> PAGE_SHIFT;
         struct vm_area_struct *area, *next;
         int err;  
         ...
         ...

*mm指的是要合并其 VMAs 的进程的内存描述符;*prev指的是其地址间隔在新区域之前的 VMA;addrendvm_flags包含新区域的开始、结束和标志。*file指的是将其内存区域映射到新区域的文件实例,pgoff指定了文件数据中的映射偏移量。

该函数首先检查新区域是否可以与前驱合并:

        ...  
        ...
        /*
         * Can it merge with the predecessor?
         */
        if (prev && prev->vm_end == addr &&
                        mpol_equal(vma_policy(prev), policy) &&
                        can_vma_merge_after(prev, vm_flags,
                                            anon_vma, file, pgoff,
                                            vm_userfaultfd_ctx)) {
        ...
        ...

为此,它调用一个辅助函数can_vma_merge_after(),该函数检查前驱的结束地址是否对应于新区域的开始地址,以及两个区域的访问标志是否相同,还检查文件映射的偏移量,以确保它们在文件区域中是连续的,并且两个区域都不包含任何匿名映射:

                ...                
                ...               
                /*
                 * OK, it can. Can we now merge in the successor as well?
                 */
                if (next && end == next->vm_start &&
                                mpol_equal(policy, vma_policy(next)) &&
                                can_vma_merge_before(next, vm_flags,
                                                     anon_vma, file,
                                                     pgoff+pglen,
                                                     vm_userfaultfd_ctx) &&
                                is_mergeable_anon_vma(prev->anon_vma,
                                                      next->anon_vma, NULL)) {
                                                        /* cases 1, 6 */
                        err = __vma_adjust(prev, prev->vm_start,
                                         next->vm_end, prev->vm_pgoff, NULL,
                                         prev);
                } else /* cases 2, 5, 7 */
                        err = __vma_adjust(prev, prev->vm_start,
 end, prev->vm_pgoff, NULL, prev);

           ...
           ...
}

然后检查是否可以与后继区域合并;为此,它调用辅助函数can_vma_merge_before()。此函数执行与之前类似的检查,如果发现前任和后继区域都相同,则调用is_mergeable_anon_vma()来检查是否可以将前任的任何匿名映射与后继的合并。最后,调用另一个辅助函数__vma_adjust()来执行最终合并,该函数适当地操作 VMA 实例。

存在类似的辅助函数用于创建、插入和删除内存区域,这些函数作为do_mmap()do_munmap()的辅助函数被调用,当用户模式应用程序尝试对内存区域进行mmap()unmap()时。我们将不再讨论这些辅助例程的详细信息。

struct address_space

内存缓存是现代内存管理的一个重要组成部分。简单来说,缓存是用于特定需求的页面集合。大多数操作系统实现了缓冲缓存,这是一个管理用于缓存持久存储磁盘块的内存块列表的框架。缓冲缓存允许文件系统通过分组和延迟磁盘同步来最小化磁盘 I/O 操作,直到适当的时间。

Linux 内核实现了页面缓存作为缓存的机制;简单来说,页面缓存是动态管理的页面帧集合,用于缓存磁盘文件和目录,并通过提供页面进行交换和需求分页来支持虚拟内存操作。它还处理为特殊文件分配的页面,例如 IPC 共享内存和消息队列。应用程序文件 I/O 调用,如读取和写入,会导致底层文件系统对页面缓存中的页面执行相关操作。对未读文件的读取操作会导致请求的文件数据从磁盘获取到页面缓存中的页面,而写操作会更新缓存页面中相关文件数据,然后标记为并在特定间隔刷新到磁盘。

缓存中包含特定磁盘文件数据的页面组通过struct address_space类型的描述符表示,因此每个address_space实例都用作由文件inode或块设备文件inode拥有的页面集合的抽象:

struct address_space {
        struct inode *host; /* owner: inode, block_device */
        struct radix_tree_root page_tree; /* radix tree of all pages */
        spinlock_t tree_lock; /* and lock protecting it */
        atomic_t i_mmap_writable;/* count VM_SHARED mappings */
        struct rb_root i_mmap; /* tree of private and shared mappings */
        struct rw_semaphore i_mmap_rwsem; /* protect tree, count, list */
        /* Protected by tree_lock together with the radix tree */
        unsigned long nrpages; /* number of total pages */
        /* number of shadow or DAX exceptional entries */
        unsigned long nrexceptional;
        pgoff_t writeback_index;/* writeback starts here */
        const struct address_space_operations *a_ops; /* methods */
        unsigned long flags; /* error bits */
        spinlock_t private_lock; /* for use by the address_space */
        gfp_t gfp_mask; /* implicit gfp mask for allocations */
        struct list_head private_list; /* ditto */
        void *private_data; /* ditto */
} __attribute__((aligned(sizeof(long))));

*host指针指的是拥有者inode,其数据包含在当前address_space对象表示的页面中。例如,如果缓存中的一个页面包含由 Ext4 文件系统管理的文件的数据,文件的相应 VFS inode将在其i_data字段中存储address_space对象。文件的inode和相应的address_space对象存储在 VFS inode对象的i_data字段中。nr_pages字段包含此address_space下页面的计数。

为了有效管理缓存中的文件页面,VM 子系统需要跟踪到同一address_space区域的所有虚拟地址映射;例如,一些用户模式进程可能通过vm_area_struct实例将共享库的页面映射到它们的地址空间中。address_space对象的i_mmap字段是包含当前映射到此address_space的所有vm_area_struct实例的红黑树的根元素;由于每个vm_area_struct实例都指回相应进程的内存描述符,因此始终可以跟踪进程引用。

address_space对象下包含文件数据的所有物理页面通过基数树进行有效访问的组织;page_tree字段是struct radix_tree_root的一个实例,用作基数树的根元素。此结构在内核头文件<linux/radix-tree.h>中定义:

struct radix_tree_root {
        gfp_t gfp_mask;
        struct radix_tree_node __rcu *rnode;
};

树的每个节点都是struct radix_tree_node类型;前一个结构的*rnode指针指向树的第一个节点元素:

struct radix_tree_node {
        unsigned char shift; /* Bits remaining in each slot */
        unsigned char offset; /* Slot offset in parent */
        unsigned int count;
        union {
                struct {
                        /* Used when ascending tree */
                        struct radix_tree_node *parent;
                        /* For tree user */
                        void *private_data;
                };
                /* Used when freeing node */
                struct rcu_head rcu_head;
        };
        /* For tree user */
        struct list_head private_list;
        void __rcu *slots[RADIX_TREE_MAP_SIZE];
        unsigned long tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];
};

offset字段指定了父节点中的节点槽偏移量,count保存了子节点的总数,*parent是指向父节点的指针。每个节点可以通过槽数组引用 64 个树节点(由宏RADIX_TREE_MAP_SIZE指定),其中未使用的槽条目初始化为 NULL。

为了有效管理地址空间下的页面,内存管理器需要在干净页面和脏页面之间设置清晰的区别;这通过为radix树的每个节点的页面分配标签来实现。标记信息存储在节点结构的tags字段中,这是一个二维数组。数组的第一维区分可能的标签,第二维包含足够数量的无符号长整型元素,以便每个可以在节点中组织的页面都有一个位。以下是支持的标签列表:

/*
 * Radix-tree tags, for tagging dirty and writeback pages within 
 * pagecache radix trees                 
 */
#define PAGECACHE_TAG_DIRTY 0
#define PAGECACHE_TAG_WRITEBACK 1
#define PAGECACHE_TAG_TOWRITE 2

Linux 的radix树 API 提供了各种操作接口来setclearget标签:

void *radix_tree_tag_set(struct radix_tree_root *root,
                                     unsigned long index, unsigned int tag);
void *radix_tree_tag_clear(struct radix_tree_root *root,
                                     unsigned long index, unsigned int tag);
int radix_tree_tag_get(struct radix_tree_root *root,
                                     unsigned long index, unsigned int tag);

以下图表描述了address_space对象下页面的布局:

每个地址空间对象都绑定了一组实现地址空间页面和后端存储块设备之间各种低级操作的函数。address_space结构的a_ops指针指向包含地址空间操作的描述符。这些操作由 VFS 调用,以启动与地址映射和后端存储块设备关联的缓存中的页面之间的数据传输:

页表

在到达适当的物理内存区域之前,对进程虚拟地址区域的所有访问操作都经过地址转换。VM 子系统维护页表,将线性页地址转换为物理地址。尽管页表布局是特定于体系结构的,但对于大多数体系结构,内核使用四级分页结构,我们将考虑 x86-64 内核页表布局进行讨论。

以下图表描述了 x86-64 的页表布局:

页全局目录的地址,即顶层页表,被初始化为控制寄存器 cr3。这是一个 64 位寄存器,按位分解如下:

描述
2:0 忽略
4:3 页级写穿和页级缓存禁用
11:5 保留
51:12 页全局目录的地址
63:52 保留

在 x86-64 支持的 64 位宽线性地址中,Linux 目前使用了 48 位,可以支持 256 TB 的线性地址空间,这被认为对于当前的使用已经足够大。这 48 位线性地址分为五部分,前 12 位包含物理帧中内存位置的偏移量,其余部分包含适当页表结构的偏移量:

线性地址位 描述
11:0 (12 bits) 物理页的索引
20:12 (9 bits) 页表的索引
29:21 (9 bits) 页中间目录的索引
38:30 (9 bits) 页上层目录的索引
47:39 (9 bits) 页全局目录的索引

每个页表结构都可以支持 512 条记录,每条记录都提供下一级页结构的基地址。在翻译给定的线性地址时,MMU 提取包含页全局目录(PGD)索引的前 9 位,然后将其加到 PGD 的基地址(在 cr3 中找到);这个查找结果会发现页上级目录(PUD)的基地址。接下来,MMU 检索线性地址中找到的 PUD 偏移量(9 位),并将其加到 PUD 结构的基地址,以达到 PUD 条目(PUDE),从而得到页中间目录(PMD)的基地址。然后将线性地址中找到的 PMD 偏移量加到 PMD 的基地址,以达到相关的 PMD 条目(PMDE),从而得到页表的基地址。然后将线性地址中找到的页表偏移量(9 位)加到从 PMD 条目中发现的基地址,以达到页表条目(PTE),进而得到所请求数据的物理帧的起始地址。最后,将线性地址中找到的页偏移量(12 位)加到 PTE 发现的基地址,以达到要访问的内存位置。

摘要

在本章中,我们关注了虚拟内存管理的具体内容,涉及进程虚拟地址空间和内存映射。我们讨论了 VM 子系统的关键数据结构,内存描述符结构(struct mm_struct)和 VMA 描述符(struct vm_area_struct)。我们看了看页缓存及其数据结构(struct address_space),用于将文件缓冲区在各种进程地址空间中进行反向映射。最后,我们探讨了 Linux 的页表布局,这在许多架构中被广泛使用。在对文件系统和虚拟内存管理有了深入了解之后,在下一章中,我们将把这个讨论扩展到 IPC 子系统及其资源。

第八章:内核同步和锁定

内核地址空间由所有用户模式进程共享,这使得可以并发访问内核服务和数据结构。为了系统的可靠运行,内核服务必须实现为可重入的。访问全局数据结构的内核代码路径需要同步,以确保共享数据的一致性和有效性。在本章中,我们将详细介绍内核程序员用于同步内核代码路径和保护共享数据免受并发访问的各种资源。

本章将涵盖以下主题:

  • 原子操作

  • 自旋锁

  • 标准互斥锁

  • 等待/伤害互斥锁

  • 信号量

  • 序列锁

  • 完成

原子操作

计算操作被认为是原子的,如果它在系统的其余部分看起来是瞬间发生的。原子性保证了操作的不可分割和不可中断的执行。大多数 CPU 指令集架构定义了可以在内存位置上执行原子读-修改-写操作的指令操作码。这些操作具有成功或失败的定义,即它们要么成功地改变内存位置的状态,要么失败而没有明显的影响。这些操作对于在多线程场景中原子地操作共享数据非常有用。它们还用作实现排他锁的基础构建块,这些锁用于保护共享内存位置免受并行代码路径的并发访问。

Linux 内核代码使用原子操作来处理各种用例,例如共享数据结构中的引用计数器(用于跟踪对各种内核数据结构的并发访问),等待-通知标志,以及为特定代码路径启用数据结构的独占所有权。为了确保直接处理原子操作的内核服务的可移植性,内核提供了丰富的与体系结构无关的接口宏和内联函数库,这些函数库用作处理器相关的原子指令的抽象。这些中立接口下的相关 CPU 特定原子指令由内核代码的体系结构分支实现。

原子整数操作

通用原子操作接口包括对整数和位操作的支持。整数操作被实现为操作特殊的内核定义类型,称为atomic_t(32 位整数)和atomic64_t(64 位整数)。这些类型的定义可以在通用内核头文件<linux/types.h>中找到:

typedef struct {
        int counter;
} atomic_t;

#ifdef CONFIG_64BIT
typedef struct {
        long counter;
} atomic64_t;
#endif

该实现提供了两组整数操作;一组适用于 32 位,另一组适用于 64 位原子变量。这些接口操作被实现为一组宏和内联函数。以下是适用于atomic_t类型变量的操作的摘要列表:

接口宏/内联函数 描述
ATOMIC_INIT(i) 用于初始化原子计数器的宏
atomic_read(v) 读取原子计数器v的值
atomic_set(v, i) 原子性地将计数器v设置为i中指定的值
atomic_add(int i, atomic_t *v) 原子性地将i添加到计数器v
atomic_sub(int i, atomic_t *v) 原子性地从计数器v中减去i
atomic_inc(atomic_t *v) 原子性地增加计数器v
atomic_dec(atomic_t *v) 原子性地减少计数器v

以下是执行相关读-修改-写RMW)操作并返回结果的函数列表(即,它们返回修改后写入内存地址的值):

操作 描述
bool atomic_sub_and_test(int i, atomic_t *v) 原子性地从v中减去i,如果结果为零则返回true,否则返回false
bool atomic_dec_and_test(atomic_t *v) 原子性地将v减 1,并在结果为 0 时返回true,否则对所有其他情况返回false
bool atomic_inc_and_test(atomic_t *v) 原子地将i添加到v,如果结果为 0 则返回true,否则返回false
bool atomic_add_negative(int i, atomic_t *v) 原子地将i添加到v,如果结果为负数则返回true,如果结果大于或等于零则返回false
int atomic_add_return(int i, atomic_t *v) 原子地将i添加到v,并返回结果
int atomic_sub_return(int i, atomic_t *v) 原子地从v中减去i,并返回结果
int atomic_fetch_add(int i, atomic_t *v) 原子地将i添加到v,并返回v中的加法前值
int atomic_fetch_sub(int i, atomic_t *v) 原子地从v中减去i,并返回v中的减法前值
int atomic_cmpxchg(atomic_t *v, int old, int new) 读取位置v处的值,并检查它是否等于old;如果为true,则交换v处的值与*new*,并始终返回在v处读取的值
int atomic_xchg(atomic_t *v, int new) new交换存储在位置v处的旧值,并返回旧值v

对于所有这些操作,都存在用于atomic64_t的 64 位变体;这些函数的命名约定为atomic64_*()

原子位操作

内核提供的通用原子操作接口还包括位操作。与整数操作不同,整数操作被实现为在atomic(64)_t类型上操作,这些位操作可以应用于任何内存位置。这些操作的参数是位的位置或位数,以及一个具有有效地址的指针。32 位机器的位范围为 0-31,64 位机器的位范围为 0-63。以下是可用的位操作的摘要列表:

操作接口 描述
set_bit(int nr, volatile unsigned long *addr) 在从addr开始的位置上原子设置位nr
clear_bit(int nr, volatile unsigned long *addr) 在从addr开始的位置上原子清除位nr
change_bit(int nr, volatile unsigned long *addr) 在从addr开始的位置上原子翻转位nr
int test_and_set_bit(int nr, volatile unsigned long *addr) 在从addr开始的位置上原子设置位nr,并返回nr^(th)位的旧值
int test_and_clear_bit(int nr, volatile unsigned long *addr) 在从addr开始的位置上原子清除位nr,并返回nr``^(th)位的旧值
int test_and_change_bit(int nr, volatile unsigned long *addr) 在从addr开始的位置上原子翻转位nr,并返回nr^(th)位的旧值

对于所有具有返回类型的操作,返回的值是在指定修改发生之前从内存地址中读取的位的旧状态。这些操作也存在非原子版本;它们对于可能需要位操作的情况是高效且有用的,这些情况是从互斥临界块中的代码语句发起的。这些在内核头文件<linux/bitops/non-atomic.h>中声明。

引入排他锁

硬件特定的原子指令只能操作 CPU 字和双字大小的数据;它们不能直接应用于自定义大小的共享数据结构。对于大多数多线程场景,通常可以观察到共享数据是自定义大小的,例如,一个具有n个不同类型元素的结构。访问这些数据的并发代码路径通常包括一堆指令,这些指令被编程为访问和操作共享数据;这样的访问操作必须被原子地执行,以防止竞争。为了确保这些代码块的原子性,使用了互斥锁。所有多线程环境都提供了基于排他协议的互斥锁的实现。这些锁定实现是建立在硬件特定的原子指令之上的。

Linux 内核实现了标准排斥机制的操作接口,如互斥和读写排斥。它还包含对各种其他当代轻量级和无锁同步机制的支持。大多数内核数据结构和其他共享数据元素,如共享缓冲区和设备寄存器,都通过内核提供的适当排斥锁接口受到并发访问的保护。在本节中,我们将探讨可用的排斥机制及其实现细节。

自旋锁

自旋锁是大多数并发编程环境中广泛实现的最简单和轻量级的互斥机制之一。自旋锁实现定义了一个锁结构和操作,用于操作锁结构。锁结构主要包含原子锁计数器等元素,操作接口包括:

  • 一个初始化例程,用于将自旋锁实例初始化为默认(解锁)状态

  • 一个锁例程,通过原子地改变锁计数器的状态来尝试获取自旋锁

  • 一个解锁例程,通过将计数器改变为解锁状态来释放自旋锁

当调用者尝试在锁定时(或被另一个上下文持有)获取自旋锁时,锁定函数会迭代地轮询或自旋直到可用,导致调用者上下文占用 CPU 直到获取锁。正是由于这个事实,这种排斥机制被恰当地命名为自旋锁。因此建议确保关键部分内的代码是原子的或非阻塞的,以便锁定可以持续一个短暂的、确定的时间,因为显然持有自旋锁很长时间可能会造成灾难。

正如讨论的那样,自旋锁是围绕处理器特定的原子操作构建的;内核的架构分支实现了核心自旋锁操作(汇编编程)。内核通过一个通用的平台中立接口包装了架构特定的实现,该接口可以直接被内核服务使用;这使得使用自旋锁保护共享资源的服务代码具有可移植性。

通用自旋锁接口可以在内核头文件 <linux/spinlock.h> 中找到,而特定架构的定义是 <asm/spinlock.h> 的一部分。通用接口提供了一系列针对特定用例实现的 lock()unlock() 操作。我们将在接下来的章节中讨论这些接口中的每一个;现在,让我们从接口提供的标准和最基本的 lock()unlock() 操作变体开始我们的讨论。以下代码示例展示了基本自旋锁接口的使用:

DEFINE_SPINLOCK(s_lock);
spin_lock(&s_lock);
/* critical region ... */
spin_unlock(&s_lock);

让我们来看看这些函数的实现细节:

static __always_inline void spin_lock(spinlock_t *lock)
{
        raw_spin_lock(&lock->rlock);
}

...
...

static __always_inline void spin_unlock(spinlock_t *lock)
{
        raw_spin_unlock(&lock->rlock);
}

内核代码实现了两种自旋锁操作的变体;一种适用于 SMP 平台,另一种适用于单处理器平台。自旋锁数据结构和与架构和构建类型(SMP 和 UP)相关的操作在内核源树的各个头文件中定义。让我们熟悉一下这些头文件的作用和重要性:

<include/linux/spinlock.h> 包含了通用的自旋锁/rwlock 声明。

以下头文件与 SMP 平台构建相关:

  • <asm/spinlock_types.h> 包含了 arch_spinlock_t/arch_rwlock_t 和初始化程序

  • <linux/spinlock_types.h> 定义了通用类型和初始化程序

  • <asm/spinlock.h> 包含了 arch_spin_*() 和类似的低级操作实现

  • <linux/spinlock_api_smp.h> 包含了 _spin_*() API 的原型

  • <linux/spinlock.h> 构建了最终的 spin_*() API

以下头文件与单处理器(UP)平台构建相关:

  • <linux/spinlock_type_up.h> 包含了通用的、简化的 UP 自旋锁类型

  • <linux/spinlock_types.h> 定义了通用类型和初始化程序

  • <linux/spinlock_up.h>包含了arch_spin_*()和 UP 版本的类似构建(在非调试、非抢占构建上是 NOP)

  • <linux/spinlock_api_up.h>构建了_spin_*()API

  • <linux/spinlock.h>构建了最终的spin_*()APIs

通用内核头文件<linux/spinlock.h>包含一个条件指令,以决定拉取适当的(SMP 或 UP)API。

/*
 * Pull the _spin_*()/_read_*()/_write_*() functions/declarations:
 */
#if defined(CONFIG_SMP) || defined(CONFIG_DEBUG_SPINLOCK)
# include <linux/spinlock_api_smp.h>
#else
# include <linux/spinlock_api_up.h>
#endif

raw_spin_lock()raw_spin_unlock()宏会根据构建配置中选择的平台类型(SMP 或 UP)动态扩展为适当版本的自旋锁操作。对于 SMP 平台,raw_spin_lock()会扩展为内核源文件kernel/locking/spinlock.c中实现的__raw_spin_lock()操作。以下是使用宏定义的锁定操作代码:

/*
 * We build the __lock_function inlines here. They are too large for
 * inlining all over the place, but here is only one user per function
 * which embeds them into the calling _lock_function below.
 *
 * This could be a long-held lock. We both prepare to spin for a long
 * time (making _this_ CPU preemptable if possible), and we also signal
 * towards that other CPU that it should break the lock ASAP.
 */

#define BUILD_LOCK_OPS(op, locktype)                                    \
void __lockfunc __raw_##op##_lock(locktype##_t *lock)                   \
{                                                                       \
        for (;;) {                                                      \
                preempt_disable();                                      \
                if (likely(do_raw_##op##_trylock(lock)))                \
                        break;                                          \
                preempt_enable();                                       \
                                                                        \
                if (!(lock)->break_lock)                                \
                        (lock)->break_lock = 1;                         \
                while (!raw_##op##_can_lock(lock) && (lock)->break_lock)\
                        arch_##op##_relax(&lock->raw_lock);             \
        }                                                               \
        (lock)->break_lock = 0;                                         \
} 

这个例程由嵌套的循环结构组成,一个外部for循环结构和一个内部while循环,它会一直旋转,直到指定的条件满足为止。外部循环的第一个代码块通过调用特定于体系结构的##_trylock()例程来原子地尝试获取锁。请注意,此函数在本地处理器上禁用内核抢占时被调用。如果成功获取锁,则跳出循环结构,并且调用返回时关闭了抢占。这确保了持有锁的调用者上下文在执行临界区时不可抢占。这种方法还确保了在当前所有者释放锁之前,没有其他上下文可以在本地 CPU 上争夺相同的锁。

然而,如果它未能获取锁,通过preempt_enable()调用启用了抢占,并且调用者上下文进入内部循环。这个循环是通过一个条件while实现的,它会一直旋转,直到发现锁可用为止。循环的每次迭代都会检查锁,并且当它检测到锁还不可用时,会调用一个特定于体系结构的放松例程(执行特定于 CPU 的 nop 指令),然后再次旋转以检查锁。请记住,在此期间抢占是启用的;这确保了调用者上下文是可抢占的,并且不会长时间占用 CPU,尤其是在锁高度争用的情况下可能发生。这也允许同一 CPU 上调度的两个或更多线程争夺相同的锁,可能通过相互抢占来实现。

当旋转上下文检测到锁可用时,它会跳出while循环,导致调用者迭代回外部循环(for循环)的开始处,再次尝试通过##_trylock()来抓取锁,同时禁用抢占:

/*
 * In the UP-nondebug case there's no real locking going on, so the
 * only thing we have to do is to keep the preempt counts and irq
 * flags straight, to suppress compiler warnings of unused lock
 * variables, and to add the proper checker annotations:
 */
#define ___LOCK(lock) \
  do { __acquire(lock); (void)(lock); } while (0)

#define __LOCK(lock) \
  do { preempt_disable(); ___LOCK(lock); } while (0)

#define _raw_spin_lock(lock) __LOCK(lock)

与 SMP 变体不同,UP 平台的自旋锁实现非常简单;实际上,锁例程只是禁用内核抢占并将调用者放入临界区。这是因为在暂停抢占的情况下,没有其他上下文可能会争夺锁。

备用自旋锁 API

到目前为止我们讨论的标准自旋锁操作适用于仅从进程上下文内核路径访问的共享资源的保护。然而,可能存在一些场景,其中特定的共享资源或数据可能会从内核服务的进程上下文和中断上下文代码中访问。例如,考虑一个设备驱动程序服务,可能包含进程上下文和中断上下文例程,都编程来访问共享的驱动程序缓冲区以执行适当的 I/O 操作。

假设使用自旋锁来保护驱动程序的共享资源免受并发访问,并且驱动程序服务的所有例程(包括进程和中断上下文)都使用标准的spin_lock()spin_unlock()操作编程了适当的临界区。这种策略将通过强制排斥来确保共享资源的保护,但可能会导致 CPU 在随机时间出现硬锁定条件,因为中断路径代码在同一 CPU 上争夺。为了进一步理解这一点,让我们假设以下事件按相同顺序发生:

  1. 驱动程序的进程上下文例程获取锁(使用标准的spin_lock()调用*)。

  2. 关键部分正在执行时,发生中断并被驱动到本地 CPU,导致进程上下文例程被抢占并让出 CPU 给中断处理程序。

  3. 驱动程序的中断上下文路径(ISR)开始并尝试获取锁(使用标准的spin_lock()调用),然后开始自旋等待可用。

在 ISR 的持续时间内,进程上下文被抢占并且永远无法恢复执行,导致永远无法释放,并且 CPU 被一个永远不会放弃的自旋中断处理程序硬锁定。

为了防止这种情况发生,进程上下文代码需要在获取时禁用当前处理器上的中断。这将确保中断在临界区和锁释放之前永远无法抢占当前上下文。请注意,中断仍然可能发生,但会路由到其他可用的 CPU 上,在那里中断处理程序可以自旋,直到变为可用。自旋锁接口提供了另一种锁定例程spin_lock_irqsave(),它会禁用当前处理器上的中断以及内核抢占。以下代码片段显示了该例程的基础代码:

unsigned long __lockfunc __raw_##op##_lock_irqsave(locktype##_t *lock)  \
{                                                                       \
        unsigned long flags;                                            \
                                                                        \
        for (;;) {                                                      \
                preempt_disable();                                      \
                local_irq_save(flags);                                  \
                if (likely(do_raw_##op##_trylock(lock)))                \
                        break;                                          \
                local_irq_restore(flags);                               \
                preempt_enable();                                       \
                                                                        \
                if (!(lock)->break_lock)                                \
                        (lock)->break_lock = 1;                         \
                while (!raw_##op##_can_lock(lock) && (lock)->break_lock)\
                        arch_##op##_relax(&lock->raw_lock);             \
        }                                                               \
        (lock)->break_lock = 0;                                         \
        return flags;                                                   \
} 

调用local_irq_save()来禁用当前处理器的硬中断;请注意,如果未能获取锁,则通过调用local_irq_restore()来启用中断。请注意,使用spin_lock_irqsave()获取的锁需要使用spin_lock_irqrestore()来解锁,这会在释放锁之前为当前处理器启用内核抢占和中断。

与硬中断处理程序类似,软中断上下文例程(如softirqs,tasklets和其他bottom halves)也可能争夺同一处理器上由进程上下文代码持有的。这可以通过在进程上下文中获取时禁用bottom halves的执行来防止。spin_lock_bh()是另一种锁定例程的变体,它负责挂起本地 CPU 上的中断上下文 bottom halves 的执行。

void __lockfunc __raw_##op##_lock_bh(locktype##_t *lock)                \
{                                                                       \
        unsigned long flags;                                            \
                                                                        \
        /* */                                                           \
        /* Careful: we must exclude softirqs too, hence the */          \
        /* irq-disabling. We use the generic preemption-aware */        \
        /* function: */                                                 \
        /**/                                                            \
        flags = _raw_##op##_lock_irqsave(lock);                         \
        local_bh_disable();                                             \
        local_irq_restore(flags);                                       \
} 

local_bh_disable()挂起本地 CPU 的 bottom half 执行。要释放由spin_lock_bh()获取的,调用者上下文将需要调用spin_unlock_bh(),这将释放本地 CPU 的自旋锁和 BH 锁。

以下是内核自旋锁 API 接口的摘要列表:

函数 描述
spin_lock_init() 初始化自旋锁
spin_lock() 获取锁,在竞争时自旋
spin_trylock() 尝试获取锁,在竞争时返回错误
spin_lock_bh() 通过挂起本地处理器上的 BH 例程来获取锁,在竞争时自旋
spin_lock_irqsave() 通过保存当前中断状态来挂起本地处理器上的中断来获取锁,在竞争时自旋
spin_lock_irq() 通过挂起本地处理器上的中断来获取锁,在竞争时自旋
spin_unlock() 释放锁
spin_unlock_bh() 释放本地处理器的锁并启用 bottom half
spin_unlock_irqrestore() 释放锁并将本地中断恢复到先前的状态
spin_unlock_irq() 释放锁并恢复本地处理器的中断
spin_is_locked() 返回锁的状态,如果锁被持有则返回非零,如果锁可用则返回零

读写器自旋锁

到目前为止讨论的自旋锁实现通过强制并发代码路径之间的标准互斥来保护共享数据的访问。这种形式的排斥不适合保护经常被并发代码路径读取的共享数据,而写入或更新很少。读写锁强制在读取器和写入器路径之间进行排斥;这允许并发读取器共享锁,而读取任务将需要等待锁,而写入器拥有锁。Rw-locks 强制在并发写入器之间进行标准排斥,这是期望的。

Rw-locks 由在内核头文件<linux/rwlock_types.h>中声明的struct rwlock_t表示:

typedef struct {
        arch_rwlock_t raw_lock;
#ifdef CONFIG_GENERIC_LOCKBREAK
        unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
        unsigned int magic, owner_cpu;
        void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
        struct lockdep_map dep_map;
#endif
} rwlock_t;

rwlocks 可以通过宏DEFINE_RWLOCK(v_rwlock)静态初始化,也可以通过rwlock_init(v_rwlock)在运行时动态初始化。

读取器代码路径将需要调用read_lock例程。

read_lock(&v_rwlock);
/* critical section with read only access to shared data */
read_unlock(&v_rwlock);

写入器代码路径使用以下内容:

write_lock(&v_rwlock);
/* critical section for both read and write */
write_unlock(&v_lock);

当锁有争用时,读取和写入锁例程都会自旋。该接口还提供了称为read_trylock()write_trylock()的非自旋版本的锁函数。它还提供了锁定调用的中断禁用版本,当读取或写入路径恰好在中断或底半部上下文中执行时非常方便。

以下是接口操作的摘要列表:

函数 描述
read_lock() 标准读锁接口,当有争用时会自旋
read_trylock() 尝试获取锁,如果锁不可用则返回错误
read_lock_bh() 通过挂起本地 CPU 的 BH 执行来尝试获取锁,当有争用时会自旋
read_lock_irqsave() 通过保存本地中断的当前状态来尝试通过挂起当前 CPU 的中断来获取锁,当有争用时会自旋
read_unlock() 释放读锁
read_unlock_irqrestore() 释放持有的锁并将本地中断恢复到先前的状态
read_unlock_bh() 释放读锁并在本地处理器上启用 BH
write_lock() 标准写锁接口,当有争用时会自旋
write_trylock() 尝试获取锁,如果有争用则返回错误
write_lock_bh() 尝试通过挂起本地 CPU 的底半部来获取写锁,当有争用时会自旋
wrtie_lock_irqsave() 通过保存本地中断的当前状态来尝试通过挂起本地 CPU 的中断来获取写锁,当有争用时会自旋
write_unlock() 释放写锁
write_unlock_irqrestore() 释放锁并将本地中断恢复到先前的状态
write_unlock_bh() 释放写锁并在本地处理器上启用 BH

所有这些操作的底层调用与自旋锁实现的类似,并且可以在前面提到的自旋锁部分指定的头文件中找到。

互斥锁

自旋锁的设计更适用于锁定持续时间短、固定的情况,因为无限期的忙等待会对系统的性能产生严重影响。然而,有许多情况下锁定持续时间较长且不确定;睡眠锁正是为这种情况而设计的。内核互斥锁是睡眠锁的一种实现:当调用任务尝试获取一个不可用的互斥锁(已被另一个上下文拥有),它会被置于休眠状态并移出到等待队列,强制进行上下文切换,从而允许 CPU 运行其他有生产力的任务。当互斥锁变为可用时,等待队列中的任务将被唤醒并通过互斥锁的解锁路径移动,然后尝试锁定互斥锁。

互斥锁由include/linux/mutex.h中定义的struct mutex表示,并且相应的操作在源文件kernel/locking/mutex.c中实现:

 struct mutex {
          atomic_long_t owner;
          spinlock_t wait_lock;
 #ifdef CONFIG_MUTEX_SPIN_ON_OWNER
          struct optimistic_spin_queue osq; /* Spinner MCS lock */
 #endif
          struct list_head wait_list;
 #ifdef CONFIG_DEBUG_MUTEXES
          void *magic;
 #endif
 #ifdef CONFIG_DEBUG_LOCK_ALLOC
          struct lockdep_map dep_map;
 #endif
 }; 

在其基本形式中,每个互斥锁都包含一个 64 位的atomic_long_t计数器(owner),用于保存锁定状态,并存储当前拥有锁的任务结构的引用。每个互斥锁都包含一个等待队列(wait_list)和一个自旋锁(wait_lock),用于对wait_list进行串行访问。

互斥锁 API 接口提供了一组宏和函数,用于初始化、锁定、解锁和访问互斥锁的状态。这些操作接口在<include/linux/mutex.h>中定义。

可以使用宏DEFINE_MUTEX(name)声明和初始化互斥锁。

还有一种选项,可以通过mutex_init(mutex)动态初始化有效的互斥锁。

如前所述,在争用时,锁操作会将调用线程置于休眠状态,这要求在将其移入互斥锁等待列表之前,将调用线程置于TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLETASK_KILLABLE状态。为了支持这一点,互斥锁实现提供了两种锁操作的变体,一种用于不可中断,另一种用于可中断休眠。以下是每个标准互斥锁操作的简要描述:

/**
 * mutex_lock - acquire the mutex
 * @lock: the mutex to be acquired
 *
 * Lock the mutex exclusively for this task. If the mutex is not
 * available right now, Put caller into Uninterruptible sleep until mutex 
 * is available.
 */
    void mutex_lock(struct mutex *lock);

/**
 * mutex_lock_interruptible - acquire the mutex, interruptible
 * @lock: the mutex to be acquired
 *
 * Lock the mutex like mutex_lock(), and return 0 if the mutex has
 * been acquired else put caller into interruptible sleep until the mutex  
 * until mutex is available. Return -EINTR if a signal arrives while sleeping
 * for the lock.                               
 */
 int __must_check mutex_lock_interruptible(struct mutex *lock); /**
 * mutex_lock_Killable - acquire the mutex, interruptible
 * @lock: the mutex to be acquired
 *
 * Similar to mutex_lock_interruptible(),with a difference that the call
 * returns -EINTR only when fatal KILL signal arrives while sleeping for the     
 * lock.                              
 */
 int __must_check mutex_lock_killable(struct mutex *lock); /**
 * mutex_trylock - try to acquire the mutex, without waiting
 * @lock: the mutex to be acquired
 *
 * Try to acquire the mutex atomically. Returns 1 if the mutex
 * has been acquired successfully, and 0 on contention.
 *
 */
    int mutex_trylock(struct mutex *lock); /**
 * atomic_dec_and_mutex_lock - return holding mutex if we dec to 0,
 * @cnt: the atomic which we are to dec
 * @lock: the mutex to return holding if we dec to 0
 *
 * return true and hold lock if we dec to 0, return false otherwise. Please 
 * note that this function is interruptible.
 */
    int atomic_dec_and_mutex_lock(atomic_t *cnt, struct mutex *lock); 
/**
 * mutex_is_locked - is the mutex locked
 * @lock: the mutex to be queried
 *
 * Returns 1 if the mutex is locked, 0 if unlocked.
 */
/**
 * mutex_unlock - release the mutex
 * @lock: the mutex to be released
 *
 * Unlock the mutex owned by caller task.
 *
 */
 void mutex_unlock(struct mutex *lock);

尽管可能会阻塞调用,但互斥锁定函数已经针对性能进行了大幅优化。它们被设计为在尝试获取锁时采用快速路径和慢速路径方法。让我们深入了解锁定调用的代码,以更好地理解快速路径和慢速路径。以下代码摘录是来自<kernel/locking/mutex.c>中的mutex_lock()例程:

void __sched mutex_lock(struct mutex *lock)
{
  might_sleep();

  if (!__mutex_trylock_fast(lock))
    __mutex_lock_slowpath(lock);
}

首先通过调用非阻塞的快速路径调用__mutex_trylock_fast()来尝试获取锁。如果由于争用而无法获取锁,则通过调用__mutex_lock_slowpath()进入慢速路径:

static __always_inline bool __mutex_trylock_fast(struct mutex *lock)
{
  unsigned long curr = (unsigned long)current;

  if (!atomic_long_cmpxchg_acquire(&lock->owner, 0UL, curr))
    return true;

  return false;
}

如果可用,此函数被设计为原子方式获取锁。它调用atomic_long_cmpxchg_acquire()宏,该宏尝试将当前线程分配为互斥锁的所有者;如果互斥锁可用,则此操作将成功,此时函数返回true。如果某些其他线程拥有互斥锁,则此函数将失败并返回false。在失败时,调用线程将进入慢速路径例程。

传统上,慢速路径的概念一直是将调用任务置于休眠状态,同时等待锁变为可用。然而,随着多核 CPU 的出现,人们对可伸缩性和性能的需求不断增长,因此为了实现可伸缩性,互斥锁慢速路径实现已经重新设计,引入了称为乐观自旋的优化,也称为中间路径,可以显著提高性能。

乐观自旋的核心思想是将竞争任务推入轮询或自旋,而不是在发现互斥体所有者正在运行时休眠。一旦互斥体变为可用(因为发现所有者正在运行,所以预计会更快),就假定自旋任务始终可以比互斥体等待列表中的挂起或休眠任务更快地获取它。但是,只有当没有其他处于就绪状态的更高优先级任务时,才有可能进行这种自旋。有了这个特性,自旋任务更有可能是缓存热点,从而产生可预测的执行,从而产生明显的性能改进:

static int __sched
__mutex_lock(struct mutex *lock, long state, unsigned int subclass,
       struct lockdep_map *nest_lock, unsigned long ip)
{
  return __mutex_lock_common(lock, state, subclass, nest_lock, ip, NULL,     false);
}

...
...
...

static noinline void __sched __mutex_lock_slowpath(struct mutex *lock) 
{
        __mutex_lock(lock, TASK_UNINTERRUPTIBLE, 0, NULL, _RET_IP_); 
}

static noinline int __sched
__mutex_lock_killable_slowpath(struct mutex *lock)
{
  return __mutex_lock(lock, TASK_KILLABLE, 0, NULL, _RET_IP_);
}

static noinline int __sched
__mutex_lock_interruptible_slowpath(struct mutex *lock)
{
  return __mutex_lock(lock, TASK_INTERRUPTIBLE, 0, NULL, _RET_IP_);
}

__mutex_lock_common()函数包含一个带有乐观自旋的慢路径实现;这个例程由所有互斥锁定函数的睡眠变体调用,带有适当的标志作为参数。这个函数首先尝试通过与互斥体关联的可取消的 mcs 自旋锁(互斥体结构中的 osq 字段)实现乐观自旋来获取互斥体。当调用者任务无法通过乐观自旋获取互斥体时,作为最后的手段,这个函数切换到传统的慢路径,导致调用者任务进入睡眠,并排队进入互斥体的wait_list,直到被解锁路径唤醒。

调试检查和验证

错误使用互斥操作可能导致死锁、排除失败等。为了检测和防止这种可能发生的情况,互斥子系统配备了适当的检查或验证,这些检查默认情况下是禁用的,可以通过在内核构建过程中选择配置选项CONFIG_DEBUG_MUTEXES=y来启用。

以下是受检的调试代码强制执行的检查列表:

  • 互斥体在给定时间点只能由一个任务拥有

  • 互斥体只能由有效所有者释放(解锁),任何尝试由不拥有锁的上下文释放互斥体的尝试都将失败

  • 递归锁定或解锁尝试将失败

  • 互斥体只能通过初始化调用进行初始化,并且任何对memset互斥体的尝试都不会成功

  • 调用者任务可能不会在持有互斥锁的情况下退出

  • 不得释放包含持有的锁的动态内存区域

  • 互斥体只能初始化一次,任何尝试重新初始化已初始化的互斥体都将失败

  • 互斥体可能不会在硬/软中断上下文例程中使用

死锁可能由许多原因触发,例如内核代码的执行模式和锁定调用的粗心使用。例如,让我们考虑这样一种情况:并发代码路径需要通过嵌套锁定函数来拥有L[1]L[2]锁。必须确保所有需要这些锁的内核函数都被编程为以相同的顺序获取它们。当没有严格强制执行这样的顺序时,总会有两个不同的函数尝试以相反的顺序锁定L1L2的可能性,这可能会触发锁反转死锁,当这些函数并发执行时。

内核锁验证器基础设施已经实施,以检查并证明在内核运行时观察到的任何锁定模式都不会导致死锁。此基础设施打印与锁定模式相关的数据,例如:

  • 获取点跟踪、函数名称的符号查找和系统中所有持有的锁列表

  • 所有者跟踪

  • 检测自递归锁并打印所有相关信息

  • 检测锁反转死锁并打印所有受影响的锁和任务

可以通过在内核构建过程中选择CONFIG_PROVE_LOCKING=y来启用锁验证器。

等待/伤害互斥体

如前一节所讨论的,在内核函数中无序的嵌套锁定可能会导致锁反转死锁的风险,内核开发人员通过定义嵌套锁定顺序的规则并通过锁验证器基础设施执行运行时检查来避免这种情况。然而,存在动态锁定顺序的情况,无法将嵌套锁定调用硬编码或根据预设规则强加。

一个这样的用例与 GPU 缓冲区有关;这些缓冲区应该由各种系统实体拥有和访问,比如 GPU 硬件、GPU 驱动程序、用户模式应用程序和其他与视频相关的驱动程序。用户模式上下文可以以任意顺序提交 dma 缓冲区进行处理,GPU 硬件可以在任意时间处理它们。如果使用锁来控制缓冲区的所有权,并且必须同时操作多个缓冲区,则无法避免死锁。等待/伤害互斥锁旨在促进嵌套锁的动态排序,而不会导致锁反转死锁。这是通过强制争用的上下文伤害来实现的,意味着强制它释放持有的锁。

例如,假设有两个缓冲区,每个缓冲区都受到锁的保护,进一步考虑两个线程,比如T[1]T[2],它们通过以相反的顺序尝试锁定来寻求对缓冲区的所有权:

Thread T1       Thread T2
===========    ==========
lock(bufA);     lock(bufB);
lock(bufB);     lock(bufA);
 ....            ....
 ....            ....
unlock(bufB);   unlock(bufA);
unlock(bufA);   unlock(bufB);

T[1]T[2]的并发执行可能导致每个线程等待另一个持有的锁,从而导致死锁。等待/伤害互斥锁通过让首先抓住锁的线程保持睡眠,等待嵌套锁可用来防止这种情况。另一个线程被伤害,导致它释放其持有的锁并重新开始。假设T[1]bufA上获得锁之前,T[2]可以在bufB上获得锁。T[1]将被视为首先到达的线程,并被放到bufB的锁上睡眠,T[2]将被伤害,导致它释放bufB上的锁并重新开始。这样可以避免死锁,当T[1]释放持有的锁时,T[2]将重新开始。

操作接口:

等待/伤害互斥锁通过在头文件<linux/ww_mutex.h>中定义的struct ww_mutex来表示:

struct ww_mutex {
       struct mutex base;
       struct ww_acquire_ctx *ctx;
# ifdef CONFIG_DEBUG_MUTEXES
       struct ww_class *ww_class;
#endif
};

使用等待/伤害互斥锁的第一步是定义一个,这是一种表示一组锁的机制。当并发任务争夺相同的锁时,它们必须通过指定这个类来这样做。

可以使用宏定义一个类:

static DEFINE_WW_CLASS(bufclass);

声明的每个类都是struct ww_class类型的实例,并包含一个原子计数器stamp,用于记录哪个竞争任务首先到达的序列号。其他字段由内核的锁验证器用于验证等待/伤害机制的正确使用。

struct ww_class {
       atomic_long_t stamp;
       struct lock_class_key acquire_key;
       struct lock_class_key mutex_key;
       const char *acquire_name;
       const char *mutex_name;
};

每个竞争的线程在尝试嵌套锁定调用之前必须调用ww_acquire_init()。这通过分配一个序列号来设置上下文以跟踪锁。

/**
 * ww_acquire_init - initialize a w/w acquire context
 * @ctx: w/w acquire context to initialize
 * @ww_class: w/w class of the context
 *
 * Initializes a context to acquire multiple mutexes of the given w/w class.
 *
 * Context-based w/w mutex acquiring can be done in any order whatsoever 
 * within a given lock class. Deadlocks will be detected and handled with the
 * wait/wound logic.
 *
 * Mixing of context-based w/w mutex acquiring and single w/w mutex locking 
 * can result in undetected deadlocks and is so forbidden. Mixing different
 * contexts for the same w/w class when acquiring mutexes can also result in 
 * undetected deadlocks, and is hence also forbidden. Both types of abuse will 
 * will be caught by enabling CONFIG_PROVE_LOCKING.
 *
 */
   void ww_acquire_init(struct ww_acquire_ctx *ctx, struct ww_clas *ww_class);

一旦上下文设置和初始化,任务可以开始使用ww_mutex_lock()ww_mutex_lock_interruptible()调用获取锁:

/**
 * ww_mutex_lock - acquire the w/w mutex
 * @lock: the mutex to be acquired
 * @ctx: w/w acquire context, or NULL to acquire only a single lock.
 *
 * Lock the w/w mutex exclusively for this task.
 *
 * Deadlocks within a given w/w class of locks are detected and handled with 
 * wait/wound algorithm. If the lock isn't immediately available this function
 * will either sleep until it is(wait case) or it selects the current context
 * for backing off by returning -EDEADLK (wound case).Trying to acquire the
 * same lock with the same context twice is also detected and signalled by
 * returning -EALREADY. Returns 0 if the mutex was successfully acquired.
 *
 * In the wound case the caller must release all currently held w/w mutexes  
 * for the given context and then wait for this contending lock to be 
 * available by calling ww_mutex_lock_slow. 
 *
 * The mutex must later on be released by the same task that
 * acquired it. The task may not exit without first unlocking the mutex.Also,
 * kernel memory where the mutex resides must not be freed with the mutex 
 * still locked. The mutex must first be initialized (or statically defined) b
 * before it can be locked. memset()-ing the mutex to 0 is not allowed. The
 * mutex must be of the same w/w lock class as was used to initialize the 
 * acquired context.
 * A mutex acquired with this function must be released with ww_mutex_unlock.
 */
    int ww_mutex_lock(struct ww_mutex *lock, struct ww_acquire_ctx *ctx);

/**
 * ww_mutex_lock_interruptible - acquire the w/w mutex, interruptible
 * @lock: the mutex to be acquired
 * @ctx: w/w acquire context
 *
 */
   int  ww_mutex_lock_interruptible(struct ww_mutex *lock, 
                                             struct  ww_acquire_ctx *ctx);

当任务抓取与类相关的所有嵌套锁(使用这些锁定例程中的任何一个)时,需要使用函数ww_acquire_done()通知所有权的获取。这个调用标志着获取阶段的结束,任务可以继续处理共享数据:

/**
 * ww_acquire_done - marks the end of the acquire phase
 * @ctx: the acquire context
 *
 * Marks the end of the acquire phase, any further w/w mutex lock calls using
 * this context are forbidden.
 *
 * Calling this function is optional, it is just useful to document w/w mutex
 * code and clearly designated the acquire phase from actually using the 
 * locked data structures.
 */
 void ww_acquire_done(struct ww_acquire_ctx *ctx);

当任务完成对共享数据的处理时,可以通过调用ww_mutex_unlock()例程开始释放所有持有的锁。一旦所有锁都被释放,上下文必须通过调用ww_acquire_fini()来释放:

/**
 * ww_acquire_fini - releases a w/w acquire context
 * @ctx: the acquire context to free
 *
 * Releases a w/w acquire context. This must be called _after_ all acquired 
 * w/w mutexes have been released with ww_mutex_unlock.
 */
    void ww_acquire_fini(struct ww_acquire_ctx *ctx);

信号量

在 2.6 内核早期版本之前,信号量是睡眠锁的主要形式。典型的信号量实现包括一个计数器、等待队列和一组可以原子地增加/减少计数器的操作。

当信号量用于保护共享资源时,其计数器被初始化为大于零的数字,被视为解锁状态。寻求访问共享资源的任务首先通过对信号量进行减操作来开始。此调用检查信号量计数器;如果发现大于零,则将计数器减一,并返回成功。但是,如果计数器为零,则减操作将调用者任务置于睡眠状态,直到计数器增加到大于零为止。

这种简单的设计提供了很大的灵活性,允许信号量适应和应用于不同的情况。例如,对于需要在任何时候对特定数量的任务可访问的资源的情况,信号量计数可以初始化为需要访问的任务数量,比如 10,这允许最多 10 个任务在任何时候访问共享资源。对于其他情况,例如需要互斥访问共享资源的任务数量,信号量计数可以初始化为 1,导致在任何给定时刻最多一个任务访问资源。

信号量结构及其接口操作在内核头文件<include/linux/semaphore.h>中声明:

struct semaphore {
        raw_spinlock_t     lock;
        unsigned int       count;
        struct list_head   wait_list;
};

自旋锁(lock字段)用作对count的保护,也就是说,信号量操作(增加/减少)被编程为在操作count之前获取lockwait_list用于将任务排队等待,直到信号量计数增加到零以上为止。

信号量可以通过宏DEFINE_SEMAPHORE(s)声明和初始化为 1。

信号量也可以通过以下方式动态初始化为任何正数:

void sema_init(struct semaphore *sem, int val)

以下是一系列操作接口及其简要描述。命名约定为down_xxx()的例程尝试减少信号量,并且可能是阻塞调用(除了down_trylock()),而例程up()增加信号量并且总是成功:

/**
 * down_interruptible - acquire the semaphore unless interrupted
 * @sem: the semaphore to be acquired
 *
 * Attempts to acquire the semaphore.  If no more tasks are allowed to
 * acquire the semaphore, calling this function will put the task to sleep.
 * If the sleep is interrupted by a signal, this function will return -EINTR.
 * If the semaphore is successfully acquired, this function returns 0.
 */
 int down_interruptible(struct semaphore *sem); /**
 * down_killable - acquire the semaphore unless killed
 * @sem: the semaphore to be acquired
 *
 * Attempts to acquire the semaphore.  If no more tasks are allowed to
 * acquire the semaphore, calling this function will put the task to sleep.
 * If the sleep is interrupted by a fatal signal, this function will return
 * -EINTR.  If the semaphore is successfully acquired, this function returns
 * 0.
 */
 int down_killable(struct semaphore *sem); /**
 * down_trylock - try to acquire the semaphore, without waiting
 * @sem: the semaphore to be acquired
 *
 * Try to acquire the semaphore atomically.  Returns 0 if the semaphore has
 * been acquired successfully or 1 if it it cannot be acquired.
 *
 */
 int down_trylock(struct semaphore *sem); /**
 * down_timeout - acquire the semaphore within a specified time
 * @sem: the semaphore to be acquired
 * @timeout: how long to wait before failing
 *
 * Attempts to acquire the semaphore.  If no more tasks are allowed to
 * acquire the semaphore, calling this function will put the task to sleep.
 * If the semaphore is not released within the specified number of jiffies,
 * this function returns -ETIME.  It returns 0 if the semaphore was acquired.
 */
 int down_timeout(struct semaphore *sem, long timeout); /**
 * up - release the semaphore
 * @sem: the semaphore to release
 *
 * Release the semaphore.  Unlike mutexes, up() may be called from any
 * context and even by tasks which have never called down().
 */
 void up(struct semaphore *sem);

与互斥锁实现不同,信号量操作不支持调试检查或验证;这个约束是由于它们固有的通用设计,允许它们被用作排他锁、事件通知计数器等。自从互斥锁进入内核(2.6.16)以来,信号量不再是排他性的首选,信号量作为锁的使用大大减少,而对于其他目的,内核有备用接口。大部分使用信号量的内核代码已经转换为互斥锁,只有少数例外。然而,信号量仍然存在,并且至少在所有使用它们的内核代码转换为互斥锁或其他合适的接口之前,它们可能会继续存在。

读写信号量

该接口是睡眠读写排他的实现,作为自旋的替代。读写信号量由struct rw_semaphore表示,在内核头文件<linux/rwsem.h>中声明:

struct rw_semaphore {
        atomic_long_t count;
        struct list_head wait_list;
        raw_spinlock_t wait_lock;
#ifdef CONFIG_RWSEM_SPIN_ON_OWNER
       struct optimistic_spin_queue osq; /* spinner MCS lock */
       /*
       * Write owner. Used as a speculative check to see
       * if the owner is running on the cpu.
       */
      struct task_struct *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
     struct lockdep_map dep_map;
#endif
};

该结构与互斥锁的结构相同,并且设计为支持通过osq进行乐观自旋;它还通过内核的lockdep包括调试支持。Count用作排他计数器,设置为 1,允许最多一个写者在某一时刻拥有锁。这是因为互斥仅在竞争写者之间执行,并且任意数量的读者可以同时共享读锁。wait_lock是一个自旋锁,用于保护信号量wait_list

rw_semaphore可以通过DECLARE_RWSEM(name)静态实例化和初始化,也可以通过init_rwsem(sem)动态初始化。

与 rw 自旋锁一样,该接口也为读者和写者路径的锁获取提供了不同的例程。以下是接口操作的列表:

/* reader interfaces */
   void down_read(struct rw_semaphore *sem);
   void up_read(struct rw_semaphore *sem);
/* trylock for reading -- returns 1 if successful, 0 if contention */
   int down_read_trylock(struct rw_semaphore *sem);
   void up_read(struct rw_semaphore *sem);

/* writer Interfaces */
   void down_write(struct rw_semaphore *sem);
   int __must_check down_write_killable(struct rw_semaphore *sem);

/* trylock for writing -- returns 1 if successful, 0 if contention */
   int down_write_trylock(struct rw_semaphore *sem); 
   void up_write(struct rw_semaphore *sem);
/* downgrade write lock to read lock */
   void downgrade_write(struct rw_semaphore *sem); 

/* check if rw-sem is currently locked */  
   int rwsem_is_locked(struct rw_semaphore *sem);

这些操作是在源文件<kernel/locking/rwsem.c>中实现的;代码相当自解释,我们不会进一步讨论它。

序列锁

传统的读写锁设计为读者优先,它们可能导致写入任务等待非确定性的持续时间,这在具有时间敏感更新的共享数据上可能不合适。这就是顺序锁派上用场的地方,因为它旨在提供对共享资源的快速和无锁访问。当需要保护的资源较小且简单,写访问快速且不频繁时,顺序锁是最佳选择,因为在内部,顺序锁会退回到自旋锁原语。

顺序锁引入了一个特殊的计数器,每当写入者获取顺序锁时都会增加该计数器,并附带一个自旋锁。写入者完成后,释放自旋锁并再次增加计数器,为其他写入者打开访问。对于读取,有两种类型的读取者:序列读取者和锁定读取者。序列读取者在进入临界区之前检查计数器,然后在不阻塞任何写入者的情况下在临界区结束时再次检查。如果计数器保持不变,这意味着在读取期间没有写入者访问该部分,但如果在部分结束时计数器增加,则表明写入者已访问,这要求读取者重新读取临界部分以获取更新的数据。锁定读取者会获得锁并在进行时阻塞其他读取者和写入者;当另一个锁定读取者或写入者进行时,它也会等待。

序列锁由以下类型表示:

typedef struct {
        struct seqcount seqcount;
        spinlock_t lock;
} seqlock_t;

我们可以使用以下宏静态初始化序列锁:

#define DEFINE_SEQLOCK(x) \
               seqlock_t x = __SEQLOCK_UNLOCKED(x)

实际初始化是使用__SEQLOCK_UNLOCKED(x)来完成的,其定义在这里:

#define __SEQLOCK_UNLOCKED(lockname)                 \
       {                                               \
               .seqcount = SEQCNT_ZERO(lockname),     \
               .lock = __SPIN_LOCK_UNLOCKED(lockname)   \
       }

要动态初始化序列锁,我们需要使用seqlock_init宏,其定义如下:

  #define seqlock_init(x)                                     \
       do {                                                   \
               seqcount_init(&(x)->seqcount);                 \
               spin_lock_init(&(x)->lock);                    \
       } while (0)

API

Linux 提供了许多用于使用序列锁的 API,这些 API 在</linux/seqlock.h>中定义。以下是一些重要的 API:

static inline void write_seqlock(seqlock_t *sl)
{
        spin_lock(&sl->lock);
        write_seqcount_begin(&sl->seqcount);
}

static inline void write_sequnlock(seqlock_t *sl)
{
        write_seqcount_end(&sl->seqcount);
        spin_unlock(&sl->lock);
}

static inline void write_seqlock_bh(seqlock_t *sl)
{
        spin_lock_bh(&sl->lock);
        write_seqcount_begin(&sl->seqcount);
}

static inline void write_sequnlock_bh(seqlock_t *sl)
{
        write_seqcount_end(&sl->seqcount);
        spin_unlock_bh(&sl->lock);
}

static inline void write_seqlock_irq(seqlock_t *sl)
{
        spin_lock_irq(&sl->lock);
        write_seqcount_begin(&sl->seqcount);
}

static inline void write_sequnlock_irq(seqlock_t *sl)
{
        write_seqcount_end(&sl->seqcount);
        spin_unlock_irq(&sl->lock);
}

static inline unsigned long __write_seqlock_irqsave(seqlock_t *sl)
{
        unsigned long flags;

        spin_lock_irqsave(&sl->lock, flags);
        write_seqcount_begin(&sl->seqcount);
        return flags;
}

以下两个函数用于通过开始和完成读取部分:

static inline unsigned read_seqbegin(const seqlock_t *sl)
{
        return read_seqcount_begin(&sl->seqcount);
}

static inline unsigned read_seqretry(const seqlock_t *sl, unsigned start)
{
        return read_seqcount_retry(&sl->seqcount, start);
}

完成锁

完成锁是一种有效的方式来实现代码同步,如果需要一个或多个执行线程等待某个事件的完成,比如等待另一个进程达到某个点或状态。完成锁可能比信号量更受欢迎,原因有几点:多个执行线程可以等待完成,并且使用complete_all(),它们可以一次性全部释放。这比信号量唤醒多个线程要好得多。其次,如果等待线程释放同步对象,信号量可能导致竞争条件;使用完成时,这个问题就不存在。

通过包含<linux/completion.h>并创建一个struct completion类型的变量来使用完成结构,这是一个用于维护完成状态的不透明结构。它使用 FIFO 来排队等待完成事件的线程:

struct completion {
        unsigned int done;
        wait_queue_head_t wait;
};

完成基本上包括初始化完成结构,通过wait_for_completion()调用的任何变体等待,最后通过complete()complete_all()调用发出完成信号。在其生命周期中还有函数来检查完成的状态。

初始化

以下宏可用于静态声明和初始化完成结构:

#define DECLARE_COMPLETION(work) \
       struct completion work = COMPLETION_INITIALIZER(work)

以下内联函数将初始化动态创建的完成结构:

static inline void init_completion(struct completion *x)
{
        x->done = 0;
        init_waitqueue_head(&x->wait);
}

以下内联函数将用于在需要重用时重新初始化完成结构。这可以在complete_all()之后使用:

static inline void reinit_completion(struct completion *x)
{
        x->done = 0;
}

等待完成

如果任何线程需要等待任务完成,它将在初始化的完成结构上调用wait_for_completion()。如果wait_for_completion操作发生在调用complete()complete_all()之后,则线程将简单地继续,因为它想要等待的原因已经得到满足;否则,它将等待直到complete()被发出信号。对于wait_for_completion()调用有可用的变体:

extern void wait_for_completion_io(struct completion *);
extern int wait_for_completion_interruptible(struct completion *x);
extern int wait_for_completion_killable(struct completion *x);
extern unsigned long wait_for_completion_timeout(struct completion *x,
                                                   unsigned long timeout);
extern unsigned long wait_for_completion_io_timeout(struct completion *x,
                                                    unsigned long timeout);
extern long wait_for_completion_interruptible_timeout(
        struct completion *x, unsigned long timeout);
extern long wait_for_completion_killable_timeout(
        struct completion *x, unsigned long timeout);
extern bool try_wait_for_completion(struct completion *x);
extern bool completion_done(struct completion *x);

extern void complete(struct completion *);
extern void complete_all(struct completion *);

完成信号

希望发出完成预期任务的执行线程调用complete()向等待的线程发出信号,以便它可以继续。线程将按照它们排队的顺序被唤醒。在有多个等待者的情况下,它调用complete_all()

void complete(struct completion *x)
{
        unsigned long flags;

        spin_lock_irqsave(&x->wait.lock, flags);
        if (x->done != UINT_MAX)
                x->done++;
        __wake_up_locked(&x->wait, TASK_NORMAL, 1);
        spin_unlock_irqrestore(&x->wait.lock, flags);
}
EXPORT_SYMBOL(complete);
void complete_all(struct completion *x)
{
        unsigned long flags;

        spin_lock_irqsave(&x->wait.lock, flags);
        x->done = UINT_MAX;
        __wake_up_locked(&x->wait, TASK_NORMAL, 0);
        spin_unlock_irqrestore(&x->wait.lock, flags);
}
EXPORT_SYMBOL(complete_all);

总结

在本章中,我们不仅了解了内核提供的各种保护和同步机制,还试图欣赏这些选项的有效性,以及它们的各种功能和缺陷。本章的收获必须是内核处理这些不同复杂性以提供数据保护和同步的坚韧性。另一个值得注意的事实是内核在处理这些问题时保持了编码的便利性和设计的优雅。

在我们的下一章中,我们将看一下中断如何由内核处理的另一个关键方面。

第九章:中断和延迟工作

中断是传递给处理器的电信号,指示发生需要立即处理的重大事件。这些信号可以来自系统连接的外部硬件或处理器内部的电路。在本章中,我们将研究内核的中断管理子系统,并探讨以下内容:

  • 可编程中断控制器

  • 中断向量表

  • IRQs

  • IRQ 芯片和 IRQ 描述符

  • 注册和注销中断处理程序

  • IRQ 线路控制操作

  • IRQ 堆栈

  • 延迟例程的需求

  • 软中断

  • 任务

  • 工作队列

中断信号和向量

当中断来自外部设备时,称为硬件中断。这些信号是由外部硬件产生的,以寻求处理器对重大外部事件的关注,例如键盘上的按键、鼠标按钮的点击或移动鼠标触发硬件中断,通过这些中断处理器被通知有数据可供读取。硬件中断与处理器时钟异步发生(意味着它们可以在随机时间发生),因此也被称为异步中断

由于当前执行的程序指令生成的事件而触发的 CPU 内部的中断被称为软件中断。软件中断是由当前执行的程序指令触发的异常引起的,或者在执行特权指令时引发中断。例如,当程序指令尝试将一个数字除以零时,处理器的算术逻辑单元会引发一个称为除零异常的中断。类似地,当正在执行的程序意图调用内核服务调用时,它执行一个特殊指令(sysenter),引发一个中断以将处理器转换到特权模式,为执行所需的服务调用铺平道路。这些事件与处理器时钟同步发生,因此也被称为同步中断

在发生中断事件时,CPU 被设计为抢占当前的指令序列或执行线程,并执行一个称为中断服务例程ISR)的特殊函数。为了找到与中断事件对应的适当的ISR,使用中断向量表中断向量是内存中包含对应于中断执行的软件定义中断服务的引用的地址。处理器架构定义支持的中断向量的总数,并描述内存中每个中断向量的布局。一般来说,对于大多数处理器架构,所有支持的向量都被设置在内存中作为一个称为中断向量表的列表,其地址由平台软件编程到处理器寄存器中。

让我们以x86架构为例,以便更好地理解。x86 系列处理器支持总共 256 个中断向量,其中前 32 个保留用于处理器异常,其余用于软件和硬件中断。x86 通过实现一个向量表来引用中断描述符表(IDT),这是一个 8 字节(32 位机器)或 16 字节(64 位x86机器)大小的描述符数组。在早期引导期间,内核代码的特定于架构的分支在内存中设置IDT并将处理器的IDTR寄存器(特殊的 x86 寄存器)编程为IDT的物理起始地址和长度。当发生中断时,处理器通过将报告的向量编号乘以向量描述符的大小(x86_32 机器上的向量编号 x8x86_64 机器上的向量编号 x16)并将结果加到IDT的基地址来定位相关的向量描述符。一旦到达有效的向量描述符,处理器将继续执行描述符中指定的操作。

在 x86 平台上,每个向量描述符实现了一个门(中断、任务或陷阱),用于在段之间传递执行控制。代表硬件中断的向量描述符实现了一个中断门,它指向包含中断处理程序代码的段的基地址和偏移量。中断门在将控制传递给指定的中断处理程序之前禁用所有可屏蔽中断。代表异常和软件中断的向量描述符实现了一个陷阱门,它也指向被指定为事件处理程序的代码的位置。与中断门不同,陷阱门不会禁用可屏蔽中断,这使其适用于执行软中断处理程序。

可编程中断控制器

现在让我们专注于外部中断,并探讨处理器如何识别外部硬件中断的发生,以及它们如何发现与中断相关联的向量编号。CPU 设计有一个专用输入引脚(中断引脚),用于信号外部中断。每个能够发出中断请求的外部硬件设备通常由一个或多个输出引脚组成,称为中断请求线(IRQ),用于在 CPU 上信号中断请求。所有计算平台都使用一种称为可编程中断控制器(PIC)的硬件电路,将 CPU 的中断引脚多路复用到各种中断请求线上。所有来自板载设备控制器的现有 IRQ 线路都被路由到中断控制器的输入引脚,该控制器监视每个 IRQ 线路以获取中断信号,并在中断到达时将请求转换为 CPU 可理解的向量编号,并将中断信号传递到 CPU 的中断引脚。简而言之,可编程中断控制器将多个设备中断请求线路多路复用到处理器的单个中断线上:

中断控制器的设计和实现是特定于平台的。英特尔 x86 多处理器平台使用高级可编程中断控制器(APIC)。APIC 设计将中断控制器功能分为两个不同的芯片组件:第一个组件是位于系统总线上的 I/O APIC。所有共享的外围硬件 IRQ 线路都被路由到 I/O APIC;该芯片将中断请求转换为向量代码。第二个是称为本地 APIC 的每 CPU 控制器(通常集成到处理器核心中),它将硬件中断传递给特定的 CPU 核心。I/O APIC 将中断事件路由到所选 CPU 核心的本地 APIC。它被编程为一个重定向表,用于进行中断路由决策。CPU 本地 APIC 管理特定 CPU 核心的所有外部中断;此外,它们传递来自 CPU 本地硬件的事件,如定时器,并且还可以接收和生成 SMP 平台上可能发生的处理器间中断(IPI)。

以下图表描述了 APIC 的分裂架构。现在事件的流程始于各个设备在 I/O APIC 上引发 IRQ,后者将请求路由到特定的本地 APIC,后者又将中断传递给特定的 CPU 核心:

类似于 APIC 架构,多核 ARM 平台将通用中断控制器(GIC)的实现分为两部分。第一个组件称为分发器,它是全局的,有几个外围硬件中断源物理路由到它。第二个组件是每 CPU 复制的,称为 CPU 接口。分发器组件被编程为将共享外围中断(SPI)的分发逻辑路由到已知的 CPU 接口。

中断控制器操作

内核代码的体系结构特定分支实现了中断控制器特定操作,用于管理 IRQ 线路,例如屏蔽/取消屏蔽单个中断,设置优先级和 SMP 亲和性。这些操作需要从内核的体系结构无关代码路径中调用,以便操纵单个 IRQ 线路,并为了促进这样的调用,内核通过一个称为struct irq_chip的结构定义了一个体系结构无关的抽象层。该结构可以在内核头文件<include/linux/irq.h>中找到:

struct irq_chip {
     struct device *parent_device;
     const char    *name;
     unsigned int (*irq_startup)(struct irq_data *data);
     void (*irq_shutdown)(struct irq_data *data);
     void (*irq_enable)(struct irq_data *data);
     void (*irq_disable)(struct irq_data *data);

     void (*irq_ack)(struct irq_data *data);
     void (*irq_mask)(struct irq_data *data);
     void (*irq_mask_ack)(struct irq_data *data);
     void (*irq_unmask)(struct irq_data *data);
     void (*irq_eoi)(struct irq_data *data);

     int (*irq_set_affinity)(struct irq_data *data, const struct cpumask
                             *dest, bool force);

     int (*irq_retrigger)(struct irq_data *data);    
     int (*irq_set_type)(struct irq_data *data, unsigned int flow_type);
     int (*irq_set_wake)(struct irq_data *data, unsigned int on);    
     void (*irq_bus_lock)(struct irq_data *data);   
     void (*irq_bus_sync_unlock)(struct irq_data *data);    
     void (*irq_cpu_online)(struct irq_data *data);   
     void (*irq_cpu_offline)(struct irq_data *data);   
     void (*irq_suspend)(struct irq_data *data); 
     void (*irq_resume)(struct irq_data *data); 
     void (*irq_pm_shutdown)(struct irq_data *data); 
     void (*irq_calc_mask)(struct irq_data *data); 
     void (*irq_print_chip)(struct irq_data *data, struct seq_file *p);    
     int (*irq_request_resources)(struct irq_data *data); 
     void (*irq_release_resources)(struct irq_data *data); 
     void (*irq_compose_msi_msg)(struct irq_data *data, struct msi_msg *msg);
     void (*irq_write_msi_msg)(struct irq_data *data, struct msi_msg *msg);  

     int (*irq_get_irqchip_state)(struct irq_data *data, enum  irqchip_irq_state which, bool *state);
     int (*irq_set_irqchip_state)(struct irq_data *data, enum irqchip_irq_state which, bool state);

     int (*irq_set_vcpu_affinity)(struct irq_data *data, void *vcpu_info);   
     void (*ipi_send_single)(struct irq_data *data, unsigned int cpu);   
     void (*ipi_send_mask)(struct irq_data *data, const struct cpumask *dest);      unsigned long flags; 
};

该结构声明了一组函数指针,以考虑各种硬件平台上发现的 IRQ 芯片的所有特殊性。因此,由特定于板级的代码定义的结构的特定实例通常只支持可能操作的子集。以下是定义 I/O APIC 和 LAPIC 操作的 x86 多核平台版本的irq_chip实例。

static struct irq_chip ioapic_chip __read_mostly = {
              .name             = "IO-APIC",
              .irq_startup      = startup_ioapic_irq,
              .irq_mask         = mask_ioapic_irq,
              .irq_unmask       = unmask_ioapic_irq,
              .irq_ack          = irq_chip_ack_parent,
              .irq_eoi          = ioapic_ack_level,
              .irq_set_affinity = ioapic_set_affinity,
              .irq_retrigger    = irq_chip_retrigger_hierarchy,
              .flags            = IRQCHIP_SKIP_SET_WAKE,
};

static struct irq_chip lapic_chip __read_mostly = {
              .name            = "local-APIC",
              .irq_mask        = mask_lapic_irq,
              .irq_unmask      = unmask_lapic_irq,
              .irq_ack         = ack_lapic_irq,
};

中断描述符表

另一个重要的抽象是与与硬件中断相关的 IRQ 号。中断控制器使用唯一的硬件 IRQ 号标识每个 IRQ 源。内核的通用中断管理层将每个硬件 IRQ 映射到称为 Linux IRQ 的唯一标识符;这些数字抽象了硬件 IRQ,从而确保内核代码的可移植性。所有外围设备驱动程序都被编程为使用 Linux IRQ 号来绑定或注册它们的中断处理程序。

Linux IRQ 由 IRQ 描述符结构表示,由struct irq_desc定义;在早期内核引导期间,对于每个 IRQ 源,将枚举此结构的一个实例。IRQ 描述符的列表以 IRQ 号为索引,称为 IRQ 描述符表:

 struct irq_desc {
      struct irq_common_data    irq_common_data;
      struct irq_data           irq_data;
      unsigned int __percpu    *kstat_irqs;
      irq_flow_handler_t        handle_irq;
#ifdef CONFIG_IRQ_PREFLOW_FASTEOI
      irq_preflow_handler_t     preflow_handler;
#endif
      struct irqaction         *action;    /* IRQ action list */
      unsigned int             status_use_accessors;
      unsigned int             core_internal_state__do_not_mess_with_it;
      unsigned int             depth;    /* nested irq disables */
      unsigned int             wake_depth;/* nested wake enables */
      unsigned int             irq_count;/* For detecting broken IRQs */
      unsigned long            last_unhandled;   
      unsigned int             irqs_unhandled;
      atomic_t                 threads_handled;
      int                      threads_handled_last;
      raw_spinlock_t           lock;
      struct cpumask           *percpu_enabled;
      const struct cpumask     *percpu_affinity;
#ifdef CONFIG_SMP
     const struct cpumask         *affinity_hint;
     struct irq_affinity_notify   *affinity_notify;

     ...
     ...
     ...
};

irq_datastruct irq_data的一个实例,其中包含与中断管理相关的低级信息,例如 Linux 中断号、硬件中断号,以及指向中断控制器操作(irq_chip)的指针等其他重要字段:

/**
 * struct irq_data - per irq chip data passed down to chip functions
 * @mask:          precomputed bitmask for accessing the chip registers
 * @irq:           interrupt number
 * @hwirq:         hardware interrupt number, local to the interrupt domain
 * @common:        point to data shared by all irqchips
 * @chip:          low level interrupt hardware access
 * @domain:        Interrupt translation domain; responsible for mapping
 *                 between hwirq number and linux irq number.
 * @parent_data:   pointer to parent struct irq_data to support hierarchy
 *                 irq_domain
 * @chip_data:     platform-specific per-chip private data for the chip
 *                 methods, to allow shared chip implementations
 */

struct irq_data { 
       u32 mask;    
       unsigned int irq;    
       unsigned long hwirq;    
       struct irq_common_data *common;    
       struct irq_chip *chip;    
       struct irq_domain *domain; 
#ifdef CONFIG_IRQ_DOMAIN_HIERARCHY    
       struct irq_data *parent_data; 
#endif    
       void *chip_data; 
};

irq_desc结构的handle_irq元素是一个irq_flow_handler_t类型的函数指针,它指的是处理线路上流管理的高级函数。通用中断层提供了一组预定义的中断流函数;根据其类型,每个中断线路都分配了适当的例程。

  • handle_level_irq():电平触发中断的通用实现

  • handle_edge_irq():边沿触发中断的通用实现

  • handle_fasteoi_irq():只需要在处理程序结束时进行 EOI 的中断的通用实现

  • handle_simple_irq():简单中断的通用实现

  • handle_percpu_irq():每 CPU 中断的通用实现

  • handle_bad_irq():用于虚假中断

irq_desc结构的*action元素是指向一个或一组动作描述符的指针,其中包含特定于驱动程序的中断处理程序等其他重要元素。每个动作描述符都是在内核头文件<linux/interrupt.h>中定义的struct irqaction的实例:

/**
 * struct irqaction - per interrupt action descriptor
 * @handler: interrupt handler function
 * @name: name of the device
 * @dev_id: cookie to identify the device
 * @percpu_dev_id: cookie to identify the device
 * @next: pointer to the next irqaction for shared interrupts
 * @irq: interrupt number
 * @flags: flags 
 * @thread_fn: interrupt handler function for threaded interrupts
 * @thread: thread pointer for threaded interrupts
 * @secondary: pointer to secondary irqaction (force threading)
 * @thread_flags: flags related to @thread
 * @thread_mask: bitmask for keeping track of @thread activity
 * @dir: pointer to the proc/irq/NN/name entry
 */
struct irqaction {
       irq_handler_t handler;
       void * dev_id;
       void __percpu * percpu_dev_id;
       struct irqaction * next;
       irq_handler_t thread_fn;
       struct task_struct * thread;
       struct irqaction * secondary;
       unsigned int irq;
       unsigned int flags;
       unsigned long thread_flags;
       unsigned long thread_mask;
       const char * name;
       struct proc_dir_entry * dir;
};  

高级中断管理接口

通用 IRQ 层提供了一组函数接口,供设备驱动程序获取 IRQ 描述符和绑定中断处理程序,释放 IRQ,启用或禁用中断线等。我们将在本节中探讨所有通用接口。

注册中断处理程序

typedef irqreturn_t (*irq_handler_t)(int, void *);

/**
 * request_irq - allocate an interrupt line
 * @irq: Interrupt line to allocate
 * @handler: Function to be called when the IRQ occurs.
 * @irqflags: Interrupt type flags
 * @devname: An ascii name for the claiming device
 * @dev_id: A cookie passed back to the handler function
 */
 int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
                 const char *name, void *dev);

request_irq()使用传递的值实例化一个irqaction对象,并将其绑定到作为第一个(irq)参数指定的irq_desc。此调用分配中断资源并启用中断线和 IRQ 处理。handler是一个irq_handler_t类型的函数指针,它接受特定于驱动程序的中断处理程序例程的地址。flags是与中断管理相关的选项的位掩码。标志位在内核头文件<linux/interrupt.h>中定义:

  • IRQF_SHARED:在将中断处理程序绑定到共享的 IRQ 线时使用。

  • IRQF_PROBE_SHARED:当调用者期望共享不匹配时设置。

  • IRQF_TIMER:标记此中断为定时器中断。

  • IRQF_PERCPU:中断是每 CPU 的。

  • IRQF_NOBALANCING:标志,用于排除此中断不参与 IRQ 平衡。

  • IRQF_IRQPOLL:中断用于轮询(仅考虑在共享中断中首先注册的中断以提高性能)。

  • IRQF_NO_SUSPEND:在挂起期间不禁用此 IRQ。不能保证此中断将唤醒系统从挂起状态。

  • IRQF_FORCE_RESUME:即使设置了IRQF_NO_SUSPEND,也在恢复时强制启用它。

  • IRQF_EARLY_RESUME:在 syscore 期间提前恢复 IRQ,而不是在设备恢复时。

  • IRQF_COND_SUSPEND:如果 IRQ 与NO_SUSPEND用户共享,则在挂起中断后执行此中断处理程序。对于系统唤醒设备,用户需要在其中断处理程序中实现唤醒检测。

由于每个标志值都是一个位,可以传递这些标志的子集的逻辑 OR(即|),如果没有适用的标志,则flags参数的值为 0 是有效的。分配给dev的地址被视为唯一的 cookie,并用作共享 IRQ 情况下操作实例的标识符。在注册中断处理程序时,此参数的值可以为 NULL,而不使用IRQF_SHARED标志。

成功时,request_irq()返回零;非零返回值表示注册指定中断处理程序失败。返回错误代码-EBUSY表示注册或绑定处理程序到已经使用的指定 IRQ 失败。

中断处理程序例程具有以下原型:

irqreturn_t handler(int irq, void *dev_id);

irq指定了 IRQ 号码,而dev_id是在注册处理程序时使用的唯一 cookie。irqreturn_t是一个枚举整数常量的 typedef:

enum irqreturn {
        IRQ_NONE         = (0 << 0),
        IRQ_HANDLED              = (1 << 0),
        IRQ_WAKE_THREAD          = (1 << 1),
};

typedef enum irqreturn irqreturn_t;

中断处理程序应返回IRQ_NONE以指示未处理中断。它还用于指示中断的来源不是来自其设备的情况下的共享 IRQ。当中断处理正常完成时,必须返回IRQ_HANDLED以指示成功。IRQ_WAKE_THREAD是一个特殊标志,用于唤醒线程处理程序;我们将在下一节详细介绍它。

注销中断处理程序

驱动程序的中断处理程序可以通过调用free_irq()例程来注销:

/**
 * free_irq - free an interrupt allocated with request_irq
 * @irq: Interrupt line to free
 * @dev_id: Device identity to free
 *
 * Remove an interrupt handler. The handler is removed and if the
 * interrupt line is no longer in use by any driver it is disabled.
 * On a shared IRQ the caller must ensure the interrupt is disabled
 * on the card it drives before calling this function. The function
 * does not return until any executing interrupts for this IRQ
 * have completed.
 * Returns the devname argument passed to request_irq.
 */
const void *free_irq(unsigned int irq, void *dev_id);

dev_id是用于在共享 IRQ 情况下标识要注销的处理程序的唯一 cookie(在注册处理程序时分配);对于其他情况,此参数可以为 NULL。此函数是一个潜在的阻塞调用,并且不得从中断上下文中调用:它会阻塞调用上下文,直到指定的 IRQ 线路上的任何中断处理程序的执行完成。

线程中断处理程序

通过request_irq()注册的处理程序由内核的中断处理路径执行。这条代码路径是异步的,通过暂停本地处理器上的调度程序抢占和硬件中断来运行,因此被称为硬中断上下文。因此,必须将驱动程序的中断处理程序编程为简短(尽量少做工作)和原子(非阻塞),以确保系统的响应性。然而,并非所有硬件中断处理程序都可以简短和原子:有许多复杂设备生成中断事件,其响应涉及复杂的可变时间操作。

传统上,驱动程序被编程为处理中断处理程序的这种复杂性,采用了分离处理程序设计,称为顶半部底半部。顶半部例程在硬中断上下文中被调用,这些函数被编程为执行中断关键操作,例如对硬件寄存器的物理 I/O,并安排底半部进行延迟执行。底半部例程通常用于处理中断非关键可推迟工作,例如处理顶半部生成的数据,与进程上下文交互以及访问用户地址空间。内核提供了多种机制来调度和执行底半部例程,每种机制都有不同的接口 API 和执行策略。我们将在下一节详细介绍正式底半部机制的设计和用法细节。

作为使用正式底半部机制的替代方案,内核支持设置可以在线程上下文中执行的中断处理程序,称为线程中断处理程序。驱动程序可以通过另一个名为request_threaded_irq()的接口例程设置线程中断处理程序:

/**
 * request_threaded_irq - allocate an interrupt line
 * @irq: Interrupt line to allocate
 * @handler: Function to be called when the IRQ occurs.
 * Primary handler for threaded interrupts
 * If NULL and thread_fn != NULL the default
 * primary handler is installed
 * @thread_fn: Function called from the irq handler thread
 * If NULL, no irq thread is created
 * @irqflags: Interrupt type flags
 * @devname: An ascii name for the claiming device
 * @dev_id: A cookie passed back to the handler function
 */
   int request_threaded_irq(unsigned int irq, irq_handler_t handler,
                            irq_handler_t thread_fn, unsigned long irqflags,
                            const char *devname, void *dev_id);

分配给handler的函数作为在硬中断上下文中执行的主要中断处理程序。分配给thread_fn的例程在线程上下文中执行,并在主处理程序返回IRQ_WAKE_THREAD时被调度运行。通过这种分离处理程序设置,有两种可能的用例:主处理程序可以被编程为执行中断关键工作,并将非关键工作推迟到线程处理程序以供以后执行,类似于底半部分。另一种设计是将整个中断处理代码推迟到线程处理程序,并将主处理程序限制为验证中断源并唤醒线程例程。这种用例可能需要相应的中断线路在线程处理程序完成之前被屏蔽,以避免中断的嵌套。这可以通过编程主处理程序在唤醒线程处理程序之前关闭中断源或通过在注册线程中断处理程序时分配的标志位IRQF_ONESHOT来实现。

以下是与线程中断处理程序相关的irqflags

  • IRQF_ONESHOT:硬 IRQ 处理程序完成后不会重新启用中断。这由需要保持 IRQ 线禁用直到线程处理程序运行完毕的线程化中断使用。

  • IRQF_NO_THREAD:中断不能被线程化。这在共享 IRQ 中用于限制使用线程化中断处理程序。

调用此例程并将 NULL 分配给handler将导致内核使用默认的主处理程序,该处理程序简单地返回IRQ_WAKE_THREAD。而将 NULL 分配给thread_fn调用此函数等同于request_irq()

static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
            const char *name, void *dev)
{
        return request_threaded_irq(irq, handler, NULL, flags, name, dev);
}

设置中断处理程序的另一种替代接口是request_any_context_irq()。此例程具有与request_irq()类似的签名,但在功能上略有不同:

/**
 * request_any_context_irq - allocate an interrupt line
 * @irq: Interrupt line to allocate
 * @handler: Function to be called when the IRQ occurs.
 * Threaded handler for threaded interrupts.
 * @flags: Interrupt type flags
 * @name: An ascii name for the claiming device
 * @dev_id: A cookie passed back to the handler function
 *
 * This call allocates interrupt resources and enables the
 * interrupt line and IRQ handling. It selects either a
 * hardirq or threaded handling method depending on the
 * context.
 * On failure, it returns a negative value. On success,
 * it returns either IRQC_IS_HARDIRQ or IRQC_IS_NESTED..
 */
int request_any_context_irq(unsigned int irq,irq_handler_t handler, 
                            unsigned long flags,const char *name,void *dev_id)

此函数与request_irq()的不同之处在于,它查看由特定于体系结构的代码设置的 IRQ 描述符的中断线属性,并决定是否将分配的函数建立为传统的硬 IRQ 处理程序或作为线程中断处理程序。成功时,如果已建立处理程序以在硬 IRQ 上下文中运行,则返回IRQC_IS_HARDIRQ,否则返回IRQC_IS_NESTED

控制接口

通用的 IRQ 层提供了对 IRQ 线进行控制操作的例程。以下是用于屏蔽和取消屏蔽特定 IRQ 线的函数列表:

void disable_irq(unsigned int irq);

通过操作 IRQ 描述符结构中的计数器来禁用指定的 IRQ 线。此例程可能是一个阻塞调用,因为它会等待此中断的任何运行处理程序完成。另外,也可以使用函数disable_irq_nosync()禁用给定的 IRQ 线;此调用不会检查并等待给定中断线的任何运行处理程序完成:

void disable_irq_nosync(unsigned int irq);

可以通过调用以下函数来启用已禁用的 IRQ 线:

void enable_irq(unsigned int irq);

请注意,IRQ 启用和禁用操作是嵌套的,即,多次禁用IRQ 线的调用需要相同数量的启用调用才能重新启用该 IRQ 线。这意味着enable_irq()只有在调用它与最后的禁用操作匹配时才会启用给定的 IRQ。

可以选择为本地 CPU 禁用/启用中断;以下宏对应用于相同目的:

  • local_irq_disable():在本地处理器上禁用中断。

  • local_irq_enable():为本地处理器启用中断。

  • local_irq_save(unsigned long flags):通过将当前中断状态保存在flags中,在本地 CPU 上禁用中断。

  • local_irq_restore(unsigned long flags):通过将中断恢复到先前的状态,在本地 CPU 上启用中断。

IRQ 堆栈

从历史上看,对于大多数体系结构,中断处理程序共享了被中断的运行进程的内核堆栈。正如第一章所讨论的,32 位体系结构的进程内核堆栈通常为 8 KB,而 64 位体系结构为 16 KB。固定的内核堆栈可能并不总是足够用于内核工作和 IRQ 处理例程,导致内核代码和中断处理程序都需要谨慎地分配数据。为了解决这个问题,内核构建(对于一些体系结构)默认配置为为中断处理程序设置每个 CPU 硬 IRQ 堆栈,并为软中断代码设置每个 CPU 软 IRQ 堆栈。以下是内核头文件<arch/x86/include/asm/processor.h>中特定于 x86-64 位体系结构的堆栈声明:

/*
 * per-CPU IRQ handling stacks
 */
struct irq_stack {
        u32                     stack[THREAD_SIZE/sizeof(u32)];
} __aligned(THREAD_SIZE);

DECLARE_PER_CPU(struct irq_stack *, hardirq_stack);
DECLARE_PER_CPU(struct irq_stack *, softirq_stack);

除此之外,x86-64 位构建还包括特殊的堆栈;更多细节可以在内核源代码文档<x86/kernel-stacks>中找到:

  • 双重故障堆栈

  • 调试堆栈

  • NMI 堆栈

  • Mce 堆栈

延迟工作

如前一节介绍的,底半部是内核机制,用于执行延迟工作,并且可以由任何内核代码参与,以推迟对非关键工作的执行,直到将来的某个时间。为了支持实现和管理延迟例程,内核实现了特殊的框架,称为softirqstaskletswork queues。每个这些框架都包括一组数据结构和函数接口,用于注册、调度和排队底半部例程。每种机制都设计有一个独特的策略来管理和执行底半部。需要延迟执行的驱动程序和其他内核服务将需要通过适当的框架绑定和调度它们的 BH 例程。

Softirqs

术语softirq大致翻译为软中断,正如其名称所示,由该框架管理的延迟例程以高优先级执行,但启用了硬中断线*。因此,softirq 底半部(或 softirqs)可以抢占除硬中断处理程序之外的所有其他任务。然而,softirq 的使用仅限于静态内核代码,这种机制对于动态内核模块不可用。

每个 softirq 通过在内核头文件<linux/interrupt.h>中声明的struct softirq_action类型的实例表示。该结构包含一个函数指针,可以保存底半部例程的地址:

struct softirq_action
{
        void (*action)(struct softirq_action *);
};

当前版本的内核有 10 个 softirq,每个通过内核头文件<linux/interrupt.h>中的枚举索引。这些索引作为标识,并被视为 softirq 的相对优先级,具有较低索引的条目被视为优先级较高,索引 0 为最高优先级的 softirq:

enum
{
        HI_SOFTIRQ=0,
        TIMER_SOFTIRQ,
        NET_TX_SOFTIRQ,
        NET_RX_SOFTIRQ,
        BLOCK_SOFTIRQ,
        IRQ_POLL_SOFTIRQ,
        TASKLET_SOFTIRQ,
        SCHED_SOFTIRQ,
        HRTIMER_SOFTIRQ, /* Unused, but kept as tools rely on the
                            numbering. Sigh! */
        RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */

        NR_SOFTIRQS
};

内核源文件<kernel/softirq.c>声明了一个名为softirq_vec的数组,大小为NR_SOFTIRQS,每个偏移量包含一个对应 softirq 枚举中的softirq_action实例:

static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;

/* string constants for naming each softirq */
const char * const softirq_to_name[NR_SOFTIRQS] = {
        "HI", "TIMER", "NET_TX", "NET_RX", "BLOCK", "IRQ_POLL",
        "TASKLET", "SCHED", "HRTIMER", "RCU"
};

框架提供了一个函数open_softriq(),用于使用相应的底半部例程初始化 softirq 实例:

void open_softirq(int nr, void (*action)(struct softirq_action *))
{
        softirq_vec[nr].action = action;
}

nr是要初始化的 softirq 的索引,*action是要用底半部例程的地址初始化的函数指针。以下代码摘录来自定时器服务,并显示了调用open_softirq来注册 softirq:

/*kernel/time/timer.c*/
open_softirq(TIMER_SOFTIRQ, run_timer_softirq);

内核服务可以使用函数raise_softirq()来发出 softirq 处理程序的执行。此函数以 softirq 的索引作为参数:

void raise_softirq(unsigned int nr)
{
        unsigned long flags;

        local_irq_save(flags);
        raise_softirq_irqoff(nr);
        local_irq_restore(flags);
} 

以下代码摘录来自<kernel/time/timer.c>

void run_local_timers(void)
{
        struct timer_base *base = this_cpu_ptr(&amp;timer_bases[BASE_STD]);

        hrtimer_run_queues();
        /* Raise the softirq only if required. */
        if (time_before(jiffies, base->clk)) {
                if (!IS_ENABLED(CONFIG_NO_HZ_COMMON) || !base->nohz_active)
                        return;
                /* CPU is awake, so check the deferrable base. */
                base++;
                if (time_before(jiffies, base->clk))
                        return;
        }
        raise_softirq(TIMER_SOFTIRQ);
}

内核维护了一个每 CPU 位掩码,用于跟踪为执行而引发的 softirq,并且函数raise_softirq()设置本地 CPU 的 softirq 位掩码中的相应位(作为参数提到的索引)以标记指定的 softirq 为待处理。

待处理的 softirq 处理程序在内核代码的各个点检查并执行。主要是在中断上下文中执行,在硬中断处理程序完成后立即执行,同时启用 IRQ 线。这保证了从硬中断处理程序引发的 softirq 的快速处理,从而实现了最佳的缓存使用。然而,内核允许任意任务通过local_bh_disable()spin_lock_bh()调用来暂停本地处理器上的 softirq 处理。待处理的 softirq 处理程序在重新启用 softirq 处理的任意任务的上下文中执行,通过调用local_bh_enable()spin_unlock_bh()来重新启用 softirq 处理。最后,softirq 处理程序也可以由每个 CPU 内核线程ksoftirqd执行,当任何进程上下文内核例程引发 softirq 时,它会被唤醒。当由于负载过高而积累了太多的 softirq 时,该线程也会从中断上下文中被唤醒。

Softirqs 最适合用于完成从硬中断处理程序推迟的优先级工作,因为它们在硬中断处理程序完成后立即运行。但是,softirq 处理程序是可重入的,并且必须编程以在访问数据结构时使用适当的保护机制。softirq 的可重入性可能导致无界延迟,影响整个系统的效率,这就是为什么它们的使用受到限制,几乎不会添加新的 softirq,除非绝对需要执行高频率的线程推迟工作。对于所有其他类型的推迟工作,建议使用任务队列。

任务队列

任务队列机制是对 softirq 框架的一种包装;事实上,任务队列处理程序是由 softirq 执行的。与 softirq 不同,任务队列不是可重入的,这保证了相同的任务队列处理程序永远不会并发运行。这有助于最小化总体延迟,前提是程序员检查并施加相关检查,以确保任务队列中的工作是非阻塞和原子的。另一个区别是在使用方面:与受限的 softirq 不同,任何内核代码都可以使用任务队列,包括动态链接的服务。

每个任务队列通过在内核头文件<linux/interrupt.h>中声明的struct tasklet_struct类型的实例表示:

struct tasklet_struct
{
        struct tasklet_struct *next;
        unsigned long state;
        atomic_t count;
        void (*func)(unsigned long);
        unsigned long data;
};

在初始化时,*func保存处理程序例程的地址,data用于在调用期间将数据块作为参数传递给处理程序例程。每个任务队列都携带一个state,可以是TASKLET_STATE_SCHED,表示已安排执行,也可以是TASKLET_STATE_RUN,表示正在执行。使用原子计数器来启用禁用任务队列;当count等于非零时,表示任务队列已禁用表示任务队列已启用*。禁用的任务队列即使已排队,也不能执行,直到将来某个时间启用。

内核服务可以通过以下任何宏之一静态实例化新的任务队列:

#define DECLARE_TASKLET(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(0), func, data }

#define DECLARE_TASKLET_DISABLED(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(1), func, data }

新的任务队列可以通过以下方式在运行时动态实例化:

void tasklet_init(struct tasklet_struct *t,
                  void (*func)(unsigned long), unsigned long data)
{
        t->next = NULL;
        t->state = 0;
        atomic_set(&t->count, 0);
        t->func = func;
        t->data = data;
}

内核为排队的任务队列维护了两个每 CPU 任务队列列表,这些列表的定义可以在源文件<kernel/softirq.c>中找到:

/*
 * Tasklets
 */
struct tasklet_head {
        struct tasklet_struct *head;
        struct tasklet_struct **tail;
};

static DEFINE_PER_CPU(struct tasklet_head, tasklet_vec);
static DEFINE_PER_CPU(struct tasklet_head, tasklet_hi_vec);

tasklet_vec被认为是正常列表,此列表中的所有排队的任务队列都由TASKLET_SOFTIRQ(10 个 softirq 之一)运行。tasklet_hi_vec是一个高优先级的任务队列列表,此列表中的所有排队的任务队列都由HI_SOFTIRQ执行,这恰好是最高优先级的 softirq。可以通过调用tasklet_schedule()tasklet_hi_scheudule()将任务队列排队到适当的列表中执行。

以下代码显示了tasklet_schedule()的实现;此函数通过要排队的任务队列实例的地址作为参数调用:

extern void __tasklet_schedule(struct tasklet_struct *t);

static inline void tasklet_schedule(struct tasklet_struct *t)
{
        if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
                __tasklet_schedule(t);
}

条件构造检查指定的任务队列是否已经排队;如果没有,它会原子地将状态设置为TASKLET_STATE_SCHED,并调用__tasklet_shedule()将任务队列实例排队到待处理列表中。如果发现指定的任务队列已经处于TASKLET_STATE_SCHED状态,则不会重新调度:

void __tasklet_schedule(struct tasklet_struct *t)
{
        unsigned long flags;

        local_irq_save(flags);
        t->next = NULL;
 *__this_cpu_read(tasklet_vec.tail) = t;
 __this_cpu_write(tasklet_vec.tail, &(t->next));
        raise_softirq_irqoff(TASKLET_SOFTIRQ);
        local_irq_restore(flags);
}

此函数将指定的任务队列静默排队到tasklet_vec的尾部,并在本地处理器上引发TASKLET_SOFTIRQ

下面是tasklet_hi_scheudle()例程的代码:

extern void __tasklet_hi_schedule(struct tasklet_struct *t);

static inline void tasklet_hi_schedule(struct tasklet_struct *t)
{
        if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
                __tasklet_hi_schedule(t);
}

此例程中执行的操作与tasklet_schedule()类似,唯一的例外是它调用__tasklet_hi_scheudle()将指定的任务队列排队到tasklet_hi_vec的尾部:

void __tasklet_hi_schedule(struct tasklet_struct *t)
{
        unsigned long flags;

        local_irq_save(flags);
        t->next = NULL;
 *__this_cpu_read(tasklet_hi_vec.tail) = t;
 __this_cpu_write(tasklet_hi_vec.tail, &(t->next));
 raise_softirq_irqoff(HI_SOFTIRQ);
        local_irq_restore(flags);
}

此调用在本地处理器上引发HI_SOFTIRQ,这将把tasklet_hi_vec中排队的所有任务队列转换为最高优先级的底部半部(优先级高于其他 softirq)。

另一个变体是tasklet_hi_schedule_first(),它将指定的 tasklet 插入到tasklet_hi_vec的开头,并提高HI_SOFTIRQ

extern void __tasklet_hi_schedule_first(struct tasklet_struct *t);

 */
static inline void tasklet_hi_schedule_first(struct tasklet_struct *t)
{
        if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
                __tasklet_hi_schedule_first(t);
}

/*kernel/softirq.c */
void __tasklet_hi_schedule_first(struct tasklet_struct *t)
{
        BUG_ON(!irqs_disabled());
        t->next = __this_cpu_read(tasklet_hi_vec.head);
 __this_cpu_write(tasklet_hi_vec.head, t);
        __raise_softirq_irqoff(HI_SOFTIRQ);
}

还存在其他接口例程,用于启用、禁用和终止已调度的 tasklet。

void tasklet_disable(struct tasklet_struct *t);

此函数通过增加其禁用计数来禁用指定的 tasklet。tasklet 仍然可以被调度,但直到再次启用它之前不会被执行。如果在调用此函数时 tasklet 当前正在运行,则此函数会忙等待直到 tasklet 完成。

void tasklet_enable(struct tasklet_struct *t);

此函数尝试通过递减其禁用计数来启用先前已禁用的 tasklet。如果 tasklet 已经被调度,它将很快运行:

void tasklet_kill(struct tasklet_struct *t);

此函数用于终止给定的 tasklet,以确保它不能再次被调度运行。如果在调用此函数时指定的 tasklet 已经被调度,则此函数会等待其执行完成:

void tasklet_kill_immediate(struct tasklet_struct *t, unsigned int cpu);

此函数用于终止已经调度的 tasklet。即使 tasklet 处于TASKLET_STATE_SCHED状态,它也会立即从列表中删除指定的 tasklet。

工作队列

工作队列wqs)是用于执行异步进程上下文例程的机制。正如名称所暗示的那样,工作队列(wq)是一个work项目的列表,每个项目包含一个函数指针,该指针指向要异步执行的例程的地址。每当一些内核代码(属于子系统或服务)打算将一些工作推迟到异步进程上下文执行时,它必须使用处理程序函数的地址初始化work项目,并将其排队到工作队列中。内核使用专用的内核线程池,称为kworker线程,按顺序执行队列中每个work项目绑定的函数。

接口 API

工作队列 API 提供了两种类型的函数接口:首先,一组接口例程用于实例化和排队work项目到全局工作队列,该队列由所有内核子系统和服务共享;其次,一组接口例程用于设置新的工作队列,并将工作项目排队到其中。我们将开始探索与全局共享工作队列相关的宏和函数的工作队列接口。

队列中的每个work项目由类型为struct work_struct的实例表示,该类型在内核头文件<linux/workqueue.h>中声明:

struct work_struct {
        atomic_long_t data;
        struct list_head entry;
        work_func_t func;
#ifdef CONFIG_LOCKDEP
        struct lockdep_map lockdep_map;
#endif
};

func是一个指针,指向延迟例程的地址;可以通过宏DECLARE_WORK创建并初始化一个新的 struct work 对象:

#define DECLARE_WORK(n, f) \
 struct work_struct n = __WORK_INITIALIZER(n, f)

n是要创建的实例的名称,f是要分配的函数的地址。可以通过schedule_work()将工作实例排队到工作队列中:

bool schedule_work(struct work_struct *work);

此函数将给定的work项目排队到本地 CPU 工作队列,但不能保证其在其中执行。如果成功排队给定的work,则返回true,如果给定的work已经在工作队列中,则返回false。一旦排队,与work项目相关联的函数将由相关的kworker线程在任何可用的 CPU 上执行。或者,可以将work项目标记为在特定 CPU 上执行,同时将其调度到队列中(这可能会产生更好的缓存利用);可以通过调用schedule_work_on()来实现:

bool schedule_work_on(int cpu, struct work_struct *work);

cpu是要绑定到的work任务的标识符。例如,要将work任务调度到本地 CPU,调用者可以调用:

schedule_work_on(smp_processor_id(), &t_work);

smp_processor_id()是一个内核宏(在<linux/smp.h>中定义),它返回本地 CPU 标识符。

接口 API 还提供了调度调用的变体,允许调用者排队work任务,其执行保证至少延迟到指定的超时。这是通过将work任务与定时器绑定来实现的,可以使用到期超时初始化定时器,直到work任务被调度到队列中为止:

struct delayed_work {
        struct work_struct work;
        struct timer_list timer;

        /* target workqueue and CPU ->timer uses to queue ->work */
        struct workqueue_struct *wq;
        int cpu;
};

timer是动态定时器描述符的一个实例,它在安排工作任务时初始化了到期间隔并启动。我们将在下一章更详细地讨论内核定时器和其他与时间相关的概念。

调用者可以通过宏实例化delayed_work并静态初始化它:

#define DECLARE_DELAYED_WORK(n, f) \
        struct delayed_work n = __DELAYED_WORK_INITIALIZER(n, f, 0)

与普通工作任务类似,延迟工作任务可以安排在任何可用的 CPU 上运行,或者安排在指定的核心上执行。要安排可以在任何可用处理器上运行的延迟工作,调用者可以调用schedule_delayed_work(),要安排延迟工作到特定 CPU 上,使用函数schedule_delayed_work_on()

bool schedule_delayed_work(struct delayed_work *dwork,unsigned long delay);
bool schedule_delayed_work_on(int cpu, struct delayed_work *dwork,
                                                       unsigned long delay);

请注意,如果延迟为零,则指定的工作项将安排立即执行。

创建专用工作队列

全局工作队列上安排的工作项的执行时间是不可预测的:一个长时间运行的工作项总是会导致其他工作项的无限延迟。或者,工作队列框架允许分配专用工作队列,这些队列可以由内核子系统或服务拥有。用于创建和安排工作到这些队列中的接口 API 提供了控制标志,通过这些标志,所有者可以设置特殊属性,如 CPU 局部性、并发限制和优先级,这些属性会影响排队的工作项的执行。

可以通过调用alloc_workqueue()来设置新的工作队列;以下摘录取自<fs/nfs/inode.c>,显示了示例用法:

   struct workqueue_struct *wq;
   ...
   wq = alloc_workqueue("nfsiod", WQ_MEM_RECLAIM, 0);

这个调用需要三个参数:第一个是一个字符串常量,用于“命名”工作队列。第二个参数是flags的位字段,第三个是称为max_active的整数。最后两个参数用于指定队列的控制属性。成功时,此函数返回工作队列描述符的地址。

以下是标志选项列表:

  • WQ_UNBOUND:使用此标志创建的工作队列由未绑定到任何特定 CPU 的 kworker 池管理。这会导致安排到此队列的所有工作项在任何可用处理器上运行。此队列中的工作项将尽快由 kworker 池执行。

  • WQ_FREEZABLE:此类型的工作队列是可冻结的,这意味着它会受到系统挂起操作的影响。在挂起期间,所有当前的工作项都会被清空,并且直到系统解冻或恢复之前,不会有新的工作项可以运行。

  • WQ_MEM_RECLAIM:此标志用于标记包含在内存回收路径中的工作项的工作队列。这会导致框架确保始终有一个工作线程可用于在此队列上运行工作项。

  • WQ_HIGHPRI:此标志用于将工作队列标记为高优先级。高优先级工作队列中的工作项优先级高于普通工作项,这些工作项由高优先级的kworker线程池执行。内核为每个 CPU 维护了一个专用的高优先级 kworker 线程池,这些线程池与普通的 kworker 池不同。

  • WQ_CPU_INTENSIVE:此标志标记此工作队列上的工作项为 CPU 密集型。这有助于系统调度程序调节预计会长时间占用 CPU 的工作项的执行。这意味着可运行的 CPU 密集型工作项不会阻止同一 kworker 池中的其他工作项的启动。可运行的非 CPU 密集型工作项始终可以延迟执行标记为 CPU 密集型的工作项。对于未绑定的 wq,此标志毫无意义。

  • WQ_POWER_EFFICIENT:标记了此标志的工作队列默认情况下是每 CPU 的,但如果系统是使用workqueue.power_efficient内核参数启动的,则变为未绑定。已确定对功耗有显着贡献的每 CPU 工作队列将被识别并标记为此标志,并且启用 power_efficient 模式会导致明显的功耗节约,但会略微降低性能。

最终参数max_active是一个整数,必须指定在任何给定 CPU 上可以同时执行的工作项的数量。

一旦建立了专用工作队列,工作项可以通过以下任一调用进行调度:

bool queue_work(struct workqueue_struct *wq, struct work_struct *work);

wq是一个指向队列的指针;它会将指定的工作项排入本地 CPU,但不能保证在本地处理器上执行。如果成功排队,则此调用返回true,如果已安排给定的工作项,则返回false

或者,调用者可以通过调用以下方式将工作项排入与特定 CPU 绑定的工作项队列:

bool queue_work_on(int cpu,struct workqueue_struct *wq,struct work_struct
                                                                 *work);                                         

一旦将工作项排入指定cpu的工作队列中,如果成功排队,则返回true,如果已在队列中找到给定的工作项,则返回false

与共享工作队列 API 类似,专用工作队列也提供了延迟调度选项。以下调用用于延迟调度工作项:

bool queue_delayed_work_on(int cpu, struct workqueue_struct *wq, struct                                                                                                                                                        delayed_work *dwork,unsigned long delay);

bool queue_delayed_work(struct workqueue_struct *wq, struct delayed_work                             *dwork, unsigned long delay

这两个调用都会延迟给定工作项的调度,直到delay指定的超时时间已经过去,但queue_delayed_work_on()除外,它会将给定的工作项排入指定的 CPU,并保证在该 CPU 上执行。请注意,如果指定的延迟为零且工作队列为空闲,则给定的工作项将被安排立即执行。

总结

通过本章,我们已经接触到了中断,构建整个基础设施的各种组件,以及内核如何有效地管理它。我们了解了内核如何利用抽象来平稳处理来自各种控制器的各种中断信号。内核通过高级中断管理接口再次突出了简化复杂编程方法的努力。我们还深入了解了中断子系统的所有关键例程和重要数据结构。我们还探讨了内核处理延迟工作的机制。

在下一章中,我们将探索内核的时间管理子系统,以了解诸如时间测量、间隔定时器和超时和延迟例程等关键概念。

第十章:时钟和时间管理

Linux 时间管理子系统管理各种与时间相关的活动,并跟踪时间数据,如当前时间和日期、自系统启动以来经过的时间(系统正常运行时间)和超时,例如,等待特定事件启动或终止的时间、在超时后锁定系统,或引发信号以终止无响应的进程。

Linux 时间管理子系统处理两种类型的定时活动:

  • 跟踪当前时间和日期

  • 维护定时器

时间表示

根据使用情况,Linux 以三种不同的方式表示时间:

  1. 墙上时间(或实时时间):这是真实世界中的实际时间和日期,例如 2017 年 8 月 10 日上午 07:00,用于文件和通过网络发送的数据包的时间戳。

  2. 进程时间:这是进程在其生命周期中消耗的时间。它包括进程在用户模式下消耗的时间以及内核代码在代表进程执行时消耗的时间。这对于统计目的、审计和分析很有用。

  3. 单调时间:这是自系统启动以来经过的时间。它是不断增加且单调的(系统正常运行时间)。

这三种时间可以用以下任一方式来衡量:

  1. 相对时间:这是相对于某个特定事件的时间,例如自系统启动以来的 7 分钟,或自用户上次输入以来的 2 分钟。

  2. 绝对时间:这是没有任何参考先前事件的唯一时间点,例如 2017 年 8 月 12 日上午 10:00。在 Linux 中,绝对时间表示为自 1970 年 1 月 1 日午夜 00:00:00(UTC)以来经过的秒数。

墙上的时间是不断增加的(除非用户修改了它),即使在重新启动和关机之间,但进程时间和系统正常运行时间始于某个预定义的时间点(通常为零),每次创建新进程或系统启动时。

计时硬件

Linux 依赖于适当的硬件设备来维护时间。这些硬件设备可以大致分为两类:系统时钟和定时器。

实时时钟(RTC)

跟踪当前时间和日期非常重要,不仅是为了让用户了解时间,还可以将其用作系统中各种资源的时间戳,特别是存储在辅助存储器中的文件。每个文件都有元数据信息,如创建日期和最后修改日期,每当创建或修改文件时,这两个字段都会使用系统中的当前时间进行更新。这些字段被多个应用程序用于管理文件,例如排序、分组,甚至删除(如果文件长时间未被访问)。make工具使用此时间戳来确定自上次访问以来源文件是否已被编辑;只有在这种情况下才会对其进行编译,否则保持不变。

系统时钟 RTC 跟踪当前时间和日期;由额外的电池支持,即使系统关闭,它也会继续运行。

RTC 可以定期在 IRQ8 上引发中断。通过编程 RTC 在达到特定时间时在 IRQ8 上引发中断,可以将此功能用作警报设施。在兼容 IBM 的个人电脑中,RTC 被映射到 0x70 和 0x71 I/O 端口。可以通过/dev/rtc设备文件访问它。

时间戳计数器(TSC)

这是通过 64 位寄存器 TSC 实现的计数器,每个 x86 微处理器都有,该寄存器称为 TSC 寄存器。它计算处理器的 CLK 引脚上到达的时钟信号数量。可以通过访问 TSC 寄存器来读取当前计数器值。每秒计数的时钟信号数可以计算为 1/(时钟频率);对于 1 GHz 时钟,这相当于每纳秒一次。

知道两个连续 tick 之间的持续时间非常关键。一个处理器时钟的频率可能与其他处理器不同,这使得它在处理器之间变化。CPU 时钟频率是在系统引导期间通过calibrate_tsc()回调例程计算的,该例程定义在arch/x86/include/asm/x86_init.h头文件中的x86_platform_ops结构中:

struct x86_platform_ops {
        unsigned long (*calibrate_cpu)(void);
        unsigned long (*calibrate_tsc)(void);
        void (*get_wallclock)(struct timespec *ts);
        int (*set_wallclock)(const struct timespec *ts);
        void (*iommu_shutdown)(void);
        bool (*is_untracked_pat_range)(u64 start, u64 end);
        void (*nmi_init)(void);
        unsigned char (*get_nmi_reason)(void);
        void (*save_sched_clock_state)(void);
        void (*restore_sched_clock_state)(void);
        void (*apic_post_init)(void);
        struct x86_legacy_features legacy;
        void (*set_legacy_features)(void);
};

这个数据结构还管理其他计时操作,比如通过get_wallclock()从 RTC 获取时间或通过set_wallclock()回调在 RTC 上设置时间。

可编程中断定时器(PIT)

内核需要定期执行某些任务,比如:

  • 更新当前时间和日期(在午夜)

  • 更新系统运行时间(正常运行时间)

  • 跟踪每个进程消耗的时间,以便它们不超过分配给 CPU 运行的时间

  • 跟踪各种计时器活动

为了执行这些任务,必须定期引发中断。每次引发这种周期性中断时,内核都知道是时候更新前面提到的时间数据了。PIT 是负责发出这种周期性中断的硬件部件,称为定时器中断。PIT 会以大约 1000 赫兹的频率在 IRQ0 上定期发出定时器中断,即每毫秒一次。这种周期性中断称为tick,发出的频率称为tick rate。tick rate 频率由内核宏HZ定义,以赫兹为单位。

系统响应性取决于 tick rate:tick 越短,系统的响应性就越高,反之亦然。使用较短的 tick,poll()select()系统调用将具有更快的响应时间。然而,较短的 tick rate 的相当大缺点是 CPU 将在内核模式下工作(执行定时器中断的中断处理程序)大部分时间,留下较少的时间供用户模式代码(程序)在其上执行。在高性能 CPU 中,这不会产生太多开销,但在较慢的 CPU 中,整体系统性能会受到相当大的影响。

为了在响应时间和系统性能之间取得平衡,在大多数机器上使用了 100 赫兹的 tick rate。除了Alpham68knommu使用 1000 赫兹的 tick rate 外,其余常见架构,包括x86(arm、powerpc、sparc、mips 等),使用了 100 赫兹的 tick rate。在x86机器中找到的常见 PIT 硬件是 Intel 8253。它是 I/O 映射的,并通过地址 0x40-0x43 进行访问。PIT 由setup_pit_timer()初始化,定义在arch/x86/kernel/i8253.c文件中。

void __init setup_pit_timer(void)
{
        clockevent_i8253_init(true);
        global_clock_event = &i8253_clockevent;
}

这在内部调用clockevent_i8253_init(),定义在<drivers/clocksource/i8253.c>中:

void __init clockevent_i8253_init(bool oneshot)
{
        if (oneshot)
                i8253_clockevent.features |= CLOCK_EVT_FEAT_ONESHOT;
        /*
        * Start pit with the boot cpu mask. x86 might make it global
        * when it is used as broadcast device later.
        */
        i8253_clockevent.cpumask = cpumask_of(smp_processor_id());

        clockevents_config_and_register(&i8253_clockevent, PIT_TICK_RATE,
                                        0xF, 0x7FFF);
}
#endif

CPU 本地定时器

PIT 是一个全局定时器,由它引发的中断可以由 SMP 系统中的任何 CPU 处理。在某些情况下,拥有这样一个共同的定时器是有益的,而在其他情况下,每 CPU 定时器更可取。在 SMP 系统中,保持进程时间并监视每个 CPU 中进程的分配时间片将更加容易和高效。

最近的 x86 微处理器中的本地 APIC 嵌入了这样一个 CPU 本地定时器。CPU 本地定时器可以发出一次或定期中断。它使用 32 位计时器,可以以非常低的频率发出中断(这个更宽的计数器允许更多的 tick 发生在引发中断之前)。APIC 定时器与总线时钟信号一起工作。APIC 定时器与 PIT 非常相似,只是它是本地 CPU 的,有一个 32 位计数器(PIT 有一个 16 位计数器),并且与总线时钟信号一起工作(PIT 使用自己的时钟信号)。

高精度事件定时器(HPET)

HPET 使用超过 10 Mhz 的时钟信号,每 100 纳秒发出一次中断,因此被称为高精度。HPET 实现了一个 64 位的主计数器,以如此高的频率进行计数。它是由英特尔和微软共同开发的,用于需要新的高分辨率计时器。HPET 嵌入了一组定时器。每个定时器都能够独立发出中断,并可以由内核分配给特定应用程序使用。这些定时器被管理为定时器组,每个组最多可以有 32 个定时器。一个 HPET 最多可以实现 8 个这样的组。每个定时器都有一组比较器匹配寄存器。当定时器的匹配寄存器中的值与主计数器的值匹配时,定时器会发出中断。定时器可以被编程为定期或周期性地生成中断。

寄存器是内存映射的,并具有可重定位的地址空间。在系统引导期间,BIOS 设置寄存器的地址空间并将其传递给内核。一旦 BIOS 映射了地址,内核就很少重新映射它。

ACPI 电源管理计时器(ACPI PMT)

ACPI PMT 是一个简单的计数器,具有固定频率时钟,为 3.58 Mhz。它在每个时钟脉冲上递增。PMT 是端口映射的;BIOS 在引导期间的硬件初始化阶段负责地址映射。PMT 比 TSC 更可靠,因为它使用恒定的时钟频率。TSC 依赖于 CPU 时钟,根据当前负载可以被降频或超频,导致时间膨胀和不准确的测量。在所有情况下,HPET 是首选,因为它允许系统中存在非常短的时间间隔。

硬件抽象

每个系统至少有一个时钟计数器。与机器中的任何硬件设备一样,这个计数器也由一个结构表示和管理。硬件抽象由include/linux/clocksource.h头文件中定义的struct clocksource提供。该结构提供了回调函数来通过readenabledisablesuspendresume例程访问和处理计数器的电源管理:

struct clocksource {
        u64 (*read)(struct clocksource *cs);
        u64 mask;
        u32 mult;
        u32 shift;
        u64 max_idle_ns;
        u32 maxadj;
#ifdef CONFIG_ARCH_CLOCKSOURCE_DATA
        struct arch_clocksource_data archdata;
#endif
        u64 max_cycles;
        const char *name;
        struct list_head list;
        int rating;
        int (*enable)(struct clocksource *cs);
        void (*disable)(struct clocksource *cs);
        unsigned long flags;
        void (*suspend)(struct clocksource *cs);
        void (*resume)(struct clocksource *cs);
        void (*mark_unstable)(struct clocksource *cs);
        void (*tick_stable)(struct clocksource *cs);

        /* private: */
#ifdef CONFIG_CLOCKSOURCE_WATCHDOG
        /* Watchdog related data, used by the framework */
        struct list_head wd_list;
        u64 cs_last;
        u64 wd_last;
#endif
        struct module *owner;
};

成员multshift对于获取相关单位的经过时间非常有用。

计算经过的时间

到目前为止,我们知道在每个系统中都有一个自由运行的、不断递增的计数器,并且所有时间都是从中派生的,无论是墙上的时间还是任何持续时间。在这里计算时间(自计数器启动以来经过的秒数)的最自然的想法是将这个计数器提供的周期数除以时钟频率,如下式所示:

时间(秒)=(计数器值)/(时钟频率)

然而,这种方法有一个问题:它涉及除法(它使用迭代算法,使其成为四种基本算术运算中最慢的)和浮点计算,在某些体系结构上可能会更慢。在处理嵌入式平台时,浮点计算显然比在个人电脑或服务器平台上慢。

那么我们如何解决这个问题呢?与其使用除法,不如使用乘法和位移操作来计算时间。内核提供了一个辅助例程,以这种方式推导时间。include/linux/clocksource.h中定义的clocksource_cyc2ns()将时钟源周期转换为纳秒:

static inline s64 clocksource_cyc2ns(u64 cycles, u32 mult, u32 shift)
{
        return ((u64) cycles * mult) >> shift;
}

在这里,参数 cycles 是来自时钟源的经过的周期数,mult是周期到纳秒的乘数,而shift是周期到纳秒的除数(2 的幂)。这两个参数都是时钟源相关的。这些值是由之前讨论的时钟源内核抽象提供的。

时钟源硬件并非始终准确;它们的频率可能会变化。这种时钟变化会导致时间漂移(使时钟运行得更快或更慢)。在这种情况下,可以调整变量mult来弥补这种时间漂移。

kernel/time/clocksource.c中定义的辅助例程clocks_calc_mult_shift()有助于评估multshift因子:

void
clocks_calc_mult_shift(u32 *mult, u32 *shift, u32 from, u32 to, u32 maxsec)
{
        u64 tmp;
        u32 sft, sftacc= 32;

        /*
        * Calculate the shift factor which is limiting the conversion
        * range:
        */
        tmp = ((u64)maxsec * from) >> 32;
        while (tmp) {
                tmp >>=1;
                sftacc--;
        }

        /*
        * Find the conversion shift/mult pair which has the best
        * accuracy and fits the maxsec conversion range:
        */
        for (sft = 32; sft > 0; sft--) {
                tmp = (u64) to << sft;
                tmp += from / 2;
                do_div(tmp, from);
                if ((tmp >> sftacc) == 0)
                        break;
        }
        *mult = tmp;
        *shift = sft;
}

两个事件之间的时间持续时间可以通过以下代码片段计算:

struct clocksource *cs = &curr_clocksource;
cycle_t start = cs->read(cs);
/* things to do */
cycle_t end = cs->read(cs);
cycle_t diff = end – start;
duration =  clocksource_cyc2ns(diff, cs->mult, cs->shift);

Linux 时间保持数据结构、宏和辅助例程

现在我们将通过查看一些关键的时间保持结构、宏和辅助例程来扩大我们的认识,这些可以帮助程序员提取特定的与时间相关的数据。

Jiffies

jiffies变量保存自系统启动以来经过的滴答数。每次发生滴答时,jiffies增加一。它是一个 32 位变量,这意味着对于 100 Hz 的滴答率,大约在 497 天后(对于 1000 Hz 的滴答率,在 49 天 17 小时后)会发生溢出。

为了解决这个问题,使用了 64 位变量jiffies_64,它允许在溢出发生之前经过数千万年。jiffies变量等于jiffies_64的 32 位最低有效位。之所以同时拥有jiffiesjiffies_64变量,是因为在 32 位机器中,无法原子地访问 64 位变量;在处理这两个 32 位半部分时需要一些同步,以避免在处理这两个 32 位半部分时发生任何计数器更新。在/kernel/time/jiffies.c源文件中定义的函数get_jiffies_64()返回jiffies的当前值:

u64 get_jiffies_64(void)
{
        unsigned long seq;
        u64 ret;

        do {
                seq = read_seqbegin(&jiffies_lock);
                ret = jiffies_64;
        } while (read_seqretry(&jiffies_lock, seq));
        return ret;
}

在处理jiffies时,必须考虑可能发生的回绕,因为在比较两个时间事件时会导致不可预测的结果。有四个宏在include/linux/jiffies.h中定义,用于此目的:

#define time_after(a,b)           \
       (typecheck(unsigned long, a) && \
        typecheck(unsigned long, b) && \
        ((long)((b) - (a)) < 0))
#define time_before(a,b)       time_after(b,a)

#define time_after_eq(a,b)     \
       (typecheck(unsigned long, a) && \
        typecheck(unsigned long, b) && \
        ((long)((a) - (b)) >= 0))
#define time_before_eq(a,b)    time_after_eq(b,a)

所有这些宏都返回布尔值;参数ab是要比较的时间事件。如果 a 恰好是 b 之后的时间,time_after()返回 true,否则返回 false。相反,如果 a 恰好在 b 之前,time_before()返回 true,否则返回 false。time_after_eq()time_before_eq()如果 a 和 b 都相等,则返回 true。可以使用kernel/time/time.c中定义的例程jiffies_to_msecs()jiffies_to_usecs()将 jiffies 转换为其他时间单位,如毫秒、微秒和纳秒,以及include/linux/jiffies.h中的jiffies_to_nsecs()

unsigned int jiffies_to_msecs(const unsigned long j)
{
#if HZ <= MSEC_PER_SEC && !(MSEC_PER_SEC % HZ)
        return (MSEC_PER_SEC / HZ) * j;
#elif HZ > MSEC_PER_SEC && !(HZ % MSEC_PER_SEC)
        return (j + (HZ / MSEC_PER_SEC) - 1)/(HZ / MSEC_PER_SEC);
#else
# if BITS_PER_LONG == 32
        return (HZ_TO_MSEC_MUL32 * j) >> HZ_TO_MSEC_SHR32;
# else
        return (j * HZ_TO_MSEC_NUM) / HZ_TO_MSEC_DEN;
# endif
#endif
}

unsigned int jiffies_to_usecs(const unsigned long j)
{
        /*
        * Hz doesn't go much further MSEC_PER_SEC.
        * jiffies_to_usecs() and usecs_to_jiffies() depend on that.
        */
        BUILD_BUG_ON(HZ > USEC_PER_SEC);

#if !(USEC_PER_SEC % HZ)
        return (USEC_PER_SEC / HZ) * j;
#else
# if BITS_PER_LONG == 32
        return (HZ_TO_USEC_MUL32 * j) >> HZ_TO_USEC_SHR32;
# else
        return (j * HZ_TO_USEC_NUM) / HZ_TO_USEC_DEN;
# endif
#endif
}

static inline u64 jiffies_to_nsecs(const unsigned long j)
{
        return (u64)jiffies_to_usecs(j) * NSEC_PER_USEC;
}

其他转换例程可以在include/linux/jiffies.h文件中探索。

Timeval 和 timespec

在 Linux 中,当前时间是通过保持自 1970 年 1 月 1 日午夜以来经过的秒数(称为纪元)来维护的;这些中的每个第二个元素分别表示自上次秒数以来经过的时间,以微秒和纳秒为单位:

struct timespec {
        __kernel_time_t  tv_sec;                   /* seconds */
        long            tv_nsec;          /* nanoseconds */
};
#endif

struct timeval {
        __kernel_time_t          tv_sec;           /* seconds */
        __kernel_suseconds_t     tv_usec;  /* microseconds */
};

从时钟源读取的时间(计数器值)需要在某个地方累积和跟踪;include/linux/timekeeper_internal.h中定义的struct tk_read_base结构用于此目的:

struct tk_read_base {
        struct clocksource        *clock;
        cycle_t                  (*read)(struct clocksource *cs);
        cycle_t                  mask;
        cycle_t                  cycle_last;
        u32                      mult;
        u32                      shift;
        u64                      xtime_nsec;
        ktime_t                  base_mono;
};

include/linux/timekeeper_internal.h中定义的struct timekeeper结构保持各种时间保持值。它是用于维护和操作不同时间线的时间保持数据的主要数据结构,如单调和原始:

struct timekeeper {
        struct tk_read_base       tkr;
        u64                      xtime_sec;
        unsigned long           ktime_sec;
        struct timespec64 wall_to_monotonic;
        ktime_t                  offs_real;
        ktime_t                  offs_boot;
        ktime_t                  offs_tai;
        s32                      tai_offset;
        ktime_t                  base_raw;
        struct timespec64 raw_time;

        /* The following members are for timekeeping internal use */
        cycle_t                  cycle_interval;
        u64                      xtime_interval;
        s64                      xtime_remainder;
        u32                      raw_interval;
        u64                      ntp_tick;
        /* Difference between accumulated time and NTP time in ntp
        * shifted nano seconds. */
        s64                      ntp_error;
        u32                      ntp_error_shift;
        u32                      ntp_err_mult;
};

跟踪和维护时间

时间保持辅助例程timekeeping_get_ns()timekeeping_get_ns()有助于获取通用时间和地球时间之间的校正因子(Δt),单位为纳秒:

static inline u64 timekeeping_delta_to_ns(struct tk_read_base *tkr, u64 delta)
{
        u64 nsec;

        nsec = delta * tkr->mult + tkr->xtime_nsec;
        nsec >>= tkr->shift;

        /* If arch requires, add in get_arch_timeoffset() */
        return nsec + arch_gettimeoffset();
}

static inline u64 timekeeping_get_ns(struct tk_read_base *tkr)
{
        u64 delta;

        delta = timekeeping_get_delta(tkr);
        return timekeeping_delta_to_ns(tkr, delta);
}

例程logarithmic_accumulation()更新 mono、raw 和 xtime 时间线;它将周期的移位间隔累积到纳秒的移位间隔中。例程accumulate_nsecs_to_secs()struct tk_read_basextime_nsec字段中的纳秒累积到struct timekeeperxtime_sec中。这些例程有助于跟踪系统中的当前时间,并在kernel/time/timekeeping.c中定义:

static u64 logarithmic_accumulation(struct timekeeper *tk, u64 offset,
                                    u32 shift, unsigned int *clock_set)
{
        u64 interval = tk->cycle_interval << shift;
        u64 snsec_per_sec;

        /* If the offset is smaller than a shifted interval, do nothing */
        if (offset < interval)
                return offset;

        /* Accumulate one shifted interval */
        offset -= interval;
        tk->tkr_mono.cycle_last += interval;
        tk->tkr_raw.cycle_last  += interval;

        tk->tkr_mono.xtime_nsec += tk->xtime_interval << shift;
        *clock_set |= accumulate_nsecs_to_secs(tk);

        /* Accumulate raw time */
        tk->tkr_raw.xtime_nsec += (u64)tk->raw_time.tv_nsec << tk->tkr_raw.shift;
        tk->tkr_raw.xtime_nsec += tk->raw_interval << shift;
        snsec_per_sec = (u64)NSEC_PER_SEC << tk->tkr_raw.shift;
        while (tk->tkr_raw.xtime_nsec >= snsec_per_sec) {
                tk->tkr_raw.xtime_nsec -= snsec_per_sec;
                tk->raw_time.tv_sec++;
        }
        tk->raw_time.tv_nsec = tk->tkr_raw.xtime_nsec >> tk->tkr_raw.shift;
        tk->tkr_raw.xtime_nsec -= (u64)tk->raw_time.tv_nsec << tk->tkr_raw.shift;

        /* Accumulate error between NTP and clock interval */
        tk->ntp_error += tk->ntp_tick << shift;
        tk->ntp_error -= (tk->xtime_interval + tk->xtime_remainder) <<
                                                (tk->ntp_error_shift + shift);

        return offset;
}

另一个例程update_wall_time(),在kernel/time/timekeeping.c中定义,负责维护壁钟时间。它使用当前时钟源作为参考递增壁钟时间。

时钟中断处理

为了提供编程接口,生成滴答的时钟设备通过include/linux/clockchips.h中定义的struct clock_event_device结构进行抽象:

struct clock_event_device {
        void                    (*event_handler)(struct clock_event_device *);
        int                     (*set_next_event)(unsigned long evt, struct clock_event_device *);
        int                     (*set_next_ktime)(ktime_t expires, struct clock_event_device *);
        ktime_t                  next_event;
        u64                      max_delta_ns;
        u64                      min_delta_ns;
        u32                      mult;
        u32                      shift;
        enum clock_event_state    state_use_accessors;
        unsigned int            features;
        unsigned long           retries;

        int                     (*set_state_periodic)(struct  clock_event_device *);
        int                     (*set_state_oneshot)(struct clock_event_device *);
        int                     (*set_state_oneshot_stopped)(struct clock_event_device *);
        int                     (*set_state_shutdown)(struct clock_event_device *);
        int                     (*tick_resume)(struct clock_event_device *);

        void                    (*broadcast)(const struct cpumask *mask);
        void                    (*suspend)(struct clock_event_device *);
        void                    (*resume)(struct clock_event_device *);
        unsigned long           min_delta_ticks;
        unsigned long           max_delta_ticks;

        const char               *name;
        int                     rating;
        int                     irq;
        int                     bound_on;
        const struct cpumask       *cpumask;
        struct list_head  list;
        struct module             *owner;
} ____cacheline_aligned;

在这里,event_handler是由框架分配的适当例程,由低级处理程序调用以运行滴答。根据配置,这个clock_event_device可以是periodicone-shotktime基础的。在这三种情况中,滴答设备的适当操作模式是通过unsigned int features字段设置的,使用这些宏之一:

#define CLOCK_EVT_FEAT_PERIODIC 0x000001
#define CLOCK_EVT_FEAT_ONESHOT 0x000002
#define CLOCK_EVT_FEAT_KTIME  0x000004

周期模式配置硬件每1/HZ秒生成一次滴答,而单次模式使硬件在当前时间后经过特定数量的周期生成滴答。

根据用例和操作模式,event_handler 可以是这三个例程中的任何一个:

  • tick_handle_periodic()是周期性滴答的默认处理程序,定义在kernel/time/tick-common.c中。

  • tick_nohz_handler()是低分辨率中断处理程序,在低分辨率模式下使用。它在kernel/time/tick-sched.c中定义。

  • hrtimer_interrupt()在高分辨率模式下使用,并在调用时禁用中断。

通过clockevents_config_and_register()例程配置和注册时钟事件设备,定义在kernel/time/clockevents.c中。

滴答设备

clock_event_device抽象是为了核心定时框架;我们需要一个单独的抽象来处理每个 CPU 的滴答设备;这是通过struct tick_device结构和DEFINE_PER_CPU()宏来实现的,分别在kernel/time/tick-sched.hinclude/linux/percpu-defs.h中定义:

enum tick_device_mode {
 TICKDEV_MODE_PERIODIC,
 TICKDEV_MODE_ONESHOT,
};

struct tick_device {
        struct clock_event_device *evtdev;
        enum tick_device_mode mode;
}

tick_device可以是周期性的或单次的。它通过enum tick_device_mode设置。

软件定时器和延迟函数

软件定时器允许在时间到期时调用函数。有两种类型的定时器:内核使用的动态定时器和用户空间进程使用的间隔定时器。除了软件定时器,还有另一种常用的定时函数称为延迟函数。延迟函数实现一个精确的循环,根据延迟函数的参数执行(通常是多次)。

动态定时器

动态定时器可以随时创建和销毁,因此称为动态定时器。动态定时器由struct timer_list对象表示,定义在include/linux/timer.h中:

struct timer_list {
        /*
        * Every field that changes during normal runtime grouped to the
        * same cacheline
        */
        struct hlist_node entry;
        unsigned long           expires;
        void                    (*function)(unsigned long);
        unsigned long           data;
        u32                      flags;

#ifdef CONFIG_LOCKDEP
        struct lockdep_map        lockdep_map;
#endif
};

系统中的所有定时器都由一个双向链表管理,并按照它们的到期时间排序,由 expires 字段表示。expires 字段指定定时器到期后的时间。一旦当前的jiffies值匹配或超过此字段的值,定时器就会过期。通过 entry 字段,定时器被添加到此定时器链表中。函数字段指向在定时器到期时要调用的例程,数据字段保存要传递给函数的参数(如果需要)。expires 字段不断与jiffies_64值进行比较,以确定定时器是否已经过期。

动态定时器可以按以下方式创建和激活:

  • 创建一个新的timer_list对象,比如说t_obj

  • 使用宏init_timer(&t_obj)初始化此定时器对象,定义在include/linux/timer.h中。

  • 使用函数字段初始化函数的地址,以在定时器到期时调用该函数。如果函数需要参数,则也初始化数据字段。

  • 如果定时器对象已经添加到定时器列表中,则通过调用函数mod_timer(&t_obj, <timeout-value-in-jiffies>)更新 expires 字段,定义在kernel/time/timer.c中。

  • 如果没有,初始化 expires 字段,并使用add_timer(&t_obj)将定时器对象添加到定时器列表中,定义在/kernel/time/timer.c中。

内核会自动从定时器列表中删除已过期的定时器,但也有其他方法可以从列表中删除定时器。kernel/time/timer.c中定义的del_timer()del_timer_sync()例程以及宏del_singleshot_timer_sync()可以帮助实现这一点:

int del_timer(struct timer_list *timer)
{
        struct tvec_base *base;
        unsigned long flags;
        int ret = 0;

        debug_assert_init(timer);

        timer_stats_timer_clear_start_info(timer);
        if (timer_pending(timer)) {
                base = lock_timer_base(timer, &flags);
                if (timer_pending(timer)) {
                        detach_timer(timer, 1);
                        if (timer->expires == base->next_timer &&
                            !tbase_get_deferrable(timer->base))
                                base->next_timer = base->timer_jiffies;
                        ret = 1;
                }
                spin_unlock_irqrestore(&base->lock, flags);
        }

        return ret;
}

int del_timer_sync(struct timer_list *timer)
{
#ifdef CONFIG_LOCKDEP
        unsigned long flags;

        /*
        * If lockdep gives a backtrace here, please reference
        * the synchronization rules above.
        */
        local_irq_save(flags);
        lock_map_acquire(&timer->lockdep_map);
        lock_map_release(&timer->lockdep_map);
        local_irq_restore(flags);
#endif
        /*
        * don't use it in hardirq context, because it
        * could lead to deadlock.
        */
        WARN_ON(in_irq());
        for (;;) {
                int ret = try_to_del_timer_sync(timer);
                if (ret >= 0)
                        return ret;
                cpu_relax();
        }
}

#define del_singleshot_timer_sync(t) del_timer_sync(t)

del_timer() 删除活动和非活动的定时器。在 SMP 系统中特别有用,del_timer_sync() 会停止定时器,并等待处理程序在其他 CPU 上执行完成。

动态定时器的竞争条件

RESOURCE_DEALLOCATE() here could be any relevant resource deallocation routine:
...
del_timer(&t_obj);
RESOURCE_DEALLOCATE();
....

然而,这种方法仅适用于单处理器系统。在 SMP 系统中,当定时器停止时,其功能可能已经在另一个 CPU 上运行。在这种情况下,资源将在del_timer()返回时立即释放,而定时器功能仍在其他 CPU 上操作它们;这绝非理想的情况。del_timer_sync()解决了这个问题:在停止定时器后,它会等待定时器功能在其他 CPU 上执行完成。del_timer_sync()在定时器功能可以重新激活自身的情况下非常有用。如果定时器功能不重新激活定时器,则应该使用一个更简单和更快的宏del_singleshot_timer_sync()

动态定时器处理

软件定时器复杂且耗时,因此不应由定时器 ISR 处理。而应该由一个可延迟的底半软中断例程TIMER_SOFTIRQ来执行,其例程在kernel/time/timer.c中定义:

static __latent_entropy void run_timer_softirq(struct softirq_action *h)
{
        struct timer_base *base = this_cpu_ptr(&timer_bases[BASE_STD]);

        base->must_forward_clk = false;

        __run_timers(base);
        if (IS_ENABLED(CONFIG_NO_HZ_COMMON) && base->nohz_active)
                __run_timers(this_cpu_ptr(&timer_bases[BASE_DEF]));
}

延迟函数

定时器在超时期相对较长时非常有用;在所有其他需要较短持续时间的用例中,使用延迟函数。在处理诸如存储设备(即闪存EEPROM)等硬件时,设备驱动程序非常关键,需要等待设备完成写入和擦除等硬件操作,这在大多数情况下是在几微秒到毫秒的范围内。在不等待硬件完成这些操作的情况下继续执行其他指令将导致不可预测的读/写操作和数据损坏。在这种情况下,延迟函数非常有用。内核通过ndelay()udelay()mdelay()例程和宏提供这样的短延迟,分别接收纳秒、微秒和毫秒为参数。

以下函数可以在include/linux/delay.h中找到:

static inline void ndelay(unsigned long x)
{
        udelay(DIV_ROUND_UP(x, 1000));
}

这些函数可以在arch/ia64/kernel/time.c中找到:

static void
ia64_itc_udelay (unsigned long usecs)
{
        unsigned long start = ia64_get_itc();
        unsigned long end = start + usecs*local_cpu_data->cyc_per_usec;

        while (time_before(ia64_get_itc(), end))
                cpu_relax();
}

void (*ia64_udelay)(unsigned long usecs) = &ia64_itc_udelay;

void
udelay (unsigned long usecs)
{
        (*ia64_udelay)(usecs);
}

POSIX 时钟

POSIX 为多线程和实时用户空间应用程序提供了软件定时器,称为 POSIX 定时器。POSIX 提供以下时钟:

  • CLOCK_REALTIME:该时钟表示系统中的实时时间。也称为墙上时间,类似于挂钟上的时间,用于时间戳和向用户提供实际时间。该时钟是可修改的。

  • CLOCK_MONOTONIC:该时钟保持系统启动以来经过的时间。它是不断增加的,并且不可被任何进程或用户修改。由于其单调性质,它是确定两个时间事件之间时间差的首选时钟。

  • CLOCK_BOOTTIME:该时钟与CLOCK_MONOTONIC相同;但它包括在挂起中花费的时间。

这些时钟可以通过以下 POSIX 时钟例程进行访问和修改(如果所选时钟允许):

  • int clock_getres(clockid_t clk_id, struct timespec *res);

  • int clock_gettime(clockid_t clk_id, struct timespec *tp);

  • int clock_settime(clockid_t clk_id, const struct timespec *tp);

函数 clock_getres() 获取由 clk_id 指定的时钟的分辨率(精度)。如果分辨率非空,则将其存储在由分辨率指向的 struct timespec 中。函数 clock_gettime()clock_settime() 读取和设置由 clk_id 指定的时钟的时间。clk_id 可以是任何 POSIX 时钟:CLOCK_REALTIMECLOCK_MONOTONIC 等等。

CLOCK_REALTIME_COARSE

CLOCK_MONOTONIC_COARSE

每个这些 POSIX 例程都有相应的系统调用,即 sys_clock_getres(),sys_clock_gettime()sys_clock_settime. 因此,每次调用这些例程时,都会发生从用户模式到内核模式的上下文切换。如果对这些例程的调用频繁,上下文切换可能会导致系统性能下降。为了避免上下文切换,POSIX 时钟的两个粗糙变体被实现为 vDSO(虚拟动态共享对象)库:

vDSO 是一个小型共享库,其中包含内核空间的选定例程,内核将其映射到用户空间应用程序的地址空间中,以便这些内核空间例程可以直接由它们在用户空间中的进程调用。C 库调用 vDSO,因此用户空间应用程序可以通过标准函数以通常的方式进行编程,并且 C 库将利用通过 vDSO 可用的功能,而不涉及任何系统调用接口,从而避免任何用户模式-内核模式上下文切换和系统调用开销。作为 vDSO 实现,这些粗糙的变体速度更快,分辨率为 1 毫秒。

总结

在本章中,我们详细了解了内核提供的大多数用于驱动基于时间的事件的例程,以及理解了 Linux 时间、其基础设施和其测量的基本方面。我们还简要介绍了 POSIX 时钟及其一些关键的时间访问和修改例程。然而,有效的时间驱动程序取决于对这些例程的谨慎和计算使用。

在下一章中,我们将简要介绍动态内核模块的管理。

第十一章:模块管理

内核模块(也称为 LKM)由于易用性而强调了内核服务的发展。本章的重点将是了解内核如何无缝地促进整个过程,使模块的加载和卸载变得动态和简单,我们将深入了解模块管理中涉及的所有核心概念、函数和重要数据结构。我们假设读者熟悉模块的基本用法。

在本章中,我们将涵盖以下主题:

  • 内核模块的关键元素

  • 模块布局

  • 模块加载和卸载接口

  • 关键数据结构

内核模块

内核模块是一种简单而有效的机制,可以在不重建整个内核的情况下扩展运行系统的功能,它们对于引入动态性和可扩展性到 Linux 操作系统至关重要。内核模块不仅满足了内核的可扩展性,还引入了以下功能:

  • 允许内核仅保留必要的功能,从而提高容量利用率

  • 允许专有/非 GPL 兼容服务加载和卸载

  • 内核可扩展性的底线特性

LKM 的元素

每个模块对象都包括init(构造函数)exit(析构函数)例程。当模块部署到内核地址空间时,将调用init例程,而在模块被移除时将调用exit例程。正如名称本身所暗示的那样,init例程通常被编程为执行设置模块主体所必需的操作和动作,例如注册到特定的内核子系统或分配对加载的功能至关重要的资源。但是,initexit例程中编程的特定操作取决于模块的设计目的以及它为内核带来的功能。以下代码摘录显示了initexit例程的模板:

int init_module(void)
{
  /* perform required setup and registration ops */
    ...
    ...
    return 0;
}

void cleanup_module(void)
{
   /* perform required cleanup operations */
   ...
   ...
}

注意,init例程返回一个整数——如果模块已提交到内核地址空间,则返回零,如果失败则返回负数。这还为程序员提供了灵活性,只有在成功注册到所需子系统时才能提交模块。

initexit例程的默认名称分别为init_module()cleanup_module()。模块可以选择更改initexit例程的名称以提高代码可读性。但是,它们必须使用module_initmodule_exit宏进行声明:

int myinit(void)
{
        ...
        ...
        return 0;
}

void myexit(void)
{
        ...
        ...
}

module_init(myinit);
module_exit(myexit);

注释宏是模块代码的另一个关键元素。这些宏用于提供模块的用法、许可和作者信息。这很重要,因为模块来自各种供应商:

  • MODULE_DESCRIPTION(): 该宏用于指定模块的一般描述

  • MODULE_AUTHOR(): 用于提供作者信息

  • MODULE_LICENSE(): 用于指定模块中代码的合法许可证

通过这些宏指定的所有信息都保留在模块二进制文件中,并且可以通过名为modinfo的实用程序由用户访问。MODULE_LICENSE()是模块必须提到的唯一强制性宏。这非常方便,因为它通知用户模块中的专有代码容易受到调试和支持问题的影响(内核社区很可能会忽略专有模块引起的问题)。

模块可用的另一个有用功能是使用模块参数动态初始化模块数据变量。这允许在模块中声明的数据变量在模块部署期间或模块在内存中live时(通过 sysfs 接口)进行初始化。这可以通过通过适当的module_param()宏族(在内核头文件<linux/moduleparam.h>中找到)将选定的变量设置为模块参数来实现。在模块部署期间传递给模块参数的值在调用init函数之前进行初始化。

模块中的代码可以根据需要访问全局内核函数和数据。这使得模块的代码可以利用现有的内核功能。通过这样的函数调用,模块可以执行所需的操作,例如将消息打印到内核日志缓冲区,分配和释放内存,获取和释放排他锁,以及向适当的子系统注册和注销模块代码。

类似地,一个模块也可以将其符号导出到内核的全局符号表中,然后可以从其他模块中的代码中访问这些符号。这通过将内核服务组织在一组模块中,而不是将整个服务实现为单个 LKM,从而促进了内核服务的细粒度设计和实现。相关服务的堆叠会导致模块依赖,例如:如果模块 A 正在使用模块 B 的符号,则 A 依赖于 B,在这种情况下,必须在加载模块 A 之前加载模块 B,并且在卸载模块 A 之前不能卸载模块 B。

LKM 的二进制布局

模块是使用 kbuild makefile 构建的;一旦构建过程完成,将生成一个带有.ko(内核对象)扩展名的 ELF 二进制文件。模块 ELF 二进制文件经过适当的调整,以添加新的部分,使其与其他 ELF 二进制文件区分开,并存储与模块相关的元数据。以下是内核模块中的部分:

.gnu.linkonce.this_module 模块结构
.modinfo 有关模块的信息(许可证等)
__versions 编译时模块依赖的符号的预期版本
__ksymtab* 由此模块导出的符号表
__kcrctab* 由此模块导出的符号版本表
.init 初始化时使用的部分
.text, .data 等 代码和数据部分

加载和卸载操作

模块可以通过一个名为modutils的应用程序包中的特殊工具部署,其中insmodrmmod被广泛使用。insmod用于将模块部署到内核地址空间,rmmod用于卸载活动模块。这些工具通过调用适当的系统调用来启动加载/卸载操作:

int finit_module(int fd, const char *param_values, int flags);
int delete_module(const char *name, int flags);

在这里,finit_module()(由insmod)被调用,带有指定模块二进制文件(.ko)的文件描述符和其他相关参数。此函数通过调用底层系统调用进入内核模式:

SYSCALL_DEFINE3(finit_module, int, fd, const char __user *, uargs, int, flags)
{
        struct load_info info = { };
        loff_t size;
        void *hdr;
        int err;

        err = may_init_module();
        if (err)
                return err;

        pr_debug("finit_module: fd=%d, uargs=%p, flags=%i\n", fd, uargs, flags);

        if (flags & ~(MODULE_INIT_IGNORE_MODVERSIONS
                      |MODULE_INIT_IGNORE_VERMAGIC))
                return -EINVAL;

        err = kernel_read_file_from_fd(fd, &hdr, &size, INT_MAX,
                                       READING_MODULE);
        if (err)
                return err;
        info.hdr = hdr;
        info.len = size;

        return load_module(&info, uargs, flags);
}

在这里,may_init_module()被调用来验证调用上下文的CAP_SYS_MODULE特权;此函数在失败时返回负数,在成功时返回零。如果调用者具有所需的特权,则通过使用kernel_read_file_from_fd()例程访问指定的模块映像,该例程返回模块映像的地址,然后将其填充到struct load_info的实例中。最后,通过将load_info的实例地址和从finit_module()调用传递下来的其他用户参数,调用load_module()核心内核例程:

static int load_module(struct load_info *info, const char __user *uargs,int flags)
{
        struct module *mod;
        long err;
        char *after_dashes;

        err = module_sig_check(info, flags);
        if (err)
                goto free_copy;

        err = elf_header_check(info);
        if (err)
                goto free_copy;

        /* Figure out module layout, and allocate all the memory. */
        mod = layout_and_allocate(info, flags);
        if (IS_ERR(mod)) {
                err = PTR_ERR(mod);
                goto free_copy;
        }

        ....
        ....
        ....

}

在这里,load_module()是一个核心内核例程,它尝试将模块映像链接到内核地址空间。此函数启动一系列健全性检查,并最终通过将模块参数初始化为调用者提供的值并调用模块的init函数来提交模块。以下步骤详细说明了这些操作,以及调用的相关辅助函数的名称:

  • 检查签名(module_sig_check()

  • 检查 ELF 头(elf_header_check()

  • 检查模块布局并分配必要的内存(layout_and_allocate()

  • 将模块附加到模块列表(add_unformed_module()

  • 为模块分配每个 CPU 区域(percpu_modalloc()

  • 由于模块位于最终位置,需要找到可选部分(find_module_sections()

  • 检查模块许可证和版本(check_module_license_and_versions()

  • 解析符号(simplify_symbols()

  • 根据 args 列表中传递的值设置模块参数

  • 检查符号的重复(complete_formation()

  • 设置 sysfs(mod_sysfs_setup()

  • 释放load_info结构中的副本(free_copy()

  • 调用模块的init函数(do_init_module()

卸载过程与加载过程非常相似;唯一不同的是,有一些健全性检查,以确保安全地从内核中移除模块,而不影响系统稳定性。模块的卸载是通过调用rmmod实用程序来初始化的,该实用程序调用delete_module()例程,该例程进入底层系统调用:

SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
                unsigned int, flags)
{
        struct module *mod;
        char name[MODULE_NAME_LEN];
        int ret, forced = 0;

        if (!capable(CAP_SYS_MODULE) || modules_disabled)
                return -EPERM;

        if (strncpy_from_user(name, name_user, MODULE_NAME_LEN-1) < 0)
                return -EFAULT;
        name[MODULE_NAME_LEN-1] = '\0';

        audit_log_kern_module(name);

        if (mutex_lock_interruptible(&module_mutex) != 0)
                return -EINTR;

        mod = find_module(name);
        if (!mod) {
                ret = -ENOENT;
                goto out;
        }

        if (!list_empty(&mod->source_list)) {
                /* Other modules depend on us: get rid of them first. */
                ret = -EWOULDBLOCK;
                goto out;
        }

        /* Doing init or already dying? */
        if (mod->state != MODULE_STATE_LIVE) {
                /* FIXME: if (force), slam module count damn the torpedoes */
                pr_debug("%s already dying\n", mod->name);
                ret = -EBUSY;
                goto out;
        }

        /* If it has an init func, it must have an exit func to unload */
        if (mod->init && !mod->exit) {
                forced = try_force_unload(flags);
                if (!forced) {
                        /* This module can't be removed */
                        ret = -EBUSY;
                        goto out;
                }
        }

        /* Stop the machine so refcounts can't move and disable module. */
        ret = try_stop_module(mod, flags, &forced);
        if (ret != 0)
                goto out;

        mutex_unlock(&module_mutex);
        /* Final destruction now no one is using it. */
        if (mod->exit != NULL)
                mod->exit();
        blocking_notifier_call_chain(&module_notify_list,
                                     MODULE_STATE_GOING, mod);
        klp_module_going(mod);
        ftrace_release_mod(mod);

        async_synchronize_full();

        /* Store the name of the last unloaded module for diagnostic purposes */
        strlcpy(last_unloaded_module, mod->name, sizeof(last_unloaded_module));

        free_module(mod);
        return 0;
out:
        mutex_unlock(&module_mutex);
        return ret;
}

在调用时,系统调用会检查调用者是否具有必要的权限,然后检查是否存在任何模块依赖项。如果没有,模块就可以被移除(否则,将返回错误)。之后,验证模块状态(live)。最后,调用模块的退出例程,最后调用free_module()例程:

/* Free a module, remove from lists, etc. */
static void free_module(struct module *mod)
{
        trace_module_free(mod);

        mod_sysfs_teardown(mod);

        /* We leave it in list to prevent duplicate loads, but make sure
        * that no one uses it while it's being deconstructed. */
        mutex_lock(&module_mutex);
        mod->state = MODULE_STATE_UNFORMED;
        mutex_unlock(&module_mutex);

        /* Remove dynamic debug info */
        ddebug_remove_module(mod->name);

        /* Arch-specific cleanup. */
        module_arch_cleanup(mod);

        /* Module unload stuff */
        module_unload_free(mod);

        /* Free any allocated parameters. */
        destroy_params(mod->kp, mod->num_kp);

        if (is_livepatch_module(mod))
                free_module_elf(mod);

        /* Now we can delete it from the lists */
        mutex_lock(&module_mutex);
        /* Unlink carefully: kallsyms could be walking list. */
        list_del_rcu(&mod->list);
        mod_tree_remove(mod);
        /* Remove this module from bug list, this uses list_del_rcu */
        module_bug_cleanup(mod);
        /* Wait for RCU-sched synchronizing before releasing mod->list and buglist. */
        synchronize_sched();
        mutex_unlock(&module_mutex);

        /* This may be empty, but that's OK */
        disable_ro_nx(&mod->init_layout);
        module_arch_freeing_init(mod);
        module_memfree(mod->init_layout.base);
        kfree(mod->args);
        percpu_modfree(mod);

        /* Free lock-classes; relies on the preceding sync_rcu(). */
        lockdep_free_key_range(mod->core_layout.base, mod->core_layout.size);

        /* Finally, free the core (containing the module structure) */
        disable_ro_nx(&mod->core_layout);
        module_memfree(mod->core_layout.base);

#ifdef CONFIG_MPU
        update_protections(current->mm);
#endif
}

此调用将模块从加载期间放置的各种列表中删除(sysfs、模块列表等),以启动清理。调用特定于体系结构的清理例程(可以在</linux/arch/<arch>/kernel/module.c>中找到。对所有依赖模块进行迭代,并从它们的列表中删除模块。一旦清理结束,将释放为模块分配的所有资源和内存。

模块数据结构

内核中部署的每个模块通常通过称为struct module的描述符表示。内核维护着模块实例的列表,每个实例代表内存中的特定模块:

struct module {
        enum module_state state;

        /* Member of list of modules */
        struct list_head list;

        /* Unique handle for this module */
        char name[MODULE_NAME_LEN];

        /* Sysfs stuff. */
        struct module_kobject mkobj;
        struct module_attribute *modinfo_attrs;
        const char *version;
        const char *srcversion;
        struct kobject *holders_dir;

        /* Exported symbols */
        const struct kernel_symbol *syms;
        const s32 *crcs;
        unsigned int num_syms;

        /* Kernel parameters. */
#ifdef CONFIG_SYSFS
        struct mutex param_lock;
#endif
        struct kernel_param *kp;
        unsigned int num_kp;

        /* GPL-only exported symbols. */
        unsigned int num_gpl_syms;
        const struct kernel_symbol *gpl_syms;
        const s32 *gpl_crcs;

#ifdef CONFIG_UNUSED_SYMBOLS
        /* unused exported symbols. */
        const struct kernel_symbol *unused_syms;
        const s32 *unused_crcs;
        unsigned int num_unused_syms;

        /* GPL-only, unused exported symbols. */
        unsigned int num_unused_gpl_syms;
        const struct kernel_symbol *unused_gpl_syms;
        const s32 *unused_gpl_crcs;
#endif

#ifdef CONFIG_MODULE_SIG
        /* Signature was verified. */
        bool sig_ok;
#endif

        bool async_probe_requested;

        /* symbols that will be GPL-only in the near future. */
        const struct kernel_symbol *gpl_future_syms;
        const s32 *gpl_future_crcs;
        unsigned int num_gpl_future_syms;

        /* Exception table */
        unsigned int num_exentries;
        struct exception_table_entry *extable;

        /* Startup function. */
        int (*init)(void);

        /* Core layout: rbtree is accessed frequently, so keep together. */
        struct module_layout core_layout __module_layout_align;
        struct module_layout init_layout;

        /* Arch-specific module values */
        struct mod_arch_specific arch;

        unsigned long taints;     /* same bits as kernel:taint_flags */

#ifdef CONFIG_GENERIC_BUG
        /* Support for BUG */
        unsigned num_bugs;
        struct list_head bug_list;
        struct bug_entry *bug_table;
#endif

#ifdef CONFIG_KALLSYMS
        /* Protected by RCU and/or module_mutex: use rcu_dereference() */
        struct mod_kallsyms *kallsyms;
        struct mod_kallsyms core_kallsyms;

        /* Section attributes */
        struct module_sect_attrs *sect_attrs;

        /* Notes attributes */
        struct module_notes_attrs *notes_attrs;
#endif

        /* The command line arguments (may be mangled).  People like
          keeping pointers to this stuff */
        char *args;

#ifdef CONFIG_SMP
        /* Per-cpu data. */
        void __percpu *percpu;
        unsigned int percpu_size;
#endif

#ifdef CONFIG_TRACEPOINTS
        unsigned int num_tracepoints;
        struct tracepoint * const *tracepoints_ptrs;
#endif
#ifdef HAVE_JUMP_LABEL
        struct jump_entry *jump_entries;
        unsigned int num_jump_entries;
#endif
#ifdef CONFIG_TRACING
        unsigned int num_trace_bprintk_fmt;
        const char **trace_bprintk_fmt_start;
#endif
#ifdef CONFIG_EVENT_TRACING
        struct trace_event_call **trace_events;
        unsigned int num_trace_events;
        struct trace_enum_map **trace_enums;
        unsigned int num_trace_enums;
#endif
#ifdef CONFIG_FTRACE_MCOUNT_RECORD
        unsigned int num_ftrace_callsites;
        unsigned long *ftrace_callsites;
#endif

#ifdef CONFIG_LIVEPATCH
        bool klp; /* Is this a livepatch module? */
        bool klp_alive;

        /* Elf information */
        struct klp_modinfo *klp_info;
#endif

#ifdef CONFIG_MODULE_UNLOAD
        /* What modules depend on me? */
        struct list_head source_list;
        /* What modules do I depend on? */
        struct list_head target_list;

        /* Destruction function. */
        void (*exit)(void);

        atomic_t refcnt;
#endif

#ifdef CONFIG_CONSTRUCTORS
        /* Constructor functions. */
        ctor_fn_t *ctors;
        unsigned int num_ctors;
#endif
} ____cacheline_aligned;

现在让我们看一下此结构的一些关键字段:

  • list:这是一个双向链表,其中包含内核中加载的所有模块。

  • name:指定模块的名称。这必须是一个唯一的名称,因为模块是通过此名称引用的。

  • state:表示模块的当前状态。模块可以处于<linux/module.h>下指定的任一状态中:

enum module_state {
        MODULE_STATE_LIVE,        /* Normal state. */
        MODULE_STATE_COMING,      /* Full formed, running module_init. */
        MODULE_STATE_GOING,       /* Going away. */
        MODULE_STATE_UNFORMED,    /* Still setting it up. */
};

在加载或卸载模块时,了解其当前状态很重要;例如,如果其状态指定模块已经存在,则无需插入现有模块。

syms, crc 和 num_syms:用于管理模块代码导出的符号。

init:这是指向在模块初始化时调用的函数的指针。

arch:表示特定于体系结构的结构,应填充体系结构特定数据,以便模块运行。但是,由于大多数体系结构不需要任何额外的信息,因此此结构大多数情况下保持为空。

taints:如果模块使内核受到污染,则使用此选项。这可能意味着内核怀疑模块会执行一些有害的操作或者是非 GPL 兼容的代码。

percpu:指向属于模块的每个 CPU 数据。它在模块加载时初始化。

source_list 和 target_list:这包含了模块依赖的详细信息。

exit:这只是 init 的相反。它指向调用模块清理过程的函数。它释放模块持有的内存并执行其他清理特定任务。

内存布局

模块的内存布局通过<linux/module.h>中定义的struct module_layout对象显示。

struct module_layout {
        /* The actual code + data. */
        void *base;
        /* Total size. */
        unsigned int size;
        /* The size of the executable code.  */
        unsigned int text_size;
        /* Size of RO section of the module (text+rodata) */
        unsigned int ro_size;

#ifdef CONFIG_MODULES_TREE_LOOKUP
        struct mod_tree_node mtn;
#endif
};

总结

在这一章中,我们简要介绍了模块的所有核心元素,其含义和管理细节。我们的目标是为您提供一个快速和全面的视角,了解内核如何通过模块实现其可扩展性。您还了解了促进模块管理的核心数据结构。内核在这个动态环境中保持安全和稳定的努力也是一个显著的特点。

我真诚地希望这本书能成为您去实验 Linux 内核的手段!

posted @   绝不原创的飞龙  阅读(141)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
点击右上角即可分享
微信分享提示