《程序是怎样跑起来的》深度解析:从零到一,揭开计算机运行的神秘面纱
——一场关于0和1的奇幻之旅
写在前面的话
当你双击鼠标打开一个应用程序时,你有没有想过:
这个程序,到底是怎么跑起来的?
当你在键盘上敲下一行代码,按下回车键,屏幕上显示出结果时:
计算机内部,到底发生了什么?
当你看着手机里的APP运行得如此流畅,处理着复杂的计算时:
这一切的背后,隐藏着怎样的秘密?
今天,我要带你读一本书——日本作家矢泽久雄的《程序是怎样跑起来的》。
这不是一本普通的技术书籍。
这是一本能让你真正理解计算机本质的书。
这是一本能让你从"会用电脑"到"懂电脑"的书。
更重要的是,这是一本能让你重新认识这个数字世界的书。
准备好了吗?
让我们开始这场关于0和1的奇幻之旅。
第一章:CPU——计算机的大脑,到底是如何思考的?
1.1 从一个简单的问题开始
让我先问你一个问题:
1 + 1 = ?
你会说:等于2啊,这还用问?
好,那我再问你:
计算机是怎么知道1 + 1 = 2的?
你可能会说:这不是很简单吗?程序员写好了加法程序,计算机执行就行了。
但是,计算机为什么能"执行"?
它到底是怎么"执行"的?
这就是我们要探讨的第一个核心问题。
1.2 CPU的本质:一个超高速的计算器
矢泽久雄在书中用了一个非常巧妙的比喻:
CPU就像一个超级勤奋的小学生,每秒钟能做几十亿次算术题。
但这个小学生有点特殊:
它只会做非常非常简单的算术题。
简单到什么程度?
它只会做这些事情:
- 加法:把两个数字加起来
- 移位:把一个数字的二进制位左移或右移
- 比较:判断两个数字哪个大
- 存取:把数字放到某个地方,或者从某个地方取出来
就这么简单。
但就是这些简单的操作,组合起来,就能完成所有复杂的计算。
就像汉字,笔画只有横竖撇捺折,但能组成成千上万个字。
就像音乐,只有7个音符,但能创作出无数动听的旋律。
这就是计算机的魔法:用最简单的规则,创造最复杂的世界。
1.3 CPU的工作原理:取指令-解码-执行
让我用一个生活化的例子来解释CPU的工作原理。
想象你是一个厨师,正在做一道菜。
你面前有一本菜谱(程序),菜谱上写着:
步骤1:拿出鸡蛋
步骤2:打碎鸡蛋
步骤3:搅拌鸡蛋
步骤4:倒入锅中
步骤5:翻炒3分钟你会怎么做?
第一步:看菜谱(取指令)
你看第一行:拿出鸡蛋。
第二步:理解菜谱(解码)
你明白了,这是要我从冰箱里拿出鸡蛋。
第三步:执行操作(执行)
你走到冰箱前,打开冰箱,拿出鸡蛋。
然后呢?
你继续看第二行,重复这个过程。
CPU的工作原理,就是这样:
- 取指令(Fetch):从内存中读取下一条要执行的指令
- 解码(Decode):理解这条指令要做什么
- 执行(Execute):按照指令的要求,进行相应的操作
这个过程,被称为"指令周期"(Instruction Cycle)。
而CPU每秒钟,能完成几十亿次这样的循环。
1.4 时钟频率:CPU的心跳
你有没有想过,为什么CPU有"主频"这个概念?
比如:3.0 GHz的处理器。
这个3.0 GHz是什么意思?
这是CPU的"心跳"频率。
就像人的心脏每分钟跳动60-100次,CPU也有自己的"心跳"。
3.0 GHz = 30亿次/秒
意思是,这个CPU每秒钟能"跳动"30亿次。
每一次"跳动",就是一个时钟周期。
在一个时钟周期内,CPU可以完成一些基本操作。
CPU越快,心跳越快,完成任务的速度就越快。
但是,有一个问题:
CPU的速度能无限提升吗?
答案是:不能。
为什么?
1.5 CPU的极限:功耗墙和散热问题
矢泽久雄在书中提到了一个非常有趣的物理现象:
CPU越快,发热越严重。
这是为什么?
因为CPU本质上是由几十亿个晶体管组成的。
每个晶体管就像一个开关,每次开关的时候,都会产生热量。
CPU主频越高,开关切换越频繁,产生的热量就越多。
到了某个临界点,CPU产生的热量会大到无法散热。
这就是"功耗墙"。
所以,从2000年代中期开始,CPU的主频增长就停滞了。
那怎么办?
解决方案是:多核心。
既然一个核心的速度提升到了极限,那就多做几个核心。
就像一个人干活太累,那就多找几个人一起干。
这就是为什么现在的CPU都是多核心的:
- 双核
- 四核
- 八核
- 十六核
- 甚至更多
但多核心带来了新的问题:如何协调?
1.6 并行计算的挑战
想象你现在要办一场婚宴,需要做100道菜。
如果只有1个厨师,可能需要10个小时。
如果有10个厨师呢?
理论上,应该只需要1个小时对吧?
但实际上不是。
为什么?
因为:
- 有些菜必须按顺序做:比如先炒菜,后装盘
- 厨师之间需要协调:谁做什么,谁用哪个炉灶
- 资源是有限的:炉灶、锅、调料都是有限的
多核心CPU也面临同样的问题。
有些任务可以并行(比如同时处理100张图片)。
有些任务必须串行(比如计算斐波那契数列,后一项依赖前一项)。
这就是为什么,多核心并不意味着性能翻倍。
这也是为什么,程序员需要学习"并发编程"。
1.7 CPU的寄存器:最快的存储器
CPU内部有一些特殊的存储器,叫做"寄存器"(Register)。
寄存器是CPU能直接访问的最快的存储器。
有多快?
它的访问速度几乎是瞬时的,只需要1个时钟周期。
相比之下:
- 访问内存(RAM)需要几十到几百个时钟周期
- 访问硬盘需要几百万个时钟周期
但是,寄存器非常非常小。
一个CPU可能只有十几个到几十个寄存器。
每个寄存器只能存储几个字节的数据。
就像你的大脑:
短期记忆(寄存器)非常快,但容量很小,只能记住几个数字。
长期记忆(内存/硬盘)容量很大,但调取速度慢。
CPU的设计,就是在速度和容量之间找平衡。
1.8 指令集:CPU的"词汇表"
不同的CPU,有不同的"指令集"。
什么是指令集?
就是CPU能听懂的"命令"的集合。
就像不同的人说不同的语言:
- x86架构:Intel和AMD的CPU使用的指令集
- ARM架构:大多数手机和平板使用的指令集
- RISC-V:一种开源的指令集
同样的程序,在不同指令集的CPU上,需要"翻译"成不同的机器码。
这就是为什么:
- iPhone的APP不能直接在Android上运行
- 为Windows编译的程序不能直接在Mac上运行
除非进行"重新编译"或"模拟"。
第二章:内存——程序运行的舞台
2.1 什么是内存?
如果说CPU是计算机的"大脑",那么内存就是计算机的"工作台"。
想象你是一个画家,正在创作一幅画。
CPU就是你的大脑,决定要画什么,怎么画。
内存就是你的画布和调色板,存放着你正在创作的内容。
硬盘就是你的仓库,存放着已经完成的画作和备用材料。
当你要创作时:
- 从仓库(硬盘)拿出画布和颜料
- 放到工作台(内存)上
- 开始创作(CPU处理)
- 完成后,把作品放回仓库(保存到硬盘)
程序的运行,就是这样一个过程。
2.2 内存的地址:每个"房间"都有门牌号
矢泽久雄在书中用了一个非常形象的比喻:
内存就像一栋巨大的公寓楼。
这栋楼有几十亿个房间(内存单元)。
每个房间能存放1个字节(8位)的数据。
每个房间都有一个唯一的门牌号,叫做"地址"(Address)。
比如:
地址 0x0000: 存放数据 01010101
地址 0x0001: 存放数据 11001100
地址 0x0002: 存放数据 00110011
...当CPU需要某个数据时,它会告诉内存:"给我地址0x0042的数据"。
内存就会找到这个地址,把数据返回给CPU。
这就是"内存寻址"。
2.3 为什么内存需要地址?
你可能会问:为什么不直接说"给我那个数据",而要用地址?
因为内存中有太多数据了。
就像你去图书馆借书:
你不能跟图书管理员说:"给我那本书。"
你要说:"给我编号A123-456的那本书。"
地址,就是内存中数据的"编号"。
而且,有了地址,我们就能:
- 快速定位:知道数据在哪里
- 随机访问:可以直接跳到任意地址读取数据
- 程序控制:程序可以通过改变地址,访问不同的数据
这就是为什么内存也叫"随机存取存储器"(RAM,Random Access Memory)。
2.4 指针:内存地址的"代号"
在编程中,有一个非常重要的概念:指针(Pointer)。
指针就是存放内存地址的变量。
听起来有点绕?
让我用一个生活化的例子:
假设你要去朋友家,朋友给了你地址:"XX路XX号"。
这个"XX路XX号"就是一个"指针"。
它不是房子本身,而是房子的位置。
通过这个位置,你可以找到房子。
在程序中:
c
int x = 100; // x是一个变量,存放数据100
int *p = &x; // p是一个指针,存放x的地址
```
**通过指针,程序可以:**
1. 找到数据在内存中的位置
2. 修改内存中的数据
3. 在不同的函数之间传递数据的位置(而不是复制整个数据)
**指针是C语言等底层语言的核心特性,也是最难理解的概念之一。**
### 2.5 内存的分配:栈和堆
矢泽久雄在书中详细解释了内存的两种分配方式:栈(Stack)和堆(Heap)。
**栈(Stack):自动分配,后进先出**
想象你在洗碗,洗好的碗一个个摞起来。
最后洗的碗在最上面,要用的时候先拿最上面的。
**这就是栈:后进先出(LIFO,Last In First Out)。**
在程序中:
- 局部变量存放在栈上
- 函数调用时,参数和返回地址也存放在栈上
- 函数返回时,栈自动清理
**栈的特点:**
- 速度快
- 自动管理
- 空间有限(一般只有几MB)
**堆(Heap):手动分配,灵活使用**
想象你有一个大仓库,你可以在任意位置放东西。
放的时候,你记住放在哪里了。
要用的时候,你去那个位置取。
**这就是堆:需要手动管理。**
在程序中:
- 需要长期存在的数据存放在堆上
- 需要动态调整大小的数据存放在堆上
- 需要手动申请和释放
**堆的特点:**
- 灵活
- 空间大
- 速度相对慢
- 需要手动管理(否则会内存泄漏)
### 2.6 内存泄漏:程序员的噩梦
什么是内存泄漏?
**就是程序申请了内存,用完后忘记释放,导致内存越用越少。**
就像你租了一个仓库,用完后没有退租,仓库一直被占用,但你又不用它。
时间长了,可用的仓库越来越少,最后没有仓库可租了。
**程序也是一样:**
内存泄漏积累到一定程度,程序会因为没有可用内存而崩溃。
**这就是为什么:**
- C/C++程序员要手动管理内存(malloc/free)
- Java/Python等语言有"垃圾回收"机制,自动释放不用的内存
- 但即使有垃圾回收,也可能因为循环引用等问题导致内存泄漏
### 2.7 虚拟内存:让小内存装下大程序
你有没有想过一个问题:
**为什么一个8GB内存的电脑,能同时运行总大小超过8GB的程序?**
这就要感谢"虚拟内存"技术。
**虚拟内存的核心思想是:让程序以为自己有很大很大的内存,但实际上只给它真正需要的部分。**
就像图书馆:
- 图书馆有100万本书(硬盘)
- 阅览室只能放1000本书(内存)
- 但每个读者都觉得自己能看到所有的书
怎么做到的?
**按需调度:**
1. 读者要某本书时,图书管理员从库房拿出来,放到阅览室
2. 阅览室满了,就把暂时不用的书放回库房
3. 每个读者都觉得自己能随时看到任何一本书
**计算机也是这样:**
1. 程序以为自己有很大的内存空间(虚拟地址空间)
2. 操作系统只把程序当前需要的部分加载到物理内存
3. 不常用的部分放在硬盘上(页面文件/交换分区)
4. 需要时再调入内存
**这就是虚拟内存的"分页"机制。**
### 2.8 内存的速度:为什么需要缓存?
前面我们提到,CPU的速度非常快,每秒几十亿次操作。
但内存的速度相对慢得多。
**这就产生了一个矛盾:**
CPU像一个超级快的计算器,每秒能做几十亿次计算。
但每次计算都要等内存把数据送过来。
**这就像一个天才厨师,做菜速度极快,但每次做菜都要等服务员慢慢地从仓库拿食材。**
怎么办?
**解决方案:缓存(Cache)。**
在CPU和内存之间,增加几层缓存:
- **L1缓存**:最快,最小(几十KB),CPU内部
- **L2缓存**:次快,较小(几百KB),CPU内部
- **L3缓存**:较快,较大(几MB到几十MB),CPU内部或附近
- **内存**:慢,大(几GB到几十GB)
**缓存的原理:**
1. CPU需要数据时,先在L1缓存找
2. 找不到,去L2缓存找
3. 还找不到,去L3缓存找
4. 还找不到,才去内存找
5. 找到后,把数据放到缓存中,下次用时就快了
**这就是"缓存命中"。**
缓存命中率越高,程序运行越快。
**这也是为什么:**
- 好的程序要考虑"数据局部性"
- 相关的数据要放在一起
- 避免频繁访问分散的内存地址
---
## 第三章:二进制——计算机世界的语言
### 3.1 为什么计算机只认识0和1?
这是很多人的第一个疑问:
**为什么计算机不能像人一样,认识0-9十个数字?**
**为什么非要用0和1?**
矢泽久雄在书中给出了一个非常清晰的解释:
**因为二进制最容易用电路实现。**
想象一个电灯开关:
- 开(有电)= 1
- 关(没电)= 0
**这就是最简单的二进制。**
如果要实现十进制呢?
你需要区分10种不同的电压等级:
- 0V = 0
- 0.5V = 1
- 1.0V = 2
- ...
- 4.5V = 9
**问题来了:**
1. 这些电压很容易受干扰,0.5V可能变成0.6V
2. 很难精确控制
3. 识别起来容易出错
**而二进制只需要区分两种状态:高电平和低电平。**
- 0-2.5V = 0
- 2.5V-5V = 1
**容错性强,不容易出错。**
**这就是为什么计算机用二进制:简单、可靠、容易实现。**
### 3.2 二进制的运算:比你想象的简单
很多人觉得二进制很难理解。
其实,二进制的运算比十进制还简单。
**十进制加法:**
```
9
+ 5
---
14
```
你要记住:9+5=14,而且要进位。
**二进制加法:**
```
1
+ 1
---
10
```
规则超级简单:
- 0 + 0 = 0
- 0 + 1 = 1
- 1 + 0 = 1
- 1 + 1 = 10(进位)
**就这么简单!**
不需要记住9+5=14这种复杂的加法表。
**这就是二进制的优势:规则简单,容易用电路实现。**
### 3.3 位运算:程序员的秘密武器
矢泽久雄在书中用了大量篇幅介绍位运算。
什么是位运算?
**就是直接对二进制位进行操作的运算。**
**常见的位运算:**
**1. AND(与运算)**
```
1010
& 1100
------
1000
```
规则:两个都是1,结果才是1。
**2. OR(或运算)**
```
1010
| 1100
------
1110
```
规则:有一个是1,结果就是1。
**3. XOR(异或运算)**
```
1010
^ 1100
------
0110
```
规则:两个不同,结果才是1。
**4. NOT(非运算)**
```
~ 1010
------
0101
```
规则:0变1,1变0。
**5. 左移/右移**
```
1010 << 1 = 10100 (左移1位,相当于乘以2)
1010 >> 1 = 0101 (右移1位,相当于除以2)位运算有什么用?
- 高效:位运算是CPU直接支持的,速度极快
- 节省空间:可以用一个整数存储多个布尔值
- 加密:很多加密算法都用到位运算
- 图形处理:颜色混合、透明度计算等
举个例子:
假设你要判断一个数是不是2的幂次方(2、4、8、16…)。
普通方法:
c
bool isPowerOfTwo(int n) {
while (n > 1) {
if (n % 2 != 0) return false;
n = n / 2;
}
return n == 1;
}用位运算:
c
bool isPowerOfTwo(int n) {
return n > 0 && (n & (n - 1)) == 0;
}
```
**一行代码搞定,而且速度快得多!**
### 3.4 原码、反码、补码:负数的表示
计算机怎么表示负数?
这是一个非常有趣的问题。
**最直观的想法:用第一位表示正负。**
比如8位二进制:
```
0 0000001 = +1
1 0000001 = -1
```
这叫"原码"。
**但原码有问题:**
```
+1
+ -1
-----
```
你期望结果是0对吧?
但用原码计算:
```
00000001
+ 10000001
----------
10000010 = -2
```
**错了!**
**为了解决这个问题,人们发明了"补码"。**
补码的规则:
1. 正数的补码就是它本身
2. 负数的补码 = 按位取反 + 1
比如:
```
+1 = 00000001
-1 = 11111111
```
为什么-1是11111111?
```
+1 = 00000001
取反 = 11111110
加1 = 11111111
```
**现在再算+1 + -1:**
```
00000001
+ 11111111
----------
100000000
```
溢出的最高位丢弃,结果是:
```
00000000 = 0
```
**对了!**
**这就是为什么计算机用补码表示负数:方便计算。**
### 3.5 浮点数:小数怎么用二进制表示?
整数的二进制表示比较简单,那小数呢?
比如:3.14159这个数,怎么用二进制表示?
**计算机用"浮点数"来表示小数。**
什么是浮点数?
**就是"小数点可以浮动的数"。**
就像科学计数法:
```
1234.56 = 1.23456 × 10³
0.00123 = 1.23 × 10⁻³
```
**浮点数也是这样:**
一个浮点数由三部分组成:
1. **符号位**:表示正负
2. **指数**:表示小数点的位置
3. **尾数**:表示有效数字
比如IEEE 754标准的32位浮点数:
```
[符号位1位] [指数8位] [尾数23位]举个例子:
数字:-13.75
二进制表示:-1101.11
科学计数法:-1.10111 × 2³
- 符号位:1(负数)
- 指数:3 + 127 = 130 = 10000010
- 尾数:10111(省略开头的1)
但是,浮点数有一个致命的问题:精度损失。
3.6 浮点数的精度问题
你有没有遇到过这种情况:
python
>>> 0.1 + 0.2
0.30000000000000004
```
**为什么不是0.3?**
因为0.1和0.2在二进制中是无限循环小数!
就像1/3在十进制中是0.333...一样。
```
0.1 (十进制) = 0.000110011001100...(二进制,无限循环)
```
**计算机只能存储有限位,所以会截断。**
这就导致了精度损失。
**这就是为什么:**
- 永远不要用==比较两个浮点数
- 金融计算不能用浮点数,要用专门的十进制类型
- 科学计算要注意累积误差
---
## 第四章:程序的结构——从源代码到可执行文件
### 4.1 编程语言的层次:从机器码到高级语言
矢泽久雄在书中用了一个非常形象的比喻:
**编程语言就像人类的语言,有不同的"层次"。**
**最底层:机器码**
这是CPU能直接执行的代码,全是0和1。
```
10110000 01100001
```
**CPU看懂了,但人类看不懂。**
**第二层:汇编语言**
给机器码起了"名字"。
```
MOV AL, 61h意思是:把61h这个数放到AL寄存器里。
人类能看懂了,但还是很难写。
第三层:高级语言
更接近人类的语言。
c
int x = 97;人类容易理解,也容易编写。
第四层:超高级语言
更抽象,更简洁。
python
x = 97甚至不用声明类型。
层次越高,越容易编写,但离硬件越远。
4.2 编译和解释:两种执行方式
高级语言写的程序,怎么变成机器能执行的代码?
有两种方式:编译和解释。
编译:一次性翻译
就像翻译一本书:
- 把整本书从英文翻译成中文
- 出版中文版
- 以后直接读中文版
编译型语言(C、C++、Go等):
源代码 → 编译器 → 机器码 → 执行优点:
- 执行速度快(已经是机器码了)
- 不需要额外的运行环境
缺点:
- 编译需要时间
- 不同平台需要重新编译
- 发现错误较晚(编译完才知道)
解释:逐句翻译
就像口译:
- 听一句英文
- 翻译成中文
- 说出来
- 重复
解释型语言(Python、JavaScript、Ruby等):
源代码 → 解释器 → 逐行执行优点:
- 跨平台(有解释器就能运行)
- 开发快,调试方便
- 动态特性强
缺点:
- 执行速度慢(每次都要翻译)
- 需要解释器环境
还有一种混合方式:先编译成中间代码,再解释执行。
比如Java:
源代码 → 编译 → 字节码 → JVM解释执行这样既有编译的优化,又有跨平台的灵活性。
4.3 链接:把零散的代码组装起来
一个大型程序,不可能写在一个文件里。
怎么办?
分成多个文件,分别编译,然后"链接"起来。
就像造汽车:
- 不同的工厂造不同的零件(编译不同的源文件)
- 最后在总装厂组装(链接)
链接器的工作:
- 符号解析:找到函数和变量的定义
- 地址分配:给每个函数和变量分配内存地址
- 重定位:修正代码中的地址引用
举个例子:
// main.c
extern int add(int a, int b); // 声明:add函数在别处定义
int main() {
int result = add(3, 5);
return 0;
}
// add.c
int add(int a, int b) { // 定义:add函数的实现
return a + b;
}编译:
gcc -c main.c → main.o
gcc -c add.c → add.o链接:
gcc main.o add.o → a.out链接器做了什么?
- 发现main.o中调用了add函数
- 在add.o中找到add函数的定义
- 把两个文件组合成一个可执行文件
- 修正main.o中对add函数的调用地址
这就是链接。
4.4 静态链接 vs 动态链接
链接有两种方式:静态链接和动态链接。
静态链接:全部打包
就像去旅行,把所有东西都装进行李箱。
优点:
- 不依赖外部库
- 运行时不需要其他文件
缺点:
- 可执行文件很大
- 浪费内存(每个程序都有一份相同的库代码)
- 库更新了,程序要重新编译
动态链接:按需加载
就像去旅行,只带必需品,其他的到当地买。
优点:
- 可执行文件小
- 多个程序共享同一个库,节省内存
- 库更新了,程序自动受益
缺点:
- 依赖外部库(DLL、SO文件)
- "DLL地狱"问题(版本冲突)
这就是为什么:
- Windows程序经常缺DLL文件
- Linux要安装各种so库
- macOS的应用经常很大(静态链接了很多库)
4.5 加载器:把程序装入内存
编译、链接完成后,得到了一个可执行文件。
但是,可执行文件不能直接执行!
为什么?
因为可执行文件在硬盘上,程序要在内存中执行。
谁来把程序从硬盘加载到内存?
加载器(Loader)。
加载器的工作:
- 读取可执行文件:从硬盘读取
- 分配内存:为程序分配虚拟地址空间
- 加载代码和数据:把程序的代码段、数据段加载到内存
- 加载动态库:如果用了动态链接库,加载这些库
- 重定位:调整程序中的地址引用
- 设置栈和堆:为程序分配栈空间和堆空间
- 跳转到入口点:开始执行程序
这就是为什么:
- 双击程序图标后,要等一会儿才能启动
- 大型程序启动慢(加载的东西多)
- 有些程序有"启动画面"(在加载时给用户看)
4.6 程序的内存布局
矢泽久雄在书中详细描述了程序在内存中的布局。
一个程序在内存中分为几个区域:
+-------------------+ 高地址
| 栈(Stack) | 局部变量、函数参数
+-------------------+
| ↓ | 栈向下增长
| |
| ↑ | 堆向上增长
+-------------------+
| 堆(Heap) | 动态分配的内存
+-------------------+
| BSS段 | 未初始化的全局变量
+-------------------+
| 数据段(Data) | 已初始化的全局变量
+-------------------+
| 代码段(Text) | 程序的机器码
+-------------------+ 低地址各个区域的作用:
1. 代码段(Text Segment)
- 存放程序的机器码
- 只读,不能修改(防止程序改自己的代码)
- 所有同一个程序的进程共享(节省内存)
2. 数据段(Data Segment)
- 存放已初始化的全局变量和静态变量
- 可读可写
3. BSS段(Block Started by Symbol)
- 存放未初始化的全局变量和静态变量
- 程序启动时自动初始化为0
- 不占用可执行文件空间(因为都是0,不需要存储)
4. 堆(Heap)
- 动态分配的内存
- 向上增长(从低地址向高地址)
- 程序员手动管理(malloc/free,new/delete)
5. 栈(Stack)
- 局部变量、函数参数、返回地址
- 向下增长(从高地址向低地址)
- 自动管理
举个例子:
int global_var = 100; // 数据段
int global_uninitialized; // BSS段
int main() {
int local_var = 200; // 栈
int *heap_var = malloc(sizeof(int)); // 堆
*heap_var = 300;
printf("%dn", global_var);
free(heap_var);
return 0;
}为什么要这样设计?
- 分离代码和数据:代码不能被修改,提高安全性
- 栈和堆分开:不同的生命周期,不同的管理方式
- 栈向下、堆向上:充分利用地址空间
4.7 函数调用的奥秘:栈帧
当一个函数调用另一个函数时,发生了什么?
这是计算机科学中最精妙的设计之一:栈帧(Stack Frame)。
让我用一个例子说明:
int add(int a, int b) {
int sum = a + b;
return sum;
}
int main() {
int x = 3;
int y = 5;
int z = add(x, y);
return 0;
}当main函数调用add函数时:
第1步:准备参数
把参数(3和5)压入栈。
栈:
+-----+
| 5 | 参数b
+-----+
| 3 | 参数a
+-----+第2步:保存返回地址
把main函数中调用add之后的那条指令的地址压入栈。
栈:
+----------+
| 返回地址 |
+----------+
| 5 |
+----------+
| 3 |
+----------+第3步:跳转到add函数
CPU跳转到add函数的代码。
第4步:保存旧的栈帧指针
把main函数的栈帧指针保存下来。
第5步:为局部变量分配空间
在栈上为add函数的局部变量sum分配空间。
栈:
+----------+
| sum | add的局部变量
+----------+
| 旧EBP |
+----------+
| 返回地址 |
+----------+
| 5 |
+----------+
| 3 |
+----------+第6步:执行函数体
计算sum = a + b = 3 + 5 = 8。
第7步:返回
- 把返回值(8)放到特定寄存器(比如EAX)
- 恢复栈:清理局部变量,恢复栈指针
- 跳转到返回地址
第8步:清理参数
main函数清理参数(有些调用约定是函数自己清理)。
栈:
+-----+
(回到main函数的栈状态)这就是函数调用的完整过程!
这个设计的精妙之处:
- 递归成为可能:每次调用都有独立的栈帧
- 自动清理:函数返回时,栈自动恢复
- 嵌套调用:可以无限嵌套(只要栈不溢出)
这也解释了为什么:
- 递归太深会"栈溢出"(Stack Overflow)
- 局部变量在函数返回后失效
- 不能返回局部变量的地址(会指向无效的栈空间)
第五章:操作系统——程序运行的管家
5.1 什么是操作系统?
如果把计算机比作一家酒店:
- 硬件是酒店的建筑、设施
- 操作系统是酒店的管理系统
- 应用程序是酒店的客人
操作系统的核心职责:
- 管理硬件:让不同的程序共享硬件资源
- 提供接口:让程序员不用直接操作硬件
- 保证安全:防止程序互相干扰
- 提高效率:让硬件资源得到充分利用
没有操作系统会怎样?
就像没有酒店管理系统:
- 客人要自己找房间
- 客人可能住到别人的房间
- 没人管理水电
- 一片混乱
5.2 进程:程序的运行实例
矢泽久雄在书中用了一个非常形象的比喻:
程序像一份菜谱,进程像一次做菜的过程。
同一份菜谱,可以同时被多个厨师使用,每个厨师做出来的菜都是独立的。
程序是静态的代码,存放在硬盘上。
进程是动态的执行实例,运行在内存中。
一个程序可以有多个进程。
比如:
- 你可以打开多个Chrome浏览器窗口(多个进程)
- 但它们都来自同一个程序(chrome.exe)
每个进程有:
- 独立的地址空间:自己的代码、数据、栈、堆
- 独立的资源:文件句柄、网络连接等
- 独立的执行状态:程序计数器、寄存器等
进程之间是隔离的。
一个进程崩溃了,不会影响其他进程。
这就是为什么:
- Chrome一个标签页崩溃,其他标签页还能用
- 一个程序死掉,不会让整个系统崩溃
5.3 线程:进程内的执行流
如果进程是一家公司,线程就是公司的员工。
一家公司(进程)可以有多个员工(线程),他们共享公司的资源(内存、文件等),但各自做不同的事情。
线程的特点:
- 轻量级:创建和切换比进程快得多
- 共享内存:同一个进程的线程共享地址空间
- 独立执行:每个线程有自己的栈和程序计数器
多线程的优势:
- 并发处理:同时做多件事
- 响应快:一个线程等待时,其他线程继续执行
- 资源共享:线程间通信方便(共享内存)
举个例子:
一个文字处理软件:
- 线程1:响应用户输入
- 线程2:自动保存文档
- 线程3:检查拼写错误
- 线程4:后台打印
但多线程也有问题:竞态条件。
5.4 竞态条件:多线程的噩梦
想象两个人同时往同一个银行账户转账:
账户初始余额:1000元
线程A:存入100元
1. 读取余额:1000
2. 计算:1000 + 100 = 1100
3. 写回余额:1100线程B:存入200元
1. 读取余额:1000
2. 计算:1000 + 200 = 1200
3. 写回余额:1200如果两个线程同时执行:
时间点1:A读取余额 = 1000
时间点2:B读取余额 = 1000
时间点3:A计算 = 1100
时间点4:B计算 = 1200
时间点5:A写回 = 1100
时间点6:B写回 = 1200最终余额:1200元
但应该是:1300元!
100元不翼而飞了!
这就是竞态条件(Race Condition)。
解决方案:锁(Lock)。
就像公共厕所,进去的人要锁门,出来的人要开门。
同一时间,只有一个人能用。
在编程中:
lock(mutex); // 加锁
balance = balance + 100;
unlock(mutex); // 解锁但锁也会带来新问题:死锁。
5.5 死锁:四个条件的恶性循环
什么是死锁?
两个线程互相等待对方释放资源,谁也无法继续执行。
经典例子:哲学家就餐问题
5个哲学家围坐在圆桌旁,每人面前一盘意大利面。
桌上有5只叉子,每两个哲学家之间放一只。
吃面需要两只叉子。
如果每个哲学家同时拿起左边的叉子:
- 每个人都拿到了一只叉子
- 每个人都在等右边的叉子
- 但右边的叉子被右边的人拿着
- 所有人都在等待,谁也无法继续
这就是死锁!
死锁的四个必要条件:
- 互斥:资源一次只能被一个线程使用
- 占有并等待:线程持有资源,同时等待其他资源
- 不可剥夺:资源不能被强制夺走
- 循环等待:存在一个线程-资源的循环等待链
打破任意一个条件,就能避免死锁。
比如:
- 规定所有哲学家必须先拿编号小的叉子(打破循环等待)
- 如果拿不到两只叉子,就放下已经拿到的(打破占有并等待)
5.6 进程调度:让CPU忙起来
一个CPU,怎么同时运行多个程序?
答案:并不是真的"同时",而是"快速切换"。
就像一个杂技演员,同时抛接多个球。
其实他每次只接一个球,但切换得很快,看起来像同时。
这就是"时间片轮转"。
操作系统给每个进程分配一个时间片(比如10毫秒):
- 进程A运行10毫秒
- 切换到进程B,运行10毫秒
- 切换到进程C,运行10毫秒
- 切换回进程A,运行10毫秒
- …
切换得很快,用户感觉不到。
但并不是所有进程都平等。
操作系统会根据优先级调度:
- 实时进程:必须在规定时间内完成(音视频播放)
- 交互式进程:需要快速响应(鼠标键盘输入)
- 批处理进程:不着急,可以慢慢来(后台下载)
这就是为什么:
- 玩游戏时,游戏进程优先级高,画面流畅
- 后台下载不影响前台操作
- 音乐播放不会卡顿
5.7 内存管理:让每个程序都有自己的空间
前面我们讲过虚拟内存,这里再深入一点。
操作系统的内存管理有几个核心任务:
1. 地址映射
把虚拟地址映射到物理地址。
2. 内存保护
防止进程访问其他进程的内存。
3. 内存共享
允许进程共享某些内存(比如共享库)。
4. 内存回收
回收不用的内存,给其他进程使用。
分页(Paging)机制:
内存被分成固定大小的"页"(通常4KB)。
虚拟地址空间也分成同样大小的"页"。
操作系统维护一个"页表":
虚拟页号 → 物理页号
0 → 100
1 → 523
2 → 234
...当程序访问虚拟地址时:
- CPU把虚拟地址分成"页号"和"页内偏移"
- 查页表,找到物理页号
- 组合物理页号和页内偏移,得到物理地址
- 访问物理内存
这个过程由MMU(内存管理单元)硬件完成,非常快。
5.8 文件系统:让数据持久化
程序关闭后,内存中的数据就没了。
怎么保存数据?
存到硬盘上,以文件的形式。
但硬盘只是一块一块的存储空间,怎么组织成"文件"?
这就需要文件系统。
文件系统做了什么:
- 组织数据:把数据组织成文件和目录
- 分配空间:决定文件存放在硬盘的哪个位置
- 管理元数据:文件名、大小、创建时间等
- 提供接口:让程序能方便地读写文件
常见的文件系统:
- FAT32:简单,兼容性好,但单文件不能超过4GB
- NTFS:Windows的默认文件系统,功能强大
- ext4:Linux的默认文件系统,性能好
- APFS:macOS的新文件系统,针对SSD优化
文件系统的核心数据结构:
1. i-node(索引节点)
存储文件的元数据:
- 文件大小
- 创建/修改时间
- 权限
- 数据块的位置
2. 目录
存储文件名到i-node的映射。
3. 数据块
存储文件的实际内容。
举个例子:
你要读取文件/home/user/document.txt:
- 从根目录
/开始 - 找到
home目录的i-node - 在
home目录中找到user的i-node - 在
user目录中找到document.txt的i-node - 根据i-node中的信息,找到数据块
- 读取数据块,得到文件内容
这就是文件系统的工作原理。
第六章:输入输出——程序与外部世界的桥梁
6.1 I/O设备的多样性
计算机要和外部世界交互,就需要I/O设备:
- 输入设备:键盘、鼠标、摄像头、麦克风…
- 输出设备:显示器、打印机、音箱…
- 存储设备:硬盘、U盘、SD卡…
- 网络设备:网卡、WiFi模块…
这些设备千差万别,怎么让程序统一管理?
答案:设备驱动程序(Device Driver)。
设备驱动就像"翻译":
- 程序说:"我要读数据"
- 驱动把这个请求翻译成设备能理解的命令
- 设备执行命令,返回数据
- 驱动把数据返回给程序
有了驱动,程序不需要知道设备的细节。
就像你不需要知道打印机的工作原理,只需要点"打印"按钮。
6.2 I/O的方式:轮询、中断、DMA
CPU怎么知道I/O设备完成了操作?
方式一:轮询(Polling)
CPU不停地问设备:"你好了吗?你好了吗?你好了吗?"
while (!device_ready()) {
// 等待
}
read_data();优点:简单
缺点:浪费CPU时间
方式二:中断(Interrupt)
设备完成后,主动通知CPU:"我好了!"
CPU收到中断信号,暂停当前工作,处理I/O。
优点:不浪费CPU时间
缺点:中断处理有开销
方式三:DMA(Direct Memory Access,直接内存访问)
让设备直接和内存交互,不经过CPU。
CPU只需要:
- 告诉DMA控制器:从哪里读,写到哪里,读多少
- DMA控制器自己完成数据传输
- 完成后,DMA控制器通知CPU
优点:CPU几乎不参与,效率高
缺点:需要专门的DMA控制器
现代计算机主要用中断+DMA的方式。
6.3 同步I/O vs 异步I/O
同步I/O:等待完成
data = read_file("test.txt"); // 阻塞,直到读取完成
process(data);程序调用read_file后,被阻塞,直到文件读取完成。
就像你去餐厅点餐,站在柜台等,直到拿到食物才离开。
异步I/O:继续执行
read_file_async("test.txt", callback); // 立即返回
do_other_work(); // 继续做其他事
// 文件读取完成后,自动调用callback程序调用read_file_async后,立即返回,继续执行。
文件读取完成后,系统自动调用回调函数。
就像你去餐厅点餐,拿个号码牌,去座位等,到了叫你。
异步I/O的优势:
- 提高效率,不浪费时间等待
- 特别适合I/O密集型任务(网络服务器、GUI程序)
但异步I/O也有挑战:
- 编程更复杂
- 容易出现"回调地狱"
- 需要小心处理竞态条件
6.4 缓冲:提高I/O效率
为什么需要缓冲?
因为I/O设备比CPU慢太多了。
如果每次只读写1个字节,效率太低。
解决方案:缓冲(Buffer)。
就像快递:
不是每次寄一个包裹就发一次车,而是积累一车货再发。
读缓冲:
// 不用缓冲:
for (int i = 0; i < 1000; i++) {
char c = read_one_byte(); // 每次读1字节,1000次I/O
}
// 用缓冲:
char buffer[1000];
read_block(buffer, 1000); // 一次读1000字节,1次I/O写缓冲:
// 不用缓冲:
for (int i = 0; i < 1000; i++) {
write_one_byte(data[i]); // 1000次I/O
}
// 用缓冲:
write_block(data, 1000); // 1次I/O缓冲的层次:
- 应用层缓冲:程序自己的缓冲
- 库缓冲:标准库提供的缓冲(如C的stdio)
- 操作系统缓冲:OS内核的缓冲
- 硬件缓冲:设备自带的缓冲
这就是为什么:
- 写文件要fflush()才能保证数据真正写入
- 读文件比写文件快(有页面缓存)
- SSD比机械硬盘快(除了物理速度,缓存策略也不同)
第七章:网络——连接世界的桥梁
7.1 网络协议:计算机之间的语言
两台计算机要通信,需要遵守共同的规则,这就是"协议"(Protocol)。
就像人类交流需要语言。
如果一个人说中文,一个人说英文,就无法沟通。
网络协议也是一样。
OSI七层模型:
矢泽久雄在书中用了邮政系统来类比网络协议栈:
7层协议,7层封装:
应用层 ← 你要寄什么内容
表示层 ← 内容的格式(文字、图片)
会话层 ← 寄信的会话(一来一回)
传输层 ← 快递还是平信(可靠性)
网络层 ← 收件地址(路由)
数据链路层 ← 快递公司的内部流程
物理层 ← 交通工具(飞机、火车)实际上,互联网用的是TCP/IP模型:
应用层 ← HTTP、FTP、SMTP、DNS...
传输层 ← TCP、UDP
网络层 ← IP
链路层 ← 以太网、WiFi...7.2 从浏览器输入URL到看到网页,发生了什么?
这是一个经典问题。让我们一步步拆解:
第1步:DNS解析
你输入:www.example.com
浏览器问:这个域名的IP地址是什么?
DNS服务器答:93.184.216.34就像你要寄信,要先查地址本,找到收件人的地址。
第2步:建立TCP连接(三次握手)
浏览器:你好,我想建立连接(SYN)
服务器:好的,我同意(SYN-ACK)
浏览器:收到,连接建立(ACK)就像打电话前的确认:
"喂,听得到吗?"
"听得到,你呢?"
"我也听得到,那我们开始说吧。"
为什么要三次握手,两次不行吗?
不行。
因为网络不可靠,数据包可能丢失、延迟、重复。
三次握手能确保双方都准备好了,而且能防止旧的连接请求干扰新连接。
第3步:发送HTTP请求
GET / HTTP/1.1
Host: www.example.com
User-Agent: Chrome/91.0
...就像你把信的内容写好,放进信封。
第4步:服务器处理请求
服务器收到请求,读取网页文件(或者动态生成),准备返回。
第5步:服务器返回HTTP响应
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1234
...
<html>
<head><title>Example</title></head>
<body>...</body>
</html>第6步:浏览器渲染页面
- 解析HTML,构建DOM树
- 解析CSS,构建样式树
- 结合DOM树和样式树,构建渲染树
- 布局:计算每个元素的位置和大小
- 绘制:在屏幕上画出来
第7步:关闭连接(四次挥手)
浏览器:我说完了(FIN)
服务器:好的,我知道了(ACK)
服务器:我也说完了(FIN)
浏览器:好的,再见(ACK)就像打电话结束时的确认:
"我说完了。"
"好的。"
"那我挂了。"
"好的,再见。"
为什么关闭需要四次挥手?
因为TCP是全双工的,两个方向的通信是独立的。
一方说完了,不代表另一方也说完了。
所以需要双方各自确认关闭。
7.3 TCP vs UDP:可靠传输 vs 高效传输
TCP(传输控制协议):可靠但慢
就像寄挂号信:
- 保证送达
- 保证顺序
- 有确认回执
- 但慢,开销大
特点:
- 面向连接(要先建立连接)
- 可靠传输(保证数据完整、有序)
- 流量控制(不让接收方来不及处理)
- 拥塞控制(不让网络太拥挤)
适用场景:
- 网页浏览(HTTP/HTTPS)
- 文件传输(FTP)
- 邮件(SMTP)
- 远程登录(SSH)
UDP(用户数据报协议):不可靠但快
就像寄平信:
- 不保证送达
- 不保证顺序
- 没有确认回执
- 但快,开销小
特点:
- 无连接(直接发,不需要建立连接)
- 不可靠(可能丢包、乱序)
- 简单高效
适用场景:
- 视频直播(丢几帧无所谓)
- 语音通话(要求实时性,丢一点可以接受)
- 在线游戏(延迟比完整性重要)
- DNS查询(失败了重发就行)
7.4 IP地址和路由:数据如何找到目的地
IP地址:互联网的门牌号
IPv4地址:32位,写成4个0-255的数字。
192.168.1.1IPv6地址:128位,写成8组16进制数字。
2001:0db8:85a3:0000:0000:8a2e:0370:7334为什么需要IPv6?
因为IPv4只有2³² ≈ 43亿个地址,不够用了。
IPv6有2¹²⁸ ≈ 3.4×10³⁸个地址,够用很久很久。
路由:数据包的导航
数据包从源主机到目的主机,可能要经过很多路由器。
每个路由器都维护一张"路由表":
目的网络 下一跳
192.168.1.0/24 直接连接
10.0.0.0/8 路由器A
0.0.0.0/0 默认网关路由器收到数据包时:
- 看目的IP地址
- 查路由表,找最匹配的条目
- 转发给下一跳
- 重复,直到到达目的地
就像快递:
- 从北京寄到上海的快递
- 先到北京分拨中心
- 再到上海分拨中心
- 最后到上海某个配送站
- 配送员送到你手上
7.5 NAT:让私有IP也能上网
问题:IPv4地址不够用
全世界几十亿设备,但IPv4只有43亿地址。
解决方案:NAT(网络地址转换)
把一个公网IP分给很多设备用。
就像公司:
- 公司只有一个门牌号(公网IP)
- 但公司里有很多员工(私有IP)
- 外面的人寄信到公司(门牌号)
- 前台收到后,根据收件人姓名,转发给对应的员工
NAT的工作原理:
家里的设备 路由器(NAT) 互联网
192.168.1.2:5000 → 公网IP:端口1 → 服务器
192.168.1.3:6000 → 公网IP:端口2 → 服务器路由器维护一张NAT表:
内部地址:端口 外部端口
192.168.1.2:5000 端口1
192.168.1.3:6000 端口2数据包出去时:
- 源地址改成公网IP
- 源端口改成分配的端口
- 记录在NAT表中
数据包回来时:
- 查NAT表
- 改回内部地址和端口
- 转发给对应设备
这就是为什么:
- 家里所有设备共用一个公网IP
- 外网看到的都是路由器的IP
- P2P通信需要"打洞"技术
第八章:程序优化——让程序跑得更快
8.1 性能优化的原则
矢泽久雄在书中强调了一个重要原则:
过早优化是万恶之源。
这是计算机科学家Donald Knuth说的。
什么意思?
不要一开始就追求极致性能,先把功能做对。
优化的步骤:
- 先写正确的代码
- 测试确保功能正确
- 性能分析(Profiling):找出瓶颈
- 优化瓶颈部分
- 测试确保优化后仍然正确
- **重复3-5,直到性能满意
记住:
- 不要优化你猜测的瓶颈,要用工具测量
- 20%的代码消耗80%的时间(帕累托法则)
- 优化这20%比优化整个程序有效得多
8.2 时间复杂度:算法的效率
什么是时间复杂度?
衡量算法运行时间随输入规模增长的速度。
常见的时间复杂度:
O(1):常数时间
不管输入多大,时间都一样。
int getFirst(int arr[]) {
return arr[0]; // 直接访问,O(1)
}O(log n):对数时间
每次操作把问题规模缩小一半。
// 二分查找
int binarySearch(int arr[], int n, int target) {
int left = 0, right = n - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}O(n):线性时间
遍历一遍数组。
int findMax(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > max) max = arr[i];
}
return max;
}O(n log n):线性对数时间
最好的排序算法的时间复杂度。
// 快速排序、归并排序O(n²):平方时间
双重循环。
// 冒泡排序
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (arr[i] > arr[j]) {
swap(arr[i], arr[j]);
}
}
}O(2ⁿ):指数时间
非常慢,要避免。
// 朴素的斐波那契递归
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2); // 指数时间!
}不同复杂度的对比:
n = 1000时:
O(1) = 1
O(log n) = 10
O(n) = 1,000
O(n²) = 1,000,000
O(2ⁿ) = 天文数字选择合适的算法,比任何微观优化都重要。
8.3 空间复杂度:内存的使用
什么是空间复杂度?
衡量算法使用的额外内存随输入规模增长的速度。
举例:
递归的斐波那契:
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}时间:O(2ⁿ),空间:O(n)(递归栈深度)
迭代的斐波那契:
int fib(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int c = a + b;
a = b;
b = c;
}
return b;
}时间:O(n),空间:O(1)
有时候可以用空间换时间:
动态规划的斐波那契:
int fib(int n) {
if (n <= 1) return n;
int dp[n+1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}时间:O(n),空间:O(n)
比递归快得多,但用了额外内存。
8.4 缓存优化:利用数据局部性
现代CPU的缓存很小但很快。
如何让程序更好地利用缓存?
关键:数据局部性(Locality)
时间局部性:
刚访问过的数据,很可能马上再次访问。
所以把它留在缓存里。
空间局部性:
访问某个数据,很可能接下来访问它附近的数据。
所以把它周围的数据也加载到缓存。
举例:
不利于缓存的写法:
// 按列访问(列优先)
for (int j = 0; j < N; j++) {
for (int i = 0; i < N; i++) {
sum += matrix[i][j]; // 跳跃访问
}
}利于缓存的写法:
// 按行访问(行优先)
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
sum += matrix[i][j]; // 连续访问
}
}为什么?
因为C语言的二维数组是按行存储的:
matrix[0][0], matrix[0][1], matrix[0][2], ...
matrix[1][0], matrix[1][1], matrix[1][2], ...按行访问是连续的,缓存命中率高。
按列访问是跳跃的,缓存命中率低。
实测差距可达数倍甚至数十倍!
8.5 分支预测:让CPU猜对
现代CPU会预测程序的分支走向。
比如:
if (x > 0) {
// 分支A
} else {
// 分支B
}CPU会猜:这次会走分支A还是分支B?
如果猜对了,就提前执行,节省时间。
如果猜错了,要回退,浪费时间。
如何提高预测准确率?
1. 让分支预测更容易
// 不好:随机的
for (int i = 0; i < n; i++) {
if (random() > 0.5) { // 无规律,难预测
doSomething();
}
}
// 好:有规律的
for (int i = 0; i < n; i++) {
if (i < n/2) { // 前半段true,后半段false,好预测
doSomething();
}
}2. 减少分支
// 不好:有分支
int max(int a, int b) {
if (a > b) return a;
else return b;
}
// 好:无分支
int max(int a, int b) {
return a > b ? a : b; // 三元运算符可能被优化成无分支代码
}3. 对数组排序后处理
这是一个反直觉的优化:
// 先排序
sort(arr, n);
// 再处理
for (int i = 0; i < n; i++) {
if (arr[i] > threshold) { // 排序后,分支更有规律
doSomething();
}
}排序需要时间,但如果数据量大,排序后的分支预测提升可能超过排序的开销。
8.6 并行化:让多核心一起工作
单核心的性能已经接近极限。
要提升性能,就要用多核心。
但并不是所有任务都能并行化。
容易并行化的任务:
// 处理100张图片,每张图片独立
for (int i = 0; i < 100; i++) {
process_image(images[i]);
}
// 可以并行:
parallel_for (int i = 0; i < 100; i++) {
process_image(images[i]);
}难以并行化的任务:
// 计算斐波那契数列,后一项依赖前一项
for (int i = 2; i < n; i++) {
fib[i] = fib[i-1] + fib[i-2]; // 依赖,无法并行
}阿姆达尔定律(Amdahl’s Law):
如果程序中50%的代码可以并行化,那么用无限多的核心,最多只能提升2倍性能。
为什么?
因为另外50%无法并行,成为瓶颈。
所以:
- 要并行化,先找出程序中可以并行的部分
- 尽可能让更多代码可以并行
- 减少核心之间的同步开销
第九章:调试与测试——让程序正确运行
9.1 程序员的噩梦:Bug
什么是Bug?
程序中的错误,导致程序:
- 崩溃
- 给出错误结果
- 行为异常
Bug的来源:
- 语法错误:写错了语法(编译器会发现)
- 逻辑错误:逻辑不对(最难发现)
- 运行时错误:运行时出现异常情况(除零、空指针等)
- 资源错误:内存泄漏、文件未关闭等
为什么叫"Bug"(虫子)?
1947年,Grace Hopper在调试Harvard Mark II计算机时,发现一只飞蛾卡在继电器里,导致计算机故障。
她把飞蛾贴在日志本上,写道:"First actual case of bug being found."
从此,程序错误被称为"Bug"。
9.2 调试的艺术
调试的步骤:
1. 重现Bug
如果不能稳定重现,就无法调试。
2. 隔离问题
用二分法,逐步缩小问题范围。
3. 理解问题
弄清楚为什么会出错。
4. 修复Bug
修改代码。
5. 测试
确保修复后没有引入新Bug。
调试的工具:
1. 打印语句
最简单但有效:
printf("DEBUG: x = %dn", x);2. 断点调试
用调试器(GDB、LLDB、VS Debugger):
- 设置断点
- 单步执行
- 查看变量
- 查看调用栈
3. 日志
记录程序运行状态:
log("INFO", "User logged in: %s", username);
log("ERROR", "Failed to open file: %s", filename);4. 断言
检查假设是否成立:
assert(x > 0); // 如果x <= 0,程序终止,报告错误调试的技巧:
1. 橡皮鸭调试法
向橡皮鸭(或任何东西)解释你的代码,往往能发现问题。
2. 休息一下
累了的时候,很难发现Bug。休息一下,换个角度看问题。
3. 查看相关代码
Bug可能不在你认为的地方,要扩大搜索范围。
4. 使用版本控制
用Git查看最近的修改,找出引入Bug的提交。
9.3 测试的重要性
"测试不能证明程序正确,只能证明程序有错。"
—— Edsger Dijkstra
但测试仍然非常重要。
测试的类型:
1. 单元测试
测试单个函数或模块。
void test_add() {
assert(add(2, 3) == 5);
assert(add(-1, 1) == 0);
assert(add(0, 0) == 0);
}2. 集成测试
测试多个模块组合在一起的行为。
3. 系统测试
测试整个系统的功能。
4. 回归测试
确保新代码没有破坏旧功能。
测试驱动开发(TDD):
- 先写测试
- 运行测试(会失败,因为还没写代码)
- 写代码,让测试通过
- 重构代码
- 重复
TDD的好处:
- 强制思考需求
- 保证代码可测试
- 回归测试自动化
- 增加信心
第十章:总结——程序运行的完整画卷
10.1 从源代码到运行:完整的旅程
让我们回顾一下,一个程序从编写到运行的完整过程:
第1步:编写源代码
程序员用高级语言(C、Python、Java等)编写源代码。
第2步:编译/解释
- 编译型语言:源代码 → 编译器 → 目标代码
- 解释型语言:源代码 → 解释器 → 直接执行
第3步:链接
把多个目标文件和库文件链接成可执行文件。
第4步:加载
操作系统把可执行文件加载到内存:
- 分配虚拟地址空间
- 加载代码段、数据段
- 加载动态链接库
- 设置栈和堆
- 跳转到程序入口点
第5步:执行
CPU开始执行指令:
while (程序未结束) {
取指令 (Fetch)
解码 (Decode)
执行 (Execute)
}第6步:与系统交互
程序通过系统调用与操作系统交互:
- 读写文件
- 网络通信
- 创建进程/线程
- 分配内存
- …
第7步:结束
程序执行完毕,返回退出码,操作系统回收资源。
10.2 计算机的本质:0和1的舞蹈
说到底,计算机做的所有事情,都是在操作0和1。
CPU只认识机器码,机器码只有0和1。
内存存储的是0和1。
硬盘存储的是0和1。
网络传输的是0和1。
但是,通过巧妙的组织和抽象,这些0和1创造了一个复杂而精彩的数字世界。
就像汉字,笔画只有横竖撇捺折,但能表达无穷的思想。
就像音乐,只有7个音符,但能创作出无数动听的旋律。
就像生命,只有4种碱基(ATCG),但能创造出丰富多彩的生命世界。
计算机也是一样,用最简单的规则,创造最复杂的世界。
10.3 矢泽久雄想告诉我们什么?
读完《程序是怎样跑起来的》,我们会发现,矢泽久雄想传达的核心思想是:
1. 简单的规则可以创造复杂的系统
CPU只会做简单的加法、移位、比较,但能完成所有计算。
2. 抽象是关键
从机器码到汇编,到高级语言,每一层抽象都让编程更简单。
3. 底层决定性能
了解底层原理,才能写出高效的程序。
4. 系统思维
程序不是孤立的,要理解程序与CPU、内存、操作系统、网络的关系。
5. 持续学习
技术在不断进步,但基本原理不变。掌握原理,就能适应变化。
写在最后的话:给每一个想了解计算机的你
这不仅仅是一本技术书
当我第一次读《程序是怎样跑起来的》时,我被震撼了。
不是因为它的技术深度,而是因为它的清晰。
它用最简单的语言,解释了最复杂的系统。
它让我第一次真正理解了,计算机是怎么工作的。
我们生活在一个数字化的时代
今天,计算机无处不在:
- 手机、电脑、平板
- 汽车、家电、手表
- 医院、银行、学校
- 工厂、农场、办公室
我们每个人,每天都在使用计算机。
但有多少人真正理解计算机?
理解计算机,就是理解这个世界
计算机不是魔法,是科学。
理解计算机的工作原理,你会发现:
1. 世界变得更清晰
你知道为什么电脑会卡,为什么程序会崩溃,为什么网络会慢。
2. 问题变得更简单
当你理解了原理,很多问题的解决方案就显而易见了。
3. 机会变得更多
在这个数字化的时代,懂计算机就多一份竞争力。
这20000字,是一个开始
这篇文章,浓缩了《程序是怎样跑起来的》的核心内容。
但书中还有更多细节,更多例子,更多思考。
如果你觉得这篇文章有用,我强烈建议你去读原书。
如果你是程序员,这本书会帮你理解底层原理,写出更好的代码。
如果你不是程序员,这本书会帮你理解计算机,更好地使用它。
计算机科学是人类最伟大的发明之一
从1946年第一台电子计算机ENIAC诞生,到今天的智能手机、人工智能,只用了不到80年。
80年,我们创造了一个全新的世界。
这个世界里:
- 信息瞬间传遍全球
- 知识触手可及
- 创造力得到释放
- 可能性无限扩展
而这一切,都建立在0和1的基础上。
致敬矢泽久雄
感谢矢泽久雄写了这本书。
感谢他用如此清晰的方式,解释了如此复杂的系统。
这本书,是给所有想了解计算机的人的礼物。
致敬每一个好奇的灵魂
如果你读到了这里,说明你对计算机有好奇心。
保持这份好奇心。
继续学习,继续探索,继续思考。
这个世界有无限的可能,等待你去发现。
延伸阅读:如果你想了解更多
读完《程序是怎样跑起来的》,如果你想继续深入学习,这里有一些建议:
入门级书籍
1. 《编码:隐匿在计算机软硬件背后的语言》
作者:Charles Petzold
用最通俗的语言,从电报、继电器讲到现代计算机。
2. 《深入理解计算机系统(CSAPP)》
作者:Randal E. Bryant, David R. O’Hallaron
计算机系统的经典教材,但比较深入,适合有编程基础的读者。
3. 《计算机是怎样跑起来的》
作者:矢泽久雄
《程序是怎样跑起来的》的姊妹篇,讲计算机硬件。
进阶主题
如果你对CPU感兴趣:
- 《计算机组成与设计》(Patterson & Hennessy)
- 《计算机体系结构:量化研究方法》
如果你对操作系统感兴趣:
- 《操作系统导论》(OSTEP)
- 《现代操作系统》(Tanenbaum)
如果你对网络感兴趣:
- 《计算机网络:自顶向下方法》
- 《TCP/IP详解》
如果你对编译原理感兴趣:
- 《编译原理》(龙书)
- 《程序员的自我修养:链接、装载与库》
实践项目
理论和实践要结合。
1. 写一个简单的操作系统
MIT的6.828课程,带你从零开始写一个操作系统。
2. 实现一个编译器
斯坦福的CS143课程,教你实现一个完整的编译器。
3. 做一个CPU模拟器
模拟CPU的指令执行过程,加深对CPU工作原理的理解。
4. 参与开源项目
在GitHub上找一个感兴趣的项目,阅读代码,提交PR。
在线资源
Coursera / edX
世界顶尖大学的计算机课程。
YouTube
- Computerphile:计算机科学的科普频道
- MIT OpenCourseWare:MIT的公开课
中文资源
- 中国大学MOOC
- bilibili上的计算机课程
- 各种技术博客和公众号
后记:一个普通人对计算机的思考
我为什么要写这篇文章?
我不是计算机专业出身。
我是在工作中,因为需要,才开始学习编程。
一开始,我觉得计算机就是魔法。
为什么敲几行代码,就能实现这么复杂的功能?
为什么程序会跑?为什么会崩溃?
这些问题困扰了我很久。
直到我读了《程序是怎样跑起来的》。
这本书解答了我所有的疑惑。
它让我明白:
计算机不是魔法,是工程。
是无数工程师,用智慧和汗水,一点一点构建起来的。
我们都是这个数字时代的见证者
2000年,互联网刚开始普及。
2007年,第一代iPhone发布。
2010年,移动互联网爆发。
2016年,AlphaGo战胜李世石。
2022年,ChatGPT横空出世。
我们见证了技术的飞速发展。
从拨号上网,到5G时代。
从功能机,到智能手机。
从人工智能还是科幻,到AI已经改变生活。
这个时代,每个人都应该了解计算机
你可能会说:我又不是程序员,为什么要了解这些?
我的回答是:
1. 这是基本素养
就像100年前,识字是基本素养。
今天,理解计算机是基本素养。
2. 这会让你更好地工作
无论你做什么工作,都会用到计算机。
理解计算机,你会用得更好。
3. 这会保护你
理解计算机,你就不容易被诈骗,不容易泄露隐私。
4. 这会给你力量
理解工具,你就能掌控工具。
而不是被工具控制。
写给年轻的你
如果你是一个学生,正在考虑未来的方向。
我想告诉你:
计算机科学是一个充满机会的领域。
这个领域:
- 充满挑战,需要不断学习
- 充满创造,可以实现想法
- 充满机会,回报丰厚
- 充满意义,改变世界
但更重要的是:
不要只学习技术,要理解原理。
不要只追求新技术,要掌握基础。
不要只写代码,要思考为什么。
写给同行的你
如果你是一个程序员,可能已经工作多年。
我想告诉你:
不要忘记当初的好奇心。
不要只是写代码,要理解代码为什么这样写。
不要只是完成任务,要思考有没有更好的方法。
读一读《程序是怎样跑起来的》这样的书。
回到基础,往往能发现新的东西。
写给未来的自己
当我写完这20000字时,已经是深夜。
窗外万家灯火,每盏灯下,可能都有一台电脑在运行。
这些电脑里,运行着无数的程序。
这些程序,是无数程序员的心血。
它们在默默地服务着人类。
我希望:
将来回看这篇文章时,我仍然保持着对技术的热爱。
仍然保持着对世界的好奇。
仍然愿意花时间,去理解事物的本质。
结语:0和1的诗篇
让我用一段诗一样的文字,结束这篇长文:
在这个世界上
有两种数字
0 和 1
看似简单
却能创造一切
它们是
计算机的DNA
程序的基石
数字世界的起源
从 0 到 1
是开关的闭合
是电流的流动
是逻辑的判断
从 1 到无穷
是无限的可能
是创造的力量
是人类的智慧
每一个程序
都是 0 和 1 的诗篇
每一行代码
都是思想的结晶
每一次运行
都是智慧的舞蹈
在这个数字时代
我们都是舞者
用 0 和 1
编织梦想
用代码
改变世界
用智慧
创造未来– 全文完 –
