第 40 章 / 共 58 章 卷八 工程与原理

编译管线原理

第 37 章你学会了打开管线的每一扇窗,本章走进去把机器看明白:六步主线——Flex 词法、Lemon 语法、import 与标准库展开、语义检查(类型/重载/泛型)、typed HIR 收口、C 生成与 TCC 后端。每一站的设计选择都有实测证据:为什么 HIR 不复制第二棵树、重载为什么在编译期就定胜负、run 和 build 的生成 C 为什么前 442 行一字不差。

本章导读

理解编译管线不是为了写编译器,而是为了三件事:一,读懂诊断——第 39 章的四层防线在本章获得机制解释(parse 为什么报一次停、sem 为什么能收集多错);二,写出更快的代码——知道了 [1,2] 是 xtarray 而 [1,"x"] 是动态容器,你就明白类型同构为什么重要;三,预判语言行为——知道了重载在编译期解析,就不会期待运行期分派。本章每讲一个机制,都用编译器自己的输出作证:ir 文本、abi 符号表、cgen 产物、module.c 的 diff——不引一句"作者说",全是"编译器说"。核心设计文档 docs/Typed_HIR与后端边界.md 只有 56 行,本章是它的展开讲义。

本章学习目标

掌握六步主线(Flex→Lemon→展开→语义→HIR→C/TCC)每站的输入输出与职责;理解 typed HIR "AST 节点+语义类型覆盖层"的紧凑表示与 inferredType 的生命周期管理;掌握完整性约束(单值表达式必须有静态类型,缺失在 hir 阶段报错不静默进后端);掌握容器推断规则(同质→静态泛型 xtarray,异质→动态容器)及 cgen 取证法;理解重载/泛型在语义阶段解析的符号证据(xl_fn_show__int);理解选择 C 作为目标的理由;掌握 AOT/JIT 共用前端的 module.c diff 证据与入口包装差异;掌握统一入口 ABI(xlang_main(xvalue arg) 与 exe main 五步骤)。

40.1 六步主线总览

docs/Typed_HIR与后端边界.md 规定的编译主线(原文照录):

DEMO6 编译主线(设计文档口径)
1. Flex 词法分析                    字符流 → token 序列
2. Lemon 语法分析,产生 AST         token 流 → 语法树
3. import 与标准库展开              多文件合并、标准库改写
4. 语义检查、重载解析与泛型推断     类型标注、符号定版
5. typed HIR 收口                   类型覆盖层完整性验证
6. C 代码生成与 TCC 后端            HIR → C → 机器码

与第 37 章"四阶段"(lex→parse→ir→cgen)的关系:四阶段是可观察口径(CLI 暴露的四扇窗),六步是实现口径——展开和语义被收进了 ir 之前,HIR 是 ir 的正式名称("ir 阶段是 DEMO6 的正式阶段",命令行设计文档原话)。两条口径不矛盾:你在 xl ir 里看到的,正是第 3、4、5 步完成后的世界。

管线两头各有一个"翻译":最前是字符→token 的结构化(信息密度骤增),最后是 HIR→C 的降维(高级概念落成平凡 C)。中间四步只做一件事:把"你写的意思"变成"无歧义的意思"——每个表达式有确定类型、每个调用有确定目标、每个变量有确定存储。这份无歧义,就是 typed HIR 的全部意义。

40.2 第一站 lex

Flex 生成的词法分析器把字符流切成 token 序列,每个 token 带三要素:种类、位置、原文。设计上的两个要点(39 章已实测,此处给机制解释):

一,词法几乎不拦人。@ 有 AT token、类型名 int 是普通 IDENT——lexer 的职责是"切分"不是"判断",把合法性留给 parse 和 sem。好处是词法器极简极快,坏处是"非法字符"类错误会以 [parse] 面貌出现(39.3 的教训)。

二,换行是有名字的。SEP token 承载换行——xlang 用换行断句而不是分号,这个语法风格在词法层就定了型。你写的每个换行都真实存在于 token 流里,参与语法判断。

