Maxw的小站

Maxw学习记录

信号的概念

信号(Signal)是通知进程已发生某种事件的一种机制。信号有时被描述为软件中断(software interrupts)。信号与硬件中断类似,因为它们会中断程序的正常执行流程;在大多数情况下,无法精确预测信号何时到达。一个进程(如果它具有适当的权限)可以向另一个进程发送信号。这种用途下,信号可以作为一种同步技术,甚至作为一种原始的进程间通信(IPC) 形式。进程也可以向自己发送信号。

然而,传递给进程的许多信号的通常来源是内核(kernel)。导致内核为进程生成信号的事件类型包括:

  • 发生硬件异常:这意味着硬件检测到故障条件并通知内核,内核随后向相关进程发送相应的信号。硬件异常的例子包括:执行格式错误的机器语言指令、除以0、或引用了无法访问的内存区域。
  • 用户键入了能生成信号的终端特殊字符。这些字符包括中断字符(通常是 Control-C)和挂起字符(通常是 Control-Z)。
  • 发生软件事件。例如:文件描述符上有输入可用、终端窗口大小改变、定时器超时、进程的CPU时间限制已超过、或该进程的一个子进程终止。

每个信号都被定义为一个唯一的(小)整数,从1开始顺序编号。这些整数在 <signal.h> 头文件中用 SIGxxxx 形式的符号名定义。由于每个信号使用的实际数字因实现而异,因此在程序中总是使用这些符号名称。例如,当用户键入中断字符时,SIGINT(信号编号2)被传递给进程。

信号分为两大类。第一组构成了传统或标准信号(standard signals),内核使用它们来通知进程事件。在Linux上,标准信号编号从1到31。本章我们描述标准信号。另一组信号由实时信号(realtime signals)组成,其与标准信号的区别将在第22.8节描述。

信号被认为是由某个事件产生(generated)。一旦产生,信号随后会被递送(delivered)给一个进程,该进程随后会采取某些动作(action)来响应信号。在信号产生和递送之间的时间段,信号被称为处于等待状态(pending)。通常,一个等待中的信号会在进程下一次被调度运行时立即递送,如果进程已经在运行则立即递送(例如,进程向自己发送信号)。

然而,有时我们需要确保一段代码不会因信号的递送而中断。为此,我们可以将一个信号添加到进程的信号掩码(signal mask)中——这是一组当前被阻塞(blocked)递送的信号。如果一个信号在阻塞状态下产生,它将保持等待状态,直到后来被解除阻塞(unblocked)(从信号掩码中移除)。各种系统调用允许进程向其信号掩码中添加和移除信号。

根据信号的不同,信号被递送时,进程会执行以下默认动作(default actions)之一:

  • 忽略信号(Ignored):即信号被内核丢弃,对进程没有影响。(进程甚至不知道它发生了。)
  • 进程被终止(Terminated)(杀死)。这有时被称为异常进程终止,与进程使用 exit() 终止的正常进程终止相对。
  • 生成核心转储文件(Core dump file)且进程被终止。核心转储文件包含进程虚拟内存的一个映像,可以将其加载到调试器中,以检查进程终止时的状态。
  • 进程被停止(Stopped)——进程的执行被暂停。
  • 进程被恢复(Resumed)执行——在之前被停止后恢复执行。

程序可以改变信号递送时发生的动作,而不是接受特定信号的默认动作。这被称为设置信号的处置方式(disposition)。程序可以为信号设置以下处置方式之一:

  • 发生默认动作。这对于撤销之前将信号处置方式更改为非默认值的操作很有用。
  • 忽略信号。这对于那些默认动作是终止进程的信号很有用。
  • 执行一个信号处理程序(signal handler)。信号处理程序是由程序员编写的函数,它执行适当的任务以响应信号的递送。例如,shell 有一个用于 SIGINT 信号(由中断字符 Control-C 产生)的处理程序,该处理程序使其停止当前正在做的事情并将控制权返回给主输入循环,从而再次向用户显示 shell 提示符(用户按下 Control-C -shell中断当前处理-用户可以再次在shell中输入指令了)。通知内核应调用某个处理函数通常被称为安装(installing)或建立(establishing)一个信号处理程序。当信号处理程序因信号递送而被调用时,我们说信号已被处理(handled)或,同义词,被捕获(caught)。 注意:不可能将信号的处置方式设置为终止或转储核心(除非其中一个是该信号的默认处置方式)。最接近这一点的是为该信号安装一个处理程序,然后该处理程序调用 exit()abort()abort() 函数(第21.2.2节)为进程生成一个 SIGABRT 信号,这会导致其转储核心并终止。

Linux特有的 /proc/PID/status 文件包含各种位掩码字段,可以检查这些字段以确定进程对信号的处理情况。位掩码以十六进制数显示,最低有效位代表信号1,左边下一位代表信号2,依此类推。这些字段是: * SigPnd(线程内等待信号,per-thread pending signals) * ShdPnd(进程范围内等待信号,process-wide pending signals;自Linux 2.6起) * SigBlk(阻塞信号,blocked signals) * SigIgn(忽略信号,ignored signals) * SigCgt(捕获信号,caught signals)。 (当我们第33.2节描述多线程进程中的信号处理时,SigPndShdPnd 字段之间的区别将变得清晰。)同样的信息也可以使用 ps(1) 命令的各种选项来获取。

fork(), exit(), wait()execve() 概述

  • fork() fork() 系统调用允许一个进程(称为父进程)创建一个新的进程(称为子进程)。这是通过使新的子进程成为父进程的(近乎)完全副本来实现的:子进程获取父进程栈、数据、堆和文本段(第6.3节)的副本。“Fork”一词源于我们可以将父进程视为分裂 (forking) 以产生自身的两个副本这一构想。

  • exit(status) exit() 库函数终止一个进程,使该进程使用的所有资源(内存、打开的文件描述符等)可供内核后续重新分配。status 参数是一个整数,用于确定进程的终止状态。通过 wait() 系统调用,父进程可以检索此状态。 exit() 库函数是基于 _exit() 系统调用构建的。在第25章,我们将解释这两个接口之间的区别。在此我们只需注意,在 fork() 之后,通常只有父进程和子进程中的一个通过调用 exit() 终止;另一个进程应使用 _exit() 终止。

  • wait(&status) wait(&status) 系统调用有两个目的。首先,如果该进程的某个子进程尚未调用 exit() 终止,那么 wait()暂停该进程的执行,直到它的一个子进程终止为止。其次,子进程的终止状态通过 wait()status 参数返回

  • execve(pathname, argv, envp) execve(pathname, argv, envp) 系统调用将一个新的程序(pathname,带有参数列表 argv 和环境列表 envp加载到一个进程的内存中。现有的程序文本被丢弃,并为新程序全新创建栈、数据和堆段。此操作通常被称为 execing 一个新程序。后面我们会看到,有几个库函数是基于 execve() 构建的,每个函数都在编程接口上提供了有用的变体。当我们不关心这些接口变体时,我们遵循通用惯例,将这些调用统称为 exec(),但请注意,并没有叫这个名字的系统调用或库函数。

与其他系统的对比: 一些其他操作系统将 fork()exec() 的功能组合到单个操作中——即所谓的 spawn——该操作创建一个新进程然后执行指定的程序。相比之下,UNIX 的方法通常更简单、更优雅。将这两个步骤分开使得 API 更简单(fork() 系统调用不需要参数),并且允许程序在两个步骤之间执行的操作具有极大的灵活性。此外,只进行 fork() 而不接着执行 exec() 通常也很有用。

SUSv3 规定了可选的 posix_spawn() 函数,它结合了 fork()exec() 的效果。此函数以及 SUSv3 规定的几个相关 API 已在 glibc 中为 Linux 实现。SUSv3 规定 posix_spawn() 是为了允许为那些不提供交换设施或内存管理单元(这在许多嵌入式系统中很典型)的硬件架构编写可移植应用程序。在此类架构上,传统的 fork() 难以或无法实现。

协同工作概述: fork(), exit(), wait(), 和 execve() 通常是如何一起使用的。(shell 持续执行一个循环,该循环读取命令、对其进行各种处理,然后 fork 一个子进程来 exec 该命令。)

创建新进程:fork()

fork() 系统调用创建一个新的进程,即子进程,它是调用进程,即父进程的一个几乎完全相同的副本。

理解 fork() 的关键在于认识到,在它完成工作后,存在两个进程,并且在每个进程中,执行都从 fork() 返回的地方继续。两个进程执行相同的程序代码,但它们拥有独立的栈、数据和堆段副本。子进程的栈、数据和堆段最初是父进程内存相应部分的精确副本。在 fork() 之后,每个进程都可以修改其栈、数据和堆段中的变量,而不会影响另一个进程

在程序代码中,我们可以通过 fork() 的返回值来区分这两个进程:

  • 对于父进程fork() 返回新创建子进程的进程ID (PID)。这很有用,因为父进程可能会创建多个子进程,并因此需要(通过 wait() 或其相关函数)跟踪它们。
  • 对于子进程fork() 返回 0
  • 如果无法创建新进程,fork() 返回 -1。失败的可能原因包括:已达到允许该(真实)用户ID创建的进程数的资源限制(RLIMIT_NPROC,在第36.3节描述),或者已达到系统范围内可创建进程数的上限。

必要时,子进程可以使用 getpid() 获取自身的进程ID,使用 getppid() 获取其父进程的进程ID。

调用 fork() 时有时会使用以下惯用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
pid_t childPid; /* 在父进程中用于记录成功 fork() 后的子进程 PID */

switch (childPid = fork()) {
case -1: /* fork() 失败 */
/* 处理错误 */

case 0: /* 成功 fork() 后的子进程进入此处 */
/* 执行子进程特定的操作 */
break; // 或 exit()

default: /* 成功 fork() 后的父进程进入此处 */
/* 执行父进程特定的操作 */
}

重要的是要认识到,在 fork() 之后,无法确定接下来是哪个进程被调度使用CPU。在编写不佳的程序中,这种不确定性可能导致称为竞争条件 (race conditions) 的错误,我们将在第24.4节进一步描述。

代码清单24-1演示了 fork() 的用法。该程序创建一个子进程,修改它在 fork() 期间继承的全局变量和自动变量的副本。在程序中(由父进程执行的代码中)使用 sleep(),是为了让子进程能在父进程之前被调度到CPU上,从而使子进程可以在父进程继续执行之前完成其工作并终止。使用 sleep() 这种方式并不是保证此结果的万无一失的方法;我们将在第24.5节探讨一种更好的方法。 代码清单 24-1: 使用 fork()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "tlpi_hdr.h"

static int idata = 111; /* 分配在数据段 (data segment) */
int
main(int argc, char *argv[])
{
int istack = 222; /* 分配在栈段 (stack segment) */
pid_t childPid;

switch (childPid = fork()) {
case -1:
errExit("fork");
case 0: /* 子进程分支 */
idata *= 3; /* 修改继承的变量副本 */
istack *= 3; /* 修改继承的变量副本 */
break;
default: /* 父进程分支 */
sleep(3); /* 给子进程一个执行的机会 */
break;
}

/* 父进程和子进程都会执行到这里 */
printf("PID=%ld %s idata=%d istack=%d\n", (long) getpid(),
(childPid == 0) ? "(child) " : "(parent)", idata, istack);
exit(EXIT_SUCCESS);
}
当我们运行清单24-1中的程序时,会看到以下输出:
1
2
3
$ ./t_fork
PID=28557 (child) idata=333 istack=666
PID=28556 (parent) idata=111 istack=222
上面的输出证明,子进程在 fork() 时获得了栈段和数据段的自有副本,并且它能够修改这些段中的变量而不影响父进程

进程创建 (Process Creation)

Unix 中的进程创建方式是独特的。大多数操作系统实现一种 spawn 机制来在新地址空间中创建新进程、读入可执行文件并开始执行。Unix 则采用了一种不寻常的方法,将这些步骤分离成两个不同的函数:fork()exec()⁷。第一个函数 fork() 创建一个作为当前任务副本的子进程。它与父进程的区别仅在于其 PID(是唯一的)、其 PPID(父进程的 PID,被设置为原始进程的 PID)以及某些资源和统计信息(如待处理的信号,这些不被继承)。第二个函数 exec() 将一个新的可执行文件加载到地址空间中并开始执行。fork() 后接 exec() 这种组合,类似于大多数操作系统提供的单一函数。

⁷ 这里的 exec() 指的是 exec() 函数家族中的任何成员。内核实现了 execve() 系统调用,基于此实现了 execlp(), execle(), execv(), 和 execvp()

写时复制 (Copy-on-Write)

传统上,在 fork() 时,父进程拥有的所有资源都会被复制,并将副本交给子进程。这种方法很朴素且低效,因为它复制了许多本可以共享的数据。更糟糕的是,如果新进程要立即执行一个新的映像(image),所有这些复制都将被浪费。在 Linux 中,fork() 是通过使用写时复制 (Copy-on-Write 或 COW) 页来实现的。写时复制是一种延迟或完全防止数据复制的技术。父进程和子进程可以共享一份单一的副本,而不是复制整个进程地址空间。

然而,数据会被标记,如果对其进行写入,就会创建一个副本,每个进程都会收到一个独一无二的副本。因此,资源的复制仅发生在它们被写入时;在此之前,它们以只读方式共享。这种技术将地址空间中每一页的复制延迟到实际被写入的时候。如果这些页永远不被写入——例如,如果在 fork() 之后立即调用 exec()——它们就永远不需要被复制。

fork() 产生的唯一开销是复制父进程的页表 (page tables) 以及为子进程创建一个唯一的进程描述符。在常见的场景中(进程在 fork 后立即执行一个新的可执行映像),这种优化避免了大量数据(整个地址空间,很容易达到几十兆字节)的浪费性复制。这是一个重要的优化,因为 Unix 哲学鼓励快速的进程执行。

Forking

Linux 通过 clone() 系统调用来实现 fork()。这个调用接受一系列标志(flags),用以指定父进程和子进程应共享哪些资源(如果有的话)。(关于这些标志的更多信息,请参阅本章后面的“Linux 的线程实现”一节。)fork(), vfork(), 和 __clone() 库调用都使用必要的标志来调用 clone() 系统调用。而 clone() 系统调用又会调用 do_fork()

forking 的大部分工作由 do_fork() 处理,该函数定义在 kernel/fork.c 中。此函数调用 copy_process(),然后启动进程运行。有趣的工作是由 copy_process() 完成的:

  1. 它调用 dup_task_struct(),该函数为新进程创建一个新的内核栈、thread_info 结构和 task_struct。新的值与当前任务的值完全相同。此时,子进程和父进程的描述符是完全相同的。
  2. 然后检查确保新的子进程不会超过当前用户所能拥有的进程数量资源限制。
  3. 子进程需要与父进程区分开来。进程描述符的各种成员被清除或设置为初始值。不被继承的进程描述符成员主要是统计信息。task_struct 中的大部分值保持不变。
  4. 子进程的状态被设置为 TASK_UNINTERRUPTIBLE 以确保它还不会运行。
  5. copy_process() 调用 copy_flags() 来更新 task_structflags 成员。PF_SUPERPRIV 标志(表示任务是否使用了超级用户权限)被清除。PF_FORKNOEXEC 标志(表示进程尚未调用 exec())被设置。
  6. 它调用 alloc_pid() 为新任务分配一个可用的 PID。
  7. 根据传递给 clone() 的标志,copy_process() 要么复制要么共享打开的文件、文件系统信息、信号处理程序、进程地址空间和命名空间。这些资源通常在给定进程的线程之间共享;否则,它们是唯一的,因此在这里被复制。
  8. 最后,copy_process() 进行清理工作,并向调用者返回一个指向新子进程的指针。

回到 do_fork(),如果 copy_process() 成功返回,新的子进程会被唤醒并运行。内核有意地让子进程先运行⁸。在子进程通常只是立即调用 exec() 的常见情况下,这消除了如果父进程先运行并开始写入地址空间可能发生的任何写时复制开销。

⁸ 虽然目标是让子进程先运行,但这目前(指原书写作时)并不能正确运行。

vfork()

vfork() 系统调用与 fork() 效果相同,但不会复制父进程的页表项 (page table entries)。相反,子进程作为父进程地址空间中的唯一线程执行,并且父进程被阻塞,直到子进程调用 exec() 或退出。不允许子进程写入地址空间。在引入这个调用的旧版 3BSD 时代,这是一个受欢迎的优化,因为当时还没有使用写时复制页来实现 fork()。如今,有了写时复制和子进程优先运行的语义,vfork() 的唯一好处就是不复制父进程的页表项

如果 Linux 有一天实现了写时复制页表项,那么 vfork() 将不再有任何好处⁹。由于 vfork() 的语义很棘手(例如,如果 exec() 失败了会发生什么?),理想情况下系统不需要 vfork(),内核也不必实现它。完全可以将 vfork() 实现为一个普通的 fork() —— 这就是 Linux 在 2.2 版本之前所做的。

vfork() 系统调用是通过向 clone() 系统调用传递一个特殊标志来实现的:

  1. copy_process() 中,task_struct 的成员 vfork_done 被设置为 NULL
  2. do_fork() 中,如果给定了特殊标志,vfork_done 会被指向一个特定的地址。
  3. 子首次运行后,父进程——不会立即返回——而是等待子进程通过 vfork_done 指针向其发出信号。
  4. mm_release() 函数中(该函数在任务退出内存地址空间时使用),会检查 vfork_done 是否为 NULL。如果不是,则向父进程发送信号。
  5. 回到 do_fork(),父进程被唤醒并返回。

如果这一切都按计划进行,那么子进程现在在新的地址空间中执行,而父进程则在其原始地址空间中恢复执行。开销是降低了,但实现并不优雅。

⁹ 页表写时复制可作为补丁,已大概率加入主流内核

Linux 的线程实现 (The Linux Implementation of Threads)

线程是一种流行的现代编程抽象。它们在同一程序的共享内存地址空间内提供多个执行线程。它们还可以共享打开的文件和其他资源。线程实现了并发编程,并且在多处理器系统上实现了真正的并行。

Linux 拥有一个独特的线程实现。对 Linux 内核而言,没有“线程”的概念。Linux 将所有线程实现为标准进程。Linux 内核并不提供任何特殊的调度语义或数据结构来表示线程。相反,一个线程仅仅是一个与其他进程共享某些资源的进程。每个线程都有一个唯一的 task_struct,并且在内核看来就是一个普通的进程——线程只是恰巧与其他进程共享了资源(例如地址空间)。

这种线程实现方法与 Microsoft Windows 或 Sun Solaris 等操作系统形成了巨大对比,这些操作系统在内核中提供了对线程的明确支持(有时称线程为轻量级进程 (Lightweight Processes))。“轻量级进程”这个名字概括了 Linux 与其他系统在哲学上的差异。对这些其他操作系统而言,线程是一种抽象,旨在提供比笨重进程更轻量、更快速的执行单元。而对 Linux 而言,线程仅仅是进程之间共享资源的一种方式(而进程本身已经相当轻量)¹⁰。

例如,假设一个包含四个线程的进程。在具有显式线程支持的系统中,可能会存在一个进程描述符,该描述符又指向四个不同的线程。进程描述符描述共享资源,如地址空间或打开的文件。线程则描述它们独自拥有的资源。相反,在 Linux 中,简单地存在四个进程,因而有四个普通的 task_struct 结构。这四个进程被设置为共享某些资源。结果非常优雅。

¹⁰ 例如,可以对比 benchmark 一下 Linux 的进程创建时间与其他操作系统的进程(甚至线程!)创建时间。结果对 Linux 有利。

创建线程 (Creating Threads)

线程的创建方式与普通任务相同,区别在于需要向 clone() 系统调用传递一些标志(flags),这些标志指定了要共享的特定资源:

1
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
上述代码产生的行为与普通的 fork() 相同,但地址空间、文件系统资源、文件描述符和信号处理程序是共享的。换句话说,新任务和其父进程就是通常所说的线程。相比之下,一个普通的 fork() 可以实现为:
1
clone(SIGCHLD, 0);
vfork() 可以实现为:
1
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
提供给 clone() 的标志有助于指定新进程的行为,并详细说明父进程和子进程将共享哪些资源。表 3.1 列出了在 <linux/sched.h> 中定义的 clone 标志及其作用。

(表 3.1 Clone 标志及含义) | 标志 (Flag) | 含义 (Meaning) | | :----------------------- | :----------------------------------------------------------------------------- | | CLONE_FILES | 父子进程共享打开的文件。 | | CLONE_FS | 父子进程共享文件系统信息(如根目录、当前工作目录)。 | | CLONE_IDLETASK | 将 PID 设置为零(仅由空闲任务使用)。 | | CLONE_NEWNS | 为子进程创建新的命名空间 (namespace)。 | | CLONE_PARENT | 子进程与父进程拥有相同的父进程(即调用者的父进程)。 | | CLONE_PTRACE | 继续跟踪子进程。 | | CLONE_SETTID | 将 TID(线程 ID)写回用户空间。 | | CLONE_SETTLS | 为子进程创建新的 TLS(线程本地存储)。 | | CLONE_SIGHAND | 父子进程共享信号处理程序和阻塞的信号掩码。 | | CLONE_SYSVSEM | 父子进程共享 System V SEM_UNDO 语义。 | | CLONE_THREAD | 父子进程位于同一个线程组中。这是将新进程标识为线程而非普通进程的关键标志。 | | CLONE_VFORK | 使用了 vfork(),父进程将睡眠直到子进程唤醒它。 | | CLONE_UNTRACED | 不允许跟踪进程强制对子进程使用 CLONE_PTRACE。 | | CLONE_STOP | 以 TASK_STOPPED 状态启动进程。 | | CLONE_CHILD_CLEARTID | 在子进程中清除 TID。 | | CLONE_CHILD_SETTID | 在子进程中设置 TID。 | | CLONE_PARENT_SETTID | 在父进程中设置 TID。 | | CLONE_VM | 父子进程共享地址空间。这是创建线程的关键标志之一。 |

内核线程 (Kernel Threads)

内核在后台执行某些操作通常很有用。内核通过内核线程 (kernel threads) 来实现这一点——内核线程是仅存在于内核空间的标准进程。内核线程与普通进程的显著区别在于内核线程没有地址空间(它们的 mm 指针指向它们的地址空间,为 NULL)。它们只在内核空间中运行,不会上下文切换到用户空间。然而,内核线程与普通进程一样,是可调度和可抢占的。

Linux 将一些任务委托给内核线程,最著名的是 flush 任务和 ksoftirqd 任务。您可以在 Linux 系统上运行 ps -ef 命令来查看内核线程。数量很多!

-ef 是 every full(info) 的简写。在所有进程中,CMD 带 [],TTY 为 ?,PPID 为 2 或 0, UID是0或者root,是内核线程的特征

内核线程在系统启动时由其他内核线程创建。实际上,内核线程只能由另一个内核线程创建。内核通过从 kthreadd 内核进程 fork 出所有新的内核线程来自动处理此事。在 <linux/kthread.h> 中声明的,用于从现有内核线程生成新内核线程的接口是:

1
2
3
struct task_struct *kthread_create(int (*threadfn)(void *data),
void *data,
const char namefmt[], ...);
新任务由 kthread 内核进程通过 clone() 系统调用创建。新进程将运行 threadfn 函数,该函数接收 data 参数。进程将被命名为 namefmt,该参数接受在可变参数列表中的 printf 风格格式化参数。进程创建时处于不可运行状态 (unrunnable state);只有在通过 wake_up_process() 显式唤醒后,它才会开始运行。可以使用单个函数 kthread_run() 来创建并使进程可运行:
1
2
3
struct task_struct *kthread_run(int (*threadfn)(void *data),
void *data,
const char namefmt[], ...);
这个例程(作为宏实现)简单地调用了 kthread_create()wake_up_process()
1
2
3
4
5
6
7
8
9
#define kthread_run(threadfn, data, namefmt, ...)                     \
({ \
struct task_struct *k; \
\
k = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
if (!IS_ERR(k)) \
wake_up_process(k); \
k; \
})
启动后,内核线程会继续存在,直到它调用 do_exit(),或者内核的另一部分调用 kthread_stop()(传入由 kthread_create() 返回的 task_struct 结构体的地址):
1
int kthread_stop(struct task_struct *k);

进程终止 (Process Termination)

这很遗憾,但进程最终都会消亡。当一个进程终止时,内核会释放该进程所拥有的资源,并通知其父进程关于子进程消亡的消息。

通常,进程销毁是自我诱导 (self-induced) 的。当进程调用 exit() 系统调用时就会发生,这可以是在它准备终止时显式调用,也可以是在从任何程序的 main 子程序返回时隐式调用(即 C 编译器会在 main() 返回后放置一个对 exit() 的调用)。进程也可能非自愿地 (involuntarily) 终止。这发生在进程收到一个它无法处理或忽略的信号或异常时。

无论进程如何终止,大部分工作都由 do_exit() 处理(定义在 kernel/exit.c 中),它完成一系列收尾工作:

  1. 它在 task_structflags 成员中设置 PF_EXITING 标志。
  2. 它调用 del_timer_sync() 来移除任何内核定时器。确保返回时没有定时器在排队,也没有定时器处理程序在运行。
  3. 如果启用了 BSD 进程记账 (process accounting),do_exit() 会调用 acct_update_integrals() 来写出记账信息。
  4. 它调用 exit_mm() 来释放该进程持有的 mm_struct。如果没有其他进程在使用这个地址空间(即地址空间未被共享),内核就会销毁它。
  5. 它调用 exit_sem()。如果进程正在排队等待一个 IPC 信号量,它在这里被移出队列。
  6. 然后它调用 exit_files()exit_fs() 来分别递减与文件描述符和文件系统数据相关的对象的引用计数。如果某个引用计数降为零,说明该对象不再被任何进程使用,随即被销毁。
  7. 它将任务的退出码(存储在 task_structexit_code 成员中)设置为 exit() 提供的代码或任何强制终止它的内核机制所提供的代码。退出码存储在这里,供父进程选择性检索。
  8. 它调用 exit_notify() 来向任务的父进程发送信号,将该任务的任何子进程重新设定父进程 (reparent) 给其线程组中的另一个线程或 init 进程,并将任务的退出状态(存储在 task_struct 结构的 exit_state 中)设置为 EXIT_ZOMBIE
  9. do_exit() 调用 schedule() 来切换到新进程(参见第 4 章)。因为该进程现在已不可调度,这是该任务将执行的最后代码。do_exit() 永不返回。

至此,与任务关联的所有对象(假设该任务是唯一使用者)都已释放。该任务不可运行(并且不再有地址空间可供运行),并处于 EXIT_ZOMBIE(僵尸)退出状态。它占用的唯一内存是它的内核栈、thread_info 结构和 task_struct 结构。该任务存在的唯一目的是向其父进程提供信息。在父进程检索到信息,或通知内核它不感兴趣之后,进程持有的剩余内存将被释放并返回给系统使用。

移除进程描述符 (Removing the Process Descriptor)

do_exit() 完成后,已终止进程的进程描述符仍然存在,但该进程已成为僵尸 (zombie) 且无法运行。如前所述,这使得系统能够在子进程终止后获取其信息。因此,清理进程之后和移除其进程描述符是分开的两个步骤。在父进程获取了已终止子进程的信息,或向内核表示不关心之后,子进程的 task_struct 才会被释放。

wait() 函数族是通过一个单一(且复杂)的系统调用 wait4() 实现的。标准行为是挂起调用任务的执行,直到它的一个子进程退出,此时函数返回退出子进程的 PID。此外,还提供一个指针给该函数,该指针在返回时持有已终止子进程的退出码。

当最终要释放进程描述符时,会调用 release_task()。它执行以下操作: 1. 它调用 __exit_signal(),后者又调用 __unhash_process(),继而调用 detach_pid() 来将进程从 pidhash 中移除,并从任务列表中移除该进程。 2. __exit_signal() 释放这个已死亡进程使用的任何剩余资源,并完成统计和簿记工作。 3. 如果该任务是线程组的最后一个成员,并且领导者 (leader) 是僵尸进程,那么 release_task() 会通知僵尸领导者的父进程。 4. release_task() 调用 put_task_struct() 来释放包含进程内核栈和 thread_info 结构的内存页,并释放包含 task_struct 的 slab 缓存。

至此,进程描述符以及仅属于该进程的所有资源都已被释放。

无父任务的困境 (The Dilemma of the Parentless Task)

如果一个父进程在其子进程之前退出,必须存在某种机制来将任何子任务重新设定父进程 (reparent) 给一个新进程,否则,没有父进程的已终止进程将永远保持僵尸状态,浪费系统内存。解决方案是在退出时将一个任务的子进程重新设定父进程给当前线程组中的另一个进程,如果失败,则设定给 init 进程。do_exit() 调用 exit_notify(),后者调用 forget_original_parent(),该函数又调用 find_new_reaper() 来执行重新设定父进程的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static struct task_struct *find_new_reaper(struct task_struct *father)
{
struct pid_namespace *pid_ns = task_active_pid_ns(father);
struct task_struct *thread;

thread = father;
while_each_thread(father, thread) {
if (thread->flags & PF_EXITING)
continue;
if (unlikely(pid_ns->child_reaper == father))
pid_ns->child_reaper = thread;
return thread;
}
...
return pid_ns->child_reaper; // 通常是 init 进程
}

(代码已简化,核心逻辑是查找同一线程组内未退出的线程,或最终返回 init 进程)

这段代码尝试在进程的线程组中查找并返回另一个任务。如果线程组中没有其他任务,它就查找并返回 init 进程。找到合适的新父进程后,需要找到每个子进程并将其重新设定父进程给这个新的“收割者”(reaper):

1
2
3
4
5
6
7
8
9
reaper = find_new_reaper(father);
list_for_each_entry_safe(p, n, &father->children, sibling) {
p->real_parent = reaper;
if (p->parent == father) {
BUG_ON(p->ptrace);
p->parent = p->real_parent;
}
reparent_thread(p, father);
}

然后调用 ptrace_exit_finish() 做同样的事情,但是针对一个被 ptrace 跟踪 (ptraced) 的子进程列表。

同时拥有一个子进程列表 (child list) 和一个被跟踪子进程列表 (ptraced list) 的基本原理很有趣;这是 2.6 内核的一个新特性。当一个任务被 ptrace 跟踪时,它被临时重新设定父进程给调试进程。然而,当该任务的原始父进程退出时,它必须与其兄弟姐妹一起被重新设定父进程。在之前的内核中,这会导致需要循环遍历系统中的每个进程来查找子进程。解决方案就是简单地维护一个进程被 ptrace 跟踪的子进程的独立列表——将查找子进程的范围从系统中的每个进程缩小到仅仅两个相对较小的列表。

随着进程成功被重新设定父进程,就不再存在 stray zombie processes( stray zombie processes)的风险。init 进程会例行地对其子进程调用 wait(),清理分配给它的任何僵尸进程。

进程

一个进程是一个正在执行中的程序(存储在某种介质上的目标代码)。然而,进程不仅仅只是执行的程序代码(在 Unix 中常称为文本段)。它们还包括一系列资源,例如打开的文件和待处理的信号、内核内部数据、处理器状态、包含一个或多个内存映射的内存地址空间、一个或多个执行线程,以及包含全局变量的数据段。实际上,进程是运行程序代码的动态产物。内核需要高效且透明地管理所有这些细节。

执行线程(通常简称为线程)是进程内部的活动对象。每个线程包含一个唯一的程序计数器、进程栈和一组处理器寄存器。内核调度的是单个线程,而不是进程。在传统的 Unix 系统中,每个进程由一个线程组成。然而,在现代系统中,由多个线程组成的多线程程序非常普遍。正如您后面将看到的,Linux 对线程的实现很独特:它并不区分线程和进程。对 Linux 而言,线程只是一种特殊的进程。

在现代操作系统上,进程提供了两种虚拟化:虚拟化处理器和虚拟内存。虚拟化处理器给进程一种假象,让它以为自己在独享系统,尽管处理器可能正与数百个其他进程共享。虚拟内存让进程可以分配和管理内存,就好像它独占了系统中的所有内存一样。

有趣的是,请注意线程共享虚拟内存抽象,而每个线程则拥有自己的虚拟化处理器。

程序本身并不是一个进程;进程是活动中的程序及其相关资源。实际上,可以存在两个或更多个执行同一程序的进程。事实上,也可以存在两个或更多共享各种资源(如打开的文件或地址空间)的进程。

毫不奇怪,进程在其创建时开始它的生命周期。在 Linux 中,这是通过 fork() 系统调用来完成的,该系统调用通过复制一个现有进程来创建一个新进程。调用 fork() 的进程是父进程,而新进程是子进程。父进程恢复执行,而子进程则在相同的地方开始执行:即从 fork() 调用返回的地方。fork() 系统调用从内核返回两次:一次在父进程中,一次在新生的子进程中。

通常,在 fork() 之后立即需要执行一个新的、不同的程序。exec() 系列函数调用会创建一个新的地址空间并将一个新的程序加载到其中。在当代的 Linux 内核中,fork() 实际上是通过 clone() 系统调用实现的,这将在后面讨论。

最后,程序通过 exit() 系统调用退出。此函数终止进程并释放其所有资源。父进程可以通过 wait4()¹ 系统调用查询已终止子进程的状态,该系统调用使一个进程能够等待特定进程的终止。当一个进程退出时,它会被置于一种特殊的僵尸 (zombie) 状态,该状态表示已终止的进程,直到父进程调用 wait()waitpid()

¹ 内核实现了 wait4() 系统调用。Linux 系统通过 C 库通常提供 wait(), waitpid(), wait3(), 和 wait4() 函数。所有这些函数都返回关于已终止进程的状态信息,尽管语义略有不同。

注意 进程的另一个名称是任务 (task)。Linux 内核在内部将进程称为任务。在本书中,我会交替使用这两个术语,尽管当我说“任务”时,通常是从内核的角度来指代一个进程。


进程描述符和任务结构 (Task Structure)

内核在一个称为任务列表 (task list)² 的环形双向链表中存储进程列表。任务列表中的每个元素都是一个类型为 struct task_struct 的进程描述符,该结构定义在 <linux/sched.h> 中。进程描述符包含了一个特定进程的所有信息。

task_struct 是一个相对较大的数据结构,在 32 位机器上约为 1.7 千字节。然而,考虑到该结构包含了内核所拥有和需要的关于一个进程的所有信息,这个大小已经相当小了。进程描述符包含了描述正在执行的程序的数据——打开的文件、进程的地址空间、待处理的信号、进程的状态等等。

² 一些操作系统设计文献称此列表为任务数组 (task array)。由于 Linux 的实现是链表而非静态数组,因此在 Linux 中它被称为任务列表 (task list)。


分配进程描述符

task_struct 结构通过 slab 分配器 (slab allocator) 进行分配,以提供对象重用和缓存着色。在 2.6 系列内核之前,struct task_struct 存储在每个进程的内核栈的末端。这使得像 x86 这样寄存器数量较少的体系结构,可以通过栈指针计算进程描述符的位置,而无需使用额外的寄存器来存储该位置。由于现在进程描述符是通过 slab 分配器动态创建的,因此引入了一个新的结构 struct thread_info,该结构再次存在于栈的底部(对于向下增长的栈)或栈的顶部(对于向上增长的栈)³。参见图 3.2。

³ 创建 struct thread_info 并不仅仅是因为寄存器受限的体系结构。新结构也使得在汇编代码中计算其值的偏移量变得相当容易。

thread_info 结构在 x86 上的 <asm/thread_info.h> 中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct thread_info {
struct task_struct *task;
struct exec_domain *exec_domain;
__u32 flags;
__u32 status;
__u32 cpu;
int preempt_count;
mm_segment_t addr_limit;
struct restart_block restart_block;
void *sysenter_return;
int uaccess_err;
};

(图 3.2 示意图说明) 进程内核栈
栈起始处(向低地址方向增长)
- 最高内存地址
- 栈指针
- struct thread_struct (进程的 struct task_struct)
- 最低内存地址, current_thread_info()
thread_info 包含一个指向进程描述符的指针

栈的增长方向:x86 体系的栈是向下(向低地址方向)增长的。 - 栈底 (Stack Base):为了充分利用这块内存,栈被初始化为从这块分配区的最高地址 (0x10000) 开始。这个初始的栈指针位置就是栈底。它是栈的“起点”,但却是分配内存块的“末尾”。 - 栈顶 (Stack Top):随着函数调用、压入参数等操作,%esp 寄存器的值会不断减小(向 0xE000 方向移动)。%esp 当前指向的位置称为栈顶。它是栈的“当前操作端”,向低地址方向延伸。

每个任务的 thread_info 结构都分配在其栈的末端。该结构的 task 元素是一个指向任务实际 task_struct 的指针。


存储进程描述符

系统通过一个唯一的进程标识值 (process identification value)PID 来识别进程。PID 是一个由不透明类型⁴ pid_t 表示的数值,通常是一个 int。然而,为了与早期的 Unix 和 Linux 版本向后兼容,默认最大值仅为 32,768(一个 short int 的值),尽管可以选择将该值增加到高达 400 万(这由 <linux/threads.h> 控制)。内核在每个进程描述符的 pid 字段中存储此值。

⁴ 不透明类型 (opaque type):指其内部细节被隐藏,只通过特定接口访问的数据类型。

这个最大值很重要,因为它本质上是系统上可能同时存在的最大进程数。虽然 32,768 对于桌面系统可能足够,但大型服务器可能需要多得多的进程。此外,该值越低,数值回绕(wrap around)就越快,这会破坏“数值大的进程比数值小的进程更晚运行”这一有用概念。如果系统愿意打破与旧应用程序的兼容性,管理员可以通过 /proc/sys/kernel/pid_max 来增加最大值。

在内核内部,通常直接通过指向其 task_struct 结构的指针来引用任务。事实上,大多数处理进程的内核代码都直接使用 struct task_struct。因此,能够快速查找当前正在执行的任务的进程描述符是非常有用的,这是通过 current 宏来完成的。这个宏必须由每个体系结构独立实现。一些体系结构将当前运行进程的 task_struct 结构的指针保存在一个寄存器中,以实现高效访问。其他体系结构,如 x86(几乎没有多余的寄存器可用),则利用 struct thread_info 存储在内核栈上这一条件,来计算 thread_info 的位置,进而找到 task_struct

在 x86 上,current 是通过滤除栈指针的最低 13 位来获得 thread_info 结构计算的。这是由 current_thread_info() 函数完成的。汇编代码如下:

1
2
movl $-8192, %eax
andl %esp, %eax
这里假设栈大小是 8KB。当启用 4KB 栈时,使用 4096 代替 8192。

最后,current 解引用 thread_infotask 成员以返回 task_struct

1
current_thread_info()->task;

将此方法与 PowerPC(IBM 的现代基于 RISC 的微处理器)采用的方法对比,后者将当前的 task_struct 存储在一个寄存器中。因此,在 PPC 上 current 仅仅返回存储在寄存器 r2 中的值。PPC 可以采用这种方法是因为,与 x86 不同,它有大量的寄存器。由于访问进程描述符是一项常见且重要的工作,PPC 内核开发人员认为使用一个寄存器是值得的。

进程状态 (Process State)

进程描述符中的 state 字段描述了进程的当前状况(参见图 3.3)。系统中的每个进程都确切地处于五种不同状态中的一种。该值由以下五个标志之一表示:

  • TASK_RUNNING (运行):进程是可运行的;它要么正在执行,要么在运行队列(runqueue)中等待运行(运行队列将在第 4 章讨论)。这是在用户空间执行的进程唯一可能的状态;它也应用于正在内核空间中主动运行的进程。
  • TASK_INTERRUPTIBLE (可中断睡眠):进程正在睡眠(即被阻塞),等待某个条件的发生。当此条件满足时,内核会将进程的状态设置为 TASK_RUNNING。如果进程接收到信号,它也会被提前唤醒并变为可运行状态。
  • TASK_UNINTERRUPTIBLE (不可中断睡眠):此状态与 TASK_INTERRUPTIBLE 类似,区别在于即使接收到信号,它也不会被唤醒并变为可运行状态。此状态用于进程必须不受中断地等待,或者预期事件会很快发生的场合。由于处在此状态的任务不响应信号,因此 TASK_UNINTERRUPTIBLE 的使用频率低于 TASK_INTERRUPTIBLE⁵。
  • **__TASK_TRACED (被跟踪)**:进程正被另一个进程(例如调试器通过 ptrace)所跟踪。
  • **__TASK_STOPPED (停止)**:进程执行已停止;任务既不在运行,也没有资格运行。如果任务收到 SIGSTOPSIGTSTPSIGTTINSIGTTOU 信号,或者在被调试时收到任何信号,就会发生这种状态。

⁵ 这就是为什么在 ps(1) 命令中会出现那些状态为 D (即 TASK_UNINTERRUPTIBLE) 的、令人头疼的无法杀死的进程。因为该任务不响应信号,你无法向它发送 SIGKILL 信号。此外,即使你能终止该任务,这样做通常也不明智,因为该任务可能正在进行一项重要操作(并可能持有信号量)。

操作当前进程状态 (Manipulating the Current Process State)

内核代码经常需要更改进程的状态。首选机制是使用:

1
set_task_state(task, state);        /* 将任务 'task' 的状态设置为 'state' */
此函数将给定的任务设置为给定的状态。在适用的情况下,它还提供一个内存屏障 (memory barrier) 以强制在其他处理器上的执行顺序(这仅在 SMP 系统上需要)。否则,它等价于:
1
task->state = state;
方法 set_current_state(state) 等同于 set_task_state(current, state)。请参阅 <linux/sched.h> 以了解这些及相关函数的实现。


进程上下文 (Process Context)

进程最重要的部分之一是正在执行的程序代码。这些代码从可执行文件中读入,并在程序的地址空间内执行。正常的程序执行发生在用户空间 (user-space)。当程序执行系统调用或触发异常时,它就进入了内核空间 (kernel-space)。此时,内核被称为“代表进程执行”并处于进程上下文 (process context) 中。在进程上下文中,current 宏是有效的⁶。退出内核后,进程会在用户空间恢复执行,除非在此期间有更高优先级的进程变为可运行状态(这种情况下将调用调度器来选择更高优先级的进程)。

⁶ 除了进程上下文,还有中断上下文 (interrupt context)。在中断上下文中,系统并非代表某个进程运行,而是在执行一个中断处理程序。没有进程与中断处理程序相关联。

系统调用和异常处理程序是进入内核的明确定义的接口。进程只能通过这些接口之一开始在内核空间中执行——所有对内核的访问都是通过这些接口进行的。


进程家族树 (The Process Family Tree)

在 Unix 系统(Linux 也不例外)的进程之间存在着一个清晰的层次结构。所有进程都是 init 进程(其 PID 为 1)的后代。内核在启动过程的最后步骤中启动 init。然后,init 进程读取系统初始化脚本并执行更多程序,最终完成启动过程。

系统中的每个进程都有且只有一个父进程 (parent)。同样,每个进程有零个或多个子进程 (children)。同一父进程的所有直接子进程称为兄弟进程 (siblings)。进程之间的关系存储在进程描述符中。每个 task_struct 都有一个指向父进程 task_struct 的指针(名为 parent),以及一个子进程的链表(名为 children)。

因此,给定当前进程,可以通过以下代码获取其父进程的描述符:

1
struct task_struct *my_parent = current->parent;
类似地,可以这样遍历一个进程的所有子进程:
1
2
3
4
5
6
7
struct task_struct *task;
struct list_head *list;

list_for_each(list, &current->children) {
task = list_entry(list, struct task_struct, sibling);
/* task 现在指向 current 的某个子进程 */
}

init 任务的进程描述符是静态分配的,名为 init_task。所有进程间关系的一个很好例证是下面这段代码总能执行成功:

1
2
3
4
struct task_struct *task;
for (task = current; task != &init_task; task = task->parent)
;
/* 循环结束后,task 指向了 init */
实际上,你可以从系统中的任何一个进程出发,循着进程层次结构到达任何其他进程。

然而,通常我们更希望简单地遍历系统中的所有进程。这很容易,因为任务列表是一个循环双向链表 (circular doubly linked list)。给定任何一个有效任务,要获取链表中的下一个任务,可以使用:

1
list_entry(task->tasks.next, struct task_struct, tasks)
获取上一个任务的方式相同:
1
list_entry(task->tasks.prev, struct task_struct, tasks)
这两个操作分别由宏 next_task(task)prev_task(task) 提供。最后,提供了宏 for_each_process(task) 用于遍历整个任务列表。每次迭代时,task 指向列表中的下一个任务:
1
2
3
4
5
struct task_struct *task;
for_each_process(task) {
/* 这段代码无意义地打印每个任务的名称和 PID */
printk("%s[%d]\n", task->comm, task->pid);
}
注意 在拥有大量进程的系统中,遍历每个任务的开销很大;代码在这样做之前应该有充分的理由(并且没有其他替代方案)。

可加载的内核模块

练习 1

准备好实验报告


练习 2: 编译内核模块

  1. 步骤:
    1
    2
    3
    4
    5
    6
    7
    # 在 Linux Lab 集群上
    mkdir -p /project/scratch01/compile/your-username/modules
    cd /project/scratch01/compile/your-username/modules
    # 保存 simple_module.c 和 Makefile (内容: obj-m := simple_module.o)
    module add arm-rpi
    export LINUX_SOURCE=/path/to/your/linux_source/linux # 设置内核源码路径
    make -C $LINUX_SOURCE ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- M=$PWD modules
  2. 答案: 提交 make 命令的完整输出。

练习 3: 加载模块与系统日志

  1. 步骤 (在树莓派上):
    1
    2
    3
    4
    5
    mkdir ~/modules
    # 使用 sftp 将 simple_module.ko 传输到此目录
    sudo dmesg --clear
    sudo insmod ~/modules/simple_module.ko
    dmesg # 查看日志
  2. 答案: 提交 dmesg 中显示的模块加载信息。

练习 4: 验证模块列表与卸载

  1. 步骤:
    1
    2
    3
    4
    lsmod # 确认 simple_module 在列表中
    sudo rmmod simple_module
    lsmod # 确认已移除
    dmesg # 查看卸载信息
  2. 答案: 提交 lsmod 的输出(证明已卸载)和 dmesg 中显示的模块卸载信息。

练习 5: 访问内核变量 jiffies

  1. 步骤:
    • 复制 simple_module.cjiffies_module.c
    • 修改其 init 和 exit 函数,使用 printk 打印 jiffies 变量(类型 extern unsigned long volatile jiffies;)。
    • 更新 Makefile: obj-m += jiffies_module.o
    • 重新编译并传输 jiffies_module.ko 到树莓派。
    • 加载并卸载模块,观察 dmesg
  2. 答案:
    • 提交 dmesg 中显示加载和卸载时 jiffies 值的日志消息。
    • 计算并说明两条消息之间发生的滴答数(tick count)。

需提交的内容

请提交一个包含以下内容的压缩包或文档: 1. 答案文件: 包含上述所有练习的答案。 2. 源代码文件: 你新创建的 jiffies_module.c 文件。


可选拓展练习

练习 6: 模块初始化返回值实验

  • 任务: 修改 init 函数,使其分别返回正数和负数(错误码,参见 /include/uapi/asm-generic/errno-base.h)。
  • 提示: 描述加载模块时发生的情况及其在系统日志中的表现。

练习 7: 探查导出的内核符号

  • 任务: 查看 /proc/kallsyms 文件(例如 cat /proc/kallsyms),了解内核符号表。查找带有 __kstrtab___ksymtab_ 前缀的符号(这些是可供模块使用的导出符号)。
  • 提示: 内核符号是 Linux 内核代码中定义的函数、变量、数据结构等的名称标签。它们代表了在内核地址空间中的一个特定内存地址。 可以将内核符号理解为内核的“公共接口”或“入口点”。主要有两种类型:
  • 导出的符号:这些是内核明确声明为可以被外部模块使用的符号。例如,printk(内核的 printf)、kmalloc(内核的内存分配函数)等。模块通过使用 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 宏来导出它们的符号,以便其他模块可以使用。
  • 非导出的符号:这些是内核内部的静态函数或变量,只在它们被定义的文件或内核的特定部分中使用。它们对于内核模块是不可见的,主要用于内核自身的组织
  • EXPORT_SYMBOL(printk) 在编译后创建了两个内部符号:__ksymtab_printk 和 __kstrtab_printk。
    • __ksymtab_printk 是一个结构体,包含了 printk 的地址和指向 __kstrtab_printk 的指针。
    • __kstrtab_printk 是一个字符串,存储着 printk 的名字。
  • 内核使用这个结构来高效地通过名字查找函数的地址

练习 8: 实现用户态读取 CPU 周期计数器 (CCNT) 的模块

  • 任务 (如果之前在系统调用工作室未完成):
    • 将提供的驱动文件放入你的内核源码树的 arch/arm/include/asm 目录。
    • 下载 enable_ccnt.c 内核模块代码到你的模块目录。
    • 编译、传输并加载此模块 (insmod enable_ccnt.ko)。
    • 使用 dmesg | tail 验证加载成功。
  • 任务 (主要部分):
    • 在树莓派上创建一个用户态程序。
    • 该程序应 #include 你获取的驱动头文件。
    • 调用 unsigned long long pmccntr_get(void) 函数两次。
  • 答案:
    • 说明运行一次 pmccntr_get() 函数大约需要多少 CPU 周期(计算两次调用的差值)。
    • 如果你在系统调用工作室完成过类似的练习,请对比直接调用此函数与通过系统调用获取周期计数所需的周期数。

文章内容来自 LWN.net

文章摘要 (Summary)

这篇发表于 2013 年 2 月的文章讨论了 Linux 内核中 IDR 子系统 API 的一次重大简化改革,由开发者 Tejun Heo 主导。IDR 机制用于高效分配和管理整数 ID(例如设备名、POSIX 定时器 ID 等),其旧 API 因其复杂性和潜在的竞争条件而闻名。

核心问题:旧 API 的缺陷 1. 两步分配:需要先调用 idr_pre_get() 预分配内存(可休眠),再调用 idr_get_new() 获取 ID(可原子上下文)。 2. 必须重试循环idr_get_new() 可能因预分配内存被其他 CPU 耗尽而失败(返回 -EAGAIN),要求调用者编写冗长且易错的循环重试代码。 3. 全局资源竞争idr_pre_get() 预分配的内存是全局的,多个 CPU 竞争时,后执行的 idr_get_new() 可能因资源不足而失败,迫使代码退出原子上下文进行重试,这条路径往往缺乏测试。

解决方案:新 API 的改进 Tejun Heo 引入了三个新函数来简化流程: 1. idr_preload(gfp_t gfp_mask): 为当前 CPU 预分配内存,并禁用抢占以防止预分配的内存被偷。 2. idr_alloc(...): 单次调用即可完成 ID 分配和关联。它接受 ID 范围参数,并仅在真正需要时(未预分配或预分配不足)才使用 gfp_mask 分配内存。它只会在内存分配彻底失败时报错,消除了对 -EAGAIN 的重试循环需求。 3. idr_preload_end(): 在 idr_alloc 后调用,重新启用抢占

关键优势: * 更简单:消除了遍布内核的百余处重复、易错的样板代码。 * 更可靠:通过每 CPU 预分配和禁用抢占,基本消除了在原子上下文中因资源竞争而失败的需要。 * 更灵活idr_alloc 可以指定 ID 范围,并且如果能在进程上下文调用,甚至可以完全省略 idr_preload/idr_preload_end

社区反应: 尽管大部分开发者接受了这个改动(给出了 Acked-by),但 Eric Biederman 表达了强烈反对,认为新 API 的 idr_preload 像是一种难以理解的“魔法”。然而,文章作者(Jonathan Corbet)预测,新 API 带来的巨大简化优势将使其最终被内核社区接受

新旧 API 对比总结

特性 | 旧 API (2013 年前) | 新 API (Tejun Heo 提议) |
:--- | :--- | :--- |
核心函数 | idr_pre_get(), idr_get_new() | idr_preload(), idr_alloc(), idr_preload_end() |
调用模式 | 两步过程,必须配合重试循环 | 单次调用 (idr_alloc),无需循环 |
预分配内存 | 全局共享,易被其他 CPU 消耗 | 每 CPU 独享,配合禁用抢占,不会被偷 |
错误处理 | 可能返回 -EAGAIN,要求调用者重试 | 仅在所有内存分配都失败时才报错 |
原子上下文 | 支持,但重试时必须退出原子上下文 | 更好支持,通过 preload/preload_end 保障 |
代码复杂度 | 高,需要大量重复的样板代码 | 低,调用逻辑非常简洁 |

结论

这篇文章记录了一个经典的内核优化案例:通过巧妙的设计(利用每 CPU 数据和禁用抢占)将一个复杂、易错、充满竞争条件的旧接口,重构为一个简洁、可靠、高效的新接口。尽管存在一些争议,但简化并提升广泛使用的底层 API 的价值是极其巨大的,这很可能是新方案最终被采纳的原因。这正是 Linux 内核持续演进的一个缩影。

Linux 内核模块开发简介

设备类型分类

类型 缩写 访问方式 典型设备 特殊文件
块设备 blkdevs 按块随机访问(支持寻址) 硬盘/SSD/光驱 /dev/sda1
字符设备 cdevs 字节流顺序访问 键盘/打印机/伪设备 /dev/ttyS0
网络设备 - 套接字API(破坏"一切皆文件"原则) 网卡/无线适配器 无设备节点
混杂设备 miscdevs 字符设备简化形式 简单专用设备 /dev/random

伪设备示例

1
2
3
4
5
/dev/random   # 内核随机数生成器
/dev/null # 空设备(丢弃所有写入)
/dev/zero # 零设备(提供无限\0字节)
/dev/full # 满设备(写入总返回ENOSPC错误)
/dev/mem # 物理内存访问设备


内核模块开发

Hello World 模块示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

/* 模块加载时执行 */
static int __init hello_init(void)
{
printk(KERN_INFO "I bear a charmed life.\n");
return 0; // 返回0表示成功
}

/* 模块卸载时执行 */
static void __exit hello_exit(void)
{
printk(KERN_INFO "Out, out, brief candle!\n");
}

/* 注册入口/出口函数 */
module_init(hello_init); // 不是函数调用,而是宏定义
module_exit(hello_exit);

/* 模块元信息 */
MODULE_LICENSE("GPL"); // 必须声明许可证
MODULE_AUTHOR("Shakespeare"); // 作者信息
MODULE_DESCRIPTION("Hello World Module"); // 模块描述

关键机制解析

  1. 入口函数
    • 形式:int init_func(void)
    • 职责:注册资源/初始化硬件/分配数据结构
    • 返回值:0=成功,非0=失败
  2. 出口函数
    • 形式:void exit_func(void)
    • 职责:释放资源/复位硬件/清理状态
  3. 许可证声明
    1
    MODULE_LICENSE("GPL");  // 合法选项:GPL/MIT/BSD等
    • 非GPL模块会导致内核标记为"tainted"
    • 无法调用GPL-only符号

模块构建指南

集成到内核源码树(推荐)

  1. 选择路径
    • 字符设备 → drivers/char/
    • 块设备 → drivers/block/
    • USB设备 → drivers/usb/
  2. 修改Makefile
    1
    2
    3
    4
    5
    6
    7
    # drivers/char/Makefile 添加
    obj-$(CONFIG_FISHING_POLE) += fishing/

    # drivers/char/fishing/Makefile 内容
    obj-$(CONFIG_FISHING_POLE) += fishing.o
    fishing-objs := main.o line.o # 多文件模块
    EXTRA_CFLAGS += -DTITANIUM_POLE # 自定义编译标志

外部独立构建

  1. Makefile示例

    1
    2
    3
    4
    5
    obj-m := fishing.o
    fishing-objs := fishing-main.o fishing-line.o

    all:
    make -C /path/to/kernel/source M=$(PWD) modules

  2. 构建命令

    1
    2
    # 在模块目录执行
    make -C /lib/modules/$(uname -r)/build M=$PWD modules
    ### 模块管理

模块安装路径规范

1
2
3
4
5
# 标准安装路径模板
/lib/modules/$(uname -r)/kernel/<源码树路径>/<模块名>.ko

# 示例:2.6.34内核的钓鱼竿模块
/lib/modules/2.6.34/kernel/drivers/char/fishing.ko

安装命令

1
sudo make modules_install  # 需root权限


模块依赖管理

1. 依赖关系生成

1
2
3
4
5
# 生成完整依赖关系
sudo depmod

# 增量更新(仅处理新模块)
sudo depmod -A

2. 依赖存储位置

1
2
3
4
5
# 依赖关系文件路径
/lib/modules/$(uname -r)/modules.dep

# 文件内容示例
kernel/drivers/char/fishing.ko: kernel/drivers/net/bait.ko

智能加载原理
当加载 chum.ko 时,系统自动解析其依赖并先加载 bait.ko


模块加载与卸载

基础工具(不推荐)

操作 命令 缺陷 示例
加载 insmod module.ko 无依赖解析 insmod fishing.ko
卸载 rmmod module_name 不检查依赖 rmmod fishing

高级工具(推荐)

1. 智能加载

1
2
3
4
5
# 加载模块(自动处理依赖)
sudo modprobe fishing pole_length=300 material=titanium

# 查看加载的模块
lsmod | grep fishing

2. 安全卸载

1
2
3
4
5
# 卸载模块(自动移除未用依赖)
sudo modprobe -r fishing

# 强制卸载(危险!)
sudo modprobe -rf fishing # 可能破坏依赖树

内核配置与模块开发高级指南

配置选项管理 (Kconfig)

linux使用kbuild系统,可以通过修改Kconfig文件便捷地管理配置选项

1
2
3
4
5
6
7
8
9
# drivers/char/Kconfig 示例
config FISHING_POLE
tristate "Fish Master 3000 support" # 三态选项,表示模块在编译配置中可以内置编译到内核(Y),作为模块编译(M)或者不编译(N)
default n # 默认禁用
depends on FISH_TANK && !NO_FISHING # 依赖条件
select BAIT # 强制关联选项
help # 帮助文档
Enable support for the Fish Master 3000 computer interface.
Choose Y to build into kernel, M for module (fishing.ko), or N to disable.

核心指令解析: | 指令 | 功能 | 示例 | |----------------|----------------------------------|-----------------------------------| | tristate | 三态选项 (Y/M/N) | 驱动标准配置 | | bool | 布尔选项 (Y/N) | 特性开关 | | default | 默认值 | default y 默认启用 | | depends on | 依赖关系 | depends on NET 需网络支持 | | select | 强制启用其他选项 | select CRC32 自动启用CRC校验 | | if | 条件显示 | if EMBEDDED 嵌入式场景可见 | | help | 帮助文档 | 用户配置时的说明文本 |


模块参数系统

1. 基础参数声明

1
2
3
static int pole_length = 200;  // 默认值
module_param(pole_length, int, 0644); // 整型参数
MODULE_PARM_DESC(pole_length, "Pole length in cm");

2. 高级参数类型

类型 说明 示例
charp 字符串指针 module_param(name, charp, 0);
bool 布尔值 module_param(enable, bool, 0);
module_param_string 直接复制到数组 char target[32]; module_param_string(dest, target, sizeof(target), 0);
module_param_array 数组参数 int ids[5]; int count; module_param_array(ids, int, &count, 0);

3. 参数传递方式

1
2
3
4
5
6
7
# 加载时指定参数
sudo modprobe fishing pole_length=300 material=carbon

# 查看参数信息
modinfo fishing
parm: pole_length:Pole length in cm (int)
parm: material:Construction material (charp)

4. sysfs集成

1
2
# 参数自动暴露到sysfs
/sys/module/fishing/parameters/pole_length # 权限0644=rwr--r--

符号导出机制

1. 基础导出

1
2
3
4
5
// 声明可被模块调用的函数
int get_pole_strength(struct pole *p) {
return p->load_capacity;
}
EXPORT_SYMBOL(get_pole_strength); // 全局导出

2. GPL受限导出

1
EXPORT_SYMBOL_GPL(calculate_bait_ratio);  // 仅GPL模块可用

3. 导出规则

导出类型 调用权限 典型场景
EXPORT_SYMBOL 所有模块 通用内核API
EXPORT_SYMBOL_GPL 仅GPL许可证模块 核心子系统接口
未导出符号 仅内核内部使用 静态函数/私有实现

配置系统元选项

1
2
3
4
5
6
7
config EXPERIMENTAL
bool "Enable experimental features" # 高风险功能开关
default n

config DEBUG_KERNEL
bool "Kernel debugging" # 调试选项总开关
default y if DEBUG

关键元选项: - CONFIG_EMBEDDED:嵌入式系统优化选项 - CONFIG_BROKEN_ON_SMP:标记非SMP安全驱动 - CONFIG_EXPERIMENTAL:实验性功能入口


开发工作流示例

1. 添加新驱动

1
2
3
4
5
6
7
8
9
10
11
# 创建Kconfig
# drivers/char/fishing/Kconfig
config FISHING_PRO
tristate "Professional Fishing Module"
select FISHING_ADVANCED
help
Support for professional-grade fishing equipment

# 修改上级Kconfig
# drivers/char/Kconfig
source "drivers/char/fishing/Kconfig"

2. 实现参数化模块

1
2
3
4
5
6
7
8
9
10
11
#include <linux/moduleparam.h>

static char material[20] = "fiberglass";
module_param_string(material, material, sizeof(material), 0644);

static int lengths[] = {180, 240};
static int nr_lengths = 2;
module_param_array(lengths, int, &nr_lengths, 0444);

static bool enable_ai;
module_param(enable_ai, bool, 0644);

3. 编译验证

1
2
3
4
5
6
7
# 配置内核
make menuconfig
# -> Device Drivers -> Character devices -> Professional Fishing Module (M)

# 编译安装
make -j$(nproc) modules
sudo make modules_install

生产环境最佳实践

  1. 参数安全

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    static int max_load = 100;
    module_param(max_load, int, 0644);

    static int __init init_func(void) {
    if (max_load > MAX_SAFE_LIMIT) {
    pr_warn("Dangerous load limit %d, capping at %d\n",
    max_load, MAX_SAFE_LIMIT);
    max_load = MAX_SAFE_LIMIT;
    }
    }

  2. 版本兼容

    1
    2
    3
    4
    5
    6
    7
    #include <linux/version.h>

    #if LINUX_VERSION_CODE >= KERNEL_VERSION(5,15,0)
    // 新版内核API
    #else
    // 旧版兼容实现
    #endif

  3. 错误处理

    1
    2
    3
    4
    5
    6
    int err = register_device();
    if (err) {
    // 彻底回滚初始化
    unregister_previous();
    return err;
    }

性能提示:高频访问的模块参数应复制到局部变量,避免频繁查sysfs

二叉树

1. 二叉搜索树(BST)核心特性

1
2
3
根节点左子树的所有节点值 < 根节点值
根节点右子树的所有节点值 > 根节点值
所有子树都是二叉搜索树
  • 操作复杂度
    • 查找:O(log n)
    • 有序遍历:O(n)
  • 缺陷:不平衡树可能导致操作退化到O(n)

2. 自平衡二叉搜索树

1
叶子节点深度差 ≤ 1   →   树高度 = O(log n)
  • 平衡机制:在插入/删除时自动调整结构
  • 常见类型
    • AVL树(严格平衡)
    • 红黑树(半平衡,Linux首选)

红黑树(Red-Black Trees)

六大约束条件

  1. 节点非红即黑
  2. 叶子节点(NIL)为黑
  3. 叶子节点不存储数据
  4. 非叶子节点必有双子
  5. 红节点的子节点必为黑(核心约束)
  6. 根到任意叶子的黑节点数相同

优势

  • 插入/删除只需O(1)次旋转(AVL需O(log n))
  • 查找效率稳定在O(log n)
  • 内存开销小(仅1bit存储颜色)

Linux实现(rbtree)

初始化

1
2
#include <linux/rbtree.h>
struct rb_root root = RB_ROOT; // 声明并初始化根节点

节点定义

1
2
3
4
struct my_node {
int key;
struct rb_node rb; // 嵌入红黑树节点
};

查找实现(页缓存示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct page *rb_search_page_cache(struct inode *inode, unsigned long offset)
{
struct rb_node *n = inode->i_rb_page_cache.rb_node;
while (n) {
struct page *page = rb_entry(n, struct page, rb_page_cache);
if (offset < page->offset)
n = n->rb_left;
else if (offset > page->offset)
n = n->rb_right;
else
return page; // 命中
}
return NULL; // 未命中
}

插入实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct page *rb_insert_page_cache(struct inode *inode, unsigned long offset, 
struct rb_node *node)
{
struct rb_node **p = &inode->i_rb_page_cache.rb_node;
struct rb_node *parent = NULL;
// 查找插入位置
while (*p) {
parent = *p;
struct page *page = rb_entry(parent, struct page, rb_page_cache);

if (offset < page->offset)
p = &(*p)->rb_left;
else if (offset > page->offset)
p = &(*p)->rb_right;
else
return page; // 已存在
}
// 执行插入
rb_link_node(node, parent, p); // 链接新节点
rb_insert_color(node, &inode->i_rb_page_cache); // 重平衡
return NULL; // 插入成功
}

XArray对比说明

适用场景差异

数据结构 最佳场景 内核应用实例 XArray替代性
红黑树 范围查询/有序遍历 进程调度CFS ❌ 不可替代
XArray 稀疏ID映射/快速点查 页缓存/文件描述符 ✅ 专精领域

性能对比

1
2
3
4
5
操作        红黑树        XArray
---------------------------------
插入 O(log n) O(k) // k=键长
范围查询 O(log n + m) O(m) // m=结果数
内存开销 40字节/节点 8字节/条目

XArray替代红黑树的条件

  1. 键为整数类型(非复杂比较键)
  2. 无需有序遍历
  3. 超高并发需求(XArray内置RCU)
    1
    2
    3
    // XArray实现类似功能
    void *xa_store(struct xarray *xa, unsigned long index, void *entry);
    void *xa_load(struct xarray *xa, unsigned long index);

关键结论

  1. 红黑树适用场景
    • VFS目录树(dentry缓存)
    • 进程调度器(CFS运行队列)
    • EPoll事件管理
  2. XArray优先场景
    • 文件页缓存(address_space
    • 内存反向映射
    • UID到指针映射

迁移建议:新代码中整数键映射优先采用XArray;复杂键/范围查询仍需红黑树。
性能数据:XArray在ext4文件系统中减少40%缓存操作耗时(内核5.15测试)

121. 买卖股票的最佳时机

贪心方法:取最左最小值,取最右最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int maxProfit(vector<int>& prices) {
if(prices.size() == 1)return 0;
int profit = 0;
int i = 0;
for(int j = 1; j<prices.size(); j++){
if((prices[j] - prices[i])>profit)profit = prices[j] - prices[i];
if(prices[j] < prices[i])i = j;
}
return profit;
}
};
动态规划方法:每天保存两个数值 - 当天持有股票的最大值 - 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0] - 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i] - 当天不持有股票的最大值 - 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1] - 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]

