内核级异常处理
一个 Linux 系统由内核和各种用户应用程序组成。用户应用程序通过系统调用与内核通信。系统调用是进入 Linux 内核的入口点,允许应用程序使用内核提供的服务。这些服务通常在用户应用程序提供的输入数据上执行。这些输入数据有时可能会构成问题。
Linux 是一个多用户操作系统;即,它支持多个并发用户。它不了解这些用户或他们使用的程序。一个错误的或恶意的应用程序可能会将无效的参数传递给系统调用。如果内核随后尝试使用无效的参数,则可能会发生不可预测的行为。因此,内核必须对系统调用的参数“保持警惕”,并且必须仔细检查每个参数,以确保它既不会危害系统稳定性,也不会损害其他用户。对于某些类型的参数来说,这很容易;文件描述符必须是打开的,ioctl 命令必须允许用于指定的设备,等等。
一类参数包含指向应用程序地址空间中输入数据位置的指针,或指向将接收系统调用数据的缓冲区的指针。例如,系统调用 write 就是一个例子。它的一个参数是指向应用程序地址空间中包含要写入数据的位置的指针。内核必须确认该地址确实是进程地址空间的一部分,并且不与为内核保留的地址范围重叠。对于将数据写入用户地址空间的系统调用(例如,read),内核还必须检查目标地址是否实际上是可写的。
在 2.1.2 之前的内核版本中,每次用户内存访问都必须由对函数 verify_area 的调用来保护。此函数检查地址范围对于特定操作(读取或写入)是否有效。verify_area 方法有四个问题
它很慢,因为内核必须查找覆盖相关地址范围的虚拟内存区域。虚拟内存区域 (VMA) 是内核用于跟踪映射到每个进程的内存的数据结构。即使使用高效的算法,搜索特定的 VMA 也是一个耗时的过程。(Linux 使用 Adelson-Velskii-Landis 树来优化 VMA 检索。)
通常不需要它,因为大多数程序都向内核提供有效的指针。尽管如此,内核必须花费宝贵的时间来为每次用户内存访问提供保护,以防止罕见的“有缺陷”的应用程序。
它容易出错。程序员很容易忘记在用户内存访问之前调用 verify_area,因为地址验证和实际的用户内存访问是单独的函数。
它并非总是可靠的,因为在新的多线程应用程序中,内存映射可能会在 verify_area 操作和实际用户内存访问之间发生变化。
特别是,第 4 点要求找到一种新的解决方案来验证地址。随着 Linux 真正的多线程程序的出现,这不再仅仅是一个表面问题。
用于地址验证问题的新解决方案必须满足以下标准
对于有效地址的正常情况,尽可能快地运行。
为内核程序员提供易于使用的编程接口。
对于所有情况都保持稳定和正确。
与其在软件中实现地址测试,不如将要求中的第 1 点和第 3 点最好地通过将实际工作交给虚拟内存硬件(所有支持 Linux 的硬件都存在)来满足。第 2 点导致将访问检查和实际内存访问合并到一个单独的访问函数中。
该实现依赖于 MMU(内存管理单元)来完成正确的事情。每当软件尝试访问无效地址时,MMU 都会传递一个页错误异常。这不仅适用于用户应用程序,也适用于内核。如果内核尝试执行无效的内存访问,则特殊的处理程序会拦截由此产生的异常并修复该访问。由于处理程序仅针对有问题的情况调用,因此正常的内存访问几乎没有增加开销。Linus 在内核版本 2.1.2 到 2.1.6 中完成了一些新的用户内存访问方案的实验性实现。它们证明了该方案在现实世界中有效,但在内核内部具有相当笨拙的 API。对于 Linux 2.1.7,Richard Henderson 实现了本文介绍的当前改进版本。现在,它可用于 Linux 支持的大多数架构。我使用 x86 实现作为示例;其他受支持架构的代码非常相似。
在这个新版本中,只有机器特定的头文件 uacess.h 中的宏和函数才允许访问用户地址空间。有用于处理以零结尾的字符串、清除内存区域以及在内核之间复制单个值和从内核复制单个值的函数和宏。其中一个宏是 get_user,它使用两个参数调用:val 和 addr。它将单个数据值从用户空间地址 addr 复制到变量 val。成功时,它返回值 0,失败时返回 -EFAULT(“坏地址”)。它在文件 uacess.h 中的源代码有些难以理解,甚至更难解释。
相反,我将追踪驱动程序/char/serial.c 中函数 rs_ioctl 的一个用法示例。内核中的源代码行是
error = get_user(arg, (unsigned int *) arg);
看起来很无辜,不是吗?好的,列表 1 显示了 C 编译器如何处理这段代码。让我们逐行浏览一下,从寄存器分配开始。在输出时,寄存器 ECX 包含从用户地址空间读取的值,寄存器 EDX 包含访问操作的错误代码。寄存器 EBX 保存要读取的值的地址。第 01 行使用 -EFAULT 初始化错误代码。在第 02 行中,结果值设置为 0。
在第 03 行到第 07 行中,内核执行检查,以确保寄存器 EBX 中的地址不与内核内存重叠。首先,它检查访问是否来自内核内部。当内核本身调用系统调用时(例如,在 NFS 客户端实现中),可能会发生这种情况。在这种情况下,始终授予访问权限。如果访问来自内核外部,则它会检查给定的地址范围是否与为内核保留的范围重叠。
在 Linux 中,内核地址空间映射到每个进程的地址空间中。内核内存从地址 0xC0000000 开始。如果允许用户程序将指向内核内存中地址的指针作为参数传递给系统调用,则内核的访问不会导致页错误异常。这反过来将允许用户程序覆盖内核数据。在我们的示例中(整数访问 = 4 字节),用户进程允许访问的最高可能地址是 0xC0000000 - 4 = 0xBFFFFFFFC = -1073741828。大于此地址的每个地址都会触及内核内存,因此无效。如果内存地址无效,则执行在第 21 行的标签 .L2395 处继续。
现在,预备工作已经完成,我们准备访问 EBX 指向的数据。假设访问将成功,我们将寄存器 EDX 设置为 0(第 09 行)。在第 10 行中,实际访问发生。请注意,可能发生错误的指令的地址标记有本地标签 1。第 13 行到第 15 行中的以下代码无条件地将错误代码设置为 --EFAULT,将结果值设置为 0,并跳转回第 16 行的本地标签 2。这看起来像一个无限循环——但事实并非如此。
Linus 在 2.1 开发周期的开始时做出的另一个重要决定是完全放弃用于内核开发的 a.out 可执行文件格式,而支持更现代的 ELF。通常,ELF 仅与用户应用程序的易于共享的库支持相关联。但是,这只是其优点之一。另一个优点是 ELF 二进制文件(对象文件和可执行文件)可以具有任意数量的命名节。
第 12 行中的 .section .fixup, "ax" 命令指示汇编器将以下代码放在名为 .fixup 的 ELF 节中。在第 16 行中,我们切换回先前的代码节;通常这是 .text。这意味着汇编器将生成的对象文件中第 13 行到第 15 行的代码从正常的执行路径中移出。第 17 行到第 20 行通过指示汇编器将两个长值放入名为 __ex_table 的 ELF 节中来完成类似的任务。这两个值初始化为可能发生错误的指令的地址(标签 1 向后,1b)和修复代码的地址(标签 3 向后,3b)。
借助命令 objdump,它是 GNU 实用程序之一,我们可以检查链接内核的内部结构。请参阅 列表 2. 内部内核结构。
正如预期的那样,有两个名为 .fixup 和 __ex_table 的节。objdump 还向我们展示了保留在正常执行路径中的代码。查看列表 3,我们可以看到整个用户内存访问减少到仅 12 条机器指令。最初用 .section 指令括起来的代码现在位于 .fixup 节中
c01a16bf <.fixup+17b3> movl $0xfffffff2,%edx c01a16c4 <.fixup+17b8> xorl %ecx,%ecx c01a16c6 <.fixup+17ba> jmp c018fafd <rs_ioctl+359>
ELF 节 __ex_table 包含由 <> 括起来的地址对:故障指令地址和匹配的修复代码地址。在我们的示例中,我们期望对 <c018fafb,c01a16bf>。使用命令
objdump --section=__ex_table --full-contents\ vmlinux和一个小程序来交换内部表示到人类可读形式的字节,给我们这个输出
c018f292 c01a1699 c018f51d c01a16a5 c018f5a5 c01a16ad c018fad0 c01a16b5 c018fafb c01a16bf c018fc5b c01a16cb c018fd02 c01a16d5 c018fd72 c01a16e1 c018ff5a c01a16e9 c018ff7b c01a16f3现在我们拥有了组装最终图片所需的所有部分。当内核访问用户空间中的无效地址时,MMU 会生成页错误异常。此异常由 C 文件 arch/i386/mm/fault.c 中的页错误处理程序 do_page_fault 处理。do_page_fault 首先从 CPU 控制寄存器 CR2 获取不可访问的地址。然后,它在当前进程映射中查找包含无效地址的 VMA。如果存在这样的 VMA,则该地址在当前进程的虚拟地址空间内。故障可能发生,因为该页未换入或受到写保护。在这种情况下,Linux 内存管理器会采取适当的措施。但是,我们更关注地址无效的情况——没有包含该地址的 VMA。在这种情况下,内核跳转到 bad_area 标签。
此时,内核调用函数 search_exception_table 来查找可以安全继续执行的地址(修复)。由于每个故障指令都有一个关联的修复处理程序,因此此函数使用 regs->eip 中的值作为搜索键。
Linux 中的可加载内核模块使问题复杂化。在运行时插入后,它们实际上是正在运行的内核的一部分,并且可以像内核的任何其他部分一样访问用户内存。因此,它们必须集成到异常处理过程中。内核模块必须提供自己的异常表。在插入时,模块的异常表在内核中注册。然后 search_exception_table 可以遍历所有已注册的表,对故障指令执行二进制搜索。
为了访问异常表的开始和结束,我们使用了链接器的功能,该功能将由节名称前缀为 __start__ 或 __stop__ 的符号名称解析为该节的起始或结束地址。此功能允许我们从 C 代码访问异常表。如果 search_exception_table 找到故障指令的地址,它将返回匹配的修复代码的地址。然后 do_page_fault 将其自身的返回地址设置为修复代码并返回。这样,异常处理程序就会被执行。异常处理程序将我们尝试获取的值设置为 0,并将 get_user 宏的返回值设置为 -EFAULT。然后它跳转回紧接在失败的用户内存访问之后的地址。周围代码的任务是将失败报告回用户应用程序。
Jöerg Pommnitz 的名字可以在整个 Linux 内核代码中找到。他最近换了工作并搬家了,但没有告诉我们他的新地址。