1. 程式人生 > >C語言中利用setjmp和longjmp做異常處理

C語言中利用setjmp和longjmp做異常處理

錯誤處理是任何語言都需要解決的問題,只有不能保證100%的正確執行,就需要有處理錯誤的機制。異常處理就是其中的一種錯誤處理方式。

1 過程活動記錄(Active Record)

C語言中每當有一個函式呼叫時,就會在堆疊(Stack)上準備一個被稱為AR的結構,拋開具體編譯器實現細節的不同,這個AR基本結構如下所示。
這裡寫圖片描述

每當遇到一次函式呼叫的語句,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有可能帶來問題。如

#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()跳轉前區域性物件可能並不會析構(g++),也可能析構(VC++),C++標準對此並無明確要求。這種依賴於具體編譯器版本的程式碼是應該避免的。

而C++本身的throw關鍵字,卻能嚴格保證區域性物件構造和析構的成對呼叫。

5 辯證看待異常處理

為實現異常處理,C++編譯器為此必須做更多的工作,也必然導致在AR中直接或間接地存放更多的資訊,併產生操作這些資訊的彙編程式碼,最終必然導致執行效率的降低。

另一方面,已經存在大量沒有嚴格使用異常處理C++函式庫和類庫,相容的C庫更是沒有異常的概念,歷史的包袱讓C++很難完全採用異常處理。在這個方面,Java和C#從頭開始,重要的庫都實現了標準的異常處理規範,完全採用異常機制切實可行。

有趣的是C++11在標準中刪除了異常規範,而且添加了 noexcept關鍵字來宣告一個函式不會丟擲異常,可見異常並不是那麼受歡迎。

C++編譯器也會提供一個禁用異常的選項,下面是VC++中禁用異常的方法。
這裡寫圖片描述

然而,C++的STL廣泛使用異常,所以實際上使用了STL的C++程式是不可能禁用異常的,要是沒有了STL,C++又有什麼優勢了呢?C++在不斷的矛盾衝突中向前發展者。