[經驗] 使用C語言庫 setjmp/longjmp 函數進行異常恢復

cruelfox 樓主
2021-3-5 00:10

  在很久前還使Turbo C 2.0寫程序的時候,我在幫助功能里面瀏覽庫函數的時候見過有 setjmp() 和 longjmp() 函數,但是從未去了解這是用來做什么的。時隔二十多年了,我才從網上別人的博文中了解到了 setjmp() 和 longjmp() 函數的功能,覺得這可算是 C 語言提供的一個“神器”了。從名稱上看,可以猜它們是實現某種 "Goto" 功能的,但必然和 C 語言關鍵字 goto 不是一回事。C 語言有 goto, 但是我寫程序從來不用(帶我入門C語言的老師叫我不要用goto, 受此影響),只有在退出多重循環的時候覺得……這里要用一下goto就省事了。

  雖然 goto 可以直接從嵌套的循環中跳出到最外面,但是跳轉限制在一個函數內部(因為 C 語言一個函數是一個代碼模塊,編譯時無法確定別的函數內部的地址)。想從函數調用嵌套中跳出來?常規做法只能一級一級函數返回。盡管這樣保持了程序結構清晰,有時候為了更高效地實現是可以借助……

  C++ 語言有 try, catch 塊功能,提供了高級的異常處理支持,可以實現入上這種“跨函數”的退出。而 C 語言的 setjmp(), longjmp() 函數提供了相似的異常處理支持,注意是在庫函數一級實現,不是語言內置。longjmp() 函數可以跳轉到 setjmp() 函數調用的位置去執行,好象是代碼從 setjmp() 函數返回一般。

 

  那么,在單片機程序里用這兩個奇特的函數有什么好處?

  我剛做的一個小東西里面,要在執行某個命令操作的時候進行超時檢查。在這個操作過程中要使用很多個 SPI slave 模式收發,因為 SPI 時鐘是外部給的,我在等待 SPI 狀態寄存器更新的時候就用一個循環在反復讀狀態寄存器。如果要增加一個超時檢查,那么就要在循環中測試定時器的狀態,或者是測試一個由定時器中斷修改的全局變量。這樣一來,每個等待 SPI 狀態的循環都要寫得更多一些(對狀態更新的響應時間也會增加);然后,在檢測到超時故障后需要一個條件分支轉去異常處理(若用goto寫,可以簡化)包括可能需要從嵌套函數返回。結果就是,得到了邏輯上正確但是我覺得冗長累贅的代碼。

  于是我改用了 setjmp/longjmp 來實現這個超時異常處理。

 

  第一步,需要 #include <setjmp.h> 

  第二步,定義一個全局變量 jmp_buf timeout_jmp;

  第三步,在要執行的命令操作的代碼當中,寫上如下代碼:

    SysTick_Config(TIMEOUT_VALUE);    /*  使用 SysTick 中斷進行超時捕捉,須設定好定時器  */

    if(setjmp(timeout_jmp))    /*  發生超時異常,函數將返回真 */
    {

       /* 此處編寫發生異常后需要的軟件硬件重新初始化代碼 */
        return ERR_CODE_TIMEOUT;  /* 因超時,命令未完成,結束 */
    }
    /* 下面的代碼開始執行命令 */

   /*    過程省略   */

    SysTick->CTRL = 0;    /* 完成,定時器禁用 */

    return cmd_status;


  第四步,編寫用來執行 longjmp() 的輔助函數

void timeout_catch(void)
{
    SysTick->CTRL = 0;
    longjmp(timeout_jmp,1);
}

  最后一步,編寫定時器的中斷處理程序,當超時發生時執行

