計算機指令與運算基礎原理筆記

一、彙編代碼與指令

1.要讓程序在一個 Linux 操作系統上跑起來,需要把整個程序翻譯成彙編語言(ASM,Assembly Language)的程序,這個過程叫編譯(Compile)成彙編代碼。針對彙編代碼,可以再用匯編器(Assembler)翻譯成機器碼(Machine Code)。這些機器碼由“0”和“1”組成的機器語言表示。這一條條機器碼,就是一條條的計算機指令。這樣一串串的 16 進制數字,就是 CPU 能夠真正認識的計算機指令。在一個 Linux 操作系統上,可以使用 gcc 和 objdump 這樣兩條命令,把C語言文件test.c對應的彙編代碼和機器碼都打印出來:

gcc -g -c test.c
$ objdump -d -M intel -S test.o

在命令輸出中,左側有一堆數字,這些就是一條條機器碼;右邊有一系列的 push、mov、add、pop 等,這些就是對應的彙編代碼。一行 C 語言代碼,有時候只對應一條機器碼和彙編代碼,有時候則是對應兩條機器碼和彙編代碼。彙編代碼和機器碼之間是一一對應的,如下所示:

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    

彙編代碼其實就是“給程序員看的機器碼”,也正因爲如此,機器碼和彙編代碼是一一對應的。程序員很容易記住add、mov這些用英文表示的指令,而8b 45 f8這樣的指令,由於很難一下子看明白是在幹什麼,所以會非常難以記憶。

常見的指令可以分成五大類:

(1)算術類指令,如加減乘除。(2)數據傳輸類指令,如給變量賦值、在內存裏讀寫數據等。(3)邏輯類指令,如邏輯上的與或非。(4)條件分支類指令,如“if/else”。(5)無條件跳轉指令,如寫一些大一點的程序,常常需要寫一些函數或者方法。在調用函數的時候,其實就是發起了一個無條件跳轉指令。在彙編語言中這五類指令的體現如下所示:

2.爲了理解機器碼的計算方式,以CPU指令集中相對簡單的MIPS指令集爲例,MIPS的指令是一個32位整數,高6位叫操作碼(Opcode),代表這條指令具體是一條什麼樣的指令,剩下的26位有三種格式,分別是R、I和J:

(1)R指令是一般用來做算術和邏輯操作,裏面有讀取和寫入數據的寄存器的地址。如果是邏輯位移操作,後面還有位移操作的位移量,而最後的功能碼,則是在前面的操作碼不夠的時候,擴展操作碼錶示對應的具體指令。

(2)I指令通常用在數據傳輸、條件分支,以及在運算時使用的是常數的情況。這個時候,沒有了位移量和操作碼,也沒有了第三個寄存器,而是把這三部分直接合併成了一個地址值或者一個常數。

(3)J指令就是一個跳轉指令,高 6 位之外的 26 位都是一個跳轉後的地址。

例如彙編代碼add $t0, $s2, $s1,對應的MIPS指令裏opcode是0,假設rs代表第一個寄存器s1的地址是17,rt代表第二個寄存器s2的地址是18,rd代表目標的臨時寄存器t0的地址是8。因爲不是位移操作,所以位移量是0。把這些數字拼在一起,就變成了一個 MIPS 的加法指令,如下所示:

真正的機器碼是二進制,但爲了避免顯示過長的數字,因此理解例子時用十六進制來表示一條機器碼,所以該例子中0X02324020就是這條指令對應的機器碼(從右到左每4個二進制位爲1個十六進制位),對於幾十年前的打孔紙帶計算機來說,如果在對應位置用打孔代表1,沒有打孔代表0,用4行8列代表一條指令來打一個穿孔紙帶,每一列都是一個二進制表示,那麼這條指令產生的打孔紙帶是這樣的:

除了C這樣的編譯型語言之外,不管是Python這樣的解釋型語言,還是Java這樣使用虛擬機的語言,最終都是由不同形式的程序,把寫好的代碼轉換成CPU能夠理解的機器碼來執行,只不過解釋型語言是通過解釋器在程序運行的時候逐句翻譯,而Java這樣使用虛擬機的語言,則是由虛擬機對編譯出來的中間代碼進行解釋,或者即時編譯成爲機器碼來最終執行。

二、指令跳轉

3.從邏輯上看,CPU實際上由一堆寄存器組成,而寄存器就是CPU內部,由多個觸發器(Flip-Flop)或者鎖存器(Latches)組成的簡單電路,這是兩種不同原理的數字電路組成的邏輯門。N個觸發器或者鎖存器,就可以組成一個N位(Bit)的寄存器,能夠保存N位的數據。比方說64位Intel的CPU,寄存器就是64位的。

一個CPU裏面會有很多種不同功能的寄存器,比較重要的有三種:

(1)PC 寄存器(Program Counter Register),也叫指令地址寄存器(Instruction Address Register),用來存放下一條需要執行的計算機指令的內存地址。

(2)指令寄存器(Instruction Register),用來存放當前正在執行的指令。

(3)條件碼寄存器(Status Register),用裏面的一個一個標記位(Flag),存放CPU進行算術或者邏輯計算的結果。

除了這些特殊的寄存器,CPU裏面還有更多用來存儲數據和內存地址的寄存器。這樣的寄存器通常一類裏面不止一個。通常根據存放的數據內容來給它們取名,比如整數寄存器、浮點數寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放數據,又能存放地址,就叫通用寄存器。

一個程序執行的時候,CPU會根據PC寄存器裏的地址,從內存裏面把需要執行的指令讀取到指令寄存器裏面執行,然後根據指令長度自增,開始順序讀取下一條指令。一個程序的一條條指令,在內存裏面是連續保存的,也會一條條順序加載。而有些特殊指令,比如J類指令,也就是跳轉指令,會修改PC寄存器裏面的地址值。這樣,下一條要執行的指令就不是從內存裏面順序加載的了。事實上,這些跳轉指令的存在,也是在寫程序的時候,可以使用if…else條件語句和while/for循環語句的原因。

4.爲了理解J類指令的運行,用以下代碼作爲例子:

// 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;
  }
}

用rand()生成了一個隨機數r,要麼是0,要麼是1。當r是0時,變量a設成1,不然就設成2。將上面代碼編譯形成彙編代碼後,if…else的部分如下:

 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代表操作的數據類型是32位的整數,而[rbp-0x4]則是一個寄存器的地址。所以,第一個操作數就是從寄存器裏拿到的變量r的值。第二個操作數0x0就是設定的常量0的十六進制表示。cmp指令的比較結果,會存入到條件碼寄存器當中去。這裏如果比較的結果是True,也就是r == 0,就把零標誌條件碼(對應的條件碼是ZF,Zero Flag)設置爲1。除了零標誌之外,Intel的CPU下還有進位標誌(CF,Carry Flag)、符號標誌(SF,Sign Flag)以及溢出標誌(OF,Overflow Flag),用在不同的判斷條件下。

cmp指令執行完成之後,PC寄存器會自動自增,開始執行下一條jne的指令。跟着的jne指令,是jump if not equal的意思,它會查看對應的零標誌位。如果不爲0,會跳轉到後面跟着的操作數4a的位置。這個4a,對應彙編代碼的行號,也就是上面設置的else條件裏的第一條指令。當跳轉發生的時候,PC寄存器就不再是自增變成下一條指令的地址,而是被直接設置成4a這個地址。這個時候,CPU再把4a地址裏的指令加載到指令寄存器中來執行。

跳轉到執行地址爲4a的指令,實際是一條mov指令,第一個操作數和前面的cmp指令一樣,是另一個32位整型的寄存器地址,以及對應的2的十六進制值0x2。mov指令把2設置到對應的寄存器裏去,相當於一個賦值操作。然後,PC寄存器裏的值繼續自增,執行下一條mov指令。

這條mov指令的第一個操作數eax,代表累加寄存器,第二個操作數0x0則是十六進制的0的表示。這條指令其實沒有實際的作用,它的作用是一個佔位符。看前面的 if 條件,如果滿足的話,在賦值的mov指令執行完成之後,有一個jmp的無條件跳轉指令。跳轉的地址就是這一行的地址51。main 函數沒有設定返回值,而mov eax, 0x0其實就是給main函數生成了一個默認的爲0的返回值到累加器裏面。if條件裏面的內容執行完成之後也會跳轉到這裏,和else裏的內容結束之後的位置是一樣的。

