从JSON到机器码:Fun语言编译实现与JIT技术解析
发布时间:2026/9/23 7:12:35 作者:尧图编辑部 阅读量:1,286

2. 核心细节解析JSON怎么变成一棵树树又怎么变成机器码说句实话很多第一次接触 Fun 的读者看到从 JSON 到机器码这个描述时第一反应都是为什么不直接从文本解析或者JSON 这种数据格式怎么可能描述逻辑。我当时的第一反应也一样。但真正把代码读进去之后我才意识到这个设计的高明之处——Fun 语言本质上把语言的语法和语言的语义彻底拆开了。2.1 JSON 输入层把词法分析这一整座大山直接砍掉传统语言实现的第一道坎是词法分析。你要处理缩进、换行、注释、字符串转义要写正则表达式或手写状态机要处理各种诡异的 Unicode 字符。这个阶段看起来简单但坑极多尤其是错误处理。我做过一个小的 DSL 解释器词法分析器花的时间比后面的语法分析还多。Fun 语言的解决方案是直接把 JSON 当作 Token 流。JSON 本身就是一个结构化的、严格校验过的 Token 序列。键名就是运算符或函数名数组就是参数列表对象就是作用域块。这样词法分析的代码量直接归零。我举个例子传统语言写一个变量赋值可能长这样x : 42;而 Fun 语言对应的 JSON 是{ set: { name: x, value: 42 } }等等这里面的键名是有讲究的。set是操作码name是变量名value是值。这种设计有一个天然的好处每一条指令的结构都是自描述的。你不需要查找符号表就知道这个指令是干嘛的。从实现角度来看解析 JSON 要比解析文本简单得多。Pascal 社区里现成的 JSON 库虽然不多但功能可靠的就那么几个选择一个能用的就行。而且 JSON 的语法规范极其严格任何不符合格式的输入在进入语义分析之前就被拦截了。2.2 AST 构建JSON 节点到语法树的直接映射JSON 解析出来之后实际上已经是一个天然的树形结构了。对象嵌套数组嵌套对象这就是一棵树。问题在于这棵树还不是真正的 AST抽象语法树它还带着 JSON 的口音。我举个例子下面这段 JSON[ { print: { value: { add: [2, 3] } } }, { set: { name: x, value: { mul: [4, 5] } } } ]第一个元素的键是print值是一个对象对象里又嵌套了一层value。第二个元素是set。这个结构看起来很像 AST但它的操作码和操作数是混在一起的。真正的 AST 需要区分哪些节点是表达式节点哪些是语句节点哪些是控制流节点。所以 Fun 语言在 JSON 解析和语义分析之间通常还会加一层 AST 转换。这一层做的事情是节点类型标注将 JSON 的键名映射为 AST 节点的类型expr/stmt/ctrl等作用域绑定在解析阶段把变量引用和变量声明关联起来常量折叠预检尽早发现明显错误比如对布尔值做算术运算这一步其实挺关键的。如果不做 AST 转换直接拿着 JSON 树去生成 IR中间表示语义分析会变得非常痛苦因为你需要在大括号和方括号之间来回穿梭而不是遍历一个干净的树结构。注意我这里说通常还会加一层是因为 Fun 语言的实现方式存在一定差异。有的版本可能直接在 JSON 树上做标记有的版本会生成独立的 AST 结构。但从软件工程的角度看独立的 AST 层更利于后续优化和调试。2.3 从 AST 到机器码的三段式流水线有了干净的 AST 之后后续处理就是经典的三段式流水线了。虽然 Fun 语言名字叫脚本语言但它的执行路径一点都不脚本。第一步语义分析这里做的事情和传统编译器差不多。遍历 AST检查变量是否已声明或者是否需要隐式声明函数调用的参数个数是否匹配操作符的类型是否合法比如add: [1, hello]应该报错是否存在未使用的变量可选检查项第二步生成中间表示IR这一步是关键。Fun 语言没有选择直接生成机器码而是先转成一种线性的中间表示——类似三地址码three-address code。我举个例子// 伪代码形式的 IR t1 ADD 2, 3 t2 PRINT t1 t3 MUL 4, 5 t4 SET x, t3为什么要先转 IR两个原因。第一IR 是平台无关的。你可以先针对 x86-64 生成机器码以后想支持 ARM 只需要加一个后端IR 不用动。第二优化更容易。在三地址码上做常量传播和死代码消除比在树形结构上做容易得多。第三步从 IR 到机器码这一步是最硬核的部分。IR 指令要映射到实际的 CPU 指令你得处理寄存器分配x86-64 只有 16 个通用寄存器变量数量一多就得往栈上存指令选择ADD可能映射为addl或者leal取决于操作数的位置栈帧布局函数调用的参数、局部变量、返回地址的空间分配Pascal 本身是一门编译型语言在生成机器码这件事上有天然优势——因为 Pascal 编译器如 Free Pascal本身就自带一套非常成熟的代码生成器Fun 语言完全可以复用这个基础设施避免从头写汇编生成器。2.4 机器码的生成细节寄存器分配与栈帧布局这部分是 Fun 语言实现里真正值钱的地方。我之前看过一个类似的小型编译器项目它在生成机器码的时候只用了土办法——所有变量一律放进栈帧用完再从栈里取根本不碰寄存器分配算法。结果就是生成的二进制体积巨大且执行效率极低。Fun 语言的实现思路就好得多。它采用的是线性扫描寄存器分配Linear Scan Register Allocation这个算法在简化版的 LLVM 里也能找到类似实现。核心思路是先做一次活跃变量分析Liveness Analysis确定每个变量在哪个区间是活跃的然后顺序扫描给每个活跃区间分配一个寄存器如果寄存器不够用就把超出部分溢出到栈帧我给你画个简单的场景。假设一个函数里有 a、b、c、d 四个变量但函数体很短四个变量的生命周期互不重叠那它们完全可以共享两个寄存器。栈帧布局这部分同样有讲究。x86-64 的栈帧通常长这样高地址 ------------------ | 参数区 | ------------------ | 返回地址 | ------------------ | 保存的帧指针 | ------------------ | 局部变量区 | - RSP 指向这里 ------------------ 低地址Fun 语言在生成机器码时需要保证每个函数调用都能正确建立和销毁栈帧。稍有差错程序轻则输出错误结果重则直接段错误segmentation fault。实操心得每次生成完机器码之后建议在二进制里嵌入一个 debug section把每条机器码指令对应的 IR 指令编号记下来。以后排查宕机原因时能直接定位到是 IR 的哪一步出了问题不然你得反编译二进制那可真是噩梦。3. 实操与运行模型Fun 语言到底怎么跑起来读到这里你可能已经理解了 Fun 语言从 JSON 到机器码的实现路径的大体轮廓。但光有静态分析还不够得从实际运行的角度来验证——也就是说当你真的把一个 .json 脚本丢进一个Fun 解释器到屏幕上出现输出这中间到底经历了什么下面我会拆出几个关键环节从设计者的角度聊一聊。3.1 不建虚拟机而是直接生成原生代码图的什么很多脚本语言比如 Lua、Python 的早期版本都会选择先编译到字节码再在虚拟机上解释执行。这种方式实现起来快、调试方便但性能天花板低。Fun 语言直接生成机器码等于舍掉了中间的解释层这个缓冲也意味着它省掉了一整套字节码调度循环——这个循环在每次执行指令时都要付出取指、解码、分发的开销。我举个例子。在 Lua 里执行一个加法操作哪怕你的变量已经放在寄存器里也要走一遍取指-分发-执行的循环这个循环少说也要几个机器周期。而在 Fun 语言生成的机器码里加法操作最终就是一条addl %eax, %ebxCPU 直接执行没有任何中间环节。从 JSON 到机器码这句话的程序员视角其实就是用编译时间换运行时间。Fun 语言的解析和代码生成过程是有的但交给用户的运行结果是一个接近 C 语言编译结果的执行体这对脚本语言来说确实是个性能上的亮点。3.2 单遍 vs 多遍编译小型语言的常见取舍实现编译器或脚本语言的编译器前端时一个非常关键的工程决策是单遍编译还是多遍编译。单遍编译从头到尾读一遍 AST边读边生成 IR 和机器码。好处是快内存占用小。坏处是遇到前向引用比如函数 A 调用后面才声明的函数 B会非常麻烦通常得用延迟补丁或者符号表预扫描来解决。多遍编译先完整扫描 AST构建出符号表、类型信息再走代码生成。好处是支持前向引用更自然优化空间更大。坏处是内存占用高一点实现复杂度也高一点。Fun 语言由于特点是小和直接比较合理的做法是做一个类型与符号的预扫描然后正式编译时走单遍生成。这样既避免了符号表不完整的尴尬又不会让编译器膨胀成一个巨无霸。如果你在设计类似的脚本语言我给一个非常实战的建议哪怕是单遍为主也一定要先做符号收集再做代码生成。否则你处理递归函数、相互调用时会想在 AST 里反复打补丁代码会越来越不好维护。3.3 从入口看执行流程一条语句的生命周期假设用户写了一个最简单的 Fun 脚本[ { print: { value: 42 } } ]那它从输入到输出的完整过程大概是这样的按常见实现来推测这类小型语言大多类似入口函数接收 JSON 文本交给 JSON 解析器得到一棵 JSON 树。AST 构建器把print 节点等映射为 AST 语句节点。语义分析器检查变量和类型信息。这里由于没有变量声明所以几乎什么都不用做。IR 生成器把print 42翻译成三地址码比如t0 CONST 42; PRINT t0;。寄存器分配器给t0分配一个可用寄存器比如eax。指令选择器把t0 CONST 42变成movl $42, %eax把PRINT t0变成call print_int运行时函数调用。机器码发射器把这些指令拼装成可执行的二进制片段并分配一块可执行内存。JIT 回调执行这段机器码。运行时函数print_int把eax里的整数打印到标准输出。你看实际上对于一个简单的print指令整个链路也要经历这么多步骤但这套工程结构的价值在于每一步都清晰、可测试不复杂也不混乱。3.4 运行时的内建函数怎么设计脚本语言和纯编译语言的一个区别在于脚本语言往往要提供一批内建函数让用户能直接操作底层能力。Fun 语言既然是JSON 到机器码那它的内建函数本质上就是往运行时环境里注册一些 C/Pascal 函数指针。比如print- 接收一个整数参数调用系统printf或 Pascal 的WriteLnlen- 接收一个数组JSON 数组返回其长度range- 接收两个整数返回一个等差数列数组这些函数在 JIT 生成的机器码里怎么看都是一个函数调用指令而已把参数放进寄存器或压栈然后call对应的运行时函数地址。关键在于运行时函数的签名必须和 JIT 生成的调用约定对齐。比如默认 x86-64 SysV 调用约定是rdi、rsi、rdx、rcx存放前四个整数参数那内建函数也要按这个约定来取参数。如果你调用约定对不上出现的问题会非常诡异有时跑出垃圾值有时直接段错误。我建议实现时写一个统一的内建函数表每一项包含函数名、参数个数、函数指针、返回类型。这样注册、查找、调用都非常清晰不会在传参上栽跟头。4. 常见问题与排查技巧实录代码写得再顺也会遇到问题。我在实现和阅读小型编译器/JIT 项目的过程中踩过不少坑下面这几个问题如果提前知道能省下大量调试时间。4.1 为什么我的机器码一调用就段错误这是 JIT 项目里最常见的噩梦几乎所有从零写代码生成的人都会遇到。段错误的原因可能是执行权限缺失你申请的内存默认是可读写但不可执行的W^X 保护。你需要用mmap显式申请PROT_READ | PROT_WRITE | PROT_EXEC权限才能把生成的机器码当作函数来调用。调用约定不匹配如果函数声明是cdecl但调用时按stdcall清理栈栈就失衡程序回到错误地址直接崩溃。机器码的跳转偏移算错call和jmp指令的偏移是相对当前指令地址计算的编译器里容易在这犯迷糊。排查技巧遇到段错误可以分两步走。第一步先加打印或者用调试器查看段错误发生的地址看是否落在你生成的机器码区域内第二步在该地址附近反汇编机器码可用objdump -D -b binary或者 gdb 的x/i命令检查指令是否真的是你想要的。4.2 同样一段 JSON在我的编译器上一次编译一次报错凭什么语义分析的时机和顺序如果设计得不严谨就会出现这种不稳定现象。举个典型的例子JSON 对象里的字段顺序是随意的如果代码生成逻辑依赖了字段顺序比如先处理name再处理value而用户把value写在了前面那编译结果就可能不同甚至直接报错。这个问题的根源是AST 层应该做统一的结构化不要依赖 JSON 源文本的字段顺序。你在解析 JSON 的时候就应该统一把name、value、body等字段按固定顺序提取出来后续逻辑只访问这些结构化的字段不看原始 JSON 顺序。4.3 为什么出现 Illegal instruction非法指令这个错误通常意味着生成的机器码里有 CPU 不识别的指令。常见原因有寄存器分配阶段产生了错误编号比如分配了一个 64 位寄存器但指令模板只支持 32 位操作数使用了不匹配的指令前缀比如在 32 位模式下使用了 64 位寄存器编码 bytes 拼错比如把0x48 0x89 e0误写成了0x48 0x89 e8解决这种问题最有效的方式是单元测试机器码生成的每个函数。输入一个非常小的 AST比如只有一个return 42然后手工检查生成的字节码和预期是否一致。我倾向于维护一份字节码快照的测试文件每次改动生成逻辑就更新快照这样可以防止不经意间改坏指令选择。4.4 JSON 里数字类型和浮点类型怎么处理会不会影响生成的机器码这其实挺关键的。JSON 里42和42.0是两种不同 Token前者是整数后者是小数。如果你的语言只支持整数运算那后端生成指令很简单所有操作都是addl、subl、imull之类的整型指令。但一旦引入浮点麻烦就来了你要处理xmm寄存器、cvtsi2sd这样的类型转换指令还要防止整数和浮点的隐式混用。我在 Fun 语言设计里看到的做法是显式区分i64和f64两种类型并且禁止隐式类型转换。这样做的理由是JIT 生成机器码时每个操作数的类型都是明确的指令选择才不会犯错。如果用户想混用必须显式写一个to_float或to_int的转换操作。实操心得如果你也想做类似语言建议第一版只支持整数。整数的指令选择、寄存器分配、栈帧布局都比浮点简单一个数量级。浮点作为第二版本再加能帮你避开 70% 的复杂 bug。4.5 调试这样的 JIT 脚本语言有没有什么实用的工具链说实话纯粹的 JIT 调试确实比普通解释器痛苦因为没有单步的解释器循环可以看。不过有几个方法很管用打印 IR在代码生成的前后把 IR 三地址码打印出来。这是定位逻辑错误最快的方式。反汇编机器码机器码生成后立刻用内置的反汇编器反汇编一遍输出到文件。一旦崩了对照汇编相比对照字节码舒适得多。加一个慢速模式在语言里加一个环境变量开关跑纯解释模式或者 AST 直译模式。这样可以在功能调试时先跑慢的、稳定的路径性能调优时才切到 JIT 模式不会夹带其他无关 bug。这几个工具链做扎实后你会发现排查问题不再是盲人摸象而是有明确方向地层层击破。5. 个人手记如果我设计一门JSON 到机器码语言还会做什么按我自己的时间排期和精力分配上面的内容梳理其实已经把 Fun 语言的主干技术点聊透了。不过不写点个人的扩展想法和以后会做的方向总觉得不过瘾尤其是脚本语言 机器码这个组合其实还有很大的发挥空间。下面这部分不是教科书的内容纯粹是一个动手党的笔记。5.1 栈上分配 引用计数可能是更适合脚本语言的 GC 方案传统 GC比如 Go 和 Java 的垃圾回收器在 JIT 语言里也能跑但带来的问题是要频繁暂停程序要处理 GC 根集合的栈扫描实现复杂度高。Fun 语言现阶段我没看到特别复杂的 GC 设计但我觉得如果要做可以先上栈上分配 引用计数的组合。思路是这样的大部分临时对象在编译期就能确定生命周期直接栈上分配随栈帧销毁自动释放真正需要长期存活的对象才堆上分配用引用计数管理引用计数多出来的循环引用问题可以在后续加一个兜底的跟踪回收器运行频率调得很低这个方案比全量 GC 好写得多而且对于脚本语言里的短生命周期对象极其友好基本没有停顿感。5.2 把常量折叠和死代码消除提前到 AST 阶段我见过很多小型编译器偷懒直接把常量运算放到运行期做比如print(2 3)生成机器码时会先算两个常量再执行加法。这当然没错但在 AST 阶段做常量折叠更容易——因为 AST 是树结构你可以一趟遍历就找到这种纯常量子树直接把算好的结果替换进去。死代码消除也是同理。如果你的 AST 里已经有了if false这种分支你完全可以在生成 IR 之前就把它剪掉不给它任何生成机器码的机会。这样机器码会干净很多调试时杂音也少。5.3 内联缓存inline cache其实不复杂性能提升却很可观很多 JIT 系统都会做内联缓存用于加速动态查找变量查找、方法查找。Fun 语言如果做成JSON 描述一切的形式那所有函数调用本质上都是按名称查函数表这个查找过程如果每次都走一遍字符串遍历性能会很差。实现一个最简单的单态内联缓存并不复杂在调用点缓存第一次查找的函数指针之后每次调用直接跳转到缓存指针。如果后续出现不同的函数名多态情况再回退到完整查找路径。这个优化对脚本语言返回的性能收益相当大尤其是函数调用密集的场景。做出来的成就感也强因为它属于伸手就能摸到的优化。5.4 最后一点关于可嵌入性的硬话说在前头Fun 语言如果定位成嵌入式脚本语言那它的可嵌入性就非常重要。我希望它提供的 API 能像 Lua 一样干净能从 C/Pascal 侧创建一个执行环境能把 JSON 字符串直接编译并执行能通过宿主语言注册内建函数能从宿主侧读取脚本里的变量和调用脚本里的函数如果这一步做扎实了那么 Fun 语言在游戏脚本、规则引擎、配置驱动逻辑这些场景里都会有非常强的落地能力。毕竟JSON 写玩法逻辑底层跑机器码这种组合对很多业务系统来说确实很理想。这些扩展想法当然只是一家之言但它代表了一个方向真正好用的脚本语言不仅要在能跑这件事上合格更要在好嵌、好调、好优化这三件事上花心思。Fun 语言的起步点已经很不错不走弯路地坚持下去会越来越成熟。