第五章:计算机中的翻译器

@高效码农  September 8, 2023

到目前为止,每次我谈到阅读和写作记忆时都有点空洞。例如,ELF 文件指定要加载数据的特定内存地址,那么为什么不同进程尝试使用冲突内存时不会出现问题呢?为什么每个进程似乎都有不同的内存环境?

另外,我们到底是怎么到这里的?我们知道这execve是一个用新程序替换当前进程的系统调用,但这并不能解释如何启动多个进程。它绝对没有解释第一个程序是如何运行的——哪只鸡(进程)产下(产卵)所有其他鸡蛋(其他进程)?

我们的旅程即将结束。回答完这两个问题后,我们将对您的计算机如何从启动到运行您现在正在使用的软件有一个大致完整的了解。

内存是假的

那么……关于记忆。事实证明,当 CPU 读取或写入内存地址时,它实际上并不是指物理内存 (RAM) 中的该位置。相反,它指向虚拟内存空间中的一个位置。

CPU 与称为内存管理单元(MMU) 的芯片进行通信。MMU 的工作方式就像一个带有字典的翻译器,将虚拟内存中的位置翻译为 RAM 中的位置。当 CPU 收到一条从内存地址读取的指令时0xfffaf54834067fe2,它会要求 MMU 转换该地址。MMU在字典中查找,发现匹配的物理地址是0x53a4b64a90179fe2,并将该数字发送回CPU。然后 CPU 可以从 RAM 中的该地址读取数据。
virtual.png
一张微笑的 CPU 和 MMU 进行对话的图。 MMU 是一个很高的芯片,戴着图书馆眼镜,拿着一本标有“字典:指针 0x0000 到 0xffff”的大书。 CPU 要求 MMU 转换长内存地址。 MMU 想了一会儿,然后用不同的指针进行响应。]

当计算机首次启动时,内存访问直接进入物理 RAM。启动后,操作系统立即创建翻译字典并告诉CPU开始使用MMU。

这个字典实际上称为页表,这种翻译每次内存访问的系统称为分页。页表中的条目称为,每个页代表虚拟内存的某一块如何映射到 RAM。这些块始终具有固定大小,并且每个处理器架构具有不同的页面大小。x86-64 的默认页面大小为 4 KiB,这意味着每个页面指定 4,096 字节长的内存块的映射。

换句话说,对于 4 KiB 页面,地址的底部 12 位在 MMU 转换前后始终相同 - 12,因为这是索引转换后获得的 4,096 字节页面所需的位数。

x86-64 还允许操作系统启用更大的 2 MiB 或 4 GiB 页面,这可以提高地址转换速度,但会增加内存碎片和浪费。页大小越大,MMU 转换的地址部分就越小。
2023-09-08T05:51:53.png
4 KiB 分页内存地址的细分。 最低 12 位索引页面,其余位由 MMU 转换并成为页面的起始地址。

页表本身仅驻留在 RAM 中。虽然它可以包含数百万个条目,但每个条目的大小仅为几个字节的数量级,因此页表不会占用太多空间。

为了在启动时启用分页,内核首先在 RAM 中构建页表。然后,它将页表起始的物理地址存储在称为页表基址寄存器(PTBR)的寄存器中。最后,内核启用分页以通过 MMU 转换所有内存访问。在 x86-64 上,控制寄存器 3 (CR3) 的前 20 位用作 PTBR。CR0 的位 31(指定用于分页的 PG)设置为 1 以启用分页。

分页系统的神奇之处在于可以在计算机运行时编辑页表。这就是每个进程可以拥有自己独立的内存空间的方式——当操作系统将上下文从一个进程切换到另一个进程时,一项重要的任务是将虚拟内存空间重新映射到物理内存中的不同区域。假设您有两个进程:进程 A 可以在 处获取其代码和数据(可能从 ELF 文件加载!)0x0000000000400000,进程 B 可以从同一地址访问其代码和数据。这两个进程甚至可以是同一程序的实例,因为它们实际上并没有争夺该地址范围!进程A的数据在物理内存中距离进程B较远的地方,0x0000000000400000在切换到进程时由内核映射到。
2023-09-08T05:52:30.png
该图显示了两个不同的进程,要求拟人台式计算机的俗气剪贴画图像转换相同的内存地址。 拟人计算机使用物理内存的连续条带内的不同部分来响应每个进程。

