您的位置:首页 > 编程语言 > C语言/C++

C/C++中可变参数的原理 与函数调用约定

2013-08-01 15:12 501 查看
今天面试比较悲剧,补一补基础知识吧。

weiqubo的可变参数:http://blog.csdn.net/weiqubo/article/details/4857567

这是Richard Wei的文章,大家去看:http://www.cnblogs.com/weiym/archive/2012/09/18/2689917.html

这是Jim‘s 的函数调用堆栈分析:http://www.cnblogs.com/ajiannet/archive/2007/04/20/721679.html

这是程序人生的常见函数调用约定:http://www.programlife.net/function-call-convention-styles.html

这是我亦精彩的博客http://blog.sina.com.cn/s/blog_95c7062e01010o2x.html

C函数要在程序中用到以下这些宏:

void va_start( va_list arg_ptr, prev_param ); 

type va_arg( va_list arg_ptr, type ); 

void va_end( va_list arg_ptr );

 

va_list:用来保存宏va_start、va_arg和va_end所需信息的一种类型。为了访问变长参数列表中的参数,必须声明

             va_list类型的一个对象       定义: typedef char *  va_list;
va_start:访问变长参数列表中的参数之前使用的宏,它初始化用va_list声明的对象,初始化结果供宏va_arg和

               va_end使用;
va_arg: 展开成一个表达式的宏,该表达式具有变长参数列表中下一个参数的值和类型。每次调用va_arg都会修改

              用va_list声明的对象,从而使该对象指向参数列表中的下一个参数;
va_end:该宏使程序能够从变长参数列表用宏va_start引用的函数中正常返回。

va在这里是variable-argument(可变参数)的意思. 

这些宏定义在stdarg.h中,所以用到可变参数的程序应该包含这个头文件.下面我们写一个简单的可变参数的函数,改函数至少有一个整数参数,第二个参数也是整数,是可选的.函数只是打印这两个参数的值.

[cpp] view
plaincopy

#include <stdio.h>;    

#include <string.h>;    

#include <stdarg.h>;    

  

/* ANSI标准形式的声明方式,括号内的省略号表示可选参数 */    

  

int demo(char *msg, ... )    

{    

    va_list argp;                   /* 定义保存函数参数的结构 */    

    int argno = 0;                  /* 纪录参数个数 */    

    char *para;                     /* 存放取出的字符串参数 */    

      

                                    /* argp指向传入的第一个可选参数,    msg是最后一个确定的参数 */    

    va_start( argp, msg );    

      

    while (1)   

    {    

        para = va_arg( argp, char *);                 /*    取出当前的参数,类型为char *. */    

        if ( strcmp( para, "/0") == 0 )    

                                                      /* 采用空串指示参数输入结束 */    

            break;    

        printf("Parameter #%d is: %s/n", argno, para);    

        argno++;    

    }    

    va_end( argp );                                   /* 将argp置为NULL */    

    return 0;    

}  

  

  

void main( void )    

{    

demo("DEMO", "This", "is", "a", "demo!" ,"333333", "/0");    

  

  

}    

 

从这个函数的实现可以看到,我们使用可变参数应该有以下步骤: 

1)首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变 

量是指向参数的指针. 

2)然后用va_start宏初始化变量arg_ptr,这个宏的第二个参数是第 

一个可变参数的前一个参数,是一个固定的参数. 

3)然后用va_arg返回可变的参数,并赋值给整数j. va_arg的第二个 

参数是你要返回的参数的类型,这里是int型. 

4)最后用va_end宏结束可变参数的获取.然后你就可以在函数里使 

用第二个参数了.如果函数有多个可变参数的,依次调用va_arg获 

取各个参数. 

二、可变参类型陷阱

下面的代码是错误的,运行时得不到预期的结果:

view plaincopy to clipboardprint?

va_start(pArg, plotNo);   

fValue = va_arg(pArg, float);  // 类型应改为double,不支持float   

va_end(pArg);  

va_start(pArg, plotNo);

fValue = va_arg(pArg, float);  // 类型应改为double,不支持float

va_end(pArg);

下面列出va_arg(argp, type)宏中不支持的type:

—— char、signed char、unsigned char

—— short、unsigned short

—— signed short、short int、signed short int、unsigned short int

—— float

在C语言中,调用一个不带原型声明的函数时,调用者会对每个参数执行“默认实际参数提升(default argument promotions)”。该规则同样适用于可变参数函数——对可变长参数列表超出最后一个有类型声明的形式参数之后的每一个实际参数,也将执行上述提升工作。

提升工作如下:

——float类型的实际参数将提升到double

——char、short和相应的signed、unsigned类型的实际参数提升到int

——如果int不能存储原值,则提升到unsigned int

然后,调用者将提升后的参数传递给被调用者。

所以,可变参函数内是绝对无法接收到上述类型的实际参数的。

关于该陷井,C/C++著作中有以下描述:

在《C语言程序设计》对可变长参数列表的相关章节中,并没有提到这个陷阱。但是有提到默认实际参数提升的规则:

在没有函数原型的情况下,char与short类型都将被转换为int类型,float类型将被转换为double类型。

                ——《C语言程序设计》第2版  2.7 类型转换 p36

在其他一些书籍中,也有提到这个规则:

事情很清楚,如果一个参数没有声明,编译器就没有信息去对它执行标准的类型检查和转换。

在这种情况下,一个char或short将作为int传递,float将作为double传递。

这些做未必是程序员所期望的。

脚注:这些都是由C语言继承来的标准提升。

对于由省略号表示的参数,其实际参数在传递之前总执行这些提升(如果它们属于需要提升的类型),将提升后的值传递给有关的函数。——译者注

                ——《C++程序设计语言》第3版-特别版 7.6 p138

…… float类型的参数会自动转换为double类型,short或char类型的参数会自动转换为int类型 ……

                ——《C陷阱与缺陷》 4.4 形参、实参与返回值 p73

这里有一个陷阱需要避免:

va_arg宏的第2个参数不能被指定为char、short或者float类型。

因为char和short类型的参数会被转换为int类型,而float类型的参数会被转换为double类型 ……

例如,这样写肯定是不对的:

c = va_arg(ap,char);

因为我们无法传递一个char类型参数,如果传递了,它将会被自动转化为int类型。上面的式子应该写成:

c = va_arg(ap,int);

                ——《C陷阱与缺陷》p164

