首页 文章

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

提问于
浏览
28

如果允许在输入缓冲区末尾读取少量数据,则可以(并且)简化在高性能算法中找到的许多方法 . 这里,"small amount"通常意味着超过结尾的 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个(未对齐字节)进行单个检查 . 然后为8个字节2的剩余 floor((size - 7) / 8) 块完成循环 . 该循环可以在缓冲区末尾读取最多7个字节(7字节情况发生在 input & 0xF == 1 时) . 但是,返回调用有一个检查,它排除了在缓冲区末尾之外发生的任何虚假匹配 .

Practically speaking, is such a function safe on x86 and x86-64?

这些类型的重写在高性能代码中很常见 . 避免这种重读的特殊尾部代码也很常见 . 有时你会看到后一种类型取代前者来沉默像valgrind这样的工具 . 有时你会看到一个建议做这样的替换,这个被拒绝的理由是成语是安全的并且工具是错误的(或者只是过于保守)3 .

语言律师的说明:

标准中绝对不允许从超出其分配大小的指针读取 . 我很欣赏语言律师的答案,甚至偶尔也会自己写一下,当有人挖出章节和节目时,我会很高兴,这些章节和节目表明上面的代码是不明确的行为,因此在严格意义上不安全(我会复制)这里的细节) . 但最终,这不是我追求的 . 实际上,许多涉及指针转换的常见习语,通过这些指针进行结构访问等等在技术上尚未定义,但在高质量和高性能代码中广泛使用 . 通常没有替代方案,或者替代方案以半速或更低的速度运行 . 如果您愿意,可以考虑这个问题的修改版本,即:在上面的代码编译成x86 / x86-64程序集之后,用户已经验证它是以预期的方式编译的(即,编译器没有'使用可证明的部分越界访问来做一些非常聪明的事情,正在执行编译的程序安全吗?在这方面,这个问题既是C问题又是x86汇编问题 . 大多数代码使用这个技巧我我们看到它是用C语言编写的,而C仍然是高性能库的主要语言,很容易超越asm之类的低级内容,以及像<everything else>这样的更高级别的东西 . 至少在FORTRAN仍然扮演的硬核数字小众之外所以我对这个问题的C编译器和下面的视图很感兴趣,这就是为什么我没有将它表示为纯x86汇编问题 . 所有这些都说,虽然我对链接只有一点兴趣标准显示这是UD,我对任何细节都很感兴趣可以使用此特定UD生成意外代码的实际实现 . 现在我不认为如果没有深入的深度跨程序分析就会发生这种情况,但是gcc溢出的东西也让很多人感到惊讶......


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

2注意这种重叠工作要求此函数和 match() 函数以特定的幂等方式运行 - 特别是返回值支持重叠检查 . 所以"find first byte matching pattern"有效,因为所有的 match() 调用仍然是有序的 . 但是,"count bytes matching pattern"方法不起作用,因为某些字节可能会被重复计算 . 顺便说一下:即使没有按顺序限制,某些函数(如"return the minimum byte"调用)也可以工作,但需要检查所有字节 .

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

2 回答

  • 17

    是的,它在x86 asm中是安全的,并且 existing libc strlen(3) implementations take advantage of this.

    它's also safe in C compiled for x86, as far as I know. Reading outside an object is of course Undefined Behaviour in C, but it'为C-targeting-x86定义明确 . 我认为积极的编译器不会是那种UB,但在这一点上来自编译器 - 编写者的确认会很好,特别是对于它真正读取的情况而言 .

    你得到的数据是不可预测的垃圾,但是垃圾字节受到了影响,没关系,没关系 . (例如,使用bithacks to find if one of the bytes of a uint64_t are zero,然后使用字节循环来查找第一个零字节,而不管它之外的垃圾是什么 . )


    类似地,使用强制转换创建未对齐的指针是C标准中的UB(即使你不需要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缓存线,即使你从一个调用这样的函数设备驱动程序(或用户空间程序,如已映射某些MMIO空间的X服务器) .

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


    strlen is the canonical example 循环处理隐式长度缓冲区,因此无法在不读取缓冲区末尾的情况下进行向量化 . 如果您需要避免读取超过终止的 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,除了像converting a string to upper-case with SSE2之类的东西之外,它已经被提升了 . 除了存储转发停止,如果你做了一个与之重叠的未对齐加载你上次对齐的商店 . )

  • 6

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

相关问题