彙編語言的簡單瞭解

彙編語言是什麼?

對於人類來說,二進制程序是不可讀的,根本看不出來機器幹了什麼。爲了解決可讀性的問題,以及偶爾的編輯需求,就誕生了彙編語言。

彙編語言是二進制指令的文本形式,與指令是一一對應的關係。比如,加法指令00000011寫成彙編語言就是 ADD。只要還原成二進制,彙編語言就可以被 CPU 直接執行,所以它是最底層的低級語言。

來歷

爲了解決二進制指令的可讀性問題,工程師將那些指令寫成了八進制。二進制轉八進制是輕而易舉的,但是八進制的可讀性也不行。很自然地,最後還是用文字表達,加法指令寫成 ADD。內存地址也不再直接引用,而是用標籤表示。

這樣的話,就多出一個步驟,要把這些文字指令翻譯成二進制,這個步驟就稱爲 assembling,完成這個步驟的程序就叫做 assembler。它處理的文本,自然就叫做 aseembly code。標準化以後,稱爲 assembly language,縮寫爲 asm,中文譯爲彙編語言。

內存模型

堆(heap)

寄存器只能存放很少量的數據,大多數時候,CPU 要指揮寄存器,直接跟內存交換數據。所以,除了寄存器,還必須瞭解內存怎麼儲存數據。

程序運行的時候,操作系統會給它分配一段內存,用來儲存程序和運行產生的數據。這段內存有起始地址和結束地址,比如從0x1000到0x8000,起始地址是較小的那個地址,結束地址是較大的那個地址。

 

程序運行過程中,對於動態的內存佔用請求(比如新建對象,或者使用malloc命令),系統就會從預先分配好的那段內存之中,劃出一部分給用戶,具體規則是從起始地址開始劃分(實際上,起始地址會有一段靜態數據,這裏忽略)。舉例來說,用戶要求得到10個字節內存,那麼從起始地址0x1000開始給他分配,一直分配到地址0x100A,如果再要求得到22個字節,那麼就分配到0x1020。

這種因爲用戶主動請求而劃分出來的內存區域,叫做 Heap(堆)。它由起始地址開始,從低位(地址)向高位(地址)增長。Heap 的一個重要特點就是不會自動消失,必須手動釋放,或者由垃圾回收機制來回收

棧(stack)

Stack 是由於函數運行而臨時佔用的內存區域。

請看下面的例子。

int main(){
    int a=2;
    int b=3;
}

上面代碼中,系統開始執行main函數時,會爲它在內存裏面建立一個幀(frame),所有main的內部變量(比如a和b)都保存在這個幀裏面。main函數執行結束後,該幀就會被回收,釋放所有的內部變量,不再佔用空間。

等到add_a_and_b運行結束,它的幀就會被回收,系統會回到函數main剛纔中斷執行的地方,繼續往下執行。通過這種機制,就實現了函數的層層調用,並且每一層都能使用自己的本地變量。

所有的幀都存放在 Stack,由於幀是一層層疊加的,所以 Stack 叫做棧。Stack 的特點就是,最晚入棧的幀最早出棧(因爲最內層的函數調用,最先結束運行)。每一次函數執行結束,就自動釋放一個幀,所有函數執行結束,整個 Stack 就都釋放了。

Stack 是由內存區域的結束地址開始,從高位(地址)向低位(地址)分配。比如,內存區域的結束地址是0x8000,第一幀假定是16字節,那麼下一次分配的地址就會從0x7FF0開始;第二幀假定需要64字節,那麼地址就會移動到0x7FB0。

CPU指令

一個實例:下面是一個簡單程序example.c

int add_a_and_b(int a,int b){
    return a+b;
}

int main(){
    return add_a_and_b(2,3);
}

上面代碼轉爲彙編語言(example.s)以後,包含了幾十行指令。這麼說吧,一個高級語言的簡單操作,底層可能由幾個,甚至幾十個 CPU 指令構成。CPU 依次執行這些指令,完成這一步操作。

example.s經過簡化後,大概是下面的樣子

_add_a_and_b:
   push   %ebx
   mov    %eax, [%esp+8]
   mov    %ebx, [%esp+12]
   add    %eax, %ebx
   pop    %ebx
   ret 

_main:
   push   3
   push   2
   call   _add_a_and_b
   add    %esp, 8
   ret

可以看到,原程序的兩個函數add_a_and_b和main,對應兩個標籤_add_a_and_b和_main。每個標籤裏面是該函數所轉成的 CPU 運行流程。

每一行就是 CPU 執行的一次操作。它又分成兩部分,就以其中一行爲例。

push   %ebx

這一行裏面,push是 CPU 指令,%ebx是該指令要用到的運算子。一個 CPU 指令可以有零個到多個運算子。

下面就一行一行解釋這個彙編程序。

push指令

根據約定,程序從_main標籤開始執行,這時會在 Stack 上爲main建立一個幀,並將 Stack 所指向的地址,寫入 ESP 寄存器。後面如果有數據要寫入main這個幀,就會寫在 ESP 寄存器所保存的地址。(ESP 寄存器是特定用途,保存當前 Stack 的地址)

然後,開始執行第一行代碼。

push 3

push指令用於將運算子放入 Stack,這裏就是將3寫入main這個幀。

雖然看上去很簡單,push指令其實有一個前置操作。它會先取出 ESP 寄存器裏面的地址,將其減去4個字節,然後將新地址寫入 ESP 寄存器。使用減法是因爲 Stack 從高位向低位發展,4個字節則是因爲3的類型是int,佔用4個字節。得到新地址以後, 3 就會寫入這個地址開始的四個字節。

push   2

第二行也是一樣,push指令將2寫入main這個幀,位置緊貼着前面寫入的3。這時,ESP 寄存器會再減去 4個字節(累計減去8)。

 

call指令

第三行的call指令用來調用函數。

call   _add_a_and_b

上面的代碼表示調用add_a_and_b函數。這時,程序就會去找_add_a_and_b標籤,併爲該函數建立一個新的幀。

 

下面就開始執行_add_a_and_b的代碼。

push   %ebx

這一行表示將 EBX 寄存器裏面的值,寫入_add_a_and_b這個幀。這是因爲後面要用到這個寄存器,就先把裏面的值取出來,用完後再寫回去。

這時,push指令會再將 ESP 寄存器裏面的地址減去4個字節(累計減去12)。

mov指令

mov指令用於將一個值寫入某個寄存器

mov    %eax, [%esp+8]

這一行代碼表示,先將 ESP 寄存器裏面的地址加上8個字節,得到一個新的地址,然後按照這個地址在 Stack 取出數據。根據前面的步驟,可以推算出這裏取出的是2,再將2寫入 EAX 寄存器。

 

下一行代碼也是幹同樣的事情。

mov    %ebx, [%esp+12]

上面的代碼將 ESP 寄存器的值加12個字節,再按照這個地址在 Stack 取出數據,這次取出的是3,將其寫入 EBX 寄存器。

add指令

add指令用於將兩個運算子相加,並將結果寫入第一個運算子。

add    %eax, %ebx

上面的代碼將 EAX 寄存器的值(即2)加上 EBX 寄存器的值(即3),得到結果5,再將這個結果寫入第一個運算子 EAX 寄存器。

pop指令

pop指令用於取出 Stack 最近一個寫入的值(即最低位地址的值),並將這個值寫入運算子指定的位置。

pop    %ebx

上面的代碼表示,取出 Stack 最近寫入的值(即 EBX 寄存器的原始值),再將這個值寫回 EBX 寄存器(因爲加法已經做完了,EBX 寄存器用不到了)。

ret 指令

ret指令用於終止當前函數的執行,將運行權交還給上層函數。也就是,當前函數的幀將被回收

ret

可以看到,該指令沒有運算子。

隨着add_a_and_b函數終止執行,系統就回到剛纔main函數中斷的地方,繼續往下執行。

add    %esp, 8

上面的代碼表示,將 ESP 寄存器裏面的地址,手動加上8個字節,再寫回 ESP 寄存器。這是因爲 ESP 寄存器的是 Stack 的寫入開始地址,前面的pop操作已經回收了4個字節,這裏再回收8個字節,等於全部回收。

ret

最後,main函數運行結束,ret指令退出程序執行。

 

參考於:彙編語言入門教程  作者:阮一峯

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章