理解了虚拟地址空间的本质后,我们便能清晰认知到:每个进程都拥有一段独属于自身的连续地址空间。不同于实际存储数据的物理内存,这段连续的虚拟地址空间更像是一张划定好范围的 "大饼"------ 仅作为进程内存访问的统一地址框架,并不承担实际的数据存储功能。真正的存储还需要在物理内存实现,那么从虚拟内存到物理内存就需要进行映射------因此就产生了页表。
前置知识------物理内存管理
一、物理内存管理的基本单位------页帧(页窗)
在 Linux 文件管理系统中,我们知道磁盘存储的最小分配与管理单位是块(block),默认大小为 4KB,恰好对应 8 个 512 字节的磁盘物理扇区。为了实现内存与磁盘的 IO 对齐、减少数据交换的碎片与开销,操作系统对物理内存的管理也采用了同规格的分块策略,物理内存中这份大小为 4KB 的固定内存块 ,被称为页帧(也作页窗)。
二、页帧的内核描述------struct_page结构体
由于页帧数量极大并且每个页帧可能处于不同的状态,操作系统为了将其进行管理就需要进行描述与组织,在内核中用于描述页帧的结构体叫做struct_page,我们可以对其中几个简单参数进行了解:
cpp
//struct_page 简化伪代码(仅保留核心成员)
struct page {
// 1. 页帧状态:标记物理页帧的核心状态(与页表项存在位联动)
unsigned int flags; // 状态标志位(按位存储)
#define PG_free 0x01 // 位0:1=页帧空闲,0=已占用
#define PG_swap 0x02 // 位1:1=页帧数据已交换到磁盘,0=在物理内存中
#define PG_kernel 0x04 // 位2:1=页帧归属内核空间,0=归属用户空间
// 2. 映射计数:记录该页帧被多少进程的页表映射(支撑内存共享)
int _mapcount; // 映射数:0=无进程映射,>0=被N个进程映射
// 3. 物理页帧地址关联:页帧对应的物理地址(页表映射的核心依据)
unsigned long pfn; // 物理页帧号(Page Frame Number)
// 注:物理地址 = pfn << 12(4KB=2^12,左移12位得到页帧起始物理地址)
// 4. 页帧归属补充:区分内核/用户页帧(页表权限管控)
unsigned int owner; // 简化标识:0=用户空间,1=内核空间
/* ......
......*/
};
-
flags(状态标志位):
- 最核心的是**
PG_free** (空闲 / 占用)和**PG_swap** (是否交换到磁盘),直接对应页表项的 "存在位"------ 若PG_swap=1,页表项存在位会置 0,触发缺页中断时内核会从磁盘换回数据并更新状态; - **
PG_kernel**标记页帧归属,页表项会据此设置 "用户 / 内核位",限制用户态进程访问内核页帧。
- 最核心的是**
-
_mapcount(映射计数):
- 比如动态库被 10 个进程共享时,其对应的页帧
_mapcount=10; - 当**
_mapcount** 减至 0 且PG_free=1时,内核会将该页帧回收至空闲链表。
- 比如动态库被 10 个进程共享时,其对应的页帧
-
pfn(物理页帧号):
- 页表项中存储的 "物理页帧基地址",本质就是pfn
<< 12(4KB 对齐); - 比如pfn
=0x12345,则物理页帧起始地址 = 0x12345 << 12 = 0x12345000。
- 页表项中存储的 "物理页帧基地址",本质就是pfn
当然其中参数远不止这些,但是该点不作为本文重点介绍,在对struct page进行组织时,Linux 内核始终以结构体数组(mem_map)作为页帧管理的核心载体。在老内核(2.4 及之前)中,由于适配的硬件物理内存规模小(主流几 MB 到几百 MB)且物理地址连续,mem_map数组也是全局连续的,此时物理页帧号(PFN)等价于数组下标,可通过pfn = page - mem_map的地址偏移计算得出,因此struct page中无需显式存储 PFN 字段;而新内核(2.6+)随着硬件升级,物理内存规模飙升至 GB、TB 级别,为适配大内存场景引入了稀疏内存技术,mem_map数组不再是全局连续的,而是按内存节点 / 区域拆分,数组下标仅代表节点内的局部编号,若仍通过地址偏移计算会出现 PFN 重复的问题,因此新内核将 PFN 显式写入struct page结构体,保证其全局唯一性,适配非连续物理内存的管理需求。
核心内容------页表的实现
一、地址转换方法:基于 unsigned long 的页帧 + 偏移寻址
Linux中对虚拟地址存储采用了unsigned int类无符号整型对应的二进制表示,其大小为0~2^32-1

