严格别名警告和 tcpdump 示例代码
Strict-Aliasing warnings and tcpdump example code
为了简化概念,严格别名规则声明对象应该通过兼容类型的指针或指向 char
的指针访问。这样,编译器就可以对代码做出一些假设并进行一些优化。
尽管它的解释可能会引起一些质疑和讨论,但该规则本身并不是任何国家机密。所以我的问题是:
为什么一些由经验丰富的程序员维护的受人尊敬的组织经常提交不遵守严格的别名规则的代码?我可以举的一个例子是 tcpdump
:在他们的网站 tutorial on libpcap
there's an 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
类型)的指针来访问它,因此,我相信,违反了严格的别名规则。
好吧,我假设您在 70 年代还没有通过 K&R C 学习过 C...并非所有代码都是在 21 世纪编写的。严格的别名规则 添加 到 C 语言以允许优化编译器进行更好的优化,编译器程序员足够聪明添加 -fno-strict-aliasing
标志以避免破坏遗留代码.
我在这里的意思是,编写违反严格别名规则的新代码确实很糟糕(*)。但是在采用严格的别名规则之前已经编写了大量遗留代码,在 只是完成工作 的应用程序中,tcpdump 就是一个简单的例子。
(*) 对于一些低级代码,主要是在处理网络数据和需要真正的低级优化时,愿意打破严格的别名规则并记录下来是有意义的。替代方案只能是无用地来回复制缓冲区,或者使用失去任何可移植性的汇编语言。但这不应该存在于普通代码中。
严格的别名规则存在争议。
背景知识:
请注意,"the strict aliasing rule" 不是正式术语,但它指的是第 6.5/6 段关于 有效类型 和 6.5./7 关于通过指针。后一段是实际严格的别名规则,只要语言被标准化,它就一直是 C 的一部分,所以它的存在实际上不应该让任何人感到震惊。 6.5./7 中的文本从 ANSI-C 草案到 C11 几乎完全相同。
然而,这部分在 C90 中并不清楚,因为它着重于 "lvalue access" 所使用的指针的类型,而不是实际存储在那里的数据的类型。这使得您转换为 void 指针的情况变得不清楚,例如在使用 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
这样的代码没有编译器做出诸如 "oh look, this array is never used, lets optimize it away" 或其他一些废话的假设。
大多数编译器不会发疯,而是会生成预期的代码。但按照标准,他们允许发疯。随着 gcc 在硬件相关编程中的日益普及,这正在成为嵌入式行业的一个严重问题,在嵌入式行业中,硬件相关编程是一项日常任务,而不是一种奇特的特例。
总的来说,标准委员会多次未能看到这个问题。
然后当然,很多程序员实际上首先并不知道严格的别名规则,这通常是他们编写违反它的代码的原因。
到 1989 年编写第一个 C 标准时,很明显,C 实现已用于具有不同需求的许多目的,以及具有不同能力的许多平台。人们为目标应用程序领域和平台编写 C 实现,在这些领域和平台中,提供超出标准最终要求的功能和保证是有意义的,而没有被任何标准强制这样做。依赖于此类功能或保证的代码只能移植到支持它们的平台,但没有任何可预见的可能性 运行 不支持此类功能的平台上的代码,这并不意味着任何类型的缺陷。此外,该标准的作者认为没有必要明确规定实施必须超出他们的方式或至少故意视而不见的行为,而不是
提供。
在 C89 下,如果函数从外部接收到指向某个存储的指针
代码并使用两种不同的非字符指针类型访问它,
行为等同于访问包含的联合成员
那些类型。根据类型及其使用方式,行为
可以由标准定义,也可以由实现定义;
一个实现可以描述需要坚持的条件
产生确定性行为,但是如果一个实现描述了事情是如何发生的
被存储并且 没有 指定编写联合成员的任何情况(通过
任何方式)和阅读另一个(通过任何方式)会产生其他行为
比他们的存储格式所暗示的,他们的行为会
由它们的存储格式定义。
gcc 的作者以某种方式决定,当标准的作者将未定义行为描述为 "non-portable or erroneous" 时,他们的意思是 "erroneous"。他们进一步认为,当作者发现可能有 一些 类型的实现时,除了强制要求之外,没有任何形式的类型双关具有足够的价值来证明支持成本的合理性,他们正在制作cost/value判断适用于所有实现。
我不认为很多程序员会反对编译器应该假设指向不同类型的东西的指针不会别名的想法 当没有证据表明这些东西可能别名时,甚至编译器不必 很远 就能找到此类证据的想法。别名问题应该只在编译器看到对一个对象的两个操作看起来是连续的,但它们之间的另一个操作影响同一个对象时才重要。在像上面这样的代码中,编译器应该没有理由被别名问题绊倒,因为使用指针转换会向任何不是故意盲目的编译器建议编译器应该只将转换前后的操作视为 "consecutive" 如果它可以看到即使没有反对别名的规则也是不可能的。
附录
别名问题实际上与 "programmer intent" 没有任何关系。在编译器测试或其他人为的场景之外,程序员本质上 从不 打算让代码的行为方式与让所有左值访问按执行顺序对底层存储进行操作所产生的行为不一致。在某些应用领域中,出于所有实际目的,此类行为始终等同于可以更有效地实现的某些其他行为,并且在某些应用领域中,后者的替代方案将明显不同且灾难性地不同。
考虑两个函数:
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
可能——尽管它的类型——实际上持有 [=11] 的地址=].另一方面,我认为第二个例子中没有任何内容表明 int *ip
可能包含 int
的地址的观点是站不住脚的。请注意,程序员 "intent" 不参与分析——问题纯粹是整数写入和读取之间是否绝对没有任何东西表明 int
可能受到影响。
在为 C89 的别名规则或后续规则给出的基本原理中,没有任何内容表明它们曾旨在让程序员跳过障碍以实现有用的行为。相反,基本原理是基于这样的观察,即在许多情况下可以无害地应用此类优化,并且应该允许编译器在此类情况下应用它们。作者希望确保需要将数据解释为字节序列的操作能够正常工作,但除此之外认为,由于不同的应用领域有不同的需求,编译器应该可以自由使用适合其预期领域的别名规则。
为了简化概念,严格别名规则声明对象应该通过兼容类型的指针或指向 char
的指针访问。这样,编译器就可以对代码做出一些假设并进行一些优化。
尽管它的解释可能会引起一些质疑和讨论,但该规则本身并不是任何国家机密。所以我的问题是:
为什么一些由经验丰富的程序员维护的受人尊敬的组织经常提交不遵守严格的别名规则的代码?我可以举的一个例子是 tcpdump
:在他们的网站 tutorial on libpcap
there's an 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
类型)的指针来访问它,因此,我相信,违反了严格的别名规则。
好吧,我假设您在 70 年代还没有通过 K&R C 学习过 C...并非所有代码都是在 21 世纪编写的。严格的别名规则 添加 到 C 语言以允许优化编译器进行更好的优化,编译器程序员足够聪明添加 -fno-strict-aliasing
标志以避免破坏遗留代码.
我在这里的意思是,编写违反严格别名规则的新代码确实很糟糕(*)。但是在采用严格的别名规则之前已经编写了大量遗留代码,在 只是完成工作 的应用程序中,tcpdump 就是一个简单的例子。
(*) 对于一些低级代码,主要是在处理网络数据和需要真正的低级优化时,愿意打破严格的别名规则并记录下来是有意义的。替代方案只能是无用地来回复制缓冲区,或者使用失去任何可移植性的汇编语言。但这不应该存在于普通代码中。
严格的别名规则存在争议。
背景知识:
请注意,"the strict aliasing rule" 不是正式术语,但它指的是第 6.5/6 段关于 有效类型 和 6.5./7 关于通过指针。后一段是实际严格的别名规则,只要语言被标准化,它就一直是 C 的一部分,所以它的存在实际上不应该让任何人感到震惊。 6.5./7 中的文本从 ANSI-C 草案到 C11 几乎完全相同。
然而,这部分在 C90 中并不清楚,因为它着重于 "lvalue access" 所使用的指针的类型,而不是实际存储在那里的数据的类型。这使得您转换为 void 指针的情况变得不清楚,例如在使用 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
这样的代码没有编译器做出诸如 "oh look, this array is never used, lets optimize it away" 或其他一些废话的假设。
大多数编译器不会发疯,而是会生成预期的代码。但按照标准,他们允许发疯。随着 gcc 在硬件相关编程中的日益普及,这正在成为嵌入式行业的一个严重问题,在嵌入式行业中,硬件相关编程是一项日常任务,而不是一种奇特的特例。
总的来说,标准委员会多次未能看到这个问题。
然后当然,很多程序员实际上首先并不知道严格的别名规则,这通常是他们编写违反它的代码的原因。
到 1989 年编写第一个 C 标准时,很明显,C 实现已用于具有不同需求的许多目的,以及具有不同能力的许多平台。人们为目标应用程序领域和平台编写 C 实现,在这些领域和平台中,提供超出标准最终要求的功能和保证是有意义的,而没有被任何标准强制这样做。依赖于此类功能或保证的代码只能移植到支持它们的平台,但没有任何可预见的可能性 运行 不支持此类功能的平台上的代码,这并不意味着任何类型的缺陷。此外,该标准的作者认为没有必要明确规定实施必须超出他们的方式或至少故意视而不见的行为,而不是 提供。
在 C89 下,如果函数从外部接收到指向某个存储的指针 代码并使用两种不同的非字符指针类型访问它, 行为等同于访问包含的联合成员 那些类型。根据类型及其使用方式,行为 可以由标准定义,也可以由实现定义; 一个实现可以描述需要坚持的条件 产生确定性行为,但是如果一个实现描述了事情是如何发生的 被存储并且 没有 指定编写联合成员的任何情况(通过 任何方式)和阅读另一个(通过任何方式)会产生其他行为 比他们的存储格式所暗示的,他们的行为会 由它们的存储格式定义。
gcc 的作者以某种方式决定,当标准的作者将未定义行为描述为 "non-portable or erroneous" 时,他们的意思是 "erroneous"。他们进一步认为,当作者发现可能有 一些 类型的实现时,除了强制要求之外,没有任何形式的类型双关具有足够的价值来证明支持成本的合理性,他们正在制作cost/value判断适用于所有实现。
我不认为很多程序员会反对编译器应该假设指向不同类型的东西的指针不会别名的想法 当没有证据表明这些东西可能别名时,甚至编译器不必 很远 就能找到此类证据的想法。别名问题应该只在编译器看到对一个对象的两个操作看起来是连续的,但它们之间的另一个操作影响同一个对象时才重要。在像上面这样的代码中,编译器应该没有理由被别名问题绊倒,因为使用指针转换会向任何不是故意盲目的编译器建议编译器应该只将转换前后的操作视为 "consecutive" 如果它可以看到即使没有反对别名的规则也是不可能的。
附录
别名问题实际上与 "programmer intent" 没有任何关系。在编译器测试或其他人为的场景之外,程序员本质上 从不 打算让代码的行为方式与让所有左值访问按执行顺序对底层存储进行操作所产生的行为不一致。在某些应用领域中,出于所有实际目的,此类行为始终等同于可以更有效地实现的某些其他行为,并且在某些应用领域中,后者的替代方案将明显不同且灾难性地不同。
考虑两个函数:
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
可能——尽管它的类型——实际上持有 [=11] 的地址=].另一方面,我认为第二个例子中没有任何内容表明 int *ip
可能包含 int
的地址的观点是站不住脚的。请注意,程序员 "intent" 不参与分析——问题纯粹是整数写入和读取之间是否绝对没有任何东西表明 int
可能受到影响。
在为 C89 的别名规则或后续规则给出的基本原理中,没有任何内容表明它们曾旨在让程序员跳过障碍以实现有用的行为。相反,基本原理是基于这样的观察,即在许多情况下可以无害地应用此类优化,并且应该允许编译器在此类情况下应用它们。作者希望确保需要将数据解释为字节序列的操作能够正常工作,但除此之外认为,由于不同的应用领域有不同的需求,编译器应该可以自由使用适合其预期领域的别名规则。