iOS需要了解的ARM64汇编(搬运)
iOS需要了解的ARM64汇编(搬运)
概述
早期的程序员发现机器语言在阅读、书写方面的问题,是如此的难以辨别和记忆,需要记住所有抽象的二进制码,为了解决这个问题,汇编语言就产生了。汇编语言是各种CPU提供的机器指令的助记符的集合,人们可以用汇编语言直接控制硬件系统进行工作。
汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上。汇编指令是机器指令便于记忆的书写格式。
汇编语言与硬件关联很深,所以涉及到的知识点有很多,如:寄存器、端口、寻址方式、内外中断、以及指令的实现原理等,额,如果想了解这些知识点,可以阅读《汇编语言(第3版)》 王爽著。本篇博客类似阅读手册,主要记录一些常见的寄存器、以及不同汇编语言规范中指令的编写风格(intel及AT&T的篇幅很少,毕竟我是一个iOSer,以移动端主流ARM64汇编为例)。
iOS相关的指令集及对应的ARM汇编语言
作为iOS开发工程师,主要需要了解的汇编语言是:
- iOS模拟器:兼容x86指令集,对应 AT&T 汇编语言规范
- iOS真机设备:兼容ARM指令集,对应 ARM 汇编语言规范
ARM64 汇编
汇编里面要学习的三个重要概念:寄存器、内存模型、指令。
arm64架构又分为2种执行状态: AArch64 Application Level 和 AArch32 Application Level(后者是为了兼容以前的32bit的程序)
AArch64执行A64指令,使用64bit的通用寄存器;
AArch32执行A32/T32指令,使用32bit的通用寄存器;
先放代码 — Hello world
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汇编代码几个规则:
以.(点)开头的是汇编器指令。汇编器指令是告诉汇编器如何生成机器码的,阅读汇编代码的时候通常可以忽略掉。
.section __TEXT,__text,regular,pure_instructions
:表示接下来的内容在生成二进制代码的时候,应该生成到Mach-O文件__TEXT(Segment)
中的__text(Section)
.cfi_startproc
:用在每个函数的开始,用于初始化一些内部数据结构.cfi_endproc
:在函数结束的时候使用与.cfi_startproc
相配套使用.cfi_def_cfa <register>, <offset>
:从寄存器中获取地址并向其添加偏移量.cfi_offset <register>, <offset>
:寄存器以前的值保存在CFA的offset偏移处
以
:(冒号)
结尾的是标签(Label)。代表一个地址,在需要时可以使用跳转指令跳转到标签
处执行。其中,以小写字母l开头的是本地(local)标签,只能用于函数内部。
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位,如图:
为了函数调用的目的,通用寄存器分为四组(官网文档):

注意,但参数过多、返回值过大时,比如是个成员很多的结构体,通用x0-x7不够用,会通过栈来传递
一些特殊寄存器
ZR:zero register 零寄存器,与通用寄存器一样,x、w分别代表64/32位(
XZR/WZR
),作用就是0,写进去代表丢弃结果,拿出来是0.SP:Stack Pointer 保存栈指针。在指令编码中,使用
SP/WSP
来进行对SP寄存器的访问。PC:程序计数器,俗称PC指针,总是指向即将要执行的下一条指令。在arm64中,软件是不能改写PC寄存器的。
V0 – V31:向量寄存器,也可以说是浮点型寄存器。它的特点是每个寄存器的大小是 128 位的。 分别可以用
Bn Hn Sn Dn Qn
的方式来访问不同的位数。可以这样理解记忆,基于一个word是32位,也就是4Byte大小:Bn:一个Byte的大小
Hn:half word. 就是16位
Sn:single word. 32位
Dn:double word. 64位
Qn:quad word. 128位SPRs:状态寄存器,用于存放程序运行中一些状态标识。不同于编程语言里面的if else。在汇编中就需要根据状态寄存器中的一些状态来控制分支的执行。状态寄存器又分为
The Current Program Status Register (CPSR)
和The Saved Program Status Registers (SPSRs)
。 一般都是使用CPSR
, 当发生异常时,CPSR
会存入SPSR
。当异常恢复,再拷贝回CPSR
。
不同于其他寄存器,其他寄存器是用来存放数据的,都是整个寄存器具有一个含义。而CPSR寄存器是按位起作用的,也就是说,它的每一位都有专门的含义,记录特定的信息。- CPSR寄存器是32位的
- CPSR的低8位(包括I、F、T和M[4:0])称为控制位,程序无法修改,除非CPU运行于特权模式下,程序才能修改控制位。
- N、Z、C、V均为条件标志位,分别代表运算过程中产生的状态。它们的内容可被算术或逻辑运算的结果所改变,并且可以决定某条指令是否被执行。
还有一些系统寄存器,还有 FPSR FPCR是浮点型运算时的状态寄存器等。基本了解上面这些寄存器就可以了。
内存模型
堆
寄存器只能存放很少量的数据,大多数时候,CPU 要指挥寄存器,直接跟内存交换数据。所以,除了寄存器,还必须了解内存怎么储存数据。
程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x1000
到0x8000
,起始地址是较小的那个地址,结束地址是较大的那个地址。
程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使mallo
命令),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户要求得到10个字节内存,那么从起始地址0x1000
开始给他分配,一直分配到地址0x100A
,如果再要求得到22个字节,那么就分配到0x1020
。
这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。
栈
Stack 是由于函数运行而临时占用的内存区域。或者说栈是指令执行时存放临时变量的内存空间。一个函数对应一帧,fp
指向当前frame的栈底,sp
指向栈顶
int main() { |
上面代码中,系统开始执行main函数时,会为它在内存里面建立一个帧(frame)
,所有main的内部变量(比如a和b)都保存在这个帧里面。main函数执行结束后,该帧就会被回收,释放所有的内部变量,不再占用空间。
如果函数内部调用了其他函数,会发生什么情况?
int main() { |
上面代码中,main函数内部调用了add_a_and_b函数。执行到这一行的时候,系统也会为add_a_and_b新建一个帧,用来储存它的内部变量。也就是说,此时同时存在两个帧:main
和add_a_and_b
。一般来说,调用栈有多少层,就有多少帧。
等到add_a_and_b
运行结束,它的帧就会被回收,系统会回到函数main刚才中断执行的地方,继续往下执行。通过这种机制,就实现了函数的层层调用,并且每一层都能使用自己的本地变量。
所有的帧都存放在 Stack,由于帧是一层层叠加的,所以 Stack 叫做栈。生成新的帧,叫做”入栈”,英文是 push;栈的回收叫做”出栈”,英文是 pop。Stack 的特点就是,最晚入栈的帧最早出栈(因为最内层的函数调用,最先结束运行),这就叫做”后进先出”的数据结构。每一次函数执行结束,就自动释放一个帧,所有函数执行结束,整个 Stack 就都释放了。
注意:
Stack 是由内存区域的结束地址开始,
从高位(地址)向低位(地址)分配
。
栈顶置针向低移动,就是分配临时存储空间,栈顶置针向高移动,就是释放临时存储空间。
比如,内存区域的结束地址是0x8000,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0。栈中一个数据所分配到的内存中,存储(读取)数据时,是
从低位(地址)向高位(地址)读写的
。即栈中数据的打印地址(起始地址)与堆中一样,是低地址开始。- 情况一:见下面代码块中的
stp
、ldp
。 - 情况二:复合类型,如创建一个结构体局部变量,打印成员变量,会发现是从低地址向高地址依次打印出来的
- 注意:基本数据类型的存储,还涉及到
大端、小端字节序
的概念,即指高位字节在前(后)。
- 情况一:见下面代码块中的
补充:复合数据类型都是由基本数据类型组成的,基本数据类型的存储不会带来空闲(冗余)空间的:
char类型的数据值为单个字符,ASCII码值对应为0-255,正好一个字节存储。
int类型,比如int = 1,int占4字节,存的时候会存0x00000001,即会转成8位的16进制表示存储,占满4字节
冗余空间的产生,往往是因为一些比如对齐之类的存储策略造成的
下面的图简单的描述了 main 调用方法 printf 时,栈是如何划分的:
下面是方法的调用过程,分别对应方法头、方法尾。
//x29就是fp, x30就是lr |
总结:
- 方法头、尾的作用就是调用前保存程序状态,调用后恢复程序状态。
- 如果一个函数内部没有其他函数调用,也就没有这几行方法头、尾了,比如一个最简单的程序如:
|
关于参数及返回值的传递,具有以下规则(赘述一遍,前面讲寄存器时提过):
- 当函数参数个数小于等于8个的时候,x0-x7依次存储前8个参数
- 参数个数大于8个的时候,多余的参数会通过栈传递
- 方法通常通过x0返回数据,如果返回的数据结构较大,则通过x8将数据的地址进行返回(寄存器最大为8字节,超过8字节的返回值,一个寄存器就传递不了了)
- 在Intel 32位汇编中:
- 通过当前的SP,FP可以得到当前函数的stack frame,通过PC可以得到当前执行的地址。
- 在当前栈的FP上方,可以得到Caller(调用者)的FP,和LR。通过偏移,我们还可以获取到Caller的SP。由于LR保存了Caller下一条指令的地址,所以实际上我们也获取到了Caller的PC
- 有了Caller的FP,SP和PC,我们就可以获取到Caller的stack frame信息,由此递归就可以不获取到所有的Stack Frame信息。
栈回溯的过程中,我们拿到的是函数的地址,又是如何通过函数地址获取到函数的名称和偏移量的呢?
- 对于系统的库,比如
CoreFoundation
我们可以直接通过系统的符号表拿到
对于自己代码,则依赖于编译时候生成的dsym
文件。 - 这个过程我们称之为
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>}
- Opcode:操作码;指令助记符,如LDR、STR等。
- Cond:可选的条件码;执行条件,如EQ、NE等。
- S:可选后缀;若指定S,则根据指令执行结果更新CPSR中的条件码
- Rd:目标寄存器
- Rn:存放在第1操作数的寄存器。
- operand2:第2个操作数。
- “< >”:“< >”内的项是必需的,例如,
是指令助记符,这是必须书写的。 - “{ }”:“{ }”内的项是可选的,例如,{< code>}为指令执行条件,是可选项。若不书写,则使用默认条件AL(无条件执行)。
- ARM处理器的指令集可以分为跳转指令、数据处理指令、程序状态寄存器(PSR)处理指令、加载/存储指令、协处理器指令和异常产生指令6大指令。
本文只列举一些常见的基本指令,可以正常阅读汇编代码即可。有几个注意点:
- 寄存器:为标号,不加前缀
- 操作数顺序:目标操作数在左,源操作数在右
- 立即数:前加#作为前缀
- 寻址格式:
;寻址格式: |
除此之外,还有两种地址表示方式(相对寻址):
程序相对地址
(程序相对的表达式):是命名寄存器的值加上或减去一个数字常数寄存器相对地址
(寄存器相对的表达式):表示为相对当前程序计数器 (PC) 的偏移量。它通常是标签与数字
表达式的组合(如ADR
指令)
由于篇幅原因,只列举了常用的一些。全面的可以查看ARM官网文档。
;数据处理指令 |
ARM指令中,不支持将立即数直接写入内存,需要先通过mov写入寄存器,然后通过str将寄存器中的值存储进内存
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 |
内联汇编
用汇编编写的程序虽然运行速度快,但开发速度非常慢,效率也很低。如果只是想对关键代码段进行优化,或许更好的办法是将汇编指令嵌入到 C 语言程序中,从而充分利用高级语言和汇编语言各自的特点。但一般来讲,在 C 代码中嵌入汇编语句要比”纯粹”的汇编语言代码复杂得多,因为需要解决如何分配寄存器,以及如何与C代码中的变量相结合等问题。
GCC 提供了很好的内联汇编支持,最基本的格式是:__asm__("asm statements")
;
汇编层次看高级语言
汇编层面上只有寄存器、内存及数据(地址(无符号整数)、数字(定点、浮点)、字符、逻辑数)
- 指针:本质上就是一个变量的地址。
- 结构体:本质上就是按照一定规则分配的连续内存。
- 结构体作为参数时,将成员通过连续的通用寄存器或者浮点型寄存器传入。当结构体过大(成员过多、复杂)的时候,作为参数和返回值时,通过栈来传递,这一点和函数的参数个数过多的时候类似。
- 举例:当使用printf直接打印结构体变量时(一般不这么使用,而是打印结构体.成员变量),不是直接打印地址,而是打印成员。前面有多少个打印字符,就会打印出多少个成员变量的值。(如果打印字符多于成员数,会打印出一些随机的东西)
- 数组: