ARM汇编入门指南

本篇文章的目的是希望以一个例子的方式,能够不那么枯燥的的给大家简单介绍一下Android或iOS这些移动终端上ARM架构的CPU是如何工作的,这其实是一件很有挑战的事情,因为学习ARM汇编真的是一件非常枯燥的事情。

如果说程序员在学习任何一门语言的起点都是从学习写 helloworld程序开始的,那么本篇文章希望的就是成为你学习ARM汇编的那第一篇入门教程,手把手的带着你用ARM汇编手写一个 helloworld 程序。

​ 

Hello, ARM

首先我们这里是准备用 GNU ARM汇编来手写一个 ARM64 架构的 helloworld 程序,那么需要先准备如下几个东西:

  • 一个文本编辑器,这里我们用 vim .
  • 一个ARM64的编译器,这里我们用的是Android NDK里面自带的 clang.

​ 

伪指令

以上准备好了,我们就可以开始新建一个文件名为 main.S 的纯文本文件,然后用任意自己最心爱的文本编辑器( 对于我而言它永远是vim) 来打开它,咱们先来起个头:

.text
    .file   "main.c"
    .globl  main                            // -- Begin function main
    .p2align    2

​ 

这里我们使用是 GNU ARM 汇编,其中以 . 开头的是汇编指令 (Assembler Directive ) 又或被称为伪指令( Pseudo-operatio), 因为它们不属于ARM指令,因此被称为伪指令,这里我们先尽量忽略它们,因为我们的主要学习目的是学习真正的ARM汇编指令,而不是这些伪东西,如果想了解它们可以参考文末的附录(伪指令参考表),这里只需要看懂其中的一句伪指令即可:

.globl main

这一句伪指令它定义了最重要的事情:在我们这个文件里面有一个叫做 main 名称的导出函数,它就是我们 helloworld 程序的入门函数。

​ 

main函数

然后我们就可以来书写我们的 helloworld 程序的 main函数:

.type   main,@function
main:                                   // @main
// %bb.0:
    sub sp, sp, #32                     // =32
    stp x29, x30, [sp, #16]             // 16-byte Folded Spill
    add x29, sp, #16                    // =16
    mov w8, wzr
    stur    wzr, [x29, #-4]
    adrp    x0, .L.str
    add x0, x0, :lo12:.L.str
    str w8, [sp, #8]                    // 4-byte Folded Spill
    bl  printf
    ldr w8, [sp, #8]                    // 4-byte Folded Reload
    mov w0, w8
    ldp x29, x30, [sp, #16]             // 16-byte Folded Reload
    add sp, sp, #32                     // =32
    ret

在 GNU ARM 汇编里面所有以 : 结尾的都会视为标签 ( label ),在这里我们定义一个叫做 main 的标签,并且使用 .type 伪指令定义这个标签的类型是一个函数(function),到此我们就定义了我们的 main 函数。

​ 

汇编指令

上面这一段ARM汇编目前就和天书一样,你不认识它,它不认识你,没关系,接下来我们会一行一行的来学习它们究竟是什么意思,在看完这篇文章后,当你再看到它们时,它们会和你学过的任何一门语言的 helloworld 一样简单的。

​ 

下面我们先窥视一下第一行写得是什么东西:

sub sp, sp, #32

​ 

这里我们需要先了解一下ARM汇编的格式,ARM指令使用的是 三地址码 , 它的格式如下:

<opcode> {<cond>} {S} <Rd>,<Rn>,<shifter_operand>

其中我们目前只需关注几个重要的:

  • opcode: 为指令,在我们第一句的指令是 sub ,表示减法。
  • Rd: 为指令操作目的寄存器,在我们第一句中是 sp 寄存器。
  • Rn: 为指令第一源操作数,在我们第一句中是 sp 寄存器
  • shifter_operand: 数据处理指令,这里我们第一句是立即数寻址,即 #32

​ 

那么这句话汇编翻译成人话就是: "将 sp 寄存器的值减去 32" ,例如伪代码:

sp = 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) ,LRX30) 寄存器都不能和 SP(x31) 寄存器一样用名字来访问,而只能使用数字索引来访问它们。

其实还有第32个专用寄存器,它就是 PC ( x32)寄存器,但是在ARM的汇编文档里面说明了,你无法在汇编中使用 PC 名称的方式或者用 X32 数字索引的访问它,因为它不是给汇编用的,而是给CPU执行汇编指令时用的,它永远记录着当前CPU正在执行哪一句指令的地址。

​ 

在众多寄存器中,我们目前只需要了解其中几个重要的作用即可:

寄存器说明
X0 寄存器用来保存返回值(或传参)
X1 ~ X7 寄存器用来保存函数的传参
X8寄存器也可以用来保存返回值
X9 ~ X28寄存器一般寄存器,无特殊用途
x29(FP)寄存器用来保存栈底地址
X30 (LR)寄存器用来保存返回地址
X31(SP) 寄存器用来保存栈顶地址
X31(ZR)寄存器零寄存器,恒为0
X32(PC)寄存器用来保存当前执行的指令的地址

​ 

内存布局

在了解完了ARM架构的寄存器以后,我们接下来还需要大概了解几个ARM64的内存布局,首先一个ARM64的进行会拥有一个非常大的虚拟内存映射空间,其中又分为两大块:

  • 内核地址(0xffff_ffff_ffff_ffff ~ 0xffff_0000_0000_0000范围的256TB的寻址空间),
  • 用户地址 (0x0000_ffff_ffff_ffff ~ 0x0000000000000_0000范围的256TB的寻址空间) 。

这里我们只关心用户地址,其中有分为两大块:

  • 栈内存( Stack),从高位向低位生长。
  • 堆内存 ( Heap ), 从低位向高位生长。

其中我们知道栈内存首先是按照线程为单元的,每个线程都有自己的栈内存块,著名的 StackOverflow 所指的就是线程的栈溢出。然后每个线程的栈内存又可以根据函数的调用层级关系分为不同的栈帧( Stack Frame )。因为这里咱不讲编程基础,本文默认读者已经拥有相关的编程基础知识,就不在赘述。

​ 

line #1

在了解了ARM64架构下的寄存器和内存布局后,我们再回头一行行的来理解 main 函数,先看第一句汇编:

sub sp, sp, #32

它作为我们 main 函数的第一句,即在栈上面开启了一个全新的栈帧 stack frame ,那么第一件事情就是申请这个栈帧(或者函数)里面所需的栈内存空间,因为我们知道栈内存的生长方式是从高位向低位生长的,那么从基地址做减法就是增长,做加法就是收缩。在这里我们的 main函数大概需要 32 bytes 的栈空间来实现一个 helloworld 的功能,所以先将栈帧指针 sp 向下移动了一点内存空间出来,即可在函数中使用栈来分配内存,放置我们的局部变量等。

​ 

从下面开始,我们在讲解每一句汇编时,都会主要通过下面的图标形式来说明,我们重点关注的是CPU是如何使用寄存器和内存来做计算的,因此只需要关注每执行一行汇编指令后,寄存器和内存的变化即可(红色标注的),例如我们进入到 main 函数时的初始状态下,内存和寄存器是这样的:

其中我们重点关注的是 sp 寄存器,因为我们这一句汇编主要就是修改 sp 寄存器的值来达到申请栈内存空间的目的。

我们的第一行汇编会将 sp 栈帧往低位移动 32 bytes,因此在CPU执行完这一句汇编指令后,内存和寄存器会变成如下的状态:

NOTE: 栈扩大32bytes内存空间

line #2

在我们开辟了新的栈内存后,我们就开始用这些栈内存来保存数据了,这里我们的 helloworld程序的逻辑其实很简单,那就是在 main 函数里面调用 printf 来打印一行 Hello World! 的信息出来。

那么现在我们在 main 函数里面,准备去调用另一个函数 printf ,这就意味着我们需要在 main 函数这个栈帧里面开启一个新的栈帧来调用 printf 。

我们在【内存布局】的一节已经提到了,每个线程的栈内存其实是按照 栈帧 (Stack Frame )为单位分割的,每个函数都有一个单独的栈帧。

随着调用栈,在每个栈帧中我们需要一些专用的寄存器来保存当前的CPU上下文,例如我们在每个栈帧(或函数)都需要如下的寄存器来记录这些信息:

  • pc 寄存器,记录当前CPU正在哪个指令。
  • sp 寄存器,记录当前栈顶。
  • fp 寄存器,记录当前栈的栈底。
  • lr 寄存器,记录当前栈的返回地址,即这个函数调用完成后应该返回到哪里。

其中 pc 和 sp 寄存器,随着程序的运行,都是实时更新的,但是例如 fp 和 lr 寄存器随着程序的调用栈,在每个栈帧中的值都不一样,例如我们 hello world 的调用栈大概会这样的:

#0 printf()
#1 main()       <- current pc
#2 libc.init()

当前我们正处在 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 到内存地址上。

stp x29, x30, [sp, #16]             // 16-byte Folded Spill

NOTE: 备份x29(fp)寄存器的值到栈上内存
NOTE: 备份x30(lr)寄存器的值到栈上内存

line #3

在我们备份了 fp 寄存器的值到栈内存上之后,我们就可以开始修改 fp 寄存器的值了,将它设置成新的栈帧的栈底,即 调用 printf 函数这个栈帧的栈底,在 printf 函数中,就可以通过 fp 寄存器来获取到它的栈帧基地址。

add x29, sp, #16                    // =16

NOTE: 用x29(fp)寄存器保存新的栈底地址,准备调用子函数

line #4

然后,我们希望调用 printf 函数,这个函数是有返回值的,类型为一个 int 值,在调动完 printf 函数后,printf 函数会希望能把它的返回值传递给它的调用者(即我们的 main 函数),那么一般情况下都是通过寄存器传值的,例如这里我们提前将 w8 寄存器的值重置为0,printf 函数就可以将返回值放到 w8 寄存器中,它的调用者 main 函数就可以通过读取 w8寄存器来接收到 printf 函数的返回值。

这里我们通过 MOV 指令,将零寄存器(其值永远是0)的值移动到 w8 寄存器上,说人话就是将 w8 寄存器里面的值都设置为 0 , 这个操作和我们写代码时,初始化一个int型的变量,将其先设置为0一样,然后将其传入到被调用的函数中去,被调用的函数将返回值设置到该变量上的逻辑是一样的。

mov w8, wzr

NOTE: 将w8寄存器重置为0,准备用它来接收调用的子函数的返回值

line #5

使用 STUR 指令,将栈上的一个 32bit 的内存全部重置为 0 .

stur    wzr, [x29, #-4]

NOTE: 将[x29, #-4]地址的内存重置为0

line #6

在调用一个函数前,我们准备了接收和保存函数的返回值,接下来我们就准备去真正去调用 printf 函数了,但是我们还忘了一点,那就是函数的传参,printf 函数需要能接收到我们的参数,即 printf 函数的第一个参数:一个用于打印的字符串,在我们这里就是 "Hello World!" 这个字符串,因为我们的字符串是一个字面量,它是一个静态全局的字符串,已经保存到内存里面了,我们只需要查到这个字符串的地址即可。

我们通过 ADRP 指令去查找这个字符串的所在内存的页的基地址,我们的字符串的标签是 .L.str ,它的 .type 类型是一个 object的字符串。(这部分是由伪指令定义的,具体可查看文末完整的汇编代码)

adrp    x0, .L.str

NOTE: 将字符串 “hello world”所在的页的基地址加载到x0寄存器中

line #7

上一句,我们得到的只是字符串所在的页的基地址,我们还需要通过偏移地址计算出这个字符串的具体内存地址在哪里。我们通过在上一句查出来的基地址的基础上再增加一个偏移量即得到字符串的内存地址,并且我们用 w0 寄存器来保存它,用于将这个字符串作为 printf 函数的参数传递进去。

add x0, x0, :lo12:.L.str

NOTE: 计算“hello world”的偏移地址保存到x0寄存器中

line #8

虽然我们在 line #4 里面重置了 w8 寄存器用于接收 printf 函数的返回值,但当我们通过寄存器接收到返回值后,我们还需要栈上的一个内存空间来保存这个返回值,因此在调用这个函数前提前在栈内存上为它准备一个内存地址来存放函数的返回值(即 w8 寄存器里的值)。

这里我们也是通过 MOV 指令,将零寄存器(WZR )的值(即0)移动到栈内存的32bit内存空间,说人话就是初始化一个 32bit 的内存空间,将这个内存块的数据都清零,准备用来保存 printf 函数的返回值。

str w8, [sp, #8]                    // 4-byte Folded Spill

NOTE: 将w8寄存器中的值保存到[sp, #8]的内存地址上

line #9

一切准备好了,我们就可以真正使用 BL 指令来调用 printf 函数了,printf 函数的地址是通过 linker 链接到的 libc 内的 printf 函数,一般来说调用指令有多个,例如 B 指令,就是单纯的跳转到另一个地方去执行了,不准备返回了,是一张单程船票,而这里我们使用的 BL 指令在跳转到另一个地方,会先将当前指令的地址保存到 lr 寄存器中,便于跳转到另一个地方之后还有坐标可以传送回来,是一张往返的套票。

bl  printf

NOTE: x0寄存器保存着  printf函数的传参,即指向字符串“hello world”的地址NOTE: 调用并跳转到  printf 函数之前,将当前的地址作为返回地址保存在x30(lr)寄存器中

line #10

在 printf 函数执行完了以后,它会把函数的返回值(一个32bit的int值)放在 w8 寄存器中,就和电影里面的特务接头一样,我们按照事前约定好的去某个指定的地方(这里是w8 寄存器)里面去拿结果,即可得到最新的情报(即 printf 函数的返回值),并且我们使用 LDR 指令将 w8 寄存器的这个返回值保存到栈内存上。

ldr w8, [sp, #8]                    // 4-byte Folded Reload

NOTE: 将w8寄存器的值保存到[sp,#8]的内存地址上

line #11

这里使用 MOV 指令,将 w8 寄存器的值移动到 w0 寄存器上,即将之前用于传参的 w0 寄存器重置回了 0 了。

mov w0, w8

NOTE: 将w8寄存器的值移动到w0寄存器上

line #12

到这里,我们的 main 函数已经通过调用 printf 函数在屏幕上打印出来的 Hello World! 的文字,printf 函数已经返回到了我们的 main 函数,我们也重置了用于传参的寄存器,接下来我们还需要恢复在调用 printf 函数之前备份的寄存器的值。

之前我们将 fp 和 lr 两个寄存器的值,保存在栈内存上,现在我们做一个反操作,将栈内存上保存的值通过 LD 指令还原到寄存器中去。

ldp x29, x30, [sp, #16]             // 16-byte Folded Reload2

NOTE: 还原之前保存在栈内存上的FP的值到x29(fp)寄存器中
NOTE: 还原之前保存在栈内存上的LR的值到x30(lr)寄存器中

line #13

咱们的 main 函数已经完成了它的历史使命,成功的打印出了 Hello World!,它作为一个栈帧也准备退出了,在进入 main 函数一开头的时候,我们在第一句汇编里面,通过 SUB 指令申请了一个 32 Bytes 大小的栈内存空间用来搞事情,现在事情办妥了以后,我们有借有还,把申请的 32 Bytes 栈内存空间通过 ADD 指令给还回去,将栈顶还原到调用 main 函数之前的位置,我们轻轻的来轻轻的走,不带着一byte的内存。

add sp, sp, #32                     // =32

NOTE: 全部出栈,栈缩小32bytes的内存空间。

line #14

最后一步,我们使用 RET 指令退出函数,它就是我们的 helloworld 程序里 main 函数的 return 语句,到此我们的程序就写完了。

ret

NOTE: 函数返回,返回值通过x0寄存器返回给调用者

​ 

结语

在写下了这14句汇编以后,我们就可以使用 clang 编译器将其编译成可执行的二进制文件:

$ aarch64-linux-android29-clang -o main_arm main.S

然后我们可以将它放到任何一台 ARM64 CPU的机器,如大部分的Android机器,或者树莓派等单片机上运行了,我们就可以看见学习一门语言最亲切的打印语句了,这里我们使用的是Android自带的 LLDB 调试器在真机上运行的:

$ gdbclient.py -r /data/local/tmp/main_arm
$ Hello World!

到此你就基本学会了如何用ARM汇编手写一个 helloworld 程序,希望这篇文章真的能带大家走进ARM汇编的世界里一起学习,路漫漫兮。

​ 

附录

本文中完整的汇编代码:

.text
    .file   "main.c"
    .globl  main                            // -- Begin function main
    .p2align    2
    .type   main,@function
main:                                   // @main
// %bb.0:
    sub sp, sp, #32                     // =32   申请32bytes的栈空间
    stp x29, x30, [sp, #16]             // 16-byte Folded Spill  将 FP(x29), LR(x30) 保存在栈上
    add x29, sp, #16                    // =16   缩小栈大小16bytes 
    mov w8, wzr                         // 将 zero寄存器的值0 移动到 w8 寄存器
    stur    wzr, [x29, #-4]             // 
    adrp    x0, .L.str
    add x0, x0, :lo12:.L.str
    str w8, [sp, #8]                    // 4-byte Folded Spill
    bl  printf
    ldr w8, [sp, #8]                    // 4-byte Folded Reload
    mov w0, w8
    ldp x29, x30, [sp, #16]             // 16-byte Folded Reload
    add sp, sp, #32                     // =32
    ret
.Lfunc_end0:
    .size   main, .Lfunc_end0-main
                                        // -- End function
    .type   .L.str,@object                  // @.str
    .section    .rodata.str1.1,"aMS",@progbits,1
.L.str:
    .asciz  "Hello World!\n"
    .size   .L.str, 14

    .ident  "Android (7155654, based on r399163b1) clang version 11.0.5 (https://android.googlesource.com/toolchain/llvm-project 87f1315dfbea7c137aa2e6d362dbb457e388158d)"
    .section    ".note.GNU-stack","",@progbits

​ 

本文汇编对应的C源码:

#include <stdio.h>

int main() {
   printf("Hello World!\n");
   return 0;
}

​ 

伪指令参考表(节选):

GNU Assembler DirectiveNote
.textTells `as` to assemble the following statements onto the end of the text subsection numbered subsection, which is an absolute expression. If subsection is omitted, subsection number zero is used.
.file`.file` (which may also be spelled `.app-file') tells `as` that we are about to start a new logical file. string is the new file name. In general, the filename is recognized whether or not it is surrounded by quotes `"';
.globl`.global` makes the symbol visible to `ld`. If you define symbol in your partial program, its value is made available to other partial programs that are linked with it. Otherwise, symbol takes its attributes from a symbol of the same name from another file linked into the same program.
.p2alignPad the location counter (in the current subsection) to a particular storage boundary. The first expression (which must be absolute) is the number of low-order zero bits the location counter must have after advancement. For example `.p2align 3' advances the location counter until it a multiple of 8. If the location counter is already a multiple of 8, no change is needed.
.sectionUse the `.section` directive to assemble the following code into a section named name.
.asciz`.asciz` is just like `.ascii`, but each string is followed by a zero byte. The "z" in `.asciz' stands for "zero".

参考资料

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值