iOS需要了解的ARM64汇编

2020-05-26  本文已影响0人  Tenloy
# 概述
# iOS相关的指令集及对应的ARM汇编语言
# ARM64汇编 
  ## 寄存器
    ### R0-R30(包括FP、LR)
    ### 一些特殊寄存器:SP、PC、V0-V31、SPRS
  ## 内存模型
    ### 堆
    ### 栈
    ### 栈回溯
  ## 指令格式及常见指令
  ## ARM指令的二进制编码
    ### 汇编指令对应的二进制编码格式
    ### 条件执行
# 汇编层次看高级语言
# GCC内联汇编
# 参考链接
# 关于intel、AT&T汇编的简单了解

# 概述


早期的程序员发现机器语言在阅读、书写方面的问题,是如此的难以辨别和记忆,需要记住所有抽象的二进制码,为了解决这个问题,汇编语言就产生了。汇编语言是各种CPU提供的机器指令的助记符的集合,人们可以用汇编语言直接控制硬件系统进行工作。

汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上。汇编指令是机器指令便于记忆的书写格式。

汇编语言与硬件关联很深,所以涉及到的知识点有很多,如:寄存器、端口、寻址方式、内外中断、以及指令的实现原理等,额,如果想了解这些知识点,可以阅读《汇编语言(第3版)》 王爽著。本篇博客类似阅读手册,主要记录一些常见的寄存器、以及不同汇编语言规范中指令的编写风格(intel及AT&T的篇幅很少,毕竟我是一个iOSer,以移动端主流ARM64汇编为例)。

# iOS相关的指令集及对应的ARM汇编语言


作为iOS开发工程师,主要需要了解的汇编语言是:

iPhone指令集

# ARM64 汇编


## 先放代码 — Hello world

#include <stdio.h>
int main(){
    printf("hello, world\n");
    return 0;
}

生成汇编文件:xcrun --sdk iphoneos clang -S -arch arm64 helloworld.c。也可以在XCode中,Product -> Perform Action -> Assemble 来生成汇编文件。

    .section    __TEXT,__text,regular,pure_instructions
    .build_version ios, 13, 2   sdk_version 13, 2
    .globl  _main                   ; -- Begin function main
    .p2align    2
_main:                                  ; @main
    .cfi_startproc
