ARM 汇编基础

汇编语言源程序格式区别:

  • .s,操作: 汇编,不可以在这里面加入预处理的命令。
  • .S,操作: 预处理 + 汇编,可以在这里面加入预处理的命令。
  • .o,只编译不链接。
$ as program.s -o program.o
$ ld program.o -o program

汇编语言本质

使用助记符和缩写来编写能控制底层高低电平电路信号的二进制机器码(指令集)组合,就是汇编程序。这些助记符集合就叫汇编语言。使用汇编工具去将汇编语言转换成机器码的过程叫做汇编(assembling)。

指令集体系结构(Instruction Set Architecture, ISA),简称体系结构或系统结构(architecture),它是软件和硬件之间接口的一个完整定义。 ISA定义了一台计算机可以执行的所有指令的集合,每条指令规定了计算机执行什么操作,所处理的操作数存放的地址空间以及操作数类型。指令集架构是一个能为电路硬件翻译应用程序的一层抽象层。

数据类型

操作:载入(load)存储(store)

数据类型:有符号或无符号的 字/ 半字/ 字节。

ldr = Load Word
ldrh = Load unsigned Half Word
ldrsh = Load signed Half Word
ldrb = Load unsigned Byte
ldrsb = Load signed Bytes
​
str = Store Word
strh = Store unsigned Half Word
strsh = Store signed Half Word
strb = Store unsigned Byte
strsb = Store signed Byte

字节序列

查看内存的字节两种方式:小端模式(Little-Endian)大端模式(Big-Endian)

ARM 寄存器

共有 30 个 32 位通用寄存器。前 16 个寄存器(r0-15)可在用户级模式下访问,其他寄存器在特权软件执行中可用。

前 16 个寄存器可以分为两组:通用寄存器和特殊用途寄存器。

  • R0-R12:可用于常见操作期间存储临时值、指针(内存位置)等等。例如R0,在算术运算期间可以称为累加器,或用于存储调用的函数时返回的结果。R7在进行系统调用时非常有用,因为它存储了系统号,R11可帮助我们跟踪作为帧指针的堆栈上的边界(稍后将介绍)。此外,ARM上的函数调用约定函数的前四个参数存储在寄存器r0-r3中。

  • R13SP(栈指针)。始终指向当前栈顶。

  • R14LR(链接寄存器)。进行函数调用时,链接寄存器将更新为当前函数调用指令的下一个指令的地址,也就是函数调用返回后需要继续执行的指令。这么做是允许子函数调用完成后,在子函数中利用该寄存器保存的指令地址再返回到父函数中。

  • R15PC(程序计数器)。程序计数器自动按执行的指令大小递增。此指令大小在 ARM 模式下始终为4个字节,在 THUMB 模式下为 2 个字节。执行分支指令时,PC 保存目标地址。在执行过程中,在 ARM 模式下 PC 将当前指令的地址加上 8(两个ARM指令),在 Thumb(v1)状态下则指令加上4(两个Thumb指令)。这与x86 中 PC 始终指向要执行的下一个指令不同。

ARM 模式 和 Thumb 模式

ARM 处理器主要有两种工作模式: ARM 状态 和 Thumb 状态。它们主要区别是指令集,在 ARM 模式下指令集始终是32-bit,但是在 Thumb 模式下可以是16-bit或者32-bit。学会怎么使用Thumb模式对于ARM开发很重要。

ARM模式和Thumb模式的态区别:

  • 条件执行:在 ARM 模式下所有的指令都支持条件执行。一些版本的ARM处理器可以通过it指令在Thumb工作模式下支持条件执行。
  • ARM 和 Thumb 模式下的32-bit指令:在 Thumb 模式下的32-bit指令有.w后缀。
  • 桶型位移器(barrel shifter)是ARM模式下的另一个特点。它可以将多条指令缩减为一条。例如,你可以通过向左位移1位的指令后缀将乘法运算直接包含在一条MOV指令中(将一个寄存器的值乘以2,再将结果MOV到另一个寄存器): MOV R1, R0, LSL#1 ;R1 = R0 * 2,而不需要使用专门的乘法指令来运算。

ARMv8 寄存器

  • 参数寄存器(X0-X7): 用作临时寄存器或可以保存的调用者保存的寄存器变量函数内的中间值,调用其他函数之间的值(8 个寄存器可用于传递参数)

  • 来电保存的临时寄存器(X9-X15): 如果调用者要求在任何这些寄存器中保留值调用另一个函数,调用者必须将受影响的寄存器保存在自己的堆栈中帧。 它们可以通过被调用的子程序进行修改,而无需保存并在返回调用者之前恢复它们。

  • 被调用者保存的寄存器(X19-X29): 这些寄存器保存在被调用者帧中。 它们可以被被调用者修改子程序,只要它们在返回之前保存并恢复。

  • 特殊用途寄存器(X8,X16-X18,X29,X30):

    • X8: 是间接结果寄存器,用于保存子程序返回地址,尽量不使用
    • X16 和 X17: 程序内调用临时寄存器
    • X18: 平台寄存器,保留用于平台 ABI,尽量不使用
    • X29: 帧指针寄存器(FP)
    • X30: 链接寄存器(LR)
    • X31: 堆栈指针寄存器 SP 或零寄存器 ZXR

异常向量表

在ARM体系结构中,存在7种异常处理。当异常发生时,处理器会把PC设置为一个特定的存储器地址。这一地址放在被称为向量表(vector table)的特定地址范围内,向量表的入口是一些跳转指令,跳转到专门处理某个异常或中断的子程序。

异常向量表地址说明:

  • 0x00: 复位,进入 管理模式
  • 0x04: 未定义指令, 进入 未定义模式
  • 0x08: 软件中断,进入 管理模式
  • 0x0c: 中止(预取),进入 中止模式
  • 0x10: 中止(数据),进入 中止模式
  • 0x14: 保留
  • 0x18: 中断 IRQ ,进入 中断模式
  • 0x1c: 快中断 FIQ,进入 快中断模式

ARM指令简介

ARM指令后面通常跟着两个操作数,像下面这样的形式:

MNEMONIC{S}{condition} {Rd}, Operand1, Operand2

解释:

MNEMONIC     - 操作指令(机器码对应的助记符)。
{S}          - 可选后缀. 如果指定了该后缀,那么条件标志将根据操作结果进行更新。
{condition}  - 执行指令所需满足的条件。
{Rd}         - 目标寄存器,存储操作结果。
Operand1     - 第一操作数(寄存器或者立即数)
Operand2     - 第二操作数. 立即数或者带有位移操作后缀(可选)的寄存器。

condition字段与CPSR寄存器的值有关,准确的说是和CPSR某些位有关。Operand2也叫可变操作数,因为它可以有多种形式--立即数、寄存器、带有位移操作的寄存器。

例如Operand2可以有以下多种形式:

#123                    - 立即数。
Rx                      - 寄存器x (如 R1, R2, R3 ...)。
Rx, ASR n               - 寄存器x,算术右移n位 (1 = n = 32)。
Rx, LSL n               - 寄存器x,逻辑左移n位 (0 = n = 31)。
Rx, LSR n               - 寄存器x,逻辑右移n位 (1 = n = 32)。
Rx, ROR n               - 寄存器x,循环右移n位 (1 = n = 31)。
Rx, RRX                 - 寄存器x,扩展的循环位移,右移1位。

示例:

ADD   R0, R1, R2         - 将寄存器R1内的值与寄存器R2内的值相加,结果存储到R0。
ADD   R0, R1, #2         - 将寄存器R1内的值加上立即数2,结果存储到R0。
MOVLE R0, #5             - 仅当满足条件LE(小于或等于)时,才将立即数5移动到R0(编译器会把它看作MOVLE R0, R0, #5)。
MOV   R0, R1, LSL #1     - 将寄存器R1的内容向左移动一位然后移动到R0(Rd)。因此,如果R1值是2,它将向左移动一位,并变为4。然后将4移动到R0。

其他指令:

sets

内存指令:加载(Load)和 存储(Stroe)

在ARM上数据必须从内存中加载到寄存器之后才能进行其他操作,而在x86上大部分指令都可以直接访问内存中的数据。

你只能使用LDR和STR指令访问内存。

三个基本偏移形式:

  1. 偏移形式:立即数作为偏移量

    • 寻址模式:立即寻址
    • 寻址模式:前变址寻址
    • 寻址模式:后变址寻址
  2. 偏移形式:寄存器作为偏移量

    • 寻址模式:立即寻址
    • 寻址模式:前变址寻址
    • 寻址模式:后变址寻址
  3. 偏移形式:缩放寄存器作为偏移量

    • 寻址模式:立即寻址
    • 寻址模式:前变址寻址
    • 寻址模式:后变址寻址

示例 1:

LDR 用于将内存中的值加载到寄存器中,STR 用于将寄存器内的值存储到内存地址。

LDR R2, [R0]   @ [R0] - R0中保存的值是源地址。
STR R2, [R1]   @ [R1] - R1中保存的值是目标地址。
  • LDR : 把R0内保存的值作为地址值,将该地址处的值加载到寄存器R2中。
  • STR : 把R1内保存的值作为地址值,将寄存器R2中的值存储到该地址处。

有时你想要更有效率,一次加载(或存储)多个值。为此我们可以使用LDM(load multiple)STM(stroe multiple)指令。

入栈和出栈

进程中有一个叫做栈的内存位置。栈指针(SP)寄存器总是指向栈内存中的地址。程序应用中通常使用栈来存储临时数据。前面讲的ARM中只能使用加载和存储来访问内存,就是只能使用LDR/STR指令或者他们的衍生指令(LDMSTMLDMIALDMDASTMDA等等)进行内存操作。在x86中使用PUSHPOP从栈内取或存,ARM中我们也可以使用这条指令。

条件状态和分支

下面的表格列出了可用的条件状态码,描述和标志位:

if

下面是条件代码和相反代码:

cmp

分支:

有三种类型的分支指令:

  • 普通分支(B),简单的跳转到一个函数。
  • 带链接的跳转(BL),将PC+4的值保存到LR寄存器,然后跳转。
  • 带状态切换的跳转(BX)和带状态切换及链接的跳转(BLX),与B和BL一致,只是添加了工作状态的切换(ARM模式-Thumb模式)。需要寄存器作为第一个操作数。

栈和函数

一般而言,栈就是进程中的一段内存。这段内存是在进程创建时分配的。我们使用栈来保存一些临时数据,如函数中的局部变量,函数之间转换的环境变量等。使用PUSH和POP指令与栈进行交互。

为了让一切变得井然有序,函数使用栈帧(专门用于函数中使用的局部内存区域)。栈帧是在函数开始调用时创建的。栈帧指针(FP)被置为栈帧的底部,然后分配栈帧的缓冲区。栈帧中通常(从底部)保存了返回地址(前面的LR寄存器值)、栈帧指针、其他一些需要保存的寄存器、函数参数(如果超过4个参数)、局部变量等等。虽然栈帧的实际内容可能有所不同,但基本就这些。最后栈帧在函数结束时被销毁。

函数

函数体的结构:开始、执行体和收尾。

开始时需要保存程序前面的状态(LR和R11分别入栈)然后为函数的局部变量设置堆栈。虽然开始部分的实现可能因编译器而异,但通常是用PUSH/ADD/SUB指令来完成的。大体看起来是下面这样:

push   {r11, lr}    /* 将lr和r11入栈 */
add    r11, sp, #0  /* 设置栈帧的底部位置 */
sub    sp, sp, #16  /* 栈指针减去16为局部变量分配缓存区 */

函数体部分就是你程序的实际逻辑区,包含了你代码逻辑的各种指令:

mov    r0, #1       /* 设置局部变量(a=1). 同时也为函数max的第一个参数 */
mov    r1, #2       /* 设置局部变量(b=2). 同时也为函数max的第二个参数 */
bl     max          /* 调用函数max */

函数的最后部分用于将程序的状态还原到它初始的状态(函数调用前),这样就可以从函数被调用的地方继续执行。所以我们需要重新调整栈指针(SP)。

重新调整栈指针后,将之前(函数开始处)保存的寄存器值从堆栈弹出到相应的寄存器来还原这些寄存器值。根据函数类型,一般POP指令是函数最后结束的指令。但是,在还原寄存器值后,我们需要使用 BX 指令来离开函数。示例如下:

sub    sp, r11, #0  /* 重新调整栈指针 */
pop    {r11, pc}    /* 恢复栈帧指针, 通过加载之前保存的LR到PC,程序跳转到之前LR保存位置。函数的栈帧被销毁 */

所以我们现在知道:

  1. 函数在开始时设置相应的环境。
  2. 函数体中执行相关逻辑,然后通过R0保存返回值。
  3. 函数收尾时恢复所有的状态,以便程序可以在函数调用前的位置继续执行。

叶子函数和非叶子函数

  • 叶子函数,在函数内不会调用/跳转到另一个函数。
  • 非叶子函数,则会在自己的函数逻辑中调用另一个函数。

指令:.MACRO/.ENDM

    语法:

        宏名称 .MACRO [形式参数]

        ........

        宏定义语句

        ........

        .ENDM

 

    描述:

        用.MACRO指令你可以定义一个宏,可以把需要重复执行的一段代码,或者是一组指令缩写成一个宏,在

        程序调用的时候就可以直接去调用这个宏而使代码更加简洁清晰,此宏由以下3部分构成:

        1. 头: 在这里可以指定这个宏的名称,别且定义形式参数

        2. 体: 这里包含的是当这个宏被调用时所需要执行的指令或者语句。

        3. 尾:  这里用.ENDM标识着这个宏的结束。

指令:.equ ,类似于 C 中的#define 宏。

代码示例


#![allow(unused)]
fn main() {
.section  .data         ; 这里.section常常省略
    <初始化数据>
.section  .bss          ; 这里.section常常省略
    <未初始化数据>
.section  .text         ; 这里.section常常省略
.global     __start     ; 如果其他文件调用__start,则需要.global声明
__start:                ; 标签,相当于函数入口
    <汇编代码>
}

寻址方式:

  • 立即数寻址。ADD R0, R0, #0x3F
  • 寄存器寻址。ADD R0, R1, R2
  • 寄存器间接寻址。LDR R0, [R2] ;相当于指针
  • 基址变址寻址。LDR R0, [R1, #4]
  • 相对寻址。BL NECT ; NEXT为标签

定义类伪指令:

  • .global 表明一个标号为全局
  • .ascii 定义字符串数据
  • .byte 定义字节数据
  • .word 定义字数据
  • .data 表明数据段
  • .size 设定指定符号的大小
  • .type 指定符号的类型
.data
varA:
.ascii  "helloworld"
varB:
.word   0xff
varC:
.byte   0x1
.text
    ...

.equ 定义一个宏:

.equ    DA, 0x89
mov r0, #DA

nop 空操作

mov r0, r0

wfiwfe:

wfi (Wait for interrupt)wfe (Wait for event)是两个让ARM核进入low-power standby模式的指令,由ARM architecture定义,由ARM core实现。spinlock实现一般和 wfe指令有关。

standby 一般为待机模式。

对WFI来说,执行WFI指令后,ARM core会立即进入low-power standby state,直到有WFI Wakeup events发生。

而WFE则稍微不同,执行WFE指令后,根据Event Register(一个单bit的寄存器,每个PE一个)的状态,有两种情况:如果Event Register为1,该指令会把它清零,然后执行完成(不会standby);如果Event Register为0,和WFI类似,进入low-power standby state,直到有WFE Wakeup events发生。

跳转:

  • b 1b 中的b是backward的意思,跳到程序的前面(往上)
  • b 1f 中的f是forward的意思,跳到程序的后面(往下)
  • 1表示标号(局部标号)。

参考

  1. https://www.zhihu.com/column/c_1215698269139152896
  2. https://azeria-labs.com/writing-arm-assembly-part-1/
  3. http://blog.leanote.com/post/tarena/ARM%E6%B1%87%E7%BC%96%E5%85%A5%E9%97%A8
  4. https://www.huaweicloud.com/articles/ca0de32c6bb0903b23de5dbcdfab3c5b.html