这一部分对应的是 lec 13 (哔大上的参考课程为[Summer 20]),主要涉及的是高级语言程序(如C语言)是如何最终在计算机硬件上运行的完整流程,即CALL过程:Compiling(编译)、Assembling(汇编)、Linking(链接)和Loading(加载)。
层次抽象(Levels of Abstraction)
计算机中运行的程序需要经过多层转换和加工才能从可读文本变成可被机器识别的电子信号。该过程每一层都向上隐藏了大量复杂的细节,只提供相应的接口供上层使用。该思想实现了层与层之间的解耦,大大简化了整体的设计流程,是很重要的架构设计思想。
以 c 的程序为例,其运行的主线流程为:
foo.c
-> (编译) -> foo.s
-> (汇编) -> foo.o
-> (链接) -> a.out
-> (加载) -> 程序在内存中运行
具体而言:
编译(Compiling)
编译时的输入为高级语言代码,输出则是汇编代码。这一过程由编译器(如 GCC , Javac 等)完成。编译器首先进行词法分析、语法分析、语义分析和优化等一系列操作,最终生成对应目标平台的汇编代码。
编译的结果中可能会产生汇编器能理解但机器并不直接支持的指令,也就是前文提及的伪指令。伪指令的存在是为了让编译器(以及我们)更容易理解和编写代码,你可以将其视为一种“语法糖”
它同样是层次抽象的一种体现,它的存在使得我们不用关心底层硬件的所有细节
汇编(Assembling)
汇编的输入显然为汇编代码,输出则是目标文件,其中包括机器码、数据、重定位和符号表等相关信息。这一过程由汇编器(如 as )完成,相关过程包括:
- 处理汇编器指令(Assembler Directives):如
.text
,.data
,.global
,.string
。注意这些不是指令,只是告诉汇编器如何组织和处理代码数据的元信息。 - 替换所有伪指令:将其转换为真正的、机器支持的相关架构指令(此处是 RISC – V )。
- 生成机器码:
- 简单指令:算术、逻辑等指令的编码信息完备,可直接生成。
- 分支/跳转指令:涉及PC相对寻址。汇编器需要计算标号(Label)与当前指令的偏移量。
- 两遍扫描 (Two-Pass Assembler):为了解决“向前引用”问题(即跳转到一个后续才定义的标号),汇编器需要先扫描一遍整个程序记录所有标号的位置,第二遍再利用这些信息生成正确的机器码。
- 创建目标文件 (Object File):生成一个结构化的文件(通常是ELF格式),包含以下部分:目标文件头:描述文件各部分的大小和位置
- 文本段 (.text):存放机器指令。
- 数据段 (.data):存放初始化了的全局/静态变量。
- 重定位表 (Relocation Table):记录所有在汇编阶段无法确定最终地址的指令位置。例如,任何跳转到外部函数(如
printf
)或访问全局变量的指令,它们的地址都需要后续链接器来“修补”。 - 符号表 (Symbol Table):记录本文件中定义的可被其他文件引用的符号(如函数名、全局变量名)及其初步地址。
想起了 NJU PA 3 的某些很友好(并非)的东西… (´-ω-`)
链接(Linking)
链接的作用相当于胶水,写过相关项目(此处以 c 为例)的都知道,我们可以将项目分为多个文件,分别编译成 .o
文件后再运行,这背后就是依靠链接器将其粘合成一个整体,而这也使得使用 c 标准库成为可能.
所以输入输出很明确了,输入包括多个目标文件,输出为可执行文件,这一过程由链接器完成,相关过程包括:
- 合并:将所有输入目标文件的相同段(如所有
.text
段、所有.data
段)分别合并到一起。 - 重定位 (Relocation):这是链接器的核心工作。链接器会处理每个目标文件的重定位表。
- 它为每个段分配最终的运行时内存地址。
- 然后,它遍历所有需要“修补”的指令,计算符号的绝对地址,并将正确的地址值回填到指令的相应字段中。
- RISC-V中,
auipc
和jalr
指令组合用于实现绝对地址跳转和函数调用,这也正是链接器需要修改的地方。
静态链接与动态链接:
- 静态链接 (.a):将库代码直接复制到最终的可执行文件中。
- 优点:执行速度快,依赖简单(单个文件即可运行)。
- 缺点:浪费磁盘和内存空间(每个程序都有一份库的副本),库更新后需要重新链接所有程序。
- 动态链接 (.so / .dll):可执行文件中只记录库的名字和少量重定位信息,程序运行时再由操作系统(Loader)将所需的库加载到内存并进行最后的重定位。
- 优点:节省空间,便于库更新(替换一个 .so 文件,所有使用它的程序都受益)。
- 缺点:稍微增加运行时开销,使程序依赖更复杂(可执行文件本身不能运行,必须确保库存在)
加载(Loading)
加载是指将可执行文件加载到内存中运行的过程,该过程由加载器完成,具体而言:
- 读取可执行文件头,了解文本段和数据段的大小。
- 为程序创建新的地址空间。
- 将文本段(指令)和数据段从磁盘拷贝到内存中。
- 设置栈空间(用于存放局部变量、函数调用信息等)。
- 将程序的命令行参数压入栈中。
- 初始化处理器寄存器(如将栈指针
sp
指向栈顶)。 - 跳转到程序的入口点(通常是
_start
符号),开始执行程序。
所以这也就回答了一个很老生常谈的问题:”程序的入口真的只是
main()
函数吗?”
写在最后
讲座中还有一些零零散散的知识点,在此一并提及:
- 解释 vs. 翻译:Prof. Dan 开头解释了这两种执行方式的区别。Python是典型的解释型语言,边解析边执行,灵活但慢。C是典型的翻译型语言,一次性翻译成机器码,执行高效。Java则介于两者之间(编译成字节码,再由JVM解释/JIT编译执行)。
- 位置无关代码 (PIC – Position Independent Code):主要运用
auipc
这类PC相对寻址指令,使得代码可以被加载到内存的任何位置而无需修改。这对于共享库(动态链接库)至关重要。
总而言之,这一讲虽然比较浅,但串起了软件和硬件,主要明晰了所写的每一行C代码是如何一步步变成CPU上流动的电子信号的。理解CALL的每个阶段,对于后续调试程序(例如,理解链接错误、段错误)、分析程序性能以及理解操作系统和体系结构都至关重要。
发表回复