1. 程式人生 > 程式設計 >程式設計師需要了解的硬核知識之組合語言(一)

程式設計師需要了解的硬核知識之組合語言(一)

之前的系列文章從 CPU 和記憶體方面簡單介紹了一下組合語言,但是還沒有系統的瞭解一下組合語言,組合語言作為第二代計算機語言,會用一些容易理解和記憶的字母,單詞來代替一個特定的指令,作為高階程式語言的基礎,有必要系統的瞭解一下組合語言,那麼本篇文章希望大家跟我一起來瞭解一下組合語言。

組合語言和原生程式碼

我們在之前的文章中探討過,計算機 CPU 只能執行原生程式碼(機器語言)程式,用 C 語言等高階語言編寫的程式碼,需要經過編譯器編譯後,轉換為原生程式碼才能夠被 CPU 解釋執行。

但是原生程式碼的可讀性非常差,所以需要使用一種能夠直接讀懂的語言來替換原生程式碼,那就是在各原生程式碼中,附帶上表示其功能的英文縮寫,比如在加法運算的原生程式碼加上add(addition)

的縮寫、在比較運運算元的原生程式碼中加上cmp(compare)的縮寫等,這些通過縮寫來表示具體原生程式碼指令的標誌稱為 助記符,使用助記符的語言稱為組合語言。這樣,通過閱讀組合語言,也能夠瞭解原生程式碼的含義了。

不過,即使是使用匯編語言編寫的原始碼,最終也必須要轉換為原生程式碼才能夠執行,負責做這項工作的程式稱為編譯器,轉換的這個過程稱為彙編。在將原始碼轉換為原生程式碼這個功能方面,彙編器和編譯器是同樣的。

用匯編語言編寫的原始碼和原生程式碼是一一對應的。因而,原生程式碼也可以反過來轉換成組合語言編寫的程式碼。把原生程式碼轉換為彙編程式碼的這一過程稱為反彙編,執行反彙編的程式稱為反彙編程式

哪怕是 C 語言編寫的原始碼,編譯後也會轉換成特定 CPU 用的原生程式碼。而將其反彙編的話,就可以得到組合語言的原始碼,並對其內容進行調查。不過,原生程式碼變成 C 語言原始碼的反編譯,要比原生程式碼轉換成彙編程式碼的反彙編要困難,這是因為,C 語言程式碼和原生程式碼不是一一對應的關係。

通過編譯器輸出組合語言的原始碼

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

首先需要先做一些準備,需要先下載 Borland C++ 5.5 編譯器,為了方便,我這邊直接下載好了讀者直接從我的百度網盤提取即可 (連結:

pan.baidu.com/s/19LqVICpn… 密碼:hz1u)

下載完畢,需要進行配置,下面是配置說明 (wenku.baidu.com/view/22e2f4…

首先用 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 的種類決定的,下面對操作碼的功能進行了整理。

原生程式碼需要載入到記憶體後才能執行,記憶體中儲存著構成原生程式碼的指令和資料。程式執行時,CPU會從記憶體中把資料和指令讀出來,然後放在 CPU 內部的暫存器中進行處理。

如果 CPU 和記憶體的關係你還不是很瞭解的話,請閱讀作者的另一篇文章 程式設計師需要了解的硬核知識之CPU 詳細瞭解。

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

指令解析

下面就對 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)的特性是後入先出,資料在儲存時是從記憶體的下層(大的地址編號)逐漸往上層(小的地址編號)累積,讀出時則是按照從上往下進行讀取的。

棧是儲存臨時資料的區域,它的特點是通過 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 檔案時,出現了下圖的這條訊息

圖中的意思是指 c 的值在 MyFunc 定義了但是一直未被使用,這其實是一項編譯器優化的功能,由於儲存著 AddNum 函式返回值的變數 c 在後面沒有被用到,因此編譯器就認為 該變數沒有意義,進而也就沒有生成與之對應的組合語言程式碼

下圖是呼叫 AddNum 這一函式前後棧記憶體的變化

函式的內部處理

上面我們用匯程式設計式碼分析了一下 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 函式入口和出口處棧的狀態變化,就如下圖所示

這是程式設計師需要了解的硬核知識之組合語言(一) 第一篇文章,下一篇文章我們會著重討論區域性變數和全域性變數以及迴圈控制語句的組合語言,防止斷更,請關注我