第 37 章 / 共 58 章 卷八 工程与原理(开篇)

构建分发与 CLI

卷八开篇。前 36 章你只用过 xl run 和 xl build 两个命令,本章把编译器的整个控制台交到你手上:九个命令、一条四阶段管线、一组混编选项。学完你不仅能逐阶段透视自己的程序(token 流、语法树、中间表示、生成 C),还能 build 出一个拷到任何 Windows 机器上双击就跑的 exe——920 KB,零第三方依赖,还能带版本号和图标。

本章导读

一个会写代码的程序员和一个会交付软件的程序员之间,隔着的就是本章内容。会写代码只需要 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):

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 章内容,这里只当素材):

geom.xl —— 带两个 C ABI 导出
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 runxl 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 追踪一个表达式的完整旅程:

mini.xl —— 追踪对象:int total = 6 * 7
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)。

深入细节

深入细节 1:动态 VFS——run 不落盘的秘密

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 小得多。外部文件系统夹在中间是有意的:允许你用外部头文件、库文件覆盖内置兜底资源。

深入细节 2:920 KB 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 组件,老机器上也没有运行时安装问题。

深入细节 3:模块名 app 与退出码的旅程

两个 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。

⚠️ 常见错误

⚠️ 坑 1:--emit/--dump-dir 的目标目录不存在
$ 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 会自建目录,批量导出时优先用它。

⚠️ 坑 2:-shared 的源文件里留了 main(或忘了 -o)
$ 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。

⚠️ 坑 3:xlp 的 kind 与命令行打架
$ 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 再跑。

⚠️ 坑 4:extern 了 C 函数却忘了 --loadlib/--link
$ 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 库名。

⚠️ 坑 5:分发带 --loadlib 依赖的 exe 却忘了带上 DLL
$ 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 节),依赖在编译期消化掉。

本章小结

命令/选项用法一句话记忆
lexxl lex f.xl [--format json] [--emit out]token 流三栏:位置、种类、原文;int 是 IDENT 不是关键字
parsexl parse f.xl [--format json] [--emit out]AST 缩进树;main(...) 的 arg 是 array<var>
checkxl check f.xl [--json]语义守门员:ok/诊断清单+退出码,CI 第一道闸
irxl ir f.xl [--emit out.xlir]编译器眼中的程序:println 原形、object 注入、类型落定
cgenxl cgen f.xl --emit o.c [--emit-header o.h] [--dump-dir d]最终生成 C;--dump-dir 六件套是排障全套化验单
abixl abi f.xl [--json] [--emit out]全量符号契约快照;模块名恒 app;版本 diff 用 --json 存档
runxl run f.xl -- args内存编译零产物;参数要 -- 分隔;退出码透传
buildxl build f.xl [-o o.exe] [--res r.json]920KB 零依赖 exe;参数直跟;--res 打版本图标
impdefxl impdef x.dll -o x.def给 C 侧 DLL 抽 def 供 --link;xlang 自产 DLL 导出表为空
--save-tempsrun/build 通用run 静默留 module.c;build 打印路径留 module.c+xrt_impl.c
混编四选项--inc-path/--lib-path/--link/--loadlib头文件路径/库路径/链接库/运行期加载;忘给报 unresolved reference
-sharedxl build lib.xl -shared -o x.dll禁 main 必须 -o;当前导出表为空,C 宿主走静态混编

复习题

  1. xl.exe 的九个命令各属于"观察/执行/工具"哪一类?四阶段管线是哪四站,check 站在什么位置?
  2. xl lex 的输出里 int 为什么是 IDENT 而不是关键字?SEP token 对应源文件里的什么?
  3. xl run 与 xl build 在传参方式、产物、--save-temps 行为上有哪些差异?main 的 int 返回值在两条路径下分别如何变成进程退出码?
  4. 你的程序报链接错误 unresolved reference,用本章的管线追踪法如何一步步定位?--dump-dir 会给你哪六件产物?
  5. build 出的 exe 为什么能拷到干净 Windows 机器直接运行?导入表里 ws2_32.dll 是谁引入的?当前 -shared 产出的 xlang DLL 为什么不能给 C 宿主直接用,替代方案是什么?

编程练习

练习 1:给自己的程序做全套体检

挑一个你此前写的 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。

练习 2:交付一个带身份的单文件工具

写一个命令行小工具(如:把 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 的 $?。

练习 3:C 库的两条消费路径

写一个 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。