本篇主要记录ARM汇编基础知识。
ARM汇编以及汇编语言基础介绍
ARM vs INTEL
Intel属于复杂指令集处理器,有很多特性丰富的访问内存的复杂指令集。因此拥有更多指令代码。但是寄存器比ARM要少。复杂指令集处理器主要被应用在PC机、工作站以及服务器上。 ARM属于简单指令集处理器。只有简单的差不多100条指令集,但是会有更多的寄存器。与Intel不同,ARM的指令集仅仅操作寄存器或者是用于从内存的加载/储存过程。
汇编语言本质
使用汇编工具去将汇编语言转换成机器码的过程叫做汇编。
ARM汇编中的数据类型
ARM汇编数据类型基础
被加载或者存储的数据类型可以是无符号(有符号)的字(words,四字节),半字(halfwords,两字节),或者字节。
ldr = 加载字,宽度四字节 ldrh = 加载无符号的半字,宽度两字节 ldrsh = 加载有符号的半字,宽度两字节 ldrb = 加载无符号的字节 ldrsb = 加载有符号的字节 str = 存储字,宽度四字节 strh = 存储无符号的半字,宽度两字节 strsh = 存储有符号的半字,宽度两字节 strb = 存储无符号的字节 strsb = 存储有符号的字节
字节序
在内存中有两种字节排布顺序,大端序(BE)或者小端序(LE)。两者的主要不同是对象中的每个字节在内存中的存储顺序存在差异。一般X86中是小端序,最低的字节存储在最低的地址上。在大端机中最高的字节存储在最低的地址上。
ARM寄存器
# | 别名 | 用途 |
---|---|---|
R0 | - | 通用寄存器 |
R1 | - | 通用寄存器 |
R2 | - | 通用寄存器 |
R3 | - | 通用寄存器 |
R4 | - | 通用寄存器 |
R5 | - | 通用寄存器 |
R6 | - | 通用寄存器 |
R7 | - | 一般放系统调用号 |
R8 | - | 通用寄存器 |
R9 | - | 通用寄存器 |
R10 | - | 通用寄存器 |
R11 | FP | 栈帧指针 |
R12 | IP | 内部程序调用 |
R13 | SP | 栈指针 |
R14 | LR | 链接寄存器(一般存放函数返回地址) |
R15 | PC | 程序计数寄存器 |
CPSR | - | 当前程序状态寄存器 |
R0-R12,用来在通用操作中存储临时的值,指针等。R0被用来存储函数调用的返回值。R7经常被用作存储系统调用号。R11存放着帮助我们找到栈帧边界的指针。以及在ARM的函数调用约定中,前四个参数按顺序存放在R0-R3中。
R13:SP(栈指针)。栈指针寄存器用来指向当前的栈顶。栈是一片来存储函数调用中相关数据的内存,在函数返回时会被修改为对应的栈指针,栈指针用来帮助在栈上申请数据空间。比如说你要申请一个字的大小,就会将栈指针减4,再将数据放入之前所指向的位置。
R14,LR(链接寄存器)。当一个函数调用发生,链接寄存器就被用来记录函数调用发生所在位置的下一条指令的地址。这么做,允许我们快速地从子函数返回父函数。
R15,PC(程序计数器)。程序计数器是一个在程序指令执行时自增的计数器。它的大小在ARM模式下总是4字节对齐,在Thumb模式下总是两字节对齐。当执行一个分支指令时,PC存储目的地址。在程序执行中,ARM模式下的PC存储着当前指令加8(两条ARM指令后)的位置,Thumb(v1)模式下PC存储着当前指令加4(两条Thumb指令)后的地址。
当前状态寄存器
ARM模式与THUMB模式
只需要知道 你设备上的关键ARM版本所支持的Thumb指令集就可以了。以及ARM信息中心可以帮你弄清楚你的ARM版本到底是多少。
Thumb也有很多不同的版本。不过不同的名字仅仅是为了区分不同版本的Thumb指令集而已(也就是对于处理器来说,这些指令永远都是Thumb指令)。
Thumb-1(16位宽指令集):在ARMv6以及更早期的版本上使用。
Thumb-2(16/32位宽指令集):在Thumb-1基础上扩展的更多的指令集(在ARMv6T2以及ARMv7即很多32位Android手机所支持的架构上使用)
Thumb-EE:包括一些改变以及对于动态生成代码的补充(即那些在设备上执行前或者运行时编译的代码)
ARM与Thumb的不同之处
ARM指令集规律含义
模板:
1 | MNEMONIC{S}{condition} {Rd}, Operand1, Operand2 |
含义:
1 | MNEMONIC -指令的助记符如ADD |
通用指令集:
指令 | 含义 | 指令 | 含义 |
---|---|---|---|
MOV | 移动数据 | EOR | 比特位异或 |
MVN | 取反码移动数据 | LDR | 加载数据 |
ADD | 数据相加 | STR | 存储数据 |
SUB | 数据相减 | LDM | 多次加载 |
MUL | 数据相乘 | STM | 多次存储 |
LSL | 逻辑左移 | PUSH | 压栈 |
LSR | 逻辑右移 | POP | 出栈 |
ASR | 算术右移 | B | 分支跳转 |
ROR | 循环右移 | BL | 链接分支跳转 |
CMP | 比较操作 | BX | 分支跳转切换 |
AND | 比特位与 | BLX | 链接分支跳转切换 |
ORR | 比特位或 | SWI/SVC | 系统调用 |
ARM汇编内存访问相关指令
当我们加载数据到寄存器时 方括号[]
意味着:将其中的值当做内存地址,并取这个内存地址中的值加载到对应寄存器。举例
1 | ldr r2, [r0] @ 将r0所指向地址中存放的值加载到寄存器日r2中 |
当我们存储数据到内存时,方括号[]
意味着:将其中的值当做内存地址,并向这个内存地址所指向的位置存入对应的值。举例
1 | str r2, [r1] @ 将r2中的值存放到r1所指向的地址 |
第一种偏移形式:立即数作为偏移。地址模式:用作偏移、前向索引和后向索引。
1 | ldr r0, adr_var1 @ 将存放var1的值的地址adr_var1加载到寄存器R0中. R0放的是地址 |
第二种偏移形式:寄存器作为偏移。地址模式:用作偏移、前向索引和后向索引。
1 | ldr r0, adr_var1 @ 将存放var1的值的地址adr_var1加载到寄存器R0中. R0放的是地址 |
第三种偏移形式:寄存器缩放值作为偏移。地址模式:用作偏移、前向索引和后向索引。
1 | ldr r0, adr_var1 @ 将存放var1的值的地址adr_var1加载到寄存器R0中. R0放的是地址 |
总结:
LDR/STR
的三种偏移模式,立即数作为偏移、寄存器作为偏移以及寄存器缩放值作为偏移。
如何区分取址模式:
- 如果有一个叹号
!
,那就是索引前置取址模式,即使用计算后的地址,之后更新基址寄存器 - 如果在
[]
外有一个寄存器,那就是索引后置取址模式,即使用原有基址寄存器重的地址,之后再更新基址寄存器 - 除此之外,就都是偏移取址模式了。
关于PC相对取址的LDR指令
1 | .section .text |
这些指令学术上被称为伪指令。但我们在编写ARM汇编时可以用这种格式的指令去引用我们文字标识池中的数据。
在ARM中使用立即数的规律
每条指令留给我们存放立即数的空间只有12位宽。也就是4096个不同的值。
这就意味着ARM在使用MOV指令时所能操作的立即数值范围是有限的。那如果很大的话,只能拆分成多个部分外加移位操作拼接了。
有效的立即数都可以通过循环移位来得到。
备注:循环移位例子这块没看太懂。
连续存取
连续加载/存储
LDM(load multiple) vs STM(store multiple)
.word
标识是对内存中长度为32位的数据块做引用。程序中由.data
段组成的数据,内存中会申请一个长度为5的4字节数组array_buff
。我们所有内存存储操作,都是针对这段内存中的数据段做读写的。不太懂...
1 | adr r0, words+12 @ words[3]的地址 -> r0 adr是小范围的地址读取伪指令 |
之前说过LDM
和STM
有很多形式。不同形式的扩展字符和含义都不同。
IA(increase after)
IB(increase before)
DA(decrease after)
DB(decrease before)
这些扩展划分的主要依据是,作为源地址或者目的地址的指针是在访问内存前增减,还是访问内存后增减。以及,LDM与LDMIDA功能相同,都是在加载操作完成后访问对地址增加的。通过这种方式,我们可以序列化的向前或者向后从一个指针指向的内存加载数据到寄存器,或者存放数据到内存。
1 | ldmia r0, {r4-r6} @ words[3] -> r4=0x3; words[4] -> r5=0x4; words[5] -> r6=0x5; |
LDAIB指令首先对指向的地址先加4,然后在加载数据到寄存器中。所以第一次加载的时候也会对指针加4,所以存入寄存器的是0x4(words[4])而不是0x3(words[3])
1 | ldmib r0, {r4-r6} @ words[4] -> r4=0x4; words[5] -> r5=0x5; words[6] -> r6=0x6; |
当用LDMDA指令时,执行的就是反向操作了。
1 | ldmda r0, {r4-r6} @ words[3] -> r6=0x3; words[2] -> r5=0x2; words[1] -> r4=0x1; |
同理STMDA和STMDB
1 | stmda r2, {r4-r6} r4 |
PUSH和POP
在内存中存在一块进程相关的区域叫做栈。栈指针寄存器SP在正常情形下指向这片区域。应用经常通过栈做临时的数据存储。
当PUSH压栈时,会发生以下事情。SP值减4,存放信息到SP指向的位置。PUSH == STMDB sp!
当POP出栈时,会发生以下事情。数据从SP指向位置被加载,SP值加4。POP == LDMIA sp!
条件执行与分支
条件执行
条件执行用来控制程序执行跳转,或者满足条件下的特定指令的执行。相关条件在CPSR寄存器中描述。寄存器中的比特位的变化决定着不同的条件。
Thumb模式中的条件执行
对于Thumb中,其实也有条件执行的(Thumb-2中有)。有些ARM处理器版本支持IT指令,允许在Thumb模式下条件执行最多四条指令。
格式:
1 | Syntax:IT{x{y{z}}} cond |
conda
代表在IT指令后第一条条件执行执行指令的需要满足的条件。
x
代表着第二条条件执行指令要满足的条件逻辑相同还是相反。
y
代表着第三条条件执行指令要满足的条件逻辑相同还是相反。
z
代表着第四条条件执行指令要满足的条件逻辑相同还是相反。
IT
指令的含义是 IF-Then-Else
,跟这个形式类似的还有:
IT
,if-Then
, 接下来的一条指令条件执行。
ITT
,if-Then-Then
, 接下来的两条指令条件执行。
ITE
,if-Then-Else
, 接下来的两条指令条件执行。
ITTE
, if-Then-Then-Else
, 接下来的三条指令条件执行。
ITTEE
, if-Then-Then-Else-Else
, 接下来的四条指令条件执行。
在IT块中的每一条条件执行指令必须是相同逻辑条件或者相反逻辑条件。比如说ITE指令,第一条和第二条指令必须使用相同的条件,而第三条必须是与前两条逻辑相反的条件。
分支指令
分支指令(也叫分支跳转)允许我们在代码中跳转到别的段。当我们需要跳到一些函数上执行或者跳过一些代码块时很有用。这部分的最佳例子就是条件跳转IF以及循环。
备注:比较简单
B/BX/BLX
B(Branch),简单的跳转到一个函数
BL(Branch Link),将下一条指令的入口(PC+4)保存到LR,跳转到函数。备注(LR链接寄存器,一般存放函数返回地址)
BX(Branch exchange)以及BLX(Branch Link exchange),与B/BL相同,外加执行模式切换(ARM与Thumb)。需要寄存器类型作为第一操作数。
BX/BLX指令被用来从ARM模式切换到Thumb模式。
条件分支指令
条件分支指令是指在满足某种特定条件下的跳转指令。指令模式是跳转指令后加上条件后缀。
通过BEQ来举例。
栈与函数
研究一片独特的内存区域叫做栈,讲解栈的目的以及相关操作。除此之外,还会研究ARM架构中函数的调用约定。
栈
一般来说,栈是一片在程序/进程中的内存区域。这部分内存是在进程创建的时候被创建的。我们利用栈来存储一些临时数据,比如函数的局部变量,环境变量等。
了解栈的相关知识以及其实现方式。首先谈谈栈的增长,即当我们把32位的数据放到栈上时候它的变化。栈可以向上增长(当栈的实现是负向增长时),或者向下增长(当栈的实现是正向增长时)。具体的关于下一个32位数据被放到哪里是由栈指针来决定的,更精确的说是由SP寄存器决定。不过这里面所指向的位置,可能是当前(也就是上一次)存储的数据,也可能是下一次存储时的位置。如果SP当前指向上一次存放的数据在栈中的位置(满栈实现),SP将会递减(降序栈)或者递增(升序栈),然后再对指向的内容进行操作。而如果SP指向的是下一次要操作的数据的空闲位置(空栈实现),数据会先被存放,然后SP会被递增(升序栈)或递减(降序栈)。
不同的栈实现,可以用不同情形下的多次存取指令来表示。很绕。
栈类型 | 压栈 | 出栈 |
---|---|---|
满栈降序(FD Full descending) | STMFD(等价于STMDB,操作之前递减) | LDMFD(等价于LDM,操作之后递加) |
满栈升序(FA Full ascending) | STMFA(等价于STMIB,操作之前递增) | LDMFA(等价于LDMDA,操作之后递减) |
空栈降序(ED Empty descending) | STMED(等价于STMDA,操作之后递减) | LDMED(等价于LDMIB,操作之前递加) |
空栈升序(EA Empty ascending) | STMEA(等价于STM,操作之后递增) | LDMEA(等价于LDMDB,操作之前递减) |
栈被用来存储局部变量,之前的寄存器状态。为了统一管理,函数使用了栈帧这个概念,栈帧是在栈内用于存储函数相关数据的特定区域。栈帧在函数开始时被创建。栈帧指针FP指向栈帧的底部元素,栈帧指针确定后,会在栈上申请栈帧所属的缓冲区。栈帧(从它的底部算起)一般包含这返回地址(之前说的LR),上一层函数的栈帧指针,以及任何需要被保存的寄存器,函数参数(当函数需要4个以上参数时),局部变量等。虽然栈帧包含着很多数据,但是不少类型已经了解过了。最后,栈帧在函数结束时被销毁。
函数
一个函数的结构:序言准备、函数体以及结束收尾。
序言的目的是为了保存之前程序的执行状态(通过存储LR以及R11到栈上)以及设定栈以及局部函数变量。这些步骤的实现可能根据编译器的不同有差异。通常来说使用PUSH/ADD/SUB这些指令。举个例子
1 | push {r11, lr} @ 保存R11和LR |
函数体部分就是函数本身要完成的任务了。这部分包括了函数自身的指令,或者跳转其它函数等。下面是函数体的例子
1 | mov r0, #1 @ 设置局部变量a=1,同时也是为函数max准备参数a |
上面的代码也展示了调用函数前需要如何准备局部变量,以及函数调用设定参数。一般情况下,前四个参数通过R0-R3来传递,而多出来的参数则需要通过栈来传递。函数调用结束后,返回值存放在R0寄存器中。所以不管max函数如何运作,我们都可以通过R0来得知返回值。而且当返回值位是64位值时,使用的是R0与R1寄存器一同存储64位的值。
函数的最后一部分即结束收尾,这一部分主要是用来恢复程序寄存器以及回到函数调用发生之前的状态。我们先恢复SP栈指针,这个可以通过之前保存的栈帧指针寄存器外加一些加减操作做到(保证回到FP,LR的出栈位置)。而当我们重新调整了栈指针后,我们就可以通过出栈操作恢复之前保存的寄存器的值。基于函数类型的不同,POP指令有可能是结束收尾的最后一条指令。然而,在恢复后我们可能还需要通过BX指令离开函数。一个收尾的样例代码是这样的。
1 | sub sp, r11, #4 @ 收尾操作开始。调整栈指针,有两个寄存器要POP,所以从栈帧底部元素再减4 |
总结:
- 序言设定函数环境
- 函数体实现函数逻辑功能,将结果存到R0
- 收尾恢复程序状态,回到调用发生的地方
关于函数,有一个关键点我们要知道,函数的类型分为叶函数和非叶函数。
- 叶函数是指函数中没有分支跳转到其它函数指令的函数。
- 非叶函数指包含跳转到其它函数的分支跳转指令的函数。
序言期主要的差别,非叶函数需要在栈上保存更多的寄存器,这是由于非叶函数的本质决定的,因为在执行时LR寄存器会被修改,所以需要保存LR寄存器以便之后恢复。当然如果有必要也可以在序言期保存更多的寄存器。叶函数与非叶函数在收尾时的差异主要是在于,叶函数的结尾直接通过LR中的值跳转回去就好,而非叶函数需要先通过POP恢复LR寄存器,再进行分支跳转。
再次强调一下在函数BL和BX指令的使用。在我们的示例中,通过使用BL指令跳转到叶函数中。在汇编代码中我们使用了标签。在编译过程中,标签被转换成对应的内存地址。在跳转到对应位置之前,BL会将下一条指令的地址存储到LR寄存器中,这样我们就能在函数max完成的时候返回了。
BX指令在被用在我们离开一个叶函数时,使用LR作为寄存器参数。刚刚说了LR存放着函数调用返回后下一条指令的地址。由于叶函数不会在执行时修改LR寄存器,所以就可以通过LR寄存器跳转返回main函数了。通过BX指令还会帮助我们切换ARM/Thumb模式。同样这也通过LR寄存器的最低比特位来完成。0代表ARM模式,1代表Thumb模式。
参考文章: