LLVM、联合、指针转换和未定义行为

LLVM, unions, pointer casts and undefined behavior

Clang 似乎将联合转换为最严格对齐的成员类型,然后免费使用指针转换,例如

union U {
  double x;
  int y;
};

int f(union U *u) { return u->y; }

编译为

%union.U = type { double }

; Function Attrs: nounwind uwtable
define i32 @f(%union.U* %u) #0 {
  %1 = alloca %union.U*, align 8
  store %union.U* %u, %union.U** %1, align 8
  %2 = load %union.U*, %union.U** %1, align 8
  %3 = bitcast %union.U* %2 to i32*
  %4 = load i32, i32* %3, align 8
  ret i32 %4
}

我很惊讶,因为将指针转换为不同的类型然后取消引用它通常是未定义的行为。当然,LLVM IR 没有义务遵循与 C 相同的 UB 规则,但在大多数情况下它确实如此 - 这就是 Clang 遵循 C UB 规则的方式,它只是将代码相当直接地转录到 IR 并让后端处理它。

所以 how/why,确切地说,这是处理工会的有效方式吗?

补充说明一下:上面的IR和下面的C:

生成的IR基本一样
struct U {
  double x;
};

int f(struct U *u) { return *(int*)u; }

唯一的区别是最后的 align 8 变成了 align 4。我希望第二个 C 代码片段是 UB,但第一个不是,因此第二个不能是。那为什么第二个C代码片段不是UB呢?

第一个例子已定义。如果读取了一个不是最后写入的成员,则该成员表示的字节将在新类型中重新解释。该类型可能是陷阱表示,以防万一您会遇到未定义的行为,但在现代机器上不太可能。

第二个示例是由于别名规则导致的未定义行为。 union 由 int 类型访问,与 struct U 或 double 类型不兼容。

正确的代码是未定义行为的可能结果之一。

第二个例子是未定义的行为。在一些现实世界的架构上,doubleint 有更严格的对齐要求。甚至可以想象一些深奥的架构,其中整数和浮点变量存储在不同的内存区域,以便 运行 在单独的 ALU 和 FPU 上更有效。反过来做,当 intdouble 不属于同一 union 时,将 int 的地址转换为 double* 并取消引用它=],例如,在 32 位 Sparc Solaris 上可能会导致程序崩溃并出现 SIGBUS 错误。

即使在未正确对齐的指针上进行转换也是 UB(因为仅将无效指针加载到寄存器中可能会导致某些系统上的程序崩溃,例如遗留 x86 保护模式中的无效段选择器)。参见第 J.2 节和第 6.3.2.3 节。请注意,您注意到的一个变化是将对齐限制从 8 字节放宽到 4 字节,允许指针的低位为 100 而不是 000,并强制转换指针以 100 结尾到必须以 000 结尾的指针类型已经是未定义的行为。 (迂腐地说,唯一的例外是将空指针转换为任何其他指针类型始终是安全的,并为您提供新类型的空指针。)

未定义的行为意味着允许编译器做任何事情,包括照字面意思做和按意思做。由于您在第二个示例中明确投射了指针,Clang 可能会让您搬起石头砸自己的脚。

你的第一个例子,有两个工会成员呢?您一定会获得有效 int 对象的地址。根据 C11 Draft standard(§6.2.5.28),“所有指向联合类型的指针都应具有相同的表示和对齐要求。指向其他类型的指针不需要具有相同的表示或对齐要求。”第 40 页的脚注 41 明确指出,“相同的表示和对齐要求意味着可以互换作为函数的参数、return 函数的值和联合的成员。”在 §6.7.2.1.16 中,“一个指向联合对象的指针,经过适当的转换,指向它的每个成员 [...],反之亦然。”

作为恒等函数实现合适的转换当然有效!编译器可以以对该体系结构有意义的任何方式表示指针,并且标准保证指针的表示对两个对象都有效。

就是说,如果它读取联合的非活动成员,则该值未指定。如果您设置 u.y 并读取 u.x,在 int 小于 64 位宽的目标上,u.x 的对象表示的剩余位可能是任何内容,包括陷阱表示。或者,如果您设置 u.x 并读取 u.y,该值将取决于 intdouble 的表示方式的详细信息。