当询问 C 中常见的未定义行为时,人们有时会提到严格的别名规则。
他们在说什么?
原文由 Benoit 发布,翻译遵循 CC BY-SA 4.0 许可协议
当询问 C 中常见的未定义行为时,人们有时会提到严格的别名规则。
他们在说什么?
原文由 Benoit 发布,翻译遵循 CC BY-SA 4.0 许可协议
这摘自我的 “什么是严格的别名规则以及我们为什么要关心?” 写上去。
在 C 和 C++ 中,别名与允许我们访问存储值的表达式类型有关。在 C 和 C++ 中,标准都指定了允许哪些表达式类型为哪些类型设置别名。允许编译器和优化器假设我们严格遵循别名规则,因此术语 _严格别名规则_。如果我们尝试使用不允许的类型访问值,则将其归类为 未定义行为( UB )。一旦我们有未定义的行为,所有的赌注都被取消了,我们的程序的结果就不再可靠了。
不幸的是,由于严格的别名违规,我们通常会获得我们期望的结果,从而有可能具有新优化的编译器的未来版本会破坏我们认为有效的代码。这是不可取的,了解严格的别名规则以及如何避免违反它们是一个值得的目标。
为了更多地了解我们为什么关心,我们将讨论违反严格别名规则时出现的问题,类型双关,因为类型双关中使用的常用技术经常违反严格的别名规则以及如何正确键入双关。
让我们看一些示例,然后我们可以确切地讨论标准所说的内容,检查一些进一步的示例,然后看看如何避免严格的混叠并捕获我们错过的违规行为。这是一个不应令人惊讶的示例( 现场示例):
int x = 10;
int *ip = &x;
std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";
我们有一个 int* 指向一个 int 占用的内存,这是一个有效的别名。优化器必须假设通过 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?
}
在函数 foo 中,我们接受一个 int* 和一个 float* ,在这个例子中,我们调用 foo 并设置两个参数指向同一个内存位置,在这个例子中包含一个 int 。请注意, reinterpret_cast 告诉编译器将表达式视为具有由其模板参数指定的类型。在这种情况下,我们告诉它将表达式 &x 视为其类型为 float* 。我们可能天真地期望第二个 cout 的结果为 0 ,但使用 -O2 启用优化后,gcc 和 clang 都会产生以下结果:
0
1
这可能不是预期的,但完全有效,因为我们调用了未定义的行为。 浮点数 不能有效地为 int 对象起别名。因此,优化器可以假设在取消引用 i 时存储的 常量 1 将是返回值,因为通过 f 进行的存储不能有效地影响 int 对象。在编译器资源管理器中插入代码表明这正是正在发生的事情( 现场示例):
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 标准在第 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 有一个扩展 并且 允许将 unsigned int* 分配给 int* ,即使它们不是兼容的类型。
— 对应于对象有效类型的限定版本的有符号或无符号类型,
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 version 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 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.
[basic.lval] 第 11 节中的 C++17 标准草案说:
如果程序尝试通过非下列类型之一的泛左值访问对象的存储值,则行为未定义: 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
}
值得注意的是,上面的列表中没有包含 signed char ,这与 C 中的一个显着区别 _是字符类型_。
我们已经到了这一点,我们可能想知道,我们为什么要别名?答案通常是 键入 pun ,通常使用的方法违反严格的别名规则。
有时我们想绕过类型系统并将对象解释为不同的类型。这称为 _类型双关语_,将一段内存重新解释为另一种类型。 类型双关语 对于希望访问对象的底层表示以查看、传输或操作的任务很有用。我们发现使用类型双关语的典型领域是编译器、序列化、网络代码等……
传统上,这是通过获取对象的地址,将其转换为我们想要重新解释它的类型的指针,然后访问该值来完成的,或者换句话说,通过别名。例如:
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++ 中 类型双关 的标准方法是 memcpy 。这可能看起来有点笨拙,但优化器应该认识到 memcpy 用于 类型双关 并优化它并生成一个寄存器来注册移动。例如,如果我们知道 _int64t 的大小与 double 相同:
static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17 does not require a message
我们可以使用 memcpy :
void func1( double d ) {
std::int64_t n;
std::memcpy(&n, &d, sizeof d);
//...
在足够的优化级别上,任何体面的现代编译器都会生成与前面提到的 reinterpret_cast 方法或 union 类型双关语 方法相同的代码。检查生成的代码,我们看到它只使用了 register mov( 实时编译器资源管理器示例)。
在 C++20 中,我们可能会获得 bit_cast ( 实现在来自提案的链接中可用),它为类型双关语提供了一种简单而安全的方法,并且可以在 constexpr 上下文中使用。
以下是如何使用 bit_cast 将 unsigned int 类型双关语转换为 float 的示例,( 现场查看):
std::cout << bit_cast<float>(0x447a0000) << "\n"; //assuming sizeof(float) == sizeof(unsigned int)
在 To 和 From 类型不具有相同大小的情况下,它需要我们使用中间结构 15。我们将使用一个包含 sizeof( unsigned int ) 字符数组( 假设 4 字节 unsigned int )作为 From 类型和 unsigned int 作为 To 类型的结构:
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 中生成警告( 现场查看):
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,它可以捕获未对齐的负载和存储。尽管这些不是直接的严格混叠违规,但它们是严格混叠违规的常见结果。例如,以下情况在使用 -fsanitize=address 使用 clang 构建时会产生运行时错误
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 都将使用 -Wold-style-cast 生成 C 风格转换的诊断。这将强制任何未定义的类型双关语使用 reinterpret_cast,通常 reinterpret_cast 应该是更仔细的代码审查的标志。在代码库中搜索 reinterpret_cast 以执行审计也更容易。
对于 C,我们已经涵盖了所有工具,并且我们还有 tis-interpreter,这是一个静态分析器,可以详尽地分析 C 语言的大部分子集的程序。给定早期示例的 C 版本,其中使用 -fstrict-aliasing 会遗漏 一种情况( 现场查看)
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-kernel 作为 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.
最后是目前正在开发的 TySan 。此清理程序在影子内存段中添加类型检查信息并检查访问以查看它们是否违反别名规则。该工具可能应该能够捕获所有混叠违规,但可能会有很大的运行时开销。
原文由 Shafik Yaghmour 发布,翻译遵循 CC BY-SA 4.0 许可协议
3 回答2k 阅读✓ 已解决
2 回答3.9k 阅读✓ 已解决
2 回答3.2k 阅读✓ 已解决
1 回答3.2k 阅读✓ 已解决
1 回答2.7k 阅读✓ 已解决
3 回答3.4k 阅读
1 回答3.3k 阅读
遇到严格别名问题的典型情况是将结构(如设备/网络消息)覆盖到系统字长的缓冲区(如指向
uint32_t
s 或uint16_t
的指针---
秒)。当您将结构覆盖到这样的缓冲区上时,或者通过指针转换将缓冲区覆盖到这样的结构上时,您很容易违反严格的别名规则。所以在这种设置中,如果我想向某个东西发送消息,我必须有两个不兼容的指针指向同一个内存块。然后我可能会天真地编写如下代码:
严格的别名规则使此设置非法:取消引用一个指针,该指针对不属于 兼容类型 或 C 2011 6.5 第 7 1段允许的其他类型之一的对象进行别名是未定义的行为。不幸的是,你仍然可以用这种方式编码, 可能 会得到一些警告,让它编译得很好,只是在你运行代码时会出现奇怪的意外行为。
(GCC 在给出别名警告的能力上似乎有些不一致,有时给我们一个友好的警告,有时不是。)
要了解为什么这种行为是未定义的,我们必须考虑严格的别名规则购买编译器的原因。基本上,有了这个规则,它就不必考虑插入指令来刷新
buff
每次循环运行的内容。相反,在优化时,通过一些关于别名的令人讨厌的非强制假设,它可以省略这些指令,在循环运行之前将buff[0]
和buff[1]
加载到 CPU 寄存器中,并加速主体的循环。在引入严格别名之前,编译器不得不处于一种妄想状态,即buff
的内容可能会被任何先前的内存存储所改变。因此,为了获得额外的性能优势,并假设大多数人不键入双关指针,引入了严格的别名规则。请记住,如果您认为该示例是人为的,那么如果您将缓冲区传递给另一个为您发送的函数(如果您有的话),甚至可能会发生这种情况。
并重写了我们之前的循环以利用这个方便的功能
编译器可能会或可能不会或足够聪明地尝试内联 SendMessage,它可能会或可能不会决定再次加载或不加载 buff。如果
SendMessage
是另一个单独编译的 API 的一部分,它可能有加载 buff 内容的指令。再说一次,也许你在 C++ 中,这是编译器认为它可以内联的一些模板化头实现。或者,这只是您在 .c 文件中为您自己的方便而编写的内容。无论如何,可能仍会出现未定义的行为。即使我们知道幕后发生的一些事情,它仍然违反规则,因此不能保证明确定义的行为。因此,仅仅通过包装一个接受我们的单词分隔缓冲区的函数并不一定有帮助。那么我该如何解决呢?
您可以在编译器中禁用严格别名( gcc 中的 f[no-]strict-aliasing ))
您可以使用
char*
来代替系统的别名。规则允许char*
例外(包括signed char
和unsigned char
)。一直假定char*
是其他类型的别名。但是,这不会以另一种方式起作用:没有假设您的结构别名为字符缓冲区。初学者小心
当两种类型相互叠加时,这只是一个潜在的雷区。您还应该了解 字节序、 字对齐 以及如何通过正确 打包结构 来处理对齐问题。
脚注
1 C 2011 6.5 7 允许左值访问的类型有: