编写 GEOM 类

商标

FreeBSD 是 FreeBSD 基金会的注册商标。

Intel、Celeron、Centrino、Core、EtherExpress、i386、i486、Itanium、Pentium 和 Xeon 是 Intel 公司或其在美国和其他国家/地区的子公司的商标或注册商标。

制造商和销售商使用的许多用于区分其产品的名称被认定为商标。在本文档中出现这些名称的情况下,FreeBSD 项目意识到了商标声明,这些名称后面都加上了“™”或“®”符号。

摘要

本文档介绍了开发 GEOM 类和内核模块的一些入门知识。假设读者熟悉 C 语言用户空间编程。


1. 简介

1.1. 文档

有关内核编程的文档很少 - 这是为数不多的几个领域之一,几乎没有友好的教程,而且“使用源代码!”这句话确实成立。然而,有一些零零散散的资料(其中一些已经严重过时)可以学习,在开始编码之前应该先研究这些资料。

2. 预备知识

进行内核开发的最佳方法是拥有(至少)两台独立的计算机。其中一台用于开发环境和源代码,另一台用于通过网络启动和网络挂载来自第一台计算机的文件系统来测试新编写的代码。这样,如果新代码中存在错误并导致机器崩溃,也不会影响源代码(和其他“实时”数据)。第二个系统甚至不需要正常的显示屏。相反,它可以通过串行电缆或 KVM 连接到第一个系统。

但是,由于并非每个人都有两台或更多台计算机,因此可以做一些事情来准备一个原本“实时”的系统以进行内核代码开发。此设置也适用于在 VMWareQEmu 虚拟机中进行开发(这是专用开发机器的第二选择)。

2.1. 修改系统以进行开发

对于任何内核编程,启用 INVARIANTS 的内核是必需的。因此,在内核配置文件中输入以下内容

options INVARIANT_SUPPORT
options INVARIANTS

为了进行更多调试,您还应包含 WITNESS 支持,这将提醒您锁定的错误

options WITNESS_SUPPORT
options WITNESS

为了调试崩溃转储,需要具有调试符号的内核

  makeoptions    DEBUG=-g

使用通常的内核安装方式(make installkernel),调试内核不会自动安装。它被称为 kernel.debug 并且位于 /usr/obj/usr/src/sys/KERNELNAME/ 中。为了方便起见,应将其复制到 /boot/kernel/ 中。

另一个便利是启用内核调试器,这样您可以在内核崩溃时检查崩溃。为此,在内核配置文件中输入以下行

options KDB
options DDB
options KDB_TRACE

为了使此方法生效,您可能需要设置一个 sysctl(如果默认情况下未启用)

  debug.debugger_on_panic=1

内核崩溃将会发生,因此应谨慎处理文件系统缓存。特别是,如果在将最新文件版本提交到存储之前发生崩溃,则使用 softupdates 可能会导致最新文件版本丢失。禁用 softupdates 会导致严重的性能下降,并且仍然不能保证数据一致性。为此,需要以“sync”选项挂载文件系统。作为一种折衷方案,可以缩短 softupdates 缓存延迟。为此,有三个有用的 sysctl(最好在 /etc/sysctl.conf 中设置)

kern.filedelay=5
kern.dirdelay=4
kern.metadelay=3

数字代表秒数。

为了调试内核崩溃,需要内核核心转储。由于内核崩溃可能会使文件系统无法使用,因此此崩溃转储首先写入原始分区。通常,这是交换分区。此分区的大小必须至少与机器中的物理 RAM 相同。在下一次引导时,转储将被复制到常规文件。这发生在检查和挂载文件系统之后,并在启用交换之前。这由两个 /etc/rc.conf 变量控制

dumpdev="/dev/ad0s4b"
dumpdir="/usr/core

dumpdev 变量指定交换分区,dumpdir 告诉系统在重新引导时在文件系统中将核心转储重新定位到哪里。

写入内核核心转储速度很慢,需要很长时间,因此如果您有大量内存(>256M)并且经常发生崩溃,则在完成此过程时(两次 - 首先写入交换,然后重新定位到文件系统)可能需要等待很长时间。因此,通过 /boot/loader.conf 可调参数限制系统将使用的 RAM 量很方便

  hw.physmem="256M"

