如何1小时入门ARM64汇编?

ARM处理器的结构和指令集

7bdd8ea612842663c05e7ac1686d9b78.png

冯·诺依曼结构与哈佛结构

哈弗结构起源于打卡纸存储程序指令, 而数据则存储在处理器,后来在嵌入式系统中沿用下来,将程序指令放在 ROM (Read Only Memory, 只读存储器)或 Flash 等存储器中, 可以有效地保护程序指令在执行时不被改写;而数据则保存在 RAM中, 可以读写。哈佛结构计算机将程序指令和数据分开的做法实际上是保护了程序指令。

6a3f7da7ebdb3aadfd58eadfc135d987.png

哈佛结构示意图

冯·诺依曼结构比哈佛结构出现的稍晚一些,应该是在哈佛结构的基础上改进而来,它将程序指令和数据存储在一起的存储器结构,简化和统一了程序和数据的总线存取,也称为存储程序计算机。由于冯·诺依曼结构具有简单、通用和低成本的优势,已经成为通用计算机领域广为接受的基本概念,远比哈佛结构要广为人知。

513fc5aaf17987051ec4f4060b14fa7c.png

冯·诺依曼结构示意图

在嵌入式专用计算机中,程序需要固化在硬件设备中,以硬件IC或固件FW的形式存在,产品出厂后程序几乎从不需要修改,而数据需要反复存取,因而程序和数据对存储器类型要求是不同的,程序(固件FW)所需的存储器可以是一次或有限次烧写反复读取的存储器;数据所需的是需要反复读写的存储器,这样程序存储器和数据存储器用两个独立的存储器,每个存储器独立编址、独立访问。哈佛结构将程序指令和数据分开的做法适合这种嵌入式设备的场景,而且这种分离的程序总线和数据总线可允许在一个机器周期内同时获得指令(来自程序存储器)和操作数(来自数据存储器),通常具有较高的执行效率可以保证嵌入式设备的实时性。因此哈佛结构在51单片机和ARM等嵌入式处理器中得以存在。

现代操作系统是以冯·诺依曼结构的通用计算机为基础开发,比如Linux中进程地址空间就是将程序指令和数据统一编址存储的,把进程作为一个虚拟的计算机的话,那它是冯·诺依曼结构的。那么问题来了,在ARM上运行Linux,那这台计算机是冯·诺依曼结构还是哈佛结构呢?显然冯·诺依曼结构与哈佛结构融合起来了,我们可以把所有计算机都笼统简化地理解为冯·诺依曼结构,而哈佛结构只在为了某些特殊目标而设计的硬件中,这大概是哈佛结构在智能手机中广为应用,却不如冯·诺依曼结构广为人知的原因吧。

复杂指令集和精简指令集

最初的计算机没有指令,完全由硬件电路实现专用的计算机。硬件电路中反复使用的通用电路模块就是指令产生的基础,可以说指令最初是电路模块的编号,这些编号的集合就是指令集,可认为是最初的编程语言,对应机器语言和汇编语言。复杂指令集就是在经验积累的基础上产生了大量实用指令的集合。

CISC(Complex Instruction Set Computer),即“复杂指令集计算机”,从计算机诞生以来,人们一直沿用CISC指令集方式。CISC的指令比较丰富,有专用指令来完成特定的功能。因此,处理特殊任务效率较高。随着对指令集的反复应用和抽象,逐渐发现可以去除冗余指令或合并不同指令中相同的功能,用最精简的指令集来完成相同的工作,从而简化硬件芯片的复杂度,这就产生了精简指令集。

RISC(Reduced Instruction Set Computer),即“精简指令集计算机”,是一种执行较少类型计算机指令的微处理器,起源于80年代的MIPS,是RISC处理器。RISC有简单高效的特色,对不常用的功能,常通过组合指令来完成,因此,在RISC 机器上实现特殊功能时,效率可能较低,但可以利用流水技术和超标量技术加以改进和弥补。

简单来说,复杂指令集相当于语言的词汇量丰富,精简指令集相当于语言的词汇量较少,但可以组合成的词组和短语较为丰富。

CISC处理器最有代表性的就是Intel和AMD的X86和X64指令集;RISC处理器有Power PC、MIPS、ARM、ARM64等指令集。

ARM64,官方称为AArch64,是ARM架构的64位扩展,在ARMv8-A架构中被首次提出。ARM64广泛应用于智能手机中,且逐渐向桌面和服务器领域拓展,比如基于苹果M1芯片的Mac电脑和MacBook笔记本电脑、基于华为鲲鹏处理器的台式机和服务器,以及树莓派等。

ARM64寄存器

ARM64通用寄存器

ARM64中有31个64位的通用寄存器,即x0-x30,低32位用Wn表示。读Wn寄存器时会保持Xn寄存器的高32位不变,如果写Wn寄存器时,会将Xn寄存器的高32位设为0。

e1a923f3510022f1032fb8db5dd4140b.png

ARM64通用寄存器示意图

通用寄存器中x0-x7:一般用于传递子程序参数和结果,使用时不需要保存,多余参数采用堆栈传递,其中x0/w0用于保存函数返回结果,64位返回结果采用x0表示,128位返回结果采用x1:x0表示。

通用寄存器中x8:用于保存子程序返回地址, 尽量不要使用 。

通用寄存器中x9~x15:临时寄存器,使用时不需要保存。

通用寄存器中x16~x17:子程序内部调用寄存器,使用时不需要保存,尽量不要使用。

通用寄存器中x18:平台寄存器,它的使用与平台相关,尽量不要使用。

通用寄存器中x19~x28:临时寄存器,使用时必须保存。

通用寄存器中x29:一般用作栈基址寄存器,习惯上称为栈帧指针fp (frame pointer),栈顶寄存器为sp(stack pointer )不是通用寄存器,是特殊的寄存器。

通用寄存器中x30:x30寄存器是lr (Link Register) ,用于保存跳转指令的下一条指令的内存地址,比如 bl 指令。

ARM64特殊寄存器

ARM64特殊寄存器中有pc(Program counter)对应X86处理器的EIP/RIP寄存器;sp(Stack pointer)对应X86处理器的ESP/RSP寄存器;SPSR(Program Status Register)对应X86处理器的EFlags/RFlags,另外还有零寄存器xzr/wzr和elr(Exception Link Register)。

9b8a13e724db7723ed6392ce45c96a1b.png

ARM64特殊寄存器示意图

zr(x31) (Zero Register),xzr/wzr分别代表 64/32 位,其作用就是 0,写进去代表丢弃结果,读出来是 0;

pc(Program Counter) 保存将要执行的指令的地址(由系统决定其值,不能由程序直接改写pc寄存器)。

状态寄存器 CPSR (Current Program Status Register)和其他寄存器不一样,其他寄存器是用来存放数据的,都是整个寄存器具有一个含义;而 CPSR 寄存器是按位起作用的,即,每一位都有专门的含义,记录特定的信息;CPSR 寄存器是 32 位的。

sp(Stack pointer)和elr(Exception Link Register)将在后续相关的部分具体介绍。

另外,还有PSTATE,但PSTATE不是一个寄存器,它表示的是保存当前进程状态信息的一组寄存器或者一些标志位信息的统称,当异常发生的时候这些信息就会保存到EL所对应的SPSR寄存器当中。

三种变址寻址方式

