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