訂閱
糾錯
加入自媒體

如何利用C語言中的setjmp和longjmp實現異常捕獲和協程?

2021-03-05 09:28
道哥分享
關注

3. setjmp:保存上下文信息

我們知道,C 代碼在編譯成二進制文件之后,在執行時被加載到內存中,CPU 按照順序到代碼段取出每一條指令來執行。在 CPU 中有很多個寄存器,用來保存當前的執行環境,比如:代碼段寄存器CS、指令偏移量寄存器IP,當然了還有其他很多其它寄存器,我們把這個執行環境稱作上下文。

CPU 在獲取下一條執行指令時,通過 CS 和 IP 這 2 個寄存器就能獲取到需要執行的指令,如下圖:

補充一下知識點:

上圖中,把代碼段寄存器 CS 當做一個基地址來看待了,也就是說:CS 指向代碼段在內存中的開始地址,IP 寄存器代表下一個要執行的指令地址距離這個基地址的偏移量。因此每次取指令時,只需要把這 2 個寄存器中的值相加,就得到了指令的地址;其實,在 x86 平臺上,代碼段寄存器 CS 并不是一個基地址,而是一個選擇子。在操作系統的某個地方有一個表格,這個表格里存儲了代碼段真正的開始地址,而 CS 寄存器中 只是存儲了一個索引值,這個索引值指向這個表格中的某個表項,這里涉及到虛擬內存的相關知識了;IP 寄存器在獲取一條指令之后,自動往下移動到下一個指令的開始位置,至于移動多少個字節,那就要看當前取出的這條指令占用了多少個字節。

CPU 是一個大傻瓜,它沒有任何的想法,我們讓它干什么,它就干什么。比如取指令:我們只要設置 CS 和 IP 寄存器,CPU 就用這 2 個寄存器里的值去獲取指令。如果把這 2 個寄存器設置為一個錯誤的值,CPU 也會傻不拉幾的去取指令,只不過在執行時就會崩潰。

我們可以簡單的把這些寄存器信息理解為上下文信息,CPU 就根據這些上下文信息來執行。因此,C 語言為我們準備了 setjmp 這個庫函數來把當前的上下文信息保存起來,暫時存儲到一個緩沖區中。

保存的目的是什么?為了在以后可以恢復到當前這個地方繼續執行。

還有一個更簡單的例子:服務器中的快照。快照的作用是什么?當服務器出現錯誤時,可以恢復到某個快照!

4. longjmp: 實現跳轉

說到跳轉,腦袋中立刻跳出的概念就是 goto 語句,我發現很多教程都對 goto 語句很有意見,認為在代碼中應該盡量不要使用它。這樣的觀點出發點是好的:如果 goto 使用太多,會影響對代碼執行順序的理解。

但是如果看一下 Linux 內核的代碼,可以發現很多的 goto 語句。還是那句話:在代碼維護和執行效率上要尋找一個平衡點。

跳轉改變了程序的執行序列,goto 語句只能在函數內部進行跳轉,如果是跨函數它就無能為力了。

因此,C 語言中為我們提供了 longjmp 函數來實現遠程跳轉,從它的名字就可以額看出來,也就是說可以跨函數跳轉。

從 CPU 的角度看,所謂的跳轉就是把上下文中的各種寄存器設置為某個時刻的快照,很顯然,上面的 setjmp 函數中,已經把那個時刻的上下文信息(快照)存儲到一個臨時緩沖區中了,如果要跳轉到那個地方去接著執行,直接告訴 CPU 就行了。

怎么告訴 CPU 呢?就是把臨時緩沖區中的這些寄存器信息覆蓋掉 CPU 中使用的那些寄存器即可。

5. setjmp:返回類型和返回值

在某些需要多進程的程序中,我們經常使用 fork 函數來從當前的進程中"孵化"一個新的進程,這個新進程從 fork 這個函數的下一條語句開始執行。

