第8章:整数与浮点数的存储:原码/反码/补码与 IEEE 754

从"数字在内存里究竟长什么样"出发:为什么负数要用补码存、浮点数为什么总有精度问题——把两种最常用数值的编码规则一路拆到比特级

🔬

本章导师:费曼

核心方法论:第一性原理

「内存里没有'数字',只有一串串比特。所谓 -1、所谓 0.1,都只是我们约定好的一种'解读方式'。这一章我们就从第一性原理出发:为什么全世界的 CPU 都选了补码来存整数?为什么 0.1 + 0.2 不等于 0.3?把这两件事在比特层面亲手算一遍,你就再也不会被它们咬到。」

8.1 整数表示的三兄弟:原码 / 反码 / 补码

第 7 章我们看了字节在内存里怎么排(大小端),但还有个更根本的问题没回答:一个 int 变量里的 32 个比特,究竟按什么规则被"解读"成一个数字?说人话:同一串比特,解读规则不同,读出来的数就完全不同。整数存储分两条路线:无符号(unsigned,只能表示非负数,直接存二进制)和有符号(signed,正、零、负都要能表示)。有符号这条路,历史上出现过三种编码方案——原码反码补码,我们叫它"三兄弟"。先给定义,全部用 8 位二进制(一个字节)举例:

原码(符号加绝对值):最直觉的方案。最高位当符号位(sign bit,用 1 个比特标记正负:0 非负、1 为负),其余位存绝对值。反码:正数的反码就是原码;负数的反码是符号位保持不变,其余位全部取反(1 变 0、0 变 1)。补码:正数的补码就是原码;负数的补码是反码再加 1。以 -1 为例,三兄弟长得完全不同:

# 8 位二进制下 -1 的三种表示(最高位是符号位)
原码:10000001   符号位 1(负)+ 绝对值 0000001
反码:11111110   符号位不动,数值位取反:0000001 → 1111110
补码:11111111   反码 + 1:11111110 + 00000001
@注意 -1 的补码是 8 个 1——"最负的数"在补码里反而长得最"满"

纸上谈兵不算数,让计算机把位模式打印出来。用 int8_t(一个字节的有符号整数)存 -1 和 -2,逐位打印内存里的真实比特:

// bits.cpp —— 打印 8 位有符号整数的真实位模式
#include <cstdio>
#include <cstdint>

// 打印一个字节的 8 位二进制位模式
void print_bits(unsigned char x) {
    for (int i = 7; i >= 0; --i)
        printf("%d", (x >> i) & 1);
}

int main() {
    int8_t a = -1, b = -2;

    printf("-1 的补码(8 位): ");
    print_bits((unsigned char)a);          // 11111111
    printf("\n-2 的补码(8 位): ");
    print_bits((unsigned char)b);          // 11111110
    printf("\n");

    // 8 位加法:0xFF + 0x01 = 0x100,第 9 位溢出丢弃,剩 0x00
    unsigned char r = (unsigned char)a + 1;
    printf("-1 + 1 = %d,结果位模式: ", (int)(int8_t)r);
    print_bits(r);                          // 00000000
    printf("\n");

    // 32 位视角:int 的 -1 是 32 个 1
    printf("int  -1 的补码 = 0x%08X\n", (uint32_t)-1);
    printf("int  +1 的补码 = 0x%08X\n", (uint32_t)1);
    printf("-1 + 1 = %d\n", -1 + 1);
    return 0;
}
# 真实输出(g++ 15.2.0 / macOS arm64 实测):
-1 的补码(8 位): 11111111
-2 的补码(8 位): 11111110
-1 + 1 = 0,结果位模式: 00000000
int  -1 的补码 = 0xFFFFFFFF
int  +1 的补码 = 0x00000001
-1 + 1 = 0
编码方案8 位 -1 的表示最高位含义说人话
原码10000001符号位(1 = 负)符号 + 绝对值,最直觉但最笨
反码11111110符号位(负数其余位取反)按位取反,是"补码的中间步骤"
补码11111111符号位(负数 = 反码 + 1)唯一活到今天的方案
费曼提示

名字里有线索:"反"码 = 每一位反过来"补"码 = 反码再"补"上 1。补码还有个隐藏优点:0 只有一种表示。原码里 +0 是 00000000、-0 是 10000000,两个零并存;补码里 00000000 就是唯一的零。

注意:今天所有主流 CPU 和编译器都用补码存有符号整数,原码和反码只活在教科书里(C++20 之前的标准甚至允许其他编码,但实践中没有任何主流平台这么做)。所以后面全部以补码为准——你只需要认识三兄弟,剩下的时间都花在补码身上。

8.2 补码为什么胜出:-1 + 1 = 0

为什么全世界都选了补码?第一性原理地问:CPU 的加法器是核心硬件,一切算术最好都能复用加法器——减法最好也能用加法实现。用原码做 -1 + 110000001 + 00000001 = 10000010,按原码解读是 -2!完全错了,因为符号位"不参与运算",还得额外写一套"同号相加、异号相减、再比大小"的规则。反码稍好一点:11111110 + 00000001 = 11111111,解读成 -0,勉强对但不漂亮。而补码呢?直接加:

# 8 位补码加法:-1 + 1 = 0(真实位运算)
    11111111   -1 的补码
  + 00000001   +1 的补码(正数补码 = 原码)
  ───────────
 100000000   第 9 位是进位,寄存器只有 8 位宽,直接丢弃
   00000000   结果 0,完全正确——减法被加法免费收编
@关键 溢出丢弃的是"进位"不是"错误":补码 = 加法 + 自动取模

背后的数学叫模运算(modular arithmetic,说人话:数到上限自动回到 0 重新数)。8 位加法本质是"模 256 加法":结果对 256 取余数。而 -1 ≡ 255 (mod 256)(同余,两数除以 256 余数相同),所以 -1 的补码就是 255 的二进制 11111111-1 + 1 等于 255 + 1 = 256 ≡ 0 (mod 256),进位那位 1 恰好是 256,被模掉——这就是"溢出丢弃低位"的真相。更妙的是符号位也"自然参与运算",没有特殊待遇,进位照样往它身上加。三兄弟同做一道题的对比:

编码方案-1 + 1 的位运算按自身规则解读结果
原码10000001 + 00000001 = 10000010-2❌ 错
反码11111110 + 00000001 = 11111111-0(还有 -0 这个幽灵)⚠️ 勉强
补码11111111 + 00000001 = 000000000✅ 完全正确

眼见为实。溢出到底长什么样?把 8 位有符号数推到顶(127)再走一步,以及把无符号数推到顶(255)再走一步——下面这段程序把两个现场都拍了下来:

// overflow.cpp —— 溢出回绕现场(真实编译运行验证)
#include <cstdio>
#include <cstdint>

int main() {
    int8_t x = 127;               // 8 位有符号能表示的最大正数
    int8_t y = x + 1;            // 01111111 + 1 = 10000000
    printf("127 + 1 = %d\n", (int)y);    // 实测输出 -128
    unsigned char z = 255;
    unsigned char w = z + 1;     // 11111111 + 1 = 00000000
    printf("255 + 1 = %d\n", (int)w);     // 实测输出 0
    return 0;
}
# 真实输出(g++ 15.2.0 / macOS arm64 实测):
127 + 1 = -128
255 + 1 = 0
费曼提示

把 8 位有符号数想象成一个"循环刻度盘":0 → 1 → … → 127 → -128 → -127 → … → -1 → 0。从 127 往前一步不是 128(放不下),而是绕回 -128——这就是回绕(wraparound,数值绕回另一头继续转)。配上"补码 = 模 256 加法"这个模型,一切整数溢出都变得可预测。第 16 章讲安全时你会看到,INT_MAX + 1 这种回绕正是无数漏洞的源头。

注意:这里必须拎清边界——无符号数的回绕是 C++ 标准明确保证的行为unsigned char255 + 1 一定是 0);而有符号数的溢出是未定义行为(UB,undefined behavior,标准不承诺任何结果)。我们实测 127 + 1 在 x86/ARM 上回绕成 -128,但这只是"这台机器恰好这么干",编译器完全有权假设"有符号数永远不会溢出"并据此优化。规矩:unsigned 溢出可依赖,signed 溢出是 bug

8.3 有符号与无符号的坑:unsigned 陷阱

补码很好用,但"有符号"和"无符号"两套解读规则并存,就埋下了雷:当有符号和无符号混在同一个表达式里,C++ 会做隐式转换(说人话:编译器偷偷把一方转成另一方)。为什么总是有符号被转?因为规则是"保精度优先":当 intunsigned int 位数相同,谁都无法无损装下对方,于是约定俗成——int 被转成 unsigned int。于是恐怖片开场了:

int a = -1;
unsigned int b = 0;
if (a > b) {  // 你以为在比较 -1 和 0?
    // 编译器先把 a 转成 unsigned:-1 → 4294967295(2^32 - 1)
    // 于是 4294967295 > 0 成立 —— -1 > 0 为真!
}

-1 > 0 在 C++ 里竟然为真,这个反直觉的事实每年坑掉无数人。真正的重灾区是 size_tint 混用:std::vector::size() 返回 size_t(无符号,64 位平台最大值 18446744073709551615)。经典连环坑:① v.size() - 1 在空 vector 上不是 -1,而是下溢成天文数字;② for (size_t i = v.size() - 1; i >= 0; --i) 倒序遍历,i 减到 0 再减 1 就回绕成巨大值,i >= 0 永远成立——死循环。全部实测:

// trap.cpp —— 三个 unsigned 陷阱现场
#include <cstdio>
#include <vector>

int main() {
    // 陷阱 1:-1 > 0 为真
    int a = -1;
    unsigned int b = 0;
    printf("a > b ? %s   (a = %d, b = %u)\n", (a > b) ? "true" : "false", a, b);
    printf("把 a 当作 unsigned 看:(unsigned)a = %u\n", (unsigned)a);

    // 陷阱 2:空 vector 时 v.size() - 1 无符号下溢
    std::vector<int> v;
    size_t n = v.size() - 1;
    printf("空 vector: v.size() = %zu, v.size() - 1 = %zu\n", v.size(), n);

    // 对比:int 循环正常结束
    std::vector<int> w = {10, 20, 30};
    for (int i = (int)w.size() - 1; i >= 0; --i)
        printf("  int  循环: w[%d] = %d\n", i, w[i]);

    // 陷阱 3:size_t 循环 i >= 0 永不退出(加保护截断)
    size_t guard = 0;
    for (size_t i = w.size() - 1; i >= 0; --i) {
        printf("  size_t 循环: i = %zu\n", i);
        if (++guard >= 5) { printf("  (0 减 1 回绕成巨大值,死循环,人为截断)\n"); break; }
    }
    return 0;
}
# 真实输出(g++ 15.2.0 / macOS arm64 实测):
a > b ? true   (a = -1, b = 0)
把 a 当作 unsigned 看:(unsigned)a = 4294967295
空 vector: v.size() = 0, v.size() - 1 = 18446744073709551615
  int  循环: w[2] = 30
  int  循环: w[1] = 20
  int  循环: w[0] = 10
  size_t 循环: i = 2
  size_t 循环: i = 1
  size_t 循环: i = 0
  size_t 循环: i = 18446744073709551615
  size_t 循环: i = 18446744073709551614
  (0 减 1 回绕成巨大值,死循环,人为截断)
陷阱场景实际现象正确姿势
-1 > 0u-1 被隐式转成 unsigned,变 4294967295,比较为真两侧统一类型:a > (int)b
空容器 v.size() - 1下溢成 18446744073709551615v.empty() 判空再算
for (size_t i = size - 1; i >= 0; --i)0 减 1 回绕,死循环for (size_t i = size; i-- > 0;)
v.size() > -1恒为真(-1 先转成巨大 unsigned)两侧都写 unsigned:v.size() > 0

注意:这类 bug 编译器往往一声不吭。开 -Wall -Wextra 对部分场景会给 -Wsign-compare 警告,但 v.size() - 1 这种写法连警告都没有——它"合法"地下溢了。别指望编译器当保姆,规矩是:写比较和减法前,先问"两边是不是都是无符号?"

费曼提示

防御姿势三件套:① 显式转换——混用点写清楚 (int)v.size()static_cast,把意图交给编译器检查;② 倒序循环用 i-- > 0——先判断后自减,i 到 0 时条件为假立即退出,永远不回绕;③ 别写 x - 1 这种裸减法——它默认了"x 至少是 1"。第 9 章数组下标会再次用到这些姿势。

8.4 浮点数的 IEEE 754:符号位 / 指数 / 尾数

整数讲完了,轮到小数。0.5 和 1/3 用整数怎么表示?没法表示——所以需要一种能"移动小数点"的编码,这就是浮点数(floating-point,说人话:小数点可以在二进制里浮动,靠"指数"指定它落在哪)。全世界的浮点数遵循同一个标准:IEEE 754(电气电子工程师学会制定的浮点数存储标准,1985 年定稿),今天每台电脑的 float 和 double 都按它存。它的本质是二进制的科学计数法——像十进制的 1.5 × 10³,二进制版写成:

# IEEE 754 单精度(float,32 位)解剖图
┌────┬──────────────┬──────────────────────┐
│ 符号 │    指数 E      │      尾数 M           │
│ S   │    8 位       │      23 位            │
└────┴──────────────┴──────────────────────┘
 1 位    偏移 127            隐含 1. 开头

数值 = (-1)^S × 1.M × 2^(E - 127)

@说人话 S 管正负(0 正 1 负,和补码符号位同理);
E 存"真实指数 + 127"(指数偏移);
M 只存小数点后面的部分,开头的 1 是隐含的

符号位(sign,1 比特)负责正负,和补码的符号位一个思路。指数(exponent,8 比特)负责"数量级",存的是"真实指数 + 127"——这个 127 叫指数偏移量(bias,让指数无需符号位也能表示负数:真实指数 -127 到 +128,存成 0 到 255)。尾数(mantissa,23 比特)负责"有效数字",且开头的 1 是隐含的:任何正规浮点数都能写成 1.xxx 形式,那个 1 永远在,干脆不占位——白赚 1 位精度。

拿 1.5 练手:二进制科学计数法 1.5 = 1.1₂ × 2⁰,所以 S = 0(正数)、指数字段 = 0 + 127 = 127、尾数存隐含 1 后面的部分(.1₂ 就是最高位 1、其余 22 位 0)。拼起来 0 | 01111111 | 10000000000000000000000 = 0x3FC00000,与下方实测一致。看真实位模式最直接的方式是联合体(union,让多个变量共用同一块内存)——把 float 的 32 位内存原样当成无符号整数读出来,再按位拆解:

// ieee.cpp —— 用 union 拆解 float 的位模式
#include <cstdio>
#include <cstdint>

int main() {
    union {
        float f;
        uint32_t u;      // 和 float 共用同一块 32 位内存
    } x;

    x.f = 1.5f;
    printf("1.5f  = 0x%08X\n", x.u);
    printf("  符号位 = %u, 指数 = %u, 尾数 = 0x%X\n",
           (x.u >> 31) & 1, (x.u >> 23) & 0xFF, x.u & 0x7FFFFF);

    x.f = 0.1f;
    printf("0.1f  = 0x%08X\n", x.u);
    printf("  符号位 = %u, 指数 = %u, 尾数 = 0x%X\n",
           (x.u >> 31) & 1, (x.u >> 23) & 0xFF, x.u & 0x7FFFFF);

    x.f = -2.0f;
    printf("-2.0f = 0x%08X\n", x.u);
    printf("  符号位 = %u, 指数 = %u, 尾数 = 0x%X\n",
           (x.u >> 31) & 1, (x.u >> 23) & 0xFF, x.u & 0x7FFFFF);
    return 0;
}
# 真实输出(g++ 15.2.0 / macOS arm64 实测):
1.5f  = 0x3FC00000
  符号位 = 0, 指数 = 127, 尾数 = 0x400000
0.1f  = 0x3DCCCCCD
  符号位 = 0, 指数 = 123, 尾数 = 0x4CCCCD
-2.0f = 0xC0000000
  符号位 = 1, 指数 = 128, 尾数 = 0x0

对照验证:1.5f 指数字段 127 → 真实指数 0,尾数 0x4000001000...0(隐含 1. 后跟一个 1),合起来 1.1₂ × 2⁰ = 1.5 ✅。-2.0f 符号 1、指数 128 → 真实指数 1、尾数全 0,即 -1.0₂ × 2¹ = -2.0 ✅。注意 0.1f 尾数 0x4CCCCD...CD 结尾——这个 D 是舍入上去的:0.1 的二进制是 0.00011001100110011001100...,无限循环,23 位尾数放不下,只能四舍五入截断。这一笔"舍入的账",就是 8.5 节所有精度问题的总根源。

类型总位数符号位指数(含偏移)尾数(含隐含 1)有效精度(十进制位)
float(单精度)32 位1 位8 位(偏移 127)23 位约 7 位
double(双精度)64 位1 位11 位(偏移 1023)52 位约 15~16 位
费曼提示

两个设计巧思值得记一辈子:指数偏移让"指数正负"不需要单独的符号位,比大小直接比位模式就行;隐含的 1 让 23 位尾数实际表达了 24 位精度。再记几个"户口":指数全 0 是次正规数(subnormal,0 附近更密的刻度)、指数全 1 尾数全 0 是无穷(inf,除以 0 的结果)、指数全 1 尾数非 0 是 NaN(Not a Number,0/0 的结果)——先混个脸熟,第 16 章再见面。

注意:浮点数是有"刻度"的,不是连续直线上的任意点。float 在 1 附近刻度间隔约 1.19e-7(2⁻²³),到 16777216 附近刻度间隔已经是 1——超过 2²⁴ 后连整数都不能精确表示。用 float 存时间戳、存 ID、存金额,都会在某个量级悄悄丢精度。double 好得多,但同样有天花板。记住:浮点数的"精确"永远是有范围、有条件的

8.5 浮点精度之谜:0.1 + 0.2 != 0.3

现在谜底呼之欲出。先看"犯罪现场"——每个程序员迟早都会撞见的一行:0.1 + 0.2 == 0.3 在 C++ 里是 false。用 printf 把两位小数展开到 20 位,真相立刻现形:

// epsilon.cpp —— 0.1 + 0.2 的现场还原
#include <cstdio>
#include <cmath>

int main() {
    double a = 0.1;
    double b = 0.2;
    double c = a + b;

    printf("0.1 + 0.2 = %.20f\n", c);
    printf("0.3       = %.20f\n", 0.3);
    printf("c == 0.3 ? %s\n", (c == 0.3) ? "true" : "false");

    // 正确姿势:用 epsilon 容差比较
    const double eps = 1e-9;
    if (fabs(c - 0.3) < eps)
        printf("|c - 0.3| = %.2e < eps,视为相等\n", fabs(c - 0.3));
    return 0;
}
# 真实输出(g++ 15.2.0 / macOS arm64 实测):
0.1 + 0.2 = 0.30000000000000004441
0.3       = 0.29999999999999998890
c == 0.3 ? false
|c - 0.3| = 5.55e-17 < eps,视为相等

为什么?把 8.4 节的原理串起来:0.1 的二进制是无限循环小数,double 存不下,只能存"最接近它的那个 64 位近似值";0.2 同理。两个近似值相加,结果再舍入一次,落点恰好比 0.3 的近似值高了一点点——差 5.55e-17。这不是 bug,是 IEEE 754 的必然代价:用有限 64 位表示无限小数,误差是设计的一部分。精确到位的账本(double 实际存的值,十六进制位模式已用程序核实):

0.1     = 0.1000000000000000055511151231257827021181583404541015625
0.2     = 0.200000000000000011102230246251565404236316680908203125
0.3     = 0.299999999999999988897769753748434595763683319091796875
0.1+0.2 = 0.3000000000000000444089209850062616169452667236328125
                                       ↑ 差 1/18014398509481984 ≈ 5.55e-17
@教训 0.1+0.2 的结果比 0.3 大了一小格——两个近似值凑巧没对上
直觉上的等式double 实际结果为什么
0.1 + 0.2 == 0.3false(差 5.55e-17)三个数都是近似值,舍入误差没抵消
0.1 + 0.7 == 0.8false(类似偏差)0.7 同样不是精确二进制小数
1.0 / 10 * 10 == 1.0有时 false中间结果先舍入,再乘回去对不上
1.5 + 0.25 == 1.75true ✅这三个数都是精确的二进制小数(0.5、0.25 是 2 的负幂)

那浮点比较的正确姿势是什么?别用 ==,用"差值的绝对值小于一个容差"。这个容差就是 epsilon(希腊字母 ε,这里指允许的误差上限):fabs(a - b) < eps。更讲究的写法是按数量级缩放(相对误差):fabs(a - b) <= eps * max(fabs(a), fabs(b))——因为 1 附近的误差和 10⁹ 附近的误差不是一回事,容差应该跟着数的大小走。工程常见选择:double 用 1e-9(机器精度 2⁻⁵² ≈ 2.2e-16,留足余量),float 用 1e-5 左右。

费曼提示

把原理压缩成一句话:凡是 2 的负幂能凑出来的小数(0.5、0.25、0.125…)都精确,其余十进制小数(0.1、0.2、0.3…)在二进制里都是无限循环,存进有限位必然有舍入误差。所以判断"该不该担心精度"只需问:这个数能不能写成 k/2ⁿ?能,放心;不能,小心。这个心智模型比背任何结论都管用。

注意钱(货币金额)绝对不能用 float/double 算。一分钱在二进制里同样不精确,金额累加千次万次,误差会滚雪球成真金白银的差额。正确做法:用整数存"分"(long long 存最小货币单位),或用专门的十进制定点库。另外记住 NaN != NaN——NaN 和自己都不相等,比较前先 std::isnan 检查,否则 == 永远 false。

8.6 本章小结

把这一章压成一条主线:内存里只有比特,没有数字;"数"是我们和 CPU 约定的解读方式。整数这条路,人类试过原码、反码、补码三种编码,最终补码胜出——减法变加法、符号位自然参与运算、消灭 -0,代价只是"溢出丢弃低位"一条规则。浮点数这条路,全世界统一到 IEEE 754:符号位 + 偏移指数 + 隐含 1 的尾数,用有限位数换超大的表示范围,代价是十进制小数大多存不精确——0.1 + 0.2 != 0.3 不是玄学,是舍入误差的必然。

费曼式收束:如果你能用三句话给大一新生讲清这两件事,本章就毕业了。① 负数为什么要用补码?——因为补码让 -1 + 1 在比特层面自动等于 0,减法免费;② unsigned 为什么危险?——因为有符号数会被悄悄转成无符号,-1 > 0 都能成立;③ 浮点为什么有精度问题?——因为 0.1 的二进制是无限循环小数,64 位装不下,只能存近似值。全部结论速查:

# 本章一分钟速查
int   -1 的补码  = 0xFFFFFFFF          32 位下 32 个 1
unsigned 回绕  = 标准保证(255+10可依赖
signed   溢出  = 未定义行为(实测回绕)    别依赖
float  1.5  = 0x3FC00000            指数偏移 127,尾数 0x400000
float  0.1  = 0x3DCCCCCD            无限循环小数,舍入截断
double 0.1  = 0x3FB999999999999A     64 位版,偏移 1023
double epsilon = 2.220446049250313e-16  机器精度,比较用容差
# 自查命令:-Wall -Wextra 编译本章示例,观察警告
g++ -std=c++17 -Wall -Wextra trap.cpp -o trap
# 混用 int 与 unsigned 的比较处会出现 -Wsign-compare 警告;
# 而 v.size() - 1 这种下溢写法编译器一声不吭——正是要你自己留神的点
主题一句话结论对应小节
整数编码补码胜出:减法变加法、符号位自然运算、零唯一8.1 / 8.2
溢出unsigned 回绕是标准行为,signed 溢出是 UB8.2
混用陷阱有符号被隐式转成无符号,-1 > 0 为真8.3
浮点编码IEEE 754:符号 + 偏移指数 + 隐含 1 的尾数8.4
浮点精度0.1 二进制无限循环,比较用 epsilon 容差,钱用整数8.5
费曼提示

下一章(第 9 章)我们带着"数字的位模式"这个装备,去看数组与指针:地址怎么算、下标为什么从 0 开始、指针加减到底加了多少字节——那些问题的答案,全都踩在"每个类型占几个字节、字节里存的什么位模式"这两块地基上。本章的位模式视角,就是第 9 章的地图。

注意:别急着合上书——本章所有结论都值得亲手验证一遍。把 bits.cpptrap.cppieee.cppepsilon.cpp 四个程序自己敲出来跑一遍(代码都在正文里),再改几个数试试:-3 的补码?0.5f 的位模式?0.1f + 0.2f(注意是 float 不是 double)的结果?亲自动手之后,这些结论才会从"别人说的"变成"我验证过的"。

章末练习

练习 1:手写三兄弟 入门

写出 8 位二进制下 -5 的原码、反码、补码,并说明 -5 的补码加 5 的补码,按 8 位加法会得到什么。

提示

先写 +5 的二进制 00000101;负数的符号位是 1。原码 = 符号位 + 绝对值;反码 = 符号位不动、其余取反;补码 = 反码 + 1。

参考答案

-5 原码 10000101 → 反码 11111010 → 补码 11111011。-5 补码 11111011 + 5 补码 00000101 = 1 00000000,第 9 位进位丢弃,得 00000000 = 0——这就是补码"减法变加法"的现场。

练习 2:真假判断 进阶

在 64 位平台上,下面三个表达式分别是什么结果?① -1 > 0u;② 空 std::vector<int> vv.size() - 1;③ for (size_t i = 3; i >= 0; --i) 这个循环(不截断)会执行多少次。

提示

回忆 8.3 节:有符号和无符号混用,int 会被转成 unsigned;size_t 在 64 位平台是 64 位无符号。

参考答案

① 为 true:-1 转成 unsigned 后是 4294967295,大于 0。② 是 18446744073709551615:0 减 1 无符号下溢。③ 永远不会结束:i 到 0 后 --i 回绕成 18446744073709551615,i >= 0 永远成立——教科书级死循环,倒序遍历请用 for (size_t i = n; i-- > 0;)

练习 3:手推位模式 进阶

不运行程序,手推 1.5f 的 IEEE 754 位模式(写出 32 位二进制和十六进制),再用 8.4 节的 union 程序验证。提示:1.5 = 1.1₂ × 2⁰。

提示

符号位 S=0;真实指数 0 加偏移 127 得 127;尾数只存隐含 1 后面的部分:.1₂ 就是"最高位是 1,其余 22 位是 0"。

参考答案

符号 0 | 指数 01111111(=127)| 尾数 10000000000000000000000(0x400000),拼成 0 01111111 10000000000000000000000 = 十六进制 0x3FC00000,与程序实测一致。

练习 4:拆解 double 挑战

把 8.4 节的 union 程序改成拆 double:用 uint64_tdouble 共用内存,拆出 0.1 的符号位(1 位)、指数(11 位,偏移 1023)、尾数(52 位),并解释它和 0.1f(0x3DCCCCCD)为什么不一样。顺带实现一个相对误差版浮点比较函数。

提示

移位量改成 63 和 52,掩码改成 0x7FF0xFFFFFFFFFFFFF(13 个 F);打印用 %016llX。0.1 在 double 里位模式是 0x3FB999999999999A——尾数更长,舍入发生在更低位。

参考答案

double 0.1 = 0x3FB999999999999A:符号 0,指数字段 0x3FB = 1019 → 真实指数 1019 - 1023 = -4,尾数 0x999999999999A(52 位)。它和 float 版存的是同一个无限循环小数 0.00011001100…₂ 的不同精度近似:float 只有 23 位尾数、舍入到 ...CCCD;double 有 52 位、舍入到 ...999A。相对误差比较:fabs(a - b) <= eps * std::max(fabs(a), fabs(b))——0.1+0.2 与 0.3 的差 5.55e-17 远小于容差,判定相等;注意 a、b 都为 0 时此式退化,可加 a == b 短路。