5.對於循環的實現,有如下的代碼例子:

int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}

對應的彙編代碼如下:

for (int 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 (int 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>
  24:   b8 00 00 00 00          mov    eax,0x0
    }

可以看到,對應的循環也是用1e這個地址上的cmp比較指令,和緊接着的jle條件跳轉指令來實現的。主要的差別在於,這裏jle跳轉的地址,在這條指令之前的地址14,而非if…else編譯出來的跳轉指令之後。往前跳轉使得條件滿足的時候,PC寄存器會把指令地址設置到之前執行過的指令位置,重新執行之前執行過的指令,直到條件不滿足,順序往下執行jle之後的指令,整個循環才結束。

其中jle和jmp指令,有點像程序語言裏面的goto命令,直接指定了一個特定條件下的跳轉位置。雖然在用高級語言開發程序的時候反對使用goto,但是實際在機器指令層面,無論是 if…else也好還是for/while,都是用和goto相同的跳轉到特定指令位置的方式來實現的。因此,想要在硬件層面實現這個goto語句,除了需要用來保存下一條指令地址,以及當前正要執行指令的PC寄存器、指令寄存器外,只需要再增加一個條件碼寄存器,來保留條件判斷的狀態。這樣三個寄存器,就可以實現條件判斷和循環重複執行代碼的功能

三、棧

6.可以通過以下代碼例子解釋,爲什麼程序要有“棧”這個概念:

#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    

在這段代碼裏,main函數主要是把 jump指令換成了函數調用的call指令。call指令後面跟着的是跳轉後的程序地址0。add函數編譯之後,代碼先執行了一條push指令和一條mov指令;在函數執行結束的時候,又執行了一條pop和一條ret指令。這四條指令的執行,其實就是在進行壓棧(Push)和出棧(Pop)操作。

函數調用和if…else和for/while循環有點像。它們都是在原來順序執行的指令過程裏,執行了一個內存地址的跳轉指令,讓指令從原來順序執行的過程裏跳開,從新的跳轉後的位置開始執行。 但是,這兩個跳轉有個區別,ifelse for/while的跳轉,是跳轉走了就不再回來了,就在跳轉後的新地址開始順序地執行指令,函數調用的跳轉是在對應函數的指令執行完了之後,還要再回到函數調用的地方,繼續執行call之後的指令

那是否有一個可以不跳轉回到函數定義的方法,實現函數的調用呢?直覺上似乎可以把調用的函數指令,直接插入在調用函數的地方,替換掉對應的 call 指令,然後在編譯器編譯代碼的時候,直接就把函數調用變成對應的指令替換掉。但是這個方法有漏洞,如果函數A調用了函數B,然後函數B再調用函數A,就得面臨在A裏面插入B的指令,然後在B裏面插入A的指令,這樣就會產生無窮無盡地替換。就像兩面鏡子面對面放在一起,任何一面鏡子裏面都會看到無窮多面鏡子。

因此,被調用函數的指令直接插入在調用處的方法行不通。那是否可以把後面要跳回來執行的指令地址給記錄下來呢?就像PC寄存器一樣,可以專門設立一個“程序調用寄存器”,來存儲接下來要跳轉回來執行的指令地址。等到函數調用結束,從這個寄存器裏取出地址,再跳轉到這個記錄的地址,繼續執行就好了。但是在多層函數調用裏,簡單隻記錄一個地址也是不夠的。在調用函數A之後,A還可以調用函數B,B還能調用函數C。這一層層的調用是無限的。在所有函數調用返回之前,每一次調用的返回地址都要記錄下來,但是CPU裏的寄存器數量並不多,例如Intel i7只有16個64位寄存器,調用的層數一多就存不下了。

最終,科學家們想到了一個比單獨記錄跳轉回來的地址更完善的辦法,即在內存裏面開闢一段空間,用棧這個後進先出(LIFOLast In First Out)的數據結構。棧就像一個球桶,每次程序調用函數之前,我們都把調用返回後的地址寫在一個球上,然後塞進這個球桶。這個操作其實就是壓棧。如果函數執行完了,就從球桶裏取出最上面的那個球,就是出棧。

拿到出棧的球,找到上面的地址,把程序跳轉過去,就返回到了函數調用後的下一條指令了。如果函數A在執行完成之前又調用了函數B,在取出球之前,需要往球桶裏塞一個球。而從球桶最上面拿球的時候,拿的也一定是最近一次的,也就是最下面一層的函數調用完成後的地址。球桶的底部,就是棧底,最上面的球所在的位置,就是棧頂,如下所示:

在真實的程序裏,壓棧的不只有函數調用完成後的返回地址。比如函數A在調用B時,需要傳輸一些參數數據,這些參數數據在寄存器不夠用的時候也會被壓入棧中。整個函數A所佔用的所有內存空間,就是函數A的棧幀(Stack Frame)。而實際的程序棧佈局,頂和底與球桶相比是倒過來的。棧底在最上面,棧頂在最下面,這樣的佈局是因爲棧底的內存地址是在一開始就固定的。而一層層壓棧之後,棧頂的內存地址是在逐漸變小而不是變大。

對應上面代碼中add的彙編代碼,main函數調用add函數時,add函數入口在 0~1 行,add函數結束之後在12~13行。調用第34行的call指令時,會把當前的PC寄存器裏的下一條指令的地址壓棧(例如函數A中執行到一半要調用B(),則將B()的棧幀壓入棧頂,該棧幀中保存A()中本來B()調用完後要執行的下一個指令地址,PC寄存器中A()的下一條指令修改爲B()的第一條指令),保留被調用函數結束後要執行的指令地址

代碼例子中add函數的第0行,push rbp這個指令就是在進行壓棧。這裏的rbp又叫棧幀指針(Frame Pointer),是一個存放了當前棧幀位置的寄存器。push rbp就把之前調用函數,也就是main函數的棧幀的棧底地址,壓到棧頂。接着,第1行的命令mov rbp, rsp裏,則是把rsp這個棧指針(Stack Pointer)的值複製到rbp裏,而rsp始終會指向棧頂。這個命令意味着,rbp這個棧幀指針指向的地址,變成當前最新的棧頂,也就是add函數的棧幀的棧底地址了。

而在函數add執行完成之後,又會分別調用第12行的pop rbp來將當前的棧頂出棧,這部分操作維護好了整個棧幀。然後,可以調用第13行的ret指令,同時要把call調用的時候壓入PC寄存器裏的原函數下一條指令出棧,寫回到PC寄存器中(調用跳轉其他函數時,PC寄存器中存放的下一條要執行的指令被修改過,從原函數中下一條指令變成被調用函數的地址),將程序的控制權返回到出棧後的棧頂

通過引入棧,可以看到無論有多少層的函數調用,或者在函數A裏調用函數B,再在函數B裏調用A這樣的遞歸調用,都只需要通過維持rbp和rsp這兩個維護棧頂所在地址的寄存器,就能管理好不同函數之間的跳轉,相當於在指令跳轉的過程中,加入了一個“記憶”的功能,能在跳轉去運行新的指令之後,再回到跳出去的位置,實現更加豐富和靈活的指令執行流程。這樣還提供了“函數”這樣一個抽象,使得在軟件開發的過程中,可以複用代碼和指令,而不是隻能簡單粗暴地複製、粘貼代碼。

但棧的大小也是有限的。如果函數調用層數太多(如太多層遞歸、棧內創建佔內存的巨大數組變量等),往棧裏壓入它存不下的內容,程序在執行的過程中就會遇到棧溢出的錯誤,這就是“stack overflow”。

四、鏈接與內存裝載

7. 函數調用需要維護棧與地址跳轉,會產生一定開銷,如果可以把被調用函數中的指令,直接插入到原函數中的位置,替換對應的函數調用指令,使指令按順序地址執行下去,會提高性能。如果被調用的函數裏,沒有調用其他函數(這種函數叫葉子函數),這個方法是可行的。事實上,這就是一個常見的編譯器進行自動優化的場景,叫函數內聯(Inline),只要在 GCC 編譯的時候,加上對應的一個讓編譯器自動優化的參數 -O,編譯器就會在可行的情況下,進行這樣的指令替換,代碼例子如下所示:

#include <stdio.h>
#include <time.h>
#include <stdlib.h>

int static add(int a, int b)
{
    return a+b;
}

int main()
{
    srand(time(NULL));
    int x = rand() % 5
    int y = rand() % 10;
    int u = add(x, y)
    printf("u = %d\n", u)
}

編譯出來的彙編代碼,沒有把add函數單獨編譯成之前例子那樣的一段指令序列,而是在調用 u = add(x, y) 的時候,直接替換成了一個 add 指令,如下所示:

gcc -g -c -O function_example_inline.c
$ objdump -d -M intel -S function_example_inline.o
    ……
return a+b;
  4c:   01 de                   add    esi,ebx

除了依靠編譯器的自動優化,還可以在定義函數的地方,加上inline關鍵字,來提示編譯器對函數進行內聯。內聯帶來的優化是,CPU需要執行的指令數變少了,根據地址跳轉的過程不需要了,壓棧和出棧的過程也不用了。不過內聯並不是沒有代價,內聯意味着,把可以複用的程序指令在調用它的地方完全展開了。如果一個函數在很多地方都被調用了,那麼就會展開很多次,整個程序佔用的空間就會變大了

8. C語言代碼→編譯爲彙編代碼→彙編成機器碼的過程,計算機上進行的時候是由兩部分組成的:

(1)第一部分由編譯(Compile)、彙編(Assemble)以及鏈接(Link,將多個代碼的目標文件與函數庫連接起來,生成可執行文件,多個代碼文件編譯的目標文件不鏈接會無法執行)三個階段組成。在這三個階段完成之後,就生成了一個可執行文件。

(2)第二部分通過裝載器(Loader)把可執行文件裝載(Load)到內存中。CPU從內存中讀取指令和數據,來開始真正執行程序。

如果將第6點中的例子代碼中add()函數與main()函數分別放在2個文件裏,分別編譯成2個目標文件(Object File)後再鏈接成1個可執行文件(Executable Program),使用objdump命令會得到比第6點中長得多的反彙編結果,如下所示:

link_example:     file format elf64-x86-64
Disassembly of section .init:
...
Disassembly of section .plt:
...
Disassembly of section .plt.got:
...
Disassembly of section .text:
...

 6b0:   55                      push   rbp
 6b1:   48 89 e5                mov    rbp,rsp
 6b4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
 6b7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
 6ba:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
 6bd:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
 6c0:   01 d0                   add    eax,edx
 6c2:   5d                      pop    rbp
 6c3:   c3                      ret    
00000000000006c4 <main>:
 6c4:   55                      push   rbp
 6c5:   48 89 e5                mov    rbp,rsp
 6c8:   48 83 ec 10             sub    rsp,0x10
 6cc:   c7 45 fc 0a 00 00 00    mov    DWORD PTR [rbp-0x4],0xa
 6d3:   c7 45 f8 05 00 00 00    mov    DWORD PTR [rbp-0x8],0x5
 6da:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
 6dd:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
 6e0:   89 d6                   mov    esi,edx
 6e2:   89 c7                   mov    edi,eax
 6e4:   b8 00 00 00 00          mov    eax,0x0
 6e9:   e8 c2 ff ff ff          call   6b0 <add>
 6ee:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax
 6f1:   8b 45 f4                mov    eax,DWORD PTR [rbp-0xc]
 6f4:   89 c6                   mov    esi,eax
 6f6:   48 8d 3d 97 00 00 00    lea    rdi,[rip+0x97]        # 794 <_IO_stdin_used+0x4>
 6fd:   b8 00 00 00 00          mov    eax,0x0
 702:   e8 59 fe ff ff          call   560 <printf@plt>
 707:   b8 00 00 00 00          mov    eax,0x0
 70c:   c9                      leave  
 70d:   c3                      ret    
 70e:   66 90                   xchg   ax,ax
...
Disassembly of section .fini:
...

長了很多的原因是在Linux下,可執行文件和目標文件所使用的都是一種叫ELF(Execuatable and Linkable File Format)的文件格式,中文名叫可執行與可鏈接文件格式,這裏面不僅存放了編譯成的彙編指令,還保留了很多別的數據。 比如對應的函數名稱如add、main等,乃至自定義的全局變量名稱,都存放在這個ELF格式文件裏。這些名字和它們對應的地址,在ELF文件裏面,存儲在一個叫作符號表(Symbols Table)的位置裏。符號表相當於一個地址簿,把名字和地址關聯了起來,如下所示:

鏈接器會掃描所有輸入的目標文件,然後把各目標文件中符號表裏的信息收集起來,構成一個全局的符號表。然後再根據重定位表,把所有不確定要跳轉地址的代碼,根據符號表裏面存儲的地址,進行一次修正。最後,把所有的目標文件的對應段進行一次合併,變成了最終的可執行代碼。這樣,可執行文件裏面的函數調用地址都是正確的。

在鏈接器把程序變成可執行文件之後,要裝載器去執行程序就容易多了。裝載器不再需要考慮地址跳轉的問題,只需要解析ELF文件,把對應的指令和數據,加載到內存裏面供CPU執行就可以了。因此,同樣一個程序在Linux 下可以執行而在Windows下不能執行了,其中一個重要原因就是兩個操作系統下可執行文件的格式不一樣。Linux 下是ELF文件格式,而Windows的可執行文件格式是PE(Portable Executable Format)。Linux下的Loader只能解析ELF格式而不能解析PE格式。

如果有一個可以能夠解析PE格式的裝載器,就有可能在Linux下運行Windows程序了,所以Linux下著名的開源項目Wine,就是通過兼容PE格式的裝載器,使得能直接在Linux下運行Windows程序。而現在Windows也提供了WSL,也就是Windows Subsystem for Linux,可以解析和加載ELF格式的文件。

綜上可知平時寫的程序,也不僅僅是把所有代碼放在一個文件裏編譯執行,而是可以拆分成不同的函數庫,最後通過一個靜態鏈接的機制,使得不同的文件之間既有分工,又能通過靜態鏈接來“合作”,變成一個可執行的程序。對於ELF格式的文件,爲了能夠實現這樣一個靜態鏈接的機制,裏面不只是簡單羅列了程序所需要執行的指令,還會包括鏈接所需要的重定位表和符號表。

9. 鏈接器把多個目標文件合併成一個最終可執行文件,在運行這個可執行文件的時候,其實是通過一個裝載器,解析ELF或者PE格式的可執行文件。Loader會把對應的指令和數據加載到內存裏讓CPU去執行,但具體並非直接加載進去那麼簡單,實際上裝載器需要滿足兩個要求

(1)可執行程序加載後佔用的內存空間應該是連續的。執行指令的時候,程序計數器是順序地一條一條指令執行下去。這也就意味着,這一條條指令需要連續地存儲在一起。

(2)內存中需要同時加載很多個程序,並且不能讓程序自己規定在內存中加載的位置。雖然編譯出來的指令裏已經有了對應的各種各樣的內存地址,但是實際加載的時候,其實沒有辦法確保這個程序一定加載在哪一段內存地址上,因爲計算機會同時運行很多個程序,想要的內存地址可能已經被其他加載的程序佔用了

要滿足這兩個基本的要求,我們很容易想到一個辦法。那就是可以在內存裏面,找到一段連續的內存空間,然後分配給裝載的程序,然後把這段連續的內存空間地址,和整個程序指令裏指定的內存地址做一個映射。彙編指令裏用到的內存地址叫作虛擬內存地址(Virtual Memory Address),實際在內存硬件裏面的空間地址,叫物理內存地址(Physical Memory Address)。對於程序只需要關心虛擬內存地址就行了。所以只需要維護一個虛擬內存到物理內存的映射表,這樣實際程序指令執行的時候,會通過虛擬內存地址,找到對應的物理內存地址,然後執行。因爲是連續的內存地址空間,所以只需要維護映射關係的起始地址和對應的空間大小就可以了

這種找出一段連續的物理內存和虛擬內存地址進行映射的方法叫分段(Segmentation)。這裏的段,就是指系統分配出來的那個連續內存空間,如下所示:

分段解決了程序本身不需要關心具體的物理內存地址的問題,但它也有不足之處,第一個就是內存碎片(Memory Fragmentation)問題。例如電腦有1GB內存,先啓動一個圖形渲染程序佔用512MB的內存,接着啓動一個Chrome佔用了128MB內存,再啓動一個Python程序佔用256MB內存。此時關掉Chrome,於是空閒內存還有1024 - 512 - 256 = 256MB。按理來說有足夠的空間再去裝載一個200MB的程序,但是這256MB的內存空間不是連續的,而是被分成了兩段128MB的內存。因此實際情況是該程序沒辦法加載進來。

解決的辦法叫內存交換(Memory Swapping)。可以把Python程序佔用的256MB內存寫到硬盤上,然後再從硬盤上讀回來到內存裏面。不過讀回來的時候,不再把它加載到原來的位置,而是緊緊跟在那已經被佔用了的512MB內存後面。這樣就有了連續的256MB內存空間,就可以去加載一個新的200MB的程序。安裝Linux操作系統時會遇到分配一個swap硬盤分區的選項。這塊分出來的磁盤空間,其實就是專門給Linux操作系統進行內存交換用的

虛擬內存、分段,再加上內存交換,看起來似乎已經解決了計算機同時裝載運行很多個程序的問題。不過這三者的組合仍然會遇到一個性能瓶頸。硬盤的訪問速度要比內存慢很多,而每一次內存交換,都需要把一大段連續的內存數據寫到硬盤上。所以,如果內存交換的時候,交換的是一個很佔內存空間的程序,這樣整個機器都會顯得卡頓

10.上述問題出在內存碎片和內存交換的空間太大上,那麼解決問題的辦法就是少出現一些內存碎片。另外,當需要進行內存交換的時候,讓需要交換寫入或者從磁盤裝載的數據更少一點,就可以解決這個問題。這個辦法,在計算機內存管理中叫作內存分頁(Paging)。

和分段這樣分配一整段連續空間給程序相比,分頁是把整個物理內存空間切成一段段固定尺寸的大小。而對應的程序所需要佔用的虛擬內存空間,也會同樣切成一段段固定尺寸的大小。這樣一個連續並且尺寸固定的內存空間叫頁(Page)。從虛擬內存到物理內存的映射,不再是拿整段連續的內存的物理地址,而是按照一個一個頁來。頁的尺寸一般遠遠小於整個程序的大小。在 Linux 下通常爲4KB,可以通過以下命令查看Linux系統設置的頁大小:

getconf PAGE_SIZE

由於內存空間都已預先劃分爲一個個page,也就沒有了不能使用的一大段內存碎片。即使內存空間不夠,需要讓正在運行的其他程序通過內存交換釋放出一些內存的頁出來,一次性寫入磁盤的也只有少數的一個頁或者幾個頁,不會花太多時間讓機器被內存交換過程給卡住。

分頁的方式使得在加載程序時,不再需要一次性把整個程序加載到物理內存中,完全可以在進行虛擬內存和物理內存頁之間的映射之後,並不真的把頁加載到物理內存裏,而是隻在程序運行中,需要用到對應虛擬內存頁裏面的指令和數據時,再加載到物理內存裏面去。實際上操作系統的確是這麼做的。當要讀取特定的頁,卻發現數據並沒有加載到物理內存裏的時候,就會觸發一個來自於CPU的缺頁錯誤(Page Fault),操作系統會捕捉到這個錯誤,然後將對應的頁從存放在硬盤上的虛擬內存裏讀取出來,加載到物理內存裏。這種方式,使得可以運行那些遠大於電腦實際物理內存的程序。

同時,這樣任何程序都不需要一次性加載完所有指令和數據,只需要加載當前需要用到就行了。通過虛擬內存、內存交換和內存分頁這三個技術的組合,最終得到了一個讓程序不需要考慮實際的物理內存地址、大小和當前分配空間的解決方案。這些技術和方法,對於程序的編寫、編譯和鏈接過程都是透明的。通過引入虛擬內存、頁映射和內存交換,程序本身就不再需要考慮對應的真實內存地址、程序加載、內存管理等問題了。任何一個程序,都只需要把內存當成是一塊完整而連續的空間來直接使用

11.程序的鏈接,是把對應不同文件內的代碼段合併到一起,成爲最後的可執行文件。這個鏈接的方式,在寫代碼的時候做到了“複用”。同樣的功能代碼只要寫一次,然後提供給很多不同的程序進行鏈接就行了。但是,如果有很多個程序都要通過裝載器裝載到內存裏面,那各程序鏈接好的相同功能代碼,也都需要再裝載一遍,再佔一遍內存空間,造成浪費,如下所示:

如果能夠讓同樣功能的代碼,在與不同的N個程序鏈接時,不需要佔N份內存空間,就能減少內存空間浪費,這個思路就引入一種新的鏈接方法,叫作動態鏈接(Dynamic Link)。相應的之前的合併多份代碼段的方法,就是靜態鏈接(Static Link)。在動態鏈接的過程中,想要“鏈接”的不是存儲在硬盤上的目標文件代碼,而是加載到內存中的共享庫(Shared Libraries)。

這個加載到內存中的共享庫會被很多個程序的指令調用到。在Windows下,共享庫文件就是.dll文件,也就是 Dynamic-Link Libary(DLL,動態鏈接庫)。在Linux下,共享庫文件就是.so文件,也就是Shared Object(也稱之爲動態鏈接庫)。這兩大操作系統下的文件名後綴,一個用了“動態鏈接”的意思,另一個用了“共享”的意思,表明了這樣的思路。

要想要在程序運行的時候共享代碼,也有一定的要求,就是這些機器碼必須是“地址無關”的。也就是說,編譯出來的共享庫文件的指令代碼,是地址無關碼(Position-Independent Code)。也就是這段代碼,無論加載在哪個內存地址,都能夠動態鏈接自己寫的程序並正常執行(如加法和打印)。如果不是這樣的代碼,就是地址相關的代碼(如絕對地址代碼、利用重定位表的代碼)。其中重定位表在程序鏈接的時候,就把函數調用後要跳轉訪問的地址確定下來了,這意味着,如果這個函數加載到一個不同的內存地址,跳轉就會失敗

對於所有動態鏈接共享庫的程序來講,雖然共享庫用的都是同一段物理內存地址,但是在不同的應用程序裏,它所在的虛擬內存地址是不同的,如下所示:

動態代碼庫內部的變量和函數調用只需要使用相對地址(Relative Address)就可以保證地址無關。各種指令中使用到的內存地址,給出的不是一個絕對的地址空間,而是一個相對於當前指令偏移量的內存地址。因爲整個共享庫是放在一段連續的虛擬內存地址中的,無論裝載到哪一段地址,不同指令之間的相對地址都是不變的

12.以下的代碼例子實現了動態鏈接共享庫,首先是頭文件:

// lib.h
#ifndef LIB_H
#define LIB_H

void show_me_the_money(int money);

#endif
下面的lib.c包含了頭文件的實際實現:
// lib.c
#include <stdio.h>


void show_me_the_money(int money)
{
    printf("Show me USD %d from lib.c \n", money);
}

最後一個包含main函數的文件調用了lib.h裏的函數:

// show_me_poor.c
#include "lib.h"
int main()
{
    int money = 5;
    show_me_the_money(money);
}

最後在Linux系統上將show_me_poor.c編譯爲一個動態鏈接庫,即.so文件,可以看到,在編譯的過程中指定了一個-fPIC的參數。這個參數其實就是Position Independent Code的意思,也就是要把這個編譯成一個地址無關代碼。然後,再通過gcc編譯show_me_poor動態鏈接了lib.so的可執行文件:

gcc lib.c -fPIC -shared -o lib.so
$ gcc -o show_me_poor show_me_poor.c ./lib.so

在這些操作都完成了之後,把show_me_poor這個文件通過objdump反編譯出來看一下:

objdump -d -M intel -S show_me_poor
……
0000000000400540 <show_me_the_money@plt-0x10>:
  400540:       ff 35 12 05 20 00       push   QWORD PTR [rip+0x200512]        # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
  400546:       ff 25 14 05 20 00       jmp    QWORD PTR [rip+0x200514]        # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
  40054c:       0f 1f 40 00             nop    DWORD PTR [rax+0x0]

0000000000400550 <show_me_the_money@plt>:
  400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
  400556:       68 00 00 00 00          push   0x0
  40055b:       e9 e0 ff ff ff          jmp    400540 <_init+0x28>
……
0000000000400676 <main>:
  400676:       55                      push   rbp
  400677:       48 89 e5                mov    rbp,rsp
  40067a:       48 83 ec 10             sub    rsp,0x10
  40067e:       c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
  400685:       8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  400688:       89 c7                   mov    edi,eax
  40068a:       e8 c1 fe ff ff          call   400550 <show_me_the_money@plt>
  40068f:       c9                      leave  
  400690:       c3                      ret    
  400691:       66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
  400698:       00 00 00 
  40069b:       0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]
……

可以看到,main()調用show_me_the_money()時,對應彙編代碼爲call  400550 <show_me_the_money@plt>,這裏後面有一個@plt的關鍵字,代表了需要從PLT,也就是程序鏈接表(Procedure Link Table)裏面找要調用的函數(plt裏面實際上是存放了GOT[i]的地址,而GOT[i]中存放了要調用函數在虛擬內存中的地址,而該地址實際上是共享函數代碼段的真實物理地址的一個映射),對應的地址則是400550。可以看到400550那行地址裏面進行了一次跳轉,這個跳轉指定的跳轉地址在後面的註釋裏面可以看到,GLOBAL_OFFSET_TABLE+0x18。這裏的GLOBAL_OFFSET_TABLE,就是全局偏移表

在動態鏈接對應的共享庫中,data section裏面,保存了一張全局偏移表(GOT,Global Offset Table)。雖然共享庫的代碼部分的物理內存是共享的,但是數據部分是各個動態鏈接它的程序裏面各加載一份的。所有需要引用當前共享庫外部地址的指令,都會查詢GOT,來找到當前運行程序的虛擬內存裏的對應位置。

而GOT表裏的數據,則是在加載一個個共享庫的時候寫進去的。不同的進程,調用同樣的lib.so,各自GOT裏面指向最終加載的動態鏈接庫裏面的虛擬內存地址是不同的。 這樣,雖然不同的程序調用的同樣的動態庫,各自的內存地址是獨立的,也不需要去修改動態庫裏面的代碼所使用的地址,而是各個程序各自維護好自己的GOT,能夠找到對應的動態庫就好了,如下所示:

