服务端要实现不停机热更,最简单实用的方案是接入脚本语言来编写逻辑,利用动态语言的特性进行热更,从而达到不停机修改游戏逻辑的目的。

脚本语言的动态特性可以通过替换函数来实现逻辑变更。在 C++ 中也可以借鉴这一思路:对于函数调用,我们可以通过函数指针来进行访问,切换指针就达到了函数替换的目的。具体做法是加载新的 dll 或 so,提取其中的函数地址。以下以 Windows 平台为例说明具体实施细节。

方案一:动态加载 DLL 替换函数指针

进程 exe 映像文件可以通过动态加载 dll 获取 dll 中的函数地址,在函数调用时转向 dll 中的函数,从而实现逻辑变更。

下面是 exe 部分代码:

下面是 dll 热更的代码:

运行结果:

exe 可以在运行过程中通过加载 dll 切换指针来实现逻辑变更。

该方案的特点

  1. 调用方需要以函数指针的方式进行调用,因为整个机制依赖函数指针。
  2. exe 进程通过加载 dll 获取新地址。dll 内函数的内存空间与 exe 进程不同,关系类似于不同线程的栈内存——彼此无法直接访问,但可以通过地址指针来互相访问。
  3. exe 与 dll 之间的数据可以通过函数参数传递。示例中通过 ProcessMemoryContex 传递进程堆内存信息上下文,dll 和 exe 通过该变量进行数据读写,达到共享的目的。当然也可以再加一个指针变量来避免每次都通过函数传递。
  4. 加载 dll 时,dll 中的全局变量会进行初始化,容易产生脏数据,混淆内存,在多次热更后尤为明显。
  5. 替换函数指针时需要特别注意堆栈平衡,函数参数和返回值的字节数不能变,调用约定也不能改变。
  6. 编译器优化可能带来一些坑,例如函数被 inline 处理,以及闭包中参数捕获的内存管理需要额外注意。

适用场景分析

  1. 无状态进程:进程中几乎没有持久性数据存储,大多通过 NoSQL 存储。无状态进程运行中的临时数据可以通过闭包、协程上下文等方式保存,流程执行完毕后数据自然消失,非常适合这种热更方式。
  2. 入口式代码工作流程、函数式编程模型:如示例中的 RPC 函数调用,网络层通过函数调用传递上下文参数,RPC 函数内部构成一个独立的子环境,替换入口函数即可改变整个流程。
  3. 不适合面向对象编程模型:函数指针的访问方式不利于面向对象编程,大量的内部函数调用和封装在调用中的 this 指针使得数据迁徙非常棘手。当然也并非完全无解,可以通过引入反射机制等手法进行大面积替换,但复杂度较高。

方案二:汇编层 HotPatch 替换

除了在 C++ 代码层通过记录和修改函数指针进行跳转替换外,还可以从汇编层次出发,直接修改可执行二进制映像在内存中的数据,达到"偷梁换柱"的目的。

函数地址 00B45EB0,首 2 字节数据为 66 90(即 xchg ax,ax),这段指令是编译器开启 /hotpatch 选项后自动填充的。

这 2 字节数据可以写入自定义数据执行跳转,跳转到替换后的指令地址。由于只有 2 字节,只能使用短跳指令 EB(跳转范围 ±128 字节)。在编译器链接时,可以在原函数上方填充空数据,以便在段内插入汇编指令来满足短跳的距离要求。

在 Visual Studio 中,可以通过 /FUNCTIONPADMIN 设置填充区域大小,从而生成填充后的映像文件,参数 5表示填充 5 字节。

在 Win32 中,这些地址是受保护的,需要先开启写入权限才能进行数据填充。

运行后结果:

关于 /hotpatch 的进一步学习,可查阅 /hotpatch (Create Hotpatchable Image) | Microsoft Docs

关于 /FUNCTIONPADMIN 的进一步学习,可查阅 /FUNCTIONPADMIN (Create Hotpatchable Image) | Microsoft Docs

HotPatch 方案与函数指针方案的对比

  1. HotPatch 后的函数有 2 次额外的跳转指令,参与编译的函数有额外的 2 字节操作指令,因此性能上略有损耗。
  2. 对于 C++ 层而言,代码无感知,这意味着引入该机制对大部分代码无需修改。面向对象编程模型也同样适用,对象内存通过 this 指针传递,不受影响。
  3. HotPatch 过程分两步:第一步新增 5 字节跳转指令,第二步修改原函数首部 2 字节为短跳指令。第一步影响不大,但第二步如果不是原子操作,则无法保证线程安全。
  4. 由于需要在函数前填充数据,可执行二进制文件大小和内存占用会略有增加。

实施建议

在具体实施中,可以通过反射获取热更函数地址列表来遍历更新。如果希望最小化更新范围,还可以手动指定需要更新的函数列表,而不是全量替换。一般来说,GamePlay 代码几乎都是单线程运行的,因此热更过程中的线程安全性可以得到一定保障。

在 Linux 下,需要编译器支持这类操作。