Delphi異常機制與SEH 轉

介紹SEH機制的文章很多,但基本都是C++的,關於Delphi的很少。最近項目需要,仔細閱讀了VCL關於異常的處理,有些心得體會,希望和大家一起分享。
SEH簡介
       SEH(struct exception handling)結構化異常處理是WIN32系統提供一種與語言無關的的異常處理機制。編程語言通過對SEH的包裝,使程序異常處理更加簡單,代碼結構更加清晰。常見的如,delphi用到的 try exception end, try finally end,C++用到的_try{} _finally{} 和_try{} _except {} 結構都是對SEH的包裝。
       SEH提供了兩種方式供開發者使用,一種是線程級的,通過設置線程的SEH鏈表結構。線程的TIB信息保存在FS:[0],而TIB的第一項就是指向SEH鏈表,所以,FS:[0]就是指向SEH鏈表,關於SEH結構後面介紹。第二種是進程級的,通過API函數SetUnhandledExceptionFilter設置過濾器函數來獲取異常,注意的是,這種方式只有在前面的異常機制都不予以處理的時候纔會被觸發。
       關於更詳細的SEH相關內容,請參見大牛Matt Pietrek的文章:
SEH鏈表的結構如下:
 

 

Delphi打造的最簡單的SEH示例
program Project1;
 
{$APPTYPE CONSOLE}
 
uses
 SysUtils, Windows;
 
type
 PEXCEPTION_HANDLER = ^EXCEPTION_HANDLER;
 
 PEXCEPTION_REGISTRATION = ^EXCEPTION_REGISTRATION;
 _EXCEPTION_REGISTRATION = record
    Prev: PEXCEPTION_REGISTRATION;
    Handler: PEXCEPTION_HANDLER;
 end;
 
 EXCEPTION_REGISTRATION = _EXCEPTION_REGISTRATION;
 
 _EXCEPTION_HANDLER = record
    ExceptionRecord: PExceptionRecord;
    SEH: PEXCEPTION_REGISTRATION;
    Context: PContext;
    DispatcherContext: Pointer;
 end;
 
 EXCEPTION_HANDLER = _EXCEPTION_HANDLER;
 
const
 EXCEPTION_CONTINUE_EXECUTION = 0; /// 恢復 CONTEXT 裏的寄存器環境,繼續執行
 EXCEPTION_CONTINUE_SEARCH    = 1; /// 拒絕處理這個異常,請調用下個異常處理函數
 EXCEPTION_NESTED_EXCEPTION   = 2; /// 函數中出發了新的異常
 EXCEPTION_COLLIDED_UNWIND    = 3; /// 發生了嵌套展開操作
 
 EH_NONE                      = 0;
 EH_NONCONTINUABLE            = 1;
 EH_UNWINDING                 = 2;
 EH_EXIT_UNWIND               = 4;
 EH_STACK_INVALID             = 8;
 EH_NESTED_CALL               = 16;
 
 STATUS_ACCESS_VIOLATION         = $C0000005; /// 訪問非法地址
 STATUS_ARRAY_BOUNDS_EXCEEDED    = $C000008C;
 STATUS_FLOAT_DENORMAL_OPERAND   = $C000008D;
 STATUS_FLOAT_DIVIDE_BY_ZERO     = $C000008E;
 STATUS_FLOAT_INEXACT_RESULT     = $C000008F;
 STATUS_FLOAT_INVALID_OPERATION = $C0000090;
 STATUS_FLOAT_OVERFLOW           = $C0000091;
 STATUS_FLOAT_STACK_CHECK        = $C0000092;
 STATUS_FLOAT_UNDERFLOW          = $C0000093;
 STATUS_INTEGER_DIVIDE_BY_ZERO   = $C0000094; /// 0 錯誤
 STATUS_INTEGER_OVERFLOW         = $C0000095;
 STATUS_PRIVILEGED_INSTRUCTION   = $C0000096;
 STATUS_STACK_OVERFLOW           = $C00000FD;
 STATUS_CONTROL_C_EXIT           = $C000013A;
 
 
var
 G_TEST: DWORD;
 
procedure Log(LogMsg: string);
begin
 Writeln(LogMsg);
end;
 
function ExceptionHandler(ExceptionHandler: EXCEPTION_HANDLER): LongInt; cdecl;
begin
 Result := EXCEPTION_CONTINUE_SEARCH;
 if ExceptionHandler.ExceptionRecord.ExceptionFlags = EH_NONE then
 begin
    case ExceptionHandler.ExceptionRecord.ExceptionCode of
      STATUS_ACCESS_VIOLATION:
        begin
          Log(' 發現異常爲非法內存訪問,嘗試修復 EBX ,繼續執行 ');
          ExceptionHandler.Context.Ebx := DWORD(@G_TEST);
          Result := EXCEPTION_CONTINUE_EXECUTION;
        end;
      else
        Log(' 這個異常我無法處理,請讓別人處理吧 ');
    end;
 
 end else if ExceptionHandler.ExceptionRecord.ExceptionFlags = EH_UNWINDING then
    Log(' 異常展開操作 ');
end;
 
begin
 asm
      /// 設置 SEH
       XOR   EAX, EAX
      PUSH   OFFSET ExceptionHandler
      PUSH   FS:[EAX]
       MOV   FS:[EAX], ESP
 
       /// 產生內存訪問錯誤
       XOR   EBX, EBX
       MOV   [EBX], 0
 
      /// 取消 SEH
       XOR   EAX, EAX
       MOV   ECX, [ESP]
       MOV   FS:[EAX], ECX
       ADD   ESP, 8
 end;
 
 Readln;
end.
這個例子演示了最簡單的異常處理,首先,通過PUSH handler 和 prev兩個字段創建一個EXCEPTION_REGISTRATION結構體。再將ESP所指的新的REGISTRATION結構體賦值給FS:[0],這樣就掛上了我們自己的SEH處理結構。當MOV   [EBX], 0發生內存訪問錯後,系統掛起,查找SEH處理鏈表,通知ExceptionHandler進行處理,ExceptionHandler中,將EBX修復到一個可以訪問的內存位置,再通知系統恢復環境繼續執行。當處理完後恢復原來的SEH結構,再還原堆棧,處理完畢。
VCL對SEH的封裝
       在Delphi裏我們通常使用try except end 和 try finally end 來處理異常,那麼在VCL裏是怎麼來實現的呢?
      1 VCL 的頂層異常捕獲
在DELPHI開發的程序中,出錯的時候,我們很少看到出現一個錯誤對話框,提示點確定結束程序,點取消調試。而在VC或VB裏就很常見,這是爲什麼呢?這是因爲VCL的理念是,只要能夠繼續運行,就儘量不結束程序,而VC或VB裏則認爲,一旦出錯,而開發者又不處理的話將會導致更嚴重的錯誤,所以乾脆結束了事。至於二者之間的優劣我們就不討論了,總之,有好有壞,關鍵要應用得當。
注意:後面的代碼都是以EXE程序來討論的,DLL的原理是一樣的
VCL的頂層異常捕獲是在程序入口函數StartExe處做的:
procedure       _StartExe(InitTable: PackageInfo; Module: PLibModule);
begin
 RaiseExceptionProc := @RaiseException;
 RTLUnwindProc := @RTLUnwind;
{$ENDIF}
 InitContext.InitTable := InitTable;
 InitContext.InitCount := 0;
 InitContext.Module := Module;
 MainInstance := Module.Instance;
{$IFNDEF PC_MAPPED_EXCEPTIONS}
 SetExceptionHandler; /// 掛上 SEH
{$ENDIF}
 IsLibrary := False;
 InitUnits;
end;
也就是在工程文件的begin處做的:
Project1.dpr.9: begin
00472004 55               push ebp
00472005 8BEC             mov ebp,esp
00472007 83C4F0           add esp,-$10    // 注意這裏,分配了 16 個字節的堆棧,其中的 12 個字節是用來存儲頂層異常結構的 SEH 內容
0047200A B8C41D4700       mov eax,$00471dc4
0047200F E81844F9FF       call @InitExe   // InitExe Sysinit 單元裏,我就不貼了, InitExe 接着就是調用 _StartExe
Project1.dpr.13: end.
00472044 E89F21F9FF       call @Halt0
00472049 8D4000           lea eax,[eax+$00]
SetExceptionHandler 的代碼:
procedure       SetExceptionHandler;
asm
 XOR     EDX,EDX                 { using [EDX] saves some space over [0] }
 LEA     EAX,[EBP-12]                 /// 這裏就是直接將 begin 處分配的內存指針傳給 EAX ,指向一個 TExcFrame 結構體
 MOV     ECX,FS:[EDX]            { ECX := head of chain                  }
 MOV     FS:[EDX],EAX            { head of chain := @exRegRec            }
 
 MOV     [EAX].TExcFrame.next,ECX
{$IFDEF PIC}
 LEA     EDX, [EBX]._ExceptionHandler
 MOV     [EAX].TExcFrame.desc, EDX
{$ELSE}
 MOV     [EAX].TExcFrame.desc,offset _ExceptionHandler /// 異常處理函數
{$ENDIF}
 MOV     [EAX].TExcFrame.hEBP,EBP /// 保存 EBP 寄存器, EBP 寄存器是一個非常關鍵的寄存器,一般用來保存進入函數時候的棧頂指針,當函數執行完後用來恢復堆棧,一旦這個寄存器被修改或無法恢復,用明叔的話說就是: windows 很生氣,後果很嚴重!
{$IFDEF PIC}
 MOV     [EBX].InitContext.ExcFrame,EAX
{$ELSE}
 MOV     InitContext.ExcFrame,EAX
{$ENDIF}
end;
介紹一下TExcFrame:
 PExcFrame = ^TExcFrame;
 TExcFrame = record
    next: PExcFrame;
    desc: PExcDesc;
    hEBP: Pointer;
    case Integer of
    0: ( );
    1: ( ConstructedObject: Pointer );
    2: ( SelfOfMethod: Pointer );
 end;
TExcFrame其實相當於在EXCEPTION_REGISTRATION基礎上擴展了hEBP和另外一個指針,這是符合規範的,因爲系統只要求前兩位就行了。一般的編程語言都會擴展幾個字段來保存一些關鍵寄存器或者其他信息方便出錯後能夠恢復現場。
當ExceptionHandler捕獲到了異常時,VCL就沒的選擇了,彈出一個錯誤對話框,顯示錯誤信息,點擊確定就結束進程了。
2 、消息處理時候的異常處理
大家可能有疑問了,那不是意味着程序裏沒有TRY EXCEPT END的話,出現異常就會直接退出?那麼我在button的事件裏拋出一個錯誤爲什麼沒有退出呢?這是因爲,DELPHI幾乎在所有的消息函數處理位置加了異常保護,以controls爲例子:
procedure TWinControl.MainWndProc(var Message: TMessage);
begin
 try
    try
      WindowProc(Message);
    finally
      FreeDeviceContexts;
      FreeMemoryContexts;
    end;
 except
    Application.HandleException(Self);
 end;
end;
一旦消息處理過程中發生了異常DELPHI將跳至Application.HandleException(Self);
進行處理:
procedure TApplication.HandleException(Sender: TObject);
begin
 if GetCapture <> 0 then SendMessage(GetCapture, WM_CANCELMODE, 0, 0);
 if ExceptObject is Exception then
 begin
    if not (ExceptObject is EAbort) then
      if Assigned(FOnException) then
        FOnException(Sender, Exception(ExceptObject))
      else
        ShowException(Exception(ExceptObject));
 end else
    SysUtils.ShowException(ExceptObject, ExceptAddr);
end;
       如果用戶掛上了application.onexception事件,VCL就會將錯誤交給事件處理,如果沒有,VCL將會彈出錯誤對話框警告用戶,但是不會結束程序。
       這種方式的好處就是,軟件不會因爲異常而直接中止,開發者可以輕鬆的在onexception裏接管所有的異常,壞處就是它破壞了系統提供的SEH異常處理結構,使得別的模塊無法獲得異常。
3 Try except end try finally end 做了什麼
       Try except end和try finally end在實現上其實沒有本質的區別,先介紹下第一個。
 
try except end 的實現:
       PASSCAL代碼(使用3個Sleep主要是用了觀看彙編代碼時比較方便隔開編譯器生成的代碼):
 try
    Sleep(1);
 except
    Sleep(1);
 end;
 Sleep(1);
 
編譯後代碼:
SEHSample.dpr.89: try
/// 掛上 SEH ,將異常處理函數指向到 00408D0E 實際上這個地址就直接跳轉到了 HandleAnyException (後面再介紹這個函數)
 
00408CEF 33C0             xor eax,eax
00408CF1 55               push ebp /// 保存了 EBP 指針
00408CF2 680E8D4000       push $00408d0e
00408CF7 64FF30           push dword ptr fs:[eax]
00408CFA 648920           mov fs:[eax],esp
 
