C编译器如何处理无符号和有符号整数?为什么无符号和有符号算术运算的汇编代码相同?

13

我正在阅读《CS-APPe2》这本书。C语言有无符号和有符号的int类型,在大多数架构中使用二进制补码算术来实现有符号值;但是在学习了一些汇编代码后,我发现很少有指令区分有符号和无符号。所以我的问题是:

  1. 编译器是否有责任区分有符号和无符号?如果是,它是如何做到的?

  2. 谁来实现二进制补码算术 - CPU还是编译器?

添加更多信息:

在学习了更多指令后,实际上有一些指令区分有符号和无符号,例如setg、seta等。此外,CF和OF分别适用于无符号。但是,大多数整数算术指令将有符号和无符号视为相同,例如:

int s = a + b

并且

unsigned s = a + b

生成相同的指令。

因此,在执行ADD s d时,CPU应该将s&d视为无符号还是有符号?或者这是不相关的,因为两个结果的位模式相同,而编译器的任务是将底层位模式结果转换为无符号或有符号?

P.S 我正在使用x86和gcc。


6
C语言有无符号和有符号整数类型,并使用二进制补码算术来实现有符号值。我们都知道这一点。- 但你们知道的是错误的。二进制补码虽然很流行,但并不普遍。C标准允许实现采用1的补码或符号加绝对值表示法来表示有符号整数。 - user529758
1
关于这些问题:1. 是的,通过查看变量的类型并在必要时发出不同的汇编代码;2. 是CPU。 - user529758
4
在低级别中,无符号数字和有符号数字的差异只有在这些数字被扩展或截断(例如sext/zext操作)时才会显现出来。对于1's complement数字,有符号和无符号的算术运算是相同的。 - SK-logic
3
除了SK逻辑:看一下除法和比较,你会发现有符号和无符号之间的差异。从CPU的角度来看,任何数据都只是一组位或字节。 - Bryan Olivier
1
@SK-logic:那是一个回答,而不是评论——我建议您将其发布为回答,特别是如果您可以提供一些实例。 - Clifford
显示剩余15条评论
7个回答

8
在许多情况下,有符号操作和无符号操作在机器级别上没有区别,只是位模式的解释不同。例如,考虑以下4位字操作:
Binary Add  Unsigned   2's comp
----------  --------   --------
  0011          3         3
+ 1011       + 11       - 5
-------     --------   --------
  1110         14        -2  
-------     --------   --------

二进制模式对于有符号和无符号操作是相同的。需要注意的是,减法只是加上一个负值。当进行SUB操作时,右操作数被二进制补码(取反并增加一)后被加上(负责的ALU电路是加法器);这并不是在指令级别上理解的,而是在逻辑级别上实现的,尽管可以在没有SUB指令的机器上执行减法,但需要两个指令而不是一个。
有些操作确实需要根据类型使用不同的指令,总的来说编译器需要生成适当的代码,尽管可能会有架构差异。

当执行SUB操作时,右操作数会被补码取反然后加上。我认为你错过了一个修正。"补码"通常指按位补码(~x),对于二进制补码而言,这与取负不是同一操作。 - user743382
@clifford,我添加了一些示例,请你阅读我的问题并再次帮忙回答一下? - tomwang1013

3
很容易。在二进制补码算术中,加减等操作不需要对带符号类型进行任何调整。只需进行心理实验,想象一下仅使用以下数学运算的算法即可:递增1、递减1、与零比较。
加法只是从一个堆中一个个拿出项目,并将它们放入另一个堆中,直到第一个堆为空为止。减法是同时从两个堆中取走物品,直到被减去的那个堆为空为止。在模运算中,您可以将最小值视为最大值加1,这样就可以正常工作。二进制补码只是最小值为负的模运算。
如果您想看到任何区别,请尝试那些与溢出无关的操作。一个例子是比较(`a < b`)。
引用: 编译器是否有责任区分有符号和无符号?如果是,它如何做到?
通过根据需要生成不同的汇编代码。
谁实现了二进制补码算术- CPU还是编译器?
这是一个困难的问题。在计算机中,二进制补码可能是处理负整数的最自然方式。对于带溢出的二进制补码,大多数操作与带溢出的无符号整数相同。符号可以从单个位中提取。比较可以通过减法(不考虑符号)、符号位提取和与零的比较来概念上完成。
但是,CPU的算术特性使编译器能够以二进制补码生成计算。 unsigned s = a + b
请注意,这里加法的计算方式不取决于结果的类型,而是取决于等号右侧变量的类型。
因此,在执行ADD s d时,CPU应该将s和d视为无符号还是有符号?
CPU指令不知道类型,只有编译器使用类型。此外,添加两个无符号数和添加两个有符号数之间没有区别。为相同操作使用两个指令是愚蠢的。

1
我认为你的意思是:“CPU指令不知道类型”。 - Bryan Olivier
@paval,所以在s = a + b中,编译器的任务是将a + b的位结果转换为s的类型,即无符号或有符号。例如,假设整数为4位,a + b的位结果为1001,则如果s为无符号,编译器将1001视为9,否则将其视为-7。所有这些转换都是由编译器完成的,而不是CPU。我说得对吗? - tomwang1013
1
不是的。编译器在运行时无法解释运行时值,因为它此时并未运行。它仅决定将使用哪些指令来执行这些值的操作。只有字面量(直接写入源文件的数字)会被编译器解释,但这些自然默认为有符号数。 - Pavel Šimerda
修正:「尽管如此,是CPU的算术特性使编译器能够以二进制补码进行计算。」这句话不仅错误,而且让问题更加混淆了。 - Pavel Šimerda
@PavelŠimerda,“conversion”这个词不太合适,我认为应该说“让编译器将a + b视为与s相同的类型,即 s = a + b”。 - tomwang1013
@user1446907:你最初想要影响加号的含义,但通过转换/赋值结果是无法实现的,因为此时加法已经完成。 - Pavel Šimerda

1
对于大多数算术/逻辑运算,没有必要区分有符号整数和无符号整数。通常只需要在打印、零扩展或比较值时考虑符号。实际上,CPU 不知道一个值的类型。一个 4 字节的值只是一系列位,除非用户指出它是浮点数、4 个字符的数组、无符号整数或有符号整数等,否则它没有任何意义。例如,在打印字符变量时,根据指定的类型和输出属性,它将打印字符、无符号整数或有符号整数。程序员有责任告诉编译器如何处理该值,然后编译器将发出处理该值所需的正确指令。

我差不多懂了:尽管 a + b 输出相同的二进制结果,但我们可以通过 s 的类型让编译器将其视为无符号或有符号,例如 s = a + b。或者通过打印语句 print("%xxx", a + b) - tomwang1013
1
是的,就是这样。但是如果将不同于格式的类型传递给printf可能会引发未定义的行为。它应该首先转换为所需的类型,但那只是一个类型转换,位模式仍然不变。 - phuclv
“@user1446907: 这是技术上的错误。你并没有告诉编译器如何处理它(编译器从a + b的类型中知道),你只是告诉它生成一系列指令,以将其从表达式类型转换为变量类型。” - Pavel Šimerda
@PavelŠimerda 谢谢,您的解释更为简明扼要且有意义,尽管我仍有许多问题。我需要学习更多。 - tomwang1013
@user1446907:只管问吧,这有助于我整理自己的知识。 - Pavel Šimerda

1
很多关于第一个问题的言论已经被提出了,但我想说一些关于你第二个问题的事情:
引用:

实现二进制补码算术的是CPU还是编译器?

C标准不要求负数具有二进制补码,它根本没有定义硬件如何表示负数。编译器的任务是将您的C代码转换为执行您请求的操作的CPU指令。因此,无论C编译器是否会创建用于二进制补码算术的代码,取决于您的CPU是否使用二进制补码算术。编译器必须知道CPU的工作原理并相应地创建代码。所以对这个问题的正确答案是:CPU。
如果您的CPU使用了一种补码表示,那么针对该CPU的C编译器将发出补码指令。另一方面,C编译器可以在完全不了解负数的CPU上模拟支持负数。由于二进制补码允许您在许多操作中忽略一个数字是有符号还是无符号,因此这并不太难做到。在这种情况下,它将是编译器实现二进制补码算术。这也可以在具有负数表示的CPU上完成,但为什么编译器要这样做,而不只是使用CPU理解的本机形式呢?因此,除非必须这样做,否则它不会这样做。

“at all” 稍微有点夸张。C语言确实规定实现可以使用2的补码、1的补码或者符号/大小编码来表示有符号整数。(但是对象表示可以包含填充位)。我认为这保证了比特位的位置值是正常顺序(模端序),如果你使用无符号字符查看对象表示(无填充的二进制),那么我们可以排除将符号位放在中间,其他位在两侧的情况。或者任何其他奇特的编码方式。 - Peter Cordes
@PeterCordes 请看第二段。C标准规定,C中的数字可以是、或者,但它并没有说“C不能在不满足这些条件的硬件上运行”。如果您有使用不同表示方法的硬件,它仍然可以支持C编译器,但是该编译器必须选择您提到的一种表示方法进行存储,并且对于计算,要么在软件中进行,要么转换为本地格式,进行计算并再次转换为所选的存储格式,可能是更快的格式。在这种罕见情况下,实现将定义它,这是正确的。 - Mecki

1

提供有用的指令是硬件的工作。编译器的工作是将抽象的高级语言翻译成这些指令。

硬件存储和操作位模式。只有在我们对其执行操作时,才会为位模式分配含义。如果我们对两个寄存器执行“有符号除法”操作,我们告诉硬件或库函数假定这些寄存器中的位表示有符号数。如果我们对两个寄存器执行加法操作,我们告诉硬件假定这些位模式表示数字,但我们并没有告诉它这些数字是有符号还是无符号。硬件没有理由知道或关心这一点。

二进制补码是有符号整数最流行的格式,因为它需要硬件的额外工作最少。特别地,如果使用模2n环绕(这是实现它们的最简单方法)来实现加法、减法和非扩展乘法,则这些相同的操作可以用于有符号和无符号算术。

尽管如此,对于有符号和无符号数,存在不同的操作,编译器或汇编程序员需要注意它们。

比较和溢出检测

在有符号和无符号算术中,相等比较是相同的。但是不等比较则不同。255毕竟大于零,而-1小于零。类似地,溢出检测也不同。在8位无符号算术中计算255 + 255会溢出,但在8位有符号算术中计算(-1) + (-1)则不会。

然而,大多数CPU没有专用的有符号和无符号比较操作。相反,大多数CPU都有一组标志,其中某些标志对无符号操作有意义,而不同的标志对有符号操作有意义。

这些标志可以作为常规算术操作的结果设置。大多数CPU还具有一个专用的比较操作,它有效地执行减法并设置标志,但丢弃结果。

例如,arm32的标志集合是(这些是相当典型的):

  • Z:零标志,用于检测结果是否为零(对于比较指令,这表示操作数相等)
  • C:进位标志,在将较大的算术操作构建成较小的操作时使用。在无符号算术中,也用于检测溢出。
  • V:溢出标志,在有符号算术操作溢出时设置。
  • N:负数标志,如果结果的最高位设置,则设置。在有符号算术操作的结果为负数时设置。

CPU并不知道或关心用户是执行有符号还是无符号操作,它总是根据一组简单的规则设置所有标志。编译器(或汇编语言编写者)需要知道哪些标志对其操作有意义,并选择适当的条件指令。

条件指令反过来会查看这些标志的各种组合。用于处理无符号值的条件指令查看C和Z标志,而用于处理有符号值的条件指令查看V、Z和N标志。

扩展

将数字从较小的类型转换为较大的类型称为扩展。对于无符号数,额外的位必须填充为零。对于有符号数,额外的位必须填充为符号位的副本。

一些体系结构可能具有专用的扩展指令,或者可能将扩展功能合并到其他指令中。例如,在32位arm上,所有算术指令都适用于32位字。在原始的arm CPU上,“加载字节”指令总是将字节零扩展。但是,在armv4中添加了其他加载指令,因此可以在单个操作中加载和扩展有符号字节和有符号和无符号半字。

