AkiraZheng's Time.

ARM64结构与编程:内存管理

Word count: 2.1kReading time: 9 min
2026/03/21

内存管理的基础内容可以看另一篇博客:内存管理篇

VMSA

MMU

VMSA 提供了 MMU 硬件单元,MMU 中还包含了 TLB,保留的是 MMU 页表翻译后的转换结果。

MMU 硬件单元用来实现 VA 到 PA 的地址转换(转换由硬件自动转换):

  • 硬件遍历页表:table walk
  • TTBR0/1:页表基地址寄存器,指向当前使用的页表(一级页表)的物理地址

其中,TTBR0 通常用于用户空间地址转换,TTBR1 用于内核空间地址转换。为了实现用户态和内核态进程的隔离,保证内核空间不被用户空间访问,因此可以看到我们总线有 64 bit,但实际上用户空间和内核空间的地址范围都被限制在 48 bit 内,原因是这样就可以通过 TTBR0 和 TTBR1 分别指向不同的页表来实现用户空间和内核空间的地址转换。

  • 内核空间:0xFFFF000000000000 ~ 0xFFFFFFFFFFFFFFFF,占用高位虚拟地址空间
  • 用户空间:0x0000000000000000 ~ 0x0000FFFFFFFFFFFF,占用低位虚拟地址空间

二、初始化 MMU

要注意,页表修改后,需要把相关的 TLB entry 刷新掉,否则可能会访问到过期的 TLB entry 导致访问错误。

初始化 MMU 的时候需要做的事:

  1. 初始化 PGD 页表目录:清空 PGD 页,准备好页表基地址(idmap_pg_dir)
  2. 创建代码段(.text)映射:建立恒等映射(VA=PA),确保启用 MMU 后代码能继续执行
  3. 创建测试内存映射:建立 512MB 内存的页表映射,用于测试页表功能
  4. 创建 MMIO 映射:为设备寄存器区域建立映射,使用设备内存属性
  5. 配置 CPU MMU 寄存器:设置内存属性(MAIR)、虚拟/物理地址范围(TCR)、浮点支持等
  6. 启用 MMU:设置页表基地址(TTBR0),使能 MMU(SCTLR_EL1.M),刷新 TLB
1
2
3
4
5
6
7
8
9
void paging_init(void)
{
memset(idmap_pg_dir, 0, PAGE_SIZE);//创建第一页 pgd 页,其中idmap_pg_dir就是基地址,需要填到TTBR中
create_identical_mapping();//创建text段的内存映射,并创建用于测试的 512 MB 内存的页表映射
create_mmio_mapping();//创建mmio I/O 映射。
cpu_init();//配置CPU启动MMU的各种寄存器配置,比如页表粒度(4KB)、va/pa地址范围(48bits)、内存属性...
enable_mmu();//使能mmu使能位、填充L0页表基地址到TTBR0中
printk("enable mmu done\n");
}

三、访问测试

该测试中,需要创建完 text 内存映射后,再创建 512MB 的内存映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void create_identical_mapping(void)
{
//...

/* 创建 512 MB 内存的页表 */
/*map memory*/
start = PAGE_ALIGN((unsigned long)_etext);
end = TOTAL_MEMORY;
__create_pgd_mapping((pgd_t *)idmap_pg_dir, start, start,
end - start, PAGE_KERNEL,
early_pgtable_alloc,
0);
}

代码中只设置了代码段~512MB的内存,因此先测试创建的内存以内的地址访问会触发页错误异常(TOTAL_MEMORY - 4096),然后测试没有内存映射的地址访问会触发页错误异常:

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
static int test_access_map_address(void)
{
unsigned long address = TOTAL_MEMORY - 4096;

*(unsigned long *)address = 0x55;

printk("%s access 0x%x done\n", __func__, address);

return 0;
}

/*
* 访问一个没有建立映射的地址
* 应该会触发一级页表访问错误。
*
* Translation fault, level 1
*
* 见armv8.6手册第2995页
*/
static int test_access_unmap_address(void)
{
unsigned long address = TOTAL_MEMORY + 4096;

*(unsigned long *)address = 0x55;

printk("%s access 0x%x done\n", __func__, address);

return 0;
}

