老“码”识途
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.3 函数调用和局部变量

1.3.1 计算指令中的跳转地址

函数是C语言为我们提供的一份大礼,它封装了许多细节。掌握一个函数的调用细节,对培养计算机的思维方式有莫大帮助,现在就开始这一探索之旅吧。

首先,给出DM1-10所示的代码。然后在main()函数的“z=Add(1,2);”行设置断点,按F5键,调试运行并停止在该断点处。反汇编代码如下:

                                  DM1-10
int Add(int x, int y){
  int sum;
  sum=x+y;
  return sum;
}
void main(){
  int z;
  z = Add(1, 2);
  printf("z=%d\n", z);
}
z = Add(1, 2);
00413762     6a 02           push  2
00413764     6a 01           push  1
00413766     e8 7a da ff ff  call  004111e5
0041376B     83 c4 08        add   esp, 8
0041376E     89 45 f8        mov   dword ptr [ebp-8], eax

执行了call指令后,程序就跳到Add()函数。可知,call指令完成了函数调用,而且执行call指令后,函数确实跳转到了0x004111e5地址。(还是从机器码开始分析来寻找这个跳转信息。)对于机器码e8 7a da ff ff,先来猜测它的含义。习惯上,e8似乎代表call指令,后面4字节似乎与跳转地址相关。怎样证明?一时也看不出后4字节是否与跳转地址相关,那么再加一个新的函数,来看是否第1字节代表call指令。添加一个sub()函数,其调用反汇编如下:

sub(2, 1);
004137BC 6a 01            push  1
004137BE 6a 02            push  2
004137C0 e8 25 da ff ff   call  004111ea

可看到新的call指令的机器码是e8 25 da ff ff。e8依然在第1字节,后面4字节则变化了,因此可推定e8代表了call指令。下面看后4字节是否与跳转地址相关。

00413766 e8 7a da ff ff      call  004111e5

要跳转去的地址是0x004111E5,而机器码后4字节是7a da ff ff。这个数也与地址0x004111E5无关。根据对mov指令的经验,小端机内存中的7a da ff ff代表的值就是0xffffda7a。(但这个似乎也不对。)我们需要分析可能的跳转机制。生活中的例子能给我们最好的提示,程序不过是自然和社会中各种机制的一种模拟罢了。生活中要找一个地方有两种方法:一是用经纬度这种方式绝对定位出位置,二是用一种指路方式“从这里左拐,然后右拐(右转弯),再左拐……”。这是用指路人位置作为坐标进行相对偏移。其实,计算机寻址(定位某个地址)也有这两种方式。绝对定位就是直接给出地址值,如mov指令中用全局变量地址直接赋值,相对定位就是用偏移量。那么,call机器码中后4字节代表的0xffffda7a是否是偏移量呢?

从上面可知,call指令在0x00413766地址处,加上0xffffda7a应该是一个非常大的数,从“call 004111e5”指令给出的提示可知是要跳转到0x4111e5,这个似乎也不对。偏移量是区分正负的(与往前还是往后跳转相关),这个看上去很大的偏移量0xffffda7a是否是一个负数?这里需要用到一个基础知识:计算机中负整数的表示。

负整数的表示

补码表示:即其正整数值求反加1。比如,-1就是1求反,它只有最后一位是0,其他位均为1,再加1,那么所有位均为1,即0xffffffff。对有符号数,最高bit为1,则说明这它是一个负数。

如果对一个补码形式的负数求其正数值,就是求反加1。比如,对-1求其正数值,就是对0xffffffff求反,即0,再加1,就是1。

0xffffda7a最高位为1,是负数。试将其求反加1:该值的二进制表示是1111 1111 1111 1111 1101 1010 0111 1010;求反,为0000 0000 0000 0000 0010 0101 1000 0101;再加1,其十六进制表示为0x2586。来看call指令,打开计算器做一个减法。

这个减法的结果是0x4111E0,理论上应该是跳转到的地址004111E5,如上面虚线箭头指示的地址。可是两者不相等。(还好,这两个值已经非常接近了,它们只差5字节。)这5字节是怎么回事?(我们需要思考、分析,不要放过任何一个理所当然的要素。很多时候,分析程序有点像脑筋急转弯,你忽略掉的理所当然的因素恰恰是关键点。)在用相对偏移量寻址时是怎么计算的?是基址+偏移量。基址的选择必须是call指令的地址吗?是的,但并非必须。call指令有5字节,而刚才的计算恰好差了5字节。这是巧合吗?

如果我们尝试将基址从call的起始处放到结束处来看,即call的下一条指令的起始地址来看新计算结果,那么call指令转跳的地址是0x00413766+call指令长度5–偏移量0x2585 = 0x4111e5。0x4111e5就是call指令指示出来的跳转地址。正确了。

call指令的偏移量寻址

x86系列CPU的call指令的寻址方式为:用与call指令相关的偏移量定位转跳到的地址。

偏移量计算如下:偏移量 = 转跳到的地址 -call指令后一条指令的起始地址。

1.3.2 返回故乡的准备

函数的特点是调用后会返回,那么如何得知这个返回地址?它是存储在函数代码中吗?就像mov指令中的地址那样?如果这样,岂非只能返回到一个固定地址?因为代码在运行期是不变的,但函数可在不同地方被调用,这意味着可返回到不同地址。那么,函数返回地址是否可以像参数一样传递给函数?

我们来看call指令除了跳转外,还对系统施加了什么影响。首先,call指令如下:

00413796  e8 4a da ff ff    call  004111e5
0041379b  83 c4 08        add  esp, 8

call的返回地址是0x0041379b。call执行后是否存储了该地址?如果是,该地址存储在何处?一般而言,数据或者存储在内存中,或者存储在寄存器中。

如果返回地址存储在内存中,那么从哪里获取该内存地址?不外乎指令、内存和寄存器。放在指令中已不可能,因为call指令的5字节只能放下偏移量。放到内存更不行,要访问该内存,其地址又从何获取?这回到了本欲解决的问题。只剩下从寄存器获取。

如果返回地址存储在寄存器中,那么call执行后某个寄存器中就是返回地址。

综合以上两点,某个寄存器中的值或者是返回地址,或者指向某内存地址,它存储了返回地址。(那就观察寄存器的变化来实证吧。)在call指令处设置断点,按F11键,单步执行一次,这时call执行后变化的寄存器值在图1.23中标注出来。变化的寄存器有两个,它们是我们的关注点。EIP中存储的是call跳转到的指令的地址,从反汇编代码窗体中可证明(见图1.24),箭头所指的位置就是EIP指示的地址0x004111e5。因此,EIP寄存器与我们的分析无关。

图1.23

图1.24

现在关注另一个变化的寄存器ESP,其值为0x00116e60。它或者是返回地址,或者是存放返回地址内存的地址。显然,它并非call指令的返回地址0x0041379b。那么,它指向内存中存储的值是什么?在内存窗体中输入地址0x00116e60,结果见图1.25。根据小端机原则,内存中9b 37 41 00代表的值是0x0041379b,这正是call指令返回地址。可知,call指令将返回地址保存在内存中,而且ESP寄存器指向了该内存。1.3.6节将讨论该返回地址如何被使用。

图1.25

栈(stack)

栈是一个先进后出的数据结构,先压栈的数据后出栈。出栈操作总是将当前栈顶的数据弹出。如果当前栈为空,压栈2后,栈顶存储的就是2,再压栈4,那么栈顶存储的就是4。执行出栈操作,就会将栈顶的4弹出,此时当前栈顶存储的就是2,如果再次执行出栈操作,2将出栈。这就是先进后出(详细可参阅数据结构的书籍)。

ESP寄存器(Extended Stack Pointer)存储栈顶内存地址。我们可在调试环境中查看寄存器窗体,获取当前的ESP值,然后将该值输入到内存窗体中(用十六进制形式),即可查看栈中数据。

压栈的指令是push,如“push 1”将1压栈,这时ESP指向存储1的内存地址。再执行“push 0c”,则0c将存入当前栈顶,ESP将指向存储0c的内存地址。每次压栈,32位机上都将用4字节存储压栈值。在x86中,push指令使ESP的值减4。换句话说,越压栈,栈顶地址越小。有的CPU中,压栈是增加ESP的值。

出栈指令为“pop寄存器”。比如,“pop EAX”就是将当前ESP指向的栈顶中存储的值赋值给EAX,并且ESP的值加4。

call指令功能

call指令相当于以下两条指令的组合:

push  返回地址
jmp   函数入口地址

其中,返回地址通过第一步保存在栈上,函数返回时会用到。

1.3.3 给函数传递参数

函数需要参数,现在来看参数是如何传递的。

还是将之前的语句“z = Add(1, 2);”进行反汇编:

004139a2  6a 02             push  2
004139a4  6a 01             push  1
004139a6  e8 3a d8 ff ff    call  p (4111e5h)
004139ab  83 c4 08          add   esp, 8

两条push指令应该是传递2和1两个参数,通过栈来传递。我们还是应该实证。最简单的方法就是将该语句改成“z=Add(2,1);”,调换参数的顺序。再次反汇编,则先压栈1后压栈2。基本可证明这个猜测,甚至还推断出C语言的参数传递是从右往左压栈传递参数。

下面单步分析执行这三条指令时栈的变化。在“push 2”处设置断点,在该断点中断,此时ESP的值是0x00116e6c,将该值输入内存窗体,然后用滚动条往上滚动一行,我们查看整个栈的情况,其结果见图1.26。

图1.26

单步执行“push 2”,查看ESP的值为0x00116e68,比执行前的0x00116e6c少了4字节(x86栈顶地址小于栈底,压栈则使栈顶地址减小)。内存窗体中显示栈布局,见图1.27。此时ESP指向的栈顶所存值如图中黑线所示为2(小端机内存值02 00 00 00代表2)。

图1.27

再次单步执行“push 1”指令,查看ESP中的值为0x00116e64,比执行前的值0x00116e68也少了4字节。而内存窗体中显示栈布局见图1.28。此时ESP指向的栈顶所存值如图中黑线所示为1 (小端机内存值01 00 00 00代表1)。

图1.28

单步执行call指令后,栈内存见图1.29,ESP又减少了4字节,为0x00116e60,其指向的内容ab 39 41 00正好是call指令的返回地址0x004139ab。(该地址见本节开始的反汇编,是call指令后的指令“004139ab add esp,8”的地址。)

图1.29

由此我们验证了参数传递时,栈顶地址不断减小的过程,并查看到参数压栈到栈内存的布局。

1.3.4 函数获取参数

被调函数如何获取栈上1和2这两个参数?我们先来看执行了call指令后的栈映像,见图1.30,反映了图1.26~图1.29的结果。

图1.30

指令“push 2”执行后,ESP指向存储2的地址;指令“push 1”执行后,ESP减4指向存储1的地址,然后执行call指令,返回地址被压栈,ESP内容再次减4,指向保存返回地址的内存。

要获取到栈上存储的参数1和2,必须得到存储1和2内存的地址。如何得到它们?是将它们再次压栈传递吗?肯定不行。存储地址的内存的地址又怎样获取?我们观察,1和2是连续存放的,只要获取一个基点,就能通过偏移量计算出1和2存储的地址。从哪里获取基点的地址?它似乎不应该是保存在内存中,否则该内存的地址又无法获取。在计算机中,除了内存,就是寄存器用来存储信息,如在图1.30中,ESP寄存器指向当前栈顶。图中一槽内存是4字节,那么1的存储地址就是esp+4,2的存储地址就是esp+8。由于ESP将随栈的变化而变化,所以为了计算简便,可以将该值存储到另一个寄存器,如EBP(Extended Base Pointer,扩展基址指针寄存器)。如果将ESP存入EBP,那么刚才的两个地址就是ebp+4和ebp+8。

不过由于EBP本身存储了值,如果直接赋值修改它,当EBP原值要使用时,就没有办法了。比如,函数f1()调用函数f2(),如果f1()和f2()都用EBP存放基点地址,f2()后执行,修改了之前f1()设定的EBP值。当f2()返回时,如不还原EBP,f1()用EBP做基点就要出错。因此,必须有一个保存和恢复EBP值的过程。下面代码完成了这一过程,将EBP压到栈上,最后用“pop ebp”指令将栈顶存储的EBP值弹出来,赋值给EBP,从而还原。

    push  ebp
    ...
    pop  ebp

因此可以猜想,在被调用的函数开始处存在如下代码:

    push  ebp
    mov  ebp, esp

它保存了EBP的值,并将ESP的值赋值给EBP。压栈EBP后,ESP将再次减4字节,见图1.31。

图1.31

此时用EBP来计算参数1和2的存储地址。存储1的地址是ebp+8,因为这时距离1存放的地方有EBP和返回地址,共8字节。存储2的地址是ebp+0ch(即12)。现在反汇编Add()函数,查看是否与我们分析的一样。

验证1:见DM1-11。

                                  DM1-11
int Add(int x, int y)
{
  00411430   push    ebp
  00411431   mov    ebp, esp
  00411433   sub     esp, 0cch
  00411439   push    ebx
  0041143a   push    esi
  0041143b   push    edi
  0041143c   lea     edi, [ebp+ffffff34h]
  00411442   mov    ecx, 33h
  00411447   mov    eax, 0cccccccch
  0041144c   rep     stos dword ptr es:[edi]
  int sum;
  sum=x+y;
  0041144e   mov    eax, dword ptr [ebp+8]
  00411451   add     eax, dword ptr [ebp+0ch]
}

反汇编代码中,黑体标注的前2行正如我们分析的那样。最后两条指令(黑体标注的)是“sum=x+y;”语句的对应指令。mov指令将地址为ebp+8的内存中存储的值赋给EAX,从图1.31看,ebp+8正好是存储1的地址,符合之前分析,执行后,EAX的值为1。其后的add指令将内存地址为ebp+0ch中存的值,就是2,与EAX相加并将其和存回EAX。执行完,该指令后EAX就是参数1与2的和3。一切如猜测。

验证2:在单步状况下停在最后两条指令处,用监视窗体查看&x和&y,获取两个参数的地址;然后通过寄存器,将ebp+8和ebp+0ch的值求出,与监视窗体中的&x和&y值比较,发现ebp+8、ebp+0ch确实是x和y的地址。

1.3.5 局部变量

局部变量在哪里分配?其特点与参数一样,当函数调用完毕就不再使用,所以仿效参数,就是将其分配在栈上。栈上方已经被参数等使用,我们只有使用栈更低地址的空间,也就是继续压栈分配局部变量,见图1.32,即ebp–4指向的内存。

反汇编,来看这个假设是否正确。

sum=x+y;
0041144e     mov  eax, dword ptr [ebp+8]
00411451     add  eax, dword ptr [ebp+0ch]
00411454     mov  dword ptr [ebp-8], eax

从前面分析已知,第1、2两条指令将x和y求和,并放入EAX中。最后的指令将EAX的值存入ebp–8指向的内存中(断点后单步执行到第3条指令,用监视器查看&sum的值确实是ebp–8),并非ebp–4,见图1.33。(请用内存窗体和寄存器窗体单步跟踪,查看这些相关内存的变化。)奇怪的是,与猜测一致,在Visual C++ 6.0中,反汇编代码如下:

00401038     mov  eax, dword ptr [ebp+8]     //获取参数x
0040103b     add  eax, dword ptr [ebp+0ch]   //获取参数y,将x、y求和放入EAX
0040103e     mov  dword ptr [ebp-4], eax     //将和存到局部变量sum,其地址是ebp-4

在VS 2008中,为了防止溢出攻击,所以产生了该现象。详细见习题4。

不过,至此我们应该有一个简单的结论:在使用了EBP寻址的函数中,ebp+偏移量就是参数的地址,ebp–偏移量就是局部变量的地址。

1.3.6 返回故乡

函数执行完毕就要返回调用的地方,从1.3.2节知道,call指令将其后指令的地址压栈保存,我们要查看它如何用该地址返回。首先介绍返回指令ret。

图1.32

图1.33

ret指令:将栈顶保存的地址弹入指令寄存器EIP,相当于“pop eip”,从而让程序跳转到该地址。执行ret指令后,寄存器EIP(存储了栈顶中保存的那个地址)和ESP(在32位x86中加4)的值有变化。

从ret的功能看,编译器要保证执行该命令时,ESP正好指向call指令压栈保存返回地址的那段内存,我们跟踪的代码见DM1-13。push指令的压栈顺序是ebx → esi → edi,pop指令的出栈顺序是edi → esi → ebx。正好体现了栈的先进后出原则。push与pop之间的指令没有影响ESP的值(可在0041143c指令处观察ESP的值,并在0041145a处观察ESP的值,两者相同),所以这两组指令完成了对EBX、ESI、EDI的值的保存和恢复。

                                  DM1-13
{
  00411430     push  ebp
  00411431     mov   ebp, esp
  00411433     sub   esp, 0cch
  00411439     push  ebx
  0041143a     push  esi
  0041143b     push  edi
  0041143c     lea   edi, [ebp+ffffff34h]
  00411442     mov   ecx, 33h
  00411447     mov   eax, 0cccccccch
  0041144c     rep   stos dword ptr es:[edi]
  int sum;
  sum=x+y;
  0041144e     mov   eax, dword ptr [ebp+8]
  00411451     add  eax, dword ptr [ebp+0ch]
  00411454     mov   dword ptr [ebp-8], eax
  return sum;
  00411457     mov   eax, dword ptr [ebp-8]
}
0041145a    pop  edi
0041145b    pop  esi
0041145c    pop  ebx
0041145d    mov esp,ebp
0041145f    pop ebp
00411460    ret

最后3条斜体标注的指令中,0041145d处的指令将EBP赋值给ESP。注意,函数执行00411431处指令后,栈映像见图1.33。当执行0041145d处指令时,EBP未改变。图1.34给出了执行其前后的栈映像。

在执行“mov esp, ebp”指令后,ESP正好指向保存EBP值的栈顶,因此其后的“pop ebp”指令弹出保存的EBP旧值给EBP,恢复EBP,使得ESP加4,指向保存返回地址的内存,见图1.35。这时执行ret指令,正好将栈顶保存的返回地址弹给EIP,完成了返回。

我们用调试器来跟踪观察以上分析,完成实证过程。首先,call指令执行完后,进入Add()函数时程序的状态见图1.36,包括代码窗体、内存窗体和寄存器状态,call压栈的返回地址为0x004139ab。

执行“push edi”指令后的ESP的值见图1.37。然后,在执行“pop edi”前观察此时ESP的值,与图1.37中的0x00116D84相同,见图1.38。

读者可以自己跟踪之前3条push指令压栈到内存中的值,然后配合ESP查看指向的内存,查看pop指令恢复这些寄存器的过程,体验先进后出的栈结构在保存和恢复寄存器的值所起的作用。

下面省略“mov esp, ebp”和“pop ebp”两条指令的观察(读者可仿效上面过程自己查看),直接在ret指令前停下,查看状态,见图1.39。ESP恢复到了0x00116E60,与图1.36执行call指令后ESP的值一样,说明这时栈已经还原到刚刚进入Add()函数时的状态了,观察ESP指向的内存,图1.39中正好是0x004139ab,即call指令压栈的返回地址。这时执行ret,就会将当前栈顶(esp指向的内存)保存的地址0x004139ab压入EIP,并将esp加4,见图1.40。

图1.34

图1.35

图1.36

图1.37

图1.38

图1.39

图1.40

1.3.7 返回点什么

函数可有返回值,那么如何返回它们?通过寄存器。

返回小于4字节的数

函数返回值:编译器习惯上用eax作为存储返回值的寄存器,被调用方在ret前设定eax,返回后,调用方从eax获取到该值。

例如,对于代码DM1-14,第1条mov指令将ebp–8指向的内存(即sum变量的内存,见1.3.5节)中存储的值赋给了EAX,之后的指令不修改EAX的值(在寄存器窗体中查看该状况)。

                                  DM1-14
  return sum;
  00411457     mov  eax, dword ptr [ebp-8]
}
  0041145a     pop  edi
  0041145b     pop  esi
  0041145c     pop  ebx
  0041145d     mov  esp, ebp
  0041145f     pop  ebp
  00411460     ret

调用方代码如下:

z = Add(1, 2);
004139a2     push  2
004139a4     push  1
004139a6     call  004111e5
004139ab     add  esp, 8
004139ae     mov  dword ptr [ebp-8], eax

call指令返回后,最后一条指令将EAX的值赋值给了ebp–8指向的内存。之前我们已经有这样的概念,ebp减一个偏移量就是局部变量的地址,该指令说明它将返回值赋给了一个局部变量。如果在该指令设置断点,查看ebp–8的值,并在监视窗体中查看&z,就会发现两者的值相同。

返回结构体

对于返回值的传递大家还有问题吗?似乎一切都很好,但仔细分析,EAX只能返回长度小于等于4字节的数据,如果返回值大于4字节怎么办?double类型(8字节)、用户自定义数据类型——结构体(有多少成员就有多大)的数据大于4字节。对于这些“过大”的数据怎样返回?(还是先猜测。)再大的数据,只要拿到地址就能访问。那么,是传给函数这个“大”数据的地址由函数去填写其内容,还是在函数中分配这个“大”数据的内存并返回其指针呢?如果是后者,会存在一个问题:该数据在何处分配?因为这个数据与局部变量一样只在函数中有效,自然会在栈上分配。可是当函数返回后,函数分配的内存“逻辑上”无效了,用“无效”指针访问于理不通,见图1.41。

图1.41

如果是调用者先分配一块内存,然后将其指针传入给函数由它写入返回值,那么被调函数返回时该内存并不失效,似乎可行,见图1.42。其中保存返回值的内存在栈的上部分配,其地址为0x12ff58,然后该地址作为一个参数被压栈传递给被调用函数。

图1.42

有了猜测,我们需要用实验来证明,用结构体来验证。需要用到结构体,请大家先参照1.4节理解结构体的相关知识。(在学习中经常会遇到这样的状态,在研究一个知识点时,发现必须先了解另外一个知识点,因此需要先去解决它,然后回头继续当前问题。某些时候,这样的跳转会发生多次,只要是在我们能力之内,跳转下去直到把一条知识线索都搞通也是一种学习方法,并非一定要所谓的“系统化”

相关代码见DM1-15。在main()函数的“r=myfunc();”处设置断点,执行并反汇编如下:

                                  DM1-15
struct myrd{
  int i1;
  int i2;
};
myrd myfunc(){
  myrd r1;
  r1.i1 = 1;
  r1.i2 = 2;
  return r1;
};
void main(int count, char** args){
  myrd r;
  r = myfunc();
}
myrd r;
r = myfunc();
0040120E   call        00401180
...

在call之前并没有如想象那样传入保存返回值内存的地址。(怎么回事?)在call指令处跟踪进函数(step into):

myrd r1;
r1.i1 = 1;
0040119e     mov  dword ptr [ebp-0ch], 1
r1.i2 = 2;
004011a5     mov  dword ptr [ebp-8], 2
return r1;
004011ac     mov  eax, dword ptr [ebp-0ch]
004011af     mov  edx, dword ptr [ebp-8]
…
ret

0040119e和004011a5处的两条指令将1和2赋值给了r1的两个成员变量,而004011ac和004011af处的两条指令又将r1的两个成员变量赋值给EAX和EDX。难道要返回的8字节的结构体是通过EAX和EDX返回的吗?如果是,在ret指令执行时,这两个寄存器应该是1和2。我们在之后的ret指令处设置断点,发现运行到ret时,EAX和EDX的值确实如此。

下面来检查调用方的代码,见DM1-16。

                                  DM1-16
myrd r;
r = myfunc();
0040120e    call  00401180
00401213    mov  dword ptr [ebp+ffffff24h], eax
00401219    mov  dword ptr [ebp+ffffff28h], edx
0040121f    mov  eax, dword ptr [ebp+ffffff24h]
00401225    mov  dword ptr [ebp-0ch], eax
00401228    mov  ecx, dword ptr [ebp+ffffff28h]
0040122e    mov  dword ptr [ebp-8], ecx

call指令后的两条指令将EAX和EDX的值分别赋给了地址为ebp+FFFFFF24h和ebp+FFFFFF28h的内存,确实是EAX和EDX用于返回结构体的值。剩下要证明的就是这个返回值赋给了变量r。我们发现,EAX的值最终赋值给了ebp–0ch,而edx最终赋值给了ebp–8,自然猜想ebp–0ch这个更小的地址应该是结构体myrd中靠前定义的成员变量i1的地址,也就是结构体r的首部地址。在监视窗体中输入如下表达式验证猜想,见图1.43,可知r的地址和ebp–0ch是一个值。(在表达式中用(void *)强制是为了用十六进制显示。)同理,请读者自己证明ebp–8就是r.i2的地址。

ebp+ffffff24h和ebp+ffffff28h两个地址的内存似乎被用来中转,EAX和EDX保存的8字节返回值先赋给这两个地址,然后从它们中读出,赋值给变量r。那么,这块内存在哪里?它们有何用?我们先将上面两个地址转换成容易读懂的东西,最高位为1就是负数,所以这两个地址其实可以转换为“ebp–xxx”的形式。将ffffff24h和ffffff28h求反加1,获得其正值,分别为dch和d8h。这两块内存的地址就是ebp–dch和ebp–d8h。它们明显小于r的地址ebp–0ch,可知这块内存位于局部变量r的下方,似乎是一个临时分配存储返回值的缓冲区。

我们不解的是,为何不直接将EDX和EAX中的返回值直接赋值给r?我们用的是Debug版,代码没有优化。这恰恰证明了之前的猜想:调用方分配一块缓冲存储返回值。优化的Release版本会用这个中转缓冲区吗?选择调试界面中的Release菜单(在VC 6.0中,调试Release需要专门设置,VS 2008不需要),见图1.44,反汇编的代码见DM1-17。

图1.43

图1.44

我们惊讶地发现,call之后那些赋值语句全部消失了!为什么将返回值赋值给r的逻辑给“优化”掉了?(优化的一个原则是看有用否。)r是main()的局部变量,而main()并未使用该局部变量,当然不用赋值。那么,只要让编译器认为变量r有用,它就不会被优化掉。如果将r定义为全局变量,要判断r是否有用就必须分析所有代码。(因为全局变量可被所有代码使用)。为了提高编译速度,编译器会“偷懒”,凡是对全局变量的操作会认为有用,不优化。

                                  DM1-17
  myrd r;
  r = myfunc();
  getchar();
  00401085      call
  00401474
}
  0040108a      xor  eax, eax

