文章分析了在 Linux 2.6 中引入的对 Intel CPU 快速系统调用指令 SYSENTER/SYSEXIT 支持的实现。Linux 驱动及内核开发者通过了解快速系统调用指令的机制,可以在自己的代码中通过利用这一机制,提高系统性能,并避开由快速系统调用方式带来的一些局限(如系统调用中嵌套系统调用)。
前言
在 Linux 2.4 内核中,用户态 Ring3 代码请求内核态 Ring0 代码完成某些功能是通过系统调用完成的,而系统调用的是通过软中断指令(int 0x80)实现的。在 x86 保护模式中,处理 INT 中断指令时,CPU 首先从中断描述表 IDT 取出对应的门描述符,判断门描述符的种类,然后检查门描述符的级别 DPL 和 INT 指令调用者的级别 CPL,当 CPL其实,在发生系统调用,由 Ring3 进入 Ring0 的这个过程浪费了不少的 CPU 周期,例如,系统调用必然需要由 Ring3 进入 Ring0(由内核调用 INT 指令的方式除外,这多半属于 Hacker 的内核模块所为),权限提升之前和之后的级别是固定的,CPL 肯定是 3,而 INT 80 的 DPL 肯定也是 3,这样 CPU 检查门描述符的 DPL 和调用者的 CPL 就是完全没必要。正是由于如此,Intel x86 CPU 从 PII 300(Family 6,Model 3,Stepping 3)之后,开始支持新的系统调用指令 sysenter/sysexit。sysenter 指令用于由 Ring3 进入 Ring0,SYSEXIT 指令用于由 Ring0 返回 Ring3。由于没有特权级别检查的处理,也没有压栈的操作,所以执行速度比 INT n/IRET 快了不少。
不同系统调用方式的性能比较:
下面是一些来自互联网的有关 sysenter/sysexit 指令和 INT n/IRET 指令在 Intel Pentium CPU 上的性能对比:
表1:系统调用性能测试 测试硬件:Intel? Pentium? III CPU, 450 MHz Processor Family: 6 Model: 7 Stepping: 2
用户模式花费的时间 核心模式花费的时间
基于sysenter/sysexit指令的系统调用 9.833 microseconds 6.833 microseconds
基于中断 INT n 指令的系统调用 17.500 microseconds 7.000 microseconds
数据来源:[1]
数据来源:[2]
表2:各种 CPU 上 INT 0x80 和 SYSENTER 执行速度的比较
CPU Int0x80 sysenter
Athlon XP 1600+ 277 169
800MHz mode 1 athlon 279 170
2.8GHz p4 northwood ht 1152 442
上述数据为对 100000 次 getppid() 系统调用所花费的 CPU 时钟周期取的平均值数据来源[3]。
自这种技术推出之后,人们一直在考虑在 Linux 中加入对这种指令的支持,在 Kernel.org 的邮件列表中,主题为 "Intel P6 vs P7 system call performance" 的大量邮件讨论了采用这种指令的必要性,邮件中列举的理由主要是 Intel 在 Pentium 4 的设计上存在问题,造成 Pentium 4 使用中断方式执行的系统调用比 Pentium 3 以及 AMD Athlon 所耗费的 CPU 时钟周期多上 5~10 倍。因此,在 Pentium 4 平台上,通过 sysenter/sysexit 指令来执行系统调用已经是刻不容缓的需求。
sysenter/sysexit 系统调用的机制:
在 Intel 的软件开发者手册第二、三卷(Vol.2B,Vol.3)中,4.8.7 节是关于 sysenter/sysexit 指令的详细描述。手册中说明,sysenter 指令可用于特权级 3 的用户代码调用特权级 0 的系统内核代码,而 SYSEXIT 指令则用于特权级 0 的系统代码返回用户空间中。sysenter 指令可以在 3,2,1 这三个特权级别调用(Linux 中只用到了特权级 3),而 SYSEXIT 指令只能从特权级 0 调用。
执行 sysenter 指令的系统必须满足两个条件:1.目标 Ring 0 代码段必须是平坦模式(Flat Mode)的 4GB 的可读可执行的非一致代码段。2.目标 RING0 堆栈段必须是平坦模式(Flat Mode)的 4GB 的可读可写向上扩展的栈段。
augustnov 于 2006-10-30 10:34:12发表:
内核中的处理和返回
sysenter_entry 整个的实现可以参见 arch/i386/kernel/entry.S。 内核处理 SYSENTER 的代码和处理 INT 的代码不太一样。通过 sysenter 指令进入 Ring 0 之后,由于当前的 ESP 并非指向正确的内核栈,而是当前 CPU 的 TSS 结构中的一个缓冲区(参见上文),所以首先要解决的是修复 ESP,幸运的是,TSS 结构中 ESP0 成员本身就保存有 Ring 0 状态的 ESP 值,所以在这里将 TSS 结构中 ESP0 的值赋予 ESP 寄存器。将 ESP 恢复成指向正确的堆栈之后,由于 SYSENTER 不是通过调用门进入 Ring 0,所以在堆栈中的上下文和使用 INT 指令的不一样,INT 指令进入 Ring 0 后栈中会保存如下的值。
低地址
返回用户态的EIP
用户态的CS
用户态的EFLAGS
用户态的ESP
用户态的SS(和DS相同)
高地址
因此,为了简化和重用代码,内核会用 pushl 指令往栈中放入上述各值,值得注意的是,内核在栈中放入的相对应用户态 EIP 的值,是一个代码标签 SYSENTER_RETURN,在 vsyscall-sysenter.S 可以看到,它就在 sysenter 指令的后面(在它们之间,有一段 NOP,是内核返回出错时的处理代码)。接下来,处理系统调用的代码就和中断方式的处理代码一模一样了,内核保存所有的寄存器,然后系统调用表找到对应系统调用的入口,完成调用。最后,内核从栈中取出前面存入的用户态的 EIP 和 ESP,存入 edx 和 ecx 寄存器,调用 SYSEXIT 指令返回用户态。返回用户态之后,从栈中取出 ESP,edx,ecx,最终返回 glibc 库。
其它操作系统以及其它硬件平台的支持
值得一提的是,从 Windows XP 开始,Windows 的系统调用方式也从软中断 int 0x2e 转换到采用 sysenter 方式,由于完全不再支持 int 方式,因此 Windows XP 的对 CPU 的最低配置要求是 PentiumII 300MHz。在其它的操作系统例如 *BSD 系列,目前并没有提供对 sysenter 指令的支持。
在 CPU 方面,AMD 的 CPU 支持一套与之对应的指令 SYSCALL/SYSRET。在纯 32 位的 AMD CPU 上,还没有支持 sysenter 指令,而在 AMD 推出的 AMD64 系列 CPU 上,处于某些模式的情况下,CPU 能够支持 sysenter/sysexit 指令。在 Linux 内核针对 AMD64 架构的代码中,采用的还是 SYSCALL/SYSRET 指令。至于这两种指令最终谁将成为标准,目前还无法得出结论。
未来
我们将 Intel 的 sysenter/sysexit 指令,AMD 的 SYSCALL/SYSRET 指令统称为"快速系统调用指令"。"快速系统调用指令"比起中断指令来说,其消耗时间必然会少一些,但是随着 CPU 设计的发展,将来应该不会再出现类似 Intel Pentium4 这样悬殊的差距。而"快速系统调用指令"比起中断方式的系统调用方式,还存在一定局限,例如无法在一个系统调用处理过程中再通过"快速系统调用指令"调用别的系统调用。因此,并不一定每个系统调用都需要通过"快速系统调用指令"来实现。比如,对于复杂的系统调用例如 fork,两种系统调用方式的时间差和系统调用本身运行消耗的时间来比,可以忽略不计,此处采取"快速系统调用指令"方式没有什么必要。而真正应该使用"快速系统调用指令"方式的,是那些本身运行时间很短,对时间精确性要求高的系统调用,例如 getuid、gettimeofday 等等。因此,采取灵活的手段,针对不同的系统调用采取不同的方式,才能得到最优化的性能和实现最完美的功能。
augustnov 于 2006-10-30 10:33:23发表:
由用户态经库函数进入内核态
为了配合内核使用新的系统调用方式,glibc 中要做一定的修改。新的 glibc-2.3.2(及其以后版本中)中已经包含了这个改动,在 glibc 源代码的 sysdeps/unix/sysv/linux/i386/sysdep.h 文件中,处理系统调用的宏 INTERNAL_SYSCALL 在不同的编译选项下有不同的结果。在打开支持 sysenter/sysexit 指令的选项 I386_USE_SYSENTER 下,系统调用会有两种方式,在静态链接(编译时加上 -static 选项)情况下,采用 "call *_dl_sysinfo" 指令;在动态链接情况下,采用 "call *%gs:0x10" 指令。这两种情况由 glibc 库采用哪种方法链接,实际上最终都相当于调用某个固定地址的代码。下面我们通过一个小小的程序,配合 gdb 来验证。
首先是一个静态编译的程序,代码很简单:
main() { getuid(); }
将代码加上 static 选项用 gcc 静态编译,然后用 gdb 装载并反编译 main 函数。
[root@test opt]# gcc test.c -o ./static -static [root@test opt]
# gdb ./static (gdb) disassemble main 0x08048204 :
push %ebp 0x08048205 : mov %esp,%ebp 0x08048207 :
sub $0x8,%esp 0x0804820a :
and $0xfffffff0,%esp 0x0804820d :
mov $0x0,%eax 0x08048212 : sub
%eax,%esp 0x08048214 : call 0x804cb20
<__getuid> 0x08048219 : leave 0x0804821a : ret
可以看出,main 函数中调用了 __getuid 函数,接着反编译 __getuid 函数。
(gdb) disassemble 0x804cb20 0x0804cb20 <__getuid+0>:
push %ebp 0x0804cb21 <__getuid+1>: mov
0x80aa028,%eax 0x0804cb26 <__getuid+6>: mov
%esp,%ebp 0x0804cb28 <__getuid+8>: test
%eax,%eax 0x0804cb2a <__getuid+10>: jle
0x804cb40 <__getuid+32> 0x0804cb2c <__getuid+12>:
mov $0x18,%eax 0x0804cb31 <__getuid+17>:
call *0x80aa054 0x0804cb37 <__getuid+23>:
pop %ebp 0x0804cb38 <__getuid+24>: ret
上面只是 __getuid 函数的一部分。可以看到 __getuid 将 eax 寄存器赋值为 getuid 系统调用的功能号 0x18 然后调用了另一个函数,这个函数的入口在哪里呢?接着查看位于地址 0x80aa054 的值。
(gdb) X 0x80aa054 0x80aa054 <_dl_sysinfo>: 0x0804d7f6
看起来不像是指向内核映射页面内的代码,但是,可以确认,__dl_sysinfo 指针的指向的地址就是 0x80aa054。下面,我们试着启动这个程序,然后停在程序第一条语句,再查看这个地方的值。
(gdb) b main Breakpoint 1 at 0x804820a (gdb)
r Starting program: /opt/static Breakpoint 1,
0x0804820a in main () (gdb) X 0x80aa054 0x80aa054
<_dl_sysinfo>: 0xffffe400
可以看到,_dl_sysinfo 指针指向的数值已经发生了变化,指向了 0xffffe400,如果我们继续运行程序,__getuid 函数将会调用地址 0xffffe400 处的代码。
接下来,我们将上面的代码编译成动态链接的方式,即默认方式,用 gdb 装载并反编译 main 函数
[root@test opt]# gcc test.c -o ./dynamic [root@test opt]
# gdb ./dynamic (gdb) disassemble main 0x08048204 :
push %ebp 0x08048205 : mov %esp,%ebp 0x08048207 :
sub $0x8,%esp 0x0804820a : and $0xfffffff0,
%esp 0x0804820d : mov $0x0,%eax 0x08048212 : sub
%eax,%esp 0x08048214 : call 0x8048288 0x08048219 :
leave 0x0804821a : ret
由于 libc 库是在程序初始化时才被装载,所以我们先启动程序,并停在 main 第一条语句,然后反汇编 getuid 库函数。
(gdb) b main Breakpoint 1 at 0x804820a
(gdb) r Starting program: /opt/dynamic Breakpoint 1,
0x0804820a in main () (gdb) disassemble getuid Dump
of assembler code for function getuid: 0x40219e50
<__getuid+0>: push %ebp 0x40219e51
<__getuid+1>: mov %esp,%ebp 0x40219e53
<__getuid+3>: push %ebx 0x40219e54
<__getuid+4>: call 0x40219e59
<__getuid+9> 0x40219e59 <__getuid+9>:
pop %ebx 0x40219e5a <__getuid+10>:
add $0x84b0f,%ebx 0x40219e60
<__getuid+16>: mov 0xffffd87c(%ebx),
%eax 0x40219e66 <__getuid+22>: test
%eax,%eax 0x40219e68 <__getuid+24>:
jle 0x40219e80 <__getuid+48> 0x40219e6a
<__getuid+26>: mov $0x18,%eax 0x40219e6f
<__getuid+31>: call *%gs:0x10 0x40219e76
<__getuid+38>: pop %ebx 0x40219e77
<__getuid+39>: pop %ebp 0x40219e78 <__getuid+40>: ret
可以看出,库函数 getuid 将 eax 寄存器设置为 getuid 系统调用的调用号 0x18,然后调用 %gs:0x10 所指向的函数。在 gdb 中,无法查看非 DS 段的数据内容,所以无法查看 %gs:0x10 所保存的实际数值,不过我们可以通过编程的办法,内嵌汇编将 %gs:0x10 的值赋予某个局部变量来得到这个数值,而这个数值也是 0xffffe400,具体代码这里就不再赘述。
由此可见,无论是静态还是动态方式,最终我们都来到了 0xffffe400 这里的一段代码,这里就是内核为我们映射的系统调用入口代码。在 gdb 中,我们可以直接反汇编来查看这里的代码。
(gdb) disassemble 0xffffe400 0xffffe414
Dump of assembler code from 0xffffe400 to
0xffffe414: 0xffffe400: push
%ecx 0xffffe401: push
%edx 0xffffe402: push
%ebp 0xffffe403: mov
%esp,%ebp 0xffffe405:
sysenter 0xffffe407: nop 0xffffe408:
nop 0xffffe409: nop 0xffffe40a:
nop 0xffffe40b: nop 0xffffe40c:
nop 0xffffe40d: nop 0xffffe40e:
jmp 0xffffe403 0xffffe410: pop
%ebp 0xffffe411: pop %edx 0xffffe412:
pop %ecx 0xffffe413: ret End of assembler dump.
这段代码正是 arch/i386/kernel/vsyscall-sysenter.S 文件中的代码。其中,在 sysenter 之前的是入口代码,在 0xffffe410 开始的是内核返回处理代码(后面提到的 SYSENTER_RETURN 即指向这里)。在入口代码中,首先是保存当前的 ecx,edx(由于 sysexit 指令需要使用这两个寄存器)以及 ebp。然后调用 sysenter 指令,跳转到内核 Ring 0 代码,也就是 sysenter_entry 入口处。
augustnov 于 2006-10-30 10:32:24发表:
Linux 对 sysenter/sysexit 系统调用方式的支持
在 2.4 内核中,直到最近的发布的 2.4.26-rc2 版本,没有加入对 sysenter/sysexit 指令的支持。而对 sysenter/sysexit 指令的支持最早是2002 年,由 Linus Torvalds 编写并首次加入 2.5 版内核中的,经过多方测试和多次 patch,最终正式加入到了 2.6 版本的内核中。http://kerneltrap.org/node/view/531/1996,http://lwn.net/Articles/18414/。
具体谈到系统调用的完成,不能孤立的看内核的代码,我们知道,系统调用多被封装成库函数提供给应用程序调用,应用程序调用库函数后,由 glibc 库负责进入内核调用系统调用函数。在 2.4 内核加上老版的 glibc 的情况下,库函数所做的就是通过 int 指令来完成系统调用,而内核提供的系统调用接口很简单,只要在 IDT 中提供 INT 0x80 的入口,库就可以完成中断调用。
在 2.6 内核中,内核代码同时包含了对 int 0x80 中断方式和 sysenter 指令方式调用的支持,因此内核会给用户空间提供一段入口代码,内核启动时根据 CPU 类型,决定这段代码采取哪种系统调用方式。对于 glibc 来说,无需考虑系统调用方式,直接调用这段入口代码,即可完成系统调用。这样做还可以尽量减少对 glibc 的改动,在 glibc 的源码中,只需将 "int $0x80" 指令替换成 "call 入口地址" 即可。
下面,以 2.6.0 的内核代码配合支持 SYSENTER 调用方式的 glibc2.3.3 为例,分析一下系统调用的具体实现。
内核在启动时做的准备
前面说到的这段入口代码,根据调用方式分为两个文件,支持 sysenter 指令的代码包含在文件 arch/i386/kernel/vsyscall-sysenter.S 中,支持int中断的代码包含在arch/i386/kernel/vsyscall-int80.S中,入口名都是__kernel_vsyscall,这两个文件编译出的二进制代码由arch/i386/kernel/vsyscall.S所包含,并导出起始地址和结束地址。
2.6 内核在启动的时候,调用了新增的函数 sysenter_setup(参见 arch/i386/kernel/sysenter.c),在这个函数中,内核将虚拟内存空间的顶端一个固定地址页面(从 0xffffe000 开始到 0xffffeffff 的 4k 大小)映射到一个空闲的物理内存页面。然后通过之前执行 CPUID 的指令得到的数据,检测 CPU 是否支持 sysenter/sysexit 指令。如果 CPU 不支持,那么将采用 INT 调用方式的入口代码拷贝到这个页面中,然后返回。相反,如果 CPU 支持 SYSETER/SYSEXIT 指令,则将采用 SYSENTER 调用方式的入口代码拷贝到这个页面中。使用宏 on_each_cpu 在每个 CPU 上执行 enable_sep_cpu 这个函数。
在 enable_sep_cpu 函数中,内核将当前 CPU 的 TSS 结构中的 ss1 设置为当前内核使用的代码段,esp1 设置为该 TSS 结构中保留的一个 256 字节大小的堆栈。在 X86 中,TSS 结构中 ss1 和 esp1 本来是用于保存 Ring 1 进程的堆栈段和堆栈指针的。由于内核在启动时,并不能预知调用 sysenter 指令进入 Ring 0 后 esp 的确切值,而应用程序又无权调用 wrmsr 指令动态设置,所以此时就借用 esp1 指向一个固定的缓冲区来填充这个 MSR 寄存器,由于 Ring 1 根本没被启用,所以并不会对系统造成任何影响。在下面的文章中会介绍进入 Ring 0 之后,内核如何修复 ESP 来指向正确的 Ring 0 堆栈。关于 TSS 结构更细节的应用可参考代码 include/asm-i386/processor.h)。
然后,内核通过 wrmsr (msr,val1,val2) 宏调用 wrmsr 指令对当前 CPU 设置 MSR 寄存器,可以看出调用宏的第三个参数即 edx 都被设置为 0。其中 SYSENTER_CS_MSR 的值被设置为当前内核用的所在代码段;SYSENTER_ESP_MSR 被设置为 esp1,即指向当前 CPU 的 TSS 结构中的堆栈;SYSENTER_EIP_MSR 则被设置为内核中处理 sysenter 指令的接口函数 sysenter_entry(参见 arch/i386/kernel/entry.S)。这样,sysenter 指令的准备工作就完成了。
通过内核在启动时进行这样的设置,在每个进程的进程空间中,都能访问到内核所映射的这个代码页面,当然这个页面对于应用程序来说是只读的。我们通过新版的ldd工具查看任意一个可执行程序,可以看到下面的结果:
[root@test]# file dynamic dynamic: ELF 32-bit LSB executable,
Intel 80386, version 1 (SYSV), for GNU/Linux 2.2.5,
dynamically linked (uses shared libs), not stripped
[root@test]# ldd dynamic linux-gate.so.1 =>
(0xffffe000) libc.so.6 => /lib/tls/libc.so.6
(0x4002c000) /lib/ld-linux.so.2 =>
/lib/ld-linux.so.2 (0x40000000)
这个所谓的 "linux-gate.so.1" 的内容就是内核映射的代码,系统中其实并不存在这样一个链接库文件,它的名字是由ldd自己起的,而在老版本的 ldd中,虽然能够检测到这段代码,但是由于没有命名而且在系统中找不到对应链接库文件,所以会有一些显示上的问题。
augustnov 于 2006-10-30 10:31:28发表:
在 Intel 的手册中,还提到了 sysenter/sysexit 和 int n/iret 指令的一个区别,那就是 sysenter/sysexit 指令并不成对,sysenter 指令并不会把 SYSEXIT 所需的返回地址压栈,sysexit 返回的地址并不一定是 sysenter 指令的下一个指令地址。调用 sysenter/sysexit 指令地址的跳转是通过设置一组特殊寄存器实现的。这些寄存器包括:
SYSENTER_CS_MSR - 用于指定要执行的 Ring 0 代码的代码段选择符,由它还能得出目标 Ring 0 所用堆栈段的段选择符;
SYSENTER_EIP_MSR - 用于指定要执行的 Ring 0 代码的起始地址;
SYSENTER_ESP_MSR-用于指定要执行的Ring 0代码所使用的栈指针
这些寄存器可以通过 wrmsr 指令来设置,执行 wrmsr 指令时,通过寄存器 edx、eax 指定设置的值,edx 指定值的高 32 位,eax 指定值的低 32 位,在设置上述寄存器时,edx 都是 0,通过寄存器 ecx 指定填充的 MSR 寄存器,sysenter_CS_MSR、sysenter_ESP_MSR、sysenter_EIP_MSR 寄存器分别对应 0x174、0x175、0x176,需要注意的是,wrmsr 指令只能在 Ring 0 执行。
这里还要介绍一个特性,就是 Ring0、Ring3 的代码段描述符和堆栈段描述符在全局描述符表 GDT 中是顺序排列的,这样只需知道 SYSENTER_CS_MSR 中指定的 Ring0 的代码段描述符,就可以推算出 Ring0 的堆栈段描述符以及 Ring3 的代码段描述符和堆栈段描述符。
在 Ring3 的代码调用了 sysenter 指令之后,CPU 会做出如下的操作:
1. 将 SYSENTER_CS_MSR 的值装载到 cs 寄存器
2. 将 SYSENTER_EIP_MSR 的值装载到 eip 寄存器
3. 将 SYSENTER_CS_MSR 的值加 8(Ring0 的堆栈段描述符)装载到 ss 寄存器。
4. 将 SYSENTER_ESP_MSR 的值装载到 esp 寄存器
5. 将特权级切换到 Ring0
6. 如果 EFLAGS 寄存器的 VM 标志被置位,则清除该标志
7. 开始执行指定的 Ring0 代码
在 Ring0 代码执行完毕,调用 SYSEXIT 指令退回 Ring3 时,CPU 会做出如下操作:
1. 将 SYSENTER_CS_MSR 的值加 16(Ring3 的代码段描述符)装载到 cs 寄存器
2. 将寄存器 edx 的值装载到 eip 寄存器
3. 将 SYSENTER_CS_MSR 的值加 24(Ring3 的堆栈段描述符)装载到 ss 寄存器
4. 将寄存器 ecx 的值装载到 esp 寄存器
5. 将特权级切换到 Ring3
6. 继续执行 Ring3 的代码
由此可知,在调用 SYSENTER 进入 Ring0 之前,一定需要通过 wrmsr 指令设置好 Ring0 代码的相关信息,在调用 SYSEXIT 之前,还要保证寄存器edx、ecx 的正确性。
如何得知 CPU 是否支持 sysenter/sysexit 指令
根据 Intel 的 CPU 手册,我们可以通过 CPUID 指令来查看 CPU 是否支持 sysenter/sysexit 指令,做法是将 EAX 寄存器赋值 1,调用 CPUID 指令,寄存器 edx 中第 11 位(这一位名称为 SEP)就表示是否支持。在调用 CPUID 指令之后,还需要查看 CPU 的 Family、Model、Stepping 属性来确认,因为据称 Pentium Pro 处理器会报告 SEP 但是却不支持 sysenter/sysexit 指令。只有 Family 大于等于 6,Model 大于等于 3,Stepping 大于等于 3 的时候,才能确认 CPU 支持 sysenter/sysexit 指令。