1 介绍

在之前,虚拟内存就写过一篇:https://blog.csdn.net/fanged/article/details/158703959。不过这一篇只是写了虚拟内存在ARM上的硬件支持,并没有写在操作系统中的应用和实现。这次就结合这个部分来写写。

首先还是回顾一下之前接触的虚拟内存的知识:

初始化

核心作用是Set up page translation tables and enable virtual memory,也就是创建映射表和使能。具体是在mmu_init中创建了paging数组,里面的内容就是PDE和PTE。之后将这个数组写入ttbr0_el1和ttbr1_el1寄存器(分别对应用户空间和内核),然后使能页表转换。

应用

应用的流程之前写过,这里直接引用一下即可。

1 启动:将 paging 数组(根页表)的物理地址加载到寄存器TTBR0。
2 索引(VPN):当访问虚拟地址时,MMU取出VA的高位作为索引,去查找页表。
3 查找(PDE/PTE):
    如果查到的是 PDE(L1/L2 描述符),MMU 获得下一级表的地址,继续查。
    如果查到的是 PTE(L3 描述符),MMU 获得PPN。
4 合成:MMU将PPN和虚拟地址的低12 位(Offset)拼接,得到最终能从内存条上拿数据的物理地址。
5 缓存(TLB):MMU把这次查到的结果存入TLB,下次访问同样的地址就不用再查页表了。

本质就是给了一个虚拟地址,如何转换成物理地址的流程。

2 代码分析

 示例代码来自:https://github.com/s-matyukevich/raspberry-pi-os/tree/master/src/lesson06

这一篇也是这个系列目前最后的一篇。其实后面还有很多内容,比如文件系统,驱动等等。但是很遗憾,作者烂尾了。。。虽然还遗憾,但是对我还好,很多知识点都写过了。

这篇相对于lesson5,台阶没那么大,新增内容要相对少一些。根据上一节的预习,我们知道,MMU分为初始化和应用两个部分。那么就根据这两个部分来阅读。

2.1 内核层页表

直接在启动的boot.S里面,降级到el1就开始。

.globl _start
_start:
	mrs	x0, mpidr_el1		
	and	x0, x0,#0xFF		// Check processor id
	cbz	x0, master		// Hang for all non-primary CPU
	b	proc_hang

proc_hang: 
	b proc_hang

master:
	ldr	x0, =SCTLR_VALUE_MMU_DISABLED
	msr	sctlr_el1, x0		

	ldr	x0, =HCR_VALUE
	msr	hcr_el2, x0

	ldr	x0, =SCR_VALUE
	msr	scr_el3, x0

	ldr	x0, =SPSR_VALUE
	msr	spsr_el3, x0

	adr	x0, el1_entry		
	msr	elr_el3, x0

	eret				

el1_entry:
	adr	x0, bss_begin
	adr	x1, bss_end
	sub	x1, x1, x0
	bl 	memzero

	bl 	__create_page_tables

2.1.1 创建表