代码见DM1-18。果然,在call指令执行后,EAX和EDX直接赋值给了变量r。用监视窗体证明0041c000h是r的地址即可。

                                  DM1-18
myrd r;
void main(){
r = myfunc();
00401085    call  0040107e
0040108a    mov  dword ptr ds:[0041c000h], eax
0040108f    mov  dword ptr ds:[0041c004h], edx
getchar();

到此猜想并没有直接被证实。如果用EAX、EDX来传递返回值,结构体很大,寄存器不够用时怎样处理呢?是否与猜想一致呢?现在我们要做的就是将结构体变大,多定义一些成员变量。

struct myrd {
  int i1;
  int i2;
  int i3;
};
myrd myfunc() {
  myrd r1;
  r1.i1 = 1;
  r1.i2 = 2;
  r1.i3 = 3;
  return r1;
};
void main(int count, char** args) {
  myrd r;
  r = myfunc();
  getchar();
}

反汇编见DM1-19。本来myfunc没有参数,但是call指令之前的“push eax”指令向函数传递了一个参数。(这似乎印证了我们的猜想。)该参数是之前保存返回值的那个临时变量的地址吗?

                                  DM1-19
myrd r;
r = myfunc();
0040121e    lea  eax, [ebp+ffffff1ch]
00401224    push  eax
00401225    call  00401180
0040122a    add  esp, 4
0040122d    mov  ecx, dword ptr [eax]
0040122f    mov  dword ptr [ebp+ffffff08h], ecx
00401235    mov  edx, dword ptr [eax+4]
00401238    mov  dword ptr [ebp+ffffff0ch], edx
0040123e    mov  eax, dword ptr [eax+8]
00401241    mov  dword ptr [ebp+ffffff10h], eax
00401247    mov  ecx, dword ptr [ebp+ffffff08h]
0040124d    mov  dword ptr [ebp-10h], ecx
00401250    mov  edx, dword ptr [ebp+ffffff0ch]
00401256    mov  dword ptr [ebp-0ch], edx
00401259    mov  eax, dword ptr [ebp+ffffff10h]

lea指令:lea eax, [ebp + 10h]。其逻辑为eax=ebp + 10h。如执行该指令时,ebp为3,执行后,eax为13h。

我们可以跟踪进函数。先记录这个压栈的值,即EAX的值0x0012fe88。函数myfunc()的反汇编如下:

myrd r1;
r1.i1 = 1;
0040119e  mov  dword ptr [ebp-10h], 1
r1.i2 = 2;
004011a5  mov  dword ptr [ebp-0ch], 2
r1.i3 = 3;
004011ac  mov  dword ptr [ebp-8], 3
return r1;
004011b3  mov  eax, dword ptr [ebp+8]
004011b6  mov  ecx, dword ptr [ebp-10h]
004011b9  mov  dword ptr [eax], ecx
004011bb  mov  edx,dword ptr [ebp-0ch]
004011be  mov  dword ptr [eax+4], edx
004011c1  mov  ecx, dword ptr [ebp-8]
004011c4  mov  dword ptr [eax+8], ecx
004011c7  mov  eax, dword ptr [ebp+8]

根据1.3.4节,该参数的地址应该是ebp+8。我们要找到哪条指令用了该地址,004011b3处的语句正是将ebp+8中存的值赋给了EAX。ebp+8中的值正好是0x0012fe88,即call指令前压栈的EAX的值,见图1.45。*(void **) (ebp+8)将地址ebp+8强制转为指针的指针(void **),它指向的值被显示。

图1.45

“mov eax, dword ptr [ebp+8]”指令使EAX存放了函数调用前分配的那个临时变量的地址,那么,我们可将注意力集中在和EAX相关的后续指令:

mov  dword ptr [eax]..
mov  dword ptr [eax+4]..
mov  dword ptr [eax+8]..

这正好是对结构体3个成员变量的赋值。i1在开始处,所以其地址就是eax,i2离首部4字节(对应eax+4),i3离首部8字节(对应eax+8)。再分析代码并简单运用调试工具分析,上面三条赋值指令确实是将局部变量r1的3个字段赋值给调用方为保存返回值分配的临时内存。代码中的最后一条指令“004011c7 mov eax, dword ptr [ebp+8]”再次将传入的保存返回值的内存的地址(在内存ebp+8中),赋值给EAX。直到ret执行前,该EAX的值一直没有变化,那么,函数返回后, EAX指向的就是保存返回值的临时变量的地址。

下面分析返回后r=myfunc(1)的机理。call指令返回后的代码如下:

0040122d     mov  ecx, dword ptr [eax]
0040122f     mov  dword ptr [ebp+ffffff08h], ecx   //为临时内存2.i1赋值
00401235     mov  edx, dword ptr [eax+4]
00401238     mov  dword ptr [ebp+ffffff0ch], edx   //为临时内存2.i2赋值
0040123e     mov  eax, dword ptr [eax+8]

其中黑体标注的3条指令分别从eax偏移0、4、8字节的地址获取值,而此时eax指向保存返回值的结构体的首部,所以这3个偏移量正好是成员变量i1、i2、i3的地址。可以验证,它们之后的mov指令的目标地址(即ebp+ffffff08h等)不是指向变量r的相关成员。而在这些代码后还有一堆mov指令,可以证明ebp–10h就是变量r的地址,这些赋值命令将最终为变量r赋值。

0040124d     mov  dword ptr [ebp-10h], ecx       //为r.i1赋值
00401250     mov  edx, dword ptr [ebp+ffffff0ch]
00401256     mov  dword ptr [ebp-0ch], edx       //为r.i2赋值
00401259     mov  eax, dword ptr [ebp+ffffff10h]
0040125f     mov  dword ptr [ebp-8], eax         //为r.i3赋值

我们可得到内存映像以及与指令的对应关系,见图1.46。

图1.46

但是,返回值先赋值给临时内存2,再从临时变量2赋值给r。这样的中转意义何在?我们可以仿效前面全局变量的Release版本来看它的反汇编。将main()函数中的r变为全局变量,反汇编如下:

r = myfunc();
0040109b     lea     eax, [esp+4]
0040109f     push    edi
004010a0     push    eax
004010a1     call    0040107e
004010a6     mov     esi, eax
004010a8     mov     edi, 41c000h
004010ad     movs    dword ptr es:[edi], dword ptr [esi]
004010ae     movs    dword ptr es:[edi], dword ptr [esi]
004010af     add     esp, 4
004010b2     movs    dword ptr es:[edi], dword ptr [esi]

从call之前的“push eax”指令来看,应该是eax指向保存返回值的内存。然后将eax赋值给esi,更可推断函数通过eax将该内存的地址再次返回。而之后出现了3条奇怪的“movs dword ptr es:[edi], dword ptr[esi]”指令,几乎可以推断出是为结构体的3个成员变量赋值,esi既然指向返回值内存的地址,那么edi指向的应该是全局变量r的地址。这3条一模一样的指令完成了3个不同内存的赋值,可以猜想该指令执行完后会将源地址esi和目标地址edi的值分别加4。猜测完毕,剩下的就是实证我们的猜测:edi是否是r的地址,esi是否是返回值的地址,movs指令是否按我们的猜想执行?如果这样,我们在Release版本中看到了更清晰的“大数据”返回的算法。实证的过程留给大家来做。

1.3.8 扫尾工作

在学习过程中,我们应该关注每个细节,如果不能马上明白,也要记下来将来分析。在1.3.7节中,返回代码如下:

z = Add(1, 2);
004139a2  push   2
004139a4  push   1
004139a6  call   004111e5
004139ab  add    esp, 8
004139ae  mov    dword ptr [ebp-8], eax

调用返回后,在call指令下有一条黑体标注的指令,将栈加了8字节。这条指令与返回值没有关系,那它有何用?我们知道,栈是保存参数、返回地址、局部变量的,那么函数调用完毕后,这些参数、返回值还有用吗?当然无用。如果它们还消耗栈的空间,将发生什么情况?很明显,随着每次函数调用,栈空间将不断减少直至耗尽栈内存,即栈溢出(stack overflow)。请大家写一段简单的代码,让栈溢出(提示:递归)。局部变量在被调用函数中释放了,保存返回地址消耗的栈被ret指令弹出也释放了。那么参数呢?“push 1”和“push 2”传参消耗了8字节,我们应该恢复它。栈结构是如何释放内存的?压栈,栈顶地址变小,弹栈,栈顶地址变大。这时,原来压栈使用的内存即可被再次压栈,存入新内容。所以我们只需将压栈减去的8字节加回来就可以了。“add esp, 8”指令正好来完成这个工作。大家还可以参考1.3.7节的例子,保存返回值的内存地址作为参数传递给函数了,一个push指令消耗了4字节的栈,那么call返回后应该有“add esp, 4”这样的代码来清理平衡栈,如下面黑体标注的指令:

r = myfunc();
0040121e  lea     eax, [ebp+ffffff1ch]
00401224  push    eax
00401225  call    00401180
0040122a  add     esp, 4

大家不妨将参数的个数增加,来看看“add esp, xxx”指令中xxx的变化。

一切似乎到此就很清楚了,我们来看如下代码:

Add(1, 2);
Add(4, 5);
Add(100, 1);
…

大家想想其反汇编代码(见DM1-20),觉得其中有什么问题?

                                  DM1-20
push  2
push  1
call   ..
add   esp, 8
push  5
push  4
call   ...
add   esp, 8
push  1
push  100
call   ...
add   esp, 8
…

每个Add()函数调用后都要执行相同的指令“add esp, 8”。这会带来什么影响?100次调用就有100条add指令。而这100条add指令完全是重复的!什么办法可防止代码重复产生的空间开销?那就是将重复代码变成函数。如果我们将“add esp, 8”变成Add()函数的一部分,调用返回后则不需“add esp, 8”指令(因为函数中已经执行了该指令)。100次调用可节省99条add指令。如何来做?是否可将“add esp, 8”直接放入Add()函数中?我们要养成一个好习惯,不要只想到“可放入”就不管了,一定要想如何放入。(在“猜测,实证”这种方法中,猜测必须到细节,细节决定成败!这样你才会走得更深入,发现更多问题和有意思的东西。)

不妨做一个理想实验,我们将指令放在ret之后:

ret
add  esp, 8

这明显无意义,执行ret后下面指令是无法执行的。那么,放在ret前?

add  esp, 8
ret

一下也看不出端倪,继续关注细节。虚拟出栈布局,分析指令执行过程,图1.47为调用Add(1, 2)时的执行栈。

图1.47

执行“add esp, 8”指令之前,ESP应指向存返回地址的内存,那么执行后ESP将指向存储参数2的地址。再执行ret命令,2将压入EIP,程序将执行地址为2的内存中存储的东西,自然不对。这里的关键是“add esp, 8”必须在ret后执行,因为ret要弹出的返回地址在参数2和1的下方,只有弹出了下方的返回地址,才能以“add esp, 8”平衡栈。而之后执行已经被证明是不行的。所以,我们并没有办法像重用代码那样,将“add esp, 8”指令加入到被调用函数中!平常大家都觉得难以发现问题,很多时候老老实实地将每个想法想清楚“怎么做”,就能发现问题。现在问题出来了,要重用代码就要将“add esp, 8”的功能加入到被调用函数中,而执行顺序和栈布局又妨碍我们这样做(需常注意ret和栈的关系),该怎么办?

如果一个东西是合理的,我们就需要有支持它的机制。这里的关键是ret后执行栈顶增加的逻辑,两个动作无法分开来做,那么就需要新指令,支持返回和增加栈顶二合一的逻辑。

ret x

ret字节数:该指令将弹出栈顶的值作为返回值,并在弹出后将esp值加字节数。相当于以下指令二合一:ret和add esp,字节数。

我们来做一个实验,在Add()函数的名字前、返回类型后加上关键字_stdcall,代码如下:

int _stdcall Add(int x, int y){
  int sum;
  sum=x+y;
  return sum;
}

下面来看被调用函数和调用者的相关反汇编。首先是Add()函数的,见DM1-21。最后一条指令表明返回后esp要加8字节。

                                  DM1-21
  return sum;
  00411457      mov  eax, dword ptr[ebp-8]
}
0041145a    pop  edi
0041145b    pop  esi
0041145c    pop  ebx
0041145d    mov  esp, ebp
0041145f    pop  ebp
00411460    ret  8

