编译管线那篇讲过字节码怎么来:高 8 位地址空间、低 24 位索引的 int 流,以及 validated 指令体系。这篇往下一层,看 gdscript_vm.cpp 里真正的执行心脏——GDScriptFunction::call()。这个函数单函数两千多行,是 Godot 代码库里最大的函数之一,它的形态决定了 GDScript 每一条语句的运行成本。
循环骨架
删减掉具体指令后,循环骨架长这样:
// modules/gdscript/gdscript_vm.cpp, 4.2(结构性删减)
Variant GDScriptFunction::call(GDScriptInstance *p_instance,
const Variant **p_args, int p_argcount,
Callable::CallError &r_err, CallState *p_state) {
// ... 栈帧准备、参数填充 ...
int ip = p_state ? p_state->ip : 0; // 指令指针
Variant *stack = ...; // 当前帧的 Variant 槽位数组
while (true) {
int instr = _code_ptr[ip]; // 取指
switch (instr) {
case OPCODE_OPERATOR: {
int op = _code_ptr[ip + 1]; // 操作数:算子类型
Address a = _code_ptr[ip + 2]; // 左操作数地址
Address b = _code_ptr[ip + 3];
Address dst = _code_ptr[ip + 4];
ip += 5;
// 取数、按 op 分派到 Variant::evaluate、写回
break;
}
case OPCODE_JUMP_IF: {
// ... ip 被改写,实现分支
}
// ... 两百来个 case ...
}
}
exit:
// 返回值拷贝、帧清理
}
三个结构事实:取指是数组下标自增;操作数没有解码表,每个 case 自己知道跟几个 int、各是什么含义,用 ip += n 跳过——这相当于把解码器手工内联进了每个分支;分支用 while(true) + switch,没有 goto 表。
为什么不玩分发优化的花活
解释器分发的经典三板斧:threaded code(computed goto,GCC/clang 的 labels-as-values)、直接线程化 switch(每个 case 结尾直接 goto *dispatch_table[_code_ptr[ip]])、超指令(把常见序列融合成一条指令)。Godot 一个都没用,原因可以逐条论证:
Computed goto 被 MSVC 卡死。 labels-as-values 是 GCC 扩展,MSVC 不支持,而 Godot 的 CI 矩阵里 Windows 是主平台之一。为一个分发优化维护两套 VM 主体,两千行代码 × 2,任何修 bug 都要同步改两遍——收益是每次取指省一次边界检查加一次间接跳转,实测在 switch 编译成跳转表的情况下大约是 5%~10% 的解释器吞吐。用双份维护成本换这个数,不划算。这是 Godot 一贯的移植性优先:引擎要能在 VC++ 2019 上原样编译。
跳转表本身不保证。 switch 编译成跳转表还是比较链是编译器的事,200 来个 case 的稠密枚举在 GCC/clang/MSVC 下都会生成跳转表,这部分收益其实已经在碗里了。真正省不掉的是每次取指后那一次间接分支预测失败——指令流是数据决定的,CPU 的 BTB 对数据流无能为力。这个成本所有纯解释器共享,不算 Godot 的锅。
没有超指令,但编译器已经在做近似。 字节码生成侧(gdscript_byte_codegen.cpp)会做局部合并,比如连续的字面量装载有专门的批量指令,for i in 3 直接生成 OPCODE_ITERATE_BEGIN_INT 而不构造 Range 对象。手工超指令的收益空间被这些特化指令吃掉大半,剩下的长尾序列不值得再加。
一次函数调用的成本模型
热路径上最贵的是 OPCODE_CALL 家族。一个非 validated 的 foo(a, b) 在 VM 里走完整链路:
- 目标解析:
OPCODE_CALL的操作数指向函数地址。脚本函数直接拿到GDScriptFunction*,内层调用是新开一个CallState、在同一块栈缓冲区上推进栈顶——GDScript 的 VM 栈是单一大缓冲加栈顶指针,不是每次调用 malloc,这点和 Lua 相同,比 Python 的帧对象分配便宜。 - 参数校验:参数个数、类型逐一对照函数签名,不匹配进慢路径报
Callable::CallError。 - 递归进
call():整个两千行函数重新走一遍帧准备。
关键点在 3:脚本调脚本不是循环内跳转,而是 C++ 层面的递归调用。每次调用多付一份 C 栈帧加全套前置检查。MAX_CALL_DEPTH = 2048 的硬限制就是给这条递归链上的保险丝。深递归脚本慢,有一半慢在这层递归开销上,而不是指令本身。
实测可以验证这个模型:一个空函数反复自调 100 万次,纯调用开销在 GDScript 里是百 ns 量级;同样的循环在 C++ 里是几 ns。差的那两个数量级就是解释器递归调用链的总和——分发、帧准备、参数校验、Variant 进出栈,一项一项都是小钱,加起来就是量级。
指令集的规模与调试通道
Opcode 枚举有多少条?4.2 里接近 200 条,分成几个区块:运算与比较(OPERATOR 系列)、属性访问(GET_NAMED/SET_NAMED 系列及 validated 版)、调用(CALL 家族)、控制流(JUMP/JUMP_IF/RETURN)、迭代(ITERATE 系列)、构造(CONSTRUCT、ARRAY、DICTIONARY)、信号(SIGNAL_CONNECT/DISCONNECT)、await(AWAIT/AWAIT_RESUME)、以及成对的 OPCODE_LINE。
OPCODE_LINE 值得单独说:它不占任何操作数,只做一件事——更新当前行号供调试器断点和报错堆栈用。非调试构建里它仍然存在,因为行号信息也服务运行时错误报告。这带来一个小的性能事实:GDScript 源码里一行写的表达式越长,生成的 LINE 指令占比越低,循环体压成一行在解释器上是真的略快——不是玄学,是少取了一次指。当然这点差距远小于算法本身的差距,不值得为此牺牲可读性。
Debug 构建(DEBUG_ENABLED)里还有个隐藏开关:gdscript_vm.cpp 里搜 GDSHIDBG 能找到带缩进的逐指令 trace,配合 GDScriptLanguage::debug_break 就是脚本调试器的底层。发布模板编译掉这整块,所以发布版的 VM 比编辑器里跑的 VM 快——之前性能实测那篇的教训在这里同样适用。
总结成一张图
本文代码引自 Godot 4.2.2-stable 的 modules/gdscript/gdscript_vm.cpp,为突出结构有删减;实测数值为同一机器上的相对比较,绝对值随平台浮动。