GOT表位於共享庫自己的data section裏。GOT表在內存裏和對應的代碼段位置之間的偏移量,始終是確定的。這樣,共享庫就是地址無關的代碼,對應的各個程序只需要在物理內存裏面加載這同一份代碼。同時,也要通過各可執行文件在load進內存時,生成的各不相同的GOT表,來找到該程序需要調用到的共享庫外部變量和函數的地址。這是一個典型的不修改代碼,而是通過修改“地址數據”來進行關聯的辦法,有點像C語言裏面用函數指針來調用對應的函數,並不是通過預先已經確定好的函數名稱來調用,而是利用當時它在內存裏面的動態地址來調用

在進行Linux下的程序開發時,一直會用到各種各樣的動態鏈接庫。C語言的標準庫就在1MB以上,撰寫任何一個程序可能都需要用到這個庫,常見的Linux服務器裏,/usr/bin下有上千個可執行文件,如果每一個都把標準庫靜態鏈接進程序,幾GB乃至幾十GB的磁盤空間一下子就用掉了。如果服務端的多進程應用要開上千個進程,幾GB 的內存空間也會一下子就用掉了。動態鏈接解決了靜態鏈接浪費虛擬與物理內存空間的問題

五、二進制編碼

13. 程序 = 算法 + 數據結構。如果對應到組成原理或者說硬件層面,算法就是各種計算機指令,數據結構就對應二進制數據。在早期,字符串(Character String)只需要使用英文字符,加上數字和一些特殊符號,然後用8位的二進制,就能表示日常需要的所有字符了,這就是常說ASCII(American Standard Code for Information Interchange,美國信息交換標準代碼)。

