【问题标题】:Is it safe to read past the end of a buffer within the same page on x86 and x64?在 x86 和 x64 上的同一页面中读取缓冲区末尾是否安全?
【发布时间】:2016-10-14 12:29:37
【问题描述】:

如果允许在输入缓冲区末尾读取少量数据,则高性能算法中的许多方法都可以(并且已经)简化。在这里,“少量”通常意味着在结尾之后最多 W - 1 个字节,其中 W 是算法的字节大小(例如,对于以 64 位块处理输入的算法,最多 7 个字节) .

很明显,写入输入缓冲区的末尾通常是不安全的,因为您可能会破坏缓冲区之外的数据1。同样清楚的是,从缓冲区末尾读取到另一个页面可能会触发分段错误/访问冲突,因为下一页可能不可读。

然而,在读取对齐值的特殊情况下,页面错误似乎是不可能的,至少在 x86 上是这样。在该平台上,页面(以及因此的内存保护标志)具有 4K 粒度(更大的页面,例如 2MiB 或 1GiB,是可能的,但这些是 4K 的倍数),因此对齐读取将仅访问同一页面中的字节作为有效缓冲区的一部分。

以下是一些循环的规范示例,该循环对齐其输入并读取缓冲区末尾后最多 7 个字节:

int processBytes(uint8_t *input, size_t size) {

    uint64_t *input64 = (uint64_t *)input, end64 = (uint64_t *)(input + size);
    int res;

    if (size < 8) {
        // special case for short inputs that we aren't concerned with here
        return shortMethod();
    }

    // check the first 8 bytes
    if ((res = match(*input)) >= 0) {
        return input + res;
    }

    // align pointer to the next 8-byte boundary
    input64 = (ptrdiff_t)(input64 + 1) & ~0x7;

    for (; input64 < end64; input64++) {
        if ((res = match(*input64)) > 0) {
            return input + res < input + size ? input + res : -1;
        }
    }

    return -1;
}

内部函数int match(uint64_t bytes) 未显示,但它会查找与特定模式匹配的字节,如果找到则返回该最低位置 (0-7),否则返回 -1。

首先,为简化说明,将大小 2 的 floor((size - 7) / 8) 块进行循环。这个循环最多可以读取缓冲区末尾的 7 个字节(input &amp; 0xF == 1 时出现 7 个字节的情况)。但是,return 调用有一个检查,它会排除任何出现在缓冲区末尾之外的虚假匹配

实际上,这样的函数在 x86 和 x86-64 上是否安全?

这些类型的重读在高性能代码中很常见。避免这种 overreads 的特殊尾代码也很常见。有时你会看到后一种类型取代了前一种类型以使 valgrind 等工具静音。有时您会看到 建议 进行这样的替换,但该建议被拒绝,理由是习语是安全的并且工具有错误(或只是过于保守)3

语言律师须知:

绝对不允许从超出其分配大小的指针中读取 在标准中。我很欣赏语言律师的回答,甚至偶尔写信 他们自己,当有人挖出这一章时,我什至会很高兴 和显示上面代码的诗句是 未定义的行为 因此 严格意义上来说并不安全(我将在此处复制详细信息)。最终,这不是什么 我在追实际上,许多涉及指针的常见习语 转换,结构访问,虽然这样的指针等等 技术上未定义,但在高质量和高 性能代码。通常没有替代品,或者替代品 以一半或更低的速度运行。

如果您愿意,可以考虑修改此问题的版本,即:

上面的代码已经编译成 x86/x86-64 程序集,并且用户已经验证它是按照预期的方式编译的(即, 编译器没有使用可证明的部分越界访问 做点什么really clever, 执行编译的程序安全吗?

在这方面,这个问题既是 C 问题,也是 x86 汇编问题。我见过的大多数使用这个技巧的代码都是用 C 编写的,而 C 仍然是高性能库的主要语言,很容易让 asm 等低级内容和 等高级内容黯然失色。至少在 FORTRAN 仍然打球的核心数字利基之外。所以我对问题的 C-compiler-and-below 视图很感兴趣,这就是为什么我没有将其表述为纯 x86 汇编问题。

说了这么多,虽然我对指向 显示这是 UD 的标准,我对任何细节都非常感兴趣 可以使用此特定 UD 生成的实际实现 意外的代码。现在我不认为这可以在没有一些深入的情况下发生 相当深入的跨过程分析,但是 gcc 溢出的东西 也让很多人感到惊讶...


1 即使在看似无害的情况下,例如,在写回相同值的情况下,它也可以break concurrent code

2 请注意,此重叠工作需要此函数和 match() 函数以特定的幂等方式运行 - 特别是返回值支持重叠检查。因此,“查找第一个字节匹配模式”有效,因为所有 match() 调用仍然是有序的。但是,“计数字节匹配模式”方法将不起作用,因为某些字节可能会被重复计算。顺便说一句:一些函数,例如“返回最小字节”调用即使没有顺序限制也可以工作,但需要检查所有字节。

3 这里值得注意的是,对于 valgrind 的 Memcheck there is a flag--partial-loads-ok,它们控制着这些读取是否实际上被报告为错误。默认值为yes,表示通常此类加载不会被视为立即错误,但会努力跟踪加载字节的后续使用,其中一些是有效的,而另一些是有效的不是,如果超出范围的字节被使用,则会标记一个错误。在上述示例中,在match() 中访问整个单词的情况下,即使结果最终被丢弃,这种分析也会得出字节被访问的结论。 Valgrind cannot in general 确定是否实际使用了来自部分加载的无效字节(通常检测可能非常困难)。

【问题讨论】:

  • 理论上,C 编译器可以实现自己的检查,这些检查比底层硬件的检查更严格。
  • 如果您的用户已经验证它是按“预期方式”编译的,预期方式是访问是安全的,那么它就是安全的。不幸的是,如果您的用户没有阅读汇编中间代码,他/她将不会有任何此类保证。不要这样做。 (您可以通过实现自己的内存管理使其安全)
  • 这看起来更像是一个答案而不是一个问题:) 至于特殊的尾部代码,通常只有在算法以块的形式进行但没有首先对齐时才会这样做。
  • 嗯,总是有asm()。 :)
  • 关于您的第一个问题,C 不保证您正在使用的内存模型甚至对应于底层硬件中的任何“边缘情况”(有几个例外情况诸如字长之类的东西,即使这样它也很挣扎)。所以不要在这方面进行。 “语言法律术语”说“未定义”是有充分理由的。关于第二个问题,您需要发布特定的 ASM 才能使问题有意义。

标签: c performance assembly optimization x86


【解决方案1】:

是的,它在 x86 asm 中是安全的,现有的 libc strlen(3) 实现在手写 asm 中利用了这一点。 甚至是 glibc's fallback C,但它在没有 LTO 的情况下编译,因此它可以从不内联。它基本上是使用 C 作为可移植汇编程序来为一个函数创建机器代码,而不是作为具有内联的更大 C 程序的一部分。但这主要是因为它还具有潜在的严格混叠 UB,请参阅我在链接问答中的回答。您可能还想要一个 GNU C __attribute__((may_alias)) typedef 而不是普通的 unsigned long 作为更广泛的类型,例如 __m128i 等已经使用。

这是安全的,因为对齐的加载永远不会跨越更高的对齐边界,并且内存保护发生在对齐的页面上,因此至少有 4k 边界1任何自然- 至少接触 1 个有效字节的对齐加载不会出错。检查您是否距离下一页边界足够远以执行 16 字节加载也是安全的,例如 if (p &amp; 4095 &gt; (4096 - 16)) do_special_case_fallback。有关详细信息,请参阅下面的部分。


据我所知,在为 x86 编译的 C 语言中通常也是安全的。在对象外部读取当然是 C 中的未定义行为,但在 C-targeting-x86 中有效。我不认为编译器明确/故意定义行为,但实际上它是这样工作的。

我认为这不是激进的编译器会assume can't happen while optimizing 的那种 UB,但编译器编写者在这一点上的确认会很好,特别是对于在编译时很容易证明访问失效的情况超过对象的末端。 (请参阅 cmets 与 @RossRidge 的讨论:此答案的先前版本断言它绝对安全,但 LLVM 博客文章并没有真正这样阅读)。

这是在 asm 中必需,以便在处理隐式长度字符串时一次快于 1 个字节。在 C 中,理论上编译器可以知道如何优化这样的循环,但实际上他们不知道,所以你必须做这样的 hack。在此之前,我怀疑人们关心的编译器通常会避免破坏包含此潜在 UB 的代码。

如果知道对象有多长的代码看不到超读,则没有危险。编译器必须生成适用于我们实际读取的数组元素的情况的 asm。 我可以看到未来可能的编译器可能存在的危险是:在内联之后,编译器可能会看到 UB 并决定永远不能采用这种执行路径。或者必须在最终的非完整向量之前找到终止条件,并在完全展开时将其省略。


您获得的数据是不可预测的垃圾,但不会有任何其他潜在的副作用。只要您的程序不受垃圾字节的影响,就可以了。 (例如,使用bithacks to find if one of the bytes of a uint64_t are zero,然后使用字节循环来查找第一个零字节,不管它后面是什么垃圾。)


这种在 x86 asm 中是安全的异常情况

  • Hardware data breakpoints (watchpoints) 在从给定地址加载时触发。如果您在数组之后立即监视变量,则可能会受到虚假命中。对于调试普通程序的人来说,这可能是一个小麻烦。如果您的函数将成为使用 x86 调试寄存器 D0-D3 的程序的一部分,并且会导致可能影响正确性的异常,那么请注意这一点。

    或者类似地,像 valgrind 这样的代码检查器可能会抱怨读取对象之外的内容。

  • 在使用分段的假设 16 位或 32 位操作系统下:分段限制可以使用 4k or 1-byte granularity,因此可以创建第一个错误偏移为奇数的分段。 (除了性能之外,将段的基址与缓存行或页面对齐是无关紧要的)。 所有主流 x86 操作系统都使用平面内存模型,并且 x86-64 移除了对 64 位模式的段限制的支持。

  • 内存映射的 I/O 寄存器紧跟在缓冲区之后,您希望在宽负载下循环,尤其是相同的 64B 高速缓存行。即使您从设备驱动程序(或用户空间程序,如映射了一些 MMIO 空间的 X 服务器)调用此类函数,这种情况也极不可能发生。

如果您正在处理一个 60 字节的缓冲区并且需要避免从 4 字节的 MMIO 寄存器中读取,那么您就会知道它并且将使用volatile T*。普通代码不会出现这种情况。


strlen 是处理隐式长度缓冲区的循环的典型示例,因此如果不读取缓冲区的末尾就无法进行矢量化。如果您需要避免读取超过终止的0 字节,则一次只能读取一个字节。

例如,glibc 的实现使用序言来处理直到第一个 64B 对齐边界的数据。然后在主循环(gitweb link to the asm source) 中,它使用四个 SSE2 对齐加载来加载整个 64B 缓存行。它将它们合并为一个带有pminub(最小无符号字节)的向量,因此只有当四个向量中的任何一个具有零时,最终向量才会具有零元素。在发现字符串的结尾位于该缓存行中的某个位置后,它会分别重新检查四个向量中的每一个以查看其位置。 (对全零向量使用典型的pcmpeqb,并使用pmovmskb / bsf 来查找向量内的位置。)glibc 曾经有几个不同的strlen strategies to choose from,但现在的很好所有 x86-64 CPU。

出于性能原因,像 glibc 的 strlen 这样的循环通常会避免接触它们不需要接触的任何额外缓存行,而不仅仅是页面。

一次加载 64B 当然只对 64B 对齐的指针是安全的,因为自然对齐的访问不能跨越 cache-line or page-line boundaries


如果您确实提前知道缓冲区的长度,则可以通过使用在最后一个字节处结束的未对齐加载来处理最后一个 完全对齐 向量之外的字节,从而避免读取超出末尾缓冲区。

(同样,这仅适用于幂等算法,如 memcpy,它不关心它们是否将存储重叠到目的地。就地修改算法通常不能做到这一点,除非像 converting a string to upper-case with SSE2 这样的东西, 可以重新处理已经被向上转换的数据。除了存储转发停顿,如果您执行与上次对齐存储重叠的未对齐加载。)

因此,如果您在已知长度的缓冲区上进行矢量化,通常最好避免过度读取。

对象的非错误重读是一种 UB,如果编译器在编译时看不到它,它肯定不会受到伤害。生成的 asm 就像额外的字节是某个对象的一部分一样工作。

但即使它在编译时可见,它通常不会对当前的编译器造成伤害。


PS:此答案的先前版本声称 int * 的未对齐 deref 在为 x86 编译的 C 中也是安全的。 That is not true。三年前写那部分的时候我有点太漫不经心了。您需要__attribute__((aligned(1))) typedef 或memcpy,以确保安全。

ISO C 未定义但英特尔内在函数要求编译器定义的一组内容确实包括创建未对齐的指针(至少对于像 __m128i* 这样的类型),但不直接取消引用它们。 Is `reinterpret_cast`ing between hardware SIMD vector pointer and the corresponding type an undefined behavior?


检查指针是否距离 4k 页面的末尾足够远

这对于strlen的第一个向量很有用;在此之后,您可以p = (p+16) &amp; -16 转到下一个对齐的向量。如果p 不是 16 字节对齐的,这将部分重叠,但做冗余工作有时是设置有效循环的最紧凑方法。避免它可能意味着一次循环 1 个字节直到对齐边界,这肯定更糟。

例如检查((p + 15) ^ p) &amp; 0xFFF...F000 == 0 (LEA / XOR / TEST),它告诉您 16 字节加载的最后一个字节与第一个字节具有相同的页面地址位。或者p+15 &lt;= p|0xFFF(具有更好ILP的LEA / OR / CMP)检查加载的最后一个字节地址是

或者更简单地说,p &amp; 4095 &gt; (4096 - 16) (MOV / AND / CMP),即p &amp; (pgsize-1) &lt; (pgsize - vecwidth) 检查页内偏移量是否距离页尾足够远。