; %bb.0:
    sub sp, sp, #32             ;sub 减法; sp = sp - 32Byte
    stp x29, x30, [sp, #16]     ;stp 寄存器存储到内存上,依次存两个;保存x29(FP),和x30(LR) 到sp+16Byte上的16个Byte
    add x29, sp, #16            ;add 加法;把sp+16Byte的结果写入x29(FP);
    .cfi_def_cfa w29, 16
    .cfi_offset w30, -8
    .cfi_offset w29, -16
    stur    wzr, [x29, #-4]     ;stur 寄存器内容存储到内存;把wzr(零寄存器)中的数据写入 x29(FP)减 4Byte 的内存
    adrp    x0, l_.str@PAGE     ;adrp 读取地址到寄存器;把符号l.str所在的Page读入x0
    add x0, x0, l_.str@PAGEOFF  ;x0 = x0 + l.str所在Page的偏移量
    bl  _printf                 ;bl 子程序调用;调用printf函数
    mov w8, #0                  ;mov 传送指令;0写入x8
    str w0, [sp, #8]            ;w0写入sp+8的内存
    mov x0, x8                  ;x8写入x0
    ldp x29, x30, [sp, #16]     ;sp+16Byte处的内存的两个8Byte,分别写入x29, x30
    add sp, sp, #32             ;sp = sp + 32Byte
    ret
    .cfi_endproc
                                        ; -- End function
    .section    __TEXT,__cstring,cstring_literals
l_.str:                                 ; @.str
    .asciz  "hellom, world\n"

.subsections_via_symbols

汇编代码几个规则:

## ARM中的寄存器

CPU 本身只负责运算,不负责储存数据。数据一般都储存在内存之中,CPU 要用的时候就去内存读写数据。但是,CPU 的运算速度远高于内存的读写速度,为了避免被拖慢,CPU 都自带一级缓存和二级缓存。基本上,CPU 缓存可以看作是读写速度较快的内存。

但是,CPU 缓存还是不够快,另外数据在缓存里面的地址是不固定的,CPU 每次读写都要寻址也会拖慢速度。因此,除了缓存之外,CPU 还自带了寄存器(register),用来储存最常用的数据。也就是说,那些最频繁读写的数据(比如循环变量),都会放在寄存器里面,CPU 优先读写寄存器,再由寄存器跟内存交换数据。

寄存器不依靠地址区分数据,而依靠名称。每一个寄存器都有自己的名称,我们告诉 CPU 去具体的哪一个寄存器拿数据,这样的速度是最快的。有人比喻寄存器是 CPU 的零级缓存。

这里介绍一下arm64常见的一些寄存器:

### 通用寄存器R0 – R30

r0 - r30 是31个通用整形寄存器。每个寄存器可以存取一个64位大小的数。 当使用 x0 - x30访问时,它就是一个64位的数。当使用 w0 - w30访问时,访问的是这些寄存器的低32位,如图:

为了函数调用的目的,通用寄存器分为四组(官网文档):

ARM64 通用寄存器
### 一些特殊寄存器

还有一些系统寄存器,还有 FPSR FPCR是浮点型运算时的状态寄存器等。基本了解上面这些寄存器就可以了。

## 内存模型

### 堆

寄存器只能存放很少量的数据,大多数时候,CPU 要指挥寄存器,直接跟内存交换数据。所以,除了寄存器,还必须了解内存怎么储存数据。

程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x10000x8000,起始地址是较小的那个地址,结束地址是较大的那个地址。

程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使用malloc命令),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户要求得到10个字节内存,那么从起始地址0x1000开始给他分配,一直分配到地址0x100A,如果再要求得到22个字节,那么就分配到0x1020

这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。

### 栈

Stack 是由于函数运行而临时占用的内存区域。或者说栈是指令执行时存放临时变量的内存空间。一个函数对应一帧,fp指向当前frame的栈底,sp指向栈顶

int main() {
   int a = 2;
   int b = 3;
}

上面代码中,系统开始执行main函数时,会为它在内存里面建立一个帧(frame),所有main的内部变量(比如a和b)都保存在这个帧里面。main函数执行结束后,该帧就会被回收,释放所有的内部变量,不再占用空间。

如果函数内部调用了其他函数,会发生什么情况?

int main() {
   int a = 2;
   int b = 3;
   return add_a_and_b(a, b);
}

上面代码中,main函数内部调用了add_a_and_b函数。执行到这一行的时候,系统也会为add_a_and_b新建一个帧,用来储存它的内部变量。也就是说,此时同时存在两个帧:mainadd_a_and_b。一般来说,调用栈有多少层,就有多少帧

等到add_a_and_b运行结束,它的帧就会被回收,系统会回到函数main刚才中断执行的地方,继续往下执行。通过这种机制,就实现了函数的层层调用,并且每一层都能使用自己的本地变量。

所有的帧都存放在 Stack,由于帧是一层层叠加的,所以 Stack 叫做栈。生成新的帧,叫做"入栈",英文是 push;栈的回收叫做"出栈",英文是 pop。Stack 的特点就是,最晚入栈的帧最早出栈(因为最内层的函数调用,最先结束运行),这就叫做"后进先出"的数据结构。每一次函数执行结束,就自动释放一个帧,所有函数执行结束,整个 Stack 就都释放了。

注意:

下面的图简单的描述了 main 调用方法 printf 时,栈是如何划分的:

下面是方法的调用过程,分别对应方法头、方法尾。

//x29就是fp, x30就是lr
//方法头:保存当前函数/子程序(main)的栈底FP、LR(main结束后需要执行的下一条指令)
sub  sp, sp, #32             // sub 减法; sp = sp - 32Byte
stp  x29, x30, [sp, #16]    // stp 寄存器存储到内存上;保存x29(FP)、x30(LR)到sp+16Byte上的16个Byte(通用寄存器,用x访问,表示64位,8Byte)
add  x29, sp, #16            // add 加法;把sp+16Byte写入x29(FP),保存即将执行函数的栈底

bl  _printf //子程序调用。// 跳转到_printf方法处,同时将该行的下一个指令的地址复制到 lr。作用也很好理解:当printf执行完了之后要返回来继续执行,但是计算机要如何知道返回到哪执行呢? 就是靠lr记录了返回的地址,方法才能得以正常返回。
// 本来LR中存储的是LR(main),是记录main函数执行完需要返回执行的下一条指令。在发生bl _printf后,LR存储的是printf函数执行完需要执行的下一条执行。这里没显示printf函数的汇编代码,在其中还有一个ret,会返回到这个LR

//方法尾
ldp x29, x30, [sp, #16]     // 将sp+16Byte后的两个8Byte,分别存入FP、LR,恢复为FP(main),LR(main)
add sp, sp, #32              // sp = sp + 32Byte
// 这一步执行完之后,fp就执行了图中FP(main);sp指向了 SP(main);lr恢复成main执行完后的返回地址。 
// 这个时候状态已经完全恢复到了 main 的环境
ret    // 返回指令,这一步直接执行lr的指令。

总结:

#include <stdio.h>
void nothing(){
    return;
}
//汇编代码中就一行ret指令,如下:
_nothing:                               ; @nothing
    .cfi_startproc
; %bb.0:
    ret
    .cfi_endproc

关于参数及返回值的传递,具有以下规则(赘述一遍,前面讲寄存器时提过):

### Stack backtrace

栈回溯对代码调试和crash定位有很重大的意义,通过之前几个步骤的图解,栈回溯的原理也相对比较清楚了。

  1. 通过当前的SP,FP可以得到当前函数的stack frame,通过PC可以得到当前执行的地址。
  2. 在当前栈的FP上方,可以得到Caller(调用者)的FP,和LR。通过偏移,我们还可以获取到Caller的SP。由于LR保存了Caller下一条指令的地址,所以实际上我们也获取到了Caller的PC
  3. 有了Caller的FP,SP和PC,我们就可以获取到Caller的stack frame信息,由此递归就可以不获取到所有的Stack Frame信息。

栈回溯的过程中,我们拿到的是函数的地址,又是如何通过函数地址获取到函数的名称和偏移量的呢?

这个过程我们称之为symbolicate,对于iOS设备上的crash log,我们可以直接通过XCode的工具symbolicatecrash来符号化:

cd /Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources
./symbolicatecrash ~/Desktop/1.crash ~/Desktop/1.dSYM > ~/Desktop/result.crash

当然,可以用工具dwarfdump去查询一个函数地址:

dwarfdump --lookup 0x000000010007528c  -arch arm64 1.dSYM

## 指令格式及常见指令

ARM作为精简指令集(RISC),所有 ARM 指令(RISC)的长度都是 32 位。行成对比的是复杂指令集(CISC,如x86),指令长度不同,最长的指令长达15 bytes,等于120位。

ARM指令使用的基本格式如下:<opcode>{<cond>}{S} <Rd>,<Rn>,{<operand2>}

ARM处理器的指令集可以分为跳转指令、数据处理指令、程序状态寄存器(PSR)处理指令、加载/存储指令、协处理器指令和异常产生指令6大指令。

本文只列举一些常见的基本指令,可以正常阅读汇编代码即可。有几个注意点:

;寻址格式:
    [x10, #0x10]      // signed offset。 意思是从 x10 + 0x10的地址取值
    [sp, #-16]!       // pre-index。  意思是从 sp-16地址取值,取值完后在把 sp-16  writeback 回 sp
    [sp], #16         // post-index。 意思是从 sp 地址取值,取值完后在把 sp+16 writeback 回 sp
    举例:
      ldr x0, [x1]              // 从`x1`指向的地址里面取出一个 64 位大小的数存入 `x0`
      ldp x1, x2, [x10, #0x10]  // 从 x10 + 0x10 指向的地址里面取出 2个 64位的数,分别存入x1, x2
      str x5, [sp, #24]         // 把x5的值(64位数值)存到 sp+24 指向的内存地址上
      stp x29, x30, [sp, #-16]! // 把 x29, x30的值存到 sp-16的地址上,并且把 sp-=16. 
      ldp x29, x30, [sp], #16   // 从sp地址取出 16 byte数据,分别存入x29, x30. 然后 sp+=16;

除此之外,还有两种地址表示方式(相对寻址):

由于篇幅原因,只列举了常用的一些,更多的可以跳转ARM64指令简易手册查阅。全面的可以查看ARM官网文档。如果想看中文版的资料可以看《汇编器指南》— 第二章、第四章

;数据处理指令  
    MOV    X1,X0              将寄存器X0的值传送到寄存器X1。MOV:从另一个寄存器、被移位的寄存器或将一个立即数加载到目的寄存器。
    ;算术运算:ADD SUB MUL … 等加减乘除运算
    ADD    X0,X1,X2          寄存器X1和X2的值相加后传送到X0
    SUB    X0,X1,X2          寄存器X1和X2的值相减后传送到X0
    MUL
    add  x14, x4, x27, lsl #1  算术运算也可以与逻辑位移运算一起用,意思是把  (x27 << 1) + x4 = x14;
    ;扩展位数运算:有 zero extend(高位补0) 和 sign extend(高位填充和符号位一致,一般有符号数用这个)。 一般用来补齐位数。常和算术运算配合一起.
    add  w20, w30, w20, uxth   算术运算也可以与扩展位数运输算一起,意思是取 w20的低16位,无符号补齐到32位后再进行  w30 + w20的运算
    ;逻辑运算指令
    LSL                        逻辑左移
    LSR                        逻辑右移
    ASR                        算术右移
    ROR                        循环右移
    AND    X0,X0,#0xF        与。X0的值与0xF相位与后的值传送到X0
    ORR    X0,X0,#9          或。X0的值与9相位或后的值传送到X0
    EOR    X0,X0,#0xF        异或。X0的值与0xF相异或后的值传送到X0

;寄存器加载/存储指令
    LDR    X5,[X6,#0x08]           ld(load): X6寄存器加0x08的和的地址值内的数据传送到X5
    LDP    x29, x30, [sp, #0x10]     ldp(load pair):是ldr 的变种指令,可以同时操作两个寄存器,从指定内存处读取两个数据到寄存器
    STR    X0, [SP, #0x8]            st:store, str:往内存中写数据(偏移值为正); X0寄存器的数据传送到SP+0x8地址值指向的存储空间
    STUR   w0, [x29, #-0x8]          往内存中写数据(偏移值为负)
    STP    x29, x30, [sp, #0x10]     stp(store pair):是str 的变种指令,可以同时操作两个寄存器,将一对寄存器中的值,入栈,存放到指定内存处
    ADR      ;将一个立即值与 pc 值相加,并将结果写入目标寄存器
    ADRP     ;以页为单位的大范围的地址读取指令,这里的P就是page的意思。取得page的基地址存入寄存器
      示例: adrp    x0, l_.str@PAGE         ;将符号l.str所在的page基址读入x0
            add     x0, x0, l_.str@PAGEOFF  ;x0 = x0 + l.str所在page中的偏移量

;跳转和控制指令
    CBZ      ;比较(Compare),如果结果为零(Zero)就转移(只能跳到后面的指令)
    CBNZ     ;比较,如果结果非零(Non Zero)就转移(只能跳到后面的指令)
    CMP      ;比较指令,相当于SUBS,影响程序状态寄存器CPSR,关于CPSR的几个状态值,前面寄存器节已经讲过

    B{条件} 目标地址        ;跳转指令,可带条件跳转与cmp配合使用。一般是本方法内的跳转,如while循环,if else等。
    BL       ;带返回的跳转指令, 返回地址保存到LR(X30)。存了LR也就意味着可以返回到本方法继续执行。一般用于不同方法之间的调用
    RET      ;子程序返回指令,返回地址默认保存在LR(X30)

;异常产生指令
    SWI(Software Interrupt)    软件中断指令。用于产生软中断,从而实现处理器从用户模式变换到管理模式,CPSR保存到管理模式的SPSR中,执行转移到SWI向量,在其他模式下也可以使用SWI指令,处理器同样切换到管理模式。
    BKPT(BreakPoint)           断点中断指令。产生一个预取异常(prefetch abort),它常被用来设置软件断点,在调试程序时十分有用。当系统中存在调试硬件时,该指令被忽略。

## ARM指令的二进制编码

### 对应的二进制编码格式

ARM指令集是以32位二进制编码的方式给出的,大部分的指令编码中定义了第一操作数、第二操作数、目的操作数、条件标志影响位以及每条指令所对应的不同功能实现的二进制位。每条32位ARM指令都具有不同的二进制编码方式,与不同的指令功能相对应

如图所示表示了ARM指令集编码。

### 条件执行

ARM指令的一个重要特点就是所有指令都是带有条件的,就是说汇编中可以根据状态寄存器中的一些状态来控制分支的执行。

在ARM的指令编码表中,统一占用编码的最高4位[31:28]来表示条件码。每种条件码用两个英文缩写字符表示其含义,可添加在指令助记符的后面,表示指令执行时必须要满足的条件。ARM指令根据CPSR中的条件位自动判断是否执行指令。在条件满足时,指令执行;否则,指令被忽略。

例如,数据传送指令MOV加上条件后缀EQ后成为MOVEQ,表示“相等则执行传送”,“不相等则本条指令不执行”,即只有当CPRS中的Z标志为1时,才会发生数据传送。ARM指令集编码表列举了4位条件码的16种编码中能为用户所使用的15种,而编码1111为系统暂不使用的保留编码。

举例

看下面几行汇编指令:

cmp x2, #0         // x2 - 0 = 0。  状态寄存器标识zero: PSTATE.NZCV.Z = 1
b.ne  0x1000d48f0  // ne就是个condition code, 这句的意思是,当判断状态寄存器 NZCV.Z != 1才跳转,因此这句不会跳转

0x1000d4ab0 bl testFuncA               // 跳转方法,这个时候 lr 设置为 0x1000d4ab4
0x1000d4ab4 orr x8, xzr, #0x1f00000000 // testFuncA执行完之后跳回lr就周到了这一行

# 内联汇编


用汇编编写的程序虽然运行速度快,但开发速度非常慢,效率也很低。如果只是想对关键代码段进行优化,或许更好的办法是将汇编指令嵌入到 C 语言程序中,从而充分利用高级语言和汇编语言各自的特点。但一般来讲,在 C 代码中嵌入汇编语句要比"纯粹"的汇编语言代码复杂得多,因为需要解决如何分配寄存器,以及如何与C代码中的变量相结合等问题。

GCC 提供了很好的内联汇编支持,最基本的格式是:__asm__("asm statements");

更详细,可参考Linux 汇编语言开发指南—第七节

# 汇编层次看高级语言


汇编层面上只有寄存器、内存及数据(地址(无符号整数)、数字(定点、浮点)、字符、逻辑数)

# 关于intel、AT&T汇编的简单了解


Intel、AT&T汇编

# 参考链接:


上一篇 下一篇

猜你喜欢

热点阅读