對于主進程來說,調用 fork 函數之后返回,也是繼續執行下一條語句,那么如何來區分是主進程還是新進程呢? fork 函數提供了一個返回值給我們來進行區分:

fork 函數返回 0:代表這是新進程;
fork 函數返回非 0:代表是原來的主進程,返回數值是新進程的進程號。

類似的,setjmp 函數也有不同的返回類型。也許用返回類型來表述不太準確,可以這樣理解:從 setjmp 函數返回,一共有 2 個場景:

主動調用 setjmp 時:返回 0,主動調用的目的是為了保存上下文,建立快照。通過 longjmp 跳轉過來時:返回非 0,此時的返回值是由 longjmp 的第二個參數來指定的。

根據以上這 2 種不同的值,我們就可以進行不同的分支處理了。當通過 longjmp 跳轉返回的時候,可以根據實際場景,返回不同的非 0 值。有過 Python、Lua 等腳本語言編程經驗的小伙伴,是不是想到了 yield/resume 函數?它們在參數、返回值上的外在表現是一樣的!

小結:到這里,基本上把 setjmp/longjmp 這 2 個函數的使用方法講完了,不知道我描述的是否足夠清楚。此時,再看一下文章開頭的示例代碼,應該一目了然了。

三、利用 setjmp/longjmp 實現異常捕獲

既然 C 函數庫給我們提供了這個工具,那就肯定存在一定的使用場景。異常捕獲在一些高級語言中(Java/C++),直接在語法層面進行了支持,一般就是 try-catch 語句,但是在 C 語言中需要自己去實現。

我們來演示一個最簡單的異常捕獲模型,代碼一共 56 行:

#include <unistd.h>#include <stdio.h>#include <stdlib.h>#include <setjmp.h>
typedef int     BOOL;#define TRUE    1#define FALSE   0
// 枚舉:錯誤代碼typedef enum _ErrorCode_ {    ERR_OK = 100,         // 沒有錯誤    ERR_DIV_BY_ZERO = -1  // 除數為 0} ErrorCode;
// 保存上下文的緩沖區jmp_buf gExcptBuf;
// 可能發生異常的函數typedef int (*pf)(int, int);int my_div(int a, int b){    if (0 == b)    {        // 發生異常,跳轉到函數執行之前的位置        // 第2個參數是異常代碼        longjmp(gExcptBuf, ERR_DIV_BY_ZERO);    }    // 沒有異常,返回正確結果    return a / b;}
// 在這個函數中執行可能會出現異常的函數int try(pf func, int a, int b){    // 保存上下文,如果發生異常,將會跳入這里    int ret = setjmp(gExcptBuf);    if (0 == ret)    {        // 調用可能發生異常的哈數        func(a, b);        // 沒有發生異常        return ERR_OK;    }    else    {        // 發生了異常,ret 中是異常代碼        return ret;    }}
int main(){    int ret = try(my_div, 8, 0);     // 會發生異常    // int ret = try(my_div, 8, 2);  // 不會發生異常    if (ERR_OK == ret)    {        printf("try ok ! ");    }    else    {        printf("try excepton. error = %d ", ret);    }        return 0;}

代碼就不需要詳細說明了,直接看代碼中的注釋即可明白。這個代碼僅僅是示意性的,在生產代碼中肯定需要更完善的包裝才能使用。

有一點需要注意:setjmp/longjmp 僅僅是改變了程序的執行順序,應用程序自己的一些數據如果需要回滾的話,需要我們自己手動處理。

四、利用 setjmp/longjmp 實現協程 

1. 什么是協程

在 C 程序中,如果需要并發執行的序列一般都是用線程來實現的,那么什么是協程呢?維基百科對于協程的解釋是:

更詳細的信息在這個頁面 協程,網頁中具體描述了協程與線程、生成器的比較,各種語言中的實現機制。

我們用生產者和消費者來簡單體會一下協程和線程的區別:

2. 線程中的生產者和消費者生產者和消費者是 2 個并行執行的序列,通常用 2 個線程來執行;生產者在生產商品時,消費者處于等待狀態(阻塞)。生產完成后,通過信號量通知消費者去消費商品;消費者在消費商品時,生產者處于等待狀態(阻塞)。消費結束后,通過信號量通知生產者繼續生產商品。3. 協程中的生產者和消費者生產者和消費者在同一個執行序列中執行,通過執行序列的跳轉來交替執行;生產者在生產商品之后,放棄 CPU,讓消費者執行;消費者在消費商品之后,放棄 CPU,讓生產者執行;4. C 語言中的協程實現

這里給出一個最最簡單的模型,通過 setjmp/longjmp 來實現協程的機制,主要是目的是來理解協程的執行序列,沒有解決參數和返回值的傳遞問題。

typedef int     BOOL;#define TRUE    1#define FALSE   0
// 用來存儲主程和協程的上下文的數據結構typedef struct _Context_ {    jmp_buf mainBuf;    jmp_buf coBuf;} Context;
// 上下文全局變量Context gCtx;
// 恢復#define resume()     if (0 == setjmp(gCtx.mainBuf))     {         longjmp(gCtx.coBuf, 1);     }
// 掛起#define yield()     if (0 == setjmp(gCtx.coBuf))     {         longjmp(gCtx.mainBuf, 1);     }
// 在協程中執行的函數void coroutine_function(void *arg){    while (TRUE)  // 死循環    {        printf("*** coroutine: working ");        // 模擬耗時操作        for (int i = 0; i < 10; ++i)        {            fprintf(stderr, ".");            usleep(1000 * 200);        }        printf("*** coroutine: suspend ");                // 讓出 CPU        yield();    }}
// 啟動一個協程// 參數1:func 在協程中執行的函數// 參數2:func 需要的參數typedef void (*pf)(void *);BOOL start_coroutine(pf func, void *arg){    // 保存主程的跳轉點    if (0 == setjmp(gCtx.mainBuf))    {        func(arg); // 調用函數        return TRUE;    }
   return FALSE;}