这一片博客原理上讲的还行,不过给的示例不够严谨,( 博主代码在VC32位机器下运行,其他环境,进阶需要考虑内存和可变参数宏定义的知识:http://wenku.baidu.com/view/ae9c3b8e6529647d27285299.htmlC/C++中可变参数的原理

以前只是知道可变参数怎么用,但是一直对它的原理是似懂非懂,现在对计算机有了比较深刻的认识之后,回头再看,豁然开朗。

要理解可变参数,首先要理解函数调用约定(http://en.wikipedia.org/wiki/Calling_convention), 为什么只有__cdecl的调用约定支持可变参数,而__stdcall就不支持?

实际上__cdecl和__stdcall函数参数都是从右到左入栈,它们的区别在于由谁来清栈,__cdecl由外部调用函数清栈,而__stdcall由被调用函数本身清栈, 显然对于可变参数的函数,函数本身没法知道外部函数调用它时传了多少参数,所以没法支持被调用函数本身清栈(__stdcall), 所以可变参数只能用__cdecll.

另外还要理解函数参数传递过程中堆栈是如何生长和变化的,从堆栈低地址到高地址,依次存储 被调用函数局部变量,上一函数堆栈桢基址,函数返回地址,参数1, 参数2, 参数3...,相关知识可以参考我的这篇堆栈桢的生成原理 

有了上面的知识,我可以知道函数调用时,参数2的地址就是参数1的地址加上参数1的长度,而参数3的地址是参数2的地址加上参数2的长度,以此类推。

于是我们可以自己写可变参数的函数了, 代码如下:

int Sum(int nCount, 

)

{

    int nSum = 0;

    int* p = &nCount;

    for(int i=0; i<nCount; ++i)

    {

        cout << *(++p) << endl;

        nSum += *p;

    }

    cout << "Sum:" << nSum << endl << endl;

    return nSum;

}

string  SumStr(int nCount, 

)

{

    string str;

    int* p = &nCount;

    for(int i=0; i<nCount; ++i)

    {

        char* pTemp = (char*)*(++p);

        cout <<  pTemp << endl;

        str += pTemp;

    }

    cout << "SumStr:" << str << endl;

    return str;

}

在我们的测试函数中nCount表示后面可变参数的个数,int Sum(int nCount, 

)会打印后面的可变参数Int值,并且进行累加;string  SumStr(int nCount, 

) 会打印后面可变参数字符串内容,并连接所有字符串。

然后用下面代码进行测试:int main() 

{

    Sum(3, 10, 20, 30);

    SumStr(5, "aa", "bb", "cc", "dd", "ff");

    

    system("pause");

    return 0;

}

测试结果如下:



可以看到,我们上面的实现有硬编码的味道,也有没有做字节对齐,为此系统专门给我们封装了一些支持可变参数的宏:

//typedef char *  va_list;

//#define _ADDRESSOF(v)   ( &reinterpret_cast<const char &>(v) )
//#define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

//#define _crt_va_start(ap,v)  ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
//#define _crt_va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
//#define _crt_va_end(ap)      ( ap = (va_list)0 )

//#define va_start _crt_va_start
//#define va_arg _crt_va_arg
//#define va_end _crt_va_end

用系统的这些宏,我们的代码可以这样写了:

//use va_arg, praram is int
int SumNew(int nCount, 

)

{

    int nSum = 0;

    va_list vl = 0;

    va_start(vl, nCount);

    for(int i=0; i<nCount; ++i)

    {

        int n = va_arg(vl, int);

        cout << n << endl;

        nSum += n;

    }

    va_end(vl);

    cout << "SumNew:" << nSum << endl << endl;

    return nSum;

}

//use va_arg,  praram is char*
string SumStrNew(int nCount, 

)

{

    string str;

    va_list vl = 0;

    va_start(vl, nCount);

    for(int i=0; i<nCount; ++i)

    {

        char* p = va_arg(vl, char*);

        cout <<  p << endl;

        str += p;

    }

    cout << "SumStrNew:" << str << endl << endl;

    return str;

}

可以看到,其中 va_list实际上只是一个参数指针,va_start根据你提供的最后一个固定参数来获取第一个可变参数的地址,va_arg将指针指向下一个可变参数然后返回当前值, va_end只是简单的将指针清0.

用下面的代码进行测试:

int main() 

{

    Sum(3, 10, 20, 30);

    SumStr(5, "aa", "bb", "cc", "dd", "ff");

    

    SumNew(3, 1, 2, 3);

    SumStrNew(3, "12", "34", "56");

    system("pause");

    return 0;

}

结果如下: 



我们上面的例子传的可变参数都是4字节的, 如果我们的可变参数传的是一个结构体,结果会怎么样呢?

下面的例子我们传的可变参数是std::string

//use va_arg,  praram is std::string
void SumStdString(int nCount, 

)

{

    string str;

    va_list vl = 0;

    va_start(vl, nCount);

    for(int i=0; i<nCount; ++i)

    {

        string p = va_arg(vl, string);

        cout <<  p << endl;

        str += p;

    }

    cout << "SumStdString:" << str << endl << endl;

}

int main() 
{
Sum(3, 10, 20, 30);
SumStr(5, "aa", "bb", "cc", "dd", "ff");

SumNew(3, 1, 2, 3);
SumStrNew(3, "12", "34", "56");

string s1("hello ");
string s2("world ");
string s3("!");
SumStdString(3, s1, s2, s3);

system("pause");

return 0;
}

运行结果如下:



可以看到即使传入的可变参数是std::string, 依然可以正常工作。

我们可以反汇编下看看这种情况下的参数传递过程:



很多时候编译器在传递类对象时,即使是传值,也会在堆栈上通过push对象地址的方式来传递,但是上面显然没有这么做,因为它要满足可变参数堆栈内存连续分布的规则, 另外,可以看到最后在调用sumStdString后,由add esp, 58h来外部清栈。

一个std::string大小是28, 58h = 88 = 28 + 28 + 28 + 4.

从上面的例子我们可以看到,对于可变参数的函数,有2种东西需要确定,一是可变参数的数量, 二是可变参数的类型,上面的例子中,参数数量我们是在第一个参数指定的,参数类型我们是自己约定的。这种方式在实际使用中显然是不方便,于是我们就有了_vsprintf, 我们根据一个格式化字符串的来表示可变参数的类型和数量,比如C教程中入门就要学习printf, sprintf等。

总的来说可变参数给我们提供了很高的灵活性和方便性,但是也给会造成不确定性,降低我们程序的安全性,很多时候可变参数数量或类型不匹配,就会造成一些不容察觉的问题,只有更好的理解它背后的原理,我们才能更好的驾驭它。

截取的brookmill的一段话:

WINDOWS的函数调用时需要用到栈(STACK,一种先入后出的存储结构)。当函数

调用完成后,栈需要清除,这里就是问题的关键,如何清除??

       如果我们的函数使用了_cdecl,那么栈的清除工作是由调用者,用COM的术语来讲

就是客户来完成的。这样带来了一个棘手的问题,不同的编译器产生栈的方式不尽相同

,那么调用者能否正常的完成清除工作呢?答案是不能。

       如果使用__stdcall,上面的问题就解决了,函数自己解决清除工作。所以,在跨

(开发)平台的调用中,我们都使用__stdcall(虽然有时是以WINAPI的样子出现)。

       那么为什么还需要_cdecl呢?当我们遇到这样的函数如fprintf()它的参数是可变

的,不定长的,被调用者事先无法知道参数的长度,事后的清除工作也无法正常的进行

,因此,这种情况我们只能使用_cdecl。

       到这里我们有一个结论,如果你的程序中没有涉及可变参数,最好使用__stdcal

函数调用约定http://www.programlife.net/function-call-convention-styles.html

本文转载自《Reversing:逆向工程揭密》一书。该书描述的是在逆向与反逆向之间展开的一场旷日持久的拉锯战。


调用约定

参数传递顺序

1.从右到左依次入栈:__stdcall,__cdecl,__thiscall

2.从左到右依次入栈:__pascal,__fastcall

调用约定(Calling Conventions)定义了程序中调用函数的方式。调用约定之所以与我们这里讨论的堆栈相关,是因为调用约定决定了在函数调用的时候数据(比如说参数)在堆栈中的组织方式。理解调用约定对你来说非常重要,因为在逆向过程中你会不时地遇到函数调用,准确地辨识所用的调用约定是哪一种将有助于你理解你正在解读的程序。

在讨论各个调用约定之前,我们先来讨论一下函数调用要用到的两条基本的指令:CALL指令和RET指令。CALL指令将当前的指令指针(这个指针指向紧接在CALL指令后面的那条指令)压入堆栈,然后执行一条无条件转移指令转移到新的代码地址。

RET是与CALL指令配合使用的指令,在绝大多数函数中它是最后一条指令。RET指令弹出返回地址(就是早些时候CALL指令压入堆栈的地址)并将其加载到EIP寄存器中,然后从这个地址开始继续执行。

接下来的几个小节中我们将讨论几种最常见的调用约定,及其它们在汇编语言中是怎样实现的。


cdecl调用约定

cdecl调用约定是C和C++语言中的标准调用约定。其特点是允许函数接收可变数量的参数。cdecl调用约定可以做到参数数量可变,是因为由主调函数负责在函数调用之后恢复堆栈指针。另外,与其他调用约定相比,cdecl函数是按照相反的顺序接收参数的。第一个参数被首先压入堆栈,最后一个参数最后压入堆栈。(代码疯子注:好像没有这回事吧,在Visual
Studio上测试一下,也是最后一个参数先入栈的,从右至左)识别cdecl调用约定的方法非常简单:如果函数接收了一个或多个参数,并且以一个简单的不带任何操作数的RET指令收尾的话,这个函数很可能是采用cdecl调用约定。

cdecl调用约定
cdecl调用约定又称为C调用约定,是C语言缺省的调用约定,它的定义语法是:

int function (int a ,int b) //不加修饰就是C调用约定
int __cdecl function(int a,int b)//明确指出C调用约定

在写本文时,出乎我的意料,发现cdecl调用约定的参数压栈顺序是和stdcall是一样的
,参数首先由有向左压入堆栈。所不同的是,函数本身不清理堆栈,调用者负责清理堆
栈。由于这种变化,C调用约定允许函数的参数的个数是不固定的,这也是C语言的一大
特色。对于前面的function函数,使用cdecl后的汇编码变成:

调用处

push 1

push 2

call function

add esp,8 注意:这里调用者在恢复堆栈

被调用函数_function处

push ebp 保存ebp寄存器,该寄存器将用来保存堆栈的栈顶指针,可以在函数退出

时恢复

mov ebp,esp 保存堆栈指针

mov eax,[ebp + 8H] 堆栈中ebp指向位置之前依次保存有ebp,cs:eip,a,b,ebp +8指向

a

add eax,[ebp + 0CH] 堆栈中ebp + 12处保存了b

mov esp,ebp 恢复esp

pop ebp

ret 注意,这里没有修改堆栈

MSDN中说,该修饰自动在函数名前加前导的下划线,因此函数名在符号表中被记录为_f

unction,但是我在编译时似乎没有看到这种变化。

由于参数按照从右向左顺序压栈,因此最开始的参数在最接近栈顶的位置,因此当采用

不定个数参数时,第一个参数在栈中的位置肯定能知道,只要不定的参数个数能够根据

第一个后者后续的明确的参数确定下来,就可以使用不定参数,例如对于CRT中的sprin

tf函数,定义为:

int sprintf(char* buffer,const char* format,...)

由于所有的不定参数都可以通过format确定,因此使用不定个数的参数是没有问题的。


fastcall调用约定

顾名思义,fastcall是一种相对比较高效的调用约定:它使用寄存器来给被调函数传递前两个参数,其余的参数通过堆栈传递给被调函数。最初,fastcall是Microsoft公司专用的调用约定,但是现在大多数主流编译器都支持支持这种调用了,所以你可以在更多的现代程序中碰见它。fastcall调用约定通常使用ECX寄存器和EDX寄存器来分别存放第一个参数和第二个参数。

fastcall

fastcall调用约定和stdcall类似,它意味着:

函数的第一个和第二个DWORD参数(或者尺寸更小的)通过ecx和edx传递,其他参数通过

从右向左的顺序压栈

被调用函数清理堆栈

函数名修改规则同stdcall

其声明语法为:int fastcall function(int a,int b)


stdcall调用约定

stdcall调用约定在windows系统中是非常常见的,因为windows系统函数和API都使用这种调用约定。stdcall调用约定在参数传递的方式和顺序上与cdecl调用约定相反。使用stdcall调用约定的函数接收参数的顺序与使用cdecl调用约定的函数的相反,即stdcall中最后一个参数最先压入堆栈。stdcall与cdecl另一个重要的区别在于:在stdcall中被调函数负责清栈,而在cdecl中是由主调函数负责清栈的。stdcall中函数使用RET指令清栈,这是RET指令带有一个操作数,该操作数指明在EIP跳回主要函数之前需要释放的堆栈空间的字节数。这就是说,stdcall调用约定中RET指令带的操作数往往就意味着函数一共传入几个参数。(操作数除以4=参数个数)这是在逆向工程中识别stdcall调用约定的一个重要的特征,并可以据此判断出函数所接收的参数的个数。

_stdcall 是Standard Call的缩写,是C++的标准调用方式:所有参数从右到左依次入栈,如果是调用类成员的话,最后一个入栈的是this指针。这些堆栈中的参数由被调用的函数在返回后清除,使用的指令是 retn X,X表示参数占用的字节数,CPU在ret之后自动弹出X个字节的堆栈空间。称为自动清栈。函数在编译的时候就必须确定参数个数,并且调用者必须严格的控制参数的生成,不能多,不能少,否则返回后会出错。

stdcall很多时候被称为pascal调用约定,因为pascal是早期很常见的一种教学用计算机

程序设计语言,其语法严谨,使用的函数调用约定就是stdcall。在Microsoft C++系列

的C/C++编译器中,常常用PASCAL宏来声明这个调用约定,类似的宏还有WINAPI和CALLB

ACK。

在C语言中,假设我们有这样的一个函数:

int function(int a,int b)

stdcall调用约定声明的语法为:

int __stdcall function(int a,int b)

stdcall的调用约定意味着:1)参数从右向左压入堆栈,2)函数自身修改堆栈 3)函数

名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸

以上述这个函数为例,参数b首先被压栈,然后是参数a,函数调用function(1,2)调用处

翻译成汇编语言将变成:

push 2 第二个参数入栈

push 1 第一个参数入栈

call function 调用参数,注意此时自动把cs:eip入栈

而对于函数自身,则可以翻译为:

push ebp 保存ebp寄存器,该寄存器将用来保存堆栈的栈顶指针,可以在函数退出

时恢复

mov ebp,esp 保存堆栈指针

mov eax,[ebp + 8H] 堆栈中ebp指向位置之前依次保存有ebp,cs:eip,a,b

ebp +8指向a

add eax,[ebp + 0CH] 堆栈中ebp + 12处保存了b

mov esp,ebp 恢复esp

pop ebp

ret 8

而在编译时,这个函数的名字被翻译成_function@8

注意不同编译器会插入自己的汇编代码以提供编译的通用性,但是大体代码如此。其中

在函数开始处保留esp到ebp中,在函数结束恢复是编译器常用的方法。

从函数调用看,2和1依次被push进堆栈,而在函数中又通过相对于ebp(即刚进函数时的

堆栈指针)的偏移量存取参数。函数结束后,ret 8表示清理8个字节的堆栈,函数自己

恢复了堆栈。


C++类成员调用约定(thiscall)

当C++程序中的类方法所接受的参数的个数是固定的时候,Microsoft和Intel编译器会使用这种调用约定。一种快速识别这种调用约定的技巧是:使用这种调用约定的函数指令流将在ecx寄存器中写入一个有效指针,并往堆栈中压入参数,但不使用edx寄存器。原因是每个C++的类方法都必须接收一个类指针(就是this指针),并可能较频繁的使用该指针。编译器则使用这种高效的技巧来传递和存储这个特殊的参数。

对于参数个数不确定的类方法,编译器就将使用cdecl调用约定,并把this指针作为第一个参数首先压入堆栈。

thiscall是唯一一个不能明确指明的函数修饰,因为thiscall不是关键字。它是C++类成

员函数缺省的调用约定。由于成员函数调用还有一个this指针,因此必须特殊处理,th

iscall意味着:

参数从右向左入栈

如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针

在所有参数压栈后被压入堆栈。

对参数个数不定的,调用者清理堆栈,否则函数自己清理堆栈

为了说明这个调用约定,定义如下类和使用代码:

class A

{

public:

int function1(int a,int b);

int function2(int a,...);

};

int A::function1 (int a,int b)

{

return a+b;

}

#include

int A::function2(int a,...)

{

va_list ap;

va_start(ap,a);

int i;

int result = 0;

for(i = 0 i < a i ++)

{

result += va_arg(ap,int);

}

return result;

}

void callee()

{

A a;

a.function1 (1,2);

a.function2(3,1,2,3);

}

callee函数被翻译成汇编后就变成:

//函数function1调用

0401C1D push 2

00401C1F push 1

00401C21 lea ecx,[ebp-8]

00401C24 call function1 注意,这里this没有被入栈

//函数function2调用

00401C29 push 3

00401C2B push 2

00401C2D push 1

00401C2F push 3

00401C31 lea eax,[ebp-8] 这里引入this指针

00401C34 push eax

00401C35 call function2

00401C3A add esp,14h

可见,对于参数个数固定情况下,它类似于stdcall,不定时则类似cdecl

Delphi中函数参数传递方式:

前三个参数通过寄存器eax,edx,ecx传递,超过三个参数部分放在堆栈传递,堆栈传递方式先压入最后的参数,即从右至左。

函数自己恢复堆栈。

当一个函数被调用时,函数的参数会被传递给被调用的函数和返回值会被返回给调用函数。函数的调用约定就是描述参数是怎么传递和由谁平衡堆栈的,当然还有返回值。

http://www.cnblogs.com/ajiannet/archive/2007/04/20/721679.html

跟一个朋友谈堆栈的时候 就写下了这段文字,顺便发到这里给需要的看看吧

汇编初学者比较头痛的一个问题

