探索一种C++二进制模块的热更新机制

    现如今,提供稳定可靠且能满足人民群众日益增长的物质文化需要的服务是互联网服务商的基本责任,所以服务端软件一定要够壮够强够灵活。服务程序一旦跑起来那就最好7X24小时地永远别挂,而且多变的、不停增长的用户需求也得尽快满足。可问题是,永远也别指望程序员写出没有bug的程序,任何架构师也没有水晶球可以预测将来的花花世界,无论
当时看起来多么完美的代码,将来也会因为种种原因要被修改(或者被丢弃?)。既然如此,那么我们或许应该想办法给程序加上点进化能力,让它能永不停歇地任劳任怨地工作,而同时还不断地反省自己、纠正自己并茁壮成长。

    本文是写给C++程序员的,如果你的工具是Lisp、Erlang、Ruby这样的动态语言,那么因为它们牛B的高级动态特性,你压根就用不着象我们这样,在二进制层干刀口舔血的勾当。

    简单来说,热更新就是程序边运行边更新。有人一定觉得我在故意(象专家那样)装B,把简单的问题搞得异常复杂,因为动态链接库本身就是可以动态加载和卸载的,只要在新的动态库build和部署好后通知程序重新加载一下不就搞定了?

    在这里,我要语重心长地告诉你们:第一,我没有装B,因为不想遭雷劈;第二,这种简单的方案在少数情况下是行得通的,但在大多数情况下却不行,因为实际的程序是代码与数据结构的正确结合,代码几乎总是要操作相应的数据结构。举个例子,A库的create函数创建了数据对象data,foo函数能正确地操作data,然后我们用B库热更新了A库,这样现在的create和foo函数都是B库实现的,而且新的create产生的数据对象与data(二进制布局)格式不同,新的foo也只能正确地操作新的数据对象;假设此后应用程序又需要用(B库的)foo操作由A库创建的data(我们无法避免,因为数据的生存周期是和应用逻辑息息相关的),这个时候严重的错误是不是就极可能发生?所以一个模块被热替换掉后,由它创建的所有数据对象也要跟着进行格式转换,转换为与新版本兼容的(二进制布局)格式。可是这又带来了新问题:如何才能找到所有由旧模块创建的数据对象?我们就象蹩脚数学家一样,把一个肮脏的问题转化成了另外一个肮脏的问题。

    换一种思路,如果在编程时愿意遵循一定的规范(规范是一种约束,但合理的约束却常常能提高总体的自由),而这规范使我们能避开找到所有旧版本数据对象这样的棘手问题,那么就能实现安全的热更新。

    本文建议的规范是采用类似COM、XPCOM这样的组件对象模型:程序由一个个的组件对象组成,每种对象提供了若干功能,外部只能通过对象的接口来使用相应功能。接口通常都用C++抽象基类来构建,从ABI(Application Binary Interface)的角度来看,C++抽象基类最关键的是规定了子类的虚函数表的布局。也可以用其它方法来构建接口机制,但是要保证与C++的虚函数表模型(g++, vc++等主流编译器在这方面的实现都是一样的)在ABI层上兼容。模块是物理上的对象容器,可以包含一个或多个组件对象。模块最常见的形式就是动态链接库(so或dll),本文探索的动态更新机制便是以模块为最小单位。

    从ABI层来看,通过组件对象接口来调用相关功能实际就是调用该对象虚表中对应项所指向的虚函数实现,正因为调用虚函数需要一个查表才能找到真正函数地址的中间操作,所以才使得我们能够hook住组件对象的调用,从而有机会把老版本的对象转换为新版本。那么如何才能hook对象的虚函数调用呢?方法很简单,修改虚表,让虚表的每一项都指向我们
