一、背景
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的內容,如下方式: