程序員須要瞭解的硬核知識之彙編語言(全)

以前的系列文章從 CPU 和內存方面簡單介紹了一下彙編語言,可是尚未系統的瞭解一下彙編語言,彙編語言做爲第二代計算機語言,會用一些容易理解和記憶的字母,單詞來代替一個特定的指令,做爲高級編程語言的基礎,有必要系統的瞭解一下彙編語言,那麼本篇文章但願你們跟我一塊兒來了解一下彙編語言。html

彙編語言和本地代碼

咱們在以前的文章中探討過,計算機 CPU 只能運行本地代碼(機器語言)程序,用 C 語言等高級語言編寫的代碼,須要通過編譯器編譯後,轉換爲本地代碼纔可以被 CPU 解釋執行。程序員

可是本地代碼的可讀性很是差,因此須要使用一種可以直接讀懂的語言來替換本地代碼,那就是在各本地代碼中,附帶上表示其功能的英文縮寫,好比在加法運算的本地代碼加上add(addition) 的縮寫、在比較運算符的本地代碼中加上cmp(compare)的縮寫等,這些經過縮寫來表示具體本地代碼指令的標誌稱爲 助記符,使用助記符的語言稱爲彙編語言。這樣,經過閱讀彙編語言,也可以瞭解本地代碼的含義了。編程

不過,即便是使用匯編語言編寫的源代碼,最終也必需要轉換爲本地代碼纔可以運行,負責作這項工做的程序稱爲編譯器,轉換的這個過程稱爲彙編。在將源代碼轉換爲本地代碼這個功能方面,彙編器和編譯器是一樣的。安全

用匯編語言編寫的源代碼和本地代碼是一一對應的。於是,本地代碼也能夠反過來轉換成彙編語言編寫的代碼。把本地代碼轉換爲彙編代碼的這一過程稱爲反彙編,執行反彙編的程序稱爲反彙編程序多線程

image.png

哪怕是 C 語言編寫的源代碼,編譯後也會轉換成特定 CPU 用的本地代碼。而將其反彙編的話,就能夠獲得彙編語言的源代碼,並對其內容進行調查。不過,本地代碼變成 C 語言源代碼的反編譯,要比本地代碼轉換成彙編代碼的反彙編要困難,這是由於,C 語言代碼和本地代碼不是一一對應的關係。less

經過編譯器輸出彙編語言的源代碼

咱們上面提到本地代碼能夠通過反彙編轉換成爲彙編代碼,可是隻有這一種轉換方式嗎?顯然不是,C 語言編寫的源代碼也可以經過編譯器編譯稱爲彙編代碼,下面就來嘗試一下。編程語言

首先須要先作一些準備,須要先下載 Borland C++ 5.5 編譯器,爲了方便,我這邊直接下載好了讀者直接從個人百度網盤提取便可 (連接:https://pan.baidu.com/s/19LqV... 密碼:hz1u)編輯器

下載完畢,須要進行配置,下面是配置說明 (https://wenku.baidu.com/view/...),教程很完整跟着配置就能夠,下面開始咱們的編譯過程函數

首先用 Windows 記事本等文本編輯器編寫以下代碼性能

// 返回兩個參數值之和的函數
int AddNum(int a,int b){
  return a + b;
}

// 調用 AddNum 函數的函數
void MyFunc(){
  int c;
  c = AddNum(123,456);
}

編寫完成後將其文件名保存爲 Sample4.c ,C 語言源文件的擴展名,一般用.c 來表示,上面程序是提供兩個輸入參數並返回它們之和。

在 Windows 操做系統下打開 命令提示符,切換到保存 Sample4.c 的文件夾下,而後在命令提示符中輸入

bcc32 -c -S Sample4.c

bcc32 是啓動 Borland C++ 的命令,-c 的選項是指僅進行編譯而不進行連接,-S 選項被用來指定生成彙編語言的源代碼

做爲編譯的結果,當前目錄下會生成一個名爲Sample4.asm 的彙編語言源代碼。彙編語言源文件的擴展名,一般用.asm 來表示,下面就讓咱們用編輯器打開看一下 Sample4.asm 中的內容

.386p
    ifdef ??version
    if    ??version GT 500H
    .mmx
    endif
    endif
    model flat
    ifndef    ??version
    ?debug    macro
    endm
    endif
    ?debug    S "Sample4.c"
    ?debug    T "Sample4.c"
_TEXT    segment dword public use32 'CODE'
_TEXT    ends
_DATA    segment dword public use32 'DATA'
_DATA    ends
_BSS    segment dword public use32 'BSS'
_BSS    ends
DGROUP    group    _BSS,_DATA
_TEXT    segment dword public use32 'CODE'
_AddNum    proc    near
?live1@0:
   ;    
   ;    int AddNum(int a,int b){
   ;    
    push      ebp
    mov       ebp,esp
   ;    
   ;    
   ;        return a + b;
   ;    
@1:
    mov       eax,dword ptr [ebp+8]
    add       eax,dword ptr [ebp+12]
   ;    
   ;    }
   ;    
@3:
@2:
    pop       ebp
    ret 
_AddNum    endp
_MyFunc    proc    near
?live1@48:
   ;    
   ;    void MyFunc(){
   ;    
    push      ebp
    mov       ebp,esp
   ;    
   ;        int c;
   ;        c = AddNum(123,456);
   ;    
@4:
    push      456
    push      123
    call      _AddNum
    add       esp,8
   ;    
   ;    }
   ;    
@5:
    pop       ebp
    ret 
_MyFunc    endp
_TEXT    ends
    public    _AddNum
    public    _MyFunc
    ?debug    D "Sample4.c" 20343 45835
    end

這樣,編譯器就成功的把 C 語言轉換成爲了彙編代碼了。

不會轉換成本地代碼的僞指令

第一次看到彙編代碼的讀者可能感受起來比較難,不過實際上其實比較簡單,並且可能比 C 語言還要簡單,爲了便於閱讀彙編代碼的源代碼,須要注意幾個要點

彙編語言的源代碼,是由轉換成本地代碼的指令(後面講述的操做碼)和針對彙編器的僞指令構成的。僞指令負責把程序的構造以及彙編的方法指示給彙編器(轉換程序)。不過僞指令是沒法彙編轉換成爲本地代碼的。下面是上面程序截取的僞指令

_TEXT    segment dword public use32 'CODE'
_TEXT    ends
_DATA    segment dword public use32 'DATA'
_DATA    ends
_BSS    segment dword public use32 'BSS'
_BSS    ends
DGROUP    group    _BSS,_DATA

_AddNum    proc    near
_AddNum    endp

_MyFunc    proc    near
_MyFunc    endp

_TEXT    ends
    end

由僞指令 segmentends 圍起來的部分,是給構成程序的命令和數據的集合體上加一個名字而獲得的,稱爲段定義。段定義的英文表達具備區域的意思,在這個程序中,段定義指的是命令和數據等程序的集合體的意思,一個程序由多個段定義構成。

上面代碼的開始位置,定義了3個名稱分別爲 _TEXT、_DATA、_BSS 的段定義,_TEXT 是指定的段定義,_DATA 是被初始化(有初始值)的數據的段定義,_BSS 是還沒有初始化的數據的段定義。這種定義的名稱是由 Borland C++ 定義的,是由 Borland C++ 編譯器自動分配的,因此程序段定義的順序就成爲了 _TEXT、_DATA、_BSS ,這樣也確保了內存的連續性

_TEXT    segment dword public use32 'CODE'
_TEXT    ends
_DATA    segment dword public use32 'DATA'
_DATA    ends
_BSS    segment dword public use32 'BSS'
_BSS    ends
段定義( segment ) 是用來區分或者劃分範圍區域的意思。彙編語言的 segment 僞指令表示段定義的起始,ends 僞指令表示段定義的結束。段定義是一段連續的內存空間

group 這個僞指令表示的是將 _BSS和_DATA 這兩個段定義彙總名爲 DGROUP 的組

DGROUP    group    _BSS,_DATA

圍起 _AddNum_MyFun_TEXT segment 和 _TEXT ends ,表示_AddNum_MyFun 是屬於 _TEXT 這一段定義的。

_TEXT    segment dword public use32 'CODE'
_TEXT    ends

所以,即便在源代碼中指令和數據是混雜編寫的,通過編譯和彙編後,也會轉換成爲規整的本地代碼。

_AddNum proc _AddNum endp 圍起來的部分,以及_MyFunc proc_MyFunc endp 圍起來的部分,分別表示 AddNum 函數和 MyFunc 函數的範圍。

_AddNum    proc    near
_AddNum    endp

_MyFunc    proc    near
_MyFunc    endp

編譯後在函數名前附帶上下劃線_ ,是 Borland C++ 的規定。在 C 語言中編寫的 AddNum 函數,在內部是以 _AddNum 這個名稱處理的。僞指令 proc 和 endp 圍起來的部分,表示的是 過程(procedure) 的範圍。在彙編語言中,這種至關於 C 語言的函數的形式稱爲過程。

末尾的 end 僞指令,表示的是源代碼的結束。

## 彙編語言的語法是 操做碼 + 操做數

在彙編語言中,一行表示一對 CPU 的一個指令。彙編語言指令的語法結構是 操做碼 + 操做數,也存在只有操做碼沒有操做數的指令。

操做碼錶示的是指令動做,操做數表示的是指令對象。操做碼和操做數一塊兒使用就是一個英文指令。好比從英語語法來分析的話,操做碼是動詞,操做數是賓語。好比這個句子 Give me money這個英文指令的話,Give 就是操做碼,me 和 money 就是操做數。彙編語言中存在多個操做數的狀況,要用逗號把它們分割,就像是 Give me,money 這樣。

可以使用何種形式的操做碼,是由 CPU 的種類決定的,下面對操做碼的功能進行了整理。

image.png

本地代碼須要加載到內存後才能運行,內存中存儲着構成本地代碼的指令和數據。程序運行時,CPU會從內存中把數據和指令讀出來,而後放在 CPU 內部的寄存器中進行處理。

image.png

若是 CPU 和內存的關係你還不是很瞭解的話,請閱讀做者的另外一篇文章 程序員須要瞭解的硬核知識之CPU 詳細瞭解。

寄存器是 CPU 中的存儲區域,寄存器除了具備臨時存儲和計算的功能以外,還具備運算功能,x86 系列的主要種類和角色以下圖所示

image.png

指令解析

下面就對 CPU 中的指令進行分析

最經常使用的 mov 指令

指令中最常使用的是對寄存器和內存進行數據存儲的 mov 指令,mov 指令的兩個操做數,分別用來指定數據的存儲地和讀出源。操做數中能夠指定寄存器、常數、標籤(附加在地址前),以及用方括號([]) 圍起來的這些內容。若是指定了沒有用([]) 方括號圍起來的內容,就表示對該值進行處理;若是指定了用方括號圍起來的內容,方括號的值則會被解釋爲內存地址,而後就會對該內存地址對應的值進行讀寫操做。讓咱們對上面的代碼片斷進行說明

mov       ebp,esp
    mov       eax,dword ptr [ebp+8]

mov ebp,esp 中,esp 寄存器中的值被直接存儲在了 ebp 中,也就是說,若是 esp 寄存器的值是100的話那麼 ebp 寄存器的值也是 100。

而在 mov eax,dword ptr [ebp+8] 這條指令中,ebp 寄存器的值 + 8 後會被解析稱爲內存地址。若是 ebp

寄存器的值是100的話,那麼 eax 寄存器的值就是 100 + 8 的地址的值。dword ptr 也叫作 double word pointer 簡單解釋一下就是從指定的內存地址中讀出4字節的數據

對棧進行 push 和 pop

程序運行時,會在內存上申請分配一個稱爲棧的數據空間。棧(stack)的特性是後入先出,數據在存儲時是從內存的下層(大的地址編號)逐漸往上層(小的地址編號)累積,讀出時則是按照從上往下進行讀取的。

image.png

棧是存儲臨時數據的區域,它的特色是經過 push 指令和 pop 指令進行數據的存儲和讀出。向棧中存儲數據稱爲 入棧 ,從棧中讀出數據稱爲 出棧,32位 x86 系列的 CPU 中,進行1次 push 或者 pop,便可處理 32 位(4字節)的數據。

函數的調用機制

下面咱們一塊兒來分析一下函數的調用機制,咱們以上面的 C 語言編寫的代碼爲例。首先,讓咱們從MyFunc 函數調用AddNum 函數的彙編語言部分開始,來對函數的調用機制進行說明。棧在函數的調用中發揮了巨大的做用,下面是通過處理後的 MyFunc 函數的彙編處理內容

_MyFunc      proc      near
    push             ebp          ; 將 ebp 寄存器的值存入棧中                                      (1) 
    mov                ebp,esp ; 將 esp 寄存器的值存入 ebp 寄存器中                            (2)
    push            456            ; 將 456 入棧                                    (3)
    push             123            ; 將 123 入棧                                (4)
    call            _AddNum ; 調用 AddNum 函數                                (5)
    add                esp,8        ; esp 寄存器的值 + 8                        (6)
    pop                ebp            ; 讀出棧中的數值存入 esp 寄存器中            (7)
    ret                             ; 結束 MyFunc 函數,返回到調用源            (8)
_MyFunc         endp

代碼解釋中的(1)、(2)、(7)、(8)的處理適用於 C 語言中的全部函數,咱們會在後面展現 AddNum 函數處理內容時進行說明。這裏但願你們先關注(3) - (6) 這一部分,這對了解函數調用機制相當重要。

(3) 和 (4) 表示的是將傳遞給 AddNum 函數的參數經過 push 入棧。在 C 語言源代碼中,雖然記述爲函數 AddNum(123,456),但入棧時則會先按照 456,123 這樣的順序。也就是位於後面的數值先入棧。這是 C 語言的規定。(5) 表示的 call 指令,會把程序流程跳轉到 AddNum 函數指令的地址處。在彙編語言中,函數名表示的就是函數所在的內存地址。AddNum 函數處理完畢後,程序流程必需要返回到編號(6) 這一行。call 指令運行後,call 指令的下一行(也就指的是 (6) 這一行)的內存地址(調用函數完畢後要返回的內存地址)會自動的 push 入棧。該值會在 AddNum 函數處理的最後經過 ret 指令 pop 出棧,而後程序會返回到 (6) 這一行。

(6) 部分會把棧中存儲的兩個參數 (456 和 123) 進行銷燬處理。雖然經過兩次的 pop 指令也能夠實現,不過採用 esp 寄存器 + 8 的方式會更有效率(處理 1 次便可)。對棧進行數值的輸入和輸出時,數值的單位是4字節。所以,經過在負責棧地址管理的 esp 寄存器中加上4的2倍8,就能夠達到和運行兩次 pop 命令一樣的效果。雖然內存中的數據實際上還殘留着,但只要把 esp 寄存器的值更新爲數據存儲地址前面的數據位置,該數據也就至關於銷燬了。

我在編譯 Sample4.c 文件時,出現了下圖的這條消息

image.png

圖中的意思是指 c 的值在 MyFunc 定義了可是一直未被使用,這實際上是一項編譯器優化的功能,因爲存儲着 AddNum 函數返回值的變量 c 在後面沒有被用到,所以編譯器就認爲 該變量沒有意義,進而也就沒有生成與之對應的彙編語言代碼

下圖是調用 AddNum 這一函數先後棧內存的變化

image.png

函數的內部處理

上面咱們用匯編代碼分析了一下 Sample4.c 整個過程的代碼,如今咱們着重分析一下 AddNum 函數的源代碼部分,分析一下參數的接收、返回值和返回等機制

_AddNum         proc        near
    push            ebp                                                               (1)
    mov                ebp,esp                                                             (2)
    mov                eax,dword ptr[ebp+8]                                     (3)
    add                eax,dword ptr[ebp+12]                                   (4)
    pop                ebp                                      (5)
    ret                                                                                          (6)
_AddNum            endp

ebp 寄存器的值在(1)中入棧,在(5)中出棧,這主要是爲了把函數中用到的 ebp 寄存器的內容,恢復到函數調用前的狀態。

(2) 中把負責管理棧地址的 esp 寄存器的值賦值到了 ebp 寄存器中。這是由於,在 mov 指令中方括號內的參數,是不容許指定 esp 寄存器的。所以,這裏就採用了不直接經過 esp,而是用 ebp 寄存器來讀寫棧內容的方法。

(3) 使用[ebp + 8] 指定棧中存儲的第1個參數123,並將其讀出到 eax 寄存器中。像這樣,不使用 pop 指令,也能夠參照棧的內容。而之因此從多個寄存器中選擇了 eax 寄存器,是由於 eax 是負責運算的累加寄存器。

經過(4) 的 add 指令,把當前 eax 寄存器的值同第2個參數相加後的結果存儲在 eax 寄存器中。[ebp + 12] 是用來指定第2個參數456的。在 C 語言中,函數的返回值必須經過 eax 寄存器返回,這也是規定。也就是 函數的參數是經過棧來傳遞,返回值是經過寄存器返回的

(6) 中 ret 指令運行後,函數返回目的地內存地址會自動出棧,據此,程序流程就會跳轉返回到(6) (Call _AddNum) 的下一行。這時,AddNum 函數入口和出口處棧的狀態變化,就以下圖所示

image.png

全局變量和局部變量

在熟悉了彙編語言後,接下來咱們來了解一下全局變量和局部變量,在函數外部定義的變量稱爲全局變量,在函數內部定義的變量稱爲局部變量,全局變量能夠在任意函數中使用,局部變量只能在函數定義局部變量的內部使用。下面,咱們就經過彙編語言來看一下全局變量和局部變量的不一樣之處。

下面定義的 C 語言代碼分別定義了局部變量和全局變量,而且給各變量進行了賦值,咱們先看一下源代碼部分

// 定義被初始化的全局變量
int a1 = 1;
int a2 = 2;
int a3 = 3;
int a4 = 4;
int a5 = 5;

// 定義沒有初始化的全局變量
int b1,b2,b3,b4,b5;

// 定義函數
void MyFunc(){
  // 定義局部變量
  int c1,c2,c3,c4,c5,c6,c7,c8,c9,c10;
  
  // 給局部變量賦值
  c1 = 1;
  c2 = 2;
  c3 = 3;
  c4 = 4;
  c5 = 5;
  c6 = 6;
  c7 = 7;
  c8 = 8;
  c9 = 9;
  c10 = 10;
  
  // 把局部變量賦值給全局變量
  a1 = c1;
  a2 = c2;
  a3 = c3;
  a4 = c4;
  a5 = c5;
  b1 = c6;
  b2 = c7;
  b3 = c8;
  b4 = c9;
  b5 = c10;
}

上面的代碼挺暴力的,不過不要緊,可以便於咱們分析其彙編源碼就好,咱們用 Borland C++ 編譯後的彙編代碼以下,編譯完成後的源碼比較長,這裏咱們只拿出來一部分做爲分析使用(咱們改變了一下段定義順序,刪除了部分註釋)

_DATA segment dword public use32 'DATA'
   align 4
  _a1 label dword
               dd 1
   align 4
  _a2 label dword
               dd 2
   align 4
  _a3 label dword
               dd 3
   align 4
  _a4 label dword
               dd 4
   align 4
  _a5 label dword
               dd 5
_DATA ends

_BSS segment dword public use32 'BSS'
 align 4
  _b1 label dword
               db 4 dup(?)
   align 4
  _b2 label dword
               db 4 dup(?)
   align 4
  _b3 label dword
               db 4 dup(?)
   align 4
  _b4 label dword
               db 4 dup(?)
   align 4
  _b5 label dword
               db 4 dup(?)
_BSS ends

_TEXT segment dword public use32 'CODE'
_MyFunc proc near

 push      ebp
 mov       ebp,esp
 add       esp,-20
 push      ebx
 push      esi
 mov       eax,1
 mov       edx,2
 mov       ecx,3
 mov       ebx,4
 mov       esi,5
 mov       dword ptr [ebp-4],6
 mov       dword ptr [ebp-8],7
 mov       dword ptr [ebp-12],8
 mov       dword ptr [ebp-16],9
 mov       dword ptr [ebp-20],10
 mov       dword ptr [_a1],eax
 mov       dword ptr [_a2],edx
 mov       dword ptr [_a3],ecx
 mov       dword ptr [_a4],ebx
 mov       dword ptr [_a5],esi
 mov       eax,dword ptr [ebp-4]
 mov       dword ptr [_b1],eax
 mov       edx,dword ptr [ebp-8]
 mov       dword ptr [_b2],edx
 mov       ecx,dword ptr [ebp-12]
 mov       dword ptr [_b3],ecx
 mov       eax,dword ptr [ebp-16]
 mov       dword ptr [_b4],eax
 mov       edx,dword ptr [ebp-20]
 mov       dword ptr [_b5],edx
 pop       esi
 pop       ebx
 mov       esp,ebp
 pop       ebp
 ret
 
_MyFunc   endp
_TEXT     ends

編譯後的程序,會被歸類到名爲段定義的組。

  • 初始化的全局變量,會彙總到名爲 _DATA 的段定義中
_DATA segment dword public use32 'DATA'
...
_DATA ends
  • 沒有初始化的全局變量,會彙總到名爲 _BSS 的段定義中
_BSS segment dword public use32 'BSS'
 ...
_BSS ends
  • 被段定義 _TEXT 圍起來的彙編代碼則是 Borland C++ 的定義
_TEXT segment dword public use32 'CODE'
_MyFunc proc near
...
_MyFunc   endp
_TEXT     ends

咱們在分析上面彙編代碼以前,先來認識一下更多的彙編指令,此表是對上面部分操做碼及其功能的接續

操做碼 操做數 功能
add A,B 把A和B的值相加,並把結果賦值給A
call A 調用函數A
cmp A,B 對A和B進行比較,比較結果會自動存入標誌寄存器中
inc A 對A的值 + 1
ige 標籤名 和 cmp 命令組合使用。跳轉到標籤行
jl 標籤名 和 cmp 命令組合使用。跳轉到標籤行
jle 標籤名 和 cmp 命令組合使用。跳轉到標籤行
jmp 標籤名 和 cmp 命令組合使用。跳轉到標籤行
mov A,B 把 B 的值賦給 A
pop A 從棧中讀取數值並存入A
push A 把A的值存入棧中
ret 將處理返回到調用源
xor A,B A和B的位進行亦或比較,並將結果存入A中

咱們首先來看一下 _DATA 段定義的內容。 _a1 label dword 定義了 _a1 這個標籤。標籤表示的是相對於段定義起始位置的位置。因爲_a1_DATA 段定義的開頭位置,因此相對位置是0。 _a1 就至關因而全局變量a1。編譯後的函數名和變量名前面會加一個(_),這也是 Borland C++ 的規定。dd 1 指的是,申請分配了4字節的內存空間,存儲着1這個初始值。 dd指的是 define double word 表示有兩個長度爲2的字節領域(word),也就是4字節的意思。

Borland C++ 中,因爲int 類型的長度是4字節,所以彙編器就把 int a1 = 1 變換成了 _a1 label dword 和 dd 1。一樣,這裏也定義了至關於全局變量的 a2 - a5 的標籤 _a2 - _a5,它們各自的初始值 2 - 5 也被存儲在各自的4字節中。

接下來,咱們來講一說 _BSS 段定義的內容。這裏定義了至關於全局變量 b1 - b5 的標籤 _b1 - _b5。其中的db 4dup(?) 表示的是申請分配了4字節的領域,但值還沒有肯定(這裏用 ? 來表示)的意思。db(define byte) 表示有1個長度是1字節的內存空間。於是,db 4 dup(?) 的狀況下,就是4字節的內存空間。

注意:db 4 dup(?) 不要和 dd 4 混淆了,前者表示的是4個長度是1字節的內存空間。而 db 4 表示的則是雙字節( = 4 字節) 的內存空間中存儲的值是 4

臨時確保局部變量使用的內存空間

咱們知道,局部變量是臨時保存在寄存器和棧中的。函數內部利用棧進行局部變量的存儲,函數調用完成後,局部變量值被銷燬,可是寄存器可能用於其餘目的。因此,局部變量只是函數在處理期間臨時存儲在寄存器和棧中的

回想一下上述代碼是否是定義了10個局部變量?這是爲了表示存儲局部變量的不只僅是棧,還有寄存器。爲了確保 c1 - c10 所需的域,寄存器空閒的時候就會使用寄存器,寄存器空間不足的時候就會使用棧。

讓咱們繼續來分析上面代碼的內容。_TEXT段定義表示的是 MyFunc 函數的範圍。在 MyFunc 函數中定義的局部變量所須要的內存領域。會被儘量的分配在寄存器中。你們可能認爲使用高性能的寄存器來替代普通的內存是一種資源浪費,可是編譯器不這麼認爲,只要寄存器有空間,編譯器就會使用它。因爲寄存器的訪問速度遠高於內存,因此直接訪問寄存器可以高效的處理。局部變量使用寄存器,是 Borland C++ 編譯器最優化的運行結果。

代碼清單中的以下內容表示的是向寄存器中分配局部變量的部分

mov       eax,1
mov       edx,2
mov       ecx,3
mov       ebx,4
mov       esi,5

僅僅對局部變量進行定義是不夠的,只有在給局部變量賦值時,纔會被分配到寄存器的內存區域。上述代碼至關於就是給5個局部變量 c1 - c5 分別賦值爲 1 - 5。eax、edx、ecx、ebx、esi 是 x86 系列32位 CPU 寄存器的名稱。至於使用哪一個寄存器,是由編譯器來決定的 。

x86 系列 CPU 擁有的寄存器中,程序能夠操做的是十幾,其中空閒的最多會有幾個。於是,局部變量超過寄存器數量的時候,可分配的寄存器就不夠用了,這種狀況下,編譯器就會把棧派上用場,用來存儲剩餘的局部變量。

在上述代碼這一部分,給局部變量c1 - c5 分配完寄存器後,可用的寄存器數量就不足了。因而,剩下的5個局部變量c6 - c10 就被分配給了棧的內存空間。以下面代碼所示

mov       dword ptr [ebp-4],6
mov       dword ptr [ebp-8],7
mov       dword ptr [ebp-12],8
mov       dword ptr [ebp-16],9
mov       dword ptr [ebp-20],10

函數入口 add esp,-20 指的是,對棧數據存儲位置的 esp 寄存器(棧指針)的值作減20的處理。爲了確保內存變量 c6 - c10 在棧中,就須要保留5個 int 類型的局部變量(4字節 * 5 = 20 字節)所需的空間。 mov ebp,esp這行指令表示的意思是將 esp 寄存器的值賦值到 ebp 寄存器。之因此須要這麼處理,是爲了經過在函數出口處 mov esp ebp 這一處理,把 esp 寄存器的值還原到原始狀態,從而對申請分配的棧空間進行釋放,這時棧中用到的局部變量就消失了。這也是棧的清理處理。在使用寄存器的狀況下,局部變量則會在寄存器被用於其餘用途時自動消失,以下圖所示。

image.png

mov       dword ptr [ebp-4],6
 mov       dword ptr [ebp-8],7
 mov       dword ptr [ebp-12],8
 mov       dword ptr [ebp-16],9
 mov       dword ptr [ebp-20],10

這五行代碼是往棧空間代入數值的部分,因爲在向棧申請內存空間前,藉助了 mov ebp, esp 這個處理,esp 寄存器的值被保存到了 esp 寄存器中,所以,經過使用[ebp - 4]、[ebp - 8]、[ebp - 12]、[ebp - 16]、[ebp - 20] 這樣的形式,就能夠申請分配20字節的棧內存空間切分紅5個長度爲4字節的空間來使用。例如, mov dword ptr [ebp-4],6 表示的就是,從申請分配的內存空間的下端(ebp寄存器指示的位置)開始向前4字節的地址([ebp - 4]) 中,存儲着6這一4字節數據。

image.png

循環控制語句的處理

上面說的都是順序流程,那麼如今就讓咱們分析一下循環流程的處理,看一下 for 循環以及 if 條件分支等 c 語言程序的 流程控制是如何實現的,咱們仍是以代碼以及編譯後的結果爲例,看一下程序控制流程的處理過程。

// 定義MySub 函數
void MySub(){
  // 不作任何處理
  
}

// 定義MyFunc 函數
void Myfunc(){
  int i;
  for(int i = 0;i < 10;i++){
    // 重複調用MySub十次
    MySub();
  }
}

上述代碼將局部變量 i 做爲循環條件,循環調用十次MySub 函數,下面是它主要的彙編代碼

xor         ebx, ebx     ; 將寄存器清0
@4  call        _MySub        ; 調用MySub函數
        inc            ebx                ; ebx寄存器的值 + 1
        cmp            ebx,10        ;    將ebx寄存器的值和10進行比較
        jl            short @4    ; 若是小於10就跳轉到 @4

C 語言中的 for 語句是經過在括號中指定循環計數器的初始值(i = 0)、循環的繼續條件(i < 10)、循環計數器的更新(i++) 這三種形式來進行循環處理的。與此相對的彙編代碼就是經過比較指令(cmp)跳轉指令(jl)來實現的。

下面咱們來對上述代碼進行說明

MyFunc 函數中用到的局部變量只有 i ,變量 i 申請分配了 ebx 寄存器的內存空間。for 語句括號中的 i = 0 被轉換爲 xor ebx,ebx 這一處理,xor 指令會對左起第一個操做數和右起第二個操做數進行 XOR 運算,而後把結果存儲在第一個操做數中。因爲這裏把第一個操做數和第二個操做數都指定爲了 ebx,所以就變成了對相同數值的 XOR 運算。也就是說無論當前寄存器的值是什麼,最終的結果都是0。相似的,咱們使用 mov ebx,0 也能獲得相同的結果,可是 xor 指令的處理速度更快,並且編譯器也會啓動最優化功能。

XOR 指的就是異或操做,它的運算規則是 若是a、b兩個值不相同,則異或結果爲1。若是a、b兩個值相同,異或結果爲0

相同數值進行 XOR 運算,運算結果爲0。XOR 的運算規則是,值不一樣時結果爲1,值相同時結果爲0。例如 01010101 和 01010101 進行運算,就會分別對各個數字位進行 XOR 運算。由於每一個數字位都相同,因此運算結果爲0。

ebx 寄存器的值初始化後,會經過 call 指定調用 _MySub 函數,從 _MySub 函數返回後,會執行inc ebx 指令,對 ebx 的值進行 + 1 操做,這個操做就至關於 i++ 的意思,++ 表示的就是當前數值 + 1。

這裏須要知道 i++ 和 ++i 的區別

i++ 是先賦值,複製完成後再對 i執行 + 1 操做

++i 是先進行 +1 操做,完成後再進行賦值

inc 下一行的 cmp 是用來對第一個操做數和第二個操做數的數值進行比較的指令。 cmp ebx,10 就至關於 C 語言中的 i < 10 這一處理,意思是把 ebx 寄存器的值與10進行比較。彙編語言中比較指令的結果,會存儲在 CPU 的標誌寄存器中。不過,標誌寄存器的值,程序是沒法直接參考的。那如何判斷比較結果呢?

彙編語言中有多個跳轉指令,這些跳轉指令會根據標誌寄存器的值來判斷是否進行跳轉操做,例如最後一行的 jl,它會根據 cmp ebx,10 指令所存儲在標誌寄存器中的值來判斷是否跳轉,jl 這條指令表示的就是 jump on less than(小於的話就跳轉)。發現若是 i 比 10 小,就會跳轉到 @4 所在的指令處繼續執行。

那麼彙編代碼的意思也能夠用 C 語言來改寫一下,加深理解

i ^= i;
L4: MySub();
        i++;
        if(i < 10) goto L4;

代碼第一行 i ^= i 指的就是 i 和 i 進行異或運算,也就是 XOR 運算,MySub() 函數用 L4 標籤來替代,而後進行 i 自增操做,若是i 的值小於 10 的話,就會一直循環 MySub() 函數。

條件分支的處理方法

條件分支的處理方式和循環的處理方式很類似,使用的也是 cmp 指令和跳轉指令。下面是用 C 語言編寫的條件分支的代碼

// 定義MySub1 函數
void MySub1(){

 // 不作任何處理
}

// 定義MySub2 函數
void MySub2(){
  
 // 不作任何處理
}

// 定義MySub3 函數
void MySub3(){

 // 不作任何處理
}

// 定義MyFunc 函數
void MyFunc(){

 int a = 123;
 // 根據條件調用不一樣的函數
 if(a > 100){
  MySub1();
 }
 else if(a < 50){
  MySub2();
 }
 else
 {
  MySub3();
 }

}

很簡單的一個實現了條件判斷的 C 語言代碼,那麼咱們把它用 Borland C++ 編譯以後的結果以下

_MyFunc proc near
 push      ebp                 
 mov       ebp,esp
 mov       eax,123            ; 把123存入 eax 寄存器中
 cmp       eax,100            ; 把 eax 寄存器的值同100進行比較
 jle       short @8            ; 比100小時,跳轉到@8標籤
 call      _MySub1            ; 調用MySub1函數
 jmp              short @11         ; 跳轉到@11標籤
@8:
 cmp       eax,50                ; 把 eax 寄存器的值同50進行比較
 jge       short @10        ; 比50大時,跳轉到@10標籤
 call      _MySub2            ; 調用MySub2函數
 jmp             short @11        ; 跳轉到@11標籤
@10:
 call      _MySub3            ; 調用MySub3函數
@11:
 pop       ebp
 ret 
_MyFunc endp

上面代碼用到了三種跳轉指令,分別是jle(jump on less or equal) 比較結果小時跳轉,jge(jump on greater or equal) 比較結果大時跳轉,還有無論結果怎樣都會進行跳轉的jmp,在這些跳轉指令以前還有用來比較的指令 cmp,構成了上述彙編代碼的主要邏輯形式。

瞭解程序運行邏輯的必要性

經過對上述彙編代碼和 C 語言源代碼進行比較,想必你們對程序的運行方式有了新的理解,並且,從彙編源代碼中獲取的知識,也有助於瞭解 Java 等高級語言的特性,好比 Java 中就有 native 關鍵字修飾的變量,那麼這個變量的底層就是使用 C 語言編寫的,還有一些 Java 中的語法糖只有經過彙編代碼才能知道其運行邏輯。在某些狀況下,對於查找 bug 的緣由也是有幫助的。

上面咱們瞭解到的編程方式都是串行處理的,那麼串行處理有什麼特色呢?

image.png

串行處理最大的一個特色就是專心只作一件事情,一件事情作完以後纔會去作另一件事情。

計算機是支持多線程的,多線程的核心就是 CPU切換,以下圖所示

image.png

咱們仍是舉個實際的例子,讓咱們來看一段代碼

// 定義全局變量
int counter = 100;

// 定義MyFunc1()
void MyFunc(){
  counter *= 2;
}

// 定義MyFunc2()
void MyFunc2(){
  counter *= 2;
}

上述代碼是更新 counter 的值的 C 語言程序,MyFunc1() 和 MyFunc2() 的處理內容都是把 counter 的值擴大至原來的二倍,而後再把 counter 的值賦值給 counter 。這裏,咱們假設使用多線程處理,同時調用了一次MyFunc1 和 MyFunc2 函數,這時,全局變量 counter 的值,理應編程 100 2 2 = 400。若是你開啓了多個線程的話,你會發現 counter 的數值有時也是 200,對於爲何出現這種狀況,若是你不瞭解程序的運行方式,是很難找到緣由的。

咱們將上面的代碼轉換成彙編語言的代碼以下

mov eax,dword ptr[_counter]     ; 將 counter 的值讀入 eax 寄存器
add eax,eax                                        ; 將 eax 寄存器的值擴大2倍。
mov dword ptr[_counter],eax        ; 將 eax 寄存器的值存入 counter 中。

在多線程程序中,用匯編語言表示的代碼每運行一行,處理都有可能切換到其餘線程中。於是,假設 MyFun1 函數在讀出 counter 數值100後,還將來得及將它的二倍值200寫入 counter 時,正巧 MyFun2 函數讀出了 counter 的值100,那麼結果就將變爲 200 。

image.png

爲了不該bug,咱們能夠採用以函數或 C 語言代碼的行爲單位來禁止線程切換的鎖定方法,或者使用某種線程安全的方式來避免該問題的出現。

如今基本上沒有人用匯編語言來編寫程序了,由於 C、Java等高級語言的效率要比彙編語言快不少。不過,彙編語言的經驗仍是很重要的,經過藉助彙編語言,咱們能夠更好的瞭解計算機運行機制。

文章參考:

《程序是怎樣跑起來的》第十章

相關文章
相關標籤/搜索