本篇文章的目的是希望以一个例子的方式,能够不那么枯燥的的给大家简单介绍一下Android或iOS这些移动终端上ARM架构的CPU是如何执行ARM汇编指令的。如果说程序员在学习任何一门语言的起点都是从学习写 helloworld
程序开始的,那么本篇文章希望的就是成为你学习ARM汇编的那第一篇入门教程,手把手的带着你用ARM汇编手写一个 helloworld
程序。
首先我们这里是准备用 GNU ARM
汇编来手写一个 ARM64
架构的 helloworld
程序,那么需要先准备如下几个东西:
一个文本编辑器,这里我们用 vim
.
一个ARM64的编译器,这里我们用的是Android NDK里面自带的 clang
.
以上准备好了,我们就可以开始新建一个文件名为 main.S
的纯文本文件,然后用任意自己最心爱的文本编辑器( 对于我而言它永远是vim
) 来打开它,咱们先来起个头:
这里我们使用是 GNU ARM
汇编,其中以 .
开头的是汇编指令 (Assembler Directive
) 又或被称为伪指令( Pseudo-operatio
), 因为它们不属于ARM指令,因此被称为伪指令,这里我们先尽量忽略它们,因为我们的主要学习目的是学习真正的ARM汇编指令,而不是这些伪东西,如果想了解它们可以参考文末的附录(伪指令参考表),这里只需要看懂其中的一句伪指令即可:
这一句伪指令它定义了最重要的事情:在我们这个文件里面有一个叫做 main
名称的导出函数,它就是我们 helloworld
程序的入门函数。
然后我们就可以来书写我们的 helloworld
程序的 main
函数:
在 GNU ARM
汇编里面所有以 :
结尾的都会视为标签 ( label
),在这里我们定义一个叫做 main
的标签,并且使用 .type
伪指令定义这个标签的类型是一个函数(function
),到此我们就定义了我们的 main
函数。
上面这一段ARM汇编目前就和天书一样,你不认识它,它不认识你,没关系,接下来我们会一行一行的来学习它们究竟是什么意思,在看完这篇文章后,当你再看到它们时,它们会和你学过的任何一门语言的 helloworld
一样简单的。
下面我们先窥视一下第一行写得是什么东西:
这里我们需要先了解一下ARM汇编的格式,ARM指令使用的是 三地址码 , 它的格式如下:
其中我们目前只需关注几个重要的:
opcode
: 为指令,在我们第一句的指令是 sub
,表示减法。
Rd
: 为指令操作目的寄存器,在我们第一句中是 sp
寄存器。
Rn
: 为指令第一源操作数,在我们第一句中是 sp
寄存器
shifter_operand
: 数据处理指令,这里我们第一句是立即数寻址,即 #32
。
那么这句话汇编翻译成人话就是: "将 sp
寄存器的值减去 32
" ,例如伪代码:
我们现在虽然知道了这句汇编在做什么运算,但是它究竟是什么意思还是一头雾水,因为我们还不熟悉另外几个预备知识:ARM64架构下的的寄存器和内存布局。
要读懂ARM汇编,首先就必须对ARM寄存器有一个基础的认知,在ARM64架构下,CPU提供了33个寄存器, 其中前31个(0~30)是通用寄存器 (general-purpose integer registers
),最后2个(31,32)是专用寄存器(sp
寄存器和 pc
寄存器)。
前面0~30个通用寄存器的访问方式有2种:
当将其作为 32bit
寄存器的时候,使用 W0
~ W30
来引用它们。(数据保存在寄存器的低32位)
当将其作为 64bit
寄存器的时候,使用 X0
~ X30
来引用它们。
第31个专用寄存器的访问方式有4种:
当将其作为 32bit
栈帧指针寄存器(stack pointer
) 的时候,使用 WSP
来引用它。
当将其作为 62bit
栈帧指针寄存器(stack pointer
) 的时候,使用 SP
来引用它。
当将其作为 32bit
零寄存器( zero register
)的时候,使用 WZR
来引用它。
当将其作为 62bit
零寄存器( zero register
)的时候,使用 ZR
来引用它。
另外需要注意的,像 FP
(X29
) ,LR
(X30
) 寄存器都不能和 SP(x31)
寄存器一样用名字来访问,而只能使用数字索引来访问它们。
其实还有第32个专用寄存器,它就是 PC
( x32
)寄存器,但是在ARM的汇编文档里面说明了,你无法在汇编中使用 PC
名称的方式或者用 X32
数字索引的访问它,因为它不是给汇编用的,而是给CPU执行汇编指令时用的,它永远记录着当前CPU正在执行哪一句指令的地址。
在众多寄存器中,我们目前只需要了解其中几个重要的作用即可:
在了解完了ARM架构的寄存器以后,我们接下来还需要大概了解几个ARM64的内存布局,首先一个ARM64的进行会拥有一个非常大的虚拟内存映射空间,其中又分为两大块:
内核地址(0xffff_ffff_ffff_ffff
~ 0xffff_0000_0000_0000
范围的256TB的寻址空间),
用户地址 (0x0000_ffff_ffff_ffff
~ 0x0000000000000_0000
范围的256TB的寻址空间) 。
这里我们只关心用户地址,其中有分为两大块:
栈内存( Stack
),从高位向低位生长。
堆内存 ( Heap
), 从低位向高位生长。
其中我们知道栈内存首先是按照线程为单元的,每个线程都有自己的栈内存块,著名的 StackOverflow
所指的就是线程的栈溢出。然后每个线程的栈内存又可以根据函数的调用层级关系分为不同的栈帧( Stack Frame
)。因为这里咱不讲编程基础,本文默认读者已经拥有相关的编程基础知识,就不在赘述。
在了解了ARM64架构下的寄存器和内存布局后,我们再回头一行行的来理解 main
函数,先看第一句汇编:
它作为我们 main
函数的第一句,即在栈上面开启了一个全新的栈帧 stack frame
,那么第一件事情就是申请这个栈帧(或者函数)里面所需的栈内存空间,因为我们知道栈内存的生长方式是从高位向低位生长的,那么从基地址做减法就是增长,做加法就是收缩。在这里我们的 main
函数大概需要 32 bytes 的栈空间来实现一个 helloworld
的功能,所以先将栈帧指针 sp
向下移动了一点内存空间出来,即可在函数中使用栈来分配内存,放置我们的局部变量等。
从下面开始,我们在讲解每一句汇编时,都会主要通过下面的图标形式来说明,我们重点关注的是CPU是如何使用寄存器和内存来做计算的,因此只需要关注每执行一行汇编指令后,寄存器和内存的变化即可(红色标注的),例如我们进入到 main
函数时的初始状态下,内存和寄存器是这样的:
其中我们重点关注的是 sp
寄存器,因为我们这一句汇编主要就是修改 sp
寄存器的值来达到申请栈内存空间的目的。
我们的第一行汇编会将 sp
栈帧往低位移动 32 bytes,因此在CPU执行完这一句汇编指令后,内存和寄存器会变成如下的状态:
在我们开辟了新的栈内存后,我们就开始用这些栈内存来保存数据了,这里我们的 helloworld
程序的逻辑其实很简单,那就是在 main
函数里面调用 printf
来打印一行 Hello World!
的信息出来。
那么现在我们在 main
函数里面,准备去调用另一个函数 printf
,这就意味着我们需要在 main
函数这个栈帧里面开启一个新的栈帧来调用 printf
。
我们在【内存布局】的一节已经提到了,每个线程的栈内存其实是按照 栈帧 (Stack Frame
)为单位分割的,每个函数都有一个单独的栈帧。
随着调用栈,在每个栈帧中我们需要一些专用的寄存器来保存当前的CPU上下文,例如我们在每个栈帧(或函数)都需要如下的寄存器来记录这些信息:
pc
寄存器,记录当前CPU正在哪个指令。
sp
寄存器,记录当前栈顶。
fp
寄存器,记录当前栈的栈底。
lr
寄存器,记录当前栈的返回地址,即这个函数调用完成后应该返回到哪里。
其中 pc
和 sp
寄存器,随着程序的运行,都是实时更新的,但是例如 fp
和 lr
寄存器随着程序的调用栈,在每个栈帧中的值都不一样,例如我们 hello world
的调用栈大概会这样的:
当前我们正处在 main
函数中,我们的 lr
寄存器记录的是 main
函数的返回值地址,即它的调用者的地址,在执行完 main
函数后,我们是需要返回到这个地址去的。
但是现在我们准备在 main
函数中调用 printf
函数,那么到 printf
函数中后,例如 lr
寄存器就需要用来保存 main
函数的地址作为返回地址,因为 printf
函数执行完了以后,我们希望能回到它的调用者即 main
函数中来继续执行 main
函数里面后面的指令。
因此,为了能让 printf
函数能使用 lr
和 fp
寄存器,可以修改它用来保存它栈帧的上下文状态,那么就需要在 main
函数里面,在准备调用 printf
函数之前,将现在咱们 main
函数的 lr
和 fp
寄存器(以及其他所有需要保存的寄存器)的数据都先备份到栈内存上面,那么 printf
函数就可以自由使用这些寄存器,执行自己的逻辑,并在执行完毕后通过 lr
寄存器返回到 main
函数中来,这时我们就可以再将之前备份到栈上面的旧的寄存器的值重新还原到寄存器中。
所以我们的第二句汇编,就是备份 fp
和 lr
两个寄存器的值,例如 lr
寄存器里面,现在保存着 main
函数的返回地址 (即它的调用者 __libc_init()
函数的地址),我们将这些寄存器的值从寄存器里面保存到栈内存上去。
在 ARM64
汇编里面,以 ST
开头的指令都是将寄存器的值 Store 到内存地址上。
在我们备份了 fp
寄存器的值到栈内存上之后,我们就可以开始修改 fp
寄存器的值了,将它设置成新的栈帧的栈底,即 调用 printf
函数这个栈帧的栈底,在 printf
函数中,就可以通过 fp
寄存器来获取到它的栈帧基地址。
然后,我们希望调用 printf
函数,这个函数是有返回值的,类型为一个 int
值,在调动完 printf
函数后,printf
函数会希望能把它的返回值传递给它的调用者(即我们的 main
函数),那么一般情况下都是通过寄存器传值的,例如这里我们提前将 w8
寄存器的值重置为0,printf
函数就可以将返回值放到 w8
寄存器中,它的调用者 main
函数就可以通过读取 w8
寄存器来接收到 printf
函数的返回值。
这里我们通过 MOV
指令,将零寄存器(其值永远是0)的值移动到 w8
寄存器上,说人话就是将 w8
寄存器里面的值都设置为 0
, 这个操作和我们写代码时,初始化一个int型的变量,将其先设置为0一样,然后将其传入到被调用的函数中去,被调用的函数将返回值设置到该变量上的逻辑是一样的。
使用 STUR
指令,将栈上的一个 32bit
的内存全部重置为 0
.
在调用一个函数前,我们准备了接收和保存函数的返回值,接下来我们就准备去真正去调用 printf
函数了,但是我们还忘了一点,那就是函数的传参,printf
函数需要能接收到我们的参数,即 printf
函数的第一个参数:一个用于打印的字符串,在我们这里就是 "Hello World!" 这个字符串,因为我们的字符串是一个字面量,它是一个静态全局的字符串,已经保存到内存里面了,我们只需要查到这个字符串的地址即可。
我们通过 ADRP
指令去查找这个字符串的所在内存的页的基地址,我们的字符串的标签是 .L.str
,它的 .type
类型是一个 object
的字符串。(这部分是由伪指令定义的,具体可查看文末完整的汇编代码)
【学习技术大群607439754】
【网盘免费资料包,大家需要的自行领取,都是免费的】:
嵌入式物联网 22个STM32项目、大赛作品,【华清远见发放资料包】http://makerschool.mikecrm.com/f4wjYBB
【下方分享一些免费教程资料,感兴趣的可以看一下】:
走进ARM-ARM开发环境搭建
ARM体系架构
ARM之蜂鸣器播放音乐
ARM之中断GIC分析
ARM体系结构A53新特性
ARM-RTC时钟的运用
Arm引领智能物联新时代P
最新ARM微控制器在物联网系统中的应用案例
高效学习ARM汇编语言
颠覆ARM,RISC-V闪亮登场!