当询问C中常见的未定义行为时,人们有时会参考严格的别名规则。
他们在说什么?
遇到严格的别名问题的典型情况是,将结构(如设备/网络消息)覆盖在系统字长的缓冲区(如指向uint32_t s或uint16_t s的指针)上时。当您将结构覆盖到这样的缓冲区上,或者通过指针强制转换将缓冲区覆盖到这样的结构上时,您很容易违反严格的别名规则。
好。
因此,在这种设置中,如果要向某条消息发送消息,则必须有两个不兼容的指针指向同一块内存。然后,我可能会天真地编写如下代码:
好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| typedef struct Msg
{
unsigned int a;
unsigned int b;
} Msg;
void SendWord(uint32_t);
int main(void)
{
// 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]);
}
} |
严格的别名规则使该设置非法:取消引用对不具有兼容类型或C 2011 6.5段落71允许的其他类型之一的对象进行别名的指针是未定义的行为。不幸的是,您仍然可以通过这种方式进行编码,也许会得到一些警告,使其能够正常运行,而在运行代码时却只有怪异的意外行为。
好。
(GCC发出锯齿警告的能力似乎有些不一致,有时会给我们友好的警告,有时则不会。)
好。
要了解为什么未定义此行为,我们必须考虑严格的别名规则会给编译器带来什么。基本上,使用此规则,不必考虑在每次循环运行时插入指令来刷新buff的内容。取而代之的是,在进行优化时,可以使用一些令人讨厌的关于别名的非强制性假设,它可以忽略这些指令,在循环运行之前将buff[0]和buff[1]加载到CPU寄存器中一次,并加快循环的执行速度。在引入严格的别名之前,编译器必须处于一种妄想状态,即buff的内容可由任何人随时随地更改。因此,为了获得额外的性能优势,并假设大多数人不键入双关指针,便引入了严格的别名规则。
好。
请记住,如果您认为该示例是人为设计的,那么即使您将缓冲区传递给另一个函数来进行发送(如果您有的话),甚至可能会发生这种情况。
好。
1 2 3 4 5 6 7
| void SendMessage(uint32_t* buff, size_t size32)
{
for (int i = 0; i < size32; ++i)
{
SendWord(buff[i]);
}
} |
并重写我们之前的循环以利用此便捷功能
好。
1 2 3 4 5 6
| 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中明确允许。
好。
1 2 3 4
| union {
Msg msg;
unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)];
}; |
您可以在编译器中禁用严格别名(gcc中的f [no-] strict-aliasing)
好。
您可以将char*用作别名而不是系统的单词。规则允许char*(包括signed char和unsigned char)例外。始终假定char*为其他类型的别名。但是,这不会以其他方式起作用:没有假设您的结构别名为char缓冲区。
好。
好。
初学者当心
好。
当两种类型彼此叠加时,这只是一个潜在的雷区。您还应该了解字节序,单词对齐以及如何通过正确打包结构来处理对齐问题。
好。
脚注
1 C 2011 6.5 7允许左值访问的类型为:
好。
与对象的有效类型兼容的类型,
与对象的有效类型兼容的类型的限定版本,
类型是与对象的有效类型相对应的有符号或无符号类型,
一种类型,是与对象的有效类型的限定版本相对应的有符号或无符号类型,
在其成员(包括递归地,子集合或包含的联盟的成员)中包括上述类型之一的集合或联合类型,或
字符类型。
好。
好。
我发现的最好的解释是Mike Acton的《理解严格的别名》。它只专注于PS3开发,但这基本上只是GCC。
从文章:
"Strict aliasing is an assumption, made by the C (or C++) compiler, that dereferencing pointers to objects of different types will never refer to the same memory location (i.e. alias each other.)"
因此,基本上,如果您有一个int*指向某个包含int的内存,然后将float*指向该内存并将其用作float,则会违反规则。如果您的代码不遵守此规定,则编译器的优化器很可能会破坏您的代码。
规则的例外是char*,它可以指向任何类型。
这是严格的别名规则,可在C ++ 03标准的3.10节中找到(其他答案提供了很好的解释,但没有一个提供规则本身):
If a program attempts to access the stored value of an object through an lvalue of other than one of the following types the behavior is undefined:
-
the dynamic type of the object,
-
a cv-qualified version of the dynamic type of the object,
-
a type that is the signed or unsigned type corresponding to the dynamic type of the object,
-
a type that is the signed or unsigned type corresponding to a cv-qualified version of the dynamic 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),
-
a type that is a (possibly cv-qualified) base class type of the dynamic type of the object,
-
a char or unsigned char type.
C ++ 11和C ++ 14措辞(强调更改):
If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined:
-
the dynamic type of the object,
-
a cv-qualified version of the dynamic type of the object,
-
a type similar (as defined in 4.4) to the dynamic type of the object,
-
a type that is the signed or unsigned type corresponding to the dynamic type of the object,
-
a type that is the signed or unsigned type corresponding to a cv-qualified version of the dynamic type of the object,
-
an aggregate or union type that includes one of the aforementioned types among its elements or non-static data members (including, recursively, an element or non-static data member of a subaggregate or contained union),
-
a type that is a (possibly cv-qualified) base class type of the dynamic type of the object,
-
a char or unsigned char type.
有两个小的变化:glvalue而不是lvalue,以及对聚集/联合情况的说明。
第三个更改提供了更强的保证(放松了强大的别名规则):现在可以安全别名的类似类型的新概念。
还有C措辞(C99; ISO / IEC 9899:1999 6.5 / 7; ISO / IEC 9899:2011§6.5?7中使用完全相同的措辞):
An object shall have its stored value accessed only by an lvalue
expression that has one of the following types 73) or 88):
-
a type compatible with the effective type of the object,
-
a quali?ed 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
quali?ed 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.
73) or 88) The intent of this list is to specify those circumstances in which an object may or may not be aliased.
注意
这摘自我的"严格的混叠规则是什么,我们为什么要关心?"写上去。
好。
什么是严格的别名?
在C和C ++中,别名与允许我们通过哪些表达式类型访问存储的值有关。在C和C ++中,标准均指定允许使用哪种表达式类型作为别名。允许编译器和优化器假定我们严格遵循别名规则,因此,术语"严格别名规则"。如果我们尝试使用不允许的类型访问值,则将其分类为未定义行为(UB)。一旦我们具有不确定的行为,所有的赌注都将关闭,我们程序的结果将不再可靠。
好。
不幸的是,在严格违反别名的情况下,我们经常会获得预期的结果,从而可能会导致带有新优化的编译器的未来版本破坏我们认为有效的代码。这是不希望的,并且了解严格的别名规则以及如何避免违反它们是一个值得的目标。
好。
要了解有关我们为什么关心的更多信息,我们将讨论违反严格的别名规则,类型修剪时出现的问题,因为类型校正中使用的常见技术通常会违反严格的别名规则以及如何正确键入pun。
好。
初步例子
让我们看一些示例,然后我们可以准确地讨论标准所说的内容,研究其他示例,然后看看如何避免严格的混叠并捕获我们错过的违规行为。这是一个不足为奇的示例(实时示例):
好。
1 2 3 4 5 6 7 8
| int x = 10;
int *ip = &x;
std::cout << *ip <<"
";
*ip = 12;
std::cout << x <<"
"; |
我们有一个int *指向一个int占用的内存,这是有效的别名。优化器必须假设通过ip进行分配可以更新x占用的值。
好。
下一个示例显示了导致未定义行为的别名(实时示例):
好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *i;
}
int main() {
int x = 0;
std::cout << x <<"
"; // Expect 0
x = foo(reinterpret_cast<float*>(&x), &x);
std::cout << x <<"
"; // Expect 0?
} |
在函数foo中,我们使用int *和float *,在此示例中,我们调用foo并将两个参数设置为指向同一内存位置,在此示例中,该内存位置包含int。注意,reinterpret_cast告诉编译器将表达式视为具有其模板参数指定的类型。在这种情况下,我们告诉它将表达式&x视为具有float *类型。我们可能天真地希望第二个cout的结果为0,但是使用-O2启用优化后,gcc和clang都会产生以下结果:
好。
因为我们已经调用了未定义的行为,所以这可能不是预期的,但是完全有效。浮点数不能有效地别名为int对象。因此,优化器可以假定在解引用i时存储的常数1将是返回值,因为通过f进行的存储无法有效影响int对象。将代码插入Compiler Explorer可以显示这正是正在发生的情况(实时示例):
好。
1 2 3 4 5
| foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret |
使用基于类型的别名分析(TBAA)的优化器假设将返回1,并将常数直接移到带有返回值的寄存器eax中。 TBAA使用有关允许使用哪种类型的别名的语言规则来优化负载和存储。在这种情况下,TBAA知道float不能别名和int并优化了i的负载。
好。
现在,到规则书
该标准确切说明允许和禁止我们做什么?标准语言不是很简单,因此我将为每一项尝试提供代码示例以说明其含义。
好。
C11标准怎么说?
C11标准在6.5表达式第7段中指出以下内容:
好。
An object shall have its stored value accessed only by an lvalue expression that has one of the following types:88)
— a type compatible with the effective type of the object,
Ok.
1 2 3 4
| int x = 1;
int *p = &x;
printf("%d
", *p); // *p gives us an lvalue expression of type int which is compatible with int |
— a qualified version of a type compatible with the effective type of the object,
Ok.
1 2 3 4
| int x = 1;
const int *p = &x;
printf("%d
", *p); // *p gives us an lvalue expression of type const int which is compatible with int |
— a type that is the signed or unsigned type corresponding to the effective type of the object,
Ok.
1 2 3 4 5
| int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u
", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to
// the effective type of the object |
gcc / clang有一个扩展,并且即使它们不是兼容类型,也允许将unsigned int *分配给int *。
好。
— a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object,
Ok.
1 2 3 4 5
| int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u
", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type
// that corresponds with to a qualified verison 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
Ok.
1 2 3 4 5 6 7 8 9
| struct foo {
int x;
};
void foobar( struct foo *fp, int *ip ); // struct foo is an aggregate that includes int among its members so it can
// can alias with *ip
foo f;
foobar( &f, &f.x ); |
— a character type.
Ok.
1 2 3 4 5
| int x = 65;
char *p = (char *)&x;
printf("%c
", *p ); // *p gives us an lvalue expression of type char which is a character type.
// The results are not portable due to endianness issues. |
C ++ 17草案标准怎么说
[basic.lval]第11段中的C ++ 17标准草案说:
好。
If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined:63
(11.1) — the dynamic type of the object,
Ok.
1 2 3 4 5
| void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0}; // Placement new changes the dynamic type of the object to int
std::cout << *ip <<"
"; // *ip gives us a glvalue expression of type int which matches the dynamic type
// of the allocated object |
(11.2) — a cv-qualified version of the dynamic type of the object,
Ok.
1 2 3 4 5
| int x = 1;
const int *cip = &x;
std::cout << *cip <<"
"; // *cip gives us a glvalue expression of type const int which is a cv-qualified
// version of the dynamic type of x |
(11.3) — a type similar (as defined in 7.5) to the dynamic type of the object,
Ok.
(11.4) — a type that is the signed or unsigned type corresponding to the dynamic type of the object,
Ok.
1 2 3 4 5 6 7 8
| // Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
si = 1;
ui = 2;
return si;
} |
(11.5)—一种类型,是与对象的动态类型的CV限定版本相对应的有符号或无符号类型,
好。
blockquote>
1
| signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing |
(11.6) — an aggregate or union type that includes one of the aforementioned types among its elements or nonstatic data members (including, recursively, an element or non-static data member of a subaggregate or contained union),
Ok.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| struct foo {
int x;
};
// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
fp.x = 1;
ip = 2;
return fp.x;
}
foo f;
foobar( f, f.x ); |
(11.7) — a type that is a (possibly cv-qualified) base class type of the dynamic type of the object,
Ok.
1 2 3 4 5 6 7 8 9 10
| struct foo { int x ; };
struct bar : public foo {};
int foobar( foo &f, bar &b ) {
f.x = 1;
b.x = 2;
return f.x;
} |
(11.8) — a char, unsigned char, or std::byte type.
Ok.
1 2 3 4 5 6 7
| int foo( std::byte &b, uint32_t &ui ) {
b = static_cast<std::byte>('a');
ui = 0xFFFFFFFF;
return std::to_integer<int>( b ); // b gives us a glvalue expression of type std::byte which can alias
// an object of type uint32_t
} |
值得一提的是,上面的列表中未包括带符号的char,这与C表示字符类型的显着区别。
好。
什么是Type Punning
我们到了这一点,我们可能想知道,为什么要别名呢?答案通常是键入pun,通常使用的方法违反严格的别名规则。
好。
有时我们想绕过类型系统,并将对象解释为其他类型。这称为类型调整,用于将内存段重新解释为另一种类型。对于需要访问对象的基础表示形式以进行查看,传输或操作的任务,类型修剪非常有用。我们发现使用的类型修剪的典型领域是编译器,序列化,网络代码等。
好。
传统上,这是通过获取对象的地址,将其转换为我们要重新解释为该类型的指针,然后访问该值或换句话说通过别名来实现的。例如:
好。
1 2 3 4 5 6 7 8 9 10
| int x = 1 ;
// In C
float *fp = (float*)&x ; // Not a valid aliasing
// In C++
float *fp = reinterpret_cast<float*>(&x) ; // Not a valid aliasing
printf("%f
", *fp ) ; |
如前所述,这不是有效的别名,因此我们正在调用未定义的行为。但是传统上,编译器没有利用严格的别名规则,这种类型的代码通常只能工作,不幸的是,开发人员已经习惯了这种方式。一种类型为punning的常见替代方法是通过联合,该联合在C中有效,但在C ++中为未定义行为(请参见实时示例):
好。
1 2 3 4 5 6 7 8 9 10 11
| union u1
{
int n;
float f;
} ;
union u1 u;
u.f = 1.0f;
printf("%d
", u.n ); // UB in C++ n is not the active member |
这在C ++中是无效的,并且一些人认为联合的目的仅仅是实现变量类型,并且觉得使用联合进行类型修剪是一种滥用。
好。
我们如何正确键入Pun?
在C和C ++中进行类型修剪的标准方法是memcpy。这看起来似乎有点费劲,但是优化程序应该认识到memcpy用于punning类型并对其进行优化,并生成一个寄存器来记录移动。例如,如果我们知道int64_t与double大小相同:
好。
1
| static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17 does not require a message |
我们可以使用memcpy:
好。
1 2 3 4
| void func1( double d ) {
std::int64_t n;
std::memcpy(&n, &d, sizeof d);
//... |
在足够的优化级别上,任何体面的现代编译器都会生成与前面提到的用于类型修剪的reinterpret_cast方法或union方法相同的代码。检查生成的代码,我们看到它仅使用了mov(实时Compiler Explorer示例)。
好。
C ++ 20和bit_cast
在C ++ 20中,我们可能会获得bit_cast(在提案的链接中可用的实现),它提供了一种简单而安全的方法来进行双打(pun-pun)以及可在constexpr上下文中使用。
好。
以下是如何使用bit_cast键入无符号int浮点型pun的示例(实时查看):
好。
1 2
| std::cout << bit_cast<float>(0x447a0000) <<"
" ; //assuming sizeof(float) == sizeof(unsigned int) |
如果To和From类型的大小不同,则需要我们使用中间struct15。我们将使用一个包含sizeof(unsigned int)字符数组的结构(假定4字节unsigned int)作为From类型,而unsigned int作为To类型:
好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| struct uint_chars {
unsigned char arr[sizeof( unsigned int )] = {} ; // Assume sizeof( unsigned int ) == 4
};
// Assume len is a multiple of 4
int bar( unsigned char *p, size_t len ) {
int result = 0;
for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
uint_chars f;
std::memcpy( f.arr, &p[index], sizeof(unsigned int));
unsigned int result = bit_cast<unsigned int>(f);
result += foo( result );
}
return result ;
} |
不幸的是我们需要这种中间类型,但这是bit_cast的当前约束。
好。
捕捉严格的混叠违规
我们没有很多很好的工具来捕获C ++中的严格别名,我们拥有的工具将捕获某些情况下的严格别名冲突以及某些情况下的装入和存储未对齐。
好。
使用标志-fstrict-aliasing和-Wstrict-aliasing的gcc可以捕获某些情况,尽管并非没有假阳性/阴性。例如,以下情况将在gcc中生成警告(实时查看):
好。
1 2 3 4 5 6 7 8 9
| int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught
// it was being accessed w/ an indeterminate value below
printf("%i
", j = *(reinterpret_cast<short*>(&a)));
printf("%i
", j = *(reinterpret_cast<int*>(&f))); |
尽管它不会捕获这种额外的情况(请现场观看):
好。
1 2 3 4 5
| int *p;
p=&a;
printf("%i
", j = *(reinterpret_cast<short*>(p))); |
尽管clang允许这些标志,但显然并没有实际实现警告。
好。
我们提供给我们的另一个工具是ASan,它可以捕获未对齐的负载和存储。尽管这些不是直接的严格混叠违例,但是它们是严格混叠违例的常见结果。例如,以下情况在使用-fsanitize = address与clang一起构建时会生成运行时错误
好。
1 2 3 4 5
| int *x = new int[2]; // 8 bytes: [0,7].
int *u = (int*)((char*)x + 6); // regardless of alignment of x this will not be an aligned address
*u = 1; // Access to range [6-9]
printf("%d
", *u ); // Access to range [6-9] |
我将推荐的最后一个工具是C ++特定的,而不是严格的工具,而是编码实践,不允许C样式转换。 gcc和clang都将使用-Wold-style-cast为C样式转换生成诊断。这将迫使所有未定义类型的双关语都使用reinterpret_cast,通常,reinterpret_cast应该是进行更仔细代码审查的标志。在代码库中搜索reinterpret_cast来执行审核也更加容易。
好。
对于C,我们已经涵盖了所有工具,并且我们还拥有tis解释器,这是一个静态分析器,可以对很大一部分C语言的程序进行详尽的分析。给定较早示例的C版本,其中使用-fstrict-aliasing会遗漏一种情况(现场观看)
好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| int a = 1;
short j;
float f = 1.0 ;
printf("%i
", j = *((short*)&a));
printf("%i
", j = *((int*)&f));
int *p;
p=&a;
printf("%i
", j = *((short*)p)); |
tis-interpeter可以捕获全部三个,下面的示例将tis-kernal用作tis-解释器(为简洁起见对输出进行了编辑):
好。
1 2 3 4 5 6 7 8 9 10 11 12 13
| ./bin/tis-kernel -sa example1.c
...
example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing
rules by accessing a cell with effective type int.
...
example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by
accessing a cell with effective type float.
Callstack: main
...
example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by
accessing a cell with effective type int. |
最后是目前正在开发中的TySan。此清理程序在影子内存段中添加类型检查信息,并检查访问以查看它们是否违反别名规则。该工具可能应该能够捕获所有违反别名的情况,但可能会有很大的运行时开销。
好。
好。
严格的别名不仅指指针,还影响引用,我为boost开发者Wiki撰写了一篇有关它的论文,并且受到广泛好评,以至于我将其变成了我的咨询网站上的一个页面。它完整??地解释了它的含义,为何使人们如此困惑以及如何处理。严格的别名白皮书。特别是,它解释了为什么工会是C ++的危险行为,以及为什么使用memcpy是跨C和C ++的唯一可移植修订。希望这会有所帮助。
作为Doug T.已写内容的附录,在这里
是一个简单的测试用例,可能会用gcc触发它:
检查
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #include <stdio.h>
void check(short *h,long *k)
{
*h=5;
*k=6;
if (*h == 5)
printf("strict aliasing problem
");
}
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。
对于那些对此感兴趣的人是由gcc 4.6.3生成的x64汇编代码,该代码在x64的ubuntu 12.04.2上运行:
1 2 3 4
| movw $5, (%rdi)
movq $6, (%rsi)
movl $.LC0, %edi
jmp puts |
因此,if条件完全脱离了汇编代码。
通过指针强制类型转换(与使用联合相对)是打破严格别名的一个主要示例。
根据C89的基本原理,该标准的作者不希望要求编译器提供以下代码:
1 2 3 4 5 6 7
| int x;
int test(double *p)
{
x=5;
*p = 1.0;
return x;
} |
应该要求在赋值和返回语句之间重新加载x的值,以便允许p可能指向x的可能性,并且赋值给*p的结果可能会更改。编译器应有权假定在上述情况下不会出现混淆的观点是无争议的。
不幸的是,C89的作者编写规则的方式是,即使从字面上读取,也可以使以下函数调用未定义行为:
1 2 3 4 5
| void test(void)
{
struct S {int x;} s;
s.x = 1;
} |
因为它使用类型为int的左值来访问类型为struct S的对象,并且int不在可用于访问struct S的类型之中。因为将所有对结构和联合的非字符类型成员的使用都视为未定义行为是荒谬的,所以几乎每个人都认识到至少在某些情况下,一种类型的左值可用于访问另一种类型的对象。不幸的是,C标准委员会未能定义这些情况。
大多数问题是由于缺陷报告#028导致的,该报告询问程序的行为,例如:
1 2 3 4 5 6 7 8 9 10 11
| int test(int *ip, double *dp)
{
*ip = 1;
*dp = 1.23;
return *ip;
}
int test2(void)
{
union U { int i; double d; } u;
return test(&u.i, &u.d);
} |
缺陷报告#28指出该程序调用了Undefined Behavior,因为编写" double"类型的并集成员并读取" int"类型之一的行为将调用" Implementation-Defined"行为。这种推理是荒谬的,但却构成了有效类型规则的基础,该规则不必要地使语言复杂化,而无助于解决原始问题。
解决原始问题的最佳方法可能是对待
关于规则目的的脚注,就好像它是规范性的一样
该规则不可执行,除非实际涉及使用别名的冲突访问。给出类似的东西:
1 2 3 4 5 6 7 8 9 10
| void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
s.x = 1;
p = &s.x;
inc_int(p);
return s.x;
} |
在inc_int中没有冲突,因为对通过*p访问的存储的所有访问都使用类型为int的左值完成,在test中也没有冲突,因为p显然是从struct S派生的,并且在下一次使用s时,将已经通过p进行的对该存储的所有访问。
如果代码稍作更改...
1 2 3 4 5 6 7 8 9 10
| void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
p = &s.x;
s.x = 1; // !!*!!
*p += 1;
return s.x;
} |
在这里,p和对标记行上的s.x的访问之间存在别名冲突,因为在执行该点时,存在另一个引用,该引用将用于访问同一存储。
如果Defect Report 028表示原始示例调用UB是因为两个指针的创建和使用之间存在重叠,这将使事情变得更加清晰,而不必添加"有效类型"或其他此类复杂性。
阅读许多答案后,我觉得有必要添加一些内容:
严格的别名(稍后将描述)很重要,因为:
内存访问可能很昂贵(从性能角度而言),这就是为什么在将数据写回物理内存之前先在CPU寄存器中对其进行操作。
如果将两个不同的CPU寄存器中的数据写入相同的内存空间,那么当我们用C编写代码时,我们无法预测哪些数据将"存活"。
在汇编中,我们手动编码CPU寄存器的加载和卸载,我们将知道哪些数据保持不变。但是C(非常感谢)将这个细节抽象了。
由于两个指针可以指向内存中的相同位置,因此这可能导致处理可能的冲突的复杂代码。
这种额外的代码很慢,并且会降低性能,因为它会执行额外的内存读/写操作,这既较慢,也可能是不必要的。
严格的别名规则使我们可以避免冗余的机器代码,在这种情况下,可以安全地假设两个指针没有指向同一个内存块(另请参见restrict关键字)。
严格的别名表示可以安全地假定指向不同类型的指针指向内存中的不同位置。
如果编译器注意到两个指针指向不同的类型(例如,int *和float *),则它将假定内存地址不同,并且无法防止内存地址冲突,从而导致更快的机器代码。
例如:
让我们假设以下功能:
1 2 3 4
| void merge_two_ints(int *a, int *b) {
*b += *a;
*a += *b;
} |
为了处理a == b(两个指针都指向相同的内存)的情况,我们需要排序和测试将数据从内存加载到CPU寄存器的方式,因此代码可能会像这样结束:
从内存中加载a和b。
将a添加到b。
保存b并重新加载a。
(从CPU寄存器保存到内存,然后从内存加载到CPU寄存器)。
将b添加到a。
将a(来自CPU寄存器)保存到内存。
步骤3非常慢,因为它需要访问物理内存。但是,需要防止a和b指向相同内存地址的实例。
严格的别名可以通过告诉编译器这些内存地址明显不同来防止这种情况发生(在这种情况下,这将允许进一??步的优化,如果指针共享一个内存地址则无法执行)。
这可以通过两种方式告诉编译器,方法是使用不同的类型指向。即:
1
| void merge_two_numbers(int *a, long *b) {...} |
使用restrict关键字。即:
1
| void merge_two_ints(int * restrict a, int * restrict b) {...} |
现在,通过满足"严格别名"规则,可以避免步骤3,并且代码的运行速度将大大提高。
实际上,通过添加restrict关键字,可以将整个功能优化为:
从内存中加载a和b。
将a添加到b。
将结果保存到a和b。
由于可能发生冲突(a和b将会是三倍而不是两倍),因此以前不可能完成此优化。
严格的别名不允许不同的指针类型指向相同的数据。
本文应帮助您详细了解此问题。
从技术上讲,在C ++中,严格的别名规则可能永远都不适用。
注意间接定义(*运算符):
The unary * operator performs indirection: the expression to which it
is applied shall be a pointer to an object type, or a pointer to a
function type and the result is an lvalue referring to the object or
function to which the expression points.
也来自glvalue的定义
A glvalue is an expression whose evaluation determines the identity of
an object, (...snip)
因此,在任何定义良好的程序跟踪中,glvalue均指对象。因此,所谓的严格别名规则永远不会适用。这可能不是设计师想要的。