技术博客

模块注册机制:register_module_types 如何串起所有模块

第 11 / 149 篇 · 2026-03-05 · godot · 模块系统源码分析初始化

Godot 的 modules/ 目录下有 40 多个可选模块:GDScript 脚本、glTF 模型导入、Jolt 物理、mbedtls 加密、text_server 字体整形……每个模块都是自包含的,可以独立开关。但模块里的类要能被 ClassDB::instantiate() 找到、编辑器要知道模块提供的导入器、运行时要能调模块的初始化函数——这些都需要一套注册机制。核心文件是构建时生成的 modules/register_module_types.gen.cpp,以及每个模块自带的 register_types.cpp。这篇讲这套机制怎么工作,以及它如何解决 C++ 静态初始化顺序这个经典坑。

模块的自描述:register_types.cpp 的三段式

每个模块目录下都有一个 register_types.cpp,结构固定(以 modules/gdscript/register_types.cpp 为例):

void initialize_gdscript_module(ModuleInitializationLevel p_level) {
    if (p_level == MODULE_INITIALIZATION_LEVEL_SERVERS) {
        // 第一阶段:注册到 ClassDB
        GDREGISTER_CLASS(GDScript);
        ScriptServer::register_language(&gdscript_language);
    }
    if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
        // 第二阶段:依赖 scene 层的初始化
        EditorNode::add_init_callback(&gdscript_editor_init);
    }
}

void uninitialize_gdscript_module(ModuleInitializationLevel p_level) {
    if (p_level == MODULE_INITIALIZATION_LEVEL_SERVERS) {
        ScriptServer::unregister_language(&gdscript_language);
    }
}

模块对外暴露两个函数:initialize_xxx_module() 和 uninitialize_xxx_module()。关键点在 ModuleInitializationLevel 参数——初始化分三个阶段,模块在不同阶段干不同的事:

  • MODULE_INITIALIZATION_LEVEL_CORE:core 层刚初始化完,可以注册不依赖任何上层的类型(如自定义 Variant 类型);
  • MODULE_INITIALIZATION_LEVEL_SERVERS:server 层就绪,可以注册依赖 RenderingServer/PhysicsServer 的类;
  • MODULE_INITIALIZATION_LEVEL_SCENE:scene 层就绪,可以注册 Node 派生类、编辑器插件。

这个分阶段设计是为了解决依赖顺序。GDScript 模块的 GDScript 类继承 Script(在 core 层),但 GDScriptLanguage 要注册到 ScriptServer(在 servers 层初始化时创建)。如果模块初始化不分阶段,要么"core 阶段注册时 ScriptServer 还没存在",要么"servers 阶段注册时某些 core 类型已经错过了注册窗口"。分阶段让每个模块在正确的时机做正确的事。

生成代码:register_module_types.gen.cpp

模块的 register_types.cpp 不会被直接调用——它们被构建时生成的 register_module_types.gen.cpp 汇总。这个文件由 methods.py 的 generate_module_registration_header() 生成,内容类似:

// Auto-generated, do not edit
void register_module_types() {
    initialize_modules(MODULE_INITIALIZATION_LEVEL_CORE);
    initialize_modules(MODULE_INITIALIZATION_LEVEL_SERVERS);
    initialize_modules(MODULE_INITIALIZATION_LEVEL_SCENE);
}

static void initialize_modules(ModuleInitializationLevel p_level) {
#ifdef MODULE_GDSCRIPT_ENABLED
    initialize_gdscript_module(p_level);
#endif
#ifdef MODULE_JOLT_ENABLED
    initialize_jolt_module(p_level);
#endif
#ifdef MODULE_MBEDTLS_ENABLED
    initialize_mbedtls_module(p_level);
#endif
    // ... 40 多个模块,每个都有 #ifdef 保护
}

生成逻辑扫描所有 modules/xxx/ 目录,检查 SCsub 里模块是否启用,启用的就生成对应的 #ifdef MODULE_XXX_ENABLED 块。这个 MODULE_XXX_ENABLED 宏由模块的 SCsub 在编译时定义(上一篇讲的 env.Append(CPPDEFINES=['MODULE_XXX_ENABLED']))。

为什么是生成代码而不是配置文件? 因为模块列表是编译期确定的(scons module_xxx_enabled=no 直接不编译该模块),用运行时的配置文件加载模块列表会引入"配置和实际编译不一致"的风险。生成代码把模块列表固化在二进制里,register_module_types() 的行为在编译期就完全确定。

调用时机:Main::setup2() 的精确位置

register_module_types() 在 Main::setup2() 里被调用,位置有严格要求(第二篇讲的启动流程):

Error Main::setup2() {
    // ... DisplayServer、RenderingServer 初始化
    register_core_types();      // core 层类型注册
    register_server_types();    // servers 层类型注册
    register_module_types();    // ← 在这里,三阶段初始化
    register_scene_types();     // scene 层类型注册
    // ...
}

模块初始化在 servers 之后、scene 之前。这个顺序的原因:

  • 模块可能注册 server 层的扩展(如 Jolt 注册为 PhysicsServer3D 的实现),必须在 scene 层之前——因为 scene 层的 PhysicalBone3D 类在 _bind_methods 里会查 PhysicsServer3D 的枚举;
  • 模块可能注册 scene 层的类(如 glTF 的 GLTFDocument 是 Resource 派生),必须在 scene 层类型注册之前——否则 GLTFDocument 里引用 Animation 类时 ClassDB 里还没有。

三阶段初始化让模块可以"按需迟到"——第一阶段注册不依赖任何上层的类型,第三阶段注册依赖 scene 的类型。这是用"时间分片"解决"依赖拓扑"的经典手法。

静态初始化顺序问题的规避

C++ 的静态初始化顺序是臭名昭著的未定义行为——不同编译单元的全局对象构造顺序不保证。Godot 的模块机制完全规避了静态初始化:

  • 模块里没有全局对象的构造函数干注册(如 static GDScriptLanguage lang; 的构造函数里调 ScriptServer::register_language)——这种写法在 Godot 里是禁止的;
  • 所有注册都通过 initialize_xxx_module() 显式调用,调用顺序由 register_module_types() 控制;
  • 模块内部的单例(如 GDScriptLanguage::get_singleton())用"首次调用时构造"的懒汉模式,而不是全局构造。

这个纪律的代价是模块作者要记住"不要在全局构造函数里注册",换来的是初始化顺序完全可预测——register_module_types() 里的调用顺序就是字母序(生成代码按目录名排序),没有任何隐藏依赖。

模块的清理:uninitialize 的对称性

uninitialize_module_types() 在 Main::cleanup() 里调用,顺序是初始化的逆序(第三阶段先清理,第一阶段最后清理)。模块的 uninitialize_xxx_module() 要做的通常是:

  • 从全局注册表移除(如 ScriptServer::unregister_language);
  • 释放模块持有的全局资源(如 GDScript 的字节码缓存);
  • 注销 ClassDB 里的类(实际是 ClassDB::cleanup() 统一处理,模块不用管)。

很多模块的 uninitialize 是空的——因为退出时的清理顺序由 Main::cleanup() 全局控制,模块只需要释放"自己 new 出来的、不属于引擎管理的资源"。GDScript 模块的清理函数就是典型案例:只负责释放 GDScriptCache 里的字节码,类的注销由 ClassDB 统一做。

模块注册的三阶段时序

模块三阶段初始化:每层就绪后调用对应阶段 register_core core 层类型 模块 LEVEL_CORE 无依赖的类型 register_servers server 层类型 模块 LEVEL_SERVERS 依赖 server 的类型 register_scene scene 层类型 模块 LEVEL_SCENE 依赖 scene 的类型 为什么不用静态初始化 问题:C++ 全局对象的构造顺序跨编译单元未定义,模块 A 的全局对象可能在 ClassDB 初始化前构造 对策:所有注册动作显式调用,initialize_xxx_module() 由 register_module_types() 统一调度 收益:初始化顺序完全可控(生成代码按模块名排序),调试时能在 register_module_types() 下断点 代价:模块作者必须遵守纪律——禁止在全局对象构造函数里注册,禁止跨模块的隐式依赖 GDExtension 例外:扩展是运行时加载的动态库,用 GDExtension 初始化函数而非模块机制 生成代码的维护:加新模块只需在 modules/ 下建目录 + SCsub + register_types.cpp,无需改主构建文件

实战:写一个最小模块

写一个自定义模块的最小骨架(以 modules/mymodule/ 为例):

modules/mymodule/
├── SCsub
├── config.py
├── register_types.h
├── register_types.cpp
└── my_node.h / my_node.cpp

config.py 定义模块元数据:

def can_build(env, platform):
    return True  # 所有平台都能编译

def configure(env):
    pass  # 可选:检查依赖库

SCsub 编译模块源文件:

Import('env')
env.add_source_files(env.modules_sources, '*.cpp')
env.Append(CPPDEFINES=['MODULE_MYMODULE_ENABLED'])

register_types.cpp 注册类型:

#include "register_types.h"
#include "core/object/class_db.h"
#include "my_node.h"

void initialize_mymodule_module(ModuleInitializationLevel p_level) {
    if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) return;
    GDREGISTER_CLASS(MyNode);
}

void uninitialize_mymodule_module(ModuleInitializationLevel p_level) {
    // 通常为空,ClassDB 统一清理
}

之后 scons 会自动发现这个模块(扫描 modules/ 目录),生成对应的注册代码。禁用方式是 scons module_mymodule_enabled=no,或者删掉整个目录。

最常见的坑是初始化阶段选错——MyNode 继承 Node(scene 层),如果在 LEVEL_SERVERS 阶段注册,GDREGISTER_CLASS 会失败(ClassDB 里还没有 Node)。规则很简单:继承链最深层的类在哪个层,就在哪个阶段注册。

本文代码引自 Godot 4.2.2-stable 的 modules/register_module_types.gen.cpp(生成)、modules/gdscript/register_types.cpp、methods.py,均有删减。