技术博客

core/os 层:OS 单例如何抽象平台差异

第 9 / 149 篇 · 2026-04-21 · godot · 平台抽象源码分析引擎架构

Godot 支持 Windows、Linux、macOS、Android、iOS、Web 六个平台,每个平台的窗口创建、输入事件、文件系统、线程模型都不同。这些差异没有散落在引擎各处,而是被收拢到一层:core/os/os.h 定义的 OS 基类,加上 platform/<os>/os_<os>.cpp 里的平台实现。上层代码只调 OS::get_singleton()->get_ticks_msec(),不关心当前跑在哪个系统。这篇拆这套抽象的具体实现,以及它为什么选择了"单例 + 虚函数"而不是更时髦的"接口 + 依赖注入"。

OS 基类:200 个虚函数的平台契约

core/os/os.h 里 OS 类约 700 行,定义了平台必须实现或可以选择性覆盖的全部能力:

class OS {
    static OS *singleton;
    // ...
public:
    virtual void initialize() = 0;                    // 平台初始化
    virtual void set_main_loop(MainLoop *p_main_loop) = 0;
    virtual void run() = 0;                           // 主循环驱动
    virtual uint64_t get_ticks_msec() const = 0;      // 毫秒时钟
    virtual uint64_t get_ticks_usec() const = 0;
    virtual Error execute(const String &p_path, const List<String> &p_arguments, ...) = 0;
    virtual Error create_process(const String &p_path, const List<String> &p_arguments, ...) = 0;
    virtual void alert(const String &p_alert, const String &p_title = "ALERT!") = 0;
    virtual String get_name() const = 0;              // "Windows" / "Linux" / "macOS"
    virtual bool has_feature(const String &p_feature) const;  // 非纯虚,有默认实现
    // ... 约 200 个虚函数
    static OS *get_singleton() { return singleton; }
};

三个设计决策值得展开。

静态单例而非传参。 OS::get_singleton() 是全局可访问的静态方法,任何地方都能拿到平台接口。这避免了"每个类构造函数都要传一个 OS&"的构造函数污染——引擎里几百个类要用平台服务,依赖注入会让构造函数签名爆炸。代价是隐式依赖(看类接口不知道它用不用 OS),但引擎代码里这个约定足够统一,实践中不是负担。

虚函数而非函数指针表。 和 RenderingDriver 的"函数指针表"(为了 GDExtension ABI)不同,OS 用传统虚函数。因为 OS 是引擎内部抽象,不跨动态库边界——platform/windows/os_windows.cpp 编译进引擎二进制,不是插件。虚函数的分派开销(一次虚表跳转)在"每帧调用几十次"的频率下可忽略,换来的是代码可读性(virtual void alert(...) 比函数指针声明清楚得多)。

纯虚 vs 有默认实现的虚函数。 get_ticks_msec() 是纯虚(= 0),平台必须实现;has_feature() 有默认实现(返回 false),平台可选覆盖。这个区分编码了"哪些能力是平台必须的"——时钟、进程创建、窗口管理是硬要求,而"是否支持触摸屏"这种特性查询可以默认否定。

平台实现:以 Linux 为例

platform/linuxbsd/os_linuxbsd.cpp 里的 OS_LinuxBSD 继承 OS,实现全部纯虚函数:

class OS_LinuxBSD : public OS {
    DisplayServerX11 *display_server = nullptr;  // X11/Wayland 窗口
    AudioDriverPulseAudio audio_driver_pulse;     // PulseAudio/PipeWire
    // ...
public:
    virtual void initialize() override {
        display_server = memnew(DisplayServerX11(...));
        audio_driver_pulse.init();
    }
    virtual uint64_t get_ticks_msec() const override {
        struct timespec ts;
        clock_gettime(CLOCK_MONOTONIC, &ts);
        return ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
    }
    virtual String get_name() const override { return "Linux"; }
    // ...
};

关键点在构造函数——OS_LinuxBSD 的构造函数里调 OS::set_singleton(this),把自己注册为全局单例。这发生在平台 main() 的第一行(上一篇讲的启动流程),之后任何代码调 OS::get_singleton() 拿到的都是这个派生类实例。虚函数机制保证调用自动分发到平台实现。

组合而非继承的细节。 OS_LinuxBSD 不直接实现显示和音频,而是持有 DisplayServerX11 和 AudioDriverPulseAudio 成员。OS 层管的是"平台生命周期和全局服务",具体的窗口和音频是更下层的驱动抽象。这种"OS 组合 DisplayServer、DisplayServer 再组合 X11/Wayland 后端"的三层结构,让 Wayland 支持可以只改 DisplayServer 层而不动 OS 接口。

线程与同步原语的平台封装

core/os/thread.h 和 mutex.h 是另一组平台抽象,但实现方式不同——不是虚函数,而是编译期选择:

// core/os/mutex.h
#ifdef PTHREADS_AVAILABLE
#include <pthread.h>
class Mutex {
    pthread_mutex_t mutex;
public:
    void lock() { pthread_mutex_lock(&mutex); }
    void unlock() { pthread_mutex_unlock(&mutex); }
};
#else
// Windows SRWLOCK 实现
#endif

为什么这里不用虚函数?性能。互斥锁是引擎里最高频的同步操作(每帧几千次 lock/unlock),虚函数分派的开销不可接受。编译期选择意味着每个平台编译出专用的 Mutex 类,没有间接层。代价是 Mutex 的 ABI 跨平台不一致——但这没关系,Mutex 不暴露给 GDExtension,只在引擎内部用。

这是"虚函数 vs 编译期多态"的决策案例:调用频率决定用哪种。OS 接口每帧几十次,虚函数没问题;Mutex 每帧几千次,必须内联。

平台检测:has_feature 的运行时查询

OS::has_feature("mobile") 这种查询是运行时分发,不是编译期宏。实现是字符串比较:

bool OS_LinuxBSD::has_feature(const String &p_feature) const {
    if (p_feature == "linux" || p_feature == "unix") return true;
    return OS::has_feature(p_feature);  // 基类查通用特性
}

为什么不用 #ifdef LINUX?因为 GDScript 和编辑器插件需要运行时查询——脚本里 OS.has_feature("web") 决定要不要显示"下载"按钮,这个判断必须在运行时。编译期宏只适合"这段代码在这个平台不编译"的场景(如 X11 特有的代码),运行时查询适合"同一份代码根据平台走不同分支"。

两套机制并存:C++ 内部用 #ifdef(性能关键路径),脚本和配置系统用 has_feature(灵活性优先)。

OS 抽象的边界:什么不在 OS 层

不是所有平台差异都归 OS 管。引擎里有明确的"不属于 OS"清单:

  • 渲染 API:Vulkan/D3D12/Metal 由 RenderingDeviceDriver 抽象,不归 OS——因为"Linux 上可以跑 Vulkan 也可以跑 GLES3",这不是平台属性是驱动属性;
  • 输入设备:手柄/触屏的差异由 Input 单例(core/input/input.h)管,OS 只提供原始事件;
  • 音频 API:PulseAudio/ALSA/CoreAudio 由 AudioDriver 抽象,OS 只管"启动音频线程"。

OS 层的职责边界是"进程级全局服务":时钟、进程创建、系统对话框、环境变量、可执行文件路径。凡是"可以有多个实例"或"运行时可选"的服务,都下沉到更专门的抽象层。

单例 vs 依赖注入的权衡

OS 单例的分发路径:从调用到平台实现 业务代码 OS::get_singleton() 静态单例指针 OS *singleton 虚函数分派 vtable 跳转 OS_Linux 平台实现 单例模式的实际影响 优点 1:任意代码可达,不用在几百个类之间传递 OS 引用 优点 2:初始化顺序可控——平台 main() 第一行注册单例,之后全局可用 优点 3:测试时可替换——单元测试里 set_singleton(MockOS),无需改业务代码 缺点 1:隐式依赖——看类接口不知道它依赖 OS,要读实现才知道 缺点 2:线程安全靠约定——OS 单例本身无线程保护,依赖"只读操作线程安全"的实现 缺点 3:多实例不可能——不支持"一个进程跑两个 OS 抽象"(沙盒/测试场景受限) 引擎的选择:全局服务用单例(OS/RenderingServer),局部依赖用传参(SceneTree 节点) 判断标准:生命周期是否等于进程生命周期 + 是否真的全局唯一

调试平台问题的入口

排查"这个 API 在 macOS 上行为不对"这类问题的标准路径:

  1. 先确认 OS 层行为:OS::get_singleton()->get_name() 和 has_feature() 的输出是否符合预期;
  2. 找到平台实现:platform/macos/os_macos.mm 里对应虚函数的实现;
  3. 检查下层驱动:如果是显示/输入问题,继续追到 DisplayServerMacOS;如果是音频,追到 AudioDriverCoreAudio。

平台差异 bug 的 80% 集中在 DisplayServer 层(窗口/输入),OS 层本身(进程/时钟/对话框)相对稳定。这是抽象边界划得好的副作用——易变的部分被隔离在更下层的专门抽象里。

本文代码引自 Godot 4.2.2-stable 的 core/os/os.h、core/os/mutex.h、platform/linuxbsd/os_linuxbsd.cpp,均有删减。