真正的词法错误只剩 token 无法成形的场景:未闭合字符串(产 ERROR token)、非法数值形态等。词法输出是管线里信息最"扁"的形态——一串线性序列,没有结构、没有优先级、没有类型。赋予它结构是下一站的事。

40.3 第二站 parse

Lemon 生成的 LALR 解析器把线性 token 流组织成树。三样东西在这一站诞生:结构(表达式变子树,优先级固化进树的形状)、位置档案(每个节点带 @行:列,此后所有诊断的坐标系)、语法糖的真身(第 37 章见过的 func main(...) → param "arg" type array<var>——语法层面的谜底在 AST 里揭晓)。

报一次停的机制(39 章深入细节 1 的展开):LALR 是自底向上的确定性分析器,一个 token 让状态机无路可走时,后续 token 的解读全部失真——错误恢复(插入假想 token 续命)在技术上可行,但产生的连锁诊断噪音远大于收益。设计选择:第一次失败就停,把唯一可信的诊断给你。所以 parse 错误的修复策略永远是"只修第一条"。

AST 是后续所有阶段的共享底物——import 合并在树上做、语义标注写在树上、typed HIR 的覆盖层挂在树上。一棵树的终身服役从这一站开始。

40.4 第三站 展开

设计文档的第三步"import 与标准库展开"在 CLI 没有独立命令,但它的产物你在 ir 里见过(37 章):

# 你写的:
println("hello")
# ir 里看到的(展开完成后):
call __xl_std_println("hello")
# 而 __xl_std_println 的真身被一并注入:
extern "xl6_xrt_print" func __xl_std_runtime_print(values: var, newline: bool)
func __xl_std_println(arg: array<var>)
  call __xl_std_runtime_print(arg, true)
end_func

这一步干两件事。import 展开:把 import 的包与文件合并进当前编译单元(第 35 章),多文件工程从此是一棵树。标准库展开:你写的 println/math.sqrt 按注册表改写为内部名并注入薄封装(第 36 章),object 基类也在此注入——ir 文本里那一大段 class object 就是它的手笔。展开完成后,"标准库"这个概念在管线里消失,剩下的只有普通函数调用和 extern 声明——第 36 章"标准库与 C 混编同一条 ABI 路径"正是这一步的功劳。

40.5 第四站 语义检查

语义阶段是管线的"法官":类型检查、重载解析、泛型推断三件大事都在此定案,判决书写在 AST 节点上(下一站变成覆盖层)。

重载解析的判决证据——两个 show 重载,abi 快照里各自身份(实测):

$ xl abi ovl.xl
func show
  signature: fn:app.show(int) as ()
  symbol: xl_fn_show__int        ← int 版专属符号
func show
  signature: fn:app.show(string) as ()
  symbol: xl_fn_show__string     ← string 版专属符号

语义阶段把每个 show(42) 绑定到 xl_fn_show__int、每个 show("hi") 绑定到 xl_fn_show__string——重载在编译期定胜负,运行期零分派开销。ir 文本里虽然也写 call show(42),那只是 dump 的显示名;真正的目标符号已在节点上钉死(cgen 产物里直接调用 xl_fn_show__int)。泛型推断同理:泛型函数的每个实例在语义阶段求出具体类型参数,第 14 章的 monomorphize 在此落地。

收集多个错误的机制(39 章深入细节 1):语义检查是树遍历,每个节点独立判决——第 2 行错不挡第 3 行查,于是诊断攒齐一次报完;代价是级联(一个根因多条诊断),修复纪律"先修第一条"由此而来。检查全部通过,节点上的类型标注才算生效——compile unit 的 hirReady 标志在此置真(xl6_compile_unit.c 源码实证)。

40.6 第五站 typed HIR

typed HIR 是本章的主角,也是设计文档唯一定义的"唯一类型口径"。它的形态一句话:"AST 节点 + 语义类型覆盖层"的紧凑表示,不复制第二棵结构相同的语法树(设计文档原文)。每个确定产生单值的表达式节点拥有规范化的 inferredType,生命周期由 AST 统一管理——源码位置、语法结构、推断类型三位一体,没有第二份节点分配。