的hook代码,这样修改之后,无论何时何地外部模块调用老版本对象都会首先执行hook代码。有朋友一定觉得这太hack、太不安全了:你咋就能确定虚表的位置,虚表的项数呢?是的,你的质疑一点都没错,如果不遵循任何规范,那么对林林总总的诡异的C++编译器抖这么点小机灵的确是一种非常危险的动作。但幸运的是,如果你采用XPCOM这样的组件对象模型,那么各大C++编译器在抽象类的虚表实现上难得的共识就可以保证我们找到虚表的正确位置,而且模型额外提供的(标准C++不具有的)运行期接口类型信息又可以保证我们安全地修改适当个数的虚表项。

    现在可以用个简单的例子来试验一下上述思路是否行得通。这里提供了代码的zip包下载,目前仅支持跑在Intel IA32架构CPU上的windows和freebsd平台(当然,其它unix平台也应该没问题,只是头文件的包含路径有可能需要调整)的实现,windows平台需要安装mingw。解压后在相应目录下运行
    gmake PLAT=windows

    gmake PLAT=freebsd
就会生成test程序。在freebsd平台下记得先用
    export LD_LIBRARY_PATH=./
将当前目录加入到动态库搜索路径中后再运行。

    例子包括如下几个源程序文件:nsIBase.h nsImp1.h nsImp1.cpp nsImp2.h nsImp2.cpp dynahook.cpp test.cpp 。

    nsIBase.h定义了一个抽象基类nsIBase,它代表着一个接口,其中包含有2个接口方法,分别是Hello和Foo。

    nsImp1.h和nsImp1.cpp共同组成了nsIBase接口的第一个版本的实现,它们会被build成一个名为libimp1.dll(unix下是libimp1.so,下同)的动态链接库,这就是一个模块;头文件中定义了nsImp1具体类,它继承自nsIBase抽象类。cpp文件中除了包括接口方法的具体实现,还有三个约定的导出函数:<1>create,相当于工厂方法,因为外部模块不知道组件对象的具体实现,所以只能用它来创建对象实例; <2>on_swapping,当该模块被新版本模块热替换时,该函数会被调用,并且传递给它新版本模块的格式转换函数指针作参数,该函数应当更改当前版本对象之虚表中的各项函数指针,指向特殊的hook代码,使得此后外部模块对这些对象的任何调用都会首先被hook截获,然后新模块的格式转换函数被执行,最后才进行通常的接口函数调用;<3>converter,格式转换函数,当该模块热替换别的模块时,它用于把老版本的对象转换为自己版本的对象,这就包括给对象设置新的虚表指针、转换数据块等等。因为nsImp1是第一个版本的实现,没有比它更老的实现需要它替换,所以它的converter是个空函数。

    nsImp2.h和nsImp2.cpp组成了nsIBase接口的第二个版本的实现,它们被build成libimp2.dll。nsImp2的组织结构和nsImp1差不多,只不过它的converter是真正要做实际的转换工作的。

    dynahook.cpp是最有趣的部分,它提供了一个函数:
void dynahook(void **p_old_vtbl, int method_count, converter_t cf),其中p_old_vtbl指向老版本对象的虚函数表,method_count指明表中有多少需要被监控的接口方法, cf是(新模块提供的)用于转换老版本对象格式的转换函数指针,作用就是产生特殊的hook代码来监控相应的接口方法调用。而这所谓特殊的hook代码其实也很简单,它的实现如下(80X86汇编,gnu assembler格式):

    pushl %ebp      ; 保存caller的stack frame base
    movl %esp, %ebp ; 设置自己的stack frame base
    pushl 8(%ebp)   ; this指针进栈
    call *converter ; 调用converter转换函数,它应该为对象设置新的虚函数表指针
    subl $4, %esp   ; 调整栈顶
    movl 8(%ebp), %eax ; this指针读入eax
    movl (%eax), %eax  ; (新版本的)虚函数表首地址读入eax
    addl $method_offset, %eax" ; 加上正确的接口函数偏移量
    movl (%eax), %eax  ; (新版本的)虚函数入口地址读入eax
    leave              ; 恢复ebp和esp,注意其后跟的并非通常的ret指令
    jmp *%eax          ; 直接跳转到(新版本的)接口函数的实现中

    上述代码只是个模板,dynahook对每个需要hook的接口方法都会依照该模板产生一段几乎一模一样的机器码,只是其中converter的地址和method_offset都会被重新设置,因为它们要在运行期才能确定,然后让虚函数表中的函数指针指向这些动态生成的hook机器码,这不就实现了动态监控吗?特别要注意的是,hook的实现依赖于caller通过栈来传递接口方法的第一个(隐含)参数――this指针。g++能满足这一点,可是Visual C++系列却不这样:即使没有使用fastcall这样的调用规范,它也会把this指针放在ecx寄存器中传递(以上结论来自对编译器生成的汇编码的观察,如有错误请指正)。有兴趣的读者可以自己改一改这段hook代码,使它也能适合Visual C++编译器。

    test.cpp演示了如何使用组件对象和热替换,部分代码如下:

    nsIBase *o1 = (*create_v1)(); // 创建版本为1的组件对象
    nsIBase *o2 = (*create_v1)(); // 创建版本为1的组件对象

    printf("create objects of version 1: %p, %p/n",
           o1,o2);

    o1->Hello();  // 直接调用版本1的实现
    o2->Foo();    // 直接调用版本1的实现

    // 热替换
    ......

    o1->Hello(); // 将会先触发转换函数,再调用版本2的实现
    o2->Foo();   // 将会先触发转换函数,再调用版本2的实现

    o1->Hello(); // 直接调用版本2的实现
    o2->Hello(); // 直接调用版本2的实现
    o1->Foo();   // 直接调用版本2的实现
    o2->Foo();   // 直接调用版本2的实现


程序的输出则是:

create objects of version 1: 00032BC8, 00032CE0
obj(00032BC8), version1, Hello(), m_data->a = 2008
obj(00032CE0), version1, Foo(), m_data->a = 2008
convert object(00032BC8) from version 1 to 2
obj(00032BC8) version 2, Hello(), m_data->a = 2008, m_data->b:77
convert object(00032CE0) from version 1 to 2
obj(00032CE0) version 2, Foo(), m_data->a:2008, m_data->b:77
obj(00032BC8) version 2, Hello(), m_data->a = 2008, m_data->b:77
obj(00032CE0) version 2, Hello(), m_data->a = 2008, m_data->b:77
obj(00032BC8) version 2, Foo(), m_data->a:2008, m_data->b:77
obj(00032CE0) version 2, Foo(), m_data->a:2008, m_data->b:77

    现在来谈谈这种方案的缺点和局限性。

    首先,方案要求新版本模块能知道旧版本对象的二进制布局,这就意味着要有旧的组件对象的定义文件等。如果旧版本对象聚合了第三方的(非组件型)对象,而且对象的内部状态又不提供接口复制,那么新版本的实现就没有办法替换掉第三方对象。这是一个极大的限制。

    其次,即使拥有旧的组件对象的定义,也需要保证每个模块的关键编译参数(比如结构体的字节对齐数)相同,否则转换函数访问旧版本对象的数据块就是一件非常危险的事情。

    再者,为了能比较方便地做数据块的格式转换,要求每一个版本的实现不能直接内嵌数据成员,只能用一个指针指向一块结构体,就象nsImp1这样:
class nsImp1 : public nsIBase
{
public:
    nsImp1();

    virtual void Hello();
    virtual void Foo();

protected:
    struct data_t
    {
        int a;
    };

    data_t *m_data;
};
这无疑使得代码的编写更繁琐。
    
    最后,正确地更改虚表需要知道一些诸如接口包含多少方法之类的类型信息,而C++没有标准的方法去取得,因此组件
框架提供的动态类型信息至关重要。
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页