Linux内核页表管理-那些鲜为人知的秘密
1.开场白
环境:
处理器架构:arm64
内核源码:linux-5.11
ubuntu版本:20.04.1
代码阅读工具:vim+ctags+cscope
通用操作系统,通常都会开启mmu来支持虚拟内存管理,而页表管理是在虚拟内存管理中尤为重要,本文主要以回答几个页表管理中关键性问题来解析Linux内核页表管理,看一看页表管理中那些鲜为人知的秘密。
2.页表的作用是什么?
1)地址转换
将虚拟地址转换为物理地址
2)权限管理
管理cpu对物理页的访问,如读写执行权限
3)隔离地址空间
隔离各个进程的地址空间,使其互不影响,提供系统的安全性
打开mmu后,对没有页表映射的虚拟内存访问或者有页表映射但是没有访问权限都会发生处理器异常,内核选择杀死进程或者panic;通过页表给一段内存设置用户态不可访问, 这样可以做到用户态的用户进程不能访问内核地址空间的内容;而由于用户进程各有一套自己的页表,所以彼此看不到对方的地址空间,更别提访问,造成每个进程都认为自己拥有所有虚拟内存的错觉;通过页表给一段内存设置只读属性,那么就不容许修改这段内存内容,从而保护了这段内存不被改写;对应用户进程地址空间映射的物理内存,内核可以很方便的进行页面迁移和页面交换,而对使用虚拟地址的用户进程来说是透明的;通过页表,很容易实现内存共享,使得一份共享库很多进程都可以映射到自己地址空间使用;通过页表,可以小内存加载大应用程序运行,在运行时按需加载和映射...
3.页表的存放在哪?
页表存放在物理内存中,打开mmu之后,如果需要修改页表,需要将页表所在的物理地址映射到虚拟地址才能访问页表(如内核初始化后会将物理内存线性映射,这样通过物理地址和虚拟地址的偏移就可以获得页表物理地址对应的虚拟地址)。
4. 页表项中存放是虚是实?
页表基地址寄存器和各级页表项中存放的都是物理地址,而不是虚拟地址。
5. 开启mmu后地址转换过程?
虚拟地址转换物理地址的过程:打开mmu后,cpu访问的都是虚拟地址,当cpu访问一个虚拟地址的时候,会通过cpu内部的mmu来查询物理地址,mmu首先通过虚拟地址在tlb中查找,如果找到相应表项,直接获得物理地址;如果tlb没有找到,就会通过虚拟地址从页表基地址寄存器保存的页表基地址开始查询多级页表,最终查询到找到相应表项,会将表项缓存到tlb中,然后从表项中获得物理地址。
6. Linux内核为何使用多级页表?
1)使用一级页表结构优劣:
优势:
只需要2次访问内存(一次访问页表,一次访问数据),效率高,实现简单
劣势:
需要连续的大块内存存放每个进程的页表(如32位系统每个进程需要4M页表),浪费内存,虚拟内存越大页表越大,内存碎片化的时候很难分配到连续大块内存,大多数虚拟内存并没有使用。
2)使用多级页表结构优劣:
优势:
1.节省内存
2.可以按需分配各级页表
3.可以离散存储页表
劣势:
需要遍历多级页表,需要多次访问内存,实现复杂度高点
3)Linux内核综合考虑:
典型的以时间换空间,可以将各级页表放到物理内存的任何地方,无论是硬件遍历还是内核遍历,比一级页表更复杂,但是为了节省内存,内核选择多级页表结构。
7.减小多级页表遍历的优化?
1)mmu中添加tlb
来缓存最近访问的页表表项,根据程序的时间和空间的局部性原理,tlb能有很高的命中率。
2)使用巨型页
减少访存次数(如使用1G或2M巨型页),可以减少tlb miss和缺页异常。
8. 硬件做了哪些事情?
遍历页表,将va转换为pa,页面权限管理
涉及到的硬件为:
mmu
->功能:查询tlb或者遍历页表
tlb ->功能:缓存最近转换的页表条目
页表基地址寄存器 如ttbr0_el1 ttbr1_el1 ->功能:存放页表基地址(物理地址)作为mmu遍历多级页表的起点
mmu进行多级页表遍历时当发现虚拟地址的最高bit为1时使用 ttbr1_el1作为遍历起点,最高bit为0时使用 ttbr0_el1作为遍历起点。
9. 软件做了哪些事情?
1)应用程序
访问虚拟内存即可如执行指令、读写内存, 没有权限管理页表
不管虚拟内存如何转换为物理内存,对应用来说透明。
2)Linux内核
填写页表,将页表基地址告诉mmu
内核初始化建立内核页表,实现缺页异常等机制为用户任务按需分配并映射页表。
当然,内核也可以遍历页表,如缺页异常时遍历进程页表。
10. 内核中涉及到的页表基地址?
内核:
idmap_pg_dir 恒等映射页表(va=pa 映射2M)
init_pg_dir 粗粒度内核页表
swapper_pg_dir 主内核页表
用户:
tsk->mm->pgd 用户进程fork的时候分配私有的pgd页,用于保存pgd表项(仅仅分配了第一级页表)。
11. 页表填写/切换时机
1)内核页表填充
内核初始化过程:
物理地址 -> 恒等映射(建立恒等映射页表和粗粒度内核页表) ->打开mmu -> paging_init(建立细粒度的内核页表和内存线性映射) -> ...
恒等映射阶段:
将恒等映射页表idmap_pg_dir 地址保存到ttbr0_el1
将 粗粒度内核页表init_pg_dir 地址保存到ttbr1_el1
paging_init阶段:
将内核主页表swapper_pg_dir 地址保存到ttbr1_el1
paging_init之后丢弃idmap_pg_dir 和init_pg_dir 页表的使用。
2)用户页表填充
访问时缺页填充:
用户进程访问已经申请的虚拟内存时,发生缺页,缺页处理程序中为进程分配各级页表等物理页并建立页表映射关系。
进程切换时切换进程页表:
switch_mm的时候切换tsk->mm->pgd到ttbr0_el1以及asid 到ttbr1_el1,从而完成了进程地址空间切换。
12.页表遍历过程
下面以arm64处理器架构多级页表遍历作为结束(使用4级页表,页大小为4K):
Linux内核中 可以将页表扩展到5级,分别是页全局目录(Page Global Directory, PGD), 页4级目录(Page 4th Directory, P4D), 页上级目录(Page Upper Directory, PUD),页中间目录(Page Middle Directory, PMD),直接页表(Page Table, PT),而支持arm64的linux使用4级页表结构分别是 pgd, pud, pmd, pt ,arm64手册中将他们分别叫做L0,L1,L2,L3级转换表,所以一下使用L0-L3表示各级页表。
tlb miss时,mmu会进行多级页表遍历遍历过程如下:
1.mmu根据虚拟地址的最高位判断使用哪个页表基地址寄存器作为起点:当最高位为0时,使用ttbr0_el1作为起点(访问的是用户空间地址);当最高位为1时,使用ttbr1_el1作为起点(访问的是内核空间地址) mmu从相应的页表基地址寄存器中获得L0转换表基地址。
2.找到L0级转换表,然后从虚拟地址中获得L0索引,通过L0索引找到相应的表项(arm64中称为L0表描述符,内核中叫做PGD表项),从表项中获得L1转换表基地址。
3.找到L1级转换表,然后从虚拟地址中获得L1索引,通过L1索引找到相应的表项(arm64中称为L1表描述符,内核中叫做PUD表项),从表项中获得L2转换表基地址。
4.找到L2级转换表,然后从虚拟地址中获得L2索引,通过L2索引找到相应的表项(arm64中称为L2表描述符,内核中叫做PUD表项),从表项中获得L3转换表基地址。
5.找到L3级转换表,然后从虚拟地址中获得L3索引,通过L3索引找到页表项(arm64中称为页描述符,内核中叫做页表项)。
6.从页表项中取出物理页帧号然后加上物理地址偏移(VA[11,0])获得最终的物理地址。
(END)
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
ARMv8中,Kernel Space的页表基地址存放在TTBR1_EL1寄存器中,User Space页表基地址存放在TTBR0_EL0寄存器中,其中内核地址空间的高位为全1,(0xFFFF0000_00000000 ~ 0xFFFFFFFF_FFFFFFFF),用户地址空间的高位为全0,(0x00000000_00000000 ~ 0x0000FFFF_FFFFFFFF) ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
ARM TTBR0,TTBR1寄存器与ARM32页表复制
一,ARM TTBR0,TTBR1寄存器;从ARMV6开始增加了TTBR1寄存器,但是在ARM32的时候,TTBR1寄存器未使用,原因如下: TTBR0和TTBR1寄存器只支持2G,1G,512M等,但是ARM32虚拟地址空间的划分比例为1:3,用户空间是3G,内核空间是1G,所以上述寄存器硬件限制无法满足这种通用配置,所以ARM32未使用TTBR1寄存器;
二,ARM32页表复制 ARM32:由于ARM32未使用TTBR1寄存器,也就是MMU只使用了一个页表基址寄存器,同时,为了避免在用户空间和内核空间切换时,切换页表带来的性能损耗,所以,用户空间和内核空间共用一个页表,即用户空间和内核空间具有相同的页表基地址TTBR0,为了实现这种机制,内核在每次fork一个新的进程的时候,都会把内核页表的一级页表复制到新的进程的一级页表中,代码如下:
ARM64:ARM64使用了TTBR0和TTBR1寄存器,用户虚拟地址空间和内核虚拟地址空间都是256TB,用户虚拟地址的高位都是0,内核虚拟地址的高位都是1,MMU会自动根据高位是否为1来判断该虚拟地址是否为内核虚拟地址,所以用户虚拟地址空间和内核虚拟地址空间采用了不同的页表,即用户空间和内核空间具有不同的页表基地址,所以ARM64在fork每一个新的进程的时候不会把内核页表的一级页表复制到每一个新进程的一级页表中,代码如下:
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
32位ARM
Linux内核页表
一. Linux地址空间 ARM的32位系统共支持4G的内存空间,其中0-3G为用户空间,3G-4G是内核空间,
ARM采用2级页表,32位地址空间ADDRESS分别为 PGD|PTE|12Bits, 在内核代码中分别为PGD 11位,PTE 9 位,页内地址12位;但是在MMU系统中对于ARM的二级分页设置分别为PGD 12位,PTE 8位,页内地址为12位。在内核代码层次虽然是11位,但是经过代码中的设置,最后都映射到MMU起作用时的PGD12位,PTE8位。看代码定义。
#define PTRS_PER_PGD 2048 //PGD页中的指针数
#definePGDIR_SHIFT 21 //地址中偏移位数,去前11位,因为要右移21位
#define PGDIR_SIZE (1UL<< PGDIR_SHIFT) 0x20 0000
#define USER_PTRS_PER_PGD (TASK_SIZE/ PGDIR_SIZE)
在linux-3.5中,用户空间大小TASK_SIZE定义如下
#define TASK_SIZE (UL(CONFIG_PAGE_OFFSET)- UL(0x01000000))
#define CONFIG_PAGE_OFFSET 0xC000 0000
在linux-2.6.25中,用户空间大小TASK_SIZE定义
#define TASK_SIZE 0xC000 0000
二. 页表 页表分为用户空间页表和内核空间页表,不同的进程,它用户空间是不同的,所以它的用户空间页表是不同的,但是不同的进程它的内核空间是共享的,它的内核空间页表也是相同的。
在创建一个进程时,会为它创建一个页表指针,即mm_struct数据结构中的pgd_t * pgd;分配的函数是mm_init ()->mm_alloc_pgd(struct mm_struct *mm) ->pgd_alloc(structmm_struct *mm) ->#define __pgd_alloc() (pgd_t*)__get_free_pages(GFP_KERNEL, 2),分配的空间为16k, 即4096*4bytes,从此处分配的页表空间包括了用户空间页表和内核空间页表。
用户空间页表映射了地址0x0000 0000到0xC000 0000的空间,即为TASK_SIZE的大小。然后
#define USER_PTRS_PER_PGD (TASK_SIZE/PGDIR_SIZE)=0xC0000000/0x20 0000 =0x600=1536
内核空间的页表映射了地址0xC000 0000到0Xffff ffff的空间,为1G,然后
0x40000000/PGDIR_SIZE=0x4000 0000/0x20 0000 = 512,
所以页表的项数共为1536+512 =2048项。
考虑到硬件的地址映射,pgd实际为12位,即只需要右移20位,而不是21位,PGDIR_SHIFT的数值,所以实际上页表的项数实际应该为2048*2的4096个项数,然后每项为4个字节,最后与上文分配的16K地址对应起来,即4096*4bytes=16k.
三. 引导代码的内核页表分配 Linux-2.6的S3C2410上,当系统启动的时候,内核页表的地址是0x3000 4000, 内核的加载地址是0x3000 8000,所以内核页表的最大空间是0x4000,4字节,也为16K。
系统启动的时候,最先执行的是最后会成为空闲进程init_mm(), 该进程在启动结束前会生成内核进程init进程,然后再启动其他进程。在init_mm()结构中定义了.pgd = swapper_pg_dir,
#define CONFIG_PAGE_OFFSET 0xC000 0000
#definePAGE_OFFSET UL(CONFIG_PAGE_OFFSET)
#defineKERNEL_RAM_VADDR (PAGE_OFFSET +TEXT_OFFSET)
.equ swapper_pg_dir,KERNEL_RAM_VADDR - PG_DIR_SIZE
#definePG_DIR_SIZE 0x4000
空闲进程的页表初始化在引导代码的汇编中实现,__create_page_tables
四. 进程内核空间页表的分配 上面已经讨论了进程页表分为用户空间页表和内核空间页表,当进程创建的时候会把
new_pgd = __pgd_alloc();
if (!new_pgd)
goto no_pgd;
memset(new_pgd, 0, USER_PTRS_PER_PGD *sizeof(pgd_t));
/*
* Copy over the kernel and IO PGD entries
*/
init_pgd = pgd_offset_k(0);
memcpy(new_pgd + USER_PTRS_PER_PGD,init_pgd + USER_PTRS_PER_PGD,
(PTRS_PER_PGD - USER_PTRS_PER_PGD) *sizeof(pgd_t));
//
以下是对pgd_offset_k(0)的解析,最后init_pgd即为swapper_pg_dir的值
#definepgd_index(addr) ((addr)>> PGDIR_SHIFT)
#definepgd_offset(mm, addr) ((mm)->pgd +pgd_index(addr))
#definepgd_offset_k(addr) pgd_offset(&init_mm,addr)
/
Memcpy()函数中参数的解析
#definePTRS_PER_PGD 2048,总的页数(暂时这么说)。
#defineUSER_PTRS_PER_PGD (TASK_SIZE /PGDIR_SIZE) 1536
前1536为用户空间的地址值,之后才是内核空间的。
五.11位和12位的问题 #define PGDIR_SHIFT 21 //地址中偏移位数,去前11位,因为要右移21位
#define PGDIR_SIZE (1UL<< PGDIR_SHIFT) 0x20 0000
ARM二级页表中PGD的位数为12, 即只要左移20为就行了,但是代码中却移动了21为,即多除了一个2;软件中相当于11位,那就可以这样理解,11位+1位,最后在11位的基础上补上0和1两个值来补齐12位,这样软件与硬件就对应起来。这样,前文中PGD页表的运算是根据11位来运算的,多除了一个2,现在补上0或者1后,它的数值应该翻倍,所以总的页表项数,应该为:
用户空间页表映射了地址0x0000 0000到0xC000 0000的空间,即为TASK_SIZE的大小。然后
#define USER_PTRS_PER_PGD (TASK_SIZE/PGDIR_SIZE)=0xC0000000/0x20 0000 =0x600=1536 // 1536*2 = 3072
内核空间的页表映射了地址0xC000 0000到0Xffff ffff的空间,为1G,然后
0x40000000/PGDIR_SIZE=0x4000 0000/0x20 0000 = 512, // 512*2 = 1024
所以页表的项数共为 1536+512=2048项。 //2048*2 =4096
然后每项为4字节, 4096*4 = 16K , 即为每个进程分配的PGD的页表空间大小,这样就对应上了。
补充:
实际上,内核代码一级页表只用到了16K/2,即8K。每项11bit, 但是硬件是12bit,所以一级页表中每项指向两个pte,最低位是即没有的12bit是0或者1。硬件中中每个pte实际上是8bit,即256项,软件上9bit,512项;所以在每项11位的pgd上就指向了两个pte, 空间大小是256*2=512,每项4字节,即2048byte,内核在分配的时候是分配了4K,即4096byte,多余的2048byte对应硬件不支持的dirty等选项。
static inline void __pmd_populate(pmd_t *pmdp, phys_addr_t pte, pmdval_t prot) { pmdval_t pmdval = (pte + PTE_HWTABLE_OFF) | prot; pmdp[0] = __pmd(pmdval); #ifndef CONFIG_ARM_LPAE pmdp[1] = __pmd(pmdval + 256 * sizeof(pte_t)); #endif flush_pmd_entry(pmdp); }
六.用户空间页表的分配 七.内核空间页表的分配(以linux-3.5为例) 在初始化内核空间页表时,大部分的内核空间已经映射好了,如内核代码的地址,内核内存地址,影响内核页表的内存分配主要有永久内核映射,临时内核映射,非连续内存分配等,当分配内存的时候,内核更新swapper_pg_dir地址下的页表,当访问该地址时,就会产生取值异常,然后就内核就把相应的内核映射的对应项复制到进程的相应的内核页表项中。
7.1 内核空间地址内存的分配 在系统初始化的时候,内核就会初始化内核内存的内核空间页表,
void __init paging_init(struct machine_desc *mdesc)
{ ……
map_lowmem();--àcreate_mapping(&map,false);
……
}
7.2 永久内核映射 pkmap : 0xbfe00000 - 0xc0000000 ( 2 MB)
kmap()
{ might_sleep();
if(!PageHighMem(page))
returnpage_address(page);
returnkmap_high(page);
}
#define PKMAP_BASE (PAGE_OFFSET - PMD_SIZE) //0xbfe00000
#define PAGE_OFFSET 0xC000 0000
#define PMD_SHIFT 21
#define PMD_SIZE (1UL << PMD_SHIFT)
#define LAST_PKMAP PTRS_PER_PTE //512
#define LAST_PKMAP_MASK (LAST_PKMAP - 1) //511
last_pkmap_nr = (last_pkmap_nr + 1) &LAST_PKMAP_MASK;
512 * 4k = 2 M,如标题所示。
7.3临时内核映射 fixmap : 0xfff00000 - 0xfffe0000 ( 896kB)
void *kmap_atomic(struct page *page)
4K一个页面
#define FIXADDR_START 0xfff00000UL
#define FIXADDR_TOP 0xfffe0000UL
#define FIXADDR_SIZE (FIXADDR_TOP - FIXADDR_START)
#define FIX_KMAP_BEGIN 0
#define FIX_KMAP_END (FIXADDR_SIZE >> PAGE_SHIFT)
7.4非连续内存分配 vmalloc : 0xe4800000 - 0xfc000000 ( 376 MB)
vmalloc()
#define VMALLOC_START (((unsignedlong)high_memory + VMALLOC_OFFSET) & ~
(VMALLOC_OFFSET-1))
#define VMALLOC_END 0xff000000UL
high_memory = __va(arm_lowmem_limit - 1) +1; //此处定义有疑义;
vmalloc -> __vmalloc_node_flags->__vmalloc_node -> __vmalloc_node_range ->
__vmalloc_area_node -> (此处可能有递归但最终会调用此函数:) map_vm_area ->
vmap_page_range ->vmap_page_range_noflush -> vmap_pud_range -> vmap_pmd_range ->
vmap_pte_range -> pte_alloc_kernel ->__pte_alloc_kernel -> pmd_populate_kernel
(&init_mm, pmd, new)->__pmd_populate(pmdp, __pa(ptep), _PAGE_KERNEL_TABLE)
static inline void __pmd_populate(pmd_t*pmdp, phys_addr_t pte,pmdval_t prot)
{ pmdval_tpmdval = (pte + PTE_HWTABLE_OFF) | prot;
pmdp[0]= __pmd(pmdval);
#ifndef CONFIG_ARM_LPAE
pmdp[1]= __pmd(pmdval + 256 * sizeof(pte_t));
#endif
flush_pmd_entry(pmdp);
}
八.空值异常 当内核访问某个地址发生异常时就会触发取值异常,从中断中进入函数
do_translation_fault(unsigned long addr,unsigned int fsr,
struct pt_regs *regs)
{ if(addr < TASK_SIZE)
returndo_page_fault(addr, fsr, regs); //用户空间的内存分配
如果地址大于TASK_SIZE,即 0xC000 0000,是内核空间的地址
进入内核空间页表的分配。
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
armv8 memory translation - _9_8 - 博客园