Arm Cortex-A 系列 内存管理单元(MMU)
由于直接分析 linux arm32 mmu版 的启动代码会涉及到内存直接物理映射模式到开启虚拟地址映射模式的转换,这需要对 ARM32 中的虚拟地址实现机制有足够的了解才行,本文通过分析Arm Cortex-A 系列内存管理单元来分析ARM32中的虚拟地址机制。 Memory Management Unit 简称为 MMU ,它的一个最主要的功能就是进行地址转换,将处理器发出的 虚拟地址 转换为 物理地址 ,有了 MMU 的支持,才能让我们更容易地设计处多任务操作系统,以及在操作系统上开发应用程序,如果学习过逆向分析,就知道不同的可执行文件(区别于动态链接库与可重定向文件)的装载地址(entry point)在一般情况下都是相同的,并且在不同的程序中,也会有极大概率访问到相同的内存地址,为了防止冲突以及不必要的重定向任务, 虚拟地址 与地址转换的概念应运而生,只要操作系统为每一个进程维护一个虚拟地址转换表,这样就可以通过地址转换将处理器发出的相同地址转换为不同的物理地址,程序中也不再存在访问同一个地址发生冲突的问题,也有效阻止了一个进程非法读写另一个进程的内存数据的现象发生。
在不同的处理器架构中,虚拟地址转换的实现往往各不相同,本文主要分析 Arm Cortex-A 系列处理器的 MMU 与虚拟地址转换过程。
首先抛开理论,凭感觉简单考虑一下处理器发出内存访问请求后,按道理应该出现的一个操作流程:
-
1、首先处理器从内存中读入一个指令,例如:ldr r0, [r1] ,而 r1 在之前已经被赋上了某个标号的值,这个值代表这个标号的所在地址,而此地址是一个虚拟地址,在程序源码 通过链接过程,将目标文件链接为可执行文件 的时候就已经确定下来了,可执行文件和动态链接库不同,可执行文件没有重映射表,所以当装载到内存后,不会进行指令地址重映射操作,程序中的每一个指令对内存的认知就只局限在被链接的时候所分配的虚拟地址,处理器通过查看 opcode 发现这个指令需要写内存,但是它知道这个是虚拟内存,通过这个内存地址直接找物理内存一定是一无所获的,所以它将地址交给了另一个处理单元,即 MMU
-
2、 MMU 收到了虚拟地址后,它应该会通过某种映射机制来将此虚拟地址转换为物理地址,这就需要在某处持续保存且更新这个映射信息,但是由于片内高速缓存资源是非常有限的,不可能将所有的映射信息全部存储在片内高速缓存中,并且在设备重启或掉电的时候,操作系统与其他应用程序会重新载入并运行,而不是继续运行,所以也没有必要将映射信息持久化保存;这样我们可以猜测这个映射信息应该是会被保存在内存中的某个位置,并且很有可能为了提高查询映射信息的速度而将部分信息存储在高速缓存中。
-
3、如果映射信息在内存中,则 MMU 必须知道映射信息位置的物理实际地址,而不是虚拟地址,要不然就发生永不停息的递归查询了,而这个物理地址必须是 MMU 事先知道的,比如存储在内存中的固定位置, MMU 通过访问内存能得到这个物理地址,或者是存储在特殊的寄存器中。 MMU 获取到这个物理地址后才能找到映射信息,并使用映射信息把虚拟地址映射为物理地址,最后在此转换得到的物理地址上执行 ldr 指令。
在上文中需要注意的信息有以下两点:
- 映射信息与其存储位置
- 映射信息的物理地址
这两点是支持地址转换的重要元素。
现在通过手册仔细阅读 MMU 实现原理与工作机制,来验证我们的猜想,这里我使用的手册是 《Arm Cortex-A Series Programmer’s Guide》version 4 Chapter 9 (在本次分析中不涉及 Large Physical Address Extensions 技术)。
通过阅读手册知道,Arm 中的 translation table 就对应着上文提到的映射信息, MMU 通过查询 translation table 来获取虚拟地址与物理地址的映射关系。而这个查询与转换过程如何实现,则与这个所谓的 translation table 的结构与定义有着很大的关系,这就需要深入了解这个 translation table 的结构组织了。根据手册描述,Arm 将可寻址的 4GB 大小的内存空间分为特定大小的块,每一个块被称作页(page),然后再给每一个块建立一个映射关系表项来完成虚拟地址中的块到物理地址中的块的映射(这个与虚拟地址中的块所对应的物理地址空间中的块被称作页框 page frame),这个以分块来映射的机制就叫做分页技术(paging),为什么要分块呢?可以想象一下如果将每一个字节,甚至每一个比特都设置一个特定的映射关系,那需要多少空间来存储这个映射信息呢?如果每一个物理内存都被映射到了虚拟内存上,是不是所有物理内存上存储的都是映射信息?那就不用干别的事情了。所以要给虚拟地址空间进行分区,每一个特定大小的块做一个映射关系,每个块内部地址映射关系则是线性的(虚拟地址空间块中的每个位置到块起始位置的偏移都与物理地址空间的块中的相应位置到物理地址空间块的起始位置的偏移相同),这样就不用存储过多的信息了。但是这个块又不能太大,不然如果每个应用进程都只占用一个块中的很小空间,那么就会留下很多的内存碎片无法被利用,会产生极大的浪费,关于块的大小,可以通过配置 translation table 表项的属性来决定,Arm 留给了操作系统开发人员极大的可定制空间。
了解了映射机制后,来具体探究一下 translation table 的结构:
每个 translation table 都占据了一块连续的物理地址,并将这块物理地址分为大小相等的块,每一块代表一个表项 (translation table entry),可以认为 translation table 是一个数组,每个元素都是一个 table entry。每个表项中都存储有特定的信息,或者是未映射,或者是映射到下一级 tranlation table(Arm 中的地址转换可以是多级的,即通过多层映射来获取虚拟地址对应的物理地址),或者是直接映射到一个物理地址上(Arm 中的地址转换也可以是单级的,即表项中包含的地址即为虚拟地址所对应的物理地址)。在没有启用 LPAE 技术时,Arm 最多可以分成两级页表,即 L1 translation table 与 L2 translation table。
现在我们有 translation table 了,也知道 translation table 的表项中存储有映射地址信息了,那问题是 MMU 获得一个虚拟地址后,怎么知道去查询哪个 translation table 和查询 translation table 中的哪个表项?
查询哪个地址转换表
上文说过,想要查询 translation table , MMU 一定需要通过某种方式获得这个 translation table 的实际物理地址,而这个物理地址就存储在协处理器 CP15 的 C2 寄存器中(在 《ARM 体系结构与编程》第二版的第 178 页有全部的 CP15 协处理器的寄存器的作用),叫做地址转换表基址。当 MMU 收到一个虚拟地址,它通过查询协处理器 CP15 的 C2 寄存器来获取地址转换表的基址,然后通过这个地址转换表来进行地址转换。当考虑多任务操作系统时,往往每一个进程都会存储一个地址转换表基址,当发生进程切换时,会将这个存储的基址加载到处理器 CP15 的 C2 寄存器中,然后就能对这个进程对虚拟地址的访问进行转换工作了。
查询哪个地址转换表表项
MMU 收到的所有与内存访问有关的信息只有处理器传过来的虚拟地址,所以查询哪个表项这个问题只能通过这个虚拟地址本身来决定。对于 ARM32 平台下,这个虚拟地址一定是 32bits 长的, MMU 使用虚拟地址的高 12bits 来决定查询的地址转换表项,虚拟地址高 12bits 表示的数值代表表项的下标索引,即从头开始的 第几个 表项(注意这个数值不代表偏移地址,而是代表“第几个表项”),所以当设虚拟地址高 12bits 的值为 INDEX ,并且协处理器 CP15 的 C2 寄存器的值为 BASE 那这个表项在内存中的实际物理地址就是 INDEX * 4 + BASE 。从这一点我们也可以看出,我们用了高 12bits 去寻找一级地址转换表的表项,还剩下 20 bits没有使用,这就代表每个表项可以分割 2^20 bytes 的地址空间,即 1MB 的内存段。以类似的方式我们可以在虚拟地址中提取出二级地址转换表表项的索引值,或者直接使用这 20bits 去映射物理内存,这些具体细节将在下文描述。
L1 Translation Table
上文已经解释过如何定位一级地址转换表,也提到了一级地址转换表可以指向二级地址转换表,也可以直接指向物理地址进行映射,现在来看一下一级地址转换表的表项的结构来明确一下怎么分辨表项指向的是物理地址还是下一级地址转换表。一级地址转换表一共有 4096 个表项,每个表项的大小为 32bits,他将整个 4GB 虚拟内存空间分为 4096份,每份 1MB 大小。一级地址转换表的表项一共有 4种,如下图所示:
可以看到表项之间通过第0、1位来判断表项的种类,像 00 代表没有进行映射的表项,01 代表表项指向下一级(图中的 Level 2 Descriptor Base Address ),即二级地址转换表的基地址。而 10 的情况比较特殊,这两个都是直接映射物理地址,但是 section 类表项代表直接映射 1MB 大小的物理地址空间,而 supersection 通过几个表项组合的方式来映射 16MB 大小的物理地址空间,supersection 比较特殊,就不展开讨论,这块内容在手册的 9.4 节有具体描述。在这里主要描述 01 表项的情况,我们注意到 Level 2 Descriptor Base Address 的大小为 22bits 这显然不能覆盖所有 4GB 大小的内存空间,看来二级地址转换表的存储位置必定会受到限制。22bits 表示的大小可以将内存均匀分为 4194304 个区域,每个区域大小为 1KB,而 Arm 刚好定义二级地址转换表的大小为 1KB,并且二级转换表的起始位置为 1KB 对齐的,所以我们均匀分出来的 4194304 个区域,每个区域都正好能存储一个二级地址转换表,嗯,二级地址转换表的基地址在内存中 1KB 对齐,并且大小为 1KB,这样就能通过一级地址转换表的表项中的 22bits 大小的 Level 2 Descriptor Base Address 来寻找二级地址转换表。
L2 Translation Table
通过上文的方式我们已经找到了二级地址转换表的位置,现在我们要利用二级地址转换表继续进行地址映射(注:我们已经利用了虚拟地址的高 12bits 来进行一级地址转换表表项的寻址工作,只剩下 20bits 来寻找二级地址转换表表项了)。这里要强调一下,进行地址转换的过程是不存在浪费地址空间的行为,即一级地址转换表将内存划分为 1MB 大小的块,如果不进行直接物理地址映射的话,那么二级地址转换表必须保证能够将每个 1MB 的块全部分配出去,现在我们还剩下 20bits,理论上可以进行 1MB 大小的内存寻址,但是需要利用这 20bits 的前几位来寻找二级地址表的表项,后几位来作为这个表项所映射的物理地址空间的地址偏移值,而我们也知道二级地址转换表的大小为 1KB,如果我们利用前 8bits 来进行地址表项的寻找,这样可以将转换表分为 256 个表项,每个表项 4bytes,并且每个表项应该表示 1MB / 256 = 4KB 大小的页框(上文已经提到页框这个术语)的起始地址,而 4bytes 全部用来寻找页框起始物理地址才能让页框的起始物理地址在内存的任意位置,但是为了给页框加一些必要的访问属性(可读可写之类的属性),不能用表项的全部 4bytes 表示页框的起始物理地址,这样就引出了经典问题,页框的起始地址不能是 4GB 空间中的任意位置,当然如果细想一下也不应该是任意位置,如果一个页框起始地址往上1KB又是另一个页框的起始地址,那么这两个页框不就重叠了么?这必定会引起访问冲突。所以最好的办法还是让 4KB 的页框的起始地址以 4KB 进行对齐,这样就能将 4GB 地址空间均匀分为 1048576 份,这个数量正好能用 20bits 来寻址,所以二级地址转换表的表项中的 20bits 应该用于映射页框,而剩下的 12bits 可以用来表示页框的属性。上述分析只是二级地址转换表表项的其中一种表示方法,二及地址转换表表项也有 3 个表示方法,如下图所示:
未完待续