ASCII碼好比一個字典,用8位二進制中的128個不同的數,映射到128個不同的字符裏。比如,a在ASCII裏就是第97個,也就是二進制的0110 0001,對應的十六進制表示就是61。而A就是第65個,也就是二進制的0100 0001,對應的十六進制表示就是41。在ASCII碼裏數字9不再像整數表示法裏一樣,用0000 1001來表示,而是用0011 1001來表示。字符串15也不是用0000 1111這8位來表示,而是變成兩個字符1和5連續放在一起,也就是0011 0001和0011 0101,需要用兩個8位來表示。

因此,最大的32位整數即2147483647,如果用整數表示法,只需要32位就能表示了。但是如果用字符串來表示,一共有10個字符,每個字符用8位的話,需要整整80位。比起整數表示法,要多佔很多空間。這也是爲什麼,很多時候在存儲數據的時候,要採用二進制序列化方式,而不是簡單地把數據通過CSV或者JSON這樣的文本格式存儲來進行序列化。不管是整數也好,浮點數也好,採用二進制序列化會比存儲文本省下不少空間

ASCII碼只表示了128個字符,早期還算夠用,然而隨着越來越多的不同國家的人都用上了計算機,想要表示中文這樣的文字,128個字符是不夠的。於是,計算機工程師們給自己國家的語言創建了對應的字符集(Charset)和字符編碼(Character Encoding)。字符集表示的是字符的一個集合,比如“中文”就是一個字符集,再比如 Unicode其實就是一個字符集,包含了150種語言的14萬個不同字符。字符編碼則是對於字符集裏的這些字符,如何用二進制表示出來的一個字典。Unicode就可以用UTF-8、UTF-16,乃至UTF-32來進行編碼,存儲成二進制,如下所示:

同樣的文本,採用不同的編碼存儲下來。如果另一個程序用一種不同的編碼方式來進行解碼和展示,就會出現亂碼。這就好比用GB2312,去解密別人用 UTF-8 加密的信息,自然無法讀出有用的信息。

六、加減法

14.計算機只有加法器,沒有減法器,因爲當初減法器的硬件實現性能開銷較大,最終選擇了用加法器來作減法。正數相加與負數單獨相加可以用原碼錶示,但是正數與負數相加時原碼的表示就有問題了。例如,對於負數來說,可以把一個數最左側的一位,當成是對應的正負號,0爲正數,1爲負數。這樣,一個4位的二進制數,0011就表示爲+3。而1011最左側的第一位是1,假如它表示-3,那這樣的原碼錶示法有一個很直觀的缺點:0可以用兩個不同的編碼來表示,即1000代表-0,0000代表+0,這樣就不能完全做到二進制和十進制的一一對應,而且還會出現0001 + 1001 = 10101+-1= -2等相反數相加不爲0的錯誤情況

爲了解決這種錯誤情況,就出現了反碼。正數的反碼依然是本身原碼,而負數例如-3的原碼是1011,符號位保持不變,低三位011按位取反得100,所以-3的反碼爲1100。反碼解決了原碼的相反數相加時不爲0的情況,如0001 + 1110 = 1111即1+(-1)= -0,但是反碼會出現兩個負數相加結果不正確的問題,例如1110 + 1100 = 1010即-1 + (-3) = -5。

因此,爲了解決上述兩個問題,出現了補碼。大部分書上負數補碼的定義都是其正數的原碼所有位取反變爲反碼再加1,但其實算補碼之前不一定必須先求正數的反碼負數的補碼也等於它的原碼自低位向高位,尾部的第一個‘1’及其右邊的‘0’保持不變,左邊的各位按位取反,符號位不變爲1。所以,負數補碼只是正好等於正數反碼加1而已,並非定義上計算就是這樣的。

