当前位置: 首页 > 知识库问答 >
问题:

C未定义的行为。严格的别名规则,还是不正确的对齐?[副本]

欧阳昊焱
2023-03-14

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

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

因此,当使用-O3和gcc编译它,并使用参数25运行时,它会引发一个segfault。如果没有优化,它可以正常工作。我已经对它进行了反汇编:它正在进行矢量化,编译器假设key2数组以16字节对齐,因此它使用movdqa。显然是UB,尽管我无法解释。我知道严格别名规则,但情况并非如此(我希望如此),因为据我所知,严格别名规则不适用于chars。为什么gcc假设这个指针是对齐的?即使进行了优化,Clang也可以很好地工作。

编辑

我将无符号字符更改为字符,并删除了常量,它仍然存在故障。

编辑2

我知道这段代码不好,但就我所知,它应该可以正常工作,因为我知道严格的别名规则。违规行为到底在哪里?

共有3个答案

双恩
2023-03-14

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

TLDR:在C/C中,对未对齐数据的访问是未定义的行为(UB)。未对齐数据是指地址(也称为指针值)处的数据,该地址不能被对齐(通常是其大小)均匀整除。在(伪)代码中:boolisaligned(T*ptr){return(ptr%alignof(T))==0;}

解析通过网络发送的文件格式或数据时,经常会出现这个问题:您有不同数据类型的密集打包结构。例如这样的协议结构包{uint16_tlen;int32_t数据[]; };(读取为: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是对齐的(在您的头脑中,您可以将所有nraw=0设置为0%n==0),那么数据就不可能对齐(假设对齐==类型大小):len位于地址0,所以数据位于地址2和2%4!=0。但是cast告诉编译器“这个数据是正确对齐的”(“…因为否则它就是UB,我们永远不会遇到UB”)。因此,在优化过程中,编译器将使用SIMD/SSE指令来更快地计算总和,并且当给定未对齐的数据时,这些指令会崩溃。
旁注:有未对齐的SSE指令,但它们速度较慢,并且由于编译器假定您promise的对齐方式,因此此处不使用它们。

你可以在@Antti Haapala的例子中看到这一点,我缩短了这个例子,并把它放在戈德博尔特供你使用: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;
...

read*处理endianess,通常是这样实现的:

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

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

这个问题非常普遍,甚至Boost在许多版本中都遇到了这个问题。还有Boost.Endian,它提供了简单的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;
}

如果当前机器端号是大端号,则类型little\u uint16\u t基本上只是一些字符,通过byteswap隐式转换为uint16\u t。在引擎盖下,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体系结构上可以进行未对齐访问的知识。来自未对齐地址的加载稍微慢一点,但即使在汇编程序级别上,也与来自对齐地址的加载相同。

然而“可能”并不意味着有效。如果编译器用一条SSE指令替换了“标准”负载,那么就会失败,就像在戈德博尔特上看到的那样。这在很长一段时间内没有被注意到,因为这些SSE指令只是在使用相同的操作处理大块数据时使用的,例如添加一个值数组,这就是我在这个例子中所做的。这在Boost 1.69中通过使用membert进行了修复,该版本可以翻译成ASM中的“标准”加载指令,支持x86上的对齐和未对齐数据,因此与强制转换版本相比没有减速。但如果没有进一步的检查,它不能被翻译成一致的上交所指令。

外卖:不要对强制转换使用快捷方式。对每一次浇铸都要怀疑,尤其是从较小的类型浇铸时,要检查对齐是否正确,或者使用安全记忆。

须志新
2023-03-14

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

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

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

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

const u16 *key2 = (const u16 *) (keyc + 1); 
// keyc + 1 did not originally pointed to a u16: UB
柳英豪
2023-03-14

代码确实打破了严格的混淆现象规则。然而,不仅存在混淆现象,而且由于混淆现象,崩溃不会发生。发生这种情况是因为无符号短指针对齐不正确;如果结果没有适当对齐,甚至指针转换本身也是未定义的。

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字节边界上对齐,否则将生成通用保护异常(#GP)。

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

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

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

翻译单元一:

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

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

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

注意那里没有混淆现象。唯一的问题是未对齐的uint16_t*keyc

使用-fsanitize=未定义的会产生以下错误:

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
              ^ 
 类似资料:
  • 我正在阅读关于reinterpret_cast的笔记,它是混淆现象(http://en.cppreference.com/w/cpp/language/reinterpret_cast)。 我写了代码: 我认为这些规则在这里不适用: T2是对象的(可能是cv限定的)动态类型 T2和T1都是指向相同类型T3的指针(可能是多级的,可能在每个级别都是cv限定的)(因为C11) T2是一个聚合类型或并集类

  • 基本上,当启用严格别名时,此代码是否合法? 这里,我们通过另一种类型的指针(指向<code>void*</code>的指针)访问一种类型(<code<int*</code>)的对象,因此我认为这确实是一种严格的别名冲突。 但是,试图突出未定义行为的样本使我怀疑(即使它不能证明它是合法的)。 首先,如果我们将<code>int*</code>和<code>char*</code>别名,我们可以根据优

  • 我一直在温习我未定义的行为规则,并阅读了以下内容: 未定义的行为和序列点 为什么f(i=-1,i=-1)行为未定义 为什么`x-- 在C 11中,“i = i 1”是否表现出未定义的行为? 最后有三个问题: < li >形式为< code>i=i 的术语的未定义行为规则是否适用于非整型?(表达式应翻译为< code > I . operator(I . operator(I)),由于每个函数调用都

  • 在Drools中考虑一组组成激活组的规则,所有规则都具有相同的显著性,并且所有规则都被激活。因为他们在一个激活组中,所以其中只有一个可以开火。 我想知道Drools引擎是否确保这些规则中的每一条都具有大致相同的触发机会(选择是随机的),或者是否只是形式上未定义将触发哪个规则(选择是不确定的)。在后一种情况下,触发的规则将取决于机器的状态(如果有),并且在实践中,例如,几乎总是会触发最高规则。

  • 考虑以下C程序: null 访问易失性对象、修改对象、修改文件,或者调用执行那些操作中的任何操作的函数都是副作用,它们是执行环境状态的改变。表达式的计算通常包括值计算和副作用的启动。用于lvalue表达式的值计算包括确定指定对象的标识。 Sequenced before是单线程执行的计算之间的非对称、传递、成对关系,它导致这些计算之间的部分顺序。给定任意两个评价A和B,如果A排序在B之前,那么A的

  • C++标准为不明确的1行为提供了数量惊人的定义,这些定义的含义或多或少是相同的,但有细微的差别。阅读这个答案时,我注意到了“程序格式不正确;不需要诊断”的措辞。 实现定义与未指定行为的不同之处在于,在前一种情况下,实现必须清楚地记录它正在做什么(在后一种情况下,它不需要),两者都是格式良好的。未定义行为与未指定行为的不同之处在于程序是错误的(1.3.13)。 否则,它们都有一个共同点,即标准对实现