Skip to content

Latest commit

 

History

History
1411 lines (1003 loc) · 92.4 KB

File metadata and controls

1411 lines (1003 loc) · 92.4 KB

四、编写你的第一个内核模块——LKMs 第一部分

欢迎来到您的旅程,了解 Linux 内核开发的一个基本方面——可加载内核模块 ( LKM )框架——以及它将如何被模块用户模块作者使用,后者通常是内核或设备驱动程序程序员。这个话题相当广泛,因此分为两章——这一章和下一章。

在本章中,我们将从快速了解 Linux 内核架构的基础知识开始,这将有助于我们理解 LKM 框架。然后,我们将研究为什么内核模块是有用的,并编写我们自己的简单的你好,世界 LKM,构建和运行它。我们将看到消息是如何写入内核日志的,以及如何理解和使用 LKM Makefile。到本章结束时,您将已经学习了 Linux 内核体系结构和 LKM 框架的基础知识,并应用它编写了一段简单而完整的内核代码。

在本章中,我们将介绍以下食谱:

  • 理解内核架构——第一部分
  • 探索语言学习管理系统
  • 编写我们的第一个内核模块
  • 内核模块上的常见操作
  • 了解内核日志和 printk
  • 了解内核模块 Makefile 的基础知识

技术要求

如果您已经仔细阅读了第 1 章内核工作空间设置,那么接下来的技术先决条件将已经得到解决。(这一章还提到了各种有用的开源工具和项目;我绝对建议你至少浏览一遍。)为了方便大家,我们在这里总结一些要点。

要在 Linux 发行版(或定制系统)上构建和使用内核模块,您至少需要安装以下两个组件:

  • 一个工具链:包括编译器、汇编器、链接器/加载器、C 库以及各种其他的零碎东西。如果构建本地系统,就像我们现在假设的那样,那么任何现代的 Linux 发行版都会预装一个本地工具链。如果没有,简单的安装gcc包给你分发就足够了;在基于 Ubuntu 或 Debian 的 Linux 系统上,使用以下命令:
sudo apt install gcc
  • 内核头:这些头将在编译期间使用。实际上,您安装的包不仅适合安装内核头,还适合将其他所需的部分(如内核 Makefile)安装到系统上。同样,任何现代的 Linux 发行版都将/应该预先安装内核头。如果没有(可以使用dpkg(1)检查,如下图),只需安装软件包供您分发即可;在基于 Ubuntu 或 Debian 的 Linux 系统上,使用以下命令:
$ sudo apt install linux-headers-generic $ dpkg -l | grep linux-headers | awk '{print $1, $2}'
ii linux-headers-5.3.0-28
ii linux-headers-5.3.0-28-generic
ii linux-headers-5.3.0-40
ii linux-headers-5.3.0-40-generic
ii linux-headers-generic-hwe-18.04
$ 

这里,使用dpkg(1)实用程序的第二个命令只是用来验证linux-headers包是否确实已安装。

This package may be named kernel-headers-<ver#> on some distributions. Also, for development directly on a Raspberry Pi, install the relevant kernel headers package named raspberrypi-kernel-headers.

这本书的整个源代码树可以在位于https://github.com/PacktPublishing/Linux-Kernel-Programming的 GitHub 存储库中找到,本章的代码在ch4目录下。我们绝对希望你克隆它:

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

本章代码在其目录同名chn下(其中n为章节号;所以在这里,它在ch4/下面。

理解内核架构–第 1 部分

在这一节中,我们开始加深对内核的理解。更具体地说,这里我们深入研究什么是用户和内核空间,以及构成 Linux 内核的主要子系统和各种组件。目前,这些信息是在更高的抽象层次上处理的,并且有意保持简短。我们将在第 6 章内核内部要素-进程和线程 中深入研究内核的结构。

用户空间和内核空间

现代微处理器至少支持两种特权级别。作为一个真实的例子,英特尔/AMD x86[-64]系列支持四种特权级别(他们称之为环级别),ARM (32 位)微处理器系列最多支持七种(ARM 称之为执行模式;六个是特权的,一个是非特权的)。

这里的关键点是,为了平台的安全性和稳定性,在这些处理器上运行的所有现代操作系统将利用(至少)两个特权级别(或模式):

  • 用户空间:供应用非特权用户模式下运行
  • 内核空间:供内核(及其所有组件)以特权模式运行–内核模式

下图显示了这个基本架构:

Figure 4.1 – Basic architecture – two privilege modes

下面是关于 Linux 系统架构的一些细节;一定要读下去。

库和系统调用 API

用户空间应用通常依赖应用编程接口 ( 应用接口)来执行工作。一个本质上是一个 API 的集合或归档,允许你使用一个标准化的、编写良好的、测试良好的接口(并利用通常的好处:不必重新发明轮子、可移植性、标准化等等)。Linux 系统有几个库;即使是企业级系统上的数百个也并不少见。其中,所有 usermode Linux 应用(可执行文件)都被“自动链接”到一个重要的、始终使用的库:glibc–GNU 标准 C 库,您将会了解到。然而,库只在用户模式下可用;内核没有库(在下一章中会有更多的介绍)。

库 API 的例子是众所周知的printf(3)(回想一下,来自第 1 章内核工作区设置,可以找到该 API 的手册页部分)、scanf(3)strcmp(3)malloc(3)free(3)

现在,一个关键点是:如果用户和内核是分开的地址空间,并且处于不同的权限级别,用户进程如何访问内核?简短的回答是通过系统调用。 A 系统调用是一个特殊的 API,从某种意义上说,它是用户空间进程访问内核的唯一合法(同步)方式。换句话说,系统调用是进入内核空间的唯一合法的入口点。它们能够从非特权用户模式切换到特权内核模式(在进程和中断上下文部分下的第 6 章内核内部要素–进程和线程中有更多关于这一点和整体设计的内容)。系统调用的例子包括fork(2)execve(2)open(2)read(2)write(2)socket(2)accept(2)chmod(2)等等。

Look up all library and system call APIs in the man pages online:

这里要强调的一点是,用户应用和内核实际上只是通过系统调用进行通信;这就是界面。在这本书里,我们不深入探讨这些细节。如果您有兴趣了解更多,请参考 Packt(具体为第一章,Linux 系统架构)的《用 Linux 进行系统编程的实践》。

内核空间组件

当然,这本书完全专注于内核空间。如今的 Linux 内核是一个相当庞大而复杂的庞然大物。在内部,它由几个主要的子系统和几个组件组成。内核子系统和组件的广泛列举产生了以下列表:

  • Core 内核:这段代码处理任何现代操作系统的典型核心工作,包括(用户和内核)进程和线程创建/销毁、CPU 调度、同步原语、信令、定时器、中断处理、名称空间、cgroups、模块支持、加密等等。

  • 内存管理(MM) :处理所有内存相关的工作,包括内核和进程的设置和维护虚拟地址空间 ( 花瓶)。

  • VFS(针对文件系统支持):虚拟文件系统交换机 ( VFS )是在 Linux 内核中实现的实际文件系统之上的抽象层(例如,ext[2|4]vfatreiserfsntfsmsdosiso9660、JFFS2 和 UFS)。

  • Block IO :实现实际文件 I/O 的代码路径,从 VFS 一直到 Block 设备驱动程序以及其间的一切(真的,很多!),就包含在这里了。

  • 网络协议栈 : Linux 以其精确、不折不扣的 RFC 而闻名,在模型的所有层高质量地实现了众所周知(也不那么知名)的网络协议,其中 TCP/IP 可能是最著名的。

  • 进程间通信(IPC)支持:IPC 机制的实现在这里完成;Linux 支持消息队列、共享内存、信号量(旧的 SysV 和新的 POSIX)以及其他 IPC 机制。

  • 声音支持:实现音频的所有代码都在这里,从固件到驱动和编解码器。

  • 虚拟化支持 : Linux 已经在大大小小的云提供商中变得非常受欢迎,一个很大的原因是其高质量、低占用空间的虚拟化引擎,基于内核的虚拟机 ( KVM )。

所有这些构成了主要的核心子系统;此外,我们还有这些:

  • 特定于内存的代码
  • 内核初始化
  • 安全框架
  • 许多类型的设备驱动程序

Recall that in Chapter 2, Building the 5.x Linux Kernel from Source – Part 1, the A brief tour of the kernel source tree section gave the kernel source tree (code) layout corresponding to the major subsystems and other components.

众所周知,Linux 内核遵循单片内核架构。本质上,单片设计是所有内核组件(我们在本节中提到的)都位于并共享内核地址空间(或内核段*)的设计。这可以从下图中清楚地看到:*

*

Figure 4.2 – Linux kernel space - major subsystems and blocks

你应该知道的另一个事实是,这些地址空间当然是虚拟的,而不是物理的。内核将(利用 MMU/TLB/缓存等硬件)在页面粒度级别将虚拟页面映射到物理页面框架。它通过使用内核分页表将内核虚拟页面映射到物理框架来实现这一点,并且对于每个活动的进程,它通过每个进程的单独分页表将进程的虚拟页面映射到物理页面框架。

More in-depth coverage of the essentials of the kernel and memory management architecture and internals awaits you in Chapter 6, Kernel Internals Essentials – Processes and Threads (and more chapters that follow).

现在我们已经对用户和内核空间有了基本的了解,让我们继续前进,开始我们进入 LKM 框架的旅程。

探索语言学习管理系统

简单地说,内核模块是一种提供内核级功能的方法,而不需要在内核源代码树中工作。

设想一个场景,在这个场景中,您必须向 Linux 内核添加一个支持特性——也许是一个新的设备驱动程序,以便使用某个硬件外围芯片、新的文件系统或新的输入/输出调度程序。一种方法很明显:用新代码更新内核源代码树,构建它,并测试它。

虽然这看起来很简单,但实际上要做很多工作——我们编写的代码中的每一个变化,无论多么微小,都需要我们重建内核映像,然后重新启动系统来测试它。一定有更干净、更容易的方法;的确有–LKM 框架

LKM 框架

LKM 框架是一种在内核源代码树之外编译一段内核代码的手段,通常被称为“树外”代码,在有限的意义上保持它独立于内核,然后将其插入或将其插入内核内存,让它运行并执行其工作,然后将其从内核内存中移除(或拔掉*)。*

内核模块的源代码,通常由一个或多个 C 源文件、头文件和一个 Makefile 组成,被构建(当然是通过make(1))到内核模块中。内核模块本身只是一个二进制目标文件,而不是二进制可执行文件。在 Linux 2.4 及更早版本中,内核模块的文件名有一个.o后缀;在现代 2.6 Linux 和更高版本上,它反而有一个.ko(kerneloobject)后缀。一旦构建完成,您可以在运行时将这个.ko文件——内核模块——插入到实时内核中,有效地使其成为内核的一部分。

Note that not all kernel functionality can be provided via the LKM framework. Several core features, such as the core CPU scheduler code, memory manage the signaling, timer, interrupt management code paths, and so on, can only be developed within the kernel itself. Similarly, a kernel module is only allowed access to a subset of the full kernel API; more on this later.

你可能会问:我如何将一个对象插入到内核中?让我们保持简单——答案是:通过insmod(8)实用程序。现在,让我们跳过细节(这些将在即将到来的运行内核模块部分解释)。下图概述了首先构建内核模块,然后将内核模块插入内核内存的过程:

Figure 4.3 – Building and then inserting a kernel module into kernel memory Worry not: the actual code for both the kernel module C source as well as its Makefile is dealt with in detail in an upcoming section; for now, we want to gain a conceptual understanding only.

内核模块装入并驻留在内核内存中,即内核分配给它的空间区域中的内核 VAS(图 4.3的下半部分)。毫无疑问,是内核代码,以内核权限运行。这样,内核(或驱动程序)开发人员就不必每次都重新配置、重建和重启系统。你所要做的就是编辑内核模块的代码,重建它,从内存中移除旧的副本(如果它存在的话),并插入新的版本。它节省时间,提高生产率。

内核模块有优势的一个原因是它们适合动态产品配置。例如,内核模块可以被设计成以不同的价位提供不同的功能;为嵌入式产品生成最终映像的脚本可以安装一组给定的内核模块,这取决于客户愿意支付的价格。这是另一个在调试或故障排除场景中如何利用这项技术的例子:内核模块可以用来动态生成现有产品的诊断和调试日志。kprobes 之类的技术就允许这样。

实际上,LKM 框架通过允许我们在内核内存中插入和移除活动代码,为我们提供了一种动态扩展内核功能的方法。这种随意插拔内核功能的能力让我们意识到,Linux 内核并不是纯粹的单片,它也是模块化的。

内核源代码树中的内核模块

事实上,内核模块对象对我们来说并不完全陌生。在第 3 章从源代码构建 5.x Linux 内核-第 2 部分中,我们构建了内核模块作为内核构建过程的一部分,并安装了它们。

回想一下,这些内核模块是内核源代码的一部分,并且已经通过在三态内核 menuconfig 提示符中选择M配置为模块。它们被安装到/lib/modules/$(uname -r)/下的目录中。因此,要了解安装在当前运行的 Ubuntu 18.04.3 LTS 来宾内核下的内核模块,我们可以这样做:

$ lsb_release -a 2>/dev/null |grep Description
Description:    Ubuntu 18.04.3 LTS
$ uname -r
5.0.0-36-generic
$ find /lib/modules/$(uname -r)/ -name "*.ko" | wc -l
5359

好吧,Canonical 和其他地方的人一直很忙!超过五千个内核模块...想想看——这是有道理的:分销商无法事先确切知道用户最终会使用什么硬件外设(尤其是在基于 x86 的系统等通用计算机上)。内核模块是一种方便的方式,可以支持大量的硬件,而不会过度膨胀内核镜像文件(例如bzImagezImage)。

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

$ ls /lib/modules/5.0.0-36-generic/kernel/
arch/  block/  crypto/  drivers/  fs/  kernel/  lib/  mm/  net/  samples/  sound/  spl/  ubuntu/  virt/  zfs/
$ ls /lib/modules/5.4.0-llkd01/kernel/
arch/  crypto/  drivers/  fs/  net/  sound/
$ 

在这里,查看发行版内核/lib/modules/$(uname -r)kernel/目录的顶层(Ubuntu 18.04.3 LTS 运行5.0.0-36-generic内核),我们看到里面有许多子文件夹和几千个内核模块。相比之下,我们构建的内核(详见第 2 章从源代码构建 5.x Linux 内核–第 1 部分第 3 章从源代码构建 5.x Linux 内核–第 2 部分)就少多了。您会从我们在第 2 章从源代码构建 5.x Linux 内核–第 1 部分中的讨论中回想起,我们故意使用localmodconfig目标来保持构建的小而快。因此,在这里,我们的定制 5.4.0 内核只有大约 60 多个内核模块是针对它构建的

内核模块大量使用的一个领域是设备驱动程序。举个例子,让我们来看看一个被设计成内核模块的网络设备驱动程序。你可以找到几个(也有熟悉的品牌!)在发行版内核的kernel/drivers/net/ethernet文件夹下:

Figure 4.4 – Content of our distro kernel's ethernet network drivers (kernel modules)

许多基于英特尔的笔记本电脑上流行的是英特尔 1gb网络接口卡 ( 网卡)以太网适配器。驱动它的网络设备驱动程序称为e1000驱动程序。我们的 x86-64 Ubuntu 18.04.3 客户机(运行在 x86-64 主机笔记本电脑上)显示它确实使用了该驱动程序:

$ lsmod | grep e1000
e1000                 139264  0

我们将很快更详细地介绍lsmod(8)(“列表模块”)实用程序。对我们来说更重要的是,我们可以看到它是一个内核模块!获取关于这个特定内核模块的更多信息怎么样?利用modinfo(8)实用程序很容易做到这一点(为了可读性,我们在这里截断了它的详细输出):

$ ls -l /lib/modules/5.0.0-36-generic/kernel/drivers/net/ethernet/intel/e1000
total 220
-rw-r--r-- 1 root root 221729 Nov 12 16:16 e1000.ko
$ modinfo /lib/modules/5.0.0-36-generic/kernel/drivers/net/ethernet/intel/e1000/e1000.ko
filename:       /lib/modules/5.0.0-36-generic/kernel/drivers/net/ethernet/intel/e1000/e1000.ko
version:        7.3.21-k8-NAPI
license:        GPL v2
description:    Intel(R) PRO/1000 Network Driver
author:         Intel Corporation, <linux.nics@intel.com>
srcversion:     C521B82214E3F5A010A9383
alias:          pci:v00008086d00002E6Esv*sd*bc*sc*i*
[...]
name:           e1000
vermagic:       5.0.0-36-generic SMP mod_unload 
[...]
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(8)实用程序允许我们窥视内核模块的二进制图像,并提取一些关于它的细节;关于使用modinfo的更多信息,请参见下一节。

Another way to gain useful information on the system, including information on kernel modules that are currently loaded up, is via the systool(1) utility. For an installed kernel module (details on installing a kernel module follow in the next chapter in the Auto-loading modules on system boot section), doing systool -m <module-name> -v reveals information about it. Look up the systool(1) man page for usage details.

底线是内核模块已经成为构建和分发某些类型内核组件的实用方式,其中设备驱动程序是它们最常用的用例。其他用途包括但不限于文件系统、网络防火墙、数据包嗅探器和定制内核代码。

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

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

当介绍一种新的编程语言或主题时,模仿原始的 K & R Hello,world 程序作为第一段代码已经成为一种被广泛接受的计算机编程传统。我很高兴遵循这一受人尊敬的传统来介绍强大的 LKM 框架。在本节中,您将学习编写简单 LKM 代码的步骤。我们详细解释代码。

介绍我们的你好,世界 LKM C 代码

下面是一些简单的 Hello,world C 代码,实现时遵守 Linux 内核的 LKM 框架:

For reasons of readability and space constraints, only the key parts of the source code are displayed here. To view the complete source code, build it, and run it, the entire source tree for this book is available in it's GitHub repository here: https://github.com/PacktPublishing/Linux-Kernel-Programming. We definitely expect you to clone it: git clone https://github.com/PacktPublishing/Linux-Kernel-Programming.git

// ch4/helloworld_lkm/hellowworld_lkm.c
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>

MODULE_AUTHOR("<insert your name here>");
MODULE_DESCRIPTION("LLKD book:ch4/helloworld_lkm: hello, world, our first LKM");
MODULE_LICENSE("Dual MIT/GPL");
MODULE_VERSION("0.1");

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

static void __exit helloworld_lkm_exit(void)
{
    printk(KERN_INFO "Goodbye, world\n");
}

module_init(helloworld_lkm_init);
module_exit(helloworld_lkm_exit);

你可以马上试用这个简单的你好,世界内核模块!只需cd到这里所示的正确的源目录,并获得我们的助手lkm脚本来构建和运行它:

$ cd <...>/ch4/helloworld_lkm
$ ../../lkm helloworld_lkm
Version info:
Distro:     Ubuntu 18.04.3 LTS
Kernel: 5.0.0-36-generic
[...]
dmesg[ 5399.230367] Hello, world
$ 

如何和为什么将很快详细解释。尽管很小,但我们第一个内核模块的代码需要仔细阅读和理解。一定要读下去。

分解它

以下小节解释了前面你好,世界 C 代码的每一行。请记住,尽管这个程序看起来非常小和琐碎,但是关于它和周围的 LKM 框架有很多需要理解的地方。这一章的其余部分集中在这一点上,并深入细节。我强烈建议你先花时间通读并理解这些基础知识。这将在以后可能难以调试的情况下极大地帮助您。

内核头

我们用#include来表示几个头文件。与用户空间“C”应用开发不同,这些是内核头(如技术要求部分所述)。回想一下第 3 章从源代码构建 5.x Linux 内核–第 2 部分,内核模块安装在特定的根可写分支下。让我们再次检查一下(在这里,我们在我们的来宾 x86_64 Ubuntu 虚拟机上运行 5.0.0-36 通用发行版内核):

$ ls -l /lib/modules/$(uname -r)/
total 5552
lrwxrwxrwx  1 root root      39 Nov 12 16:16 build -> /usr/src/linux-headers-5.0.0-36-generic/
drwxr-xr-x  2 root root    4096 Nov 28 08:49 initrd/
[...]

请注意名为build的符号或软链接。它指向系统上内核头的位置。在前面的代码中,它在/usr/src/linux-headers-5.0.0-36-generic/下面!正如您将看到的,我们将把这些信息提供给用于构建内核模块的 Makefile。(另外,一些系统有一个类似的软链接,叫做source)。

The kernel-headers or linux-headers package unpacks a limited kernel source tree onto the system, typically under /usr/src/.... This code, however, isn't complete, hence our use of the phrase limited source tree. This is because the complete kernel source tree isn't required for the purpose of building modules – just the required components (the headers, the Makefiles, and so on) are what's packaged and extracted.

我们的 Hello,world 内核模块的第一行代码是#include <linux/init.h>

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

$ ls -l /usr/src/linux-headers-5.0.0-36-generic/include/linux/init.h
-rw-r--r-- 1 root root 9704 Mar  4  2019 /usr/src/linux-headers-5.0.0-36-generic/include/linux/init.h

内核模块源代码中包含的其他内核头也是如此。

模块宏

接下来,我们有几个MODULE_FOO()形式的模块宏;大多数都很直观:

  • MODULE_AUTHOR():指定内核模块的作者
  • MODULE_DESCRIPTION():简述这个 LKM 的功能
  • MODULE_LICENSE():指定发行此内核模块的许可证
  • MODULE_VERSION():指定内核模块的(本地)版本

如果没有源代码,这些信息将如何传达给最终用户(或客户)?啊,modinfo(8)实用程序正是这么做的!这些宏及其信息看似微不足道,但在项目和产品中却很重要。例如,供应商通过在所有已安装内核模块的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函数返回值遵循的重要约定。

0/-E 返回约定

内核模块的init 功能是返回一个类型为int的值;这是一个关键方面。关于从内核空间到用户空间进程的返回值,Linux 内核已经进化出了一种风格或惯例。LKM 框架遵循俗称的0/-E公约:

  • 成功后,返回整数值0
  • 失败后,返回您希望将用户空间全局未初始化整数errno设置为的负值。

Be aware that errno is a global residing in a user process VAS within the uninitialized data segment. With very few exceptions, whenever a Linux system call fails, -1 is returned and errno is set to a positive value, representing the failure code; this work is carried out by glibc "glue" code on the syscall return path.

Furthermore, the errno value is actually an index into a global table of English error messages (const char * const sys_errlist[]); this is really how routines such as perror(3), strerror[_r](3) and the like can print out failure diagnostics.

By the way, you can look up the complete list of error codes available to you from within these (kernel source tree) header files: include/uapi/asm-generic/errno-base.h and include/uapi/asm-generic/errno.h.

一个如何从内核模块的init 函数返回的快速例子将有助于明确这一点:假设我们的内核模块的init 函数正在尝试动态分配一些内核内存(关于kmalloc() API 等的细节将在后面的章节中介绍);请暂时忽略它)。然后,我们可以这样编码:

