从零到负一

02. ARMv8 MMU的初始化

2022/04/03

在上一篇笔记【TODO】中,我已经总结了ARMv8中MMU的基础知识。这篇笔记主要总结在Linux启动阶段,MMU是如何开启,以及临时页表是如何建立起来的。由于ARMv8有多种Exception Level, Security Level以及Execute Level等,为了方便起见,我这里就分析一种:EL1, NS, AARCH64, 4K页面。

本文所用的部分宏

为了便于写这篇笔记,很有必要将这里使用过的宏定义总结一下:

1
2
3
4
5
6
7
8
9
10
#define VA_BITS                         48 
#define CONFIG_ARM64_4K_PAGES 1
#define ARM64_SWAPPER_USES_SECTION_MAPS 1
#define CONFIG_PGTABLE_LEVELS 4
#define SWAPPER_PGTABLE_LEVELS 3
#define IDMAP_PGTABLE_LEVELS 3
#define SWAPPER_DIR_SIZE 12
#define IDMAP_DIR_SIZE 12
#define SWAPPER_TABLE_SHIFT 30
#define SWAPPER_BLOCK_SHIFT 21

页表的结构

要决定页表的结构,我们首先需要确定的就是VA_BITS是多少,也就是有效的虚拟地址位数。这个值由TCR_EL1中的TxSZ决定。在Linux内核中,VA_BITS的设置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ./arch/arm64/Kconfig
config ARM64_VA_BITS
int
default 36 if ARM64_VA_BITS_36
default 39 if ARM64_VA_BITS_39
default 42 if ARM64_VA_BITS_42
default 47 if ARM64_VA_BITS_47
default 48 if ARM64_VA_BITS_48

// ./arch/arm64/configs/defconfig
// 默认设置是48bit的有效位
CONFIG_ARM64_VA_BITS_48=y

// ./arch/arm64/include/asm/memory.h:59
#define VA_BITS (CONFIG_ARM64_VA_BITS)

要确定页表的结构,我们还需要知道页面的大小,下面3个宏定义了页面的大小:

1
2
3
CONFIG_ARM64_4K_PAGES
CONFIG_ARM64_16K_PAGES
CONFIG_ARM64_64K_PAGES

我这里使用的是4级页表+4K的页面,VA_BITS = 48,因此其结构如下:

1
2
3
4
5
6
7
|63 --- 48|47 --- 39|38 --- 30|29 --- 21|20 --- 12|11 --- 0|
| | | | | |-- Page offset ( = 4KB )
| | | | |------------ Level 3 PTE idx (512 * 4KB = 2MB )
| | | |---------------------- Level 2 PMD idx (512 * 512 * 4KB = 1GB )
| | |-------------------------------- Level 1 PUD idx (512 * 512 * 512 * 4K = 512GB)
| |------------------------------------------ Level 0 PGD idx (512 * 512 * 512 * 512 * 4K = 256TB)
|-- 全1或0

基于这个页表,我们来看看Linux是如何设置页表以及开启MMU的。

创建启动页表

在Linux启动阶段,内核创建了两个页表, idmap_pg_dirswapper_pg_dir,其中第一个用于保障开启MMU前后取指地址保持不变,第二个用于映射Linux内核。在MMU开启前,系统是直接使用物理地址进行取指,而开启MMU后,由于有了VA->PA的映射,PA就可能不是之前的PA了,因此需要做一个VA = PA的映射来保证内核的正常工作。这两个页表的位置可以通过./arch/arm64/kernel/vmlinux.lds.S查看,它们在BSS段的后面。

1
2
3
4
5
6
7
BSS_SECTION(0, 0, 0)

. = ALIGN(PAGE_SIZE);
idmap_pg_dir = .;
. += IDMAP_DIR_SIZE;
swapper_pg_dir = .;
. += SWAPPER_DIR_SIZE;

我们再来看看这两个页表的大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ./arch/arm64/include/asm/kernel-hwdef.h
/*
* Number of page-table levels required to address 'va_bits' wide
* address, without section mapping. We resolve the top (va_bits - PAGE_SHIFT)
* bits with (PAGE_SHIFT - 3) bits at each page table level. Hence:
*
* levels = DIV_ROUND_UP((va_bits - PAGE_SHIFT), (PAGE_SHIFT - 3))
*
* where DIV_ROUND_UP(n, d) => (((n) + (d) - 1) / (d))
*
* We cannot include linux/kernel.h which defines DIV_ROUND_UP here
* due to build issues. So we open code DIV_ROUND_UP here:
*
* ((((va_bits) - PAGE_SHIFT) + (PAGE_SHIFT - 3) - 1) / (PAGE_SHIFT - 3))
*
* which gets simplified as :
*/
#define ARM64_HW_PGTABLE_LEVELS(va_bits) (((va_bits) - 4) / (PAGE_SHIFT - 3))

