MMU #
MMU的原理如下:
在RAM中声明一片特殊的区域,作为虚拟内存和物理内存的映射表(页表),并把这块区域告诉MMU。 MMU是在ARM内核中的单独一个概念,每次读/写虚拟内存的时候,都通过MMU,MMU快速的从页表中查到物理地址,返回给读写方。
MMU的一个独立的物理CPU,其可以高速的完成页表查找的功能
页表: #
上述所述的RAM中的特殊区域叫做页表,MMU通过查询页表来进行虚拟地址->物理地址的转换。
页表 具体的实现来说,如下:
- 每个进程都维护自身的一个表(叫做页表),表中就两列,进程中的内存地址 <—-> 实际内存地址
- linux中有三级页表: 【linux三级页表】https://www.bottomupcs.com/virtual_memory_linux.xhtml#:~:text=In%20a%20three%20level%20page,map%20to%20the%20physical%20frame.
- 三级页表使得每个页表的页号都不会过大。如果当某个进程只用到很小的高地址内存(位于一级页表的尾部)的时,可以只加载所有的一级页表,而不加载大部分的二级页表。
页表的translate查表过程,请见 附录A: MMU寻址过程:
缺页: #
如上所述,linux中线程只会加载很小一部分的页表。 当线程需要用到更多内存时(例如malloc/vmalloc),这个时候就需要加载更多的页表了。 此时会触发缺页异常。
缺页异常是由MMU向CPU发送的一种异常,CPU会进入 sync exception 的状态
触发缺页异常后,linux会根据缺页的目标虚拟地址,判断此次缺页是属于正常、异常:
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
...
if (!vmf->pte)
return do_pte_missing(vmf);
if (!pte_present(vmf->orig_pte))
return do_swap_page(vmf);
if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
return do_numa_page(vmf);
if (unlikely(!pte_same(ptep_get(vmf->pte), entry))) {
update_mmu_tlb(vmf->vma, vmf->address, vmf->pte);
goto unlock;
}
if (vmf->flags & (FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)) {
if (!pte_write(entry))
return do_wp_page(vmf);
else if (likely(vmf->flags & FAULT_FLAG_WRITE))
entry = pte_mkdirty(entry);
}
...
}
如果此次缺页属于正常缺页,那么会进入 do_pte_missing
:
do_pte_missing
->do_anonymous_page
--> vmf->flags & FAULT_FLAG_WRITE //入股只读分配零页
--> alloc_anon_folio //分配对应的物理内存,内存管理使用 folio 管理系统进行分配
--->...
---->__alloc_pages_slowpath
零页:系统初始化过程中分配了一页的内存,大小为一页,页对齐到bss段,所有这段数据内核初始化的时候会被清零,所有称之为0页。作用为一个是它的数据都是被0填充,读的时候数据都是0,二是节约内存,匿名页面第一次读的时候数据都是0都会映射到这页中从而节约内存(共享0页),那么如果有进程要去写这个这个页会发生写时复制,重新分配页来写。对于匿名映射,映射完成之后,只是获得了一块虚拟内存,并没有分配物理内存,当第一次访问的时候:如果是读访问,会将虚拟页映射到0页,以减少不必要的内存分配;如果是写访问,则会分配新的物理页,并用0填充,然后映射到虚拟页上去。
malloc分配内存后,直接读:这种情况下,在linux内核中会进入匿名页面的缺页中断,使用系统零页进行映射,这时映射的PTE属性是只读 malloc分配内存后,先读后写: 这种情况下,读的操作会让linux内核使用系统零页来建立页表的映射关系,这时PTE的属性是只读的。当应用程序需要往这个虚拟内存中写内容时,又触发了另外一个缺页异常,也就是后面的写时复制技术 malloc分配内存后,直接写: 这种情况下,在Linux内核中会进入缺页匿名的缺页异常中,使用alloc_zeroed_user_highpage_movable函数分配一个新的页面,并且使用该页面来设置PTE,这时候这个PTE的属性是可写的。8
进程的页表 #
如上所述,每个进程都有自己的页表。 即进程A不能访问进程B的资源(通常情况下,除非用共享内存)。
- 用户空间创建线程,拷贝MMU页表:
//https://github.com/bminor/glibc/blob/master/nptl/pthread_create.c#L621
__pthread_create_2_1
//https://github.com/bminor/glibc/blob/master/nptl/pthread_create.c#L297
>create_thread
>__clone_internal (&args, &start_thread, pd);
>__clone3
//https://github.com/bminor/glibc/blob/master/sysdeps/unix/sysv/linux/aarch64/arch-syscall.h#L20
>#define __NR_clone3 435
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
| CLONE_SIGHAND | CLONE_THREAD
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID
| 0);
//kernel:
#define __NR_clone3 435
__SYSCALL(__NR_clone3, sys_clone3)
SYSCALL_DEFINE2(clone3, struct clone_args __user *, uargs, size_t, size)
{
...
return kernel_clone(&kargs);
}
--->copy_process
---->copy_mm // 创建线程时,拷贝对应的MMU页表
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm);
if (!mm)
return -ENOMEM;
}
tsk->mm = mm;
tsk->active_mm = mm;
- 用户空间创建进程,不拷贝MMU页表:
//https://elixir.bootlin.com/glibc/glibc-2.40.9000/source/posix/fork.c
__libc_fork
->_Fork //https://elixir.bootlin.com/glibc/glibc-2.40.9000/source/sysdeps/nptl/_Fork.c#L23
-->arch_fork //https://elixir.bootlin.com/glibc/glibc-2.40.9000/source/sysdeps/unix/sysv/linux/arch-fork.h#L35
const int flags = CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD;
//kernel:
...
...
同上面创建线程
小结: 创建线程时,会拷贝进程的页表,所以线程可以访问进程的所有资源; 创建进程时,则不会拷贝,所以进程之间的数据访问是隔离的
但是不同的进程,切换到内核运行时(SVC指令),却是共用同一份内存资源,即内核态的页表是共享的。
这是通过 TTBR0 和 TTBR1 实现的,当系统进入到EL1级别运行时,只有 TTBR1 会生效, TTBR1 中始终保存了内核的页表: swapper_pg_dir
TLB #
翻译后备缓冲区(Translation lookaside buffer),CPU可以通过特定的指令,将特定的页表项放到TLB高速缓存中去,可以加快MMU读取这些页表项的速度。
MMU启动流程 #
MMU启动流程代码解析 #
ARM64 #
//arch/arm64/kernel/head.S
->__HEAD
-->b primary_entry
--->adrp x1, early_init_stack //初始化栈、sp指针
--->adrp x0, init_idmap_pg_dir //创建id_map映射区域,映射范围为 kernel镜像的 text段、data段
---->__cpu_setup //开启MMU
mov_q tcr, TCR_T0SZ(IDMAP_VA_BITS) | TCR_T1SZ(VA_BITS_MIN) | TCR_CACHE_FLAGS | \
TCR_SHARED | TCR_TG_FLAGS | TCR_KASLR_FLAGS | TCR_ASID16 | \
TCR_TBI0 | TCR_A1 | TCR_KASAN_SW_FLAGS | TCR_MTE_FLAGS
mov_q x0, INIT_SCTLR_EL1_MMU_ON
---->__primary_switch
----->__enable_mmu //mmu貌似有两个开关,再开一个
----->map_kernel //开启MMU后映射kernel的DDR加载区域到虚拟地址
start_kernel // init/main.c
->setup_arch
-->early_fixmap_init // 初始化恒等映射区
-->early_ioremap_init // 没啥用
-->setup_machine_fdt // 映射fdt设备树到虚拟内存
-->paging_init // 映射swapper_pg_dir
ARM32 #
Linux启动时,MMU的开启流程如下:
1.uboot跳转Linux,带参数x0寄存器代表设备树的地址
>_/arch/arm/kernel/head.S
>__create_page_tables 初始化页表
>创建恒等映射区
>创建kernel Image映射区
>开启MMU enable_mmu
> 初始化堆栈 add sp, x4, #THREAD_SIZE
> start_kernel 执行C代码
> 初始化 fixmap: early_fixmap_init
>
1.创建恒等映射区 #
将 虚拟地址 0x8100100 映射到物理地址 0x81001000 。保证处理器在开启MMU前后可以连续取指令,因为处理器大多是流水线体系结构。
//获取idmap_pg_dir变量(此变量在vmlinux.lds文件中指定)的物理地址
adrp x0, idmap_pg_dir
//获取__idmap_text_start变量的物理地址
adrp x3, __idmap_text_start
//进行映射
map_memory x0, x1, x3, x6, x7, x3, x4, x10, x11, x12, x13, x14
2.创建kernel Image映射区 #
将 虚拟地址 0xffff8xxxxxxx 映射到物理地址 0x81001000 。之后可以使用0xffff8xxxxxx访问kernel函数
adrp x0, init_pg_dir // 运行时动态获取init_pg_dir的物理地址
mov_q x5, KIMAGE_VADDR // 编译时指定 KIMAGE_VADDR 虚拟地址 0xffff8xxxxxxx
add x5, x5, x23
mov x4, PTRS_PER_PGD
adrp x6, _end // 运行时动态获取init_pg_dir的物理地址
adrp x3, _text // 运行时动态获取init_pg_dir的物理地址
sub x6, x6, x3 // _end - _text
add x6, x6, x5 // runtime __va(_end)IMAGE_VADDR
map_memory x0, x1, x5, x6, x7, x3, x4, x10, x11, x12, x13, x14
3.开启MMU #
将 init_pg_dir的值填入 ttbr0 寄存器
adrp x1, init_pg_dir
__enable_mmu
phys_to_ttbr x1, x1
phys_to_ttbr x2, x2
msr ttbr0_el1, x2 // load TTBR0
offset_ttbr1 x1, x3
msr ttbr1_el1, x1 // load TTBR1
4. 创建fixmap区域 #
fixmap区域意思是划分一段固定的虚拟地址,例如 0xffff 8000 0000 5000,映射到固定的某段物理地址。主要用于 earlycon、APEI等比较特殊的应用场景
//FIXADDR_START = 0xfffffdfffe5fb000
//pgdp = 0xffff800011ce4fd8
//p4dp = 0xffff800011ce4fd8
//pudp = 0xffff800011858ff8
//pmdp = 0xffff800011859f90
//bm_pte = 0xffff80001185a000
void __init early_fixmap_init(void) {
...
pgdp = pgd_offset_k(addr); //获取pgd的虚拟地址, 这里获取到的是swapper_pg_dir的虚拟地址;
p4dp = p4d_offset(pgdp, addr); //获取p4d的虚拟地址,这里获取到的是: 0xffff800011ce4fd8
...
__p4d_populate(p4dp, __pa_symbol(bm_pud), PUD_TYPE_TABLE);//往p4d中填地址
...
__pud_populate(pudp, __pa_symbol(bm_pmd), PMD_TYPE_TABLE);//往pud中填地址
...
__pmd_populate(pmdp, __pa_symbol(bm_pte), PMD_TYPE_TABLE);//往pmd中填地址
}
附录A: MMU寻址过程: #
https://app.diagrams.net/#G1J1rl36p9lfc9MLUUNMSnLFGVi90wqVFI
附录B: 为什么每个进程都有单独的MMU页表 #
https://www.zhihu.com/question/24916947
题主的理解不完全正确,这样做岂不是和“所有用户进程共享内核页表
” 这个结论矛盾了? 这个解读是错误的。
正确的解读是:所有的进程内核态空间的映射关系是完全一样的,但每个进程的页表是独立一份(也即每个进程中关于内核态空间
的页表内容是完全一样的)。
所以题主是有两个问题需要回答的,我来写一下吧:
1. 为什么内核空间页表各进程内容是完全一样,还是每个进程独立一份
2. 为什么fork进程,需要从swapper_pg_dir
拷贝一份
问题1:为什么内核空间页表各进程内容是完全一样,还需要每个进程独立一份
我认为是出于性考虑,如果所有进程在内核态都使用同一份页表,CPU从用户态进入内核态的所有场景(系统调用,硬中断),首先做的一个事情就是切页表,然后得刷TLB等事情,开销较大,不可接受,所以采用每个进程进入内核态进不需要切换页表的方案。
题2:为什么fork进程,需要从swapper_pg_dir拷贝一份
答案同样是很简单,内核态空间只有lowmem
的映射是稳的(永远不会变),对于lowmem之后的虚拟地址空间的映射是时刻在发生变的,比如vmalloc操作,ioremap操作,kmap等等操作。由于所有进程的内核映射
关系都应的数据完全一致的,那一旦要修改,则面临两个问题:
1) 如何让所有进程感知,并修改页表
2)在此之后fork出来的进程,怎么感知这个映射
对于1),如果马上修改所有进程页表,会引发系统“群惊”,而对2)总得找个地方把最新的页表记录下来,在fork进程时拷贝一份来使用。
是的,Linux内核使用swapper_pg_dir作为主控页表,所以内核空间的页表修改,都只修改这个页责,不信你可以看看vmalloc函数,kmap函数
是修改哪个页表的。
由于其它进程内核态页表是没有变化的,所以访问最新的内核指针时,会出现 缺页异常,这时内核会将swapper_pg_dir相应的页表项拷贝到当前进程,接着运行。
以上就是整个原理,对,只说原理,不帖代码。