如果崩溃频繁并且文件系统很大(或者您只是不信任 softupdates+后台 fsck),建议通过 /etc/rc.conf 变量关闭后台 fsck

  background_fsck="NO"

这样,文件系统将在需要时始终被检查。请注意,使用后台 fsck,在它检查磁盘时可能会发生新的崩溃。再次,最安全的方法是不使用很多本地文件系统,而是使用另一台计算机作为 NFS 服务器。

2.2. 启动项目

为了创建一个新的 GEOM 类,需要在任意用户可访问目录下创建一个空子目录。您不必在 /usr/src 下创建模块目录。

2.3. Makefile

对于任何非平凡的编码项目,包括内核模块,创建 Makefile 是一个好习惯。

由于系统提供了一套广泛的辅助例程,创建 Makefile 很简单。简而言之,以下是内核模块的最小 Makefile 的外观

SRCS=g_journal.c
KMOD=geom_journal

.include <bsd.kmod.mk>

Makefile(更改文件名后)适用于任何内核模块,GEOM 类可以驻留在一个内核模块中。如果需要多个文件,请将其列在 SRCS 变量中,并在文件名之间用空格隔开。

3. 关于 FreeBSD 内核编程

3.1. 内存分配

请参阅 malloc(9)。基本内存分配与用户空间等效分配仅略有不同。最值得注意的是,malloc() 和 free() 接受额外的参数,如手册页中所述。

必须在源文件的声明部分声明“malloc 类型”,如下所示

  static MALLOC_DEFINE(M_GJOURNAL, "gjournal data", "GEOM_JOURNAL Data");

要使用此宏,必须包含 sys/param.hsys/kernel.hsys/malloc.h 头文件。

还有另一种内存分配机制,UMA(通用内存分配器)。有关详细信息,请参阅 uma(9),但它是一种特殊的分配器,主要用于快速分配由相同大小的项组成的列表(例如,结构的动态数组)。

3.2. 列表和队列

请参阅 queue(3)。在很多情况下,需要维护一个事物列表。幸运的是,此数据结构由系统中包含的 C 宏以多种方式实现。最常用的列表类型是 TAILQ,因为它是最灵活的。它也是内存需求最大的列表(其元素是双向链接的),也是最慢的(虽然速度差异只相差几个 CPU 指令,因此不应该被认真对待)。

如果数据检索速度非常重要,请参阅 tree(3)hashinit(9)

3.3. BIO

结构 bio 用于所有与 GEOM 相关的输入/输出操作。它基本上包含有关哪个设备(“提供者”)应该满足请求、请求类型、偏移量、长度、指向缓冲区的指针以及一组用于实现各种技巧的“用户特定”标志和字段的信息。

这里重要的是,bio 是异步处理的。这意味着,在代码的大多数部分,没有类似于用户空间的 read(2)write(2) 调用,这些调用不会在请求完成(或导致错误)之前返回。相反,在请求完成(或导致错误)时,将调用一个由开发人员提供的函数作为通知。

异步编程模型(也称为“事件驱动”模型)比用户空间中使用得更多的命令式模型(至少需要一段时间才能适应)稍微难一些。在某些情况下,可以使用辅助例程 g_write_data() 和 g_read_data(),但并非始终如此。特别是,当持有互斥锁时,它们不能使用;例如,在 .start() 和 .stop() 函数期间持有的 GEOM 拓扑互斥锁或内部互斥锁。

4. 关于 GEOM 编程

4.1. Ggate

如果不需要最大性能,那么实现数据转换的更简单方法是在用户空间通过 ggate (GEOM 网关) 机制实现。不幸的是,两种方法之间没有简单的转换方法,甚至无法在两者之间共享代码。

4.2. GEOM 类

GEOM 类是对数据的转换。这些转换可以以树状方式组合。GEOM 类的实例称为几何

