严格的别名规则是什么?
-
01-07-2019 - |
题
当询问有关 C 中常见的未定义行为, ,灵魂比我提到的严格别名规则更开明。
他们在说什么?
解决方案
遇到严格别名问题的典型情况是将结构(如设备/网络消息)覆盖到系统字大小的缓冲区(如指向 uint32_t
或 uint16_t
s)。当您将结构覆盖到此类缓冲区上,或通过指针转换将缓冲区覆盖到此类结构上时,您很容易违反严格的别名规则。
因此,在这种设置中,如果我想向某个对象发送消息,我必须有两个不兼容的指针指向同一块内存。然后我可能会天真地编写这样的代码:
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 第 7 段允许的其他类型之一1 是未定义的行为。不幸的是,你仍然可以这样编码, 或许 收到一些警告,让它编译良好,但在运行代码时却出现奇怪的意外行为。
(GCC 给出别名警告的能力似乎有些不一致,有时给我们友好的警告,有时则不。)
要了解为什么这种行为是未定义的,我们必须考虑严格的别名规则会给编译器带来什么。基本上,有了这个规则,它就不必考虑插入指令来刷新内容 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]);
}
}
并重写了我们之前的循环以利用这个方便的功能
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 中明确允许。
union { Msg msg; unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)]; };
您可以在编译器中禁用严格别名(f[无-]严格别名 在海湾合作委员会))
您可以使用
char*
用于别名而不是系统的单词。规则允许例外char*
(包括signed char
和unsigned char
)。人们总是假设char*
其他类型的别名。然而,这不会以其他方式工作:不假设您的结构为字符缓冲区别名。
初学者要小心
当两种类型相互叠加时,这只是一个潜在的雷区。您还应该了解 字节顺序, 单词对齐, ,以及如何通过以下方式处理对齐问题 包装结构 正确。
脚注
1 C 2011 6.5 7 允许左值访问的类型有:
- 与对象的有效类型兼容的类型,
- 与对象的有效类型兼容的类型的限定版本,
- 与对象的有效类型相对应的有符号或无符号类型,
- 与对象有效类型的限定版本相对应的有符号或无符号类型的类型,
- 聚合或联合类型,其成员中包含上述类型之一(递归地包括子聚合或包含联合的成员),或者
- 一种字符类型。
其他提示
我找到的最好的解释是迈克·阿克顿(Mike Acton)的, 了解严格别名. 。它稍微关注 PS3 开发,但基本上只是 GCC。
来自文章:
“严格别名是 C(或 C++)编译器做出的一种假设,即解除引用不同类型对象的指针永远不会引用相同的内存位置(即互相别名。)”
所以基本上如果你有一个 int*
指向包含某个内存 int
然后你指向一个 float*
到该内存并将其用作 float
你违反了规则。如果您的代码不遵守这一点,那么编译器的优化器很可能会破坏您的代码。
该规则的例外是 char*
, ,允许指向任何类型。
这是严格的别名规则,见 3.10 节 C++03 标准(其他答案提供了很好的解释,但没有提供规则本身):
如果程序尝试通过以下类型之一以外的左值访问对象的存储值,则行为未定义:
- 对象的动态类型,
- 对象动态类型的 cv 限定版本,
- 与对象的动态类型相对应的有符号或无符号类型,
- 与对象动态类型的 cv 限定版本相对应的有符号或无符号类型,
- 聚合或联合类型,其成员中包含上述类型之一(递归地包括子聚合或包含联合的成员),
- 是对象动态类型的(可能是 cv 限定的)基类类型的类型,
- A
char
或者unsigned char
类型。
C++11 和 C++14 措辞(强调变化):
如果程序尝试通过访问对象的存储值 左值 除以下类型之一外,行为未定义:
- 对象的动态类型,
- 对象动态类型的 cv 限定版本,
- 与对象的动态类型类似的类型(如 4.4 中定义),
- 与对象的动态类型相对应的有符号或无符号类型,
- 与对象动态类型的 cv 限定版本相对应的有符号或无符号类型,
- 聚合或联合类型,其中包括上述类型之一 元素或非静态数据成员 (递归地包括 元素或非静态数据成员 子聚合或包含的联合),
- 是对象动态类型的(可能是 cv 限定的)基类类型的类型,
- A
char
或者unsigned char
类型。
有两个变化很小: 左值 代替 左值, ,以及聚合/联合案例的澄清。
第三个更改提供了更强的保证(放宽了强别名规则):新概念 相似类型 现在可以安全地使用别名了。
还有 C 措辞(C99;ISO/IEC 9899:1999 6.5/7;ISO/IEC 9899:2011 §6.5 ¶7) 中使用了完全相同的措辞:
一个对象应仅通过具有以下类型之一的LVALUE表达式访问其存储值 73) 或 88):
- 与对象的有效类型兼容的类型,
- 与对象的有效类型兼容的类型的合格版本,
- 与对象的有效类型相对应的签名或无符号类型的类型,
- 与对象的有效类型的合格版本相对应的签名或无符号类型的类型,
- 汇总或工会类型,其中包括其成员中上述类型之一(包括,递归,是亚群落或包含工会的成员),或
- 一种字符类型。
73) 或 88) 此列表的目的是指定对象可以使用或不使用别名的情况。
笔记
这是摘自我的 “什么是严格别名规则以及我们为什么关心?” 写上去。
什么是严格别名?
在 C 和 C++ 中,别名与允许我们访问存储值的表达式类型有关。在 C 和 C++ 中,标准指定允许哪些表达式类型为哪些类型设置别名。编译器和优化器可以假设我们严格遵循别名规则,因此术语 严格的别名规则. 。如果我们尝试使用不允许的类型访问值,则会将其分类为 未定义的行为(UB)。一旦我们出现未定义的行为,所有的赌注都会失效,我们的程序的结果将不再可靠。
不幸的是,在严格的别名违规情况下,我们通常会获得我们期望的结果,从而使得具有新优化的编译器的未来版本可能会破坏我们认为有效的代码。这是不可取的,但理解严格的别名规则以及如何避免违反它们是一个值得的目标。
为了更多地了解我们关心的原因,我们将讨论违反严格别名规则、类型双关时出现的问题,因为类型双关中使用的常见技术经常违反严格的别名规则以及如何正确输入双关语。
初步例子
让我们看一些例子,然后我们可以准确地讨论标准的内容,检查一些进一步的例子,然后看看如何避免严格的别名和捕获我们错过的违规行为。这是一个不足为奇的例子(活生生的例子):
int x = 10;
int *ip = &x;
std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";
我们有一个 整数* 指向被占用的内存 整数 这是一个有效的别名。优化器必须假设通过分配 ip 可以更新占用的值 X.
下一个示例显示了导致未定义行为的别名(活生生的例子):
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *i;
}
int main() {
int x = 0;
std::cout << x << "\n"; // Expect 0
x = foo(reinterpret_cast<float*>(&x), &x);
std::cout << x << "\n"; // Expect 0?
}
在函数中 富 我们采取 整数* 和一个 漂浮*, ,在这个例子中我们称 富 并将两个参数设置为指向同一内存位置,在此示例中包含 整数. 。注意, 重新解释_cast 告诉编译器将表达式视为具有模板参数指定的类型。在这种情况下,我们告诉它处理表达式 &X 就好像它有类型一样 漂浮*. 。我们可能会天真地期待第二个结果 库特 成为 0 但启用优化后使用 -氧气 gcc 和 clang 都会产生以下结果:
0
1
这可能不是预期的,但完全有效,因为我们调用了未定义的行为。A 漂浮 无法有效地别名 整数 目的。因此优化器可以假设 常数1 取消引用时存储 我 将是自存储以来的返回值 F 不能有效地影响 整数 目的。将代码插入编译器资源管理器中表明这正是正在发生的事情(活生生的例子):
foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret
优化器使用 基于类型的别名分析 (TBAA) 假设 1 将被返回并直接将常量值移入寄存器 埃克斯 它携带返回值。TBAA 使用有关允许使用别名的类型的语言规则来优化加载和存储。在这种情况下,TBAA 知道 漂浮 不能使用别名和 整数 并优化掉负载 我.
现在,进入规则手册
标准到底规定了什么是允许做的,什么是不允许做的?标准语言并不简单,因此对于每个项目,我将尝试提供演示其含义的代码示例。
C11标准怎么说?
这 C11 标准在章节中说明了以下内容 6.5 表达式第 7 段:
对象的存储值只能由具有以下类型之一的左值表达式访问:88)— 与对象的有效类型兼容的类型,
int x = 1;
int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int
— 与对象的有效类型兼容的类型的限定版本,
int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int
— 与对象的有效类型相对应的有符号或无符号类型,
int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to
// the effective type of the object
gcc/clang 有一个扩展 和 还 允许分配 无符号整数* 到 整数* 即使它们不是兼容的类型。
— 与对象有效类型的限定版本相对应的有符号或无符号类型,
int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *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
— 聚合或联合类型,其成员中包含上述类型之一(递归地包括子聚合或包含联合的成员),或者
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 );
— 字符类型。
int x = 65;
char *p = (char *)&x;
printf("%c\n", *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 标准草案的内容
C++17 标准草案部分 [basic.lval] 第 11 段 说:
如果程序尝试通过以下类型之一以外的泛左值访问对象的存储值,则行为未定义:63(11.1) — 对象的动态类型,
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 << "\n"; // *ip gives us a glvalue expression of type int which matches the dynamic type
// of the allocated object
(11.2) — 对象动态类型的 cv 限定版本,
int x = 1;
const int *cip = &x;
std::cout << *cip << "\n"; // *cip gives us a glvalue expression of type const int which is a cv-qualified
// version of the dynamic type of x
(11.3) — 与对象的动态类型类似(如 7.5 中定义)的类型,
(11.4) — 与对象的动态类型相对应的有符号或无符号类型,
// 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 限定版本相对应的有符号或无符号类型,
signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing
(11.6) — 聚合或联合类型,其元素或非静态数据成员中包含上述类型之一(递归地包括子聚合或包含的联合的元素或非静态数据成员),
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) — 一种类型,该类型是对象动态类型的(可能是 cv 限定的)基类类型,
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) — char、unsigned char 或 std::byte 类型。
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
}
值得注意 签名字符 未包含在上面的列表中,这是与 C 其中说 字符类型.
什么是类型双关语
到了这一步,我们可能会想,为什么我们要为 for 起别名?答案通常是 类型双关语, ,所使用的方法通常违反严格的别名规则。
有时我们想绕过类型系统并将对象解释为不同的类型。这就是所谓的 类型双关语, ,将一段内存重新解释为另一种类型。 类型双关语 对于想要访问对象的底层表示以进行查看、传输或操作的任务非常有用。我们发现使用类型双关的典型领域是编译器、序列化、网络代码等……
传统上,这是通过获取对象的地址,将其转换为我们想要将其重新解释为的类型的指针,然后访问该值(或者换句话说,通过别名)来完成的。例如:
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\n", *fp ) ;
正如我们之前所看到的,这不是有效的别名,因此我们调用未定义的行为。但传统上编译器并没有利用严格的别名规则,这种类型的代码通常可以正常工作,不幸的是,开发人员已经习惯了这种方式。类型双关的常见替代方法是通过联合,这在 C 中有效,但 未定义的行为 在 C++ 中(查看实例):
union u1
{
int n;
float f;
} ;
union u1 u;
u.f = 1.0f;
printf( "%d\n”, u.n ); // UB in C++ n is not the active member
这在 C++ 中是无效的,有些人认为联合的目的仅仅是为了实现变体类型,并且认为使用联合进行类型双关是一种滥用。
我们如何正确输入双关语?
标准方法为 类型双关语 在 C 和 C++ 中都是 内存复制. 。这可能看起来有点严厉,但优化器应该认识到使用 内存复制 为了 类型双关语 并将其优化并生成一个寄存器来注册移动。例如如果我们知道 int64_t 大小与 双倍的:
static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17 does not require a message
我们可以用 内存复制:
void func1( double d ) {
std::int64_t n;
std::memcpy(&n, &d, sizeof d);
//...
在足够的优化级别上,任何像样的现代编译器都会生成与前面提到的相同的代码 重新解释_cast 方法或 联盟 方法用于 类型双关语. 。检查生成的代码,我们发现它仅使用寄存器 mov (实时编译器资源管理器示例).
C++20 和 bit_cast
在C++20中我们可能会获得 位播 (提案链接中提供了实施方案) 它提供了一种简单而安全的双关语类型,并且可以在 constexpr 上下文中使用。
以下是如何使用的示例 位播 输入双关语 a 无符号整数 到 漂浮, (现场观看):
std::cout << bit_cast<float>(0x447a0000) << "\n" ; //assuming sizeof(float) == sizeof(unsigned int)
在这种情况下 到 和 从 类型没有相同的大小,它需要我们使用中间struct15。我们将使用一个包含 sizeof(无符号整型) 字符数组 (假设 4 字节无符号整型)成为 从 类型和 无符号整数 作为 到 类型。:
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 ;
}
不幸的是,我们需要这种中间类型,但这是当前的限制 位播.
捕获严格的别名违规行为
我们没有很多好的工具来捕获 C++ 中的严格别名,但我们拥有的工具可以捕获一些严格别名违规的情况以及一些加载和存储未对齐的情况。
gcc 使用标志 -fstrict-别名 和 -Wstrict-别名 可以捕获一些病例,尽管并非没有误报/漏报。例如,以下情况将在 gcc 中生成警告(现场观看):
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\n", j = *(reinterpret_cast<short*>(&a)));
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));
尽管它不会捕获这种额外的情况(现场观看):
int *p;
p=&a;
printf("%i\n", j = *(reinterpret_cast<short*>(p)));
尽管 clang 允许使用这些标志,但它显然并未实际执行这些警告。
我们可以使用的另一个工具是 ASan,它可以捕获未对齐的负载和存储。尽管这些不是直接的严格别名违规,但它们是严格别名违规的常见结果。例如,以下情况在使用 clang 构建时将生成运行时错误 -fsanitize=地址
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\n", *u ); // Access to range [6-9]
我推荐的最后一个工具是 C++ 特定的,严格来说不是一个工具,而是一种编码实践,不允许 C 风格的强制转换。gcc 和 clang 都会使用以下命令生成 C 风格转换的诊断 -Wold风格铸造. 。这将强制任何未定义的类型双关语使用reinterpret_cast,一般来说reinterpret_cast应该是一个用于更仔细的代码审查的标志。在代码库中搜索reinterpret_cast 来执行审核也更容易。
对于 C,我们拥有已经涵盖的所有工具,并且我们还有 tis-interpreter,这是一个静态分析器,可以针对 C 语言的大部分子集详尽地分析程序。给定前面示例的 C 版本,其中使用 -fstrict-别名 漏掉一例(现场观看)
int a = 1;
short j;
float f = 1.0 ;
printf("%i\n", j = *((short*)&a));
printf("%i\n", j = *((int*)&f));
int *p;
p=&a;
printf("%i\n", j = *((short*)p));
tis-interpeter 能够捕获所有三个,以下示例调用 tis-kernal 作为 tis-interpreter(为了简洁起见,编辑了输出):
./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.
最后有 泰桑 目前正在开发中。该清理程序在影子内存段中添加类型检查信息,并检查访问以查看它们是否违反别名规则。该工具应该能够捕获所有别名违规,但可能会产生大量运行时开销。
严格别名不仅仅指指针,它也会影响引用,我为 boost 开发人员 wiki 写了一篇关于它的论文,它受到了广泛好评,以至于我将其变成了我的咨询网站上的一个页面。它完整地解释了它是什么、为什么它让人们如此困惑以及如何应对。 严格别名白皮书. 。特别是,它解释了为什么联合对于 C++ 来说是危险的行为,以及为什么使用 memcpy 是跨 C 和 C++ 的唯一可移植的解决方案。希望这有帮助。
作为 Doug T. 内容的附录。已经写过,这是一个简单的测试用例,可能会触发GCC:
检查.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 条件从汇编代码中完全消失了。
类型双关语 通过指针强制转换(而不是使用联合)是打破严格别名的一个主要例子。
根据 C89 的基本原理,该标准的作者不想要求编译器给出如下代码:
int x;
int test(double *p)
{
x=5;
*p = 1.0;
return x;
}
应该需要重新加载的值 x
在赋值和返回语句之间,以便允许以下可能性: p
可能指向 x
, ,以及分配给 *p
可能会因此改变的价值 x
. 。编译器应该有权假设不会出现别名的概念 在上述情况下 是没有争议的。
不幸的是,C89 的作者编写规则的方式是,如果按字面意思阅读,甚至会导致以下函数调用未定义行为:
void test(void)
{
struct S {int x;} s;
s.x = 1;
}
因为它使用类型的左值 int
访问类型的对象 struct S
, , 和 int
不属于可用于访问的类型之一 struct S
. 。因为将结构体和联合体的非字符类型成员的所有使用都视为未定义行为是荒谬的,所以几乎每个人都认识到至少在某些情况下可以使用一种类型的左值来访问另一种类型的对象。不幸的是,C 标准委员会未能定义这些情况是什么。
大部分问题是缺陷报告 #028 造成的,该报告询问了以下程序的行为:
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 指出程序调用未定义行为,因为写入“double”类型的联合成员并读取“int”类型之一的操作会调用实现定义的行为。这种推理是无意义的,但却构成了有效类型规则的基础,该规则不必要地使语言复杂化,同时对解决原始问题没有任何作用。
解决原始问题的最佳方法可能是将脚注处理有关规则的目的,就好像是规范性的一样,并使该规则无法执行,除非在实际涉及使用别名相互冲突的访问的情况下。给出类似的东西:
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
将会已经发生。
如果代码稍微改变一下......
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
在标记线上,因为在执行时存在另一个引用 将用于访问相同的存储.
如果缺陷报告 028 表示原始示例由于两个指针的创建和使用之间的重叠而调用了 UB,那么事情就会变得更加清晰,而无需添加“有效类型”或其他此类复杂性。
看了很多答案,我觉得有必要补充一下:
严格别名(我将稍后描述) 很重要,因为:
内存访问可能会很昂贵(性能方面),这就是为什么 数据在CPU寄存器中进行操作 在被写回物理内存之前。
如果两个不同CPU寄存器中的数据将被写入同一个内存空间, 我们无法预测哪些数据会“幸存” 当我们用C编写代码时。
在汇编中,我们手动编写 CPU 寄存器的加载和卸载代码,我们将知道哪些数据保持不变。但幸运的是,C 抽象了这个细节。
由于两个指针可以指向内存中的同一位置,这可能会导致 处理可能的冲突的复杂代码.
这个额外的代码很慢并且 损害表现 因为它执行额外的内存读/写操作,这些操作既慢又(可能)不必要。
这 严格的别名规则使我们能够避免冗余的机器代码 在这种情况下 应该 可以安全地假设两个指针不指向同一个内存块(另请参阅 restrict
关键词)。
严格别名声明可以安全地假设指向不同类型的指针指向内存中的不同位置。
如果编译器注意到两个指针指向不同的类型(例如, int *
和一个 float *
),它会假设内存地址不同并且它 将不会 防止内存地址冲突,从而产生更快的机器代码。
例如:
让我们假设以下函数:
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
指向同一个内存地址。
严格的别名可以让我们通过告诉编译器这些内存地址明显不同来防止这种情况发生(在这种情况下,这将允许进一步的优化,如果指针共享内存地址则无法执行)。
这可以通过两种方式告诉编译器,即使用不同的类型来指向。IE。:
void merge_two_numbers(int *a, long *b) {...}
使用
restrict
关键词。IE。:void merge_two_ints(int * restrict a, int * restrict b) {...}
现在,通过满足严格别名规则,可以避免步骤 3,并且代码的运行速度将显着加快。
事实上,通过添加 restrict
关键字,整个函数可以优化为:
加载
a
和b
从记忆里。添加
a
到b
.将结果保存到
a
并b
.
由于可能发生冲突,这种优化以前无法完成(其中 a
和 b
将增加三倍而不是两倍)。
严格别名不允许不同的指针类型指向相同的数据。
本文 应该可以帮助您全面详细地了解该问题。