18. 位运算
一、进制、位和字节
现在计算机存储和处理的信息以 二进制 信号表示。这些微不足道的二进制数字,或称为 位(bit),形成了数字革命的基础。计算机的表示法是用有限数量的位来对一个数字编码,因此,当结果太大或以至于不能表示时,某些运算就会溢出(overflow)。
以 2 位基底表示的数字被称为 二进制数(binary number)。用二进制系统可以把任意整数(如果有足够的位)表示为 0 和 1 的组合。通常,1 字节包含 8 位。C 语言用 字节(byte)表示储存系统字符集所需的大小。
在 C 语言中,1 个字节 对应 8 位。我们可以从左到右给这 8 位分别编号为 7 ~ 0。在 1 字节中,编号是 7 的为被称为高阶位(high-order bit),编号是 0 的位被称为低阶位(low-order bit)。每 1 位的编号对应 2 的相应指数。
一个字节能表示的最大整数是把所有的位都设置为 1:11111111。这个二进制对应的十进制是:\(2^{0} + 2^{1} + 2^{2} + 2^{3} + 2^{4} + 2^{5} + 2^{6} + 2^{7} = 1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 = 255\);而一个字节最小的整数是 00000000,其值为 0。
或者,我们可以用最高位表示 符号位,其余位表示值,这样可以表示负数。在二进制中,我们一般用最高位为 0 表示这是一个整数,最高位为 0 表示这是一个负数。但是这种存储方法也是存在问题的。例如,0 有两种表表示的方法:10000000 和 00000000,而且整数和负数加上不能得到正确的答案,例如,\(1 + (-3) = 00000001 + 10000011 = 10000100 = -4\)。
因此,我们采用负数取反的方法来解决整数和负数相加问题。整数的反码还是它本身,负数的反码是符号位不变,其它位取反。因此,1 的反码还是 00000001,-3 的反码为 11111100。此时, $ 1 + (-3) = 00000001 + 11111100 = 11111101 \(。11111101 是一个负数的反码,它对应的原码为 10000010 = -2。此时,我们得到一个正确的答案。但是,整数和负数相加的结果一旦跨零的的话,得到的结果正好差 1,例如:\) 3 + (-1) = 00000011 + 11111110 = 00000001 = 1 $。这是因为 0 在反码中有 2 中表示形式:11111111 和 00000000。
所以,我们在负数的反码上加 1 的方法错开两个 0 的问题。整数的补码还是它本身,负数的补码是它的符号位不变,其它位取反再加 1。例如,之前的 11111111,我们把它减 1,得到 11111110,然后再符号位不变,其它位取反,得到它的原码:10000001。此时,它对应的原码为 -1。此时的 $ 3 + (-1) = 00000011 + 11111111 = 00000010 = 2$。
在一个字节中,我们规定 -128 为 10000000 的补码,它没有原码和反码。因此,在计算机中 1 个字节可以表示数的范围为 -128~127。
二、按位运算符
C 提供按位逻辑运算符和移位运算符。
2.1、按位逻辑运算符
C 语言中提供 4 个按位逻辑运算符,它们都用于整型数据,包括 char 类型。之所以叫按位(bitwise)运算,是因为这些操作都是针对每一个位进行操作,不会影响它左右两边的位。
【1】、按位取反:~
一元运算符 ~ 把 1 变成 0,把 0 变成 1。该运算符会创建一个可以使用或赋值的新值,不会改变原来的值。
~(10100010) = 0101101;
【2】、按位与:&
二元运算符 & 通过逐位比较两个运算对象,生成一个新值。对于每个位,只有两个运算对象中相应的位都为 1 时,结果才为 1(从真/假方面看,只有当两个位都为真时,结果才为真)。
(10010011) & (10011100) = 10010000;
C 语言中有一个按位与和赋值结合的运算符:&=。
value = 3;
value &= 5;
【3】、按位或:|
二元运算符 | 通过逐位比较两个运算对象,生成一个新值。对于每个位,如果两个运算对象中相应的位为 1 时,结果就为 1(从真/假方面看,只有两个运算对象中相应的一个位为真或两个位都为真时,结果才为真)。
(10010011) | (10011100) = 10011111;
C 语言中有一个按位或和赋值结合的运算符:|=。
value = 3;
value |= 5;
【4】、按位异或:^
二元运算符 ^ 通过逐位比较两个运算对象,生成一个新值。对于每个位,如果两个运算对象中相应的位一个为 1(但不是两个都为 1)时,结果为 1(从真/假方面看,如果两个运算对象中相应的一个位为真且不是两个位同为真,结果为真)。简单的来说,相同为 0,不同为 1。
(10010011) ^ (10011100) = 00001111;
C 语言中有一个按位异或和赋值结合的运算符:^=。
value = 3;
value ^= 5;
2.2、移位运算符
【1】、左移:<<
左移运算符(<<)将其左侧运算对象的每一位的值向左移动其右侧运算对象指定的位数。左侧运算对象移出左末端的值丢失,用 0 填充突出的位置。该操作产生了一个新值,但是不会改变其运算对象。
(10010011) << 2 = 01001100;
C 语言中有一个左移和赋值结合的运算符:<<=。
value = 3;
value <<= 2;
【1】、右移:>>
右移运算符(>>)将其左侧运算对象的每一位的值向右移动其右侧运算对象指定的位数。左侧运算对象移出右末端的值丢失。对于无符号类型,用 0 填充空出的位置;对于有符号类型,其结果取决于编译器,空出的位可用 0 填充,或者用符号位(即,最左端的位)的副本填充。该操作产生了一个新值,但是不会改变其运算对象。
(10010011) >> 2 = 00100100; // 0填充
(10010011) >> 3 = 11100100; // 符号位填充
C 语言中有一个右移和赋值结合的运算符:>>=。
value = 3;
value >>= 2;
2.3、按位运算符的用法
【1】、打开位(设置位)
有时需要打开一个值中特定位,同时保持其它位不变。这时,我们可以使用 | 运算符,任何位与 0 组合,结果都为本身,任何位与 1 组合,结果为 1。例如,我们可以通过如下方法将 value 的第 n 位置位。
value |= 1 << n;
【2】、关闭位(清空位)
有时需要关闭一个值中特定位,同时保持其它位不变。这时,我们可以使用 & 运算符,任何位与 1 组合,结果都为本身,任何位与 0 组合,结果为 0。例如,我们可以通过如下方法将 value 的第 n 位置位。
value &= ~1 << n;
【3】、切换位
切换位指的是打开已关闭的位,或关闭已打开的位。可以使用按位异或运算符(^)切换位。也就是说,假设 b 是一个位(1 或 0),如果 b 为 1,则 1^b = 0;如果 b 为 0,则 1^b 为 1。另外,无论 b 为 1 还是 0,0 ^b 均为 0。
【4】、提取位
按位与运算符与移位运算符搭配使用,可以提取数据中某些位。例如,一个 16 位的数据,我们可以通过如下方法提取低 8 位 和高 8 位。
value &= 0xff; // 提取低8位
(value >> 8) &= 0xff; // 提取高8位
三、位段
3.1、什么是位段
操控位的第 2 中方法是 位字段(bit field)。位字段是一个 signed int 或 unsigned int 类型变量中的一组相邻的位(C99 和 C11 新增了 _Bool 类型的位字段)。位字段通过一个结构体声明来建立,该结构体声明为每个字段提供标签,并确定该字段的宽度。
struct 结构体类型名
{
[unsighed] int 字段名1 : 字段长度1;
[unsighed] int 字段名2 : 字段长度2;
...
[unsighed] int 字段名n : 字段长度n;
}
我们可以使用结构体成员运算符(.)单独给这些字段赋值;
3.2、位段的内存分配
- 位段的成员可以是 int、unsigned int、signed int 或 char (属于整型家族)类型;
- 位段的空间是按需要以 4 字节(int)或 1 个字节(char)的方式开辟的;
- 位段涉及很多不确定因素,位段是不跨平台的,注意可移植的程序应该避免使用位段;
#include <stdio.h>
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
int main(void)
{
struct A a;
printf("%zu\n", sizeof(a));
a._c = 125;
printf("%d\n",a._c);
return 0;
}
如果声明的总位数超过一个 unsigned int 类型的大小,会用到下一个 unsighed int 类型的存储位置。一个字段不允许跨越两个 unsigned int 之间的边界。编译器会自动移动跨界的字段,保持 unsigned int 的边界对齐。一旦发生这种情况,第 1 个 unsigned int 中会留下一个未命名的“洞”。我们可以使用未命名的字段宽度 “填充” 未定名的 “洞”。
3.3、位段的跨平台问题
- int 位段被当作有符号数还是无符号数是不确定的;
- 位段中最大位的数目不能确定。(16 位机器最大 16,32 位机器最大 32)
- 位段中的成员在内存中从左到右分配,还是从右到左分配标准尚未定义;
- 如果一个结构体包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的;