////////////////////////////////////////////////////////////////////

比如 我们有这样一个C函数

 1

#include<stdio.h>
 2

long test(int a,int b)
 3

{
 4

    a = a + 1;
 5

    b = b + 100;
 6

    return a + b;
 7

}
 8

void main()
 9

{  
10

  printf("%d",test(1000,2000));
11

}
12


写成32位汇编就是这样

 1

;//////////////////////////////////////////////////////////////////////////////////////////////////////
 2

.386
 3

.module flat,stdcall           ;这里我们用stdcall 就是函数参数 压栈的时候从最后一个开始压,和被调用函数负责清栈
 4

option casemap:none            ;区分大小写
 5


 6

includelib msvcrt.lib          ;这里是引入类库 相当于 #include<stdio.h>了       
 7

printf  PROTO C:DWORD,:VARARG  ;这个就是声明一下我们要用的函数头,到时候 汇编程序会自动到msvcrt.lib里面找的了 
 8

                                ;:VARARG 表后面的参数不确定 因为C就是这样的printf(const char *, 

);
 9

                               ;这样的函数要注意 不是被调用函数负责清栈 因为它本身不知道有多少个参数
10

                               ;而是有调用者负责清栈  下面会详细说明
11

.data
12

szTextFmt  BYTE '%d',0        ;这个是用来类型转换的,跟C的一样,字符用字节类型
13

a          dword 1000         ;假设
14

b          dword 2000         ;处理数值都用双字 没有int 跟long 的区别
15


16

;/////////////////////////////////////////////////////////////////////////////////////////
17

.code
18


19

_test proc A:DWORD,B:DWORD 
20

      push ebp
21

      mov  ebp,esp
22

      mov  eax,dword ptr ss:[ebp+8]
23

      add  eax,1
24

      mov  edx,dword ptr ss:[ebp+0Ch]
25

      add  edx,100
26

      add  eax,edx
27

      pop  ebp      
28

      retn 8
29

_test endp
30


31

_main proc 
32

      push dword ptr ds:b       ;反汇编我们看到的b就不是b了而是一个[*****]数字 dword ptr 就是我们在ds(数据段)把[*****]
33

                                ;开始的一个双字长数值取出来
34

      push dword ptr ds:a       ;跟她对应的还有 byte ptr ****就是取一个字节出来 比如这样 mov  al,byte ptr ds:szTextFmt 
35

                                ;就把 % 取出来 而不包括 d
36

      call _test                  
37

      push eax                  ;假设push eax的地址是×××××
38

      push offset szTextFmt
39

      call printf
40

      add  esp,8
41

      ret             
42

_main endp
43

end  _main
44


45

;////////////////////////////////////////////////////////////// 下面介绍堆栈的变化
46


首先要明白的是 操作堆栈段 ss 只能用 esp或ebp寄存器 其他的寄存器eax ebx edx等都不能够用 而 esp永远指向堆栈栈顶 ebp用来 在堆栈段

