摘要服务端不停机热更的两种 C++ 实现方案:函数指针 + DLL 动态加载、以及汇编层 HotPatch 字节替换。前者实现简单、但对代码结构有侵入;后者对业务透明、但依赖编译期参数配合与汇编改写。
服务端要做到不停机热更,最直接的思路是引入脚本语言,利用动态特性替换函数实现。C++ 项目里也可以借用这个思路:把函数调用改为函数指针调用,通过加载新的 DLL / SO 并替换指针指向,实现不停机切换逻辑。下文以 Windows 平台为例,介绍两种落地方案。
方案一 动态加载 DLL,替换函数指针
核心思路:exe 主映像通过运行时加载 DLL 获取新版本函数地址,原调用点改为经由函数指针转发,切换指针即切换实现。
实现流程
- exe 把要热更的逻辑抽成函数指针形式的调用点。
- 新版本逻辑编译为独立 DLL。
- 运行时
LoadLibrary加载 DLL,GetProcAddress取到新函数地址。 - 原子写入函数指针,后续调用自动走新逻辑。
示例代码
exe 主体:
DLL 新逻辑:
运行结果:
方案要点
| 要点 | 说明 |
|---|---|
| 调用方式 | 必须以函数指针调用,整个机制依赖指针转发。 |
| 内存关系 | DLL 与 exe 内存空间相互独立,类似不同线程的栈——不能直接访问,需通过地址指针互相引用。 |
| 数据共享 | 通过函数参数传递上下文。示例以 ProcessMemoryContex 传递堆内存信息,也可额外维护一个全局指针避免重复参数传递。 |
| 脏数据风险 | 每次加载 DLL,内部全局变量会重新初始化,多次热更后容易产生脏数据、混淆内存状态。 |
| ABI 约束 | 替换函数时必须保证堆栈平衡——参数与返回值字节数、调用约定都不能变。 |
| 编译器陷阱 | 注意 inline 展开带来的替换失效,以及闭包捕获参数的内存生命周期问题。 |
适用场景
- 无状态进程 几乎不持有长期内存数据,主数据通过 NoSQL 存储,运行时临时状态仅在闭包 / 协程上下文中维护。流程结束即释放,天然适合本方案。
- 函数式 / 入口式工作流 典型如 RPC 调用:网络层把上下文通过参数传入,RPC 函数内部构成独立子环境,替换入口函数即相当于替换整段流程。
- 不适合面向对象模型 OOP 中大量的内部函数调用与隐式
this指针让数据迁移变得棘手。并非完全无解,但通常要引入反射等机制做大面积替换,成本偏高。
方案二 汇编层 HotPatch 替换
除了在 C++ 层经由函数指针跳转,还可以直接在汇编层改动——修改可执行映像在内存中的字节,实现"偷梁换柱"。
原理剖析
观察函数开头 2 字节:66 90(即 xchg ax, ax),这是编译器在开启 /hotpatch 选项后自动填入的占位指令。
既然是占位,就可以把这 2 字节替换为跳转指令。由于只有 2 字节,只能使用短跳 EB(跳转范围 ±128 字节)。所以还要在链接期于原函数上方预留足够空间,用来安放真正要跳到的自定义指令。
关键编译参数
| 参数 | 作用 |
|---|---|
/hotpatch | 函数首部填入 2 字节 xchg ax, ax,为短跳预留占位。 |
/FUNCTIONPADMIN:5 | 函数前填充 5 字节,用来安放自定义的长跳指令。 |
Win32 下这些地址受只读保护,需要先通过 VirtualProtect 开启写入权限才能改写。
实施步骤
- 编译期开启
/hotpatch与/FUNCTIONPADMIN,生成可打补丁的映像。 - 运行期调用
VirtualProtect解除目标内存的写保护。 - 在函数前填充区写入跳转到新函数的 5 字节长跳指令。
- 将函数首部
66 90改写为 2 字节短跳,指向上一步填入的长跳指令。
执行效果:
延伸阅读:/hotpatch (Create Hotpatchable Image)、/FUNCTIONPADMIN (Create Hotpatchable Image)。
方案要点
| 要点 | 说明 |
|---|---|
| 性能开销 | 每次调用多出 2 次跳转,每个参与编译的函数额外多出 2 字节占位,存在轻微性能损耗。 |
| 侵入性 | C++ 代码零改动,业务层完全无感知。OOP 同样适用,this 指针按原规则传递。 |
| 原子性 | 打补丁分两步:先在填充区写 5 字节长跳(不影响运行),再改函数首部 2 字节短跳。第二步必须原子,否则会有竞态。 |
| 体积 | 由于函数前有填充字节,可执行文件与运行时内存占用略有增大。 |
两种方案对比
| 维度 | 函数指针 | HotPatch |
|---|---|---|
| 代码侵入 | 高,需改调用方式 | 低,业务代码零改动 |
| OOP 适配 | 差 | 好 |
| 运行时开销 | 一次指针解引用 | 两次跳转 + 2 字节占位 |
| 原子性保障 | 指针写入天然原子 | 需要保证 2 字节写入原子 |
| 实现难度 | 简单 | 较复杂,依赖编译参数与汇编改写 |
| 脏数据风险 | 高(全局变量重复初始化) | 低 |
实施建议
- 可以通过反射遍历热更函数地址列表批量更新;追求最小更新范围时,则手动维护替换清单。
- GamePlay 层通常单线程运行,热更过程的线程安全有天然保障。
- Linux 下实现类似机制需要编译器支持;不同平台的填充字节数与跳转指令需重新评估。