第2章 整数与浮点——数字的奥秘
元数据卡
| 项目 | 内容 |
|---|---|
| 难度 | (进阶) |
| 前置 | 第1章(比特、字节、进制) |
| 关键词 | 补码、无符号溢出、有符号溢出、IEEE 754、浮点精度、舍入模式 |
| C 标准 | C99+ |
| 代码量 | ~80 行 |
你的进度
"你继续往下走。地心里铺满了数字——整数和浮点数。但这里的数字不是你在变量村学的那些——它们在底层的表示方式藏着无数陷阱。"
上一章我们学会了数据怎么编码——比特拼成字节,字节拼成字。但我们遇到一个棘手的问题:内存里的 0xFFFFFFFF 到底是什么?是 4294967295(无符号 32 位最大值),还是 -1(32 位补码)?
答案早就在你心中了——是上下文。同一个比特模式,不同的解释方式,得到完全不同的数值。 这是计算机数字系统的第一块基石,也是无数 bug 的第一道裂缝。
这一章我们把整数和浮点的编码掀个底朝天,重点看两件事:
- 为什么整数会溢出,而且有符号溢出是未定义行为(不是你想的那种「绕一圈回来」)。
- 为什么
0.1 + 0.2不等于0.3,以及百万美元级 bug 是怎么从这么简单的问题里长出来的。
你的任务
本章分层
- 必读:无符号整数的范围与回绕行为;补码编码的原理;浮点精度的根本原因(0.1+0.2≠0.3)
- 选读:IEEE 754 浮点编码的 S/E/M 结构;整数溢出检测的安全编程实践
- 进阶:有符号溢出是未定义行为的编译器优化后果;非规格化数与舍入模式 本章不会要求你掌握
- C 语言的隐式类型提升(integer promotion)规则的全部细节
- IEEE 754 的四种舍入模式的具体实现
- Kahan 求和算法的详细推导
学完本章,你应当能做到:
- 手算补码表示,解释为什么
-128能用 8 位表示而+128不行 - 解释 C 语言中有符号整数溢出为什么是未定义行为,以及编译器的优化后果
- 理解 IEEE 754 单精度/双精度的指数偏移、尾数隐含 1
- 用代码演示浮点精度陷阱,并给出实际工作中的规避方案
- 说明四种舍入模式的基本差异
破局 · 溯源
第1战:无符号整数——从 0 到最大值
问题: 一个 8 位无符号整数的范围是多少?为什么答案是 0 ~ 255,而不是 1 ~ 256?
因为 8 位能表示的组合就是 2⁸ = 256 种。从 0 开始,最大就是 255 = 2⁸ - 1。这个公式对所有位宽成立:
无符号范围: 0 到 2ⁿ - 1无符号溢出(wrapping):
#include <stdio.h>
#include <stdint.h>
int main() {
uint8_t x = 255;
printf("x = %u\n" x);
x = x + 1;
printf("x + 1 = %u\n" x); /* 绕回 0 */
x = x - 1; /* 此时 x=0 */
printf("x - 1 = %u\n" x); /* 回绕到 255 */
return 0;
}
/* 输出:
x = 255
x + 1 = 0
x - 1 = 255
*/无符号溢出的行为在 C 标准中是严格定义的——模 2ⁿ 运算,结果是数学上可预测的。这符合 CPU 硬件的实际行为:ALU(算术逻辑单元)做加法时,进位标志会被置位,但值本身就是模运算的结果。
实际场景: TCP 序列号、环形缓冲区指针、计时器计数——这些场景刻意利用无符号回绕来工作。
第2战:有符号整数——补码编码
问题: 我们要表示负数。最直觉的方案是「符号位 + 数值位」,即 10000001 = -1(最高位表示符号,后 7 位是数值)。这个方案有什么问题?为什么计算机不用它?
问题有三:
- 有正零
00000000和负零10000000,两个零浪费了一种编码。 - 加法器需要额外电路处理符号位,不能直接用同一套加法器。
- 负数比较大小需要特殊逻辑。
补码(two's complement)优雅地解决了全部三个问题:
对于 n 位有符号数:
正数: 0 到 2ⁿ⁻¹ - 1
负数: -1 到 -2ⁿ⁻¹
公式:N 位补码的值 = (-1) × xₙ₋₁ × 2ⁿ⁻¹ + Σ xᵢ × 2ⁱ8 位补码示例:
01111111 → 127 (最大值)
00000001 → 1
00000000 → 0
11111111 → -1
10000000 → -128 (最小值)验证 -1 的补码为什么是 11111111:代入公式:(-1)×1×2⁷ + Σ(后面的位都是1) = -128 + 127 = -1。
简洁的结果:补码让你用一个加法器处理加减法。x - y 在硬件上等价于 x + (~y + 1)。CPU 不需要「减法器」。
#include <stdio.h>
int main() {
signed char x = -1; /* 0xFF */
signed char y = -128; /* 0x80,最小值 */
unsigned char z = (unsigned char)x; /* 0xFF 解释为无符号 = 255 */
printf("x = %d (十六进制: %#04x)\n" x (unsigned char)x);
printf("y = %d\n" y);
printf("x 当无符号看 = %u\n" z);
/* 补码的特性:-x = ~x + 1 */
signed char a = 42;
signed char neg_a = ~a + 1;
printf("-42 = %d\n" neg_a); /* 输出 -42 */
return 0;
}
/* 输出:
x = -1 (十六进制: 0xff)
y = -128
x 当无符号看 = 255
-42 = -42
*/不对称的范围: 8 位补码范围是 -128 ~ 127。因为 -128 的补码 10000000 没有对应的正数(+128 需要 9 位才能表示)。这是初学者最易搞混的点。
** Java 差异:** Java 没有无符号类型。
int永远是 32 位有符号。byte是 8 位有符号,范围 -128~127。如果要把0xFF当 255 用,需要int b = abyte & 0xFF。Java 8 引入了Integer.toUnsignedString()等方法,但本质上仍然是「把有符号当无符号看待」。
** Python 差异:** Python 的整数是任意精度的,不存在溢出问题。这对初学者友好,但也意味着 Python 程序员可能从未遇到过整数溢出 bug——而 C 程序员每天都在和它战斗。
系统/安全旁路:有符号溢出是 C 语言特有的未定义行为陷阱。在其他语言(Java/Python)中溢出行为是定义良好的,不会触发此类问题。以下内容对 C 系统程序员和嵌入式开发者尤为重要。
第3战:整数溢出——未定义行为的深渊
问题: 这段代码输出什么?
int i = 2147483647; /* INT_MAX */
i = i + 1;
printf("%d\n" i);答案:不知道。 这是未定义行为(undefined behavior,UB)。编译器可能:
- 让
i变成-2147483648(回绕,貌似合理) - 优化掉整个代码块(因为 UB 路径按定义「不会发生」)
- 在运行时触发 CPU 异常
- 做任何它想做的事
有符号溢出和无符号回绕在 C 标准中有天壤之别。 无符号是定义良好的模运算。有符号是 UB——这意味着编译器可以假设有符号整数永远不会溢出来做激进优化:
int foo(int x) {
/* 有符号溢出是 UB,编译器可以假设 x+1 > x 永远成立 */
if (x + 1 <= x) {
/* 这个分支可能被完全删除 */
handle_overflow();
}
return x + 1;
}编译器会认为 x + 1 <= x 永远为假,并删除 handle_overflow() 的调用——即便实际运行时 x 真的达到了 INT_MAX。
#include <stdio.h>
#include <limits.h>
int main() {
/* 安全的加法:先检测 */
int x = INT_MAX;
if (x > INT_MAX - 100) {
printf("溢出风险,不做加法\n");
} else {
int y = x + 100;
printf("y = %d\n" y);
}
return 0;
}UB 不是「Bug 也能跑」的借口。 它是编译器与你签订的合同:你保证代码符合标准,编译器保证生成正确的机器码。你违反合同,编译器就没有义务帮你的程序「正常工作」。
实际案例: Linux 内核在 2000 年代早期修复了大量整数溢出漏洞。比如某个系统调用中
copy_from_user()的大小参数如果从有符号整数溢出为负数(被解释为极大的无符号数),就会导致内核堆栈缓冲区被攻破。这不是理论问题——CVE 历史中满是此类漏洞。
** Java 差异:** Java 的有符号整数溢出不会 UB——它总是回绕(模 2ⁿ)。
Integer.MAX_VALUE + 1一定等于Integer.MIN_VALUE。Java 8+ 提供了Math.addExact()等会抛出异常的版本。Java 的设计哲学是「行为可预测」,C 的设计哲学是「相信程序员,追求最高性能」,两者的溢出策略就是这种分歧的缩影。
第4战:IEEE 754 浮点——小数点漂移术
问题: 1.0 / 3.0 为什么在计算机里不能精确等于 1/3?因为它必须用有限位二进制表示。
定点数 vs 浮点数的核心区别: 固定小数点位置就限制了表示范围;浮动小数点(科学记数法)动态分配精度范围。
IEEE 754 标准定义了三种核心格式(主要看前两种):
| 格式 | 位数 | 符号位 | 指数位 | 尾数位 | 十进制精度 |
|---|---|---|---|---|---|
单精度(float) | 32 | 1 | 8 | 23 | ~7 位 |
双精度(double) | 64 | 1 | 11 | 52 | ~15-16 位 |
半精度(__fp16) | 16 | 1 | 5 | 10 | ~3 位 |
编码公式: (-1)ˢ × 1.ₘ × 2⁽ᵉ⁻ᵇⁱᵃˢ⁾
其中:
s= 符号位(0 正,1 负)m= 尾数(小数部分,隐含前导1.,所以实际精度是 24/53 位)e= 指数编码值bias= 指数偏移量(单精度 127,双精度 1023)
举例:float f = 5.25 怎么编码?
5.25 的二进制:101.01b
规格化:1.0101 × 2²
符号位 s = 0(正数)
指数 e = 2 + 127 = 129 = 10000001b
尾数 m = 01010000000000000000000(截断到 23 位)
最终 32 位:0 10000001 01010000000000000000000 = 0x40A80000#include <stdio.h>
#include <stdint.h>
int main() {
float f = 5.25f;
uint32_t *p = (uint32_t *)&f;
printf("float %f 的 IEEE 754 编码: %#010x\n" f *p);
/* 逐位打印 */
for (int i = 31; i >= 0; i--) {
putchar((*p >> i) & 1 ? '1' : '0');
if (i == 31 || i == 23) putchar(' ');
}
putchar('\n');
/* 预期输出:
float 5.250000 的 IEEE 754 编码: 0x40a80000
0 10000001 01010000000000000000000
*/
return 0;
}特殊编码值:
| 编码(指数+尾数) | 含义 |
|---|---|
| 全 0 指数 + 全 0 尾数 | ±0(视符号位而定) |
| 全 0 指数 + 非 0 尾数 | 非规格化数(接近 0 的极小数) |
| 全 1 指数 + 全 0 尾数 | ±∞(infinity) |
| 全 1 指数 + 非 0 尾数 | NaN(Not a Number) |
** Python 差异:** Python 的
float就是 C 的double(64 位双精度)。除非引入decimal.Decimal或fractions.Fraction,否则它在底层和 C 没有任何区别。Python 提供math.isnan()、math.isinf()来安全检测特殊值。C 则用<math.h>的isnan()宏(C99 引入)或fpclassify()。
第5战:浮点精度陷阱——0.1 + 0.2 为什么不等于 0.3
问题: 打开你的 Python/Java/C 控制台,算 0.1 + 0.2。
>>> 0.1 + 0.2
0.30000000000000004这不是 bug,是必然结果。因为 0.1 在二进制里是无限循环小数:
0.1 的二进制:0.0001100110011001100110011...(0011 无限循环)
任何有限位的浮点数都无法精确表示 0.1。#include <stdio.h>
int main() {
float a = 0.1f;
float b = 0.2f;
float c = a + b;
printf("0.1 + 0.2 = %.15f\n" c); /* 0.100000001490116... */
printf("精确对比: %d\n" c == 0.3f); /* 0(不相等!)*/
double d = 0.1;
double e = 0.2;
printf("double: 0.1 + 0.2 = %.17f\n" d + e); /* 0.30000000000000004 */
/* 正确的比较方式:容忍误差 */
double diff = (d + e) - 0.3;
printf("差值: %e\n" diff);
if (diff < 1e-10 && diff > -1e-10)
printf("≈ 相等(误差容限内)\n");
return 0;
}致命陷阱:浮点比较永远不要用 ==。 正确的做法是比较差的绝对值是否在某个 epsilon 范围内。epsilon 选多少取决于具体场景——物理引擎用 1e-6,金融计算需要十进制定点数而非浮点数。
#include <stdio.h>
#include <math.h>
#define EPSILON 1e-9f
int feq(float a float b) {
return fabsf(a - b) < EPSILON;
}
int main() {
float a = 0.1f * 10;
float b = 1.0f;
printf("0.1*10 == 1.0 ? %s\n" feq(a b) ? "是" : "否");
/* 输出: 是(epsilon 内) */
return 0;
}易错场景清单:
- 累加误差放大: 一亿次
0.0000001的累加误差可能高达数倍。Kahan 求和算法可以缓解。 - 大数吃小数:
1e20 + 1.0f - 1e20在单精度下等于 0,而不是 1.0。因为 1.0 的精度落在1e20的尾数舍入范围之外。 - 非交换性:
(a + b) + c ≠ a + (b + c),浮点加法不满足结合律。 - 跨平台差异: x87 FPU 用 80 位内部精度,SSE/AVX 用 32/64 位,相同的代码在不同优化级别下可能产生不同的结果。
常见陷阱
实现一个简单的浮点数查看器——输入一个 float 或 double,输出它的 IEEE 754 编码分解。
#include <stdio.h>
#include <stdint.h>
void print_float_bits(float f) {
uint32_t bits;
/* 用 memcpy 而非指针转换,避免 aliasing 问题 */
__builtin_memcpy(&bits &f sizeof(bits));
uint32_t sign = (bits >> 31) & 1;
uint32_t exponent = (bits >> 23) & 0xFF;
uint32_t mantissa = bits & 0x7FFFFF;
printf("%+.10f → " f);
printf("S=%u E=%u (偏置后=%d) M=0x%06X (%s)\n"
sign exponent (int)exponent - 127 mantissa
exponent == 0xFF ? (mantissa == 0 ? "∞" : "NaN") :
exponent == 0 ? "非规格化" : "规格化");
}
int main() {
print_float_bits(1.0f);
print_float_bits(0.0f);
print_float_bits(-5.25f);
print_float_bits(0.1f);
print_float_bits(1.0f / 0.0f); /* 正无穷 */
return 0;
}
/* 预期输出:
+1.0000000000 → S=0 E=127 (偏置后=0) M=0x000000 (规格化)
+0.0000000000 → S=0 E=0 (偏置后=-127) M=0x000000 (非规格化/零)
-5.2500000000 → S=1 E=129 (偏置后=2) M=0x280000 (规格化)
+0.1000000015 → S=0 E=123 (偏置后=-4) M=0x4CCCCD (规格化)
+inf → S=0 E=255 (偏置后=128) M=0x000000 (∞)
*/通关挑战
问题 1(补码): 给定以下 8 位二进制序列,解释为有符号补码和无符号整数各是多少:10000000、11111111、01111111、11001010。
问题 2(溢出检测): 写一个 C 函数 safe_add(int a int b int *result),在溢出时返回 -1,否则返回 0 并将和写入 *result。
问题 3(浮点编码): 写出 float f = -6.75f 的 IEEE 754 单精度编码(步骤:转二进制 → 规格化 → 偏置指数 → 拼合 32 位)。验证十六进制结果。
问题 4(精度陷阱): 以下循环运行后 sum 的值是多少?解释原因。
float sum = 0.0f;
for (int i = 0; i < 10000000; i++) sum += 0.1f;验收标准
| 技能 | 自检方法 |
|---|---|
| 补码理解 | 能在纸上写出 -128~127 的 8 位补码,并能解释不对称性 |
| 溢出安全 | 能区分有符号(UB)和无符号(模运算)的溢出行为差异 |
| IEEE 754 | 能徒手编码/解码单精度浮点数(正负、±∞、NaN 的二进制模式) |
| 精度意识 | 能解释 0.1+0.2≠0.3 的原因,并写出安全的浮点比较函数 |
常见卡点
- 把有符号和无符号溢出等同对待。 C 中有符号溢出是 UB,编译器可能生成你完全意料不到的代码。不要假设它会回绕。
- 浮点用
==比较。 永远不要。用 epsilon 比较,或者考虑用十进制定点数。 - 隐含类型转换的陷阱。
-1 < 0u在 C 中为假,因为-1被隐式转为unsigned int变成4294967295。-1 < 0u实际上是4294967295u < 0u,结果为假。 - 混淆浮点精度和范围。
float的范围可以到 ±10³⁸,但精度只有 7 位十进制有效数字。一个大数加一个小数,小数可能被完全忽略。
现在不需要理解
- 非规格化数的详细下溢行为(了解存在即可,在数值分析中才深入)
- 浮点异常标志和 trap handler(
fegetexceptflag、fesetenv——C99 的浮点环境控制) - decimal128 / binary128(四精度) 和未来标准
- Kahan 求和算法的分支优化(知道它是为了缓解累加误差,具体实现查时再补)
- FPU 控制字:舍入模式的 x87 设置汇编指令
旅人笔记
- 补码是「一个加法器搞定加减法」的优雅方案,代价是负数的范围比正数多一个。这不是 bug,是二进制编码的必然结果。
- 有符号溢出是 UB,无符号溢出是模运算。C 标准做出了这个区分,意味着编译器可以(也确实会)利用这个假设做优化。如果你想要可靠的回绕行为,用无符号类型。
- 浮点的敌人不是精度损失本身——所有有限表示法都有精度损失——而是你以为没损失。IEEE 754 的设计是深思熟虑的,但它在数值计算中的陷阱需要专门的学习才能避开。
0.1 + 0.2不等于0.3不是计算机的 bug,而是数学事实:分母含质因数非 2 的分数在二进制中必然无限循环。理解了这一点,你才算真正懂了浮点数。- 强制类型转换是 C 的核武器。
(unsigned int)(-1)不是「取模」,而是「重新解释比特模式」。理解这件事,就能理解为什么 C 的隐式类型转换可以制造如此多的安全漏洞。
→ 下一站预告
数字在内存里存好了,但 CPU 怎么把它们取出来做运算?指令怎么在寄存器、内存、ALU 之间流转?数据怎么从内存搬上 CPU?下一章我们从数字表征转向「CPU 数据通路」——指令执行、流水线、寄存器文件、内存层次的第一层:SRAM 缓存。