122.买卖股票的最佳时机II

尝试一下动态规划方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
void printdp(vector<vector<int>> dp){
for(auto i: dp){
for(auto j: i){
cout<<j<<' ';
}
cout<<endl;
}
}
int maxProfit(vector<int>& prices) {
if(prices.size() == 1)return 0;
vector<vector<int>> dp(prices.size(), vector<int>(2, 0));
// printdp(dp);
dp[0][1] -= prices[0];
for(int i = 1; i < prices.size(); i++){
dp[i][0] = max(dp[i-1][1]+prices[i], dp[i-1][0]);
dp[i][1] = max(dp[i-1][1], dp[i-1][0]-prices[i]);
}
// printdp(dp);
return dp[prices.size()-1][0];
}
};

123.买卖股票的最佳时机III

为什么“选择两个最大的上升区间”这种解决方式不正确: 由于交易次数限制,并不是所有上升区间都需要被单独考虑。有时一笔交易可能覆盖多个上升区间

本题建议使用动态规划,推导四个状态: - 第一次持有股票 - 第一次不持有股票 - 第二次持有股票 - 第二次不持有股票

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int maxProfit(vector<int>& prices) {
if(prices.size() == 1)return 0;
vector<vector<int>> dp(prices.size(), vector<int>(5, 0));
dp[0][1] -= prices[0];
dp[0][3] -= prices[0];
for(int i = 1; i<prices.size(); i++){
dp[i][1] = max(dp[i-1][0]-prices[i], dp[i-1][1]);
dp[i][2] = max(dp[i-1][1]+prices[i], dp[i-1][2]);
dp[i][3] = max(dp[i-1][2]-prices[i], dp[i-1][3]);
dp[i][4] = max(dp[i-1][3]+prices[i], dp[i-1][4]);
}
return dp[prices.size()-1][4];
}
};

映射(Maps)

基本概念

映射(又称关联数组)是由唯一键组成的集合,每个键关联一个特定值。键与值的关系称为映射关系,支持以下基本操作:
- 添加(Add):插入键值对
- 移除(Remove):删除指定键
- 查找(Lookup):通过键获取值

尽管哈希表是一种映射实现,但并非所有映射都基于哈希。映射也可使用自平衡二叉搜索树存储数据:
- 哈希表:平均时间复杂度更优(O(1)),但最坏情况为线性(O(n))
- 二叉搜索树:最坏情况为对数复杂度(O(log n)),且支持有序遍历,无需哈希函数(仅需定义比较操作符)

在Linux内核中,映射的特定实现称为idr(ID Radix Tree-旧版实现,现为XArray),专用于将唯一ID(UID)映射到指针。


初始化idr

1
2
3
4
#include <linux/idr.h>

struct idr id_huh; // 静态定义
idr_init(&id_huh); // 初始化

分配UID

1. 预分配资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int idr_pre_get(struct idr *idp, gfp_t gfp_mask);  
```
- **功能**:必要时调整底层树结构,准备分配新UID
- **参数**:
- `idp`:目标idr结构
- `gfp_mask`:内存分配标志(如`GFP_KERNEL`)
- **返回值**:成功返回1,失败返回0(与其他内核函数相反!)

##### 2. 分配UID并关联指针
```c
int idr_get_new(struct idr *idp, void *ptr, int *id);
```
- **功能**:分配新UID,将其与`ptr`关联
- **返回值**:
- 成功:返回0,UID存储在`id`中
- 失败:返回`-EAGAIN`(需重试`idr_pre_get`)或`-ENOSPC`(idr已满)

##### 示例:分配UID
```c
int id, ret;
do {
if (!idr_pre_get(&id_huh, GFP_KERNEL))
return -ENOSPC;
ret = idr_get_new(&id_huh, ptr, &id);
} while (ret == -EAGAIN);
分配指定最小UID
1
2
3
4
5
6
7
int idr_get_new_above(struct idr *idp, void *ptr, int starting_id, int *id);  
```
- **功能**:分配不小于`starting_id`的UID,确保UID单调递增
```c
static int next_id = 1; // 全局计数器
if (!idr_get_new_above(&id_huh, ptr, next_id, &id))
next_id = id + 1; // 更新下一个起始ID
XArray方式 (Linux 4.2以后)
1
2
3
4
5
6
7
/* 原子分配 (无需预分配) */
int xa_alloc(struct xarray *xa, unsigned int *id,
void *entry, struct xa_limit limit, gfp_t gfp);
/* 分配递增ID示例 */
unsigned int next_id = 1;
xa_alloc(&xa_huh, &next_id, ptr, XA_LIMIT(next_id, UINT_MAX), GFP_KERNEL);
// 成功后 next_id 自动递增

查找与删除

查找UID
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void *idr_find(struct idr *idp, int id);  
```
- **返回值**:成功返回关联指针,失败返回`NULL`
- **注意**:在分配UID时,禁止将`NULL`作为有效idr值映射,否则无法区分查找失败与有效`NULL`

##### 移除UID
```c
void idr_remove(struct idr *idp, int id);
```
- **注意**:无错误返回值,需调用者确保UID存在

##### XArray方式
```c
/* 查找 */
void *xa_load(struct xarray *xa, unsigned long index);
/* 删除并返回删除项 */
void *xa_erase(struct xarray *xa, unsigned long index);

销毁

1
2
3
4
5
6
7
8
void idr_destroy(struct idr *idp);                  // 释放未使用内存  
void idr_remove_all(struct idr *idp); // 强制移除所有UID
```
**典型流程**:
```c
idr_remove_all(&id_huh); // 先清空所有映射
idr_destroy(&id_huh); // 再释放内存,确保所有idr内存被释放
kfree(user_data_ptr); // 释放实际业务数据
XArray方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 销毁并释放所有资源 */
void xa_destroy(struct xarray *xa);

/* 安全销毁流程示例 */
void module_exit(void)
{
unsigned long id;
void *entry;
// 遍历释放关联资源
xa_for_each(&xa_huh, id, entry) {
xa_erase(&xa_huh, id);
kfree(entry);
}
xa_destroy(&xa_huh); // 释放XArray管理内存
}

关键注意事项

  1. 并发控制
    • idr_pre_get无需加锁
    • idr_get_new等操作需自旋锁保护(参见第9/10章)
0%