ARM 彙編入門指南

本篇文章的目的是希望以一個例子的方式,能夠不那麼枯燥的的給大家簡單介紹一下 Android 或 iOS 這些移動終端上 ARM 架構的 CPU 是如何執行 ARM 彙編指令的。如果說程序員在學習任何一門語言的起點都是從學習寫 helloworld程序開始的,那麼本篇文章希望的就是成爲你學習 ARM 彙編的那第一篇入門教程,手把手的帶着你用 ARM 彙編手寫一個 helloworld 程序。

Hello, ARM

首先我們這裏是準備用 GNU ARM彙編來手寫一個 ARM64 架構的 helloworld 程序,那麼需要先準備如下幾個東西:

僞指令

以上準備好了,我們就可以開始新建一個文件名爲 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>

其中我們目前只需關注幾個重要的:

那麼這句話彙編翻譯成人話就是: " 將 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 種:

第 31 個專用寄存器的訪問方式有 4 種:

另外需要注意的,像 FP (X29) ,LRX30) 寄存器都不能和 SP(x31) 寄存器一樣用名字來訪問,而只能使用數字索引來訪問它們。

其實還有第 32 個專用寄存器,它就是 PC ( x32)寄存器,但是在 ARM 的彙編文檔裏面說明了,你無法在彙編中使用 PC 名稱的方式或者用 X32 數字索引的訪問它,因爲它不是給彙編用的,而是給 CPU 執行彙編指令時用的,它永遠記錄着當前 CPU 正在執行哪一句指令的地址。

在衆多寄存器中,我們目前只需要瞭解其中幾個重要的作用即可:

lNIIWl

內存佈局

在瞭解完了 ARM 架構的寄存器以後,我們接下來還需要大概瞭解幾個 ARM64 的內存佈局,首先一個 ARM64 的進行會擁有一個非常大的虛擬內存映射空間,其中又分爲兩大塊:

這裏我們只關心用戶地址,其中有分爲兩大塊:

其中我們知道棧內存首先是按照線程爲單元的,每個線程都有自己的棧內存塊,著名的 StackOverflow 所指的就是線程的棧溢出。然後每個線程的棧內存又可以根據函數的調用層級關係分爲不同的棧幀 ( Stack Frame )。因爲這裏咱不講編程基礎,本文默認讀者已經擁有相關的編程基礎知識,就不在贅述。

line #1

在瞭解了 ARM64 架構下的寄存器和內存佈局後,我們再回頭一行行的來理解 main 函數,先看第一句彙編:

sub sp, sp, #32

它作爲我們 main 函數的第一句,即在棧上面開啓了一個全新的棧幀 stack frame ,那麼第一件事情就是申請這個棧幀(或者函數)裏面所需的棧內存空間,因爲我們知道棧內存的生長方式是從高位向低位生長的,那麼從基地址做減法就是增長,做加法就是收縮。在這裏我們的 main 函數大概需要 32 bytes 的棧空間來實現一個 helloworld 的功能,所以先將棧幀指針 sp 向下移動了一點內存空間出來,即可在函數中使用棧來分配內存,放置我們的局部變量等。

從下面開始,我們在講解每一句彙編時,都會主要通過下面的圖標形式來說明,我們重點關注的是 CPU 是如何使用寄存器和內存來做計算的,因此只需要關注每執行一行彙編指令後,寄存器和內存的變化即可(紅色標註的),例如我們進入到 main 函數時的初始狀態下,內存和寄存器是這樣的:

0

其中我們重點關注的是 sp 寄存器,因爲我們這一句彙編主要就是修改 sp 寄存器的值來達到申請棧內存空間的目的。

我們的第一行彙編會將 sp 棧幀往低位移動 32 bytes,因此在 CPU 執行完這一句彙編指令後,內存和寄存器會變成如下的狀態:

1

NOTE: 棧擴大 32bytes 內存空間

line #2

在我們開闢了新的棧內存後,我們就開始用這些棧內存來保存數據了,這裏我們的 helloworld 程序的邏輯其實很簡單,那就是在 main 函數里面調用 printf 來打印一行 Hello World! 的信息出來。

那麼現在我們在 main 函數里面,準備去調用另一個函數 printf ,這就意味着我們需要在 main 函數這個棧幀裏面開啓一個新的棧幀來調用 printf

我們在【內存佈局】的一節已經提到了,每個線程的棧內存其實是按照 棧幀 (Stack Frame ) 爲單位分割的,每個函數都有一個單獨的棧幀。

隨着調用棧,在每個棧幀中我們需要一些專用的寄存器來保存當前的 CPU 上下文,例如我們在每個棧幀(或函數)都需要如下的寄存器來記錄這些信息:

其中 pcsp 寄存器,隨着程序的運行,都是實時更新的,但是例如 fplr 寄存器隨着程序的調用棧,在每個棧幀中的值都不一樣,例如我們 hello world 的調用棧大概會這樣的:

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

當前我們正處在 main 函數中,我們的 lr 寄存器記錄的是 main 函數的返回值地址,即它的調用者的地址,在執行完 main 函數後,我們是需要返回到這個地址去的。

但是現在我們準備在 main 函數中調用 printf 函數,那麼到 printf 函數中後,例如 lr 寄存器就需要用來保存 main 函數的地址作爲返回地址,因爲 printf 函數執行完了以後,我們希望能回到它的調用者即 main 函數中來繼續執行 main 函數里面後面的指令。

因此,爲了能讓 printf 函數能使用 lrfp 寄存器,可以修改它用來保存它棧幀的上下文狀態,那麼就需要在 main 函數里面,在準備調用 printf 函數之前,將現在咱們 main 函數的 lrfp 寄存器(以及其他所有需要保存的寄存器)的數據都先備份到棧內存上面,那麼 printf 函數就可以自由使用這些寄存器,執行自己的邏輯,並在執行完畢後通過 lr 寄存器返回到 main 函數中來,這時我們就可以再將之前備份到棧上面的舊的寄存器的值重新還原到寄存器中。

所以我們的第二句彙編,就是備份 fplr 兩個寄存器的值,例如 lr 寄存器裏面,現在保存着 main 函數的返回地址 (即它的調用者 __libc_init() 函數的地址),我們將這些寄存器的值從寄存器裏面保存到棧內存上去。

ARM64 彙編裏面,以 ST 開頭的指令都是將寄存器的值 Store 到內存地址上。

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

2

NOTE: 備份 x29(fp) 寄存器的值到棧上內存 NOTE: 備份 x30(lr) 寄存器的值到棧上內存

line #3

在我們備份了 fp 寄存器的值到棧內存上之後,我們就可以開始修改 fp 寄存器的值了,將它設置成新的棧幀的棧底,即 調用 printf 函數這個棧幀的棧底,在 printf 函數中,就可以通過 fp 寄存器來獲取到它的棧幀基地址。

add	x29, sp, #16                    // =16

3

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

4

NOTE: 將 w8 寄存器重置爲 0,準備用它來接收調用的子函數的返回值

line #5

使用 STUR 指令,將棧上的一個 32bit 的內存全部重置爲 0 .

stur	wzr, [x29, #-4]

5

NOTE: 將 [x29, #-4] 地址的內存重置爲 0

line #6

在調用一個函數前,我們準備了接收和保存函數的返回值,接下來我們就準備去真正去調用 printf 函數了,但是我們還忘了一點,那就是函數的傳參,printf 函數需要能接收到我們的參數,即 printf 函數的第一個參數:一個用於打印的字符串,在我們這裏就是 "Hello World!" 這個字符串,因爲我們的字符串是一個字面量,它是一個靜態全局的字符串,已經保存到內存裏面了,我們只需要查到這個字符串的地址即可。

我們通過 ADRP 指令去查找這個字符串的所在內存的頁的基地址,我們的字符串的標籤是 .L.str ,它的 .type 類型是一個 object的字符串。(這部分是由僞指令定義的,具體可查看文末完整的彙編代碼)

adrp	x0, .L.str

6

NOTE: 將字符串 “hello world” 所在的頁的基地址加載到 x0 寄存器中

line #7

上一句,我們得到的只是字符串所在的頁的基地址,我們還需要通過偏移地址計算出這個字符串的具體內存地址在哪裏。我們通過在上一句查出來的基地址的基礎上再增加一個偏移量即得到字符串的內存地址,並且我們用 w0 寄存器來保存它,用於將這個字符串作爲 printf 函數的參數傳遞進去。

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

7

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

8

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

line #9

一切準備好了,我們就可以真正使用 BL 指令來調用 printf 函數了,printf 函數的地址是通過 linker 鏈接到的 libc 內的 printf 函數,一般來說調用指令有多個,例如 B 指令,就是單純的跳轉到另一個地方去執行了,不準備返回了,是一張單程船票,而這裏我們使用的 BL 指令在跳轉到另一個地方,會先將當前指令的地址保存到 lr 寄存器中,便於跳轉到另一個地方之後還有座標可以傳送回來,是一張往返的套票。

bl	printf

9

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

10

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

line #11

這裏使用 MOV 指令,將 w8 寄存器的值移動到 w0 寄存器上,即將之前用於傳參的 w0 寄存器重置回了 0 了。

mov	w0, w8

11

NOTE: 將 w8 寄存器的值移動到 w0 寄存器上

line #12

到這裏,我們的 main 函數已經通過調用 printf 函數在屏幕上打印出來的 Hello World! 的文字,printf 函數已經返回到了我們的 main 函數,我們也重置了用於傳參的寄存器,接下來我們還需要恢復在調用 printf 函數之前備份的寄存器的值。

之前我們將 fplr 兩個寄存器的值,保存在棧內存上,現在我們做一個反操作,將棧內存上保存的值通過 LD 指令還原到寄存器中去。

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

12

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

13

NOTE: 全部出棧,棧縮小 32bytes 的內存空間。

line #14

最後一步,我們使用 RET 指令退出函數,它就是我們的 helloworld 程序裏 main 函數的 return 語句。到此我們的程序就寫完了。

ret

14

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;
}

僞指令參考表 (節選):

vKsJns

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0mSTH5y8QhpIZt-CcJD9Lg