Linux内核编程——编写你的第一个内核模块(一)

487 阅读46分钟

在前两章中,你已经学习了如何获取内核源码树、配置并构建内核(针对 x86 平台)。现在,欢迎进入 Linux 内核开发的一个基础领域——可加载内核模块(LKM)框架的学习之旅,了解如何使用它,无论是作为模块用户还是模块作者,这通常是内核或设备驱动程序员的工作。这一主题内容广泛,因此分为两章来讲解——本章和下一章。

在本章中,我们将首先快速了解 Linux 内核架构的基础知识,以帮助我们理解 LKM 框架。然后,我们会探讨为什么内核模块是有用的,接着编写、构建并运行我们第一个简单的 "Hello, world" 内核模块。我们将学习如何将消息写入内核日志,并理解并使用 LKM 的 Makefile。在本章结束时,你将学会 Linux 内核架构和 LKM 框架的基础知识,并应用它编写一个简单而完整的内核代码。

本章将涵盖以下内容:

  • 理解内核架构 – 第 1 部分
  • 探索可加载内核模块(LKM)
  • 编写我们的第一个内核模块
  • 内核模块的常见操作
  • 理解内核日志记录和 printk
  • 理解内核模块 Makefile 的基础知识

技术要求

如果你已经仔细遵循了在线章节《内核工作区设置》(Kernel Workspace Setup)的步骤,那么接下来的一部分技术前提条件已经被满足。(第一章还提到了各种有用的开源工具和项目,我强烈建议你至少浏览一遍。)为了方便起见,我们在此总结一些关键点。

要在 Linux 发行版(或自定义系统)上构建和使用外部(或树外)内核模块,你需要:

  • 内核必须启用模块支持(CONFIG_MODULES=y)。

  • 至少需要安装以下两个组件:

    1. 工具链:工具链包括编译器、汇编器、链接器/加载器、C 库以及其他各种工具。如果为本地系统构建(目前假设是这种情况),那么任何现代 Linux 发行版都会预装本地工具链。如果没有,只需安装你发行版的 GCC 包即可;在基于 Ubuntu 或 Debian 的 Linux 系统上,可以使用以下命令安装:
    sudo apt install gcc
    

    2. 内核头文件:这些头文件将在 LKM 编译过程中使用。实际上,你需要安装一个包含内核头文件及其他必需部分(如内核 Makefile)的软件包。同样,任何现代 Linux 发行版都会预装内核头文件。如果没有预装(你可以使用 dpkg 工具进行检查,如下所示),只需安装适用于你的发行版的内核头文件包即可;在基于 Ubuntu 或 Debian 的 Linux 系统上,使用以下命令:

    sudo apt install linux-headers-generic
    dpkg -l | grep linux-headers | awk '{print $1, $2}'
    

    输出示例:

    ii linux-headers-5.19.0-45-generic
    ii linux-headers-generic-hwe-22.04
    

    第二个命令使用 dpkg 工具来验证是否已经安装了 linux-headers 软件包。

    在某些发行版上,该软件包可能被命名为 kernel-headers-<版本号>。如果直接在树莓派上进行开发,可以安装相关的内核头文件包,名称为 raspberrypi-kernel-headers

  • 如果你无法构建模块,可以尝试通过在内核源码树的根目录中运行 make modules_prepare 来准备外部模块构建。在典型的现代发行版中,通常不需要这样做,模块构建应该能正常工作。

本书的完整源码树可以从其 GitHub 仓库获取,网址为:Linux Kernel Programming GitHub Repository。你可以使用以下命令克隆仓库:

git clone \
  https://github.com/PacktPublishing/Linux-Kernel-Programming_2E.git

本章的代码位于对应章节目录下,即 ch4/

理解内核架构 – 第 1 部分

在本节中,我们开始深入理解 Linux 内核。具体来说,我们将探讨用户空间和内核空间的概念,以及组成内核的主要子系统和各个组件。目前,这些信息将以较高的抽象层次进行讲解,并刻意简要处理。我们将在第 6 章《内核内部基础知识——进程与线程》中更加深入地探讨内核的架构。

用户空间与内核空间

现代微处理器至少支持两种特权级别的代码执行。例如,Intel/AMD 的 x86[-64] 处理器系列支持四个特权级别(称为“环级”),AArch32(ARM-32)微处理器系列支持多达七种模式(ARM 称为执行模式,其中六种是特权模式,一种是非特权模式),AArch64(ARM-64/ARMv8)微处理器系列支持四个异常级别(EL0 到 EL3,EL0 权限最低,EL3 权限最高)。

关键点在于,为了确保平台的安全性和稳定性,所有运行在这些处理器上的现代操作系统都会使用至少两种特权级别(或模式)来执行代码,从而将虚拟地址空间(VAS)划分为两个明确区分的(虚拟)地址空间:

  • 用户空间:用于在非特权用户模式下运行应用程序。所有的应用程序(进程和线程)都会在这个空间中以这种权限执行。现在,你可能正在使用浏览器、编辑器、PDF 阅读器、终端、电子邮件客户端等应用程序。它们都是进程和线程,在 Linux 上,它们都在用户空间中、以非特权的用户模式执行。我们将很快讨论“特权”和“非特权”的确切含义。
  • 内核空间:用于内核及其所有组件在特权模式下运行——即内核模式。这是操作系统及其所有内容(如驱动程序、网络、I/O 等,包括内核模块)的领域。它们都具有操作系统的特权,实际上可以执行任何操作!需要特别注意的是,这种特权级别是硬件功能,它与是否以 root 身份运行并不相同(后者是纯粹的软件层次概念);在许多情况下,以内核特权运行可以被认为实际上相当于以 root 身份运行。

下图展示了这种基本架构:

image.png

以下是一些关于 Linux 系统架构的细节,请继续阅读。

库和系统调用 API

用户空间应用程序通常依赖于应用程序编程接口(API)来完成工作。库本质上是 API 的集合或归档,它允许你使用标准化、编写良好且经过测试的接口(从而享受不必重新发明轮子、移植性和标准化等好处)。Linux 系统中有许多库,在企业级系统中甚至可能有数百个库。这些库中,所有用户模式的 Linux 应用程序(可执行文件)都会自动链接到一个非常重要且经常使用的库:glibc —— GNU 标准 C 库,如你将会了解到的。然而,库仅在用户模式下可用;内核并不使用这些用户模式库(在下一章会详细讨论这个内容)。

库 API 的示例包括众所周知的 printf(3)scanf(3)strcmp(3)malloc(3)free(3)。(请回顾在线章节《内核工作区设置》中“使用 Linux 手册页”部分的内容。)

现在,关键的一点是:如果用户和内核位于不同的地址空间,并且拥有不同的特权级别,那么用户进程——正如我们刚刚了解到的,它们被限制在用户空间——如何访问内核呢?简短的回答是:通过系统调用。系统调用是一种特殊的 API,因为它是用户空间进程(或线程)访问内核的唯一合法(同步)方式。换句话说,系统调用是进入内核空间的唯一合法入口。

系统调用具有从非特权用户模式切换到特权内核模式的内置能力(有关这方面的更多内容,请参阅第 6 章《内核内部基础知识——进程与线程》中的“进程和中断上下文”部分)。系统调用的示例包括 fork(2)execve(2)open(2)read(2)write(2)socket(2)accept(2)chmod(2) 等等。

你可以在在线手册页中查找所有库和系统调用 API:

这里强调的要点是,用户应用程序(进程和线程)与内核的通信只能通过系统调用进行——这就是它们的接口。在本书中,我们不会进一步探讨这些细节。如果你对此有兴趣,建议参考我之前的著作《Hands-On System Programming with Linux》(Packt),特别是第 1 章《Linux 系统架构》。

本书专注于内核开发,接下来我们将开始了解内核的各个组成部分。

内核空间组件

本书自然完全专注于内核空间。如今,Linux 内核非常庞大且复杂。其内部由几个主要子系统和多个组件组成。对内核子系统和组件的广泛分类包括以下内容:

  • 核心内核:该部分代码处理任何现代操作系统的核心任务,包括(用户和内核)进程和线程的创建/销毁、CPU 调度、同步原语、信号处理、定时器、中断处理、命名空间、cgroups、模块支持、加密等。
  • 内存管理(MM) :负责处理所有与内存相关的工作,包括内核和进程虚拟地址空间(VAS)的设置和维护。
  • VFS(文件系统支持) :虚拟文件系统切换(VFS)是 Linux 内核中实际文件系统(如 ext[2|4]、vfat、ntfs、msdos、iso9660、f2fs、ufs 等)的抽象层。
  • 块 I/O:实现实际文件 I/O 的代码路径,从文件系统到块设备驱动程序及其之间的所有内容(确实相当复杂!)都包含在此。
  • 网络协议栈:Linux 以其精确实现众所周知(以及不太知名)的网络协议而闻名,各层模型中的协议严格遵循 RFC 标准,其中 TCP/IP 可能是最著名的。
  • 进程间通信(IPC)支持:这里实现了 IPC 机制;Linux 支持消息队列、共享内存、信号量(包括旧的 SysV 和新的 POSIX 信号量)以及其他 IPC 机制。
  • 声音支持:所有实现音频功能的代码都在这里,从固件到驱动程序再到编解码器。
  • 虚拟化支持:Linux 在大大小小的云服务提供商中非常受欢迎,其中一个重要原因是其高质量、低开销的虚拟化引擎——基于内核的虚拟机(KVM)。

这些构成了主要的内核子系统,除此之外还有:

  • 与架构(CPU)相关的代码
  • 内核初始化
  • 安全框架
  • 各类设备驱动程序

回想一下,在第 2 章《从源码构建 6.x Linux 内核 – 第 1 部分》中,"内核源码树简要导览"部分展示了与主要子系统和其他组件相对应的内核源码树布局。

众所周知,Linux 内核遵循单体内核架构(相对于微内核架构)。基本上,单体设计意味着所有内核组件(如本节提到的)都位于内核虚拟地址空间(或内核段)中并共享它。下图清楚地展示了这一点:

image.png

你还需要了解的另一个事实是,这些地址空间当然是虚拟地址空间,而不是物理地址空间。内核通过利用硬件模块(如内存管理单元 (MMU) 和处理器外加转换后备缓冲 (TLB) 缓存)来提高效率,在页面粒度级别将虚拟页面映射到物理页面框架。它通过使用主内核分页表将内核虚拟页面映射到物理页面框架(RAM),并且对于每个存活的用户空间进程,它通过每个进程的单独分页表将进程的(用户)虚拟页面映射到物理页面框架。

在第 6 章《内核内部基础知识——进程与线程》(以及随后的更多章节)中,将深入探讨内核和内存管理架构及其内部机制的基本内容。

现在我们已经对用户和内核的虚拟地址空间有了基本的理解,接下来让我们进入 LKM(可加载内核模块)框架的学习旅程。

探索 LKM(可加载内核模块)

内核模块是一种无需在内核源码树和静态内核镜像中进行工作,就能提供内核级功能的手段。

设想这样一个场景:你需要为 Linux 内核添加某种支持功能,例如一个新的设备驱动程序,以便使用某个特定的硬件外设芯片,或添加一个新的文件系统,或一个新的 I/O 调度程序。实现这一目标的一个明显方法是:更新内核源码树,加入新的代码,进行配置、编译、测试并部署它。

