如果只让我选一章作为《程序是怎样跑起来的》全书的地基我会毫不犹豫选第2章“数据是用二进制数表示的”。当年我第一次读这本书时这一章只花了一个晚上以为看懂了没想到后面读运算、读内存、读位操作时反复翻回来的都是这一章。这篇精读版就当陪你把第2章从头捋一遍为什么计算机里只有0和1二进制和十进制怎么互转负数为什么靠补数表示小数为什么会有误差以及程序员为什么天天跟十六进制较劲。适合正在啃这本书的初学者也适合学过编程但一直没把“二进制”这层窗户纸捅破的同学。这一章表面讲的是“数怎么表示”实际上讲的是“计算机怎么看待世界”。别急着背转换口诀先把下面这个物理事实记住后面所有内容都是它的延伸。1. 这一章解决的核心问题为什么计算机世界里只能看到0和11.1 一个IC引脚能告诉你的只有“高”和“低”计算机内部最核心的部件是集成电路也就是常说的IC。IC上排满了引脚引脚之间靠电压传递信号。在实际电路里我们并不会把电压精细地区分成十档、百档而是只认可两种稳定状态一个接近0V的低电压和一个高于某个阈值的高电压。低电压记作0高电压记作1这就是二进制的最底层来源。你可能会问为什么不做成十进制让引脚直接传0到9答案是物理世界不允许。电压在传输过程中会受到干扰要让电路识别出十种不同的电压等级对元器件精度要求极高而且噪声一来就容易误判。二值电路就简单得多开关管要么导通要么截止电平要么高要么低中间状态不需要。一个晶体管就能表示一位整机电路可以做得又便宜又稳定。所以二进制不是程序员的审美偏好而是硬件特性决定的物理现实。整个计算机世界本质上就是无数个只认“高/低”的小开关在协作。1.2 这一章打的地基后面每一章都要用《程序是怎样跑起来的》前几章的安排很有意思上一章讲程序执行的大框架CPU从内存读指令、执行、写回这一章则把镜头拉近看内存里的数据到底长什么样。没有这一章后面讲加减法运算、讲数组与内存寻址、讲字符编码时很多概念都会悬在空中。举个例子。你在线上排查一个协议报文抓包工具里显示一段十六进制数据比如7B 22 75 73 65 72 22 3A 20 22 61 64 6D 69 6E 22 7D。如果你完全没有二进制直觉看到的就是天书如果你知道一个十六进制字符对应4个二进制位一段字节可以进一步拆成位你就能通过查ASCII表把这段数据还原成{user: admin}。这种能力不是靠背而是靠这一章建立起来的“数据就是二进制位序列”的观念。所以这篇总述我也会按原书的思路走先讲二进制与十进制的转换再讲负数和补数接着讲小数和浮点数最后聊聊数据量单位、十六进制这些随时会碰到的东西。每一节都会补一些原书简洁带过、但实际用的时候一定会踩坑的细节。2. 位权、短除法、拆数法二进制和十进制互转的实用手法2.1 位权把十进制的习惯搬到二进制我们从小用十进制看到427脑子里会拆成“4个百、2个十、7个一”也就是4×100 2×10 7×1。这里的100、10、1就是十进制每一位的“位权”从右往左依次是10⁰、10¹、10²。二进制完全同理只是位权从10的幂换成了2的幂。从右往左第一位位权是2⁰1第二位是2¹2第三位是2²4第四位是2³8。所以二进制1101代表的十进制数就是1×8 1×4 0×2 1×1 13。我在纸上列个常用的小表第几位4321位权8421二进制11011101熟练之后看到1010就会条件反射8加2等于10看到1001就是9。口算的关键不是去列算式而是把它当作“哪些位权上放了一个筹码”。2.2 短除法十进制转二进制最稳的算法反向转换从十进制到二进制最不容易出错的方法是连续除以2把每次的余数记下来最后从下往上读。拿13举例13 ÷ 2 6 余 16 ÷ 2 3 余 03 ÷ 2 1 余 11 ÷ 2 0 余 1把余数从最后一次向前读得到1101。这个结果和前面位权展开的结果一致。为什么余数要倒着读因为除法过程中第一次除2得到的余数是原数最低位2⁰位的值第二次是2¹位依此类推。我们习惯从左往右写二进制最高位在左所以必须先得到最低位最后才拿到最高位自然要倒过来。初学的人最容易栽在这里正着读成1011算出来完全不是原来的数。如果在代码里手动实现思路也是同一个循环void print_binary(int n) { // 从高位到低位输出方便看 for (int i 7; i 0; i--) { printf(%d, (n i) 1); } }用除法和位运算都能拿到每一位本质都是“不断把最低位摘下来”。2.3 拆数法用2的幂组合出任何数字除了短除法我更推荐初学者练一种“拆数法”它更接近大脑的直觉把十进制数拆成若干个2的幂相加。比如200先找不超过它的最大2的幂是128200减128等于7272里能拆出6472减64等于88本身就是2³。所以200 128 64 8。对照8位二进制的权值表128643216842111001000得到二进制11001000。这个方法比短除法快尤其在位数不多的场景下多练几次就能心算。为什么我强调要练到条件反射因为真实的开发场景里你看到的往往是反向需求权限位0b101是什么含义子网掩码255.255.255.0二进制是什么样子文件权限755为什么是八进制这些都建立在“看到二进制位立刻知道它由哪些权值组成”的基础上。比如简单的权限标记PERM_READ 0b100 # 4 PERM_WRITE 0b010 # 2 PERM_EXEC 0b001 # 1 perm 0b101 if perm PERM_READ: print(可读) if perm PERM_EXEC: print(可执行)0b101就是5你一眼能看出它含读和执行的权限而不是通过算5模几来猜。这种能力没有技巧只有练。3. 负数的真面目是补数从“取反加一”到有符号范围的闭环3.1 “负号”去哪儿了很多人第一次接触补数时都会困惑为什么要绕这么大一圈直接存一个负号不行吗问题是一个存储单元里只有一串0和1没有多余的地方写“-”这个符号。就算你用ASCII码存一个负号运算器也不可能聪明到先看符号、再做减法。计算机里做减法是一件很麻烦的事情但做加法很容易。于是前辈们想到一个办法用另一个正数来代替负数让“减x”变成“加某个数”最后的效果一致。《程序是怎样跑起来的》里用了时钟例子。我在这里换个说法假设现在只有0到11这12个刻度相当于模12运算。要让4点钟倒退3小时变成1点可以逆时针拨3格你也可以顺时针拨9格结果同样是1点。这里9就是“负3”在模12下的替身。二进制里的思路完全一样n位二进制数的运算天然是模2ⁿ的一个负数x就可以用2ⁿ - x这个正数来代替。这个替身就叫“补数”。3.2 “取反加一”为什么成立补数怎么算口诀是“取反加一”。很多教程只说口诀不说为什么导致读者只能死记。我们推导一下。设x是一个n位二进制数把x每一位取反后得到的是(2ⁿ - 1) - x因为n位全1的数字是2ⁿ - 1取反相当于把x从“全1”里减掉。在这个结果基础上加1就得到(2ⁿ - 1) - x 1 2ⁿ - x。这正是我们要的补数。看一个8位的例子。x 3二进制00000011。取反得11111100加1得11111101。这就是-3在8位下的补数。验证一下00000011 (3) 11111101 (-3的补数) ----------- 100000000结果产生一个9位的100000000但在8位机器里最高位被丢弃剩下00000000正好是0。可以看到补数的构造目的就是让“x 补数”产生最高位进位并自灭于是加法结果归零完美模拟了x (-x) 0。3.3 符号位、数值范围和溢出的闭环采用补数表示负数后会出现一个非常自然的规律最高位为0的数恰好是非负数最高位为1的数恰好是负数。这个最高位就被称为符号位。注意它不是单独存在的“负号”而是补码体系内统计出来的特征只是刚好能当符号用。8位有符号数的范围因此变成-128到127而不是很多人直觉以为的-127到127。原因很简单0已经有00000000一种表示按补数规则推导10000000这个组合不再表示“负0”而会被定义为-128。也就是说负数比正数多一个。补码还会导致一个奇妙的溢出闭环8位有符号数里127加1会变成-128。这不是bug是有限位数数学的必然结果就像时钟走到11点再走1小时变成0点。在CPU层面这个现象会体现在标志位上在C语言层面它对应有符号整数溢出的未定义行为或者无符号数的回绕。我在实际工作中见过不少新手在这里踩坑。一个典型的例子signed char a 0x80; // 常见实现下 a 是 -128 unsigned char b 0x80; // b 是 128同样是0x80只看字节内容看不出正负正负取决于你把它当作什么类型。如果你把一个signed char赋值给一个32位整数还牵扯到符号扩展-1会变成0xFFFFFFFF而不是0x000000FF。很多人排查半天发现是符号扩展把数据撑大了其实就是这一章没学透。再看一个Python的例子。很多人会奇怪为什么bin(-5)输出的是-0b101好像负数在内存里只是“二进制数前加个负号”。其实Python内置的bin只是按数学形式展示内存里的-5是补码形态想看低8位的补码可以这样print(hex((-5) 0xFF)) # 0xfb即251251这个数怎么来的8位模256下256 - 5 251写成二进制是11111011。所以-5的低8位就是251和“取反加一”公式完全一致。把这个过程亲手算一遍比背十遍口诀都管用。4. 小数在计算机里是“特困生”浮点数结构、0.1不精确与判断习惯4.1 二进制小数能精确表示的其实很少整数可以用二进制完美表示小数就没那么幸运了。二进制小数点右边的位权是2的负次幂第1位是1/2第2位是1/4第3位是1/8以此类推。所以二进制0.101等于十进制的0.5 0.125 0.625这个没问题。问题在于十进制里常见的0.1、0.2、0.3换成二进制后是无限循环小数。比如0.1二进制是0.00011001100110011...永远写不完。这就像在十进制里写1/3只能写成0.333...一个道理。计算机的存储位数是有限的总要在某个地方截断于是误差就诞生了。这一章的标题是“数据是用二进制数表示的”看到标题就应该条件反射不是所有十进制数都能用二进制精确表示。整数靠补数规则解决正负问题小数却根本没有完美方案只能靠“近似存储”。4.2 浮点数结构符号、指数、尾数为了在有限位数里尽量扩大数值范围计算机采用类似科学计数法的思路任何一个数都可以写成±1.xxx × 2ᵉ的形式其中1.xxx是尾数e是指数。现代计算机普遍采用一套浮点数标准单精度浮点数用32位双精度用64位它们的位分配大致这样精度总位数符号位指数位尾数位指数偏移量单精度321823127双精度64111521023符号位决定正负指数位本来可正可负存储时统一加一个偏移量让它变成非负数这样比较大小、排序都方便尾数位存储有效数字。因为规格化后尾数最高位总是1这个1就不用存了于是23位尾数实际能表示24位精度这就是单精度浮点数大约能精确到7位十进制有效数字的来源。很多初学者第一次看到0.1 0.2不等于0.3会怀疑是自己代码写错了。其实不是是0.1和0.2在二进制里本身就是无限循环小数被截断后再相加误差被放大了而已。4.3 0.1 0.2 的经典现场与处理经验在绝大多数编程语言里跑下面这行代码结果都不是0.3print(0.1 0.2) # 0.30000000000000004这个现象几乎每个程序员都会遇到。解决方式主要有三种。第一不要用直接比较浮点数而是比较绝对差是否小于一个很小的容差def close(a, b, eps1e-9): return abs(a - b) eps第二在涉及金额、计费、库存数量的场景不要用浮点直接用整数最小单位比如把“元”换算成“分”来存。第三如果必须用十进制精确运算就选语言提供的小数类型比如Python的decimal而不是用二进制浮点数硬扛。提示浮点数误差不是某个语言或某个库的bug而是“有限二进制位表示十进制小数”的必然代价。写代码时提前想清楚“这里到底能不能用浮点”能省掉以后很多线上纠纷。这本书第2章并没有把IEEE的浮点位布局全部展开但我觉得读到这里就应该建立意识计算机里的整数是精确的小数是近似的。有了这个前提后面学数值计算、学序列化、学数据库存储时很多“奇怪bug”就能秒懂。5. 数据量的单位之争字节、KB/KiB与硬盘容量差异的来龙去脉5.1 从最小单位到内存地址二进制的一位叫比特bit8个比特组成一个字节Byte。为什么偏偏是8位这跟字符编码的历史有关。早期机器用过6位、7位后来为了让一个字节能容纳更多字符并且正好覆盖ASCII的128个字符外加扩展位8位就逐渐成了事实标准。8位能表示256种组合对基本拉丁字母、数字、符号来说足够用了。内存地址也是二进制数。地址按字节编号如果CPU有32根地址线能表示2³²个地址也就是4GB。这就是老一代“32位系统最多只能识别4GB内存”说法的根源。你不需要背这个数字只要知道2的30次方是1G2的32次方就是4G就够了。到了64位地址空间大得离谱我们反而很少再去算上限。5.2 1KB到底是1000还是1024顺着进制讲这里有个老生常谈但必须谈的坑1KB到底等于多少字节在纯粹的二进制世界里2的10次方等于1024所以“1KB 1024字节”在计算机内部非常自然。但硬盘厂商在标注容量时更愿意用十进制1KB按1000字节计算。这就导致你买一个标称500GB的硬盘插到系统里看容量只有大约465GB左右。不是厂商偷了你的容量而是两边对“GB”的定义不同。为了结束这种混乱后来的标准把二进制单位单独命名1KiB 1024字节1MiB 1024KB而KB、MB继续作为十进制单位。我列个对照表名称含义数值KB千字节十进制1000字节KiB二进制千字节1024字节MB兆字节十进制1000²字节MiB二进制兆字节1024²字节GB吉字节十进制1000³字节GiB二进制吉字节1024³字节搞清这些不是为了考试而是因为单位换算错误真的会在生产环境里出事故。5.3 单位造成的实际事故我见过一个线上日志系统磁盘告警阈值按GiB算但配置文档里写的是GB。单看一个文件差2.4%不致命但日志量一多整个磁盘分区在告警阈值还没触发时就已经满了最后把服务拖垮。排查下来根因就是有人把1024和1000当成一回事。还有一次文件上传服务的上限写死在代码里为100000000字节前端文案却显示“100MB”用户反馈“明明没到100MB怎么传不了”。客服查了半天最后发现一个是十进制兆一个是MiB差了大概4.5%。提示代码里保存尺寸、容量、长度时永远只用一个权威单位我习惯全部用“字节”显示层面再做单位转换。接口文档里必须写明单位并且区分bit和Byte。网络带宽的Mbps和文件传输的MB/s是不同概念前者是兆比特每秒后者是兆字节每秒差8倍。存储领域还有更细的“块单位”传统硬盘扇区是512字节现代4K扇区是4096字节SSD的页、块又自有一套。学这一章不用钻那么深但脑子里要有个意识很多“单位”只是从二进制位数衍生出来的不同尺度换算前先问一句“这里到底是1000还是1024是bit还是Byte”。6. 十六进制是程序员的二进制速记法拆字节、拼字节与常用值6.1 为什么程序员用十六进制而不是直接看二进制二进制虽然真实但8位写出来已经是一长串32位整数更是眼睛都要看花。程序员需要一种更紧凑、又能和二进制快速互译的表示法十六进制正好胜任。一个十六进制字符能表示0到15也就是4个二进制位。所以一位十六进制对应4位二进制两个十六进制字符刚好对应一个字节十六进制二进制00000100012001030011401005010160110701118100091001A1010B1011C1100D1101E1110F1111比如0x3A拆成两个十六进制字符3对应0011A对应1010所以0x3A就是00111010。一个字节正好两位十六进制因此内存查看、网络抓包、寄存器调试里数据几乎一律以十六进制呈现。你甚至不需要做任何算术只要把十六进制字符逐个替换成二进制位就行。八进制也有过它的时代它一位对应3个二进制位在Unix文件权限chmod 755这类场景里仍然出现。但现在的调试工具、协议描述、哈希摘要普遍使用十六进制主要就是因为按字节分组最直观。6.2 用位运算拆字节/拼字节把二进制用起来学这一章最好的落地练习是拆字节和拼字节。假设有一个32位整数0x12345678我想把它拆成4个独立的字节uint32_t x 0x12345678; uint8_t b0 x 0xFF; // 低字节0x78 uint8_t b1 (x 8) 0xFF; // 0x56 uint8_t b2 (x 16) 0xFF; // 0x34 uint8_t b3 (x 24) 0xFF; // 0x12这里的 0xFF就是“把高位全部清零只留低8位” n是“把第n位之后的内容挪到最低位”。反向拼回去也很直接uint32_t y b0 | (b1 8) | (b2 16) | ((uint32_t)b3 24);这个操作在解析二进制文件头、网络协议报文、自定义数据结构时每天都在发生。第2章只要求你理解“位、字节、十六进制”三者是同一件事的不同视角不要求立刻精通所有位运算但把这个例子动手敲一遍后面的章节会轻松很多。6.3 几个高频十六进制值看到不慌最后送一张我自己常用的“高频值速查表”都是出镜率极高的组合十六进制二进制无符号十进制有符号8位含义常见场景0x000000000000空字节、字符串结束符0x010000000111最低位置位0x0F000011111515低4位掩码0x7F01111111127127ASCII DEL、最大正数0x8010000000128-128最高位置位0xFF11111111255-18位全1同一个十六进制值在不同长度、不同符号类型下解释结果完全不同。这就是为什么我不能只记数字要回到“它二进制长什么样”去想。比如看到0xFF脑海里应该立刻浮现8个1而不是死记255或-1。想自测的话试着把0x8F写开10001111。按无符号解释是143按8位有符号解释是-113。能流畅算出来这一章就真过关了。读完整章我自己最大的体会是与其说这是一章“数学课”不如说它是一次世界观的转变——从“数字就是十进制里那个数”转变成“数据是一串二进制位解释方式取决于上下文”。整本书后面讲运算、讲字符串、讲内存全都长在第2章这棵树上。如果你时间有限我建议优先把补数那段亲手推五遍它是我见过初学者最卡壳、却最值得花时间的知识点。