☰
程序是怎样跑起来的?精读第2章:二进制如何支撑整个计算机世界
2026/10/5 4:47:39 网站建设 项目流程

很多人学编程是从“hello world”开始的,但真问一句“计算机凭什么认识这些代码”,八成要卡壳。我看了好几本入门书,直到遇到《程序是怎样跑起来的》这本,才把脑子里那些零散概念串成线。这本书第1章讲CPU的工作方式,第2章就直接捅到最底层——数据是用二进制数表示的。千万别小看这一章,它是整个计算机知识体系的承重墙:不理解二进制,后面的内存寻址、文件格式、网络协议、图像编码,学起来全是隔着一层纸。这篇博客把第2章的核心内容和我这些年实际用到的东西揉在一起,做一份能直接落地的“精读版总述”,适合正在学基础、准备补计算机功底的开发者。

1. 为什么程序偏偏要用二进制

1.1 不用十进制,是物理电路的现实约束

很多人第一次听说“程序里所有数据都是0和1”时,都会问一句:为什么不用十进制?明明人类从小就习惯十个数字,计算机要是能直接处理0到9,不是更省事吗?

答案不在数学里,在硬件里。计算机的运算核心是CPU,CPU由数以亿计的晶体管构成。晶体管这东西,本质上就是一个受电压控制的开关,只有两种稳定工作状态:导通或者截止。所谓“导通”,就是电路通了,对应高电平;所谓“截止”,就是电路断了,对应低电平。要做数字电路,最自然、最可靠的方式就是拿这两种状态来编码信息:高电平记作1,低电平记作0。

如果非要让电路直接表示十进制的10个数字,就得把电压划分成10个不同的区间,每个区间对应一个数字。这在理论上能实现,但在工程上是一场噩梦。一来,芯片内部电磁干扰不可避免,电压会波动,10个区间的容错空间太小,某一位电压稍微偏一点,机器就读错了;二来,电路设计会变得极其复杂,一个十进制加法器的电路复杂度比二进制加法器高出几个量级。二进制虽然“啰嗦”,数字位数长,但胜在抗干扰、易实现、可靠,这就是工业界的共识。你可以把计算机里的二进制理解成用灯泡的亮灭来传递信息:亮是1,灭是0,简单直接,永远不会看错。

1.2 位置计数法:二进制没那么反人类

当然,光知道“为什么用二进制”还不够,更关键的是得会用。很多人在进制转换上栽跟头,本质是没有理解“位置计数法”这个底层规律。

十进制里的“123”为什么是一百二十三?其实它表示的是1×10² + 2×10¹ + 3×10⁰,每一位的权重是10的幂。二进制完全同理,只是把底数换成2:比如二进制1101,展开就是1×2³ + 1×2² + 0×2¹ + 1×2⁰ = 8 + 4 + 0 + 1 = 13。所以二进制不是“另一种数学”,只是换了一套权重规则。

我教新手转换时,最推荐的办法不是背公式,而是记住下面这串权重数字:

位权2⁷2⁶2⁵2⁴2³2²2¹2⁰
十进制值1286432168421

把任何一个十进制数拆成这8个数之和,然后对应位置填1,其余填0,就得到8位二进制。比如37 = 32 + 4 + 1,那么就是00100101;再比如200 = 128 + 64 + 8,对应11001000。这个方法比“不断除以2取余数”直观得多,心算就能完成,我面试别人时也经常用这招试探基本功。

2. 数据与程序:底层只有0和1在流动

2.1 数值、字符、图像、声音,全在做同一件事

理解了二进制本身,下一个推演就很自然:计算机里的一切数据,不管看起来是数字、英文、汉字,还是一张照片、一段音乐,落到内存里统统都是二进制。区别只在于“解释方式”。

拿常见的类型举例:

  • 整数42,在32位环境下内存里是00000000 00000000 00000000 00101010。
  • 字符'A',ASCII码是65,二进制就是01000001。
  • 一个像素点,用RGB三通道表示,每个通道占8位,三个字节就能描述一个颜色,例如纯红色是11111111 00000000 00000000。
  • 一段CD音质的音频,每秒钟要采样44100次,每次采样的幅度用16位整数记录,这就是量化。