虽然看起来这很直接,但实际上这是一个繁琐的过程——每次代码的修改,无论多么细微,都需要重新编译内核镜像并重启系统以进行测试。必须有一种更简洁、更容易的方法——实际上,LKM 框架就是这样的一个解决方案。

LKM 框架

LKM 框架是一种将一段内核代码编译为“内核外”代码(通常称为“out-of-tree”代码),从而在一定程度上与内核保持独立性,然后将生成的“模块对象”插入到内核内存(内核虚拟地址空间,VAS)中,在运行时执行其任务,再从内核内存中移除(或拔出)它的机制。(需要注意的是,LKM 框架也可以用于生成“内核内”模块,正如我们在编译内核时所做的那样。此处我们重点讨论“内核外”模块。)

内核模块的源码通常由一个或多个 C 源文件、头文件以及一个 Makefile 构成,并通过 make 命令将其编译为一个内核模块。内核模块本身是一个二进制对象文件,而非二进制可执行文件。在 Linux 2.4 及更早的版本中,内核模块的文件名后缀为 .o;而在较新的 2.6 及以后版本中,则使用 .ko(表示内核对象)。编译完成后,你可以在运行时将这个 .ko 文件(即内核模块)插入到正在运行的内核中,从而将其有效地成为内核的一部分。

需要注意的是,并非所有内核功能都可以通过 LKM 框架来实现。一些核心功能,例如核心 CPU 调度器代码、内存管理、信号处理、定时器、以及中断管理代码路径,或者与平台相关的驱动程序(如针脚控制器、时钟等),只能在内核内部开发。此外,内核模块只能访问部分内核 API 和数据变量,这一点我们会在后面详细讲解。

你可能会问:如何在运行时将这个模块对象插入内核?简单来说,答案是通过 insmod 工具来完成的。当前我们暂时略过详细的步骤(将在接下来的“运行内核模块”部分详细说明)。下图提供了首先编译内核模块,然后将其插入到内核内存中的概览:

image.png

内核模块被加载到内核内存中并驻留在那里,即位于内核虚拟地址空间(VAS)中(图 4.3 的下半部分),这是由内核为其分配的特定区域。请注意,内核模块本质上是内核代码,并以内核特权运行。

这样一来,作为内核(或驱动程序)开发人员,你就不必每次修改代码后都重新配置、重建内核并重启系统。你只需编辑内核模块的代码,重新编译它,移除内存中的旧版本(如果存在),然后插入新的版本。这样可以节省时间并提高工作效率。

尽管如此,在现实世界中,即使是内核模块也有可能导致系统崩溃,进而需要重启系统(这也是在隔离的虚拟机中运行的一个优势所在)。

内核模块的另一个优势是,它们有助于动态产品配置。例如,内核模块可以设计为在不同的价格点提供不同的功能;生成嵌入式产品最终镜像的脚本可以根据客户愿意支付的价格安装一组特定的内核模块。这里还有一个关于调试或故障排除场景的例子:内核模块可以用于动态生成现有产品的诊断和调试日志。诸如 Kprobes 等技术正是用于此类应用场景(我的《Linux Kernel Debugging》一书对此有详细讲解)。

实际上,LKM 框架为我们提供了一种动态扩展内核功能的方法,允许我们将运行中的代码插入到内核内存中(或从中移除)。这种随意插入和拔出内核功能的能力使我们意识到,Linux 内核不仅仅是纯粹的单体架构,它也是模块化的。

内核模块在内核源码树中的作用

实际上,内核模块对象并不是完全陌生的。在第 3 章《从源码构建 6.x Linux 内核——第 2 部分》中,我们在构建内核的过程中构建了内核模块并将它们安装了起来。

回想一下,这些内核模块是内核源码的一部分,通过在三态内核菜单配置提示中选择 M 配置为模块。它们会被安装在 /lib/modules/$(uname -r)/ 目录下。因此,我们可以通过以下操作查看当前运行的 x86_64 Ubuntu 22.04 LTS 客户端内核下安装的内核模块:

$ lsb_release -a 2>/dev/null |grep Description
Description:    Ubuntu 22.04.2 LTS
$ uname -r
5.19.0-45-generic
$ find /lib/modules/$(uname -r)/ -name "*.ko" | wc -l
6189

好吧,Canonical 和其他开发者们确实很忙!超过六千个内核模块...想想看,这很有意义:发行版的维护者无法提前知道用户最终会使用什么硬件外设(尤其是在像 x86 PC 这样通用的计算机上)。内核模块是一种支持大量硬件的便捷方式,而无需将内核镜像文件(例如 bzImage 或 Image 文件)无限膨胀。

我们 Ubuntu Linux 系统上已安装的内核模块位于 /lib/modules/$(uname -r)/kernel 目录下,如下所示:

$ ls /lib/modules/5.19.0-45-generic/kernel/
arch/     block/   crypto/   drivers/       fs/  kernel/  lib/  mm/  net/
samples/  sound/   ubuntu/   v4l2loopback/  zfs/
$ ls /lib/modules/6.1.25-lkp-kernel/kernel/
arch/  crypto/  drivers/  fs/  lib/  net/  sound/

在发行版内核(Ubuntu 22.04 LTS 运行 5.19.0-45-generic 内核)下的 /lib/modules/$(uname -r)/kernel/ 目录中,我们可以看到很多子文件夹,里面包含了几千个内核模块。相比之下,我们自己构建的内核(详情请参阅第 2 章和第 3 章)包含的模块要少得多。你可能还记得我们在第 2 章中讨论过,我们有意使用了 localmodconfig 目标来保持构建相对较小和快速。因此,例如,我们的定制 6.1.25 内核只构建了大约 70 个内核模块。

设备驱动程序是大量使用内核模块的领域之一。作为示例,让我们看看一个被设计为内核模块的网络设备驱动程序。你可以在发行版内核的 kernel/drivers/net/ethernet 文件夹中找到多个网络设备驱动程序(一些名字可能相当熟悉)。

image.png

在许多基于Intel的笔记本电脑上,Intel 1GbE网卡(NIC)以太网适配器非常流行。用于驱动它的网络设备驱动程序叫做 e1000 驱动程序(较新的系统可能使用更晚的型号,因此对应的模块驱动程序是 e1000e)。

要查看当前加载到内核内存中的所有内核模块,可以使用 lsmod 工具(可以理解为“列出模块”)。在我们运行在x86_64主机笔记本上的 x86_64 Ubuntu 22.04 客户端中,可以看到该驱动程序确实在使用:

$ lsmod | grep e1000
e1000 159744 0

对我们来说,重要的是可以看到 e1000 驱动程序是一个内核模块!那我们如何获得这个内核模块的更多信息呢?这可以通过使用 modinfo 工具轻松完成(为了便于阅读,我们对其详细输出进行了截取):

$ ls -l /lib/modules/5.19.0-45-generic/kernel/drivers/net/ethernet/intel/e1000
total 364
-rw-r--r-- 1 root root 372185 Jun 7 19:53 e1000.ko

$ modinfo /lib/modules/5.19.0-45-generic/kernel/drivers/net/ethernet/intel/e1000/e1000.ko 
filename:        /lib/modules/5.19.0-45-generic/kernel/drivers/net/ethernet/intel/e1000/e1000.ko
license:         GPL v2
description:     Intel(R) PRO/1000 Network Driver
author:          Intel Corporation, <linux.nics@intel.com>
srcversion:      F35F102C5522A6614A9D65C
alias:           pci:v00008086d00002E6Esv*sd*bc*sc*i*
alias:           pci:v00008086d000010B5sv*sd*bc*sc*i*
[ … ]
intree:          Y
name:            e1000
vermagic:        5.19.0-45-generic SMP preempt mod_unload modversions
sig_id:          PKCS#7
signer:          Build time autogenerated kernel key
[ … ]
parm:            SmartPowerDownEnable:Enable PHY smart power down (array of int)
parm:            copybreak:Maximum size of packet that is copied to a new buffer on receive (uint)
parm:            debug:Debug level (0=none,...,16=all) (int)

modinfo 工具允许我们查看内核模块的二进制映像,并提取关于它的一些详细信息。在下一节中,我们会进一步讨论如何使用 modinfo

你可能会发现内核模块文件是压缩的(例如 e1000.ko.xz);这是一项功能,而非错误(后续会详细介绍)。另一种获取系统有用信息(包括当前加载的内核模块信息)的方法是使用 systool 工具。对于已安装的内核模块(如何安装内核模块的详细信息会在下一章的 “系统启动时自动加载模块” 中讨论),执行 systool -m <module-name> -v 可以揭示有关它的详细信息。你可以查看 systool(1) 的手册页了解其使用详情。

总之,内核模块已经成为构建和分发某些类型内核组件的实际方法,其中设备驱动程序可能是最常见的使用场景。其他用途还包括但不限于文件系统、网络防火墙、数据包嗅探器以及自定义内核代码。

因此,如果你想学习如何编写 Linux 设备驱动程序、文件系统或防火墙,强烈建议你首先学习如何编写内核模块,从而利用内核的强大 LKM 框架。这正是我们接下来要做的事情。

编写我们的第一个内核模块

在介绍一种新的编程语言或主题时,模仿经典的 Hello, world 程序作为第一个代码示例已成为广泛接受的计算机编程传统。我很乐意遵循这一受人尊敬的传统,以介绍Linux内核强大的可加载内核模块(LKM)框架。在本节中,你将学习编写一个简单LKM的步骤,并且我们将详细解释代码。

介绍我们的 Hello, world LKM C代码

不再赘述,下面是一些简单的 Hello, world C代码,按Linux内核的LKM框架实现:

由于可读性和空间限制,这里仅展示了大部分源码的关键部分。要查看完整源码(包括所有注释),构建并运行它,你可以在本书的GitHub代码库中找到完整的源码树,访问地址为:github.com/PacktPublis…。我们希望你克隆并使用它:

git clone https://github.com/PacktPublishing/Linux-Kernel-Programming_2E.git

查看代码文件:

$ cat <LKP2E_src>/ch4/helloworld_lkm/helloworld_lkm.c

代码解析

以下子节解释了前面 Hello, world 内核模块 C 代码的几乎每一行。虽然程序看起来非常小且简单,但围绕它的LKM框架还有很多内容需要理解。本章的其余部分将详细介绍这一内容。我强烈建议你花时间阅读并理解这些基础知识。这将极大地帮助你应对以后可能难以调试的情况。

内核头文件

代码中的第一件事是使用 #include 显然包含了几个头文件。与用户空间C应用程序开发不同,这些是内核头文件(如技术需求部分所述)。回想一下第3章《从源码构建6.x Linux内核(第2部分)》中提到的,内核模块安装在特定的具有写权限的根目录下。

让我们再次检查一下(这里我们在使用 5.19.0-45-generic 发行版内核的 x86_64 Ubuntu 客户机上运行):

$ ls -l /lib/modules/$(uname -r)
total 6712
lrwxrwxrwx  1 root root      40 Jun  7 19:53 build -> /usr/src/linux-headers-5.19.0-45-generic
drwxr-xr-x  2 root root    4096 Jun 23 09:47 initrd
[ … ]

请注意这里的符号链接 build,它指向系统上的内核头文件位置。在前面的代码块中,你可以看到它位于 /usr/src/linux-headers-5.19.0-45-generic/ 目录下!稍后你会看到,我们将把此信息提供给用于构建内核模块的 Makefile。(有些系统还有一个名为 source 的类似软链接。)

