第十五章 位操作

GitHub地址,欢迎 star

15.1 二进制数、位和字节

通常是基于数字 10 来书写数字。例如 2157 的千位是 2,百位是 1,十位是 5,个位是 7,可以写成:2 x 1000 + 1 x 100 + 5 x 10 + 7 x 1

注意 1000 是 10 的立方(即 3 次幂),100 是 10 的平方(即 2 次幂),10 是 10 的 1 次幂,而且 10(以及任意正数)的 0 次幂都是 1.因此,2157 也可以写成: 2 x 103 + 1 x 102 + 5 x 10 1 + 7 x 100

因为这种书写数字的方法是基于 10 的幂,所以称为以 10 为基底书写 2157。

15.1.1 二进制

通常,1 字节包含 8 位。C 语言用字节(byte)表示储存系统字符集所需的大小,所以 C 字节可能是 8 位、9 位、16 位或其他值。不过,描述存储器芯片和数据传输率中所用的字节指的是 8 为字节。为了简化起见,本章假设 1 字节是 8 位(计算机界通常用八位组(octet)这个术语特指 8 位字节)。可以从左往右给这 8 位分别编号为 7 ~ 0。在 1 字节中,编号是 7 的位被称为高阶位(high-order bit),编号是 0 的位被称为低阶位(low-order bit)。每 1 位的编号对应 2 的相应指数。因此,可以根据图所示的例子理解字节。
在这里插入图片描述
这里,128 是 2 的 7 次幂,以此类推。该字节能表示的最大数字时把所有位都设置为1:11111111。这个二进制数的值是:128 + 65 + 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,表示的值为负。这两种方法的区别在于如何确定负值。从一个 0 位组合 100000000(256 的二进制形式)减去一个负数的位组合,结果是该负值的量。例如,假设一个负值的位组合是 10000000,作为一个无符号字节,该组合为表示 128:作为一个有符号值,该组合表示负值(编码是 7 的位为 1),而且值为 100000000 - 10000000,即 1000000(128)。因此,该数是 -128(在符号量表示法中,该位组合表示 -0)。类似地,10000001 是 -127,11111111 是 -1。该方法可以表示 -128 ~ +127 范围内的数。

要得到一个二进制补码数的相反数,最简单的方法是反转每一位(即 0 变 1,1 变为 0),然后加 1.因为 1 是 00000001,那么 -1 则是 11111110 + 1,或 11111111。这与上面的介绍一致。

二进制反码(one’s-complement)方法通过反转位组合中的每一位形成一个负数。例如,00000001 是 1,那么 11111110 是 -1。这种方法也有一个 -0:11111111。该方法可以表示 -128 ~ +127 范围内的数。

15.1.3 二进制浮点数

浮点数分两部分储存:二进制小数和二进制指数。

1、二进制小数

一个普通的浮点数 0.527,表示如下: 5/10 + 2/100 + 7/1000

从左往右,各分母都是 10 的递增次幂。在二进制小数中,使用 2 的幂作为分母,所以二进制小数,101 表示为:1/2 + 0/4 + 1/8 用十进制表示法为:0.50 + 0.00 + 0.125 即是 0.625。

许多分数(如,1/3)不能用十进制表示法精确地表示。与此类似,许多分数也不能用二进制表示法准确地表示。实际上,二进制表示法只能精确地表示多个 1/2 的幂的和。因此,3/4 和 7/8 可以精确地表示为二进制小数,但是 1/3 和 2/5 却不能。

2、浮点数表示法

为了在计算机中表示一个浮点数,要留出若干位(因系统而异)储存二进制分数,其他位储存指数。一般而言,数字的实际值有二进制小数乘以 2 的指定次幂组成。例如,一个浮点数乘以 4,那么二进制小数不变,其指数乘以 2,二进制分数不变。如果一份浮点数乘以一个不是 2 的幂的数,会改变二进制小数部分,如有必要,也会改变指数部分。

15.2 其他进制数

计算机界通常使用八进制记数系统和十六进制记数系统。因为 8 和 16 都是 2 的幂,这些系统比十进制系统更接近计算机的二进制系统。

15.2.1 八进制

八进制(octal)是指八进制记数系统。该系统基于 8 的幂,用 0 ~ 7 表示数字。例如,八进制数 451(在 C 中写作 0451)表示为:4 x 82 + 5 x 81 + 1 x 80 = 297(十进制)

了解八进制的一个简单的方法是,每个八进制位对应 3 个二进制位。表列出了这种对应关系。这种关系使得八进制与二进制之间的转换很容易。例如,八进制数 0377 的二进制形式是 11111111。即,用 111 代替 0377 中的最后一个 7,再用 111 代替倒数第 2 个 7,最后用 011 代替 3,并舍去第 1 位的 0。这表明比 0377 大的八进制要用多个字节表示。这是八进制唯一不方便的地方:一个 3 位八进制数可能要用 9 为二进制数来表示。注意,将八进制数转换为二进制形式时,不能去掉中间的 0.例如,八进制数 0173 的二进制形式是 01111011,不是 0111111.

八进制位等价的二进制位八进制位等价的二进制位
00004100
10015101
20106110
30117111

15.2.2 十六进制

十六进制(hexadecimal 或 hex)是指十六进制记数系统。该系统基于 16 的幂,用 0 ~ 15 表示数字。但是,由于没有单独的数(digit,即 0 ~ 9 这样单独一位的数)表示 10 ~ 15,所以用字母 A ~ F 来表示。例如,十六进制数 A3F(在 C 中写作 0xA3F)表示为: 10 x 162 + 3 x 161 + 15 x 160 = 2623(十进制)

由于 A 表示 10,F 表示 15。在 C 语言中,A ~ F 既可用小写也可用大写。因此,2623 也可写作 0xa3f。

每个十六进制位都对应一个 4 位的二进制数(即 4 个二进制位),那么两个十六进制位恰好对应一个 8 为字节。第 1 个十六进制表示前 4 为,第 2 个十六进制位表示后 4 位。因此,十六进制很适合表示字节值。

表列出了各进制之间的对应关系。例如,十六进制值 0xC2 可转换为 11000010。相反,二进制值 11010101 可以看作是 11010101,可转换为 0xD5.

十进制十六进制等价二进制十进制十六进制等价二进制
000000881000
110001991001
22001010A1010
33001111B1011
44010012C1100
55010113D1101
66011014E1110
77011115F1111

介绍了位和字节的相关内容,接下来我们研究 C 用位和字节进行哪些操作。C 有两个操控位的工具。第 1 个工具是一套(6 个)作用于位的按位运算符。第 2 个工具是字段(field)数据形式,用于访问 int 中的位。

15.3 C 按位运算符

C 提供按位逻辑运算符和移位运算符。在下面的例子中,为了方便读者了解位的操作,我们用二进制记数法写出值。但是在实际的程序中不必这样,用一般形式的整型变量或常量即可。例如,在程序中用 25 或 031 或 0x19,而不是 00011001。另外,下面的例子均使用 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。注意,该运算符不会改变 val 的值,就像 3 * val 不会改变 val 的值一样,val 仍然是 2。但是,该运算符确实创建了一个可以使用或赋值的新值:

newval = ~val;
printf("%d",~val);

如果要把 val 的值改为 ~val,使用下面这条语句:val = ~val;

2、按位与:&
二元运算符 & 通过逐位比较两个运算对象,生成一个新值。对于每个位,只有两个运算对象中相应的位都为 1 时,结果才为 1(从真 / 假方面看,只有当两个位都为真时,结果才为真)。因此,对下面的表达式求值:(10010011) & (00111101) // 表达式

由于两个运算符对象中编号为 4 和 0 的位都为 1,得:(00010001) // 结果值

C 有一个按位与和赋值结合的运算符:&=。下面两条语句产生的最终结果相同:

val &= 0377;
val = val & 0377;

3、按位或:|
二元运算符 |,通过逐位比较两个运算对象,生成一个新值。对于每个位,如果两个运算对象中相应的位为 1,结果就为 1(从真 / 假方面看,如果两个运算对象中相应的一个位为真或两个位都为真,那么结果为真)。因此,对下面的表达式求值:(10010011) | (00111101) // 表达式

除了编号为 6 的位,这两个运算对象的其他位至少有一个位为 1,得:(10111111) // 结果值

C 有一个按位或和赋值结合的运算符:|=。下面两条语句产生的最终作用相同:

val |= 0377;
val = val | 0377;

4、按位异或:^
二元运算符 ^ 逐位比较两个运算对象。对于每个位,如果两个运算对象中相应的位一个位 1(但不是两个位 1),结果为 1(从真 / 假方面看,如果两个运算对象中相应的一个位为真且不是两个为同为 1,那么结果为真)。因此,对下面表达式求值:(10010011) ^ (00111101) // 表达式

编号为 0 的位都是 1,所以结果为 0,得:(10101110) // 表达式

C 有一个按位异或和赋值结合的运算符:^=。下面两条语句产生的最终作用相同:

val ^= 0377;
val = val ^ 0377;

15.3.2 用法:掩码

按位与运算符常用于掩码(mask)。所谓掩码指的是一些设置为开(1)或关(0)的位组合。要明白称其为掩码的原因,先来看通过 & 把一个量与掩码结合后发生什么情况。例如,假设定义符号常量 MASK 为 2(即,二进制形式为 00000010),只有 1 号位是 1,其他位都是 0。下面的语句:flags = 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 的位才可见。
在这里插入图片描述
用 &= 运算符可以简化前面的代码,如下所示:flags &= MASK;

下面这条语句时按位与的一种常见用法: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 的 1 号位设置为 1,且其他位不变。因为使用 | 运算符,任何位与 0 组合,结果都为本身:任何位与 1 组合,结构都为 1.

例如,假设 flags 是 00001111,MASK 是 10110110。下面的表达式: flags | MASK // 表达式 即是:(10111111) // 结果值

MASK 中为 1 的位,flags 与其对应的位也为 1。MASK 中为 0 的位,flags 与其对应的位不变。用 |= 运算符可以简化上面的代码,如下所示:flags |= MASK;

同样,这种方法根据 MASK 中为 1 的位,把 flags 中对应的位设置为 1,其他位不变。

15.3.4 用法:关闭位(清空位)

和打开特定的位类似,有时也需要在不影响其他位的情况下关闭指定的位。假设要关闭变量 flags 中的 1 号位。同样,MASK 只有 1 号位为 1(即,打开)。可以这样做:flags = flags & ~MASK;

由于 MASK 除 1 号位为 1 以外,其他位全为 0,所以 ~MASK 除 1 号位为 0 以外,其他位全为 1。使用 &,任何位与 1 组合都得本身,所以这条语句保持 1 号位不变,改变其他各位。另外,使用 &,任何位与 0 组合都得 0。所以无论 1 号位的初始值是什么,都将其设置为 0。

例如,假设 flags 是 00001111,MASK 是 10110110。下面的表达式:flags & ~MASK; 即是:(00001111) & ~(10110110) // 表达式 其结果为:(00001001) // 结果值

MASK 中为 1 的位在结果中都被设置(清空)为 0 的位相应的位在结果中都未改变。可以使用下面的简化形式:flags &= ~MASK;

15.3.5 用法:切换位

切换位指的是打开已关闭的位,或关闭已打开的位。可以使用按位异或运算符(^)切换位。也就是说,假设 b 是一个位(1 或 0),如果 b 为 1,则 1^b 为 1;如果 b 为 0,则 1^b 为 1。另外,无论 b 为 1 还是 0,0^b 均为 b。因此,如果使用 ^ 组合一个值和一个掩码,将切换该值与 MASK 为 1 的位相对应的位,该值与 MASK 为 0 的位相对应的位不变。要切换 flags 中的 1 号位,可以使用下面两种方法:

flags = flags ^ MASK;
flags ^= MASK;

例如,假设 flags 是 00001111,MASK 是 10110110。表达式:flags ^ MASK; 即是:(00001111) ^ (10110110) // 表达式 其结果为:(101111001) // 结果值

flags 中与 MASK 为 1 的位相对应的位都被切换了,MASK 为 0 的位相对应的位不变。

15.3.6 用法:检查位的值

前面介绍了如何改变位的值。有时,需要检查某位的值。例如,flags 中 1 号位是否被设置为 1?不能这样直接比较 flags 和 MASK:

if(flags == MASK)
	puts("Wow!"); /* 不能正常工作 */

这样做即使 flags 的 1 号位为 1,其他位的值会导致比较结果为假。因此,必须覆盖 flags 中的其他位,只用 1 号位和 MASK 比较:

if((flags & MASK) == MASK)
	puts("Wow!");

由于按位运算符的优先级比 == 低,所以必须在 flags & MASK 周围加上圆括号。

为了避免信息漏过边界,掩码至少要与其覆盖的值宽度相同。

15.3.7 移位运算符

下面介绍 C 的移位运算符。移位运算符向左或向右移动位。同样,我们在示例中仍然使用二进制数,有助于读者理解其工作原理。

1、左移:<<
左移运算符(<<)将其左侧运算对象每一位的值向左移动其右侧运算对象指定的位数。左侧运算对象移出左末端位的值丢失,用 0 填充空出的位置。下面的例子中,每一位都向左移动两个位置:

(10001010) << 2 // 表达式
(00101000) // 结果值

该操作产生了一个新的位值,但是不改变其运算对象。例如,假设 stonk 为 1,那么 stonk << 2 为 4,但是 stonk 本身不变,仍为 1.可以使用左移赋值运算符(<<=)来更改变量的值。该运算符将变量中的位向左移动其右侧运算对象给定值的位数。如下例:

int stonk = 1;
int  onkoo;
onkoo = stonk << 2; /* 把 4 赋给 onkoo */
stonk <<= 2; /* 把 stonk 的值改为 4 */

2、右移:>>
右移运算符(>>)将其左侧运算对象每一位的值向右移动其右侧运算对象指定的位数。左侧运算对象移出右末端的值丢失。对于无符号类型,用 0 填充空出的位置;对于有符号类型,其结果取决于机器。空出的位置可用 0 填充,或者用符号位(即,最左端的位)的副本填充:

(10001010) >> 2 // 表达式,有符号值
(00100010) // 在某些系统中的结果值
(10001010) >> 2 // 表达式,有符号值
(11100010) // 在另一些系统上的结果值

下面是无符号值的例子:

(10001010) >> 2 // 表达式,无符号值
(00100010) // 所有系统都得到该结果值

每个位向右移动两个位置,空出的位用 0 填充。

右移赋值运算符(>>=)将其左侧的变量向右移动指定数量的位数。如下所示:

int sweet = 16;
int ooosw;
ooosw = sweet >> 3; // ooosw = 2,sweet 的值仍然为 16
sweet >>= 3; // sweet 的值为 2

3、用法:移位运算符
移位运算符针对 2 的幂提供快速有效的乘法和除法:

number << n /* number 乘以 2 的 n 次幂 */
number >> n /* 如果 number 为非负,则用 number 除以 2 的 n 次幂 */

这些移位运算符类似于在十进制中移动小数点来乘以或除以 10。

移位运算符还可用于从较大单元中提取一些位。例如,假设用一个 unsigned long 类型的值表示颜色值,低阶位字节储存红色的强度,下一个字节储存绿色的强度,第 3 个字节储存蓝色的强度。随后你希望把每种颜色的强度分别储存在 3 个不同的 unsigned char 类型的变量中。那么,可以使用下面的语句:

#define BYTE_MASK 0xff
unsigned long color = 0x002a162f;
unsigned char blue, green, red;
red = color & BYTE_MASK;
green = (color >>8) & BYTE_MASK;
blue = (color >>16) & BYTE_MASK;

以上代码中,使用右移运算符将 8 为颜色值移动至低阶字节,然后使用掩码技术把低阶字节赋给指定的变量。

15.3.8 编程示例

在第 9 章中,我们用递归的方法编写了一个程序,把数字转换为二进制形式。现在,要用移位运算符来解决相同的问题。程序清单的程序,读取用户从键盘输入的整数,将该整数和一个字符串地址传递给 itobs() 函数(itobs 表示 integer to binary string,即整数转换成二进制字符串)。然后,该函数使用移位运算符计算出正确的 1 和 0 的组合,并将其放入字符串中。

/* 使用位操作显示二进制 */
#include <stdio.h>
#include <limits.h> // 提供 CHAR_BIT 的定义,CHAR_BIT 表示每字节的位数
char * itobs(int ,char *);
void show_bstr(const char *);

int main(void)
{
	char bin_str[CHAR_BIT * sizeof(int) + 1];
	int number;

	puts("Enter integers and see them in binary.");
	puts("Non-numeric input terminates program.");
	while(scanf("%d", &number) == 1)
    {
        itobs(number,bin_str);
        printf("%d is ",number);
        show_bstr(bin_str);
        putchar('\n');
    }
    puts("Bye!");

    return 0;
}

char * itobs(int n,char * ps)
{
    int i;
    const static int size = CHAR_BIT * sizeof(int);

    for(i = size - 1; i >= 0; i--, n >>= 1)
        ps[i] = (01 & n) + '0';
    ps[size] = '\0';

    return ps;
}

/* 4 位一组显示二进制字符串 */
void show_bstr(const char * str)
{
    int i = 0;

    while(str[i]) /* 不是一个空字符 */
    {
        putchar(str[i]);
        if(++i % 4 == 0 && str[i])
            putchar(' ');
    }
}

程序清单使用 limits.h 中的 CHAR_BIT 宏,该宏表示 char 中的位数。sizeof 运算符返回 char 的大小,所以表达式 CHAR_BIT * sizeof(int) 表示 int 类型的位数。bin_str 数组的元素个数是 CHAR_BIT * sizeof(int) + 1,留出一个位置给末尾的空字符。

itobs() 函数返回的地址与传入的地址相同,可以把该函数作为 printf() 的参数。在该函数中,首次执行 for 循环时,对 01 & n 求值。01 是一个八进制形式的掩码,该掩码除 0 号位是 1 之外,其他所有位都为 0。因此,01 & n 就是 n 最后一位的值。该值为 0 或 1。但是对数组而言,需要的是字符 ‘0’ 或字符 ‘1’。该值加上 ‘0’ 即可完成这种转换(假设按顺序编码的数字,如 ASCII)。其结果存放在数值中倒数第 2 个元素中(最后一个元素用来存放空字符)。

顺带一提,用 1 & n 或 01 & n 都可以。我们用八进制 1 而不是十进制 1,只是为了更接近计算机的表达方式。

然后,循环指向 i-- 和 n >>= 1。i-- 移动到数组的前一个元素,n >>= 1 使 n 中的所有位向右移动一个位置。进入下一轮迭代时,循环中处理的是 n 中心的最右端的值。然后,把该值储存在倒数第 3 个元素中,以此类推,itobs() 函数用这种方式从右往左填充数组。

可以使用 printf() 或 puts() 函数显示最终的字符串,但是程序清单中定义了 show_bstr() 函数,以 4 位一组打印字符串,方便阅读。

