x86 彙編/GNU 彙編語法
本文中的示例使用 GNU AS 中的 AT&T 彙編語法建立。使用此語法的主要優點是它與 GCC 內聯彙編語法相容。但是,這不是表示 x86 操作的唯一語法。例如,NASM 使用不同的語法來表示彙編助記符、運算元和定址模式,就像一些 高階彙編器 一樣。AT&T 語法是類 Unix 系統的標準,但一些彙編器使用 Intel 語法,或者像 GAS 本身一樣,可以接受兩種語法。有關比較表,請參閱 X86 組合語言語法。
GAS 指令通常具有以下形式:助記符 源,目標。例如,以下 mov 指令
movb $0x05, %al
這會將十六進位制值 5 移動到 al 暫存器中。
GAS 彙編指令通常以字母 "b"、"s"、"w"、"l"、"q" 或 "t" 為字尾,以確定正在操作的運算元的大小。
b= 位元組(8 位)。s= 單精度(32 位浮點數)。w= 字(16 位)。l= 長整型(32 位整數或 64 位浮點數)。q= 四位元組(64 位)。t= 十位元組(80 位浮點數)。
如果未指定字尾,並且指令沒有記憶體運算元,GAS 會根據目標暫存器運算元(最後一個運算元)的大小推斷運算元大小。
引用暫存器時,暫存器需要以 "%" 為字首。常數需要以 "$" 為字首。
地址運算元最多有 4 個引數,它們以語法 段:偏移量(基址暫存器,索引暫存器,比例因子) 表示。這等效於 Intel 語法中的 段:[基址暫存器 + 偏移量 + 索引暫存器 * 比例因子]。
基址、索引和偏移量元件可以以任何組合使用,並且每個元件都可以省略;省略的元件從上面的計算中排除[1][2]。
movl -8(%ebp, %edx, 4), %eax # Full example: load *(ebp + (edx * 4) - 8) into eax
movl -4(%ebp), %eax # Typical example: load a stack variable into eax
movl (%ecx), %edx # No index: copy the target of a pointer into a register
leal 8(,%eax,4), %eax # Arithmetic: multiply eax by 4 and add 8
leal (%edx,%eax,2), %eax # Arithmetic: multiply eax by 2 and add edx
本節作為 GAS 的簡短介紹。GAS 是 GNU 專案 的一部分,這賦予了它以下良好的特性
- 它在許多作業系統上可用。
- 它與其他 GNU 程式設計工具(包括 GNU C 編譯器 (gcc) 和 GNU 連結器 (ld))很好地整合。
如果你使用的是裝有 Linux 作業系統的計算機,那麼你可能已經在系統上安裝了 GAS。如果你使用的是裝有 Windows 作業系統的計算機,你可以透過安裝 Cygwin 或 Mingw 來安裝 GAS 和其他有用的程式設計工具。本介紹的剩餘部分假設你已安裝 GAS 並且知道如何開啟命令列介面和編輯檔案。
由於組合語言直接對應於 CPU 執行的操作,因此精心編寫的彙編程式可能比用 C 等高階語言編寫的相同程式執行得快得多。另一方面,彙編程式的編寫通常比用 C 語言編寫的等效程式需要更多的努力。因此,快速編寫效能良好的程式的典型方法是先用高階語言編寫程式(這更容易編寫和除錯),然後用匯編語言重新編寫選定的程式(效能更好)。將 C 程式重新編寫為組合語言的一個好方法是使用 C 編譯器自動生成組合語言。這不僅會給你一個正確編譯的彙編檔案,而且還能確保彙編程式完全按照你的意圖執行。 [3]
我們現在將使用 GNU C 編譯器來生成彙編程式碼,以檢查 GAS 組合語言語法。
這是用 C 語言編寫的經典 "Hello, world" 程式
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}
將它儲存到名為 "hello.c" 的檔案中,然後在提示符處鍵入
gcc -o hello_c hello.c
這應該編譯 C 檔案並建立一個名為 "hello_c" 的可執行檔案。如果你遇到錯誤,請確保 "hello.c" 的內容是正確的。
現在你應該能夠在提示符處鍵入
./hello_c
該程式應該將 "Hello, world!" 列印到控制檯。
現在我們知道 "hello.c" 鍵入正確並且按預期執行,讓我們生成等效的 32 位 x86 組合語言。在提示符處鍵入以下命令
gcc -S -m32 hello.c
這應該建立一個名為 "hello.s" 的檔案(".s" 是 GNU 系統為彙編檔案提供的副檔名)。在更新的 64 位系統上,32 位原始碼樹可能不包含,這會導致 "bits/predefs.h 致命錯誤";你可以將 -m32 gcc 指令替換為 -m64 指令來生成 64 位彙編。要將彙編檔案編譯成可執行檔案,請鍵入
gcc -o hello_asm -m32 hello.s
(請注意,gcc 為我們呼叫了彙編器 (as) 和連結器 (ld)。) 現在,如果你在提示符處鍵入以下命令
./hello_asm
該程式也應該將 "Hello, world!" 列印到控制檯。不出所料,它與編譯後的 C 檔案執行相同操作。
讓我們看看 "hello.s" 中的內容
.file "hello.c"
.def ___main; .scl 2; .type 32; .endef
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
call __alloca
call ___main
movl $LC0, (%esp)
call _printf
movl $0, %eax
leave
ret
.def _printf; .scl 2; .type 32; .endef
"hello.s" 的內容可能因安裝的 GNU 工具版本而異;此版本使用 Cygwin 和 gcc 版本 3.3.1 生成。
以句點開頭的行,如 .file、.def 或 .ascii,是彙編器指令 — 指示彙編器如何彙編檔案的命令。以一些文字後跟冒號開頭的行,如 _main:,是標籤,或程式碼中的命名位置。其他行是彙編指令。
.file 和 .def 指令用於除錯。我們可以將它們省略
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
call __alloca
call ___main
movl $LC0, (%esp)
call _printf
movl $0, %eax
leave
ret
.text
這行程式碼聲明瞭一個程式碼段的開始。您可以使用此指令命名程式碼段,這可以讓你對生成的機器程式碼在可執行檔案中的位置進行細粒度的控制,這在某些情況下非常有用,例如程式設計嵌入式系統。使用 .text 本身告訴彙編器將以下程式碼放入預設段,對於大多數目的來說這已經足夠了。
LC0:
.ascii "Hello, world!\12\0"
這段程式碼聲明瞭一個標籤,然後將一些原始 ASCII 文字放入程式中,從標籤的位置開始。\12 指定換行符,而 \0 指定字串末尾的空字元;C 函式使用空字元標記字串的結束,由於我們要呼叫 C 字串函式,因此這裡需要這個字元。(注意!C 中的字串是字元型別資料(char [])的陣列,並且不存在任何其他形式,但由於人們會從大多數程式語言中理解字串作為一個單個實體,因此以這種方式表達它會更加清楚。)
.globl _main
這行程式碼告訴彙編器標籤 _main 是一個全域性標籤,這允許程式的其他部分看到它。在本例中,連結器需要能夠看到 _main 標籤,因為與程式連結的啟動程式碼呼叫 _main 作為子程式。
_main:
這行程式碼聲明瞭 _main 標籤,標記了從啟動程式碼呼叫的位置。
pushl %ebp
movl %esp, %ebp
subl $8, %esp
這些程式碼行將 EBP 的值儲存在堆疊上,然後將 ESP 的值移入 EBP,然後從 ESP 中減去 8。注意,pushl 自動將 ESP 減去了相應的長度。每個操作碼末尾的 l 表示我們想要使用適用於 *長*(32 位)運算元的操作碼版本;通常彙編器可以從運算元中推斷出正確操作碼版本,但為了安全起見,最好包含 l、w、b 或其他字尾。百分號表示暫存器名稱,美元符號表示字面值。這組指令在子程式開始時很常見,用於在堆疊上為區域性變數保留空間;EBP 被用作基址暫存器來引用區域性變數,並且從 ESP 中減去一個值來在堆疊上保留空間(因為 Intel 堆疊從高記憶體地址向低記憶體地址增長)。在本例中,在堆疊上保留了八個位元組。我們將在稍後看到為什麼需要這個空間。
andl $-16, %esp
這段程式碼將 ESP 與 0xFFFFFFF0 進行 `and` 操作,使堆疊與下一個最小的 16 位元組邊界對齊。檢查 Mingw 的源程式碼表明,這可能是為了讓出現在 `_main` 例程中的 SIMD 指令工作,這些指令只對對齊的地址進行操作。由於我們的例程不包含 SIMD 指令,因此這行程式碼是不必要的。
movl $0, %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
這段程式碼將零移入 EAX,然後將 EAX 移入記憶體位置 EBP - 4,即我們在過程開始時在堆疊上保留的臨時空間。然後將記憶體位置 EBP - 4 移回 EAX;顯然,這不是最佳化的程式碼。注意,括號表示記憶體位置,而括號前的數字表示相對於該記憶體位置的偏移量。
call __alloca
call ___main
這些函式是 C 庫設定的一部分。由於我們正在呼叫 C 庫中的函式,因此可能需要這些。它們執行的確切操作取決於平臺和安裝的 GNU 工具版本。
movl $LC0, (%esp)
call _printf
這段程式碼(終於!)列印了我們的訊息。首先,它將 ASCII 字串的位置移到堆疊的頂部。C 編譯器似乎已經將 popl %eax; pushl $LC0 的一系列指令最佳化成了一個將值移到堆疊頂部的指令。然後,它呼叫 C 庫中的 _printf 子程式將訊息列印到控制檯。
movl $0, %eax
這行程式碼將零(我們的返回值)儲存在 EAX 中。C 呼叫約定是在退出例程時將返回值儲存在 EAX 中。
leave
這行程式碼通常出現在子程式的末尾,它透過將 EBP 複製到 ESP 來釋放我們在堆疊上保留的空間,然後將 EBP 的儲存值彈回到 EBP。
ret
這行程式碼透過從堆疊中彈出會儲存的指令指標來將控制權返回到呼叫過程。
注意,我們只需要在需要呼叫 C 庫中的函式(如 printf())時才需要呼叫 C 庫設定例程。如果我們直接與作業系統通訊,可以避免呼叫這些例程。直接與作業系統通訊的缺點是會失去可移植性;我們的程式碼將被鎖定到特定的作業系統。但是,為了教學目的,讓我們看一下如何在 Windows 下進行操作。以下是可在 Mingw 或 Cygwin 下編譯的 C 原始碼
#include <windows.h>
int main(void) {
LPSTR text = "Hello, world!\n";
DWORD charsWritten;
HANDLE hStdout;
hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
WriteFile(hStdout, text, 14, &charsWritten, NULL);
return 0;
}
理想情況下,您希望檢查 “GetStdHandle” 和 “WriteFile” 的返回值,以確保它們正常工作,但這對於我們的目的已經足夠了。以下是生成的彙編程式碼
.file "hello2.c"
.def ___main; .scl 2; .type 32; .endef
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -16(%ebp)
movl -16(%ebp), %eax
call __alloca
call ___main
movl $LC0, -4(%ebp)
movl $-11, (%esp)
call _GetStdHandle@4
subl $4, %esp
movl %eax, -12(%ebp)
movl $0, 16(%esp)
leal -8(%ebp), %eax
movl %eax, 12(%esp)
movl $14, 8(%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
movl -12(%ebp), %eax
movl %eax, (%esp)
call _WriteFile@20
subl $20, %esp
movl $0, %eax
leave
ret
即使我們從未使用過 C 標準庫,生成的程式碼也為我們初始化了它。另外,還有許多不必要的堆疊操作。我們可以簡化
.text
LC0:
.ascii "Hello, world!\12\0"
.globl _main
_main:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
pushl $-11
call _GetStdHandle@4
pushl $0
leal -4(%ebp), %ebx
pushl %ebx
pushl $14
pushl $LC0
pushl %eax
call _WriteFile@20
movl $0, %eax
leave
ret
逐行分析
pushl %ebp
movl %esp, %ebp
subl $4, %esp
我們儲存了舊的 EBP 併為堆疊保留了四個位元組,因為呼叫 WriteFile 需要一個地方來儲存寫入的字元數,這是一個 4 位元組值。
pushl $-11
call _GetStdHandle@4
我們將常量值 STD_OUTPUT_HANDLE(-11)壓入堆疊並呼叫 GetStdHandle。返回的控制代碼值在 EAX 中。
pushl $0
leal -4(%ebp), %ebx
pushl %ebx
pushl $14
pushl $LC0
pushl %eax
call _WriteFile@20
我們將 WriteFile 的引數壓入堆疊並呼叫它。注意,Windows 呼叫約定是將引數從右到左壓入堆疊。裝入有效地址(lea)指令將 EBP 的值加上 -4,得到我們在堆疊上儲存的列印字元數的位置,我們將它儲存在 EBX 中,然後壓入堆疊。另外,EAX 仍然包含 GetStdHandle 呼叫的返回值,因此我們直接將其壓入堆疊。
movl $0, %eax
leave
這裡我們設定了程式的返回值,並使用 leave 指令恢復 EBP 和 ESP 的值。
UnixWare 彙編器,以及可能其他源自 AT&T 的 ix86 Unix 彙編器,在某些情況下會生成帶有反轉源暫存器和目標暫存器的浮點指令。不幸的是,gcc 和可能許多其他程式都使用這種反轉語法,所以我們只能接受它。
例如
fsub %st, %st(3)
導致 %st(3) 被更新為 %st - %st(3),而不是預期的 %st(3) - %st。這種情況發生在所有帶有兩個暫存器運算元的非交換算術浮點運算中,其中源暫存器為 %st,目標暫存器為 %st(i)。
注意,即使 objdump -d -M intel 仍然使用反轉的操作碼,因此請使用其他反彙編器檢查這一點。有關更多資訊,請參見 http://bugs.debian.org/372528。
您可以在 GNU GAS 文件頁面閱讀更多關於 GAS 的內容
https://sourceware.org/binutils/docs/as/
| 指令 | 含義 |
|---|---|
movq %rax, %rbx
|
rbx ≔ rax |
movq $123, %rax
|
rax ≔ |
movq %rsi, -16(%rbp)
|
mem[rbp-16] ≔ rsi |
subq $10, %rbp
|
rbp ≔ rbp − 10 |
cmpl %eax %ebx
|
將 ebx 與 eax 進行比較,並相應地設定標誌。如果 eax = ebx,則零標誌被設定。 |
jmp location
|
無條件跳轉 |
je location
|
如果等於標誌被設定,則跳轉到 location |
jg, jge, jl, jle, jne, … |
>, ≥, <, ≤, ≠, … |