kernel-headerslinux-headers 包将有限的内核源代码树解压缩到系统上,通常在 /usr/src/ 下。然而,这个内核代码库并不完整,因此我们称其为有限的源代码树。这是因为构建模块不需要完整的内核源代码树,只需要必要的组件(头文件、Makefile 等)即可打包和解压。

我们 Hello, world 内核模块中的第一行代码是:

#include <linux/init.h>

编译器通过在 /lib/modules/$(uname -r)/build/include/ 下搜索前面提到的内核头文件来解析此行。因此,通过跟随 build 软链接,我们可以看到它最终选择了此头文件:

$ ls -l /usr/src/linux-headers-5.19.0-45-generic/include/linux/init.h 
-rw-r--r-- 1 root root 11963 Aug  1  2022 /usr/src/linux-headers-5.19.0-45-generic/include/linux/init.h

对于模块源代码中包含的其他内核头文件,也是如此。

模块宏

接下来,我们有一些形式为 MODULE_FOO() 的模块宏(俗称“模块信息”)。大多数都非常直观:

  • MODULE_AUTHOR():指定内核模块的作者
  • MODULE_DESCRIPTION():简要描述此LKM的功能或目的
  • MODULE_LICENSE():指定发布此内核模块的许可证
  • MODULE_VERSION():指定内核模块的(本地)版本字符串

如果没有源代码,如何将这些信息传达给最终用户(或客户)?正是 modinfo 实用程序提供了这些信息!这些宏及其信息可能看起来微不足道,但在项目和产品中却非常重要。

例如,供应商可以通过在所有已安装内核模块上使用 modinfo 输出中的 grep 命令,来确认所运行代码的开源许可证。(这些是基本的模块宏,后续我们将涵盖更多内容。)

入口和退出点

永远不要忘记,内核模块毕竟是运行在内核权限下的内核代码。它不是应用程序,因此其入口点不是我们熟悉的 main() 函数。问题自然是:内核模块的入口和退出点是什么?请注意,在我们的简单内核模块底部,有以下几行:

module_init(helloworld_lkm_init);
module_exit(helloworld_lkm_exit);

module_{init|exit}() 代码是指定入口和退出点的宏。每个的参数都是函数指针。在现代C编译器中,我们只需指定函数的名称。因此,在我们的代码中,以下规则适用:

  • helloworld_lkm_init() 函数是入口点。
  • helloworld_lkm_exit() 函数是退出点。

你几乎可以将这些入口和退出点视为内核模块的构造函数/析构函数对。当然,从技术上讲,这并不完全正确,因为这不是面向对象的 C++ 代码,而是纯 C。不过,这可能是一个有用的类比。

返回值

请注意 initexit 函数的签名如下:

static int __init <modulename>_init(void);
static void __exit <modulename>_exit(void);

作为良好的编码实践,我们使用了函数命名格式 <modulename>_{init|exit}(),其中 <modulename> 被替换为内核模块的名称。你会意识到,这种命名约定只是一个约定,技术上并不是必要的,但它是直观且有帮助的(记住,我们写代码是为了让人类阅读和理解,而不是为了机器)。显然,这两个例程都不接收任何参数。

将这两个函数标记为 static 修饰符意味着它们是此内核模块的私有函数,这是我们想要的。

接下来,我们将介绍内核模块的 init 函数的返回值约定。

// ch4/helloworld_lkm/helloworld_lkm.c
#include <linux/init.h>
#include <linux/module.h>
/* 模块信息 */
MODULE_AUTHOR("<插入你的名字>");
MODULE_DESCRIPTION("LKP2E book:ch4/helloworld_lkm: hello, world, our first LKM");
MODULE_LICENSE("Dual MIT/GPL");
MODULE_VERSION("0.2");

static int __init helloworld_lkm_init(void)
{
    printk(KERN_INFO "Hello, world\n");
    return 0;    /* 成功返回0 */
}

static void __exit helloworld_lkm_exit(void)
{
    printk(KERN_INFO "Goodbye, world! 气候变化摧毁了我们...\n");
}

module_init(helloworld_lkm_init);
module_exit(helloworld_lkm_exit);

你可以立即尝试这个简单的 Hello, world 内核模块!只需进入正确的源码目录,并使用我们的辅助脚本 lkm 来构建和运行它:

$ cd ~/Linux-Kernel-Programming_2E/ch4/helloworld_lkm
$ ../../lkm helloworld_lkm.c
Usage: lkm name-of-kernel-module-file ONLY (do NOT put any extension).
$ ../../lkm helloworld_lkm

输出示例:

Version info:
Distro: 	Ubuntu 22.04.2 LTS
Kernel: 5.19.0-45-generic
[ … ]
make || exit 1
------------------------------
make -C /lib/modules/5.19.0-45-generic/build/ M=/home/c2kp/Linux-Kernel-Programming_2E/ch4/helloworld_lkm modules
[ … ]
sudo dmesg
------------------------------
[ 4123.028252] Hello, world
$  

虽然这个代码很小,但它是我们第一个内核模块,理解它需要仔细阅读和分析。接下来将详细解释其中的工作原理和原因。继续阅读吧!

0/-E 返回约定

内核模块的 init 函数需要返回一个 int 类型的整数值,这是一个关键方面。Linux 内核在返回值(即从内核空间返回到用户空间进程的值)方面演变出了一种风格或约定,被称为 0/-E 约定:

  • 成功时,返回整数值 0。
  • 失败时,返回一个负值,表示你希望用户空间全局未初始化的整数 errno 被设置为的值。

errno 是一个驻留在用户进程虚拟地址空间(VAS)中未初始化数据段的全局整数。Linux 系统调用失败时,通常返回 -1,并将 errno 设置为正值,表示失败码或诊断信息。这项工作是通过 glibc 在系统调用返回路径上的“粘合”代码完成的。