下面的该程序的运行示例:
Enter integers and see them in binary.
Non-numeric input terminates program.
7
7 is 0000 0000 0000 0000 0000 0000 0000 0111
2013
2013 is 0000 0000 0000 0000 0000 0111 1101 1101
-1
-1 is 1111 1111 1111 1111 1111 1111 1111 1111
32123
32123 is 0000 0000 0000 0000 0111 1101 0111 1011
q
Bye!

15.3.9 另一个例子

我们来看另一个例子。这次要编写的函数用于切换一个值中的后 n 位,待处理值和 n 都是函数的参数。
运算符切换一个字节的所有位,而不是选定的少数位。但是,^ 运算符(按位异或)可用于切换单个位。假设创建了一个掩码,把后 n 位 设置为 1,其余位设置为 0,然后使用 ^ 组合掩码和待切换的值便可切换该值的最后 n 位,而且其他位不变。方法如下:
int invert_end(int num, int bits)
{
	int mask = 0;
	int bitval = 1;

	while(bits-- > 0)
	{
		mask != bitval;
		bitval <<= 1;
	}
	return num ^ mask;
}

while 循环用于创建所需的掩码。最初,mask 的所有位都为 0。第 1 轮循环将 mask 的 0 号位设置为 1。然后第 2 轮循环将 mask 的 1 号位设置为 1,以此类推。循环 bits 次,mask 的后 bits 为就都被设置为 1。最后,num ^ mask 运算即得所需的结果。

我们把这个函数放入程序中,测试该函数。

/* 使用位操作显示二进制 */
#include <stdio.h>
#include <limits.h> // 提供 CHAR_BIT 的定义,CHAR_BIT 表示每字节的位数
char * itobs(int ,char *);
void show_bstr(const char *);
int invert_end(int num, int bits);

int main(void)
{
	char bin_str[CHAR_BIT * sizeof(int) + 1];
	int number;

	puts("Enter integers and see them in binary.");
	puts("Non-numeric input terminates program.");
	while(scanf("%d", &number) == 1)
    {
        itobs(number,bin_str);
        printf("%d is ",number);
        show_bstr(bin_str);
        putchar('\n');
        number = invert_end(number, 4);
        printf("Inverting the last 4 bits gives\n");
        show_bstr(itobs(number,bin_str));
        putchar('\n');
    }
    puts("Bye!");

    return 0;
}

char * itobs(int n,char * ps)
{
    int i;
    const static int size = CHAR_BIT * sizeof(int);

    for(i = size - 1; i >= 0; i--, n >>= 1)
        ps[i] = (01 & n) + '0';
    ps[size] = '\0';

    return ps;
}

/* 4 位一组显示二进制字符串 */
void show_bstr(const char * str)
{
    int i = 0;

    while(str[i]) /* 不是一个空字符 */
    {
        putchar(str[i]);
        if(++i % 4 == 0 && str[i])
            putchar(' ');
    }
}

int invert_end(int num, int bits)
{
    int mask = 0;
    int bitval = 1;

    while(bits-- > 0)
    {
        mask != bitval;
        bitval <<= 1;
    }
    return num ^ mask;
}

15.4 位字段

操控位的第 2 种方法是位字段(bit field)。位字段是一个 signed int 或 unsigned int 类型变量中的一组相邻的位(C99 和 C11 新增了 _Bool 类型的位字段)。位字段通过一个结构声明来建立,该结构声明为每个字段提供标签,并确定该字段的宽度。例如,下面的声明建立了一个 4 个 1 为的字段:

struct {
	unsigned int autfd : 1;
	unsigned int bldfc : 1;
	unsigned int undln : 1;
	unsigned int itals : 1;
} prnt;

根据该声明,prnt 包含 4 个 1 位的字段。现在,可以通过普通的结构成员运算符(.)单独给这些字段赋值:

prnt.itals = 0;
prnt.undln = 1;

由于每个字段恰好为 1 位,所以只能为其赋值 1 或 0。变量 prnt 被储存在 int 大小的内存单元中,但是在本例中只使用了其中的 4 位。

带有位字段的结构提供一种记录设置的方便途径。许多设置(如,字体的粗体或斜体)就是简单的二选一。例如,开或关、真或假。如果只需要使用 1 位,就不需要使用整个变量。内含位字段的结构允许在一个存储单元储存多个设置。

有时,某些设置也有多个选择,因此需要多位来表示。这没问题,字段不限制 1 位大小。可以使用如下的代码:

struct {
	unsigned int code1 : 2;
	unsigned int code2 : 2;
	unsigned int code3 : 8;
} prcode;

以上代码创建了两个 2 位的字段和一个 8 位的字段。可以这样赋值:

prcode.code1 = 0;
prcode.code2 = 3;
prcode.code3 = 102;

但是,要确保所赋的值不超出字段可容纳的范围。

如果声明的总位数超过一个 unsigned int 类型的大小会怎样?会用到下一个 unsigned int 类型的存储位置。一个字段不允许跨越两个 unsigned int 之间的边界。编译器会自动移动跨界的字段,保持 unsigned int 的边界对齐。一旦发生这种情况,第 1 个 unsigned int 中会留下一个未命名的 “洞”。

可以用未命名的字段宽度 “填充” 未命名的 “洞”。使用一个宽度为 0 的未命名字段迫使下一个字段与下一个整数对齐:

struct {
	unsigned int field1 : 1;
	unsigned int           : 2;
	unsigned int field2 : 1;
	unsigned int           : 0;
	unsigned int field3 : 1;
} stuff;

这里,在 stuff.field1 和 stuff.field2 之间,有一个 2 位的空隙:stuff.field3 将储存在下一个 unsigned int 中。

字段储存在一个 int 中的顺序取决于机器。在有些机器上,存储的顺序是从左往右,而在另一些机器上,是从右往左。另外,不同的机器中两个字段边界的位置也有区别。由于这些原因,位字段通常都不容易移植。尽管如此,有些情况却要用到这种不可移植的特性。例如,以特定硬件设备所用的形式储存数据。

15.4.1 位字段示例

通常,把位字段作为一种更紧凑储存数据的方式。例如,假设要在屏幕上表示一个方框的属性。为简化问题,我们假设方框具有如下属性:

  • 方框是透明的或不透明的;
  • 方框的填充色选自一下调色板:黑色、红色、绿色、黄色、蓝色、紫色、青色或白色;
  • 边框可见或隐藏;
  • 边框颜色与填充色使用相同的调色板;
  • 边框可以使用实线、点线或虚线样式。

可以使用单独的变量或全长(full-sized)结构成员来表示每个属性,但是这样做有些浪费位。例如,只需 1 位即可表示方框是透明还是不透明;只需 1 位即可表示边框是显示还是隐藏。8 种颜色可以用 3 位单元的 8 个可能得值来表示,而 3 种边框样式也只需 2 位单元即可表示。总共 10 位就足够表示方框的 5 个属性设置。

一种方案是:一个字节储存方框内部(透明和填充色)的属性,一个字节储存方框边框的属性,每个字节中的空隙用未命名字段填充。struct box_props 声明如下:

struct box_props {
	bool opaque                      : 1;
	unsigned int fill_color        : 3;
	unsigned int                      : 4;
	bool show_border             : 1;
	unsigned int border_color : 3;
	unsigned int border_style : 2;
	unsigned int                      : 2;
};

加上未命名的字段,该结构共占用 16 位。如果不使用填充,该结构占用 10 位。但是要记住,C 以 unsigned int 作为字段结构的基本布局单元。因此,即使一个结构唯一的成员是 1 位 字段,该结构的大小也是一个 unsigned int 类型的大小,unsigned int 在我们的系统中是 32 位。另外,以上代码假设 C99 新增 _Bool 类型可用,在 stdbool.h 中,bool 是 _Bool 的别名。

对于opaque 成员,1 表示方框不透明,0 表示透明。show_border 成员也用类似的方法。对于颜色,可以用简单的 RGB(即 red-green-blue 的缩写)表示。这些颜色都是三原色的混合。显示器通过混合红、绿、蓝像素来产生不同的颜色。在早期的计算机色彩中,每个像素都可以打开或关闭,所以可以使用用 1 位来表示三原色中每个二进制颜色的亮度。常用的顺序是,左侧位表示蓝色亮度、中间为表示绿色亮度、右侧位表示红色亮度。表列出了这 8 种可能得组合。fill_color 成员和 border_color 成员可以使用这些组合。最后,border_style 成员可以使用 0、1、2 来表示实线、点线和虚线样式。

位组合十进制颜色
0000黑色
0011红色
0102绿色
0113黄色
1004蓝色
1015紫色
1106青色
1117白色

程序清单中的程序使用 box_props 结构,该程序用 #define 创建供结构成员使用的符号常量。注意,只打开一位即可表示三原色之一。其他颜色用三原色的组合来表示。例如,紫色由打开的蓝色位和红色位组成,所以,紫色可表示为 BLUE|RED。

/* 定义并使用字段 */
#include <stdio.h>
#include <stdbool.h> // C99 定义了 bool、true、false

/* 线的样式 */
#define SOLID 0
#define DOTTED 1
#define DASHED 2

/* 三原色 */
#define BLUE 4
#define GREEN 2
#define RED 1
/* 混合色 */
#define BLACK 0
#define YELLOW (RED | GREEN)
#define MAGENTA (RED | BLUE)
#define CYAN (GREEN | BLUE)
#define WHITE (RED | GREEN | BLUE)

const char * colors[8] = {"black","red","green","yellow","blue","magenta","cyan","white"};

struct box_props {
    bool opaque : 1;
    unsigned int fill_color : 3;
    unsigned int : 4;
    bool show_border : 1;
    unsigned int border_color : 3;
    unsigned int border_style : 2;
    unsigned int : 2;
};

void show_settings(const struct box_props * pb);

