繼承自 C 的優良傳統, C++ 也是一門非常靠近底層的語言, 可是實在是太靠近了, 很多問題語言本身沒有提供解決方案, 可執行代碼貼近機器, 運行時沒有虛擬機來反饋錯誤, 跑着跑着就毫無征兆地崩潰了, 簡直比過山車還刺激.
雖然 C++ 加入了異常機制來處理很多運行時錯誤, 但是異常機制的功效非常受限, 很多錯誤還沒辦法用原生異常手段捕捉, 比如整數除 0 錯誤. 下面這段代碼
#include <iostream>
int main()
{
try {
int x, y;
std::cin >> x >> y;
std::cout << x / y << std::endl;
} catch (...) {
std::cerr << "attempt to divide integer by 0." << std::endl;
}
return 0;
}
輸入 "1 0" 則會導致程序掛掉, 而那對 try-catch 還呆在那里好像什么事情都沒發生一樣. 像 Python 一類有虛擬機環境支持的語言, 都會毫無懸念地捕獲除 0 錯誤.
使用信號
不過, 底層自然有底層的辦法, 而且有虛擬機的環境也並非在每個整數除法指令之前都添上一句 if 0 == divisor: raise 之類的挫語句來觸發異常. 這得益於硬件體系中的中斷機制. 簡而言之, 當發生整數除 0 之類的錯誤時, 硬件會觸發中斷, 這時操作系統會根據上下文查出是哪個進程不給力了, 然后給這個進程發出一個信號. 某些時候也可以手動給進程發信號, 比如惱怒的用戶發現某個程序卡死的時候果斷 kill 掉這個進程, 這也是信號的一種.
這次就不是 C 標准了, 而是 POSIX 標准. 它規定了哪些信號進程不處理也不會有太大問題, 有些信號進程想處理也是不行的, 還有一些信號是錯誤中斷, 如果程序處理了它們, 那么程序能繼續執行, 否則直接殺掉.
不過, 這些錯誤處理默認過程都是不存在的, 需要通過調用 signal 函數配置. 方法類似下面這個例子
#include <csignal>
#include <cstdlib>
#include <iostream>
void handle_div_0(int)
{
std::cerr << "attempt to divide integer by 0." << std::endl;
exit(1);
}
int main()
{
if (SIG_ERR == signal(SIGFPE, handle_div_0)) {
std::cerr << "fail to setup handler." << std::endl;
return 1;
}
int x, y;
std::cin >> x >> y;
std::cout << x / y << std::endl;
return 0;
}
可以看出, 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 中拋出就行.
#include <csignal>
#include <iostream>
struct div_0_exception {};
void handle_div_0(int)
{
throw div_0_exception();
}
int main()
{
if (SIG_ERR == signal(SIGFPE, handle_div_0)) {
std::cerr << "fail to setup handler." << std::endl;
return 1;
}
try {
int x, y;
std::cin >> x >> y;
std::cout << x / y << std::endl;
} catch (div_0_exception) {
std::cerr << "attempt to divide integer by 0." << std::endl;
}
return 0;
}
更精准的信號處理
上述方法的缺陷在於, 只要發生 SIGFPE 中斷, 無論是整數除 0 錯誤, 還是其它浮點異常, 處理方式是統一的. 不過, POSIX 還規定了一組更精細的信號處理接口, 它們是 sigaction.
呃... 對它們都是 sigaction. 這又是一個雷死人的東西. 在 csignal 中定義了兩個同名的東西, 分別是
struct sigaction;
int sigaction(int sig
, struct sigaction const* restrict act
, struct sigaction* restrict old_act);
前面那個結構體在設置信號處理函數時用到, 里面存放了一些標志位和信號處理函數指針. 而后面那個函數就是設置信號處理的入口 (如果函數的第三個參數並非 NULL, 並且之前設置過信號處理結構體, 那么會將之前的處理方法寫入第三個參數所指向的結構中, 這一點並不需要, 所以后面的例子中這個參數直接傳入 NULL, 詳情請見man 3 sigaction).
結構 sigaction 中會有兩個函數入口地址, 它們分別是
void (* sa_handler)(int);
void (* sa_sigaction)(int, siginfo_t*, void*);
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 居然跟函數重名, 所以下面的例子中會對其包裝一下, 提供合適的初始化過程.
#include <csignal>
#include <cstring>
#include <iostream>
struct my_sig_action {
typedef void (* handler_type)(int, siginfo_t*, void*);
explicit my_sig_action(handler_type handler)
{
memset(&_sa, 0, sizeof(struct sigaction));
_sa.sa_sigaction = handler;
_sa.sa_flags = SA_SIGINFO;
}
operator struct sigaction const*() const
{
return &_sa;
}
protected:
struct sigaction _sa;
};
struct div_0_exception {};
void handle_div_0(int sig, siginfo_t* info, void*)
{
if (FPE_INTDIV == info->si_code)
throw div_0_exception();
}
int main()
{
my_sig_action sa(handle_div_0);
if (0 != sigaction(SIGFPE, sa, NULL)) {
std::cerr << "fail to setup handler." << std::endl;
return 1;
}
try {
int x, y;
std::cin >> x >> y;
std::cout << x / y << std::endl;
} catch (div_0_exception) {
std::cerr << "attempt to divide integer by 0." << std::endl;
}
return 0;
}