[...]
ptr = kmalloc(87, GFP_KERNEL);
if (!ptr) {
    pr_warning("%s:%s:%d: kmalloc failed!\n", __FILE__, __func__, __LINE__);
    return -ENOMEM;
}
[...]
return 0;   /* success */

如果内存分配失败(非常不可能,但是嘿,它可能会发生!),我们执行以下操作:

  1. 首先,我们发出警告printk。事实上,在这种特殊的情况下——“出于记忆”——这是迂腐和不必要的。如果内核空间内存分配失败,内核肯定会发出足够的诊断信息!详见本链接:https://lkml.org/lkml/2014/6/10/382;我们在这里这样做仅仅是因为它在讨论的早期,为了读者的连续性。
  2. 返回-ENOMEM值:
    • 该值在用户空间返回到的图层实际上是glibc;它有一些“胶水”代码,将这个值乘以-1并将全局整数errno设置为它。
    • 现在,[f]init_module(2)系统调用将返回-1,表示失败(这是因为insmod(8)实际上调用了这个系统调用,您很快就会看到)。
    • errno将设置为ENOMEM,反映内核模块插入失败是因为内存分配失败。

相反,框架期望init函数在成功时返回值0。事实上,在旧的内核版本中,成功时不返回0会导致内核模块突然从内核内存中卸载。现在,内核模块的这种移除不会发生,但是内核会发出一条警告消息,告知已经返回了一个可疑的非零值。

清理程序没什么好说的。它不接收任何参数,也不返回任何内容(void)。它的工作是在内核模块从内核内存卸载之前执行任何和所有需要的清理。

Not including the module_exit() macro in your kernel module makes it impossible to ever unload it (notwithstanding a system shutdown or reboot, of course). Interesting... (I suggest you try this out as a small exercise!).

Of course, it's never that simple: this behavior preventing the unload is guaranteed only if the kernel is built with the CONFIG_MODULE_FORCE_UNLOAD flag set to Disabled (the default).

ERR_PTR 和 PTR_ERR 宏

关于返回值的讨论,您现在明白了内核模块的init例程必须返回一个整数。如果你想返回一个指针呢?ERR_PTR()内联函数来拯救我们,允许我们返回一个指针伪装成一个整数,只需把它打造成void *。它实际上变得更好:您可以使用IS_ERR()内联函数(它实际上只是计算出该值是否在[-1 到-4095]的范围内)来检查错误,通过ERR_PTR()内联函数将一个负错误值编码到指针中,使用逆向例程PTR_ERR()从指针中检索该值。

举个简单的例子,看看这里给出的被调用者代码。这一次,我们让(示例)函数myfunc()返回一个指针(指向名为mystruct的结构),而不是一个整数:

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

调用者代码如下:

[...]
gmys = myfunc();
if (IS_ERR(gmys)) {
    pr_warn("%s: myfunc alloc failed, aborting...\n", OURMODNAME);
    stat = PTR_ERR(gmys); /* sets 'stat' to the value -ENOMEM */
    goto out_fail_1;
}
[...]
return stat;
out_fail_1:
    return stat;
}

