Linux下c++热更新

我的文章会先发布到个人博客后,再更新到简书,可以到个人博客或者公众号获取更多内容。


背景介绍

我们都知道游戏服务器经常会有一些小版本或者线上问题紧急修复版本,对于游戏业务或多或少都有一些损害,特别是有些进程会携带有状态信息和维持和客户端的长连接。当你维护重启过于频繁的话会影响线上玩家的体验和留存,同时也影响所提供服务的稳定性。
因此就有了两种方案,热重启热更新,分别或一起保障所提供服务一定的稳定性。

  • 热重启:进程中使用共享内存保存状态或玩家信息,直接杀进程快速重启(对于和客户端直连的长连接服务还是会有影响)。
  • 热更新:不停进程,支持代码实时更新。

热更新方案

不同的技术栈有不同的热更新方式,如:

  1. java热更:替换内存中已经加载好的class字节码
  2. 内嵌lua热更: 通过lua提供的require机制强制替换已加载好的模块(由于性能不高,后续如果太耗性能会需要优化)
  3. python热更: 通过python提供的reload实现
  4. c++热更:
    • 父进程通过fork,产生子进程,然后子进程数据更改从而触发写时复制,复制父进程的数据,如果有socket,需要子进程处理新连接,父进程批量转移旧连接,或等待旧连接处理完毕后自杀(复杂)
    • 修改进程中的GOT表,跳转至新函数从而达到热更(优雅但有局限)
    • 通过汇编修改代码段中现有函数最开始的指令jmp至新加so中的函数地址(粗暴但适用性广)

修改代码段热更

  • 假设需要热更新的函数是funcA
  • 让进程在运行的过程中,通过信号或其他的机制,触发加载一个动态库
  • 动态库中包含定义了修复后的函数funcB
  • 通过加载动态库之后,解析动态库中的符号表,找到要修复的函数funcA和修复后的实现funcB的内存地址
  • 通过mprotect修改进程空间代码段的权限,添加写的权限。这样意味着可以修改funcA对应的代码段地址中的内容
  • 在funcA的内存地址插入一段汇编代码,来实现调用funcB函数或者跳转到funcB

归纳如下:

  1. 找到对应函数的符号和合适的汇编指令
  2. 将指令转换为机器码
  3. 修改代码段,替换函数地址

找到合适的汇编指令

在原函数对应的内存地址插入一段汇编代码,来实现调用新函数的逻辑或跳转到新函数。如果对汇编熟悉的小伙伴可能就知道对应的指令有cal和jmp,call会破坏栈平衡,故适用的是jmp。

  • jmp: jmp 函数地址,跳转到对应的函数,即修改EIP寄存器的值,不改变栈平衡
  • call: 将下一条指令的所在地址(即当时程序计数器PC的内容)入栈,修改EIP寄存器的值,会改变栈平衡。并且call会与ret对应
  • ret: 返回到CALL指令PUSH到栈顶的基址,并把栈顶的值POP出来
    在jmp之前,咱们需要将新函数的地址加载到内存,然后再jmp。故需要用到movq和寄存器rax(由于64位机中会将rdi,rsi,rdx,rcx,r8,r9作为函数传参的保存位置,rax会作为返回值,故使用rax不影响栈)。

将指令转换为机器码

通过写一个测试汇编文件,得到对应的机器码。
my_test_assembler.s

movq $0x1fffffffff,%rax
jmpq *%rax

gcc -c 汇编文件my_test_assembler.s生成对应my_test_assembler.o
然后通过objdum -d my_test_assembler.o 查看对应的机器码


机器码

得到对应的机器码以后咱们就可以开始修改程序的代码段来实现函数的更新。

修改代码段

void *dlmopen (Lmid_t lmid, const char *filename, int flags);
加载动态共享库

void *dlsym(void *handle, const char *symbol);
返回共享库中对应符号的地址

int mprotect(void *addr, size_t len, int prot);
用来修改对应进程中从addr开始的len长的内存叶保护权限,addr必须按内存叶大小对齐。
prot可以取以下几个值,并且可以用“|”将几个属性合起来使用:
1)PROT_READ:表示内存段内的内容可写;
2)PROT_WRITE:表示内存段内的内容可读;
3)PROT_EXEC:表示内存段中的内容可执行;

找到函数对应的符号

通过指令nm 和objdum -d可以得到所有符号信息。

主要实现

int replaceFunction(void* pSoHandle, const char* pSymbol) {
    void* pNewAddr = dlsym(pSoHandle, pSymbol);
    if (nullptr == pNewAddr) {
        fprintf(stderr, "get new addr, Error: %s\n" ,strerror(errno));
        return -1;
    }

    void* pOldAddr = dlsym(nullptr, pSymbol);
    if (nullptr == pOldAddr) {
        fprintf(stderr, "get old addr, Error: %s\n" ,strerror(errno));
        return -1;
    }

    size_t iPageSize = sysconf(_SC_PAGE_SIZE);
    uintptr_t pAlignAddr = (uintptr_t)(pOldAddr) & (~(iPageSize - 1));
    if (mprotect((void*)(pAlignAddr), 2 * iPageSize, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) {
        fprintf(stderr, "mprotect old addr, Error: %s\n" ,strerror(errno));
        return -1;
    }

    // 以下只支持64系统
    memset(static_cast<char*>(pOldAddr), 0x48, 1);
    memset(static_cast<char*>(pOldAddr)+1, 0xb8, 1);
    memcpy(static_cast<char*>(pOldAddr)+2, &pNewAddr, 8);
    memset(static_cast<char*>(pOldAddr)+10, 0xff, 1);
    memset(static_cast<char*>(pOldAddr)+11, 0xe0, 1);
    if (mprotect((void*)(pAlignAddr), 2 * iPageSize, PROT_READ | PROT_EXEC) != 0) {
        fprintf(stderr, "mprotect2 old addr, Error: %s\n" ,strerror(errno));
    }

    return 0;
}

void signalHandle(int) {
    void* pSoHandle = dlopen(SO_FILE, RTLD_NOW);
    if (nullptr == pSoHandle) {
        fprintf(stderr, "Error:%s\n", dlerror());
        exit(-1);
    }

    replaceFunction(pSoHandle, "_ZN4Role12getClassNameB5cxx11Ev");
    //replaceFunction(so_handle, "对应函数的符号");
}

程序通过接收相应的信号来触发函数的替换和热更操作。
代码地址

修改GOT表热更

后续完善

参考资料

欢迎关注我的其它发布渠道

个人博客
公众号

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容