JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

errno 與錯誤回報

第 1 到 3 篇講了一次呼叫如何越界進入核心、又如何回來。但核心也可能帶著「失敗」回來——磁碟滿了、檔案不在、你沒有權限。這篇要談的,是 C 與作業系統用來告訴你「某次呼叫失敗了、而且失敗的確切原因」的那套小巧、古老、又有點彆扭的慣例:一個哨兵回傳值,加上一個叫做 errno 的數字。

問題所在:系統呼叫可能帶著失敗回來

從本階前面幾篇,你已經有了這幅圖:當你呼叫像 open() 這樣的東西,控制權會陷入核心,核心代你做特權工作,然後你帶著一個結果回到使用者模式。我們畫的是順利的那條路:open() 遞回給你一個小整數——一個檔案描述符——你就接著做下去。但核心並非總會成功。你指名的檔案也許不存在。磁碟也許滿了。你也許根本沒有權限去碰它。核心沒辦法變魔術把檔案變出來——所以它必須回來,在使用者模式裡告訴你「它失敗了、以及為什麼」。這套回報慣例,就是本篇的全部主題。

這裡有個彆扭之處,值得誠實地點明。一個 C 函式只能遞回一個回傳值。但核心透過它的 libc 包裝層,一次需要傳達「兩件」事:是成功還是失敗,以及若是失敗,數十種可能原因中是哪一種。C 沒有內建方式回傳一對值,於是 Unix 的設計者把這則訊息拆到兩個管道上。函式本身的回傳值回答第一個問題——成功或失敗——用的是一個保留的哨兵值。另一個看起來像全域變數、叫做 errno 的整數,則回答第二個問題——確切的原因。兩個管道,一個給判決,一個給解釋。

這份約定:先看哨兵回傳,失敗後才看 errno

每一個 POSIX 呼叫都遵循同一套兩步驟約定,一旦你把它內化,就能讀懂任何一個。第一步:看回傳值,把它跟那個函式記載的哨兵比對——那個「不可能」是真正答案、因而代表「失敗了」的保留值。哨兵因函式而異,這常絆倒初學者。open() 失敗回傳 -1(合法的描述符永遠是 0 或更大)。malloc() 失敗回傳NULL 指標(真正的配置永遠不會是 NULL)。read() 和 write() 失敗回傳 -1。fork() 失敗回傳 -1。模式永遠是:挑一個成功路徑絕不可能產出的值,保留它來代表失敗。

第二步——這也是大家常違反的規則——你「只有在」回傳值已經告訴你它失敗之後,才去讀 errno。errno 裝著「原因」:一個有名字的小整數,例如 ENOENT(沒有這個檔案或目錄)、EACCES(權限被拒)、ENOMEM(記憶體不足)、或 EINTR(呼叫被一個訊號打斷)。這裡最要緊的誠實話:成功的呼叫「並不」會把 errno 清成零。errno 只有在失敗時才被「設定」,成功時則原封不動。所以若你沒先檢查回傳值就去讀 errno,你讀到的可能是更早某次失敗呼叫殘留下來的陳舊原因——一個跟你剛才那次呼叫毫無關係的值。回傳值是那道閘門;只有當閘門說了「失敗」,errno 才有意義。

  fd = open(path, O_RDONLY);   /* step 1: the verdict */
  if (fd == -1) {              /* sentinel says it failed */
      /* step 2: NOW errno holds the reason */
      /* errno == ENOENT  -> no such file
         errno == EACCES  -> permission denied  */
      perror("open");          /* prints: open: No such file or directory */
      return -1;
  }
  /* fd is a valid descriptor here; do NOT consult errno */
先檢查哨兵回傳;只在失敗分支裡讀 errno。

為什麼 errno 其實不是單一個全域變數

errno 看起來像個普通的全域整數,而且有很長一段時間,它真的就是整個程式共用的一個全域 `int errno;`。在程式只有單一控制流時,這沒問題。但想想當程式有兩條執行緒(你會在後面的階級正式遇到它們)時會發生什麼:執行緒 A 呼叫 open(),它失敗了,把那唯一共用的 errno 設成 ENOENT;在 A 讀到之前,執行緒 B 呼叫了另一個會失敗的呼叫,把同一個 errno 覆寫成 EACCES。執行緒 A 現在讀到的是錯誤的原因。一個共用的 errno,是一場等著發生的資料競爭