您可以使用 32 位操作数大小来节省此检查或任何其他检查的代码大小(REX 前缀),因为高位无关紧要。一些编译器没有注意到这种优化,因此您可以转换为unsigned int 而不是uintptr_t,尽管要消除有关非 64 位干净代码的警告,您可能需要转换为(unsigned)(uintptr_t)p。使用((unsigned int)p &lt;&lt; 20) &gt; ((4096 - vectorlen) &lt;&lt; 20) (MOV / SHL / CMP) 可以进一步节省代码大小,因为shl reg, 20 是 3 个字节,而and eax, imm32 是 5,或任何其他寄存器为 6。 (使用 EAX 还将允许 cmp eax, 0xfff 的 no-modrm 短格式。)

如果在 GNU C 中执行此操作,您可能希望 typedef unsigned long aliasing_unaligned_ulong __attribute__((aligned(1),may_alias)); 使其可以安全地进行未对齐的访问。

【讨论】:

  • @DavidC.Rankin:想一想当终止的0 可能是第一个字节时,将uint32_t 从内存加载到寄存器中意味着什么。除此之外,我链接并解释了 glibc 的strlen 的实际 asm 源代码,它读取 64 字节块。因此,它使用 16 字节向量最多读取字符串末尾之外的 63 个字节。
  • @DavidC.Rankin: uint32_t foo = *(uint32_t*)aligned_pointer 将编译为 32 位负载。一次只测试foo 的字节也没关系。如果您的代码的行为取决于终止 0 之后的字节中的内容,那是一个错误,但完全加载它们可能会导致问题。访问检查发生在加载/存储上;寄存器不会跟踪有关数据来自何处的信息。 glibc 的 strlen 实现甚至通过 ALU 为整个 64B 提供数据,以将其组合成一个可以分支的东西。
  • 感谢@PeterCordes,这是一个全面的答案。注意到现有广泛使用的实现这样做给了它在其他代码中也可以的想法很大的权重(对于它产生可测量差异的有限情况)。
  • @RossRidge:嗯,我认为你是对的;如果编译器可以在编译时(或链接时优化)证明有关数组边界的某些事情,那么在 C 中执行此操作实际上可能会出现问题。我认为它在实践中总是安全的,但可能只适用于矢量加载,因为__m128i 等在 gcc/clang 中定义为 may_alias。我很想听听编译器内部专家关于我可能过于自信的断言是否正确。
  • 如果您有一个已知长度的数组,我认为通常最好处理最后一个未对齐负载的元素,无论如何都会在末尾停止。所以在实践中,我认为只有在循环开始时不知道迭代次数的情况下才应该这样做,这样编译器无论如何都无法证明任何事情。
【解决方案2】:

如果您允许考虑非 CPU 设备,那么潜在不安全操作的一个示例是访问PCI-mapped memory 页面的越界区域。无法保证目标设备使用与主内存子系统相同的页面大小或对齐方式。例如,如果设备处于 2KiB 页面模式,则尝试访问地址 [cpu page base]+0x800 可能会触发设备页面错误。这通常会导致系统错误检查。

【讨论】:

  • @BeeOnRope 通常只允许操作系统和内核模式组件创建这种映射,但是内核模式组件有几种路径会将映射区域移交给用户模式.例如,CUDA 会这样做,并且出于与 CPU 端类似的性能原因,通常不会对访问执行任何边界检查。访问结束会触发 device 页面错误,这通常比进程页面错误更糟糕,并且通常会使操作系统无法恢复。虽然不确定 CUDA。
  • 如果它以用户模式进程可以执行使整个系统崩溃的访问的方式移交到用户空间的映射,这似乎是一个操作系统错误。无论 C 规范对未定义行为有何规定,操作系统都不应该允许用户模式代码导致不可恢复的系统级错误。任何未定义的东西都应该限制在进程中。
  • @Barmar:拥有足够特权的用户模式程序直接访问硬件的情况一直都在发生,这肯定足以使系统崩溃。 man 2 iopl 在 Linux 机器上,如果你想玩的话。如果 X 服务器不这样做,它们可能会非常缓慢。 (或者对于用户空间程序使系统崩溃的更体面的方式,man 2 shutdown。)
  • 是的,在我发布之后我意识到获取直接访问权限的操作可能仅限于特权用户或应用程序,并且它们应该是安全的(因为特权用户也可以执行以下操作关闭系统)。
  • @NateEldredge: IIRC, iopl 仅用于使用 in / out 指令。大多数现代硬件的大部分接口都使用内存映射 I/O,而软件可以通过 Linux 上的内存映射 /dev/mem 访问它。但是,是的,用户空间软件可以并且确实可以直接访问硬件。
猜你喜欢
  • 2020-10-29
  • 2020-04-27
  • 1970-01-01
  • 2020-01-26
  • 2014-03-26
  • 2013-04-02
  • 1970-01-01
  • 2015-01-24
  • 2021-11-28
相关资源
最近更新 更多