根据数据传输的时机以及在指令执行后基址寄存器是否被更新,寄存器变址有前变址([sp, #0x8])、回写前变址([sp, #0x8]!)和后变址([sp], #0x8)三种方式。如下以从内存中取数据的ldr指令为例来看看三种变址寻址方式的具体用法和作用。

ldr x5,[x6,#0x8]

如上汇编代码中使用了前变址寻址方式,相当于C代码 x5 = *(x6+0x8)。

ldr x5,[x6],#0x8

如上汇编代码中使用了后变址寻址方式,相当于C代码 x5 = *(x6)和x6 = x6+0x8。

    后变址与前变址的一个重要的区别:后变址用变址运算的结果更新了x6寄存器,这就是所谓的“回写”。显然回写是一种很有用的功能,因此ARM在前变址方式中又增加了一种回写前变址的寻址方式。为了与前变址方式做区分,回写前变址寻址方式要求在第2操作数的方括号后边添加符号“!”。

ldr x5,[x6,#0x8]!

如上汇编代码中使用了回写前变址方式,相当于C代码 x5 = *(x6+0x8)和x6 = x6+0x8

ARM64汇编指令

基本汇编指令

mov x1,x0

如上汇编代码将寄存器x0的值传送到寄存器x1,mov指令只能用于寄存器之间传值,寄存器和内存之间传值通过 ldr和 str指令实现。

str x0, [sp, #0x8]

如上汇编代码中str指令即Store存储的意思,是将x0寄存器的数据传送到sp+0x8地址值指向的存储空间,注意这里用到了前变址寻址方式。

ldr x5,[x6,#0x8]!

如上汇编代码中ldr指令即load加载的意思,是x6寄存器加0x8的和作为内存地址取其中存储的数据传送到x5,注意这里用到了回写前变址寻址方式,x6被回写为x6+0x8。

add x0,x1,x2

如上汇编代码中寄存器x1和x2的值相加后传送到x0。

sub x0,x1,x2

如上汇编代码中寄存器x1和x2的值相减后传送到x0。

函数调用相关的指令和寄存器

函数调用一般使用bl指令来实现。bl(Branch with Link)指令是带返回的跳转指令,大致执行过程为,先将下一指令地址(即函数返回地址)保存到寄存器 lr (x30)中,再进行跳转 。

bl     ffff000008dc566c
// 下一条指令的地址

如上汇编代码中bl在跳转到ffff000008dc566c地址(一般是一个函数)之前,先将下一条指令的地址保存到寄存器 lr (x30)中,以便函数返回时继续往下执行。

函数返回指令由ret指令实现。ret指令负责将寄存器 lr (x30)存入寄存器pc(Program Counter) 。注意pc(Program Counter) 保存将要执行的指令的地址,由系统决定其值,不能由程序直接改写pc寄存器,但是可以由ret一类的特殊指令间接改写pc寄存器的值。

ARM64汇编中入栈出栈的操作一般由stp和ldp指令实现。

入栈指令stp是str指令的变种指令,可以同时操作两个寄存器,如下汇编指令即是将 x29(fp), x30(lr) 的值存入 sp 偏移 16个字节的位置,即sp - 16的内存地址所在的16个字节的存储空间。注意这里用到了回写前变址寻址方式,sp被回写为sp - 16。需要注意的是ARM64中栈空间以16个字节作为一个存储单元。

stp x29, x30, [sp, #-16]!

出栈指令ldp是ldr指令的变种指令,可以同时操作两个寄存器,如下汇编指令即是在sp 偏移 16 个字节的内存地址取16个字节分别存入x29(fp)和x30(lr)寄存器。注意这里用到了后变址寻址方式,sp寄存器被回写为sp + 16。

ldpx29, x30, [sp], 16

ARM64汇编程序的注释

ARM64汇编程序的注释与C/C++程序的注释是大致相同的,特殊的地方是在ARM32位下,单行注释可以选择采用@或者//,到ARM64汇编程序的注释与C/C++程序的注释则保持了完全一致,即单行注释采用//,多行注释采用/* */。

C语言代码和ARM64汇编代码对比分析

我们以如下一段简单的C语言代码为例,来看看如何将它编译成ARM64汇编代码,以及ARM64汇编代码的是如何执行的。

int g(int x)
{
    return x + 3;
}
 
int f(int x)
{
    return g(x);
}
 
int main(void)
{
    return f(8) + 1;
}

将C语言代码编译成ARM64汇编代码的方式取决于您当前的环境,对于基于X86体系结构的主机用户来讲,将C语言代码编译成ARM64汇编代码需要安装交叉编译环境;而对于使用基于ARMv8架构的主机的用户则可以直接使用默认编译环境,比如多数中高端智能手机、采用华为鲲鹏处理的主机、部分版本的树莓派或采用苹果M1处理器的主机支持ARM64指令集的。

可以使用gcc -v命令查看一下当前默认编译环境的目标平台,如果Target是x86_64-linux-gnu则当前是基于X86体系结构的主机,如果Target是aarch64-linux-gnu则是支持ARM64的主机。目前多数个人电脑和普通服务器是X86体系结构,我们以X86主机为例来看看如何安装交叉编译环境以及将C语言代码编译成ARM64汇编代码。

sudo apt-get install gcc-aarch64-linux-gnu
aarch64-linux-gnu-gcc -S -o assembly_aarch64.s assembly.c

如上两行Shell命令可以安装gcc-aarch64-linux-gnu交叉编译环境,并使用aarch64-linux-gnu-gcc工具将上述C语言代码assembly.c编译为如下汇编代码assembly_aarch64.s。

.arch armv8-a
  .file  "assembly.c"
  .text
  .align  2
  .global  g
  .type  g, %function
g:
  sub  sp, sp, #16             //  sp = sp - 16
  str  w0, [sp, 12]            //  *(sp + 12) = w0
  ldr  w0, [sp, 12]            //  w0 = *(sp + 12)
  add  w0, w0, 3               //  w0 = w0 + 3
  add  sp, sp, 16              //  sp = sp + 16
  ret                          //  pc = x30
  .size  g, .-g
  .align  2
  .global  f
  .type  f, %function
f:
  stp  x29, x30, [sp, -32]!    //  *(sp-32) = x29和x30; sp = sp - 32;
  add  x29, sp, 0              //  x29 = sp + 0
  str  w0, [x29, 28]           //  *(x29+28) = w0
  ldr  w0, [x29, 28]           //  w0 =  *(x29+28)
  bl  g                        //  x30 = 下一指令地址;  pc = g;
  ldp  x29, x30, [sp], 32      //  x29和x30 = *(sp); sp = sp + 32;
  ret                          //  pc = x30
  .size  f, .-f
  .align  2
  .global  main
  .type  main, %function
main:
  stp  x29, x30, [sp, -16]!    //  *(sp-16) = x29和x30; sp = sp - 16;
  add  x29, sp, 0              //  x29 = sp + 0
  mov  w0, 8                   //  w0 = 8
  bl  f                        //  x30 = 下一指令地址// pc = f;
  add  w0, w0, 1               //  w0 = w0 + 1
  ldp  x29, x30, [sp], 16      //  x29和x30 = *(sp); sp = sp + 16;
  ret                          //  pc = x30
  .size  main, .-main
  .ident  "GCC: (Ubuntu/Linaro 7.5.0-3ubuntu1~18.04) 7.5.0"
  .section  .note.GNU-stack,"",@progbits

如上ARM64汇编代码中以"."开头的都是编译链接过程用到的辅助信息,我们只要看对应C代码三段汇编指令,从main开始,为了便于理解每一行右侧都给出了类似C伪代码的注释,您可以参考本文前述内容一行一行理解应该能搞清楚汇编代码的执行过程,甚至能弄清函数调用堆栈空间的变化过程。

本例没有涉及系统调用,在ARM Linux内核中,系统调用是一种特殊的异常,通常被归于同步异常的范畴,这是因为它是通过SVC指令触发的,SVC指令在ARMv8体系结构中被归于异常处理类指令,该指令能允许用户程序调用内核代码,即触发系统调用的执行。系统调用涉及到操作系统的结构问题,我们将在后续章节详细探讨。

  • 6
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码农孟宁

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值