《計算機組成的基本硬件設備》
《計算機組成原理 — 馮諾依曼體系結構》
《計算機組成原理 — 中央處理器》程序員
指令系統決定了計算機的基本功能。計算機的性能與它所設置的指令系統有很大的關係,而指令系統的設置又與機器的硬件結構密切相關。指令系統的改進主要圍繞縮小指令與高級語言的語義差別以及有利於操做系統的優化而進行的,例如:高級語言中常常用到的 if 語句、do 語句,因此設置了功能較強的條件跳轉指令;爲了操做系統的實現和優化,設置了有控制系統狀態的特權指令、管理多道程序和多處理機系統的專用指令。編程
而後指令系統太過於複雜並不徹底是好事,在大多數場景中實際上只有算術邏輯運算、數據傳輸、跳轉和程序調用等幾十條指令會被頻繁的使用到,而須要大量硬件支持的大多數複雜的指令卻並不經常使用,由此會形成硬件資源的浪費。爲了解決這個問題,指令系統被分爲 精簡指令系統計算機(RISC) 和 複雜指令系統計算機(CISC)。數組
計算機指令集是 CPU 提供的可執行指令的集合;而程序所描述的指令是程序員但願在 CPU 上執行的指令,這些指令的範圍不會超出計算機指令集的範圍。不一樣類型的 CPU 可以理解不一樣的 「語言」,即具備不一樣的計算機指令集(Instruction Set)。同一個程序能夠在相同型號的 CPU 中運行,反之則沒法運行。sass
CPU 運行一個計算機程序的本質就是運行這個程序所描述的指令,有如咱們在 Linux 操做系統上執行指令通常,只是前者由 CPU 硬件支持。一個計算機程序一般由成千上萬條指令組成,CPU 顯然不能存放全部的指令,而是將這些指令存放在存儲器中,只有在運行時纔會被 CPU 讀取。又由於現代計算機被設計爲能夠運行多種程序,存儲器被各類各樣的程序共享着,因此存儲器也不會持久化的保存它們。而是當咱們要運行(啓動)某個程序時,纔會將其加載到存儲器中,最終再由 CPU 從存儲器中逐一讀取其指令。咱們常見的內部存儲器多爲 RAM(隨機存儲器),這是一種被設計成掉電就會自動重置的存儲設備。性能優化
以上就是馮·諾依曼機的典型特性,因此又稱之爲 「存儲程序計算機」。馮·諾依曼體系結構解決了計算機實現領域的一個重要難題:如何可以動態加載程序指令。解決了這個問題,「計算器」 才得以成爲 「計算機」,咱們今天才得以在計算機上運行各類各樣的應用程序。markdown
注:計算器的 「程序」 是焊死在主板上的。架構
計算機是經過執行指令來處理各類數據的,爲了瞭解數據的來源、操做結果的去向及所執行的操做類型,一條計算機指令通常包含如下信息。編程語言
綜上,指令格式主要有 操做碼 和 地址碼 組成。須要注意的是,在指令字長較長的計算機中,操做碼的長度通常是固定的,而且由指令集的數量決定。但在指令字較短的計算機中,爲了可以充分利用指令字的位數,在有限的長度中實現更多的指令集數目,因此其操做碼長度被設計成是可變的,即把它們的操做碼在必要的時候擴充到地址碼字段。這就是所謂的 指令操做碼擴展技術。指令字的長度與 CPU 的位數密切相關。函數
平常使用的 Intel CPU 大概有 2000 多條 CPU 指令。能夠分爲如下 5 大類型:性能
繼續細分的話,具備以下指令類型:
指令尋址,便是根據指令字的地址碼來獲取到實際的數據,尋址的方式跟硬件關係密切,不一樣的計算機有不一樣的尋址方式。有的計算機尋址方式種類少,因此會直接在操做碼上表示尋址方式;有些計算機的尋址方式種類多,就會在指令字中添加一個特別用於標記尋址方式的字段,例如:假設該字段具備 3 位,那麼就能夠表示 8 種尋址方式。
NOTE:尋址方式與 CPU 內的寄存器設計密切相關。
直接尋址:指令字的地址碼直接給出了操做數在存儲器中的地址,是最簡單的尋址方式。
間接尋址:指令字的地址碼所指向的寄存器或存儲器的內容並非真實的操做數,而是操做數的地址。間接尋址經常使用於跳轉指令,只要修改寄存器或存儲器的地址就能夠實現跳轉到不一樣的操做數上。
相對尋址:把程序計數器(PC)的內容,即當前執行指令的地址與地址碼部分給出的偏移量(Disp)之和做爲操做數的地址。這種尋址方式一樣經常使用於跳轉(轉移)指令,當程序執行到本條指令後,跳轉到 PC+Disp。
當即數尋址:即地址碼自己就是一個操做數的尋址方式,該方式的特色就是數據塊(由於實際上沒有尋址),但操做數固定。經常使用於爲某個寄存器或存儲器單元賦初值,或提供一個常數。
通用寄存器尋址:CPU 中大概會有幾個到幾十個通用寄存器用於臨時儲存操做數、操做數的地址或中間結果,指令字的地址碼能夠指向這些寄存器。通用寄存器具備地址短,存取速度快的特性,因此地址碼指向通用寄存器的指令的長度也會更短,節省存儲空間,執行效率更快。常被用於執行速度要求嚴格的指令中。
基址寄存器尋址:基址,即基礎地址,基址寄存器就是存放基址的寄存器,能夠是一個專用寄存器,也可使用通用寄存器來充當基址寄存器。執行指令時,須要將基址與指令字的地址碼結合獲得完成的地址,此時的地址碼充當着偏移量(位移量)的角色。當存儲器容量較大時,直接尋址方式是沒法存取到全部存儲單元的,因此一般會採用 分段 或 分頁 的內存管理方式。此時,段或頁的首地址就會存放於基址寄存器中,而指令字的地址碼就做爲段或頁的長度,這樣只要修改基址寄存器的內容就能夠訪問到存儲器的任意單元了。這種尋址方式常被用於爲程序或數據分配存儲區,與虛擬地址實現密切相關。基址寄存器尋址方式解決了程序在存儲器中的定位存儲單元和擴大 CPU 尋址空間的問題。
變址寄存器尋址:變址寄存器內的地址與指令字地址之和獲得了實際的有效地址,若是 CPU 中存在基址寄存器,那麼就還得加上基址地址。這種尋址方式經常使用於處理須要循環執行的程序,例如:循環處理數組,此時變址寄存器所改變的就是數組的下標了。
堆棧尋址:堆棧是有若干個連續的存儲器單元組成的先進後出(FILO)存儲區。堆棧是用於提供操做數和保存運算結果的主要存儲區,同時還主要用於暫存中斷和子程序調用時的線程數據及返回地址。
MIPS(Millions of Instructions Per Second)是一種最簡單的精簡指令集架構,由 MIPS 科技公司設計。MIPS 指令具備 32 位(最新版本爲 64 位),高 6 位爲操做碼(OPCODE),描述了指令的操做類型。其他 26 位具備 3 種格式:R、I 和 J。不一樣的指令類型和操做碼組合可以完成多種功能實現,以下:
加法算數指令 add $t0,$s2,$s1
的指令字及其對應的機器碼以下:
最終加法算數指令 add $t0,$s2,$s1
的二進制機器碼錶示爲 000000 10001 10010 01000 00000 1000000(0X02324020)。能夠看見,機器碼中沒有保存任何實際的程序數據,而是保存了程序數據的儲存的地址,這也算是存儲程序計算機指令集設計的一大特色。
彙編語言是與機器語言最接近的高級編程語言(或稱爲中級編程語言),彙編語言基本上與機器語言對應,即彙編指令和計算機指令是相對匹配的。雖然彙編語言具備與硬件的關係密切,佔用內存小,運行速度快等優勢,但也具備可讀性低、可重用性差,開發效率低下等問題。高級語言的出現是爲了解決這些問題,讓軟件開發變得更加簡單高效,易於協做。但高級語言也存在本身的缺陷,例如:難以編寫直接操做硬件設備的程序等。
因此爲了權衡上述的問題,最終彙編語言被做爲中間的狀態保留了下來。一些高級語言(e.g. C 語言)提供了與彙編語言之間的調用接口,彙編程序可做爲高級語言的外部過程或函數,利用堆棧在二者之間傳遞參數或參數的訪問地址。二者的源程序經過編譯或彙編生成目標文件(OBJ)以後再利用鏈接程序(linker)把它們鏈接成爲可執行文件即可在計算機上運行了。保留彙編語言還爲程序員提供一種調優的手段,不管是 C 程序仍是 Python 程序,當咱們要進行代碼性能優化時,瞭解程序的彙編代碼是一個不錯的切入點。
計算機指令是一種邏輯上的抽象設計,而機器碼則是計算機指令的物理表現。機器碼(Machine Code),又稱爲機器語言,本質是由 0 和 1 組成的數字序列。一條機器碼就是一條計算機指令。程序由指令組成,但讓人類使用機器碼來編寫程序顯然是不人道的,因此逐步發展了對人類更加友好的高級編程語言。這裏咱們須要瞭解計算機是如何將高級編程語言編譯爲機器碼的。
Step 1. 編寫高級語言程序。
// test.c int main() { int a = 1; int b = 2; a = a + b; }
Step 2. 編譯(Compile),將高級語言編譯成彙編語言(ASM)程序。
$ gcc -g -c test.c
Step 3. 使用 objdump 命令反彙編目標文件,輸出可閱讀的二進制信息。下述左側的一堆數字序列就是一條條機器碼,右側 push、mov、add、pop 一類的就是彙編代碼。
$ objdump -d -M intel -S test.o test.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <main>: int main() { 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp int a = 1; 4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1 int b = 2; b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 a = a + b; 12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] 15: 01 45 fc add DWORD PTR [rbp-0x4],eax } 18: 5d pop rbp 19: c3 ret
NOTE:這裏的程序入口是 main()
函數,而不是第 0 條彙編代碼。
值得注意的是,某些特殊的指令,好比跳轉指令,會主動修改 PC 的內容,此時下一條地址就不是從存儲器中順序加載的了,而是到特定的位置加載指令內容。這就是 if…else 條件語句,while/for 循環語句的底層支撐原理。
Step 1. 編寫高級語言程序。
// test.c #include <time.h> #include <stdlib.h> int main() { srand(time(NULL)); int r = rand() % 2; int a = 10; if (r == 0) { a = 1; } else { a = 2; } }
Step 2. 編譯(Compile),將高級語言編譯成彙編語言。
$ gcc -g -c test.c
Step 3. 使用 objdump 命令反彙編目標文件,輸出可閱讀的二進制信息。咱們主要分析 if…else 語句。
if (r == 0) 33: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0 37: 75 09 jne 42 <main+0x42> { a = 1; 39: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1 40: eb 07 jmp 49 <main+0x49> } else { a = 2; 42: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 }
首先進入條件判斷,彙編代碼爲 cmp 比較指令,比較數 1:DWORD PTR [rbp-0x4]
表示變量 r 是一個 32 位整數,數據在寄存器 [rbp-0x4] 中;比較數 2:0x0
表示常量 0 的十六進制。比較的結果會存入到 條件碼寄存器,等待被其餘指令讀取。當判斷條件爲 True 時,ZF 設置爲 1,反正設置爲 0。
條件碼寄存器(Condition Code)是一種單個位寄存器,它們的值只能爲 0 或者 1。當有算術與邏輯操做發生時,這些條件碼寄存器當中的值就隨之發生變化。後續的指令經過檢測這些條件碼寄存器來執行條件分支指令。經常使用的條件碼類型以下:
回到正題,PC 繼續自增,執行下一條 jnp 指令。jnp(jump if not equal)會查看 ZF 的內容,若爲 0 則跳轉到地址 42 <main+0x42>
(42 表示彙編代碼的行號)。前文提到,當 CPU 執行跳轉類指令時,PC 就再也不經過自增的方式來得到下一條指令的地址,而是直接被設置了 42 行對應的地址。由此,CPU 會繼續將 42 對應的指令讀取到 IR 中並執行下去。
42 行執行的是 mov 指令,表示將操做數 2:0x2
移入到 操做數 1:DWORD PTR [rbp-0x8]
中。就是一個賦值語句的底層實現支撐。接下來 PC 恢復如常,繼續以自增的方式獲取下一條指令的地址。
// test.c int main() { int a = 0; int i; for (i = 0; i < 3; i++) { a += i; } }
for (i = 0; i < 3; i++) b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0 12: eb 0a jmp 1e <main+0x1e> { a += i; 14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] 17: 01 45 fc add DWORD PTR [rbp-0x4],eax for (i = 0; i < 3; i++) 1a: 83 45 f8 01 add DWORD PTR [rbp-0x8],0x1 1e: 83 7d f8 02 cmp DWORD PTR [rbp-0x8],0x2 22: 7e f0 jle 14 <main+0x14> }
與普通的跳轉程序(e.g. if…else、while/for)不一樣,函數調用的特色在於具備迴歸(return)的特色,在調用的函數執行完以後會再次回到執行調用的 call 指令的位置,繼續往下執行。可以實現這個效果,徹底依賴堆棧(Stack)存儲區的特性。 首先咱們須要瞭解幾個概念。
堆棧(Stack):是有若干個連續的存儲器單元組成的先進後出(FILO)存儲區,用於提供操做數、保存運算結果、暫存中斷和子程序調用時的線程數據及返回地址。經過執行堆棧的 Push(壓棧)和 Pop(出棧)操做能夠將指定的數據在堆棧中放入和取出。堆棧具備棧頂和棧底之分,棧頂的地址最低,而棧底的地址最高。堆棧的 FILO 的特性很是適用於函數調用的場景:父函數調用子函數,父函數在前,子函數在後;返回時,子函數先返回,父函數後返回。
棧幀(Stack Frame):是堆棧中的邏輯空間,每次函數調用都會在堆棧中生成一個棧幀,對應着一個未運行完的函數。從邏輯上講,棧幀就是一個函數執行的環境,保存了函數的參數、函數的局部變量以及函數執行完後返回到哪裏的返回地址等等。棧幀的本質是兩個指針寄存器: EBP(基址指針,又稱幀指針)和 ESP(棧指針)。其中 EBP 指向幀底,而 ESP 指向棧頂。當程序運行時,ESP 是能夠移動的,大多數信息的訪問都經過移動 ESP 來完成,而 EBP 會一直處於幀低。EBP ~ ESP 之間的地址空間,就是當前執行函數的地址空間。
NOTE:EBP 指向當前位於系統棧最上邊一個棧幀的底部,而不是指向系統棧的底部。嚴格說來,「棧幀底部」 和 「系統棧底部」 不是同一個概念,而 ESP 所指的棧幀頂部和系統棧頂部是同一個位置。
簡單歸納一下函數調用的堆棧行爲,ESP 隨着當前函數的壓棧和出棧會不斷的移動,但因爲 EBP 的存在,因此當前執行函數棧幀的邊界是始終清晰的。當一個當前的子函數調用完成以後,EBP 就會跳到父函數棧幀的底部,而 ESP 也會隨其天然的來到父函數棧幀的頭部。因此,理解函數調用堆棧的運做原理,主要要掌握 EBP 和 ESP 的動向。下面以一個例子來講明。
NOTE:咱們習慣將將父函數(調用函數的函數)稱爲 「調用者(Caller)」,將子函數(被調用的函數)稱爲 「被調用者(Callee)」。
#include <stdio.h> int add(int a, int b) { int result = 0; result = a + b; return result; } int main(int argc, char *argv[]) { int result = 0; result = add(1, 2); printf("result = %d \r\n", result); return 0; }
(gdb) disassemble main Dump of assembler code for function main: 0x08048439 <+0>: push %ebp 0x0804843a <+1>: mov %esp,%ebp 0x0804843c <+3>: and $0xfffffff0,%esp 0x0804843f <+6>: sub $0x20,%esp 0x08048442 <+9>: movl $0x0,0x1c(%esp) # 給 result 變量賦 0 值 0x0804844a <+17>: movl $0x2,0x4(%esp) # 將第 2 個參數 argv 壓棧(該參數偏移爲esp+0x04) 0x08048452 <+25>: movl $0x1,(%esp) # 將第 1 個參數 argc 壓棧(該參數偏移爲esp+0x00) 0x08048459 <+32>: call 0x804841c <add> # 調用 add 函數 0x0804845e <+37>: mov %eax,0x1c(%esp) # 將 add 函數的返回值地址賦給 result 變量,做爲子函數調用完以後的迴歸點 0x08048462 <+41>: mov 0x1c(%esp),%eax 0x08048466 <+45>: mov %eax,0x4(%esp) 0x0804846a <+49>: movl $0x8048510,(%esp) 0x08048471 <+56>: call 0x80482f0 <printf@plt> 0x08048476 <+61>: mov $0x0,%eax 0x0804847b <+66>: leave 0x0804847c <+67>: ret End of assembler dump. (gdb) disassemble add Dump of assembler code for function add: 0x0804841c <+0>: push %ebp # 將 ebp 壓棧(保存函數調用者的棧幀基址) 0x0804841d <+1>: mov %esp,%ebp # 將 ebp 指向棧頂 esp(設置當前被調用函數的棧幀基址) 0x0804841f <+3>: sub $0x10,%esp # 分配棧空間(棧向低地址方向生長) 0x08048422 <+6>: movl $0x0,-0x4(%ebp) # 給 result 變量賦 0 值(該變量偏移爲ebp-0x04) 0x08048429 <+13>: mov 0xc(%ebp),%eax # 將第 2 個參數的值賦給 eax 寄存器(準備運算) 0x0804842c <+16>: mov 0x8