本章导读
一个会写代码的程序员和一个会交付软件的程序员之间,隔着的就是本章内容。会写代码只需要 xl run app.xl;会交付则需要回答一连串工程问题:这个语法错误到底卡在词法、语法还是语义阶段?生成的 C 长什么样,能不能交给自己的构建系统?build 出来的 exe 拷给同事能不能直接跑?要带 DLL 吗?版本号和图标怎么打进去?C 写的库怎么链接进来?本章把这些问题逐一实测回答。前六节(37.2–37.7)把六个观察命令当成"编译器的六扇窗"逐个推开,37.8 把 run 与 build 这对孪生命令摆上解剖台做完全对照,37.9 用一个表达式走完整条管线、教会你自己诊断编译器问题的方法论,37.10 收束到交付:零依赖 exe 的实测证据、四个 C 混编选项、以及 -shared DLL 的真实现状。读完你会发现:xl.exe 不是一个黑盒,而是一台每个舱盖都能打开的机器。
掌握 xl.exe 九命令(lex/parse/check/ir/cgen/abi/run/build/impdef)各自的用途与输出形态;会用 --format json 与 --emit 把阶段产物落盘;会用 cgen --dump-dir 一把导出六件套;会用 check --json 接入 CI;理解 run(内存编译、动态 VFS、-- 传参)与 build(产物 exe)的本质差异与退出码透传;会用 --save-temps 保留临时产物;掌握"一个表达式四次变身"的管线追踪法;会用 build -o/--res 产出带版本资源的 exe 并验证零依赖分发;会用 --inc-path/--lib-path/--link/--loadlib 四个混编选项;知道 -shared DLL 导出表为空的现状与 impdef 的真正用途。
37.1 九命令与四阶段管线
不带参数运行 xl.exe,它把全部家底打印出来(真实输出):
$ xl
Usage:
xl lex <file.xl> [--format text|json] [--emit output.tokens]
xl parse <file.xl> [--format text|json] [--emit output.ast]
xl check <file.xl> [--json]
xl ir <file.xl> [--format text|json] [--emit output.xlir]
xl cgen <file.xl> [--emit output.c] [--emit-header output.h] [--dump-dir dir]
xl abi <file.xl> [--json] [--emit output.abi.txt]
xl run <file.xl|file.c|file.xlp> [helper.c ...] [--save-temps] [--inc-path dir] [--lib-path dir] [--link lib] [--loadlib dll] [-- <args>...]
xl build <file.xl|file.c|file.xlp> [helper.c ...] [-o <output.exe|dll>] [-shared] [--res file.json|file.xson] [--save-temps] [--emit-header output.h] [--inc-path dir] [--lib-path dir] [--link lib] [--loadlib dll]
xl impdef <file.dll> -o <output.def>
九个命令分三类。观察类六个:lex、parse、check、ir、cgen、abi——只看不跑,把编译管线的每个阶段剖开给你检查。执行类两个:run(内存编译直接执行)和 build(产出 native 文件)。工具类一个:impdef,从 DLL 抽取符号定义文件。给错命令名会直截了当拒绝:
$ xl frobnicate app.xl unknown command: frobnicate # 退出码 1
六个观察命令对应编译器内部的四阶段管线(docs/命令行设计.md 口径):
source.xl → lex 词法:字符流 → token 序列(flex) → parse 语法:token 流 → AST 语法树(lemon) → ir 中间表示:类型解析、控制流、标准库注入 → cgen 代码生成:IR → C 源码 → tcc 内嵌 TCC:C → 机器码(run 内存执行 / build 写盘) check 是 parse 之后、ir 之前的语义诊断便捷命令,不算主阶段; abi 独立于主线,输出模块对外契约快照。
run 和 build 还按输入文件后缀自动分派:.xl 走 xlang 管线;.c/.cc/.cpp/.cxx 直接交内嵌 TCC(所以 xl run hello.c 能直接跑 C 程序,37.9 实测);.xlp 按工程文件处理(37.10)。不做向上查找、不看目录——后缀决定一切。
37.2 lex:看 token 流
本章用一个贯穿始终的演示程序(app.xl):
func square(int x) as int return x * x end func main(...) println("hello from app") println("argc = " + string(arg.length)) if arg.length > 1 then println("arg[1] = " + arg[1]) end println("square(7) = " + string(square(7))) end
xl lex 只跑词法阶段,输出三栏:位置、token 种类、原始文本(真实输出,截取前部):
$ xl lex app.xl
1:1 KW_FUNC func
1:6 IDENT square
1:12 LPAREN (
1:13 IDENT int
1:17 IDENT x
1:18 RPAREN )
1:20 KW_AS as
1:23 IDENT int
1:26 SEP
2:5 KW_RETURN return
2:12 IDENT x
2:14 STAR *
2:16 IDENT x
2:17 SEP
……
注意两件事:一,int 是 IDENT(普通标识符)而不是关键字——xlang 的类型名在语义阶段才被认领,词法阶段一视同仁;二,换行产生 SEP token——这就是为什么 xlang 用换行而不是分号断句,第 2 章"每条语句独占一行"的规则在词法层就埋下了。
加 --format json --emit 把 token 流落盘成机器可读格式,供编辑器插件、语法高亮器等工具消费:
$ xl lex app.xl --format json --emit out/app.tokens.json # 无输出即成功;注意 out/ 目录必须已存在(坑 1) $ head -c 300 out/app.tokens.json [ {"kind":"KW_FUNC","line":1,"column":1,"text":"func"}, {"kind":"IDENT","line":1,"column":6,"text":"square"}, {"kind":"LPAREN","line":1,"column":12,"text":"("}, ……
37.3 parse:看语法树
xl parse 跑完词法+语法两阶段,输出缩进文本形态的 AST(真实输出,截取 square 函数与 main 开头):
$ xl parse app.xl
module
func "func" @1:1
name "square" @1:6
list
param "x" @1:17
type "int" @1:13
return_types
type "int" @1:23
block
return "return" @2:5
binary "*" @2:14
name "x" @2:12
name "x" @2:16
type_params
func "func" @5:1
name "main" @5:6
list
param "arg" @5:11
type "array<var>" @5:11
……
这棵树回答了一个从第 2 章遗留至今的问题:func main(...) 的 ... 到底是什么?看 main 的参数节点——变参 ... 被解析成 param "arg",类型 array<var>。原来 arg 不是语法糖,是变参的真实落点:一个装任意命令行参数的动态数组,arg[0] 是程序名,arg[1] 起才是你传的参数(第 5 章用过,如今见到真身)。
每个节点带 @行:列 坐标——编译器报错时那些 app.xl:2:5 就是从这里来的。--format tree(默认)给人看,--format json --emit 落盘给工具看,结构与 lex 的 JSON 同风格。
37.4 check:CI 守门员
xl check 跑词法+语法+语义三阶段,不生成任何代码。好程序它就回一个字:
$ xl check app.xl ok # 退出码 0
坏程序它按行号列数开清单,并且一次报完所有错误而不是死在第一个(bad.xl 故意埋两个错):
$ cat bad.xl func main(...) int n = "hello" println(n + true) end $ xl check bad.xl bad.xl:2:13: [sem] 初始化表达式类型和声明类型不匹配: target int, value string bad.xl:3:15: [sem] numeric operator operands must be numeric # 退出码 1
--json 输出机器可读诊断,含阶段标记(phase)——CI 脚本和编辑器靠它区分错误来自哪一阶段:
$ xl check app.xl --json {"ok":true,"phase":"checked","diagnostics":[]} $ xl check bad.xl --json {"ok":false,"phase":"sem","diagnostics":[{"file":"bad.xl","line":2,"column":13,"message":"初始化表达式类型和声明类型不匹配: target int, value string"},{"file":"bad.xl","line":3,"column":15,"message":"numeric operator operands must be numeric"}]}
工程用法:CI 流水线里 xl check src/*.xl || exit 1 就是最低成本的回归门禁;编辑器保存时跑 --json 解析 diagnostics 数组画波浪线。它不运行程序、不生成文件,速度比 run 快,是"改完代码第一秒就想知道对错"的正确工具。
37.5 ir:中间表示
xl ir 输出语义处理后的中间表示——这是"编译器眼中你的程序"(真实输出,截取前部):
$ xl ir app.xl
xlir 0
func square(x: int) as int
return (x * x)
end_func
func main(arg: array<var>)
call __xl_std_println("hello from app")
call __xl_std_println(("argc = " + call string(arg.length)))
if (arg.length > 1)
call __xl_std_println(("arg[1] = " + arg[1]))
end_if
call __xl_std_println(("square(7) = " + call string(call square(7))))
end_func
class object
field __fields : var = dict{}
func __field__(name: string) as var
return self.__fields[name]
end_func
……
end_class
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
对比 parse 的树,ir 是三阶段语义加工的成品,四处变化值得逐一看懂:
一,println 现了原形。你写的 println 在 ir 里变成 call __xl_std_println(...),而文件尾部自动追加了它的真身:一个包着 __xl_std_runtime_print 的普通函数,后者是 extern 到运行时符号 xl6_xrt_print 的桥。第 36 章讲的"标准库默认注入"在这里可见可触。
二,object 基类被注入。就算你的程序一个 class 都没写,ir 里依然出现完整的 object 基类(__fields 字典加四个方法)——第 21 章"万物皆 object 子类"的兑现现场。
三,隐式动作显式化。字符串拼接里的 string(...) 转换、函数调用的嵌套顺序,全部展开成无歧义的嵌套表达式,每个运算都有确定类型。
四,main 参数定型。arg: array<var> 再次出现——从 parse 到 ir,这个推断被保留并参与类型检查。
首行 xlir 0 是格式版本号。--format json --emit 输出 JSON 形态(stage 字段标记 "ir"),--emit out/app.xlir 落盘文本形态,建议扩展名 .xlir。
37.6 cgen 与 --dump-dir
xl cgen 是管线的最后一扇窗,也是最重要的一扇:它输出 xlang 编译的最终成品——交给内嵌 TCC 的 C 源码。换成带 export 的 geom.xl 演示(export 是第 33 章内容,这里只当素材):
export "app_add" func add(int a, int b) as int return a + b end export "app_scale" func scale(float x, float k) as float return x * k end
$ xl cgen geom.xl --emit out/geom.c --emit-header out/geom.h # 无输出即成功;geom.c 725 行,geom.h 65 行 $ grep app_ out/geom.h int64_t app_add(int64_t a, int64_t b); double app_scale(double x, double k);
.c 是完整可编译的 C 翻译单元(含运行时声明注入,725 行里大部分是按需注入的运行时桥),.h 是给 C 宿主看的对外头文件——xlang 的 int 落成 int64_t、float 落成 double,与第 32 章的 ABI 位宽规则严丝合缝。这份 .c 的用途远超"看看":第 33 章的静态混编闭环就是把它交给外部 tcc/gcc 参与构建,你的构建系统完全可以接管这一步。
调试时更常用的是 --dump-dir——一把梭,把全部阶段的产物一次性倒进一个目录(真实输出):
$ xl cgen geom.xl --dump-dir dump $ ls -la dump geom.abi.json 699 B ← abi 阶段的契约快照 geom.ast.json 9876 B ← parse 阶段的语法树 geom.c 21840 B ← cgen 阶段的生成 C geom.h 1549 B ← 对外头文件 geom.tokens.json 3557 B ← lex 阶段的 token 流 geom.xlir 819 B ← ir 阶段的中间表示
六件套按管线顺序排开:tokens → ast → xlir → abi → c → h。报告编译器 bug 时附上这个目录,等于把病人的全套化验单交给医生;自己排查"编译器到底怎么理解我的代码"时,这也是起点。
37.7 abi:对外契约快照
xl abi 输出模块的 ABI 元信息——每个符号的最终名字、签名、C 侧别名(真实输出,完整):
$ xl abi geom.xl
xlang abi version: 6
module: app
export app_add
name: add
signature: fn:app.add(int,int) as int
symbol: xl6_export_add
c_export: app_add
export app_scale
name: scale
signature: fn:app.scale(float,float) as float
symbol: xl6_export_scale
c_export: app_scale
func main
signature: fn:app.main(array<var>) as ()
symbol: main
extern xl6_xrt_print
name: __xl_std_runtime_print
signature: fn:app.__xl_std_runtime_print(var,bool) as ()
c_symbol: xl6_xrt_print
func __xl_std_println
signature: fn:app.__xl_std_println(array<var>) as ()
symbol: __xl_std_println
逐字段读:export 函数有两重身份——xlang 内部符号 xl6_export_add(重载/泛型场景下保证唯一)和 C 侧名字 app_add(export 别名);signature 是带模块前缀的完整签名串,重载函数靠它区分。第 33 章已逐字段精讲过,本章补两个新观察:
一,普通函数也登记。没有 export 的 main 和注入的 __xl_std_println 同样出现在快照里——abi 是模块全量符号表,不只是导出表。二,模块名恒为 app。无论文件叫 geom.xl 还是别的什么、放在哪个目录,单文件编译的模块名都是 app(package 机制下另论,第 35 章)。signature 里的 app. 前缀由此而来。
--json --emit out/geom.abi.json 落盘机器可读版(abiVersion、module、symbols 数组),适合存档做版本间 diff——契约变了没有,diff 一下就知道,这正是"ABI 快照"的工程意义。
37.8 run 与 build 完全对照
run 和 build 共用同一条管线(lex→parse→ir→cgen→TCC),差别全在最后一步:run 把 C 编译进内存直接调用,不落任何 exe;build 让 TCC 把机器码写盘成文件。先看 run 的三项实测。
传参用 -- 分隔(防止程序参数被 xl 自己吃掉):
$ xl run app.xl hello from app argc = 1 square(7) = 49 $ xl run app.xl -- foo bar hello from app argc = 3 arg[1] = foo square(7) = 49
--save-temps 保留内存编译的幕后产物。run 模式下它静默地把生成物留在 xl.exe 旁的 tmp 目录(不打印路径——这是与 build 的差别,如实记录):
$ xl run app.xl --save-temps hello from app …… # 事后在 release\tmp\ 下多出 xl6_run_22588_3822121828_0\ # 里面只有一件:module.c(22 KB)——run 此刻编译的正是它
build 的三项实测。省略 -o 时在当前工作目录用源文件名命名(app.xl → app.exe);产物的命令行参数不需要 --,直接跟在 exe 后面:
$ xl build app.xl $ ls -la app.exe -rwxr-xr-x 1 Administrator 197121 920576 … app.exe $ ./app.exe foo bar hello from app argc = 3 arg[1] = foo square(7) = 49
build 的 --save-temps 会在结束时向 stderr 打印保留路径,内容也比 run 多一件:
$ xl build app.xl -o out/myapp.exe --save-temps temporary files kept: D:\GIT\x-lang\demo6\release\tmp\xl6_build_24520_3822121875_0 $ ls D:\GIT\x-lang\demo6\release\tmp\xl6_build_24520_3822121875_0 module.c 22684 B ← 比 run 版多 exe 入口包装 xrt_impl.c 416 B ← 运行时实现翻译单元
最后是一项容易忽视的一致性:main 的 int 返回值就是进程退出码,run 与 build 完全对齐——
$ cat retcode.xl func main(...) as int println("about to return 7") return 7 end $ xl run retcode.xl; echo $? about to return 7 7 $ xl build retcode.xl -o retcode.exe && ./retcode.exe; echo $? about to return 7 7
对照表收拢全部差异:
| 维度 | xl run | xl build |
|---|---|---|
| 产物 | 无(内存编译即抛) | exe/dll 写盘 |
| 程序参数 | 需 -- 分隔 | 直接跟在 exe 后 |
| 默认输出名 | — | 当前目录下 源文件名.exe |
| --save-temps | 静默留在 xl.exe 旁 tmp\xl6_run_*(仅 module.c) | stderr 打印路径;module.c + xrt_impl.c |
| 退出码 | 透传 main 返回值 | 透传 main 返回值 |
| .xlp 工程 | 只接 kind exe(kind dll 报错,坑 3) | exe/dll 皆可,kind 决定产物 |
| 典型场景 | 开发期快速迭代、脚本执行、CI | 交付、部署、性能复测 |
37.9 管线调试实战
六扇窗单独看过了,现在把它们串成一套诊断方法论。原则一句话:编译器报错一定属于某个阶段,把程序逐阶段过一遍,第一个崩的阶段就是病灶。用一个更小的程序 mini.xl 追踪一个表达式的完整旅程:
func main(...) int total = 6 * 7 println("total = " + string(total)) end
第一站 lex:表达式还是五个孤立 token——
2:9 IDENT total 2:15 ASSIGN = 2:17 INT 6 2:19 STAR * 2:21 INT 7
第二站 parse:token 组织成结构——var_decl 节点下挂一棵 binary "*" 子树,优先级正确(乘法是一个整体):
var_decl "total" @2:9
type "int" @2:5
binary "*" @2:19
int "6" @2:17
int "7" @2:21
第三站 ir:类型落定,println 已被改写成内部名——
func main(arg: array<var>)
var total : int = (6 * 7)
call __xl_std_println(("total = " + call string(total)))
end_func
第四站 cgen:xlang 类型映射到 C 类型,字面量带上了位宽后缀——
$ xl cgen mini.xl --emit out/mini.c && grep -n "total" out/mini.c
643: long long total = (6LL * 7LL);
648: xvalue __xl6_print_args_4 = xl6_xrt_array_varargs(1, xvoCreateText(
(void*)(xl6_xrt_str_concat("total = ",
xl6_xrt_i64_to_string((long long)(total)))), 0, false));
int 落成 long long、6 变成 6LL(xlang int 是 64 位,第 3 章)、println 展开成 xrt 函数链——第 36 章"按需注入"的证据链在生成 C 里一应俱全。
诊断方法论由此成型:遇到诡异报错,先 xl check 定位阶段(JSON 的 phase 字段直接告诉你 lex/parse/sem);怀疑编译器理解错了你的代码,就 xl ir 看语义加工后的形态;怀疑代码生成或链接,就 xl cgen --dump-dir 拿六件套逐层比对。前 36 章本书制造的每一个报错,都是这样定位到阶段的。
顺带实测一个常被忽视的能力:run 和 build 也直接接受 .c 文件——内嵌 TCC 跳过 xlang 管线直编 C:
$ cat hello.c #include <stdio.h> int main(void) { printf("hello from plain C\n"); return 0; } $ xl run hello.c hello from plain C $ xl build hello.c -o hello_c.exe && ./hello_c.exe hello from plain C
这让 xl.exe 同时是一个随身 C 编译器——验证一段 C 的行为、给混编写对照实验时,不用再找 gcc。
37.10 分发、混编选项与 -shared
零依赖分发实测。build 出的 exe 拷进一个除了它什么都没有的目录,直接运行:
$ mkdir /tmp/xlship && cp out/myapp.exe /tmp/xlship/ && cd /tmp/xlship $ ls -la -rwxr-xr-x 1 Administrator 197121 920576 … myapp.exe ← 目录里只有它 $ ./myapp.exe 你好 世界 hello from app argc = 3 arg[1] = 你好 square(7) = 49
解析这份 exe 的 PE 导入表,依赖只有 5 个 Windows 系统 DLL:msvcrt.dll、kernel32.dll、SHELL32.dll、ws2_32.dll、IPHLPAPI.dll——没有任何 xlang 运行时 DLL、没有 TCC 组件、没有第三方库(ws2_32/IPHLPAPI 是 xrt 网络支持静态带入的系统接口)。xlang 运行时被整体静态链接进 exe,这就是 920 KB 的去向,也是"拷走即跑"的原理。
--res 打版本资源。Windows exe 右键属性里的版本号、图标、manifest 用一份 JSON 注入:
$ cat app.res.json { "format": "xlang-windows-resource-1", "language": 1033, "codepage": 1200, "version": { "fileVersion": "1.2.3.4", "productVersion": "1.2.3.4", "fileDescription": "xlang resource smoke", "productName": "xlang resource smoke", "companyName": "xlang", …… }, "icon": { "base64": "AAABAAEA……" }, "manifest": { "text": "<?xml version=\"1.0\" ……</assembly>" } } $ xl build app.xl -o out/appres.exe --res app.res.json # 产物 922112 B(比无资源版多 1.5 KB 资源段); # 实测 exe 内可检索到 UTF-16 的 "xlang resource smoke" 与 "1.2.3.4" —— 写入成功
资源对象在内存中生成 COFF .rsrc 挂进动态 VFS,不产生磁盘中间文件——这是功能清单的原话,与实测一致。
混编四选项逐个实测。它们把 C 世界的 include 路径、库路径、链接库、运行库注入 TCC 后端:
# ① --inc-path:helper.c 里 #include "mathx.h",头文件在 myinc\ 下 $ xl run usehelper.xl helper.c --inc-path myinc triplePlus1(10) = 31 $ xl run usehelper.xl helper.c ← 不给 --inc-path 的对照 tcc: In file included from __xlvfs__/csrc_0.c:3: helper.c:1: error: include file 'mathx.h' not found # ②③ --lib-path + --link:用 impdef 抽出的 cdll.def 链接 DLL $ xl run usedll.xl --lib-path . --link cdll c_double_it(21) = 42 c_add3(1,2,3) = 6 # ④ --loadlib:运行期直接加载 DLL(第 32 章的闭环) $ xl run usedll.xl --loadlib cdll.dll c_double_it(21) = 42 c_add3(1,2,3) = 6 $ xl run usedll.xl ← 忘给的对照(坑 4) tcc: tcc: error: unresolved reference to 'c_add3'
①的报错路径 __xlvfs__/csrc_0.c 顺带暴露了实现:helper.c 被包进动态 VFS 命名为 csrc_N.c 参与编译(深入细节 1 展开)。③的 --link 接库名,TCC 在 --lib-path 里找 def/a/dll 任一形态;④的 --loadlib 不做链接期检查,直接把 DLL 加进运行期符号解析——两条路殊途同归,--link 适合有 def/静态库的正式依赖,--loadlib 适合"只有 DLL"的快速验证。
.xlp 工程文件速览。JSON 声明 kind/entry/cSources/resources,build 时 kind 决定产物:
$ cat lib.xlp { "format": "xlang-project-1", "kind": "dll", "entry": "entry.xl" } $ xl build lib.xlp -o lib.dll ← kind dll 不需要 -shared # 产出 502784 B lib.dll $ xl build app.xlp -o appx.exe && ./appx.exe from xlp
-shared 与 impdef 的现状(如实记录)。第 33 章的结论本章复核依然成立:xlang 源码 build -shared 能产出 DLL,但导出表是空的——
$ xl build geolib.xl -shared -o geom.dll --emit-header geom_shared.h # 产出 502784 B geom.dll(注意:源文件含 main 会被拒,见坑 2) $ xl impdef geom.dll -o geom.def impdef failed: geom.dll tcc: impdef: no symbols found in 'D:\GIT\x-lang\demo6\tmp\bookch\geom.dll'
原因与对策第 33 章 33.8 节已详述:生成的导出 wrapper 没有 dllexport 标注,当前 C 宿主闭环走静态混编(cgen 出 .c 交自己的构建系统),或自行补导出声明编 DLL。impdef 的真正用武之地是反方向:给带 dllexport 的 C 侧 DLL 抽 def,供 --link 消费——上面 ②③ 的 cdll.def 就是这么来的(xl build cdll.c -shared -o cdll.dll 编 C DLL,xl impdef cdll.dll -o cdll.def 抽符号,输出标准 LIBRARY/EXPORTS 格式)。分发含 --loadlib 依赖的 exe 还有一条铁律:DLL 必须随 exe 一起走,缺失时程序静默退出(实测退出码 127,无任何提示,坑 5)。
深入细节
run 为什么不生成临时 exe 也能跑?功能清单 §2 给了答案:TCC 的文件查找顺序固定为动态 VFS → 外部文件系统 → 静态 VFS。cgen 产出的 module.c、生成头、C 混编 wrapper 全部挂进内存里的动态 VFS(优先级最高),TCC 直接在内存里读完编译,全程不碰磁盘。本章两处实测让它现形:--inc-path 缺失时报错路径是 __xlvfs__/csrc_0.c:3——helper.c 被包装成动态 VFS 里的 csrc_0.c;run --save-temps 则是"把动态 VFS 显式导出到磁盘"的调试开关。静态 VFS 也分两层:xl.dll(脚本运行版)只携带运行所需的 TCC include 与 libtcc1 资源,xl.exe 携带完整编译兜底资源——所以嵌入场景的 xl.dll 可以比开发用的 xl.exe 小得多。外部文件系统夹在中间是有意的:允许你用外部头文件、库文件覆盖内置兜底资源。
hello 级程序 920 KB,大头是整体静态链接进来的 xlang 运行时(xrt):xvalue 动态类型系统、容器实现、字符串处理、标准库桥、GC 与临时内存区,一个不缺全在里面——这正是"零依赖分发"的代价与底气。导入表里的 ws2_32.dll(套接字)和 IPHLPAPI.dll(网络信息)不是你的代码引的,是 xrt 网络支持链入的系统接口,它们在所有 Windows 机器上天然存在,不构成分发负担。编译后端是内嵌 TCC:它的设计目标是编译速度与小巧,不做激进优化,所以同一段计算,xl build 产物的运行性能低于 gcc -O2 编译的等价 C——对脚本类、工具类、胶水类程序这完全够用;真有热点,第 32/33 章的混编路把它下沉到 C 侧用优化编译器处理。另外 TCC 生成的 exe 不依赖 vcruntime/ucrt 之外的任何 MSVC 组件,老机器上也没有运行时安装问题。
两个 abi 输出里的小字藏着机制。模块名:单文件编译时模块名恒为 app——你的 main 登记为 fn:app.main(array<var>) as (),与文件名、目录名无关。模块名真正的来源是 package 机制(第 35 章),单文件只是兜底默认。退出码:main 声明 as int 并 return 7 时,run 模式下 xlang_main 的返回值被运行时接住,经 xl.exe 的"取最近一次退出码"机制透传为进程退出码;build 产物里它就是 C main 的 return 值,由操作系统直接承载。两条路殊途同归到同一个数字——所以 CI 里 xl run tool.xl || alert 与部署后 ./tool.exe || alert 的判断逻辑可以一字不改。这也是 main 返回类型"只能省略或声明为 int"这条语义规则(第 11 章)的工程动机:进程退出码本来就是 int。
⚠️ 常见错误
$ xl lex app.xl --format json --emit out/app.tokens.json # out\ 还没建 cannot write token output: Failed to open file (3): out/app.tokens.json
原因:--emit 不会替你创建父目录,直接打不开文件。正确写法:先 mkdir 再落盘;--dump-dir 会自建目录,批量导出时优先用它。
$ xl build geom.xl -shared -o geom.dll # geom.xl 里有 main shared build cannot contain xlang main or top-level executable statements $ xl build geolib.xl -shared # 去掉 main 但忘了 -o build shared library requires -o <output.dll>
原因:共享库没有入口概念,main 与顶层可执行语句在 -shared 下是矛盾物;DLL 输出路径也不能靠默认命名。正确写法:库源码只留 export 函数(main 挪到测试文件),并且永远显式 -o xxx.dll。
$ xl build app.xlp -shared -o app.dll # app.xlp 里 kind: "exe" xlp kind conflicts with -shared: app.xlp $ xl run lib.xlp # lib.xlp 里 kind: "dll" cannot run dll project: lib.xlp
原因:kind 是工程文件的权威声明,-shared 不能翻案;dll 工程没有可运行入口。正确写法:改产物类型就改 .xlp 里的 kind;想跑就 build 出 exe 再跑。
$ xl run usedll.xl # extern "c_add3" 但没给库 tcc: tcc: error: unresolved reference to 'c_add3'
原因:extern 只是声明"这个符号存在",符号的尸体在 DLL/静态库里——不给 --loadlib 或 --link,TCC 链接时自然找不到。报错里 tcc: tcc: 双前缀是真实输出(外层 xl 标记来源、内层 TCC 自身前缀),不是本书笔误。正确写法:快速验证用 --loadlib xxx.dll;正式依赖用 --lib-path 目录 --link 库名。
$ cp usedll.exe /tmp/xlship2/ && cd /tmp/xlship2 && ./usedll.exe # 无任何输出,退出码 127 —— 静默死亡 $ cp ../cdll.dll . && ./usedll.exe c_double_it(21) = 42 c_add3(1,2,3) = 6
原因:--loadlib 引入的是运行期依赖,exe 启动时就要解析 DLL,找不到就静默退出(连错误框都没有)。正确写法:分发清单里把 exe 与依赖 DLL 放同一目录一并交付;想回到"单文件拷走即跑",就把 C 代码改成静态混编(第 33.7 节),依赖在编译期消化掉。
本章小结
| 命令/选项 | 用法 | 一句话记忆 |
|---|---|---|
| lex | xl lex f.xl [--format json] [--emit out] | token 流三栏:位置、种类、原文;int 是 IDENT 不是关键字 |
| parse | xl parse f.xl [--format json] [--emit out] | AST 缩进树;main(...) 的 arg 是 array<var> |
| check | xl check f.xl [--json] | 语义守门员:ok/诊断清单+退出码,CI 第一道闸 |
| ir | xl ir f.xl [--emit out.xlir] | 编译器眼中的程序:println 原形、object 注入、类型落定 |
| cgen | xl cgen f.xl --emit o.c [--emit-header o.h] [--dump-dir d] | 最终生成 C;--dump-dir 六件套是排障全套化验单 |
| abi | xl abi f.xl [--json] [--emit out] | 全量符号契约快照;模块名恒 app;版本 diff 用 --json 存档 |
| run | xl run f.xl -- args | 内存编译零产物;参数要 -- 分隔;退出码透传 |
| build | xl build f.xl [-o o.exe] [--res r.json] | 920KB 零依赖 exe;参数直跟;--res 打版本图标 |
| impdef | xl impdef x.dll -o x.def | 给 C 侧 DLL 抽 def 供 --link;xlang 自产 DLL 导出表为空 |
| --save-temps | run/build 通用 | run 静默留 module.c;build 打印路径留 module.c+xrt_impl.c |
| 混编四选项 | --inc-path/--lib-path/--link/--loadlib | 头文件路径/库路径/链接库/运行期加载;忘给报 unresolved reference |
| -shared | xl build lib.xl -shared -o x.dll | 禁 main 必须 -o;当前导出表为空,C 宿主走静态混编 |
复习题
- xl.exe 的九个命令各属于"观察/执行/工具"哪一类?四阶段管线是哪四站,check 站在什么位置?
- xl lex 的输出里 int 为什么是 IDENT 而不是关键字?SEP token 对应源文件里的什么?
- xl run 与 xl build 在传参方式、产物、--save-temps 行为上有哪些差异?main 的 int 返回值在两条路径下分别如何变成进程退出码?
- 你的程序报链接错误 unresolved reference,用本章的管线追踪法如何一步步定位?--dump-dir 会给你哪六件产物?
- build 出的 exe 为什么能拷到干净 Windows 机器直接运行?导入表里 ws2_32.dll 是谁引入的?当前 -shared 产出的 xlang DLL 为什么不能给 C 宿主直接用,替代方案是什么?
编程练习
挑一个你此前写的 xlang 程序(至少含一个自定义函数和一次标准库调用),执行:xl check --json 确认 phase 为 checked;xl cgen --dump-dir dump 导出六件套;在 dump 的 .xlir 里找到你调用的标准库函数被改写成什么内部名;在 .c 里找到该函数对应的 xrt 调用链;最后用 abi --json 存档。写三行笔记:你的函数的内部符号名、标准库调用的落地形态、模块名。
参考思路:37.5 的 println 追踪是模板;.c 文件里搜函数名比逐行读快;abi 的 symbols 数组按 kind 过滤 export_func/func/extern_func。
写一个命令行小工具(如:把 arg[1] 文本做 base64 编码输出,缺参数时打印用法并 return 2),然后:xl build 带 --res 注入版本号 1.0.0.0 与你的署名;把 exe 拷到新建的空目录验证零依赖运行;分别验证成功(退出码 0)与缺参(退出码 2)两条路径;最后故意制造一个语义错误,用 xl check --json 确认 CI 能拦住(退出码 1、phase 为 sem)。
参考思路:base64 用 codec.base64Encode(第 36 章);--res 的 JSON 照 37.10 的骨架改;退出码验证用 shell 的 $?。
写一个 C 文件导出两个函数(如 c_min3/c_max3 求三数最值,注意 xlang int 是 64 位,C 侧用 long long),然后:用 xl build x.c -shared 编成 DLL;xl impdef 抽出 def;xlang 侧 extern 声明后,分别用 --loadlib 和 --lib-path + --link 两条路径各跑通一次;最后 build 成 exe,分别在带 DLL 与不带 DLL 的目录运行,记录两种退出码并解释差异。
参考思路:37.10 的 cdll 闭环是完整模板;位宽不匹配会撞 redefinition 报错(37.10 ①的教训);不带 DLL 的静默退出对应坑 5。