1. 程式人生 > 實用技巧 >極簡版《計算機原理》

極簡版《計算機原理》

這兩週讀了日本作者矢澤久雄寫的《程式是怎麼跑起來的》,解開了我這個作為通訊專業的軟體從業者的很多困惑,為了避免日後遺忘,將一些看了這本書之後的問題的解答記錄下來。

Q:電腦的 CPU 中包含哪些部分?各自的作用有哪些?
A:CPU 包含暫存器,控制器,時鐘和運算器四種主要的結構。如下圖所示

  • 控制器負責將記憶體上的指令、資料等讀入到暫存器,並根據運算的結果控制整個計算機;
  • 暫存器用來暫存資料、指令等處理物件,一般 CPU 包含 20~100 個不同的暫存器;
  • 時鐘負責 CPU 開始計時的時鐘訊號;
  • 運算器負責運算從記憶體讀入暫存器的資料

從程式設計師的角度來說,CPU 可以看作暫存器的集合。CPU 中包含不同種類的暫存器,各自有不同的功能,如下表所示:

種類 功能 數目
累加暫存器 儲存運算中和運算後的資料 1
標誌暫存器 儲存運算後的 CPU 狀態 1
程式計數器 存取下一條指令的記憶體地址 1
基址暫存器 儲存資料記憶體的起始地址 多個
變址暫存器 儲存基址暫存器的相對地址 多個
通用暫存器 儲存任意資料 多個
指令暫存器 儲存指令。CPU 內部使用,程式設計師無法通過程式對暫存器進行讀寫操作 多個
棧暫存器 儲存棧區域的起始地址 多個

Q:一個典型的 C 語言原始碼在電腦中執行的基本流程是怎樣的?
A:C 語言寫成的原始碼是高階語言程式,但是 CPU 執行的程式碼是本地機器語言,因此 C 的原始碼並不能立即執行。實際上,一個 C 的原始碼需要經過編譯、和連結生成. exe 的可執行檔案之後,電腦會將. exe 檔案的副本複製到記憶體中再執行,基本的流程如下圖所示:

Q:記憶體內部結構如何?記憶體的資料存取都有哪些資料結構?
A:記憶體是計算機的主儲存器,通過晶片與計算機相連,主要負責儲存指令和資料,CPU 通過基址暫存器和變址暫存器讀取和寫入記憶體中的資料。記憶體由連續的長度為 8bit(1 個位元組)的基本元素構成,程式啟動之後 CPU 的控制暫存器根據時鐘訊號從記憶體中讀取指令和資料。

存取記憶體的資料結構包括陣列、棧、堆、佇列、連結串列和二叉樹。我們可以通過指標直接訪問和改變對應記憶體地址中的變數的數值。

  • 陣列是多個同樣型別的資料在記憶體中連續的排列的形式,可以通過陣列的索引訪問陣列元素;
  • 棧可以不通過指定地址和索引對陣列元素進行讀寫。棧由棧底、棧頂描述,一般用來臨時儲存運算過程中的資料、連線在計算機裝置上或者輸入輸出的資料;
  • 佇列與棧相似,棧的元素是 FILO,但是佇列是 FIFO,佇列一般用環形緩衝區實現;
  • 連結串列與陣列不同,它在記憶體中不是連續儲存的,每個元素都有一個直接後繼,像串珠一樣將每個元素串聯起來,最大優勢是增減元素方便快捷;
  • 二叉樹中除了最終的子節點之外,每個元素都有兩個後繼結點,有序二叉樹使得搜尋變得更有效

Q:資料和程式是如何儲存在計算機中的?
A:程式和資料是儲存在計算機的硬碟中的,但是程式執行需要將機器語言的程式載入到記憶體,因為 CPU 的程式計數器指定記憶體地址才能讀出程式內容。記憶體和磁碟因為自身特點的差異,它們之間具有緊密的聯絡。

  • 磁碟快取。由於磁碟的讀取速度較慢,為了加快程式的執行,將磁碟中的部分資料載入到記憶體中快取起來,之後在訪問同一個資料的時候就直接從記憶體中讀取資料,這樣的機制叫磁碟快取

  • 虛擬記憶體。虛擬記憶體剛好與之相反,在執行比較大的程式或者記憶體資源比較緊張可以將部分磁碟當作假想的記憶體來用。實現虛擬記憶體機制需要在磁碟為記憶體預留空間,並在程式執行時與記憶體中的內容進行置換(swap),window 中提過分頁式虛擬記憶體機制,如下圖所示

一般虛擬記憶體的大小與記憶體相當或者是記憶體的兩倍。

Q:什麼是動態連結和靜態連結?二者有何不同?
A:DLL(Dynamic link libary)是在程式執行時候動態載入的檔案,維基百科中的解釋是

動態連結函式庫(英語:Dynamic-link library,縮寫為 DLL)是微軟公司微軟視窗作業系統中實現共享函式庫概念的一種實作方式。這些函式庫函式的副檔名.DLL.OCX(包含 ActiveX 控制的函式庫)或者.DRV(舊式的系統驅動程式)。

