虚拟内存杂谈

H0y9L.png

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 的版本,简单简短直接展示出最基础的实现

学习进程调度的时候基于 RTOS 学习会更为的简单且高效。

CPU 寻址

对于 16 位时代都是 实模式,我们访问的都是真实的物理地址。

H0aSU.png

对于 8086 型号的 CPU 来说,我们采用了偏移地址技术。段地址 * 16 + 偏移地址 = 实际的物理地址,总之我们可以直接访问到物理内存,这样对于所有的程序来说我们共享了一份真实内存。而对于之后时代,进入了 保护模式 我们访问的都是虚拟地址。处理方式有所变化:

H0i2O.png

现代处理器使用的是一种称为虚拟寻址(Virtual Addressing)的寻址方式。使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。虚拟寻址需要硬件与操作系统之间互相合作。CPU中含有一个被称为内存管理单元(Memory Management Unit, MMU)的硬件,它的功能是将虚拟地址转换为物理地址。MMU需要借助存放在内存中的页表来动态翻译虚拟地址,该页表由操作系统管理。

ℹ️ : 重点: 转换的工作是由 MMU 进行控制,而页表是由 OS 控制的,这是软硬件的一次配合。

页表 (PTE)

首先真实的物理内存资源是有限的,比如 4G 10G 决计不是无限的,虚拟内存对于所有进程都是从 0 开始到 2 ^ N N是常见的 32/64 位,那涉及到虚拟内存和真实内存的映射关系。

H0DPw.png

虚拟内存:下称 VM, 真实内存:下称 PM

首先,VM 和 PM 对应并非是整块的,操作系统通过将虚拟内存分割为大小固定的块来作为硬盘和内存之间的传输单位,这个块被称为虚拟页(Virtual Page, VP),每个虚拟页的大小为P=2^p字节。物理内存也会按照这种方法分割为物理页(Physical Page, PP),大小也为P字节。

也就是说对于 VM 是由许多个 VP 构成的,PM 也是由许多个 PP 构成的。 将 PPVP 关联来一起的是就是 页表(PET)

H0Mu8.png

由于 CPU 每次进行寻址时候都需要使用 PTE,所以如果想控制内存系统的访问,可以在 PTE 上添加一些额外的许可位(例如读写权限、内核权限等),这样只要有指令违反了这些许可条件,CPU 就会触发一个一般保护故障,将控制权传递给内核中的异常处理程序。一般这种异常被称 段错误(Segmentation Fault)

因涉及到转换,就会出现两种情况:

  • 页命中: MMU根据虚拟地址在页表中寻址到了地址,就返回物理地址了
  • 缺页: 对应的就是没有找到相对于的物理地址,会触发一个缺页异常,缺页异常将控制权转向操作系统内核,然后调用内核中的缺页异常处理程序

对于一个 32 位的操作系统,大概我们可以访问到 2^32 = 4GB 的空间,一个 PET 为 4bytes,那我们 4MB 个 PTE才能满足所有的寻址需求,那这样 PTE 实在太大,因此真实的环境下,我们会采用 多级页表 技术。

多集页表

H0O4i.png

采用多级页表最明显的有2个好处

  • 如果一个一级页表的一个PTE是空的,那么相应的二级页表也不会存在。这代表一种巨大的潜在节约(对于一个普通的程序来说,虚拟地址空间的大部分都会是未分配的)。
  • 只有一级页表才总是需要缓存在内存中的,这样虚拟内存系统就可以在需要时创建、页面调入或调出二级页表(只有经常使用的二级页表才会被缓存在内存中),这就减少了内存的压力。

物理页

PET 是我们的映射关系,因此对于物理地址的管理,早期的内核中直接用一个数组 mem_map 来表示,这是我们物理页的集合。在内核中的定义为

mm/memory.c
1
unsigned char mem_map [ PAGING_PAGES ] = {0,};

我们在启动的时候时候进行初始化

mm/memory.c:mem_init
1
2
3
4
5
6
7
8
9
10
11
12
13
void mem_init(long start_mem, long end_mem)
{
int i;

HIGH_MEMORY = end_mem;
for (i=0 ; i<PAGING_PAGES ; i++)
mem_map[i] = USED;
i = MAP_NR(start_mem);
end_mem -= start_mem;
end_mem >>= 12;
while (end_mem-->0)
mem_map[i++]=0;
}

按照下标的方式将系统划分成 PAGING_PAGES 段,其中为 0 值的下标对象即是可用的区域。

地址翻译

H2Cqt.png

地址翻译这个事情,就是如何从 线性地址 翻译成 物理地址,这个事情还是比较直观的。 将虚拟地址的 前 N 位作为页表目录, (N+1) ~ (2N) 作为页表1,以此类推我们就可以结合在 PET 的数据得到我们真实的地址。

H2KHR.png

Page allocator

每一个进程的 PET 都是独立的,还记得 CR3 寄存器吗?

H3php.png

对于 Linux 来说,我么对于任务的定义包含了很多数据,如下定义

task_struct
1
2
3
4
5
6
7
8
9
10
struct task_struct {
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* tss for this task */
struct tss_struct tss;
};

tss 的数据结构中就保存了我们的 cr3 的寄存器的值,在我们调度进程的时候,就可以将 cr3的寄存器恢复即可。在后续的版本中单独使用 pdg 指向我们一级页表的基址。
对于所有的进程在创建之后,我们都要为其开辟一个单独的页表空间,都是通过 copy_page_tables 函数进行创建对象的 page_tables 空间。因此开辟一个任务并非是 Free 的,我们至少要付出一些代价。而这仅仅是我们开辟了存储空间,想象一下 malloc 函数的场景:

malloc
1
2
3
4
5
6
7
8
int main()
{
char *str;
str = (char *) malloc(15);
strcpy(str, "runoob");
printf("String = %s, Address = %u\n", str, str);
return(0);
}

我们需要开辟一个新的内存空间这是实实在在的对于物理空间的占用,因此我们要在我们的 PET 中增加一个对于 User 的虚拟内存地址,也增加一个对于 PET 和真实空间的映射关系,因此会发生如下故事。

get_free_page
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
/*
* 循环的找到一个可用的物理页,然后标记成已用。
*/
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");

repeat:
__asm__("std ; repne ; scasb\n\t"
"jne 1f\n\t"
"movb $1,1(%%edi)\n\t"
"sall $12,%%ecx\n\t"
"addl %2,%%ecx\n\t"
"movl %%ecx,%%edx\n\t"
"movl $1024,%%ecx\n\t"
"leal 4092(%%edx),%%edi\n\t"
"rep ; stosl\n\t"
"movl %%edx,%%eax\n"
"1:"
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map+PAGING_PAGES-1)
:"di","cx","dx");
if (__res >= HIGH_MEMORY)
goto repeat;
if (!__res && swap_out())
goto repeat;
return __res;
}

杂项

很多初学者会被 GDTLDT 卡住,其实对于 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架构上的可移植性。

参考