二进制与数制 入门

从我们要发的那条 URL 里抽一个字节: curl https://api.example.com/user/4242 里的字符 4 是位模式 0011 0100;代码里的 32 位整数 42 0000 0000 0000 0000 0000 0000 0010 1010。 有些位置上的位形状一样,意义却不同 —— 而意义完全由下一条读它们的指令决定。5 个 section 把答案搭出来:比特、字节、十六进制 —— 衬底;二进制补码 + 交互式拖动 + 把 Ariane 5 火箭搞炸的那个 bug;IEEE-754 浮点 + 位级剖析器,以及为啥 0.1 + 0.2 ≠ 0.3;字节序(endianness) 以及为啥你的 hex dump 看起来字节是反的; 最后是 速查表,值得能冷讲清楚的那几个问题。

01

比特、字节、和你真正读的那个十六进制

8 比特 = 1 字节;1 字节装 256 种取值。这个最小单位是上面所有层 —— 内存、网络、磁盘 —— 唯一懂得寻址的东西。

一个比特(bit)是一个二进制位,取 0 或 1。一个字节(byte)是 8 比特,所以能装 28 = 256 种不同的值。为啥恰好 8?历史选择: IBM System/360(1964)定下来的,因为 8 比特刚好够一个可打印字符, 又能干净地被 2 的幂整除,而且当时是内存芯片能经济寻址的最小单位。 一旦全世界的工具链(编译器、文件格式、网络协议、操作码)都锁到字节粒度, 这个选择就再也改不动了。现代所有可寻址的内存位置 —— 寄存器除外 —— 单位都是字节, 从来不是比特。

二进制超过 8 位人眼就读不动了。所以程序员把每 4 位压成一个十六进制位:0–9 然后 a–f。 一个字节变成两位十六进制;0xff = 255 = 1111 1111。 十六进制映射到二进制干净,是因为 16 = 24, 每位十六进制恰好等于一个 nibble,没有信息被藏起来。十进制没有这个性质 ——255 这个十进制写法,告诉不了你哪些位被置上。 所以所有内存 dump、抓包、协议 RFC、调试器、Wireshark 显示整数时默认都用十六进制。

URL 字符   '4'        '2'        '/'        ' '
ASCII      52         50         47         32
十六进制    0x34       0x32       0x2F       0x20
比特       0011 0100  0011 0010  0010 1111  0010 0000

上面这 4 个字节就是 curl 实际写到线上的、字符串 "42/ "的字节序列。字符串以字节形态存在内存里;这些字节的「意义」(ASCII 文本?大端整数?RGBA 像素?) 完全由下一条读它们的指令决定。CPU 在字节级别上根本没有「类型」概念; 那是编程语言加在上面的抽象。

UTF-8,简短版。7 位 ASCII 子集(码点 0–127)编码成一个字节, 最高位永远是 0。码点 128 及以上用 2–4 字节,最高位用来编码字节数。 所以纯 ASCII 字符串(URL、HTTP 头、英文源码)在 ASCII 和 UTF-8 下比特一致 —— 这就是为啥 Web 标准化到 UTF-8 时,所有 ASCII 工具没坏。example.com.jp 里如果有 CJK 码点,每个占 3 字节;emoji 占 4 字节。

位移与位运算。硬件层只关心三个操作:AND(屏蔽位)、OR(置位)、XOR(翻位 / 检测差异)。 加位移:x << 1 = 乘 2,x >> 1 = 除 2 (有符号值有些注意点)。所有更高层的操作 —— 协议打包、加密、压缩、哈希 —— 都是基于这几个搭出来的。Linux 内核和所有数据库引擎天天用; Web 应用代码通常不用,所以大多数工程师在性能 profile 提醒他们之前会忘了它们存在。

要点。「一个字节是 8 比特、256 种取值,也是现代任何 CPU 一次能读 / 写的最小单位。十六进制就是压缩过的二进制 —— 每 4 位变成 1 位 —— 所以所有内存 dump、抓包、协议规范都用它。字节本身不带类型; 下一条读它们的指令决定它们是文本、整数、浮点、还是像素。」

02

二进制补码 —— 有符号运算到底怎么工作

CPU 只有一套加法电路。它用同一套算 42 + 17−42 + 17unsigned 200 + unsigned 30。能这么干的原因是二进制补码, 补码本身能成立的原因是模算术。

把正整数存成二进制很显然 —— 42 = 00101010。 有意思的问题是怎么存 −42。历史上试过三种方法,前两种都不好, 第三种就是现代所有 CPU 用的。

符号 + 绝对值(sign-and-magnitude)—— 最高位放符号,剩下位放绝对值。容易读,但有两个零 (+0 = 00000000−0 = 10000000), 而且加法要先看两个操作数的符号、再路由到 4 种情况(同号 / 异号 × 绝对值大 / 小)。 硬件更大更慢,没有上行收益。反码(one's complement)—— 翻位得负数 —— 同样有两个零的问题,加法还需要「end-around carry」环。 到 1970 年代两者都败给了二进制补码。

二进制补码(two's complement)就是关键的把戏。 取负:翻每一位、然后加 1。为啥能成立?用模算术看就清楚了。 8 位寄存器里每个值都隐式地取模 28 = 256。 我们要让 −x 满足 x + (−x) = 0。 模 256 下,这意味着 −x ≡ 256 − x。 而对任意 8 位 x,256 − x 正好就是「翻位、加 1」—— 因为翻所有位得 255 − x,加 1 得 256 − x。 算术规则从模代数里自然落出来;翻位法只是一种高效实现。

因为 −x 其实就是一个长得奇怪的「无符号值」,同一套加法电路 既算有符号也算无符号。用普通二进制加法算 42 + (−42):00101010 + 11010110 = 1 00000000。最高位的进位被丢掉 —— 模 256 下,结果就是 0。减法变成「取反第二操作数、再加」。 CPU 没有单独的减法电路。这一条,从某种数法看,是 20 世纪硬件 vs 软件接口里最重要的一个决定。

二进制补码拖动条 —— 同样 8 位、3 种解读bit 7 (sign)2^62^52^42^32^22^12^000000000有符号(int8)0无符号(uint8)0十六进制0x00
全 0。有符号和无符号读一样。
1 / 8
二进制补码本质上就是模算术:在 8 位寄存器里,−x ≡ 256 − x (mod 256)。「翻位 + 1」是算法, 模算术是它成立的原因。一套加法电路同时处理有符号和无符号加法, 是因为位模式本身不知道自己的符号 —— 只有读它的那条指令知道。

坑人的不对称

对 8 位有符号整数,可表示范围是 −128+127。 256 种不同位模式要覆盖这些值,但范围不对称:|−128| 会是 128, 而 8 位有符号根本没有 128 这个值。所有有符号类型都是这个形状:INT32_MIN = −2³¹INT32_MAX = +2³¹ − 1。 经典陷阱是 abs(INT_MIN),在 C/C++ 里是未定义行为(UB), 因为数学上正确的结果装不下。大多数实现就把 INT_MIN 原样返回 ——abs(−2147483648) == −2147483648 —— 悄无声息地破坏所有「假设返回值非负」的下游代码。

有符号溢出是 UB。C 和 C++ 标准把有符号整数溢出定为未定义行为, 不是因为硬件做不了 —— 现代 CPU 模 2n 包裹得好好的 —— 而是因为编译器被允许假设它不会发生,并据此激进优化。for (int i = 0; i < n; i++) 可以在「i + 1 > i永远成立」的假设下被激进向量化,而这只有在溢出不可能的前提下才对。 真实生产事故:SR-71 导航系统的整数溢出、Ariane 5 火箭 1996 年爆炸 (一次 64 位 float 转 16 位 int 溢出)、Pac-Man 第 256 关「kill screen」、 2014 年 12 月 YouTube 播放计数上限(江南 Style 击中 2³¹ − 1 次播放; Google 因此换到 int64)。

Y2038 问题。Unix 时间戳是 32 位有符号的「从 1970 年开始的秒数」。 它们在 2038-01-19 03:14:08 UTC 溢出,翻成 −2147483648 —— Unix 把这解读成 1901-12-13。 所有把时间以 time_t 存的数据库、日志文件、在线协议都有截止日期。 现代代码用 64 位时间,但 legacy 系统还在。

有符号 vs 无符号比较 —— 无声 bug

C/C++ 里,有符号和无符号值比较时,有符号操作数会被隐式转成无符号。int x = −1; unsigned y = 1; if (x < y) ...:x 变成 4294967295,大于 1。 编译器对显式情况会警告,大多数隐式情况漏掉。 修法是保持类型一致 —— 要么都有符号、要么都无符号 —— 并避免在和有符号计数器混用的表达式里用 size_t(它是无符号的)。 这是 C++ 代码评审里最常见的一类无声 bug。

要点。「二进制补码让负数在模加法下表现得和无符号数一样, 所以 CPU 只需要一套加法器就能同时处理两种。代价是不对称 —— INT_MIN 没有正数孪生,这就是 abs(INT_MIN) 未定义、有符号溢出在 C++ 里 是 UB 的原因。另一个陷阱是隐式的有符号转无符号转换:−1 和无符号值比较时,悄悄变成最大无符号值。」

03

IEEE-754 —— 浮点为啥撒谎

有限位无法表示无穷多个实数。浮点接受一种系统化的、有界的撒谎, 换来巨大的动态范围 + 一套硬件 ALU 同时处理 1.0e−381.0e+38

一个 32 位 Float(也叫 Float32、float)把 32 位分成 3 个字段:1 位符号8 位指数23 位尾数。 64 位 Double(Float64、double)用 1 + 11 + 52。 规格化值的公式:

value = (−1)^sign × 1.mantissa(binary) × 2^(exp − bias)

bias = 127(Float32),1023(Float64)

例子(Float32):
  1.0  →  sign=0  exp=01111111  mantissa=00000000000000000000000
  0.5  →  sign=0  exp=01111110  mantissa=00000000000000000000000
  0.1  →  sign=0  exp=01111011  mantissa=10011001100110011001101  (不精确)

偏置(bias)技巧很巧:把指数存为 实际值 + 127,意味着所有合法的存储指数都非负 —— 于是 CPU 可以把两个浮点的位当成普通无符号整数来比较(符号位需要小修一下)。 也意味着整数比较硬件能被复用到浮点比较 —— 1985 年 IEEE-754 标准化时这是真实的考量。

1.mantissa 里的那个前导 1 没被存 —— 它是隐式的。 每个规格化浮点按定义都以 1. 开头(二进制),存它就是浪费 1 位。 边界情况例外:

  • 零:全 0。专属的特殊编码,因为公式套上去会得到 1.0 × 2^(−127)而不是 0。还有 −0.0(符号位置 1、其他全 0)—— 位模式不同,但和 +0.0比较时相等。
  • ±∞:指数全 1、尾数为 0。来自溢出(1.0e38 × 100)或除以 0。 可以比较,通过算术正常传播。
  • NaN:指数全 1、尾数非 0。来自 0.0/0.0sqrt(−1)∞ − ∞。NaN 和任何东西(包括自身)都不相等 —— 每个 IEEE-754 兼容的语言里 NaN == NaN 都是 false。检测 NaN 的标准写法:x != x
  • Subnormal(也叫 denormal):指数为 0、尾数非 0。比最小规格化值还小, 精度降低。它们的存在让向 0 的下溢平滑过渡。大部分 CPU 上它们出了名地慢(比正常慢 10–100 倍), 性能敏感代码常通过 FTZ / DAZ 标志把它们冲掉。
IEEE-754 剖析器 —— Float32、3 个字段、8 个例子值: 0.0signexponent · 8 bitsmantissa · 23 bits00000000000000000000000000000000解码sign = + exp = 0 (unbiased -127)mantissa-fraction = 0.000000 原始(十六进制) = 0x00000000公式= ±0 (special case)实际 Float320.00000000
全 0。最干净的值 —— 也是唯一一个公式不成立的值(不可能是 1.0 × 2^(0−127))。所以特殊处理。
1 / 8
Float64(C/Java/JS 默认)结构一样:位数变成 1 + 11 + 52 而不是 1 + 8 + 23。 偏置变成 1023,最大精确整数变成 253。其他一切 —— 规格化形式、舍入行为、特殊值 —— 跟这里展示的完全一致。

为啥 0.1 + 0.2 ≠ 0.3

十进制 0.1 的二进制展开是 0.000110011001100110011…, 一个无限循环小数 —— 类比十进制里 1/30.333…。 Float64 有 52 位尾数,所以值被截断,存下来的结果跟真实 0.1 差约 5×10⁻¹⁷。0.2 有同样的无限展开、向左移一位二进制位,被同样机制舍入。 两个已被舍入的值相加,结果又是另一个值,这个值不是 Float64 对 0.3 的精确表示 —— 而是稍微大一点:0.30000000000000004

这不是 bug。所有用 IEEE-754 浮点的现代语言(主流的全部:C、C++、Java、JavaScript、Python、Go、Rust……) 都产出完全相同的值,因为它们都遵循标准里的舍入规则。这个数不是错的; 它就是「最接近 0.1 的 Float64 和最接近 0.2 的 Float64 的真实数学和」最接近的 Float64 值。

精度不均匀

浮点值在数轴上不是等距的。它们在 0 附近密、离 0 越远越稀疏。Float64:

1.0 附近:    相邻浮点差约 2.2 × 10⁻¹⁶
1.0e6 附近:  相邻浮点差约 1.2 × 10⁻¹⁰
1.0e15 附近: 相邻浮点差约 0.125(小于 1!)
1.0e16 附近: 相邻浮点差约 2.0(跳过奇数整数)

Float64 里最大精确整数是 253 = 9,007,199,254,740,992。 往上每隔一个整数就无法表示:2^53 + 1 舍回到 2^53。 JavaScript 把所有数(包括整数)都存成 Float64,所以Number.MAX_SAFE_INTEGER = 2^53 − 1 —— BigInt 被加进语言,就是给加密和 ID 处理代码用的。

哪些场景用浮点是错的

金钱。所有浮点入门教程都说,大家还是不听。0.1 + 0.2 ≠ 0.3 在百万级交易上累积起来很可怕; 舍入模式救不了你。用定点整数(分,不是元)或十进制类型 (java.math.BigDecimalpython.decimal.Decimal)。

相等比较。对浮点用 a == b 几乎总是意味着「位模式逐字节相同」, 而这几乎永远不是你想要的。用 epsilon 比较:abs(a − b) < ε, 其中 ε 随涉及的量级缩放。或者用 ULP 距离(units in the last place)做更有数值原则的度量。 Knuth 的比较和 Numerical Recipes 都有整章讲这个。

要点。「Float64 是 sign(1)+ exponent(11)+ mantissa(52)。 每个规格化值的前导 1 是隐式的。指数的极值是特殊编码:±0、±∞、NaN、subnormal。0.1 + 0.2 ≠ 0.3 因为两个输入都是无限二进制小数,进入时被舍入。 Float64 里最大精确整数是 253。永远不要对浮点用 ==; 永远不要用浮点存钱。」

04

字节序 —— 为啥你的字节看起来是反的

CPU 把 32 位整数存成内存里的 4 个字节。问题是哪个字节先放。 这个世界在 1970 年代选了两个互不兼容的答案,把它们固化下来, 从此你就在为这个付代价。

看 32 位整数 0x12345678。它需要 4 个字节 ——0x120x340x560x78 —— 硬件得决定哪个放在最低的内存地址。

小端(x86、ARM 默认、RISC-V、每一台消费级 CPU)
  地址:    0x1000  0x1001  0x1002  0x1003
  字节:      78      56      34      12
                ↑ 最低有效字节在前

大端(「网络字节序」、PowerPC、SPARC、老 IBM)
  地址:    0x1000  0x1001  0x1002  0x1003
  字节:      12      34      56      78
                ↑ 最高有效字节在前

小端赢了。你将来会碰到的所有 CPU —— x86、x86-64、ARM 默认配置、 Apple Silicon、AMD64、Raspberry Pi —— 都是小端。 大端现在主要是历史:遗留的 IBM 大型机、一些老 MIPS 嵌入式变种,以及大多数网络协议的线上格式。

硬件为啥用小端?有一个漂亮的性质:把宽整数截断成窄整数是免费的。 如果你在地址 X 有一个 32 位值,想把它当 uint8 读,就读 X 处的那一个字节 —— 最低有效字节已经在前面了。大端就需要先知道原始宽度,再算偏移量。 还有别的论点(多精度加法的进位从低位向高位流得自然),但截断免费这个是被引用最多的。

网络为啥用大端?历史的意外。ARPANET 早期主机(PDP-10、IBM 360、IMP 路由器)主要是大端, IETF 把这个选择在 1980 年代早期冻进了 IP、TCP、UDP、DNS 协议。 到小端 x86 拿下桌面的时候,线上格式早就部署了。网络字节序(network byte order)就是大端 —— 每个 IP 头、每个 TCP 序号、每个 UDP 长度字段, 不管两端坐着什么 CPU,都按大端在线上跑。

代码里怎么转

BSD 派生的 socket API 提供 4 个你到处都会见的函数:

htons(x)    // host→network, short (16 位)
htonl(x)    // host→network, long  (32 位)
ntohs(x)    // network→host, short (16 位)
ntohl(x)    // network→host, long  (32 位)

在大端主机上它们都是 no-op;在小端主机上(也就是几乎所有时候)它们翻转字节序。 现代代码大多藏在序列化库(Protobuf、Cap'n Proto、MessagePack、JSON)后面 —— 但只要你手工处理 TCP / UDP / IP 头,或者读一个其他架构产出的二进制文件,翻转就回来了。 GCC 和 Clang 的内建 __builtin_bswap16/32/64 给你一条指令的翻转。

字节序还在哪里咬人

  • tcpdump / Wireshark 的十六进制 dump。它们按字节到达线上的顺序显示 (网络 = 大端),所以你代码里 print 成 0x12345678 的 32 位值, 在屏幕上长 12 34 56 78。看起来「正常」—— 但如果你从内存 dump 复制字节 (x86 上是小端),同样这个值就显示为 78 56 34 12,每次都让人懵。
  • 二进制文件的可移植性。大端大型机上 dump 出的 C struct,在 x86 上读回来就是垃圾。 所以每一个值得用的二进制文件格式都会文档化它的字节序。PNG、TIFF、ELF、MIDI、WAV 都写明。
  • 文件头里的 magic number。很多文件格式以 magic number 开头, 它的字节序列不依赖字节序:PNG 以字节 89 50 4E 47(拼成 "\\x89PNG")开头,JPEG 以 FF D8 FF 开头。 这些是字节序列,不是多字节整数,所以两种架构上看起来一样。
  • 位字节序(bit endianness)。在一个字节内部,位顺序在你会碰到的任何 架构上都不会变 —— 位 7 是最高有效位,位 0 是最低。 有些古老文档区分「最高有效位编号为 0」vs「最低有效位编号为 0」, 这是文档约定,不是硬件差异。

对我们正在跟的 curl 请求,URL 字符串没有字节序问题 —— 每个字节就是它自己的值,没有多字节字需要重排。但承载它的 TCP 段有多字节字段: 源端口(16 位)、目标端口(16 位)、序号(32 位)、确认号(32 位)、 窗口大小(16 位)、校验和(16 位)。所有这些都按大端在线上跑, 在每一台小端 CPU 上都需要先翻转,内核才能解读。

要点。「小端(LSB 在前)是每一台消费级 CPU 内存里的格式。 大端(MSB 在前)是每一个标准网络协议线上的格式。 转换函数是 htonl/ntohl/htons/ntohs; 小端主机上它们翻转字节,大端主机上是 no-op。 找出字节序 bug 最简单的方法:十六进制 dump 里的多字节值,看起来跟它的十进制值是反的。」

05

速查表

6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。 先把问题刻进脑子;答题骨架就跟在后面。

为啥 0.1 + 0.2 ≠ 0.3?

0.10.2 在二进制里都是无限循环小数 (类比十进制 1/3 = 0.333…)。Float64 只存 52 位尾数, 值在进入内存的那一刻就被舍入到最近的可表示 Float64。 两个舍入误差在加法里叠加,产出第三个 Float64,这个值比最近的 Float64 对 0.3的表示稍微大一点 —— 0.30000000000000004。 所有 IEEE-754 语言产出完全相同的值。

Float64 里最大精确整数是多少?

253 = 9,007,199,254,740,992。 往上,相邻浮点的间距超过 1,所以连续整数无法都被表示 ——253 + 1 舍回到 253。 这就是 JavaScript 的 Number.MAX_SAFE_INTEGER, 也是 BigInt 被加进语言的原因。

为啥 abs(INT_MIN) == INT_MIN?

二进制补码不对称:n 位有符号下,INT_MIN = −2n−1INT_MAX = 2n−1 − 1。 数学上的 |INT_MIN| 会是 2n−1, 这装不进 n 位有符号。C/C++ 把它定为未定义行为; 大多数实现就把 INT_MIN 原样返回。经典 bug: 二分查找算 mid = (low + high) / 2 在两边都很大时溢出。

有符号 / 无符号比较什么时候会无声出错?

当你在一个表达式里混用、而有符号值恰好是负数的时候。 C/C++ 的隐式转换把有符号操作数提升为无符号:int x = −1; size_t y = 1; (x < y) 结果是, 因为 x 变成 UINT_MAXsize_t(来自 strlenvector::size() 等等)是最常见的祸根。 修法是类型保持一致或加显式转换。

网络字节序和主机字节序的区别?

网络字节序是大端(最高有效字节在前),IETF 在 1980 年代把它冻进 IP/TCP/UDP 协议时 大多数 ARPANET 主机是大端。主机字节序是本地 CPU 用的 —— 你拥有的每一台消费机,都是小端(最低有效字节在前)。 BSD socket API 提供 htons/htonl(host→network)和ntohs/ntohl(network→host)用于 16 位和 32 位转换。

为啥程序员用十六进制看内存 dump?

因为 16 = 24,每个十六进制位精确映射到 4 比特, 没有信息被藏。十进制完全把位结构藏起来(255 告诉不了你哪些位被置上, 而 0xFF 告诉你 8 位全置)。 十六进制还能把一个字节压成两个字符,字节边界一眼就能扫到。 所有调试器、十六进制编辑器、抓包、协议 RFC 都用十六进制,就是这个原因。

Code review 红旗

  • 浮点相等比较。if (a == b),其中 abfloat / double。几乎一定是 bug —— 换成 epsilon 比较或 ULP 距离。
  • abs(x)x 可能是 INT_MIN任何从外部数据算出来的有符号整数,这都是未定义行为。 用 (x < 0) ? -static_cast<unsigned>(x) : x 或者更宽的类型存绝对值。
  • 有符号 / 无符号混用比较、没显式转换。尤其是 for (int i = 0; i < vec.size(); ++i) —— 比较把 i 提升为 size_t,在循环计数器变负之前都没事。 用 size_t 当计数器,或者拿 static_cast<int>(vec.size())来比较。
  • double 存钱。每次乘法都累积舍入误差。 用定点整数(分)或十进制类型(BigDecimalDecimal)代替。
  • 读二进制数据没处理字节序。fread(&header, sizeof(header), 1, fp) 其中 header 有多字节字段、 而文件来自另一个架构。C struct 会成功加载并产出胡说八道。 用显式按字节序列化,或者基于 schema 的格式(Protobuf 等)。