// ./arch/arm64/include/asm/kernel-pgtable.h
/*
* The linear mapping and the start of memory are both 2M aligned (per
* the arm64 booting.txt requirements). Hence we can use section mapping
* with 4K (section size = 2M) but not with 16K (section size = 32M) or
* 64K (section size = 512M).
*/
// 我将不适用的部分注释掉
#ifdef CONFIG_ARM64_4K_PAGES
#define ARM64_SWAPPER_USES_SECTION_MAPS 1
#else
// #define ARM64_SWAPPER_USES_SECTION_MAPS 0
#endif

#if ARM64_SWAPPER_USES_SECTION_MAPS
#define SWAPPER_PGTABLE_LEVELS (CONFIG_PGTABLE_LEVELS - 1)
#define IDMAP_PGTABLE_LEVELS (ARM64_HW_PGTABLE_LEVELS(PHYS_MASK_SHIFT) - 1)
#else
// #define SWAPPER_PGTABLE_LEVELS (CONFIG_PGTABLE_LEVELS)
// #define IDMAP_PGTABLE_LEVELS (ARM64_HW_PGTABLE_LEVELS(PHYS_MASK_SHIFT))
#endif

#define SWAPPER_DIR_SIZE (SWAPPER_PGTABLE_LEVELS * PAGE_SIZE) // 3 * 4KB
#define IDMAP_DIR_SIZE (IDMAP_PGTABLE_LEVELS * PAGE_SIZE) // 3 * 4KB

在前面我说过,我用的是48bit的虚拟地址空间,需要4级页表。但在启动阶段,如果我们定义了页面的大小为4K,我们就不需要4级页表了,只需要3级即可。第4级和最后12位合并,成为2MB的一个块。这么做,每个页表就可以少用一个页,并且简化了映射的过程。

有了上面的这些知识,我们可以来看看内核是如何创建这两个页表的。下面内容牵涉较多汇编语言,本人不才,汇编了解不多,只挑重点介绍下。
下面是Linux内核初始化最开始的一部分,我们这里就看两个函数:(1)创建并初始化页表,(2)启动MMU:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    // ./arch/arm64/kernel/head.S 
/*
* The following callee saved general purpose registers are used on the
* primary lowlevel boot path:
*
* Register Scope Purpose
* x21 stext() .. start_kernel() FDT pointer passed at boot in x0
* x23 stext() .. start_kernel() physical misalignment/KASLR offset
* x28 __create_page_tables() callee preserved temp register
* x19/x20 __primary_switch() callee preserved temp registers
*/
ENTRY(stext)
bl preserve_boot_args
bl el2_setup // Drop to EL1, w0=cpu_boot_mode
adrp x23, __PHYS_OFFSET
and x23, x23, MIN_KIMG_ALIGN - 1 // KASLR offset, defaults to 0
bl set_cpu_boot_mode_flag
bl __create_page_tables // ------------------------------------------ (1)
/*
* The following calls CPU setup code, see arch/arm64/mm/proc.S for
* details.
* On return, the CPU will be ready for the MMU to be turned on and
* the TCR will have been set.
*/
bl __cpu_setup // initialise processor
b __primary_switch // ---------------------------------------------- (2)
ENDPROC(stext)

要开启MMU,我们必须要先设置好页表,否则开启MMU前后,VA->PA的映射可能会不同。

__create_page_tables 第一步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/*
* Setup the initial page tables. We only setup the barest amount which is
* required to get the kernel running. The following sections are required:
* - identity mapping to enable the MMU (low address, TTBR0)
* - first few MB of the kernel linear mapping to jump to once the MMU has
* been enabled
*/
__create_page_tables:
mov x28, lr

/*
* Invalidate the idmap and swapper page tables to avoid potential
* dirty cache lines being evicted.
*/
adrp x0, idmap_pg_dir // ---------------------------------------------- (1)
ldr x1, =(IDMAP_DIR_SIZE + SWAPPER_DIR_SIZE + RESERVED_TTBR0_SIZE)
bl __inval_dcache_area // ---------------------------------------------- (2)

/*
* Clear the idmap and swapper page tables.
*/
adrp x0, idmap_pg_dir // ---------------------------------------------- (3)
ldr x1, =(IDMAP_DIR_SIZE + SWAPPER_DIR_SIZE + RESERVED_TTBR0_SIZE)
1: stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
subs x1, x1, #64
b.ne 1b

