抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

今日、海を見た。もう怖くない

作为“底层”语言的 C++ 有着很多十分炫酷的很“底层”的操作,其中比较常见的就有 reinterpret_cast;根据它在 C++ Reference 上的描述,它纯粹是编译时指令,指示编译器将表达式 视为如同具有目标类型 类型一样处理。通过这种操作可以绕过 C++ 的类型系统,随意解释一块内存上的数据,实在是太酷啦!

那么古尔丹,代价是什么呢?—— 即使是面对这样的质问,很多人想必也会不以为意:不就是保证内存安全吗?我都操作指针了,这种事情对我来说不是小菜一碟?然而很不幸的是,很多事情的运作并没有这么简单;请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
int foo(float *f, int *i) { 
*i = 1;
*f = 0.f;
return *i;
}

int main() {
int x = 0;
x = foo((float*)(&x), &x);
std::cout << x << "\n"; // What will be printed?
}

函数 foo 十分简单,想必学过 C++ 的人一眼就可以得出答案 —— 这不就是输出 0 吗?然而在某些编译器,比如某个版本的 clang 下,如果开启了 -O3 优化,它的输出竟然是 1…… 只能说确实有点急。

但是如果只是因为这个就心急火燎,直接判定这个 clang 出 bug 了倒也大可不必。实际上从 C++ 的标准来看,在某些特定的场合,它还真的应该是 1;只是要解释这背后的原因,就要涉及到一个比较隐蔽的概念,也就是所谓的严格别名规则了。

前置概念

在了解严格别名规则之前,你可能还会需要了解以下冷门概念。

兼容类型

兼容类型是 C 特有的概念。作为比 C++ 还要底层的语言,C 完全遵循“古法”编译,有着纯粹的结构体,代码本身更能直接反馈底层的结构…… 这使得它的 ABI 比较简单。

C 的兼容类型解决了不同的翻译单元以及一些其他场合中,对象类型的同一性的认定问题。在不同翻译单元关于同一个对象的不同定义、函数形参实参的绑定、通过左值访问对象的过程中,实际上并不严格要求两者类型的严格一致 —— 它们只需要具有兼容的类型,那么就是合法的。

对于两个类型是否兼容的判断,可以遵循以下的要求进行:

  • 两者本质是同一类型时,两者兼容(下略)
  • 两者是使用相同的 cvr 限定的兼容类型
  • 两者是指向兼容类型的指针
  • 两者是包含兼容类型的数组,并且存在相同的常量大小
  • 两者都是结构体/枚举/联合体,并且
    • 都是完整类型,且成员必须在数量上准确对应、各自以兼容类型声明、各自拥有匹配的名称
    • 若都是枚举,还要求对应成员亦必须拥有相同值
    • 若都是结构体/联合体,还要求对应成员声明顺序一致,且对应位域宽度相同
  • 两者中的一方是枚举,而另一方是枚举的底层类型
  • 两者都是函数类型,并且满足
    • 返回值的类型是兼容的
    • 参数列表在执行函数/数组到对应指针的退化之后,数量和类型的相同
  • 特别地,char 既不与 signed char 兼容,也不与 unsigned char 兼容

这个方面,C++ 的要求会严格许多 —— C++ 要求类型必须完全一致,即使两个类型“翻译到底层后布局一致”,如果不是同一个类型,那么也是非法的。此外,C++ 有相似类型的概念,但是和这里的作用相去甚远。

有效类型

有效类型是 C 特有的概念。每个 C 对象都有一个“有效类型”,这个类型是更本质的,和指向该对象所在内存的指针类型未必相同。通过它可以决定通过某些左值对特定对象的访问是否合法,从而有了严格别名使用规则。

判断对象的有效类型十分简单;如果对象是由声明创建的,那么声明的类型就自然成为了对象的有效类型;否则对象是由内存分配函数“创建”的,那么它并不会立即获得有效类型,直到首次通过非字符的左值写入或者使用内存复制函数写入时,它获得这些复制的源的类型作为其有效类型。

由此可见,即使在 C 这么“原始”的底层语言中,内存和对象之间也有着明确的界限。

动态类型

动态类型是 C++ 的概念。它类似 C 的有效类型的概念,但是它出现在一门可以是 OOP 的语言中就显得更加有说服力一些。它的完整定义如下:

如果某个泛左值表达式指代某个多态对象,那么它的最终派生对象的类型被称为它的动态类型

一个很常见的例子:将一个派生类对象的地址赋值给一个指向基类对象的指针,那么该指针的动态类型是派生类,而静态类型是基类。这是作为一个实现了运行时多态的编程语言必须具备的基础设施。除了这种场合之外,将动态类型类比 C 的有效类型也未尝不可 —— 毕竟 C++ 明确要求一块内存必须先被初始化后才能够合法使用,一块没有类型的内存块在初始化中获得了动态类型也不是什么情理之外的事情。

restrict 关键字

restrict 关键字是 C 独有的。它只能够用来限定 C 的对象类型的指针或者数组,用来标识在当前作用域内,该指针是访问它所指向的内存块的唯一合法方法 —— 即不应该存在其他指向该指针所指的内存块的指针。然而语言层面上 restrict 并没有严格的约束,因此需要程序员来保证它的运用合乎语义。

restrict 的意义是,编译器可以利用这个信息进行特定的优化;然而编译器也可以选择无视这个信息,而一个合乎语义的程序不会因此产生表现上的差异;但是如果程序员没有保障语义的正确性,那么程序可能 UB。

此外,虽然 C++ 标准不再包含 restrict 关键字,但是作为常用工具,大多数的编译器都以 __restrict 或者是 __restrict__ 的形式进行了拓展;然而使用它们会使得你的代码和编译器强相关,请自行斟酌。

严格别名规则

严格别名规则限制了可以用作实质是某类型的对象的别名的类型 —— 简单的说就是规定了合法的类型双关。在介绍严格别名规则之前还需要介绍什么是别名,什么是类型双关。

别名

当通过多个左值访问同一个对象的时候,那么这些左值就是这个对象的别名。在 C/C++ 的场合,多个指向同一块内存的指针就满足了这种情况,这些指针就构成了这块内存中存放的对象的别名。

类型双关

绕过 C/C++ 的类型系统,将一个类型的对象(所占用的内存块)重新解释为另一个不同的类型,就产生了所谓的类型双关(Type Punning)—— 从定义上来看,这简直就是 reinterpret_cast 在做的事情;实际上,获得一个对象的指针,然后使用 C 风格的强制转换或者 reinterpret_cast 转换为另一个类型后再访问,就是产生类型双关最常见的方法。

虽然这看起来十分丑陋,但是作为一门几乎直接在和底层打交道的编程语言,在很多需要直接访问或操作对象的底层表示的场合,类型双关十分有用;就比如序列化、网络传输、驱动开发中,会大量使用到这种特性。

C 的规则

若要通过类型为 T2 的左值表达式访问具有有效类型的 T1 的对象(即将 T2 类型的左值表达式看作是 T1 类型的对象的别名,这显然可能产生类型双关),则必须满足以下条件之一

  1. T2T1 兼容

  2. T2 是和 T1 兼容的类型的 cvr 限定版本,或者是有符号/无符号版本

  3. T2 是聚合体/联合体,并且包含一个和 T1 满足上述要求的成员

  4. T2 是字符类型;即 charsigned charunsigned char 中的一个

否则,程序 UB。

可以注意到条件 3 的存在允许了通过联合体的非活跃成员(和活跃成员之间的类型差异)进行类型双关;而且即使在类型双关可以发生的场合, C 独有 restrict 限定符也可用于显式指示指针指向的对象不包含其他别名,从而允许编译器进行大胆的优化。

C++ 的规则

如果要通过类型为 TAlias 的(泛左值)别名读取/修改动态类型为 TDynamic 的对象的值的时候(即 TAlias 作为 TDynamic 对象的别名而可能产生的类型双关),需要满足以下条件之一

  1. 类型 TAliasTDynamic 是相似的
  2. 类型 TAliasTDynamic 的有 cv 的、有符号/无符号的变体
  3. 类型 TAliasstd::bytechar 或者 unsigend char —— 按字节处理对象占用的内存总是合法的

