Why this matters

很多“代码看起来没问题,结果却不对”的问题,根子并不在业务逻辑,而在数据是怎么被底层表示和解释的。

同样的一组比特位,可能因为字节序不同、是否带符号、是否发生隐式类型提升,最终得到完全不同的结果。把这些基础概念串起来之后,很多看似诡异的现象都会变得可解释。

Byte order decides how memory is read

当一个对象占多个字节时,内存需要决定“低位字节”和“高位字节”怎么排布:

  • 小端序:最低有效字节放在前面。
  • 大端序:最高有效字节放在前面。

比如一个 int32 的值是 0x12345678,起始地址是 0x100:

Text
大端序
地址: 0x100 0x101 0x102 0x103
数据:   12    34    56    78

小端序
地址: 0x100 0x101 0x102 0x103
数据:   78    56    34    12

如果你想直接观察一段对象在内存里的字节排布,最简单的方法就是把它按 unsigned char * 来看:

C
#include <stdio.h>

typedef unsigned char* byte_pointer;

void show_bytes(byte_pointer start, int len) {
  for (int i = 0; i < len; i++) {
    printf("%02x ", start[i]);
  }
  printf("\n");
}

void show_int(int x) {
  show_bytes((byte_pointer)&x, sizeof(int));
}

void show_float(float x) {
  show_bytes((byte_pointer)&x, sizeof(float));
}

void show_pointer(void* x) {
  show_bytes((byte_pointer)&x, sizeof(void*));
}

这也是为什么文本数据通常比二进制数据更容易跨平台传输。文本遵循字符编码规则,而不是直接暴露底层字节布局,所以它对字节序没那么敏感。

Shift operations are about bit semantics

位移不是简单的“乘 2 / 除 2”语法糖,它首先是按位移动。

  • 左移:整体向左移动,高位丢弃,低位补 0。
  • 右移:需要区分逻辑右移和算术右移。

逻辑右移会在左侧补 0,适合无符号数。算术右移会在左侧补符号位,常用于补码整数。多数机器和编译器对有符号数使用算术右移。

Text
0x8A = 1000 1010
如果按算术右移 3 位:
1111 0001

这里左侧补的是 1,因为原数是负数。也正因为如此,对带符号整数做右移时,最好明确自己依赖的是数值意义,还是位级行为。

Integers are encoded, not stored as abstract numbers

无符号整数和有符号整数,看到的是同一组比特,但解释方法不同。

  • 无符号编码:每一位都按正权重累加。
  • 补码编码:最高位按负权重解释,这也是现代计算机里最常见的有符号整数表示方式。

补码有两个特别重要的结论:

  • 负数的可表示范围会比正数多一个值。
  • 类型转换很多时候并不会“改值”,只是改解释方式。

例如下面这段代码:

C
short v = -12345;
unsigned short uv = (unsigned short)v;
printf("v = %d, uv = %u\n", v, uv);

这里并不是先把 -12345 重新算成一个新的正数再存进去,而是保留原来的位模式,然后按无符号整数重新解释。所以最后会看到一个很大的正整数结果。

混合有符号数和无符号数比较时,这个问题尤其容易踩坑:

C
int b1 = -1 < 0;              // 1
int b2 = 2147483647U > -1;    // 0

第二个表达式里,-1 会先被提升并转换成无符号值,于是比较结果就和直觉不同了。

Overflow is part of the model

整数溢出并不是“机器算错了”,而是位宽固定后必然出现的结果。

无符号加法

对 w 位无符号整数来说,结果本质上是在做模 2^w 运算:

  • 如果 x + y < 2^w,结果正常落在范围内。
  • 如果 x + y >= 2^w,高位会被截掉,结果等于 x + y - 2^w。

一个常见的小技巧是:无符号加法后,如果结果小于其中一个加数,通常就已经发生了回绕。

补码加法

补码加法在位级实现上和无符号加法非常像,但解释方式不同。对程序员来说,关键不是死记公式,而是记住一句话:

底层电路只负责按位计算,语言规则才负责解释结果。

乘以或除以 2 的幂

很多乘法都可以重写成移位和加减,例如:

  • x * 8 可以写成 x << 3
  • x * 10 可以写成 (x << 3) + (x << 1)

而除以 2^k 时,右移经常能替代除法,但要注意:

  • 无符号右移更接近“直接截断”。
  • 补码算术右移更接近“向下取整”。
  • C 语言整数除法的语义是向零截断。

所以对于负数,位移结果和真正的整数除法并不总是一致。如果你需要模拟“除以 2^k 再向零截断”,通常需要在右移前先加偏置。

Floating point is approximation

浮点数并不是把十进制小数原样存进内存,而是按照科学计数法的思路编码。可以把它理解成:

V = (-1)^s × M × 2^E

其中:

  • s 是符号位。
  • E 是指数部分。
  • M 是有效数字部分。

以 IEEE 754 为例:

  • 单精度浮点数是 32 位:1 位符号位、8 位指数位、23 位尾数位。
  • 双精度浮点数是 64 位:1 位符号位、11 位指数位、52 位尾数位。

这套表示方式除了普通数值,还定义了几类特殊值:

  • +∞ 和 -∞
  • NaN
  • 非规格化数
  • +0.0 和 -0.0

理解浮点数时最重要的现实感是:很多十进制小数根本不能被二进制精确表示。

这也是为什么像 0.1 + 0.2 这样的表达式,在不同语言里常常会暴露出舍入误差。默认情况下,IEEE 754 通常采用“舍入到最接近值,遇到中点时取偶数”的规则,也就是常说的 round to even。

Practical checks

遇到底层表示相关的问题时,可以按这个顺序检查:

  • 先确认当前值是按有符号还是无符号解释。
  • 直接打印字节,别只看十进制输出。
  • 对右移操作,确认你依赖的是逻辑右移还是算术右移。
  • 混用 signed / unsigned 时,先检查是否发生了隐式提升。
  • 处理浮点数时,不要默认“看起来一样”的十进制值就一定按位相等。

Closing notes

这篇内容真正想建立的,不是公式记忆,而是一种判断习惯:当程序结果和直觉不一致时,先回到底层表示层看一眼。

字节序、补码、溢出、类型转换和浮点舍入看起来分散,但它们其实都在回答同一个问题:同一组比特位,机器到底是怎么理解它的。

感谢读到这里

日志还会继续,欢迎常来。

返回首页