序言
本教程描述了32位x86匯編語言編程的基礎知識,涵蓋了可用指令和匯編器指令的一小部分但很有用的子集。
有幾種不同的匯編語言可用於生成x86機器碼。在這里我們使用Microsoft Macro Assembler (MASM)
作為示例。MASM使用標准的Intel語法編寫x86匯編代碼。完整的x86指令集十分龐大復雜(英特爾的x86指令集手冊長達2900多頁),我們在本教程中不會全部介紹。例如,x86指令集有一個16位的子集。使用16位編程模型可能相當復雜。它采用分段內存模型,對寄存器使用有更多限制,等等。
在本教程中,我們將把注意力集中在x86編程的更多現代方面上,並深入研究指令集,以便對x86編程有一個基本的了解。
寄存器
現代(即386或更高)x86處理器有8個32位通用寄存器,如圖1所示。寄存器名稱大多是歷史名稱。例如,EAX過去被稱為累加器(accumulator),因為它被許多算術運算使用,而ECX被稱為計數器(counter),因為它被用來保存循環索引。雖然大多數寄存器在現代指令集中已經失去了它們的特殊用途,但按照慣例,有兩個寄存器被保留用於特殊用途——堆棧指針(ESP)和基指針(EBP)。
對於EAX、EBX、ECX和EDX寄存器,可以使用子部分。例如,EAX的最低有效2個字節可被視作稱為AX的16位寄存器。AX的最低有效字節可用作稱為AL的單個8位寄存器,而AX的最高有效字節可用作稱為AH的單個8位寄存器。這些名稱指的是同一物理寄存器。將兩字節量放入DX時,更新會影響DH、DL和EDX的值。這些子寄存器主要是兼容較舊的16位指令集版本。但是,在處理小於32位的數據(例如,1字節ASCII字符)時,它們有時會很方便。
在匯編語言中引用寄存器時,名稱不區分大小寫。例如,名稱EAX和eax引用相同的寄存器。
x86的通用寄存器有eax、ebx、ecx、edx、edi、esi。這些寄存器在大多數指令中是可以任意使用的,但有些指令限制只能用其中某些寄存器做某種用途。比如idiv、系統中斷……
x86的特殊寄存器有ebp、esp、eip、eflags。eip是程序計數器(program counter,用於存放下一條指令所在單元的地址)。eflags保存計算過程中產生的標志位,包括進位、溢出、零、負數四個標志位,在x86的文檔中這幾個標志位分別稱為CF、OF、ZF、SF。ebp和esp用於維護函數調用的棧幀(詳見調用約定
)。
內存空間和尋址模式
聲明靜態數據區域
為此,您可以使用特殊的匯編指令在x86匯編中聲明靜態數據區域(類似於全局變量)。數據聲明前面應該有.DATA
指令。在此指令之后,可以使用指令DB、DW和DD分別聲明一個、兩個和四個字節的數據位置。聲明的位置可以用名稱標記以供以后引用——這類似於按名稱聲明變量,但遵循一些較低級別的規則。例如,相鄰定義的標簽在內存中是連續存放的。
示例聲明:
.DATA
var DB 64 ; 聲明一個字節,位置為var,值為6
var2 DB ? ; 聲明一個未初始化的字節,位置為var2
DB 10 ; 聲明一個沒有標簽的字節,值為10,位置是var2+1
X DW ? ; 聲明一個2字節的未初始化值,位置為X
Y DD 30000 ; 聲明一個4字節值,位置為Y,初始化為30000
在高級語言中,數組可以有很多維,並且可以通過索引進行訪問,而x86匯編語言中的數組則不同,它只是位於內存中的若干個連續的單元格。只需列出值即可聲明數組,如下面的第一個示例所示。用於聲明數據數組的另外兩種常用方法是DUP指令和字符串常量的使用。DUP指令告訴匯編器將表達式復制給定的次數。例如,4 DUP(2)
等於2,2,2,2
。
下面是一些示例:
Z DD 1, 2, 3 ; 聲明三個4字節值,初始化為1、2和3。位置Z+8的值將為3
bytes DB 10 DUP(?) ; 從位置bytes開始聲明10個未初始化的字節
arr DD 100 DUP(0) ; 聲明100個從位置arr開始的4字節字,全部初始化為0
str DB 'hello',0 ; 從地址str開始聲明6個字節,初始化為hello的ASCII字符值和空字節0。
內存尋址
現代x86兼容處理器能夠尋址多達232字節的內存:內存地址是32位寬。在上面的示例中,我們使用標簽來引用內存區域,這些標簽實際上被匯編器替換為指定32位的內存地址。除了支持通過標簽(比如常量值)引用內存區域之外,x86還提供了計算和引用內存地址的靈活方案:最多可以將兩個32位寄存器和一個32位有符號常量相加來計算內存地址。其中一個寄存器可以任選地預乘2、4或8。
尋址模式可以與許多x86指令一起使用(我們將在下一節描述它們)。這里我們演示了一些使用MOV指令在寄存器和內存之間移動數據的示例。此指令有兩個操作對象:第一個是目標,第二個指定源。
使用地址計算的MOV指令的一些示例如下:
mov eax, [ebx] ; 將EBX中的地址所指向的內存中的4個字節移動到EAX中
mov [var], ebx ; 將EBX的內容移到內存地址var的4個字節中(注意,不加中括號的var是一個32位地址常量,加中括號才是取地址指向的內容)
mov eax, [esi-4] ; 將內存地址ESI+(-4)上的4個字節移入EAX
mov [esi+eax], cl ; 將CL的內容移到地址為ESI+EAX的單字節中
mov edx, [esi+4*ebx] ; 將地址為ESI+4*EBX的4字節數據移動到EDX中
無效地址計算的一些示例包括:
mov eax, [ebx-ecx] ; 兩個寄存器的值只能相加
mov [eax+esi+edi], ebx ; 在地址計算表達式中最多有2個寄存器出現
字節大小
通常,數據項在給定內存地址的預期大小可以從引用它的匯編代碼指令中推斷出來。例如,在上述所有指令中,可以從寄存器操作對象的大小推斷內存區域的大小。當我們加載一個32位寄存器時,匯編器可以推斷出我們引用的內存區域是4字節寬。當我們將一個字節寄存器的值存儲到內存中時,匯編程序可以推斷出我們希望該地址引用內存中的一個字節。
然而,在某些情況下,引用的內存區域的大小是不明確的。考慮指令mov [ebx], 2
。此指令是否應將值2移入地址EBX處的單字節空間中?也許它應該將32位整數表示的2移到地址EBX開始的4個字節中。由於這兩種解釋都是有效的可能解釋,因此必須明確指示匯編程序哪種解釋是正確的。大小指令BYTE PTR
、WORD PTR
和DWORD PTR
用於此目的,分別表示1、2和4字節的大小。
例如:
mov BYTE PTR [ebx], 2 ; 將2移入EBX指向的內存地址的單字節
mov WORD PTR [ebx], 2 ; 將16位整數表示的2移動到從EBX指向的地址開始的2個字節中
mov DWORD PTR [ebx], 2 ; 將32位整數表示的2移動到從EBX指向的地址開始的4個字節中
常用指令
機器指令通常分為三類:數據移動、算術/邏輯和控制流。在本節中,我們將查看每個類別中的重要x86指令示例。本節不會詳盡地列出所有x86指令,但它對於新手來說仍將會非常有用。有關完整列表,請參閱英特爾指令集參考。
我們使用以下符號:
<reg32> ; 任何32位寄存器 (EAX, EBX, ECX, EDX, ESI, EDI, ESP, or EBP)
<reg16> ; 任何16位寄存器 (AX, BX, CX, or DX)
<reg8> ; 任何8位寄存器 (AH, BH, CH, DH, AL, BL, CL, or DL)
<reg> ; 任何寄存器
<mem> ; 一個內存地址 (e.g., [eax], [var + 4], or dword ptr [eax+ebx])
<con32> ; 任何32位常量
<con16> ; 任何16位常量
<con8> ; 任何8位常量
<con> ; 任何8、16、32位常量
數據移動說明
mov — Move (操作碼: 88, 89, 8A, 8B, 8C, 8E, ...)
MOV指令將其第二操作對象(即寄存器內容、內存內容或常量值)所引用的數據項復制到其第一操作對象(即寄存器或內存)所引用的位置。雖然寄存器到寄存器的移動是可能的,但是直接內存到內存的移動是不可能的。在需要內存傳輸的情況下,必須首先將源內存中的內容加載到寄存器中,然后才能將其存儲到目標內存地址。
語法
mov <reg>,<reg>
mov <reg>,<mem>
mov <mem>,<reg>
mov <reg>,<const>
mov <mem>,<const>
示例
mov eax, ebx ; 將EBX中的值復制到EAX
mov byte ptr [var], 5 ; 將5存儲到地址var的一個字節中
push — Push stack (操作碼: FF, 89, 8A, 8B, 8C, 8E, ...)
PUSH指令將其操作對象放在內存中硬件支持堆棧的頂部。具體地說,PUSH首先將ESP遞減4,然后將其操作對象放入內存地址[ESP]
處的32位大小的區域中。ESP(堆棧指針)通過push遞減,因為x86堆棧向下增長——即堆棧從高位地址增長到低位地址。
Syntax
push <reg32>
push <mem>
push <con32>
示例
push eax ; 將eax入棧
push [var] ; 將地址var處開始的4個字節入棧
pop — Pop stack
POP指令將4字節數據元素從硬件支持的堆棧頂部移至指定的操作對象(即寄存器或內存位置)。它首先將位於內存位置[SP]
的4個字節移動到指定的寄存器或內存位置,然后將SP遞增4。
語法
pop <reg32>
pop <mem>
示例
pop edi ; 將堆棧的頂部元素彈出到EDI中
pop [ebx] ; 將堆棧的頂部元素彈出到內存從EBX位置開始的四個字節中
lea — 加載有效地址
LEA指令將其第二個操作對象指定的地址放入其第一個操作對象指定的寄存器中。注意,內存位置的內容不會被加載,並且只有有效地址會被計算並放入寄存器中。這對於獲取指向內存區域的指針非常有用。
語法
lea <reg32>,<mem>
示例
lea edi, [ebx+4*esi] ; 將地址EBX+4*ESI放入EDI
lea eax, [var] ; 將var中的值放在EAX中
算術和邏輯指令
add — 整數加法
ADD指令將其兩個操作對象相加,將結果存儲在其第一個操作對象中。注意,雖然兩個操作對象都可以是寄存器,但最多只有一個操作對象可以是內存位置。
語法
add <reg>,<reg>
add <reg>,<mem>
add <mem>,<reg>
add <reg>,<con>
add <mem>,<con>
示例
add eax, 10 ; EAX ← EAX + 10
add BYTE PTR [var], 10 ; 將存儲在內存地址var的單字節值加上10
sub — 整數減法
SUB指令在其第一個操作對象的值中存儲從其第一個操作對象的值中減去其第二個操作對象的值的結果。與ADD一樣。
語法
sub <reg>,<reg>
sub <reg>,<mem>
sub <mem>,<reg>
sub <reg>,<con>
sub <mem>,<con>
示例
sub al, ah ; AL ← AL - AH
sub eax, 216 ; 從存儲在EAX中的值中減去216
inc, dec — 遞增,遞減
INC指令將其操作對象的內容加1。DEC指令將其操作對象的內容減1。
語法
inc <reg>
inc <mem>
dec <reg>
dec <mem>
示例
dec eax ; 從EAX的內容中減去1
inc DWORD PTR [var] ; 將存儲在位置var的32位整數加1
imul — 整數乘法
IMUL指令有兩種基本格式:兩個操作對象和三個操作對象。
兩個操作對象的形式將其兩個操作對象相乘,並將結果存儲在第一個操作對象中。結果(即第一個)操作對象必須是寄存器。
三個操作對象的形式將其第二個和第三個操作對象相乘,並將結果存儲在其第一個操作對象中。同樣,結果操作對象必須是寄存器。此外,第三個操作對象被限制為常量值。
語法
imul <reg32>,<reg32>
imul <reg32>,<mem>
imul <reg32>,<reg32>,<con>
imul <reg32>,<mem>,<con>
示例
imul eax, [var] ; 將EAX的內容乘以內存位置var的32位內容並將結果存儲在EAX中
imul esi, edi, 25 ; ESI → EDI * 25
idiv — 整數除法
IDIV指令將64位整數EDX:EAX
(通過將EDX視為最高有效四個字節,EAX視為最低有效四個字節)的內容除以指定的操作對象值。除法的商結果存儲在EAX中,其余數的存儲在EDX中。
語法
idiv <reg32>
idiv <mem>
示例
idiv ebx ; 將EDX:EAX的內容除以EBX的內容。把商放在EAX中,余放在EDX中
idiv DWORD PTR [var] ; 將EDX:EAX的內容除以存儲在內存位置var的32位值。把商放在EAX中,余放在EDX中
and, or, xor — 按位與、或和異或
這些指令對其操作對象執行指定的位運算(分別為按位與、或和異或),並將結果放在第一個操作對象位置。
語法
and <reg>,<reg>
and <reg>,<mem>
and <mem>,<reg>
and <reg>,<con>
and <mem>,<con>
or <reg>,<reg>
or <reg>,<mem>
or <mem>,<reg>
or <reg>,<con>
or <mem>,<con>
xor <reg>,<reg>
xor <reg>,<mem>
xor <mem>,<reg>
xor <reg>,<con>
xor <mem>,<con>
示例
and eax, 0fH ; 清除EAX的除最后4位以外的所有位
xor edx, edx ; 將EDX的內容設置為零
not — 按位取反
NOT 指令觸發(翻轉)操作對象中的所有位。其結果被稱為反碼。
語法
not <reg>
not <mem>
示例
not BYTE PTR [var] ; 取反內存位置var的字節中的所有位
neg — 求補
NEG是匯編指令中的求補指令,對操作對象執行求補運算:用零減去操作對象,然后結果返回操作對象。求補運算也可以表達成:將操作對象按位取反后加1。
語法
neg <reg>
neg <mem>
示例
neg eax ; EAX → - EAX
shl, shr — 左移,右移
這些指令將其第一個操作對象內容中的位左右移位,用零填充產生的空位位置。移位后的操作對象最多可以移位31位。要移位的位數由第二個操作對象指定,該操作對象可以是8位常量,也可以是寄存器CL。在任一情況下,以32為模執行大於31的移位計數。
語法
shl <reg>,<con8>
shl <mem>,<con8>
shl <reg>,<cl>
shl <mem>,<cl>
shr <reg>,<con8>
shr <mem>,<con8>
shr <reg>,<cl>
shr <mem>,<cl>
示例
shl eax, 1 ; 將EAX的值乘以2(如果最高有效位為0)
shr ebx, cl ; 將EBX的值除以2^n^的結果的下限存儲在EBX中,其中n是CL中的值
控制流指令
x86處理器維護一個指令指針(IP)寄存器,它是一個32位值,指示當前指令在內存中的起始位置。通常,在執行一條指令后,它會遞增以指向內存中的下一條指令的起始位置。IP寄存器不能直接操作,而是由提供的控制流指令隱式更新。
我們使用符號<LABEL>
來表示代碼中已標記的位置。通過輸入標簽名稱后跟冒號,可以在x86匯編代碼中的任意位置插入標簽。例如:
mov esi, [ebp+8]
begin: xor ecx, ecx
mov eax, [esi]
此代碼段中的第二條指令被標記為BEGIN。在代碼的其他地方,我們可以使用更方便的符號名稱BEGIN來引用此指令所在的內存中的位置。這個標簽只是表示位置的一種方便方式,而不是它的32位值。
JMP — 跳轉
將程序控制流轉移到操作對象指示的內存位置上
語法
jmp <label>
示例
jmp begin ; 跳到標記為begin的指令位置
jcondition — 條件跳轉
這些指令是基於一組條件碼狀態判斷是否進行跳轉,該條件碼被存儲在稱為機器狀態字的特殊寄存器中。機器狀態字的內容包括有關上次執行的算術運算的信息。例如,此字的某一比特位表示最后結果是否為零,某另一個比特位指示上次結果是否為負數。基於這些條件碼,可以執行多個條件跳轉。例如,如果上次算術運算的結果為零,則JZ指令執行到指定操作對象標簽的跳轉。否則,控制按順序前進到下一條指令。
許多條件分支的名字都是根據上一次執行的特殊比較指令cmp命名的(見下文)。例如,條件分支(如JLE和JNE)基於首先對所需操作對象執行CMP操作。
語法
je <label> ; 相等時跳轉
jne <label> ; 不相等時跳轉
jz <label> ; 最后結果為零時跳轉
jg <label> ; 大於時跳轉
jge <label> ; 大於等於時跳轉
jl <label> ; 小於時跳轉
jle <label> ; 小於等於時跳轉
示例
cmp eax, ebx
jle done ; 如果EAX的中的值小於或等於EBX中的值,跳至標簽done。否則,繼續執行下一條指令
cmp — 比較
比較兩個指定操作對象的值,適當設置機器狀態字中的條件代碼。此指令等同於SUB指令,不同之處在於將丟棄減法結果,而不是替換第一個操作對象。
語法
cmp <reg>,<reg>
cmp <reg>,<mem>
cmp <mem>,<reg>
cmp <reg>,<con>
示例
cmp DWORD PTR [var], 10
jeq loop ; 如果存儲在var中的4個字節的值等於4字節整數常量10,則跳轉到標記為loop的位置
call, ret — 子程序調用和返回
這些指令實現一個子程序調用和返回。CALL指令首先將當前代碼位置壓入到內存中硬件支持的堆棧中(有關詳細信息,請參閱PUSH指令),然后無條件跳轉到標簽操作對象指示的代碼位置。與簡單的跳轉指令不同,CALL指令保存當前位置,並在子程序完成時返回到此處。
RET指令實現子程序返回機制。此指令首先從硬件支持的內存堆棧中彈出代碼位置(有關詳細信息,請參閱POP指令),然后無條件跳轉至該代碼位置。
語法
call <label>
ret
調用約定
為了允許單獨的程序員共享代碼,開發供多個程序使用的庫,並且為了簡化子程序的使用,程序員通常采用共同的調用約定。調用約定是關於如何調用程序和從程序返回的協議。例如,給定一組調用約定規則,程序員不需要檢查子程序的定義來確定應該如何將參數傳遞給該子程序。此外,給定一組調用約定規則,可以使高級語言編譯器遵循這些規則,從而允許手工編碼的匯編語言程序和高級語言程序相互調用。
在實踐中,存在許多調用約定。我們采用廣泛使用的C語言調用約定。遵循此約定將允許您編寫可從C(和C++)代碼安全調用的匯編語言子程序,還將使您能夠從匯編語言代碼調用C庫函數。
C調用約定在很大程度上基於硬件支持的堆棧的使用。它基於PUSH、POP、CALL和RET指令。子程序參數通過堆棧傳遞。寄存器保存在堆棧上,子程序使用的局部變量放在堆棧的內存中。在大多數處理器上實現的絕大多數高級過程語言都使用了類似的調用約定。
調用約定分為兩組規則:第一組規則由子程序的調用者使用,第二組規則由子程序的編寫者(被調用者)遵守。應該強調的是,在實現這些約定時產生的馬虎會導致致命的程序錯誤,因為堆棧將處於不一致的狀態。因此,在您自己的子程序中實現調用約定時應該非常小心。
可視化調用約定操作的一個好方法是在子程序執行期間繪制堆棧附近區域的內容。上圖描述了具有三個參數和三個局部變量的子程序執行期間的堆棧內容。堆棧中描述的單元是32位寬的內存空間,因此單元的內存地址相隔4字節。第一個參數位於距基指針8字節的偏移量處。調用指令在堆棧上的參數上方(基指針下方)放置返回地址,從而導致從基指針到第一個參數的額外4個字節的偏移量。當RET指令用於從子程序返回時,它將跳轉到堆棧上存儲的返回地址。
調用方規則
要進行子程序調用,調用方應:
-
在調用子程序之前,調用方應保存某些寄存器的內容,這些寄存器被稱為
caller-saved
。調用方保存寄存器為EAX、ECX、EDX。由於允許被調用子程序修改這些寄存器,因此如果調用者在子程序返回后依賴於它們的值,則調用者必須將這些寄存器中的值壓入堆棧(以便在子程序返回后恢復它們。 -
若要將參數傳遞給子程序,請在調用之前將它們壓入堆棧。參數應按倒序壓入(即最后一個參數先入棧)。由於堆棧向下生長,第一個參數將存儲在最低地址(這種參數的倒序在歷史上用於允許向函數傳遞可變數量的參數)。
-
要調用子程序,請使用CALL指令。此指令將返回地址放在堆棧上的參數之上,並跳轉到子程序代碼。這將調用子程序,該子程序應遵循下面的被調用者規則。
子程序返回后(緊跟在CALL指令之后),調用者可以期望在寄存器EAX中找到該子程序的返回值。要恢復機器狀態,調用方應:
-
從堆棧中刪除參數。這會將堆棧恢復到執行調用之前的狀態。
-
通過從堆棧中彈出調用方保存寄存器(EAX、ECX、EDX)的內容來恢復這些寄存器的內容。調用者可以假設該子程序沒有修改任何其他寄存器。
示例
下面的代碼顯示了遵循調用方規則的函數調用。調用方正在調用一個函數_myFunc,該函數接受三個整數參數。第一個參數在EAX中,第二個參數是常量216;第三個參數在內存位置var中。
push [var] ; 先壓入最后一個參數
push 216 ; 再將倒數第二個參數入棧
push eax ; 最后將第一個參數入棧
call _myFunc ; 調用函數_myFunc
add esp, 12
請注意,調用返回后,調用方使用Add指令清理堆棧。我們在堆棧上有12個字節(3個參數*每個參數大小4個字節),堆棧向下生長。因此,要去掉這些參數,我們只需在堆棧指針上加12即可。
_myFunc產生的結果現在可以在寄存器EAX中使用。調用方保存寄存器(ECX和EDX)的值可能已經被更改,如果調用方在調用之后想繼續使用它們,則需要在調用之前將它們保存在堆棧中,並在調用之后恢復它們。
被調用方規則
子程序在初始化時應遵循以下規則:
- 將EBP的值壓入堆棧,然后按照以下說明將ESP的值復制到EBP中:
push ebp
mov ebp, esp
-
此初始操作維護基指針EBP。按照慣例,基指針用作查找堆棧上的參數和局部變量的參考點。當子程序執行時,基指針保存該子程序開始執行時的堆棧指針值的副本。參數和局部變量將始終位於距離基指針值的已知常量偏移量處。我們在子程序的開始處壓入舊的基指針值,以便稍后當子程序返回時恢復調用方的基指針值。請記住,調用方並不期望子程序更改基指針值。然后,我們將堆棧指針移動到EBP所指示的內存地址,以獲得訪問參數和局部變量的參考點。
-
接下來,通過在堆棧上騰出空間來分配局部變量。回想一下,堆棧向下增長,因此為了在堆棧頂部騰出空間,堆棧指針應該遞減。堆棧指針遞減的數量取決於所需的局部變量的數量和大小。例如,如果需要3個整數局部變量(每個4字節),堆棧指針將需要減12,以便為這些局部變量騰出空間(即,
sub esp,12
)。與參數一樣,局部變量將位於距基指針已知偏移量處。 -
接下來,保存函數將使用的被調用者保存寄存器(
callee-saved
)的值。要保存寄存器,請將它們壓入堆棧。被調用者保存寄存器是EBX、EDI和ESI(ESP和EBP也將根據調用約定保留,但在此步驟中不需要推入堆棧)。
在執行這三個動作之后,子程序的主體可以繼續。當子程序返回時,它必須遵循以下步驟:
-
將返回值保留為EAX。
-
恢復任何已修改的被調用方保存寄存器(EDI和ESI)的舊值,通過從堆棧中彈出寄存器內容來恢復它們,寄存器應該以與它們被推入相反的順序彈出。
-
取消分配局部變量。最顯而易見的方法可能是將堆棧指針加上相應的偏移量(因為空間是通過從堆棧指針中減去所需的量來分配的)。實際上,釋放變量的一種不太容易出錯的方法是將堆棧指針指向基指針值:
mov esp,ebp
。這是可行的,因為基指針在分配局部變量之前存入了堆棧指針的值。 -
在返回之前,通過從堆棧中彈出EBP來恢復調用方的基指針值。回想一下,我們在執行子程序時所做的第一件事就是壓入基指針以保存其舊值。
-
最后,通過執行RET指令返回給調用方。此指令將從堆棧中查找適當的返回地址並刪除它。
請注意,被調用者規則可以干凈利落地分為兩個部分,它們基本上是彼此的對稱鏡像。規則的前半部分應用於函數的開頭,規則的后半部分應用於函數的末尾。
示例
以下是遵循被調用方規則的示例函數定義:
.486
.MODEL FLAT
.CODE
PUBLIC _myFunc
_myFunc PROC
; 子程序開頭
push ebp ; 保存舊的基指針值
mov ebp, esp ; 設置新的基指針值
sub esp, 4 ; 為一個4字節的局部變量騰出空間
push edi ; 保存該函數將會修改的寄存器的值
push esi ; 此函數使用EDI和ESI(無需保存EBX、EBP或ESP)
; 子程序主體
mov eax, [ebp+8] ; 將參數1的值移動到EAX中
mov esi, [ebp+12] ; 將參數2的值移動到ESI中
mov edi, [ebp+16] ; 將參數3的值移動到EDI中
mov [ebp-4], edi ; 將EDI移入局部變量
add [ebp-4], esi ; 將ESI加到局部變量上
add eax, [ebp-4] ; 將局部變量的內容加到EAX上,(最終結果)
; 子程序結尾
pop esi ; 恢復寄存器的值
pop edi
mov esp, ebp ; 銷毀局部變量
pop ebp ; 恢復調用方的基指針值
ret
_myFunc ENDP
END
子程序開頭執行以下標准操作:在EBP中保存堆棧指針的快照(基指針),通過遞減堆棧指針來分配局部變量,以及在堆棧上保存寄存器值。
在子程序的主體中,我們可以看到基指針的使用。在子程序執行期間,參數和局部變量都位於基指針的常量偏移量上。特別地,我們注意到,由於參數是在調用子程序之前放到堆棧上的,所以它們總是位於堆棧上的基指針之下(即更高的地址)。子程序的第一個參數始終位於內存位置EBP+8
,第二個參數位於EBP+12
,第三個參數位於EBP+16
。類似地,由於局部變量是在基指針設置之后分配的,因此它們始終位於堆棧的基指針上方(即較低的地址)。特別是,第一個局部變量始終位於EBP-4
,第二個局部變量位於EBP-8
,依此類推。基指針的這種常規用法允許我們快速識別函數體中局部變量和參數的使用。
函數結尾基本上是函數開頭的鏡像。從堆棧中恢復調用方的寄存器值、通過重置堆棧指針來釋放局部變量、恢復調用方的基指針值、並使用RET指令返回到調用方代碼的適當位置。
AT&T和Intel格式的區別
上面講到的匯編格式是Intel的,下面介紹一下AT&T和Intel格式的區別。
一、AT&T和Intel格式區別
- 在 AT&T 匯編格式中,寄存器名要加上 '%' 作為前綴;而在 Intel 匯編格式中,寄存器名不需要加前綴。例如:
AT&T 格式 | Intel 格式 |
---|---|
pushl %eax |
push eax |
- 在 AT&T 匯編格式中,用 '$' 前綴表示一個立即操作數(直接操作數);而在 Intel 匯編格式中,立即數的表示不用帶任何前綴。例如:
AT&T 格式 | Intel 格式 |
---|---|
pushl $1 |
push 1 |
- AT&T 和 Intel 格式中的源操作數和目標操作數的位置正好相反。在 Intel 匯編格式中,目標操作數在源操作數的左邊;而在 AT&T 匯編格式中,目標操作數在源操作數的右邊。例如:
AT&T 格式 | Intel 格式 |
---|---|
addl $1, %eax |
add eax, 1 |
- 在 AT&T 匯編格式中,操作數的字長由操作符的最后一個字母決定,后綴'b'、'w'、'l'分別表示操作數為字節(byte,8 比特)、字(word,16 比特)和長字(long,32比特);而在 Intel 匯編格式中,操作數的字長是用 "
byte ptr
" 和 "word ptr
" 等前綴來表示的。例如:
AT&T 格式 | Intel 格式 |
---|---|
movb val, %al |
mov al, byte ptr val |
-
在 AT&T 匯編格式中,絕對轉移和調用指令(jump/call)的操作數(也即轉移或調用的目標地址),前要加上'*'作為前綴(有點像C語言里的指針),而在 Intel 格式中則不需要。
-
遠程轉移指令和遠程子調用指令的操作碼,在 AT&T 匯編格式中為
ljump
和lcall
,而在 Intel 匯編格式中則為jmp far
和call far
,即:
AT&T 格式 | Intel 格式 |
---|---|
ljump $section, $offset |
jmp far section:offset |
lcall $section, $offset |
call far section:offset |
- 與之相應的遠程返回指令則為:
AT&T 格式 | Intel 格式 |
---|---|
lret $stack_adjust |
ret far stack_adjust |
- 對於間接尋址的一般格式,在 Intel 匯編格式中,內存操作數的尋址方式為:
section:[base + index*scale + disp]
。 - 而在 AT&T 匯編格式中,內存操作數的尋址方式是
section:disp(base, index, scale)
。由於 Linux 工作在保護模式下,用的是 32 位線性地址,所以在計算地址時不用考慮段基址和偏移量,而是采用如下的地址計算方法:disp + base + index * scale
。其中disp
和scale
必須是常數,base
和index
必須是寄存器。 - 注意在AT&T格式中隱含了所進行的計算。例如,當section省略,index和scale 也省略,base為ebp,而disp(位移)為4時,表示如下:AT&T格式
-4(%ebp)
,Intel格式[ebp - 4]
。 - 在AT&T格式的括號中如果只有一項base,就可省略逗號,否則不能省略,所以
(%ebp)
相當於(%ebp,,)
,進一步相當於(%ebp, 0, 0)
。有如,當index為eax,scale為4(32位),disp為foo,而其他均省略,則表示為:AT&T格式foo(, %EAX, 4)
,Intel格式[foo+EAX*4]
。這種尋址方式常常用於在數據結構數組中訪問特定元素內的一個字段,base為數組的起始地址,scale為每個數組元素的大小,index為下標。如果數組元素是數據結構,則disp為具體字段在結構中的位移。 - 下面是一些內存操作數的例子:
AT&T 格式 | Intel 格式 |
---|---|
movl -4(%ebp), %eax |
mov eax, [ebp - 4] |
movl array(, %eax, 4), %eax |
mov eax, [eax*4 + array] |
movw array(%ebx, %eax, 4), %cx |
mov cx, [ebx + 4*eax + array] |
movb $4, %fs:(%eax) |
mov fs:eax, 4 |
二、Hello World 示例
既然所有程序設計語言的第一個例子都是在屏幕上打印一個字符串 "Hello World!",那我們也以這種方式來開始介紹 Linux 下的匯編語言程序設計。
在 Linux 操作系統中,你有很多辦法可以實現在屏幕上顯示一個字符串,但最簡潔的方式是使用 Linux 內核提供的系統調用。使用這種方法最大的好處是可以直接和操作系統的內核進行通訊,不需要鏈接諸如 libc 這樣的函數庫,也不需要使用 ELF 解釋器,因而代碼尺寸小且執行速度快。
Linux 是一個運行在保護模式下的 32 位操作系統,采用 flat memory
模式,目前最常用到的是 ELF 格式的二進制代碼。一個 ELF 格式的可執行程序通常划分為如下幾個部分:.text
、.data
和 .bss
,其中 .text
是只讀的代碼區,.data
是可讀可寫的數據區,而 .bss
則是可讀可寫且沒有初始化的數據區。代碼區和數據區在 ELF 中統稱為 section,根據實際需要你可以使用其它標准的 section,也可以添加自定義 section,但一個 ELF 可執行程序至少應該有一個 .text
部分。下面給出我們的第一個匯編程序,用的是 AT&T 匯編語言格式:
例1. AT&T 格式
#hello.s
.data # 數據段聲明
msg : .string "Hello, world!\\n" # 要輸出的字符串
len = . - msg # 字串長度
.text # 代碼段聲明
.global _start # 指定入口函數
_start: # 在屏幕上顯示一個字符串
movl $len, %edx # 參數三:字符串長度
movl $msg, %ecx # 參數二:要顯示的字符串
movl $1, %ebx # 參數一:文件描述符(stdout)
movl $4, %eax # 系統調用號(sys_write)
int $0x80 # 調用內核功能
# 退出程序
movl $0,%ebx # 參數一:退出代碼
movl $1,%eax # 系統調用號(sys_exit)
int $0x80 # 調用內核功能
初次接觸到 AT&T 格式的匯編代碼時,很多程序員都認為太晦澀難懂了,沒有關系,在 Linux 平台上你同樣可以使用 Intel 格式來編寫匯編程序:
例2. Intel 格式
; hello.asm
section .data ; 數據段聲明
msg db "Hello, world!", 0xA ; 要輸出的字符串
len equ $ - msg ; 字串長度
section .text ; 代碼段聲明
global _start ; 指定入口函數
_start: ; 在屏幕上顯示一個字符串
mov edx, len ; 參數三:字符串長度
mov ecx, msg ; 參數二:要顯示的字符串
mov ebx, 1 ; 參數一:文件描述符(stdout)
mov eax, 4 ; 系統調用號(sys_write)
int 0x80 ; 調用內核功能
; 退出程序
mov ebx, 0 ; 參數一:退出代碼
mov eax, 1 ; 系統調用號(sys_exit)
int 0x80 ; 調用內核功能
上面兩個匯編程序采用的語法雖然完全不同,但功能卻都是調用 Linux 內核提供的 sys_write
來顯示一個字符串,然后再調用 sys_exit
退出程序。在 Linux 內核源文件 include/asm-i386/unistd.h
中,可以找到所有系統調用的定義。
AT&T的JMP之直接跳轉和間接跳轉
假如標簽叫做mylabel,它的地址是0x8048377
,而且有個全局變量b,b存儲的內容就是mylabel的地址,而b的地址是0x80494A8
。有這樣的賦值(加載)語句:
movl $mylabel, %eax ; 把mylabel的地址加載到eax寄存器中
movl %eax, b ; 把mylabel的地址加載到b中
movl $b, %ebx ; 把b的地址加載到ebx寄存器中
我們考慮下面的語句:
jmp mylable
jmp 0x8048377
jmp %eax
jmp *%eax
jmp *(%ebx)
jmp *0x80494A8
jmp *b
jmp $0x5
下面來探究一下這7句jmp語句分別都做了什么:
1.不用說,跳轉到mylabel標簽處繼續執行代碼,但是,是如何跳轉的呢?就是PC加上了mylabel標簽處對於jmp處的一個偏移地址!可執行的二進制代碼是這樣表示的:eb 03
,就是說,pc+0x03
就可以了。
2.這里,0x8048377
是mylabel的地址,我以前研究過,標簽的作用,跟它的地址的作用是等效的。所以,這里的執行效果跟1中的相同。但是,還有些不一樣!這里的二進制代碼成了:e9 03 00 00 00
這里用了32位表示了這個偏移,而在1中,只用了8位!
3.在編譯鏈接的時候,這句代碼會有警告:warning:indirect jmp without '*'
。間接跳轉沒有‘*’符號,但是,執行起來,還是沒有錯。看一下二進制的可執行文件的代碼,發現,給補上了個‘*’號!而且二進制是:ff e0
.
4.其實,4是3的補充版,正常的形式就是4,而三是有警告的被補充的版本。
5.%ebx
是b的地址,那么(%ebx)
表示ebx(ebx的值為地址)指向的地方。這里指向了b的內容,也就是mylabel的地址!於是,化簡后,5也就等效與2,但是,二進制表示是:ff 23
。
6.0x80494A8
是b的地址,這里看做內存數,那么實質上,b指向的值是mylabel的地址,於是,化簡后同2,二進制代碼是:ff 25 a8 94 04 08
。
7.b是標簽,代表一個地址,所以,這里同6,二進制代碼也同6
8.這句話是錯誤的,jmp不支持立即數!
所以說,正確的寫法有:
jmp mylable ; eb 03
jmp 0x8048377 ; e9 03 00 00 00
jmp *%eax ; ff e0
jmp *(%ebx) ; ff 23
jmp *0x80494A8 ; ff 25 a8 94 04 08
jmp *b ; ff 25 a8 94 04 08
1和2叫做間接尋址,就是算偏移量的。后面沒有‘*’號,而是直接一個標簽或者地址(標簽就可以看做是地址),所以說,就是一個直接的地址的值。間接跳轉的二進制代碼是eb或者e9,是e開頭的。
3,4,5,6叫做直接尋址,直接尋址的標識就是這個‘*’號!直接尋址,就是PC直接賦值某個地址,而不是加偏移量。所以,‘*’號后面的部分,其實是一個要賦給PC的值,那么,取值的方式就好想象了!直接跳轉的二進制代碼是ff開頭的。
3是寄存器直接取值;4是寄存器間接取值;5是內存數取值;6是標簽取值(實質上同5)。