representation - boundaries

05. 表示、边界与解释规则

0. 本章先解决什么问题

计算机里没有天然的整数、小数、字符、图片、文件、请求。机器保存的只是 bit。

数据 = bit 序列 + 解释规则

这句话能解释一大堆底层问题:

  • 为什么整数会溢出?
  • 为什么小数会不精确?
  • 为什么中文会乱码?
  • 为什么网络协议要规定字节序?
  • 为什么同一个 11111111 可以是 255,也可以是 -1?
  • 为什么文件看起来存在,却打不开或解析失败?

学习“表示”就是学习:同一段 bit 如何在不同规则下变成不同意义,以及这些规则的边界在哪里。

表示边界

这张图把本章核心压缩成一句话:bit 本身没有天然意义,signed/unsigned、编码、字节序、位宽和边界条件才决定它被解释成什么。很多“数据怪问题”其实不是数据变了,而是解释规则变了。

这张图怎么读

图里每一条边界都在提醒你:

同一段 bit 穿过一个解释边界,意义就可能改变。

常见解释边界包括:

边界问题
位宽边界8 bit、16 bit、32 bit、64 bit 能表示的范围不同
符号边界signed 和 unsigned 对最高位的解释不同
编码边界字节如何变成字符
字节序边界多字节数值从哪一端开始读
对齐边界数据是否落在硬件友好的地址上
格式边界文件/协议字段如何切分
精度边界近似值能不能代表真实值

这就是本章的排障原则:

先拿到原始 bytes,
再确认解释规则,
最后讨论值是否正确。

如果你直接从“显示出来的值”开始猜,通常会被误导。

1. bit 没有天然意义

看这段 bit:

01000001

它可以是:

解释规则结果
无符号整数65
ASCII字符 A
某个协议字段由协议定义
某条指令的一部分由指令集定义
某个压缩数据片段由压缩格式定义

所以很多错误不是“数据没了”,而是“解释方式错了”。

例子:

同一份字节
用 UTF-8 解释 -> 正常中文
用错误编码解释 -> 乱码

1.1 数据在边界处会换身份

同一份数据在系统里会不断换身份:

内存中的对象
-> 序列化后的 byte
-> 网络报文里的字段
-> 对端接收 buffer
-> 解析后的对象
-> 存入文件或数据库

每跨一层,都要问:

问题例子
字段长度是多少2 byte length 还是 4 byte length?
字段顺序是什么先 header 后 body,还是固定偏移?
数字如何编码文本十进制、二进制补码、变长整数?
字符如何编码UTF-8、UTF-16、其他编码?
缺失值怎么表示空串、0、null、特殊标记?
版本怎么区分新字段旧解析器能不能跳过?

很多跨语言、跨进程、跨机器的问题,都发生在这些边界。

2. 常见单位:从 bit 到块

不同层次用不同单位管理数据。

单位含义常见位置
bit0 或 1最小信息单位
byte8 个 bit内存、文件、网络数据常用单位
word机器自然处理的字长CPU、寄存器、指令
cache lineCPU cache 一次搬运的小块连续内存组成原理
pageOS 管理虚拟内存的固定大小块操作系统
block存储设备或文件系统读写块文件系统、磁盘
packet网络层传输的数据包IP
frame链路层传输的数据帧Ethernet/Wi-Fi

这些单位背后都是管理边界。

比如:

  • CPU cache 按 cache line 搬数据。
  • OS 虚拟内存按 page 管理。
  • 磁盘和文件系统按 block 读写。
  • 网络按 packet/frame 传输。

理解单位,才能理解为什么“只读 1 byte”也可能触发一整块数据的搬运。

2.1 单位不同,成本和错误边界也不同

不同单位不只是名称差异,它们决定系统一次处理的最小边界。

单位典型后果
cache line访问一个字节可能把附近几十字节一起拉进 CPU cache
page访问一个地址可能触发整页缺页加载
block修改一点文件内容也可能读写整个块
packet一段数据可能被拆包、重组、丢包
frame局域网里一帧有最大长度和校验

这解释了两个现象:

  1. 小操作不一定真的只付出小成本。
  2. 边界错位会让系统做额外工作。

比如顺序读文件时,系统可以提前读后面的块;随机读时,预读效果差。再比如结构体字段没有对齐,CPU 可能需要多次内存访问。

3. 位宽决定范围

n 个 bit 一共有 $2^n$ 种状态。

bit 数状态数
12
8256
1665536
32约 42 亿
64约 $1.84 \times 10^{19}$

如果用 8 bit 表示无符号整数,范围是:

0 ~ 255

如果用 8 bit 表示有符号补码整数,范围是:

-128 ~ 127

状态数有限,所以范围有限。范围有限,所以一定有边界。超过边界,就会溢出或无法表示。

3.1 截断、符号扩展和零扩展

位宽变化时,最常见的问题有三个:

操作含义风险
截断宽值放进窄字段,只保留低位高位信息丢失
零扩展unsigned 窄值变宽,高位补 0对 unsigned 合理
符号扩展signed 窄值变宽,高位补符号位对补码 signed 合理

例子:8 bit 的 11111111:

按 unsigned 看: 255
按 signed 看: -1

扩展到 16 bit:

代码块PLAINTEXT · 2 行收起展开
零扩展:   00000000 11111111 = 255
符号扩展: 11111111 11111111 = -1

底层 bit 操作、协议解析、二进制文件读取里,这类错误非常常见。你以为只是“转成更大的整数”,实际是在选择解释规则。

4. 有符号和无符号

同一段 bit:

11111111

不同解释:

规则
8 bit 无符号整数255
8 bit 有符号补码整数-1

底层 bit 没变,变的是解释规则。

这也是为什么二进制协议、文件格式、硬件寄存器说明书必须明确字段含义:

这个字段是 signed 还是 unsigned?
这个字段占多少 bit?
最大值是多少?
溢出怎么办?

4.1 补码为什么常用

现代机器常用补码表示有符号整数,一个重要原因是加法硬件可以统一。

以 8 bit 为例:

-1 表示为 11111111
1 表示为 00000001
相加:
11111111
+00000001
=1 00000000

只保留 8 bit 后得到:

00000000 = 0

这正好符合 -1 + 1 = 0。也就是说,补码让有符号数和无符号数在底层加法电路上可以共享很多规则。

但共享电路不代表共享解释。比较大小、范围判断、格式解析时,signed/unsigned 仍然必须分清。

5. 整数溢出

溢出不是随机错误,而是范围不够。

8 bit 无符号整数最大是:

11111111 = 255

再加 1:

255 + 1 -> 需要 9 bit

如果只能保留 8 bit,多出来的进位会被截断,结果回到 0。

这就是环绕。

整数溢出会影响:

  • 数组长度计算。
  • 时间戳计算。
  • 文件大小计算。
  • 哈希值计算。
  • 二分查找中点计算。
  • 安全边界检查。

底层边界问题,经常会变成高层 bug。

5.1 溢出会绕过边界检查

一个经典风险是长度计算:

需要分配: count * element_size

如果乘法溢出,结果可能变小:

真实需要很大空间
-> 溢出后得到一个小数字
-> 分配了小 buffer
-> 后续写入按真实 count 写
-> 越界

这说明边界检查必须发生在正确的表示范围内。你不能先让值溢出,再拿溢出后的结果判断“够不够”。

再看二分查找中点:

mid = (left + right) / 2

left + right 可能超过整数范围时,中点计算会溢出。更稳的写法是:

mid = left + (right - left) / 2

这不是语法技巧,而是避免中间结果越过表示边界。

6. 浮点数不是精确小数

很多十进制小数不能被二进制有限位精确表示。

就像十进制无法有限表示:

1/3 = 0.333333…

二进制也无法有限表示很多十进制小数。

所以可能出现:

0.1 + 0.2 -> 0.30000000000000004

这不是某个语言算错了,而是表示规则决定的近似。

结论:

  • 浮点适合测量、科学计算、近似值。
  • 金额、计数、精确比较要非常小心。
  • 比较浮点结果通常要用误差范围,而不是直接相等。

6.1 浮点数要看“范围、精度、舍入”

浮点不是“更大的小数类型”,它有自己的模型:

符号位 + 指数 + 尾数

它擅长表示非常大或非常小的近似量,但有限尾数意味着精度有限。数越大,相邻两个可表示数之间的间隔也可能越大。

浮点问题常见在:

问题原因
直接相等比较失败计算过程有舍入误差
小数加法有尾巴十进制小数不能被二进制有限表示
大数加小数没变化小数低于当前精度间隔
累加误差扩大多次舍入误差积累

所以浮点排查要问:

这个值需要精确,还是允许误差?
误差范围是多少?
操作顺序会不会放大误差?
显示格式是否和内部值混淆?

7. 字符编码

字符也不是天然存在的。字符需要编码。

概念含义
字符人理解的符号,比如 A
码点字符在字符集里的编号
编码码点如何变成字节

乱码通常来自:

写入时使用一种编码
读取时使用另一种编码

文本文件本质上仍然是字节文件,只是这些字节按字符编码解释。

所以看到乱码时,优先问:

原始字节是什么?
写入编码是什么?
读取编码是什么?

7.1 文本问题不止乱码

字符编码还会带来这些边界问题:

问题说明
字符数和字节数不同一个字符可能占多个 byte
截断可能截半个字符按 byte 截断 UTF-8 可能得到非法序列
大小写规则不简单不同语言环境规则不同
规范化问题看起来一样的字符可能有不同码点组合
换行符差异\n\r\n 会影响解析
BOM / 文件头有些文本开头带编码标记

所以“字符串长度”要说清楚:

byte length?
code point count?
user perceived character count?
display width?

不同答案对应不同场景。网络协议通常关心 byte length;文本编辑器可能关心用户看到的字符;终端排版还要关心显示宽度。

8. 字节序

多字节数据存储时,要决定高位字节和低位字节的顺序。

数字:

0x12345678

字节序存储顺序
大端12 34 56 78
小端78 56 34 12

如果发送方按大端写,接收方按小端读,值就会错。

字节序常见于:

  • 网络协议。
  • 二进制文件。
  • CPU 架构。
  • 设备寄存器。
  • 跨语言数据交换。

8.1 字节序只影响多字节数值字段

字节序不是“所有数据都倒过来”。它主要影响多字节数值如何解释。

例如字节序列:

12 34 56 78

如果按 32 bit 大端整数读,是:

0x12345678

如果按 32 bit 小端整数读,是:

0x78563412

但如果它是四个独立 byte,或者是文本字符序列,就不能随便反转。排查协议时要看字段定义:

这个字段是 1 byte、2 byte、4 byte 还是变长?
这个字段是不是数值?
协议规定网络字节序还是本机字节序?

9. 对齐和填充

很多硬件访问对齐数据更快。

如果一个 4 byte 整数放在 4 的倍数地址上,通常更适合硬件访问。

地址: 0 1 2 3 4 5 6 7
数据: [ int ][ int ]

如果从地址 1 开始,可能跨边界:

代码块PLAINTEXT · 2 行收起展开
地址: 0 1 2 3 4 5
数据:   [ int ]

某些系统会变慢,某些硬件甚至不允许。

因此结构里可能出现填充:

字段 A: 1 byte
填充: 3 byte
字段 B: 4 byte

空间变大,但访问更规整。

9.1 内存布局不是字段简单相加

假设一个记录包含:

1 byte flag
4 byte id
2 byte code

字段大小相加是 7 byte,但实际内存布局可能因为对齐变成 12 byte 或其他大小。原因是某些字段希望放在特定倍数地址上。

这影响:

场景影响
二进制文件直接映射结构不同平台 padding 可能不同
网络协议设计不能依赖本机内存布局
cache 性能字段排列影响 cache line 利用
并发共享数据不同字段落在同一 cache line 可能互相影响

所以稳定的外部格式通常要显式定义每个字段的长度、顺序、字节序,而不是直接把内存结构原样写出去。

10. 边界条件

边界条件是表示规则的极限位置。

类型边界例子
整数最大值、最小值、0、-1
数组空数组、首元素、尾元素、越界下标
字符串空串、超长字符串、多字节字符
文件空文件、最后一个字节、文件过大
网络最大包长、超时、断连、半包
时间时钟回拨、闰秒、溢出的时间戳

初学者经常只测普通输入。底层学习要求你主动盯边界。

10.1 边界测试要覆盖“刚好”和“越过一点”

只测最大值和最小值还不够。更好的边界测试会覆盖:

最小值 - 1
最小值
最小值 + 1
普通值
最大值 - 1
最大值
最大值 + 1

不同数据类型还要加特殊值:

类型特殊边界
整数0、1、-1、最大值、最小值、溢出点
浮点0.0、极小值、极大值、NaN、Infinity、舍入边界
字符串空串、单字符、多字节字符、超长、非法编码
数组空数组、一个元素、满容量、越界下标
时间时区、时钟回拨、跨天、闰年、过期时间刚好到达
协议空 body、最大包、半包、粘包、未知字段

边界测试的目标不是多写测试,而是逼自己说清楚表示规则的极限。

11. 联系实际:遇到“数据怪问题”怎么排查

如果你遇到:

  • 数字突然变负。
  • 小数多了一串尾巴。
  • 文件打开乱码。
  • 二进制协议解析错。
  • 图片或压缩包打不开。
  • 时间戳变成奇怪日期。

按这个顺序查:

  1. 原始 bit/byte 是什么?
  2. 当前解释规则是什么?
  3. 写入方和读取方规则是否一致?
  4. 位宽是否足够?
  5. 是否有 signed/unsigned 混用?
  6. 字节序是否一致?
  7. 编码是否一致?
  8. 是否触碰边界条件?

这就是表示问题的排查路径。

11.1 二进制协议解析错:从 offset 开始查

假设你解析一个二进制消息,发现字段全错。不要先怀疑业务逻辑,先画字段布局:

offset size field
0 2 magic
2 1 version
3 1 flags
4 4 body_length
8 N body

逐项检查:

检查项为什么
offset 是否对前面字段长度错,后面全错
size 是否对2 byte 当 4 byte 读会错位
endian 是否对多字节数值会反
signed 是否对高位为 1 时解释不同
body length 是否可信长度字段错会读过界或读不全
version 是否兼容新旧格式字段可能不同
checksum 是否通过判断内容是否损坏

这类问题要用十六进制视角看。显示层的“值”只是解释结果,offset 和 byte 才是证据。

手推:一个长度字段读错,后面为什么全错

假设协议规定:

offset size field
0 2 magic
2 1 version
3 1 flags
4 2 body_length
6 N body

收到的原始 byte 是:

CA FE 01 00 00 05 48 65 6C 6C 6F

按协议正确解释:

magic = CA FE
version = 01
flags = 00
body_length = 00 05 = 5
body = 48 65 6C 6C 6F

如果解析器把 body_length 错读成 4 byte:

body_length = 00 05 48 65
body 起点 = offset 8

问题立刻变成连锁反应:

错位点后果
长度字段多读 2 byte把 body 的前 2 byte 当成长度
body 起点从 6 变成 8真正内容被截掉前缀
长度值变成巨大数字解析器可能等待不存在的数据
后续消息边界错位下一条消息也会被错误切分

所以二进制排障有一个很硬的规则:

第一个 offset 错误之后,所有显示值都不可信。

先找到“第一次错位”的字段,比逐个解释后面的乱码更有效。

边界条件:截断不一定报错,但意义已经变了

很多表示错误危险在于它们不会立刻失败。

例如一个字段只允许 8 bit,但上游传入了:

value = 300

8 bit 只能保留低 8 位。因为:

300 = 256 + 44

所以截断后得到:

44

系统没有崩溃,字段也有值,但语义已经变了。更危险的是 signed/unsigned 混用:

原始 byte: 80
unsigned 8-bit: 128
signed 8-bit: -128

如果这个字段表示长度,128-128 会走向完全不同的控制流:

解释结果可能路径
128分配 128 byte 或继续读取
-128被当作非法长度、特殊值,甚至参与错误计算

这类 bug 的指纹是:

普通小值正常;
一到 127/128、255/256、32767/32768 这类边界就异常。

遇到这种现象,不要只看业务含义,先查位宽、符号、截断和扩展规则。

小实验:同一组 byte 换规则解释

拿一组原始 byte:

FF 00 41

分别按这些规则解释:

规则会看到什么
第 1 个 byte 作为 unsigned 8-bit255
第 1 个 byte 作为 signed 8-bit-1
第 3 个 byte 作为 ASCIIA
两个 byte 作为大端整数 00 4165
两个 byte 作为小端整数 41 0016640

这个实验的意义不是背结果,而是形成排障直觉:

先看原始 byte
再问解释规则
最后才讨论“值为什么不对”

只要写入方和读取方在位宽、符号、字节序、编码上有一个规则不一致,值就会变成另一个世界。

问题:设计一个稳定的二进制记录格式

学完本章,可以尝试设计一个小记录格式:

保存一条传感器读数:
设备 ID、时间戳、温度、状态码、校验和

不要直接写“把对象存进去”。先定义字节级格式:

offsetsize字段解释规则
02magic固定值,用于识别格式
21version格式版本
31statusunsigned 8-bit
48timestamp_msunsigned 64-bit,大端
124device_idunsigned 32-bit,大端
164temperature_millisigned 32-bit,大端,单位是千分之一度
204checksum对前 20 byte 做校验

这个设计显式回答了:

字段多长?
是否有符号?
字节序是什么?
单位是什么?
版本怎么识别?
如何发现损坏?

这就是表示边界意识。只要格式要跨文件、网络、语言或时间保存,就必须把这些规则写出来。

12. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 为什么同一段 bit 可以表示不同意义?
  2. 为什么整数会溢出,溢出后为什么会环绕?
  3. 为什么小数计算会出现精度误差?
  4. 为什么中文会乱码,乱码时应该查编码而不是乱猜?
  5. 为什么网络协议和二进制文件要规定字节序?
  6. 为什么结构体或记录中会有 padding?
  7. 为什么边界条件是底层 bug 的高发区?

如果你以后看到“数据值很奇怪”,能先问“这段 bit 是按什么规则解释的”,你就抓住了表示问题的核心。

延伸阅读