类型双关(type punning)与严格别名(strict aliasing)

作者:Debao Zhang | Aug 30, 2011 12:47:15 PM

原文链接:Thiago MacieiraType-punning and strict-aliasing

几个月前,我在内部的讨论列表中发了一封长长的关于类型双关和破坏严格别名问题的电子邮件。当时,我唯一的目标是清理在使用GCC 4.5构建Qt 4.7过程中的所有警告。在那以后,GCC 4.6发布了,而被报告的缺陷QTBUG-19736涉及的正是我一直以来试图清理的代码。

随后,一个同事鼓励我在博客中贴出邮件内容来解释一下这个问题。下面便是本文要讨论的内容了(做了点更新):

GCC的警告内容如下:

"dereferencing type-punned pointer will break strict aliasing"

这是相当可怕的,因为它说,这打破一些东西。那么,是说什么呢?

引用自http://cellperformance.beyond3d.com/articles/2006/06/understanding-strict-aliasing.html

One pointer aliases another when they both point to the same memory location.

类型双关是这样一个技巧:通过其他类型来引用一个对象。而严格别名是C99的要求,一个对象只能通过它自己的类型或char类型来访问(见下面来自C99的准确定义)。这意味着以下代码是不能被接受的:

        int i = 42;
short s = *(short*)&i;

上面的代码或许能工作(重点在或许),但其结果是未定义的。这意味着编译器可以自由地做任何事,比如给你的老板发送电子邮件报告这次违规(transgression)。但是,即使没有严格别名的规则,上述代码仍有不确定的行为,根据大小端的不同,它有两个可能的结果:0或42。

上面是一个对对齐要求不太严格的类型。而下列代码增强了这种需求:

        short s[2] = { 0, 42 };
int i = *(int *)s;

有三种可能的结果:i == 0,i == 42或崩溃(加载的内容未4字节对齐)。

现在,当我们将严格别名规则和优化放到一块时,它将变得很有趣。标准允许编译器这样来假设,要解引用(dereference)的不同类型的指针绝对不会指向相同的内存区域(它们不会互为别名)。

这意味着下面的代码:

        void *buf[4];
int *i = (int *) buf;
short *s = (short *) buf;

*i = 42;
s[0] = 0;
s[1] = 1;

printf("%dn", *i);

也是未定义的,因为你打破了这个规则。上述代码可以打印三种不同的东西(或者为你的冰箱除霜):

    1 (大端架构)
    65536 (小端架构)
    42  

之所以打印出42,是因为编译器可以假定变量short *s绝对不会是int *i的别名。这意味着它知道*i == 42并可以将short优化掉。事实上,这正是GCC 4.5所做的,上面代码的反汇编也可印证这一点。

这对下面的代码也有效:

        union {
int i;
short s;
} u;
u.i = 42;
u.s = 1;

printf("%dn", u.i);

根据C标准,上述行为是未定义的。尽管如此,它仍被GCC所接受(在x86上输出1)——但是不被其他编译器所接受(译者注:我在MSVC/CLANG/SUN CC等编译器下测试结果也是1)。我建议你看看这个案例c1b067ea8169e1d37e2a120334406f1f115298bb。QMutexLocker 中有:

    union {
QMutex *mtx;
quintptr val;
};

而后我们做了:

            if ((val & quintptr(1u)) == quintptr(1u)) {
val &= ~quintptr(1u);
mtx->unlock();
}

该代码可以读为“如果该指针地址的最低位被置位,清除该位并调用mtx->unlock()”。但是,它打破了严格别名规则,而且Sun CC生成了坏的——但是完全合法的——代码,在最低位被置位的情况下调用了QMutex::unlock()。当然了,代码会导致崩溃。

一个更困难的案例是2c1b11f2192fd48da01a1093a7cb4a848de43c8a (任务247708,不好意思,没有导入到新的缺陷追踪系统中),影响了QDataStream的字节交换代码。它是:

QDataStream &QDataStream::operator>>(qint16 &i)
{
...
register uchar *p = (uchar *)(&i);
char b[2];
if (dev->read(b, 2) == 2) {
*p++ = b[1];
*p = b[0];
...

看起来很安全,对吧?恩,其实不然,而且有一段时间我都不敢确认这不是编译器的缺陷。实际上是这样的,由于链接时代码生成(Link Time Code Generation),MSVC内联了上面的operator>>并移除了真正设置变量qint16的代码。看后面C99中的定义来理解为什么我不敢确认。

无论如何,这个问题是,当编译器按照严格别名规则进行优化后,会导致不可预知的结果。我们来看看下面这篇博客中的例子:

http://jeffreystedfast.blogspot.com/2010/01/weird-bugs-due-to-gcc-44-and-strict.html

作者编写了他认为有效并且已经正常工作了很长时间的代码。但GCC升级到4.4后打破了代码(有效性),这是因为它打破了严格别名规则。代码行

        tail = (Node *) &list;

创建了类型双关的变量,而它的解引用

        tail->next = node;

属于违例。

你可能会问,为什么会有这个规则存在。好吧,其原因是,该规则对于编译器优化特别有用,因为它给于编译器一些在其他情况下所没有的自由。看一下博客每一个C程序员应该了解的未定义行为第一部分的例子:

float *P;
void zero_array() {
int i;
for (i = 0; i < 10000; ++i)
P[i] = 0.0f;
}

该博客的作者告诉你

该规则允许clang将该函数[...]优化为"memset(P, 0, 40000)"

而若没有这种自由的话

Clang必须将该循环编译成10000次4字节的赋值(将会慢若干倍)

稍进一步,在第3部分,作者解释了为何会如此。所以,考虑一下:如果没有严格别名,编译器为什么必须进行10000次4字节存储操作而不是一个简单的40000字节的memset?你先考虑着这个问题,这是第2部分的链接。

原因在于,当为每一个P[i](浮点类型)赋值时,编译器不能假设P(指针类型)会保持不变。有些人可能会编写这样的代码:

int main() {
P = (float*)&P;
zero_array();
}

在这种情况下,对P[0]赋值会改变P的值,因此,下次操作(为P[1]赋值)时必须按不同地址计算。

通过小心地编码,使用类型双关而不打破严格别名规则也是可能的。但是,这很难实现,而且也很难不让编译器陷入混乱。经验法则是:如果你使用类型双关(也就是,通过C风格的转换或reinterpret_cast转换成另一类型的指针),你应该再思量一下。如果你使用类型双关并解引用,需再三思量。

C99 6.5 7:

An object shall have its stored value accessed only by an lvalue expression that has one of the following types:

— a type compatible with the effective type of the object,

— a qualified version of a type compatible with the effective type of the object,

— a type that is the signed or unsigned type corresponding to the effective type of the object,

— a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object,

— an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union), or

— a character type.