☰
Linux 内核系统调用提速机制解析:vsyscall 与 vDSO(linux-insides-zh 系统调用系列第三节)
2026/10/4 13:48:57 网站建设 项目流程
  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides-zh
点击查看免费下载

本文基于 linux-insides-zh 仓库中《Linux 内核系统调用》章节的第三节整理扩充。该节从 上一节 讨论的"用户空间如何发起系统调用、内核如何经系统调用表处理"出发,深入讲解两个与系统调用形态相似但旨在绕过传统系统调用开销的机制——vsyscall(虚拟系统调用)与vDSO(虚拟动态共享对象)。读完本文,你将掌握:vsyscall 固定地址映射的初始化路径、vsyscall=内核命令行参数的三种模式及其安全考量、vDSO 镜像的生成与按进程动态映射的原理,以及两者在x86_64平台上的本质差异。

为什么需要"加速的系统调用"

常规系统调用是昂贵的:用户程序执行syscall指令后,处理器需要中断当前任务、切换到内核模式上下文,由内核经 系统调用表 分发到具体处理函数,处理完毕后再跳回用户空间。这一过程伴随频繁的上下文切换(context switch)。

vsyscall与vDSO的设计目标,就是让某些"高频且逻辑简单"的系统调用(如读取时间戳gettimeofday、time、getcpu)在用户空间直接执行,从而完全避免上下文切换。两者的核心思路都是:内核把包含系统调用实现的内存页映射进用户进程,用户程序不经过内核即可完成调用。但两者在"映射形态"上有根本区别,这正是本节内容的重点。

vsyscall:最古老的虚拟系统调用

固定地址的用户空间内核页

vsyscall(virtual system call)是最早出现的加速机制,工作原理非常朴素:Linux 内核在用户空间映射一个包含若干变量及若干系统调用实现的内存页。对于x86_64架构,内核文档给出的 vsyscall 内存区域地址为:

ffffffffff600000 - ffffffffffdfffff (=8 MB) vsyscalls

实际观察一个运行中的进程,其 vsyscall 映射通常只有一页(4 KB):

~$ sudo cat /proc/1/maps | grep vsyscall ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]

注意映射属性中的r-xp——可读、可执行、不可写,且该地址是全局固定的:在任何进程、任何时刻,vsyscall 页都位于ffffffffff600000。这份"固定"既是 vsyscall 的优势(glibc 可以直接硬编码地址),也埋下了安全隐患(后文详述)。

map_vsyscall:映射发生在内核初始化期

vsyscall 页的映射由 arch/x86/entry/vsyscall/vsyscall_64.c 中的map_vsyscall函数完成。该函数在 Linux 内核架构初始化阶段被调用——具体是在setup_arch函数中,而setup_arch是本书 初始化系列第五章 讨论过的庞大架构初始化函数,本仓库的 Initialization/linux-initialization-7.md 中"vsyscall 映射"一节也复述了这段调用关系。

map_vsyscall的实现是否生效,取决于内核配置选项CONFIG_X86_VSYSCALL_EMULATION:

#ifdef CONFIG_X86_VSYSCALL_EMULATION extern void map_vsyscall(void); #else static inline void map_vsyscall(void) {} #endif

该配置选项在帮助文本中描述为"使能 vsyscall 模拟"(enable vsyscall emulation)。这里就引出一个关键问题:为什么要"模拟" vsyscall?答案是:vsyscall 出于安全原因是一种遗留(legacy)ABI。它的地址是绑定的、固定不变的,这意味着攻击者可以预知该页位置,增加了被利用的风险,因此现代内核默认不再以"原生执行"的方式对待它,而是选择"模拟"。

map_vsyscall的实现如下:

void __init map_vsyscall(void) { extern char __vsyscall_page; unsigned long physaddr_vsyscall = __pa_symbol(&__vsyscall_page); ... ... ... }

函数开头通过宏__pa_symbol获取__vsyscall_page的物理地址(该宏的原理在本书初始化系列第四章中讨论过)。

__vsyscall_page:一个装着三个系统调用的汇编页

__vsyscall_page定义在 arch/x86/entry/vsyscall/vsyscall_emu_64.S 汇编文件中,位于.data..page_aligned, aw段,其虚拟地址为:

ffffffff81881000 D __vsyscall_page

页面内容极其精简——只包含三个系统调用的入口:

__vsyscall_page: mov $__NR_gettimeofday, %rax syscall ret .balign 1024, 0xcc mov $__NR_time, %rax syscall ret .balign 1024, 0xcc mov $__NR_getcpu, %rax syscall ret

三个系统调用分别是:

  • gettimeofday(获取当前时间)
  • time(获取当前时间戳)
  • getcpu(获取当前 CPU 编号)