mov x7, SWAPPER_MM_MMUFLAGS

/*
* Create the identity mapping.
*/
adrp x0, idmap_pg_dir // ------------------------------------------------- (4)
adrp x3, __idmap_text_start // __pa(__idmap_text_start)

#ifndef CONFIG_ARM64_VA_BITS_48
// 这部分就是略去,我们直接看VA_BITS = 48的情况
#endif

(1)中使用adrp指令(目前我还没搞懂这个指令是如何工作的)获取idmap_pg_dir的基地址(物理地址)并存入x0

(2)中清空这几个页表所在的dcache - 在MMU开启前,dcache是不会被使用的(内存不是cacheable类型)这么做是为了避免不必要的问题;

(3)将这几个页表置零(表示这些页表项都是无效的);

(4)获取idmap_pg_dir__idmap_text_start,分别存入x0x3

__create_page_tables 第二步

接下来,我们要开始初始化PGD, PUD, PMD和PTE了:

1
2
3
4
create_pgd_entry x0, x3, x5, x6 // --------------------------------------- (1)
mov x5, x3 // __pa(__idmap_text_start)
adr_l x6, __idmap_text_end // __pa(__idmap_text_end)
create_block_map x0, x7, x3, x5, x6 // ----------------------------------- (2)

我们先来看(1)这里的函数,根据我们的配置,它会初始化idmap_pg_dir的PGD和PUD(实际上是因为SWAPPER_TABLE_SHIFT = PUD):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/*
* Macro to populate the PGD (and possibily PUD) for the corresponding
* block entry in the next level (tbl) for the given virtual address.
*
* Preserves: tbl, next, virt
* Corrupts: tmp1, tmp2
*/
.macro create_pgd_entry, tbl, virt, tmp1, tmp2
// tbl = idmap_pg_dir
// virt = __idmap_text_start
create_table_entry \tbl, \virt, PGDIR_SHIFT, PTRS_PER_PGD, \tmp1, \tmp2

// SWAPPER_PGTABLE_LEVELS = 3,这部分不会运行
// #if SWAPPER_PGTABLE_LEVELS > 3
// create_table_entry \tbl, \virt, PUD_SHIFT, PTRS_PER_PUD, \tmp1, \tmp2
// #endif

// SWAPPER_PGTABLE_LEVELS = 3
#if SWAPPER_PGTABLE_LEVELS > 2
create_table_entry \tbl, \virt, SWAPPER_TABLE_SHIFT, PTRS_PER_PTE, \tmp1, \tmp2
#endif
.endm

