- x86/x64體系探索及編程
- 鄧志著
- 279字
- 2019-03-01 11:49:32
第2章 x86/x64編程基礎
在這一章里,我們主要了解在x86和x64平臺上編寫匯編程序的基礎和常用的一些指令。
2.1 選擇編譯器
nasm?fasm?yasm?還是masm、gas或其他?
前面三個是免費開源的匯編編譯器,總體上來講都使用Intel的語法。yasm是在nasm的基礎上開發的,與nasm同宗。由于使用了相同的語法,因此nasm的代碼可以直接用yasm來編譯。
yasm雖然更新較慢,但對nasm一些不合理的地方進行了改良。從這個角度來看,yasm比nasm更優秀些,而nasm更新快,能支持更新的指令集。在Windows平臺上,fasm是另一個不錯的選擇,平臺支持比較好,可以直接用來開發Windows上的程序,語法也比較獨特。在對Windows程序結構的支持上,fasm是3個免費的編譯器里做得最好的。
masm是微軟發布的匯編編譯器,現在已經停止單獨發布,被融合在Visual Studio產品中。gas是Linux平臺上的免費開源匯編編譯器,使用AT&T的匯編語法,使用起來比較麻煩。
由于本書的例子是在祼機上直接運行,因此筆者使用nasm,因為它的語法比較簡潔,使用方法簡單,更新速度非常快。不過如果要是用nasm來寫Windows程序則是比較痛苦的,這方面的文檔很少。
從nasm的官網可以下載最新的版本:http://www.nasm.us/pub/nasm/releasebuilds/?C=M;O=D,也可以瀏覽和下載其文檔:http://www.nasm.us/docs.php。
2.2 機器語言
一條機器指令由相應的二進制數標識,直接能被機器識別。在匯編語言出現之前,使用機器指令編寫程序是直接將二進制數輸入計算機中。
C語言中的c=a+b在機器語言中應該怎樣表達?
這是一個很麻煩的過程,a、b和c都是變量,在機器語言中應該怎樣表達?C語言不能直接轉換為機器語言,要先由C編譯器譯出相當的assembly,然后再由assembler生成機器指令,最終再由鏈接器將這些變量的地址定下來。
我們來看看怎樣轉化機器指令。首先用相應的匯編語言表達出來。
mov eax,[a] ; 變量 a 的值放到 eax 寄存器中 add eax,[b] ; 執行 a+b mov [c],eax ; 放到 c 中
在x86機器中,如果兩個內存操作數要進行加法運算,不能直接相加,其中一方必須是寄存器,至少要將一個操作數放入寄存器中。這一表達已經是最簡單形式了,實際上當然不止這么簡單,還要配合程序的上下文結構。如果其中一個變量只是臨時性的,C編譯器可能會選擇不放入內存中。那么這些變量是局部變量還是外部變量呢?編譯器首先要決定變量的地址。
mov eax,[ebp-4] ; 變量 a 是局部變量 add eax,[ebp-8] ; 執行 a+b,變量b也是局部變量 mov [0x0000001c],eax ; 放到 c 中,變量c可能是外部變量
變量a和b是在stack上。在大多數的平臺下,變量c會放入到.data節,可是在進行鏈接之前,c的地址可能只是一個偏移量,不是真正的地址,鏈接器將負責用變量c的真正地址來代替這個偏移值。
上面的匯編語言譯成機器語言為
8b 45 fc ; 對應于 mov eax,[ebp-4] 03 45 f8 ; 對應于 add eax,[ebp-8] a3 1c 00 00 00 ; 對應于 mov [0x0000001c],eax
x86機器是CISC(復雜指令集計算)體系,指令的長度是不固定的,比如上述前面兩條指令是3字節,最后一條指令是5字節。
x86機器指令長度最短1字節,最長15字節。
最后,假定.data節的基地址是0x00408000,那么變量c的地址就是0x00408000+0x1c=0x0040801c,經過鏈接后,最后一條機器指令變成
a3 1c 80 40 00 ; 原始匯編表達形式: mov [c],eax
指令同樣采用little-endian存儲序列,從低到高依次存放a3 1c 80 40 00字節,其中1c 80 40 00是地址值0x0040801c的little-endian字節序排列。
2.3 Hello world
按照慣例,我們先看看“Hello,World”程序的匯編版。
實驗2-1:hello world程序
下面的代碼相當于C語言main()里的代碼。
代碼清單2-1(topic02\ex2-1\setup.asm):
main: ; 這是模塊代碼的入口點。 mov si,caller_message call puts ; 打印信息 mov si,current_eip mov di,caller_address current_eip: call get_hex_string ; 轉換為 hex mov si,caller_address call puts mov si,13 ; 打印回車 call putc mov si,10 ; 打印換行 call putc call say_hello ; 打印信息 jmp $ caller_message db 'Now:I am the caller,address is 0x' caller_address dq 0 hello_message db 13,10,'hello,world!',13,10 db 'This is my first assembly program...',13,10,13,10,0 callee_message db "Now:I'm callee - say_hello(),address is 0x" callee_address dq 0
實際上這段匯編語言相當于下面的幾條C語言語句。
int main() { printf("Now:I am the caller,address is 0x%x",get_hex_string(current_eip)); printf(" "); say_hell0(); /* 調用 say_hello() */ }
相比而言,匯編語言的代碼量就大得多了。下面是say_hello()的匯編代碼。
代碼清單2-2(topic02\ex2-1\setup.asm):
;-------------------------------------------; say_hello() ;------------------------------------------- say_hello: mov si,hello_message call puts mov si,callee_message call puts mov si,say_hello mov di,callee_address call get_hex_string mov si,callee_address call puts ret
這個say_hello()也僅相當于以下幾條C語句。
void say_hello() { printf("hello,world This is my first assembly program..."); printf("Now:I'm callee - say_hello(),address is 0x%x",get_hex_string(say_hello)); }
代碼清單2-1和2-2就組成了我們這個16位實模式下的匯編語言版本的hello world程序,它在VMware上的運行結果如下所示。
當然僅這兩段匯編代碼還遠遠不能達到上面的運行結果,這個例子中背后還有boot.asm和lib16.asm的支持,boot.asm用來啟動機器的MBR模塊,lib16.asm則是16位實模式下的庫(在lib\目錄下),提供類似于C庫的功能。
main()的代碼被加載到內存0x8000中,lib16.asm的代碼被加載到0x8a00中,作為一個共享庫的形式存在。這個例子里的全部代碼都在topic02\ex2-1\目錄下,包括boot.asm源文件和setup.asm源文件,而lib16.asm則在x86\source\lib\目錄下。main()所在的模塊是setup.asm。
16位?32位?還是64位?
在機器啟動時處理器工作于16位實模式。這個hello world程序工作于16位實模式下,在編寫代碼時,需要給nasm指示為16位的代碼編譯,在代碼的開頭使用bits 16指示字聲明。
bits 32指示編譯為32位代碼,bits 64指示編譯為64位代碼。
2.3.1 使用寄存器傳遞參數
C語言中的__stdcall和__cdecl調用約定會使用stack傳遞參數。
printf("hello,world This is my first assembly program...");
C編譯器大概會如下這樣處理。
push hello_message call printf
在匯編程序里盡量采用寄存器傳遞參數,正如前面的hello world程序那樣:
mov si,hello_message ; 使用 si寄存器傳遞參數 call puts
使用寄存器傳遞參數能獲得更高的效率。要注意在程序中修改參數值時,是否需要參數的不變性,按照慣例傳遞的參數通常是volatile(可變)的,可是在某些場合下保持參數的nonvolatile(不變)能簡化代碼,應盡量統一風格。
2.3.2 調用過程
call指令用來調用一個過程,可以直接給出一個目標地址值作為操作數,編譯器生成的機器指令如下。
e8 c2 00 ; call puts
e8是指令call的opcode操作,c200是目標地址偏移量的little-endian排列,它的值是0x00c2,因而目標地址就在地址ip+0x00c2上。ip指示出了下一條指令地址。
instruction pointer在16位下表示為ip,32位下表示為eip,64位下表示為rip。
調用過程的另外一些常用形式如下。
mov ax,puts call ax ; 寄存器操作數 ;;; 或者是: call word [puts_pointer] ; 內存操作數
這些是near call的常用形式,[puts_pointer]存放puts過程的地址值,puts_pointer相當于C語言中的函數指針!是不是覺得很熟悉。它們的機器指令形式如下。
ff d0 ; call ax ff 16 10 80 ; call word [0x8010]
如上所示,在0x8010地址上存放著puts過程的地址。
2.3.3 定義變量
在nasm的匯編源程序里,可以使用db系列偽指令來定義初始化的變量,如下所示。
例如,我們可以這樣使用db偽指令。
hello_message db 13,10,'hello,world!',13,10
這里為hello_message定義了一個字符串變量,相當于如下C語句。
hello_message[]=“ hello,world! ”;
十進制數字13是ASCII碼中的回車符,10是換行符,當然也可以使用十六進制數0x0d和0x0a來賦初值。在nasm中可以使用單引號或雙引號表達字符串常量。
callee_message db "Now:I'm callee - say_hello(),address is 0x"
2.4 16位編程、32位編程,以及64位編程
在nasm中可以在同一個源代碼文件里同時指出16位代碼、32位代碼,以及64位代碼。
bits 16 … … ; 以下是 16位代碼 bits 32 … … ; 以下是 32位代碼 bits 64 … … ; 以下是 64位代碼
不用擔心這里會有什么問題,編譯器會為每部分生成正確的機器指令。關于16位機器碼、32位機器碼以及64位機器碼,詳見筆者個人網站里的《x86/x64指令系統》篇章,地址為http://www.mouseos.com/x64/default.html。
16位編程、32位編程,以及64位編程有什么不同之處?
這確實需要簡單了解一下。
2.4.1 通用寄存器
在16位和32位編程里,可以使用的通用寄存器是一樣的,如下所示。
在16位編程里可以使用32位的寄存器,在32位編程里也可以使用16位的寄存器,編譯器會生成正確的機器碼。
bits 16 ; 為16 位代碼而編譯 mov eax,1 ; 機器碼是:66 b8 01 00 00 00
上面這段代碼為16位代碼編譯,使用了32位的寄存器,編譯器會自動加上default operand-size override prefix(66H字節),這個66H字節用來調整為正確的操作數。
bits 32 ; 為32位代碼而編譯 mov eax,1 ; 機器碼是:b8 01 00 00 00
這段代碼的匯編語句是完全一樣的,只不過是為32位代碼而編譯,它們的機器碼就是不一樣的。
在x64體系里,在原來的8個通用寄存器的基礎上新增了8個寄存器,并且原來的寄存器也得到了擴展。在64位編程里可以使用的通用寄存器如下表所示。
在64位編程里可以使用20個8位寄存器,和16個16位、32位及64位寄存器,寄存器體系得到了完整的補充。
所有的16個寄存器都可以分割出相應的8位、16位或32位寄存器。在16位編程和32位編程里,sp、bp、si及di不能使用低8位。在64位編程里,可以使用分割出的spl、bpl、sil及dil低8位寄存器。
64位的r8~r15寄存器分割出相對應的8位、16位及32位寄存器形式為:r8b~r15b、r8w~r15w,以及r8d~r15d。
bits 64 ; 為64位代碼編譯 mov r8b,1 mov spl,r8b
比如上面這兩條指令必須在64位下使用,r8b和spl寄存器在16位和32位下是無效的。
2.4.2 操作數大小
在16位編程和32位編程下,寄存器沒有使用上的不便,32位的操作數依舊可以在16位編程里使用,而16位的操作數也可以在32位編程下使用。
bits 16 push word 1 ; 16位操作數 push dword 1 ; 32位操作數 call ax ; 16 位操作數 call eax ; 32 位操作數 bits 32 push word 1 ; 16位操作數 push dword 1 ; 32位操作數 call ax ; 16 位操作數 call eax ; 32 位操作數
上面的代碼完全可以用在16編程和32位編程里。在64位編程里操作數可以擴展到64位。
bits 64 mov rax,0x1122334455667788 ; 機器碼是:b8 8877665544332211
這條指令直接使用了64位立即操作數。
2.4.3 64位模式下的內存地址
在64位編程里可以使用寬達64位的地址值。
canonical地址形式
然而,在x64體系里只實現了48位的virtual address,高16位被用做符號擴展。這高16位必須要么全是0,要么全是1,這種形式的地址被稱為canonical地址,如下所示。
與canonical地址形式相對的是non-canoncial地址形式,如下所示。在64位模式下non-canonical地址形式是不合法的。
在64位的線性地址空間里,
① 0x00000000_00000000到0x00007FFF_FFFFFFFF是合法的canonical地址。
② 0x00008000_00000000到0xFFFF7FFF_FFFFFFFF是非法的non-canonical地址。
③ 0xFFFF8000_00000000到0xFFFFFFFF_FFFFFFFF是合法的canonical地址。
在non-canonical地址形式里,它們的符號擴展位出現了問題。
2.4.4 內存尋址模式
在16位和32位編程里,16位和32位的尋址模式都可以使用。在64位下,32位的尋址模式被擴展為64位,而且不能使用16位的尋址模式。
16位內存尋址模式
在16位編程里,內存操作數的尋址模式如下所示。
在16位尋址模式里基址只能使用bx和bp寄存器,變址只能使用si和di寄存器,displacement值使用8位或16位的偏移量。
32位內存尋址模式
在32位編程里,內存操作數的尋址模式如下所示。
基址和變址可以是8個通用寄存器。displacement的值是8位或32位。
如以下指令中地址操作數的使用:
mov eax,[eax + ecx*4 + 0x1c]
這是典型的“基址(base)加變址(index)尋址加上偏移量尋址”。
64位內存尋址模式
64位尋址模式形式和32位尋址模式是一致的,基址和變址寄存器默認情況下使用64位的通用寄存器。
64位尋址模式新增了一個RIP-Relative尋址形式。
RIP-Relative尋址:[rip+disp32]
這個displacement值是32位寬,地址值依賴于當前的RIP(指令指針)值。可是nasm的語法并不支持直接使用rip,像下面的用法是錯誤的。
mov rax,[rip + 0x1c] ; error:symbol 'rip' undefined
rip是處理器內部使用的寄存器,并不是外部編程可用的資源,但在yasm語法上是支持的。nasm中的解決方案是使用rel指示字。
mov rax,[rel table] ; rel指示字后面跟上一個地址label
這樣就將編譯為RIP-Relative尋址模式。RIP-Relative尋址最直接的好處是很容易構造PIC代碼結構。
什么是PIC?PIC是指Position-Independent Code(不依賴于位置的代碼)。
假設有一條指令調用了GetStdHandle()函數。
00073BEC FF15 DC810700 call dword ptr [__imp__GetStdHandle]
call指令從 [__imp__GetStdHandle] 里讀取 Kernel32.dll 庫里的 GetStdHandle() 入口地址,這里的__imp__GetStdHandle是絕對地址,地址值為 0x000781DC。
__imp__ReadFile: 000781D4 A3 3E 4D 75 __imp__XXXX: 000781D8 2C 3F 4D 75 __imp__GetStdHandle: 000781DC 83 51 4D 75
在0x000781DC(__imp__GetStdHandle)里放著的就是GetStdHandle()在庫里的地址0x754D5183。
那么這條call指令就屬于PDC(Position-Dependent Code,依賴于位置的代碼)。
FF15 DC810700 call dword ptr [__imp__GetStdHandle@4 (781DCh)] ---------- 依賴于這個絕對地址
由于使用了絕對地址,當__imp__GetStdHandle的位置因重定位而有可能改變時,這條call指令就會出錯,這個絕對地址已經不是__imp__GetStdHandle的地址了。
在x64體系的64位環境下,使用RIP-Relative很容易得到改善。
00073BEC 48 8d 85 e1 45 00 00 lea rbx,[rip + 0x45e1] ; 得到 __IMP_FUNCTION_TABLE的地址 00073BF3 48 03 1c c3 add rbx,[rbx + rax * 8] ; 得到 __imp_GetStdHandle 的地址 00073BF7 ff 13 call [rbx] ; call [__imp_GetStdHandle] ... ... __IMP_FUNCTION_TABLE: ; 函數表地址在 0x000781D4 000781D4 A3 3E 4D 75 000781D8 2C 3F 4D 75 000781DC 83 51 4D 75 ; GetStdHandle()的入口地址
在nasm里應該是lea rbx,[rel__IMP_FUNCTION_TABLE],這里使用rip是為了便于理解。使用lea指令配合RIP-Relative尋址得到的__IMP_FUNCTION_TABLE的地址不會因為重定位改變而改變,因為這里使用基于RIP的相對地址,沒什么絕對地址,而這個代碼的相對地址是不會變的。
內存尋址模式的使用
在16位編程和32位編程下依舊可以使用16位地址模式和32位地址模式。
bits 16 mov ax,[bx+si] ; 使用 16 位地址模式 mov eax,[eax+ecx*4] ; 使用 32 位地址模式 bits 32 mov ax,[bx+si] ; 使用 16 位地址模式 mov eax,[eax+ecx*4] ; 使用 32 位地址模式
指令的默認地址(16位或32位)依賴于CS.D標志位(在保護模式章節會有詳細的描述),CS.D=1時使用32位的尋址模式,CS.L=0使用16位的尋址模式。
上面的代碼中,編譯器會生成正確的機器指令,當改變default address-size(默認的地址尺寸)時,生成的機器指令會相應地插入67H(address-size override prefix)這個前綴值。
在64位模式下,也可以使用67H改變默認的64位尋址模式,改變為32位的尋址模式。
2.4.5 內存尋址范圍
在正常的情況下,16位實模式編程里,雖然可以使用32位的尋址模式,可是依然逃不過64K內存空間的限制(實際上可以改變地址值大小,在后面實模式的章節里進行探討)。
假如在16位實模式下寫出如下代碼。
mov eax,0x200000 ; 2M 地址 mov eax,[eax] ; 錯誤:> 64K mov eax,0x2000 mov ecx,1 mov eax,[eax + ecx * 4] ; 正確:<= 64K
在32位保護模式下,可以尋址4G的線性空間,OS通常的做法會使用最大的4G尋址空間;而在64位環境,尋址空間增加到了64位,這個空間大小是不會改變的。
2.4.6 使用的指令限制
有些指令在64位環境里是不可用的,在編程過程中應避免,典型的如push cs/ds/es/ss指令和pop ds/es/ss指令,這些在16位和32位下常用的指令在64位模式下是無效的。
call 0x0018:0x00100000 ; 無效 jmp 0x0018:0x00100000 ; 無效
這些常用的direct far call/jmp(直接的遠程call/jmp)也是無效的。此外還需要注意是否有權限去執行指令,像cli/sti這類指令需要0級的執行權限,in/out指令需要高于eflags.IOPL的執行權限。這里不再一一列舉。
2.5 編程基礎
在x86/x64平臺上,大多數匯編語言(如:nasm)源程序的一行可以組織為
label: instruction-expression ; comment
一行有效的匯編代碼主體是instruction expression(指令表達式),label(標簽)定義了一個地址,匯編語言的comment(注釋)以“;”號開始,以行結束為止。
最前面是指令的mnemonic(助記符),在通用編程里x86指令支持最多3個operand(操作數),以逗號分隔。前面的操作數被稱為first operand(第1個操作數)或者目標操作數,接下來是second operand(第2個操作數)或源操作數。
有的時候,first operand會被稱為first source operand(第1個源操作數),second operand會被稱為second source operand(第2個源操作數):
兩個操作數都是源操作數,并且第1個源操作數是目標操作數,可是還有另外一些情況。
在一些指令中并沒有顯式的目標操作數,甚至也沒有顯式的源操作數。而在AVX指令中first source operand也可能不是destination operand。
例如mul指令的目標操作數是隱含的,lodsb系列指令也不需要提供源操作數和目標操作數,它的操作數也是隱式提供的。使用source和destination來描述操作數,有時會產生迷惑。使用first operand(第1個操作數)、second operand(第2個操作數)、third operand(第3個操作數),以及fourth operand(第4個操作數)這些序數來描述操作數更清晰。
2.5.1 操作數尋址
數據可以存放在寄存器和內存里,還可以從外部端口讀取。操作數尋址(operand addressing)是一個尋找數據的過程。
寄存器尋址
register addressing:在寄存器里存/取數據。
x86編程可用的寄存器操作數有GPR(通用寄存器)、flags(標志寄存器)、segment register(段寄存器)、system segment register(系統段寄存器)、control register(控制寄存器)、debug register(調試寄存器),還有SSE指令使用的MMX寄存器和XMM寄存器,AVX指令使用的YMM寄存器,以及一些配置管理用的MSR。
系統段寄存器:GDTR(全局描述符表寄存器),LDTR(局部描述符表寄存器),IDTR(中斷描述符表寄存器),以及TR(任務寄存器)。使用在系統編程里,是保護模式編程里的重要系統數據資源。
系統段寄存器操作數是隱式提供的,沒有明確的字面助記符,這和IP(Instruction Pointer)有異曲同工之處。
LGDT [GDT_BASE] ; 從內存 [GDT_BASE] 處加載GDT的base和limit值到 GDTR
x86體系里還有更多的隱式寄存器,MSR(Model Specific Register)能提供對處理器更多的配置和管理。每個MSR有相應的編址。在ecx寄存器里放入MSR的地址,由rdmsr指令進行讀,wdmsr指令進行寫。
mov ecx,1bH ; APIC_BASE 寄存器地址 rdmsr ; 讀入APIC_BASE寄存器的64位值到edx:eax mov ecx,C0000080h ; EFER 地址 rdmsr ; 讀入EFER原值 bts eax,8 ; EFER.LME=1 wdmsr ; 開啟 long mode
用戶編程中幾乎只使用GPR(通用寄存器),sp/esp/rsp寄存器被用做stack top pointer(棧頂指針),bp/ebp/rbp寄存器通常被用做維護過程的stack frame結構。可是它們都可以被用戶代碼直接讀/寫,維護stack結構的正確和完整性,職責在于程序員。
內存操作數尋址
memory addressing:在內存里存/取數據。
內存操作數由一對[]括號進行標識,而在AT&T的匯編語法中使用()括號進行標識。x86支持的內存操作數尋址多種多樣,參見前面所述內存尋址模式。
內存操作數的尋址如何提供地址值?
直接尋址是memory的地址值明確提供的,是個絕對地址。
mov eax,[0x00400000] ; 明確提供一個地址值
直接尋址的對立面是間接尋址,memory的地址值放在寄存器里,或者需要進行求值。
mov eax,[ebx] ; 地址值放在ebx寄存器里mov eax,[base_address + ecx * 2] ; 通過求值得到地址值
地址值的產生有多種形式,x86支持的最復雜形式如下。
在最復雜的形式里,額外提供了一個段值,用于改變原來默認的DS段,這個地址值提供了base寄存器加上index寄存器,并且還提供了偏移量。
上面的內存地址值是一個對有效地址進行求值的過程。那么怎么得到這個地址值呢?如下所示。
lea eax,[ebx + ecx*8 + 0x1c]
使用lea指令可以很容易獲得這個求出來的值,lea指令的目的是load effective address(加載有效地址)。
立即數尋址
immediate:立即數無須進行額外的尋址,immediate值將從機器指令中獲取。
在機器指令序列里可以包括immediate值,這個immediate值屬于機器指令的一部分。
b8 01 00 00 00 ; 對應 mov eax,1
在處理器進行fetch instruction(取指)階段,這個操作數的值已經確定。
I/O端口尋址
x86/x64體系實現了獨立的64K I/O地址空間(從0000H到FFFFH),IN和OUT指令用來訪問這個I/O地址。
一些數據也可能來自外部port。
in指令讀取外部端口數據,out指令往外部端口寫數據。
in al,20H ; 從端口20H里讀取一個 byte
in和out指令是CPU和外部接口進行通信的工具。許多設備的底層驅動還是要靠in/out指令。端口的尋址是通過immediate形式,還可以通過DX寄存器提供port值。immediate只能提供8位的port值,在x86上提供了64K范圍的port,訪問0xff以上的port必須使用DX寄存器提供。
在x86/x64體系中device(設備)還可以使用memory I/O(I/O內存映射)方式映射到物理地址空間中,典型的如VGA設備的buffer被映射到物理地址中。
內存地址形式
在x86/x64體系里,常見的有下面幾種地址形式。
① logical address(邏輯地址)。
② linear address(線性地址)。
③ physical address(物理地址)。
virtual address(虛擬地址)
virtual address并不是獨立的,非特指哪一種地址形式,而是泛指某一類地址形式。physical address的對立面是virtual address,實際上,logical address和linear address(非real模式下)都是virtual address的形式。
logical address(邏輯地址)
邏輯地址是我們的程序代碼中使用的地址,邏輯地址最終會被處理器轉換為linear address(線性地址),這個linear address在real模式以及非分頁的保護模式下就是物理地址。
邏輯地址包括兩部分:segment和offset(segment:offset),這個offset值就是段內的effective address(有效地址值)。
segment值可以是顯式或隱式的(或者稱為默認的)。邏輯地址在real模式下會經常使用到,保護模式下在使用far pointer進行控制權的切換時顯式使用segment值。
在高級語言層面上(典型的如C語言)我們實際上使用的是邏輯地址中的effective address(有效地址)部分,例如:變量的地址或者指針都是有效地址值。因此,在我們的程序中使用的地址值可以稱為邏輯地址或虛擬地址。
effective address(有效地址)
如前面所述,effective address是logical address的一部分,它的意義是段內的有效地址偏移量。
logic addres(邏輯地址):Segment:Offset。Offset值是在一個Segment內提供的有效偏移量(displacement)。
這種地址形式來自早期的8086/8088系列處理器,Offset值基于一個段內,它必須在段的有效范圍內,例如實模式下是64K的限制。因此,effective address就是指這個Offset值。
如上所示,這條lea指令就是獲取內存操作數中的effective address(有效地址),在這個內存操作數里,提供了顯式的segment段選擇子寄存器,而最終的有效地址值為
effective_address=ebx + ecx * 8 + 0x1c
因此,目標操作數eax寄存器的值就是它們計算出來的結果值。
linear address(線性地址)
有時linear address(線性地址)會被直接稱為virtual address(虛擬地址),因為linear address在之后會被轉化為physical address(物理地址)。線性地址是不被程序代碼中直接使用的。因為linear address由處理器負責從logical address中轉換而來(由段base+段內offset而來)。實際上線性地址的求值中重要的一步就是:得到段base值的過程。
典型地,對于在real模式下一個邏輯地址segment:offset,有
linear_address=segment << 4 + offset
這個real模式的線性地址轉換規則是segment*16+offset,實際上段的base值就是segment<<4。在protected-mode(保護模式)下,線性地址的轉化為
linear_address=segment_base + offset
段的base值加上offset值,這個段的base值由段描述符的base域加載而來。而在64位模式下,線性地址為
linear_address=offset ; base 被強制為0值
在64位模式下,除了FS與GS段可以使用非0值的base外,其余的ES、CS、DS及SS段的base值強制為0值。因此,實際上線性地址就等于代碼中的offset值。
physical address(物理地址)
linear address(或稱virtual address)在開啟分頁機制的情況下,經過處理器的分頁映射管理轉換為最終的物理地址,輸出到address bus。物理地址應該從以下兩個地址空間來闡述。
① 內存地址空間。
② I/O地址空間。
在這些地址空間內的地址都屬于物理地址。在x86/x64體系里,支持64K的I/O地址空間,從0000H到FFFFH。使用IN/OUT指令來訪問I/O地址,address bus的解碼邏輯將訪問外部的硬件。
物理內存地址空間將容納各種物理設備,包括:VGA設備,ROM設備,DRAM設備,PCI設備,APIC設備等。這些設備在物理內存地址空間里共存,這個DRAM設備就是機器上的主存設備。
在物理內存地址空間里,這些物理設備是以memory I/O的內存映射形式存在。典型地local APIC設置被映射到0FEE00000H物理地址上。
在Intel上,使用MAXPHYADDR這個值來表達物理地址空間的寬度。AMD和Intel的機器上可以使用CPUID的80000008 leaf來查詢“最大的物理地址”值。
2.5.2 傳送數據指令
x86提供了非常多的data-transfer指令,在這些傳送操作中包括了:load(加載),store(存儲),move(移動)。其中,mov指令是最常用的。
2.5.2.1 mov指令
mov指令形式如下。
目標操作數只能是register或者memory,源操作數則可以是register、memory或者immediate。x86/x64上不支持memory到memory之間的直接存取操作,只能借助第三方進行。
mov eax,[mem1] mov [mem2],eax ; [mem2] <- [mem1]
還要注意的是將immediate操作數存入memory操作數時,需要明確指出operand size(操作數大小)。
這是錯誤的!編譯器不知道立即數1的寬度是多少字節,同樣也不知道[mem]操作數到底是多少字節。兩個操作數的size都不知道,因此無法生成相應的機器碼。
mov eax,[mem1] ; OK! 目標操作數的 size 是 DWORD
編譯器知道目標操作數的size是DWORD大小,[mem1]操作數無須明確指示它的大小。
mov dword [mem1],1 ; OK! 給目標操作數指示 DWORD 大小 mov [mem1],dword 1 ; OK! 給源操作數指示 DWORD 大小
nasm編譯器支持給立即數提供size的指示,在有些編譯器上是不支持的,例如:masm編譯器。
mov dword ptr [mem1],1 ; OK! 只能給 [mem1] 提供 size 指示
微軟的masm編譯器使用dword ptr進行指示,這也是Intel與AMD所使用的形式。
什么是move、load、store、load-and-store操作?
在傳送指令中有4種操作:move,load,store,以及load-and-store。下面我們來了解這些操作的不同。
move操作
在處理器的寄存器內部進行數據傳送時,屬于move操作,如下所示。
這種操作是最快的數據傳送方法,無須經過bus上的訪問。
load操作
當從內存傳送數據到寄存器時,屬于load操作,如下所示。
內存中的數據經過bus從內存中加載到處理器內部的寄存器。
store操作
當將處理器的數據存儲到內存中時,屬于store操作,如下所示。
MOV指令的目標操作數是內存。同樣,數據經過bus送往存儲器。
load-and-store操作
在有些指令里,產生了先load(加載)然后再store(存)回去的操作,如下所示。
這條ADD指令的目標操作數是內存操作數(同時也是源操作數之一)。它產生了兩次內存訪問,第1次讀源操作數(第1個源操作數),第2次寫目標操作數,這種屬于load-and-store操作。
注意:這種操作是non-atomic(非原子)的,在多處理器系統里為了保證指令執行的原子性,需要在指令前加上lock前綴,如下所示。
lock add dword [mem],eax ; 保證 atomic
2.5.2.2 load/store段寄存器
有幾組指令可以執行load/store段寄存器。
load段寄存器
下面的指令進行load段寄存器。
MOV sReg,reg/mem POP sReg LES/LSS/LDS/LFS/LGS reg
store段寄存器
下面的指令進行store段寄存器。
MOV reg/mem,sReg PUSH sReg
CS寄存器可以作為源操作數,但不能作為目標操作數。對于CS寄存器的加載,只能通過使用call/jmp和int指令,以及ret/iret返回等指令。call/jmp指令需要使用far pointer形式提供明確的segment值,這個segment會被加載到CS寄存器。
mov cs,ax ; 無效opcode,運行錯誤 #UD 異常 mov ax,cs ; OK!
pop指令不支持CS寄存器編碼。
push cs ; OK! pop cs ; 編譯錯誤,無此opcode!
les系列指令的目標操作數是register,分別從memory里加載far pointer到segment寄存器和目標寄存器操作數。far pointer是32位(16:16)、48位(16:32),以及80位(16:64)形式。
注意:在64位模式下,push es/cs/ss/ds指令、pop es/ss/ds指令及les/lds指令是無效的。而push fs/gs指令和pop fs/gs指令,以及lss/lfs/lgs指令是有效的。
實驗2-2:測試les指令
在這個實驗里,使用les指令來獲得far pointer值,下面是主體代碼。
代碼清單2-3(topic02\ex2-2\protected.asm):
les ax,[far_pointer] ; get far pointer(16:16) current_eip: mov si,ax mov di,address call get_hex_string mov si,message call puts jmp $ far_pointer: dw current_eip ; offset 16 dw 0 ; segment 16 message db 'current ip is 0x', address dd 0,0
在Bochs里的運行結果如下。
2.5.2.3 符號擴展與零擴展指令
sign-extend(符號擴展)傳送指令有兩大類:movsx系列和cbw系列。
在movsx指令里8位的寄存器和內存操作數可以符號擴展到16位、32位及64位寄存器。而16位的寄存器和內存操作數可以符號擴展到32位和64位的寄存器。
movsxd指令將32位的寄存器和內存操作數符號擴展到64位的寄存器,形成了x64體系的全系列符號擴展指令集。
cbw指令族實現了對al/ax/eax/rax寄存器的符號擴展。而cwd指令族將符號擴展到了dx/edx/rdx寄存器上。
int a; /* signed DWORD size */ short b; /* signed WORD size */ a=b; /* sign-extend */
像上面這樣的代碼,編譯器會使用movsx指令進行符號擴展。
movsx eax,word ptr [b] ; WORD sign-extend to DWORD mov [a],eax
zero-extend(零擴展)傳送指令movzx在規格上和符號擴展movsx是一樣的。
mov ax,0xb06a movsx ebx,ax ; ebx=0xffffb06a movzx ebx,ax ; ebx=0x0000b06a
2.5.2.4 條件mov指令
CMOVcc指令族依據flags寄存器的標志位做相應的傳送。
在x86中,flags寄存器標志位可以產生16個條件。
signed數運算結果
G (greater) :大于 L (less) :小于 GE (greater or equal) :大于或等于 LE (less or equal) :小于或等于
于是就有了4個基于signed數條件CMOVcc指令:cmovg,cmovl,cmovge,以及cmovle,這些指令在mnemonic(助記符)上還可以產生另一些形式。
G => NLE(不小于等于) L => NGE(不大小等于) GE => NL(不小于) LE => NG(不大于)
因此,cmovg等價于cmovnle,在匯編語言上使用這兩個助記符效果是一樣的。
unsigned數運算結果
A (above) :高于 B (below) :低于 AE (above or equal) :高于或等于 BE (below or equal) :低于或等于
于是就有了4個基于unsigned數條件的CMOVcc指令:cmova,cmovb,cmovae,以及cmovbe,同樣每個條件也可以產生否定式的表達:NBE(不低于等于),NAE(不高于等于),NB(不低于),以及NA(不高于)。
標志位條件碼
另外還有與下面的標志位相關的條件。
① O(Overflow):溢出標志。
② Z(Zero):零標志。
③ S(Sign):符號標志。
④ P(Parity):奇偶標志。
當它們被置位時,對應的COMVcc指令形式為:cmovo,cmovz,cmovs,以及cmovp。實際上,OF標志、ZF標志和SF標志,它們配合CF標志用于產生signed數條件和unsigned數條件。
當它們被清位時,CMOVcc指令對應的指令形式是:cmovno,cmovnz,cmovns,以及cmovnp。
CMOVcc指令能改進程序的結構和性能,如對于下面的C語言代碼。
printf("%s ",b == TRUE ? "yes" :"no");
這是一個典型的條件選擇分支,在不使用CMOVcc指令時如下。
mov ebx,yes ; ebx=OFFSET "yes" mov ecx,no ; ecx=OFFSET "no" mov eax,[b] test eax,eax ; b == TRUE ? jnz continue mov ebx,ecx ; FALSE:ebx=OFFSET "no" continue: push ebx push OFFSET("%s ") call printf
使用CMOVcc指令可以去掉條件跳轉指令。
mov ebx,yes ; ebx=OFFSET "yes" mov ecx,no ; ecx=OFFSET "no" mov eax,[b] test eax,eax ; b == TRUE ? cmovz ebx,ecx ; FALSE:ebx=OFFSET "no" push ebx push OFFSET("%s ") call printf
2.5.2.5 stack數據傳送指令
棧上的數據通過push和pop指令進行傳送。
stack的一個重要的作用是保存數據,在過程里需要修改寄存器值時,通過壓入stack中保存原來的值。
push ebp ; 保存原stack-frame基址 mov ebp,esp ... mov esp,ebp pop ebp ; 恢復原stack-frame基址
像C語言,大多數情況下的函數參數是通過stack傳遞的。
printf("hello,world "); /*C中調用函數 */ push OFFSET("hello,world") ; 壓入字符串 “hello,word” 的地址 call printf
如上所見stack具有不可替代的地位,因此push和pop指令有著舉足輕重的作用。
2.5.3 位操作指令
x86也提供了幾類位操作指令,包括:邏輯指令,位指令,位查詢指令,位移指令。
2.5.3.1 邏輯指令
常用的包括and、or、xor,以及not指令。and指令做按位與操作,常用于清某位的操作;or指令做按位或操作,常用于置某位的操作。
and eax,0xFFFFFFF7 ; 清eax寄存器的Bit3位 or eax,8 ; 置eax寄存器的Bit3位
xor指令做按位異或操作,用1值異或可以取反,用0值異或可以保持不變,常用于快速清寄存器的操作。
xor eax,eax ; 清eax寄存器,代替 mov eax,0 xor eax,0 ; 效果等同于 and eax,eax xor eax,0xFFFFFFFF ; 效果類似于 not eax(不改變eflags標志)
not指令做取反操作,但是并不影響eflags標志位。
2.5.3.2 位指令
x86有專門對位進行操作的指令:bt,bts,btr,以及btc,它們共同的行為是將某位值復制到CF標志位中,除此而外,bts用于置位,btr用于清位,btc用于位取反。
bt eax,0 ; 取Bit0值到CF bts eax,0 ; 取Bit0值到CF,并將Bit0置位 btr eax,0 ; 取Bit0值到CF,并將Bit0清位 btc eax,0 ; 取Bit0值到CF,并將Bit0取反
這些指令可以通過查看CF標志來測試某位的值,很實用。
lock bts DWORD [spinlock],0 ; test-and-set,不斷地進行測試并上鎖
如果不想使用煩人的and與or指令,就可以使用它們(缺點是只能對1個位進行操作)。第1個operand可以是reg和mem,第2個operand可以是reg與imm值。
2.5.3.3 位查詢指令
bsf指令用于向前(forward),從LSB位向MSB位查詢,找出第1個被置位的位置。bsr指令用于反方向(reverse)操作,從MSB往LSB位查詢,找出第1個被置位的位置。
mov eax,70000003H bsf ecx,eax ; ecx=0(Bit0為1) bsr ecx,eax ; ecx=30(Bit30為1)
它們根據ZF標志查看是否找到,上例中如果eax寄存器的值為0(沒有被置位),則ZF=1,目標操作數不會改變。找到時ZF=0,當然可能出現bsf與bsr指令的結果一樣的情況(只有一個位被置位)。
2.5.3.4 位移指令
x86上提供了多種位移指令,還有循環位移,并且可以帶CF位移。
① 左移:shl/sal
② 右移:shr
③ 符號位擴展右移:sar
④ 循環左移:rol
⑤ 循環右移:ror
⑥ 帶進位循環左移:rcl
⑦ 帶進位循環右移:rcr
⑧ double左移:shld
⑨ double右移:shrd
SHL/SAL指令在移位時LSB位補0,SHR右移時MSB補0,而SAR指令右移時MSB位保持不變。
ROL移位時,MSB移出到CF的同時補到LSB位上。ROR指令移位時,LSB移出CF的同時補到MSB位上。
如上所示,RCL與RCR都是帶進位標志的循環移位,CF值會分別補到LSB和MSB。
SHLD和SHRD指令比較獨特,可以移動的操作數寬度增加一倍,改變operand 1,但operand 2并不改變。
mov eax,11223344H mov ebx,55667788H ; shld ebx,eax,8 ; ebx=66778811H,eax不變
2.5.4 算術指令
① 加法運算:ADD,ADC,以及INC指令。
② 減法運算:SUB,SBB,以及DEC指令。
③ 乘法運算:MUL和IMUL指令。
④ 除法運算:DIV和IDIV指令。
⑤ 取反運算:NEG指令。
加減運算是二進制運算,不區別unsigned與signed數,乘除運算按unsigned和signed區分指令。neg指令是對singed進行取負運算。ADC是帶進位的加法,SBB是帶借進的減法,用來構造大數的加減運算。
add eax,ebx ; edx:eax + ecx:ebx adc edx,ecx ; edx:eax=(edx:eax + ecx:ebx) sub eax,ebx ; edx:eax – ecx:ebx sbb edx,ecx ; edx:eax=(edx:eax – ecx:ebx)
2.5.5 CALL與RET指令
CALL調用子過程,在匯編語言里,它的操作數可以是地址(立即數)、寄存器或內存操作數。call指令的目的是要裝入目標代碼的IP(Instruction Pointer)值。
目標地址放在register里時,EIP從寄存器里取;放在memory里時,從memory里獲得EIP值。在匯編語言表達里,直接給出目標地址作為call操作數的情況下,編譯器會計算出目標地址的offset值(基于EIP偏移量),這個offset值作為immediate操作數。
為了返回到調用者,call指令會在stack中壓入返回地址,ret指令返回時從stack里取出返回值重新裝載到EIP里然后返回到調用者。
2.5.6 跳轉指令
跳轉指令分為無條件跳轉指令JMP和條件跳轉指令Jcc(cc是條件碼助記符),這個cc條件碼和前面CMOVcc指令的條件碼是同樣的意義。
jmp系列指令與call指令最大的區別是:jmp指令并不需要返回,因此不需要進行壓stack操作。
2.6 編輯與編譯、運行
選擇一個自己習慣的編輯器編寫源代碼。有許多免費的編輯器可供選擇,其中Notepad++就非常不錯。然后使用編譯器對源碼進行編譯。
nasm t.asm ; 輸出t.o在當前目錄下 nasm t.asm –oe:\test\t.o ; 提供輸出文件 nasm t.asm –fbin ; 提供輸出文件 nasm t.asm –Ie:\source\ ; 提供編譯器的當前工作目錄 nasm t.asm –dTEST ; 預先定義一個符號(宏名)
-I<目錄>參數似乎是提供include文件路徑,實際上,對于nasm來說理解為提供當前工作目錄更為適合,如果t.asm文件里有
%include “..\inc\support.inc” ; include類似C的頭文件
inc目錄在source目錄下,如果當前的目錄為source\topic01\,那么你應該選擇的命令是
e:\x86\source\topic01>nasm –Ie:\x86\source\ t.asm
或者
e:\x86\source\topic01>nasm –I..\ t.asm
關于nasm的詳細資料請查閱nasm的幫助文檔。關于運行編譯出來的程序,請參考下一章。