程式設計師應瞭解的那些事(16)C語言中利用setjmp和longjmp做例外處理 / 不要在C++中使用setjmp和longjmp

2020-08-07 21:30:01

       錯誤處理是任何語言都需要解決的問題,只有不能保證100%的正確執行,就需要有處理錯誤的機制 機製。例外處理就是其中的一種錯誤處理方式。
<1>、過程活動記錄(Active Record)
       C語言中每當有一個函數呼叫時,就會在堆疊(Stack)上準備一個被稱爲AR的結構,拋開具體編譯器實現細節的不同,這個AR基本結構如下所示。每當遇到一次函數呼叫的語句,C編譯器都會產生出彙編程式碼來在堆疊上分配這個AR。例如下面 下麪的C程式碼:

         每當遇到一次函數呼叫的語句,C編譯器都會產生出彙編程式碼來在堆疊上分配這個AR。例如下面 下麪的C程式碼:

void a(int i)
{
    if(i==0){
        i = 1;
    }
    else
    {
        printf("i = %d \n", i);
    }
}
int main(int argc, char** argv)
{
    a(1);
}

        當程式執行後執行到printf()語句時,堆疊上的AR佈局如下:

<2>通過setjmp和longjmp操縱AR,完成任意跳轉
       那麼如何來操縱AR呢,一個可能的方法是,根據區域性變數的地址進行推算,例如對於上面的a函數,執行a函數時的當前AR地址就是參數i的地址偏移8個位元組,也就是 ((char*)&i) - 8。然而,不同的C編譯器,以及不同的硬體平臺都會產生不同的AR結構佈局,甚至在一些平臺上,AR根本不會存放到Stack中。所以這種方式操縱AR是不通用的。爲此,C語言通過庫函數的方式提供了操縱AR的統一方法,那就是setjmp和longjmp函數。

int setjmp(jmp_buf jb);
void longjmp(jmp_buf jb, int r);

        setjmp用於儲存當前AR到jb變數中;
        而longjmp用於設定當前AR爲jb,並跳轉到呼叫setjmp();之後的第一個語句處。其結果就相當於回到了setjmp()剛執行完畢,只是偷偷的修改了setjmp的返回值。

        setjmp()第一次呼叫時總是返回0,而通過longjmp(jb,r)跳轉後其返回值總是被修改爲r,並且r不能爲0。這樣程式中就很容易根據setjmp()的返回值來判斷是否是longjmp()導致了跳轉才執行到此。
       setjmp/longjmp主要從巢狀的函數呼叫中跳出來。

#include <stdio.h>
#include <setjmp.h>
jmp_buf jb;
void a();
void b();
void c();
 
int main()
{
    if(setjmp(jb)==0){
        a();
    }
    printf("after a(); \n");
    return 0;
}
void a()
{
    b();
    printf("a() is called\n");
}
void b()
{
    c();
    printf("b() is called\n");
}
void c()
{
    printf("c() is called\n");
    longjmp(jb, 1);
}

       在c()中可以直接跳轉到main()中,實際上longjmp不限制跳轉的目的地,可以跳轉到任意位置並恢復當時的堆疊環境(堆疊平衡)。

<3>C語言中實現例外處理
      例外處理是錯誤處理的一種方式,C語言中更常用的錯誤處理方式是檢測函數返回值。

#include <stdio.h>
int f1()
{
    if(1/*正確執行*/) { return 1; }
    else { return -1; }
}
int f2()
{
    if(0/*正確執行*/) { return 1; }
    else { return -1; }
}
int main()
{
    if(f1()<0){
        printf("錯誤處理1\n");
        exit(1);
    }
 
    if(f2()<0){
        printf("錯誤處理2\n");
        exit(2);
    }
    return 0;
}

        上面程式碼顯示了常見的C語言錯誤處理方式。嚴謹的軟件開發中,必須檢測每一次函數呼叫可能出現的錯誤,並做相應的處理。造成的後果就是冗長繁瑣的程式碼。爲了統一處理錯誤,C++,C#,Java等現代語言引入了例外處理機制 機製。同樣功能的C++程式碼大概如下:

#include <stdio.h>
class Ex1{
};
class Ex2{
};
void f1()
{
    printf("進入f1()\n");
    if(0/*正確執行*/){ }
    else {
        throw Ex1();
    }
    printf("退出f1()\n");
}
void f2()
{
    printf("進入f2()\n");
    if(1/*正確執行*/) {  }
    else {
        throw Ex2();
    }
    printf("退出f2()\n");
}
int main()
{
    try{
        f1();
        f2();
    }catch(Ex1 &ex){
        printf("處理錯誤1\n");
        exit(1);
    }
    catch(Ex2 &ex){
        printf("處理錯誤2\n");
        exit(2);
    }
    return 0;
}
程式輸出:
進入f1()
處理錯誤1

       可見,例外處理讓程式碼看起來更加整潔,邏輯程式碼在一起,錯誤處理程式碼在一起。throw後面的語句不再執行,執行流直接跳轉到最近的try對應的catch塊。可以推測,
throw要負責兩件事情:(1)完成跳轉;(2)恢復堆疊AR;
try則負責儲存當前AR。

         可見這與setjmp/longjmp基本相當。於是可以在C中近似寫成:

#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h>
jmp_buf jb;
 
void f1()
{
    printf("進入f1()\n");
    if(0/*正確執行*/){ }
    else {
        longjmp(jb,1);
    }
    printf("退出f1()\n");
}
void f2()
{
    printf("進入f2()\n");
    if(1/*正確執行*/) {  }
    else {
        longjmp(jb, 2);
    }
    printf("退出f2()\n");
}
int main()
{
    int r = setjmp(jb);
    if(r==0){
        f1();
        f2();
    }else if(r==1){
        printf("處理錯誤1\n");
        exit(1);
    }else if(r==2){
        printf("處理錯誤2\n");
        exit(2);
    }
    return 0;
}
//當然完整的例外處理遠比這裏的程式碼要複雜,需要考慮異常的巢狀等,這裏僅僅給出最簡單的思路。

<4> 不要在C++中使用setjmp和longjmp
        C++爲例外處理提供了直接支援。除非極特殊需要,不要再重新實現自己的異常機制 機製,尤其需要說明的是,簡單的呼叫setjmp/longjmp有可能帶來問題

        longjmp()跳轉前區域性物件可能並不會解構(g++),也可能解構(VC++),C++標準對此並無明確要求。這種依賴於具體編譯器版本的程式碼是應該避免的。而C++本身的throw關鍵字,卻能嚴格保證區域性物件構造和解構的成對呼叫。

<範例>
#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h> 
class MyClass
{
public:
    MyClass(){ printf("MyClass::MyClass()\n");}
    ~MyClass(){ printf("MyClass::~MyClass()\n");}
};
jmp_buf jb;
void f1()
{
    MyClass obj;
    printf("進入f1()\n");
    if(0/*正確執行*/){ }
    else {
        longjmp(jb,1);
    }
    printf("退出f1()\n");
}
void f2()
{
    printf("進入f2()\n");
    if(1/*正確執行*/) {  }
    else {
        longjmp(jb, 2);
    }
    printf("退出f2()\n");
}
int main()
{
    int r = setjmp(jb);
    if(r==0){
        f1();
        f2();
    }else if(r==1){
        printf("處理錯誤1\n");
        exit(1);
    }else if(r==2){
        printf("處理錯誤2\n");
        exit(2);
    }
    return 0;
}
輸出結果:
g++編譯,程式輸出:
MyClass::MyClass()
進入f1()
處理錯誤1

vc++編譯,程式輸出:
MyClass::MyClass()
進入f1()
MyClass::~MyClass()
處理錯誤1
longjmp()跳轉前區域性物件

<5>辯證看待例外處理
       爲實現例外處理,C++編譯器爲此必須做更多的工作,也必然導致在AR中直接或間接地存放更多的資訊,併產生操作這些資訊的彙編程式碼,最終必然導致執行效率的降低。
       另一方面,已經存在大量沒有嚴格使用例外處理C++函數庫和類庫,相容的C庫更是沒有異常的概念,歷史的包袱讓C++很難完全採用例外處理。在這個方面,Java和C#從頭開始,重要的庫都實現了標準的例外處理規範,完全採用異常機制 機製切實可行。
有趣的是C++11在標準中刪除了異常規範,而且新增了 noexcept關鍵字來宣告一個函數不會拋出異常,可見異常並不受歡迎。  
        然而,C++的STL廣泛使用異常,所以實際上使用了STL的C++程式是不可能禁用異常的,要是沒有了STL,C++又有什麼優勢了呢?C++在不斷的矛盾衝突中向前發展着。