回调函数

          程序员常常需要实现回调。本文将讨论函数指针的基本原则并说明如何使用函数指针实现回调。注意这里针对的是普通的函数,不包括完全依赖于不同语法和语义规则的类成员函数(类成员指针将在另文中讨论)。   
    
  1  声明函数指针   
          回调函数是一个程序员不能显式调用的函数;通过将回调函数的地址传给调用者从而实现调用。要实现回调,必须首先定义函数指针。尽管定义的语法有点不可思议,但如果你熟悉函数声明的一般方法,便会发现函数指针的声明与函数声明非常类似。请看下面的例子:   
  void   f();//   函数原型      
  上面的语句声明了一个函数,没有输入参数并返回void。那么函数指针的声明方法如下:   
  void   (*)   ();   
          让我们来分析一下,左边圆括弧中的星号是函数指针声明的关键。另外两个元素是函数的返回类型(void)和由边圆括弧中的入口参数(本例中参数是空)。注意本例中只是声明了变量类型,还没有创建指针变量,目前可以用这个变量类型来创建类型定义名及用sizeof表达式获得函数指针的大小:   
  unsigned   psize   =   sizeof   (void   (*)   ());       //   获得函数指针的大小 
  typedef   void   (*p)   ();                                       //   为函数指针变量声明类型定义     
p是一个函数指针变量,它指向的函数没有输入参数,返回类行为void。有了指针变量便可以赋值,值的内容是署名匹配的函数名和返回类型。例如有func函数:   
  void   func()    
  {  
  /*   do   something   */  
  }

 可以将函数名func赋值给p,即p = func。p的赋值可以不同,但一定要是函数的地址,并且署名和返回类型相同。  
   
  2 传递回调函数的地址给调用者   
          现在可以将p传递给另一个函数(调用者)-   caller(),它将调用p指向的函数,而此函数名是未知的:  
  void   caller(void(*ptr)())  
  {  
         ptr();   /*   调用ptr指向的函数   */    
  }  
  void   func();  
  int   main()  
  {  
         p = func;    
         caller(p);   /*   传递函数地址到调用者   */  
  }   
   如果赋了不同的值给p(不同函数地址),那么调用者将调用不同地址的函数。赋值可以发生在运行时,这样使你能实现动态绑定。   
   
 3 调用规范   
          到目前为止,我们只讨论了函数指针及回调而没有去注意ANSI C/C++的编译器规范。许多编译器有几种调用规范。如在Visual   C++中,可以在函数类型前加_cdecl,_stdcall或者_pascal来表示其调用规范(默认为_cdecl)。C++  Builder也支持_fastcall调用规范。调用规范影响编译器产生的给定函数名,参数传递的顺序(从右到左或从左到右),堆栈清理责任(调用者或者被调用者)以及参数传递机制(堆栈,CPU寄存器等)。   
          将调用规范看成是函数类型的一部分是很重要的;不能用不兼容的调用规范将地址赋值给函数指针。例如:   
     __stdcall  int  callee(int);                   //   被调用函数是以int为参数,以int为返回值   
    void  caller( __cdecl  int(*ptr)(int) );   //   调用函数以函数指针为参数     
    __cdecl  int(*p)(int) = callee;             //   在p中企图存储被调用函数地址的非法操作,出错   
          指针p和callee()的类型不兼容,因为它们有不同的调用规范。因此不能将被调用者的地址赋值给指针p,尽管两者有相同的返回值和参数列。     
    
  4 回调函数、消息和事件例程   
          调用(calling)机制从汇编时代起已经大量使用:准备一段现成的代码,调用者可以随时跳转至此段代码的起始地址,执行完后再返回跳转时的后续地址。CPU为此准备了现成的调用指令,调用时可以压栈保护现场,调用结束后从堆栈中弹出现场地址,以便自动返回。借堆栈保护现场真是一项绝妙的发明,它使调用者和被调者可以互不相识,于是才有了后来的函数和构件,使吾辈编程者如此轻松愉快。若评选对人类影响最大之发明,在火与车轮之后,笔者当推压栈调用。  
          话虽这样说,此调用机制并非完美。回调函数就是一例。函数之类本是为调用者准备的美餐,其烹制者应对食客了如指掌,但实情并非如此。例如,写一个快速排序函数供他人调用,其中必包含比较大小。麻烦来了:此时并不知要比较的是何类数据--整数、浮点数、字符串?于是只好为每类数据制作一个不同的排序函数。更通行的办法是在函数参数中列一个回调函数地址,并通知调用者:君需自己准备一个比较函数,其中包含两个指针类参数,函数要比较此二指针所指数据之大小,并由函数返回值说明比较结果。排序函数借此调用者提供的函数来比较大小,借指针传递参数,可以全然不管所比较的数据类型。被调用者回头调用调用者的函数,故称其为回调(callback)。  
          回调函数使程序结构乱了许多。Windows  API  函数集中有不少回调函数,尽管有详尽说明,仍使初学者一头雾水。恐怕这也是无奈之举。无论何种事物,能以树形结构单向描述毕竟让人舒服些。如果某家族中孙辈又是某祖辈的祖辈,恐怕无人能理清其中的头绪。但数据处理之复杂往往需要构成网状结构,非简单的客户/服务器关系能穷尽。  
          Windows 系统还包含着另一种更为广泛的回调机制,即消息机制。消息本是 Windows 的基本控制手段,乍看与函数调用无关,其实是一种变相的函数调用。发送消息的目的是通知收方运行一段预先准备好的代码,相当于调用一个函数。消息所附带的 WParam 和   LParam 相当于函数的参数,只不过比普通参数更通用一些。应用程序可以主动发送消息,更多情况下是坐等Windows 发送消息。一旦消息进入所属消息队列,便检感兴趣的那些,跳转去执行相应的消息处理代码。操作系统本是为应用程序服务,由应用程序来调用。而应用程序一旦启动,却要反过来等待操作系统的调用。这分明也是一种回调,或者说是一种广义回调。其实,应用程序之间也可以形成这种回调。假如进程B收到进程 A 发来的消息,启动了一段代码,其中又向进程 A 发送消息,这就形成了回调。这种回调比较隐蔽,弄不好会搞成递归调用,若缺少终止条件,将会循环不已,直至把程序搞垮。若是故意编写成此递归调用,并设好终止条件,倒是很有意思。但这种程序结构太隐蔽,除非十分必要,还是不用为好。  
          利用消息也可以构成狭义回调。上面所举排序函数一例,可以把回调函数地址换成窗口handle。如此,当需要比较数据大小时,不是去调用回调函数,而是借API 函数----SendMessage 向指定窗口发送消息。收到消息方负责比较数据大小,把比较结果通过消息本身的返回值传给消息发送方。所实现的功能与回调函数并无不同。当然,此例中改为消息纯属画蛇添脚,反倒把程序搞得很慢。但其他情况下并非总是如此,特别是需要异步调用时,发送消息是一种不错的选择。假如回调函数中包含文件处理之类的低速处理,调用方等不得,需要把同步调用改为异步调用,去启动一个单独的线程,然后马上执行后续代码,其余的事让线程慢慢去做。一个替代办法是借 API函数   PostMessage 发送一个异步消息,然后立即执行后续代码。这要比自己搞个线程省事许多,而且更安全。  
          如今我们是活在一个object 时代。只要与编程有关,无论何事都离不开 object。但 object 并未消除回调,反而把它发扬光大,弄得到处都是,只不过大都以事件(event)的身份出现,镶嵌在某个结构之中,显得更正统,更容易被人接受。应用程序要使用某个构件,总要先弄清构件的属性、方法和事件,然后给构件属性赋值,在适当的时候调用适当的构件方法,还要给事件编写处理例程,以备构件代码来调用。何谓事件?它不过是一个指向事件例程的地址,与回调函数地址没什么区别。  
          不过,此种回调方式比传统回调函数要高明许多。首先,它把让人不太舒服的回调函数变成一种自然而然的处理例程,使编程者顿觉气顺。再者,地址是一个危险的东西,用好了可使程序加速,用不好处处是陷阱,程序随时都会崩溃。现代编程方式总是想法把地址隐藏起来(隐藏比较彻底的如 VB和 Java),其代价是降低了程序效率。事件例程使编程者无需直接操作地址,但并不会使程序减速。更妙的是,此一改变,本是有损程序结构之奇技怪巧变成一种崭新设计理念,不仅免去被人抨击,而且逼得吾等凡人净手更衣,细细研读,仰慕至今。只是偶然静心思虑,发觉不过一瓶旧酒而已,故引得此番议论,让诸君见笑了。  

 

    
函数调用方式      
  我们知道在进行函数调用时,有几种调用方法,主要分为C式,Pascal式。在C和C++中,C式调用是缺省的,类的成员函数缺省调用为_stdcall。二者是有区别的,下面我们用实例说明一下:   
  1.   __cdecl   :C和C++缺省调用方式  
  例子:    
  void   Input( int &m,  int &n);  /*相当于void   __cdecl   Input(int &m,  int &n);*/  
  以下是相应的汇编代码:  
  00401068   lea   eax,[ebp-8]                         ;取[ebp-8]地址(ebp-8),存到eax  
  0040106B   push   eax                                   ;然后压栈  
  0040106C   lea   ecx,[ebp-4]                          ;取[ebp-4]地址(ebp-4),存到ecx  
  0040106F   push   ecx                                     ;然后压栈  
  00401070   call   @ILT+5(Input) (0040100a)  ;然后调用Input函数  
  00401075   add   esp,8                                   ;恢复栈   
          从以上调用Input函数的过程可以看出:在调用此函数之前,首先压栈ebp-8,然后压栈ebp-4,然后调用函数Input,最后Input函数调用结束后,利用esp+8恢复栈。由此可见,在C语言调用中默认的函数修饰_cdecl,由主调用函数进行参数压栈并且恢复堆栈。  
  下面看一下:地址ebp-8和ebp-4是什么?  
          在VC的VIEW下选debug   windows,然后选Registers,显示寄存器变量值,然后在选debug   windows下面的Memory,输入ebp-8的值和ebp-4的值(或直接输入ebp-8和-4),看一下这两个地址实际存储的是什么值,实际上是变量 n的地址(ebp-8),m的地址  
  (ebp-4),由此可以看出:在主调用函数中进行实参的压栈并且顺序是从右到左。另外,由于实参是相应的变量的引用,也证明实际上引用传递的是变量的地址(类似指针)。

        总结:在C或C++语言调用中默认的函数修饰_cdecl,由主调用函数进行参数压栈并且恢复堆栈,实参的压栈顺序是从右到左,最后由主调函数进行堆栈恢复。由于主调用   函数管理堆栈,所以可以实现变参函数。另外,命名修饰方法是在函数前加一个下划线(_).  
   
  2.   WINAPI   (实际上就是PASCAL,CALLBACK,_stdcall)  
  例子:    
  void   WINAPI   Input( int &m,  int &n);  
  看一下相应调用的汇编代码:  
  00401068   lea   eax,[ebp-8]  
  0040106B   push   eax  
  0040106C   lea   ecx,[ebp-4]  
  0040106F   push   ecx  
  00401070   call   @ILT+5(Input)   (0040100a)  
     
          从以上调用Input函数的过程可以看出:在调用此函数之前,首先压栈ebp-8,然后压栈ebp-4,然后调用函数Input,在调用函数Input之后,没有相应的堆栈恢复工作(为其它的函数调用,所以我没有列出) 下面再列出Input函数本身的汇编代码:(实际此函数不大,但做汇编例子还是大了些,大家可以只看前和后,中间代码与此例子无关)    
  39:   void   WINAPI   Input(   int   &m,int   &n)  
  40:   {  
  00401110   push   ebp  
  00401111   mov   ebp,esp  
  00401113   sub   esp,48h  
  00401116   push   ebx  
  00401117   push   esi  
  00401118   push   edi  
  00401119   lea   edi,[ebp-48h]  
  0040111C   mov   ecx,12h  
  00401121   mov   eax,0CCCCCCCCh  
  00401126   rep   stos   dword   ptr   [edi]  
  41:   int   s,i;  
  42:  
  43:   while(1)  
  00401128   mov   eax,1  
  0040112D   test   eax,eax  
  0040112F   je   Input+0C1h   (004011d1)  
  44:   {  
  45:   printf("/nPlease   input   the   first   number   m:");  
  00401135   push   offset   string   "/nPlease   input   the   first   number   m"...   (004260b8)  
  0040113A   call   printf   (00401530)  
  0040113F   add   esp,4  
  46:   scanf("%d",&m);  
  00401142   mov   ecx,dword   ptr   [ebp+8]  
  00401145   push   ecx  
  00401146   push   offset   string   "%d"   (004260b4)  
  0040114B   call   scanf   (004015f0)  
  00401150   add   esp,8  
  47:  
  48:   if   (   m=   s   )  
  004011B3   mov   eax,dword   ptr   [ebp+8]  
  004011B6   mov   ecx,dword   ptr   [eax]  
  004011B8   cmp   ecx,dword   ptr   [ebp-4]  
  004011BB   jl   Input+0AFh   (004011bf)  
  57:   break;  
  004011BD   jmp   Input+0C1h   (004011d1)  
  58:   else  
  59:   printf("   m   <   n*(n+1)/2,Please   input   again!/n");  
  004011BF   push   offset   string   "   m   <   n*(n+1)/2,Please   input   agai"...   (00426060)  
  004011C4   call   printf   (00401530)  
  004011C9   add   esp,4  
  60:   }  
  004011CC   jmp   Input+18h   (00401128)  
  61:  
  62:   }  
  004011D1   pop   edi  
  004011D2   pop   esi  
  004011D3   pop   ebx  
  004011D4   add   esp,48h  
  004011D7   cmp   ebp,esp  
  004011D9   call   __chkesp   (004015b0)  
  004011DE   mov   esp,ebp  
  004011E0   pop   ebp  
  004011E1   ret   8   
  
          最后,我们看到在函数末尾部分,有ret 8,明显是恢复堆栈,由于在32位C++中,变量地址为4个字节(int也为4个字节),所以弹栈两个地址即8个字节。 由此可以看出:在主调用函数中负责压栈,在被调用函数中负责恢复堆栈。因此不能实现变参函数,因为被调函数不能事先知道弹栈数量,但在主调函数中是可以做到的,因为参数数量由主调函数确定。下面再看一下,ebp-8和ebp-4这两个地址实际存储的是什么值,ebp-8地址存储的是n的值,ebp-4存储的是m的值。说明也是从右到左压栈,进行参数传递。   
          总结:在主调用函数中负责压栈,在被调用函数中负责弹出堆栈中的参数,并且负责恢复堆栈。因此不能实现变参函数,参数传递是从右到左。另外,命名修饰方法是在函数前加一个下划线(_),在函数名后有符号(@),在@后面紧跟参数列表中的参数所占字节数(10进制),如:void   Input(int   &m,int   &n),被修饰成:_Input@8  
         对于大多数api函数以及窗口消息处理函数皆用CALLBACK,所以调用前,主调函数会先压栈,然后api函数自己恢复堆栈。     
  如:    
  push   edx  
  push   edi  
  push   eax  
  push   ebx  
  call   getdlgitemtexta  

 

 

