0.1 + 0.2 在 C/Java 里都不等于 0.3300 强转成 unsigned char 会变成 44;网络里收到的 0x12345678 在你本机读出来是 0x78563412。这三个"离谱"现象,根子都在同一个问题上:计算机怎么用 0/1 表示一个数。CSAPP 整门课都在讲"程序员的计算机系统视角",第一篇就从地基开始——信息表示。

1. 位、字节与字长

计算机只能存两种状态,记作 0 和 1,一位就叫一个 bit(位)。8 个 bit 组成 1 字节(byte),这是内存编址的最小单位。所谓"机器字长",指 CPU 一次能处理的位数——64 位机器上指针就是 8 字节。写一段代码把"地基尺寸"打出来:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void) {
printf("sizeof(char) = %zu 字节\n", sizeof(char));
printf("sizeof(int) = %zu 字节\n", sizeof(int));
printf("sizeof(long) = %zu 字节\n", sizeof(long));
printf("sizeof(float) = %zu 字节\n", sizeof(float));
printf("sizeof(double) = %zu 字节\n", sizeof(double));
printf("sizeof(int*) = %zu 字节\n", sizeof(int *)); // 指针宽度 = 字长
return 0;
}
1
2
3
4
5
6
sizeof(char)   = 1 字节
sizeof(int) = 4 字节
sizeof(long) = 8 字节
sizeof(float) = 4 字节
sizeof(double) = 8 字节
sizeof(int*) = 8 字节

本质一句话: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
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdint.h>

int main(void) {
uint32_t x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
printf("低地址 -> 高地址:");
for (int i = 0; i < 4; i++)
printf(" %02X", p[i]);
putchar('\n');
return 0;
}
1
低地址 -> 高地址: 78 56 34 12

打印出 78 56 34 12,说明这台机器是小端0x12 这个最高字节被放在了最高地址。同样一串字节,两种读法完全不同:

int32 x = 0x12345678 在内存中的两种字节排布 大端 Big-Endian(网络字节序) 0x12 0x34 0x56 0x78 地址 0x100 0x101 0x102 0x103 小端 Little-Endian(x86 / 主流 ARM) 0x78 0x56 0x34 0x12 地址 0x100 0x101 0x102 0x103

:跨机器传输或读写二进制文件时,两端字节序不一致就会读反。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 位补码是 0xFFFFFFFF0xFFFFFFFF + 1−1 + 1 在硬件里是同一件事——溢出丢掉进位后回到 0。看代码验证"位不变、读法变":

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdint.h>

int main(void) {
uint32_t u = 0xFFFFFFFF; // 一串全 1 的位
int32_t s = (int32_t)u; // 换种读法
printf("0xFFFFFFFF 当无符号读 = %u\n", u);
printf("0xFFFFFFFF 当补码读 = %d\n", s);

int32_t m = -2; // 补码里 -2 = 0xFFFFFFFE
printf("-2 的位模式 = 0x%08X\n", (uint32_t)m);
return 0;
}
1
2
3
0xFFFFFFFF 当无符号读 = 4294967295
0xFFFFFFFF 当补码读 = -1
-2 的位模式 = 0xFFFFFFFE

下面这张图是"同一根 32 位标尺"的两种刻度:左半边从 0 数到 2147483647,右半边从 0x80000000 开始读作负数,一路到 −1 恰好闭合回 0——这正是补码能"减法变加法"的原因:

同一个 32 位串的两种读法(0x00000000 → 0xFFFFFFFF) 补码读左半边为正:0x00000000 ~ 0x7FFFFFFF 右半边开头 0x80000000 = -2147483648(INT_MIN) 0xFFFFFFFF 按无符号读 = 4294967295 0xFFFFFFFF 按补码读 = -1 本质:-1 与 0xFFFFFFFF 是同一个位模式(mod 2^32) 减法在硬件里不存在——a - b 就是 a + b 的补码,全加器不区分符号

本质一句话:无符号和补码是同一串 bit 的两种解释;补码让"负数"参与加法时无需任何特殊电路,代价只是范围不对称(负数比正数多一个)。

5. 转换、截断与符号扩展:位模式变了,数字就变了

C 里类型转换非常"物理":赋值、强转基本都是位模式重解释,于是出现两类经典坑。

截断(truncation):把大类型塞进小类型,高位直接丢弃,等价于对 2^N 取模:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdint.h>

int main(void) {
int big = 300; // 0x012C
uint8_t t = (uint8_t)big; // 截断成低 8 位
printf("300 截断到 uint8_t = %u\n", t); // 300 mod 256 = 44

int neg = -2; // 0xFFFFFFFE
uint8_t n = (uint8_t)neg;
printf("-2 截断到 uint8_t = %u\n", n); // 低 8 位 0xFE = 254
return 0;
}
1
2
300 截断到 uint8_t = 44
-2 截断到 uint8_t = 254

符号扩展(sign extension):把小类型转回大类型时,若原来是有符号数,高位要补符号位而不是补 0,否则 -2 会变成 254

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <stdint.h>

int main(void) {
int8_t s8 = -2; // 0xFE
int32_t s32 = s8; // 符号扩展 -> 0xFFFFFFFE,仍是 -2
uint32_t u32 = (uint32_t)(uint8_t)s8; // 先转无符号再扩展 -> 0x000000FE = 254
printf("int8 -2 扩展为 int32 = %d\n", s32);
printf("int8 -2 先当无符号 = %u\n", u32);
return 0;
}
1
2
int8 -2 扩展为 int32 = -2
int8 -2 先当无符号 = 254

: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)

float(单精度)IEEE 754 位布局:S | E | F S 1位 E 阶码 8 位(偏置 127) F 尾数 23 位(隐含整数位 1.) 规格化值 = (-1)^S × 1.F × 2^(E-127) 1.0f = 0x3F800000 → S=0 E=127 F=0 → 1.0 × 2^0 = 1.0 0.1f = 0x3DCCCCCD:0.1 的二进制是无限循环 0.000110011…,只能近似 E=255 → ±∞ / NaN;E=0 且 F≠0 → 非规格化;S 决定正负

代码验证"打印浮点数的位":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdint.h>
#include <string.h>

static void dump_float(float f) {
uint32_t bits;
memcpy(&bits, &f, 4); // 用 memcpy 取位,别用 union/强转(有别名/UB 争议)
printf("%.6f = 0x%08X\n", f, bits);
}

int main(void) {
dump_float(1.0f);
dump_float(0.1f);
dump_float(-2.0f);
printf("0.1 + 0.2 = %.17g\n", 0.1 + 0.2);
float f = 16777217.0f; // 2^24 + 1
printf("16777217.0f 存进 float 后 = %.0f\n", f);
return 0;
}
1
2
3
4
5
1.000000 = 0x3F800000
0.100000 = 0x3DCCCCCD
-2.000000 = 0xC0000000
0.1 + 0.2 = 0.30000000000000004
16777217.0f 存进 float 后 = 16777216

三个现象逐一解释:

  • 0.1f = 0x3DCCCCCD:0.1 在二进制里是无限循环小数,IEEE 754 只能存最接近的近似值,所以 0.1 + 0.2 对不上精确的 0.3——0.30000000000000004 就是累加两次舍入的误差。判断浮点相等别用 ==,用误差容限(如 fabs(a - b) < 1e-9)或干脆用整数/十进制定点数;
  • 16777217.0f 变回 16777216float 尾数只有 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 里则是确定语义,但原理同源;③ 浮点天生有精度边界,二进制近似 + 有限尾数决定了它不适合精确比较和精确金额计算。记住这张位布局图和"减法=加补码"这两句话,后面看汇编、看缓冲区溢出、看数据对齐,全都是这几块的展开。