Linux内核编程——内核内部机制基础 :进程与线程

372 阅读24分钟

在上一章中,你已经掌握了理解和编写简单内核模块所需的基础知识。在本章中,我们将开始探索Linux内核的内部机制,这是一个庞大而复杂的话题。本书中,我们并不打算深入探讨内核和内存的细节,但我将提供足够的背景知识,帮助你这类新兴的内核模块作者或设备驱动开发人员成功应对理解内核架构中涉及的关键主题,尤其是如何管理进程、线程及其栈。掌握这些知识后,你将能更好地理解接下来章节中关于如何正确高效地管理动态内核内存的内容。作为额外的好处,你会发现自己在调试用户空间和内核空间代码方面变得更加熟练。

我将内核内部机制的讨论分为两章,本章和下一章。本章主要讲解Linux内核架构的关键方面,特别是进程和线程的管理方式。下一章将聚焦于内存管理的内部机制,这是理解和操作Linux内核的另一个关键方面。当然,现实情况是,所有这些内容并不会在一两章中完全覆盖,而是贯穿于整本书中(例如,关于进程/线程的CPU调度的细节会在后面的章节中讨论,同样,内存内部机制、同步话题等也会有所涉及)。

简而言之,本章涵盖的内容如下:

  • 理解进程和中断上下文
  • 理解进程虚拟地址空间(VAS)的基础知识
  • 组织进程、线程及其栈——用户空间和内核空间
  • 理解并访问内核任务结构体
  • 通过‘current’操作任务结构体
  • 遍历内核的任务列表

技术要求

我假设你已经完成了在线章节《内核工作区设置》,并适当地准备了一个运行Ubuntu 22.04 LTS(或更高稳定版本)的虚拟机,并安装了所有必需的软件包。如果没有,建议你先完成这些准备工作。

此外,如果你还没有,记得克隆本书的GitHub仓库以获取代码(地址在这里:github.com/PacktPublis…);让我们以动手实践的方式进行学习。

我假设你对虚拟内存的基本概念有所了解,尤其是用户态进程虚拟地址空间(VAS)的段布局(映射)、栈等。不过,我们将在随后的“理解进程VAS的基础知识”一节中为你解释这些基本概念。

理解进程和中断上下文

在第4章《编写你的第一个内核模块 – 第一部分》中,我们简要介绍了“理解内核架构 – 第一部分”(如果你还没有阅读,建议在继续之前先阅读)。现在,我们将对这一讨论进行扩展。

首先,现代处理器在不同的特权级别上执行代码。例如,基于x86的处理器提供四个特权级(或环),其中环0是最高特权级,环3是最低特权级。类似地,ARM-32(AArch32)有七个执行模式,其中六个是特权模式。ARM64(AArch64)使用异常级别(EL0到EL3,EL0为最低特权级,EL3为最高特权级)。然而,现实中,且这一点很关键:所有现代操作系统仅使用两个可用的CPU特权级——特权级和非特权级,这两个级别分别是内核模式和用户模式。

理解这一点也至关重要:大多数现代操作系统都是单片式设计的。单片式字面意思是一个单一的大块石头。稍后我们将更详细地讨论这一点如何适用于我们最喜欢的操作系统!现在,只需理解单片式设计意味着:当一个进程或线程发出系统调用时,它会切换到(特权的)内核模式,执行内核代码,并可能操作内核数据。是的,系统中并没有一个内核线程在代表其执行代码;发出系统调用的进程(或线程)会切换到内核模式,并自己执行内核代码。因此,我们说内核代码是在用户空间进程或线程的上下文中执行的——我们称之为进程上下文。想一想,内核的很大一部分代码就这样执行,包括设备驱动程序的大部分代码。(顺便提一下,即使是处理器异常的处理——如页面错误或系统调用——以及CPU调度,都是在进程上下文中执行的)。

那么,你可能会问,既然我理解了这一点,那么除了进程上下文外,内核代码还可以如何执行呢?还有另一种方式:当硬件中断(来自外设设备——如键盘、网络卡、磁盘等)触发时,CPU的控制单元保存当前上下文,并立即将CPU重定向到中断处理程序(中断服务例程,ISR)的代码。在这种情况下,代码也会在内核(特权)模式下运行——实际上,这是一种异步的切换到内核模式的方式(除非你本来就在内核模式中)!许多设备驱动程序的中断代码就是以这种方式执行的;我们现在说,内核/驱动程序以这种方式执行的代码是在中断上下文中执行的。

(再说一遍,顺便提一下,许多现代驱动程序使用线程化中断模型,其中大多数中断处理是在内核线程的上下文中进行的,实际上是在进程上下文中)。

因此,任何内核(或模块/驱动程序)代码的执行,都是在以下两种上下文中的一种:

  • 进程上下文:由于进程或线程发出了系统调用,或发生了处理器异常(如页面错误),内核空间被进入,执行内核代码,操作内核数据;这通常是同步的。
  • 中断上下文:由于外设芯片触发了硬件中断,内核空间被进入,执行内核(和/或驱动程序)代码,操作内核数据;这是异步的。

图6.1展示了概念图:用户模式的进程和线程在非特权用户上下文中执行;用户模式线程可以通过发出系统调用切换到特权的内核模式。该图还展示了Linux中纯内核线程的存在;它们与用户模式线程非常相似,主要区别在于它们只在内核空间中执行;它们甚至无法“看到”用户VAS。通过系统调用(或处理器异常)同步切换到内核模式后,任务现在在进程上下文中运行内核代码。(内核线程也在进程上下文中运行内核代码)。

然而,硬件中断则是另一回事——它们会抢占任何东西,包括内核代码,使得执行异步切换到内核特权模式(如果尚未处于内核模式)。它们执行的代码(通常是内核或设备驱动程序的中断处理程序)会在所谓的中断上下文中运行。

图6.1提供了更多细节——中断上下文的上下半部分、内核线程和工作队列等;这些细节(以及更多内容)将在本书的伴随卷《Linux内核编程 - 第二部分》中详细讲解。

image.png

在后面的“确定上下文”部分,我们将向你展示如何准确地检查你的内核(或驱动程序)代码当前正在运行的上下文(进程上下文或中断上下文)。继续阅读!

理解进程虚拟地址空间(VAS)的基础

虚拟内存的一个基本“规则”是:所有潜在的可寻址内存都在一个“盒子”里;也就是说,它是被沙箱化的。我们将这个“盒子”称为进程映像或进程的虚拟地址空间(VAS)。要“看”到盒子外部是无法做到的。

在这里,我们仅提供进程用户虚拟地址空间(VAS)的快速概述(这应该足够了)。如果想了解更多详细信息,请参考我之前的书籍《Linux系统编程实战》。

用户虚拟地址空间被划分为同质的内存区域,称为段,或更技术性地称为映射(因为它们是通过mmap()系统调用内部构建的)。图6.2展示了每个Linux(用户空间)进程将拥有的最小映射(段):

image.png

让我们快速回顾一下这些段或映射的基本结构(从底向上):

文本段:这是存储机器代码的地方;它是处理器核心的指令指针(或等效寄存器)在一个线程执行代码时指向的位置 —— 静态/固定大小(模式:r-x)。 (请注意,文本段并不从虚拟地址0x0开始,它距离0x0有一定距离。事实上,第一个虚拟页——封装NULL地址(0x0)的那个——被称为“空陷阱”页。稍后会讨论空陷阱。)

数据段:紧接在文本段之上。这里存储全局和静态数据变量(模式:rw-)。实际上,有三个不同的数据段:

  • 初始化数据段:存储预初始化的全局/静态变量 —— 静态/固定大小。
  • 未初始化数据段:存储未初始化的全局/静态变量(在运行时会自动初始化为0;这个区域有时被称为bss) —— 静态/固定大小。
  • 堆段:标准C库的内存分配和释放API(熟悉的malloc()系列函数)从这里获取内存。但这并不完全正确。在现代的glibc中,只有malloc()调用请求小于MMAP_THRESHOLD字节(默认128 KB)的内存时,才会从堆中获取内存。任何更大的请求会作为一个单独的映射分配到进程的虚拟地址空间(VAS)中(通过强大的mmap()系统调用),称为匿名(或anon)映射。堆是一个动态段(它可以增长/缩小)。我们通常说堆“向上”增长,指向更高的虚拟地址。堆上的最后一个合法可引用位置被称为程序断点(可以通过调用sbrk(0)获取)。

共享库(文本、数据) :所有进程动态链接的共享库都会被映射(在运行时,通过加载器调用mmap())到进程的VAS中 —— 在堆的顶部和main()线程的栈之间(模式:r-x/rw-)。这个区域 —— 在堆和栈之间 —— 还包含其他线程的栈内存(除了main()的栈),匿名内存和共享内存区域。

:一个使用“后进先出”(LIFO)语义的内存区域;栈用于实现高级语言的函数调用机制,并有效地保存线程的执行上下文。栈(帧)包括参数传递、局部变量实例化(和销毁)、返回值传播等任务。它是一个动态段。在所有现代处理器(包括x86和ARM架构)中,栈“向下”增长,指向较低的虚拟地址(称为完全下降栈)。每次调用一个函数时,都会分配并初始化一个栈帧(或调用帧);栈帧的精确布局非常依赖于CPU(你必须查阅相应的CPU应用二进制接口(ABI)文档 —— 参见“进一步阅读”部分以获取参考)。处理器核心的栈指针(SP)寄存器(或等效寄存器)始终指向当前的栈帧,也就是栈的顶部;由于栈向较低的(虚拟)地址增长,栈的顶部实际上是最低的(虚拟)地址!这可能不直观,但却是真的(模式:rw-)。 (顺便提一下,栈帧并不是在函数调用时真的分配和初始化的,那样会太慢。类似地,栈帧也不会在函数返回时被销毁;随着你深入了解,你会学到更多。)

