C 語言如何變成組合語言?
從前置處理與連結,到 x86-64 呼叫慣例和完整 stdio.h 流程
C 語言如何變成組合語言?
本部落格文章以教學為目的。編譯器版本、作業系統、ABI 與最佳化選項都可能改變輸出,重要內容請在自己的環境中再次確認。
執行 gcc -S file.c 很簡單,閱讀產生的 .s 檔卻不一定容易。#include <stdio.h> 消失了,printf 的龐大實作只剩一行 call,Linux 與 Windows 範例使用的引數暫存器也不相同。
與其背誦單一指令,不如先理解 C 原始碼成為執行檔的完整過程。本文依序介紹前置處理、編譯、組譯、連結、x86-64 呼叫慣例、最佳化,以及 stdio.h 的 45 個核心函式。
除非另外說明,範例以 x86-64 Linux、System V ABI 與 Intel 語法為準。C 標準本身不指定 CPU 或組合語言語法。
從原始碼到執行的五個階段
- 前置處理器處理
#include、#define與#if等指令。 - 編譯器分析處理後的 C 程式並產生目標 CPU 的組合語言。
- 組譯器把指令與中繼資料編碼成目的檔。
- 連結器解析符號,將目的檔與函式庫組成執行檔。
- 作業系統載入器把執行檔與共享函式庫配置到記憶體。
一般所說的「把 C 轉成組合語言」,通常是停在第二階段並保留 .s 或 .asm 檔。
最小觀察範例
#include <stdio.h>
static int add(int left, int right)
{
return left + right;
}
int main(void)
{
int value = add(20, 22);
printf("value = %d\n", value);
return 0;
}
這段程式同時包含函式呼叫、區域變數、字串常值與可變引數函式,但仍小到可以逐行觀察。
讓 GCC 停在各個階段
gcc -std=c17 -E stdio_trace.c -o stdio_trace.i
gcc -std=c17 -O0 -fno-omit-frame-pointer -S -masm=intel -fverbose-asm stdio_trace.c -o stdio_trace.s
gcc -std=c17 -c stdio_trace.c -o stdio_trace.o
gcc stdio_trace.o -o stdio_trace
-E 保留前置處理結果,-S 保留組合語言,-c 在產生目的檔後停止。-masm=intel 選擇 Intel 語法。學習時可比較 -O0 與 -O2,前者較容易對照原始碼,後者能看見實際最佳化結果。
gcc -std=c17 -O2 -save-temps=obj stdio_trace.c -o stdio_trace
-save-temps=obj 可以一次保留多個中間檔。
Clang 與 MSVC
clang -std=c17 -O0 -S -masm=intel stdio_trace.c -o stdio_trace.s
clang -std=c17 -O2 -S -emit-llvm stdio_trace.c -o stdio_trace.ll
LLVM IR 不是 CPU 組合語言,而是最終指令選擇前的中間表示。
MSVC 可在 Developer Command Prompt 中產生組合語言列表。
cl /nologo /std:c17 /Od /FAs /Fa:stdio_trace.asm stdio_trace.c
cl /nologo /std:c17 /O2 /FAs /Fa:stdio_trace_opt.asm stdio_trace.c
編譯器列表與反組譯並不相同
編譯器輸出的組合語言仍可能包含標籤、區段、指示詞與原始碼註解。反組譯則從已編碼的目的檔或執行檔中讀取指令。
objdump -d -Mintel stdio_trace.o
objdump -d -Mintel stdio_trace
連結後可能出現動態連結跳板與重定位後位址。除錯符號能保留較多名稱,但不能還原完整的 C 原始碼。
Intel 語法的最低閱讀方式
Intel 語法通常先寫目的運算元。
mov eax, 42
add eax, 8
cmp eax, 50
je .equal
mov 搬移資料,lea 計算位址運算式,cmp 設定旗標,條件跳躍再讀取旗標。call 儲存返回位址並轉移控制,ret 回到呼叫端。Intel 語法中的方括號表示記憶體存取。
讀取未最佳化的 add
System V 的除錯型建置可能產生如下程式碼。
add:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret
前兩個整數引數位於 edi 與 esi,回傳值放在 eax。最佳化後,堆疊儲存可能消失,函式可縮成一個 lea,甚至完全被內嵌。
System V x86-64 通常用 rdi、rsi、rdx、rcx、r8、r9 傳遞前六個整數或指標引數,以 rax 傳回整數結果,浮點值則使用 XMM 暫存器。
Windows x64 通常使用 rcx、rdx、r8、r9 傳遞前四個引數,呼叫端還要保留 32 位元組 shadow space。閱讀暫存器前必須先確認目標 ABI。
printf 呼叫如何出現
mov esi, 42
lea rdi, .LC0[rip]
xor eax, eax
call printf@PLT
rdi 取得格式字串位址,esi 取得整數 42。System V 的可變引數規則還利用 al 傳達浮點向量暫存器數量,所以此處把 eax 清為 0。printf@PLT 是動態連結入口,不是 printf 的完整實作。
最佳化會讓 C 的外形消失
編譯器可以內嵌 add、在編譯期算出 20 + 22、不替 value 配置堆疊空間、刪除不可到達的程式碼,也可能將固定的 printf("hello\n") 改成 puts。只要符合語言規則下的可觀察行為,這些轉換都是允許的。
<stdio.h> 真正提供什麼
包含標頭不會把完整 I/O 函式庫貼進原始碼。前置處理結果主要包含型別、巨集與函式宣告,實作本體通常存在於 C 執行階段函式庫。
核心抽象是以 FILE * 表示的串流。FILE 可保存緩衝區、檔案位置、EOF、錯誤與同步狀態,但內部成員屬於實作細節。程式啟動時會有 stdin、stdout 與 stderr。
重要型別包括 FILE、fpos_t、size_t。常見巨集包括 EOF、NULL、BUFSIZ、FOPEN_MAX、FILENAME_MAX、L_tmpnam、TMP_MAX、_IOFBF、_IOLBF、_IONBF、SEEK_SET、SEEK_CUR、SEEK_END。
45 個核心函式
檔案操作,4 個
| 函式 | 用途 |
|---|---|
remove | 依名稱刪除檔案 |
rename | 重新命名檔案 |
tmpfile | 建立暫存二進位串流 |
tmpnam | 產生暫存名稱,但很難安全使用 |
真實程式應優先使用能原子化建立並開啟唯一檔案的平台安全 API,避免 tmpnam 的競爭條件。
開啟、關閉與緩衝,6 個
fclose、fflush、fopen、freopen、setbuf、setvbuf。
常用模式為 r、w、a、r+、w+、a+,需要時加上 b。w 會截斷既有內容,所有開啟結果都要檢查。可攜式 C 不使用 fflush(stdin) 丟棄輸入。
格式化輸出,8 個
fprintf、printf、sprintf、snprintf、vfprintf、vprintf、vsprintf、vsnprintf。
轉換規格大致為 %[flags][width][.precision][length]specifier。格式與實際引數型別必須一致,不能把不受信任的文字直接當作格式字串。可變引數中 float 會提升為 double,較窄的整數型別通常提升為 int。
格式化輸入,6 個
fscanf、scanf、sscanf、vfscanf、vscanf、vsscanf。
轉換目標通常必須是可寫入且型別正確的指標。%s 要限制欄位寬度,並檢查表示成功賦值數量的回傳值。互動輸入經常適合先用 fgets 讀一整行,再分開解析。
字元與字串 I/O,10 個
fgetc、fgets、fputc、fputs、getc、getchar、putc、putchar、puts、ungetc。
讀取字元時應存入 int,才能區分所有 unsigned char 值與 EOF。
區塊 I/O,2 個
fread 與 fwrite 以物件陣列為單位傳輸。回傳值是完整傳輸的元素數,不一定是位元組數。直接寫入結構體會包含 padding、位元組順序與型別大小差異,不是可攜式序列化格式。
檔案位置,5 個
fgetpos、fseek、fsetpos、ftell、rewind。文字串流位置有額外的可攜性限制,大型檔案也常需要平台提供的 64 位元擴充 API。
狀態與診斷,4 個
clearerr、feof、ferror、perror。只有在讀取嘗試超過檔尾後才知道 EOF,因此不要用 while (!feof(file)) 控制迴圈,應以讀取函式的結果為準。
Annex K 與平台擴充
部分實作提供 fopen_s、fprintf_s、scanf_s 等函式,但它們不是所有 C 環境都支援的核心替代品。Microsoft 的 _s 函式也有自己的參數規則。說明 API 時應區分 ISO C、POSIX 與 Windows 擴充。
標準 I/O 不等於每次一個系統呼叫
printf 通常先在使用者空間格式化資料並更新 FILE 緩衝區,之後 flush 才可能呼叫 POSIX write 等 OS API。緩衝可減少昂貴的核心切換。
getc 與 putc 允許以巨集實作,快速路徑可能直接展開。沒有看到 call getc 不代表 C 操作消失了。
二進位檔案複製的基本迴圈
unsigned char buffer[64 * 1024];
size_t read_count;
while ((read_count = fread(buffer, 1, sizeof buffer, source)) > 0)
{
size_t written = 0;
while (written < read_count)
{
size_t count = fwrite(buffer + written, 1,
read_count - written, destination);
if (count == 0)
{
break;
}
written += count;
}
}
if (ferror(source))
{
perror("fread");
}
完整程式還要檢查兩次 fopen、部分寫入、ferror,以及 fclose 時才出現的輸出錯誤。
適合的學習順序
先觀察只有整數引數與回傳值的函式,再比較 -O0 與 -O2。接著一次只增加一項:區域陣列、一般函式呼叫、printf。比較目的檔與連結後執行檔,最後在 Windows x64 等另一種 ABI 上重做。
常見錯誤
- 認為一段 C 程式只有唯一的組合語言結果。
- 把
-O0當成逐行機械翻譯。 - 格式指定符與引數型別不一致。
- 把使用者輸入當作
printf格式字串。 scanf("%s", buffer)沒有限制寬度。- 使用
while (!feof(file))。 - 在可攜式程式依賴
fflush(stdin)。 - 忽略輸出串流的
fclose失敗。 - 把結構體原始位元組當成可攜式格式。
- 從未定義行為推導一般組合語言規則。
一行 printf 到實際輸出
<stdio.h> 提供宣告,編譯器依 ABI 配置引數,組譯器編碼含符號參照的指令,連結器或載入器把符號接到 C 執行階段。執行時函式庫格式化資料、更新串流緩衝區,最後在需要時請求 OS 寫出位元組。
參考資料
- ISO C 工作草案 N1570
- GCC code generation options
- Clang command-line reference
- MSVC 0
- System V AMD64 ABI
- Microsoft x64 calling convention
- GNU objdump
總結
C 程式經過前置處理、編譯、組譯、連結與載入才執行。組合語言結果取決於目標 ABI 與最佳化。<stdio.h> 提供宣告與巨集,C 執行階段則提供真正的串流實作。把這些層次分開後,指令、呼叫、緩衝與 OS API 就能連成一條可理解的路徑。