摘要:而在函數add執行完成之後,又會分別調用第12行的pop rbp來將當前的棧頂出棧,然後調用第13行的ret指令,將程序的控制權返回到出棧後的棧頂,也就是main函數的返回地址。實際上,一個程序執行的時候,CPU會根據PC寄存器裏的地址,從內存裏面把需要執行的指令讀取到指令寄存器裏面執行,然後根據指令長度自增,開始順序讀取下一條指令。

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,通過提升“吞吐率”而不是“響應時間”,來達到目的。

但是,並不是所有問題,都可以通過並行提高性能來解決。如果想要使用這種思想,需要滿足這樣幾個條件。

  1. 需要進行的計算,本身可以分解成幾個可以並行的任務。
  2. 需要能夠分解好問題,並確保幾個人的結果能夠彙總到一起。
  3. 在“彙總”這個階段,是沒有辦法並行進行的,還是得順序執行,一步一步來。

所以並行計算涉及到了一個 阿姆達爾定律 (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去執行。

裝載器需要滿足兩個要求:

  1. 可執行程序加載後佔用的內存空間應該是連續的。因爲CPU在執行指令的時候,程序計數器是順序地一條一條指令執行下去。
  2. 我們需要同時加載很多個程序,並且不能讓程序自己規定在內存中加載的位置。因爲我們現在的計算機通常會同時運行很多個程序,可能你想要的內存地址已經被其他加載了的程序佔用了。

基於上面,我們需要在內存空間地址和整個程序指令指定的內存地址做一個映射。

把指令裏用到的內存地址叫作 虛擬內存地址 (Virtual Memory Address),實際在內存硬件裏面的空間地址,我們叫 物理內存地址 (Physical Memory Address)。

內存分頁

分頁是把整個物理內存空間切成一段段固定尺寸的大小。而對應的程序所需要佔用的虛擬內存空間,也會同樣切成一段段固定尺寸的大小。這樣一個連續並且尺寸固定的內存空間,我們叫頁(Page)。

從虛擬內存到物理內存的映射,不再是拿整段連續的內存的物理地址,而是按照一個一個頁來的。

分頁之後避免了整個程序和硬盤進行交換而產生性能瓶頸。即使內存空間不夠,需要讓現有的、正在運行的其他程序,通過內存交換釋放出一些內存的頁出來,一次性寫入磁盤的也只有少數的一個頁或者幾個頁,不會花太多時間,讓整個機器被內存交換的過程給卡住。

相關文章