对于这个32位整型,其低12位表示偏移地址,2^12正好代表4096个字节(4KB),刚好对应一个页帧大小,其高20位则代表页帧起始地址,2^20=1024*1024,代表虚拟空间4GB对应的页帧总数量,虚拟内存与物理内存均以4KB(x86 架构默认)为最小映射单元 进行划分,物理内存同时也是以 4KB 为最小管理单元 ,虚拟内存则以 4KB 为最小地址映射单元。精确地址就等于页帧号*4096+偏移地址 ,程序编译完成后,编译器会将所有变量、函数、指令的完整 32 位虚拟地址 固化到 ELF 二进制文件中,这是该 32 位整数的静态存储载体, 在进行页表映射过程中,按照4KB的最小单元规则,会提取前20位进行物理内存映射。这样的一组映射关系称为页表项。
虚拟地址向精确物理地址转化过程
MMU (CPU中负责进行虚拟地址和物理地址转化的硬件)从CR3 寄存器 (存当前进程页目录基物理地址,MMU寻址入口)拿到进程页目录基址后,先将 32 位虚拟地址拆分为高 10 位页目录索引 + 中间 10 位页表索引 + 低 12 位页内偏移 ;通过页目录索引找到对应页表的基地址,再通过页表索引找到目标页表项,提取页表项高 20 位物理页帧号 ,将物理页帧号左移 12 位 得到物理页帧起始地址,最后拼接页内偏移,即可得到访问的精确物理地址,全程由 MMU 硬件完成,无软件参与。
二、分页管理思想(核心逻辑)
不采用分页管理的痛点
透过之前对虚拟地址空间的了解,在32位Linux机器中,虚拟空间大小为4GB,其中[0,3]属于用户空间,[3,4]属于内核空间,由于虚拟地址无法真正进行存储,所以需要与物理内存产生映射关系,在物理内存中进行真正的存储,页表就是这张地址映射表,那么就很容易想到直接创建一整张表然后进行映射关系填入即可,但是为了严格保证进程间的独立性,需要为每一个进程都创建这样一张表,我们粗略对表大小进行计算4(字节)(单个页表项大小)*1024*1024(4GB对应页帧数量)=4MB,但是由于内存中可能同时存在大量进程,那么就需要大量创建这样的页表,但是对于4GB的虚拟内存,绝大部分软件是远远用不完的,那么也就造成了大量内存资源的浪费。
分页管理思想的切入
单张表进行映射的思想缺陷太过明显,于是就有了分页管理的思想
分页管理采用二级索引的思想,对1024*1024个目录项用页目录和页表进行分级保存,4GB对应页目录共有1024个,每一个页目录对应1024张页表,此时就可以进行动态申请,目录创建的内存成本大大降低。
成本对比(进程仅使用100个虚拟页):
-
单级页表:必须分配4MB连续内存(1M项×4字节),浪费4096KB;
-
二级页表:仅需分配4KB页目录 + 4KB页表(100个虚拟页落在1张页表内),总占用8KB,浪费仅4KB(单张页表的最小粒度冗余); 多进程场景下,二级页表的内存节约效果会成倍数放大(如100个进程可节约≈400MB物理内存)。
补充:页表创建全过程
-
硬件触发映射检测:CPU访问虚拟地址,MMU从CR3寄存器(存当前进程页目录基物理地址,MMU寻址入口)读取页目录基址,拆分地址查页表,发现无有效物理映射后触发缺页中断;CPU自动将**EIP寄存器**(存触发中断的内存访问指令地址)、通用寄存器值等现场保存至内核栈,切内核态并跳转中断处理函数。
-
内核地址合法性校验:内核校验触发中断的虚拟地址是否为进程合法地址,非法则触发段错误,进程直接崩溃。
-
内核分类判断缺页类型:内核识别缺页原因,要么是物理内存不足导致页帧数据被换出到磁盘,要么是地址合法但首次访问、未建立物理映射。
-
分配物理内存并更新映射:内核为该虚拟地址分配空闲物理内存,将磁盘数据加载/初始化空数据至物理内存;同时更新页表,把物理页帧号写入页表项并置存在位为1,完成虚拟→物理地址映射。
-
恢复现场重新执行:内核从内核栈恢复进程**EIP、通用寄存器**等原有现场信息,切回用户态;CPU重新执行原内存访问指令,此次MMU通过**CR3寄存器**查页表命中有效映射,正常访问物理内存。