於是現代系統悄悄改變了 errno「是什麼」。在今天任何支援執行緒的平台上,errno 不再是一個普通變數,而是一個展開成函式呼叫的巨集——道理上像 `(*__errno_location())`——那個函式回傳一個「每執行緒」errno 的位址,每條執行緒在自己的儲存空間裡有一份私有副本。每條執行緒讀寫自己的 errno,所以 A 的 ENOENT 永遠不會被 B 蓋掉。標頭 `<errno.h>` 把這一切都藏起來:你仍然寫 `errno` 這個字、像對待變數一樣去賦值與檢測它,但底下它解析成你這條執行緒的私有欄位。這是一個你能具體指出的 執行緒區域儲存用例。

把一個數字變成看得懂的東西

對人類來說,像 2 這樣的整數不是個有用的錯誤訊息。所以 libc 給你兩個幫手,把一個 errno 值轉成英文(在地化支援下也能轉成其他語言)。第一個是 perror("open"):它看「當前的」errno,印出你傳入的字串、一個冒號、人看得懂的原因、再一個換行——例如 `open: No such file or directory`。第二個是 strerror(errno),它不印任何東西,而是「回傳」那則訊息字串,讓你能把它折進自己格式化的輸出裡,例如 `fprintf(stderr, "could not open %s: %s\n", path, strerror(errno));`。兩者都把同一張數字對文字的表轉譯出來。

注意這些訊息「去哪裡」。perror() 和上面的例子寫到標準錯誤——我們叫它 stderr 的那個串流——而不是標準輸出。這種分離是刻意的,你會在下一篇正式遇到標準串流。簡短版:stdout 是給程式真正的結果——管線下游想消費的那個東西——而 stderr 是給診斷訊息,例如錯誤訊息,刻意分開正是為了讓你用管線接走程式輸出時,不會把它的抱怨也一併吞掉。把錯誤回報到 stderr 是慣例;把它印到 stdout 會污染資料。

為什麼你必須每次都檢查

想跳過檢查很誘人——「open() 基本上都會成功啦」。要狠狠忍住。整套 errno 機制,若你不讀那個哨兵就毫無用處,而跳過它的代價不是一次乾淨的崩潰,而是之後悄無聲息的崩壞。假設 open() 失敗回傳了 -1,但你無視那個判決,把那個 -1 直接傳進 read(fd, ...)。現在你在從描述符 -1 讀取,那根本不是你的檔案;read() 也失敗了,你又無視「它」,於是你的緩衝區裡還是原本那堆垃圾。你的程式昂首闊步,自信地對著一堆胡言亂語繼續運算。原始的錯誤早已不見蹤影;症狀在好幾頁之後、某個毫不相干的地方才冒出來。這就是未檢查錯誤的代價的真正意涵:不是你跳過的那次失敗,而是它留下的那個被汙染的狀態。

所以紀律很簡單:對每一個「可能失敗」的呼叫,在它可能失敗的當下都檢查。對每個會失敗的呼叫,你接著決定三件事之一,而對「選哪一個」保持自覺,就是錯誤處理的大半。你可以在本地處理它——重試、退回備案、用個預設值。你可以傳遞它——回傳你自己的錯誤給你的呼叫者讓他們決定,這就是錯誤如何一層層往上爬。或者你可以中止——若這個錯誤意味著程式無法合理地繼續,就把它回報到 stderr 並以非零的結束狀態離開,好讓執行你的人知道它失敗了。誠實的框架是處理、傳遞,或中止:對每一個錯誤都從這三者裡選一個,絕不選「無視」。

該坦承這套慣例確實顯露出它的年紀。把「判決」和「原因」拆到回傳值與一個旁路的 errno 上,之所以容易出錯,正是因為這兩者可能彼此漂移——忘了檢查回傳,或讓夾在中間的呼叫踩壞 errno,整套機制就會誤導你。較新的語言對此做出了回應。比方說 Rust,讓一次呼叫回傳單一的 Result 值,這個值「本身」要嘛是成功、要嘛是錯誤,於是編譯器能逼你在使用裡頭的東西之前先看它一眼——失敗沒辦法像一個未檢查的 -1 那樣被悄悄丟掉。這不是魔法,Rust 也有它自己的代價,但它是對 errno 風格那個確切弱點的一個直接而誠實的回應:它把「我忘了檢查」變成一個編譯錯誤,而不是凌晨三點的線上崩潰。