签到

05月06日
尚未签到

共有回帖数 0

    幻梦如戏

    等级:
    终于到这里了!开始本篇最重要的内容。
    累。我可是全手工打码的啊。
    首先复习两个80X86汇编指令,call和ret.

    先来一段汇编代码。很简单,有注释。
    请注意。不同的汇编编译器使用不同的文法。MASM、NASM、gcc后端汇编编译器,它们的文法几乎完全不一样。尤其是gcc后端,他妹的那文法那个汗。
    这里使用的是MASM.学习汇编的话用MASM还是NASM都没关系,学了之后用什么都一样,因为那只是文法方面的东西。指令助记符一般也不会有太多改变。如果真的写汇编代码的话,我想我倾向于使用NASM.
    汇编语句分为指令(instruction)、指示性语句(directive)、和宏(macro).
    只有指令是真正的机器代码。指示性语句是编译器处理的东西。宏是一堆指令性语句或指示性语句。
    以下代码使用MASM。

    .386                                             ;386系统
    .MODEL FLAT                                     ;32位平坦地址模式

    Exit PROTO NEAR32 stdcall, dwPara:DWORD         ;退出函数原型
                                                   ;Exit是函数名,dwPara是函数参数

    .STACK    4096                                   ;保留4096字节栈空间

    .DATA                                            ;数据段,定义全局变量
    number1     DWORD    11111111h                  ;定义变量number1,大小4字节
    number2     DWORD     22222222h                  ;定义变量number2, 大小4字节

    .CODE                                  ;程序代码
    Init PROTO NEAR32                     ;定义函数Init
           mov    number1, 0             ;假设该指令地址为0x0040 0000
       mov    number2, 0
           ret                           ;函数Init返回
    Init ENDP                            ;函数Init结束

    _start:                              ;相当于main函数
           call    Init                 ;调用函数Init,此指令地址为0x0040 000f
            ......                       ;该处指令地址为0x0040 0014
           
           INVOKE Exit, 0                ;调用Exit退出

    PUBLIC _start                        ;公开入口点

    END                                   ;程序结束

    其实代码不用看的...
    假设程序被加载入内存,这时esp被初始化,然后esp指向栈顶。设此时栈顶地址为0x0063 00f8.一切为了说明方便哈。总之程序加载后,栈被初始化,也就是esp被初始化,esp会指向内存中的某个地址,并以这个地址作为栈的起始。
    eip始终指向执行流,也就是“下一条指令”。

    这里说明一下。程序一旦加载,所有的指令、全局变量都被载入内存并有了确切的内存地址(程序加载前,或者说程序没有运行时,只是硬盘上的一个可执行文件对吧。程序运行前有一个系统加载动作,这个加载由操作系统完成)。这个我的另一篇BLOG《程序员的基本概念》里面略提过。清楚加载细节的是操作系统开发者,同时涉及到编译器和链接器。要更明白这个问题请参照《Linker and Loader》。

    那么程序加载。栈初始化了。数据区域在内存中开辟出来了,全局变量被给予确切地址(这里是虚拟地址,因为这是一个进程,它的地址只管在虚拟地址空间中给就可以了,虚拟地址到物理地址的映射由操作系统和MMU完成)。代码段(也就是要执行的指令)也被放入内存中并给予确切地址。eip指向代码段的开始,并开始执行程序...

    所以eip只管指向某个内存地址,这个内存地址存储着程序员编写的指令,然后CPU把指令取出来执行就是了。所以计算机叫做“顺序存储控制机”。对不起我啰嗦了。

    好的。我们假设了,在程序加载后,esp被初始化为0x0063 00f8,并假设了mov number1, 0这个指令的地址在0x0040 0000,根据这个假设的地址和每个指令码的长度(这些指令都放在代码段,而且一个一个指令就是挨着放的),推断出call指令的地址是0x0040 000f,call指令的下一条指令的地址是0x0040 0014(因为这个call指令的长度占用5个字节,0x0040 000f + 5 = 0x0040 0014)。这里不算我对指令长度的计算错误,总之假设我的地址计算是正确的。

    OK开始了。程序已经加载。那么开始程序执行。eip首先指向call指令,因为_start开始那里就是call指令。嗯,eip就是一个32位寄存器,这个寄存器里面的值永远是即将执行的指令的内存地址,这时eip里面的值是0x0040 000f。

    call指令执行!该指令首先将下一条指令的地址压入栈,也就是说,call指令的第一个动作是将0x0040 0014(call指令的下一条指令地址)压入栈。esp此时变化,其值变为0x0063 00f4。为什么?因为esp被初始化为0x0063 00f8,一个地址4个字节入栈之后,esp = esp - 4。然后call指令转去调用Init过程代码。eip变化为0x0040 0000,为什么?因为Init过程的第一个指令地址就是0x0040 0000.这个过程是由CPU自动完成的,也就是说,call指令,让CPU自动完成这一系列动作。

    然后Init过程执行到ret指令。
    ret指令干什么?它将栈内数据弹出,并用该数据填充eip。栈内数据是什么?就是0x0040 0014,它就是call指令的下一条指令的地址!同时esp = esp + 4.也就是说,ret指令执行后,eip值变为0x0040 0014, esp的值变回0x0063 00f8.这个过程由CPU自动完成。ret指令让CPU自动完成这一系列动作。

    整理:执行call,call指令首先将下一条指令地址入栈,然后跑去执行过程代码;过程代码中执行ret,ret首先从栈中将下一条指令地址弹回eip,这样程序就开始执行call指令后的指令。一句话:eip始终指向下一条指令地址。

    以上!就是汇编函数调用和返回的过程。就是一个call和一个ret.eip在这个执行过程中通过栈来保存。

    接下来,让我们开始考察C语言的过程调用和返回,也就是C语言函数的参数压栈和参数访问过程。
    先看一个汇编调用压参和参数访问过程。

    假设有一个add过程,这个过程的工作是将两个整型值(每个整型值4字节)相加,并将相加的和返回eax寄存器。
    如果通过把参数压入堆栈来传递参数调用过程,那么调用方(caller)代码如下:
       push var1               ;第一个变量值
       push var2               ;第二个变量值
       call add                ;调用add过程
       add esp, 8              ;从栈移除参数

    而被调用过程(callee)add的代码如下:
    add    PROC NEAR32           ;add过程,该过程将两个整型值相加
       push ebp                 ;保存基栈指针
       mov ebp, esp            ;建立栈
       mov eax, [ebp + 8]     ;复制第二个参数值(var2)
       mov eax, [ebp + 12]    ;加上第一个参数值(var1)
       pop ebp                 ;恢复ebp寄存器
       ret                     ;过程返回
    add     ENDP                ;过程结束

    我们将根据这段代码建立栈图。
     
    先把调用栈图发上来...
    解释再说


    这你妹...有点看不清楚。点击看大图。要不将就看吧。

    每个格子是一个字节。

    左边是caller(调用者)栈,右边是callee(被调用者)栈(是同一个栈,分别是压参前、call指令执行后的状态。caller和callee的视图)。
    图中画的内存地址是向上增长的。

    首先,esp是栈顶,直接从caller栈顶看起。也就是,在调用前,esp指向某个内存地址。
    在调用函数前将参数压入栈中。
    push var1
    push var2
    这两行代码使esp - 8. 然后压参完毕,图中即为压参完毕esp.
    然后调用函数:
    call add
    嗯,之前复习call指令时说什么了?call指令执行时,首先将返回地址压入栈。
    也就是将add esp, 8 这条指令的地址压入栈。
    如左图所示。

    然后call指令执行过程调用,eip指向add函数内第一条指令的地址:
    push ebp    ;将ebp保存到栈中,同时esp - 4(说过了80X86的栈是向低地址方向增长的).
    此时ebp原值被保存入栈中。参看右图,蓝色部分是ebp原值。
    然后:
    mov ebp, esp
    此时以ebp为基准的栈建立了。此时ebp和esp都指向栈顶(ebp原值被栈保存起来了哦)。
    为什么要这么做?
    因为esp是随时变动的,只要有压栈和出栈的操作,esp的值就随着压栈和出栈的操作变化(随着push和pop操作变化,甚或,程序员直接改动esp的值)。
    而ebp却不会随着push和pop操作变化。程序员在callee中不会修改ebp的值,而是使用ebp作为基准访问参数。

    那么接下来就很好理解了,第二个参数的地址是ebp + 8, 第一个参数的地址是ebp + 12.
    所以
    mov eax, [ebp + 8]      ;复制第二个参数值(var2)到eax
    mov eax, [ebp + 12]     ;加上第一个参数值(var1)
    就不难理解了。

    在过程把实现代码处理完毕的最后,pop ebp将ebp原值从栈中弹出恢复。
    然后ret返回指令将返回地址弹出并赋给eip(请注意,返回地址弹出后,esp + 4, 这时esp正好指向调用者压参完毕的位置),...
    回到调用者的地方并继续执行。

    那么调用处的add esp, 8               ;从栈移除参数
    是干什么用的?注释已经说得很清楚了。
    调用者将var1和var2压到栈中,由于调用者的压栈,esp被往下移动了8;那么这个esp的原始位置也就是caller的栈顶应该在过程调用后恢复,add esp, 8就是恢复esp的。

    ok。基本上就是如此了!

    对于C语言的过程调用,比如,在main函数里面调用add
    int main(int argc, char* argv[])
    {
       ...
       add(x, y);
       ...
    }
    实际上,这里add(x, y)(调用者处)被编译器编译成如下汇编代码:
    push y
    push x
    call add
    add esp, 8

    以上,这就是C过程调用的汇编解释。

    接下来给出一般过程的入口代码和出口代码。
    不难猜测,所有的过程(被调用函数)都有一样的入口代码和出口代码:

    所有的C函数,在被编译器编译成汇编代码之后,
    函数开始的几行汇编代码总是这样的,所以我们称这它为入口代码(entry code):
    push ebp         ;保存基址
    mov   ebp, esp    ;建立ebp偏移基准
    sub esp, n      ;n个字节的局部变量参数
    push ...         ;保存过程中会用到的通用寄存器
    ...
    pushf             ;保存标识寄存器,也就是保存标志位

    而结尾的几行总是这样的,所以称其为出口代码:
    popf             ;恢复标识寄存器
    pop ...         ;恢复寄存器
    ...
    mov esp, ebp    ;恢复callee esp
    pop ebp          ;恢复ebp
    ret              ;返回
    4. stdcall和cdcel

    既然已经了解了上述内容,那么调用惯例就很容易理解了。
    cdcel和stdcall是约定俗成的调用惯例,它们的区别在于由谁来恢复esp。

    cdcel是由调用者恢复esp的调用惯例,
    也就是说
    push var1
    push var2
    call add
    add esp, 8
    这是cdcel调用惯例

    而stdcall则是由callee恢复esp的调用惯例
    stdcall会在callee里面将ret这样写:
    ret 8
    意思是返回的同时esp + 8.

    这两种调用惯例,stdcall的好处是不用每次都在调用过程后写add esp, 8这样就减小了代码量,减小了目标文件的体积。
    而stdcall的缺陷更明显,那就是callee有时候无法推断参数的个数和长度,这样的话esp只能由调用者恢复(比如变参数函数,这种函数callee是无法推断参数个数的,也就无法知道应该在ret后面加多少偏移量)。

    ......
    ......
    ......
    发现了我的代码的一个严重错误。

    call add      ;不能有这样的过程名字,因为这个过程名字TMD和add助记符重名了。


    不过不太影响理解。把这个过程名换掉就可以了。

    楼主 2015-11-26 15:12 回复

共有回帖数 0
  • 回 帖
  • 表情 图片 视频
  • 发表

登录直线网账号

Copyright © 2010~2015 直线网 版权所有,All Rights Reserved.沪ICP备10039589号 意见反馈 | 关于直线 | 版权声明 | 会员须知