在x86和x64的同一页面中读取缓冲区末尾是否安全?

时间:2016-06-13 23:32:27

标签: c performance assembly optimization x86

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

很明显,超过输入缓冲区的结尾通常是不安全的,因为你可能会破坏缓冲区 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。

首先,大小&lt;为了简化说明,图8示出了另一个功能。然后对前8个(未对齐字节)进行单个检查。然后为8字节 2 的剩余floor((size - 7) / 8)块完成循环。该循环可以在缓冲区结束之后读取最多7个字节(当input & 0xF == 1时出现7字节情况)。但是,返回调用有一个检查,它排除了超出缓冲区末尾的任何虚假匹配

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

这些类型的 overreads 在高性能代码中很常见。避免此类 overreads 的特殊尾部代码也很常见。有时你会看到后一种类型取代前者来沉默像valgrind这样的工具。有时你会看到一个提案来做这样的替换,由于成语是安全的并且工具出错(或者过于保守) 3 ,因此被拒绝。< / p>

语言律师的说明:

  

绝对不允许从超出其分配大小的指针读取   在标准中。我很欣赏语言律师的答案,甚至偶尔也会写   他们自己,当有人挖掘这一章时,我甚至会感到高兴   以及显示上述代码的经文是未定义的行为因此   从严格意义上来说并不安全(我会在这里复制细节)。但最终,那不是什么   我之后。作为一个实际问题,许多常见的成语涉及指针   转换,结构访问虽然这样的指针等等   技术上尚未定义,但在高品质和高品质下广泛使用   性能代码。通常没有替代品或替代品   以半速或更低速度运行。

     

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

     

将上面的代码编译成x86 / x86-64程序集后,用户已经验证它是以预期的方式编译的(即   编译器没有使用可证明的部分越界访问   做一些事情really clever,   正在执行编译的程序安全吗?

     

在这方面,这个问题既是C问题,也是x86汇编问题。使用这个技巧的大多数代码都是用C语言编写的,C仍然是高性能库的主要语言,很容易超越像asm这样的低级内容,以及更高级别的东西,比如&lt; everything else&gt;。至少在FORTRAN仍在打球的硬核数字利基之外。所以我对问题的 C-compiler-and-below 视图感兴趣,这就是为什么我没有将它表示为纯x86汇编问题。

     

所有这一切都说,虽然我只是对一个链接的中等兴趣   标准显示这是UD,我对任何细节都很感兴趣   可以使用此特定UD生成的实际实现   意外的代码。现在我没有想到这种情况可能会在没有深刻的情况下发生   相当深入的跨程序分析,但gcc溢出的东西   也让很多人感到惊讶......

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

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

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

2 个答案:

答案 0 :(得分:18)

是的,它在x86 asm中是安全的,现有的libc strlen(3)实现可以利用这一点。

据我所知,在为x86编译的C中它也是安全的。读取对象外部当然是C中的未定义行为,但它对于C-targeting-x86来说是很好的定义。我认为攻击性编译器不会是那种UB assume can't happen while optimizing,但在这一点上编译器 - 作者的确认会很好,特别是对于在编译时很容易证明的情况 - 访问超出对象结束的时间。 (参见@RossRidge评论中的讨论:这个答案的先前版本声称它绝对安全,但LLVM博客文章并没有真正这样阅读。)

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

类似地,使用强制转换创建未对齐的指针是C标准中的UB(即使您没有取消引用它们)。在针对x86时,它在所有已知的C编译器中都有明确定义。英特尔的SSE内在函数甚至需要它;例如__m128i _mm_loadu_si128 (__m128i const* mem_addr)获取指向未对齐的16字节__m128i的指针。

(对于AVX512,他们最终将这种不方便的设计选择更改为void*,用于__m512i _mm512_loadu_si512 (void const* mem_addr)等新的内在函数。

甚至解除引用未对齐的uint64_t*int*在为x86编译的C中是安全的(并且具有良好定义的行为)。但是,直接解除引用__m128i*(而不是使用加载/存储内在函数)将使用movdqa,这会对未对齐的指针产生错误。

由于性能原因,通常这样的循环可以避免触及他们不需要触摸的任何额外缓存行,而不仅仅是页面。

在同一页面中,存储器映射的I / O寄存器与用于宽负载循环的缓冲区,或者特别是相同的64B缓存线,即使你是&#,也极不可能39;从设备驱动程序(或像X服务器那样映射了一些MMIO空间的用户空间程序)调用这样的函数。

如果您正在处理一个60字节的缓冲区并且需要避免从4字节MMIO寄存器中读取数据,那么您就会知道它。对于普通代码,这种情况不会发生。

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都很好。

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

如果您事先知道缓冲区的长度,则可以使用在缓冲区的最后一个字节处结束的未对齐加载来处理超出最后一个对齐向量的字节,从而避免读取结束。 (同样,这只适用于幂等算法,例如memcpy,如果它们将存储重叠到目的地,则不在乎。原位修改算法通常不会这样做,除了像{{{{{{{ 3}},可以重新处理已经被提升的数据。除了存储转发停止,如果你做的是与你上次对齐的商店重叠的未对齐加载。)

答案 1 :(得分:6)

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