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

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

本文关键字:硬件 数组 边界 检查 64位 内存保护      更新时间:2023-10-16

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

作者指出:

对于asm.js代码,增加的地址空间还允许我们使用硬件内存保护来安全地从asm.js堆访问中删除边界检查。收益相当可观:据arewefastyet.com报道,asmjs应用程序的吞吐量测试的收益为8%-17%。

我试图了解64位硬件如何对C/C++进行自动边界检查(假设编译器支持硬件)。我在SO中找不到任何答案。我找到了一篇关于这个主题的技术论文,但我不知道如何做到这一点。

有人能解释一下64位硬件辅助边界检查吗?

大多数现代CPU实现虚拟寻址/虚拟内存-当程序引用特定地址时,该地址是虚拟的;到物理页面的映射(如果有的话)由CPU的MMU(存储器管理单元)实现。CPU通过在操作系统为当前进程设置的页面表中查找每个虚拟地址,将其转换为物理地址。这些查找是由TLB缓存的,所以大多数时候没有额外的延迟。(在一些非x86 CPU设计中,TLB未命中由操作系统在软件中处理。)

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

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

页面错误可能导致以下几种结果:

  • 首先,操作系统可以说"哦,它不在TLB中,因为我无法安装它"。操作系统从TLB中逐出一个条目,并使用进程的页表映射填充新条目,然后让进程继续运行。在中等负载的机器上,这种情况每秒发生数千次。(在具有硬件TLB未命中处理的CPU上,如x86,这种情况是在硬件中处理的,甚至不是"小"页面错误。)
  • 第二,操作系统可以说"哦,这个虚拟页面现在没有映射,因为它使用的物理页面被交换到了磁盘,因为我的内存用完了"。操作系统暂停进程,找到一些要使用的内存(可能是通过交换其他一些虚拟映射),为请求的物理内存排队读取磁盘,当磁盘读取完成时,使用新填充的页表映射恢复进程。(这是一个"主要"页面错误。)
  • 第三,这个过程试图访问不存在映射的内存——它正在读取不应该存在的内存。这通常被称为分段错误

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

我们可以利用这一点来执行"硬件加速"索引检查,但在尝试这样做时还会遇到一些障碍

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

第一个问题是我们只能将整页标记为可读或不可读。数组大小可能不是页面大小的偶数倍,所以我们有一个问题——我们不能在数组结束前后设置围栏。我们能做的最好的事情是在数组开始之前或数组结束之后,在数组和最近的"围栏"页之间留下一个小间隙。

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

因此,他们所做的是对齐数组,使数组的末尾正好与页面的末尾对接,就像这样("-"表示未映射,"+"表示映射):

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

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

第二个混乱的块是,在映射下一个对象之前,我们确实应该在数组末尾留下大量未映射的虚拟内存,否则,如果一个索引超出了界限,但又远远超出界限,它可能会命中一个有效页面,而不会导致索引超出界限异常,而是会读取或写入任意内存。

为了解决这个问题,我们只需要使用大量的虚拟内存——我们将每个数组放入其自己的4GiB内存区域,其中只有前N个页面被实际映射。我们之所以能做到这一点,是因为我们在这里只是使用地址空间,而不是实际的物理内存。一个64位进程有大约40亿块4GiB的内存区域,所以在用完之前我们有足够的地址空间来处理。在32位CPU或进程上,我们几乎没有地址空间可供使用,因此这种技术不太可行。事实上,如今许多32位程序在试图访问真实内存时,虚拟地址空间已经用完,更不用说试图在该空间中映射空的"围栏"页,以尝试用作"硬件加速"索引范围检查。

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

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