一个程序(如王者荣耀)平常是存储在硬盘上的,运行时才把这个程序载入内存,CPU才能执行。
问题:
这个程序载入内存的哪个位置呢?载入内核所在的空间吗?系统直接挂了。
一、虚拟内存
1.1 内存分段管理
最早的程序员写代码时是需要指定程序在内存的运行位置的,也即他们使用绝对地址来进行内存访问的。
- 计算机刚启动时采用实模式运行,即使用绝对地址来进行内存访问;
- 操作系统加载完成会变成保护模式,即使用虚拟地址进行内存访问。
问题来了:
- 在有限的物理内存空间内,多道程序是无法并行运行的,举个栗子:王者荣耀需要在内存地址0x0000 ~ 0x6000上,微信需要在内存地址 0x0000 ~ 0x3000,这下好了,谁也跑不起来,直接崩了;
- 程序员需要关注自己写的程序要跑在多大的物理内存上,内存地址怎么分配……这耦合性也太高了,而且不安全,容易访问到其他程序已使用的物理内存。
解决方法很简单:所有程序无需关心内存物理地址,代码经过编译,链接看到的地址都一样,从 0
开始到最大地址的空间,这个地址空间是独立的,是该程序私有的,其它程序既看不到,也不能访问该地址空间,这个地址空间和其它程序无关,记为虚拟内存(Virtual memory) ,虚拟内存地址记为虚拟地址,物理内存的地址记为物理地址。
-
程序运行时找一块空闲物理内存装入即可。比如之前例子:
- 王者荣耀(虚拟地址0x0000 ~ 0x6000)运行在物理内存0x0000 ~ 0x6000;
- 微信(虚拟地址0x0000 ~ 0x3000)运行在0x6000 ~ 0x9000;
-
上述分配意味着我们需要记录虚拟空间和物理空间的映射关系:我们将这个表记为段表,因为映射的一段完整连续的物理内存。
进程 虚拟地址 物理地址 王者荣耀 0x0000 ~ 0x6000 0x0000 ~ 0x6000 微信 0x0000 ~ 0x3000 0x6000 ~ 0x9000 -
段表实际记录的是段的基址和边界,使用时通过段表找到物理内存地址,再结合段内偏移量即可定位数据。
上述为内存分段管理大致原理,实际内存分段管理和早期硬件发展相关,分为几个大的段,同时配合段寄存器使用。
cs: 代码段
ds: 数据段
ss: 栈段
es:扩展段
1.2 内存分页管理
前面我们成功的让2个程序同时正确的跑起来了,但是物理内存是有限的,如之前所示最大地址为0xA000,现在想听网易云音乐(虚拟地址为 0x0000 ~ 0x4000),这就尴尬了,空闲的物理内存(0x9000 ~ 0xA000)不够支持网易云音乐了,但现实中,我们上述需求的确可以同时满足啊?
- 上述无法使用的内存空间(0x9000 ~ 0xA000),称为内存外部碎片;即分段管理存在明显的外部碎片;
- 相对应的,已分配过程序内存内部存在没有使用到的空间称为内存内部碎片。
其实也很简单,我们把程序分批次装入物理内存中,每次只载入一部分来满足运行即可,我们把这部分程序内容记为 page (页),存放页的物理内存区域记为page frame (页框)。
- 管理内存的最小单位是页;
- 分页管理没有外部碎片了,每一个页框均可使用。
实际当中每次载入多少数据合适呢?一般为 4KB,即 页的大小为4KB,页框的大小也为 4KB。将物理内存和虚拟内存按照页的大小(4KB) 分割,需要哪页就加载那页内容,找一个空闲的页框存放即可。这样我们在有限的物理内存内,载入并正确并行了多道程序。
按照上述策略,我们成功得将3个程序并行跑起来了,如下图所示:
1.2.1 页表
虚拟内存页和物理内存页的映射关系也得占用物理内存进行存储,即上图所示的 页表,一个页表也占用一个页框存储,即一个页表最大为 4KB。下图即为页表图示,观察下图,虚拟页号就是下标,所以页表可以用一个一维数组存储即可:数组下标为虚拟页号,数组内容为物理页号和物理内存起始地址等其它额外信息:
页面大小为4kByte,因此每个页框的低12位均为0。内核将低12位充分利用,每个位都表示对应虚拟页的相关属性。同样的骚操作在64位JVM中指针压缩也有。
1.2.2 物理地址计算
给定虚拟地址 0x0809 怎么计算物理地址呢?
- 根据虚拟地址和页大小(4KB,按Byte编址需要 12 个 bit )计算虚拟页号,和页内偏移量;
- 虚拟页号: 0(0x0809 / 4k );
- 页内偏移量: 9(0x0800 % 4k);
- 页表基址寄存器找到页表物理地址;
- 查页表找到物理页号;
- 根据物理页号起始地址和页内偏移量得到物理地址。
计算机中完成上述地址转换的部件由特定硬件完成,叫做 MMU(Memory Management Unit,内存管理单元),它位于CPU 芯片中。
1.2.3 缺页中断
因为我们只将程序部分页面载入到内存当中,当运行完这些页面继续往后运行时,进程访问的虚拟地址在页表中查不到时,即后续页面还在磁盘上,此时 MMU 会触发缺页中断(page fault),从磁盘上将对应页面加载到物理内存中空闲页框内,同时更新页表。
1.2.4 多级页表
在 32 位的环境下,每个进程的虚拟内存为4GB,一个页表最大为4KB,页内偏移量12 bit,剩余 20bit (32-12) 用于页号,可以表示约 1,000,000(220)页, 页表中每一项(记为页表项)需要 4Byte,所以4GB空间需要4MB(220 * 4Byte)物理内存来存储页表。
这意味着,如果计算机并行10个进程的话需要 40MB 内存,实际中开启的进程远不止10个,需要更大内存来存储页表。
所以不可能将页表(4MB)全部放进内存,又必须能看到页表全貌,怎么办?
兵仙韩信说,只需十个将军,即可统帅百万大军;
同理,只需一个页表索引(页目录),即可查找百万页表项,即:
一页可以放1024(4KB / 4Byte)个页索引,二级可以存储即可表示 220(1024 * 1024)页,刚好覆盖整个 4GB (4KB * 220 )虚拟地址空间。
- 使用时通过一级页表(记为页目录),找到二级页表,从而得到最终物理地址;
- 若二级页表不存在,触发缺页中断进行加载即可;
- 这不是一颗B树吗?树高2。
1.2.4.1 多级页表物理地址
- 在 32 位的环境下,我们将虚拟地址原页号进行分割,10bit 存放一级页号(页目录),10bit 存放二级页号(页表项),如下图所示:
- 在 64 位的环境下,二级分页是无法满足的,实际使用时分为四级索引,即B树高度为4;
- 四级索引前面3级为页目录,叶子节点为页表,依次命名为:
- 全局页目录项 PGD(Page Global Directory)
- 上层页目录项 PUD(Page Upper Directory)
- 中间页目录项 PMD(Page Middle Directory)
- 页表项 PTE(Page Table Entry)
- 页表项为 8Byte,一页(4KB)可以索引 512 (4KB / 8Byte) 个页;
- 四级可以索引 5124 个页,可以表示 256TB (4KB * 5124) 虚拟空间;
- 512 个页号使用 9 (29 = 512)个 bit就可以表示;
- 四级页表使用 36(4 * 9)个bit可以表示;
- 64 位环境用
48
(36 + 12)个bit 进行寻址(意味着PGD中 高位 16 bit 全0或者全1),表示 256TB 空间。
- 四级索引前面3级为页目录,叶子节点为页表,依次命名为:
小节:虚拟地址本质上是索引+偏移量。
1.2.5 页表缓存TLB(Translation Lookaside Buffer)
计算机中为协调CPU和内存的访问速度(一次内存的访问,大约需要 120 个 CPU Cycle),中间加了 高速缓存(CPU Cache) 。
- 高速缓存使用特定的由 SRAM (静态RAM (random-access memory,随机访问存储器)) 组成的物理芯片,内存使用DRAM(动态RAM);
- 高速缓存为3级:L1/L2/L3 Cache。其中 L1/L2 是 CPU 私有,L3 是所有 CPU 共享。
- 缓存行(Cache Line):高速缓存的最小单元,一次从内存中读取的数据大小。常用的 Intel 服务器 Cache Line 的大小通常是 64 字节。
- CPU Cache 的命中率通常能达到 95% 以上。
我们前面提到的 页表项(PTE) 同样需要从内存加载到高速缓存来提高CPU访问速度,由于高速缓存空间远小于内存空间,所以只能缓存程序中最常访问的页表项,我们把缓存页表项的这块区域称为转址旁路缓存(TLB,Translation Lookaside Buffer),也称为快表(内存中的表相应为慢表)。
1.3 内存段页式管理
内存管理最开始分段管理,后面又提出分页管理,由于硬件和版本限制,现在操作系统中的内存管理理论上是由段页结合一起管理的,即先分段,然后段内分页。
实际使用时,操作系统做了简化,将代码段和数据段的基址设为0,段空间大小为这个虚拟内存的大小。
即现代操作系统中,分段管理名存实亡。
在 CPU 中,程序使用逻辑内存地址;
1.4 虚拟内存空间整体布局
虚拟内存空间并非全部留给程序,一般而言高地址段为内核空间,用于系统内核使用,低地址段为用户空间,留给程序使用,如下图所示:
1.4.1 用户态虚拟内存
程序是由源代码经过编译、链接形成的可执行文件(ELF)。ELF中分为代码段(.text)、数据段(.data)、未初始化数据段(.bss)等很多逻辑段,虚拟内存为保持这种程序的逻辑性,同样也在逻辑上将内存划分为代码段、数据段、堆、文件映射与匿名映射区、栈等。
注意:
- 一个段(segment)一般包含多个页(page),每个段的长度并不相等;
- 保留区这段虚拟内存是不可访问的,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。
- 栈和文件映射区使用时均是从高地址向低地址分配;
- 堆使用时从低地址向高地址分配;
- 不可访问段仅存在于64位环境中,为了防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
- 通过
cat /proc/pid/maps
或者pmap pid
查看进程的虚拟内存空间布局以及其中包含的所有内存区域。- 进程进入内核态之后使用的仍然是虚拟内存地址,只不过在内核中使用的虚拟内存地址被限制在了内核态虚拟内存空间范围中。
虚拟内存的内容暂时介绍到这里,后续会介绍物理内存的实际分配情况。