###1. VDSO
VDSO就是Virtual Dynamic Shared Object,就是内核提供的虚拟的.so,这类.so文件不在磁盘上,而是包含在内核里面。内核把包含某一.so的物理页在程序启动的时候映射入其进程的内存空间,对应的程序就可以当普通的.so来使用里头的函数
linux上目前使用的vdso是“linux-vdso.so.1”(旧版的名称可能是“linux-gate.so.1”), 通过ldd命令可以发现,基本所有的应用都会依赖这一so
$ ldd cat
linux-vdso.so.1 => (0x00007fff47ffe000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f45f6af2000)
/lib64/ld-linux-x86-64.so.2 (0x00007f45f6ecc000)
VDSO在进程中映射的地址可通过如下方式来查看:
$ cat /proc/xxx/maps | grep vdso
需要注意的是, 新的内核中提供了的进程地址随机化功能, 因此,linux-vdso.so.1每一次的映射地址都不相同(即使是对于同一个可执行程序), linux中的”/proc/sys/kernel/randomize_va_space”用于控制linux的进程地址随机化, 取值如下:
- 0 : 表示关闭内存地址随机化
- 1 : 表示将mmap的基址,stack和vdso地址随机化
- 2 : 表示在1的基础上, heap地址随机化
可通过如下命令关闭进程地址随机化
$ echo "0" > /proc/sys/kernel/randomize_va_space
###2. VDSO和glibc
往往linux中新增加一个系统调用, glibc要花很久才能添加支持, 因为glibc还要支持 BSD,SysV Windows, solari, hurd等, 而linux同样也要兼容不同版本的glibc, 用户可能会经常升级内核而不升级glibc, 这样双方都背负了很大的历史包袱, 为次linux让glibc变成VDSO进驻内核, 这样,随内核发行的libc就唯一的和一个特定版本的内核绑定到一起了, 双方都不需要为了兼容对方的多个版本而额外添加一堆代码, 也减少了bug, 当然, VDSO中的glibc只是对系统调用的包裹,和平台无关的部分还是存放在用户空间
###3. VDSO和系统调用
####3.1 int 0x80
在 Linux 2.4 内核中,用户态 Ring3 代码请求内核态 Ring0 代码完成某些功能是通过系统调用完成的,而系统调用的是通过软中断指令(int 0x80)实现的。在 x86 保护模式中,处理 INT 中断指令时,CPU 首先从中断描述表 IDT 取出对应的门描述符,判断门描述符的种类,然后检查门描述符的级别 DPL 和 INT 指令调用者的级别 CPL,当 CPL<=DPL 也就是说 INT 调用者级别高于描述符指定级别时,才能成功调用,最后再根据描述符的内容,进行压栈、跳转、权限级别提升。内核代码执行完毕之后,调用 IRET 指令返回,IRET 指令恢复用户栈,并跳转会低级别的代码。
在发生系统调用,由 Ring3 进入 Ring0 的这个过程浪费了不少的 CPU 周期,例如,系统调用必然需要由 Ring3 进入 Ring0(由内核调用 INT 指令的方式除外,这多半属于 Hacker 的内核模块所为),权限提升之前和之后的级别是固定的,CPL 肯定是 3,而 INT 80 的 DPL 肯定也是 3,这样 CPU 检查门描述符的 DPL 和调用者的 CPL 就是完全没必要
####3.2 sysenter/sysexit
Intel x86 CPU 从 PII 300(Family 6,Model 3,Stepping 3)之后,开始支持新的系统调用指令 sysenter/sysexit。sysenter 指令用于由 Ring3 进入 Ring0,SYSEXIT 指令用于由 Ring0 返回 Ring3。由于没有特权级别检查的处理,也没有压栈的操作,所以执行速度比 INT n/IRET 快了不少
在 Intel 的软件开发者手册第二、三卷(Vol.2B,Vol.3)中,4.8.7 节是关于 sysenter/sysexit 指令的详细描述。手册中说明,sysenter 指令可用于特权级 3 的用户代码调用特权级 0 的系统内核代码,而 SYSEXIT 指令则用于特权级 0 的系统代码返回用户空间中。sysenter 指令可以在 3,2,1 这三个特权级别调用(Linux 中只用到了特权级 3),而 SYSEXIT 指令只能从特权级 0 调用
在 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 执行
在 Ring3 的代码调用了 sysenter 指令之后,CPU 会做出如下的操作:
- 将 SYSENTER_CS_MSR 的值装载到 cs 寄存器.
- 将 SYSENTER_EIP_MSR 的值装载到 eip 寄存器.
- 将 SYSENTER_CS_MSR 的值加 8(Ring0 的堆栈段描述符)装载到 ss 寄存器.
- 将 SYSENTER_ESP_MSR 的值装载到 esp 寄存器.
- 将特权级切换到 Ring0.
- 如果 EFLAGS 寄存器的 VM 标志被置位,则清除该标志.
- 开始执行指定的 Ring0 代码.
在 Ring0 代码执行完毕,调用 SYSEXIT 指令退回 Ring3 时,CPU 会做出如下操作:
- 将 SYSENTER_CS_MSR 的值加 16(Ring3 的代码段描述符)装载到 cs 寄存器.
- 将寄存器 edx 的值装载到 eip 寄存器.
- 将 SYSENTER_CS_MSR 的值加 24(Ring3 的堆栈段描述符)装载到 ss 寄存器.
- 将寄存器 ecx 的值装载到 esp 寄存器.
- 将特权级切换到 Ring3.
- 继续执行 Ring3 的代码.
由此可知,在调用 SYSENTER 进入 Ring0 之前,一定需要通过 wrmsr 指令设置好 Ring0 代码的相关信息,在调用 SYSEXIT 之前,还要保证寄存器edx、ecx 的正确性
AMD 的 CPU 支持一套与之对应的指令 SYSCALL/SYSRET。在纯 32 位的 AMD CPU 上,还没有支持 sysenter 指令,而在 AMD 推出的 AMD64 系列 CPU 上,处于某些模式的情况下,CPU 能够支持 sysenter/sysexit 指令。在 Linux 内核针对 AMD64 架构的代码中,采用的还是 SYSCALL/SYSRET 指令
####3.3 检查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 指令
####3.4 linux对2.6的两种调用方式的支持
在 2.6 内核中,内核代码同时包含了对 int 0x80 中断方式和 sysenter 指令方式调用的支持,因此内核会给用户空间提供一段入口代码,内核启动时根据 CPU 类型,决定这段代码采取哪种系统调用方式。对于 glibc 来说,无需考虑系统调用方式,直接调用这段入口代码,即可完成系统调用。
支持 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调用方式的入口代码拷贝到这个页面中
通过内核在启动时的设置,在每个进程的进程空间中,都能访问到内核所映射的这个代码页面, 这一部分包含在VDSO中, 即所看到的“linux-vdso.so.1”
####3.5 快速系统调用指令
我们将 Intel 的 sysenter/sysexit 指令,AMD 的 SYSCALL/SYSRET 指令统称为”快速系统调用指令”。”快速系统调用指令”比起中断指令来说,其消耗时间必然会少一些,但是随着 CPU 设计的发展,将来应该不会再出现类似 Intel Pentium4 这样悬殊的差距。而”快速系统调用指令”比起中断方式的系统调用方式,还存在一定局限,例如无法在一个系统调用处理过程中再通过”快速系统调用指令”调用别的系统调用。因此,并不一定每个系统调用都需要通过”快速系统调用指令”来实现。比如,对于复杂的系统调用例如 fork,两种系统调用方式的时间差和系统调用本身运行消耗的时间来比,可以忽略不计,此处采取”快速系统调用指令”方式没有什么必要。而真正应该使用”快速系统调用指令”方式的,是那些本身运行时间很短,对时间精确性要求高的系统调用,例如 getuid、gettimeofday 等等。因此,采取灵活的手段,针对不同的系统调用采取不同的方式,才能得到最优化的性能和实现最完美的功能