Bounds检查64位硬件

我正在hacks.mozilla.org上阅读64位Firefox版本的博客 。

作者说:

对于asm.js代码,增加的地址空间还允许我们使用硬件内存保护来安全地从asm.js堆访问中删除边界检查。 收益非常显着: asmjs-apps上的8%-17% – * –在arewefastyet.com上报告的吞吐量测试。

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

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

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

所以我的程序访问地址0x8050,它位于虚拟页面8中(假设标准的4096字节(0x1000)页面大小)。 CPU看到虚拟页面8被映射到物理页面200,因此在物理地址200 * 4096 + 0x50 == 0xC8050处执行读取。 (正如TLB缓存页表查找一样,更熟悉的L1 / L2 / L3缓存对物理RAM的缓存访问。)

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

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

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

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

我们可以利用这个优势来执行“硬件加速”索引检查,但是我们尝试这样做时会遇到更多绊脚石。

首先,一般的想法:对于每个数组,我们将它放在它自己的虚拟内存区域中,所有包含数组数据的页面都照常映射。 在实际数组数据的任一侧,我们创建了不可读且不可写的虚拟页面映射。 如果您尝试在arrays外部读取,则会生成页面错误。 编译器在创建程序时插入自己的页面error handling程序,它处理页面错误,将其转换为索引越界exception。

第一个绊脚石是我们只能将整个页面标记为可读或不可读。 数组大小可能不是页面大小的偶数倍,所以我们遇到了一个问题 – 我们不能在数组结束之前和之后准确地放置围栏。 我们能做的最好的事情是在数组开始之前或在数组和最近的’fence’页面之间的数组结束之后留下一个小间隙。

他们如何解决这个问题? 那么,在Java的情况下,编译执行负索引的代码并不容易; 如果确实如此,无论如何都没关系,因为负索引被视为无符号,这使得索引远远超出数组的开头,这意味着它很可能会触及未映射的内存,并且无论如何都会导致错误。

所以他们做的是对齐数组,以便数组的末端直接对着页面的末端,就像这样(’ – ‘表示未映射,’+’表示映射):

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

现在,如果索引是数组的末尾,它将触及第7页,这是未映射的,这将导致页面错误,这将变成索引超出范围的exception。 如果索引在数组的开头之前(也就是说它是负数),那么因为它被视为无符号值,它将变得非常大且正面,使我们再次超过第7页,导致未映射的内存读取,导致页面错误,它将再次变成索引超出范围的exception。

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

为了解决这个问题,我们只使用了大量的虚拟内存 – 我们将每个数组放入其自己的4 GiB内存区域,其中只有前N个页面实际映射。 我们可以这样做,因为我们只是在这里使用地址空间 ,而不是实际的物理内存。 64位进程有大约40亿个4 GiB区域的内存块,因此在用完之前我们有足够的地址空间可供使用。 在32位CPU或进程上,我们只能使用很少的地址空间,因此这种技术不太可行。 事实上,今天许多32位程序正在耗尽虚拟地址空间,只是试图访问实际内存,从未尝试在该空间中映射空的“fence”页面以尝试用作“硬件加速”索引范围检查。

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

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