当然,你已经理解了进程必须包含至少一个执行线程(线程是进程中的执行路径);唯一保证的线程当然是main()函数。以图6.2为例,我们展示了三条执行线程 —— main、thrd2和thrd3。另外,如你所料,每个线程共享进程VAS中的所有内容,除了栈;正如你所知道的,每个线程都有自己的私有栈。main的栈位于进程(用户)VAS的最顶部;thrd2和thrd3线程的栈可以被分配到位于库映射和main栈之间的任何位置,图中用两个(蓝色)方框表示这一区域。

我设计并实现了一个我认为非常有用的学习/教学和调试工具,叫做procmap(github.com/kaiwan/proc…);它是一个基于控制台的进程VAS可视化工具。它实际上可以向你展示完整的进程VAS(非常详细);我们将在下一章开始使用它。但不要等到那时再使用它,你现在就可以克隆并在Linux系统上试用(它确实需要root权限)。

现在,你已经理解了进程VAS的基本概念,是时候深入了解一下内核的内部实现,关于进程VAS、用户和内核地址空间以及它们的线程和栈的更多内容了。

组织进程、线程及其栈 – 用户空间和内核空间

传统的UNIX进程模型 —— 一切都是进程;如果不是进程,那就是文件 —— 有很多优点。事实上,正是因为这个模型已经持续了超过五十年,并且仍然被操作系统采用,才充分证明了它的有效性。当然,现在线程被认为是原子执行上下文;线程是进程中的执行路径。线程共享所有进程资源,包括用户虚拟地址空间(VAS)、打开的文件、信号处理、进程间通信(IPC)对象、凭证、分页表等,除了栈。每个线程都有自己的私有栈区域(这很有道理;如果没有栈,线程怎么可能真正并行执行,因为栈保存的是执行上下文)。

我们关注线程而不是进程的另一个原因在第10章《CPU调度器 —— 第1部分》中会更清晰地解释。现在我们可以简要地说:线程,而非进程,是内核可调度的实体(也称为KSE) —— 它是被调度到CPU核心上执行的。这是Linux操作系统架构中的一个关键方面。在Linux中,每个线程 —— 包括内核线程 —— 都映射到一个称为任务结构(task structure)的内核元数据结构。任务结构(也叫进程描述符)本质上是一个大型内核数据结构,内核使用它作为每个线程的属性结构。对于每个存活的线程,内核都会维护一个相应的任务结构(参见图6.3,别担心,我们将在接下来的章节中详细讨论任务结构)。

下一个要理解的关键点是,每个线程每个CPU支持的权限级别都需要一个栈。在现代操作系统(如Linux)中,我们支持两种CPU权限级别 —— 无权限的用户模式(或用户空间)和有权限的内核模式(或内核空间)。因此,在Linux中,每个活跃的用户空间线程都有两个栈:

  • 用户空间栈:当线程执行用户模式代码路径时,这个栈在起作用。
  • 内核空间栈:当线程切换到内核模式(通过系统调用或处理器异常)并执行内核代码路径时,这个栈在起作用(在进程上下文中)。

当然,每个好规则都有一个例外:内核线程(简写为kthreads)是完全在内核中运行的线程,因此它们只能“看到”内核(虚拟)地址空间;它们无法“看到”用户空间。因此,这些kthreads只执行内核空间代码路径,它们每个kthread只有一个栈 —— 即内核空间栈。

此外,这可能会让你想知道在处理硬件中断的处理程序时使用的是哪个栈;尽管依赖于架构,内核通常会为每个核心维护一个IRQ栈,专门用于此目的。

为了帮助更清楚地理解这个关键点,举个例子:当你执行经典的K&R C语言的“Hello, world”程序时,内核创建了进程;这时,内核设置并初始化了几个对象 —— 其中包括进程任务结构、它的进程VAS(包括用户模式栈),以及一个唯一的内核模式栈。一旦进程运行,当然,它首先在用户模式下执行printf() API,从而使用用户空间栈(main()的栈)。printf()在设置好后,发出了write()系统调用!这使得我们的进程切换到内核模式,并实际写入Hello, world\n字符串(通过内核中的tty层代码路径)到stdout设备。当它执行内核代码时(在内核模式下),它使用内核空间栈。

图6.3将地址空间分为两部分 —— 用户空间和内核空间。在图的上半部分 —— 用户空间 —— 你可以看到几个进程及其用户VAS的概念视图。在下半部分 —— 内核空间(一个所有用户模式进程共享的大型单体空间) —— 你可以看到与每个用户模式线程对应的内核元数据结构struct task_struct,以及该线程的内核模式栈(我们稍后会详细介绍)。

另外,我们还看到(在最底部)作为示例,三个内核线程(标记为kthrd1、kthrd2和kthrdn);如预期的那样,它们也有一个task_struct元数据结构,表示它们的内部信息(属性)和一个内核模式栈。

image.png

运行一个小脚本来查看当前存活的进程和线程数量

为了让关于用户空间和内核虚拟地址空间的讨论更加具体,我们执行了一个简单的Bash脚本(ch6/countem.sh),该脚本用来统计当前存活的进程和线程的数量。我在本地的x86_64 Ubuntu 22.04 LTS系统上执行了这个脚本,以下是执行结果(不同的发行版可能会略有不同,输出的前部分差异无关紧要):

$ cd <booksrc>/ch6
$ ./countem.sh 
System release info:
Distributor ID: Ubuntu
Description:    Ubuntu 22.04.1 LTS
Release:        22.04
Codename:       jammy
 
Total # of processes alive              =       234
Total # of threads alive                =       514
Total # of kernel threads alive         =       116
Thus, total # of user mode threads alive =       398
$

脚本是如何获取进程和线程数量的?很简单,它只是基于ps命令,使用适当的选项开关,过滤输出并进行一些计算。(接下来的章节会详细讲解更多细节。)我将留给你去查找这个简单脚本的代码:ch6/countem.sh。研究一下(输出的底部部分),理解它。你当然会意识到,这只是某一时刻的快照,情况会发生变化。

接下来的章节,我们将讨论两个部分(对应于两个地址空间):图6.3中我们看到的用户空间内容,以及在内核空间中看到的内容。我们先从用户空间部分开始。

用户空间组织

参考我们在前一部分运行的countem.sh Bash脚本,我们将对其进行分析,并讨论一些关键点,当前仅限于进程VAS的用户空间部分。请务必阅读并理解这一部分(我们在后续讨论中提到的数字是针对我们在“运行一个小脚本来查看当前存活的进程和线程数量”部分运行countem.sh脚本的结果)。为了更好地理解,我已经将图中的用户空间部分放在这里:

image.png

这里(图6.4)你可以看到三个独立的用户模式进程。每个进程至少有一个执行线程(即main()线程)。在这里,我们展示了三个进程,P1、P2和Pn,分别包含一个、三个和两个线程,其中包括main()线程。在我们前面示例运行(即在“运行一个小脚本来查看当前存活的进程和线程数量”部分展示的结果)中,Pn中的n值为234。

请注意,这些图示纯粹是概念性的。例如,实际上,PID为2的进程通常是一个单线程的内核线程,称为kthreadd。

每个进程由多个段(严格来说是映射)组成。大致上,用户模式段(或映射)如下所示:

  • 文本段:代码;r-x
  • 数据段:rw-;由三个不同的映射组成 —— 初始化数据段、未初始化数据段(或bss)以及一个“向上增长”的堆
  • 库映射:每个共享库的文本和数据部分,进程动态链接时映射到进程中:
  • 向下增长的栈

关于这些栈,我们从前面的示例运行中看到,系统当前有398个活跃的用户模式线程。这意味着也有398个用户空间栈,因为每个活跃的用户模式线程都有一个用户模式栈。对于这些用户空间线程栈,我们可以这样说:

  • 一个用户空间栈始终存在于main()线程中,并且它将位于用户VAS的顶部 —— 高端。如果进程是单线程的(只有main()线程),那么它只会有一个用户模式栈;图6.4中的P1进程展示了这种情况。

顺便提一下,但这点很重要:在Linux上,任何foo()系统调用通常会变成内核中的sys_foo()函数。而且,通常但并不总是,这个sys_foo()函数是一个包装器,调用真正的代码do_[*]_foo()。一个进一步的细节:在内核代码中,你可能会看到类似SYSCALL_DEFINEn(foo, ...)的宏;这个宏会变成sys_foo()例程。附加的数字n表示传递给内核的参数个数,范围是[0,6];它表示通过系统调用从用户空间传递给内核的参数数量。

  • 如果进程是多线程的,它将为每个存活的线程(包括main()线程)分配一个用户模式线程栈;图6.4中的P2和Pn进程展示了这种情况。栈的分配发生在调用fork(2)(对于main())或pthread_create()(对于进程中的其他线程)时,这将导致该代码路径在内核中的进程上下文中执行(在kernel/fork.c中):sys_fork() --> kernel_clone()。 (顺便说一下,早期,内核中fork()系统调用的工作例程被称为_do_fork();从5.10版本的内核开始,这个函数已经被重命名为kernel_clone();提交号为cad6967ac108。)

此外,值得注意的是,Linux上的Pthreads创建库API pthread_create() 调用的是(非常Linux特有的)clone()系统调用(内核中的代码位于kernel/fork.c:sys_clone())。这个系统调用最终调用kernel_clone();传递的参数 —— 特别是标志值 —— 告诉内核如何创建自定义进程 —— 换句话说,一个线程!

用户空间栈当然是动态的;它们可以增长(并且也可以缩小),直到栈大小资源限制 RLIMIT_STACK(通常为8MB —— 你可以使用prlimit工具查找)。

顺便提一下:在6.6版的Linux中,x86架构的新功能“用户空间影子栈”最终被合并。这本质上是一个安全加固功能,帮助保护应用程序免受危险的“面向返回的编程”(ROP)攻击。这些攻击通过修改调用栈/函数返回地址来工作。使用影子栈允许(仅)内核实现将返回地址保存在(硬件)影子栈中,该栈无法被修改,然后在返回时弹出其值,并将其与常规栈中的值进行比较。如果它们不同,就会发出信号,表示可能正在进行利用,并使处理器引发一般保护故障,以阻止攻击的发生。当前的实现(在6.6版上)仅适用于x86_64架构,并且仅限于用户模式栈。更多信息请见:用户空间影子栈(可能)对于6.4,Jon Corbet,LWN,2023年3月lwn.net/Articles/92…

在了解了用户空间部分后,现在让我们深入探讨内核空间部分的内容。

内核空间组织

继续我们在“运行一个小脚本来查看当前存活的进程和线程数量”部分中提到的countem.sh Bash脚本,我们现在将对其进行分析,并讨论一些关键点,重点讨论VAS中的内核空间部分。请务必仔细阅读并理解这一部分内容(同时阅读我们前面示例运行countem.sh脚本时输出的数字)。

为了更好地理解,我在这里放置了图中的内核空间部分(图6.5):

image.png

再次回顾我们前面示例运行的结果,你可以看到系统当前有398个用户模式线程和116个内核线程存活。这总共产生了514个内核空间栈。怎么回事呢?如前所述,每个用户模式线程有两个栈 —— 一个用户模式栈和一个内核模式栈。因此,我们将有398个用户模式线程的内核模式栈,再加上116个内核线程的内核模式栈(回想一下,内核线程只有内核模式栈 —— 它们完全无法“看到”用户空间),总数为(398 + 116 =)514个内核空间栈。接下来我们列出一些内核模式栈的特点:

  • 每个存活的用户模式线程(包括main()线程)都有一个内核模式栈。
  • 内核模式栈的大小是固定的(静态的),且非常小。实际上,它们在32位操作系统中通常为2页,在64位操作系统中通常为4页(每页通常为4 KB)。
  • 不要简单地假设页大小总是4 KB —— 在用户空间中,可以使用getpagesize()系统调用查询其值;在内核空间中,PAGE_SIZE宏也能得出相同的值。
  • 它们在线程创建时分配(通常归结为内核代码:kernel_clone() --> copy_process() --> dup_task_struct())。

再次强调:每个用户模式线程有两个栈 —— 一个用户模式栈和一个内核模式栈。规则的例外是内核线程;它们只有内核模式栈(因为它们没有用户映射,因此没有用户空间段)。在图6.5的下半部分,我们展示了三个内核线程 —— kthrd1kthrd2kthrdn(在我们前面的示例运行中,kthrdn会有n=116)。此外,每个内核线程在创建时都有一个任务结构和一个内核模式栈分配给它。

内核模式栈在大多数方面与用户模式栈相似 —— 每次调用内核空间中的函数时,都会设置一个栈帧(栈帧的布局依赖于架构,属于CPU ABI文档的一部分;有关这些细节,请参见“进一步阅读”部分)。CPU有一个寄存器来跟踪栈的当前位置信息(通常称为栈指针(SP)),栈会“向下”增长到较低的虚拟地址。但是,与动态的用户模式栈不同,内核模式栈的大小是固定的并且很小。

对于内核/驱动程序开发者来说,这个非常小的(两页或四页)内核模式栈大小有一个重要含义 —— 在进行堆栈密集型工作时(例如使用大量局部变量或递归时),务必小心不要溢出内核栈。

内核提供了一个可配置选项(CONFIG_FRAME_WARN),在编译时警告你内核栈的高使用情况;以下是lib/Kconfig.debug文件中的相关文本:

config FRAME_WARN:
  int "Warn for stack frames larger than"
  range 0 8192
  […]
  default 2048 if 64BIT
  help
     Tell gcc to warn at build time for stack frames larger than this.
     Setting this too low will cause a lot of warnings.
     Setting it to 0 disables the warning.

总结内核中关于线程、任务结构和栈的内容

好的,太棒了,现在让我们总结一下从前面讨论和运行countem.sh脚本(在“运行一个小脚本来查看当前存活的进程和线程数量”部分)的学习和发现:

任务结构:
  • 每个存活的线程(无论是用户线程还是内核线程)在内核中都有一个对应的任务结构(struct task_struct);这是内核跟踪和管理线程的方式。所有线程的属性都存储在这里(你将在后面的“理解和访问内核任务结构”部分了解更多内容)。

  • 关于我们前面运行的ch6/countem.sh脚本(在“运行一个小脚本来查看当前存活的进程和线程数量”部分):

    • 系统当前存活的线程总数为514个(包括用户线程和内核线程),这意味着内核内存中有514个任务(元数据)结构(在代码中是struct task_struct),我们可以得出以下结论:

      • 其中398个任务结构表示用户线程。
      • 剩下的(514 - 398 =)116个任务结构表示内核线程。
栈:
  • 每个用户空间线程有两个栈:

    • 一个用户模式栈(当线程执行用户模式代码路径时使用)
    • 一个内核模式栈(当线程执行内核模式代码路径时使用)
  • 此外,每个核心都有一个独立的IRQ栈,用于处理硬件中断时执行代码路径。

  • 异常情况:内核线程只有一个栈,即内核模式栈。

因此,根据我们前面运行的ch6/countem.sh脚本,我们可以得出以下结论:

  • 有398个用户空间栈(位于用户空间)。
  • 以上,再加上398个内核空间栈(位于内核内存)。
  • 以上,再加上116个内核空间栈(用于116个存活的内核线程)。

这合起来就是总共:398 + 398 + 116 = 912个栈! (在64位Linux系统上,假设每个内核模式栈为4页,每页4 KB,那么占用的内存就是 4 * 4096 * 912 = 14.25 MB的栈内存)。

FYI,命令grep "KernelStack" /proc/meminfo会显示当前内核栈使用了多少内存。(可以查阅proc(5)的手册页了解更多细节)。

如前所述,许多架构(包括x86和ARM64)支持每个CPU有一个独立的栈来处理中断(称为IRQ栈)。当外部硬件中断发生时,CPU的控制单元会立即将控制转移到最终的中断处理代码(可能是在设备驱动程序中)。一个独立的每CPU中断栈用来保存中断代码路径的栈帧;这有助于避免给被中断的进程/线程的现有(较小的)内核模式栈带来过大的压力。IRQ栈的大小与该架构的内核模式栈大小相同。(另外,像x86_64这样的架构支持更多类型的栈,但我们不再进一步讨论)。

好了,现在你已经理解了用户空间和内核空间中关于进程/线程及其栈的整体组织结构,接下来我们将讨论如何实际查看内核和用户空间栈的内容。除了用于学习之外,这些知识对调试情况也非常有帮助。

查看用户和内核栈

栈通常是调试会话的关键。它保存着线程当前的执行上下文 —— 目前正在执行哪个函数的代码,以及关键的执行路径 —— 这使我们能够推断出线程的历史(它在做什么,以及发生了什么)。能够看到并理解线程的调用栈(也称为调用链、调用跟踪或回溯)对于理解我们是如何到达当前状态至关重要。所有这些宝贵的信息都存在栈中。然而,每个线程都有两个栈 —— 用户空间栈和内核空间栈。我们如何查看它们的内容呢?

在这里,我们将展示两种查看给定进程或线程的内核和用户模式栈的广泛方法,首先是传统方法,然后是更现代的方式(通过eBPF)。请继续阅读。

传统方法查看栈

让我们首先学习如何使用传统方法查看给定进程或线程的内核和用户模式栈。我们从内核模式栈开始。

查看给定线程或进程的内核空间栈

好消息,这很简单。Linux内核通过通常的机制将给定线程的内核栈暴露给用户空间 —— 强大且多功能的proc文件系统接口。只需要读取伪文件/proc/PID/stack的内容。

一个例子总是有帮助的;让我们查看我们的Bash进程的内核模式栈。假设在我们的x86_64 Ubuntu系统上,Bash进程的PID是2459:

在现代内核中,为了避免信息泄露,查看进程或线程的内核模式栈需要root权限作为安全要求。

$ sudo cat /proc/2549/stack
[<0>] do_wait+0x184/0x340
[<0>] kernel_wait4+0xaf/0x150
[<0>] __do_sys_wait4+0x89/0xa0
[<0>] __x64_sys_wait4+0x1e/0x30
[<0>] do_syscall_64+0x5c/0x90
[<0>] entry_SYSCALL_64_after_hwframe+0x63/0xcd
$

在前面的输出中,每一行代表栈上的一个调用帧(或栈帧)。为了帮助解读内核栈回溯,值得了解以下几点:

  • 显示的名称是被调用的函数名 —— 例如,这里第二个显示的是do_syscall_64()(因为我们总是从底部向上读取栈追踪)。
  • 函数调用图的顺序是从底到顶;因此,这个输出应当从底部到顶读取。所以,这里意味着(忽略最底部的第一个)调用图是这样的:do_syscall_64() --> __x64_sys_wait4() --> __do_sys_wait4() --> kernel_wait4() --> do_wait()
  • 每一行输出代表一个调用帧 —— 实际上是调用链中的一个函数。
  • 如果调用帧前面有一个或多个?符号,这意味着内核无法可靠地解释这个栈帧。忽略它,它是内核在说这个栈帧很可能是无效的(一个由以前的调用栈遗留下来的痕迹);内核的回溯代码通常是正确的!(意识到,很常见的是,相同的栈内存会被重复使用;这可能会留下与当前调用栈无关的痕迹)。

如前所述,在Linux上,任何foo()系统调用通常会变成内核中的sys_foo()函数。此外,通常(但不总是),sys_foo()是一个包装器,调用实际的代码do_[*]_foo()

再来看前面的输出。应该很清楚:我们的Bash进程当前正在内核中执行do_wait()函数;调用图清晰地显示它是通过一个系统调用wait4()到达的!这很正确;Shell通过分叉一个子进程然后通过wait4(2)系统调用等待子进程的终止。

