内核模式 Linux
内核模式 Linux (KML) 是一项使普通用户空间程序能够在内核空间内执行的技术。本文介绍了 KML 的背景、一种方法和实现。同时还介绍了一个简短的性能实验。
传统的内核通过使用 CPU 的硬件设施来保护自身。例如,Linux 内核通过使用 CPU 的特权级别机制和内存保护机制来保护自身。内核将自身分配为最高特权级别,即内核模式。用户进程处于最低特权级别,即用户模式。因此,内核受到 CPU 的保护,因为在用户模式下执行的程序无法访问属于在内核模式下执行的程序的内存。
然而,这种“硬件保护”方法存在一个问题:用户进程无法完全访问内核。也就是说,内核无法向用户进程提供任何有用的服务,例如文件系统、网络通信和进程管理。简而言之,用户进程无法在内核中调用系统调用。
为了应对这个问题,传统的内核利用现代 CPU 提供的硬件设施,以安全且受限制的方式提升程序的特权级别。例如,IA-32 平台的 Linux 内核使用 IA-32 固有的软件中断机制。软件中断可以看作是一种特殊的跳转指令,其目标地址受到内核的限制。在初始化时,内核将软件中断的目标地址设置为处理系统调用的特殊例程的地址。为了调用系统调用,用户程序执行一条特殊指令 int 0x80。然后,内核中的系统调用处理例程在内核模式下执行。该例程执行上下文切换;也就是说,它保存用户程序的寄存器内容。最后,它调用实现用户程序指定的系统服务的内核函数。
然而,基于硬件的系统调用方法可能会变得非常慢,因为软件中断和上下文切换需要繁重且复杂的操作。在最新的奔腾 4 处理器上,软件中断和上下文切换比简单的函数调用慢约 132 倍。
顺便说一句,最新的 IA-32 Linux 内核,版本 2.5.53 及更高版本,使用一对特殊指令 sysenter 和 sysexit 进行系统调用。但是,这仍然比简单的函数调用慢约 36 倍。
加速系统调用的明显方法是在内核模式下执行用户进程。这样,系统调用可以快速处理,因为不需要软件中断和上下文切换。它们可以只是函数调用,因为用户进程可以直接访问内核。这种方法似乎存在安全问题,因为在内核模式下执行的用户进程可以访问内核的任意部分。静态程序分析的最新进展,例如类型理论,可以用来保护内核免受用户进程的侵害。许多技术能够实现这种“软件保护”方法,包括 Java 字节码、.NET CIL、O'Caml、类型化汇编语言和携带证明的代码。
作为迈向软件保护内核的第一步,我实现了 KML。KML 是一个修改后的 Linux 内核,它在内核模式下执行用户进程,这些进程随后被称为内核模式用户进程。内核模式用户进程可以直接与内核交互。因此,可以消除系统调用的开销。
KML 以补丁的形式提供给原始 Linux 内核的源代码,因此您需要从源代码构建内核。要使用 KML,请应用补丁并在配置内核时启用内核模式 Linux。构建并安装内核,然后重新启动。KML 补丁可从 www.yl.is.s.u-tokyo.ac.jp/~tosh/kml 获取。
在当前的 KML 中,目录 /trusted 下的程序作为内核模式用户进程运行。内核本身不执行任何安全检查。例如,以下命令
% cp /bin/bash /trusted/bin && /trusted/bin/bash
在内核模式下执行 bash。
内核模式用户进程是普通用户进程,当然,除了它们的权限级别之外。因此,它们基本上可以做普通用户进程可以做的任何事情。例如,内核模式用户进程可以调用所有系统调用,甚至包括 fork、clone 和 mmap。此外,如果您使用最新的 GNU C 库(2.3.2 及更高版本或来自 CVS 的开发版本),系统调用会在内核模式用户进程中自动转换为函数调用,只有少数例外,例如 clone。因此,您程序中的系统调用开销被消除,而无需修改它。
分页机制也有效。也就是说,内核模式用户进程各自拥有自己的地址空间,与普通用户进程相同。此外,即使内核模式用户进程过度分配大量内存,内核也会自动将内存分页到磁盘,就像对普通用户进程所做的那样。
异常,例如段错误和非法指令异常,可以像普通用户进程一样处理,除非程序不正确地访问内核的内存或不正确地执行特权指令。例如,构建以下程序并将其作为内核模式进程执行
int main(int argc, char* argv[]) { *(int*)0 = 1; return 0; }
该进程因段错误异常而终止,而不会发生内核崩溃。这个例子也表明信号机制有效。
作为第二个例子,构建以下程序并将其作为内核模式用户进程执行
int main(int argc, char* argv[]) { for (;;); return 0; }
然后,使用 Ctrl-C 向进程发送 SIGINT 信号。请注意,它接收到信号并正常退出。
第二个例子也表明进程调度有效。也就是说,即使内核模式用户进程进入无限循环,内核也会抢占该进程并执行其他进程。您可能已经注意到,即使在这个例子的无限循环中,您的系统也没有挂起。
虽然内核模式用户进程是普通用户进程,但它们有一些限制。如果内核模式用户进程违反了这些限制,系统将处于未定义状态。在最坏的情况下,您的系统可能会崩溃。
限制 1:不要修改 CS、DS、SS 或 FS 段寄存器。当前的 IA-32 KML 假定这些段寄存器不会被内核模式用户进程修改,并且在内部使用它们。
限制 2:不要不正确地执行特权操作。在内核模式下,程序可以执行任何特权操作。但是,如果您的程序以与内核不一致的方式执行此类操作,系统将处于未定义状态。例如,如果您将以下程序作为内核模式用户进程执行
int main(int argc, char* argv[]) { /* disable hardware interrupts */ __asm__ __volatile__ ("cli"); for (;;); return 0; }
您的系统将会挂起。
根据我的经验,很少有应用程序违反这些限制。违反这些限制的应用程序包括 WINE 和 VMware。这些限制仅针对内核模式用户进程。即使在支持 KML 的内核上运行,普通用户进程也永远不会受到这些限制的影响。
在 IA-32 CPU 中,已执行程序的特权级别由程序执行所在的代码段的特权级别决定。回想一下,IA-32 CPU 的程序计数器由一个段(由 CS 段寄存器指定)和一个段内偏移量(EIP 寄存器)组成。代码段的特权级别由其段描述符确定。段描述符有一个字段用于指定段的特权级别。
基本上,Linux 内核准备了两个段,内核代码段和用户代码段。内核代码段用于内核自身,其特权级别为内核模式。用户代码段用于普通用户进程,其特权级别为用户模式。当对用户进程使用 execve 时,原始 Linux 内核将其 CS 段寄存器设置为用户代码段。因此,用户进程在用户模式下执行。
为了将用户进程作为内核模式用户进程执行,KML 唯一要做的就是将进程的 CS 寄存器设置为内核代码段,而不是用户代码段。然后,该进程在内核模式下执行。由于 KML 的简单方法,内核模式用户进程可以是普通用户进程。
如上一节所述,KML 的基本方法非常简单。它最大的问题被称为堆栈饥饿。首先,我将解释原始 Linux 内核如何在 IA-32 CPU 上处理异常(页错误)和中断(定时器中断)。然后,我将描述堆栈饥饿问题。最后,我将介绍我对这个问题的解决方案。
在原始 Linux 内核中,中断由中断处理例程处理,这些例程在中断描述符表 (IDT) 中指定为门。当发生中断时,IA-32 CPU 停止正在运行的程序的执行,保存程序的执行上下文,并执行中断处理例程。
IA-32 CPU 在中断时如何保存正在运行的程序的执行上下文取决于程序的特权级别。如果程序在用户模式下执行,IA-32 CPU 会自动将其内存堆栈切换到内核堆栈。然后,它将执行上下文(EIP、CS、EFLAGS、ESP 和 SS 寄存器)保存到内核堆栈。另一方面,如果程序在内核模式下执行,IA-32 CPU 不会切换其内存堆栈,而是将上下文(EIP、CS 和 EFLAGS 寄存器)保存到正在运行的程序的内存堆栈。
如果 KML 的内核模式用户进程访问其内存堆栈(CPU 的页表未映射该堆栈),会发生什么情况?首先,会发生页错误,CPU 尝试中断进程并跳转到 IDT 中指定的页错误处理程序。但是,CPU 无法完成这项工作,因为没有堆栈用于保存执行上下文。由于该进程在内核模式下执行,CPU 永远无法将内存堆栈切换到内核堆栈。为了表示这种致命情况,CPU 尝试生成一个特殊的异常,即双重错误。同样,CPU 无法生成双重错误,因为没有堆栈用于保存正在运行的进程的执行上下文。最后,CPU 放弃并重置自身。
为了解决这个堆栈饥饿问题,KML 利用了 IA-32 CPU 的任务管理机制。IA-32 任务管理机制旨在支持内核的进程管理。使用该机制,内核只需一条指令即可在进程之间切换。然而,今天的内核不使用这种机制,因为它比纯软件方法慢。因此,该机制几乎被所有人遗忘。
IA-32 CPU 中任务管理机制的优势在于它可以用于处理中断和异常。由 IA-32 CPU 管理的任务可以设置为 IDT。如果发生中断并且分配了一个任务来处理该中断,CPU 首先将中断程序的执行上下文保存到程序的任务数据结构中,而不是内存堆栈中。然后,CPU 从 IDT 中指定的任务数据结构恢复上下文。
最重要的一点是,如果使用任务管理机制来处理中断,则无需切换内存堆栈。也就是说,如果我们使用该机制处理页错误异常,内核模式用户进程可以安全地访问其内存堆栈。
但是,如果我们使用该机制处理所有页错误,整个系统的性能会下降,因为基于任务的中断处理比普通中断处理慢。
因此,我们仅以这种方式处理双重错误异常。因此,只有由内存堆栈缺失引起的页错误才由任务管理机制处理。根据我的经验,内存堆栈很少引起页错误,性能下降可以忽略不计。
为了衡量性能改进的程度,我进行了两个实验。这两个实验都比较了原始 Linux 内核和 KML 的性能。我使用 sysenter/sysexit 机制来测量原始 Linux 内核的性能,而不是 int 0x80 指令。实验环境如表 1 所示。
在第一个实验中,我测量了 getpid 和 gettimeofday 系统调用的延迟。在测量中,系统调用由用户程序直接调用,没有 libc。延迟使用 rdtsc 指令测量。结果如表 2 所示。
结果表明,在 KML 中,getpid 比原始 Linux 内核快 36 倍,gettimeofday 在 KML 中比在原始 Linux 内核中快两倍。
第二个实验是使用 Iozone 文件系统基准测试进行的文件 I/O 基准测试。我测量了四种类型的文件 I/O 的吞吐量:写入、重写、读取和重读。测量是在从 16KB 到 256KB 的各种文件大小下进行的,缓冲区大小固定为 8KB。底层文件系统是 ext3。在每次测量中,我执行了 30 次 Iozone 基准测试,并选择了最佳吞吐量。
表 3 显示了重读的吞吐量。由于篇幅限制,写入、重写和读取的详细结果已省略。
结果表明,KML 中重读的吞吐量提高了 6.8-21%。此外,写入提高了 0.6-3.2%,重写提高了 3.3-5.3%,读取提高了 3.1-15%。
这些实验结果表明,KML 可以提高频繁调用系统调用的应用程序的性能,例如那些读取或写入许多小文件的应用程序。例如,Web 服务器和数据库可以在 KML 中高效执行。
我已经在 KML 上对 Apache HTTP 服务器进行了基准测试。它没有显示性能提升,因为我只有一个 100Base-T 以太网局域网,它成为了主要的瓶颈。如果我在更快的网络(例如,1000Base-T 以太网或更快)上进行基准测试,我预测它会显示性能提升。
在前面的实验中,值得注意的是,KML 仅消除了系统调用的开销。通过对应用程序进行一些修改,KML 将能够为性能提升做更多的事情。例如,内核模式用户进程可以直接访问内核中的 I/O 缓冲区,以提高 I/O 性能。

Toshiyuki Maeda 是东京大学计算机科学专业的博士候选人。他最喜欢的漫画是《棋魂》(Hikaru's Go)、《JOJO 的奇妙冒险》(Jojo no Kimyo na Boken)和《乱七八糟团》(Runatikku Zatsugidan)。