此外,errno 值是一个全局错误消息表(const char * sys_errlist 等函数可以输出错误诊断的原因。

你可以在内核源代码树的头文件 include/uapi/asm-generic/errno-base.hinclude/uapi/asm-generic/errno.h 中查找可用的错误代码列表。

示例

以下示例展示了如何从内核模块的 init 函数返回值。假设我们的内核模块正在尝试动态分配一些内核内存(kmalloc() API 的详细信息将在后续章节中介绍,请暂时忽略):

ptr = kmalloc(87, GFP_KERNEL);
if (!ptr) {
    pr_warning("%s():%s():%d: kmalloc failed! Out of memory\n", __FILE__, __func__, __LINE__);
    return -ENOMEM;
}
return 0;   /* 成功 */

如果内存分配失败(虽然这种情况很少见,但在某些情况下可能会发生),我们会:

  1. 使用 printk 发出警告(稍后我们会详细介绍 printk 的语法)。

  2. 返回整数值 -ENOMEM

    • 这个值会被返回到用户空间中的 glibc,它会将该值乘以 -1,并将全局整数 errno 设置为这个值。
    • finit_module() 系统调用将返回 -1,表示失败,errno 将被设置为 ENOMEM,表明内核模块插入失败是由于内存分配失败。

相反,框架期望 init 函数在成功时返回 0。在较早的内核版本中,如果成功时未返回 0,内核模块会立即从内核内存中卸载。如今,内核会发出警告消息,指出返回了一个可疑的非零值。

ERR_PTRPTR_ERR

如果你想返回指针而不是整数,内核提供了 ERR_PTR() 内联函数,它允许我们通过将整数类型转换为指针类型来返回一个整数。还可以使用 IS_ERR() 内联函数来检查错误,并通过 PTR_ERR() 从指针中检索此值。

例如:

struct mystruct *myfunc(void) {
    struct mystruct *mys = NULL;
    mys = kzalloc(sizeof(struct mystruct), GFP_KERNEL);
    if (!mys)
        return ERR_PTR(-ENOMEM);
    return mys;
}

调用代码如下:

retp = myfunc();
if (IS_ERR(retp)) {
    pr_warn("myfunc() mystruct alloc failed, aborting...\n");
    stat = PTR_ERR(retp);  /* 设置 'stat' 为 -ENOMEM */
    goto out_fail_1;
}

__init__exit 关键字

在我们的简单模块中,initcleanup 函数使用了 __init__exit 宏,这些宏指定了内存优化的链接器属性。__init 宏将代码放置在 init.text 段中,使用 __initdata 声明的数据将放入 init.data 段。清理函数则使用 __exit 宏,表示一旦调用完成,所有内存都将被释放。

构建与操作内核模块

接下来,我们将详细讨论如何构建内核模块、将其加载到内核内存中执行、卸载它,以及你可能希望执行的其他操作。

内核模块的常见操作

现在我们来深入探讨如何构建、加载和卸载内核模块。此外,我们还将介绍极为有用的 printk() 内核 API,如何使用 lsmod 列出当前加载的内核模块,以及一个便捷的脚本,用于自动化内核模块开发中的一些常见任务。让我们开始吧!

构建内核模块

尽管可能有些重复,我们还是建议你将我们简单的 "Hello, world" 内核模块作为练习来尝试(如果你还没有这么做的话)!为此,我们假设你已经克隆了本书的 GitHub 仓库(github.com/PacktPublis…)。如果还没有,请现在这样做(有关详细信息,请参阅“技术要求”部分)。

在这里,我们一步步演示如何构建并将我们的第一个内核模块插入到内核内存中。再提醒一下:我们在运行 Ubuntu 22.04 LTS 发行版的 x86_64 Linux 虚拟机上(在 Oracle VirtualBox 7.x 下)执行了这些步骤(不管你使用的是哪个发行版,通用步骤是相同的):

  1. 切换到本书源代码对应章节的目录及其子目录。我们的第一个内核模块在自己的文件夹中(正如它应该的那样),这个文件夹名为 helloworld_lkm

    cd <book-code-dir>/ch4/helloworld_lkm
    

    <book-code-dir> 当然是你克隆此书 GitHub 仓库的文件夹;在我的系统上(参见图 4.5 的截图),路径为 /home/c2kp/kaiwanTECH/Linux-Kernel-Programming_2E

  2. 验证代码库:

    $ pwd
    <book-code-dir>/ch4/helloworld_lkm
    $ ls -l
    total 8
    -rw-rw-r-- 1 c2kp c2kp 1238  Dec 18  12:38 helloworld_lkm.c
    -rw-rw-r-- 1 c2kp c2kp   290  Oct 27  07:26 Makefile
    

    (文件大小不完全匹配是没问题的,软件是不断演化的。)

  3. 使用 make 构建内核模块:

    $ make
    

    这将调用内核构建系统,并根据当前内核版本使用适当的头文件和配置来构建模块。

image.png

如果在构建模块时遇到有关“由于无法使用 vmlinux,跳过生成 BTF”的警告消息,目前可以安全忽略这些警告。

上面的截图显示,我们的内核模块已经成功构建,生成的文件是名为 ./helloworld_lkm.ko 的“内核对象”。

始终使用 make 和提供的 Makefile 来构建内核模块,不要尝试直接调用 gcc(或 Clang)来手动构建它。

另外,注意我们启动的系统并已针对我们之前章节中构建的自定义 6.1.25 LTS 内核构建了该内核模块。现在我们已经成功构建了模块,接下来让我们运行它!

运行内核模块

要让内核模块运行,首先需要将其加载到内核内存空间。这被称为将模块插入内核内存。

将内核模块加载到 Linux 内核段或虚拟地址空间(当然是在 RAM 中)有几种方法,最终都归结为调用 [f]init_module() 系统调用。

为了方便起见,有几个封装的工具可供使用(你也可以自己编写一个)。我们将使用流行的 insmod(“插入模块”)工具;insmod 的参数是要插入的内核模块的路径名(顺便说一下,insmod 在底层调用了 finit_module() 系统调用将模块加载到内核内存中):

$ insmod ./helloworld_lkm.ko 
insmod: ERROR: could not insert module ./helloworld_lkm.ko: Operation not permitted

它失败了!事实上,应该很明显为什么会失败。想一想:将代码插入内核从某种意义上说比在系统上成为 root(超级用户)还要更强大——再次提醒你,这是内核代码,将以内核权限运行。如果允许任何用户插入或删除内核模块,黑客将有机可乘!在操作系统级别部署恶意代码将变得微不足道。因此,出于安全原因,只有具有 root 权限时才能插入或删除内核模块。

从技术上讲,成为 root 意味着进程(或线程)的实际和/或有效用户 ID(RUID/EUID)值为特殊值 0。不仅如此,现代内核还通过现代和更高级的 POSIX 能力模型“看到”线程。除了是 root 之外,更好的方法是利用这个能力模型;只有具有 CAP_SYS_MODULE 能力的进程/线程才能加载或卸载内核模块。有关更多详细信息,请参考 capabilities(7) 的手册页。

现在,让我们使用 root 权限通过 sudo 再次尝试将我们的内核模块插入内存:

$ sudo insmod ./helloworld_lkm.ko
[sudo] password for c2kp: 
$ echo $?
0

现在它可以工作了($? 变量为 0 表示上一个 shell 命令成功)!如前所述,insmod 工具通过调用 finit_module() 系统调用来工作。insmod 可能失败的几种情况如下:

  1. 内核配置 CONFIG_MODULES 未设置为 y,即内核构建时不支持加载内核模块。
  2. 未以 root 身份运行,或者缺少 CAP_SYS_MODULE 能力(此时 errno 的值为 EPERM)。
  3. 内核可调参数 /proc/sys/kernel/modules_disabled 设置为 1(默认为 0)。
  4. 内存中已存在具有相同名称的内核模块(此时 errno 的值为 EEXISTS)。

到目前为止,一切正常。这很棒,但我们期待的 "Hello, world" 消息在哪呢?请继续阅读!

内核 printk() 的快速初步了解

用户空间的 C 开发者通常会使用可靠的 printf() glibc API(或者在编写 C++ 代码时使用 cout)。然而,需要理解的是,在内核空间中,没有用户模式库。因此,我们无法使用传统的 printf() API。相反,它在内核中被重新实现为 printk() 内核 API。你可能好奇它的代码在哪里?它在内核源代码树中的 include/linux/printk.h 中定义为一个宏:printk(fmt, …)。实际函数位于 kernel/printk/printk.c:_printk()

幸运的是,内核或内核模块通过 printk() API 发送消息非常简单,且与使用 printf() 非常相似。在我们简单的内核模块中,输出消息的部分如下:

printk(KERN_INFO "Hello, world\n");

虽然一开始看起来与 printf 非常相似,但内核中的 printk API 实际上有很大的不同。两者的相似之处在于,printk() API 也接收一个格式化字符串作为其参数,该格式字符串与 printf 几乎相同。

但不同点在于,用户空间的 printf() 库 API 格式化文本字符串后,会调用 write() 系统调用,将输出写入 stdout 设备,通常是终端窗口。而内核的 printk() API 格式化文本字符串后,输出位置则有所不同,包括:

  1. 一个(易失的)内核日志缓冲区(位于 RAM 中)
  2. 一个(非易失的)日志文件(即内核日志文件)
  3. 控制台设备

如何查看内核日志?

要查看内核日志,可以使用 dmesg 工具。默认情况下,dmesg 会将整个内核日志缓冲区内容输出到标准输出。让我们试试看:

$ dmesg
dmesg: read kernel buffer failed: Operation not permitted

在某些现代发行版中,一个名为 dmesg_restrict 的 sysctl 选项会阻止普通用户查看内核日志内容。为了安全起见,我们将使用 root 权限重新运行 dmesg 并查看内核日志的最后两行:

$ sudo dmesg | tail -n2
[39884.691954] Hello, world

我们的 "Hello, world" 消息终于出现了!

列出当前加载的内核模块

回到我们的内核模块。到目前为止,我们已经构建了它,将其加载到内核中,并验证其入口点 helloworld_lkm_init() 函数已被调用,从而执行了 printk API。那么接下来它做了什么呢?其实什么都没做;这个内核模块仅仅是“愉快地”待在内核内存中,什么也不做。我们可以很容易地使用 lsmod 实用程序来查看它:

$ lsmod | head
helloworld_lkm         16384  0
isofs                  49152  1
vboxvideo              45056  0
vboxsf                 90112  0
binfmt_misc            24576  1
snd_intel8x0           49152  2
snd_ac97_codec        176128  1 snd_intel8x0
ac97_bus               16384  1 snd_ac97_codec
snd_pcm               151552  2 snd_intel8x0,snd_ac97_codec

lsmod 会列出所有当前驻留(或正在运行)于内核内存中的内核模块,并按时间顺序倒序排列。输出是列格式的,共有三列,第四列是可选的。让我们分别看一下每一列的含义:

  • 第一列显示内核模块的名称。
  • 第二列是它在内核中占用的静态内存大小(以字节为单位)。
  • 第三列是模块的使用计数。
  • 可选的第四列表示依赖的模块(将在下一章的 "模块堆叠" 部分进行解释)。此外,在最近的 x86_64 Ubuntu 发行版中,一个内核模块似乎占用了最少 16 KB 的内存,在 Fedora 上大约是 12 KB。

很好!到目前为止,你已经成功构建、加载并运行了第一个内核模块,它基本上可以正常工作:接下来该做什么呢?嗯,对于这个模块来说,暂时没有什么更多的事情可以做!接下来我们将学习如何卸载它。继续阅读,精彩内容还在后面!

从内核内存中卸载模块

要卸载内核模块,我们使用方便的 rmmod(“移除模块”)工具:

$ rmmod 
rmmod: ERROR: missing module name.
$ rmmod helloworld_lkm
rmmod: ERROR: ../libkmod/libkmod-module.c:799 kmod_module_remove_module() could not remove 'helloworld_lkm': Operation not permitted
rmmod: ERROR: could not remove module helloworld_lkm: Operation not permitted
$ sudo rmmod helloworld_lkm
[sudo] password for c2kp: 
$ dmesg | tail -n2
[39884.691954] Hello, world
[40280.138269] Goodbye, world! Climate change has done us in...

rmmod 的参数是内核模块的名称(如 lsmod 的第一列所示),而不是路径名。显然,就像 insmod 一样,我们需要以 root 用户身份运行 rmmod,或者具有 CAP_SYS_MODULE 权限才能成功卸载。

在这里我们看到,由于 rmmod 的操作,LKM 框架首先调用了 helloworld_lkm_exit() 这个内核模块的退出例程(或“析构函数”)。然后它调用 printk,输出了 "Goodbye, world..." 的消息(我们通过 dmesg 查看了这些日志)。

何时 rmmod 会失败?

以下是几种常见情况:

  • 权限问题:如果没有以 root 身份运行,或者缺少 CAP_SYS_MODULE 能力,errno 将设置为 EPERM
  • 如果内核模块的代码或数据被其他模块使用(即存在依赖关系),或者该模块当前正被进程(或线程)使用,那么模块的使用计数将为正数,rmmod 将失败,并设置 errnoEBUSY
  • 内核模块未指定退出例程(使用 module_exit() 宏),且内核配置选项 CONFIG_MODULE_FORCE_UNLOAD 被禁用。

模块管理相关的其他工具

几个与模块管理相关的便利工具实际上只是 kmod 工具的符号链接(类似于流行的 busybox 实用程序)。这些包装工具包括 lsmodrmmodinsmodmodinfomodprobedepmod。让我们看一下其中的几个示例:

$ ls -l $(which insmod) ; ls -l $(which lsmod) ; ls -l $(which rmmod)
lrwxrwxrwx  1 root root 9 Apr 23  2022 /usr/sbin/insmod -> /bin/kmod
lrwxrwxrwx  1 root root 9 Apr 23  2022 /usr/sbin/lsmod  -> /bin/kmod
lrwxrwxrwx  1 root root 9 Apr 23  2022 /usr/sbin/rmmod -> /bin/kmod

请注意,这些工具的确切位置(如 /bin, /sbin/usr/sbin)可能因发行版而异。

自定义的 lkm 脚本

我们可以使用一个简单但有用的自定义 Bash 脚本 lkm,来帮助你自动完成内核模块的构建、加载、dmesg 和卸载工作流。该脚本的完整代码位于本书源代码树的根目录中。

通过提供内核模块的名称作为参数(不带任何扩展名),lkm 脚本可以执行一些有效性检查,显示一些版本信息,并使用 runcmd() 函数来显示并运行给定的命令,简化了内核模块的构建、加载、查看日志和卸载工作流。你可以在书中相关章节中查看并尝试这个脚本。

image.png

全都完成了!记得用 rmmod 卸载内核模块。

恭喜你!你现在已经学会了如何编写并测试一个简单的 "Hello, world" 内核模块。不过,在你松口气之前,还有很多工作要做;下一节将深入探讨内核日志记录和多功能的 printk API 的更多关键细节。

理解内核日志和 printk

关于通过 printk 内核 API 记录内核消息,还有许多内容需要深入探讨。本节将详细介绍其中的某些细节。对于像你这样的新手内核或驱动开发人员,清楚地理解这些主题非常重要。

我们之前在 A quick first look at the kernel printk() 章节中看到了如何使用 printk API 的基础功能(你可以回顾一下该章节的内容)。接下来,我们将更加深入地探讨 printk() API 的用法。让我们开始吧!

使用内核内存环形缓冲区

内核日志缓冲区是内核虚拟地址空间中的一个内存缓冲区,用于保存 printk 输出(即日志)。更技术性地讲,它是全局变量 __log_buf[]。它在内核源代码中的定义如下:

kernel/printk/printk.c
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
#define LOG_BUF_LEN_MAX (u32)(1 << 31)
static char __log_buf[__LOG_BUF_LEN] __aligned(LOG_ALIGN);
static char *log_buf = __log_buf;
static u32 log_buf_len = __LOG_BUF_LEN;

这个缓冲区被设计成一个环形缓冲区(circular buffer),具有有限的大小(__LOG_BUF_LEN 字节),一旦缓冲区满了,就会从字节 0 开始覆盖。这就是为什么称它为“环形”缓冲区。在代码中,缓冲区的大小基于 Kconfig 变量 CONFIG_LOG_BUF_SHIFT(在 C 中,1 << n 意味着 2 的 n 次方)。这个值可以在内核配置(menuconfig)中的 General Setup | Kernel log buffer size 选项中查看和修改。

它的范围是 12 到 25(我们可以通过搜索 init/Kconfig 查看它的规格),默认值为 18。所以默认情况下,内核日志缓冲区的大小为 (1 << 18),即 256 KB。然而,实际运行时的大小还受其他配置指令影响,特别是 LOG_CPU_MAX_BUF_SHIFT,它使得缓冲区的大小成为系统上 CPU 数量的函数。此外,相关的 Kconfig 文件说明,“当使用 log_buf_len 内核参数时,该选项被忽略,因为它强制环形缓冲区的大小为 2 的幂次方。” 也就是说,我们可以通过引导加载程序传递内核参数来覆盖默认值!

日志级别和系统日志管理

内核日志信息会被写入一个非易失性的文件(如 /var/log/syslog/var/log/messages),这样日志信息不会因为系统崩溃或重启而丢失。现代 Linux 发行版使用 systemd 进行日志管理,其中 journalctl 是一个非常强大的日志查看工具。通过 journalctl,我们可以方便地查看系统日志和内核日志,并且能够按时间、服务或消息级别等进行过滤和查找。

例如,使用以下命令查看当前启动期间的所有内核日志:

journalctl -b -k --no-pager

我们还可以使用 journalctl 来查看最近半小时的内核日志:

journalctl -k --since="30 min ago"

了解 printk 的日志级别对于有效地调试和监控内核行为至关重要。通过不同的日志级别(如 KERN_INFOKERN_ERR 等),我们可以控制 printk 输出的详细程度,确保在开发和调试过程中只记录最相关的消息。

使用 printk 的日志级别

当你通过 printk API(及其相关 API)向内核日志发出消息时,通常还需要指定消息记录的日志级别。为了理解并使用这些日志级别,让我们从我们著名的 helloworld_lkm 内核模块中的第一行代码入手:

printk(KERN_INFO "Hello, world\n");

现在来解释 KERN_INFO 是什么。首先要小心,不要误以为它是一个参数。注意,在 KERN_INFO"Hello, world\n" 格式字符串之间没有逗号,而只有空格。KERN_INFO 是内核日志的八个日志级别之一,它用于记录 printk 的输出。关键点是,这个日志级别并不是优先级的指示符,它的作用是让我们能够基于日志级别进行消息过滤。

内核定义了 printk 可使用的八个日志级别,它们如下:

// include/linux/kern_levels.h
#ifndef __KERN_LEVELS_H__
#define __KERN_LEVELS_H__
#define KERN_SOH    "\001"      /* ASCII Start Of Header */
#define KERN_SOH_ASCII  '\001'
#define KERN_EMERG    KERN_SOH "0"    /* 系统不可用 */
#define KERN_ALERT    KERN_SOH "1"    /* 必须立即采取行动 */
#define KERN_CRIT     KERN_SOH "2"    /* 严重的条件 */
#define KERN_ERR      KERN_SOH "3"    /* 错误条件 */
#define KERN_WARNING  KERN_SOH "4"    /* 警告条件 */
#define KERN_NOTICE   KERN_SOH "5"    /* 正常但重要的情况 */
#define KERN_INFO     KERN_SOH "6"    /* 信息性的消息 */
#define KERN_DEBUG    KERN_SOH "7"    /* 调试级别消息 */
#define KERN_DEFAULT          ""      /* 默认的内核日志级别 */

现在我们看到,KERN_<FOO> 日志级别实际上是字符串("0", "1", ..., "7"),它们被作为前缀添加到 printk 发出的内核消息中,除此之外没有别的作用。这给了我们根据日志级别过滤消息的能力。右侧的注释清楚地表明了开发人员在什么情况下应该使用哪个日志级别。

日志级别示例

例如,当内核中的 hangcheck-timer 驱动程序检测到计时器超时超过阈值时,它将发出严重的日志消息并重启系统。相关代码如下:

// drivers/char/hangcheck-timer.c
if (hangcheck_reboot) {
    printk(KERN_CRIT "Hangcheck: hangcheck is restarting the machine.\n");
    emergency_restart();
}

此处使用了 KERN_CRIT 日志级别,因为这是一个关键性的问题。

另一个例子是打印机驱动程序发出信息级别的日志来通知设备状态(即打印机着火了)。代码如下:

// drivers/char/lp.c
if (last != LP_PERRORP) {
    last = LP_PERRORP;
    printk(KERN_INFO "lp%d on fire\n", minor);
}

尽管设备着火似乎应该被标记为紧急日志级别,但这里只是用了 KERN_INFO 来发出信息。

此外,在处理 CPU 热失控的异常时,pentium_machine_check() 函数会使用 pr_emerg 来发出紧急日志,如下:

// arch/x86/kernel/cpu/mce/p5.c
pr_emerg("CPU#%d: Machine Check Exception: 0x%8X (type 0x%8X).\n", smp_processor_id(), loaddr, lotype);
if (lotype & (1<<5)) {
    pr_emerg("CPU#%d: Possible thermal failure (CPU on fire ?).\n", smp_processor_id());
}

在没有指定 printk() 的日志级别时,默认情况下会使用 KERN_WARNING(级别 4)。不过,通常你应该明确指定适合的日志级别,以确保日志记录符合预期。

下一步,我们将探讨一个更简便的日志记录方式,即 pr_<foo>() 宏。

pr_<foo> 便捷宏

pr_<foo>()(通常称为 pr_*())宏使得编码更加简便。它们将较为笨拙的

printk(KERN_FOO "<format-str>", vars...);

替换为更优雅的形式:

pr_foo("<format-str>", vars...);

其中,<foo> 是日志级别,可以是 emergalertcriterrwarnnoticeinfodebug。这些宏的使用非常鼓励,以下是其定义:

// include/linux/printk.h:
[ ... ]
/**
 * pr_emerg - 打印紧急级别的消息
 * @fmt: 格式化字符串
 * @...: 格式化字符串的参数
 *
 * 这个宏扩展为带有 KERN_EMERG 日志级别的 printk。它使用 pr_fmt() 生成格式化字符串。
 */
#define pr_emerg(fmt, ...) \
    printk(KERN_EMERG pr_fmt(fmt), ##__VA_ARGS__)

/**
 * pr_alert - 打印警报级别的消息
 * @fmt: 格式化字符串
 * @...: 格式化字符串的参数
 *
 * 这个宏扩展为带有 KERN_ALERT 日志级别的 printk。它使用 pr_fmt() 生成格式化字符串。
 */
#define pr_alert(fmt, ...) \
    printk(KERN_ALERT pr_fmt(fmt), ##__VA_ARGS__)
[ … ]
#define pr_err(fmt, ...) \
    printk(KERN_ERR pr_fmt(fmt), ##__VA_ARGS__)
[ … ]
#define pr_warn(fmt, ...) \
    printk(KERN_WARNING pr_fmt(fmt), ##__VA_ARGS__)
[ … ]
#define pr_notice(fmt, ...) \
    printk(KERN_NOTICE pr_fmt(fmt), ##__VA_ARGS__)
[ … ]
#define pr_info(fmt, ...) \
    printk(KERN_INFO pr_fmt(fmt), ##__VA_ARGS__)
[ … ]
/**
 * pr_devel - 有条件地打印调试级别的消息
 [ … ]
 * 这个宏扩展为带有 KERN_DEBUG 日志级别的 printk,如果定义了 DEBUG,它将生效,否则不做任何操作。
 [ … ]
#ifdef DEBUG
#define pr_devel(fmt, ...) \
    printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__)
[ … ]

使用这些 pr_*() 宏时,还可以用 pr_cont() 来作为前一个 printk() 的续接字符串,继续打印日志。以下是一个使用示例:

// kernel/module.c
if (last_unloaded_module[0])
    pr_cont(" [last unloaded: %s]", last_unloaded_module);
pr_cont("\n");

通常,我们会确保只有最后一个 pr_cont() 包含换行符 \n。为了避免意外(例如 printk() 似乎没有效果),建议始终在 printk() 结尾添加换行符 \n

使用 dev_*()

驱动程序的作者通常需要使用 dev_*() 宏。这些宏允许传递一个指向设备的指针(struct device *),并且自动为日志消息添加有用的上下文信息,比如驱动程序名称、总线号和设备地址等。有关 dev_*() 宏的定义,可以参考 内核源码。例如,在写一个 I2C 驱动时,日志可能包含 I2C 总线号和芯片地址。

控制日志输出到控制台

内核日志不仅可以记录在内存环形缓冲区或非易失性日志文件中,还可以输出到控制台设备。Linux 内核使用 sysctl 机制来控制 printk 日志是否发送到控制台。你可以通过读取 /proc/sys/kernel/printk 文件查看当前日志级别:

$ cat /proc/sys/kernel/printk
4    4    1    7

这四个数字代表以下含义:

  1. 当前的控制台日志级别,所有低于该级别的日志消息也会输出到控制台。
  2. 默认日志级别(缺省情况下没有指定日志级别的消息会使用该级别)。
  3. 最低允许的日志级别。
  4. 启动时的默认日志级别。

例如,默认情况下,日志级别为 4(KERN_WARNING),这意味着所有紧急程度高于警告的日志消息(例如 KERN_EMERG, KERN_ALERT, KERN_CRIT, KERN_ERR)会被输出到控制台。

image.png

请注意,默认的 printk 日志级别(即 printk 的 sysctl 设置)可能会与 x86_64 上的不同,这种情况时有发生。

有趣的部分来了:将 /proc/sys/kernel/printk 伪文件的第一个整数值设置为 8,将保证所有的 printk 实例直接输出到控制台,这样 printk 就像 printf 一样工作了!下面展示了 root 用户如何轻松设置这一点:

$ sudo sh -c "echo '8 4 1 7' > /proc/sys/kernel/printk"
$ cat /proc/sys/kernel/printk
8       4       1       7

我们当然是以 root 权限执行的;请注意使用的 shell 语法。这样设置控制台输出在开发和测试期间非常方便!

然而,需要注意的是,通过 /proc 进行的 sysctl 修改是临时的;这些更改仅在当前会话中有效,一旦系统重启或断电,修改将丢失。要使对 sysctl 的更改永久生效,可以使用 sysctl(8) 工具。例如,运行以下命令作为 root 用户:

sysctl -w kernel.printk='8 4 1 7'

这将使 printk sysctl 在每次启动时都设置为这些值。

在我的 Raspberry Pi(以及其他开发板)上,我保留了一个启动脚本,其中包含以下行:

[[ $(id -u) -eq 0 ]] && echo "8 4 1 7" > /proc/sys/kernel/printk

因此,当以 root 身份运行时,这一设置生效,所有的 printk 实例都会直接显示在 minicom 控制台上,就像 printf 一样,非常适合调试!通过 sysctl,你可以使其永久生效。

说到功能强大的 Raspberry Pi,下一节我们将演示如何在 Raspberry Pi 上运行一个内核模块。

向 Raspberry Pi 控制台写出输出

进入我们的第二个内核模块!从现在开始,我们将使用 pr_*() 风格的宏来打印九个 printk 实例,覆盖八个日志级别,外加一个通过 pr_devel() 宏(实际上是 KERN_DEBUG 日志级别)的打印。下面是相关代码:

// ch4/printk_loglvl/printk_loglvl.c
[ … ]
static int __init printk_loglvl_init(void)
{
    pr_emerg("Hello, world @ log-level KERN_EMERG   [0]\n");
    pr_alert(  "Hello, world @ log-level KERN_ALERT   [1]\n");
    pr_crit(    "Hello, world @ log-level KERN_CRIT    [2]\n");
    pr_err(     "Hello, world @ log-level KERN_ERR     [3]\n");
    pr_warn(  "Hello, world @ log-level KERN_WARNING [4]\n");
    pr_notice("Hello, world @ log-level KERN_NOTICE  [5]\n");
    pr_info(    "Hello, world @ log-level KERN_INFO    [6]\n");
    pr_debug("Hello, world @ log-level KERN_DEBUG   [7]\n");
    pr_devel( "Hello, world via the pr_devel() macro"
        " (eff @KERN_DEBUG) [7]\n");
    return 0; /* success */
}

static void __exit printk_loglvl_exit(void)
{
    pr_info("Goodbye, world @ log-level KERN_INFO    [6]\n");
}

module_init(printk_loglvl_init);
module_exit(printk_loglvl_exit);

现在,我们将讨论在 Raspberry Pi 设备上运行上述 printk_loglvl 内核模块时的输出。如果你没有 Raspberry Pi 或者暂时没有使用到,也没关系,你可以在 x86_64(虚拟机或本地系统)上尝试。

在 Raspberry Pi 设备上(这里我们使用的是运行默认 Raspberry Pi OS 的 Raspberry Pi 4 Model B),登录并通过 sudo -s 获取 root shell,然后编译内核模块。

如果你在设备上安装了默认的 Raspberry Pi 映像,那么所有必需的开发工具、内核头文件等都已经预装好!因此,登录开发板,编译模块并运行它。图 4.8 是在 Raspberry Pi 板上运行我们的 printk_loglvl 内核模块的截图。需要注意的是,我们是在控制台设备上运行的,因为我们使用了前面提到的 USB 到串行电缆,借助 minicom 终端仿真器应用,而不是通过 SSH 连接。

image.png

注意到与 x86_64 环境略有不同的地方:在 Raspberry Pi 上,默认情况下 /proc/sys/kernel/printk 输出的第一个整数——当前的控制台日志级别——是 3(而不是 4)。这意味着,所有日志级别低于 3 的 printk 实例会直接显示在控制台设备上。看看截图:情况确实如此!此外,正如预期的那样,“紧急”日志级别(0,即 KERN_EMERG)的 printk 实例总会显示在控制台上——实际上,它会出现在每个已打开的非图形化终端窗口中。

现在到了有趣的部分;我们将把当前的控制台日志级别(记住,它是 /proc/sys/kernel/printk 输出的第一个整数)设置为 8(当然,需要 root 权限)。这样,所有的 printk 实例都应该直接显示在控制台上。我们在这里正是测试这一点:

image.png

当然,我们首先通过 rmmod 从内存中移除早先的模块实例。正如预期的那样,我们看到所有的 printk 实例直接显示在控制台设备上,这样就不需要使用 dmesg 了。

不过,稍等一下:pr_debug()pr_devel() 宏发出的日志级别为 KERN_DEBUG 的内核消息(即整数值 7)发生了什么?它们既没有出现在控制台上,也没有出现在 dmesg 输出中。我们稍后会解释,请继续阅读。

当然,使用 dmesg 可以查看所有内核消息——至少那些仍然保存在内核日志(环形)缓冲区中的消息。我们看到以下内容:

rpi # rmmod printk_loglvl
rpi # dmesg
 [...]
[ 2086.684939] Hello, world @ log-level KERN_EMERG   [0]
[ 2086.690143] Hello, world @ log-level KERN_ALERT   [1]
[ 2086.695526] Hello, world @ log-level KERN_CRIT    [2]
[ 2086.700826] Hello, world @ log-level KERN_ERR     [3]
[ 2086.706233] Hello, world @ log-level KERN_WARNING [4]
[ 2086.711999] Hello, world @ log-level KERN_NOTICE  [5]
[ 2086.717931] Hello, world @ log-level KERN_INFO    [6]
[ 2372.690250] Goodbye, world @ log-level KERN_INFO    [6]
rpi #

所有 printk 实例——除了 KERN_DEBUG 级别的——都能通过 dmesg 实用工具看到。那么,如何显示调试级别的消息呢?接下来我们会介绍。

启用调试级别的内核消息

我们刚才看到,我们可以在不同的日志级别发出 printk 实例,除了调试级别的消息,其他的都显示了。是的,pr_debug()(以及 dev_dbg())有点特殊;除非为内核模块定义了 DEBUG 符号,否则 KERN_DEBUG 级别的 printk 实例不会显示出来。我们可以通过编辑内核模块的 Makefile 来启用调试消息。至少有两种方法可以设置:

  1. Makefile 中插入这一行:

    CFLAGS_printk_loglvl.o := -DDEBUG
    

    通常格式是:CFLAGS_<文件名>.o := <值>

  2. 或者,通常我们会插入以下语句到 Makefile 中:

    ccflags_y += -DDEBUG
    

传统上,用于传递额外编译标志的 Makefile 变量被称为 EXTRA_CFLAGS,但现在它已被弃用,因此我们使用更新的 ccflags_y 变量。

在我们的 Makefile 中,我们故意将 -DDEBUG 开头的行注释掉了。现在,为了测试它,取消注释以下两行之一(建议取消注释 ccflags_y 变量那一行):

# 启用 pr_debug()(删除下面某一行的注释)
#(注意:EXTRA_CFLAGS 已弃用;使用 ccflags-y)
#ccflags-y += -DDEBUG
#CFLAGS_printk_loglvl.o := -DDEBUG

编辑后,重新编译并使用我们的 lkm 脚本插入它。截图中的部分 lkm 脚本输出(图 4.10)清楚地显示了 dmesg 的颜色编码,KERN_ALERT / KERN_CRIT / KERN_ERR 分别以红色背景/粗体红色字体/红色前景色高亮显示,而 KERN_WARNING 以粗体黑色字体显示,帮助我们快速识别重要的内核消息。

image.png

我们重点标出了在 KERN_DEBUG 日志级别发出的两个 printk 实例。需要注意的是,当启用了“动态调试”功能(CONFIG_DYNAMIC_DEBUG=y,下一节将介绍)时,pr_debug() 的行为并不完全相同。

正如之前提到的,设备驱动程序的作者应特别注意,发出 printk 实例时必须使用特殊的 dev_*() 宏,而不是内核和纯模块中常用的 pr_*() 宏。这意味着要传递一个附加的第一个参数,该参数是指向相关设备的指针(struct device *)。你可以在这里找到它们的定义:dev_printk.h

此外,pr_devel() 用于内核内部的调试 printk 实例,其输出在生产系统中不应可见。

回到关于控制台输出的部分,如果你希望在内核/驱动程序调试过程中(或者其他情况下)确保所有 printk 实例都被定向到控制台,确实有一种方法可以保证:只需传递一个称为 ignore_loglevel 的内核启动参数。有关更多详细信息,请参阅官方内核文档

此外,你还可以通过将 Y 回显到以下伪文件中,在运行时开启忽略 printk 日志级别的功能,从而允许所有 printk 实例出现在控制台设备上:

sudo bash -c "echo Y > /sys/module/printk/parameters/ignore_loglevel"

相反,你可以通过回显 N 来关闭此功能。这非常实用!

dmesg 实用工具还可以用于控制启用/禁用内核消息输出到控制台设备,并通过各种选项(特别是 --console-level 选项)控制控制台日志级别(即消息出现在控制台上的最低日志级别)。你可以浏览 dmesg(1) 的手册页了解更多详细信息。

重要提示:使用 printk(及其相关 API/宏)进行调试的相关内容已经在我的《Linux Kernel Debugging》书中详细讨论了(特别是在第 3 章《通过插装调试——printk 和相关 API》中)。它涵盖了常规的 printk 使用,调试技巧,速率限制,以及重要的动态调试内核框架的使用。请务必查阅。

内核强大的动态调试功能简介

作为程序员,我们需要使用调试技术。也许最知名的技术就是插装调试,即在代码中的关键位置插入调试打印语句。通过查看日志,我们可以理解控制流,并可能发现错误。

在 Linux 内核中,调试和其他打印输出通过各种 printk 变体发出——常规的 printk() 宏,推荐使用的 pr_*() 宏,以及驱动程序使用的 dev_*() 宏。那些使用 KERN_DEBUG 日志级别发出的就是所谓的调试打印。因此,以下几种都是可能的“调试级别”打印调用点:

pr_debug("<fmt str>"[, args…]);
dev_dbg(dev, "<fmt str>"[, args…]);
printk(KERN_DEBUG "<fmt str>"[, args…]);

重要的是要记住,调试 printk 实例默认情况下始终关闭;只有在定义了 DEBUG 符号时,它们才会真正生效。

现在,这看起来很方便;但是在实际生产环境中,如果你需要从某个模块发出一些调试打印呢?为此,你必须定义 DEBUG 符号(当然,可以在 Makefile 中完成),然后重新编译、卸载并重新加载模块。然而,在大多数生产系统中,这种方法既不实际,也可能不允许。因此,我们需要一种更动态的方法,而这正是内核动态调试功能所提供的。

通过内核的动态调试功能,来自内核以及内核模块的所有以 KERN_DEBUG 日志级别发出的 printk 调用点都会被编译到内核中。在 make menuconfig 界面中,动态调试选项位于:Kernel hacking > printk and dmesg options > Enable dynamic printk() support。该配置是一个布尔值,名为 CONFIG_DYNAMIC_DEBUG

假设你启用了它(设置为 Y;即使启用时,不追踪动态打印的开销被认为是最小的,这也是该功能被合并的原因!)。

由于本书并非专注于内核调试,因此这里只介绍基础内容;更多细节请参考《Linux Kernel Debugging》。不过,足够的信息已经涵盖,足以让你入门并理解此强大的功能。

此功能的“帮助”文档(在某个 Kconfig* 文件中)非常出色;你可以阅读它来了解如何使用此功能(在 6.1.25 内核中,可以在此找到:Kconfig.debug)。

image.png

由于完整的细节无法显示,这里是下一张截图以补充内容:

image.png

请仔细阅读图 4.11 和图 4.12,它们提供了使用动态调试的详细步骤,帮助你快速入门。官方内核文档对此功能的介绍非常详细,建议你浏览:www.kernel.org/doc/html/v6…

要检查内核的动态调试功能是否可用(在 x86 系统上),如果显示为 y(如这里所示),则该功能可用,否则不可用:

$ grep "DYNAMIC_DEBUG=y" /boot/config-$(uname -r)
CONFIG_DYNAMIC_DEBUG=y

因此,我们与动态调试功能交互的接口是一个位于 debugfs 下的伪文件:<debugfs_mount_point>/dynamic_debug/control。不过,在许多生产系统上,debugfs 可能未配置(或被隐藏),在这种情况下,控制文件可以通过 /proc/dynamic_debug/control 查看。这两个文件是相同的。

查看控制文件的内容可以显示所有已编译到内核中的调试打印或调用点。快速查看如下:

$ head -n1 /proc/dynamic_debug/control 
# filename:lineno [module]function flags format

第一行揭示了格式。除了名为 flags 的关键字段外,所有其他字段都很容易理解;而正是这个 flags 字段带来了神奇的功能!

简单来说,flags 的值如果是 =_,表示调试打印是关闭的(通常默认情况下是这样的)。例如,查看 e1000 驱动程序的几个调试打印调用点:

# grep "e1000" /proc/dynamic_debug/control | head -n3
drivers/net/ethernet/intel/e1000/e1000_hw.c:385 [e1000]e1000_reset_hw =_ "Disabling MWI on 82542 rev 2.0\n"
drivers/net/ethernet/intel/e1000/e1000_hw.c:390 [e1000]e1000_reset_hw =_ "Masking off all interrupts\n"
drivers/net/ethernet/intel/e1000/e1000_hw.c:423 [e1000]e1000_reset_hw =_ "Issuing a global reset to MAC\n"

你可以看到调试打印是关闭的(=_ 证实了这一点)。要打开它,只需在 flags 字段中写入 +p。此外,还有其他可选的值如 mflt,请参阅文档以了解它们的含义。

以下是运行动态调试功能的几个示例,范围不同。首先确保满足基本前提(CONFIG_DYNAMIC_DEBUG=y)。你需要以 root 用户运行这些命令;此外,你可以将 /sys/kernel/debug/dynamic_debug/control 路径替换为 /proc/dynamic_debug/control

  • 文件范围:启用所有文件路径中包含字符串 “usb” 的调试消息:

    echo -n 'file *usb* +p' > /sys/kernel/debug/dynamic_debug/control
    

    插入或拔出 USB 设备时,观察内核日志(在另一个终端中使用 journalctl -k -f)。通过以下命令关闭调试:

    echo -n 'file *usb* -p' > /sys/kernel/debug/dynamic_debug/control
    
  • 文件范围:启用所有文件路径中包含字符串 “ip” 的调试消息:

    echo -n 'file *ip* +p' > /sys/kernel/debug/dynamic_debug/control
    

    尝试 ping 一个网站,同时在另一个终端中使用 journalctl -k -f 观察内核日志。通过以下命令关闭调试:

    echo -n 'file *ip* -p' > /sys/kernel/debug/dynamic_debug/control
    
  • 模块范围:启用所有当前内存中的内核模块的调试打印实例(所有 pr_debug()dev_dbg() 调用点):

    echo -n 'module * +pflmt' > /sys/kernel/debug/dynamic_debug/control
    

    警告:这可能会导致非常大量的输出!

    通过以下命令关闭调试:

    echo -n 'module * -pflmt' > /sys/kernel/debug/dynamic_debug/control
    

在另一个终端中使用 journalctl -k -f 观察内核日志输出。当然,这些设置是暂时的,系统重启或断电后会恢复默认值。

内核文档中有更多示例,建议查看:www.kernel.org/doc/html/v6…

下一节将介绍另一个非常有用的日志功能:速率限制(Rate Limiting)。

限制 printk 实例的速率

当我们从一个经常执行的代码路径中发出 printk 实例时,大量的 printk 实例可能会迅速填满内核日志缓冲区(记住,它是一个循环缓冲区!),从而覆盖掉可能是关键的调试信息。此外,不断增长的非易失性日志文件反复记录几乎相同的内核日志消息也不是一个好主意,浪费磁盘空间,甚至是闪存空间。例如,考虑一个在中断处理程序中调用的较大的 printk,如果硬件中断的调用频率为每秒 100 次(即每秒调用 100 次),那么日志就会变得非常冗长。

为了解决这些问题,内核提供了一个有趣且实用的选择:速率限制的 printkpr_<foo>_ratelimited() 宏(其中 <foo>emergalertcriterrwarnnoticeinfodebug 之一)具有与常规 printk 相同的语法,但其关键点是,在满足某些条件时,它会有效地抑制常规打印。

内核通过 proc 文件系统提供了两个名为 printk_ratelimitprintk_ratelimit_burstsysctl 文件,用于速率限制目的。以下是来自官方 sysctl 文档中的解释(www.kernel.org/doc/Documen…),介绍了这两个伪文件的确切含义:

  • printk_ratelimit:某些警告消息会受到速率限制。printk_ratelimit 指定了这些消息之间的最小时间间隔(以 jiffies 为单位),默认情况下,我们允许每 5 秒一次。

    • 值为 0 将禁用速率限制。
  • printk_ratelimit_burst:虽然长时间内我们每 printk_ratelimit 秒钟只允许发送一条消息,但我们允许一批消息通过。printk_ratelimit_burst 指定在速率限制生效之前可以发送的消息数量。

在我们的 x86_64 Ubuntu 22.04 LTS 系统上,它们的默认值如下:

$ cat /proc/sys/kernel/printk_ratelimit /proc/sys/kernel/printk_ratelimit_burst
5
10

这意味着默认情况下,在 5 秒的时间间隔内,最多允许 10 条相同的消息通过,之后速率限制就会生效。

你可以使用这个在线工具来搜索特定的 sysctl 含义:sysctl-explorer.net/kernel/prin…

printk 被速率限制时,内核会发出一条有用的消息,提到有多少早期的 printk 回调被抑制。以下是一些示例输出:

ratelimit_test_init: 41 callbacks suppressed
ratelimit_test:ratelimit_test_init():45: [51] ratelimited printk @ KERN_INFO [6]

速率限制宏的使用

内核提供了以下宏来帮助你限制打印或日志的速率(应使用 #include <linux/kernel.h>):

  • printk_ratelimited() :警告!请不要使用它——内核建议不要使用此宏。
  • pr_*_ratelimited()* 通常替换为 emergalertcriterrwarnnoticeinfodebug 之一。
  • dev_*_ratelimited()* 同样替换为 emergalertcriterrwarnnoticeinfodebug 之一。

确保使用 pr_*_ratelimited() 宏,优先于 printk_ratelimited();驱动程序作者应使用 dev_*_ratelimited() 宏。

如果你想要查看使用速率限制(驱动程序调试)的打印实例,AMD GPU 驱动程序中有一个示例,代码位置:elixir.bootlin.com/linux/v6.1.…

不建议使用较旧的(现已弃用的)printk_ratelimited()printk_ratelimit() 宏。此外,实际的速率限制代码位于 lib/ratelimit.c:___ratelimit()。按照内核约定,两个或多个下划线开头的函数/宏(如 __foo())被认为是内部函数/宏,避免直接使用它,而应使用其包装器 foo()

接下来我们会讨论如何从用户空间生成内核级消息。

从用户空间生成内核消息

编程中一个常见的调试技巧是在代码的不同位置插入打印语句,帮助我们逐步定位问题。这确实是一个有效的调试方法,正式名称叫做“代码插装”(instrumenting the code)。内核开发者经常使用经典的 printk API(及其相关功能)来实现这一目的。

假设你已经编写了一个内核模块,并通过在代码中适当位置添加 printk 实例来调试它。你可以在运行时通过 dmesg(或 journalctl 等工具)查看这些 printk 消息。

这很好,但如果你正在运行一个自动化的用户空间测试脚本,并且希望看到脚本在内核模块中某个动作被触发的日志输出,你该怎么办?例如,你希望日志输出类似这样:

test_script: @user msg 1 ; kernel_module: msg n, msg n+1, ..., msg n+m ; test_script: @user msg 2 ; ...

你可以让用户空间的测试脚本向内核日志缓冲区写入一条消息,类似于内核 printk,方法是将该消息写入特殊设备文件 /dev/kmsg

echo "test_script: @user msg 1" > /dev/kmsg

不过需要注意,执行此操作需要以 root 权限运行。当然,简单地在 echo 前加上 sudo 是行不通的:

$ sudo echo "test_script: @user msg 1" > /dev/kmsg
bash: /dev/kmsg: Permission denied

你可以使用以下方法实现:

$ sudo bash -c "echo "test_script: @user msg 1" > /dev/kmsg"
[sudo] password for c2kp:
$ dmesg | tail -n1
[55527.523756] test_script: @user msg 1

第二种语法有效,但更简单的方法是获取 root shell(例如使用 sudo -s),然后执行这些任务。

再多提一件事。dmesg 工具提供了多种选项,可以让输出更具可读性;以下是我们在 dmesg 中使用的示例别名设置:

$ alias dmesg='sudo dmesg --decode --nopager --color --ctime'
$ dmesg | tail -n1
kern  :debug : [Mon Jul 10 08:14:32 2023] wlo1: Limiting TX power to 30 (30 - 0) dBm as advertised by b8:<...>

通过特殊设备文件 /dev/kmsg 写入内核日志的消息会以当前默认的日志级别(通常为 4 : KERN_WARNING)输出。你可以通过在消息前添加所需的日志级别(作为字符串格式的数字)来覆盖此设置。例如,要从用户空间以日志级别 6 : KERN_INFO 写入内核日志,可以这样做:

$ sudo bash -c "echo "<6>test_script: test msg at KERN_INFO"   \
   > /dev/kmsg"
$ dmesg | tail -n1
user  :info  : [Mon Jul 10 08:25:27 2023] test_script: test msg at KERN_INFO

我们可以看到,后一条消息确实以指定的 6 级别输出,和在 echo 中设置的一致。

实际上,没有办法区分用户生成的内核消息和内核 printk() 生成的消息,它们看起来是完全相同的。因此,你可以在消息中插入或前缀一些特殊的标记字符串(如 @user),以便你能够区分用户生成的消息和内核消息。

顺便提一下,用户空间与内核空间之间的接口是一个重要话题,特别是驱动程序开发者经常需要处理的内容。Linux 提供了多种方式来实现这种接口,通常涉及从用户空间发出系统调用,作为切换到内核的方式。常见的接口技术包括通过 procfssysfsdebugfsnetlink 套接字以及 ioctl() 系统调用来完成。这些内容在《Linux Kernel Programming - Part 2》书中的第二章《用户与内核通信路径》中有详细介绍。

接下来,我们将讨论如何轻松标准化内核 printk 输出格式。

通过 pr_fmt 宏标准化 printk 输出

关于 printk,还有一个重要的点需要说明:为了给 printk() 输出添加上下文(比如它发生的位置),你可以借助 GCC 的各种宏(如 __FILE____func____LINE__),分别打印出文件名、函数名和代码行号,如下所示:

pr_info("%s:%s:%s():%d: mywork XY failed!\n", OURMODNAME, __FILE__, __func__, __LINE__);

这虽然没问题,但如果你的项目中有很多 printk 实例,确保所有人都始终如一地遵循标准的 printk 格式(比如,先显示模块名,然后是文件名、函数名和行号)可能会非常麻烦。

这时 pr_fmt() 宏可以派上用场。在代码的开头定义这个宏(必须在第一个 #include 之前),可以保证代码中的每个后续 printk 都会按照这个宏中指定的格式进行前缀。

让我们来看一个示例(这是下一章中的代码片段,别担心,它非常简单,可以作为你未来内核模块的模板):

// ch5/lkm_template/lkm_template.c
#define pr_fmt(fmt) "%s:%s(): " fmt, KBUILD_MODNAME, __func__
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
[...]
static int __init lkm_template_init(void)
{
    pr_info("inserted\n");
    [...]
}

pr_fmt() 宏使用预定义的 KBUILD_MODNAME 宏来替换模块名称,并使用 GCC 的 __func__ 来显示当前运行的函数名!你还可以添加 %s 来显示 __FILE__,以及用 %d__LINE__ 来显示行号,灵活度很大。

因此,代码中的 pr_info() 在内核日志中显示的格式如下:

[381534.391966] lkm_template:lkm_template_init(): inserted

你可以看到,模块名和函数名自动添加到了 printk 消息内容的前面!这非常有用,事实上,内核中有上千个源文件都以 pr_fmt() 开头。我们在 6.1.25 版本的内核代码库中搜索到超过 2200 个 pr_fmt() 实例!尽管在我们的演示模块中不会每个都使用此惯例,但我们会尽量遵循。

pr_fmt() 也会对驱动开发者推荐使用的 dev_*() 宏产生作用。

接下来,我们讨论如何在 printk 中使用与可移植性相关的格式说明符。

可移植性与 printk 格式说明符

使用功能强大的 printk API 时,你可能会遇到一个问题:如何确保你的 printk 输出格式正确(例如在不同 CPU 架构下都能正常显示)?这个问题与代码的可移植性有关。好消息是,掌握了各种格式说明符后,你将能够编写架构无关的 printk 代码。

理解 size_tssize_t 类型很重要:size_t 是无符号整数的类型定义,ssize_t 则是有符号整数的类型定义。

以下是一些常用的 printk 格式说明符,帮助你编写可移植代码:

  • 对于 size_tssize_t 类型的整数,分别使用 %zu%zd
  • 对于内核指针,使用 %pK(安全的哈希值),实际指针用 %px(但不要在生产环境中使用!)。物理地址使用 %pa,并且需要传递地址的引用。
  • 对于原始缓冲区(以十六进制字符表示的字符串),使用 %*ph* 表示字符数),适用于 64 字符以内的缓冲区。对于更大的缓冲区,使用 print_hex_dump_bytes() 例程。
  • 对于 IPv4 地址,使用 %pI4,而对于 IPv6 地址,使用 %pI6

关于 printk 格式说明符的详细列表以及使用示例可以在官方内核文档中找到:printk 格式文档

内核文档还特别指出,使用未修饰的 %p 可能会引发安全问题(参考此链接)。建议仔细阅读。

最后,我们将简要介绍最近的功能:printk 索引。

理解新的 printk 索引功能

假设你编写了一个内核模块(可能是一个驱动程序),在执行任务时发出了 printk 消息。比如,某个日志消息是 mydriver: detected crazy situation X,这看起来没问题。项目投入生产后,假设某种用户空间监控守护进程正在监视内核日志消息,以便在检测到异常情况时通知用户。这让我们意识到,printk 消息不仅是供人类查看的,程序也可能会持续监视这些消息,这在大型系统中是很常见的情况。

几个月后,另一个开发人员认为你的日志消息不太好,改成了 mydriver: detected abnormal condition X。虽然看起来更好,但问题是,日志监控守护进程仍在查找旧消息(可能通过 grep 命令搜索 mydriver: detected crazy situation 字符串),因此它将完全忽略这个新的关键消息,进而引发各种问题。

为了解决这种情况,Chris Down 提出了一个内核 printk 索引功能,并在 5.15 版本中合入主线。启用该功能后,每个 printk 实例的元数据(包括实际的格式字符串、其来源代码位置、日志级别等)都会保存到一个结构体中(struct pi_entry),并将这些结构体汇总到内核 vmlinux 镜像的一个特殊部分(命名为 .printk_index)中,同样适用于各个内核模块!

这些消息通过 debugfs 条目(需要启用 debugfs)暴露出来,路径为 <debugfs_mount_point>/printk/index/<file>,其中 <file> 可以是 vmlinux 以及所有内核模块。

相关的内核配置是一个布尔值,名为 CONFIG_PRINTK_INDEX,默认是关闭的(可以通过常见的 make menuconfig 用户界面找到它,路径是:General setup | Printk indexing debugfs interface)。

在编译启用了该功能的内核后,下面是一些快速示例(注意,我们以 root 身份运行,因为 debugfs 只能 root 访问):

# head -n3 /sys/kernel/debug/printk/index/vmlinux 
# <level/flags> filename:line function "format"
<3> init/main.c:1591 console_on_rootfs "Warning: unable to open an initial console.\n"
<3> init/main.c:1569 kernel_init "Default init %s failed (error %d)\n"

每行的格式由第一行显示,解释很直观。再来看一个例子(查找 "fire"):

# grep -Hn -i -w "fire" /sys/kernel/debug/printk/index/*
/sys/kernel/debug/printk/index/lp:13:<6> drivers/char/lp.c:262 lp_check_status "lp%d on fire\n"

(哈哈,我们在 "使用 printk 日志级别" 这一节看到了这个 printk 示例!)

因此,用户空间的监控守护进程现在可以通过 printk 索引 debugfs 条目查看 vmlinux 和各内核模块的日志消息,确保它们符合预期。如果不是,守护进程可以采取补救措施(比如报告问题等)。

此外,当 PRINTK_INDEX 未启用时,没有任何额外开销。然而,启用 printk 索引后,可能会出现两个问题:首先,许多生产系统中未启用 debugfs;其次,保存数千个格式字符串会占用额外的内核镜像内存(在我的 6.1.25 版本 vmlinux 镜像中,printk 索引检测到接近 12,000 个 printk 实例!)。尽管如此,这确实解决或缓解了问题,因此它被合入主线内核。

练习:启用此功能并构建内核,尝试它的效果。

此外,这里是合入 PRINTK_INDEX 的提交记录:GitHub 链接

快速思考:这个 printk 索引功能和我们之前讨论的动态内核调试功能是否完全相同?不是。动态调试仅涵盖所有调试级别的 printk 调用点,而 printk 索引则涵盖所有 printk 调用点!

在深入了解了 printk 及其相关功能后,您可能会好奇其内部实现细节。LWN Kernel Index 是一个很好的地方,可以找到关于内核各个方面的详细技术文章:LWN Kernel Index。在撰写本文时(2023 年 7 月),你会发现 printk 正在进行大量的优化工作,目的是解决旧的延迟问题,最终为 PREEMPT_RT 的合并铺平道路(第 11 章《CPU 调度器 - 第 2 部分》将介绍 Linux 实时调度的基本概念)。

好了!接下来我们将学习内核模块 Makefile 的基础知识,帮助你了解如何编写和构建模块。

理解内核模块 Makefile 的基础

你可能已经注意到,我们遵循了“每个内核模块单独放在一个目录”的规则。这样确实有助于保持代码的整洁。例如,我们的第二个内核模块 ch4/printk_loglvl。要构建它,我们只需进入其文件夹,输入 make,就可以生成 printk_loglevel.ko 内核模块对象。接下来可以通过 insmod/rmmod 操作加载或卸载该模块。但它是如何通过 make 构建的呢?这正是本节要解释的内容。

首先,我们假设你已经掌握了 make 和 Makefile 的基础知识。如果还不熟悉,请参考本章的进一步阅读部分中的链接(GitHub链接)。

接下来,由于这是我们第一次涉及 LKM 框架及其对应的 Makefile,本节会保持简单。后续我们将在第 5 章的“更好的内核模块 Makefile 模板”部分引入一个更复杂但更易理解的 Makefile。

如你所知,make 命令默认会查找当前目录中的 Makefile 文件。如果存在,它会解析并执行文件中指定的命令。以下是 printk_loglevel 内核模块项目的简单 Makefile:

# ch4/printk_loglvl/Makefile
PWD          := $(shell pwd)
KDIR         := /lib/modules/$(shell uname -r)/build/
obj-m       += printk_loglvl.o

# Enable the pr_debug() and pr_devel() by uncommenting one of the lines below
# (Note: EXTRA_CFLAGS deprecated; use ccflags-y)
#ccflags-y += -DDEBUG
#CFLAGS_printk_loglvl.o := -DDEBUG

all:
    make -C $(KDIR) M=$(PWD) modules
install:
    make -C $(KDIR) M=$(PWD) modules_install
clean:
    make -C $(KDIR) M=$(PWD) clean

Makefile 基本格式如下:

target: [依赖文件]
    规则

规则行必须以 [Tab] 键开头,而不是空格。

如何构建模块

内核的 Kbuild 系统主要使用两个变量 obj-yobj-m 来构建内核或模块。

  • obj-y 包含所有需要编译并合并到最终内核镜像的对象列表。y 代表 “Yes”,表示这些对象将被直接编译进内核。

  • obj-m 则表示需要编译为内核模块的对象。它将模块代码编译为 .o 对象文件,然后添加到 obj-m 列表中。Makefile 中的这行代码:

    obj-m += printk_loglvl.o
    

    就是告诉 Kbuild 系统编译 printk_loglvl.c 源代码并生成 printk_loglvl.o 对象文件。

默认的 make 规则是 all(第 10 和 11 行),执行如下:

all:
    make -C $(KDIR) M=$(PWD) modules

这行代码的执行过程如下:

  1. -C 选项告诉 make 先切换到 $(KDIR) 指定的目录,$(KDIR) 是当前系统的内核构建目录,通常是 /lib/modules/$(uname -r)/build,指向内核头文件安装的地方。

    例如在我的系统中:

    $ ls -l /lib/modules/$(uname -r)/build
    lrwxrwxrwx 1 root root 31 May 5 10:51 build -> /home/c2kp/kernels/linux-6.1.25/
    

    这意味着 make 将切换到内核源代码树的根目录。

  2. make 会解析内核顶级 Makefile,它非常复杂,但包含了构建模块所需的所有关键信息和变量,确保内核模块与当前编译的内核紧密耦合,保证二进制兼容性。

  3. make 然后回到模块的目录,执行模块的构建命令。

installclean 规则

  • install 规则用于安装内核模块:

    install:
        make -C $(KDIR) M=$(PWD) modules_install
    
  • clean 规则用于清理构建过程中生成的临时文件和目标文件:

    clean:
        make -C $(KDIR) M=$(PWD) clean
    

例如,执行 make clean 会删除构建过程中生成的所有临时文件,包括模块对象文件 printk_loglevel.ko

通过这些 Makefile 规则,我们就可以有效地构建、安装和清理内核模块。

image.png

你可以在内核文档中查阅 modules.ordermodules.builtin 文件(以及其他文件)的用途,链接为:elixir.bootlin.com/linux/v6.1.…。如前所述,我们将在下一章引入并使用一个更复杂的 Makefile 变体——一个“更好”的 Makefile。它旨在帮助内核模块/驱动程序开发人员通过运行与内核代码风格检查、静态分析、简单打包以及(一个虚拟目标)动态分析相关的目标来提高代码质量。

至此,本章内容结束。做得好——你已经迈出了学习 Linux 内核开发的重要一步!

总结

在本章中,我们介绍了 Linux 内核架构和 LKM 框架的基础知识。你学习了什么是内核模块,以及它为什么有用。随后我们编写了一个简单但完整的内核模块——非常基础的“Hello, world”模块。然后我们进一步探讨了它的工作原理和编写时需要遵循的编码规范,详细介绍了如何构建、加载、查看模块列表以及卸载模块的过程。我们还深入讨论了使用 printk(及其相关函数)进行内核日志记录的细节,解释了 printk 的日志级别、如何控制输出到控制台等内容。之后我们讲解了如何发出纯调试级别的内核消息,更重要的是,介绍了如何使用内核的动态调试功能。

接着,我们讨论了如何限制 printk 的速率、如何从用户空间生成内核消息、如何标准化其输出格式,以及如何理解新的 printk 索引功能。最后,我们通过理解内核模块 Makefile 的基础知识,掌握了它在 LKM 框架中的工作原理。

我强烈建议你练习这些示例代码(可通过本书的 GitHub 仓库获取),并完成问题/作业,然后继续学习下一章内容,我们将继续探讨编写 Linux 内核模块的知识!