旁白:被诅咒的 ELF 事实

在某些情况下,binfmt_elf必须将内存的第一页映射为零。一些为 UNIX System V Release 4.0 (SVr4)(这是 1988 年第一个支持 ELF 的操作系统)编写的程序依赖于空指针的可读性。不知何故,一些程序仍然依赖于这种行为。

似乎实现这一点的 Linux 内核开发人员有点不满

“你问为什么这样???SVr4 将第 0 页映射为只读,并且某些应用程序“依赖于”此行为。由于我们没有能力重新编译它们,因此我们模拟 SVr4 行为。叹。”

叹。

寻呼安全性

内存分页启用的进程隔离改进了代码人体工程学(进程不需要知道其他进程即可使用内存),但它也创建了一定程度的安全性:进程无法从其他进程访问内存。这一半回答了本文开头的原始问题之一:

如果程序直接在CPU上运行,并且CPU可以直接访问RAM,为什么代码不能从其他进程访问内存,或者,上帝保佑,不能从内核访问内存?

还记得吗?感觉好像是很久以前的事情了……

那么内核内存呢?首先,内核显然需要存储大量自己的数据来跟踪所有正在运行的进程,甚至页表本身。每次触发硬件中断、软件中断或系统调用并且 CPU 进入内核模式时,内核代码都需要以某种方式访问​​该内存。

Linux的解决方案是始终将虚拟内存空间的上半部分分配给内核,因此Linux被称为上半部分内核。Windows 采用了类似的技术,而 macOS 则……稍微 复杂一些 ,读到它时我的大脑已经从耳朵里流出来了。〜(++)〜[](https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/vm/vm.html)
2023-09-08T05:53:11.png
将虚拟内存空间显示为条带的图表。 左半部分标记为用户空间:执行程序的内存。 右半部分被标记为内核空间:用于与内核相关的所有内容的固定区域。 分割两个段的中间点标记为内存地址 0x8000000000000000。

如果用户态进程可以读取或写入内核内存,那么安全性将非常糟糕,因此分页启用了第二层安全性:每个页面必须指定权限标志。一个标志决定该区域是可写还是只读。另一个标志告诉 CPU 仅允许内核模式访问该区域的内存。后一个标志用于保护整个上半部分内核空间 - 整个内核内存空间实际上在用户空间程序的虚拟内存映射中可用,只是它们没有访问它的权限。
2023-09-08T05:53:37.png
页表项权限表。 现在:真实。 读/写:只读。 用户/内核:所有模式。 脏:假。 已访问:真实。 等等。

页表本身实际上包含在内核内存空间中!当定时器芯片触发硬件中断进行进程切换时,CPU将特权级切换到内核模式并跳转到Linux内核代码。处于内核模式(Intel 环 0)允许 CPU 访问内核保护的内存区域。然后,内核可以写入页表(位于内存上半部分的某个位置),为新进程重新映射虚拟内存的下半部分。当内核切换到新进程并且CPU进入用户模式时,它不能再访问任何内核内存。

几乎每次内存访问都会经过 MMU。中断描述符表处理程序指针?这些也解决了内核的虚拟内存空间。

分层分页和其他优化

64 位系统的内存地址为 64 位长,这意味着 64 位虚拟内存空间的大小高达 16 艾字节。这是令人难以置信的大,远远大于当今存在或即将存在的任何计算机。据我所知,有史以来 RAM 最多的计算机是Blue Waters 超级计算机,其 RAM 超过 1.5 PB。这仍不到 16 EiB 的 0.01%。

