程式與外界溝通最古老、最普遍的介面就是命令列引數。Unix 哲學中「小工具 + 管線」的組合能力, 正建立在每個程式都能透過 argc/argv 接收參數之上。本單元對應 K&R §5.10, 從 echo 範例出發,實作簡易版 grep,並補充現代選項解析(getopt)與環境變數(getenv)。

學習目標

  • 理解 argc/argv 的記憶體結構與慣例(argv[0]、argv[argc] == NULL)
  • 能以指標運算走訪引數(K&R 風格 while (--argc > 0))
  • 實作接受「選項旗標 + 位置參數」的程式(如 grep -n -v pattern)
  • 會使用 POSIX getopt 解析 -x、-o value 形式的選項
  • 會用 getenv 讀取環境變數,理解環境變數與引數的差別

對照章節 / 參考來源

  • K&R §5.10 Command-line Arguments(echo 與 pattern-finding 程式)
  • C11 標準 §5.1.2.2.1(main 的兩種簽名與 argv 的保證)
  • POSIX:man 3 getopt、man 3 getenv
  • POSIX Utility Syntax Guidelines(選項設計慣例)

觀念講解

1. argc / argv 的結構

作業系統啟動程式時,把命令列切成字串陣列交給 main:

int main(int argc, char *argv[])
  • argc(argument count):引數個數,至少為 1(含程式名稱本身)。
  • argv(argument vector):字串指標陣列;argv[0] 慣例上是程式名稱, argv[argc] 標準保證是 NULL。

執行 ./prog -n hello 時的記憶體圖:

argc = 3
argv ──> [ptr0] ──> "./prog\0"
         [ptr1] ──> "-n\0"
         [ptr2] ──> "hello\0"
         [NULL]

argv 的型別是 char **(指標的指標),與 char *argv[] 在參數位置等價。 標準允許程式修改 argv 指向的字串內容(getopt 就會重排它)。

2. 走訪引數的兩種風格

/* 索引風格:直觀 */
for (int i = 1; i < argc; i++)
    printf("%s\n", argv[i]);

/* K&R 指標風格:echo 範例的寫法 */
while (--argc > 0)
    printf("%s%s", *++argv, argc > 1 ? " " : "");

*++argv:先把 argv 往後移一格(跳過程式名),再取出該指標。 這種寫法緊湊但要小心副作用的順序,面試與讀舊碼時常見。

3. K&R 的 pattern-finding(簡易 grep)

K&R §5.10 把 §4.1 的「找出含有指定模式的行」改造成從命令列讀取模式:

while (getline(line, MAXLINE) > 0)
    if (strstr(line, pattern) != NULL) {
        printf("%s", line);
        found++;
    }

再加上兩個選項:-x(反相:印出「不含」模式的行)與 -n(印行號)。 選項解析的骨架是雙層迴圈——外層走引數、內層走一個引數內的多個旗標字母 (支援 -nx 合併寫法):

while (--argc > 0 && (*++argv)[0] == '-')
    while ((c = *++argv[0]) != '\0')
        switch (c) {
        case 'x': except = 1; break;
        case 'n': number = 1; break;
        default:  /* 未知選項 */ ;
        }

注意 (*++argv)[0] 與 *++argv[0] 的不同:前者移動「引數陣列的游標」, 後者移動「目前字串內的游標」——正是上一單元複雜宣告的實戰。

4. POSIX getopt

手寫解析容易漏掉邊角(合併旗標、-o value 與 -ovalue、-- 終止符)。 POSIX 的 getopt 幫你處理這些慣例:

#include <unistd.h>
int opt;
while ((opt = getopt(argc, argv, "nvf:")) != -1) {
    switch (opt) {
    case 'n': line_numbers = 1; break;
    case 'v': invert = 1;       break;
    case 'f': filename = optarg; break;   /* f: 表示需要參數 */
    case '?': /* 未知選項,getopt 已印錯誤訊息 */ break;
    }
}
/* optind 是第一個「非選項」引數的索引 */

optstring 中字母後面的 : 表示該選項需要參數,參數會放在全域變數 optarg。 解析結束後 optind 之後才是位置參數。長選項(--verbose)需用 GNU getopt_long。

5. 環境變數 getenv

環境變數是另一條「由外向內」傳遞設定的通道,適合放不常變動的組態 (如 HOME、PATH、LANG):

const char *home = getenv("HOME");
if (home == NULL)
    home = "/tmp";      /* 查不到時務必有預設值 */

引數 vs 環境變數的取捨:一次性、針對單次執行的用引數; 跨多個程式共享、屬於「執行環境」性質的用環境變數。

範例程式

檔案 內容 編譯 / 執行
01_echo_args.c argc/argv 結構解剖、索引與 K&R 指標兩種走訪風格 cc -std=c11 -Wall -Wextra -o out 01_echo_args.c && ./out foo bar
02_minigrep.c 簡易 grep:-x/-n 手寫旗標解析;自建 /tmp 測試檔示範 cc -std=c11 -Wall -Wextra -o out 02_minigrep.c && ./out
03_getopt_env.c getopt 解析 -n/-v/-f file、getenv 讀環境變數 cc -std=c11 -Wall -Wextra -o out 03_getopt_env.c && ./out

三個程式在「不給任何引數」時都會用內建的示範資料自我展示; 你也可以自行加上引數觀察行為變化(例如 ./out -n -x main)。

常見錯誤與陷阱

  1. 假設 argc >= 2 而直接使用 argv[1]:使用者沒給引數時 argv[1] 是 NULL, 對它做 strcmp 直接 segfault。永遠先檢查 argc。
  2. 把 argv[0] 當成「第一個引數」:argv[0] 是程式名稱,實際引數從 argv[1] 開始。 注意某些環境下 argv[0] 可能是完整路徑,也可能只是名字。
  3. 混淆 (*++argv)[0] 與 *++argv[0]:後綴 [] 優先權高於前綴 * 與 ++, *++argv[0] 解析為 *++(argv[0]),移動的是字串內部游標。
  4. getopt 之後仍從 argv[1] 讀位置參數:解析後應該從 argv[optind] 開始, 而且 getopt 可能已經重排過 argv。
  5. 修改 getenv 回傳的字串:標準規定不可修改,且後續呼叫可能覆寫同一塊緩衝, 需要保留就 strdup 一份。

練習題

  1. 【易】寫一個 args_dump 程式,逐行印出 argc、每個 argv[i] 的索引、內容與長度。
  2. 【易】實作 sum 程式:把所有引數當整數相加(用 strtol), 遇到非數字引數時印出警告並跳過。
  3. 【中】為 02_minigrep.c 加上 -c 選項:只印出匹配行數,不印內容(模仿 grep -c)。
  4. 【中】用 getopt 寫一個 head 複製品:支援 -n N(預設 10), 從自建的 /tmp 測試檔讀取並印出前 N 行。
  5. 【難】實作 mini_env:不帶引數時印出 environ 中所有環境變數; 帶 NAME=value 形式引數時設定後執行後續命令(提示:extern char **environ、setenv、execvp)。
  6. 【難】手寫支援長選項的解析器:接受 --number、--invert、--file=NAME, 並支援 -- 之後全部視為位置參數。

補充:選項設計的實務慣例

設計自己的命令列工具時,遵循 POSIX / GNU 慣例可以讓使用者「猜得到」用法:

  • 短選項一個字母(-n)、可合併(-nx);長選項雙破折號(--number)。
  • 需要值的選項同時支援 -f file 與 -f=file/--file=name 兩種寫法。
  • -- 之後的引數一律視為位置參數(即使長得像選項), 這是操作「檔名以 - 開頭」的唯一安全方法:rm -- -rf。
  • 無引數執行時:過濾器類工具應讀 stdin(如 grep、sort), 其他工具印出簡短 usage 並以非零碼離開。
  • 離開碼慣例:0 成功、1 一般錯誤、2 用法錯誤(grep 則以 1 表示無匹配)。
  • 訊息去向:正常輸出走 stdout(可被管線接走), 錯誤與診斷一律走 stderr(fprintf(stderr, ...))。

延伸閱讀

  • K&R §5.10;§4.1(pattern matching 的原始版本)
  • man 3 getopt、man 3 getopt_long(GNU 擴充)
  • POSIX.1-2017 XBD §12 Utility Conventions(選項語法慣例的官方定義)
  • 《The Art of Unix Programming》(E. S. Raymond) 第 10 章:命令列介面設計
  • musl libc 的 getopt.c 原始碼(不到百行,適合閱讀)