C++数组指针运算导致未定义行为

20

为什么这个程序的输出是4

#include <iostream>

int main()
{
    short A[] = {1, 2, 3, 4, 5, 6};
    std::cout << *(short*)((char*)A + 7) << std::endl;
    return 0;
}

根据我的理解,在 x86 小端系统上,char 有 1 字节,short 有 2 字节,因此输出应为 0x0500,因为数组 A 中的数据在十六进制中如下:

01 00 02 00 03 00 04 00 05 00 06 00

我们向前移动了7个字节,然后读取2个字节。我错过了什么吗?


8
如果这是未定义行为,我不会感到惊讶。我知道至少有一些CPU体系结构会捕获未对齐的访问。尝试将字节memcpy()到一个short中,然后输出该值。 - Ulrich Eckhardt
你使用的是哪个版本的GCC? - Jonathon Reinhart
@JonathonReinhart:g++ 5.4.1-2 - Jacek Skiba
7
行为未定义,编译器可以为所欲为。 - n. m.
6
标题提到了C语言,但问题实际上是关于C++的。请不要混淆这两种语言,它们是不同的语言。此外,“在x86小端系统上,char占1字节,short占2字节” - 在x86上short占2字节可能很常见,但不是保证的。 - marcelm
3个回答

22

你在这里违反了严格别名规则。你不能只读取对象的一半并假装它是一个完整的对象。你不能使用字节偏移量来发明假想的对象。当你将程序交给GCC时,它完全有权像回到过去杀死猫王(Elvis Presley)那样做出疯狂的操作。

可以使用char*检查和操作组成任意对象的字节。利用这个特权:

#include <iostream>
#include <algorithm>

int main()
{
    short A[] = {1, 2, 3, 4, 5, 6};

    short B;
    std::copy(
       (char*)A + 7,
       (char*)A + 7 + sizeof(short),
       (char*)&B
    );
    std::cout << std::showbase << std::hex << B << std::endl;
}

// Output: 0x500

(演示)

但是您不能在原始集合中“凭空创造”一个不存在的对象。

此外,即使您有一个可以被告知忽略此问题的编译器(例如使用GCC的-fno-strict-aliasing开关),虚构的对象对于任何当前主流体系结构都不正确地对齐。 short类型无法合法地存在于内存中的奇数位置,因此您不能假装在那里有一个short类型的变量。 实际上,无法规定原始代码的行为方式,没有任何方法可以绕过该问题; 事实上,如果您向GCC传递-fsanitize=undefined开关,它会告诉您这一点。

我稍微简化了一下。


2
你说得完全正确,编译器在这种情况下可以随意处理。但是话虽如此,我却无法理解,即使禁用了优化并传递了“-fno-strict-aliasing”,GCC在使用中间的“short”或“short *”变量时会有不同的行为。 - Jonathon Reinhart
2
@JonathanWakely:两者皆是。在解引用地址时,不存在动态类型为 short 的对象,因此使用类型为 short 的左值读取数据违反了严格别名规则。如果你尝试向未对齐的地址写入数据,则问题只存在于内存对齐方面。 - Ben Voigt
1
@BenVoigt,为什么?[basic.lval]是指访问值而不仅仅是读取,访问意味着读取或修改([defns.access])。但是你不能通过无效指针访问一个short - Jonathan Wakely
1
因为当您重复使用存储器(如果它具有足够的大小和对齐方式-这不是),您将获得一个全新的对象,该对象是您编写的类型,结束了先前存在的任何对象的生命周期。然后,如果类型不匹配,当您尝试使用原始标识符读取它时,您将在稍后遇到严格别名问题。好吧,如果您尝试使用没有平凡初始化的类型值进行覆盖,则必须使用放置new调用构造函数。但是,short确实具有平凡初始化。 - Ben Voigt
2
@LightnessRacesinOrbit 是的,我完全同意问题在于试图访问一个不存在的对象(或者甚至是在有效程序中无法存在的对象,因为它们不对齐)。我的观点是,期望-fno-strict-aliasing有任何作用是错误的,因为比严格别名更根本的问题存在。这不是“你将类型为int的对象作为类型short访问了”,而是“你将世界之间的间隙作为类型short访问了,那里是Xitalu居住的地方,它是一种有着触手、多眼、吞噬灵魂的可怕物体,而不是一个short。” -fno-strict-aliasing不能解除它的召唤。 - Jonathan Wakely
显示剩余11条评论

12
由于将一个未正确对齐的指针转换为(short*),该程序存在未定义的行为。这违反了C11中6.3.2.3 p6的规定,与其他答案声称的严格别名无关:

对象类型的指针可以转换为不同对象类型的指针。如果所得到的指针没有正确对齐引用类型,则行为是未定义的。

在C++中,在[expr.static.cast]的第13段中,将未对齐的char*转换为short*会给出一个未指定的指针值,它可能是无效指针,无法进行解引用操作。
检查字节的正确方法是通过char*而不是将其强制转换回short*并假装在无法存储short的地址处有一个short

3
一个规则被破坏并不涉及到“严格别名”的问题,并不能说明别名是没问题的。它只是意味着多个规则都被破坏了。在一个short没有对齐要求的平台上,这点尤为重要——代码仍然是有问题的。 - Ben Voigt
1
只有当short所需的对齐方式是7的因数(即值为17)时,行为才被明确定义。 short没有对齐要求(即与char相同的对齐方式,即1)非常罕见,在实践中,short具有某些倍数的对齐要求,这些倍数既不是1也不是7。 我还没有遇到任何对齐要求为77的倍数的实现。 - Peter
7
@Peter "没有一个硬件架构与 7 对齐。此外,7 太小了,只有邪恶代码才会尝试访问小数字内存。" 很抱歉 - 忍不住分享一下,米肯斯实在太有趣了。 - Maciej Piechotka
1
你引用的是C11标准,与问题中的C++程序关系甚微。 - pipe
这是对Jonathon R重写的示例作为GCC错误报告的回应,同时我也指出了相关的C++规则[expr.static.cast] p13。 - Jonathan Wakely
1
这个问题最初被标记为C,标题中也有C的字样,但是展示的是C++代码。 - Jonathon Reinhart

