← SEARCHABLE ARCHIVE
Assembly · 2026-08-01

C 語言如何變成組合語言?

從前置處理與連結,到 x86-64 呼叫慣例和完整 stdio.h 流程

INTERACTIVE READING MODE →

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 或組合語言語法。

從原始碼到執行的五個階段

  1. 前置處理器處理 #include、#define 與 #if 等指令。
  2. 編譯器分析處理後的 C 程式並產生目標 CPU 的組合語言。
  3. 組譯器把指令與中繼資料編碼成目的檔。
  4. 連結器解析符號,將目的檔與函式庫組成執行檔。
  5. 作業系統載入器把執行檔與共享函式庫配置到記憶體。

一般所說的「把 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 寫出位元組。

參考資料

總結

C 程式經過前置處理、編譯、組譯、連結與載入才執行。組合語言結果取決於目標 ABI 與最佳化。<stdio.h> 提供宣告與巨集,C 執行階段則提供真正的串流實作。把這些層次分開後,指令、呼叫、緩衝與 OS API 就能連成一條可理解的路徑。