Skip to content

第2章 整数与浮点——数字的奥秘


元数据卡

项目内容
难度(进阶)
前置第1章(比特、字节、进制)
关键词补码、无符号溢出、有符号溢出、IEEE 754、浮点精度、舍入模式
C 标准C99+
代码量~80 行

你的进度

"你继续往下走。地心里铺满了数字——整数和浮点数。但这里的数字不是你在变量村学的那些——它们在底层的表示方式藏着无数陷阱。"

上一章我们学会了数据怎么编码——比特拼成字节,字节拼成字。但我们遇到一个棘手的问题:内存里的 0xFFFFFFFF 到底是什么?是 4294967295(无符号 32 位最大值),还是 -1(32 位补码)?

答案早就在你心中了——是上下文。同一个比特模式,不同的解释方式,得到完全不同的数值。 这是计算机数字系统的第一块基石,也是无数 bug 的第一道裂缝。

这一章我们把整数和浮点的编码掀个底朝天,重点看两件事:

  1. 为什么整数会溢出,而且有符号溢出是未定义行为(不是你想的那种「绕一圈回来」)。
  2. 为什么 0.1 + 0.2 不等于 0.3,以及百万美元级 bug 是怎么从这么简单的问题里长出来的。

你的任务

本章分层

  • 必读:无符号整数的范围与回绕行为;补码编码的原理;浮点精度的根本原因(0.1+0.2≠0.3)
  • 选读:IEEE 754 浮点编码的 S/E/M 结构;整数溢出检测的安全编程实践
  • 进阶:有符号溢出是未定义行为的编译器优化后果;非规格化数与舍入模式 本章不会要求你掌握
  • C 语言的隐式类型提升(integer promotion)规则的全部细节
  • IEEE 754 的四种舍入模式的具体实现
  • Kahan 求和算法的详细推导

学完本章,你应当能做到:

  1. 手算补码表示,解释为什么 -128 能用 8 位表示而 +128 不行
  2. 解释 C 语言中有符号整数溢出为什么是未定义行为,以及编译器的优化后果
  3. 理解 IEEE 754 单精度/双精度的指数偏移、尾数隐含 1
  4. 用代码演示浮点精度陷阱,并给出实际工作中的规避方案
  5. 说明四种舍入模式的基本差异

破局 · 溯源

第1战:无符号整数——从 0 到最大值

问题: 一个 8 位无符号整数的范围是多少?为什么答案是 0 ~ 255,而不是 1 ~ 256

因为 8 位能表示的组合就是 2⁸ = 256 种。从 0 开始,最大就是 255 = 2⁸ - 1。这个公式对所有位宽成立:

无符号范围: 0 到 2ⁿ - 1

无符号溢出(wrapping):

c
#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 位是数值)。这个方案有什么问题?为什么计算机不用它?

问题有三:

  1. 有正零 00000000 和负零 10000000,两个零浪费了一种编码。
  2. 加法器需要额外电路处理符号位,不能直接用同一套加法器。
  3. 负数比较大小需要特殊逻辑。

补码(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 不需要「减法器」。

c
#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战:整数溢出——未定义行为的深渊

问题: 这段代码输出什么?

c
int i = 2147483647; /* INT_MAX */
i = i + 1;
printf("%d\n" i);

答案:不知道。 这是未定义行为(undefined behavior,UB)。编译器可能:

  • i 变成 -2147483648(回绕,貌似合理)
  • 优化掉整个代码块(因为 UB 路径按定义「不会发生」)
  • 在运行时触发 CPU 异常
  • 做任何它想做的事

有符号溢出和无符号回绕在 C 标准中有天壤之别。 无符号是定义良好的模运算。有符号是 UB——这意味着编译器可以假设有符号整数永远不会溢出来做激进优化:

c
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

c
#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 标准定义了三种核心格式(主要看前两种):

格式位数符号位指数位尾数位十进制精度
单精度(float321823~7 位
双精度(double6411152~15-16 位
半精度(__fp16161510~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
c
#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.Decimalfractions.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

python
>>> 0.1 + 0.2
0.30000000000000004

这不是 bug,是必然结果。因为 0.1 在二进制里是无限循环小数

0.1 的二进制:0.0001100110011001100110011...(0011 无限循环)

任何有限位的浮点数都无法精确表示 0.1。
c
#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,金融计算需要十进制定点数而非浮点数。

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

易错场景清单:

  1. 累加误差放大: 一亿次 0.0000001 的累加误差可能高达数倍。Kahan 求和算法可以缓解。
  2. 大数吃小数: 1e20 + 1.0f - 1e20 在单精度下等于 0,而不是 1.0。因为 1.0 的精度落在 1e20 的尾数舍入范围之外。
  3. 非交换性: (a + b) + c ≠ a + (b + c),浮点加法不满足结合律。
  4. 跨平台差异: x87 FPU 用 80 位内部精度,SSE/AVX 用 32/64 位,相同的代码在不同优化级别下可能产生不同的结果。

常见陷阱

实现一个简单的浮点数查看器——输入一个 floatdouble,输出它的 IEEE 754 编码分解。

c
#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 位二进制序列,解释为有符号补码和无符号整数各是多少:10000000111111110111111111001010

问题 2(溢出检测): 写一个 C 函数 safe_add(int a int b int *result),在溢出时返回 -1,否则返回 0 并将和写入 *result

问题 3(浮点编码): 写出 float f = -6.75f 的 IEEE 754 单精度编码(步骤:转二进制 → 规格化 → 偏置指数 → 拼合 32 位)。验证十六进制结果。

问题 4(精度陷阱): 以下循环运行后 sum 的值是多少?解释原因。

c
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 的原因,并写出安全的浮点比较函数

常见卡点

  1. 把有符号和无符号溢出等同对待。 C 中有符号溢出是 UB,编译器可能生成你完全意料不到的代码。不要假设它会回绕。
  2. 浮点用 == 比较。 永远不要。用 epsilon 比较,或者考虑用十进制定点数。
  3. 隐含类型转换的陷阱。 -1 < 0u 在 C 中为假,因为 -1 被隐式转为 unsigned int 变成 4294967295-1 < 0u 实际上是 4294967295u < 0u,结果为假。
  4. 混淆浮点精度和范围。 float 的范围可以到 ±10³⁸,但精度只有 7 位十进制有效数字。一个大数加一个小数,小数可能被完全忽略。

现在不需要理解

  • 非规格化数的详细下溢行为(了解存在即可,在数值分析中才深入)
  • 浮点异常标志和 trap handlerfegetexceptflagfesetenv——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 缓存。

Built with VitePress | Software Systems Atlas