完整性约束是它的牙齿。src/ir/xl6_hir.c 在语义检查成功后执行最终验证:字面量、插值、匿名函数、索引、条件、一元二元、is/as/await、容器字面量——所有确定产生单值的节点必须具有静态类型。缺了怎么办?"缺少必需类型会在 hir 阶段直接报错,不允许静默进入后端"(原文)。两个豁免也写得明白:函数调用可能无返回或多返回,由已解析签名承担(不伪造单一 inferredType);只作 callee 的函数名/成员名不强制伪造运行时值类型。

容器推断规则(设计文档 §4)是 HIR 最可见的行为差异:

同质静态泛型 vs 异质动态容器(设计文档示例)
auto a = [1, 2]          // array<int>   —— 同质:静态泛型
auto b = {1, 2}          // set<int>
auto c = {"x": 1}       // dict<int>
auto d = [1, "x"]       // 异质:保留动态容器

这个类型差异不是纸上标签——下一站你将看到它直接决定生成的 C 代码形态。ir 文本里 var a : auto 显示的仍是声明写法 auto,权威类型在覆盖层里;想看真身,请出 cgen。

40.7 第六站 cgen

cgen 的规则在设计文档 §3 写得像法律条文:cgen 通过 xl6HirExprType() 读取语义阶段的权威类型(这个函数真实存在于 xl6_hir.c);源码表达式不允许因为 cgen 处理不了某种形状就从静态类型降级为 var;"auto 的 C 存储由 HIR 类型决定;例如 array<int> 必须使用 xtarray,不能再次按初始化表达式形状猜测"。

用 40.6 的同质/异质对照实测取证——同一段代码里两个 auto 变量在生成 C 里的命运(真实 cgen 产物):

$ xl cgen hir.xl --emit out_hir.c && grep -n "xtarray a =\|xvalue d =\|TypedArrayCount\|ArrayItemCount" out_hir.c
963:    xtarray a = xrtTypedArrayCreate(xrtTypeInt(), XRT_OBJMODE_LOCAL);
983:    xvalue d = xvoCreateArray();
997:    …… xrtTypedArrayCount(a) …… xvoArrayItemCount(d) ……

三个铁证一次看全:一,auto a 的存储是 xtarray(专门化的类型化数组),auto d 是 xvalue(动态盒子)——同一个 auto,HIR 类型不同,C 存储不同,正是"不能再次按初始化表达式形状猜测"的正面执行;二,连 .length 都按类型生成不同代码——xrtTypedArrayCount(a) 直取计数,xvoArrayItemCount(d) 走通用容器接口;三,这一切发生在 cgen,但决策全部来自 HIR——cgen 只是照章办事的执行者。这就是 typed HIR 的工程价值:类型决策集中在语义阶段一处完成,后端不做二次猜测,前后端永不分家打架。

类型映射全景(第 32 章 ABI 表的 cgen 侧视角):int→long long、float→double、bool→C99 bool、string→const char*、array<int>→xtarray、动态容器/var→xvalue。字面量带位宽后缀(6LL),运算直落 C 运算符——生成 C 的平凡程度,就是运行时性能的直接来源。

40.8 为什么是 C

xlang 不直接产机器码也不产字节码,而是产 C——这个选择买到四样东西:

一,可移植的汇编。C 是所有平台的最大公约数,生成 C 等于一次获得全部平台的入场券;Windows 由 TCC 直接消化,其他平台可交系统编译器。

二,内嵌 TCC 的极速闭环。TCC 编 C 的速度以毫秒计,塞进进程后"编译"与"运行"的边界消失——xl run 的 JIT 体验、xl.dll 的嵌入脚本(38 章)、tcc 模块的脚本内编 C(36 章),全部建立在这份速度上。

三,混编零翻译。extern/export 的双向 ABI(32/33 章)之所以能做到"声明即调用",正因为双方最终在同一个 C 世界里见面——xlang 函数是 C 函数,C 函数是 xlang 函数,没有 FFI 胶水层。