如果虚拟内存空间的每 4 KiB 部分都需要页表中的一个条目,则需要 4,503,599,627,370,496 个页表条目。对于 8 字节长的页表条目,仅存储页表就需要 32 PEB 的 RAM。您可能会注意到,这仍然比计算机中最大 RAM 的世界纪录还要大。

旁白:为什么要用奇怪的单位?

我知道这并不常见,而且非常丑陋,但我发现清楚地区分二进制字节大小单位(2 的幂)和公制字节大小单位(10 的幂)很重要。千字节 (kB) 是 SI 单位,表示 1,000 字节。千字节 (KiB) 是 IEC 推荐的单位,表示 1,024 字节。就 CPU 和内存地址而言,字节数通常是 2 的幂,因为计算机是二进制系统。使用 KB(或更糟糕的是 kB)来表示 1,024 会更加含糊。

由于不可能(或者至少是极其不切实际)为整个可能的虚拟内存空间提供连续的页表条目,因此 CPU 架构实现了分层分页。在分层分页系统中,页表有多层,粒度越来越小。顶级条目覆盖大内存块并指向较小块的页表,从而创建树结构。4 KiB 块或任何页面大小的各个条目都是树的叶子。

x86-64 历史上使用 4 级分层分页。在该系统中,每个页表条目是通过将包含表的起始位置偏移地址的一部分来找到的。该部分以最高有效位开始,作为前缀,因此该条目涵盖以这些位开头的所有地址。该条目指向包含该内存块的子树的下一级表的开始,这些子树再次使用下一个位集合进行索引。

x86-64的4级分页的设计者还选择忽略所有虚拟指针的前16位以节省页表空间。48 位为您提供 128 TiB 虚拟地址空间,这被认为足够大。(完整的 64 位将为您提供 16 EiB,这有点多了。)

由于前 16 位被跳过,用于索引第一级页表的“最高有效位”实际上从第 47 位开始,而不是第 63 位。这也意味着本章前面的上半内核图在技术上是不准确的;内核空间起始地址应该被描述为小于 64 位的地址空间的中点。
2023-09-08T05:54:02.png
x86-64 上 4 级分页的大型、详细、全彩图表。 它描述了页表的四个级别,突出显示了每个级别上充当“前缀”的位。 它还显示通过将这些前缀位的值添加到表的基地址来索引的表。 每个表中的条目都指向下一个表的开始,但最终级别 1 除外,它指向 RAM 中 4 KiB 块的开始。 MMU 将最低 12 位添加到该地址以获得最终的物理地址。 有一张 4 级表、n 方 3 级表,依此类推。

分层分页解决了空间问题,因为在树的任何级别,指向下一个条目的指针都可以为 null ( 0x0)。这允许删除页表的整个子树,这意味着虚拟内存空间的未映射区域不会占用 RAM 中的任何空间。对未映射内存地址的查找可能会很快失败,因为 CPU 一旦在树的较高位置看到空条目就会出错。页表条目还有一个存在标志,可用于将它们标记为不可用,即使地址看起来有效。

分层分页的另一个好处是能够有效地切换大部分虚拟内存空间。对于一个进程,一大片虚拟内存可能会映射到物理内存的一个区域,而对于另一个进程则可能会映射到不同的区域。内核可以将这两个映射存储在内存中,并在切换进程时简单地更新树顶层的指针。如果整个内存空间映射存储为平面条目数组,则内核将必须更新大量条目,这会很慢,并且仍然需要独立跟踪每个进程的内存映射。

我说 x86-64“历史上”使用 4 级分页,因为最近的处理器实现了5 级分页。5 级分页添加了另一级间接寻址以及另外 9 个寻址位,以将地址空间扩展到具有 57 位地址的 128 PiB。自 2017 年以来,包括 Linux 在内的操作系统以及最新的 Windows 10 和 11 服务器版本均支持 5 级分页。

旁白:物理地址空间限制

正如操作系统不使用所有 64 位虚拟地址一样,处理器也不使用整个 64 位物理地址。当 4 级分页成为标准时,x86-64 CPU 不会使用超过 46 位,这意味着物理地址空间仅限于 64 TiB。通过5级分页,支持已扩展到52位,支持4 PiB物理地址空间。

