• 1.3 函数调用反汇编解析以及调用惯例案例分析


    首先来段代码来瞧瞧:

    #include <stdio.h>
    
    int add(int x,int y){
        int z;
        z=x+y;
        return z;
    }
    
    int main(){
        int r=add(3,4);
        printf("%d
    ",r);
        return 0;
    }

    一个简单的函数调用,我们把main函数里的r=add(3,4)反汇编:

    可以看到,(这里采用c默认的函数调用惯例,)首先进行参数压栈,看清楚了,是把参数从右往左压栈,然后call这个函数。跟踪,call跟进去后,发现call指令执行后,ESP寄存器减4,也就是说,有往栈里压了个参数--函数返回地址。看内存变化:

    压进去的是0x00401081,从第一图可以看到,这就是call指令后的下一句,也就是函数的返回地址,函数调用完后得知道往哪里返回啊。

    其实,call指令等价于两步操作:
    push 返回地址 jmp 函数入口地址

    我们继续跟进,看被调函数的反汇编代码:

      首先,再提醒一下,前面知道了,已经压栈了三次,前两次是压栈形参,第三次是压栈返回地址。看这里的前两句,又把EBP压栈了,然后把ESP赋值给了EBP。有没有觉得奇怪呢?通常情况下,EBP都存储基址,这里也不例外,由于后面可能出现多次压栈出栈操作,ESP是变动的,需要一个基址寄存器来加减偏移量去栈上的值,毕竟刚才也看到了,栈里可是有不少重要的东东哦,通过基址加减偏移量就可以访问了。于是,EBP就暂时担待了这个重任。后面,ESP做了个减法,为函数内部局部变量等留下一定的栈空间,又压栈了几个寄存器,以备使用他们而不至于毁坏原有数据(后面再出栈就恢复了)。看核心代码,z=x+y后面,把ebp+8地址的值赋给eax,思考一下,ebp+8是哪块内存?回忆下前面栈里都压了什么进去?ebp+0存的是ebp原有值,ebp+4存的是返回地址,ebp+8存的是最后一个被压的参数,ebp+0c存的是......所以这里就是作加法,然后赋值给了ebp-4.这又是什么呢?这是z的地址。在监视窗口里可以看到z的地址就是如此。so,这里可以得出一个结论:ebp+x存的是返回地址、形参等;ebp-x存的是局部变量。

      现在看return z后,又把z的值赋给了寄存器EAX,现在可以明白,函数返回值是借用寄存器来实现的。寄存器是个好东西,但是有一个缺点,就是数量少。要是返回的数据比较多,比如结构体,怎么办?这个后面说。接着看图,出栈,与前面一个个对应,注意顺序蛤。然后,ret,这什么东东?经跟踪发现,ret执行后,ESP+4,也就是说,有数据出栈,细细看下,是返回地址。ret它给EIP提供了返回地址,即等价于POP EIP。完了吗?没有完。别忘了,栈上还有参数呢。先压的是形参,现在还没出呢。看图:

    ret后,即执行到call后面的一句。这里ESP+8,这是维持栈平衡,之前形参占据的空间就释放了,也称之为调用方清栈。然后后面把寄存器存储的返回值赋给r,也就是EBP-4,别忘了,r是main函数的局部变量呢。

      这里顺便提一个汇编知识。看那个call语句,他的16进制编码与返回值代码有何端倪?FFFFFF84<>00401005。这是因为call语句不是直接传绝对地址,而是传偏移量,计算下0040107c与ffffff84结合能不能得到00401005呢?得出是00401000。结论:偏移量=跳转到的地址-call指令后一条指令的起始地址。

      下面,我把返回值改为一个结构体:

    #include <stdio.h>
    
    struct node{
        int x,y,z;
    };
    
    struct node f(struct node t){
        return t;
    }
    
    int main(){
        struct node r;
        r.x=1;
        r.y=2;
        r.z=5;
        f(r);
        return 0;
    }

    进行反汇编,先看下main函数里的情况:

    看call语句之前都做了什么?压栈。这里把ESP赋给EAX,然后传值给EAX+0/4/8等价于压栈的push操作。注意一个地方,就是push edx。他又压栈了个参数,这是什么?后面就知道了。看函数里的反汇编代码:

    直接return t。看return后一句,把EBP+8上的值赋给eax,EBP+0是ebp原有值,EBP+4是函数返回地址,EBP+8是main里面call之前那个push EDX操作,so,这里把那个edx赋给了eax。而后面,把ebp+0C/10/14上的内容都赋给了EAX+0/4/8。最后又把EBP+8也就是edx那个地址赋给了寄存器。别忘了,这里是储存函数返回值的。前面说到,寄存器不够用,那怎么办?这里edx传进来一个地址,然后返回值都依次存进了这个地址,这代表什么?缓冲地带。既然寄存器使不动了,那就靠内存,这里拿这个缓冲地带来中转数据,解决了返回值过多的问题。那么,调用结束后,main函数一定会把这个缓冲地带的内容取出来赋给某个变量的。我们来看看:

      记好了,刚才缓冲地带的地址放进了EAX里面,这样它就可以用来做基址了。故而把EAX+0/4/8赋给一块内存,这里可以看到,赋给了一个匿名局部变量,刚好与前面那个r的地址紧挨着呢。

      故而总结下:c默认的调用惯例,函数返回值可以用寄存器或内存来存储,选择方式依赖于寄存器是否有能力完成任务。

      关于函数的调用惯例,可以参考我的这篇博文:http://www.cnblogs.com/jiu0821/p/4219545.html

      

      下面讲解一下一个有趣的案例,先看源码:

    #include <stdio.h>
    
    typedef int (*func)(int,int);
    
    func pfunc;
    
    int _stdcall add(int a,int b){
        return a+b;
    }
    
    void test(){
        pfunc=(func)add;
        pfunc(1,2);
        add(1,2);
    }
    
    int main(){
        test();
        return 0;
    }

    这里用的函数指针,有不熟悉的可以看我的这篇博文:http://www.cnblogs.com/jiu0821/p/4159487.html

    简单说下源码,就是定义一个函数指针pfunc,把add强转赋给pfunc,分别执行pfunc(1,2)和add(1,2),看有没有什么不一样的地方。add被_stdcall约束。运行一下会发现,pfunc(1,2)运行出现异常。什么原因呢?反汇编一下就知道了。

    对比一下,发现pfunc多了一个出栈操作,而add没有。那个cmp和call是异常处理,不用管,add也有,下面没有截出来而已。还记得函数调用之后出栈操作是为了什么来着?维持栈平衡。这里我基本可以估摸出是栈出了问题。跟进去看看:

    pfunc与add指向同一块内存,因为函数入口地址一样嘛。直接看最后的清栈部分--ret 8。那个8是什么意思呢?ret 8等价于两条指令:pop eip; add  esp,8.

    发现问题了吗?pfunc执行的时候,函数里出栈8字节,外面main那里又出栈8字节,画蛇添足的结果就是自取灭亡。这里的端倪在于调用惯例不同。c缺省调用惯例是_cdecl,调用方清栈,而这里的_stdcall是被调用方清栈。pfunc使用的是_cdecl,却执行_stdcall约束的函数,这可能不错吗?源码里的强转如果去掉,编译器会报错的,而强转就是欺骗编译器。有时候,欺骗别人,往往到最后,把自己也骗了。

    让我想起来之前在博问里一个博友遇到的问题:http://q.cnblogs.com/q/71965/

    const_cast实行强转,成功地骗过了编译器,但是程序员自己写出了未定义行为。我还是直接把我的回复截过来好了。

    const对象是不允许修改的,而const_cast的存在是为了有些特殊情况需要表面去除const属性,比如函数传参,把const对象传进非const属性参数里,表面修改属性实则不改变其内容。而你这里表面上是修改了,*x=3,这种修改const对象属于c++标准里未定义行为,针对这样的,是由编译器来自行处理的。你可以看到他们地址都相同,但却值不一样,这是编译器的处理效果。我们需要做的是,避免编程里出现这种未定义行为。

    c/c++赋给了我们强大的权力,我们不要去胡作非为......

    总结一下就是,强转是很方便,但我们使用的时候,千万要注意,使用得当。

  • 相关阅读:
    程序员丨学习编程需要攻克这 8 个壁垒,解决后编程能力显著提升!
    编程不难学,方法最重要!学习编程语言最好的方法是什么?
    Navicat for MySQL怎么往表中填数据
    WinForm开发(1)——DataGridView控件(1)——C# DataGridView控件用法介绍
    【C#】图解如何添加引用using MySql.Data.MySqlClient;
    源代码管理工具(2)——SVN(2)——第一次用SVN遇到的问题
    源代码管理工具(1)——SVN(1)——SVN 的使用新手指南,具体到步骤详细介绍----TortoiseSVN
    C#取整函数Math.Round、Math.Ceiling和Math.Floor
    服务器(2)——IIS(2)——IIS Express(1)——IIS跟IIS Express之间的区别和关系
    C# 连接 Oracle 的几种方式
  • 原文地址:https://www.cnblogs.com/jiu0821/p/4504917.html
Copyright © 2020-2023  润新知