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、还是一个被截断的指针。
约定:除非特别说明,字长 ,字节序为小端,编译器为 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 个映射,转换就不需要计算器:
| 十六进制 | 二进制 | 十六进制 | 二进制 |
|---|---|---|---|
| 0 | 0000 | 8 | 1000 |
| 1 | 0001 | 9 | 1001 |
| 2 | 0010 | A | 1010 |
| 3 | 0011 | B | 1011 |
| 4 | 0100 | C | 1100 |
| 5 | 0101 | D | 1101 |
| 6 | 0110 | E | 1110 |
| 7 | 0111 | F | 1111 |
例:0x3F8A = 0011 1111 1000 1010。按权展开:。反过来,十进制转十六进制可以不断除以 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。
字长是 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 无符号、补码与取值范围
无符号数的编码最直接:位向量 的值是
补码(two’s complement)把最高位的权重取负:
同一个位向量 1111 1011(0xFB):
- 按无符号:;
- 按 8 位补码:。
所以 0xFB 不是「一个数」,而是「一个位向量 + 一种解释」。C 里 (uint8_t)0xFB 是 251,(int8_t)0xFB 是 -5。
8 位补码的取值是 到 ,不对称:负数比正数多一个。原因是最小值 0x80 没有对应的正数。一般地,
| 宽度 | |||
|---|---|---|---|
| 8 | -128 | 127 | 255 |
| 16 | -32768 | 32767 | 65535 |
| 32 | -2147483648 | 2147483647 | 4294967295 |
| 64 | -9223372036854775808 | 9223372036854775807 | 18446744073709551615 |
补码的关键性质是加法与无符号加法共用同一套电路:把位向量按无符号相加,再丢弃最高位进位,结果的位模式与补码相加完全一致。这就是为什么 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 展开。
取负的位级做法是「取反加一」:
验证 (0x05):~0x05 = 0xFA,加 1 得 0xFB,即 -5。对 取负会得到它自己(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 已含)能让编译器提醒大部分情况。
- 「补码就是取反加一」——取反加一是「求相反数」的算法,不是编码定义。编码定义是最高位权重为负;理解这一点才能解释为什么
0x80是 -128 而不是 128。 - 「有符号溢出会回绕成负数」——C 标准说这是未定义行为,编译器可以假设它不发生。实际硬件可能回绕,但优化后的代码可能把整个循环删掉。
- 「
char默认是 signed」——由实现决定。x86-64 Linux 上char有符号,ARM64 Linux 上通常无符号(取决于编译器默认)。需要明确时用signed char/unsigned char。 - 「
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 能表示它的全部值,所以是符号扩展。反过来,把宽类型赋给窄类型是截断:保留低 位,丢弃高位,结果按目标类型解释。
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 在最后。
多字节对象的字节顺序:小端(little-endian)把最低有效字节放在最低地址;大端(big-endian)把最高有效字节放在最低地址。x86-64、ARM64(默认)是小端;网络协议使用大端,称为网络字节序。字节序只在「把对象当字节数组看」时才有意义,例如序列化、IPC、抓包。
1.2.4 整数运算与溢出
无符号加法是模 的加法:
UINT_MAX + 1 的结果是 0,这是定义良好的行为,C 标准保证。补码加法在数学上也是模 ,但 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 再判断范围。
除法有两个必须记住的边界:
- 整数除法向零截断:
-7 / 2 == -3,-7 % 2 == -1。余数的符号与被除数相同。 INT_MIN / -1溢出:结果 无法用int表示,x86 的idiv会触发#DE,进程收到SIGFPE。这不是「除数为 0」的专利。
int a = INT_MIN, b = -1;
int c = a / b; /* 未定义行为;x86 上通常 SIGFPE */
- 「无符号减法不会出问题」——
0u - 1是UINT_MAX,不是 -1。用无符号表示「差值」时,先判断大小。 - 「
abs(INT_MIN)是正数」——abs(INT_MIN)仍是INT_MIN(或 UB),因为相反数不可表示。用labs/llabs并检查范围。 - 「
x * 2比x << 1慢」——现代编译器对两者生成同样的指令;可读性优先。对负数左移在 C 里是 UB,别用移位代替乘法。 - 「
size_t是无符号的,所以更安全」——它会让for (size_t i = n-1; i >= 0; i--)永远成立。无符号只改变解释,不消除边界。 - 「开
-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;无符号左移是模 的乘法,定义良好。
- 有符号右移是实现定义:算术右移(补符号位)或逻辑右移(补 0)。GCC/Clang 在 x86-64 上做算术右移:
-8 >> 1 == -4。无符号右移一定是逻辑右移。
几个高频位技巧:
| 表达式 | 作用 | 例子(8 位) |
|---|---|---|
x & (x - 1) | 清除最低位的 1 | 0xB0 → 0xA0 |
x & -x | 取出最低位的 1 | 0xB0 → 0x10 |
x | (x + 1) | 把最低位的 0 置 1 | 0xB0 → 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 的最低位的 1 在位置 ,则 x 的低 位全 0、第 位是 1。-x = ~x + 1:取反后低 位全 1、第 位变 0;加 1 后低 位进位成全 0、第 位变回 1,高位全部取反。两者相与,只有第 位同时为 1。这个推导不依赖具体位宽。
逻辑运算与位运算:&&、||、! 不是 &、|、~
| 运算 | 类型 | 求值 | 结果 |
|---|---|---|---|
&& / ` | ` | 逻辑 | |
& / ` | ` | 位 | 两边都求值,逐位 |
! | 逻辑非 | 0→1,非 0→0 | 0/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 看过。工程上要记住三个场景:
- 网络:IP/TCP 首部中的多字节字段是大端,所以有
htons/htonl;发送前转网络序,接收后转主机序。 - 文件格式:PNG、JPEG、ELF 都显式规定字节序;跨架构读写要按格式转换,不能直接
fwrite一个结构体。 - 共享内存/管道:两个进程若架构不同(比如 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) │
└─┴──────────────────────────┴───────────────────────────────┘
规格化数的值是
其中单精度 bias 是 127,双精度是 1023; 表示「隐含的前导 1 加上尾数小数部分」。阶码全 0 表示非规格化数(没有隐含 1,值更小),阶码全 1 表示无穷或 NaN。
手算一个例子:。
- 转二进制:。
- 规格化:。
- 符号 ;阶码 。
- 尾数小数部分
.01,补齐 23 位:0100 0000 0000 0000 0000 000。 - 拼起来:
0 01111100 01000000000000000000000=0x3E200000。
验证:0x3E200000 的阶码字段是 124,尾数是 ,所以值是 。若符号位为 1,则是 0xBE200000 = 。
几个基准位模式要背下来:
| 值 | float 位模式 | double 位模式 |
|---|---|---|
+0.0 | 0x00000000 | 0x0000000000000000 |
-0.0 | 0x80000000 | 0x8000000000000000 |
1.0 | 0x3F800000 | 0x3FF0000000000000 |
0.5 | 0x3F000000 | 0x3FE0000000000000 |
+∞ | 0x7F800000 | 0x7FF0000000000000 |
| quiet NaN | 0x7FC00000 | 0x7FF8000000000000 |
0.1 无法用二进制有限小数表示,只能舍入:
float0.1 的位模式是0x3DCCCCCD,实际值约 ;double0.1 的位模式是0x3FB999999999999A,实际值约 。
两者都不是精确的 0.1,只是精度不同。0.1 + 0.2 在 double 下得到 0.30000000000000004,因为两个近似值相加后又舍入了一次。
特殊值的规则:
| 阶码 | 尾数 | 含义 |
|---|---|---|
| 全 0 | 全 0 | |
| 全 0 | 非 0 | 非规格化数(逐渐下溢) |
| 1–254(单) | 任意 | 规格化数 |
| 全 1 | 全 0 | |
| 全 1 | 非 0 | NaN(尾数最高位区分 quiet/signaling) |
NaN 与任何值比较(包括自己)都返回假,所以判断要用 isnan(x),不能写 x == NAN。INFINITY - INFINITY 产生 NaN;1.0 / 0.0 产生无穷(若编译器没把它当编译期错误)。
单精度有 24 位有效二进制位(1 位隐含 + 23 位尾数),机器精度 ;双精度有 53 位有效位,。 是「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 位有效位,所有大于 的整数不一定能精确表示:
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。
浮点比较的正确写法取决于场景:
- 判断「相等」:用相对误差 ,或
fabs(a-b) <= 1e-9(量级已知时)。 - 判断「排序」:直接
</>,但要注意 NaN 会让所有比较为假;排序前先处理 NaN。 - 累加大量小数:用 Kahan 求和或
long double,或按数量级排序后相加。 - 金额:用整数「分」或十进制库,不要用
double。
- 「
float和double只是精度不同,语义一样」——特殊值、舍入、异常标志的规则相同,但float的有效位少,累加误差放大得更快;大量累加应优先double。 - 「
0.1f == 0.1为真」——0.1f提升为double后是0.100000001490116...,不等于double的 0.1。混合比较会踩坑。 - 「浮点运算是确定性的」——同一表达式在不同优化级别、不同 FMA 使用、不同架构下可能得到不同最低位。
-ffast-math会进一步放弃 IEEE 语义。 - 「NaN 等于它自己」——IEEE 754 规定 NaN 与任何值(含自身)的无序比较都为假。用
isnan,不要用==。
1.2.9 手算速查:进制、补码、移位与溢出
这一节回答一个问题:手边没有计算器、也懒得开 Python 时,怎样在三十秒内把一条位运算表达式算对。办法不是背更多结论,而是把 1.2.1–1.2.4 的规则压成五张表,每张表配一道手算例题。考试、代码评审、崩溃现场读寄存器,用的都是这套动作。
表一:位宽与边界常数
| 宽度 | 十六进制写法 | |||
|---|---|---|---|---|
| 8 | -128 | 127 | 255 | 0x80 / 0x7F / 0xFF |
| 16 | -32768 | 32767 | 65535 | 0x8000 / 0x7FFF / 0xFFFF |
| 32 | -2147483648 | 2147483647 | 4294967295 | 0x80000000 / 0x7FFFFFFF / 0xFFFFFFFF |
| 64 | -9223372036854775808 | 9223372036854775807 | 18446744073709551615 | 0x8000000000000000 / 0x7FFF…F / 0xFFFF…F |
三条由此推出的常数关系,比表本身更常用:
- ,所以 ,负数比正数多一个。
- 。看到
0xFFFFFFFF时,先问「按什么类型读」。 -1的补码是全 1。(unsigned)-1 == UINT_MAX永远成立,与位宽无关。
表二:4 位补码全表(16 个位模式)
| 位模式 | 无符号 | 补码 | 取负后的位模式 | 取负后的值 |
|---|---|---|---|---|
0000 | 0 | 0 | 0000 | 0 |
0001 | 1 | 1 | 1111 | -1 |
0010 | 2 | 2 | 1110 | -2 |
0011 | 3 | 3 | 1101 | -3 |
0100 | 4 | 4 | 1100 | -4 |
0101 | 5 | 5 | 1011 | -5 |
0110 | 6 | 6 | 1010 | -6 |
0111 | 7 | 7 | 1001 | -7 |
1000 | 8 | -8 | 1000 | -8(溢出) |
1001 | 9 | -7 | 0111 | 7 |
1010 | 10 | -6 | 0110 | 6 |
1011 | 11 | -5 | 0101 | 5 |
1100 | 12 | -4 | 0100 | 4 |
1101 | 13 | -3 | 0011 | 3 |
1110 | 14 | -2 | 0010 | 2 |
1111 | 15 | -1 | 0001 | 1 |
表里唯一的异常行是 1000:取负后仍是 1000。这就是「 的相反数不可表示」,在 C 里对 INT_MIN 取负是 UB,在硬件上是 neg 指令设置 OF=1 但结果不变。
手算例 1:0xFFFFFFF0 作为 int 是多少?做法:全 1 前缀说明是负数,先取负看大小——~0xFFFFFFF0 + 1 = 0x00000010 = 16,所以原值是 。作为 unsigned 则是 。
表三:移位与除法的等价关系
| 表达式 | 数学值 | 前提 | 常见误用 |
|---|---|---|---|
x << k(无符号) | 是 UB | ||
x << k(有符号非负) | 不溢出 | 溢出是 UB,不是回绕 | |
x >> k(无符号) | 对负数不适用(无符号没有负数) | ||
x >> k(有符号,算术) | 实现定义,GCC 为算术右移 | 不等于 C 的 / | |
x / 2^k(有符号) | 向零截断 | 恒定义(除 TMin / -1) | 与 >> 只在 时相同;负数要加偏置 |
最后两行的差别是所有「用移位代替除法」优化的根源。取 、:
-7 的 32 位补码 = 1111 ... 1111 1001
-7 >> 1(算术右移) = 1111 ... 1111 1100 = -4 (向下取整)
-7 / 2(C 的截断除法) = -3 (向零取整)
编译器要实现 x / 4,又不能假设 ,于是生成「先加偏置再算术右移」:
div4:
leal 3(%rdi), %eax # eax = x + 3
testl %edi, %edi
cmovnsl %edi, %eax # 若 x >= 0,eax = x(不需要偏置)
sarl $2, %eax # 算术右移 2 位
ret
偏置 的作用是把「向零截断」补成「向下取整」: 时 ,-4 >> 2 = -1,而 ,对上了。对无符号数则不需要偏置,直接 shrl $2。
表四:溢出判定
| 运算 | 何时溢出 | 硬件标志 | C 里的检测 |
|---|---|---|---|
有符号加 a+b | a、b 同号且结果异号 | OF=1 | __builtin_add_overflow |
有符号减 a-b | a、b 异号且结果符号与 a 不同 | OF=1 | __builtin_sub_overflow |
有符号乘 a*b | 真实积超出 | OF=1(双操作数 imul) | __builtin_mul_overflow |
无符号加 a+b | 最高位有进位 | CF=1 | 结果 < a |
无符号乘 a*b | 高位非零 | CF=1 | a != 0 && r / a != b |
除法 a/b | b==0 或 a==TMin && b==-1 | #DE → SIGFPE | 先判 b,再判边界 |
无符号的溢出是定义良好的回绕,有符号溢出是 UB。表中「C 里的检测」一列之所以全部推荐内建函数,是因为手写判定在类型提升、位宽混用时会悄悄失效;内建函数由编译器按目标位宽生成,且不会触发 UB。
手算例 2:int a = 0x7FFFFFFF; a + 1 的机器结果是什么?位模式相加得 0x80000000,按补码是 ,OF=1。C 程序里这一行是 UB,-O2 下编译器可以假定它不发生;用 volatile 或 __builtin_add_overflow 才能观察真实机器行为。
表五:有符号与无符号的转换
| 转换 | 规则 | 例子() |
|---|---|---|
| 有符号 → 同宽无符号 | 时 | (unsigned)-1 → 4294967295 |
| 无符号 → 同宽有符号 | 时 | (int)0x80000000u → -2147483648 |
| 宽 → 窄 | 保留低 位,丢弃高位 | (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。
- 先看最高位定符号;最高位 0 直接按无符号读。
- 最高位 1 时,取反加一得到绝对值,再添负号。
- 加减法一律在位上做,丢弃最高位进位;结果按目标类型解释。
- 比较大小前先问「按有符号还是无符号」,两者用不同条件码。
- 移位前先问「左移会不会溢出、右移的对象是不是负数」。
1.2.10 IEEE 754 逐字段:非规格化、舍入与 ULP
1.2.7 给出了浮点的整体公式,这一节把三个字段拆到每一位,回答四个容易含糊的问题:非规格化数到底表示什么、舍入模式怎样影响最后一位、ULP 怎么算、为什么浮点排序不能直接比较位模式。
字段与取值
| 字段 | float 位区间 | double 位区间 | 作用 |
|---|---|---|---|
s(符号) | bit 31 | bit 63 | 0 为正,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 |
| bias | 127 | 1023 |
三种编码的完整取值规则( 为符号位, 为 frac 解释出的小数):
非规格化数的阶码固定为 ,不是 。这个「1」是 IEEE 754 的设计技巧:让最大的非规格化数与最小的规格化数无缝衔接。以 float 为例:
| 位模式 | 含义 | 十进制近似 |
|---|---|---|
0x00000000 | +0.0 | 0 |
0x00000001 | 最小正非规格化数 | |
0x007FFFFF | 最大非规格化数 | |
0x00800000 | 最小正规格化数 | |
0x7F7FFFFF | 最大有限值 | |
0x7F800000 | — | |
0x7FC00000 | quiet NaN(默认) | — |
从 0x007FFFFF 到 0x00800000,值只增加了 ,没有跳变;这就是「逐渐下溢」(gradual underflow)。若没有非规格化数,最小规格化数与 0 之间会有一段 宽的真空,减法结果会被突然冲成 0。
手算例 4:把 0x3E200000 拆开。符号位 0;阶码 0111 1100 = 124,;尾数 .010…0 = 。值是 。反过来,把 写成 float:,尾数小数部分 .01,阶码 ,拼出 0 01111100 0100…0。
ULP 与机器精度
ULP(unit in the last place)是相邻两个可表示数之间的间距。对规格化数,
其中 是有效位数(float 为 24,double 为 53)。所以 float 在 内的 ULP 是 ,在 内是 ;double 在 内是 。
| 区间 | float ULP | double ULP |
|---|---|---|
表里最后两行解释了两个经典现象:float 在 之后连「加 1」都表示不了;float 在 附近 ULP 已经是 1,所以 16777216.0f + 1.0f == 16777216.0f。
四种舍入模式
IEEE 754 规定五种舍入方向,C 的 <fenv.h> 暴露其中四种:
fesetround 参数 | 规则 | 1.5 的结果 | -1.5 的结果 |
|---|---|---|---|
FE_TONEAREST(默认) | 就近,平局取偶 | 2 | -2 |
FE_DOWNWARD | 向 | 1 | -2 |
FE_UPWARD | 向 | 2 | -1 |
FE_TOWARDZERO | 向 0 | 1 | -1 |
默认的「就近取偶」(round-to-nearest, ties-to-even)在手算时的动作是:找到相邻的两个可表示数,取更近的;正好在中点时,看哪一个的尾数最低位是 0。用 3 位有效位表示 :候选是 与 ,中点取偶得 。 的候选是 与 ,取偶得 。
「取偶」不是审美偏好:如果每次都向上取,大量累加会产生系统性正偏差;取偶让平局在两个方向上均匀分布,误差期望为 0。
一次舍入的相对误差有硬上界:在规格化范围内,
float 的 ,所以「约 7 位十进制有效数字」;double 是 ,约 16 位。这是单次运算的界, 次累加的最坏误差会按 增长(向前误差分析),不是 次都保持 。
与 各自发生两次舍入,舍入点不同。取 、、(float): 舍入回 ,再加 仍是 ;而 ,加到 上得 。差别不在数学,在「哪一步被舍掉」。并行归约改变求和顺序,因此结果可能与串行版本逐位不同——这不是 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)把 做成一条指令,只在最后舍入一次。它与「先乘后加」的结果可能差一个 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 的差别。
- 「非规格化数的阶码是 0」——字段值是 0,但有效阶码是 ;公式里写成 才对。
- 「
float能精确表示所有 7 位十进制数」——只能保证 6–7 位相对精度;0.1f本身就不精确,16777217也存不下。 - 「
x == x对 NaN 为真」——IEEE 754 规定 NaN 与任何值(含自身)的无序比较都为假,所以x != x是判断 NaN 的经典写法(更清楚的是isnan)。 - 「
-0.0 == 0.0为假」——比较为真;但1.0/(-0.0)是 ,signbit(-0.0)为真。相等与同号是两件事。 - 「舍入误差总是随机正负抵消」——只有在平局取偶、输入分布对称时才近似成立;固定模式的累加(如
+= 0.1)会产生可预测的漂移。
1.2.11 字节序、对齐与结构体布局的完整规则
1.2.6 给了三个结构体的例子。这一节补全规则本身:对齐值从哪来、填充插在哪、联合体与位域怎么算、packed 改变了什么。掌握规则后,任何结构体都能在纸上算出 sizeof 和每个成员的偏移,不需要编译器。
对齐值表(x86-64 System V)
| 类型 | sizeof | 对齐值 | 备注 |
|---|---|---|---|
char / signed char / unsigned char | 1 | 1 | 任何地址都合法 |
short | 2 | 2 | 偏移必须是偶数 |
int / unsigned | 4 | 4 | |
long / unsigned long / 指针 | 8 | 8 | LP64 |
long long | 8 | 8 | |
float | 4 | 4 | |
double | 8 | 8 | |
long double | 16 | 16 | x86-64 SysV 的 80 位扩展精度 |
_Bool | 1 | 1 | |
| 数组 | 元素大小 | 元素对齐 | 元素之间无填充 |
| 结构体 | 见规则 | 成员最大对齐值 | |
| 联合体 | 最大成员大小(向上取整) | 成员最大对齐值 |
ARM64 Linux 的规则基本相同,差别在 long double:AArch64 上是 128 位(sizeof 16,对齐 16),而 macOS arm64 把它当作 double(8/8)。同一个结构体跨这两个平台可能大小不同。
四条布局规则
- 成员偏移:每个成员的偏移必须是自身对齐值的整数倍,不足则在它前面插填充。
- 结构体对齐:结构体的对齐值等于所有成员对齐值的最大值(含嵌套结构体)。
- 结构体大小:
sizeof必须是结构体对齐值的整数倍,不足则在尾部补填充。这样数组中每个元素都对齐。 - 嵌套:内层结构体先按自己的规则算完大小与对齐,再作为一个整体参与外层布局。
按这四步手算:
| 结构体 | 成员偏移 | 内部填充 | 尾部填充 | sizeof |
|---|---|---|---|---|
struct S1 { char c; int i; char d; } | 0, 4, 8 | 3 | 3 | 12 |
struct S2 { char c; double d; char e; } | 0, 8, 16 | 7 | 7 | 24 |
struct S3 { double d; char c; char e; } | 0, 8, 9 | 0 | 6 | 16 |
struct S4 { char a; short b; int c; long d; } | 0, 2, 4, 8 | 1 | 0 | 16 |
struct S5 { char a[3]; short b; char c; } | 0, 4, 6 | 1 | 1 | 8 |
struct S6 { char c; long double ld; } | 0, 16 | 15 | 0 | 32 |
union U1 { char c[9]; int i; } | 0, 0 | 0 | 3 | 12 |
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 这一行值得停一下:它等于 ,与公式 是同一个数,只是规格化写法不同。ulp(0.0f)=0x1p-149 说明在 0 附近 ULP 不是 ,而是最小非规格化数;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 十二条陷阱:现象、原因与修复
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | printf("%d", sizeof x) 打印负数或截断 | sizeof 是 size_t(无符号 64 位),%d 按 int 读 | 用 %zu,或转 (unsigned long) 配 %lu |
| 2 | (int16_t)0x8F 得 143,不是 -113 | 0x8F 的类型是 int,截断不扩展 | 先 (int8_t) 再提升,或用 sign_extend |
| 3 | -1 < 0u 为假 | 混合运算把 int 转成 unsigned | 比较前统一类型;开 -Wsign-compare |
| 4 | for (unsigned i = n; i >= 0; i--) 死循环 | 无符号永远不小于 0 | 用 i != 0 或 for (size_t i = n; i-- > 0;) |
| 5 | 1 << 31 得到负数或 UB | 有符号左移溢出 | 用 1u << 31 或 UINT32_C(1) << 31 |
| 6 | INT_MIN / -1 崩溃(SIGFPE) | 商 不可表示,x86 idiv 触发 #DE | 除法前判 b == 0 || (a == INT_MIN && b == -1) |
| 7 | abs(INT_MIN) 仍是负数 | 相反数不可表示 | 用 llabs 并先转 long long,或显式处理边界 |
| 8 | 同一份序列化代码在 ARM 上解析错 | char 默认无符号/有符号不同,或结构体布局不同 | 用 uint8_t/int8_t,逐字段序列化,不 fwrite 结构体 |
| 9 | 加 __attribute__((packed)) 后 ARM 上崩溃 | 成员地址非对齐,取地址后解引用触发对齐异常 | 用 memcpy 逐字段读出;开 -Waddress-of-packed-member |
| 10 | memcmp(&a, &b, sizeof a) 对相同内容返回非 0 | 填充字节的值未定义 | 逐成员比较,或初始化时 memset 清零 |
| 11 | 0.1f == 0.1 为假 | float 提升为 double 后值不同 | 比较用容差;需要精确时用整数/十进制 |
| 12 | scanf("%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」,而是「另一套语义」。金融、科学计算、需要逐位复现的场景不要开。
- 「填充字节会被清零」——
malloc的内存和栈上的结构体都不保证;calloc才清零。比较、哈希、序列化都必须跳过填充。 - 「结构体大小等于成员大小之和」——只有成员按对齐顺序排列且无尾部填充时才成立;
S1的 6 字节成员占 12 字节。 - 「
float的 ULP 是固定的 」——只在 内成立;跨阶码边界 ULP 翻倍,0 附近是最小非规格化数。
1.2.18 C 与 Rust 的表示语义对照
这一节回答:同一台机器、同一套 IEEE 754 与补码规则,C 和 Rust 对这些位的「承诺」有什么不同?答案不是「Rust 更安全」这种口号,而是逐条对照:哪些行为在 C 里是未定义、在 Rust 里有定义,哪些只是换了一种说法。
逐条对照
| 行为 | C17 | Rust(release 默认) | Rust(debug 默认) |
|---|---|---|---|
| 有符号整数溢出 | 未定义行为(UB) | 回绕(二进制补码) | panic |
| 无符号整数溢出 | 回绕,定义良好 | 回绕 | panic |
| 移位量 ≥ 位宽 | UB | 按目标机掩码(不保证可移植) | panic |
| 有符号右移 | 实现定义(GCC 算术右移) | 算术右移 | 算术右移 |
| 宽转窄整数 | 实现定义(通常截断) | as 截断,定义良好 | 同左 |
| 负整数转无符号 | 定义良好(加 ) | as 截断(同左) | 同左 |
| 浮点转整数越界 | UB | 饱和(1.45+) | 饱和 |
| NaN 转整数 | UB | 0 | 0 |
| 结构体布局 | 实现定义,同一 ABI 内稳定 | repr(Rust) 可重排;repr(C) 与 C 一致 | 同左 |
| 数组越界 | UB | 边界检查,panic | 边界检查,panic |
| 未初始化内存 | 读是 UB | MaybeUninit,读需 unsafe | 同左 |
| 空指针解引用 | UB | unsafe 下的 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 没换。
- 与 C/Rust 之外的代码共享结构体(FFI、内核 UAPI、驱动)。
- 结构体直接映射到文件/网络格式(ELF 头、
sockaddr、协议首部)。 - 需要
offsetof或依赖字段顺序的序列化。 - 把结构体放进
static并被汇编/链接脚本按名字引用。 其余场景用默认布局,让编译器减小体积;不要为了「稳定」给所有结构体加repr(C),那会放弃优化填充的机会。
- 「
as和 C 的强制转换一样」——Rust 的as对整数截断、对浮点转整数饱和;C 的越界转换是 UB。跨语言对比时先统一语义。 - 「release 也会检查溢出」——release 默认关闭
overflow-checks,有符号/无符号都回绕;要检查用checked_*或显式开启。 - 「结构体字段顺序就是声明顺序」——默认
repr(Rust)不保证;上面的 12 vs 8 就是证据。 - 「
unsafe只是语法糖」——unsafe块里编译器不再保证别名/越界/初始化的规则,违反仍是 UB;它不是「关掉检查」,而是「由你负责」。 - 「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)0x8F | 1.2.3 |
| 检测有符号加法溢出 | 会用 __builtin_add_overflow | 1.2.4 |
| 解释结构体填充 | 能手算 3 个结构体的 sizeof | 1.2.6 |
拆解 0.15625 的 IEEE 754 位 | 能算出 0x3E200000 | 1.2.7 |
解释 0.1+0.2 的结果 | 能说出舍入与近似 | 1.2.8 |
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 汇编。