Nuttx - Address Translation

从 arm_head.S 的 boot 初始化到每进程复制 L1 页表,再到 ELF 加载与 syscall 调度——一条完整链路拆解 NuttX 如何在 ARMv7-A 上通过两级页表实现轻量级进程隔离。


1. 背景:三种构建模式

NuttX 支持三种构建模式,内存隔离能力逐级递增:

模式 硬件 隔离程度 内核/用户特权 典型平台
FLAT 无要求 无隔离——单一地址空间 无分离 Cortex-M、无 MMU RISC-V
PROTECTED MPU 特权级分离(同一个地址空间) PL0/PL1 Cortex-M3/M4/M7 + MPU
KERNEL MMU 完整虚拟地址隔离(每进程独立地址空间) PL0/PL1 + TTBR0 切换 Cortex-A7/A9/A15

FLAT 模式下,内核、C 库、NSH shell、所有应用程序链接为单一 nuttx ELF。应用调用内核是一条 bl 指令——零 syscall 开销,但也零保护。任意空指针解引用直接崩溃整个系统。

PROTECTED 模式引入两层特权级,但所有代码仍位于同一物理地址空间。MPU 通过 8 个硬件 region 隔离内核和用户的内存块。应用通过 SVC 指令进入内核(类似 ARM Cortex-M 的 svc),有 syscall 开销但无需 MMU。

KERNEL 模式颠覆一切:内核运行在独立的特权地址空间中,每个用户进程拥有私有的虚拟地址空间,通过 syscall 与内核通信——很像 Linux,但更精简。

本文以 qemu-armv7a:knsh 配置(Cortex-A7,CONFIG_BUILD_KERNEL=y)为蓝本,逐层拆解 ARMv7-A 两级页表的完整翻译机制。

1.1 knsh 内存布局(核心常量)

以下是整个分析的基础——来自 boards/arm/qemu/qemu-armv7a/configs/knsh/defconfig 的实际数值:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
         Physical (PA == VA identity mapped)            Virtual (VA only)
───────────────────────────────────── ─────────────────
0x00000000 ┌──────────────────────────────┐
│ FLASH 1MB │ kernel .text + .rodata
│ MMU_MEMFLAGS, 128 sections │ (NUTTX_TEXT resident here
0x00100000 ├──────────────────────────────┤ when CONFIG_BOOT_RUNFROMFLASH)
│ (reserved / unused) │
0x08000000 ├──────────────────────────────┤
│ IO/Peripherals 96MB │ UART, GIC, timers,
│ MMU_IOFLAGS, XN │ all MMIO registers
0x0E000000 ├──────────────────────────────┤
│ Secure Memory 16MB │
0x0F000000 ├──────────────────────────────┤
│ │
│ PCIE ECAM/MMIO 768MB │
│ MMU_IOFLAGS, XN │
0x40000000 ├──────────────────────────────┤ ── kernel/user boundary (L1 index 0x800) ──
│ │ ┌──────────────────────────────┐
│ DDR (kernel heap, stacks, │ 0x80000000 │ User .text (1MB, 256 pages) │
│ TCBs, free pages...) │ │ MMU_L2_UTEXTFLAGS (PL0=RO) │
│ MMU_MEMFLAGS, 256 sections │ │ each process → different PA │
0x40300000 │ ┌────────────────────────┐ │ 0x80100000 ├──────────────────────────────┤
│ │ Page Pool ~13MB │ │ │ User .data/.bss (1MB) │
│ │ (L1 tables, L2 tables, │ │ │ MMU_L2_UDATAFLAGS (PL0=RW) │
│ │ user data/heap pages) │ │ │ [+ARCH_DATA_RESERVE 4KB] │
0x40FFC000 │ ├────────────────────────┤ │ 0x80200000 ├──────────────────────────────┤
│ │ Kernel L1 Page Table │ │ │ User heap (1MB, 256 pgs) │
│ │ 16KB (4096 entries) │ │ │ grows via sbrk → pgalloc │
│ │ PGTABLE_BASE_VADDR │ │ 0x80300000 ├──────────────────────────────┤
0x41000000 │ └────────────────────────┘ │ │ User stack (if dynamic) │
0x50000000 └──────────────────────────────┘ └──────────────────────────────┘

All kernel regions are IDENTITY mapped: PA == VA.
User regions at 0x80000000+ are per-process L2 page tables → different PA per process.

Detailed config parameters (knsh defconfig):
Parameter Value Meaning
────────────────────────────────────────────────────────────────────
CONFIG_RAM_START 0x40000000 Physical RAM base (16MB total)
CONFIG_RAM_VSTART 0x40000000 Virtual RAM base (identity mapped)
CONFIG_RAM_SIZE 0x01000000 16MB
CONFIG_FLASH_START 0x00000000 Physical FLASH base
CONFIG_FLASH_VSTART 0x00000000 Virtual FLASH base (identity)
CONFIG_FLASH_SIZE 0x00100000 1MB

CONFIG_ARCH_TEXT_VBASE 0x80000000 User .text virtual base
CONFIG_ARCH_TEXT_NPAGES 256 .text region: 256 pages x 4KB = 1MB
CONFIG_ARCH_DATA_VBASE 0x80100000 User .data/.bss virtual base
CONFIG_ARCH_DATA_NPAGES 256 .data region: 256 pages x 4KB = 1MB
CONFIG_ARCH_HEAP_VBASE 0x80200000 User heap virtual base
CONFIG_ARCH_HEAP_NPAGES 256 heap region: 256 pages x 4KB = 1MB

CONFIG_ARCH_PGPOOL_PBASE 0x40300000 Page pool physical base
CONFIG_ARCH_PGPOOL_VBASE 0x40300000 Page pool virtual base (identity)
CONFIG_ARCH_PGPOOL_SIZE 13631488 ~13MB page pool

PGTABLE_BASE_PADDR 0x40FFC000 L1 page table (16KB, end of RAM - 16KB)
PGTABLE_BASE_VADDR 0x40FFC000 (identity mapped)
PGTABLE_SIZE 0x00004000 16KB (4096 entries x 4 bytes)

CONFIG_MM_PGSIZE 4096 4KB pages
CONFIG_MM_PGSHIFT 12 log2(4096) = 12
CONFIG_ARCH_KERNEL_STACKSIZE 3072 Bytes 每进程内核栈

Derived:
ARCH_TEXT_NSECTS 1 ((256 + 255) >> 8) = 1 个 1MB section
ARCH_DATA_NSECTS 1 同上
ARCH_HEAP_NSECTS 1 同上
ARCH_DATA_RESERVE_SIZE 4096 内核在用户 .data 区域的保留空间

关键观察:NuttX 的 knsh 给每个用户进程分配 3 个 1MB section(text/data/heap),总共 3MB 虚拟地址空间。这远小于 Linux 的默认 3GB 用户空间,但对嵌入式 RTOS 场景完全够用。NuttX 官方文档 Documentation/implementation/memory_configurations.rst:796-805 明确阐述了这一设计权衡——“Reducing the supported virtual address from 3GiB to, say, 4MiB would also reduce the amount of memory that has to be allocated for each process.”


2. ARMv7-A MMU 两级页表结构

2.1 整体架构

ARMv7-A 使用 Short-descriptor 格式的两级页表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Virtual Address (32-bit) — L1 index selects 1MB section, L2 index selects 4KB page within section
┌────────────────────┬──────────────────────────────────────────┐
│ L1 Index[31:20] │ L2 Index[19:12] + Page Offset[11:0] │
│ 12 bits = 4096 │ 8 bits = 256 + 12 bits = 4096 │
│ entries │ entries + page bytes │
└────────────────────┴──────────────────────────────────────────┘