调用__create_page_tables创建页表。整个代码如下:

	.macro	create_pgd_entry, tbl, virt, tmp1, tmp2
	create_table_entry \tbl, \virt, PGD_SHIFT, \tmp1, \tmp2
	create_table_entry \tbl, \virt, PUD_SHIFT, \tmp1, \tmp2
	.endm

	.macro	create_table_entry, tbl, virt, shift, tmp1, tmp2
	lsr	\tmp1, \virt, #\shift
	and	\tmp1, \tmp1, #PTRS_PER_TABLE - 1			// table index
	add	\tmp2, \tbl, #PAGE_SIZE
	orr	\tmp2, \tmp2, #MM_TYPE_PAGE_TABLE	
	str	\tmp2, [\tbl, \tmp1, lsl #3]
	add	\tbl, \tbl, #PAGE_SIZE					// next level table page
	.endm

	.macro	create_block_map, tbl, phys, start, end, flags, tmp1
	lsr	\start, \start, #SECTION_SHIFT
	and	\start, \start, #PTRS_PER_TABLE - 1			// table index
	lsr	\end, \end, #SECTION_SHIFT
	and	\end, \end, #PTRS_PER_TABLE - 1				// table end index
	lsr	\phys, \phys, #SECTION_SHIFT
	mov	\tmp1, #\flags
	orr	\phys, \tmp1, \phys, lsl #SECTION_SHIFT			// table entry
9999:	str	\phys, [\tbl, \start, lsl #3]				// store the entry
	add	\start, \start, #1					// next entry
	add	\phys, \phys, #SECTION_SIZE				// next block
	cmp	\start, \end
	b.ls	9999b
	.endm

__create_page_tables:
	mov	x29, x30						// save return address

	adrp	x0, pg_dir
	mov	x1, #PG_DIR_SIZE
	bl 	memzero

	adrp	x0, pg_dir
	mov	x1, #VA_START 
	create_pgd_entry x0, x1, x2, x3

	/* Mapping kernel and init stack*/
	mov 	x1, xzr							// start mapping from physical offset 0
	mov 	x2, #VA_START						// first virtual address
	ldr	x3, =(VA_START + DEVICE_BASE - SECTION_SIZE)		// last virtual address
	create_block_map x0, x1, x2, x3, MMU_FLAGS, x4

	/* Mapping device memory*/
	mov 	x1, #DEVICE_BASE					// start mapping from device base address 
	ldr 	x2, =(VA_START + DEVICE_BASE)				// first virtual address
	ldr	x3, =(VA_START + PHYS_MEMORY_SIZE - SECTION_SIZE)	// last virtual address
	create_block_map x0, x1, x2, x3, MMU_DEVICE_FLAGS, x4

	mov	x30, x29						// restore return address
	ret

这里使用了嵌套宏来构建 ARM64 的多级页表(PGD -> PUD -> PMD/Section),可以看到,和之前的教程还是不同,之前是PDE/PTE。这个源自 x86(32位)架构或者是比较简化的二级页表教程。

区别:

  • 32位系统 (PDE/PTE):地址空间只有 2^32 (4GB)。使用两级页表(10+10+12 位拆分)就足以覆盖全范围。

  • 64位系统 (PGD/PUD/PMD/PTE):理论地址空间高达 2^64。即便 ARM64 实际通常只使用39位或48位地址线,两级页表也完全不够用。如果只用两级,单张页表会变得极其巨大(几百 GB),这在物理内存里根本存不下。

不过在这里的内核态代码中,也进行了简化。在应用层中的普通映射:PGD -> PUD -> PMD -> PTE (一般4KB)。

虚拟地址到物理page映射完整的示意如下:

不过这里内核层代码直接是段映射 (Block Mapping):PGD -> PUD -> PMD (直接指向 2MB 物理块)。简化了一级。

入口是__create_page_tables

这里首先用到的是pg_dir,这个是在link.ld里面定义的。基本上是在整个分区的最后。

SECTIONS
{
    . = 0xffff000000000000;
    .text.boot : { *(.text.boot) }
    . = ALIGN(0x00001000);
    user_begin = .;
    .text.user : { build/user* (.text) }
    .rodata.user : { build/user* (.rodata) }
    .data.user : { build/user* (.data) }
    .bss.user : { build/user* (.bss) }
    user_end = .;
    .text :  { *(.text) }
    .rodata : { *(.rodata) }
    .data : { *(.data) }
    . = ALIGN(0x8);
    bss_begin = .;
    .bss : { *(.bss*) } 
    bss_end = .;
    . = ALIGN(0x00001000);
    pg_dir = .;
    .data.pgd : { . += (3 * (1 << 12)); }
}

VA_START是一个宏,0xffff000000000000,在内核中,就是将物理地址做一个填充,就成为虚拟地址。将pg_dir和VA_START作为参数传给create_pgd_entry。

    .macro    create_pgd_entry, tbl, virt, tmp1, tmp2
    create_table_entry \tbl, \virt, PGD_SHIFT, \tmp1, \tmp2
    create_table_entry \tbl, \virt, PUD_SHIFT, \tmp1, \tmp2
    .endm

可以看到,这里是用的create_table_entry创建。

    .macro    create_table_entry, tbl, virt, shift, tmp1, tmp2
    lsr    \tmp1, \virt, #\shift
    and    \tmp1, \tmp1, #PTRS_PER_TABLE - 1            // table index
    add    \tmp2, \tbl, #PAGE_SIZE
    orr    \tmp2, \tmp2, #MM_TYPE_PAGE_TABLE    
    str    \tmp2, [\tbl, \tmp1, lsl #3]
    add    \tbl, \tbl, #PAGE_SIZE                    // next level table page
    .endm

这里的页表都是使用汇编创建,不过据说linux这些还是用的C创建。

上面涉及到的宏定义如下:

#define PAGE_SHIFT          12

#define TABLE_SHIFT             9

#define PGD_SHIFT           PAGE_SHIFT + 3*TABLE_SHIFT

#define PUD_SHIFT           PAGE_SHIFT + 2*TABLE_SHIFT

#define PMD_SHIFT           PAGE_SHIFT + TABLE_SHIFT

#define PTRS_PER_TABLE          (1 << TABLE_SHIFT)

这里的PAGE_SHIFT 12,表示了一个页面的大小,2的12次方, 4096,即标准的4KB页面。ABLE_SHIFT 9,表示每一级页表包含的条目。这里就是2的9次方,也就是512个。

PMD_SHIFT (Level 2),这里是12+9=21,也就是2MB,表示了一个PMD的空间。PUD_SHIFT (Level 1)是30,也就是1GB,表示一个PUD表示1GB空间。最后的PGD_SHIFT (Level 0)是39,表示512GB,表示整个虚拟地址空间有效位是 39 位(即所谓的39-bit VA)。

这里创建了一个表的基本结构:

物理地址 页表层级 (Level) 存放内容
pg_dir + 0 PGD (Level 0) 包含一个指向 PUD 的指针 (描述符)
pg_dir + 4KB PUD (Level 1) 包含一个指向 PMD 的指针 (描述符)
pg_dir + 8KB PMD (Level 2) 目前为空(等待后续 create_block_map 填入物理地址)

2.1.2 填充内容

使用的create_block_map填充PMD的内容。该函数将一段连续的虚拟地址映射到连续的物理地址上。这里使用的是 Section Mapping(段映射),即 2MB 的大页。代码如下:

    /* Mapping kernel and init stack*/
    mov     x1, xzr                            // start mapping from physical offset 0
    mov     x2, #VA_START                        // first virtual address
    ldr    x3, =(VA_START + DEVICE_BASE - SECTION_SIZE)        // last virtual address
    create_block_map x0, x1, x2, x3, MMU_FLAGS, x4

    /* Mapping device memory*/
    mov     x1, #DEVICE_BASE                    // start mapping from device base address 
    ldr     x2, =(VA_START + DEVICE_BASE)                // first virtual address
    ldr    x3, =(VA_START + PHYS_MEMORY_SIZE - SECTION_SIZE)    // last virtual address
    create_block_map x0, x1, x2, x3, MMU_DEVICE_FLAGS, x4

    .macro    create_block_map, tbl, phys, start, end, flags, tmp1
    lsr    \start, \start, #SECTION_SHIFT
    and    \start, \start, #PTRS_PER_TABLE - 1            // table index
    lsr    \end, \end, #SECTION_SHIFT
    and    \end, \end, #PTRS_PER_TABLE - 1                // table end index
    lsr    \phys, \phys, #SECTION_SHIFT
    mov    \tmp1, #\flags
    orr    \phys, \tmp1, \phys, lsl #SECTION_SHIFT            // table entry
9999:    str    \phys, [\tbl, \start, lsl #3]                // store the entry
    add    \start, \start, #1                    // next entry
    add    \phys, \phys, #SECTION_SIZE                // next block
    cmp    \start, \end
    b.ls    9999b
    .endm

大概含义就是:

1 计算起始和结束索引。
2 构造 Entry(物理地址 + 属性位 flags)。
3 循环填充页表项,直到覆盖从 start 到 end 的范围。也就是填充完整个PMD。

填充完成后的表格如下:

物理地址 (PA) 逻辑名称 填入的内容 (Entry 内容) 指向哪里
0x1000 PGD (L0) `0x2000 MM_TYPE_PAGE_TABLE`
0x2000 PUD (L1) `0x3000 MM_TYPE_PAGE_TABLE`
0x3000 PMD (L2) `0x000000 MMU_FLAGS`
0x3008 PMD (L2) `0x200000 MMU_FLAGS`
... ... ... ...
0x3xxx PMD (L2) `0x3F000000 MMU_DEVICE_FLAGS`

最后将pg_dir写入ttbr1_el1寄存器,之后内核态的查表,就是查的上面创建的表。

至于应用层的表创建,是set_pgd函数完成,这个下面介绍。。。

2.2 应用层页表

首先还是创建内核进程copy_process。

和lesson在内存管理商的第一个区别,之前的childregs->sp = stack + PAGE_SIZE;p->stack = stack;换成了copy_virt_memory(p);实现如下:

int copy_virt_memory(struct task_struct *dst) {
	struct task_struct* src = current;
	for (int i = 0; i < src->mm.user_pages_count; i++) {
		unsigned long kernel_va = allocate_user_page(dst, src->mm.user_pages[i].virt_addr);
		if( kernel_va == 0) {
			return -1;
		}
		memcpy(kernel_va, src->mm.user_pages[i].virt_addr, PAGE_SIZE);
	}
	return 0;
}

这里就是遍历父进程的页表,然后重新申请,最后将内容拷贝过来。

在真正的 Linux 内核中,这里通常会使用 写时复制 (Copy-on-Write, COW) 技术。fork 时不拷贝内容,只把页表设为只读并共享物理页。只有当有人想写数据时,硬件触发异常,内核才在那一刻进行真正的 memcpy

之后就是将内核进程切换到用户进程,使用move_to_user_mode实现。

int move_to_user_mode(unsigned long start, unsigned long size, unsigned long pc)
{
	struct pt_regs *regs = task_pt_regs(current);
	regs->pstate = PSR_MODE_EL0t;
	regs->pc = pc;
	regs->sp = 2 *  PAGE_SIZE;  
	unsigned long code_page = allocate_user_page(current, 0);
	if (code_page == 0)	{
		return -1;
	}
	memcpy(code_page, start, size);
	set_pgd(current->mm.pgd);
	return 0;
}

重要的首先还是分配了用户页allocate_user_page。

unsigned long allocate_user_page(struct task_struct *task, unsigned long va) {
	unsigned long page = get_free_page();
	if (page == 0) {
		return 0;
	}
	map_page(task, va, page);
	return page + VA_START;
}

这里做的就是创建了用户空间的页表。使用map_page:

void map_page(struct task_struct *task, unsigned long va, unsigned long page){
	unsigned long pgd;
	if (!task->mm.pgd) {
		task->mm.pgd = get_free_page();
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = task->mm.pgd;
	}
	pgd = task->mm.pgd;
	int new_table;
	unsigned long pud = map_table((unsigned long *)(pgd + VA_START), PGD_SHIFT, va, &new_table);
	if (new_table) {
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = pud;
	}
	unsigned long pmd = map_table((unsigned long *)(pud + VA_START) , PUD_SHIFT, va, &new_table);
	if (new_table) {
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = pmd;
	}
	unsigned long pte = map_table((unsigned long *)(pmd + VA_START), PMD_SHIFT, va, &new_table);
	if (new_table) {
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = pte;
	}
	map_table_entry((unsigned long *)(pte + VA_START), va, page);
	struct user_page p = {page, va};
	task->mm.user_pages[task->mm.user_pages_count++] = p;
}

unsigned long map_table(unsigned long *table, unsigned long shift, unsigned long va, int* new_table) {
	unsigned long index = va >> shift;
	index = index & (PTRS_PER_TABLE - 1);
	if (!table[index]){
		*new_table = 1;
		unsigned long next_level_table = get_free_page();
		unsigned long entry = next_level_table | MM_TYPE_PAGE_TABLE;
		table[index] = entry;
		return next_level_table;
	} else {
		*new_table = 0;
	}
	return table[index] & PAGE_MASK;
}

这里的基本功能和内核层的汇编功能一致。但是这里实现的是Page Mapping(4KB 页)。完整实现了 PGD -> PUD -> PMD -> PTE。

这里的pgd + VA_START,就是把物理地址pgd,通过偏移转换成虚拟地址。这个也仅限在内核中的操作。

最后通过set_pgd将页表地址设置到寄存器ttbr0_el1。

set_pgd:
	msr	ttbr0_el1, x0
	tlbi vmalle1is
  	DSB ISH              // ensure completion of TLB invalidation
	isb
	ret

假设get_free_page的地址是0x100000,此时生成的表结构如下:

物理地址 (PA) 表类型 条目索引 (Index) 条目内容 (Entry Value) 含义解释
0x100000 PGD [0] `0x101000 0x3`
0x101000 PUD [0] `0x102000 0x3`
0x102000 PMD [0] `0x103000 0x3`
0x103000 PTE [0] `0x400000 FLAGS`

2.3 应用

运行时,整体流程和lesson5没有太大区别。在内核中创建一个user_process,之后将它切换到用户态。

void user_process() 
{
	call_sys_write("User process\n\r");
	int pid = call_sys_fork();
	if (pid < 0) {
		call_sys_write("Error during fork\n\r");
		call_sys_exit();
		return;
	}
	if (pid == 0){
		loop("abcde");
	} else {
		loop("12345");
	}
}

在这里,首先打印User process,之后fork了这个进程,然后循环打印abcde和12345 。

3 代码运行

输出:

Kernel process started. EL 1
User process
12345123451234512abcdeabcdeabcdeabcdeab34512345123451234cdeabcdeabcdeabc

deabcd51234

打印的速度比之前慢很多。

4 调试

4.1 内核页表创建

在代码分配内存的地方增加了printf,但是对于内核态的页表的打印,只能写函数去读取,内存地址是根据log填的,因为运行时已经开启了MMU,所以增加了0xffff000000000000偏移。具体的函数内容如下:

void dump_page_tables() {
	unsigned long ttbr1;
	asm volatile("mrs %0, ttbr1_el1" : "=r" (ttbr1));
	printf("TTBR1_Low: %u\n", (unsigned int)ttbr1);

	unsigned long manual_pgd_va = 0x84000 + 0xffff000000000000;
    unsigned long *manual_ptr = (unsigned long *)manual_pgd_va;

    // 打印前 8 个条目(看看 0x3 属性位)
    for (int i = 0; i < 1; i++) {
        unsigned int low = (unsigned int)(manual_ptr[i] & 0xFFFFFFFF);
        printf("PGD Index [%d] Low_32: %u\r\n", i, low);
    }
	unsigned long *pud_ptr = (unsigned long *)(0x85000 + 0xffff000000000000);
    unsigned int pud_low = (unsigned int)(pud_ptr[0] & 0xFFFFFFFF);
    printf("PUD Index [0] Low_32: %u\r\n", pud_low);

	unsigned long *pmd_ptr = (unsigned long *)(0x86000 + 0xffff000000000000);
	printf("PMD Index [0] Low_32: %u\r\n", (unsigned int)(pmd_ptr[0] & 0xFFFFFFFF));
	for (int i = 1; i < 512; i++) {
		if (pmd_ptr[i] != 0) {
			//前面几百个条目是有规律递增的
			printf("PMD Index [%d] Low_32: %u\r\n",i, (unsigned int)(pmd_ptr[i] & 0xFFFFFFFF));
		}
	}
}

内核页表打印结果如下:

dump_page_tables
TTBR1_Low: 540672 (0x84000)
PGD Index [0] Low_32: 544771 (0x85003)
PUD Index [0] Low_32: 548867(0x86003)
PMD Index [0] Low_32: 1029(0x405)
PMD Index [1] Low_32: 2098181
PMD Index [2] Low_32: 4195333
PMD Index [3] Low_32: 6292485
PMD Index [4] Low_32: 8389637
PMD Index [5] Low_32: 10486789
PMD Index [6] Low_32: 12583941
PMD Index [7] Low_32: 14681093
PMD Index [8] Low_32: 16778245
PMD Index [9] Low_32: 18875397

...

生成的页表结构如下,不过这里只有一个PGD,一个PUD,PMD是有一组,后面没有PTE了,直接接的2MB的block。

页表的定义如下:

根据打印的内核页表信息和上面的页表结构,整理结果如下:

步骤 寄存器/条目 物理地址 关键值 (Hex) 动作
起点 TTBR1_EL1 0x84000 - 告诉 CPU 去 528KB 处找 PGD
第一级 PGD[0] 0x84000 0x85003 发现 0x3,跳往 PUD (0x85000)
第二级 PUD[0] 0x85000 0x86003 发现 0x3,跳往 PMD (0x86000)
第三级别 PMD[0] 0x86000 0x405 映射成功! 对应物理地址 0x0
PMD[1] 0x200405

这里PGD的0x3就是属性,0x3也就是11,最后两个一个是Present,一个是Read/Write。表示该页存在于内存中 (Present) 且用户拥有读写权限 (Writable)。硬件看到这最后两位,知道这是一个 Table Descriptor(指向下一级页表)。

最后的各种flag含义如下:

十六进制 二进制 含义
0x1 0001 存在 (Present),但只读
0x3 0011 存在 (Present),可读可写
0x5 0101 存在,只读,且已访问 (Accessed)(位 2 是 User/Supervisor 位)。
0x7 0111 存在,可读写,且用户态可访问

至于PDE,flag定义和上面也是一样。

最后的1029,也就是0x405,PMD(Level 2)的 Block Descriptor(块描述符)中,低位 (Bit 0 - 11):被保留用来存放属性(Attributes)和类型(Type)。高位 (Bit 12 - 47):才是真正的输出物理地址(Output Address)

0x400是Bit 10 (AF - Access Flag):访问标志位。表示这个内存已经访问过。最低两位是 01,表示这是一个block。

整体的LOG显示有个问题,作者源代码里面带的printf无法完整打印0xffff000000000000这种64位的地址,应该是只打印最后32位。

4.2 应用层页表创建

...
copy_process page 4194304
Kernel process started. EL 1
allocate_user_page page 4198400
11111111111111
map_page task->mm.pgd 4202496
map_table next_level_table 4206592
map_page pud 4206592
map_table next_level_table 4210688
map_page pmd 4210688
map_table next_level_table 4214784
map_page pte 4214784
move_to_user_mode code_page 4198400
map_page task->mm.pgd 4202496
map_page pud 4206592
map_page pmd 4210688
map_page pte 4214784
User process
copy_process page 4222976
allocate_user_page page 4227072
11111111111111
map_page task->mm.pgd 4231168
map_table next_level_table 4235264
map_page pud 4235264
map_table next_level_table 4239360
map_page pmd 4239360
map_table next_level_table 4243456
map_page pte 4243456
copy_virt_memory kernel_va 4227072
allocate_user_page page 4247552
map_page task->mm.pgd 4231168
map_page pud 4235264
map_page pmd 4239360
map_page pte 4243456
copy_virt_memory kernel_va 4247552
12345123451234512abcdeabcdeabcdeabcdeab3451234512345123cdeabcde

代码中首先调用的是copy_process里面的分类内核页,

copy_process page 4194304: 内核分配了一个 4KB 页面(物理地址 4194304,即 0x400000)作为 task_struct 结构体。

之后是将内核的进程切换到用户层。move_to_user_mode(begin, end - begin, process - begin);

在这里增加打印,是这样的、Kernel process move_to_user_mode. begin 4096 end  4398 process 4188。这里对应的是link的位置。

SECTIONS
{
    . = 0xffff000000000000;
    .text.boot : { *(.text.boot) }
    . = ALIGN(0x00001000);
    user_begin = .;
    .text.user : { build/user* (.text) }
    .rodata.user : { build/user* (.rodata) }
    .data.user : { build/user* (.data) }
    .bss.user : { build/user* (.bss) }
    user_end = .;
    .text :  { *(.text) }
    .rodata : { *(.rodata) }
    .data : { *(.data) }
    . = ALIGN(0x8);
    bss_begin = .;
    .bss : { *(.bss*) } 
    bss_end = .;
    . = ALIGN(0x00001000);
    pg_dir = .;
    .data.pgd : { . += (3 * (1 << 12)); }
}

在这里,就是把    .text.user : { build/user* (.text) } .rodata.user : { build/user* (.rodata) } .data.user : { build/user* (.data) } .bss.user : { build/user* (.bss) }这些全部拷贝到了用户层的页表中。之后将user_process的起始位置传给pc寄存器。可以看到,此时的process 4188是位于begin4096和end4398之间。

之后调用allocate_user_page page 4198400: 为用户代码分配了第一块物理内存页(0x401000)。

之后就类似内核中的页表,开始创建用户层页表。对应的代码如下:

	if (!task->mm.pgd) {
		task->mm.pgd = get_free_page();
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = task->mm.pgd;
	}
	pgd = task->mm.pgd;
	int new_table;
	unsigned long pud = map_table((unsigned long *)(pgd + VA_START), PGD_SHIFT, va, &new_table);
	if (new_table) {
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = pud;
	}
	unsigned long pmd = map_table((unsigned long *)(pud + VA_START) , PUD_SHIFT, va, &new_table);
	if (new_table) {
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = pmd;
	}
	unsigned long pte = map_table((unsigned long *)(pmd + VA_START), PMD_SHIFT, va, &new_table);
	if (new_table) {
		task->mm.kernel_pages[++task->mm.kernel_pages_count] = pte;
	}
	map_table_entry((unsigned long *)(pte + VA_START), va, page);

可以看到,用户层这里比起内核页表的区别是多了一层PTE。PTE中再去映射4KB的页。

  • map_page task->mm.pgd 4202496: 分配 PGD 根表4202496(0x402000)。

  • map_table 4206592: 分配 PUD 表4206592(0x403000)。

  • map_table 4210688: 分配 PMD 表4210688(0x404000)。

  • map_table 4214784: 分配 PTE 表4214784(0x405000)。

最后有一个函数去创建映射page页。

map_table_entry((unsigned long *)(pte + VA_START), va, page);

void map_table_entry(unsigned long *pte, unsigned long va, unsigned long pa) {
	unsigned long index = va >> PAGE_SHIFT;
	index = index & (PTRS_PER_TABLE - 1);
	unsigned long entry = pa | MMU_PTE_FLAGS; 
	pte[index] = entry;
}

这里是填充PTE页表项,这里只有一个,就是PTE[0]=0x401447(4199495)。

这里的entry = pa | MMU_PTE_FLAGS,去掉低三位,此时是0x401000,也就是在函数中分配的物理page。0x447是标志位,也就是0100 0100 0111,最后的111在上面说过,表示存在,可读写,且用户态可访问。

也就是说,此时创建了0x0000000000000000对应到0x401000的映射。

之后又调用了map_page,触发了缺页do_mem_abort。原因是作者特意在move_to_user_mode这里使用了2倍pagesize,后面会触发中断。

int move_to_user_mode(unsigned long start, unsigned long size, unsigned long pc)

{

    struct pt_regs *regs = task_pt_regs(current);

    regs->pstate = PSR_MODE_EL0t;

    regs->pc = pc;

    regs->sp = 2 *  PAGE_SIZE;  

    unsigned long code_page = allocate_user_page(current, 0);

    if (code_page == 0) {

        return -1;

    }

    memcpy(code_page, start, size);

    set_pgd(current->mm.pgd);

    return 0;

}

处理缺页中断的函数如下:

int do_mem_abort(unsigned long addr, unsigned long esr) {
	printf("do_mem_abort\r\n");
	unsigned long dfs = (esr & 0b111111);
	if ((dfs & 0b111100) == 0b100) {
		unsigned long page = get_free_page();
		if (page == 0) {
			return -1;
		}
		map_page(current, addr & PAGE_MASK, page);
		ind++;
		if (ind > 2){
			return -1;
		}
		return 0;
	}
	return -1;
}

这里首先判断是不是缺页0b100,之后用ind避免死循环。

整个页表没有重建,只是在最后PTE中增加了一项。PTE[1]=0x406447(4219975)。

根据上面的分析,447是标志位,这里的物理页是0x406000。也就说虚拟地址0x0000000000001000对应到了0x406000。

之后的代码是在用户态fork第二个进程。如下:

void user_process() 
{
	call_sys_write("User process\n\r");
	int pid = call_sys_fork();
	if (pid < 0) {
		call_sys_write("Error during fork\n\r");
		call_sys_exit();
		return;
	}
	if (pid == 0){
		loop("abcde");
	} else {
		loop("12345");
	}
}

在fork的时候,如果发现是用户层状态下,此时会调用copy_virt_memory,复制自身的物理页和页表。

int copy_virt_memory(struct task_struct *dst) {
	struct task_struct* src = current;
	for (int i = 0; i < src->mm.user_pages_count; i++) {
		unsigned long kernel_va = allocate_user_page(dst, src->mm.user_pages[i].virt_addr);
		if( kernel_va == 0) {
			return -1;
		}
		memcpy(kernel_va, src->mm.user_pages[i].virt_addr, PAGE_SIZE);
	}
	return 0;
}

在这里又会调用allocate_user_page,触发map_page,这个流程和上面一致,就不多写了。

因为src->mm.user_pages_count此时已经是2了,就是由上面缺页多分配了一个,所以整体流程是循环了两次。两个过程中PGD(0x409000),PUD(0x40A000),PMD(0x40B000),PTE(0x40C000)都完全一致,只是在PTE中增加了一个条目。第二个进程中PTE中两个内容如下(0x408447)4228167和(0x40D447)4248647,也就是0x408000和0x40D000。

调用批次 目标虚拟地址 (VA) 目标物理页 (PA) 操作性质
第一波 0x0 (代码段) 0x408000 开荒:创建了全套页表,填入了第一个物理页。
第二波 0x1000 (栈/异常页) 0x40D000 填坑:发现路都通了,直接在现成的PTE表里填入第二个物理页。

到现在整个流程就结束了。创建了两个用户态的进程,并且使用虚拟地址。

4.3 实验

在user进程中增加一个栈上的变量data,打印地址。在子进程中修改data,看看两个进程中的变量data是不是独立的。

	char data = 'A';	
	int pid = call_sys_fork();
	if (pid == 0) 
		call_sys_write("Son process ");
	else
		call_sys_write("father process ");
	char buf[20] = {0};
	hex_to_str((unsigned long)buf, buf);
	call_sys_write(buf);
	if (pid == 0) {
		data = 'C'; // 子进程修改自己物理页里的内容
		while(1) {
			char msg[2] = {data, '\0'};
			call_sys_write(msg); // 应该一直打 'C'
			user_delay(1000000);
		}
	} else {
		user_delay(500000); // 稍微等子进程改完
		while(1) {
			char msg[20] = {data, '\0'};
			call_sys_write(msg); // 应该一直打 'A'
			user_delay(1000000);
		}
	}
}

得到的log如下:

Kernel process started. EL 1
allocate_user_page page 4198400
11111111111111
map_page task->mm.pgd 4202496
map_table next_level_table 4206592
map_table table[index] 0 4206595
map_page pud 4206592
map_table next_level_table 4210688
map_table table[index] 0 4210691
map_page pmd 4210688
map_table next_level_table 4214784
map_table table[index] 32 4214787
map_page pte 4214784
map_table_entry index 0 entry4199495
allocate_user_page page 4218880
map_page task->mm.pgd 4202496
map_page pud 4206592
map_page pmd 4210688
map_table next_level_table 4222976
map_table table[index] 0 4222979
map_page pte 4222976
map_table_entry index 0 entry4219975
move_to_user_mode code_page 4218880
do_mem_abort
do_mem_abort page 4227072
map_page task->mm.pgd 4202496
map_page pud 4206592
map_page pmd 4210688
map_page pte 4222976
map_table_entry index 1 entry4228167
User process
copy_process page 4231168
allocate_user_page page 4235264
11111111111111
map_page task->mm.pgd 4239360
map_table next_level_table 4243456
map_table table[index] 0 4243459
map_page pud 4243456
map_table next_level_table 4247552
map_table table[index] 0 4247555
map_page pmd 4247552
map_table next_level_table 4251648
map_table table[index] 32 4251651
map_page pte 4251648
map_table_entry index 0 entry4236359
copy_virt_memory kernel_va 4235264
allocate_user_page page 4255744
map_page task->mm.pgd 4239360
map_page pud 4243456
map_page pmd 4247552
map_table next_level_table 4259840
map_table table[index] 0 4259843
map_page pte 4259840
map_table_entry index 0 entry4256839
copy_virt_memory kernel_va 4255744
allocate_user_page page 4263936
map_page task->mm.pgd 4239360
map_page pud 4243456
map_page pmd 4247552
map_page pte 4259840
map_table_entry index 1 entry4265031
copy_virt_memory kernel_va 4263936
father process 0x0000000000001FE0
AAAAAAAAAAAAAAAAAAAA_schedule
Son process 0x0000000000001FE0
CCCCCCCCCCCCCCCCCCCCCC_schedule
_schedule
AAAAAAAAAAAAAAAAAAAAAA_schedule
CCCCCCCCCCCCCCCCCCCCCC_schedule
_schedule
AAAAAAAAAAAAAAAAAAAAAA_schedule
CCCCCCCCCCCCCCCCCCCCCC_schedule
_schedule
AAAAAAAAAAAAAT

分析log可以看到,父子进程的buf地址都是0x0000000000001FE0。但是对应的物理地址却不同。在子进程中修改了data,并不会影响到父进程。

进程 虚拟地址 (完全一样) 独有物理页基址 最终 buf 物理地址
父进程 0x4000FE0 4239360 4239360/ 0x40B000
子进程 0x4000FE0 4263936 4251648 / 0x411000

这部分看着简单,实际代码调了好几天了,稍微一点没对,就是一个报错,大概这样的IRQ_INVALID_EL0_32, ESR: 82000007, address: 1fe0,都要把我搞得麻木了。

好了,整个流程,大概就是这样。。。

参考:

https://wiki.osdev.org/Paging

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