跳过正文

linux mmu/页表

·549 字·3 分钟
jiladahe1997
作者
jiladahe1997
To see the world as it is, and to love it

MMU
#

MMU的原理如下:


RAM中声明一片特殊的区域,作为虚拟内存和物理内存的映射表页表),并把这块区域告诉MMU。 MMU是在ARM内核中的单独一个概念,每次读/写虚拟内存的时候,都通过MMU,MMU快速的从页表中查到物理地址,返回给读写方。

MMU的一个独立的物理CPU,其可以高速的完成页表查找的功能

图片.png


页表:
#

上述所述的RAM中的特殊区域叫做页表,MMU通过查询页表来进行虚拟地址->物理地址的转换。

页表 具体的实现来说,如下:


页表的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的资源(通常情况下,除非用共享内存)。

  1. 用户空间创建线程,拷贝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;
  1. 用户空间创建进程,不拷贝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指令),却是共用同一份内存资源,即内核态的页表是共享的。

这是通过 TTBR0TTBR1 实现的,当系统进入到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相应的页表项拷贝到当前进程,接着运行。

以上就是整个原理,对,只说原理,不帖代码。