計算機的設計目標是讓程式高效、穩定、安全的執行,因此一個程式的執行涉及作業系統和幾乎所有計算機核心硬體。對於計算機初學者而言,簡單瞭解一個程式的執行過程可以更好的理解作業系統、計算機組成等專業課程
從原始碼到機器指令
在瞭解程式是怎麼執行之前,首先來了解一下程式是怎麼來的
組合語言的誕生
我們肯定聽說過計算機世界只有0與1,確實如此,一段計算機可以執行的程式是這樣的
11010000000000000000001111111111 10111001000000000000001111111111 01010010000000000000000000101000 10111001000000000000001011101000 01010010000000000000000001001000 10111001000000000000001111101000 10111001010000000000001011101000 10111001010000000000001111101001 00001011000010010000000100001000 10111001000000000000001111101000 10111001010000000000001100000000 10010001000000000000001111111111 11010110010111110000001111000000
一條可執行計算機指令通常由操作碼和運算元,顯然這樣的二進制數字人類是無法處理的,於是計算機的先賢發明了人類與計算機的中間語言——程式語言,最開始是組合語言,爲了讓程式可被人類理解,組合語言對計算機指令表達做了幾個改善
使用助記符表示機器指令,例如
MOV
、ADD
等,這些詞彙比直接使用二進制或十六進制程式碼更容易理解和記憶使用標籤和符號來表示記憶體地址、暫存器名、變數和函式名,使得程式的結構更加清晰
比如上面程式的第一條指令11010000000000000000001111111111
使用匯編語言表達變成
sub sp, sp, #16
sub
是指令的操作碼,表示“減法”操作第一個
sp
表示目標暫存器第二個
sp
表示要從中減去的暫存器#16
表示要減去的值是16
這條指令的意思是將當前 sp
(堆疊指標)的值減去 16
,並將結果儲存回 sp
暫存器,換句話說它是在堆疊上“為後續操作分配空間”。上面的二進制程式碼用 ARM 彙編大概是這樣
.section .data // 數據段, 如果需要的話可以在這裏定義常量資料 .section .text // 程式碼段 .globl _start // 定義全域性入口點 _start: // 程式入口 // 初始化變數 mov w0, #1 // a = 1, 將 1 儲存到暫存器 w0 mov w1, #2 // b = 2, 將 2 儲存到暫存器 w1 // 計算 c = a + b add w2, w0, w1 // 將 w0 和 w1 相加,結果儲存到暫存器 w2 (c) // 返回 c 作為程式的退出程式碼 mov x0, w2 // 將 c (在 w2 中) 儲存到 x0 中 mov x8, #93 // 系統呼叫號 93 對應於 exit svc 0 // 呼叫核心執行退出
講的很清晰,但還是不懂,因為組合語言本質上是對機器語言的低階抽象,使用助記符(通常是英文單詞或詞根)來表示機器指令,試圖指令更易於人類理解和編寫
面向普通人的程式語言 C
組合語言做到了人類可理解,但只限於對計算機硬體和作業系統很瞭解的專業人員,大部分人還是難以理解,於是出現了高階語言,也就是遮蔽了計算機底層硬體,更貼近於人類自然語言的程式語言,其中有劃時代意義的就是 C 語言,上面的組合語言用 C 語言表示如下
#include <stdio.h> int main() { int a = 1; int b = 2; int c = a + b; return c; }
即使沒有學過程式語言,也能大概看懂上面的程式原來是是在計算 1+2
現代的高階語言 Java
C 語言已經奠定了現代程式語言的基礎,爲了保證執行效率,C 語言允許直接訪問底層硬體和作業系統的資源,比如透過指標操作記憶體、直接操作檔案系統。但這種特性也使得 C 程式碼通常由兩個問題
程式設計師手動管理記憶體,增加了記憶體洩漏、非法記憶體訪問等風險
依賴於具體硬體的實現,從而降低了程式碼的可移植性
隨著計算機從業人口變多,和計算機硬體提升,對程式語言本身執行效率的訴求在下降,而對程式語言容易理解、記憶體安全、編寫效率(跨平臺、內建庫等)訴求在上升,而 Java 很好的滿足了這些方面的訴求,迅速成為多數企業的第一選擇
自然語言轉為機器指令
C、Java 等滿足了人類簡單、高效編寫程式的訴求,唯一的缺點是計算機不懂 C 或 Java,而編譯器、連結器等充當了其中翻譯官的角色
編譯器的主要任務是將原始碼轉換為目的碼(機器程式碼或中間程式碼,如位元組碼),中間有幾個子過程
詞法分析、語法分析、語義分析,最終生成語法樹
將語法樹轉換為中間程式碼,這種程式碼較為接近原始碼,但不依賴於最終目標機器的具體實現
程式碼最佳化,以提高程式的效率,常見的最佳化有常量摺疊、死程式碼消除等
目的碼生成將中間程式碼轉換為特定機器的目的碼或機器程式碼
連結器的主要任務是將一個或多個目標檔案組合在一起,形成一個可執行檔案,也有幾個子過程
確定符號(如函式和變數)的地址,解決不同物件檔案之間對同一符號的引用
為目標檔案中的所有符號分配地址,並更新這些符號的引用,以確保指向正確的記憶體地址
將所有目標檔案和資源整合成一個完整的可執行檔案
我們可以使用 objdump
工具反編譯連結器生成的可執行檔案
objdump -d sum > sum.txt
這樣可以看到經過編譯器、連結器處理的程式碼
sum: file format mach-o arm64 Disassembly of section __TEXT,__text: 0000000100003f74 <_main>: 100003f74: d10043ff sub sp, sp, #16 100003f78: b9000fff str wzr, [sp, #12] 100003f7c: 52800028 mov w8, #1 100003f80: b9000be8 str w8, [sp, #8] 100003f84: 52800048 mov w8, #2 100003f88: b90007e8 str w8, [sp, #4] 100003f8c: b9400be8 ldr w8, [sp, #8] 100003f90: b94007e9 ldr w9, [sp, #4] 100003f94: 0b090108 add w8, w8, w9 100003f98: b90003e8 str w8, [sp] 100003f9c: b94003e0 ldr w0, [sp] 100003fa0: 910043ff add sp, sp, #16 100003fa4: d65f03c0 ret
反編譯後使用了 記憶體地址: 機器指令 彙編指令
的格式供人閱讀,數字部分使用十六進制縮短長度,比如第一條指令可以這樣分解
100003f74: d10043ff sub sp, sp, #16
100003f74:表示這條指令儲存在記憶體地址
100003f74
d10043ff:是這條指令的機器碼錶示
sub sp, sp, #16:是這條指令的組合語言形式的表示,objdump 顯示出來是爲了方便人理解
綜上所述,100003f74: d10043ff sub sp, sp, #16
這一行代碼表示在記憶體地址 100003f74
處儲存了一條指令,該指令的功能是將當前的堆疊指標 sp
減去 16
看完這一小節就知道需要知道幾點
計算機可執行的機器指令是由編譯器和連結器給翻譯的
連結器不僅僅是把多個目標檔案合併成一個可執行檔案,還為指令確定了相對的記憶體地址
指令的執行需要地址 + 操作碼 + 運算元
將可執行檔案載入到記憶體
當用戶或系統請求執行程式時,作業系統響應請求,建立程序,將可執行檔案載入到記憶體中準備執行該程式,過程分為幾步
建立程序控制塊(PCB)
作業系統會為即將建立的新程序生成一個程序控制塊,PCB 是作業系統用來管理程序的核心數據結構,包含了關於該程序的關鍵資訊:
程序ID(PID)
程序狀態(如就緒、執行、阻塞)
程式計數器
堆疊指標
記憶體管理資訊(如頁表或段表)
資源使用情況(如開啟的檔案描述符)
在生成 PCB 時,作業系統會進行一些簡單的初始化,但並不會立即為程式分配記憶體,此時 PCB 只是處於一種準備狀態,等待後續的步驟
讀取可執行檔案頭資訊
接下來作業系統將根據指定路徑找到可執行檔案,並讀取其頭部資訊,程式頭部資訊包含了比如機器架構、程式入口、記憶體分配、動態連結等基本資訊,這些資訊對於作業系統的載入器(loader)可以正確地載入和執行程式至關重要
readelf 是一個用於檢視 ELF (Executable and Linkable Format) 格式檔案的命令列工具,廣泛用於 Unix/Linux 系統。Mac 系統使用 Mach-O 檔案格式,可以使用 otool 檢視程式頭資訊
Mach header: ------------------------------------------- magic : 0xfeedfacf cputype : 16777228 (x86_64) cpusubtype : 0 (no specific subtype) caps : 0x00 filetype : 2 (Executable) ncmds : 16 (Number of Load Commands) sizeofcmds : 744 (Size of Load Commands in bytes) flags : 0x00200085 (Flags) -------------------------------------------
每個可執行檔案都包含一個入口點資訊(main 函式),這個入口點是程式的第一條指令地址,作業系統會將其儲存在 PCB 中
分配記憶體空間
根據檔案頭資訊,作業系統為程序的邏輯地址空間分配必要的資訊,通常包括程式碼段、數據段、堆和棧等
程式碼段:也稱 .text 段,儲存程式的可執行程式碼(機器指令),通常是隻讀的,防止程式執行時修改自身的程式碼,程式碼中函式體、迴圈、條件判斷等,都被編譯為指令並存儲在程式碼段中
數據段:儲存已初始化的全域性和靜態變數,是可讀可寫的,程式執行時可以修改其中的變數值,
int a = 5;
這樣的全域性變數會被儲存在數據段中堆:用於動態記憶體分配,堆的大小在程式執行時動態變化,在 C 語言中程式設計師使用
malloc()
和free()
手動管理棧:用於管理函式呼叫及區域性變數,每當函式被呼叫時,相應的區域性變數會被壓入棧,函式返回時這些變數會被彈出。棧的大小通常是固定的,由系統預設,在函式呼叫巢狀太深時可能會導致棧溢位,這就是我們常見的 stackoverflow 報錯
如果有足夠的記憶體空間,作業系統將進行實體記憶體分配,沒有的話要用進行記憶體回收或者使用虛擬記憶體等技術騰挪出可用記憶體空間
為程式建立一個新的程序邏輯記憶體空間,分配必要的程式碼段、數據段、堆和棧區域
在分頁系統中更新程序的頁表,以便將實體記憶體頁面對映到程序的邏輯地址空間
實體記憶體地址是計算機硬體中實際存在的記憶體地址,這些地址直接對映到實體記憶體晶片上的特定位置。
邏輯記憶體地址(也稱虛擬地址)是程式生成的地址,這些地址是相對於程序的虛擬地址空間的,這樣每個程序可以認為自己擁有獨立的、連續的記憶體地址空間,進而簡化了程式設計模型
在程式實際執行訪問記憶體時候,邏輯地址透過記憶體管理單元(MMU)被轉換為實體地址
進一步初始化,等待排程
一旦記憶體被分配,作業系統會進行更復雜的初始化,例如:
設定許可權標誌(可讀、可寫、可執行等)
初始化未初始化的變數
準備堆和棧
然後程序的 PCB 會被更新以反映新的狀態,例如程序現在處於就緒狀態,並且已經設定了程式的入口點、頁表等資訊,PCB 加入到就緒佇列等待 CPU 排程
指令執行
指令的執行需要多個硬體合作配合,大體可以分為幾個流程
取指(Fetch) :從記憶體中獲取指令並傳輸到指令暫存器(IR)
解碼(Decode) :解析指令的操作碼和運算元
執行(Execute) :執行操作,比如算術計算或邏輯操作
訪存(Memory Access) :對於需要訪問記憶體的指令,執行讀/寫操作
寫回(Write Back) :將結果寫回暫存器
1. 取指(Fetch)
取指需要程式計數器(Program Counter, PC)和指令暫存器(Instruction Register, IR)的配合:
程式計數器指向當前執行指令的地址(初始化的是 PCB 儲存的程式入口指令地址)
CPU 從記憶體中讀取這條指令並將其存入指令暫存器中
PC 始終儲存下一條要執行的機器指令的地址。每次 CPU 執行完一條指令後,PC 都會更新,以指向下一條指令
在每個指令週期開始時,CPU 使用 PC 中儲存的地址從記憶體中讀取指令,將指令載入到 IR,以便 CPU 解碼和執行,因此 IR 中也只有一條指令
2. 解碼(Decode)
CPU 包含一個指令解碼單元,用以解析指令的具體操作,指令通常由操作碼(Opcode)和運算元組成:
操作碼表示要執行的操作型別(加法、減法、跳轉等)
運算元是指參與操作的資料或者記憶體地址
3. 執行(Execute)
根據指令型別,CPU 啟動相應的執行單元:
算術邏輯單元(ALU):用於算術和邏輯運算,如加法、減法、邏輯與、邏輯或等
暫存器:可以直接在暫存器中進行操作,加速數據處理
控制單元(Control Unit):根據解碼結果控制各個部件執行相應的操作
4. 儲存結果(Store)
執行完指令後,結果可能需要存放:
暫存器:直接存放在 CPU 內部的暫存器中
記憶體:將結果存回記憶體中的特定地址
5. 更新程式計數器(PC)
在執行完一條指令後,程式計數器自動更新,指向下一條將要執行的指令。這通常是透過加一實現的,除非是執行了跳轉指令,這時會直接修改程式計數器
6. 重複迴圈
CPU 重複進行取指、解碼、執行、儲存結果和更新程式計數器的過程,直到程式結束或被外部中斷
CPU 內部有一個時鐘生成器(Timer、定時器),用於提供穩定的時鐘訊號,這個時鐘訊號以固定的頻率振盪,控制著 CPU 的操作節奏。每個時鐘訊號的脈衝稱為一個時鐘週期,指令的各個階段(取指令、解碼、執行等)通常與時鐘訊號的脈衝同步進行
機器週期是指 CPU 完成一基本操作所需要的時間,這些基本操作可以包括取指令、執行指令、讀寫記憶體等,一個機器週期可以包括多個時鐘週期
7. 外部裝置和中斷處理
在多工作業系統中,時鐘生成器可以生成定期中斷,通知 CPU 某項任務需要處理。這使得作業系統能夠暫停當前任務,切換到其他任務或處理系統事件,讓作業系統實現時間片輪轉排程
如果程式需要與外部裝置互動(例如 I/O 操作),發出系統呼叫,CPU 會暫停當前執行的任務,轉而處理中斷請求,執行相應的中斷服務程式,這時系統會從使用者模式切換到核心模式,以執行安全的操作
當外部互動處理完成時候,利用時鐘生成器生成的定期中斷,作業系統恢復之前儲存的上下文資訊,將程序切換回原來的程式繼續執行
8. 結束和清理
程式完成後,控制權透過系統呼叫返回作業系統
釋放資源:作業系統釋放為程式分配的記憶體和其他資源
退出狀態:程式可以返回一個狀態碼,以指示成功或失敗
小結
程式的生命週期涵蓋了從編寫到結束的多個階段,包括編寫、編譯、載入、連結、準備執行、執行、暫停與切換、結束和資源回收等。這些階段體現了程式在作業系統中如何執行和管理以及計算機的各個核心硬體是怎麼配合的。如果圍繞如何讓程式高效、穩定、安全執行來展開計算機組成和作業系統的教學,這兩門課也會有有趣很多