第15章 位操作
- 运算符:
~
、&
、|
、^
、<<
、>>
、&=
、|=
、^=
、>>=
、<<=
- 二进制、十进制和十六进制计数法(复习)
- 处理一个值中的位的两个C工具:位运算和位字段
- 关键字:
_Alignas
、_Alignof
15.1 二进制数、位和字节
计算机适用基底为 2 的数制系统。它用 2 的幂而不是 10 的幂。
eg: 二进制数 1101
可表示为:
1 * 2^3 + 1 * 2^2 + 0 * 2^1 + 1 * 2^0
15.1.1 二进制整数
1 字节(byte) 包含 8 位。
这里,128
是 2
的 7
次幂,以此类推。该字节能表示的最大数字是把所有位都设置为 1
:11111111
。这个二进制数的值是:
128 + 64 + 32 + 16 + 8 + 4+ 2 + 1 = 255
而该字节最小的二进制数是 00000000
,其值为 0
。
因此,1
字节可储存 0 ~ 255
范围内的数字,总共 256
个值。
或者,通过不同的方式解释 位组合(bit pattern),程序可以用 1
字节储存 -128 ~ +127
范围内的整数,总共还是 256
个值。
例如,通常 unsigned char
用 1
字节表示的范围是 0 ~ 255
,而 signed char
用 1
字节表示的范围是 -128 ~ +127
。
15.1.2 有符号整数
原码、补码、反码:
原码
如何表示有符号整数取决于硬件,而不是C语言。
也许表示有符号数最简单的方式是用 1
位(如,高阶位)储存符号,只剩下 7
位表示数字本身(假设存储在 1
字节中)。
用这种**符号量(sign-magnitude)**表示法,
10000001
表示 -1
,
00000001
表示 1
。
因此,其表示范围是 -127 ~ +127
。
缺点:这种方法的缺点是有两个 0
:+0
和 -0
。这很容易混淆,而且用两个位组合来表示一个值也有些浪费。
补码
**二进制补码(two’s-complement)**方法避免了这个问题,是当今最常用的系统。
我们将以 1
字节为例,讨论这种方法。
- 方法一:二进制补码用
1
字节中的后7
位表示0 ~ 127
,高阶位设置为0
。目前,这种方法和符号量的方法相同。 - 方法二:另外,如果高阶位是
1
,表示的值为负。 - 上述这两种方法的区别在于如何确定负值。
如何确定负值:从一个 9
位组合 100000000
(256
(2^8) 的二进制形式) 减去一个负数的位组合,结果是该负值的量。
举例:假设一个负值的位组合是 10000000
,
作为一个无符号字节,该组合为表示 128
(2^7);
作为一个有符号值,该组合表示负值(编码是7
的位为1
),而且值为 100000000 - 10000000
,即10000000
,值为 2^8 - 2^7 = 128
。
类似的,
10000001
(129) 是 -127
(即 -(256 - 129) = -127),
11111111
(255) 是 -1
,(即 -(256 - 255) = -1)。
【总结】如何求补码?
第一步:先求该二进制数作为无符号字节时,该组合表示的值 x
;
第二步:用 - (2^8 - x)
即可求出当该二进制数作为有符号值时的负值。
反码
**二进制反码(one’s-complement)**方法通过反转位组合中的每一位形成一个附属。
例如:00000001
是 1
,那么 11111110
是 -1
。
缺点:这种方法也有一个 -0
。该方法能表示 -127 ~ +127
之间的数。
15.1.3 二进制浮点数
十进制小数中,使用 10 的幂作为分母:
eg: 0.527
表示为:
5/10 + 2/100 + 7/1000
二进制小数中,使用 2 的幂作为分母:
eg: .101
表示为:
1/2 + 0/4 + 1/8
,即 0.625
实际上,二进制表示法只能精确地表示多个 1/2
的幂的和。
因此,3/4 和 7/8 可以精确地表示为二进制小数,但是 1/3 和 2/5 却不能。
15.2 其他进制数
15.2.1 八进制
**八进制(octal)**是指八进制记数系统。基于 8
的幂,用 0~7
表示数字。
例如:八进制数 451
(在C中写作 0451
)表示为:
4 * 8^2 + 5 * 8^1 + 1 * 8^0 = 297(十进制)
了解八进制的简单方法是,每个八进制位对应 3
个二进制位。
这种关系使得八进制与二进制之间的转换很容易。
例如:八进制数 0377
的二进制形式是 11111111
。
用 111
代替 0377
中的最后一个 7
,再用 111
代替倒数第 2
个 7
,最后用 011
代替 3
,并社区第1位的 0
。
这表明比 0377
大的八进制要用多个字节表示。
这是八进制唯一不方便的地方:
一个 3 位的八进制数可能要用 9
位二进制数来表示。
表15.1 与八进制位等价的二进制位
八进制位 | 等价的二进制位 |
---|---|
0 | 000 |
1 | 001 |
2 | 010 |
3 | 011 |
4 | 100 |
5 | 101 |
6 | 110 |
7 | 111 |
15.2.2 十六进制
**十六进制(hexadecimal 或 hex)**是指十六进制记数系统。基于 16
的幂,用 0 ~ 15表示,0 ~ 9
表示数字,而 10 ~ 15 用字母 A ~ F
或者小写 a ~ f
来表示。
例如:十六进制数 A3F
(在C中写作 OxA3F
或者 0xa3f
)表示为:
10 * 16^2 + 3 * 16^1 + 15 * 16^0 = 2623(十进制)
每个十六进制为都对应一个 4
位的二进制数(即 4
个二进制位),那么两个十六进制为恰好对应一个 8
位字节。第 1 个十六进制表示前 4 位,第 2 个十六进制位表示后 4 位。因此,十六进制很适合表示字节值。
表 15.2 列出了各进制之间的对应关系。
例如:十六进制值 0xC2
可转换为 11000010
。
相反,二进制值 11010101
可以看做是 1101 0101
,课转换为 0xD5
。
表15.2 十进制、十六进制和等价的二进制
十进制 | 十六进制 | 等价二进制 |
---|---|---|
0 | 0 | 0000 |
1 | 1 | 0001 |
2 | 2 | 0010 |
3 | 3 | 0011 |
4 | 4 | 0100 |
5 | 5 | 0101 |
6 | 6 | 0110 |
7 | 7 | 0111 |
8 | 8 | 1000 |
9 | 9 | 1001 |
10 | A | 1010 |
11 | B | 1011 |
12 | C | 1100 |
13 | D | 1101 |
14 | E | 1110 |
15 | F | 1111 |
15.3 C按位运算符
8
位二进制数,从左往后每位的编号为 7 ~ 0
。
15.3.1 按位逻辑运算符
4 个按位逻辑运算符都用于整型数据,包括 char
。
之所以叫做 **按位(bitwise)**运算,是因为这些操作都是针对每一个位进行,不影响它左右两边的位。
注意:不要把这些按位运算符与常规的逻辑运算符(&&
、||
和 !
)混淆,常规的逻辑运算符操作的是整个值。
1. 二进制反码或按位取反:~
一元运算符 ~
把 1
变为 0
,把 0
变为 1
。
~ (10011010) // 表达式
(01100101) // 结果值
例:假设 val
的类型是 unsigned char
,已被赋值为 2
。
在二进制中,00000010
表示 2
。
那么,~val
的值是 11111101
,即 253
。
newval = ~val;
printf("%d", ~val);
// 把 val 的值改为 ~val:
val = ~val;
2. 按位与:&
二元运算符 &
通过逐位比较两个运算对象,生成一个新值。
对于每个位,只有两个运算对象中相应的位都为 1
时,结果才为 1
。
例:对下面的表达式求值
(10010011) & (00111101) // 表达式
// 由于两个运算对象中编号为 4 和 0 的位都为 1,得:
(00010001) // 结果值
3. 按位或:|
对于每个位,如果两个运算对象中相应的位为 1
,结果就是 1
(只有有一个为真,则结果为真)。
(10010011) | (00111101) // 表达式
// 除了编号为6的位,其他位至少有一个位为 1
(10111111) // 结果值
4. 按位异或:^
对于每个位,如果两个运算对象中相应的位一个为 1
(但不是两个为 1
),结果为 1
。(只有一真一假时,结果才为 1)
(10010011) ^ (00111101) // 表达式
// 编号为 0 的位都是 1,所以结果为 0,得:
(10101110) // 结果值
15.3.2 用法:掩码
按位与运算符常用于 掩码(mask)。所谓掩码指的是一些设置 开(1)
或 关(0)
的位组合。
要明白其为掩码的原因,先来看通过 &
把一个量与掩码结合后发生什么情况。
例如,假设定义符号常量 MASK
为 2
(即,二进制形式为 00000010
),只有1号位是 1
,其他位都是 0
。
flags = flags & MASK;
// 或者写成
flags &= MASK;
把flags
除1号位以外的所有位都设置为 0
,因为使用按位与运算符(&
),任何位与 0
组合都得 0
。
1号位的值不变(如果1号位是 1
,那么 1&1
得 1
;如果1号位是 0
,那么 0&1
也得0
)。这个过程叫做“使用掩码”,因为掩码中的 0
隐藏了 flags
中相应的位。
可以这样类比:把掩码中的 0
看作不透明,1
看作透明。
表达式 flags & MASK
相当于用掩码覆盖在 flags
的位组合上,只有 MASK
为 1
的位才可见。
再举例:
ch &= 0xff; /*或者 ch &= 0377; */
前面介绍过 0xff
的二进制形式是 11111111
,八进制形式是 0377
。
这个掩码保持 ch
中最后 8
位不变,其他位都设置为 0
.
无论 ch
原来是 8
位、16
位或是其他更多位,最终的值都被修改为 1
个 8
位字节。
在该例中,掩码的宽度为 8
位。
15.3.3 用法:打开位(设置位)
有时,需要打开一个值中的特定位,同时保持其他位不变。
例如,一台IBM PC通过向端口发送值来控制硬件。为了打开内置扬声器,必须打开 1 号位,同时保持其他位不变。这种情况可以使用**按位或(|
)**运算符。
以上一节的 flags 和 MASK (只有1号位为 1
)为例。
flags = flags | MASK;
// 或者写成
flags |= MASK;
把 flags
的 1号位设置为 1
,且其他位不变。
因为使用 |
运算符,
- 使用
|
,任何位与0
组合,结果都为本身; - 使用
|
, 任何位与1
组合,结果都为1
。
例如:
假设 flags
是 00001111
,MASK
是 10110110
。下面的表达式:flags | MASK
即是:
(00001111) | (10110110)
// 表达式
其结果为:(10111111)
// 结果值
总结:根据 MASK
中为 1
的位,把 flags
中对应的位设置为 1
,其他位不变。
MASK
中为0
的位,flags
与其对应的位不变。MASK
中为1
的位,flags
与其对应的位也为1
。
15.3.4 用法:关闭位(清空位)
和打开特定的位类似,有时也需要在不影响其他位的情况下关闭指定的位。
假设要关闭变量 flags 的1号位。同样 MASK 只有1号位为 1(即,打开)。可以这样做:
flags = flags & ~MASK;
// 或者写成
flags &= ~MASK;
由于 MAKS
除1号位为 1
以外,其他位全为 0
,
所以 ~MASK
除1号位为 0
以外,其他位全为 1
。
- 使用
&
,任何位与1
组合都得本身; - 使用
&
,任何位与0
组合都得0
。 - 所以无论1号位的初始值是什么,都将其设置为
0
。
例如,假设 flags 是 00001111,MASK 是 10110110。
下面的表达式: flags & ~MASK
即是:(00001111) & ~(10110110)
// 表达式
其结果为:(00001001)
// 结果值
MASK
中为 1
的位在结果中都被设置(清空)为 0
。
flags
中与 MASK
为 0
的位相应的位在结果中都未改变。
15.3.5 用法:切换位
切换位置的是打开已关闭的位,或关闭已打开的位。
可以使用**按位异或(^
)**运算符切换位。
- 假设
b
是一个位(1
或0
),如果b
为1
,则1^b
为0
;如果b
为0
,则1^b
为1
。 - 另外,无论
b
为1
还是0
,0^b
均为b
。
例如,假设 flags 是 00001111, MASK 是 10110110。表达式:
flags ^ MASK
即是:
(00001111) ^ (10110110)
// 表达式
其结果为:
10111001
// 结果值
flags: 00001111
MASK: 10110110
结果: 10111001
总结:flags
中 与 MASK
为 1
的位相对应的位都被切换了,MASK
为 0
的位相对应的位不变。
15.3.6 用法:检查位的值
有时,需要检查某位的值。
例如:flags 中1号位是否被设置为 1?
我们必须覆盖 flags
中的其他位,只用 1 号位
和 MASK
比较:
if ((flags & MASK) == MASK)
puts("Wow!");
由于按位运算符的优先级比 ==
低,所以必须在 flags & MASK
周围加上圆括号。
为了避免信息漏过边界,掩码至少要与覆盖的值宽度相同。
15.3.7 移位运算符
1. 左移: <<
左移运算符(<<
),假设 stonk
为 1
,那么 stonk << 2
为 4
,但是 stonk
本身不变,仍为 1
。
int stonk = 1;
int onkoo;
onkoo = stonk << 2; /* 把 4 赋给 onkoo */
stonk <<= 2; /* 把 sotnk 的值改为 4 */
2. 右移: >>
int sweet = 16;
int ooosw;
ooosw = sweet >> 3; // ooosw = 2,sweet 的值仍然为 16
sweet >>= 3; // sweet 的值为 2
【总结】位移运算
<<
运算
a<<b
表示把a转为二进制后左移 b
位(在后面添加 b
个0
)。例如 100 的二进制表示为 1100100,100 左移 2 位后(后面加 2 个零):1100100<<2 = 110010000 = 400,可以看出,a<<b 的值实际上就是 a 乘以 2 的 b 次方,因为在二进制数后面添加一个 0 就相当该数乘以 2,2 个零即 2 的 2 次方 等于 4。通常认为 a<<1 比 a*2 更快,因为前者是更底层一些的操作。因此程序中乘以 2 的操作尽量用左移一位来代替。
定义一些常量可能会用到 << 运算。你可以方便的用 1<<16 - 1 来表示 65535(unsingned int 最大值16位系统)。很多算法和数据结构要求数据模块必须是 2 的幂,此时就可以用<<
来定义 MAX_N
等常量。
>>
运算
和 <<
相似,a>>b
表示二进制右移 b
位(去掉末 b
位),相当于 a
除以 2
的 b
次方(取整)。我们经常用>>1
来代替 /2
(div 2),比如二分查找、堆的插入操作等等。想办法用 >>
代替除法运算可以使程序的效率大大提高。最大公约数的二进制算法用除以 2 操作来代替慢的出奇的%
(mod)运算,效率可以提高 60%。
3. 用法:移位运算符
移位运算符针对 2 的幂提供快速有效的乘法和除法:
number << n; // number 乘以 2 的 n 次幂
number >> n; // 如果 number 为非负,则用 number 除以 2 的 n 次幂