再来看调用者的反汇编:

004139a2     push   2
004139a4     push   1
004139a6     call   004111f4
004139ab     mov    dword ptr [ebp-8], eax
printf("z=%d\n",z);
...

call指令返回后,只有赋值的mov指令,之前的“add esp, 8”指令消失了,因为栈顶在Add()函数中通过最后的“ret 8”指令得以增加。

现在我们不禁要问:什么原因导致C语言选择了调用方清理栈这种耗费代码空间的做法?获得了什么好处?从性能上分析,不过是时间和空间开销。空间上已经开销大了,难道时间上更快?两条指令(ret和add esp, 8)会比一条指令(ret 8)更快?似乎也不像。除性能还有什么?功能。C语言支持一种变参函数,即函数参数个数不确定,如我们最早接触过的函数printf(),根据字符串参数中格式化符号的不同,参数个数可不同(如printf(“%d\n”, i)与printf(“%s is %d”, “this”, i))。ret x方式不支持变参函数吗?在编译器中,在一个函数最后生成ret代码时,根据参数的不同,它生成的指令是固定的,如Add(1, 2)生成“ret 8”,sum(1, 2, 3)生成“ret 0c”。代码一经生成就固定了,“ret x”指令运行期中不可变,决定了其参数只能是固定值。否则,如果调用Add(1, 2, 3),岂非ret指令要从“ret 8”变为“ret 0c”?这是不可能的。世间万物都是有代价的,功能、性能、空间、时间,难以两全。为了灵活性,C语言舍弃了一点点空间性能。

