這一章我們會走過程式從建立、編譯、執行到結束之間,應用程式、作業系統與電腦硬體究竟發生了哪些事情,以及簡述計算機元件在執行程式時的職責,各個概念點到為止,留到後面章節詳述。

存在硬碟中的 Hello, world

一般的開發流程中,我們會撰寫 Source code 並存放在硬碟裡,像是 C 語言這樣的高階語言是相當語意化的,更不用說像 Python、JavaScript 這些更晚出現的新語言,但大家也都瞭解,計算機系統只認得 0 與 1 ,而人類的文字與符號是多樣的,因此我們必須使用編碼來表示它們。

對於大部分的程式來說(撇除文言文中文寫程式的不算 ==),ASCII 編碼範圍已經足以表示所有可能的字元,包括數字、英文字母大小寫、符號,以及換行等特殊字元,像是換行(\n)轉換成十進位數字是 10。

書上說「大部分電腦系統是以 ASCII 標準來表示文字字元」並非指 ASCII 編碼方式,因為現今大部分的 Editor 預設應為 UTF8 編碼,而 UTF8 是向下相容於 ASCII 編碼的,因此 UTF8 確實也是實作 ASCII 標準。

所以我們讀起來是 mainprintf 等 ⋯⋯ 和英文字彙,在電腦裡都是以被稱為位元(Bit)的 0、1 數字組合而成,而不只是我們寫的程式,在電腦系統中,無論是硬碟裡的資料,記憶體裡的程式,你在程式中分配的變數,以及網路傳遞的封包,所有的資訊都是由有序的位元組合而成。

關於編碼,第二章會詳細說明

但如果有同樣兩組「有序位元組」的存在,該如何分辨他們之間的差別呢?唯一的方法就是透過上下文 (Context) 來做分別:

1
2
3
4
// 0100 1000 0100 1001
// 在 ASCII 中,表示的是 'H', 'I' 兩個字元
// 在 C 的整數中,表示的是十進位的 18505
// 在別的 context 中可能指的是一種指令

Before Runtime : 程式的編譯過程

像 C、JAVA、C# 這樣的編譯語言 (Compiled Language) 都必須透過編譯這個過程:將人能讀懂的程式語言,經過數個步驟轉化成可執行的目標程式 (Object Program),並以二進位檔的方式儲存在硬碟中。

任何儲存在電腦系統中的檔案,若不是文字檔 (Text file) 就是二進位檔 (Binary file),差別在於一個存放的是文字,一個是二進位資料

GCC 的編譯步驟

GCC 全名 GNU Compiler Collection,在它甫推出之時,只能處理 C 語言,隨著眾開發者的努力,他現在能夠處理許多不同的程式語言,如 C++、JAVA、Go 等 ⋯

Source Code

1
2
3
4
5
6
7
8
#include <stdio.h>

int main()
{
printf("hello, world\n");

return 0;
}

完整的編譯指令,最後會產生可執行的目標程式

1
gcc -o hello hello.c
  • 前編譯
    處理程式中「#」開頭的程式,像是 #include 引入外部資源, #define 定義資料,這時產生的副檔名為文字檔 .i

  • 編譯
    將前編譯產生的文字檔編譯成組合語言,你可以透過 gcc 下 -S 指令來產生這個檔案。

1
gcc -S hello.c
  • 組譯
    將編譯出來的組合語言,透過組譯器(同樣存在於 GNU 工具中)翻譯為低階機械語言,稱為目標程式 .o (Relocatable object program),在這個階段產生出來的已經是二進位檔了。

  • 連結
    組譯階段產生出來的目標程式僅包含的 main 函式,但我們寫程式時必定會用到來自函式庫 (Library) 的程式,像是 printf 就是存在於標準 C 函式庫的函式,在這個階段,Linker 會幫我們合併這些外部函式,並產生可執行的目標程式 (Excutable object program)。

在編譯的流程完全結束後,我們就能夠執行這個程式:


During Runtime : 程式的執行過程

了解這些硬體代表的角色