每个 GEOM 类都有几个“类方法”,当没有几何实例可用(或者它们根本没有绑定到单个实例)时,这些方法会被调用。

  • .init 在 GEOM 意识到 GEOM 类时被调用(当内核模块加载时)。

  • .fini 在 GEOM 放弃该类时被调用(当模块卸载时)。

  • .taste 接下来被调用,对系统可用的每个提供者调用一次。如果适用,此函数通常会创建并启动几何实例。

  • .destroy_geom 在应该解散几何时被调用。

  • .ctlconf 在用户请求重新配置现有几何时被调用。

此外还定义了 GEOM 事件函数,这些函数将被复制到几何实例中。

g_class 结构中的 .geom 字段是一个列表,其中包含从该类实例化的几何。

这些函数从 g_event 内核线程调用。

4.3. Softc

名称“softc”是“驱动程序私有数据”的传统术语。这个名字很可能来自过时的术语“软件控制块”。在 GEOM 中,它是一个结构(更准确地说,是指向结构的指针),可以附加到几何实例,以保存该几何实例的私有数据。大多数 GEOM 类具有以下成员

  • struct g_provider *provider:此几何实例化的“提供者”。

  • uint16_t n_disks:此几何消耗的消费者数量。

  • struct g_consumer **disksstruct g_consumer* 数组。(不可能只使用单级间接寻址,因为 struct g_consumer* 是由 GEOM 代表我们创建的)。

softc 结构包含几何实例的所有状态。每个几何实例都有自己的软状态。

4.4. 元数据

元数据格式或多或少依赖于类,但必须以以下形式开头:

  • 用于空终止签名的 16 字节缓冲区(通常是类名)。

  • uint32 版本 ID。

假设几何类知道如何处理版本 ID 低于其本身的元数据。

元数据位于提供者的最后一个扇区(因此必须适合它)。

(所有这些都是实现相关的,但所有现有代码都以这种方式工作,并且受库支持)。

4.5. 标记/创建 GEOM

事件顺序是

  • 用户调用 geom(8) 实用程序(或其硬链接的朋友)。

  • 该实用程序确定应该处理哪个几何类,并搜索 geom_CLASSNAME.so 库(通常在 /lib/geom 中)。

  • dlopen(3)-s 该库,提取命令行参数和辅助函数的定义。

在创建/标记新几何的情况下,会发生以下情况

  • geom(8) 在命令行参数中查找命令(通常是 label),并调用辅助函数。

  • 辅助函数检查参数并收集元数据,然后将其写入所有相关的提供者。

  • 这会“破坏”现有几何(如果有)并初始化新的“品尝”提供者轮次。目标几何类会识别元数据并启动几何。

(上述事件序列是实现相关的,但所有现有代码都以这种方式工作,并且受库支持)。

4.6. GEOM 命令结构

辅助 geom_CLASSNAME.so 库导出 class_commands 结构,它是一个 struct g_command 元素数组。命令格式统一,如下所示:

  verb [-options] geomname [other]

常见的动词是

  • label - 向设备写入元数据,以便它们可以在品尝时被识别并在几何中启动。

  • destroy - 销毁元数据,以便几何被销毁。

常见的选项是

  • -v:详细输出。

  • -f:强制。

许多操作(如标记和销毁元数据)可以在用户空间执行。为此,struct g_command 提供了 gc_func 字段,可以将其设置为一个函数(在同一个 .so 中),该函数将被调用来处理动词。如果 gc_func 为 NULL,则命令将传递到内核模块,传递到几何类的 .ctlreq 函数。

4.7. 几何

几何是 GEOM 类的实例。它们具有内部数据(softc 结构)和一些函数,它们使用这些函数来响应外部事件。

事件函数是

  • .access:计算权限(读/写/独占)。

  • .dumpconf:返回关于几何的 XML 格式信息。

  • .orphan:在某些底层提供者断开连接时调用。

  • .spoiled:在某些底层提供者被写入时调用。

  • .start:处理 I/O。

这些函数从 g_down 内核线程调用,在此上下文中无法睡眠(请参阅其他地方的睡眠定义),这在很大程度上限制了可以执行的操作,但强制处理速度快。

其中,.start() 函数是最重要的用于执行实际有用工作的函数,当 BIO 请求到达由几何类实例管理的提供者时,该函数会被调用。

4.8. GEOM 线程

