在x86和x64上,在同一页内读取缓冲区末尾是否安全?

48
许多高性能算法中的方法,如果允许读取输入缓冲区末尾的少量数据,那么这些方法就可以(并且已经)被简化了。在这里,“少量”通常意味着超出末尾不超过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(如果未找到)。

首先,对于大小小于8的情况,为了简化阐述,将其转交给另一个函数。然后对前8个(不对齐的字节)进行单个检查。然后循环处理剩余的floor((size - 7) / 8)个8字节块2。此循环可能读取缓冲区末尾7个字节(当input & 0xF == 1时,会出现7字节的情况)。但是,返回调用具有检查,排除了发生在缓冲区末尾之外的任何虚假匹配

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

这种类型的过度读取在高性能代码中很常见。为了避免这种过度读取,特殊的尾部代码也很常见。有时候你会看到后者替换前者以消除像valgrind这样的工具的警告。有时候你会看到一个提议去进行这样的替换,但是基于这个习惯是安全的和该工具存在错误(或者太保守),这个提议被拒绝3
对于语言专家的注释:
在标准中,读取超出其分配大小的指针是绝对不允许的。我欣赏语言律师的回答,有时甚至会自己写,当有人挖掘出章节和课文显示上面的代码是未定义行为并且因此在最严格的意义上不安全时(我将复制详细信息在这里),我甚至会感到高兴。但归根结底,这不是我追求的。作为实际问题,许多涉及指针转换、通过这些指针访问结构等常见习惯用法在技术上是未定义的,但在高质量和高性能代码中广泛使用。通常没有替代方案,或者替代方案运行速度只有一半或更低。
如果您愿意,请考虑修改此问题的版本,即:
在将上述代码编译为x86 / x86-64汇编代码之后,并且用户已验证它以预期的方式进行编译(即,编译器没有使用可证明的部分越界访问来执行某些非常聪明的操作),执行编译后的程序是否安全?
在这方面,这个问题既是一个C问题,也是一个x86汇编问题。我看到使用这个技巧的大部分代码都是用C编写的,而C仍然是高性能库的主导语言,轻松超越低级别的东西,如汇编语言,以及高级别的东西,如<所有其他东西>。至少在FORTRAN仍然打球的核心数字领域之外。因此,我对问题的视图感兴趣,这就是为什么我没有将其制定为纯x86汇编问题的原因。
话虽如此,尽管我对显示这是UD的标准链接只是适度感兴趣,但我非常感兴趣任何实际实现的细节,可以使用此特定UD生成意外代码。现在我认为这不可能发生,除非进行了一些深入的交叉过程分析,但gcc溢出的东西也让很多人感到惊讶...

1 即使在表面看来无害的情况下,例如写回相同的值,它也可能破坏并发代码

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

3 需要注意的是,对于valgrind的Memcheck 有一个标志--partial-loads-ok,用于控制此类读取是否会被报告为错误。默认值为yes,通常这种加载不会被视为立即错误,但会尝试跟踪所加载的字节的后续使用情况,其中一些是有效的,一些则无效,在使用超出范围的字节时会标记出错。在像上面的示例中,match()中访问整个单词的情况下,这样的分析将得出字节已被访问的结论,即使结果最终被丢弃。Valgrind 通常无法确定从部分加载的无效字节是否实际使用(并且通常检测非常困难)。


1
理论上,C编译器可以实现比底层硬件更严格的检查。 - Barmar
如果您的用户已经验证编译是以“预期方式”进行的,其中预期方式是访问是安全的,则它是安全的。不幸的是,如果您的用户没有阅读汇编中间代码,他/她将无法获得任何此类保证。不要这样做。(您可以通过实现自己的内存管理使其安全) - BadZen
为什么不直接使用循环处理所有 8 字节的块,然后对最后一个块调用 shortMethod() 呢? - Barmar
1
好吧,总有asm()。 :) - Barmar
1
关于您的第一个问题,C语言并不保证您使用的内存模型与底层硬件的任何内容相对应,尤其是在这种“边缘情况”下(除了像字长这样的一些例外,即使是如此也很困难)。因此,在这方面无法进行。 "语言条款"之所以说“未定义”,也有充分的理由。关于第二个问题,您需要发布特定的ASM代码才能让问题有意义。 - BadZen
显示剩余12条评论
2个回答

43

是的,在x86汇编中是安全的,现有的libc strlen(3) 实现利用了这一点手写汇编。即使是 glibc 的回退 C,但它没有使用 LTO 编译,因此永远不会内联。它基本上将 C 用作可移植汇编器,以创建一个函数的机器代码,而不是作为具有内联的较大 C 程序的一部分。但这主要是因为它也存在潜在的严格别名 UB,请参见我在链接的 Q&A 上的答案。您可能还需要 GNU C __attribute__((may_alias)) typedef,而不是普通的 unsigned long 作为更宽的类型,例如 __m128i 等已经使用。

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