你应该已经发现了:同样是8个比特位,你可以把它解释成无符号整数、有符号整数、ASCII字符,甚至是一个很小的小数。同一个字节0x41,当整数看是65,当字符看是'A',当浮点数的一部分看又是另一个数。所谓数据类型,本质上就是“给一段0/1序列规定解读规则”。这个认知特别重要,很多程序出错都是因为同一个字节被两种不同规则解读了。

2.2 程序本体就是二进制指令序列

除了数据,程序本身也是二进制。你写的C语言、Python代码在运行前,最终都要变成CPU能直接执行的机器语言指令,而机器语言就是一堆二进制数字。x86架构里,把数字1写入eax寄存器的一条指令,机器码可能是B8 01 00 00 00,在内存里就是11111000 00000001 00000000 00000000 00000000。

编译器、汇编器干的事情,本质上是把一个人类可读的文本文件转换成“CPU可读”的二进制文件。这个文件存储着一连串指令和数据,加载到内存后,CPU按照程序计数器指示的地址一条条取出、解码、执行。这就是“程序”能跑起来的真相。

所以我一直跟新人强调:先理解二进制,再谈上层框架。因为不管你用React还是Spring Boot,最终产物在执行时,走的还是底层那套取指令、解码、执行的流程。框架换得再勤,地基永远是那些0和1。

3. 第2章精读:四个必须吃透的核心知识点

3.1 位、字节与数据表示范围

《程序是怎样跑起来的》第2章花了大量篇幅讲数据范围,这是很多人囫囵吞枣的地方。先说两个单位:1位(bit)只能表示0或1;8位组成1字节(byte),这是计算机存储的基本单位。

8位能表示多少种状态?2⁸ = 256种。如果你把这些状态全部当作无符号整数,就能表示0到255;如果当作有符号整数,就涉及负数表示法,通常范围是-128到127。为什么是-128到127而不是-127到128?这里藏着补码的伏笔,下面再说。

常见数据类型范围请直接记这张表:

类型字节数无符号范围有符号范围
char10 ~ 255-128 ~ 127
short20 ~ 65535-32768 ~ 32767
int40 ~ 4294967295-2147483648 ~ 2147483647
long long8超大更大

我想让你盯住一个现象:给char类型赋127,加1之后就变成-128;给unsigned char赋255,加1之后就变成0。这不是编译器bug,而是位模式的回绕——二进制数字在有限位数内“溢出”了。127的二进制是01111111,加1变成10000000,按有符号解释就是-128;255的二进制是11111111,加1变成100000000,但char只有8位,最高位的1丢失,只剩00000000,于是变回0。

3.2 补码:负数为什么要用补码表示

有符号数的负数表示,是这一章最容易劝退人的地方。我当年学的时候也很懵:为什么不能干脆用最高位当符号位,把10000011看成“-3”?理论上可以,但硬件会变得很复杂,因为加减法得分别处理正数和负数。补码的巧妙之处在于,它让减法变成了加法。

8位补码的定义是:一个数的负数等于对它按位取反再加1。比如3的二进制是00000011,按位取反得11111100,再加1得11111101,这就是-3的补码。验证一下:3 + (-3) = 00000011 + 11111101 = 100000000,超过8位,丢掉进位,剩00000000,完美归零。

为什么能这样?你可以拿钟表打比方。钟表是12进制,13点和1点其实是同一个位置;对8位二进制来说,模是256,所以-3和253在“取余”的意义上是同一个数,减去3和加上253效果相同,而253恰好就是11111101补码对应的无符号整数值。这就是补码把减法统一成加法的底层逻辑。

正因为这个设计,8位有符号数的负数是-128而不是-127:因为0只有一种编码00000000,而10000000就空出来,留给-128了。

3.3 移位运算:乘除法的底层真相

书中还讲了一个非常实用的话题:移位运算。虽然现在的CPU有乘法器,直接用乘法指令就行,但理解移位,对理解底层性能优化和位运算代码很有帮助。

规则很简单:无符号数左移1位,相当于乘以2;右移1位,相当于除以2。比如5的二进制0101,左移1位得到1010,也就是10;右移1位得到0010,也就是2(多余的一位被丢掉,相当于向下取整)。

这个规律的本质是“位置计数法”的必然结果:二进制每向左挪一位,权值就放大2倍。在早期没有乘法指令的CPU上,编译器会把“乘以2”优化成“左移1位”,把“乘以10”优化成“左移3位加左移1位”,因为移位比乘法快得多。即使今天,你在优化性能敏感代码时,看到a << 2、b >> 1这种写法,也能立刻反应过来它想干什么。

需要留意的是:右移分逻辑右移和算术右移。逻辑右移,高位一律补0;算术右移,则保留符号位。对有符号负数来说,算术右移能保持符号不变,这一点如果搞错,很可能会出现“除2之后变成很大的正数”的诡异bug。

3.4 逻辑运算:程序里的位级开关

第2章最后一般会落到逻辑运算上:AND、OR、NOT、XOR。别觉得这是数电课才用的东西,日常写代码的位操作,全部源于这几个运算。

三个最实用的结论:

  • AND(按位与):可以用来“提取”或“清零”某些位。想保留一个数低4位、把高4位清掉,就与0x0F做按位与。
  • OR(按位或):用来“置位”,把某一位强行设为1。权限管理里给用户加一个读权限,就用OR。
  • XOR(按位异或):相同为0、不同为1,可以用来“翻转”特定位,也是很多简易加密算法的核心。

这套东西在底层协议解析里无处不在。比如一个8位的状态寄存器,第0位表示设备在线,第1位表示有数据待读,第2位表示错误标志,你就可以用(status & 0x01)判断在线,用(status & 0x04)判断错误,而不用把整个状态字段拆成三个变量。一次读一个字节,三个判断,效率极高。

4. 学完这一章,能干点啥

4.1 手写一个进制转换工具

光看书不做题,等于白看。我建议你动手写一个进制转换小工具,不用调现成函数,从零实现才能把“位权”刻进脑子里。

下面是我常用的最小Python实现,可直接运行:

def dec_to_bin(n): if n == 0: return "0" bits = [] while n > 0: bits.append(str(n % 2)) n //= 2 return ''.join(reversed(bits)) def bin_to_dec(bits): val = 0 for ch in bits: val = val * 2 + int(ch) return val print(dec_to_bin(37)) # 输出 100101 print(bin_to_dec("100101")) # 输出 37

这段代码不依赖任何黑魔法。核心就是反复取余数,再反向拼接;反过来就是迭代乘2累加。理解了这两个函数的循环过程,你对“逢二进一”的理解就彻底落地了。建议你在纸上也走一遍来验证:37除以2余1、商18;18除以2余0、商9;以此类推,最后把余数逆序排,就是100101。

4.2 实战一:位运算做权限管理

二进制在业务代码里最常见的应用,就是用一个整型变量去管理多个开关状态。比如一个后台系统的文件权限,要标记“可读、可写、可执行”,你当然可以定义三个bool变量,但更优雅的做法是分配三个位。

READ = 0b100 # 4 WRITE = 0b010 # 2 EXEC = 0b001 # 1 # 初始状态:没有任何权限 perm = 0b000 # 添加权限 perm |= READ perm |= WRITE # 判断权限 if perm & READ: print("可读") if perm & WRITE: print("可写") if perm & EXEC: print("可执行") # 移除写权限 perm &= ~WRITE

这里的|=、&=、&= ~,看起来有点抽象,但你把整型当成一串灯泡开关就懂了:置1是开灯、清0是关灯、判断是看灯是否亮着。Linux系统里你天天见的chmod 755,其实就是三位八进制数拆成二进制位:7 = 111表示读、写、执行全开,5 = 101表示读、执行开,写关。用二进制视角再看权限,一切清清楚楚。

4.3 实战二:IP地址与子网掩码的二进制视角

网络协议同样逃不开二进制。IP地址192.168.1.10看起来是四段十进制,路由器和操作系统的眼里,它就是32位二进制,分成4组:

192.168.1.10 = 11000000.10101000.00000001.00001010 255.255.255.0 = 11111111.11111111.11111111.00000000

子网掩码的作用,就是通过按位与运算,把IP地址中“网络号”部分保留下来,把“主机号”部分清零。192.168.1.10跟255.255.255.0做AND,结果就是192.168.1.0,这就是它所属的网络段。

不理解二进制,就理解不了“为什么子网掩码必须是连续的1”,也理解不了“为什么CIDR写法/24等价于255.255.255.0”。凡是网络排查中碰到这种基础的,最后追溯到底层都是二进制。

5. 常见误区和避坑经验

5.1 溢出回绕:255加1为什么会变成0

我在实际项目中踩过最典型的一次坑,就是时间戳字段用了8位无符号整型。设备上报的序列号每隔一段时间就跳到0,一开始还以为是通信问题,抓包半天没查出毛病,最后才发现是数据在内存里回绕了:255再加1,8位位模式从11111111变成00000000,于是归零。

这类问题的排查思路,可以整理成一张速查表:

现象二进制层面的原因处理建议
计数到最大值后归零无符号整型溢出回绕换成更大类型,或检查临界值
正数加正数变成负数有符号整型溢出留意中间运算结果是否超范围
负数存进去,读出来变成大正数符号位被当作数值位保证读写双方类型一致
移位之后结果异常算术右移与逻辑右移混淆明确是有符号还是无符号数

请永远记住:程序里没有“数学上的无限”,任何数据都有二进制位数限制,超出的高位会被无情丢弃。写代码时多问一句“这个值最大能到多少”,能省下大量排查时间。

5.2 用xxd看文件的二进制真面目

学完这一章,我强烈建议你亲眼看看文件在二进制层面的样子。在Linux或macOS终端下,随便写个文本文件:

echo "abc" > test.txt xxd test.txt

你会看到类似这样的输出:

00000000: 6162 630a abc.

61、62、63分别是'a'、'b'、'c'的ASCII十六进制码,0a是换行符。你平时用文本编辑器看到的“字符”,在文件里就是这些字节序列。

再用file命令或者直接看可执行文件的头部,例如编译一个C程序,然后执行xxd a.out | head,会发现文件头部出现7f 45 4c 46,也就是ELF魔数。所有文件——文本、图片、压缩包、可执行程序——本质上都是字节流,只是格式约定不同。这个认知能让你在看“文件无法识别”“文件头损坏”这类报错时,直接动手从二进制层面找原因。

5.3 乱码的二进制根源

乱码是每个开发者都遇到过的。理解了二进制,乱码就不再神秘。乱码的根源是同一个字节序列,被不同的字符编码规则解释了。

一个中文字符在GBK编码下占2字节,在UTF-8编码下占3字节。以“程”字为例:

  • GBK:B3 CC(两个字节)
  • UTF-8:E7 A8 8B(三个字节)

如果一段文本原本是UTF-8编码,你用GBK解码,字节流就会被拆成错误的分组,猜出完全不同的字符,屏幕上自然是一堆“锟斤拷”“烫烫烫”之类的乱码。解决方式也很粗暴:确认文件的真实编码,然后用对应编码打开。Windows记事本老版本默认ANSI,Linux默认UTF-8,跨平台传文件最容易踩这个坑。

除此之外我还想提醒一句:在日志、数据库、接口传输里,尽量统一字符编码。我的做法是全世界一律UTF-8,没得商量。这个习惯救过我无数次。


最后分享一段实际经历。前两年排查一起设备上报数据错乱问题,整个团队查了整整一天,最后发现就是“数据回绕”四个字:一个计数器字段被定义成单字节无符号类型,溢出归零后,后续所有逻辑全部错位。那一刻我真切感受到,第2章里那些干巴巴的二进制知识,不是考试用的,是拿来救命的。

你现在觉得二进制绕,是因为还没把它跟具体问题挂上钩。多动手写几个转换工具、多看几次文件的hexdump、多思考几个位运算场景,用起来之后就再也丢不掉了。这本书的逻辑,也恰恰是先把“数据到底怎么存”的地基打好,后面讲内存、讲文件、讲网络,你才会有真正“跑起来了”的感觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询