官术网_书友最值得收藏!

第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的幫助文檔。關于運行編譯出來的程序,請參考下一章。

主站蜘蛛池模板: 武隆县| 泰安市| 玉林市| 揭西县| 城固县| 广灵县| 深州市| 平陆县| 留坝县| 日照市| 胶州市| 马关县| 南溪县| 大田县| 迭部县| 开远市| 奇台县| 桂林市| 谢通门县| 临朐县| 寿光市| 九江县| 新邵县| 平远县| 兰坪| 湘潭市| 凌海市| 武穴市| 克拉玛依市| 绥宁县| 北海市| 启东市| 喀喇沁旗| 晋宁县| 武乡县| 富锦市| 江永县| 自治县| 蚌埠市| 南召县| 德化县|