printf 為什麼可以接受任意個數、任意型別的引數?答案是 C 的可變參數機制: <stdarg.h> 提供的 va_list 家族。本單元對應 K&R §7.3 的 minprintf 範例, 帶你親手實作一個迷你 printf,並徹底理解「預設引數提升」這個可變參數最重要的規則。

學習目標

  • 會使用 va_list、va_start、va_arg、va_end、va_copy 五件套
  • 能實作 K&R §7.3 的 minprintf(走訪格式字串、逐一取引數)
  • 能設計自己的可變參數函式(sum、average、字串串接等)
  • 理解預設引數提升(default argument promotions):char/short 升 int、float 升 double
  • 認識可變參數的型別安全缺口與防禦方法(哨兵、計數、格式字串檢查)

對照章節 / 參考來源

  • K&R §7.3 Variable-length Argument Lists(minprintf)
  • C11 標準 §7.16(stdarg.h)、§6.5.2.2(函式呼叫與引數提升)
  • man 3 stdarg
  • GCC/Clang 的 __attribute__((format(printf, m, n))) 文件

觀念講解

1. 宣告與基本流程

可變參數函式至少要有一個具名參數,之後接 ...:

#include <stdarg.h>

int sum_n(int count, ...)          /* count 告訴我們後面有幾個引數 */
{
    va_list ap;                    /* 引數游標 */
    va_start(ap, count);           /* 從「最後一個具名參數」之後開始 */

    int total = 0;
    for (int i = 0; i < count; i++)
        total += va_arg(ap, int);  /* 每次取一個 int,游標自動前進 */

    va_end(ap);                    /* 必須配對呼叫,否則是 UB */
    return total;
}

四個巨集的分工:

巨集 作用
va_start(ap, last) 初始化游標,last 是最後一個具名參數
va_arg(ap, type) 以 type 取出下一個引數並前進
va_end(ap) 清理;與 va_start 必須成對
va_copy(dst, src) 複製游標狀態(要走訪兩次時用;C99 起)

2. 關鍵規則:預設引數提升

傳入 ... 部分的引數會經過預設引數提升:

  • char、short(含無號版)→ 提升為 int(或 unsigned int)
  • float → 提升為 double

因此 va_arg 絕對不能寫 va_arg(ap, char)、va_arg(ap, short)、va_arg(ap, float) ——這些型別根本不可能出現在引數堆疊上,行為未定義。正確做法:

int   c = va_arg(ap, int);      /* 呼叫端傳 char:以 int 收 */
double f = va_arg(ap, double);  /* 呼叫端傳 float:以 double 收 */

這也解釋了為什麼 printf 的 %f 同時用於 float 與 double——到達 printf 時早已都是 double。 (但 scanf 的 %f 與 %lf 必須區分——指標不會被提升!)

3. K&R minprintf 的骨架

void minprintf(const char *fmt, ...)
{
    va_list ap;
    const char *p;
    va_start(ap, fmt);
    for (p = fmt; *p; p++) {
        if (*p != '%') { putchar(*p); continue; }
        switch (*++p) {
        case 'd': printf("%d", va_arg(ap, int));         break;
        case 'f': printf("%f", va_arg(ap, double));      break;
        case 's': fputs(va_arg(ap, char *), stdout);     break;
        default:  putchar(*p);                           break;
        }
    }
    va_end(ap);
}

核心思想:格式字串是引數型別的「密碼本」。函式自己完全不知道呼叫端傳了什麼, 只能相信格式字串的描述。密碼本寫錯(%d 對到 char*)就是未定義行為—— 這是 printf 家族所有安全問題的根源。

4. 走訪兩次:va_copy

va_list 走過一遍後狀態已耗盡,要再走一次必須先複製:

va_list ap, ap2;
va_start(ap, fmt);
va_copy(ap2, ap);          /* 先複製再消耗 */
int n = count_args(ap);    /* 第一輪:算長度 */
render(ap2);               /* 第二輪:真正輸出 */
va_end(ap2);
va_end(ap);                /* 每個 va_copy/va_start 都要配對 va_end */

典型場景:vsnprintf 先算需要的緩衝區大小、再實際格式化。

5. 安全注意事項

