linux內核1-GNU彙編入門_X86-64&ARM

原文地址:linux內核1-GNU彙編入門_X86-64&ARM

1 引言

爲了閱讀Linux內核源代碼,是需要一些彙編語言知識的。因爲與架構相關的代碼基本上都是用匯編語言編寫的,所以掌握一些基本的彙編語言語法,能夠更好地理解Linux內核源代碼,甚至可以對各種架構的差異有一個更深入的理解。

大部分人可能認爲彙編語言晦澀難懂,閱讀手冊又冗長乏味。但是,經過本人的經驗,可能常用的指令也就是30個。許多其它的指令都是解決特定的情況而出現,比如浮點運算和多媒體指令。所以,本文就從常用指令出發,基於GNU彙編語言格式,對x86_64架構和ARM架構下的指令做了一個入門介紹。學習完這篇文章,希望可以對彙編有一個基本的理解,並能夠解決大部分問題。

閱讀本文需要一些硬件架構的知識。必要的時候,可以翻閱Intel Software Developer ManualARM Architecture Reference Manual

2 開源彙編工具

對於相同的芯片架構,不同的芯片製造商或者其它開源工具可能會有不同的語法格式。所以,本文支持GNU編譯器和彙編器,分別是gccas(有時候也稱爲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

從上邊的彙編代碼中可以看出,彙編代碼大概由三部分組成:

  1. 僞指令

    僞指令前綴一個小數點.,供彙編器、鏈接器或者調試器使用。比如,.file記錄最初的源文件名稱,這個名稱對調試器有用;.data,表明該部分的內容是程序的數據段;.text,表明接下來的內容是程序代碼段的內容;.string,表示一個數據段中的字符串常量;.global main,表示符號main是一個全局符號,可以被其它代碼模塊訪問。

  2. 標籤

    標籤是由編譯器產生,鏈接器使用的一種引用符號。本質上,就是對代碼段的一個作用域打上標籤,方便鏈接器在鏈接階段將所有的代碼拼接在一起。所以,標籤就是鏈接器的一種助記符。

  3. 彙編指令

    真正的彙編代碼,其實就是機器碼的助記符。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接口規範文檔。這兒,我們總結如下:

  1. 前6個參數(包括指針和其它可以存儲爲整形的類型)依次保存在寄存器%rdi%rsi%rdx%rcx%r8%r9

  2. 前8個浮點型參數依次存儲在寄存器%xmm0-%xmm7。

  3. 超過這些寄存器個數的參數才被壓棧。

  4. 如果函數接受可變數量的參數(如printf),則必須將%rax寄存器設置爲浮動參數的數量。

  5. 函數的返回值存儲在%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的ADDSUB指令,使用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調用習慣總結

  1. 前4個參數存儲在r0、r1、r2 和r3中;
  2. 其餘的參數按照相反的順序存入棧中;
  3. 如果需要,調用者必須保存r0-r3和r12;
  4. 調用者必須始終保存r14,即鏈接寄存器;
  5. 如果需要,被調用者必須保存r4-r11;
  6. 結果存到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模式不同,但是有一些架構原理是相同的,只是做了一些改變而已:

  1. 字寬度

    A64模式的指令還是32位大小的,只是寄存器和地址的計算是64位。

  2. 寄存器

    A64具有32個64位的寄存器,命名爲x0-x31。x0是專用的0寄存器:當讀取時,總是返回0值;寫操作無效。x1-x15是通用目的寄存器,x16和x17是爲進程間通信使用,x29是棧幀指針寄存器,x30是lr鏈接寄存器,x31是棧指針寄存器。(程序寄存器(PC)用戶態代碼不可直接訪問)32位的值可以通過將寄存器命名爲w#來表示,而不是使用數據類型後綴,在這兒#代表0-31。

  3. 指令

    A64模式的指令大部分和A32模式相同,使用相同的助記符,只是有一點小差異。分支預測不再是每條指令的一部分。相反,所有的條件執行代碼必須顯式地執行CMP指令,然後執行條件分支指令。LDM/STM指令和僞指令PUSH/POP不可用,必須通過顯式地加載和存儲指令序列實現。(使用LDP/STP,在加載和存儲成對的寄存器時更有效率)。

  4. 調用習慣

    當調用函數的時候,前8個參數被存儲到寄存器x0-x7中,其餘的參數壓棧。調用者必須保留寄存器x9-x15和x30,而被調用者必須保留x19-x29。返回值的標量部分存儲到x0中,而返回值的擴展部分存儲到x8中。

5 參考

本文對基於X86和ARM架構的彙編語言的核心部分做了闡述,可以滿足大部分需要了。但是,如果需要了解各個指令的細節,可以參考下面的文檔。

  1. 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
  2. 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
  3. ARM Architecture Reference Manual ARMv8. ARM Limited, 2017.
    https://static.docs.arm.com/ddi0487/bb/DDI0487B_b_armv8_arm.pdf.
  4. The ARM-THUMB Procedure Call Standard. ARM Limited, 2000.
    http://infocenter.arm.com/help/topic/com.arm.doc.espc0002/ATPCS.pdf.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章