GCC、严格别名和通过联合转换类型

38

你有任何恐怖故事要讲吗?GCC手册最近新增了一条关于-fstrict-aliasing和通过联合转换指针的警告:

[...] 取地址,将结果指针强制转换并解引用结果具有未定义的行为 [强调添加],即使转换使用联合类型,例如:

    union a_union {
        int i;
        double d;
    };

    int f() {
        double d = 3.0;
        return ((union a_union *)&d)->i;
    }

有没有例子可以说明这个未定义行为?
注意,这个问题不是关于C99标准的规定或不规定。而是关于现今gcc和其他编译器的实际运行情况。
我猜测,一个潜在的问题可能在于将d设置为3.0。因为d是一个临时变量,从来没有直接读取过,也没有通过“相当兼容”的指针读取过,所以编译器可能不会费心去设置它。然后f()将从堆栈中返回一些垃圾值。
我的简单、天真的尝试失败了。例如:
#include <stdio.h>

union a_union {
    int i;
    double d;
};

int f1(void) {
    union a_union t;
    t.d = 3333333.0;
    return t.i; // gcc manual: 'type-punning is allowed, provided...' (C90 6.3.2.3)
}

int f2(void) {
    double d = 3333333.0;
    return ((union a_union *)&d)->i; // gcc manual: 'undefined behavior' 
}

int main(void) {
    printf("%d\n", f1());
    printf("%d\n", f2());
    return 0;
}

运行良好,在CYGWIN上:

-2147483648
-2147483648

看汇编代码,我们可以发现gcc已经完全优化掉了tf1()只是简单地存储预先计算好的答案:

movl    $-2147483648, %eax

f2()将3333333.0推入浮点数栈中,然后提取返回值:

flds   LC0                 # LC0: 1246458708 (= 3333333.0) (--> 80 bits)
fstpl  -8(%ebp)            # save in d (64 bits)
movl   -8(%ebp), %eax      # return value (32 bits)

这些函数也是内联的(这似乎是一些微妙的严格别名错误的原因),但这与本文无关。(这个汇编程序也不太相关,但它增加了证实的细节。)

另外要注意的是,取地址显然是错误的(或者如果您试图说明未定义行为,则是正确的)。例如,就像我们知道这是错误的:

extern void foo(int *, double *);
union a_union t;
t.d = 3.0;
foo(&t.i, &t.d); // undefined behavior

我们同样知道这是错误的:

extern void foo(int *, double *);
double d = 3.0;
foo(&((union a_union *)&d)->i, &d); // undefined behavior

关于这个问题的背景讨论,可以参考以下内容:

http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1422.pdf
http://gcc.gnu.org/ml/gcc/2010-01/msg00013.html
http://davmac.wordpress.com/2010/02/26/c99-revisited/
http://cellperformance.beyond3d.com/articles/2006/06/understanding-strict-aliasing.html
(= 谷歌搜索页面,然后查看缓存页面)

什么是严格别名规则?
C++中的C99严格别名规则(GCC)

在第一个链接中,ISO会议的草案记录显示,在第4.16节中,一位与会者指出:

有没有人认为规则足够清晰明了? 没有人真正能够解释它们。

其他注意事项:我的测试使用gcc 4.3.4,并带有-O2; 选项-O2和-O3意味着-fstrict-aliasing。 GCC手册中的示例假定sizeof(double) >= sizeof(int); 如果它们不相等,则无关紧要。

此外,正如Mike Acton在cellperformace链接中指出的那样,-Wstrict-aliasing = 2,但不是=3,对于此处的示例会产生warning: dereferencing type-punned pointer might break strict-aliasing rules


1
你编译时使用了哪个优化级别?优化级别越高,编译器依赖严格别名规则的可能性就越大。 (顺便说一句,ISO标准中的许多部分都可以应用这个委员会会议记录中的引述 :-P) - James McNellis
你可以看一下这个例子:https://dev59.com/1nI-5IYBdhLWcg3whYsr#1812359 - James McNellis
请注意,联合体的对齐要求可能比其各个单独成员的要求更强。 - Simon Richter
我相信标准将其定义为未定义行为。基于编译器今天的行为做出判断是非常危险的,因为你永远不知道编译器将来可能会做什么。 - M.M
@PaulR 注意,在当今任何广泛使用的编译器中,对于x86-64架构,int始终为32位,double始终为64位。long和指针值的大小取决于您使用的编译器:Windows上,long为32位,指针为64位;而在几乎所有其他平台上,long和指针都是64位。 - programmerjake
显示剩余2条评论
7个回答

