第 40 章 / 共 51 章
卷五 · 扩展、运维与实战

嵌套 TCC 与插件系统:脚本里再开编译器

xsCreateTCC 给脚本一台预置好一切的编译器——动态规则、表达式求值、第三方插件,都在"运行期编译原生代码"这条路上。

本章导读

站位:ch27 管理路由的"升级形态"——当管理面开始编译代码,它就成了插件平台。func_test 有嵌套 TCC 与 reload 并发的真实用例(4 线程 × 12 请求),本章以它为验收标准。

40.1 引入

有一类需求无法用"写死的代码"满足:风控规则每周变、报表公式由用户定义、第三方想给你的平台写扩展。传统答案是把一门解释器嵌进来(Lua/JS)——代价是双运行时与边界开销。XServer 的答案顺理成章:宿主用 TCC 编译脚本,脚本也能用 TCC 编译代码——同一种语言、同一套符号、零边界。xsCreateTCC 预置了 include 路径与全部宿主符号,嵌套代码生下来就能调用你的 API。

40.2 插件编译五步

表达式插件(完整可运行模式)
/* 需求:用户给一段 C 表达式,服务端求值 */
typedef int (*ExprFn)(int);

static bool CompileExpr(const char* sExpr, ExprFn* ppFn)
{
    char sSource[1024];
    TCCState* pTcc;

    snprintf(sSource, sizeof(sSource),
        "int eval(int x) { return %s; }", sExpr);
    /* 1. 创建(预置 include/符号) */
    pTcc = xsCreateTCC();
    if ( pTcc == NULL ) return false;
    /* 2. 编译字符串 */
    if ( tcc_compile_string(pTcc, sSource) < 0 ) {
        xsDestroyTCC(pTcc);
        return false;             /* 语法错——回 400 */
    }
    /* 3. 重定位到内存 */
    if ( tcc_relocate(pTcc, TCC_RELOCATE_AUTO) < 0 ) {
        xsDestroyTCC(pTcc);
        return false;
    }
    /* 4. 取符号(须先声明函数指针类型——签名约定在你手里)*/
    *ppFn = (ExprFn)tcc_get_symbol(pTcc, "eval");
    /* 5. pTcc 由调用方持有:符号使用期间不得销毁 */
    return *ppFn != NULL;
}
/* 用毕:xsDestroyTCC(pTcc) —— 之后 ppFn 立即失效 */

刚才发生了什么:编译五步(创建→编译→重定位→取符号→销毁)的关键纪律是符号的生命周期绑在 TCCState 上——产物是 TCCState 拥有的内存,Destroy 即失效。所以插件缓存的正确结构是"缓存 TCCState",不是"缓存函数指针"。

40.3 错误回调与符号注入

  • 接管错误输出:tcc_set_error_func 把编译诊断引到你的收集器(默认打 stderr)——用户提交的表达式写错时,把诊断返回给他是产品质量的一部分
  • 符号注入:tcc_add_symbol 让插件调用你定义的宿主函数——暴露一个 plugin_api_* 前缀的小函数集,就是你的插件 SDK
  • 权限默认全开:xsCreateTCC 注入的是全套宿主符号——需要收窄时自行封装(只 add_symbol 白名单集),这是你设计插件沙箱边界的原料(完整讨论见 ch44)
  • VFS 可用:插件代码同样 include VFS 内的头;tcc_vfs_mount_memory 能在运行期注入"虚拟文件"——宿主装载脚本用的就是这招

40.4 与热重载并发

嵌套编译器与 reload 并发时的安全问题是:插件符号解析到的宿主函数,会不会在换代时被回收?func_test 的用例给出了答案与验收——4 线程并发跑嵌套编译 × 12 请求,同时反复 reload,全程零失败。机制上:

  • 插件的 TCCState 是你的代的状态——挂进注册表、随代销毁,就自动安全(ServiceUnit 里 DestroyTCC)
  • 旧代插件解析的符号指向旧代宿主函数——旧代 lease 保住它们到插件一起退役
  • 新代重新编译插件(或按需惰性重编)——与路由表重建同构

40.5 契约

  • 生命周期线:函数指针活在 TCCState 里;缓存的单位是 TCCState;Destroy 后指针即死。
  • 代际线:插件状态随代管理(Init 建/Unit 销)——与 reload 并发即安全。
  • 签名线:取符号后的函数指针类型由你声明——配套文档写清约定,错了就是未定义行为。

常见错误

缓存了函数指针,销毁了编译器
症状
插件缓存命中时跳转到已释放内存。
原因
产物内存归 TCCState 所有。缓存键值都应是"表达式 → TCCState",生命周期与缓存一致。
用错误的函数指针签名调用
症状
参数错位、栈损坏、随机崩溃。
原因
tcc_get_symbol 只给地址不验签名——类型约定全靠你。插件 SDK 里固定一个签名模板(如 int fn(int)),文档写死。

编程练习

基础表达式服务

实现 POST /expr {code:"x*2+1", x:5} → 11。

验收:正确求值;语法错误返回 400 + 诊断信息。
进阶编译缓存

加"表达式 → TCCState"缓存(LRU 上限 100),统计命中。

验收:同表达式二次调用不再编译;缓存满时最久未用的被销毁。
挑战并发 + reload 风暴

复刻 func_test 用例:4 线程并发求值 × 12 请求,期间换代 5 次。

验收:零失败;旧代插件的 Unit 里能看到 DestroyTCC 被调。

小结

要点结论
入口xsCreateTCC:预置 include + 全套宿主符号
五步创建→编译→重定位→取符号→(用毕)销毁
缓存单位TCCState(不是函数指针)
并发安全插件状态随代管理;func_test 有并发验收用例
边界权限默认全开;收窄靠自封装符号白名单(见 ch44)
xrt 延伸阅读