3

这可能是GCC中的一个bug。

首先,需要注意的是,您的代码违反了严格别名规则,因此会引起未定义行为。

话虽如此,我认为这是一个bug的原因是:

  1. 当该表达式首次赋值给一个中间的shortshort *时,会产生预期的行为。只有在将表达式直接作为函数参数传递时,才会出现意外的行为。

  2. 即使使用-O0 -fno-strict-aliasing编译,也会发生此情况。

我用C重新编写了您的代码,以消除任何C++的疯狂行为可能性。毕竟,您的问题曾经被标记为c!我添加了pshort函数以确保不涉及可变参数性质的printf

#include <stdio.h>

static void pshort(short val)
{
    printf("0x%hx ", val);
}

int main(void)
{
    short A[] = {1, 2, 3, 4, 5, 6};

#define EXP ((short*)((char*)A + 7))

    short *p = EXP;
    short q = *EXP;

    pshort(*p);
    pshort(q);
    pshort(*EXP);
    printf("\n");

    return 0;
}

使用 gcc (GCC) 7.3.1 20180130 (Red Hat 7.3.1-2)编译后:

gcc -O0 -fno-strict-aliasing -g -Wall -Werror  endian.c

输出:

0x500 0x500 0x4

看起来当表达式直接用作参数时,GCC实际上正在生成不同的代码,尽管我明显在使用相同的表达式(EXP)。

使用objdump -Mintel -S --no-show-raw-insn endian进行转储:

int main(void)
{
  40054d:   push   rbp
  40054e:   mov    rbp,rsp
  400551:   sub    rsp,0x20
    short A[] = {1, 2, 3, 4, 5, 6};
  400555:   mov    WORD PTR [rbp-0x16],0x1
  40055b:   mov    WORD PTR [rbp-0x14],0x2
  400561:   mov    WORD PTR [rbp-0x12],0x3
  400567:   mov    WORD PTR [rbp-0x10],0x4
  40056d:   mov    WORD PTR [rbp-0xe],0x5
  400573:   mov    WORD PTR [rbp-0xc],0x6

#define EXP ((short*)((char*)A + 7))

    short *p = EXP;
  400579:   lea    rax,[rbp-0x16]             ; [rbp-0x16] is A
  40057d:   add    rax,0x7
  400581:   mov    QWORD PTR [rbp-0x8],rax    ; [rbp-0x08] is p
    short q = *EXP;
  400585:   movzx  eax,WORD PTR [rbp-0xf]     ; [rbp-0xf] is A plus 7 bytes
  400589:   mov    WORD PTR [rbp-0xa],ax      ; [rbp-0xa] is q

    pshort(*p);
  40058d:   mov    rax,QWORD PTR [rbp-0x8]    ; [rbp-0x08] is p
  400591:   movzx  eax,WORD PTR [rax]         ; *p
  400594:   cwde   
  400595:   mov    edi,eax
  400597:   call   400527 <pshort>
    pshort(q);
  40059c:   movsx  eax,WORD PTR [rbp-0xa]      ; [rbp-0xa] is q
  4005a0:   mov    edi,eax
  4005a2:   call   400527 <pshort>
    pshort(*EXP);
  4005a7:   movzx  eax,WORD PTR [rbp-0x10]    ; [rbp-0x10] is A plus 6 bytes ********
  4005ab:   cwde   
  4005ac:   mov    edi,eax
  4005ae:   call   400527 <pshort>
    printf("\n");
  4005b3:   mov    edi,0xa
  4005b8:   call   400430 <putchar@plt>

    return 0;
  4005bd:   mov    eax,0x0
}
  4005c2:   leave  
  4005c3:   ret
  • 我使用来自Docker Hub的GCC 4.9.4和GCC 5.5.0得到了相同的结果

5
假装一个非“short”类型是“short”类型,“不是未定义行为”的意思是什么? - Lightness Races in Orbit
6
整个前提是从一个“short”中读取一个字节,从另一个“short”中读取另一个字节,然后假装它们是一个“short”。但实际上它们并不是。依赖于这种技巧的代码库要么(a)通过研究或因为相同的人编写,精确地知道他们使用的编译器会做什么,要么(b)是有缺陷的。这就是为什么你必须在第一次明确地给出该标志:告诉编译器 “我正在冒着自己的责任踏出C++有效领域”的原因。 - Lightness Races in Orbit
4
我不明白为什么这应该是一个错误。当编译器面对不符合规范的代码时,它可以自由地做任何事情。期望行为在编译器的不同环境配置下应该相同是没有根据的。这种期望才是错误所在。 - IInspectable
4
-fno-strict-aliasing并不意味着“允许未定义的废物”,也不意味着您可以忽略对齐要求。尝试使用-fsanitize=undefined编译,并查看是否有投诉(提示:会有)。 - Jonathan Wakely
3
更具体地说,这不是由于严格别名违规引起的。该程序中的未定义行为来自C11中的6.3.2.3 p6,并且不能通过-fstrict-aliasing-fno-strict-aliasing来控制:“一个对象类型的指针可以转换为指向不同对象类型的指针。如果得到的指针未正确对齐以适用于被引用的类型,则其行为未定义。” - Jonathan Wakely
显示剩余18条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接