语言标准试图在使用语言的程序员和希望使用广泛的优化来生成合理快速代码的编译器编写者之间有时相互竞争的利益之间取得平衡。将变量保存在寄存器中就是这样一种优化。对于在程序的某个部分中“活动”的变量,编译器会尝试将它们分配到寄存器中。存储在指针中的地址可以存储程序地址空间中的任何位置——这将使寄存器中的每个变量都无效。有时编译器可以分析程序并找出指针可以或不可以指向的位置,但 C(和 C++)语言标准认为这是一个过度的负担,对于“系统”类型的程序来说,这通常是一项不可能完成的任务。因此,语言标准通过指定某些构造导致“未定义的行为”来放松约束,因此编译器编写者可以假设它们不会发生并在该假设下生成更好的代码。如果是strict aliasing
达成的折衷方案是,如果您使用一种指针类型存储到内存,则假定不同类型的变量不变,因此可以保存在寄存器中,或者可以对这些其他类型的存储和加载重新排序指针存储。
在这篇论文“未定义的行为:我的代码发生了什么?”中有很多这类优化的例子。
http://pdos.csail.mit.edu/papers/ub:apsys12.pdf
在Linux内核中有一个违反严格别名规则的例子,显然内核通过告诉编译器不要使用严格别名规则进行优化来避免这个问题“Linux内核使用-fno-strict -aliasing 禁用基于严格别名的优化。”
struct iw_event {
uint16_t len; /* Real length of this stuff */
...
};
static inline char * iwe_stream_add_event(
char * stream, /* Stream of events */
char * ends, /* End of stream */
struct iw_event *iwe, /* Payload */
int event_len ) /* Size of payload */
{
/* Check if it's possible */
if (likely((stream + event_len) < ends)) {
iwe->len = event_len;
memcpy(stream, (char *) iwe, event_len);
stream += event_len;
}
return stream;
}
图 7: Linux 内核的 include/net/iw_handler.h 中的严格别名违规,它使用 GCC-fno-strict-aliasing
来防止可能的重新排序。
2.6 类型双关指针取消引用
C 让程序员可以自由地将一种类型的指针转换为另一种类型。指针转换经常被滥用来重新解释具有不同类型的给定对象,这种技巧称为类型双关语。通过这样做,程序员期望两个不同类型的指针指向同一个内存位置(即别名)。但是,C 标准对别名有严格的规定。特别是,除了少数例外,两个不同类型的指针没有别名 [19, 6.5]。违反严格的别名会导致未定义的行为。图 7 显示了一个来自 Linux 内核的示例。该函数首先更新 iwe->len,然后使用 memcpy 将包含更新后的 iwe->len 的 iwe 的内容复制到缓冲区流。请注意,Linux 内核提供了自己优化的 memcpy 实现。在这种情况下,
iwe->len = 8;
*(int *)stream = *(int *)((char *)iwe);
*((int *)stream + 1) = *((int *)((char *)iwe) + 1);
扩展后的代码首先将 8 写入 uint16_t 类型的 iwe->len,然后使用不同的 int 类型读取指向 iwe->len 相同内存位置的 iwe。根据严格的别名规则,GCC 得出的结论是读和写不会发生在同一个内存位置,因为它们使用不同的指针类型,并对这两个操作重新排序。因此,生成的代码复制了一个陈旧的 iwe->len 值。Linux 内核用于-fno-strict-aliasing
禁用基于严格别名的优化。
答案
1) 在这种别名情况下编译器可以执行哪些优化?
语言标准对严格符合程序的语义(行为)非常具体 - 编译器编写者或语言实现者的负担是正确的。一旦程序员越界并调用未定义的行为,那么标准很清楚,证明这将按预期工作的责任落在程序员身上,而不是编译器编写者身上——在这种情况下,编译器已经足够好地警告未定义行为已被调用,尽管它甚至没有义务这样做。有时令人讨厌的人会告诉你,在这一点上“任何事情都可能发生”通常伴随着一些笑话/夸张。对于您的程序,编译器可以生成“平台典型”的代码localval
something
从 加载localval
并存储在DataPtr
,如您所愿,但请理解它没有义务这样做。它将存储localval
视为对某种uint32
类型的存储,并将加载的取消引用(*(const float32*)((const void*)(&localval)))
视为来自某个float32
类型的加载,并得出结论,它们不在同一个位置,因此localval
可以在包含something
从未初始化加载时的寄存器中localval
如果它决定需要将该寄存器“溢出”回其保留的“自动”存储(堆栈),则保留堆栈上的位置。在取消引用指针并从内存加载之前,它可能会也可能不会存储localval
到内存中。根据您的代码后面的内容,它可能会决定localval
不使用它并分配something
没有副作用,因此它可能会认为赋值是“死代码”,甚至不会对寄存器进行赋值。
2)由于两者将占用相同的大小(如果不是,请纠正我)这种编译器优化的副作用是什么?
效果可能是未定义的值存储在 指向的地址DataPtr
。
3)我可以安全地忽略警告或关闭别名吗?
这是特定于您正在使用的编译器的 - 如果编译器记录了一种关闭严格别名优化的方法,那么可以,无论编译器提出什么警告。
4) 如果编译器没有执行优化并且我的程序在我第一次编译后没有损坏?我可以安全地假设每次编译器都会以相同的方式运行(不进行优化)吗?
也许,有时程序的另一部分中非常小的变化可能会改变编译器对这段代码所做的事情,想一想如果函数是“内联”的,它可能会被扔到你代码的其他部分的混合中,看这个所以问题。
5) 别名是否也适用于 void * 类型转换?还是仅适用于标准类型转换(int、float 等...)?
您不能取消引用 a void *
,因此编译器只关心您最终演员的类型(在 C++ 中,如果您将 a 转换为const
,non-const
反之亦然)。
6)如果我禁用别名规则有什么影响?
请参阅您的编译器的文档 - 通常您会得到较慢的代码,如果您这样做(就像 Linux 内核在上面论文中的示例中选择的那样)然后将其限制为一个小的编译单元,只包含必要的函数.
结论
我了解您的问题是出于好奇,并试图更好地了解其工作原理(或可能不起作用)。您提到要求代码是可移植的,暗示然后要求程序是合规的并且不调用未定义的行为(请记住,如果您这样做,负担就在您身上)。在这种情况下,正如您在问题中指出的那样,一种解决方案是使用memcpy
,因为事实证明这不仅使您的代码兼容并因此具有可移植性,而且还可以在当前 gcc 上以最有效的方式执行您想要的操作优化级别-O3
,编译器将 转换为memcpy
一条指令localval
,DataPtr
将movl %esi, (%rdi)
操作说明。