信息表示:位、字节、整数与浮点
0.1 + 0.2 在 C/Java 里都不等于 0.3;300 强转成 unsigned char 会变成 44;网络里收到的 0x12345678 在你本机读出来是 0x78563412。这三个"离谱"现象,根子都在同一个问题上:计算机怎么用 0/1 表示一个数。CSAPP 整门课都在讲"程序员的计算机系统视角",第一篇就从地基开始——信息表示。
1. 位、字节与字长
计算机只能存两种状态,记作 0 和 1,一位就叫一个 bit(位)。8 个 bit 组成 1 字节(byte),这是内存编址的最小单位。所谓"机器字长",指 CPU 一次能处理的位数——64 位机器上指针就是 8 字节。写一段代码把"地基尺寸"打出来:
1 |
|
1 | sizeof(char) = 1 字节 |
本质一句话:C 只保证 char 是 1 字节、int 至少 2 字节、sizeof(char*) 等于机器字长——其余"多大"由平台决定。上面是 64 位 Linux 的 LP64 模型(long 8 字节);Windows 上是 LLP64(long 仍 4 字节),所以别写死"long 一定是 8 字节",要用 sizeof 或 <stdint.h> 的 int32_t/int64_t。
2. 十六进制:人写给机器看的速记
4 个 bit 能表示 16 种组合,正好对应一位十六进制数(09、AF),所以 1 字节 = 2 位十六进制。0x3F800000 这种写法不是给机器看的(机器只认二进制),而是让人一眼读出字节串:
0x3F800000= 二进制0011 1111 1000 0000 0000 0000 0000 0000,按 4 bit 一组直接翻译;- 反过来,网络抓包、调试器里看到的
78 56 34 12,心里补成0x12345678需要先知道一件事——字节怎么排。
3. 字节序:0x12345678 到底先存哪个字节
一个 4 字节整数在内存里占 4 个连续地址。问题是:高字节存低地址(大端)还是低字节存低地址(小端)?x86 / 主流 ARM 默认小端,网络协议用大端。写个小程序看本机:
1 |
|
1 | 低地址 -> 高地址: 78 56 34 12 |
打印出 78 56 34 12,说明这台机器是小端:0x12 这个最高字节被放在了最高地址。同样一串字节,两种读法完全不同:
坑:跨机器传输或读写二进制文件时,两端字节序不一致就会读反。C 的做法是用 ntohl/htonl 这类"网络序 ⇄ 主机序"转换函数;Java 里 ByteBuffer.order() 显式指定 BIG_ENDIAN/LITTLE_ENDIAN;C++ 20 起用 <bit> 里的 std::byteswap。任何跨端二进制协议都逃不开显式约定字节序这一步。
4. 整数编码:无符号 vs 补码
字节排好之后,一串 bit 到底代表几,取决于"读法"。C 里有两种读法:
- 无符号(unsigned):直接按位权累加,范围
[0, 2^N − 1]; - 补码(two's complement):最高位权取负,范围
[−2^(N−1), 2^(N−1) − 1]。
补码的妙处在于同一个加法电路不用区分符号:−1 的 32 位补码是 0xFFFFFFFF,0xFFFFFFFF + 1 和 −1 + 1 在硬件里是同一件事——溢出丢掉进位后回到 0。看代码验证"位不变、读法变":
1 |
|
1 | 0xFFFFFFFF 当无符号读 = 4294967295 |
下面这张图是"同一根 32 位标尺"的两种刻度:左半边从 0 数到 2147483647,右半边从 0x80000000 开始读作负数,一路到 −1 恰好闭合回 0——这正是补码能"减法变加法"的原因:
本质一句话:无符号和补码是同一串 bit 的两种解释;补码让"负数"参与加法时无需任何特殊电路,代价只是范围不对称(负数比正数多一个)。
5. 转换、截断与符号扩展:位模式变了,数字就变了
C 里类型转换非常"物理":赋值、强转基本都是位模式重解释,于是出现两类经典坑。
截断(truncation):把大类型塞进小类型,高位直接丢弃,等价于对 2^N 取模:
1 |
|
1 | 300 截断到 uint8_t = 44 |
符号扩展(sign extension):把小类型转回大类型时,若原来是有符号数,高位要补符号位而不是补 0,否则 -2 会变成 254:
1 |
|
1 | int8 -2 扩展为 int32 = -2 |
坑:C 里 uint8_t → int32_t 这种"窄→宽"的隐式转换行为取决于源类型——源是无符号就补 0,源是有符号就补符号位。想当然"反正变宽了数值不变"就会在混用 char/int 时翻车。Java 的 byte 参与运算前也会被提升(promotion)成 int 并做符号扩展,同样要注意;C++ 规则与 C 一致,只是推荐用 static_cast 让意图显式。
另外注意:无符号溢出是标准定义的回绕(mod 2^N),而有符号溢出在 C/C++ 里是未定义行为(UB)——编译器可以假设它不发生并据此优化,别写依赖"回绕"的代码;Java 则规定所有整数溢出都按补码回绕,行为确定。
6. 浮点:IEEE 754 的位布局
整数之外,带小数的数用 IEEE 754 表示。float 用 32 位:1 位符号 + 8 位阶码(偏置 127)+ 23 位尾数;double 用 64 位:1 + 11(偏置 1023)+ 52。核心思想是科学计数法的二进制版:值 = (−1)^S × 1.F × 2^(E−127)。
代码验证"打印浮点数的位":
1 |
|
1 | 1.000000 = 0x3F800000 |
三个现象逐一解释:
0.1f = 0x3DCCCCCD:0.1 在二进制里是无限循环小数,IEEE 754 只能存最接近的近似值,所以0.1 + 0.2对不上精确的0.3——0.30000000000000004就是累加两次舍入的误差。判断浮点相等别用==,用误差容限(如fabs(a - b) < 1e-9)或干脆用整数/十进制定点数;16777217.0f变回16777216:float尾数只有 23 位 + 隐含 1 位 = 24 位有效精度,只能精确表示到 2^24 = 16777216,再大的整数会"跳着走",每 2 的幂密度减半;-2.0f = 0xC0000000:最高位 S=1 表示负数,印证"符号位独立于数值部分"。
7. 三种语言对照表与小结
| 维度 | C | C++ | Java |
|---|---|---|---|
| 无符号整数 | 有 unsigned/uintN_t |
同 C | 没有(用 long + 位运算模拟) |
int 宽度 |
平台相关(常 32 位) | 同 C | 固定 32 位 |
| 有符号溢出 | 未定义行为 UB | 同 C | 定义好的补码回绕 |
| 无符号溢出 | 定义好的 mod 2^N | 同 C | 无无符号类型 |
| 窄→宽转换 | 按源类型补 0/符号扩展 | 同 C(推荐 static_cast) |
有符号类型一律符号扩展 |
| 浮点 | IEEE 754(主流) | 同 C | IEEE 754,行为与平台无关 |
| 判浮点相等 | 别用 == |
同 C | 同 C(Math.abs(a-b)<eps) |
🐾 小结:信息表示这节课就三件事——① 位模式是唯一的真实,字节序决定"串怎么排",无符号/补码/浮点只是同一串 bit 的不同读法;② 读法切换会带来截断、符号扩展、溢出这些"物理性"后果,C/C++ 里它们大多是 UB 或实现定义,Java 里则是确定语义,但原理同源;③ 浮点天生有精度边界,二进制近似 + 有限尾数决定了它不适合精确比较和精确金额计算。记住这张位布局图和"减法=加补码"这两句话,后面看汇编、看缓冲区溢出、看数据对齐,全都是这几块的展开。

