原文地址:linux內核1-GNU彙編入門_X86-64&ARM
1 引言
爲了閱讀Linux內核源代碼,是需要一些彙編語言知識的。因爲與架構相關的代碼基本上都是用匯編語言編寫的,所以掌握一些基本的彙編語言語法,能夠更好地理解Linux內核源代碼,甚至可以對各種架構的差異有一個更深入的理解。
大部分人可能認爲彙編語言晦澀難懂,閱讀手冊又冗長乏味。但是,經過本人的經驗,可能常用的指令也就是30個。許多其它的指令都是解決特定的情況而出現,比如浮點運算和多媒體指令。所以,本文就從常用指令出發,基於GNU彙編語言格式,對x86_64架構和ARM架構下的指令做了一個入門介紹。學習完這篇文章,希望可以對彙編有一個基本的理解,並能夠解決大部分問題。
閱讀本文需要一些硬件架構的知識。必要的時候,可以翻閱Intel Software Developer Manual和ARM Architecture Reference Manual。
2 開源彙編工具
對於相同的芯片架構,不同的芯片製造商或者其它開源工具可能會有不同的語法格式。所以,本文支持GNU編譯器和彙編器,分別是gcc
和as
(有時候也稱爲gas
)。
將C代碼轉換成彙編代碼,是一種非常好的學習方式。所以,可以通過在編譯選項中加入-S
標誌,生成彙編目標文件。在類Unix系統,彙編源代碼文件使用.s
的後綴標記。
比如,運行gcc -S hello.c -o hello.s
編譯命令,編譯hello程序:
#include <stdio.h>
int main( int argc, char *argv[] )
{
printf("hello %s\n","world");
return 0;
}
可以在hello.s
文件中看到如下類似的輸出:
.file "test.c"
.data
.LC0:
.string "hello %s\n"
.LC1:
.string "world"
.text
.global main
main:
PUSHQ %rbp
MOVQ %rsp, %rbp
SUBQ $16, %rsp
MOVQ %rdi, -8(%rbp)
MOVQ %rsi, -16(%rbp)
MOVQ $.LC0, %rax
MOVQ $.LC1, %rsi
MOVQ %rax, %rdi
MOVQ $0, %rax
CALL printf
MOVQ $0, %rax
LEAVE
RET
從上邊的彙編代碼中可以看出,彙編代碼大概由三部分組成:
-
僞指令
僞指令前綴一個小數點
.
,供彙編器、鏈接器或者調試器使用。比如,.file
記錄最初的源文件名稱,這個名稱對調試器有用;.data
,表明該部分的內容是程序的數據段;.text
,表明接下來的內容是程序代碼段的內容;.string
,表示一個數據段中的字符串常量;.global main
,表示符號main
是一個全局符號,可以被其它代碼模塊訪問。 -
標籤
標籤是由編譯器產生,鏈接器使用的一種引用符號。本質上,就是對代碼段的一個作用域打上標籤,方便鏈接器在鏈接階段將所有的代碼拼接在一起。所以,標籤就是鏈接器的一種助記符。
-
彙編指令
真正的彙編代碼,其實就是機器碼的助記符。GNU彙編對大小寫不敏感,但是爲了統一,我們一般使用大寫。
彙編代碼編譯成可執行文件,可以參考下面的代碼編譯示例:
% gcc hello.s -o hello
% ./hello
hello world
把彙編代碼生成目標文件,然後可以使用nm
工具顯示代碼中的符號,參考下面的內容:
% gcc hello.s -c -o hello.o
% nm hello.o
0000000000000000 T main
U printf
nm -> 是names的縮寫,nm命令主要是用來列出某些文件中的符號(換句話說就是一些函數和全局變量)。
上面的代碼顯示的符號對於鏈接器都是可用的。main
出現在目標文件的代碼段(T),位於地址0處,也就是說位於文件的開頭;printf
未定義(U),因爲它需要從庫文件中鏈接。但是像.LC0
之類的標籤出現,因爲它們沒有使用.global
,所以說對於鏈接器是無用的。
編寫C代碼,然後編譯成彙編代碼。這是學習彙編一個好的開始。
3 X86彙編語言
X86是一個通用術語,指從最初的IBM-PC中使用的Intel-8088處理器派生(或兼容)的一系列微處理器,包括8086、80286、386、486以及其它許多處理器。每一代cpu都增加了新的指令和尋址模式(從8位到16位再到32位)。同時還保留了與舊代碼的向後兼容性。各種競爭對手(如AMD)生產的兼容芯片也實現了相同的指令集。
但是,到了64位架構的時候,Intel打破了這個傳統,引入了新的架構(IA64)和名稱(Itanium),不再向後兼容。它還實現了一種新的技術-超長指令字(VLIW),在一個Word中實現多個併發操作。因爲指令級的併發操作可以顯著提升速度。
AMD還是堅持老方法,實現的64位架構(AMD64)向後兼容Intel和AMD芯片。不論兩種技術的優劣,AMD的方法首先贏得了市場,隨後Intel也生產自己的64位架構Intel64,並與AMD64和它自己之前的產品兼容。所以,X86-64是一個通用術語,包含AMD64和Intel64架構。
X86-64是複雜指令集CISC的代表。
3.1 寄存器和數據類型
X86-64具有16個通用目的64位寄存器:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
%rax | %rbx | %rcx | %rdx | %rsi | %rdi | %rbp | %rsp | %r8 | %r9 | %r10 | %r11 | %r12 | %r13 | %r14 | %r15 |
說它們是通用寄存器是不完全正確的,因爲早期的CPU設計寄存器是專用的,不是所有的指令都能用到每一個寄存器。從名稱上就可以看出來,前八個寄存器的作用,比如rax就是一個累加器。
AT&T語法-Intel語法
GNU使用傳統的AT&T語法,許多類Unix操作系統使用這種風格,與DOS和Windows上用的Intel語法是不同的。
下面一條指令是符合AT&T語法:MOVQ %RSP, %RBP
MOVQ是指令,%表明RSP和RBP是寄存器。AT&T語法,源地址在前,目的地址在後。
Intel語法省略掉%,參數順序正好相反。同樣的指令,如下所示:
MOVQ RBP, RSP
所以,看%就能區分是AT&T語法,還是Intel語法。
隨着設計的發展,新的指令和尋址模式被添加進來,使得這些寄存器幾乎一樣了。其餘的指令,尤其是和字符串處理相關的指令,要求使用rsi和rdi寄存器。另外,還有兩個寄存器專門爲棧指針寄存器(rsp)和基址指針寄存器(rbp)保留。最後的8個寄存器沒有特殊的限制。
隨着處理器從8位一直擴展到64位,有一些寄存器還能拆分使用。rax的低八位是一個8位寄存器al,接下來的8位稱爲ah。如果把rax的低16位組合起來就是ax寄存器,低32位就是累加器eax,整個64位纔是rax寄存器。這樣設計的目的是向前兼容,具體可以參考下圖:
圖1: X86 寄存器結構
r8-r15,這8個寄存器具有相同的結構,就是命名機制不同。
圖2: X86 寄存器結構
爲了簡化描述,我們還是着重講64位寄存器。但是,大多數編譯器支持混合模式:一個字節可以表示一個布爾型;32位對於整數運算就足夠了,因爲大多數程序不需要大於2^32以上的整數值;64位類型常用於內存尋址,能夠使虛擬地址的空間理論上可以達到1800萬TB(1TB=1024GB)。
3.2 尋址模式
MOV指令可以使用不同的尋址模式,在寄存器和內存之間搬運數據。使用B、W、L和Q作爲後綴,添加在指令後面,決定操作的數據的位數:
後綴 | 名稱 | 大小 |
---|---|---|
B | BYTE | 1 字節(8位) |
W | WORD | 2 字節(16位) |
L | LONG | 4 字節(32位) |
Q | QUADWORD | 8 字節(64位) |
MOVB移動一個字節,MOVW移動2個字節,MOVL移動4個字節,MOVQ移動8個字節。在某些情況下,可以省略掉這個後綴,編譯器可以推斷正確的大小。但還是建議加上後綴。
MOV指令可以使用下面幾種尋址模式:
-
全局符號
一般給其定義一個簡單的名稱,通過這個名稱來引用,比如x、printf之類的。編譯器會將其翻譯成絕對地址或用於地址計算。
-
立即數
使用美元符號$標記,比如$56。但是立即數的使用是有限制範圍的。
-
寄存器
使用寄存器尋址,比如%rbx。
-
間接引用
通過寄存器中包含的地址進行尋址,比如
(%rsp)
,表示引用%rsp
指向的那個值。 -
基址變址尋址
在間接引用的基礎上再加上一個常數作爲地址進行尋址。比如
-16(%rcx)
,就是寄存器rcx中的地址再減去16個字節的地址處的內容。這種模式對於操作堆棧,局部變量和函數參數非常重要。 -
複雜地址尋址
比如,
D(RA,RB,C)
,就是引用*RA + RB * C + D*
計算後的地址處的值。RA和RB是通用目的寄存器,C可以是1、2、4或8,D是一個整數位移。這種模式一般用於查找數組中的某一項的時候,RA給出數組的首地址,RB計算數組的索引,C作爲數組元素的大小,D作爲相對於那一項的偏移量。
下表是不同尋址方式下加載一個64位值到%rax
寄存器的示例:
尋址模式 | 示例 |
---|---|
全局符號 | MOVQ x, %rax |
立即數 | MOVQ $56, %rax |
寄存器 | MOVQ %rbx, %rax |
間接引用 | MOVQ (%rsp), %rax |
基址變址尋址 | MOVQ -8(%rbp), %rax |
複雜地址尋址 | MOVQ -16(%rbx,%rcx,8), %rax |
大部分時候,目的操作數和源操作數都可以使用相同的尋址模式,但是也有例外,比如MOVQ -8(%rbx), -8(%rbx)
,源和目的都使用基址變址尋址方式就是不可能的。具體的就需要查看手冊了。
有時候,你可能需要加載變量的地址而不是其值,這對於使用字符串或數組是非常方便的。爲了這個目的,可以使用LEA
指令(加載有效地址),示例如下:
尋址模式 | 示例 |
---|---|
全局符號 | LEAQ x, %rax |
基址變址尋址 | LEAQ -8(%rbp), %rax |
複雜地址尋址 | LEAQ -16(%rbx,%rcx,8), %rax |
3.3 基本算術運算
你需要爲你的編譯器提供四種基本的算術指令:整數加法、減法、乘法和除法。
ADD和SUB指令有兩個操作數:源操作目標和既作源又作目的的操作目標。比如:
ADDQ %rbx, %rax
將%rbx加到%rax上,把結果存入%rax。這必須要小心,以免破壞後面可能還用到的值。比如:c = a+b+b
這樣的語句,轉換成彙編語言大概是下面這樣:
MOVQ a, %rax
MOVQ b, %rbx
ADDQ %rbx, %rax
ADDQ %rbx, %rax
MOVQ %rax, c
IMUL
乘法指令有點不一樣,因爲通常情況下,兩個64位的整數會產生一個128位的整數。IMUL指令將第一個操作數乘以rax寄存器中的內容,然後把結果的低64位存入rax寄存器中,高64位存入rdx寄存器。(這裏有一個隱含操作,rdx寄存器在指令中並沒有提到)
比如,假設這樣的表達式c = b*(b+a)
,將其轉換成彙編語言;在這兒,a、b、c都是全局整數。
MOVQ a, %rax
MOVQ b, %rbx
ADDQ %rbx, %rax
IMULQ %rbx
MOVQ %rax, c
IDIV指令做相同的操作,除了最後的處理:它把128位整數的低64位存入rax寄存器,高64位存入rdx寄存器,然後除以指令中的第一個操作數。商存入rax寄存器,餘數存入rdx寄存器。(如果想要取模指令,只要rdx寄存器的值即可。)
爲了正確使用除法,必須保證兩個寄存器有必要的符號位。如果被除數低64位就可以表示,但是是負數,那麼高64位必須都是1,才能完成二進制補碼操作。CQO指令可以實現這個特殊目的,將rax寄存器的值的符號位擴展到rdx寄存器中。
比如,一個數被5整除:
MOVQ a, %rax # 設置被除數的低64位
CQO # 符號位擴展到%rdx
IDIVQ $5 # %rdx:%rax除以5,結果保存到%rax
自增和自減指令INC、DEC,操作數必須是一個寄存器的值。例如,表達式a = ++b
轉換成彙編語句後:
MOVQ b, %rax
INCQ %rax
MOVQ %rax, b
MOVQ %rax, a
指令AND、OR和XOR,提供按位操作。按位操作意味着把操作應用到操作數的每一位,然後保存結果。
所以,AND $0101B $0110B
就會產生結果$0100B
。同樣,NOT指令對操作數的每一位執行取反操作。比如,表達式c = (a & ˜b)
,可以轉換成下面這樣的彙編代碼:
MOVQ a, %rax
MOVQ b, %rbx
NOTQ %rbx
ANDQ %rax, %rbx
MOVQ %rbx, c
這裏需要注意的是,算術位操作與邏輯bool操作是不一樣的。比如,如果你定義false爲整數0,true爲非0。在這種情況下,$0001
是true,而NOT $0001B
的結果也是true!要想實現邏輯bool操作,需要使用CMP比較指令。
與MOV指令一樣,各種算術指令能在不同尋址模式下工作。但是,對於一個編譯器項目,使用MOV指令搬運寄存器之間或者寄存器與立即數之間的值,然後僅使用寄存器操作,會更加方便。
3.4 比較和跳轉
使用JMP
跳轉指令,我們就可以創建一個簡單的無限循環,使用rax
累加器從0開始計數,代碼如下:
MOVQ $0, %rax
loop: INCQ %rax
JMP loop
但是,我們大部分時候需要的是一個有限的循環或者if-then-else這樣的語句,所以必須提供計算比較值並改變程序執行流的指令。大部分彙編語言都提供2個指令:比較和跳轉。
CMP指令完成比較。比較兩個不同的寄存器,然後設置EFLAGS
寄存器中對應的位,記錄比較的值是相等、大於還是小於。使用帶有條件跳轉的指令自動檢查EFLAGS
寄存器並跳轉到正確的位置。
指令 | 意義 |
---|---|
JE | 如果相等跳轉 |
JNE | 如果不相等跳轉 |
JL | 小於跳轉 |
JLE | 小於等於跳轉 |
JG | 大於跳轉 |
JGE | 大於等於跳轉 |
下面是使用%rax
寄存器計算0到5累加值的示例:
MOVQ $0, %rax
loop: INCQ %rax
CMPQ $5, %rax
JLE loop
下面是一個條件賦值語句,如果全局變量x大於0,則全局變量y=10,否則等於20:
MOVQ x, %rax
CMPQ $0, %rax
JLE .L1
.L0:
MOVQ $10, $rbx
JMP .L2
.L1:
MOVQ $20, $rbx
.L2:
MOVQ %rbx, y
注意,跳轉指令要求編譯器定義標籤。這些標籤在彙編文件內容必須是唯一且私有的,對文件外是不可見的,除非使用.global
僞指令。標籤像.L0
、.L1
等是由編譯器根據需要生成的。
3.5 棧
棧是記錄函數調用過程和局部變量的一種數據結構,也可以說,如果沒有棧,C語言的函數是無法工作的。%rsp
寄存器稱爲棧指針寄存器,永遠指向棧頂元素(棧的增長方向是向下的)。
爲了把%rax
寄存器的內容壓入棧中,我們必須把%rsp
寄存器減去8(%rax
寄存器的大小),然後再把%rax
寄存器內容寫入到%rsp
寄存器指向的地址處:
SUBQ $8, %rsp
MOVQ %rax, (%rsp)
從棧中彈出數據,正好相反:
MOVQ (%rsp), %rax
ADDQ $8, %rsp
如果僅僅是拋棄棧中最近的值,可以只移動棧指針正確的字節數即可:
ADDQ $8, %rsp
當然了,壓棧和出棧是常用的操作,所以有專門的指令:
PUSHQ %rax
POPQ %rax
需要注意的是,64位系統中,PUSH和POP指令被限制只能使用64位值,所以,如果需要壓棧、出棧比這小的數必須使用MOV和ADD實現。
3.6 函數調用
先介紹一個簡單的棧調用習慣:參數按照相反的順序被壓入棧,然後使用CALL調用函數。被調用函數使用棧上的參數,完成函數的功能,然後返回結果到eax
寄存器中。調用者刪除棧上的參數。
但是,64位代碼爲了儘可能多的利用X86-64架構中的寄存器,使用了新的調用習慣。稱之爲System V ABI,詳細的細節可以參考ABI接口規範文檔。這兒,我們總結如下:
-
前6個參數(包括指針和其它可以存儲爲整形的類型)依次保存在寄存器
%rdi
、%rsi
、%rdx
、%rcx
、%r8
和%r9
。 -
前8個浮點型參數依次存儲在寄存器%xmm0-%xmm7。
-
超過這些寄存器個數的參數才被壓棧。
-
如果函數接受可變數量的參數(如printf),則必須將
%rax
寄存器設置爲浮動參數的數量。 -
函數的返回值存儲在
%rax
。
另外,我們也需要知道其餘的寄存器是如何處理的。有一些是調用者保存,意味着函數在調用其它函數之前必須保存這些值。另外一些則由被調用者保存,也就是說,這些寄存器可能會在被調用函數中修改,所以被調用函數需要保存調用者的這些寄存器的值,然後從被調用函數返回時,恢復這些寄存器的值。保存參數和結果的寄存器根本不需要保存。下表詳細地展示了這些細節:
表-System V ABI寄存器分配表
寄存器 | 目的 | 誰保存 |
---|---|---|
%rax | 結果 | 不保存 |
%rbx | 臨時 | 被調用者保存 |
%rcx | 參數4 | 不保存 |
%rdx | 參數3 | 不保存 |
%rsi | 參數2 | 不保存 |
%rdi | 參數1 | 不保存 |
%rbp | 基址指針 | 被調用者保存 |
%rsp | 棧指針 | 被調用者保存 |
%r8 | 參數5 | 不保存 |
%r9 | 參數6 | 不保存 |
%r10 | 臨時 | 調用者保存 |
%r11 | 臨時 | 調用者保存 |
%r12 | 臨時 | 被調用者保存 |
%r13 | 臨時 | 被調用者保存 |
%r14 | 臨時 | 被調用者保存 |
%r15 | 臨時 | 被調用者保存 |
爲了調用函數,首先必須計算參數,並把它們放置到對應的寄存器中。然後把2個寄存器%r10
和%r11
壓棧,保存它們的值。然後發出CALL
指令,它會吧當前的指令指針壓入棧,然後跳轉到被調函數的代碼位置。當從函數返回時,從棧中彈出%r10
和%r11
的內容,然後就可以利用%rax
寄存器的返回結果了。
這是一個C代碼示例:
int x=0;
int y=10;
int main()
{
x = printf("value: %d\n",y);
}
翻譯成彙編語言大概是:
.data
x:
.quad 0
y:
.quad 10
str:
.string "value: %d\n"
.text
.global main
main:
MOVQ $str, %rdi # 第一個參數保存到%rdi中,是字符串類型
MOVQ y, %rsi # 第二個參數保存到%rsi中,是y
MOVQ $0, %rax # 0個浮動參數
PUSHQ %r10 # 保存調用者保存的寄存器
PUSHQ %r11
CALL printf # 調用printf
POPQ %r11 # 恢復調用者保存的寄存器
POPQ %r10
MOVQ %rax, x # 保存結果到x
RET # 從main函數返回
3.7 定義葉子函數
因爲函數參數保存到寄存器中,所以寫一個不調用其它函數的葉子函數是非常簡單的。比如,下面的代碼:
square: function integer ( x: integer ) =
{
return x*x;
}
可以簡化爲:
.global square
square:
MOVQ %rdi, %rax # 拷貝第一個參數到%rax
IMULQ %rax # 自己相乘
# 結果保存到%rax
RET # 返回到調用函數中
不幸的是,這對於還要調用其它函數的函數是不可行的,因爲我們沒有爲其建立正確的棧。所以,需要一個複雜方法實現通用函數。
3.8 定義複雜函數
複雜函數必須能夠調用其它函數,且能夠計算任意複雜度的表達式,還能正確地返回到調用者中。考慮下面的示例,具有3個參數和2個局部變量的函數:
.global func
func:
pushq %rbp # 保存基址指針
movq %rsp, %rbp # 設置新的基址指針
pushq %rdi # 第一個參數壓棧
pushq %rsi # 第二個參數壓棧
pushq %rdx # 第三個參數壓棧
subq $16, %rsp # 給2個局部變量分配棧空間
pushq %rbx # 保存應該被調用者保存的寄存器
pushq %r12
pushq %r13
pushq %r14
pushq %r15
### 函數體 ###
popq %r15 # 恢復被調用者保存的寄存器
popq %r14
popq %r13
popq %r12
popq %rbx
movq %rbp, %rsp # 復位棧指針
popq %rbp # 恢復之前的基址指針
ret # 返回到調用者
這個函數需要追蹤的信息比較多:函數參數,返回需要的信息,局部變量空間等等。考慮到這個目的,我們使用基址指針寄存器%rbp
。棧指針%rsp
指向新棧的棧頂,而%rbp
指向新棧的棧底。%rsp
和%rbp
之間的這段空間就是函數調用的棧幀。
還有就是,函數需要調用寄存器計算表達式,也就是上面的%rbx
、%r12
、%r13
、%r14
、%r15
、%rbp
、%rsp
。這些寄存器可能已經在調用者函數體內被使用,所以我們不希望被調用函數內部破壞這些寄存器的值。這就需要在被調用函數中保存這些寄存器的值,在返回之前,再恢復這些寄存器之前的值。
下圖是func
函數的棧佈局:
圖3 X86-64棧佈局示例
基址指針寄存器(%rbp)
位於棧的起始處。所以,在函數體內,完全可以使用基址變址尋址方式,去引用參數和局部變量。參數緊跟在基址指針後面,所以參數0的位置就是-8(%rbp)
,參數1的位置就是-16(%rbp)
,依次類推。接下來是局部變量,位於-32(%rbp)
地址處。然後保存的寄存器位於-48(%rbp)
地址處。棧指針指向棧頂的元素。
下面是一個複雜函數的C代碼示例:
compute: function integer ( a: integer, b: integer, c: integer ) =
{
x:integer = a+b+c;
y:integer = x*5;
return y;
}
將其完整地轉換成彙編代碼,如下所示:
.global compute
compute:
##################### preamble of function sets up stack
pushq %rbp # save the base pointer
movq %rsp, %rbp # set new base pointer to rsp
pushq %rdi # save first argument (a) on the stack
pushq %rsi # save second argument (b) on the stack
pushq %rdx # save third argument (c) on the stack
subq $16, %rsp # allocate two more local variables
pushq %rbx # save callee-saved registers
pushq %r12
pushq %r13
pushq %r14
pushq %r15
######################## body of function starts here
movq -8(%rbp), %rbx # load each arg into a register
movq -16(%rbp), %rcx
movq -24(%rbp), %rdx
addq %rdx, %rcx # add the args together
addq %rcx, %rbx
movq %rbx, -32(%rbp) # store the result into local 0 (x)
movq -32(%rbp), %rbx # load local 0 (x) into a register.
movq $5, %rcx # load 5 into a register
movq %rbx, %rax # move argument in rax
imulq %rcx # multiply them together
movq %rax, -40(%rbp) # store the result in local 1 (y)
movq -40(%rbp), %rax # move local 1 (y) into the result
#################### epilogue of function restores the stack
popq %r15 # restore callee-saved registers
popq %r14
popq %r13
popq %r12
popq %rbx
movq %rbp, %rsp # reset stack to base pointer.
popq %rbp # restore the old base pointer
ret # return to caller
下面有轉換爲彙編的代碼段。代碼是正確的,但不是完美的。事實證明,這個函數不需要使用寄存器%rbx
和%r15
,所以不需要保存和恢復他們。同樣的,我們也可以把參數就保留在寄存器中而不必把它們壓棧。結果也不必存入局部變量y中,而是可以直接寫入到%rax
寄存器中。這其實就是編譯器優化功能的一部分。
4 ARM彙編
最新的ARM架構是ARMv7-A(32位)和ARMv8-A(64位)。本文着重介紹32位架構,最後討論一下64位體系架構的差異。
ARM是一個精簡指令計算機(RISC)架構。相比X86,ARM使用更小的指令集,這些指令更易於流水線操作或並行執行,從而降低芯片複雜度和能耗。但由於一些例外,ARM有時候被認爲是部分RISC架構。比如,一些ARM指令執行時間的差異使流水線不完美,爲預處理而包含的桶形移位器引入了更復雜的指令,還有條件指令減少了一些潛在指令的執行,導致跳轉指令的使用減少,從而降低了處理器的能耗。我們側重於編寫編譯器常用到的指令,更復雜的內容和程序語言的優化留到以後再研究。
4.1 寄存器和數據類型
32位ARM架構擁有16個通用目的寄存器,r0~r15,使用約定如下所示:
名稱 | 別名 | 目的 |
---|---|---|
r0 | - | 通用目的寄存器 |
r1 | - | 通用目的寄存器 |
… | - | - |
r10 | - | 通用目的寄存器 |
r11 | fp | 棧幀指針,棧幀起始地址 |
r12 | ip | 內部調用臨時寄存器 |
r13 | sp | 棧指針 |
r14 | lr | 鏈接寄存器(返回地址) |
r15 | pc | 程序計數器 |
除了通用目的寄存器,還有2個寄存器:當前程序狀態寄存器(CPSR)和程序狀態保存寄存器(SPSR),它們不能被直接訪問。這兩個寄存器保存着比較運算的結果,以及與進程狀態相關的特權數據。用戶態程序不能直接訪問,但是可以通過一些操作的副作用
修改它們。
ARM使用下面的後綴表示數據大小。它們與X86架構不同!如果沒有後綴,彙編器假設操作數是unsigned word類型。有符號類型提供正確的符號位。任何word類型寄存器不會再有細分且被命名的寄存器。
後綴 | 數據類型 | 大小 |
---|---|---|
B | Byte | 8 位 |
H | Halfword | 16 位 |
W | WORD | 32 位 |
- | Double Word | 64 位 |
SB | Signed Byte | 8 位 |
SH | Signed Halfword | 16 位 |
SW | Signed Word | 32 位 |
- | Double Word | 64 位 |
4.2 尋址模式
與X86不同,ARM使用兩種不同的指令分別搬運寄存器之間、寄存器與內存之間的數據。MOV拷貝寄存器之間的數據和常量,而LDR和STR指令拷貝寄存器和內存之間的數據。
MOV指令可以把一個立即數或者寄存器值搬運到另一個寄存器中。ARM中,用#
表示立即數,這些立即數必須小於等於16位。如果大於16位,就會使用LDR指令代替。大部分的ARM指令,目的寄存器在左,源寄存器在右。(STR是個例外)。具體格式如下:
模式 | 示例 |
---|---|
立即數 | MOV r0, #3 |
寄存器 | MOV r1, r0 |
MOV指令後面添加標識數據類型的字母,確定傳輸的類型和如何傳輸數據。如果沒有指定,彙編器假定爲word。
從內存中搬運數據使用LDR和STR指令,它們把源寄存器和目的寄存器作爲第一個參數,要訪問的內存地址作爲第二個參數。簡單情況下,使用寄存器給出地址並用中括號[]
標記:
LDR Rd, [Ra]
STR Rs, [Ra]
Rd
,表示目的寄存器;Rs
,表示源寄存器;Ra
,表示包含內存地址的寄存器。(必須要注意內存地址的類型,可以使用任何內存地址訪問字節數據,使用偶數地址訪問半字數據等。)
ARM尋址模式
模式 | 示例 |
---|---|
文本 | LDR Rd, =0xABCD1234 |
絕對地址 | LDR Rd, =label |
寄存器間接尋址 | LDR Rd, [Ra] |
先索引-立即數 | LDR Rd, [Ra, #4] |
先索引-寄存器 | LDR Rd, [Ra, Ro] |
先索引-立即數&Writeback | LDR Rd, [Ra, #4]! |
先索引-寄存器&Writeback | LDR Rd, [Ra, Ro]! |
後索引-立即數 | LDR Rd, [Ra], #4 |
後索引-寄存器 | LDR Rd, [Ra], Ro |
如上表所示,LDR和STR支持多種尋址模式。首先,LDR能夠加載一個32位的文本值(或絕對地址)到寄存器。(完整的解釋請參考下一段內容)。與X86不同,ARM沒有可以從一個內存地址拷貝數據的單指令。爲此,首先需要把地址加載到一個寄存器,然後執行一個寄存器間接尋址:
LDR r1, =x
LDR r2, [r1]
爲了方便高級語言中的指針、數組、和結構體的實現,還有許多其它可用的尋址模式。比如,先索引模式可以添加一個常數(或寄存器)到基址寄存器,然後從計算出的地址加載數據:
LDR r1, [r2, #4] ; # 載入地址 = r2 + 4
LDR r1, [r2, r3] ; # 載入地址 = r2 + r3
有時候可能需要在把計算出的地址中的內容讀取後,再把該地址寫回到基址寄存器中,這可以通過在後面添加感嘆號!
實現。
LDR r1, [r2, #4]! ; # 載入地址 = r2 + 4 然後 r2 += 4
LDR r1, [r2, r3]! ; # 載入地址 = r2 + r3 然後 r2 += r3
後索引模式做相同的工作,但是順序相反。首先根據基址地址執行加載,然後基址地址再加上後面的值:
LDR r1, [r2], #4 ; # 載入地址 = r2 然後 r2 += 4
LDR r1, [r2], r3 ; # 載入地址 = r2 然後 r2 += r3
通過先索引和後索引模式,可以使用單指令實現像我們經常寫的C語句b = a++
。STR使用方法類似。
在ARM中,絕對地址以及其它長文本更爲複雜些。因爲每條指令都是32位的,因此不可能將32位的地址和操作碼(opcode)一起添加到指令中。因此,長文本存儲在一個文本池中,它是程序代碼段中一小段數據區域。使用與PC寄存器相關的加載指令,比如LDR,加載文本類型數據,這樣的文本池可以引用靠近load指令的±4096個字節數據。這導致有一些小的文本池散落在程序中,由靠近它們的指令使用。
ARM彙編器隱藏了這些複雜的細節。在絕對地址和長文本的前面加上等號=
,就代表向彙編器表明,標記的值應該存儲在一個文本池中,並使用與PC寄存器相關的指令代替。
例如,下面的指令,把x的地址加載到r1中,然後取出x的值,存入r2寄存器中。
LDR r1, =x
LDR r2, [r1]
下面的代碼展開後,將會從相鄰的文本池中加載x的地址,然後加載x的值,存入r2寄存器中。也就是,下面的代碼與上面的代碼是一樣的。
LDR r1, .L1
LDR r2, [r1]
B .end
.L1:
.word x
.end:
4.3 基本算術運算
ARM的ADD
和SUB
指令,使用3個地址作爲參數。目的寄存器是第一個參數,第二、三個參數作爲操作數。其中第三個參數可以是一個8位的常數,或者帶有移位的寄存器。使能進位的加、減法指令,將CPSR寄存器的C標誌位寫入到結果中。這4條指令如果分別後綴S,代表在完成時是否設置條件標誌(包括進位),這是可選的。
指令 | 示例 |
---|---|
加 | ADD Rd, Rm, Rn |
帶進位加 | ADC Rd, Rm, Rn |
減 | SUB Rd, Rm, Rn |
帶進位減 | SBC Rd, Rm, Rn |
乘法指令的工作方式與加減指令類似,除了將2個32位的數字相乘能夠產生一個64位的值之外。普通的MUL指令捨棄了結果的高位,而UMULL指令把結果分別保存在2個寄存器中。有符號的指令SMULL,在需要的時候會把符號位保存在高寄存器中。
指令 | 示例 |
---|---|
乘法 | MUL Rd, Rm, Rn |
無符號長整形 | UMULL RdHi, RdLo, Rm, Rn |
有符號長整形 | SMULL RdHi, RdLo, Rm, Rn |
ARM沒有除法指令,因爲它不能再單個流水線週期中執行。因此,需要除法的時候,調用外部標準庫中的函數。
邏輯指令在結構上和算術指令非常相似,如下圖所示。特殊的是MVN指令,執行按位取反然後將結果保存到目的寄存器。
指令 | 示例 |
---|---|
位與 | AND Rd, Rm, Rn |
位或 | ORR Rd, Rm, Rn |
位異或 | EOR Rd, Rm, Rn |
位置0 | BIC Rd, RM, Rn |
取反並移動 | MVN Rd, Rn |
4.4 比較和跳轉
比較指令CMP比較2個值,將比較結果寫入CPSR寄存器的N(負)和Z(零)標誌位,供後面的指令讀取使用。如果比較一個寄存器值和立即數,立即數必須作爲第二個操作數:
CMP Rd, Rn
CMP Rd, #imm
另外,也可以在算術指令後面添加S
標誌,以相似的方式更新CPSR寄存器的相應標誌位。比如,SUBS指令是兩個數相減,保存結果,並更新CPSR。
ARM跳轉指令
操作碼 | 意義 | 操作碼 | 意義 |
---|---|---|---|
B | 無條件跳轉 | BL | 設置lr寄存器爲下一條指令的地址並跳轉 |
BX | 跳轉並切換狀態 | BLX | BL+BX指令的組合 |
BEQ | 相等跳轉 | BVS | 溢出標誌設置跳轉 |
BNE | 不等跳轉 | BVC | 溢出標誌清除跳轉 |
BGT | 大於跳轉 | BHI | 無符號>跳轉 |
BGE | 大於等於跳轉 | BHS | 無符號>=跳轉 |
BLT | 小於跳轉 | BLO | 無符號<跳轉 |
BLE | 小於等於跳轉 | BLS | 無符號<=跳轉 |
BMI | 負值跳轉 | BPL | >= 0跳轉 |
各種跳轉指令參考CPSR寄存器中之前的值,如果設置正確就跳到相應的地址(標籤表示)執行。無條件跳轉指令就是一個簡單的B
。
比如,從0累加到5:
MOV r0, #0
loop: ADD r0, r0, 1
CMP r0, #5
BLT loop
再比如,如果x大於0,則給y賦值爲:10;否則,賦值爲20:
LDR r0, =x
LDR r0, [r0]
CMP r0, #0
BGT .L1
.L0:
MOV r0, #20
B .L2
.L1:
MOV r0, #10
.L2:
LDR r1, =y
STR r0, [r1]
BL指令用來實現函數調用。BL指令設置lr寄存器爲下一條指令的地址,然後跳轉到給定的標籤(比如絕對地址)處執行,並將lr寄存器的值作爲函數結束時的返回地址。BX指令跳轉到寄存器中給定的地址處,最常用於通過跳轉到lr寄存器而從函數調用中返回。
BLX指令執行的動作跟BL指令一樣,只是操作對象換成了寄存器中給定的地址值,常用於調用函數指針,虛函數或其它間接跳轉的場合。
ARM指令集的一個重要特性就是條件執行。每條指令中有4位表示16中可能的條件,如果條件不滿足,指令被忽略。上面各種類型的跳轉指令只是在最單純的B指令上應用了各種條件而已。這些條件幾乎可以應用到任何指令。
例如,假設下面的代碼片段,哪個值小就會自加1:
if(a<b) { a++; } else { b++; }
代替使用跳轉指令和標籤實現這個條件語句,我們可以前面的比較結果對每個加法指令設置條件。無論那個條件滿足都被執行,而另一個被忽略。如下面所示(假設a和b分別存儲在寄存器r0和r1中):
CMP r0, r1
ADDLT r0, r0, #1
ADDGE r1, r1, #1
4.5 棧
棧是一種輔助數據結構,主要用來存儲函數調用歷史以及局部變量。按照約定,棧的增長方向是從髙地址到地地址。sp
寄存器保存棧指針,用來追蹤棧頂內容。
爲了把寄存器r0壓入棧中,首先,sp
減去寄存器的大小,然後把r0
存入sp
指定的位置:
SUB sp, sp, #4
STR r0, [sp]
或者,可以使用一條單指令完成這個操作,如下所示:
STR r0, [sp, #-4]!
這兒,使用了先索引並write-back
的尋址方式。也就是說,sp
先減4,然後把r0
的內容存入sp-4
指向的地址處,然後再把sp-4
寫入到sp
中。
ARM調用習慣總結
- 前4個參數存儲在r0、r1、r2 和r3中;
- 其餘的參數按照相反的順序存入棧中;
- 如果需要,調用者必須保存r0-r3和r12;
- 調用者必須始終保存r14,即鏈接寄存器;
- 如果需要,被調用者必須保存r4-r11;
- 結果存到r0寄存器中。
PUSH僞指令可以壓棧的動作,還可以把任意數量的寄存器壓入棧中。使用花括號{}
列出要壓棧的寄存器列表:
PUSH {r0,r1,r2}
出棧的動作正好與壓棧的動作相反:
LDR r0, [sp]
ADD sp, sp, #4
使用後索引模式
LDR r0, [sp], #4
使用POP
指令彈出一組寄存器:
POP {r0,r1,r2}
與X86不同的是,任何數據項(從字節到雙word)都可以壓入棧,只要遵守數據對齊即可。
4.6 函數調用
《The ARM-Thumb Procedure Call Standard》描述了ARM的寄存器調用約定,其摘要如下:
ARM寄存器分配:
寄存器 | 目的 | 誰保存 |
---|---|---|
r0 | 參數0 | 不保存 |
r1 | 參數1 | 調用者保存 |
r2 | 參數2 | 調用者保存 |
r3 | 參數3 | 調用者保存 |
r4 | 臨時 | 被調用者保存 |
… | … | … |
r10 | 臨時 | 被調用者保存 |
r11 | 棧幀指針 | 被調用者保存 |
r12 | 內部過程 | 調用者保存 |
r13 | 棧指針 | 被調用者保存 |
r14 | 鏈接寄存器 | 調用者保存 |
r15 | 程序計數器 | 保存在r14 |
爲了調用一個函數,把參數存入r0-r3寄存器中,保存lr寄存器中的當前值,然後使用BL
指令跳轉到指定的函數。返回時,恢復lr寄存器的先前值,並檢查r0寄存器中的結果。
比如,下面的C語言代碼段:
int x=0;
int y=10;
int main() {
x = printf("value: %d\n",y);
}
其編譯後的ARM彙編格式爲:
.data
x: .word 0
y: .word 10
S0: .ascii "value: %d\012\000"
.text
main:
LDR r0, =S0 @ 載入S0的地址
LDR r1, =y @ 載入y的地址
LDR r1, [r1] @ 載入y的值
PUSH {ip,lr} @ 保存ip和lr寄存器的值
BL printf @ 調用printf函數
POP {ip,lr} @ 恢復寄存器的值
LDR r1, =x @ 載入x的地址
STR r0, [r1] @ 把返回的結果存入x中
.end
4.7 定義葉子函數
因爲使用寄存器傳遞函數參數,所以編寫一個不調用其它函數的葉子函數非常簡單。比如下面的代碼:
square: function integer ( x: integer ) =
{
return x*x;
}
它的彙編代碼可以非常簡單:
.global square
square:
MUL r0, r0, r0 @ 參數本身相乘
BX lr @ 返回調用者
但是,很不幸,對於想要調用其他函數的函數,這樣的實現就無法工作,因爲我們沒有正確建立函數使用的棧。所以,需要一種更爲複雜的方法。
4.8 定義複雜函數
複雜函數必須能夠調用其它函數並計算任意複雜度的表達式,然後正確地返回到調用者之前的狀態。還是考慮具有3個參數和2個局部變量的函數:
func:
PUSH {fp} @ 保存棧幀指針,也就是棧的開始
MOV fp, sp @ 設置新的棧幀指針
PUSH {r0,r1,r2} @ 參數壓棧
SUB sp, sp, #8 @ 分配2個局部變量的棧空間
PUSH {r4-r10} @ 保存調用者的寄存器
@@@ 函數體 @@@
POP {r4-r10} @ 恢復調用者的寄存器
MOV sp, fp @ 復位棧指針
POP {fp} @ 恢復之前的棧幀指針
BX lr @ 返回到調用者
通過上面的代碼,我們可以看出,不管是ARM架構的函數實現還是X86架構系列的函數實現,本質上都是一樣的,只是指令和寄存器的使用不同。
圖4 ARM棧佈局示例
同樣考慮下面一個帶有表達式計算的複雜函數的C代碼:
compute: function integer
( a: integer, b: integer, c: integer ) =
{
x: integer = a+b+c;
y: integer = x*5;
return y;
}
將其完整地轉換成彙編代碼,如下所示:
.global compute
compute:
@@@@@@@@@@@@@@@@@@ preamble of function sets up stack
PUSH {fp} @ save the frame pointer
MOV fp, sp @ set the new frame pointer
PUSH {r0,r1,r2} @ save the arguments on the stack
SUB sp, sp, #8 @ allocate two more local variables
PUSH {r4-r10} @ save callee-saved registers
@@@@@@@@@@@@@@@@@@@@@@@@ body of function starts here
LDR r0, [fp,#-12] @ load argument 0 (a) into r0
LDR r1, [fp,#-8] @ load argument 1 (b) into r1
LDR r2, [fp,#-4] @ load argument 2 (c) into r2
ADD r1, r1, r2 @ add the args together
ADD r0, r0, r1
STR r0, [fp,#-20] @ store the result into local 0 (x)
LDR r0, [fp,#-20] @ load local 0 (x) into a register.
MOV r1, #5 @ move 5 into a register
MUL r2, r0, r1 @ multiply both into r2
STR r2, [fp,#-16] @ store the result in local 1 (y)
LDR r0, [fp,#-16] @ move local 1 (y) into the result
@@@@@@@@@@@@@@@@@@@ epilogue of function restores the stack
POP {r4-r10} @ restore callee saved registers
MOV sp, fp @ reset stack pointer
POP {fp} @ recover previous frame pointer
BX lr @ return to the caller
構建一個合法棧幀的形式有多種,只要函數使用棧幀的方式一致即可。比如,被調函數可以首先把所有的參數和需要保存的寄存器壓棧,然後再給局部變量分配棧空間。(當然了,函數返回時,順序必須正好相反。)
還有一種常用的方式就是,在將參數和局部變量壓棧之前,爲被調函數執行PUSH {fp,ip,lr,pc}
,將這些寄存器壓入棧中。儘管這不是實現函數的嚴格要求,但是以棧回溯的形式爲調試器提供了調試信息,可以通過函數的調用棧,輕鬆地重構程序的當前執行狀態。
與前面描述X86_64的示例時一樣,這段代碼也是有優化的空間的。事實證明,這個函數不需要保存寄存器r4和r5,當然也就不必恢復。同樣的,參數我們也不需要非得保存到棧中,可以直接使用寄存器。計算結果可以直接寫入到寄存器r0中,不必再保存到變量y中。這其實就是ARM相關的編譯器所要做的工作。
4.9 ARM-64位
支持64位的ARMv8-A架構提供了兩種擴展模式:A32模式-支持上面描述的32位指令集;A64模式-支持64位執行模式。這就允許64位的CPU支持操作系統可以同時執行32位和64位程序。雖然A32模式的二進制執行文件和A64模式不同,但是有一些架構原理是相同的,只是做了一些改變而已:
-
字寬度
A64模式的指令還是32位大小的,只是寄存器和地址的計算是64位。
-
寄存器
A64具有32個64位的寄存器,命名爲x0-x31。x0是專用的0寄存器:當讀取時,總是返回0值;寫操作無效。x1-x15是通用目的寄存器,x16和x17是爲進程間通信使用,x29是棧幀指針寄存器,x30是lr鏈接寄存器,x31是棧指針寄存器。(程序寄存器(PC)用戶態代碼不可直接訪問)32位的值可以通過將寄存器命名爲w#來表示,而不是使用數據類型後綴,在這兒#代表0-31。
-
指令
A64模式的指令大部分和A32模式相同,使用相同的助記符,只是有一點小差異。分支預測不再是每條指令的一部分。相反,所有的條件執行代碼必須顯式地執行CMP指令,然後執行條件分支指令。LDM/STM指令和僞指令PUSH/POP不可用,必須通過顯式地加載和存儲指令序列實現。(使用LDP/STP,在加載和存儲成對的寄存器時更有效率)。
-
調用習慣
當調用函數的時候,前8個參數被存儲到寄存器x0-x7中,其餘的參數壓棧。調用者必須保留寄存器x9-x15和x30,而被調用者必須保留x19-x29。返回值的標量部分存儲到x0中,而返回值的擴展部分存儲到x8中。
5 參考
本文對基於X86和ARM架構的彙編語言的核心部分做了闡述,可以滿足大部分需要了。但是,如果需要了解各個指令的細節,可以參考下面的文檔。
- Intel64 and IA-32 Architectures Software Developer Manuals. Intel Corp., 2017. http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html
- System V Application Binary Interface, Jan Hubicka, Andreas Jaeger, Michael Matz, and Mark Mitchell (editors), 2013. https://software.intel.com/sites/default/files/article/402129/mpx-linux64-abi.pdf
- ARM Architecture Reference Manual ARMv8. ARM Limited, 2017.
https://static.docs.arm.com/ddi0487/bb/DDI0487B_b_armv8_arm.pdf. - The ARM-THUMB Procedure Call Standard. ARM Limited, 2000.
http://infocenter.arm.com/help/topic/com.arm.doc.espc0002/ATPCS.pdf.