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 的幂。

图中位 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 个二进制位。
| 八进制数字 | 二进制等值 |
|---|---|
| 0 | 000 |
| 1 | 001 |
| 2 | 010 |
| 3 | 011 |
| 4 | 100 |
| 5 | 101 |
| 6 | 110 |
| 7 | 111 |
例如 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 个二进制位,两位十六进制正好一个字节,因此是表示字节值的首选。
| 十进制 | 十六进制 | 二进制 | 十进制 | 十六进制 | 二进制 |
|---|---|---|---|---|---|
| 0 | 0 | 0000 | 8 | 8 | 1000 |
| 1 | 1 | 0001 | 9 | 9 | 1001 |
| 2 | 2 | 0010 | 10 | A | 1010 |
| 3 | 3 | 0011 | 11 | B | 1011 |
| 4 | 4 | 0100 | 12 | C | 1100 |
| 5 | 5 | 0101 | 13 | D | 1101 |
| 6 | 6 | 0110 | 14 | E | 1110 |
| 7 | 7 | 0111 | 15 | F | 1111 |
例如 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 下面的位。

#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 种颜色:
| 位模式 | 十进制 | 颜色 |
|---|---|---|
| 000 | 0 | 黑 black |
| 001 | 1 | 红 red |
| 010 | 2 | 绿 green |
| 011 | 3 | 黄 yellow |
| 100 | 4 | 蓝 blue |
| 101 | 5 | 品红 magenta |
| 110 | 6 | 青 cyan |
| 111 | 7 | 白 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.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]);
/* ...其余类似... */
}这份程序把两条路线的差异暴露得很清楚:
- 位运算视角需要位置信息。结构视角里”蓝色”就是数值 4,但按位视角里填充色的蓝位是位 3(0x8)、边框色的蓝位是位 11(0x800)。十六进制的优点在此:0x800 就是 0x8 后补 8 个 0,位位置一目了然(写成 2048 就看不出门道)。若是 2 的幂也可用移位写法
#define FILL_BLUE 1<<3——1<<n就是只有第 n 位为 1 的整数,且是编译期常量表达式。 - 按位修改设置更麻烦。改填充色为 cyan,光
|= (FILL_BLUE | FILL_GREEN)不够——若红位原本是 1(原色 yellow),结果会变成 white。必须先清位、再置位。程序里|= BORDER_RED;那行就是故意演示忘清位的后果:边框色成了黄而不是红。 - 位字段版本简单得多:
box.st_view.fill_color = CYAN;一句搞定,不用先清位,填充色和边框色还能共用同一套值。 - 取值方式不同。
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,对有符号负数由实现决定;位字段的布局细节同样由实现决定。
自测题(点击展开答案)
1. 把十进制 3、13、59 转成二进制。 3 = 00000011;13 = 8+4+1 = 00001101;59 = 32+16+8+2+1 = 00111011。
2. 二进制 00010101 的十进制、八进制、十六进制各是多少? 十进制 = 16+4+1 = 21;按 3 位一组 000 010 101 得八进制 025;按 4 位一组 0001 0101 得十六进制 0x15。
3. 求 3 & 6、3 | 6、3 ^ 6、7 >> 1(8 位假设)。 3 = 00000011,6 = 00000110。与:00000010 = 2;或:00000111 = 7;异或:00000101 = 5;7 = 00000111 右移 1 位得 00000011 = 3。
4. ~3 和 !3 有什么区别? ~3 是按位取反:~00000011 = 11111100(8 位下即 -4);!3 是逻辑非,把整个非零值当作”真”,结果为 0。一个是位操作、一个是逻辑操作,完全不同。
5. 为什么检查某位要写 if ((flags & MASK) MASK) 而不是 if (flags MASK)? flags 的其他位可能也是 1,直接比较相等会因无关位而失败。先用 & 清掉无关位,只留 MASK 覆盖的位再比较,才能准确判断目标位;同时 & 优先级低于 ==,外层括号不可省略。