其中0xcc是int3(断点)指令的操作码,0x400(1024)字节的对齐让每个入口的地址可被精确预计算——这正是 glibc 能够硬编码入口地址的基础。在 Initialization/linux-initialization-7.md 中可以找到该页的完整汇编定义(含.balign PAGE_SIZE, 0xcc与.globl __vsyscall_page)。

固定映射(fixmap)与页权限标志

回到map_vsyscall:拿到__vsyscall_page的物理地址后,内核通过__set_fixmap把 vsyscall 页挂载到固定映射(fix-mapped)地址区:

if (vsyscall_mode != NONE) __set_fixmap(VSYSCALL_PAGE, physaddr_vsyscall, vsyscall_mode == NATIVE ? PAGE_KERNEL_VSYSCALL : PAGE_KERNEL_VVAR);

关于固定映射地址(fixmap),本仓库的 MM/linux-mm-2.md 有专门讲解:固定映射地址是一组编译期确定的虚拟地址,物理地址只在引导期被设定,之后内核像指针一样使用它们但绝不修改地址。固定映射区位于内核虚拟地址空间的顶部(模块区之后、0xffffffffffffffff之前)。

__set_fixmap的三个参数依次是:fixed_addresses枚举的索引、待映射页的物理地址、页标志位。VSYSCALL_PAGE是x86_64下fixed_addresses枚举中的一个元素(值为511):

enum fixed_addresses { ... #ifdef CONFIG_X86_VSYSCALL_EMULATION VSYSCALL_PAGE = (FIXADDR_TOP - VSYSCALL_ADDR) >> PAGE_SHIFT, #endif ... };

页标志位取决于vsyscall_mode变量,两种宏展开如下:

#define __PAGE_KERNEL_VSYSCALL (__PAGE_KERNEL_RX | _PAGE_USER) #define __PAGE_KERNEL_VVAR (__PAGE_KERNEL_RO | _PAGE_USER)

两者的共同点是都带_PAGE_USER,即允许低特权级的用户模式进程访问;区别在于权限性质:

