汇编学习——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基础

相关推荐
ps酷教程9 分钟前
springboot3学习
学习
名字不要太长 像我这样就好38 分钟前
【iOS】源码阅读(二)——NSObject的alloc源码
开发语言·macos·ios·objective-c
麦田里的稻草人w1 小时前
拍摄学习笔记【前期】(一)曝光
笔记·学习
C++ 老炮儿的技术栈1 小时前
C++中什么是函数指针?
c语言·c++·笔记·学习·算法
pigfu1 小时前
go 通过汇编学习atomic原子操作原理
汇编·golang·atomic·缓存行·lock指令
我想吃余1 小时前
【Linux修炼手册】Linux开发工具的使用(一):yum与vim
linux·运维·学习·vim
Chef_Chen2 小时前
从0开始学习大模型--Day06--大模型的相关网络架构
运维·服务器·学习
strongwyy3 小时前
DA14585墨水屏学习(2)
前端·javascript·学习
renhl2523 小时前
英语句型结构
学习
海尔辛4 小时前
学习黑客了解Python3的“HTTPServer“
学习