void SysTick_Handler(void
{
    asm volatile (
    "str %[ret_addr], [sp,#0x18]\n"
    ::[ret_addr]"r"(timeout_catch) :
    );
}

 

  實現原理:setjmp() 函數用了一個 jmp_buf 結構類型的變量來保存現場,longjmp() 則從保存的現場恢復。也就是 longjmp() 的參數提供了保存的現場存放在哪里的地址,從這塊存儲中還獲取到繼續執行的地址。超時異常捕捉是用定時器中斷引發,那么為什么不在中斷處理程序中調用 longjmp() 呢?因為 longjmp() 只恢復軟件層面上的現場,對中斷狀態是一無所知的,必須要回到非中斷模式下再調用 longjmp(), 否則后續代碼未退出中斷。我上面寫的 timeout_catch() 就作此用途——讓中斷返回到 timeout_catch() 函數中去。

  這里我又用了一個特殊技巧,這是用ARM Cortex-m0處理器匯編代碼書寫。因為中斷要返回的地址保存在堆棧里面,所以把 timeout_catch() 函數入口地址寫到堆棧中原來 PC 寄存器的位置——就是當中斷發生時,要執行的下一條指令的地址(現在不需要執行那里了,因為發生異常,要取消執行)。這樣中斷返回到 timeout_catch() 函數,然后先關掉定時器,再執行 longjmp().  此時,程序就轉移到 setjmp() 調用后的那段異常處理代碼中了。

 

  可以反匯編看一下 setjmp() 和 longjmp() 都做了什么:

080017b8 <setjmp>:
 80017b8:	c0f0      	stmia	r0!, {r4, r5, r6, r7}
 80017ba:	4641      	mov	r1, r8
 80017bc:	464a      	mov	r2, r9
 80017be:	4653      	mov	r3, sl
 80017c0:	465c      	mov	r4, fp
 80017c2:	466d      	mov	r5, sp
 80017c4:	4676      	mov	r6, lr
 80017c6:	c07e      	stmia	r0!, {r1, r2, r3, r4, r5, r6}
 80017c8:	3828      	subs	r0, #40	; 0x28
 80017ca:	c8f0      	ldmia	r0!, {r4, r5, r6, r7}
 80017cc:	2000      	movs	r0, #0
 80017ce:	4770      	bx	lr

080017d0 <longjmp>:
 80017d0:	3010      	adds	r0, #16
 80017d2:	c87c      	ldmia	r0!, {r2, r3, r4, r5, r6}
 80017d4:	4690      	mov	r8, r2
 80017d6:	4699      	mov	r9, r3
 80017d8:	46a2      	mov	sl, r4
 80017da:	46ab      	mov	fp, r5
 80017dc:	46b5      	mov	sp, r6
 80017de:	c808      	ldmia	r0!, {r3}
 80017e0:	3828      	subs	r0, #40	; 0x28
 80017e2:	c8f0      	ldmia	r0!, {r4, r5, r6, r7}
 80017e4:	1c08      	adds	r0, r1, #0
 80017e6:	d100      	bne.n	80017ea <longjmp+0x1a>
 80017e8:	2001      	movs	r0, #1
 80017ea:	4718      	bx	r3

  它們保存和恢復了若干寄存器。包括PC, SP寄存器,這樣程序堆棧也能夠恢復到 setjmp() 調用時候的樣子——后續子函數調用中堆棧是向下生長的,當SP寄存器恢復,那些子函數的局部變量也就無效了。longjmp() 做的現場恢復也僅限于此,硬件寄存器的改變,動態分配內存的情況等,就要編寫軟件的自己處理了。

 

  在我上面的實現中,SysTick 定時器專用來觸發超時動作,若正常操作在 SysTick 定時器走到0之前結束,將關閉它,就不會再引發中斷。這樣,處理事情的代碼中不需要去檢測是否發生超時。當超時發生時不論代碼執行到哪里,都由 SysTick 中斷服務程序轉移到 timeout_catch() 這個函數中,調用 longjmp() 恢復已保存的現場。這樣,就在初始代碼中從 setjmp() 返回值判斷到超時已發生,然后進行后續處理。

回復評論

暫無評論,趕緊搶沙發吧
電子工程世界版權所有 京B2-20211791 京ICP備10001474號-1 京公網安備 11010802033920號
    我也要說兩句
    發送
    評論
    萝卜大香蕉