程式是怎樣跑起來的-第10章 通過組合語言瞭解程式的實際構成
第10章通過組合語言瞭解程式的實際構成
熱身問題
1.原生代碼指令中,表示其功能的英文縮寫稱為什麼?
助記符、組合語言是通過利用助記符來記述程式的。
2.組合語言的原始碼轉換成原生代碼的方式稱為什麼?
彙編、使用匯編器這個工具來進行彙編。
3.原生代碼轉換成組合語言的原始碼的方式稱為什麼?
反彙編、通過返彙編,得到人們可以理解的程式碼。
4.組合語言的原始檔的副檔名,通常是什麼格式?
.asm、.asm是assembler(彙編器)的簡稱
5.組合語言程式中的段定義指的是什麼?
構成程式的命令和資料的集合組、在高階語言的原始碼中,即使指令和資料在編寫時是分散的,編譯後也會在段定義中集合彙總起來。
6.組合語言的跳轉指令,是在何種情況下使用的?
將程式流程跳轉到其它地址時需要用到該指令、在組合語言中,通過跳轉指令,可以實現迴圈和條件分支。
10.1 組合語言和原生代碼是一一對應的
計算機CPU能直接解釋執行的只有原生代碼(機器語言)程式。用C語言等編寫的原始碼,需要通過各自的編譯器編譯後,轉換成原生代碼。
通過組合語言編寫的原始碼,最終也必須要轉換成原生代碼才能執行。負責轉換工作的程式稱為彙編器,轉換這一處理本身稱為彙編。
用匯編語言編寫的原始碼,和原生代碼是一一對應的。因而,原生代碼也可以反過來轉換成組合語言的原始碼。持有該功能的逆變換程式稱為反彙編程式,逆變換這一處理本身稱為反彙編。
10.2 通過編譯器輸出組合語言的原始碼
大部分C語言編譯器,都可以把利用C語言編寫的原始碼轉換成組合語言的原始碼,而不是原生代碼。利用該功能就可以得到組合語言的原始碼。
main函式是程式執行的起始位置,程式執行的起始位置也稱為“入口點”。
組合語言的原始碼,是由轉換操作碼的指令和針對彙編器的偽指令構成的。
偽指令負責把程式的構造以及彙編的方法指示給彙編器。不過偽指令本身是無法彙編轉換成原生代碼的。
由偽指令segment和ends圍起來的部分,是給構成程式的命令和資料的集合加上一個名字而得到的,稱為段定義。段定義的英文表達segment具有“區域”的意思。在程式中,段定義指的是命令和資料等程式的集合體的意思。一個程式有多個段定義構成。
組合語言指令的語法結構是操作碼+運算元(也存在著只有操作碼沒有運算元的指令),操作碼(opcode)表示指令動作,運算元表示指令物件。
原生代碼載入到記憶體後才能執行。記憶體中儲存著構成原生代碼的指令和資料。程式執行時,CPU會從記憶體中把指令和資料讀出,然後再將其儲存在CPU內部的暫存器中進行處理。
程式執行時,會在記憶體申請分配一個稱為棧的資料空間。棧(stack)有“乾草堆積如山”的意思。就如該名稱所表示的那樣,資料在儲存時是從記憶體的下層(大的地址編號)逐漸往上層(小的地址編號)累積,讀出則是按照從上往下的順序進行。
32位系列的CPU中,進行1次push或pop,即可處理32位(4位元組)的資料。
push指令和pop指令執行後,esp暫存器的值會自動進行更新(push指令是-4,pop指令是+4),因而程式猿沒有必要指定記憶體地址裡。
以下面的程式為例:
int AddNum(int a,int b)
{
return a+b;
}
void MyFunc()
{
int c;
c= AddNum(123,456);
}
轉換為組合語言如下:
_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
其中:
ebp(extended base pointer):擴充套件基址指標暫存器:儲存資料儲存領域基點的記憶體地址。
esp(Extended stack pointer):擴充套件棧指標暫存器:儲存棧中最高位資料的記憶體地址。對棧進行讀寫的記憶體地址是由esp暫存器(棧指標)進行管理的。
eax:資料暫存器中的累加器(accumulator)。
e是extend的意思,為區別於16位CPU的暫存器。
mov A,B 把B的值賦值給我
mov eax,dword ptr [ebp+8] //[ebp+8]表示ebp+8所指向的記憶體,dword ptr表示double word pointer,以為從ebp+8所指向的記憶體讀取4個位元組的資料寫入到eax暫存器。
and A,B 把A同B的值相加,並將結果賦值給A
call A 呼叫函式A
ret 無 將處理返回到函式的呼叫源
push A 把A的值儲存到棧中
pop A 從棧中讀出值,並將其賦值給A
(3)和(4)表示的是傳遞給AddNum函式的引數通過push入棧。雖然呼叫時是AddNum(123,456),但456會先入棧。
(5)在組合語言中,函式名錶示的是函式所在的記憶體地址。
(6)此處時呼叫完AddNum指令後要返回的地址,通過此操作可以把棧中的兩個引數(456和123)進行銷燬處理,也就是棧清理處理。
(6)雖然通過兩次pop指令也可以實現,不過採用esp加8的方式更加有效率。
(6)雖然記憶體中的資料實際上還殘留著,但只要把esp暫存器的值更新為資料儲存地址前面的資料位置,該資料就相當於被銷燬了。
_AddNum proc near
push ebp ——————————(1)
mov ebp,esp ————————(2)
mov eax,dword ptr [ebp+8] ——(3)//[ebp+8]表示ebp+8所指向的記憶體,
add eax,dword ptr [ebp+12] —-(4)
pop ebp ——————————(5)
ret ———————————-(6)
_AddNum endup
(1)+(5):注意ebp的值在(1)中被壓入棧,在(5)中從棧中讀出,重新寫入ebp(擴充套件基址指標暫存器),這是因為暫存器的數量是有限的,為了在函式內使用該暫存器,所以在函式進入時儲存ebp的值,在函式返回時再恢復它的值。
(2)中把負責管理棧地址的esp暫存器的值賦值到了ebp暫存器中。這是因為,在mov指令中方括號內的引數,是不允許指定esp暫存器的。因此,這裡就採用了不直接通過esp,而是通過ebp暫存器來讀寫棧內容的方法。
(3)使用[ebp+8]指定棧中儲存的第1個引數123,並將其讀出到eax暫存器中。像這樣,不使用pop指令,也可以參照棧的內容。而之所以從多個暫存器中選中了eax暫存器,是因為eas暫存器是負責運算的累加暫存器。
(4):通過(4)的add指令,把當前eax暫存器的值同第二個引數相加後的結果儲存到eax暫存器中。[ebp+12]是用來指定第2個引數456的。在C語言中,函式的返回值必須通過eax暫存器返回,這也是規定。不過,和ebp暫存器不同的是,eax暫存器的值不用還原到原始狀態。
(6)中ret指令執行後,函式返回目的地的記憶體地址會自動出棧,程式流程就會跳轉返回到call _AddNum指令的下一條地址。
至此,我們進行了很多解釋,就是為了說明“函式的引數是通過棧來傳遞,返回值是通過暫存器來返回的”這一點。
上面兩個段程式碼結合起來,棧的情況如下:空白表示未使用的空間
AddNum函式呼叫前 |
函式的入口 |
運算處理時 |
函式的出口 |
從AddNum返回後 |
MyFunc函式處理完畢時 |
ebp |
|||||
返回目的地的記憶體地址 |
返回目的地的記憶體地址 |
返回目的地的記憶體地址 |
|||
123 |
123 |
123 |
123 |
123 |
|
456 |
456 |
456 |
456 |
456 |
|
ebp暫存器的值 |
ebp暫存器的值 |
ebp暫存器的值 |
ebp暫存器的值 |
ebp暫存器的值 |
|
. |
. |
. |
. |
. |
. |
10.9 始終確保全域性變數的記憶體空間。
C語言中在函式外部定義的變數稱為全域性變數,在函式內部定義的變數稱為區域性變數。全域性變數可以參閱原始碼的任意部分,而區域性變數只能在定義該變數的函式內部進行參閱。
編譯後的程式,會被歸類到名為段定義的組中,以Borland C++為例,轉換成彙編後,初始化的全域性變數,會被彙總到名為DATA的段定義中,沒有被初始化的全域性變數,會被彙總到BSS的段定義中。指令則被彙總到名為TEXT的段定義中。
以全域性變數int a= 1;為例
DATA segment
_a label dword
dd 1
DATA ends
_a label dword 定義了_a這個標籤。標籤表示的是相對於段定義起始位置的位置。由於_a在DATA段定義的開始位置,所以相對位置是0。_a就相當於全域性變數a。
dd 1指的是,申請分配了4個位元組的記憶體空間,儲存著1這個初始值。dd(define double word)表示的是兩個長度為2的位元組領域(word),也就是4位元組的意思。
以 int b;為例
BSS segment
b label dword
db 4 dup(?)
BSS ends
db 4 dup(?)表示申請分配了4位元組的領域,但之尚未確定。db表示(define byte)有1個長度是1位元組的記憶體空間。
Borland C++中,之所以把全域性變數分為已經初始化的全域性變數和沒有初始化的全域性變數,是因為,程式執行時沒有初始化的全域性變數的領域都會被設定為0進行初始化。可見,通過彙總,初始化很容易實現,只是把記憶體的特定範圍設定為0就可以了。
10.10 臨時確保區域性變數用的記憶體空間
區域性變數被臨時儲存在暫存器和棧中。函式內部利用的棧,在函式處理完畢後會恢復到初始狀態,因此區域性變數的值也就被銷燬了,區域性變數只在函式處理執行期間臨時儲存在暫存器和棧上。
Borland C++編譯器自動優化有可能把區域性變數分配到暫存器中,暫存器空間時使用暫存器,暫存器空間不足的話就使用棧。
10.11 迴圈處理的實現方法
以下面程式碼為例
void MySub()
{
//不做任何處理
}
Void MyFunc()
{
int i;
for (i = 0;i < 10; i++)//其中i稱為迴圈計數器
{
//重複呼叫MySub函式10次
MySub();
}
}
將程式碼中的for迴圈語句轉換成組合語言後:
其中ebx為基址暫存器,儲存記憶體地址。
xor A,B A和B進行異或比較,並將結果存入A中
inc A A的值加1
cmp A,B 對A和B的值進行比較,比較結果會自動存入標誌暫存器中
jl 標籤 和cmp命令組合使用。跳轉到標籤行
xor ebx,ebx ; 將eax暫存器清0-------1
@4 call _MySub ; 呼叫MySub函式-------2
inc ebx ; ebx暫存器的值加1------3
cmp ebx,10 ; 將ebx暫存器的值和10進行比較-4
jl short @4 ; 如果小於10 就跳轉到@4 ---5
1:MyFunc函式中用到的區域性變數只有i,變數i申請分配了ebx暫存器的記憶體空間。for語句的括號中的i=0;被轉換成了xor ebx,ebx這一處理。不管ebx當時的值是什麼,結果肯定是0。雖然用mov ebx,0也會得到同樣的結果,但與mov指令相比,xor指令的處理速度更快。
4:cmp ebx,10就相當於i<10這個處理,把比較結果儲存到標誌暫存器中。
5:標誌暫存器的值,程式時無法直接參考的。那麼程式如何判斷比較結果呢?實際上,組合語言中有多個跳轉指令,這些跳轉指令會根據標誌暫存器的值來判斷是否需要跳轉。jl是jump on less than(小於的話就跳轉)的意思。5的意思是,ebx若小於10的話就跳轉到@4標籤。
人們常說“組合語言是對CPU的實際執行進行直接描述的低階程式語言,C語言是用與人類的感覺相近的表現來描述的高階程式語言”,如果用C語言表示上述的彙編程式碼,則有:
i^=i;
L4: MySub();
i++;
if(i<20) goto L4;
10.12 條件分支的實現方法
以以下函式為例:
void MySub1()
{
//不做任何事情
}
void MySub2()
{
//不做任何事情
}
void MySub3()
{
//不做任何事情
}
void MyFunc()
{
int a = 123;
if ( a>100 )
{
MySub1();
}
else if (a<50)
{
MySub2();
}
else
{
MySub3();
}
}
轉換成彙編後:
_MuFunc proc near
push ebp ;
mov ebp,esp ;
mov eax,123 ; 把123存入eax暫存器中
cmp eax,100 ; 把eax暫存器的值同100進行比較
jle short @8 ; 比100小時,跳轉到@8標籤
call _MySub1 ;
jmp short @11 ; 跳轉到@11標籤
@8: cmp eax,50 ; 把eax暫存器的值同50進行比較
jge short @10 ; 大於50時,跳轉到@10
call _MySun2 ;
jmp short @11 ;
@10: call _MySub3
@11: pop ebp
ret
_MyFunc endp
上述程式碼清單中用到了三種跳轉指令,分別是比較結果小時跳轉的jle(jump on less or equal),大時跳轉到jge(jump on greater or equal)、無論結果如何都無條件跳轉的jmp。此處程式碼使用了eax暫存器儲存變數a。
雖然大部分的C語言參考書中都寫著“為了便於理解程式的結構,應儘量避免使用無條件分支的goto語句”,不過在組合語言這一領域中,如果不使用相當於C語言的goto語句的jmp指令,就無法實現迴圈和條件分支。由此看來,關於應不應該在C語言中使用goto語句,大家沒有必要這麼緊張。