如果只需要结果的低位部分,哪些 2 的补码整数运算可以在不将输入中的高位归零的情况下使用?

Posted

技术标签:

【中文标题】如果只需要结果的低位部分,哪些 2 的补码整数运算可以在不将输入中的高位归零的情况下使用?【英文标题】:Which 2's complement integer operations can be used without zeroing high bits in the inputs, if only the low part of the result is wanted? 【发布时间】:2016-03-26 11:12:14 【问题描述】:

在汇编编程中,想要从不保证其他位为零的寄存器的低位计算某些东西是相当普遍的。在像 C 这样的高级语言中,您只需将输入转换为小尺寸,然后让编译器决定是否需要分别将每个输入的高位归零,或者是否可以在事实。

这对于 x86-64(又名 AMD64)来说尤其常见,原因有很多1,其中一些存在于其他 ISA 中。

我将使用 64 位 x86 作为示例,但目的是询问/讨论 2's complement 和一般的无符号二进制算术,因为 all modern CPUs use it。 (请注意,C 和 C++ 不保证二进制补码4,并且有符号溢出是未定义的行为。)

例如,考虑一个可以编译为LEA 指令的简单函数2。 (在 x86-64 SysV(Linux) ABI3 中,前两个函数 args 在 rdirsi 中,在 rax 中返回。int 是32位类型。)

; int intfunc(int a, int b)  return a + b*4 + 3; 
intfunc:
    lea  eax,  [edi + esi*4 + 3]  ; the obvious choice, but gcc can do better
    ret

gcc 知道加法,即使是负符号整数,也只能从右到左进行,因此输入的高位不会影响 eax 的内容。因此,it saves an instruction byte and useslea eax, [rdi + rsi*4 + 3]

还有哪些运算具有结果的低位不依赖于输入的高位的这种特性?

为什么它会起作用?



脚注

1 为什么x86-64 经常出现这种情况: x86-64 具有可变长度指令,其中一个额外的前缀字节会更改操作数大小(从 32 到 64 或 16),因此通常可以在以相同速度执行的指令中保存一个字节。在写入寄存器的低 8b 或 16b 时(或稍后读取完整寄存器(Intel pre-IvB)时停止),它也有错误依赖(AMD/P4/Silvermont):由于历史原因,only writes to 32b sub-registers zero the rest of the 64b register。几乎所有算术和逻辑都可以在通用寄存器的低 8、16 或 32 位以及完整的 64 位上使用。整数向量指令也是相当非正交的,有些操作不适用于某些元素大小。

此外,与 x86-32 不同的是,ABI 在寄存器中传递函数 args,对于窄类型,高位不需要为零。

2 LEA: 与其他指令一样,LEA 的默认操作数大小为 32 位,但默认地址大小为 64 位。操作数大小的前缀字节(0x66REX.W)可以使输出操作数大小为 16 位或 64 位。地址大小前缀字节 (0x67) 可以将地址大小减少到 32 位(在 64 位模式下)或 16 位(在 32 位模式下)。所以在 64 位模式下,lea eax, [edx+esi]lea eax, [rdx+rsi] 多占用一个字节。

可以做到lea rax, [edx+esi],但地址仍然只能用 32 位计算(进位不会设置rax 的第 32 位)。使用lea eax, [rdx+rsi] 可以获得相同的结果,它短了两个字节。因此,地址大小前缀对于LEA 永远不会有用,因为来自 Agner Fog 出色的 objconv 反汇编程序的反汇编输出中的 cmets 会发出警告。

3 x86 ABI: 调用者不必必须将用于按值传递或返回较小类型的 64 位寄存器的上半部分归零(或符号扩展)。想要将返回值用作数组索引的调用者必须对其进行符号扩展(使用movzx rax, eax,或特殊情况下的eax指令cdqe。(不要与cdq混淆,哪个符号将eax 扩展为edx:eax,例如设置idiv。))

这意味着返回unsigned int 的函数可以在rax 中以64 位临时计算其返回值,并且不需要mov eax, eax to zero the upper bits 或rax。这种设计决策在大多数情况下都适用:调用者通常不需要任何额外的指令来忽略rax 上半部分的未定义位。


4 C 和 C++

C 和 C++ 特别需要二进制补码二进制有符号整数(C++ std::atomic types 除外)。 One's complement and sign/magnitude are also allowed,因此对于完全可移植的 C,这些技巧仅对 unsigned 类型有用。显然,对于有符号运算,符号/幅度表示中的一组符号位意味着例如减去其他位,而不是相加。我还没有完成补码的逻辑

然而,bit-hacks only work with two's complement 是 widespread,因为实际上没有人关心其他任何事情。许多与二进制补码一起工作的东西也应该与一个补码一起工作,因为符号位仍然不会改变其他位的解释:它只有一个值 -(2N-1) (而不是 2N)。符号/大小表示没有这个属性:每个位的位值是正数还是负数取决于符号位。

还请注意,允许 C 编译器假设签名溢出永远不会发生,因为它是未定义的行为。所以例如compilers can and do assume (x+1) < x is always false。这使得在 C 中检测有符号溢出相当不方便。Note that the difference between unsigned wraparound (carry) and signed overflow。

【问题讨论】:

【参考方案1】:

可用于高位垃圾的宽操作:

按位逻辑 左移(包括[reg1 + reg2*scale + disp]中的*scale) 加法/减法(因此LEA 指令:永远不需要地址大小前缀。如果需要,只需使用所需的操作数大小截断。)

乘法的低半部分。例如16b x 16b -> 16b 可以用 32b x 32b -> 32b 完成。您 can avoid LCP stalls (and partial-register problems) from imul r16, r/m16, imm16 使用 32 位 imul r32, r/m32, imm32,然后只读取结果的低 16 位。 (不过,如果使用 m32 版本,请注意更宽的内存引用。)

正如英特尔的 insn 参考手册所指出的,imul 的 2 和 3 操作数形式可安全用于无符号整数。输入的符号位不会影响N x N -> N 位乘法中结果的 N 位。)

2x(即 shift by x):至少在 x86 上有效,其中移位计数被屏蔽,而不是饱和,下降到操作,ecx 中的高垃圾,甚至cl 的高位,都不会影响移位计数。也适用于 BMI2 无标志移位(shlx 等),但不适用于向量移位(pslld xmm, xmm/m128 等,使计数饱和)。 Smart compilers optimize away masking of the shift count, allowing for a safe idiom for rotates in C (no undefined behaviour)。

显然,进位/溢出/符号/零等标志都会受到更广泛操作的高位垃圾的影响。 x86 的移位将最后一位移出的位放入进位标志,因此这甚至会影响移位。

不能与高位垃圾一起使用的操作:

右移

全乘:例如对于 16b x 16b -> 32b,在执行 32b x 32b -> 32b imul 之前,确保输入的前 16 个输入为零或符号扩展。或者使用 16 位单操作数 mulimul 不方便地将结果放入 dx:ax。 (有符号指令与无符号指令的选择将影响高位 16b,其方式与 32b imul 之前的零或符号扩展相同。)

内存寻址 ([rsi + rax]):根据需要进行符号或零扩展。没有[rsi + eax]寻址模式。

除法和余数

log2(即最高设置位的位置) 尾随零计数(除非您知道在您想要的部分某处有一个设置位,或者只是检查大于 N 的结果,因为您未找到检查。)

二进制补码,就像无符号基数 2 一样,是一个位值系统。无符号 base2 的 MSB 在 N 位数(例如 231)中的位置值为 2N-1。在 2 的补码中,MSB 的值为 -2N-1(因此用作符号位)。 The wikipedia article 解释了许多其他理解 2 的补码和否定无符号 base2 数的方法。

关键是设置符号位不会改变其他位的解释。加法和减法的工作方式与 unsigned base2 完全相同,只是对结果的解释在有符号和无符号之间有所不同。 (例如signed overflow happens when there's a carry into but not out of the sign bit。)

此外,进位仅从 LSB 传播到 MSB(从右到左)。减法是一样的:不管高位有没有东西要借,低位就借。如果这导致溢出或进位,则只有高位会受到影响。例如:

 0x801F
-0x9123
-------
 0xeefc

低 8 位,0xFC,不依赖于他们从什么借来的。它们“环绕”并将借位传递给高 8 位。

所以加法和减法具有结果的低位不依赖于操作数的任何高位的特性。

因为LEA 只使用加法(和左移),所以使用默认地址大小总是可以的。延迟截断直到操作数大小对结果起作用总是可以的。

(例外:16 位代码可以使用地址大小前缀进行 32 位数学运算。在 32 位或 64 位代码中,地址大小前缀减小而不是增加宽度。)


乘法可以被认为是重复的加法,或者是移位和加法。低半部分不受任何高位影响。在这个 4 位示例中,我已经写出了所有的位积,这些位积相加到低 2 个结果位中。仅涉及任一源的低 2 位。很明显,这通常是有效的:部分乘积在加法之前被移位,因此源中的高位通常不会影响结果中的低位。

见Wikipedia for a larger version of this with much more detailed explanation。有很多不错的google hits for binary signed multiplication,包括一些教材。

    *Warning*: This diagram is probably slightly bogus.


       ABCD   A has a place value of -2^3 = -8
     * abcd   a has a place value of -2^3 = -8
     ------
   RRRRrrrr

   AAAAABCD * d  sign-extended partial products
 + AAAABCD  * c
 + AAABCD   * b
 - AABCD    * a  (a * A = +2^6, since the negatives cancel)
  ----------
          D*d
         ^
         C*d+D*c

使用有符号乘法而不是无符号乘法仍然会在低半部分得到相同的结果(本例中为低 4 位)。部分乘积的符号扩展只发生在结果的上半部分。

这个解释不是很透彻(甚至可能有错误),但有充分的证据表明在生产代码中使用它是真实且安全的:

gcc 使用imul 计算两个unsigned long 输入的unsigned long 乘积。 See an example of this of gcc taking advantage of LEA for other functions on the Godbolt compiler explorer.

英特尔的 insn 参考手册说:

二操作数和三操作数形式也可以与无符号一起使用 操作数,因为产品的下半部分是相同的 如果操作数有符号或无符号。然而,CF 和 OF 标志, 不能用于确定结果的上半部分是否为 非零。

英特尔的设计决定仅引入 imul 的 2 和 3 操作数形式,而不是 mul

显然,按位二进制逻辑运算(和/或/xor/not)独立处理每个位:位位置的结果仅取决于该位位置的输入值。位移也相当明显。

【讨论】:

以上是关于如果只需要结果的低位部分,哪些 2 的补码整数运算可以在不将输入中的高位归零的情况下使用?的主要内容,如果未能解决你的问题,请参考以下文章

Java位运算符

flinksql的 / 的结果只会保留整数部分,flinksql 不支持 div运算符。hive mysql : / 结果是小数, div 结果只会保留整数部分

剑指offer

flinksql的 / 的结果只会保留整数部分,flinksql 不支持 div运算符。hive mysql : / 结果是小数, div 结果只会保留整数部分

Python 数据类型都有哪些?

位移&二进制转换&原码&反码&补码