13

虽然GCC警告联合体,但并不一定意味着联合体当前无法工作。以下是一个比您的例子稍微复杂一些的示例:

#include <stdio.h>

struct B {
    int i1;
    int i2;
};

union A {
    struct B b;
    double d;
};

int main() {
    double d = 3.0;
    #ifdef USE_UNION
        ((union A*)&d)->b.i2 += 0x80000000;
    #else
        ((int*)&d)[1] += 0x80000000;
    #endif
    printf("%g\n", d);
}

输出:

$ gcc --version
gcc (GCC) 4.3.4 20090804 (release) 1
Copyright (C) 2008 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ gcc -oalias alias.c -O1 -std=c99 && ./alias
-3

$ gcc -oalias alias.c -O3 -std=c99 && ./alias
3

$ gcc -oalias alias.c -O1 -std=c99 -DUSE_UNION && ./alias
-3

$ gcc -oalias alias.c -O3 -std=c99 -DUSE_UNION && ./alias
-3

在GCC 4.3.4上,联合体“挽救了一天”(假设我想要输出“-3”)。它禁用了依赖于严格别名的优化,这导致第二种情况(仅此情况)的输出为“3”。使用-Wall选项时,USE_UNION还会禁用类型转换警告。

我没有gcc 4.4进行测试,但请尝试运行此代码。实际上,您的代码测试了在通过联合访问之前是否初始化了d的内存:我的代码则测试其是否被修改。

顺便说一下,将double型的一半作为int读取的安全方法是:

double d = 3;
int i;
memcpy(&i, &d, sizeof i);
return i;

在启用GCC优化的情况下,这将导致:

    int thing() {
401130:       55                      push   %ebp
401131:       89 e5                   mov    %esp,%ebp
401133:       83 ec 10                sub    $0x10,%esp
        double d = 3;
401136:       d9 05 a8 20 40 00       flds   0x4020a8
40113c:       dd 5d f0                fstpl  -0x10(%ebp)
        int i;
        memcpy(&i, &d, sizeof i);
40113f:       8b 45 f0                mov    -0x10(%ebp),%eax
        return i;
    }
401142:       c9                      leave
401143:       c3                      ret

所以实际上没有调用memcpy。如果你没有这样做,那么如果union转换在GCC中停止工作,那么你就会得到你应得的结果;-)


问题的重点在于GCC手册指出联合体并不总是“救命稻草”。但是每个测试似乎都表明,就像你的一样,它们实际上可以正常工作。当然,除了cellperformance链接中标记为INVALID的示例之外。 - Joseph Quinsey
手册(至少你引用的部分)说明代码具有未定义的行为。这并不排除联合体禁用严格别名假设的可能性。因此,联合体可能总是“拯救全局”,GCC仅包含额外的警告来教育用户,并为某些未来的更改做好准备。这可能会发生,也可能不会发生。我猜想,如果有一个失败案例,GCC优化专家可以从第一原理构建出来,否则就是随机搜索。 - Steve Jessop
同意!我不是gcc的专家,这就是为什么我来SO寻求帮助的原因。 - Joseph Quinsey
@SteveJessop,那么建议使用memcpy而不是联合或隐式转换吗? - Har

5
这是一篇旧帖回复,但下面有个恐怖故事。我正在移植一个程序,它是以本机字节顺序为大端的假设编写的。现在我需要它也能在小端上运行。不幸的是,我无法仅在每个地方使用本机字节顺序,因为数据可以通过多种方式访问。例如,64位整数可以被视为两个32位整数或4个16位整数,甚至可以被视为16个4位整数。更糟糕的是,没有办法弄清楚存储在内存中的确切内容,因为软件是一种某种类型字节码的解释器,数据由该字节码形成。例如,字节码可能包含将一个16位整数数组写入并访问其中一对作为32位浮点数的指令。而且无法预测或更改字节码。

因此,我不得不创建一组包装类来处理以大端序存储的值,而不考虑本机的尾端序。在Visual Studio和Linux上的GCC中没有启用优化后,它工作得非常完美。但是,使用gcc -O2就会出现问题。经过大量调试,我发现问题出在这里:

double D;
float F; 
Ul *pF=(Ul*)&F; // Ul is unsigned long
*pF=pop0->lu.r(); // r() returns Ul
D=(double)F; 

