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

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

#include  #include  #include  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)); } 

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

编辑

我将unsigned char更改为char ,并删除了const ,它仍然是segfaults。

EDIT2

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

代码确实打破了严格的别名规则。 但是, 不仅存在别名冲突,而且由于别名冲突而不会发生崩溃 。 这是因为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"); 

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

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

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

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

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

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

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

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

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


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

翻译单位1

 #include  #include  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; } 

翻译单位2

 #include  #include  #include  #include  #include  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)); } 

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

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

请注意,那里没有别名冲突。 唯一的问题是未对齐的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 ^ 

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

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

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

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

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

除非代码确实能够确保字符类型的数组对齐,否则它不应该特别期望它。

如果对齐得到处理,代码将获取其地址一次,将其转换为另一种类型的指针,并且永远不会通过任何不是从后一指针派生的方式访问存储,那么为低级编程设计的实现应该没有特定的难以将存储视为抽象缓冲区。 由于这种处理并不困难,并且对于某些类型的低级编程是必要的(例如,在malloc()可能不可用的上下文中实现内存池),不支持这种结构的实现不应该声称是合适的用于低级编程。

因此,在为低级编程设计的实现中,您描述的构造将允许将适当对齐的数组视为无类型存储。 遗憾的是,没有简单的方法来识别这样的实现,因为主要为低级编程设计的实现通常无法列出作者认为这些实现在环境的时尚特征中表现得很明显的所有情况(而那些设计专注于其他目的的人可能声称适合低级编程,即使他们的行为不恰当。

该标准的作者认识到C是非便携式程序的有用语言,并且明确表示他们不希望将其用作“高级汇编程序”。 然而,他们期望用于各种目的的实现将支持流行扩展以促进这些目的,而不考虑标准是否要求它们这样做,因此没有必要让标准解决这些问题。 因为这样的意图被降级为基本原理而不是标准,但是,一些编译器编写者认为标准是对程序员应该从实现中期望的一切的完整描述,因此可能不支持低级概念,如静态的使用 – 或自动持续时间对象作为有效无类型缓冲区。