/*
* Macro to create a table entry to the next page.
*
* tbl: page table address
* virt: virtual address
* shift: #imm page table shift
* ptrs: #imm pointers per table page
*
* Preserves: virt
* Corrupts: tmp1, tmp2
* Returns: tbl -> next level table page address
*/
.macro create_table_entry, tbl, virt, shift, ptrs, tmp1, tmp2
lsr \tmp1, \virt, #\shift
and \tmp1, \tmp1, #\ptrs - 1 // 根据virt获取table index
add \tmp2, \tbl, #PAGE_SIZE // tmp2中存的是下一级页表的基地址
orr \tmp2, \tmp2, #PMD_TYPE_TABLE // address of next table and entry type - 这是一个table descriptor
str \tmp2, [\tbl, \tmp1, lsl #3] // 更新当前页表的页表项,存入下一级页表的基地址(这个地址也是物理地址)
add \tbl, \tbl, #PAGE_SIZE // next level table page
.endm

完成create_pgd_entry后,我们分别在idmap_pg_dir中的PGD和PUD初始化了一个entry,接下来就需要做最后的映射,也就是完成PMD的初始化,我们需要使用(2)处的函数。

__create_page_tables 第三步

在(2)处,我们传入参数tbl = PMD table, phys = __pa(__idmap_text_start), start = phys, end = __pa(__idmap_text_end),因此,映射的物理地址和虚拟地址的起始地址一样。
注意,这里我们只是将[start, end]范围内的内核进行了映射(这部分包括MMU开启前后的部分),并没有将整个内核进行映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* Macro to populate block entries in the page table for the start..end
* virtual range (inclusive).
*
* Preserves: tbl, flags
* Corrupts: phys, start, end, pstate
*/
.macro create_block_map, tbl, flags, phys, start, end
lsr \phys, \phys, #SWAPPER_BLOCK_SHIFT // phys左移21位
lsr \start, \start, #SWAPPER_BLOCK_SHIFT // start左移21位
and \start, \start, #PTRS_PER_PTE - 1 // table index
orr \phys, \flags, \phys, lsl #SWAPPER_BLOCK_SHIFT // table entry - 这步设置block descriptor的lower/upper attributes
lsr \end, \end, #SWAPPER_BLOCK_SHIFT
and \end, \end, #PTRS_PER_PTE - 1 // table end index
9999: str \phys, [\tbl, \start, lsl #3] // store the entry
add \start, \start, #1 // next entry
add \phys, \phys, #SWAPPER_BLOCK_SIZE // next block
cmp \start, \end
b.ls 9999b
.endm

到此,idmap_pg_dir初始化完成,我们继续看代码。这部分代码和上面基本重复,就不再详细分析了。这里需要注意两个地方,

  1. idmap_pg_dir不同,这里是将整个内核text部分进行映射;
  2. 虚拟地址的起始地址是KIMAGE_VADDR + TEXT_OFFSET,已经在TTBR1_EL1的地址范围了;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    /*
* Map the kernel image (starting with PHYS_OFFSET).
*/
adrp x0, swapper_pg_dir
mov_q x5, KIMAGE_VADDR + TEXT_OFFSET // compile time __va(_text)
add x5, x5, x23 // add KASLR displacement
create_pgd_entry x0, x5, x3, x6
adrp x6, _end // runtime __pa(_end)
adrp x3, _text // runtime __pa(_text)
sub x6, x6, x3 // _end - _text
add x6, x6, x5 // runtime __va(_end)
create_block_map x0, x7, x3, x5, x6

/*
* Since the page tables have been populated with non-cacheable
* accesses (MMU disabled), invalidate the idmap and swapper page
* tables again to remove any speculatively loaded cache lines.
*/
adrp x0, idmap_pg_dir
ldr x1, =(IDMAP_DIR_SIZE + SWAPPER_DIR_SIZE + RESERVED_TTBR0_SIZE)
dmb sy
bl __inval_dcache_area

ret x28
ENDPROC(__create_page_tables)
.ltorg

开启MMU

MMU是在__primary_switch中的__enable_mmu中开启的,在开启MMU前,我们还需要对ARM的各种寄存器进行配置,这里我就不详细记录了(汇编功力太差,一会儿就看晕了),直接看MMU启动的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* Enable the MMU.
*
* x0 = SCTLR_EL1 value for turning on the MMU.
*
* Returns to the caller via x30/lr. This requires the caller to be covered
* by the .idmap.text section.
*
* Checks if the selected granule size is supported by the CPU.
* If it isn't, park the CPU
*/
ENTRY(__enable_mmu)
mrs x1, ID_AA64MMFR0_EL1
ubfx x2, x1, #ID_AA64MMFR0_TGRAN_SHIFT, 4
cmp x2, #ID_AA64MMFR0_TGRAN_SUPPORTED
b.ne __no_granule_support
update_early_cpu_boot_status 0, x1, x2
// 将这两个页表添加入TTBRX_EL1,
// 1. idmap_pg_dir是在TTBR0_EL1(因为内核的物理地址就在这个区域),swapper_pg_dir是在TTBR1_EL1处
// 2. 从这里可以看出,TTBRx放的是物理地址
adrp x1, idmap_pg_dir
adrp x2, swapper_pg_dir
msr ttbr0_el1, x1 // load TTBR0
msr ttbr1_el1, x2 // load TTBR1
isb
msr sctlr_el1, x0
isb
/*
* Invalidate the local I-cache so that any instructions fetched
* speculatively from the PoC are discarded, since they may have
* been dynamically patched at the PoU.
*/
ic iallu
dsb nsh
isb
ret
ENDPROC(__enable_mmu)

至此,MMU开启成功,至于这两个页表,idmap_pg_dir在之后会被用户态的进程修改,而swapper_pg_dir会作为内核态的页表被所有内核线程使用。

参考资料

ARM64的启动过程之(二):创建启动阶段的页表

ARM64的启动过程之(四):打开MMU

ARMv8 MMU及Linux页表映射

arm64_linux启动流程分析05_配置内核启动的临时页表

CATALOG
  1. 1. 本文所用的部分宏
  2. 2. 页表的结构
  3. 3. 创建启动页表
    1. 3.1. __create_page_tables 第一步
    2. 3.2. __create_page_tables 第二步
    3. 3.3. __create_page_tables 第三步
  4. 4. 开启MMU
  5. 5. 参考资料