1.3 字节序与位掩码
地心控制台把同一个四字节符文交给两台旧机器:一台显示 0x12345678,另一台却像把符文倒着读。数据没有坏,双方只是没有约定先放最高位还是最低位。等这些 byte 离开机器、沿魔法驿道传输,这种分歧就会直接变成协议错误。
前两课说明了位模式的基本表示和文字编码。这一课处理两个紧邻机器边界的问题:多 byte 数值怎样排列,以及怎样用掩码提取和更新其中的位。我们仍在计算机地心,但已经走到下一卷网络协议会反复使用的接口处。
同一个整数,两种常见排列
把 32-bit 整数 0x12345678 放进四个连续 byte。若按地址从低到高观察:
地址偏移 +0 +1 +2 +3
大端表示 12 34 56 78
小端表示 78 56 34 12大端(big-endian)把最高有效 byte 放在最低地址,小端(little-endian)把最低有效 byte 放在最低地址。这里讨论的是一个多 byte 值的对象表示;单个 byte 没有内部 byte 顺序。
字节序不会改变整数的抽象值。CPU 按本机规则写入和读取时,得到的仍是 0x12345678。问题出现在双方直接交换对象表示,却没有共同约定格式时。
互联网协议通常把多 byte 整数字段规定为 network byte order,也就是大端。套接字 API 的 htons、htonl 与对应的 ntoh* 函数负责主机序和网络序转换;它们只覆盖特定宽度,不能替代完整的序列化设计。
在控制台两端显式编码
那块四字节符文若只在同一台机器内部来回,native byte order 往往不会暴露。把它交给另一台机器或协议边界,才必须由发送方逐 byte 编码、接收方按同一约定解码。先观察本机,再写出不依赖本机顺序的版本。
下面的 C17 程序做两件事:读取一个对象的 byte 表示,以及不依赖本机字节序地编码、解码一个 32-bit 大端整数。
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
static void encode_u32_be(uint32_t value, uint8_t output[4]) {
output[0] = (uint8_t)(value >> 24);
output[1] = (uint8_t)(value >> 16);
output[2] = (uint8_t)(value >> 8);
output[3] = (uint8_t)value;
}
static uint32_t decode_u32_be(const uint8_t input[4]) {
return ((uint32_t)input[0] << 24)
| ((uint32_t)input[1] << 16)
| ((uint32_t)input[2] << 8)
| (uint32_t)input[3];
}
int main(void) {
uint32_t value = UINT32_C(0x12345678);
const uint8_t *native = (const uint8_t *)&value;
uint8_t encoded[4];
printf("native:");
for (size_t index = 0; index < sizeof value; index++) {
printf(" %02" PRIX8, native[index]);
}
putchar('\n');
encode_u32_be(value, encoded);
printf("big-endian: %02" PRIX8 " %02" PRIX8 " %02" PRIX8 " %02" PRIX8 "\n",
encoded[0], encoded[1], encoded[2], encoded[3]);
printf("decoded: 0x%08" PRIX32 "\n", decode_u32_be(encoded));
return 0;
}无论本机序是什么,后两行都应为:
big-endian: 12 34 56 78
decoded: 0x12345678通过 uint8_t 观察对象表示在常见实现上很直观;C 标准最通用的规则是任何对象都可以通过 unsigned char * 检查。这里使用 uint8_t 的同时,代码也隐含了实现提供精确 8-bit 无符号类型这一前提。
不要把 C 结构体整体写进文件或网络。结构体可能有 padding,字段对齐、整数宽度和本机字节序也可能不同。稳定格式应该逐字段规定宽度、顺序、合法范围和缺失值,再显式编码。
掩码是在局部修改状态
位掩码把多个布尔状态压进一个无符号整数。假设第 0、1、2 位分别表示读、写、执行权限:
#include <stdbool.h>
#include <stdint.h>
enum Permission {
PERM_READ = UINT32_C(1) << 0,
PERM_WRITE = UINT32_C(1) << 1,
PERM_EXEC = UINT32_C(1) << 2
};
uint32_t permissions = PERM_READ | PERM_WRITE;
permissions |= PERM_EXEC; /* 设置 */
permissions &= ~((uint32_t)PERM_WRITE); /* 清除 */
permissions ^= PERM_EXEC; /* 翻转 */
bool can_read = (permissions & PERM_READ) != 0; /* 测试 */四种基本操作值得直接记住:
| 目的 | 表达式 | 含义 |
|---|---|---|
| 设置若干位 | `value | = mask` |
| 清除若干位 | value &= ~mask | 掩码为 1 的位置变成 0 |
| 翻转若干位 | value ^= mask | 掩码为 1 的位置取反 |
| 测试任一位 | (value & mask) != 0 | 至少一个目标位已设置 |
若要求掩码中的位全部存在,应比较 (value & mask) == mask。任一位与全部位是接口设计中常见的语义混淆。
Unix 权限 0755 可按三组三位读取:所有者是 7 = 111,组用户和其他用户都是 5 = 101,因此显示为 rwxr-xr-x。开头的 0 是 C 传统八进制字面量写法;在命令行 chmod 755 file 中,工具按权限语法解释参数。
C 的移位不是没有边界的乘除法
位运算优先使用无符号类型,并先检查移位次数。对宽度为 w 的整数,移位次数必须满足 0 <= count < w;负数或大于等于宽度的移位次数会触发未定义行为。
还要区分这些情况:
- 无符号右移在高位补零,结果定义明确;
- 负的有符号整数右移在 C17 中是实现定义行为,不能假设所有平台都做算术右移;
- 有符号正数左移只有在结果可表示且其他条件满足时才安全,溢出会产生未定义行为;
- 无符号左移按模
2^w规则计算,但被移出的高位会丢失; - 写第 n 位时,用与目标类型匹配的无符号常量,例如
UINT32_C(1) << n,并先保证n < 32。
因此,x << 1 不能不加条件地替换 x * 2,x >> 1 也不能普遍替换有符号除以 2。编译器会做合适的强度优化,源码首先应该表达正确语义。
经典的 XOR 交换也不值得用于普通代码。两个变量若指向同一对象,朴素写法会把值清零;即使没有别名,它通常也不比临时变量更清楚或更快。
一个安全的位字段接口
将检查集中到函数中,比在调用处反复手写移位更可靠:
#include <stdbool.h>
#include <stdint.h>
static bool set_bit_u32(uint32_t *value, unsigned int bit) {
if (value == NULL || bit >= 32U) {
return false;
}
*value |= UINT32_C(1) << bit;
return true;
}
static bool test_bit_u32(uint32_t value, unsigned int bit, bool *result) {
if (result == NULL || bit >= 32U) {
return false;
}
*result = (value & (UINT32_C(1) << bit)) != 0;
return true;
}真实项目中还可以用 UINT32_WIDTH(C23)或由类型/配置提供宽度。这里固定为 32,是因为接口明确选用了存在时恰好 32 bit 的 uint32_t。
序列化边界清单
- 先写格式,再写代码:字段宽度、signedness、byte order、文本编码和版本都应明确。
- 解码前验证缓冲区长度,组合字段后再检查数值范围。
- 不要用未对齐的指针强制转换读取 byte 缓冲区;可逐 byte 组合,或复制到合适对象后按协议转换。
- 位掩码常量与被操作值保持同一种无符号宽度,避免整数提升带来的意外高位。
- 测试
0、全 1、最高位、边界移位次数和往返性质decode(encode(x)) == x。
动手验证机器边界
- 运行示例,判断本机
uint32_t的 byte 表示是大端还是小端。 - 实现
encode_u32_le与decode_u32_le,用0、1、0xFFFFFFFF做往返测试。 - 写一个接口设置
[offset, offset + width)位字段,并处理越界与width == 32。 - 解释为什么把网络缓冲区直接转成
uint32_t *可能同时遇到对齐、别名与字节序问题。 - 比较“任一权限存在”和“全部权限存在”两种测试表达式。
下一层是整数如何解释最高位
现在已经能安全地排列 byte、提取和更新 bit。下一章讨论整数、溢出与浮点表示:同一宽度怎样解释正负数,为什么无符号运算回绕,而浮点数又为什么无法精确保存许多十进制小数。