否则,程序 UB。

需要注意到,在 C 中被视为合法的通过 union 进行的类型双关在 C++ 中是非法的 —— 即使绝大多数的编译器都会默许这种行为并且生成正确的代码;这可能是由于 C++ 的对象比 C 的对象(结构体等聚合体)的内存布局要更加复杂导致的。

动机

通俗地总结 C/C++ 的严格别名规则,那就是任何类型的指针指向的区域,都不能与类型“无关”的指针指向的区域产生重叠;但是字符是这个规则允许的“例外” —— 似乎是底层语言最后的矜持?不过确实也并不难理解,绝大多数的硬件都是按照字节寻址,此时字节就是最小的单位,不会因为硬件差异而有所差别。

restrict 类似,严格别名存在允许编译器进行基于类型的别名分析(Type-Based Alias Analyze,TBAA):即编译器基于“通过某些(上述严格别名规则之外的情况)类型的泛左值读取的值,不会被通过不同类型的泛左值的写入所修改”的假设,得以进行一些优化。通常情况下编译器优化的依据很多,但是在复杂的代码中,编译器无法根据上下文判断指针指向的内存区域的重叠情况的时候,这就能作为依据使得编译器进行优化。

一些例子

尽管这样看来,严格别名规则十分自洽,似乎也没什么问题;但是在需要用到违反它的类型双关的场合,它就可能使得编译器产生意料之外的代码…… 这里给出了一些例子。

明知故犯

Compiler Explorer:https://godbolt.org/z/1Mvhx1Kz7

代码:

1
2
3
4
5
6
7
8
9
10
11
int foo(float *f, int *i) { 
*i = 1;
*f = 0.f;
return *i;
}

int main() {
int x = 0;
x = foo((float*)(&x), &x);
std::cout << x << "\n"; // Expect 0, But Output 1
}

这便是文章开头的那个例子。在了解了严格别名规则之后,解释它就并不困难了:foo 函数接受的两个指针显然不符合严格别名规则 —— 因此它们不应该指向重叠的内存,也就是说这两个指针“理应”不指向同一片内存。基于这个假设,编译器对 foo 函数的返回语句做了死代码优化:既然返回 *i 前,它指向的内存不会被修改,那么比起根据指针 i 访存,不如直接将用于赋值的值作为立即数返回;反汇编的结果也支持这种观点 ——

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

可以看到这个函数体就是错误的 —— 就像我们的分析一样直接返回了立即数 1。不过实际上这种情况已经不太会出现在 2024 年的编译器上了毕竟还是得服务实际应用的,但是仍然需要意识到这是个会引发 UB 的写法。

gcc 魅力时刻

Compiler Explorer:https://godbolt.org/z/gjh5hB

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Store128(void *dst, const void *src) {
const uint8_t *s = (const uint8_t*)src;
uint8_t *d = (uint8_t*)dst;
for (int i = 0; i < 16; i += 4)
*(uint32_t*)(d + i) = *(const uint32_t*)(s + i);
}

int main(void) {
float A[4] = {1, 2, 3, 4};
float B[4] = {5, 6, 7, 8};
Store128(B, A);
for (int i = 0; i < 4; i++)
printf("%f\n", B[i]); // Expected 1.000 2.000 3.000 4.000, but ??
return 0;
}

某位大牛在他的这篇回答中就这个问题对 gcc 团队直球辱骂,但是在详细分析了这个案例之后只能说好骂。

在这段代码中,我们尝试在函数 Store128 中使用类型为 uint32_t 的别名修改有效类型为 float 的内存,这显然违背了严格别名规则 —— 所以 uint32_t 指针和 float 数组首指针不应该指向同一片内存…… 到这里还没什么问题,但某个并不算老的(9.x)gcc 在经过一番优化之后,产生的代码的输出却是和 AB 都毫无关联的 0 -0 0 0…… 怎么会这样?

查看产生的反汇编之后,更离谱的事情发生了 —— 编译器甚至没有为局部变量数组 A/B 生成初始化代码,也难怪结果和 AB 都毫无关联。但是这到底是为什么呢?就算是死代码消除,可是明明后面就有 printfB 的使用啊?只能说大概 gcc 确实是出 bug 了 —— 它的好兄弟 clang 和 msvc 都不会有这个问题。