SEHSample.dpr.90: Sleep(1);
00408CFD 6A01             push $01
00408CFF E8F8C1FFFF       call Sleep
 
/// 如果沒有發生異常,取消 SEH ,恢復堆棧
00408D04 33C0             xor eax,eax
00408D06 5A               pop edx
00408D07 59               pop ecx
00408D08 59               pop ecx
00408D09 648910           mov fs:[eax],edx
/// 沒有發生異常,跳轉到 00408D1F 繼續執行下面的代碼
00408D0C EB11             jmp +$11
 
///如果在異常處理裏用了on E:Exception 語法的話會交給另外一個函數
_HandleOnException處理,這裏不詳細介紹HandleAnyException的實現了,其中的很大一個作用就是把異常翻譯成DELPHI的EXCEPTION對象交給開發者處理,這就是爲什麼你只是聲明瞭個E:Exception沒有構造就直接可以使用,而且也不用釋放,其實是VCL幫你做了創建和釋放工作。
00408D0E E9ADAAFFFF       jmp @HandleAnyException
 
/// 發生異常後, HandleAnyException 處理完畢,交給開發者處理
SEHSample.dpr.92: Sleep(1);
00408D13 6A01             push $01
00408D15 E8E2C1FFFF       call Sleep
 
/// 執行清理工作,釋放異常對象,取消 SEH ,恢復 EBP
00408D1A E881ACFFFF       call @DoneExcept
 
SEHSample.dpr.94: Sleep(1);
00408D1F 6A01             push $01
00408D21 E8D6C1FFFF       call Sleep
當代碼進入try except end 結構時,首先掛上SEH,如果代碼正常執行,在執行完畢後取消SEH,這種情況比較簡單。如果出現了異常,那麼代碼就會跳到錯誤處理函數位置,首先會交給HandleAnyException處理,再返回到開發者代碼,最後執行DoneExcept進行清理工作。
 
Try finally end 的實現:
       Passcal代碼:
try
    Sleep(1);
 finally
    Sleep(1);
 end;
 Sleep(1);
編譯後代碼:
SEHSample.dpr.89: try
/// 掛上 SEH ,將異常處理函數指向到 00408D0E 實際上這個地址就直接跳轉到了 HandleFinally
00408CEC 33C0             xor eax,eax
00408CEE 55               push ebp
00408CEF 68168D4000       push $00408d16
00408CF4 64FF30           push dword ptr fs:[eax]
00408CF7 648920           mov fs:[eax],esp
 
SEHSample.dpr.90: Sleep(1);
00408CFA 6A01             push $01
00408CFC E8FBC1FFFF       call Sleep
 
/// 如果沒有發生異常,取消 SEH ,恢復堆棧
00408D01 33C0             xor eax,eax
00408D03 5A               pop edx
00408D04 59               pop ecx
00408D05 59               pop ecx
00408D06 648910           mov fs:[eax],edx
 
/// try finally end 結構後的用戶代碼放在棧頂,爲後面 ret 指令所作的工作
00408D09 681D8D4000       push $00408d1d
 
SEHSample.dpr.92: Sleep(1);
00408D0E 6A01             push $01
00408D10 E8E7C1FFFF       call Sleep
 
/// 彈回到 $00408d1d 處,就是 try finally end 後的代碼
00408D15 C3               ret
 
/// 處理異常 HandleFinally 處理完畢後,會跳轉到 00408D16 的下一段代碼,
           HandleFinally                      
MOV     ECX,[EDX].TExcFrame.desc /// 將錯誤處理函數保存在 ECX
        MOV     [EDX].TExcFrame.desc,offset @@exit
 
        PUSH    EBX
        PUSH    ESI
        PUSH    EDI
        PUSH    EBP
 
        MOV     EBP,[EDX].TExcFrame.hEBP
ADD     ECX,TExcDesc.instructions /// ECX 指向下段代碼
 
        CALL    NotifyExceptFinally
        CALL    ECX   /// 調用 ECX ,實際上就是 00408D1B
////////////////////////////////////
 
00408D16 E9D1ABFFFF       jmp @HandleFinally
/// 跳到 00408D0E 處,就是 FINALLY 內的代碼處
00408D1B EBF1             jmp -$0f
 
