Learning Objectives
- 說明為什麼一般程式不能直接操作硬體,以及使用者模式與核心模式的差別
- 區分系統呼叫、硬體中斷與例外這三種進入核心的路
- 看懂教學核心的畫面,並說出它和真實 Linux 不一樣的地方
- 判斷 printf 的內容什麼時候才真的透過 write 送出去
一行指令就能讓程式死掉,電腦卻沒事
下面這支程式讓子行程執行 x86 的 hlt 指令,這個指令的意思是「讓 CPU 停下來」。如果任何程式都能下這個指令,一支寫錯的程式就可以讓整台電腦停擺。放到真正的 Linux 上跑:
子行程被訊號 11 終止
父行程和這台電腦都還好好的
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
CPU 拒絕執行,核心把這個行程終止,其他程式照常運作。(訊號和 fork 後面的課會細講,這裡先看結果。)
這個結果背後是作業系統最基本的安排:一般程式不能直接碰硬體。原因有三個:
- 保護:程式有錯或有惡意時,不能影響別人。你的程式不能讀走另一個程式的記憶體,也不能把整顆硬碟寫壞。
- 共用:CPU、記憶體、磁碟只有一份,卻有很多程式同時要用,需要有人分配。
- 抽象:程式只要說「把這些位元組寫進這個檔案」,不用知道這顆是 SSD 還是硬碟、是哪一家的產品。
負責這些事的程式叫核心(kernel)。它也是一支程式,只是有硬體賦予的特殊權限。
使用者模式、核心模式與系統呼叫
CPU 內部記著自己目前在哪一種模式:
| 模式 | 誰在跑 | 能做什麼 |
|---|---|---|
| 使用者模式(user mode) | 你的程式、瀏覽器、Shell | 計算、讀寫自己的記憶體;碰到硬體或特權指令就會被 CPU 擋下 |
| 核心模式(kernel mode) | 核心 | 全部指令都能執行,可以操作硬體、修改任何記憶體 |
程式想開檔、印字、建立行程,都要請核心代勞。請託的方式叫系統呼叫(system call):程式把「要做什麼」放進暫存器,執行一個特殊指令(x86-64 上就叫 syscall),CPU 切到核心模式,跳到核心事先設定好的入口;核心檢查參數與權限,做完之後切回使用者模式,把結果交還給程式。
入口位址由核心決定,程式不能自己選要跳到核心的哪一行,所以系統呼叫是使用者程式進入核心唯一由自己發起的門。
除了系統呼叫,還有兩條路會讓 CPU 進入核心:
| 進入核心的路 | 誰引發 | 例子 |
|---|---|---|
| 系統呼叫 | 程式主動要求 | read、write、fork |
| 例外(exception) | 程式執行某個指令時,CPU 自己發現問題 | 除以 0、存取不該碰的記憶體、在使用者模式下特權指令 |
| 硬體中斷(interrupt) | 程式以外的硬體 | 按下鍵盤、網路封包到了、計時器響了 |
計時器中斷特別重要:就算程式陷入無窮迴圈,核心也會定期拿回控制權,這是多工能夠運作的基礎(第 5 課)。
C 語言的
read、write是函式庫包的薄薄一層,幫你準備參數再執行syscall指令。一般程式不會直接寫那個指令,都透過函式庫呼叫。
這一軌怎麼讀 C
作業系統的介面是用 C 定義的,所以範例用 C 寫。不熟 C 沒有關係,這一軌用到的語法很少,先看這幾個:
#include <stdio.h> // 使用 printf 這類函數,要先引入宣告
int counter = 0; // 全域變數,整個程式都看得到
int main(void) { // 程式從 main 開始
int x = 5; // 區域變數
printf("x = %d\n", x); // %d 會換成整數 x,\n 是換行
int *p = &x; // &x 是 x 的位址;p 是指標,存著那個位址
*p = 7; // *p 是「p 指到的那個位置」,所以 x 變成 7
char buf[64]; // 64 個位元組的陣列
return 0; // main 的回傳值就是程式的結束碼,0 代表正常
}
三個會一直遇到的習慣:
- 指標存的是記憶體位址。系統呼叫常要你給一個位址,讓核心把結果放進去,例如
read(fd, buf, 64)就是「把讀到的資料放到buf那裡」。 NULL是不指向任何地方的指標。- 系統呼叫失敗時多半回傳 -1,原因放在全域變數
errno。真正的程式都要檢查回傳值;範例為了聚焦觀念,有時會省略。
想多了解 C 與指標,可以看 C++ 軌,指標的語法兩者相同。
教學核心是什麼,哪裡簡化了
這一軌的主角是一個在你的瀏覽器裡執行的教學核心:你寫的 C 程式會被編譯成簡化的機器指令,放到這個小核心上跑。它的行程、頁表、檔案描述子都是真的資料結構,畫面上的數字是它算出來的,不是事先做好的動畫。你也可以自己當排程器,決定下一步輪到誰。
它的目的是讓你看得見過程,所以有些地方和真實的 Linux 不同,讀的時候要記得:
| 項目 | 教學核心 | 真實的 Linux |
|---|---|---|
| 機器指令 | 簡化成 load、add、store、call、ret |
x86-64 或 ARM 的真實指令 |
| pid | 固定從 1000 開始 | 由核心分配,每次不同 |
| 記憶體位址 | 每次一樣 | 有位址隨機化,堆積與堆疊每次不同 |
| C 語言 | int、long、char、指標、陣列、函數 |
完整的 C;這裡沒有 struct、浮點數、switch |
| 時間 | 虛擬時鐘,sleep(2) 不用真的等 |
真實時間 |
| 記憶體順序 | 每個讀寫都照程式順序發生 | 多核心 CPU 可能重排,第 9 課會談 |
| 指令 | /bin/ 裡只有十個小程式:echo、cat、wc、grep、sleep、true、false、yes、ls、head |
完整的系統 |
輸出和真實 Linux 比對過,每一課也會放幾段真實 Linux 跑出來的結果,讓你知道核心之外的真實樣子。教學核心印出的 pid 和位址只在這裡有效,不要當成真實系統的值。
操作方式:
- 編譯並開始:把程式載入核心。
- 執行一步:走一個機器指令或一行程式(看「一步」的選項)。卡片上會寫著「下一步」要做什麼,以及這個行程目前的狀態。
- 跑到結束:一路跑完。
- 右邊的面板可以切換:系統呼叫列出每一次進入核心,變數與鎖顯示記憶體裡的值,記憶體是位址空間與頁表。
printf 不等於 write
剛才看到的就是 stdio 緩衝:C 標準函式庫在每個行程裡有一塊記憶體,printf 先把字放在那裡,累積到一定條件才一次呼叫 write。系統呼叫要切換模式,一個字呼叫一次太花時間,所以函式庫幫你累積。
什麼時候送出,要看 stdout 接到哪裡:
| stdout 接到 | 緩衝方式 | 什麼時候呼叫 write |
|---|---|---|
| 終端機 | 行緩衝 | 遇到換行、緩衝區滿了、fflush、程式正常結束 |
| 檔案或管線 | 全緩衝 | 緩衝區滿了、fflush、程式正常結束 |
stderr(用 fprintf(stderr, …) 輸出)預設不緩衝,因為錯誤訊息應該馬上看到。
write 不經過這個緩衝區,呼叫就直接進核心。所以 printf 和 write 混用時,順序可能和程式寫的不一樣。下面這支程式放到真正的 Linux 上跑,stdout 接到管線:
printf("1 printf\n");
write(1, "2 write\n", 8); // 1 是 stdout 的檔案描述子,8 是位元組數
printf("3 printf\n");
2 write
1 printf
3 printf
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
第 2 行先出現了,因為 1 和 3 都還在緩衝區,等到程式結束才一起送出。同一支程式在終端機上跑,順序就是 1、2、3。這也是為什麼程式在終端機看起來正常,輸出導到檔案之後順序卻變了。
真實的 Linux 長什麼樣子
同樣用 getpid 和系統提供的資訊,在真正的 Linux 上查一次。uname 回報核心版本,sysconf(_SC_PAGESIZE) 回報記憶體分頁大小,/proc/self/maps 是核心提供的虛擬檔案,內容是這個行程的位址空間。這個檔案每一行是位址範圍、權限、位移、裝置、inode、名稱,為了容易閱讀,程式只印出起始位址、權限和名稱:
pid = 2
kernel = Linux 7.0.0-1014-aws x86_64
page size = 4096 bytes
--- /proc/self/maps ---
00400000 r--p output.s
00401000 r-xp output.s
00402000 r--p output.s
00403000 r--p output.s
00404000 rw-p output.s
03e8a000 rw-p [heap]
704652e00000 r--p libc.so.6
704652e28000 r-xp libc.so.6
704652fb1000 r--p libc.so.6
704653000000 r--p libc.so.6
704653004000 rw-p libc.so.6
704653006000 rw-p
7046531d5000 rw-p
7046531da000 r--p [vvar]
7046531de000 r--p [vvar_vclock]
7046531e0000 r-xp [vdso]
7046531e2000 r--p ld-linux-x86-64.so.2
7046531e3000 r-xp ld-linux-x86-64.so.2
70465320e000 r--p ld-linux-x86-64.so.2
704653218000 r--p ld-linux-x86-64.so.2
70465321a000 rw-p ld-linux-x86-64.so.2
7ffe9d132000 rw-p [stack]
ffffffffff600000 --xp [vsyscall]
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
幾個可以對照的地方:
- pid 是 2:這個環境跑在容器裡,看到的行程編號是獨立的一組(第 18 課)。一般的 Linux 上會是比較大的數字。
- 每一行是一段位址,權限欄的
r、w、x是能不能讀、寫、執行:r-xp是程式碼,rw-p是可以寫的資料。可以看到[heap]和[stack]。output.s是這個環境編譯出來的執行檔。教學核心的「記憶體」面板列的是同樣的資訊,第 12 課會逐段細看。 libc.so.6是 C 函式庫,printf就在裡面。[vdso]是核心放進每個行程的一小段程式。查時間這類很頻繁的呼叫可以直接在使用者模式完成,省下切換模式的成本。
位址隨機化也可以直接看到。同一支程式連跑三次,印出全域變數、堆積、堆疊的位址:
第 1 次:global 0x404020 heap 0x3e0172a0 stack 0x7fffbd774fa4
第 2 次:global 0x404020 heap 0x3a6962a0 stack 0x7fffd7f46de4
第 3 次:global 0x404020 heap 0x305f72a0 stack 0x7ffc6ca07df4
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
堆積和堆疊每次都不同,讓攻擊者比較難猜到位址。這個環境編譯出的全域變數位址固定,一般發行版預設的編譯方式連程式本身的位址也會隨機。教學核心沒有這個機制,所以每次跑結果都一樣,方便你對照。
在自己的 Linux 或 WSL 上,
strace ./程式可以列出這支程式做的每一個系統呼叫,和練習裡的面板是同一個概念,只是真實程式啟動時載入函式庫也會有很多呼叫。
確認一下
程式在使用者模式下執行 hlt 這類特權指令,會發生什麼事?
下面哪一個函數通常不需要進入核心就能完成?
在終端機上執行這段程式,這 60 秒內畫面會看到什麼?
printf("a");
sleep(60);
printf("b\n");
執行 ./main > out.txt(stdout 接到檔案),out.txt 的內容順序是什麼?
printf("1\n");
write(1, "2\n", 2);
printf("3\n");
下面哪一件事的來源是硬體中斷?
重點整理
- CPU 有使用者模式與核心模式。一般程式跑在使用者模式,碰硬體或執行特權指令會被擋下。
- 進入核心有三條路:系統呼叫(程式主動)、例外(指令出問題)、硬體中斷(外部硬體)。程式能主動走的只有系統呼叫,入口由核心決定。
printf是函式庫函數,先放進行程自己的緩衝區;write才是系統呼叫。接到終端機是行緩衝,接到檔案或管線是全緩衝。- 教學核心用真的資料結構執行你的程式,但指令、位址、pid、時間都簡化過;真實系統的值要看真實輸出。
下一課看一支程式跑起來之後,核心用什麼資料記住它:行程、PCB 與狀態。