许多初学者对 《《(左移)和 》》(右移)运算符在 C/C++ 等编程语言中的工作方式感到困惑。在本专栏中,所有(嗯,相当多)都将被揭示,但在我们满怀热情地投入战斗之前,我们首先需要确保我们都了解一些基本概念。
位、半字节和字节
可以在计算机内部存储和操作的最小数据量是二进制数字或位,它可用于存储两个不同的值:0 或 1。这些值在任何特定情况下实际体现的内容时间取决于我们。例如,我们可能决定一位代表开关的状态(例如,向下或向上)或灯(例如,关闭或打开)或逻辑值(例如,假或真)。或者,我们可能决定使用我们的位来表示数值 0(零)或 1(一)。
只是为了增加乐趣和轻浮性,我们可以随时更改我们希望我们的位代表的内容。在程序的一部分中,我们可以将位视为表示一个逻辑值;稍后,我们可能会决定将同一位视为体现一个数字量。电脑不在乎。它所看到的只是 0 或 1。它不知道我们在任何特定时间使用 0 或 1 来表示什么。
我们只能用一个单独的部分做很多事情。因此,计算机内部的数据通常使用比特组进行存储和操作。常见的分组有 4 位、8 位、16 位、32 位和 64 位。一组 8 位称为byte,而一组 4 位称为nybble(或nibble)。“两个 nybbles 组成一个字节”的想法是一个工程笑话,从而同时证明 (a) 工程师确实有幽默感和 (b) 他们的幽默不是很复杂。
已经零星地尝试采用其他大小的位组的术语。例如,tayste(或crumb)用于 2 位组;playte(或chawmp)用于 16 位组;32 位组的dynner(或gawble );和table用于 64 位组。但是到目前为止,您只能开个玩笑,因此使用标准术语byte和nybble(或nibble)以外的任何内容都极为罕见。
字节、字符和整数
在尝试解释与计算机相关的主题时遇到的问题之一是,您经常会陷入“鸡或蛋”的境地,理想情况下,您需要理解概念 A 才能理解概念 B ,但是您确实需要熟悉概念 B 才能将您的大脑包裹在概念 A 上(有一个古老的编程笑话说:“要理解递归,必须先了解递归”)。
我们只是说,稍后我们将介绍无符号二进制数的概念。稍后,我们将介绍有符号二进制数的概念。关键是》》(右移)运算符执行其魔法的方式可能取决于我们是否告诉计算机将其正在操作的值视为有符号或无符号。
C/C++ 中两种常用的数据类型是 8 位char(字符)和int(整数)。Arduino IDE/编译器也支持 8-bit byte,但 ANSI-C 标准不支持这种类型。在 Arduino 草图中使用这些类型的示例变量声明如下:
byte myByte = 65;
char myChar = ‘A’;
int myInt = 65;
请注意,在 char 类型的情况下,字符在计算机内部使用ASCII 标准存储为数字。在 ASCII 中,数字 65 代表大写“A”,因此“myChar = ‘A’;” 和“myChar = 65;” 两者都会以包含数字 65 的变量 myChar 结束。
不幸的是,int 的大小是未定义的,并且因一台计算机而异。例如,对于 Arduino,int 是 16 位宽,但在另一种类型的计算机上可能是 16、32 或 64 位宽。
请记住,我们将在下面解释有符号和无符号二进制数之间的区别。然而,当我们在这里时,我们应该注意,一个字节将被 Arduino IDE 的编译器视为未签名,而一个 int 将被视为由任何 C/C++ 编译器签名。只是为了咯咯笑和笑,C/C++ 标准允许将 char 类型视为有符号或无符号,具体取决于平台和编译器。
十进制数和约定
十进制(以 10 为底)数字系统由十位数字组成——0、1、2、3、4、5、6、7、8 和 9——并且是一个位值系统。这意味着十进制数中的每一列都有一个与之关联的“权重”,而一个数字的值取决于它所在的列。
如果我们取一个像 362 这样的数字,那么右边的一列代表 1(个),左边的下一列代表 10(十),下一列代表 100(百),依此类推。 因此,当我们看到 362 时,我们将其理解为代表三个百、六个十和两个一。
另外,当我们用十进制写一个数字时,我们可能会在它后面加上一个符号来表示它是负数还是正数;例如,–42 和 +42。按照惯例,没有符号的数字(例如 42)被理解为正数。
无符号二进制数
二进制(以 2 为底)数系统仅包含两个数字,0 和 1。让我们考虑一个包含 0 和 1 的随机模式的 8 位二进制字段,例如 11001010。这种位模式的含义是什么我们决定它是。例如,每个位都可以表示现实世界中相关灯的逻辑状态,其中 0 表示关闭的灯,而 1 表示打开的灯,反之亦然。
或者,我们可以使用我们的 8 位字段来表示一个数值。正如我们之前提到的,我们将在本专栏中考虑的两种格式称为无符号和有符号二进制数。让我们从无符号品种开始。顾名思义,我们知道无符号二进制数没有符号,这意味着它们只能用于表示正值。
在无符号二进制数的情况下,右列表示 1,下一列表示 2,下一列表示 4,下一列表示 8,依此类推。还值得注意的是,在 8 位二进制字段的情况下,我们将位编号从 0 到 7,其中位 0 称为最低有效位 (LSB),位 7 称为最高有效位位(MSB)。
因此,二进制值 11001010 将等于 (1 × 128) + (1 × 64) + (0 × 32) + (0 × 16) + (1 × 8) + (0 × 4) + (1 × 2 ) + (0 × 1) = 202 十进制。当然,当你习惯了这一点时,你会跳过繁琐的东西,简单地说:“二进制的 11001010 等于 128 加 64 加 16 加 2 等于十进制的 202。”
由于我们目前讨论的是 8 位无符号二进制数,这意味着我们可以存储 2 8 = 2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 = 256 个不同的 0 和 1 模式,我们可以用于表示 0 到 255 范围内的正十进制值。
请注意,没有下标的数字被假定为十进制。当引用其他基数中的数字时,我们通常使用下标来反映基数(例如,11001010 2表示二进制/base-2 值),除非它在另一个基数中的事实从上下文中是显而易见的,例如说明它是文本中的二进制值。
另请注意,在写入二进制值时,我们通常显示前导零 (0) 以反映计算机内关联数据类型或存储位置的大小。例如,如果我们看到二进制值 00001010,那么显示四个前导 0 会立即通知我们正在使用 8 位值。
对无符号二进制数使用 《《(左移)运算符
正如我们之前所讨论的,int类型的大小是未定义的,并且因一台计算机而异。它的unsigned int对应物也是如此。因为这会导致问题,现代 C/C++ 编译器支持类型uint8_t、uint16_t、uint32_t和uint64_t,它们允许我们分别声明宽度正好为 8、16、32 和 64 位的无符号整数变量。例如:
uint8_t myUintA = B00011000;
uint8_t myUintB;
这声明了两个名为 myUintA 和 myUintB 的无符号整数,它们的宽度正好是 8 位。此外,在 myUintA 的情况下,我们还为其分配了一个 8 位二进制值 00011000,这等于十进制的 (1 × 16) + (1 × 8) = 24(我们也可以使用“myUintA = 24 ;”以十进制分配值,或“myUintA = 0×18;”以十六进制分配值)。
现在假设我们执行以下操作:
myUintB = myUintA 《《 1;
这将在 myUintA 中获取我们原来的 00011000 值,将其向左移动一位,并将结果存储在 myUintB 中。作为其中的一部分,它将一个新的 0 移到最右边的列中。同时,最左边的位将“掉到最后”并被丢弃。。
当然,所有这些动作都是在计算机内部同时进行的。我们只是以这种方式将其拆分,以便我们更容易想象正在发生的事情。
观察我们得到的二进制值 00110000 等于十进制的 (1 × 32) + (1 × 16) = 48。因为我们原始的二进制值 00011000 等于十进制的 24,这意味着将其向左移动一位与将其乘以 2 相同。
事实上,向左的每一个位移都等于乘以 2。例如,记住 myUintA 仍然包含 00011000,考虑当我们执行以下操作时会发生什么:
myUintB = myUintA 《《 2;
这将采用我们原来的 00011000 值并将其向左移动两位。作为其中的一部分,它将两个0 移到最右边的列中,而最左边的两个位将“掉到最后”并被丢弃。再一次,我们可以将这个序列形象化如下:
在这种情况下,我们得到的二进制值 01100000 等于十进制的 (1 × 64) + (1 × 32) = 96。因为我们的原始二进制值 00011000 等于十进制的 24,这意味着将其向左移动两位与将其乘以四(2 × 2 = 4)相同。
同样,执行“myUintB = myUintA 《《 3;”的操作 将我们的初始值 00011000 向左移动三位,得到 11000000,相当于十进制的 192。这意味着将我们的原始值向左移动三位与将其乘以八(2 × 2 × 2 = 8)相同。
当然,当我们开始将 1 移到值的末尾时,就会出现问题。例如,“myUintB = myUintA 《《 4;” 会将我们的初始值 00011000 向左移动四位,得到 10000000,相当于十进制的 128。虽然这是一个完全合法的操作,但我们必须知道 128 不等于 24 × 16。如果这对我们来说是个问题,那么解决方案是将 myUintA 和 myUintB 声明为 uint16_t 或更大的类型。
对无符号二进制数使用 》》(右移)运算符
假设我们像以前一样声明了 myUintA 和 myUintB 变量,但这一次,我们执行以下操作:
myUintB = myUintA 》》 1;
这将采用我们原来的 00011000 值并将其向右移动一位。作为其中的一部分,它将一个新的 0 移到最左边的列中。同时,最右边的位将“从末端脱落”并被丢弃。
在这种情况下,我们得到的二进制值 00001100 等于十进制的 (1 × 8) + (1 × 4) = 12。因为我们最初的二进制值 00011000 等于十进制的 24,这意味着将其向右移动一位与将其除以 2 相同。
事实上,每一次右移就等于除以二。例如,使用“myUintB = myUintA 》》 2;”的操作 将我们的初始值 00011000 向右移动两位,得到 00000110,相当于十进制的 6。这意味着将我们的原始值向右移动两位与将其除以四相同。
同样,使用“myUintB = myUintA 》》 3;” 将我们的初始值 00011000 向右移动三位,得到 00000011,相当于十进制的 3。这意味着将我们的原始值向右移动三位与将其除以八相同。
毫不奇怪,当我们开始将 1 移到末尾时,就会出现问题。例如,“myUintB = myUintA 》》 4;” 将我们的初始值 00011000 向右移动四位,得到 00000001,相当于十进制的 1。再一次,虽然这是一个完全合法的操作,但我们必须知道 1 不等于 24 除以 16……或者是吗?事实上,如果我们丢弃(截断)任何余数,24 除以 16 确实等于 1,这实际上就是我们在这里所做的。
有符号二进制数
在有符号二进制数的情况下,我们使用 MSB 来表示数字的符号。其实比这复杂一点,因为我们也用这个位来表示一个量。
请注意,在这种情况下,第 7 位表示 –128s 列(与其无符号对应项中的 +128s 列相反)。同时,其余位继续表示与以前相同的正值。
因此,二进制值 00011000 仍然等于十进制的 24;即 (0 × –128) + (0 × 64) + (0 × 32) + (1 × 16) + (1 × 8) + (0 × 4) + (0 × 2) + (0 × 1 ) = 24。但是,二进制值 11001010 以前等于无符号形式的十进制 202,现在等于 (1 x –128) + (1 × 64) + (0 × 32) + (0 × 16 ) + (1 × 8) + (0 × 4) + (1 × 2) + (0 × 1) = –128 + 74 = –54 十进制。
和以前一样,因为我们目前讨论的是 8 位二进制字段,所以我们可以存储 2 8 = 2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 = 256 个不同的 0 和 1 模式。在有符号二进制数的情况下,我们可以使用这些模式来表示 –128 到 127 范围内的十进制值。
这种表示形式称为二进制补码。虽然一开始可能有点令人困惑,但这种格式在在计算机内部创建算术逻辑函数方面提供了巨大的优势(我们将在我们的“从头开始构建 4 位计算机”中更详细地讨论这些概念》系列文章)。
对有符号二进制数使用《《(左移)运算符int8_t、int16_t、int32_t和int64_t数据类型允许我们分别声明宽度正好为 8、16、32 和 64 位的有符号整数变量(Arduino int数据类型等价于int16_t类型)。
例如:
int8_t myIntA = B00011000;
int8_t myIntB;
这声明了两个名为 myIntA 和 myIntB 的有符号整数,它们的宽度正好为 8 位。此外,在 myIntA 的情况下,我们还为其分配了一个 8 位二进制值 00011000,它等于十进制的 (1 × 16) + (1 × 8) = 24。
现在假设我们执行以下操作:
myIntB = myIntA 《《 1;
和以前一样,这将在 myIntA 中获取我们原来的 00011000 值,将其向左移动一位,并将结果存储在 myIntB 中。作为其中的一部分,它将一个新的 0 移到最右边的列中。同时,最左边的位将“掉到最后”并被丢弃。我们可以将这个序列形象化如下:
再一次,所有这些动作都在计算机内部同时发生。我们只是以这种方式将其拆分,以便我们更容易想象正在发生的事情。再一次,我们得到的二进制值 00110000 等于十进制的 48。因为我们原始的二进制值 00011000 等于十进制的 24,这意味着将其向左移动一位与将其乘以 2 相同。
负数呢?假设我们存储在 myIntA 中的原始二进制值是 11100101,它等于 –128 + 64 + 32 + 4 + 1 = –27。如下图所示,执行“myIntB = myIntA 《《 1;”的操作 将我们的初始值 11100101 向左移动一位,得到 11001010,这相当于十进制的 –128 + 64 + 8 + 2 = –54。
因为 –54 = –27 × 2,这意味着将带负号的二进制数左移一位与将其乘以 2 相同。
同样,假设初始值为11100101,执行“myUintB = myUintA 《《 2;”的操作 将产生 10010100,相当于十进制的 –108。这意味着将我们的原始值向左移动两位与将其乘以四相同。
在这种情况下,只有将符号位的值从 0 翻转到 1 时才会开始出现问题,反之亦然。这包括任何中间“翻转”;例如,将 10111111(十进制的 –65)向左移动两位会得到 11111100(十进制的 –4)。虽然符号位没有改变(它仍然是 1),因为我们可以将 0 想象为通过它,所以结果在数学上是不正确的,因为 –65 × 4 不会导致 –4。
需要注意的是,上述结果本身并不是无效的。计算机只是在做我们告诉它做的事情,我们告诉它使用的 8 位有符号二进制数不足以容纳结果,这不是可怜的小流氓的错。假设我们使用了 int16_t 数据类型。在这种情况下,我们的起始值应该是 1111111110111111,它仍然等于十进制的 –65。将这个 16 位值向左移动两位得到 1111111011111100,相当于 –260,这是我们期望看到的。
对有符号二进制数使用 》》(右移)运算符
这就是事情开始变得有点棘手的地方,所以请坐起来,深呼吸,并注意。早些时候,当我们对无符号二进制数执行左移或右移操作时,这些操作称为逻辑移位。在逻辑左移的情况下,我们将 0(零)移到 LSB;在逻辑右移的情况下,我们将 0 移入 MSB。
相比之下,当我们对有符号二进制数执行左移或右移时,这些被称为算术移位。在算术左移的情况下,我们将 0(零)移到 LSB,这意味着算术左移的工作方式与逻辑左移相同。当我们执行算术右移时,棘手的部分就来了。在这种情况下,我们并不总是将 0 移入 MSB。相反,我们将原始符号位的副本转移到 MSB 中。
让我们从之前使用过的示例位模式开始。假设 myIntA 包含一个正符号二进制值 00011000,相当于十进制的 24。观察 MSB(最左边的位,即符号位)为 0。现在让我们执行操作“myintB = myIntA 》》 1;”。
正如预期的那样,我们得到的二进制值 00001100 等于十进制的 (1 × 8) + (1 × 4) = 12。因为我们最初的二进制值 00011000 等于十进制的 24,这意味着将其向右移动一位与将其除以 2 相同。
现在假设我们从包含负符号二进制数的 myIntA 开始,例如 10110000。观察 MSB(最左边的位,即符号位)为 1,因此该值等于 –128 + 32 + 16 = –80 十进制。现在让我们执行“myintB = myIntA 》》 1;”。
在这种情况下,因为我们将原始符号位的副本(即 1)移至 MSB,所以我们得到的二进制值 11011000 等于十进制的 –128 + 64 + 16 + 8 = –40。此外,因为我们最初的二进制值 10110000 等于十进制的 –80,这意味着将这个负值向右移动一位,正如我们所期望的那样,与将其除以 2 相同。
这里要注意的重要一点是,符号位将被复制到右移操作所需的尽可能多的位中。例如,如果我们从包含 10110000 的 myIntA 开始并执行“myintB = myIntA 》》 3;”操作。
在这种情况下,因为我们将原始符号位的副本(即 1)移到三个 MSB 中,所以我们得到的二进制值 11110110 等于十进制的 –128 + 64 + 32 + 16 + 4 + 2 = –10。因为我们最初的二进制值 10110000 等于十进制的 –80,这意味着将这个负值向右移动三位,正如我们所期望的那样,与将其除以八(万岁)相同。
谨防!根据官方 C 标准,右移有符号二进制数会产生未定义的行为。上面描述的带符号二进制数右移的行为是大多数编译器供应商实现的方式,但不能保证!这就是为什么大多数标准(例如 MISRA-C)添加的规则本质上是说“位移有符号二进制数是禁忌”,因为假设符号将被保留,可以在一个编译器上创建代码,只是为了将您的代码移动到另一个编译器以发现它不是。