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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

當程式執行後執行到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);
  • 1
  • 2
  • 1
  • 2

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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

在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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

上面程式碼顯示了常見的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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

程式輸出:

進入f1()
處理錯誤1
  • 1
  • 2
  • 1
  • 2

可見,異常處理讓程式碼看起來更加整潔,邏輯程式碼在一起,錯誤處理程式碼在一起。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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

當然完整的異常處理遠比這裡的程式碼要複雜,需要考慮異常的巢狀等,這裡僅僅給出最簡單的思路。

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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48

g 編譯,程式輸出:

MyClass::MyClass()
進入f1()
處理錯誤1
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

vc 編譯,程式輸出:

MyClass::MyClass()
進入f1()
MyClass::~MyClass()
處理錯誤1
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

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 在不斷的矛盾衝突中向前發展者。