本章导读
第 32/33 章讲的是"xlang 与 C 平级混编",第 37 章讲的是"xlang 自己交付 exe",本章是第三种关系:C 是主程序,xlang 是内嵌的脚本引擎。你的 C 程序启动时创建一个语言上下文,把规则、配置、业务逻辑写成 xlang 脚本喂给它执行;脚本里可以 extern 回调你注册的 C 函数,你可以把数据包成 xvalue 传给脚本,脚本编译成模块常驻内存保状态,甚至从 C 侧一条调用把脚本编译成独立 exe 分发出去。整套能力由两个 DLL 承载:xl.dll(轻量运行版,只管跑脚本)和 xlc.dll(编译版,多一套编译产物 API)。本章全部示例用第 37 章的 xl build host.c 内嵌 TCC 编译成宿主 exe 实测——不需要安装任何额外 C 编译器。读完你将能把 xlang 嵌进任何 C 程序,也知道多线程、所有权、DLL 分发这三类生产事故的避雷位置。
理解 xl.dll 运行版与 xlc.dll 编译版的分层与选型(含 XL_RUNTIME_ONLY_API 裁剪);掌握宿主三部曲(xlDefaultContextOptions → xlCreateContext → xlRunScriptString/File → xlDestroyContext)与宿主编译命令;会用 xlGetLastError/xlClearLastError/xlGetLastExitCode 处理错误与退出码;掌握上下文配置(workDir/tempDir/saveTemps 与路径快照口径)与环境注入六函数;会用 xlRunScriptStringArg 传 xvalue 数组并理解所有权边界;会用 xlRegisterCFunction 完成 C→脚本的回调闭环;会用常驻模块(xlCompileScriptStringToModule → xlActivateModule → xlModuleGetSymbol → xlDeactivateModule)保持脚本全局状态;会用 xlCompileScriptFileToExe 从 C 侧编译产物;掌握多线程宿主"每线程独立 context"模型与当前 string() 并发边界;掌握 xlc.dll 33 个 API 与 xl.exe 的包装关系、76 个 API 的分组地图。
38.1 嵌入架构与选型
release 目录里躺着两个 DLL 和它们的 def 文件:xl.dll + xl.def(运行版)与 xlc.dll + xlc.def(编译版)。宿主程序包含头文件、链接 def、运行期加载 DLL——三件套各司其职:
| 维度 | xl.dll(运行版) | xlc.dll(编译版) |
|---|---|---|
| 头文件 | include/xl.h(共用);编译器 API 另有 include/xlc.h | |
| 能力 | 上下文、跑脚本、注册 C 函数、模块管理 | 运行版全部能力 + 编译 exe/dll 产物 |
| API 数 | xl.h 共 43 个声明,其中 6 个编译 API 可用 XL_RUNTIME_ONLY_API 宏裁掉(实测 38.8) | xlc.h 33 个(含 emit 全族) |
| 内置资源 | 运行版 VFS 小清单:TCC include + libtcc1.a | 完整编译清单:多 exe 链接所需 def 资源与单头 xrt.h |
| libtcc | 静态内嵌,不搜索不复制外部 libtcc.dll | |
| 典型场景 | 脚本引擎嵌入(游戏/服务/工具热更层) | 自建构建工具、IDE 后端、脚本交付流水线 |
选型一句话:只跑脚本选 xl.dll,要从 C 侧产出 exe/dll 选 xlc.dll。拿不准就链接 xlc.dll——它是运行版的超集,代价只是体积。两个 DLL 都不提供 lex/parse/ir 等调试 dump(那是 xl.exe CLI 的地盘,第 37 章),DLL 是纯粹的生产接口。
def 文件另有乾坤:xl.def 导出表实测 2640 行——除了 43 个公开 API,还导出了全套 XRT 符号(xrt*/xvo*,154 个 xvo 函数)与 libtcc 内部符号。所以宿主不仅能调公开 API,还能直接用 xvoCreateText 这类 xvalue 构造函数(38.5 传参就靠它)。
38.2 宿主三部曲
最小宿主程序只有三步:建上下文、跑脚本、毁上下文(host1.c,完整可编译):
#include "include/xl.h" #include <stdio.h> int main(void) { XL_ContextOptions options = xlDefaultContextOptions(); XL_Context* ctx = xlCreateContext(&options); if ( ctx == NULL ) { fprintf(stderr, "cannot create xl context\n"); return 1; } if ( !xlRunScriptString(ctx, "println(\"hello from embedded xlang\")\n") ) { fprintf(stderr, "%s\n", xlGetLastError(ctx)); xlDestroyContext(ctx); return 2; } printf("script exit code = %d\n", xlGetLastExitCode(ctx)); xlDestroyContext(ctx); return 0; }
宿主本身用第 37 章的 xl build 编译——内嵌 TCC 就是 C 编译器,--inc-path 指头文件根、--lib-path + --link 链 xl.def:
$ xl build host1.c -o host1.exe --inc-path D:\GIT\x-lang\demo6\release --lib-path D:\GIT\x-lang\demo6\release --link xl # 产出 1833472 B host1.exe $ cp host1.exe D:\GIT\x-lang\demo6\release\ && cd D:\GIT\x-lang\demo6\release && ./host1.exe hello from embedded xlang script exit code = 0
注意运行目录的选择:host1.exe 必须与 xl.dll 在一起(或 xl.dll 在 PATH)。不带 DLL 运行的下场与第 37 章坑 5 同型——静默退出码 127,一个字符都不打(实测,坑 1)。这是嵌入分发的第一条铁律:exe、xl.dll/xlc.dll 一起交付。
三部曲里每一步的语义:xlDefaultContextOptions() 返回一份填好默认值的配置结构体(含 size/version 字段做 ABI 版本自描述);xlCreateContext(&options) 按配置创建独立语言上下文——每个上下文持有独立的运行时引用,互不干扰(38.9 多线程靠这个);xlRunScriptString 把字符串当完整脚本跑完,返回非 0 成功;xlDestroyContext 连上下文带运行时引用一起归还。脚本的 println 直接打到宿主 stdout——脚本与宿主共享控制台。
38.3 错误处理与退出码
运行失败时 API 返回 0,细节在 xlGetLastError 里(实测输出):
$ ./host2.exe
run failed as expected, error = __xl_string.xl:1:13: [parse] 语法错误
after clear, error is empty = 1
hostAdd(20, 22) = 42
exit code from script main = 7
三个细节。一,逻辑文件名。字符串脚本没有真实文件,错误位置却显示 __xl_string.xl:1:13——这是字符串脚本的虚拟文件名(第 37 章 __xlvfs__ 机制的同一族),相对 import 也以它为锚。二,错误要清理。xlClearLastError 之后错误串回到空——连续跑多个脚本时,每个脚本跑完清一次,避免上一次的错误污染下一次判断。三,退出码透传。脚本 func main(...) as int return 7 之后 xlGetLastExitCode(ctx) 读回 7——与 CLI 的 run/build 同一条机制(第 37 章深入细节 3),宿主据此决定自己的返回码。
API 自身的参数错误也走同一通道,不会默默吞掉(实测):
$ ./host11.exe
xlSetWorkDir("") rc = 0, error = work dir path is empty
空路径被当场拒绝并说明原因——官方验证程序对七个配置 API 逐一做了这类负例断言。调 API 检查返回值、失败读 xlGetLastError,是嵌入代码的标准节奏。
38.4 上下文配置与环境注入
XL_ContextOptions 结构体六个字段(xl.h 原文):
typedef struct XL_ContextOptions { unsigned int size; // 结构体自描述大小(ABI 兼容锚点) unsigned int version; // XL_API_VERSION const char* workDir; // 工作目录:脚本相对路径/import/包搜索基准 const char* tempDir; // 临时目录:saveTemps 导出落点 const char* tccDllPath; // 保留字段:静态 libtcc 构建不使用 const char* tccLibPath; // TCC lib 根(默认走环境变量/内置兜底) int saveTemps; // 非 0 时导出 module.c 等可检查副本 } XL_ContextOptions;
三个 setter(xlSetWorkDir/xlSetTempDir/xlSetSaveTemps)可在创建后改。路径快照是这里最重要的机制(设计文档口径):xlCreateContext 记录创建时的宿主当前目录,把 workDir/tempDir 等相对路径按那一刻固化;setter 与 xlAddIncludePath/xlAddLibraryPath 同样按调用当下的当前目录固化快照。之后宿主自己 chdir 到别处,上下文的路径解析纹丝不动——多线程宿主的 cwd 安全性由此而来(38.9)。实测佐证:宿主在 release 目录跑 xlRunScriptFile(ctx, "tool.xl"),脚本就按 workDir 找——文件名拷错时立刻报 [parse] 无法读取源文件(坑 5)。
环境注入六函数对应第 37 章的 CLI 选项,逐一映射:xlAddIncludePath ↔ --inc-path,xlAddLibraryPath ↔ --lib-path,xlAddLinkLibrary ↔ --link(裸库名保留走 TCC 搜索规则,路径形态固化快照),xlLoadDependencyLibrary ↔ --loadlib(句柄由 context 持有,DestroyContext 释放),xlAddResourceFile ↔ --res(登记 Windows 资源配置),xlRegisterCFunction 是 DLL 独有——CLI 没有对应物,它是"C 函数进脚本"的门(38.6)。
38.5 脚本传参:xvalue
脚本侧 func main(...) 的 arg 是 array<var>(第 37 章 parse 树已见真身),宿主侧对应的 API 是 xlRunScriptStringArg / xlRunScriptFileArg——第三个参数就是一个 xvalue。构造 xvalue 用 xl.def 导出的 xvo 族函数(声明在 include/xl_runtime.h):
#include "include/xl.h" #include "include/xl_runtime.h" // xvalue 构造函数族 /* 构造 ["report.xl", "fast", "42"] 作为脚本命令行参数 */ xvalue arg = xvoCreateArrayArgs(3, xvoCreateText((ptr)"report.xl", 0, true), xvoCreateText((ptr)"fast", 0, true), xvoCreateText((ptr)"42", 0, true)); xlRunScriptStringArg(ctx, "func main(...)\n" " println(\"argc = \" + string(arg.length))\n" " println(\"arg[1] = \" + arg[1])\n" " println(\"arg[2] * 2 = \" + string(int(arg[2]) * 2))\n" "end\n", arg); /* 注意:arg 传入后不要再 xvoUnref —— 所有权已移交运行时(坑 2) */
$ ./host3.exe
argc = 3
arg[1] = fast
arg[2] * 2 = 84
脚本看到的与 CLI xl run app.xl -- fast 42 完全一致:arg[0] 是"程序名"位置的占位(这里放了 report.xl),arg[1] 起是真实参数。xvalue 是 xlang 运行时的通用值盒子(第 15 章 var 的 C 形态),xvoCreate* 族能造整数、浮点、文本、数组、字典——宿主与脚本之间的一切数据交换都装在这个盒子里。xvoCreateText((ptr)"...", 0, true) 的第二个参数传 0 表示按 C 字符串长度自动计算,第三个 true 表示复制一份(宿主缓冲区之后可以自由释放)。
所有权红线(实测划定):传进 xlRunScriptStringArg 的 xvalue 所有权随之移交上下文运行时——脚本跑完后由运行时回收。宿主画蛇添足地再 xvoUnref(arg),进程以退出码 127 异常收场(实测对照:去掉 unref 后 exit 0)。官方示例全部传 NULL 或不管后事,原因即此。
38.6 注册 C 函数供脚本调用
嵌入架构的灵魂是双向通话:宿主调脚本(三部曲),脚本也能调回宿主。xlRegisterCFunction 把宿主的 C 函数登记进上下文,脚本 extern 声明后像调普通函数一样调(host2.c 核心段,实测):
/* C 侧:普通 C 函数,xlang int 对应 long long */ static long long host_add(long long a, long long b) { return a + b; } /* 注册进上下文 */ xlRegisterCFunction(ctx, "host_add", (void*)host_add);
// 脚本侧:extern 声明后直调 extern "host_add" func hostAdd(int a, int b) as int println("hostAdd(20, 22) = " + string(hostAdd(20, 22)))
$ ./host2.exe
hostAdd(20, 22) = 42
这套机制与第 32 章的 extern 是同一 ABI 通道,差别只在符号来源:第 32 章从 DLL/def 链接,这里由宿主进程把函数指针直接注入 TCC 执行环境。设计文档特意强调了原则:C 函数不会自动污染脚本命名空间——注册只是"备料",脚本必须自己 extern 声明才能用,脚本环境的干净由脚本自己把关。类型规则不变:xlang int ↔ long long,float ↔ double,string ↔ const char*(UTF-8),返回值超数值范围的仍走第 33 章"返回即复制"纪律。
典型用法:脚本管业务规则,遇到性能热点或系统能力(读硬件、查数据库、调宿主内部状态)就 extern 回调宿主——脚本灵活层与 C 主干各司其职。
38.7 常驻模块与符号直调
xlRunScript* 是"一次性"的:脚本跑完,它的全局状态随之烟消云散。需要脚本长期驻留、保持状态、随时响应宿主调用时,走常驻模块四步:编译成模块 → 激活 → 取符号直调 → 卸载(host5.c 实测):
const char* source = "int requestCount = 40\n" "export \"counter_read\" func read() as c_int\n" " requestCount += 1\n" " return c_int(requestCount)\n" "end\n"; XL_Module* module = xlCompileScriptStringToModule(ctx, source); xlActivateModule(ctx, module, NULL); int (*read_counter)(void) = (int (*)(void))xlModuleGetSymbol(module, "counter_read"); printf("read() = %d\n", read_counter()); // 41 printf("read() = %d\n", read_counter()); // 42 printf("read() = %d\n", read_counter()); // 43 xlDeactivateModule(ctx, module); xlDestroyModule(module);
$ ./host5.exe
read() = 41
read() = 42
read() = 43
41→42→43 证明脚本的全局变量 requestCount 在三次宿主调用之间持续存活——脚本真正成了宿主进程里一个有状态的组件。规则要点:
取符号靠 export 别名。xlModuleGetSymbol 查的是 export "c_symbol" 的 C 侧名字(第 33 章),没 export 的函数对宿主不可见。取到的就是普通 C 函数指针,调用开销与调普通 C 无异。
生命周期成对且幂等。xlActivateModule / xlDeactivateModule 配对使用;重复激活、重复卸载都是幂等操作(官方验证程序连调两次 deactivate 再激活回来,状态从 41 重新计——激活时模块全局重新初始化)。xlDestroyModule 会对仍在活动状态的模块做兜底卸载,但显式配对是专业习惯。
一次性模块也有捷径。不需要长期驻留但想复用编译结果时,xlCompileScriptStringToModule + xlRunModuleMain 一把跑完(等价于 RunScript* 但模块形态),官方示例用它验证模块入口 xlang_main 的存在。模块族一共 10 个 API:5 个 Compile*ToModule(脚本字符串/文件、C 源字符串/文件/多文件)+ Activate/Deactivate/RunMain/GetSymbol/Destroy。
38.8 C 侧编译产物
链接 xlc.dll(或 xl.dll 未裁剪时)后,宿主自己就是编译器——xlCompileScriptFileToExe 一条调用把脚本编译成独立 exe(host4.c 实测):
$ ./host4.exe file script running 6 * 7 = 42 tool_from_c.exe emitted $ ls -la tool_from_c.exe && ./tool_from_c.exe -rwxr-xr-x … 920064 … tool_from_c.exe file script running 6 * 7 = 42
产物 920 KB 且独立可运行——与第 37 章 xl build 的输出同尺寸同行为,因为走的是同一条管线(DLL 版 exe 的 XRT 由编译版 VFS 里的单头文件 xrt.h 以 #define XRT_IMPLEMENTATION 方式提供,不再需要外部 xrt.o)。编译族共 6 个 API:脚本字符串/文件 → exe,C 源文件/多文件 → exe,C 源文件/多文件 → dll。用它们可以造自己的构建工具:配置生成器读模板产脚本、就地编译成 exe 交付——第 37 章 CLI 能做的一切,DLL 里都能编程驱动。
运行版裁剪宏 XL_RUNTIME_ONLY_API(实测):在 #include xl.h 之前定义它,6 个编译 API 从头文件中消失,误用当场链接失败——
$ cat host12.c #define XL_RUNTIME_ONLY_API #include "include/xl.h" …… return xlCompileScriptFileToExe(ctx, "tool.xl", "x.exe"); $ xl build host12.c -o host12.exe … --link xl tcc: tcc: error: unresolved reference to 'xlCompileScriptFileToExe'
这是编译期的契约保护:面向纯运行场景的宿主,用它把"不可能用到"的 API 从视野里清掉,链接 xl.dll 时万一写错调用也在构建期爆炸而不是运行期。
38.9 多线程宿主
线程模型一句话:每个线程创建自己的 context,互不共享。xlCreateContext 为每个上下文持有独立运行时引用(设计文档口径),线程之间不需要锁。实测 4 线程并行,各自建 context、注册同名 C 函数、跑各自脚本(host6.c 节选):
static DWORD WINAPI worker(LPVOID param) { int index = (int)(intptr_t)param; XL_ContextOptions options = xlDefaultContextOptions(); XL_Context* ctx = xlCreateContext(&options); // 线程私有 xlRegisterCFunction(ctx, "host_double", (void*)host_double); /* …snprintf 拼出带 index 的脚本… */ xlRunScriptString(ctx, source); xlDestroyContext(ctx); return 0; }
$ ./host9.exe # 4 线程,脚本 println 直打计算结果 thread 1 direct print: 4 thread 3 direct print: 8 thread 0 direct print: 2 thread 2 direct print: 6 all done
数值全部正确、无串扰、宿主进程 exit 0。官方验证程序 xl_dll_threaded_host.c 比这个更严苛,它验证三件事:一,4 线程各自完整走"建 context → 注册函数 → 跑脚本(含写文件标记)→ 毁 context"且宿主的当前目录全程不被上下文改动(路径快照机制的反向验证);二,脚本执行中(卡在 extern 回调里等事件)宿主另一线程销毁 context,放行后脚本侧优雅失败而不是崩溃;三,同样场景对活动模块销毁也成立。这三条共同划定了生产可用的并发边界:context 是并发单位,销毁是安全的,但不要让两个线程碰同一个 context。
本章实测发现的一处边界(如实记录):多线程下脚本里的 string() 显式转换返回空串——
# 脚本:int v = (index+1) * 2 然后 println("v = " + string(v)) thread 0 pure script: v = 2 ← 只有线程 0 正常 thread 1 pure script: v = ← string() 返回空串 thread 3 pure script: v = thread 2 pure script: v =
同一次运行里数值计算与 assert 全部正确(assert v == 期望值 全过),println 多参直打(println("...", v) 让 println 内部转换)也完全正常——问题只出在脚本显式调用 string() 转换函数这一条路径上,疑似该路径经共享缓冲、多 context 并发时相互覆写。单线程宿主与 CLI 场景都不受影响。绕行:多线程宿主的脚本里用 println 多参直打、或拼接前用 f-string 以外的途径(本例 println 直打已实测正常);需要字符串结果的先 assert 数值正确再另行处理。官方线程验证全部用 assert 数值断言、正好绕开了这条路径——这也是它从未暴露的原因。
38.10 xlc.dll 与 76 API 地图
xlc.h 的 33 个 API 是"编译器即库"的完整接口。实测从 C 侧驱动编译管线(host10.c):加源文件 → 吐生成 C → 吐 exe:
$ cat host10.c (节选) XLC_ContextOptions options = xlcDefaultContextOptions(); XLC_Context* ctx = xlcCreateContext(&options); xlcAddSourceFile(ctx, "tool.xl"); xlcEmitC(ctx, "tool_gen.c"); xlcEmitExe(ctx, "tool_xlc.exe"); $ ./host10.exe && ls -la tool_gen.c tool_xlc.exe && ./tool_xlc.exe tool_gen.c emitted tool_xlc.exe emitted -rw-r--r-- … 21083 … tool_gen.c -rwxr-xr-x … 920064 … tool_xlc.exe file script running 6 * 7 = 42
设计文档说得明白:xl.exe 只是这些 API 的命令行包装——"xl.exe is responsible for command-line parsing and maps those commands onto these APIs"。第 37 章的每个 CLI 命令在这里都有对应物:lex↔xlcEmitTokens、parse↔xlcEmitAst、check↔xlcCheck、ir↔xlcEmitIr、cgen↔xlcEmitC/EmitHeader、abi↔xlcEmitAbi、build↔xlcEmitExe/EmitSharedLibrary、run↔xlcRun。想在 IDE 里做"保存即检查",xlcCheck 一条调用带 JSON 输出;想做渐进式构建系统,xlcCompile + xlcRelocateModule 可以在进程内直接重定位执行而不落盘。
76 个 API 的全景地图(xl.h 43 + xlc.h 33,逐行清点):
| 分组 | 数量 | 成员 | 本节 |
|---|---|---|---|
| 生命周期 | 4 | xlRuntimeInit/xlRuntimeUnit(进程级计数)/xlCreateContext/xlDestroyContext | 38.2 |
| 配置 | 4 | xlDefaultContextOptions + SetWorkDir/SetTempDir/SetSaveTemps | 38.4 |
| 环境注入 | 6 | AddIncludePath/AddLibraryPath/AddLinkLibrary/AddResourceFile/LoadDependencyLibrary/RegisterCFunction | 38.4/38.6 |
| 脚本运行 | 4 | RunScriptString/File(±Arg 两变体) | 38.2/38.5 |
| C 源运行 | 3 | RunCSourceString/File/Files(内嵌 TCC 直跑 C) | — |
| 模块 | 10 | Compile*ToModule×5 + Activate/Deactivate/RunModuleMain/ModuleGetSymbol/DestroyModule | 38.7 |
| TCC 桥 | 3 | TccRegisterXrtEnvironment/TccRegisterContextEnvironment/TccImpdef | 深入 1 |
| 编译产物(宏控) | 6 | Compile*ToExe×4 + CompileCSource*ToSharedLibrary×2 | 38.8 |
| 错误 | 3 | xlGetLastError/xlClearLastError/xlGetLastExitCode | 38.3 |
| xlc 生命周期与配置 | 6 | xlcDefaultContextOptions/CreateContext/DestroyContext + 3 setter | 38.10 |
| xlc 环境注入 | 7 | 同 xl 六函数 + AddCSourceFile | 38.10 |
| xlc 源与编译 | 4 | AddSourceFile/AddSourceString/Compile/Check | 38.10 |
| xlc 阶段产物 | 7 | EmitTokens/Ast/Ir/C/DebugC/Header/Abi | 38.10 |
| xlc 最终产物与运行 | 5 | EmitExe/DebugExe/SharedLibrary/RelocateModule/Run | 38.10 |
| xlc 桥接与错误 | 4 | RuntimeContext + 错误三件套 | 38.10 |
记忆锚点:xl 前缀管运行,xlc 前缀管编译;Run* 是一次性,Module 族是常驻;Emit* 族与 CLI 命令一一对应。xlcRuntimeContext 是桥梁——拿到编译上下文内嵌的运行上下文指针,编译与运行两套 API 由此互通。
深入细节
每次 xlRunScript* 背后是一条八步流水线(设计文档原文):parse → import expansion → semantic check → C code generation → libtcc compile → relocate → 取 xlang_main → 调用 xlang_main(xvalue arg)。与 CLI run 完全同构,差别在运行时供应方式:CLI 的 exe 链接 xrt 实现,DLL 则把自身内部链接的 XRT 函数用 tcc_add_symbol 逐一注入 TCC 执行环境——所以 xl.dll 不需要也不携带 xrt.o。宿主注册的 C 函数走同一注入通道,这就是 xlRegisterCFunction 零样板的原因。独立 exe 输出反其道而行:XRT 实现由编译版 VFS 的单头文件 xrt.h 以 XRT_IMPLEMENTATION 宏展开提供,产物与宿主进程零瓜葛——这也是为什么"编译为独立 exe 时不能直接固化宿主进程函数指针"(设计文档原话),exe 里的 extern 符号必须靠普通 C/库链接解决。
TCC 与脚本 import 都需要文件路径,而进程当前目录是全局共享状态——多线程宿主的天然雷区。xl.dll 的解法是创建即快照:xlCreateContext 记录创建瞬间的宿主 cwd,workDir/tempDir/TCC 路径全部按它固化为绝对路径;xlAddIncludePath 等注入 API 也按各自调用瞬间固化;xlLoadDependencyLibrary 先把路径固化再用同一份快照加载。从此上下文的文件解析与进程 cwd 脱钩——官方 threaded_host 的验证项"宿主 cwd 全程不变"正是这条机制的压测。推论:想让 context 用某个相对路径,必须在创建 context 之前 chdir 到位,或者直接传绝对路径。
xl.dll 与 xlc.dll 都静态内嵌 libtcc,但携带的内置资源清单不同且边界由 tools/check_dll_api_boundary.ps1 静态守护:运行版清单只允许 TCC include 目录和 lib/libtcc1.a——XRT 靠 tcc_add_symbol 导入,清单里不能出现 xrt.h、xrt.o 或 exe 链接 def 资源;编译版清单必须多带 exe 链接所需 def 资源与单头 xrt.h,但同样不能带 xrt.o。外部同路径资源存在时优先覆盖内置兜底(第 37 章"外部文件系统 > 静态 VFS"规则的 DLL 版)。这套清单管理的目的是让 xl.dll 尽量小——只跑脚本的宿主不必背着整套编译资源;tccDllPath 字段虽保留在公开结构体里,静态 libtcc 构建下它不会被用来加载动态库(设计文档如实口径)。
⚠️ 常见错误
$ ./host1.exe # 目录里没有 xl.dll # 无任何输出,退出码 127 —— 静默死亡
原因:宿主经 def 链接的是导入库,xlCreateContext 等符号运行期才从 xl.dll 解析,DLL 缺席进程直接起不来。正确写法:交付清单 exe + xl.dll(或 xlc.dll)同目录,或把 DLL 目录加进 PATH;这与第 37 章 --loadlib 产物的分发铁律同型。
xlRunScriptStringArg(ctx, source, arg); xvoUnref(arg); // ← 画蛇添足 // 进程退出码 127(异常收场);去掉 unref 后 exit 0
原因:xvalue 传入后所有权移交上下文运行时,脚本结束由运行时回收;宿主再 unref 等于重复释放。正确写法:构造、传入、不再碰它——官方示例全部如此。
println("v = " + string(v)) // 多线程宿主下 v = 后面是空的
原因:本章实测边界——多 context 并发时 string() 转换路径返回空串(数值计算与 assert 不受影响)。正确写法:用 println 多参直打(println("v =", v) 已实测正常),或先 assert 数值再处理;单线程宿主与 CLI 无此问题。
#define XL_RUNTIME_ONLY_API #include "include/xl.h" // ……xlCompileScriptFileToExe(ctx, …) tcc: tcc: error: unresolved reference to 'xlCompileScriptFileToExe'
原因:宏把 6 个编译 API 从头文件裁掉,调用失去声明、链接期爆炸(这正是它的保护意义)。正确写法:需要编译产物就链接 xlc.dll 并去掉该宏;纯运行场景保留宏,让构建期替你抓错。
xlRunScriptFile(ctx, "tool.xl"); # workDir 下没有 tool.xl tool.xl:1:1: [parse] 无法读取源文件
原因:脚本相对路径按 context 工作目录(创建时快照)解析,不是按"宿主 exe 所在目录"也不是按"此刻 cwd"。正确写法:创建 context 前 chdir 到位、options.workDir 显式指定,或干脆传绝对路径;字符串脚本的相对 import 同理锚定 workDir。
本章小结
| 主题 | 要点 |
|---|---|
| 选型 | xl.dll 运行版(跑脚本)/ xlc.dll 编译版(多编译产物 API);XL_RUNTIME_ONLY_API 裁剪 6 个编译 API |
| 三部曲 | xlDefaultContextOptions → xlCreateContext → xlRunScriptString/File → xlDestroyContext;宿主用 xl build host.c --link xl 编译 |
| 错误 | API 返 0 读 xlGetLastError;字符串脚本逻辑名 __xl_string.xl;xlClearLastError 防污染;xlGetLastExitCode 透传脚本 main 返回值 |
| 配置 | workDir/tempDir/saveTemps;路径创建即快照,cwd 之后无关;环境注入六函数对应 CLI 四选项 + --res + 注册函数 |
| 传参 | xlRunScript*Arg + xvoCreateArrayArgs/xvoCreateText;脚本 arg 与 CLI 同形态;所有权移交勿 unref |
| 注册函数 | xlRegisterCFunction 注指针,脚本 extern 显式声明后直调;int↔long long 位宽纪律不变 |
| 常驻模块 | Compile*ToModule → Activate → ModuleGetSymbol(查 export 别名)→ 直调 → Deactivate → Destroy;全局状态跨调用保持,激活/卸载幂等 |
| 编译产物 | xlCompileScriptFileToExe 从 C 侧产 920KB 独立 exe;编译族 6 API;产物 XRT 来自单头 xrt.h |
| 多线程 | 每线程独立 context 免锁;销毁执行中的 context/module 安全;当前 string() 显式转换并发返回空串(println 直打绕行) |
| xlc.dll | 33 API 与 CLI 命令一一对应;xl.exe 是命令行包装;xlcRuntimeContext 桥接运行 API |
| API 总量 | xl.h 43 + xlc.h 33 = 76;xl 管运行、xlc 管编译、Module 族管常驻、Emit 族对 CLI |
复习题
- xl.dll 与 xlc.dll 的能力差在哪里?XL_RUNTIME_ONLY_API 宏的作用是什么,误用编译 API 会在哪个阶段暴露?
- 宿主三部曲是哪三步?编译宿主程序的完整命令行是什么,运行期目录里必须有什么文件?
- 字符串脚本报错里的 __xl_string.xl 是什么?xlClearLastError 为什么要在连续跑多个脚本时调用?
- xlRegisterCFunction 注册的函数脚本为什么不声明就不能用?xlang int 在 C 侧对应什么类型?
- 常驻模块与一次性 RunScript* 的本质差别是什么?xlModuleGetSymbol 查的是什么名字?多线程宿主的 context 使用纪律与当前 string() 边界分别是什么?
编程练习
写一个 C 宿主:启动后 xlRunScriptFile 加载 rules.xl(内含 export "rules_check" func check(int score) as c_int,score≥60 返回 1 否则 0),xlModuleGetSymbol 取出后循环读 5 个硬编码分数打印判定;然后把 rules.xl 的阈值改成 80,重新编译加载模块再跑一遍,观察判定变化——这就是"不改 C 代码热更业务规则"的最小闭环。
参考思路:38.7 常驻模块四步照抄;重载时先 Deactivate + Destroy 旧模块再 Compile 新的;宿主编译命令照 38.2。
C 侧注册 host_mul(x, y)(返回乘积)与一个 host_log(msg)(printf 打日志);脚本接收宿主经 xlRunScriptStringArg 传入的 xvalue 数组(两个数字文本),脚本里 int() 转换后调 host_mul 计算、调 host_log 汇报、println 输出结果。验证:arg 长度不为 2 时脚本 println 用法提示并以 return 3 结束,宿主用 xlGetLastExitCode 读到 3。
参考思路:38.5 的数组构造 + 38.6 的注册声明双线组合;string 入 C 是 const char* UTF-8;退出码读法见 38.3。
用 xlc.dll 写一个 mini-builder.c:xlcAddSourceFile 加入指定 .xl,依次 xlcCheck(JSON 输出到文件)、xlcEmitC、xlcEmitAbi(JSON)、xlcEmitExe;四步都成功打印 "build pipeline ok",任一步失败打印 xlcGetLastError 并以非零退出。用它构建第 37 章的 app.xl 类程序,最后运行产出的 exe 验证。
参考思路:38.10 的 host10.c 是骨架;xlcCheck 第三参 jsonOutput 传 1;链接 xlc.def 而非 xl.def。