徹底搞懂異常控制流


 

《深入理解計算機系統》學習筆記與總結

  首先一個大的總結:在計算機中,使控制流發生突變的源頭被稱為異常控制流。異常是分為多個層級的,硬件異常與軟件異常。我們在討論異常的處理時也應該分情況討論。異常控制流存在的邏輯是:我們的程序除了需要對程序內部狀態的變化做出反應外,也應該可以對系統狀態的變化做出反應。

  而系統狀態的變化可能都不是來自程序自身,無法被程序內部捕獲。對於這類外部異常狀態的變化需要由硬件捕獲或者操作系統捕獲,后反饋給處理程序。

  硬件異常是由硬件與操作系統合作來進行處理的,比如處理器定義的異常以及中斷(來自系統調用、I/O設備信號等)。該類異常由硬件捕獲,處理器捕獲異常后會直接跳轉到異常處理程序對異常進行處理。而異常處理程序的指定與實現是由操作系統完成的。

  軟件異常又可以細分為內核異常與應用層異常。內核異常是由操作系統內核捕獲並反饋的,如windows的SEH。除硬件可以直接捕獲並反饋給操作系統的異常外,操作系統通常通過返回值判斷是否出現了異常,並通過結構化的處理方式對異常進行處理。而應用層異常由應用定義並捕獲處理,如C語言的setjmp與longjmp。C++與JAVA的try catch 機制可以看做是setjmp與longjmp更加結構化的版本。可以將catch看做類似setjmp的函數而throw就是類似longjmp的函數。

  異常發生的層級不同,異常的處理和傳播過程也是不同的,應該區分開來分析

  對於異常的處理,OS的處理與應用層級的處理應該分開來看。以一個除零異常為例子,這是處理器的四大故障之一,由處理器檢查並捕獲。

  處理器捕獲到故障的發生,如果處理器蠢一點會怎么辦,會直接跑飛或者停機。聰明一點的辦法便是通過IDT找到異常向量表並跳轉到操作系統的異常處理程序。

  操作系統通過異常處理程序拿到了異常,蠢一點的做法便是藍屏。聰明點的做法是將異常告知應用程序,由應用程序處理。如果應用程序處理不了,再由操作系統處理。告知應用程序的方式就是向進程發送一個信號,將控制交回給進程時,進程會先對信號做出響應,運行信號處理程序。

  如果信號的默認行為為終止進程且進程未修改該信號的對應行為,則進程直接終止。

  如果進程為該信號注冊了更聰明的處理函數,則執行該函數,執行完后繼續運行程序或退出進程。

  對於硬件級的異常,操作系統進行了對應的封裝,異常通過異常處理程序傳播到了操作系統。對於操作系統自定義的異常,是在操作系統層級靠返回值判斷並拋出的,通過信號傳播到了應用程序(也不一定,如windows的SEH就是應用向操作系統注冊處理函數而不是通過信號通知)。而對於應用層級自定義的異常,則需要應用層級自己判斷並拋出了。

  比如linux下,JVM修改了對linux的各種異常信號的默認行為,當發生這些內核級異常時,JVM內部都通過信號處理函數將其對應成了JAVA的異常類型,並在信號處理函數內部對異常進行了處理。而在JAVA中我們自定義的一個異常,在try塊中是不能被自動的檢測到的,因為其不會被操作系統發現並通知JVM,必須由我們自己顯式的throw。

  JVM為每個函數都分配了一個異常表,表中詳細記錄了每種異常生效的 try 塊的范圍。一旦一個異常傳播到了JVM,JVM首先檢查函數的異常表,發生異常的指令是否在該異常生效的范圍內,如果不在則在上層調用函數的異常表中繼續尋找。找到了處理該異常的函數層級,記錄堆棧信息后將后續被調函數的棧清空(棧展開)並在該層執行catch塊中的處理程序。

  對於final關鍵字是一個語法糖,在編譯期會將final中的代碼在try塊與catch塊中都拷貝一份,無論執行到那一個塊,都會執行final。也就是說,同樣的代碼我們在try塊與catch塊寫兩遍與使用fianl的效果是一樣的(try或catch中有return另說),編譯期幫我們做到了這一點。

  再來說 try 中有 return 的情況,final依然會執行,這是很有意思的一點。JVM規范中提到,如果try中有return語句,會先將待return的值存在一個一個本地變量中,執行完final后return這個臨時變量的值。而當try與final中均有return時,會忽略try的return。

  比如下面的程序會返回0,雖然final試圖改變返回的值:

    public static int test(){
        int i=0;
        try{
            return i;
        }catch(Exception e){
            e.printStackTrace();
        }finally{
            i=9;
        }
        return 10;
    }

  而下面的程序,會返回9,因為final中存在return所以忽略了try中的return:

    public static int test(){
        int i=0;
        try{
            return i;
        }catch(Exception e){
            e.printStackTrace();
        }finally{
            return 9;
        }
    }

  大總結結束,另外針對Java對異常處理進行一下延伸:

  如上面所說,異常可能會定義和發生在各個層面。比如硬件層定義的異常由硬件層感知,並通知操作系統由操作系統進行處理;操作系統層定義的異常在調用系統函數時發生,並由操作系統進行直接處理或委托用戶進程處理;而應用層定義的異常由用戶層自行判斷和感知,由應用層自行決定如何處理。異常,即為發生在各個層面的可預料或不可預料的邏輯錯誤,而異常處理則是異常的定義層面向異常的處理層面發送通知請求其處理的過程。在應用層,如Java中,異常的拋出和捕獲也是一樣的概念。

  異常的處理總是包含了異常的拋出、異常的捕獲並處理兩個動作。何時捕獲、何時拋出是異常處理時不可避免的重要問題。總的來說,異常的捕獲和處理需要遵循以下三個原則:

  1. 具體明確
  2. 提早拋出
  3. 延遲捕獲

  對於 1 不再細說,對於2,提早拋出:

  提早拋出的基本目的還是為了防止問題擴散,這樣出現異常的話排查起來會比較耗時,比較典型的一種情況是 NPE(NullPointerException),當某個參數對象為null時,如果不提早判斷並拋出異常的話,這個null可能會藏的比較深,等到出現NPE時就需要往回追溯代碼了。這樣就給排查問題增加了難度。所以我們的處理原則是出現問題就及早拋出異常。比如我們的調用層級較深時,我們應當在外層提早預測可能出現的異常並進行拋出,防止錯誤的邏輯進入下層代碼,避免出現問題在向下擴散的過程中出現更多的不確定性,降低異常的排查難度。

  而對於3.延遲捕獲:

  當異常發生時,不應立即捕獲,而是應該考慮當前作用域是否有有能力處理這一異常的能力,如果沒有,則應將該異常繼續向上拋出,交由更上層的作用域來處理。比如我們在MVC模型下,Dao層出現異常我們不應該捕獲,而應該拋出給 Service 層。因為同樣的一個錯誤比如 NPE 出現在 Dao 層時,Service 層的不同業務邏輯可能需要對異常做出不同的動作,Dao 層並不清楚它是在Service層中何種情景發生了異常,這種情況下,只需要將異常拋出給Service層,由Service層酌情進行更加合理的處理。

  下面開始正文:

1.什么是異常控制流

  在操作系統層面,最簡單的一種控制流是一個平滑的序列,執行的相鄰指令在內存中也是相鄰的。

  造成控制流的突變(執行的相鄰指令在內存中是不相鄰的),通常是由跳轉、調用和返回這些熟悉的指令造成的,這些指令的存在使得程序可以對由程序變量表示的程序內部狀態的變化做出反應

  除了對程序內部狀態的改變做出反應外,程序也應該能對系統狀態的變化做出反應系統狀態的變化並不一定來自程序自身,也並不能被內部程序變量捕獲。比如,一個磁盤IO完成后磁盤發出中斷信號通知進程繼續執行、一個時鍾中斷信號通知CPU進行進程調度、一個子進程終止並使其父進程得到通知。

  現代操作系統通過使控制流發生突變來對這些情況做出反應。一般而言,我們把這些突變稱作“異常控制流”(Exception Control Flow,EFC)

  異常控制流是發生在計算機系統的各個層次的,比如:

  在硬件層,硬件檢測到事件(如中斷、指令異常)會觸發控制突然轉移到異常處理程序。

  在操作系統層,內核通過上下文切換將控制從一個進程轉移到另一個進程(進程調度)。

  在應用層,一個進程可以發送信號給另一個進程,而接收者會將控制突然轉移到它的一個信號處理程序。

  一個程序可以回避通常的棧規則,並執行到其它函數中任意位置的非本地跳轉來對錯誤做出反應。(像C++、JAVA通過try、catch、throw來提供軟件異常機制。軟件異常允許程序進行非本地跳轉來響應錯誤情況,是一種應用層ECF)。

  應用程序與操作系統的交互都是基於ECF的,其中異常位於硬件與操作系統的交界處,信號位於操作系統與應用的交界處,而非本地跳轉是應用層的一種EFC形式

2.異常