static void test_mmu(void)
{
test_access_map_address();//在已经建立页表的512MB内访问内存
test_access_unmap_address();//在建立映射之外的地址进行访问:触发abort
}

最后的运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
akira@akira:~/BenOS/BenOS_ARM/benos$ make run
qemu-system-aarch64 -machine raspi3 -nographic -kernel benos.bin
Booting at EL2
Booting at EL1
Welcome BenOS!
printk init done
<0x800880> func_c
el = 1
done
enable mmu done
test_access_map_address access 0x1ffff000 done
Bad mode for Sync Abort, far:0x20001000, esr:0x0000000096000046 - DABT (current EL)
ESR info:
ESR = 0x96000046
Exception class = DABT (current EL), IL = 32 bits
Data abort:
SET = 0, FnV = 0
EA = 0, S1PTW = 0
CM = 0, WnR = 1
DFSC = Translation fault, level2
Kernel panic

实验二:dump 页表

我们 debug 内存相关的内容的时候,经常需要 dump 出页表的虚拟地址、页表项属性等信息。

同时,这也可以看出我们的页表映射是否正确。

实现方法是软件上遍历页表,遍历到页表的叶子节点时,打印出虚拟地址、页表项属性等信息。

一、定义打印内容-数据结构

需要打印的内容: - 页表项属性对应的属性值(转成文字描述) - 叶子节点的层级:pg_level[].name = PGD/PUD/PMD/PTE - 每个叶子节点 entry 代表的页面大小 - 普通内存页是 4K,叶子节点的层级是PTE - 大页有可能是是 2MB,叶子节点的层级是PMD

1
2
3
4
5
6
7
8
9
10
11
12
13
struct prot_bits {
unsigned long mask;//ATTR 表中的哪一类属性(idx),比如PTE_RDONLY、PTE_AF
unsigned long val;//ATTR 表中的哪一类属性(idx),比如PTE_RDONLY、PTE_AF
const char *set;//属性值,表示开启这个属性的话应该打印什么,比如"ro"、"AF"
const char *clear;//属性值,表示关闭这个属性的话应该打印什么,比如"RW"、" "
};

struct pg_level {
const struct prot_bits *bits;//数组,数组中每一项表示一种属性及其值。
const char *name; // 层级名称:"PGD"/"PUD"/"PMD"/"PTE"
size_t num;//页面大小:
unsigned long mask;//提取当前level entry 项的 prot mask
};

下面在初始化 MMU 后,就可以初始化一下各个层级的各个属性的mask值,把所有属性位全部置为1:

1
2
3
4
5
6
7
8
9
static void pg_level_init()
{
unsigned int i, j;

for (i = 0; i < ARRAY_SIZE(pg_level); ++i)//4个页表级别,ARRAY_SIZE(pg_level)=4
if (pg_level[i].bits)
for (j = 0; j < pg_level[i].num; ++j)//这里设置了ATTR表的15个属性
pg_level[i].mask |= pg_level[i].bits[j].mask;
}

运行结果如下:

1
2
3
4
5
6
7
8
9
(gdb) p/x pg_level[1].mask
$2 = 0x70000000000fdf
(gdb) p/x pg_level[2].mask
$3 = 0x70000000000fdf
(gdb) p/x pg_level[3].mask
$4 = 0x70000000000fdf
(gdb) p/x pg_level[4].mask
$5 = 0x70000000000fdf
(gdb)

二、遍历页表并打印

遍历页表的逻辑跟创建页表的类似,也是一层一层遍历下去,这里就不赘述了。唯一不一样的是遇到PUD_TYPE_SECT/PMD_TYPE_SECT/PTE_TYPE_SECT属性时,表示是叶子节点,此时停止遍历,调用print_pgtabledump 打印节点信息。

1
2
3
4
walk_pgd
+-> walk_pud
+-> walk_pmd
+-> walk_pte
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
static void walk_pud(pgd_t *pgdp, unsigned long start, unsigned long end)
{
unsigned long next, addr = start;
pud_t *pudp = pud_offset_phys(pgdp, start);
pud_t pud;

do {
pud = *pudp;
next = pud_addr_end(start, end);

//pud_sect(pud) == true 表示是叶子节点
if (pud_none(pud) || pud_sect(pud))
print_pgtable(addr, next, 2, pud_val(pud));
else
//非叶子节点,向下继续遍历
walk_pmd(pudp, addr, next);
+-> walk_pte

} while (pudp++, addr = next, addr != end);
}

