C 中的 Union 改变了 Float Addition 的机器行为

Union in C changing machine behavior of Float Addition

C 编程的新手,有人告诉我要避免联合,这通常非常合理,我同意。但是,作为学术练习的一部分,我正在通过对无符号 32 位整数进行位操作来编写硬件单精度浮点加法模拟器。我提到这个只是为了解释 为什么 我想使用联合;我在模拟方面没有遇到任何问题。

为了测试这个模拟器,我写了一个测试程序。但当然,我正试图在我的硬件上找到浮点数的位表示,所以我认为这可能是联合的完美用途。我写了这个联盟:

typedef union {
  float floatRep;
  uint32_t unsignedIntRep;
} FloatExaminer;

这样,我可以用 floatRep 成员初始化一个浮点数,然后检查 unsignedIntRep 成员的位。

这在大部分时间都有效,但是当我进行 NaN 添加时,我开始 运行 陷入困境。确切的情况是我写了一个函数来自动化这些测试。它的要点是:

void addTest(float op1, float op2){
  FloatExaminer result;
  result.floatRep = op1 + op2;

  printf("%f + %f = %f\n", op1, op2, result.floatRep);
  //print bit pattern as well
  printf("Bit pattern of result: %08x", result.unsignedIntRep);
}

好的,现在是令人困惑的部分:

我添加了一个 NAN 和一个具有不同尾数位模式的 NAN 来区分两者。在我的特定硬件上,它应该 return 第二个 NAN 操作数(如果它发出信号则使其安静)。 (我将在下面解释我是如何知道这一点的。)但是,每次传递位模式 op1=0x7fc00001, op2=0x7fc00002 都会 return op1,0x7fc00001

我知道它应该是 return 第二个操作数,因为我试过——在函数之外——初始化为整数并转换为浮点数,如下所示:

uint32_t intRep1 = 0x7fc00001;
uint32_t intRep2 = 0x7fc00002;
float *op1 = (float *) &intRep1;
float *op2 = (float *) &intRep2;
float result = *op1 + *op2;
uint32_t *intResult = (uint32_t *)&result;
printf("%08x", *intResult); //bit pattern 0x7fc00002

最后,我得出结论,工会是邪恶的,我永远不应该使用它们。但是,有谁知道我为什么会得到这样的结果?我是否犯了愚蠢的错误或假设? (我知道硬件架构各不相同,但这看起来很奇怪。)

我假设当您说“我的特定硬件”时,您指的是使用 SSE 浮点的 Intel 处理器。但实际上,根据 Intel® 64 和 IA-32 架构,该架构有不同的规则 软件开发人员手册。这是该文档第 1 卷中 Table 4.7(“处理 NaN 的规则”)的摘要,其中描述了算术运算中 NaN 的处理:(QNaN 是一个安静的 NaN;SNaN 是一个信号 NaN;我'我们仅包含有关 two-operand 说明的信息)

  • SNaN 和 QNaN
    • x87 FPU — QNaN 源操作数。
    • SSE — 第一个源操作数,转换为 QNaN。
  • 两个 SNaN
    • x87 FPU — 具有较大有效数的 SNaN 源操作数,转换为 QNaN
    • SSE — 第一个源操作数,转换为 QNaN。
  • 两个 QNaN
    • x87 FPU — 具有较大有效数的 QNaN 源操作数
    • SSE — 第一个源操作数
  • NaN 和一个 floating-point 值
    • x87/SSE — NaN 源操作数,转换为 QNaN。

SSE 浮点机器指令通常具有 op xmm1, xmm2/m32 的形式,其中第一个操作数是目标寄存器,第二个操作数是寄存器或内存位置。该指令实际上将执行 xmm1 <- xmm1 (op) xmm2/m32,因此第一个操作数既是操作的 left-hand 操作数又是目标。这就是上图中“第一个操作数”的含义。 AVX 添加 three-operand 指令,其中目标可能是不同的寄存器;它是第三个操作数,并没有出现在上表中。 x87 FPU 使用 stack-based 架构,其中堆栈顶部始终是操作数之一,结果替换堆栈顶部或另一个操作数;在上面的图表中,会注意到规则并不试图决定哪个操作数是“第一个”,而是依靠简单的比较。

现在,假设我们正在为 SSE 机器生成代码,我们必须处理 C 语句:

a = b + c;

其中 none 这些变量在寄存器中。这意味着我们可能会发出这样的代码:(我在这里没有使用真正的指令,但原理是一样的)

LOAD  r1, b  (r1 <- b)
ADD   r1, c  (r1 <- r1 + c)
STORE r1, a  (a  <- r1)

但我们也可以这样做,结果(几乎)相同:

LOAD  r1, c  (r1 <- c)
ADD   r1, b  (r1 <- r1 + b)
STORE r1, a  (a  <- r1)

这将具有完全相同的效果,除了涉及 NaN 的添加(并且仅在使用 SSE 时)。由于 C 标准未指定涉及 NaN 的算术,因此编译器没有理由关心它选择这两个选项中的哪一个。特别是,如果 r1 中恰好已经有值 c,编译器可能会选择第二个选项,因为它节省了加载指令。 (谁会抱怨?我们都希望编译器生成尽可能快地运行的代码,不是吗?)

因此,简而言之,ADD 指令的操作数顺序将随着编译器选择优化代码的方式的复杂细节以及当前寄存器的特定状态而变化正在发出加法运算符。这可能会受到联合的使用的影响,但它同样或更可能与以下事实有关:在使用联合的代码中,添加的值是 的参数函数,因此已经放在寄存器中。

确实,不同版本的 gcc 和不同的优化设置会为您的代码产生不同的结果。强制编译器发出 x87 FPU 指令会产生不同的结果,因为硬件根据不同的逻辑运行。


注:

如果您想睡前阅读,可以从 their site.

下载整个英特尔 SDM(目前 4,684 页/23.3MB,但它会越来越大)