在操作系统层面,虚拟地址空间大于物理地址空间是有利的。正如莱纳斯·托瓦兹 (Linus Torvalds)所说,“它需要更大,至少是两倍  坦率地说,这是在推动它,而且如果是十倍或更多,效果会更好。任何不明白这一点的人都是白痴。讨论完毕。”

交换和请求分页

内存访问可能会因多种原因而失败:地址可能超出范围,页表可能未映射它,或者它可能有一个标记为不存在的条目。在任何这些情况下,MMU都会触发一个称为页面错误的硬件中断,让内核处理问题。

在某些情况下,读取确实无效或被禁止。在这些情况下,内核可能会因分段错误错误而终止程序。

外壳会话

$ ./program
Segmentation fault (core dumped)
$
旁白:段错误本体

“分段错误”在不同的上下文中意味着不同的事情。当未经许可读取内存时,MMU 会触发称为“分段错误”的硬件中断,但“分段错误”也是操作系统可以向正在运行的程序发送的信号名称,以因任何非法内存访问而终止它们。

在其他情况下,内存访问可能会故意失败,从而允许操作系统填充内存,然后将控制权交还给 CPU 重试。例如,操作系统可以将磁盘上的文件映射到虚拟内存,而无需实际将其加载到 RAM 中,然后在请求地址并发生页面错误时将其加载到物理内存中。这称为请求分页
2023-09-08T05:54:40.png
关于如何通过硬件中断实现请求分页的三面板漫画风格图。 面板 1:CPU 与 MMU 进行对话。 CPU 说“读 0xfff”,MMU 看起来很困惑,然后 MMU 向 CPU 发送一个标记为“页面错误!”的闪电信号。 面板 2 标记为“页面错误处理程序”并具有锯齿形轮廓。 它描述了 CPU 将一些数据加载到 RAM 中,然后从中断中返回。 最后,面板 3 又回到了 CPU 和 MMU 的对话。 MMU 心里想:“哦嘿,页面现在已经存在了。” 它回复 CPU 的原始请求:“这是你的内存!” CPU说谢谢。

其一,这允许诸如mmap之类的系统调用将整个文件从磁盘延迟映射到虚拟内存的存在。如果您熟悉 LLaMa.cpp(泄露的 Facebook 语言模型的运行时),Justine Tunney 最近通过使所有加载逻辑都使用 mmap对其进行了显着优化。(如果您以前没有听说过她,请查看她的资料!Cosmopolitan Libc 和 APE 真的很酷,如果您喜欢这篇文章,可能会很有趣。)

显然,贾斯汀参与这一变化有很多戏剧性 的内容 只是指出这一点,这样我就不会被随意的互联网用户尖叫。我必须承认我还没有读完这部剧的大部分内容,我所说的贾斯汀的东西很酷的一切仍然是非常真实的。[](https://news.ycombinator.com/item?id=35458004)

当您执行程序及其库时,内核实际上不会将任何内容加载到内存中。它仅创建文件的 mmap — 当 CPU 尝试执行代码时,页面会立即出错,内核会用真实的内存块替换该页面。

请求分页还支持您可能在“交换”或“分页”名称下见过的技术。操作系统可以通过将内存页写入磁盘,然后将其从物理内存中删除,但将它们保留在虚拟内存中并将当前标志设置为 0 来释放物理内存。如果读取该虚拟内存,操作系统可以从磁盘恢复内存到 RAM 并将当前标志设置回 1。操作系统可能必须交换 RAM 的不同部分,以便为从磁盘加载的内存腾出空间。磁盘读写速度很慢,因此操作系统尝试通过高效的页面替换算法尽可能少地发生交换。

一个有趣的技巧是使用页表物理内存指针来存储物理存储中文件的位置。由于 MMU 一旦看到负的当前标志就会出现页面错误,因此它们是否是无效的内存地址并不重要。这并不适用于所有情况,但想想很有趣。



评论已关闭