从零入门8086汇编

2019-09-29  本文已影响0人  喵子G

为什么要了解汇编?

了解汇编语言能够更加深入的理解高级语言的本质,彻底理解之前只是知道却又不清楚为什么的知识,比如:

基本概念

汇编语言的种类

高级语言的编译流程

编译和反编译

查看高级语言对应的汇编

使用 Visual C++ 6.0 创建一个 Win32 Console Application项目,创建一个数组:

#include "stdafx.h"

int main(int argc, char* argv[])
{
    int array[] = {3, 4};
    return 0;
}

输入光标在 return 0 位置按 F9 插入断点运行项目,然后鼠标右击出下拉菜单点击 Go To Disassembly 查看反汇编代码:

image

基本可以看出来汇编代码的意义:将 3 存在内存地址为 ebp-8 的位置上,将 4 存在内存地址为 ebp-4 的位置上。

将高级语言代码修改为:

int main(int argc, char* argv[])
{
    // int array[] = {3, 4};
    struct Person {
        int no;
        int id;
    } p = {3, 4};
    return 0;
}

反汇编查看对应的汇编代码为:

image

可以发现,对应的汇编代码一摸一样,所以通过汇编无法得到对应的高级语言代码,因为对于汇编来讲,上面的两段代码都是开辟8个字节的内存空间,分别存3和4。通过汇编语言看逆向推导,是无法知道高级语言是创建了数组还是结构体的。这就是为什么汇编语言无法还原成高级语言。

sizeof 是函数调用吗?

通过查看对应的汇编代码可以看到:

image

直接将4传给对应的内存空间,并没有调用任何函数,所以sizeof并不是一个函数,而是一个编译器特性,在编译后直接转成了对应的汇编代码。

同理在Xcode中也是一样的:

image

总线

总线:即一根根导线的集合。

每一个CPU新片都有许多管脚,这些管脚和总线相连,CPU通过总线和外部器件进行交互。

总线的分类:

CPU从内存中读取数据的步骤:

  1. 首先CPU通过地址线 找到要读取数据地址
  2. 通过 控制线 告诉内存去要 读取 操作
  3. 内存通过 数据线 返回数据给CPU

地址总线

地址总线决定了CPU的寻址能力,8086地址总线宽度是20,所以它的寻址能力是1M(2^20)。

寻址能力的计算:首先明白总线就是导线,导线能够传递的是电信号,电信号分为两种:高电平信号、低电平信号,高电平信号即 1,低电平信号即 0。假如总线总线的宽度是3,那么3根导线高电平为1,低电平为0,它们最大能够传递的值只有2^3 种:000,001,010,011,100,101,110,111。

8086地址总线宽度是20,可以表示220种不同的地址值,它的寻址能力就是220即1M。

数据总线

数据总线决定了CPU单次数据的传送量,也就是数据传送的速度。8086的数据总线宽度是16,所以单次最大能够传递2个字节的数据。

单次数据传送量的计算:数据总线的宽度是16,同地址线一样,16根线代表16位0或1,即16位二进制数据,一次最多能够传送16个二进制位。一个字节是8位,16位即2个字节。所以8086单次能够传递的最大数据量就是2个字节。

8088的数据总线宽度是8,8086的数据总线宽度是16,分别向内存中写入89D8H时(89D8H即16进制的89D8,汇编语言中末尾加H代码16进制)。一个16进制代表4个二进制位,两个16进制代表8个二进制位即1个字节,四个16进制即2个字节。

因为8088数据线宽度是8,一次只能传递一个字节,所以8088传递89D8H需要传2次,第一次传D8,第二次传89。而8086只需要一次就能够将89D8传递完成。

控制总线

控制总线决定了CPU的控制能力,代表CPU有多少种控制能力。

练习题

1个CPU的寻址能力位8KB,那么它的地址总线宽度为 ( 13 )

2^10 = 1KB,2^10 * 8 = 8KB = 2^10 * 2^3 = 2^13。

8080、8088、80286、80386 的地址总线宽度分别为 16 根、20 根、24根、32根,则它们的寻址能力分别为:( 64KB )、( 1MB )、( 16MB )、( 4GB )。

2^10 = 1KB,2^20 = 1MB,2^30 = 1GB
16根:2^16 = 2^10 * 2^6 = 64KB
20根:2^20 = 1MB
24根:2^24 = 2^20 * 2^4 = 16MB
32根:2^32 = 2*30 * 2^2 = 4GB
现在知道为什么32位的Windows最大只能支持4G内存的把

8080、8088、8086、80286、80386 的数据总线宽度分别为 8根、8根、16根、16根、32根,则它们一次可以传送的数据为:( 1B )、( 1B )、( 2B )、( 2B )、( 4B )。

8个二进制位(0000 0000):1B
16个二进制位(0000 0000 0000 0000):2B
32个二进制位(0000 0000 0000 0000 0000 0000 0000 0000):4B

从内存中读取 1024 字节的数据,8086至少需要读( 512 )次,80386至少需要读( 256 )次。

读取数据看数据总线的宽度:
8086:16,一次可以读2个字节,1024 / 2 = 512
80386:32,一次可以读4个字节,1024 / 4 = 256

内存

所有的内存单元都有唯一的地址,这个地址叫做物理地址。

8086CPU的地址总线是20根,那么它能够访问的内存空间的地址值范围即 0x00000 - 0xFFFFF(上面已经说明过,一个16进制位=4个二进制位),通过这个范围可以定位2^20个不同的内存单元,所以8006的内存空间大小为1M。

下面是8086内存空间的示意图:

image

8086的寻址方式

上面提到8086的地址总线宽度为20,寻址能力为1M,但是实际上8086是一个16位架构的CPU,它内部能够一次性处理、传输、暂存的数据只有16位。这就意味这8086实际上只能够直接送出16的地址,但是它的地址总线宽度又是20位,意味这这样就有4位是无法使用的,它的实际寻址能力只能够是64KB。那么它是如何做到实现1M的寻址能力呢,具体步骤如下:

  1. CPU中的相关部件提供两个16的地址,一个成为段地址,一个成为偏移地址。
  2. 段地址和偏移地址通过内部总线送入地址加法器。
  3. 地址加法器将两个16位地址合成一个20位的物理地址。
  4. 地址加法器通过内部总线将20位物理地址送入输入输出控制电路。
  5. 输入输出控制电路将20位物理地址送入地址总线。
  6. 20位的物理地址被地址总线送到内存。
image

段地址和偏移地址合成物理地址的计算规则:物理地址 = 段地址 * 10H + 偏移地址。

假如8086CPU需要访问地址为 0x136CC 的内存单元。
需要的拆分为:段地址0x1360,偏移地址0x00CC。
物理地址 = 0x1360 * 0x10 + 0x00CC = 0x136CC

通过上面的计算可以得出在16进制位表示下,合成段地址和偏移地址的规律:段地址 * 0x10 + 偏移地址

当段地址一定的时候,根据变化编译地址最多可访问的内存单元数量为偏移地址的范围0x0000 - 0xFFFF,即64KB。

:段地址和偏移地址计算物理地址并不是所有CPU通用的寻址方式,只是8086是比较特殊,它是一个16位架构的CPU,但是地址线宽度为20。其它高级的CPU并没有这种情况,即它们没有段地址,也不需要地址加法器,只需要一个偏移地址就能够访问全部内存。

寄存器

寄存器是CPU非常重要的部件,可以通过改变寄存器的值来实现对程序的控制。不同CPU的寄存器个数和结构一般都不相同,下面是8086CPU寄存器的结构,8086CPU有14个寄存器,所有寄存器都是16位的。

image

CPU在对内存中的数据进行运算时,首先将内存中的数据存储到寄存器中,然后再对寄存器的数据进行运算。

字节与字

汇编语言没有数据类型的概念,它是直接操作内存的,汇编语言的数据存储单位有两个:

  1. 字节:byte,1个字节由8bit组成,可以存储在8位寄存器中。
  2. 字:word,1个字由2个字节组成,这两个字节分别成高字节和低字节。
比如数据4E20H,高字节是4EH(78),低字节是20H(32)。

0x4E20

0100 1110 0010 0000
|_______| |_______|
 高位字节   低位字节

数据寄存器

数据寄存器由AX、BX、CX、DX组成,虽然上图里边每个每一个寄存器都分成了两块,但它依然是一个寄存器。由于8086之前的CPU是8位的架构,所以8086为了兼容8位的程序,每个16位数据寄存器都可以当作两个单独的8位寄存器来使用。

AX寄存器可以分成两个独立的8位寄存器,高8位为AH,低8位为AL,BX、CX、DX同理。除了四个数据寄存器之外,其它的寄存器均不可以分为两个独立的8位寄存器。独立的意思是:当AH和AL做为8位寄存器使用时,可以看作它们是互不相关的,形式上可以看作两个完全独立的寄存器。既然数据寄存器可以当作两个独立的寄存器,那么它们的即可以用整个寄存器的16位存放一个数据,也可以高8位和低8位分别存放一个数据共存放两个数组。

段寄存器

前面关于8086的寻址方式里边提到,8086需要16位的段地址和偏移地址合成20位地址,其中的段地址就由段寄存器提供。段寄存器一共有四个,每个段寄存器的作用都不相同。

CS 代码段寄存器

CS和IP配合使用,它们指示了CPU当前要读取指令的地址。任何时候,8086CPU都会将CS:IP指向的指令做为下一条需要取出执行的指令。

指令执行的过程:

  1. 从CS:IP指向的代码段内存单元读取指令,读取的指令进入指令缓冲器。
  2. IP = IP+读取指令的长度,进而可以读取下一条指令。
  3. 返回步骤1。

在内存或者磁盘上中,指令和数据没有任何区别,都是二进制信息。
CPU在工作时,有时候把信息当作指令,有时候看作数据,同样的信息赋予不同的意义。

CPU根据什么将内存中的数据信息当作指令?
通过CS:IP指向的内存单元内容看作指令。

DS 数据段寄存器

DS是用来操作内存时提供段地址的,假如需要将内存中10000H 存入1122H,直接这样写是不可以的:

mov 1000H:[0H],1122H

因为汇编语言又如下要求:

  1. 不能直接给内存地址赋值,必须通过DS:[偏移地址]指向内存。
  2. 不能直接通过给DS赋值,必须通过寄存器中转。
; 不能直接给DS赋值,需要通过寄存器中转
mov ax, 1000H
mov ds, ax
; 不能直接给内存地址赋值,必须通过DS:[偏移地址]指向内存
; 内存中的10000H位置存入了1122H
mov [0H], 1122H

SS 堆栈段寄存器

SS配合SP使用,SS:SP指向栈顶元素。后面栈章节中会有更详细的介绍。

8086常用指令

mov指令

mov指令可以修改大部分寄存器的值,比如AX、BX、CX、DX、SS、SP、DS,但是不能修改CS、IP的值,8086没有提供这样的功能。

; 汇编语言中的注释用;
; 将1122H存入寄存器ax
mov ax,1122H

mov使用时最好和byte和word配合使用,明确操作的字节数量:

; 假设内存10000H原始值: 1122H
; 8086是小端模式,高字节放在高地址,低字节放在低地址
; 1000:0000  22
; 1000:0001  11 

; 准备修改10000H位置的值
mov ax, 1000H
mov ds, ax

; 1000:0000  66
; 1000:0001  11
; 修改后10000H: 1166H
mov [0], 66h

; 1000:0000  66
; 1000:0001  11
; 修改后10000H: 1166H
mov byte ptr [0], 66h

; 1000:0000  66
; 1000:0001  00
; 修改后10000H: 0066H
mov word ptr [0], 66h

jmp指令

在高级语言中,很多情况下都需要改变代码的执行流程,比如if...else判断,switch判断等,这些改变代码的执行流程本质上就是改变了CS、IP的指向。但是上面提到不能够直接CS、IP,8086提供了jmp指令:“ jmp 段地址:编译地址 ” 或 “ jmp 某个合法寄存器 ”来完成。

; 修改CS:IP
jmp 23E4:3  
; 执行后:CS=23E4H,IP=0003H
; CPU从23E43处读取指令并送入指令缓冲区。

; 只修改IP
jmp ax
; 执行前:ax=1000H, CS=2000H, IP=0003H
; 执行后:ax=1000H, CS=2000H, IP=1000H

add指令

add是汇编语言中加法操作,add ax, 1111H 指令为将寄存器ax中的值加上1111H再赋值给ax。

; ax=1122H
mov ax,1122H
; ax=2233H
add ax,1111H

sub指令

sub是汇编语言中减法操作,sub ax,0011H 指令为将寄存器ax中的值减去0011H再赋值给ax。

; ax=1122H
mov ax,1122H
; ax=1111H
sub ax,0011H

push指令

入栈,详见后面栈章节。

pop指令

出栈,详见后面栈章节。

大小端

将0x1234存放在CPU内存中的0x4000位置,大小端的区别为:
            小端            大端
0x4000      0x34           0x12
0x4001      0x12           0x34

小端模式:8086、x86

大端模式:PowerPC、IBM、Sun

ARM既可以工作在大端模式,也可以工作在小端模式

8086汇编环境使用和调试

需要运行和调试8086汇编会好的工具就是这个软件 emu8086,这个软件可以非常方便和直观编写、调试、运行8086汇编,支持Windows平台,软件界面如下:

image

安装完成后我先尝试使用一下:

打开emu8086,打开后默认就有一个编辑界面,我们尝试在内存中10003H中写入1234H,编写如下指令后点击emulate按钮执行:

image

执行后会弹出一个调试窗口,点击窗口顶部的菜单栏view-memory打开内存查看视图:

image

在内存查看视图修改默认的段地址和偏移地址,查看1000:0000的位置,可以看到内存中1000:0003位置的值都是00H

image

现在观察调试窗口的信息

image

左侧是当前所有寄存器的值;中间蓝色的是当前执行指令的位置,蓝色的行数就是当前执行指令的长度;右侧就是当前即将执行的指令。我们可以发现如下规律:

点击single step执行mov ax, 1000H

image

点击single step执行mov ds, ax

image

点击single step执行mov bx, 1234H

image

点击single step执行:mov [3H], bx

image

栈是一种具有特殊访问方式的存储空间(后进先出),在栈和队列中有关于栈的数据结构和原理介绍。

; 将ax寄存器的数据入栈
push ax
; 将栈顶的数据送入ax寄存器
pop ax

; 注:8086 push和pop就是以word为单位,没有byte的操作,不需要指定单位

现在假设SS=1000H,SP=0004H,AX寄存器中存放着2266H,并且现在栈的内存空间都是存放00H。

下面就是栈的当前内存结构:

image

push

push ax 指令执行的步骤:

image

虽然栈顶相对内存是上移的,但是存入两个字节时,还是要从栈顶往高拿两个字节的内存存放元素。

如上存入2266H,栈顶上移两位后为:10002H,那么需要用10002H和10003H存放2266H。8086是小端模式,高字节22H放在高地址10003H,低字节66H放在低地址10002H。

pop

接着上面的栈的状态,我们现在执行指令 pop bx。

image

:先从栈顶指针指向的内存位置取两个字节的数据,依然是往高取两个字节:10002H和10003H。按照高字节高地址、第字节低地址的规则,10002H和10003H的存储的值是2266H,将2266H放入bx。

:观察上图第二步后栈的状态,10002H的值依然是66H,10003H的值依然是22H,即pop操作后,内存中的值是不会清0的,它们还保持着原来的值。假如下次再进行将3399H入栈是,那么33H就会覆盖22H,99H就是覆盖66H。

栈越界

image

注意观察上图push操作的SS:SP位置,当栈是空的时候,SS:SP指向的是10004H的位置,push2266H后,最高存放22H的内存是10003H。

假如继续将3399H入栈,那么栈顶指针相对于栈空间的位置关系如下:

image

汇编语言中栈是不会自动判断栈是否越界的,那么就可能出现如下图push和pop越界问题:

image

无论是push还是pop越界都是非常危险的,因为栈外部的内存中可能存放其它任意数据,可能是代码、重要数据等,将它们覆盖或者拿出来使用都可能发生不可预知的严重错误。

练习

将10000H-1000FH当作栈空间,初始状态栈是空的,假设AX=001A,BX=001BH,利用栈将AX、BX值交换。

mov ax, 1000H
mov ss, ax
mov sp, 0010H

mov ax, 001AH
mov bx, 001BH
push ax
push bx
pop ax
pop bx
image

汇编语言的基本语法

下面的代码包含汇编语言的基本指令:

; 将代码段寄存器和我们的代码段关联起来
assume cs:code

; 代码段开始
code segment  
    mov ax, 1122h
    mov bx, 3344h
    add ax, bx         
    
    ; 正常推出程序 相当于 return 0
    mov ah, 4ch
    int 21h
     
; 代码段结束
code ends     
   
; 程序的结束
end

汇编语言指令分为2类:

segment和ends

上面的segment和ends的作用是定义一个段,segment代表段的开始,ends代表段的结束:

段名 segment
    :
段名 ends

一个有意义的汇编程序中,至少要有一个段做为代码段存放代码。

assume

assume 的作用是将代码段和mycode段和CPU中的CS寄存器关联起来。

end

end 代码程序的结束,编译器遇到end就会结束编译。

中断 (重要)

下面的代码代表退出程序,int不是整形的意思,是interrupt的简写,代表中断:

; 只要ah是4ch就可以结束
; al是返回码,类似于return 0的0,mov ax, 4c00h
mov ah, 4ch 
int 21h

中断是由于软件或硬件的信号,使CPU暂停执行当前的任务,转而去执行另一段子程序。

可以通过 “ int 中断码 ” 实现中断,内存中有一张中断向量表,用来存放中断码处理中断程序的入口地址。CPU在接受到中断信号后,暂停当前正在执行的程序,跳转到中断码对应的向量表地址处去执行中断。

常见中断:

下面是int21对应AH寄存器部分功能对照表

AH 功能 调用参数 返回参数
09 显示字符串 DS:DX=串地址 (DS:DX+1)=实际输入的字符数
4c 带返回码结束 AL=返回码 无返回参数

db、dw

db基本使用

汇编语言中可以使用db定义数据:

; 定义100个
// 定义一个字节的00H
db 0h
// 定义一个字的数据0000H
dw 0h

在数据段定义数据相当于创建全局变量

在栈段定义数据相当于指定栈的容量

在代码段定义数据一般不会这样使用

dup 批量声明

可以使用dup批量的去声明数据:

dw 3 dup(1234H)

声明3个1234H:

汇编语言的分段

代码段

代码段用存放我们需要执行的代码。

数据段

数据段的指令用于创建数据,数据段的数据在程序开始运行的时候就已经创建好了,相当于全局变量。

栈段

栈段就是用来函数执行需要使用的临时空间,一般用于存放临时变量、函数返回后下一条指令的偏移地址。

创建一个包含完整的数据段、代码段、栈段的汇编程序:

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:  
    mov ax, stack
    mov ss, ax

    mov ax, data
    mov ds, ax

    mov ax, 1122h
    push ax  
    pop bx  
        
    mov ax, 4c00h 
    int 21h
code ends
      
end start

上面我们定义的栈段的容量是100,可以看到程序运行后,SP=64H=100。

image

结合iOS内存图理解汇编

做为iOS程序员如果了解过iOS内存管理的话一定知道下面的iOS内存布局:

低地址
  |            保留段
  |    
  |            代码段(__TEXT)
  |         
  |            数据段(__DATA)
  |                 字符串常量
  |                 已初始化数据:已初始化的全景变量、静态变量等
  |                 未初始化数据:未初始化的全局变量、静态变量等
  |
  |            堆(heap)⬇️ 地址越来越高
  |
  |            栈(stack)⬆️地址越来越低
  |
  |            内核区
高地址

首先看一下是不是和我们刚刚实现的汇编很相似,有代码段和和数据段。

其中栈地址越来越低是不是和我们刚刚汇编分析的栈push一样,push的时候SP=SP-2,栈顶指针上移,栈顶指针变小,地址变低。

全局变量放在数据段中,即程序运行时就将数据放在数据段中跟刚刚汇编代码中在数据段创建数据是一样的,还有这也解释了为什么全局变量的地址在编译就已经确定好不会再次改变,我们汇编中在数据段创建的数据地址也是确定并且不会变化的。

HelloWorld输出

有了上面的基础指令和分段之后,终于可以实现经典程序HelloWorld的输出了:

; 将代码段寄存器和我们的代码段关联起来
; 将数据段寄存器和我们的数据段关联起来
; 注:这里的关联并没有任何实际操作,相当于给我们自己的注释而已
; 相当于即使不写这一行也没有关系
assume cs:code, ds:data
  
; 数据段开始
data segment  
    ; 创建字符串
    ; 汇编打印字符串要在尾部用 $ 标记字符串的结束位置
    ; 将字符串用hello做一个标记,方便后面使用它
    hello db 'Hello World, Whip!$'
     
; 数据段结束  
data ends

; 代码段开始
code segment  
; 指令执行的起始,类似于C语言的main函数入口
start:  
    ; 汇编语言不会自动把数据段寄存器指向我们程序的数据段
    ; 将数据段寄存器指向我们自己程序的数据段
    mov ax, data
    mov ds, ax

    ; 打印字符串的参数
    ; DS:DX=串地址,将字符串的偏移地址传入dx寄存器
    ; 字符串是在数据段起始创建的,它的偏移地址是0h
    ; offset hello 即找到标记为hello的数据段字符串的编译地址
    ; 还可以写成 mov dx, 0h
    mov dx, offset hello  
    ; 打印字符串,ah=9h代表打印
    mov ah, 9h   
    int 21h  
    
    ; 正常退出程序,相当于高级语言的 return 0
    mov ah, 4ch
    int 21h
     
; 代码段结束      
code ends     
   
; 程序的结束
end start

运行程序会显示打印的窗口:

image

call-ret 汇编中的函数调用

使用call和ret配合可以调用和返回一段其它位置的指令,相当于面向对象语言的中的函数调用:

assume cs:code, ds:data
  
data segment  
    hello db 'Hello World, Whip!$'
data ends

code segment  
start:  
    mov ax, data
    mov ds, ax
    
    call print
    
    mov ah, 4ch
    int 21h  
    
print:
    mov dx, offset hello  
    mov ah, 9h   
    int 21h   
    ret
                  
code ends     
   
end start

上面的汇编指令看起来很简单,call调用,调用的指令完成ret返回,然后在执行call后面的指令。但是,它内部是如何实现的呢?ret之后,是如何知道继续调用哪一条指令呢?下面就从调试工具来看看到底是如何实现的,首先指令的调用是根据CS:IP的指向来决定的,我们要关注CS、IP寄存器的变化,以及各个指令的内存地址,另外这里既然涉及到类似复原的操作,首先想到的就是查看栈里边是否有变化。

首先先知道到即将调用call print的位置,可以发现

image

下面执行call 0000Ch

image

下面一直执行到ret

image

通过上面的分析可以知道call和ret的作用

call

将下一条指令的偏移地址入栈

执行函数

ret

将栈顶的值出栈,赋值给IP

带返回值的函数

上面我们通过call print实现了打印hello world,这里我们换成另一种方式,让call print返回需要打印的字符串的偏移地址,ret后打印出来。首先就是考虑如何将字符串的编译地址返回出来。

下面就用数据段实现:

assume cs:code, ds:data

