虚拟地址:为什么 CPU 看到的内存,不是内存条上真正的内存
从 02 那一章把 CPU 拉进长模式、打开分页开始,咱们写的每一段程序,用的就不再是内存条上真实的地址了——它看到的是一种叫「虚拟地址」的东西,要经一层翻译才能落到真实的物理内存上。可为什么要绕这么一层?直接用物理地址不是更简单吗?这一篇咱们就把虚拟地址到底是个什么东西、它解决了哪些硬问题、又是怎么被翻译过去的讲清楚,顺带把一个你会在后面反复见到的操作——刷 TLB——的来由说透。不铺这块底,后面 VMM 那一章你会觉得处处是咒语。
先从最朴素的办法讲起:直接用物理地址
要理解虚拟地址,得先想想「不用它」会怎样。
最直白的办法,是 CPU 直接用物理地址——也就是内存条上真实的地址。CPU 要读地址 0x1000,就到内存条的 0x1000 那个位置去取。简单,没中间商。早期的机器、以及现在的不少单片机,就是这么干的。
这套办法在「只跑一个程序」的时候毫无问题。可一旦你想在机器上同时跑好几个程序,麻烦就来了。
头一个麻烦是互相踩。程序 A 和程序 B 都觉得自己在用内存,可谁也不知道对方占了哪些物理地址。要是俩程序都往同一个物理地址写,后写的就把先写的冲掉了,谁也落不着好。你当然可以规定「A 用前半段、B 用后半段」,可这意味着每个程序在写的时候,就得知道自己被分配在了哪一段——程序一旦换个位置跑,它内部所有写死的地址全得改。
下一个麻烦是重定位。你编一个程序,它内部引用的地址(变量、函数)在编译时就得定下来。如果用物理地址,那你编译时根本不知道这程序将来会被加载到内存的哪个位置;可要是加载位置不固定,程序里那些地址就没法在编译时写死。
这两个麻烦合起来,把「多个程序共用一台机器」这件事变得极其别扭。于是硬件设计者想出了一个抽象——虚拟地址。
虚拟地址:给每个程序一套自己的、假想的地址空间
虚拟地址的核心想法,是 CPU 不再直接用物理地址,而是用一个假想的地址;这个假想地址,要通过一张叫页表的翻译表,才能换算成真实的物理地址。
关键在于,这张页表是每个进程一份的。于是,每个进程都拥有自己独立的一套虚拟地址空间——在进程 A 眼里,地址 0x4000 可能落在物理内存的某处;在进程 B 眼里,同一个 0x4000 完全可以落在物理内存的另一处。两个进程用着「同名」的虚拟地址,却互不干扰,因为它们各自走各自的页表,翻译到各自不同的物理页。
打个比方。虚拟地址像酒店里的房间号,物理地址像房间在楼层里的真实位置。这家「酒店」(物理内存)只有一份,但它同时接待好多「客人」(进程),每个客人都拿到一本自己的房间目录(页表):两个客人都说自己住 301,可这俩 301 是两间不同的真实房间——只是在各自那本目录里,它都叫 301。客人只认自己目录上的房间号,前台(页表)负责把房间号翻译成真实位置。
这套抽象一立起来,前面两个麻烦就都化解了。互相踩的问题没了:每个进程的虚拟地址经各自的页表翻译到不同的物理页,物理上根本不碰面。重定位的问题也没了:程序编译时统一用虚拟地址(比如「我的代码从虚拟地址 0x400000 开始」),至于这个虚拟地址最终落在哪个物理页,由加载它的内核填进页表决定,程序自己根本不用关心——同一份程序,今天加载、明天加载,虚拟地址永远不变,物理地址随便换。
顺带它还送了两个好处。一个是保护:页表项里带着权限位(可读、可写、用户态能不能访问),翻译时硬件会顺带检查,权限不对就触发异常——内核正是靠这个,把内核自己的地址空间标成「用户态不可访问」,挡住用户程序的越界。另一个是按需分配的种子:既然虚拟地址和物理页是靠页表挂上的,那「虚拟地址存在」和「物理页真给了」就可以解耦——先让虚拟地址登记在案,物理页等真正访问时再补,这就是后面按需分页的思路。
翻译:虚拟地址怎么变成物理地址
那么这张「翻译」具体怎么进行?这正是页表的职责,也是你会在 VMM 那一章反复看到的「走页表」。
内存被切成固定大小的页(咱们这套系统是 4 KiB 一页),虚拟地址空间和物理地址空间都按页来切。页表记录的就是「虚拟页号 → 物理页号」的对应关系。CPU 访问一个虚拟地址时,硬件会把它拆成两段:高位的虚拟页号(用来查页表),和低位的页内偏移(在页里的位置)。查到对应的物理页号,拼上同样的页内偏移,就得到了真实的物理地址。
因为一张「虚拟页号 → 物理页号」的全表太大(虚拟地址空间动辄几十上百 GB,全展开根本存不下),所以实际用的是多级页表——咱们 x86-64 上是四级。虚拟页号被进一步拆成四段,每段当一级表的索引,一级一级往下查:第一级查到第二级表的位置,第二级查到第三级,…… 最后一级才给出物理页号。这就像查电话簿时不翻一本厚厚的全表,而是先按省、再按市、再按区一级级缩小范围。具体的位拆分和每一级的细节,VMM 那一章会掰开讲,这里你只要记住一件事:翻译就是「虚拟地址 → 查多级页表 → 物理地址」。
这套翻译慢,于是有了 TLB
讲到这里你可能嗅到一个问题:每次访问内存,都要查四级页表,岂不是要额外读好几次内存?
没错,而且这是实打实的代价。最坏情况下,访一个虚拟地址要先读四次内存(每一级页表各一次)才能拿到物理地址,然后再读一次真正的数据——慢了一倍都不止。硬件当然不会坐视,它的对策是一块叫 TLB(Translation Lookaside Buffer,地址翻译后备缓冲)的小缓存,就做在 CPU 里。
TLB 是地址翻译结果的缓存。每次「虚拟页号 → 物理页号」被算出来一次,硬件就把它记进 TLB;下一次再访问同一个虚拟页,直接从 TLB 里取翻译结果,跳过那四级查表。因为程序访存通常有很强的局部性(来来回回就访问那么些页),TLB 的命中率极高,绝大多数翻译都能被它兜住,四级查表只是偶尔兜底的慢路径。
类比一下:页表是那本厚厚的、按省市区逐级查的电话簿;TLB 是前台桌上的一张速查卡,最近查过的几个号码抄在上面,再用就直接看卡,不用翻簿。簿一直在那儿(它是权威),卡只是用来加速。
为什么改了映射,就得「刷 TLB」
理解了 TLB 是缓存,后面那个反复出现的操作就好懂了。
TLB 既然是缓存,它里头记的就是旧的翻译结果。可页表是会改的——内核把一个虚拟页重新映射到另一个物理页,页表项变了,但 TLB 里那条旧翻译还赖着没走。要是不管它,CPU 下次访问这个虚拟页,会从 TLB 直接取到旧的物理页号,落到错的物理页上,数据就全乱了。
所以内核每次改动一个页表项,都得显式地把这条改动通知到 TLB——也就是把 TLB 里那条过期翻译作废掉,逼 CPU 下次重新走页表、拿到新翻译。这个「作废」操作,在 x86 上就是那条 invlpg 指令(在 Cinux 里封装成 flush_tlb),意思是「针对这一个虚拟地址,丢掉 TLB 里它的缓存」。你会在 VMM 的 map / unmap 里反复看到它:改完页表项,紧接着就是一发 flush,确保 CPU 立刻看到新映射,而不是沉迷在旧缓存里。
这一篇留下了什么
虚拟地址这套抽象,是后面整条内存线的地基。一句话收住:CPU 不直接用物理地址,而是用一套假想的虚拟地址,经多级页表翻译成物理地址——每个进程一份页表,于是各进程有各进程独立的虚拟地址空间,天然隔离、天然免重定位,还顺带能做权限保护和按需分配;而翻译有代价(要走多级页表),硬件就用 TLB 缓存翻译结果来兜速度;TLB 既然是缓存,内核每次改了页表项就得显式刷掉对应的旧翻译,不然 CPU 会照着过期缓存走到错的物理页。
带着这些,下一篇咱们就看 Cinux 的 VMM 怎么把这套「管理虚拟地址空间」的能耐做出来——它怎么走四级页表、怎么按需建中间表、怎么在改映射后正确地刷 TLB。