1. 从一个“草台班子”起家的实验项目说起最近在翻自己早年的代码仓库时看到那个用 Object PascalDelphi写的解释器项目又想起当年在技术群里和人争论“动态语言到底能不能编译到机器码”的夜晚。这个项目就是 Fun 语言——一门从 JSON 文法语法树出发最终落到原生机器码执行的解释型脚本语言。严格说它是个解释器但执行路径已经触碰到了机器码的边界。说到底Fun 语言是一个用 Pascal 写就的、以 JSON 为“外衣”、以 AST 为“骨架”、以机器码为“落点”的脚本语言实现。它解决的问题很简单当你想在桌面应用尤其是 Delphi 写的传统 Windows 程序里嵌入一段可热更新的业务逻辑又不想背上一整个虚拟机或者浏览器引擎的运行时到底用什么方案Lua 需要绑一堆 C APIJavaScript 引擎动辄几十兆二进制随随便便引入就可能让安装包多几个 MB还会带来 GC 停顿和线程模型上的烦恼。Fun 语言的选择是解析 JSON 描述的逻辑树把它转成内部指令再编译成机器码去执行。放到今天看这像是一个“低配版 JIT”的实验但思路至今仍有参考价值。这个内容适合谁读如果你正在设计自己的脚本引擎想在 C/C/Delphi 项目里塞一段可解释执行的 DSL或者单纯对“JSON 如何变成可运行的机器码”这个过程感到好奇这篇博文就是写给你的。我会尽量把每个环节的取舍逻辑、踩过的坑、还有关键代码都摆出来不搞玄学只讲实打实能复现的东西。2. 整体设计思路为什么“JSON 语法”而不是自创词法2.1 省掉一个词法分析的野路子大多数语言解释器第一步是词法分析把源码字符串切分成 token。Fun 语言的想法比较“偷懒”——干脆不定义自己的文本语法直接用 JSON 来描述程序。程序员写的是{ op: let, name: x, value: { op: add, left: {op: num, value: 2}, right: {op: num, value: 3} } }而不是let x 2 3这个设计在当时被群友吐槽“反人类”“不像一种语言”今天回头看它其实有一个很实际的好处不用维护词法分析器和语法分析器。JSON 本质是层层嵌套的键值对天然就是一棵树解析 JSON 的过程等同于构建 AST。你随便从网上下一个 JSON 解析器Delphi 里我用的是 mORMot 自带的 JSON 引擎后来换成了超级对象 pass 的 SuperObject就能完成从文本到内存结构的转换。而后续的语义分析、指令生成全部可以站在一棵已经结构化好的树上进行省下的不只是开发量还有大量“括号不匹配”之类的用户错误。还有一个容易被忽略的优势JSON 生成非常容易。宿主程序比如一个 Delphi 写的记分软件可以通过代码动态拼 JSON 字符串没有任何转义歧义。程序员的编辑器只需要 JSON 语法高亮不需要专门为 Fun 语言写 Syntax Highlighting 插件。这让我想到很多企业内部的规则引擎比如银行里的“决策流”本质上也是在用 XML/JSON 描述逻辑——稳定、安全、容易持久化。2.2 从 JSON 节点到内部指令AST 与指令集的分层JSON 解析出来以后Fun 的“编译器前端”会做一次遍历把 JSON 节点转换为内部 AST 节点TOpNode 体系。这一步不是简单的映射实际干了两件事类型归一化JSON 里的数字可能是整数或浮点字符串和布尔也需要区分开。Fun 内部定义了一套统一的 Variant 数据结构在编译早期就把所有常量统一标定为标准 FunValue。语义检查比如add节点要求左右子树都存在if节点要求有cond、then、else三个子节点。这些检查在这层完成而不是等执行到一半再报错。为什么中间要加一层 AST 而不是直接拿 JSON 树去解释执行因为 JSON 树的节点类型是“字典型的”字段名是字符串查找子节点每次都要走哈希AST 则是把节点类型变成枚举把字段变成索引/引用访问起来快得多。尤其当你执行一个循环 10 万次的逻辑时每次迭代都在 JSON 树里做字符串查找性能会非常难看。做过编译器的朋友都知道那句老话编译器的前端是艺术品中端是科学后端是工程。Fun 语言把“前端”简化成 JSON 解析“中端”做成一棵轻量 AST“后端”则是一个三板斧式的寄存器虚拟机加密钥机器码发射器。层次分得越清后面的扩展就越容易。3. 核心语法语义与数据结构设计3.1 表达式的表示一切都是“节点”Fun 语言是个表达式导向的语言没有传统语句和表达式的严格区分。每一个 JSON 对象都是一个“表达式表达式”只要它含有op字段。举几个常见的 op 类型op 名称作用示例 JSONnum数字常量{op: num, value: 42}str字符串常量{op: str, value: hello}var变量引用{op: var, name: x}add/sub/mul/div四则运算{op: add, left: ..., right: ...}if条件分支{op: if, cond: ..., then: ..., else: ...}while循环{op: while, cond: ..., body: ...}call调用函数{op: call, fn: print, args: [...]}每个 op 在内部都对应一个 TFunOp 类类里保存操作数引用。这样表达的最大好处是中间代码生成的遍历逻辑极其统一——不管什么节点遍历的时候先递归访问子节点再在当前节点生成对应的指令码。3.2 变量的作用域与闭包处理Fun 语言的变量作用域分两级全局和环境上下文。全局就是宿主程序预设的变量池比如把 Delphi 窗体上的某个 Edit 控件内容暴露为host_edit_text环境上下文则是 Fun 脚本内部用let创建的局部变量。这里提一个实际工程中很重要的点Delphi/Pascal 下闭包其实很费劲。早期 Fun 语言不支持闭包函数只能引用全局变量和自己的参数后面为了功能完整性我在 TFunContext 里加了一个upvalue数组——捕获父级环境里变量在栈上的槽位号。写起来类似这样type TFunClosure record FuncId: Integer; UpvalueIds: array of Integer; // 栈槽索引 end;这个闭包并不支持任意层级的变量捕获只支持一层向上引用本质上是“半闭包”。实际使用中业务脚本大都是几个简单函数组合不会出现函数工厂套三层的情况所以这个简化是被需求驱动的——做解释器最忌讳一开始就把功能面铺太大要想清楚 80% 场景是什么。3.3 值类型从 JSON 数字到 FunValue 的统一封装JSON 只有数字、字符串、布尔、数组、对象、null 六种类型Fun 语言在此基础上增加了一个“函数对象”类型和“原生指针”类型。内部用 Pascal 里的记录record实现了一套带标签的联合体type TFunValueType (vtInt, vtFloat, vtStr, vtBool, vtArray, vtObj, vtFunc, vtPtr); TFunValue record case ValueType: TFunValueType of vtInt: AsInt: Int64; vtFloat: AsFloat: Double; vtStr: AsStr: string; vtBool: AsBool: Boolean; vtArray: AsArray: TFunArrayRef; vtObj: AsObj: TFunObjectRef; vtFunc: AsFunc: TFunClosure; vtPtr: AsPtr: Pointer; end;为什么要用 Pascal 的变体记录而不是用一个类层次因为值拷贝频繁变体记录能直接压在栈上省去引用计数的开销。需要 GC 的对象数组、对象再用引用计数指针包一层。这一套做法在后来的实际压力测试里表现不错10 万次循环求和耗时约 28 毫秒2.8 GHz 的老 i5 处理器上虽然没法跟原生 C 代码比但作为解释器已经完全够用。4. 从 AST 到机器码执行引擎的工作原理4.1 虚拟机指令集轻量、线性、易翻译很多脚本语言解释器会先把 AST 编译成字节码再用一个大循环执行字节码。Fun 语言最初也走的是这条路线指令格式就是操作码 几个操作数槽位。比如加法指令type TInstruction packed record Op: Byte; // 操作码如 1ADD, 2SUB... Res: Byte; // 结果存放槽位 A, B: Integer; // 两个源操作数在栈上的位置 end;栈式虚拟机用 push/pop 传递参数寄存器式虚拟机直接指定操作数下标。Fun 语言采用的是混合式表达式求值用栈局部变量和函数参数用寄存器槽本质还是栈帧里的偏移。这样做的理由是表达式嵌套复杂用栈式求值最顺手而函数调用的实参、局部变量使用固定偏移可以避免反复 push/pop 带来的指令膨胀。4.2 机器码发射的“轻 JIT”策略严格说Fun 语言并没有像 V8 那样的完整 JITJust-In-Time编译器它用的是一种“模板化编译”技巧。当一段代码比如一个函数被反复调用超过一定阈值解释器会把该函数的指令数组翻译成一段原生 x86 机器码存放在可执行内存页中然后直接用函数指针调用。以add指令为例它会对应一小段 x86 机器码在 32 位模式下; 假设值放在栈帧偏移量中 mov eax, [ebp - 8] ; 加载左操作数 add eax, [ebp - 12] ; 加上右操作数 mov [ebp - 16], eax ; 存回结果槽每个指令一个模板JIT 编译就是把这些模板按顺序拼接到内存里同时修正跳转偏移。这个过程我形容为“拼乐高”每条机器码指令是一个标准积木块跳转指令则是特殊的带弯管积木拼接的时候把目标地址算好。相比传统字节码解释这种方式在循环密集的数值运算上能获得 35 倍性能提升但提升主要来自省去了指令分派循环的开销并不能像真正的优化编译器那样做寄存器分配、常量折叠。值得一提的坑**Windows 的数据执行保护DEP**默认不允许从堆上分配的内存直接执行。解决方法是使用VirtualAlloc并带上PAGE_EXECUTE_READWRITE标志function AllocExecutableMemory(Size: Integer): Pointer; begin Result : VirtualAlloc(nil, Size, MEM_COMMIT, PAGE_EXECUTE_READWRITE); end;用这个函数分配的内存才能被 CPU 当作代码执行否则直接访问会触发访问冲突异常。4.3 为什么选择 x86 模板而不是汇编编译器有人可能会问既然都到机器码这一步了为什么不在 Pascal 里内嵌汇编来生成整个函数那样性能不是更好吗答案很实际可维护性差且 x64 下内嵌汇编支持弱。用指令模板拼接的方式代码和数据分离清晰每增加一个 opcode 只需要增加一个模板函数测试也方便。后来 Fun 语言做了 64 位移植模板从 32 位 x86 换到 x64 只需要重写这些模板函数AST 和 JSON 前端完全不用动。这个决策给后来的扩展留了很大空间。偶尔我还会在代码里加入 SSE 指令模板做向量加法比如给数组求和这种场景加个小优化。能做到这一点正是因为设计之初就把“指令生成”抽象成“模板列表”而不是硬编码的一整段汇编函数。5. 实操在 Delphi 工程里集成 Fun 语言的完整步骤5.1 代码结构概览整个 Fun 语言项目分为四个单元所有代码大约 5800 行 Pascal不含 JSON 解析库单元名职责核心类FunValue.pas值类型定义、引用计数、算术运算TFunValue, TFunArray, TFunObjectFunParser.pasJSON 解析、AST 构建、语义检查TFunNode, TFunParserFunVM.pas字节码执行、函数调用、异常处理TFunVM, TFunEnvFunJIT.pas机器码生成、可执行内存管理TFunJIT, TFunTemplate5.2 最小集成案例假设我想在 Delphi 窗体程序里计算1 2 * 3只需要这样uses FunVM, FunParser; procedure TForm1.Button1Click(Sender: TObject); var Parser: TFunParser; VM: TFunVM; Root: TFunNode; Result: TFunValue; begin Parser : TFunParser.Create; try Root : Parser.ParseString( {op: add, left: {op: num, value: 1}, right: {op: mul, left: {op: num, value: 2}, right: {op: num, value: 3}}} ); finally Parser.Free; end; VM : TFunVM.Create; try Result : VM.Execute(Root); ShowMessage(Format(结果是: %d, [Result.AsInt])); finally VM.Free; end; end;这里有个关键点Parser 负责解析 JSON 并返回已语义检查的根节点VM 负责执行。两者生命周期独立意味着你可以在程序启动时把所有脚本解析好、缓存起来运行时只需要拿根节点给 VM 执行即可。这样热更新的成本非常低——只需要替换 JSON 文本重新解析一次就行。5.3 注册本地函数Pascal 代码成 Fun 的后端Fun 语言最实用的功能之一是可以调用 Delphi 侧的函数。这需要一个注册表VM.RegisterFunction(host_show_msg, procedure(const Args: array of TFunValue; var Ret: TFunValue) begin ShowMessage(Args[0].AsStr); Ret : TFunValue.MakeNull; end);对应 Fun 脚本里就是{op: call, fn: host_show_msg, args: [{op: str, value: Hello from Fun}]}在注册回调函数的时候要注意回调发生在请求上下文中。如果脚本跟 UI 线程跑在一起弹出 ShowMessage 会导致模态循环嵌套极易引发重入问题。我在实际项目里处理过这种坑最好的策略是脚本回调只负责往队列里塞一个“消息对象”UI 线程再统一处理。5.4 宿主应用里的 JSON Schema 校验既然是 JSON 写逻辑难免会有手滑写错字段名的情况。我的建议是在脚本还处于开发期时启用严格的 JSON Schema 校验上线后再关闭以提升性能。我用了一个轻量方案写一个 JSON Schema 子集检查器只校验必需字段和类型不处理复杂的条件逻辑。对于错误信息错误消息要尽量带上路径Error at path [statements, 2, cond]: expected object but found number这种可读错误消息在整个开发周期里节省的时间比写这个检查器本身还要多值得投入。6. 热词背后的真实需求JSON、机器码与脚本边界看到项目标题的人很容易被“JSON”“机器码”“脚本语言”这几个词组合出的反差感吸引。实际上这几个词对应了几个不同的使用场景我结合现在社区里的真实热词展开聊聊。6.1 JSON 不只是数据交换它还能当“程序”大多数人对 JSON 的认知停留在“配置格式”“接口返回数据”这导致一看到“JSON 写的语言”就觉得不伦不类。但换个角度看JSON 对于非专业使用者来说反而非常友好键值对、嵌套、数组这些概念和大脑里的思维导图几乎是同构的。而且 JSON 天然适合持久化——你写好的脚本可以直接存进数据库修改时只需要做文本替换不需要理解 AST 的二进制表示。许多人在搜“json转换”“json数组”“json文件”说明大家在实际项目里已经积累了不少 JSON 处理经验。如果能把业务规则从“if-else 硬编码”改成“JSON 描述规则”业务人员也能参与规则维护对系统灵活性的提升是质变级的。Fun 语言这类技术正是这种思路的极端版本——用 JSON 直接描述程序流程而不是让 JSON 去描述一些中间规则配置。6.2 机器码不是“改机器码”是程序执行的落点热词里有非常多“机器码修改”“机器码修改工具”“怎么让自己的服务器锁住别人的机器码”之类的搜索这些基本是游戏防破解、软件授权方向的需求和编译器生成的机器码完全不是一回事。Fun 语言里的“机器码”指的是 CPU 直接执行的原生指令是程序执行的最终形态。在真实桌面软件环境里“从 JSON 到机器码”这条路线的价值在于你不需要带着一个巨大的运行时解释器也能获得接近原生的性能。Fun 语言的 JIT 模板虽然在优化深度上很浅但工程复杂度可控嵌入成本低特别适合“脚本逻辑不复杂但调用频繁”的场景。如果一篇博文能让读者分清“机器码生成”和“机器码修改”的差异也就完成了一部分科普使命。6.3 脚本语言的边界为什么不直接用 Lua 或 Python这是评论区被问得最多的问题。正常的技术选型建议是如果项目允许引入 C 扩展库直接用 LuaJIT 或者嵌个 Python 解释器都是好选择但如果你的宿主程序是纯 Delphi/Pascal 体系又不想折腾复杂的构建链自研一个轻量 DSL 并非天方夜谭。我在做 Fun 语言时最核心的取舍是我为 5% 的复杂逻辑额外付出了解释器开发成本为剩下 95% 的简单逻辑省下了大量内存占用和打包体积。一个 Delphi 程序集成了 Fun 语言后二进制体积只增加约 300 KB这个账在很多 Windows 老项目的现代化改造中非常有吸引力。如果你正面临类似的“脚本引擎选型”问题我建议先画一张表逻辑复杂度分布、热更新频率、运行性能要求、宿主语言绑定难度。Fun 语言这条路适合“逻辑简单但更新频繁、宿主语言绑定深、性能要求中等”的组合。如果你需要跑复杂的算法还是乖乖上专业脚本引擎。7. 性能与稳定性实测数据、瓶颈与优化方向7.1 基准测试JSON 解析到执行全链路我用一台上古时代的 i5-45903.3 GHzDDR3 内存跑了三个典型任务与原生 Delphi 代码做了对照任务Fun 解释执行Fun JIT 执行Delphi 原生代码说明循环 100 万次空转612 ms143 ms2 ms循环自身开销为主循环 10 万次整数累加28 ms9 ms0.8 msadd 指令密集递归计算 fib(25)1162 ms487 ms52 ms函数调用开销显著JIT 模板化编译对“解释器循环分派开销”的优化立竿见影但远离原生性能。主要瓶颈是模板 JIT 没有做寄存器分配每个加法都会把操作数从栈帧搬到 CPU 寄存器算完再搬回去访存指令比原生多了一个量级。此外Fun 的值类型是带标签联合体每次算术操作前都要检查类型标签、做类型转换这又增加了分支判断。7.2 内存管理与引用计数Pascal 下的“免责声明”Fun 语言的值类型采用引用计数管理数组和对象内部用InterlockedIncrement/InterlockedDecrement维护计数。这个设计带来了极大的便利——闭包和函数返回复杂对象时不需要深度拷贝但也引入了循环引用泄漏的风险。有一个真实的 bug 让我记得很深{op: array_push, arr: {op: var, name: a}, value: {op: var, name: a}}这个操作把数组自身 push 进自己形成循环引用导致整个环境退出时内存无法释放。排查了两天最后在 Delphi 的TStringList调试窗口看到引用计数异常才定位。最终方案是在TFunValue销毁时多做了一个“深度破解循环”的辅助函数虽然不能覆盖所有循环场景但足够应对业务里的简单自引用。7.3 插桩与性能分析让 JIT 缓存在实际中用好JIT 不是所有函数都值得编译。Fun 语言用了一个“调用计数阈值”策略函数每被调用一次计数加一超过 100 次才触发机器码编译。因为模板编译虽然轻量但在函数体较大时也会有几毫秒的停顿如果只调用一次就编译反而得不偿失。这个策略在实际测试中帮我避开了“小函数高频调用”和“大函数低频调用”两种极端场景的性能陷阱。如果你要自己做一个带 JIT 的实验语言我强烈建议在你的调试器里能显示每个函数当前的执行模式和调用次数。否则你会不知道怎么优化。8. Fun 语言开发中的常见问题与踩坑日记8.1 JSON 解析中的数字精度和特殊字符处理JSON 对数字的定义是 IEEE 754 双精度浮点但业务场景里经常会出现大整数比如订单号超过 16 位。在 Fun 语言里如果 JSON 解析器把大整数当成浮点解析后续做比较时会因为精度溢出产生不可思议的 bug。解决方法是解析 JSON 时如果数字节点不包含小数点且绝对值小于 2^53优先按整数保存只有超出这个范围才转成浮点或字符串。字符串转义也是个大坑。JSON 转义规则和 Pascal 的字符串转义并不完全一致比如\uXXXX这样一个 Unicode 转义序列直接StringReplace朴素处理会漏掉代理对surrogate pair。正确做法是解析阶段就用一个统一的递归下降 JSON 解析器而不是正则替换。如果你在集成其他语言写好的 JSON 库记得先验证它对 emoji 和生僻汉字的支持——这是最容易翻车的地方。8.2 可执行内存的分配粒度与对齐踩坑用VirtualAlloc分配可执行内存时如果不注意对齐生成出来的机器码很容易在跳转时崩溃。x86 体系下大部分指令不要求严格对齐但跳转指令的目标地址如果出现在奇数地址某些 CPU 分支预测会有额外惩罚。更严重的问题其实是分配的一页内存多大页大小通常是 4 KB如果你的 JIT 函数体超过了 4 KB就必须做跨页拼接跨越页面时需要额外处理。我在早期版本遇到过“函数正常跑几百次后随机崩溃”的问题查到最后是 JIT 编译时对指令长度估算不足生成代码覆盖到了下一个页面的开头。解决办法是做一个慢速指令长度计算器确保每个函数体的机器码长度估算绝对不小于真实长度再分配额外的 64 字节填充。8.3 调试手段从“万能日志”到“指令跟踪器”解释器最容易出的问题不是“结果错”而是“静默内存破坏”——比如数组越界写坏了环境结构。Fun 语言初期我只靠日志打印每个函数入口/出口遇到“跑一大段逻辑结果不对”的 bug 时日志长度能到几百 MB。后来写了一个“指令跟踪器”模式把每条指令的 op、操作数槽位、执行前和执行后的值栈快照全部记录到文件。这个跟踪器的价值在于它可以自动做两次执行差分。比如同一段逻辑解释器模式跑一遍JIT 模式跑一遍把两次执行轨迹做 diff立刻就能定位是解释器 bug 还是 JIT 模板 bug。这个方法虽然听着笨但效率极高是做过编译器的人才会用的“土办法”。8.4 线程安全多个 VM 实例共享 AST 的坑有些宿主程序希望同时跑多个 Fun 脚本比如每个游戏对象一个脚本如果能共享同一份解析后的 AST 会节省非常多内存。实际操作中AST 本身是只读的所有执行状态都在 VM 实例里因此“共享 AST、独立 VM”在理论上完全成立。但前提是 AST 里的字符串和数字常量不能在求值过程中被修改。Fun 语言早期在num节点求值时会直接修改节点缓存的值造成多 VM 并发修改同一个节点最终导致数据错乱。修复其实非常简单求值过程中所有中间结果都写入 VM 自己的栈帧绝不写回 AST 节点。这个约束后来写进了注释但还是值得给每个做解释器的人提醒一句AST 是程序不是数据不要在执行时改程序。9. 这套设计在今天还剩多少意义前阵子有个做嵌入式方向的朋友来问能不能把 Fun 语言移植到 MCU 上。我想了想直接生成机器码的传统思路在现代硬件上已经逐渐被更优雅的方案取代但“用 JSON/DSL 描述业务逻辑宿主程序负责执行环境”这个分层思想在物联网设备、桌面工具、游戏模组系统里依然有很强的生命力。Fun 语言作为一次技术实验倒是把“从数据描述到直接执行”的整个路径完整走了一遍对理解编译器、虚拟机和计算机体系结构之间的关系帮助很大。如果你也想玩类似的技术实验我的建议是不要从零写 JSON 解析器先用现成的库搭起整条链路把精力放在“指令生成”和“API 设计”上性能优化永远在后面先把功能跑通再考虑 JIT。编译器领域最大的错觉就是“我能在第一版写出高效且正确的代码”现实是第一版只需要能输出正确结果就行了。折腾一门语言本身就是一个拆解思维的过程。每次有人问我“用 Pascal 写解释器是不是很过时”我都会拿 Fun 语言举例子语言只是工具真正决定一个项目价值的是它能不能以最小的成本解决实际问题。Fun 语言用 JSON 代替语法、用模板拼接代替完整编译优化每一步都是用工程权衡换来的“刚刚好”。最后分享一个开发中总结的小心得如果将来要在自己的项目里嵌脚本不要让脚本语言“太强大”。适当的限制、明确的能力边界反而会让它成为一个健康、稳定、可维护的子系统。Fun 语言刻意不支持文件操作、网络操作和动态加载外部模块这带来的安全性收益远超那点“功能完备性”上的损失。
