匯編語言是一種什么語言?
結合本人從清華學習經驗,說說本人的深切體會吧,初識它時,認為匯編語言是一種助記符,一種低級語言,直接面對指令,將二進制指令替換成人類便于記憶的字符串,并冠以特殊的格式。每一條匯編指令對應一條二進制指令。根據內核架構的不同,不同的指令有不同的長度和格式。
大多數人一開始都以為匯編語言本身很簡單,常用指令沒幾個,語法規則也不多,看幾個小時資料似乎就明白了,但其實不然。匯編的背后是體系結構,是程序設計拋開各種高層形態的最根本,最本質的解釋。本人從業多年,除了跟我一樣搞過很久安全的同學,其余的沒有一個我認為算是精通匯編。而我是怎么掌握匯編的呢 ?
1 早年用匯編手寫病毒。比如處理指令重定位,是真的用匯編計算指令地址,push push call 實現函數調用。
2 長期病毒木馬二進制分析。分析明白各種 malware 的原理,實現查殺防。個別病毒,需要實現修復。
3 漏洞挖掘。fuzzer 發現漏洞,匯編級詳細分析,exploit 編寫,武器化利用,一條龍。
4 各種逆向分析。好的東西沒有代碼,IDA 里看就是了。
5 跟debuger 做朋友。從來看不上print 方式的bug定位。所有問題在調試器里分析明白,絕不靠猜。
6 編譯器后端研究。什么指令選擇,指令調度,寄存器分配,全都研究一遍。
7 底層開發,操作系統,設備驅動,虛擬化都走上一遍。X86很熟? ARM學一遍做對比。歷經這么多,終于敢說學明白匯編了。推薦如下的文檔,很基礎又相對全面的介紹了很多計算機常見問題在匯編層面的解釋。《Introduction to Computer Organization with x86-64 Assembly Language & GNU/Linux》
學匯編不是說一定要用這它做多牛鼻的事情, 問題的關鍵在于, 學透了匯編會使你真正理解計算機另外一方面, 如上面所說, 在工作中你遲早會在某個陰暗的角落遇到匯編. 不管你承認不承認, 現在的CPU沒有直接跑高級語言的, 哪怕是虛擬機也都是類似匯編的指令集.當遇到崩潰分析, 性能優化甚至編譯器抽風等等的時候, 匯編是你最后一根救命稻草.
下面再講講匯編語言的基本內容吧:
目前國內的匯編語言教材大多都是上來先講一大堆CPU、總線、寄存器、標志位……再講匯編語言程序設計。這種字典式的編寫方法對入門是很不利的,因為在不知道這些東西都是用來干什么的情況下,全部記憶往往很難。然而這些概念在編程中還不得不用到,于是又得重新往前翻書,這就陷入了一個循環。
實際上,匯編語言的學習完全可以和高級語言一樣。只不過因為匯編語言是根據CPU的工作原理進行操作,所以一切代碼都要從CPU和內存的角度考慮問題。理解了指令在內存層面的執行過程,編程就水到渠成了。
先從最簡單的開始:給定兩個數a和b,讓CPU做一次加法,結果儲存在c中。輸出c。
用C語言編寫這個程序:
int a=3;
int b=4;
int c;
int main()
{
c=a+b;
printf("%d",c);
return 0;
}
(注意:如果寫int c=3+4,一行就可以搞定。但是這里沒這樣做,而是先統一聲明所有的變量,然后再在進行運算的主函數中執行相加操作。后面可以看到,這種編程習慣是符合二進制數據在內存中的存放規律的。)
如果用匯編語言編寫,該怎樣寫呢?
再重復一下題目:給定兩個數a和b,讓CPU做一次加法,結果儲存在c中。輸出c。
要從原理上寫這個過程,就要解決以下問題:
①數據a和b怎么存儲 ②怎么做加法 ③怎么儲存結果 ④怎么輸出結果
下面將分別解決這四個問題。
1.匯編語言程序的結構
首先,我們要知道二進制信號在內存中的存放規律。眾所周知,計算機能直接處理的只能是二進制信號,這些信號以高低電平的方式存放在內存中,既可以作為指令,也可以作為程序使用的數據。一塊內存區域所存放的二進制信號到底是指令還是數據,是由相應的命令說了算的。
CPU在讀取指令/數據時,每讀取一條指令/數據,內存位置指針就加1,指向下一條指令/數據的內存地址。這樣就產生了一個問題:數據和指令在內存中應該分塊,并且要連續存放。否則如果內存位置指針不知道下一個位置是數據還是代碼,將會給內存位置指針的尋址帶來極大的不便。所以,在匯編程序中,要人工將內存分為數據段(Data Segment),代碼段(Code Segment),堆棧段(Stack Segment)和附加段(Extra Segment)。
這樣劃分好以后,我們只需要告訴內存位置指針每個段在內存中的起始地址,內存位置指針就可以順利尋址了。怎樣告訴呢?在CPU中,有一組專門的段寄存器用來存放各個段的起始地址。它們是:DS(用來存放數據段的起始地址),CS(用來存放代碼段的起始地址),SS(用來存放堆棧段的起始地址),ES(用來存放附加段的起始地址)。程序員在編程時,需要人工指定這些段寄存器對應于程序中的哪個段。
有了段的概念,我們就可以寫出一個匯編程序的基本框架如下:
DATA SEGMENT ;定義一個叫DATA的段。DATA既是這個段的名稱,也指代這個段的地址。但這里并未規定這個段是數據段、代碼段還是其他段
……
SEGMENT ENDS ;表示段結束。ENDS是END SEGMENT的縮寫。
STACK SEGMENT ;定義一個叫STACK的段,這個段的地址用STACK表示。
……
SEGMENT ENDS ;段結束
CODE SEGMENT ;定義一個叫CODE的段,,這個段的地址用CODE表示。
ASSUME:CS:CODE,DS:DATA,SS:SEGMENT ;告訴編譯器,將代碼中寫的各段分別對應上各個段寄存器。這句話要放在準備用作代碼段的段開頭
……
SEGMENT ENDS ;段結束
好了。回到我們的問題:怎樣存儲a和b呢?在數據段中聲明變量如下:
DATA SEGMENT
A DW 03H ;定義一個名為A的雙字節(即1個字)的數據,DW是Define Word 的縮寫。末尾加H表示十六進制。
這相當于C語言中的int A=3,只不過int表示的范圍遠大于DW而 已。
B DW 04H ;定義一個名為B的雙字節數據。由于B是緊挨著A之后定義的,根據 數據段的連續性,B在數據段的偏移地址就是A在數據段的偏移地 址 + A的長度。由于 A是雙字節數據,所以A的長度是2個字。
SEGMENT ENDS
2.CPU的運算方式及運算結果的判定
第二個問題:怎樣做一次加法?
CPU只能處理電平信號。學過模電的都知道,有一種東西叫“加法器”,輸入2個電壓信號,經過運算放大器后,就會得到這兩個信號的和。所以CPU做加法的方式就是:把輸入的兩個二進制信號輸入加法器,得到結果。
問題似乎解決了。但是我們突然發現,這樣的結果幾乎沒有任何意義,因為我們無法知道結果的性質。比如,如果結果超出了能容許的最大位數(溢出),會怎么樣?CPU沒有任何提示。又或者,我們要比較兩個數的大小,這就要將兩個數相減。然而結果是正是負?我們無從知曉。
為了獲知運算結果的性質,在CPU中設置了一個“標志寄存器”,專門用于存放運算結果的各種標志。它們都是用電路實現的。比如:
CF(Carry Flag)就是用來標志無符號數運算是否產生進位。產生進位時,CF=1,反之CF=0。特別指出,CF標志位的值對有符號數的運算沒有意義。
OF(Overflow Flag)則是用來標志有符號數運算是否產生溢出。產生溢出時,OF=1,反之OF=0。同理,OF標志位的值對無符號數的運算沒有意義。
SF(Sign Flag)用來標志結果的正負。當結果是負(SF)時,SF=1。反之SF=0。
回到我們的問題:怎么做一次加法?或者更一般地,怎樣做一次運算?
我們不必關心具體的電路實現細節,只需要執行相應的運算指令,運算完成后,不僅會得到結果,各個標志位的值也可能發生相應的改變,從而有利于我們對結果的判斷。例如:
ADD AX,BX ;把AX和BX中的內容相加,結果存放在AX中。若AX,BX為有符號數,當 產生溢出時,OF=1.CF的值不確定。當結果為負時,SF=1。
3.內存與寄存器的關系
內存(RAM)是存放各種數據、指令的地方。根據用途的不同,又可以把它分成不同的段。而寄存器(Register)則是CPU內部臨時存放運算結果的地方。與容量較大的內存相比,寄存器的容量極小(每個寄存器只有16位),數量有限(只有少數幾個),用途專一(各個寄存器有不同的用途,用來存放不同方面的結果)。例如,前面所述的段寄存器(DS,CS,SS,ES)就是用來存放段的起始地址的。除了段寄存器之外,CPU中還設有通用寄存器(AX,BX,CX,DX……)。它們各自有其專門的用途,在不致于產生沖突的情況下,也可以用來存放數據或運算結果。
通用寄存器的用途簡述如下:(通用寄存器容量都是16位的)
AX:①用來存放數據或運算結果
②AX的高8位AH用于與DOS操作系統通信。向AH中裝入DOS系統的指令碼并執行,可以利用DOS系統完成一些操作,如在屏幕上輸出字符。
DX:①用來存放數據或運算結果
②與AH的DOS屏幕輸出指令碼配合使用,存放準備輸出到屏幕上的數據
CX:在有循環的程序中,用來存放循環次數。相當于for循環中的計數變量i。
BX、SI、DI:①用來存放數據或運算結果
②用來存放數據段中的數據在段中的偏移地址
一般而言,需要運算的數據存放在內存中。CPU在程序的指令下,通過指針確定它們的位置,將它們讀入寄存器。進行運算后,再將結果返回到內存預留的結果位置中。
回到我們的問題,在內存的DATA SEGMENT中存放有兩個雙字節數據A=3和B=4。要將它們讀入寄存器進行相加運算,再將結果寫入到內存中。為了讀入寄存器,首先需要獲取A和B在內存數據段中的偏移地址。確定它們的地址后,按地址將它們讀入寄存器(這里可以任選兩個寄存器),然后執行運算指令。運算完成后,將儲存在寄存器中的結果寫入到內存DATA SEGMENT中事先預留的位置。使用"MOV 目標,源"指令完成源對目標的賦值。代碼如下:
DATA SEGMENT
A DW 03H
B DW 04H
C DW ? ;?表示聲明時不賦值。相當于int c;
SEGMENT ENDS
STACK SEGMENT
SEGMENT ENDS
CODE SEGMENT
ASSUME:CS:CODE,DS:DATA,SS:SEGMENT
START: ;指定程序的入口位置(這個位置當然要在代碼段中啦),并命名為START。
MOV AX,DATA
MOV DS,AX ;這兩行的意思是,以通用寄存器AX為中介,將數據段DATA的起始地址(用句柄DATA表示)送入數據段寄存器DS中。在需要使用數據段的程序中,這一步是必須的,否則CPU無法確定數據段的位置。注意:ASSUME是一個偽代碼,它只是告訴了編譯器各個段與段寄存器的對應關系,并未存入各段的地址。(由于電路結構的原因,AX不能直接對DS賦值。不能直接寫MOV DS,DATA。)
LEA SI,A ; 注意:與DATA SEGMENT中DATA的含義不同,數據段中A DW 03H中的A僅表示變量名,不表示變量的地址。類似于int a=3,a只是名稱,取地址要用&a。在匯編中,取地址用LEA BX/SI/DI,A的格式。注:通常用BX、SI、DI這三個寄存器存數據的偏移地址。
MOV AX,[SI] ;將 [內存數據段中以SI為偏移地址的內容] 送入通用寄存器 AX中。這里AX也可以換成BX,CX等。
INC SI ;SI的值加1。即SI++。
INC SI ;因為A是雙字節數據,所以SI要加2才能指向下一個數據B的偏移地址。當 然,這兩條也可用 LEA SI,B替代。
MOV BX,[SI] ;將 [內存數據段中以SI為偏移地址的內容] ——就是B,送入通用寄存器 BX中。
ADD BX,AX ;BX與AX相加,結果存放在BX中。當然也可以寫ADD AX,BX。但之所以不 存放在AX中,是因為一會輸出要用到AH,避免沖突。
LEA DI,C
MOV [DI],BX ;這兩條是將存放在BX中的結果寫回數據段中C所在位置
MOV DX,BX ;準備在屏幕上輸出結果。屏幕輸出的是寄存器DX中的內容。
ADD DX,30H ;由于屏幕上輸出的是文字,因此必須將數字+30H轉換為對應的ASCII碼
MOV AH,02H ;將控制DOS系統輸出數值的代碼02H裝入AH。這步可理解為printf()中的"%d"
INT 21H ;INT=Interrupt,中斷,執行DOS命令。執行后返回程序。
MOV AH,4CH ;4CH是程序結束,返回DOS系統的命令。將此命令裝入AH,等待執行。
INT 21H ;中斷,DOS系統執行4CH命令。程序結束。這兩步類似于 "return 0"
SEGMENT ENDS
END START ;在程序的尾部,告訴MASM(宏匯編)編譯器程序的入口位置在標號START處運行結果如下:
輸出結果為7。當然,你可以不用MASM編譯工具,而用其它的匯編IDE集成開發環境.
最后,希望通過這篇短文,能夠幫助你能更好的認識,理解,學習好這門語言。