Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
arch-handbook
欢迎来到 FreeBSD 架构手册。本手册还在不断完善中,是许多人共同努力的成果。许多部分尚不存在,一些已存在的部分也需要更新。如果你有兴趣参与此项目,请发送电子邮件至 FreeBSD 文档项目邮件列表。
该文档的最新版本始终可从 FreeBSD 全球网络服务器获取。也可以从 FreeBSD 下载服务器或众多镜像站点之一以各种格式和压缩选项下载。
版权 © 2000-2006,2012-2023 FreeBSD 文档项目
[1] Marshall Kirk McKusick, Keith Bostic, Michael J Karels, and John S Quarterman. Copyright © 1996 Addison-Wesley Publishing Company, Inc.. 0-201-54979-4. Addison-Wesley Publishing Company, Inc.. The Design and Implementation of the 4.4 BSD Operating System. 1-2.
本章概述了从 BIOS(固件)POST 开始,到第一个用户进程创建的启动和系统初始化过程。由于系统启动的初始步骤非常依赖架构,本章以 IA-32 架构为例进行说明。然而,AMD64 和 ARM64 架构是更为重要和引人注目的例子,应该在不久的将来根据本文档的主题进行解释。
FreeBSD 启动过程可能出乎意料地复杂。在控制权从 BIOS 转交之后,必须进行大量的低级配置,才能加载并执行内核。此设置必须以简单且灵活的方式完成,以便用户能够进行大量的自定义操作。
启动过程是一个极其依赖机器的活动。编写代码时不仅需要考虑每种计算机架构,还可能存在同一架构下的多种启动方式。例如,stand 目录中包含了大量与架构相关的代码。每种受支持的架构都有一个对应的目录。FreeBSD 支持 CSM 启动标准(兼容性支持模块)。因此,支持 CSM(支持 GPT 和 MBR 分区)以及 UEFI 启动(完全支持 GPT,MBR 主要支持)。它还支持从 ext2fs、MSDOS、UFS 和 ZFS 加载文件。FreeBSD 还支持 ZFS 的启动环境功能,该功能允许主机操作系统传递有关启动的信息,这超越了过去简单的分区方式。但如今 UEFI 比 CSM 更为相关。下面的示例展示了如何从 MBR 分区的硬盘启动 x86 计算机,使用 FreeBSD boot0 多重引导加载程序,存储在第一个扇区。该引导代码启动了 FreeBSD 三阶段的引导过程。
下面是不同引导阶段生成的输出示例。实际输出可能因机器而异:
FreeBSD 组件
输出(可能有所不同)
boot0
装载程序
内核
当计算机开机时,处理器的寄存器被设置为一些预定义的值。其中一个寄存器是 指令指针 寄存器,它在开机后的值是固定的:它是一个 32 位的值 0xfffffff0
。指令指针寄存器(也称为程序计数器)指向处理器将要执行的代码。另一个重要的寄存器是 cr0
32 位控制寄存器,它在重启后刚开始时的值为 0
。cr0
寄存器的一个位,PE(保护启用)位,表示处理器是否在 32 位保护模式下运行,还是在 16 位实模式下运行。由于此位在启动时被清除,处理器以 16 位实模式启动。实模式意味着,在此模式下,线性地址和物理地址是相同的。处理器不立即进入 32 位保护模式的原因是向后兼容性,特别是启动过程依赖于 BIOS 提供的服务,而 BIOS 本身是在传统的 16 位代码下工作的。
0xfffffff0
的值略小于 4 GB,因此除非机器有 4 GB 的物理内存,否则无法指向有效的内存地址。计算机的硬件会将这个地址转换,使其指向 BIOS 的内存块。
BIOS(基本输入输出系统)是主板上的一块芯片,包含相对较小的只读存储器(ROM)。这些内存包含与主板配套硬件相关的各种底层例程。处理器首先会跳转到地址 0xfffffff0
,该地址实际上位于 BIOS 的内存中。通常,该地址包含跳转指令,跳转到 BIOS 的 POST 例程。
POST(开机自检)是一组例程,包括内存检查、系统总线检查以及其他底层初始化,以便 CPU 能够正确地设置计算机。该阶段的重要步骤是确定启动设备。现代 BIOS 实现允许选择启动设备,支持从软盘、CD-ROM、硬盘或其他设备启动。
POST 的最后一步是 INT 0x19
指令。INT 0x19
处理程序从启动设备的第一个扇区读取 512 字节数据到内存地址 0x7c00
。第一个扇区 这一术语源自硬盘架构,其中磁盘被分为多个圆柱形的磁道。磁道是有编号的,每个磁道又分为多个(通常为 64)扇区。磁道编号从 0 开始,而扇区编号从 1 开始。磁道 0 是磁盘上最外层的部分,第 1 扇区具有特殊用途,也叫做主引导记录(MBR,Master Boot Record)。第一个磁道上的其余扇区从未被使用。
这个扇区是我们启动序列的起点。正如我们将看到的,这个扇区包含了我们的 boot0 程序。BIOS 会跳转到地址 0x7c00
,使得该程序开始执行。
boot0
)当控制权从 BIOS 转交到内存地址 0x7c00
时,boot0 开始执行。它是第一个在 FreeBSD 控制下执行的代码。boot0 的任务非常简单:扫描分区表并让用户选择要从哪个分区启动。分区表是一个特殊的标准数据结构,嵌入在主引导记录(MBR)中(因此也嵌入在 boot0 中),描述了四个标准的 PC “分区”。boot0 存在于文件系统中,路径为 /boot/boot0。它是一个 512 字节的小文件,正是 FreeBSD 安装程序在安装时选择了“引导管理器”选项时写入硬盘 MBR 的内容。实际上,boot0 就是 MBR。
如前所述,我们调用 BIOS 的 INT 0x19
指令,将 MBR(boot0)加载到内存地址 0x7c00
。boot0 的源文件可以在 stand/i386/boot0/boot0.S 中找到——这是 Robert Nordier 编写的一段非常棒的代码。
从 MBR 的偏移量 0x1be
开始的一个特殊结构被称为 分区表。它包含四个记录,每个记录 16 字节,称为 分区记录,这些记录表示硬盘的分区情况,或者用 FreeBSD 的术语来说,就是分片。每个 16 字节中的一个字节表示该分区(分片)是否可引导。必须恰好有一个记录设置了这个标志,否则 boot0 的代码将拒绝继续执行。
分区记录包含以下字段:
1 字节的文件系统类型
1 字节的可引导标志
6 字节的 CHS 格式描述符
8 字节的 LBA 格式描述符
分区记录的描述符包含关于分区在磁盘上确切位置的信息。LBA 和 CHS 两种描述符提供相同的信息,但方式不同:LBA(逻辑块寻址)提供分区的起始扇区和分区的长度,而 CHS(柱面、磁头、扇区)提供分区第一个和最后一个扇区的坐标。分区表以特殊的签名 0xaa55
结束。
MBR 必须适合 512 字节,即一个磁盘扇区。这个程序使用了低级“技巧”,例如利用某些指令的副作用,并重用先前操作中的寄存器值,以最大限度地减少指令的使用。当处理分区表时,必须特别小心,因为它是嵌入在 MBR 本身中的。因此,在修改 boot0.S 时要非常小心。
请注意,boot0.S 源文件是“原样”汇编的:指令逐一转换为二进制,没有附加信息(例如没有 ELF 文件格式)。这种低级控制是在链接时通过传递特殊的控制标志给链接器来实现的。例如,程序的文本段被设置为位于地址 0x600
。实际上,这意味着 boot0 必须被加载到内存地址 0x600
才能正常工作。
查看 boot0 的 Makefile(stand/i386/boot0/Makefile)是很有意义的,因为它定义了 boot0 的一些运行时行为。例如,如果通过串行端口(COM1)连接了终端进行输入输出,则必须定义宏 SIO
(-DSIO
)。-DPXE
启用通过 PXE 启动,并可以通过按 F6 启动。此外,程序还定义了一组 标志,允许进一步修改其行为。所有这些在 Makefile 中都有说明。例如,查看链接器指令,命令链接器将文本段从地址 0x600
开始,并“原样”构建输出文件(去掉任何文件格式):
stand/i386/boot0/Makefile
现在让我们开始学习 MBR 或 boot0,从执行开始的地方入手。
注意
为了更好地展示,某些指令已进行了一些修改。例如,扩展了一些宏,并省略了某些宏测试,当测试的结果已知时。这适用于所有显示的代码示例。
stand/i386/boot0/boot0.S
接下来的代码块负责将程序搬迁到新的地址,并跳转到搬迁后的代码。
stand/i386/boot0/boot0.S
由于 boot0 被 BIOS 加载到地址 0x7C00
,它将自己复制到地址 0x600
,然后将控制权转移到该位置(记得它被链接为在地址 0x600
执行)。源地址 0x7c00
被复制到寄存器 %si
中,目标地址 0x600
被复制到寄存器 %di
中。复制的字数(程序的大小 = 512 字节)被复制到寄存器 %cx
中。接下来,rep
指令重复执行后续的 movsw
指令,重复次数由 %cx
寄存器的值决定。movsw
指令将由 %si
指向的字复制到由 %di
指向的地址。这一过程重复 255 次。每次重复后,源寄存器和目标寄存器(%si
和 %di
)都会分别增加 1。因此,经过 256 字(512 字节)的复制后,%di
的值为 0x600+512=0x800
,而 %si
的值为 0x7c00+512=0x7e00
,完成了代码的 搬迁。自本文档最后一次更新以来,代码中的复制指令已发生变化,现已使用 movsw
和 stosw
,这会一次复制 2 字节(1 字)。
接下来,目标寄存器 %di
被复制到 %bp
,%bp
的值为 0x800
。值 8
被复制到 %cl
,为新的字符串操作做准备(就像前面的 movsw
)。现在,执行 stosw
8 次。此指令将 0
值复制到目标寄存器 %di
(即 0x800
)所指向的地址,并自增 %di
。这一过程重复 7 次,最终 %di
的值为 0x810
。有效地,这会清除地址范围 0x800
-0x80f
。这个范围被用作(假的)分区表,以便将 MBR 写回到磁盘。最后,CHS 寻址的分区的扇区字段被赋值为 1,并跳转到搬迁后的代码的主函数。请注意,在跳转到搬迁后的代码之前,任何对绝对地址的引用都被避免了。
以下代码块测试是否应该使用 BIOS 提供的驱动器号,还是使用存储在 boot0 中的驱动器号。
stand/i386/boot0/boot0.S
这段代码测试 SETDRV
位(0x20
)在 标志 变量中的状态。记得 %bp
寄存器指向地址 0x800
,因此测试是针对地址 0x800-69=0x7bb
处的 标志 变量进行的。这是对 boot0 可以进行的修改类型的一个示例。SETDRV
标志默认没有设置,但可以在 Makefile 中设置。当设置时,使用存储在 MBR 中的驱动器号,而不是 BIOS 提供的驱动器号。我们假设使用默认设置,并且 BIOS 提供了有效的驱动器号,因此跳转到 save_curdrive
。
接下来的代码块将保存 BIOS 提供的驱动器号,并调用 putn
来打印一个换行符。
stand/i386/boot0/boot0.S
请注意,我们假设 TEST
没有被定义,因此其中的条件代码不会被汇编,也不会出现在我们编译后的 boot0 可执行文件中。
接下来的代码块实现了实际的分区表扫描。它将每个分区表项的类型打印到屏幕上,并将其与已知操作系统文件系统的列表进行比较。常见的分区类型包括 NTFS(Windows®,ID 0x7)、ext2fs
(Linux®,ID 0x83),当然还有 ffs
/ufs2
(FreeBSD,ID 0xa5)。该实现相对简单。
stand/i386/boot0/boot0.S
需要注意的是,每个条目的活动标志都被清除,因此扫描完成后,在 boot0 的内存副本中 没有 分区条目是活动的。稍后,活动标志将被设置为所选分区。这确保了只有一个活动分区存在,如果用户选择将更改写回磁盘。
接下来的代码块测试其他驱动器。在启动时,BIOS 将计算机中存在的驱动器数量写入地址 0x475
。如果存在其他驱动器,boot0 会将当前驱动器打印到屏幕上。用户稍后可以命令 boot0 扫描另一个驱动器上的分区。
stand/i386/boot0/boot0.S
我们假设存在单一驱动器,因此不会跳转到 print_drive
。我们还假设没有发生任何异常,因此跳转到 print_prompt
。
接下来的代码块只是输出一个提示符,后面跟着默认选项:
stand/i386/boot0/boot0.S
最后,跳转到 start_input
,在这里使用 BIOS 服务启动定时器并读取用户的键盘输入;如果定时器到期,则会选择默认选项:
stand/i386/boot0/boot0.S
请求中断 0x1a
,并将参数 0
放入寄存器 %ah
。BIOS 有一套预定义的服务,应用程序通过软件生成的中断 int
指令请求这些服务,并将参数传递到寄存器(在这里是 %ah
)。在此,我们请求自午夜以来的时钟滴答数;该值由 BIOS 通过 RTC(实时时钟)计算。这个时钟可以被设置为 2 Hz 到 8192 Hz 的频率,BIOS 在启动时设置为 18.2 Hz。当请求完成时,BIOS 会将 32 位结果返回到寄存器 %cx
和 %dx
(其中 %dx
的低字节存储了结果)。此结果(即 %dx
部分)被复制到寄存器 %di
,然后将 TICKS
变量的值加到 %di
中。该变量存储在 boot0 中,位于寄存器 %bp
(指向 0x800
)偏移 _TICKS
处(这是一个负值)。该变量的默认值是 0xb6
(十进制 182)。此时,boot0 会不断请求 BIOS 获取时间,并且当寄存器 %dx
返回的值大于存储在 %di
中的值时,时间到期,将选择默认选项。由于 RTC 每秒滴答 18.2 次,因此此条件将在 10 秒后满足(这个默认行为可以在 Makefile 中更改)。在这段时间内,boot0 会不断通过 int 0x16
和参数 1
在 %ah
中向 BIOS 查询用户输入。
无论是按下了键,还是时间到期,接下来的代码都会验证选择。根据选择,寄存器 %si
会指向分区表中的相应条目。这个新的选择会覆盖先前的默认选择,实际上,它会成为新的默认选择。最后,选定分区的 ACTIVE 标志被设置。如果在编译时启用了该功能,带有这些修改值的 boot0 内存副本将被写回到磁盘上的 MBR。我们将此实现的细节留给读者。
最后,我们通过以下代码块结束对 boot0 程序的研究:
stand/i386/boot0/boot0.S
回顾一下,寄存器 %si
指向所选的分区条目。该条目告诉我们该分区在磁盘上的起始位置。我们假设,当然,所选的分区实际上是一个 FreeBSD 切片(slice)。
注意
从现在开始,我们将优先使用技术上更准确的术语“切片”(slice)而不是“分区”(partition)。
传输缓冲区被设置为 0x7c00
(寄存器 %bx
),并请求通过调用 intx13
读取 FreeBSD 切片的第一个扇区。我们假设一切正常,因此不会跳转到 beep
。特别地,新读取的扇区必须以魔法序列 0xaa55
结束。最后,寄存器 %si
中的值(指向选定分区表的指针)被保存,以供下一阶段使用,然后跳转到地址 0x7c00
,在该位置启动我们刚刚读取的代码块的执行。
boot1
阶段到目前为止,我们已经经历了以下的引导过程:
BIOS 完成了早期的硬件初始化,包括自检(POST)。MBR(boot0)被从磁盘的绝对扇区一加载到地址 0x7c00
。控制权被传递到该位置。
boot0 将自己重新定位到它被链接执行的位置(0x600
),然后跳转到适当的位置继续执行。最后,boot0 从 FreeBSD 切片加载了第一个磁盘扇区到地址 0x7c00
。控制权被传递到该位置。
boot1 的主要任务是加载下一个引导阶段。下一个阶段更加复杂,它由一个名为 "Boot Extender"(BTX)的服务器和一个名为 boot2 的客户端组成。正如我们将看到的,最后的引导阶段 loader 也是 BTX 服务器的一个客户端。
现在,让我们详细看看 boot1 做了什么,首先从它的入口点开始,就像我们分析 boot0 一样:
stand/i386/boot2/boot1.S
在 start
处的入口点仅仅是跳过一个特殊的数据区域,跳到标签 main
,后者的内容如下:
stand/i386/boot2/boot1.S
和 boot0 一样,这段代码将 boot1 重新定位到内存地址 0x700
。然而,不像 boot0,它并没有跳转到那个位置。boot1 被链接到地址 0x7c00
执行,实际上是它最初被加载的位置。关于这种重新定位的原因,我们稍后将讨论。
stand/i386/boot2/boot1.S
在上面的代码中,寄存器 %dl
保持着关于启动设备的信息。这些信息由 BIOS 传递并由 MBR 保留。0x80
及以上的数字表示我们正在处理硬盘,因此会调用 nread
来读取 MBR。nread
的参数通过 %si
和 %dh
传递。标签 part4
处的内存地址被复制到 %si
。该内存地址保存了一个“伪分区”,由 nread
使用。以下是伪分区中的数据:
stand/i386/boot2/boot1.S
特别地,这个伪分区的 LBA 被硬编码为零。这被用作传递给 BIOS 的参数,用来读取硬盘的绝对扇区一。或者,也可以使用 CHS 定址,在这种情况下,伪分区包含了柱面 0、磁头 0 和扇区 1,这相当于绝对扇区一。
接下来,我们来看一下 nread
的实现:
stand/i386/boot2/boot1.S
回想一下,%si
指向伪分区。偏移量 0x8
处的字节被复制到寄存器 %ax
,偏移量 0xa
处的字节被复制到 %cx
。它们被 BIOS 解释为表示要读取的 LBA 的低 4 字节值(高四个字节假定为零)。寄存器 %bx
保存着 MBR 将被加载到的内存地址。将 %cs
压入堆栈这一指令非常有趣。在这种情况下,它没有实际效果。然而,正如我们稍后将看到的,boot2 与 BTX 服务器一起也使用了 xread.1
。这一机制将在下一节讨论。
xread.1
的代码进一步调用了 read
函数,实际上是向 BIOS 请求读取磁盘扇区:
stand/i386/boot2/boot1.S
请注意这段代码结束时的 lret
指令。这个指令弹出了 nread
压入的 %cs
寄存器,然后返回。最终,nread
也返回。
在 MBR 加载到内存后,实际查找 FreeBSD 切片的循环开始了:
stand/i386/boot2/boot1.S
如果找到了 FreeBSD 切片,执行将继续到 main.5
。注意,当找到 FreeBSD 切片时,%si
指向分区表中的相应条目,%dh
保存了分区号。我们假设找到了 FreeBSD 切片,因此继续执行 main.5
:
stand/i386/boot2/boot1.S
请记住,此时寄存器 %si
指向 MBR 分区表中的 FreeBSD 切片条目,因此对 nread
的调用将有效地读取此分区开始处的扇区。传递给寄存器 %dh
的参数告诉 nread
读取 16 个磁盘扇区。回想一下,FreeBSD 切片的前 512 字节(即第一个扇区)与 boot1 程序相对应。还要记住,写入 FreeBSD 切片开头的文件不是 /boot/boot1,而是 /boot/boot。我们来看一下这些文件在文件系统中的大小:
boot0 和 boot1 都是 512 字节,所以它们正好适合一个磁盘扇区。boot2 要大得多,包含了 BTX 服务器和 boot2 客户端。最后,名为 boot 的文件比 boot2 大 512 字节。这个文件是 boot1 和 boot2 的串联。正如前面所述,boot0 是写入绝对第一个磁盘扇区(MBR)中的文件,而 boot 是写入 FreeBSD 切片第一个扇区中的文件;boot1 和 boot2 并没有直接写入磁盘。用于将 boot1 和 boot2 串联成一个 boot 的命令是 cat boot1 boot2 > boot
。
从地址 0x9000
开始是 BTX 服务器的起始位置,紧接着是 boot2 客户端。BTX 服务器充当内核,并以受保护模式在最高特权级别下执行。相反,BTX 客户端(例如 boot2)以用户模式执行。我们将在下一节中详细讨论如何实现这一点。调用 nread
后的代码会定位 boot2 在内存缓冲区中的起始位置,并将其复制到内存地址 0xc000
。这是因为 BTX 服务器将 boot2 安排在从 0xa000
开始的段中。我们将在接下来的章节中详细探讨这一点。
stand/i386/boot2/boot1.S
请注意,在跳转之前,中断已被启用。
接下来,在我们的启动序列中是 BTX 服务器。让我们快速回顾一下我们是如何到达这里的:
BIOS 将绝对扇区一(MBR,或 boot0)加载到地址 0x7c00
并跳转到该地址。
boot0 将自身重新定位到 0x600
(它被链接执行的地址),然后跳转过去。接着,它读取 FreeBSD 分区的第一个扇区(包含 boot1)到地址 0x7c00
,并跳转过去。
boot1 将 FreeBSD 分区的前 16 个扇区加载到地址 0x8c00
。这 16 个扇区或 8192 字节构成了整个 boot 文件。该文件是 boot1 和 boot2 的连接体。boot2 进一步包含了 BTX 服务器和 boot2 客户端。最后,跳转到地址 0x9010
,即 BTX 服务器的入口点。
在详细研究 BTX 服务器之前,我们先回顾一下如何创建单一的、集成的 boot 文件。boot 文件的构建方式在其 Makefile(stand/i386/boot2/Makefile)中定义。我们来看一下创建 boot 文件的规则:
stand/i386/boot2/Makefile
这告诉我们,boot1 和 boot2 是必需的,规则简单地将它们连接起来生成一个名为 boot 的单一文件。创建 boot1 的规则也很简单:
stand/i386/boot2/Makefile
要应用创建 boot1 的规则,必须先解决 boot1.out。这反过来依赖于 boot1.o 的存在。这个文件就是将我们熟悉的 boot1.S 汇编后的结果,且没有进行链接。现在,创建 boot1.out 的规则被应用。这告诉我们,boot1.o 应该使用 start
作为入口点,并从地址 0x7c00
开始链接。最终,boot1 是从 boot1.out 创建的,通过应用适当的规则。这个规则就是对 boot1.out 使用 objcopy 命令。请注意传给 objcopy 的标志:-S
告诉它去除所有的重定位和符号信息;-O binary
指示输出格式是简单的、未格式化的二进制文件。
有了 boot1,我们再看看 boot2 是如何构建的:
stand/i386/boot2/Makefile
构建 boot2 的机制要复杂得多。我们来指出其中最相关的内容。依赖关系列表如下:
stand/i386/boot2/Makefile
注意,最初并没有头文件 boot2.h,但是它的创建依赖于我们已经拥有的 boot1.out。创建它的规则有点简略,但重要的是,输出的 boot2.h 文件内容大致如下:
stand/i386/boot2/boot2.h
回想一下,boot1 是被重新定位的(即,从 0x7c00
复制到 0x700
)。这个重新定位现在变得有意义,因为正如我们将看到的,BTX 服务器回收了一些内存,包括原来 boot1 被加载的空间。然而,BTX 服务器需要访问 boot1 中的 xread
函数;根据 boot2.h 的输出,该函数位于地址 0x725
。实际上,BTX 服务器正是从 boot1 被重新定位的代码中使用 xread
函数。这个函数现在可以从 boot2 客户端中访问。
接下来的规则指导链接器链接多个文件(ashldi3.o、boot2.o 和 sio.o)。请注意,输出文件 boot2.out 被链接为在地址 0x2000
执行(${ORG2})。回想一下,boot2 将在用户模式下执行,在 BTX 服务器设置的特殊用户段中。这个段从 0xa000
开始。此外,记住 boot2 部分已经被复制到地址 0xc000
,即从用户段的起始位置偏移了 0x2000
,因此,当我们将控制权转交给它时,boot2 将能够正常工作。接下来,boot2.bin 是从 boot2.out 生成的,通过去除其符号和格式信息;boot2.bin 是一个 原始 二进制文件。现在,请注意,文件 boot2.ldr 是一个 512 字节的全零文件。这个空间预留给 bsdlabel。
现在,我们已经有了 boot1、boot2.bin 和 boot2.ldr 文件,剩下的就是 BTX 服务器,才能完成创建全功能 boot 文件的过程。BTX 服务器位于 stand/i386/btx/btx,它有自己的 Makefile 和一套构建规则。需要注意的是,BTX 服务器也是作为一个 raw 二进制文件编译的,并且它被链接到地址 0x9000
执行。详细信息可以在 stand/i386/btx/btx/Makefile 中找到。
拥有了构成 boot 程序的文件后,最后一步是将它们 合并。这通过一个名为 btxld 的特殊程序来完成(源代码位于 /usr/src/usr.sbin/btxld)。该程序的一些参数包括输出文件名(boot)、入口点 (0x2000
) 和文件格式(原始二进制)。这些文件最终由该工具合并成 boot 文件,这个文件包含了 boot1、boot2、bsdlabel
和 BTX 服务器。这个文件大小正好为 16 个扇区,或 8192 字节,实际在安装过程中写入 FreeBSD 分区的开头。接下来,我们将研究 BTX 服务器程序。
BTX 服务器准备了一个简单的环境,并在将控制权交给客户端之前,从 16 位实模式切换到 32 位保护模式。这个过程包括初始化和更新以下数据结构:
修改 中断向量表(IVT)
。IVT 提供了实模式代码的异常和中断处理程序。
创建 中断描述符表(IDT)
。为处理器异常、硬件中断、两个系统调用和 V86 接口提供了条目。IDT 为保护模式代码提供异常和中断处理程序。
创建一个 任务状态段(TSS)
。这是必要的,因为处理器在执行客户端(boot2)时处于 最不特权级,而在执行 BTX 服务器时处于 最高特权级。
设置 GDT(全局描述符表)。为监督代码和数据、用户代码和数据,以及实模式代码和数据提供了条目。
接下来,我们将开始研究实际的实现。回想一下,boot1 跳转到地址 0x9010
,即 BTX 服务器的入口点。在研究程序执行之前,请注意,BTX 服务器在其入口点之前(即地址范围 0x9000-0x900f
)有一个特殊的头部。这个头部定义如下:
stand/i386/btx/btx/btx.S
注意前两个字节是 0xeb
和 0xe
。在 IA-32 架构中,这两个字节被解释为一个相对跳转,跳过头部直接进入入口点。因此,理论上 boot1 可以直接跳到这里(地址 0x9000
),而不是跳到地址 0x9010
。请注意,BTX 头部的最后一个字段是指向客户端(boot2)入口点的指针。这个字段在链接时会被修补。
紧接着头部的是 BTX 服务器的入口点:
stand/i386/btx/btx/btx.S
这段代码禁用中断,设置一个工作堆栈(从地址 0x1800
开始),并清除 EFLAGS 寄存器中的标志。请注意,popfl
指令会从堆栈中弹出一个双字(4 字节),并将其放入 EFLAGS 寄存器。由于实际弹出的值为 2
,所以 EFLAGS 寄存器会被清除(IA-32 要求 EFLAGS 寄存器的第 2 位始终为 1)。
接下来的代码块清除(设置为 0
)内存范围 0x5e00-0x8fff
。这个范围是用来创建各种数据结构的:
stand/i386/btx/btx/btx.S
回想一下,boot1 最初加载到地址 0x7c00
,因此,在这次内存初始化之后,那个副本实际上已经消失了。然而,boot1 也被重定位到 0x700
,所以 那个 副本仍然在内存中,并且 BTX 服务器将会使用它。
接下来,实模式的中断向量表(IVT)被更新。IVT 是一个包含异常和中断处理程序的段/偏移对数组。BIOS 通常将硬件中断映射到中断向量 0x8
到 0xf
以及 0x70
到 0x77
,但正如我们将看到的那样,8259A 可编程中断控制器(控制硬件中断映射的芯片)被编程为将这些中断向量从 0x8-0xf
映射到 0x20-0x27
,并将 0x70-0x77
映射到 0x28-0x2f
。因此,中断处理程序被提供给中断向量 0x20-0x2f
。之所以不直接使用 BIOS 提供的处理程序,是因为它们只能在 16 位实模式下工作,而不能在 32 位保护模式下工作。处理器模式将很快切换到 32 位保护模式。然而,BTX 服务器设置了一种机制,实际上利用 BIOS 提供的处理程序:
stand/i386/btx/btx/btx.S
接下来的代码块创建了 IDT(中断描述符表)。IDT 在保护模式下类似于实模式下的 IVT。也就是说,IDT 描述了处理器在执行保护模式下的各种异常和中断处理程序。它本质上也是一个段/偏移对的数组,尽管结构稍微复杂一些,因为在保护模式下,段与实模式下的不同,并且应用了各种保护机制:
stand/i386/btx/btx/btx.S
IDT 中的每个条目长 8 字节。除了段/偏移信息外,它们还描述了段的类型、特权级别,以及段是否存在于内存中。构建方式是,中断向量 0
到 0xf
(异常)由函数 intx00
处理;中断向量 0x10
(也是异常)由 intx10
处理;硬件中断,从中断向量 0x20
到 0x2f
,由函数 intx20
处理。最后,中断向量 0x30
,用于系统调用,由 intx30
处理,而向量 0x31
和 0x32
由 intx31
处理。需要注意的是,只有中断向量 0x30
、0x31
和 0x32
的描述符被设置为特权级别 3,这与 boot2 客户端的特权级别相同,这意味着客户端可以通过 int
指令执行软件生成的中断来访问这些向量而不失败(这就是 boot2 使用 BTX 服务器提供的服务的方式)。同时,只有软件生成的中断才会在较低特权级别的代码执行时得到保护。硬件生成的中断和处理器生成的异常始终得到适当的处理,无论实际的特权级别如何。
接下来的步骤是初始化 TSS(任务状态段)。TSS 是一种硬件特性,帮助操作系统或执行软件通过进程抽象实现多任务功能。IA-32 架构要求在使用多任务功能或定义不同特权级别时,必须至少创建一个 TSS。由于 boot2 客户端在特权级别 3 中执行,而 BTX 服务器在特权级别 0 中运行,因此必须定义一个 TSS:
stand/i386/btx/btx/btx.S
请注意,TSS 中为特权级 0 的堆栈指针和堆栈段设置了一个值。因为如果在特权级 3 执行 boot2 时接收到中断或异常,处理器会自动切换到特权级 0,所以需要新的工作堆栈。最后,TSS 的 I/O 映射基地址字段被赋予一个值,该值是从 TSS 开始到 I/O 权限位图和中断重定向位图的 16 位偏移量。
在创建 IDT 和 TSS 后,处理器准备好切换到保护模式。接下来的代码块执行这一操作:
stand/i386/btx/btx/btx.S
首先,调用 setpic
来编程 8259A PIC(可编程中断控制器)。该芯片连接到多个硬件中断源。接收到来自设备的中断时,它通过适当的中断向量通知处理器。可以自定义此映射,使特定中断与特定的中断向量关联。接下来,使用 lidt
和 lgdt
指令加载 IDTR(中断描述符表寄存器)和 GDTR(全局描述符表寄存器)。这两个寄存器分别加载 IDT 和 GDT 的基地址和限制地址。接下来的三条指令设置 %cr0
寄存器的保护启用(PE)位。这样就有效地将处理器切换到 32 位保护模式。然后,使用段选择符 SEL_SCODE 进行长跳转到 init.8
,该段选择符选择了监督代码段。跳转后,处理器实际上在 CPL 0(最高特权级)下执行。最后,通过将段选择符 SEL_SDATA 分配给 %ss
寄存器,选择监督数据段作为堆栈。这一数据段的特权级也是 0
。
我们的最后一个代码块负责加载 TR(任务寄存器)与我们之前创建的 TSS 的段选择符,并在将执行控制传递给 boot2 客户端之前设置用户模式环境。
stand/i386/btx/btx/btx.S
请注意,客户端的环境包括堆栈段选择符和堆栈指针(寄存器 %ss
和 %esp
)。事实上,一旦 TR 被加载到适当的堆栈段选择符(指令 ltr
),堆栈指针将被计算并与堆栈的段选择符一起推送到堆栈。接下来,值 0x202
被推送到堆栈,它是当控制权传递给客户端时,EFLAGS 寄存器将获得的值。此外,用户模式代码段选择符和客户端的入口点也被推送。请记住,这个入口点是在链接时通过 BTX 头文件进行修补的。最后,存储在寄存器 %ecx
中的段选择符(用于 %gs, %fs, %ds
和 %es
的段寄存器)也被推送到堆栈中,连同 %edx
中的值(0xa000
)。请记住,已经推送到堆栈中的各种值(它们稍后将被弹出)。接下来,剩余的通用寄存器的值也被推送到堆栈中(注意 loop
指令,它推送了 7 次 0
)。现在,值将开始从堆栈中弹出。首先,popa
指令从堆栈中弹出最近推送的 7 个值。它们按顺序存储在通用寄存器中,顺序为 %edi, %esi, %ebp, %ebx, %edx, %ecx, %eax
。然后,推送的各种段选择符被弹出并复制到各个段寄存器中。堆栈中仍然剩下 5 个值。它们在执行 iret
指令时被弹出。该指令首先弹出 BTX 头文件推送的值。这个值是 boot2 的入口点指针,它被放置在寄存器 %eip
(指令指针寄存器)中。接下来,用户代码段的段选择符被弹出并复制到寄存器 %cs
中。请记住,这个段的特权级是 3,即最低特权级。这意味着我们必须为这个特权级的堆栈提供值。这就是为什么处理器除了进一步弹出 EFLAGS 寄存器的值之外,还会从堆栈中弹出两个值。这些值被放入堆栈指针(%esp
)和堆栈段(%ss
)中。现在,执行控制权将继续在 boot0
的入口点。
需要注意的是,用户代码段的定义。该段的 基地址 设置为 0xa000
。这意味着代码内存地址是相对于 0xa000
的;如果正在执行的代码从地址 0x2000
获取,实际 地址将是 0xa000+0x2000=0xc000
。
boot2
定义了一个重要的结构体,struct bootinfo
。这个结构体由 boot2
初始化并传递给加载器,随后加载器再传递给内核。这个结构体的部分节点由 boot2
设置,其他部分则由加载器设置。该结构体包含了许多信息,例如内核文件名、BIOS 硬盘几何信息、引导设备的 BIOS 驱动器编号、可用的物理内存、envp
指针等。其定义如下:
boot2
进入一个无限循环,等待用户输入,然后调用 load()
。如果用户没有按任何键,循环会在超时后中断,接着 load()
会加载默认文件(/boot/loader)。ino_t lookup(char *filename)
和 int xfsread(ino_t inode, void *buf, size_t nbyte)
函数用于将文件的内容读取到内存中。/boot/loader 是一个 ELF 二进制文件,但在 ELF 头部之前有一个 a.out 的 struct exec
结构。load()
扫描加载器的 ELF 头部,将 /boot/loader 的内容加载到内存,并将执行权传递给加载器的入口点:
loader 的主要任务是引导内核。当内核被加载到内存中后,loader 会调用内核:
让我们看看链接内核的命令。这将帮助我们确定加载器将执行权传递给内核的确切位置。这个位置就是内核的实际入口点。这个命令现在已经从 sys/conf/Makefile.i386 中移除。我们感兴趣的内容可以在 /usr/obj/usr/src/i386.i386/sys/GENERIC/ 中找到。
这里可以看到几个有趣的事情。首先,内核是一个 ELF 动态链接的二进制文件,但内核的动态链接器是 /red/herring,这显然是一个虚假的文件。其次,查看文件 sys/conf/ldscript.i386 可以了解到在编译内核时使用的 ld 选项。读完前几行后,可以看到字符串
这表明内核的入口点是符号 btext
。这个符号在 locore.s 中定义:
首先,EFLAGS 寄存器被设置为预定义的值 0x00000002。然后初始化所有段寄存器:
btext 调用了 locore.s 中定义的 recover_bootinfo()
和 identify_cpu()
函数。以下是它们的作用说明:
recover_bootinfo
该例程解析引导程序传递给内核的参数。内核可能通过三种方式启动:通过上面描述的加载器,通过旧的磁盘引导块,或者通过旧的无盘引导程序。此函数确定引导方式,并将 struct bootinfo
结构存储到内核内存中。
identify_cpu
该函数试图找出运行的 CPU 类型,并将找到的值存储在变量 _cpu
中。
接下来的步骤是启用 VME,如果 CPU 支持的话:
然后,启用分页:
接下来的三行代码是因为已设置分页,因此需要跳转到虚拟地址空间中继续执行:
内核在调用 mi_startup()
后完成引导,而在此之前调用了 init386()
。init386()
是一个与架构相关的初始化函数,而 mi_startup()
是一个与架构无关的函数('mi_' 前缀表示与机器无关)。内核在调用 mi_startup()
后不会再返回,且通过调用它,内核完成了引导:
init386()
init386()
定义在 sys/i386/i386/machdep.c 中,执行与 i386 芯片特定的低级初始化。保护模式的切换由加载器完成。加载器已经创建了第一个任务,内核在其中继续运行。在查看代码之前,可以考虑处理器必须完成的任务,以初始化保护模式执行:
初始化从引导程序传递过来的内核可调参数。
准备 GDT(全局描述符表)。
准备 IDT(中断描述符表)。
初始化系统控制台。
初始化 DDB(调试器),如果它被编译到内核中。
初始化 TSS(任务状态段)。
准备 LDT(局部描述符表)。
设置线程 0 的 PCB(进程控制块)。
init386()
通过设置环境指针(envp)并调用 init_param1()
来初始化从引导程序传递过来的可调参数。envp 指针是通过 bootinfo
结构从加载器传递过来的:
init_param1()
定义在 sys/kern/subr_param.c 中。该文件有多个 sysctl 以及两个函数 init_param1()
和 init_param2()
,它们都从 init386()
被调用:
TUNABLE_INT_FETCH
用于从环境中获取值:
Sysctl kern.hz
是系统时钟滴答的频率。此外,init_param1()
还设置了以下 sysctl:kern.maxswzone
、kern.maxbcache
、kern.maxtsiz
、kern.dfldsiz
、kern.maxdsiz
、kern.dflssiz
、kern.maxssiz
、kern.sgrowsiz
。
然后,init386()
准备了全局描述符表(GDT)。在 x86 上,每个任务都在自己的虚拟地址空间中运行,这个空间由段:偏移对来寻址。例如,假设当前由处理器执行的指令位于 CS:EIP,那么该指令的线性虚拟地址将是“代码段 CS 的虚拟地址”+ EIP。为了方便,段从虚拟地址 0 开始,到达 4GB 边界。因此,在此示例中,指令的线性虚拟地址仅为 EIP 的值。像 CS、DS 等段寄存器就是选择符,即 GDT 的索引(准确地说,选择符的索引字段才是索引,而不是选择符本身)。FreeBSD 的 GDT 为每个 CPU 持有 15 个选择符的描述符:
在 init386()
中,首先会初始化 GDT(全局描述符表)。以下代码定义了初始的 GDT,并设置为 gdt0
:
然后,在 sys/x86/include/segments.h 中定义了 GDT 中的各个选择符(selector):
这些定义并不是选择符本身,而仅仅是选择符的索引字段。它们对应 GDT 中的各个条目的索引。例如,内核代码选择符(GCODE_SEL
)的实际选择符值是 0x20。
接下来,init386()
会初始化中断描述符表(IDT)。该表由处理器在发生软件或硬件中断时引用。例如,用户应用程序通过发出 INT 0x80
指令来进行系统调用。这是一个软件中断,处理器的硬件会查找 IDT 中索引为 0x80 的记录,这个记录指向处理该中断的例程。在这种情况下,指向的就是内核的系统调用门(syscall gate)。IDT 最多可以有 256 条记录。内核为 IDT 分配了 NIDT 条记录,其中 NIDT 是最大值(256):
然后,为每个中断设置相应的处理程序。INT 0x80
的系统调用门也被设置:
当用户空间的应用程序发出 INT 0x80
指令时,控制将转移到内核代码段中的函数 _Xint0x80_syscall
,并以 supervisor 特权级别执行。
接下来,控制台和 DDB(内核调试器)被初始化:
任务状态段(TSS)是另一个 x86 保护模式结构。TSS 由硬件在任务切换时存储任务信息。接下来是局部描述符表(LDT)的初始化。LDT 用于引用用户空间的代码和数据。以下是定义 LDT 中选择符的几个宏,它们是系统调用门和用户代码、数据选择符:
接下来,初始化 proc0
的进程控制块(PCB)结构。proc0
是一个描述内核进程的 struct proc
结构,它在内核运行时始终存在,因此它与 thread0
关联:
struct pcb
是 proc
结构的一部分,定义了与 i386 架构相关的进程信息,如寄存器值。
mi_startup()
系统初始化mi_startup()
函数执行所有系统初始化对象的冒泡排序,然后依次调用每个对象的入口函数:
每个系统初始化对象(sysinit 对象)都是通过调用 SYSINIT()
宏来创建的。以下是一个 announce
sysinit 对象的示例,它打印版权信息:
该对象的子系统 ID 为 SI_SUB_COPYRIGHT
(0x0800001),因此版权信息会在控制台初始化之后首先打印。
宏 SYSINIT()
扩展为 C_SYSINIT()
宏。C_SYSINIT()
宏再扩展为一个静态的 struct sysinit
结构声明,并调用 DATA_SET
宏:
宏 DATA_SET()
扩展为 _MAKE_SET()
,这是隐藏所有 sysinit 魔法的地方:
执行这些宏后,内核中会创建多个段,包括 set.sysinit_set
。通过运行 objdump
命令查看内核二进制文件时,你可能会发现这些小段的存在:
这段输出显示 set.sysinit_set
的大小是 0x14d8 字节,因此 0x14d8/sizeof(void *)
个 sysinit 对象被编译进内核。其他段如 set.sysctl_set
表示其他链接器集。
通过定义一个类型为 struct sysinit
的变量,set.sysinit_set
部分的内容将被“收集”到该变量中:
struct sysinit
定义如下:
回到 mi_startup()
的讨论,现在必须清楚,sysinit 对象是如何组织的。mi_startup()
函数对它们进行排序并逐个调用。最后一个对象是系统调度器:
系统调度器的 sysinit 对象定义在文件 sys/vm/vm_glue.c 中,该对象的入口点是 scheduler()
。该函数实际上是一个无限循环,它代表一个进程,PID 为 0,即交换进程。之前提到的 thread0
结构用于描述它。
第一个用户进程,即 init,由 sysinit 对象 init
创建:
create_init()
函数通过调用 fork1()
分配一个新进程,但没有标记它为可运行。当这个新进程被调度器调度执行时,将会调用 start_init()
。该函数定义在 init_main.c 中。它尝试加载并执行 init 二进制文件,首先探测 /sbin/init,然后是 /sbin/oinit、/sbin/init.bak,最后是 /rescue/init:
用户空间 jail 的源代码位于 /usr/src/usr.sbin/jail 目录,包含一个文件 jail.c。该程序接受以下参数:jail 的路径、主机名、IP 地址以及要执行的命令。
在 jail.c 中,首先需要注意的是声明了一个重要的结构 struct jail j;
,该结构包含自 /usr/include/sys/jail.h 引入的内容。
jail
结构的定义如下:
如你所见,调用了 jail()
函数,并且其参数是已填充的 jail
结构,最后执行了指定的程序。我将讨论 jail 在内核中的实现。
在 kern_jail.c 中,定义了以下 sysctl:
在 jail.h 中,还定义了另一个重要的结构 prison
。prison
结构只在内核空间中使用。以下是 prison
结构的定义。
在 FreeBSD 中,每个内核可见的线程都由其 thread
结构标识,而进程则由其 proc
结构描述。你可以在 /usr/include/sys/proc.h 中找到 thread
和 proc
结构的定义。例如,任何系统调用中的 td
参数实际上是指向调用线程的 thread
结构的指针。如前所述,td_proc
是指向表示包含该线程的进程的 proc
结构的指针。proc
结构包含可以描述进程所有者身份(p_ucred
)、进程资源限制(p_limit
)等信息。在 proc
结构中指向的 ucred
结构(通过 p_ucred
成员访问)中,存在指向 prison
结构的指针(cr_prison
)。
在内核中,针对被 jail 限制的进程有访问限制。通常,这些限制只会检查进程是否被 jail 限制,如果是,它将返回一个错误。例如:
/usr/src/sys/kern/sysv_msg.c:
msgget(key, msgflg)
:msgget
返回(并可能创建)一个消息描述符,该描述符表示要在其他函数中使用的消息队列。
msgctl(msgid, cmd, buf)
:通过此函数,进程可以查询消息描述符的状态。
msgsnd(msgid, msgp, msgsz, msgflg)
:msgsnd
将一条消息发送到进程。
msgrcv(msgid, msgp, msgsz, msgtyp, msgflg)
:进程使用此函数接收消息。
在与这些函数对应的每个系统调用中,都有以下条件判断:
/usr/src/sys/kern/sysv_sem.c:
semctl(semid, semnum, cmd, …)
:semctl
对由 semid
标识的信号量队列执行指定的 cmd
。
semget(key, nsems, flag)
:semget
创建一个信号量数组,该数组与 key
对应。key 和 flag 的意义与 msgget 中相同。
semop(semid, array, nops)
:semop
对由 semid
标识的信号量集合执行由 array
指定的一组操作。
/usr/src/sys/kern/sysv_shm.c:
shmctl(shmid, cmd, buf)
:shmctl
对由 shmid
标识的共享内存区域执行各种控制操作。
shmget(key, size, flag)
:shmget
访问或创建一个大小为 size
字节的共享内存区域。
shmat(shmid, addr, flag)
:shmat
将由 shmid
标识的共享内存区域附加到进程的地址空间。
shmdt(addr)
:shmdt
从地址 addr
中分离先前附加的共享内存区域。
有一些非常常见的协议,比如 TCP、UDP、IP 和 ICMP。IP 和 ICMP 位于同一层级:网络层 2。在某些情况下,会采取一些预防措施,防止 jailed 进程绑定到某个特定地址,前提是 nam
参数被设置。nam
是一个指向 sockaddr
结构的指针,该结构描述了绑定服务的地址。更准确的定义是,sockaddr
"可以用作引用每个地址的标识标签和长度的模板"。在函数 in_pcbbind_setup()
中,sin
是指向 sockaddr_in
结构的指针,该结构包含端口、地址、长度和要绑定的套接字的域族。基本上,这不允许 jail 中的任何进程指定不属于该进程所在 jail 的地址。
你可能会想知道 prison_ip()
函数是做什么的。prison_ip()
函数接受三个参数,一个指向凭证(由 cred
表示)的指针、任何标志和一个 IP 地址。如果该 IP 地址不属于 jail,它将返回 1,否则返回 0。从代码中可以看出,如果该 IP 地址确实不属于 jail,则协议不允许绑定到该地址。
即使是 jail 中的 root
用户,在安全级别(securelevel)大于 0 的情况下,也不允许取消或修改任何文件标志,例如不可变(immutable)、仅追加(append-only)和不可删除(undeleteable)标志。
Kernel Objects,或称 Kobj,为内核提供了一个面向对象的 C 编程系统。因此,操作的数据包含了如何操作它的描述。这使得操作可以在运行时动态地添加或移除,并且不会破坏二进制兼容性。
Object
一组数据——数据结构——数据分配。
Method
一种操作——函数。
Class
一个或多个方法。
Interface
一组标准的一个或多个方法。
Kobj 通过生成方法描述来工作。每个描述包含一个唯一的 id 和一个默认函数。描述的地址用于在类的函数表中唯一地标识该方法。
通过创建一个方法表,将一个或多个函数与方法描述关联来构建一个类。在使用之前,类需要被编译。编译过程会分配一个缓存,并将其与类关联。如果方法描述尚未由其他引用类的编译分配唯一 id,则为类的方法表中的每个方法描述分配一个唯一的 id。为了使用每个方法,脚本会生成一个函数,用于验证参数,并自动引用方法描述进行查找。生成的函数通过使用与方法描述关联的唯一 id,作为哈希值查找与对象类关联的缓存。如果方法未被缓存,生成的函数将继续使用类的表格查找方法。如果找到该方法,则使用类中关联的函数;否则,使用方法描述中关联的默认函数。
这些间接引用可以用以下方式可视化:
使用 Kobj 的第一步是创建接口。创建接口包括创建一个模板,脚本 src/sys/kern/makeobjops.pl 可以用来生成方法声明和方法查找函数的头文件和代码。
在这个模板中使用以下关键字:#include
、INTERFACE
、CODE
、EPILOG
、HEADER
、METHOD
、PROLOG
、STATICMETHOD
和 DEFAULT
。
#include
语句及其后面的内容会被原样复制到生成的代码文件的开头。
例如:
INTERFACE
关键字用于定义接口名称。该名称会与每个方法名称连接成 [interface name]_[method name]
。其语法为 INTERFACE [interface name];
。
例如:
CODE
关键字会将其参数原样复制到代码文件中。其语法为 CODE { [whatever] };
例如:
HEADER
关键字会将其参数原样复制到头文件中。其语法为 HEADER { [whatever] };
例如:
METHOD
关键字描述一个方法。其语法为 METHOD [return type] [method name] { [object [, arguments]] };
例如:
DEFAULT
关键字可以跟在 METHOD
关键字后面。它扩展了 METHOD
关键字,包含方法的默认函数。扩展后的语法为 METHOD [return type] [method name] { [object; [other arguments]] }DEFAULT [default function];
例如:
STATICMETHOD
关键字的使用方式与 METHOD
类似,但 Kobj 数据不在对象结构的开头,因此将其强制转换为 kobj_t 会不正确。STATICMETHOD
依赖于 Kobj 数据被引用为 'ops'。这对于直接从类的函数表调用方法也很有用。
PROLOG
和 EPILOG
关键字分别在 METHOD
前后插入代码。这一功能主要用于分析情境,其中很难通过其他方式获取信息。
其他完整示例:
使用 Kobj 的第二步是创建类。类由名称、方法表和如果使用 Kobj 的对象处理功能时的对象大小组成。要创建类,请使用宏 DEFINE_CLASS()
。要创建方法表,创建一个由 kobj_method_t 组成的数组,以 NULL 条目结束。每个非 NULL 条目可以使用宏 KOBJMETHOD()
创建。
例如:
类必须被“编译”。根据系统在初始化类时的状态,可能需要使用静态分配的缓存、“ops 表”。这可以通过声明 struct kobj_ops
并使用 kobj_class_compile_static();
来实现。否则,应该使用 kobj_class_compile()
。
使用 Kobj 的第三步是定义对象。Kobj 对象创建例程假定 Kobj 数据位于对象的开头。如果这种假设不适用,你将需要自行分配对象,并在对象的 Kobj 部分使用 kobj_init()
;否则,你可以使用 kobj_create()
来自动分配和初始化对象的 Kobj 部分。kobj_init()
也可以用于更改对象使用的类。
为了将 Kobj 集成到对象中,你应该使用宏 KOBJ_FIELDS
。
例如:
使用 Kobj 的最后一步是使用生成的函数来调用对象类中的所需方法。这与使用接口名称和方法名称,并进行一些修改非常相似。接口名称应与方法名称连接,中间使用下划线,并且全部大写。
例如,如果接口名称是 foo,方法是 bar,那么调用方式将是:
当通过 kobj_create()
分配的对象不再需要时,可以调用 kobj_delete()
对其进行清理;当类不再使用时,可以调用 kobj_class_free()
对其进行清理。
vm_page_t
物理内存是通过 vm_page_t
结构逐页管理的。页面的物理内存通过将各自的 vm_page_t
结构放置到多个分页队列中的一个来分类。
页面可以处于有线、活动、非活动、缓存或空闲状态。除了有线状态外,页面通常被放置在代表其当前状态的双向链表队列中。有线页面不会被放置到任何队列中。
FreeBSD 实现了一个更复杂的缓存和空闲页面分页队列,用于实现页面着色。每个状态都涉及多个队列,队列按处理器的 L1 和 L2 缓存的大小排列。当需要分配新页面时,FreeBSD 会尝试从内存中获取一个与 L1 和 L2 缓存相对齐的页面,以便与正在分配的 VM 对象对齐。
此外,页面可以通过引用计数来持有或通过忙碌计数来锁定。VM 系统还通过页面的标志中的 PG_BUSY 位实现了一个“最终锁定”状态。
通常,每个分页队列按 LRU(最近最少使用)方式操作。页面最初通常会放置在有线或活动状态下。当页面被标记为有线时,通常会与某个页面表相关联。VM 系统通过扫描活动分页队列(LRU)中的页面并将其移到不那么活跃的分页队列来“老化”页面。被移动到缓存中的页面仍然与 VM 对象关联,但可以立即重新使用。空闲队列中的页面是完全空闲的。FreeBSD 尽量减少空闲队列中的页面数量,但必须保持一定数量的真正空闲页面,以便在中断时进行页面分配。
如果进程尝试访问其页面表中不存在的页面,但该页面在某个分页队列中存在(例如,非活动队列或缓存队列中),则会发生一个相对便宜的页面重新激活错误,导致页面被重新激活。如果页面根本不存在于系统内存中,进程必须阻塞,直到页面从磁盘中加载进来。
FreeBSD 动态地调整其分页队列,并尝试保持队列中页面的合理比例,同时努力维持干净页面与脏页面的合理分布。发生的重平衡量取决于系统的内存负载。此重平衡由页面输出守护进程(pageout daemon)实现,并涉及将脏页面同步到其备份存储、监视页面是否被活动引用(通过重置其在 LRU 队列中的位置或在队列之间移动它们)、当队列失衡时在队列之间迁移页面等。FreeBSD 的 VM 系统愿意接受一定数量的重新激活页面错误,以确定页面实际是活跃还是空闲。这使得在何时洗净或交换页面时做出更好的决策。
vm_object_t
FreeBSD 实现了一个通用的“VM 对象”概念。VM 对象可以与不同类型的备份存储相关联:无备份、交换备份、物理设备备份或文件备份存储。由于文件系统使用相同的 VM 对象来管理与文件相关的内存中的数据,因此得出了一个统一的缓冲区缓存。
VM 对象可以被影射(shadowed),即可以堆叠在一起。例如,你可以将一个交换备份的 VM 对象堆叠在一个文件备份的 VM 对象上,以实现 MAP_PRIVATE 的 mmap() 映射。这种堆叠也用于实现各种共享属性,包括对地址空间的复制时写(copy-on-write)。
需要注意的是,vm_page_t
只能与一个 VM 对象关联一次。VM 对象的影射实现了在多个实例之间共享同一页面的效果。
struct buf
由 vnode 支持的 VM 对象,如文件支持的对象,通常需要维护独立于 VM 系统的干净/脏信息。例如,当 VM 系统决定将物理页面同步到其备份存储时,VM 系统需要在页面实际写入备份存储之前将其标记为干净。此外,文件系统还需要能够将文件或文件元数据的部分映射到 KVM 中,以便对其进行操作。
用于管理这些操作的实体被称为文件系统缓冲区,struct buf
或 bp
。当文件系统需要操作 VM 对象的一部分时,它通常会将对象的一部分映射到一个 struct buf
中,然后将 struct buf
中的页面映射到 KVM。同样,磁盘 I/O 通常通过将对象的部分映射到缓冲区结构中,然后对缓冲区结构发起 I/O 操作来实现。在 I/O 的整个过程中,底层的 vm_page_t
通常会被忙碌标记。文件系统缓冲区也有其自己的忙碌概念,这对于文件系统驱动代码非常有用,因为它们更愿意操作文件系统缓冲区而不是硬 VM 页面。
FreeBSD 保留了一定量的 KVM 来存放 struct buf
的映射,但需要明确的是,这部分 KVM 仅用于存放映射,并不限制数据缓存的能力。物理数据缓存严格来说是 vm_page_t
的功能,而非文件系统缓冲区。然而,由于文件系统缓冲区用于占位 I/O 操作,它们本质上限制了可以并发执行的 I/O 数量。不过,由于通常有几千个文件系统缓冲区可用,这通常不是问题。
vm_map_t, vm_entry_t
FreeBSD 将物理页表拓扑与 VM 系统分开。所有硬件的每进程页表可以动态重建,通常被视为临时的。管理 KVM 的特殊页表通常会被永久预分配。这些页表不是临时的。
FreeBSD 通过 vm_map_t
和 vm_entry_t
结构将 vm_object
的部分与虚拟内存中的地址范围关联。页表是通过 vm_map_t
/vm_entry_t
/vm_object_t
层级直接合成的。回想一下,我提到过物理页面只直接与 vm_object
关联;这并不完全准确。vm_page_t
还会链接到它们实际关联的页表中。一个 vm_page_t
可以被链接到多个 pmaps 中,这些页表即是所谓的“页面映射”。不过,层级关联依然有效,因此同一对象中的所有对同一页面的引用都会引用相同的 vm_page_t
,从而实现统一的缓存。
FreeBSD 使用 KVM 来存储各种内核结构。KVM 中存储的单一最大实体是文件系统缓冲区缓存。即,与 struct buf
实体相关的映射。
与 Linux 不同,FreeBSD 不会将所有物理内存映射到 KVM 中。这意味着 FreeBSD 在 32 位平台上可以处理最大 4G 的内存配置。实际上,如果 MMU 足够支持,FreeBSD 理论上可以在 32 位平台上处理最多 8TB 的内存配置。然而,由于大多数 32 位平台只能映射 4GB 的内存,因此这个问题在实际应用中是无关紧要的。
KVM 是通过多种机制来管理的。管理 KVM 的主要机制是 zone 分配器。zone 分配器将一块 KVM 内存分割成固定大小的内存块,用于分配特定类型的结构。你可以使用 vmstat -m
命令查看当前按 zone 分类的 KVM 使用情况。
FreeBSD 内核已做出集中努力,动态调整自身配置。通常情况下,你无需修改除 maxusers
和 NMBCLUSTERS
内核配置选项以外的任何内容。也就是说,内核编译选项通常在 /usr/src/sys/i386/conf/CONFIG_FILE 文件中指定。所有可用的内核配置选项的描述可以在 /usr/src/sys/i386/conf/LINT 中找到。
在大型系统配置中,你可能希望增加 maxusers
。其值通常范围从 10 到 128。请注意,将 maxusers
设置得过高可能会导致系统溢出可用的 KVM,进而导致不可预测的操作。最好将 maxusers
设置为合理的数值,并增加其他选项(如 NMBCLUSTERS
)来增加特定的资源。
如果你的系统将频繁使用网络,你可能希望增加 NMBCLUSTERS
。典型的值范围从 1024 到 4096。
NBUF
参数传统上用于调整系统。此参数决定了系统可用于映射文件系统缓冲区进行 I/O 的 KVA 数量。请注意,此参数与统一缓冲区缓存无关!该参数在 3.0-CURRENT 及更高版本的内核中是动态调整的,通常不应手动调整。我们建议你不要尝试指定 NBUF
参数。过小的值可能导致极其低效的文件系统操作,而过大的值可能会导致页面队列饥饿,因而使得太多页面被固定。
默认情况下,FreeBSD 内核并未进行优化。你可以在内核配置文件中使用 makeoptions
指令设置调试和优化标志。请注意,除非你能够处理生成的大型内核(通常超过 7 MB),否则不应使用 -g
。
Sysctl 提供了一种在运行时调整内核参数的方法。通常,你无需修改任何 sysctl 变量,尤其是与 VM 相关的变量。
运行时的 VM 和系统调整相对简单。首先,尽可能在你的 UFS/FFS 文件系统上使用 Soft Updates。/usr/src/sys/ufs/ffs/README.softupdates 包含了配置它的说明(和限制)。
其次,配置足够的交换空间。你应该在每个物理磁盘上配置一个交换分区,最多四个,即使是你的“工作”磁盘。交换空间的大小应至少是主内存的两倍,如果内存较小,可能需要更多的交换空间。你还应该根据你计划在机器上配置的最大内存量来调整交换分区的大小,以便以后无需重新分区。如果你希望能够容纳崩溃转储,你的第一个交换分区必须至少与主内存大小相同,并且 /var/crash 必须有足够的空闲空间来存储转储。
基于 NFS 的交换在 4.X 或更高版本的系统上完全可以接受,但你必须意识到,NFS 服务器将承受大部分的分页负载。
本章由 FreeBSD 下一代 SMP 项目维护。
互斥锁(mutex)仅仅是用来保证互斥性的锁。具体来说,互斥锁一次只能由一个实体拥有。如果其他实体希望获取已经被拥有的互斥锁,它必须等待直到该互斥锁被释放。在 FreeBSD 内核中,互斥锁由进程拥有。
每个互斥锁有几个重要属性:
变量名:内核源代码中 struct mtx
变量的名称。
逻辑名称:通过 mtx_init
分配给它的互斥锁名称。此名称会显示在 KTR 跟踪消息和 witness 错误及警告中,用于区分 witness 代码中的互斥锁。
MTX_DEF
:一个睡眠互斥锁
MTX_SPIN
:一个自旋互斥锁
MTX_RECURSE
:此互斥锁允许递归
保护对象:此条目所保护的数据结构或数据结构成员的列表。对于数据结构成员,名称格式为 结构体名.成员名
。
依赖函数:只有在持有此互斥锁时,才能调用的函数。
mtx
.mtx_lock
本文件假设读者对 FreeBSD 中的设备驱动程序和 SCSI 协议有一定的理解。本文中的大部分信息来源于以下驱动程序:
ncr (/sys/pci/ncr.c) 作者:Wolfgang Stanglmeier 和 Stefan Esser
sym (/sys/dev/sym/sym_hipd.c) 作者:Gerard Roudier
aic7xxx (/sys/dev/aic7xxx/aic7xxx.c) 作者:Justin T. Gibbs
以及来自 CAM 代码本身(由 Justin T. Gibbs 编写,见 /sys/cam/)。当某个解决方案看起来最为合理,并且基本上是直接从 Justin T. Gibbs 的代码中提取时,我将其标记为“推荐”。
本文使用伪代码的示例进行说明。尽管有时这些示例包含许多细节并看起来像实际代码,但它们仍然是伪代码。它们的编写目的是以易于理解的方式展示概念。对于实际的驱动程序,其他方法可能在模块化和效率上更好。它还抽象了硬件细节,以及可能会掩盖演示概念或应该在其他章节中描述的内容。这些细节通常以描述性名称的函数调用、注释或伪语句的形式呈现。幸运的是,现实中的完整示例包含了所有细节,可以在实际的驱动程序中找到。
CAM 代表通用访问方法(Common Access Method)。它是一种以类似 SCSI 的方式访问 I/O 总线的通用方法。这使得通用设备驱动程序与控制 I/O 总线的驱动程序可以分离:例如,磁盘驱动程序可以控制 SCSI、IDE 以及任何其他总线上的磁盘,因此磁盘驱动程序部分不必为每个新的 I/O 总线重写(或复制并修改)。因此,最重要的两个活跃实体是:
外设模块 - 外设设备的驱动程序(如磁盘、磁带、CD-ROM 等)
SCSI 接口模块(SIM) - 连接到 I/O 总线(如 SCSI 或 IDE)的主机总线适配器驱动程序
外设驱动程序从操作系统接收请求,将它们转换为一系列 SCSI 命令,并将这些命令传递给 SCSI 接口模块。SCSI 接口模块负责将这些命令传递给实际的硬件(如果实际硬件不是 SCSI,而是例如 IDE,那么它还会将 SCSI 命令转换为硬件的本地命令)。
由于我们在这里关注的是编写 SCSI 适配器驱动程序,因此从这一点开始,我们将从 SIM 的角度来看待所有内容。
一个典型的 SIM 驱动程序需要包含以下与 CAM 相关的头文件:
每个 SIM 驱动程序首先需要将自己注册到 CAM 子系统中。这是在驱动程序的 xxx_attach()
函数中完成的(此处以及之后的所有 xxx_
用来表示唯一的驱动程序名称前缀)。xxx_attach()
函数本身是由系统总线自动配置代码调用的,我们在此不做详细描述。
这一过程分为多个步骤:首先需要为与此 SIM 相关的请求队列分配内存:
这里的 SIZE
是要分配的队列大小,即队列中可以容纳的最大请求数。它是 SIM 驱动程序在一个 SCSI 卡上可以并行处理的请求数。通常可以通过以下方式计算:
接下来我们创建 SIM 的描述符:
注意,如果无法创建 SIM 描述符,我们也会释放 devq
,因为如果无法创建 SIM 描述符,我们就无法继续使用它,同时也希望释放内存。
如果一张 SCSI 卡上有多个 SCSI 总线,那么每条总线需要自己的 cam_sim
结构体。
一个有趣的问题是,如果一张 SCSI 卡有多个 SCSI 总线,是否需要每个卡一个 devq
结构,还是每个 SCSI 总线一个?根据 CAM 代码中的注释,答案是:两者都可以,根据驱动程序的作者的选择。
以下是相关的参数说明:
action_func:指向驱动程序的 xxx_action
函数的指针。
poll_func:指向驱动程序的 xxx_poll
函数的指针。
driver_name:实际驱动程序的名称,例如 "ncr" 或 "wds"。
softc:指向驱动程序的内部描述符的指针,代表该 SCSI 卡。该指针将用于驱动程序在未来访问私有数据。
unit:控制器单元号,例如对于 "mps0" 控制器,该号为 0。
mtx:与该 SIM 相关的锁。对于不涉及锁定的 SIM,可以传入 Giant
。对于涉及锁定的 SIM,传入用于保护该 SIM 数据结构的锁。在调用 xxx_action
和 xxx_poll
时会持有该锁。
max_dev_transactions:每个 SCSI 目标在非标记模式下允许的最大并发事务数。此值通常为 1,只有少数非 SCSI 卡可能有不同的设置。支持预处理一个事务而另一个事务正在执行的驱动程序可能将其设置为 2,但这通常不值得为此增加复杂性。
max_tagged_dev_transactions:同样的参数,但在标记模式下。标记是 SCSI 用来发起多事务的方式:每个事务都分配一个唯一的标记,并将事务发送到设备。当设备完成某个事务时,会将结果与标记一起发送回,以便 SCSI 适配器(和驱动程序)能够识别哪个事务已完成。这个参数也被称为最大标记深度,取决于 SCSI 适配器的能力。
最终,我们注册与 SCSI 适配器相关联的 SCSI 总线:
如果每个 SCSI 总线有一个 devq
结构(即我们认为一张卡上有多个总线就像有多个每个只有一个总线的卡),那么总线编号将始终为 0;否则,每个 SCSI 卡上的每个总线应当分配一个不同的编号。每个总线需要独立的 cam_sim
结构。
完成这些步骤后,我们的控制器就完全连接到了 CAM 系统。devq
的值可以被丢弃,因为从现在开始,sim
会作为所有后续 CAM 调用的参数传递,而 devq
可以从中派生出来。
CAM 提供了框架来处理这样的异步事件。有些事件来自低层(SIM 驱动程序),有些事件来自外设驱动程序,还有一些事件来自 CAM 子系统本身。任何驱动程序都可以注册回调函数来处理某些类型的异步事件,从而在这些事件发生时得到通知。
一个典型的事件例子是设备重置。每个事务和事件通过“路径”来标识其应用的设备。特定目标的事件通常发生在与该设备的事务期间。因此,可以重用来自事务的路径来报告该事件(这是安全的,因为事件路径在事件报告过程中被复制,但不会被释放或传递到其他地方)。同样,在任何时候,包括中断例程中,动态分配路径也是安全的,尽管这会带来一定的开销,而这个方法的潜在问题是可能在此时没有足够的内存。对于总线重置事件,我们需要定义一个包含总线上所有设备的通配符路径。因此,我们可以提前创建路径,以便为未来的总线重置事件使用,从而避免内存短缺的问题:
如你所见,路径包括:
外设驱动程序的 ID(这里是 NULL,因为我们没有外设)
SIM 驱动程序的 ID(cam_sim_path(sim)
)
设备的 SCSI 目标号(CAM_TARGET_WILDCARD
表示“所有设备”)
子设备的 SCSI LUN 号(CAM_LUN_WILDCARD
表示“所有 LUN”)
如果驱动程序无法分配此路径,则无法正常工作,因此在这种情况下,我们会拆卸该 SCSI 总线。
然后,我们将路径指针保存在 softc
结构中以供未来使用。之后,我们保存 sim
的值(或者在 xxx_probe()
退出时也可以丢弃它,如果我们愿意的话)。
这就是最简初始化的全部内容。但为了做好这些工作,还有一个问题需要解决。
对于 SIM 驱动程序来说,有一个特别有趣的事件:当目标设备被认为丢失时。此时,重置与该设备的 SCSI 协商可能是一个好主意。因此,我们需要为此事件在 CAM 中注册一个回调。请求通过在 CAM 控制块上请求 CAM 操作来传递给 CAM,该控制块处理此类型的请求:
根据 CAM 子系统的请求执行某些操作。sim
描述了请求的 SIM,ccb
是请求本身。CCB 代表“CAM 控制块”(CAM Control Block)。它是一个联合体,包含多种具体实例,每个实例描述某种类型的事务的参数。所有这些实例共享一个 CCB 头部,其中存储了公共参数的部分。
CAM 支持工作在发起者(“正常”)模式和目标(模拟 SCSI 设备)模式下的 SCSI 控制器。这里我们只讨论与发起者模式相关的部分。
有一些函数和宏(换句话说,是方法)被定义用来访问 sim
结构中的公共数据:
cam_sim_path(sim)
- 路径 ID(如上所述)
cam_sim_name(sim)
- SIM 的名称
cam_sim_softc(sim)
- 指向 softc(驱动程序私有数据)结构的指针
cam_sim_unit(sim)
- 单元号
cam_sim_bus(sim)
- 总线 ID
为了识别设备,xxx_action()
可以使用这些函数获取单元号和指向其 softc 结构的指针。
请求的类型存储在 ccb→ccb_h.func_code
中。因此,通常 xxx_action()
包含一个大的 switch
语句:
如 default
案例所示(如果收到未知命令),命令的返回代码会被设置到 ccb→ccb_h.status
中,处理完的 CCB 会通过调用 xpt_done(ccb)
返回给 CAM。
xpt_done()
不一定要在 xxx_action()
中调用:例如,一个 I/O 请求可能会被放入 SIM 驱动程序和/或其 SCSI 控制器的队列中。然后,当设备通过中断信号表明请求处理完成时,xpt_done()
可能会在中断处理程序中被调用。
实际上,CCB 状态不仅仅在返回代码中分配,CCB 始终具有一些状态。在 CCB 被传递给 xxx_action()
例程之前,它的状态会被设置为 CCB_REQ_INPROG
,表示它正在处理中。/sys/cam/cam.h 中定义了大量状态值,可以详细表示请求的状态。更有趣的是,状态实际上是“按位或”了一个枚举状态值(低 6 位)和可能的附加标志位(高位)。枚举值会在后面详细讨论。它们的汇总可以在错误总结部分找到。可能的状态标志包括:
CAM_DEV_QFRZN - 如果 SIM 驱动程序在处理 CCB 时遇到严重错误(例如,设备没有响应选择或违反了 SCSI 协议),它应该通过调用 xpt_freeze_simq()
冻结请求队列,将其他尚未处理的 CCB 返回 CAM 队列,然后为该问题 CCB 设置此标志,并调用 xpt_done()
。该标志会导致 CAM 子系统在处理完错误后解冻队列。
CAM_AUTOSNS_VALID - 如果设备返回了错误状态且 CAM_DIS_AUTOSENSE
标志未设置,SIM 驱动程序必须自动执行 REQUEST SENSE
命令,以从设备中提取感知(扩展错误信息)数据。如果此操作成功,感知数据应保存到 CCB 中,并设置此标志。
CAM_RELEASE_SIMQ - 类似于 CAM_DEV_QFRZN,但用于在 SCSI 控制器本身出现问题(或资源短缺)时。此时,所有未来对该控制器的请求应通过 xpt_freeze_simq()
停止。控制器队列将在 SIM 驱动程序解决短缺并通过返回带有该标志的 CCB 通知 CAM 后恢复。
CAM_SIM_QUEUED - 当 SIM 将 CCB 放入其请求队列时,应设置此标志(当该 CCB 在被返回 CAM 之前出队时移除此标志)。目前,CAM 代码中并未使用此标志,因此其目的是纯粹的诊断用途。
CAM_QOS_VALID - QOS 数据现在有效。
xxx_action()
不允许睡眠,因此所有资源访问的同步必须通过冻结 SIM 或设备队列来完成。除了上述标志,CAM 子系统还提供了 xpt_release_simq()
和 xpt_release_devq()
函数,用于直接解冻队列,而无需传递 CCB 给 CAM。
CCB 头部包含以下字段:
path - 请求的路径 ID
target_id - 请求的目标设备 ID
target_lun - 目标设备的 LUN ID
timeout - 此命令的超时间隔(毫秒)
timeout_ch - SIM 驱动程序用来存储超时句柄的便利字段(CAM 子系统本身不会对其做出任何假设)
flags - 请求的各种标志位
spriv_ptr0, spriv_ptr1 - 供 SIM 驱动程序私用的字段(如链接到 SIM 队列或 SIM 私有控制块);实际上,这些字段是联合体:spriv_ptr0
和 spriv_ptr1
的类型是 void *
,spriv_field0
和 spriv_field1
的类型是 unsigned long
,sim_priv.entries[0].bytes
和 sim_priv.entries[1].bytes
是与联合体的其他版本大小一致的字节数组,sim_priv.bytes
是一个更大的字节数组。
推荐的使用 SIM 私有字段的方式是为它们定义一些有意义的名称,并在驱动程序中使用这些名称,例如:
最常见的发起者模式请求有:
联合体 ccb
中的实例 struct ccb_scsiio csio
用于传递参数。它们包括:
cdb_io - 指向 SCSI 命令缓冲区的指针,或者是缓冲区本身
cdb_len - SCSI 命令的长度
data_ptr - 指向数据缓冲区的指针(如果使用了 scatter/gather,情况会更复杂)
dxfer_len - 要传输的数据长度
sglist_cnt - scatter/gather 段的计数器
scsi_status - 返回 SCSI 状态的地方
sense_data - 如果命令返回错误,SCSI 感知信息的缓冲区(如果 CCB 标志 CAM_DIS_AUTOSENSE
没有设置,SIM 驱动程序应自动执行 REQUEST SENSE 命令)
sense_len - 该缓冲区的长度(如果其大小大于 sense_data
的大小,SIM 驱动程序必须默认为较小的值)
resid, sense_resid - 如果数据或 SCSI 感知的传输出现错误,这些是返回的残余(未传输的)数据计数器。它们似乎没有特别的意义,因此在计算困难时(例如,计算 SCSI 控制器 FIFO 缓冲区中的字节数)可以使用一个近似值。对于成功完成的传输,它们必须设置为零。
tag_action - 要使用的标签类型:
CAM_TAG_ACTION_NONE
- 不为此事务使用标签
MSG_SIMPLE_Q_TAG
, MSG_HEAD_OF_Q_TAG
, MSG_ORDERED_Q_TAG
- 与适当标签消息相等的值(参见 /sys/cam/scsi/scsi_message.h
);这仅给出了标签类型,SIM 驱动程序必须自己分配标签值
处理此请求的一般逻辑如下:
首先要做的是检查可能的竞态,确保命令在排队时没有被中止:
接着检查设备是否被我们的控制器支持:
然后分配我们需要的任何数据结构(例如,卡依赖的硬件控制块)来处理此请求。如果无法分配,则冻结 SIM 队列并记住我们有一个挂起的操作,将 CCB 返回并请求 CAM 重新排队它。稍后,当资源可用时,SIM 队列必须通过返回一个带有 CAM_SIMQ_RELEASE
位设置的 CCB 来解冻。否则,如果一切顺利,将 CCB 与硬件控制块(HCB)关联并标记为已排队。
从 CCB 提取目标数据到硬件控制块中。检查是否要求分配标签,如果是,则生成一个唯一标签并构建 SCSI 标签消息。SIM 驱动程序还负责与设备进行协商,以设置最大互相支持的总线宽度、同步速率和偏移量。
接下来设置 SCSI 命令。命令存储可能通过 CCB 中的各种方式指定,这些方式由 CCB 标志指定。命令缓冲区可以包含在 CCB 中或通过指针指向,在后一种情况下,指针可以是物理或虚拟的。由于硬件通常需要物理地址,因此我们总是将地址转换为物理地址,通常使用 busdma API。
如果请求的是物理地址,返回带有状态 CAM_REQ_INVALID
的 CCB 是可以接受的,当前的驱动程序就是这么做的。如果需要,也可以将物理地址转换回虚拟地址,但这样做相当麻烦,因此我们通常不这么做。
现在是时候设置数据了。同样,数据存储可以通过 CCB 中的各种方式指定,这些方式由 CCB 标志指定。首先获取数据传输的方向。最简单的情况是,如果没有数据要传输:
接下来检查数据是一个块还是 scatter-gather 列表,地址是物理的还是虚拟的。SCSI 控制器可能只能处理有限数量的块,且块的长度也有限。如果请求超过了这一限制,我们就返回一个错误。我们使用一个特殊的函数来返回 CCB,在一个地方处理 HCB 资源短缺。添加块的函数是驱动程序相关的,这里我们不提供详细的实现。有关地址转换问题的更多信息,请参考 SCSI 命令(CDB)处理部分。如果某些变体在特定卡上实现困难或不可能实现,返回 CAM_REQ_INVALID
状态是可以接受的。实际上,现在似乎在 CAM 代码中没有使用 scatter-gather 功能。但至少必须实现针对单个非散布虚拟缓冲区的情况,因为它在 CAM 中被广泛使用。
这段代码描述了如何根据 CCB 标志设置数据的传输模式,并处理各种情况,包括单一数据块、scatter-gather 列表以及虚拟/物理地址的处理。如果处理过程中出现错误,则返回相应的状态,并通过 free_hcb_and_ccb_done
函数释放资源。
如果此 CCB 禁用了断开连接,我们将此信息传递给 HCB:
如果控制器能够独立运行 REQUEST SENSE
命令,则应该将 CAM_DIS_AUTOSENSE
标志的值传递给它,以防 CAM 子系统不希望自动执行 REQUEST SENSE
。
最后,我们需要设置超时,将 HCB 传递给硬件,并返回,剩下的工作将在中断处理程序(或超时处理程序)中完成:
下面是返回 CCB 的可能实现:
此 CCB 中没有数据传输,只有头部,最有趣的参数是 target_id
。根据控制器硬件的不同,可能会像 XPT_SCSI_IO
请求一样构建一个硬件控制块(见 XPT_SCSI_IO
请求描述)并发送到控制器,或者 SCSI 控制器可能会立即编程以向设备发送此 RESET
消息,或者此请求可能根本不被支持(并返回状态 CAM_REQ_INVALID
)。此外,在请求完成时,必须终止该目标的所有断开连接事务(可能在中断例程中完成)。
另外,所有当前的协商都将丢失,因此它们可能也需要被清除。或者清除过程可以延迟,因为无论如何目标将在下一次事务中请求重新协商。
此 CCB 中没有传递任何参数,唯一有趣的参数是由 struct sim
指针指示的 SCSI 总线。
最简化的实现会忽略该总线所有设备的 SCSI 协商,并返回状态 CAM_REQ_CMP
。
正确的实现应该除了实际重置 SCSI 总线(可能还需要重置 SCSI 控制器)外,还标记所有正在处理的 CCB,无论是硬件队列中的还是断开连接的,都标记为完成并设置状态 CAM_SCSI_BUS_RESET
。示例如下:
在这个实现中,reset_scsibus_hardware
负责执行硬件层面的重置操作,xpt_done
则标记每个 CCB 为完成,并传递状态 CAM_SCSI_BUS_RESET
。
在实现 SCSI 总线重置时,将其作为函数实现可能是个好主意,因为如果事情出现问题,超时处理程序可以作为最后的手段重新使用该函数。
首先,参数被传输到 union ccb
中的实例 struct ccb_abort cab
。其中唯一的参数字段是:
abort_ccb
- 指向要中止的 CCB 的指针。
如果不支持中止,直接返回状态 CAM_UA_ABORT
。这也是最简单的方式,在任何情况下都返回 CAM_UA_ABORT
。
更复杂的方法是诚实地实现此请求。首先需要检查中止是否适用于 SCSI 事务:
然后,必须在队列中找到该 CCB。这可以通过遍历我们所有硬件控制块的列表来实现,以查找与此 CCB 关联的一个:
现在我们查看 HCB 当前的处理状态。它可能处于以下几种状态之一:
正在排队等待发送到 SCSI 总线,
当前正在传输,
断开连接并等待命令的结果,
或者已经由硬件完成,但尚未由软件标记为完成。
为了确保我们不会与硬件发生竞争条件,我们将 HCB 标记为正在中止,这样如果该 HCB 即将发送到 SCSI 总线,SCSI 控制器就会看到这个标志并跳过它:
接下来,取消当前的事务,确保中止请求的正确处理。如果 HCB 处于处理中状态,我们需要相应地通知硬件进行处理。
如果 CCB 当前正在传输中,我们希望通过某种硬件相关的方式向 SCSI 控制器发出中止当前传输的信号。SCSI 控制器会设置 SCSI ATTENTION 信号,并在目标响应时发送 ABORT 消息。同时,我们会重置超时,以确保目标不会一直处于休眠状态。如果命令在合理的时间内(例如 10 秒)没有被中止,超时例程会重置整个 SCSI 总线。由于命令会在合理的时间内被中止,我们可以立即返回中止请求,并将被中止的 CCB 标记为已中止(但不标记为完成)。
如果 CCB 在断开连接的列表中,则将其设置为中止请求,并将其重新排队到硬件队列的前端。重置超时并报告中止请求已完成。
这就是 ABORT 请求的全部内容,尽管还有一个问题。由于 ABORT 消息会清除 LUN 上所有正在进行的事务,我们需要将该 LUN 上的其他所有活动事务标记为已中止。这个操作应该在中断例程中进行,在事务被中止之后。
将 CCB 中止实现为一个函数是一个很好的主意,因为在 I/O 事务超时时可以重复使用此函数。唯一的区别是,超时的事务会返回状态 CAM_CMD_TIMEOUT,而不是 CAM_REQ_ABORTED。然后,XPT_ABORT 的处理就像这样:
参数通过联合体 ccb
的实例 struct ccb_trans_setting cts
传递:
valid - 一个位掩码,显示哪些设置应该更新:
CCB_TRANS_SYNC_RATE_VALID - 同步传输速率
CCB_TRANS_SYNC_OFFSET_VALID - 同步偏移
CCB_TRANS_BUS_WIDTH_VALID - 总线宽度
CCB_TRANS_DISC_VALID - 设置启用/禁用断开连接
CCB_TRANS_TQ_VALID - 设置启用/禁用标记排队
flags - 由两部分组成,二进制参数和子操作的标识。二进制参数包括:
CCB_TRANS_DISC_ENB - 启用断开连接
CCB_TRANS_TAG_ENB - 启用标记排队
子操作包括:
CCB_TRANS_CURRENT_SETTINGS - 更改当前的协商设置
CCB_TRANS_USER_SETTINGS - 记住所需的用户值,如 sync_period
, sync_offset
- 不言自明,如果 sync_offset == 0
则请求异步模式,bus_width
- 总线宽度,以位为单位(不是字节)
支持两组协商的参数,分别是用户设置和当前设置。用户设置在 SIM 驱动程序中并未真正使用,通常只是一个内存区域,上层可以在其中存储(并在后续调用时回忆)其对参数的看法。设置用户参数不会导致重新协商传输速率。但是,当 SCSI 控制器进行协商时,必须始终将值设置为不大于用户设置的值,因此它本质上是上限。
当前设置正如其名称所示,是实际生效的设置。更改它们意味着下次传输时必须重新协商这些参数。同样,这些“新的当前设置”不应强制应用于设备,它们只是协商的初始步骤。它们还必须受到 SCSI 控制器实际能力的限制:例如,如果 SCSI 控制器具有 8 位总线,而请求要求设置 16 位宽的传输,则在将其发送给设备之前,此参数必须静默地截断为 8 位传输。
需要注意的是,总线宽度和同步参数是按目标 (target) 设置的,而断开连接和标记启用参数是按 LUN 设置的。
推荐的实现方法是保留三组协商的(总线宽度和同步传输)参数:
user - 用户设置,如上所述
current - 当前实际生效的设置
goal - 通过设置“当前”参数请求的设置
代码示例:
然后,在处理下一个 I/O 请求时,它将检查是否需要重新协商,例如通过调用函数 target_negotiated(hcb)
。它可以这样实现:
在重新协商值之后,结果值必须分配给当前和目标参数,因此对于未来的 I/O 事务,当前和目标参数将相同,并且 target_negotiated()
将返回 TRUE。当卡片初始化时(在 xxx_attach()
中),当前协商值必须初始化为狭窄的异步模式,目标和当前值必须初始化为控制器支持的最大值。
此操作是 XPT_SET_TRAN_SETTINGS 的反向操作。根据 CCB_TRANS_CURRENT_SETTINGS 或 CCB_TRANS_USER_SETTINGS 中的标志,填充 CCB 实例 "struct ccb_trans_setting cts"(如果两个标志都设置,则现有的驱动程序返回当前设置)。设置有效字段中的所有位。
参数通过联合体 ccb
的实例 "struct ccb_calc_geometry ccg" 传递:
block_size - 输入,块(即扇区)大小,单位字节
volume_size - 输入,卷大小,单位字节
cylinders - 输出,逻辑柱面数
heads - 输出,逻辑磁头数
secs_per_track - 输出,每磁道逻辑扇区数
如果返回的几何结构与 SCSI 控制器 BIOS 认为的几何结构差异过大,且此 SCSI 控制器上的磁盘被用作可启动盘,系统可能无法启动。以下是来自 aic7xxx 驱动程序的典型计算示例:
这给出了大致的思路,确切的计算取决于特定 BIOS 的细节。如果 BIOS 无法设置 EEPROM 中的“扩展翻译”标志,则此标志通常应假定为 1。其他流行的几何结构包括:
一些系统 BIOS 和 SCSI BIOS 彼此之间有时会发生冲突,例如,Symbios 875/895 SCSI 与 Phoenix BIOS 的组合可能会在上电后给出 128/63 的几何结构,而在硬重启或软重启后给出 255/63 的几何结构。
这些属性通过联合体 ccb
中的实例 "struct ccb_pathinq cpi" 返回:
version_num - SIM 驱动程序的版本号,目前所有驱动程序使用 1
hba_inquiry - 控制器支持的特性位掩码:
PI_MDP_ABLE - 支持 MDP 消息(来自 SCSI3?)
PI_WIDE_32 - 支持 32 位宽 SCSI
PI_WIDE_16 - 支持 16 位宽 SCSI
PI_SDTR_ABLE - 可以协商同步传输速率
PI_LINKED_CDB - 支持链式命令
PI_TAG_ABLE - 支持标记命令
PI_SOFT_RST - 支持软复位替代方式(硬复位和软复位在 SCSI 总线上是互斥的)
target_sprt - 目标模式支持的标志,如果不支持则为 0
hba_misc - 控制器的其他特性:
PIM_SCANHILO - 总线从高 ID 扫描到低 ID
PIM_NOREMOVE - 扫描中不包括可拆卸设备
PIM_NOINITIATOR - 不支持启动器角色
PIM_NOBUSRESET - 用户已禁用初始总线复位
hba_eng_cnt - 神秘的 HBA 引擎计数,可能与压缩相关,目前始终设置为 0
vuhba_flags - 唯一厂商标志,目前未使用
max_target - 最大支持的目标 ID(对于 8 位总线为 7,对于 16 位总线为 15,对于光纤通道为 127)
max_lun - 最大支持的 LUN ID(对于较旧的 SCSI 控制器为 7,对于较新的为 63)
async_flags - 已安装的异步处理程序的位掩码,目前未使用
hpath_id - 子系统中的最高路径 ID,目前未使用
unit_number - 控制器单元编号,cam_sim_unit(sim)
bus_id - 总线编号,cam_sim_bus(sim)
initiator_id - 控制器本身的 SCSI ID
base_transfer_speed - 异步窄传输的名义传输速率,单位 KB/s,对于 SCSI 等于 3300
sim_vid - SIM 驱动程序的厂商 ID,最大长度为 SIM_IDLEN 的零终止字符串,包括终止符
hba_vid - SCSI 控制器的厂商 ID,最大长度为 HBA_IDLEN 的零终止字符串,包括终止符
dev_name - 设备驱动程序名称,最大长度为 DEV_IDLEN 的零终止字符串,包括终止符,等于 cam_sim_name(sim)
设置字符串字段的推荐方法是使用 strncpy
,例如:
设置完值后,将状态设置为 CAM_REQ_CMP
,并标记 CCB 为完成。
poll
函数用于在中断子系统不可用时模拟中断(例如,当系统崩溃并创建系统转储时)。CAM 子系统在调用 poll
例程之前会设置适当的中断级别。因此,它只需要做的是调用中断例程(或者相反,poll
例程可能执行实际操作,而中断例程只会调用 poll
例程)。那么为什么要使用一个单独的函数呢?这是因为调用约定不同。xxx_poll
例程获取 struct cam_sim
指针作为其参数,而 PCI 中断例程根据惯例获取 struct xxx_softc
的指针,而 ISA 中断例程仅获取设备单元编号。因此,poll
例程通常看起来像这样:
或者
如果已设置异步事件回调,则应定义回调函数。
callback_arg - 在注册回调时提供的值
code - 标识事件的类型
path - 标识事件适用的设备
arg - 与事件相关的参数
单个事件类型 AC_LOST_DEVICE
的实现如下所示:
中断例程的确切类型取决于连接到 SCSI 控制器的外设总线类型(PCI、ISA 等)。
SIM 驱动程序的中断例程在中断级别 splcam
上运行。因此,驱动程序中应使用 splcam()
来同步中断例程与驱动程序其他部分的活动(对于支持多处理器的驱动程序,情况会更复杂,但在此我们忽略这种情况)。本文档中的伪代码忽略了同步问题。实际代码必须不忽略这些问题。一个简单的做法是在进入其他例程时设置 splcam()
,并在返回时重置它,从而通过一个大的临界区来保护它们。为了确保中断级别始终恢复,可以定义一个包装函数,例如:
这种方法简单且健壮,但它的问题是中断可能会被阻塞较长时间,这会对系统性能产生负面影响。另一方面,spl()
家族的函数具有相当高的开销,因此大量的小临界区也可能不太合适。
中断例程处理的条件和细节在很大程度上取决于硬件。我们考虑“典型”条件的集合。
首先,我们检查总线上是否遇到了 SCSI 复位(可能是由同一 SCSI 总线上的另一个 SCSI 控制器引起的)。如果是这样,我们会丢弃所有排队的和断开的请求,报告事件,并重新初始化我们的 SCSI 控制器。重要的是,在此初始化过程中,控制器不能发出另一个复位,否则同一 SCSI 总线上的两个控制器可能会相互“反复”复位。致命的控制器错误/挂起的情况可以在同一位置处理,但它可能还需要向 SCSI 总线发送 RESET 信号,以重置与 SCSI 设备连接的状态。
如果中断不是由控制器范围的条件引起的,那么可能是当前硬件控制块发生了问题。根据硬件的不同,可能还有其他与 HCB 无关的事件,这里我们不予考虑。然后我们分析发生了什么:
首先检查 HCB 是否已经完成,如果是,我们检查返回的 SCSI 状态。
然后查看这个状态是否与 REQUEST SENSE
命令相关,如果是,则以简单的方式处理它。
否则,命令本身已完成,请更关注细节。如果此 CCB 未禁用自动感知,并且命令因带有感知数据而失败,则运行 REQUEST SENSE 命令以接收该数据。
一个典型的情况是协商事件:从 SCSI 目标收到的协商消息(响应我们的协商尝试或由目标主动发起)或目标无法协商(拒绝我们的协商消息或不回应)。
然后我们像之前一样,用简单的方式处理在自动感知过程中可能发生的任何错误。否则,我们再次仔细查看细节。
接下来我们考虑的是意外断开连接。对于 ABORT 或 BUS DEVICE RESET 消息后,这是正常现象,而在其他情况下则为异常。
如果目标拒绝接受标签,我们会通知 CAM 并返回该 LUN 的所有命令:
然后我们检查其他一些条件,处理基本上限于设置 CCB 状态:
接着我们检查错误是否严重到需要冻结输入队列,直到其被处理,如果是这样的话,我们就这么做:
这结束了通用的中断处理,尽管特定的控制器可能需要一些附加处理。
在执行 I/O 请求时,可能会发生很多问题。错误的原因可以通过 CCB 状态报告详细信息。本文档中有许多使用示例。为了完整性,以下是典型错误条件的推荐响应摘要:
CAM_RESRC_UNAVAIL - 某些资源暂时不可用,SIM 驱动程序无法生成事件来指示其何时可用。此类资源的一个例子是某个控制器内部硬件资源,该控制器在该资源变得可用时不会生成中断。
CAM_UNCOR_PARITY - 发生了无法恢复的奇偶校验错误
CAM_DATA_RUN_ERR - 数据溢出或意外的数据阶段(与 CAM_DIR_MASK 中指定的方向相反),或宽传输的奇数传输长度
CAM_SEL_TIMEOUT - 选择超时发生(目标没有响应)
CAM_CMD_TIMEOUT - 命令超时发生(超时函数已运行)
CAM_SCSI_STATUS_ERROR - 设备返回错误
CAM_AUTOSENSE_FAIL - 设备返回错误且 REQUEST SENSE 命令失败
CAM_MSG_REJECT_REC - 收到 MESSAGE REJECT 消息
CAM_SCSI_BUS_RESET - 收到 SCSI 总线重置
CAM_REQ_CMP_ERR - 发生了“不可能的”SCSI 阶段或其他类似的异常,或者如果没有进一步细节可用,则作为通用错误
CAM_UNEXP_BUSFREE - 发生了意外断开连接
CAM_BDR_SENT - 向目标发送了 BUS DEVICE RESET 消息
CAM_UNREC_HBA_ERROR - 不可恢复的主机总线适配器错误
CAM_REQ_TOO_BIG - 请求对于此控制器来说过大
CAM_REQUEUE_REQ - 该请求应重新排队以保持事务顺序。这通常发生在 SIM 识别到一个应该冻结队列的错误时,并且必须将目标的其他排队请求在 sim 层返回到 XPT 队列中。典型的此类错误情况包括选择超时、命令超时等。在这种情况下,出现问题的命令返回指示错误的状态,而那些尚未发送到总线的其他命令则被重新排队。
CAM_LUN_INVALID - 请求中的 LUN ID 不被 SCSI 控制器支持
CAM_TID_INVALID - 请求中的目标 ID 不被 SCSI 控制器支持
当 HCB 的超时到期时,应中止该请求,就像执行 XPT_ABORT 请求一样。唯一的区别是,中止请求返回的状态应该是 CAM_CMD_TIMEOUT 而不是 CAM_REQ_ABORTED(这就是为什么更好地将中止操作实现为一个函数的原因)。但还有一个可能的问题:如果中止请求本身卡住了怎么办?在这种情况下,应该重置 SCSI 总线,就像执行 XPT_RESET_BUS 请求一样(这里也建议像之前一样将其实现为一个从两个地方调用的函数)。如果设备重置请求卡住了,我们也应该重置整个 SCSI 总线。因此,超时函数的实现如下所示:
当我们中止一个请求时,所有与该目标/LUN 断开的其他请求也会被中止。那么问题来了,我们应该用 CAM_REQ_ABORTED 还是 CAM_CMD_TIMEOUT 状态来返回它们?当前的驱动程序使用 CAM_CMD_TIMEOUT。这是合理的,因为如果一个请求超时了,那么可能发生了非常严重的问题,如果它们不被干扰,它们可能会自行超时。
本文档由 Safeport Network Services 和 Network Associates Laboratories(网络关联实验室)安全研究部的 Chris Costello 为 FreeBSD 项目开发,属于 DARPA/SPAWAR 合同 N66001-01-C-8035(“CBOSS”)的一部分,作为 DARPA CHATS 研究项目的一部分。
允许在源代码(SGML DocBook)和“已编译”形式(SGML、HTML、PDF、PostScript、RTF 等)中进行再分发和使用,无论是否修改,只要满足以下条件:
源代码(SGML DocBook)再分发必须保留上述版权声明、此条件列表以及以下免责声明,作为文件的前几行,并且不做修改。
已编译形式的再分发(转换为其他 DTD,转换为 PDF、PostScript、RTF 和其他格式)必须在文档和/或随附的其他材料中复制上述版权声明、此条件列表和以下免责声明。
重要
本文档由 NETWORKS ASSOCIATES TECHNOLOGY, INC 提供,“按原样”提供,且不作任何明示或暗示的担保,包括但不限于对适销性和特定用途适用性的默示担保。在任何情况下,NETWORKS ASSOCIATES TECHNOLOGY, INC 不对因使用本文档而导致的任何直接、间接、附带、特殊、惩罚性或间接损害负责(包括但不限于:替代商品或服务的采购;使用、数据或利润的丧失;或业务中断),无论是合同、严格责任或侵权(包括疏忽或其他)理论下,是否已被告知可能发生此类损害。
FreeBSD 包括对几种强制访问控制(MAC)策略的实验性支持,并提供了一个内核安全扩展框架——TrustedBSD MAC 框架。MAC 框架是一个可插拔的访问控制框架,允许新的安全策略轻松地链接到内核中,可以在启动时加载,也可以在运行时动态加载。该框架提供了多种功能,使得实现新的安全策略变得更容易,包括能够轻松地将安全标签(如机密性信息)附加到系统对象。
本章介绍了 MAC 策略框架,并提供了一个示例 MAC 策略模块的文档。
TrustedBSD MAC 框架提供了一种机制,允许在编译时或运行时扩展内核访问控制模型。新的系统策略可以作为内核模块实现并链接到内核;如果存在多个策略模块,它们的结果将会组合。MAC 框架为策略编写者提供了多种访问控制基础设施服务,包括对瞬时和持久的、与策略无关的对象安全标签的支持。目前,这项支持被认为是实验性的。
本章提供了适用于策略模块开发人员以及潜在的 MAC 启用环境消费者的信息,帮助他们了解 MAC 框架如何支持对内核的访问控制扩展。
强制访问控制(MAC)指的是一组由操作系统强制执行的访问控制策略。这些 MAC 策略可以与自由裁量访问控制(DAC)保护进行对比,后者允许非管理员用户(按其自由裁量)保护对象。在传统的 UNIX 系统中,DAC 保护包括文件权限和访问控制列表;而 MAC 保护包括防止用户间调试的进程控制和防火墙。操作系统设计师和安全研究人员已经提出了各种 MAC 策略,包括多级安全(MLS)机密性策略、Biba 完整性策略、基于角色的访问控制(RBAC)、域和类型强制(DTE)以及类型强制(TE)。每种模型的决策基于多种因素,包括用户身份、角色和安全许可,以及表示数据敏感性和完整性等概念的对象安全标签。
TrustedBSD MAC 框架能够支持实现所有这些策略的模块,以及一大类系统硬化策略,这些策略可以利用现有的安全属性,如用户和组 ID,以及文件的扩展属性和其他系统属性。此外,尽管名字中带有“强制访问控制”,MAC 框架也可以用来实现纯粹的自由裁量策略,因为策略模块在授权保护时具有相当大的灵活性。
TrustedBSD MAC 框架允许内核模块扩展操作系统安全策略,同时提供许多访问控制模块所需的基础设施功能。如果多个策略同时加载,MAC 框架将有效地(在某些定义下有用地)组合这些策略的结果。
MAC 框架包含多个内核元素:
框架管理接口
并发和同步原语
策略注册
可扩展的内核对象安全标签
策略入口点组成运算符
标签管理原语
内核服务调用的入口点 API
策略模块的入口点 API
入口点实现(策略生命周期、对象生命周期/标签管理、访问控制检查)
与策略无关的标签管理系统调用
mac_syscall()
多路复用系统调用
作为 MAC 策略模块实现的各种安全策略
TrustedBSD MAC 框架可以通过 sysctl、加载器调优选项和系统调用进行直接管理。
在大多数情况下,具有相同名称的 sysctl 和加载器调优选项会修改相同的参数,并控制与各种内核子系统相关的保护执行。此外,如果内核中编译了 MAC 调试支持,将维护多个计数器,用于跟踪标签分配。通常建议在生产环境中不要使用每个子系统的执行控制来控制策略行为,因为它们广泛影响所有活动策略的操作。相反,应优先使用每个策略的控制,因为它们为策略模块提供了更大的粒度和更大的操作一致性。
策略模块的加载和卸载是通过系统模块管理系统调用和其他系统接口执行的,包括启动加载器变量;策略模块将有机会影响加载和卸载事件,包括防止不希望的策略卸载。
由于活动策略集可能在运行时发生变化,并且入口点的调用是非原子的,因此需要同步以防止在入口点调用过程中加载或卸载策略,从而在此期间冻结活动策略集。这是通过框架忙碌计数来完成的:每当进入一个入口点时,忙碌计数会增加;每当退出时,忙碌计数会减少。在忙碌计数为正时,不允许修改策略列表,试图修改策略列表的线程将被挂起,直到列表不再忙碌。忙碌计数由互斥锁保护,并且使用条件变量唤醒等待策略列表修改的线程。该同步模型的一个副作用是,允许从策略模块内部递归进入 MAC 框架,尽管通常不会使用。
为了减少忙碌计数的开销,使用了各种优化,包括避免在列表为空或仅包含静态条目的情况下完全增减计数(静态条目是指在系统启动之前加载的政策,并且不能卸载)。还提供了一个编译时选项,禁止在运行时更改已加载的策略集,这消除了与支持动态加载和卸载策略相关的互斥锁锁定开销,因为不再需要同步。
由于 MAC 框架在某些入口点中不允许阻塞,因此不能使用普通的睡眠锁;因此,加载或卸载尝试可能会因等待框架变为空闲而阻塞相当长的时间。
由于感兴趣的内核对象通常可能被多个线程同时访问,并且允许多个线程同时进入 MAC 框架,因此 MAC 框架维护的安全属性存储必须小心同步。通常,使用现有的内核同步机制来保护内核对象上的 MAC 框架安全标签:例如,套接字上的 MAC 标签使用现有的套接字互斥锁进行保护。同样,并发访问的语义通常与容器对象的语义相同:对于凭证,标签内容采用写时复制语义,类似于凭证结构的其余部分。当使用对象引用调用 MAC 框架时,它会在对象上加上必要的锁。策略作者必须了解这些同步语义,因为它们有时会限制对标签的访问类型:例如,当通过入口点将对凭证的只读引用传递给策略时,只允许对附加到凭证的标签状态进行读取操作。
策略模块必须假设由于 FreeBSD 内核的并行性和抢占性,多个内核线程可能会同时进入一个或多个策略入口点。如果策略模块使用可变状态,则可能需要在策略内使用同步原语,以防止在该状态上产生不一致的视图,从而导致策略操作错误。策略通常可以利用现有的 FreeBSD 同步原语来实现这一目的,包括互斥锁、睡眠锁、条件变量和计数信号量。然而,策略应该小心使用这些原语,遵守现有的内核锁定顺序,并认识到某些入口点不允许阻塞,因此在这些入口点中使用的原语仅限于互斥锁和唤醒操作。
当策略模块调用其他内核子系统时,它们通常需要释放任何策略内的锁,以避免违反内核锁定顺序或导致锁递归。这将使策略锁保持为全局锁顺序中的叶子锁,有助于避免死锁。
MAC 框架维护两种活动策略列表:静态列表和动态列表。这些列表仅在锁定语义上有所不同:使用静态列表时不需要提高引用计数。当包含 MAC 框架策略的内核模块被加载时,策略模块将使用 SYSINIT
调用注册函数;当策略模块被卸载时,SYSINIT
也会调用注销函数。如果策略模块被加载多次,或者在注册过程中没有足够的资源(例如,策略可能需要标签,而可用的标签状态不足),或者其他策略先决条件没有满足(某些策略可能仅能在启动前加载),注册可能会失败。同样,如果策略被标记为不可卸载,注销也可能失败。
内核服务通过两种方式与 MAC 框架交互:它们调用一系列 API 通知框架相关事件,并提供安全相关对象中的与策略无关的标签结构指针。标签指针由 MAC 框架通过标签管理入口点维护,允许框架通过对维护对象的内核子系统进行相对不具侵入性的修改,为策略模块提供标签服务。例如,标签指针已被添加到进程、进程凭证、套接字、管道、vnode、Mbuf、网络接口、IP 重组队列以及其他各种安全相关结构中。当内核服务执行重要的安全决策时,它们也会调用 MAC 框架,允许策略模块基于自己的标准(可能包括存储在安全标签中的数据)来增强这些决策。这些安全关键的决策大多是显式的访问控制检查;然而,某些决策影响更一般的决策函数,例如套接字的包匹配和程序执行时的标签过渡。
当多个策略模块同时加载到内核中时,框架将使用组合运算符组合策略模块的结果。该运算符当前是硬编码的,要求所有活动策略必须批准请求才能返回成功。由于策略可能返回多种错误条件(成功、访问被拒绝、对象不存在等),优先级运算符从策略返回的错误集选择最终的错误。通常,指示对象不存在的错误会优先于指示拒绝访问对象的错误。虽然无法保证结果组合将是有用或安全的,但我们发现许多有用的策略选择适用于此。例如,传统的受信系统通常使用两种或更多策略进行类似的组合。
由于许多有趣的访问控制扩展依赖于对象上的安全标签,MAC 框架提供了一组与策略无关的标签管理系统调用,涵盖了各种用户暴露的对象。常见的标签类型包括分区标识符、敏感度标签、完整性标签、隔离区、域、角色和类型。所谓与策略无关,意味着策略模块能够完全定义与对象相关的元数据的语义。策略模块参与用户应用程序提供的基于字符串的标签的内化和外化,并可以在需要时向应用程序暴露多个标签元素。
内存中的标签存储在 slab 分配的 struct label
中,该结构由一个固定长度的联合体数组组成,每个联合体包含一个 void *
指针和一个 long
。注册标签存储的策略将被分配一个“槽”标识符,该标识符可用于解引用标签存储。存储的语义完全由策略模块决定:模块提供与内核对象生命周期相关的多种入口点,包括初始化、关联/创建和销毁。通过这些接口,策略模块可以实现引用计数和其他存储模型。通常,策略模块不需要直接访问对象结构来检索标签,因为 MAC 框架通常会将对象的指针和对象标签的直接指针传递给入口点。唯一的例外是进程凭证,必须手动解引用才能访问凭证标签。这可能会在 MAC 框架的未来版本中发生变化。
初始化入口点通常包括一个睡眠处置标志,指示是否允许初始化时进入睡眠状态;如果不允许睡眠,可能会返回失败,取消标签(及其对象)的分配。例如,在网络栈中的中断处理期间,不能进行睡眠,或者当调用者持有互斥锁时,可能会发生这种情况。由于在飞行中的网络数据包(Mbuf)上维护标签的性能开销,策略必须明确声明需要分配 Mbuf 标签。使用标签的动态加载策略必须能够处理其初始化函数尚未在对象上调用的情况,因为在加载策略时,某些对象可能已经存在。MAC 框架保证未初始化的标签槽将保持 0 或 NULL 值,策略可以使用该值来检测未初始化的标签。然而,由于 Mbuf 标签的分配是条件性的,如果 Mbuf 标签尚未分配,策略也必须能够处理 NULL 标签指针。
在文件系统标签的情况下,提供了对安全标签在扩展属性中的持久化存储的特殊支持。在可用的情况下,使用扩展属性事务来进行安全标签的复合更新,以便在 vnodes 上一致地更新安全标签——目前此支持仅在 UFS2 文件系统中存在。策略作者可以选择使用一个或多个扩展属性来实现多标签文件系统对象标签。出于效率考虑,vnode 标签(v_label
)是任何磁盘标签的缓存;策略可以在 vnode 实例化时将值加载到缓存中,并根据需要更新缓存。因此,每次访问控制检查时不需要直接访问扩展属性。
注意
当前,如果一个标记策略允许动态卸载,则其状态槽不能被回收,这对标记策略的卸载-重新加载操作数量设置了严格(且相对较低)的上限。
MAC 框架实现了多个系统调用:这些调用中的大多数支持暴露给用户应用程序的与策略无关的标签检索和操作 API。
标签管理调用接受一个标签描述结构体 struct mac
,该结构体包含一系列 MAC 标签元素。每个元素包含一个字符字符串名称和字符字符串值。每个策略都将有机会声明一个特定的元素名称,从而允许策略暴露多个独立的元素(如果需要的话)。策略模块通过入口点执行内核标签和用户提供的标签之间的内化和外化,从而支持各种语义。标签管理系统调用通常通过用户库函数进行包装,以执行内存分配和错误处理,从而简化需要管理标签的用户应用程序。
以下是 FreeBSD 内核中存在的 MAC 相关系统调用:
mac_get_proc()
可用于检索当前进程的标签。
mac_set_proc()
可用于请求更改当前进程的标签。
mac_get_fd()
可用于检索由文件描述符引用的对象(文件、套接字、管道等)的标签。
mac_get_file()
可用于检索由文件系统路径引用的对象的标签。
mac_set_fd()
可用于请求更改由文件描述符引用的对象(文件、套接字、管道等)的标签。
mac_set_file()
可用于请求更改由文件系统路径引用的对象的标签。
mac_syscall()
允许策略模块在不修改系统调用表的情况下创建新的系统调用;它接受目标策略名称、操作编号和供策略使用的不透明参数。
mac_get_pid()
可用于请求通过进程 ID 获取另一个进程的标签。
mac_get_link()
与 mac_get_file()
相同,只是如果路径的最后一项是符号链接,它不会跟随符号链接,因此可以用于检索符号链接的标签。
mac_set_link()
与 mac_set_file()
相同,只是如果路径的最后一项是符号链接,它不会跟随符号链接,因此可以用于操作符号链接的标签。
mac_execve()
与 execve()
系统调用相同,只是它还接受一个请求的标签,用于在开始执行新程序时设置进程的标签。这种执行时的标签更改被称为“过渡”。
mac_get_peer()
实际上通过套接字选项实现,用于检索远程对等端在套接字上的标签(如果可用)。
除了这些系统调用外,SIOCSIGMAC
和 SIOCSIFMAC
网络接口 ioctl 允许检索和设置网络接口上的标签。
安全策略要么直接链接到内核中,要么编译成可加载的内核模块,可以在启动时或通过运行时的模块加载系统调用动态加载。策略模块通过一组声明的入口点与系统交互,提供对系统事件流的访问,并允许策略影响访问控制决策。每个策略包含以下几个元素:
策略的可选配置参数。
策略逻辑和参数的集中实现。
策略生命周期事件的可选实现,例如初始化和销毁。
可选的支持,用于在选定的内核对象上初始化、维护和销毁标签。
可选的支持,用于用户进程检查和修改选定对象的标签。
实现策略感兴趣的选定访问控制入口点。
声明策略身份、模块入口点和策略属性。
模块可以使用 MAC_POLICY_SET()
宏声明,该宏为策略命名,提供指向 MAC 入口点向量的引用,提供决定策略框架如何处理策略的加载时标志,并可选择性地请求框架分配标签状态。
MAC 策略入口点向量,在本例中为 mac_policy_ops
,将模块中定义的函数与特定的入口点关联起来。可用入口点及其原型的完整列表可以在 MAC 入口点参考部分找到。在模块注册期间,.mpo_init 和 .mpo_destroy 入口点尤其重要。mpo_init
会在策略成功注册到模块框架后,但在其他任何入口点变为活跃之前调用。这样,策略就可以执行任何策略特定的分配和初始化,例如初始化数据或锁。mpo_destroy
会在卸载策略模块时调用,用于释放分配的内存并销毁锁。目前,这两个入口点是在持有 MAC 策略列表互斥锁的情况下调用的,以防止调用其他入口点:这将发生变化,但在此之前,策略应小心调用内核原语,以避免锁顺序或睡眠问题。
策略声明中的模块名称字段存在,以便为模块提供唯一标识,用于模块依赖关系。应选择一个适当的字符串。策略的完整字符串名称将在加载和卸载事件期间通过内核日志显示,并在提供用户空间进程的状态信息时导出。
策略声明的标志字段允许模块在加载时向框架提供有关其功能的信息。目前,定义了三个标志:
MPC_LOADTIME_FLAG_UNLOADOK
:此标志指示策略模块可以卸载。如果未提供此标志,则策略框架将拒绝卸载模块的请求。此标志可能用于分配了标签状态且无法在运行时释放该状态的模块。
MPC_LOADTIME_FLAG_NOTLATE
:此标志指示策略模块必须在启动过程中尽早加载和初始化。如果指定了此标志,则尝试在启动后注册模块将被拒绝。此标志可用于需要对所有系统对象进行普遍标记的策略,并且无法处理未由策略正确初始化的对象。
MPC_LOADTIME_FLAG_LABELMBUFS
:此标志指示策略模块需要对 Mbufs 进行标记,并且应始终为 Mbuf 标签分配内存。默认情况下,MAC 框架不会为 Mbuf 分配标签存储,除非至少一个已加载的策略设置了此标志。当策略不需要 Mbuf 标记时,这会显著提高网络性能。存在一个内核选项 MAC_ALWAYS_LABEL_MBUF
,该选项强制 MAC 框架分配 Mbuf 标签存储,无论此标志的设置如何,并且在某些环境中可能有用。
注意
使用
MPC_LOADTIME_FLAG_LABELMBUFS
而未设置MPC_LOADTIME_FLAG_NOTLATE
标志的策略必须能够正确处理传入入口点的NULL
Mbuf 标签指针。这是必要的,因为在启用 Mbuf 标记的策略加载后,未存储标签的在飞 Mbuf 可能会持续存在。如果策略在网络子系统启用之前加载(即策略不是在后期加载的),那么所有 Mbuf 都保证有标签存储。
框架提供了四类入口点供已注册策略使用:与策略注册和管理相关的入口点,表示内核对象的初始化、创建、销毁和其他生命周期事件的入口点,策略模块可能影响的访问控制决策相关的事件,以及与对象标签管理相关的调用。此外,提供了 mac_syscall()
入口点,以便策略可以扩展内核接口,而无需注册新的系统调用。
策略模块的编写者应注意内核锁策略,以及在不同入口点期间可用的对象锁。编写者应尽量避免通过在入口点内部获取非叶锁而导致死锁场景,并遵循对象访问和修改的锁定协议。特别地,编写者应注意,虽然访问对象及其标签所需的锁通常已经持有,但对于所有入口点,可能并不总是有足够的锁来修改对象或其标签。参数的锁定信息已在 MAC 框架入口点文档中进行记录。
策略入口点会传递对象标签的引用以及对象本身。这使得带标签的策略可以在不知道对象内部细节的情况下,仍然基于标签做出决策。例外情况是进程凭证,策略假定它作为内核中的一类安全对象是被理解的。
mpo_init
策略加载事件。持有策略列表互斥锁,因此不能执行睡眠操作,并且调用其他内核子系统时必须小心。如果策略初始化期间需要潜在的睡眠内存分配,则应通过单独的模块 SYSINIT()
来进行。
mpo_destroy
策略卸载事件。持有策略列表互斥锁,因此应谨慎处理。
mpo_syscall
该入口点提供了一个策略多路复用的系统调用,允许策略为用户进程提供额外的服务,而无需注册特定的系统调用。在注册时提供的策略名称用于从用户空间解复用调用,并且参数将被转发到此入口点。在实现新服务时,安全模块应确保根据需要调用 MAC 框架中的适当访问控制检查。例如,如果某个策略实现了扩展的信号功能,则应调用必要的信号访问控制检查以调用 MAC 框架和其他已注册策略。
注意
当前模块必须自行执行
copyin()
来处理系统调用数据。
mpo_thread_userret
此入口点允许策略模块在线程返回到用户空间时,通过系统调用返回、陷阱返回或其他方式执行与 MAC 相关的事件。这对于具有浮动进程标签的策略是必需的,因为在系统调用处理过程中,通常无法在堆栈中的任意点获取进程锁;进程标签可能表示传统的认证数据、进程历史信息或其他数据。为了使用此机制,计划更改进程凭证标签的数据可以存储在 p_label
中,受每策略自旋锁保护,然后设置每线程的 TDF_ASTPENDING
标志和每进程的 PS_MACPENDM
标志,以便调度调用 userret
入口点。从此入口点,策略可以创建替代凭证,而无需过多担心锁定上下文。策略编写者需要注意,调度 AST 和 AST 执行之间的事件排序可能是复杂的,并且在多线程应用程序中可能是交错的。
mpo_init_bpfdesc_label
初始化新创建的 bpfdesc(BPF 描述符)上的标签。允许睡眠。
mpo_init_cred_label
初始化新创建的用户凭证上的标签。允许睡眠。
mpo_init_devfsdirent_label
初始化新创建的 devfs 条目上的标签。允许睡眠。
mpo_init_ifnet_label
初始化新创建的网络接口上的标签。允许睡眠。
mpo_init_ipq_label
mpo_init_mbuf_label
mpo_init_mount_label
初始化新创建的挂载点上的标签。允许睡眠。
mpo_init_mount_fs_label
初始化新挂载的文件系统上的标签。允许睡眠。
mpo_init_pipe_label
初始化新创建的管道上的标签。允许睡眠。
mpo_init_socket_label
mpo_init_socket_peer_label
mpo_init_proc_label
初始化新创建的进程标签。允许睡眠。
mpo_init_vnode_label
初始化新创建的 vnode 上的标签。允许睡眠。
mpo_destroy_bpfdesc_label
销毁 BPF 描述符上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_cred_label
销毁凭证上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_devfsdirent_label
销毁 devfs 条目上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_ifnet_label
销毁移除的网络接口上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_ipq_label
销毁 IP 分片队列上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_mbuf_label
销毁 mbuf 头部上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_mount_label
销毁挂载点上的标签。在此入口点,策略模块应释放与 mntlabel
相关的任何内部存储,以便将其销毁。
mpo_destroy_mount_label
销毁挂载点上的标签。在此入口点,策略模块应释放与 mntlabel
和 fslabel
相关的任何内部存储,以便将其销毁。
mpo_destroy_socket_label
销毁套接字上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_socket_peer_label
销毁套接字上的对等标签。在此入口点,策略模块应释放与 peerlabel
相关的任何内部存储,以便将其销毁。
mpo_destroy_pipe_label
销毁管道上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_proc_label
销毁进程上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_destroy_vnode_label
销毁 vnode 上的标签。在此入口点,策略模块应释放与 label
相关的任何内部存储,以便将其销毁。
mpo_copy_mbuf_label
将 src
中的标签信息复制到 dest
。
mpo_copy_pipe_label
将 src
中的标签信息复制到 dest
。
mpo_copy_vnode_label
将 src
中的标签信息复制到 dest
。
mpo_externalize_cred_label
根据传递的标签结构生成外部化标签。外部化标签是标签内容的文本表示,可以供用户态应用程序使用,并由用户读取。目前,所有策略的 externalize
入口点都会被调用,因此实现应检查 element_name
的内容,在尝试填充 sb
之前。如果 element_name
与你的策略名称不匹配,直接返回 0。只有在外部化标签数据时发生错误时,才返回非零值。一旦策略填充了 element_data
,*claimed
应递增。
mpo_externalize_ifnet_label
根据传递的标签结构生成外部化标签。外部化标签是标签内容的文本表示,可以供用户态应用程序使用,并由用户读取。目前,所有策略的 externalize
入口点都会被调用,因此实现应检查 element_name
的内容,在尝试填充 sb
之前。如果 element_name
与你的策略名称不匹配,直接返回 0。只有在外部化标签数据时发生错误时,才返回非零值。一旦策略填充了 element_data
,*claimed
应递增。
mpo_externalize_pipe_label
根据传递的标签结构生成外部化标签。外部化标签是标签内容的文本表示,可以供用户态应用程序使用,并由用户读取。目前,所有策略的 externalize
入口点都会被调用,因此实现应检查 element_name
的内容,在尝试填充 sb
之前。如果 element_name
与你的策略名称不匹配,直接返回 0。只有在外部化标签数据时发生错误时,才返回非零值。一旦策略填充了 element_data
,*claimed
应递增。
mpo_externalize_socket_label
根据传递的标签结构生成外部化标签。外部化标签是标签内容的文本表示,可以供用户态应用程序使用,并由用户读取。目前,所有策略的 externalize
入口点都会被调用,因此实现应检查 element_name
的内容,在尝试填充 sb
之前。如果 element_name
与你的策略名称不匹配,直接返回 0。只有在外部化标签数据时发生错误时,才返回非零值。一旦策略填充了 element_data
,*claimed
应递增。
mpo_externalize_socket_peer_label
根据传递的标签结构生成外部化标签。外部化标签是标签内容的文本表示,可以供用户态应用程序使用,并由用户读取。目前,所有策略的 externalize
入口点都会被调用,因此实现应检查 element_name
的内容,在尝试填充 sb
之前。如果 element_name
与你的策略名称不匹配,直接返回 0。只有在外部化标签数据时发生错误时,才返回非零值。一旦策略填充了 element_data
,*claimed
应递增。
mpo_externalize_vnode_label
根据传递的标签结构生成外部化标签。外部化标签是标签内容的文本表示,可以供用户态应用程序使用,并由用户读取。目前,所有策略的 externalize
入口点都会被调用,因此实现应检查 element_name
的内容,在尝试填充 sb
之前。如果 element_name
与你的策略名称不匹配,直接返回 0。只有在外部化标签数据时发生错误时,才返回非零值。一旦策略填充了 element_data
,*claimed
应递增。
mpo_internalize_cred_label
根据外部化标签数据的文本格式生成内部标签结构。目前,所有策略的 internalize
入口点都会在请求内部化时被调用,因此实现应将 element_name
的内容与其策略名称进行比较,以确保应该内部化 element_data
中的数据。与 externalize
入口点一样,如果 element_name
与策略名称不匹配,则返回 0;如果数据能够成功内部化,则递增 *claimed
。
mpo_internalize_ifnet_label
根据外部化标签数据的文本格式生成内部标签结构。目前,所有策略的 internalize
入口点都会在请求内部化时被调用,因此实现应将 element_name
的内容与其策略名称进行比较,以确保应该内部化 element_data
中的数据。与 externalize
入口点一样,如果 element_name
与策略名称不匹配,则返回 0;如果数据能够成功内部化,则递增 *claimed
。
mpo_internalize_pipe_label
根据外部化标签数据的文本格式生成内部标签结构。目前,所有策略的 internalize
入口点都会在请求内部化时被调用,因此实现应将 element_name
的内容与其策略名称进行比较,以确保应该内部化 element_data
中的数据。与 externalize
入口点一样,如果 element_name
与策略名称不匹配,则返回 0;如果数据能够成功内部化,则递增 *claimed
。
mpo_internalize_socket_label
根据外部化标签数据的文本格式生成内部标签结构。目前,所有策略的 internalize
入口点都会在请求内部化时被调用,因此实现应将 element_name
的内容与其策略名称进行比较,以确保应该内部化 element_data
中的数据。与 externalize
入口点一样,如果 element_name
与策略名称不匹配,则返回 0;如果数据能够成功内部化,则递增 *claimed
。
mpo_internalize_vnode_label
根据外部化标签数据的文本格式生成内部标签结构。目前,所有策略的 internalize
入口点都会在请求内部化时被调用,因此实现应将 element_name
的内容与其策略名称进行比较,以确保应该内部化 element_data
中的数据。与 externalize
入口点一样,如果 element_name
与策略名称不匹配,则返回 0;如果数据能够成功内部化,则递增 *claimed
。
这一类入口点由 MAC 框架使用,允许策略维护内核对象的标签信息。对于每个对 MAC 策略感兴趣的被标记内核对象,可以为相关生命周期事件注册入口点。所有对象都实现了初始化、创建和销毁挂钩。一些对象还实现了重新标记,允许用户进程更改对象上的标签。某些对象还实现了特定于对象的事件,例如与 IP 重组相关的标签事件。一个典型的标记对象会有以下生命周期的入口点:
标签初始化允许策略在没有对象使用上下文的情况下分配内存并设置标签的初始值。分配给策略的标签槽将默认被清零,因此某些策略可能不需要执行初始化。
标签创建发生在内核结构与实际内核对象关联时。例如,mbuf 可能会被分配并保持未使用状态,直到需要它们。mbuf 分配会导致 mbuf 上的标签初始化,但 mbuf 创建发生在 mbuf 与数据报文关联时。通常,在创建事件中会提供上下文,包括创建的情况以及创建过程中其他相关对象的标签。例如,当一个 mbuf 从套接字创建时,套接字及其标签将与新创建的 mbuf 及其标签一起呈现给注册的策略。创建事件中不鼓励进行内存分配,因为它可能发生在内核性能敏感部分;此外,创建调用不允许失败,因此内存分配失败无法报告。
特定对象事件通常不属于标签事件的其他广泛类别,但通常会提供在额外上下文的基础上修改或更新对象标签的机会。例如,IP 分片重组队列上的标签可能会在 MAC_UPDATE_IPQ 入口点中更新,这是由于接受额外的 mbuf 到该队列中。
访问控制事件将在以下部分详细讨论。
标签销毁允许策略释放与标签相关的存储或状态,以便支持对象的内核数据结构可以被重用或释放。
除了与特定内核对象关联的标签之外,还有一种额外的标签类别:临时标签。这些标签用于存储由用户进程提交的更新信息。这些标签与其他标签类型一样进行初始化和销毁,但创建事件是 MAC_INTERNALIZE,接受用户标签并将其转换为内核表示。
6.7.3.1.1. mpo_associate_vnode_devfs
根据传入的 Devfs 目录项 de
及其标签,填充新创建的 Devfs vnode 的标签 (vlabel
)。
6.7.3.1.2. mpo_associate_vnode_extattr
尝试从文件系统扩展属性中检索 vp
的标签。成功时返回值为 0
。如果不支持扩展属性检索,则可以接受的备用方案是将 fslabel
复制到 vlabel
中。如果发生错误,则应返回适当的 errno
值。
6.7.3.1.3. mpo_associate_vnode_singlelabel
在非多标签文件系统上,调用此入口点根据文件系统标签 fslabel
设置 vp
的策略标签。
6.7.3.1.4. mpo_create_devfs_device
为传入的设备创建的 devfs_dirent
填充标签。此调用会在设备文件系统挂载、重新生成或新设备可用时触发。
6.7.3.1.5. mpo_create_devfs_directory
为传入的目录创建的 devfs_dirent
填充标签。此调用会在设备文件系统挂载、重新生成或新设备需要特定目录层次时触发。
6.7.3.1.6. mpo_create_devfs_symlink
为新创建的 Devfs 符号链接条目填充标签 (delabel
)。
6.7.3.1.7. mpo_create_vnode_extattr
将 vp
的标签写入适当的扩展属性。如果写入成功,填充 vlabel
并返回 0
。如果失败,返回适当的错误值。
6.7.3.1.8. mpo_create_mount
根据传入的主体凭证填充挂载点的标签。此调用会在新文件系统挂载时触发。
6.7.3.1.9. mpo_create_root_mount
根据传入的主体凭证填充根文件系统挂载点的标签。此调用会在根文件系统挂载后、mpo_create_mount
调用之后触发。
6.7.3.1.10. mpo_relabel_vnode
根据传入的更新 vnode 标签和主体凭证更新传入的 vnode 标签。
6.7.3.1.11. mpo_setlabel_vnode_extattr
将 intlabel
中的策略标签写入扩展属性。这是从 vop_stdcreatevnode_ea
调用时进行的。
6.7.3.1.12. mpo_update_devfsdirent
根据传入的 devfs vnode 标签更新 devfs_dirent
的标签。当 devfs vnode 成功重新标记后,会调用此函数以确保标签更改持久化,即使 vnode 被回收。同时,在 devfs 创建符号链接时,也会调用此函数,在 mac_vnode_create_from_vnode
调用之后初始化 vnode 标签。
6.7.3.2.1. mpo_create_mbuf_from_socket
根据传入的套接字标签设置新创建的 mbuf 头的标签。此调用在套接字生成新数据报或消息并存储到传入的 mbuf 时触发。
6.7.3.2.2. mpo_create_pipe
根据传入的主体凭证设置新创建的管道的标签。此调用在新管道创建时触发。
6.7.3.2.3. mpo_create_socket
根据传入的主体凭证为新创建的套接字设置标签。此调用在套接字创建时触发。
6.7.3.2.4. mpo_create_socket_from_socket
6.7.3.2.5. mpo_relabel_pipe
将新的标签 newlabel
应用到管道 pipe
上。
6.7.3.2.6. mpo_relabel_socket
根据传入的标签更新,更新套接字 so
的标签。
6.7.3.2.7. mpo_set_socket_peer_from_mbuf
根据传入的 mbuf 标签为流式套接字设置对端标签。此调用在流式套接字接收到第一个数据报时触发,Unix 域套接字除外。
6.7.3.2.8. mpo_set_socket_peer_from_socket
根据传入的远程套接字端点为流式 Unix 域套接字设置对端标签。此调用将在套接字对连接时触发,并将为两个端点进行调用。
6.7.3.3.1. mpo_create_bpfdesc
根据传入的主体凭证为新创建的 BPF 描述符设置标签。此调用将在进程打开 BPF 设备节点时触发。
6.7.3.3.2. mpo_create_ifnet
为新创建的网络接口设置标签。此调用可能在新物理接口可用时或在启动过程中由用户操作触发,当伪接口被实例化时也会触发。
6.7.3.3.3. mpo_create_ipq
根据第一个接收到的数据片段的 mbuf 标头设置新创建的 IP 重组队列的标签。
6.7.3.3.4. mpo_create_datagram_from_ipq
根据 IP 重组队列中生成的数据报从中提取标签,为其设置标签。
6.7.3.3.5. mpo_create_fragment
根据数据报的 mbuf 标头的标签为新创建的 IP 数据片段的 mbuf 标头设置标签。
6.7.3.3.6. mpo_create_mbuf_from_mbuf
根据现有数据报的 mbuf 标头为新创建的数据报的 mbuf 标头设置标签。此调用可能在多种情况下触发,包括当 mbuf 因对齐目的而重新分配时。
6.7.3.3.7. mpo_create_mbuf_linklayer
根据传入的网络接口为新创建的数据报设置 mbuf 标头标签。此调用可能在多种情况下触发,包括 ARP 或 IPv4 和 IPv6 堆栈中的 ND6 响应。
6.7.3.3.8. mpo_create_mbuf_from_bpfdesc
根据传入的 BPF 描述符为新创建的数据报设置 mbuf 标头标签。此调用在对与传入的 BPF 描述符关联的 BPF 设备执行写操作时触发。
6.7.3.3.9. mpo_create_mbuf_from_ifnet
根据传入的网络接口为新创建的数据报设置 mbuf 标头标签。
6.7.3.3.10. mpo_create_mbuf_multicast_encap
根据现有数据报生成的新数据报,在经过传入的多播封装接口处理时设置其 mbuf 标头标签。此调用发生在 mbuf 通过虚拟接口交付时。
6.7.3.3.11. mpo_create_mbuf_netlayer
根据现有接收到的数据报(oldmbuf
)生成的新数据报,设置其 mbuf 标头标签。此调用可能在多种情况下触发,包括响应 ICMP 请求数据报时。
6.7.3.3.12. mpo_fragment_match
确定包含 IP 数据报片段(fragment
)的 mbuf 标头是否与传入的 IP 片段重组队列(ipq
)的标签匹配。如果匹配,则返回(1);否则返回(0)。此调用发生在 IP 堆栈尝试为新接收的片段找到现有的片段重组队列时;如果失败,可能会为该片段实例化一个新的片段重组队列。策略可以使用此入口点,防止在策略不允许基于标签或其他信息重组匹配的 IP 片段时进行重组。
6.7.3.3.13. mpo_relabel_ifnet
根据传入的更新标签(newlabel
)和传入的主体凭证(cred
),更新网络接口 ifnet
的标签。
6.7.3.3.14. mpo_update_ipq
根据接收的 IP 片段 mbuf 标头(mbuf
),更新 IP 片段重组队列(ipq
)的标签。
6.7.3.4.1. mpo_create_cred
6.7.3.4.2. mpo_execve_transition
根据执行传入的 vnode(vp
)引起的标签过渡,从传入的现有主体凭证(old
)更新新创建的主体凭证(new
)的标签。此调用在进程执行传入的 vnode 时发生,并且如果某个策略从 mpo_execve_will_transition
入口点返回成功,才会触发。策略可以选择通过调用 mpo_create_cred
并传递这两个主体凭证来实现此调用,而无需实现过渡事件。如果策略实现了 mpo_create_cred
,则不应将此入口点留空,即使它们没有实现 mpo_execve_will_transition
。
6.7.3.4.3. mpo_execve_will_transition
确定策略是否希望因执行传入的 vnode(vp
)而进行标签过渡。若需要过渡,则返回 1;否则返回 0。即使策略返回 0,它在遇到 mpo_execve_transition
被意外调用时也应表现正确,因为该调用可能是由另一个策略请求过渡时触发的。
6.7.3.4.4. mpo_create_proc0
创建进程 0 的主体凭证,进程 0 是所有内核进程的父进程。
6.7.3.4.5. mpo_create_proc1
创建进程 1 的主体凭证,进程 1 是所有用户进程的父进程。
6.7.3.4.6. mpo_relabel_cred
根据传入的更新标签(newlabel
),更新主体凭证(cred
)的标签。
如果所有模块返回的错误值都不在优先级图表中,则将从该集中的任意选定值返回。通常,规则按以下顺序提供错误优先级:内核失败、无效参数、对象不存在、访问不允许、其他。
mpo_check_bpfdesc_receive
确定 MAC 框架是否应允许来自传入接口的数据报传递到传入的 BPF 描述符的缓冲区。若允许,则返回 0;否则返回 errno
值。建议的失败值:标签不匹配时返回 EACCES,缺少权限时返回 EPERM。
mpo_check_kenv_dump
mpo_check_kenv_get
确定主体是否应被允许检索指定内核环境变量的值。
mpo_check_kenv_set
确定主体是否应被允许设置指定的内核环境变量。
mpo_check_kenv_unset
确定主体是否应被允许取消设置指定的内核环境变量。
mpo_check_kld_load
确定主体是否应被允许加载指定的模块文件。
mpo_check_kld_stat
确定主体是否应被允许检索已加载的内核模块文件及其相关统计信息。
mpo_check_kld_unload
确定主体是否应被允许卸载内核模块。
mpo_check_pipe_ioctl
mpo_check_pipe_poll
确定主体是否应被允许对 pipe
进行轮询操作。
mpo_check_pipe_read
确定主体是否应被允许读取 pipe
。
mpo_check_pipe_relabel
确定主体是否应被允许重新标记 pipe
。
mpo_check_pipe_stat
确定主体是否应被允许检索与 pipe
相关的统计信息。
mpo_check_pipe_write
确定主体是否应被允许向 pipe
写入数据。
mpo_check_socket_bind
mpo_check_socket_connect
确定主体凭证 (cred
) 是否可以将传入的套接字 (socket
) 连接到传入的套接字地址 (sockaddr
)。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),EPERM(缺乏权限)。
mpo_check_socket_receive
确定主体是否应被允许从套接字 so
接收信息。
mpo_check_socket_send
确定主体是否应被允许通过套接字 so
发送信息。
mpo_check_cred_visible
确定主体凭证 u1
是否能够“查看”其他具有传入主体凭证 u2
的主体。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),EPERM(缺乏权限),或 ESRCH(隐藏可见性)。此调用可在多种情况下使用,包括通过 ps
使用的进程间状态 sysctl,以及在 procfs 查找时。
mpo_check_socket_visible
mpo_check_ifnet_relabel
确定主体凭证是否可以将传入的网络接口 ifnet
重新标记为传入的标签更新 newlabel
。
mpo_check_socket_relabel
确定主体凭证是否可以将传入的套接字 socket
重新标记为传入的标签更新 newlabel
。
mpo_check_cred_relabel
确定主体凭证是否可以将其自身重新标记为传入的标签更新 newlabel
。
mpo_check_vnode_relabel
确定主体凭证是否可以将传入的 vnode 重新标记为传入的标签更新。
mpo_check_mount_stat
mpo_check_proc_debug
mpo_check_vnode_access
确定主体凭证在对传入的 vnode 执行 access(2)
和相关调用时,如何根据传入的访问标志返回结果。此功能应使用与 mpo_check_vnode_open
相同的语义实现。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配)或 EPERM(缺乏权限)。
mpo_check_vnode_chdir
确定主体凭证是否可以将进程的工作目录更改为传入的 vnode。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_chroot
mpo_check_vnode_create
mpo_check_vnode_delete
mpo_check_vnode_deleteacl
确定主体凭证是否可以删除传入 vnode 的指定类型的 ACL。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_exec
确定主体凭证是否可以执行传入的 vnode。执行权限的确定与任何过渡事件的决策是分开进行的。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_getacl
确定主体凭证是否可以从传入的 vnode 获取指定类型的 ACL。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_getextattr
确定主体凭证是否可以从传入的 vnode 获取指定命名空间和名称的扩展属性。实现使用扩展属性的标记策略的政策可能需要特别处理这些扩展属性上的操作。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_link
确定主体是否应被允许通过 cnp
指定的名称创建一个指向 vnode vp
的链接。
mpo_check_vnode_mmap
确定主体是否应该被允许以 prot
指定的保护方式映射 vnode vp
。
mpo_check_vnode_mmap_downgrade
基于主体和对象标签降级 mmap 保护标志。
mpo_check_vnode_mprotect
确定主体是否应该被允许为从 vnode vp
映射的内存设置指定的内存保护。
mpo_check_vnode_poll
确定主体是否应被允许轮询 vnode vp
。
mpo_check_vnode_rename_from
确定主体是否应被允许将 vnode vp
重命名为其他名称。
mpo_check_vnode_rename_to
确定主体是否应被允许将 vnode vp
重命名到目录 dvp
或 cnp
表示的名称。如果没有现有文件要覆盖,则 vp
和 label
将为 NULL。
mpo_check_socket_listen
确定主体凭证是否可以在传入的 socket 上监听。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_lookup
确定主体凭证是否可以在传入的目录 vnode 中查找传入的名称。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_open
确定主体凭证是否可以以指定的访问模式对传入的 vnode 执行打开操作。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_readdir
确定主体凭证是否可以对传入的目录 vnode 执行 readdir
操作。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_readlink
确定主体凭证是否可以对传入的符号链接 vnode 执行 readlink
操作。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。此调用可能在多个场景中发生,包括用户进程显式调用 readlink
,或者在进程的名称查找过程中隐式调用 readlink
。
mpo_check_vnode_revoke
确定主体凭证是否可以撤销对传入 vnode 的访问权限。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_setacl
确定主体凭证是否可以在传入的 vnode 上设置传入类型的 ACL。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_setextattr
确定主体凭证是否可以在传入的 vnode 上设置传入名称和命名空间的扩展属性。实现安全标签并将其嵌入扩展属性的策略可能希望对这些属性提供额外的保护。此外,策略应避免根据 uio
中引用的数据做出决策,因为在此检查与实际操作之间可能存在竞争条件。如果正在执行删除操作,uio
可能为 NULL
。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_setflags
确定主体凭证是否可以在传入的 vnode 上设置传入的标志。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_setmode
确定主体凭证是否可以在传入的 vnode 上设置传入的文件模式。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_setowner
确定主体凭证是否可以将传入的 uid 和 gid 设置为传入 vnode 的文件用户 ID 和文件组 ID。如果 ID 设置为 (-1
),则表示请求不更新。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_vnode_setutimes
确定主体凭证是否可以在传入的 vnode 上设置传入的访问时间戳。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_proc_sched
确定主体凭证是否可以更改传入进程的调度参数。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),EPERM(缺乏权限),或 ESRCH(限制可见性)。
mpo_check_proc_signal
确定主体凭证是否可以向传入的进程发送传入的信号。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),EPERM(缺乏权限),或 ESRCH(限制可见性)。
mpo_check_vnode_stat
确定主体凭证是否可以对传入的 vnode 执行 stat
操作。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_ifnet_transmit
确定网络接口是否可以发送传入的 mbuf。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_socket_deliver
确定套接字是否可以接收传入的 mbuf 数据报头中的数据。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。
mpo_check_socket_visible
mpo_check_system_acct
确定主体是否被允许启用会计功能,基于其标签和会计日志文件的标签。
mpo_check_system_nfsd
mpo_check_system_reboot
确定主体是否被允许以指定的方式重启系统。
mpo_check_system_settime
确定用户是否被允许设置系统时钟。
mpo_check_system_swapon
确定主体是否被允许将 vp
添加为交换设备。
mpo_check_system_sysctl
当用户进程请求修改对象的标签时,会发生标签重标事件。该过程分为两个阶段:首先执行访问控制检查,以确定更新是否有效且被允许;然后通过单独的入口点执行更新。标签重标入口点通常接受对象、对象标签引用以及进程提交的更新标签。重标期间不建议进行内存分配,因为重标调用不允许失败(失败应在重标检查阶段之前报告)。
TrustedBSD MAC 框架包括许多与策略无关的元素,其中包括用于抽象管理标签的 MAC 库接口、对系统凭证管理和登录库的修改,以支持将 MAC 标签分配给用户,以及一组用于监视和修改进程、文件和网络接口标签的工具。有关用户架构的更多细节将在近期添加到本节中。
注意
当前,MAC 库不支持直接操作标签元素,除了通过转换为文本字符串、编辑字符串并再转换为内部标签的方式。如果有必要,后续可能会添加这样的接口。
注意
TrustedBSD MAC 框架允许内核模块以高度集成的方式增强系统安全策略。它们可以基于现有的对象属性,或基于与 MAC 框架协作维护的标签数据来实现这一点。该框架足够灵活,能够实现多种策略类型,包括信息流安全策略(如 MLS 和 Biba),以及基于现有 BSD 凭证或文件保护的策略。策略作者在实现新的安全服务时,可能需要参考本文档以及现有的安全模块。
本章简要介绍了如何为 FreeBSD 编写设备驱动程序。在这个上下文中,“设备”通常指的是与硬件相关的系统组件,例如磁盘、打印机或带有键盘的显示器。设备驱动程序是操作系统中控制特定设备的软件组件。还有所谓的伪设备,设备驱动程序通过软件模拟设备的行为,而没有任何特定的底层硬件。设备驱动程序可以静态地编译进系统,或者通过动态内核链接器设施 kld
按需加载。
在类 UNIX® 操作系统中,大多数设备通过设备节点进行访问,这些节点有时也称为特殊文件。这些文件通常位于文件系统层次结构中的 /dev 目录下。
设备驱动程序大致可以分为两类:字符设备驱动程序和网络设备驱动程序。
kld
接口允许系统管理员动态地为运行中的系统添加和移除功能。这使得设备驱动程序的编写者能够在不需要不断重启来测试更改的情况下,将新的更改加载到运行中的内核中。
通过以下命令使用 kld
接口:
kldload
- 加载一个新的内核模块
kldunload
- 卸载一个内核模块
kldstat
- 列出已加载的模块
内核模块的框架布局
FreeBSD 提供了一个系统 Makefile 来简化编译内核模块。
使用此 Makefile 运行 make
将生成一个文件 skeleton.ko,可以通过以下命令将其加载到内核中:
字符设备驱动程序是直接与用户进程交换数据的驱动程序。这是最常见的设备驱动程序类型,源代码树中有许多简单的示例。
这个简单的伪设备例子记住了写入它的所有值,并且在读取时可以将其回显出来。
加载此驱动程序后,尝试执行:
真实硬件设备将在下一章中描述。
其他 UNIX® 系统可能支持另一种磁盘设备,称为块设备。块设备是内核提供缓存的磁盘设备。这种缓存使得块设备几乎无法使用,或者至少是不可靠的。缓存会重新排序写操作的顺序,剥夺了应用程序在任何时刻知道磁盘内容的能力。
这使得磁盘上数据结构(文件系统、数据库等)的可预测和可靠的崩溃恢复变得不可能。由于写操作可能被延迟,内核无法报告哪个特定的写操作遇到了写入错误,这进一步加剧了一致性问题。
因此,没有严肃的应用程序依赖于块设备,事实上,几乎所有直接访问磁盘的应用程序都非常注意指定应始终使用字符(或“原始”)设备。由于每个磁盘(分区)别名为两个具有不同语义的设备的实现显著复杂了相关内核代码,FreeBSD 在磁盘 I/O 基础设施现代化过程中放弃了对缓存磁盘设备的支持。
网络设备的驱动程序不使用设备节点来访问。它们的选择基于内核中的其他决策,且通常通过使用系统调用 socket(2) 来引入对网络设备的使用,而不是调用 open()。
有关更多信息,请参见 ifnet(9),这是环回设备的源代码。
本文档介绍了当前的 SMPng 架构的设计和实现。首先,介绍了基本的原语和工具。接着,概述了 FreeBSD 内核的同步和执行模型的总体架构。然后,讨论了特定子系统的锁策略,记录了为每个子系统引入细粒度同步和并行性的方式。最后,提供了详细的实现说明,解释了设计选择,并使读者了解使用特定原语时的重要影响。
本文档仍在不断完善中,将会根据 SMPng 项目的设计和实现进展进行更新。目前许多部分仅以大纲形式存在,但随着工作推进会进一步详细化。有关文档的更新或建议可以发送给文档编辑者。
关于内存屏障和原子指令已经有了若干现有的处理方式,因此本节不会涉及过多的细节。简而言之,如果一个变量的写操作需要锁保护,那么不能在没有锁的情况下读取该变量。这一点通过内存屏障的作用变得显而易见;内存屏障仅仅确定内存操作的相对顺序,并不保证内存操作的时序。也就是说,内存屏障不会强制刷新 CPU 的本地缓存或存储缓冲区。相反,锁释放时的内存屏障确保所有对受保护数据的写入对其他 CPU 或设备可见,只要释放锁的写入操作对其他 CPU 可见。CPU 可以将该数据保持在其缓存或存储缓冲区中,直到它想要时才刷新。然而,如果另一个 CPU 对同一数据执行原子指令,首先执行的 CPU 必须确保更新后的值对第二个 CPU 可见,并且与内存屏障可能要求的其他操作一同进行。
例如,假设一个简单模型,其中数据在主内存(或全局缓存)中时才被认为是可见的。当一个 CPU 触发原子指令时,其他 CPU 的存储缓冲区和缓存必须刷新对相同缓存行的任何写入,并且将任何待处理操作一起提交到内存屏障之后。
这就要求在使用受原子指令保护的项时要特别小心。例如,在 sleep 互斥锁实现中,我们必须使用 atomic_cmpset
而不是 atomic_set
来开启 MTX_CONTESTED
位。原因在于我们将 mtx_lock
的值读取到一个变量中,然后根据该读取值做出决策。然而,我们读取到的值可能已经过时,或者在我们做出决策的过程中发生了变化。因此,当 atomic_set
执行时,它可能会在不同的值上设置该位,而非我们做决策时使用的值。因此,我们必须使用 atomic_cmpset
,只有在我们做决策时所用的值是最新且有效时才会设置该值。
最后,原子指令仅允许对一个项进行更新或读取。如果需要原子地更新多个项,那么必须使用锁来保护。例如,如果两个计数器必须被读取,并且它们的值相对于彼此保持一致,那么这两个计数器必须通过锁来保护,而不是通过单独的原子指令。
读锁不需要像写锁那样强大。两种类型的锁都需要确保它们访问的数据不是陈旧的。然而,只有写访问才要求独占访问。多个线程可以安全地读取一个值。使用不同类型的锁来区分读取和写入可以通过多种方式实现。
首先,sx 锁可以通过在写入时使用独占锁,读取时使用共享锁来实现。这种方法相当直接。
第二种方法略微复杂一些。你可以使用多个锁来保护一个数据项。然后,在读取该数据时,你只需要获取其中一个锁的读锁。然而,要写入数据,你需要获取所有锁的写锁。这可能使得写入操作变得昂贵,但对于数据以多种方式访问的情况很有用。例如,父进程指针由 proctree_lock
sx 锁和每个进程的互斥锁共同保护。有时,使用进程锁会更容易,因为我们只是检查一个已经被锁定的进程的父进程是谁。然而,在其他地方,如 inferior
,需要通过父进程指针遍历进程树,并且锁定每个进程会带来高昂的代价,同时还要保证在检查条件和根据检查结果采取行动时条件保持有效。
如果需要一个锁来检查变量的状态,以便根据读取的状态采取行动,那么你不能只在读取变量时持有锁,然后在执行操作之前释放锁。一旦你释放了锁,变量可能已经发生变化,从而使你的决策无效。因此,你必须在读取变量时持有锁,并在执行根据读取结果采取的操作时仍然保持锁定。
遵循其他多线程 UNIX® 内核的模式,FreeBSD 通过为中断处理程序提供自己的线程上下文来处理它们。为中断处理程序提供上下文允许它们在锁上阻塞。然而,为了避免延迟,中断线程以实时内核优先级运行。因此,中断处理程序不应执行过长时间,以避免饿死其他内核线程。此外,由于多个处理程序可能共享一个中断线程,因此中断处理程序不应睡眠或使用可睡眠锁,以避免饿死另一个中断处理程序。
FreeBSD 中当前的中断线程被称为重型中断线程。之所以称之为重型,是因为切换到中断线程涉及完整的上下文切换。在最初的实现中,内核是非抢占式的,因此中断一个内核线程时,必须等到内核线程阻塞或返回用户空间后,才能有机会运行中断。
为了处理延迟问题,FreeBSD 内核已经被改为抢占式。目前,只有在释放睡眠互斥锁或中断发生时,我们才会抢占一个内核线程。然而,计划是使 FreeBSD 内核完全抢占式,如下文所述。
并非所有中断处理程序都在线程上下文中执行。相反,某些处理程序直接在主中断上下文中执行。这些中断处理程序目前被误称为“快速”中断处理程序,因为早期内核中使用的 INTR_FAST
标志用于标记这些处理程序。当前,只有时钟中断和串行 I/O 设备中断使用这些类型的中断处理程序。由于这些处理程序没有自己的上下文,因此它们不能获取阻塞锁,因此只能使用自旋互斥锁。
最后,在 MD 代码中可以添加一种可选的优化,称为轻量级上下文切换。由于中断线程在内核上下文中执行,它可以借用任何进程的 vmspace。因此,在轻量级上下文切换中,切换到中断线程时不会切换 vmspace,而是借用被中断线程的 vmspace。为了确保被中断线程的 vmspace 不会在我们使用时消失,被中断的线程在中断线程不再借用其 vmspace 之前不能执行。这可以发生在中断线程阻塞或完成时。如果中断线程阻塞,它将使用自己的上下文在再次被调度时运行。因此,它可以释放被中断线程。
这种优化的缺点是它们非常机器特定且复杂,因此只有在带来显著性能提升时才值得付出努力。目前来看,可能还为时过早,事实上,几乎所有中断处理程序都会立即在 Giant 上阻塞,并且当它们阻塞时需要进行线程修复,这可能会影响性能。另外,Mike Smith 提出了另一种中断处理方法,其工作原理如下:
每个中断处理程序有两部分:一个谓词,在主中断上下文中运行,以及一个处理程序,在其自己的线程上下文中运行。
如果中断处理程序有谓词,当中断被触发时,谓词将被执行。如果谓词返回 true,则认为中断已完全处理,内核将返回中断。如果谓词返回 false 或没有谓词,则线程处理程序将被调度执行。
将轻量级上下文切换融入这一方案可能会变得相当复杂。由于我们可能希望在未来某个时候改用这种方案,因此最好推迟对轻量级上下文切换的工作,直到我们确定最终的中断处理架构,并且确定轻量级上下文切换是否适合其中。
内核抢占相对简单。基本的想法是,CPU 应该始终执行优先级最高的可用工作。至少这是理想的情况。当然,也有一些情况,实现理想状态的代价不值得追求完美。
实现完整的内核抢占是非常直接的:当你调度一个线程执行时,将其放入运行队列,检查其优先级是否高于当前执行的线程。如果是,则启动一个上下文切换,将控制权转交给该线程。
虽然锁可以在抢占的情况下保护大多数数据,但并非所有内核代码都能保证抢占安全。例如,如果一个持有自旋互斥锁的线程被抢占,而新线程尝试获取相同的自旋互斥锁,则新线程可能会永远自旋,因为被中断的线程可能永远无法执行。另外,一些代码(例如在 Alpha 上执行 exec
时为进程分配地址空间编号的代码)需要避免被抢占,因为它支持实际的上下文切换代码。这些代码段通过使用临界区来禁用抢占。
临界区 API 的职责是防止在临界区内发生上下文切换。对于一个完全抢占的内核,除了当前线程外,任何线程的 setrunqueue
操作都是一个抢占点。其一个实现是,critical_enter
设置一个每线程的标志,并且该标志由对应的退出函数清除。如果在该标志被设置时调用了 setrunqueue
,则无论新线程相对于当前线程的优先级如何,都会跳过抢占。
然而,由于临界区在自旋互斥锁中用于防止上下文切换,并且多个自旋互斥锁可能被同时获取,因此临界区 API 必须支持嵌套。因此,当前实现使用了一个嵌套计数,而不是单一的每线程标志。
为了最小化延迟,临界区内的抢占被延迟,而不是被丢弃。如果一个本应被抢占的线程在当前线程处于临界区时变得可运行,则设置一个每线程的标志,表示存在待处理的抢占。当退出最外层的临界区时,将检查该标志。如果标志被设置,则会抢占当前线程,以允许优先级更高的线程运行。
中断在自旋互斥锁方面带来了问题。如果一个低级中断处理程序需要锁,它必须确保不会中断任何需要该锁的代码,以避免可能的数据结构损坏。目前,通过 cpu_critical_enter
和 cpu_critical_exit
函数,提供了一种将该机制与临界区 API 结合使用的方法。目前,该 API 在 FreeBSD 的所有平台上禁用了中断并在需要时重新启用。这个方法可能不是最优的,但它简单易懂且容易正确实现。从理论上讲,这第二个 API 只需要在自旋互斥锁用于主中断上下文时使用。然而,为了简化代码,它被用于所有自旋互斥锁,甚至所有临界区。如果采取这种方法,可能需要将 MD API 与 MI API 分开,并仅在自旋互斥锁实现中与 MI API 一起使用。如果采用这种方法,则可能需要重命名 MD API,以表明它是一个独立的 API。
如前所述,做出了一些权衡,以牺牲完美抢占的情况来换取在某些情况下可能更好的性能。
第一个权衡是抢占代码不考虑其他 CPU。假设我们有两个 CPU A 和 B,其中 A 的线程优先级为 4,B 的线程优先级为 2。如果 CPU B 使一个优先级为 1 的线程变为可运行,那么理论上,我们希望 CPU A 切换到这个新线程,以便我们能运行两个最高优先级的可运行线程。然而,确定哪个 CPU 应该执行抢占以及通过 IPI 实际通知该 CPU 的成本,以及所需的同步,都会非常巨大。因此,当前代码会强制 CPU B 切换到优先级更高的线程。请注意,这仍然将系统置于一个更好的状态,因为 CPU B 正在执行优先级为 1 的线程,而不是优先级为 2 的线程。
第二个权衡是将即时内核抢占限制为实时优先级内核线程。在上面定义的简单抢占情况下,如果一个更高优先级的线程变为可运行,线程总是会立即被抢占(或者在退出临界区后立即抢占)。然而,许多在内核中执行的线程只会在内核上下文中执行很短的时间,然后阻塞或返回到用户态。因此,如果内核抢占这些线程去运行另一个非实时优先级的内核线程,内核可能会在该线程即将休眠或执行时将其切换出去。此时,CPU 上的缓存需要调整以适应新线程。当内核返回到被抢占的线程时,它必须重新填充所有丢失的缓存信息。此外,还会执行两次上下文切换,如果内核等到第一个线程阻塞或返回到用户态再进行抢占,则可以避免这些上下文切换。因此,默认情况下,抢占代码只会立即抢占如果更高优先级的线程是实时优先级线程。
为所有内核线程启用完整的内核抢占在调试时是有价值的,因为它能暴露更多的竞态条件。它在单处理器系统(UP 系统)中特别有用,因为许多竞态条件在其他情况下很难模拟。因此,提供了一个内核选项 FULL_PREEMPTION
,可以启用所有内核线程的抢占,用于调试目的。
简而言之,当一个线程从一个 CPU 移动到另一个 CPU 时,就是线程迁移。在非抢占内核中,这只能发生在一些明确定义的点,例如调用 msleep
或返回用户态。然而,在抢占内核中,中断可能会随时强制发生抢占并可能导致迁移。这可能对每个 CPU 的数据产生负面影响,因为除了 curthread
和 curpcb
外,数据可以在迁移时发生变化。由于可以随时迁移,这使得没有保护的每个 CPU 数据访问变得几乎没有用。因此,希望能够禁用代码段的迁移,这样可以确保每个 CPU 数据的稳定性。
当前,临界区可以防止迁移,因为它不允许上下文切换。然而,这在某些情况下可能过于严格,因为临界区实际上还会阻止当前处理器上的中断线程。因此,提供了另一个 API 允许当前线程在被抢占时指示它不应迁移到另一个 CPU。
这个 API 被称为线程固定,并由调度器提供。该 API 包括两个函数:sched_pin
和 sched_unpin
。这两个函数管理一个每线程的嵌套计数 td_pinned
。当一个线程的嵌套计数大于零时,它被认为是固定的,而线程从零开始未固定。每个调度器实现需要确保固定的线程仅在它首次调用 sched_pin
时所在的 CPU 上执行。由于嵌套计数仅由线程本身写入,并且只有在固定线程不执行时,由其他线程读取(但在持有 sched_lock
时),因此 td_pinned
不需要任何锁。sched_pin
函数递增嵌套计数,而 sched_unpin
函数则递减嵌套计数。请注意,这些函数仅操作当前线程,并将当前线程绑定到它执行时所在的 CPU。要将任意线程绑定到特定的 CPU,应使用 sched_bind
和 sched_unbind
函数。
timeout
内核设施允许内核服务注册函数,以便在 softclock
软件中断期间执行。事件是基于所需的时钟滴答数来调度的,回调将大致在适当的时间调用用户提供的函数。
挂起的超时事件的全局列表由全局自旋互斥锁 callout_lock
保护;所有对超时列表的访问必须在持有此互斥锁时执行。当 softclock
被唤醒时,它会扫描挂起的超时事件列表,查找应该触发的事件。为了避免锁顺序倒转,softclock
线程在调用提供的 timeout
回调函数时会释放 callout_lock
互斥锁。如果在注册时未设置 CALLOUT_MPSAFE
标志,则在调用回调函数之前会抓取 Giant
锁,之后会释放。然后会重新抓取 callout_lock
互斥锁,继续执行。softclock
代码在释放互斥锁时非常小心,以确保列表保持一致的状态。如果启用了 DIAGNOSTIC
,则会测量执行每个函数的时间,并且如果时间超过阈值,会生成警告。
struct ucred
是内核的内部凭证结构,通常作为内核中基于进程的访问控制的基础。BSD 衍生系统使用 "写时复制" 模型来管理凭证数据:凭证结构可能会有多个引用,当需要修改时,会复制该结构,修改后再替换引用。由于凭证广泛缓存以实现打开文件时的访问控制,这带来了显著的内存节省。随着向精细粒度 SMP 的转变,这一模型也大大节省了锁操作,因为只需要在未共享的凭证上进行修改,从而避免了在使用已知共享的凭证时进行显式同步。
具有单一引用的凭证结构被认为是可变的;共享的凭证结构不得修改,否则会导致竞争条件的风险。一个互斥锁 cr_mtxp
保护 struct ucred
的引用计数,以保持一致性。在使用该结构时,必须拥有一个有效的引用,否则结构可能会在非法使用者访问时被释放。
struct ucred
的互斥锁是叶子互斥锁,并通过互斥池实现,以提高性能。
通常,凭证是以只读方式用于访问控制决策的,在这种情况下,td_ucred
更受青睐,因为它不需要加锁。当进程的凭证被更新时,必须持有 proc
锁来执行检查和更新操作,从而避免竞争条件。进程凭证 p_ucred
必须用于检查和更新操作,以防止检查时与使用时的竞争条件。
如果系统调用在更新进程凭证后执行访问控制,则必须刷新 td_ucred
的值为当前进程的值。这将防止在更改后使用过期的凭证。内核在进程进入内核时会自动刷新线程结构中的 td_ucred
指针,使其指向进程的 p_ucred
,从而允许使用新鲜的凭证进行内核访问控制。
细节待补充。
TrustedBSD MAC 框架在多种内核对象中维护数据,形式为 struct label
。一般来说,内核对象中的标签由与该内核对象其余部分相同的锁保护。例如,struct vnode
中的 v_label
标签由 vnode 锁保护。
除了在标准内核对象中维护的标签外,MAC 框架还维护一份已注册和活跃的策略列表。该策略列表由全局互斥锁(mac_policy_list_lock
)和一个忙碌计数(也由互斥锁保护)来保护。由于许多访问控制检查可能会并行发生,因此对策略列表的只读访问需要在持有互斥锁的同时增减忙碌计数。互斥锁不必在整个 MAC 条目操作期间持有——某些操作,如对文件系统对象的标签操作——可能会持续较长时间。要修改策略列表(如在策略注册和注销期间),必须持有互斥锁,并且引用计数必须为零,以防止在列表正在使用时修改该列表。
一个条件变量 mac_policy_list_not_busy
可供线程等待列表变得不忙碌时使用,但只有当调用者没有持有其他锁时,才可以等待此条件变量,否则可能会出现锁顺序违反的情况。忙碌计数实际上充当了对框架访问的共享/独占锁:不同的是,与 sx 锁不同,等待列表变为不忙碌的消费者可能会被饿死,而不是允许与进入(或进入 MAC 框架内部)时持有的其他锁产生锁顺序问题。
对于模块子系统,存在一个单一的锁,用于保护共享数据。这个锁是共享/独占(SX)锁,并且很可能需要以共享或独占的方式获取,因此添加了一些宏来简化访问锁的过程。这些宏可以在 sys/module.h 中找到,使用起来非常基础。这个锁下保护的主要结构是 module_t
结构(当为共享时)和全局的 modulelist_t
结构,即模块。应该查看 kern/kern_module.c 中的相关源代码,以进一步理解锁定策略。
…
进程层次结构
proc 锁,引用
系统调用期间冻结的线程特定进程条目的副本,包括 td_ucred
进程间操作
进程组和会话
涉及大量对 sched_lock
的引用,并且有注释指向文档中其他特定原语和相关的细节。
select
和 poll
函数允许线程阻塞,等待文件描述符上的事件——通常是文件描述符是否可读或可写。
…
SIGIO 服务允许进程在指定的文件描述符的读写状态变化时,要求将 SIGIO 信号发送到其进程组。每个内核对象最多只能有一个进程或进程组注册 SIGIO,且该进程或组被称为所有者。每个支持 SIGIO 注册的对象包含一个指针字段,如果该对象没有注册,字段为 NULL
,否则指向一个 struct sigio
结构,描述注册信息。这个字段由一个全局互斥锁 sigio_lock
保护。调用 SIGIO 维护函数时,必须传入该字段的“引用”,以避免在未加锁的情况下进行本地副本注册。
每个与进程或进程组关联的注册对象都分配一个 struct sigio
,并包含指向对象、所有者、信号信息、凭证以及注册的一般处置的回指针。每个进程或进程组都包含一个已注册的 struct sigio
结构列表,进程为 p_sigiolst
,进程组为 pg_sigiolst
。这些列表分别由进程或进程组锁保护。每个 struct sigio
中的大多数字段在注册期间是常量的,唯一例外是 sio_pgsigio
字段,它将 struct sigio
链接到进程或进程组列表中。开发人员在实现新的内核对象以支持 SIGIO 时,通常希望避免在调用 SIGIO 支持函数时持有结构锁,例如 fsetown
或 funsetown
,以避免定义结构锁和全局 SIGIO 锁之间的锁顺序。这通常可以通过使用结构的提高引用计数来实现,例如在管道操作期间依赖文件描述符对管道的引用。
sysctl
MIB 服务通过系统调用从内核和用户空间应用程序中调用。至少有两个问题涉及锁定:首先是保护维护命名空间的结构,其次是与通过 sysctl 接口访问的内核变量和函数的交互。由于 sysctl 允许直接导出(和修改)内核统计信息和配置参数,sysctl 机制必须意识到这些变量的适当锁定语义。目前,sysctl 使用一个全局 sx 锁来序列化 sysctl
的使用;然而,它假设在 Giant 锁下运行,并且没有提供其他保护。本节的其余部分推测了 sysctl 的锁定和语义变化。
需要更改 sysctl 的操作顺序,将“读取旧值,copyin 和 copyout,写入新值”改为“copyin,锁定,读取旧值并写入新值,解锁,copyout”。对于只是将旧值 copyout 并设置新值然后 copyin 的普通 sysctl,仍然可以遵循旧模型。然而,使用第二个模型处理所有 sysctl 处理程序可能更清晰,以避免锁操作。
为了支持常见情况,可以在 SYSCTL_FOO 宏和结构体中嵌入一个指向互斥锁的指针。这对于大多数 sysctl 都适用。对于由 sx 锁、spin 互斥锁或其他锁定策略(除了单个睡眠互斥锁)保护的值,可以使用 SYSCTL_PROC 节点来正确进行锁定。
Taskqueue 的接口有两个基本锁与之相关,用于保护相关的共享数据。taskqueue_queues_mutex
用作锁来保护 taskqueue_queues
TAILQ。与该系统相关的另一个互斥锁是 struct taskqueue
数据结构中的锁。此同步原语的使用旨在保护 struct taskqueue
中数据的完整性。需要注意的是,由于这些锁很可能不会在 kern/subr_taskqueue.c 之外使用,因此没有单独的宏来协助用户锁定他们自己的工作。
睡眠队列是一个结构体,用于保存在等待通道上处于睡眠状态的线程列表。每个不在等待通道上睡眠的线程都会携带一个睡眠队列结构。当线程在等待通道上阻塞时,它会将其睡眠队列结构捐赠给该等待通道。与等待通道关联的睡眠队列存储在哈希表中。
睡眠队列哈希表保存那些至少有一个被阻塞线程的等待通道的睡眠队列。哈希表中的每一项称为一个睡眠队列链。该链包含一个睡眠队列的链表和一个自旋互斥锁。自旋互斥锁保护睡眠队列列表以及列表中睡眠队列结构的内容。每个等待通道只关联一个睡眠队列。如果多个线程在一个等待通道上阻塞,那么所有线程除了第一个线程外的睡眠队列将被存储在主睡眠队列的空闲睡眠队列列表中。当线程从睡眠队列中移除时,如果它不是队列中唯一的线程,则会从主队列的空闲列表中分配一个睡眠队列结构。如果是最后一个线程被恢复,它将被分配主睡眠队列。由于线程可能以不同于添加顺序的顺序从睡眠队列中移除,因此线程可能会使用不同于到达时的睡眠队列结构。
sleepq_lock
函数会锁定与特定等待通道关联的睡眠队列链的自旋互斥锁。sleepq_lookup
函数在哈希表中查找与给定等待通道关联的主睡眠队列。如果未找到主睡眠队列,它将返回 NULL
。sleepq_release
函数会解锁与给定等待通道关联的自旋互斥锁。
线程通过 sleepq_add
函数添加到睡眠队列。该函数接受等待通道、指向保护等待通道的互斥锁的指针、一个等待消息描述字符串和一个标志掩码作为参数。此时应通过 sleepq_lock
锁定睡眠队列链。若没有互斥锁保护等待通道(或者是由 Giant 保护),则互斥锁指针参数应为 NULL
。标志参数包含一个类型字段,指示线程将要加入的睡眠队列类型以及是否为可中断睡眠 (SLEEPQ_INTERRUPTIBLE
)。当前只有两种类型的睡眠队列:通过 msleep
和 wakeup
函数管理的传统睡眠队列 (SLEEPQ_MSLEEP
) 和条件变量睡眠队列 (SLEEPQ_CONDVAR
)。睡眠队列类型和锁定指针参数仅用于内部断言检查。调用 sleepq_add
的代码应在锁定相关的睡眠队列链后显式解锁保护等待通道的互斥锁,然后才可以在睡眠队列上阻塞。
通过调用 sleepq_set_timeout
可以为睡眠设置超时。该函数接受等待通道和作为相对滴答计数的超时时间作为参数。如果睡眠应被到达的信号中断,则应调用 sleepq_catch_signals
函数。该函数只接受等待通道作为参数。如果此线程已经有挂起的信号,则 sleepq_catch_signals
将返回一个信号号;否则,它将返回 0。
一旦线程被添加到睡眠队列,它将使用其中一个 sleepq_wait
函数进行阻塞。根据调用者是否希望使用超时或希望睡眠被捕获信号或用户态线程调度器中断中止,存在四种等待函数。sleepq_wait
函数简单地等待,直到当前线程通过某个唤醒函数显式恢复。sleepq_timedwait
函数等待,直到线程显式恢复或之前调用 sleepq_set_timeout
设置的超时到期。sleepq_wait_sig
函数等待,直到线程显式恢复或其睡眠被中断。sleepq_timedwait_sig
函数等待,直到线程显式恢复、之前调用 sleepq_set_timeout
设置的超时到期,或者线程的睡眠被中断。所有等待函数都接受等待通道作为第一个参数。此外,sleepq_timedwait_sig
函数接受第二个布尔参数,用于指示 sleepq_catch_signals
是否发现了挂起的信号。
如果线程被显式恢复或被信号中断,等待函数将返回零,表示成功睡眠。如果线程因超时或用户态线程调度器中断恢复,则会返回相应的错误号。请注意,sleepq_wait
只能返回 0,因此它不返回任何内容,调用者应假定睡眠成功。此外,如果线程的睡眠同时超时并被中断,则 sleepq_timedwait_sig
将返回表示超时发生的错误。如果返回值为 0,且使用了 sleepq_wait_sig
或 sleepq_timedwait_sig
来阻塞,则应调用 sleepq_calc_signal_retval
来检查是否有挂起的信号,并在发现信号时计算适当的返回值。sleepq_catch_signals
的调用返回的信号号应作为唯一参数传递给 sleepq_calc_signal_retval
。
通过 sleepq_broadcast
和 sleepq_signal
函数可以显式恢复处于等待通道上的线程。两个函数都接受要恢复线程的等待通道、恢复线程时提升的优先级和一个标志参数,用于指示恢复的是哪种类型的睡眠队列。优先级参数被视为最小优先级。如果恢复的线程的优先级已经高于(数值上更低)优先级参数,则不会调整其优先级。标志参数用于内部断言,确保睡眠队列不会被当作错误的类型进行处理。例如,条件变量函数不应恢复传统睡眠队列上的线程。sleepq_broadcast
函数恢复在指定等待通道上阻塞的所有线程,而 sleepq_signal
只恢复在等待通道上阻塞的优先级最高的线程。调用这些函数之前,应首先通过 sleepq_lock
锁定睡眠队列链。
通过调用 sleepq_abort
函数可以中断一个睡眠中的线程。此函数必须在持有 sched_lock
时调用,并且线程必须已排队到一个睡眠队列。线程也可以通过 sleepq_remove
函数从特定的睡眠队列中移除。该函数接受线程和等待通道作为参数,仅在线程在指定等待通道的睡眠队列中时唤醒线程。如果线程不在睡眠队列中或在不同的等待通道上的睡眠队列中,则此函数不执行任何操作。
与睡眠队列的比较与对比: 旋转门和睡眠队列都是线程同步机制,但它们的作用有所不同。睡眠队列用于管理等待某些条件的线程,而旋转门主要用于管理因争用而阻塞的线程。在旋转门中,线程被放置在一个优先级队列中,以便根据优先级恢复执行。
查找/等待/释放:
lookup
:旋转门的查找操作用于查找与某个互斥锁或资源相关的旋转门。该查找通过获取锁来确保同步。
wait
:线程在旋转门上等待时,它会被放入一个优先级队列中,直到能够获得资源为止。
release
:一旦线程获取到资源或锁,旋转门会释放资源并将线程从等待队列中移除。
TDF_TSNOBLOCK竞态条件:TDF_TSNOBLOCK
标志表示线程不能被阻塞。在旋转门的上下文中,竞争条件可能会发生,因为线程可能在没有有效的同步操作的情况下被错误地插入到旋转门队列中,或错过了通知从而造成死锁或优先级倒置。
优先级传播: 在旋转门中,线程的优先级会传播到等待队列中。这意味着较高优先级的线程会优先获取资源,从而确保系统的响应性。
我们是否应该要求互斥锁在 mtx_destroy()
时被拥有?
由于我们无法安全地断言互斥锁没有被其他任何人拥有,最好要求互斥锁在销毁时由某个线程持有。这样可以避免潜在的竞争条件和资源冲突。
使用临界区: 自旋互斥锁通常在临界区中使用,防止多个线程同时访问共享资源。自旋锁会在等待时不断地检查锁是否可用,因此在锁被占用时,线程会保持活跃,避免进入睡眠状态。
竞争互斥锁时的竞态条件: 当多个线程竞争相同的互斥锁时,可能会出现竞态条件,导致某些线程长时间无法获得锁或发生死锁。为了避免这种情况,需要确保互斥锁的正确使用和线程的优先级管理。
为什么在持有旋转门链锁时,读取竞争互斥锁的 mtx_lock
是安全的:
读取竞争中的互斥锁状态是安全的,因为在持有旋转门链锁时,旋转门确保了资源的正确调度和同步,避免了竞态条件。
它的作用: 见证系统主要用于追踪锁的使用情况,确保在多线程环境中不存在锁的错误使用(如死锁或优先级倒置)。它提供了一种机制,可以在锁操作期间收集诊断信息。
它如何工作: 见证通过插桩代码来监控锁的获取、释放以及锁之间的依赖关系。当系统检测到潜在的死锁或优先级倒置时,见证机制会触发警告或日志记录,从而帮助开发人员调试和优化代码。
struct isrc:isrc
是中断源控制器(Interrupt Source Controller)结构,它用于管理和抽象硬件中断源。通过 isrc
结构,系统可以统一处理来自不同硬件组件的中断信号。
pic 驱动: PIC(Programmable Interrupt Controller)驱动用于管理和处理中断信号。PIC 驱动提供中断的配置、触发和优先级管理。
我们是否应该传递一个互斥锁给 sema_wait
?
在某些情况下,sema_wait
可能需要一个互斥锁来确保在等待信号量时不会出现资源争用问题。传递互斥锁可以提高线程同步的可靠性。
我们是否应该有不可睡眠的 sx 锁? 不可睡眠的 sx 锁通常用于不能进入睡眠状态的操作中。这类锁的设计有助于减少上下文切换,提供更高的执行效率。
关于引用计数的正确使用添加一些信息: 引用计数是管理资源生命周期的有效方式,但必须小心处理,避免泄漏和无效引用。适当的增加和减少引用计数可以有效防止内存泄漏和未定义的行为。
atomic(原子操作) 如果所有操作的效果在遵循正确的访问协议时一起对其他 CPU 可见,则该操作是原子的。在最简单的情况下,原子指令由机器架构直接提供。在更高层次上,如果结构体的多个成员受到锁保护,那么一组操作如果在持有锁的情况下执行,并且在任何操作之间不释放锁,则该组操作是原子的。
另见:操作(operation)。
block(阻塞) 当线程在等待锁、资源或条件时,它处于阻塞状态。不幸的是,这个术语有些过载,因此可能会引起歧义。
另见:睡眠(sleep)。
MD 与机器相关的(Machine Dependent)。
另见:MI(与机器无关的)。
memory operation(内存操作) 内存操作是指读取和/或写入内存位置的操作。
MI 与机器无关的(Machine Independent)。
另见:MD(与机器相关的)。
operation(操作) 见 memory operation(内存操作)。
primary interrupt context(主中断上下文) 主中断上下文是指中断发生时执行的代码。该代码可以直接运行中断处理程序,或者调度一个异步中断线程来执行给定中断源的中断处理程序。
realtime kernel thread(实时内核线程) 高优先级内核线程。目前,唯一的实时优先级内核线程是中断线程。
另见:线程(thread)。
sleep(睡眠) 当线程在条件变量或通过 msleep 或 tsleep 在睡眠队列上阻塞时,线程处于睡眠状态。
另见:阻塞(block)。
sleepable lock(可睡眠锁) 可睡眠锁是一个线程可以在其上睡眠的锁。Lockmgr 锁和 sx 锁是当前 FreeBSD 中唯一的可睡眠锁。最终,一些 sx 锁,如 allproc 锁和 proctree 锁,可能会变成不可睡眠的锁。
另见:睡眠(sleep)。
thread(线程) 由 struct thread 表示的内核线程。线程拥有锁并持有单一的执行上下文。
wait channel(等待通道) 线程可以在其上睡眠的内核虚拟地址。
FreeBSD 的声音子系统将通用的声音处理问题与设备特定问题分开,这使得为新硬件添加支持变得更容易。
一个系统调用接口(读、写、ioctl)用于数字化声音和混音功能。ioctl 命令集与传统的 OSS 或 Voxware 接口兼容,允许常见的多媒体应用程序无需修改即可移植。
用于处理声音数据的通用代码(格式转换、虚拟通道)。
一个统一的软件接口,用于硬件特定的音频接口模块。
对一些常见硬件接口(如 ac97)或共享硬件特定代码(例如:ISA DMA 例程)的额外支持。
特定声音卡的支持由硬件特定的驱动程序实现,这些驱动程序提供通道和混音器接口,以便接入通用的 pcm 代码。
在本章中,pcm 术语将指代声音驱动程序的核心通用部分,而不是硬件特定的模块。
预计的驱动程序编写者当然希望从现有模块开始,并使用代码作为最终参考。尽管声音代码整洁且清晰,但它也几乎没有注释。本文档尝试概述框架接口,并回答在适配现有代码时可能出现的一些问题。
所有相关代码都位于 /usr/src/sys/dev/sound/,除公共 ioctl 接口定义外,后者位于 /usr/src/sys/sys/soundcard.h。
在 /usr/src/sys/dev/sound/ 下,pcm/ 目录包含核心代码,而 pci/、isa/ 和 usb/ 目录则包含 PCI 和 ISA 板卡的驱动程序,以及 USB 音频设备的驱动程序。
然而,声音驱动程序在一些方面有所不同:
它们声明自己为 pcm 类设备,并使用 struct snddev_info
设备私有结构:
大多数声音驱动程序需要存储有关其设备的额外私有信息。私有数据结构通常在附加例程中分配。其地址通过对 pcm_register()
和 mixer_init()
的调用传递给 pcm。随后,pcm 在调用声音驱动程序接口时将该地址作为参数传递回去。
声音驱动程序的附加例程通过调用 pcm_register(dev, sc, nplay, nrec)
向 pcm 声明其一般 CHANNEL 配置,其中 sc
是设备数据结构的地址,用于后续 pcm 的调用,nplay
和 nrec
分别是播放和录音通道的数量。
声音驱动程序的拆卸例程应在释放资源之前调用 pcm_unregister()
。
有两种处理非 PnP 设备的方法:
使用 device_identify()
方法(例如:sound/isa/es1888.c)。device_identify()
方法在已知地址探测硬件,如果发现受支持的设备,则创建一个新的 pcm 设备,并将其传递给探测/附加过程。
使用带有适当提示的自定义内核配置(例如:sound/isa/mss.c)。
pcm 驱动程序应实现 device_suspend
、device_resume
和 device_shutdown
例程,以便电源管理和模块卸载能够正常工作。
声音驱动通常会提供两个主要接口:CHANNEL 和 MIXER 或 AC97。
AC97 接口是一个非常小的硬件访问(寄存器读写)接口,适用于具有 AC97 编解码器的硬件驱动。在这种情况下,实际的 MIXER 接口是由 pcm 中共享的 AC97 代码提供的。
声音驱动通常具有一个私有数据结构,用于描述其设备,并为其支持的每个播放和录音数据通道提供一个结构。
对于所有 CHANNEL 接口函数,第一个参数是一个不透明指针。
第二个参数是指向私有通道数据结构的指针,channel_init()
除外,后者具有指向私有设备结构的指针(并返回通道指针,供 pcm 进一步使用)。
对于声音数据传输,pcm 核心与声音驱动通过一个共享内存区域进行通信,该区域由 struct snd_dbuf
描述。
struct snd_dbuf
是 pcm 私有的,声音驱动通过调用访问器函数(sndbuf_getxxx()
)获取感兴趣的值。
共享内存区域的大小由 sndbuf_getsize()
确定,并且被划分为固定大小的块,每块为 sndbuf_getblksz()
字节。
播放时,一般的传输机制如下(录音时则反向操作):
然后,声音驱动将整个内存区域(sndbuf_getbuf()
,sndbuf_getsize()
)以 sndbuf_getblksz()
字节为单位重复传输到设备。对于每个传输的块,它会回调 chn_intr()
pcm 函数(通常在中断时发生)。
chn_intr()
会将新数据复制到已传输到设备的区域(现在为空闲),并对 snd_dbuf
结构进行适当的更新。
xxxchannel_init()
用于初始化每个播放或录音通道。该调用由声音驱动的 attach 例程发起。(参见 crossref:sound)。
① b
是指向通道 struct snd_dbuf
的地址。函数中应通过调用 sndbuf_alloc()
初始化该缓冲区。要使用的缓冲区大小通常是设备“典型”单位传输大小的小倍数。c
是 pcm 通道控制结构的指针,这是一个不透明对象。函数应将其存储在本地通道结构中,以供后续 pcm 调用使用(例如:chn_intr(c)
)。dir
表示通道的方向(PCMDIR_PLAY
或 PCMDIR_REC
)。
② 该函数应返回指向用于控制此通道的私有区域的指针。该指针将作为参数传递给其他通道接口调用。
xxxchannel_setformat()
应该为指定的通道和指定的声音格式设置硬件。
① format
作为 AFMT_XXX
值(soundcard.h)指定。
xxxchannel_setspeed()
为指定的采样速度设置通道硬件,并返回可能调整后的速度。
xxxchannel_setblocksize()
设置块大小,即 pcm 和声音驱动之间、以及声音驱动和设备之间的单位事务的大小。通常,这是传输发生中断前传输的字节数。在传输过程中,每当传输该大小的数据时,声音驱动应调用 pcm 的 chn_intr()
。
大多数声音驱动仅在此处注意到块大小,以便在实际传输开始时使用。
① 函数返回可能调整后的块大小。如果块大小确实更改,则应调用 sndbuf_resize()
来调整缓冲区。
xxxchannel_trigger()
由 pcm 调用以控制驱动中的数据传输操作。
① go
定义当前调用的操作。可能的值为:
注意
如果驱动程序使用 ISA DMA,应该在对设备执行操作之前调用
sndbuf_isadma()
,它会处理 DMA 芯片的相关操作。
xxxchannel_getptr()
返回传输缓冲区中的当前偏移量。通常这将由 chn_intr()
调用,这样 pcm 就能知道可以在哪里传输新数据。
xxxchannel_free()
用于释放通道资源,例如在卸载驱动程序时。如果通道数据结构是动态分配的,或者 sndbuf_alloc()
没有用于缓冲区分配,则应该实现此函数。
① 该例程返回指向(通常是静态定义的)pcmchan_caps
结构的指针(定义在 sound/pcm/channel.h)。该结构包含最小和最大采样频率,以及接受的声音格式。可以查看任何声音驱动程序的示例。
channel_setdir()
已弃用。
xxxmixer_init()
用于初始化硬件,并告诉 pcm 可用的播放和录音混音设备。
① 在整数值中设置位,并调用 mix_setdevs()
和 mix_setrecdevs()
来告诉 pcm 存在哪些设备。
混音器位定义可以在 soundcard.h 中找到(SOUND_MASK_XXX
值和 SOUND_MIXER_XXX
位移)。
xxxmixer_set()
设置一个混音器设备的音量级别。
① 设备由 SOUND_MIXER_XXX
值指定。音量值的范围是 [0-100]。值为零时应该静音设备。
② 由于硬件级别可能与输入比例不匹配,并且可能会发生一些四舍五入,函数返回实际的音量级别(范围为 0-100)。
xxxmixer_setrecsrc()
设置录音源设备。
① 所需的录音设备作为一个位字段指定。
② 返回实际设置为录音的设备。有些驱动程序只能为录音设置一个设备。如果发生错误,函数应返回 -1
。
xxxmixer_uninit()
应确保所有声音被静音,并且如果可能,混音器硬件应该关闭电源。
xxxmixer_reinit()
应确保混音器硬件重新启动,并恢复任何未通过 mixer_set()
或 mixer_setrecsrc()
控制的设置。
AC97 接口由带有 AC97 编解码器的驱动程序实现。它只有三个方法:
xxxac97_init()
返回找到的 AC97 编解码器的数量。
ac97_read()
和 ac97_write()
分别读取或写入指定的寄存器。
AC97 接口由 pcm 中的 AC97 代码用于执行更高级别的操作。可以参考 sound/pci/maestro3.c 或 sound/pci/ 下的许多其他示例。
链接器集(Linker Set) 一种链接器技术,链接器将程序源代码中静态声明的数据收集成一个连续可寻址的数据单元。
SYSINIT 依赖于链接器将多个位置声明的静态数据组合为一个连续的数据块。这个链接器技术被称为“链接器集(linker set)”。SYSINIT 使用两个链接器集来维护包含每个消费者调用顺序、函数和要传递给该函数的数据的两个数据集。
SYSINIT 在排序执行函数时使用两个优先级。第一个优先级是子系统 ID,提供 SYSINIT 调度函数的整体顺序。当前预声明的 ID 列表位于 <sys/kernel.h> 中的枚举列表 sysinit_sub_id
。第二个优先级是子系统内的元素顺序。当前预声明的子系统元素顺序位于 <sys/kernel.h> 中的枚举列表 sysinit_elem_order
。
SYSINIT 目前有两个用途:系统启动时的函数调度和内核模块加载时的调度,以及系统关闭时的函数调度和内核模块卸载时的调度。内核子系统通常使用系统启动时的 SYSINIT 来初始化数据结构,例如进程调度子系统使用 SYSINIT 来初始化运行队列数据结构。设备驱动程序应避免直接使用 SYSINIT()
。相反,属于总线结构的实际设备驱动程序应使用 DRIVER_MODULE()
来提供一个函数,该函数检测设备并在设备存在时初始化设备。它会做一些设备特定的操作,然后调用 SYSINIT()
。对于非总线结构的伪设备,应使用 DEV_MODULE()
。
SYSINIT()
宏在 SYSINIT 的启动数据集中创建必要的 SYSINIT 数据,以便 SYSINIT 能对系统启动和模块加载时的函数进行排序和调度。SYSINIT()
需要一个唯一标识符,用于标识特定的函数调度数据,子系统顺序,子系统元素顺序,要调用的函数,以及传递给该函数的数据。所有函数必须接受一个常量指针参数。
示例 1:SYSINIT()
示例
请注意,SI_SUB_FOO
和 SI_ORDER_FOO
需要在 sysinit_sub_id
和 sysinit_elem_order
枚举列表中,如上所述。可以使用现有的枚举值,或者添加自己的枚举值。你也可以使用数学来微调 SYSINIT 的执行顺序。此示例显示了一个需要在处理内核参数调优的 SYSINIT 之前运行的 SYSINIT。
示例 2:调整 SYSINIT()
顺序
SYSUNINIT()
宏与 SYSINIT()
宏的行为类似,不同之处在于它将 SYSINIT 数据添加到 SYSINIT 的关机数据集中。
示例 3:SYSUNINIT()
示例
本章将讨论如何为 PC Card 或 CardBus 设备编写 FreeBSD 设备驱动程序的机制。然而,目前它仅记录了如何向现有的 pccard 驱动程序中添加新设备。
设备驱动程序知道它们支持哪些设备。内核中有一个支持设备的表格,驱动程序使用它来附加到设备。
PC 卡通过两种方式之一进行识别,这两种方式都基于存储在卡上的 Card Information Structure(CIS)。第一种方法是使用数字制造商和产品编号。第二种方法是使用同样包含在 CIS 中的可读字符串。PC 卡总线使用一个集中式数据库和一些宏来促进设计模式,帮助驱动程序编写者将设备与其驱动程序匹配。
原始设备制造商(OEM)通常会开发一个 PC 卡产品的参考设计,然后将这个设计出售给其他公司进行市场营销。这些公司会改进设计、将产品推广到他们的目标受众或地区,并在卡上加上他们自己的品牌标签。通常这些卡的物理改进非常小,或者根本没有改动。为了增强品牌,这些厂商会将公司名称放入 CIS 空间中的可读字符串中,但保持制造商和产品 ID 不变。
由于这种做法,FreeBSD 驱动程序通常依赖数字 ID 来识别设备。使用数字 ID 和集中式数据库增加 ID 和卡支持到系统中变得复杂。必须仔细检查卡的真正制造商,特别是在发现卡的制造商可能已经在集中数据库中列出了不同的制造商 ID 时。Linksys、D-Link 和 NetGear 是一些美国制造商,通常会出售相同的设计。这些相同的设计可能会以 Buffalo 和 Corega 等名称在日本销售。这些设备通常会有相同的制造商和产品 ID。
PC 卡总线代码保持着一个卡信息的中央数据库,但不包含与之关联的驱动程序,位于 /sys/dev/pccard/pccarddevs。它还提供了一组宏,允许我们轻松地在驱动程序用于声明设备的表格中构建简单的条目。
最后,一些非常低端的设备根本没有包含制造商标识。这些设备必须通过匹配可读的 CIS 字符串来检测。尽管我们希望不必将此方法作为后备方案,但对于一些非常低端的 CD-ROM 播放器和以太网卡来说,这是必要的。这种方法通常应该避免,但有一些设备在这个章节中列出,因为它们是在意识到 PC 卡业务的 OEM 特性之前添加的。在添加新设备时,建议优先使用数字方法。
pccarddevs 文件分为四个部分。第一部分列出了使用制造商编号的供应商。此部分按数字顺序排序。接下来的部分列出了这些供应商使用的所有产品,以及它们的产品 ID 和描述字符串。描述字符串通常不使用(即使我们根据数字版本匹配设备,仍然基于人类可读的 CIS 设置设备的描述)。这两个部分随后会为使用字符串匹配方法的设备重复一次。最后,C 风格的注释被允许出现在文件中的任何位置,注释以 /*
和 */
符号括起来。
文件的第一部分包含了供应商 ID。请保持这个列表按数字顺序排序。同时,请协调对此文件的更改,因为我们与 NetBSD 共享该文件,以帮助促进这一信息的公共清算。例如,以下是前几个供应商 ID:
NETGEAR_2
条目的出现很可能是 Netgear 从某个 OEM 采购的卡片,而当时支持这些卡的作者并未意识到 Netgear 使用了他人 ID。这些条目非常简单。vendor
关键字表示这一行的类型,后面跟随供应商的名称。这个名称将在 pccarddevs 中重复,并在驱动程序的匹配表中使用,因此保持简短,并确保它是有效的 C 标识符。接下来是以十六进制表示的供应商的数字 ID。不要添加 0xffffffff
或 0xffff
形式的 ID,因为这些是保留的 ID(前者表示“未设置 ID”,后者有时出现在极差质量的卡中,试图表示“无”)。最后是一个描述制造商的字符串。此字符串在 FreeBSD 中仅用于注释目的,其他地方不会使用。
文件的第二部分包含了产品。在这个例子中,格式与供应商行类似:
product
关键字后跟供应商名称,与前面重复。接着是产品名称,驱动程序将使用此名称,它应为有效的 C 标识符,但也可以以数字开头。与供应商一样,该卡的十六进制产品 ID 遵循相同的 0xffffffff
和 0xffff
规则。最后是设备本身的描述字符串。该字符串通常不会在 FreeBSD 中使用,因为 FreeBSD 的 pccard 总线驱动程序会根据人类可读的 CIS 条目构造一个字符串,但在某些特殊情况下,如果这个方法不足够,还可以使用它。产品按制造商的字母顺序排列,然后按产品 ID 的数字顺序排列。在每个制造商的条目之前有一个 C 注释,并且条目之间有一个空行。
要理解如何将设备添加到受支持设备的列表中,必须了解许多驱动程序具有的探测和/或匹配例程。由于 FreeBSD 5.x 中也存在 OLDCARD 的兼容层,这一点稍微复杂一些。由于只是外观不同,本文将提供一个理想化版本。
这里有一个简单的 pccard 探测例程,它匹配几个设备。如上所述,函数名可能不同(如果不是 foo_pccard_probe()
,它将是 foo_pccard_match()
)。pccard_product_lookup()
函数是一个通用函数,它遍历表格并返回第一个匹配的条目的指针。有些驱动程序可能使用此机制向驱动程序的其余部分传递有关某些卡的附加信息,因此表格中可能会有所不同。唯一的要求是,表格的每一行必须具有 struct pccard_product
作为第一个元素。
查看表格 wi_pccard_products
,可以看到所有条目都采用 PCMCIA_CARD(foo, <span> </span>bar, <span> </span>baz)
形式。foo 部分是来自 pccarddevs 的供应商 ID。bar 部分是产品 ID。baz 是此卡的预期功能编号。许多 pccard 可能具有多个功能,因此需要某种方式来区分功能 1 和功能 0。你可能会看到 PCMCIA_CARD_D
,它包含来自 pccarddevs 的设备描述。你还可能会看到 PCMCIA_CARD2
和 PCMCIA_CARD2_D
,它们在需要同时匹配 CIS 字符串和制造商编号时使用,分别表示“使用默认描述”和“从 pccarddevs 获取描述”。
要添加新设备,首先需要获取设备的识别信息。最简单的方法是将设备插入 PC Card 或 CF 插槽,然后执行 devinfo -v
。示例输出:
manufacturer
和 product
是此产品的数字 ID,而 cisvendor
和 cisproduct
是来自 CIS 的产品描述字符串。
由于我们首先希望优先使用数字选项,因此首先尝试基于此构建条目。上面的卡片已经稍微进行了虚构化处理。供应商是 BUFFALO,我们看到它已经有一个条目:
但是没有这个特定卡片的条目。相反,我们发现:
为了添加该设备,我们只需将此条目添加到 pccarddevs 中:
完成这些步骤后,设备就可以添加到驱动程序中。这是一个简单的操作,即添加一行:
请注意,我在我添加的行之前包含了一个“+”,但那只是为了突出显示这一行。实际驱动程序中不应添加它。添加该行后,可以重新编译内核或模块并进行测试。如果设备被识别并且正常工作,请提交补丁。如果设备无法正常工作,请找出使其正常工作的必要步骤并提交补丁。如果设备完全无法识别,那么说明你在某些步骤上出错了,需要重新检查每一步。
如果你是 FreeBSD 的源代码提交者,且一切正常,则可以将更改提交到树中。然而,仍然有一些小的技巧需要注意。pccarddevs 必须首先提交到树中。然后,必须重新生成并提交 pccarddevs.h,确保后者文件中有正确的 $FreeBSD$ 标签。最后,提交对驱动程序的添加。
请不要直接将新设备的条目发送给作者。相反,应作为 PR 提交,并将 PR 编号发送给作者以供记录。这可以确保条目不会丢失。提交 PR 时,不必将 pccarddevs.h 的差异包含在补丁中,因为这些差异将会重新生成。必须包括设备的描述以及对客户端驱动程序的补丁。如果你不知道设备的名称,请使用 OEM99 作为名称,作者会在调查后相应地调整 OEM99。提交者不应提交 OEM99,而应查找最高的 OEM 条目并提交其后的条目。
通用串行总线(USB)是一种将设备连接到个人计算机的新方式。该总线架构具有双向通信功能,是对设备变得更加智能并且需要与主机更多交互的响应。USB 支持包含在所有当前的 PC 芯片组中,因此所有新建的 PC 都支持 USB。苹果公司推出的仅支持 USB 的 iMac 是硬件制造商生产 USB 版本设备的重要推动力。未来的 PC 规格要求将 PC 上的所有传统连接器替换为一个或多个 USB 连接器,提供通用的即插即用功能。NetBSD 早期就提供了 USB 硬件支持,并由 Lennart Augustsson 为 NetBSD 项目开发。该代码已经移植到 FreeBSD,目前我们正在维护一个共享的代码库。对于 USB 子系统的实现,USB 的若干特性非常重要。
Lennart Augustsson 完成了大部分 NetBSD 项目的 USB 支持实现。非常感谢他付出的大量工作。还要特别感谢 Ardy 和 Dirk 对本文的评论和校对。
设备通过计算机的端口或叫做集线器的设备连接,形成一个树形的设备结构。
设备可以在运行时连接和断开。
设备可以自我挂起,并触发主机系统的恢复。
由于设备可以从总线供电,主机软件必须跟踪每个集线器的电源预算。
由于不同设备类型具有不同的服务质量要求,加上同一总线上最多可以连接 126 个设备,因此需要对共享总线上的传输进行适当调度,以充分利用可用的 12 Mbps 带宽。(USB 2.0 可达到 400 Mbps)
设备是智能的,包含可以轻松访问的关于它们自身的信息。
USB 子系统及其连接设备的驱动程序开发得到了已经制定和即将制定的规格的支持。这些规格可从 USB 网站公开获取。苹果公司在推动基于标准的驱动程序方面做得非常好,提供了他们操作系统 MacOS 中用于通用类设备的驱动程序,并且不鼓励为每个新设备使用单独的驱动程序。本章试图汇总一些基本的 USB 2.0 实现栈在 FreeBSD/NetBSD 中的关键信息。然而,建议将其与相关的 2.0 规格和其他开发者资源一起阅读:
FreeBSD 中的 USB 支持可以分为三层。最底层包含主机控制器驱动程序,提供硬件和调度设施的通用接口。它支持硬件初始化、传输调度和已完成或失败传输的处理。每个主机控制器驱动程序实现一个虚拟集线器,提供对机器背面根端口控制寄存器的硬件独立访问。
中间层处理设备的连接和断开、设备的基本初始化、驱动程序选择、通信通道(管道)以及资源管理。该服务层还控制默认管道和通过这些管道传输的设备请求。
最上层包含支持特定(类)设备的各个驱动程序。这些驱动程序实现了在除默认管道外的管道上使用的协议。它们还实现了其他功能,使设备可以被内核或用户空间的其他部分使用。它们使用服务层暴露的 USB 驱动程序接口(USBDI)。
主机控制器(HC)控制总线上数据包的传输。每帧为 1 毫秒。在每帧开始时,主机控制器会生成一个帧开始(SOF)数据包。
SOF 数据包用于同步帧的开始,并跟踪帧编号。在每帧内,数据包要么从主机传输到设备(out),要么从设备传输到主机(in)。传输始终由主机发起(轮询传输)。因此,每个 USB 总线上只能有一个主机。每次数据包传输都有一个状态阶段,在该阶段,接收数据的一方可以返回 ACK(确认接收)、NAK(重试)、STALL(错误状态)或不返回任何内容(数据损坏阶段、设备不可用或已断开)。USB 2.0 规格的第 8.5 节对数据包进行了更详细的解释。USB 总线上可以发生四种不同类型的传输:控制传输、批量传输、中断传输和等时传输。以下是这些传输类型及其特性的描述。
主机控制器或主机控制器驱动程序会将大规模传输在 USB 总线上的设备与设备驱动程序之间拆分成多个数据包。
设备请求(控制传输)到默认端点是特殊的。它们由两个或三个阶段组成:SETUP、DATA(可选)和 STATUS。设置数据包被发送到设备。如果有数据阶段,数据包的方向将在设置数据包中给出。状态阶段的方向与数据阶段的方向相反,或者如果没有数据阶段,则为 IN。主机控制器硬件还提供了根端口的当前状态寄存器,并且记录自上次重置状态更改寄存器以来发生的更改。通过虚拟集线器访问这些寄存器,正如 USB 规格中所建议的那样。虚拟集线器必须符合该规格第 11 章中给出的集线器设备类。它必须提供一个默认管道,通过该管道可以发送设备请求。它返回标准的 andhub 类特定描述符集。它还应该提供一个中断管道,报告其端口发生的更改。目前有两种主机控制器的规格可用:英特尔的通用主机控制器接口(UHCI)和康柏、微软和国家半导体的开放主机控制器接口(OHCI)。UHCI 规格旨在通过要求主机控制器驱动程序提供每帧的完整传输调度来减少硬件复杂性。而 OHCI 类型的控制器则更加独立,提供了一个更加抽象的接口,能够自己完成大量工作。
UHCI 主机控制器维护一个帧列表,其中包含 1024 个指向每帧数据结构的指针。它理解两种不同的数据类型:传输描述符(TD)和队列头(QH)。每个 TD 代表一个要与设备端点通信的数据包。QH 是将 TD(和 QH)组合在一起的一种方式。
每个传输由一个或多个数据包组成。UHCI 驱动程序将大规模传输拆分为多个数据包。除了等时传输外,每个传输都会分配一个 QH。对于每种传输类型,这些 QH 会在该类型的 QH 中收集。由于固定的延迟要求,等时传输必须首先执行,并且通过帧列表中的指针直接引用。最后一个等时 TD 会引用该帧的中断传输的 QH。所有中断传输的 QH 都指向控制传输的 QH,而控制传输的 QH 又指向批量传输的 QH。以下图示提供了一个图形化概览:
这导致在每个帧中运行以下调度。在从帧列表中获取当前帧的指针后,控制器首先执行该帧中所有等时数据包的 TD。最后一个等时 TD 会引用该帧的中断传输的 QH。然后主机控制器将从该 QH 继续执行各个中断传输的 QH。在完成该队列后,中断传输的 QH 将引用控制传输的 QH。控制器将执行所有在该队列中安排的子队列,随后执行所有在批量 QH 队列中的传输。为了便于处理已完成或失败的传输,硬件会在每帧结束时生成不同类型的中断。在传输的最后一个 TD 中,主机控制器驱动程序会设置“完成时中断”位,以便在传输完成时触发中断。如果 TD 达到其最大错误计数,则会触发错误中断。如果在 TD 中设置了短包检测位,并且传输的数据包长度小于设定的包长度,则会触发此中断,通知控制器驱动程序传输已完成。主机控制器驱动程序的任务是确定哪个传输已完成或产生错误。当中断服务程序被调用时,它将定位所有已完成的传输并调用其回调函数。
请参考 UHCI 规格以获得更详细的描述。
编程 OHCI 主机控制器要简单得多。控制器假定一组端点是可用的,并且知道调度优先级以及帧内传输类型的顺序。主机控制器使用的主要数据结构是端点描述符(ED),每个 ED 附加一个传输描述符(TD)队列。ED 包含该端点允许的最大数据包大小,控制器硬件负责将传输拆分为多个数据包。数据缓冲区的指针会在每次传输后更新,当起始指针和结束指针相等时,TD 会被退回到完成队列中。四种类型的端点(中断、等时、控制和批量)各自有自己的队列。控制和批量端点在各自的队列中排队。中断 ED 以树状结构排队,树的层级定义了它们运行的频率。
主机控制器在每帧中运行的调度如下。控制器首先运行非周期性的控制和批量队列,直到 HC 驱动程序设置的时间限制为止。然后,使用帧编号的低五位作为索引,运行该帧的中断传输,进入中断 ED 树的第 0 层。树的末端连接了等时 ED,这些 ED 会随后被遍历。等时 TD 包含第一次运行传输的帧编号。所有周期性传输完成后,将再次遍历控制和批量队列。定期调用中断服务程序来处理完成队列,调用每个传输的回调并重新调度中断和等时端点。
请参考 UHCI 规格以获得更详细的描述。中间层提供对设备的受控访问,并维护不同驱动程序和服务层使用的资源。该层负责以下方面:
设备配置的信息
与设备通信的管道
设备的探测、附加和分离
每个设备提供不同级别的配置信息。每个设备有一个或多个配置,其中一个配置在探测/附加过程中被选中。一个配置提供电力和带宽要求。在每个配置中,可以有多个接口。设备接口是端点的集合。例如,USB 扬声器可以有一个用于音频数据的接口(音频类)和一个用于旋钮、拨盘和按钮的接口(HID 类)。配置中的所有接口同时处于活动状态,并可以被不同的驱动程序附加。每个接口可以有不同的备用选项,提供不同的服务质量参数。例如,在摄像头中,这用于提供不同的帧大小和每秒帧数。
在每个接口中,可以指定 0 个或多个端点。端点是与设备进行通信的单向访问点。它们提供缓冲区来暂时存储来自设备的输入或输出数据。每个端点在一个配置中有一个唯一的地址,由端点的编号和方向组成。默认端点,即端点 0,不属于任何接口,并且在所有配置中可用。它由服务层管理,设备驱动程序不能直接访问。
这种层级的配置信息通过一组标准的描述符在设备中进行描述(见 USB 规格的第 9.6 节)。可以通过获取描述符请求来请求这些描述符。服务层缓存这些描述符,以避免在 USB 总线上进行不必要的传输。可以通过函数调用来访问这些描述符。
设备描述符:关于设备的一般信息,如供应商、产品和修订号、支持的设备类、子类和协议(如果适用)、默认端点的最大数据包大小等。
配置描述符:该配置中接口的数量、支持的挂起和恢复功能以及电力要求。
接口描述符:接口类、子类和协议(如果适用)、接口的备用设置数量和端点数量。
端点描述符:端点地址、方向和类型、最大数据包大小支持及其轮询频率(如果类型是中断端点)。默认端点(端点 0)没有描述符,且在接口描述符中永远不计入。
字符串描述符:在其他描述符中,一些字段提供字符串索引。这些索引可以用来获取描述性字符串,可能是多种语言版本。
类规格可以添加自己的描述符类型,这些可以通过获取描述符请求来访问。
与设备端点的通信通过所谓的管道进行。驱动程序将传输提交给端点的管道,并提供一个回调函数,以便在传输完成或失败时调用(异步传输),或者等待传输完成(同步传输)。端点的传输在管道中被串行化。传输可以完成、失败或超时(如果已设置超时)。传输有两种类型的超时。超时可能由于 USB 总线上的超时(毫秒)发生。这些超时被视为失败,可能是由于设备断开连接。第二种超时形式是在软件中实现的,当传输未在指定时间内完成(秒)时触发。这些超时是由设备对传输的包进行否定确认(NAK)引起的。发生这种情况的原因可能是设备尚未准备好接收数据、缓冲区下溢或上溢,或协议错误。
如果通过管道的传输大于关联的端点描述符中指定的最大数据包大小,主机控制器(OHCI)或主机控制器驱动程序(UHCI)将把传输拆分成最大数据包大小的多个数据包,最后一个数据包的大小可能小于最大数据包大小。
有时,设备返回的数据少于请求的数据量并不是问题。例如,向调制解调器进行的批量输入传输可能请求 200 字节的数据,但调制解调器在那个时刻只有 5 字节的数据可用。驱动程序可以设置短数据包(SPD)标志。这允许主机控制器接受一个数据包,即使传输的数据量少于请求的数据量。此标志仅对输入传输有效,因为发送到设备的数据量始终是预先知道的。如果在设备传输过程中发生无法恢复的错误,管道将被暂停。在接受或发送更多数据之前,驱动程序需要解决暂停的原因,并通过发送清除端点暂停设备请求通过默认管道来清除端点的暂停条件。默认端点不应暂停。
有四种不同类型的端点及其对应的管道:
控制管道 / 默认管道:每个设备有一个控制管道,连接到默认端点(端点 0)。此管道承载设备请求和相关数据。通过默认管道与其他管道之间的传输区别在于,传输的协议在 USB 规格中有所描述。这些请求用于重置和配置设备。第 9 章中提供了每个设备必须支持的一组基本命令。设备类规格可以扩展这些命令,以支持更多的功能。
批量管道:这是 USB 等同于原始传输介质的管道。
中断管道:主机向设备发送请求获取数据,如果设备没有数据可发送,则会对数据包进行 NAK(否定确认)。中断传输在创建管道时指定的频率下进行调度。
同步管道:这些管道用于同步数据,例如视频或音频流,具有固定的延迟,但不保证数据的交付。目前的实现中支持这种类型的管道。控制、批量和中断传输中的数据包在传输过程中如果发生错误或设备因缺乏存储接收数据的缓冲区而对数据包进行 NAK 时,会进行重试。然而,若同步数据包未成功交付或数据包被 NAK,系统不会重试,因为这可能违反时间约束。
必要带宽的可用性在创建管道时进行计算。传输在 1 毫秒的时间框架内进行调度。每个框架内的带宽分配由 USB 规格第 5.6 节规定。同步和中断传输可以使用框架带宽的最多 90%。控制和批量传输的数据包在所有同步和中断数据包之后调度,并将消耗剩余的带宽。
有关传输调度和带宽回收的更多信息,请参阅 USB 规格第 5 章、UHCI 规格第 1.3 节和 OHCI 规格第 3.4.2 节。
在集线器通知新设备连接后,服务层会打开端口,给设备提供 100 毫安的电流。此时,设备处于默认状态,并监听设备地址 0。服务层接着会通过默认管道检索各种描述符。之后,它会发送一个设置地址请求,将设备移出默认设备地址(地址 0)。多个设备驱动程序可能能够支持该设备。例如,一个调制解调器驱动程序可能能够通过 AT 兼容接口支持 ISDN TA(终端适配器)。然而,针对特定型号的 ISDN 适配器的驱动程序可能能为该设备提供更好的支持。为了支持这种灵活性,探测程序返回优先级,表示支持的级别。对特定产品版本的支持优先级最高,而通用驱动程序的优先级最低。如果一个配置中有多个接口,多个驱动程序也可能附加到同一个设备。每个驱动程序只需要支持接口的一个子集。
对于新连接设备的驱动程序探测,首先检查是否有设备特定的驱动程序。如果没有找到,探测代码会遍历所有支持的配置,直到有驱动程序在某个配置中附加。为了支持具有多个驱动程序的设备(位于不同的接口上),探测程序会遍历所有尚未被驱动程序占用的接口。超过集线器功率预算的配置会被忽略。在附加过程中,驱动程序应该初始化设备到正确的状态,但不应重置设备,因为这会导致设备断开与总线的连接,并重新启动探测过程。为了避免不必要的带宽消耗,驱动程序不应在附加时就占用中断管道,而应推迟分配该管道,直到文件被打开并且数据实际使用时。当文件关闭时,即使设备仍然附加,管道也应该被关闭。
设备驱动程序应预期在与设备的任何事务中接收到错误。USB 设计支持并鼓励设备在任何时间断开连接。驱动程序应确保在设备消失时执行正确的操作。
此外,已断开并重新连接的设备不会重新附加到相同的设备实例。未来,随着更多设备支持序列号(参见设备描述符)或其他定义设备身份的方法得到开发,这种情况可能会发生变化。
设备的断开连接通过集线器在传递给集线器驱动程序的中断数据包中信号传递。状态更改信息指示哪个端口发生了连接变化。为该端口连接的设备的所有设备驱动程序都会调用设备分离方法,并清理相应的数据结构。如果端口状态指示此时该端口上已连接了一个设备,将启动设备的探测和附加过程。设备重置将产生集线器上的断开-连接序列,按上述方式处理。
USB 规范未定义除默认管道外其他管道上的协议。有关此协议的信息可以通过各种渠道找到。最准确的来源是 USB 官方主页上的开发者部分。从这些页面上,越来越多的设备类规范可以获取。这些规范指定了合规设备从驱动程序角度应该具备的外观、需要提供的基本功能以及通信通道上使用的协议。USB 规范包括了集线器类的描述。为了满足键盘、平板、条形码阅读器、按钮、旋钮、开关等设备的需求,已经创建了人机接口设备(HID)类规范。另一个例子是大容量存储设备的类规范。有关完整的设备类列表,请参见 USB 官方主页上的开发者部分。
然而,对于许多设备,协议信息尚未发布。有关所使用协议的信息可能可以从制造该设备的公司获得。一些公司要求你在提供规范之前签署保密协议(NDA)。在大多数情况下,这意味着无法将驱动程序开源。
另一个有用的信息来源是 Linux 驱动源代码,因为许多公司已经开始为其设备提供 Linux 驱动程序。联系这些驱动程序的作者获取信息源是一个好主意。
示例:人机接口设备
像键盘、鼠标、平板、按钮、旋钮等人机接口设备的规范在其他设备类规范中有所提及,并且被许多设备使用。
例如,音频扬声器提供连接到数字-模拟转换器的端点,可能还会为麦克风提供额外的管道。它们还在一个单独的接口中为设备正面的按钮和旋钮提供 HID 端点。监视器控制类也是如此。通过现有的内核和用户空间库结合 HID 类驱动程序或通用驱动程序,构建这些接口的支持是直接的。另一个示例是内置传统鼠标端口的廉价键盘。为了避免将 USB 集线器硬件包含到设备中,制造商将从键盘后部 PS/2 端口接收到的鼠标数据与键盘按键压下的数据,合并成同一配置中的两个独立接口。鼠标和键盘驱动程序分别附加到相应的接口,并为这两个独立端点分配管道。
示例:固件下载
许多基于通用处理器的设备在其上添加了 USB 核心。由于 USB 设备的驱动程序和固件开发仍然非常新,许多设备在连接后需要下载固件。
其过程是直接的。设备通过供应商和产品 ID 来识别自己。第一个驱动程序探测并附加到设备,并将固件下载到设备中。之后,设备会自我重置,驱动程序被分离。稍作暂停后,设备会在总线上宣布其存在。设备会更改其供应商/产品/修订 ID,以反映其已加载固件的事实,因此第二个驱动程序会探测并附加到该设备。
这类设备的一个例子是基于 EZ-USB 芯片的 ActiveWire I/O 板。该芯片有一个通用固件下载器可用。下载到 ActiveWire 板中的固件会更改修订 ID,然后它将执行 EZ-USB 芯片 USB 部分的软重置,断开与 USB 总线的连接,并重新连接。
示例:大容量存储设备
对大容量存储设备的支持主要围绕现有协议构建。Iomega 的 USB Zipdrive 基于其驱动的 SCSI 版本。SCSI 命令和状态消息被封装在块中,通过 bulk 管道进行传输,从设备到主机和从主机到设备,模拟通过 USB 线的 SCSI 控制器。ATAPI 和 UFI 命令也以类似的方式得到支持。
大容量存储规范支持两种不同的命令块封装方式。最初的尝试是通过默认管道发送命令和状态,并使用 bulk 传输将数据在主机和设备之间传送。根据经验,设计了第二种方法,它基于封装命令和状态块,并通过 bulk out 和 in 端点发送。规范明确规定了必须在何时发生什么,以及在遇到错误条件时应该做什么。编写这些设备驱动程序时最大的问题是将基于 USB 的协议适配到现有的大容量存储设备支持中。CAM 提供了钩子,能够以相对直接的方式完成此操作。ATAPI 则更复杂,因为历史上 IDE 接口并未有很多不同的表现形式。
Y-E Data 的 USB 软盘支持则更不简单,因为设计了一个新的命令集。
特别感谢 Matthew N. Dodd、Warner Losh、Bill Paul、Doug Rabson、Mike Smith、Peter Wemm 和 Scott Long。
本章详细解释了 Newbus 设备框架。
设备驱动程序是一个软件组件,它提供了内核的外围设备通用视图(例如,磁盘、网络适配器)与外围设备实际实现之间的接口。*设备驱动程序接口(DDI)*是内核与设备驱动程序组件之间定义的接口。
在 UNIX® 和 FreeBSD 的早期,有四种定义的设备类型:
块设备驱动程序
字符设备驱动程序
网络设备驱动程序
虚拟设备驱动程序
块设备以使用固定大小的数据块的方式进行操作。这种类型的驱动程序依赖于所谓的 缓冲区缓存,它将访问过的数据块缓存在内存的专用部分中。通常,这个缓冲区缓存是基于写入延迟的,这意味着当数据在内存中被修改时,它会在系统进行定期磁盘刷新时同步到磁盘,从而优化写操作。
然而,从 FreeBSD 4.0 版本开始,块设备和字符设备之间的区别不再存在。
Newbus 是一种基于抽象层的新总线架构实现,首次出现在 FreeBSD 3.0 中,当时 Alpha 端口被引入到源代码树中。直到 4.0 版本,它才成为默认的设备驱动系统。其目标是为操作系统提供一种更面向对象的方式,用于连接主机系统提供的各种总线和设备。
它的主要特点包括:
动态附加
驱动程序的模块化简化
虚拟总线
其中一个最显著的变化是从扁平化和临时系统迁移到设备树布局。
在顶层是 “root” 设备,它是所有其他设备的父设备。对于每种架构,通常有一个“root”的子设备,它连接诸如 host-to-PCI 桥 等设备。对于 x86 架构,这个“root”设备是 “nexus” 设备。对于 Alpha 架构,不同型号的 Alpha 对应着不同的顶层设备,这些设备对应不同的硬件芯片组,包括 lca、apecs、cia 和 tsunami 等。
在 Newbus 的上下文中,设备表示系统中的单个硬件实体。例如,每个 PCI 设备由一个 Newbus 设备表示。系统中的任何设备都可以有子设备;具有子设备的设备通常被称为 “总线”。系统中常见的总线有 ISA 和 PCI,它们分别管理附加到 ISA 和 PCI 总线的设备列表。
不同种类的总线之间的连接通常由 “桥接” 设备表示,该设备通常具有一个附加总线的子设备。例如,PCI-to-PCI 桥 在父 PCI 总线上表示为设备 pcibN,并为附加的总线提供一个子设备 pciN。这种布局简化了 PCI 总线树的实现,使得可以使用通用代码处理顶层总线和桥接总线。
在 Newbus 架构中,每个设备都会请求其父设备来映射其资源。父设备会继续请求自己的父设备,直到达到 nexus。所以,基本上,nexus 是 Newbus 系统中唯一了解所有资源的部分。
技巧
一个 ISA 设备可能想要映射其 IO 端口
0x230
,它会请求其父设备,即 ISA 总线。ISA 总线将请求交给 PCI-to-ISA 桥接设备,后者再请求 PCI 总线,最终达到主机到 PCI 的桥接设备,最后到达 nexus。这个向上的过渡的优点是,可以在此过程中翻译请求。例如,在 MIPS 系统上,0x230
IO 端口请求可能会通过 PCI 桥接转换为内存映射地址0xb0000230
。
资源分配可以在设备树的任何位置进行控制。例如,在许多 Alpha 平台上,ISA 中断与 PCI 中断是分别管理的,ISA 中断的资源分配由 Alpha 的 ISA 总线设备管理。而在 IA-32 平台上,ISA 和 PCI 中断都由顶级的 nexus 设备管理。对于端口、内存和端口地址空间,IA-32 的资源由 nexus 管理,而 Alpha 上则由相关的芯片组驱动程序管理(例如,CIA 或 tsunami)。
为了规范化对内存和端口映射资源的访问,Newbus 集成了来自 NetBSD 的 bus_space
API。这些 API 提供了一个单一接口,取代了 inb/outb 和直接的内存读写操作。其优点是,单个驱动程序可以轻松地使用内存映射寄存器或端口映射寄存器(某些硬件同时支持两者)。
这种支持被集成到资源分配机制中。当分配资源时,驱动程序可以从资源中检索相关的 bus_space_tag_t
和 bus_space_handle_t
。
Newbus 还允许在专门的文件中定义接口方法。这些文件通常是 .m 文件,位于 src/sys 目录下。
Newbus 系统的核心是一个可扩展的“面向对象编程”模型。系统中的每个设备都有一个它支持的方法表。系统和其他设备使用这些方法来控制设备并请求服务。设备支持的不同方法由多个“接口”定义。“接口”是一组相关方法,可以由设备实现。
在 Newbus 系统中,设备的方法由系统中的各种设备驱动程序提供。当设备在 自动配置 过程中附加到驱动程序时,它使用驱动程序声明的方法表。设备可以稍后 脱离 驱动程序并 重新附加 到新的驱动程序,从而使用新的方法表。这允许动态替换驱动程序,这对于驱动程序开发非常有用。
接口由类似于文件系统中定义 vnode 操作的语言来描述。接口将存储在方法文件中(通常命名为 foo_if.m)。
示例 1. Newbus 方法
当这个接口被编译时,它会生成一个头文件 "foo_if.h",其中包含以下函数声明:
同时,还会创建一个源文件 "foo_if.c",该文件与自动生成的头文件一起使用;它包含这些函数的实现,这些实现会查找对象方法表中的相关函数并调用该函数。
系统定义了两个主要接口。第一个基础接口称为 "device",包括与所有设备相关的方法。"device" 接口中的方法包括 "probe"、"attach" 和 "detach" 用于控制硬件检测,以及 "shutdown"、"suspend" 和 "resume" 用于关键事件通知。
"bus" 接口中的许多方法为总线设备的某个子设备提供服务。这些方法通常使用前两个参数来指定提供服务的总线以及请求服务的子设备。为了简化驱动程序代码,许多这些方法具有访问函数,查找父设备并调用父设备上的方法。例如,方法 BUS_TEARDOWN_INTR(device_t dev, device_t child, …)
可以通过函数 bus_teardown_intr(device_t child, …)
调用。
系统中的某些总线类型定义了额外的接口,以提供对总线特定功能的访问。例如,PCI 总线驱动程序定义了 "pci" 接口,它具有两个方法 read_config
和 write_config
,用于访问 PCI 设备的配置寄存器。
由于 Newbus API 非常庞大,本节努力对其进行文档化。更多信息将在此文档的下一个版本中提供。
src/sys/[arch]/[arch] - 特定机器架构的内核代码位于此目录。例如,i386
架构或 SPARC64
架构。
src/sys/dev/[bus] - 特定 [bus]
的设备支持位于此目录。
src/sys/dev/pci - PCI 总线支持代码位于此目录。
src/sys/[isa|pci] - PCI/ISA 设备驱动程序位于此目录。PCI/ISA 总线支持代码曾经存在于此目录中,直到 FreeBSD 版本 4.0
。
devclass_t
- 这是指向 struct devclass
的指针类型定义。
device_method_t
- 这是与 kobj_method_t
相同的类型(请参见 src/sys/kobj.h)。
device_t
- 这是指向 struct device
的指针类型定义。device_t
代表系统中的一个设备。它是一个内核对象。有关实现细节,请参见 src/sys/sys/bus_private.h。
driver_t
- 这是引用 struct driver
的类型定义。driver
结构是 device
内核对象的一种类;它还保存驱动程序的私有数据。
driver_t 实现
device_state_t
类型,它是一个枚举,表示 Newbus 设备在自动配置过程前后的可能状态。
设备状态 _device_state_t
本章将介绍 FreeBSD 在 PCI 总线上编写设备驱动程序的机制。
这里介绍了 PCI 总线代码如何遍历未附加的设备,并查看新加载的 kld 是否会附加到它们中的任何一个。
如果将上述源文件和 Makefile 放入一个目录中,你可以运行 make
来编译示例驱动程序。还可以运行 make load
将驱动程序加载到当前正在运行的内核中,运行 make unload
在加载后卸载该驱动程序。
《PCI 系统架构》第四版,Tom Shanley 等著
FreeBSD 提供了一种面向对象的机制,用于从父总线请求资源。几乎所有设备都是某种总线(PCI、ISA、USB、SCSI 等)的子设备,这些设备需要从其父总线获取资源(如内存段、中断线或 DMA 通道)。
要对 PCI 设备执行任何特别有用的操作,你需要从 PCI 配置空间获取 基地址寄存器(BAR)。获取 BAR 的 PCI 特定细节在 bus_alloc_resource()
函数中被抽象化。
例如,一个典型的驱动程序可能在 attach()
函数中包含如下代码:
每个基地址寄存器的句柄保存在 softc
结构中,以便稍后用于写入设备。
这些句柄可以用来通过 bus_space_*
函数从设备寄存器读取或写入。例如,驱动程序可能包含一个简化函数,用于从特定寄存器读取数据,如下所示:
类似地,可以使用以下代码向寄存器写入数据:
这些函数有 8 位、16 位和 32 位版本,你应根据需要使用 bus_space_{read|write}_{1|2|4}
。
注意
在 FreeBSD 7.0 及更高版本中,你可以使用
bus_*
函数来代替bus_space_*
函数。bus_*
函数使用struct resource *
指针,而不是总线标签和总线句柄。因此,你可以删除softc
结构中的总线标签和总线句柄成员,并将board_read()
函数重写为:
中断从面向对象的总线代码中以类似于内存资源的方式分配。首先,必须从父总线分配一个 IRQ 资源,然后必须设置中断处理程序来处理这个 IRQ。
以下是来自设备 attach()
函数的一个示例,它比文字更能说明问题。
在驱动程序的 detach
例程中必须特别小心。你必须使设备的中断流保持安静,并移除中断处理程序。一旦 bus_teardown_intr()
返回,你就可以确定中断处理程序将不再被调用,且所有可能正在执行此中断处理程序的线程都已返回。由于此函数可以休眠,因此在调用此函数时,你不能持有任何互斥锁。
此部分已过时,仅供历史参考。正确的方法是使用 bus_space_dma*()
函数来处理这些问题。该段内容可以在更新为反映这些用法时删除。然而,目前,API 仍在变化中,因此一旦稳定下来,最好更新此部分以反映相关用法。
在 PC 上,想要进行总线主控 DMA 的外设必须处理物理地址。这个问题在于 FreeBSD 使用虚拟内存并几乎完全处理虚拟地址。幸运的是,存在一个名为 vtophys()
的函数来帮助解决这个问题。
然而,在 Alpha 架构上,解决方案有所不同,我们真正需要的是一个名为 vtobus()
的函数。
在 attach()
过程中分配的所有资源必须在驱动程序卸载时释放。即使在发生失败条件时,也必须小心地释放正确的资源,以确保系统在驱动程序退出时仍然可用。
本章介绍了编写 ISA 设备驱动程序时需要考虑的问题。这里呈现的伪代码相当详细,并且接近实际代码,但仍然只是伪代码。它避免了与讨论主题无关的细节。实际的示例可以在真实驱动程序的源代码中找到,特别是 ep
和 aha
驱动程序是很好的信息来源。
一个典型的 ISA 驱动程序需要以下包含文件:
这些文件描述了特定于 ISA 和通用总线子系统的内容。
总线子系统采用面向对象的方式实现,主要结构通过关联的方法函数进行访问。
ISA 驱动程序实现的总线方法列表与其他总线驱动程序类似。假设有一个名为 "xxx" 的驱动程序,其方法如下:
static void xxx_isa_identify (driver_t *, device_t);
通常用于总线驱动程序,而非设备驱动程序。但是对于 ISA 设备,这个方法可能有特殊用途:如果设备提供某种设备特定的(非 PnP)方式来自动检测设备,则该例程可以实现这一功能。
static int xxx_isa_probe (device_t dev);
在已知的(或 PnP)位置探测设备。该例程还可以适应设备特定的参数自动检测,适用于部分配置的设备。
static int xxx_isa_attach (device_t dev);
附加并初始化设备。
static int xxx_isa_detach (device_t dev);
在卸载驱动程序模块之前卸载设备。
static int xxx_isa_shutdown (device_t dev);
在系统关机之前执行设备的关闭操作。
static int xxx_isa_suspend (device_t dev);
在系统进入省电模式之前挂起设备。也可能中止进入省电模式的操作。
static int xxx_isa_resume (device_t dev);
在从省电状态恢复后恢复设备活动。
xxx_isa_probe()
和 xxx_isa_attach()
是必须实现的,其余方法根据设备的需求可以选择性实现。
驱动程序通过以下描述集与系统链接。
这里 struct xxx_softc
是一个设备特定的结构体,包含了私有的驱动程序数据和驱动程序资源的描述符。总线代码会根据需要自动分配每个设备的一个 softc 描述符。
如果驱动程序是作为可加载模块实现的,那么在加载或卸载驱动程序时会调用 load_function()
来进行驱动程序的初始化或清理操作,load_argument
会作为其参数之一传递。如果驱动程序不支持动态加载(即必须始终链接到内核中),则这些值应设置为 0,最后的定义应如下所示:
如果驱动程序是针对支持 PnP 的设备,则必须定义一个支持的 PnP ID 表。该表包含了此驱动程序支持的 PnP ID 列表以及带有这些 ID 的硬件类型和型号的可读描述。示例如下:
如果驱动程序不支持 PnP 设备,它仍然需要一个空的 PnP ID 表,如下所示:
device_t
指针device_t
是设备结构的指针类型。在这里,我们只考虑从设备驱动程序编写者角度来看有用的方法。用于操作设备结构中值的方法如下:
device_t device_get_parent(dev)
获取设备的父总线。
driver_t device_get_driver(dev)
获取指向设备驱动程序结构的指针。
char *device_get_name(dev)
获取驱动程序名称,例如我们示例中的 "xxx"
。
int device_get_unit(dev)
获取设备的单元号(设备的单元号从 0 开始,为每个驱动程序关联的设备编号)。
char *device_get_nameunit(dev)
获取包括单元号的设备名称,例如 "xxx0"、"xxx1" 等。
char *device_get_desc(dev)
获取设备描述。通常,它描述设备的确切型号,以人类可读的形式表示。
device_set_desc(dev, desc)
设置设备描述。此操作使设备描述指向字符串 desc
,并且该字符串在之后不能被释放或修改。
device_set_desc_copy(dev, desc)
设置设备描述。描述被复制到一个内部动态分配的缓冲区,因此字符串 desc
之后可以进行修改,而不会产生不良影响。
void *device_get_softc(dev)
获取与此设备关联的设备描述符(结构 xxx_softc
)的指针。
u_int32_t device_get_flags(dev)
获取设备在配置文件中指定的标志。
可以使用便利函数 device_printf(dev, fmt, …)
来打印设备驱动程序的消息。它会自动在消息前面加上单元名称和冒号。
device_t
方法在文件 kern/bus_subr.c 中实现。
ISA 设备在内核配置文件中描述如下:
端口、IRQ 等的值会被转换为与设备关联的资源值。它们是可选的,具体取决于设备的需求以及自动配置的能力。例如,某些设备根本不需要 DRQ,而一些设备允许驱动程序从设备的配置端口读取 IRQ 设置。如果机器有多个 ISA 总线,可以在配置行中指定精确的总线,如 isa0
或 isa1
,否则设备将在所有 ISA 总线上进行搜索。
sensitive
是一个资源标记,表示该设备必须在所有非敏感设备之前进行探测。它已被支持,但在当前的驱动程序中似乎没有使用。
对于传统的 ISA 设备,驱动程序在很多情况下仍能检测到配置参数。但系统中每个设备都必须有一个配置行。如果系统中安装了两台相同类型的设备,但只有一个对应驱动程序的配置行,例如:
但是对于支持通过 Plug-n-Play 或某些专有协议自动识别的设备,一行配置足以配置系统中的所有设备,如上所示,或者只需简单地写:
如果驱动程序同时支持自动识别和传统设备,并且两种设备都在同一台机器中安装,那么只需要在配置文件中描述传统设备即可。自动识别的设备会自动添加。
当一个 ISA 总线进行自动配置时,事件发生的顺序如下:
所有驱动程序的识别例程(包括用于识别所有 PnP 设备的 PnP 识别例程)会被随机顺序调用。当它们识别设备时,会将设备添加到 ISA 总线的设备列表中。通常,驱动程序的识别例程会将它们的驱动程序与新设备关联。PnP 识别例程还不知道其他驱动程序,因此它不会将任何驱动程序与它所添加的新设备关联。
PnP 设备会使用 PnP 协议进入睡眠状态,以防止它们作为传统设备被探测。
标记为 sensitive
的非 PnP 设备的探测例程会被调用。如果设备探测成功,将会调用该设备的附加例程。
所有非 PnP 设备的探测和附加例程会按同样的方式调用。
PnP 设备会从睡眠状态中恢复,并分配它们请求的资源:I/O 和内存地址范围、IRQ 和 DRQ,所有这些都与已附加的传统设备没有冲突。
然后,对于每个 PnP 设备,所有现有 ISA 驱动程序的探测例程都会被调用。第一个声明该设备的驱动程序会被附加。可能会有多个驱动程序以不同优先级声明同一个设备;在这种情况下,优先级最高的驱动程序胜出。探测例程必须调用 ISA_PNP_PROBE()
来比较实际的 PnP ID 和驱动程序支持的 ID 列表,如果该 ID 不在表中,则返回失败。这意味着每个驱动程序,甚至是那些不支持任何 PnP 设备的驱动程序,也必须调用 ISA_PNP_PROBE()
,至少用一个空的 PnP ID 表来返回不支持的 PnP 设备的失败。
探测例程在出错时返回正值(错误码),在成功时返回零或负值。
负返回值用于支持多个接口的 PnP 设备。例如,支持较旧兼容接口和新接口的设备,这两个接口由不同的驱动程序支持。在这种情况下,两个驱动程序都会探测到该设备。返回值较高的驱动程序优先(换句话说,返回 0 的驱动程序优先,返回 -1 的次之,返回 -2 的次之,以此类推)。结果是,仅支持旧接口的设备会由旧驱动程序(探测例程应返回 -1)处理,而支持新接口的设备会由新驱动程序(探测例程应返回 0)处理。如果多个驱动程序返回相同的值,那么先被调用的驱动程序获胜。因此,如果驱动程序返回值为 0,则可以确信它赢得了优先级竞争。
设备特定的识别例程还可以将设备与一类驱动程序而不是单个驱动程序关联。然后,所有该类中的驱动程序都会对该设备进行探测,就像 PnP 设备一样。但这种特性在现有的驱动程序中没有实现,本文件不再进一步讨论。
由于在探测传统设备时 PnP 设备被禁用,因此它们不会被附加两次(一次作为传统设备,一次作为 PnP 设备)。但是,对于设备特定的识别例程,确保同一设备不会被驱动程序附加两次是驱动程序的责任:一次作为传统的用户配置设备,另一次作为自动识别设备。
自动识别设备(无论是 PnP 设备还是设备特定的设备)的另一个实际影响是,内核配置文件中的标志不能传递给它们。因此,它们必须要么根本不使用标志,要么使用设备单元 0 的标志来处理所有自动识别的设备,或者使用 sysctl 接口代替标志。
其他不寻常的配置可以通过直接访问配置资源来处理,使用 resource_query_*()
和 resource_*_value()
函数族。这些函数的实现位于 kern/subr_bus.c 中。旧的 IDE 硬盘驱动程序 i386/isa/wd.c 包含了此类用法的示例。但始终应优先使用标准的配置方式,将配置资源的解析留给总线配置代码。
用户在内核配置文件中输入的信息将被处理并传递给内核作为配置资源。这些信息由总线配置代码解析,并转换为 device_t
结构及与之关联的总线资源。驱动程序可以使用 resource_*
函数直接访问配置资源,但通常情况下这并不必要,也不推荐这么做,因此此问题在此文中不再讨论。
总线资源与每个设备相关联。它们通过类型和类型内的编号进行标识。对于 ISA 总线,定义了以下几种类型:
SYS_RES_IRQ
- 中断号
SYS_RES_DRQ
- ISA DMA 通道号
SYS_RES_MEMORY
- 映射到系统内存空间的设备内存范围
SYS_RES_IOPORT
- 设备 I/O 寄存器的范围
在类型内的枚举从 0 开始,因此,如果一个设备有两个内存区域,它将拥有编号为 0 和 1 的 SYS_RES_MEMORY
类型资源。资源类型与 C 语言类型无关,所有资源值的 C 语言类型都是 unsigned long
,并且在需要时必须进行类型转换。资源编号不必是连续的,尽管在 ISA 中通常是连续的。ISA 设备的资源编号范围如下:
所有资源都表示为一个范围,包含开始值和计数。对于 IRQ 和 DRQ 资源,计数通常为 1。内存的值指的是物理地址。
可以对资源执行三种类型的操作:
设置/获取
分配/释放
激活/停用
设置操作为资源设置范围。分配操作保留请求的范围,确保没有其他驱动程序能够占用该范围(并检查是否已经有其他驱动程序占用该范围)。激活操作通过做必要的事情使得资源对驱动程序可用(例如,对于内存,它会映射到内核虚拟地址空间)。
用于操作资源的函数包括:
int bus_set_resource(device_t dev, int type, int rid, u_long start, u_long count)
设置资源的范围。如果成功返回 0,失败时返回错误码。通常,当 type
、rid
、start
或 count
的值超出允许的范围时,该函数才会返回错误。
dev - 驱动程序的设备
type - 资源类型,SYS_RES_*
rid - 类型内的资源编号(ID)
start, count - 资源范围
int bus_get_resource(device_t dev, int type, int rid, u_long *startp, u_long *countp)
获取资源的范围。如果成功返回 0,若资源尚未定义则返回错误码。
u_long bus_get_resource_start(device_t dev, int type, int rid)u_long bus_get_resource_count(device_t dev, int type, int rid)
方便的函数,仅获取开始位置或计数。如果出错,返回 0。因此,如果资源开始位置为 0 并且这是合法值之一,就无法分辨是值为 0 还是发生了错误。幸运的是,ISA 资源的附加驱动程序的起始值不可能为 0。
void bus_delete_resource(device_t dev, int type, int rid)
删除资源,使其变为未定义状态。
struct resource * bus_alloc_resource(device_t dev, int type, int *rid, u_long start, u_long end, u_long count, u_int flags)
分配一个资源范围,范围内的计数值未被其他人占用,且范围介于 start
和 end
之间。遗憾的是,这里不支持对齐。如果资源尚未设置,则会自动创建该资源。特殊值 start = 0
和 end = ~0
(所有位为 1)意味着之前通过 bus_set_resource()
设置的固定值必须被使用:start
和 count
作为它们自己,end = (start + count)
,如果资源之前没有定义,则会返回错误。尽管 rid
通过引用传递,但在 ISA 总线的资源分配代码中不会对其进行修改(其他总线可能使用不同的方式并修改它)。
标志是一个位图,调用者感兴趣的标志有:
RF_ACTIVE
- 在分配资源后,自动激活该资源。
RF_SHAREABLE
- 资源可以同时由多个驱动程序共享。
RF_TIMESHARE
- 资源可以被多个驱动程序共享时间段,即许多驱动程序可以同时分配资源,但每次只能由一个驱动程序激活。
返回 0 表示错误。分配的值可以通过返回的句柄使用 rhand_*()
方法获取。
int bus_release_resource(device_t dev, int type, int rid, struct resource *r)
释放资源,r
是由 bus_alloc_resource()
返回的句柄。成功时返回 0,失败时返回错误码。
int bus_activate_resource(device_t dev, int type, int rid, struct resource *r)int bus_deactivate_resource(device_t dev, int type, int rid, struct resource *r)
激活或停用资源。成功时返回 0,失败时返回错误码。如果资源是时间共享的,并且当前由另一个驱动程序激活,则返回 EBUSY
。
int bus_setup_intr(device_t dev, struct resource *r, int flags, driver_intr_t *handler, void *arg, void **cookiep)int bus_teardown_intr(device_t dev, struct resource *r, void *cookie)
将中断处理程序与设备关联或取消关联。成功时返回 0,失败时返回错误码。
r
- 激活资源句柄,描述 IRQ。
flags
- 中断优先级级别,以下之一:
INTR_TYPE_TTY
- 终端和其他类似字符类型的设备。使用 spltty()
来屏蔽它们。
(INTR_TYPE_TTY | INTR_TYPE_FAST)
- 具有小输入缓冲区的终端设备,输入时数据丢失非常关键(例如旧式串口)。使用 spltty()
来屏蔽它们。
INTR_TYPE_BIO
- 块类型设备,除了 CAM 控制器上的设备。使用 splbio()
来屏蔽它们。
INTR_TYPE_CAM
- CAM(通用访问方法)总线控制器。使用 splcam()
来屏蔽它们。
INTR_TYPE_NET
- 网络接口控制器。使用 splimp()
来屏蔽它们。
INTR_TYPE_MISC
- 杂项设备。没有其他方式来屏蔽它们,只有通过 splhigh()
,它会屏蔽所有中断。
当一个中断处理程序执行时,所有与其优先级匹配的中断将被屏蔽。唯一的例外是 MISC 级别的中断,它不会屏蔽任何其他中断,并且不会被任何其他中断屏蔽。
handler
- 指向处理程序函数的指针,driver_intr_t
类型定义为 void driver_intr_t(void *)
。
arg
- 传递给处理程序的参数,用于标识该特定设备。处理程序会将其从 void*
强制转换为实际类型。旧的 ISA 中断处理程序约定是使用单元号作为参数,新的(推荐的)约定是使用指向设备 softc
结构的指针。
cookie[p]
- 从 setup()
返回的值,用于在 teardown()
时标识处理程序。
有许多方法可以操作资源处理程序(struct resource *
)。对设备驱动程序作者感兴趣的方法包括:
u_long rman_get_start(r) u_long rman_get_end(r)
获取已分配资源范围的起始和结束位置。
void *rman_get_virtual(r)
获取激活的内存资源的虚拟地址。
在许多情况下,数据通过内存在驱动程序和设备之间交换。有两种可能的变体:
(a) 内存位于设备卡上
(b) 内存是计算机的主内存
在情况 (a) 中,驱动程序会根据需要将数据来回复制到设备卡上的内存和主内存之间。为了将设备卡上的内存映射到内核虚拟地址空间中,必须将设备卡上内存的物理地址和长度定义为 SYS_RES_MEMORY
资源。然后可以分配并激活该资源,通过 rman_get_virtual()
获取其虚拟地址。旧版驱动程序使用 pmap_mapdev()
函数来实现这一目的,但现在应该避免直接使用该函数。现在,它是资源激活的内部步骤之一。
大多数 ISA 卡的内存会被配置在物理地址范围 640KB 到 1MB 之间。某些 ISA 卡需要更大的内存范围,这些内存应放置在 16MB 以下(因为 ISA 总线的 24 位地址限制)。在这种情况下,如果计算机的内存大于设备内存的起始地址(换句话说,它们重叠了),则必须在设备使用的地址范围内配置内存孔。许多 BIOS 允许配置一个从 14MB 或 15MB 开始的 1MB 的内存孔。如果 BIOS 正确报告这些信息,FreeBSD 可以正确处理内存孔(旧版 BIOS 上可能存在问题)。
在情况 (b) 中,仅将数据的地址发送到设备,设备通过 DMA 来实际访问主内存中的数据。此情况下有两个限制:首先,ISA 卡只能访问 16MB 以下的内存。其次,虚拟地址空间中的连续页面在物理地址空间中可能不是连续的,因此设备可能需要进行散布/聚集操作。总线子系统为其中一些问题提供了现成的解决方案,其余的问题则需要驱动程序自行处理。
DMA 内存分配使用两个结构:bus_dma_tag_t
和 bus_dmamap_t
。bus_dma_tag_t
描述了 DMA 内存所需的属性,bus_dmamap_t
代表一个根据这些属性分配的内存块。多个映射可以与同一个标签相关联。
标签按树状层次结构组织,支持属性的继承。子标签继承父标签的所有要求,并且可以使这些要求更严格,但不能使其更宽松。
通常为每个设备单元创建一个顶级标签(没有父标签)。如果每个设备需要多个具有不同要求的内存区域,则可以为每个内存区域创建一个作为父标签子标签的标签。
标签可以通过两种方式创建映射:
第一种方法是分配符合标签要求的连续内存块(稍后可以释放)。通常用于为与设备通信分配相对长生命周期的内存区域。将这种内存加载到映射中是简单的:它始终被视为适当物理内存范围内的一个块。
第二种方法是将任意虚拟内存区域加载到映射中。此内存的每一页将检查是否符合映射要求。如果符合要求,则保持在其原始位置。如果不符合要求,则分配一个新的符合要求的“跃点页面”并用作中间存储。在将数据从不符合要求的原始页面写入时,它们会先被复制到跃点页面,然后从跃点页面传输到设备。当读取数据时,数据会从设备传输到跃点页面,然后再复制到不符合要求的原始页面。这种在原始页面和跃点页面之间复制的过程称为同步。通常这是按每次传输进行的:每次传输的缓冲区都会被加载、传输完成后缓冲区被卸载。
DMA 内存操作相关的函数如下:
int bus_dma_tag_create(bus_dma_tag_t parent, bus_size_t alignment, bus_size_t boundary, bus_addr_t lowaddr, bus_addr_t highaddr, bus_dma_filter_t *filter, void *filterarg, bus_size_t maxsize, int nsegments, bus_size_t maxsegsz, int flags, bus_dma_tag_t *dmat)
创建一个新的标签。成功返回 0,失败返回错误代码。
parent - 父标签,如果为 NULL,则创建顶级标签。
alignment - 为此标签分配的内存区域要求的物理对齐方式。若无特定对齐要求,则使用值 1。仅适用于未来的 bus_dmamem_alloc()
,而不适用于 bus_dmamap_create()
调用。
boundary - 分配内存时不能跨越的物理地址边界。若没有边界要求,则使用值 0。仅适用于未来的 bus_dmamem_alloc()
,而不适用于 bus_dmamap_create()
调用。该值必须是 2 的幂。如果内存计划在非级联 DMA 模式下使用(即 DMA 地址不会由设备本身提供,而是由 ISA DMA 控制器提供),则由于 DMA 硬件的限制,边界不能大于 64KB(64 * 1024)。
lowaddr, highaddr - 这两个名称有点误导;这些值用于限制分配内存时允许使用的物理地址范围。具体含义取决于未来的使用方式:
对于 bus_dmamem_alloc()
,所有从 0 到 lowaddr-1 的地址都被视为允许的,较高的地址则被禁止。
对于 bus_dmamap_create()
,所有在区间 [lowaddr, highaddr] 之外的地址都被视为可访问。区间内的页面地址会传递给过滤函数,由它决定是否可访问。如果没有提供过滤函数,则整个范围被视为不可访问。
对于 ISA 设备,正常的值(无过滤函数时)为:
lowaddr = BUS_SPACE_MAXADDR_24BIT
highaddr = BUS_SPACE_MAXADDR
filter, filterarg - 过滤函数及其参数。如果传递 NULL,则 bus_dmamap_create()
时整个范围 [lowaddr, highaddr] 会被视为不可访问。否则,将会传递每个在 [lowaddr, highaddr] 范围内的页面物理地址给过滤函数,由它判断该页面是否可访问。过滤函数的原型为:int filterfunc(void *arg, bus_addr_t paddr)
。如果页面可访问,则返回 0,否则返回非 0 值。
maxsize - 通过此标签分配的内存的最大大小(以字节为单位)。如果很难估算或可能是任意大的,则 ISA 设备的值应为 BUS_SPACE_MAXSIZE_24BIT
。
nsegments - 设备支持的最大散布/聚集段数。如果没有限制,则应使用 BUS_SPACE_UNRESTRICTED
值。推荐为父标签使用此值,实际的限制会在子标签中指定。nsegments 等于 BUS_SPACE_UNRESTRICTED
的标签不能用于实际加载映射,它们只能作为父标签使用。nsegments 的实际限制似乎约为 250-300,较高的值会导致内核栈溢出(而硬件通常也无法支持这么多的散布/聚集缓冲区)。
maxsegsz - 设备支持的最大散布/聚集段大小。ISA 设备的最大值为 BUS_SPACE_MAXSIZE_24BIT
。
flags - 标志的位图。唯一感兴趣的标志是:
BUS_DMA_ALLOCNOW - 请求在创建标签时分配所有可能需要的跃点页面。
dmat - 存储新标签的指针。
int bus_dma_tag_destroy(bus_dma_tag_t dmat)
销毁标签。成功返回 0,失败返回错误代码。
dmat - 要销毁的标签。
int bus_dmamem_alloc(bus_dma_tag_t dmat, void** vaddr, int flags, bus_dmamap_t *mapp)
分配标签描述的连续内存区域。要分配的内存大小为标签的 maxsize
。成功返回 0,失败返回错误代码。结果仍需通过 bus_dmamap_load()
加载,以便获取内存的物理地址。
dmat - 标签
vaddr - 存储分配的内核虚拟地址的指针。
flags - 标志的位图。唯一感兴趣的标志是:
BUS_DMA_NOWAIT - 如果内存不可用立即返回错误。如果未设置此标志,则该例程可以在内存可用时休眠。
mapp - 存储新映射的指针。
继续 DMA 内存操作的函数说明:
void bus_dmamem_free(bus_dma_tag_t dmat, void *vaddr, bus_dmamap_t map)
释放通过 bus_dmamem_alloc()
分配的内存。目前,对于具有 ISA 限制的内存分配,释放操作尚未实现。因此,建议的使用模式是尽可能长时间地保留并重用分配的内存区域。避免轻率地释放某个区域,然后很快重新分配该区域。这并不意味着 bus_dmamem_free()
应该完全不使用:希望它会尽快正确实现。
dmat - 标签
vaddr - 内存的内核虚拟地址
map - 内存的映射(由 bus_dmamem_alloc()
返回)
int bus_dmamap_create(bus_dma_tag_t dmat, int flags, bus_dmamap_t *mapp)
为标签创建一个映射,以便以后在 bus_dmamap_load()
中使用。成功返回 0,失败返回错误代码。
dmat - 标签
flags - 理论上是标志的位图,但目前没有定义任何标志,因此现在始终为 0。
mapp - 用于返回新映射的存储指针
int bus_dmamap_destroy(bus_dma_tag_t dmat, bus_dmamap_t map)
销毁一个映射。成功返回 0,失败返回错误代码。
dmat - 与映射关联的标签
map - 要销毁的映射
int bus_dmamap_load(bus_dma_tag_t dmat, bus_dmamap_t map, void *buf, bus_size_t buflen, bus_dmamap_callback_t *callback, void *callback_arg, int flags)
将缓冲区加载到映射中(映射必须是通过 bus_dmamap_create()
或 bus_dmamem_alloc()
先前创建的)。缓冲区的所有页面都会检查是否符合标签要求,对于不符合要求的页面,会分配跃点页面。一个物理段描述符数组将被构建并传递给回调函数。回调函数应以某种方式处理它。如果跃点缓冲区需要但暂时不可用,请求将被排队,并且当跃点缓冲区变为可用时,回调函数会被调用。如果回调函数立即执行,返回 0;如果请求已排队,返回 EINPROGRESS
,此时驱动程序负责与排队的回调函数进行同步。
dmat - 标签
map - 映射
buf - 缓冲区的内核虚拟地址
buflen - 缓冲区的长度
callback, callback_arg
- 回调函数及其参数
回调函数的原型为:void callback(void *arg, bus_dma_segment_t *seg, int nseg, int error)
arg - 与 callback_arg
相同
seg - 段描述符的数组
nseg - 数组中的描述符数量
error - 段号溢出的指示:如果设置为 EFBIG
,则说明缓冲区没有适应标签允许的最大段数。在这种情况下,数组中将仅包含允许的描述符数。如何处理这种情况取决于驱动程序:根据期望的语义,驱动程序可以将其视为错误,或者将缓冲区分成两部分并分别处理第二部分。
每个段描述符数组项包含以下字段:
ds_addr - 段的物理总线地址
ds_len - 段的长度
void bus_dmamap_unload(bus_dma_tag_t dmat, bus_dmamap_t map)
卸载映射。
dmat - 标签
map - 已加载的映射
void bus_dmamap_sync(bus_dma_tag_t dmat, bus_dmamap_t map, bus_dmasync_op_t op)
在物理数据传输前后,同步已加载的缓冲区与其跃点页面。这是执行原始缓冲区和映射版本之间数据复制的函数。缓冲区在传输前后都必须同步。
dmat - 标签
map - 已加载的映射
op - 要执行的同步操作类型:
BUS_DMASYNC_PREREAD
- 在从设备读取到缓冲区之前
BUS_DMASYNC_POSTREAD
- 在从设备读取到缓冲区之后
BUS_DMASYNC_PREWRITE
- 在将缓冲区写入设备之前
BUS_DMASYNC_POSTWRITE
- 在将缓冲区写入设备之后
关于 DMA 映射和缓冲区的管理,以下是一些重要细节和实践注意事项:
当前 BUS_DMASYNC_PREREAD
和 BUS_DMASYNC_POSTWRITE
是空操作,但将来可能会改变。因此,驱动程序中不应忽略这些操作,即使它们现在不执行任何操作。特别地,内存通过 bus_dmamem_alloc()
获取的情况下,不需要同步操作。
在调用 bus_dmamap_load()
的回调函数之前,段数组会被存储在栈上,并为标签允许的最大段数进行预分配。因此,i386 架构上支持的最大段数大约是 250-300(内核栈为 4KB,减去用户结构的大小,每个段数组项的大小为 8 字节,还需要留出一些空间)。因此,实际允许的段数应根据需求设置,避免过高。由于这个数组是基于最大段数进行分配的,因此这个最大值不能设置得过大。幸运的是,对于大多数硬件而言,最大支持的段数要低得多。如果驱动程序需要处理大量散射-聚集段的缓冲区,应分批加载缓冲区:加载缓冲区的一部分,传输到设备,然后加载下一部分,以此类推。
如果缓冲区的所有页面都是物理上不连续的,那么该缓冲区的最大支持大小将受到段数的限制。例如,如果最大支持 10 个段,则 i386 上最大保证支持的缓冲区大小为 40K。如果需要更大的缓冲区大小,驱动程序应该使用一些特殊的技巧来处理。如果硬件不支持散射-聚集(scatter-gather),或者驱动程序想要支持一个即使是高度碎片化的缓冲区大小,解决方案是驱动程序分配一个连续的缓冲区,并在原始缓冲区不适配时作为中间存储使用。
对于在设备附加和分离之间几乎保持不变的缓冲区,典型的调用序列如下:
对于频繁变化并且从外部传入的缓冲区,调用序列如下:
当使用 bus_dmamem_alloc()
创建的映射时,传递的缓冲区地址和大小必须与 bus_dmamem_alloc()
中使用的完全相同。在这种情况下,可以保证整个缓冲区将作为一个单一的段进行映射(因此回调可以基于此假设进行),并且请求将立即执行(不会返回 EINPROGRESS
)。在这种情况下,回调函数只需要保存物理地址即可。
典型示例如下:
看起来有点长且复杂,但这是正确的方法。实际的后果是:如果多个内存区域总是一起分配,最好将它们都合并成一个结构体并作为一个整体进行分配(前提是对齐和边界的限制允许这样做)。
当将一个任意的缓冲区加载到通过 bus_dmamap_create()
创建的映射时,如果回调可能会延迟,必须采取特殊措施进行同步。代码看起来如下:
请求处理的两种可能方法:
如果通过显式标记请求已完成(例如 CAM 请求),那么将所有进一步的处理放入回调驱动程序中会更简单,回调完成时会标记请求为已完成。然后不需要太多额外的同步工作。出于流控的考虑,最好在请求完成之前冻结请求队列。
如果请求在函数返回时完成(例如字符设备的经典读写请求),则应在缓冲区描述符中设置同步标志,并调用 tsleep()
。稍后当回调被调用时,它将执行处理并检查此同步标志。如果标志已设置,则回调函数应发出唤醒信号。在这种方法中,回调函数可以执行所有必要的处理(就像前一种情况一样),也可以仅仅将段数组保存到缓冲区描述符中。然后在回调完成后,调用函数可以使用这个保存的段数组并完成所有的处理。
直接内存访问(DMA)通过 DMA 控制器在 ISA 总线上实现(实际上有两个 DMA 控制器,但这是一个无关的细节)。为了使早期的 ISA 设备简单且便宜,公交控制和地址生成的逻辑集中在 DMA 控制器中。幸运的是,FreeBSD 提供了一组函数,基本上隐藏了设备驱动程序需要处理的 DMA 控制器的细节。
最简单的情况是对于相当智能的设备。例如,像 PCI 上的总线主设备,它们能够自己生成总线周期和内存地址。它们实际上只需要 DMA 控制器提供总线仲裁。因此,它们假装是级联的从 DMA 控制器。系统 DMA 控制器所需的唯一操作是在 DMA 通道上启用级联模式,可以通过在附加驱动时调用以下函数来实现:
void isa_dmacascade(int channel_number)
所有进一步的活动都由设备编程完成。卸载驱动时,不需要调用任何与 DMA 相关的函数。
对于较简单的设备,事情变得更复杂。使用的函数包括:
int isa_dma_acquire(int channel_number)
保留一个 DMA 通道。如果通道已被当前驱动程序或其他驱动程序保留,则返回 EBUSY,成功时返回 0。大多数 ISA 设备无法共享 DMA 通道,因此通常在附加设备时调用此函数。尽管现代总线资源接口使得这个保留操作变得冗余,但它仍然必须在后者之前使用。如果未使用此函数,后续的其他 DMA 函数将导致系统 panic。
int isa_dma_release(int channel_number)
释放先前保留的 DMA 通道。在释放通道时,不能有正在进行的传输(此外,设备在释放通道后也不能尝试发起传输)。
void isa_dmainit(int chan, u_int bouncebufsize)
为指定的通道分配一个跳跃缓冲区。请求的缓冲区大小不能超过 64KB。如果传输缓冲区不连续,或者不在 ISA 总线可访问的内存范围内,或者跨越 64KB 边界时,此跳跃缓冲区将在稍后自动使用。如果传输总是从符合这些条件的缓冲区(如通过 bus_dmamem_alloc()
分配的缓冲区,并且具有适当的限制)进行,则不必调用 isa_dmainit()
。但对于通过 DMA 控制器传输任意数据而言,调用此函数是非常方便的。跳跃缓冲区将自动处理散布-聚集问题。
chan - 通道号
bouncebufsize - 跳跃缓冲区的大小(以字节为单位)
void isa_dmastart(int flags, caddr_t addr, u_int nbytes, int chan)
准备开始 DMA 传输。在实际开始设备上的传输之前,必须调用此函数来设置 DMA 控制器。它检查缓冲区是否连续,并且是否位于 ISA 内存范围内,如果不是,则自动使用跳跃缓冲区。如果需要跳跃缓冲区,但未通过 isa_dmainit()
设置,或者跳跃缓冲区太小以适应请求的传输大小,系统将 panic。在写请求的情况下,数据将自动复制到跳跃缓冲区。
flags - 用于确定要执行的操作类型的位掩码。方向位 B_READ 和 B_WRITE 是互斥的。
B_READ - 从 ISA 总线读取到内存
B_WRITE - 从内存写入到 ISA 总线
B_RAW - 如果设置,DMA 控制器将在传输结束后记住缓冲区,并自动重新初始化自己,以便再次传输相同的缓冲区(当然,驱动程序可以在开始设备传输之前更改缓冲区中的数据)。如果未设置,则这些参数仅适用于一次传输,isa_dmastart()
必须在启动下一次传输之前再次调用。使用 B_RAW 仅在没有使用跳跃缓冲区时才有意义。
addr - 缓冲区的虚拟地址
nbytes - 缓冲区的长度。必须小于或等于 64KB。不允许长度为 0:DMA 控制器会将其理解为 64KB,而内核代码则会将其理解为 0,这将导致不可预测的效果。对于编号为 4 及更高的通道,长度必须是偶数,因为这些通道每次传输 2 个字节。如果长度为奇数,最后一个字节将不会被传输。
chan - 通道号
void isa_dmadone(int flags, caddr_t addr, int nbytes, int chan)
在设备报告传输完成后,同步内存。如果这是一次读取操作并且使用了跳跃缓冲区,那么数据将从跳跃缓冲区复制到原始缓冲区。参数与 isa_dmastart()
相同。B_RAW 标志是允许的,但它不会以任何方式影响 isa_dmadone()
。
int isa_dmastatus(int channel_number)
返回当前传输中剩余的字节数。如果在 isa_dmastart()
中设置了 B_READ 标志,返回的数字将永远不会等于 0。传输结束时,它将自动重置为缓冲区的长度。正常使用是检查在设备信号表明传输已完成后,剩余的字节数。如果剩余字节数不为 0,则可能是传输出了问题。
int isa_dmastop(int channel_number)
中止当前传输并返回剩余未传输的字节数。
此函数探测设备是否存在。如果驱动程序支持自动检测设备配置的某些部分(如中断向量或内存地址),则必须在此例程中进行自动检测。
与任何其他总线一样,如果设备无法检测到,或者检测到设备但自检失败或发生其他问题,则返回正值错误。如果设备不存在,则必须返回 ENXIO
。其他错误值可能表示其他情况。零或负值表示成功。大多数驱动程序返回零表示成功。
负值返回用于支持多个接口的 PnP 设备。例如,支持较旧的兼容接口和较新的高级接口的设备,分别由不同的驱动程序支持。然后,两个驱动程序都将检测设备。返回探测例程中较高值的驱动程序优先(换句话说,返回 0 的驱动程序优先,其次是返回 -1 的,之后是返回 -2 的,依此类推)。因此,仅支持旧接口的设备将由旧驱动程序处理(该驱动程序应该从探测例程返回 -1),而同时支持新接口的设备将由新驱动程序处理(该驱动程序应该从探测例程返回 0)。
设备描述符结构 xxx_softc
在调用探测例程之前由系统分配。如果探测例程返回错误,系统会自动释放该描述符。因此,如果发生探测错误,驱动程序必须确保在探测期间使用的所有资源都已释放,并且没有任何东西阻止描述符被安全地释放。如果探测成功完成,描述符将由系统保留,并在稍后传递给 xxx_isa_attach()
例程。如果驱动程序返回负值,它不能确保自己具有最高的优先级,其附加例程将被调用。因此,在这种情况下,它还必须在返回之前释放所有资源,并在必要时在附加例程中重新分配它们。当 xxx_isa_probe()
返回 0 时,返回之前释放资源也是一个好主意,表现良好的驱动程序应该这么做。但在某些释放资源时存在问题的情况下,驱动程序可以在从探测例程返回 0 和执行附加例程之间保持资源。
一个典型的探测例程开始时获取设备描述符和单元:
接着检查是否为 PnP 设备。检查通过一个表格进行,表格包含该驱动程序支持的 PnP ID 列表,以及与这些 ID 对应的设备模型的人类可读描述。
ISA_PNP_PROBE
的逻辑如下:如果该卡(设备单元)没有被检测为 PnP,则返回 ENOENT。如果它被检测为 PnP,但其检测到的 ID 与表格中的任何 ID 都不匹配,则返回 ENXIO。最后,如果它具有 PnP 支持并且与表格中的某个 ID 匹配,则返回 0,并且通过 device_set_desc()
设置表格中的相应描述。
如果驱动程序仅支持 PnP 设备,则条件如下:
对于不支持 PnP 的驱动程序,则不需要特殊处理,因为它们传递一个空的 PnP ID 表格,并且在对 PnP 卡调用时总是会返回 ENXIO。
探测例程通常至少需要一些基本的资源集,如 I/O 端口号,用于找到并探测卡。根据硬件的不同,驱动程序可能能够自动发现其他必要的资源。PnP 设备的所有资源都由 PnP 子系统预设,因此驱动程序无需自行发现它们。
通常,访问设备所需的最小信息是 I/O 端口号。然后,一些设备允许从设备的配置寄存器中获取其余的信息(尽管并非所有设备都这样做)。因此,首先我们尝试获取端口起始值:
基址端口地址保存在结构 softc
中供以后使用。如果该端口地址会被频繁使用,那么每次调用资源函数会非常慢。我们如果没有获取到端口地址,就返回错误。一些设备驱动程序可以更聪明地尝试探测所有可能的端口,像这样:
当然,通常应该使用驱动程序的 identify()
函数来处理此类事情。但有一个有效的原因说明为什么在 probe()
中处理可能更好:如果这个探测过程会使其他敏感设备出现问题。探测例程是按 sensitive
标志排序的:敏感设备首先被探测,其他设备随后被探测。但是,identify()
例程在任何探测之前就会被调用,因此它们不会考虑敏感设备,并且可能会扰乱它们。
现在,在我们获取到起始端口后,我们需要设置端口计数(非 PnP 设备除外),因为内核在配置文件中没有这个信息。
最后,分配并激活一块端口地址空间(起始和结束的特殊值意味着“使用我们通过 bus_set_resource()
设置的值”):
现在,拥有访问端口映射寄存器的权限后,我们可以以某种方式与设备进行交互,并检查它是否按预期作出反应。如果没有响应,则可能是此地址处有其他设备,或者根本没有设备。
通常,驱动程序不会在 attach
例程之前设置中断处理程序。相反,它们会使用 DELAY()
函数在轮询模式下进行探测并设置超时。探测例程永远不能无限期挂起,所有对设备的等待必须使用超时机制。如果设备在超时时间内没有响应,那么它可能已经损坏或配置错误,驱动程序必须返回错误。在确定超时间隔时,给设备一些额外的时间以确保安全:尽管 DELAY()
在任何机器上都应该延迟相同的时间,但它有一定的误差范围,具体取决于 CPU 的不同。
函数 xxx_probe_ports()
还可能根据其发现的具体设备型号设置设备描述。但如果只有一个支持的设备型号,可以硬编码来实现。当然,对于 PnP 设备,PnP 支持会自动从表中设置描述。
然后,探测例程应该通过读取设备配置寄存器来发现所有资源的范围,或者确保它们已经由用户明确设置。我们将通过一个板载内存的例子来考虑它。探测例程应该尽可能不具侵入性,因此资源的其余部分(除了端口)的分配和功能检查最好留给 attach
例程来处理。
内存地址可以在内核配置文件中指定,或者在某些设备上它可能已经预先配置在非易失性配置寄存器中。如果两种来源都可用且不同,应该使用哪一种?可能如果用户在内核配置文件中明确设置了地址,他们知道自己在做什么,因此应该优先使用此地址。实现的示例可能是:
IRQ 和 DRQ 的资源检查可以通过类比来进行。
最后,处理麻烦的情况。在返回之前,所有资源应该被释放。我们利用了这样的事实:在结构 softc
传递给我们之前,它会被清零,所以我们可以发现是否分配了某些资源:如果有分配的资源,其描述符不为零。
这就是探测例程的所有内容。资源的释放在多个地方进行,因此它被移到一个函数中,函数代码可能如下所示:
attach
例程实际上将驱动程序连接到系统,如果探测例程返回成功且系统选择附加该驱动程序。如果探测例程返回 0,那么 attach
例程可以期望接收到设备结构 softc
,并且它的内容应当与探测例程设置的一致。此外,如果探测例程返回 0,它还可以期望该设备的 attach
例程将在未来某个时刻被调用。如果探测例程返回负值,则驱动程序不应作出这些假设。
attach
例程返回 0 表示成功,否则返回错误代码。
attach
例程开始的方式与探测例程类似,首先将一些常用的数据存储到更易访问的变量中。
然后分配并激活所有必要的资源。由于通常端口范围会在返回探测例程时被释放,因此必须再次分配它。我们期望探测例程已经正确设置了所有资源范围,并将它们保存在结构 softc
中。如果探测例程已经分配了某些资源,则不需要再次分配(如果重新分配,则视为错误)。
DMA 请求通道(DRQ)的分配方法类似。要初始化它,可以使用 isa_dma*()
系列函数。例如:
中断请求行(IRQ)稍微特殊一些。除了分配外,驱动程序的中断处理程序应该与其关联。历史上,在旧的 ISA 驱动程序中,系统传递给中断处理程序的参数是设备的单元号。但在现代驱动程序中,约定是传递指向 softc
结构的指针。这样做的一个重要原因是,当 softc
结构动态分配时,从 softc
获取单元号很容易,而从单元号获取 softc
却很困难。此外,这种约定使得不同总线的驱动程序看起来更加统一,并且能够共享代码:每个总线有自己的探测、附加、分离和其他总线特定的例程,而大部分驱动程序代码可以在它们之间共享。
这样完成了资源分配并设置了中断处理程序。
如果设备需要对主内存进行 DMA 操作,则应像之前描述的那样分配内存:
在分配了所有必要的资源之后,设备应进行初始化。初始化可能包括测试所有预期功能是否正常工作。
总线子系统会自动在控制台上打印探测过程中设置的设备描述信息。但是,如果驱动程序希望打印一些额外的设备信息,它也可以这么做,例如:
如果初始化过程中遇到任何问题,建议在返回错误之前打印相关信息。
附加例程的最终步骤是将设备附加到内核中的其功能子系统。具体如何操作取决于驱动程序的类型:字符设备、块设备、网络设备、CAM SCSI 总线设备等。
如果一切顺利,则返回成功:
最后,处理那些棘手的情况。在返回错误之前,所有资源都应该被释放。我们利用结构 softc 在传递给我们之前会被清零的事实,因此可以通过检查其描述符是否为非零值来判断是否已分配某些资源。
这就是附加例程的全部内容。
如果此函数存在且驱动程序被编译为可加载模块,则该驱动程序可以被卸载。这个功能在硬件支持热插拔时非常重要,但 ISA 总线不支持热插拔,因此这个功能对于 ISA 设备来说并不是特别重要。卸载驱动程序的能力在调试时可能有用,但在许多情况下,仅在旧版本驱动程序某种方式卡住系统并且需要重新启动的情况下,才需要安装新版本的驱动程序,因此花费时间编写卸载例程可能不值得。另一个认为卸载可以在生产机器上升级驱动程序的论点似乎大多是理论性的。在生产机器上执行安装新版本驱动程序的操作是危险的操作,应当避免(并且在系统处于安全模式时是不允许的)。尽管如此,为了完整性,仍然可以提供卸载例程。
卸载例程返回 0 表示驱动程序已成功卸载,或者返回错误码。
卸载的逻辑是附加例程的镜像。首先要做的是将驱动程序从内核子系统中分离。如果设备当前正在打开,那么驱动程序有两个选择:拒绝卸载或强制关闭并继续卸载。选择哪种方式取决于特定内核子系统是否支持强制关闭以及驱动程序作者的偏好。通常情况下,强制关闭似乎是更为常见的选择。
接下来,驱动程序可能希望将硬件重置到某个一致的状态。这包括停止任何正在进行的传输,禁用 DMA 通道和中断,以避免设备对内存造成损坏。对于大多数驱动程序来说,这正是关闭例程所执行的操作,因此如果在驱动程序中包含了它,我们可以直接调用它。
最后释放所有资源并返回成功:
当系统即将关闭时,调用此例程。它期望将硬件置于某个一致的状态。对于大多数 ISA 设备来说,不需要特别的操作,因为设备会在重新启动时重新初始化。然而,某些设备需要通过特殊的程序进行关闭,以确保它们在软重启后能被正确检测(尤其是许多设备使用专有的识别协议)。无论如何,禁用 DMA 和中断并停止正在进行的传输是一个好主意。具体操作取决于硬件,因此我们在此不做详细讨论。
当接收到中断时,若中断来自该特定设备,则会调用中断处理程序。ISA 总线不支持中断共享(除非在一些特殊情况下),因此在实际操作中,如果中断处理程序被调用,那么中断几乎可以肯定是来自该设备。然而,中断处理程序必须轮询设备寄存器,并确保中断确实是由该设备产生的。如果不是,则应该直接返回。
旧的 ISA 驱动程序约定是将设备单元号作为参数传递。这种方式已经过时,新的驱动程序会接收在调用 bus_setup_intr()
时为其指定的参数。按照新的约定,应该是指向结构 softc 的指针。所以,中断处理程序通常是这样开始的:
它以由 bus_setup_intr()
中的中断类型参数指定的中断优先级运行。这意味着同一类型的所有其他中断以及所有软件中断都会被禁用。
为了避免竞态条件,中断处理程序通常会写成一个循环:
中断处理程序必须仅对设备进行中断确认,而不是对中断控制器进行确认,后者由系统负责处理。
理解此过程的关键在于它是由一系列复杂性逐渐增加的阶段组成。这些阶段包括 boot1、boot2 和 loader(有关更多详细信息,请参见 )。启动系统按顺序执行每个阶段。最后一个阶段 loader 负责加载 FreeBSD 内核。以下部分将逐一讲解每个阶段。
boot2
^[]^
这段代码是程序的入口点,也是 BIOS 转交控制的地方。首先,它确保字符串操作能够自动递增其指针操作数(使用 cld
指令)^[^。然后,由于它不假设段寄存器的状态,因此它会初始化这些寄存器。最后,它将堆栈指针寄存器(%sp
)设置为($LOAD = 地址 0x7c00
),以确保堆栈可用。
boot1 是引导加载序列中的下一步,它是三个引导阶段中的第一个。需要注意的是,我们一直在处理磁盘扇区。实际上,BIOS 加载了绝对的第一个扇区,而 boot0 加载了 FreeBSD 切片的第一个扇区。这两个加载都发生在地址 0x7c00
。我们可以从概念上将这些磁盘扇区视为包含 boot0 和 boot1 的文件,但实际上对于 boot1 来说,这并不完全准确。严格来说,和 boot0 不同,boot1 并不是引导块的一部分 ^[]^。相反,一个完整的文件 boot(即 /boot/boot)最终被写入磁盘。这个文件结合了 boot1、boot2 和 Boot Extender
(或 BTX)。这个单一文件的大小大于单个扇区(大于 512 字节)。幸运的是,boot1 占据了这个文件的前 512 字节,因此当 boot0 加载 FreeBSD 切片的第一个扇区(512 字节)时,它实际上是加载了 boot1 并将控制权传递给它。
接下来是一个循环,用于查找 FreeBSD 切片。尽管 boot0 已经从 FreeBSD 切片加载了 boot1,但它并没有传递关于该切片的信息 ^[]^,因此 boot1 必须重新扫描分区表以找出 FreeBSD 切片的起始位置。因此,它重新读取 MBR:
因此,boot1 正好占据了 boot 的前 512 字节,并且因为 boot 被写入 FreeBSD 切片的第一个扇区,所以 boot1 完全适合这个第一个扇区。当 nread
读取 FreeBSD 切片的前 16 个扇区时,它实际上读取了整个 boot 文件 ^[]。我们将在下一节看到更多关于 boot 如何由 boot1 和 boot2 组成的细节。
回想一下,nread
使用内存地址 0x8c00
作为转移缓冲区来存储读取的扇区。这个地址选择得非常方便。实际上,因为 boot1 属于前 512 字节,它正好位于地址范围 0x8c00
-0x8dff
中。接下来的 512 字节(地址范围 0x8e00
-0x8fff
)用来存储 bsdlabel ^[]。
boot1 的最后一段代码启用对 1MB 以上内存的访问 ^[],并以跳转到 BTX 服务器的起始点作为结束:
loader 也是一个 BTX 客户端。这里我不再详细描述它,Mike Smith 写的详细手册 可以提供更多信息。上面已经讨论了底层机制和 BTX。
. 如果用户在 boot0 阶段选择操作系统后按下键盘,就会显示此提示。
. 如果有疑问,我们建议读者参考官方的 Intel 手册,手册中详细描述了每条指令的语义。
. 有一个文件 /boot/boot1,但它并不会写入到 FreeBSD 切片的开头。相反,它与 boot2 拼接在一起,形成 boot,这个 boot 会被写入 FreeBSD 切片的开头,并在启动时读取。
. 实际上,我们将指向切片条目的指针传递到了寄存器 %si 中。然而,boot1 并不假设它是由 boot0 加载的(也许是其他 MBR 加载了它,并且没有传递这个信息),因此它什么也不假设。
. 在 16 位实模式的上下文中,一个字是 2 字节。
. 512*16=8192 字节,正好是 boot 的大小。
. 历史上称为 disklabel。如果你曾经想知道 FreeBSD 将这些信息存储在哪里,它就在这个区域——请参见 。
. 这是由于遗留原因所需的。有兴趣的读者可以查看。
. 在从保护模式切换回实模式时,实模式代码和数据是必要的,如 Intel 手册所建议。
在大多数 UNIX® 系统中,root
拥有无上的权限。这容易导致不安全。如果攻击者获得了 root
权限,他将能够轻易地访问所有功能。在 FreeBSD 中,有一些 sysctl 可以稀释 root
的权限,从而最小化攻击者造成的损害。具体来说,其中一个功能叫做“secure levels”(安全级别)。另一个自 FreeBSD 4.0 起提供的功能是一个名为 的实用程序。Jail 将环境 chroot 并设置对在 jail 中派生的进程的某些限制。例如,jail 中的进程无法影响 jail 外部的进程,无法使用某些系统调用,也无法对主机环境造成任何损害。
Jail 正在成为新的安全模型。人们将一些可能存在漏洞的服务器(如 Apache、BIND 和 sendmail)运行在 jails 中,以便如果攻击者在 jail 中获得了 root
权限,这只是一个麻烦,而不是一场灾难。本文主要关注 jail 的内部实现(源代码)。有关如何设置 jail 的信息,请参见 。
Jail 由两个部分组成:用户空间程序 和内核中实现的代码:系统调用 及其相关限制。我将讨论用户空间程序,然后讨论 jail 在内核中的实现。
如你所见,每个传递给 程序的参数都有一个对应的条目,实际上它们会在程序执行期间被设置。
程序的一个参数是用于通过网络访问 jail 的 IP 地址。 将给定的 IP 地址转换为主机字节序,然后将其存储在 j
(jail
结构)中。
函数“将指定的字符字符串解释为互联网地址,并将该地址存放到提供的结构中。”ip_number
成员仅在通过 将 IP 地址存储到 in
结构中,并通过 转换为主机字节序时被设置。
最后,用户空间程序将进程限制在 jail 中。此时,jail 也成为一个被监禁的进程,并执行通过 提供的命令。
现在我们来看一下 /usr/src/sys/kern/kern_jail.c 文件。这是定义了 系统调用、相关的 sysctl 以及网络功能的文件。
每个 sysctl 都可以通过 程序由用户访问。整个内核都可以通过这些特定的 sysctl 名称识别它们。例如,第一个 sysctl 的名称是 security.jail.set_hostname_allowed
。
像所有系统调用一样, 系统调用接收两个参数,struct thread *td
和 struct jail_args *uap
。td
是指向描述调用线程的 thread
结构的指针。在此上下文中,uap
是指向一个结构的指针,该结构包含指向用户空间 jail.c 传递的 jail
结构的指针。当我之前描述用户空间程序时,你看到 系统调用接收一个 jail
结构作为参数。
因此,可以使用 uap→jail
来访问传递给系统调用的 jail
结构。接下来,系统调用使用 函数将 jail
结构复制到内核空间。 接受三个参数:要复制到内核空间的数据的地址(即 uap→jail
),存储目标地址(即 j
)和存储的大小。传递给 uap→jail
的 jail
结构被复制到内核空间并存储在另一个 jail
结构 j
中。
系统调用接着为 prison
结构分配内存,并在 jail
和 prison
结构之间复制数据。
接下来,我们将讨论另一个重要的系统调用 ,它实现了将进程放入 jail 的功能。
该系统调用对进程进行一系列更改,使其与未被 jail 限制的进程区分开来。为了理解 的作用,我们需要一些背景信息。
在 kern_jail.c 中,jail()
函数调用 jail_attach()
并传入给定的 jid
。jail_attach()
然后调用 change_root()
函数来更改调用进程的根目录。随后,jail_attach()
创建一个新的 ucred
结构,并在成功将 prison
结构附加到 ucred
结构后,将新创建的 ucred
结构附加到调用进程。从那时起,调用进程将被视为一个已被 jail 限制的进程。当内核例程 jailed()
被调用并传入新创建的 ucred
结构时,它将返回 1,表示该凭据与某个 jail 相关联。所有在 jail 内创建的进程的公共祖先进程是运行 的进程,因为它调用了 系统调用。当一个程序通过 被执行时,它会继承父进程的 ucred
结构中的 jail 属性,因此它也会有一个 jail 的 ucred
结构。
当进程从其父进程派生时, 系统调用使用 crhold()
来维持新派生进程的凭据。这本质上保持了新派生子进程的凭据与其父进程的一致性,因此子进程也会被 jail 限制。
System V IPC 基于消息。进程可以彼此发送这些消息,告诉它们如何操作。与消息相关的函数包括: 、、 和 。之前我提到过,你可以开启或关闭某些 sysctl 来影响 jail 的行为。其中一个 sysctl 是 security.jail.sysvipc_allowed
。默认情况下,这个 sysctl 被设置为 0。如果它被设置为 1,将破坏 jail 的基本目的;jail 内的特权用户将能够影响 jail 外部的进程。消息和信号之间的区别在于,消息仅包含信号编号。
信号量系统调用允许进程通过对一组信号量执行原子操作来同步执行。基本上,信号量为进程提供了另一种锁定资源的方法。然而,等待信号量的进程将会进入睡眠状态,直到资源被释放。以下信号量系统调用在 jail 内部被阻止:、 和 。
System V IPC 允许进程共享内存。进程可以通过共享它们的虚拟地址空间的部分内容,并读取和写入存储在共享内存中的数据,从而直接相互通信。这些系统调用在 jail 环境内被阻止:、、 和 。
Jail 以特殊的方式处理 系统调用和相关的低级套接字函数。为了确定是否允许创建某个套接字,首先检查 sysctl security.jail.socket_unixiproute_only
是否被设置。如果设置了,只有当套接字的类型为 PF_LOCAL
、PF_INET
或 PF_ROUTE
时,才允许创建该套接字,否则返回错误。
伯克利数据包过滤器(BPF)提供了一种原始接口,用于在协议无关的方式下访问数据链路层。现在,BPF 是否能在 jail 环境中使用由 控制。
本文档概述了 FreeBSD 内核中用于实现有效多处理的锁机制。锁定可以通过多种方式实现。数据结构可以通过互斥锁(mutex)或 锁来保护。有些变量则通过始终使用原子操作来访问,从而保护它们。
互斥锁可以递归获取,但它们的目的是短时间持有。具体来说,持有互斥锁时不能休眠。如果需要在休眠期间持有锁,请使用 锁。
类型:互斥锁的类型,使用 MTX_*
标志表示。每个标志的含义与 中的文档相关。
这些锁提供基本的读写锁类型功能,可以由睡眠中的进程持有。目前,它们是由 支持的。
原子保护变量是一种特殊的变量,未通过显式锁保护。相反,所有对这些变量的访问都使用特殊的原子操作,如 中所描述的那样。很少有变量是以这种方式处理的,尽管其他同步原语(如互斥锁)是通过原子保护变量实现的。
初始化新创建的 IP 分片重组队列上的标签。flag
字段可以是 M_WAITOK 或 M_NOWAIT,用于避免在此初始化调用过程中执行睡眠的 。IP 分片重组队列的分配通常发生在性能敏感的环境中,因此实现时应小心避免睡眠或长时间操作。此入口点可能会失败,导致无法分配 IP 分片重组队列。
初始化新创建的 mbuf 包头(mbuf
)上的标签。flag
字段可以是 M_WAITOK 或 M_NOWAIT,用于避免在此初始化调用过程中执行睡眠的 。mbuf 的分配通常发生在性能敏感的环境中,因此实现时应小心避免睡眠或长时间操作。此入口点可能会失败,导致无法分配 mbuf 头部。
初始化新创建的套接字上的标签。flag
字段可以是 M_WAITOK 或 M_NOWAIT,用于避免在此初始化调用过程中执行睡眠的 。
初始化新创建的套接字对等标签。flag
字段可以是 M_WAITOK 或 M_NOWAIT,用于避免在此初始化调用过程中执行睡眠的 。
根据 接受的新套接字 newsocket
为其设置标签,标签来源于 中的监听套接字 oldsocket
。
根据传入的主体凭证为新创建的主体凭证设置标签。当调用 时,此调用将在新创建的 struct ucred
上触发。此调用不应与进程的分叉或创建事件混淆。
访问控制入口点允许策略模块影响内核做出的访问控制决策。通常,尽管并非总是如此,访问控制入口点的参数将包括一个或多个授权凭证、可能包括标签的其他对象的信息。访问控制入口点可以返回 0 以允许操作,或者返回一个 错误值。各个注册策略模块调用该入口点的结果将按照以下方式组合:如果所有模块都允许操作成功,则返回成功。如果一个或多个模块返回失败,则返回失败。如果多个模块返回失败,则返回给用户的 errno 值将根据以下优先级选择,该优先级由 kern_mac.c 中的 error_select()
函数实现:
确定主体是否应被允许检索内核环境(参见 )。
确定主体是否应被允许执行指定的 调用。
确定主体凭证是否可以查看对文件系统执行 statfs 时的结果。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配)或 EPERM(缺乏权限)。此调用可在多种情况下使用,包括调用 和相关调用时,以及在调用 时,确定要排除哪些文件系统。
确定主体凭证是否可以调试传入的进程。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),EPERM(缺乏权限),或 ESRCH(隐藏目标的可见性)。此调用可在多种情况下使用,包括使用 和 API 时,以及某些类型的 procfs 操作时。
确定主体凭证是否允许 进入指定的目录(dvp
)。
确定主体凭证是否可以在传入的父目录、传入的名称信息和传入的属性信息的基础上创建一个 vnode。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。此调用可在多种情况下使用,包括作为对 带有 O_CREAT 标志、 等的调用的响应。
确定主体凭证是否可以从传入的父目录中删除 vnode,并且可以基于传入的名称信息删除 vnode。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),或 EPERM(缺乏权限)。此调用可在多种情况下使用,包括作为对 和 的调用的响应。实现此接口的策略应当同时实现 mpo_check_rename_to
以授权删除作为重命名目标的对象。
参见 获取更多信息。
参见 获取更多信息。
确定主体凭证是否可以通过系统监控功能“查看”传入的套接字(socket
),例如 和 使用的功能。返回 0 表示成功,或返回一个 errno
值表示失败。建议的失败:EACCES(标签不匹配),EPERM(缺乏权限),或 ESRCH(隐藏可见性)。
确定主体是否被允许调用 。
确定主体是否应被允许进行指定的 操作。
TrustedBSD MAC 框架提供了许多库和系统调用,允许应用程序使用与策略无关的接口管理对象的 MAC 标签。这使得应用程序可以操作多种策略的标签,而无需为特定的策略编写支持。这些接口被通用工具如 、 和 用于查看网络接口、文件和进程上的标签。API 还支持 MAC 管理工具,包括 、、、 和 。MAC API 在 中有文档说明。
应用程序处理 MAC 标签的两种形式:一种是内部化的形式,用于返回和设置进程和对象上的标签(mac_t
),另一种是外部化的形式,基于 C 字符串,适用于存储在配置文件中、显示给用户或从用户输入。每个 MAC 标签包含多个元素,每个元素由名称和值对组成。内核中的策略模块绑定到特定名称,并以特定于策略的方式解释值。在外部化字符串形式中,标签由用 /
字符分隔的名称和值对组成。可以使用提供的 API 直接将标签转换为文本并返回;当从内核检索标签时,必须首先为所需的标签元素集准备内部标签存储。通常,这通过使用 和一个任意的标签元素列表,或通过调用变体从 配置文件加载默认元素集来完成。每个对象的默认值允许应用程序编写者在不了解系统中存在的策略的情况下,有效地显示与对象关联的标签。
标准用户上下文管理接口 已被修改,以从 检索与用户类相关的 MAC 标签。这些标签随后在指定 LOGIN_SETALL
或显式指定 LOGIN_SETMAC
时,与其他用户上下文一起设置。
预计在未来版本的 FreeBSD 中,MAC 标签数据库将与 login.conf 用户类抽象分离,并维护在一个独立的数据库中。然而, API 在这种变更之后应保持不变。
SMPng 的目标是允许内核中的并发。内核本质上是一个相当庞大且复杂的程序。为了使内核支持多线程,我们使用了一些使其他程序支持多线程的工具。这些工具包括互斥锁、共享/独占锁、信号量和条件变量。有关这些和其他 SMP 相关术语的定义,请参阅本文的 部分。
struct prison
存储与使用 API 创建的 jail 相关的管理信息。这包括每个 jail 的主机名、IP 地址和相关设置。该结构是引用计数的,因为对该结构实例的指针被多个凭证结构共享。一个单一的互斥锁 pr_mtx
保护对引用计数和 struct jail
内部所有可变变量的读写访问。某些变量仅在 jail 创建时设置,且拥有有效的 struct prison
引用即可读取这些值。每个条目的精确锁定方式在 sys/jail.h 中通过注释进行了记录。
Newbus 系统将使用一个 sx 锁。读操作将持有共享(读)锁(),写操作将持有独占(写)锁()。内部函数将不进行锁定。外部可见的函数会根据需要进行锁定。那些如果竞争获胜或失败都无关紧要的项将不会加锁,因为它们通常被频繁读取(例如,)。Newbus 数据结构的变化相对较少,因此一个锁应该足够且不会带来性能损失。
critical section(临界区) 不允许被抢占的代码段。通过使用 API 进入和退出临界区。
框架是声音子系统的核心部分。它主要实现以下几个元素:
作为替代方案,或者作为对现有示例的补充,你可以在 上找到一个带注释的驱动程序模板。
声音驱动程序几乎与任何硬件驱动模块一样进行探测和附加。你可能想查看手册中的 或 特定部分以获取更多信息。
声音驱动程序的附加例程应通过调用 mixer_init()
向 pcm 声明其 MIXER 或 AC97 接口。对于 MIXER 接口,这会进一步调用 。
声音驱动程序的附加例程通过调用 pcm_addchan()
声明每个通道对象。这会在 pcm 中设置通道连接,并进一步调用 。
pcm 核心与声音驱动之间的接口是通过 定义的。
pcm 首先填充缓冲区,然后调用声音驱动的 函数,参数为 PCMTRIG_START。
channel_reset()
、channel_resetdone()
和 channel_notify()
是特殊用途的函数,应该在不讨论的情况下不要在驱动程序中实现,建议通过 讨论。
USB 2.0 规格()
通用主机控制器接口(UHCI)规格()
开放主机控制器接口(OHCI)规格()
USB 网站的开发者部分()
第二个更复杂的接口是 "bus"。这个接口包含适用于具有子设备的设备的方法,包括访问总线特定的每个设备信息的方法^[]^、事件通知(child_detached
、driver_added
)和资源管理(alloc_resource
、activate_resource
、deactivate_resource
、release_resource
)。
sched_lock
"sched lock"
MTX_SPIN
MTX_RECURSE
_gmonparam
, cnt.v_swtch
, cp_time
, curpriority
, mtx
.mtx_blocked
, mtx
.mtx_contested
, proc
.p_procq
, proc
.p_slpq
, proc
.p_sflag
, proc
.p_stat
, proc
.p_estcpu
, proc
.p_cpticks
, proc
.p_pctcpu
, proc
.p_wchan
, proc
.p_wmesg
, proc
.p_swtime
, proc
.p_slptime
, proc
.p_runtime
, proc
.p_uu
, proc
.p_su
, proc
.p_iu
, proc
.p_uticks
, proc
.p_sticks
, proc
.p_iticks
, proc
.p_oncpu
, proc
.p_lastcpu
, proc
.p_rqindex
, proc
.p_heldmtx
, proc
.p_blocked
, proc
.p_mtxname
, proc
.p_contested
, proc
.p_priority
, proc
.p_usrpri
, proc
.p_nativepri
, proc
.p_nice
, proc
.p_rtprio
, pscnt
, slpque
, itqueuebits
, itqueues
, rtqueuebits
, rtqueues
, queuebits
, queues
, idqueuebits
, idqueues
, switchtime
, switchticks
setrunqueue
, remrunqueue
, mi_switch
, chooseproc
, schedclock
, resetpriority
, updatepri
, maybe_resched
, cpu_switch
, cpu_throw
, need_resched
, resched_wanted
, clear_resched
, aston
, astoff
, astpending
, calcru
, proc_compare
vm86pcb_lock
"vm86pcb lock"
MTX_DEF
vm86pcb
vm86_bioscall
Giant
"Giant"
MTX_DEF
MTX_RECURSE
几乎所有内容
很多
callout_lock
"callout lock"
MTX_SPIN
MTX_RECURSE
callfree
, callwheel
, nextsoftcheck
, proc
.p_itcallout
, proc
.p_slpcallout
, softticks
, ticks
allproc_lock
allproc
, zombproc
, pidhashtbl
, proc
.p_list
, proc
.p_hash
, nextpid
proctree_lock
proc
.p_children
, proc
.p_sibling
conf
MAC 策略定义
conf
MAC 策略定义
td
调用线程
call
策略特定的系统调用编号
arg
指向系统调用参数的指针
td
返回的线程
label
新标签应用
label
初始化的新标签
label
新标签应用
label
新标签应用
mntlabel
用于挂载点本身的策略标签
fslabel
用于文件系统的策略标签
label
用于初始化的标签
label
用于填充的新标签
label
要初始化的进程标签
label
要初始化的 vnode 标签
label
bpfdesc 标签
label
正在销毁的标签
label
正在销毁的标签
label
正在销毁的标签
label
正在销毁的标签
label
正在销毁的标签
label
正在销毁的挂载点标签
mntlabel
正在销毁的挂载点标签
fslabel
正在销毁的文件系统标签
label
正在销毁的套接字标签
peerlabel
正在销毁的套接字对等标签
label
管道标签
label
进程标签
label
进程标签
src
源标签
dest
目标标签
src
源标签
dest
目标标签
src
源标签
dest
目标标签
label
要外部化的标签
element_name
需要外部化标签的策略名称
sb
用于填充标签文本表示的字符串缓冲区
claimed
当 element_data
可以填充时,应该递增该值
label
要外部化的标签
element_name
需要外部化标签的策略名称
sb
用于填充标签文本表示的字符串缓冲区
claimed
当 element_data
可以填充时,应该递增该值
label
要外部化的标签
element_name
需要外部化标签的策略名称
sb
用于填充标签文本表示的字符串缓冲区
claimed
当 element_data
可以填充时,应该递增该值
label
要外部化的标签
element_name
需要外部化标签的策略名称
sb
用于填充标签文本表示的字符串缓冲区
claimed
当 element_data
可以填充时,应该递增该值
label
要外部化的标签
element_name
需要外部化标签的策略名称
sb
用于填充标签文本表示的字符串缓冲区
claimed
当 element_data
可以填充时,应该递增该值
label
要外部化的标签
element_name
需要外部化标签的策略名称
sb
用于填充标签文本表示的字符串缓冲区
claimed
当 element_data
可以填充时,应该递增该值
label
要填充的标签
element_name
需要内部化标签的策略名称
element_data
需要内部化的文本数据
claimed
当数据能够成功内部化时,应该递增该值
label
要填充的标签
element_name
需要内部化标签的策略名称
element_data
需要内部化的文本数据
claimed
当数据能够成功内部化时,应该递增该值
label
要填充的标签
element_name
需要内部化标签的策略名称
element_data
需要内部化的文本数据
claimed
当数据能够成功内部化时,应该递增该值
label
要填充的标签
element_name
需要内部化标签的策略名称
element_data
需要内部化的文本数据
claimed
当数据能够成功内部化时,应该递增该值
label
要填充的标签
element_name
需要内部化标签的策略名称
element_data
需要内部化的文本数据
claimed
当数据能够成功内部化时,应该递增该值
mp
Devfs 挂载点
fslabel
Devfs 文件系统标签(mp→mnt_fslabel
)
de
Devfs 目录项
delabel
与 de
关联的策略标签
vp
与 de
关联的 vnode
vlabel
与 vp
关联的策略标签
mp
文件系统挂载点
fslabel
文件系统标签
vp
要标记的 vnode
vlabel
与 vp
关联的策略标签
mp
文件系统挂载点
fslabel
文件系统标签
vp
要标记的 vnode
vlabel
与 vp
关联的策略标签
dev
与 devfs_dirent
相关的设备
devfs_dirent
要标记的 Devfs 目录项
label
要填充的 devfs_dirent
标签
dirname
被创建目录的名称
namelen
字符串 dirname
的长度
devfs_dirent
被创建的目录的 Devfs 目录项
cred
主体凭证
mp
Devfs 挂载点
dd
符号链接的目标
ddlabel
与目标 dd
关联的标签
de
符号链接条目
delabel
与符号链接条目 de
关联的标签
cred
主体凭证
mp
文件系统挂载点
fslabel
文件系统标签
dvp
父目录 vnode
dlabel
与父目录 dvp
关联的标签
vp
新创建的 vnode
vlabel
与 vp
关联的策略标签
cnp
vp
的组件名称
cred
主体凭证
mp
被挂载的文件系统对象
mntlabel
填充的文件系统挂载标签
fslabel
文件系统的策略标签
cred
主体凭证
vp
要重新标记的 vnode
vnodelabel
vp
当前的策略标签
newlabel
替换 vnodelabel
的新标签,可能是部分标签
cred
主体凭证
vp
要写入标签的 vnode
vlabel
与 vp
关联的策略标签
intlabel
要写入的标签
devfs_dirent
Devfs 目录项
direntlabel
需要更新的 devfs_dirent
的策略标签
vp
父目录 vnode
锁定
vnodelabel
与父目录 vp
关联的策略标签
socket
套接字
套接字锁定正在进行
socketlabel
与 socket
关联的策略标签
m
mbuf 对象
mbuflabel
为 m
填充的策略标签
cred
主体凭证
pipe
管道
pipelabel
与 pipe
关联的策略标签
cred
主体凭证
不变
so
要标记的套接字
socketlabel
为 so
填充的标签
oldsocket
监听套接字
oldsocketlabel
与 oldsocket
关联的策略标签
newsocket
新的套接字
newsocketlabel
与 newsocket
关联的策略标签
cred
主体凭证
pipe
管道
oldlabel
当前与管道关联的策略标签
newlabel
要应用于管道的策略标签更新
cred
主体凭证
不变
so
套接字对象
oldlabel
当前套接字 so
的标签
newlabel
套接字 so
的标签更新
mbuf
通过套接字接收到的第一个数据报
mbuflabel
与 mbuf
关联的标签
oldlabel
套接字当前的标签
newlabel
要为套接字设置的对端标签
oldsocket
本地套接字
oldsocketlabel
与 oldsocket
关联的策略标签
newsocket
对端套接字
newsocketpeerlabel
要为 newsocket
填充的对端策略标签
cred
主体凭证
不变
bpf_d
对象;BPF 描述符
bpf
要为 bpf_d
填充的策略标签
ifnet
网络接口
ifnetlabel
要为 ifnet
填充的策略标签
fragment
第一个接收到的 IP 数据片段
fragmentlabel
与 fragment
关联的策略标签
ipq
要标记的 IP 重组队列
ipqlabel
要为 ipq
填充的策略标签
ipq
IP 重组队列
ipqlabel
与 ipq
关联的策略标签
datagram
要标记的数据报
datagramlabel
要为 datagram
填充的策略标签
datagram
数据报
datagramlabel
与 datagram
关联的策略标签
fragment
要标记的数据片段
fragmentlabel
要为 fragment
填充的策略标签
oldmbuf
现有的(源)mbuf
oldmbuflabel
与 oldmbuf
关联的策略标签
newmbuf
新的 mbuf 要被标记
newmbuflabel
要为 newmbuf
填充的策略标签
ifnet
网络接口
ifnetlabel
与 ifnet
关联的策略标签
mbuf
新数据报的 mbuf 标头
mbuflabel
要为 mbuf
填充的策略标签
bpf_d
BPF 描述符
bpflabel
与 bpf_d
关联的策略标签
mbuf
新的 mbuf 要被标记
mbuflabel
要为 mbuf
填充的策略标签
ifnet
网络接口
ifnetlabel
与 ifnet
关联的策略标签
mbuf
新数据报的 mbuf 标头
mbuflabel
要为 mbuf
填充的策略标签
oldmbuf
现有数据报的 mbuf 标头
oldmbuflabel
与 oldmbuf
关联的策略标签
ifnet
网络接口
ifnetlabel
与 ifnet
关联的策略标签
newmbuf
要标记的新数据报的 mbuf 标头
newmbuflabel
要为 newmbuf
填充的策略标签
oldmbuf
接收到的数据报
oldmbuflabel
与 oldmbuf
关联的策略标签
newmbuf
新创建的数据报
newmbuflabel
与 newmbuf
关联的策略标签
fragment
IP 数据报片段
fragmentlabel
与 fragment
关联的策略标签
ipq
IP 片段重组队列
ipqlabel
与 ipq
关联的策略标签
cred
主体凭证
ifnet
对象;网络接口
ifnetlabel
与 ifnet
关联的策略标签
newlabel
要应用于 ifnet
的标签更新
mbuf
IP 片段
mbuflabel
与 mbuf
关联的策略标签
ipq
IP 片段重组队列
ipqlabel
要更新的 ipq
的策略标签
parent_cred
父主体凭证
child_cred
子主体凭证
old
现有主体凭证
不可变
new
要标记的新主体凭证
vp
要执行的文件
已锁定
vnodelabel
与 vp
关联的策略标签
cred
要填充的主体凭证
cred
要填充的主体凭证
cred
主体凭证
newlabel
要应用于 cred
的标签更新
EINVAL
ESRCH
EACCES
优先级最低
EPERM
bpf_d
主体;BPF 描述符
bpflabel
bpf_d
的策略标签
ifnet
对象;网络接口
ifnetlabel
ifnet
的策略标签
cred
主体凭证
cred
主体凭证
name
内核环境变量名称
cred
主体凭证
name
内核环境变量名称
cred
主体凭证
name
内核环境变量名称
cred
主体凭证
vp
内核模块 vnode
vlabel
与 vp
关联的标签
cred
主体凭证
cred
主体凭证
cred
主体凭证
pipe
管道
pipelabel
与 pipe
关联的策略标签
cred
主体凭证
pipe
管道
pipelabel
与 pipe
关联的策略标签
cred
主体凭证
pipe
管道
pipelabel
当前与 pipe
关联的策略标签
newlabel
更新后的标签,应用于 pipelabel
cred
主体凭证
pipe
管道
pipelabel
与 pipe
关联的策略标签
cred
主体凭证
pipe
管道
pipelabel
与 pipe
关联的策略标签
cred
主体凭证
socket
待绑定的套接字
socketlabel
与 socket
关联的策略标签
sockaddr
socket
的地址
cred
主体凭证
socket
待连接的套接字
socketlabel
与 socket
关联的策略标签
sockaddr
socket
的地址
cred
主体凭证
so
套接字
socketlabel
与 so
关联的策略标签
cred
主体凭证
so
套接字
socketlabel
与 so
关联的策略标签
u1
主体凭证
u2
对象凭证
cred
主体凭证
socket
对象;套接字
socketlabel
与 socket
关联的策略标签
cred
主体凭证
ifnet
对象;网络接口
ifnetlabel
与 ifnet
关联的现有策略标签
newlabel
将应用于 ifnet
的标签更新
cred
主体凭证
socket
对象;套接字
socketlabel
与 socket
关联的现有策略标签
newlabel
将应用于 socketlabel
的标签更新
cred
主体凭证
newlabel
将应用于 cred
的标签更新
cred
主体凭证
不可变
vp
对象;vnode
锁定
vnodelabel
与 vp
关联的现有策略标签
newlabel
将应用于 vp
的标签更新
cred
主体凭证
mp
对象;文件系统挂载
mountlabel
与 mp
关联的策略标签
cred
主体凭证
不可变
proc
对象;进程
cred
主体凭证
dvp
目录 vnode
dlabel
与 dvp
关联的策略标签
cred
主体凭证
dvp
对象;父目录 vnode
dlabel
与 dvp
关联的策略标签
cnp
与 dvp
关联的组件名称
vap
vnode 的属性
cred
主体凭证
dvp
父目录 vnode
dlabel
与 dvp
关联的策略标签
vp
对象;要删除的 vnode
label
与 vp
关联的策略标签
cnp
与 vp
关联的组件名称
cred
主体凭证
不可变
vp
对象;vnode
锁定
label
与 vp
关联的策略标签
type
ACL 类型
cred
主体凭证
vp
对象;要执行的 vnode
label
与 vp
关联的策略标签
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
type
ACL 类型
cred
主体凭证
dvp
目录 vnode
dlabel
与 dvp
关联的策略标签
vp
链接目标 vnode
label
与 vp
关联的策略标签
cnp
创建链接的组件名称
cred
主体凭证
vp
映射的 vnode
prot
内存保护标志
active_cred
主体凭证
file_cred
与文件结构关联的凭证
vp
被轮询的 vnode
label
与 vp
关联的策略标签
cred
主体凭证
dvp
目录 vnode
dlabel
与 dvp
关联的策略标签
vp
要重命名的 vnode
label
与 vp
关联的策略标签
cnp
vp
的组件名称
cred
主体凭证
dvp
目录 vnode
dlabel
与 dvp
关联的策略标签
vp
被覆盖的 vnode
label
与 vp
关联的策略标签
samedir
布尔值;如果源目录和目标目录相同,则为 1
cnp
目标组件名称
cred
主体凭证
socket
对象;socket
socketlabel
与 socket
关联的策略标签
cred
主体凭证
dvp
对象;vnode
dlabel
与 dvp
关联的策略标签
cnp
正在查找的组件名称
cred
主体凭证
dvp
对象;目录 vnode
dlabel
与 dvp
关联的策略标签
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
type
ACL 类型
acl
ACL
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
uid
用户 ID
gid
组 ID
cred
主体凭证
proc
对象;进程
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
cred
主体凭证
ifnet
网络接口
ifnetlabel
与 ifnet
关联的策略标签
mbuf
对象;待发送的 mbuf
mbuflabel
与 mbuf
关联的策略标签
cred
主体凭证
ifnet
网络接口
ifnetlabel
与 ifnet
关联的策略标签
mbuf
对象;待发送的 mbuf
mbuflabel
与 mbuf
关联的策略标签
cred
主体凭证
不可变
so
对象;套接字
socketlabel
与 so
关联的策略标签
cred
主体凭证
cred
主体凭证
cred
主体凭证
vp
交换设备
vlabel
与 vp
关联的标签
label
新标签应用
flag
flag
label
要初始化的策略标签
label
要初始化的新标签
flag
label
要初始化的新标签
flag
old
不可变
vp
要执行的文件
vnodelabel
与 vp
关联的策略标签
cred
主体凭证
pipe
管道
pipelabel
与 pipe
关联的策略标签
cmd
data
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
flags
cred
主体凭证
dvp
dlabel
与 dvp
关联的策略标签
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
attrnamespace
扩展属性命名空间
name
扩展属性名称
uio
cred
主体凭证
vp
要映射的 vnode
label
与 vp
关联的策略标签
prot
cred
vp
label
prot
需要降级的 mmap 保护标志
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
acc_mode
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
attrnamespace
扩展属性命名空间
name
扩展属性名称
uio
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
flags
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
mode
cred
主体凭证
vp
对象;vnode
label
与 vp
关联的策略标签
atime
mtime
cred
主体凭证
proc
对象;进程
signal
ucred
主体凭证
vp
vlabel
与 vp
关联的标签
cred
主体凭证
howto
cred
主体凭证
name
namelen
old
oldlenp
inkernel
布尔值;如果来自内核,则为 1
new
newlen
睡眠/非睡眠 ;见下文
睡眠/非睡眠 ;见下文
标志
标志
参见
调用前的主体凭证
命令
数据
标志
对象;要通过 进入的 vnode
I/O 结构指针;参见
mmap 保护标志(参见 )
参见
访问模式
I/O 结构指针;参见
文件标志;参见
文件模式;参见
访问时间;参见
修改时间;参见
信号;参见
会计文件;参见
来自 的 howto
参数
参见
参见