L1 Page Table (TTBR0 -> 16KB = 4096 entries x 4 bytes)
┌──────────┬──────────┬──────────┬─────┬───────────┐
│ Entry0 │ Entry1 │ Entry2 │ ... │ Entry4095 │
│ VA 0~1M │ VA 1~2M │ VA 2~3M │ │ VA ~4G │
└──────────┴──────────┴──────────┴─────┴───────────┘

Each L1 entry [1:0] determines type:
"10" = Section descriptor -> direct 1MB mapping
"01" = PTE descriptor -> pointer to L2 page table
"00" = Fault -> unmapped, access => translation fault

L1 Section Descriptor (1MB direct mapping):

1
2
3
4
5
6
      Bit 31              20 19     15 14 12 11 10   5  4   3 2   1 0
┌───────────────────────────┬──────┬──────┬────┬────┬───┬───┬───┬───┐
│ PhysBase[31:20] │ NG S │ TEX │ AP │Dom │C B│XN │1 0│ │
│ 12-bit physical base │ │ │ │ │ │ │ │ │
└───────────────────────────┴──────┴──────┴────┴────┴───┴───┴───┴───┘
PA = PhysBase << 20 | VA[19:0] ↑ Section type marker

L2 Small Page Descriptor (4KB mapping):

1
2
3
4
5
6
7
      Bit 31              12 11 10  9  8  7  6  5  4  3 2   1 0
┌───────────────────────────┬───┬───┬──┬──┬──┬──┬──┬───┬───┐
│ PhysBase[31:12] │NG │S │AP│T │T │T │AP│C B│1 0│
│ 20-bit physical base │ │ │2 │E │E │E │ │ │ │
│ │ │ │ │X │X │X │ │ │ │
└───────────────────────────┴───┴───┴──┴──┴──┴──┴──┴───┴───┘
PA = PhysBase << 12 | VA[11:0] ↑ Small page marker

2.2 实例一:内核访问 UART 寄存器(段映射,一步到位)

CPU 要读 0x0800_1234(PL011 UART 状态寄存器,位于 IO 区域)。knsh 的内核页表已将整个 IO 区域通过 identity 段映射建立好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Step 1 — 拆虚拟地址 VA = 0x08001234
[31:20] = 0x080 → L1 表索引 = 128
[19:0] = 0x01234 → 段内偏移

Step 2 — 查 L1 表
L1 表物理基址 PGTABLE_BASE_PADDR = 0x40FFC000
目标项地址 = 0x40FFC000 + 128 × 4 = 0x40FFC200
读出该项的值 = 0x08000000 | MMU_IOFLAGS
= 0x08000C12
│31 20│19 12│11 10 5 4 3 2 1 0│
│ 0x080 (PhysBase[31:20])│ SBZ │ AP │Dom │ XN│C B│1 0│
│ │ │ RW │ 0 │ 1 │0 1│ │ ↑ Section marker
→ 末尾 [1:0] = 0b10 → 段描述符,直接拼物理地址

Step 3 — 拼物理地址
PA = PhysBase[31:20] << 20 | VA[19:0]
= 0x080 << 20 | 0x01234
= 0x0800_0000 | 0x01234
= 0x0800_1234
→ 物理 == 虚拟(identity mapping),一步完成

三步走完。内核所有 IO/DDR/FLASH 都是这种 identity 段映射,物理==虚拟,TLB 命中后硬件自动完成翻译。MMU_IOFLAGS 中的 PMD_SECT_XN 确保这段 IO 空间不可执行。

2.3 实例二:用户进程取 .text 指令(L2 小页映射,两级查表)

