深入 Ruby 35:Prism、YARV 与字节码
系列导航
导读 · 上一篇:34:对象分配与 GC · 下一篇:36:YJIT 与性能实验 · 完整源码包
解析器、字节码与执行对象各自负责什么
Ruby 源码先要被理解为语法结构,再变成当前实现可以执行的形式,执行时才读取变量、调用方法并产生结果。Prism、YARV 与 Ruby 对象处于这条路径的不同位置。Ruby 3.4 把 Prism 设为默认解析器,不意味着删除了字节码虚拟机,也不意味着解析源码已经运行了程序。
这种区分直接影响调试。语法错误发生在解析阶段;方法不存在通常要到执行阶段才能确定;运行结果错误还可能来自对象状态或方法重新定义。把所有问题称为“编译器错误”,会使排查入口变得模糊。
本章选择一个很小的表达式,同时观察 Prism AST、CRuby InstructionSequence 以及求值结果。源码阅读固定到 Ruby 3.4.11 的完整提交 592f1ffdb36153e8be83603ade3c2e9ab6138a77。不同补丁版本的节点、指令文本或优化可能变化,不能拿未来 master 的实现解释当前实验。
下图区分源码处理与运行时执行。第 35 篇实验观察 AST、ISeq 和求值结果;虚线表示启用 YJIT 后可能采用的编译路径,具体启用与性能证据见下一篇,不代表所有指令都会生成机器码。
flowchart TD
subgraph Prepare["源码处理:尚未执行这段程序"]
Source["Ruby 源码"] --> Parse["Prism 解析"]
Parse --> AST["AST 语法树"]
AST --> Compile["CRuby 编译"]
Compile --> ISeq["ISeq 指令序列"]
end
subgraph Execute["运行时:调用方法并产生结果"]
VM["YARV 解释执行"]
JIT["YJIT 编译满足条件的热点路径"]
Native["生成的机器码执行"]
Result["Ruby 可观察结果"]
JIT --> Native
Native -->|"需要回退时"| VM
VM --> Result
Native --> Result
end
ISeq --> VM
ISeq -.->|"YJIT 已启用且路径适合编译"| JIT
同一表达式的三种表示
从仓库根目录执行:
1 | |
被测输入是:
1 | |
业务结果明确:每个整数乘二,再求和得到十二。脚本对结果作断言,同时保留 AST 和反汇编全文。小输入减少无关机制,让读者能把接收者、方法名、块参数和运算对应起来。
Prism.parse(source) 返回解析结果,success? 表明语法检查是否通过,value 给出语法树。AST 描述源代码结构,不保存本次 map 最终得到的数组。节点中的数值字面量与运行时创建或使用的对象,也不能仅凭相同显示内容就视为同一个层次。
另一个输入 def broken( 故意缺少完整结构,实验要求解析失败并保留错误信息。这个反例防止脚本只打印一个树对象便宣布“解析器工作”。诊断文本会随版本变化,因此断言检查失败状态,日志保存具体内容用于当前版本复核。
从 AST 理解接收者和块
表达式的最外层操作是对 map 结果调用 sum。map 的接收者是数组字面量,块有一个局部参数 number,块体对它调用乘法。Ruby 的很多操作使用方法调用语义,但语法树仍需要区分各种节点形状,以保留位置、参数与控制结构信息。
块参数在自己的局部作用域中绑定。语法树能够告诉编译阶段哪些位置引用局部变量,而“这个 number 本次是哪个整数”属于执行状态。方法名也不等于已经固定的方法实现;运行期间的方法查找和缓存仍要遵守 Ruby 的对象模型。
解析 API 可供格式化、静态检查和重构工具使用,因为它们往往需要结构与源码位置,却不应该执行用户提交的 Ruby。相反,使用 eval 来“看看表达式是什么”会越过信任边界。即使源码看上去只是一个表达式,其中也可能包含文件和进程操作。
语法成功也不能证明业务类型正确。unknown_receiver.map 可以是合法 Ruby 语法,但执行时接收者可能不存在或不响应 map。静态工具若希望进一步报告这些问题,需要额外的名称和类型分析;不能把解析成功扩大成运行安全保证。
InstructionSequence 是实现层观察面
脚本调用:
1 | |
ISeq 文档明确这是 CRuby 暴露的执行表示。它适合教学、调试和特定工具,不属于所有 Ruby 实现都必须支持的接口。JRuby 或其他实现可以保留 Ruby 语义而采用不同执行路径。
反汇编中需要关注数组输入、map 调用、关联的块指令序列以及后续 sum。块体又包含参数读取、常量二和乘法操作。脚本只对当前案例的 map 调用信息与块存在作有限断言,不把整段反汇编逐字锁死。否则增加一个无害优化就可能使教学验收失败。
指令偏移、栈深和局部变量索引是理解执行布局的线索,不是源码行数的另一种写法。一条 Ruby 语句可能对应多个执行步骤,多个语法结构也可能被优化合并。调试时应先确认观察的是编译选项和版本一致的产物。
ISeq.eval 会真的执行代码。把编译后的二进制缓存当作普通数据跨信任边界加载,同样需要谨慎;字节码不是天然安全的中间格式,也不应假定跨 Ruby 版本可移植。版本固定与输入可信度需要由缓存格式和部署流程明确保证。
用固定源码连接三个阶段
本章发行源码的 revision.h 给出完整 SHA。阅读 prism_compile.c 可以找到 pm_parse 调用、pm_iseq_compile_node 与 pm_compile_call。这些入口分别连接解析结果、指令序列构造和调用节点编译,帮助定位“结构怎样变成执行步骤”。
阅读 insns.def 可以看到 send 与 opt_send_without_block 的指令定义。后者明确没有传入块处理器,两类路径继续通过调用辅助逻辑完成方法执行。名称带 opt 不能解释为“永久绕过 Ruby 方法语义”;优化仍需满足实现的保护条件和失效机制。
虚拟机上下文可结合 vm.c 阅读。源码文件名是定位入口,不能代替机制解释。对本例,重要的是接收者和参数如何准备、块代码如何关联到调用,以及执行结果怎样回到后续 sum,而不是背下内部宏名。
本次源码内容核对使用官方 3.4.11 发行包解压文件。固定 SHA 链接用于精确标识版本;网页抓取服务无法获取链接时,不能声称网页内容已成功打开。可复查的发行源与实际运行的 revision 一致,比引用一个会变化的 master 页面更容易重现实验。
优化后的形状不等于语言规则
给数字乘法增加观测、改变接收者类型或重新定义相关方法,都可能影响优化路径。不能因为一个反汇编显示特殊指令,就得出“乘法永远不是方法调用”的结论。语言可见行为与执行快路径需要分别描述。
类似地,YJIT 可以把热点执行路径编译为机器码,但 ISeq 仍参与整体执行体系。解释执行、JIT 编译和回退可能在同一程序生命周期中共同存在。下一章检查实际启用状态与工作负载收益,不用“有字节码”或“有机器码”直接推导速度。
源码工具也要尊重版本边界。复制 AST 节点字段或反汇编解析器到长期项目时,应有明确支持版本及失败提示。工具遇到未知节点时静默略过,可能生成看似完整却漏掉关键代码的分析结果。显式拒绝不支持格式,通常比猜测更可靠。
本次 AST 与指令对应
本次 Prism 版本为 1.5.3,解析根节点为 ProgramNode。反汇编顶层显示 duparray 准备数组,send 携带 map 和块,opt_send_without_block 调用 sum,最后 leave 返回。块中 getlocal 读取 number,putobject 放入二,opt_mult 处理乘法。最终求值结果为十二。
这些观察能帮助定位数据依赖:map 调用必须先得到数组,sum 必须等待 map 返回,块参数每次绑定到当前元素。反汇编展示的是执行表示中的安排,没有给出每次调用耗时。看到一条指令并不能推出它比另一条便宜,方法调用内部还可能执行大量代码。
如果把整个表达式替换为常量十二,结果断言仍会通过,但 AST 与 map/块存在断言会失败。这个反例说明实验同时检查了业务结果与被研究机制,避免测试被一个绕过机制的实现满足。反过来,只检查指令中含 map,也不能证明返回值正确,因此两类断言都需要保留。
非法输入产生三条解析诊断,包括参数右括号与方法结束标记缺失。它们是同一个残缺输入的多个恢复诊断,不代表执行了三次程序。解析器为了继续分析可能生成更多错误信息,工具界面应保留位置与关联,避免把每条诊断误当成独立根因。
调试工具若需要机器可读结果,应优先使用有文档的接口,并在版本变更时重新运行兼容性测试。直接按空格切分反汇编字符串,容易被格式调整破坏。
练习与验收
把 map 块改为调用一个命名方法,保留返回十二的业务契约,对比新增方法定义和调用在反汇编中的位置。然后把块改为包含条件分支,说明 AST 结构与指令跳转分别表示什么。
再构造一个语法正确但运行时缺少方法的表达式,分别运行 parse、compile 和 eval。记录究竟哪一步失败,不把三个阶段合并到同一个 rescue 中。练习完成后,应能根据错误类别选择解析、编译还是运行状态作为排查入口。
| 观察材料 | 回答的问题 | 不能替代 |
|---|---|---|
| Prism AST | 源码有哪些语法结构 | 运行时业务结果 |
| ISeq 反汇编 | 当前 CRuby 如何安排执行步骤 | 跨实现语言规范 |
| eval 结果 | 当前环境中实际执行了什么 | 不可信代码安全证明 |
| 固定源码 | 当前版本实现路径在哪里 | 当前输入的动态轨迹 |
