程式是怎麼跑起來的
寫好的 .c / .cpp 要經過前置處理 → 編譯 → 組譯 → 連結四個步驟,才會變成 .exe。程式執行時,作業系統把它載入記憶體,資料依種類分別放在程式碼、唯讀資料、全域/靜態資料、heap、stack 幾個區域,每個區域的大小和「存在多久」都不一樣。
從原始碼到執行檔
Section titled “從原始碼到執行檔”按下 Ctrl+Shift+B 時,沙盒會呼叫 clang 完成下面四個步驟:
| 步驟 | 做什麼 | 自己試試(在 VS Code 的終端機) | 產出 |
|---|---|---|---|
| 1. 前置處理 | 處理 # 開頭的指令:把 #include 的檔案內容整個貼進來、把 #define 的名字換成內容 | clang -E playground\hello.c | 展開後的原始碼(還是文字) |
| 2. 編譯 | 檢查語法和型態,翻譯成組合語言 | clang -S playground\hello.c | hello.s |
| 3. 組譯 | 把組合語言變成機器碼 | clang -c playground\hello.c | hello.o(目的檔) |
| 4. 連結 | 把你的 .o 和函式庫接在一起,例如找到 printf 真正的程式碼 | clang hello.o -o hello.exe | hello.exe |
試試 clang -E,會發現短短幾行的程式被展開成上千行:<stdio.h> 的內容全部貼進來了。
錯誤出現在哪一步
Section titled “錯誤出現在哪一步”| 錯誤種類 | 發生在 | 典型訊息 | 常見原因 |
|---|---|---|---|
| 編譯錯誤 | 步驟 2 | error: expected ';' after expression | 語法寫錯、型態不合、用了沒宣告的變數 |
| 連結錯誤 | 步驟 4 | undefined symbol: foo | 函式只有宣告沒有實作、名字拼錯、沒有 main |
| 執行錯誤 | 執行時 | Sanitizer 報告、stack overflow | 編譯和連結都沒問題,但執行時出事,例如越界、除以 0 |
標頭檔裡放的是什麼
Section titled “標頭檔裡放的是什麼”#include <stdio.h> 只是把 stdio.h 的內容貼進來。標頭檔裡主要是宣告,例如「printf 這個函式存在,參數長這樣」,讓編譯器能檢查你呼叫得對不對。printf 真正的程式碼在 C 執行環境的函式庫裡,到了步驟 4 才接上。
#include <…> 從系統的標頭檔資料夾找;#include "…" 先從原始檔所在的資料夾找,自己寫的標頭檔用這種寫法。
程式執行時的記憶體
Section titled “程式執行時的記憶體”| 區域 | 放什麼 | 什麼時候出現 / 消失 | 大小 |
|---|---|---|---|
| 程式碼 | 函式的機器碼 | 程式載入時 / 程式結束時 | 固定 |
| 唯讀資料 | 字串常值(例如 "hello")、const 全域常數 | 同上 | 固定;寫入會讓程式當掉 |
| 全域 / 靜態資料 | 全域變數、static 變數 | 進入 main 之前就準備好,整個程式執行期間都在 | 編譯時就決定好 |
| heap | malloc / new 配置的記憶體 | 你呼叫 malloc / new 時 / 你呼叫 free / delete 時 | 很大,受實體記憶體限制 |
| stack | 區域變數、函式參數、函式的返回位址 | 進入函式時 / 離開函式時(自動) | 很小:Windows 預設只有 1 MB |
印出各種變數的位址來看看:
/* 程式執行時,不同種類的變數放在記憶體的不同區域。 印出它們的位址來看看(每次執行的位址可能不同,這是作業系統的安全機制 ASLR)。 */#include <stdio.h>#include <stdlib.h>
int global_var = 1; /* 全域變數:放在「全域/靜態資料」區 */
void some_function(void) {} /* 函式本身的機器碼:放在「程式碼」區 */
int main(void) { static int static_var = 2; /* static 區域變數:也在「全域/靜態資料」區 */ int local_var = 3; /* 一般區域變數:在 stack 上 */ int *heap_var = malloc(sizeof(int)); /* malloc 配置的:在 heap 上 */ const char *literal = "hello"; /* 字串常值:在唯讀資料區 */ if (heap_var == NULL) return 1;
printf("code (function) : %p\n", (void *)some_function); printf("rodata (string literal): %p\n", (const void *)literal); printf("data (global) : %p\n", (void *)&global_var); printf("data (static local) : %p\n", (void *)&static_var); printf("heap (malloc) : %p\n", (void *)heap_var); printf("stack (local) : %p\n", (void *)&local_var);
free(heap_var); return 0;}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
code (function) : 00007FF62F7B1470rodata (string literal): 00007FF62F7B3078data (global) : 00007FF62F7B5000data (static local) : 00007FF62F7B5004heap (malloc) : 00000154F0F51F80stack (local) : 00000019E30FF7D4在這台 Windows 電腦上,程式碼、唯讀資料、全域資料都在 00007FF6… 附近,這是執行檔被載入的位置;heap 和 stack 則在完全不同的地方。
課本上常見的圖是「stack 在最高的位址往下長,heap 在低的位址往上長」,那是 Linux 的典型配置,Windows 的實際位置不一樣(上面的結果裡,stack 反而比 heap 低)。要記住的重點是資料分成不同的區域,每個區域的生命週期和大小限制不同,而不是確切的位址。
stack 很小
Section titled “stack 很小”stack 預設只有 1 MB,所以在函式裡宣告大陣列(例如 int a[1000000];,4 MB)會直接 stack overflow。大陣列有三種放法:
- 宣告成全域變數,或加上
static:放在全域/靜態資料區 - 用
malloc(C)或std::vector(C++):放在 heap - 確定很小(幾 KB 以內)才放在 stack
變數的生命週期
Section titled “變數的生命週期”區域變數在進入函式時建立、離開時消失;static 區域變數只在程式開始時建立一次,數值會一直保留:
/* 區域變數每次呼叫都重新建立;static 區域變數只建立一次,數值會一直保留。 */#include <stdio.h>
void count_calls(void) { int local = 0; /* 每次呼叫都從 0 開始 */ static int total = 0; /* 只在程式開始時設成 0 一次 */ local++; total++; printf("local = %d, total = %d\n", local, total);}
int main(void) { count_calls(); count_calls(); count_calls(); return 0;}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
local = 1, total = 1local = 1, total = 2local = 1, total = 3所以不能回傳區域變數的位址,因為函式結束後那塊 stack 空間就被回收、會被下一個函式拿去用:
int *bad(void) { int x = 42; return &x; /* 錯:x 在函式結束時就消失了(clang 會警告) */}另外,沒有給初始值的區域變數內容是垃圾值;沒有給初始值的全域和 static 變數則保證是 0。
main 之前和之後
Section titled “main 之前和之後”- 程式其實不是從
main的第一行開始。在那之前,C 執行環境會先做準備工作,例如準備好stdin/stdout;C++ 還會先執行全域物件的建構子。 main的回傳值會變成程式的結束代碼(exit code)。0 代表成功,沙盒的檢查會把非 0 當成執行錯誤。- 程式結束時,作業系統會收回這個程式用過的全部記憶體。所以短短跑一下的練習程式就算有記憶體洩漏,也不會傷害電腦;但長時間執行的程式(伺服器、遊戲、瀏覽器)洩漏會一直累積,最後把記憶體用光。這就是為什麼要從練習開始養成「配置了就要釋放」的習慣。
- 在函式裡宣告大陣列:超過 stack 大小,程式一開始就 stack overflow。
- 回傳區域變數的位址:函式結束後那塊記憶體已經不屬於它。
- 修改字串常值:
char *s = "hi"; s[0] = 'H';會當掉,因為"hi"在唯讀資料區。要修改的話寫成陣列char s[] = "hi";,它會把內容複製一份到可以寫入的地方。 - 使用沒有初始化的區域變數:內容是垃圾值,每次執行的結果可能都不同。
-Wall通常會警告。
用到這個主題的題目
Section titled “用到這個主題的題目”- 01|Hello, World! — ✔ 已完成
(還沒寫)