实际上,如果解决了严格别名规则的问题,也就是使用字符类型 uint8_t 来进行这个操作,就一切正常了;但是毕竟是编译器 bug,只能说希望一辈子都不要遇到== 而且逐字符的赋值,产生的汇编真的就是直接的 mov BYTE PTR…… 但如果使用 int 理应能产生数量更少的 mov DWORD PTR 啊!

所以就这个案例来说,只能说大牛好骂 —— 为了一个根本优化不到 1% 的性能的语言特性,不做任何警告,静默进行这么大的优化,甚至还实现出 bug 了;只能说一般开发者根本无力排查。

性能问题

Compiler Explorer:https://godbolt.org/z/36oKz85od

代码:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <iostream>
#include <iomanip>
#include <stdint.h>

struct T{
uint8_t* target;
char* source;
void unpack3bit( int size);
};

void T::unpack3bit(int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
// uint8_t* target = this->target; // << ptr cached in local variable
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
target+=16;
}
}

void unpack3bit(uint8_t* target, char* source, int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
target+=16;
}
}

但是,TBAA 确实有时有奇效。上述的代码并没有产生什么意料之外的输出 —— 两个版本的 unpack3bit 函数,一个版本是被声明为 T 的成员函数 —— 它的两个参数被作为数据成员储存在 T 中,另一个版本是简单函数;两种写法都能得到理想的结果,也似乎没有什么差别 —— 但是编译之后非成员函数版本比成员函数版本快了足足 15%,这又是怎么回事呢?

查看它们生成的代码的反汇编之后就找到了答案:成员函数版本每次操作都会额外生成一条多余的指令

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
26
27
T::unpack3bit(int):
# ...
mov rdx, rax
mov rcx, QWORD PTR [rdi] # 无用的指令:加载指针 target 的值(数组首地址)
shr rdx, 3
and edx, 7
mov BYTE PTR [rcx+1], dl

mov rdx, rax
mov rcx, QWORD PTR [rdi] # 无用的指令:同上,重新加载理应不会改变的数组首地址
shr rdx, 6
and edx, 7
mov BYTE PTR [rcx+2], dl
# ...

unpack3bit(unsigned char*, char*, int):
# ...
mov rdx, rax
shr rdx, 3
and edx, 7
mov BYTE PTR [rdi+1], dl

mov rdx, rax
shr rdx, 6
and edx, 7
mov BYTE PTR [rdi+2], dl
# ...

为什么在成员函数版本中,编译器就不能判断 target 是不变的呢?虽然很隐晦,但是这实际上也和严格别名规则相关:target 对于 T 而言都是数据成员 —— 也就是说它实际上是 this->target,它的值依赖 this 指针指向的对象;而它的类型是 unsigned char,这满足了严格别名规则,使得它可以成为任何指针的别名候选,这些候选中自然也包括了 this;因此,编译器认为它们可能指向同一片内存,而指针 this->target 的地址就储存在 this 指针指向的内存区域中,这意味着通过 this->target 的修改可能修改它自身!

实际上在极端如 this->target = this 的场合,通过 target 修改数据,还真的就修改了 this 的数据块,从而修改它自身。因此,在 target 可能被改变的前提下,为了保证在极端情况下的正确性,编译器必须在每次赋值前都会生成重新加载 taeget 的代码,才符合程序的语义。

知道问题所在之后,解决就很容易了;主要有三个思路:

  • 在函数体内用局部变量缓存一次 target(像代码中注释的部分一样) —— 这其实已经改变了语义,它使得每次赋值的指针 target 只是调用函数是 this->target 的值;这自然是不会改变的,无需重新加载

  • 修改指针的类型为 uint16_t 或者其他数值类型 —— 这样就使得它破坏了严格别名规则,编译器会认为它和 this 不再会指向同一块内存,从而通过 target 的修改不再会影响 this->target,尽管这对语义变动更大

  • 使用 restrict 显式地限定 this —— 这样相当于指示了编译器:它的成员只能通过它修改,而不会被什么其他的指针修改,比如 this->target;但是这么做的前提是你的编译器为 C++ 拓展了这个关键字

