跳到内容

第 3 卷 / 第 1 章 / 第 3 课

1.3 字节序与位掩码 ​

同一个四 byte 序列,一台机器可能按 0x12345678 解读,另一台却可能得到相反顺序。数据没有坏,双方只是没有约定先放最高位 byte 还是最低位 byte。等这些 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 的 htons、htonl 与对应的 ntoh* 函数负责主机序和网络序转换;它们只覆盖特定宽度,不能替代完整的序列化设计。

显式编码与解码 ​

下面的 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 unsigned char *native = (const unsigned char *)&value;
    uint8_t encoded[4];

    printf("native:");
    for (size_t index = 0; index < sizeof value; index++) {
        printf(" %02X", (unsigned int)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;
}

保存为 byte_order.c,运行 cc -std=c17 -Wall -Wextra -Werror byte_order.c -o byte_order,然后执行 ./byte_order。

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

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

原生对象表示通过 unsigned char * 读取,这是 C 的字符类型别名规则允许的方式;不要仅因 uint8_t 恰好也是 8 位,就假设它在所有实现上都属于字符类型。本例的序列化缓冲区仍使用 uint8_t,明确要求实现提供精确 8 位和 32 位无符号类型。两个编解码函数都要求调用方提供至少 4 字节的有效缓冲区,数组形参本身不会检查长度。

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

掩码是在局部修改状态 ​

位掩码把多个布尔状态压进一个无符号整数。假设第 0、1、2 位分别表示读、写、执行权限。下面是独立可编译程序,最终输出 1 1,表示只剩读取位且读取测试为真:

c
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>

enum Permission {
    PERM_READ  = UINT32_C(1) << 0,
    PERM_WRITE = UINT32_C(1) << 1,
    PERM_EXEC  = UINT32_C(1) << 2
};

int main(void) {
    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; /* 测试 */
    printf("%u %d\n", (unsigned int)permissions, can_read);
    return 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 交换也不值得用于普通代码。两个变量若指向同一对象,朴素写法会把值清零;即使没有别名,它通常也不比临时变量更清楚或更快。

一个安全的位字段接口 ​

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

c
#include <stdbool.h>
#include <stddef.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_le 与 decode_u32_le,用 0、1、0xFFFFFFFF 做往返测试。
  3. 写一个接口设置 [offset, offset + width) 位字段,并处理越界与 width == 32。
  4. 解释为什么把网络缓冲区直接转成 uint32_t * 可能同时遇到对齐、别名与字节序问题。
  5. 比较“任一权限存在”和“全部权限存在”两种测试表达式。

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

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

Built with VitePress | Software Systems Atlas