Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.2 信息的表示与运算

本章回答

为什么 0.1 + 0.2 != 0.3?为什么 -1 < 0u 是假?一个 8 位补码数 0xFB 到底是 251 还是 -5?结构体里那些看不见的填充字节从哪来?

读完你会

在十进制、十六进制、二进制之间手算转换;写出补码取负、符号扩展、溢出检测的代码;拆解 IEEE 754 的符号/阶码/尾数并解释舍入误差;用 offsetof 与字节序解释内存布局。

计算机里的所有信息——整数、浮点数、字符、指针、指令——最终都是位(bit)。位本身没有类型,类型是解释位的方式:同一串 32 个位,按无符号解释是 4294967295,按补码解释是 -1,按 IEEE 754 单精度解释是一个 NaN。C 语言的类型系统做的核心事情,就是规定「这块内存用哪种解释」。

这一章讲三组规则:整数怎么编码、运算怎么溢出、浮点怎么近似。三组规则都有精确的数学定义,也都有容易被编译器利用的边界(未定义行为)。理解它们不是为了应付面试题,而是为了在调试器里看到 0xffffffff 时,能立刻判断它是 -1、是 UINT_MAX、还是一个被截断的指针。

约定:除非特别说明,字长 w=32w=32,字节序为小端,编译器为 GCC 13、平台 x86-64。文中所有位运算例子都可以用 printf 验证。

1.2.1 位、字节、字与十六进制

一个字节(byte)是 8 位,取值 0–255。字(word)是机器一次处理的数据宽度:x86-64 的通用寄存器是 64 位,所以「字」通常指 64 位;但 C 的 int 仍是 32 位,long 在 LP64 上是 64 位。不要把「字长」和「int 宽度」混为一谈。

十六进制是二进制的紧凑写法:一位十六进制对应 4 位二进制。记住 8 个映射,转换就不需要计算器:

十六进制二进制十六进制二进制
0000081000
1000191001
20010A1010
30011B1011
40100C1100
50101D1101
60110E1110
70111F1111

例:0x3F8A = 0011 1111 1000 1010。按权展开:3×163+15×162+8×16+10=12288+3840+128+10=162663 \times 16^3 + 15 \times 16^2 + 8 \times 16 + 10 = 12288 + 3840 + 128 + 10 = 16266。反过来,十进制转十六进制可以不断除以 16 取余,或先转二进制再每 4 位分组。

C 标准只保证「至少」的宽度,不保证具体值。LP64(Linux、macOS)与 LLP64(Windows)的关键差别是 long:

#include <stdio.h>
#include <stdint.h>
#include <limits.h>

int main(void)
{
    printf("char=%zu short=%zu int=%zu long=%zu ptr=%zu\n",
           sizeof(char), sizeof(short), sizeof(int), sizeof(long), sizeof(void *));
    printf("INT_MIN=%d INT_MAX=%d UINT_MAX=%u\n", INT_MIN, INT_MAX, UINT_MAX);
    printf("int8=[%d,%d] uint8 max=%u\n", INT8_MIN, INT8_MAX, UINT8_MAX);
    printf("int64=[%lld,%lld]\n", (long long)INT64_MIN, (long long)INT64_MAX);
    return 0;
}

在 x86-64 Linux 上输出为 char=1 short=2 int=4 long=8 ptr=8。跨平台代码不要用 long 存指针或文件偏移,用 intptr_t、off_t、size_t;不要假设 int 是 4 字节,用 stdint.h 的 int32_t。

定义 1.2.1 · 字长与数据模型

字长是 CPU 通用寄存器与地址的位宽。数据模型用三个字母描述 int/long/指针的宽度:LP64 是 32/64/64(Linux、macOS),LLP64 是 32/32/64(Windows),ILP32 是 32/32/32(32 位系统)。C 只规定 sizeof(char)==1 与最小范围,其余由实现决定。

1.2.2 无符号、补码与取值范围

无符号数的编码最直接:位向量 x⃗\vec{x} 的值是

B2Uw(x⃗)=∑i=0w−1xi2i. B2U_w(\vec{x}) = \sum_{i=0}^{w-1} x_i 2^i .

补码(two’s complement)把最高位的权重取负:

B2Tw(x⃗)=−xw−12w−1+∑i=0w−2xi2i. B2T_w(\vec{x}) = -x_{w-1}2^{w-1} + \sum_{i=0}^{w-2} x_i 2^i .

同一个位向量 1111 1011(0xFB):

  • 按无符号:128+64+32+16+8+0+2+1=251128+64+32+16+8+0+2+1 = 251;
  • 按 8 位补码:−128+64+32+16+8+0+2+1=−5-128+64+32+16+8+0+2+1 = -5。

所以 0xFB 不是「一个数」,而是「一个位向量 + 一种解释」。C 里 (uint8_t)0xFB 是 251,(int8_t)0xFB 是 -5。

8 位补码的取值是 −128-128 到 127127,不对称:负数比正数多一个。原因是最小值 0x80 没有对应的正数。一般地,

TMinw=−2w−1,TMaxw=2w−1−1,UMaxw=2w−1. TMin_w = -2^{w-1},\qquad TMax_w = 2^{w-1}-1,\qquad UMax_w = 2^w-1 .

宽度 wwTMinTMinTMaxTMaxUMaxUMax
8-128127255
16-327683276765535
32-214748364821474836474294967295
64-9223372036854775808922337203685477580718446744073709551615

补码的关键性质是加法与无符号加法共用同一套电路:把位向量按无符号相加,再丢弃最高位进位,结果的位模式与补码相加完全一致。这就是为什么 CPU 不需要两套加法器。

                    0x00 (0)
             0x7F (127)   0xFF (-1)
          0x7E (126)       0xFE (-2)
          ...                  ...
          0x02 (2)         0x82 (-126)
          0x01 (1)         0x81 (-127)
                    0x80 (-128)
        顺时针:+1;从 0x7F 到 0x80 是「正溢出」到 TMin

这张「补码圆环」说明两件事:第一,加 1 就是顺时针走一格,0x7F + 1 走到 0x80,按补码解释从 127 变成 -128;第二,圆环上没有「终点」,所以有符号加法溢出后得到的仍是一个合法位模式,只是数学值错了。C 标准把这种有符号溢出定义为未定义行为(UB),而不是「回绕」,原因在 1.2.4 展开。

取负的位级做法是「取反加一」:

−x=∼x+1(按 w 位截断). -x = \sim x + 1 \quad (\text{按 } w \text{ 位截断}).

验证 x=5x=5(0x05):~0x05 = 0xFA,加 1 得 0xFB,即 -5。对 TMinTMin 取负会得到它自己(0x80 → 0x7F + 1 = 0x80),这是补码不对称的直接后果;在 C 里对 INT_MIN 取负是 UB。

C 的整数转换规则容易埋坑:当有符号与无符号混合运算时,有符号数会被转成无符号(若其宽度不大于无符号类型)。于是:

#include <stdio.h>

int main(void)
{
    int a = -1;
    unsigned b = 0;
    printf("-1 < 0u : %d\n", a < b);          /* 0:a 被转成 UINT_MAX */
    printf("-1 == UINT_MAX : %d\n", a == (int)4294967295u);
    printf("(unsigned)-1 = %u\n", (unsigned)a);
    return 0;
}

第一行输出 0,因为 a 先转成 unsigned,-1 变成 4294967295,当然不小于 0。这类比较在 for (unsigned i = n; i >= 0; i--) 里会变成死循环。开启 -Wsign-compare(-Wall 已含)能让编译器提醒大部分情况。

关于补码的四个误解
  1. 「补码就是取反加一」——取反加一是「求相反数」的算法,不是编码定义。编码定义是最高位权重为负;理解这一点才能解释为什么 0x80 是 -128 而不是 128。
  2. 「有符号溢出会回绕成负数」——C 标准说这是未定义行为,编译器可以假设它不发生。实际硬件可能回绕,但优化后的代码可能把整个循环删掉。
  3. 「char 默认是 signed」——由实现决定。x86-64 Linux 上 char 有符号,ARM64 Linux 上通常无符号(取决于编译器默认)。需要明确时用 signed char/unsigned char。
  4. 「0xFFFFFFFF 就是 -1」——它是 32 位的位模式;按 int 解释是 -1,按 unsigned 解释是 4294967295。类型决定了答案。

1.2.3 符号扩展、截断与类型转换

把一个较短的整数放进较宽的类型,需要决定高位填什么。无符号数填 0(零扩展);补码数填符号位(符号扩展),才能保持数值不变。

例:8 位 0x8F 按 int8_t 是 -113。扩展到 16 位,符号位是 1,所以高 8 位全填 1:

0x8F        = 1000 1111                 = -113 (int8)
0xFF8F      = 1111 1111 1000 1111       = -113 (int16)
0x008F      = 0000 0000 1000 1111       = +143 (错误:零扩展)

C 的整型提升(integer promotion)会自动做这件事:int8_t 参与运算前先提升为 int,因为 int 能表示它的全部值,所以是符号扩展。反过来,把宽类型赋给窄类型是截断:保留低 ww 位,丢弃高位,结果按目标类型解释。

int32_t x = 0x000001FF;     /* 511 */
int8_t  y = (int8_t)x;      /* 低 8 位 0xFF,按补码是 -1 */
uint8_t z = (uint8_t)x;     /* 低 8 位 0xFF,无符号是 255 */
int16_t s = (int16_t)0x8F;  /* 0x008F = 143,不是 -113 */
int16_t t = (int16_t)(int8_t)0x8F;  /* 先按 int8 解释,再符号扩展:-113 */

第三行是常见错误:0x8F 的类型是 int,值是 143,直接截断到 16 位仍是 143;只有先告诉编译器「它是一个 8 位有符号数」,才会发生符号扩展。

看内存里的字节,需要把对象地址转成字节指针。下面的程序同时展示字节序与符号扩展:

#include <stdio.h>
#include <stdint.h>
#include <string.h>

static void show_bytes(const void *p, size_t n)
{
    const unsigned char *b = p;
    for (size_t i = 0; i < n; i++)
        printf(" %02x", b[i]);
    putchar('\n');
}