所以,虽然有性能影响,但是还是得骂 C++ 标准委员会 —— 这三种方法,甚至只有看似意义不明的方法一,在最大限度的保留了语义的前提下实现了优化;方法二三不是大动干戈,就是编译器依赖,只能说放那么多 policyF N M D P()

分析和教训

在见识了一众基于严格别名规则的例子之后,刚才还眉清目秀,有点道理的严格别名规则突然就变的面目可憎了起来,不由让人感叹:你妈的,为什么?可能在此基础上还有一句长叹:它都这样了,我只能选择顺从…… 那么怎么顺从呢?

以下内容均为作者暴论,仅供参考。

吐槽

为什么严格别名规则这么愚蠢?我个人认为可以归纳为以下四点:

  1. 优化效果并不好 —— 尽管有上面那种极端案例的存在,但是这些都不是一个单纯的 TBAA 能救的。至少从现在的编译器来看,一个对在他的领域内的 UB 了如指掌的程序员,能对代码进行的优化还是远远超出编译器能做的范畴的。Linux 内核都会禁用这个优化呢!
  2. 语言的约束使得 UB ≠ 硬件行为 —— 大多数需要用到这种奇技淫巧的程序员,都对自己的代码以及将要运行它的硬件心中有数。而这个标准的引入,使得编译器也可以在 UB 的表现中横插一脚,严重增加了程序员的心智负担和代码的维护成本。你赞同 C/C++ 的 UB 就应该 fallback 到硬件的表现吗?
  3. 编译器实现的脱钩 —— 即使 C++ 标准三令五申上述第一个例子,产生 1 是合理的,在今天你也很难找到真的能老实产生类似结果的编译器了;实际上编译器的优化是依据多个要素的,也是为实践服务的,几乎不太会对这种问题生成 UB 的代码;更何况还有上述 gcc 魅力时刻这种纯反面教材……
  4. 和之前已有的语义冲突 —— 就像本文开头的引子那样,reinterpret_cast 到底还是不是“重解释”了?结合它必须要遵循严格别名规则才是符合语义的限制,我们惊喜的发现它原来就是个小丑!你大可以继续在你的代码里继续使用它,但是它比 C 风格强制类型转换的好处就是出了 bug 方便你全局搜索,然后再一处一处地分析思考:它是不是违背内啥啥规则了?编译器是不是在这里会产生抽象代码?

简而言之,就现在的应用而言,C/C++ 仍然是更多地作为和底层打交道的语言被使用;它的很多用户比起它的语义的现代性和编译器那甚至可以说是微不足道的优化而言,更在意它是否能直观地,准确地,如实地反应它将要被编译成的汇编指令;但是委员会似乎不这么想…… 这某种层面上也算是学术界和生产界的认知鸿沟?

类型双关的正确姿势

那么我们要怎么进行类型双关呢?可能有如下思路:

  • 禁用 TBAA 优化:在编译时使用 fno-strict-aliasing 选项,然后继续 reinterpret_cast —— 眼不见为净,反正蚊子腿的性能优化,不要也罢!
  • 使用 memcpy:分配一块新的内存,然后使用 memcpy 复制一份 —— 语义上是正确的,两种不兼容的别名分别指向了互不重叠的原件和副本,但是创建副本就是两倍的内存开销!
  • 使用 C 的 union:C++ 标准不是说这非法吗?那就 extern "C"!再不济 —— 相信的心就是你的魔法!你要相信现在的编译器都十分人性化,不会因为这种微不足道的错误就发癫的!
  • 使用 C++ 20 的 bit_cast:然而只能进行编译期就能确定大小相同的两个类型的转换 —— 本质上是 memcpy 的语法糖 + 编译期的拓展,但是运行时会产生副本还是一样的,更何况这糖也不甜啊!

总上说述,只能说千言万语汇成一个字 —— 摆;不然还能怎么办呢?有没有董哥教教==

后记

喔喔,原来是这样子,嗯,我现在完全搞懂了。

参考资料

评论