2.1 異常概述

  異常是EFC的一種形式,它一部分由硬件實現,一部分由操作系統實現

  異常是為了響應處理器中某種狀態變化的控制流的突變。在處理器中,狀態被編碼為不同的位和信號,狀態的變化成為事件(event)。事件可能與當前執行的指令相關,如缺頁異常、除零異常或算術溢出;也可能與當前執行的指令無關,如硬件中斷的發生。

  但在任何情況下,處理器檢測的事件的發生,都會通過一個叫異常表的跳轉表進行間接過程調用,到一個專門用來處理該事件的操作系統子程序(異常處理程序(exception handler))。在異常處理子程序執行完后,根據引起異常的事件的類型,會發生以下三種情況之一:

  1. 重新執行引發事件的指令。

  2. 繼續執行引發事件指令的下一條指令。 

  3. 終止引發事件的程序。

  在該階段,處理器對事件進行檢測並固定跳轉到異常跳轉表,而操作系統實現了異常處理的具體程序並在操作系統啟動時初始化異常跳轉表。操作系統與硬件配合對異常進行處理

  異常處理的基本邏輯如下圖所示:

   其實無論是硬件異常還是軟件異常,處理邏輯均為跳轉到一個指定的異常處理程序,即通過控制流的突變對事件作出反應。

  區別在於硬件異常由硬件檢測並分發給操作系統處理,觸發機制為標志硬件狀態的狀態位。如中斷引腳、狀態寄存器等。而跳轉是由硬件與操作系統配合完成的,硬件將控制轉移到一個固定區域進行第一次跳轉,操作系統將處理邏輯注冊到控制區域,並由這些處理邏輯進行后續跳轉

  軟件異常是由軟件或操作系統定義並檢測的,觸發機制為系統內的變量或系統外的全局變量,由應用程序內的非本地跳轉(如C的setjmp/longjmp)或操作系統的異常處理程序分發(如windows的結構化異常處理SEH)

2.2 異常的處理

  因為異常的處理需要軟件與硬件的緊密配合,我們很容易搞混哪個部分執行哪個任務。

  系統中的每一個異常都被分配了一個唯一的非負異常號,這些異常有一部分是處理器設計者定義的,有一部分是操作系統設計者定義的。前者包括除零、缺頁、內存訪問違例、斷點以及算數運算溢出。后者包括系統調用和來自I/O設備的信號

  在系統啟動時(計算機重啟或加電),操作系統分配和初始化一張稱為異常表的跳轉表,使得表目k包含異常k的處理程序的地址。

  在運行時,處理器檢測到發生事件,並確定異常號。隨后處理器觸發異常,方法是執行間接過程調用,經過異常表目k跳轉到異常處理程序。

  

   異常表的基址存放在一個叫異常表基址寄存器的CPU特殊寄存器中。

   一旦硬件觸發了異常,剩下的工作是由異常處理程序在軟件中完成。在程序處理完后,通過一個“從中斷返回”的特殊指令可選的返回到被中斷的程序,該指令將適當的狀態彈回到處理器的控制和數據寄存器中。

2.3 異常的類別

  異常可以分為四類:故障(fault)、中斷(interrupt)、終止(abort)和陷阱(trap)。

  中斷

  中斷我外面有專門的博客寫過,略。

  陷阱和系統調用

  陷阱是有意的異常。可以理解為,我們用一條指令觸發了中斷(或者說模擬了中斷)。就像中斷處理程序一樣,陷阱處理程序將控制返回給下一條指令。

  陷阱最重要的用途是在用戶程序和系統內核之間提供一個像過程一樣的接口,叫做系統調用。

  可以想象,通過陷阱指令觸發一個中斷。CPU通過異常表找到對應的內核處理程序,執行完后控制權返回給下一條指令。該過程雖然涉及到了用戶態到內核態的轉換,但對於程序編寫者來說,執行與調用過程與調用了一個本地的函數並沒有區別。

  用戶程序經常需要向內核請求服務,如讀文件(read)、創建新進程(fork)、加載一個新程序(execve)或終止當前進程(exit)。為允許對這些內核服務的受控訪問,處理器提供了一個特殊的指令 “syscall n” ,當用戶程序想要請求服務 n 時,可以執行者條指令。

  故障  

  故障由錯誤引起,它可能會被異常處理程序修正。如果可以修正,控制返回到引起故障的指令並重新執行(如缺頁異常楊),否則返回內核的abort例程終止該應用程序。

  終止  

  由不可恢復的致命錯誤造成。如 DRAM 或 SRAM 位損壞時造成的奇偶錯誤。終止處理程序從不將控制返回應用程序而是直接終止應用程序。