data segment    
    db 100 dup(0)
    hello db 'Hello World, Whip!$'
data ends

code segment  
start:         
    mov ax, data
    mov ds, ax
    
    call print
        
    mov dx, [0]
    mov ah, 9h
    int 21h   
    
    mov ah, 4ch
    int 21h  
    
print:
    mov [0], offset hello  
    ret
                  
code ends     
   
end start
assume cs:code, ds:data

data segment    
    db 100 dup(0)
    hello db 'Hello World, Whip!$'
data ends

code segment  
start:         
    mov ax, data
    mov ds, ax
    
    call print
        
    mov dx, ax
    mov ah, 9h
    int 21h   
    
    mov ah, 4ch
    int 21h  
    
print:
    mov ax, offset hello  
    ret
                  
code ends     
   
end start

验证函数的返回值存放在ax寄存器中:

先执行函数sum后,将eax寄存器中的值存入int c中,打印c的值,如果c=1+2=3,那么就证明函数返回结构存放在eax中。

注:eax相当于8086的ax。

#include "stdafx.h"

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

int main(int argc, char* argv[])
{
    sum(1, 2);
    int c = 0;

    __asm {
        mov c, eax
    }

    printf("%d", c);

    getchar();
    return 0;
}

输出结果:

image

带参数和返回值的函数

高级语言的函数几乎都是由 返回值-参数-函数名 构成的,比如:

int add(int a, int b) {
    return a + b;
}

我们之前已经在汇编函数实现了带返回值的调用,这里实现完整的带参数-返回值的调用,来实现一个加法的功能。

汇编想要传递数据和上面实现返回值的思路是一样的,可以用很多种方式来考虑,比如使用数据段、使用栈、使用寄存器。

数据段一般不要用来做这种参数的传值,因为参数数据是临时,应该使用完成就释放掉,不应该存到数据段中。

用寄存器实现

在iOS中,编译器默认是优先使用寄存器传值的,当寄存器不够用时才会用栈,这里我们先用寄存器实现一个加法:

assume cs:code, ds:data, 

data segment
    db 20H dup(0)
data ends     

code segment  
start:
    mov ax, data
    mov ds, ax
    
    mov cx, 1111h,
    mov dx, 2222h,
    
    call sum1
    
    mov ax, 4c00h
    int 21h   
    
sum1:
    mov ax, cx
    add ax, dx
    ret
    
code ends
          
end start

先讲1111h、2222h存入寄存器cx、dx中,再调用sum1将cx、dx相加返回到ax,上面已经提到在汇编中将值存入ax即返回值。

用栈实现

下面我们再用栈来传递参数,假如调用call sum之前,先将1111h、2222h入栈,那么在调用call的时候,又会将call的下一条指令的偏移地址入栈,那么此时栈顶其实是call的下一条指令的偏移地址。如何在sum中直接pop的话,会将call的下一条指令的偏移地址出栈,那么ret后就无法继续执行call之后的代码了。所以在sum中不能够进行出栈操作,而是要直接访问栈内的元素。那么可能会有疑问,栈不是只能访问栈顶吗?汇编中不是的,汇编没有编译器语法和API的限制,只要是内存,我们都能够访问。

assume cs:code, ds:data, ss:stack

stack segment
    db 20 dup(0)    
stack ends

data segment
    db 20 dup(0)
data ends     

code segment  
start:
    mov ax, stack
    mov ss, ax
    mov ax, data
    mov ds, ax
    
    push 1111h
    push 2222h
    call sum   
    add sp, 4
    
    mov ax, 4c00h
    int 21h   
          
sum: 
    mov bp, sp
    mov ax, ss:[bp+2]
    add ax, ss:[bp+4] 
    ret

code ends
          
end start

代码解析:

sum: 
    mov bp, sp
    mov ax, ss:[bp+2]
    add ax, ss:[bp+4] 
    ret
    push 1111h
    push 2222h
    call sum   
    add sp, 4

栈平衡

通过上面的操作,就可以明白为什么说高级语言中方法的参数是临时变量,因为还是函数的操作都是在栈中,方法结束后栈又恢复原来栈顶位置。这个恢复栈的操作就是栈平衡。

回收内存空间的误区

栈只是恢复了栈顶的位置,原来存入栈的值并没有恢复成00,所谓的内存的回收并不是将内存重新清零,只是不再占用这块内存,以后需要使用内存的时候可以用新的值覆盖这块内存。如果以前面对高级语言中的内存回收,认为是将内存清空是不对的。

递归调用的问题

通过上面的汇编可以知道,方法的调用需要将参数和下一条汇编指令的偏移地址入栈,在方法结束才会对栈顶进行恢复。当递归调用出现时,每一个方法都会在其内部进行调用另一个方法,相当于在当前汇编方法的ret之前又调用call,这样栈就会无限的push而不会pop,当栈溢出后,就会发生错误导致崩溃。

另外即便不是无限的递归,如果函数之间的调用层级深到一定程度,使得栈空间溢出的话,仍然会造成严重的错误乃至崩溃。而且函数之间的调用也会额外的占用栈空间(内存),这也就是为什么高级语言大多数情况下如果能够用循环解决问题的话,都尽量不用递归的原因之一。

汇编代码相当于:

assume cs:code, ds:data, ss:stack

stack segment
    db 20H dup(0)    
stack ends

data segment    
    db 20H dup(0)
data ends      

code segment
start:
    mov ax, stack
    mov ss, ax
    mov ax, data
    mov ds, ax
    
    push 1111h
    push 2222h
    call sum
    add sp, 4
      
    mov ax, 4c00h
    int 21h

sum:                       
    mov bp, sp
    mov ax, ss:[bp+2]
    add ax, ss:[bp+4]  
    
    push 1111h
    push 2222h
    call sum
    add sp, 4
    
    ret 
    
    
code ends

end start

如下图,递归调用栈内无限的push

image

外平栈和内平栈

外平栈

我们刚刚使用的栈平衡的方法就是外平栈,在函数调用后面对栈进行平衡。

    push 1111h
    push 2222h
    call sum   
    add sp, 4

内平栈

还有一种栈平衡的方法,在函数的内部进行栈平衡操作:

    push 1111h
    push 2222h
    call sum
      
    mov ax, 4c00h
    int 21h

sum:                       
    mov bp, sp
    mov ax, ss:[bp+2]
    add ax, ss:[bp+4]  
    
    ret 4 

函数的调用约定

C++的函数在调用时,可以指定自己对应的汇编代码使用哪种方法传递参数和使用哪种栈平衡方式:

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

:iOS开发中,在Xcode里边设置是无效的,规定就是使用第三种方式,而且会使用更多的寄存器传递参数,基本满足开发使用的函数全部使用寄存器传参。

函数中使用局部变量

单个函数的调用

我们还一直没有在汇编函数中创建局部变量,对应的高级语言如下函数:

int sum(int a, int b) {
    int c = 3;
    int d = 4;
    int e = c + d
    return a + b + e;
}

int mian() {
    sum(1, 2);
    return 0;
}

根据局部变量的特性:只能够在函数内部访问并且函数结束后要回收内存,这样还是使用栈来实现局部变量。下面就来用汇编实现上面的逻辑:

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 1h
    push 2h
    ; 调用方法
    ; 相当于 sum(1, 2);
    call sum
     
    ; 程序结束
    mov ax, 4c00h
    int 21h

sum:   
    ; 当前sp的值复制给bp,用于从栈中取值
    mov bp, sp
    ; 将sp=sp - 10,扩容栈,bp指向栈扩容前栈顶
    ; 用于函数存放函数中创建的临时变量
    sub sp, 10  
    
    ; 在栈中存入临时变量
    ; 在栈扩容前的栈顶位置加入,即bp-2的位置
    ; 对应 int c = 3;
    mov word ptr ss:[bp-2], 3h
    ; 在栈中存入另一个临时变量
    ; 对应 int d = 4;
    mov word ptr ss:[bp-4], 4h
    
    ; 将两个临时变量相加并压入栈中
    ; 相当于 e = c + d;
    mov ax, ss:[bp-2]
    add ax, ss:[bp-4]
    mov ss:[bp-6], ax
    
    ; 将函数的两个参数、两个临时变量的和的临时变量相加,并返回结果
    ; return a + b + e;
    mov ax, ss:[bp+2]
    add ax, ss:[bp+4]   
    add ax, ss:[bp-6]  
    
    ; 函数开始将sp上移了,而bp记录着上移前的位置
    ; 因为ret的时候,需要从栈顶获取下一条指令的偏移地址,现在栈顶ss:sp指向的是扩容后的位置
    ; 需要将sp恢复到上移之前的位置
    mov sp, bp               
    
    ; 将栈顶pop给IP,继续执行call sum 的下一条指令       
    ; 栈平衡(内平栈)
    ret 4 
    
    
code ends

end start

下面分析一下流程:

image

在调用push 01h之前,还有没有传递参数,现在栈是空的,sp=64H,栈定为:0710:0064。

image

执行call sum后,call下一条指令的偏移地址会入栈,当前IP=000E,call指令的长度为3,那么下一条指令的偏移地址为000E + 3 = 11H。

image

可以看到,执行call sum指令后,0011H入栈。当前SP=005E,BP=0000E。

image

接下来将SP=SP-10,可以看到栈容量扩充了10byte,BP指向SP之前的值,即栈顶原来的位置。

image

将两个临时变量3、4入栈,3、4就在运来栈顶的位置继续压入,中间没有空余的内存浪费。

要将输入存入栈最后一个可用位置,加到SS:[BP-2]的位置正好是SP下移之前的位置。第二次临时变量继续在原来BP-2的基础再减2,就是SS:[BP-4],同理,当需要继续加入临时变量,就是SS:[BP-6]的位置。

image

将3和4的和7入栈,因为它对应的也是一个临时变量 e ,需要入栈保存。

image

将两个参数和两个临时变量的和相加,SS:[BP]指向的位置是0710:005E,之前两个参数0001H、0002H存放的位置是:SS:[BP+2]、SS:[BP+4]的位置,临时变量的和存放在SS:[BP-6]的位置。可以发现一个规律,参数同BP+X获取,临时变量通过BP-X获取。

将相加结果存入ax寄存器中,ax=0001H+0002H+0003H+0004H=000A,结果复合预期。

image

ret之前需要现将之前为了存放临时变量将SP=SP-10恢复,这里只需要将BP的值给SP就可以了,可以看到临时变量0003H、0004H、0007H都释放了。

image

执行ret 4,将栈顶0011E给IP,CS:IP指向call sum的下一条指令继续执行接下来的汇编代码,然后内平栈,可以发现栈顶恢复到函数函数之前的位置0710:0064,函数调用占用的所有栈空间全部回收。

函数之间的调用问题

上面的代码看上去好像没有任何问题了,但是实际上还不够,只是单个函数调用的时候看上去可以的。下面假设如下的代码:

int minus(int a, int b) {
    int c = 1;
    int d = 2;
    int e = a - b;
    return e + c - d;
}

int sum(int a, int b) {
    int c = 3;
    int d = 4;
    // 0 + 1 + 2 + 3 + 4 = 10
    int e = minus(8, 7);
    // 0 +
    return e + a + b + c + d;
}


int mian() {
    // 000A;
    sum(1, 2);
    return 0;
}

按照之前单个函数调用的方式,在添加一个minus方法,并在sum函数内部调用它:

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: 

    mov ax, data
    mov ds, ax
    mov ax, stack
    mov ss, ax
                
    push 2h
    push 1h
    call sum
      
    mov ax, 4c00h
    int 21h

sum:                     
    mov bp, sp
    sub sp, 10  
    
    mov word ptr ss:[bp-2], 3h
    mov word ptr ss:[bp-4], 4h
    
    push 7h
    push 8h
    call minus
    mov word ptr ss:[bp-6], ax
        
    mov ax, ss:[bp+2]
    add ax, ss:[bp+4]   
    add ax, ss:[bp-2]
    add ax, ss:[bp-4]
    
    mov sp, bp               
                      
    ret 4   
    
    
minus:

    mov bp, sp
    sub sp, 10
    
    mov word ptr ss:[bp-2], 1h
    mov word ptr ss:[bp-4], 2h
    mov ax, ss:[bp+2]
    sub ax, ss:[bp+4]
    mov ss:[bp-6], ax  
    
    mov ax, ss:[bp-6]
    add ax, ss:[bp-2]
    sub ax, ss:[bp-4]
    
    mov sp, bp
    
    ret 4
                                                                
code ends

end start

运行起来我们会发现汇编指令无限的进行一个循环操作,根本无法正常结束。现在来分析一下原因:

image

调用call minus这里都是我们前面都已经理解的流程,下面开始是关键之处。

image

call minus之前首先将0007H和0008H入栈,现在SP由0054变成了0050,注意当前BP的值是sum栈扩容前SP的值,BP是关键,要特别关注。

这里再额外注意一下call minus 的下一条指令和它的指令地址:

; 071E:0034
mov word ptr ss:[bp-6], ax
image

当执行call minus后,栈中又压入call minus后面指令的偏移地址,上面可以知道call minus指令的偏移地址是0031,长度是3,所以0031+3=0034H,入栈的值也刚好是0034H。现在BP的值依然是005E。

接下来要执行的代码就是 mov bp, sp 。这个方法之前单独执行sum方法的时候已经使用过,是为了能够访问当前方法使用的栈的元素,而且也是为了释放当前栈的局部变量空间。

image

现在BP已经从之前的005E指向了当前SP的值004E了,而且当前SS:BP存储的值是0034,是sum函数中 call minus后面一条指令的偏移地址。

image

当minus执行完计算结果到 mov sp, bp这条指令时;ax=8-7+1-2=0H,结果复合预期。当前SP指向0044,BP指向004E,下面执行mov sp, bp,将bp的值送给sp。

image

接下来minus执行ret,将0034送给IP,并将恢复栈平衡。

image

虽然现在会直接执行call minus后面的代码:

; 071E:0034
mov word ptr ss:[bp-6], ax

这里已经发现了问题:BP还是指向004E,这就导致sum内部在调用minus函数之后,通过bp取的值都是错误,而且sum在结算完结果后,恢复sp的时候,使用的bp也是错误的,它会将sp设置为0034。

而ret指令又将ip设置为ss:sp的值,即ip变成了0034,这就导致ret之后执行的不是call sum之后的指令,而是071E:0034地址对应的指令。

这个指令我们之前已经特别记录了,就是sum函数中调用call minus后面的指令,所以sum在调用call minus后,会在 mov word ptr ss:[bp-6], ax 和 ret 4 之间无限的循环执行。

既然问题出现在BP寄存器,我们就需要想办法解决汇编函数之间调用,BP寄存器的状态恢复问题。

BP寄存器的保护和恢复

上面已经分析了函数之间调用是由于BP的问题,这里就来解决这个问题,依然是使用栈来对BP的原始值进行存储,在需要恢复的BP的时候进行恢复,只需要添加和修改几处代码:

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: 
    mov ax, data
    mov ds, ax
    mov ax, stack
    mov ss, ax
    
    ; 记得前面的函数调用约定吧,参数从右到左入栈
    push 2h
    push 1h
    call sum
      
    mov ax, 4c00h
    int 21h

sum:  
    ; 先将bp的值入栈存储,函数结束将bp恢复
    ; 因为这里有了一次push操作,所以当前栈顶相比之前多了bp的值
    ; 现在栈顶数据结构为:
    ;  -- bp值  
    ;  -- 函数调用完要执行的指令的偏移地址
    ;  -- 函数的参数
    push bp  
    ; 将sp的值赋值给bp
    ; 因为栈相比之前多push了bp的原始值
    ; 所以此时bp的地址相比之前要小2
    mov bp, sp
    sub sp, 10  
    
    mov word ptr ss:[bp-2], 3h
    mov word ptr ss:[bp-4], 4h
    
    push 7h
    push 8h
    call minus
    mov word ptr ss:[bp-6], ax
       
    ; 因为bp比之前小2,所以通过bp取参数需要在原来的基础上+2
    mov ax, ss:[bp+4]
    add ax, ss:[bp+6]   
    add ax, ss:[bp-2]
    add ax, ss:[bp-4]
    
    ; 将bp赋值给sp之后,这里的sp相比之前+2
    ; 栈顶存放值bp之前的值
    mov sp, bp 
    ; 将bp原来的值重新送给bp
    ; 现在栈顶重新指向了下一条指令的偏移地址
    pop bp              
                      
    ret 4   
    
minus:
    push bp
    mov bp, sp
    sub sp, 10
    
    mov word ptr ss:[bp-2], 1h
    mov word ptr ss:[bp-4], 2h
    mov ax, ss:[bp+4]
    sub ax, ss:[bp+6]
    mov ss:[bp-6], ax  
    
    mov ax, ss:[bp-6]
    add ax, ss:[bp-2]
    sub ax, ss:[bp-4]
    
    mov sp, bp    
    pop bp
    
    ret 4
                                                                
code ends

end start
image

观察ax寄存器中,已经得到正确的结果000AH,BP按照上图的流程正确的恢复为0000E,栈空间也正确的回收。

通过上面的函数调用应该已经可以发现汇编的规律:

其他寄存器的保护和恢复

将上面的C++代码运行并查看其对应的汇编代码:

13:   int sum(int a, int b) {
// 对应我们汇编代码的 push bp
00401080   push        ebp
// 将sp值赋给bp
00401081   mov         ebp,esp
// 栈顶下移扩容栈空间,用于存放临时变量
00401083   sub         esp,4Ch
00401086   push        ebx
00401087   push        esi
00401088   push        edi
// 填充栈空间
00401089   lea         edi,[ebp-4Ch]
0040108C   mov         ecx,13h
00401091   mov         eax,0CCCCCCCCh
00401096   rep stos    dword ptr [edi]
// 局部变量 通过 bp-x 赋值
14:       int c = 3;
00401098   mov         dword ptr [ebp-4],3
15:       int d = 4;
0040109F   mov         dword ptr [ebp-8],4
16:       // 0 + 1 + 2 + 3 + 4 = 10
17:       int e = minus(8, 7);
// 调用函数前通过栈push参数
// 从右到左入栈
004010A6   push        7
004010A8   push        8
004010AA   call        @ILT+10(minus) (0040100f)
004010AF   add         esp,8
004010B2   mov         dword ptr [ebp-0Ch],eax
18:       // 0 +
19:       return e + a + b + c + d;
004010B5   mov         eax,dword ptr [ebp-0Ch]
004010B8   add         eax,dword ptr [ebp+8]
004010BB   add         eax,dword ptr [ebp+0Ch]
004010BE   add         eax,dword ptr [ebp-4]
004010C1   add         eax,dword ptr [ebp-8]
20:   }
004010C4   pop         edi
004010C5   pop         esi
004010C6   pop         ebx
004010C7   add         esp,4Ch
004010CA   cmp         ebp,esp
004010CC   call        __chkesp (00401140)
// 将bp值给sp
004010D1   mov         esp,ebp
// 恢复bp
004010D3   pop         ebp
004010D4   ret

虽然在函数内部穿插着很多现在还看不懂的代码,但是还是能够从中发现,函数调用的总体流程是一致的。下面就来一一去解释和上面比我们多出来的代码指令。

这段代码是在保护和恢复bp寄存器的基础上,对如下寄存器也进行了保护和恢复:

// 栈顶下移扩容栈空间,用于存放临时变量
00401083   sub         esp,4Ch

00401086   push        ebx
00401087   push        esi
00401088   push        edi

004010C4   pop         edi
004010C5   pop         esi
004010C6   pop         ebx

下面这段代码是将栈空内用于存放临时变量的空间,全部用CC填充,当用程序异常,IP指向了临时变量的值,并且这里的值是CC的话,就会中断,程序停在这里,是一种安全机制。

00401089   lea         edi,[ebp-4Ch]
0040108C   mov         ecx,13h
00401091   mov         eax,0CCCCCCCCh
00401096   rep stos    dword ptr [edi]

完整的汇编程序结构:

我们也把其他寄存器的保护代码加上,实现一个完整的汇编程序:

; 代码段、数据段、栈段声明
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: 
    ; 数据段关联
    mov ax, data
    mov ds, ax
    ; 栈段关联
    mov ax, stack
    mov ss, ax
    
    ; 记得前面的函数调用约定吧,参数从右到左入栈
    ; 参数入栈
    push 2h
    push 1h
    ; 调用函数
    call sum
      
    ; 程序正常退出
    mov ax, 4c00h
    int 21h

sum:  
    ; 先将bp的值入栈存储,函数结束将bp恢复
    ; 因为这里有了一次push操作,所以当前栈顶相比之前多了bp的值
    ; 现在栈顶数据结构为:
    ;  -- bp值  
    ;  -- 函数调用完要执行的指令的偏移地址
    ;  -- 函数的参数
    push bp  
    ; 将sp的值赋值给bp
    ; 因为栈相比之前多push了bp的原始值
    ; 所以此时bp的地址相比之前要小2
    mov bp, sp
    sub sp, 10  
    
    ; 在后面保护寄存器
    ; 因为这样方便bp访问局部变量和参数,让bp在局部变量和参数之间
    push bx
    push si
    push di
    
    ; 函数执行逻辑
    mov word ptr ss:[bp-2], 3h
    mov word ptr ss:[bp-4], 4h
    
    ; 传入参数
    push 7h
    push 8h
    ; 调用函数
    call minus
    
    ; 函数执行逻辑
    mov word ptr ss:[bp-6], ax
       
    ; 因为bp比之前小2,所以通过bp取参数需要在原来的基础上+2
    mov ax, ss:[bp+4]
    add ax, ss:[bp+6]   
    add ax, ss:[bp-2]
    add ax, ss:[bp-4]
    
    ; 恢复寄存器
    pop di
    pop si
    pop bx
    
    ; 将bp赋值给sp之后,这里的sp相比之前+2
    ; 栈顶存放值bp之前的值
    mov sp, bp 
    ; 将bp原来的值重新送给bp
    ; 现在栈顶重新指向了下一条指令的偏移地址
    pop bp              
         
    ; 结束函数并恢复栈平衡             
    ret 4   
    
minus:
    push bp
    mov bp, sp
    sub sp, 10
    
    push bx
    push si
    push di
    
    mov word ptr ss:[bp-2], 1h
    mov word ptr ss:[bp-4], 2h
    mov ax, ss:[bp+4]
    sub ax, ss:[bp+6]
    mov ss:[bp-6], ax  
    
    mov ax, ss:[bp-6]
    add ax, ss:[bp-2]
    sub ax, ss:[bp-4]
    
    pop di
    pop si
    pop bx
    
    mov sp, bp    
    pop bp
    
    ret 4
    
code ends

end start

完整的汇编函数调用流程:

1,push 参数
    传递参数给函数
2,push 函数下一条指令的偏移地址
    用于函数执行完成后,能够正确执行后面的指令
3,push bp
    保存bp之前的值用于恢复bp
4,mov bp, sp
    保留sp之前的值,用于恢复sp;用于访问栈空间的数据
5,sub sp x
    分配栈空间给函数用于存储局部变量,x为自定义的大小
6,push bx\si\di
    保护需要保护的寄存器
7,执行函数代码
8,pop di\si\bx
    恢复寄存器
9,mov sp,bp
    恢复sp
10,pop bp
    恢复bp
11,ret (x) 
    函数返回,栈平衡(内平栈或外平栈)
上一篇下一篇

猜你喜欢

热点阅读