现在我们知道,扫尾工作(平衡栈/清栈)有两种方式:① 调用方清栈,call返回后,执行“add esp, x”指令;② 被调用方清栈,执行“ret x”指令。前者用空间代价换取了变参功能。

1.3.9 调用惯例

我们来回顾函数调用的环节:传参、清栈。清栈有两种选择,传参呢?前面参数通过栈传递,其实从寄存器也可传递,就如同返回值通过寄存器传递。寄存器传递不需要寻址内存空间,直接操控CPU中的寄存器速度快。但寄存器数目有限,特别是可自由使用的非常少,所以难以应对大量参数的传递。

我们来看VC中用寄存器传递参数的例子。将Add()函数申明为_fastcall,其实从名字上也透露了寄存器传参的特点:fast(快速)。

int _fastcall Add(int x,int y){
  int sum;
  sum=x+y;
  return sum;
}

对应的调用方代码如下:

z = Add(1, 2);
004139A2    mov    edx,2
004139A7    mov    ecx,1
004139AC    call   004111F9

我们发现,2和1通过寄存器edx和ecx分别传递。如果参数多了,往往编译器会将前两个参数用寄存器传递,后面的用压栈传递。比如,将Add()函数变为有3个参数,我们看到,第3个参数实际是push压栈传递的。

z = Add(1, 2, 3);
004139A2    push   3
004139A4    mov    edx,2
004139A9    mov    ecx,1
004139AE    call   004111FE

另外,即使栈传递参数,先压谁后压谁也是个问题,通过观察已知,C语言的参数是从右往左压栈。至于从右往左还是左往右,不同语言有自己的选择,原因见习题16。函数调用的习惯如下:

⊙ 是寄存器还是栈传递参数?

⊙ 栈传递时,参数是从右往左还是从左往右压栈?

⊙ 谁来清栈,是调用方还是被调用方?

而这几点的组合就是调用的习惯,即调用惯例(calling convention)。C语言的调用方式是栈传参,参数从右往左压栈,调用方清栈。Pascal是栈传参,参数从左往右压栈,被调用方清栈。_stdcall是微软系统调用采用的惯例,除清栈是被调用方外,其他同C语言方式。_fastcall是寄存器传递,不同语言在选择寄存器时有所不同,没有统一的规定。调用惯例非常重要,它涉及调用方和被调用方的约定,涉及它们能否正常沟通,在第2章中我们可以看到这个要点在跨语种编程中的重要影响。

1.3.10 函数指针

这时,我们可接触C语言中第2种重要指针——函数指针了。从使用角度出发,仿照数据指针的使用逻辑,可推出函数指针的内涵:数据指针是用来访问数据的,所以包含指向的内存地址;同时因为访问的长度需要确定,所以指针类型决定了从该地址访问多少字节。

函数指针是用来调用函数的,所以要包含函数代码的入口地址。函数指针有类型吗?调用函数并非只需要入口地址,还需要说明函数的参数表、返回类型、调用惯例,这样编译器才能生成正确的调用代码(如push多少参数、是否调用方清栈等),这三点合在一起就是函数原型。因此指针类型就是函数原型。所以,函数指针包含入口地址和函数原型两方面的信息。

比如,对于int Add(int i, int b)函数,我们怎样描述其指针?首先,随便定义一个名字如pfunc,然后在其左边加上“*”代表指针,即“* pfunc;”,接着需要描述其函数原型。先来描述其参数表(两个整数参数):

* pfunc(int, int);

然后描述其返回类型:

int * pfunc(int, int);

问题:如果返回类型是int *怎么办?难道写成“int ** pfunc(int, int);”?这样表示无法与返回值类型int **区分,因此我们表示函数Add()的函数指针类型如下:

int (*pfunc)(int, int);

最后,如果调用惯例是C方式,则完毕,否则需要将其标识出来,如_stdcall。例如,下面定义了一个函数指针变量pfunc:

int (_stdcall *pfunc)(int, int);

然后可将函数原型匹配该指针类型的函数的入口地址赋值给它。这时用函数的名字代表入口地址,或在其前加“&”:

pfunc = Add;     或     pfunc = &Add;

当函数指针指向一个函数后,我们就能用函数指针访问该函数:

int sum = pfunc(1, 2);

反汇编代码见DM1-22。代码标注的第2行、第4行分别是函数指针赋值的两种表示法,都是将函数Add()入口地址401000h赋给变量pfunc。

                                  DM1-22
int (*pfunc)(int, int);
int Add(int a, int b){
  return a + b;
}
void main(){
    push  ebp
    mov  ebp, esp
    sub  esp, 8
1    pfunc = Add;
2    mov  dword ptr ds:[00403374h],401000h
3    pfunc = &Add;
4    mov  dword ptr ds:[00403374h],401000h
5    pfunc(1, 2);
6    push  2
7    push  1
8    call   dword ptr ds:[00403374h]
9    add   esp,8
10   Add(1, 2);
11   push  2
12   push  1
13   call   00401000
14   add   esp, 8
}