int main(void)
{
	/* 创建并初始化 box_props 结构 */
	struct box_props box = {true,YELLOW,true,GREEN,DASHED};

	printf("Original box settings:\n");
	show_settings(&box);

	box.opaque = false;
	box.fill_color = WHITE;
	box.border_color = MAGENTA;
	box.border_style = SOLID;
	printf("\nModified box settings:\n");
	show_settings(&box);

    return 0;
}

void show_settings(const struct box_props * pb)
{
    printf("Box is %s.\n",pb->opaque == true ? "opaque" : "transparent");
    printf("The fill color is %s.\n",pb->show_border == true ? "shown" : "not shown");
    printf("The border color is %s.\n",colors[pb->border_color]);
    printf("The border style is ");
    switch(pb->border_style)
    {
        case SOLID : printf("solid.\n"); break;
        case DOTTED : printf("dotted.\n"); break;
        case DASHED : printf("dashed.\n"); break;
        default: printf("unknown type.\n");
    }
}

Original box settings:
Box is opaque.
The fill color is shown.
The border color is green.
The border style is dashed.

Modified box settings:
Box is transparent.
The fill color is shown.
The border color is magenta.
The border style is solid.

该程序要注意几个要点。首先,初始化位字段结构与初始化普通结构的语法相同:struct box_props box = {true,YELLOW,true,GREEN,DASHED};

注意,根据 colors 数组的定义,每个索引对应一个表示颜色的字符串,而每张颜色都把索引值作为该颜色的数值。例如,索引 1 对应字符串 “red”,枚举常量 red 的值是 1。

15.5 对齐特性(C11)

C11 的对齐特性比用位填充字节更自然,它们还代表了 C 在处理硬件相关问题上的能力。在这种上下文中,对齐指的是如何安排对象在内存中的位置。例如,为了效率最大化,系统可能要把一个 double 类型的值储存在 4 字节内存地址上,但却允许把 char 储存在任意地址。大部分程序员都对对齐不以为然。但是,有些情况又受益于对齐控制。例如,把数据从一个硬件位置转移到另一个位置,或者调用指令同时操作多个数据项。

_Alignof 运算符给出一个类型的对齐要求,在关键字 _Alignof 后面的圆括号中写上类型名即可:size_t d_align = _Alignof(float);

假设 d_align 的值是 4,意思是 float 类型对象的对齐要求是 4.也就是说,4 是储存该类型值相邻地址的字节数。一般而言,对齐值都应该是 2 的非负整数次幂。较大的对齐值被称为 stricter 或 stronger,较小的对齐值被称为 wraker。

可以使用 _Alignas 说明符指定一个变量或类型的对齐值。但是,不应该要求该值小于基本对于值。例如,如果 float 类型的对齐要求是 4,不要请求其对齐值是 1 或 2.该说明符用作声明的一部分,说明符后面的圆括号内包含对齐值或类型:

_Alignas(double) char c1;
_Alignas(8) char c2;
unsigned char _Alignas(long double) c_arr(sizeof(long double));

程序清单演示了 _Alignas 和 _Alignof 的用法。

/* 使用 _Alignof 和 _Alignas */
#include <stdio.h>

int main(void)
{
	double dx;
	char ca;
	char cx;
	double dz;
	char cb;
	char _Alignas(double) cz;

	printf("char alignment: %zd\n",_Alignof(char));
	printf("double alignment: %zd\n",_Alignof(double));
	printf("&dx: %p\n",&dx);
	printf("&ca: %p\n",&ca);
	printf("&cx: %p\n",&cx);
	printf("&dz: %p\n",&dz);
	printf("&cb: %p\n",&cb);
	printf("&cz: %p\n",&cz);

    return 0;
}

该程序的输出如下:
char alignment: 1
double alignment: 8
&dx: 0060FEF8
&ca: 0060FEF7
&cx: 0060FEF6
&dz: 0060FEE8
&cb: 0060FEE7
&cz: 0060FEE0

在我们的系统中,double 的对齐值是 8,这意味着地址的类型对齐可以被 8 整除。以 0 或 8 结尾的十六进制地址可被 8 整除。这就是地址常用两个 double 类型的变量和 char 类型的变量 cz(该变量是 double 对齐值)。因为 char 的对齐值是 1,对于普通的 char 类型变量,编译器可以使用任何地址。

在程序中包含 stdalign.h 头文件后,就可以把 alignas 和 alignof 分别作为 _Alignof 和 _Alignas 的别名。这样做可以与 C++ 关键字匹配。

C11 stdlib.h 库还添加了一个新的内存分配函数,用于对齐动态分配的内存。该函数的原型如下:void *aligned_alloc(size_t alignment, size_t size); 第 1 个参数代表指定的对齐,第 2 个参数是所需的字节数,其值应是第 1 个参数的倍数。与其他内存分配函数一样,要使用 free() 函数释放之前分配的内存。

15.6 关键概念

C 区别于许多高级语言的特性之一是访问整数中单独位的能力。该特性通常是与硬件设备和操作系统交互的关键。

