本章导读
理解编译管线不是为了写编译器,而是为了三件事:一,读懂诊断——第 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 规定的编译主线(原文照录):
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 最可见的行为差异:
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 只做五件事(原文照录):
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、嵌入、模块三种世界的调用约定——这是管线设计的最后一环:前方千变万化,终点只有一个门。
深入细节
编译器教科书里 IR 通常是独立的新数据结构(LLVM IR、JVM 字节码都是另起炉灶),typed HIR 偏不——"AST 节点 + 语义类型覆盖层"的理由写在设计文档里:保留源码位置和语法结构(诊断永远能指回你的代码),避免额外的节点、字符串和类型树分配(编译自身的内存与时间成本)。inferredType 的生命周期由 AST 统一管理,意味着类型标注与树同生共死,没有悬垂指针的数学空间。代价是 IR 不"独立"——不能像 LLVM IR 那样脱离源码流转;但 xlang 的 IR 消费者只有自家 cgen,这个代价为零。工程启示:IR 的形态应当服务它的消费者,而不是服务教科书。
typed HIR 最硬的条款是"缺少必需类型会在 hir 阶段直接报错,不允许静默进入后端"。它防的是编译器开发期的 bug:语义阶段漏标了某个节点的类型,如果没有这道闸,cgen 只能猜测或降级 var——错误会以"生成错误的 C"的形式逃到用户手里。有了这道闸,编译器内部的类型缺陷在 hir 阶段原地爆炸、直接暴露给编译器开发者。配套的豁免同样精细:函数调用交给已解析签名(多返回装不进单一 inferredType)、cgen 自造的协议探测节点允许后端局部推断(它们没有源码 HIR)、"源码表达式不得降级 var"(用户代码的类型尊严)。约束、豁免、禁令三位一体——这是一份把"前后端信任"制度化的合同。
第六站的后半程是 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 的行号,它从来不是你的文件。
⚠️ 常见错误
$ xl ir hir.xl var a : auto = [1, 2] ← auto 还在,推断没做?
原因:ir 文本 dump 显示的是声明写法;推断结果在 typed HIR 覆盖层(inferredType),不在文本表面。正确做法:想看推断真身,xl cgen --emit 看生成 C——xtarray a 会告诉你 HIR 的结论(40.7)。
auto a = [1, 2] // xtarray —— 类型化存储 auto d = [1, "x"] // xvalue 动态容器 —— 通用盒子
原因:两者的 C 存储与访问路径完全不同(xrtTypedArrayCreate vs xvoCreateArray,xrtTypedArrayCount vs xvoArrayItemCount)——动态容器的每次访问都经过通用 xvalue 接口,开销高于类型化存储。正确做法:性能敏感的容器保持元素同构,让 HIR 给你静态泛型存储;异质是灵活性,不是默认选择。
func show(int x) ... end func show(string s) ... end // 以为运行时会看值的类型选版本
原因:重载解析在语义阶段完成——show(42) 已绑定 xl_fn_show__int(abi 快照实证),生成 C 里是直调,运行期没有任何分派。正确做法:重载按声明类型理解(第 14 章);运行期按值分派的需求用 var + is 判断手写。
"run 能跑 build 报错,编译器有两套行为?"
原因:module.c diff 铁证——前 442 行一字不差,只有尾部入口包装不同(xl_script_* 桥 vs main 五步)。行为差异若存在,根源在运行环境(VFS、loadlib、工作目录),不在编译管线。正确做法:run/build 不一致时查环境与依赖(37 章对照表、39 章 exit 127 成因表),别怀疑编译器人格分裂。
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+TCC | HIR → C → 机器码 | xl6HirExprType 读权威类型;auto→xtarray 不得降 var;动态 VFS 内存编译 |
| 目标 C 的理由 | 可移植汇编 / TCC 极速闭环 / 混编零翻译 / 透明可调试;代价:优化有限 | |
| AOT=JIT | module.c 前 442 行相同;差异仅入口包装(main 五步 vs xl_script_* 桥) | |
| 统一入口 | int xlang_main(xvalue arg);exe main 五步;嵌入跳过 1/2 步自供 arg | |
复习题
- 六步主线是哪六步?与第 37 章"四阶段"观察口径是什么关系?
- typed HIR 为什么叫"覆盖层"而不是新 IR?inferredType 的生命周期由谁管理?完整性约束防的是什么?
- auto a = [1, 2] 与 auto d = [1, "x"] 在生成 C 里的存储和 .length 访问分别是什么形态?这条规则在设计文档里怎么表述?
- 重载解析发生在哪一站?xl_fn_show__int 这个符号名证明了什么?
- module.c 的 diff 证明了 AOT 与 JIT 的什么关系?run 版尾部的 xl_script_* 函数是给谁用的?exe 入口五步骤是哪五步?
编程练习
写一个含以下元素的程序:一个重载函数(两版本)、一个同质数组、一个异质数组、一次标准库调用(如 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 里搜符号后缀。
复现 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。
写一组变量声明: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——它是动态类型的终极形态。