c++程序的热更新一直是让c++开发者头疼的事情,一般场景下若线上业务逻辑需要修复,常规更新方式是重启进程,整体风险较高。
造成这种现象原因之一是c++比较“古老和灵活“。区别于脚本语言以及一些现代诞生出来就支持热更新的语言,它有其特有的适用场景与技术积累(褒义和贬义都有)。
写这篇文章的时候,c和c++两兄弟仍然可以位居编程语言流行榜前5:
一些成熟的c++热更新方式
这里热更新定义范围会放的宽一些,理解成对业务影响小的更新方式。
虽然c++自身不具备热更新的能力,但是也有一些其他手段来达到此目标:
- 逻辑设计成无状态,平滑重启,现在随着容器与无服务推广,应用面更广了;
- 子进程替换,nginx在使用;
- 所有逻辑编入动态库,利用动态库热加载机制;
- 利用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+=100,TestClass::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的逻辑替换掉。
那么怎么做?
- 程序已在运行中,我们已知需要更新的函数是TestClass::Decr;
- 通过预设的信号处理函数signalUserHander,触发加载含有正确逻辑的动态库(这里为了演示方便使用了信号,只要能接受外部指令,方式不限);
- 解析动态库中的符号表,使用正确的TestClass::Decr逻辑替换掉错误的;
- 替换方案是:在错误的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处直接替换地址,我们假设正确函数的偏移地址是 7fb0177d4990( callq 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