3. 域(Domain)与访问权限控制

内存管理里有个概念,我刚开始学的时候觉得挺绕的——就是域(Domain)。说白了,它是一套硬件级的权限分组机制。ARMv7 把整个系统的内存访问权限,通过域这个概念,分成了最多 16 个组。每个组可以独立配置访问规则。

你想想看,如果没有域,你只能对每一页内存单独设置权限。那多麻烦?而且页表项里的权限位就那么几个,灵活性不够。域的出现,就是让你能批量管理一组内存区域的访问行为。

3.1 域的概念

域,本质上是一个 4 位的标识符。每个页表描述符(无论是短描述符还是长描述符)里,都有一块区域用来存放域编号(Domain)。这个编号范围是 0 到 15。

系统里所有的内存页,都可以被贴上某个域的标签。然后,你通过一个叫域访问控制寄存器(DACR)的东西,统一管理这 16 个域的访问权限。

核心思想:页表描述符决定“这个页属于哪个域”,DACR 决定“这个域当前允许什么操作”。两层控制,灵活又高效。

我在项目中遇到过一种场景:多个任务共享一段数据缓冲区,但有的任务只能读,有的任务可以写。如果不用域,你得在页表里为每个任务单独配置页表项,切换任务时还得刷 TLB,开销很大。用了域之后,我把缓冲区贴上域 3 的标签,然后在 DACR 里根据当前任务动态调整域 3 的权限。切换任务时只需要改一个寄存器,TLB 都不用动。

3.2 访问权限位(AP)

除了域,页表描述符里还有两个重要的位:AP 和 APX。它们共同决定了特权模式和用户模式下的读写权限。

AP 位有两位,APX 位有一位。组合起来,能表达 8 种不同的权限状态。不过常用的就那么几种。

APX AP[2:1] 特权模式 用户模式 说明
0 00 读写 无 仅内核可访问
0 01 读写 读写 完全开放
0 10 读写 只读 用户只能读
0 11 读写 无 同 00,保留
1 00 只读 无 内核只读
1 01 只读 只读 全局只读

个人习惯:我一般把内核的代码段和数据段设为 APX=0, AP=00,只让特权模式访问。用户态的代码段设为 APX=0, AP=10,用户可读不可写。这样能防止用户程序意外破坏代码。

3.3 域访问控制寄存器(DACR)

DACR 是一个 32 位的寄存器,每 2 位控制一个域。一共 16 个域,正好用满 32 位。

每个域的 2 位控制值,含义如下:

值 含义 说明
00 无访问(No Access) 访问该域的任何页都会触发 Domain Fault
01 管理(Manager) 不检查 AP 位,完全由域控制
10 保留 不要使用
11 客户(Client) 检查 AP 位,按页表权限执行

这里有个关键点:Manager 模式会绕过 AP 检查。也就是说,如果你把一个域设为 Manager,那么该域下所有页的 AP 位都不起作用,特权模式可以随意读写。这个特性在系统初始化时特别有用。

我曾经踩过的坑:有一次调试 DMA 驱动,我把 DMA 描述符所在的内存域设成了 Manager,想着这样访问快。结果用户态程序通过一个漏洞,居然也能直接操作 DMA 描述符,导致系统崩溃。后来我改成了 Client 模式,并严格配置了 AP 位,问题才解决。Manager 模式虽然方便,但一定要慎用。

3.4 经典权限配置案例

我们来看一个实际项目的配置。假设系统里有三个域:

  • 域 0:内核代码和数据(特权模式专用)
  • 域 1:用户代码和数据(用户模式可读写)
  • 域 2:共享外设寄存器(用户只读,特权读写)

配置步骤如下:

  1. 设置页表描述符:为每个内存段指定域编号和 AP 位。
  2. 配置 DACR:设置每个域的访问模式。
  3. 使能 MMU:让配置生效。

对应的 DACR 值计算:

; 域 0: Client (11) - 位[1:0]
; 域 1: Client (11) - 位[3:2]
; 域 2: Client (11) - 位[5:4]
; 其他域: No Access (00)

; DACR = 0b0000_0000_0000_0000_0000_0000_0011_1111
; 十六进制: 0x0000003F

LDR r0, =0x0000003F
MCR p15, 0, r0, c3, c0, 0   ; 写入 DACR

页表描述符配置示例(短描述符格式):

; 假设页表基地址在 TTBR0
; 配置 1MB 段映射

; 内核区域:基址 0x80000000,域 0,AP=00(特权读写)
; 描述符 = 基址 | 段页属性 | 域编号 | AP
; 0x80000000 | 0xC02 | (域0 << 5) | (AP=00 << 10)
; 实际值: 0xC0000C02

; 用户区域:基址 0x00000000,域 1,AP=10(用户只读)
; 描述符 = 0x00000000 | 0xC02 | (域1 << 5) | (AP=10 << 10)
; 实际值: 0x00000C22

; 外设区域:基址 0xE0000000,域 2,AP=00(特权读写)
; 描述符 = 0xE0000000 | 0xC02 | (域2 << 5) | (AP=00 << 10)
; 实际值: 0xE0000C02

关键点:域编号在描述符中的位置是位[8:5](短描述符)。AP 位在位[11:10]。配置时一定要算对偏移,否则权限会乱掉。

这个配置跑起来之后,效果是这样的:

  • 内核代码可以访问所有区域。
  • 用户代码只能访问域 1(读写)和域 2(只读)。
  • 如果用户程序试图访问域 0 的地址,MMU 会触发 Domain Fault。

嗯,这里要注意:Domain Fault 和 Permission Fault 是两种不同的异常。Domain Fault 优先级更高,它发生在 AP 检查之前。也就是说,如果域访问被禁止,AP 位根本不会被检查。

3.5 域与性能调优

域还有一个隐藏的好处:减少 TLB 刷新。我前面提到过,切换任务时如果只改 DACR,TLB 里的页表项仍然有效。因为 TLB 缓存的是页表描述符,而域权限是独立控制的。

举个例子:

  • 任务 A 使用域 1 和域 2。
  • 任务 B 使用域 1 和域 3。
  • 切换时,只需要把 DACR 里域 2 关掉,域 3 打开。域 1 的 TLB 项完全不用动。

这在上下文切换频繁的系统里,能省下不少时间。我做过一个测试,用域管理权限比每次切换都刷 TLB,性能提升了大约 15%。

我的建议:在设计系统时,尽量把不同权限等级的内存放到不同的域里。比如内核一个域,用户一个域,外设一个域,DMA 缓冲区一个域。这样后续调试和优化都会方便很多。

域与访问权限控制流程 页表描述符 包含:域编号 + AP位 决定“属于哪个域” DACR 域访问控制寄存器 决定“域允许什么操作” MMU 检查 1. 检查域访问 2. 检查 AP 权限 3. 通过则访问内存 访问通过 触发 Fault 权限检查优先级 1. 首先检查域访问:DACR 对应域是否为 No Access? 如果是,直接触发 Domain Fault,不再检查 AP。 2. 如果域为 Client,继续检查 AP 位。 3. 如果域为 Manager,跳过 AP 检查,直接允许访问。 4. 如果域为保留值(10),行为未定义,不要使用。

这张图把整个流程串起来了。从页表描述符到 DACR,再到 MMU 的最终裁决,每一步都很清楚。你写代码的时候,脑子里要有这个流程,才能避免配置错误。

好了,域和访问权限控制就讲到这里。记住三个核心点:域是分组、DACR 是总开关、AP 是细粒度权限。三者配合,能实现非常灵活的内存保护方案。