目录

C++二进制兼容性设计和程序更新

发布于 2023/10/12 更新于 2023/10/12

作者 趣宽科技 码云上的源文件

H1

动态库的程序架构

在使用C++开发应用程序时,随着需求的增加,功能模块也会随之增加。导致最后发布的程序也会变得越来越庞大。这时候有必要按照某种功能划分将系统划分成主程序和若干个子模块组成的架构。每个子模块采用动态链接库(Shared Library)的形式存在。具体表现为,在windows平台编译成dll文件(文件名以.dll结尾),Linux平台编译成object文件(文件名以.o结尾)。当模块的需求有变更时,只需要单独针对需要修改的模块来编译,而无需编译整个系统。也就是说只需要更新指定的dll文件即可,避免了其他模块和主程序的重新编译和更新,这样既节省了成本又提高了效率。这种当动态链接库重新编译和更新时,使用该动态库的应用程序无须编译就可以正常使用最新功能的,则称该动态链接库是二进制兼容的。

然而,在设计动态库时(Shared Library)时,并不是自动就可以实现二进制兼容。在C++中,我们必须要有意采用二进制兼容的设计来开发动态链接库。如果不采用这种设计,将导致整个系统不可预测的崩溃。下面介绍使用Qt C++来开发二进制兼容的动态链接库(Shared Library)。

H1

使用Qt c++动态库

以Qt c++为例,假设我们在Qt中开发一个项目名称为”SourceCompatible“,类型为 ”Qt Widgets Application"的工程项目,并将它作为主程序。同时该项目还包括一个子模块,名为“SCLib”,它以动态链接库的形式存在,它被主程序“SourceCompatible”调用。子模块“SCLib”封装了一些单的功能:显示一个绿色窗口(Widget)并在该窗口中显示一些文字信息。下面介绍在Qt中创建这两个项目。

/assets/images/article/cpp/80b55b57-e8a1-45e4-8509-9e6bf8c3a6c3

创建Qt Widgets Application并命名为"SourceCompatible"

/assets/images/article/cpp/8e9a17b4-b440-4951-a396-58564f7eaecb

创建Shared Library并命名为"SCLib"

我们先来看SCLib的的代码以及它如何以dll的形式被主程序调用。创建SCLib后,生成了sclib.h, sclib.cpp,SCLib_global.h,下面是sclib.h,它使用了宏SCLIB_EXPORT定义了一个导出类SCLib,它表明类SCLib作为该dll的导出类,可以在其他应用程序使用。稍作修改,因为我们需要显示一个窗口,所以将该类继承QWidget。下面是修改后的sclib.h代码:


#ifndef SCLIB_H
#define SCLIB_H

#include "SCLib_global.h"
#include <QWidget>

class SCLIB_EXPORT SCLib : public QWidget
{
public:
    explicit SCLib(QWidget* parent = nullptr);
private:
    int m_val;
};

#endif // SCLIB_H
                    

在该类中,我们定义了一个名为m_val的int类型的成员变量。下面是sclib.cpp的代码:


#include "sclib.h"
#include <QLabel>

SCLib::SCLib(QWidget* parent)
    : QWidget(parent)
{

    // 设置背景颜色
    setAutoFillBackground(true);
    QPalette pal = palette();
    pal.setColor(QPalette::Window, Qt::darkCyan);
    setPalette(pal);

    // 设置label字体颜色
    setStyleSheet("QLabel { color: white; }");

    m_val = 10;
    QLabel * label_val = new QLabel(QString("m_val=%1").arg(m_val), this);
    label_val->move(10,10);
}
                    

上述的代码创建了一个label,并显示m_val的赋值。采用MinGW64 Debug模式编译,编译SCLib后,我们可以看到在Debug目录下生成了下列文件:

/assets/images/article/cpp/52b87481-fcf5-4e67-bc8f-064527ff5c4d

注意,.a文件是在需要使用该动态库的程序中引用,它和windows平台下的.lib文件不完全相同。.dll文件就是windows平台下的动态链接库,.o文件就是Linux下的动态链接库。下面介绍如何在主程序"SourceCompatible"引用“SCLib"库。

在上面如果已经创建了SourceCompatible工程项目,我们在该项目目录下创建一个SClib的目录:

/assets/images/article/cpp/bf89984f-062e-47dc-ad1b-fab3be14741b

