深入解析Linux页表:从虚拟地址到物理内存的映射艺术

目录

前言

一、

1.页表的由来

[2.struct page管理物理内存](#2.struct page管理物理内存)

page地址计算

3.页表的多级映射

"双刃剑"多级页表

[补充:catch缓存,也就是所谓的L1, L2, L3缓存。](#补充:catch缓存,也就是所谓的L1, L2, L3缓存。)

3.缺页异常

延迟申请

补充:越界不一定报错的情况

二、拆解虚拟地址

1.虚拟地址空间组成

补充:为什么采用低地址12位做偏移量,而不是高地址的12位?

2.链接之前的知识

本文总结



引言:虚拟内存的必要性

页表是Linux程序执行的重要一环,它实现了从进程地址空间到物理内存的映射,可你是否仔细思考过页表的工作机制?

我们极端假设进程地址空间中每一个字节,都通过页表与物理地址之间有着映射关系。以32位系统为例,那么页表为完成每一个字节的地址映射至少需要两个指针共8字节:一个存虚拟地址,一个存实际物理地址,32位即有2^32个字节,只是维持页表映射每个字节就需要8个字节存储映射关系,那么需要2^32 * 8 = 4294967296 * 8 = 4GB * 8 = 32GB的内存才能存下这些映射关系,这显然不可能!!

对于我们编写的程序,编译器在编译时默认都是从全0到全F的进程地址空间进行编址的,但这带来一个问题:如果内存中只有一个进程还好,可当有两个即以上的进程时难免会有地址冲突的情况发生。于是**页表的作用就突显而出了:将进程的虚拟地址通过某种方法映射到非一一对应的物理地址上。**当运行进程时,CPU通过页表映射找到代码数据的实际物理地址,这样即满足CPU的使用,又避免了进程间地址冲突。

那么操作系统究竟是如何组织页表,使他能管理好庞杂的虚拟进程地址与物理地址之间的映射的呢?


一、页表基础:从理论到实践

1.页表的由来

编译器编译产生的ELF文件中(即.o或可执行文件)代码数据是连续编址的(虚拟进程地址空间),可问题是当他加载到内存上,实际分配的物理内存空间也是连续的吗?这显然不可能,因为每一个程序的大小是不一样的,如果按照文件大小的申请多多大的内存,那么物理内存将会被分割成各种大小不同的块,这些程序运行结束物理内存回收后,就形成大小不一的内存空间,久而久之内存中将会充斥着大量的内存碎片。

怎么办?我们既希望操作系统提供给上层用户的空间是连续的,但是物理内存最好不要连续 ------进程虚拟地址与页表就应运而生了。

进程地址空间在之前的博客中已经介绍过,点击蓝字可阅读,这里介绍页表。以32位系统为例,操作系统会把物理内存以4KB为单位进行分割,每块4KB物理内存区间被称作页框,于是天然的每个页框能容纳一个4KB的页。

页框是是物理内存中的实际存储区域,常是4KB大小,也有2MB、1GB的大页;

页是虚拟地址空间中大小固定的一个数据块,通常是4KB与页框保持映射。

将进程虚拟地址中的页与实际物理地址中的页框建立映射关系,这就是页表,页表上上记录了每一 对页和页框的映射关系,能让CPU使用虚拟地址间接的页表通过访问物理内存地址。

页表是怎么解决上述内存碎片的问题呢?操作系统使用页表将ELF文件加载到内存后形成的连续的进程地址空间,通过页表映射到物理地址上,由于有一层映射关系,那么物理内存就可以不用连续,也就是说加载到物理内存的文件不用集中到一片区域,可以根据内存的使用情况分散到不同区域,只要维护好页表的映射关系。这样就解决了使用连续的物理内存造成的碎片问题。

2.struct page管理物理内存

32位系统下有2^32 个字节,也就是4GB空间,那么就有4GB / 4KB = 1048576 个页框,为了管理这些物理页,操作系统使用struct page 来描述这些物理页的属性信息,以便于将他们统一管理起来。每一个物理页框都有一个struct page描述。

cpp 复制代码
struct page
{
    ......
    flags;
    ......
    _mapcount;
    ......
    _virtual;
    ......
};

struct page的内核源码十分复杂,有兴趣的读者可通过下述方式查看,本文介绍其中的重要成员。

提示:中途可通过ctrl + c终止

flags:

struct page中的flag 成员用来存放页的状态。它采用位图的方式记录物理页的状态,每一位比特位单独表示一种状态,所以它至少可以同时表示出32种不同的状态。这些状态包括页是不是被使用过的,是不是被锁定在内存中不允许换出内存等等。

_mapcount:

表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变 为-1时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。

_virtual:

用于将物理地址转换成逻辑地址(虚拟地址)。

操作系统使用数组的方式将众多的page管理起来,即struct page mem1048567 。你可能会问这么大个数组会占用多少内存呢 ?由于struct page中使用了大量的联合体union,所以一个page的大小并不算太大,实际大概60字节左右(随不同内核版本而不同),那么struct page mem1048567结构体消耗的内存大概 60MB 左右,相对32位系统 4GB 内存而言,仅是很小的一部分,用这很小的一部分内存消耗就能管理整个物理内存是非常划算的。

page地址计算

struct page mem1048567这么大的数组,操作系统如何精准定位到每个page的物理地址呢?

我们知道一个page代表着一个4KB物理页框,那么用数组下标 * 4KB就可以得到每个page的起始物理地址;

而page中每个字节 的地址,又可以用page首地址 + 偏移量得到,所以操作系统无需存储每个page的地址,通过计算便可得到整个物理内存中所以的page乃至每个字节的地址。

至于操作系统是如何得知每个字节在page中的偏移量大小,将在下文"拆解虚拟地址"中介绍。

3.页表的多级映射

我们已经知道页表是进程虚拟地址与物理地址之间转化的关键一环,现在介绍它是如何将2^32字节的虚拟地址映射到物理地址上的。

如前言中所述,如果页表存储每一个字节的地址映射关系,那么所需要的内存将会大到不可接受。但如果只存储page的首地址呢?32位系统下一共有1048576个页框,一个地址4字节,那么需要 1048576 * 4 = 4MB大小,也就是说如果只存储每个page的首地址,那么仅需要一片连续的4MB = 1024个page大小的内存即可。相较于存储每个一个字节的映射关系,只存储page首地址无疑大大减小所需空间,但依旧有个缺点:需要连续的1024个page。回想上述页表出现的原因就是要解决程序加载需要连续物理地址,而页表本身却需要一大片连续的地址空间,这无疑依旧有待优化的地方,怎么办呢?

于是操作系统使用了页表的多级映射。解决页表需要连续物理地址的最好方法是:把页表看成普通的文件,对页表再分页, 由此形成多级页表的思想。

现在总结一下,我们让页表的每一个表项指向实际物理内存中的一个page,但如果只用一个页表整体存储1048576个page需要1024个连续的page,这与页表的设计初衷不符,于是采用多级页表的方式将页表进行二次映射。

于是我们将表项指向实际物理内存page的页表叫做二级页表 ,每个二级页表占用一个page,即一个二级页表可以指向4KB / 4B =1024 个物理page;

我们将管理页表的表称之为页目录表,页目录的每一个表项指向着一个二级页表的首地址。页目录也只占一个page,所以一个页目录可以指向1024个二级页表:1024 * 1024 = 1048576正好可以将32位系统中4GB / 4 KB = 1048576个page完成映射,并且解决了单张页表需要一片连续物理地址的问题。

实际上一个程序不可能将进程地址空间的4GB用完,一般用几十个页表就差不多了,也就是说上述的多级页表并不是完整的使用。比如一个二级页表可以存储1024个page的映射关系,4KB*1024 = 4MB,一个10MB的可执行程序用上三个二级页表就装下了。

"双刃剑"多级页表

多级页表解决了单张页表的缺陷,但随之也带来了一定的缺陷。下面总结多级页表的优缺点。

优点:

①解决单张页表需要连续存储要求;

②节省内存:可根据实际程序大小灵活分配二级页表,无需将整个4GB映射,节省内存;

③支持大规模地址空间:64位系统,即2^64地址空间(16EB),单级页表不可能实现,但可采用多级页表的方式完成虚拟地址到物理地址的映射;

④灵活的权限管理:所指page数据设置权限为可读、可行等,以及划分用户空间内核空间。

缺点:

访问效率降低,过程开销增大:MMU需要多次读取内存,随着层级增加访问开销线性增大。比如二级页表映射过程,MMU读页目录------>内存------>MMU读二级页表------>内存数据------>CPU;

MMU:集成在中央处理器中的一种硬件,用于虚拟地址到物理地址的映射解析。

那有没有在使用多级页表的情况下,提升访问效率的办法呢?------MMU中引入一段快表缓存TLB( Translation Lookaside Buffer**)**,当 CPU 给 MMU 传新虚拟地址之后, MMU 先去快表缓存那边有没有记录,如果有就直接拿到物理地址;如果没有再走页表映射路线,找到实际目标数据后再将它记录进快表缓存,方便下次快速访问。

补充: Cache缓存 ,也就是所谓的L1, L2, L3缓存。

TLB用于缓存最近的地址转换结果,得到物理地址后CPU会先检查各级缓存(L1, L2, L3)中是否缓存了该物理地址对应的数据,如果有则直接读取,否则需要访问内存。所以它们是串行关系:先TLB(地址转换),再缓存(数据获取)。所以挑选处理器时不仅要看CPU的主频,也要看它的Cache缓存大小如何,过小的catch必然使得CPU频繁访问内存导致性能下降。

值得一提的是TLB与Cache的结合,正好符合了局部性原理(时间局部性和空间局部性)。

3.缺页异常

当MMU在TLB以及多级页表中都找不到对应的物理地址时,就会触发缺页异常。前文说到过异常其实是中断的一种,当发生异常进程会从用户态转入内核态,根据异常的种类在中断向量表IDT中查询相关处理方法。

下面介绍常见的缺页异常种类:

硬缺页(Hard Page Fault):数据没有从磁盘加载到内存,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立虚拟 地址和物理地址的映射。

软缺页(Soft Page Fault):数据已经加载到内存,但没有建立映射(比如多进程用同一个动态库文件),MMU只需要建立映射即可。

无效缺页(Invalid Page Fault):越界访问或野指针解引用,内核会报 segment fault 中断,直接终止进程。

延迟申请

实际上我们在语言层面使用的malloc/new等内存申请函数,申请出来的空间都是虚拟进程地址空间,其实操作系统并没用立即在物理内存中开辟相同大小的内存块,而是等到程序执行时发生硬缺页中断才申请相应物理内存,建立页表的映射关系后才继续执行相关代码。这样做的目的是提高内存的利用率,毕竟在申请内存到正式使用之间还有着不少时间。

同理 fork 创建子进程时的写时拷贝 也是如此,操作系统并没有在物理内存立即申请一块空间用来存储子进程的代码与数据,而是将父子进程的页表权限修改为只读,两个进程的页表映射同一块物理内存。当父子进程中某一方将会对数据进行修改,发现权限不匹配报出页面错误(Page Fault)异常,此时再将要更改的数据给父或者子进程单独准备一份,更改页表权限以供修改。

补充:越界不一定报错的情况

越界通常指访问了不属于进程的内存区域,而越界也分情况:

①当访问的地址没有映射到进程的地址空间,或者访问权限不足 ,Linux操作系统会发送SIGSEGV信号终止进程,在windowd会显式弹出窗口显示segmentation fault / 段错误;

②如果越界访问的地址恰好位于进程已映射的地址空间内,并且具有相应的读写权限,那么访问将成功,但会破坏原本在此的数据。

实际上一些编译器在分配内存时可能会进行对齐,导致分配的实际内存比请求的多。因此,稍微越界可能不会立即触发错误,但依旧有极大的风险造成数据丢失,而且这种Bug编译器还检查不出。

二、拆解虚拟地址

1.虚拟地址空间组成

上文较为系统的介绍了页表的实际工作原理,而此次则介绍进程虚拟地址究竟是怎么转化成物理地址的。

在32位系统中,一个地址有32个比特位,实际上所谓的虚拟地址不止是进程地址空间【0-4】GB里面的某个地址,还可以看成是由多级页表索引和页内偏移组成的。从进程视角看,它是连续的地址空间【0-4GB】中的某个位置;从硬件视角看,它的二进制位被解释为多级页表索引和页内偏移的组合。

怎么理解呢?**虚拟地址的高10位比特位,记录的是在页目录中的索引,它指向一个二级页表首地址。**什么意思?这个索引可以看作页目录中的偏移量,MMU可以拿着这个索引再结合CR3寄存器中的页目录物理内存首地址,从而找到目标二级页表的物理首地址。

**虚拟地址的中10位比特位,记录的是二级页表中的索引,它指向着一个page首地址。**同理,结合高10位找到的二级页表首地址,加上索引就能找到目标page的物理首地址。

**虚拟地址的后12位比特位,记录的是目标数据/代码相较于page首地址的偏移量。**在得到page的首地址后加上偏移量就能找到目标数据。

为什么是10+10+12的组合?因为页目录与二级页表最多有1024个页表项,2^10 == 1024,而一个物理页page中有4KB,2^12 == 4096。

cpp 复制代码
示例:虚拟地址0x08048000的转换
虚拟地址:0x08048000 = 0000 1000 0000 0100 1000 0000 0000 0000
分解:
- 页目录索引:高10位 0000100000 = 0x20
- 页表索引:中10位 0000010010 = 0x12 
- 页内偏移:低12位 000000000000 = 0x000

假设:
- 页目录物理地址:0x1000
- 页目录项[0x20]:指向页表物理地址0x5000
- 页表项[0x12]:指向物理页框0x12345
- 最终物理地址:0x12345000 + 0x000 = 0x12345000

补充:为什么采用低地址12位做偏移量,而不是高地址的12位?

这里采用低地址原因是由于被前面的高20位影响的,什么意思?如果有两个地址,它们前面20位比特位相同而后12位不同,那它们两个一定是在同一个4KB page页框里。比如一个数组char buffer 128,其中的元素地址是地址的,地址0x08048000, 0x08048001, 0x08048002, ......

两个相邻元素是在同一个page中。如果采取高12位做偏移量,那么地址递增导致的结果将是两个相邻元素是在不在一个page,甚至不在一个二级页表中,这样无疑破坏了局部性原理并且操作系统在读取数据时将会增加额外的损耗。

2.链接之前的知识

①为什么共享内存的大小要设置成4096的整数倍?因为物理内存中以4KB = 4096B为基本单位,将共享内存的大小设置成4096的整数倍既方便操作系统设置page属性,又节省空间减少内存碎片的产生;

②操作系统中被打开的文件是怎么找到它在物理内存中的地址的?通过观察发现struct file中有个address_space指针成员,struct address_space里有个struct radix_tree,这个树将所有的page管理起来,radix_tree的树节点radix_tree_node里有个void * slots ,这个slots的每一个成员都指向了struct page的指针,由此操作系统就能通过file找到它实际物理地址的位置。

同时这也是为什么进程挂接共享内存后,操作系统会为其创建struct file并指向共享内存struct file的原理。

③.o文件在合并成可执行文件时,为什么要将节合并成段?如果每个节都单独映射到一个内存页,那么对于许多小的节,可能会浪费大量内存。合并具有相同权限的节到一个段,可以使得它们共享一个或多个内存页,提高内存利用率。同时,将具有相同访问模式的节放在一起,可以利用局部性原理,将只读的代码节放在一起,可以提高指令缓存的效率;将可读写的全局变量放在一起,可以提高数据缓存的效率。


本文总结

本文较为深入的解析了Linux页表的工作原理及其重要性。

本文首先介绍了页表的由来,以及page的计算方法,之后介绍了页表通过多级映射机制(页目录+二级页表)将虚拟地址转换为物理地址,避免了直接存储每个字节映射关系的内存消耗(32位系统仅需约4MB)。

之后文章详细介绍了struct page结构对物理内存的管理方式、多级页表的优缺点(提升灵活性但增加访问开销),以及通过TLB和缓存优化的访问效率。同时探讨了缺页异常处理机制和写时拷贝技术,解释了虚拟地址的10+10+12位组成原理及其与物理内存的映射关系。最后链接了共享内存设置、文件地址查找等实际应用场景,全面展现了页表在内存管理中的核心作用。

笔者水平浅薄,文中若有错误缺漏的地方,万望读者指出。

读完点赞,手留余香~

相关推荐
律宏阔5 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔5 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴5 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千6 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei376 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei376 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei376 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
judezh6 小时前
api 容器一直 unhealthy?我把一个 Agent 运行时的健康检查逐条拆了,查出四个问题
运维·docker
GeW6 小时前
制造业高端化,为什么必须重估Linux和数据库?
linux
百万蹄蹄向前冲6 小时前
双端同步!云服务器装最新Node.js v26.10全过程追踪
服务器·人工智能·node.js