Note

大多数高级语言都回避”单个比特”这一层细节,但 C 允许你直接操作整数里的每一个位(bit)。本章先补齐二进制、八进制、十六进制的基础,再学习 C 的两件”位级工具”:按位运算符(bitwise operators)和位字段(bit fields)。写设备驱动、嵌入式程序、压缩加密算法时,这些是必备知识。

二进制数、位与字节

我们平时用十进制(base 10)写数:2157 表示 2×10³ + 1×10² + 5×10¹ + 7×10⁰。十进制流行大概因为人有十根手指,而计算机的”位”只有两根手指——只能是 0 或 1(关或开),所以计算机天然使用二进制(binary,base 2):1101 就是 1×2³ + 1×2² + 0×2¹ + 1×2⁰ = 13。只要有足够的位,任何整数都能用一串 0 和 1 表示。

二进制整数

通常 1 个字节(byte)含 8 位,从左到右编号 7~0:位 7 叫高位(high-order bit),位 0 叫低位(low-order bit),每个位号对应一个 2 的幂。

/图:图15.1 位的编号与位的值

图中位 6、3、0 置 1,字节值为 64 + 8 + 1 = 73。一个字节最大 11111111(255),共 256 种取值。同一组位模式可以换一种解释:unsigned char 表示 0~255,signed char 表示 -128~+127。

有符号整数

有符号数的表示由硬件决定,常见三种方案:

  • 符号量值法(sign-magnitude):最高位表符号,其余 7 位表数值。缺点是有 +0 和 -0 两个零。
  • 二进制补码(two’s complement):如今最常用。最高位为 1 表示负数,求相反数的方法是逐位取反后加 1。它不重不漏地表示 -128~+127,且只有一个零。
  • 反码(one’s complement):直接逐位取反表示相反数,同样有 -0,范围 -127~+127。

Success

二进制补码的巧妙之处:加法器电路无需为负数做特殊处理,减一个数等价于加上它的补码,硬件因此大大简化——这正是它成为现代标准的原因。

二进制浮点数

浮点数(floating-point)分两部分存储:二进制小数(binary fraction)和二进制指数。二进制小数 .101 表示 1/2 + 0/4 + 1/8 = 0.625。许多分数在二进制下无法精确表示——只有 1/2 的幂的组合(如 3/4、7/8)能精确表示,1/3、2/5 不行,这就是浮点舍入误差的根源。实际值 = 小数部分 × 2^指数;乘以 2 的幂只改指数,不动小数部分。

八进制

C 语言不允许直接写二进制字面量,但支持八进制(前缀 0)和十六进制(前缀 0x)——它们都是 2 的幂,换算方便,是程序员观察位模式的”速记法”。八进制(octal)是 base 8,用数字 0~7:0451 表示 4×8² + 5×8¹ + 1×8⁰ = 297。它最大的便利:每个八进制数字恰好对应 3 个二进制位

八进制数字二进制等值
0000
1001
2010
3011
4100
5101
6110
7111

例如 0377 就是 11111111。换算时中间的 0 不能丢:0173 是 01 111 011。

十六进制

十六进制(hexadecimal,简称 hex)是 base 16,数字 0~15 中 10~15 用字母 A~F(大小写均可)表示。0xA3F 表示 10×16² + 3×16¹ + 15×16⁰ = 2623。它的便利:每个十六进制数字恰好对应 4 个二进制位,两位十六进制正好一个字节,因此是表示字节值的首选。

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

例如 0xC2 = 11000010;反过来 11010101 = 1101 0101 = 0xD5。

C 的位运算符

C 提供按位逻辑运算符和移位运算符。下面的例子为看清过程直接写二进制,实际程序里你会写 25、031 或 0x19,而不是 00011001。

按位逻辑运算符

四个按位逻辑运算符作用于整数类型(含 char)。它们叫”按位”,是因为每一位的运算与相邻位无关。别把它们与逻辑运算符 &&、||、! 混淆——后者把整个值当真/假处理。

按位取反:~(一元)——每个 1 变 0,每个 0 变 1。若 val 为 2(00000010),~val 为 11111101(253)。注意 ~ 不改变 val 本身,就像 3 * val 不改变 val 一样;想修改就写 val = ~val;

~(10011010)               // 结果 (01100101)
(10010011) & (00111101)   // 与:两边都为 1 结果位才为 1,得 (00010001)
(10010011) | (00111101)   // 或:任一边为 1 结果位就是 1,得 (10111111)
(10010011) ^ (00111101)   // 异或:恰好一边为 1 结果位才为 1,得 (10101110)

异或的例子中位 0 两边都是 1,所以得 0。三者各有复合赋值形式 &=、|=、^=,如 val &= 0377; 等价于 val = val & 0377;

用法:掩码

掩码(mask)是某些位为 1、某些位为 0 的位模式。把掩码与一个值用 & 组合,掩码中为 0 的位会”遮住”原值对应位——任何位与 0 做 & 都得 0,与 1 做 & 则保持原样。可以把掩码的 0 想象成不透明、1 想象成透明:flags & MASK 就像把掩码盖在 flags 上,只露出 1 下面的位。

/图:图15.2 掩码的工作原理

#define MASK 2          /* 二进制 00000010,只有位 1 为 1 */
flags = flags & MASK;   /* 除位 1 外全部清零 */

一个常见的 C 用法是 ch &= 0xff;(即 0377):0xff 是 11111111,保留 ch 的最后 8 位、其余清零,不管 ch 原来多宽,结果都”裁剪”进一个字节。

Success

ch &= 0xff; 是处理宽字符类型的经典技巧:ASCII 只用最后 7 位,0xff 掩码一过滤,高位杂质全清,只留下干净的字符编码。

用法:打开、关闭与切换位

**打开位(置 1)**用 |:任何位与 0 做 | 还是它自己,与 1 做 | 必为 1。**关闭位(清 0)**用 & 配合取反的掩码:~MASK 除了位 1 全是 1,保持其余位不变,位 1 处的 0 则无论原值如何都清成 0。**切换位(toggle)**指 1 变 0、0 变 1,用 ^ 正合适:1 ^ b 翻转 b,0 ^ b 保持 b——掩码中为 1 的位被切换,为 0 的位不动。

flags |= MASK;    /* 打开位 1,其余不变 */
flags &= ~MASK;   /* 关闭位 1,其余不变 */
flag ^= MASK;     /* 切换位 1,其余不变 */

用法:检查位的值

判断 flags 的位 1 是否为 1,不能直接 if (flag == MASK)——即使位 1 是 1,其他位也会让 == 失败。正确做法是先用掩码滤掉其他位再比较:

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

Warning

位运算符 &、|、^ 的优先级低于 和 !=,`(flag & MASK) MASK的括号绝不能省。漏写的话flag & MASK MASK` 会被解析成 `flag & (MASK MASK)`,逻辑完全走样,且编译器多半不报错。

移位运算符

左移:<<——位向左移,空位补 0,移出左端的高位丢失;<< 不改变操作数,想改用 <<=。

右移:>>——位向右移,移出右端的丢失。对无符号类型左端一律补 0;对有符号类型结果是实现相关的——可能补 0,也可能补符号位;对负数右移结果未定义行为。

(10001010) << 2   // 左移两位,得 (00101000)
(10001010) >> 2   // 有符号:有的系统得 00100010,有的得 11100010
(10001010) >> 2   // 无符号:所有系统都得 00100010

用法:移位与位提取

移位能快速完成乘除 2 的幂(效率取决于硬件):number << n 等于乘 2ⁿ;number >> n(number 非负)等于除以 2ⁿ——和十进制”移动小数点乘除 10”一个道理。移位还能从大单位里抽取位组:比如 unsigned long 存了 RGB 强度(低字节红、次字节绿、第三字节蓝):

#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;

先右移把目标字节挪到最低字节,再用掩码截取——“先移位、后掩码”是位提取的标准套路。

编程实例:程序清单 15.1 binbit.c

第 9 章曾用递归把整数转成二进制,这里用位运算符重写。itobs()(integer-to-binary string)把整数填成 0/1 模式字符串:

/* binbit.c -- 用位操作显示二进制 */
#include <stdio.h>
char * itobs(int, char *);
void show_bstr(const char *);
 
int main(void)
{
    char bin_str[8 * 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;
    static int size = 8 * 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(' ');
    }
}

逐段拆解:bin_str 的大小 8 * sizeof(int) + 1 就是 int 的位数再加 1(留给结尾空字符)。itobs() 从数组末尾往前填:01 & n(八进制 1,等价十进制 1,写 01 只为更有”计算机味”)取出最低位,加上字符 ‘0’ 的 ASCII 码转成 ‘0’ 或 ‘1’;然后 n >>= 1 右移一位,下一轮取新的最低位。函数返回传入的地址,所以能直接嵌在 printf() 参数里;show_bstr() 每 4 位插个空格便于阅读。

运行示例:

Enter integers and see them in binary.
7
7 is 0000 0000 0000 0000 0000 0000 0000 0111
-1
-1 is 1111 1111 1111 1111 1111 1111 1111 1111
q
Bye!

Success

itobs() 十几行就实现了任意整数的二进制可视化,且位数用 sizeof 现场计算、不写死,移植性好。调试位运算代码时把它拷过去随时打印位模式,比心算可靠得多。同理,<<=>>= 之类的复合移位赋值可以原地改变变量的位模式。

另一个例子:反转最后 n 位

目标:把一个值的最后 n 位取反,n 和值都是参数。~ 会把所有位一锅端,而 ^ 能精确切换掩码覆盖的位。思路:构造”最后 n 位为 1、其余为 0”的掩码,再用 ^ 切换(清单 15.2 invert4.c 把它加进 binbit.c 测试):

int invert_end(int num, int bits)
{
    int mask = 0;
    int bitval = 1;
 
    while (bits-- > 0)
    {
        mask |= bitval;    /* 把 bitval 所在的位并入掩码 */
        bitval <<= 1;      /* 移到下一位 */
    }
    return num ^ mask;
}

循环 bits 次后 mask 的最后 bits 位全是 1,num ^ mask 只切换这些位。测试:7(…0111)切换最后 4 位后变成 8(…1000),异或的”定向开关”效果一目了然。

位字段

操作位的第二种方法是位字段(bit field):有符号 int 或 unsigned int 内一组相邻的位(C99 还允许 _Bool)。位字段用结构声明定义,给每个字段贴标签并指定宽度:

struct {
    unsigned int autfd : 1;
    unsigned int bldfc : 1;
    unsigned int undln : 1;
    unsigned int itals : 1;
} prnt;
 
prnt.itals = 0;    /* 1 位字段只能赋 0 或 1 */
prnt.undln = 1;

很多设置项(加粗、斜体、开关)本质是二选一,用一整个变量太浪费——位字段把多个开关打包进一个存储单元。字段不必限于 1 位,选择多就多给几位,只要别超出容量:

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

若总位数超过一个 unsigned int,就启用下一个存储单元;单个字段不允许跨越两个 unsigned int 的边界,编译器会自动把越界字段对齐到下一个 int 并留下空洞。你也可以用未命名字段宽度主动”挖洞”,宽度 0 强制下一个字段对齐到下一个整数:

struct {
    unsigned int field1 : 1;
    unsigned int       : 2;    /* 2 位空洞 */
    unsigned int field2 : 1;
    unsigned int       : 0;    /* 强制对齐到下一个 int */
    unsigned int field3 : 1;
} stuff;

注意一个重要的机器依赖:字段在 int 中从左往右还是从右往左排列由实现决定,所以位字段可移植性不好——不过它通常本来就是用来匹配特定硬件的数据格式的。

位字段示例:程序清单 15.3 fields.c

假设要表示屏幕上一个方框的属性:不透明/透明(1 位)、填充色 8 选 1(3 位)、边框显示/隐藏(1 位)、边框色(3 位)、线型实线/点线/虚线(2 位)——共 10 位。用填充把填充信息和边框信息分进两个字节;颜色用 RGB 表示:红绿蓝三原色各占 1 位,组合出 8 种颜色:

位模式十进制颜色
0000黑 black
0011红 red
0102绿 green
0113黄 yellow
1004蓝 blue
1015品红 magenta
1106青 cyan
1117白 white
/* fields.c -- 定义并使用位字段 */
#include <stdio.h>
#define YES 1
#define NO  0
#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 {
    unsigned int opaque        : 1;
    unsigned int fill_color    : 3;
    unsigned int               : 4;   /* 填充到字节边界 */
    unsigned int show_border   : 1;
    unsigned int border_color  : 3;
    unsigned int border_style  : 2;
    unsigned int               : 2;
};
 
int main(void)
{
    struct box_props box = {YES, YELLOW, YES, GREEN, DASHED};
 
    printf("Original box settings:\n");
    show_settings(&box);
 
    box.opaque = NO;
    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 == YES ? "opaque" : "transparent");
    printf("The fill color is %s.\n", colors[pb->fill_color]);
    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");
    }
}

几个要点:位字段结构可用与普通结构相同的语法初始化({YES, YELLOW, YES, GREEN, DASHED});可以给位字段成员赋值、用作 switch 的判断表达式,甚至用作数组下标——colors 数组下标正好对应颜色数值,colors[pb->fill_color] 直接得到颜色名。

位字段与位运算符:程序清单 15.4 dualview.c

位字段和位运算符是同一类问题的两条路线,可以用联合(union)把两种视角叠在同一块数据上:

union Views    /* 同一数据:既是结构,又是 unsigned int */
{
    struct box_props st_view;
    unsigned int     ui_view;
};

同一块内存,既能按结构看也能按整数看。结构的哪个字段对应整数的哪个位取决于实现:IBM PC 上结构从低位端往高位端加载,第一个位字段落在位 0。

/图:图15.3 同一块数据的联合视图:既是整数又是结构

清单 15.4 中 box 是 Views 联合,先用位运算符视角修改设置(结构视角的 show_settings() 与清单 15.3 完全相同,此处省略):

/* 位运算常量:值反映实际位位置 */
#define OPAQUE        0x1
#define FILL_BLUE     0x8
#define FILL_GREEN    0x4
#define FILL_MASK     0xE
#define BORDER_RED    0x200
#define B_DOTTED      0x1000
#define STYLE_MASK    0x3000
/* ……其余常量类似,详见原书清单…… */
 
int main(void)
{
    union Views box = {{YES, YELLOW, YES, GREEN, DASHED}};
    char bin_str[8 * sizeof(unsigned int) + 1];
 
    box.ui_view &= ~FILL_MASK;               /* 先清空填充色位 */
    box.ui_view |= (FILL_BLUE | FILL_GREEN); /* 再置新填充色 */
    box.ui_view ^= OPAQUE;                   /* 切换不透明位 */
    box.ui_view |= BORDER_RED;               /* 错误示范:忘了先清位 */
    box.ui_view &= ~STYLE_MASK;              /* 清空线型位 */
    box.ui_view |= B_DOTTED;                 /* 线型改为点线 */
    /* ...显示修改后的设置... */
}
 
/* 位运算视角读取设置:先移位、后掩码 */
void show_settings1(unsigned short us)
{
    printf("box is %s.\n",
        (us & OPAQUE) == OPAQUE ? "opaque" : "transparent");
    printf("The fill color is %s.\n", colors[(us >> 1) & 07]);
    printf("The border color is %s.\n", colors[(us >> 9) & 07]);
    /* ...其余类似... */
}

这份程序把两条路线的差异暴露得很清楚:

  1. 位运算视角需要位置信息。结构视角里”蓝色”就是数值 4,但按位视角里填充色的蓝位是位 3(0x8)、边框色的蓝位是位 11(0x800)。十六进制的优点在此:0x800 就是 0x8 后补 8 个 0,位位置一目了然(写成 2048 就看不出门道)。若是 2 的幂也可用移位写法 #define FILL_BLUE 1<<3——1<<n 就是只有第 n 位为 1 的整数,且是编译期常量表达式。
  2. 按位修改设置更麻烦。改填充色为 cyan,光 |= (FILL_BLUE | FILL_GREEN) 不够——若红位原本是 1(原色 yellow),结果会变成 white。必须先清位、再置位。程序里 |= BORDER_RED; 那行就是故意演示忘清位的后果:边框色成了黄而不是红。
  3. 位字段版本简单得多box.st_view.fill_color = CYAN; 一句搞定,不用先清位,填充色和边框色还能共用同一套值。
  4. 取值方式不同pb->border_color 天然在 0~7 范围可直接当数组下标;位运算版本要 (us >> 9) & 07——先移位把目标字段挪到最低 3 位,再掩码滤掉其余。

Warning

位字段与位位置的对应关系是实现相关的。同样的代码在 Macintosh 上运行时结构从高位端加载,位布局与 PC 相反,“位运算视角”读出的设置全部张冠李戴,改的也是错误的位。这类代码天生绑定平台,移植前务必确认目标机器的位序。

关键概念与小结

C 区别于大多数高级语言的一大特色,是能访问整数内部的每一个位——这常常是与硬件设备、操作系统打交道的关键。C 的两大位级工具是位运算符家族和结构中的位字段;使用它们的程序通常与特定平台绑定,本身就不以可移植为目标。

  • 八进制每 1 位对应 3 个二进制位,十六进制每 1 位对应 4 个二进制位,换算方便。
  • ~ 翻转每一位;& 要求两边都为 1;| 任一边为 1;^ 恰好一边为 1。复合形式 &=、|=、
  • 掩码四件套:& MASK 取位、|= MASK 置位、&= ~MASK 清位、^= MASK 切位;检查某位用 (flags & MASK) == MASK(注意 & 优先级低于 ==,括号不可省)。
  • 左移空位补 0;右移对无符号值补 0,对有符号负数由实现决定;位字段的布局细节同样由实现决定。