技术博客

GDScript 编译管线:从 tokenizer 到字节码 VM

第 80 / 149 篇 · 2025-04-13 · godot · GDScript源码分析虚拟机

GDScript 常被当成"不需要编译的解释语言",这个印象只对了一半:它确实没有 AOT 产物,但每次加载脚本都走了一条完整的编译管线,产出是字节码,由专用 VM 执行。翻 modules/gdscript/ 的源码时我最有收获的发现是:4.x 的编译器不是"解析完直接生成字节码",中间多了一层 analyzer 做类型推导,而你在脚本里写的类型标注,会直接改变生成的指令。这篇按 4.2.2 的代码把管线拆开,重点讲每个阶段为什么存在、产出物的形态为什么是现在这样。

入口:GDScript::reload()

所有编译动作从 gdscript.cpp 的 GDScript::reload() 开始。删减无关分支后,主干干净得可以一眼看完:

// modules/gdscript/gdscript.cpp, 4.2.2
Error GDScript::reload(bool p_keep_state) {
    // ...
    valid = false;
    GDScriptParser parser;
    Error err = parser.parse(source, path, false);   // 阶段 1+2:词法+语法
    if (err) { return ERR_PARSE_ERROR; }

    GDScriptAnalyzer analyzer(&parser);
    err = analyzer.analyze();                        // 阶段 3:类型检查/推导
    if (err) { return ERR_PARSE_ERROR; }

    GDScriptCompiler compiler;
    err = compiler.compile(&parser, this, p_keep_state);  // 阶段 4:生成字节码
    // ...
}

四个角色,对应 modules/gdscript/ 下的四个主要文件:gdscript_tokenizer.cpp、gdscript_parser.cpp、gdscript_analyzer.cpp、gdscript_compiler.cpp(字节码生成委托给 gdscript_byte_codegen.cpp)。

管线和各阶段的产出物可以先看一张全景:

GDScript 编译管线:每个阶段的输入与产出(4.2.2) 源码文本 .gd Token 流 AST(无类型) AST(带类型) int[] 字节码 tokenizer parser analyzer compiler 缩进→INDENT 只查语法 递归下降 推导 DataType 选 VALIDATED 指令 运行时:GDScriptFunction::call()(gdscript_vm.cpp,单函数 2000+ 行) 巨型 switch 按 opcode 分发;操作数 = 高 8 位地址空间 + 低 24 位索引;取数只需一次数组下标 await:OPCODE_AWAIT 把 ip + 栈打包进 CallState 返回,信号触发 resume() 从断点续跑 关键事实 1. 字节码不落盘:每次启动从源码重新编译,启动耗时与脚本总量成正比 2. 类型标注不是注释:analyzer 推出确定类型 → compiler 换发 VALIDATED 指令 → VM 跳过动态检查

tokenizer 和 parser:文本到 AST

GDScriptTokenizer 把源码切成 token 流,缩进在这里被转成 INDENT/DEDENT token——所以 GDScript 的缩进敏感是在词法层解决的,parser 看到的已经是结构化流。GDScriptParser::parse() 用递归下降把 token 流组织成 AST:类节点 ClassNode 下面挂 FunctionNode、VariableNode,表达式是 BinaryOpNode、CallNode 这一家子。

这个阶段只检查语法,不认识任何标识符。print("hi") 在 AST 里只是一个"对名为 print 的标识符发起调用"的节点,至于 print 是谁、参数类型对不对,parser 一概不管。

把缩进处理放在词法层是个划算的分工:Python 同款方案。缩进本质是布局信息,如果交给 parser 处理,每个产生式都要感知列位置,语法规则会复杂一个量级;词法层把它转成一对括号式 token 之后,parser 面对的就是普通的块结构文法,缩进规则的所有复杂度被压缩进 tokenizer 的状态机里。编译器前端的通行智慧——能往前一阶段推的复杂度就别往后留,因为越靠后的阶段数据结构越重。

analyzer:类型标注改变指令的那一层

GDScriptAnalyzer 遍历 AST 做类型推导和静态检查:解析标识符、推导表达式类型、检查返回值匹配。它产出的关键东西是每个表达式的 GDScriptDataType。这个结构在 gdscript_function.h 里:

// modules/gdscript/gdscript_function.h, 4.2.2
class GDScriptDataType {
public:
    enum Kind {
        UNINITIALIZED,
        BUILTIN,   // int、Vector3 等内建类型
        NATIVE,    // ClassDB 里的引擎类
        SCRIPT,    // 脚本类
        GDSCRIPT,
    };
    Kind kind = UNINITIALIZED;
    bool has_type = false;
    Variant::Type builtin_type = Variant::NIL;
    StringName native_type;
    Script *script_type = nullptr;
    // ...
};

为什么这一层重要?看编译产物的指令集就明白了。顺带纠正一个常见误解:analyzer 不是"可选的严格模式开关",它在管线里是必经环节——即使你不写任何类型标注,它也全程跑完,只是推导结果是 Variant(不确定类型)。标注的作用是让推导结果从"不确定"变"确定",从而解锁后面 compiler 的指令选择。这也是为什么标注带来的性能收益不需要全代码库覆盖:一个热循环里的局部变量标了类型,这个循环就走快路径,其余代码不受影响。类型系统的回报是按表达式粒度结算的。

compiler:字节码里藏着"validated"指令

GDScriptCompiler 把 AST 交给 GDScriptByteCodeGenerator,产出挂到 GDScriptFunction 上。看 gdscript_function.h 里 Opcode 枚举的一段(原文节选):

enum Opcode {
    OPCODE_OPERATOR,
    OPCODE_OPERATOR_VALIDATED,          // 类型已知时用的算子指令
    OPCODE_GET_NAMED,
    OPCODE_GET_NAMED_VALIDATED,
    OPCODE_CALL,
    OPCODE_CALL_UTILITY,
    OPCODE_CALL_UTILITY_VALIDATED,
    OPCODE_CALL_BUILTIN_TYPE_VALIDATED,
    OPCODE_CALL_METHOD_BIND,
    OPCODE_CALL_METHOD_BIND_VALIDATED_RETURN,
    // ...
    OPCODE_ITERATE_BEGIN,
    OPCODE_ITERATE_BEGIN_INT,           // for i in 3 专用的整数迭代
    OPCODE_ITERATE_BEGIN_FLOAT,
    // ...
};

规律一眼可见:几乎每条通用指令都有一个 VALIDATED 变体。analyzer 推导出确定类型时,compiler 就生成 validated 版本——比如 OPCODE_CALL_METHOD_BIND 直接拿着 MethodBind* 调 C++ 函数,跳过方法名查找和参数动态检查;OPCODE_ITERATE_BEGIN_INT 知道迭代的是整数,连 Range 对象都不构造。这就是"写类型标注能让 GDScript 快一截"的底层原因:不是解释器变快了,是编译出来的指令本身换了。

两套指令的成本差异可以量化理解。以一次方法调用为例,非 validated 路径:查 method_map(StringName 哈希,常数但非零)→ 逐参数做 Variant::can_convert_strict 校验 → 装箱进参数数组 → 调 MethodBind::call → 拆返回值。Validated 路径:编译期已完成查找和类型匹配,运行期把栈上的 Variant 直接摆进参数位 → 调函数指针。省掉的是一次哈希查找加 $n$ 次类型校验,$n$ 为参数个数。单次数十纳秒级别,但 GDScript 的热循环里每轮迭代可能有好几次调用,一个每帧跑一万次的循环累计就是毫秒级差距——这就是"标注类型快一倍"这类说法在微观上的构成。

GDScriptFunction 的最终形态也值得一看(节选):

class GDScriptFunction {
    // ...
    Vector<int> code;                  // 字节码:指令+操作数都是 int
    Vector<Variant> constants;         // 常量表
    Vector<MethodBind *> methods;      // 缓存的方法绑定
    Vector<Variant::ValidatedSetter> setters;
    Vector<Variant::ValidatedGetter> getters;
    // ...
    int *_code_ptr = nullptr;
    MethodBind **_methods_ptr = nullptr;
};

注意字节码是 Vector<int>——指令和操作数共用一条 int 流。操作数不是值,而是地址,编码方式在同一个文件里:

enum Address {
    ADDR_BITS = 24,
    ADDR_MASK = ((1 << ADDR_BITS) - 1),
    ADDR_TYPE_MASK = ~ADDR_MASK,
    ADDR_TYPE_STACK = 0,
    ADDR_TYPE_CONSTANT = 1,
    ADDR_TYPE_MEMBER = 2,
    ADDR_TYPE_MAX = 3,
};

一个 int 的高 8 位标记地址空间(栈/常量表/成员变量),低 24 位是索引。栈地址指向当前调用帧的 Variant 槽位,常量地址指向 constants 表。所以 VM 取操作数只是一次数组下标,没有哈希查找。

字节码为什么是 int 数组而不是结构体数组或变长字节流?这是解码速度与内存局部性的折中。定长 32 位槽位意味着取指/取操作数全是 code[ip++] 这种自增下标,指令流水线可以完全预测;字节流(像 JVM 那种)要按 opcode 查长度表再偏移,多一次分支。结构体数组(像 LuaJIT 的 TValue 指令字)更宽,每条指令 8 字节,缓存行利用率减半。int 流的代价是表达能力受限——操作数最多 24 位索引,栈帧最多 $2^{24}$ 个槽位——但对脚本函数来说这个上限永远碰不到。设计取舍的痕迹就写在这两个常量里。

VM:一个巨型 switch

执行入口是 gdscript_vm.cpp 的 GDScriptFunction::call()——整个引擎里最壮观的函数之一,单个函数两千多行,主体就是一个按 opcode 分发的大 switch,每个 case 从 _code_ptr 读指令和操作数、按地址类型取 Variant、执行、写回栈。

await 的实现也在这里:执行到 OPCODE_AWAIT 时,VM 把当前指令指针 ip、行号、整个栈打包进 CallState,交给一个 GDScriptFunctionState 对象返回给调用者;信号到来时 resume() 拿着这份状态从断点继续跑。所以 GDScript 协程不是栈式协程(像 lua 那种),是状态保存式——栈帧被复制走了。这也解释了它的限制:await 之后原函数体已经返回,局部变量活在 CallState.stack 里。

栈式协程和无栈协程的差别值得展开,因为它决定了 await 能写在哪儿。栈式协程(Lua coroutine)保存整条 C 栈或虚拟机调用栈,可以在任意嵌套深度 yield——函数 A 调 B 调 C,C 里 yield,整条链都冻住。GDScript 的无栈方案只保存当前函数的栈帧,await 之上的调用者当场收到 GDScriptFunctionState 返回值并返回——暂停能力不能跨函数透明传播,这就是"await 会传染"(调用 await 函数的函数自己也要 await)的底层原因。换来的是实现简单:不需要独立的协程栈管理,不用处理栈生长,内存开销按挂起函数数量线性计,而且和 GC/引用计数体系天然兼容。对游戏脚本这种"协程深度一两层、挂起时间短"的场景,无栈是对的取舍;真要写深嵌套的生成器式代码,就会撞上这堵墙。

另外两个从 CallState 能看出的工程细节:

static constexpr int MAX_CALL_DEPTH = 2048;  // 防止栈溢出崩溃的硬限制

递归写太深时看到的 "Stack overflow" 报错就来自这个计数;而 CallState.stack 是 Vector<uint8_t>——栈是原始字节缓冲区,按槽位强转成 Variant,省掉了每槽位一次堆分配。

编译产物不落盘

一个容易误解的点:GDScript 的字节码不缓存到磁盘。.godot/ 目录里只有全局类名缓存(global_script_class_cache.cfg),没有 .gdc 之类的编译产物——每次启动游戏,所有脚本都从源码重新走一遍上面这条管线。好在 parser 到 bytecode 整条链是纯内存操作,中小工程感觉不到;但脚本量巨大(几千个 .gd)的工程,启动时那一段卡顿就花在这里。

为什么不做字节码缓存?缓存的敌人从来不是生成成本,而是失效判断。GDScript 脚本可以互相引用(class_name 全局类、preload 常量),一个脚本的类型环境依赖它引用的所有脚本的当前内容;缓存一份字节码就要缓存整个传递闭包的哈希,任何一环变了全部重来。而编译本身是纯 CPU 内存操作,量级在毫秒/百脚本——失效系统的复杂度换这点时间不值得。对比着色器那边:shader 编译要走 glslang 加驱动,单次成本几十毫秒起,所以引擎给 shader 做了 pipeline cache。缓存与否的分界线就是"生成成本"和"失效成本"的比值,不是"能不能"。

编辑器里"保存即生效"也走同一条路:ResourceFormatSaverGDScript::save() 落盘后,如果 ScriptServer::is_reload_scripts_on_save_enabled() 为真(项目设置里"保存时重新加载脚本"),会立刻调 GDScriptLanguage::reload_tool_script() 重新编译——这就是改完 @tool 脚本不切场景也能看到效果的原因,也是保存时脚本错误立刻弹红的原因。

一条 print("hello") 的完整旅程

收拢一下:

  1. ResourceLoader 加载 .gd → GDScript::reload();
  2. GDScriptParser::parse 产出 AST(CallNode(print, ["hello"]));
  3. GDScriptAnalyzer 认出 print 是 utility function、参数是 String 字面量;
  4. GDScriptCompiler 生成 OPCODE_CALL_UTILITY_VALIDATED,操作数是常量表里的 "hello" 地址和 utility 函数指针索引;
  5. 调用时 GDScriptFunction::call 执行这条指令,直接命中 Variant::ValidatedUtilityFunction 函数指针——没有一次字符串比较。

下次再纠结"GDScript 要不要写类型标注"时,答案是源码给的:标注不是注释,是编译指令。

本文代码引自 Godot 4.2.2-stable 的 modules/gdscript/gdscript.cpp 与 modules/gdscript/gdscript_function.h,均有删减。