使用硬件内存保护对 64 位硬件进行数组边界检查

Array bounds checks on 64-bit hardware using hardware memory-protection

我在 hacks.mozilla.org 上阅读了一篇关于 64 位 Firefox 版本的博客。

作者声明:

For asm.js code, the increased address space also lets us use hardware memory protection to safely remove bounds checks from asm.js heap accesses. The gains are pretty dramatic: 8%-17% on the asmjs-apps-*-throughput tests as reported on arewefastyet.com.

我试图了解 64 位硬件如何为 C/C++ 进行自动边界检查(假设编译器支持硬件)。我在 SO 中找不到任何答案。我找到了 one technical paper on this subject,但我无法理解这是如何完成的。

有人可以解释 64 位硬件辅助边界检查吗?

他们使用的技术类似于 Windows 页面堆调试模式,只是不是将每个 VirtualAlloc() 粘在其自己的虚拟内存页面中的堆,这是一个粘附的系统每个数组(静态或基于堆栈)在其自己的虚拟内存页面中(更准确地说,它将分配放在页面的 end 处,因为 运行一个数组比尝试在它开始之前访问要常见得多);然后它会在分配页面之后放置一个不可访问的 "guard page",甚至在它们的情况下放置大量页面。

因此,边界检查不是问题,因为越界访问将触发访问冲突 (SIGSEGV) 而不是破坏内存。这在早期的硬件上是不可能的,因为 32 位机器只有 1M 页面可以使用,这不足以处理非玩具应用程序。

大多数现代 CPUs 实现虚拟 addressing/virtual 内存 - 当程序引用特定地址时,该地址是虚拟的;到物理页面的映射(如果有的话)是由 CPU 的 MMU(内存管理单元)实现的。 CPU 通过在 page table the OS set up for the current process. These lookups are cached by the TLB 中查找将每个虚拟地址转换为物理地址,因此大多数时候没有额外的延迟。 (在某些非 x86 CPU 设计中,TLB 未命中由 OS 在软件中处理。)

所以我的程序访问地址 0x8050,它位于虚拟页面 8(假设标准 4096 字节 (0x1000) 页面大小)。 CPU 看到虚拟页 8 映射到物理页 200,因此在物理地址 200 * 4096 + 0x50 == 0xC8050 处执行读取。

当 CPU 没有该虚拟地址的 TLB 映射时会发生什么?这种事情经常发生,因为 TLB 的大小有限。答案是 CPU 生成 页面错误 ,由 OS.

处理

页面错误可能会导致多种结果:

  • 一、OS会说"oh, well it just wasn't in the TLB because I couldn't fit it"。 OS 从 TLB 中逐出一个条目并使用进程的页面 table 映射填充新条目,然后让进程保持 运行ning。这种情况在中等负载的机器上每秒发生数千次。 (在具有硬件 TLB 未命中处理的 CPU 上,如 x86,这种情况是在硬件中处理的,甚至不是 "minor" 页面错误。)
  • 二、OS可以说"oh, well that virtual page isn't mapped right now because the physical page it was using was swapped to disk because I ran out of memory"。 OS 挂起进程,找到一些内存使用(可能通过换出一些其他虚拟映射),为请求的物理内存排队磁盘读取,当磁盘读取完成时,用新填充的内存恢复进程页 table 映射。 (这是一个"major" page fault。)
  • 三,进程正在尝试访问不存在映射的内存 - 它正在读取不应该的内存。这通常称为分段错误。

相关案例是第 3 个。当发生段错误时,操作系统的默认行为是中止进程并执行诸如写出核心文件之类的操作。然而,允许一个进程捕获它自己的段错误并尝试处理它们,甚至可能不停止。这就是事情变得有趣的地方。

我们可以利用这一优势来执行 'hardware accelerated' 索引检查,但我们在尝试这样做时遇到了一些障碍。

首先,总体思路:对于每个数组,我们将其放在自己的虚拟内存区域中,所有包含数组数据的页面都像往常一样被映射。在真实数组数据的两边,我们创建不可读和unwritable的虚拟页面映射。如果您尝试读取数组之外的内容,则会产生页面错误。编译器在编写程序时插入自己的页面错误处理程序,并处理页面错误,将其变成索引越界异常。

第一个障碍 是我们只能将整个页面标记为可读或不可读。数组大小可能不是页面大小的偶数倍,所以我们遇到了一个问题——我们不能恰好在数组末尾之前和之后放置栅栏。我们能做的最好的事情就是在数组开头之前或数组结尾之后在数组和最近的 'fence' 页之间留一个小间隙。

他们如何解决这个问题?好吧,在 Java 的情况下,编译执行负索引的代码并不容易;如果是这样,那也没关系,因为负索引被视为无符号索引,这使索引远远领先于数组的开头,这意味着它很可能会命中未映射的内存并且无论如何都会导致错误.

所以他们所做的是对齐数组,使数组的末尾正好靠在页面的末尾,就像这样('-' 表示未映射,'+' 表示映射):

-----------++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
|  Page 1  |  Page 2  |  Page 3  |  Page 4  |  Page 5  |  Page 6  |  Page 7  | ...
                 |----------------array---------------------------|

现在,如果索引超过数组末尾,它将到达未映射的第 7 页,这将导致页面错误,这将变成索引越界异常。如果索引在数组的开头之前(即它是负数),那么因为它被视为无符号值,它会变得非常大且为正数,再次使我们远离第 7 页,从而导致未映射的内存读取,从而导致页面错误,这将再次变成索引越界异常。

第 2 个绊脚石 是我们确实应该在映射之前在数组末尾留下 大量 未映射的虚拟内存下一个对象,否则,如果索引超出范围,但超出范围很远很远,它可能会命中有效页面并且不会导致索引越界异常,而是会任意读取或写入内存。

为了解决这个问题,我们只使用了大量的虚拟内存——我们将每个数组放入其自己的 4 GiB 内存区域,其中只有前 N 几页实际被映射。我们可以这样做是因为我们只是在这里使用 地址 space,而不是实际的物理内存。一个 64 位进程有大约 40 亿块 4 GiB 的内存区域,所以在我们 运行 之前我们有足够的地址 space 可以使用。在 32 位 CPU 或进程上,我们可以使用的地址 space 很少,因此这种技术不太可行。事实上,今天许多 32 位程序 运行 宁出虚拟地址 space 只是试图访问实际内存,更不用说试图映射空 'fence' 页 space 尝试用作 'hardware accelerated' 索引范围检查。