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 型別系統的一個「盲區」,注意:
- 引數個數與型別完全靠約定:多取、取錯型別都是 UB,不會有編譯錯誤。
- 傳
NULL給%s:標準未定義(glibc 印(null)是好心,別依賴)。 - 哨兵結尾要小心:用
NULL當結尾哨兵時,呼叫端必須寫(char *)NULL, 因為NULL可能只是0(int),在 64 位平台上大小不同。 - 善用編譯器檢查:自製 printf 風格函式時加上
__attribute__((format(printf, 1, 2))),讓 clang/gcc 幫你檢查格式與引數是否匹配。 - 能不用就不用:固定參數 + 陣列/結構通常更安全;可變參數留給真正的格式化場景。
範例程式
| 檔案 | 內容 | 編譯 / 執行 |
|---|---|---|
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 |
常見錯誤與陷阱
va_arg(ap, float)/va_arg(ap, char):提升規則使這些型別不可能出現, 結果未定義。永遠用double/int收。- 忘記
va_end:在某些 ABI 上va_list會配置資源,不配對呼叫是 UB (x86-64 上常常「恰好沒事」,換平台就出事)。 va_list傳給子函式後繼續用:把ap傳給vprintf之後,ap的狀態不確定, 要再用必須事先va_copy。- 格式字串與引數不同步:
minprintf("%d %s", 42)少一個引數,va_arg讀到垃圾。 編譯器只會檢查標準 printf;自製函式要加 format attribute。 - 用使用者輸入當格式字串:
printf(user_input)是經典漏洞(format string attack), 永遠寫printf("%s", user_input)。
練習題
- 【易】寫
int max_n(int count, ...),回傳 count 個 int 引數中的最大值。 - 【易】寫
double product(int count, ...),計算 count 個 double 的乘積。 - 【中】為
01_minprintf.c加上%o(八進位)、%u(無號)與欄寬支援(如%5d)。 - 【中】實作
char *concat(const char *first, ...):以(char *)NULL為哨兵, 把所有字串串成一個新配置的字串(記得算總長、一次 malloc)。 - 【難】實作
my_snprintf(char *buf, size_t size, const char *fmt, ...): 內部用vsnprintf,並回傳「完整輸出所需長度」,示範截斷偵測。 - 【難】寫一個
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 在暫存器傳參下的實際結構)