这是在C中实现+运算符的方式吗?


79

当了解如何原始的运营商,如+-*/用C实现,我发现从下面的代码片段一个有趣的答案

// replaces the + operator
int add(int x, int y) {
    while(x) {
        int t = (x & y) <<1;
        y ^= x;
        x = t;
    }
    return y;
}

似乎该功能演示了如何+在后台实际工作。但是,这让我难以理解。我相信这种操作是使用编译器生成的汇编指令很长时间完成的!

+操作员是否按照发布在MOST实施中的代码实施?这是否利用了二进制补码或其他与实现相关的功能?


60
我想大多数实现将使用本add机指令,我猜这是所有CPU都具备的,并实现为可以在几个时钟内工作的硬件加法器。
MikeCAT '16

3
是的,+操作员很可能会利用实现定义的功能。这些被称为“机器语言”和“ CPU”。您的问题是什么?如果您想知道如何将表达式转换为机器代码,请阅读有关编译器构造的信息。
对此网站来说太老实了

您是说我发布的代码完全没有用,因为+s被add编译器等汇编指令翻译了吗?
nalzok '16

11
大多数+操作将被编译为某种 机器代码指令的变体(或组合)add。您的代码在每个实际场景中都是令人费解的,没有用,但是它可以用来教授二进制操作。
Anders Tornblad '16

11
虽然不是C的工作方式(请参阅下面的答案),但它与所涉及的电路如何以最低的水平进行添加非常接近。尝试用纸和笔处理它以获取较小的二进制值(例如3或4位字节),并查看其工作方式。现在想象一下电路如何利用电脉冲完成同样的事情。现在想象一下并行执行所有位,而不是循环。现在,您可以构建1940年代的计算机了:D
乔恩·汉娜

Answers:


184

为了学究,C规范未指定如何实现加法。

但实际上,+小于或等于CPU字长的整数类型的运算符直接转换为CPU的加法指令,而较大的整数类型则转换为带有一些额外位的多个加法指令以处理溢出。

CPU在内部使用逻辑电路来实现加法运算,而不使用循环,位移位或与C的工作方式非常相似的任何东西。


12
这个答案非常好,因为它以非同寻常的清晰度和简单性呈现。我根本没有发现它过于学究,只是针对该问题的正确剂量的学问。
杰里米·安德森

5
实际上,@ orlp可以从HDL编译CPU逻辑电路,并且您可能会使用类似于OP的建议的循环和位移位生成加法器(但只是模糊地)。所述循环和移位将描述硬件的布局及其连接方式。再说一遍,在顶级硬件中,有人可能会展开所说的循环和位移,甚至取消HDL并手动布置电路以实现与加法器一样重要的性能。
Yakk-Adam Nevraumont

5
线性加法器电路完全可以执行C代码的操作,但是循环在硬件中完全展开(32次)。
usr

2
@usr不仅展开,而且每个“步骤”都同时发生。
OrangeDog '16

4
@OrangeDog一个简单的硬件加法器会像C代码一样通过进位波纹来限制并行运算。高性能加法器可以使用进位超前电路来减少这种情况。
plugwash '16

77

当您将两位相加时,结果如下:(真值表)

a | b | sum (a^b) | carry bit (a&b) (goes to next)
--+---+-----------+--------------------------------
0 | 0 |    0      | 0
0 | 1 |    1      | 0
1 | 0 |    1      | 0
1 | 1 |    0      | 1

因此,如果您进行按位异或运算,则无需进位就可以获得和。如果按位进行操作,则可以得到进位位。

将这种观察扩展到多位数ab

a+b = sum_without_carry(a, b) + carry_bits(a, b) shifted by 1 bit left
    = a^b + ((a&b) << 1)

曾经b0

a+0 = a

因此,算法归结为:

Add(a, b)
  if b == 0
    return a;
  else
    carry_bits = a & b;
    sum_bits = a ^ b;
    return Add(sum_bits, carry_bits << 1);

如果您摆脱了递归并将其转换为循环

Add(a, b)
  while(b != 0) {
    carry_bits = a & b;
    sum_bits = a ^ b;

    a = sum_bits;
    b = carrry_bits << 1;  // In next loop, add carry bits to a
  }
  return a;

考虑到以上算法,从代码进行解释应该更简单:

int t = (x & y) << 1;

携带位。如果两个操作数右边的1位为1,则进位位为1。

y ^= x;  // x is used now

无进位加法(忽略进位)

x = t;

重用x将其设置为携带

while(x)

在有更多进位位时重复


递归实现(易于理解)将是:

int add(int x, int y) {
    return (y == 0) ? x : add(x ^ y, (x&y) << 1);
}

似乎此函数演示了+在后台如何实际工作

否。通常(几乎总是)整数加法转换为机器指令加法。这只是演示了使用按位xor和and的替代实现。


5
这是最好的答案海事组织,所有其他国家,它的通常翻译为单一的指令,但是这确实是和解释了给定函数。
Nick Sweeting

@NickSweeting谢谢。这个问题可以用两种方式来解释,我认为公认的答案正确地解释了OP想要问的问题。
Mohit Jain

25

似乎此函数演示了+在后台如何实际工作

不会。这会转换为本地add机器指令,实际上是在使用硬件加法器ALU

如果您想知道计算机如何添加,这是一个基本的添加器。

计算机中的所有操作都是使用逻辑门完成的,逻辑门主要由晶体管组成。完整加法器中有半加法器。

有关逻辑门和加法器的基本教程,请参见this。该视频虽然很长,但却非常有用。

在该视频中,显示了一个基本的半加法器。如果您需要简要说明,就是这样:

半加法器的两位相加。可能的组合是:

  • 加0和0 = 0
  • 加1和0 = 1
  • 加1和1 = 10(二进制)

那么,现在半加法器如何工作?嗯,它由三个逻辑门andxor和组成nand。的nand 给出了一个正电流,如果两个输入是负的,从而使装置这解决了0和0的情况下,xor给出了输入的正输出一个为正,另一个为负,因此这意味着,它解决的问题1和0。and仅当两个输入均为正时,a才给出正输出,这样就解决了1和1的问题。因此,基本上,我们现在有了半加法器。但是我们仍然只能添加位。

现在,我们使我们的全加法器。完整的加法器包括一次又一次调用半加法器。现在,它有一个携带。当我们将1和1相加时,我们得到一个进位1。因此,全加器的工作是从半加器中获取进位,将其存储,然后将其作为另一个参数传递给半加器。

如果您对如何传递进位感到困惑,则基本上可以先使用半加器将位相加,然后再将总和与进位相加。因此,现在您添加了带有两位的进位。因此,您一次又一次地执行此操作,直到必须添加的位结束为止,然后得到结果。

惊讶吗 这是实际发生的情况。这看起来像一个漫长的过程,但是计算机在半个时钟周期内完成了几分之一纳秒的时间,或更具体而言,做到了这一点。有时甚至在一个时钟周期内执行。基本上,计算机具有ALU(的主要部分CPU),内存,总线等。

如果您想从逻辑门,存储器和ALU中学习计算机硬件,并模拟计算机,则可以查看本课程,我从中学到了这一切:从第一原理构建现代计算机

如果您不想要电子证书,则它是免费的。该课程的第二部分将于今年春季上线


11
几毫秒?一次添加?
JAB 2016年

2
通常在单个时钟中完成两个注册值的加法运算。
科迪·格雷

5
@Tamoghna Chowdhury:尝试几分之一秒。在最近的英特尔处理器上,寄存器加号是IIRC的一个时钟,因此时钟速度为几GHz ...这还不包括流水线,超标量执行等。
jamesqf

这种带有脉动的加法器会增加过多的延迟,因此甚至在硬件中都没有实现。
管道

带有涟波的加法器几十年来一直没有被CPU使用,因为它太慢了。取而代之的是,他们使用更复杂的加法器,这些加法器可以在单个时钟周期内完成工作(对于某些英特尔双时钟ALU,甚至可以在半个周期内完成工作)。(嗯,大部分CPU不使用它低端的嵌入式CPU仍然可以使用它的低晶体管数量。)
马克

15

C使用抽象机来描述C代码的作用。因此,未指定其工作方式。例如,有C个“编译器”实际上将C编译为脚本语言。

但是,在大多数C实现中,+小于机器整数大小的两个整数之间将转换为汇编指令(经过许多步骤)。汇编指令将被翻译成机器代码,并嵌入您的可执行文件中。汇编是一种从机器代码中“一步一步删除”的语言,旨在比一堆打包的二进制代码更易于阅读。

然后,由目标硬件平台解释该机器代码(经过许多步骤),然后由CPU上的指令解码器解释该机器代码。该指令解码器接收指令,并将其转换为信号以沿“控制线”发送。这些信号通过CPU将来自寄存器和存储器的数据路由到CPU,然后通常在算术逻辑单元中将这些值加在一起。

算术逻辑单元可能具有单独的加法器和乘法器,或者可能将它们混合在一起。

算术逻辑单元具有一堆晶体管,这些晶体管执行加法运算,然后产生输出。所述输出通过从指令解码器产生的信号进行路由,并存储在存储器或寄存器中。

算术逻辑单元和指令解码器中的所述晶体管的布局(以及我已经介绍过的部分)都被蚀刻到工厂的芯片中。蚀刻图案通常是通过编译硬件描述语言来产生的,该语言描述了连接到什么以及它们如何操作以及如何产生晶体管和互连线的抽象概念。

硬件描述语言可以包含移位和循环,这些移位和循环不能描述时间上发生的事情(就像一个接一个地发生),而可以描述在空间上发生的事情-它描述了硬件不同部分之间的连接。所说的代码看起来很模糊,就像您上面发布的代码一样。

上面的内容覆盖了许多部分和层,并包含一些不准确之处。这既是由于我自己的无能(我既编写了硬件,也编写了编译器,但都不是专家),而且因为完整的细节需要一两个职业,而不是SO职位。

是关于8位加法器的SO帖子。 是一篇非SO文章,您会在其中注意到一些仅operator+在HDL中使用的加法器!(HDL本身会理解+并为您生成较低级别的加法器代码)。


14

几乎所有可以运行已编译C代码的现代处理器都将内置支持整数加法。您发布的代码是在不执行整数加操作码的情况下执行整数加法的一种聪明方法,但通常不是执行整数加法的方式。实际上,函数链接可能使用某种形式的整数加法来调整堆栈指针。

您发布的代码基于以下观察:将x和y相加时,可以将其分解为它们共有的位以及x或y之一唯一的位。

表达式x & y(按位与)给出x和y共有的位。表达式x ^ y(按位异或)给出x或y之一唯一的位。

x + y可以将总和重写为它们共有的位的两倍(因为x和y都贡献了这些位)加上x或y唯一的位。

(x & y) << 1 是它们共有的位的两倍(左移1有效乘以2)。

x ^ y 是x或y之一唯一的位。

因此,如果我们将x替换为第一个值,并将y替换为第二个值,则总和应保持不变。您可以将第一个值视为按位加法的进位,将第二个值视为按位加法的低位。

这个过程一直持续到x为零为止,此时y保持总和。


14

您找到的代码试图解释非常原始的计算机硬件如何实现“添加”指令。我说“可能”是因为我可以保证任何人都不会使用方法CPU,因此我将解释原因。

在正常生活中,您使用十进制数字,并且学习了如何将它们相加:要添加两个数字,请添加最低的两位数字。如果结果小于10,则记下结果并继续到下一个数字位置。如果结果是10或更大,则记下结果减去10,继续到下一位,买时请记住再加上1。例如:23 + 37,您加3 + 7 = 10,写下0,并记住为下一个位置再加1。在10s位置,您将(2 + 3)+ 1 = 6记下来。结果是60。

您可以使用二进制数执行完全相同的操作。不同之处在于,唯一的数字是0和1,因此,唯一可能的总和是0、1、2。对于32位数字,您将要处理一个数字的位置。这就是真正的原始计算机硬件将如何做。

此代码的工作方式不同。您知道如果两个数字均为1,则两个二进制数字的总和为2。因此,如果两个数字均为1,则您将在下一个二进制位置再加上1,并记下0。这就是t的计算结果:它找到所有位置其中两个二进制数字均为1(即&),并将它们移至下一个数字位置(<< 1)。然后,它执行加法运算:0 + 0 = 0,0 + 1 = 1,1 + 0 = 1,1 + 1是2,但是我们写下了0。这就是排他或运算符的作用。

但是尚未处理下一位数字中必须处理的所有1。它们仍然需要添加。这就是代码执行循环的原因:在下一次迭代中,将添加所有额外的1。

为什么没有处理器那样做?因为这是一个循环,并且处理器不喜欢循环,所以速度很慢。它很慢,因为在最坏的情况下,需要32次迭代:如果将数字0xffffffff加1(32个1位),则第一次迭代将y的位0清零并将x设置为2。第二次迭代将清零bit 1。 y并将x设置为4。依此类推。它需要32次迭代才能得到结果。但是,每次迭代都必须处理x和y的所有位,这需要大量硬件。

从最低位置到最高位置,原始处理器将以与十进制算术一样快的速度执行操作。它也需要32个步骤,但是每个步骤仅处理两位加上前一位位置的一个值,因此实现起来容易得多。即使在原始计算机中,也可以负担得起而不必实现循环。

现代,快速且复杂的CPU将使用“条件和加法器”。特别是如果位数很高,例如64位加法器,则可以节省大量时间。

64位加法器由两部分组成:首先,是最低32位的32位加法器。该32位加法器产生一个和,以及一个“进位”(指示必须在下一个位位置加1)。其次,两个32位加法器用于较高的32位:一个加x + y,另一个加x + y +1。所有三个加法器并行工作。然后,当第一个加法器产生进位后,CPU会选择两个结果x + y或x + y +1中的哪个是正确的,您便得到了完整的结果。因此,64位加法器仅比32位加法器花费一点时间,而不是两倍。

32位加法器部分再次使用多个16位加法器实现为条件和加法器,而16位加法器则是条件和加法器,依此类推。


13

我的问题是:+运算符是否作为发布在MOST实现上的代码实现?

让我们回答实际的问题。所有运算符都由编译器实现为某些内部数据结构,这些数据结构在进行一些转换后最终转换为代码。您不能说一次加法将生成什么代码,因为几乎没有现实世界的编译器会为单个语句生成代码。

编译器可以自由生成任何代码,只要它们的行为就像实际操作是根据标准执行的一样即可。但是实际发生的事情可能完全不同。

一个简单的例子:

static int
foo(int a, int b)
{
    return a + b;
}
[...]
    int a = foo(1, 17);
    int b = foo(x, x);
    some_other_function(a, b);

此处无需生成任何附加说明。编译器将其转换为:

some_other_function(18, x * 2);

或者,也许编译器会注意到您foo连续调用了几次函数,并且这是一种简单的算法,并且会为其生成向量指令。或将相加的结果用于以后的数组索引,并将使用lea指令。

您根本无法谈论操作符的实现方式,因为几乎永远不会单独使用它。


11

如果代码故障有助于其他人,请举个例子x=2, y=6


x不为零,因此请开始添加y

while(2) {

x & y = 2 因为

        x: 0 0 1 0  //2
        y: 0 1 1 0  //6
      x&y: 0 0 1 0  //2

2 <<1 = 4因为<< 1将所有位向左移动:

      x&y: 0 0 1 0  //2
(x&y) <<1: 0 1 0 0  //4

总之,藏匿这一结果,4t

int t = (x & y) <<1;

现在应用按位XOR y^=x

        x: 0 0 1 0  //2
        y: 0 1 1 0  //6
     y^=x: 0 1 0 0  //4

这样x=2, y=4。最后,t+y通过重置x=t并返回while循环的开头进行求和:

x = t;

t=0(或在循环的开始处x=0)完成时

return y;

1
关于为什么我们存放进位的问题已经有了很好的解释,因此我发布了此答案以说明代码是如何工作的。
user1717828 '02

11

出于兴趣,在带有avr-g ++编译器的Atmega328P处理器上,以下代码实现了通过减去-1来加一:

volatile char x;
int main ()
  {
  x = x + 1;  
  }

生成的代码:

00000090 <main>:
volatile char x;
int main ()
  {
  x = x + 1;  
  90:   80 91 00 01     lds r24, 0x0100
  94:   8f 5f           subi    r24, 0xFF   ; 255
  96:   80 93 00 01     sts 0x0100, r24
  }
  9a:   80 e0           ldi r24, 0x00   ; 0
  9c:   90 e0           ldi r25, 0x00   ; 0
  9e:   08 95           ret

特别注意,加法是通过subi指令(从寄存器中减去常数)完成的,在这种情况下,0xFF实际上是-1。

同样令人感兴趣的是,该特定处理器没有addi指令,这意味着设计人员认为编译器编写者可以适当地进行补码的减法处理。

这是否利用了二进制补码或其他与实现相关的功能?

可以公平地说,编译器编写者将尝试以最有效的方式来实现所需的效果(将一个数字添加到另一个数字),以实现特定的体系结构。如果那需要减去补码,那就这样吧。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.