汇编学习——iOS开发对arm64汇编的初步了解

汇编学习------iOS开发对arm64汇编的初步了解

文章目录

前言

最近也是开始每天都在看源码,其中在阅读源码的过程之中,无论是属性关键字还是消息转发的内容,都多多少少涉及到了关于汇编的内容,看着真是令人头大,本着看了就要看全的精神,于是开始对arm64的汇编进行简单的学习。这篇文章就是对汇编浅尝则止的学习记录

栈 指令 寄存器

汇编最重要的就是三个部分就是栈 指令 寄存器,我们从较为复杂的寄存器说起

寄存器

寄存器是 CPU 中的高速存储单元,存取速度比内存快很多。

寄存器 职责
X0 返回值、第一个参数(self
X1-X7 第 2~8 个参数、临时变量(如 Objective-C 方法的选择器 _cmd 通常通过 X1 传递)
X8 间接返回地址、全局变量偏移、系统调用号(在 Objective-C 中可能用于计算全局变量或类对象的偏移地址)
X9-X15 调用者保存的临时变量
X19-X28 被调用者保存的长期变量
X29 (FP) 栈帧管理、调试支持
X30 (LR) 保存函数调用后的返回地址(如 bl 指令跳转时写入)
XZR 清零操作,用于快速清零其他寄存器(如 mov x0, xzr 等价于 x0 = 0

注:

x0 - x7 :用于子程序调用时的参数传递,超过八个会放到栈上传递

x0 和 w0 是同一个寄存器的不同尺寸的区别,x0 为 8 字节,w0 为 4 字节(x0 寄存器的低4字节), x0/w0 还用于返回值的传递

指令

需要提前知道的就是:在ARM64架构中,栈是向下生长的,换句话来说,栈内存从高地址向低地址方向扩展。具体来说,当数据被压入栈时,栈指针(SP)会减小(指向更低的内存地址);当数据弹出时,SP会增大(指向更高的内存地址)。这是ARM64与其他架构(如x86)共有的特性。

  • 压栈操作会导致SP减小(如sp = sp - 16),栈顶向低地址方向移动
  • 出栈操作则会使SP增大(如sp = sp + 16),栈顶恢复高地址。

这部分可能有点乱,我们在后面用一个例子的展示一下栈具体的存储结构

运算指令
asm 复制代码
mov    x1,x0         ;将寄存器x0值 赋值 给x1
add    x0,x1,x2     ;x0 = x1 + x2
sub    x0,x1,x2     ;x0 = x1 - x2
mul    x0,x1,x2     ;x0 = x1 * x2
sdiv   x0,x1,x2     ;x0 = x1 / x2;
 
and    x0,x0,#0xF    ;x0 = x0 & #0xF (与操作)
orr    x0,x0,#9      ;x0 = x0 | #9   (或操作)
eor    x0,x0,#0xF    ;x0 = x0 ^ #0xF (异或操作)
寻址指令

寻址指令简单的可以分为 存和取

L开头的就是取: LDR(Load Register)、LDP(Load Pair)

S开头的就是存: STR(Store Register)、STP(Store Pair)

asm 复制代码
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 指向的地址内存上
stur   w0,[x29, #0x8]        ;往内存中写数据(偏移值为负),将 w0 的值存储到 x29 - 0x8 这个地址里
stp    x29,x30,[sp, #-16]!   ;把 x29、x30 的值存到 sp-16 的地址上,并且把sp-=16 后面有个感叹号表示前变基模式
ldp    x29,x30,[sp],#16      ;从 sp 地址取出16 byte数据,分别存入x29、x30,然后 sp+=16

寻址指令的格式

asm 复制代码
mov x0
[x10, #0x10]        ;从 x10+0x10 的地址取值
[sp, #-16]!         ;从 sp-16 地址取值,取完后再把 sp-16 writeback 回 sp
[sp], #16           v从 sp 地址取值,取完后把 sp+16 writeback 回 sp
前变基 与 后变基
模式 操作顺序 语法示例 应用场景
前变基 1. 先更新基址寄存器 2. 再访问内存 LDR X0, [X1, #8]! 函数调用时预留栈空间
后变基 1. 先访问内存 2. 再更新基址寄存器 LDR X0, [X1], #8 函数返回时恢复栈指针

关于栈的内容,需要我们复习一下之前学习过的相关内容,就是计算机的内存结构

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

堆(Heap)内存机制
  1. 核心特征
  • 分配方式 :动态申请(malloc/new等)
  • 地址增长:从低位地址向高位地址扩展
  • 生命周期:需手动释放或依赖垃圾回收
  1. 分配实例
c 复制代码
// 内存起始地址 0x1000
void* p1 = malloc(10); // 分配 0x1000-0x100A
void* p2 = malloc(22); // 分配 0x100B-0x1020
三、栈(Stack)内存机制
  1. 核心特征
  • 分配方式:函数调用自动创建帧(Frame)
  • 地址增长:从高位地址向低位地址扩展
  • 生命周期:函数结束时自动释放

简单来说,栈是由于函数运行而临时占用的内存区域

  1. 函数帧结构
c 复制代码
int main() {
    int a = 2;  // ↘ 主函数帧
    int b = 3;  // │ 变量存储区
}               // ↖ 栈顶地址 0x8000

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

3. 多级调用示例

c 复制代码
int test(int x, int y) {   // ↘ 子帧
    return x + y;          // │ 参数/变量存储区
}                          // ↖ 新栈顶 0x7FF0

int main() {               // ↘ 主帧
    test(2, 3);            // │ 返回地址保存区
}                          // ↖ 初始栈顶 0x8000

当我们在main函数之中调用了test函数时,当我们执行这一步,系统就会为了这个test函数创建一个帧,现在栈区就有了两个函数帧,一般调用了多少层的函数就有多少个帧

等到test函数运行结束,它的帧就会被系统自动回收,实现了函数的层层调用。

前面我们说到,arm64架构下的栈时从高位(地址)向低位(地址)分配的,,内存区域的结束地址是 0x8000,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0。

操作 栈地址范围 剩余空间
主帧分配(16字节) 0x8000 - 0x7FF0 0x7FF0
子帧分配(64字节) 0x7FF0 - 0x7FB0 0x7FB0

例子

objc 复制代码
// hello.c
#include <stdio.h>
int test(int a, int b) {
  int res = a + b;
  return res;
}
int main() {
  int res = test(1, 2);
  return 0;
}

使用clang指令把以上内容编译为arn64代码

asm 复制代码
.section __TEXT,__text,regular,pure_instructions
.build_version ios, 13, 2 sdk_version 13, 2
.globl _test                   ; -- Begin function test
.p2align 2
_test:                                  ; @test
.cfi_startproc
; %bb.0:
sub sp, sp, #16             ; =16
.cfi_def_cfa_offset 16
str w0, [sp, #12]
str w1, [sp, #8]
ldr w0, [sp, #12]
ldr w1, [sp, #8]
add w0, w0, w1
str w0, [sp, #4]
ldr w0, [sp, #4]
add sp, sp, #16             ; =16
ret
.cfi_endproc
                                        ; -- End function
.globl _main                   ; -- Begin function main
.p2align 2
_main:                                  ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #32             ; =32
stp x29, x30, [sp, #16]     ; 16-byte Folded Spill
add x29, sp, #16            ; =16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
stur wzr, [x29, #-4]
orr w0, wzr, #0x1
orr w1, wzr, #0x2
bl _test
str w0, [sp, #8]
mov w0, #0
ldp x29, x30, [sp, #16]     ; 16-byte Folded Reload
add sp, sp, #32             ; =32
ret
.cfi_endproc
                                        ; -- End function
.subsections_via_symbols

完整如上

.p2align 2 用于指定程序的对齐方式,这类似于结构体的字节对齐,为的是加速程序的执行速度,p2align 的单位是指数,即按照 2 的 n 次方对齐,这里的 .p2align 2 表示按照 2^2 = 4 字节对齐,如果单行指令数据长度不足4字节,将用 0 补全,超过 4 但不是 4 的倍数,则按照最小倍数补全

asm 复制代码
.cfi_startproc    ;定义函数开始
.cfi_endproc      ;定义函数结束

汇编中的如下部分被称为方法头(prologue),用于保存上一个方法调用栈帧的帧头,以及预留部分用于局部变量的栈空间。

复制代码
sub sp, sp, #32             ; =32
stp x29, x30, [sp, #16]     ; 16-byte Folded Spill
add x29, sp, #16            ; =16

汇编中的如下部分被称为方法尾(epilogue),用于取出方法头中栈帧信息及方法的返回地址,并将栈恢复到调用前的位置

复制代码
ldp	x29, x30, [sp, #16]     ; 16-byte Folded Reload
add	sp, sp, #32             ; =32
ret

我们先来看看Test函数

asm 复制代码
 //源代码
 int test(int a, int b) {
    int res = a + b;
    return res;
 }
 
 
 //汇编
 sub	sp, sp, #16             ; =16
 .cfi_def_cfa_offset 16
 str	w0, [sp, #12]
 str	w1, [sp, #8]
 ldr	w0, [sp, #12]
 ldr	w1, [sp, #8]
 add	w0, w0, w1
 str	w0, [sp, #4]
 ldr	w0, [sp, #4]
 add	sp, sp, #16             ; =16
 ret

在编译器生成汇编时,会首先计算需要的栈空间大小,并利用 sp (stack pointer)指针指向低地址开辟相应的空间。从 test 函数可以看到这里涉及了3个变量,分别是 a、b、res,int变量占据4个字节,因此需要12个字节,但 ARM64 汇编为了提高访问效率要求按照16字节进行对齐,因此需要16 byte 的空间,也就是需要在栈上开辟16字节的空间

代码的大致意思如下

asm 复制代码
sub sp, sp, #16             ; =16

将栈顶指针下移,即为函数的栈帧扩充空间

asm 复制代码
str w0, [sp, #12]
str w1, [sp, #8]

这2句的意思是,将 w0 存储在 sp+12 的地址指向的空间,w1 存储在 sp+8 存储的空间里,寄存器x0~x7用于子程序调用时的参数传递,按顺序入参。 x0 和 w0 是同一个寄存器的不同尺寸形式,x0为8字节,w0为x0的前4个字节,因此w0是函数的第一个入参a,w1是函数的第二个入参b

接下来 test 函数内部将 a 和 b 进行相加,需要注意的是,只有寄存器才能参与运算,因此接下来的汇编代码又将变量的值从内存中读出来,再进行相加运算。

asm 复制代码
ldr w0, [sp, #12]
ldr w1, [sp, #8]
add w0, w0, w1

为什么需要先存取后取出再操作,这个操作确实多余,是因为汇编没有进行优化的结果,把这个操作体现出来更有利于

ARM64 栈结构示意图(从高地址向低地址生长)

内存地址 存储内容(示例值) 用途说明 备注
0xFFFFFFC0 (未分配,原栈顶) 父函数栈帧 栈初始位置,SP 初始指向此处(高地址)
0xFFFFFFBC w0(参数 a = 0x12345678) 参数存储区(sp + 12) 32 位参数,按 小端序 存储:0x78 0x56 0x34 `0x12
0xFFFFFFB8 w1(参数 b) 参数存储区(sp + 8) 第二个参数,通过 w1 传递
0xFFFFFFB4 w0(计算结果 c) 临时结果存储(sp + 4) 计算后的 32 位结果,通过 w0 返回
0xFFFFFFB0 (填充区,未使用) 16 字节对齐填充 因栈分配需按 16 字节对齐(48 字节或 16 的倍数)
↓ SP 新位置 SP = 0xFFFFFFB0 当前函数栈顶(低地址) 通过 sub sp, sp, #16 分配空间,栈向低地址生长
关键说明:
  1. 栈生长方向

    • 栈从 高地址(0xFFFFFFC0)向低地址(0xFFFFFFB0) 扩展,符合 ARM64 的 满减栈(FD)
    • 每次函数调用会通过 sub sp, sp, #N 分配栈空间,N 必须是 16 的倍数。
  2. 数据读写规则

    • 存储指令(str :写入时从 低地址向高地址 填充字节(如 0xFFFFFFB40xFFFFFFB7)。
    • 加载指令(ldr :读取时从起始地址(如 sp + 4)连续读取 4 字节,按小端序组合为 32 位值。
  3. 对齐与填充

    • 若存储 64 位数据(如 x0),需占用 8 字节且起始地址按 8 字节对齐(如 0xFFFFFFB4 对齐到 0xFFFFFFB0)。
    • 未使用的填充区域(如 0xFFFFFFB0)确保栈帧按 16 字节对齐,避免内存访问错误。
示例验证(小端序存储):

假设 w0 = 0x12345678,存储到 0xFFFFFFB4 后的内存布局:

地址 字节值 说明
0xFFFFFFB4 0x78 最低有效字节 (LSB)
0xFFFFFFB5 0x56
0xFFFFFFB6 0x34
0xFFFFFFB7 0x12 最高有效字节 (MSB)

参考文章

iOS 汇编入门 - arm64基础

相关推荐
泰勒朗斯7 小时前
Helicopter FBI-405尾电机版本设置
学习
Summer-Bright8 小时前
消费者 AI 变现竞争:从“一家独大“到“iOS vs Android“,谁在为 AI 买单?
android·人工智能·ios·ai·自然语言处理·agi
学逆向的8 小时前
汇编——寻址方式
开发语言·汇编·网络安全
minglie18 小时前
香橙派zero3配个iic屏显示ip
linux·学习
微露清风9 小时前
模拟算法学习记录
学习·算法
我不是QI9 小时前
Master PDF Editor 逆向复现
汇编·安全·pdf
筱谙10 小时前
BES BLE CTKD 完整学习笔记
笔记·学习
minglie110 小时前
zynq开发板的xvcServer作为烧录器
学习
微露清风11 小时前
快速排序算法学习记录
学习·算法·排序算法
AAA@峥12 小时前
系统化学习 MySQL:数据类型、库表管理、增删改查全解析
数据库·学习·mysql