C 沒有安全網——失敗只是一個值
你在前面幾個階段學會了一支程式究竟如何執行:它如何用一次系統呼叫請求核心開啟檔案、malloc() 如何從堆積上切出記憶體、一個行程又如何透過檔案描述符與世界對話。這每一筆請求都可能被拒絕。磁碟滿了、檔案不存在、記憶體耗盡、網路斷了。本階段賴以成立的關鍵事實是:C 在設計上,並沒有任何內建機制強迫你去注意。沒有拋出的例外、沒有自動的堆疊回退、沒有會把你攔下的執行期。如果一次呼叫失敗了而你沒去看,你的程式就只是手裡握著一個錯誤的值,繼續向前行軍。
這使得 C 裡的錯誤處理成為一件頭等大事,而非事後補充:因為語言不會替你做,所以「你」必須在每一次呼叫處,用普通的程式碼明確地去做。這聽起來像個負擔,起初也確實是——但它同時是一種誠實。在 C 裡,處理錯誤本身就是寫程式的一部分,而不是事後才加上的裝飾。一個做正事的函式,和一個處理它失敗的函式,並不是兩支程式;它們是同一支被好好寫出來的程式。從你下筆的第一行起,就把失敗路徑當成頭等的程式碼來對待,本階段接下來的一切——清理、斷言、優雅降級——才有一塊堅實的地面可以站。
兩條管道:回傳值與 errno
當一個 C 函式庫或系統呼叫失敗時,它得告訴你兩件不同的事,而它為這兩件事各用了一條不同的管道。第一條管道是回傳值:一個在頻道內、單一的值,說的是「這件事成功了還是失敗了?」第二條管道是 errno,一個全域整數,函式庫把它設好以說明「如果失敗了,原因是什麼?」把這兩者分清楚,是本篇的核心,因為它們回答的是不同的問題,而你在不同的時刻去讀它們。回傳值是你的閘門;errno 則是你只有在閘門告訴你出事之後,才去取的那份解釋。
回傳值是一個哨兵值:一個特別挑出來、落在正常範圍之外、代表「失敗」的值。每個呼叫各自載明自己的哨兵值,而它們確實不一樣。open() 成功時回傳一個檔案描述符,失敗時回傳 -1。malloc() 成功時回傳指向你那塊記憶體的指標,失敗時回傳 NULL。read() 回傳讀到的位元組數,出錯時回傳 -1。fork() 回傳一個 pid,失敗時回傳 -1。並沒有單一的通用慣例——而這正是為什麼你必須去讀每個呼叫的手冊頁、學會它特定的哨兵值,而不是用猜的。
現在來談微妙之處,也是關於 errno 最常見的那一個錯誤。errno 是一個全域變數,會被「下一個失敗的呼叫」覆寫,而許多成功的呼叫則維持它原樣、不去動它。所以 errno 只有在一個精確的瞬間才帶有意義:就在某個呼叫透過它的回傳值告訴你它失敗了之後。規則很明確:先檢查回傳值;只有當它示意失敗時,你才去讀 errno;而且要在你發出任何其他函式庫呼叫「之前」就讀,因為那下一個呼叫可能會把它蓋掉。在回傳值說成功時去讀 errno 是沒有意義的——它可能還留著幾分鐘前的過時碼。
the two channels, read in the right order:
int fd = open("data.txt", O_RDONLY);
if (fd == -1) { /* channel 1: the sentinel says FAIL */
int e = errno; /* channel 2: read errno NOW, save it */
fprintf(stderr, "open: %s\n", strerror(e));
/* strerror(e) turns e.g. ENOENT into "No such file or directory" */
return -1;
}
/* fd is valid here, and ONLY here */
perror("open") does the same as the strerror line above, in one call.為什麼「檢查每一次呼叫」不是疑神疑鬼
你會很想只檢查你認為有風險的那些呼叫,其餘的就揮手放行。這是個陷阱,而它有個名字:未檢查錯誤的代價。危險之處並不在於一個未檢查的呼叫會立刻崩潰——要是它會就好了。真正的危險在於,它回傳一個「看起來合理的錯誤值」,而你的程式高高興興地拿去用,於是那場毀壞在很遠的地方、很久之後,在一段完全無辜的程式碼裡才浮現。一個你沒檢查的 open() 回傳了 -1;你把 -1 當成檔案描述符、對它 read();那次 read 也失敗了;你把空緩衝區當成資料來處理——再過三個函式,某處就印出了亂碼,而對於它從哪開始毫無線索。
這就是為什麼這份紀律是檢查每一個可能失敗的呼叫,而不只是那些嚇人的。每個人都該刻進肌肉記憶的經典例子,是檢查 malloc:malloc() 在無法給你記憶體時回傳 NULL,若你略過檢查、去解參考那個 NULL,幸運時你會立刻得到一個區段錯誤——不幸時則是無聲的堆積毀壞。這個檢查只有兩行,它把一顆看不見的定時炸彈,換成了一次乾淨、有座標的失敗。這份不對稱壓倒性地懸殊:檢查讓你付出一個分支;不檢查則讓你在某個無從得知的日後,付出一整場除錯,或一個資安漏洞。
函式庫呼叫、系統呼叫,以及 errno 從何而來
知道「誰」設定了 errno 是有幫助的,因為它釐清了哪些呼叫遵守這些規則。回想 OS 階段所學:像 open() 或 read() 這樣的呼叫並不是普通的函式呼叫——它是一層薄薄的 C 包裝,包著一個真正會穿越進核心的系統呼叫。核心自己透過暫存器裡一個小小的負數來回報失敗;libc 包裝接住那個數,把 errno 設成對應的正值碼,再把 -1 交還給你。所以 errno 是 libc 與 POSIX 把核心錯誤浮現到 C 裡的慣例,整個 POSIX 呼叫家族——open()、read()、write()、close()、fork() 及其親族——都共用它。
然而並非每個函式都用 errno,假設它有用就是個悄無聲息的臭蟲。不碰核心的純 C 函式庫函式,往往只透過回傳值來回報失敗——strtol() 用回傳值示意溢位,而某些數學函式則是少數確實會設定 errno 的。更高層的函式有時則完全自創一套方案:回傳一個小小的列舉錯誤碼、或以 0 代表成功、非零代表某個錯誤碼。實務上的收穫是:在你呼叫每個函式之前,先讀它的說明文件並回答三個問題——它的哨兵值是什麼、它會不會設定 errno、它在失敗時留下了什麼?永遠別假設;這些慣例是一塊拼湊的補丁。
還有一個值得帶著走的小小執行緒安全要點。在任何現代系統上,errno 其實並不是一個共享的全域變數——它是每執行緒各一份的,因此一個執行緒裡的失敗,不會打亂另一個執行緒的錯誤碼。你不需要做任何特別的事就能得到這點;系統標頭會替你安排好。但這又多給了一個理由,說明那條「時機規則」為何成立:在失敗的呼叫之後立刻、在同一個執行緒裡、在其他任何東西執行之前就讀 errno,它便會告訴你真相。
偵測到失敗之後:處理、傳遞,或中止
偵測到錯誤只是工作的一半;另一半是決定該拿它怎麼辦。恰好有三種誠實的回應,把它們命名出來能讓你的程式碼保持清晰:處理、傳遞,或中止。處理它,意思是你能就地復原——檔案不見了,就退回一個預設值;連線斷了,就重試。傳遞它,意思是這個函式無法定奪,於是它向它的呼叫者回傳自己的失敗哨兵值,讓一個擁有更多脈絡的層級去抉擇。中止,意思是這個錯誤反映了一個根本到「再走下去就不安全」的假設破裂,於是你記錄下來、停手。
「傳遞」正是把一堆經過檢查的呼叫,變成一支健壯程式的關鍵,也是通往後面數篇的橋樑。一個低層函式偵測到失敗、回傳 -1;它的呼叫者看見那個 -1,做完它自己該負責的清理,再依樣回傳 -1;於是失敗沿著呼叫堆疊往上爬,直到它抵達一個能有意義地反應的層級——向使用者印出訊息、重試、或以一個非零的結束狀態退出。這種刻意把錯誤往上交的動作,就是錯誤傳遞,你會在〈可復原的錯誤 vs 臭蟲〉那一篇正面再遇到它,那篇會把「每種情境該得到處理、傳遞、還是中止的哪一個」磨得更利。
但傳遞製造了一個本篇必須交棒、而非解決的問題:當一個函式在半途回傳 -1 時,它先前已經開好的檔案、已經配置好的記憶體,怎麼辦?不關閉它們就提早返回,會洩漏資源。這正是緊接著下一篇〈清理:goto-cleanup 與 RAII〉的主題,那篇裡,傳遞這條路徑會學會在離場途中乾淨地回收它自己的資源。眼下,根基已經就位:在每一次呼叫透過回傳值偵測失敗、僅在 errno 有意義時去讀它、並有意識地選擇處理、傳遞或中止。以此為基,本階段裡的每一項健壯性技巧,都有了一個可以掛靠的地方。