1 背景介绍
我们知道游戏服务器经常有小版本发布,如果每次一点小的改动就重启,对于游戏业务来说或多或少是有损服务的,如果是有状态的进程影响更大,因此服务器支持热更能够使得服务更加稳定、用户体验更好。
2 不同热更方案
不同的服务器有各自的热更方式,比如 java 热更(替换内存中已经加载好的 class 字节码)、内嵌 lua 热更(通过 lua 提供的 require 机制强制刷新已加载好的模块)、c 热更(例如通过修改现有函数最开始的指令 jmp 至新函数地址处,其中涉及的细节比较多、还有一种针对 so 的热更方式修改进程内存中的 got 表,跳转至新函数从而达到热更这也正是本文要介绍的方法)。
3 原理介绍
c 程序在运行时有两种方式加载动态连接库:隐式链接和显式链接。 (1) 隐式链接就是在编译的时候使用-l 参数链接的动态库,进程在开始执行时就将动态库文件映射到内存空间中。 (2) 显示链接使用 libdl.so 库的 api 接口在运行中加载和卸载动态库,主要的 api 有 dlopen、dlclose、dlsym、dlerror。
动态修改 got 表的方式适用于上面描述中的第一种情况。
针对隐式链接 so 库的函数调用和数据访问方式做了个总结:
(1) 模块内部的非静态函数调用 使用 plt 的方式访问
(2) 模块内部的静态函数调用 使用相对地址的方式访问
(3) 模块间的函数访问 使用 plt 的方式访问
(4) 模块内部的全局变量访问 使用 got 表的方式进行访问
(5) 模块内部的静态变量访问 使用相对地址的方式访问
(6) 模块间的数据访问 使用 got 表的方式进行访问
针对其中函数调用的情况总结下来只有模块内部的静态函数调用不会通过 plt 的方式去调用,另外两种函数调用方式都适用于本文介绍的热更方法。
先来分析下什么是 plt 和 got,通过一个最简单的例子来看下。
fun 函数是定义在 lib1.so 里面的函数
可以看到调用 fun 的时候并不是直接通过函数地址跳转而是采用了 fun@plt 的方式,这里的 plt 就是上文讲的 plt,那为什么要用这个呢?因为程序在链接动态库的时候并不知道进程启动加载链接库之后函数的地址在哪里,所以上面代码里面的 fun 函数地址是没法确定和填充的,那么什么时候可以确定函数的地址呢?进程运行加载完动态库之后可以找到函数地址,于是就把真正解析函数地址的时机留到了运行时去处理。没错这个 plt 就是用来干这件事的,plt 通过一段代码指令来完成动态库函数的运行时重定位,但是这样的话还面临两个问题:
1 调用函数的地方在代码段,目前的操作系统中默认情况下代码段没有可写属性,不过数据段是可以修改的
2 进程 mmap 动态库到内存里面使用的是 private 方式(从下图代码中可以看出),如果修改了代码段里面的内容,那么就会触发写时复制机制,这些代码段内容就无法做到系统内所有进程共享。
所以 fun 函数地址不能回写到代码段,而应该写到数据段,还记得上面提到的 got 吗?没错 got 就是在数据段的,got 里面会记录 fun 的地址,继续刚刚函数运行的流程往下看。
fun@plt 第一条指令就是 jmp 到 [email protected],这个地方记录的的是记录 fun 函数地址的 got 表项,不过第一次调用的时候 fun 在 got 里面的表项还没初始化,所以跳转到 fun@plt 后面的指令,后面的指令会真正解析 fun 函数地址,如果每次调用都去解析一遍会比较浪费,所以在第一次解析成功之后会覆盖 got 表项,那么再次调到这里就不用再走后面的解析流程了,0x601018 这个地址是 fun 的 got 表项地址,目前记录的是 plt 后面的指令地址,下图中在正确解析之后覆盖 0x601018 内容更新为 fun 真实的地址。
等我们再次调用这个函数就直接 jmp 到 fun 函数了。画了个调用流程图:
可以看出来调用完之后 got 表项里面就是真实的 fun 函数地址,这个在数据段是可写的,热更的时候把这个表项地址覆盖为新的函数地址即可。
4 数据继承问题
下面是进程在虚拟内存中的布局图:
热更新之后对于数据继承问题我把数据拆分成几种类型依次说下: (1) 全局变量 -wl,-export-dynamic 选项可以把原进程数据导出。 (2) 静态变量 这个随着库加载而确定地址的,而且 linux 有 aslr 机制每次启动都不一样有随机性,但是离库 mmap 的首地址偏移是固定的,所以使用 offset 方式访问。 (3) 局部非静态变量 这个生命周期和函数调用栈帧一致,函数内部维护堆栈平衡即可,访问权限也只仅限函数调用期间的函数内部访问,所以不需要处理。
5 设计与实现
void fun()->void fun_v_1()
为了访问各种类型的数据和函数我特意定义了全局变量、静态变量、局部静态变量
(1) 首先把 1.c 编译成 lib1.so
gcc -fpic -shared -o lib1.so 1.c
(2) a.out 隐式链接 lib1.so,并且调用 fun 函数
gcc main.c -l./ -l1 -g -wl,-rpath ./ -wl,-export-dynamic -ldl
./a.out
(3)热更 fun 函数,先定义新函数 fun_v_1 原型和 fun 一样,编译生成 lib2.so
(4) a.out 捕获 sigusr1 信号,在这个函数里面热更新 fun 函数,赋值 got 表
(5)运行效果打印了变量地址可以发现热更前后全局变量和静态变量地址都是一致的,热更之后再次调用 fun 进入到函数 fun_v_1 了
6 与 textcode jmp 热更方案对比
这种做法是通过 ptrace attach 到进程,先保存当前的寄存器信息,然后修改 rip 地址跳转到 dl_open 函数,dl_open 函数加载 so 的时候会先执行 init 段代码,然后在 init 里面找到原函数地址修改最开始的指令为 jmp 到新的热更函数,完成之后恢复之前保存的寄存器,detach 进程,后面再次调用原函数就会跳转到新函数从而完成热更,下面对比两种方案的优缺点。
(1) 性能方面 可以看到修改 got 表之后性能没有任何损失和之前的调用流程完全一样,textcode jmp 方式多了一次 jmp,另外现代 cpu 会预取指令,jmp 的话会使得预取到的指令失效 (2) 适用场景 修改 got 表方式只能应用于隐式加载 so 的场景,textcode jmp 适用的场景更广 (3) 便捷与安全性方面 修改 got 表方式修改的是具有可写属性的数据段,对进程没有影响,textcode jmp 修改了原本没有可写属性的代码段需要 attach 到原进程从过程上来说更为凶残(^^)一点
7 总结与后续
从上可知修改 got 表确实可以热更 so,但是也有一些限制只能热更使用 got 跳转的函数,对于其他函数还是要通过其他的比如入侵式的修改函数指令方式热更,从性能上来说热更前后性能一致,没有多余的指令,但是如果用在多线程里面需要注意一点,第一次调用解析函数覆盖之前热更函数,会出现解析完之后覆盖回去的问题如下图中 a 线程第一次调用这个函数在即将解析成功之前(严格来说执行到第 3 步之后第七步之前),b 线程热更,等 a 线程解析完成返回的时候会覆盖回去,导致热更失效。
以上就是对 got 热更 so 的探讨,后期的话准备进一步工程化,规范一些流程和操作,使得可以方便的在项目中去使用。