好奇的读者(你!)应该注意到,在前面命令输出的每个栈帧的最左边列中的[<0>]是该函数的文本(代码)地址的占位符。再次强调,为了安全原因 —— 防止信息泄露 —— 在现代内核中它被置为零。(与内核和进程布局相关的另一项安全措施将在第7章《内存管理内部 —— 基本知识》中讨论 —— 在“内存布局随机化 —— KASLR和用户模式ASLR”部分。)

接下来,什么是<func>+x/y语法 —— 比如,行do_wait+0x184/0x340,我听到你问?这些信息在调试时非常有用:

  • 第一个数字(x,总是以十六进制表示)是函数开始位置与当前执行位置之间的字节偏移量。
  • 第二个数字(y,依然是十六进制)是内核认为该函数的长度;通常是正确的。

所以,do_wait+0x184/0x340意味着do_wait()函数的机器代码在函数开始的偏移量为0x184(388十进制)字节,而该函数的长度是0x340(832十进制)字节! (顺便说一下,我的《Linux内核调试》一书详细覆盖了这些方面。)

查看给定线程或进程的用户空间栈

具有讽刺意味的是,在典型的Linux发行版上,查看进程或线程的用户空间栈似乎比查看内核模式栈更困难(正如我们在上一节中看到的)。有一个工具可以做到这一点:gstack。实际上,它只是一个简单的脚本封装,调用著名的GDB调试器的批处理模式,让GDB调用其回溯命令。

不幸的是,在Ubuntu(至少是23.10及以下版本)上,似乎有一个问题;在任何本地包中都找不到gstack程序。(Ubuntu确实有一个旧的pstack工具,但它只在x86 32位Linux系统上有效。)

一个简单的解决方法是使用GDB的非交互模式或批处理模式(你可以始终运行GDBattach <PID>命令,然后发出[thread apply all] bt命令来查看用户模式栈)。实际上,这几乎就是一个名为“穷人分析器”(poormansprofiler.org/)的网页告诉我们要做的事情(正如pstack工具在Fedora上所做的那样)!因此,以下简单(且简化的)包装脚本可以显示给定进程的用户模式栈帧(其中$1是进程/线程的PID,前提是你以root身份运行它):

sudo gdb \
  -ex "set pagination 0" \
  -ex "thread apply all bt" \
  --batch -p $1

我将这段简单的代码放入了脚本中:ch6/ustack。好吧,时间来(最小化地)测试它!我们把脚本应用于我们最新的Bash进程:

$ ./ustack $(pgrep --newest bash)
[sudo] password for c2kp:
0x00007fadd3109c3a in __GI___wait4 (pid=-1, stat_loc=0x7fffe3a3a1e0, options=10, usage=0x0) at ../sysdeps/unix/sysv/linux/wait4.c:27
27    ../sysdeps/unix/sysv/linux/wait4.c: No such file or directory.

Thread 1 (process 2549):
#0  0x00007fadd3109c3a in __GI___wait4 (pid=-1, stat_loc=0x7fffe3a3a1e0, options=10, usage=0x0) at ../sysdeps/unix/sysv/linux/wait4.c:27
#1  0x0000555b98cd4f03 in ?? ()
#2  0x0000555b98cd6373 in wait_for ()
#3  0x0000555b98cc3216 in execute_command_internal ()
#4  0x0000555b98cc39da in execute_command ()
#5  0x0000555b98cab665 in reader_loop ()
#6  0x0000555b98ca9ef9 in main ()
[Inferior 1 (process 2549) detached]

到此为止!Bash的用户模式栈及其所有调用帧(按底向上阅读)显示出来了!再次强调,在“Thread 1 (process …)”下方的每一行(Thread 1是main()线程)代表一个调用帧;按底向上阅读。显然,Bash执行一个命令并最终调用了__GI___wait4()函数(在现代Linux系统中,它只是glibc对实际wait4()系统调用的包装)!最左边的列(例如#3)代表调用帧编号;调用帧#0被视为栈顶 —— 即最新执行的函数(它是调用链中的最后一个函数)。

再次,简单忽略任何标记为??的调用帧。(顺便提一下,gstack工具在我的x86_64 Fedora 38/39虚拟机上工作得很好。)

能够查看内核和用户空间栈(如前面的代码片段所示)并使用包括straceltrace在内的工具来跟踪进程(或线程)的系统调用和库调用,对于调试来说是极大的帮助!不要忽视它们。

顺便说一句,你可能会好奇在调用栈的第0帧中显示的... at ../sysdeps/unix/sysv/linux/wait4.c:27字符串是什么意思。好吧,libthread_db库中的调试信息允许GDB找出调用__GI___wait4()函数的精确源码行!当然,你需要该版本的glibc源码来查找它。

接下来是查看栈的现代方法。

eBPF – 查看两个栈的现代方法

现在,令人兴奋的内容来了!让我们学习一种强大的现代方法,利用(截至写作时)最新的技术——扩展伯克利数据包过滤器(eBPF)。我们曾在在线章节《内核工作区设置》中的“附加有用项目”部分提到过eBPF项目。老版的BPF已经存在很长时间,并用于网络数据包跟踪;而eBPF是一个相对较新的创新,只在4.x版本的Linux内核中提供(这当然意味着你需要运行4.x或更高版本的Linux系统才能使用这种方法)。

直接使用底层内核级BPF字节码技术是(极其)困难的;因此,好消息是,有几个易于使用的前端工具(工具和脚本)可以实现这一技术。在这些前端工具中,BPF编译器集合(BCC)和bpftrace被认为非常有用。(显示当前BCC性能分析工具的图表可以在这里找到;eBPF前端的列表可以在这里找到。)

在这里,我们将简单演示如何使用一个名为stackcount的BCC工具(在Ubuntu中,这些eBPF工具通常以-bpfcc结尾,因此这个工具被命名为stackcount-bpfcc)。另一个优点是,使用这个工具可以让你同时查看内核和用户模式栈;无需使用多个工具。

你可以通过阅读安装说明来为你的主机Linux发行版安装BCC工具。那么在运行我们的自定义6.1内核的Linux虚拟机中安装它们怎么办?你可以安装它们(尽管在较早的内核版本中,这可能会带来一些问题,并且需要运行Ubuntu或Fedora等发行版提供的内核)。

图6.6是该工具在使用–h参数时显示的简洁帮助屏幕的截图:

image.png

一定要看看。我强烈建议你查看Brendan Gregg和其他贡献者提供的stackcount[-bpfcc]示例,内容可以在这里找到。它们值得一看。

在接下来的示例中,我们使用stackcount-bpfcc BCC工具(在我的x86_64 Ubuntu 22.04 LTS主机系统上运行我们的自定义6.1内核)来查看ping进程运行时调用栈的各个方面。为了设置,我在后台运行了ping yahoo.com,并通过一个简单的包装脚本(ch6/stackcount_eg.sh)运行了几个stackcount_bpfcc实例,使用不同且适当的参数;一定要看看。

即使是许多生产系统,内核符号通常也已经足够启用,至少能看到(至少是)函数名;事实上,kptr_restrict系统控制参数有一定的影响(你可以在这里查阅)。另一方面,具有一定讽刺意味的是,通常是那些没有编译符号的应用程序(因为它们通常使用-fomit-frame-pointer编译选项),因此它们的函数名可能不会出现在用户模式调用栈中。将它们编译为-fno-omit-frame-pointer或使用调试符号包可以解决这个问题(在调试时)。

使用stackcount[-bpfcc]时,你必须指定一个或多个感兴趣的函数(有趣的是,你可以指定用户空间或内核空间的函数,并且可以在指定时使用“通配符”或甚至正则表达式!);只有在这些函数被调用时,栈才会被追踪并报告。图6.7是我们演示stackcount脚本运行时的部分截图;当ping进行时,你可以清楚地看到内核调用栈和不太清晰的用户模式调用栈(因为用户空间符号不可用):

image.png

传递的--delimited选项开关打印分隔符--;它表示进程的内核模式和用户模式栈之间的边界。(不幸的是,由于大多数生产环境中的用户模式应用程序会剥离符号信息,许多用户模式栈帧通常显示为[unknown]。)至少在此系统上,内核栈帧非常清晰;传递--verbose选项时,甚至会打印出相关函数文本(代码)函数的虚拟地址。再次提醒,stackcount-bpfcc工具仅适用于Linux 4.6及以上版本,并且需要root权限。请查看其手册页获取更多详细信息。

请注意,由于相对较新的内核锁定功能启用,eBPF程序可能会失败(不过它默认是禁用的)。这是一个Linux安全模块(LSM),为Linux系统提供了额外的安全保护。安全性当然是把双刃剑;拥有一个非常安全的系统意味着某些事情可能无法按预期工作,其中包括一些eBPF程序。有关内核锁定的更多信息,请参阅“进一步阅读”部分。

作为一个有趣且有用的附带内容:Brendan Gregg曾经构建了一些强大的可视化工具,恰当地命名为Flame Graphs;它是对分析过的代码路径的栈追踪可视化,允许用户快速发现频繁运行的代码(具有放大或缩小调用路径的功能!)。我们建议你查阅“进一步阅读”部分,获取关于eBPF、BCC、bpftrace和Flame Graphs的链接。

我们这里只是触及了表面;eBPF工具如BCC和bpftrace确实是Linux操作系统中用于系统应用追踪和性能分析的现代强大方法(以及一般的可观察性工具)。其他有用的性能分析工具包括perf、Flame Graphs等。请花时间学习如何使用这些强大的工具!

好了,做得很好,让我们通过回顾你目前所学到的内容来总结这一部分!

进程虚拟地址空间(VAS)的10000英尺视图

在我们结束这一部分之前,重要的是退后一步,看看每个进程的完整虚拟地址空间(VAS)以及整个系统的总体情况——换句话说,拉远视角,看一下完整系统地址空间的“10000英尺视图”。这正是我们通过以下这幅相当大且详细的图(图6.8)来尝试呈现的内容,它是我们之前图6.3的扩展或超集。

对于那些正在阅读纸质书籍的读者(真棒!),我强烈建议你在此PDF文档中查看本书的彩色插图:packt.link/gbp/9781803…

除了你已经学到并看到的内容——进程用户空间段、用户和内核线程以及内核模式栈——别忘了内核中还有大量的其他元数据:每个线程的任务结构、内核线程、每个进程的内存描述符元数据结构、打开文件的元数据结构、进程间通信(IPC)元数据结构等等。它们都是内核虚拟地址空间的一部分,通常被称为内核段。

内核段的内容不仅仅是任务和栈。它还包含(显然!)静态的内核(核心)代码和数据——实际上,所有主要(和次要)的内核子系统、架构特定的代码等(我们在第4章《编写你的第一个内核模块 - 第1部分》中已经讨论过,位于“内核空间组件”部分)。

正如刚才提到的,以下图表展示了我们尝试将所有(或者说是大部分)这些信息集中呈现的努力:

image.png

呼,真是个大工程,不是吗?前面图中的内核段的大(红色)框(概念上)涵盖了核心内核代码和数据——主要的内核子系统;它还显示了每个线程的任务结构和内核模式栈。其余部分被视为非核心内容;包括设备驱动程序。(架构特定的代码可以说是核心代码;我们这里只是将其单独显示)。另外,不要让前面的信息让你感到困惑;现在只需要集中精力关注我们当前的重点——进程、线程、任务结构和栈。如果你仍然不清楚,请一定重新阅读前面的内容。

现在,让我们继续深入理解并学习如何引用每个存活线程的关键或“根”元数据结构——任务结构。

理解并访问内核任务结构

正如你现在已经学到的,每个用户空间和内核空间线程都由一个包含其所有属性的元数据结构在Linux内核中进行表示——任务结构。任务结构在内核代码中表示为:include/linux/sched.h:struct task_struct

要查看任何版本的内核代码,在线提供了一个出色的(可搜索的)系统:elixir.bootlin.com/linux/lates…。例如,对于6.1.25 LTS内核版本,以下是任务结构的定义:elixir.bootlin.com/linux/v6.1.…

不幸的是,它常被称为“进程描述符”,这往往会造成很多困惑!幸运的是,任务结构这个术语要好得多;它代表一个可运行的任务——实际上就是一个线程。

所以,我们可以得出结论:在Linux设计中,每个进程由一个或多个线程组成,每个线程都映射到一个名为任务结构(struct task_struct)的内核元数据结构。

任务结构是线程的“根”元数据结构——它封装了操作系统所需的该线程的所有信息。这包括关于其内存(段/映射设置、分页表、使用信息等)、CPU调度详情、当前打开的所有文件、凭证、能力位掩码、定时器、锁、异步I/O(AIO)上下文、硬件上下文信息、信号处理、IPC对象、资源限制、(可选的)审计、安全和分析信息,以及更多这样的细节。

图6.9是Linux内核任务结构的概念性表示,展示了它包含的大部分信息(元数据):

image.png

正如从图6.9中可以看到的,任务结构包含了关于系统中每个存活任务(线程)的大量信息(再次强调:这也包括内核线程)。我们在图6.9中以模块化的概念格式展示了此数据结构中封装的不同类型的属性。此外,如你所见,某些属性将在通过fork()(或pthread_create())创建子进程或子线程时继承,而一些属性则不会继承,并会被重置为默认值。

目前,至少可以说,内核能够识别一个任务是进程还是线程。我们稍后将演示一个内核模块(ch6/foreach/thrd_showall),它将揭示我们如何确定这一点(稍等,我们很快会展示!)。

现在,让我们开始更详细地了解任务结构中一些更重要的成员;继续阅读!

在这里,我只是想让你对内核任务结构有所了解;我们不会深入细节,因为目前不需要。你会发现,在本书的后续部分,我们会根据需要深入探讨特定领域。

查看任务结构

首先,回想一下,任务结构本质上是进程或线程的根数据结构——它包含了任务的所有属性(正如我们之前看到的)。因此,它相当大;强大的crash工具(用于分析Linux崩溃转储数据或调查活动系统)报告说,6.1.25内核版本下x86_64平台的任务结构大小为13,120字节,C语言的sizeof操作符也报告了相同的结果。

任务结构在include/linux/sched.h内核头文件中定义(这是一个相当关键的头文件)。在以下代码中,我们展示了其定义,并提醒我们这里只展示了它众多成员中的一部分。(此外,<<双角括号>>中的注释用来简要解释成员):

// include/linux/sched.h
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
    /*
     * 由于头文件混乱(见current_thread_info()),
     * 这必须是task_struct的第一个元素。
     */
    struct thread_info  thread_info;   << 重要的标志和状态位 >>
#endif
    unsigned int            __state;
    [...]
    void     *stack;        << 内核模式栈的位置 >>
    [...]
    << 以下成员与CPU调度有关;其中一些将在第10章和第11章关于CPU调度中讨论 >>
    int on_rq;
    int prio;
    int static_prio;
    int normal_prio;
    unsigned int rt_priority;
    struct sched_entity se;
    struct sched_rt_entity rt;
    struct sched_dl_entity dl;
    const struct sched_class *sched_class;
    [...]
    unsigned int policy;
    int nr_cpus_allowed;
    const cpumask_t *cpus_ptr;
    [...]

继续查看任务结构的下一个代码块,看到与内存管理(mm)、进程ID(PID)和线程组ID(TGID)值、凭证结构、信号处理、打开文件等相关的成员。再次声明,我们并不打算深入探讨(所有)这些细节;在本章的后续部分,可能会在本书的其他章节中根据需要重新访问它们:

    [...]
    struct mm_struct *mm;     << 内存管理信息 >>
    struct mm_struct *active_mm;
    [...]
    pid_t pid;    << 任务的PID和TGID值;下文解释 >>
    pid_t tgid;
    [...]
    /* 上下文切换计数: */
    unsigned long nvcsw;
    unsigned long nivcsw;
    [...]
    /* 有效(可覆盖的)任务凭证(COW): */
    const struct cred __rcu *cred;
    [...]
    /* 信号处理器: */
    struct signal_struct        *signal;
    struct sighand_struct __rcu        *sighand;
    sigset_t            blocked;
    sigset_t            real_blocked;
    /* 如果使用set_restore_sigmask()恢复: */
    sigset_t            saved_sigmask;
    struct sigpending        pending;
    [...]
    char comm[TASK_COMM_LEN];           << 任务名称 >>
    [...]
    /* 打开的文件信息: */
    struct files_struct *files;  << 指向“打开文件”数据结构的指针 >>
    [...]
#ifdef CONFIG_VMAP_STACK
    struct vm_struct *stack_vm_area;
#endif
    [...]
#ifdef CONFIG_SECURITY
    /* LSM模块用于访问限制: */
    void *security;
#endif
    [...]
    /* 该任务的CPU特定状态: */
    struct thread_struct thread;  << 任务硬件上下文的详细信息 >>
    [...]
};

重要的是要意识到,前面代码块中的struct task_struct成员是基于6.1.25内核源代码展示的;在其他内核版本中,成员可能会发生变化!当然,这也适用于整本书——所有代码/数据都是以6.1(.25) LTS Linux内核为基础展示的(该内核将维护至2026年12月,CIP SLTS 6.1内核则将一直维护到2033年8月)。

好了,现在你对任务结构的成员有了更好的了解,那么你究竟如何访问它及其各种成员呢?继续阅读。

通过 current 访问任务结构

你可能还记得,在我们之前运行的 countem.sh 脚本示例中(在“运行一个小脚本来查看当前存活的进程和线程数量”部分),我们发现系统中有总共514个存活的线程(包括用户线程和内核线程)。这意味着在内核内存中将有总共514个任务结构对象。

这些任务结构需要以某种方式进行组织,以便内核能够在需要时轻松访问它们。因此,所有任务结构对象在内核内存中都通过一个循环双向链表进行链接,这个链表叫做任务列表(task list)。这种组织方式是必需的,以便各种内核代码路径可以对它们进行遍历(通常是procfs代码,等等)。然而,想一想:当一个进程或线程正在运行内核代码时(在进程上下文中),它如何确定在内核内存中成百上千个任务结构中,哪个 task_struct 属于它呢?这其实是一个非平凡的任务。内核开发者已经演化出一种方法,以确保你能高效地找到当前运行内核代码的线程所代表的特定任务结构。这是通过一个名为 current 的宏来实现的。可以这么理解:

  • 查找 current 会返回指向正在运行内核代码的线程的 struct task_struct 的指针——换句话说,就是当前在处理器核心上运行的进程上下文(执行查找的那个核心!)。

current 类似(但当然并不完全等同)于面向对象语言中的 this 指针。

current 宏的实现非常依赖于架构/CPU。在这里,我们不会深入探讨所有复杂的实现细节。简而言之,这个实现经过精心设计,确保其高效(通常是通过 O(1) 算法)。例如,在一些具有许多通用寄存器的精简指令集计算机(RISC)架构(如PowerPC和AArch64处理器)上,专门有一个寄存器来保存 current 的值!

我建议你浏览内核源代码树,查看 current 的实现细节(在 arch/<arch>/asm/current.h 中)。在ARM32上,使用 O(1) 计算得出结果;在AArch64和PowerPC上,它被存储在寄存器中(因此查找速度非常快)。在x86_64架构上,current 的实现使用了一种无锁技术——每CPU变量——来存储 current(避免了使用昂贵的锁;我们将在第13章《内核同步 – 第2部分》中讲解每CPU变量部分)。在你的(模块)代码中使用 current 宏所需要做的就是包含 <linux/sched.h> 头文件。

我们可以使用 current 来解引用任务结构并从中提取信息;例如,可以如下查找进程(或线程)的 PID 和名称:

#include <linux/sched.h>
current->pid, current->comm

在下一部分,你将看到一个完整的内核模块,它遍历任务列表,打印出它遇到的每个任务结构的一些细节。

确定上下文

正如你现在已经知道的,内核代码运行在以下两种上下文之一:

  • 进程(或任务)上下文
  • 中断(原子)上下文

它们是互斥的——内核代码在任何给定的时间都只会运行在进程上下文(有时是原子)或中断上下文(始终是原子)中(我们很快会解释“原子”这个术语)。

那么,为什么能够确定内核或驱动程序代码运行的上下文很重要呢?内核中的黄金规则是,你不能在任何原子上下文中休眠(或阻塞);这样做会导致内核错误,通常会导致内核恐慌(kernel panic)并使系统锁死。

为什么呢?首先要意识到,休眠意味着上下文切换——在当前任务休眠的同时,CPU切换到运行另一个任务。因此,休眠意味着调用调度器代码并进行随后的上下文切换(我们将在第10章《CPU调度器 – 第1部分》和第11章《CPU调度器 – 第2部分》中详细讲解)。这就是任何阻塞API工作的方式。然而,当运行在原子上下文中时——例如硬件中断(以及软件中断,softirq),或者持有自旋锁时——必须完成工作而不阻塞、不休眠、也不让出CPU。这就是为什么使用“原子”这个词;它意味着必须运行到完成,不可被中断。

现在我们知道了这个规则——不要在原子上下文中休眠——问题来了:我如何知道我的代码路径是否(或者不)运行在原子上下文中呢?以下是如何轻松确定你的内核/驱动程序代码当前执行在哪个上下文中的方法:

#include <linux/preempt.h>
if (in_task())
    foo(); /* 运行在进程上下文中;通常可以安全地休眠或阻塞 */
else
    bar(); /* 运行在原子上下文中;不安全休眠或阻塞! */

in_task() 宏返回一个布尔值;如果返回True,说明你的代码正在运行在进程(或任务)上下文中,通常可以安全地休眠;如果返回 False,则表示你正在某种原子上下文中——可能是中断上下文——此时绝不可以休眠。

你可能会遇到 in_interrupt() 宏的使用——如果返回 True,表示你的代码在中断上下文中;如果返回 False,则不在中断上下文中。然而,现代代码的推荐做法是不依赖这个宏(因为底半部分(BH)禁用可能会干扰其工作)。因此,我们建议使用 in_task() 宏。

但请注意!这可能会有点复杂:尽管 in_task() 返回 True 确实意味着你的代码在进程上下文中,但这本身并不能保证它是否当前是原子的,或者是否可以安全休眠。例如,你可能在进程上下文中运行内核或驱动程序代码,但同时持有自旋锁(这是内核中常用的一种锁);在自旋锁的锁定和解锁之间的代码——所谓的临界区——必须以原子方式运行!这意味着,尽管你的代码可能在进程(或任务)上下文中,但如果它尝试调用任何可能导致阻塞(休眠)的API,依然会导致内核级错误!不要担心,锁机制在本书的最后两章中会有详细讨论。

另外要小心的是:(根据定义)current 宏的使用只有在运行在进程上下文中时才被认为是有效的。

好了,现在你已经学到了关于任务结构的有用背景信息,如何通过 current 宏访问它以及如何确定内核或驱动程序代码当前执行的上下文。接下来,让我们写一些内核模块代码,来检查一部分内核任务结构!

通过 ‘current’ 操作任务结构

在这一部分,我们将编写一个简单的内核模块,展示任务结构的几个成员。此外,我希望你思考一下:到底是谁在运行这个(或任何)内核模块的初始化和清理代码路径?根据我们学到的内容,答案并不是内核;如前所述,内核并没有一个总的“内核进程”……那么,谁在运行它?

这个问题在像Linux这样的单内核系统中应该很清楚:当用户空间的进程(或线程)发出系统调用时,它会切换到内核模式,并在进程上下文中运行内核(或模块)代码。所以,是的,它将是一个进程(或线程)。哪个进程或线程?我们编写的代码将揭示我们的模块的初始化和清理代码路径运行的进程上下文。(我们将在即将到来的《看到Linux操作系统是单内核》部分详细讨论这一点。)为了做到这一点,我们编写了一个 show_ctx() 函数,它使用 current 来访问任务结构的几个成员并显示它们的值。该函数将在初始化和清理方法中都被调用,代码如下:

由于可读性和空间限制,这里只显示了源代码的关键部分。本书的整个源代码树可以在其GitHub仓库中找到;我们建议你克隆并使用它:git clone https://github.com/PacktPublishing/Linux-Kernel-Programming_2E

/* 代码:ch6/current_affairs/current_affairs.c */
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
[ ... ]
#include <linux/sched.h>      /* current */
#include <linux/preempt.h>    /* in_task() */
#include <linux/cred.h>        /* current_{e}{u,g}id() */
#include <linux/uidgid.h>     /* {from,make}_kuid() */
[ ... ]
static void show_ctx(char *nm)
{
        /* 使用提供的辅助方法提取任务UID和EUID */
        unsigned int uid = from_kuid(&init_user_ns, current_uid());
        unsigned int euid = from_kuid(&init_user_ns, current_euid());
        pr_info("\n"); /* 显示模块和函数名(由于pr_fmt()!) */
        if (likely(in_task())) {
        pr_info("我们正在运行在进程上下文中 ::\n"
            " name        : %s\n"
            " PID            : %6d\n"
            " TGID          : %6d\n"
            " UID            : %6u\n"
            " EUID          : %6u (%s root)\n"
            " state          : %c\n"
            " current (指向我们的进程上下文的task_struct) :\n"
            "               0x%pK (0x%px)\n"
            " stack start : 0x%pK (0x%px)\n", 
            current->comm,
            /* 总是使用提供的辅助方法 */
        task_pid_nr(current), task_tgid_nr(current),
        /* ... 而不是直接查找:
         * current->pid, current->tgid,
         */
        uid, euid,
        (euid == 0 ? "有" : "没有"),
        task_state_to_char(current),
        /* 打印地址两次 - 通过 %pK 和 %px
         * 这里,默认情况下,值通常是相同的
         * 因为kptr_restrict == 1且我们是root。
         */
        current, current, current->stack, current->stack);
} else
    pr_alert("哇!在中断上下文中运行[这里不应该发生!]\n");

如上所示,正如前面代码片段中突出显示的那样,你可以看到(对于某些成员)我们可以直接解引用 current 指针来访问各种 task_struct 成员并显示它们(通过内核日志缓冲区)。

太好了!前面的代码片段确实向你展示了如何通过 current 直接访问一些 task_struct 成员;不过,并不是所有成员都可以或应该直接访问。实际上,内核提供了一些辅助方法来访问它们;我们将简要介绍这些方法。

内核内置的辅助方法和优化

在前面的代码示例中,我们使用了几个内核内置的辅助方法来提取任务结构的不同成员。这是推荐的方法;例如,我们使用 task_pid_nr() 来查看 PID 成员,而不是直接使用 current->pid。类似地,任务结构中的进程凭证(如有效UID(EUID)以及我们在前面代码中展示的类似成员)被抽象在 struct cred 中,访问它们的方法通过辅助例程提供,例如我们在前面代码中使用的 from_kuid() 辅助方法。以类似的方式,还有其他几个辅助方法;可以在 include/linux/sched.h 中查找它们,位于 struct task_struct 定义下方(链接:elixir.bootlin.com/linux/v6.1.…)。

为什么会这样?为什么不直接通过 current-><member-name> 来访问任务结构的成员?实际上,有多种原因:首先,访问可能需要获取锁(我们将在本书最后两章中详细讲解关于锁和同步的关键话题)。其次,可能存在更优化的访问方式;第三,可能不应该直接访问它们,因为它们只在任务所属的命名空间中才有意义,等等……事实上,内核命名空间和控制组(或cgroups)的关键概念构成了容器技术的基础;我们将在第11章《CPU调度器 – 第2部分》的“控制组介绍”部分详细讨论cgroups,稍微提及命名空间。

此外,如前面代码所示,我们可以通过使用 in_task() 宏轻松地判断内核代码(即我们的内核模块)是运行在进程上下文还是中断上下文中——如果返回True,说明处于进程(或任务)上下文;如果返回False,则不在进程上下文中。

你注意到了吗?我们使用了一个有趣的宏——likely()——来控制条件判断;它是什么意思?通常,它是与性能有关的;这个宏成为了编译器的 __builtin_expect 属性。它为编译器的分支预测提供提示,并优化传入CPU管道的指令序列,从而保持我们的代码在“快速路径”上(更多关于这个微优化的信息,可以在本章的“进一步阅读”部分找到)。你会经常看到内核代码使用 likely()unlikely() 宏,在开发者“知道”某个代码路径可能或不可能发生的情况下,分别使用这两个宏。

前面提到的 likely()unlikely() 宏是微优化的一个好例子,展示了Linux内核如何深度利用GCC或clang编译器。实际上,直到最近,Linux内核只能用GCC编译;但最近,使用clang编译内核已经成为现实(顺便提一下,现代的Android开源项目(AOSP)就是用clang编译的)。

好了,既然你已经理解了我们内核模块的 show_ctx() 函数的工作原理,为什么不亲自试试呢?

尝试运行内核模块以打印进程上下文信息

我们构建了我们的 current_affair.ko 内核模块(这里不展示构建输出),然后像往常一样通过 insmod 将其插入内核空间。接下来,让我们使用 dmesg 查看内核日志,然后使用 rmmod 卸载模块,再次使用 dmesg 查看。以下截图展示了这个过程:

image.png

显然,如图6.10所示,最初的进程上下文——运行我们内核模块的初始化内核代码的进程(或线程)——即 current_affairs.c:current_affairs_init() —— 是 insmod 进程(请查看输出:name : insmod)。同样,执行清理代码的 current_affairs.c:current_affairs_exit() 进程上下文是 rmmod 进程!

注意,在前面的图中,左列的时间戳([sec.usec])帮助我们理解 rmmod 调用发生在 insmod 之后大约6秒的时间点。

这个小的示范内核模块,可能比最初看到的要复杂。它实际上非常有助于理解Linux内核架构。接下来的部分将解释为什么是这样。

看到Linux操作系统是单内核的

除了使用 current 宏的练习之外,这个内核模块(ch6/current_affairs)的关键点之一就是明确展示了Linux操作系统单内核特性的一方面。在前面的代码中,我们看到,当我们对内核模块文件(current_affairs.ko)执行 insmod 操作时,它被插入到内核中,并且其初始化代码路径运行;一个非常重要的问题是:是谁运行的?哦,这个问题的答案可以通过查看输出得到:insmod 进程本身在进程上下文中运行了模块的初始化代码,从而证明了Linux内核的单内核特性!(同样,清理代码路径是由 rmmod 进程在进程上下文中运行的。)

请仔细注意:没有“内核”(或内核线程)执行内核模块的代码;是用户空间的进程(或线程)通过发出系统调用(回想一下,insmodrmmod 工具都会发出系统调用)切换到内核空间并执行内核模块的代码。这就是单内核系统的工作方式。

顺便提一下,微内核架构可能是与单内核架构完全相反的方法。它采用的是消息传递的方式(没有系统调用),其中消息从用户应用程序/进程传递到服务器进程,后者执行任务。它们又根据需要与小型微内核通过消息进行通信。做得好的话,它也提供了出色的性能;不过,显然,设计和实现一个基于微内核的操作系统并不容易(想想GNU Hurd)。当然,微内核操作系统确实存在:QNX、VxWorks和ENEA是做得很好的微内核操作系统的现实例子,而Tannenbaum的Minix则是(主要)课堂上的微内核例子。

回到单内核方式和Linux:这种内核代码执行方式(正如图6.10所示)就是我们所说的在进程上下文中运行,而不是在中断上下文中运行。然而,Linux内核并不严格是纯粹的单内核;如果是这样,它将是一个单一的硬编码内存块。相反,像所有现代操作系统一样,Linux支持模块化(通过LKM框架)。

顺便提一下,值得注意的是,你可以在内核空间中创建并运行内核线程;它们就像用户模式线程一样,被调度到CPU上运行,并在进程上下文中执行内核代码。

使用 printk 进行安全编码

在我们之前的内核模块演示(ch6/current_affairs/current_affairs.c)中,你应该注意到我们使用了 printk 并且使用了特定的 %pK 格式说明符。我们在这里重复相关的代码片段:

pr_info(
[...]
     " current (ptr to our process context's task_struct) :\n"
     "               0x%pK (0x%px)\n"
     " stack start : 0x%pK (0x%px)\n",
     [...]
     current, current,
     current->stack, current->stack); [...]

回想一下我们在第5章《编写你的第一个内核模块 - 第2部分》中,在“关于 kptr_restrict sysctl 的简短说明”部分的讨论,当打印地址时(首先,你真的不应该在生产环境中打印地址),我建议你不要使用常规的 %p(或 %px),而应该使用 %pK 格式说明符。这就是我们在前面的代码中所做的;这是出于安全原因,防止内核信息泄漏。在一个经过良好调整(安全)的系统中,%pK 将输出一个被清零的或哈希值,而不是实际的地址。为了显示这一点,我们还通过 0x%px 格式说明符显示了实际的内核地址,仅供对比。

有趣的是,在这里,%pK 似乎没有任何效果——两个地址打印出相同的值(如图6.10所示);这是可以预期的,因为 kptr_restrict sysctl 的默认值是1,并且我们正在以root权限运行。

在生产系统(无论是嵌入式系统还是其他)中,为了安全起见,建议将 kernel.kptr_restrict 设置为 1,或者更好的是设置为 2,从而清理指针,并将 kernel.dmesg_restrict 设置为 1(这样只有特权用户才能读取内核日志)。

现在,让我们继续讨论一些非常有趣的内容:在接下来的部分,你将学习如何遍历Linux内核的任务列表,从而实际上学习如何获取系统上每个存活的进程和/或线程的内核级信息。

遍历内核的任务列表

如前所述,所有任务结构都在内核内存中按链表形式组织,称为任务列表(task list),允许它们被遍历。这个列表数据结构已经演变成了常用的循环双向链表。事实上,操作这些列表的核心内核代码已经被提取到一个名为 list.h 的头文件中;它是一个广为人知的、期望用于所有基于列表的操作的工具。

include/linux/types.h:list_head 数据结构构成了基本的双向循环链表;如预期的那样,它包含两个指针,一个指向列表中的前一个成员,另一个指向下一个成员:

struct list_head {
        struct list_head *next, *prev;
};

你可以通过 include/linux/sched/signal.h 头文件中提供的宏轻松地遍历与任务相关的各种列表,适用于版本 >= 4.11;注意,对于4.10及更早版本的内核,这些宏位于 include/linux/sched.h 中。

现在,像往常一样,让我们通过实际操作来进行这个讨论!在接下来的部分中,我们将编写内核模块,通过两种方式遍历内核任务列表:

  1. 遍历内核任务列表并显示所有存活的进程(因此只显示每个进程的 main()T0 线程)。
  2. 遍历内核任务列表并显示所有存活的线程。

我们将展示后一种情况的详细代码视图。继续阅读,并确保自己动手尝试这两种方式!

遍历任务列表 I – 显示所有进程

内核提供了一个方便的例程,即 for_each_process() 宏,让你轻松地遍历任务列表中的每个进程:

// include/linux/sched/signal.h:
#define for_each_process(p) \
    for (p = &init_task ; (p = next_task(p)) != &init_task ; )

显然,这个宏扩展成了一个 for 循环,使我们能够遍历循环链表。init_task 是一个方便的“头”或起始点——它总是指向CPU核心的“空闲”线程的任务结构——当没有其他线程需要运行时,这个线程会被调度运行(在该核心上)。

注意,for_each_process() 宏特别设计为仅遍历每个进程的 main()T0 线程,而不会遍历其他(子线程或同级线程)。

以下是我们 ch6/foreach/prcs_showall 内核模块输出的开头部分的简短代码片段(图6.11,当在我们的 x86_64 Ubuntu 22.04 LTS 客户系统上运行,使用我们自定义的6.1.25内核时):

image.png

注意,在前面的代码片段(图6.11)中,每个进程的 TGID 和 PID 值始终相等,这证明了 for_each_process() 宏仅遍历每个进程的主线程(而不是每个线程)。我们将在下一部分解释有关 TGID/PID 成员的详细信息。

我们将把学习和尝试 ch6/foreach/prcs_showall 示例内核模块作为一个练习留给你。

遍历任务列表 II – 显示所有线程

为了遍历系统中每个存活的线程,我们使用 do_each_thread()while_each_thread() 这对辅助宏;我们编写了一个示例内核模块来完成这个任务(在这里:ch6/foreach/thrd_showall/)。

在深入代码之前,让我们先看一下它生成的部分输出(通过 dmesg)。因此,我们在我们的 x86_64 Ubuntu 22.04 LTS 客户系统上构建并使用 insmod 插入该模块。由于无法在此显示完整的输出(它太大了),我仅展示了输出的下半部分(图6.12)。此外,我们已复制了输出头部,以便你理解每列的含义:

Timestamp     TGID    PID   current          stack pointer   name      #threads

image.png

在图6.12中,注意到所有(内核模式)栈的起始地址(第五列)都以零结尾,即 0xffff .... .... .000,这意味着栈区域总是按页边界对齐(因为 0x1000 在十进制中是 4096)。这是因为内核模式栈的大小总是固定的,并且是系统页面大小的倍数(通常为 4 KB)。

按照惯例(就像 ps aux 输出的样式),在我们的内核模块中,我们安排了这样的显示方式:如果线程是内核线程,它的名称会显示在方括号中。

那么如何理解(相当奇怪的)内核线程名称(如图6.12中看到的“[ kworker/u12:2 ]”)呢?这个链接提供了答案:unix.stackexchange.com/a/152865

在继续阅读代码之前,我们首先需要稍微详细地研究任务结构中的 TGID 和 PID 成员。

区分进程和线程——TGID 和 PID

想一想:由于 Linux 内核使用唯一的任务结构(struct task_struct)来表示每个线程,而且其中的唯一成员是进程标识符(PID),这意味着在 Linux 内核中,每个线程都有一个唯一的 PID。这引出了一个问题,一个难题:在一个多线程的进程中,如何让这个进程的多个线程共享一个公共的 PID(因为这是 POSIX 线程标准 POSIX.1c 所要求的)?实际上,它们并没有共享 PID:在现代 Linux 中,所有多线程进程的每个线程(实际上是每个存活的线程)都有一个唯一的 PID!但等一下:这是不是违反了 Pthreads 标准?确实,一度(很久以前)Linux 并不符合该标准,这带来了移植等问题。

为了修复这个困扰用户空间标准的问题,Ingo Molnar(来自 Red Hat)在 2.5 内核系列中提出并合并了一个补丁。一个名为线程组标识符(Thread Group Identifier,TGID)的新成员被加入到任务结构中。

它是如何工作的呢:如果进程是单线程的,tgid 和 pid 值是相同的。如果是多线程进程,那么主线程的 tgid 值等于主线程的 pid 值;进程中的其他线程将继承主线程的 tgid 值,但会保留自己独立的 pid 值。总结起来,可以如下理解:

  • 单线程进程:main()(或 T0)线程:一个唯一的 PID 和 TGID == PID

  • 多线程进程:

    • main()(或 T0)线程:PID 和 TGID 相同:TGID == PID
    • main() 线程:多个唯一的 PIDs:TGID == 主线程的 PID

为了更好地理解这一点,让我们看一下前面截图中的实际示例。在图6.12中,注意到如果最后一列中出现正整数,它表示紧接其左侧的多线程进程中线程的数量。

例如,看看图6.12中突出显示的 dhclient 进程(通过圆角矩形标出);它的 TGID 和 PID 值为 2301,表示它的 main()(T0)线程,共有四个线程。那四个线程是什么呢?首先,当然,main() 线程是 dhclient,而显示在它下面的三个线程分别是,按名称,isc-worker0000isc-socketisc-timer。我们如何确保这一点呢?很简单:仔细查看图6.12中的第二列和第三列,它们分别表示 TGID 和 PID。如果它们相同,那就是进程的主线程;如果 TGID 重复,则说明该进程是多线程的,PID 值代表其工作线程或同级线程的唯一 PID。

实际上,通过通用的 GNU ps 命令,你完全可以在用户空间看到内核的 TGID/PID 表示,可以使用 -LA 选项(以及其他方式)来实现:

$ ps -LA
    PID   LWP  TTY          TIME  CMD
      1       1 ?        00:00:01 systemd
      2       2 ?        00:00:00 kthreadd
      3       3 ?        00:00:00 rcpu_gp
[...]
   2301    2301 ?        00:00:00 dhclient
   2301    2302 ?        00:00:00 isc-worker0000
   2301    2303 ?        00:00:00 isc-socket
   2301    2304 ?        00:00:00 isc-timer
 [...]

ps 标签的含义如下:

  • 第一列是 PID —— 但是请注意,这个值实际上表示内核中该任务的 tgid 成员!
  • 第二列是 LWP(字面意思是轻量级进程,或线程)—— 它表示内核中该任务的 pid 成员。

注意,只有使用 GNU ps 工具时,你才能传递参数(如 -LA)并查看线程;对于像 busybox 这样的轻量级实现的 ps,这做不到。不过,这不是问题:你始终可以通过 procfs 查找相同的详细信息。例如,在 /proc/2301/task 下,你将看到表示工作线程的子文件夹。猜猜怎么:这实际上也是 GNU ps 在后台的工作方式!

好了,现在继续代码部分吧...

遍历任务列表 III – 代码实现

现在让我们来看一下 thrd_showall 内核模块的相关代码:

// ch6/foreach/thrd_showall/thrd_showall.c
[...]
#include <linux/sched.h>     /* current */
#include <linux/version.h>
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 10, 0)
#include <linux/sched/signal.h>
#endif
[...]
static int showthrds(void)
{
    struct task_struct *p, *t;       /* 'p' : 进程指针;'t': 线程指针 */
    [...]
    disp_idle_thread();

关于前面代码的一些要点:

  • 我们使用 LINUX_VERSION_CODE 宏来有条件地包含所需的头文件。
  • 请暂时忽略内核级锁定操作(我们使用的 task_{un}lock() 和/或 RCPU 例程);我们将在最后两章中讨论这个话题。
  • 一个关键点:当操作(甚至读取)任务结构的成员时,我们需要通知内核的其他部分(以防它突然将其释放);这通过使用 get_task_struct() 辅助函数获取对该结构的引用,并通过 put_task_struct() 释放引用来完成。

不要忘记 CPU 空闲线程!每个 CPU 核心都有一个专用的空闲线程(名为 swapper/n,当没有其他线程需要运行时,该线程会被调度运行,其中 n 是核心编号,从 0 开始)。不幸的是,我们运行的 do ... while 循环并不会从这个线程开始(ps 也永远不会显示它)。因此,我们包含了一个小例程来显示它,利用了空闲线程的任务结构在 init_task 中硬编码并导出的事实(一个细节:init_task 总是指向第一个 CPU(核心 #0)空闲线程的任务结构)。

让我们继续遍历每个存活的线程,我们需要使用一对宏,形成一个循环;do_each_thread(p,t) { ... } while_each_thread(p,t) 这对宏正是执行此操作,允许我们遍历系统上每个存活的线程。

从 6.6 版本的内核(截至目前非常新的版本)开始,do_each_thread()/while_each_thread() 风格的宏已被移除,取而代之的是更简洁、可读的 for_each_process_thread() 宏。我们的代码通过 LINUX_VERSION_CODEKERNEL_VERSION 宏考虑到了这一点,从而提高了可移植性。

以下是代码的相关部分:

#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0)
    do_each_thread(p, t) {     /* 'p' : 进程指针;'t': 线程指针 */
#else
    for_each_process_thread(p, t) {   /* 'p' : 进程指针;'t': 线程指针 */
#endif
         get_task_struct(t);    /* 获取任务结构的引用 */
        task_lock(t);
        snprintf_lkp(tmp1, TMPMAX-1, "%8d %8d  0x%px 0x%px",
                  p->tgid, t->pid, t, t->stack);
 
        [...]            << 这部分我们将在下文的注释中详细说明 >>
    total++;
    memset(tmp1, 0, sizeof(tmp1));       << 清理 >>
    memset(tmp2, 0, sizeof(tmp2));
    memset(tmp3, 0, sizeof(tmp3));
    task_unlock(t);
       
        memset(tmp, 0, sizeof(tmp));
        put_task_struct(t);    /* 释放任务结构的引用 */
#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 6, 0)
    } while_each_thread(p, t);
#else
    }
#endif
    return total;
}

代码解析

根据前面代码块的描述,do_each_thread() { ... } while_each_thread() 宏对形成了一个 do-while 循环,允许我们遍历系统上每个存活的线程。以下是代码片段的进一步细节:

  • 我们使用了几个临时变量(命名为 tmp1tmp2tmp3)来获取并设置所需的数据项,然后在每次循环迭代时打印它们。
  • 为了设置我们要报告的数据,C程序员通常使用 sprintf() 例程;但请注意,这样做可能会成为安全问题(由于著名的缓冲区溢出漏洞和相关攻击)。因此,我们已经学会了使用 snprintf() 来避免此类问题。
  • 当然,我们可以进一步改进:我们编写了一个名为 snprintf_lkp() 的小包装函数,并在我们的 klib.c '库' 源文件中定义了它。它显式地检查并警告我们是否存在缓冲区溢出问题(进一步增强了我们的安全性)。
  • 获取 TGID、PID、task_struct 和栈起始地址非常简单——这里我们保持简单,直接使用 current 来解引用它们(当然,你也可以使用本章之前看到的更复杂的内核辅助方法;但在这里,我们希望保持简单)。
  • 此外,注意到这里我们故意没有使用(更安全的)%pK printk 格式说明符,而是使用了通用的 %px 说明符,以显示任务结构和内核模式栈的实际内核虚拟地址(生产环境中请勿这么做)。
  • 在遍历过程中,我们根据需要进行清理(递增线程计数器、将临时缓冲区重置为 NULL 等)。
  • 最后,我们返回已遍历的线程总数。

在接下来的代码块中,我们解释了前面代码块中故意省略的部分。我们获取线程的名称,如果是内核线程,则将其名称显示在方括号中。我们还查询进程中的线程数量。解释如下:

        if (!p->mm)         // 内核线程?
            snprintf_lkp(tmp2, TMPMAX-1, " [%16s]", t->comm);
        else
            snprintf_lkp(tmp2, TMPMAX-1, "  %16s ", t->comm);
    
        /* 是否为多线程进程的“主”线程?
         * 我们通过查看 (a) 它是用户空间线程,
         * (b) 它的 TGID == PID,
         * (c) 进程中有多个线程来进行检查。
         * 如果是,则显示进程中线程的总数。
         */
        nr_thrds = get_nr_threads(g);
        if (p->mm && (p->tgid == t->pid) && (nr_thrds > 1))
            snprintf_lkp(tmp3, TMPMAX-1, " %3d", nr_thrds);
      pr_info("%s%s%s\n", tmp1, tmp2, tmp3);
        [...]

代码解析

  • 内核线程(kthread)没有用户空间映射。main() 线程的 current->mm 成员是指向 mm_struct 类型结构的指针,表示整个进程的用户空间映射;如果为 NULL,说明这是一个内核线程(因为内核线程没有用户空间映射)。我们根据这一点检查并打印名称。
  • 我们也打印线程的名称(通过查找任务结构中的 comm 成员)。你可能会问,为什么我们在这里不使用 get_task_comm() 辅助例程来获取任务名称?简短的答案是,这样会导致(自)死锁!我们将在后续的内核同步章节中详细探讨这个问题(以及如何检测和避免它)。
  • 我们通过 get_nr_threads() 宏方便地获取给定进程中的线程数量;其余内容在前面代码块的注释中已有说明。
  • 最后,代码通过一次 printk 调用将临时字符串输出到内核日志中。

太好了!到此,我们已经完成了对 Linux 内核内部和架构的讨论,重点关注进程、线程及其栈。

总结

在本章中,我们涵盖了内核内部的关键方面,这将帮助你作为内核模块或设备驱动程序的作者更好、更深入地理解操作系统的内部工作原理。我们详细探讨了进程及其线程和栈的组织结构及相互关系(包括用户空间和内核空间)。我们还研究了内核 task_struct 元数据结构,并学习了如何通过自定义编写的内核模块以不同的方式遍历任务列表。

虽然这可能不太显而易见,但事实上,理解这些内核内部的细节是成为一名经验丰富的内核(和/或设备驱动)开发者的必要步骤。本章的内容将帮助你调试许多系统编程场景,并为我们深入探索 Linux 内核(特别是内存管理)的奠定基础。

接下来的章节和随后的几章至关重要:我们将涵盖你需要了解的关于内存管理内部的深刻和复杂的主题。我建议你先消化本章的内容,浏览相关的进一步阅读链接,完成练习(和问题部分),然后进入下一章!

问题

在本章结束时,这里有一系列问题,供你测试自己对本章材料的理解:github.com/PacktPublis…。你可以在本书的 GitHub 仓库中找到部分问题的答案:github.com/PacktPublis…

进一步阅读

为了帮助你深入研究这个主题,书中提供了详细的在线参考资料和链接(有时甚至是书籍),这些资料在本书的 GitHub 仓库中的“进一步阅读”文档中列出。你可以在此查看进一步阅读文档:github.com/PacktPublis…