  • __PAGE_KERNEL_VSYSCALL=__PAGE_KERNEL_RX | _PAGE_USER,可读可执行;
  • __PAGE_KERNEL_VVAR=__PAGE_KERNEL_RO | _PAGE_USER,只读不可执行。

也就是说,vsyscall_mode为NATIVE时,页面可执行,虚拟系统调用以本地syscall指令直接运行;为EMULATE时,页面只读不可执行,任何执行尝试都会触发 page fault,由内核模拟处理。

vsyscall 内核命令行参数:native / emulate / none

vsyscall_mode的值由vsyscall_setup函数根据内核命令行参数设定:

static int __init vsyscall_setup(char *str) { if (str) { if (!strcmp("emulate", str)) vsyscall_mode = EMULATE; else if (!strcmp("native", str)) vsyscall_mode = NATIVE; else if (!strcmp("none", str)) vsyscall_mode = NONE; else return -EINVAL; return 0; } return -EINVAL; }

该函数通过early_param宏注册,在内核早期参数解析阶段被调用:

early_param("vsyscall", vsyscall_setup);

early_param宏的机制(把参数处理函数挂入__setup_param链表、由parse_early_param/do_early_param遍历执行)详见本书初始化系列第六章。三种取值对应三种行为:

命令行值vsyscall_mode页标志行为
vsyscall=nativeNATIVEPAGE_KERNEL_VSYSCALL(RX)页面可执行,虚拟系统调用以本地syscall指令运行
vsyscall=emulateEMULATEPAGE_KERNEL_VVAR(RO)页面不可执行,执行尝试触发 page fault 后由内核模拟(默认)
vsyscall=noneNONE—不映射 vsyscall 页,任何访问都出错

BUILD_BUG_ON:编译期校验地址一致性

map_vsyscall的最后,通过BUILD_BUG_ON宏检查 vsyscall 页的虚拟地址是否严格等于常量VSYSCALL_ADDR(ffffffffff600000):

BUILD_BUG_ON((unsigned long)__fix_to_virt(VSYSCALL_PAGE) != (unsigned long)VSYSCALL_ADDR);

BUILD_BUG_ON是编译期断言,若条件不成立则编译失败——这保证了"固定映射页的虚拟地址"与"glibc 预知的地址"永远一致。该宏的用法在初始化系列第一章有介绍。

glibc 如何知道 vsyscall 入口地址

由于 vsyscall 地址固定且入口按 1024 字节对齐,glibc 可以在编译期硬编码所有虚拟系统调用处理器的地址:

#define VSYSCALL_ADDR_vgettimeofday 0xffffffffff600000 #define VSYSCALL_ADDR_vtime 0xffffffffff600400 #define VSYSCALL_ADDR_vgetcpu 0xffffffffff600800

调用流程为:把虚拟系统调用请求映射到__vsyscall_page+VSYSCALL_ADDR_xxx的偏移处,将虚拟系统调用编号放入通用寄存器(%rax),随后执行本地的syscall指令。

emulate 模式:page fault 与 emulate_vsyscall

在vsyscall=emulate模式下,由于页属性为只读不可执行(__PAGE_KERNEL_VVAR),任何对虚拟系统调用处理器的"提升执行"尝试都会触发 page fault(#PF)。do_page_fault是 page fault 的处理函数,它会分析最近一次缺页的原因;当判断为 emulate 模式下的虚拟系统调用时,控制权交给定义在 arch/x86/entry/vsyscall/vsyscall_64.c 中的emulate_vsyscall函数。

emulate_vsyscall首先通过addr_to_vsyscall_nr把触发地址转换为虚拟系统调用编号,并做一系列合法性检查;非法或未对齐的地址会被视为错误并发送段错误信号(SIGSEGV):

... vsyscall_nr = addr_to_vsyscall_nr(address); if (vsyscall_nr < 0) { warn_bad_vsyscall(KERN_WARNING, regs, "misaligned vsyscall..."); goto sigsegv; } ... sigsegv: force_sig(SIGSEGV, current); return true;

通过检查后,函数按编号分发到对应的内核系统调用实现——注意参数来自保存的寄存器(regs->di、regs->si对应%rdi、%rsi):

switch (vsyscall_nr) { case 0: ret = sys_gettimeofday( (struct timeval __user *)regs->di, (struct timezone __user *)regs->si); break; ... ... ... }

最后,与普通系统调用处理完毕后一样,把返回值写入%rax(regs->ax),并手工恢复指令指针、将栈指针加 8 字节以模拟ret指令,从而回到调用者继续执行:

regs->ax = ret; do_ret: regs->ip = caller; regs->sp += 8; return true;

至此,一个虚拟系统调用在"用户空间触发 → 内核缺页处理 → 模拟执行 → 伪造返回"的完整闭环完成。

vDSO:现代的动态共享对象方案

从静态地址到动态映射

如前述,vsyscall 的致命缺陷是地址固定、形态静态。vDSO(virtual dynamic shared object,虚拟动态共享对象)正是为解决这一局限而生。它与 vsyscall 的核心区别在于:vDSO 以共享对象(shared object)的形式把内存页映射到每个进程,地址是动态的;而 vsyscall 在内存中静态存在、地址恒定。

在x86_64架构上,这个共享对象名为linux-vdso.so.1,所有用户空间程序经由 glibc 与其链接。用ldd观察一个普通工具(如uname)即可看到:

~$ ldd /bin/uname linux-vdso.so.1 (0x00007ffe014b7000) libc.so.6 => /lib64/libc.so.6 (0x00007fbfee2fe000) /lib64/ld-linux-x86-64.so.2 (0x00005559aab7c000)

uname链接了三个库:

  • linux-vdso.so.1—— 提供 vDSO 功能;
  • libc.so.6—— C 标准库;
  • ld-linux-x86-64.so.2—— 程序解释器(动态链接器),其工作原理见本书杂项系列《链接器》。

进程地址空间中对应的映射:

~$ sudo cat /proc/1/maps | grep vdso 7fff39f73000-7fff39f75000 r-xp 00000000 00:00 0 [vdso]

注意这里 vdso 的地址(7fff39f73000)是每个进程运行时动态分配的,与 vsyscall 的固定地址ffffffffff600000形成鲜明对比。

init_vdso:vDSO 镜像的初始化

vDSO 的初始化发生在 arch/x86/entry/vdso/vma.c 中的init_vdso函数,它依据CONFIG_X86_X32_ABI配置决定初始化 32 位与 64 位镜像:

static int __init init_vdso(void) { init_vdso_image(&vdso_image_64); #ifdef CONFIG_X86_X32_ABI init_vdso_image(&vdso_image_x32); #endif ... ... ... }

vdso_image结构体代表了某种特定系统调用入口方式下的 vDSO 镜像,其具体实例由vdso2c程序从不同的源文件生成(生成产物为vdso-image-64.c等源码文件)。之所以需要多份镜像,是因为不同的调用方式对应不同的指令序列:兼容模式下可以用int 0x80中断进入系统调用,也可以使用本机syscall指令或sysenter指令。镜像的完整集合取决于内核配置:

#ifdef CONFIG_X86_64 extern const struct vdso_image vdso_image_64; #endif
#ifdef CONFIG_X86_X32 extern const struct vdso_image vdso_image_x32; #endif
#if defined CONFIG_X86_32 || defined CONFIG_COMPAT extern const struct vdso_image vdso_image_32_int80; #ifdef CONFIG_COMPAT extern const struct vdso_image vdso_image_32_syscall; #endif extern const struct vdso_image vdso_image_32_sysenter; #endif

vdso_image 结构:镜像的元数据

vdso_image结构描述一个 vDSO 镜像的完整布局:vDSO 区域的大小(始终为PAGE_SIZE=4096 字节的整数倍)、指向文本映射(text mapping)的指针、alternatives(针对特定处理器的指令替换集合)的起始地址与长度等。以x86_64为例:

const struct vdso_image vdso_image_64 = { .data = raw_data, .size = 8192, .text_mapping = { .name = "[vdso]", .pages = pages, }, .alt = 3145, .alt_len = 26, .sym_vvar_start = -8192, .sym_vvar_page = -8192, .sym_hpet_page = -4096, };

其中raw_data是 64 位 vDSO 系统调用的原始二进制代码,大小为两个页(pages[2]),即 8 KB。

init_vdso_image定义在同一个源文件中,主要工作是把raw_data中的每一页转换为对应的page结构并填入text_mapping.pages:

void __init init_vdso_image(const struct vdso_image *image) { int i; int npages = (image->size) / PAGE_SIZE; for (i = 0; i < npages; i++) image->text_mapping.pages[i] = virt_to_page(image->data + i*PAGE_SIZE); ... ... ... }

这里用virt_to_page宏把内核虚拟地址转换成对应的struct page。

init_vdso通过subsys_initcall宏注册进initcalls链表,最终由 init/main.c 中的do_initcalls在内核启动阶段逐一调用:

subsys_initcall(init_vdso);

程序加载时的动态映射:arch_setup_additional_pages 与 map_vdso

初始化只是准备了page结构,真正的映射发生在内核把可执行文件装载进内存时。程序加载后,Linux 内核会调用 arch/x86/entry/vdso/vma.c 中的arch_setup_additional_pages函数,它检查x86_64上 vDSO 是否启用,然后调用map_vdso:

int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp) { if (!vdso64_enabled) return 0; return map_vdso(&vdso_image_64, true); }

map_vdso定义在同一文件中,负责把 vDSO 的代码页以及共享的 vDSO 变量页映射进新进程的地址空间。这也解释了为什么每个进程看到的[vdso]映射地址各不相同——它是在execve加载 ELF 程序时按进程动态布局的。关于 ELF 加载的完整流程(load_elf_binary、start_thread等),见本系列第四节《Linux 内核如何运行程序》。

vDSO 提供的四个系统调用

vDSO 不仅地址动态,提供的调用也比 vsyscall 多一个:

  • __vdso_clock_gettime
  • __vdso_getcpu
  • __vdso_gettimeofday
  • __vdso_time

vsyscall 与 vDSO 对比总结

维度vsyscallvDSO
地址形态静态固定(ffffffffff600000)每个进程动态映射
载体形式固定内存页(不可写、固定位置)共享对象linux-vdso.so.1
提供的调用3 个:gettimeofday、time、getcpu4 个:clock_gettime、getcpu、gettimeofday、time
映射时机内核初始化期(map_vsyscall,setup_arch内)程序加载期(arch_setup_additional_pages→map_vdso)
安全形态遗留 ABI,地址可预知,默认转为模拟(emulate)现代方案,动态布局,无固定地址暴露
相关源码arch/x86/entry/vsyscall/vsyscall_64.c、vsyscall_emu_64.Sarch/x86/entry/vdso/vma.c

两者本质相同——都是把系统调用实现放进用户空间可访问的内存页、省去上下文切换;区别在于 vsyscall 是静态的遗留方案,vDSO 是动态的现代替代。vDSO 的初始化路径、镜像生成(vdso2c)与按进程映射机制,共同解决了 vsyscall 固定地址带来的安全隐患,同时保留"用户空间零上下文切换执行轻量系统调用"的性能优势。

延伸阅读

  • 系统调用概念简介——系统调用的基本概念与用户空间视角
  • Linux 内核如何处理系统调用——系统调用表的初始化与内核侧处理流程
  • Linux 内核如何运行程序——ELF 加载与arch_setup_additional_pages的调用背景
  • 固定映射地址和输入输出重映射——fixmap 地址区与VSYSCALL_PAGE枚举
  • 链接器(Linkers)——程序解释器与动态链接原理
  • Linux 内核初始化第六章——early_param宏与早期参数解析
  • Linux 内核初始化第七章——setup_arch中的map_vsyscall调用细节
  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides-zh
点击查看免费下载
上一篇:AppleRa1n:5步免费解锁iOS 15-16设备激活锁的完整指南
下一篇:Windows安卓驱动安装终极指南:告别手动配置的ADB Fastboot一键安装工具

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询