热重载核心是运行时安全卸载旧模块并迁移状态,而非简单重编译加载;需用函数指针表替代虚函数、原子同步确保无执行中调用、版本化序列化协议迁移对象状态,并手动管理调试符号。

热重载不是链接时替换,而是运行时模块卸载+重加载
直接用 std::system("g++ -shared -o game_logic.so ...") 编译再 dlopen 加载,不等于热重载——你得先安全卸载旧符号、清理状态、再映射新代码,否则大概率 crash。核心难点不在编译或加载,而在“如何让旧函数指针失效”和“如何迁移运行中对象的状态”。游戏逻辑里常见实体(Player、Enemy)的成员函数被重载后,不能简单覆盖 vtable,必须用间接调用层隔离。
用函数指针表(Function Pointer Table)绕过 vtable 硬绑定
别让游戏对象直接继承含虚函数的基类;改用纯数据结构 + 外部函数表。例如:
struct GameLogicAPI {
void (*update_entity)(Entity* e, float dt);
bool (*on_collision)(Entity* a, Entity* b);
void (*init)(void);
};
主程序只持有 GameLogicAPI*,每次重载后更新该指针指向新 SO 的导出表。这样所有调用走的是数据而非编译期绑定的虚函数,避免因 vtable 偏移错位导致非法跳转。
- 旧 SO 卸载前,确保没有线程正在执行其中任意函数(可用原子计数器 + 自旋等待)
- 函数表本身需在 SO 中定义为全局变量,并用
__attribute__((visibility("default")))导出 - 不要在函数表里放 lambda 或捕获上下文的 std::function——它们无法跨模块 ABI 兼容
dlopen 必须带 RTLD_NOW | RTLD_GLOBAL 标志
缺 RTLD_NOW 会导致符号解析延迟到首次调用,此时若旧 SO 已卸载,dlsym 可能返回 null 而不报错;缺 RTLD_GLOBAL 则新 SO 里定义的符号对后续 dlopen 的其他模块不可见,影响插件链式加载。
立即学习“C++免费学习笔记(深入)”;
- Linux 下用
dlclose前要确认引用计数为 1,否则可能只是减计数而非真正卸载 - macOS 的
dyld不支持真正卸载 dylib,只能靠NSAddImage+ 手动符号管理,实际项目中建议 Linux 优先 - Windows 上对应的是
LoadLibraryEx+FreeLibrary,但要注意 DLL 的 TLS 和构造函数不会被二次触发,状态迁移更难
状态迁移不能靠 memcpy,要用显式序列化协议
重载前后 Entity 内存布局很可能变化(字段增删、顺序调整、padding 改变),直接 memcpy 旧对象到新类型是未定义行为。正确做法是定义轻量级迁移接口:
// 在旧 SO 中实现 void save_state_to_blob(const Entity* e, uint8_t* out, size_t max_size); // 在新 SO 中实现 void load_state_from_blob(Entity* e, const uint8_t* blob, size_t size);
主程序在卸载前调用旧 SO 的 save_state_to_blob,加载新 SO 后调用其 load_state_from_blob。关键点在于:迁移协议必须版本化(比如加一个 uint32_t version 字段),且新旧 SO 都要能处理至少一个历史版本。
- 别依赖
sizeof(Entity)—— 它在重载前后几乎肯定不同 - 避免在迁移中调用新 SO 的任意非迁移函数(比如
init()),防止触发未就绪的初始化逻辑 - 如果逻辑模块用了全局变量(如配置缓存),需额外设计 reload hook,在
init()里重新读取或合并
热重载最易被忽略的其实是调试符号与行号映射:GDB/Lldb 默认只认第一次加载的 SO 符号,重载后断点会失效。需要手动用 add-symbol-file 加载新 SO 的 debug info,并注意地址偏移校准——这步没做,等于白搭。



