int main(void)
{
    uint32_t x = 0x01234567u;
    int16_t  s = -113;          /* 0xFF8F */
    double   d = 0.15625;

    printf("x :"); show_bytes(&x, sizeof x);
    printf("s :"); show_bytes(&s, sizeof s);
    printf("d :"); show_bytes(&d, sizeof d);
    return 0;
}

在 x86-64 上输出:

x : 67 45 23 01
s : 8f ff
d : 00 00 00 00 00 00 c4 3f

x 的低字节 0x67 在最前面,说明是小端;s 的 0x8F 在前、符号扩展的 0xFF 在后;d 的位模式是 0x3FC4000000000000,小端存放,所以最高字节 0x3f 在最后。

定义 1.2.2 · 小端与大端

多字节对象的字节顺序:小端(little-endian)把最低有效字节放在最低地址;大端(big-endian)把最高有效字节放在最低地址。x86-64、ARM64(默认)是小端;网络协议使用大端,称为网络字节序。字节序只在「把对象当字节数组看」时才有意义,例如序列化、IPC、抓包。

1.2.4 整数运算与溢出

无符号加法是模 2w2^w 的加法:

x+wuy=(x+y) mod 2w. x +_w^u y = (x + y) \bmod 2^w .

UINT_MAX + 1 的结果是 0,这是定义良好的行为,C 标准保证。补码加法在数学上也是模 2w2^w,但 C 标准把「结果超出可表示范围」的情况留作未定义:

int x = INT_MAX;
x = x + 1;          /* UB:编译器可以假设 x+1 不会溢出 */

为什么标准不直接规定回绕?因为编译器需要利用「有符号加法不溢出」做优化。例如:

int f(int i) {
    return i + 1 > i;      /* 若允许溢出,i=INT_MAX 时结果为假 */
}

如果标准规定回绕,编译器就必须生成比较指令;如果溢出是 UB,编译器可以假设它不发生,直接返回 1。这就是 UB 的「价值」——也是它危险的原因:一段在 -O0 下能跑的溢出代码,在 -O2 下可能被优化成完全不同的行为。

检测有符号加法溢出,最稳的是用编译器内建函数:

#include <stdio.h>
#include <stdint.h>
#include <limits.h>

static int add_ok(int a, int b, int *out)
{
    if (__builtin_add_overflow(a, b, out))
        return 0;              /* 溢出,*out 的值未定义,不要用 */
    return 1;
}

int main(void)
{
    int r = 0;
    int ok1 = add_ok(100, 200, &r);
    printf("100+200: ok=%d r=%d\n", ok1, r);

    int ok2 = add_ok(INT_MAX, 1, &r);
    printf("INT_MAX+1: ok=%d\n", ok2);

    unsigned u = UINT_MAX;
    printf("UINT_MAX+1 = %u\n", u + 1u);
    printf("INT_MIN/-1 会触发 SIGFPE,不能直接算\n");
    return 0;
}

__builtin_add_overflow 同时适用于有符号和无符号,GCC/Clang 都支持。手写检测也不是不行,但符号组合容易漏:

/* 有符号加法溢出:同号相加,结果异号 */
int overflow = (b > 0 && a > INT_MAX - b) ||
               (b < 0 && a < INT_MIN - b);

乘法更复杂,直接调 __builtin_mul_overflow 或先转 long long 再判断范围。

除法有两个必须记住的边界:

  1. 整数除法向零截断:-7 / 2 == -3,-7 % 2 == -1。余数的符号与被除数相同。
  2. INT_MIN / -1 溢出:结果 2312^{31} 无法用 int 表示,x86 的 idiv 会触发 #DE,进程收到 SIGFPE。这不是「除数为 0」的专利。
int a = INT_MIN, b = -1;
int c = a / b;      /* 未定义行为;x86 上通常 SIGFPE */
关于整数运算的五个陷阱
  1. 「无符号减法不会出问题」——0u - 1 是 UINT_MAX,不是 -1。用无符号表示「差值」时,先判断大小。
  2. 「abs(INT_MIN) 是正数」——abs(INT_MIN) 仍是 INT_MIN(或 UB),因为相反数不可表示。用 labs/llabs 并检查范围。
  3. 「x * 2 比 x << 1 慢」——现代编译器对两者生成同样的指令;可读性优先。对负数左移在 C 里是 UB,别用移位代替乘法。
  4. 「size_t 是无符号的,所以更安全」——它会让 for (size_t i = n-1; i >= 0; i--) 永远成立。无符号只改变解释,不消除边界。
  5. 「开 -fwrapv 就能放心溢出」——它让 GCC 按回绕处理有符号溢出,但代码仍不可移植到其他编译器;正确做法是检测,而不是依赖回绕。

1.2.5 位运算与移位

六个位运算符:&(与)、|(或)、^(异或)、~(取反)、<<(左移)、>>(右移)。前四个对每一位独立运算,常用于掩码(mask):

uint32_t flags = 0x5A;          /* 0101 1010 */
flags |=  (1u << 0);            /* 置位 bit 0:0101 1011 */
flags &= ~(1u << 3);            /* 清位 bit 3:0101 0011 */
flags ^=  (1u << 1);            /* 翻转 bit 1:0101 0001 */
int bit2 = (flags >> 2) & 1u;   /* 取 bit 2:0 */

异或的三个性质值得记:x ^ x = 0、x ^ 0 = x、x ^ y ^ y = x。最后一条可以用来交换两个数(但有溢出与可读性问题,工程上不推荐)、找「只出现一次的数」、做简单的校验。

移位有三条边界:

  • 移位量必须非负且小于位宽。1 << 32 对 32 位 int 是 UB;x << -1 也是 UB。用 1u << 31 而不是 1 << 31,因为后者在 32 位 int 上溢出。
  • 有符号左移若溢出是 UB;无符号左移是模 2w2^w 的乘法,定义良好。
  • 有符号右移是实现定义:算术右移(补符号位)或逻辑右移(补 0)。GCC/Clang 在 x86-64 上做算术右移:-8 >> 1 == -4。无符号右移一定是逻辑右移。

几个高频位技巧:

表达式作用例子(8 位)
x & (x - 1)清除最低位的 10xB0 → 0xA0
x & -x取出最低位的 10xB0 → 0x10
x | (x + 1)把最低位的 0 置 10xB0 → 0xB1
x ^ (x >> 1)格雷码变换(近似)0xB0 → 0x98
__builtin_popcount(x)1 的个数0xB0 → 3
__builtin_clz(x)前导 0 的个数0x0F(32 位)→ 28

x & (x-1) 常用于判断 2 的幂:x > 0 && (x & (x-1)) == 0。x & -x 是树状数组(Fenwick tree)的核心。__builtin_clz(0) 是 UB,用之前先判 0。

为什么 `x & -x` 能取出最低位的 1

设 x 的最低位的 1 在位置 kk,则 x 的低 kk 位全 0、第 kk 位是 1。-x = ~x + 1:取反后低 kk 位全 1、第 kk 位变 0;加 1 后低 kk 位进位成全 0、第 kk 位变回 1,高位全部取反。两者相与,只有第 kk 位同时为 1。这个推导不依赖具体位宽。

逻辑运算与位运算:&&、||、! 不是 &、|、~

运算类型求值结果
&& / ``逻辑
& / ``位两边都求值,逐位
!逻辑非0→1,非 0→00/1
~按位取反逐位整数

短路会改变副作用:

int p = 0;
if (p != NULL && *p == 1) { ... }   /* 安全:p 为 NULL 时不解引用 */
if (p != NULL & *p == 1)  { ... }   /* 仍会解引用 p,崩溃 */

有符号/无符号混用是另一个坑:-1 < 0u 为假,因为 -1 被转成 UINT_MAX;编译器会警告 -Wsign-compare,不要忽略。

1.2.6 字节序、对齐与结构体布局

字节序在 1.2.3 已经用 show_bytes 看过。工程上要记住三个场景:

  1. 网络:IP/TCP 首部中的多字节字段是大端,所以有 htons/htonl;发送前转网络序,接收后转主机序。
  2. 文件格式:PNG、JPEG、ELF 都显式规定字节序;跨架构读写要按格式转换,不能直接 fwrite 一个结构体。
  3. 共享内存/管道:两个进程若架构不同(比如 x86 与 ARM 大端模式),直接传结构体会错位。

对齐(alignment)是另一类「看不见的字节」。CPU 访问 8 字节 long 时,若地址不是 8 的倍数,某些架构会 fault,x86 虽能处理但可能跨缓存行、变慢。C 要求每个类型的地址是其对齐值的倍数,编译器因此在结构体成员之间插入填充(padding)。

#include <stdio.h>
#include <stddef.h>

struct A {
    char   c;      /* offset 0, size 1 */
    int    i;      /* 需要 4 对齐:offset 4,填充 3 字节 */
    char   d;      /* offset 8 */
};                 /* 总大小对齐到 4:12 */

struct B {
    char   c;      /* offset 0 */
    double d;      /* 需要 8 对齐:offset 8,填充 7 字节 */
    char   e;      /* offset 16 */
};                 /* 总大小对齐到 8:24 */

struct C {
    double d;      /* offset 0 */
    char   c;      /* offset 8 */
    char   e;      /* offset 9 */
};                 /* 总大小 16:尾部填充 6 字节 */

int main(void)
{
    printf("A: size=%zu c=%zu i=%zu d=%zu\n", sizeof(struct A),
           offsetof(struct A, c), offsetof(struct A, i), offsetof(struct A, d));
    printf("B: size=%zu c=%zu d=%zu e=%zu\n", sizeof(struct B),
           offsetof(struct B, c), offsetof(struct B, d), offsetof(struct B, e));
    printf("C: size=%zu d=%zu c=%zu e=%zu\n", sizeof(struct C),
           offsetof(struct C, d), offsetof(struct C, c), offsetof(struct C, e));
    return 0;
}

输出:A: size=12 c=0 i=4 d=8,B: size=24 c=0 d=8 e=16,C: size=16 d=0 c=8 e=9。三个结构体成员相同(两个 char、一个 8 字节),只是顺序不同,大小从 16 变成 24。把大的成员放前面通常能减少填充,但不要为了省几字节破坏可读性;只有在数组或网络协议里才值得精打细算。

struct A (12 B)          struct B (24 B)
0  c                     0  c
1  pad                   1  pad
2  pad                   2  pad
3  pad                   3  pad
4  i   i   i   i         4  pad
8  d                     5  pad
9  pad                   6  pad
10 pad                   7  pad
11 pad                   8  d  d  d  d  d  d  d  d
                        16  e
                        17  pad ... 23 pad

这张布局图说明填充不是「浪费」,而是对齐规则的必然结果。每个成员的偏移必须是其对齐值的倍数,结构体总大小必须是最大对齐值的倍数(这样数组里每个元素都对齐)。#pragma pack(1) 或 __attribute__((packed)) 可以取消填充,但会让成员可能非对齐访问,在 ARM 上可能 fault,在 x86 上变慢;只有解析外部二进制格式时才用。

什么时候真的需要关心布局

高性能场景:数组元素是结构体时,缓存行里能放几个决定了访存效率(1.6)。系统编程场景:stat、sockaddr、ELF 头都依赖精确布局,错一个字节就解析错。普通业务代码:用 offsetof 和 sizeof,不要手算。

1.2.7 IEEE 754 浮点

浮点数用「符号 + 阶码 + 尾数」表示近似值。IEEE 754 单精度(float)32 位、双精度(double)64 位:

float  31 30            23 22                                    0
       ┌─┬────────────────┬───────────────────────────────────────┐
       │S│   exp (8 bit)  │            frac (23 bit)              │
       └─┴────────────────┴───────────────────────────────────────┘
​
double 63 62                       52 51                           0
       ┌─┬──────────────────────────┬───────────────────────────────┐
       │S│       exp (11 bit)       │          frac (52 bit)        │
       └─┴──────────────────────────┴───────────────────────────────┘

规格化数的值是

(−1)S×1.f×2E−bias, (-1)^S \times 1.f \times 2^{E - \text{bias}},

其中单精度 bias 是 127,双精度是 1023;1.f1.f 表示「隐含的前导 1 加上尾数小数部分」。阶码全 0 表示非规格化数(没有隐含 1,值更小),阶码全 1 表示无穷或 NaN。

手算一个例子:0.156250.15625。

  1. 转二进制:0.15625=5/32=0.0010120.15625 = 5/32 = 0.00101_2。
  2. 规格化:0.001012=1.012×2−30.00101_2 = 1.01_2 \times 2^{-3}。
  3. 符号 S=0S=0;阶码 E=−3+127=124=0111 11002E = -3 + 127 = 124 = \texttt{0111 1100}_2。
  4. 尾数小数部分 .01,补齐 23 位:0100 0000 0000 0000 0000 000。
  5. 拼起来:0 01111100 01000000000000000000000 = 0x3E200000。

验证:0x3E200000 的阶码字段是 124,尾数是 0.250.25,所以值是 1.25×2−3=0.156251.25 \times 2^{-3} = 0.15625。若符号位为 1,则是 0xBE200000 = −0.15625-0.15625。

几个基准位模式要背下来:

值float 位模式double 位模式
+0.00x000000000x0000000000000000
-0.00x800000000x8000000000000000
1.00x3F8000000x3FF0000000000000
0.50x3F0000000x3FE0000000000000
+∞0x7F8000000x7FF0000000000000
quiet NaN0x7FC000000x7FF8000000000000

0.1 无法用二进制有限小数表示,只能舍入:

  • float 0.1 的位模式是 0x3DCCCCCD,实际值约 0.1000000014901160.100000001490116;
  • double 0.1 的位模式是 0x3FB999999999999A,实际值约 0.100000000000000005550.10000000000000000555。

两者都不是精确的 0.1,只是精度不同。0.1 + 0.2 在 double 下得到 0.30000000000000004,因为两个近似值相加后又舍入了一次。

特殊值的规则:

阶码尾数含义
全 0全 0±0\pm 0
全 0非 0非规格化数(逐渐下溢)
1–254(单)任意规格化数
全 1全 0±∞\pm\infty
全 1非 0NaN(尾数最高位区分 quiet/signaling)

NaN 与任何值比较(包括自己)都返回假,所以判断要用 isnan(x),不能写 x == NAN。INFINITY - INFINITY 产生 NaN;1.0 / 0.0 产生无穷(若编译器没把它当编译期错误)。

定义 1.2.3 · 机器精度与有效位数

单精度有 24 位有效二进制位(1 位隐含 + 23 位尾数),机器精度 ϵ=2−23≈1.19×10−7\epsilon = 2^{-23} \approx 1.19 \times 10^{-7};双精度有 53 位有效位,ϵ=2−52≈2.22×10−16\epsilon = 2^{-52} \approx 2.22 \times 10^{-16}。ϵ\epsilon 是「1 与下一个可表示数之间的间隔」,决定了相对误差的下限,不等同于十进制有效位数(单精度约 7 位,双精度约 16 位)。

1.2.8 舍入、精度陷阱与比较

IEEE 754 默认舍入模式是「向偶数舍入」(round-to-nearest, ties-to-even):恰好在两个可表示数中间时,选尾数最低位为 0 的那个。它避免了「总是向上」带来的统计偏差。C 的 fesetround 可以改模式,但大多数程序不该改,因为库函数和编译期常量都假设默认模式。

三个常见精度陷阱:

吸收(absorption)。当两个数数量级差太大,小的那个在相加时被完全舍掉:

double x = 1e16;
double y = x + 1.0;       /* 1.0 小于 ulp(1e16)=2,结果仍是 1e16 */

灾难性抵消(catastrophic cancellation)。两个相近的大数相减,有效位大量丢失:

double a = 1e16 + 1.0;    /* 已经是 1e16 */
double b = 1e16;
double c = a - b;         /* 0,而不是 1 */

整数转浮点的丢失。float 只有 24 位有效位,所有大于 2242^{24} 的整数不一定能精确表示:

float f = 16777217.0f;    /* 2^24 + 1,舍入为 16777216 */

下面的程序把这三件事打印出来:

#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <math.h>

static uint64_t d_bits(double d) { uint64_t u; memcpy(&u, &d, sizeof u); return u; }
static uint32_t f_bits(float f)  { uint32_t u; memcpy(&u, &f, sizeof u); return u; }

int main(void)
{
    double a = 0.1, b = 0.2;
    printf("0.1  = %016llx\n", (unsigned long long)d_bits(a));
    printf("0.2  = %016llx\n", (unsigned long long)d_bits(b));
    printf("sum  = %.17g\n", a + b);
    printf("0.15625f = %08x\n", (unsigned)f_bits(0.15625f));
    printf("16777217 -> %.1f\n", (double)(float)16777217);
    printf("1e16+1 == 1e16 ? %d\n", (1e16 + 1.0) == 1e16);
    printf("nan==nan ? %d, isnan ? %d\n", (int)(NAN == NAN), isnan(NAN));
    return 0;
}

输出会确认:0.1 的位模式是 3fb999999999999a,和是 0.30000000000000004,16777217 变成 16777216.0,1e16+1 仍等于 1e16,nan==nan 为 0 而 isnan 为 1。

浮点比较的正确写法取决于场景:

  • 判断「相等」:用相对误差 ∣a−b∣≤ϵ⋅max⁡(∣a∣,∣b∣)|a-b| \le \epsilon \cdot \max(|a|,|b|),或 fabs(a-b) <= 1e-9(量级已知时)。
  • 判断「排序」:直接 </>,但要注意 NaN 会让所有比较为假;排序前先处理 NaN。
  • 累加大量小数:用 Kahan 求和或 long double,或按数量级排序后相加。
  • 金额:用整数「分」或十进制库,不要用 double。
关于浮点的四个误解
  1. 「float 和 double 只是精度不同,语义一样」——特殊值、舍入、异常标志的规则相同,但 float 的有效位少,累加误差放大得更快;大量累加应优先 double。
  2. 「0.1f == 0.1 为真」——0.1f 提升为 double 后是 0.100000001490116...,不等于 double 的 0.1。混合比较会踩坑。
  3. 「浮点运算是确定性的」——同一表达式在不同优化级别、不同 FMA 使用、不同架构下可能得到不同最低位。-ffast-math 会进一步放弃 IEEE 语义。
  4. 「NaN 等于它自己」——IEEE 754 规定 NaN 与任何值(含自身)的无序比较都为假。用 isnan,不要用 ==。

1.2.9 手算速查:进制、补码、移位与溢出

这一节回答一个问题:手边没有计算器、也懒得开 Python 时,怎样在三十秒内把一条位运算表达式算对。办法不是背更多结论,而是把 1.2.1–1.2.4 的规则压成五张表,每张表配一道手算例题。考试、代码评审、崩溃现场读寄存器,用的都是这套动作。

表一:位宽与边界常数

宽度 wwTMinwTMin_wTMaxwTMax_wUMaxwUMax_w十六进制写法
8-1281272550x80 / 0x7F / 0xFF
16-3276832767655350x8000 / 0x7FFF / 0xFFFF
32-2147483648214748364742949672950x80000000 / 0x7FFFFFFF / 0xFFFFFFFF
64-92233720368547758089223372036854775807184467440737095516150x8000000000000000 / 0x7FFF…F / 0xFFFF…F

三条由此推出的常数关系,比表本身更常用:

  • TMin=−TMax−1TMin = -TMax - 1,所以 ∣TMin∣=TMax+1|TMin| = TMax + 1,负数比正数多一个。
  • UMax=2×TMax+1UMax = 2 \times TMax + 1。看到 0xFFFFFFFF 时,先问「按什么类型读」。
  • -1 的补码是全 1。(unsigned)-1 == UINT_MAX 永远成立,与位宽无关。

表二:4 位补码全表(16 个位模式)

位模式无符号补码取负后的位模式取负后的值
00000000000
0001111111-1
0010221110-2
0011331101-3
0100441100-4
0101551011-5
0110661010-6
0111771001-7
10008-81000-8(溢出)
10019-701117
101010-601106
101111-501015
110012-401004
110113-300113
111014-200102
111115-100011

表里唯一的异常行是 1000:取负后仍是 1000。这就是「TMinTMin 的相反数不可表示」,在 C 里对 INT_MIN 取负是 UB,在硬件上是 neg 指令设置 OF=1 但结果不变。

手算例 1:0xFFFFFFF0 作为 int 是多少?做法:全 1 前缀说明是负数,先取负看大小——~0xFFFFFFF0 + 1 = 0x00000010 = 16,所以原值是 −16-16。作为 unsigned 则是 232−16=42949672802^{32} - 16 = 4294967280。

表三:移位与除法的等价关系

表达式数学值前提常见误用
x << k(无符号)x⋅2k mod 2wx \cdot 2^k \bmod 2^w0≤k<w0 \le k &lt; wk≥wk \ge w 是 UB
x << k(有符号非负)x⋅2kx \cdot 2^k不溢出溢出是 UB,不是回绕
x >> k(无符号)⌊x/2k⌋\lfloor x / 2^k \rfloor0≤k<w0 \le k &lt; w对负数不适用(无符号没有负数)
x >> k(有符号,算术)⌊x/2k⌋\lfloor x / 2^k \rfloor实现定义,GCC 为算术右移不等于 C 的 /
x / 2^k(有符号)向零截断恒定义(除 TMin / -1)与 >> 只在 x≥0x \ge 0 时相同;负数要加偏置

最后两行的差别是所有「用移位代替除法」优化的根源。取 x=−7x=-7、k=1k=1:

-7 的 32 位补码          = 1111 ... 1111 1001
-7 >> 1(算术右移)      = 1111 ... 1111 1100 = -4   (向下取整)
-7 / 2(C 的截断除法)    = -3                        (向零取整)

编译器要实现 x / 4,又不能假设 x≥0x \ge 0,于是生成「先加偏置再算术右移」:

div4:
    leal  3(%rdi), %eax      # eax = x + 3
    testl %edi, %edi
    cmovnsl %edi, %eax       # 若 x >= 0,eax = x(不需要偏置)
    sarl  $2, %eax           # 算术右移 2 位
    ret

偏置 2k−12^k-1 的作用是把「向零截断」补成「向下取整」:x=−7x=-7 时 −7+3=−4-7+3=-4,-4 >> 2 = -1,而 −7/4=−1-7/4=-1,对上了。对无符号数则不需要偏置,直接 shrl $2。

表四:溢出判定

运算何时溢出硬件标志C 里的检测
有符号加 a+ba、b 同号且结果异号OF=1__builtin_add_overflow
有符号减 a-ba、b 异号且结果符号与 a 不同OF=1__builtin_sub_overflow
有符号乘 a*b真实积超出 [TMin,TMax][TMin,TMax]OF=1(双操作数 imul)__builtin_mul_overflow
无符号加 a+b最高位有进位CF=1结果 < a
无符号乘 a*b高位非零CF=1a != 0 && r / a != b
除法 a/bb==0 或 a==TMin && b==-1#DE → SIGFPE先判 b,再判边界

无符号的溢出是定义良好的回绕,有符号溢出是 UB。表中「C 里的检测」一列之所以全部推荐内建函数,是因为手写判定在类型提升、位宽混用时会悄悄失效;内建函数由编译器按目标位宽生成,且不会触发 UB。

手算例 2:int a = 0x7FFFFFFF; a + 1 的机器结果是什么?位模式相加得 0x80000000,按补码是 TMinTMin,OF=1。C 程序里这一行是 UB,-O2 下编译器可以假定它不发生;用 volatile 或 __builtin_add_overflow 才能观察真实机器行为。

表五:有符号与无符号的转换

转换规则例子(w=32w=32)
有符号 → 同宽无符号x<0x&lt;0 时 x+2wx+2^w(unsigned)-1 → 4294967295
无符号 → 同宽有符号x≥2w−1x \ge 2^{w-1} 时 x−2wx-2^w(int)0x80000000u → -2147483648
宽 → 窄保留低 ww 位,丢弃高位(int8_t)0x1FF → -1
窄 → 宽(有符号)符号扩展(int)0x8F 若源自 int8_t → -113
窄 → 宽(无符号)零扩展(unsigned)0x8F → 143
混合运算有符号转无符号后再算-1 < 0u → 0

手算例 3:unsigned short a = 65535; int b = a; 的结果?unsigned short 先整型提升为 int(int 能表示 0–65535),所以 b == 65535,不发生符号扩展。若写成 short a = -1; unsigned b = a;,a 提升为 int 的 -1,再转 unsigned 得 4294967295,而不是 65535。

手算四位补码题的固定动作
  1. 先看最高位定符号;最高位 0 直接按无符号读。
  2. 最高位 1 时,取反加一得到绝对值,再添负号。
  3. 加减法一律在位上做,丢弃最高位进位;结果按目标类型解释。
  4. 比较大小前先问「按有符号还是无符号」,两者用不同条件码。
  5. 移位前先问「左移会不会溢出、右移的对象是不是负数」。

1.2.10 IEEE 754 逐字段:非规格化、舍入与 ULP

1.2.7 给出了浮点的整体公式,这一节把三个字段拆到每一位,回答四个容易含糊的问题:非规格化数到底表示什么、舍入模式怎样影响最后一位、ULP 怎么算、为什么浮点排序不能直接比较位模式。

字段与取值

字段float 位区间double 位区间作用
s(符号)bit 31bit 630 为正,1 为负(含 -0.0)
exp(阶码)bit 30–23(8 位)bit 62–52(11 位)偏置指数,bias 127 / 1023
frac(尾数)bit 22–0(23 位)bit 51–0(52 位)规格化数省略前导 1
bias1271023E=exp−biasE = exp - bias

三种编码的完整取值规则(ss 为符号位,ff 为 frac 解释出的小数):

v={(−1)s×0.f×21−bias,exp=0(非规格化)(−1)s×1.f×2exp−bias,0<exp<全 1(规格化)±∞ 或 NaN,exp=全 1 v = \begin{cases} (-1)^s \times 0.f \times 2^{1-bias}, &amp; exp = 0 \quad \text{(非规格化)}\ (-1)^s \times 1.f \times 2^{exp-bias}, &amp; 0 &lt; exp &lt; \text{全 1} \quad \text{(规格化)}\ \pm\infty \text{ 或 NaN}, &amp; exp = \text{全 1} \end{cases}

非规格化数的阶码固定为 1−bias1-bias,不是 0−bias0-bias。这个「1」是 IEEE 754 的设计技巧:让最大的非规格化数与最小的规格化数无缝衔接。以 float 为例:

位模式含义十进制近似
0x00000000+0.00
0x00000001最小正非规格化数 2−1492^{-149}1.40129846×10−451.40129846 \times 10^{-45}
0x007FFFFF最大非规格化数 (1−2−23)×2−126(1-2^{-23}) \times 2^{-126}1.17549421×10−381.17549421 \times 10^{-38}
0x00800000最小正规格化数 2−1262^{-126}1.17549435×10−381.17549435 \times 10^{-38}
0x7F7FFFFF最大有限值 (2−2−23)×2127(2-2^{-23}) \times 2^{127}3.40282347×10383.40282347 \times 10^{38}
0x7F800000+∞+\infty—
0x7FC00000quiet NaN(默认)—

从 0x007FFFFF 到 0x00800000,值只增加了 2−1492^{-149},没有跳变;这就是「逐渐下溢」(gradual underflow)。若没有非规格化数,最小规格化数与 0 之间会有一段 2−1262^{-126} 宽的真空,减法结果会被突然冲成 0。

手算例 4:把 0x3E200000 拆开。符号位 0;阶码 0111 1100 = 124,E=124−127=−3E=124-127=-3;尾数 .010…0 = 1/41/4。值是 1.25×2−3=0.156251.25 \times 2^{-3} = 0.15625。反过来,把 0.156250.15625 写成 float:0.15625=1.25×2−30.15625 = 1.25 \times 2^{-3},尾数小数部分 .01,阶码 124124,拼出 0 01111100 0100…0。

ULP 与机器精度

ULP(unit in the last place)是相邻两个可表示数之间的间距。对规格化数,

ulp(x)=2⌊log⁡2∣x∣⌋−(p−1), \text{ulp}(x) = 2^{\lfloor \log_2 |x| \rfloor - (p-1)},

其中 pp 是有效位数(float 为 24,double 为 53)。所以 float 在 [1,2)[1,2) 内的 ULP 是 2−232^{-23},在 [2,4)[2,4) 内是 2−222^{-22};double 在 [1,2)[1,2) 内是 2−522^{-52}。

区间float ULPdouble ULP
[1,2)[1,2)1.1920929×10−71.1920929 \times 10^{-7}2.220446×10−162.220446 \times 10^{-16}
[2,4)[2,4)2.3841858×10−72.3841858 \times 10^{-7}4.440892×10−164.440892 \times 10^{-16}
[220,221)[2^{20},2^{21})0.1250.1252.3283064×10−102.3283064 \times 10^{-10}
[223,224)[2^{23},2^{24})111.8626451×10−91.8626451 \times 10^{-9}

表里最后两行解释了两个经典现象:float 在 2242^{24} 之后连「加 1」都表示不了;float 在 2232^{23} 附近 ULP 已经是 1,所以 16777216.0f + 1.0f == 16777216.0f。

四种舍入模式

IEEE 754 规定五种舍入方向,C 的 <fenv.h> 暴露其中四种:

fesetround 参数规则1.5 的结果-1.5 的结果
FE_TONEAREST(默认)就近,平局取偶2-2
FE_DOWNWARD向 −∞-\infty1-2
FE_UPWARD向 +∞+\infty2-1
FE_TOWARDZERO向 01-1

默认的「就近取偶」(round-to-nearest, ties-to-even)在手算时的动作是:找到相邻的两个可表示数,取更近的;正好在中点时,看哪一个的尾数最低位是 0。用 3 位有效位表示 2.5=10.122.5 = 10.1_2:候选是 10.02=210.0_2=2 与 11.02=311.0_2=3,中点取偶得 10.02=210.0_2=2。3.5=11.123.5=11.1_2 的候选是 11.02=311.0_2=3 与 100.02=4100.0_2=4,取偶得 100.02=4100.0_2=4。

「取偶」不是审美偏好:如果每次都向上取,大量累加会产生系统性正偏差;取偶让平局在两个方向上均匀分布,误差期望为 0。

一次舍入的相对误差有硬上界:在规格化范围内,

∣fl(x)−xx∣≤ϵ2,ϵ=2−(p−1). \left| \frac{\text{fl}(x) - x}{x} \right| \le \frac{\epsilon}{2}, \qquad \epsilon = 2^{-(p-1)} .

float 的 ϵ=2−23≈1.19×10−7\epsilon = 2^{-23} \approx 1.19 \times 10^{-7},所以「约 7 位十进制有效数字」;double 是 2−52≈2.22×10−162^{-52} \approx 2.22 \times 10^{-16},约 16 位。这是单次运算的界,nn 次累加的最坏误差会按 nϵn\epsilon 增长(向前误差分析),不是 nn 次都保持 ϵ/2\epsilon/2。

为什么浮点加法不满足结合律

(a+b)+c(a+b)+c 与 a+(b+c)a+(b+c) 各自发生两次舍入,舍入点不同。取 a=1.0a=1.0、b=2−24b=2^{-24}、c=2−24c=2^{-24}(float):a+ba+b 舍入回 1.01.0,再加 cc 仍是 1.01.0;而 b+c=2−23b+c=2^{-23},加到 aa 上得 1.00000011.0000001。差别不在数学,在「哪一步被舍掉」。并行归约改变求和顺序,因此结果可能与串行版本逐位不同——这不是 bug,是浮点语义。

浮点数的全序:位模式排序技巧

IEEE 754 的位模式按无符号整数比较时,正数部分单调递增,负数部分单调递减,且 -0.0 < +0.0。要让「无符号比较」等价于「浮点比较」,做一次符号折叠:

#include <stdint.h>
#include <string.h>

/* 把 double 的位模式映射成单调的无符号键:键序 == 浮点序(NaN 除外) */
static uint64_t fkey(double d)
{
    uint64_t u;
    memcpy(&u, &d, sizeof u);
    return u ^ ((u >> 63) ? UINT64_MAX : 0x8000000000000000ULL);
}

验证:-1.0 的位模式是 0xBFF0…,折叠后是 0x400F…;-0.0 是 0x8000… → 0x7FFF…;+0.0 是 0x0000… → 0x8000…;1.0 是 0x3FF0… → 0xBFF0…。键值严格递增,和浮点值顺序一致。把键当 uint64_t 排序,就得到浮点升序,且不用处理 -0.0 与 +0.0 的比较分支。NaN 不参与全序,排序前要单独剔除。

融合乘加与双重舍入

FMA(fused multiply-add)把 a×b+ca \times b + c 做成一条指令,只在最后舍入一次。它与「先乘后加」的结果可能差一个 ULP:

a = 1 + 2^-23, b = 1 - 2^-23, c = -1      (float)
a*b 精确值 = 1 - 2^-46
先乘后加:fl(a*b) = 1.0,再加 -1 得 0
FMA:      fl(1 - 2^-46 - 1) = -2^-46(可表示)

GCC 在非 ISO 模式默认 -ffp-contract=fast;-std=c17 只是 ISO 模式,仍允许 contraction(on),并不等于关闭 FMA。需要逐位可复现时,显式用 -ffp-contract=off,或加 #pragma STDC FP_CONTRACT OFF(支持度因编译器而异)。

另一个方向是双重舍入:x87 的 80 位寄存器把 double 运算结果先舍入到 64 位有效位,再存回 53 位,可能得到与 SSE 直接舍入不同的最低位。x86-64 的 double 运算默认走 SSE2,不受影响;但 long double 在 x86-64 Linux 上就是 80 位 x87 格式,跨平台时要注意它与 double 的差别。

关于 IEEE 754 的五个精确说法
  1. 「非规格化数的阶码是 0」——字段值是 0,但有效阶码是 1−bias1-bias;公式里写成 2−1262^{-126} 才对。
  2. 「float 能精确表示所有 7 位十进制数」——只能保证 6–7 位相对精度;0.1f 本身就不精确,16777217 也存不下。
  3. 「x == x 对 NaN 为真」——IEEE 754 规定 NaN 与任何值(含自身)的无序比较都为假,所以 x != x 是判断 NaN 的经典写法(更清楚的是 isnan)。
  4. 「-0.0 == 0.0 为假」——比较为真;但 1.0/(-0.0) 是 −∞-\infty,signbit(-0.0) 为真。相等与同号是两件事。
  5. 「舍入误差总是随机正负抵消」——只有在平局取偶、输入分布对称时才近似成立;固定模式的累加(如 += 0.1)会产生可预测的漂移。

1.2.11 字节序、对齐与结构体布局的完整规则

1.2.6 给了三个结构体的例子。这一节补全规则本身:对齐值从哪来、填充插在哪、联合体与位域怎么算、packed 改变了什么。掌握规则后,任何结构体都能在纸上算出 sizeof 和每个成员的偏移,不需要编译器。

对齐值表(x86-64 System V)

类型sizeof对齐值备注
char / signed char / unsigned char11任何地址都合法
short22偏移必须是偶数
int / unsigned44
long / unsigned long / 指针88LP64
long long88
float44
double88
long double1616x86-64 SysV 的 80 位扩展精度
_Bool11
数组n×n \times 元素大小元素对齐元素之间无填充
结构体见规则成员最大对齐值
联合体最大成员大小(向上取整)成员最大对齐值

ARM64 Linux 的规则基本相同,差别在 long double:AArch64 上是 128 位(sizeof 16,对齐 16),而 macOS arm64 把它当作 double(8/8)。同一个结构体跨这两个平台可能大小不同。

四条布局规则

  1. 成员偏移:每个成员的偏移必须是自身对齐值的整数倍,不足则在它前面插填充。
  2. 结构体对齐:结构体的对齐值等于所有成员对齐值的最大值(含嵌套结构体)。
  3. 结构体大小:sizeof 必须是结构体对齐值的整数倍,不足则在尾部补填充。这样数组中每个元素都对齐。
  4. 嵌套:内层结构体先按自己的规则算完大小与对齐,再作为一个整体参与外层布局。

按这四步手算:

结构体成员偏移内部填充尾部填充sizeof
struct S1 { char c; int i; char d; }0, 4, 83312
struct S2 { char c; double d; char e; }0, 8, 167724
struct S3 { double d; char c; char e; }0, 8, 90616
struct S4 { char a; short b; int c; long d; }0, 2, 4, 81016
struct S5 { char a[3]; short b; char c; }0, 4, 6118
struct S6 { char c; long double ld; }0, 1615032
union U1 { char c[9]; int i; }0, 00312

S2 与 S3 的成员完全相同,只是顺序不同,大小差 8 字节。把最大的成员放前面通常最省空间,但规则不是「降序排列」这么简单:S4 按 1/2/4/8 升序排列反而没有内部填充,因为它正好是自然对齐的顺序。真正的判据是「每个成员的偏移是否已经对齐」。

手算一个嵌套结构体
struct inode_key {
    char  dev;          /* 0, 对齐 1 */
    struct {            /* 对齐 4,偏移必须是 4 的倍数 */
        int  ino;       /* 4 */
        int  gen;       /* 8 */
    } id;               /* 大小 8,对齐 4 */
    short mode;         /* 12,对齐 2 */
    char  name[5];      /* 14,对齐 1,占 14..18 */
};                      /* 最大对齐 4,大小向上取整到 20 */

id 从偏移 4 开始(0 后面补 3 字节),mode 在 12,name 在 14,总大小 19 向上取整到 20。若把 name[5] 换成 name[6],总大小变成 20,sizeof 仍是 20——尾部填充从 1 变成 0。加一个字节可能不改变大小,这是容量规划时最容易算错的地方。

联合体、位域与柔性数组

联合体(union)的所有成员共享起始地址,大小是最大成员的大小按最大对齐值向上取整。U1 中 char[9] 占 9 字节,int 要求 4 对齐,所以大小是 12。联合体常用于「同一块内存的多种解释」:协议解析、变体类型(tagged union)、浮点位操作。

位域(bitfield)的布局由实现定义。GCC 在 x86-64 上按「从低位到高位、尽量塞进同一个存储单元」分配:

struct flags {
    unsigned a : 3;    /* bit 0–2 */
    unsigned b : 5;    /* bit 3–7 */
    unsigned c : 8;    /* bit 8–15 */
    unsigned d : 16;   /* bit 16–31 */
};                     /* sizeof = 4,对齐 4 */

a=5, b=17, c=0xAB, d=0xBEEF 打包成 0xBEEFAB8D。跨编译器时不要依赖位域顺序:网络协议、文件格式要用手写的移位与掩码(1.2.12 的程序就是这么做的),位域只用于编译器内部的标志集合。

柔性数组(flexible array member)必须是最后一个成员且不占空间:

struct packet {
    uint16_t len;
    uint8_t  kind;
    uint8_t  data[];   /* 不占 sizeof,按需 malloc(sizeof + n) */
};                     /* sizeof = 4(2 + 1 + 1 尾部填充) */

data 的偏移是 3,但结构体对齐值是 2,所以 sizeof 是 4。分配时写 malloc(sizeof(struct packet) + n),多出来的 1 字节填充不影响使用。

改变布局的工具

手段效果代价
__attribute__((packed))取消所有填充成员可能非对齐;ARM 上取地址/访问可能 fault,x86 变慢
#pragma pack(n)把对齐上限压到 n同上,且是编译器的方言
_Alignas(64) / alignas提高对齐(如缓存行)浪费空间;栈上大对齐可能失败
__attribute__((aligned(64)))同上(GCC 语法)同上
重排成员减少填充,零运行时代价改变 ABI,需重新编译所有使用者

packed 最危险的用法是对成员取地址:

struct __attribute__((packed)) hdr {
    uint8_t  ver;
    uint32_t len;
};
struct hdr h;
uint32_t *p = &h.len;      /* 地址可能是奇数,*p 在 ARM 上触发对齐异常 */

GCC 的 -Waddress-of-packed-member 会警告这类代码。解析外部格式时,正确做法是把字节逐字段 memcpy 到本地对齐变量,而不是把整个结构体强转成 packed 指针。

什么时候布局会进入性能路径

结构体数组的每个元素都按对齐值排布,一个 24 字节的结构体在 64 字节缓存行里只能放 2 个(浪费 16 字节)。把「热字段」聚在一起、把冷字段拆到旁路数组(SoA),能显著减少缓存行占用,详见 1.6。另外,多线程下两个高频写的变量若落在同一缓存行,会因缓存行争用(false sharing)导致性能骤降;alignas(64) 是标准解法。

1.2.12 完整程序一:整型位操作工具箱

这个程序把本章的整数规则落到可复用的函数上:溢出检测、循环移位、符号扩展、字节翻转、位域打包。每个函数都不依赖 UB,且给出了可核对的输出。

/* bits_tool.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g bits_tool.c */
#include <stdio.h>
#include <stdint.h>
#include <limits.h>
#include <string.h>

/* 返回 1 表示成功;溢出时 *out 被写入回绕值,但调用方不应使用 */
static int checked_add(int a, int b, int *out)
{
    return !__builtin_add_overflow(a, b, out);
}

static int checked_mul(int a, int b, int *out)
{
    return !__builtin_mul_overflow(a, b, out);
}

/* 循环左移:k 先对 32 取模,避免 k==0 时的 x>>32(UB) */
static uint32_t rotl32(uint32_t x, unsigned k)
{
    k &= 31u;
    if (k == 0)
        return x;
    return (x << k) | (x >> (32u - k));
}

/* 把低 w 位(1..32)按补码符号扩展到 32 位,不依赖有符号右移 */
static int32_t sign_extend(uint32_t x, unsigned w)
{
    uint32_t m = 1u << (w - 1u);
    x &= (w == 32u) ? UINT32_MAX : ((1u << w) - 1u);
    return (int32_t)((x ^ m) - m);
}

/* 字节序翻转:用掩码+移位,展示 bswap 的等价实现 */
static uint32_t bswap32(uint32_t x)
{
    return ((x & 0x000000FFu) << 24) | ((x & 0x0000FF00u) << 8) |
           ((x & 0x00FF0000u) >> 8)  | ((x & 0xFF000000u) >> 24);
}

/* 位域打包:a:3 b:5 c:8 d:16,从低位起 */
static uint32_t pack(uint32_t a, uint32_t b, uint32_t c, uint32_t d)
{
    return (a & 0x7u) | ((b & 0x1Fu) << 3) | ((c & 0xFFu) << 8) |
           ((d & 0xFFFFu) << 16);
}

static void unpack(uint32_t v, uint32_t *a, uint32_t *b, uint32_t *c, uint32_t *d)
{
    *a = v & 0x7u;
    *b = (v >> 3) & 0x1Fu;
    *c = (v >> 8) & 0xFFu;
    *d = (v >> 16) & 0xFFFFu;
}

int main(void)
{
    int r = 0;
    printf("INT_MAX+1 ok=%d\n", checked_add(INT_MAX, 1, &r));
    printf("100+200 ok=%d r=%d\n", checked_add(100, 200, &r), r);
    printf("65536*65536 ok=%d\n", checked_mul(65536, 65536, &r));

    printf("rotl32(0x80000001,1)=0x%08X\n", rotl32(0x80000001u, 1));
    printf("rotl32(0x12345678,8)=0x%08X\n", rotl32(0x12345678u, 8));

    printf("sign_extend(0x8F,8)=%d\n", sign_extend(0x8Fu, 8));
    printf("sign_extend(0x1FF,8)=%d\n", sign_extend(0x1FFu, 8));
    printf("sign_extend(0x8000,16)=%d\n", sign_extend(0x8000u, 16));

    printf("bswap32(0x01234567)=0x%08X\n", bswap32(0x01234567u));
    printf("popcount(0xB0)=%d clz(0x0F)=%d ctz(0xB0)=%d\n",
           __builtin_popcount(0xB0u), __builtin_clz(0x0Fu),
           __builtin_ctz(0xB0u));

    uint32_t v = pack(5, 17, 0xAB, 0xBEEF);
    uint32_t a, b, c, d;
    unpack(v, &a, &b, &c, &d);
    printf("packed=0x%08X -> a=%u b=%u c=0x%X d=0x%X\n", v, a, b, c, d);
    return 0;
}

在 x86-64 Linux(GCC 13)上的输出:

INT_MAX+1 ok=0
100+200 ok=1 r=300
65536*65536 ok=0
rotl32(0x80000001,1)=0x00000003
rotl32(0x12345678,8)=0x34567812
sign_extend(0x8F,8)=-113
sign_extend(0x1FF,8)=-1
sign_extend(0x8000,16)=-32768
bswap32(0x01234567)=0x67452301
popcount(0xB0)=3 clz(0x0F)=28 ctz(0xB0)=4
packed=0xBEEFAB8D -> a=5 b=17 c=0xAB d=0xBEEF

关键行:

  • checked_add(INT_MAX, 1, &r) 返回 0,说明溢出被捕获;r 里是回绕值,但函数契约是「失败时不要读」。
  • rotl32(0x80000001,1) 把最高位的 1 转到最低位,得到 0x00000003:原来的最高位与最低位都成了 1。
  • sign_extend(0x1FF,8) 先截到 0xFF 再符号扩展,得到 -1;不先截断就会得到 +511。
  • pack 的结果 0xBEEFAB8D 与 1.2.11 的位域结构体在 GCC 下的布局一致:d 在最高 16 位,a 在最低 3 位。

1.2.13 完整程序二:浮点位模式分析器

这个程序把任意 float 拆成符号、阶码、尾数,判断它属于零、非规格化、规格化、无穷还是 NaN,并测量 ULP、演示四种舍入模式。它解决一个实际问题:日志里出现 nan 或 -0 时,怎样确认位模式而不是猜。

/* fp_analyze.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 fp_analyze.c -lm */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <math.h>
#include <fenv.h>
#include <limits.h>

#pragma STDC FENV_ACCESS ON

static uint32_t fbits(float f) { uint32_t u; memcpy(&u, &f, sizeof u); return u; }
static float ffrom(uint32_t u) { float f; memcpy(&f, &u, sizeof f); return f; }

static const char *classify(float f)
{
    uint32_t u = fbits(f);
    unsigned exp = (u >> 23) & 0xFFu;
    unsigned frac = u & 0x7FFFFFu;
    if (exp == 0xFFu)
        return frac ? "NaN" : "Inf";
    if (exp == 0)
        return frac ? "Subnormal" : "Zero";
    return "Normal";
}

static void dump(float f)
{
    uint32_t u = fbits(f);
    unsigned s = u >> 31, e = (u >> 23) & 0xFFu, fr = u & 0x7FFFFFu;
    printf("%-10s bits=%08X s=%u exp=%3u(0x%02X) frac=0x%06X value=%a\n",
           classify(f), u, s, e, e, fr, (double)f);
}

int main(void)
{
    puts("== 分类 ==");
    dump(0.0f); dump(-0.0f); dump(1.0f); dump(0.15625f);
    dump(ffrom(0x00000001u));   /* 最小非规格化 */
    dump(ffrom(0x007FFFFFu));   /* 最大非规格化 */
    dump(ffrom(0x00800000u));   /* 最小规格化 */
    dump(ffrom(0x7F7FFFFFu));   /* 最大有限 */
    dump(ffrom(0x7F800000u));   /* +inf */
    dump(ffrom(0x7FC00000u));   /* quiet NaN */

    puts("\n== ULP ==");
    float one = 1.0f;
    printf("ulp(1.0f)=%a = %.10g\n", nextafterf(one, INFINITY) - one,
           (double)(nextafterf(one, INFINITY) - one));
    printf("ulp(2.0f)=%a\n", nextafterf(2.0f, INFINITY) - 2.0f);
    printf("ulp(0.0f)=%a\n", nextafterf(0.0f, 1.0f));

    puts("\n== 0.1+0.2 ==");
    double a = 0.1, b = 0.2;
    printf("a+b=%.17g  eq 0.3? %d  diff=%.3g\n", a + b, (a + b) == 0.3,
           fabs((a + b) - 0.3));

    puts("\n== 舍入模式(rint 遵循当前模式)==");
    double vals[] = {2.5, 3.5, -1.5};
    int modes[] = {FE_TONEAREST, FE_DOWNWARD, FE_UPWARD, FE_TOWARDZERO};
    const char *names[] = {"TONEAREST", "DOWNWARD", "UPWARD", "TOWARDZERO"};
    for (int m = 0; m < 4; m++) {
        if (fesetround(modes[m]) != 0) { puts("fesetround failed"); return 1; }
        printf("%-11s", names[m]);
        for (int i = 0; i < 3; i++)
            printf(" rint(%.1f)=%+.1f", vals[i], rint(vals[i]));
        putchar('\n');
    }
    fesetround(FE_TONEAREST);

    puts("\n== 精度损失 ==");
    printf("(float)16777217 = %.1f\n", (double)(float)16777217);
    printf("(float)INT_MAX  = %.1f\n", (double)(float)INT_MAX);
    printf("1e16+1 == 1e16 ? %d\n", (1e16 + 1.0) == 1e16);
    printf("nan==nan ? %d, isnan ? %d\n", (int)(NAN == NAN), isnan(NAN));
    return 0;
}

x86-64 Linux 上的输出(节选,%a 是 C99 的十六进制浮点格式):

Zero       bits=00000000 s=0 exp=  0(0x00) frac=0x000000 value=0x0p+0
Zero       bits=80000000 s=1 exp=  0(0x00) frac=0x000000 value=-0x0p+0
Normal     bits=3E200000 s=0 exp=124(0x7C) frac=0x200000 value=0x1.4p-3
Subnormal  bits=00000001 s=0 exp=  0(0x00) frac=0x000001 value=0x1p-149
Subnormal  bits=007FFFFF s=0 exp=  0(0x00) frac=0x7FFFFF value=0x1.fffffcp-127
Normal     bits=00800000 s=0 exp=  1(0x01) frac=0x000000 value=0x1p-126
Normal     bits=7F7FFFFF s=0 exp=254(0xFE) frac=0x7FFFFF value=0x1.fffffep+127
ulp(1.0f)=0x1p-23 = 1.192092896e-07
a+b=0.30000000000000004  eq 0.3? 0  diff=5.55e-17
TONEAREST   rint(2.5)=+2.0 rint(3.5)=+4.0 rint(-1.5)=-2.0
DOWNWARD    rint(2.5)=+2.0 rint(3.5)=+3.0 rint(-1.5)=-2.0
UPWARD      rint(2.5)=+3.0 rint(3.5)=+4.0 rint(-1.5)=-1.0
TOWARDZERO  rint(2.5)=+2.0 rint(3.5)=+3.0 rint(-1.5)=-1.0
(float)16777217 = 16777216.0
(float)INT_MAX  = 2147483648.0
1e16+1 == 1e16 ? 1
nan==nan ? 0, isnan ? 1

0x1.fffffcp-127 这一行值得停一下:它等于 (2−2−23)×2−127(2-2^{-23}) \times 2^{-127},与公式 (1−2−23)×2−126(1-2^{-23}) \times 2^{-126} 是同一个数,只是规格化写法不同。ulp(0.0f)=0x1p-149 说明在 0 附近 ULP 不是 2−232^{-23},而是最小非规格化数;ULP 是分段常数,不是全局常数。

1.2.14 完整程序三:不依赖 libc 的字节序验证(x86-64 汇编)

前两个程序用 C 的 memcpy 看字节。这个 30 行的汇编程序不用 libc,直接把一个 4 字节常量写进 .rodata,再用 write 系统调用输出原始字节。它同时演示了 lea、RIP 相对寻址、循环和系统调用约定。

# endian_sum.s — 汇编:cc -nostdlib -static -o endian_sum endian_sum.s
# 运行:./endian_sum | od -An -tx1 ; echo "exit=$?"
        .section .rodata
val:    .long 0x01234567          # 小端存放为 67 45 23 01

        .text
        .globl _start
_start:
        movl   $1, %eax           # SYS_write
        movl   $1, %edi           # fd = stdout
        leaq   val(%rip), %rsi    # buf = &val
        movl   $4, %edx           # count = 4
        syscall

        movq   $10, %rcx          # sum = 1 + 2 + ... + 10
        xorl   %eax, %eax
.Lsum:
        addq   %rcx, %rax
        decq   %rcx
        jne    .Lsum              # 55

        andl   $0x7F, %eax
        movl   %eax, %edi         # 退出码 = 55
        movl   $60, %eax          # SYS_exit
        syscall

预期输出:

$ ./endian_sum | od -An -tx1
 67 45 23 01
$ echo "exit=$?"
exit=55

关键点:.long 0x01234567 在目标文件里就是 4 个字节 67 45 23 01,CPU 不做任何转换;字节序是存储约定,不是指令行为。andl $0x7F 把和截到 0–127,因为退出码只有低 8 位有效,而 shell 还会把它截到 0–255。汇编程序没有 main,入口是 _start,退出必须自己发 exit 系统调用,否则会从 _start 返回并崩溃。

1.2.15 实验一:用 gdb 观察溢出、转换与结构体布局

准备一个带结构体和溢出点的程序:

/* layout.c — 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -g -O0 -o layout layout.c */
#include <stdio.h>
#include <limits.h>

struct S2 { char c; double d; char e; };
struct S5 { char a[3]; short b; char c; };

int main(void)
{
    struct S2 s2 = {'A', 1.5, 'Z'};
    struct S5 s5 = {{1, 2, 3}, 0x1234, 9};
    int i = INT_MAX;
    unsigned u = 0;
    printf("%zu %zu %d %u\n", sizeof s2, sizeof s5, i, u);
    return 0;
}

实验步骤与要观察的点:

$ cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -g -O0 -o layout layout.c
$ gdb -q ./layout
(gdb) break main
(gdb) run
(gdb) p sizeof(struct S2)          # 24:不是 10
(gdb) p sizeof(struct S5)          # 8
(gdb) p &((struct S2 *)0)->d       # (double *) 0x8:偏移是编译期常量
(gdb) p &((struct S5 *)0)->b       # (short *) 0x4
(gdb) x/24xb &s2                   # 前 8 字节:41 后跟 7 个 00
(gdb) p i                          # 2147483647
(gdb) set var i = i + 1            # -O0 下真实回绕为 -2147483648
(gdb) p i
(gdb) p/x i                        # 0x80000000
(gdb) p (int)0xFFFFFFF0            # -16
(gdb) p/u (unsigned)-1             # 4294967295
(gdb) info registers eflags        # 看 OF/ZF/CF/SF

p &((struct S2 *)0)->d 是标准技巧:对空指针取成员地址不会真的访存,gdb 在编译期常量上算出偏移。x/24xb 里 A 后面的 7 个 00 就是填充;1.5 的字节是 00 00 00 00 00 00 f8 3f(小端)。set var i = i + 1 在 -O0 下能看到硬件回绕,但同一行在 -O2 下是 UB,不能作为「C 会回绕」的证据。

再看优化后的差异:

(gdb) disassemble /m main
(gdb) p sizeof(struct S2) + sizeof(struct S5)

/m 把源码行与指令混排,可以确认「sizeof 在编译期就是常数,运行时没有计算」。若把 layout.c 用 -O2 重编译,i + 1 可能被折叠,断点也可能被移动;这是 UB 与优化交互的直接演示。

1.2.16 实验二:用 objdump/readelf 读常量与浮点指令

浮点常量在二进制里长什么样?写一个只有两个函数的小文件:

/* fc.c */
float  fc(void) { return 0.15625f; }
double dc(void) { return 0.1; }
int    add(int a, int b) { return a + b; }
$ cc -O2 -c fc.c -o fc.o
$ objdump -d -M intel fc.o | sed -n '/<fc>:/,+6p'
0000000000000000 <fc>:
   0:   f3 0f 10 05 00 00 00 00  movss  xmm0,DWORD PTR [rip+0x0]
   8:   c3                    ret
$ objdump -s -j .rodata.cst4 fc.o
 0000 0000203e                             .. >
$ objdump -s -j .rodata.cst8 fc.o
 0000 9a9999999999b93f                     ........

.rodata.cst4 里的 00 00 20 3e 按小端读是 0x3E200000,正是 0.15625 的单精度位模式;.rodata.cst8 的 9a 99 99 99 99 99 b9 3f 是 0x3FB999999999999A,即 double 的 0.1。两条指令都是 RIP 相对寻址:常量放在只读节,指令里编码的是「相对下一条指令的偏移」,加载到任何地址都能取到。

把整数与浮点的加法放在一起对比:

$ objdump -d -M intel fc.o | sed -n '/<add>:/,+4p'
0000000000000020 <add>:
  20:   8d 04 37              lea    eax,[rdi+rsi*1]
  23:   c3                    ret

add 没有用 add 指令,而是用 lea 做「基址+变址」的加法——lea 不设标志位、延迟更低。浮点加法则是 addss/addsd,操作数在 %xmm 寄存器;整数与浮点走的是两套执行单元和两套寄存器堆(详见 1.3)。

最后确认 ELF 的字节序标记:

$ readelf -h fc.o | grep -E 'Class|Data|Machine'
  Class:                             ELF64
  Data:                              2's complement, little endian
  Machine:                           Advanced Micro Devices X86-64

Data: little endian 是文件头的 EI_DATA 字节(偏移 5),它告诉加载器如何解释文件里的多字节字段。把同一个文件拿到大端机器上,加载器会拒绝执行,而不是「按大端读一遍」。

1.2.17 十二条陷阱:现象、原因与修复

#现象原因修复
1printf("%d", sizeof x) 打印负数或截断sizeof 是 size_t(无符号 64 位),%d 按 int 读用 %zu,或转 (unsigned long) 配 %lu
2(int16_t)0x8F 得 143,不是 -1130x8F 的类型是 int,截断不扩展先 (int8_t) 再提升,或用 sign_extend
3-1 < 0u 为假混合运算把 int 转成 unsigned比较前统一类型;开 -Wsign-compare
4for (unsigned i = n; i >= 0; i--) 死循环无符号永远不小于 0用 i != 0 或 for (size_t i = n; i-- > 0;)
51 << 31 得到负数或 UB有符号左移溢出用 1u << 31 或 UINT32_C(1) << 31
6INT_MIN / -1 崩溃(SIGFPE)商 2312^{31} 不可表示,x86 idiv 触发 #DE除法前判 b == 0 || (a == INT_MIN && b == -1)
7abs(INT_MIN) 仍是负数相反数不可表示用 llabs 并先转 long long,或显式处理边界
8同一份序列化代码在 ARM 上解析错char 默认无符号/有符号不同,或结构体布局不同用 uint8_t/int8_t,逐字段序列化,不 fwrite 结构体
9加 __attribute__((packed)) 后 ARM 上崩溃成员地址非对齐,取地址后解引用触发对齐异常用 memcpy 逐字段读出;开 -Waddress-of-packed-member
10memcmp(&a, &b, sizeof a) 对相同内容返回非 0填充字节的值未定义逐成员比较,或初始化时 memset 清零
110.1f == 0.1 为假float 提升为 double 后值不同比较用容差;需要精确时用整数/十进制
12scanf("%f", &d)(d 是 double)崩溃或错值%f 要 float *,double 要用 %lf检查格式串与指针类型;开 -Wformat

补充三条不算「陷阱」但常被忽略的边界:

  • char 的符号性:x86-64 Linux 默认有符号,ARM64 Linux 默认无符号(GCC)。跨平台代码显式写 signed char/unsigned char。
  • 位域布局:unsigned a:3 从低位还是高位开始、能否跨存储单元,都是实现定义;跨编译器/网络协议不要用位域。
  • 浮点的编译选项:-ffast-math 会关闭 NaN/Inf 语义、假设无符号零、允许重排;它不是「更快的 IEEE」,而是「另一套语义」。金融、科学计算、需要逐位复现的场景不要开。
本节最容易记反的三件事
  1. 「填充字节会被清零」——malloc 的内存和栈上的结构体都不保证;calloc 才清零。比较、哈希、序列化都必须跳过填充。
  2. 「结构体大小等于成员大小之和」——只有成员按对齐顺序排列且无尾部填充时才成立;S1 的 6 字节成员占 12 字节。
  3. 「float 的 ULP 是固定的 2−232^{-23}」——只在 [1,2)[1,2) 内成立;跨阶码边界 ULP 翻倍,0 附近是最小非规格化数。

1.2.18 C 与 Rust 的表示语义对照

这一节回答:同一台机器、同一套 IEEE 754 与补码规则,C 和 Rust 对这些位的「承诺」有什么不同?答案不是「Rust 更安全」这种口号,而是逐条对照:哪些行为在 C 里是未定义、在 Rust 里有定义,哪些只是换了一种说法。

逐条对照

行为C17Rust(release 默认)Rust(debug 默认)
有符号整数溢出未定义行为(UB)回绕(二进制补码)panic
无符号整数溢出回绕,定义良好回绕panic
移位量 ≥ 位宽UB按目标机掩码(不保证可移植)panic
有符号右移实现定义(GCC 算术右移)算术右移算术右移
宽转窄整数实现定义(通常截断)as 截断,定义良好同左
负整数转无符号定义良好(加 2w2^w)as 截断(同左)同左
浮点转整数越界UB饱和(1.45+)饱和
NaN 转整数UB00
结构体布局实现定义,同一 ABI 内稳定repr(Rust) 可重排;repr(C) 与 C 一致同左
数组越界UB边界检查,panic边界检查,panic
未初始化内存读是 UBMaybeUninit,读需 unsafe同左
空指针解引用UBunsafe 下的 UB;安全代码用 Option<&T>同左
指针别名restrict 违反是 UB&mut 独占,违反是 UB(unsafe 才可能)同左

三行值得展开:

  • 溢出:C 的 UB 让编译器可以假设「不会溢出」,从而把 i + 1 > i 优化成恒真;Rust 的 release 模式把溢出定义为「按补码回绕」,debug 模式则 panic。要显式控制,用 wrapping_add、checked_add、saturating_add、overflowing_add,而不是依赖构建模式。
  • 浮点转整数:C 的 (int)1e300 是 UB,x86 的 cvttsd2si 会给出 INT_MIN(“integer indefinite”);Rust 的 1e300 as i64 饱和到 i64::MAX,NaN as i64 得 0。两者在越界时的结果不同,跨语言传值时要显式 clamp。
  • 布局:repr(C) 不是「更安全的 C」,而是「按 C 的 ABI 排布」;Rust 默认布局允许编译器重排字段以减少填充。下面的程序直接测出差别。

完整程序:布局、溢出与位模式

// repr.rs — 编译:rustc -O repr.rs -o repr
use std::mem::{align_of, size_of};

#[repr(C)]
struct CfgC { a: u8, b: u32, c: u8 }

struct CfgRust { a: u8, b: u32, c: u8 }

fn main() {
    println!("C:    size={} align={}", size_of::<CfgC>(), align_of::<CfgC>());
    println!("Rust: size={} align={}", size_of::<CfgRust>(), align_of::<CfgRust>());

    let a: i32 = i32::MAX;
    println!("checked={:?}", a.checked_add(1));
    println!("wrapping={}", a.wrapping_add(1));
    println!("saturating={}", a.saturating_add(1));
    println!("overflowing={:?}", a.overflowing_add(1));

    let x: i32 = -1;
    println!("(-1i32) as u32 = {}", x as u32);

    let f = 1e300f64;
    println!("1e300 as i64 = {}", f as i64);
    println!("NAN as i64 = {}", f64::NAN as i64);

    let y: u8 = 0x8F;
    println!("0x8F as i8 = {}", y as i8);

    println!("f32 0.1 bits = {:08x}", (0.1f32).to_bits());
    println!("f64 0.1 bits = {:016x}", (0.1f64).to_bits());
}

在 arm64 macOS(rustc 1.99)上的输出:

C:    size=12 align=4
Rust: size=8 align=4
checked=None
wrapping=-2147483648
saturating=2147483647
overflowing=(-2147483648, true)
(-1i32) as u32 = 4294967295
1e300 as i64 = 9223372036854775807
NAN as i64 = 0
0x8F as i8 = -113
f32 0.1 bits = 3dcccccd
f64 0.1 bits = 3fb999999999999a

两个布局数字说明了 1.2.11 的规则:CfgC 按 C 规则是 12 字节(u8 在 0,u32 在 4,u8 在 8,尾部补到 4 的倍数);CfgRust 让编译器重排,把 b 放前面、两个 u8 塞在后面,压到 8 字节。跨 FFI 的结构体必须写 #[repr(C)],否则 C 侧读到的字段顺序可能是错的。

f32 0.1 的位模式 3dcccccd、f64 0.1 的 3fb999999999999a,与 1.2.7 手算的 C 结果逐位相同——语言换了,IEEE 754 没换。

什么时候必须 `repr(C)`
  1. 与 C/Rust 之外的代码共享结构体(FFI、内核 UAPI、驱动)。
  2. 结构体直接映射到文件/网络格式(ELF 头、sockaddr、协议首部)。
  3. 需要 offsetof 或依赖字段顺序的序列化。
  4. 把结构体放进 static 并被汇编/链接脚本按名字引用。 其余场景用默认布局,让编译器减小体积;不要为了「稳定」给所有结构体加 repr(C),那会放弃优化填充的机会。
C 程序员写 Rust 最常踩的五个坑
  1. 「as 和 C 的强制转换一样」——Rust 的 as 对整数截断、对浮点转整数饱和;C 的越界转换是 UB。跨语言对比时先统一语义。
  2. 「release 也会检查溢出」——release 默认关闭 overflow-checks,有符号/无符号都回绕;要检查用 checked_* 或显式开启。
  3. 「结构体字段顺序就是声明顺序」——默认 repr(Rust) 不保证;上面的 12 vs 8 就是证据。
  4. 「unsafe 只是语法糖」——unsafe 块里编译器不再保证别名/越界/初始化的规则,违反仍是 UB;它不是「关掉检查」,而是「由你负责」。
  5. 「Rust 没有 UB」——安全代码没有,unsafe 代码有;get_unchecked、from_raw_parts、transmute 都能制造 UB。

1.2.19 本章出口检查

检查项达标线没达标去哪
手算 0xFB 的无符号与补码值能写出两种解释与公式1.2.2
解释 -1 < 0u 为假能说出整型提升与转换1.2.2
写出符号扩展与截断的例子能区分 (int8_t)0x8F 与 (int16_t)0x8F1.2.3
检测有符号加法溢出会用 __builtin_add_overflow1.2.4
解释结构体填充能手算 3 个结构体的 sizeof1.2.6
拆解 0.15625 的 IEEE 754 位能算出 0x3E2000001.2.7
解释 0.1+0.2 的结果能说出舍入与近似1.2.8
即时自测 · 1.2

Q1 32 位补码 0xFFFFFFF0 作为 int 和 unsigned 分别是多少?如何用一行代码验证?

Q2 int8_t x = 0x80; 的值是多少?-x 在 8 位补码里是多少?在 C 里对 INT_MIN 取负会发生什么?

Q3 struct { char c; double d; char e; } 在 x86-64 上的 sizeof 是多少?把 double 移到最前面会变成多少?

Q4 为什么 0.1 + 0.2 == 0.3 是假?如果要比较这两个表达式,应该怎么写?

答案

A1 作为 int 是 -16(~0xF + 1);作为 unsigned 是 4294967280。验证:printf("%d %u\n", (int)0xFFFFFFF0u, 0xFFFFFFF0u);。

A2 0x80 按 8 位补码是 -128。-x 仍是 0x80(-128 的相反数不可表示),在 C 里对 int8_t 的 -128 取负会先提升为 int,得到 128;对 INT_MIN 取负是 UB,x86 上通常仍是 INT_MIN。

A3 24 字节(偏移 0、8、16,尾部填充到 8 的倍数)。把 double 放最前:double 0,char 8,char 9,总大小 16。

A4 0.1 和 0.2 都不能用二进制有限位精确表示,相加后舍入得到 0.30000000000000004,而字面量 0.3 是另一个近似值。比较应使用容差:fabs((0.1+0.2)-0.3) < 1e-12,或改用整数/十进制类型。

母本对应

母本章节本书落点程度
C(CSAPP)第 2 章 信息的表示和处理 2.1 信息存储1.2.1、1.2.3、1.2.6完整:进制、字长、字节序、对齐
C(CSAPP)第 2 章 2.2 整数表示1.2.2、1.2.3完整:无符号/补码、转换、符号扩展
C(CSAPP)第 2 章 2.3 整数运算1.2.4、1.2.5完整:溢出、除法、移位、位技巧
C(CSAPP)第 2 章 2.4 浮点数1.2.7、1.2.8完整:IEEE 754、舍入、特殊值、精度
R(奔跑吧)第 1 章 处理器体系结构1.2.2、1.2.4背景:ALU 与标志位,详见 1.4
K(Kerrisk)第 7 章 内存分配1.2.6背景:对齐与 malloc 返回地址
L(李治军)第 6 章 内存管理1.2.3背景:程序重定位中的地址表示

下一站:位模式如何变成指令?寄存器、寻址模式、条件码和栈帧怎样组合出 if、for、函数调用?进入 1.3 机器级程序:x86-64 汇编。