程序员求职经验分享与学习资料整理平台

网站首页 > 文章精选 正文

二进制兼容的C++接口

balukai 2025-05-28 15:27:33 文章精选 20 ℃

作者:Chad Austin, 2002.02.15

翻译自
:https://chadaustin.me/cppinterface.html


概述

本文阐述如何创建可跨多编译器和不同编译配置(Release,Debug... etc.)的C++ DLL APIs.

背景

许多平台对于平台上偏好的编程语言存在ABI(
Application-Binary-Interface,应用程序二进制接口)。例如, BeOS系统的主要编程语言是C++,因此C++编译器必须能够生成与操作系统的C++系统调用(和类,etc.)保持二进制兼容的代码 .

Windows API和ABI是为C语言定义, 所以 C++编译器编写者可以随心所欲地实现C++ ABI\. 然而,Microsoft最终为windows创建了面向对象的ABI叫做COM. 为了简化 COM 使用, 他们使C++ ABI的虚表与COM接口要求的虚表相匹配. 由于不能使用COM的Windows编译器十分有限, 其他编译器供应商也强制要求COM虚表与C++虚表之间相映射关系.

ABI涉及几个方面. 本文只讨论在Windows上使用C++的问题。其他平台有不通的要求。(幸运的是,由于其他大部分平台并没有Windows那样流行,他们只有1或2个款编译器,因此不会有太大问题。)

概念

  • ABI - 应用程序二进制接口. 系统之间的二进制接口.如果一个二进制接口变化,则双方的接口(用户和实现)必须重新编译。
  • API - 应用程序接口. 系统之间的源接口。如果一个源接口发生变化,使用这个接口的代码必须相应修改。 API变化通常意味着ABI改变.
  • Interface - 一个类包含的每个方法是纯虚的,且因此没有固有实现。一个接口仅仅是不通对象之间通信的一个协议。
  • Factory - 工厂是某种用于创建对象的地方。在本文中,我们用单个全局函数作为我们的工厂。
  • DLL Boundary - 实例化于DLL内部的代码与调用进程内部的代码之间的界线叫做DLL边界. 在某些场景下,代码可能存在边界两侧:例如,考虑一个位于头文件中的inline函数,它用于DLL和可执行程序中。这个函数实际上在边界两侧都进行了实例化。 因此,如果一个inline函数有一个静态变量,这种情况会创建两个变量, 一个在可行性程序中,另一个一个在DLL中, 而哪个变量被使用取决于是DLL还是可执行程序中的代码在调用该函数。

初次尝试

让我们假设你想创建一套可移植的windowing API,而且你想坚持在DLL实现。我将创建一个叫做Window的类,它代表在几个不通窗口系统上一个窗口:例如Win32,MFC,wxWindows, Qt, Gtk, Aqua, X11, Swing (*gasp*),等等。 我们将尝试创建一个接口,直到它能够跨不同的实现、编译器和编译配置工作。

// Window.h


#include <string>


#ifdef WIN32

#ifdef EXPORTING

#define DLLIMPORT __declspec(dllexport)

#else

#define DLLIMPORT __declspec(dllimport)

#endif

#define CALL __stdcall

#else

#define DLLIMPORT

#define CALL

#endif


class DLLIMPORT Window {

public:

Window(std::string title);

~Window();


void setTitle(std::string title);

std::string getTitle();


// ...


private:

HWND m_window;

};

我不打算展示它的实现,因为我假设你已经知道如何实现。这个接口有一个明显的问题:它假设您使用的是基本的Win32 API。也就是说,它持有一个HWND作为私有成员,这在Window类和Win32SDK之间引入了依赖关系。一种可能的解决方案是使用pImpl习惯用法从类定义中删除类的私有成员。您可以在[1]、[2]、[3]和[4]的其他地方阅读更多关于这方面的内容。此外,由于类的大小会发生变化,你不能在不破坏二进制兼容性的情况下向类中添加新成员。 此外

也许这方法最重要的问题是它的方法是非虚的。因此,他们被实现为以‘this’指针作为它们第一个参数的特殊命名的函数。不幸的是,我并不知道有哪两类编译器使用了相同方式修改(mangle)方法名。因此,不要以为你的DLL可以与另一个编译器编译的可执行文件一起工作。!

第二次尝试 #2

对于那些在面向对象编程方面有经验的人来说,您知道每个类都可以分为两个概念:接口和工厂。工厂是一种创建对象的机制,接口允许您与它们进行通信。下一版本的Window.h将分离这些概念。请注意,您不再需要导出该类(但必须导出工厂函数!),因为它是抽象的:所有方法调用都通过对象的vtable,而不是直接链接到DLL。只有对工厂函数的调用是直接调用了DLL。

// Window.h


#include <string>


class Window {

public:

virtual ~Window() { }

virtual void setTitle(std::string title) = 0;

virtual std::string getTitle() = 0;

};


Window* DLLIMPORT CreateWindow(std::string title);

这好多了。使用窗口对象的代码不再关心窗口对象的实际类型,只是它实现了window接口。然而,仍然存在一个问题:不同的编译器对符号名称的处理方式不同,因此不同编译器生成的DLL中的CreateWindow函数将具有不同的名称。这意味着,如果使用Visual C++6编译窗口DLL,则无法在Borland C++中使用它,反之亦然。幸运的是,C++标准允许我们通过外部“C”禁用对指定名称的符号篡改。

你们中的一些人可能注意到了此代码的另一个问题。不同的编译器实现标准C++库的方式不同。在不太明显的情况下,有些人会用另一个(例如STLPort)替换编译器对库的实现。由于不能依赖STL对象在编译器之间是二进制兼容的,因此不能在DLL接口中安全地使用它们(STL对象)。

如果一个C++ABI是为Windows创建的,那么它将需要精确地指定如何与标准库中的每个类连接,但我认为这不会很快发生。

这里的最后一个问题是一个小问题。按照约定,COM方法和DLL函数使用__stdcall调用约定。我们可以用我上面定义的CALL宏来解决这个问题。(您需要在项目中重命名它。)

修订版 3

// Window.h


class Window {

public:

virtual ~Window() { }

virtual void CALL setTitle(const char* title) = 0;

virtual const char* CALL getTitle() = 0;

};


extern "C" Window* CALL CreateWindow(const char* title);

我们已经差不多了!这个特殊的接口可能在很多情况下都能工作。然而,虚析构函数使事情有点有趣...... 由于COM不使用虚析构函数,因此不能依赖不同的编译器来同样地使用它们。但是,你可以用虚方法替换虚析构函数,在实现类中,该方法通过‘delete this’来实现;这样,构造和析构都在DLL边界的同一侧实现。例如,如果您尝试将VC++6 Debug DLL与Release的可执行文件一起使用,则可能会崩溃或遇到类似“ESP值未在函数调用中保存”的警告。发生此错误是因为VC++运行库的调试版本与发布版本具有不同的分配器。由于两个分配器不兼容,我们不能在DLL边界的一侧分配内存,而在另一侧删除内存。

“但虚析构函数与另一个虚方法有何不同?”虚析构函数不负责释放对象所使用的内存:它们只是在对象被释放之前执行必要的清理。使用DLL的可执行文件将尝试释放对象本身的内存。另一方面,destroy()方法负责释放内存,因此所有new和delete调用都停留在DLL边界的同一侧。

将接口的析构函数设置为受保护的也是一个好主意,这样接口的用户就不会在无意中对其使用delete。

修订版 4

// Window.h


class Window {

protected:

~Window() { } // use destroy()


public:

virtual void CALL destroy() = 0;

virtual void CALL setTitle(const char* title) = 0;

virtual const char* CALL getTitle() = 0;

};


extern "C" Window* CreateWindow(const char* title);

由于这段代码不使用任何COM未定义的语义,因此它应该在编译器和配置设置之间完美地工作。不幸的是,这并不理想。你必须记住使用object->destroy();来删除对象,这远不如delete object;那么直观。也许更重要的是,你不能再对这种类型的对象使用std::auto_ptr。auto_ptr希望使用delete object;删除它所持有的对象。是否有一种方法使语法delete object;实际上调用object->destroy();?是的。这就是事情变得有点奇怪的地方……您可以重载接口的delete操作符,并让它调用destroy()。因为operator delete接受一个void指针,你必须假设你从来没有在任何不是Window的东西上调用Window::operator delete。这是一个相当安全的假设。这是运算符的实现:

...


void operator delete(void* p) {

if (p) {

Window* w = static_cast<Window*>(p);

w->destroy();

}

}


...

看起来不错……现在你可以再次使用auto_ptr,且仍然拥有稳定的二进制接口。当你重新编译和测试你的新代码时(你正在测试,对吗??),你会注意到在WindowImpl::destroy发生栈溢出!这是怎么回事呢?如果您还记得destroy方法是如何实现的,就会看到它只是简单地执行delete this;。由于接口重载operator delete, WindowImpl::destroy调用Window::operator delete,而它又调用WindowImpl::destroy…如此无限递归。这个特殊问题的解决方案是重载实现类中的delete操作符来调用全局delete操作符:

...


void operator delete(void* p) {

::operator delete(p);

}


...

收尾工作

如果您的系统有很多接口和实现,您会发现需要某种方法来自动取消定义delete操作符。幸运的是,这也是可能的。只需创建一个名为DefaultDelete的模板类,并从类DefaultDelete<I>派生实现类,而不是从接口I派生实现类。下面是DefaultDelete的定义:

template<typename T>

class DefaultDelete : public T {

public:

void operator delete(void* p) {

::operator delete(p);

}

};

最终实现

下面是最终版本的代码。

// Window.h


class Window {

public:

virtual void CALL destroy() = 0;

virtual void CALL setTitle(const char* title) = 0;

virtual const char* CALL getTitle() = 0;


void operator delete(void* p) {

if (p) {

Window* w = static_cast<Window*>(p);

w->destroy();

}

}

};


extern "C" Window* CALL CreateWindow(const char* title);

// Window.cpp

#include <string>

#include <windows.h>

#include "DefaultDelete.h"


class WindowImpl : public DefaultDelete<Window> {

public:

WindowImpl(HWND window) {

m_window = window;

}


~WindowImpl() {

DestroyWindow(m_window);

}


void CALL destroy() {

delete this;

}


void CALL setTitle(const char* title) {

SetWindowText(m_window, title);

}


const char* CALL getTitle() {

char title[512];

GetWindowText(m_window, title, 512);

m_title = title; // save the title past the call

return m_title.c_str();

}


private:

HWND window;

std::string m_title;

};


Window* CALL CreateWindow(const char* title) {

// create the Win32 window object

HWND window = ::CreateWindow(..., title, ...);

return (window ? new WindowImpl(window) : 0);

}

// DefaultDelete.h


template<typename T>

class DefaultDelete : public T {

public:

void operator delete(void* p) {

::operator delete(p);

}

};

总结

差不多就是这样。最后,我将列举创建C++接口时要牢记的指导原则。你可以回顾这篇文章作为参考,或者用它来巩固你的知识。

  • 所有的接口类都应该是完全抽象的。每个方法都应该是纯虚的。(或内联……您可以安全地编写调用其他方法的内联方便方法。)
  • 所有全局函数都应该是extern“C”,以防止不兼容的命名修改。此外,导出的函数和方法应该使用__stdcall调用约定,因为DLL函数和COM传统上使用该调用约定。这样,如果库的用户默认使用__cdecl编译,对DLL的调用仍将使用正确的约定。
  • 不要使用标准c++库。
  • 不要使用异常处理。
  • 不要使用虚析构函数。相反,创建destroy()方法和重载操作符delete来调用destroy()。
  • 不要在DLL边界的一边分配内存,而在另一边释放内存。不同的DLLs和可执行文件可以使用不同的堆来构建,使用不同的堆来分配和释放内存块肯定会导致崩溃(的原因)。例如,不要内联你的内存分配函数,这样它们就可以在可执行文件和DLL中以不同的方式构建。
  • 不要在接口中使用重载方法。不同的编译器在虚表中对它们的排序是不同的。


参考文献

  • STLPort 这是STL另一种实现。
  • SGI 有另一个标准C++库实现。
  • Corona 是一个镜像IO库使用了本文中介绍的方法.

反馈

我真的想要这篇文章的反馈。有什么地方不清楚吗?你想知道具体情况的更多细节吗?代码有错吗?发送电子邮件至aegis@aegisknight.org。

最近发表
标签列表