CS110 计算机体系结构 5 C.A.L.L.

C.A.L.L,是编译 Compilation - 汇编 Assembly - 链接 Linking - 加载 Loading 的缩写,是从(编译执行类语言)程序代码到进程的一般过程。我们接下来一个个部分来看。
编译 Compilation
对于 C 语言而言,编译过程由 C 编译器(如 gcc)完成。在这个过程中,程序员把写好的代码文件 main.c 交给编译器,然后编译器负责把这份代码编译为汇编代码文件 main.s,其中存放的是汇编语言代码,如 RISC-V。
既然是负责把高级语言转换为汇编语言,编译器就需要恪尽职守地努力”优化“转换生成的汇编语言,使其执行需要的时空开销尽可能小。我们在使用 gcc 进行编译时,可以选择 -O2 -O3 这样的优化策略——这些策略就是不同激进程度的优化。优化有很多种类型,在此仅举一个例子来供读者理解:
1 | int sum(int n) { |
对于以上函数,如果按照
-O0的忠于源码的优化方式来编译,编译结束后的汇编代码可能是这样的(事实上,gcc 的翻译结果并不是 RISC-V,在此仅作演示,方便理解):
1 | sum: |
如果我们按照
-O3的激进的优化方式来编译,结果可能会变成类似于这样:
1 | sum: |
可以看到,最直观的区别就是,
-O3优化的汇编代码比-O0的汇编代码短得多。事实上,-O3优化在这里避免了不必要的内存存取,改在寄存器中更快地完成这部分工作;甚至,这种优化还将庞大的循环直接从数学上化简成了求和公式。-O3优化,在代码的时空复杂度上讲,比-O0优化有很大的缩减。
以上只是一个非常浅显的、便于理解的优化的例子。在后续的章节中,我们可能会涉及到如循环展开之类的优化方式,等涉及到时再补充。
汇编 Assembly
汇编过程是由汇编器 Assembler 完成的,它把由编译器生成的汇编代码汇编成二进制代码——这个过程的重点部分,我们已经在前两章中讨论过了,在此概括、补充一下完整过程:
第一步,根据含有伪指令的汇编代码来组织代码和数据,如将伪指令替换为标准指令、确定代码段、数据段的位置,声明全局符号等;第二步,汇编器将处理后的标准指令转换成二进制01串,生成机器语言;第三步,汇编器将生成的机器语言、信息表等打包为目标对象文件 .o,供后续链接器使用。
大端序 / 小端序
除了前面涉及的 RISC-V 代码翻译问题外,这里需要额外补充的是关于大端序/小端序的内容。所谓大端序/小端序 Big/Little Endian,就是缓冲区、内存、硬盘等存储容器中字节的存放/读取顺序。不难看出,这个顺序对于读取一段数据或代码来说至关重要。其中,小端序指的是最低位在最右侧的写法,大端序指的是最低位在最左侧的写法。RISC-V 使用的是小端序。比如,对于 0x12345678 这样一段十六进制数据,不同方式的存储如下:
| 内存地址 | 0x00 | 0x01 | 0x02 | 0x03 |
|---|---|---|---|---|
| 小端序 | 0x78 | 0x56 | 0x34 | 0x12 |
| 大端序 | 0x12 | 0x34 | 0x56 | 0x78 |
需要注意的是,具体按照小端序还是大端序存储/读取只是一种解释规则,具体顺序由 CPU 管理。另外,不论是小端序还是大端序,在每个字节内部的存储顺序是不会改变的——这是在说,即使是小端序,0x78 在同一内存地址字节上的存储方式还是 0b 0111 1000,而不会发生任何翻转。
汇编器的输出
这里简单整理一下汇编器的输出内容,主要包括代码和信息表两类,有兴趣的读者可以看一眼。
- 目标文件头 Object file header 包含目标文件中其它部分的大小和位置信息
- 文本段 Text segment 存储可执行的机器码
- 静态数据段 Static data segment 存储静态数据
- 符号表 Symbol table 列出文件中的标签和静态数据,方便被其他程序引用
- 重定向表 Relocation table 包含之后需要被链接器 Linker 修正的代码
- 调试信息 Debugging info 用于调试程序,记录变量名、函数名、行号与机器代码的对应关系
链接 Linking
链接过程,就是把目标 .o 文件合并整理成正式可执行文件的过程。从输入输出的角度来看,链接器 Linker 输入的是目标代码和信息表,而输出的是可执行文件。从具体操作的角度来看,链接过程中,链接器会给代码中所有的数据标签(内存中变量的地址)、指令标签(内存中代码的地址)分配一个具体的地址(动态链接库函数除外),然后把几个目标文件的文本段合并,数据段合并放在文本段后。
链接这一步的主要意义在于,在有多个目标文件的情况下,每个文件独立编译、汇编形成的二进制指令都以各自的文件第一行代码为地址 0x0000 0000——这样在整个项目运行时,代码跳转就全乱套了!因此,链接过程会给这些目标文件排个序,然后把代码、内存变量按顺序排好,然后刷新这些设计内存地址的指令。这样一来,整个项目就可以形成一个完整的可执行文件,独立于其它文件运行。
对于上面我这一段解释,有两处不严谨的地方,我将在下面一一补充:
跳转
在 RISC-V 代码中,存在 3 种指令跳转:程序内部跳转、外部函数引用、外部静态数据访问。其中,程序内部跳转是不需要链接器的,因为它使用的是代码相对地址(还记得 RISC-V B-Type 指令跳转吗?当时我们举例时只说了跳转目标在当前指令下面第几行,并没有给出具体的地址),汇编器就能正确翻译成最终执行时的二进制指令。而后两种(外部函数引用、外部静态数据访问)跳转目标地址不在指令所在文件中,因此汇编器不知道应该跳转到哪里,需要链接器来辅助定位。
静态链接 & 动态链接
在学习 C/C++ 时,你一定听说过静态(链接)库和动态(链接)库——两者都是代码对外部库的链接方式。静态链接库的整个库都会被链接器导入可执行文件,而动态链接库在链接器处只登记一个地址,到执行时才被链接上。
一旦知道了这个区别,二者的优劣势就很容易理解了。静态链接库由于在链接时就完成了写入,因此可执行文件不会跟随库的更新而更新;而且可执行文件中包含了大量静态库中未使用的函数,因此可执行文件占用空间较大;但是静态链接库由于在可执行文件中包括了全部执行所需内容,因此执行时不需要做其它链接工作,运行速度较快。反过来,动态链接库会实时更新,可执行文件较小,但运行速度略慢。
具体地说,在链接器那里,静态链接库会被直接视为项目文件的一部分,和其它目标文件一起链接为一个完整的可执行文件。而动态链接库则只在可执行文件中留下一个内存地址——这个内存地址就像一个邮箱,在链接时邮箱是空的,而加载时动态库中用到的代码段会加载进这个邮箱里,执行时就跳转到这个邮箱这里。可以说,静态库函数的导入完全发生在链接器中,而动态库函数的导入发生在链接器和加载器中。(如果你刷一些往年卷,涉及到动态库导入所在步骤的选择题都有两个答案正确)
加载 Loading
加载器,在大部分情况下,是操作系统的一部分,负责在运行前把可执行文件启动为一个进程。不同于前面三个步骤往往在编译时全部完成,加载过程在程序运行前进行。
具体地说,加载过程有以下步骤:
- 读取可执行文件的文件头,确定代码
text段和数据data段分别的大小 - 申请一个足够大的内存空间,来存储代码
text段、数据data段和一个栈区 - 把指令和数据从可执行文件里复制到新申请的内存空间(在这一步动态库加载完成)
- 把传递给程序的参数复制到栈区上
- 初始化寄存器:大部分寄存器清除数据,栈指针
sp/x2指向第一块空栈区地址 - 开始运行程序,把程序的参数从栈区复制到寄存器上,设定程序计数器寄存器 PC
经过这些步骤,可执行文件就成功在操作系统上跑起来了。
至此,整个 C.A.L.L. 步骤就完成了,一个程序从高级语言代码到执行的进程的全过程大致如上,读者可以在此略作总结回顾,从下一章开始,我们就要开始探索计算机的硬件系统了。
- 标题: CS110 计算机体系结构 5 C.A.L.L.
- 作者: aaaaa
- 创建于 : 2026-08-13 22:00:00
- 更新于 : 2026-08-16 22:07:38
- 链接: https://redefine.ohevan.com/2026/08/13/零基础速通系列/CS110 计算机体系结构/零基础速通:CS110_计算机体系结构_5/
- 版权声明: 版权所有 © aaaaa,禁止转载。