一种基于动态库C++热更新的方式

c++程序的热更新一直是让c++开发者头疼的事情,一般场景下若线上业务逻辑需要修复,常规更新方式是重启进程,整体风险较高。

造成这种现象原因之一是c++比较“古老和灵活“。区别于脚本语言以及一些现代诞生出来就支持热更新的语言,它有其特有的适用场景与技术积累(褒义和贬义都有)。

写这篇文章的时候,c和c++两兄弟仍然可以位居编程语言流行榜前5:

tiobe-2023.10编程语言流行排行

一些成熟的c++热更新方式

这里热更新定义范围会放的宽一些,理解成对业务影响小的更新方式

虽然c++自身不具备热更新的能力,但是也有一些其他手段来达到此目标:

  1. 逻辑设计成无状态,平滑重启,现在随着容器与无服务推广,应用面更广了;
  2. 子进程替换,nginx在使用;
  3. 所有逻辑编入动态库,利用动态库热加载机制;
  4. 利用lua脚本语言处理逻辑,skynet在使用,很多游戏服务器在使用此方案;

这篇文章会介绍一种基于动态库的方式,和上面第3点主要区别在于对于原工程侵入较小,底层机制也有所不同。

一个程序例子

系统环境是Debian 8.7 x86_64,因为编译环境不同,偏移地址等信息会有区别。

完成程序:https://github.com/lanyutc/cpp_hotfix

//为了简洁隐去了头文件
int TestClass::Incr(int incrVal)
{
    m_val += incrVal;
    std::cout << "After Incr Val:" << m_val << std::endl;
}

int TestClass::Decr(int decrVal)
{
    m_val += decrVal;  //FIXME 逻辑错误,减法变成加法
    std::cout << "After Decr Val:" << m_val << std::endl;
}

int main()
{
    signal(SIGUSR1, signalUserHander);

    while (1) {
        TestClass stTest(100);
        stTest.Incr(100);
        stTest.Decr(200);
        std::cout << "-----" << std::endl;
        sleep(1);
    }
    return 0;
}

程序逻辑比较简单,使用signalUserHander函数监听信号SIGUSR1(10),每1秒循环创建类TestClass,构造初始化m_val=100,TestClass::Incr增加m_val+=100TestClass::Decr再减去m_val-=200,最终结果为0。

但是因为TestClass::Decr逻辑错误,减法变成了加法,未及预期。

热更新原理

假设我们示例文件编译产物是test(增加-g编译),使用objdump可以看到:

$objdump -S test

