跳到内容

1.3 字节序与位掩码

地心控制台把同一个四字节符文交给两台旧机器:一台显示 0x12345678,另一台却像把符文倒着读。数据没有坏,双方只是没有约定先放最高位还是最低位。等这些 byte 离开机器、沿魔法驿道传输,这种分歧就会直接变成协议错误。

前两课说明了位模式的基本表示文字编码。这一课处理两个紧邻机器边界的问题:多 byte 数值怎样排列,以及怎样用掩码提取和更新其中的位。我们仍在计算机地心,但已经走到下一卷网络协议会反复使用的接口处。

同一个整数,两种常见排列

把 32-bit 整数 0x12345678 放进四个连续 byte。若按地址从低到高观察:

text
地址偏移       +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 的 htonshtonl 与对应的 ntoh* 函数负责主机序和网络序转换;它们只覆盖特定宽度,不能替代完整的序列化设计。

在控制台两端显式编码

那块四字节符文若只在同一台机器内部来回,native byte order 往往不会暴露。把它交给另一台机器或协议边界,才必须由发送方逐 byte 编码、接收方按同一约定解码。先观察本机,再写出不依赖本机顺序的版本。

下面的 C17 程序做两件事:读取一个对象的 byte 表示,以及不依赖本机字节序地编码、解码一个 32-bit 大端整数。

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

无论本机序是什么,后两行都应为:

text
big-endian: 12 34 56 78
decoded: 0x12345678

通过 uint8_t 观察对象表示在常见实现上很直观;C 标准最通用的规则是任何对象都可以通过 unsigned char * 检查。这里使用 uint8_t 的同时,代码也隐含了实现提供精确 8-bit 无符号类型这一前提。

不要把 C 结构体整体写进文件或网络。结构体可能有 padding,字段对齐、整数宽度和本机字节序也可能不同。稳定格式应该逐字段规定宽度、顺序、合法范围和缺失值,再显式编码。

掩码是在局部修改状态

位掩码把多个布尔状态压进一个无符号整数。假设第 0、1、2 位分别表示读、写、执行权限:

c
#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 * 2x >> 1 也不能普遍替换有符号除以 2。编译器会做合适的强度优化,源码首先应该表达正确语义。

经典的 XOR 交换也不值得用于普通代码。两个变量若指向同一对象,朴素写法会把值清零;即使没有别名,它通常也不比临时变量更清楚或更快。

一个安全的位字段接口

将检查集中到函数中,比在调用处反复手写移位更可靠:

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

动手验证机器边界

  1. 运行示例,判断本机 uint32_t 的 byte 表示是大端还是小端。
  2. 实现 encode_u32_ledecode_u32_le,用 010xFFFFFFFF 做往返测试。
  3. 写一个接口设置 [offset, offset + width) 位字段,并处理越界与 width == 32
  4. 解释为什么把网络缓冲区直接转成 uint32_t * 可能同时遇到对齐、别名与字节序问题。
  5. 比较“任一权限存在”和“全部权限存在”两种测试表达式。

下一层是整数如何解释最高位

现在已经能安全地排列 byte、提取和更新 bit。下一章讨论整数、溢出与浮点表示:同一宽度怎样解释正负数,为什么无符号运算回绕,而浮点数又为什么无法精确保存许多十进制小数。

Built with VitePress | Software Systems Atlas