从 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 的物理地址映射到虚拟地址。
为了理解下面这段汇编,先知道三条关键信息:
.LCtextinfo 是一个编译时定义的常量表,存着 .text 的物理基址、虚拟基址、MMU 标志、段数量
ldmia 一次从内存加载 4 个值到 4 个寄存器
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 个段条目,覆盖 0x40000000 → 0x41000000。此时 L1 表里 .text 和 RAM 的条目都有了。
第四步:无效化 TLB 和 Cache (arm_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/TTBCR (arm_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(128 MB) }, { VIRT_IO_PSECTION, VIRT_IO_VSECTION, MMU_IOFLAGS, _NSECTIONS(96 MB) }, { VIRT_SEC_MEM_PSECTION, VIRT_SEC_MEM_VSECTION, MMU_MEMFLAGS, _NSECTIONS(16 MB) }, { VIRT_PCIE_PSECTION, VIRT_PCIE_VSECTION, MMU_IOFLAGS, _NSECTIONS(768 MB) }, { VIRT_DDR_PSECTION, VIRT_DDR_VSECTION, MMU_MEMFLAGS, _NSECTIONS(256 MB) }, };
每个映射由 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(); uint32_t index = vaddr >> 20 ; l1table[index] = (paddr | mmuflags); cp15_clean_dcache_bymva(&l1table[index]); mmu_invalidate_region(vaddr, SECTION_SIZE); }
重要 :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 { 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 指令 :
MCR p15, 0, Rd, c2, c0, 0(写 TTBR0)
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 从 0x40000000 到 0x50000000,共 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; }
这意味着 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 处理过程中:
SYS_nanosleep stub 调用内核的 sleep 实现
内核将进程 A 移出就绪队列,选择进程 B
SYS_switch_context → nxsched_switch_context() 设置 TCB 切换标记
返回到 arm_syscall 后,SYS_syscall_return 注意到需要切换上下文
SYS_restore_context 处理 → addrenv_switch(tcb_B) → 写 TTBR0 为进程 B 的 L1 表 → 刷 TLB
返回进程 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. 关键设计要点
L1 管内核,L2 管用户。 内核物理区域用 PMD_TYPE_SECT(1MB 段描述符)直接映射,用户空间用 PMD_TYPE_PTE → L2 小页(4KB)映射。内核用段映射追求简单和性能,用户用小页追求灵活分配和权限隔离。
整表复制策略是 NuttX 的最大特色。 每个进程获得一张完整 L1 表的 16KB 拷贝——不是共享 L1 + 交换条目,也不是 Linux 的部分复制。arch_addrenv_s:136 的注释直接说明了原因:”Alloc whole l1table to make better context switch performance”。这在嵌入式场景(进程数少,内存有限但够用)是合理的——用 16KB/进程换取上下文切换只需写一次 TTBR0。
进程切换 = 一次 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 介入。
内核映射存在于每个进程的页表中。 通过 memcpy() 从内核 L1 表复制,每个进程的 L1 表 entries 02047(覆盖 PA 02GB)完全相同。这意味着 syscall 处理过程中无需切换 TTBR0 ——内核代码就在”那里”,只是从 PL1 执行。
ELF 加载需要临时地址空间切换。 内核通过 addrenv_select() 临时切换到目标进程的页表,将 ELF 段通过 memcpy 写入目标虚拟地址。加载完成后,.text 的 AP 位从 PROT_WRITE 恢复为只读。
双栈机制是 exec() 正确性的保障。 当进程 A 执行 exec() 启动进程 B 时,A 的地址环境(包括用户栈)需要被替换。如果内核逻辑跑在 A 的用户栈上,替换地址空间时栈就没了。解决方案:CONFIG_ARCH_KERNEL_STACK 为每个进程分配一个 3072 字节的内核栈,syscall 处理期间切换到内核栈。
页池 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