关于位操作:什么是位移位(bit-shift)运算符以及它们是如何工作的?

What are bitwise shift (bit-shift) operators and how do they work?

我一直在尝试在空闲时间学习C语言,而其他语言(C语言,Java等等)有相同的概念(通常是相同的操作符)…

我想知道的是,在核心层面上,比特移位(<<>>>>>是做什么的,它能帮助解决什么问题,以及在拐弯处潜伏着什么?换言之,一个绝对的初学者指南在所有的好处位移动。


位移运算符的作用与它们的名称完全相同。它们移动比特。以下是对不同轮班操作人员的简要介绍(或不简单)。

算子

  • >>是算术(或有符号)右移运算符。
  • >>>是逻辑(或无符号)右移运算符。
  • <<是左移位运算符,同时满足逻辑移位和算术移位的需要。

所有这些运算符都可以应用于整数值(intlong、可能是shortbytechar。在某些语言中,将移位运算符应用于任何小于int的数据类型都会自动将操作数调整为int的大小。

注意,<<<不是一个运算符,因为它是冗余的。还要注意,C和C++不区分右移运算符。它们只提供>>运算符,并且为签名类型定义了右移位行为。

左移(<)

整数以一系列位的形式存储在内存中。例如,存储为32位int的数字6是:

1
00000000 00000000 00000000 00000110

将该位模式向左移动一个位置(6 << 1将导致数字12:

1
00000000 00000000 00000000 00001100

如您所见,数字向左移动了一个位置,右边的最后一个数字填充了一个零。您还可能注意到向左移位等于乘以2的幂。因此,6 << 1相当于6 * 26 << 3相当于6 * 8。如果可能的话,一个好的优化编译器会用移位替换乘法。

非圆位移

请注意,这些不是循环移位。将该值向左移动一个位置(3,758,096,384 << 1

1
11100000 00000000 00000000 00000000

结果3221225472:

1
11000000 00000000 00000000 00000000

"偏离末尾"的数字丢失。它不缠绕。

逻辑右移(>>>)

逻辑上的右移位与左移位相反。它们不是向左移动位,而是向右移动。例如,移动数字12:

1
00000000 00000000 00000000 00001100

右边的一个位置(12 >>> 1号)将取回我们原来的6:

1
00000000 00000000 00000000 00000110

所以我们看到右移相当于2的除法。

丢失的比特不见了

但是,移位不能回收"丢失"的位。例如,如果我们改变这个模式:

1
00111000 00000000 00000000 00000110

在左边4个位置(939,524,102 << 4处),我们得到2147483744:

1
10000000 00000000 00000000 01100000

然后返回((939,524,102 << 4) >>> 4)我们得到134217734:

1
00001000 00000000 00000000 00000110

一旦丢失位,我们就无法恢复原始值。

算术右移(>>)

算术右移与逻辑右移完全相同,除了用零填充之外,它用最有效的位填充。这是因为最有效的位是符号位,或者是区分正数和负数的位。通过填充最重要的位,算术右移是保留符号的。

例如,如果我们将此位模式解释为负数:

1
10000000 00000000 00000000 01100000

我们有号码2147483552。用算术移位将其右移4个位置(-2147483552>>4),可以得到:

1
11111000 00000000 00000000 00000110

或者号码-134217722。

所以我们看到,我们使用算术右移而不是逻辑右移来保留负数的符号。再一次,我们看到我们正在执行2的除法。


假设我们只有一个字节:

1
0110110

应用一个左移位可以得到:

1
1101100

最左边的零被移出字节,新的零被附加到字节的右端。

钻头不会滚动,它们会被丢弃。这意味着如果你左移1101100然后右移它,你就不会得到同样的结果了。

向左移动n等于乘以2n。

右移n等于(如果使用的是一个补数)除以2n,四舍五入为零。

如果你使用的是2的幂,移位可以用于疯狂的快速乘法和除法。几乎所有低级图形例程都使用位移位。

例如,回到过去,我们在游戏中使用了13H模式(320x20256色)。在模式13h中,视频存储器按像素顺序排列。这意味着要计算像素的位置,您将使用以下数学公式:

1
memoryOffset = (row * 320) + column

现在,回到那个时代,速度是至关重要的,所以我们将使用位移来执行这个操作。

然而,320不是二的幂,因此要绕过这个问题,我们必须找出二的幂加起来构成320:

1
(row * 320) = (row * 256) + (row * 64)

现在我们可以把它转换成左移位:

1
(row * 320) = (row << 8) + (row << 6)

最终结果如下:

1
memoryOffset = ((row << 8) + (row << 6)) + column

现在我们得到了与以前相同的偏移量,除了一个昂贵的乘法运算之外,我们使用了两个位移位……在x86中,它是这样的(注意,自从我做了汇编(编辑的注释:更正了几个错误并添加了一个32位的示例)以来,它一直是这样的:

1
2
3
4
5
6
7
8
mov ax, 320; 2 cycles
mul word [row]; 22 CPU Cycles
mov di,ax; 2 cycles
add di, [column]; 2 cycles
; di = [row]*320 + [column]

; 16-bit addressing mode limitations:
; [di] is a valid addressing mode, but [ax] isn't, otherwise we could skip the last mov

总共:28个周期在任何古老的CPU有这些时间。

虚拟现实

1
2
3
4
5
6
7
mov ax, [row]; 2 cycles
mov di, ax; 2
shl ax, 6;  2
shl di, 8;  2
add di, ax; 2    (320 = 256+64)
add di, [column]; 2
; di = [row]*(256+64) + [column]

在同一个古老的CPU上运行12个周期。

是的,我们会努力减少16个CPU周期。

在32位或64位模式下,两种版本都会变得更短、更快。像IntelSkylake这样的现代无序执行CPU(见http://agner.org/optimize/)具有非常快的硬件倍增(低延迟和高吞吐量),因此增益要小得多。AMD推土机系列有点慢,尤其是64位乘法。在Intel CPU和AMD Ryzen上,两个移位的延迟稍低,但指令比乘法多(这可能导致吞吐量降低):

1
2
3
imul edi, [row], 320    ; 3 cycle latency from [row] being ready
add  edi, [column]      ; 1 cycle latency (from [column] and edi being ready).
; edi = [row]*(256+64) + [column],  in 4 cycles from [row] being ready.

VS

1
2
3
4
5
mov edi, [row]
shl edi, 6               ; row*64.   1 cycle latency
lea edi, [edi + edi*4]   ; row*(64 + 64*4).  1 cycle latency
add edi, [column]        ; 1 cycle latency from edi and [column] both being ready
; edi = [row]*(256+64) + [column],  in 3 cycles from [row] being ready.

编译器将为您做到这一点:请参阅gcc、clang和msvc在优化return 320*row + col;时如何使用shift+lea。

这里要注意的最有趣的一点是,x86有一个移位和加法指令(LEA),它可以同时执行小的左移位和加法,并且具有as和add指令的性能。ARM更强大:任何指令的一个操作数都可以自由地左移或右移。因此,通过一个已知为-2次方的编译时间常数进行缩放比乘法更有效。

好吧,回到现代…现在更有用的方法是使用位移位将两个8位值存储在一个16位整数中。例如,在c:

1
2
3
4
5
6
// Byte1: 11110000
// Byte2: 00001111

Int16 value = ((byte)(Byte1 >> 8) | Byte2));

// value = 000011111110000;

在C++中,编译器使用了两个8位成员来使用EDCOX1(3),但实际上并不总是这样。


位操作,包括位移位,是底层硬件或嵌入式编程的基础。如果您阅读了设备的规范,甚至是一些二进制文件格式,您将看到字节、字和双字,这些字节、字和双字被分解成非字节对齐的位字段,其中包含各种感兴趣的值。访问这些位字段进行读/写是最常见的用法。

图形编程中的一个简单实例是16位像素表示如下:

1
2
  bit | 15| 14| 13| 12| 11| 10| 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1  | 0 |
      |       Blue        |         Green         |       Red          |

要获得绿色值,您可以这样做:

1
2
3
4
5
 #define GREEN_MASK  0x7E0
 #define GREEN_OFFSET  5

 // Read green
 uint16_t green = (pixel & GREEN_MASK) >> GREEN_OFFSET;

解释

为了仅获得绿色值,即从偏移量5开始到10结束(即6位长),需要使用(位)遮罩,当对整个16位像素应用时,它将仅产生我们感兴趣的位。

1
#define GREEN_MASK  0x7E0

适当的掩码是0x7e0,二进制为0000011111100000(十进制为2016)。

1
uint16_t green = (pixel & GREEN_MASK) ...;

要应用遮罩,请使用和运算符(&;)。

1
uint16_t green = (pixel & GREEN_MASK) >> GREEN_OFFSET;

在应用了掩码之后,您将得到一个16位的数字,它实际上只是一个11位的数字,因为它的最大有效位在第11位。绿色实际上只有6位长,因此我们需要使用右移(11-6=5)将其缩小,因此使用5作为偏移(#define GREEN_OFFSET 5)。

另一个常见的方法是使用位移位来快速乘除2的幂:

1
2
 i <<= x;  // i *= 2^x;
 i >>= y;  // i /= 2^y;


位屏蔽和移位

在低阶图形编程中经常使用位移。例如,以32位字编码的给定像素颜色值。

1
2
 Pixel-Color Value in Hex:    B9B9B900
 Pixel-Color Value in Binary: 10111001  10111001  10111001  00000000

为了更好地理解,用什么部分代表什么颜色部分标记的相同二进制值。

1
2
                                 Red     Green     Blue       Alpha
 Pixel-Color Value in Binary: 10111001  10111001  10111001  00000000

例如,我们想要得到这个像素颜色的绿色值。我们可以很容易地通过掩蔽和转移来获得这个价值。

我们的面具:

1
2
3
4
5
6
7
                  Red      Green      Blue      Alpha
 color :        10111001  10111001  10111001  00000000
 green_mask  :  00000000  11111111  00000000  00000000

 masked_color = color & green_mask

 masked_color:  00000000  10111001  00000000  00000000

逻辑&运算符确保只保留掩码为1的值。我们现在要做的最后一件事是,将所有这些位右移16位(逻辑右移),得到正确的整数值。

1
 green_value = masked_color >>> 16

等等,我们有一个整数,用像素颜色表示绿色的数量:

1
2
3
 Pixels-Green Value in Hex:     000000B9
 Pixels-Green Value in Binary:  00000000 00000000 00000000 10111001
 Pixels-Green Value in Decimal: 185

这通常用于编码或解码图像格式,如jpgpng...


其中一个问题是,以下内容依赖于实现(根据ANSI标准):

1
2
char x = -1;
x >> 1;

X现在可以是127(0111111)或静止-1(11111111)。

实际上,通常是后者。


我只写提示和技巧,可能在考试中有用。

  • n = n*2n = n<<1
  • n = n/2n = n>>1
  • 检查n是否为2的幂(1,2,4,8,…):检查!(n & (n-1))
  • 获取n的xth位:n |= (1 << x)
  • 检查x是偶数还是奇数:x&1 == 0(偶数)
  • 切换x:x ^ (1<的第n位

  • 注意,在Java实现中,要移位的位的数量取决于源的大小。

    例如:

    1
    (long) 4 >> 65

    等于2。您可能期望将位右移65倍将使所有内容归零,但实际上相当于:

    1
    (long) 4 >> (65 % 64)

    这对于<,>>,and>>是正确的。我没有用其他语言试过。


    请注意,Windows平台上只有32位版本的PHP可用。

    例如,如果移位<<或>>超过31位,则结果是不可预测的。通常返回原始数字而不是零,这可能是一个非常棘手的错误。

    当然,如果使用64位版本的PHP(Unix),则应避免移动超过63位。但是,例如,MySQL使用64位bigint,因此不应该存在任何兼容性问题。

    更新:从php7窗口,php构建最终能够使用完整的64位整数:整数的大小取决于平台,尽管最大值约为20亿是通常的值(即32位有符号)。64位平台的最大值通常约为9e18,除了在php 7之前的Windows上,该值始终为32位。