第6~9行和第11~14行分别是用函数指针和函数名两种方式调用的反汇编,通过比较发现,除了call指令,后者是用偏移量跳转,其他相同,执行结果也相同。

函数指针赋值的原则是:只能将与指针原型匹配的函数的入口地址赋值给它。

例如,如果将Add()变成int Add(int a, int b, int c),Add()还可赋给pfunc吗?答案是“否”,因为两者的函数原型不同(参数表不同)

如果将Add()改成float Add(int a, int b),Add()可赋给pfunc吗?答案是“否”,因为两者的函数原型不同(返回类型不同)

如果将Add()改成int _stdcall Add(int a, int b),Add()可赋给pfunc吗?答案是“否”,因为两者的函数原型不同(调用惯例不同)。

我们就最后一种情况,分析为什么不可以赋值,其他情况自行分析。假定能够赋值将是怎样的状况?分析之前的反汇编码:

5   pfunc(1, 2);
6   push  2
7   push  1
8   call   dword ptr ds:[00403374h]
9   add   esp, 8

第9行的指令将压栈传参的8字节清栈。如果Add()是_stdcall,那么在函数中已通过“ret 8”清栈,这里再次清栈8字节,势必影响栈布局。简单来说,当包含这段代码的函数返回时,返回地址必然错误。

函数指针变量申明比较麻烦,如果多次在不同的地方写,很有可能少写一个参数或调用惯例写错等导致编程错误。为了减少这种麻烦,如果能将一种函数指针声明为一种类型,然后所有定义函数指针变量的地方都用该类型定义就好了。例如,typedef int (*funcPtrType)(int, int)声明了一个函数指针变量类型funcPtrType。funcPtrType pfunc了定义一个函数指针变量pfunc。之前的代码可如下修改:

typedef int (*funcPtrType)(int, int);
funcPtrType pfunc;
int Add(int a, int b){
  return a + b;
}
…

问题:对于数据指针,在遵循一定原则下,可安全地进行指针强制转换(见1.2节),是否函数指针也可遵循某种原则进行指针强制转换?在回答这个问题前,我们用强制转换处理前面分析的调用惯例不匹配例子,并分析其反汇编,见DM1-23。注意test函数中的第一行语句,调用Add()前,用指针强制转换将Add()的指针类型进行了强制转换,因为Add()的函数原型是_stdcall,而funcPtrType定义的函数指针类型是C方式,如果不强制转换,编译器会报错:

                                  DM1-23
typedef int (*funcPtrType)(int, int);
funcPtrType pfunc;
int _stdcall Add(int a, int b){
  return a + b;
}
void test(){
  pfunc = (funcPtrType)Add;
  pfunc(1, 2);
  Add(1, 2);
}
void main(){
  test();
}
错误 1 error C2440: “=”:无法从“int (__stdcall *) (int, int)转换为“funcPtrType”
e:\project\cprj\test2\test2\test2.cpp   18      test2

错误提示无法将Add()的类型转换为pfunc的类型funcPtrType,因为两者的调用惯例不同。

下面来看强制转换后的反汇编,见DM1-24。

                                  DM1-24
  void test(){
      push  ebp
      mov  ebp, esp
1    pfunc = (funcPtrType)Add;
2    mov  dword ptr ds:[00403374h], 401000h
3    pfunc(1, 2);
4    push  2
5    push  1
6    call   dword ptr ds:[00403374h]
7    add   esp, 8
8    Add(1, 2);
9    push  2
10   push  1
11   call   00401000
  }

首先,第2行强制转换没有多余的逻辑,只是“欺骗编译器”,将入口地址赋值给了pfunc变量。与数据指针的强制转换一样,强制转换本身并没有对应的指令,仅仅是将地址赋值给指针变量。

如果大家执行该程序会产生错误。我们看看错误与正常方式的差别吧。

① 错误方式:函数指针发起的调用,call后第7行将栈顶上移8字节(这是正常方式没有的),因为这时pfunc的类型的调用惯例是C方式,所以是调用方清栈(第7行)。

② 正常方式:用函数名调用,第11行call指令后无清栈指令(因为Add()是_stdcall,被调用方清栈,自然不需call后再加ESP中的值)。

被调用的Add()函数中本已用ret 8清栈了(ESP加了8),而用函数指针调用时,第7行自作多情地再次对ESP加8清栈,这必会导致错误!

具体来看错误是什么。先看test()函数需返回的地址,单步跟踪进test()函数中,查看ESP的值为0x0012FF70,指向内存中的4字节整数是0x0040104b(用小端机顺序解读),即返回地址。因为第7行将栈顶多上移了8字节,所以test()函数执行ret指令前,ESP没指向刚才保存返回地址的内存,即0x0012FF70,而是0x0012FF78,这时执行ret命令,将会把0x0012FF78中保存的值作为返回地址弹出。下面证明该论断。

在ret指令处设置断点,按F5键执行,在该断点停下,ESP的值确实是0x0012FF78。而此时在内存窗体中输入0x0012FF78,可知其中保存的整数值是0x78542201,那么ret执行后,程序将执行该地址的指令。按F11键,单步执行一次,我们可以看到代码确实飞到这个地方了:

78542201 add esi, 4

这当然是错误的返回位置,如果执行下去,谁也不知道会有什么结果,最终必然出现异常。

简单总结一下函数指针强制转换的作用:与数据指针一样,函数指针在转换赋值的时候,除了赋值地址没有多余动作。起效时是在调用函数的时候,编译器会按其原型产生不同的代码。

通过这个例子,我们能回答之前的问题“函数指针是否可通过某种规则强制转换”。答案是:几乎不可。不能赋值,一定是因为指针变量和函数两者的函数原型不同,函数原型不同必然是参数顺序、个数、清栈等有差异,势必造成某种错误。因此,可以说99%的函数指针强制转换都必然导致错误发生,至于那1%,大家也许将来能遇到,这里不再详谈。

最后一个问题:函数指针有什么用?用函数名字调用就可以了。那么,用函数指针调用函数,如果不看指针的值,肯定不知道是哪个函数,因为这个指针指向的是什么函数并不清楚。也就是说,同样的调用代码可能调用到不同的函数。这个好像面向对象的某个高级特征:虚函数调用。同样的代码,调用到的函数可能是父类的,也可能是子类的。后面我们将看到函数指针的威力:超强的灵活性,模块化编程的支撑力量!