C 有两种访问位的方法。一种方法是通过按位运算符,另一种方法是在结构中创建位字段。

C11 新增了检查内存对齐要求的功能,而且可以知指定比基本对齐值更大的对齐值。

通常(但不总是),使用这些特性的程序仅限于特定的硬件平台或操作系统,而且设计为不可移植的。

15.7 本章小结

计算硬件与二进制记数系统密不可分,因为二进制数的 1 和 0 可用于表示计算机内存和寄存器中位的开闭状态。虽然 C 不允许以二进制形式书写数字,但是它识别与二进制相关的八进制和十六进制记数法。正如每个二进制数字表示 1 位一样,每个八进制位代表 3 位,每个十六进制位代表 4 位。这种关系使得二进制转为八进制或十六进制较为简单。

C 提供多种按位运算符,之所以称为按位是因为它们单独操作一个值中的每个位。~ 运算符将其运算对象的每一位取反,将 1 转为 0,0 转为 1。按位与运算符(&)通过两个运算对象形成一个值。如果两运算对象中相同号位都为 1,那么该值中对应的位为 1;否则,该位为 0。按位或运算符(|)同样通过两个运算对象形成一个值。如果两运算对象中相同号位有一个为 1 或都为 1,那么该值中对应的位为 1;否则,该位为 0。按位异或运算符(^)也有类似的操作,只有两运算对象中相同号位有一个为 1 时,结果值中对应的位才为 1。

C 还有左移(<<)和右移(>>)运算符。这两个运算符使组合中的所有位都向左或向右移动指定数量的位,以形成一个新值。对于左移运算符,空出的位置设为 0.对于右移运算符,如果是无符号类型的值,空出的位设为 0;如果是有符号类型的值,右移运算符的行为取决于实现。

可以在结构中使用位字段操控一个值中的单独位或多组位。具体细节因实现而异。

可以使用 _Alignas 强制执行数据存储区上的对齐要求。

这些位工具帮助 C 程序处理硬件问题,因此它们通常用于依赖实现的场合中。

15.8 复习题

1、把下面的十进制转换为二进制:
a、3
b、13
c、59
d、119

2、将下面的二进制值转换为十进制、八进制和十六进制的形式:
a、00010101
b、01010101
c、01001100
d、10011101

3、对下面的表达式求值,假设每个值都为 8 为:
a、~3
b、3 & 6
c、3 | 6
d、1 | 6
e、3 ^ 6
f、7 >> 1
g、7 << 2

4、对下面的表达式求值,假设每个值都为 8 位:
a、~0
b、!0
c、2 & 4
d、2 && 4
e、2 | 4
f、2 || 4
g、5 << 3

5、因为 ASCII 码只使用最后 7 位,所以有时需要用掩码关闭其他位,其相应的二进制掩码是什么?分别用十进制、八进制和十六进制来表示这个掩码。

6、程序清单,可以把下面的代码:

while(bits-- > 0)
{
	mask != bitval;
	bitval <<= 1;
}

替换成:

while(bits-- > 0)
{
	mask += bitval;
	bitval *= 2;
}

程序照常工作。这是否意味着 *=2 等同于<<= 1?+= 是否等同于 != ?

7、
a、Tinkerbell 计算机有一个硬件字节可读入程序。该字节包含以下信息:

含义
0~11.4MB 软盘驱动器的数量
2未使用
3~4CD-ROM 驱动器数量
5未使用
6~7硬盘驱动器数量

Tinkerbell 和 IBM PC 一样,从右往左填充结构位字段。创建一个适合存放这些信息的位字段模板。

b、Klinkerbell 与 Tinkerbell 类似,但是它从左往右填充结构位字段。请为 Klinkerbell 创建一个相应的位字段模板。

15.9 编程练习

1、编写一个函数,把二进制字符串转换为一个数值。例如,有下面的语句:char * pbin = "01001001"; 那么把 pbin 作为参数传递给该函数后,它应该返回一个 int 类型的值 25。

2、编写一个程序,通过命令行参数读取两个二进制字符串,对这两个二进制数使用 ~ 运算符、& 运算符、| 运算符和 ^ 运算符,并以二进制字符串形式打印结果(如果无法使用命令行环境,可以通过交互式让程序读取字符串)。

3、编写一个函数,接受一个 int 类型的参数,并返回该参数中打开位的数量。在一个程序中测试该函数。

4、编写一个程序,接受两个 int 类型的参数:一个是值;一个是位的位置。如果指定位的位置为 1,该函数返回 1;否则返回 0。在一个程序中测试该函数。

5、编写一个函数,把一个 unsigned int 类型值中的所有位向左旋转指定数量的位。例如rotate_1(x,4) 把 x 中所以位向左移动 4 个位置,而且从最左端移出的位会重新出现在右端。也就是说,把高阶位移出的位放入低阶位。在一个程序中测试该函数。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值