在補碼中,依然通過最左側第一位的0和1來判斷數的正負,但是不再把這一位當成獨立的符號位不加入運算中,而是在計算整個二進制值的時候,在最左側的符號位也加入運算。比如一個4位的二進制補碼數值1011轉換成十進制,就是−1×2^3+0×2^2+1×2^1+1×2^0 = −5。如果左側最高位是1,這個數必然是負數;最高位是0,必然是正數。並且,只有0000表示0,1000在這樣的情況下表示-8。一個4位的二進制數,可以表示從-8到7這16個整數,不會白白浪費一位。更重要的一點是,用補碼來表示負數,使得整數相加變得很容易,不需要做任何電路改造等特殊處理,只是把它當成普通的二進制相加,就能得到正確的結果。因此反碼、補碼的產生過程,就是爲了解決計算機做減法和引入符號位(正號和負號)的問題

那麼爲什麼使用補碼和加法器,就能做減法呢?這就像是時鐘,10點鐘減2小時爲8點鐘,但是10點鐘加上10小時也爲8點鐘(下午),只不過時針多走了一輪(就像產生進位),減數2和加數10加起來正好是鐘上的12小時,那麼2和10在時鐘這個場景互爲同餘數,12爲。其實減去一個數,對於數值有限制,有溢出的運算(模運算)來說,也相當於加上這個減數的同餘數。例如希望0110減去0010即6 – 2 = 4,只有加法器可以用,而4位二進制數的模即最大容量爲16,表示爲10000(溢出了1位),那麼減數2的同餘數,在這裏就是10000-0010=1110即14。因此這兩種計算等同:

0110(6)- 0010(2)= 0110(6)+ 1110(14) = 10100(20=16+4)

因爲是4位二進制數,所以最高位的1溢出,剩下0100就是4了!6+14在這裏等同於6-2,因爲14的二進制1110就是-2補碼的二進制表示。補碼運算的原理借鑑了生活中的模運算

七、門電路、加法器、乘法器

15.CPU中的各種邏輯功能,其實就靠下面這幾種邏輯門組成:

其中異或門就是一個最簡單的整數加法,所需要使用的基本門電路,因爲在二進制加法中,需要計算的兩位是00和11的情況下,加法結果都應該是0;在輸入的兩位是10和01的情況下,輸出都是1。當相加的兩位都是1時,還需要向更左側的一位進行進位。這就對應一個與門。所以,通過一個異或門計算出個位,通過一個與門計算出是否進位,就通過電路算出了一個一位數的加法。於是,把兩個門電路結合,就變成半加器(Half Adder),如下所示:

半加器可以解決最低位上的加法問題,但是如果左側有第二位就不夠用了。平常十進制豎式加法結果從右到左位個位、十位和百位等,二進制運算如果寫成豎式,右往左數第二列就是“二位”,對應的再往左就分別是四位、八位。 在二位除了一個加數和被加數之外,還需要加上來自最低位的進位信號,一共需要三個數進行相加,才能得到結果。但是半加器輸入都只能是兩個bit,也就是兩個電路通斷開關,因此多位加法只用半加器還不夠。爲了進行多位加法,可以用兩個半加器和一個或門,組合成一個全加器,如下所示:

第一個半加器用於最低位加法,得到是否進位X和最低位二個數相加後的結果Y這2個輸出。然後,把最低位相加後的結果Y,和最低位相加後輸出的進位信息U,再連接到一個半加器上,就會得到一個是否進位的信號V和第二位相加後的結果W。這個W就是在第二位上留下的結果。把兩個半加器的進位輸出,作爲一個或門的輸入連接起來,只要兩次加法中任何一次需要進位,那麼在二位上,就會向左側的四位進一位。因爲一共只有三個bit相加,即使3個bit都是1,也最多會進一位。

有了全加器,要進行對應的兩個8 bit數的加法就很容易了,只要把8個全加器串聯起來就好了。個位全加器的進位信號作爲二位全加器的輸入信號,二位全加器的進位信號再作爲四位全加器的進位信號,這樣一層層串接八層,就得到了一個支持8位數加法的算術單元,如下所示:

唯一需要注意的是,這樣的全加器在最低位只需要用一個半加器,或者讓全加器的進位輸入始終是0,因爲最低位沒有來自更右側的進位。而最左側的一位輸出的進位信號,表示的並不是再進一位,而是表示加法是否溢出了。因此,在整個加法器的結果中,其實有一個電路的信號,會標識出加法的結果是否溢出。可以把這個對應的信號輸出到硬件中其他標誌位裏,讓計算機知道計算的結果是否溢出。這就是爲什麼在撰寫程序的時候,能夠知道計算結果是否溢出在硬件層面得到的支持。

16.上面的門電路組成了半加器,半加器組成了全加器,全加器最終組成了加法器以及算術邏輯單元ALU,這其實就是計算機中,無論軟件還是硬件中一個很重要的設計思想,分層

從簡單到複雜,這樣一層層搭出了擁有更強能力的功能組件。在下面的一層,只需要考慮怎麼用上一層的組件搭建出自己的功能,而不需要考慮跨層的其他組件。就像之前並沒有深入學習過計算機組成原理,一樣可以直接通過高級語言撰寫代碼,實現功能。當進一步打造強大的 CPU 時,就不會再去關注最細顆粒的門電路,只需要把門電路組合而成的ALU,當成一個能夠完成基礎計算的黑盒就可以了。

17.在計算機的乘法實現中,例如十進制中13乘以9,計算的結果是117,通過轉換成二進制,然後列豎式的辦法,在計算機看來整個計算過程實際上如下:

二進制的乘法有個很大的優點,就是這個過程與九九乘法口訣表無關。因爲單個位置上,乘數只能是0或者1,所以實際的乘法就退化成了位移和加法。在13×9這個例子裏,被乘數13的二進制是1101,乘數9的二進制是1001。1001最右邊的個位是1,所以個位乘以被乘數1101,就是把1101複製下來。因爲1001的二位和四位都是0,所以乘以被乘數都是0,那麼保留下來的都是0000。乘數9的八位是1,所以仍然需要把被乘數1101複製下來。不過這裏和個位位置的單純複製有一點差別,那就是要把複製的1101向左側移三位,最後把四位單獨進行乘法與位移的結果進行求和,就得到了最終結果。

對應到數字電路和 ALU,可以看到最後一步的加法,可以用加法器來實現。乘法因爲只有“0”和“1”兩種情況,所以可以做成輸入輸出都是4個開關,中間用1個開關,同時來控制8個開關的方式,這就實現了二進制下的單位的乘法,如下所示:

至於被乘數遇到乘數的1時進行位移,只要斜着錯開位置去接線就好了。如果要左移一位,就錯開一位接線;如果要左移兩位,就錯開兩位接線,如下所示:

這樣並不需要引入任何新的、更復雜的電路,仍然用最基礎的電路,只要用不同的接線方式,就能夠實現一個“列豎式”的乘法。而且,因爲二進制下,只有0和1,也就是開關的開和閉這兩種情況,所以計算機也不需要去“背誦”九九乘法口訣表,不需要單獨實現一個更復雜的電路,就能夠實現乘法。

爲了節約一點開關,也就是晶體管的數量,實際上像13×9這樣兩個四位二進制數的乘法,不需要把四次單位乘法的結果,用四組獨立的開關單獨都記錄下來,然後再把這四個數加起來,因爲這樣做,需要很多組開關,太浪費晶體管了。如果順序地來計算,只需要一組開關就好了。先拿乘數最右側的個位乘以被乘數,然後把結果寫入用來存放計算結果的開關裏面,然後,把被乘數左移一位,把乘數右移一位,仍然用乘數去乘以被乘數,然後把結果加到剛纔的結果上。反覆重複這一步驟,直到不能再左移和右移位置。這樣,乘數和被乘數就像兩列相向而駛的列車,僅僅需要簡單的加法器、一個可以左移一位的電路和一個右移一位的電路,就能完成整個乘法,如下所示:

這裏的控制測試,其實就是通過一個時鐘信號,來控制左移、右移以及重新計算乘法和加法的時機。還是以計算13×9,也就是二進制的1101×1001的例子來看,如下所示:

這個計算方式雖然節約電路,但是計算速度慢。在這個乘法器的實現過程裏,其實就是把乘法展開,變成了“加法+位移”來實現。用的是4位數,所以要進行4組“位移+加法”的操作,而且這4組操作還不能同時進行,因爲下一組的加法要依賴上一組的加法後的計算結果,下一組的位移也要依賴上一組的位移的結果。這樣,整個算法是“順序”的,每一組加法或者位移的運算都需要一定的時間。