int main(){    // 啟動一個協程    start_coroutine(coroutine_function, NULL);        while (TRUE) // 死循環    {        printf("=== main: working ");
       // 模擬耗時操作        for (int i = 0; i < 10; ++i)        {            fprintf(stderr, ".");            usleep(1000 * 200);        }
       printf("=== main: suspend ");                // 放棄 CPU,讓協程執行        resume();    }
   return 0;}
打印信息如下:

如果想深入研究 C 語言中的協程實現,可以看一下達夫設備這個概念,其中利用 goto 和 switch 語句來實現分支跳轉,其中使用的語法比較怪異、但是合法。

五、總結

這篇文章的重點是介紹 setjmp/longjmp 的語法和使用場景,在某些需求場景中,能達到事半功倍的效果。

當然,你還可以發揮想象力,通過執行序列的跳轉來實現更加花哨的功能,一切皆有可能!

不吹噓,不炒作,不浮夸,認真寫好每一篇文章!

歡迎轉發、分享給身邊的技術朋友,道哥在此表示衷心的感謝!轉發的推薦語已經幫您想好了:

道哥總結的這篇總結文章,寫得很用心,對我的技術提升很有幫助。好東西,要分享!

最后,祝您:面對代碼,永無bug;面對生活,春暖花開!


<上一頁  1  2  
聲明: 本文由入駐維科號的作者撰寫,觀點僅代表作者本人,不代表OFweek立場。如有侵權或其他問題,請聯系舉報。

發表評論

0條評論,0人參與

請輸入評論內容...

請輸入評論/評論長度6~500個字

您提交的評論過于頻繁,請輸入驗證碼繼續

暫無評論

暫無評論

    人工智能 獵頭職位 更多
    掃碼關注公眾號
    OFweek人工智能網
    獲取更多精彩內容
    文章糾錯
    x
    *文字標題:
    *糾錯內容:
    聯系郵箱:
    *驗 證 碼:

    粵公網安備 44030502002758號