cpp代码热更方案
服务端要实现不停机热更,简单好用的方案是接入脚本语言写逻辑来利用动态语言特性进行热更。达到不停机修改游戏逻辑的目的。
脚本语言中动态特性可以吧函数进行替换来达到更新函数从而实现逻辑变更。在cpp中也可以有类似的思路。
对于函数的调用我们可以通过函数指针来进行访问,指针一边也达到了函数替换的目的。在这里可以通过加载新dll或so,把函数地址提取出来。 通过windows平台来举例说明具体实施细节。
进程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指针,数据迁徙会很棘手。当然也不是没解决的办法,可以通过一些比较复杂的手法进行大面积替换,比如加入反射机制。
对于通过记录函数指针,修改指针本身的方式进行跳转替换,这种是在cpp代码层进行的机制。我们从汇编层次来看的话,可以通过修改可执行二进制映像在内存中的数据,从而达到”偷梁换柱”的目的。
函数地址00B45EB0,首2字节数据为 66 90 (xchg ax,ax)这段指令是打开编译器/hotpatch自动填充的,
这2字节数据可以写入自定义数据进行跳转操作,跳转到替换的指令地址,因为是2字节因此只能短跳EB加上+-128跳转。 在编译器进行链接操作的时候,可以在原函数上面填充空数据,来允许在段内插入汇编指令达到能够短跳的目的。
在vistual studio中,可以通过/FUNCTIONPADMIN设置该填充区域大小,以生成填充后的映像文件,参数5为填充5字节。
在win32中,这些地址是保护的,需要打开写入权限来进行数据填充。
运行后结果:
关于/hotpatch可以进一步学习可以查阅 /hotpatch (Create Hotpatchable Image) | Microsoft Docs
关于/FUNCTIONPADMIN可以进一步学习可以查阅 /FUNCTIONPADMIN (Create Hotpatchable Image) | Microsoft Docs
我们现在来看看这个方案相比函数指针的方案有什么区别:1.HotPatch后的函数有2次额外的跳转指令,参与编译的函数有额外2字节的操作指令,因此性能上慢了一些。
2.对于cpp层来说,没有感知,这就意味着增加该机制对于大部分代码来说无需修改。面向对象编程模型中也可以适用该方案,对象内存都通过this指针传递。
3.HotPatch过程中,第一个操作是新添加5字节跳转指令,第二个操作是修改原函数首部2字节为短跳指令。修改第一个没什么影响,第二个操作如果不是原子操作的话,那么线程安全性是得不到保障。
4.因为要在函数前进行填充数据,因此可执行二进制文件大小和内存占用会多一点点。
在实施细节中可以通过反射来获取热更函数地址列表来遍历更新,如果为了最小化更新,还可以人为指定更新的函数列表而不是粗暴的方式。一般来说,GamePlay代码几乎都是单线程运作的,因此热更过程中的线程安全性可以有一定的保障。
在Linux下需要编译器支持这种操作。
TODO