简化概念,严格别名规则规定对象应该由兼容类型的指针或指向char
的指针访问。这样,编译器可以对代码做一些假设并进行一些优化。
即使它的解释可以引起一些疑虑和讨论,但规则本身并不是任何国家机密。所以我的问题是:
为什么一些由经验丰富的程序员维护的受尊敬的组织经常提交不遵守严格别名规则的代码?我可以提供一个示例tcpdump
:在libpcap
的网站上tutorial,example code显然违反了严格的别名规则几个次。我似乎也有许多其他代码也这样做,特别是在处理网络数据包时。
他们是否幸运,编译器没有完全破坏他们的代码,所以它没有被注意到?他们是否依赖于用-fno-strict-aliasing
标志编译的用户?考虑到一些尊重的程序员,这可能是一种可能性 - 我认为Linus Torvalds本身也是如此,因为我似乎在某些邮件列表中看到一个Linux代码片段会破坏严格的别名 - 不要认为优化通过严格的混叠获得的补偿可以补偿编译器可能做出的错误假设。或者只是糟糕的代码和糟糕的做法,遗憾的是编程社区是固有的?
另一个问题是来自tcpdump的sniffex.c
代码:为什么即使使用gcc -O5 -Wall -Wextra -Wstrict-aliasing=1 sniffex.c -lpcap
gcc 5.4.0
进行编译也不会对严格的别名规则发出任何警告被打破了?是不是因为当他们没有地址运算符&
时,它很容易检测到这些类型的冲突?
我再次提出这个话题感到很难过(因为还有很多其他问题),但即使我理解这个规则,我似乎也无法理解为什么它会在很多地方经常被忽略。
修改
明显违反严格别名规则的tcpdump
示例代码的代码段是:
void got_packet(u_char *args, const struct pcap_pkthdr *header, const u_char *packet)
{
...
/* declare pointers to packet headers */
const struct sniff_ethernet *ethernet; /* The ethernet header [1] */
const struct sniff_ip *ip; /* The IP header */
const struct sniff_tcp *tcp; /* The TCP header */
const char *payload; /* Packet payload */
...
/* define ethernet header */
ethernet = (struct sniff_ethernet*)(packet);
/* define/compute ip header offset */
ip = (struct sniff_ip*)(packet + SIZE_ETHERNET);
...
/* define/compute tcp header offset */
tcp = (struct sniff_tcp*)(packet + SIZE_ETHERNET + size_ip);
...
/* define/compute tcp payload (segment) offset */
payload = (u_char *)(packet + SIZE_ETHERNET + size_ip + size_tcp);
...
在那里,它们用代表网络数据包不同部分的结构进行某种覆盖,以便更容易地访问每个字段。在底线,它使用几个指针,它们不具有有效类型u_char
(原始packet
类型)来访问它,因此,我认为,违反了严格的别名规则。
答案 0 :(得分:3)
严格的别名规则存在争议。
背景知识:
请注意"严格的别名规则"这不是一个正式术语,而是指关于有效类型的第6.5 / 6段和关于通过指针访问数据的6.5./7。后一段是实际的严格别名规则,只要语言已经标准化,它就是C语言的一部分,所以它的存在实际上不应该让任何人感到震惊。 6.5./7中的文本从ANSI-C草案到C11几乎完全相同。
然而,这一部分在C90中并不清楚,因为它侧重于用于"左值访问"的指针类型,而不是实际存储在那里的数据类型。这使得你转向无效指针的情况不明确,例如当使用memcpy
时,或者你正在进行各种形式的惩罚时。
在C99中,有一些尝试通过引入有效类型来澄清这一点。这并没有真正改变严格别名规则的措辞,只是让解释更加清晰。 (它仍然是标准中最难理解的部分之一。)
该规则的最初意图是允许编译器避免奇怪的最坏情况假设,例如C99原理中的这个例子:
int a;
void f( double * b )
{
a = 1;
*b = 2.0;
g(a);
}
如果编译器可以假设b
没有指向a
,这应该是一个合理的假设,假设给出了截然不同的类型,那么它可以优化函数
a = 1;
*b = 2.0;
g(1); // micro-optimization, doesn't have to load `a` from memory
所以尽管规则一直存在,但在C99的某个地方之前它并没有问题,特别是当gcc编译器决定变得混乱并且滥用不同的情况时使用了有效的类型。例如,这段代码非常有意义,但却违反了严格的别名:
uint32_t u32=0;
uint16_t* p16 = (uint16_t*)&u32; // grab the ms/ls word (endian-dependent)
*p16 = something;
if(u32)
do_stuff();
上述将是各种比特和硬件相关编程的非常有用的代码。大多数编译器将生成程序员期望的内容,即更改32位值的ms / ls字的代码,然后检查是否应该调用该函数。
但是,由于严格的别名冲突,上述代码是正式未定义的行为,因此像gcc这样的编译器可能决定滥用它并生成始终从机器代码中删除对do_stuff()
的调用的代码,因为它可能假设代码中的任何内容都不会使u32
的值变为0。
为了避免不必要的编译器行为,程序员必须尽力而为。要么使u32
易失性,以便编译器被强制读取 - 这会阻止对变量的所有优化,而不仅仅是非期望的优化。或者提出一个家庭酿造的联合类型,其中包含一个uint32_t
和两个uint16_t
。或者可能每个字节访问u32字节。非常不方便。
因此,程序员倾向于反对严格的别名规则并编写代码依赖于编译器而不是基于严格的别名进行令人难以置信的奇怪优化。当您想要在不同部分中分解一大块数据时,例如在反序列化原始数据字节块时,存在许多有效情况。
例如,如果我逐字节地接收串行数据并将其存储在uint8_t
的数组中,我,程序员,知道包含uint16_t
,我应该能够编写类似的代码(uint16_t*)array
没有编译器做出假设,例如"哦,看,这个数组永远不会被使用,让我们优化它"或其他一些废话。
大多数编译器不会发疯但会产生预期的代码。但他们允许按标准疯狂。随着gcc在硬件相关编程中的日益普及,对于嵌入式行业而言,这已经成为一个严重的问题,其中硬件相关的编程是日常任务,而不是异国情调的特殊情况。
总的来说,标准委员会一再没有看到这个问题。然后当然,很多程序员实际上并不知道严格的别名规则,这通常是他们编写违反它的代码的原因的解释。
答案 1 :(得分:1)
我认为你没有在70年代学过C语言。与K& R C ...并非所有代码都是在21世纪编写的。严格的别名规则添加到C语言,以允许优化编译器进行更好的优化,编译器程序员足够聪明,可以添加-fno-strict-aliasing
标志,以免破坏遗留代码。
我的意思是,编写破坏严格别名规则的新代码确实很糟糕(*)。但是在采用严格别名规则之前已经编写了大量遗留代码,在只是完成工作的应用程序中,tcpdump就是一个简单的例子。
(*)对于一些低级代码,主要是在处理网络数据时,当你需要真正的低级优化时,自愿打破严格的别名规则并记录它是有意义的。替代方案只是无用地来回复制缓冲区,或使用失去任何可移植性的汇编语言。但这应该永远不会存在于普通代码中。
答案 2 :(得分:0)
到1989年编写第一个C标准时,显然C实现被用于具有不同需求的许多目的,并且在许多具有不同能力的平台上使用。人们编写C实现来定位应用程序领域和平台,其中有意义的是提供超出标准最终要求的功能和保证,而不是被任何标准强制执行。依赖于这些功能或保证的代码只能移植到支持它们的平台上,但没有任何可预见的可能性,希望在无法支持此类功能的平台上运行代码,这并不意味着任何类型缺陷此外,标准的作者并没有认为有必要明确强制实施必须不受影响的行为,或至少参与故意失明, not 提供。
在C89下,如果函数从外部接收指向某个存储的指针 代码并使用两种不同的非字符指针类型访问它, 行为等同于访问包含的union的成员 那些类型。根据类型和使用方式,行为 可以由标准定义,也可以是实现定义; 实现可以描述需要维护的条件 产生确定性行为,但如果一个实现描述了事情 存储,不指定编写工会成员的任何情况(通过 任何手段)和阅读另一个(通过任何手段)将产生其他行为 比他们的存储格式所隐含的,他们的行为会 按其存储格式定义。
gcc的作者以某种方式决定,当标准的作者将未定义的行为描述为“非便携或错误的”时,他们的意思是“错误的”#34;。他们进一步认为,当作者发现可能存在某些类型的实施时,除了那些规定之外没有任何形式的惩罚将具有足够的价值来证明支持成本是合理的,他们正在制定成本/价值判断适用于所有实施。
我不认为许多程序员会反对这样的想法:当没有证据证明这些东西可能是别名时,编译器应该假定指向不同类型的东西的指针不会别名 ,甚至也不认为编译器不应该到目前为止来找到这样的证据。别名问题应该只是当编译器在看似连续的对象上看到两个操作,但它们之间的另一个操作影响同一个对象时。在上面这样的代码中,编译器应该没有理由通过别名问题来解决问题,因为使用指针强制转换会建议任何编译器都不会盲目地认为编译器应该只考虑强制转换之前和之后的操作as"连续"如果它能看到即使在没有规则的情况下也不可能存在别名。
<强>附录强>
别名问题实际上与&#34;程序员意图&#34;无关。在编译器测试或其他设计方案之外,程序员本质上从不意图使代码的行为方式与通过使所有左值访问以执行顺序对底层存储进行操作而产生的行为不一致。在某些应用领域中,出于所有实际目的,这种行为总是等同于可以更有效地实现的一些其他行为,并且存在一些应用领域,其中后者的替代方案将是可观察的和灾难性的不同。
考虑两个功能:
int i1;
int test1(float *fp)
{
i1 = 2;
*fp = 1.0f;
return i1;
}
int i2;
int test2(int *ip)
{
i1 = 2;
*(float*)ip = 1.0f;
return i1;
}
在任何一种情况下,代码的意图都表现得好像存储一样
将int
值2表示到i1
或i2
占用的存储空间中,
然后将float
值1.0f
的表示存储到地址中
从外部代码传递,最后读作int
中的值
i1
或i2
占用的存储空间并将其返回给调用者。什么是
两个程序之间的差异不是意图,而是是否
i1
/ i2
的写入与其读取之间有任何关系
会暗示任何事情都可能影响到它。我同意C89标准委员会(使用类似的例子),test1()
中没有任何内容表明float *fp
可能 - 尽管它的类型 - 实际上保持{{1}的地址}}。另一方面,我认为第二个例子中没有任何内容表明int
可能包含int *ip
的地址。请注意程序员&#34;意图&#34;在分析中不起作用 - 问题纯粹是整数写入和读取之间是否完全没有任何暗示int
可能已经受到影响。
C89的别名规则或后续规则的理由中没有任何内容表明他们曾经打算让程序员跳过箍来实现有用的行为。相反,理由是基于这样的观察,即有许多情况下可以无害地应用这种优化,并且应该允许编译器在这种情况下应用它们。作者希望确保需要将数据解释为字节序列的操作可以工作,但是否则认为由于不同的应用程序字段有不同的需求,编译器应该可以自由地使用适合其预期字段的别名规则。