里面寻址

push 指令是压栈 ESP=ESP-4

pop  指令是出栈 ESP=ESP+4

我们假设main函数一开始堆栈定是 ESP=400

push dword ptr ds:b                 ;ESP-4=396 ->里面的值就是 2000 就是b的数值

push dword ptr ds:a                 ;ESP-4=392 ->里面的值就是 1000 就是a的数值

call test                           ;ESP-4=388->里面的数值是什么?这个太重要了 就是我们用来找游戏函数的原理所在。

                                                 里面的数值就是call test 指令下一条指令的地址->即push eax的地址×××××

到了test函数里面

push ebp                           ;ESP-4=384->里面保存了当前ebp的值 而不是把ebp清零

mov  ebp,esp                       ;这里ESP=384就没变化了,但是 ebp=esp=384,为什么要这样做呢 因为我们要用ebp到堆栈里面找参数

mov  eax,dword ptr ss:[ebp+8]      ;反汇编是这样的 想想为什么a就是[ebp+8]呢

                                   ;我们往上看看堆栈里地址392处就保存着a的值 这里ebp=384 加上8正好就是392了

                                   ;这样就把传递过来的1000拿了出来eax=1000

add  eax,1                         ;相当于 a+1了 eax=1001

mov  edx,dword ptr ss:[ebp+0Ch]    ; 0Ch=12 一样道理这里指向堆栈的地址是384+12=396 就是2000了 edx=2000

add  edx,100                       ;相当于 b+100 edx=2100

add  eax,edx                       ;eax=eax+edx=1001+2100=3101 这里eax已经保存了最终的结果了 

                                   ;因为win32汇编一般用eax返回结果 所以如果最终结果不是在eax里面的话 还要把它放到eax

                                   ;比如假设我的结果保存在变量nRet里面 最后还是要这样 mov eax,dword ptr nRet

pop  ebp                           ;ESP=384+4=388 而保存在栈顶384的值 保存到 ebp中 即恢复ebp原来的值                       

                                   ;因为一开始我们就把ebp的值压栈了,mov ebp,esp已经改变了ebp的值,这里恢复就是保证了堆栈平衡

retn  8                            ;ESP+8->396 这里retn是由系统调用的 我们不用管 系统会自动把EIP指针指向 原来的call的下一条指令

                                   ;由于是系统自动恢复了call那里的压栈所以 真正返回到的时候ESP+4就是恢复了call压栈的堆栈

                                   ;到了这个时候 ESP=400 就是函数调用开始的堆栈,就是说函数调用前跟函数调用后的堆栈是一样的

                                   ;这就是堆栈平衡 

由于我们用stdcall上面retn 8就是被调用者负责恢复堆栈的意思了,函数test是被调用者,所以负责把堆栈加8,call 那里是系统自动恢复的

push eax                ;ESP-4=396->里面保存了eax的值3101

                        ;上面已经看到了eax保存着返回值,我们要把它传给printf也是通过堆栈传递       

push offset szTextFmt   ;ESP-4=392->里面保存了szTextFmt的地址 也就是C里面的指针 实际上没有什么把字符串传递的,我们传的都是地址

                        ;无论是在汇编或C 所以在汇编里没有什么字符串类型 用最多的就是DWORD。嘿嘿游戏里面传递参数 简单多了

call printf             ;ESP-4=388->里面保存了下一条指令的地址

add  esp,8              ;ESP+8=400 恢复了调用printf前的堆栈状态

                        ;上面说了由于printf后面参数是:VARARG 这样的类型是有调用者恢复堆栈的 所以printf里面没有retn 8之类的指令

                        ;这是由调用者负责清栈 main是调用者 所以下面一句就是 add esp,8 把堆栈恢复到调用printf之前

                        ;而call printf那里的压栈 是由系统做的 恢复的工作也是系统完成 我们不用理 只是知道里面保存是返回地址就够  

                      ;了

ret                     ;main 函数返回 其他的事情是系统自动搞定 我们不用理 任务完成
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: