跳到內容

程式是怎麼跑起來的

寫好的 .c / .cpp 要經過前置處理 → 編譯 → 組譯 → 連結四個步驟,才會變成 .exe。程式執行時,作業系統把它載入記憶體,資料依種類分別放在程式碼、唯讀資料、全域/靜態資料、heap、stack 幾個區域,每個區域的大小和「存在多久」都不一樣。

按下 Ctrl+Shift+B 時,沙盒會呼叫 clang 完成下面四個步驟:

步驟做什麼自己試試(在 VS Code 的終端機)產出
1. 前置處理處理 # 開頭的指令:把 #include 的檔案內容整個貼進來、把 #define 的名字換成內容clang -E playground\hello.c展開後的原始碼(還是文字)
2. 編譯檢查語法和型態,翻譯成組合語言clang -S playground\hello.chello.s
3. 組譯把組合語言變成機器碼clang -c playground\hello.chello.o(目的檔)
4. 連結把你的 .o 和函式庫接在一起,例如找到 printf 真正的程式碼clang hello.o -o hello.exehello.exe

試試 clang -E,會發現短短幾行的程式被展開成上千行:<stdio.h> 的內容全部貼進來了。

錯誤種類發生在典型訊息常見原因
編譯錯誤步驟 2error: expected ';' after expression語法寫錯、型態不合、用了沒宣告的變數
連結錯誤步驟 4undefined symbol: foo函式只有宣告沒有實作、名字拼錯、沒有 main
執行錯誤執行時Sanitizer 報告、stack overflow編譯和連結都沒問題,但執行時出事,例如越界、除以 0

#include <stdio.h> 只是把 stdio.h 的內容貼進來。標頭檔裡主要是宣告,例如「printf 這個函式存在,參數長這樣」,讓編譯器能檢查你呼叫得對不對。printf 真正的程式碼在 C 執行環境的函式庫裡,到了步驟 4 才接上。

#include <…> 從系統的標頭檔資料夾找;#include "…" 先從原始檔所在的資料夾找,自己寫的標頭檔用這種寫法。

區域放什麼什麼時候出現 / 消失大小
程式碼函式的機器碼程式載入時 / 程式結束時固定
唯讀資料字串常值(例如 "hello")、const 全域常數同上固定;寫入會讓程式當掉
全域 / 靜態資料全域變數、static 變數進入 main 之前就準備好,整個程式執行期間都在編譯時就決定好
heapmalloc / new 配置的記憶體你呼叫 malloc / new 時 / 你呼叫 free / delete 時很大,受實體記憶體限制
stack區域變數、函式參數、函式的返回位址進入函式時 / 離開函式時(自動)很小:Windows 預設只有 1 MB

印出各種變數的位址來看看:

memory_layout.c
/* 程式執行時,不同種類的變數放在記憶體的不同區域。
印出它們的位址來看看(每次執行的位址可能不同,這是作業系統的安全機制 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) : 00007FF62F7B1470
rodata (string literal): 00007FF62F7B3078
data (global) : 00007FF62F7B5000
data (static local) : 00007FF62F7B5004
heap (malloc) : 00000154F0F51F80
stack (local) : 00000019E30FF7D4
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同

在這台 Windows 電腦上,程式碼、唯讀資料、全域資料都在 00007FF6… 附近,這是執行檔被載入的位置;heap 和 stack 則在完全不同的地方。

課本上常見的圖是「stack 在最高的位址往下長,heap 在低的位址往上長」,那是 Linux 的典型配置,Windows 的實際位置不一樣(上面的結果裡,stack 反而比 heap 低)。要記住的重點是資料分成不同的區域,每個區域的生命週期和大小限制不同,而不是確切的位址。

stack 預設只有 1 MB,所以在函式裡宣告大陣列(例如 int a[1000000];,4 MB)會直接 stack overflow。大陣列有三種放法:

  • 宣告成全域變數,或加上 static:放在全域/靜態資料區
  • 用 malloc(C)或 std::vector(C++):放在 heap
  • 確定很小(幾 KB 以內)才放在 stack

區域變數在進入函式時建立、離開時消失;static 區域變數只在程式開始時建立一次,數值會一直保留:

lifetime.c
/* 區域變數每次呼叫都重新建立;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 = 1
local = 1, total = 2
local = 1, total = 3
在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同

所以不能回傳區域變數的位址,因為函式結束後那塊 stack 空間就被回收、會被下一個函式拿去用:

int *bad(void) {
int x = 42;
return &x; /* 錯:x 在函式結束時就消失了(clang 會警告) */
}

另外,沒有給初始值的區域變數內容是垃圾值;沒有給初始值的全域和 static 變數則保證是 0。

  • 程式其實不是從 main 的第一行開始。在那之前,C 執行環境會先做準備工作,例如準備好 stdin / stdout;C++ 還會先執行全域物件的建構子。
  • main 的回傳值會變成程式的結束代碼(exit code)。0 代表成功,沙盒的檢查會把非 0 當成執行錯誤。
  • 程式結束時,作業系統會收回這個程式用過的全部記憶體。所以短短跑一下的練習程式就算有記憶體洩漏,也不會傷害電腦;但長時間執行的程式(伺服器、遊戲、瀏覽器)洩漏會一直累積,最後把記憶體用光。這就是為什麼要從練習開始養成「配置了就要釋放」的習慣。
  • 在函式裡宣告大陣列:超過 stack 大小,程式一開始就 stack overflow。
  • 回傳區域變數的位址:函式結束後那塊記憶體已經不屬於它。
  • 修改字串常值:char *s = "hi"; s[0] = 'H'; 會當掉,因為 "hi" 在唯讀資料區。要修改的話寫成陣列 char s[] = "hi";,它會把內容複製一份到可以寫入的地方。
  • 使用沒有初始化的區域變數:內容是垃圾值,每次執行的結果可能都不同。-Wall 通常會警告。

(還沒寫)