x86上的有符号和无符号算术实现

5

C语言有带符号和无符号类型,如char和int。 我不确定在汇编级别上它是如何实现的,例如,我认为有符号数和无符号数的乘积会产生不同的结果,所以汇编是否同时进行有符号和无符号算术运算,还是只进行其中一种,并且对于不同的情况进行模拟?


我建议在C99上使用<stdint.h> - Basile Starynkevitch
1
有符号和无符号乘法只有在你指的是 C 不支持的版本时才会产生不同的结果——也就是结果是操作数宽度的两倍的版本。 - harold
C不支持这个?你是什么意思? - user2214913
3
在二进制补码中,对于有符号和无符号数字进行加法、减法和非扩展乘法没有任何不同。https://dev59.com/VGYq5IYBdhLWcg3w8lJo - phuclv
相关:如果只想要结果的低位部分,哪些二进制补码整数操作可以在不将输入的高位清零的情况下使用? 通常,任何高位不影响结果低位部分的操作也意味着符号位不是特殊的,有符号和无符号的按位操作是相同的(例如加法或左移,但不包括右移(算术与逻辑))。 - Peter Cordes
3个回答

16

如果您查看x86的各种乘法指令,仅考虑32位变体并忽略BMI2,则会发现以下内容:

  • imul r/m32(32x32->64有符号乘法)
  • imul r32,r/m32(32x32->32乘法)*
  • imul r32,r/m32,imm(32x32->32乘法)*
  • mul r/m32(32x32->64无符号乘法)

请注意,只有“扩展”乘法有一个无符号的对应项。 中间带星号的两种形式都是有符号和无符号乘法,因为在您不获取额外的“上部分”的情况下,那是一样的

“扩展”乘法在C语言中没有直接的等价物,但编译器可以(而且经常会)使用这些形式。

例如,如果您编译以下内容:

uint32_t test(uint32_t a, uint32_t b)
{
    return a * b;
}

int32_t test(int32_t a, int32_t b)
{
    return a * b;
}

使用GCC或其他相对合理的编译器,您将得到以下结果:

test(unsigned int, unsigned int):
    mov eax, edi
    imul    eax, esi
    ret
test(int, int):
    mov eax, edi
    imul    eax, esi
    ret

(带有 -O1 的实际 GCC 输出)


因此,在一些操作中,符号并不重要(至少对于在 C 中使用的乘法而言),包括:

  • 加法和减法
  • 按位 AND、OR、XOR 和 NOT
  • 否定
  • 左移
  • 相等比较

x86 对于这些操作没有单独的有符号和无符号版本,因为它们本身就没有区别。

但是,对于某些操作,存在区别,例如:

  • 除法(idiv vs div
  • 余数(也是 idiv vs div
  • 右移(sar vs shr)(但要注意 C 中的有符号右移)
  • 大于/小于比较

但最后一个是特殊情况,x86 没有针对有符号和无符号的单独版本,而是拥有一种操作(cmp,实际上只是非破坏性的 sub),可以同时执行两种比较,并且生成多个结果("标志位" 中的多个位受到影响)。稍后使用这些标志位的指令(分支、条件移动、setcc)可以选择它们关心的标志位。例如,

cmp a, b
jg somewhere

如果a被"大于符号"签名,就会去somewhere

cmp a, b
jb somewhere

如果 a 是 "无符号小于" b,则会去某个地方。

有关标志和分支,请参见Assembly - JG/JNLE/JL/JNGE after CMP


这不是有关有符号和无符号乘法相同的正式证明,我只是试图让你了解为什么它们应该是相同的。

考虑4位2补码整数。它们各自的位权从最低有效位到最高有效位依次为:1、2、4和-8。当您将其中两个数字相乘时,您可以将其中一个数字分解成与其位对应的4个部分,例如:

0011 (decompose this one to keep it interesting)
0010
---- *
0010 (from the bit with weight 1)
0100 (from the bit with weight 2, so shifted left 1)
---- +
0110

2 * 3 = 6,所以所有东西都很对。这只是大多数人在学校里学习的常规长乘法,只不过是二进制,这使得它更容易,因为您不必乘以十进制数字,而只需乘以0或1,然后进行移位。

无论如何,现在取一个负数。符号位的权重为-8,因此在某一点上,您将进行部分乘积-8 * something。乘以8相当于左移3位,因此以前的lsb现在成为msb,所有其他位都为0。现在如果你取反它(毕竟它是-8,而不是8),什么也不会发生。显然,零不变,但是8也不变,在一般情况下,只有msb设置的数字也是如此:

-1000 = ~1000 + 1 = 0111 + 1 = 1000

所以,您所做的事情与无符号情况下最高有效位的权重为8时相同,而不是-8。


2
@user2214913,你可以写uint64_t res = a * b,但这会导致狭窄的结果,然后扩展。或者你可以写uint64_t res = a * (uint64_t)b,得到宽的结果,但这时你真的有一个64x64->64的乘法,其中操作数的高位恰好为零(但在某些情况下,编译器可能会使用32x32->64 mul来实现这一点)。虽然不完全相同,但也差不多了。至于汇编是否支持(无)符号算术运算,本答案(和其他答案)的重点是表明它们基本上是相同的东西。 - harold
1
@user2214913 非常确定。此外,我将展示为什么有符号和无符号乘法是相同的。 - harold
1
@user2214913:uint64_tdouble之间的转换是不支持的,但有符号版本是可以的。我想现在想到的就是这些了,除了矢量指令以外,所有东西都有一个有符号和无符号版本或者不需要两者都有。 - harold
2
"imul更强大,因为它接受使用任意操作寄存器,而mul必须使用eax作为其中一个输入,并将结果写入edx:eax。imul使编译器更容易...由于C编译器使用imul而不是mul,因此英特尔和AMD投入更多的精力来优化imul而不是mul,使前者在最近的处理器中更快。这使imul变得更加有吸引力。" - phuclv
@harold,mul的64位版本会将高64位字返回到rdx中吗?对于任何实现64x64到128的人来说,这非常重要,否则获取高位字需要进行大量计算。 - Z boson
显示剩余8条评论

5
现代处理器大多数支持有符号和无符号算术运算。对于不支持的算术运算,我们需要模拟实现。
引用来自这个答案的X86体系结构:
首先,x86原生支持有符号数的二进制补码表示法。您可以使用其他表示法,但这需要更多的指令,并且通常会浪费处理器时间。
我所说的“原生支持”是什么意思?基本上,我指的是您用于无符号数的一组指令和用于有符号数的另一组指令。无符号数可以放在与有符号数相同的寄存器中,实际上您可以混合使用有符号和无符号指令而不必担心处理器。编译器(或汇编程序员)负责跟踪数字是有符号还是无符号,并使用适当的指令。
首先,二进制补码数字具有加法和减法与无符号数字完全相同的特性。无论数字是正数还是负数都没有区别。(因此,您只需毫不担心地进行ADD和SUB操作即可。)
当涉及比较时,差异开始显现:x86有一种简单的方法来区分它们:above/below表示无符号比较,greater/less than表示有符号比较。(例如,JAE表示“跳转如果大于或等于”并且是无符号的。)
还有两组乘法和除法指令用于处理有符号和无符号整数。
最后:如果您要检查溢出,例如,您将针对有符号和无符号数字分别进行检查。

谢谢回答,但我必须说我还是不理解。 - user2214913
1
主要有两点,C规范只是指定了有符号和无符号类型的行为,但并没有指定如何实现这种行为。如果处理器支持所需操作,则使用这些操作进行实现,否则编译器编写者选择使用可用的有限指令集来实现该行为。这就是为什么大多数编译器编写者选择使用2s补码表示负数的原因,因为在2s补码上进行加法/减法与无符号数相同。在X86上,有不同的指令用于有符号/无符号乘法。 - Mohit Jain
有符号和无符号似乎不同,例如-200 * -200(说的是字节)将带来与unsigned(-200)* unsigned(-200)相同的结果?我有点迷失了。所以你说c不支持窄参数的扩展乘法?你确定吗? - user2214913
1
是的,由于以下选择,该产品是相同的。在无符号情况下,-a变为(m-a),其中m是2 ^(CHAR_BITS * sizeof(unsigned int))。无符号(-a)无符号(-a)=(m-a)(m-a)= m(m-2a)+ a * a = a * a(C保证无符号整数类型的环绕)= -a * -a。 - Mohit Jain
如果m为256,-200将会是256-200=56吗?忘记了-200超出了字节范围,应该以-100为例,-100是156,-100 * -100将会是10,000,156 * 156是24,336,那又怎么样呢? - user2214913
1
[(156 * 156) mod 256] = [(100 * 100) mod 256] = 16 - Mohit Jain

3
一点补充关于cmpsub。我们知道cmp被视为非破坏性的sub,所以让我们聚焦于sub
当x86 CPU执行sub指令时,例如:
sub eax, ebx

CPU如何知道eax或ebx的值是有符号还是无符号的?例如,考虑一个使用二进制补码表示的4位宽度数字:
eax: 0b0001
ebx: 0b1111

“无论是有符号还是无符号,eax的值都将被解释为1(十进制),这是可以的。然而,如果ebx是无符号的,它将被解释为15(十进制),结果如下:”
ebx:15(dec) - eax: 1(dec) = 14(dec) = 0b1110 (two's complement)

如果ebx是有符号的,则结果如下:
ebx: -1(dec) - eax: 1(dec) = -2(dec) = 0b1110 (two's complement)

即使对于有符号或无符号的数,它们在二进制补码中的编码相同:0b1110

但一个是正数:14(十进制),另一个是负数:-2(十进制),那么我们的问题来了:CPU如何判断哪个是哪个?

答案是CPU将同时评估两者,来自于:http://x86.renejeschke.de/html/file_module_x86_id_308.html

它会对有符号和无符号整数操作数的结果进行评估,并设置OF和CF标志以分别指示有符号或无符号结果中的溢出。SF标志指示有符号结果的符号。

对于这个特定的例子,当CPU看到结果:0b1110时,如果将其解释为负数,则它将设置SF标志为1,因为它是-2(十进制)

然后,它取决于接下来的指令是否需要使用SF标志,或者是否简单地忽略它。


http://teaching.idallen.com/dat2343/10f/notes/040_overflow.txt 这个链接中还有4位示例,并详细介绍了有符号溢出和无符号进位,以及这些标志是如何设置的。 - Peter Cordes
我想你的意思是 0b1111。通常 0x 表示十六进制。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接