計算機組成原理(一)
CPU效能
響應時間:指的就是,我們執行一個程式,到底需要花多少時間。花的時間越少,自然效能就越好。
吞吐率:在一定的時間範圍內,到底能處理多少事情。這裡的“事情”,在計算機裡就是處理的資料或者執行的程式指令。
我們一般把效能,定義成響應時間的倒數,也就是: 效能 = 1/響應時間
程式執行的時間
程式執行的時間=程式執行結束的時間-程式開始執行的時間
但是,計算機可能同時執行著好多個程式,CPU實際上不停地在各個程式之間進行切換。在這些走掉的時間裡面,很可能CPU切換去執行別的程式了。所以這個時間並不準。
我們使用time命令統計執行時間:
$ time seq 1000000 | wc -l 1000000 real 0m0.101s user 0m0.031s sys 0m0.016s
其中real就是Wall Clock Time,而程式實際花費的CPU執行時間,就是user time加上sys time。
我們下面對程式的CPU執行時間進行拆解:
程式的CPU執行時間=CPU時鐘週期數×時鐘週期時間
時鐘週期時間:如果一臺電腦的主頻是2.8GHz,那麼可以簡單認為,CPU在1秒時間內,可以執行的簡單指令的數量是2.8G條。在這個2.8GHz的CPU上,這個時鐘週期時間,就是1/2.8G。
對於上面的公式:CPU時鐘週期數還可以拆解成指令數×每條指令的平均時鐘週期數Cycles Per Instruction,簡稱CPI)。
程式的CPU執行時間=指令數×CPI×Clock Cycle Time
並行優化
由於通過提升CPU頻率已經達到瓶頸,所以開始推出多核CPU,通過提升“吞吐率”而不是“響應時間”,來達到目的。
但是,並不是所有問題,都可以通過並行提高效能來解決。如果想要使用這種思想,需要滿足這樣幾個條件。
- 需要進行的計算,本身可以分解成幾個可以並行的任務。
- 需要能夠分解好問題,並確保幾個人的結果能夠彙總到一起。
- 在“彙總”這個階段,是沒有辦法並行進行的,還是得順序執行,一步一步來。
所以平行計算涉及到了一個阿姆達爾定律(Amdahl’s Law)。
對於一個程式進行優化之後,處理器並行運算之後效率提升的情況。具體可以用這樣一個公式來表示:
優化後的執行時間 = 受優化影響的執行時間/加速倍數+不受影響的執行時間
比如做一段資料的計算, 本來如果整個計算單核完成需要120ns,但是我們可以將這個任務拆分成4個,最後再彙總加起來。如果每個任務單獨計算需要25ns,加起來彙總需要20ns,那麼4個任務平行計算需要100/4+20=25ns。
即使我們增加更多的並行度來提供加速倍數,比如有100個CPU,整個時間也需要100/100+20=21ns。
從編譯到彙編,程式碼怎麼變成機器碼?
如下C語言程式例子:
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
我們給兩個變數 a、b分別賦值1、2,然後再將a、b兩個變數中的值加在一起,重新賦值給了a整個變數。
要讓這段程式在一個Linux作業系統上跑起來,我們需要把整個程式翻譯成一個組合語言(ASM,Assembly Language)的程式,這個過程我們一般叫編譯(Compile)成彙編程式碼。
針對彙編程式碼,我們可以再用匯編器(Assembler)翻譯成機器碼(Machine Code)。這些機器碼由“0”和“1”組成的機器語言表示。這一條條機器碼,就是一條條的計算機指令。這樣一串串的16進位制數字,就是我們CPU能夠真正認識的計算機指令。
彙編程式碼其實就是“給程式設計師看的機器碼”,也正因為這樣,機器碼和彙編程式碼是一一對應的。我們人類很容易記住add、mov這些用英文表示的指令,而8b 45 f8這樣的指令,由於很難一下子看明白是在幹什麼,所以會非常難以記憶。所以我們需要彙編程式碼。
程式指令
指令是如何被執行的
一個CPU裡面會有很多種不同功能的暫存器。我這裡給你介紹三種比較特殊的。
一個是PC暫存器(Program Counter Register),也叫指令地址暫存器(Instruction Address Register)。它就是用來存放下一條需要執行的計算機指令的記憶體地址。
第二個是指令暫存器(Instruction Register),用來存放當前正在執行的指令。
第三個是條件碼暫存器(Status Register),用裡面的一個一個標記位(Flag),存放CPU進行算術或者邏輯計算的結果。
實際上,一個程式執行的時候,CPU會根據PC暫存器裡的地址,從記憶體裡面把需要執行的指令讀取到指令暫存器裡面執行,然後根據指令長度自增,開始順序讀取下一條指令。可以看到,一個程式的一條條指令,在記憶體裡面是連續儲存的,也會一條條順序載入。
程式的執行和跳轉
現在就來看一個包含if…else的簡單程式。
// 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;
}
把這個程式編譯成彙編程式碼。
if (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
3f: 75 09 jne 4a <main+0x4a>
{
a = 1;
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
48: eb 07 jmp 51 <main+0x51>
}
else
{
a = 2;
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
}
可以看到,這裡對於r == 0的條件判斷,被編譯成了cmp和jne這兩條指令。
對於:
cmp DWORD PTR [rbp-0x4],0x0
cmp指令比較了前後兩個運算元的值,這裡的DWORD PTR代表操作的資料型別是32位的整數,而[rbp-0x4]則是一個暫存器的地址。所以,第一個運算元就是從暫存器裡拿到的變數r的值。第二個運算元0x0就是我們設定的常量0的16進製表示。cmp指令的比較結果,會存入到條件碼暫存器當中去。
在這裡,如果比較的結果是False,也就是0,就把零標誌條件碼(對應的條件碼是ZF,Zero Flag)設定為1。
cmp指令執行完成之後,PC暫存器會自動自增,開始執行下一條jne的指令。
對於:
jne 4a <main+0x4a>
jne指令,是jump if not equal的意思,它會檢視對應的零標誌位。如果為0,會跳轉到後面跟著的運算元4a的位置。這個4a,對應這裡彙編程式碼的行號,也就是上面設定的else條件裡的第一條指令。
當跳轉發生的時候,PC暫存器就不再是自增變成下一條指令的地址,而是被直接設定成這裡的4a這個地址。這個時候,CPU再把4a地址裡的指令載入到指令暫存器中來執行。
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
4a的指令,實際是一條mov指令,第一個運算元和前面的cmp指令一樣,是另一個32位整型的暫存器地址,以及對應的2的16進位制值0x2。mov指令把2設定到對應的暫存器裡去,相當於一個賦值操作。然後,PC暫存器裡的值繼續自增,執行下一條mov指令。
下一條指令也是mov,第一個運算元eax,代表累加暫存器,第二個運算元0x0則是16進位制的0的表示。這條指令其實沒有實際的作用,它的作用是一個佔位符。
函式呼叫
我們先來看個例子:
// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
我們把這個程式編譯之後:
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 <main>:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 <add>
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
在add函式編譯之後,程式碼先執行了一條push指令和一條mov指令;在函式執行結束的時候,又執行了一條pop和一條ret指令。
add函式的第0行,push rbp這個指令,就是在進行壓棧。這裡的rbp又叫棧幀指標(Frame Pointer),是一個存放了當前棧幀位置的暫存器。push rbp就把之前呼叫函式的返回地址,壓到棧頂。
接著,第1行的一條命令mov rbp, rsp裡,則是把rsp這個棧指標(Stack Pointer)的值複製到rbp裡,而rsp始終會指向棧頂。這個命令意味著,rbp這個棧幀指標指向的返回地址,變成當前最新的棧頂,也就是add函式的返回地址了。
而在函式add執行完成之後,又會分別呼叫第12行的pop rbp來將當前的棧頂出棧,然後呼叫第13行的ret指令,將程式的控制權返回到出棧後的棧頂,也就是main函式的返回地址。
拆解程式執行
實際上,“C語言程式碼-彙編程式碼-機器碼” 這個過程,在我們的計算機上進行的時候是由兩部分組成的。
第一個部分由編譯(Compile)、彙編(Assemble)以及連結(Link)三個階段組成。在這三個階段完成之後,我們就生成了一個可執行檔案。
第二部分,我們通過裝載器(Loader)把可執行檔案裝載(Load)到記憶體中。CPU從記憶體中讀取指令和資料,來開始真正執行程式。
連結
靜態連結
程式的連結,是把對應的不同檔案內的程式碼段,合併到一起,成為最後的可執行檔案。
在可執行檔案裡,我們可以看到,對應的函式名稱,像add、main等等,乃至你自己定義的全域性可以訪問的變數名稱對應的地址,儲存在一個叫作符號表(Symbols Table)的位置裡。符號表相當於一個地址簿,把名字和地址關聯了起來。
經過程式的連結之後,main函式裡呼叫add的跳轉地址,不再是下一條指令的地址了,而是add函式的入口地址了。
連結器會掃描所有輸入的目標檔案,然後把所有符號表裡的資訊收集起來,構成一個全域性的符號表。然後再根據重定位表,把所有不確定要跳轉地址的程式碼,根據符號表裡面儲存的地址,進行一次修正。最後,把所有的目標檔案的對應段進行一次合併,變成了最終的可執行程式碼。
這個合併程式碼段的方法,是叫靜態連結。
動態連結
在動態連結的過程中,我們想要“連結”的,不是儲存在硬碟上的目標檔案程式碼,而是載入到記憶體中的共享庫(Shared Libraries)。
要想要在程式執行的時候共享程式碼,也有一定的要求,就是這些機器碼必須是“地址無關”的。換句話說就是,這段程式碼,無論載入在哪個記憶體地址,都能夠正常執行。
動態程式碼庫內部的變數和函式呼叫都是使用相對地址。因為整個共享庫是放在一段連續的虛擬記憶體地址中的,無論裝載到哪一段地址,不同指令之間的相對地址都是不變的。
裝載程式
在執行這些可執行檔案的時候,我們其實是通過一個裝載器,解析ELF或者PE格式的可執行檔案。裝載器會把對應的指令和資料載入到記憶體裡面來,讓CPU去執行。
裝載器需要滿足兩個要求:
- 可執行程式載入後佔用的記憶體空間應該是連續的。因為CPU在執行指令的時候,程式計數器是順序地一條一條指令執行下去。
- 我們需要同時載入很多個程式,並且不能讓程式自己規定在記憶體中載入的位置。因為我們現在的計算機通常會同時執行很多個程式,可能你想要的記憶體地址已經被其他載入了的程式佔用了。
基於上面,我們需要在記憶體空間地址和整個程式指令指定的記憶體地址做一個對映。
把指令裡用到的記憶體地址叫作虛擬記憶體地址(Virtual Memory Address),實際在記憶體硬體裡面的空間地址,我們叫實體記憶體地址(Physical Memory Address)。
記憶體分頁
分頁是把整個實體記憶體空間切成一段段固定尺寸的大小。而對應的程式所需要佔用的虛擬記憶體空間,也會同樣切成一段段固定尺寸的大小。這樣一個連續並且尺寸固定的記憶體空間,我們叫頁(Page)。
從虛擬記憶體到實體記憶體的對映,不再是拿整段連續的記憶體的實體地址,而是按照一個一個頁來的。
分頁之後避免了整個程式和硬碟進行交換而產生效能瓶頸。即使記憶體空間不夠,需要讓現有的、正在執行的其他程式,通過記憶體交換釋放出一些記憶體的頁出來,一次性寫入磁碟的也只有少數的一個頁或者幾個頁,不會花太多時間,讓整個機器被記憶體交換的過程給卡住