所謂動態連結,就是把一些經常會共用的程式碼(靜態連結的 OBJ 程式庫)製作成 DLL 檔,當執行檔呼叫到 DLL 檔內的函式時,Windows 作業系統才會把 DLL 檔載入記憶體內,DLL 檔本身的結構就是可執行檔,當程式需求函式才進行連結。透過動態連結方式,記憶體浪費的情形將可大幅降低。靜態連結函式庫則是直接連結到執行檔。

DLL 的檔案格式與視窗 EXE 檔案一樣——也就是說,等同於 32 位視窗的可移植執行檔案(PE)和 16 位視窗的 New Executable(NE)。作為 EXE 格式,DLL 可以包括原始碼、資料和資源的多種組合。

簡單來說,已經編譯成組合語言的程式檔案,在進一步連結時如果直接將庫檔案連結進 exe 可執行檔案,則該連結檔案就是靜態庫,如果僅僅在程式執行時才進行連結稱為動態連結,連結的目標檔案就是動態連結庫(windows 中為 dll 檔案)。需要說明的是,在連結之後,exe 檔案中包含了靜態連結庫的所有內容,所以會比較大,而動態連結庫相對輕巧,並且動態連結庫可以在被多個同時執行的程式所共有,並且保證記憶體中只有一個 dll 檔案中呼叫函式的副本,這樣就節省了程式執行的空間。實際上 window 作業系統的大部分 API 目標檔案是動態連結庫,動態連結庫一般由匯入庫匯入,匯入庫中並不存在目標函式的實體,僅僅儲存目標函式所在的動態連結庫的名稱及路徑。下面的表格是對兩者的總結。

連結型別 何時連結 是否可共享 檔案型別 資源佔用
靜態 編譯後連結時 .a/.lib
動態 程式執行時 可被多個程式共享 .dll/.so

關於動態連結和靜態連結的詳細介紹請參考博文 C++ 靜態庫與動態庫

Q:一個 C 語言源程式是如何變成可執行檔案(exe)的?又是如何在作業系統中執行的? A:這是個比較大的問題,作者在書中舉了個 C 語言的例子。大體來說,C 的源程式需要通過編譯器編譯成組合語言(asm 檔案),進一步連結需要的庫檔案(dll 檔案)生成可執行檔案(exe 檔案),最後點選 exe 將可執行檔案匯入記憶體執行程式。以Sample.c檔案為例

#include <stdio.h>
#include <windows.h>

char *title = "messgae box";
double average(double a, double b)
{
    return (a + b)/2.0;
}

int WINAPI WinMain(HINSTANCE h, HINSTANCE d, LPSTR s, int m)
{
    double ave;
    char buff[80];
    ave = average(123,456);

    sprintf(buff, "average value is %f", ave);

    MessageBox(NULL, buff, title, MB_OK);

    return 0;
}
  1. 編譯該檔案,在原始檔目錄上執行命令bcc32 -W -c Sample.c,生成 sample.obj 目標檔案;
  2. 連結需要的庫檔案,執行命令ilink32 -Tpe -c -x -aa c0w32.obj Sample.obj, Sample.exe,, import32.lib cw32.lib

需要說明的是,c0w32.obj 檔案是與所有程式起始位置相結合的處理內容,稱為程式的啟動。在源程式中,我們呼叫了系統函式 sprintf 和 messagebox,因此,需要將這兩個函式對應的庫函式(其中的內容與 exe 檔案相同,都是原生代碼)連結進來,告訴連結器去哪裡找這兩個函式對應的原生代碼。

sprintf 的原生代碼在 cwlib32.lib 中,編譯之後會將它的目標函式合成到 exe 檔案中,稱為靜態連結;而 messagebox 的原生代碼在庫檔案 user32.dll 裡,使用 import32.dll 是為了告訴聯結器 “messagebox 在庫檔案 user32.dll 中,以及 user32.dll 在哪裡”,所以 import32.dll 稱為匯入庫。程式執行時,執行從 DLL 檔案調出的 MessageBox() 函式這一資訊就會和 exe 檔案結合,稱為動態連結

源程式到可執行檔案的流程如下所示:

Q:可執行檔案包含哪些內容?它載入到記憶體中是什麼樣子?
A:可執行檔案中包含了源程式的變數和函式的虛擬地址,在載入到記憶體之後需要必要的資訊將虛擬地址轉換成實際地址,轉換需要的資訊就在 exe 檔案開始的部分,稱為再配置資訊。exe 檔案被載入到記憶體之後,就將這些虛擬記憶體轉換成實際記憶體,程式執行中會生成棧和堆,因此在記憶體中的樣子如下圖所示

Q:c,o,a,lib,obj,dll 這些檔案分別是什麼?他們之間是什麼關係?
A:c 是 C 語言的原始檔,如博文 Linux 的. a、.so 和. o 檔案 中所述

lib,dll,exe 都算是最終的目標檔案,是最終產物。而 c/c++ 屬於原始碼。原始碼和最終目標檔案中過渡的就是中間程式碼 obj,實際上之所以需要中間程式碼,是你不可能一次得到目標檔案。比如說一個 exe 需要很多的 cpp 檔案生成。而編譯器一次只能編譯一個 cpp 檔案。這樣編譯器編譯好一個 cpp 以後會將其編譯成 obj,當所有必須要的 cpp 都編譯成 obj 以後,再統一 link 成所需要的 exe,應該說缺少任意一個 obj 都會導致 exe 的連結失敗。而 .o, 是 Linux 目標檔案, 相當於 windows 中的. obj 檔案,.so 檔案為共享庫, 是 shared object, 用於動態連線的, 相當於 windows 下的 dll,.a 為靜態庫, 是好多個. o 合在一起, 用於靜態連結

Q:什麼是_BSS 段和_DATA 段?全域性變數和區域性變數在程式執行時有何不同?
A:這是組合語言的概念,編譯器將高階語言源程式轉換成彙編檔案 (.asm 檔案),有如下的原始檔sample2.c

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

void MyFun()
{
    int c;
    c = AddNum(123,456);
}

經過編譯之後的彙編檔案(軟體環境 win10,gcc 編譯器)內容如下:

    .file    "sample.c"
    .text
    .globl    _AddNum
    .def    _AddNum;    .scl    2;    .type    32;    .endef
_AddNum:
    pushl    %ebp
    movl    %esp, %ebp
    movl    8(%ebp), %edx
    movl    12(%ebp), %eax
    addl    %edx, %eax
    popl    %ebp
    ret
    .globl    _MyFun
    .def    _MyFun;    .scl    2;    .type    32;    .endef
_MyFun:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $24, %esp
    movl    $456, 4(%esp)
    movl    $123, (%esp)
    call    _AddNum
    movl    %eax, -4(%ebp)
    leave
    ret
    .ident    "GCC: (tdm-1) 4.9.2"

彙編程式最接近機器語言,而且其與 C 語言一一對應,所以通過彙編檔案就可以瞭解程式執行的大體情況。從上面的彙編檔案,可以看到如下的結果

  1. 暫存器 esp 指向棧頂元素地址,每個元素佔據 4 個位元組的資料;
  2. 在每個函式開始的時候,都要將暫存器 ebp 的資料壓入棧中進行保護;
  3. 上述程式中隱藏的一個關鍵步驟是在第 21 行,call AddNum 時,計算機已經將 MyFun 函式的下一個指令的地址壓入棧中,在呼叫完 AddNum 時(第 12 行),返回函式 Myfun 時候會自動將棧中的返回指令的地址出棧交給 CPU 的程式計數器,這樣就可以實現在呼叫函式之後仍然返回原來的呼叫的地方;
  4. 函式的入參被儲存在棧中,返回值被儲存在暫存器裡。
int a;
int b;
float fl;

int c = 9;
int d = 10;
int e = 11;
int f = 12;

void MyFun(void)
{
    int a1,b1,c1;
    float fl1;
    a1 = 1;
    b1 = -1;
    fl1 = -99.34;
    c1 = -87;

    a1 = a;
    b1 = b;
    fl1 = fl;
    c1 = c;
}

以上的 C 原始碼轉換成組合語言是

    .file    "sample2.c"
    .comm    _a, 4, 2
    .comm    _b, 4, 2
    .comm    _fl, 4, 2
    .globl    _c
    .data
    .align 4
_c:
    .long    9
    .globl    _d
    .align 4
_d:
    .long    10
    .globl    _e
    .align 4
_e:
    .long    11
    .globl    _f
    .align 4
_f:
    .long    12
    .text
    .globl    _MyFun
    .def    _MyFun;    .scl    2;    .type    32;    .endef
_MyFun:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    $1, -4(%ebp)
    movl    $-1, -8(%ebp)
    movl    LC0, %eax
    movl    %eax, -12(%ebp)
    movl    $-87, -16(%ebp)
    movl    _a, %eax
    movl    %eax, -4(%ebp)
    movl    _b, %eax
    movl    %eax, -8(%ebp)
    movl    _fl, %eax
    movl    %eax, -12(%ebp)
    movl    _c, %eax
    movl    %eax, -16(%ebp)
    leave
    ret
    .section .rdata,"dr"
    .align 4
LC0:
    .long    -1027166700
    .ident    "GCC: (tdm-1) 4.9.2"

從中可以看出全域性變數儲存在. comm 和. globl 段,區域性變數儲存在暫存器中,因此在程式執行的整個過程中,全域性變數可以隨時訪問,但是區域性變數卻會在用過之後消失。

關於 windows 的彙編的內容可進一步參考文章彙編與逆向分析


以上是此書最乾貨的部分,書中該介紹了計算機二進位制數,和計算機硬體的部分內容,在此略過不提。