SEHSample.dpr.94: Sleep(1);
00408D1D 6A01             push $01
00408D1F E8D8C1FFFF       call Sleep
       當代碼進入到try finally end時,首先掛上SEH,如果代碼正常執行,取消SEH,將try finally end後的代碼地址壓入堆棧,再finally裏的代碼運行完畢後,ret就返回到了該地址。如果發生異常,跳到HandleFinally,HandleFinally處理完後再跳轉到finally裏的代碼,ret返回後,回到HandleFinally,返回 EXCEPTION_CONTINUE_SEARCH給系統,異常將會繼續交給上層SEH結構處理。
 
從代碼可以看出,簡單的try except end和try finally end背後,編譯器可是做了大量的工作,這也是SEH結構化異常處理的優點,複雜的東西編譯器都給你弄好了,開發者面對的東西相對簡單。
4 VCL 對象構造時的異常處理
       在Delphi開發的時候,經常會重載構造函數constractor,構造函數是創造對象的過程,如果這個時候出現異常VCL會怎麼辦呢?看代碼吧:
function _ClassCreate(AClass: TClass; Alloc: Boolean): TObject;
asm
        { ->    EAX = pointer to VMT      }
        { <-    EAX = pointer to instance }
        PUSH    EDX
        PUSH    ECX
        PUSH    EBX
        TEST    DL,DL
        JL      @@noAlloc
            /// 首先通過 NewInstance 構造對象,分配內存
        CALL    DWORD PTR [EAX] + VMTOFFSET TObject.NewInstance
@@noAlloc:
{$IFNDEF PC_MAPPED_EXCEPTIONS}
                  /// 掛上 SEH
        XOR     EDX,EDX
        LEA     ECX,[ESP+16]
        MOV     EBX,FS:[EDX]
        MOV     [ECX].TExcFrame.next,EBX
        MOV     [ECX].TExcFrame.hEBP,EBP
/// 將異常處理函數指向 @desc
        MOV     [ECX].TExcFrame.desc,offset @desc
/// EAX ,也就是對象實例存在在擴展字段裏
        MOV     [ECX].TexcFrame.ConstructedObject,EAX   { trick: remember copy to instance }
        MOV     FS:[EDX],ECX
{$ENDIF}
/// 返回,調用構造函數
        POP     EBX
        POP     ECX
        POP     EDX
        RET
 
{$IFNDEF PC_MAPPED_EXCEPTIONS}
@desc:
           /// 發生異常先交給 HandleAnyException 處理
          
        JMP     _HandleAnyException
 
 {       destroy the object                                                      }
            /// 異常處理完畢後,獲取對象
        MOV     EAX,[ESP+8+9*4]
        MOV     EAX,[EAX].TExcFrame.ConstructedObject
/// 判斷對象是否爲空
        TEST    EAX,EAX
        JE      @@skip
/// 調用析構函數,釋放對象
        MOV     ECX,[EAX]
        MOV     DL,$81
        PUSH    EAX
        CALL    DWORD PTR [ECX] + VMTOFFSET TObject.Destroy
        POP     EAX
        CALL    _ClassDestroy
@@skip:
 {       reraise the exception   }
/// 重新拋出異常
        CALL    _RaiseAgain
{$ENDIF}
end;
這也算一個VCL裏比較特殊的SEH應用吧,過程大概就是,對構造函數進行保護,如果出現異常就調用析構函數釋放。
這個地方很容易讓開發者犯錯誤,下面舉個例子:
type
 TTest = class
 private
    a: TObject;
    b: TObject;
 public
    constructor Create;
    destructor Destroy; override;
 end;
constructor TTest.Create;
begin
 inherited;
 a := TObject.Create;
 b := TObject.Create;
end;
 
destructor TTest.Destroy;
begin
 a.Free;
 b.Free;
 inherited;
end;
這段代碼看起來沒啥問題,可實際上卻不然,正常情況下,沒有異常可以順利通過,但如果a := TObject.Create;出現了異常,意味着b := TObject.Create;就不會被運行,b對象就不存在,這個時候VCL又會主動調用析構函數,結果b.free的時候就出錯了。所以在析構函數裏釋放對象的時候,一定要注意判斷對象是否存在。改正如下:
destructor TTest.Destroy;
begin
    if a <> nil then
 a.Free;
       if b <> nil then
 b.Free;
 inherited;
end;
結語
       以上就是我所瞭解到delphi裏關於SEH的處理了,內容基本是自己摸索出來的心得,有不當之處,歡迎指正。
參考資料
聯繫方式
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章