进程 A 要取指 0x8000_4567(自己的 .text 段,假设该处指令为 mov r0, #0):

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
Step 1 — 拆虚拟地址 VA = 0x80004567
[31:20] = 0x800 → L1 表索引 = 2048
[19:0] = 0x04567 → 段内偏移(还要再拆)

Step 2 — 查 L1 表,发现是 PTE 条目
目标项地址 = l1table_base + 2048 × 4
读出值 = l1table[2048]
= 0x4030_0000 | MMU_L1_PGTABFLAGS
= 0x4030_1001
│31 10│9 5 4 3 2 1 0│
│ L2 表基址 PA[31:10] │ Domain 0 │PXN│ │0 1│
│ = 0x4030_0000 >> 10 │ │ 1 │ │ │ ↑ PTE marker
→ 末尾 [1:0] = 0b01 → PTE 描述符,指向一张 L2 页表在物理地址 0x40300000

Step 3 — 查 L2 页表
段内偏移 0x04567 再拆:
[19:12] = 0x045 → L2 表内索引 = 69
[11:0] = 0x567 → 页内偏移

L2 表虚拟地址 = arm_pgvaddr(0x40300000) = 0x40300000(identity)
目标项地址 = 0x40300000 + 69 × 4 = 0x40300114
读出值 = l2table[69]
= 0x4030_4000 | MMU_L2_UTEXTFLAGS
= 0x4030_45FE
│31 12│11 4│ 3 2 1 0│
│ PhysBase[31:12] │ AP TEX │C B│ 1 0│
│ = 0x40304 │R12 R0 │1 1│ │ ↑ Small page marker
│ │PL1=rw PL0=ro│ │ │

Step 4 — 拼物理地址
PA = PhysPage << 12 | 页内偏移[11:0]
= 0x40304 << 12 | 0x567
= 0x4030_4000 | 0x567
= 0x4030_4567
→ 虚拟 0x80004567 → 物理 0x40304567,四步完成,比段映射多一次查表

这两个实例展示了关键区别:

  • 段映射(查表 1 次):用于内核区域(0~2GB),1MB 粒度,identity 映射
  • 小页映射(查表 2 次):用于用户区域(0x80000000+),4KB 粒度,独立物理页

同一虚拟地址 0x80004567 在进程 B 中会映射到不同物理页(如 0x4031_4567),因为 B 的 L2 页表指向 B 独有的物理页。这就是虚拟地址隔离的本质。

2.4 NuttX 使用的 MMU 标志

来自 arch/arm/src/armv7-a/mmu.h:592-657

展开值 含义
MMU_MEMFLAGS (PMD_TYPE_SECT | PMD_SECT_AP_RW1 | PMD_CACHEABLE | PMD_SECT_DOM(0)) 内核 RAM 段映射:1MB section, PL1 读写, Write-Back Cache, Domain 0
MMU_IOFLAGS (PMD_TYPE_SECT | PMD_SECT_AP_RW1 | PMD_DEVICE | PMD_SECT_DOM(0) | PMD_SECT_XN) 内核 IO 段映射:同上 + Device 内存 + 禁止执行
MMU_L2_UTEXTFLAGS (PTE_TYPE_SMALL | PTE_WRITE_BACK | PTE_AP_RW12_R0) 用户 .text L2 小页:PL1 读写, PL0 只读, Write-Back Cache
MMU_L2_UDATAFLAGS (PTE_TYPE_SMALL | PTE_WRITE_BACK | PTE_AP_RW01) 用户 .data/heap L2 小页:PL1 读写, PL0 读写, Write-Back Cache
MMU_L1_PGTABFLAGS (PMD_TYPE_PTE | PMD_PTE_PXN | PTE_WRITE_THROUGH | PMD_PTE_DOM(0)) L1 指向 L2 页表的 PTE 条目:禁止 PL1 执行, Write-Through
MMU_L1_TEXTFLAGS (PMD_TYPE_PTE | PMD_PTE_DOM(0)) 可用于 L1 用户 .text 区域的 PTE(当前未使用此宏)

权限模型(legacy AP[2:0],非 AFE 模式):

  • PMD_SECT_AP_RW1 / PTE_AP_RW12 = AP[2:0]=001:PL1 读写, PL0 不可访问
  • PTE_AP_RW01 / PTE_AP_RW012 = AP[2:0]=011:PL1 读写, PL0 读写
  • PTE_AP_RW12_R0 / PTE_AP_RW012_R0 = AP[2:0]=010:PL1 读写, PL0 只读

设计要点:内核的所有映射使用 PMD_SECT_AP_RW1(仅 PL1 可访问),用户进程的 .text 使用 PTE_AP_RW12_R0(PL0 只读),.data/heap 使用 PTE_AP_RW01(PL0 可读写)。这是进程隔离的基础——用户代码执行在 PL0 (USR mode),不能访问内核的段映射区域。


3. 启动流程:从物理地址到虚拟地址

3.1 总体执行流

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
__start (arm_head.S:181)          [MMU OFF, executing at phys=0x00000000]
├→ disable MMU + caches (SCTLR) arm_head.S:229-232
├→ clear 16KB L1 page table arm_head.S:237-248
├→ map .text region (1MB sections) arm_head.S:285-321
│ ├→ if !IDENTITY_TEXTMAP: create temp identity mapping arm_head.S:296-298
│ └→ loop: write L1 entries for each 1MB section arm_head.S:316-321
├→ map RAM (if run-from-flash) arm_head.S:340-353
├→ invalidate TLB + caches arm_head.S:377-389
├→ write TTBR0, TTBR1, TTBCR arm_head.S:407-420
├→ write DACR (domain 0 client) arm_head.S:432-433
├→ configure SCTLR (enable MMU + caches) arm_head.S:437-551
└→ mov pc, lr -> .Lvstart arm_head.S:559 [NOW IN VIRTUAL ADDRESS SPACE]

.Lvstart (arm_head.S:660)
├→ remove temp identity mapping arm_head.S:668-672
├→ set up stack pointer arm_head.S:676-678
├→ arm_data_initialize arm_head.S:687
│ ├→ zero .bss
│ └→ copy .data from FLASH to RAM
├→ arm_boot() qemu_boot.c:77
│ └→ qemu_setupmappings() qemu_memorymap.c:80
│ └→ mmu_l1_map_regions(g_section_mapping[]) arm_mmu.c:176
│ └→ for each section: mmu_l1_setentry(paddr, vaddr, flags)
│ └→ l1table[vaddr>>20] = paddr | flags arm_mmu.c:55-73
└→ nx_start() arm_head.S:708 [NuttX OS entry]

3.2 启动阶段一:__start 的页表初始化

内核被 QEMU 加载到物理地址 0x00000000(FLASH 起始地址,identity 映射到 NUTTX_TEXT_VADDR=0x00000000)。入口 __start 在 MMU 关闭的情况下执行,所有地址都是物理地址。

第一步:清零 L1 页表arm_head.S:237-248

1
2
3
4
5
6
7
8
9
10
11
ldr     r5, .LCppgtable           ; r5 = PGTABLE_BASE_PADDR = 0x40FFC000
mov r0, r5 ; r0 = start of page table
mov r1, #0 ; r1 = zero
add r2, r0, #PGTABLE_SIZE ; r2 = end of page table (0x41000000)
.Lpgtableclear:
str r1, [r0], #4 ; write zero, advance by 4
str r1, [r0], #4
str r1, [r0], #4
str r1, [r0], #4
teq r0, r2
bne .Lpgtableclear ; loop until all 16384 bytes cleared

第二步:映射 .text 区域arm_head.S:285-321

目标:让内核自己的代码在 MMU 开启后能被 CPU 取指。做法是往 L1 页表里写入段描述符,把 .text 的物理地址映射到虚拟地址。

为了理解下面这段汇编,先知道三条关键信息:

  1. .LCtextinfo 是一个编译时定义的常量表,存着 .text 的物理基址、虚拟基址、MMU 标志、段数量
  2. ldmia 一次从内存加载 4 个值到 4 个寄存器
  3. r5 在第 1 步已经设为 L1 页表的物理基址 0x40FFC000
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
  adr     r0, .LCtextinfo             /* 取 .text 映射描述符表的地址             */
ldmia r0, {r1, r2, r3, r4} /* 一次加载四个值: */
/* r1 = NUTTX_TEXT_PADDR (物理起始地址) */
/* r2 = NUTTX_TEXT_VADDR (虚拟起始地址) */
/* r3 = MMU_MEMFLAGS (段描述符标志位) */
/* r4 = 段数量 (1MB 段) */

#ifndef CONFIG_IDENTITY_TEXTMAP
/* 创建 .text 第一个 1MB 段的身份映射。 *
* 没有这个映射,MMU 一开启下一条指令取指就会 fault, *
* 因为此时 PC 还在物理地址上跑。物理 ≠ 虚拟时此映射是必需的。 */

orr r0, r1, r3 /* r0 = 物理段基址 | MMU 标志 */
str r0, [r5, r1, lsr #18] /* l1table[phys>>20] = r0 */
/* [r5, r1, lsr #18] = l1table基址 + 偏移 */
/* 偏移 = 索引 × 4 */
/* 索引 = phys >> 20 */
/* 所以偏移 = (phys >> 20) × 4 = phys >> 18 */
#endif

/* 将整个 .text 区域填入 L1 页表。此时还没有开启 cache, *
* 可以确保数据在 cache 打开之前已经写入物理内存。 *
* *
* r5 = L1 表物理基址 *
* r2 = 虚拟基址 >> 18,即 l1table 中的字节偏移 */

add r2, r5, r2, lsr #18 /* r2 = l1table基址 + 虚拟基址索引的偏移 */
.Lpgtextloop:
orr r0, r1, r3 /* r0 = 物理段地址 | MMU 标志 */
subs r4, r4, #1 /* 剩余段数 - 1 */
str r0, [r2], #4 /* l1table[index] = r0, index++ */
add r1, r1, #(1024*1024) /* 物理地址前进 1MB */
bne .Lpgtextloop /* 段数 ≠ 0 则继续 */

lsr #18 为什么是右移 18 位?

L1 表是 uint32_t l1table[4096]。要写入的索引是 VA[31:20]。计算地址偏移:索引 × 4 = (VA >> 20) × 4 = VA >> 18

当前 CPU 跑在物理地址(MMU 还没开)。r1 存的是 .text 的物理起始地址。r1 >> 20 就是物理地址所在的 L1 索引,r1 >> 18 就是该索引在 L1 表中的字节偏移。

举个例子:假设 .text 物理在 0x40000000(DDR 起始),r1 = 0x40000000

1
2
3
l1 索引 = 0x40000000 >> 20 = 0x400
字节偏移 = 0x400 × 4 = 0x1000
r1 >> 18 = 0x40000000 >> 18 = 0x1000 ← 一样

所以 str r0, [r5, r1, lsr #18] 就是 l1table[0x400] = 0x40000000 | MMU_MEMFLAGS

#ifndef CONFIG_IDENTITY_TEXTMAP 块在做什么?

它创建一个”身份映射”——把物理地址 0x40000000 所在的 1MB 区域映射到它自己。结果:

  • VA 0x40000000 → PA 0x40000000(通过这条新写的 entry)
  • VA 0x40001000(启用 MMU 后下一条指令的 PC)→ PA 0x40001000

没有这个映射,MMU 一开启,CPU 的下一次取指就会因为虚拟地址在页表里找不到对应条目而触发 prefetch abort——系统在开 MMU 的瞬间就死了。身份映射充当了”过渡桥梁”:MMU 开启后的第一条指令在物理和虚拟两边都能命中。后续到达 .Lvstart 之后这个临时映射会被清除。

为什么 knsh 不需要这个?

因为 knsh 的 .text 物理地址就是 0x00000000,虚拟地址也是 0x00000000。第一步清零 L1 表后,Lpgtextloop 写入的段映射已经把 0x00000000 → 0x00000000 | MMU_MEMFLAGS 写进了 l1table[0]。物理==虚拟,所以这个映射天然就是身份映射——不需要额外写一份。

1
2
3
4
5
knsh 的情况:
物理 .text = 0x00000000,虚拟 .text = 0x00000000
→ CONFIG_IDENTITY_TEXTMAP 被定义
→ #ifndef 块被完整跳过
→ 直接进入 Lpgtextloop

用 knsh 实际值走一遍 Lpgtextloop:

1
2
3
4
5
6
7
初始状态:r1=0x00000000, r2=l1table[0]地址, r3=MMU_MEMFLAGS, r4=1

第 1 次循环:
r0 = 0x00000000 | MMU_MEMFLAGS → 写 l1table[0]
r4 = 0 → 减到零
r1 = 0x00000000 + 1MB = 0x100000
循环结束

结果:l1table[0] = 0x00000000 | MMU_MEMFLAGS,L1 表里只有 entry 0 指向 .text 的第一个 1MB。其余 4095 个 entry 全为零(第一步清零的结果)。

第三步:映射 RAM 区域arm_head.S:340-355

如果内核从 FLASH 启动(CONFIG_BOOT_RUNFROMFLASH=y),.text 在 FLASH 里(0x00000000),但内核的 .data、.bss、heap、stack 都在 DDR(0x40000000)。在 MMU 打开之前必须先把 DDR 的 L1 条目写好,否则一开 MMU,arm_data_initialize 要清零 .bss 时访问 0x4000_???? 就会 translation fault。

这段代码和映射 .text 完全一样的逻辑,只是源数据表叫 .LCraminfo

1
2
3
4
5
6
7
8
9
10
  adr     r0, .LCraminfo             /* 取 RAM 映射描述符表的地址             */
ldmia r0, {r1, r2, r3, r4} /* r1=NUTTX_RAM_PADDR, r2=NUTTX_RAM_VADDR */
/* r3=MMU_MEMFLAGS, r4=RAM 的 1MB 段数量 */
add r2, r5, r2, lsr #18 /* r2 = l1table[vaddr_index] 的地址 */
.Lpgramloop:
orr r0, r1, r3 /* r0 = 物理段地址 | MMU_MEMFLAGS */
subs r4, r4, #1 /* 剩余段数 - 1 */
str r0, [r2], #4 /* 写 L1 条目 */
add r1, r1, #(1024*1024) /* 物理地址前进 1MB */
bne .Lpgramloop /* 段数 ≠ 0 则继续 */

knsh 里 NUTTX_RAM_SIZE = 16MB,所以 r4 = 16。循环写 16 个段条目,覆盖 0x400000000x41000000。此时 L1 表里 .text 和 RAM 的条目都有了。

第四步:无效化 TLB 和 Cachearm_head.S:377-389

开 MMU 之前必须清空 TLB 和 Cache——你不知道 bootloader 在里面留了什么:

1
2
3
4
5
6
7
8
mov     r0, #0
mcr CP15_TPIDRPRW(r0) /* 初始化 percpu 寄存器 (线程指针) */
mcr CP15_TLBIALL(r0, c7) /* 无效化整个统一 TLB */
mcr CP15_TLBIALL(r0, c6)
mcr CP15_TLBIALL(r0, c5)
mcr CP15_BPIALL(r0) /* 无效化分支预测缓存 */
mcr CP15_ICIALLU(r0) /* 无效化指令 Cache */
isb

数据和统一 TLB 全部清空。I-Cache 刷新确保 MMU 开启后的第一条指令不会被旧的缓存行干扰。

第五步:告诉 MMU 页表在哪——写 TTBR0/TTBR1/TTBCRarm_head.S:407-420

MMU 需要知道页表的物理地址才能做表遍历:

1
2
3
4
5
6
7
8
9
orr     r1, r5, #(TTBR0_RGN_WBWA | TTBR0_IRGN0)
/* r5 = L1 表物理地址, 加上 cache 属性: */
/* Outer/Inner Write-Back, Write-Allocate */
mcr CP15_TTBR0(r1) /* TTBR0 = L1 表物理地址 */
mcr CP15_TTBR1(r1) /* TTBR1 = 同上 (实际只用 TTBR0) */
mcr CP15_TTBCR(r0) /* TTBCR = 0: */
/* N=0 → 用 TTBR0, 16KB 页表 */
/* PD0=0 → 使能 TTBR0 的表遍历 */
/* PD1=0 → 使能 TTBR1 (备用) */

TTBR0 存的是物理地址0x40FFC000),不是虚拟地址。MMU 做页表遍历时直接用这个物理地址访问内存,不经过翻译。

第六步:开 MMU——DACR + SCTLR + 跳转arm_head.S:432-559

Domain 0 设为 client 模式(让页表条目的 AP 位生效),然后写 SCTLR 的 M/C/I 位使能 MMU 和 Cache:

1
2
3
4
5
6
7
8
9
10
11
12
mov     r0, #DACR_CLIENT(0)        /* DACR = 0x01: domain 0 受页表权限控制    */
mcr CP15_DACR(r0)

mrc CP15_SCTLR(r0) /* 读当前 SCTLR */
bic r0, r0, #(SCTLR_A | SCTLR_C | ...) /* 清除复位默认位 */
orr r0, r0, #(SCTLR_M | SCTLR_C | SCTLR_I) /* M=MMU, C=D-Cache, I=I-Cache */
mcr CP15_SCTLR(r0) /* 写 SCTLR——MMU 从这里开始生效! */
isb
nop; nop; ... (12 NOPs) /* 等流水线刷新 */

ldr lr, .LCvstart /* lr = .Lvstart 的虚拟地址 */
mov pc, lr /* 跳转——从此 CPU 在虚拟地址空间执行 */

为何需要 12 个 NOP? 架构参考手册指出写入 SCTLR 后需要同步流水线。部分核心(Cortex-A8)需要这段 NOP 确保新映射已被识别。

3.3 启动阶段二:补全内核页表

.Lvstart 处(arm_head.S:660),栈已设置、.bss 已清零,然后调用 arm_boot()qemu_boot.c:77),后者调用 qemu_setupmappings() 补全剩余的内核 L1 条目。

qemu 的静态内存映射表(qemu_memorymap.c:44-66):

1
2
3
4
5
6
7
8
static const struct section_mapping_s g_section_mapping[] =
{
{ VIRT_FLASH_PSECTION, VIRT_FLASH_VSECTION, MMU_MEMFLAGS, _NSECTIONS(128MB) }, // 0x00000000, 128 sections
{ VIRT_IO_PSECTION, VIRT_IO_VSECTION, MMU_IOFLAGS, _NSECTIONS(96MB) }, // 0x08000000, 96 sections
{ VIRT_SEC_MEM_PSECTION, VIRT_SEC_MEM_VSECTION, MMU_MEMFLAGS, _NSECTIONS(16MB) }, // 0x0E000000, 16 sections
{ VIRT_PCIE_PSECTION, VIRT_PCIE_VSECTION, MMU_IOFLAGS, _NSECTIONS(768MB) }, // 0x10000000, 768 sections
{ VIRT_DDR_PSECTION, VIRT_DDR_VSECTION, MMU_MEMFLAGS, _NSECTIONS(256MB) }, // 0x40000000, 256 sections
};

每个映射由 mmu_l1_setentry()arm_mmu.c:55-73)依次写入 L1 条目:

1
2
3
4
5
6
7
8
void mmu_l1_setentry(uintptr_t paddr, uintptr_t vaddr, uint32_t mmuflags)
{
uintptr_t *l1table = mmu_l1_getpgtable(); // get current L1 table base
uint32_t index = vaddr >> 20; // L1 index from VA[31:20]
l1table[index] = (paddr | mmuflags);
cp15_clean_dcache_bymva(&l1table[index]); // flush D-cache line
mmu_invalidate_region(vaddr, SECTION_SIZE); // invalidate TLB for this 1MB
}

重要:qemu 上所有内核区域都是 identity 映射(VIRT_FLASH_VSECTION == VIRT_FLASH_PSECTION 等,见 qemu_memorymap.h:51-55)。这意味着物理地址 == 虚拟地址,大大简化了内核地址管理。

启动完成后,内核 L1 页表的用户区域(0x80000000 以上)全是 fault entry(值为 0)。这些条目会在进程创建时由 up_addrenv_create() 填充。

最终内核 L1 页表布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
L1 Table (PGTABLE_BASE_VADDR=0x40FFC000):
Index VA Range PA Range Type Flags
──────── ────────────── ────────────── ────── ──────────
[0x000] 0x00000000 0x00000000 (ID) Sect MMU_MEMFLAGS } 128
... ~0x08000000 ~0x08000000 } sections
[0x07F] } (FLASH)
[0x080] 0x08000000 0x08000000 (ID) Sect MMU_IOFLAGS } 96
... ~0x0E000000 ~0x0E00000 } sections
[0x0DF] } (IO)
[0x0E0] 0x0E000000 0x0E000000 (ID) Sect MMU_MEMFLAGS } 16 sections (SEC_MEM)
[0x0EF]
[0x100] 0x10000000 0x10000000 (ID) Sect MMU_IOFLAGS } 768 sections (PCIE)
[0x3FF]
[0x400] 0x40000000 0x40000000 (ID) Sect MMU_MEMFLAGS } 256
... ~0x50000000 ~0x50000000 } sections (DDR)
[0x4FF]
[0x500] 0x50000000 - Fault (zero)
... ~0x7FFFFFFF
[0x7FF] <- 内核区域结束
[0x800] 0x80000000 - Fault (zero) <- 用户区域开始
... ~0xFFFFFFFF
[0xFFF]

4. 每进程地址环境:完整 L1 复制策略

这是 NuttX 与 Linux 之间最大的设计差异。NuttX 为每个用户进程拷贝一份完整的 L1 页表(16KB),而 Linux 只复制内核部分的 L1 条目(通常约 256 个,对应 1GB 内核空间)。

4.1 设计决策:为何复制整张 L1 表?

NuttX 官方文档(Documentation/implementation/memory_configurations.rst:787-805)记录了最初的提案方案:

原始提案(未采用):共享单张 L1 页表,每次上下文切换只替换 3~4 个 L1 条目(text/data/heap/stack)。这样可以节省内存但每次切换要执行多次 MMU 写入+缓存刷新。

实际实现(当前代码):为每个进程分配并复制完整的 16KB L1 表。arch_addrenv_s 结构(arch/arm/include/arch.h:134-157)中的注释直接说明了原因:

1
2
3
4
5
6
struct arch_addrenv_s
{
/* Alloc whole l1table to make better context switch performance */
uintptr_t *l1table;
...
};

权衡分析

策略 内存开销/进程 上下文切换延迟 实现复杂度
共享 L1 + 替换条目 ~16KB(共享)+ 几乎 0 多次 MMU 写入 + 多次 TLB 刷新 高(需追踪哪些条目被修改)
完整 L1 复制(实际采用) 16KB × 每进程 单次 TTBR0 写 + 单次 TLB 刷新 低(memcpy + 写 TTBR0)
Linux 方式(部分复制) ~4KB(仅内核 256 条目 + 用户 PGD) 写 TTBR0 + ASID(无需刷 TLB) 高(需 ASID 支持)

NuttX 选择了最简单的路径——在嵌入式场景中,多占用几十 KB 内存换取上下文切换的极致简单是合理的。Linux 用 ASID 避免刷 TLB 是为了服务器场景(数千进程),NuttX 不需要。

4.2 up_addrenv_create() 完整流程

arch/arm/src/armv7-a/arm_addrenv.c:165-254

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
up_addrenv_create(textsize, datasize, heapsize, &addrenv)

├→ memset(addrenv, 0, sizeof(arch_addrenv_t)) // 清零结构体

├→ addrenv->l1table = mm_pgalloc_align(4, 4) // 从物理页池分配 16KB
│ // mm_pgalloc_align 分配 4 个页(16KB = 4 × 4KB),并按 4 页对齐

├→ memcpy(addrenv->l1table, PGTABLE_BASE_VADDR, 16KB) // 完整复制内核 L1 表!
│ // 进程由此继承所有内核映射(PA 0~0x7FFFFFFF)
│ // 关键:内核的 IO、DDR、FLASH 映射全部继承

├→ arm_addrenv_create_region(l1table, 1, 0x80000000, textsize, MMU_L2_UTEXTFLAGS)
│ // 为用户 .text 区域 (0x80000000) 建立 L2 页表映射

├→ arm_addrenv_create_region(l1table, 1, 0x80100000, datasize+4096, MMU_L2_UDATAFLAGS)
│ // 为用户 .data/.bss 区域 (0x80100000) 建立 L2 页表映射
│ // datasize + ARCH_DATA_RESERVE_SIZE(4096)

├→ arm_addrenv_create_region(l1table, 1, 0x80200000, heapsize, MMU_L2_UDATAFLAGS)
│ // 为用户 heap 区域 (0x80200000) 建立 L2 页表映射

└→ addrenv->heapsize = (npages << 12) // 保存初始堆大小

复制内核 L1 表的意义:内核映射在整个 L1 表中占据 entry 02047(0x0000x7FF),覆盖物理地址 0~2GB。通过 memcpy 复制,每个进程自动继承对所有硬件 IO 和内内存的访问权。当进程执行 syscall 进入内核时,TTBR0 无需切换——内核映射已经在那儿了。

4.3 L2 页表构建器

arm_addrenv_create_region()arm_addrenv_utils.c:58-149)是构建 L2 页表的主函数:

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
arm_addrenv_create_region(l1table, nsects, vaddr, size, mmuflags)

├→ npages = MM_NPAGES(size) // 需要的 4KB 页数
├→ nlist = ceil(npages / 256) // 需要的 L2 页表数
│ // 对 knsh:每个 region 最大 256 页 = 1MB = 恰好 1 个 L2 表

└→ for each L2 table (i = 0; i < nlist; i++):

├→ paddr = mm_pgalloc(1) // 分配 4KB 物理页作为 L2 表

├→ mmu_l1table_setentry(l1table, paddr, vaddr, MMU_L1_PGTABFLAGS)
│ // 写 L1 条目:l1table[vaddr>>20] = paddr | PMD_TYPE_PTE | PMD_PTE_PXN | ...
│ // PXN 位确保 L2 表本身不会被当做代码执行

├→ l2table = arm_pgvaddr(paddr) // 获取 L2 表的虚拟地址
│ // arm_pgvaddr: paddr - 0x40300000 + 0x40300000 = paddr (identity)

├→ memset(l2table, 0, 256*sizeof(uintptr_t)) // 清零 L2 表

└→ for each 4KB page in this L2 (j = 0; j < 256 && nmapped < size; j++):

├→ paddr = mm_pgalloc(1) // 分配一页物理内存
├→ set_l2_entry(l2table, paddr, vaddr, mmuflags)
│ // l2table[(vaddr>>12)&0xFF] = paddr | mmuflags
│ // 例如:l2table[0] = 0x40305000 | MMU_L2_UTEXTFLAGS
├→ nmapped += 4096
└→ vaddr += 4096

以 knsh 配置创建新进程为例(假设 .text 需要 64KB):

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
创建一个进程的 .text 映射:
textsize = 64KB → npages = 16 → 需要 1 个 L2 表 (16页 <= 256)
.data = 32KB → npages = 8 → 需要 1 个 L2 表
heap = 128KB → npages = 32 → 需要 1 个 L2 表

物理页分配总计:
- L1 表:1 × 16KB = 4 页
- L2 表:3 × 4KB = 3 页
- 数据页:16+8+32 = 56 页
─────────────────────────
总计:63 页 = 252KB

最终每进程地址空间:
┌──────────────┬────────────────┬──────────────┬─────────────────────────┐
│ Virtual Addr │ Physical Addr │ Mapping │ Permission │
├──────────────┼────────────────┼──────────────┼─────────────────────────┤
│ 0x00000000 │ 0x00000000(ID) │ L1 Sect 128× │ Kern rwx (from copy) │
│ ... │ ... │ │ │
│ 0x40000000 │ 0x40000000(ID) │ L1 Sect 256× │ Kern rwx (from copy) │
│ 0x7FFFFFFF │ 0x7FFFFFFF │ │ │
├──────────────┼────────────────┼──────────────┼─────────────────────────┤
│ 0x80000000 │ 0x40304000 │ L2 L1→0x4030 │ PL1=rw PL0=ro (text) │
│ 0x80010000 │ 0x40305000 │ L2 L1→0x4030 │ PL1=rw PL0=rw (text) │
│ ... (16 pgs) │ ... │ │ │
│ 0x8003FFFF │ ... │ │ │
├──────────────┼────────────────┼──────────────┼─────────────────────────┤
│ 0x80100000 │ 0x40310000 │ L2 L1→0x4031 │ PL1=rw PL0=rw (data) │
│ 0x80101000 │ ← ARCH_DATA_RESERVE_SIZE = 4096 → kernel reserved area │
│ 0x80102000 │ 0x40311000 │ L2 L1→0x4031 │ PL1=rw PL0=rw (data) │
│ ... │ ... │ │ │
├──────────────┼────────────────┼──────────────┼─────────────────────────┤
│ 0x80200000 │ 0x40320000 │ L2 L1→0x4032 │ PL1=rw PL0=rw (heap) │
│ ... │ ... │ │ │
└──────────────┴────────────────┴──────────────┴─────────────────────────┘

进程 A 和进程 B 都看到 0x80000000 是它们的 .text 起始地址,但各自的 L2 页表指向完全不同的物理页。 内核映射区域对两个进程完全一致(拷贝自同一源),而用户区域通过各自的 L2 表实现了物理隔离。

4.4 页表切换:上下文切换的核心

addrenv_switch()sched/addrenv/addrenv.c:125)在每次任务上下文切换时被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
addrenv_switch(tcb)

├→ next = tcb->addrenv_curr // 目标进程的地址环境
├→ if (curr != next):
│ ├→ up_addrenv_coherent(&curr->addrenv) // 刷出旧环境的 D-Cache
│ │ // arm_addrenv.c:331-337
│ │ up_invalidate_icache_all();
│ │ up_flush_dcache_all();
│ │
│ └→ up_addrenv_select(&next->addrenv) // 切换到新环境
│ // arm_addrenv.c:453-460
│ └→ mmu_l1_setpgtable(addrenv->l1table)
│ // mmu.h:1408-1412
│ cp15_wrttb((uint32_t)ttb | TTBR0_RGN_WBWA | TTBR0_IRGN0);
│ cp15_invalidate_tlbs(); // 刷整个 TLB

这就是 NuttX 进程切换的全部 MMU 操作——两条 CP15 指令

  1. MCR p15, 0, Rd, c2, c0, 0(写 TTBR0)
  2. MCR p15, 0, R0, c8, c7, 0(invalidate unified TLB)+ barriers

对比 Linux 的上下文切换:Linux 使用 ASID(Address Space ID)标记 TLB 条目,切换进程时只写 TTBR0 但不刷 TLB。TTBR0 的高位携带新的 ASID,硬件在 TLB 命中时会检查 ASID 匹配。NuttX 选择不用 ASID,直接全刷 TLB——简单直接,在嵌入式场景(最多几十个进程)下 TLB 重填开销可忽略。


5. ELF 加载:往另一个进程的空间写数据

将 ELF 加载到一个新进程需要内核在进程被调度之前将数据写入其虚拟内存。整个过程涉及临时地址空间切换:

1
2
3
4
5
6
7
8
9
binfmt_execmodule.c:223-226

├→ addrenv_select(binp->addrenv, &binp->oldenv) // 临时切换到目标进程页表
│ └→ addrenv_switch(tcb) // 写 TTBR0(目标L1表) + 刷TLB

├→ umm_initialize(vheap, heap_base, heap_size) // 初始化用户堆管理器

└→ addrenv_attach(tcb, binp->addrenv) // 绑定 addrenv 到 TCB
└→ tcb->addrenv_own = addrenv // 进程退出时自动释放

ELF 加载器在加载过程中使用的流程(libs/libc/elf/elf_addrenv.c):

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
libelf_addrenv_alloc(loadinfo, textsize, datasize)    // elf_addrenv.c:83

├→ addrenv_allocate() // 分配 addrenv_s 包装结构
├→ up_addrenv_create(textsize, datasize, heapsize, &addrenv->addrenv)
│ // 创建完整地址环境(L1复制 + L2页表 + 物理页分配)
├→ up_addrenv_vtext(&addrenv->addrenv, &vtext) // vtext = 0x80000000
├→ up_addrenv_vdata(&addrenv->addrenv, textsize, &vdata) // vdata = 0x80101000
│ // 注意 vdata 跳过了 ARCH_DATA_RESERVE_SIZE (4096)
├→ loadinfo->textalloc = 0x80000000
└→ loadinfo->datastart = 0x80101000

libelf_addrenv_select(loadinfo) // elf_addrenv.c:159

├→ addrenv_select(loadinfo->addrenv, &loadinfo->oldenv)
│ // 保存当前内核环境,切换到目标进程页表
└→ up_addrenv_mprot(&addrenv->addrenv, textalloc, textsize, PROT_READ|PROT_WRITE|PROT_EXEC)
// 将 .text 区域临时设为可写(加载 ELF 数据时需要)
// arm_addrenv_text_enable_write 遍历 L2 表修改 AP 位

// 此时内核可以直接 memcpy(0x80000000, elf_data, size)
// 写入操作经过目标进程页表 -> 落到目标的物理页

libelf_addrenv_restore(loadinfo) // elf_addrenv.c:200

├→ up_addrenv_mprot(&addrenv->addrenv, textalloc, textsize, PROT_READ|PROT_EXEC)
│ // 将 .text 恢复为只读+可执行
└→ addrenv_restore(loadinfo->oldenv)
// 切回内核页表

为什么加载期间要把 .text 设成可写? 因为 ELF 的 .text 段通过 memcpy 写入目标进程的虚拟地址。MMU 的 AP 位决定了 PL0 访问权限,内核执行在 PL1 本来不受影响——但通过 up_addrenv_mprot() 修改的是页表条目的 AP 字段,确保两个特权级都能写入。加载完成后恢复 PTE_AP_RW12_R0(PL1 可读写,PL0 只读)。


6. 物理页池

NuttX 的所有物理内存分配(L1 表、L2 表、用户数据/堆页面)都来自一个由 mm_pgalloc() 管理的连续物理页池。

6.1 页池配置

1
2
3
CONFIG_ARCH_PGPOOL_PBASE = 0x40300000
CONFIG_ARCH_PGPOOL_VBASE = 0x40300000 // identity 映射
CONFIG_ARCH_PGPOOL_SIZE = 13631488 // ~13MB = 3328 个 4KB 页面

这个页池位于 DDR 地址空间的上半部分(DDR 从 0x400000000x50000000,共 256MB,页池占用其中 0x40300000 开始的 ~13MB)。

6.2 虚实地址转换

由于页池是 identity 映射的,arm_pgvaddr() 宏极其简单(pgalloc.h:68-74):

1
2
3
4
5
6
7
static inline uintptr_t arm_pgvaddr(uintptr_t paddr)
{
DEBUGASSERT(paddr >= CONFIG_ARCH_PGPOOL_PBASE &&
paddr < CONFIG_ARCH_PGPOOL_PEND);
return paddr - CONFIG_ARCH_PGPOOL_PBASE + CONFIG_ARCH_PGPOOL_VBASE;
// = paddr - 0x40300000 + 0x40300000 = paddr
}

这意味着 mm_pgalloc() 返回的物理地址可以直接当虚拟地址用。这对 ARMv7-A 来说正合适——ARM MMU 支持硬件页表遍历(hardware page table walk),但软件在访问页表内容时仍然需要虚拟地址。

6.3 堆的按需增长

用户进程调用 malloc() 耗尽预分配堆空间时,sbrk() 系统调用最终触发 pgalloc()arm_pgalloc.c:176-254):

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
pgalloc(brkaddr=0x80201000, npages=3)          // 进程请求增长 3 页

└→ for i=0; i<3; i++:

├→ paddr = get_pgtable(addrenv, brkaddr) // arm_pgalloc.c:96
│ │
│ ├→ l1entry = mmu_l1_getentry(vaddr) // 读当前 L1 条目
│ ├→ if l1entry == 0: // heap 的 L2 表还不存在
│ │ ├→ paddr = alloc_pgtable() // 分配新 L2 表 (4KB)
│ │ │ ├→ mm_pgalloc(1) // 物理分配
│ │ │ ├→ memset(l2table, 0, 4096) // 清零
│ │ │ └→ up_flush_dcache(...) // 确保写入物理内存
│ │ │
│ │ ├→ l1entry = paddr | MMU_L1_PGTABFLAGS // 构建 L1 PTE 条目
│ │ └→ up_addrenv_select(addrenv) // 实例化新 L2 表
│ │ // 为什么这里又做一次 addrenv_select?
│ │ // pgalloc 可能在内核上下文中(syscall 处理期间),
│ │ // 需要确保目标进程的页表是当前活跃的
│ │
│ └→ return l1entry & PMD_PTE_PADDR_MASK // 返回 L2 表物理地址

├→ l2table = arm_pgvaddr(paddr) // 获取 L2 表的虚拟地址
├→ index = (brkaddr & 0x000FF000) >> 12 // L2 表内索引
├→ l2table[index] = mm_pgalloc(1) | MMU_L2_UDATAFLAGS // 分配物理页 + 写 PTE
├→ up_flush_dcache(...) // 确保写入物理内存
└→ brkaddr += 4096

7. 系统调用与上下文切换全链路

KERNEL 模式下,用户进程的所有内核交互都通过 SVC #0 指令触发 syscall。以下是完整的从用户 write() 到返回用户态的端到端流程:

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
44
45
[User mode, PL0, 进程 A 的页表活跃]
app_main()
└→ printf("hello\n")
└→ write(fd, buf, len) // libc 中的 syscall proxy
└→ SVC #0 // 触发 SVC 异常
───── enters SVC mode ─────

[Kernel mode, PL1, 同一进程 A 的 L1 表仍然活跃]
arm_vectorsvc (arm_vectors.S:294)
├→ srsdb sp!, #PSR_MODE_SYS // 保存 LR_svc 和 SPSR_svc 到 SYS 模式栈
├→ cpsid if, #PSR_MODE_SYS // 切换到 SYS 模式
├→ stmdb sp!, {r0-r12} // 保存通用寄存器
├→ save r13(sp_usr), r14(lr_usr) // 保存用户栈指针和返回地址
└→ bl arm_syscall // 调用 C 语言 syscall 分发器

arm_syscall(regs) (arm_syscall.c:162)
├→ switch (regs[REG_R0]):
│ case SYS_write:
│ dispatch_syscall() (arm_syscall.c:124)
│ ├→ g_stublookup[R0-CONFIG_SYS_RESERVED]
│ │ // 查找 SYS_write 对应的内核 stub 函数指针
│ ├→ blx ip // 调用真正的内核 write() 实现
│ │ └→ uart_write() → pl011_send()
│ └→ SVC #SYS_syscall_return // 重新进入 svc 以返回用户态

│ case SYS_syscall_return: (arm_syscall.c:218)
│ // 恢复用户 CPSR、PC、R0,处理 pending signal
│ // 如果此 syscall 触发了任务切换(如 sleep),
│ // 则返回的是新任务的寄存器上下文

│ case SYS_switch_context: (arm_syscall.c:269)
│ └→ nxsched_switch_context() // 触发调度器选择下一个任务

│ case SYS_restore_context: (arm_syscall.c:275)
│ ├→ addrenv_switch(tcb) // <— 地址空间切换发生在这里!
│ │ └→ up_addrenv_select(&next->addrenv)
│ │ └→ mmu_l1_setpgtable(l1table) // 写 TTBR0 + 刷 TLB
│ ├→ *running_task = tcb
│ └→ regs = tcb->xcp.regs // 获取新任务的寄存器快照

└→ return regs; // 返回寄存器上下文给汇编层

[汇编层]
rfeia r14 // 原子恢复 PC (r14) 和 CPSR (SPSR_svc)
───── returns to user mode ─────

关键洞察:整个 syscall 处理过程中从未切换 TTBR0——因为进程 A 的 L1 表中已经有完整的内核映射。内核代码不需要切换到”内核页表”,因为根本就不存在单独的”内核页表”——每个进程的 L1 表就是内核页表和用户页表的混合体。这消除了 syscall 路径上的页表切换开销。

7.1 进程切换时的地址空间切换时机

当进程 A 执行 sleep(1),syscall 处理过程中:

  1. SYS_nanosleep stub 调用内核的 sleep 实现
  2. 内核将进程 A 移出就绪队列,选择进程 B
  3. SYS_switch_contextnxsched_switch_context() 设置 TCB 切换标记
  4. 返回到 arm_syscall 后,SYS_syscall_return 注意到需要切换上下文
  5. SYS_restore_context 处理 → addrenv_switch(tcb_B)写 TTBR0 为进程 B 的 L1 表刷 TLB
  6. 返回进程 B 的寄存器上下文,rfeia 回到进程 B 的用户态

8. FLAT 与 KERNEL 模式完整对比

8.1 架构差异

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
FLAT Mode:
┌──────────────────────────────────────┐
│ Single flat address space │
│ ┌────────┬────────┬────────┬──────┐ │
│ │ Kernel │ libc │ NSH │ App │ │
│ │ code │ │ │ code │ │
│ └────────┴────────┴────────┴──────┘ │
│ All linked into one "nuttx" binary │
│ No MMU page tables (identity only) │
└──────────────────────────────────────┘
App calls kernel: bl <func> (direct branch, 1 cycle)
App crashes: Data Abort → PANIC (everything dies)

KERNEL Mode:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Process A │ │ Process B │ │ Process C │
│ VA space │ │ VA space │ │ VA space │
│ │ │ │ │ │
│ Kernel map ──┼──┼──────────────┼──┼── (shared) │
│ (0x00000000) │ │ (same copy) │ │ (same copy) │
│ │ │ │ │ │
│ User .text │ │ User .text │ │ User .text │
│ (0x80000000) │ │ (0x80000000) │ │ (0x80000000) │
│ → phys A │ │ → phys B │ │ → phys C │
│ User .data │ │ User .data │ │ User .data │
│ (0x80100000) │ │ (0x80100000) │ │ (0x80100000) │
│ → phys A' │ │ → phys B' │ │ → phys C' │
└──────────────┘ └──────────────┘ └──────────────┘
App calls kernel: SVC #0 → ... (syscall, ~50-100 cycles)
App crashes: Data Abort → SIGSEGV (only this app dies)

8.2 代码执行路径对比

printf("hello\n") 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FLAT:
printf() // libc function, same address space
└→ lib_vsprintf()
└→ up_putc() // direct register write to UART
// No privilege change, no MMU interaction

KERNEL:
printf() // user-space libc proxy
└→ write(fd, buf, len) // user-space syscall proxy
└→ SVC #0 // trap to kernel
└→ arm_vectorsvc // save context (20+ registers)
└→ arm_syscall
└→ dispatch_syscall
└→ sys_write() stub
└→ uart_write()
└→ pl011_send() // UART register write
└→ rfeia // return to user

8.3 故障隔离对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FLAT crash:
Task: *((int*)0) = 42; // null pointer write
CPU: Data Abort at PC=0x80001234
NuttX: PANIC! System halted.
Result: Everything dead.

KERNEL crash:
Task: *((int*)0) = 42; // null pointer write in PL0
CPU: Data Abort at PC=0x80001234, DFSR=0x805 (translation fault, PL0)
NuttX: arm_dataabort()
├→ Read DFAR (fault address), DFSR (fault status)
├→ Determine: translation fault at address 0, PL0 access
└→ Deliver SIGSEGV to task
└→ Task exits, resources freed, page table destroyed
NSH continues running.

Task's L1 table freed: l1table returned to page pool
Task's L2 tables freed: each L2 page + data pages freed
Task's kernel stack freed
Remaining processes untouched.


9. 关键设计要点

  1. L1 管内核,L2 管用户。 内核物理区域用 PMD_TYPE_SECT(1MB 段描述符)直接映射,用户空间用 PMD_TYPE_PTE → L2 小页(4KB)映射。内核用段映射追求简单和性能,用户用小页追求灵活分配和权限隔离。

  2. 整表复制策略是 NuttX 的最大特色。 每个进程获得一张完整 L1 表的 16KB 拷贝——不是共享 L1 + 交换条目,也不是 Linux 的部分复制。arch_addrenv_s:136 的注释直接说明了原因:”Alloc whole l1table to make better context switch performance”。这在嵌入式场景(进程数少,内存有限但够用)是合理的——用 16KB/进程换取上下文切换只需写一次 TTBR0。

  3. 进程切换 = 一次 TTBR0 写入 + 一次 TLB 刷新。 mmu_l1_setpgtable() 就是两条 CP15 指令。没有 ASID 管理、没有复杂的 TLB shootdown 协议。NuttX 官方文档 Documentation/implementation/memory_configurations.rst:725-737 提到 ARMv7-A 的硬件页表遍历(HW page table walk)是”huge performance advantage”——TLB miss 由硬件自动填充,无需软件 page fault handler 介入。

  4. 内核映射存在于每个进程的页表中。 通过 memcpy() 从内核 L1 表复制,每个进程的 L1 表 entries 02047(覆盖 PA 02GB)完全相同。这意味着 syscall 处理过程中无需切换 TTBR0——内核代码就在”那里”,只是从 PL1 执行。

  5. ELF 加载需要临时地址空间切换。 内核通过 addrenv_select() 临时切换到目标进程的页表,将 ELF 段通过 memcpy 写入目标虚拟地址。加载完成后,.text 的 AP 位从 PROT_WRITE 恢复为只读。

  6. 双栈机制是 exec() 正确性的保障。 当进程 A 执行 exec() 启动进程 B 时,A 的地址环境(包括用户栈)需要被替换。如果内核逻辑跑在 A 的用户栈上,替换地址空间时栈就没了。解决方案:CONFIG_ARCH_KERNEL_STACK 为每个进程分配一个 3072 字节的内核栈,syscall 处理期间切换到内核栈。

  7. 页池 identity 映射简化了虚实转换。 页池在 0x40300000 处 identity 映射,arm_pgvaddr(paddr) = paddr。这对页表遍历至关重要——MMU 的硬件页表遍历使用物理地址,但软件在修改页表内容时使用虚拟地址。


10. 与 Linux 的对比

维度 NuttX Linux
L1 页表管理 每进程完整 16KB 拷贝 每进程独立 PGD,内核部分共享(swapper_pg_dir 作为模板)
上下文切换 写 TTBR0 + 刷全部 TLB 写 TTBR0(含 ASID),不刷 TLB
ASID 不使用 使用 8 位 ASID,支持 256 个活跃地址空间
用户空间大小 ~3MB(可配置) 3GB(arm),128TB(arm64)
页表占用 16KB L1 + ~1-4KB L2/进程 ~16KB PGD + ~1-4KB PUD/PMD/PTE/进程
Demand Paging 不支持(CONFIG_PAGING 仅 LPC31xx) 全面支持 page fault + swap
Copy-on-Write 不支持 fork() fork() 的核心机制
Shared Libraries 静态链接(计划支持) ELF .so 动态链接
内核/用户地址空间 不重叠(内核 0~2GB, 用户 0x80000000+) 3G/1G 或 2G/2G 分割

参考资料

内容 位置
构建模式与内存配置官方文档 Documentation/implementation/memory_configurations.rst
地址环境 API 官方文档 Documentation/reference/os/addrenv.rst
ARM 页表提案与设计权衡 Documentation/implementation/memory_configurations.rst:695-812
启动页表初始化(汇编) arch/arm/src/armv7-a/arm_head.S:181-824
MMU 标志与描述符位域定义 arch/arm/src/armv7-a/mmu.h:267-657
MMU CP15 内联操作 arch/arm/src/armv7-a/mmu.h:1237-1476
L1 映射引擎(mmu_l1_setentry 等) arch/arm/src/armv7-a/arm_mmu.c:55-218
QEMU 内核内存映射表 arch/arm/src/qemu/qemu_memorymap.c:44-85
QEMU 内存映射常量定义 arch/arm/src/qemu/qemu_memorymap.h:41-64
QEMU 芯片级常量(PGTABLE_BASE_*) arch/arm/src/qemu/chip.h:44-59
每进程 addrenv 创建与销毁 arch/arm/src/armv7-a/arm_addrenv.c:165-355
L2 区域构建器 arch/arm/src/armv7-a/arm_addrenv_utils.c:58-212
页表切换实现 arch/arm/src/armv7-a/arm_addrenv.c:453-460
arch_addrenv_s 结构定义 arch/arm/include/arch.h:134-159
虚实地址转换(VA→PA) arch/arm/src/armv7-a/arm_physpgaddr.c:57-117
页池虚实转换(arm_pgvaddr) arch/arm/src/armv7-a/pgalloc.h:68-74
L2 条目操作(set/get/clr_l2_entry) arch/arm/src/armv7-a/pgalloc.h:112-176
堆按需增长(pgalloc) arch/arm/src/armv7-a/arm_pgalloc.c:176-254
ELF addrenv 集成 libs/libc/elf/elf_addrenv.c:83-251
调度器地址环境切换 sched/addrenv/addrenv.c:125-180
SVC 异常入口(汇编) arch/arm/src/armv7-a/arm_vectors.S:294-352
Syscall 分发器(C) arch/arm/src/armv7-a/arm_syscall.c:162-556
knsh defconfig boards/arm/qemu/qemu-armv7a/configs/knsh/defconfig
地址环境常量派生 include/nuttx/addrenv.h:54-254