同样,最近版本的32位ARM具有特定的符号扩展指令,它们丢弃寄存器的上半部分,并用下半部分的符号或零扩展替换其内容。

在其他情况下,可以使用更通用的指令进行扩展。这可能包括移位指令和按位操作。例如,将单个字值扩展为双字值可能使用比字大小少一位的移位(例如32位字大小的31)来构造一个新字,其中完全重复第一个字的符号位。

扩展乘法

扩展乘法通常产生比其参数大两倍的结果。例如,32x32扩展乘法通常会产生64位结果。在某些情况下,可能有一个单独的指令执行完整的扩展乘法,而在其他情况下,可能提供一个“高位乘法”指令,仅提供结果的顶部部分。

扩展乘法在许多应用中都很有用,包括定点算术和作为执行比字大小更宽的数字运算的构建块。

当处理器提供扩展乘法时,它们通常为有符号和无符号操作提供单独的版本。

位移

位移可用于将数字乘以或除以2的幂。

将数字“左移”以使其变大对于有符号和无符号数是相同的。然而,将数字“右移”以使其变小是不同的,对于无符号数,额外的位置应该填充零,而对于有符号数,应该用有符号位的副本来填充。

大多数CPU为有符号和无符号操作分别提供单独的右移指令。这些指令被称为“逻辑移位”(用于无符号操作),而对于有符号操作则称为“算术移位”(可能由于历史原因)。

请注意,一些高级语言,特别是C和旧版本的C ++,将负数移位实现的行为定义为实现定义。

除法


可以轻松看出,对于有符号和无符号数,除法是不同的。(-2)/(-1) = 2,但 254 / 255 = 0。

根据我的经验,许多CPU根本不提供任何除法指令,但当提供除法指令时,通常会提供有符号和无符号版本。


0

这个问题困扰了我很长时间。我不知道编译器在处理默认和隐式指令时是如何工作的。但我的寻找答案让我得出以下结论:

自从负数被发现以来,现实世界只使用有符号整数。这就是为什么编译器默认将int视为有符号整数的原因。我完全忽略了无符号数算术,因为它是无用的。

CPU并不知道有符号和无符号整数。它只知道0和1位。你如何解释它的输出取决于你作为汇编程序员的能力。这使得汇编编程变得繁琐。处理整数(有符号和无符号)需要大量的标志检查。这就是为什么高级语言被开发出来的原因。编译器可以帮你摆脱所有的痛苦。

编译器的工作原理是一种非常高级的学习。我接受了目前我还无法理解它的事实。这种接受帮助我在我的课程中继续前进。

在x86架构中:

add和sub指令修改eflags寄存器中的标志。这些标志可以与adc和sbb指令一起使用,以构建更高精度的算术运算。在这种情况下,我们将数字的大小移动到ecx寄存器中。循环指令执行的次数与数字的字节数相同。

Sub指令对被减数取2的补码,加上被减数,反转进位。这是在硬件中完成的(通过电路实现)。Sub指令“激活”了不同的电路。使用Sub指令后,程序员或编译器检查CF。如果为0,则结果为正数,目标具有正确的结果。如果为1,则结果为负数,目标具有结果的2的补码。通常,结果保留为2的补码,并作为带符号数读取,但NOT和INC指令可用于更改它。NOT指令执行操作数的1的补码,然后将操作数递增以获得2的补码。

当程序员计划将加法或减法指令的结果读取为带符号数时,他应该注意OF标志。如果设置为1,则结果错误。在运行它们之间的操作之前,他应该对数字进行符号扩展。


我很想给你一个+1(虽然你实际上并不需要它),但我需要一些信息(例如关于子程序和更正)来支持易于理解但可靠的来源。 - Pavel Šimerda
@PavelŠimerda 我从这本书中学到了sub指令:http://www.amazon.com/x86-PC-Assembly-Language-Interfacing/product-reviews/0135026482/ref=dp_top_cm_cr_acr_txt?ie=UTF8&showViewpoints=1 这本书很老,它是关于在模拟器内部的Windows汇编编程的。 - KawaiKx
是的,CF指示无符号减法是否溢出,但它可能会溢出到超出有符号2的补码的“-2 ^ 31..2 ^ 31-1”范围。例如,eax = 0,ecx = 0xffffffff的sub eax,ecx将使EAX = 1,CF = 1。-0xffffffff = -4294967295需要33位才能用2的补码表示,并且+1仅是这些位的低32位。 - Peter Cordes
当然,在某些情况下,无符号输入的减法会得到正确的二进制补码有符号结果,但你必须检查更多的标志位才能确定是否发生了这种情况。 - Peter Cordes

0

2 的补码只是十进制和二进制数字之间的映射。

编译器通过将文字数字转换为相应的二进制来实现此映射,例如将 -3 转换为 0xFFFFFFFD(如在反汇编中所示),并生成与 2 的补码表示一致的机器代码。例如,当它尝试执行 0-3 时,应选择一个指令,该指令应通过将 0x00000000 和 0x00000003 作为参数来产生 0xFFFFFFFD。

选择相同的 SUB 用于无符号减法,是因为它可以简单地产生预期的 0xFFFFFFFD。没有必要请求 CPU 为有符号减法提供特殊的 SUB。

认为第二个操作数被 2 的补码倒置并由此推断出 CPU 在 SUB 中实现了 2 的补码是不公平的,因为从高位借位在减法中的运用方法与 2 的补码取反是相同的,而且 SUB 也用于无符号减法,完全不需要涉及 2 的补码概念。

以下反汇编说明了带符号减法和无符号减法使用相同的 SUB 运算。

//int32_3 = -3;
010B2365  mov         dword ptr [int32_3],0FFFFFFFDh  
//int32_1 = 0, int32_2 = 3;
010B236C  mov         dword ptr [int32_1],0  
010B2373  mov         dword ptr [int32_2],3  
//uint32_1 = 0, uint32_2 = 3;
010B237A  mov         dword ptr [uint32_1],0  
010B2384  mov         dword ptr [uint32_2],3  
//int32_3 = int32_1 - int32_2;
010B238E  mov         eax,dword ptr [int32_1]  
010B2391  sub         eax,dword ptr [int32_2]  
010B2394  mov         dword ptr [int32_3],eax  
//uint32_3 = uint32_1 - uint32_2;
010B2397  mov         eax,dword ptr [uint32_1]  
010B239D  sub         eax,dword ptr [uint32_2]  
010B23A3  mov         dword ptr [uint32_3],eax  

CPU在CF和OF标志中保留额外的信息,以便进一步使用SUB的结果的指令可以根据分配结果的变量类型以不同的方式使用。

以下反汇编说明了编译器如何为有符号比较和无符号比较生成不同的指令。请注意,cmp包括内部的sub,而jle基于OF标志,jbe基于CF标志。

//if (int32_3  > 1)  int32_3 = 0;
010B23A9  cmp         dword ptr [int32_3],1  
010B23AD  jle         main+76h (010B23B6h)  
010B23AF  mov         dword ptr [int32_3],0  
//if (uint32_3 > 1) uint32_3 = 0;
010B23B6  cmp         dword ptr [uint32_3],1  
010B23BD  jbe         main+89h (010B23C9h)  
010B23BF  mov         dword ptr [uint32_3],0 

OF标志的设置表明CPU实现了二进制补码,因为当超过中间二进制数字0x10000000或0x0FFFFFFF时,OF的设置方式是如此。而二进制补码表示将0x10000000映射为-268435456,将0x0FFFFFFF映射为268435455,这是32位整数的上限和下限。因此,此OF标志专门针对二进制补码设计,因为其他表示方法可能会选择将其他二进制数字映射到上限和下限。

总结: 1.编译器通过实现相应的表示(映射)并生成指令来区分有符号和无符号算术运算,其结果符合编译器对有符号和无符号整数的表示。 2.编译器实现二进制补码表示,CPU也实现它来支持编译器生成算术指令,其结果符合二进制补码表示。


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