仅供参考,内联ERR_PTR()PTR_ERR()IS_ERR()函数都位于(内核头)include/linux/err.h文件中。内核文档(https://kernel . readed docs . io/en/sphinx-samples/kernel-hacking . html # return-约定)谈到了内核函数的返回约定。此外,您可以在内核源代码树中的crypto/api-samples代码下找到这些函数的用法示例:https://www . kernel . org/doc/html/v 4.17/crypto/API-samples . html

__init 和 __exit 关键字

一个小问题:我们在前面的函数签名中看到的__init__exit宏到底是什么?这些只是链接器插入的内存优化属性。

__init宏为代码定义了一个init.text部分。类似地,任何用__initdata属性声明的数据都会进入init.data部分。这里的重点是init函数中的代码和数据在初始化期间只使用一次。一旦被调用,就不会再被调用;因此,一旦被调用,它就会被释放(通过free_initmem())。

这笔交易类似于__exit宏,当然,这仅适用于内核模块。一旦调用cleanup函数,所有的内存都会被释放。如果代码是静态内核映像的一部分(或者如果模块支持被禁用),这个宏将没有任何作用。

很好,但是到目前为止,我们还没有解释一些实用性:如何将内核模块对象放入内核内存,让它执行,然后卸载它,再加上您可能希望执行的其他操作。让我们在下一节讨论这些。

内核模块上的常见操作

现在让我们深入研究如何构建、加载和卸载内核模块。除此之外,我们还将浏览关于非常有用的printk()内核 API 的基础知识,列出当前加载的带有lsmod(8)的内核模块的细节,以及用于在内核模块开发过程中自动化一些常见任务的便利脚本。那么,让我们开始吧!

构建内核模块

We definitely urge you to try out our simple Hello, world kernel module exercise (if you haven't already done so)! To do so, we assume you have cloned this book's GitHub repository (https://github.com/PacktPublishing/Linux-Kernel-Programming) already. If not, please do so now (refer to the Technical requirements section for details).

在这里,我们一步一步地展示如何构建我们的第一个内核模块,然后将它插入内核内存。再次提醒一下:我们已经在运行 Ubuntu 18.04.3 LTS 发行版的 x86-64 Linux 来宾虚拟机(在 Oracle VirtualBox 6.1 下)上执行了以下步骤:

  1. 改为本书的源代码章节目录和子目录。我们的第一个内核模块位于它自己的文件夹中(这是应该的!)称为helloworld_lkm:
 cd <book-code-dir>/ch4/helloworld_lkm

<book-code-dir> is, of course, the folder into which you cloned this book's GitHub repository; here (see the screenshot, Figure 4.5), you can see that it's /home/llkd/book_llkd/Linux-Kernel-Programming/.

  1. 现在验证代码库:
$ pwd
*<book-code-dir>*/ch4/helloworld_lkm
$ ls -l
total 8
-rw-rw-r-- 1 llkd llkd 1211 Jan 24 13:01 helloworld_lkm.c
-rw-rw-r-- 1 llkd llkd  333 Jan 24 13:01 Makefile
$ 
  1. make建造:

Figure 4.5 – Listing and building our very first Hello, world kernel module

前面的截图显示内核模块已经成功构建。是./helloworld_lkm.ko档。(另外,请注意,我们是从前面几章中构建的定制 5.4.0 内核启动的,因此构建了内核模块。)

运行内核模块

当然,为了让内核模块运行,您需要首先将其加载到内核内存空间中。这就是所谓的模块插入内核内存。

将内核模块放入 Linux 内核段有几种方法,最终归结为调用其中一个[f]init_module(2)系统调用。为了方便起见,有几个包装实用程序可以做到这一点(或者您总是可以编写一个)。我们将使用下面流行的insmod(8)(阅读为“ ins ert mod ule”)实用程序;insmod的参数是要插入的内核模块的路径名:

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

它失败了!事实上,原因应该很明显。想想看:在非常真实的意义上,将代码插入内核甚至比成为系统上的(超级用户)更优越——我再次提醒你:这是内核代码,将以内核特权运行。如果任何一个用户都被允许插入或移除内核模块,那么黑客们将会大有作为!部署恶意代码将变得相当琐碎。因此,出于安全原因,只有拥有 root 访问权限,才能插入或移除内核模块

Technically, being root implies that the process' (or thread's) Real and/or Effective UID (RUID/EUID) value is the special value zero. Not just that, but the modern kernel "sees" a thread as having certain capabilities(via the modern and superior POSIX Capabilities model); only a process/thread with the CAP_SYS_MODULE capability can (un)load kernel modules. We refer the reader to the man page on capabilities(7) for more details.

因此,让我们再次尝试将我们的内核模块插入内存,这次是通过sudo(8)特权:

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

现在成功了!如前所述,insmod(8)实用程序通过调用[f]init_module(2)系统调用来工作。insmod(8)实用程序(实际上是内部的[f]init_module(2)系统调用)何时会失败

有几个例子:

  • 权限:非 root 运行或缺少CAP_SYS_MODULE能力(errno <- EPERM)。
  • proc文件系统/proc/sys/kernel/modules_disabled内的内核可调参数设置为1(默认为0)。
  • 内核内存中已经有一个同名的内核模块(errno <- EEXISTS)。

好吧,一切看起来都很好。$?结果为0意味着前一个 shell 命令成功。太好了,但是我们的你好,世界信息在哪里?继续读!

首先快速看一下内核 printk()

为了发出消息,用户空间 C 开发人员通常会使用值得信赖的printf(3) glibc API(或者在编写 C++代码时使用cout)。然而,重要的是要理解在内核空间中,没有库。因此,我们只是做而不是有机会接触到好的旧printf()原料药*。*相反,它本质上已经在内核中被重新实现为printk()内核 API(好奇它的代码在哪里?它在内核源代码树中:kernel/printk/printk.c:printk())。

通过printk()应用编程接口发送消息很简单,非常类似于通过printf(3)发送消息。在我们简单的内核模块中,这里是操作发生的地方:

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

虽然乍一看很像printf,但是printk真的很不一样。就相似性而言,应用编程接口接收格式字符串作为其参数。格式字符串与printf非常相似。

但相似之处到此为止。printfprintk的关键区别在于:用户空间printf(3)库 API 通过按照请求格式化文本字符串并调用write(2)系统调用来工作,该系统调用反过来实际上执行对stdout 设备的写入,默认情况下是终端窗口(或控制台设备)。内核printk API 也按照请求格式化其文本字符串,但是其输出 目的地不同。它至少写到一个地方——下面列表中的第一个地方——并且可能写到更多的地方:

  • 内存中的内核日志缓冲区(易失性)
  • 日志文件,内核日志文件(非易失性)
  • 控制台设备

For now, we shall skip the inner details regarding the workings of printk. Also, please ignore the KERN_INFO token within the printk API; we shall cover all this soon enough.

当你通过printk发出一条消息时,保证输出进入内核内存(RAM)的日志缓冲区。这实际上构成了内核日志。需要注意的是,在运行 X 服务器进程的图形模式下工作时,您永远不会直接看到printk输出(在典型的 Linux 发行版上工作时的默认环境)。所以,这里显而易见的问题是:如何查看内核日志缓冲区内容?有几种方法。现在,让我们利用快速简单的方法。

使用dmesg(1)实用程序!默认情况下,dmesg会将整个内核日志缓冲区内容转储到 stdout。在这里,我们用它查找内核日志缓冲区的最后两行:

$ dmesg | tail -n2
[ 2912.880797] hello: loading out-of-tree module taints kernel.
[ 2912.881098] Hello, world
$ 

终于来了:我们的你好,世界消息!

You can simply ignore the loading out-of-tree module taints kernel. message for now. For security reasons, most modern Linux distros will mark the kernel as tainted (literally, "contaminated" or "polluted") if a third party "out-of-tree" (or non-signed) kernel module is inserted. (Well, it's really more of a pseudo-legal cover-up along the lines of: "if something goes wrong from this point in time onward, we are not responsible, and so on..."; you get the idea).

为了更加多样化,这里是我们的 Hello,world 内核模块在运行 5.4 Linux LTS 内核的 x86-64 CentOS 8 客户机上被插入和移除的截图(细节如下)(我们在第一章和第二章中详细显示了定制的内容):

Figure 4.6 – Screenshot showing our working with the Hello, world kernel module on a CentOS 8 x86-64 guest

在内核日志中,如dmesg(1)实用程序所示,最左边一列中的数字是一个简单的时间戳,以[seconds.microseconds]格式表示系统启动后经过的时间(但不建议将其视为完全准确)。顺便说一下,这个时间戳是一个名为CONFIG_PRINTK_TIMEKconfig变量——一个内核配置选项;它可以被printk.time内核参数覆盖。

列出实时内核模块

回到我们的内核模块:到目前为止,我们已经构建了它,将其加载到内核中,并验证其入口点helloworld_lkm_init()函数被调用,从而执行printk API。那么现在,它是做什么的?嗯,真的没什么;内核模块仅仅(高兴地?)坐在内核内存中,什么也不做。事实上,我们可以很容易地用lsmod(8)实用程序来查找它:

$ lsmod | head
Module                  Size  Used by
helloworld_lkm         16384  0
isofs                  32768  0
fuse                  139264  3
tun                    57344  0
[...]
e1000                 155648  0
dm_mirror              28672  0
dm_region_hash         20480  1 dm_mirror
dm_log                 20480  2 dm_region_hash,dm_mirror
dm_mod                151552  11 dm_log,dm_mirror
$

lsmod显示当前驻留在内核内存中的所有内核模块(或活动的),按照相反的时间顺序排序。它的输出是列格式的,有三列和可选的第四列。让我们分别看一下每一列:

  • 第一列显示内核模块的名称
  • 第二列是内核中的(静态)大小,以字节为单位。
  • 第三列是模块使用次数
  • 可选的第四列(以及随后的更多内容)将在下一章(在理解模块堆叠部分)中解释。此外,在最近的 x86-64 Linux 内核上,至少 16 KB 的内核内存似乎被内核模块占用。)

所以,太好了:到目前为止,你已经成功地将第一个内核模块构建、加载并运行到内核内存中,它基本上可以工作了:接下来呢?这个没什么特别的!我们只是在下一节学习如何卸载它。当然还会有更多...继续前进!

从内核内存中卸载模块

要卸载内核模块,我们使用便利实用程序rmmod(8) ( 移除模块):

$ rmmod 
rmmod: ERROR: missing module name.
$ rmmod helloworld_lkm
rmmod: ERROR: 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 llkd: 
$ dmesg |tail -n2
[ 2912.881098] Hello, world
[ 5551.863410] Goodbye, world
$

rmmod(8)的参数是内核模块的名称(如lsmod(8)第一列所示),而不是路径名。显然,就像insmod(8)一样,我们需要作为用户运行rmmod(8)实用程序才能成功。

在这里,我们还可以看到,由于我们的rmmod,内核模块的退出例程(或“析构函数”)helloworld_lkm_exit() 函数被调用。它反过来调用了printk,后者发出了再见,世界的信息(我们用dmesg查找)。

*rmmod(注意内部变成了delete_module(2)系统调用)什么时候可以失败?以下是一些案例:

  • 权限:如果不是以 root 身份运行或者缺少CAP_SYS_MODULE能力(errno <- EPERM)。

  • 如果内核模块的代码和/或数据正被另一个模块使用(如果存在依赖关系;这将在下一章的模块堆叠部分详细介绍)或者模块当前正被进程(或线程)使用,则模块使用计数将为正,rmmod将失败(errno <- EBUSY)。

  • 内核模块没有使用module_exit()指定退出例程(或析构函数)CONFIG_MODULE_FORCE_UNLOAD内核配置选项被禁用。

几个与模块管理相关的便利实用程序只不过是单一kmod(8)实用程序的符号(软)链接(类似于流行的 busybox 实用程序)。饺子皮有lsmod(8), rmmod(8)insmod(8)modinfo(8)modprobe(8)depmod(8)。看一看其中的几个:

$ ls -l $(which insmod) ; ls -l $(which lsmod) ; ls -l $(which rmmod)
lrwxrwxrwx 1 root root 9 Oct 24 04:50 /sbin/insmod -> /bin/kmod
lrwxrwxrwx 1 root root 9 Oct 24 04:50 /sbin/lsmod -> /bin/kmod
lrwxrwxrwx 1 root root 9 Oct 24 04:50 /sbin/rmmod -> /bin/kmod
$ 

请注意,这些实用程序(/bin/sbin/usr/sbin)的精确位置会随着分布而变化。

我们的 lkm 便利脚本

让我们用一个简单但有用的名为lkm的定制 Bash 脚本来结束这个第一个内核模块的讨论,这个脚本通过自动化内核模块构建、加载、dmesg和卸载工作流来帮助您。在这里(完整的代码在书的源代码树的根中):

#!/bin/bash
# lkm : a silly kernel module dev - build, load, unload - helper wrapper script
[...]
unset ARCH
unset CROSS_COMPILE
name=$(basename "${0}")

# Display and run the provided command.
# Parameter(s) : the command to run
runcmd()
{
    local SEP="------------------------------"
    [ $# -eq 0 ] && return
    echo "${SEP}
$*
${SEP}"
    eval "$@"
    [ $? -ne 0 ] && echo " ^--[FAILED]"
}

### "main" here
[ $# -ne 1 ] && {
  echo "Usage: ${name} name-of-kernel-module-file (without the .c)"
  exit 1
}
[[ "${1}" = *"."* ]] && {
  echo "Usage: ${name} name-of-kernel-module-file ONLY (do NOT put any extension)."
  exit 1
}
echo "Version info:"
which lsb_release >/dev/null 2>&1 && {
  echo -n "Distro: "
  lsb_release -a 2>/dev/null |grep "Description" |awk -F':' '{print $2}'
}
echo -n "Kernel: " ; uname -r
runcmd "sudo rmmod $1 2> /dev/null"
runcmd "make clean"
runcmd "sudo dmesg -c > /dev/null"
runcmd "make || exit 1"
[ ! -f "$1".ko ] && {
  echo "[!] ${name}: $1.ko has not been built, aborting..."
  exit 1
}
runcmd "sudo insmod ./$1.ko && lsmod|grep $1"
runcmd dmesg
exit 0

给定内核模块的名称作为参数——没有任何扩展部分(如.c),lkm脚本执行一些有效性检查,显示一些版本信息,然后使用包装器runcmd() bash 函数显示给定命令的名称并运行给定命令,实际上轻松完成了clean/build/load/lsmod/dmesg工作流。让我们在第一个内核模块上尝试一下:

$ pwd
<...>/ch4/helloworld_lkm
$ ../../lkm
Usage: lkm name-of-kernel-module-file (without the .c)
$ ../../lkm helloworld_lkm
Version info:
Distro:          Ubuntu 18.04.3 LTS
Kernel: 5.0.0-36-generic
------------------------------
sudo rmmod helloworld_lkm 2> /dev/null
------------------------------
[sudo] password for llkd: 
------------------------------
sudo dmesg -C
------------------------------
------------------------------
make || exit 1
------------------------------
make -C /lib/modules/5.0.0-36-generic/build/ M=/home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm modules
make[1]: Entering directory '/usr/src/linux-headers-5.0.0-36-generic'
  CC [M]  /home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm/helloworld_lkm.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC      /home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm/helloworld_lkm.mod.o
  LD [M]  /home/llkd/book_llkd/Learn-Linux-Kernel-Development/ch4/helloworld_lkm/helloworld_lkm.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.0.0-36-generic'
------------------------------
sudo insmod ./helloworld_lkm.ko && lsmod|grep helloworld_lkm
------------------------------
helloworld_lkm         16384  0
------------------------------
dmesg
------------------------------
[ 8132.596795] Hello, world
$ 

全部完成!记得用rmmod(8)卸载内核模块。

恭喜你!你现在已经学会了如何编写和试用一个简单的 Hello,world 内核模块。然而,在你满足于自己的荣誉之前,还有许多工作要做;下一节将深入探讨关于内核日志记录和通用 printk API 的更多关键细节。

了解内核日志和 printk

关于通过 printk 内核应用编程接口*记录内核消息,还有很多需要讨论的。*本节将深入探讨一些细节。对于像您这样的初露头角的内核开发人员来说,清楚地理解这些非常重要。

在本节中,我们将深入研究内核日志记录的更多细节。我们开始了解 printk 输出是如何处理的,看看它的优缺点。我们讨论 printk 日志级别、现代系统如何通过 systemd 日志记录消息,以及如何将输出定向到控制台设备。我们用一个关于限速 printk 和用户生成的打印、从用户空间生成 printk 和标准化 printk 输出格式的注释来结束这个讨论。

我们在前面的快速浏览内核 printk 部分看到了使用内核 printk API 功能的要点。在这里,我们将更多地探讨printk()应用编程接口的用法。在我们简单的内核模块中,下面是发出“ *Hello,world”*消息的代码行:

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

同样,printk格式字符串及其工作原理方面与printf相似——但相似之处就此结束。为了强调,我们重复一遍:printfprintk 的一个关键区别在于printf(3)是一个用户空间库 API,通过调用write(2)系统调用工作,该调用写入标准输出设备,默认情况下通常是终端窗口(或控制台设备)。另一方面,printk 是一个内核空间应用编程接口,它的输出转到至少一个地方,下面列表中显示的第一个地方,可能还有更多地方:

  • 内核日志缓冲区(内存中;挥发性的)
  • 内核日志文件(非易失性)
  • 控制台设备

让我们更详细地检查内核日志缓冲区。

使用内核内存环形缓冲区

内核日志缓冲区只是内核地址空间中保存(记录)printk 输出的内存缓冲区。更确切地说,是全局__log_buf[]变量。它在内核源代码中的定义如下:

kernel/printk/printk.c:
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
static char __log_buf[__LOG_BUF_LEN] __aligned(LOG_ALIGN);

它被设计成一个环形缓冲区;它有一个有限的大小(__LOG_BUF_LEN字节),一旦它满了,就会从字节 0 被覆盖。因此,它被称为“环”或环形缓冲区)。这里我们可以看到大小是基于Kconfig变量CONFIG_LOG_BUF_SHIFT(C 中1 << n暗指2^n)。该值被显示,并且可以作为内核(menu)config的一部分被覆盖,这里:General Setup > Kernel log buffer size

它是一个范围为12 - 25的整数值(我们可以随时搜索init/Kconfig查看其规格),默认值为18。所以,日志缓冲区的大小= 2 18 = 256 KB。然而,实际的运行时大小也受到其他配置指令的影响,特别是LOG_CPU_MAX_BUF_SHIFT,它使大小成为系统上 CPU 数量的函数。此外,相关的Kconfig文件说,“当使用 log_buf_len 内核参数时,该选项也被忽略,因为它强制使用精确(2 的幂)大小的环形缓冲区。”所以,这很有趣;我们通常可以通过传递一个内核参数(通过引导程序)来覆盖默认值!

内核参数是有用的、多种多样的,非常值得检查。参见这里的官方文档:https://www . kernel . org/doc/html/latest/admin-guide/kernel-parameters . html。关于log_buf_len内核参数的 Linux 内核文档片段揭示了细节:

log_buf_len=n[KMG]   Sets the size of the printk ring buffer,
                     in bytes. n must be a power of two and greater                
                     than the minimal size. The minimal size is defined
                     by LOG_BUF_SHIFT kernel config parameter. There is
                     also CONFIG_LOG_CPU_MAX_BUF_SHIFT config parameter
                     that allows to increase the default size depending  
                     on the number of CPUs. See init/Kconfig for more 
                     details.

无论内核日志缓冲区的大小如何,在处理 printk API 时有两个问题变得显而易见:

  • 其消息被记录在易失性存储器(RAM)中;如果系统以任何方式崩溃或断电,我们将失去宝贵的内核日志(通常会消除我们的调试能力)。
  • 默认情况下,日志缓冲区不是很大,通常只有 256 KB 大量的打印会淹没环形缓冲区,使其环绕,从而丢失信息。

我们如何解决这个问题?继续读...

内核日志和系统日志

前面提到的问题的一个显而易见的解决方案是将内核 printk 写入(追加)到一个文件中。这正是大多数现代 Linux 发行版的设置方式。日志文件的位置因发行版而异:传统上,基于红帽的写入/var/log/messages文件,基于 Debian 的写入/var/log/syslog。传统上,内核 printk 会连接到用户空间系统日志守护程序 ( syslogd ) 来执行文件日志记录,从而自动受益于更复杂的功能,如日志旋转、压缩和存档。

然而,在过去的几年里,系统日志记录已经完全被一个有用且强大的新系统初始化框架所取代,这个框架被称为 systemd (它取代了旧的 SysV init 框架,或者通常是旧的 SysV init 框架的补充)。事实上,systemd 现在甚至被常规地用在嵌入式 Linux 设备上。在 systemd 框架内,日志记录由一个名为systemd-journal的守护进程执行,而journalctl(1) 实用程序是它的用户界面。

The detailed coverage of systemd and its associated utilities is beyond the scope of this book. Please refer to the Further reading section of this chapter for links to (a lot) more on it.

使用日志检索和解释日志的一个关键优势是来自应用、库、系统守护程序、内核、驱动程序等的所有日志都写在这里(合并)。这样,我们可以看到事件的(反向)时间线,而不必手动将不同的日志拼凑成时间线。journalctl(1) 实用程序的手册页详细介绍了它的各种选项。在这里,我们基于这个实用程序提供了一些(希望)方便的别名:

#--- a few journalctl(1) aliases
# jlog: current (from most recent) boot only, everything
alias jlog='/bin/journalctl -b --all --catalog --no-pager'
# jlogr: current (from most recent) boot only, everything,
#  in *reverse* chronological order
alias jlogr='/bin/journalctl -b --all --catalog --no-pager --reverse'
# jlogall: *everything*, all time; --merge => _all_ logs merged
alias jlogall='/bin/journalctl --all --catalog --merge --no-pager'
# jlogf: *watch* log, akin to 'tail -f' mode;
#  very useful to 'watch live' logs
alias jlogf='/bin/journalctl -f'
# jlogk: only kernel messages, this (from most recent) boot
alias jlogk='/bin/journalctl -b -k --no-pager'

Note that the -b option current boot implies that the journal is displayed from the most recent system boot date at the present moment. A numbered listing of stored system (re)boots can be seen with journalctl --list-boots.

我们故意使用--no-pager选项,因为它允许我们根据需要用[e]grep(1)awk(1), sort(1)等进一步过滤输出。使用journalctl(1)的简单示例如下:

$ journalctl -k |tail -n2
Mar 17 17:33:16 llkd-vbox kernel: Hello, world
Mar 17 17:47:26 llkd-vbox kernel: Goodbye, world
$  

请注意日志的默认日志格式:

[timestamp] [hostname] [source]: [... log message ...]

这里[source]是内核消息的kernel,或者写消息的特定应用或服务的名称。

journalctl(1)的手册页上看到几个用法示例很有用:

Show all kernel logs from previous boot:
    journalctl -k -b -1

Show a live log display from a system service apache.service:
    journalctl -f -u apache

当然,将内核消息非易失性地记录到文件中非常有用。但是,请注意,存在一些情况,通常由硬件限制决定,这可能会使它变得不可能。例如,一个资源高度受限的小型嵌入式 Linux 设备可能会使用一个小型内部闪存芯片作为存储介质。现在,它不仅很小,而且所有的空间都被应用、库、内核和引导加载程序占用了,而且基于闪存的芯片在耗尽之前可以维持的擦除-写入周期数也受到了有效的限制。因此,给它写几百万次可能会结束它!因此,有时,系统设计人员会故意和/或额外使用更便宜的外部闪存,如(微型)SD/MMC 卡(用于非关键数据)来减轻这种影响,因为它们很容易更换。

让我们继续了解 printk 日志级别。

使用 printk 日志级别

为了理解和使用 printk 日志级别,让我们从复制这一行代码开始——第一个 printk 来自我们的helloworld_lkm内核模块:

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

现在我们来谈谈房间里的大象:KERN_INFO到底是什么意思?首先,现在要小心:这是而不是你的下意识反应所说的——一个参数。不要。请注意,它和格式字符串之间没有逗号字符;只有空白。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"   /* system is unusable */
#define KERN_ALERT    KERN_SOH      "1"   /* action must be taken  
                                             immediately */
#define KERN_CRIT     KERN_SOH      "2"   /* critical conditions */
#define KERN_ERR      KERN_SOH      "3"   /* error conditions */
#define KERN_WARNING  KERN_SOH      "4"   /* warning conditions */
#define KERN_NOTICE   KERN_SOH      "5"   /* normal but significant 
                                             condition */
#define KERN_INFO     KERN_SOH      "6"   /* informational */
#define KERN_DEBUG    KERN_SOH      "7"   /* debug-level messages */

#define KERN_DEFAULT  KERN_SOH      "d"   /* the default kernel loglevel */

因此,现在我们看到KERN_<FOO>日志级别仅仅是字符串("0", "1", ..., "7"),它们被作为 printk 发出的内核消息的前缀;仅此而已。这为我们提供了基于日志级别过滤消息的有用能力。每个日志右侧的注释清楚地向开发人员显示了何时使用哪个日志级别。

What's KERN_SOH? That's the ASCII Start Of Header (SOH) value \001. See the man page on ascii(7); the ascii(1) utility dumps the ASCII table in various numerical bases. From here, we can clearly see that numeric 1 (or \001) is the SOH character, a convention that is followed here.

让我们快速看一下 Linux 内核源代码树中的几个实际例子。当内核的hangcheck-timer 设备驱动程序(有点类似软件看门狗)确定某个定时器到期(默认为 60 秒)延迟超过某个阈值(默认为 180 秒)时,它会重启系统!这里我们展示了相关的内核代码——在这方面,hangcheck-timer驱动程序发出 printk 的地方:

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

查看如何在日志级别设置为KERN_CRIT的情况下调用 printk API。

另一方面,发出信息信息可能正是医生所要求的:在这里,我们看到通用并行打印机驱动程序礼貌地通知所有相关人员打印机着火了(相当低调,是吗?):

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

你会认为一个着火的设备会使 printk 在“紧急”日志级别发出...嗯,至少arch/x86/kernel/cpu/mce/p5.c:pentium_machine_check()功能坚持这一点:

// 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());
    } 
[...]

(接下来介绍pr_<foo>()便利宏)。

一个常见问题 : 如果在printk()内,日志级别为而非指定,打印是在什么日志级别发出的?默认为4,也就是KERN_WARNING(写入控制台部分揭示了这到底是为什么)。但是,请注意,在使用 printk 时,您应该总是指定一个合适的日志级别。

有一种简单的方法可以指定内核消息日志级别。这就是我们接下来要深入研究的。

pr_ 便利宏

这里给出的 pr_<foo>() 宏的便利缓解了编码的痛苦。笨重的 printk(KERN_FOO "<format-str>");换成了优雅的 pr_foo("<format-str>");,其中<foo>为原木级别;鼓励使用它们:

// include/linux/printk.h:
[...]
/*
 * These can be used to print at the various log levels.
 * All of these will print unconditionally, although note that pr_debug()
 * and other debug macros are compiled out unless either DEBUG is defined
 * or CONFIG_DYNAMIC_DEBUG is set.
 */
#define pr_emerg(fmt, ...) \
        printk(KERN_EMERG pr_fmt(fmt), ##__VA_ARGS__)
#define pr_alert(fmt, ...) \
        printk(KERN_ALERT pr_fmt(fmt), ##__VA_ARGS__)
#define pr_crit(fmt, ...) \
        printk(KERN_CRIT pr_fmt(fmt), ##__VA_ARGS__)
#define pr_err(fmt, ...) \
        printk(KERN_ERR pr_fmt(fmt), ##__VA_ARGS__)
#define pr_warning(fmt, ...) \
        printk(KERN_WARNING pr_fmt(fmt), ##__VA_ARGS__)
#define pr_warn pr_warning
#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() should produce zero code unless DEBUG is defined */
#ifdef DEBUG
#define pr_devel(fmt, ...) \
    printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__)
#else
#define pr_devel(fmt, ...) \
    no_printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__)
#endif

The kernel allows us to pass loglevel=n as a kernel command-line parameter, where n is an integer between 0 and 7, corresponding to the eight log levels mentioned previously. As expected (as you shall soon learn), all printk instances with a log level less than that which was passed will be directed to the console device as well.

将内核消息直接写入控制台设备有时非常有用;下一节将讨论如何实现这一点的细节。

控制台接线

回想一下,printk 输出最多可以到达三个位置:

  • 第一个是内核内存日志缓冲区(总是)
  • 第二个是非易失性日志文件
  • 最后一个(我们将在这里讨论):控制台设备

传统上,控制台设备是一个纯内核特性,即超级用户在非图形环境中登录(/dev/console)的初始终端窗口。有趣的是,在 Linux 上,我们可以定义几个控制台——一个电传终端 ( tty )窗口(如/dev/console)、一个文本模式 VGA、一个帧缓冲器,甚至一个通过 USB 服务的串行端口(这在开发期间的嵌入式系统上很常见;在本章的进一步阅读*部分,了解更多关于 Linux 控制台的信息。

例如,当我们通过 USB 到 RS232 TTL UART (USB 到 serial)电缆将树莓 Pi 连接到 x86-64 笔记本电脑时(请参阅本章的进一步阅读部分,了解关于这个非常有用的配件以及如何在树莓 Pi 上设置它的博客文章!)然后使用minicom(1)(或screen(1))获得一个串行控制台,这就是显示为tty 设备的东西——它是串行端口:

rpi # tty
/dev/ttyS0

这里的重点是控制台通常是重要日志消息的目标,包括那些来自内核深处的日志消息。Linux 的 printk 使用基于proc的机制有条件地将其数据传送到控制台设备。为了更好地理解这一点,让我们首先检查一下相关的proc伪文件:

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

我们将前面四个数字解释为 printk 日志级别(就“紧急程度”而言,0最高,7最低)。前面四个整数序列的含义是:

  • 当前(控制台)日志级别 -这意味着所有小于该值的消息都将出现在控制台设备上!
  • 缺少显式日志级别的消息的默认级别
  • 允许的最小日志级别
  • 引导时默认日志级别

由此可见,日志级别4对应KERN_WARNING。因此,第一个数字是4(实际上,这是 Linux 发行版的典型默认值),*所有低于日志级别 4 的 printk 实例都将出现在控制台设备上,*当然,还会被记录到一个文件中——实际上,所有消息都在以下日志级别:KERN_EMERGKERN_ALERTKERN_CRITKERN_ERR

Kernel messages at log level 0 [KERN_EMERG] are always printed to the console, and indeed to all Terminal windows and the kernel log file, regardless of any settings.

值得注意的是,在进行嵌入式 Linux 或任何内核开发时,您通常会在控制台设备上使用*,就像刚才给出的树莓 Pi 示例一样。将proc printk伪文件的第一个整数值设置为8将会保证所有 printk 实例都直接出现在控制台、**上,从而使 printk 表现得像普通 printf 一样!*在这里,我们展示了根用户如何轻松设置:

# echo "8 4 1 7" > /proc/sys/kernel/printk

(当然,这必须以 root 身份完成。)这在开发和测试过程中非常方便。

On my Raspberry Pi, I keep a startup script that contains the following line: [ $(id -u) -eq 0 ] && echo "8 4 1 7" > /proc/sys/kernel/printk Thus, when running it as root, this takes effect and all printk instances now directly appear on the minicom(1) console, just as printf would.

谈到通用的树莓 Pi,下一节将演示如何在一个上面运行内核模块。

将输出写入树莓 Pi 控制台

进入我们的第二个内核模块!在这里,我们将发出九个 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);

Now, we will discuss the output when running the preceding printk_loglvlkernel module on a Raspberry Pi device. If you don't possess one or it's not handy, that's not a problem; please go ahead and try it out on an x86-64 guest VM.

在树莓 Pi 设备上(这里我使用了运行默认树莓 Pi 操作系统的树莓 Pi 3B+模型),我们登录并通过一个简单的sudo -s获得一个根 Shell。然后我们构建内核模块。如果你已经在树莓 Pi 上安装了默认的树莓 Pi 映像,那么所有需要的开发工具、内核头等等都将被预装!图 4.7 是在树莓 Pi 板上运行我们的printk_loglvl内核模块的截图。此外,重要的是要意识到我们正在控制台设备上运行**,因为我们正在通过minicom(1)终端仿真器应用(而不是简单地通过 SSH 连接*)使用前述的 USB 至串行电缆:***

*

Figure 4.7 – The minicom Terminal emulator app window – the console – with the printk_loglvl kernel module output

请注意与 x86-64 环境有点不同的地方:这里,默认情况下,/proc/sys/kernel/printk输出中的第一个整数——当前控制台日志级别——是3(不是4)。好的,这意味着日志级别低于日志级别 3 的所有内核 printk 实例将直接出现在控制台设备上。看截图:确实是这样!此外,不出所料,处于“紧急”日志级别(0)的 printk 实例总是出现在控制台上,甚至出现在每个打开的终端窗口上。

现在有趣的部分:让我们将当前控制台日志级别(记住,这是/proc/sys/kernel/printk输出中的第一个整数)设置为值8(当然是根)。这样,所有 printk 实例都应该直接出现在控制台*上。*我们在此精确测试:

Figure 4.8 – The minicom Terminal – in effect, the console – window, with the console log level set to 8

事实上,正如预期的那样,我们在控制台设备上看到了所有的 printk 实例,避免了使用dmesg的需要。

等一下,无论pr_debug()pr_devel()宏在日志级别KERN_DEBUG发出内核消息(即整数值7)发生了什么?它有没有出现在这里,也没有在下面dmesg输出?我们很快会解释这一点;请继续读下去。

当然,有了dmesg(1),所有内核消息——嗯,至少那些还在内存内核日志缓冲区的消息——都会被显示出来。我们认为这是事实:

rpi # rmmod printk_loglvl
rpi # dmesg
[...]
[ 1408.603812] Hello, world @ log-level KERN_EMERG   [0]
[ 1408.611335] Hello, world @ log-level KERN_ALERT   [1]
[ 1408.618625] Hello, world @ log-level KERN_CRIT    [2]
[ 1408.625778] Hello, world @ log-level KERN_ERR     [3]
[ 1408.625781] Hello, world @ log-level KERN_WARNING [4]
[ 1408.625784] Hello, world @ log-level KERN_NOTICE  [5]
[ 1408.625787] Hello, world @ log-level KERN_INFO    [6]
[ 1762.985496] Goodbye, world @ log-level KERN_INFO    [6]
rpi # 

除了KERN_DEBUG实例之外,所有 printk 实例都被视为我们通过dmesg实用程序查看内核日志。那么,如何显示调试消息呢?接下来会讲到。

启用 pr_debug()内核消息

啊,是的,pr_debug()有点特殊:除非DEBUG符号是为内核模块定义的,否则日志级别KERN_DEBUGprintk 实例不会出现。我们编辑内核模块的 Makefile 来实现这一点。有(至少)两种方法来设置它:

  • 将这一行插入 Makefile:
CFLAGS_printk_loglvl.o := -DDEBUG

一般来说,是CFLAGS_<filename>.o := -DDEBUG

  • 我们也可以将这个语句插入到 Makefile 中:
EXTRA_CFLAGS += -DDEBUG

首先,在我们的 Makefile 中,我们特意将-DDEBUG注释掉了。现在,尝试一下,取消注释以下注释掉的行:

# Enable the pr_debug() as well (rm the comment from one of the lines below)
#EXTRA_CFLAGS += -DDEBUG
#CFLAGS_printk_loglvl.o := -DDEBUG

一旦完成,我们从内存中移除旧的过时内核模块,重建它,并使用我们的lkm 脚本插入它。输出显示pr_debug() 现在确实生效:

# exit                      << exit from the previous root shell >>
$ ../../lkm printk_loglvl Version info:
Distro:     Ubuntu 18.04.3 LTS
Kernel: 5.4.0-llkd01
------------------------------
sudo rmmod printk_loglvl 2> /dev/null
------------------------------
[...]
sudo insmod ./printk_loglvl.ko && lsmod|grep printk_loglvl
------------------------------
printk_loglvl          16384  0
------------------------------
dmesg
------------------------------
[  975.271766] Hello, world @ log-level KERN_EMERG [0]
[  975.277729] Hello, world @ log-level KERN_ALERT [1]
[  975.283662] Hello, world @ log-level KERN_CRIT [2]
[  975.289561] Hello, world @ log-level KERN_ERR [3]
[  975.295394] Hello, world @ log-level KERN_WARNING [4]
[  975.301176] Hello, world @ log-level KERN_NOTICE [5]
[  975.306907] Hello, world @ log-level KERN_INFO [6]
[  975.312625] Hello, world @ log-level KERN_DEBUG [7]
[  975.312628] Hello, world via the pr_devel() macro (eff @KERN_DEBUG) [7]
$

lkm脚本输出的部分截图(图 4.9)清楚地揭示了dmesg的颜色编码,其中KERN_ALERT / KERN_CRIT / KERN_ERR背景分别以红色/粗体红色字体/红色前景色突出显示,而KERN_WARNING以粗体黑色字体突出显示,帮助我们人类快速发现重要的核心信息:

Figure 4.9 – Partial screenshot of lkm script's output

请注意,启用动态调试功能(CONFIG_DYNAMIC_DEBUG=y)时,pr_debug()的行为并不相同。

Device driver authors should note that for the purpose of emitting debug printk instances, they should avoid using pr_debug(). Instead, it is recommended that a device driver uses the dev_dbg() macro (additionally passing along a parameter to the device in question). Also, pr_devel() is meant to be used for kernel-internal debug printk instances whose output should never be visible in production systems.

现在,回到控制台输出部分。那么,也许是为了内核调试的目的(如果没有其他目的的话),有没有一种有保证的方法来确保所有的printk 实例都指向控制台是的,确实如此——只需传递名为ignore_level的内核(启动时)参数。有关这方面的更多详细信息,请查阅官方内核文档中的描述:https://www . kernel . org/doc/html/latest/admin-guide/kernel-parameters . html。切换对 printk 日志级别的忽略也是可能的:如上所述,您可以通过这样做来打开对 printk 日志级别的忽略,从而允许所有 printk 出现在控制台设备上(相反,通过将 N 回显到同一个伪文件来关闭它):

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

dmesg(1)实用程序还可用于通过各种选项开关(特别是--console-level选项)来控制对控制台设备的内核消息的启用/禁用,以及控制台日志记录级别(即消息将出现在控制台上的数字级别)。详情请你浏览dmesg(1)的手册页。

下一部分讨论另一个非常有用的日志功能:速率限制。

限制 printk 实例的速率

当我们从频繁执行的代码路径中发出printk实例时,printk实例的数量可能会很快溢出内核日志缓冲区(内存中;请记住,这是一个循环缓冲区),从而覆盖可能是关键信息的内容。除此之外,不断增长的非易失性日志文件重复几乎相同的printk实例(几乎)也不是一个好主意,会浪费磁盘空间,或者更糟的是,浪费闪存空间。例如,想象一下中断处理程序代码路径中的大型 printk。如果硬件中断以 100 赫兹的频率被调用,也就是说,每秒 100 次,会怎么样!

为了缓解这些问题,内核提供了一个有趣的替代方案:速率受限的 printk printk_ratelimited()宏具有与常规 printk 相同的语法;关键是在满足一定条件的情况下有效抑制规则打印。为此,内核通过proc文件系统提供了两个名为printk_ratelimitprintk_ratelimit_burst的控制文件。在这里,我们直接复制sysctl文件(来自https://www.kernel.org/doc/Documentation/sysctl/kernel.txt)来解释这两个(伪)文件的确切含义:

printk_ratelimit:
Some warning messages are rate limited. printk_ratelimit specifies
the minimum length of time between these messages (in jiffies), by
default we allow one every 5 seconds.
A value of 0 will disable rate limiting.
==============================================================
printk_ratelimit_burst:
While long term we enforce one message per printk_ratelimit
seconds, we do allow a burst of messages to pass through.
printk_ratelimit_burst specifies the number of messages we can
send before ratelimiting kicks in.

在我们的 Ubuntu 18.04.3 LTS 来宾系统上,我们发现它们的(默认)值如下:

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

这意味着默认情况下,在限速生效之前,在 5 秒钟的时间间隔内,同一条消息最多有 10 个实例可以通过。

当 printk 速率限制器抑制内核printk实例时,它会发出一条有用的消息,确切地说明有多少早期的 printk 回调被抑制。例如,我们有一个定制的内核模块,它利用Kprobes 框架在每次调用schedule()之前发出一个printk实例,T3 是内核的核心调度例程。

A kprobe is essentially an instrumentation framework often leveraged for production system troubleshooting; using it, you can specify a function that can be set to execute before or after a given kernel routine. The details are beyond the scope of this book.

现在,由于调度经常发生,常规的 printk 会导致内核日志缓冲区快速溢出。正是这种情况保证了限速 printk 的使用。在这里,我们看到了示例内核模块的一些示例输出(这里不显示它的代码),通过kprobe使用printk_ratelimited() API,该 API 设置了一个名为handle_pre_schedule()预处理程序函数:

[ 1000.154763] kprobe schedule pre_handler: intr ctx = 0 :process systemd-journal:237
[ 1005.162183] handler_pre_schedule: 5860 callbacks suppressed
[ 1005.162185] kprobe schedule pre_handler: intr ctx = 0 :process dndX11:1071

在 Linux 内核的实时时钟 ( RTC )驱动程序的中断处理程序代码中可以看到一个使用速率受限 printk 的代码级示例:

static void rtc_dropped_irq(struct timer_list *unused)
{ 
[...]
    spin_unlock_irq(&rtc_lock);
    printk_ratelimited(KERN_WARNING "rtc: lost some interrupts at         %ldHz.\n", freq);
    /* Now we have new data */
    wake_up_interruptible(&rtc_wait);
[...]
}

Don't mix up the printk_ratelimited() macro with the older (and now deprecated) printk_ratelimit() macro. Also, the actual rate-limiting code is in lib/ratelimit.c:___ratelimit().

此外,就像我们之前看到的pr_<foo>宏一样,内核也提供了等效的pr_<foo>_ratelimited宏,用于在启用了速率限制的情况下在日志级别<foo>生成内核 printk。以下是它们的快速列表:

pr_emerg_ratelimited(fmt, ...)
pr_alert_ratelimited(fmt, ...)
pr_crit_ratelimited(fmt, ...) 
pr_err_ratelimited(fmt, ...)  
pr_warn_ratelimited(fmt, ...) 
pr_notice_ratelimited(fmt, ...)
pr_info_ratelimited(fmt, ...)  

我们能从用户空间生成内核级消息吗?听起来很有趣;这是我们的下一个子话题。

从用户空间生成内核消息

我们程序员使用的一种流行的调试技术是在代码中的不同点散布打印,这通常允许我们缩小问题的来源。这确实是一种有用的调试技术,被称为检测代码。内核开发人员经常使用古老的 printk API 来实现这个目的。

因此,假设您已经编写了一个内核模块,并且正在调试它(通过添加几个 printk)。您的内核代码现在发出几个 printk 实例,当然,您可以通过dmesg或其他方式在运行时看到这些实例。这很好,但是如果,特别是因为您正在运行一些自动化的用户空间测试脚本,您希望看到脚本在我们的内核模块中通过打印出某个消息来启动某个动作的点会怎么样。举一个具体的例子,假设我们希望日志看起来像这样:

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

我们可以让我们的用户空间测试脚本将一条消息写入内核日志缓冲区,就像内核 printk 那样,通过将所述消息写入特殊的/dev/kmsg设备文件:

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

等等,这样做当然需要以 root 访问权限运行。但是,请注意,在echo之前的一个简单的sudo(8)不起作用:

$ sudo echo "test_script: msg 1" > /dev/kmsg
bash: /dev/kmsg: Permission denied
$ sudo bash -c "echo \"test_script: msg 1\" > /dev/kmsg"
[sudo] password for llkd:
$ dmesg |tail -n1
[55527.523756] test_script: msg 1
$ 

第二次尝试中使用的语法是可行的,但是为自己获取一个根 Shell 并执行这样的任务更简单。

还有一件事:dmesg(1)实用程序有几个选项,旨在使输出更加人性化;我们通过示例别名将其中一些显示在dmesg中,之后我们使用它:

$ alias dmesg='/bin/dmesg --decode --nopager --color --ctime'
$ dmesg | tail -n1
user :warn : [Sat Dec 14 17:21:50 2019] test_script: msg 1
$ 

通过特殊的/dev/kmsg设备文件写入内核日志的消息将以当前默认日志级别打印,通常为4 : KERN_WARNING。我们可以通过在消息前面加上所需的日志级别(字符串格式的数字)来覆盖这一点。例如,要在日志级别6 : KERN_INFO将用户空间写入内核日志,请使用以下命令:

$ sudo bash -c "echo \"<6>test_script: test msg at KERN_INFO\"   \
   > /dev/kmsg"
$ dmesg | tail -n2
user :warn : [Fri Dec 14 17:21:50 2018] test_script: msg 1
user :info : [Fri Dec 14 17:31:48 2018] test_script: test msg at KERN_INFO

我们可以看到后一条消息是在日志级别6发出的,如echo中所指定的。

真的没有办法区分用户生成的内核消息和内核printk() - 生成的消息;他们看起来一模一样。因此,当然,它可以像在消息中插入一些特殊的签名字节或字符串一样简单,例如@user@,以便帮助您区分这些用户生成的打印和内核打印。

通过 pr_fmt 宏标准化 printk 输出

关于内核 printk 的最后但重要的一点;通常,给你的printk()输出提供上下文(它到底发生在哪里?),可以这样写代码,利用各种 gcc 宏(如__FILE____func____LINE__):

pr_warning("%s:%s():%d: kmalloc failed!\n", OURMODNAME,  __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__说明符来显示我们当前运行的函数的名称!(您甚至可以添加一个与相应的__LINE__宏匹配的%d来显示行号)。所以,底线是:我们在这个 LKM 的init函数中发出的pr_info()将在内核日志中显示如下:

[381534.391966] lkm_template:lkm_template_init(): inserted

请注意 LKM 名称和函数名称是如何自动加前缀的。这很有用,也确实很常见;在内核中,几乎有数百个源文件以pr_fmt()开头。(对 5.4 内核代码库的快速搜索显示,代码库中有超过 2000 个此宏的实例!我们也将遵循这个惯例,尽管不是在我们所有的演示内核模块中)。

The pr_fmt() also takes effect on the recommended printk usage for driver authors - via the dev_<foo>() functions.

可移植性和 printk 格式规范

关于通用的 printk 内核 API,有一个问题需要思考,您将如何确保您的 printk 输出看起来正确(格式正确)并且在任何 CPU 上都同样工作良好,而不管位宽如何?便携性的问题在这里凸显出来;好消息是,熟悉所提供的各种格式说明符将在这方面对您有很大帮助,实际上允许您编写独立于 arch 的 printks。

It's important to realize that the size_t - pronounced size type - is a typedef for an unsigned integer; similarly, ssize_t (signed size type) is a typedef for a signed integer.

在编写可移植代码时,需要记住几个首要的常见 printk 格式说明符:

  • 对于size_tssize_t(有符号和无符号)整数:分别使用%zd%zu
  • 内核指针:安全使用%pK(哈希值),实际指针使用%px(不要在生产中使用这个!),此外,物理地址使用%pa(必须通过引用传递)
  • 原始缓冲区为一串十六进制字符:%*ph(其中*由字符数代替;用于 64 个字符以内的缓冲区,使用print_hex_dump_bytes()例程获取更多信息);变体是可用的(参见内核文档,链接如下)
  • %pI4的 IPv4 地址,带%pI6的 IPv6 地址(也有变体)

printk 格式说明符的详尽列表,当(举例)是这里的官方内核文档的一部分时使用:https://www.kernel.org/doc/Documentation/printk-formats.txt。我劝你浏览一下!

好的。让我们通过学习内核模块的 Makefile 如何构建内核的基础知识来完成这一章。

了解内核模块 Makefile 的基础知识

你会注意到我们倾向于遵循一种每个目录一个内核模块的排序规则。是的,这肯定有助于保持事情的条理。那么,让我们以第二个内核模块ch4/printk_loglvl 为例。要构建它,我们只需将cd放到它的文件夹中,键入make,然后(祈祷吧!)瞧,完成了。我们有新生成的printk_loglevel.ko 内核模块对象(然后我们可以insmod(8)/rmmod(8))。但是我们打make的时候到底是怎么造出来的呢?啊,解释这就是这一节的目的。

As this is our very first chapter that deals with the LKM framework and its corresponding Makefile, we will keep things nice and simple, especially with regard to the Makefile here. However, early in the following chapter, we shall introduce a more sophisticated, simply betterMakefile (that is still quite simple to understand). We shall then use this better Makefile in all subsequent code; do look out for it and use it!

如您所知,make命令默认会在当前目录中查找名为Makefile的文件;如果它存在,它将解析它并执行其中指定的命令序列。这是我们的内核模块printk_loglevel项目的 Makefile:

// ch4/printk_loglvl/Makefile
PWD       := $(shell pwd)obj-m     += printk_loglvl.o

# Enable the pr_debug() as well (rm the comment from the line below)
#EXTRA_CFLAGS += -DDEBUG
#CFLAGS_printk_loglvl.o := -DDEBUG

all:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules
install:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules_install
clean:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) clean

不用说,Unix Makefile 语法基本上要求这样:

target: [dependent-source-file(s)]
        rule(s)

rule(s)实例总是以[Tab]字符为前缀,而不是空格。

让我们收集关于这个 Makefile 如何工作的基础知识。首先,一个关键点是:内核的Kbuild系统(我们从第 2 章从源代码构建 5.x Linux 内核–第 1 部分开始就一直提到和使用它),主要使用两个软件变量串来构建,链接在两个obj-yobj-m变量中。

obj-y字符串包含要构建并合并到最终内核映像文件中的所有对象的串联列表-未压缩的vmlinux 和压缩的(可引导的)[b]zImage 映像。想想看——有道理:obj-y中的y代表*是的。*内核配置过程中设置为Y的所有内核内置和Kconfig选项(或默认为Y)通过此项链接在一起,构建,最终由Kbuild构建系统编织成最终的内核镜像文件。

另一方面,现在很容易看到obj-m字符串是所有内核对象的串联列表,分别构建*、作为内核模块!这就是为什么我们的 Makefile 有这样一条非常重要的线:*

obj-m += printk_loglvl.o

实际上,它告诉Kbuild系统包含我们的代码;更正确地说,它告诉它将printk_loglvl.c源代码隐式编译成printk_loglvl.o 二进制对象,然后将这个对象添加到obj-m列表中。接下来,make的默认规则为all规则,处理如下:

all:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules

这个单一语句的处理相当复杂;事情是这样的:

  1. -C选项切换到make会使make进程将目录(通过chdir(2)系统调用)更改为-C后面的目录名称。因此,它将目录更改为内核build文件夹(正如我们前面介绍的,这是通过kernel-headers包安装的“受限”内核源代码树的位置)。
  2. 一旦到了那里,它就在解析内核的顶级 Makefile 的内容——也就是说,驻留在那里的 Makefile,在这个有限内核源代码树的根中。这是一个关键点。通过这种方式,可以保证所有内核模块都与它们所针对的内核紧密耦合(稍后将详细介绍)。这也保证了内核模块是用与内核映像本身完全相同的一组规则构建的,即编译器/链接器配置(CFLAGS选项、编译器选项开关等)。所有这些都是二进制兼容所必需的。
  3. 接下来可以看到名为M的变量的初始化,指定的目标是modules;因此,make进程现在将目录更改为由M变量指定的目录,您可以看到该变量被设置为$(PWD)-我们开始的文件夹(当前工作目录;Makefile 中的PWD := $(shell pwd)将其初始化为正确的值)!

因此,有趣的是,这是一个递归构建:构建过程,已经(非常重要地)解析了内核顶层 Makefile,现在切换回内核模块的目录,并在其中构建模块。

你有没有注意到,当构建一个内核模块时,也会生成相当数量的中间工作文件?其中有modules.order<file>.mod.c<file>.oModule.symvers<file>.mod.o.<file>.o.cmd.<file>.ko.cmd,一个名为.tmp_versions/的文件夹,当然还有内核模块二进制对象本身<file>.ko——构建练习的全部要点。去掉所有这些对象,包括内核模块对象本身,很容易:只需执行make cleanclean规则清理了这一切。(我们将在下一章深入探讨install目标。)

You can look up what the modules.order and modules.builtin files (and other files) are meant for within the kernel documentation here: Documentation/kbuild/kbuild.rst.

Also as mentioned previously, we shall, in the following chapter, introduce and use a more sophisticated Makefile variant - a 'better' Makefile; it is designed to help you, the kernel module/driver developer, improve code quality by running targets related to kernel coding style checks, static analysis, simple packaging, and (a dummy target) for dynamic analysis.

至此,我们结束这一章。干得好——你现在已经在学习 Linux 内核开发的路上了!

摘要

在本章中,我们介绍了 Linux 内核体系结构和 LKM 框架的基础知识。您了解了什么是内核模块以及它为什么有用。然后我们编写了一个简单而完整的内核模块,一个非常基础的 Hello,world 。然后,该材料进一步深入研究了它的工作原理,以及如何加载它、查看模块列表和卸载它。printk 的内核日志记录有一些详细的介绍,包括速率限制 printk、从用户空间生成内核消息、将其输出格式标准化,以及理解内核模块 Makefile 的基础知识。

这一章到此结束;我敦促您(通过本书的 GitHub 存储库)处理示例代码,处理问题/作业,然后继续下一章,继续我们编写 Linux 内核模块的内容。

问题

作为我们的总结,这里有一个问题列表,供您测试您对本章材料的知识:https://github . com/packt publishing/Linux-Kernel-Programming/tree/master/questions。你会在这本书的 GitHub repo 中找到一些问题的答案:https://GitHub . com/PacktPublishing/Linux-Kernel-Programming/tree/master/solutions _ to _ assgn

进一步阅读

为了帮助您用有用的材料更深入地研究这个主题,我们在本书的 GitHub 存储库中的进一步阅读文档中提供了一个相当详细的在线参考资料和链接列表(有时甚至是书籍)。进一步阅读文档可在此获得:https://github . com/packt publishing/Linux-Kernel-Programming/blob/master/进一步阅读. md 。********