汇编四 - 函数的本质

函数的本质

call和ret指令

  1. 注:8086/8088的指令系统(三)-过程调用和返回指令,这节有详细说明该指令
  2. call 标号:
    1. 将下一条指令的偏移地址入栈后
    2. 转到标号处执行指令
  3. ret:将栈顶的值出栈,赋值给ip,即 pop ip
    1. 注意:此时的sp=sp-2
  4. call和ret联合使用的作用其实就是高级语言中的函数调用
  5. 实践,考虑以下几种情况
    1. 有无参数
    2. 有无返回值
    3. 现场保护
    4. 局部变量
    5. 堆栈平衡

函数返回值

  1. 函数返回值几种常见的存放方式如下:

     assume cs:code, ds:data, ss:stack
    
     ; 栈段
     stack segment
         db 100 dup(0)
     stack ends  
        
     ; 数据段
     data segment  
         a dw 0
         db 100 dup(0) 
         string db 'Hello!$'
     data ends
        
     ; 代码段
     code segment
     start:
         ; 手动设置ds、ss的值
         mov ax, data
         mov ds, ax
         mov ax, stack
         mov ss, ax  
            
         ; 业务逻辑 
         call mathFunc3
            
         mov bx, ax  
            
         ; 退出
         mov ax, 4c00h
         int 21h 
            
     ; 返回2的3次方
     ; 返回值放到ax寄存器中
     ; 这种方式是最常见的方式,基本上所有的平台(linx、mac、iOS、安卓等)都会把函数的返回值
     ; 默认放在AX中     
     mathFunc3:  
         mov ax, 2
         add ax, ax
         add ax, ax 
            
         ret 
        
     ; 返回2的3次方
     mathFunc2:  
         mov ax, 2
         add ax, ax
         add ax, ax 
         ; 返回值放到a中  
         mov a, ax
            
         ret  
            
     ; 返回2的3次方  
     mathFunc1:  
         mov ax, 2
         add ax, ax
         add ax, ax 
         ; 返回值放到ds:0中  
         mov [0], ax
            
         ret 
                        
     code ends  
        
     end start
    
  2. 总结;

    1. 基本上所有的平台(linx、mac、iOS、安卓等)都会把函数的返回值默认放在AX中
    2. 因为AX本身就在CPU中,不需要去到内存中存取,效率高。

函数的参数

  1. 函数传参如下:

     assume cs:code, ds:data, ss:stack
    
     ; 栈段
     stack segment
         db 100 dup(0)
     stack ends  
    
     ; 数据段
     data segment  
         db 100 dup(0) 
     data ends
    
     ; 代码段
     code segment
     start:
         ; 手动设置ds、ss的值
         mov ax, data
         mov ds, ax
         mov ax, stack
         mov ss, ax  
            
         ;------业务逻辑-----
         ; 使用栈传参
         push 1122h
         push 3344h 
         call sum3 
         add sp, 4
            
         ;寄存器传参
         mov cx, 1122h 
         mov dx, 2233h 
         call sum1 
            
         ;数据段内存传参
         mov word ptr [0], 1122h 
         mov word ptr [2], 2233h 
         call sum2  
            
         ; 退出
         mov ax, 4c00h
         int 21h 
            
     ; 返回值放ax寄存器
     ; 传递2个参数(放入栈中)
     ; 最常用的方法   
     sum3:   
         ; 访问栈中的参数  
         ; SS: [sp + 2] 这样是错误的,因为语法不允许这样写,只能用BP
         mov bp, sp
         mov ax, ss:[bp+2]
         add ax, ss:[bp+4]
         ret 
                  
     ; 返回值放ax寄存器
     ; 传递2个参数(分别放ds:0、ds:2)    
     sum2:         
         mov ax, [0]
         add ax, [2]
         ret 
                    
     ; 返回值放ax寄存器
     ; 传递2个参数(分别放cx、dx中)    
     sum1:  
         mov ax, cx
         add ax, dx
         ret 
                        
     code ends  
        
     end start
    
  2. 总结

    1. 最常用的方式就是使用栈传参
    2. 但是我们知道调用函数前会将函数的下一条指令偏移地址存入栈中,函数退出时也会弹出那个偏移地址给IP,那么怎么办呢?
    3. 部分代码如下:(下面这段代码,即使是监听C语言的反汇编,也是同样的代码)

       push 1122h
       push 3344h 
       call sum3 
       add sp, 4
      
      1. 图片分析 pic-w50
      2. 有图片可知,因此可以理解下面函数的处理:

         sum3:   
         ; 访问栈中的参数  
         ; SS: [sp + 2] 这样是错误的,因为语法不允许这样写,只能用BP
         mov bp, sp
         mov ax, ss:[bp+2]
         add ax, ss:[bp+4]
         ret 
        
    4. 所以BP寄存器的第二作用是:存放堆栈段的偏移地址。

栈平衡

  1. 从上面的代码看出,函数调用完毕之后,栈中会多了两个参数占用内存
  2. 这样的话,栈空间早晚会被用完的
  3. 同时也不符合,函数调用完毕之后局部变量自动销毁的功能
  4. 栈平衡:函数调用前后的栈顶指针要一致
  5. 栈如果不平衡的结果:栈空间迟早会被用完
  6. 因此在函数调用完毕之后要将栈回到原位置

     add sp, 4
    
    1. 栈一回到原来的位置,尽管那些数据还存在栈中,但是已经是垃圾数据,下一次使用时会自动覆盖掉这些数据。
  7. 从上面的分析我们也可以得出如下结论
    1. 函数嵌套调用
      1. 占用的栈是紧挨在一起的,嵌套越多消耗的栈空间越大
      2. 如果是死循环,可想而知,将会超栈
    2. 函数分开调用
      1. 后面的函数一定会覆盖掉前面函数的栈空间
  8. 栈平衡的方法:
    1. 外平栈: 就是上面的平衡方法,就是在函数外面(函数调用完毕)平衡栈

       push 1122h
       push 3344h 
       call sum
       //平衡栈
       add sp, 4 
      
    2. 内平栈:在函数内部平栈: ret 4

       函数调用:
       push 1122h
       push 3344h 
       call sum
              
       函数内部:
       sum:   
       mov bp, sp
       mov ax, ss:[bp+2]
       add ax, ss:[bp+4]
       //平衡栈
       ret 4
      
    3. 通常使用外平栈

  9. 函数调用的本质
    1. 参数:push 参数值
    2. 返回值:返回值存放到ax中
    3. 栈平衡

函数的调用约定(了解)

  1. 就是规定:函数的参数是用还是其他方式传入和栈平衡是用外平栈还是内平栈
  2. 常见规定;
    1. __odecl: 外平栈,参数从右到左入栈
    2. __atdcall: 内平栈,参数从左到右入栈
    3. __fastcall:内平栈,ecx,edx分别传递前面两个参数,其他参数分别从右至左入栈;
      1. ecx,edx:寄存器
      2. 说白了用寄存器传参,当然快
      3. 当寄存器不够用时,就用到栈了,此时用内平栈
    4. 这几个参数仅仅适用于C、C++,iOS是固定好的,不需要设置,默认采用最快的.下面的是C语言,采用__fastcall方式。

       int __fastcall sum(int a, int b)
       {
       	return a+b;
       }
      

函数局部变量

  1. 局部变量特点
    1. 只能在函数内部使用
    2. 函数嵌套时不影响当前局部变量
  2. 代码举例:

     assume cs:code, ds:data, ss:stack
    
     ; 栈段
     stack segment
         db 100 dup(0)
     stack ends  
        
     ; 数据段
     data segment  
         db 100 dup(0) 
     data ends
        
     ; 代码段
     code segment
     start:
         ; 手动设置ds、ss的值
         mov ax, data
         mov ds, ax
         mov ax, stack
         mov ss, ax  
            
         ; 业务逻辑
         push 1
         push 2 
         call sum 
         add sp, 4 
             
         push 1
         push 2 
         call sum 
         add sp, 4   
            
         ; 退出
         mov ax, 4c00h
         int 21h 
            
     ; 返回值放ax寄存器
     ; 传递2个参数(放入栈中)    
     sum:
         ; 保护bp(保证调用前后,bp的值不改变)    
         push bp
         ; 保存sp之前的值:指向bp以前的值
         mov bp, sp
         ; 预留10个字节的空间给局部变量 
         sub sp, 10  
            
         ; -------- 业务逻辑 - begin
         ; 定义2个局部变量  
         ;此时就不能用push了,因为如果用push是操作sp
         ;往你分配的10个空间外面放东西了,而不是往你预留的10个空间内放东西了
         mov word ptr ss:[bp-2], 3 
         mov word ptr ss:[bp-4], 4 
         mov ax, ss:[bp-2]
         add ax, ss:[bp-4] 
         ;在紧接着分配一个空间,存放局部变量计算后的值
         mov ss:[bp-6], ax
            
         ; 访问栈中的参数
         mov ax, ss:[bp+4]
         add ax, ss:[bp+6] 
         add ax, ss:[bp-6] 
         ; -------- 业务逻辑 - end
                               
         ; 恢复sp
         mov sp, bp
         ; 恢复bp
         pop bp
            
         ret 
                        
     code ends  
        
     end start
    
  3. 内存分析图如下: pic-w50

  4. 为什么要保护BP呢?
    1. 已知调用函数进入后BP = sp ,BP存储的是SP的值
    2. 已知每个函数(函数内部有局部变量)处理业务逻辑之后,要恢复sp,然后退出函数,因为一旦函数中有内部参数,SP因要分配内存就会改变,不在是初始进来的时候的值。
    3. 保护与恢复sp的代码如下:
       ; 保护bp(保证调用前后,bp的值不改变)    
       push bp
              
       ...
              
       ; 恢复sp
       mov sp, bp
      
    4. 如果在当前函数中去调用另外一个函数,这里称之为函数2
    5. 函数2调用完毕后,此时的BP已经是函数2的SP初始值了
    6. 再回到函数1用BP恢复SP,那就错误了。
  5. BP + 与 BP- 的规律
    1. BP + 一定是往旧的方向走,访问外部参数
    2. BP- 一定是往新的方向走,局部变量

保护可能会用到的寄存器

  1. 从上面分析的保护BP我们可以看出,有些寄存器,一旦在函数内部修改了他,当函数调用完毕后,我们还要恢复他,那么这些寄存器就是需要保护的寄存器。 那么哪些寄存器通常需要保护呢?
  2. 你在编写程序之前,如果发现可能会需要用到哪些寄存器,那么你就要对这些寄存器进行保护
  3. 比如在编写程序之前我觉得我可能会用到SI/DI/BX这3个寄存器(AX不需要保护,因为AX就是用来临时存放返回值的),那么完整的函数程序代码如下:

     ; 返回值放ax寄存器
     ; 传递2个参数(放入栈中)    
     sum:
         ; 保护bp    
         push bp
         ; 保存sp之前的值:指向bp以前的值
         mov bp, sp
         ; 预留10个字节的空间给局部变量 
         sub sp, 10
            
         ; 保护可能会用到的寄存器
         push si
         push di
         push bx 
             
         ; -------- 业务逻辑 - begin
         ; 定义2个局部变量
         mov word ptr ss:[bp-2], 3 
         mov word ptr ss:[bp-4], 4 
         mov ax, ss:[bp-2]
         add ax, ss:[bp-4]
         mov ss:[bp-6], ax 
            
         ; 访问栈中的参数
         mov ax, ss:[bp+4]
         add ax, ss:[bp+6] 
         add ax, ss:[bp-6]   
         ; -------- 业务逻辑 - end 
            
         ; 恢复寄存器的值
         pop bx
         pop di
         pop si
                               
         ; 恢复sp
         mov sp, bp
         ; 恢复bp
         pop bp
            
         ret 
    
  4. 内存分析图:

    pic-w50

    1. 从内存分析图我们可以看到,为啥要保护的三个寄存器要放在分配函数局部变量内存空间后面呢? 为啥不跟保护BP一样在分配局部变量内存之前呢?
      1. 那是因为BP距离参数跟局部变量的内存越近越好,便于访问,最好挨着。
      2. 这样访问局部变量,直接就可以访问;访问参数,跨越固定的4个字节,就可以访问参数。

保护局部变量的空间

  1. 我们给局部变量分配内存空间时,直接sub sp, 10,但是分配这10个空间可能之前存在这一些垃圾数据,为了安全起见,我们在分配之后要给这些空间赋一些值
  2. 通过在Windows中反汇编C语言代码,我们可以看到,Windows填充的是:CCCC,这个其实就是int 3中断向量码。
  3. 那么如何把局部变量空间这段内存都初始化为:CCCC呢?
    1. 用附加段寄存器es:di来操作局部变量内存这段空间
    2. 只要将es:di指向 ss:bp-10 就可以了
  4. 完整的代码如下:

     ; 返回值放ax寄存器
     ; 传递2个参数(放入栈中)    
     sum:
         ; 保护bp    
         push bp
         ; 保存sp之前的值:指向bp以前的值
         mov bp, sp
         ; 预留10个字节的空间给局部变量 
         sub sp, 10
            
         ; 保护可能会用到的寄存器
         push si
         push di
         push bx 
                     
         ; 保护局部变量空间
         ; 给局部变量空间填充int 3(CCCC):一个中断码 
         ; 汇编规定:立即数要是英文字母开头,前面必须加个0区别
         mov ax, 0cccch       
            
         ;用附加段寄存器es来操作局部变量内存这段空间。
         ; 只要将es:di 指向 ss:bp-10 就可以了
         ; 让es等于ss
         mov bx, ss
         mov es, bx 
         ; 让di等于bp-10(局部变量地址最小的区域)
         mov di, bp
         sub di, 10   
         ; cx决定了stosw的执行次数
         mov cx, 5 
         ; stosw的作用:将ax的值拷贝到es:di中,同时di的值会+2 
         rep stosw  
         ; rep的作用:重复执行某个指令(执行次数由cx决定)
             
         ; -------- 业务逻辑 - begin
         ; 定义2个局部变量
         mov word ptr ss:[bp-2], 3 
         mov word ptr ss:[bp-4], 4 
         mov ax, ss:[bp-2]
         add ax, ss:[bp-4]
         mov ss:[bp-6], ax 
            
         ; 访问栈中的参数
         mov ax, ss:[bp+4]
         add ax, ss:[bp+6] 
         add ax, ss:[bp-6]   
         ; -------- 业务逻辑 - end 
            
         ; 恢复寄存器的值
         pop bx
         pop di
         pop si
                               
         ; 恢复sp
         mov sp, bp
         ; 恢复bp
         pop bp
            
         ret 
    
  5. 综上所述:完整的函数的调用流程(内存)
    1. push 参数
    2. push 函数的返回地址
    3. push bp (保留bp之前的值,方便以后恢复)
    4. mov bp, sp (保留sp之前的值,方便以后恢复)
    5. sub sp,空间大小 (分配空间给局部变量) (这一步最难理解!!!)
    6. 保护可能要用到的寄存器
    7. 使用CC(int 3)填充局部变量的空间 (初始化局部变量内存空间
    8. ——–执行业务逻辑——–
    9. 恢复寄存器之前的值
    10. mov sp, bp (恢复sp之前的值)
    11. pop bp (恢复bp之前的值)
    12. ret (将函数的返回地址出栈,执行下一条指令)
    13. 恢复栈平衡 (add sp,参数所占的空间)

函数的执行环境

  1. 栈帧(Stack Frame Layout)
    1. 就是一个函数执行的环境
    2. 包括:参数、局部变量、返回地址等
    3. 就是在本函数内部执行的所有操作,其实就是上面分析的东西
    4. 函数是在栈中执行的,一个函数代表一个栈帧
    5. 函数3先调用函数1,接着调用函数2

      pic-w50

    6. 进入函数之后,栈中的情况

      pic-w50

    7. 具体图

      pic-w50

Table of Contents