动态的调用可变参数函数

最近,碰到了一个奇怪的问题:如何在函数中动态的调用可变参数函数。例如说,有某个可变参数函数:

void Func1(int a, ...) { ... }

现在给出一个个数不定的动态数组,把里面的数值按顺序的作为可变参数传递进 Func1 函数中。

当然,如果允许改变 Func1 的定义,那么我相信每个人都可以轻松的完成这个任务,而且方法一定也是八仙过海,各显神通。但是如果限定不可以更改 Func1 的定义,那么这个问题就麻烦了。在找遍手头上所有 C/C++ 语法资料之后,才愕然的发现,这个问题在 C/C++ 语法的规范内,是无解的。那么,为了解决这个问题,方法就只剩下一个了:动用 C/C++ 的终极绝招:嵌入汇编代码。

说起,嵌入汇编,很多人会觉得很深奥,很难懂。但是仔细分析一下,其实也不见得高不可攀。回到这个问题上,这个问题的根源在于:C/C++的语法中,函数调用时的压栈、调用、清理堆栈和取得返回值必须在一条 C/C++ 语句中完成。所以,任何企图用循环或者宏展开后模拟循环的方法都不可能实现这个目的。所以,我们嵌入汇编的目的也就很明确了:就是自己用汇编实现一次函数调用过程。

C/C++的函数中,我们可以手工指定3种不同的调用约定:__stdcall、__cdecl、__fastcall。关于这几种调用约定的异同,网上的文章已经是汗牛充栋,比比皆是了,在这里就不再继续重复,不过值得指出的是,只有 __cdecl 可以支持可变参数,所以,我们只需要实现这个调用约定的过程即可。

__cdecl的调用约定其实并不复杂,简而言之,就是从右到左压栈,调用者恢复(清理)堆栈。我们就可以先构造一段简单的函数调用代码看看:

void Func3(int a, int b);
void Func4(void)
{
 __asm
 {
  push 2 //压入第二个参数
  push 1 //压入第一个参数
  call Func3 //调用函数
  add esp, 8 //恢复栈顶的指针
 }
}

看上去并不复杂,参数压栈的顺序是从右到左,然后调用 call,最后再把esp的指针恢复为调用前的地方。这基本上就是编译器在编译“Func3(1, 2);”语句时在后台偷偷实现的代码。那么对于我们需要实现的可变参数函数调用来说,压栈和清理的工作,都显得复杂一些,因为我们无法预知参数的个数,所以,压栈的指令(push)将不会是固定的个数,而必须在一个循环中实现。简单的实现代码如下:

int Func5(int iNum)
{
 int* p = new int[iNum];
 //初始化p ...

 __asm
 {
  mov ebx, dword ptr [p]  ;//把p的首地址(基址)放入ebx
  mov ecx, dword ptr [iNum]  ;//把iNum的值放入ecx,既作为循环控制变量,也作为偏移值
  mov edx, ecx   ;//把ecx(iNum)的值放入edx,将作为Func1的第一个参数压栈
  dec ecx    ;//递减ecx(p[iNum-1]为最后一个参数地址)

LOOP1:

  mov eax, dword ptr [ebx+ecx*4] ;//倒序把数组p的内容加载到eax,偏移值为ecx,递减4
  push eax    ;//把eax压栈
  dec ecx    ;//递减ecx

  jns LOOP1    ;//如果ecx不为负值,则跳转到LOOP1:

  push edx    ;//把edx(iNum)压栈,作为Func1的第一个参数

  call Func1    ;//调用Func1

  mov ebx, dword ptr [iNum]  ;//把iNum的值放入ebx
  SHL ebx, 2    ;//左移两位,这是可变参数的大小
  add ebx, 4    ;//ebx加4,这是第一个参数的大小
  add esp, ebx   ;//恢复堆栈指针
 }

 delete[] p;
}

这段代码比上面的Func4要稍微复杂一点,但是也是很清晰明了的:循环压栈的过程是 LOOP1 到 “jns LOOP1”之间的代码,值得注意的是在调用函数 Func1 之后,需要重新计算栈顶指针(esp)的位置,这时,需要重新从 iNum 中读取并计算,而且尽量不要用到 eax 和 edx 这两个寄存器,因为它们可能会被用来传递 Func1 的返回值。对于函数的返回值,如果返回的数据小于32位,那么它将会被放入 eax 寄存器中,如果它的大小大于32位小于64位,那么会用 edx 保存高32位,eax 保存低32位。其它的情况下,编译器会在内存中开辟一块新的内存存放返回值,然后在 eax 传递这块内存的指针。

问题到这里就应该结束了。但是,为了满足一些不是 int 数组类型作为参数的情况,我们需要对函数的参数压栈方式进行了解:对于内置数据类型作参数压栈时,用的是32位对齐的方式压栈,不足32位的,补足32位(其实补的是什么没关系,反正在被调用函数中不会用到这些位,只是 push 指令要求32位而已)。大于32位的,则按照由高到低的顺序依次压栈。对于不可转换为内置数据类型的变量压栈,就会复杂很多,因为这涉及到这些对象的构造和析构的问题,在这里就不详细描述了。为了调用的方便,我把它简单的封装成了一个类:

class CVarArg
{
public:
 template
 void SetArg(const T& tArg)
 {
  if (sizeof(T) == sizeof(unsigned __int32))
  {
   unsigned __int32 iArg = *(unsigned __int32*)&tArg;
   m_Args.push_back(iArg);
  }
  else
  {
   int iSize = ((sizeof(T) & 0x03) == 0) ? sizeof(T) >> 2 : (sizeof(T) >> 2) + 1;
   unsigned __int32* p = new unsigned __int32[iSize];
   memcpy(p, &tArg, sizeof(T));
   for (int i=0; i   {
    m_Args.push_back(p[i]);
   }
   delete[] p;
  }
 }

 template
 T Run(void* pFunc)
 {
  unsigned __int32 iNum = m_Args.size();
  unsigned __int32* p = &m_Args[iNum-1];  //m_Args[iNum-1]为最后一个参数地址

  __asm
  {
   mov ebx, dword ptr [p]  ;//把p指向的地址(参数列表的尾地址)放入ebx
   mov ecx, dword ptr [iNum]  ;//把iNum的值放入ecx,作为循环控制变量
   dec ecx    ;//递减ecx

LOOP1:

   mov eax, dword ptr [ebx]  ;//倒序把数组p(ebx指向的内容)的内容加载到eax
   sub ebx, 4    ;//把ebx的内容递减4(ebx指向的前移一位)
   push eax    ;//把eax压栈
   dec ecx    ;//递减ecx

   jns LOOP1    ;//如果ecx不为负值,则跳转到LOOP1:

   call dword ptr [pFunc]  ;//调用Func1

   mov ebx, dword ptr [iNum]  ;//把iNum的值放入ebx
   SHL ebx, 2    ;//左移两位,这是可变参数的大小
   add esp, ebx   ;//恢复堆栈指针
  }
 }

 void Reset(void)
 {
  m_Args.clear();
 }

private:
 std::vector m_Args;
};

在这个类中,SetArg 负责把需要传入的各种类型的参数统一转换为 int 数组,存入临时“堆栈” m_Args 中,在 Run 函数中,利用 vector 存放地址连续的特性,结合上面 Func5 给出的示例代码,就可以完成这个调用。要注意两点,第一:在 Run 函数中,实际调用的目标函数的函数指针被定义为 void*,这时因为在汇编中,所有的指针都是一样的,无须区分指针的类型。(其实所有 C/C++ 中的指针类型,都是给编译器看的,只要能骗过编译器,那么用起来就百无禁忌了,不过要是出错,也会是匪夷所思的);第二:在这个函数的结尾处没有 return 语句,也没有在调用 pFunc 函数后改变任何 eax、edx 的值,所以,Run 函数的返回值,实际上等价于 pFunc 函数的返回值。(其实对于这个没有“return”语句的函数,我也觉得有点奇怪:编译器不但给编译通过了,居然还一个 Warning 都没有,不过也好,省得我还要给它伪造返回值)。

这个类的使用,也是很方便的:
CVarArg va;
va.SetArg(1);  //这里需要按照正确顺序调用 SetArg
va.SetArg(2);
va.Run(Func1); //如果需要返回值,用类似 T t = va.Run(Func1); 的语句调用

当然,这个类还有很大的优化和改进空间,特别是 SetArg 函数,如果有兴趣的话,可以继续研究一下。不过在这里还是要提醒各位:由于汇编代码的可移植性不强,所以,如果需要照搬这里的代码使用的话,必须对这部分的代码进行充分的测试,并尽可能的覆盖所有可能使用到的情况,例如各种参数类型,各种调用/被调用函数等等。否则,出了问题,是很难查的。

你可能感兴趣的:(C/C++)