编译原理
预处理
最后简单描述一下预处理。预处理主要是处理一些宏定义,比如 #define、#include、#if 等。预处理的实现有很多种,有的编译器会在词法分析前先进行预处理,替换掉所有 # 开头的宏,而有的编译器则是在词法分析的过程中进行预处理。当分析到 # 开头的单词时才进行替换。虽然先预处理再词法分析比较符合直觉,但在实际使用中,GCC 使用的却是一边词法分析,一边预处理的方案。
词法分析
- 输入:源代码
- 输出:中间代码
- 把源代码分割开,形成若干个单词。原理主要是状态机。处理之后,编译器就知道了每个单词
语法分析
- 思路是模板匹配,把单词组合起来
- 成功解析语法以后,我们会得到抽象语法树(AST: Abstract Syntax Tree)
int fun(int a, int b) { int c = 0; c = a + b; return c;}
它的语法树如下:
生成中间代码
- 意义:不同cpu的汇编语法不一致。因此需要生成和语言无关、也和cpu无关的中间代码,然后再生成各个cpu的汇编代码。可以理解为中间代码是一种非常抽象,又非常普适的代码。它客观中立的描述了代码要做的事情,如果用中文、英文来分别表示 C 和 Java 的话,中间码某种意义上可以被理解为世界语。
- eg. 如gcc,
- 语法树转高端gimple
- 主要处理寄存器和栈,如c=a+b变成寄存器处理。
- 函数调用则建栈
- 高端gimple转低端gimple
- 主要是把变量定义,语句执行和返回语句区分存储
- 好处是容易计算一个函数需要多少栈空间
- 主要是把变量定义,语句执行和返回语句区分存储
- 低端gimple转中间代码
- (略)
- 语法树转高端gimple
生成目标代码(生成汇编代码)
- 主要的工作量在于兼容各种 CPU 以及填写模板。在最终生成的汇编代码中,不仅有汇编命令,也有一些对文件的说明。
汇编,生成二进制机器码
把汇编代码转成 二进制的机器码,机器码可以直接被 CPU 识别并执行
从目标代码可以猜出来,最终的目标文件(机器码)也是分段的,这主要有以下三个原因:
- 分段可以将数据和代码区分开。其中代码只读,数据可写,方便权限管理,避免指令被改写,提高安全性。
- 现代 CPU 一般有自己的数据缓存和指令缓存,区分存储有助于提高缓存命中率。
- 当多个进程同时运行时,他们的指令可以被共享,这样能节省内存。
对于一个目标文件来说,文件的最开头(也叫作 ELF 头)记录了目标文件的基本信息,程序入口地址,以及段表的位置,相当于是对文件的整体描述。接下来的重点是段表,它记录了每个段的段名,长度,偏移量。比较常用的段有:
- .strtab 段: 字符串长度不定,分开存放浪费空间(因为需要内存对齐),因此可以统一放到字符串表(也就是 .strtab 段)中进行管理。字符串之间用
\0分割,所以凡是引用字符串的地方用一个数字就可以代表。 - .symtab: 表示符号表。符号表统一管理所有符号,比如变量名,函数名。符号表可以理解为一个表格,每行都有符号名(数字)、符号类型和符号值(存储地址)
- .rel 段: 它表示一系列重定位表。这个表主要在链接时用到,下面会详细解释。
编译 VS 解释
总结一下,对于 C 语言来说,从源码到运行结果大致上需要经历编译、汇编和链接三个步骤。编译器接收源代码,输出目标代码(也就是汇编代码),汇编器接收汇编代码,输出由机器码组成的目标文件(二进制格式,.o 后缀),最后链接器将各个目标文件链接起来,执行重定位,最终生成可执行文件。
解释型语言
- 解释型语言:源代码 -> 解释器 -> 运行结果
- 编译型语言:源代码 -> 中间代码 -> 目标代码 -> 运行结果
解释型语言和编译型语言的根本区别在于,对于用户来说,到底是直接从源码开始执行,还是从中间代码开始执行。以 C 语言为例,所有的可执行程序都是二进制文件。而对于传统意义的 Python 或者 JavaScript,用户并没有拿到中间代码,他们直接从源码开始执行。从这个角度来看, Java 不可能是解释型语言,虽然 Java 虚拟机会解释字节码,但是对于用户来说,他们是从编译好的 .class 文件开始执行,而非源代码。
解释程序和编译程序的根本区别:是否生成目标代码。有没有虚拟机,虚拟机是不是解释执行,会不会生成中间代码,这些都不重要,重要的是如果从中间代码开始执行,而且 AST 已经事先生成好,那就是编译型的语言。
运行时
运行时库的基本概念
以 C 语言为例,有非常多的操作最终都依赖于 glibc 这个动态链接库。包括但不限于字符串处理(strlen、strcpy)、信号处理、socket、线程、IO、动态内存分配(malloc)等等。这一点很好理解,如果回忆一下之前编译器的工作原理,我们会发现它仅仅是处理了语言的语法,比如变量定义,函数声明和调用等等。至于语言的功能, 比如内存管理,內建的类型,一些必要功能的实现等等。如果要对运行时库进行分类,大概有两类。一种是语言自身功能的实现,比如一些內建类型,内置的函数;另一种则是语言无关的基础功能,比如文件 IO,socket 等等。
由于每个程序都依赖于运行时库,这些库一般都是动态链接的,比如 C 语言的 (g)libc。这样一来,运行时库可以存储在操作系统中,节省内存占用空间和应用程序大小。
对于 Java 语言来说,它的垃圾回收功能,文件 IO 等都是在虚拟机中实现,并提供给 Java 层调用。从这个角度来看,虚拟机/解释器也可以被看做语言的运行时环境(库)。
链接和装入
链接
在一个目标文件中,不可能所有变量和函数都定义在文件内部。比如 strlen 函数就是一个被调用的外部函数,此时就需要把 main.o 这个目标文件和包含了 strlen 函数实现的目标文件链接起来。我们知道函数调用对应到汇编其实是 jump 指令,后面写上被调用函数的地址,但在生成 main.o 的过程中,strlen() 函数的地址并不知道,所以只能先用 0 来代替,直到最后链接时,才会修改成真实的地址。
链接器就是靠着重定位表来知道哪些地方需要被重定位的。每个可能存在重定位的段都会有对应的重定位表。在链接阶段,链接器会根据重定位表中,需要重定位的内容,去别的目标文件中找到地址并进行重定位。
链接是将多个目标文件(.o 文件)和库文件(.a / .so)合并为一个可执行文件的过程。
编译后是否已经完成链接,取决于链接方式
- 静态链接:编译完成 = 链接完成,可独立运行
- 动态链接:编译完成 ≠ 链接完成,运行时仍需加载
.so库
装入(Loading)
装入是指操作系统将程序从磁盘加载到内存,并准备运行的过程。
装入的三种方式
- 绝对装入(Absolute Loading)
- 代码只能装入到固定的内存地址(如嵌入式设备)
- 缺点:不同机器或不同进程可能导致冲突
- 可重定位装入(Relocatable Loading)
- 常见于现代操作系统,程序可装入任意空闲地址
- 需要重定位表(Relocation Table),装入时修改地址引用
- 动态装入(Dynamic Loading)
- 按需装入程序或库,如
dlopen()方式 - 常用于共享库、插件系统,如 Python 的
import机制
- 按需装入程序或库,如
Go 的编译、链接、装入概览
| 阶段 | 作用 | 执行者 | 输出 |
|---|---|---|---|
| 编译(Compilation) | 源代码 → 汇编/机器码 | go build(调用 cmd/compile) | .o 目标文件 |
| 链接(Linking) | 目标文件 + 库 → 可执行文件 | cmd/link | 可执行文件 |
| 装入(Loading) | 加载可执行文件到内存并执行 | OS Loader | 进程 |
编译步骤
go build -o myapp main.go
内部执行流程
- 词法分析(Lexical Analysis):将 Go 代码转换为 Token。
- 语法分析(Parsing):生成 AST(抽象语法树)。
- 类型检查(Type Checking):检查变量、函数、结构体等是否正确。
- 优化(Optimization):移除无用代码(dead code elimination)、内联(inlining)等。
- 代码生成(Code Generation):生成中间代码(SSA)。
- 寄存器分配(Register Allocation):为变量分配 CPU 寄存器。
- 机器码生成(Machine Code Generation):最终生成
.o目标文件。
中间文件
go build -gcflags="-m" main.go
-m选项会显示编译优化情况。- 可以用
-S选项查看 Go 生成的汇编代码。
QA
.o文件包含机器码,不同架构(如 x86_64 vs. ARM)无法通用.o文件是 机器代码(Machine Code),但包含符号表和重定位信息,不能直接执行。- 汇编代码(
.s) 是 人类可读的中间代码,需要汇编器转换成.o文件。 - 在 Go 语言中,
.a文件是静态库(Archive Library),它包含已经编译好的 Go 代码(机器码),但尚未链接成最终的可执行文件。在go build过程中,Go 编译器会先编译.go源文件为.o目标文件,再将多个.o文件打包成.a静态库。最终,链接器(cmd/link)会将.a库与其他依赖合并,生成可执行文件。
| 文件类型 | 内容 | 能否执行 | 作用 |
|---|---|---|---|
.o(目标文件) | 单个编译单元的机器码 | ❌ 不能直接执行 | 用于链接 |
.a(静态库) | 多个 .o 目标文件的集合 | ❌ 不能直接执行 | 提供复用的静态库 |
| 可执行文件 | 完整的机器码 | ✅ 可以执行 | 运行程序 |
Go 默认静态链接
Go 默认使用 静态链接(Static Linking),不会依赖外部动态库(除非使用 CGO)。
静态链接过程
合并所有目标文件(.o)
链接 Go 标准库(runtime)
生成完整可执行文件
go build -o myapp main.go生成的
myapp不依赖 任何外部库。
Go 的装入过程
(1)装入流程
当运行 ./myapp 时,OS 执行以下步骤:
- 加载 ELF 文件(Linux)或 Mach-O 文件(macOS)。
- 解析 Go 运行时(Go runtime)。
- 初始化全局变量。
- 调用
main.main()并开始执行。
(2)Go 运行时(Runtime)
- 垃圾回收(GC):Go 运行时自带 GC,不依赖外部库。
- goroutine 调度:Go 运行时管理协程调度,而非 OS 线程调度。
- 系统调用(Syscall):Go 通过
runtime处理系统调用。