这段代码用于将存储在32位整数中的float的32位表示转换为double。似乎编译器决定在赋值给D之后再对*pF进行赋值,结果第一次执行代码时D的值是垃圾值,而随后的值则“滞后”了1个迭代。
奇迹般的是,在那时并没有其他问题。所以我决定继续测试我的新代码,原来的平台是HP-UX,运行在具有本地大端顺序的RISC处理器上。现在它又坏了,这次是在我的新类中:
typedef unsigned long long Ur; // 64-bit uint
typedef unsigned char Uc;
class BEDoubleRef {
        double *p;
public:
        inline BEDoubleRef(double *p): p(p) {}
        inline operator double() {
                Uc *pu = reinterpret_cast<Uc*>(p);
                Ur n = (pu[7] & 0xFFULL) | ((pu[6] & 0xFFULL) << 8)
                        | ((pu[5] & 0xFFULL) << 16) | ((pu[4] & 0xFFULL) << 24)
                        | ((pu[3] & 0xFFULL) << 32) | ((pu[2] & 0xFFULL) << 40)
                        | ((pu[1] & 0xFFULL) << 48) | ((pu[0] & 0xFFULL) << 56);
                return *reinterpret_cast<double*>(&n);
        }
        inline BEDoubleRef &operator=(const double &d) {
                Uc *pc = reinterpret_cast<Uc*>(p);
                const Ur *pu = reinterpret_cast<const Ur*>(&d);
                pc[0] = (*pu >> 56) & 0xFFu;
                pc[1] = (*pu >> 48) & 0xFFu;
                pc[2] = (*pu >> 40) & 0xFFu;
                pc[3] = (*pu >> 32) & 0xFFu;
                pc[4] = (*pu >> 24) & 0xFFu;
                pc[5] = (*pu >> 16) & 0xFFu;
                pc[6] = (*pu >> 8) & 0xFFu;
                pc[7] = *pu & 0xFFu;
                return *this;
        }
        inline BEDoubleRef &operator=(const BEDoubleRef &d) {
                *p = *d.p;
                return *this;
        }
};

由于某些非常奇怪的原因,第一个赋值运算符只正确地分配了1到7字节。字节0总是有一些无意义的内容,这破坏了一切,因为存在符号位和排序部分。

我尝试使用联合作为解决方法:

union {
    double d;
    Uc c[8];
} un;
Uc *pc = un.c;
const Ur *pu = reinterpret_cast<const Ur*>(&d);
pc[0] = (*pu >> 56) & 0xFFu;
pc[1] = (*pu >> 48) & 0xFFu;
pc[2] = (*pu >> 40) & 0xFFu;
pc[3] = (*pu >> 32) & 0xFFu;
pc[4] = (*pu >> 24) & 0xFFu;
pc[5] = (*pu >> 16) & 0xFFu;
pc[6] = (*pu >> 8) & 0xFFu;
pc[7] = *pu & 0xFFu;
*p = un.d;

但是它仍然没有起作用。事实上,这次它稍微好些——它只对负数失效。

在这一点上,我考虑添加一个本地字节序的简单测试,然后通过使用char*指针来完成所有操作,并在其周围加上if (LITTLE_ENDIAN)检查。更糟糕的是,该程序大量使用联合体,目前似乎工作正常,但在这场混乱之后,如果它突然无缘无故地出现问题,我不会感到惊讶。


有点晚了,但你可以尝试使用-fno-strict-aliasing编译,这将允许那些指针操作,但可能会牺牲一些性能。 - user2472093
@user2472093,然后在其他编译器上咬我?不,谢谢。事实上,我想我已经遇到过了。关于在发布配置中在MSVC上崩溃的问题。最糟糕的是,它只在数百个值中的一个特定值上崩溃,而且那个特定值只有一个错误的位。但是那个位是在顺序部分,所以结果完全不同。 - Sergei Tachenov
@user2472093:任何一个像样的编译器都应该有一个等同于“-fno-strict-aliasing”的选项;记录使用这种选项的要求可能比依赖编译器遵守任何特定别名规则更安全。具有讽刺意味的是,编译器的增加攻击性使得程序员需要积极阻止甚至不会造成麻烦的基于别名的优化形式[无论是通过编译器选项还是通过到处使用memcpy],但这似乎是现状。 - supercat
@supercat,但更安全的方法是消除不正确的别名,并让编译器优化所有这些丑陋的char*。最终我就是这样做的,自那以后,我不记得有任何与此特定软件有关的问题了。 - Sergei Tachenov
@SergeyTachenov:不是这样的。由于使用char*来绕过更严格的别名限制的代码通常不能被优化为与符合旧别名要求的直接代码一样高效,因此一些编译器已经停止将“char*”视为真正的“任何指针”类型,以恢复效率。由于无法预测未来的编译器在这方面会做什么,我认为根据明确定义的“-fno-strict-aliasing”规则编写最有效的代码是更简单的方法[可能没有正式名称,但是……]。 - supercat
由于它只是“删除了第XX节的C标准”,任何诚实的编译器编写者都会知道它应该如何行事。 - supercat

4

您认为以下代码是“错误”的:

extern void foo(int *, double *);
union a_union t;
t.d = 3.0;
foo(&t.i, &t.d); // undefined behavior

这是错误的。仅仅将两个联合成员的地址传递给外部函数并不会导致未定义的行为;只有在以无效的方式解引用其中一个指针时才会出现这种情况。例如,如果函数foo立即返回而没有解引用您传递给它的指针,则行为不是未定义的。按照C99标准的严格阅读,甚至有一些情况下可以对指针进行解引用而不会触发未定义的行为;例如,它可以读取第二个指针引用的值,然后通过第一个指针存储一个值,只要它们都指向动态分配的对象(即没有“声明类型”)。


1
@Joseph,strict-aliasing 并不允许存储以任意顺序执行,因为存储可以改变对象的有效类型(C99 6.5p7)。它确实允许读取与不允许别名的存储重新排序。一个更好的示例是int f(int *i, double *d) {*i = 1; *d = 2; return *i} - davmac
1
C99的技术勘误3N1265增加了注脚82:“如果用于访问共用体对象内容的成员不同于最后用于在对象中存储值的成员,则将该值的对象表示的适当部分重新解释为新类型中的对象表示,如6.2.6所述(有时称为“类型切换”)。 这可能是一个陷阱表示。” - Joseph Quinsey
1
我认为f是“错误的”,用TC3 6.5.2.3示例3中的话来说,“因为联合类型在函数f内部不可见”。 - Joseph Quinsey
很久以后,但是:6.5.2.3示例3讨论了对“共同初始序列”成员的访问,在这里并不真正相关。我阅读标准与GCC文档以及观察GCC 4.8.4和LLVM 3.5.1的行为,认为通过指针读取或写入联合成员仅在该成员是“当前活动”的情况下才是合法的,即是最后一个通过成员访问运算符存储的成员。当然,编译器可能是错误的 :) - davmac
@davmac:我认为gcc和clang不能同时以符合标准和高效的方式运行,而不失去应用一些真正有用的优化的能力。我认为处理这个问题的最佳方法是拥有更广泛的优化模式,认识到最激进的模式只适用于某些类型的程序。话虽如此,编译器确保&unionObject.member将产生一个指针,该指针在下一次有东西改变联合体而不通过该成员时仍可用,应该... - supercat
显示剩余11条评论

3

当编译器有两个指针指向同一块内存时,就会出现别名错误。通过类型转换指针,您可以生成一个新的临时指针。如果优化程序重新排列汇编指令,例如访问这两个指针可能会给出两个完全不同的结果 - 它可能会在写入相同地址之前重新排序读取。这就是为什么它是未定义行为的原因。

在非常简单的测试代码中,您不太可能看到这个问题,但是当有很多事情发生时,它会出现。

我认为警告是为了明确表示联合体不是特殊情况,尽管您可能希望它们是。

有关别名的更多信息,请参见此维基百科文章:http://en.wikipedia.org/wiki/Aliasing_(computing)#Conflicts_with_optimization


1
我愿意接受任何常用编译器上的复杂问题示例。 - Joseph Quinsey
我认为术语“别名”通常意味着在特定上下文中发生了两个引用之间的别名,如果(1)这两个引用都在该上下文中使用,并且(2)在使用时没有一个引用明显地从另一个引用派生。即使像someAggregate.intMember = 23;这样的操作在标准下是未定义的,但不应被视为“别名”,因为在派生左值somAggregate.intMember和最后一次使用它之间,所有对存储的操作都将使用后者左值进行。 - supercat
如果我们接受关于6.5p7目的的脚注,即告诉编译器何时必须将别名识别为建议,那么这将定义优秀编译器如何处理someAggregate.intmember = 23;,消除Effective Type规则的需要,并且对程序员和编译器编写者都更好。 - supercat

2

这个问题在cellperformance链接中两次提到了Mike Acton广泛而且非常有信息量的文章。然而需要注意的是,另外一个链接与他持不同意见。 - Joseph Quinsey
  1. Paul R已经指出,在现实世界中sizeof(double)通常比sizeof(int)大。但这在这里并不相关,而且例子也来自GCC手册。
- Joseph Quinsey

1

以下是我的观点:我认为这是所有GCC v5.x及更高版本中的一个bug。

#include <iostream>
#include <complex>
#include <pmmintrin.h>

template <class Scalar_type, class Vector_type>
class simd {
 public:
  typedef Vector_type vector_type;
  typedef Scalar_type scalar_type;
  typedef union conv_t_union {
    Vector_type v;
    Scalar_type s[sizeof(Vector_type) / sizeof(Scalar_type)];
    conv_t_union(){};
  } conv_t;

  static inline constexpr int Nsimd(void) {
    return sizeof(Vector_type) / sizeof(Scalar_type);
  }

  Vector_type v;

  template <class functor>
  friend inline simd SimdApply(const functor &func, const simd &v) {
    simd ret;
    simd::conv_t conv;

    conv.v = v.v;
    for (int i = 0; i < simd::Nsimd(); i++) {
      conv.s[i] = func(conv.s[i]);
    }
    ret.v = conv.v;
    return ret;
  }

};

template <class scalar>
struct RealFunctor {
  scalar operator()(const scalar &a) const {
    return std::real(a);
  }
};

template <class S, class V>
inline simd<S, V> real(const simd<S, V> &r) {
  return SimdApply(RealFunctor<S>(), r);
}



typedef simd<std::complex<double>, __m128d> vcomplexd;

int main(int argc, char **argv)
{
  vcomplexd a,b;
  a.v=_mm_set_pd(2.0,1.0);
  b = real(a);

  vcomplexd::conv_t conv;
  conv.v = b.v;
  for(int i=0;i<vcomplexd::Nsimd();i++){
    std::cout << conv.s[i]<<" ";
  }
  std::cout << std::endl;
}

应该给出
c010200:~ peterboyle$ g++-mp-5 Gcc-test.cc -std=c++11 
c010200:~ peterboyle$ ./a.out 
(1,0) 

但是在-O3情况下:我认为这是错误的,是编译器错误。
c010200:~ peterboyle$ g++-mp-5 Gcc-test.cc -std=c++11 -O3 
c010200:~ peterboyle$ ./a.out 
(0,0) 

在g++4.9下
c010200:~ peterboyle$ g++-4.9 Gcc-test.cc -std=c++11 -O3 
c010200:~ peterboyle$ ./a.out 
(1,0) 

在 LLVM Xcode 下。
c010200:~ peterboyle$ g++ Gcc-test.cc -std=c++11 -O3 
c010200:~ peterboyle$ ./a.out 
(1,0) 

我认为你的代码避免了UB(在GNU C++中,联合类型转换被定义为类似于ISO C99/C11但不是ISO C++。请注意,这是一个C问题)。无论如何,看起来这个错误在gcc6.3中已经修复,但在gcc6.2中仍然存在:https://godbolt.org/g/M2mpSr。请注意,gcc6.3使用`.LC0:`保存FP常量,而gcc6.2使用`vxorpd`在寄存器中创建`0.0`。有一个警告:`51: ignoring attributes on template argument '__m128d {aka __vector(2) double}'vcomplexd的定义),但我不知道这是否意味着“向量”类型只是double`... - Peter Cordes

0

我不太理解你的问题。编译器在你的示例中确切地执行了它应该执行的操作。union转换是你在f1中所做的。在f2中,它是一个普通的指针类型转换,你将它转换为联合体是无关紧要的,它仍然是一个指针转换


  1. AndreyT的回答链接似乎暗示gcc是正确的,而且其他人都是错的。但这不是问题所在。我正在寻找恐怖故事。或者甚至是一个微小的例子。
- Joseph Quinsey
好的。你有关注过http://davmac.wordpress.com/2010/02/26/c99-revisited/这篇博客文章吗?特别是http://davmac.wordpress.com/2009/10/25/mysql-and-c99-aliasing-rules-a-detective-story/,他在其中发现了MySQL中的一个别名错误。这可能是你正在寻找的东西。 - Patrick Schlüter
@tristopia:我的问题非常狭窄,涉及通过指针进行联合游戏。我刚刚在https://dev59.com/-HA85IYBdhLWcg3wD_O8上提出了一个更一般的问题。 - Joseph Quinsey

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