可變參數是 C 型別系統的一個「盲區」,注意:

  1. 引數個數與型別完全靠約定:多取、取錯型別都是 UB,不會有編譯錯誤。
  2. 傳 NULL 給 %s:標準未定義(glibc 印 (null) 是好心,別依賴)。
  3. 哨兵結尾要小心:用 NULL 當結尾哨兵時,呼叫端必須寫 (char *)NULL, 因為 NULL 可能只是 0(int),在 64 位平台上大小不同。
  4. 善用編譯器檢查:自製 printf 風格函式時加上 __attribute__((format(printf, 1, 2))),讓 clang/gcc 幫你檢查格式與引數是否匹配。
  5. 能不用就不用:固定參數 + 陣列/結構通常更安全;可變參數留給真正的格式化場景。

範例程式

檔案 內容 編譯 / 執行
01_minprintf.c K&R §7.3 minprintf 完整實作(含 %d %f %s %c %x %%) cc -std=c11 -Wall -Wextra -o out 01_minprintf.c && ./out
02_sum_average.c 計數式 sum/average、哨兵式字串串接、va_copy 兩次走訪 cc -std=c11 -Wall -Wextra -o out 02_sum_average.c && ./out
03_promotions.c 引數提升實驗:char/short/float 傳入後的實際型別觀察 cc -std=c11 -Wall -Wextra -o out 03_promotions.c && ./out

常見錯誤與陷阱

  1. va_arg(ap, float) / va_arg(ap, char):提升規則使這些型別不可能出現, 結果未定義。永遠用 double/int 收。
  2. 忘記 va_end:在某些 ABI 上 va_list 會配置資源,不配對呼叫是 UB (x86-64 上常常「恰好沒事」,換平台就出事)。
  3. va_list 傳給子函式後繼續用:把 ap 傳給 vprintf 之後,ap 的狀態不確定, 要再用必須事先 va_copy。
  4. 格式字串與引數不同步:minprintf("%d %s", 42) 少一個引數,va_arg 讀到垃圾。 編譯器只會檢查標準 printf;自製函式要加 format attribute。
  5. 用使用者輸入當格式字串:printf(user_input) 是經典漏洞(format string attack), 永遠寫 printf("%s", user_input)。

練習題

  1. 【易】寫 int max_n(int count, ...),回傳 count 個 int 引數中的最大值。
  2. 【易】寫 double product(int count, ...),計算 count 個 double 的乘積。
  3. 【中】為 01_minprintf.c 加上 %o(八進位)、%u(無號)與欄寬支援(如 %5d)。
  4. 【中】實作 char *concat(const char *first, ...):以 (char *)NULL 為哨兵, 把所有字串串成一個新配置的字串(記得算總長、一次 malloc)。
  5. 【難】實作 my_snprintf(char *buf, size_t size, const char *fmt, ...): 內部用 vsnprintf,並回傳「完整輸出所需長度」,示範截斷偵測。
  6. 【難】寫一個 log_msg(int level, const char *fmt, ...): 加上時間戳與等級字首後轉呼 vfprintf(stderr, ...), 並用 format attribute 讓編譯器檢查呼叫端。

補充:v 系列函式與包裝器模式

標準函式庫的每個 printf 家族成員都有一個「接受 va_list」的攣生兄弟:

一般版 va_list 版 輸出到
printf vprintf stdout
fprintf vfprintf 任意 FILE*
snprintf vsnprintf 字元緩衝區(含長度保護)

自製「printf 風格」函式的標準骨架永遠是三層:

void log_error(const char *fmt, ...)      /* 1. 對外介面:收 ... */
{
    va_list ap;
    va_start(ap, fmt);
    vlog(LOG_ERROR, fmt, ap);             /* 2. 轉交 va_list */
    va_end(ap);
}
/* 3. vlog 內部呼叫 vfprintf/vsnprintf 完成真正的格式化 */

這個模式的好處:格式化邏輯只寫一次, 所有變體(帶等級、帶時間戳、寫檔案、寫網路)都只是薄薄的外殼。 記住鐵律:va_list 交給 v 函式之後狀態即不確定, 同一個 ap 不可再次使用(要用就先 va_copy)。

延伸閱讀

  • K&R §7.3;附錄 B §B7(stdarg 巨集規格)
  • C11 標準 §7.16 Variable arguments
  • man 3 vsnprintf(v 系列函式:接受 va_list 的版本)
  • CWE-134: Use of Externally-Controlled Format String(格式字串攻擊)
  • System V AMD64 ABI 文件(了解 va_list 在暫存器傳參下的實際結構)