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 到块
不同层次用不同单位管理数据。
| 单位 | 含义 | 常见位置 |
|---|---|---|
| bit | 0 或 1 | 最小信息单位 |
| byte | 8 个 bit | 内存、文件、网络数据常用单位 |
| word | 机器自然处理的字长 | CPU、寄存器、指令 |
| cache line | CPU cache 一次搬运的小块连续内存 | 组成原理 |
| page | OS 管理虚拟内存的固定大小块 | 操作系统 |
| 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 | 局域网里一帧有最大长度和校验 |
这解释了两个现象:
- 小操作不一定真的只付出小成本。
- 边界错位会让系统做额外工作。
比如顺序读文件时,系统可以提前读后面的块;随机读时,预读效果差。再比如结构体字段没有对齐,CPU 可能需要多次内存访问。
3. 位宽决定范围
n 个 bit 一共有 $2^n$ 种状态。
| bit 数 | 状态数 |
|---|---|
| 1 | 2 |
| 8 | 256 |
| 16 | 65536 |
| 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:
代码块收起展开
零扩展: 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 开始,可能跨边界:
代码块收起展开
地址: 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. 联系实际:遇到“数据怪问题”怎么排查
如果你遇到:
- 数字突然变负。
- 小数多了一串尾巴。
- 文件打开乱码。
- 二进制协议解析错。
- 图片或压缩包打不开。
- 时间戳变成奇怪日期。
按这个顺序查:
- 原始 bit/byte 是什么?
- 当前解释规则是什么?
- 写入方和读取方规则是否一致?
- 位宽是否足够?
- 是否有 signed/unsigned 混用?
- 字节序是否一致?
- 编码是否一致?
- 是否触碰边界条件?
这就是表示问题的排查路径。
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-bit | 255 |
| 第 1 个 byte 作为 signed 8-bit | -1 |
| 第 3 个 byte 作为 ASCII | A |
两个 byte 作为大端整数 00 41 | 65 |
两个 byte 作为小端整数 41 00 | 16640 |
这个实验的意义不是背结果,而是形成排障直觉:
先看原始 byte
再问解释规则
最后才讨论“值为什么不对”
只要写入方和读取方在位宽、符号、字节序、编码上有一个规则不一致,值就会变成另一个世界。
问题:设计一个稳定的二进制记录格式
学完本章,可以尝试设计一个小记录格式:
保存一条传感器读数:
设备 ID、时间戳、温度、状态码、校验和
不要直接写“把对象存进去”。先定义字节级格式:
| offset | size | 字段 | 解释规则 |
|---|---|---|---|
| 0 | 2 | magic | 固定值,用于识别格式 |
| 2 | 1 | version | 格式版本 |
| 3 | 1 | status | unsigned 8-bit |
| 4 | 8 | timestamp_ms | unsigned 64-bit,大端 |
| 12 | 4 | device_id | unsigned 32-bit,大端 |
| 16 | 4 | temperature_milli | signed 32-bit,大端,单位是千分之一度 |
| 20 | 4 | checksum | 对前 20 byte 做校验 |
这个设计显式回答了:
字段多长?
是否有符号?
字节序是什么?
单位是什么?
版本怎么识别?
如何发现损坏?
这就是表示边界意识。只要格式要跨文件、网络、语言或时间保存,就必须把这些规则写出来。
12. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 为什么同一段 bit 可以表示不同意义?
- 为什么整数会溢出,溢出后为什么会环绕?
- 为什么小数计算会出现精度误差?
- 为什么中文会乱码,乱码时应该查编码而不是乱猜?
- 为什么网络协议和二进制文件要规定字节序?
- 为什么结构体或记录中会有 padding?
- 为什么边界条件是底层 bug 的高发区?
如果你以后看到“数据值很奇怪”,能先问“这段 bit 是按什么规则解释的”,你就抓住了表示问题的核心。