什么是严格的锯齿规则?
在询问C语言中常见的不确定行为时 ,比起我提到严格的别名规则,灵魂更加开明。
他们在说什么?
遇到严格别名问题的典型情况是将结构(如设备/networking信息)覆盖到系统字大小的缓冲区(如指向uint32_t
s或uint16_t
s的指针)。 当你通过指针转换将一个结构覆盖到这样一个缓冲区或一个缓冲区时,你可以很容易地违反严格的别名规则。
所以在这种设置中,如果我想发送消息给某个东西,我必须有两个不兼容的指针指向同一块内存。 那么我可能天真地编码这样的东西:
struct Msg { unsigned int a; unsigned int b; }; void SendWord(uint32_t); int main() { // Get a 32-bit buffer from the system uint32_t* buff = malloc(sizeof(Msg)); // Alias that buffer through message Msg* msg = (Msg*)(buff); // Send a bunch of messages for (int i =0; i < 10; ++i) { msg->a = i; msg->b = i+1; SendWord(buff[0]); SendWord(buff[1]); } }
严格的别名规则使得这种设置非法:取消引用别名不兼容的types的指针是未定义的行为。 不幸的是,你仍然可以用这种方式进行编码, 也许会得到一些警告,编译好,只是在运行代码的时候出现奇怪的意外行为。
(海湾合作委员会似乎在给予别名警告的能力方面有些不一致,有时会给我们一个友好的警告,有时候不会。)
要明白为什么这个行为是未定义的,我们必须考虑严格的别名规则是如何购买编译器的。 基本上,用这个规则,不用考虑插入指令来刷新循环中每次运行的buff
的内容。 相反,在进行优化时,有些混淆的假设可能会忽略这些指令,在循环运行之前将buff[0]
和buff[1
]加载到CPU寄存器,并加速循环体。 在引入严格的别名之前,编译器必须处于一种偏执狂的状态,即任何人随时随地都可以改变buff
的内容。 所以为了获得额外的性能优势,并假设大多数人不使用双关键指针,引入了严格的别名规则。
请记住,如果你认为这个例子是人为的,那么如果你将一个缓冲区传递给另一个为你发送数据的函数,甚至会发生这种情况。
void SendMessage(uint32_t* buff, size_t size32) { for (int i = 0; i < size32; ++i) { SendWord(buff[i]); } }
并重写了我们之前的循环,以利用这个方便的function
for (int i =0; i < 10; ++i) { msg->a = i; msg->b = i+1; SendMessage(buff, 2); }
编译器可能会或可能不能够或足够聪明地尝试内联SendMessage,它可能会或可能不会决定加载或不加载buff再次。 如果SendMessage
是单独编译的另一个API的一部分,它可能有加载buff内容的指令。 然后再次,也许你是在C + +,这是一些模板头只有实现,编译器认为它可以内联。 或者,也许这只是为了您的方便,您在.c文件中写的东西。 反正未定义的行为可能还会继续。 即使我们知道一些发生了什么,但仍然违反了规则,所以没有明确定义的行为得到保证。 所以只需要包裹在一个函数中,这个函数需要我们分隔的缓冲区并不一定有帮助。
那么我怎么解决这个问题?
-
使用联盟。 大多数编译器都支持这一点,而不会抱怨严格的别名。 这在C99中是允许的,并且在C11中明确允许。
联合{ 消息msg; unsigned int asBuffer [sizeof(Msg)/ sizeof(unsigned int)]; };
-
你可以在编译器中禁用严格别名( f [no-] gcc中的严格别名 ))
-
你可以使用
char*
作为别名而不是系统的单词。 规则允许char*
(包括signed char
和unsigned char
)的exception。 它总是假定char*
别名其他types。 然而,这不会以另一种方式工作:没有任何假设您的结构别名字符的缓冲区。
初学者要小心
当两种types叠加时,这只是一个潜在的雷区。 您还应该了解字节顺序 , 字alignment以及如何通过正确包装结构来处理alignment问题。
我发现的最好的解释是Mike Acton, 了解严格的别名 。 它关注于PS3的开发,但基本上只是GCC。
从文章:
“严格的别名是由C(或C ++)编译器做出的一个假设,即对不同types的对象的取消引用指针永远不会引用相同的内存位置(即相互别名)。”
所以基本上,如果你有一个int*
指向一个包含一个int
内存,然后你指向一个float*
内存,并使用它作为一个float
你打破了规则。 如果你的代码不尊重这个,那么编译器的优化器很可能会破坏你的代码。
规则的例外是一个char*
,它可以指向任何types。
这是C ++ 03标准第3.10节中的严格别名规则(其他答案提供了很好的解释,但是没有人提供规则本身):
如果程序试图通过以下types之一的左值访问对象的存储值,则行为是未定义的:
- 对象的dynamictypes,
- 该对象的dynamictypes的cv限定版本,
- types是与对象的dynamictypes对应的有符号或无符号types,
- types是对应于对象的dynamictypes的cv限定版本的有符号或无符号types,
- 包括其成员之一(包括recursion地,子成员或包含工会的成员)中的上述types之一的集合体或联合体types,
- 作为对象的dynamictypes的(可能是cv合格的)基类types的types,
- 一个
char
或unsigned char
types。
C ++ 11和C ++ 14措辞(重点更改):
如果程序试图通过以下types之一的glvalue来访问对象的存储值,则行为是未定义的:
- 对象的dynamictypes,
- 该对象的dynamictypes的cv限定版本,
- types(与4.4中定义的)types的对象的dynamictypes,
- types是与对象的dynamictypes对应的有符号或无符号types,
- types是对应于对象的dynamictypes的cv限定版本的有符号或无符号types,
- 包含其元素之一的上述types或非静态数据成员 (包括recursion地包含子集或包含的联合的元素或非静态数据成员)的集合或联合types,
- 作为对象的dynamictypes的(可能是cv合格的)基类types的types,
- 一个
char
或unsigned char
types。
两个变化很小: stream 值而不是左值 ,并澄清汇总/工会案件。
第三个变化做出了更强的保证(放宽强烈的别名规则):现在可以安全别名的类似types的新概念。
C语言 (C99; ISO / IEC 9899:1999 6.5 / 7;在ISO / IEC 9899:2011§6.5¶7中使用完全相同的措词):
对象的存储值只能由具有以下types之一的左值expression式访问: 73)或88) :
- 与对象的有效types兼容的types,
- 与对象的有效types兼容的types的限定版本,
- 对应于对象的有效types的有符号或无符号types,
- 一种types是与对象的有效types的合格版本相对应的有符号或无符号types,
- 包括其成员(包括recursion,子成员或包含联盟的成员)中的上述types之一的聚合或联合types,或者
- 一个字符types。
73)或88)这个清单的目的是为了指定一个对象可以被别名化或不被别名化的情况。
严格的别名并不仅仅指向指针,它也影响引用,我为boost开发者wiki写了一篇关于它的文章,并且很受欢迎,因此我把它变成了我咨询网站上的一个页面。 它完全解释了它是什么,为什么这么混淆了人们以及如何处理这个问题。 严格的别名白皮书 。 特别是它解释了为什么工会是C ++的风险行为,以及为什么使用memcpy是C和C ++中唯一的可修复的移植。 希望这是有帮助的。
作为Doug T.已经写过的附录,这里是一个简单的testing用例,可能会用gcc触发它:
check.c
#include <stdio.h> void check(short *h,long *k) { *h=5; *k=6; if (*h == 5) printf("strict aliasing problem\n"); } int main(void) { long k[1]; check((short *)k,k); return 0; }
用gcc -O2 -o check check.c
编译。 通常(在我尝试过的大多数gcc版本的情况下)输出“严格别名问题”,因为编译器假定“h”不能与“check”函数中的“k”地址相同。 因为编译器优化了if (*h == 5)
,并且总是调用printf。
对于这里感兴趣的是x64汇编代码,由gcc 4.6.3生成,在ubuntu 12.04.2上运行x64:
movw $5, (%rdi) movq $6, (%rsi) movl $.LC0, %edi jmp puts
所以if条件完全从汇编代码中消失了。
通过指针types转换(与使用联合相对)是打破严格别名的一个主要示例。
严格的别名不允许不同的指针types到相同的数据。
这篇文章应该可以帮助你全面了解这个问题。
根据C89的原理,标准的作者不想要求编译器给出如下的代码:
int x; int test(double *p) { x=5; *p = 1.0; return x; }
应该要求在赋值语句和返回语句之间重新加载x
的值,以便允许p
可能指向x
的可能性,并且对*p
的赋值可能会因此而改变x
的值。 编辑者有权假定在上述情况下不会出现别名的概念是没有争议的。
标准的作者指出了一些情况,可能会在代码中使用别名,这些代码应该几乎100%可移植 ,并要求编译器至less在这些情况下允许别名。 他们并没有试图判断哪些构造应该在只能在特定平台上使用的代码中使用,也不应该由那些声称适用于那些平台上的系统编程的高质量实现来支持哪些构造。
如果某个特定平台的编译器指出它是用于高端数字运算应用程序,并且该平台的一段操作系统代码在被提供给该编译器时出现故障,这并不意味着编译器有缺陷,意味着代码有缺陷。 这仅仅意味着编译器和操作系统代码不适合彼此使用。
不幸的是,一些编译器编写者指出,标准并不要求所有的编译器都能识别某些别名结构,这意味着所有使用这种结构的代码都应该被认为是有缺陷的,即使代码做了一些不能做的事情有效地任何其他方式。 如果这样的编译器编写者认识到标准的作者从来没有试图列举使编译器适用于任何特定目的所需的所有特性和保证,他们可以转移他们的努力去找出如何使他们的编译器尽可能有用出于广泛的目的,而不是试图争辩,标准不要求他们这样做。