static void walk_pgd(pgd_t *pgd, unsigned long start, unsigned long size)
{
unsigned long end = start + size;
unsigned long next, addr = start;
pgd_t *pgdp;
pgd_t pgd_entry;

pgdp = pgd_offset_raw(pgd, start);

do {
pgd_entry = *pgdp;
next = pgd_addr_end(addr, end);

if (pgd_none(pgd_entry))//无效页,未用于映射的页表项
print_pgtable(addr, next, 1, pgd_val(pgd_entry));
else
walk_pud(pgdp, addr, next);
} while (pgdp++, addr = next, addr != end);
}

运行结果如下:

  • create_identical_mapping函数创建出来的.text 段不是 2MB 对齐的,虽然设置了BLOCK_MAPPINGS但是非 2MB 对齐,因此也是创建 PTE 节点:

    1
    2
    3
    4
    5
    6
    7
    8
    ---[ Identical mapping ]---                                                                                                                                                         
    0x0000000000080000-0x0000000000081000 4K PTE ro x SHD AF UXN MEM/NORMAL
    0x0000000000081000-0x0000000000082000 4K PTE ro x SHD AF UXN MEM/NORMAL
    0x0000000000082000-0x0000000000083000 4K PTE ro x SHD AF UXN MEM/NORMAL
    #...
    0x00000000001fd000-0x00000000001fe000 4K PTE RW NX SHD AF UXN MEM/NORMAL
    0x00000000001fe000-0x00000000001ff000 4K PTE RW NX SHD AF UXN MEM/NORMAL
    0x00000000001ff000-0x0000000000200000 4K PTE RW NX SHD AF UXN MEM/NORMAL

  • create_identical_mapping函数创建出来的 512MB 块内存区域:

    1
    2
    3
    4
    5
    6
    7
    0x0000000000200000-0x0000000000400000           2M PMD       RW NX SHD AF        BLK UXN MEM/NORMAL                                                                                 
    0x0000000000400000-0x0000000000600000 2M PMD RW NX SHD AF BLK UXN MEM/NORMAL
    0x0000000000600000-0x0000000000800000 2M PMD RW NX SHD AF BLK UXN MEM/NORMAL
    #...
    0x000000001fa00000-0x000000001fc00000 2M PMD RW NX SHD AF BLK UXN MEM/NORMAL
    0x000000001fc00000-0x000000001fe00000 2M PMD RW NX SHD AF BLK UXN MEM/NORMAL
    0x000000001fe00000-0x0000000020000000 2M PMD RW NX SHD AF BLK UXN MEM/NORMAL

非 text 段的普通内存页范围为0x86000~0x20000000,所以也有一部分未对齐的普通内存页被写成 4KB 内存了。原因是:

1
2
_etext = 0x850d0
PAGE_ALIGN(_etext) = 0x86000 # 4KB 对齐

0x86000 不是 2MB 对齐的,因此会创建 4KB 页节点

1
2
$ python3 -c "print(hex(0x86000 % 0x200000))"
0x86000 # 余数不为0,说明不是2MB对齐

所以从 0x86000 开始:

  • 先用 4KB 页填充到 0x90000(下一个 2MB 边界)
  • 从 0x200000 开始才是 2MB 对齐,才用 2MB 块
1
2
3
0x86000 ──── 4KB 页 ───→ 0x200000 ──── 2MB 块 ───→ 0x20000000
↑ ↑ ↑
开始 第一个2MB边界 512MB结束

不同内存区域的对齐方式导致 dump_pgtable 会同时看到 4KB 和 2MB 叶子节点:

内存区域 起始/结束对齐 结果
.text 代码段 通常不是严格 2MB 对齐 4KB 页(PTE 叶子)
512MB 内存 通常是 2MB 对齐 2MB 块(PMD 叶子)
CATALOG
  1. VMSA
    1. MMU
    2. 二、初始化 MMU
    3. 三、访问测试
  2. 实验二:dump 页表
    1. 一、定义打印内容-数据结构
    2. 二、遍历页表并打印