从零编写现代编译器 19 - 多文件模块、ABI 与链接
前 18 篇的所有程序都住在一个文件里。泛型函数、结构体、枚举、引用计数——全部挤在同一份 .spr 源码中。程序再大一点,这种组织方式就不可维护了。
本篇把编译单元从单文件扩展到多文件:每个 .spr 文件是一个模块,模块之间通过 import 声明交换类型签名,编译器分别产出目标文件,最终由系统链接器合并成一个可执行程序。
模块就是文件
Sprout 采用最直接的模块方案:一个 .spr 文件就是一个模块,模块名从文件路径导出。math.spr 的模块名是 math,util/sort.spr 的模块名是 util_sort。不存在与文件脱离的命名空间声明。
在模块顶部用 import 引入依赖:
1 | |
import math 使 math 模块中所有标记为 pub 的函数名在当前模块可见。被导入模块的私有函数不可访问——编译器在名称解析阶段就会报错,不需要等到链接。
可见性:默认私有
函数声明不加修饰时只对本模块可见:
1 | |
pub fn 把函数放入模块的公开签名集合。编译 math.spr 时,编译器导出 gcd 的签名供其他模块类型检查使用,而 helper 只在 math.spr 内部存在。
这与 Rust 的默认私有策略一致。在教学语言里,可见性只作用于函数;结构体和枚举的导出规则留给后续扩展。
导入图与循环检测
编译开始前,编译器扫描所有参与编译的源文件,收集每个模块的 import 列表,构建依赖有向图。
1 | |
图中不允许出现环。如果 math.spr 同时 import util,而 util.spr 又 import math,编译器必须报错并给出环路路径,而不是无限递归地尝试解析签名。
检测方法是标准的拓扑排序:对模块依赖图做 DFS,遇到回边即报告循环。排序结果同时给出编译顺序——被依赖的模块先编译,依赖方后编译,保证类型检查时所需签名已就绪。
分离编译与类型检查
每个模块独立编译到一个 .o 文件。编译 main.spr 时,编译器只需要 math 和 util 的公开签名,不需要它们的函数体。流程如下:
- 拓扑排序得到编译顺序:
math→util→main。 - 编译
math.spr:类型检查、生成 IR、输出math.o,同时缓存公开签名。 - 编译
util.spr:加载math的公开签名用于类型检查,生成util.o。 - 编译
main.spr:加载math和util的签名,生成main.o。 - 调用系统链接器:
cc math.o util.o main.o runtime.o -o program。
每个模块的类型检查是完整的——调用外部函数时,参数类型和返回类型都通过导入签名验证。链接阶段只负责地址解析,不再做语义检查。
跨模块泛型是单独编译模型的主要挑战。Sprout 的策略是:泛型函数的 IR 以序列化形式嵌入模块的元数据段(类似 Rust 的 MIR 序列化)。当另一个模块需要单态化某个泛型函数时,从元数据中还原 IR 后在调用侧完成单态化。这意味着泛型函数的"编译"实际上推迟到了链接阶段——这是一种延迟单态化策略。
符号修饰
链接器在全局符号表中查找定义。如果 math 和 util 都有一个内部函数叫 helper,两个 helper 符号就会冲突。
Sprout 用模块名和函数名的组合作为链接符号。修饰规则:
1 | |
泛型函数的单态化实例还需要附加类型参数。第 18 篇的 identity<i64> 在 math 模块中变成:
1 | |
_Spr_ 前缀与 C 符号和系统库分隔,长度前缀让反修饰工具可以无歧义地恢复原始名称。这比 C++ 的 Itanium ABI 简单得多,但原理相同。
运行时 ABI
第 01–02 篇建立的入口包装仍然有效:源语言 fn main() -> i64 被编译为修饰后的 _Spr_4main_4main,链接时由一段 C 包装调用它,把 i64 返回值截断为进程退出码。
运行时提供的函数使用固定的 C ABI:
| 运行时函数 | C 签名 | 作用 |
|---|---|---|
print_i64 |
void print_i64(int64_t) |
打印整数到 stdout |
spr_alloc |
void* spr_alloc(int64_t) |
分配堆内存 |
spr_free |
void spr_free(void*) |
释放堆内存 |
spr_rc_retain |
void spr_rc_retain(void*) |
引用计数加一 |
spr_rc_release |
void spr_rc_release(void*) |
引用计数减一,归零时释放 |
这些是 Sprout 程序与外部世界的唯一接口。编译器生成的代码只调用上述函数和用户自定义的 Sprout 函数。没有通用 FFI 机制可以调用任意 C 函数——这是有意为之。受控接口避免了类型系统被绕过、内存管理失去追踪的问题。
本篇处理三层约定中的前三层:语言内部函数调用约定(Sprout 函数之间),C 运行时接口(Sprout 到运行时),对象文件格式(.o 中的符号与重定位)。第四层——系统调用——留给运行时内部处理,用户代码不直接触及。
入口约定
多文件程序需要指定入口。Sprout 要求命令行指定的主模块中必须存在 pub fn main() -> i64。编译器检查这个签名存在且类型正确,然后在链接时将 C 包装的 main 指向它。
退出码只取返回值的低 8 位。sum_to(10) 的结果 55 可以通过 echo $? 观察到,但超过 255 的值会被截断。数值验证应通过 print_i64 输出,不应依赖退出码传递任意计算结果。
多文件测试
准备两个文件:
1 | |
1 | |
编译并运行:
1 | |
编译器按拓扑序处理 math.spr 和 main.spr,生成各自的 .o 文件,链接运行时,产出可执行文件。输出 6 是 48 和 18 的最大公约数。
错误案例
编译器必须在链接之前拦截以下错误:
导入不存在的模块:
1 | |
1 | |
访问私有函数:
1 | |
1 | |
循环导入:
1 | |
重复的公开符号: 如果两个模块导出同名的 pub fn,名称解析阶段不会冲突(它们在不同命名空间),但如果调用方同时导入两个模块并使用了歧义名称,编译器要求显式的模块前缀限定。
练习
- 把第 09 篇的
sum_to程序拆成两个文件:calc.spr导出pub fn sum_to,main.spr导入并调用它。写出编译命令,验证输出仍为55。 - 给
calc.spr增加一个私有辅助函数,在main.spr中尝试调用它,观察错误信息。 - 手动写出
sum_to在calc模块中的修饰符号名,用nm calc.o验证。
上一篇:18 - 泛型如何变成具体代码。下一篇:20 - 让错误回到源程序。
