一文读懂《程序是怎样跑起来的》

《程序是怎样跑起来的》深度解析:从零到一,揭开计算机运行的神秘面纱

——一场关于0和1的奇幻之旅

写在前面的话

当你双击鼠标打开一个应用程序时,你有没有想过:

这个程序,到底是怎么跑起来的?

当你在键盘上敲下一行代码,按下回车键,屏幕上显示出结果时:

计算机内部,到底发生了什么?

当你看着手机里的APP运行得如此流畅,处理着复杂的计算时:

这一切的背后,隐藏着怎样的秘密?

今天,我要带你读一本书——日本作家矢泽久雄的《程序是怎样跑起来的》。

这不是一本普通的技术书籍。

这是一本能让你真正理解计算机本质的书。

这是一本能让你从"会用电脑"到"懂电脑"的书。

更重要的是,这是一本能让你重新认识这个数字世界的书。

准备好了吗?

让我们开始这场关于0和1的奇幻之旅。


第一章:CPU——计算机的大脑,到底是如何思考的?

1.1 从一个简单的问题开始

让我先问你一个问题:

1 + 1 = ?

你会说:等于2啊,这还用问?

好,那我再问你:

计算机是怎么知道1 + 1 = 2的?

你可能会说:这不是很简单吗?程序员写好了加法程序,计算机执行就行了。

但是,计算机为什么能"执行"?

它到底是怎么"执行"的?

这就是我们要探讨的第一个核心问题。

1.2 CPU的本质:一个超高速的计算器

矢泽久雄在书中用了一个非常巧妙的比喻:

CPU就像一个超级勤奋的小学生,每秒钟能做几十亿次算术题。

但这个小学生有点特殊:

它只会做非常非常简单的算术题。

简单到什么程度?

它只会做这些事情:

  1. 加法:把两个数字加起来
  2. 移位:把一个数字的二进制位左移或右移
  3. 比较:判断两个数字哪个大
  4. 存取:把数字放到某个地方,或者从某个地方取出来

就这么简单。

但就是这些简单的操作,组合起来,就能完成所有复杂的计算。

就像汉字,笔画只有横竖撇捺折,但能组成成千上万个字。

就像音乐,只有7个音符,但能创作出无数动听的旋律。

这就是计算机的魔法:用最简单的规则,创造最复杂的世界。

1.3 CPU的工作原理:取指令-解码-执行

让我用一个生活化的例子来解释CPU的工作原理。

想象你是一个厨师,正在做一道菜。

你面前有一本菜谱(程序),菜谱上写着:

步骤1:拿出鸡蛋
步骤2:打碎鸡蛋
步骤3:搅拌鸡蛋
步骤4:倒入锅中
步骤5:翻炒3分钟

你会怎么做?

第一步:看菜谱(取指令)

你看第一行:拿出鸡蛋。

第二步:理解菜谱(解码)

你明白了,这是要我从冰箱里拿出鸡蛋。

第三步:执行操作(执行)

你走到冰箱前,打开冰箱,拿出鸡蛋。

然后呢?

你继续看第二行,重复这个过程。

CPU的工作原理,就是这样:

  1. 取指令(Fetch):从内存中读取下一条要执行的指令
  2. 解码(Decode):理解这条指令要做什么
  3. 执行(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个小时对吧?

但实际上不是。

为什么?

因为:

  1. 有些菜必须按顺序做:比如先炒菜,后装盘
  2. 厨师之间需要协调:谁做什么,谁用哪个炉灶
  3. 资源是有限的:炉灶、锅、调料都是有限的

多核心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就是你的大脑,决定要画什么,怎么画。

内存就是你的画布和调色板,存放着你正在创作的内容。

硬盘就是你的仓库,存放着已经完成的画作和备用材料。

当你要创作时:

  1. 从仓库(硬盘)拿出画布和颜料
  2. 放到工作台(内存)上
  3. 开始创作(CPU处理)
  4. 完成后,把作品放回仓库(保存到硬盘)

程序的运行,就是这样一个过程。

2.2 内存的地址:每个"房间"都有门牌号

矢泽久雄在书中用了一个非常形象的比喻:

内存就像一栋巨大的公寓楼。

这栋楼有几十亿个房间(内存单元)。

每个房间能存放1个字节(8位)的数据。

每个房间都有一个唯一的门牌号,叫做"地址"(Address)。

比如:

地址 0x0000: 存放数据 01010101
地址 0x0001: 存放数据 11001100
地址 0x0002: 存放数据 00110011
...

当CPU需要某个数据时,它会告诉内存:"给我地址0x0042的数据"。

内存就会找到这个地址,把数据返回给CPU。

这就是"内存寻址"。

2.3 为什么内存需要地址?

你可能会问:为什么不直接说"给我那个数据",而要用地址?

因为内存中有太多数据了。

就像你去图书馆借书:

你不能跟图书管理员说:"给我那本书。"

你要说:"给我编号A123-456的那本书。"

地址,就是内存中数据的"编号"。

而且,有了地址,我们就能:

  1. 快速定位:知道数据在哪里
  2. 随机访问:可以直接跳到任意地址读取数据
  3. 程序控制:程序可以通过改变地址,访问不同的数据

这就是为什么内存也叫"随机存取存储器"(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)

位运算有什么用?

  1. 高效:位运算是CPU直接支持的,速度极快
  2. 节省空间:可以用一个整数存储多个布尔值
  3. 加密:很多加密算法都用到位运算
  4. 图形处理:颜色混合、透明度计算等

举个例子:

假设你要判断一个数是不是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 编译和解释:两种执行方式

高级语言写的程序,怎么变成机器能执行的代码?

有两种方式:编译解释

编译:一次性翻译

就像翻译一本书:

  1. 把整本书从英文翻译成中文
  2. 出版中文版
  3. 以后直接读中文版

编译型语言(C、C++、Go等):

源代码 → 编译器 → 机器码 → 执行

优点:

  • 执行速度快(已经是机器码了)
  • 不需要额外的运行环境

缺点:

  • 编译需要时间
  • 不同平台需要重新编译
  • 发现错误较晚(编译完才知道)

解释:逐句翻译

就像口译:

  1. 听一句英文
  2. 翻译成中文
  3. 说出来
  4. 重复

解释型语言(Python、JavaScript、Ruby等):

源代码 → 解释器 → 逐行执行

优点:

  • 跨平台(有解释器就能运行)
  • 开发快,调试方便
  • 动态特性强

缺点:

  • 执行速度慢(每次都要翻译)
  • 需要解释器环境

还有一种混合方式:先编译成中间代码,再解释执行。

比如Java:

源代码 → 编译 → 字节码 → JVM解释执行

这样既有编译的优化,又有跨平台的灵活性。

4.3 链接:把零散的代码组装起来

一个大型程序,不可能写在一个文件里。

怎么办?

分成多个文件,分别编译,然后"链接"起来。

就像造汽车:

  1. 不同的工厂造不同的零件(编译不同的源文件)
  2. 最后在总装厂组装(链接)

链接器的工作:

  1. 符号解析:找到函数和变量的定义
  2. 地址分配:给每个函数和变量分配内存地址
  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

链接器做了什么?

  1. 发现main.o中调用了add函数
  2. 在add.o中找到add函数的定义
  3. 把两个文件组合成一个可执行文件
  4. 修正main.o中对add函数的调用地址

这就是链接。

4.4 静态链接 vs 动态链接

链接有两种方式:静态链接动态链接

静态链接:全部打包

就像去旅行,把所有东西都装进行李箱。

优点:

  • 不依赖外部库
  • 运行时不需要其他文件

缺点:

  • 可执行文件很大
  • 浪费内存(每个程序都有一份相同的库代码)
  • 库更新了,程序要重新编译

动态链接:按需加载

就像去旅行,只带必需品,其他的到当地买。

优点:

  • 可执行文件小
  • 多个程序共享同一个库,节省内存
  • 库更新了,程序自动受益

缺点:

  • 依赖外部库(DLL、SO文件)
  • "DLL地狱"问题(版本冲突)

这就是为什么:

  • Windows程序经常缺DLL文件
  • Linux要安装各种so库
  • macOS的应用经常很大(静态链接了很多库)

4.5 加载器:把程序装入内存

编译、链接完成后,得到了一个可执行文件。

但是,可执行文件不能直接执行!

为什么?

因为可执行文件在硬盘上,程序要在内存中执行。

谁来把程序从硬盘加载到内存?

加载器(Loader)。

加载器的工作:

  1. 读取可执行文件:从硬盘读取
  2. 分配内存:为程序分配虚拟地址空间
  3. 加载代码和数据:把程序的代码段、数据段加载到内存
  4. 加载动态库:如果用了动态链接库,加载这些库
  5. 重定位:调整程序中的地址引用
  6. 设置栈和堆:为程序分配栈空间和堆空间
  7. 跳转到入口点:开始执行程序

这就是为什么:

  • 双击程序图标后,要等一会儿才能启动
  • 大型程序启动慢(加载的东西多)
  • 有些程序有"启动画面"(在加载时给用户看)

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;
}

为什么要这样设计?

  1. 分离代码和数据:代码不能被修改,提高安全性
  2. 栈和堆分开:不同的生命周期,不同的管理方式
  3. 栈向下、堆向上:充分利用地址空间

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步:返回

  1. 把返回值(8)放到特定寄存器(比如EAX)
  2. 恢复栈:清理局部变量,恢复栈指针
  3. 跳转到返回地址

第8步:清理参数

main函数清理参数(有些调用约定是函数自己清理)。

栈:
+-----+
(回到main函数的栈状态)

这就是函数调用的完整过程!

这个设计的精妙之处:

  1. 递归成为可能:每次调用都有独立的栈帧
  2. 自动清理:函数返回时,栈自动恢复
  3. 嵌套调用:可以无限嵌套(只要栈不溢出)

这也解释了为什么:

  • 递归太深会"栈溢出"(Stack Overflow)
  • 局部变量在函数返回后失效
  • 不能返回局部变量的地址(会指向无效的栈空间)

第五章:操作系统——程序运行的管家

5.1 什么是操作系统?

如果把计算机比作一家酒店:

  • 硬件是酒店的建筑、设施
  • 操作系统是酒店的管理系统
  • 应用程序是酒店的客人

操作系统的核心职责:

  1. 管理硬件:让不同的程序共享硬件资源
  2. 提供接口:让程序员不用直接操作硬件
  3. 保证安全:防止程序互相干扰
  4. 提高效率:让硬件资源得到充分利用

没有操作系统会怎样?

就像没有酒店管理系统:

  • 客人要自己找房间
  • 客人可能住到别人的房间
  • 没人管理水电
  • 一片混乱

5.2 进程:程序的运行实例

矢泽久雄在书中用了一个非常形象的比喻:

程序像一份菜谱,进程像一次做菜的过程。

同一份菜谱,可以同时被多个厨师使用,每个厨师做出来的菜都是独立的。

程序是静态的代码,存放在硬盘上。

进程是动态的执行实例,运行在内存中。

一个程序可以有多个进程。

比如:

  • 你可以打开多个Chrome浏览器窗口(多个进程)
  • 但它们都来自同一个程序(chrome.exe)

每个进程有:

  1. 独立的地址空间:自己的代码、数据、栈、堆
  2. 独立的资源:文件句柄、网络连接等
  3. 独立的执行状态:程序计数器、寄存器等

进程之间是隔离的。

一个进程崩溃了,不会影响其他进程。

这就是为什么:

  • Chrome一个标签页崩溃,其他标签页还能用
  • 一个程序死掉,不会让整个系统崩溃

5.3 线程:进程内的执行流

如果进程是一家公司,线程就是公司的员工。

一家公司(进程)可以有多个员工(线程),他们共享公司的资源(内存、文件等),但各自做不同的事情。

线程的特点:

  1. 轻量级:创建和切换比进程快得多
  2. 共享内存:同一个进程的线程共享地址空间
  3. 独立执行:每个线程有自己的栈和程序计数器

多线程的优势:

  1. 并发处理:同时做多件事
  2. 响应快:一个线程等待时,其他线程继续执行
  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只叉子,每两个哲学家之间放一只。

吃面需要两只叉子。

如果每个哲学家同时拿起左边的叉子:

  1. 每个人都拿到了一只叉子
  2. 每个人都在等右边的叉子
  3. 但右边的叉子被右边的人拿着
  4. 所有人都在等待,谁也无法继续

这就是死锁!

死锁的四个必要条件:

  1. 互斥:资源一次只能被一个线程使用
  2. 占有并等待:线程持有资源,同时等待其他资源
  3. 不可剥夺:资源不能被强制夺走
  4. 循环等待:存在一个线程-资源的循环等待链

打破任意一个条件,就能避免死锁。

比如:

  • 规定所有哲学家必须先拿编号小的叉子(打破循环等待)
  • 如果拿不到两只叉子,就放下已经拿到的(打破占有并等待)

5.6 进程调度:让CPU忙起来

一个CPU,怎么同时运行多个程序?

答案:并不是真的"同时",而是"快速切换"。

就像一个杂技演员,同时抛接多个球。

其实他每次只接一个球,但切换得很快,看起来像同时。

这就是"时间片轮转"。

操作系统给每个进程分配一个时间片(比如10毫秒):

  1. 进程A运行10毫秒
  2. 切换到进程B,运行10毫秒
  3. 切换到进程C,运行10毫秒
  4. 切换回进程A,运行10毫秒

切换得很快,用户感觉不到。

但并不是所有进程都平等。

操作系统会根据优先级调度:

  • 实时进程:必须在规定时间内完成(音视频播放)
  • 交互式进程:需要快速响应(鼠标键盘输入)
  • 批处理进程:不着急,可以慢慢来(后台下载)

这就是为什么:

  • 玩游戏时,游戏进程优先级高,画面流畅
  • 后台下载不影响前台操作
  • 音乐播放不会卡顿

5.7 内存管理:让每个程序都有自己的空间

前面我们讲过虚拟内存,这里再深入一点。

操作系统的内存管理有几个核心任务:

1. 地址映射

把虚拟地址映射到物理地址。

2. 内存保护

防止进程访问其他进程的内存。

3. 内存共享

允许进程共享某些内存(比如共享库)。

4. 内存回收

回收不用的内存,给其他进程使用。

分页(Paging)机制:

内存被分成固定大小的"页"(通常4KB)。

虚拟地址空间也分成同样大小的"页"。

操作系统维护一个"页表":

虚拟页号 → 物理页号
0        → 100
1        → 523
2        → 234
...

当程序访问虚拟地址时:

  1. CPU把虚拟地址分成"页号"和"页内偏移"
  2. 查页表,找到物理页号
  3. 组合物理页号和页内偏移,得到物理地址
  4. 访问物理内存

这个过程由MMU(内存管理单元)硬件完成,非常快。

5.8 文件系统:让数据持久化

程序关闭后,内存中的数据就没了。

怎么保存数据?

存到硬盘上,以文件的形式。

但硬盘只是一块一块的存储空间,怎么组织成"文件"?

这就需要文件系统。

文件系统做了什么:

  1. 组织数据:把数据组织成文件和目录
  2. 分配空间:决定文件存放在硬盘的哪个位置
  3. 管理元数据:文件名、大小、创建时间等
  4. 提供接口:让程序能方便地读写文件

常见的文件系统:

  • FAT32:简单,兼容性好,但单文件不能超过4GB
  • NTFS:Windows的默认文件系统,功能强大
  • ext4:Linux的默认文件系统,性能好
  • APFS:macOS的新文件系统,针对SSD优化

文件系统的核心数据结构:

1. i-node(索引节点)

存储文件的元数据:

  • 文件大小
  • 创建/修改时间
  • 权限
  • 数据块的位置

2. 目录

存储文件名到i-node的映射。

3. 数据块

存储文件的实际内容。

举个例子:

你要读取文件/home/user/document.txt

  1. 从根目录/开始
  2. 找到home目录的i-node
  3. home目录中找到user的i-node
  4. user目录中找到document.txt的i-node
  5. 根据i-node中的信息,找到数据块
  6. 读取数据块,得到文件内容

这就是文件系统的工作原理。


第六章:输入输出——程序与外部世界的桥梁

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只需要:

  1. 告诉DMA控制器:从哪里读,写到哪里,读多少
  2. DMA控制器自己完成数据传输
  3. 完成后,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

缓冲的层次:

  1. 应用层缓冲:程序自己的缓冲
  2. 库缓冲:标准库提供的缓冲(如C的stdio)
  3. 操作系统缓冲:OS内核的缓冲
  4. 硬件缓冲:设备自带的缓冲

这就是为什么:

  • 写文件要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步:浏览器渲染页面

  1. 解析HTML,构建DOM树
  2. 解析CSS,构建样式树
  3. 结合DOM树和样式树,构建渲染树
  4. 布局:计算每个元素的位置和大小
  5. 绘制:在屏幕上画出来

第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.1

IPv6地址: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       默认网关

路由器收到数据包时:

  1. 看目的IP地址
  2. 查路由表,找最匹配的条目
  3. 转发给下一跳
  4. 重复,直到到达目的地

就像快递:

  • 从北京寄到上海的快递
  • 先到北京分拨中心
  • 再到上海分拨中心
  • 最后到上海某个配送站
  • 配送员送到你手上

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说的。

什么意思?

不要一开始就追求极致性能,先把功能做对。

优化的步骤:

  1. 先写正确的代码
  2. 测试确保功能正确
  3. 性能分析(Profiling):找出瓶颈
  4. 优化瓶颈部分
  5. 测试确保优化后仍然正确
  6. **重复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的来源:

  1. 语法错误:写错了语法(编译器会发现)
  2. 逻辑错误:逻辑不对(最难发现)
  3. 运行时错误:运行时出现异常情况(除零、空指针等)
  4. 资源错误:内存泄漏、文件未关闭等

为什么叫"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):

  1. 先写测试
  2. 运行测试(会失败,因为还没写代码)
  3. 写代码,让测试通过
  4. 重构代码
  5. 重复

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
编织梦想
用代码
改变世界
用智慧
创造未来

– 全文完 –

发表回复