GEOM 框架创建并运行三个内核线程

  • g_down:处理来自高级实体(如用户空间请求)的请求,这些请求正在前往物理设备的路上。

  • g_up:处理来自设备驱动程序的对高级实体发出的请求的响应。

  • g_event:处理所有其他情况:几何实例的创建、访问计数、“破坏”事件等。

当用户进程发出“在文件偏移量 Y 处读取数据 X”请求时,会发生以下情况

  • 文件系统将请求转换为 struct bio 实例并将其传递给 GEOM 子系统。它知道哪个几何实例应该处理它,因为文件系统直接托管在几何实例上。

  • 请求最终以对 g_down 线程上的 .start() 函数的调用结束,并到达顶层几何实例。

  • 此顶层几何实例(例如分区切片器)确定该请求应该路由到更低级别的实例(例如磁盘驱动程序)。它复制了 bio 请求(bio 请求始终需要在实例之间复制,使用 g_clone_bio()!),修改了数据偏移量和目标提供者字段,并使用 g_io_request() 执行复制。

  • 磁盘驱动程序也以对 g_down 线程上的 .start() 的调用的形式获得 bio 请求。它与硬件通信,获取数据,并在 bio 上调用 g_io_deliver()。

  • 现在,bio 完成通知在 g_up 线程中“冒泡”上升。首先,分区切片器在 g_up 线程中被调用 .done(),它使用存储在 bio 中的信息来释放克隆的 bio 结构(使用 g_destroy_bio())并在原始请求上调用 g_io_deliver()。

  • 文件系统获取数据并将其传输到用户空间。

请参阅 g_bio(9) 手册页,了解数据如何在 bio 结构中来回传递的信息(尤其要注意 bio_parentbio_children 字段以及如何处理它们)。

一个重要功能是:在 G_UP 和 G_DOWN 线程中不能睡眠。这意味着在这些线程中不能执行以下任何操作(列表当然不完整,仅供参考)。

  • msleep() 和 tsleep() 的调用,显而易见。

  • g_write_data() 和 g_read_data() 的调用,因为这些调用在将数据传递给消费者和返回之间会睡眠。

  • 等待 I/O。

  • malloc(9)uma_zalloc() 的调用,其中设置了 M_WAITOK 标志。

  • sx 和其他可睡眠锁。

此限制是为了阻止 GEOM 代码阻塞 I/O 请求路径,因为睡眠通常不受时间限制,并且无法保证需要多长时间(也有一些其他更技术性的原因)。这也意味着在这些线程中不能做太多事情;例如,几乎所有复杂的事情都需要内存分配。幸运的是,有一种方法可以解决这个问题:创建额外的内核线程。

4.9. 用于 GEOM 代码的内核线程

内核线程使用 kthread_create(9) 函数创建,它们的行为类似于用户空间线程,只是它们不能返回到调用者以表示终止,而必须调用 kthread_exit(9).

在 GEOM 代码中,线程的通常用途是从 g_down 线程(.start() 函数)卸载请求的处理。这些线程看起来像“事件处理程序”:它们有一个与之关联的事件链表(各种线程中的各种函数可以将事件发布到该链表,因此它必须由互斥锁保护),从链表中逐个获取事件,并在一个大的 switch() 语句中处理它们。

使用线程来处理 I/O 请求的主要好处是它可以在需要时睡眠。现在,这听起来不错,但应该仔细考虑。睡眠既好又很方便,但可以非常有效地降低几何转换的性能。对性能极其敏感的类可能应该在 .start() 函数调用中完成所有工作,并小心处理内存不足和类似错误。

拥有此类事件处理程序线程的另一个好处是将来自不同几何线程的所有请求和响应序列化到一个线程中。这也很方便,但速度可能很慢。在大多数情况下,.done() 请求的处理可以留给 g_up 线程。

FreeBSD 内核中的互斥锁(请参阅 mutex(9))与更常见的用户空间互斥锁有一个区别 - 代码在持有互斥锁时不能睡眠)。如果代码需要大量睡眠,那么 sx(9) 锁可能更合适。另一方面,如果你在单个线程中完成几乎所有事情,那么你可能根本不需要使用互斥锁。


上次修改时间:2021 年 11 月 3 日,作者:Sergio Carlavilla Delgado