一、背景
vdso的全称是Virtual Dynamic Shared Object,它是一个特殊的共享库,是在编译内核时生成,并在内核镜像里某一段地址段作为该共享库的内容。vdso的前身是vsyscall,为了兼容一些旧的程序,x86上还是默认加载了vsyscall:
但是在arm64上并不支持vsyscall,也没有这样的程序段:
我们在做一些用户态程序的栈的抓取时,有时候是会运行到vdso里去的,在x86上也甚至可能运行到vsyscall里去,这时候,我们也需要知道vdso里有哪些符号。
在下面第二章里,我们会介绍vdso的概念和原理,原理侧重于介绍内核部分的相关逻辑,用户态部分的会在后面的博客里介绍。另外,还会对vdso的代码段的fault函数也就是vdso_fault函数进行介绍,并拓展到page的引用计数和vm_insert_page函数的使用。另外,也会介绍vvar_fault等也属于vdso范畴的其他相关细节。
vdso可以用来做性能相关的优化,但是前提肯定是得先了解其原理。
二、vdso概念及实现原理,vdso_fault缺页异常逻辑
2.1 vdso函数以时间获取为主
在下面的第三章里,我们会将如何捞取vdso里的符号,我们把捞取到的内容展示一下:
x86下的vdso的符号多一些:
arm64下的vdso的符号少一些:
可以从上两张图里可以看到,无论是x86还是arm64,符号里基本都是和时间接口有关。
2.2 时间获取走vdso的原因
原因是为了性能优化,因为时间获取并不是一个敏感的信息,并不涉及很多安全考虑,另外,时间获取的逻辑相对也较为简单,并不依赖内核里的很多函数,这样,把时间获取有关的数据和代码段映射到用户态来执行也相对简单。
如果时间获取逻辑走了vdso,那么用户态代码在执行该时间获取路基时就不需要陷入内核来执行,这很明显能提升运行效率。因为每次系统调用陷入内核之后要做上下文切换,用户栈和内核栈的保存和切换,另外在内核态代码执行完后返回用户态时也得做reschedule的判断,更别提使能了rt-linux之后内核逻辑里如果用到了锁还会更加一些调度有关的检查和切换逻辑,这些都是消耗cpu的。
2.3 以时间获取为例介绍vdso的实现原理
这一节我们介绍vdso的实现原理,以时间获取为例来介绍。不过无论是哪个接口,底层这块的vdso的逻辑都是一样的,我们从底到上来介绍实现原理。
2.3.1 内核的vdso映射逻辑,vdso段和vvar段及测试ko
我们运行如下命令可以看到两个vdso模块会用到的地址段:
cat /proc/1/maps | grep -E "vdso|vvar"
我们上图看到的是用户态地址段,是进程1的用户空间地址范围,可以看到这两个maps的条目比较特殊,是用"[]"来包裹的,事实上,内核里确实对其进行了特殊映射,相关函数是_install_special_mapping,如在x86时,在arch/x86/entry/vdso/vma.c里的map_vdso函数里有如下映射逻辑:
上图里的vdso_mapping和vvar_mapping如下定义:
这里,[vdso]是.text段也就是代码段,[vvar]是数据段内容,是用户态和内核态共享维护的vdso逻辑相关的数据的地址段。
针对代码段和数据段的缺页异常有不同的.fault函数,代码段的缺页异常函数vdso_fault,我们使用一个ko代码来kprobe这个vdso_fault来确定它的触发点的调用堆栈并非来自于_install_special_mapping时同步触发(同步触发的场景只在映射时带上MAP_POPULATE/MAP_LOCKED时才会同步触发pagefault,在之前的博客 内存管理相关——malloc,mmap,mlock与unevictable列表-CSDN博客 里的 3.2.1 一节里讲到),抓到的堆栈如下:
对于vvar_fault,抓到的堆栈如下,和vdso_fault是一样的,不是同步触发pagefault:
测试用的源码:
#include <linux/module.h>
#include <linux/capability.h>
#include <linux/sched.h>
#include <linux/uaccess.h>
#include <linux/proc_fs.h>
#include <linux/ctype.h>
#include <linux/seq_file.h>
#include <linux/poll.h>
#include <linux/types.h>
#include <linux/ioctl.h>
#include <linux/errno.h>
#include <linux/stddef.h>
#include <linux/lockdep.h>
#include <linux/kthread.h>
#include <linux/sched.h>
#include <linux/delay.h>
#include <linux/wait.h>
#include <linux/init.h>
#include <asm/atomic.h>
#include <trace/events/workqueue.h>
#include <linux/sched/clock.h>
#include <linux/string.h>
#include <linux/mm.h>
#include <linux/interrupt.h>
#include <linux/tracepoint.h>
#include <trace/events/osmonitor.h>
#include <trace/events/sched.h>
#include <trace/events/irq.h>
#include <trace/events/kmem.h>
#include <linux/ptrace.h>
#include <linux/uaccess.h>
#include <asm/processor.h>
#include <linux/sched/task_stack.h>
#include <linux/nmi.h>
#include <asm/apic.h>
#include <linux/version.h>
#include <linux/sched/mm.h>
#include <asm/irq_regs.h>
#include <linux/kallsyms.h>
#include <linux/kprobes.h>
#include <linux/stop_machine.h>MODULE_LICENSE("GPL");
MODULE_AUTHOR("zhaoxin");
MODULE_DESCRIPTION("Module for vdso_fault debug.");
MODULE_VERSION("1.0");struct kprobe _kp1;static bool _blog = false;int kprobecb_vdso_fault_pre(struct kprobe* i_k, struct pt_regs* i_p)
{if (!_blog) {_blog = true;dump_stack();}return 0;
}int kprobe_register_func_vdso_fault(void)
{int ret;memset(&_kp1, 0, sizeof(_kp1));_kp1.symbol_name = "vvar_fault";_kp1.pre_handler = kprobecb_vdso_fault_pre;_kp1.post_handler = NULL;ret = register_kprobe(&_kp1);if (ret < 0) {printk("register_kprobe fail!\n");return -1;}printk("register_kprobe success!\n");return 0;
}void kprobe_unregister_func_vdso_fault(void)
{unregister_kprobe(&_kp1);
}static int __init testvdso_init(void)
{kprobe_register_func_vdso_fault();return 0;
}static void __exit testvdso_exit(void)
{kprobe_unregister_func_vdso_fault();
}module_init(testvdso_init);
module_exit(testvdso_exit);
在接下来的三节里,我们依次来分析一下上面提到的vdso_fault,vvar_fault,_install_special_mapping三个函数。
2.4 vdso_fault缺页异常逻辑,及get_page
表面上来看vdso_fault的函数实现,其实还是比较简单的,就是判断一个地址范围,超出的话报VM_FAULT_SIGBUS错误,除此以外就获取到vdso代码段的page,增加该物理页的引用计数:
如上图里,核心逻辑其实就是两步:设置vmf->page设置对应的物理页,再调用get_page。
内核里相似的在缺页异常里处理的做法如下图:
但是事实上,虽然我们看到的只是简单的两步,但是缺页异常的调用链上还是配合有很多其他逻辑的。
2.4.1 详细跟踪vdso_fault的调用链
我们回过来看一下vdso_fault相关的调用链:
根据里面的调用链里的函数的offset,结合vmlinux.txt(objdump -S出来的文件),我们得到上图的调用链包含inline函数的完整调用链是:
handle_mm_fault->__handle_mm_fault->handle_pte_fault->do_pte_missing->do_fault->do_read_fault->__do_fault,拆解一下如下:
handle_mm_fault调用了__handle_mm_fault:
__handle_mm_fault调用了handle_pte_fault:
handle_pte_fault调用了do_pte_missing:
do_pte_missing调用了do_fault:
do_fault调用了do_read_fault:
do_read_fault调用了__do_fault:
2.4.2 do_read_fault里在执行完__do_fault后调用了finish_fault进行了页表设置
详细来说,就是在__do_fault函数里设置了vmf->page后,在finish_fault里根据vmf->page来进行pte的设置:
finish_fault里根据vmf的页的标志位信息,如果是可写且不共享的,则用vmf->cow_page,如果是其他情况,则用vmf->page:
finish_fault里用page和vma的信息来设置页表和tlb:
2.4.3 关于缺页异常里的使用的vmf_insert_pfn情形
在上面几节搞清楚了vdso_fault的缺页异常的流程里设置pte的操作是在vdso_fault的这个special vma的.fault函数执行之后做的这个细节之后,还有一个get_page的疑问,就是为什么在vdso_fault里有这样的get_page的显示的调用,而在别的缺页异常的处理函数里,vm_insert_page或vmf_insert_pfn这样的调用之后不需要再get_page调用了,我们依次看一下原因,先看vmf_insert_pfn的情形,如下图例子:
如上图看到,在执行完vmf_insert_pfn之后,返回了VM_FAULT_NOPAGE。这个VM_FAULT_NOPAGE表示什么含义呢?
它是表示缺页异常处理函数里配置了新的PTE,这次缺页异常并没有返回一个新的页面。既然不是新的页面,那么也并不需要通过get_page来增加引用计数。
当return了VM_FAULT_NOPAGE之后,在do_read_fault里执行了__do_fault函数,拿到的返回值如果是VM_FAULT_NOPAGE时,如下图,就会直接返回,并不会执行finish_fault的根据vmf->page进行pte配置的动作,这种情况,相关的pte动作都是在vmf_insert_pfn里执行的。
vmf_insert_pfn里执行相关pte配置的动作的截图:
2.4.4 关于缺页异常里的使用的vm_insert_page情形
上面一节里介绍的vmf_insert_pfn是直接拿着页框去做映射,缺页异常里使用vmf_insert_pfn来做映射的情况也是非常常见的。另外,我们其实也可以用vm_insert_page或者vm_insert_pages函数是根据page结构体来去做映射。如下图方式:
如上图情况下,用的是vm_insert_page接口,从使用角度来说,这个vm_insert_page相对更方便,不用再去找页框,有page就可以了。使用vm_insert_page的时候不需要再去get_page一下,因为vm_insert_page里已经有了get_page动作。
vm_insert_page里先是要检查page的引用计数不能是0,这对应的是kmalloc这种分配接口,分配出来以后自然引用计数就不为0了,这个我们用一个测试ko来验证,这个ko会在下面一节里介绍,另外,例子里也会使用vm_insert_page函数,也有一个用户态的mmap改ko创建的dev的节点的对应的例子程序。
我们继续分析vm_insert_page下面的逻辑:
看一下insert_page里,会调用insert_page_into_pte_locked:
insert_page_into_pte_locked里会调用get_page增加page的引用计数:
get_page:
2.4.5 关于page的引用计数和vm_insert_page的实验
测试ko源码:
#include <linux/module.h>
#include <linux/miscdevice.h>
#include <linux/mm.h>
#include <linux/uaccess.h>
#include <linux/fs.h>
#include <linux/slab.h>MODULE_LICENSE("GPL");
MODULE_AUTHOR("zhaoxin");
MODULE_DESCRIPTION("Module for kernel test fault.");
MODULE_VERSION("1.0");static void *kaddr;static vm_fault_t my_fault(struct vm_fault *vmf)
{struct vm_area_struct *vma = vmf->vma;int offset, ret;offset = vmf->pgoff * PAGE_SIZE;ret = vm_insert_page(vma, vmf->address, virt_to_page(kaddr + offset));if (ret)return VM_FAULT_SIGBUS;return VM_FAULT_NOPAGE;
}static const struct vm_operations_struct vm_ops = {.fault = my_fault,
};static int my_mmap(struct file *file, struct vm_area_struct *vma)
{//vma->vm_flags |= VM_MIXEDMAP;vm_flags_set(vma, VM_MIXEDMAP);vma->vm_ops = &vm_ops;return 0;
}static struct file_operations my_fops = {.owner = THIS_MODULE,.mmap = my_mmap,
};static struct miscdevice mdev = {.minor = MISC_DYNAMIC_MINOR,.name = "my_dev",.fops = &my_fops,
};static int __init my_init(void)
{kaddr = kzalloc(PAGE_SIZE * 3, GFP_KERNEL);for (int i = 0; i < 3; i++) {printk("page[%d]:count[%d]\n",i, page_count(virt_to_page(kaddr + PAGE_SIZE*i)));}return misc_register(&mdev);
}static void __exit my_exit(void)
{misc_deregister(&mdev);kvfree(kaddr);
}module_init(my_init);
module_exit(my_exit);
insmod之后,可以看到:
可以如上下图看到k*alloc函数分配出来的每个page的引用计数都是1:
用户态程序使用mmap来触发缺页异常:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>#define DEVICE "/dev/my_dev"
#define PAGE_SIZE 4096
#define NUM_PAGES 3int main() {int fd;void *mapped_memory;// 打开设备fd = open(DEVICE, O_RDWR);if (fd < 0) {perror("Failed to open device");return EXIT_FAILURE;}// 使用 mmap 映射设备mapped_memory = mmap(NULL, NUM_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);if (mapped_memory == MAP_FAILED) {perror("mmap failed");close(fd);return EXIT_FAILURE;}// 写入数据const char *data = "Hello, mmap!";memcpy(mapped_memory, data, strlen(data) + 1); // +1 以包含字符串的终止符// 读取数据char buffer[PAGE_SIZE];memset(buffer, 0, sizeof(buffer));memcpy(buffer, mapped_memory, sizeof(buffer));printf("Read from mmap: %s\n", buffer);// 解除映射munmap(mapped_memory, NUM_PAGES * PAGE_SIZE);close(fd);return EXIT_SUCCESS;
}
运行测试程序后,可以成功读写(说明缺页异常的逻辑基本是正确的):
2.4.6 新版本内核设置VM_MIXEDMAP时需要用vm_flags_set接口
在linux-6.5版本上,使用vma->vm_flags会编译报错:
这是因为新版本内核设置VM_MIXEDMAP时需要用vm_flags_set接口。
下面就是找的一个相对旧的版本和相对新的版本的同样函数里使用上的对比:
下图左边是linux-6.5,右边是linux-source-5.19.0(都是fs/cramfs/inode.c里的cramfs_physmem_mmap函数):
2.5 vvar_fault函数的相关细节
x86下的vvar_fault会考虑多种情形:
根据上图里的sym_offset来去匹配不同的vvar_page的种类。
如上图,默认选择的sym_vvar_start是sym_vvar_page。
这个sym_vvar_start和sym_vvar_page等其他种类的定义都是在vdso-image-64.c里定义的:
回过来看vvar_fault里的逻辑:
上图里先通过page_to_pfn得到页框pfn,再通过vmf_insert_pfn进行映射的逻辑其实在上面 2.4.3 里已经介绍过了。
2.6 _install_special_mapping的调用链,涉及execve系统调用
我们改写一下 2.3.1 里的测试ko,kprobe这个_install_special_mapping函数,打印这个函数的调用栈情况,如下:
分析vmlinux之后得到:
exec_binprm->do_execveat_common->bprm_execve->exec_binprm->search_binary_handler->load_elf_binary->ARCH_SETUP_ADDITIONAL_PAGES宏->arch_setup_additional_pages
从execve系统调用出发:
然后运行do_execve:
然后调用到do_execveat_common:
然后调用bprm_execve:
bprm_execve调用了exec_binprm:
exec_binprm调用了search_binary_handler:
然后search_binary_handler调用了fmt->load_binary:
fmt->load_binary里的load_binary和load_elf_binary的映射关系:
继续看load_elf_binary函数里:
load_elf_binary里调用了如下图的ARCH_SETUP_ADDITIONAL_PAGES(bprm, elf_ex, !!interpreter):
ARCH_SETUP_ADDITIONAL_PAGES宏使用了arch_setup_additional_pages:
arch_setup_additional_pages调用了map_vdso_randomized(&vdso_image_64):
(这里面的vdso_image_64在下面第三章里会详细介绍)
map_vdso_randomized调用了map_vdso:
map_vdso调用_install_special_mapping:
三、如何捞取vdso里的符号
在上面的 2.5 一节里有提到vdso-image-64.c里定义了vdso_image_64数组:
这个vdso_image结构体里的.data变量就是放的vdso的代码段裸数据:
3.1 通过内核空间来获取vdso代码段内容
输入如下命令:
cat /proc/kallsyms | grep vdso_image_64
得到如下图:
我们通过之前的博客 获取内存内容的几种方法-CSDN博客 里的第五章里的ko的方法:
insmod testgetkmem.ko address=0xffffffff8e8010e0 size=8 filedir="output.txt"
读到的这个地址的前8字节是0xffffffff8ee47000:
正好和:
cat /proc/kallsyms | grep raw_data
得到的一个raw_data的符号的数值一样:
这个raw_data是小写的d的符号,即如下图里的raw_data的static数组:
我们再读一下这个raw_data的内容是否和代码里的一致,可以看到是一致的:
3.2 通过/dev/mem来读取mmap到用户空间的vdso代码段的内容
在之前的博客 获取内存内容的几种方法-CSDN博客 里的第四章里也提及。
cat /proc/1/maps | grep vdso
把grep到的vdso的段的起始地址转换成10进制数,替换下面命令里skip=后面的数字:
dd if=/proc/1/mem of=vdso.so skip=140736471179264 ibs=1 count=8192
我们比较 3.1 导出的output.txt的md5sum和这个vdso.so的md5sum,如下图是一样的:
四、关于vsyscall和vvar及捞取它们符号的实验
4.1 vsyscall相比vdso的劣势
如第一章里描述所说,vsyscall是比vdso早的东西,vdso相比vsyscall改进了很多。
vdso本质上是一个elf目标文件,而vsyscall仅仅是代码+数据。如何理解这句话呢,意思就是vdso这个模拟出来的一个elf目标文件有了elf的一些基本属性,比如可以像so文件一样按照进程颗粒度动态映射到进程地址空间中,所有所谓的PIC(Position-Independent Code)属性,而vsyscall则是固定的一段内核空间的地址段,不可更改,常见的地址是起始于0xffffffffff60000,size是4096,如下图:
由于映射的地址不变,所以它是非常不安全的,内核里也对其相关页进行了保护,下面会讲到vsyscall的内容是不可读的,只可执行,是拿不出来的。
另外,vdso的内容是so的格式,而vsyscall的内容是二进制格式,这也是两者的区别。
4.2 vsyscall符号的捞取
上面的vsyscall的maps里条目可以看到vsyscall的地址段是不可读的,但是我们也可以通过设置grub把vsyscall设置成emulate模式,再配合修改下图里的__PAGE_KERNEL_VVAR宏,红色框出的部分改成__RW:
重编内核,来通过gdb dump来获取。
我们也可以用上面 3.1 一节里差不多的方法:
然后用之前的博客 获取内存内容的几种方法-CSDN博客 里第五章集成的dumpkmem的工具来导出到文件vsyscall.bin里去:
dumpkmem 0xffffffff8f004000 4096 vsyscall.bin
然后,我们可以用如下命令把该bin文件objdump出可阅读代码:
objdump -b binary -Mintel,x86-64,addr64 -m i386:x86-64 --adjust-vma=0xffffffffff600000 -D vsyscall.bin > vsyscall.txt
上图里的--adjust-vma后面的数值是vsyscall的代码段的固定起始地址:0xffffffffff60000
看一下上面的命令导出到的vsyscall.txt文件里的内容:
如下图里命令方式去grep一下看到相关的几个syscall的开始的指令位置:
对应于源码里(第一个vsyscall的syscall是PAGE_SIZE对齐,后面的两个符号是1024字节对齐):
4.3 vvar数据段的捞取方法
vvar在上面 2.3 一节里讲vdso原理也介绍过是用于用户vdso相关用户态和内核态代码共享内存数据所需要的。
先确认vvar的size是多少(如下图看到是4个page):
这四个page的size对应代码里的位置:
上图里的sym_vvar_start对应于下面的在_install_special_mapping时传入的size参数:
虽然vvar地址段显示的是可读,但是实际还是读不出来的(用上面 3.2 里的方法):
dd if=/proc/1/mem of=vvar.bin skip=140736471162880 ibs=1 count=8192
如下图提示错误:
我们需要知道vvar的用的是哪个符号,通过kprobe来打印出相关逻辑的数值(相关逻辑的介绍在上面 2.5 一节里有介绍):
vvar用到的page除了__vvar_page以外,还有可能会用到namespace里的time_ns里的vvar_page:
当前我们没用到,如kprobe里打出来的情况:
我们读__vvar_page,我们可以直接读内核空间里的相关vvar的page的内容,如下方式: