什么是vdso?
vdso是virtual ELF dynamic shared object的缩写,即虚拟动态共享库,其实就是“虚拟的so库”。根据linux手册https://man7.org/linux/man-pages/man7/vdso.7.html的介绍,vdso是内核中内置的一个so库。在启动ELF程序时,内核会自动将其映射到进程的虚拟地址空间。通过cat /proc/$pid/maps可以看到每个进程中vdso映射的虚拟地址。
vdso的主要存在意义,是为一些频繁调用的内核功能提供用户态的缓存和加速处理,从而避免频繁发生用户态/内核态切换。在不同架构下,vdso提供的接口是不同的,在x86_64架构下vdso提供的接口有:
-
symbol version
-
─────────────────────────────────
-
__vdso_clock_gettime LINUX_2.6
-
__vdso_getcpu LINUX_2.6
-
__vdso_gettimeofday LINUX_2.6
-
__vdso_time LINUX_2.6
在vdso的手册中,明确写道:vdso一般只由libc调用,普通开发者不应该直接调用vdso的符号和接口。不能在程序中直接调用vdso接口的原因是vdso与架构/内核版本是强相关的,不同架构/内核下vdso提供的接口差异很大,因此无法保证程序调用的接口在运行环境下存在。而libc会根据架构/内核版本,以及运行时实际状态(vdso是否映射,需要的符号是否能查找到)判断相关功能是否可以通过vdso接口实现。
这里有一个问题:为什么vdso提供的用户态功能要通过内核映射用户态so这种奇特的方式实现,而不直接实现在libc中呢?原因是vdso提供的功能需要和内核配合实现,不是纯粹的用户态逻辑。time和cpu相关的功能,都依赖于内核将相关数据写入到进程的特定虚拟地址空间,然后才能在用户态读取到。这些功能的实现在不同版本内核中可能有变化,纯用户态逻辑也很难高效的获取内核这些细节信息,内核也不想暴露新的接口(系统调用)来初始化这些细节信息,因此无法实现在libc中。因此,vdso虽然使用起来是一个用户态so,但其功能是内核直接提供的,vdso是内核功能的一部分。
vdso最早被称为vsyscall,只用于帮助libc选择最高效的系统调用实现:是INT 80,sysenter,还是syscall?其实这种功能在glibc中就能够实现,不需要内核介入。但后来内核开发者发现这种机制能够在用户态高效实现内核功能,因此将其改名为vdso,在其中加入了更多与内核配合的用户态接口。目前在大多数架构下,vdso中已经不再包含vsyscall接口。
gVisor中的vdso
gVisor是google开源的安全容器运行时。其原理是截获进程的系统调用,在用户态实现系统调用功能,只在必要时才会调用有限的内核系统调用。通过限制进程能调用的系统调用数量,gVisor减少了进程利用系统调用实现缺陷来攻击内核的可能性,从而保障容器的隔离性和系统的安全性,避免恶意或被控制容器攻击宿主机和宿主机上的其他容器。
gVisor有趣的一点是它可以被看作是在用户态用golang实现的一个类Linux内核。gVisor目前的版本中已经支持了绝大部分Linux系统调用,其实现原理大多与linux内核接近,但由于使用golang开发,代码量更小、可读性更好,还能够很方便的进行修改和调试,是一个学习内核原理的好途径。
在gVisor中也实现了vdso,下面分析一下其实现逻辑。
gVisor中vdso库的实现在gvisor/vdso/路径下,主要包括如下文件:
- check_vdso.py,用于检查生成的vdso.so文件的段结构是否符合预期,确认so中没有重定位段。
- vdso_amd64.lds/vdso_arm64.lds,不同架构下的so链接脚本,指导链接器生成符合要求的so文件。
- vdso.cc,接口实现文件。以x86_64架构为例,vdso提供的接口共5个。其中__vdso_getcpu和__kernel_rt_sigreturn这两个接口并没有提供高效实现,而是直接调用了系统调用。提供了优化的只有获取时间的三个接口:__vdso_clock_gettime、__vdso_gettimeofday、__vdso_time。这些接口的实现原理都是一样的,从内核映射的地址中直接获取当前时间,然后进行一些转换来获取接口要求的时间格式。具体的实现在vdso_time.cc中。
- vdso_time.cc,时间接口的实现。在这里的实现逻辑中可以看到gVisor将时间戳相关的数据地址映射到vdso的映射地址的前一个页上,称作ParamPage。通过直接访问这个地址,可以获取到gVisor提供的当前时间戳。再通过rdtsc获取当前高精度tsc时间戳,与gVisor更新时间戳时的基准tsc取差值,即可获得与gVisor时间戳间的精确差距,从而计算出当前的精确时间戳。
vdso的实现分析完毕,另一个更重要的问题是:vdso库和时间戳页的映射是在什么地方做的?以及,时间戳页的数据是如何更新的?
vdso在内核中的初始化
vdso的初始化在boot命令创建sandbox时完成。在boot.New函数中,调用PrepareVDSO函数实现了vdso库的载入和时间戳页的创建。这个函数在boot.New中调用,在创建loader时完成了vdso的初始化。之后使用kernel.NewTimekeeper为时间戳页注册了一个Timekeeper来维护时间戳的更新。最后在Kernel.Init中将Timekeeper和vdso记录在Kernel结构中。
vdso在进程中的映射
在创建进程时(Kernel.CreateProcess、linux.Execveat),会调用Kernel.LoadTaskImage。这个函数负责载入可执行文件等进程初始化操作,其中也调用了loadVDSO完成vdso在进程地址空间中的映射。其中首先分配了vdso.Length()+ParamPage.Length()长度的虚拟地址空间,之后将ParamPage和vdso连续映射到这段空间。在映射时,设置了映射地址空间的权限为只读,防止有进程恶意或错误的修改映射页内容造成其他进程运行错误。
vdso中时间戳页面的更新
在内核初始化vdso时,创建了Timekeeper来维护时间戳的更新。在初始化TimeKeeper的Timekeeper.SetClocks函数中,会调用Timekeeper.startUpdater函数。顾名思义,这个函数启动了一个updater来更新时间戳。startUpdater函数启动了一个无限循环的goroutine,每1秒获取一次当前的时间信息,更新到ParamPage中。这个1秒的定时器通过golang的定时器time.Ticker实现。
通过上述过程,gVisor在启动sandbox时将vdso载入到kernel中,在进程创建时将vdso地址映射到进程虚拟地址空间,并定时更新时间戳页面来保持时间戳的准确性。这样,gVisor中的用户进程就能够通过vdso接口,在不调用syscall的情况下获取精确时间。