C++ 中捕獲整數除零錯誤

發表於2016-12-21

繼承自 C 的優良傳統, C++ 也是一門非常靠近底層的語言, 可是實在是太靠近了, 很多問題語言本身沒有提供解決方案, 可執行程式碼貼近機器, 執行時沒有虛擬機器來反饋錯誤, 跑著跑著就毫無徵兆地崩潰了, 簡直比過山車還刺激.

雖然 C++ 加入了異常機制來處理很多執行時錯誤, 但是異常機制的功效非常受限, 很多錯誤還沒辦法用原生異常手段捕捉, 比如整數除 0 錯誤. 下面這段程式碼

輸入 “1 0” 則會導致程式掛掉, 而那對 try-catch 還呆在那裡好像什麼事情都沒發生一樣. 像 Python 一類有虛擬機器環境支援的語言, 都會毫無懸念地捕獲除 0 錯誤.

使用訊號

不過, 底層自然有底層的辦法, 而且有虛擬機器的環境也並非在每個整數除法指令之前都添上一句 if 0 == divisor: raise 之類的挫語句來觸發異常. 這得益於硬體體系中的中斷機制. 簡而言之, 當發生整數除 0 之類的錯誤時, 硬體會觸發中斷, 這時作業系統會根據上下文查出是哪個程式不給力了, 然後給這個程式發出一個訊號. 某些時候也可以手動給程式發訊號, 比如惱怒的使用者發現某個程式卡死的時候果斷 kill 掉這個程式, 這也是訊號的一種.

這次就不是 C 標準了, 而是 POSIX 標準. 它規定了哪些訊號程式不處理也不會有太大問題, 有些訊號程式想處理也是不行的, 還有一些訊號是錯誤中斷, 如果程式處理了它們, 那麼程式能繼續執行, 否則直接殺掉.

不過, 這些錯誤處理預設過程都是不存在的, 需要通過呼叫 signal 函式配置. 方法類似下面這個例子

可以看出, signal 接受兩個引數, 分別是訊號編號和訊號處理函式. 成功設定了針對 SIGFPE (吐槽: 為什麼是浮點異常 FPE 呢?) 的處理函式 handle_div_0, 如果再發生整數除 0 的慘劇, handle_div_0 就會被呼叫.

handle_div_0 的引數是訊號碼, 也就是 SIGFPE, 忽略它也行.

底層機制

雖然說 handle_div_0 是異常處理過程, 但畢竟是函式都會有呼叫棧, 能返回. 假如在 handle_div_0 中不呼叫 exit 自尋死路, 而是選擇返回, 那麼程式會怎麼樣呢? 執行一下, 當出現錯誤時, stderr 會死迴圈般地刷屏.

實際上, 當錯誤發生時, 作業系統會在當前錯誤出現處載入訊號處理函式的呼叫棧幀, 並且把它的返回地址設定為出錯的那條指令之前, 這樣看起來就像是出錯之前的瞬間呼叫了訊號處理函式. 當訊號處理函式返回時, 則又會再次執行那條會出錯的指令, 除非訊號處理函式能通過某些特別的技巧修復指令, 否則退出時會重蹈覆轍.

上面提到的 “修復指令” 指的是修復 CPU 級別的指令碼或者運算元. 把除數 y 變成全域性變數, 然後在 handle_div_0 中設定 y 為 1, 這樣做是於事無補的.

使用異常處理機制

修復指令這種事情簡直是天方夜譚, 所以選擇輸出一跳錯誤語句並退出也算是不錯的方法. 在 C 語言時代, 還可以通過 setjmp 和 longjmp 來跳轉程式流程. 不過 setjmp 和 longjmp 操作起來太不方便了, 相比之下 try-catch 要好得多.

剛才說過, 錯誤處理函式的呼叫棧幀直接位於錯誤發生處所在函式棧幀之上, 因此, 丟擲異常能夠被外部設定的 try-catch 捕獲. 現在定義一個異常型別, 然後在 handle_div_0 中丟擲就行.

更精準的訊號處理

上述方法的缺陷在於, 只要發生 SIGFPE 中斷, 無論是整數除 0 錯誤, 還是其它浮點異常, 處理方式是統一的. 不過, POSIX 還規定了一組更精細的訊號處理介面, 它們是 sigaction.

呃… 對它們都是 sigaction. 這又是一個雷死人的東西. 在 csignal 中定義了兩個同名的東西, 分別是

前面那個結構體在設定訊號處理函式時用到, 裡面存放了一些標誌位和訊號處理函式指標. 而後面那個函式就是設定訊號處理的入口 (如果函式的第三個引數並非 NULL, 並且之前設定過訊號處理結構體, 那麼會將之前的處理方法寫入第三個引數所指向的結構中, 這一點並不需要, 所以後面的例子中這個引數直接傳入 NULL, 詳情請見 man 3 sigaction).

結構 sigaction 中會有兩個函式入口地址, 它們分別是

sa_handler 也就是之前所演示的輕便型訊號處理函式; 而 sa_sigaction, 從它接受的引數就能看出, 它能獲得更多的上下文資訊 (然而, 一看第三個引數的型別是 void* 就知道沒有好事, 資訊都在第二個引數指向的結構體中).

既然有兩個處理函式, 那麼如何決定使用哪一個呢? 在 struct sigaction 中有一個標誌位成員 sa_flags, 如果為它置上 SA_SIGINFO 位, 那麼就使用 sa_sigaction 作為處理函式.

siginfo_t 型別中有一個叫做 si_code 的成員, 它為訊號型別提供進一步的細分, 比如在 SIGFPE 訊號下, si_code 可能有 FPE_INTOVF (整數溢位), FPE_FLTUND (浮點數下溢), FPE_FLTOVF (浮點數上溢) 等各種相關取值, 當然還有現在最關心的整數除 0 訊號碼 FPE_INTDIV. 如果陷入 SIGFPE 的窘境中, 而 si_code 又恰好是 FPE_INTDIV 那麼就要果斷丟擲 0 異常了.

由於原生的 struct sigaction 居然跟函式重名, 所以下面的例子中會對其包裝一下, 提供合適的初始化過程.

相關文章