据我所知,针对x86编译的C代码通常也是安全的。在C中,访问对象之外的内容当然是未定义行为,但是针对x86的C却可以这样做。我认为编译器并没有明确/故意“定义”这种行为,但在实践中它确实可以这样工作。
我认为这不是侵略性编译器会假定无法发生优化的UB类型,但是从编译器作者在这一点上的确认会很好,特别是对于那些可以在编译时轻松证明访问超出对象末尾的情况。(请参见与@RossRidge的评论讨论:此答案的先前版本断言它绝对是安全的,但LLVM博客文章并没有真正阅读到这一点)。
这在汇编中是必需的,以比每次处理一个隐式长度字符串更快地进行1字节处理。理论上,C编译器可能知道如何优化这样的循环,但实际上它们不会,因此您必须像这样进行黑客攻击。在这种情况下,我认为人们关心的编译器通常会避免破坏包含此潜在UB的代码。

当超范围读取不对知道对象长度的代码可见时,就不会有危险。编译器必须为实际读取的数组元素情况生成工作的汇编代码。我所能想到的可能出现的未来编译器的潜在危险是:在内联之后,编译器可能会看到UB并决定不采取该执行路径。或者当完全展开时,在寻找终止条件之前找到非完整向量,并将其留在一边。


你得到的数据是不可预测的垃圾数据,但不会有其他潜在的副作用。只要您的程序不受垃圾字节的影响,就可以正常运行。(例如使用bithacks来查找uint64_t中的一个字节是否为零,然后通过字节循环查找第一个零字节,无论其后面是什么垃圾数据。)


在x86汇编中不安全的不寻常情况

  • 硬件数据断点(监视点) 可以在从给定地址加载时触发。如果您正在监视一个数组后面的变量,可能会出现虚假命中。这对于调试常规程序的人来说可能是一个小烦恼。如果您的函数将成为使用x86调试寄存器D0-D3及其产生的异常影响正确性的程序的一部分,请小心处理。

    或者类似地,代码检查器(如valgrind)可能会抱怨读取对象外部。

  • 在使用分段的假设16位或32位操作系统下:段限制 可以使用4k或1字节粒度,因此可以创建一个段,其中第一个故障偏移量是奇数。(除了性能外,将段基准对齐到缓存行或页面与此无关)。所有主流的x86操作系统都使用平面内存模型,并且x86-64删除了64位模式下段限制的支持。

  • 在您想要用宽载荷循环遍历的缓冲区之后的内存映射I/O寄存器,特别是相同的64B缓存行。即使您从设备驱动程序(或已映射某些MMIO空间的用户空间程序,如X服务器)中调用此类函数,这种情况也极不可能发生。

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

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

例如,glibc的实现使用前言来处理数据直到第一个64B对齐边界。然后在主循环中(gitweb链接到asm源代码),它使用四个SSE2对齐加载来加载整个64B缓存行。它们合并为一个向量,使用pminub(无符号字节的最小值),因此如果任何四个向量中有一个零元素,则最终向量将只有一个零元素。在找到字符串的末尾在那个缓存行的某个地方之后,它重新单独检查每个四个向量以查看位置在哪里。 (使用典型的pcmpeqb与全零向量和pmovmskb/ bsf来查找向量内的位置。) glibc以前有几种strlen策略可供选择,但当前的策略对所有x86-64 CPU都很好。
通常像这样的循环为了性能原因避免触及任何额外的缓存行(不仅仅是页面),例如glibc的strlen。
每次加载64B当然只有在64B对齐指针的情况下才是安全的,因为自然对齐访问不能跨越缓存行或页面边界。
如果您事先知道缓冲区的长度,您可以通过使用以缓冲区最后一个字节结尾的非对齐加载处理超出最后一个完整对齐向量的字节,从而避免读取超过末尾。
(再次强调,这仅适用于幂等算法(如memcpy),它们不关心是否将重叠存储写入目标。除了在执行与上一个对齐存储重叠的非对齐加载时可能出现的存储转发延迟之外,就地修改算法通常无法做到这一点,除非使用类似于使用SSE2将字符串转换为大写这样的方法,其中重新处理已经被大写化的数据是可以的。)
因此,如果您正在对已知长度的缓冲区进行矢量化处理,通常最好避免过度读取。
对象的非故障性过度读取是那种肯定不会在编译时损害编译器的 UB。生成的汇编代码将按照额外字节是某个对象的一部分的方式工作。
但即使在编译时可见,对当前编译器来说通常也不会有影响。

PS: 此前版本的回答声称在为x86编译的C语言中,对于int *的非对齐解引用也是安全的。这是不正确的。我在写那一部分时有点过于轻率了。你需要使用GNU C __attribute__((aligned(1),may_alias))memcpy来使其安全。如果你只通过signed/unsigned int*和/或`char*进行访问,则不需要may_alias部分,即以不违反常规C严格别名规则的方式。

ISO C未定义但Intel intrinsic要求编译器定义的事物集包括创建非对齐指针(至少对于像__m128i*这样的类型),但不直接解引用它们。 `reinterpret_cast`硬件SIMD向量指针和相应类型之间是否未定义行为?


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

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

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

更简单地说,p & 4095 > (4096 - 16)(MOV / AND / CMP),即 p & (pgsize-1) < (pgsize - vecwidth) check 确保页面内偏移与页面末尾足够远。

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

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

3
考虑将一个uint32_t从内存加载到寄存器中的含义,当终止的0可能是第一个字节时。此外,我链接并解释了glibc的strlen实际汇编源代码,它以64字节块读取。因此,它使用16字节向量读取字符串结尾之外高达63个字节的数据。 - Peter Cordes
1
@DavidC.Rankin: uint32_t foo = *(uint32_t*)aligned_pointer 将编译为32位加载。无论您一次只测试foo的一个字节,都没有关系。如果您的代码行为取决于终止0后的字节内容,则存在错误,但是加载它们可能会导致问题。访问检查发生在加载/存储时;寄存器不跟踪数据来自何处的任何信息。 glibc的strlen实现甚至通过ALU将整个64B馈送到一件可以分支的事情上。 - Peter Cordes
2
谢谢@PeterCordes,这是一份全面的答案。注意到现有广泛使用的实现方式可以为其他代码的使用提供很大的支持(对于那些可以测量差异的有限情况而言)。 - BeeOnRope
2
@RossRidge: 嗯,我想你是对的;如果编译器能在编译时(或链接时优化)证明数组边界的某些属性,可能在C语言中进行这种操作确实会有问题。从实践角度来看,我觉得它总是安全的,但也许只对于向量加载有效,因为在gcc/clang中,__m128i等类型被定义为may_alias。我很想听听编译器内部专家关于我的可能过于自信的断言是否正确的意见。 - Peter Cordes
4
如果您有一个已知长度的数组,我认为最好使用不对齐的加载来处理最后几个元素,因为它会在结束时停止。因此,在实践中,我认为只有在循环开始时迭代计数未知的情况下才应该这样做,这样编译器将无法证明任何事情。 - Peter Cordes
显示剩余15条评论

9
如果允许考虑非CPU设备,那么一个潜在的不安全操作的例子是访问超出PCI映射内存页范围。没有保证目标设备使用与主存储子系统相同的页面大小或对齐方式。例如,试图访问地址[cpu page base]+0x800可能会触发设备页面故障,如果设备处于2KiB页面模式,则这通常会导致系统错误检查。

4
通常只有操作系统和内核模式组件才能创建这种映射,但是有几种情况下内核模式组件会将映射的区域交给用户模式。例如,CUDA 就是这样做的,出于与 CPU 端类似的性能原因,通常不对访问进行任何边界检查。访问超出结尾将触发 设备 页错误,这通常比进程页错误更糟,并且经常使操作系统无法恢复。不确定 CUDA 是否也是这样。 - MooseBoys
2
如果操作系统以这样的方式将映射移交给用户空间,以至于用户模式进程可以执行导致整个系统崩溃的访问,那么这似乎是一个操作系统的错误。无论C规范对未定义行为有何规定,操作系统都不应该允许用户模式代码引起不可恢复的系统级错误。任何未定义的行为都应该限制在进程内部。 - Barmar
4
经常发生的情况是,具有足够特权的用户模式程序可以直接访问硬件,这足以导致系统崩溃。如果您想尝试一下,可以在Linux系统上查看“man 2 iopl”。如果X服务器不这样做,它们可能会变得无法使用,或者用户空间程序也可以使用更高贵的方式来崩溃系统,“man 2 shutdown”。 - Nate Eldredge
1
是的,在我发布那篇文章之后,我意识到获取直接访问权限的操作可能仅限于特权用户或应用程序,并且它们被认为是安全的(因为特权用户还可以执行诸如关闭系统之类的操作)。 - Barmar
1
据我所知,iopl仅用于使用in/out指令。大多数现代硬件使用基于内存映射的I/O作为其接口的主要方式,软件通过在Linux上内存映射/dev/mem来访问它。但是,用户空间软件确实可以直接访问硬件。 - Peter Cordes
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接