1 一个 Hello, World 程序的生命周期
本节中可能会引入大量全新的概念,但请不要慌张——这些概念都会在后续章节中进行详细的阐述。现在只需要形成一个模糊的但能够纵览整个课程内容的认知就好。
本节内容需要随时在某个概念被详细介绍后添加指向对应介绍笔记的链接。
这是一个十分普通的,能够向标准输出流输出一行字符串 Hello, world! 的 C 程序:
#include <stdio.h>
int main()
{
printf("Hello, world!\n");
return 0;
}
让我们来纵览一下它从被创建到执行完成的一整个生命周期吧。
源文件
上面的代码本质上仍然只是我们手动写入到一个文件名为 hello.c 的文本文件中的字符串。它们在硬盘中以一系列值 0 与 1 的位(bit)序列的形式存放。8 个位为一个字节(byte)。现代计算机采用 UTF-8 或 ASCII 标准,为每一个特定的 bit 序列赋予一个字符的意义。计算机根据读取到的 bit 序列,将其解释为对应的字符,并在屏幕上显示出来。
不仅仅是文本——计算机系统中所有的信息都是由一串串 bit 序列表示的。而读取这些 bit 时所处的 环境 决定了我们如何理解、区分这些 bit 序列。2 信息的表示与处理 会讲解字符、整数、小数等基本数据类型的存储与表示方式。
翻译
我们书写的 C 语言程序虽然能够被有编程基础的人类读懂,但机器却无法理解这些指令。因此,为了在机器上实际运行该程序,我们需要将这些高级语言代码经一些程序转化为一系列机器可以理解并执行的 低级机器指令。这些指令以 可执行目标程序 的格式打好包,存放在二进制磁盘文件中。
在 Unix 系统中,从源文件到目标程序的转化是由 编译系统 完成的。例如 gcc、clang、msvc 等 C 语言编译器。注意这里所述的“编译”是一个十分笼统的概念,实际上,其由 预处理、编译、汇编、链接 四个主要阶段组成。执行每个阶段的程序都不同,所有这些程序一同构成了一套复杂的编译工具链。我们以 gcc 为例讲解这四个阶段:
- 预处理阶段:预处理器(cpp)根据源程序中所有以
#开头的命令(这被称作预处理命令)对源文件作修改。例如在上面的程序中,cpp 会根据#include <stdio.h>命令寻找到stdio.h这一系统库文件,并将其内容直接插入到该源文件中。预处理阶段得到一个以.i为扩展名的文本文件。 - 编译阶段:编译器(ccl)将文本文件
hello.i翻译为包含一系列 汇编指令 的汇编语言程序hello.s,该程序包含函数main的定义。 - 汇编阶段:汇编器(as)将
hello.s翻译为机器语言指令,并打包为 可重定位目标程序(Relocatable Object Program) 的格式,将结果保存在二进制文件hello.o中,其包含了函数main的指令编码。 - 链接阶段:我们书写的程序调用了 C 标准库中的函数
printf,而它并不存在于hello.o而是存在一个单独的预编译目标文件printf.o中。因此,链接器(ld)负责将二者合并,并得到最终的文件名为hello的可执行文件。
gcc 默认使用动态链接方法,即在编译阶段仅记录对库文件(如 .so 共享库)的依赖关系,真正的库代码在程序运行时由操作系统动态加载到内存中。动态链接生成的可执行程序体积小,当多个程序共用同一库函数时内存占用低,但其依赖于外部库环境。
通过在编译选项中加入 -static 选项,可以使用静态链接编译可执行文件,其包含所有依赖库的代码,独立性强,兼容性高,不受运行时库环境的影响,但带来的缺点就是生成的程序体积大。
运行前
在 Unix 系统中,我们需要一个名叫 shell 的应用程序来运行编译好的程序。shell 是一个命令行解释器,其等待用户输入一条命令,并执行输入的命令。在 GNU/Linux 中,我们将 shell 定位到 hello 程序所在的文件夹,并输入
./hello
即可运行该程序。
硬件系统
为了理解 hello 程序在被执行时究竟发生了什么,我们需要先对计算机的基本架构有一个基本认知。
这是一台典型的 PC 机的硬件组成:

其由如下几个主要部分组成:
- 总线:为贯穿整个计算机系统的一组电子管道,其负责在各个器件间传递信息。总线以传递一个固定的字节数为单位传送数据,该单位被称作字(word),一个字包含的 bit 数被称作字长。
- I/O 设备:它们是计算机与外部世界相联系的通道。显示器、鼠标、键盘、USB等都属于 I/O 设备。每个 I/O 设备都通过一个控制器或适配器与 I/O 总线相连。控制器与适配器的主要区别在于其封装方式。控制器通常是某个系统的主控芯片或专用集成电路,负责控制和管理硬件设备;而控制器的主要任务是转换接口或信号,使不同标准的设备相互兼容。
- 主存:负责存储程序与程序处理的数据供处理器处理。从物理上来说,其由一组 动态随机存取存储器(Dynamic Random Access Memory,DRAM) 芯片组成;从逻辑上来说,其是一个线性的字节数组,每个字节都有唯一的地址作为索引编址。
- 处理器:全称是 中央处理单元(Central Processing Unit,CPU),负责解释并执行主存中存储的程序指令。CPU 中有一个 程序计数器(Program Counter,PC) 指向某个地址,该地址存放着 CPU 将要执行的下一条指令。处理器按照一个特定的指令执行模型来操作,这被称为该处理器的 指令集。CPU 每次从 PC 指向的地址处取出一条指令,解释并执行该指令,随后更新 PC 使其指向下一条指令。这些操作一般都围绕主存、寄存器(Register) 与 算术逻辑单元(ALU) 进行,例如加载、存储、操作、跳转等。现代处理器使用了非常复杂的机制(例如分支预测)来加速程序的执行,因此需要对 CPU 的指令集架构与微体系结构进行区分:前者描述了每条机器代码指令的效果,而后者描述了处理器如何实现这些效果。
运行
接下来我们来从一个非常宏观、粗略的视角来分析运行 hello 程序时究竟发生了什么:
起初我们处于 shell 程序中,它正等待我们输入一个命令。
我们从键盘上键入 ./hello 命令,CPU 将字符逐一读入寄存器,然后放入主存中。当我们按下回车键后,shell 程序知道我们结束了命令输入,随后其会执行一系列指令(从硬件角度来看这些都是 CPU 完成的)来加载存储在硬盘上的 hello 可执行文件:将该文件的代码与数据从磁盘复制到主存,执行代码。这些代码涉及了向标准输出流上输出字符串 Hello, world!,因此这些字符串先被复制到 CPU 的寄存器中,再从寄存器被复制到显示设备中,最终显示在屏幕上。如下图所示:

在过去,所有的数据存取操作都需要 CPU 完成,CPU 从磁盘中读取数据存入寄存器,随后 CPU 再将寄存器的内容存入主存中。由于当时 CPU 的运行频率远远高于其余部件,这样做可以加快数据读写。然而在今天,各种外围设备的运行频率也得到了极大提升,如果仍然让 CPU 处理所有的数据存取操作将极大拖慢 CPU 执行指令的效率。因此,直接存储器访问(Direct Memory Access,DMA) 技术应运而生,该技术允许数据从一个地址空间在无 CPU 干预的情况下直接复制到另外一个地址空间,使得外设与存储器、存储器与存储器之间直接的高速数据传输成为可能。如下图所示:

即使有着 DMA 技术的存在,从上面的例子中仍然可以看出程序执行时会将大量时间花费在把数据从一处移动到另一处。因此为了加快数据的读取速度,加快数据的传输过程必不可少。然而,存储器的”数据容量、存取速度、单位容量的造价“构成了一个不可能三角。寄存器文件一般只能缓存几字节至几百字节的数据,而当前计算机的主存容量以 GB 为单位衡量,磁盘的容量甚至可以达到几 TB 至几百 TB。此外,处理器与主存之间的读取速度差距也在增大:例如目前 DDR4 内存的时钟频率为 4800MHz,而 CPU 的时钟频率却能达到 3GHz。为了解决这种问题,计算机设计者采取了两种方法:
- 一是使用 高速缓存(Cached Memory) 暂存处理器近期 可能 需要用到的数据。这种高速缓存一般是 多级 的,L1 缓存的容量范围在 128 KB 到 2 MB 之间,与单个 CPU 核心直接相连,读写 L1 缓存的速度几乎与读写寄存器的速度等同;L2 缓存可以为每个核心独有,也可以是共享的,具体取决于 CPU 的设计,L2 的容量量范围在 256 KB 到 32 MB 之间,通过一条特殊的总线连接到 CPU,其读写速度比 L1 缓存略慢;L3 缓存响应速度最慢但容量最大,通常由所有 CPU 核心共享,容量范围在 1 MB 到 128 MB 之间。高速缓存使用 静态随机访问存储器(Static Random Access Memory,SRAM),速度要比使用在主存上的 动态随机访问存储器(Dynamic Random Access Memory,DRAM) 快得多。
- 二是通过在 CPU 与大而慢的存储设备之间插入一个相对小但快的设备,组织形成一个存储器层次结构。其主要思想是,靠近处理器的存储器作为远离处理器的存储器的高速缓存。如下图所示:

操作系统
在上面的例子中,从软件的角度来看,读取键盘输入指令,加载 hello 程序等操作似乎都是 shell 程序完成的。然而 shell 与 hello 程序都没有直接访问键盘、显示器、磁盘或是主存。这些程序通过调用 操作系统(Operating System,OS) 提供的 系统调用(System Call) 完成这一点。操作系统是介于计算机硬件与软件之间的一层特殊程序,其负责管理计算机的所有硬件资源供软件使用,所有应用程序都必须通过操作系统提供的系统接口才能实现对硬件资源的间接调用。操作系统通过三个核心抽象:
- 文件 是所有 I/O 设备的抽象表示
- 虚拟内存 是主存与磁盘 I/O 设备的抽象表示
- 进程 是处理器、主存与 I/O 设备的抽象表示
来实现向所有程序提供简单、一致的对底层庞杂各异的硬件设备的统一控制。
Unix 一般指代在上世纪六七十年代,由贝尔实验室研发的,后衍生出多种版本的多用户多任务操作系统家族。其在当时是最为流行的操作系统架构,被广泛用于各种计算机中。
上世纪八十年代中期,一些 Unix 厂商试图为自己开发的 Unix 系统加入一些不与其它 Unix 系统兼容的特性。为了维护 Unix 系统的兼容性,IEEE 牵头制定了 Posix 规范,规定了系统 API、命令行程序 shell 等规范,保证程序在不同的类 Unix 系统上可以兼容运行。
进程 是操作系统对一个正在运行的程序的抽象。在一个系统上可以同时运行多个进程,而每个进程 似乎 在运行时独占使用硬件。多个进程同时运行被叫做 并发 运行,其本质是多个进程的指令是交错在 CPU 上执行的,操作系统实现这种交错执行的机制被叫做 上下文切换。上下文指的是一个程序运行所需的所有状态信息,操作系统跟踪这些信息,在决定需要将硬件的控制权交换到另一个进程上时,它保存正在运行的程序的上下文,恢复新进程的上下文,然后将控制权传递给新进程。
从一个进程到另一个进程的转换由操作系统 内核(kernel) 管理。内核,即操作系统代码常驻主存的部分,内核不是一个独立的进程,而是操作系统管理全部进程所用代码和数据结构的集合。应用进程需要访问硬件而进行系统调用时,本质是将控制权转移给内核,由内核调用硬件完成相应指令并将结果和控制权返还给应用进程。例如我们一直举的例子中,就发生了如下图所示的上下文切换:

在现代的应用程序中,一个进程可以可以由多个 线程(Thread) 组成,所有线程运行在该进程的上下文中。由于现代的处理器一般是多核心的,多线程可以加快程序运行速度。第十二章会介绍并发与线程的详细知识。
虚拟内存 同样是操作系统提供给应用进程的抽象,它可以使得每个进程都认为自己 独占使用主存,而内存的每个字节都由一个唯一的数字来表示,这被称为该字节的地址(address),所有可能地址的集合被称作 虚拟地址空间(Virtual Address Space)。每个进程看到的内存都是一致的。在 Unix 系统中,每个进程的虚拟地址空间一般都由下图组成:

进程看到的虚拟地址空间由多个被准确定义的区组成,每个区都有着专门的功能,从低地址向高地址依次是:
- 程序代码与数据:具体而言,代码位于最低的地址,随后的位置存储着诸如全局变量等第 2 ~ 7 节将详细讨论背后的机制。内容。详细介绍见第七章。
- 堆:在 C 语言中,每当调用
malloc与free函数时,使用的都是堆区的内存。详细介绍见第 9 章。 - 共享库:存放 C 标准库、数学库这些共享库。详细介绍见第 7 章。
- 栈:供编译器实现函数调用。调用一个新函数,栈区扩张;从函数中返回,栈区收缩。详细介绍见第 3 章。
- 内核虚拟内存:不允许程序访问。用于内核处理进程的系统调用。
第九章将介绍编译器与运行时系统如何将存储器空间划分为可管理的单元用于存放不同的 程序对象(Program Objects),即程序数据、指令与控制信息的集合。一种机制是 C 语言中的指针,其值为它指向的对象在内存中第一个字节的虚拟地址。C 编译器会维护指针的大小与类型信息,并根据指针的类型生成不同的机器代码,但其生成的可执行程序却不包含这些类型信息。
文件 本质上就是字节序列。在 Unix 系统中,所有输入输出都是通过使用一小组 Unix I/O 系统调用函数实现的。
从上面的粗略描述中,我们可以窥见:系统是硬件与系统软件交织的集合体,它们必须协作才能达到运行应用程序的最终目的。
其它重要主题
互联网
在当下,各种计算机设备通过互联网连接在一起,相互传输数据。从某个单独的设备来看,网络本质上也只是一个虚拟的 I/O 设备,可以发送或读取数据。第 11 章或 1.1 什么是互联网 会详细介绍计算机网络的相关知识。
Amdahl 定律
我们已经知道计算机是由大量的硬件与软件交织组成的系统。那么我们提升了该系统中某一个部分的性能,这对提升整个计算机系统的性能有多少帮助呢?
Amdahl 定律指出,当我们对系统的某个部分加速时,其对系统整体的加速程度取决于该部分的重要程度与加速程度。
具体的,设该系统原来执行某个任务耗时为
假设我们现在拥有无限的资源可以使得
并发与并行
并发(Concurrency) 是指同时具有多个活动的系统;而 并行(Parallelism) 则指使用并发来使一个系统运行得更快。并行可以在计算机系统的多个抽象层级上运用:
- 在传统的单处理器系统上,线程级并发只是一种假象,是通过处理器在多个进程间快速进行上下文切换实现的。而在由多核处理器构成的多处理器系统上,多个线程同时并发运行成为了可能。此外,超线程,或被称为 同时多线程(Simultaneous Multi-threading),也允许一个 CPU 执行多个控制流。
- 现代的处理器同样支持 指令级并行,即同时执行多条指令。第 4 章会详细介绍的流水线,该技术将一条指令的执行划分为多个步骤,将处理器的硬件组织为一系列的阶段,每个阶段执行一个步骤,而这些阶段可以并行操作。
- 此外,现代处理器还拥有一些特殊硬件,允许一条指令产生多个可以并行执行的操作,即 单指令、多数据(SIMD) 并行。