所以,最終這個乘法的計算速度,其實和要計算的數的位數有關。比如,這裏的4位,就需要4次加法。而現代CPU常常要用32位或者是64位來表示整數,那麼對應就需要32次或者64次加法。比起4位數,要多花上8倍乃至16倍的時間。在算法和數據結構中的術語中,這樣的一個順序乘法器硬件進行計算的時間複雜度是O(N)。這裏的N就是乘法的數裏面的位數。

18.爲了把上述乘法的時間複雜度降下來,在涉及CPU和電路的時候,可以改電路。32位數雖然是32次加法,但是可以讓很多加法同時進行。把位移和乘法的計算結果加到中間結果裏的方法,32位整數的乘法其實就變成了32個整數相加。前面順序乘法器硬件的實現辦法,就好像守擂賽,只有一個擂臺會存下最新的計算結果。每一場新的比賽就來一個新的選手,實現一次加法,實現完了剩下的還是原來那個守擂的,直到其餘31個選手都上來比過一場,一共要比31場。

加速的辦法,就是把比賽變成像世界盃那樣的淘汰賽,32個球隊兩兩同時開賽。這樣一輪一下子就淘汰了16支隊,也就是說,32個數兩兩相加後,可以得到16個結果。後面的比賽也是一樣同時兩兩開賽。只需要5輪也就是O(log2N)的時間就能得到計算的結果。但是這種方式要求我們得有16個球場。因爲在淘汰賽的第一輪,需要16場比賽同時進行,對應到CPU的硬件上,就是需要更多的晶體管開關,來放下中間計算結果,如下所示:

之所以不經優化的加法與乘法計算會慢,核心原因其實是“順序”計算,也就是說要等前面的計算結果完成之後,才能得到後面的計算結果。最典型的例子就是加法器,每一個全加器,都要等待上一個全加器,把對應的進入輸入結果算出來,才能算下一位的輸出。位數越多,越往高位走,等待前面的步驟就越多,這個等待的時間叫作門延遲(Gate Delay)。每通過一個門電路,就要等待門電路的計算結果,就是一層的門電路延遲,一般給它取一個“T”作爲符號。一個全加器,其實就已經有了3T的延遲(進位需要經過 3 個門電路)。而4位整數,最高位的計算需要等待前面三個全加器的進位結果,也就是要等9T的延遲。如果是64位整數,就要變成63×3=189T的延遲。

除了門延遲之外,還有一個問題就是時鐘頻率。在上面的順序乘法計算裏面,如果想要用更少的電路,計算的中間結果需要保存在寄存器裏面,然後等待下一個時鐘週期的到來,控制測試信號才能進行下一次移位和加法,這個延遲比上面的門延遲更大。解決這個問題就是在進行加法的時候,如果相加的兩個數是確定的,那高位是否會進位其實也是確定的。可以把電路連結得複雜一點,讓高位不需要等待低位的進位結果,而是把低位的所有輸入信號都放進來,直接計算出高位的計算結果和進位結果。只要把進位部分的電路完全展開就好了,門電路的計算邏輯,可以像數學裏的多項式乘法一樣完全展開。在展開之後,可以把原來需要數量較少的,但是有較多層前後計算依賴關係的門電路,展開成數量較多的,但是依賴關係更少的門電路,如下所示,其中C4是前4位的計算結果是否進位的門電路表示,整個電路由與門(左側平,用於進位)和異或門(左側凹陷,用於求和)結合的多個半加器組成:

一個4位整數最高位是否進位,展開門電路後,只需要3T的延遲就可以拿到是否進位的計算結果。而對於64位的整數,也不會增加門延遲,只是從上往下複製這個電路,接入更多的信號而已。通過把電路變複雜,就解決了延遲的問題。這個優化,本質上是利用了電路天然的並行性。無論是這裏把對應的門電路邏輯進行完全展開以減少門延遲,還是上面的乘法通過並行計算多個位的乘法,都是把完成一個計算的電路變複雜了,也就意味着晶體管變多了,所以晶體管的數量增加可以優化計算機的計算性能。實際上,這裏的門電路展開和上面的並行計算乘法都是很好的例子,通過更多的晶體管,就可以拿到更低的門延遲,以及用更少的時鐘週期完成一個計算指令

通過ALU和門電路,搭建出來了乘法器,很多在生活中不得不順序執行的事情,通過簡單地連結一下線路,就變成並行執行了。通過精巧地設計電路,用較少的門電路和寄存器,就能夠計算完成乘法這樣相對複雜的運算。是用更少更簡單的電路,但是需要更長的門延遲和時鐘週期;還是用更復雜的電路,但是更短的門延遲和時鐘週期來計算一個複雜的指令,這之間的權衡,其實就是計算機體系結構中RISC和CISC的經典歷史路線之爭。

八、不精確的浮點數

19.浮點數float的計算結果是不準確的。例如在Python中,0.3+0.6並不等於0.9,如下所示:

>>> 0.3 + 0.6
0.8999999999999999

計算機通常用16/32個比特(bit)來表示一個數,32個比特,不能表示所有實數,只能表示2的32次方個不同的數,差不多是40億個。如果表示的數要超過這個數,就會有兩個不同的數的二進制表示是一樣的。那計算機就會不知道這個數到底是多少,這個時候,計算機的設計者們就會面臨一個問題:應該讓這40億個數映射到實數集合上的哪些數,在實際應用中才最划得來。

如果用4個比特來表示0~9的整數,那麼32個比特就可以表示8個這樣的整數。然後把最右邊的2個0~9的整數當成小數部分;把左邊6個0~9的整數當成整數部分。這樣就可以用32個比特,來表示從0到999999.99這樣1億個實數了。這種用二進制來表示十進制的編碼方式,叫作BCD編碼(Binary-Coded Decimal)。它的運用非常廣泛,最常用的是在超市、銀行這樣需要用小數記錄金額的情況裏。

這樣的表示方式也有幾個缺點。第一,這樣的表示方式有點“浪費”。本來32個比特可以表示40億個不同的數,但是在BCD編碼下只能表示1億個數,如果要精確到分的話,那麼能夠表示的最大金額也就是到100萬。第二,這樣的表示方式沒辦法同時表示很大的數字和很小的數字。在寫程序的時候,實數的用途可能是多種多樣的。有時候想要表示商品的金額,關心的是9.99這樣小的數字;有時候又要進行物理學的運算,需要表示光速這樣很大的數字。這時就需要一個辦法,既能夠表示很小的數又能表示很大的數,同時不會超過3264位存儲位數

此時浮點數(Floating Point)即float類型應運而生。IEEE的標準定義了兩個基本的格式,一個是用32比特表示單精度的浮點數,也就是常說的float或者float32類型,另外一個是用64比特表示雙精度的浮點數,也就是平時說的double或者float64類型。單精度float類型的結構如下所示:

單精度的32個比特可以分成三部分:

(1)一個符號位,用來表示是正數還是負數。一般用s來表示。在浮點數裏,所有的浮點數都是有符號的。

(2)一個8個比特組成的指數位,一般用e來表示。8個比特能夠表示的整數空間,就是0~255,在這裏用1~254映射到-126~127這254個有正有負的數上,這裏沒有用到0和255,沒錯,這裏的0(也就是8個比特全部爲0) 和255(也就是8個比特全部爲1)另有它用,下面會講到。

(3)一個23個比特組成的有效數位,用f來表示。綜合科學計數法,浮點數就可以表示成下面這樣:

(−1)^s × 1.f × 2^e

顯然,這裏的浮點數沒有辦法直接表示0。要表示0和一些特殊的數,就要用上在e裏面留下的0255這兩個標識,其實是兩個標記位。在e爲0且f爲0的時候,就把這個浮點數認爲是0。至於其它的e是0或者255的特殊情況,可以看下面這個表格,分別可以表示出無窮大、無窮小、NAN 以及一個特殊的不規範數:

以0.5爲例子,s應該是0,f應該是0,而e應該是-1,即0.5 = (−1)^0 × 1.0×2^(−1) = 0.5,對應的浮點數表示,就是 32個比特,如下所示:

需要注意,e表示從-126到127,-1是其中的第126個數,因此e用整數表示,就是 2^6+2^5+2^4+2^3+2^2+2^1=126,對應二進制就是01111110,而1.f=1.0所以f爲0。

在這樣的表示方式下,浮點數能夠表示的數據範圍一下子大了很多。正是因爲這個數對應的小數點的位置是“浮動”的,它才被稱爲浮點數。隨着指數位e的值的不同,小數點的位置也在變動。而前面BCD編碼的實數,就是小數點固定在某一位的方式,稱爲定點數

那麼爲什麼用0.3 + 0.6不能得到0.9呢?因爲浮點數sef的計算方式導致沒有辦法精確表示0.30.60.9。事實上,0.1~0.9這9個數中只有0.5能夠被精確地表示成二進制的浮點數,而0.3、0.6與0.9,都只是一個近似的表達。所以浮點數無論是表示還是計算其實都是近似計算

20.浮點數可以大到3.40×10^38,也可以小到1.17×10^(−38)。但是,平時寫的0.1、0.2並不是精確的數值,只是一個近似值。只有0.5這樣可以表示成2^(−1)這種形式的,纔是一個精確的浮點數。

舉個浮點數無法精確計算小數的例子,假設有一個二進制小數0.1001,與十進制整數轉換成二進制不斷除以2取倒序餘數的方式相反,小數點後的每一位,都表示對應的2的-N次方。那麼0.1001轉化成十進制就是: 1×2^(−1) + 0×2^(−2) + 0×2^(−3) + 1×2^(−4) = 0.5625。

和整數的二進制表示採用“除以 2,然後看餘數”的方式相比,小數部分轉換成二進制是用一個相似的反方向操作,就是乘以2,然後看看是否超過1。如果超過1就記下1,並把乘以2的結果減去1,進一步循環操作。在這裏就會看到,0.1其實變成了一個無限循環的二進制小數,0.000110011……..。這裏的“0011”會無限循環下去,如下所示:

然後,把整數部分和小數部分拼接在一起,9.1這個十進制數就變成了1001.000110011…這樣一個二進制表示。浮點數其實是用二進制的科學計數法來表示的,所以可以把小數點左移三位,這個數就變成了:

1.0010001100110011…×2^3

這個二進制的科學計數法表示就可以對應到了浮點數的格式裏了。這裏的符號位s = 0,對應的有效位 f=001000110011…。因爲f最長只有 23 位,那這裏“0011”無限循環,最多到 23 位就截止了。於是,f=00100011001100110011 001,最後的一個“0011”循環中的最後一個“1”會被截斷掉。對應的指數爲e,代表的應該是3(2的3次方)。因爲指數位有正又有負,所以指數位在127之前代表負數,之後代表正數,那3其實對應的是加上127的偏移量130,轉化成二進制就是10000010,如下所示:

然後把“s+e+f”拼在一起,就可以得到浮點數9.1的二進制表示了,變成了:010000010 0010 0011001100110011 001。如果再把這個浮點數表示換算成十進制,實際準確的值是9.09999942779541015625,並非精確的9.1。所以如果程序面對的場景是必須準確的高精度場景,不能使用floatdouble類型,應該使用int(數值不超過9位十進制)或者long(不超過18位十進制)以及BigDecimal(超過18位十進制)

21. 浮點數的加法原理也不復雜,其實就是六個字,那就是先對齊、再計算。兩個浮點數的指數位e可能是不一樣的,所以要把兩個指數位變成一樣的,然後只去計算有效位f的加法就好了。比如0.5表示成浮點數,對應的指數位e是-1,有效位f是00…(後面全是0,f前默認有一個1)。0.125表示成浮點數對應的指數位e是-3,有效位f也還是00…(後面全是 0,f前默認有一個1)。

那麼在計算0.5+0.125的浮點數運算時,首先要把兩個指數位e對齊,也就是把指數位e都統一成兩個其中較大的 -1,而0.125對應的有效位1.00…也要對應右移兩位,因爲f前面有一個默認的1,所以就會變成0.01。然後計算兩者相加的有效位1.f,就變成了有效位1.01,而兩者的指數位e都是-1,這樣就得到了想要的加法後的結果,如下所示:

實現這樣一個加法,也只需要位移,用和整數加法類似的半加器和全加器的方法就能夠實現,在電路層面,也並沒有引入太多新的複雜性。然而,其中指數位較小的數,需要在有效位進行右移,在右移的過程中,最右側的有效位就被丟棄掉了。這會導致對應的指數位較小的數,在加法發生之前,就丟失精度。兩個相加數的指數位差的越大,位移的位數越大,可能丟失的精度也就越大。

32位浮點數的有效位長度一共只有23位,如果兩個數的指數位差出23位,較小的數右移24位之後,較小數所有的有效位就都丟失了。這也就意味着,雖然浮點數可以表示上到3.40×10^38,下到1.17×10^(−38),但在實際計算時,只要兩個數差出 2^24,也就是差1600萬倍左右,那這兩個數相加之後,結果完全不會變化。例如下面的Java程序,讓一個值爲2000萬的32位浮點數和1相加,會發現+1這個過程因爲精度損失,被“完全拋棄”了,如下所示:

public class FloatPrecision {
  public static void main(String[] args) {
    float a = 20000000.0f;
    float b = 1.0f;
    float c = a + b;
    System.out.println("c is " + c);
    float d = c - a;
    System.out.println("d is " + d);
  }
}

對應的運行結果爲:

c is 2.0E7
d is 0.0

22.在一些“積少成多”的計算過程如機器學習中,經常要通過海量樣本計算出梯度或者loss,於是會出現幾億個浮點數的相加。每個浮點數可能都差不多大,但是隨着累積值的越來越大,就會出現“大數喫小數”的情況。例如用一個循環相加2000萬個1.0f,最終的結果會是1600萬左右,而不是2000萬。這是因爲,加到1600萬之後的加法因爲精度丟失都沒有了,如下所示:

public class FloatPrecision {
  public static void main(String[] args) {
    float sum = 0.0f;
    for (int i = 0; i < 20000000; i++) {
    	float x = 1.0f;
    	sum += x;    	
    }
    System.out.println("sum is " + sum);   
  }	
}

對應的輸出結果爲:

sum is 1.6777216E7

爲了解決這樣的精度問題,科學家提出了一種叫作Kahan Summation的算法。算法的對應代碼如下,可以看到同樣是2000萬個1.0f相加,用這種算法就得到了準確的2000萬的結果:

public class KahanSummation {
  public static void main(String[] args) {
    float sum = 0.0f;
    float c = 0.0f;
    for (int i = 0; i < 20000000; i++) {
    	float x = 1.0f;
    	float y = x - c;
    	float t = sum + y;
    	c = (t-sum)-y;
    	sum = t;    	
    }
    System.out.println("sum is " + sum);   
  }	
}

對應的輸出結果爲:

sum is 2.0E7

這個算法的原理就是在每次的計算過程中,都用一次減法,把當前加法計算中損失的精度記錄下來,然後在後面的循環中,把這個精度損失放在要加的小數上,再做一次運算

根據上面的原理與例子可以看到,雖然浮點數能夠表示的數據範圍變大了很多,但是在實際應用的時候,由於存在精度損失,會導致加法的結果和預期不同,乃至於完全沒有加上的情況。所以,一般情況下,在實踐應用中,對於需要精確數值的,比如銀行存款、電商交易,都會使用定點數或者整數類型。而浮點數則更適合不需要有一個非常精確的計算結果的情況。因爲在真實的物理世界裏,很多數值本來就不是精確的,只需要有限範圍內的精度就好了。比如,從家到辦公室的距離,就不存在一個100%精確的值。可以精確到公里、米,甚至釐米,但是既沒有必要、也沒有可能去精確到微米乃至納米。對於浮點數加法中可能存在的精度損失,特別是大量加法運算中累積產生的巨大精度損失,可以用 Kahan Summation 這樣的軟件層面的算法來解決如果是兩個64位的浮點數的指數位相差52位後,較小的那個數就會因爲要右移53位導致有效位數完全丟失。

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