一.认识汇编语言
要认识汇编语言,还得从编程语言的发展说起,语言有以下几种分类,其发展都是为了让我们更容易去操纵计算机:
- 机器语言:由0和1组成。
- 汇编语言:用符号代替了0和1,比机器语言更便于阅读和记忆。
- 高级语言:Objective-C/Java/C++等,更接近人类语言,方便程序员的使用。
举个例子🌰:
以赋值a=b为例(计算上的操作就是将寄存器BX的内容送入寄存器AX):
可以看到我们写的代码经过编译转换成计算机能够知道的指令,从而让计算机进行相应的操作。汇编语言与机器语言一一对应,每一条机器指令都有与之对应的汇编指令。汇编语言可以通过编译得到机器语言,机器语言可以通过反汇编得到汇编语言。高级语言可以通过编译得到汇编语言\机器语言,但汇编语言\机器语言几乎不可能还原成高级语言。
汇编语言有如下特点:
① 可以直接访问、控制各种硬件设备,比如存储器、CPU等,能最大限度地发挥硬件的功能。但是知识点过多,开发者需要对CPU等硬件结构有所了解,所以不易于编写、调试、维护。
② 能够不受编译器的限制,对生成的二进制代码进行完全的控制。
③ 目标代码简短,占用内存少,执行速度快。可能写的源文件比较大(相对高级语言比较啰嗦),但是编译后的目标文件、可执行文件都小得多。
以c = a + b为例:(对比汇编语言和C++语言)
④ 汇编指令是机器指令的助记符,同机器指令一一对应。每一种CPU都有自己的机器指令集\汇编指令集,所以汇编语言不具备可移植性。
也是因为汇编语言的这些特点,让学习它有了重要的用途:
- 理解代码的本质,为编写高效代码打下基础。
- 理解整个计算机系统的最佳起点和最有效途径。
- 编写驱动程序、操作系统,对性能要求极高的程序或者代码片段,可与高级语言混合使用(内联汇编)
- 软件安全、病毒分析与防治
iPhone里面用到的是ARM汇编:
- armv6:iPhone, iPhone2, iPhone3G, 第一代、第二代 iPod Touch
- armv7:3GS,4, 4S,iPad, iPad2, iPad3, iPad mini, iPod Touch 3G, iPod Touch4
- armv7s:5, 5C, iPad4
- arm64:5S 及以后 iPhoneX , iPad Air, iPad mini2以后
二. 计算机硬件结构基础与汇编
要想学好汇编语言,首先要对CPU等硬件结构有一定的了解。最为关键的是需要了解CPU和内存,我们遇到的绝大部分指令都是跟内存、CPU有关的。
App的启动过程如下:
接下来我们具体讲讲数据(指定)的交互,其中有这么几个重要的部件需要了解:
① 总线
② 内存
③ 寄存器
① 总线
每一个CPU芯片都有许多管脚,这些管脚和总线相连,CPU通过总线跟外部器件进行交互。
总线有三种类型:地址总线、数据总线和控制总线。
以CPU从内存的3号单元读取数据为例:
地址总线负责传地址(寻址),它的宽度决定了CPU的寻址能力。比如8086的地址总线是20(20根线),寻址能力是1M(2的20次方)(2的10次方就进1个单位,B → KB → M)。
数据总线负责传数据,它的宽度决定了CPU的单次数据传输量,也就是数据传输速度。比如从内存中读取1024字节的数据(1024B),8086(16位,即每次传2字节)至少要读(传输)512次,80386(32位,即每次传4字节)至少要读256次。 → 我们常说的32位和64位CPU指的就是这个
控制总线负责传控制命令,它的宽度决定了CPU对其他器件的控制类型的多少。
例子🌰:8088的数据总线宽度是8(8条线,一条只能传0和1两种讯号,),8086的数据总线宽度是16,分别向内存中写入89D8H(这边4个16进制,需要16位,所以8080只能先传一半)
常见的数据宽度:
位(Bit): 1个位就是1个二进制位0或1。
字节(Byte): 1个字节由8个Bit组成(8位)。内存中的最小单元Byte。
字(Word): 1个字由2个字节组成(16位),这2个字节分别称为高字节和低字节。
进制占字节数
2进制:一个2进制占1位,1/8个字节。
8进制:一个8进制占3位,3/8个字节。
16进制:一个16进制占4位,1/2个字节。
② 内存
内存(Memory)也被称为内存储器,其作用是用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。只要计算机在运行中,CPU就会把需要运算的数据调到内存中进行运算,当运算完成后CPU再将结果传送出来,内存的运行也决定了计算机的稳定运行。
计算机中有各类存储器,它们的逻辑连接情况如下:
内存地址范围(空间大小)受CPU地址总线宽度的限制。比如8086地址总线宽度是20,可以定位2的20次方不同的内存单元,所以内存地址范围为0x00000~0xFFFFF(1个16位占4),内存空间大小为1M。
各类存储器地址肯定是不一样的,他们又不一定是在一块,所以我们把它想象成一块虚拟的逻辑存储器。如下图:
每段内存地址都有特定的用途:
CPU访问内存单元时,要给出内存单元的地址。8086有20位地址总线,可以传送20位的地址,1M的寻址能力(从首地址到尾地址为1M)。但它又是16位结构的CPU,它内部能够一次性处理、传输、暂时存储的地址为16位。如果将地址从内部简单地发出,那么它只能送出16位的地址,表现出来的寻址能力只有64KB。
所以,8086采用一种在内部用2个16位地址(段地址和偏移地址)合成的方法来生成1个20位的物理地址。8086是用“起始地址(段地址×16(16位就是进一格,后面补0)) + 偏移地址 = 物理地址”的方式给出物理地址。
为了开发方便,我们可以采取分段的方法来管理内存,比如:
地址10000H - 100FFH的内存单元组成一个段,该段的起始地址为10000H,段地址为1000H,大小为100H。地址10000H - 1007FH、10080H - 100FFH的内存单元组成2个段,它们的起始地址为:10000H和10080H,段地址为1000H和1008H,大小都为80H。
偏移地址为16位,变化范围0-FFFFH,仅用偏移地址来寻址最多可寻64KB个内存单元(寻址能力为64KB,所以一个段的长度最大为64KB)。比如给定段地址1000H,用偏移地址寻址,CPU的寻址范围为:10000H - 1FFFFH。
③ 寄存器
总线和内存算是CPU外面的东西,接下来我们要说最重要的部分——CPU。
CPU主要是解释计算机指令以及处理计算机软件中的数据。它主要由三大部件构成(其内部部件之间由总线相连):
对程序员来说,CPU中最主要部件是寄存器,可以通过改变寄存器的内容来实现对CPU的控制。
不同的CPU,寄存器的个数、结构是不相同的。比如:8086是16位结构的CPU,有14个16位的寄存器(每个寄存器可以存放2个字节)。
以通用寄存器为例:
AX、BX、CX、DX这4个寄存器通常用来存放一般性的数据,称为通用寄存器(有时也有特定用途)。通常,CPU会先将内存中的数据存储到通用寄存器中,然后再对通用寄存器中的数据进行运算。
假设内存中有块红色内存空间的值是3,现在想把它的值加1,并将结果存储到蓝色内存空间。会有三个步骤:
① CPU首先会将红色内存空间的值放到AX寄存器中:mov ax,红色内存空间
② 然后让AX寄存器与1相加:add ax,1
③ 最后将值赋值给内存空间:mov 蓝色内存空间,ax
像AX、BX、CX、DX这4个寄存器都是16位的,意味着它们能存16个0或1,可以存两个字节(byte),一个字(word)。上一代8086的寄存器都是8位的,为了保证兼容, AX、BX、CX、DX都可分为2个独立的8位寄存器来使用。
在汇编的数据存储中,有2个比较常用的单位:
字节(byte):1个字节由8bit组成,可以存储在8位寄存器中。
字(word):1个字由2个字节组成,这2个字节分别称为字的高字节和低字节。
比如上图中,数据20000(4E20H,0100111000100000B),高字节的值是78,低字节的值是32。1个字可以存在1个16位寄存器中,这个字的高字节、低字节分别存储在这个寄存器的高8位寄存器(AH)、低8位寄存器(AL)中。
已知,8086在访问内存时要由相关部件提供内存单元的段地址和偏移地址,送入地址加法器合成物理地址。那是什么部件提供段地址?
段地址在8086的段寄存器中存放,8086有4个段寄存器:CS、DS、SS、ES,当CPU需要访问内存时由这4个段寄存器提供内存单元的段地址。
CS (Code Segment):代码段寄存器
DS (Data Segment):数据段寄存器
SS (Stack Segment):堆栈段寄存器
ES (Extra Segment):附加段寄存器
CS(代码段寄存器)
CS为代码段寄存器,IP为指令指针寄存器,它们指示了CPU当前要读取指令的地址。任意时刻,8086CPU都会将CS:IP指向的指令作为下一条需要取出执行的指令。
步骤如下:
① CS、IP中的内容送入地址加法器,生成物理地址20000H。
② 地址加法器将物理地址送入输入输出控制电路。
③ 输入输出控制电路将物理地址20000H送上地址总线。
④ 从内存20000H单元开始存放的机器指令(B8 23 01)通过数据总线被送入CPU。(为什么是3个呢?应该是这三个组成一个完整的机器指令,有的是两个组成的)
⑤ 输入输出控制电路将指令(B8 23 01)送入指令缓冲器。
读取一条指令后,IP中的值自动增加,以使CPU可以读取下一条指令。因当前读入的指令(B8 23 01)为三个字节,所以IP中的值加3。此时,CS:IP 指向内存单元2000:0003。
⑥ 执行控制器执行指令(B8 23 01),即mov ax 0123H。
⑦ 指令被执行后AX中的内容为0123H。
此时,CPU将从内存单元2000:0003处读取指令。
⑧ CPU从内存20003H处读取指令(BB 03 30)入指令缓存器,IP中的值加3。
后面指令以此类推。。。
在8086CPU启动或复位后,CS:IP 被设置为FFFFH:0000H,所以FFFF0H单元中的指令是开机后的第一条指令。
CPU从何处执行指令是由CS、IP中的内容决定的,我们可以通过改变CS、IP的内容来控制CPU执行目标指令。
jmp指令(跳转)
8086提供了一个mov指令(传送指令),可以用来修改大部分寄存器的值,比如mov ax,10、mov bx,20、mov cx,30、mov dx,40。但是,mov指令不能用于设置CS、IP的值,8086没有提供这样的功能。
8086提供了另外的指令来修改CS、IP的值,这些指令统称为转移指令,最简单的是jmp指令。
jmp 段地址:偏移地址 ➡️ 用指令中给出的段地址修改CS,偏移地址修改IP
jmp 直接值➡️用直接值修改IP
jmp 某一合法寄存器➡️用寄存器中的值修改IP
例子🌰:
jmp 2AE3:3,执行后CS = 2AE3H,IP = 0003H,CPU将从2AE33H处读取指令。
DS(地址段寄存器)
CPU要读写一个内存单元时,必须要先给出这个内存单元的地址,在8086中,内存地址由段地址和偏移地址组成,8086中有一个DS段寄存器,通常用来存放要访问数据的段地址。
举个例子🌰:
mov bx,1000H
mov dx,bx
mov al,[0]
上面3条指令的作用将10000H(1000:0)中的内存数据赋值到al寄存器中。mov al,[address]的意思将DS:address中的内存数据赋值到al寄存器中,由于al是8位寄存器,所以是将一个字节的数据赋值给al寄存器(由于8086不支持将数据直接送入段寄存器中,mov ds,1000H是错误的)。
上面的例子中是赋值给al(8位),如果是赋值给ax呢(16位)?那就是字型数据的传递(2个字节,16位)。
在内存中,一个地址放一个字节的数据(8位,两个16进制,如34),传递字型意味着要2个内存单元。那是从低内存开始或者从高内存开始,这就涉及到大小端的概念。
- 大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中(高低\低高)
- 小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中(高高\低低)
比如我们常见的x86就是小端模式,ARM小端大端都可以。
举个例子🌰:
mov指令(赋值)
mov 寄存器,数据 // 比如mov ax,8
mov 寄存器,寄存器 // 比如mov ax,bx
mov 寄存器,内存单元 // 比如mov ax,[0]
mov 内存单元,寄存器 // 比如mov [0],ax
mov 段寄存器,寄存器 // 比如mov ds,ax
mov 寄存器,段寄存器 // 比如mov ax,ds
mov 内存单元,内存单元 //这个是错误的
add和sub指令
add 寄存器,数据 // 比如add ax,8
add 寄存器,寄存器 // 比如add ax,bx
add 寄存器,内存单元 // 比如add ax,[0]
add 内存单元,寄存器 // 比如add [0],ax
sub 寄存器,数据 // 比如sub ax,8
sub 寄存器,寄存器 // 比如sub ax,bx
sub 寄存器,内存单元 // 比如sub ax,[0]
sub 内存单元,寄存器 // 比如sub [0],ax
栈(栈段)
栈:是一种具有特殊的访问方式的存储空间(后进先出)
8086会将CS作为代码段的段地址,将CS:IP指向的指令作为下一条需要取出执行的指令。
8086会将DS作为数据段的段地址,mov ax,[address]就是取出DS:address的内存数据放到ax寄存器中。
8086会将SS作为栈段的段地址,任意时刻,SS:SP指向栈顶元素。
8086提供了PUSH(入栈)和POP (出栈)指令来操作栈段的数据
比如push ax是将ax的数据入栈,pop ax是将栈顶的数据送入ax。
栈空时,SS:SP指向栈空间最高地址单元的下一个单元(就是栈的下面,栈外了)。
push和pop指令
在8086中,push、pop操作的数据都是2个字节的,也就是以字为单位的。
push 寄存器 // 将一个寄存器中的数据入栈
pop 寄存器 //用一个寄存器接收出栈的数据
push 段寄存器 // 将一个段寄存器中的数据入栈
pop 段寄存器 //用一个段寄存器接收出栈的数据
push 内存单元 // 将一个内存单元处的字(也就是开始的两个内存单元)入栈
pop 内存单元 //用一个内存单元接收出栈的数据
举个例子🌰:
mov ax,1000H
mov dx,ax //dx = 1000H
push [0] //将1000:0处的字压入栈中
pop [2] //出栈的数据送入1000:2处
段的总结
我们可以用一个段存放数据,将它定义为“数据段”;
我们可以用一个段存放代码,将它定义为“代码段”;
我们可以用一个段当作栈,将它定义为“栈段”。
对于数据段,将它的段地址放在DS中,用mov、add、sub等访问内存单元的指令时,CPU就将我们定义的数据段中的内容当做数据来访问;
对于代码端,将它的段地址放在CS中,将段中第一条指令的偏移地址放在IP中,这样CPU就将执行我们定义的代码段中的指令;
对于栈段,将它的段地址放在SS中,将栈顶单元的偏移地址放在SP中,这样CPU在需要进行栈操作的时候,比如执行push、pop指令等,就将我们定义的栈段当成栈空间来用。
三.完整的汇编程序
完整的汇编程序包含汇编指令和伪指令。
汇编指令如mov、add、sub等,有对应的机器指令,可以被编译为机器指令,最终被CPU执行。伪指令如assume、 segment、ends、end等,没有对应的机器指令,由编译器解析,最终不被CPU执行。
// 声明一下code段是cs段、代码段
assume cs:code
// segment和ends的作用是定义一个段,code是我们取的段名
code segment
mov ax, 1122h
mov bx, 3344h
add ax, bx
// 正常退出程序
mov ax, 4c00h
int 21h
code ends
// 编译器遇到end时,就结束对源程序的编译
end
使用汇编语言编写一个完整的程序,步骤大致如下:
编写源代码 → 编译、链接 → 调试、运行
在完整的汇编程序中,我们会看到常见的几种语法。
- 中断int
- 循环loop
- call和ret指令
中断int
可以通过指令int n产生中断,n是中断码,内存中有一张中断向量表,用来存放中断码对应中断处理程序的入口地址。
CPU在接收到中断信号后,暂停当前正在执行的程序,跳转到中断码对应的中断向量表地址处,去执行中断处理程序。
常见中断:
- int 10h用于执行BIOS中断
- int 3是“断点中断”,用于调试程序
- int 21h用于执行DOS系统功能调用,AH寄存器存储功能号
循环loop
loop指令和cx配合使用,用于循环执行重复的操作,类似于高级语言中的for、while循环。
loop指令的执行流程,让cx的值减一,判断cx的值。
mov ax, 2h
mov cx, 5 // 5为循环次数
s: add ax, ax // s为标号
loop s // 循环执行标号的程序,即 add ax, ax
call和ret指令
call是将下一条指令的偏移地址入栈后,转到标号处执行指令。
call 标号
ret是将栈顶的值出栈,赋值给ip。
四.栈帧
栈帧就是一个函数执行的环境,包括:参数、局部变量、返回地址等。
每个函数都有自己对应的栈帧,用来保存(或者说保护)自己的数据。
以下是关于函数执行过程,栈帧的变化(对应的图片如下):
- push 参数
- push 函数的返回地址
- push bp (保留bp之前的值,方便以后恢复)
- mov bp, sp (保留sp之前的值,方便以后恢复)
- sub sp,空间大小 (分配空间给局部变量)
- 保护可能要用到的寄存器
- 使用CC(int 3)填充局部变量的空间
- --------执行业务逻辑--------
- 恢复寄存器之前的值
- mov sp, bp (恢复sp之前的值)
- pop bp (恢复bp之前的值)
- ret (将函数的返回地址出栈,执行下一条指令)
- 恢复栈平衡 (add sp,参数所占的空间)