0000000000400f8a <_ZN9TestClass4DecrEi>:
int TestClass::Decr(int decrVal)
{
  400f8a:       55                      push   %rbp
  400f8b:       48 89 e5                mov    %rsp,%rbp
  400f8e:       53                      push   %rbx
  400f8f:       48 83 ec 18             sub    $0x18,%rsp
  400f93:       48 89 7d e8             mov    %rdi,-0x18(%rbp)
  400f97:       89 75 e4                mov    %esi,-0x1c(%rbp)
//...省略一些

0000000000400f4e <main>:
int main()
{
//...省略一些
stTest.Decr(200);
  401017:       48 8d 45 f0             lea    -0x10(%rbp),%rax
  40101b:       be c8 00 00 00          mov    $0xc8,%esi
  401020:       48 89 c7                mov    %rax,%rdi
  401023:       e8 62 ff ff ff          callq  400f8a <_ZN9TestClass4DecrEi>
std::cout << "-----" << std::endl;
//...省略一些
}

main函数callq 400f8a <_ZN9TestClass4DecrEi>进入了TestClass::Decr函数处理(地址400f8a),这个方案的核心思路就是把上面调用TestClass::Decr的逻辑替换掉。

那么怎么做?

  1. 程序已在运行中,我们已知需要更新的函数是TestClass::Decr
  2. 通过预设的信号处理函数signalUserHander,触发加载含有正确逻辑的动态库(这里为了演示方便使用了信号,只要能接受外部指令,方式不限);
  3. 解析动态库中的符号表,使用正确的TestClass::Decr逻辑替换掉错误的;
  4. 替换方案是:在错误的TestClass::Decr覆盖一段汇编代码,将调用逻辑跳转到正确逻辑;

为此,我们增加以下三段代码:

代码1:正确的TestClass::Decr函数

int TestClass::Decr(int decrVal) //Decr_hotfix
{
    m_val -= decrVal;    //正确的减法逻辑
    std::cout << "After Decr Val:" << m_val << std::endl;
}

代码2:动态库读取与函数逻辑跳转

//prefix和posfix是汇编的转义,目的将需要调用的新地址放入寄存器rax,然后跳转
//为什么使用rax,不影响栈
const char prefix[] = { '\x48', '\xb8' };  //MOV new_func %rax
const char postfix[] = { '\xff', '\xe0' };  //JMP %rax

void* loadSymbolAddr(const char *path, const char *symbol)
{
    void *handler = dlopen(path, RTLD_NOW);
    char *err = dlerror();
    if (handler == NULL || err != NULL)
    {
        std::cerr << (path ? path : "test") << " dlopen failed!" << err << std::endl;
        exit(-1);
    }

    void* func = dlsym(handler, symbol);
    err = dlerror();
    if (err != NULL)
    {
        std::cerr << (path ? path : "test") << " dlsym failed!" << err << std::endl;
        exit(-1);
    }
    return func;
}

void HotfixFuncByAddr(void *oldFunc, void *newFunc)
{
    //得到机器PAGE_SIZE
    size_t pageSize = getpagesize(); 

    //执行长度mov+函数地址+jmp
    //
    size_t instructionLen = sizeof(prefix) + sizeof(void *) + sizeof(postfix); 

    //man mprotect ref:addr must be aligned to a page boundary
    char *alignAddr = (char *)oldFunc - ((unsigned long long)oldFunc % pageSize); 

    //开启代码可写权限
    int ret = mprotect(alignAddr, (char *)oldFunc - alignAddr + instructionLen, PROT_READ | PROT_WRITE | PROT_EXEC);
    if (ret != 0)
    {
        std::cerr << "mprotect write failed!" << ret << std::endl;
        exit(-1);
    }

    //将跳转指令写入原函数开头
    //覆盖并打乱了原来函数的内容
    memcpy((char *)oldFunc, prefix, sizeof(prefix));
    memcpy((char *)oldFunc + sizeof(prefix), &newFunc, sizeof(void *));
    memcpy((char *)oldFunc + sizeof(prefix) + sizeof(void *), postfix, sizeof(postfix));

    //关闭代码可写权限
    ret = mprotect(alignAddr, (char *)oldFunc - alignAddr + instructionLen, PROT_READ | PROT_EXEC);
    if (ret != 0)
    {
        std::cerr << "mprotect read failed!" << ret << std::endl;
        exit(-1);
    }
}

这里的方案采取了覆盖原函数内容的方式,可以理解成修改了地址400f8a开始的内容(同时破坏了原来函数):

//假设这里是原函数,经过上面memcpy后,内容被覆盖了(根据函数内容不是全部覆盖,这里只是示例)
int TestClass::Decr(int decrVal) 
{
    return TestClass::Desc(decrVal); //Decr_hotfix in so    插入这样一段逻辑
    //m_val += decrVal;//被覆盖且破坏的内存
    //std::cout << "After Decr Val:" << m_val << std::endl;//被覆盖且破坏的内存
}

这里会有疑问:为什么不在callq处直接替换地址,我们假设正确函数的偏移地址是 7fb0177d4990callq 400f8a(7fb0177d4990) <_ZN9TestClass4DecrEi>),而是采用函数地址内容覆盖跳转的方式?

目的为了保护并维护堆栈,特别是在函数有嵌套使用的情况下。

代码3:信号处理逻辑

分别编译例子二进制和需要热更新的动态库 :

 $g++ -o test test.cpp loadso.cpp -ldl -rdynamic -g 
 $g++ -fPIC -shared -o libhotfix.so hotfix.cpp 

找到二进制程序与修复动态库的修复函数符号:

$nm test | grep De
0000000000400f8a T _ZN9TestClass4DecrEi

$nm libhotfix.so | grep De
0000000000000990 T _ZN9TestClass4DecrEi

我们将函数符号写入信号处理函数:

void signalUserHander(int signum)
{
    std::cout << "Recv Signal User" << std::endl;
    void *libFuncAddr = loadSymbolAddr("./libhotfix.so", "_ZN9TestClass4DecrEi");
    if (!libFuncAddr)
    {
        std::cerr << "libFuncAddr is null!" << std::endl;
        return;
    }

    void *mainFuncAddr = loadSymbolAddr(NULL, "_ZN9TestClass4DecrEi");
    if (!mainFuncAddr)
    {
        std::cerr << "mainFuncAddr is null!" << std::endl;
        return;
    }

    std::cout << "libfuncaddr:" << libFuncAddr << "|mainFuncAddr:" << mainFuncAddr << std::endl;
    HotfixFuncByAddr(mainFuncAddr, libFuncAddr);
};

编译验证

$./test
After Incr Val:200
After Decr Val:400
-----
After Incr Val:200
After Decr Val:400
-----

$ps aux | grep test
20839
$kill -10 20839
After Incr Val:200
After Decr Val:400
-----
Recv Signal User
libfuncaddr:0x7fb0177d4990|mainFuncAddr:0x400f8a
After Incr Val:200
After Decr Val:0

可以看到现在结果可以正确的输出0。

一些其他注意问题

  • 这种方式不是线程安全的,被修复的函数若被多线程调用会有问题;
  • 注意核心函数mprotect使用限制:在man手册里有一段NOTES:On Linux it is always permissible to call mprotect() on any address in a process’s address space (except for the kernel vsyscall area)
  • 若修复函数内部有静态变量,会被重置;

(全文结束)


转载文章请注明出处:漫漫路 - lanindex.com

Leave a Comment

Your email address will not be published.