在SClib目录下,将sclib.h和SCLib_gloabl.h拷贝到该目录,同时将libSCLib.a拷贝到debug目录下。
/assets/images/article/cpp/6e66295e-5c3d-49ad-835d-6663e5cfddfb

若采用qmake编译,我们还需要修改SourceCompatible.pro,加入:


    DISTFILES += \
    SClib/debug/SCLib.dll \
    SClib/debug/libSCLib.a
    
win32:CONFIG(release, debug|release): LIBS += -L$$PWD/SClib/release/ -lSCLib
else:win32:CONFIG(debug, debug|release): LIBS += -L$$PWD/SClib/debug/ -lSCLib
else:unix: LIBS += -L$$PWD/SClib/ -lSCLib

INCLUDEPATH += $$PWD/SClib
DEPENDPATH += $$PWD/SClib/debug
                    

上面的编译设定用意是,在编译链接阶段时,需要链接到libSCLib.a库,同时指定include的搜索路径。在这里不做编译设定的详细介绍。

在 SourceCompatible 项目的mainwindow.cpp中,我们对SCLib动态库导出的类SCLib进行实例化,也就是要正式使用SClib.dll了。mainwindow.cpp代码如下:


#include "mainwindow.h"
#include "SClib/sclib.h"

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
{
    // 实例化SClib
    SCLib *lib_widget = new SCLib(this);
    lib_widget->resize(200, 200);
    lib_widget->move(10,10);
}

MainWindow::~MainWindow()
{
}
                    

对SourceCompatible进行编译,这里同样使用MinGW64的Debug模式。builld成功后在debug目录下可以看到SourceCompatible.exe。我们可以直接在QtCreator中运行该项目,而不必单独在debug目录下单击该程序运行。因为单独在debug目录中单击SourceCompatible.exe运行时还需要额外的依赖库。在这里需要将SCLib.dll拷贝到debug目录下,运行的结果如下:

/assets/images/article/cpp/2f980263-1a82-4c9d-99e6-716ca646f5e5

这样就算完成了一个简单的在应用程序中调用dll的例子。这种调用是正常的,也不会出现什么问题。
现在假设需要对SCLib进行修改,增加了一个新的成员变量m_name,sclib.h代码如下:


#ifndef SCLIB_H
#define SCLIB_H

#include "SCLib_global.h"
#include <QWidget>
#include <QString>

class SCLIB_EXPORT SCLib : public QWidget
{
public:
    explicit SCLib(QWidget* parent = nullptr);
private:
    QString m_name;
    int m_val;
};
#endif // SCLIB_H
                    

sclib.cpp代码如下:


#include "sclib.h"
#include <QLabel>

SCLib::SCLib(QWidget* parent)
    : QWidget(parent)
{

    // 设置背景颜色
    setAutoFillBackground(true);
    QPalette pal = palette();
    pal.setColor(QPalette::Window, Qt::darkCyan);
    setPalette(pal);

    // 设置label字体颜色
    setStyleSheet("QLabel { color: white; }");

    m_val = 10;
    QLabel * label_val = new QLabel(QString("m_val=%1").arg(m_val), this);
    label_val->move(10,10);
    
    m_name = "my name";
    QLabel *label_name = new QLabel(m_name, this);
    label_name->move(10,40);
}
                    

上述代码增加了一个新的label显示,重新编译SCLib后,我们将SCLib.dll拷贝到SourceCompatible的debug目录下,替换原有的文件,这样是不是就代表修改生效了呢? 当我们在SourceCompatible项目中运行时,会发现经常会出现:...debug\SourceCompatible.exe crashed. 提示信息。或者运行失败,或者在退出时出现crashed的提示。这种crash是以非预期方式出现的,并不是每次都会出现。这是因为:

在加入成员变量后,改变了类的对象布局。内存访问发生了偏移。编译器为原来的对象按照其大小在内存上分配了空间。而在运行时,由于新数据成员的加入导致类的构造函数重写了已经存在的内存空间,导致了程序崩溃。

如何解决这个问题?使得只需修改动态库的代码并编译后,就可以正常被它应用使用,而其他应用无须再编译,下面介绍二进制兼容设计。

H1

在Qt中使用C++二进制兼容设计

在Qt C++中,提供了二进制兼容设计的理念,当然这种理念也不是唯一的实现方法。其原理就是将导出类的成员变量封装在一个指针类中,这样就可以在调用dll时,实现对dll导出类的内存对象的动态分配,而不是在编译时就确定内控空间,利用C++的运行时绑定的特性来实现。下面我们对sclib.h稍作修改:


#ifndef SCLIB_H
#define SCLIB_H

#include "SCLib_global.h"
#include <QWidget>
#include <QScopedPointer>
#include <QLabel>
#include <QString>

class SCLIB_EXPORT SCLib : public QWidget
{
    class SCLibPrivate;

    // 使用 Q_DECLARE_PRIVATE 定义并访问 SCLibPrivate 类指针,同时将SCLibPrivate声明为friend class,
    // 意味着在 SCLib 中可以访问 SCLibPrivate所有类型的成员。
    Q_DECLARE_PRIVATE(SCLib)
    QScopedPointer<SCLibPrivate> d_ptr;
public:
    explicit SCLib(QWidget* parent = nullptr);
    void changeName(const QString& name);
private:
    int getVal() const { return 99; }
};

#endif // SCLIB_H
                    
                    
                    

上述代码,可以看到移除了SCLib的两个私有成员变量。增加了SCLibPrivate一个私有类,该类的命名方式就是”类+Private"的规则。同时类的指针变量d_ptr也是固定的。
sclib.cpp代码如下:


#include "sclib.h"
#include <QLabel>

// 实现 class SCLibPrivate
class SCLib::SCLibPrivate
{
    Q_DISABLE_COPY(SCLibPrivate)

    // 使SCLibPrivate可以访问SCLib中的成员
    // 通过定义 Q_Q(SCLib)来实现
    SCLib* const q_ptr{};
    Q_DECLARE_PUBLIC(SCLib)

public:
    SCLibPrivate(SCLib* parent)
        : q_ptr(parent)
    {

        m_val = 10;
        m_label_val = new QLabel(QString("m_val=%1").arg(m_val), parent);
        m_label_val->move(10,10);

        m_name = "my name";
        m_label = new QLabel(m_name, parent);
        m_label->move(10,40);
    }

    void changeVal()
    {
        Q_Q(SCLib);

        m_label_val->setText(QString("m_val=%1").arg(q->getVal()));
    }

private:
    QString m_name;
    int m_val;
    QLabel *m_label;
    QLabel * m_label_val;
};

SCLib::SCLib(QWidget* parent)
    : QWidget(parent)
    , d_ptr(new SCLibPrivate(this))
{

    // 设置背景颜色
    setAutoFillBackground(true);
    QPalette pal = palette();
    pal.setColor(QPalette::Window, Qt::darkCyan);
    setPalette(pal);

    // 设置label字体颜色
    setStyleSheet("QLabel { color: white; }");
}

void SCLib::changeName(const QString& name)
{
    Q_D(SCLib);
    d->m_label->setText(name);
    d->changeVal();
}
                    

我们可以看到通过将SCLib的成员变量集中到SCLibPrivate中,并通过指针来实现就可以,在这里SCLibPrivate可以访问SCLib中的所有成员,可以通过Q_Q(CSLib),q->xxx来实现。而在SCLib中,可以通过Q_D(CSLib),d->xxx来访问SCLibPrivate中的所有成员,实现相互访问。可以简单表示为Q_D,Q_Q,Q_DECLARE_PRIVATE,Q_DECLARE_PUBLIC模式。​这里不对它的原理做进一步的解释,相信熟悉C++的人也一定能明白这些宏定义的逻辑和用意。
在mainwindow.cpp调用时,修改为:


#include "mainwindow.h"
#include "SClib/sclib.h"
#include <QPushButton>

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
{
    // 实例化SClib
    SCLib *lib_widget = new SCLib(this);
    lib_widget->resize(200, 200);
    lib_widget->move(10,10);

    QPushButton *btn = new QPushButton("修改", this);
    btn->move(10, 200);
    connect(btn, &QPushButton::clicked, [this, lib_widget] {
        lib_widget->changeName("changed!!");
    });

}

MainWindow::~MainWindow()
{
}
                    

采用这种设计,第一次需要将全新的SCLib.a文件再次拷贝到SClib\debug目录下。同时使用全新的sclib.h文件,重新编译SourceCompatible,运行后再也不会出现非预期崩溃的情况。

/assets/images/article/cpp/95e2451a-c1e2-4731-a0b1-92ac00210cb0

在C++中通过二进制兼容设计,此后对于成员变量修改或其他代码修改的情况,只需重新编译指定的子模块并替换掉指定的动态库文件即可,而无需编译整个系统。

获取上述源代代码,请访问:trooquant / BinaryCompatibility