四,透明可调试。生成 C 可读(37 章 cgen 取证、39 章管线调试全靠它),编译器行为永远可以验证——"不虚构编译器行为"的前提就是行为可见。代价同样如实:TCC 不做激进优化(37 章深入细节 2),性能天花板由后端决定——这是设计权衡,不是缺陷。

40.9 AOT 与 JIT 共用前端

run(内存执行,JIT 形态)与 build(写盘 exe,AOT 形态)是不是两套编译器?用 --save-temps 各取一份 module.c 做 diff(实测,程序只有一行 println):

$ diff run_temps/module.c build_temps/module.c
443c443
< int xl_script_has_error(void)
---
> int main(int argc, char** argv)
445,446c445,447
<     return xl6_error_has();
< }
---
>     XL_InitOptions __xl6_options = xlInitOptions(argc, argv);
>     xvalue __xl6_arg;
>     int __xl6_code;
……(后略:build 版是 exe 入口五步,run 版是 xl_script_* 宿主桥)
$ wc -l run_temps/module.c build_temps/module.c
  463 run_temps/module.c
  470 build_temps/module.c

铁证:前 442 行一字不差——六步主线对 run 和 build 是完完全全的同一份代码、同一次执行。差异只有尾部 7 行入口包装:build 版生成标准 int main(int argc, char** argv)(xlInit → argv 转 xvalue → xlang_main → xlUnit 五步,40.10 展开);run 版生成 xl_script_has_error/print_uncaught/clear_error 等宿主桥函数——内存执行时 xl.exe(或 xl.dll)通过这些桥读脚本的错误状态(38 章 xlGetLastError 的另一端)。所以"AOT 还是 JIT"在 xlang 里不是编译策略分歧,只是同一个编译结果的两种打包方式——行为一致性由构造保证,37 章 run/build 退出码透传一致、38 章嵌入与 CLI 行为一致,根源都在这 442 行。

40.10 统一入口 ABI

所有执行路径的汇合点是同一个符号:int xlang_main(xvalue arg)。设计文档规定:xlang 侧 func main(...) as int 固定降低为这个 C 形态;main 没有显式返回值时生成器尾部补 return 0。编译为 exe 时,生成的 C main 只做五件事(原文照录):

exe 入口五步骤(TCC内嵌与运行入口设计.md)
1. 调用生成代码的 xlInit                // 拉起运行时
2. 将 argc/argv 转为 xvalue array       // 命令行 → arg
3. 调用 xlang_main(arg)                 // 你的程序
4. 释放 arg                             // 所有权收尾
5. 调用生成代码的 xlUnit                // 运行时归还

这五步解释了全书一串现象:main(...) 的 arg 是 array<var>(第 2 步的产物);退出码透传(第 3 步返回值即进程码);arg[0] 是程序名(argv[0] 的遗传);嵌入场景 xlRunScriptStringArg 直接调 xlang_main 跳过第 1/2 步(宿主自供 arg——38 章)。一个入口 ABI,统一了 CLI、嵌入、模块三种世界的调用约定——这是管线设计的最后一环:前方千变万化,终点只有一个门。

深入细节

深入细节 1:为什么不复制第二棵树

编译器教科书里 IR 通常是独立的新数据结构(LLVM IR、JVM 字节码都是另起炉灶),typed HIR 偏不——"AST 节点 + 语义类型覆盖层"的理由写在设计文档里:保留源码位置和语法结构(诊断永远能指回你的代码),避免额外的节点、字符串和类型树分配(编译自身的内存与时间成本)。inferredType 的生命周期由 AST 统一管理,意味着类型标注与树同生共死,没有悬垂指针的数学空间。代价是 IR 不"独立"——不能像 LLVM IR 那样脱离源码流转;但 xlang 的 IR 消费者只有自家 cgen,这个代价为零。工程启示:IR 的形态应当服务它的消费者,而不是服务教科书。

深入细节 2:完整性约束——不信任,就验证

typed HIR 最硬的条款是"缺少必需类型会在 hir 阶段直接报错,不允许静默进入后端"。它防的是编译器开发期的 bug:语义阶段漏标了某个节点的类型,如果没有这道闸,cgen 只能猜测或降级 var——错误会以"生成错误的 C"的形式逃到用户手里。有了这道闸,编译器内部的类型缺陷在 hir 阶段原地爆炸、直接暴露给编译器开发者。配套的豁免同样精细:函数调用交给已解析签名(多返回装不进单一 inferredType)、cgen 自造的协议探测节点允许后端局部推断(它们没有源码 HIR)、"源码表达式不得降级 var"(用户代码的类型尊严)。约束、豁免、禁令三位一体——这是一份把"前后端信任"制度化的合同。

深入细节 3:TCC 后端与 VFS 的合流

第六站的后半程是 TCC,它与 37/38 章的 VFS 机制在此合流:cgen 产物挂进动态 VFS(__xlvfs__/module.c),tcc_add_file 直接从内存编译(设计文档口径:不再用磁盘临时文件交换主 C);运行时符号(XRT 与宿主注册函数)经 tcc_add_symbol 注入执行环境;文件查找固定为动态 VFS → 外部文件系统 → 静态 VFS。于是整条管线从字符流到机器码可以全程不碰磁盘——xl run 的速度密码。只有 --save-temps(取证)和 build(交付)才让产物落盘。回头看 39 章的 TCC warning 噪音,现在也读得懂了:__xlvfs__/module.c:449 就是这份内存 C 的行号,它从来不是你的文件。

⚠️ 常见错误

⚠️ 坑 1:看 ir 文本里的 auto 以为类型没推断
$ xl ir hir.xl
  var a : auto = [1, 2]     ← auto 还在,推断没做?

原因:ir 文本 dump 显示的是声明写法;推断结果在 typed HIR 覆盖层(inferredType),不在文本表面。正确做法:想看推断真身,xl cgen --emit 看生成 C——xtarray a 会告诉你 HIR 的结论(40.7)。

⚠️ 坑 2:以为 [1, 2] 和 [1, "x"] 只是类型标注不同
auto a = [1, 2]      // xtarray —— 类型化存储
auto d = [1, "x"]    // xvalue 动态容器 —— 通用盒子

原因:两者的 C 存储与访问路径完全不同(xrtTypedArrayCreate vs xvoCreateArray,xrtTypedArrayCount vs xvoArrayItemCount)——动态容器的每次访问都经过通用 xvalue 接口,开销高于类型化存储。正确做法:性能敏感的容器保持元素同构,让 HIR 给你静态泛型存储;异质是灵活性,不是默认选择。

⚠️ 坑 3:期待重载在运行期按实际值分派
func show(int x) ... end
func show(string s) ... end
// 以为运行时会看值的类型选版本

原因:重载解析在语义阶段完成——show(42) 已绑定 xl_fn_show__int(abi 快照实证),生成 C 里是直调,运行期没有任何分派。正确做法:重载按声明类型理解(第 14 章);运行期按值分派的需求用 var + is 判断手写。

⚠️ 坑 4:以为 run 和 build 是两套编译器
"run 能跑 build 报错,编译器有两套行为?"

原因:module.c diff 铁证——前 442 行一字不差,只有尾部入口包装不同(xl_script_* 桥 vs main 五步)。行为差异若存在,根源在运行环境(VFS、loadlib、工作目录),不在编译管线。正确做法:run/build 不一致时查环境与依赖(37 章对照表、39 章 exit 127 成因表),别怀疑编译器人格分裂。

⚠️ 坑 5:把生成 C 的 TCC 警告当自己代码的病
tcc: __xlvfs__/module.c:449: warning: 'return' with no value

原因:路径 __xlvfs__/ 说明这是动态 VFS 里的生成 C,不是你的源码;该警告是 uncaught 异常路径的伴生现象(39 章深入细节 2)。正确做法:警告指向 __xlvfs__ 一律先读下一行——真正的诊断(uncaught XxxError)在那里;想研究生成 C 用 --save-temps 导出原文。