要了解程式在執行過程中,我們電腦裡的硬體分別做了什麼事,我們必須先來認識一下這些角色,上面的圖是這些角色之間的關係,看起來有些複雜,但如果你都能記住冰與火之歌的角色關係了,這也只是一塊蛋糕而已,而且我們可以把它概括為四種角色

匯流排 (Bus)

匯流排負責在各個元件之間傳遞資料,匯流排的寬度是指它一次能夠傳遞多少個 bits ,這單位稱為 word size_,在現今的系統中,_word size 會是 4 Bytes (32 bits) 或是 8 Bytes (64 bits)

輸出/輸入裝置 (I/O devices)

就如同它的名字所述,它是電腦與外部世界的連結,舉凡輸入裝置的鍵盤、滑鼠、麥克風,輸出的螢幕裝置,或是儲存資料使用的硬碟

記憶體 (Main memory)

記憶體是程式執行與資料儲存時使用的暫時儲存空間,例如你在程式宣告的變數,或是你的呼叫函式時的 Call stack,都會儲存在這裡。邏輯上,我們能夠將記憶體視為一串存放 Bytes 的陣列,每個空間都有一個從 0 開始的唯一位址。

處理器 (Processor)

眾所皆知的 CPU,它負責執行存放在記憶體中的指令,在它的暫存器中,有一個最重要的角色稱為程式計數器 (Program Counter, PC) ,它負責指向下一個要執行的指令的記憶體位址,只要 CPU 是供電的狀態,處理器就會執處理程序式計數器指向的指令,並更新程式計數器讓他指向下一個指令

暫存器 (Register) 可以想像成處理器裡面的自有記憶體,儲存的空間非常小,但它的存取速度比記憶體快上許多。處理器中存在著各式不同功能的暫存器,例如:剛剛介紹的程式計數器存放指令位址,資料暫存器存放整數,浮點暫存器存放浮點數等 ⋯

「指令」說起來似乎有點抽象,實際上到底是做了什麼事?就像我們寫程式時會下的陳述句 x += 1 一樣,但處理器實作的是較低階的指令集架構 (Instruction set architecture),舉例來說,他會做像是下面這些事:

  • 載入:將資料從記憶體複製一份到暫存器
  • 儲存:將資料從暫存器複製一份到記憶體的某個位址
  • 計算:將數個暫存器的資料複製到算術單元 (ALU) 中,並將結果存放到暫存器
  • JUMP:將資料複製到程式計數器(接下來會執行這個指令)

執行 hello 程式

介紹完上面的硬體角色,我們現在來實際跑一次 hello 程式:

  1. 從鍵盤輸入 ./hello,終端機將讀入的每一個字元複製到暫存器,再將字元從暫存器複製到記憶體中
  2. 當我們按下 Enter,終端機解析了這一個字串,並開始執行 hello 程式
  3. 終端機執行一系列的指令:將 hello 程式的程式碼資料從硬碟複製到記憶體,資料的內容包括 hello, world 字串
  4. 處理器接著執行 hello 程式中,main 函式裡的指令,包括將 hello, world 字串從記憶體複製到暫存器中,並送到輸出裝置 (螢幕)

快取記憶體與記憶體階層

看了上面的步驟簡述,最常出現的動詞是什麼呢?就是「複製」,或是說「把資料從 A 移動到 B」的動作,電腦系統把許多時間花費在這件事情上,尤其若牽涉到硬碟,存取的時間要比暫存器和記憶體多上許多。

呃,不知道大家會不會在床旁邊打造自己的「一日生活圈」,就是把常用的東西,像是鬧鐘、手機、電視遙控器都擺在伸手可碰的距離,這是因為覺得自己很常花時間起床去拿這些東西,所以才乾脆把它擺在床旁邊,以減少起床拿東西再上床的頻率。(aka 懶)

沒錯,我們很常聽到的快取 (Cache) 也是同樣的道理,系統設計師在 CPU 裡規劃了快取記憶體來儲存資料,在需要存取資料時,會先檢查快取記憶體,若快取裡面有資料,就可以不用到記憶體去拿了。

通常快取記憶體會有兩層,最接近暫存器的稱為 L1,它的存取速度和暫存器幾乎一樣快,再往上則是 L2,存取速度雖然比 L1 慢,但速度仍然比記憶體快上五倍,且儲存的空間也比 L1 大,現代處理器會有 L3,則以此類推(比記憶體快但比 L2 慢)。

快取的意義在於將高成本的查詢結果儲存起來,這一點在後端程式開發也是必須的,對於減少伺服器的負載有很大的幫助,系統會透過局限性 (Locality) 來判斷這個資料是否複製到快取記憶體:

  • 時間局限性:最近存取過的資料
  • 空間局限性:最近存取資料的相鄰記憶體空間(如陣列)

快取 (Cache) 的概念是相對性的,每一層的記憶體都是它下一層的快取,像是暫存器是快取記憶體的快取,而硬碟也是遠端存取資料的快取,舉例來說,我們會將成本較高的 Web API 回傳的資料儲存在資料庫中,在資料庫有資料的狀況下,可以省下呼叫 API 的時間。(當然,這邊要依據資料改變的情境來設計)

系統公道伯 - 作業系統

在上述 hello 程式執行的步驟中,雖然我們的程式看似天生神力,能夠直接讀取鍵盤、顯示字串到螢幕上,但其實任何對硬體的操作都還是得先經過作業系統 (Operating System, OS) 才行,它就像是硬體與軟體之間的協調者。

作業系統主要的目的為:

  1. 保護硬體不被軟體濫用
  2. 提供統一的操作介面給軟體使用

為了達到上述目的,OS 提供了三個抽象概念:

  • 處理程序 (Process)
  • 虛擬記憶體 (Virtual Memory)
  • 檔案 (File)

處理程序

既然處理器一次只能處理一個指令 (在單核的狀況下),那麼我們的作業系統是如何在電腦中同時「跑」這麼多程式呢?

歸功於處理程序這個將程式抽象化的概念,作業系統會管理每個處理程序執行所需要的資訊,像是暫存器裡的資料、程式計數器指向的位址、記憶體裡的資料,這些我們稱為 Context

當作業系統要將控制權轉移到另一個處理程序時,就會發生 Context Switch,作業系統會幫我們把這些 Context 儲存起來,並建立新的 Context,未來若控制權回到這個處理程序時,就可以把載入已儲存的 Context,因此程式的狀態不會被中斷。

有點像是你跟你哥輪流打電動,然後你們每次換人玩之前都會存檔,於是無論手把交給誰,他都能夠從上次的進度開始玩,如果你們轉換(Context Switch)的頻率夠快的話(可能接近 1/24 秒吧),看起來就會像是你跟你哥同時打電動。

負責管理處理程序轉換的程式稱為作業系統的 kernel,kernel 並不是一個處理程序,而是永遠存在於記憶體裡面的程式。當應用程式需要作業系統幫它操作硬體時,它就會發出 system call,將控制權交給 kernel 並完成這個請求。

虛擬記憶體 (Virtual Memory)

作業系統像劃地一樣,將記憶體依照用途劃分為不同的區塊,稱為虛擬記憶體空間 (Virtual memory space)。

  • 程式碼與資料:每個處理程序的起點是同樣一個固定的記憶體位址,像是程式宣告變數的資料會儲存在這,這個區塊的大小在編譯階段就能得知

  • Heap:程式在 runtime 時搜集 (malloc) 和釋放 (free) 的記憶體,因為是執行時才產生,因此不像上面的資料是固定大小

  • 共享資源庫:在連結 (Linking) 階段產生

  • Stack:處理程序的 call stack,因為也是在執行時產生,因此大小非固定

  • Kernel 虛擬記憶體:永遠會在最上方的區塊,當應用程式需要作業系統的幫忙時,可以呼叫 Kernel 程式(像是讀寫檔案、轉換處理程序的控制權)

透過虛擬記憶體,處理程序在持有控制權時,就像「獨佔」了整塊記憶體,而在控制權轉移時,這些處理程序資料會被作業系統儲存起來,以便後續控制權轉回這個處理程序時,能夠恢復原本的狀態。


喜歡這篇文章嗎?

歡迎點擊按鈕分享到 Facebook 上唷!

Weightless Theme
Rocking Basscss