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 組合語言語法。
以下是經典的“Hello, world”程式,用 C 語言編寫
#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 fatal error”;你可以將 -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 資料型別的陣列 (char[]),並且不存在其他形式,但由於大多數程式語言會將字串理解為單個實體,因此以這種方式表達它更清晰。)
.globl _main
此行告訴彙編器,標籤 _main 是一個全域性標籤,這允許程式的其他部分看到它。在本例中,連結器需要能夠看到 _main 標籤,因為與程式連結的啟動程式碼會將 _main 作為子程式呼叫。
_main:
這行程式碼聲明瞭 `_main` 標籤,標記了從啟動程式碼呼叫的位置。
pushl %ebp
movl %esp, %ebp
subl $8, %esp
這些行將 EBP 的值儲存到堆疊中,然後將 ESP 的值移到 EBP 中,然後從 ESP 中減去 8。請注意,`pushl` 會自動將 ESP 按適當的長度遞減。每個操作碼末尾的 `l` 指示我們想要使用適用於 *long*(32 位)運算元的操作碼版本;通常彙編器能夠從運算元中確定正確操作碼版本,但為了安全起見,最好包含 `l`、`w`、`b` 或其他字尾。百分號表示暫存器名稱,美元符號表示字面量值。這組指令序列通常在子例程開始時使用,為區域性變數在堆疊中保留空間;EBP 用作基址暫存器來引用區域性變數,並且從 ESP 中減去一個值以在堆疊中保留空間(因為 Intel 堆疊從更高的記憶體位置增長到較低的記憶體位置)。在本例中,在堆疊中保留了 8 個位元組。我們將在後面看到為什麼需要這個空間。
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
這一行透過從堆疊中彈出儲存的指令指標來將控制權返回給呼叫過程。
直接與作業系統通訊
[edit | edit source]請注意,我們只需要在需要呼叫 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 並在堆疊中保留了 4 個位元組,因為呼叫 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 的值。
注意事項
[edit | edit source]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。
其他 GAS 閱讀材料
[edit | edit source]您可以在 GNU GAS 文件頁面閱讀更多關於 GAS 的資訊
https://sourceware.org/binutils/docs/as/
快速參考
[edit | edit source]| 指令 | 含義 |
|---|---|
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, … |
>, ≥, <, ≤, ≠, … |