本章小结

站输入 → 输出核心机制与证据
1 lex(Flex)字符流 → token 序列三要素(种类/位置/原文);@ 是合法 AT;SEP 承载换行断句
2 parse(Lemon)token 流 → AST结构/位置档案/语法糖真身;报一次停(LALR 恢复成本)
3 展开多文件 → 一棵树import 合并;标准库改写注入(println→__xl_std_println);object 基类注入
4 语义AST → 标注 AST类型检查收集多错;重载定版(xl_fn_show__int);泛型推断;hirReady 置真
5 typed HIR标注 AST → 类型覆盖层不复制第二棵树;inferredType;完整性约束(缺类型 hir 报错);同质→静态泛型
6 cgen+TCCHIR → C → 机器码xl6HirExprType 读权威类型;auto→xtarray 不得降 var;动态 VFS 内存编译
目标 C 的理由可移植汇编 / TCC 极速闭环 / 混编零翻译 / 透明可调试;代价:优化有限
AOT=JITmodule.c 前 442 行相同;差异仅入口包装(main 五步 vs xl_script_* 桥)
统一入口int xlang_main(xvalue arg);exe main 五步;嵌入跳过 1/2 步自供 arg

复习题

  1. 六步主线是哪六步?与第 37 章"四阶段"观察口径是什么关系?
  2. typed HIR 为什么叫"覆盖层"而不是新 IR?inferredType 的生命周期由谁管理?完整性约束防的是什么?
  3. auto a = [1, 2] 与 auto d = [1, "x"] 在生成 C 里的存储和 .length 访问分别是什么形态?这条规则在设计文档里怎么表述?
  4. 重载解析发生在哪一站?xl_fn_show__int 这个符号名证明了什么?
  5. module.c 的 diff 证明了 AOT 与 JIT 的什么关系?run 版尾部的 xl_script_* 函数是给谁用的?exe 入口五步骤是哪五步?

编程练习

练习 1:管线六站观察员

写一个含以下元素的程序:一个重载函数(两版本)、一个同质数组、一个异质数组、一次标准库调用(如 math.sqrt)。依次执行 xl lex / parse / ir / cgen --dump-dir 取证,然后写一份"六站观察笔记":token 流里 SEP 的位置;AST 里重载两版本的节点;ir 里标准库调用的改写形态;abi 里两个重载符号的后缀;生成 C 里同质/异质数组的声明行(xtarray vs xvalue)。

参考思路:40.5/40.7 的实测命令直接复用;grep 比通读快——生成 C 里搜变量名、abi 里搜符号后缀。

练习 2:AOT/JIT 同源性取证

复现 40.9 的实验:写一行打印的程序,分别 xl run --save-temps 与 xl build --save-temps,找到两份 module.c(在 xl.exe 旁的 tmp 目录),diff 并回答:①相同部分到第几行;②build 版尾部 main 里 xlInit/argv 转换/xlang_main/xlUnit 各在第几行;③run 版尾部的 xl_script_* 桥函数有哪几个。最后两份 module.c 里 grep xlang_main 确认统一入口。

参考思路:build 版会打印保留路径,run 版不打印(37 章坑——去 release\tmp 按时间找最新的 xl6_run_*);diff 输出只有尾部一个 hunk。

练习 3:类型覆盖层侦探

写一组变量声明:auto 推导的 int、float、string、array<int>、dict<string>、以及一个 var。xl cgen 取证后在生成 C 里找出每个变量的声明行,列出"xlang 类型 → C 存储"对照(提示:long long / double / const char* / xtarray / xtdict / xvalue);再 xl ir 对比文本里显示的声明写法,验证"文本显示声明、覆盖层藏真身"。

参考思路:dict 字面量 {"k": 1} 是 dict<int>,要 dict<string> 用 {"k": "v"};var 变量的存储恒为 xvalue——它是动态类型的终极形态。