MMU是Memory Management Unit的缩写,中文名是内存管理单元,有时称作分页内存管理单元(英语:paged memory management unit,缩写为PMMU)。它是一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件。
¶引言
MMU
对于软件工程师来说并不容易理解,至少笔者在学完了 OS
之后的很长一段时间内都搞不清楚这个玩意到底怎么和操作系统配合使用的,趁着有空将其彻底的梳理清楚。对于操作系统来说 Virtual Memory
并不是一个必要的概念,在学习计算机原理的时候,我们采用 C51
Stm32
入门都是可以的,对于常见的 real-time operating system (RTOS)
来说,没有 MMU
的硬件支持,我们依然可以实现 OS
的进程调度等工作,而引入 MMU
硬件和 Virtual Memory
内存的概念我认为是一次软硬件工程师的共同协助,Virtual Memory
的收益很多:定义了一个连续的虚拟地址空间,使得程序的编写难度降低,对于进程也可以进行硬件级别的隔离。
代码分析选择是 Linux 0.12 的版本,简单简短直接展示出最基础的实现
¶CPU 寻址
对于 16
位时代都是 实模式
,我们访问的都是真实的物理地址。
对于 8086
型号的 CPU
来说,我们采用了偏移地址
技术。段地址
* 16
+ 偏移地址
= 实际的物理地址
,总之我们可以直接访问到物理内存,这样对于所有的程序来说我们共享了一份真实内存。而对于之后时代,进入了 保护模式
我们访问的都是虚拟地址。处理方式有所变化:
现代处理器使用的是一种称为虚拟寻址(Virtual Addressing)的寻址方式。使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。虚拟寻址需要硬件与操作系统之间互相合作。CPU中含有一个被称为内存管理单元(Memory Management Unit, MMU)的硬件,它的功能是将虚拟地址转换为物理地址。MMU需要借助存放在内存中的页表来动态翻译虚拟地址,该页表由操作系统管理。
ℹ️ : 重点: 转换的工作是由 MMU 进行控制,而页表是由 OS 控制的,这是软硬件的一次配合。
¶页表 (PTE)
首先真实的物理内存资源是有限的,比如 4G
10G
决计不是无限的,虚拟内存对于所有进程都是从 0
开始到 2 ^ N
N是常见的 32/64 位,那涉及到虚拟内存和真实内存的映射关系。
虚拟内存:下称 VM, 真实内存:下称 PM
首先,VM 和 PM 对应并非是整块的,操作系统通过将虚拟内存分割为大小固定的块来作为硬盘和内存之间的传输单位,这个块被称为虚拟页(Virtual Page, VP),每个虚拟页的大小为P=2^p字节。物理内存也会按照这种方法分割为物理页(Physical Page, PP),大小也为P字节。
也就是说对于 VM
是由许多个 VP
构成的,PM
也是由许多个 PP
构成的。 将 PP
和 VP
关联来一起的是就是 页表(PET)
由于 CPU
每次进行寻址时候都需要使用 PTE
,所以如果想控制内存系统的访问,可以在 PTE
上添加一些额外的许可位(例如读写权限、内核权限等),这样只要有指令违反了这些许可条件,CPU
就会触发一个一般保护故障,将控制权传递给内核中的异常处理程序。一般这种异常被称 段错误(Segmentation Fault)
。
因涉及到转换,就会出现两种情况:
- 页命中: MMU根据虚拟地址在页表中寻址到了地址,就返回物理地址了
- 缺页: 对应的就是没有找到相对于的物理地址,会触发一个缺页异常,缺页异常将控制权转向操作系统内核,然后调用内核中的缺页异常处理程序
对于一个 32
位的操作系统,大概我们可以访问到 2^32 = 4GB 的空间,一个 PET 为 4bytes
,那我们 4MB 个 PTE
才能满足所有的寻址需求,那这样 PTE 实在太大,因此真实的环境下,我们会采用 多级页表
技术。
¶多集页表
采用多级页表最明显的有2个好处
- 如果一个一级页表的一个PTE是空的,那么相应的二级页表也不会存在。这代表一种巨大的潜在节约(对于一个普通的程序来说,虚拟地址空间的大部分都会是未分配的)。
- 只有一级页表才总是需要缓存在内存中的,这样虚拟内存系统就可以在需要时创建、页面调入或调出二级页表(只有经常使用的二级页表才会被缓存在内存中),这就减少了内存的压力。
¶物理页
PET
是我们的映射关系,因此对于物理地址的管理,早期的内核中直接用一个数组 mem_map
来表示,这是我们物理页的集合。在内核中的定义为
1 | unsigned char mem_map [ PAGING_PAGES ] = {0,}; |
我们在启动的时候时候进行初始化
1 | void mem_init(long start_mem, long end_mem) |
按照下标的方式将系统划分成 PAGING_PAGES
段,其中为 0 值的下标对象即是可用的区域。
¶地址翻译
地址翻译这个事情,就是如何从 线性地址
翻译成 物理地址
,这个事情还是比较直观的。 将虚拟地址的 前 N 位作为页表目录, (N+1) ~ (2N) 作为页表1,以此类推我们就可以结合在 PET
的数据得到我们真实的地址。
¶Page allocator
每一个进程的 PET
都是独立的,还记得 CR3
寄存器吗?
对于 Linux
来说,我么对于任务的定义包含了很多数据,如下定义
1 | struct task_struct { |
在 tss
的数据结构中就保存了我们的 cr3
的寄存器的值,在我们调度进程的时候,就可以将 cr3
的寄存器恢复即可。在后续的版本中单独使用 pdg
指向我们一级页表的基址。
对于所有的进程在创建之后,我们都要为其开辟一个单独的页表空间,都是通过 copy_page_tables
函数进行创建对象的 page_tables
空间。因此开辟一个任务并非是 Free
的,我们至少要付出一些代价。而这仅仅是我们开辟了存储空间,想象一下 malloc
函数的场景:
1 | int main() |
我们需要开辟一个新的内存空间这是实实在在的对于物理空间的占用,因此我们要在我们的 PET
中增加一个对于 User
的虚拟内存地址,也增加一个对于 PET
和真实空间的映射关系,因此会发生如下故事。
1 | /* |
¶杂项
很多初学者会被 GDT
和 LDT
卡住,其实对于 Linux
来说这两部分完全只是白色,所有的段都使用了全部内存,因此对于 Linux
并没有太多的价值。在 Linux Programming Interface那本书里就谈到
Linux uses segmentation in a very limited way. In fact, segmentation and paging are somewhat redundant, because both can be used to separate the physical address spaces of processes: segmentation can assign a different linear address space to each process, while paging can map the same linear address space into different physical address spaces.
通过将 cs (代码段) 和 ds (数据段) 寄存器的值设为0,Linux实际没有使用分段而只使用了分页。因为这样内存管理会更加简单,并且由于像RISC架构的处理器只对分段提供了非常有限的支持,不使用分段会提高Linux操作系统在不同CPU架构上的可移植性。