2.4 Linux/x86-64 異常類型

  在 x86-64 系統中有高大 256 種異常。其中 0-31 號異常是由Intel架構師定義的,對任何 x86-64 系統都是一樣的。32-255 號碼對應的是操作系統定義的中斷或陷阱

   Linux/x86-64中的故障和終止

  除法錯誤:浮點異常。

  缺頁異常:會加載所缺頁並重新執行引起該異常的指令。

  一般保護故障:段故障。嘗試訪問非法地址或寫入只讀地址等內存錯誤。

  機器檢查:致命硬件錯誤。

  Linux/x86-64中的系統調用  

  在x86-64系統上,通過一個syscall指令進行系統調用。所有到 linux 系統調用的參數都是通過寄存器傳遞的而不是棧。按照慣例 %rax 包含系統調用號,%rdi,%rsi,%rdx,%r10,%r8 和 %r9 包含最多6個參數。第一個參數在 %rdi 中,第二個參數在 %rsi 中,以此類推。

  調用返回時,%rcx 和 %r11 都會被破壞,%rax 包含返回值,-4095到-1之間的負數值代表發生了錯誤。對應於負的 errno 。

  比如一個簡單的系統調用,先設置寄存器的值后執行 syscall 指令:

   當 Unix 系統級函數遇到錯誤時,它們通常會返回 -1 ,並設置全局的 errno 變量標識錯誤類型,我們應該總是檢查錯誤是否發生。該處的返回 -1 是函數邏輯的結果,我們可以將其作為異常控制流(可以作為異常的源頭進行拋出),進行異常處理。

3.信號

  相對於前面所說的硬件與軟件的配合實現的基本的低層異常機制,信號是一種更高層的軟件形式的異常。它允許進程和內核中斷其它進程。

  信號就是一個小消息,它通知進程系統發生了一個某種類型的事件。比如下面是linux中的一些信號:

   傳送一個信號到目的進程是由兩個不同的步驟組成的。

  發送信號內核通過更新進程上下文中的某個狀態,發送一個信號給目的進程。發送信號有如下兩種原因:

  1. 內核檢測到一個事件,如除0錯誤或子進程終止。

  2. 一個進程調用了 kill 函數。

  接收信號:當目的進程被內核強迫以某種方式對信號的發送做出反應時,它就接收了信號。進程可以忽略這個信號、終止或者通過執行一個叫做信號處理函數的用戶層函數來捕獲該信號。

  當內核把進程p從內核模式切換到用戶模式時(如從系統調用返回或者進行了一次進程調度),它會檢查p的待處理信號集合。由於進程調度的存在,使得信號響應的時效性得到了一定的保障。每個信號都有一個預定義的默認行為,是下述四種情況之一:

  進程終止、進程終止並轉儲內存、進程掛起直到被SIGCONT信號喚醒、進程忽略該信號。

  進程可以使用 signal 函數修改信號關聯的默認行為。唯一的例外是SIGKILL 和SIGSTOP ,它們的默認行為不能被修改。

  signal函數可以通過下述三種方式改變與信號signum關聯的行為:

  1. 如果 handler 為 SIG_IGN,則行為為忽略signum。

  2. 如果 handler 為SIG_DFL,則恢復signum的默認行為。

  3. 否則,handler就是用戶自定義的函數的地址,這個函數被稱為信號處理程序。只要進程接收到一個類型為signum的信號則會調用該程序。

  下圖中我們將處理 SIGINT 信號的行為改為輸出一條消息后退出進程:

   信號處理程序可以被其它信號處理程序中斷,邏輯如下圖所示:

   信號有一個有違直覺地方就是同類型的信號是不會排隊的,如果排隊信號中已有了一個信號k,那么再來的信號k就會被簡單的丟棄。一個典型的例子便是在子進程結束通知父進程時,如果有多個子進程的結束信號 SIGCHLD 同時到達父進程,那么會有部分信號被丟棄。未被處理的子進程則變成了僵死進程。

  所以每當父進程接收到一個 SIGCHLD 信號時並不代表只有一個子進程結束了,而是代表至少有一個子進程結束了。其它信號也是如此,在linux中,如果父進程接收到一個 SIGCHLD 信號,並不是回收一個子進程,而是盡可能多的回收結束的子進程。

  另外因為信號是可以穿插在進程的運行過程中的任意階段執行的,所以會帶來一些並發問題。比如 fork 函數,父進程創建子進程並在進程表種種添加子進程。如果創建子進程之后,在進程表中添加子進程之前,父進程被時鍾信號打斷轉而執行子進程,而子進程在這一次時鍾時間內結束並向父進程發送SIGCHLD 

信號。那么當父進程再次被調度時,會先響應信號刪除表中的子進程,而此時表中還沒有子進程。響應完信號后父進程才執行將子進程加入進程表的操作,而此時子進程已經不存在了。

  這是一個稱為競爭(race)的經典同步錯誤示例。目前的解決方法是在執行 fork 函數時先顯示的禁止對信號的響應(顯示的阻塞信號),然后在將紫禁城添加到進程表后解除阻塞,以此來避免類似的並發問題。

  在通常的並發編程中,我們的重點在於不同線程對共享變量的訪問安全問題。而在信號帶來的並發問題中(信號處理程序與源程序的並發),我們關注的重點是信號處理程序可能在邏輯流的任意時刻執行,因為我們無法預知進程的調度時間,如果某一信號的處理會導致某個函數的邏輯錯亂,那么在執行該函數前我們應該阻塞對信號的處理,函數執行結束時再恢復對該信號的響應

4.非本地跳轉  

  C語言提供了一種用戶級異常控制流形式,稱為非本地跳轉。

  受限於棧規則,普通的跳轉指令如 goto 僅能將控制轉移到當前執行函數內部的某處代碼處。而非本地跳轉無視棧規則,直接將控制從一個函數轉到另一個當前正在執行的函數,而不需要經過正常的調用-返回序列。

  非本地跳轉是通過 setjmp 和 longjmp 提供的。

 

 setjmp 在 env 緩沖區中保存當前環境,已供 longjmp 調用恢復到當前的執行節點。longjmp可以顯式的改變 setjmp 的返回值。

  setjmp有違直覺的地方是這個函數被調用了一次卻返回了兩次。但真正的情況是,setjmp被調用了兩次,一次是我們在代碼中進行的顯式調用,返回0。另一次是longjmp 跳轉到當前執行節點后(這個節點是setjmp第一次執行的節點),setjmp再次被執行,並返回了不同的結果,這個結果是 longjmp 指定的。

  非本地跳轉的一個重要作用是允許從一個深層嵌套的函數中立即返回(這里如果不理解看一下函數調用時棧的變化過程),通常是由檢測到某個錯誤引起的。如果一個深層嵌套的函數發生錯誤,我們可以直接使用非本地跳轉將控制返回到一個普通的本地化錯誤處理程序,而不是費力的解開調用棧。

  longjmp 允許所有中間調用的特性可能導致的后果。比如中間調用函數中分配了某些數據結構,本來打算在函數調用結尾釋放它們,那么這些釋放代碼會被跳過,因而產生內存泄漏。

  非本地跳轉的另一個重要應用是使信號處理程序分支到一個特殊的程序位置,而不是被信號處理程序中斷的指令位置。比如下述代碼實現了使用 SIGINT 信號對應用的重啟:

執行結果:

 

下面是一個C語言中使用非本地跳轉處理除0異常的例子:

#include<stdio.h>
#include<stdlib.h>
#include<setjmp.h>

int divide(int a, int b, jmp_buf jmpBuf){
    char *s = (char *)malloc(sizeof(char) * 10);
    if(b == 0){
        free(s); 
        printf("b == 0\n");
        longjmp(jmpBuf,1);
    }
    printf("b != 0\n");
    return a/b;
}

int main(int argc, char const *argv[])
{
    jmp_buf jmpBuf;
    if( (setjmp(jmpBuf)) !=0 ){
        printf("divide by zero\n");
        goto end;
    }
    divide(10,0,jmpBuf);
end:   
    return 0;
}

  下面我們再來看一下 JS 中處理異常的代碼:

var longJump=function(reason){
    try{
        var result=1/reason;
        if(reason===10){
            throw new Error("值為10");
        }
        if(reason===20){
            throw new Error("值為20");
        }
        console.log(result);
    }catch(err){
        console.log("錯誤信息為  :  "+err.message);
    }
}
longJump("sss");
longJump(10);
longJump(20);

  對於 JS 解釋器或更底層所捕獲的異常,由底層直接通過信號將發生的異常通知進程,進行異常的處理。但未被底層定義的邏輯錯誤必須由我們自己創建異常對象進行處理,其實底層定義的異常也是底層的邏輯錯誤。我們想一下,硬件層定義的異常由硬件層反饋給操作系統,操作系統進行處理(操作系統也會有委托用戶進程處理異常的邏輯,比如windows中我們注冊異常處理程序);操作系統定義的異常在調用系統函數時觸發,陷入異常處理流程,由操作系統通過信號反饋給相關進程,進程進行處理;而進程層面的邏輯錯誤,由我們自己判斷和處理。

  異常處理的過程是一個層層遞進,由異常定義層到異常處理層的向上反饋和委托的過程。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM