C未定义的行为.严格别名规则或错误对齐?

Nik*_*yev 13 c gcc strict-aliasing memory-alignment

我无法解释这个程序的执行行为:

#include <string> 
#include <cstdlib> 
#include <stdio.h>

typedef char u8;
typedef unsigned short u16;

size_t f(u8 *keyc, size_t len)
{
    u16 *key2 = (u16 *) (keyc + 1);
    size_t hash = len;
    len = len / 2;

    for (size_t i = 0; i < len; ++i)
        hash += key2[i];
    return hash;
}

int main()
{
    srand(time(NULL));
    size_t len;
    scanf("%lu", &len);
    u8 x[len];
    for (size_t i = 0; i < len; i++)
        x[i] = rand();

    printf("out %lu\n", f(x, len));
}
Run Code Online (Sandbox Code Playgroud)

因此,当使用带有gcc的-O3编译并使用参数25运行时,它会引发段错误.没有优化它工作正常.我已经对它进行了反汇编:它正在进行矢量化,并且编译器假定key2数组以16字节对齐,因此它使用movdqa.显然它是UB,虽然我无法解释它.我知道严格的别名规则,并不是这种情况(我希望),因为据我所知,严格的别名规则不适用于chars.为什么gcc认为这个指针是对齐的?即使经过优化,Clang也能正常工作.

编辑

我改变unsigned charchar,并删除了const它仍然是段错误.

EDIT2

我知道这段代码不好,但据我所知,严格的别名规则应该可行.违规究竟在哪里?

Ant*_*ala 33

代码确实打破了严格的别名规则.但是,不仅存在别名冲突,而且由于别名冲突而不会发生崩溃.它发生是因为unsigned short指针不正确对齐 ; 如果结果没有适当对齐,甚至指针转换本身也是未定义的.

C11(草案n1570)附录J.2:

1在以下情况下,行为未定义:

....

  • 两种指针类型之间的转换会产生错误对齐的结果(6.3.2.3).

6.3.2.3p7

[...]如果结果指针未正确对齐[68]引用类型,则行为未定义.[...]

unsigned short您的实现(x86-32和x86-64)上的对齐要求为2,您可以使用它进行测试

_Static_assert(_Alignof(unsigned short) == 2, "alignof(unsigned short) == 2");
Run Code Online (Sandbox Code Playgroud)

但是,您强制u16 *key2指向未对齐的地址:

u16 *key2 = (u16 *) (keyc + 1);  // we've already got undefined behaviour *here*!
Run Code Online (Sandbox Code Playgroud)

有无数的程序员坚持认为,在x86-32和x86-64的任何地方都可以保证不对齐访问,并且在实践中不会有任何问题 - 好吧,他们都错了.

基本上发生的是编译器注意到的

for (size_t i = 0; i < len; ++i)
     hash += key2[i];
Run Code Online (Sandbox Code Playgroud)

如果适当对齐,可以使用SIMD指令更有效地执行.使用这些值加载到SSE寄存器中MOVDQA,这要求参数与16个字节对齐:

当源或目标操作数是内存操作数时,操作数必须在16字节边界上对齐,否则将生成一般保护异常(#GP).

对于指针在开始时没有适当对齐的情况,编译器将生成一个代码,该代码将逐个对第一个1-7个无符号短路求和,直到指针对齐到16个字节.

当然,如果你从一个指向奇数地址的指针开始,即使添加7次2也不会将一个地址与一个与16个字节对齐的地址.当然,编译器甚至不会生成将检测到这种情况的代码,因为"行为未定义,如果两个指针类型之间的转换产生错误对齐的结果" - 并且完全忽略了具有不可预测结果的情况,这意味着操作数MOVDQA将无法正确对齐,这将使程序崩溃.


可以很容易地证明,即使不违反任何严格的别名规则,也可能发生这种情况.考虑以下由2个翻译单元组成的程序(如果两个翻译单元f及其调用者都放在一个翻译单元中,我的GCC足够聪明,可以注意到我们在这里使用了打包结构,并且不生成代码MOVDQA):

翻译单位1:

#include <stdlib.h>
#include <stdint.h>

size_t f(uint16_t *keyc, size_t len)
{
    size_t hash = len;
    len = len / 2;

    for (size_t i = 0; i < len; ++i)
        hash += keyc[i];
    return hash;
}
Run Code Online (Sandbox Code Playgroud)

翻译单位2

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <inttypes.h>

size_t f(uint16_t *keyc, size_t len);

struct mystruct {
    uint8_t padding;
    uint16_t contents[100];
} __attribute__ ((packed));

int main(void)
{
    struct mystruct s;
    size_t len;

    srand(time(NULL));
    scanf("%zu", &len);

    char *initializer = (char *)s.contents;
    for (size_t i = 0; i < len; i++)
       initializer[i] = rand();

    printf("out %zu\n", f(s.contents, len));
}
Run Code Online (Sandbox Code Playgroud)

现在编译并将它们链接在一起:

% gcc -O3 unit1.c unit2.c
% ./a.out
25
zsh: segmentation fault (core dumped)  ./a.out
Run Code Online (Sandbox Code Playgroud)

请注意,那里没有别名冲突.唯一的问题是未对齐uint16_t *keyc.

-fsanitize=undefined下面的错误产生:

unit1.c:10:21: runtime error: load of misaligned address 0x7ffefc2d54f1 for type 'uint16_t', which requires 2 byte alignment
0x7ffefc2d54f1: note: pointer points here
 00 00 00  01 4e 02 c4 e9 dd b9 00  83 d9 1f 35 0e 46 0f 59  85 9b a4 d7 26 95 94 06  15 bb ca b3 c7
              ^ 
Run Code Online (Sandbox Code Playgroud)

  • `unsigned short`的对齐要求是实现定义的.您说"2的内在对齐",但该语句只能在特定实现的上下文中进行.OP的编译器文档必须指定它; 并且它可以用`_Alignof(unsigned short)`进行检查.也许您可以在程序中添加一个`_Static_assert`来确认这一点 (2认同)
  • 我不知道@ Antti的环境,但我观察到对于Linux x86_64上的gcc 4.8.5,`unsigned short`的对齐要求确实是2. (2认同)
  • “ _但是,您正在强迫u16 * key2指向未对齐的地址:_”;`keyc + 1`将不对齐* iff *`keyc`已对齐! (2认同)

Ser*_*sta 6

将指向对象的指针别名为指向char的指针,然后迭代原始对象中的所有字节是合法的.

当指向char的指针实际指向一个对象(通过前一个操作获得)时,将转换返回到指向原始类型的指针是合法的,并且标准要求您返回原始值.

但是将指向char的任意指针转换为指向对象的指针并取消引用获取的指针会违反严格别名规则并调用未定义的行为.

所以在你的代码中,以下行是UB:

const u16 *key2 = (const u16 *) (keyc + 1); 
// keyc + 1 did not originally pointed to a u16: UB
Run Code Online (Sandbox Code Playgroud)

  • 这不是这里的错 (3认同)

Fla*_*ire 6

为@Antti Haapala 的出色回答提供更多信息和常见陷阱:

TLDR:访问未对齐数据是 C/C++ 中的未定义行为 (UB)。未对齐的数据是地址(又名指针值)上的数据,它不能被对齐(通常是它的大小)整除。在(伪)代码中:bool isAligned(T* ptr){ return (ptr % alignof(T)) == 0; }

在解析通过网络发送的文件格式或数据时,经常会出现这个问题:您有一个不同数据类型的密集结构。示例是这样的协议:(struct Packet{ uint16_t len; int32_t data[]; };读作:16 位长度后跟 len 乘以 32 位 int 作为值)。你现在可以这样做:

char* raw = receiveData();
int32_t sum = 0;
uint16_t len = *((uint16_t*)raw);
int32_t* data = (int32_t*)(raw2 + 2);
for(size_t i=0; i<len; ++i) sum += data[i];
Run Code Online (Sandbox Code Playgroud)

行不通!如果假设raw对齐(在你的心中,你可以设置raw = 0它对准任何尺寸,0 % n == 0所有n),那么data就不可能对齐(假设对准==类型的大小):len在地址0,所以data在地址2和2 % 4 != 0。但是演员表告诉编译器“这个数据是正确对齐的”(“......因为否则它是UB,我们永远不会遇到UB”)。因此,在优化期间,编译器将使用 SIMD/SSE 指令来更快地计算总和,并且当给定未对齐的数据时,这些指令会崩溃。
旁注:有未对齐的 SSE 指令,但它们速度较慢,并且由于编译器假定您承诺的对齐方式,因此此处不使用它们。

您可以在@Antti Haapala 的示例中看到这一点,我将其缩短并放在 Godbolt 上供您使用:https ://godbolt.org/z/KOfi6V 。观看“程序返回:255”又名“崩溃”。

这个问题在反序列化例程中也很常见,如下所示:

char* raw = receiveData();
int32_t foo = readInt(raw); raw+=4;
bool foo = readBool(raw); raw+=1;
int16_t foo = readShort(raw); raw+=2;
...
Run Code Online (Sandbox Code Playgroud)

read*需要的字节序的关怀,往往是这样实现的:

int32_t readInt(char* ptr){
  int32_t result = *((int32_t*) ptr);
  #if BIG_ENDIAN
  result = byteswap(result);
  #endif
}
Run Code Online (Sandbox Code Playgroud)

请注意此代码如何取消引用一个指向较小类型的指针,该类型可能具有不同的对齐方式,并且您遇到了一些确切的问题。

这个问题非常普遍,甚至 Boost 在许多版本中都遇到过这个问题。Boost.Endian 提供了简单的字节序类型。来自 Godbolt 的 C 代码可以很容易地写成这样

#include <cstdint>
#include <boost/endian/arithmetic.hpp>


__attribute__ ((noinline)) size_t f(boost::endian::little_uint16_t *keyc, size_t len)
{
    size_t hash = 0;
    for (size_t i = 0; i < len; ++i)
        hash += keyc[i];
    return hash;
}

struct mystruct {
    uint8_t padding;
    boost::endian::little_uint16_t contents[100];
};

int main(int argc, char** argv)
{
    mystruct s;
    size_t len = argc*25;

    for (size_t i = 0; i < len; i++)
       s.contents[i] = i * argc;

    return f(s.contents, len) != 300;
}
Run Code Online (Sandbox Code Playgroud)

的类型little_uint16_t基本上是一些字符与从/到的隐式转换uint16_tbyteswap如果当前机器字节序是BIG_ENDIAN。在底层,Boost:endian 使用的代码类似于:

class little_uint16_t{
  char buffer[2];
  uint16_t value(){
    #if IS_x86
      uint16_t value = *reinterpret_cast<uint16_t*>(buffer);
    #else
    ...
    #endif
    #if BIG_ENDIAN
    swapbytes(value);
    #endif
    return value;
};
Run Code Online (Sandbox Code Playgroud)

它使用的知识在x86架构上对齐访问是可能的。来自未对齐地址的加载速度稍慢,但即使在汇编级别上也与来自对齐地址的加载相同。

然而,“可能”并不意味着有效。如果编译器用SSE 指令替换了“标准”加载,那么这将失败,正如在Godbolt所见。这很长时间没有引起注意,因为这些 SSE 指令只是在使用相同操作处理大块数据时使用,例如添加一个值数组,这就是我在这个例子中所做的。这在Boost 1.69 中得到修复,使用memcopy它可以转换为 ASM 中的“标准”加载指令,该指令支持 x86 上的对齐和未对齐数据,因此与强制转换版本相比没有减速。但是如果没有进一步检查,它不能被翻译成对齐的 SSE 指令。

要点:不要在演员表中使用快捷方式。对每个演员表保持怀疑,尤其是从较小的类型进行演员表时,并检查对齐是否错误或使用安全的 memcpy。

  • @Alexis:在 Nehalem 和更新版本上,“movdqu”与“movdqa”*对于对齐负载*(或者实际上对于不跨越缓存行边界的任何负载)具有相同的速度。“movdqu”在缓存行分割上确实具有更高的延迟和更差的吞吐量,并且在页面分割上更糟糕。((而不仅仅是错误)。此外,如果没有 AVX,只有 `_mm_load_si128` 可以折叠到 ALU 指令的内存源中,例如 `paddd xmm0, [rdi]`。使用 `loadu` 编译器将需要 `movdqu xmm1, [ rdi]` / `paddd xmm0, xmm1`。(使用 AVX,内存操作数默认不需要对齐,仅适用于 `vmovdqa`。) (2认同)