65.9K
CodeProject 正在变化。 阅读更多。
Home

如何从内联汇编代码段调用 C++ 成员操作

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.74/5 (14投票s)

2005 年 9 月 19 日

7分钟阅读

viewsIcon

51588

downloadIcon

444

使用成员函数指针从汇编代码调用 C++ 操作。

引言

早在 80 年代末,当我开始在 Commodore 著名的 Amiga PC 上编程时,别无选择,只能使用汇编语言来优化代码,以榨取硬件的任何一点资源。尽管情况已经发生了变化,编译器供应商在代码优化方面做得很好,但在某些情况下,您仍然可以比编译器做得更好(假设您对汇编语言编程和处理器架构有深入的了解)。顺便说一句,当我说代码优化时,我只指针对给定算法优化机器码,而不是算法本身。在大多数情况下,在算法上进行优化更为准确。如果您比较冒泡排序 (n2) 和堆排序 (n * log n) 算法的计算复杂度,您会发现冒泡排序算法的代码优化并不能阻止堆排序算法在某个固定的 n1 > n 时更快。

由于本文并非旨在介绍代码优化,我们姑且假设您在 C++ 项目中有一段汇编代码(无论是否出于优化目的),并且您希望在该汇编代码片段中调用给定对象的成员函数。由于汇编(过程式范式)和 C++(面向对象范式)编程语言之间的概念差异,我将首先简要概述 C++ 的概念(如虚函数调用)如何在汇编中实现。之后,我们将了解 C++ 成员函数指针如何用于从汇编代码段调用成员函数。

调用非虚函数和虚函数

尽管 C++ 中非虚函数调用和虚函数调用的语法没有区别,但编译器生成的汇编代码却大相径庭。原因是虚函数调用是动态调用。这意味着实际的被调用者是在运行时确定的。因此,这也称为晚期绑定。虚函数对于实现多态至关重要,而多态是面向对象语言的关键范例之一。让我们看一下下面的类层次结构。

class ServiceA
{
public:
  void sub(int a, int b) {
    printf("ServiceA: %d - %d = %d\n", a, b, a-b);
  }
  virtual void add(int a, int b)  {
    printf("ServiceA: %d + %d = %d\n", a, b, a+b);
  }
  virtual void mul(int a, int b)  {
    printf("ServiceA: %d * %d = %d\n", a, b, a*b);
  } 
};
class ServiceB : public ServiceA
{
public:
  void sub(int a, int b) {
    printf("ServiceB: %d - %d = %d\n", a, b, a-b);
  }
  virtual void add(int a, int b)  {
    printf("ServiceB: %d + %d = %d\n", a, b, a+b);
  }
  virtual void mul(int a, int b)  {
    printf("ServiceB: %d * %d = %d\n", a, b, a*b);
  } 
};

ServiceA 声明了两个虚函数 add()mul(),它们被子类 ServiceB 覆盖。当使用类型为 ServiceA 的指针调用某个实例(类型为 ServiceB)的其中一个操作时,将调用 ServiceB 类中正确的操作。这种行为正是我们所知的面向对象多态,与非虚函数的调用不同。

1  ServiceA serviceA; ServiceB serviceB;
2  ServiceA *pSA = &serviceB;
3
4  pSA->sub(20, 5); //static call
5  pSA->add(10, 50); //dynamic call
6  pSA->mul(2, 2); //dynamic call

通过仔细检查第 4 行和第 5 行的汇编代码,您可以比较虚函数调用和非虚函数调用的区别。像第 4 行这样的非虚函数调用是在编译时处理的。当编译器接收到第 4 行作为输入时,它知道指针的类型和函数的类型。它确定非虚函数 sub() 的地址,并生成调用该函数的汇编语句。第 4 行的汇编代码大致如下:

push 5;
push 20;
mov ecx, pSA;
call 0x40000; This is a pseudo-address where the member function 
              ServiceA::sub is located.

正如我们已经知道的:虚函数调用是动态调用。这意味着函数的地址是在运行时计算的。但其魔力何在?为了能够根据对象类型调用正确的函数,编译器会为虚函数生成一个特定的函数查找表,也称为 **vtable**。每个对象都有一个指向其 vtable 的指针,编译器将函数指针存储在该表中,指向正确的函数。重要的是,不同虚函数的偏移量是相同的。只有函数指针因对象类型而异。下表描述了不同对象类型的 vtable 的结构和内容。

类型为 ServiceA 的实例的 vtable

偏移量 C++ 函数指针
0x00 add() 0x40010
0x04 mul() 0x40070

类型为 ServiceB 的实例的 vtable

偏移量 C++ 函数指针
0x00 add() 0x40230
0x04 mul() 0x402C0

顺便说一句,如果您避免在内存占用很少(例如几个字节)且创建大量实例的类中使用虚函数,可以显著减小应用程序的内存占用。举个例子:一个类的实例可能为其属性使用 4 字节的内存。如果该类有虚函数,编译器将为该类生成一个 vtable(在某些情况下,编译器优化可以阻止这种情况,但这与我们无关)。因为该类的每个实例都会有一个指向该 vtable 的指针,所以每个实例的内存使用量将加倍(我认为有些编译器只使用 2 字节作为 vtable 的偏移量,这将导致“仅”增加 50%)。因此,请考虑在可能的情况下避免使用虚函数。

调用虚函数会指示编译器生成代码,以从 vtable 中查找函数指针并调用该函数。第 5 行的汇编代码大致如下:

push 50;
push 10;
mov ecx, pSA;
mov edx, [ecx];//first 4 bytes are the pointer to the vtable
call [edx];//call first element from the vtable (offset 0)

第 6 行的汇编代码也相应地生成。

push 2;
push 2;
mov ecx, pSA;
mov edx, [ecx];//first 4 bytes are the pointer to the vtable
call [edx + 4];//call second element from the vtable (offset 4)

操作 mul() 存储在 vtable 的第二个位置。因此,调用需要 4 字节的偏移量(call [edx + 4])。

thiscall 调用约定

C++ 中调用成员函数的默认调用约定称为 thiscall。此调用约定的特性与标准调用约定类似。这意味着参数是从右到左在堆栈上传递的。隐式的 this 指针放在 ECX 中。最后,由被调用函数清理堆栈,如果需要,返回值的寄存器为 EAX。因此,我们首先需要将 5 推送到堆栈,然后将 20 推送到堆栈,并将指针加载到 ECX 中;对于 C++ 语句,例如 pSA->sub(20, 5)(关于调用约定的良好介绍可以在 Code Project 上找到 Calling Conventions Demystified)。

使用 C++ 成员函数指针

上述示例的问题在于您无法在内联汇编代码片段中使用它们。对于非虚函数调用,您需要确定成员函数的地址。但是,您将无法编写如下所示的汇编代码:

push 5;
push 20;
mov ecx, pSA;
call &;ServiceA::sub; // Error! You can not determine
                     // the address of a member function
                     // like this

对于虚函数调用,我们看到编译器生成了访问实例 vtable 的代码。在我们的示例中,指向 vtable 的指针位于前 4 个字节。但这种内存布局不是由 ANSI C++ 规范定义的。因此,您不能依赖它。另一个大问题是,您无法确定特定虚函数在 vtable 中的位置。因此,最好使用 C++ 成员函数指针来从汇编代码片段中的 C++ 类调用成员函数。如果您不熟悉成员函数指针,Don Clugston 的文章 "Member Function Pointers and the Fastest Possible C++ Delegates" 是一个非常好的起点。即使您知道什么是成员函数指针,它们也有一些令人困惑的方面,因此我建议阅读这篇文章。

正如 Don Clugston 指出的那样,不同编译器在成员函数指针方面存在实现上的差异。因此,讨论汇编语句层面的细节肯定会导致错误的结果。但是,为了理解成员函数的工作原理以及如何在我们的内联汇编代码段中使用它们,我们可以对它们做出一些假设。

成员函数指针不仅仅指向某个成员函数。正如我们已经知道的,这对于虚函数是行不通的。它们实际上指向另一个由编译器隐式创建的函数。

1  typedef void (ServiceA::*TypeAPtr)(int, int);
2
3  int main(int argc, char* argv[])
4  {
5    ServiceA serviceA; ServiceB serviceB;
6    ServiceA *pSA = &serviceB;
7    TypeAPtr _add = &ServiceA::add;
8    (pSA->*_add)(10,20);
9  }

我认为这段代码中最奇怪的部分是第 7 行。根据 C++ 语法,该语句如下:将类 ServiceA 的名为 add() 的虚成员函数的地址赋给成员函数指针 _add。但是,由于这是一个虚函数,在没有 ServiceA 或其任何子类的实例的情况下,您无法确定确切的成员函数。更奇怪的是,有些编译器甚至允许您省略 & 运算符。但省略 & 运算符是非标准的,您应该避免使用它。

第 7 行赋值的汇编代码很简单。它只是将调用 ServiceA 类型上虚成员函数 add() 的隐式函数的地址复制到成员函数指针中。连同第 8 行的成员函数调用,汇编代码如下:

  mov [_add], address of implicit function; //line 7
  mov ecx, pSA; //line 8
  call [_add];

这正是我们可以在内联汇编部分使用的。

int main(int argc, char* argv[])
{
  ServiceA serviceA; ServiceB serviceB;
  ServiceA *pSA = &serviceB;
  TypeAPtr _add = &ServiceA::add;

  pSA->add(10,50);
  pSA->mul(102,50);
  pSA->sub(20,5);

  (pSA->*_add)(10,20);
  _asm
  {
    lea ecx, serviceB;
    push 60;
    push 40;
    call _add;
  }
  return 0;
}

结论

本文介绍了一种在内联汇编代码段中调用 C++ 类成员函数的方法。据我所知,这是您可以做到的唯一方法,无论它是否有用。希望您仍能使用它。

© . All rights reserved.