Win32程序函数调用时堆栈变化情况分析   
      在经典的汇编语言教程中,函数调用时堆栈的使用都是着重讲解的问题。如今随着高级语言的越来越完善,单纯使用汇编开发的程序已经不多了。但对函数调用时堆栈动向的了解仍有助于我们明晰程序的执行流程,从而在程序编写和调试的过程中有一个清晰的思路。     
  一.调用约定  
  在Win32中,有关函数的调用主要有两种约定。  
  1._stdcall :用于Win32  API函数和COM+接口。它从右向左将参数推入堆栈,被调函数在返回之前从堆栈中弹出自己的参数。从堆栈中弹出自己参数的函数不支持参数数目的变化。 
  2.__cdecl: 它也是从右向左传递参数。但是被调函数不负责从堆栈中弹出参数,调用函数将在函数调用返回后清空堆栈。__cdecl约定是C/C++函数的默认调用约定。
CALLBACK采用方式_stdcall ,它称为回调函数,即供系统调用的函数。例如窗口函数、定时处理函数、线程处理函数等。 CALLBACK 是老式叫法,现在微软都改为WINAPI.   
              
  二.Win32函数调用过程   
  1.  压入参数  
       这里依据以上的调用方式将调用者给出的参数一一压入堆栈。   
  2.  压入断点   
       当程序执行到Call指令的时候,当前语句的地址作为断点地址压入堆栈。     
  3.  跳转  
       eip的值被重新设置为被调函数的起始地址。     
  4.  mov   ebp,   esp  
       这里ebp被用来在堆栈中寻找调用者压入的参数,同时作为调用者堆栈指针的一个备份。在此前还应该执行一条:  
  push   ebp  
       把ebp中原来的数值保存。   
  5.  sub   esp,N  
        这里N是函数内局部变量的总字节数加上一个整数,一般为40。此后esp即为被调函数的堆栈指针了。  
  6.  初始化esp~esp-N之间的N字节空间  
       这是对堆栈中已分配给局部变量使用的内存空间的初始化,一般全部设置为0xcc。   
  7.  顺序执行函数内语句。  
       此时函数的堆栈位于所有局部变量的内存空间之后,二者之间一般有40字节的隔离带。   
  8.返回  
       为保障调用的正常返回,函数内应当保证规范使用堆栈,使即将返回的时候esp的值恢复为执行第一条语句前的状态。说明白点,就是每一条push都要有相应的pop。  
  调用返回的过程如下:  
  mov   esp,   ebp  
  执行后,esp恢复为调用者的堆栈指针,栈顶除断点地址外,还存有原ebp的值和调用时压入的参数。  
  然后依次弹出ebp的值和断点地址。如果是__cdecl约定则直接返回调用者,调用者将负责调整堆栈,丢弃调先前压入的参数。如果是__stdcall则这个工作由被调函数来执行。  
   
  程序样例如下:  
  ……  
  0040B8E8       push                 1                         ;压入参数  
  0040B8EA       call                 00401028                 ;调用函数  
  ……  
  00401028       jmp                   0040b7c0                 ;跳转到函数入口  
  ……  
  0040B7C0       push                 ebp                         ;保存ebp  
  0040B7C1       mov                   ebp,esp                  
  0040B7C3       sub                   esp,44h                 ;设置函数的堆栈指针,此函数中有4  
  ;字节的局部变量  
  0040B7C6       push                 ebx  
  0040B7C7       push                 esi                  
  0040B7C8       push                 edi  
  0040B7C9       lea                   edi,[ebp-44h]          
  0040B7CC       mov                   ecx,11h  
  0040B7D1       mov                   eax,0CCCCCCCCh  
  0040B7D6       rep   stos         dword   ptr   [edi]         ;初始化局部变量空间  
  0040B7D8       mov                   eax,dword   ptr   [ebp+8]  
  0040B7DB       mov                   dword   ptr   [ebp-4],eax  
  ……  
  0040B7DE       pop                   edi                         ;弹出曾压栈的数据  
  0040B7DF       pop                   esi  
  0040B7E0       pop                   ebx  
  0040B7E1       mov                   esp,ebp                 ;恢复调用者的堆栈  
  0040B7E3       pop                   ebp                         ;弹出原ebp值  
  0040B7E4       ret                   4                         ;返回并将堆栈向上调整4字节。  
  ;此处为__stdcall约定,所以由函数调整堆栈  
   
  相应的C代码如下:    
  void   __stdcall   fun(int);  
   
  int   main(void)  
  {  
          ……          
  fun(1);  
  ……  
          return   0;  
  }  
   
  void   __stdcall   fun(int   para)  
  {  
          int   localpara   =   para;  
          ……  
  }     
     
 三、 C++编译时函数名修饰约定规则:     
  __stdcall调用约定:    
  1、以“?”标识函数名的开始,后跟函数名;    
  2、函数名后面以“@@YG”标识参数表的开始,后跟参数表;    
  3、参数表以代号表示:    
  X--void   ,    
  D--char,    
  E--unsigned   char,    
  F--short,    
  H--int,    
  I--unsigned   int,    
  J--long,    
  K--unsigned   long,    
  M--float,    
  N--double,    
  _N--bool,    
  ....    
  PA--表示指针,后面的代号表明指针类型,如果相同类型的指针连续出现,以“0”代替,一个“0”代表一次重复;    
  4、参数表的第一项为该函数的返回值类型,其后依次为参数的数据类型,指针标识在其所指数据类型前;    
  5、参数表后以“@Z”标识整个名字的结束,如果该函数无参数,则以“Z”标识结束。    
   
  其格式为“?functionname@@YG*****@Z”或“?functionname@@YG*XZ”,例如    
  int   Test1(char   *var1,unsigned   long)-----“?Test1@@YGHPADK@Z”    
  void   Test2()   -----“?Test2@@YGXXZ”    
   
  __cdecl调用约定:    
  规则同上面的_stdcall调用约定,只是参数表的开始标识由上面的“@@YG”变为“@@YA”。     


  __fastcall调用约定:    
  规则同上面的_stdcall调用约定,只是参数表的开始标识由上面的“@@YG”变为“@@YI”。     
  编写DLL的时候输出函数老是不认就是编译器解释的不同造成的。EXTERN  "C"就是为了统一。全部用c方式解释。   
  两种声明是不同的,vc默认的调用是__cdecl   ,所以   extern   "C"   __declspec(dllimport)   int   mydllfunc(int);是extern   "C"   __declspec(dllimport)   int   __cdecl   mydllfunc(int);  
  因此和extern   "C"   __declspec(dllimport)   int   __stdcall   mydllfunc(int);是有区别的    

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值