笨鸟先飞学编程系列之三 函数
提及“函数”这个词儿,很多人都像我一样,感觉很恐慌,因为它总让我想起代数里讲的方方面面。这些对于像我这样的笨鸟来说,真的太深奥,总是不敢去考虑它,去琢磨它。虽然这里讲的跟那个并非同一个东西,但不免总是心有余悸。
今天要讲的东西比较多,我尽量把它讲的详细明白,但由于本人笨鸟一个,能力实在有限,大家多多包涵。
先列一下今天要讲的目录:
1. 什么是函数。
2. 函数的定义和使用方法。
3. 从调试中看函数的调用机制。
4. 撩开函数的面纱。
5. 结尾语。
好,以上是今天要讲的目录,下面进入正题:
一、 什么是函数。
函数,就是完成某个或者某种固定功能的最小的模块(总感觉这样写不是很严谨)。
当然,如果我就这样解释,相比大家很定会说我应付,说我不负责任,所以,这里我多牢骚几句。
在C语言中,默认指定的函数入口点是main函数,所以,我们在很多时候,只在这个函数中写代码,但是当我们的程序大到一定的程度,这个函数未免显的台过臃肿了;而且从方便实用的角度来说,把所有的功能都写在main函数中,看起来很不直观;而且很多的功能我们可能在别的程序里还能用到,如果我们都在一个函数里,那移植起来肯定也很麻烦;从维护方面来讲,这样很不容易维护,牵一处则动全身。比如下面的代码:
int main()
{
//初始化变量;
initcode001;
initcode002;
initcode003;
//开始实现功能一的代码
gn001:
{
code001;
code002;
code003;
//显示结果
printf("功能一的执行结果...\n请选择下一个功能:");
scanf("%d",&bl001);
switch (bl001)
{
case gn001:
goto gn001;
break;
case gn002:
goto gn002;
break;
case gn003:
goto gn003;
break;
case gn004:
goto gn004;
break;
}
}
// 开始实现功能二的代码
gn002:
{
……
//显示结果
printf("功能二的执行结果...\n请选择下一个功能:");
scanf("%d",&bl001);
switch (bl001)
{
case gn001:
goto gn001;
break;
case gn002:
goto gn002;
break;
case gn003:
goto gn003;
break;
case gn004:
goto gn004;
break;
}
}
}
从上面的代码可以看出,很多的重复代码,而且,如果我要在别的程序里使用功能二的代码,需要认真的将代码提取出来,难免发生错误。而且如果这个程序有70多个功能那这个程序就麻烦了。
因此,在写程序的时候,需要根据功能来讲整个程序划分成一个个模块,哪个模块有问题,我们就只要对有问题的模块修改,整理就可以了。在另外的程序中,需要用到哪个模块就将相应的模块移植到指定的程序里,就可以了,而函数,就是模块中最小的单位。以后,根据我们系列的深入,我们会继续讲到DLL,LIB等。彻底的将我们的项目工程模块化。如下面的代码:
#include "stdio.h"
// 这里声明一下函数MaxNum,让编译器知道有一个名字叫MaxNum的函数,它有三个整型参数。
int MaxNum(int num001, int num002, int num003);
//////////////////////////////////////////////////////////////////////////
void main()
{
int num1 = 0, num2 = 0, num3 = 0;
int result = 0;
scanf("%d,%d,%d", &num1, &num2, &num3); // 让用户输入任意三个数
result = MaxNum(num1, num2, num3); // 调用MaxNum 函数
printf("%d\n", result); // 显示MaxNum函数的返回值
}
//下面是函数定义部分
/************************************************************************/
// 函数名: MaxNum
// 参 数:
// num001: 随便一个整型数据
// num002: 随便一个整型数据
// num003: 随便一个整型数据
// 功 能:
// 取出三个参数中最大的一个数并返回。
/************************************************************************/
int MaxNum(int num001, int num002, int num003)
{
if (num001 >= num002)
{
if (num001 >= num003)
{
return num001;
}
else
{
return num003;
}
}
else
{
if (num002 >= num003)
{
return num002;
}
else
{
return num003;
}
}
}
这样下来,我们的程序就比较规范了,也方便任务的分工,写这个函数的人只管这个函数功能的实现,调用这个函数的人只要知道这个函数的功能和怎么使用就可以了,不用管这个函数功能是怎么实现的,OK既然知道函数是什么及为什么要用函数了,那下面我们进入下一节
二、 函数的定义和使用方法。
通过上一小节的节的代码,我相信很多的朋友已经知道函数是怎么声明并使用的了,这里我再具体的说一下:
定义一个函数的格式是:
返回值类型 函数调用方式 函数名(参数1, 参数2……)
{
函数指令;
return 返回值;
}
具体的使用例子,大家就看上一小节中的函数例子就可以,我偷个懒,嘿嘿……
相信,很多的朋友会问我一些问题:
1. 上面的代码中,那个MaxNum函数好像是定义了两次哎~,先是声明,再是定义,声明跟定义有什么区别呀。
2. 在上面代码中函数的定义好像没有本节函数定义格式中的调用方式……
好,第一个问题呢,我们可以先把声明的那一条语句删除掉,然后编译一下程序,看看,提示什么呢?
Compiling...
Func.cpp
E:\项目工程\测试例子\Func.cpp(11) : error C2065: 'MaxNum' : undeclared identifier
E:\项目工程\测试例子\Func.cpp(26) : error C2373: 'MaxNum' : redefinition; different type modifiers
Error executing cl.exe.
Func.exe - 2 error(s), 0 warning(s)
好,那我们再把这个MaxNum函数的定义部分移到main函数的前面,再编译,哈哈没有问题了。
这说明了什么呢?我们程序再执行的时候,先进入main函数,如果我们自定义的函数再main函数前面,那编译器就会知道,MaxNum是我们自己定义的函数,如果我们定义的函数MaxNum在main函数的后面,编译器再编译我们再main函数调用的代码时由于它不知道我们定义了MaxNum,所以调用MaxNum的代码就不能被识别了。
因此,我们应该在调用我们定义的函数前,先声明一下,让编译器知道我们定义了这么个函数,就可以了,当然,如果程序很想,我们完全可以把我们定义的函数放在程序文件的前面,main函数放在最后,免去声明的麻烦,但是定义函数前,先声明函数是个好习惯,因为以后我们写的程序可能会几个程序文件一起编译……
关于第二个问题,我们看下一节吧……
三、 从调试中看函数的调用机制。
我们直接使用上面的程序做例子,Release编译时,设置生成MAP文件,编译好程序以后,OD打开它,载入MAP文件,当然,如果不会捣鼓的,可以参考MAP文件中的信息:
Address
|
Publics by Value
|
Rva+Base
|
Lib:Object
|
0001:00000000
|
_main
|
00401000 f
|
Func.obj
|
0001:00000050
|
?MaxNum@@YAHHHH@Z
|
00401050 f
|
Func.obj
|
0001:00000070
|
_printf
|
00401070 f
|
LIBC:printf.obj
|
0001:000000a1
|
_scanf
|
004010a1 f
|
LIBC:scanf.obj
|
来到我们的main函数中:
00401000 >/$ 83EC 0C sub esp, 0C ; 申请一块堆栈,给局部变量预留空间
00401003 |. 33C0 xor eax, eax
00401005 |. 8D4C24 04 lea ecx, dword ptr [esp+4]
00401009 |. 894424 08 mov dword ptr [esp+8], eax
0040100D |. 894424 04 mov dword ptr [esp+4], eax
00401011 |. 894424 00 mov dword ptr [esp], eax
00401015 |. 8D4424 00 lea eax, dword ptr [esp]
00401019 |. 50 push eax ; Arg4 = 8
0040101A |. 8D5424 0C lea edx, dword ptr [esp+C]
0040101E |. 51 push ecx ; Arg3 = 5
0040101F |. 52 push edx ; Arg2 = 3
00401020 |. 68 34804000 push 00408034 ; Arg1 = ASCII "%d,%d,%d"
00401025 |. E8 77000000 call _scanf
0040102A |. 8B4424 10 mov eax, dword ptr [esp+10]
0040102E |. 8B4C24 14 mov ecx, dword ptr [esp+14]
00401032 |. 8B5424 18 mov edx, dword ptr [esp+18]
00401036 |. 50 push eax ; Arg3 = 8
00401037 |. 51 push ecx ; Arg2 = 5
00401038 |. 52 push edx ; Arg1 = 3
00401039 |. E8 12000000 call ?MaxNum@@YAHHHH@Z
{
00401050 >/$ 8B4C24 04 mov ecx, dword ptr [esp+4] ; 仔细观看堆栈的变化,总结局部变量的定位
00401054 |. 8B4424 08 mov eax, dword ptr [esp+8]
00401058 |. 3BC8 cmp ecx, eax
0040105A |. 7C 09 jl short Fu.00401065
0040105C |. 8B4424 0C mov eax, dword ptr [esp+C]
00401060 |. 3BC8 cmp ecx, eax
00401062 |. 7D 09 jge short Fu.0040106D
00401064 |. C3 retn
00401065 |> 8B4C24 0C mov ecx, dword ptr [esp+C]
00401069 |. 3BC1 cmp eax, ecx
0040106B |. 7D 02 jge short Fu.0040106F
0040106D |> 8BC1 mov eax, ecx
0040106F \> C3 retn
}
0040103E |. 50 push eax
0040103F |. 68 30804000 push 00408030 ; ASCII "%d",LF
00401044 |. E8 27000000 call _printf
00401049 |. 83C4 30 add esp, 30 ; 回复堆栈平衡
0040104C \. C3 retn
这里面应该没有我们不认识的汇编指令吧。我们再这里就看一下这些代码,当然如果可以的话,你可以单步跟踪这个程序,尤其注意看下堆栈的变化。
1. __cdecl 调用方式
好的,我们现在来看一下这段代码,先看一下堆栈吧,在函数头,申请了一段大小为0xC的堆栈空间,在函数结尾平衡堆栈的时候,恢复了0x30的大小,也就是说,中间的这些PUSH的函数参数,占用了0x24的堆栈空间,(我们可以算一下,第一个函数scanf有4个参数PUSH了4次,第二个函数MaxNum有3个参数,PUSH了3次,第三个函数是printf,有两个参数,push了两次,一共PUSH了9次,DWORD(4)*9 = 0x24,再加上一开始在函数头申请的0xC大小的堆栈空间,一共是0x30)需要我们的代码再调用完函数后,进行恢复,否则堆栈就不平衡程序就出错误了,由此可以见,我们可以整理一下这类函数的调用方式:
push 参数n
……
Push 参数2
Push 参数1
call 函数首地址
add esp, 函数参数的个数*4
由于很多的C库函数都是用这样的方式调用它,所以,这种函数的调用方式叫做C类调用(在C语言中用在这个程序中,模式都是这类的调用方式,也可以用__cdecl修饰)
在这个程序中,由于main使用的3个函数都是这一种调用方式,编译器为了减少指令把堆栈一起平衡了,而没有分别对每个函数进行堆栈平衡。
2. __stdcall 调用方式
好的,现在我们把我们自己定义的函数MaxNum的声明和定义都改成这样:
int __stdcall MaxNum(int num001, int num002, int num003);
这样,Main函数中应该就是有两种调用方式了,我们可以更清楚的看出C类调用的特点:
00401000 >/$ 83EC 0C sub esp, 0C
00401003 |. 33C0 xor eax, eax
00401005 |. 8D4C24 04 lea ecx, dword ptr [esp+4]
00401009 |. 894424 08 mov dword ptr [esp+8], eax
0040100D |. 894424 04 mov dword ptr [esp+4], eax
00401011 |. 894424 00 mov dword ptr [esp], eax
00401015 |. 8D4424 00 lea eax, dword ptr [esp]
00401019 |. 50 push eax
0040101A |. 8D5424 0C lea edx, dword ptr [esp+C]
0040101E |. 51 push ecx
0040101F |. 52 push edx
00401020 |. 68 34804000 push Func.00408034 ; ASCII "%d,%d,%d"
00401025 |. E8 87000000 call Func.scanf>
0040102A |. 8B4424 10 mov eax, dword ptr [esp+10]
0040102E |. 8B4C24 14 mov ecx, dword ptr [esp+14]
00401032 |. 8B5424 18 mov edx, dword ptr [esp+18]
00401036 |. 83C4 10 add esp, 10 ; 平衡Scanf的参数使用的堆栈
00401039 |. 50 push eax
0040103A |? 51 push ecx
0040103B |? 52 push edx
0040103C |? E8 0F000000 call Func.MaxNum>
{
00401050 >/$ 8B4C24 04 mov ecx, dword ptr [esp+4]
00401054 |. 8B4424 08 mov eax, dword ptr [esp+8]
00401058 |. 3BC8 cmp ecx, eax
0040105A |. 7C 0B jl short Func.00401067
0040105C |. 8B4424 0C mov eax, dword ptr [esp+C]
00401060 |. 3BC8 cmp ecx, eax
00401062 |. 7D 0B jge short Func.0040106F
00401064 |. C2 0C00 retn 0C
00401067 |? 8B4C24 0C mov ecx, dword ptr [esp+C]
0040106B |. 3BC1 cmp eax, ecx
0040106D |> 7D 02 jge short Func.00401071
0040106F \> 8BC1 mov eax, ecx
00401071 |. C2 0C00 retn 0C ; __stdcall的调用方式,在子函数中平衡堆栈
}
00401041 |? 50 push eax
00401042 |? 68 30804000 push Func.00408030 ; ASCII "%d",LF
00401047 |? E8 34000000 call Func.printfGetStringTypeWsWyte
0040104C \. 83C4 14 add esp, 14 ; 这里只平衡printf的参数跟一开始申请的0xC的堆栈就可以了。
0040104F C3 retn
我们在程序中加入的__stdcall就修改程序默认的调用方式为Windows标准调用方式,现在我们留心看一下MaxNum函数的调用和实现部分,总结一下这种Win标准调用的特点:
push 参数n
……
Push 参数2
Push 参数1
call 函数首地址
{
函数的代码;
retn 函数参数的个数*4 ; 这里就相当于add esp, 函数参数的个数*4 ,然后再RETN
}
这类调用就是windows的标准调用,它的修饰符号是__stdcall,几乎所有的windows的API都用这种方式调用,所以,在VS开发环境中,__stdcall又被定义成了WINAPI。
3. __fastcall调用方式
为了保证本次课题的完整性,我多唠叨几句,说一下__fastcall的调用方式(本来是想在下一次课题面向对象编程中再讲述的),这种方式的调用在面向对象编程中比较常见,这里大概的做一下简单的介绍,等在下一次课题:C++的基础特性 中详细讲述。
这种调用方式就是同时使用寄存器和堆栈一起传递参数,为了描述的更清楚,我们还是用上一小节的程序做例子,我们再把程序中的__stdcall改成__fastcall,然后Release编译,OD打开:
00401000 >/$ 83EC 0C sub esp, 0C
00401003 |. 33C0 xor eax, eax
00401005 |. 8D4C24 04 lea ecx, dword ptr [esp+4]
00401009 |. 894424 08 mov dword ptr [esp+8], eax
0040100D |. 894424 04 mov dword ptr [esp+4], eax
00401011 |. 894424 00 mov dword ptr [esp], eax
00401015 |. 8D4424 00 lea eax, dword ptr [esp]
00401019 |. 50 push eax
0040101A |. 8D5424 0C lea edx, dword ptr [esp+C]
0040101E |. 51 push ecx
0040101F |. 52 push edx
00401020 |. 68 34804000 push Func.00408034 ; ASCII "%d,%d,%d"
00401025 |. E8 77000000 call Func.scanf>
0040102A |. 8B4424 10 mov eax, dword ptr [esp+10]
0040102E |. 8B5424 14 mov edx, dword ptr [esp+14]
00401032 |. 8B4C24 18 mov ecx, dword ptr [esp+18]
00401036 |. 83C4 10 add esp, 10
00401039 |. 50 push eax
0040103A |. E8 11000000 call Func.MaxNumr> ; 原本3个参数的函数,现在编程一个参数了
{
00401050 >/$ 8B4424 04 mov eax, dword ptr [esp+4] ; 从这里明白:它用了ECX和EDX传递了两个参数
00401054 |. 3BCA cmp ecx, edx
00401056 |. 7C 09 jl short Func.00401061
00401058 |. 3BC8 cmp ecx, eax
0040105A |. 7C 0B jl short Func.00401067
0040105C |. 8BC1 mov eax, ecx
0040105E |. C2 0400 retn 4
00401061 |> 3BD0 cmp edx, eax
00401063 |. 7C 02 jl short Func.00401067
00401065 |. 8BC2 mov eax, edx
00401067 \> C2 0400 retn 4
}
看到了么?也不麻烦哦,我们总结一下__fastcall的调用特点:
push 参数n
……
mov edx, 参数2
mov ecx, 参数1
call 函数首地址
{
函数的代码;
retn 函数参数的个数*4 ; 这里就相当于add esp, 函数参数的个数*4 ,然后再RETN
}
当然,也不完全都是使用ECX和EDX两个寄存器,根据编译器的不同,使用的寄存器也不同,如果调试程序调试的多了,我们可以发现:
a) VS的编译器如果用__fastcall方式调用函数,一般都是将最左边的两个小于DWORD类型的参数分别用ECX和EDX传递。
b) Borland公司的编译器如果用__fastcall方式调用函数,一般都是将最左边的三个小于DWORD类型的参数分别用EAX,EDX和ECX传递。
更多的特点还需要大家自己去总结。
4. PASCAL 调用方式
还有一种调用方式:PASCAL方式调用,限于篇幅,这里就不再举例子了,只是简单的总结一下它的特点,我们就进入下一节:
Push 参数1
Push 参数2
……
push 参数n
call 函数首地址
{
函数的代码;
retn 函数参数的个数*4 ; 这里就相当于add esp, 函数参数的个数*4 ,然后再RETN
}
很明显,这种调用方式与__stdcall方式十分相似,就是传递的参数顺序不同而已。
四、 撩开函数的面纱。
如果认真调试了本节的这个程序的朋友,一定会发现如下一些知识点:
a) 函数的返回值一般存放再EAX中。
b) 用[esp+偏移]和用[ebp+偏移]标识函数的参数及局部变量的异同。
c) CALL/JMP的区别。
a) 区别就是一个CALL就相当于push eip+5 然后再JMP 到指定的代码中。这样,再retn的时候,就知道返回到哪个地址了。
b) 利用这个有很多的用法和小技巧,比如代码自定位 等等。
其实归根结底,程序的代码本身就是数据,当若我们比较一个数组的二进制数据跟一个函数代码的二进制形式,我们根本无法区别他们,换句话说,我们完全可以把代码当作数据来处理,
这里引用一个比较简单的例子,大家可以一起试一下:http://bbs.pediy.com/showthread.php?t=71790
另外,我们知道,一个函数的参数一般都是变量,当我们把一个函数名字(函数的首地址)当作一个变量来处理,那我们完全就可以让一个函数名作为另一个函数的参数,这个最典型的应用就是回调。具体的我们等到提高篇中具体讲解 回调函数。
本节为了证明我上面的描述,给出一个小程序,算是开阔视野,也算是最函数的本质做个解释,希望大家能调试跟踪一下。
#include "stdio.h"
#include "windows.h"
typedef unsigned char BYTE;
typedef VOID (CALLBACK *MYSPRINTF)(char *, const char *, ...);
typedef VOID (CALLBACK *MYLSTRCAT)(char *, char *);
typedef VOID (CALLBACK *MYMSGBOX)(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
MYSPRINTF mySprintf = (MYSPRINTF)GetProcAddress(LoadLibraryA("msvcr71.dll"), "sprintf");
MYLSTRCAT myStrCat = (MYLSTRCAT)GetProcAddress(LoadLibraryA("KERNEL32.dll"), "lstrcatA");
MYMSGBOX myMsgBox = (MYMSGBOX)GetProcAddress(LoadLibraryA("user32.dll"), "MessageBoxA");
BYTE buf[] = { 0xB8,0x00,0x12,0x00,0x00,0xE8,0xAE,0x00,0x00,0x00,0x55,0x56,0x57,0xB9,0x7F,0x00,\
0x00,0x00,0x33,0xC0,0x8D,0x7C,0x24,0x0D,0xC6,0x44,0x24,0x0C,0x00,0xC6,0x84,0x24,\
0x0C,0x02,0x00,0x00,0x00,0xF3,0xAB,0x66,0xAB,0xAA,0xB9,0xFF,0x03,0x00,0x00,0x33,\
0xC0,0x8D,0xBC,0x24,0x0D,0x02,0x00,0x00,0xBE,0x01,0x00,0x00,0x00,0xF3,0xAB,0x66,\
0xAB,0xAA,0xBF,0x01,0x00,0x00,0x00,0x3B,0xF7,0x7C,0x33,0x8B,0xEE,0xA1,0x18,0x61,\
0x40,0x00,0x55,0x57,0x56,0x8D,0x4C,0x24,0x18,0x50,0x51,0xFF,0x15,0x20,0x86,0x40,\
0x00,0x83,0xC4,0x14,0x8D,0x54,0x24,0x0C,0x8D,0x84,0x24,0x0C,0x02,0x00,0x00,0x52,\
0x50,0xFF,0x15,0x28,0x86,0x40,0x00,0x47,0x03,0xEE,0x3B,0xFE,0x7E,0xCF,0x8D,0x8C,\
0x24,0x0C,0x02,0x00,0x00,0x68,0x24,0x61,0x40,0x00,0x51,0xFF,0x15,0x28,0x86,0x40,\
0x00,0x46,0x83,0xFE,0x0A,0x7C,0xAB,0x6A,0x00,0x8D,0x94,0x24,0x10,0x02,0x00,0x00,\
0x68,0x28,0x61,0x40,0x00,0x52,0x6A,0x00,0xFF,0x15,0x24,0x86,0x40,0x00,0x5F,0x5E,\
0x5D,0x81,0xC4,0x00,0x12,0x00,0x00,0xC3\
};
BYTE AllocBuf[] = { 0x51,0x3D,0x00,0x10,0x00,0x00,0x8D,0x4C,0x24,0x08,\
0x72,0x14,0x81,0xE9,0x00,0x10,0x00,0x00,0x2D,0x00,\
0x10,0x00,0x00,0x85,0x01,0x3D,0x00,0x10,0x00,0x00,\
0x73,0xEC,0x2B,0xC8,0x8B,0xC4,0x85,0x01,0x8B,0xE1,\
0x8B,0x08,0x8B,0x40,0x04,0x50,0xC3\
};
const char *pConChar = "%d*%d=%-4d\0";
const char *pTitle = "九九乘法表\0";
char *pNchar = "\n";
void main()
{
long* pVoid = (long *)buf;
__asm
{
CALL pVoid;
}
}
以上程序,VC6.0,Release方式编译、运行通过。
五、 结尾语。
本章的东西不是很好组织,我一提函数就想起来很多很多的东西,像回调,像成员函数,虚函数等等,不知道该怎么讲,索性还是分开描述吧,不把它们都放再一章了,以免大家都看不明白或混淆。
我尽量以我认为比较好理解的方式来讲述我所学到的内容,但愿这样能达到我想要的效果,也希望大家都能看懂,学的明白。
倘若大家有看不懂的地方或者我写的不对的地方请大家一定要告知我,以免误人子弟……。
也欢迎大家到吾爱破解论坛编程版块(http://www.52pojie.cn/forum-24-1.html)或者我的博客(http://www.cppblog.com/besterChen/)交流,共同学习。