第15章:位操作
位操作把整数看作一组二进制位,适合表达开关、标志集合和紧凑字段。位字段让结构成员直接使用有限位宽,对齐工具则描述对象在内存中的放置要求。
本章的主线是:明确整数类型和位宽,再用掩码选择目标位,最后验证操作没有影响其他字段。 位图示意、C 表达式类型和实际内存布局要分别理解。
1. 二进制、字节与数值表示
1.1 位的位置决定权重
一个无符号二进制整数可以按各位权重展开:
二进制: 1 0 1 1 0 1
位编号: 5 4 3 2 1 0
权重: 32 16 8 4 2 1
101101 = 32 + 8 + 4 + 1 = 45
最低位编号为 0,权重是 1;第 n 位的权重是 2^n。
这里“第 0 位”按数值意义编号,不是在描述它一定存放在内存的第一个字节。整数的数值位编号与多字节对象的字节顺序是不同概念。
1.2 C 的一个字节不必恰好是 8 位
#include <limits.h>
size_t storage_bits = sizeof(unsigned int) * CHAR_BIT;
sizeof 以 C 字节为单位,CHAR_BIT 给出每个字节的位数。sizeof(char) 始终为 1,但 CHAR_BIT 不必在所有实现上都是 8。
上述乘积得到对象表示占用的位数;若整数类型存在填充位,就不能简单把它当成全部有效数值位数。
以下 8 位图只是讲解用的固定宽度视图。需要精确的 32 位无符号数时,可以使用提供该类型的实现中的 <stdint.h> 的 uint32_t;这种精确宽度类型没有填充位,但不是所有实现都必须提供。
1.3 无符号与有符号表示
8 个有效数值位的无符号整数范围为 0 到 255。常见的 8 位二进制补码有符号表示范围为 -128 到 127。
补码示意:
1 → 00000001
-1 → 11111111
-128 → 10000000
在本章采用的 C99/C11 背景下,还需要区分符号量、反码与补码等可能的有符号表示,不能把某台机器的负数位模式当作全部实现的规则。
即使实现使用补码,有符号算术溢出也不能按“自动绕回”处理。无符号算术按其取值范围进行模运算,语义不同。
1.4 二进制小数解释了部分浮点误差
二进制 0.101 = 1/2 + 0/4 + 1/8 = 十进制 0.625
有限二进制小数能够精确表示适合其位数的二的幂分母分数;很多十进制小数不能表示为有限二进制小数,所以会被近似保存。
浮点表示通常把有效数字与指数分开。乘以适当的二的幂可能主要改变指数,但仍需考虑范围、舍入与特殊值,不能把任意浮点运算当作对整数位模式的简单移动。
2. 八进制与十六进制让位模式更紧凑
2.1 一个八进制位对应 3 位,一个十六进制位对应 4 位
二进制: 1101 0010
十六进制: D 2 → 0xD2
二进制: 011 010 010
八进制: 3 2 2 → 0322
0xD2、0322 和十进制 210 表示相同数值。写法只改变源码表示,不会自动改变对象类型的位宽。
2.2 常见十六进制位值
| 十六进制 | 二进制 | 十进制 |
|---|---|---|
0x0 |
0000 |
0 |
0x1 |
0001 |
1 |
0x2 |
0010 |
2 |
0x4 |
0100 |
4 |
0x8 |
1000 |
8 |
0xA |
1010 |
10 |
0xF |
1111 |
15 |
0xFF 表示低 8 位全为 1;0x0F 表示低 4 位全为 1。
在 C99/C11 的可移植源码中,整数常量常用十进制、八进制或十六进制书写。不要把说明图中的 00001010 直接当成二进制常量写进代码,前导零会使它按八进制解释。
无符号掩码通常显式加后缀,例如 1u、0xFFu。精确宽度示例使用 UINT32_C(...) 等宏帮助表达相应常量。
3. 四个按位逻辑运算符
3.1 对应位分别运算
输入位 a |
输入位 b |
a & b |
a | b |
a ^ b |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 |
| 1 | 1 | 1 | 1 | 0 |
~a 把每个位取反,0 变成 1,1 变成 0。
a: 10101010
b: 11001100
a & b: 10001000
a | b: 11101110
a ^ b: 01100110
这些运算通常产生新值,不会单独改变原变量。需要更新变量时,使用赋值或相应复合赋值:
flags &= mask;
flags |= mask;
flags ^= mask;
3.2 按位运算与普通逻辑运算不同
| 表达式 | 结果含义 |
|---|---|
2u & 4u |
对应位没有共同的 1,结果为 0 |
2u && 4u |
两个值都非零,结果为 1 |
2u | 4u |
合并位,结果为 6 |
2u || 4u |
至少一个值非零,结果为 1 |
!0u |
逻辑非,结果为 1 |
~0u |
unsigned int 各值位全为 1 |
&&、|| 有短路求值,&、| 不具备这种替代关系。^ 是按位异或,不是乘方。
3.3 整数提升会影响实际操作宽度
unsigned char small = 2;
在常见的 8 位 unsigned char、较宽 int 实现中,计算 ~small 之前,small 会先提升为 int。因此结果不是自动限制在 8 位内的 253,而是一个 int 表达式。
若想得到窄无符号类型中取反后的低位结果,可以先确保按无符号整数运算,再显式转换回目标类型:
unsigned char inverted = (unsigned char)~(unsigned int)small;
在 CHAR_BIT == 8 的实现中,inverted 为 253。位图中展示了 8 个格子,不代表表达式求值时也只使用了 8 位。
4. 掩码:选择哪些位参与操作
掩码中为 1 的位置表示目标位,为 0 的位置表示其他位。不同操作对它的使用方式不同。
| 目的 | 表达式 | 对其他位的影响 |
|---|---|---|
| 仅保留目标位 | value & mask |
其他位清零 |
| 设置目标位为 1 | value | mask |
其他位保持 |
| 清除目标位 | value & ~mask |
其他位保持 |
| 翻转目标位 | value ^ mask |
其他位保持 |
| 检查任意目标位为 1 | (value & mask) != 0 |
不修改 |
| 检查所有目标位为 1 | (value & mask) == mask |
不修改 |
| 检查所有目标位为 0 | (value & mask) == 0 |
不修改 |
检查“任意一位”和“所有位”只有在单比特非零掩码时才会自然一致。对于零掩码,(value & mask) == mask 恒成立,接口要避免无意传入空目标集合。
4.1 完整示例:维护标志集合
#include <stdio.h>
#define FLAG_READ (1u << 0)
#define FLAG_WRITE (1u << 1)
#define FLAG_READY (1u << 2)
int main(void)
{
unsigned int flags = 0;
flags |= FLAG_READ | FLAG_WRITE;
printf("read and write: %s\n",
(flags & (FLAG_READ | FLAG_WRITE)) ==
(FLAG_READ | FLAG_WRITE) ? "yes" : "no");
flags &= ~FLAG_WRITE;
flags ^= FLAG_READY;
printf("read: %u\n", (flags & FLAG_READ) != 0 ? 1u : 0u);
printf("write: %u\n", (flags & FLAG_WRITE) != 0 ? 1u : 0u);
printf("ready: %u\n", (flags & FLAG_READY) != 0 ? 1u : 0u);
return 0;
}
输出:
read and write: yes
read: 1
write: 0
ready: 1
4.2 检查时给按位部分加括号
if ((flags & FLAG_READ) == FLAG_READ) {
/* 目标位已设置。 */
}
== 比按位 & 结合得更紧。写成 flags & FLAG_READ == FLAG_READ,会先比较两个掩码,再把比较结果与 flags 按位与,含义已经改变。
复合赋值的左侧只求值一次,也不要把它与重复书写带副作用左值的普通赋值机械等同。
5. 移位:位的位置变化,类型规则仍然适用
5.1 无符号移位的基本效果
固定 8 位视图:
00010110 左移 2 位 → 01011000
00010110 右移 2 位 → 00000101
无符号右移用零填充高位;无符号左移在有效结果类型范围内按相应模数计算,移出的高位不再保留。
unsigned int value = 3u;
unsigned int moved = value << 2; // 12,value 仍为 3。
value <<= 2; // 更新 value 为 12。
<<=、>>= 用于更新左侧对象。实际移位仍会先受到整数提升的影响。
5.2 移位数量必须合法
移位数不能为负,也不能大于等于左操作数提升后类型的位宽。无符号类型不意味着任何移位数量都合法。
// uint32_t value = 1;
// value << 32; // 若提升后类型为 32 位,这不是合法的“得到零”操作。
构造第 n 位掩码时,使用适当类型的 1,并先验证 n:
uint32_t mask = UINT32_C(1) << position; // 此处约定 position < 32。
不要先用可能过窄的 1 << n 完成危险运算,再把结果强制转换成较宽类型。
5.3 有符号移位需要额外条件
在 C99/C11 背景下:
- 对负有符号数左移,不具有可依赖的普通位图语义。
- 非负有符号数左移,也必须符合结果可表示等规则。
- 负有符号数右移的结果由实现定义,不能假定所有实现都进行符号扩展。
- 非负数右移可以按除以二的幂、舍去余数理解。
做掩码与打包时,优先使用合适的无符号类型。做普通乘除法时,直接写算术通常更能表达意图,编译器可以选择适当优化。
6. 多位字段:先提取,再清空后写入
6.1 提取字段
假设颜色位于整数的第 4 到第 6 位,共 3 位:
位置: ... 7 6 5 4 3 2 1 0
内容: ... x [颜色] [其他信息]
可以先右移,再保留低 3 位:
unsigned int color = (settings >> 4) & 7u;
6.2 修改字段不能只做按位或
若旧颜色是 3、新颜色是 4,直接把新值按位或进去会得到 7,而不是替换成 4。
正确步骤是:清除旧字段,移动新值到位,再合并;同时保证新值没有超出字段宽度。
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#define COLOR_SHIFT 4u
#define COLOR_MASK (UINT32_C(7) << COLOR_SHIFT)
int set_color(uint32_t *settings, unsigned int color);
int main(void)
{
uint32_t settings = UINT32_C(1) << 12;
if (!set_color(&settings, 5)) {
return 1;
}
printf("color: %u\n",
(unsigned int)((settings >> COLOR_SHIFT) & UINT32_C(7)));
printf("raw: 0x%08" PRIX32 "\n", settings);
return 0;
}
int set_color(uint32_t *settings, unsigned int color)
{
if (color > 7u) {
return 0;
}
uint32_t moved = (uint32_t)color << COLOR_SHIFT;
*settings = (uint32_t)(((uintmax_t)*settings &
~(uintmax_t)COLOR_MASK) | moved);
return 1;
}
输出:
color: 5
raw: 0x00001050
第 12 位是其他设置,修改颜色后仍然保留。非法颜色返回失败,并保持原值。
这里选择拒绝超范围输入。另一种接口可能有意保留低位进行截断,但那是不同的输入策略,不能把“按掩码截断”当成“已经验证数值合法”。
7. 二进制字符串与整数的相互转换
7.1 逐位累积,同时检查输入与溢出
读取一个二进制数字,相当于把已有数值乘以 2,再加上当前位。下面的程序只接受非空、完全由 0 和 1 组成、且数值能放入 uint32_t 的字符串:
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
int parse_binary32(const char *text, uint32_t *out);
void to_bits32(uint32_t value, char out[static 33]);
int main(void)
{
uint32_t value;
char bits[33];
if (!parse_binary32("01001001", &value)) {
return 1;
}
to_bits32(value, bits);
printf("value: %" PRIu32 "\n", value);
printf("bits: %s\n", bits);
return 0;
}
int parse_binary32(const char *text, uint32_t *out)
{
if (*text == '\0') {
return 0;
}
uint32_t value = 0;
for (; *text != '\0'; ++text) {
if (*text != '0' && *text != '1') {
return 0;
}
if (value > (UINT32_MAX >> 1)) {
return 0;
}
value = (uint32_t)((value << 1) | (unsigned int)(*text - '0'));
}
*out = value;
return 1;
}
void to_bits32(uint32_t value, char out[static 33])
{
for (unsigned int i = 0; i < 32; ++i) {
out[31u - i] = (value & UINT32_C(1)) != 0 ? '1' : '0';
value >>= 1;
}
out[32] = '\0';
}
输出:
value: 73
bits: 00000000000000000000000001001001
01001001 的数值是 64 + 8 + 1 = 73。进制转换应按位权核对,不能只根据字符串外观猜测数值。
这个解析接口允许前导零,失败时不修改输出对象。out 必须指向可写的 uint32_t,输入必须是有效字符串。
7.2 输出的是数值的固定宽度表示
to_bits32 每次提取最低位,从结果数组末尾向前填入字符,最后补 \0。数组需要 33 个位置:32 个数字和一个结束标记。
这个函数描述无符号数值,不是在逐字节展示一个任意对象的内存。把负整数转换为 uint32_t,得到的是按无符号转换规则产生的值,也不等于在所有 C99/C11 实现上直接观察负数原始对象表示。
输出精确宽度整数时,<inttypes.h> 的 PRIu32、PRIX32 等宏可以避免猜测 uint32_t 的底层类型。
8. 位计数、低位掩码与循环移位
8.1 统计值为 1 的位数
unsigned int count_bits(uint32_t value)
{
unsigned int count = 0;
while (value != 0) {
value &= value - UINT32_C(1);
++count;
}
return count;
}
对于非零值,value & (value - 1) 会清除最低的那个 1。循环次数等于原数值中 1 的数量;零值不进入循环。
另一种直观方式是每次检查最低位,再右移一位。两种写法都应使用合适无符号类型,不要依赖负数右移。
8.2 构造低 n 位全为 1 的掩码
常见公式是 (1 << n) - 1,但它需要处理类型与位宽边界:
int low_mask32(unsigned int count, uint32_t *out)
{
if (count > 32u) {
return 0;
}
if (count == 32u) {
*out = UINT32_MAX;
} else {
*out = (uint32_t)((UINT32_C(1) << count) - UINT32_C(1));
}
return 1;
}
count == 0 得到零掩码;count == 32 单独返回全 1,避免移位 32 位。非法数量返回失败,输出对象保持原状。
有了有效掩码,value ^ mask 就能只翻转低 n 位。
8.3 循环移位把移出的位重新接回另一端
固定 8 位示意:
10010001 循环左移 3 位 → 10001100
普通左移丢弃高位,循环左移则把它们接回低端。对 32 位数值:
uint32_t rotate_left32(uint32_t value, unsigned int count)
{
unsigned int shift = count % 32u;
if (shift == 0) {
return value;
}
return (uint32_t)(((uintmax_t)value << shift) |
((uintmax_t)value >> (32u - shift)));
}
数量先对 32 取余,再单独处理零移位,避免另一侧出现右移 32 位。uintmax_t 提供无符号运算类型,最后收窄回目标 32 位。
这个函数约定移位 32、64 等整倍数等价于不移动。接口先确定好这种行为,再写运算式,会比依赖某台处理器如何处理非法移位更可靠。
9. 位字段:通过成员名访问有限位宽的值
9.1 在结构成员后面指定宽度
struct settings {
unsigned int enabled : 1;
unsigned int color : 3;
unsigned int style : 2;
};
宽度表示字段的位数。这里 enabled 能表示 0 到 1,color 能表示 0 到 7,style 能表示 0 到 3。
使用方式与普通结构成员相似:
struct settings value = {.enabled = 1, .color = 5, .style = 2};
value.color = 3;
位字段适合集中保存内部设置,但不会自动验证“所有可表示值是否都有业务意义”。例如只有三种样式时,2 位字段中的 3 仍然是需要处理的额外值。
9.2 明确无符号,避免猜测普通 int 字段的符号性
本章标准背景下常用的位字段类型包括 _Bool、signed int、unsigned int;其他允许类型由实现规定。
对于普通 int field : n,其有符号或无符号性质由实现决定。表示非负设置时,显式写 unsigned int 更清楚。
宽度必须满足相应类型的约束。向字段赋超范围数值时,不能把截断结果当作输入验证;应先判断允许范围。
9.3 完整示例:验证后更新设置
#include <stdio.h>
struct settings {
unsigned int enabled : 1;
unsigned int color : 3;
unsigned int style : 2;
};
int set_style(struct settings *settings, unsigned int style);
int main(void)
{
struct settings value = {.enabled = 1, .color = 5, .style = 0};
if (!set_style(&value, 2)) {
return 1;
}
printf("enabled: %u, color: %u, style: %u\n",
(unsigned int)value.enabled,
(unsigned int)value.color,
(unsigned int)value.style);
return 0;
}
int set_style(struct settings *settings, unsigned int style)
{
if (style > 2u) {
return 0;
}
settings->style = style;
return 1;
}
输出:
enabled: 1, color: 5, style: 2
输出时显式转换成 %u 对应的类型,避免忽略窄位字段可能发生的整数提升。
10. 位字段布局不能直接当作文件格式
10.1 匿名字段与零宽字段
struct layout {
unsigned int first : 1;
unsigned int : 2;
unsigned int second : 1;
unsigned int : 0;
unsigned int third : 1;
};
未命名字段可以留出位空间;零宽未命名字段要求后续字段不再塞入前一个字段所在的同一分配单元。
这不意味着程序员已经得到了所有平台统一的字节布局。分配单元、位排列方向、跨单元处理和对齐等细节可能依赖实现。
10.2 不要假定第一个字段就是整数最低位
成员声明顺序 ≠ 可移植的具体数值位位置
把位字段结构和整数放进联合后,通过一边写成员、另一边写掩码,依赖它们拥有完全一致的布局。这种对应关系必须由具体实现与目标环境确认,不能从字段声明顺序直接推出。
同样,几个 1 位字段并不保证整个结构只占一个字节,也不保证结构大小恰好等于 sizeof(unsigned int)。
10.3 位字段不是独立可取地址的普通对象
不能对位字段使用普通取地址操作,也不能直接对某个位字段应用 sizeof。不能依赖 offsetof 来取得一个位字段的可用字节偏移。
对于需要固定协议或文件位布局的任务,显式的无符号数值、移位和掩码通常更容易定义和核对;位字段更适合由实现负责内部布局的场合。
11. 数值位顺序与内存字节顺序
例如数值 0x12345678 的最高字节是 0x12,最低字节是 0x78,这是数值意义。
它在内存中可以按不同字节顺序排列。对这个数值使用 >> 24 与低 8 位掩码,得到高字节数值,并不依赖直接观察内存时先看见哪个字节。
uint32_t value = UINT32_C(0x12345678);
unsigned int high = (unsigned int)((value >> 24) & UINT32_C(0xFF));
unsigned int low = (unsigned int)(value & UINT32_C(0xFF));
这里 high 为 0x12,low 为 0x78。
如果要写入规定按 8 位单元组成的外部格式,还应确认本实现的字节模型与格式要求相符,并明确逐字节编码规则。把原始整数对象直接 fwrite 出去,不会自动变成某种统一的外部字节序。
12. 对齐:类型需要对象放在怎样的位置
12.1 _Alignof 查询类型要求
#include <stddef.h>
size_t requirement = _Alignof(double);
结果类型是 size_t,以字节单位表达对齐要求。它不是 double 的数值精度,也不直接等于相邻成员之间的距离。
不同类型可能有不同要求。同一种结构的成员布局与尾部填充,也会受到这些要求影响。
12.2 _Alignas 为对象提出对齐要求
_Alignas(double) unsigned char storage[sizeof(double)];
这要求该数组满足 double 的对齐。也可以使用实现支持的有效对齐常量表达式,不能要求对象使用低于其本来要求的弱对齐。
对齐只说明地址放置要求,不会自动改变对象的声明类型,也不意味着任意指针强制转换和访问方式都合法。
_Alignas 放在指针变量声明中,约束的是指针对象本身的对齐,不会自动约束它后来指向的那块内存。
12.3 完整示例:通过成员要求提高结构对齐
#include <stdio.h>
struct aligned_block {
_Alignas(double) unsigned char bytes[sizeof(double)];
};
int main(void)
{
struct aligned_block block = {{0}};
block.bytes[0] = 7;
printf("meets double alignment: %s\n",
_Alignof(struct aligned_block) >= _Alignof(double)
? "yes" : "no");
printf("first byte: %u\n", (unsigned int)block.bytes[0]);
return 0;
}
输出:
meets double alignment: yes
first byte: 7
在 C11 中包含 <stdalign.h>,还可以使用 alignas 与 alignof 这两个宏名称对应相应关键字功能。
12.4 动态分配的对齐要求
malloc 为具有基本对齐要求的对象提供相应保证,不能由此推断它必然满足所有更强的扩展对齐要求。
C11 提供 aligned_alloc:
void *aligned_alloc(size_t alignment, size_t size);
请求的对齐必须由实现支持,大小应是对齐的整数倍;失败返回空指针,成功结果使用 free 释放。
#include <stddef.h>
#include <stdlib.h>
void *memory = aligned_alloc(_Alignof(max_align_t), sizeof(max_align_t));
if (memory != NULL) {
/* 在正确的对象与访问规则下使用 memory。 */
free(memory);
}
这里选择实现的基本最大对齐类型。若根据外部输入计算分配大小,还要验证乘法与向对齐倍数取整时是否溢出。接口是否可用以及支持哪些扩展对齐,需要结合相应实现。
13. 把位操作组织成可验证的接口
| 任务 | 需要明确的条件 |
|---|---|
| 构造单个位掩码 | 类型、有效位数、位置范围 |
| 检查标志集合 | 检查任意位还是所有位,是否允许空掩码 |
| 替换多位字段 | 字段位置、位宽、输入范围以及旧值清除 |
| 构造低位掩码 | 零位、全部位和非法数量的处理 |
| 循环移位 | 固定位宽、零移位、整倍数移位语义 |
| 解析二进制文本 | 字符集合、是否允许空输入及前导零、结果范围 |
| 位字段结构 | 可表示值与业务允许值、实现布局要求 |
| 对齐分配 | 有效对齐、大小倍数、大小计算和释放责任 |
把掩码和移位封装在有明确输入范围的小函数中,调用者就能围绕“读取颜色”“修改样式”等任务操作,不必到处重复记忆位位置。验证时,既检查目标字段是否正确,也检查其他位是否保持原样。

