ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

程序是怎样跑起来的:二进制、补码与位运算实战精读

程序是怎样跑起来的:二进制、补码与位运算实战精读 很多人学编程是从语法入门的会写 if/else、for 循环但遇到char c 0xFF打印出 -1、数组越界导致数据错乱、解析文件总是少一个字节这类问题时才会发现自己对“程序到底怎么跑起来的”其实一无所知。《程序是怎样跑起来的》把这一话题放在第 2 章讲的是整个计算机世界里最基础的砖——数据是用二进制数表示的。这本书我反复读过很多遍这一章更是常读常新。它不是简单告诉你 0101 就是二进制而是把二进制、位、字节、补码这些概念讲成了 CPU 真正工作的方式。这篇精读笔记我按自己的理解重新拆了一遍这一章为什么计算机只能选二进制补码是怎么从模运算里“长”出来的十进制怎么快速转二进制以及位运算、二进制除法、gdb 查内存、xxd 看文件这些在实际项目里用得上的内容。适合刚学完语法、想补底层课的同学也适合工作了一两年但一直把“二进制”当抽象概念理解的开发者。看完之后你再打开调试器看内存里那一串十六进制感觉会完全不一样。需要说明的是这篇文章不替代原书。原书配了很多图和例子讲得更循序渐进我这边更偏向把关键概念掰开揉碎再补上实际工程里踩过的坑和工具用法帮你在书的基础上多走一步。1. 为什么计算机非要二进制不可——从本质看它的“不得不”1.1 从红绿灯到晶体管二进制是物理现实先回答那个最基础也最容易被一笔带过的问题为什么偏偏是二进制十进制的计算机难道不是更符合人类习惯吗答案不在数学里在物理里。现代 CPU 中的核心元件是 MOS 管本质上是一个电压控制的开关栅极电压高源漏导通栅极电压低源漏截止。导通和截止就是两个稳定状态对应 1 和 0。你要让一个最小单元表示十个数就得让同一个开关处于十个可区分的电平这在工程上几乎不可想象。再往实里说一点。芯片内部并不是理想的数字世界它本质是一堆模拟信号跑在导线上噪声无处不在电源纹波、信号串扰、温度漂移。3.3V 的 IO 口通常 0.8V 以下判为 02.0V 以上判为 1中间约 1.2V 是禁止区。两态之间留出了足够的噪声余量。如果改成十态把 3.3V 分成十份每份只有 0.33V 的判决窗口一个毫伏级的毛刺就可能让数值跳变。为了可靠性工程师宁可把信号做简单点。这就是二进制在物理层面的胜利。至于逻辑层面二进制还有一个伴生优势布尔代数只有两个值与、或、非可以直接用开关电路实现。人类设计的 CPU 本质上是把几十亿个开关组织成逻辑门再靠时钟让它们按顺序工作。十进制虽然有 10 个符号但没法用简单的开关电路直接实现十进制加法除非先转成二进制再算这反而是多此一举。1.2 程序里的 0x41硬件上到底长什么样理解了开关就可以回答一个很多初学者没想过的问题代码里的整数变量在硬件上到底是什么答案是一组触发器的电平状态。每个触发器锁存一个高或低电平8 个触发器排在一起就是 8 位也就是 1 字节。你写int i 65CPU 做的实际事情是把这一组触发放置成0100 0001的位模式。0x41 这个数字只是人类给这组电平起的名字。这里马上会碰到第一个让人懵的地方字节序。同一个 0x12345678在 x86 小端机器上内存里是78 56 34 12在网络上传输时按大端约定则是12 34 56 78。我第一次用 gdb 查内存看到78 56 34 12还以为程序写错了其实数据一点问题没有。把这张表记住后面调试少走很多弯路存储地址大端字节序小端字节序0x0012780x0134560x0256340x037812这个视角对理解第 2 章特别重要二进制不仅是一串数字还是一组物理电平的抽象。你在代码里写的赋值、比较、位移最终都是对这一组电平的操作。有了这层认识再去看什么叫“内存里的数据”很多抽象概念就落地了。2. 二进制的核心规则从无符号到补码读懂第 2 章真正的重点2.1 十进制转二进制的两种实操方法以及“二进制扩展法”到底在说什么第 2 章的练习题里出现最多的就是十进制转二进制。书上给了除 2 取余法但实际手算时我更推荐另一种思路权值分解也就是很多人口中的二进制扩展法。两种方法都写一下你挑顺手的用。除 2 取余法把 173 不断除以 2记录余数再倒序读。173÷286 余 186÷243 余 043÷221 余 121÷210 余 110÷25 余 05÷22 余 12÷21 余 01÷20 余 1倒着读10101101。这个方法适合写程序本质上就是不断分离最右一位。权值分解法扩展法先把 2 的幂写一排256、128、64、32、16、8、4、2、1。从大到小逐个试173 减 128 得 45128 位写 145 减 32 得 1332 位写 113 不够减 1616 位写 013 减 8 得 58 位写 15 减 4 得 14 位写 11 不够减 22 位写 01 减 1 得 01 位写 1。从 128 位往下写1 0 1 0 1 1 0 1和除 2 取余完全一致。我在实际手算时几乎只用第二种因为不用倒序而且能顺带验证 12832841173算完心里踏实。如果你是第一次学建议两种都写一遍体会它们其实是一个过程的两种视角。用程序验证也很简单def to_bin(n): bits [] while n: bits.append(str(n 1)) n 1 return .join(reversed(bits)) or 0 print(to_bin(173)) # 10101101 print(bin(173)) # 0b10101101十六进制也是这一章绕不开的东西因为 1 位十六进制恰好对应 4 位二进制两者可以逐位互转0x3C 就是 0011 1100。熟练之后调试时看十六进制比直接看二进制快得多。0xFF 是 2550x80 是 1280x40 是 64这些对应关系建议直接背下来做协议分析和看崩溃日志时能省大量时间。2.2 补码为什么是“取反加一”——从模运算重新理解负数负数是二进制里最劝退的话题书里直接给出结论补码等于取反加一。很多人背会了结论但不知道它从哪来。我换个角度讲你马上会明白。假设只用 4 位二进制能表示 0~15模是 16。在模 16 的世界里13 和 -3 是同一个东西因为 13 3 16 ≡ 0mod 16。所以加法 5 13 1818 对 16 取模得 2正好等于 5 - 3 2。也就是说4 位系统里用 13 表示 -3减法就变成了加法。为什么 13 的二进制正好是 1101而 3 是 0011因为 13 16 - 3 (16 - 1 - 3) 1。16 - 1 是全 11111减去 0011 得到 1100这就是按位取反再加 1 得到 1101这就是“取反加一”的来源。把这句话翻译成公式n 位下x 的补码 2^n - x (2^n - 1 - x) 1而 2^n - 1 - x 就是对 x 按位取反。用时钟类比更直观时钟只有 12 个刻度模是 12。9 3 12显示成 09 - 3 6等价于 9 9 1818 对 12 取模也是 6。这里的 9 既是 9也是 -3。二进制补码就是一台二进制钟区别只是刻度从 0~11 变成了 0~2^n-1。在 C 里试一下printf(%x, -1)32 位机器输出 ffffffff。这不是“负数的十六进制长这样”而是 -1 的补码位模式恰恰是全 1。只有理解了位模式与数值的区分你才会明白为什么同样的 0xFF按 %x 打印是 ff按 %d 打印却可能是 -1。这个理解还解释了一个关键好处补码体系里只有一个零而原码会有 0 和 -0 两个零补码让 CPU 只需要加法器就能完成加减法硬件极简。这一小节是第 2 章最重要的概念没有之一。2.3 位、字节与整数范围为什么 char 的范围是 -128~127位是二进制的一位字节是 8 位。这本书第 2 章很快会进入一个现实问题一个整数类型能装多大的数。直接看表类型位宽无符号范围有符号范围char80 ~ 255-128 ~ 127short160 ~ 65535-32768 ~ 32767int32常见0 ~ 4294967295-2147483648 ~ 2147483647long long640 ~ 18446744073709551615-9223372036854775808 ~ 9223372036854775807为什么有符号范围不对称因为在补码体系里最高位为 0 的位模式表示 0 和正数最高位为 1 的位模式全部表示负数。8 位一共 256 个位模式一半给非负数0~127另一半给负数-128~-1所以正数只能到 127负数却可以到 -128。这就是为什么 INT_MIN 取绝对值仍然溢出——它是一个经典的边界坑。实战中这个表格最常用到的地方是协议解析。一个 1 字节字段如果用 signed char 接收0xFF 会被读成 -1用 unsigned char 接收则是 255。长度、计数这类字段几乎都应该用无符号类型方向搞反整个程序跑出的数字全都不对。另外补一句1KB 不是 1000B而是 1024B。这个 1024 2^10同样是二进制世界带来的习惯。文件系统、内存容量、网络带宽这几种单位混在一起最容易算错区分 MiB 和 MB 也是从这个知识点延伸出去的。3. 真实项目中的二进制位运算、调试与定位3.1 位运算把二进制变成程序员工具箱位运算就是直接操作二进制的位学了二进制之后最自然的延伸。先过一遍基本操作unsigned int a 0b1100; unsigned int b 0b1010; a b // 1000 a | b // 1110 a ^ b // 0110 ~a // 0011在 int 位宽下是 ...110011 a 1 // 11000 a 1 // 0110工作中最常见的用法是标志位。一个 int 当 32 个布尔开关用#define FLAG_DEBUG (1U 0) #define FLAG_CACHE (1U 1) #define FLAG_SYNC (1U 2) unsigned int flags 0; flags | FLAG_DEBUG | FLAG_SYNC; // 打开两个开关 flags ~FLAG_DEBUG; // 关闭 DEBUG if (flags FLAG_SYNC) { /* ... */ } // 判断有没有打开如果改用 3 个 bool不仅占用更大也没有办法一次比较多个状态。顺便说一句嵌入式里读寄存器状态用的也是这套思路寄存器某几位代表某个状态用掩码提取。第二个常见场景是从整数里拆字段。比如 RGB 颜色 0x3CA6F5unsigned int color 0x3CA6F5; unsigned int r (color 16) 0xFF; unsigned int g (color 8) 0xFF; unsigned int b color 0xFF;写起来并不复杂本质就是把需要的位对齐到低 8 位再用 0xFF 把其它位清零。第三个是内存对齐。向上取整到 8 的倍数size_t aligned (size 7) ~7U;加上 7 是为了让任何余数都产生进位再按位取反 7 把低 3 位清零。这类技巧在底层开发里随处可见。位运算最大的坑在右移。C 标准里对负数右移是“实现定义”的主流平台都会做算术右移补符号位意味着int x -16; x 1得到 -8而不是 8。如果你希望逻辑右移就用无符号类型。Java 对此做了明确区分是算术右移是逻辑右移。看到位运算时先确认类型再下结论这样最不容易出错。3.2 调试时怎么“看”二进制gdb、抓包与 hexdump纸上谈兵没用调试器里亲眼看到位模式才算真的理解。先用 gdb 调试一个最简 C 程序int main(void) { unsigned int var 0x12345678; char ch 0xFF; return 0; }gdb 里打断点之后(gdb) p/x var $1 0x12345678 (gdb) x/4bx var 0x7fffffffe4b0: 0x78 0x56 0x34 0x12 (gdb) p/x ch $2 0xff (gdb) p/d ch $3 -1注意两个信息一是内存里的小端字节序所以看到的顺序是78 56 34 12二是 ch 明明是 0xFF按十进制打印却是 -1这就是有符号类型解释位模式的结果。gdb 的x/4bx是 examine 内存的老命令b 表示按字节查看4 表示连续看 4 个单元x 表示十六进制显示。看结构体数组时直接x/16bx arr就行。文件层面的排查用 xxd 或 od。以 PNG 文件为例xxd logo.png | head -2 00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR看到89 50 4E 47就能确认文件头正确。文本文件打开乱码时先看前三个字节是不是EF BB BFUTF-8 BOM或FF FEUTF-16 LE。文件头、BOM、魔数都是二进制数据的指纹xxd 就是看指纹的工具。网络抓包看到的更是实实在在的二进制。Wireshark 里的 Hex 窗格一帧一帧摆在那里Ethernet II 帧开头是目的 MAC、源 MAC、类型字段。抓包不神秘本质就是把你发出去的那组电平序列重新整理给人看。遇到对不上的字段先怀疑一件事字节序对不对。3.3 “二进制除法”到底怎么算为什么比十进制除法更简单除法和加法、减法不一样在硬件上要贵得多。先看手算10110 ÷ 10。01011 --------- 10 ) 10110 10 --- 0110 -10 --- 10 -10 --- 0商是 01011余 0和十进制 22 ÷ 2 11 一致。观察这个过程每一步只看“当前余数能不能减去除数”能减商 1不能减商 0。整个过程只有比较、减法和移位不需要乘法口诀表这就是二进制除法的可爱之处。硬件和软件里常用的是“移位减法”。每次从被除数取一位补到余数末尾然后判断够不够减。用 Python 模拟一个无符号除法def binary_division(a, b): if b 0: raise ValueError(division by zero) q, r 0, 0 n a.bit_length() for i in range(n - 1, -1, -1): r (r 1) | ((a i) 1) if r b: r - b q | (1 i) return q, r print(binary_division(0b10110, 0b10)) # (11, 0)每轮一个移位、一个比较、一个减法n 位就要做 n 轮所以除法器比加法器慢得多。编译器也会对常数除法做优化x / 8 在无符号场景可以直接变成 x 3。但有符号除法和右移并不完全等价因为取整方向不同这就是为什么优化和语义要分开看。顺带解释一个搜索热词“linux 二进制安装 mysql8.4.11”。这里的二进制指官方已经编译好的可执行程序包相对源码安装省得你自己 gcc make。可执行文件本身当然是一堆机器码也就是二进制的集合。这恰好是《程序是怎样跑起来的》整本书在讲的事你写的高级语言代码最终会被翻译成 CPU 直接执行的二进制机器指令而二进制安装包必须匹配 CPU 架构x86_64、aarch64因为不同架构的机器码完全不同。4. 现在回看第 2 章三个最容易被忽略的坑4.1 溢出不一定报错回绕、未定义行为与安全二进制位模式是有限的一旦超出范围结果不会报个错提醒你而是直接回绕或产生未定义行为这是新手最容易忽略的点。无符号数溢出是定义良好的回绕uint8_t 的 255 1 0。我见过一个死循环for (uint8_t i 250; i ! 300; i) { // 永远出不来 }i 到 255 后下一次变成 0条件永远为真。正确写法是明确循环次数或者用 i 300 配合其它终止条件。C 和 C 里有符号整数溢出是未定义行为。编译器会假设“int 1 永远大于 int”并基于这个假设做优化于是你在调试版里看到的“正常”可能在开 -O2 后完全变样。常见出错点是 INT_MAX 1、时间戳累加、金额计算。Java 采取另一套策略int 溢出会安静回绕Integer.MAX_VALUE 1 Integer.MIN_VALUE同样坑。日常开发推荐用更大范围的类型或者显式做溢出检查不要赌边界。这一章如果只看结论会以为二进制就是“0101”但它真正的价值是让你理解数值范围从哪来一个 n 位二进制数能表达的有限状态是 2^n 个超出范围后一切皆有可能。工程上所有和长度、数量、时间相关的计算都要先问自己这个变量的极值是多少。4.2 类型提升与符号扩展char 为什么变成了 -1第二个常见困惑char c 0xFF; printf(%d, c)结果为什么是 -1用位模式解释就很简单c 的 8 位是 11111111。当它参与表达式时C 会把它提升为 int无符号 char 直接补 0变成 0x000000FF也就是 255有符号 char 做符号扩展最高位是 1于是补 1变成 0xFFFFFFFF也就是 -1。看一段完整代码#include stdio.h int main(void) { unsigned char uc 0xFF; signed char sc 0xFF; printf(uc %d\n, uc); // 255 printf(sc %d\n, sc); // -1 return 0; }这个坑在解析二进制协议时非常致命。你从缓冲区取一个字节如果缓冲区是 char*那么p 可能为负正确做法是用 uint8_t或 unsigned char*从一开始就约定类型。我曾经解析一个图片尺寸字段解析出来是负数整个程序直接崩溃最后发现就是 signed char 的锅。写完代码之后用 -Wall -Wextra 编译也能帮你压掉一部分这种问题。4.3 大小端之外二进制和字节序在协议中的埋伏第 2 章讲完二进制位模式紧接着就是字节序问题。同一个 4 字节整数 0x12345678在小端机器上是 78 56 34 12网络协议明确规定用大端叫网络字节序。所以网络编程里才有 htons、htonl、ntohs、ntohl 这一族函数负责在主机序和网络序之间转换。实际抓包时你看到端口 443 的二进制往往是 01 BB 而不是 BB 01。如果抓包工具恰好按主机字节序解读你会以为端口变成了 44345这就是字节序造成的错觉。写解析器前先确认协议的字节序约定再决定用 struct.unpack 的 I 还是 I。大小端还影响本地文件格式。PE 可执行文件头很多字段都是小端PNG、JPEG、TCP/IP 这些格式则是大端惯例。我的经验是看到二进制数据先别急着算数值第一步判断这个数据要求什么字节序第二步才谈怎么解析。很多“数据错乱”其实不是数据错乱是解释方式错乱。5. 常见问题与排查技巧实录5.1 与“二进制数”有关的六个常见坑把平时最容易踩的坑整理成一张速查表遇到类似问题可以直接翻现象原因解决办法int x 010;以为是 10结果是 8C/C 以 0 开头是八进制字面量不要写前导 0用十进制或 0b0b1010在老编译器上报错C23、C14 才正式支持 0b 字面量老 MSVC 不支持用十六进制 0xA 代替unsigned short a 0xFFFF; a后变成 0无符号整数回卷检查边界改用更宽类型x 1和x / 2对负数结果不一样算术右移向下取整整数除法向零取整明确业务需求再决定用哪个解析协议时 0xFF 被当成 -1char 可能是有符号类型发生了符号扩展统一用 uint8_t / unsigned char文件里多出 0x0D 字节Windows 文本模式把 \n 转成 \r\n以二进制模式打开文件如 rb这些坑单独看都很小但每一个都真实发生过。尤其是最后一个我做过一个跨平台文件解析工具在 Windows 上读 Linux 生成的文本每次长度都对不上最后发现是文本模式和二进制模式的锅。凡是要处理原始字节流的场景一律用二进制模式这是铁律。5.2 用 xxd 和 gdb 快速判断“这段数据是什么”xxd 就是把文件按十六进制展开的命令前面已经演示过。用它判断数据是什么常见做法是看前几个字节因为很多格式都有魔数文件开头含义89 50 4E 47PNG 图片7F 45 4C 46ELF 可执行文件25 50 44 46PDF 文档EF BB BFUTF-8 带 BOM 文本FF FEUTF-16 LE 文本gdb 这边的用法再梳理一遍p/x 变量看变量的十六进制值x/4bx 变量看变量所在内存的原始字节。看结构体时直接x/32bx 结构体常见于排查内存破坏或填充字节问题。我之前定位一个“结构体大小对不上”的 bug就是用 x/8bx 看内存布局发现编译器插入了 padding 字节把结构体实际大小撑大了。5.3 一个练感觉的小项目手写“二进制查看器”最后建议你用 Python 写一个 20 行的二进制查看器把任意文件的前几十个字节同时显示成十进制下标、十六进制、二进制和 ASCIIwith open(somefile.bin, rb) as f: data f.read(32) for i, b in enumerate(data): ch chr(b) if 32 b 127 else . print(f{i:04d} 0x{b:02x} {b:08b} {ch})输出长这样0000 0x89 10001001 . 0001 0x50 01010000 P 0002 0x4e 01001110 N 0003 0x47 01000111 G拿 PNG 文件跑一下再看一个 ELF再看一个文本文件三者的区别一目了然。这个练习不需要任何框架几行代码就能帮你把二进制、十六进制、ASCII 三种视角焊在一起。以后在调试器或抓包工具里看到十六进制你会下意识在脑子里补出对应的二进制速度和准确率都会明显提升。最后说一个我自己的习惯调试任何跟字节有关的 bug我都先把值在脑子里转成十六进制而不是直接看二进制——十六进制和二进制一一对应但长度只有四分之一而且和内存 dump、抓包窗口的显示完全一致。比如看到 0x80我第一反应是 1000 0000知道它作为有符号 char 是 -128作为无符号数是 128。这种条件反射不是一天炼成的把第 2 章多读两遍再用上面的小程序随便打开几个文件看几天自然就有了。如果你也想把《程序是怎样跑起来的》读透建议从这一章开始拿这本书配合调试器一起看。看完一章动手写一段能打印变量内存的代码比单纯记结论有用得多。祝你在 0 和 1 的世界里玩得开心。
返回列表