C 未定义的行为。严格的别名规则,还是不正确的对齐方式?

C undefined behavior. Strict aliasing rule, or incorrect 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));
}

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

编辑

我把unsigned char改成了char,去掉了const,还是有段错误。

编辑2

我知道这段代码不好,但据我所知严格的别名规则,它应该可以正常工作。具体违规在哪里?

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

当一个char指针实际指向一个对象(已经通过前面的操作得到)时,转换回原始类型的指针是合法的,标准要求你取回原始值.

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

因此在您的代码中,以下行是 UB:

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

该代码确实违反了严格的别名规则。但是,不仅 存在混叠违规,而且崩溃不会因为混叠违规 而发生。这是因为 unsigned short 指针 对齐不正确 ;如果结果没有适当对齐,甚至 指针转换 本身也是未定义的。

C11 (draft n1570) Appendix J.2:

1 The behavior is undefined in the following circumstances:

....

  • Conversion between two pointer types produces a result that is incorrectly aligned (6.3.2.3).

6.3.2.3p7

[...] If the resulting pointer is not correctly aligned [68] for the referenced type, the behavior is undefined. [...]

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 instructions if suitably aligned. The values are loaded into the SSE registers using MOVDQA更有效地执行,它要求参数对齐到16字节:

When the source or destination operand is a memory operand, the operand must be aligned on a 16-byte boundary or a general-protection exception (#GP) will be generated.

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

当然,如果您从一个指向奇数地址的指针开始,即使将7乘以2也不会落到与16字节对齐的地址。当然,编译器甚至不会生成检测这种情况的代码,如 "the behaviour is undefined, if conversion between two pointer types produces a result that is incorrectly aligned" - 并忽略 the situation completely with unpredictable results,这意味着 MOVDQA 的操作数不会正确对齐,这将然后让程序崩溃。


很容易证明,即使不违反任何严格的别名规则,这也可能发生。考虑以下由 2 翻译单元组成的程序(如果 f 及其调用者都放入 one 翻译单元,我的 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;
}

翻译单元 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));
}

现在一起编译和link它们:

% 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
              ^ 

除非代码做了一些事情来确保字符类型的数组对齐,否则不应特别期望它会对齐。

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

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

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

为@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];

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

您可以在@Antti Haapala 的示例中看到这一点,我将其缩短并放在 godbolt 上供您试用:https://godbolt.org/z/KOfi6V。观看 "program returned: 255" 又名 "crashed"。

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

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

read* 负责字节顺序,通常是这样实现的:

int32_t readInt(char* ptr){
  int32_t result = *((int32_t*) ptr);
  #if BIG_ENDIAN
  result = byteswap(result);
  #endif
}

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

这个问题非常普遍,甚至连 Boost 也经历了很多版本。 Boost.Endian 提供简单的字节序类型。 Godbolt 的 C 代码可以很容易地写成 this:

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

类型 little_uint16_t 基本上只是一些具有隐式转换的字符 from/to 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;
};

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

然而"possible"并不代表有效。如果编译器用 SSE 指令替换了 "standard" 加载,那么这将失败,正如在 godbolt. This went unnoticed for a long time because those SSE instructions are just used when processing large chunks of data with the same operation, e.g. adding an array of values which is what I did for this example. This was fixed in Boost 1.69 上看到的那样,使用 memcopy 可以将其转换为 ASM 中的 "standard" 加载指令它支持 x86 上的对齐和未对齐数据,因此与 cast 版本相比没有减速。但它不能在没有进一步检查的情况下被翻译成对齐的 SSE 指令。

要点:不要对转换使用快捷方式。对 every 投射保持怀疑,尤其是从较小的类型投射时,并检查对齐是否有误或使用安全的 memcpy。