1. 程式人生 > >深入理解計算機系統----第八章異常控制流

深入理解計算機系統----第八章異常控制流

原文連結 https://www.jianshu.com/p/c8a6c4154219

目  錄

每次從一條指令過渡到另外一條指令的過程稱為控制轉移,這樣的一個控制轉移序列叫做控制流,如果每條指令都是相鄰的,這樣的過渡就是平滑序列。如果一條指令與另外一條指令不相鄰,這樣突發性的過渡稱為異常,也就是我們這一章要學到的異常控制流(Exceptional Contro Flow)。學習這些知識將有助於我們併發這一重要的概念,異常控制流其實發生在系統的各個層次,我們將從硬體層的ECF:異常、作業系統層的ECF:程序和訊號、應用層的ECF非本地跳轉三大部分講起,逐步帶領大家打牢一些重要的系統概念的真正含義,理解應用程式與作業系統的互動(syscall)、編寫Web伺服器的方法、併發、軟體異常工作原理等等。廢話不多說,開始飆車了。

1.1 異常(硬體層)


處理器狀態中的事件,觸發了從應用程式到異常處理程式的突發性控制轉移就是:異常

這一過程如上圖所示,應用程式本來在執行Icur指令,但是有些事件(定時器訊號、算術溢位等)會使得處理器的狀態發生變化,這時候處理器會通過一張異常跳轉表,進行跳轉到專門的異常處理程式中,異常處理程式執行完任務以後:可能返回當前正在執行指令、返回當前下一條指令或者終止被中斷的程式。

舉一個小時候的例子:在我很小的時候,經常和一些小朋友彈彈珠,這時候玩兒的正起勁兒(正在執行當前指令),突然母親大人在門口一聲大喊:“xxx 回家吃飯了”(突發性事件)。我就必須要放下手頭上正在玩兒的遊戲,一溜小跑回家吃飯(異常處理程式)。吃完飯以後,我可以選擇繼續玩,玩其他遊戲,或者不玩遊戲(異常處理程式完成以後)。

① 異常處理

我們來描述一下異常處理的這個過程,當你按下計算機的啟動按鈕的時候,作業系統分配和初始化一張異常跳轉表,包含k個異常條目和其處理程式的地址,如下圖:

當執行本機上的一個應用程式的時候,如果處理器檢測到了一個事件,並且確定了異常號4,處理器就會觸發異常,通過異常表中的地址4跳轉到相應的異常處理程式中執行。這一過程如下:

(說明:1>異常表的起始地址存放在異常表基址暫存器中,通過異常號就能計算出具體的異常條目地址;2>異常處理模式不同於普通的函式呼叫,不需要將返回地址壓入棧中,也不需要儲存額外的一些狀態,由於是執行在核心模式下,意味著擁有對所有資源的訪問許可權)

② 異常分類

中斷:是非同步發生的,來自處理外部的I/O裝置訊號的結果。如我目前在打字的鍵盤:

我的電腦上也在播放音樂,當在聽歌的同時輸入字元到螢幕上的時候,處理器就會注意到I/O裝置鍵盤上的中斷引腳電壓變高了,當前指令執行完畢以後就會從系統匯流排中讀取異常號,然後呼叫中斷處理程式,輸入完字元以後,中斷處理繼續執行聽歌程式的下一條指令。由於這個過程相當的快,就好像什麼都沒有發生一樣。

陷阱(陷入核心):實現系統呼叫,在使用者程式和核心之間提供一個像函式呼叫一樣的介面。

比如要讀一個檔案的內容(read),這些核心服務受到控制的訪問,處理器提供的是syscall n指令來響應使用者的請求,導致一個陷阱異常,這個異常程式對引數解碼並呼叫核心程式。這個異常處理程式執行在核心模式中。(後面詳解這一過程)

故障:能夠被故障處理程式修正的錯誤。

故障發生時,處理器將控制轉移到故障處理程式中,由故障處理程式修正錯誤。如果能夠修正就返回當前指令重新執行,如果不能修正就返回到核心的abort中。

終止:通常是由一些硬體引起的不可恢復的致命錯誤

直接返回到abort中,終止該應用程式。

③ Linux系統中的異常

IA32有高達256種不同的異常,0-31的號碼是Intel架構師定義的;32-255號是作業系統定義的中斷和陷阱。

系統呼叫:陷入到核心中執行(不知道為啥翻譯成了陷阱)

Linux系統上有數百個系統呼叫,比如常用的讀檔案、寫檔案、建立程序等等,這些系統呼叫都有一個唯一的整數編號:

由於歷史的原因,系統呼叫通過異常128來處理(0x80)提供。我們來看看使用系統級函式寫出的hello world程式:

我們直接來看看hello程式的組合語言版本:

所有Linux系統呼叫的引數都是通過暫存器而不是棧來傳遞的。按照慣例:行9:eax中包含呼叫號;行10-12:設定引數;行13:使用int 指令來呼叫系統呼叫。(write系統呼叫)

1.2 程序基礎知識(作業系統層)


我們講了一些異常的基礎知識,下面來看看異常是怎樣實現了電腦科學上最偉大的一個成就:程序。對於程序最經典的定義就是一個執行中的程式例項。程序是一個偉大的魔術師,她提供給每個執行的程式一種假象,好像每個程式都在獨佔處理器和地址空間。我們接下來不會討論程序的實現細則,我們關注的是程序提供給程式兩個關鍵抽象:邏輯控制流和私有地址空間。把這兩點理解清楚也就夠了:

程序控制流:

上圖是一個運行了三個程序A、B、C的系統,處理器的控制流分成了3個,每一個程序1個。隨著時間的增加,程序A先運行了一小段(①),然後程序B執行直到結束(②),隨後程序C運行了一小段(③)後切換到程序A執行直到A結束(④),最後切換到程序C執行直到結束(⑤)。這樣一來每個程序執行它的流的一部分,然後被搶佔。由於CPU總是毫秒級別的轉移我們什麼都不會察覺到。就提供了一種每個程式獨佔的假象。

至於併發就很好理解了,說白了就是某程序開始執行以後並未完成以後,跳轉到其他程序執行,這兩個就是併發。如上圖的程序A和程序B,程序A和程序C。由於程序B執行結束以後才開始程序C,所有B和C不算是併發。

時間片:程序A執行它控制流的一部分的每個時間片段,就叫時間片。

私有地址空間:

程序為每一個程式提供它自己的私有地址空間

這個私有的地址空間最上部是核心保留的,最下部是預留給使用者程式的。程式碼始終是從0x08048000處開始(32位系統)。

使用者模式和核心模式:

處理器為了安全起見,不至於損壞作業系統,必須限制一個應用程式可執行指令能訪問的地址空間範圍。就發明了兩種模式使用者模式和核心模式,其中核心模式(上帝模式)有最高的訪問許可權,甚至可以停止處理器、改變模式位,或者發起一個I/O操作,處理器使用一個暫存器當作模式位,描述當前程序的特權。程序只有當中斷、故障或者陷入系統呼叫時,才會將模式位設定成上帝模式,得到核心訪問許可權,其他情況下都始終在使用者許可權中,就能夠保證系統的絕對安全。

上下文切換機制:

核心中有一個專門的排程程式,當從程序A切換到程序B的時候,核心排程器為每個程序儲存一個上下文狀態(執行環境儲存):包含程式計數器、使用者棧、狀態暫存器等,然後切換到另外一個程序處開始執行。

當核心代表使用者執行系統呼叫的時候,就會發生上下文切換,如上圖所示,當程序A呼叫read函式的時候,核心代表程序A開始執行系統呼叫讀取磁碟上的檔案,這需要耗費相對很長的時間,處理器這時候不會閒著什麼都不做,而是開始一種上下文切換機制,切換到程序B開始執行。當B在使用者模式下執行了一段時間,磁碟讀取完檔案以後傳送一箇中斷訊號,將執行程序B到程序A的上下文切換,將控制權返回給程序A系統呼叫read指令後面的那條指令,繼續執行程序A。(注:在切換的臨界時間核心模式其實也執行了B一個小段時間)

1.3 程序的控制


① fork函式為例:封裝系統呼叫錯誤處理

系統級別的函式遇到錯誤時,通常會返回-1,並設定全域性變數errno來標識是什麼地方出錯了,通過,strerror(errno)可以解析出錯誤描述的字串。

我們看上面的錯誤檢查程式,有時候覺得每次系統呼叫函式都這樣處理就太臃腫了,於是我們來簡化上面的這種錯誤檢查的方法,定義unix_error函式,如下:

這樣當我們要檢查fork函式是否有錯誤的時候就可以這樣使用:

這裡就會輸出對應的fork error系統呼叫錯誤,我們同樣的認為上面的函式有點兒臃腫,於是在每個系統呼叫函式中,用首大寫的Fork函式來定義如下的檢查錯誤呼叫方法:

這樣一來,錯誤就會通過pid = Fork();這樣就會顯示的將錯誤資訊打印出來,並返回值。

② 建立和終止程序

我們使用fork()函式通過系統呼叫建立一個與原來程序幾乎完全相同的程序,除了PID不同外,子程序可以讀寫父程序中開啟的任何檔案。一個程序呼叫fork()函式後,系統先給新的程序分配資源,例如儲存資料和程式碼的空間。然後把原來的程序的所有值都複製到新的新程序中,只有少數值與原來的程序的值不同。相當於克隆了一個自己。fork函式有一個特別的地方,雖然只被呼叫一次,卻能返回兩次。我們來看:

我們使用的是封裝好錯誤處理的首字元大寫的Fork函式,第一次一次返回是在父程序中,返回的是子程序的PID;一次是在子程序中,返回的是0;因為PID總是非零,返回值為0就說明在子程序中執行了。

編譯生成fork可執行檔案,下面是結果:

然而fork函式這樣的執行方式令人疑惑的,我們來分析一下這輸出的兩個不同的x的值。當呼叫pid = Fork();的時候,第一次返回的是程序的子ID,由於不為0,所以繼續執行main函式中的printf,列印輸出x = 0;第二次就在子程序中執行了,返回的pid為0表示在子程序中執行,由於兩個程序有相對獨立的地址空間,子程序得到的只是父程序的一個拷貝,所以x的初始值仍然是1,輸出的結果就是x=2了。(至於為啥都輸出在螢幕上了,是因為這兩個程序共享已經被開啟的stdout檔案,子程序是繼承父程序的,因此輸出也是指向螢幕的)

我們來看看程序分析圖:(以三次呼叫fork函式為例)

第一次呼叫,紅色部分分出的子程序打印出4次printf,第二次呼叫fork藍色線,打印出2個hello;第三次呼叫土黃顏色,打印出2個hello。

③ 回收子程序:waitpid函式

當我們使用fork函式建立了一個子程序的時候,子程序就會在獨立的地址空間執行,我們並不是放任其一直執行,而是希望在某些時候回收子程序,瞭解子程序的狀態,而不是讓其消耗儲存器的資源(殭屍狀態)。這時候就需要用到waitpid函式,我們看看函式定義:

如果呼叫waitpid函式時,等待的子程序已經執行結束,該函式會立即返回。否則父程序會被阻塞,暫停執行。引數詳解如下:

pid : 等待的集合成員(pid>0為單獨的一個子程序ID號,pid=-1等待所有子程序);

status:檢查已回收子程序的退出狀態,有了這個資訊父程序就可以瞭解子程序為什麼會推出,是正常推出還是出了什麼錯誤。

options : 修改預設的行為如果不想使用這些選項,則可以把這個引數設為0。

返回值:如果成功就返回子程序ID;如果沒有失敗返回-1(沒有子程序的失敗設定ECHILD;被中斷設定EINTR)

還有一個簡化的版本wait函式,wait(&statue)相當於waitpid(-1,&statue,0)。

waitpid函式有點兒複雜,我們來看看兩個使用示範:

第一個我們編寫如下waitpid1.c檔案

說明:在for迴圈內,我們建立了N=2個程序,並且保證在子程序中執行的時候使用exit返回分別為100,101兩個值;在接下來的while迴圈內,父程序使用waitpid函式作為迴圈測試檢測每個子程序的狀態,當子任意一個程序返回的時候,waitpid會返回其pid值,並將退出子程序的狀態儲存到status中去,也就是在while迴圈體內輸出的status值。當回收了所有子程序以後waitpid就會返回-1,不再執行while迴圈了。我們執行的結果就是:

至於返回的順序,是不定的。甚至在同一系統兩次不同的執行都有不一樣的結果。如果想規定回收的順序。就只有在11行,顯示的儲存下每個程序的pid,然後在第16號,按照程序的pid進行回收。

④ 讓程序休眠:sleep函式和pause函式

⑤ 載入並執行程式:execve函式(呼叫一次,從不返回)

其中,filename是execve載入並執行的可執行檔名,argv是引數列表,envp是環境變數列表。結構如下:

當開始一個新程式的時候,使用者棧結構如下:

⑥ 利用fork和execve執行程式(一個簡單的殼的實現)

我們接下來展示一個簡單的外殼程式(read/evaluate),使用100行左右的程式碼。主要完成兩個操作,讀取(read)命令列和求值(evaluate)執行程式,來看看主要部分:

簡單的讀取使用者輸入的命令列,使用求值函式(eval)解析命令列,並代表使用者執行程式。如上圖的Fgets函式將讀取的命令列儲存到cmdline中,然後傳遞給求值函式(eval)。

求值函式(eval)中,首要任務是使用parseline函式,解析以空格分割的命令列引數,並將最後的結果儲存在argv向量中。然後使用Fork函式建立程序,在程序中呼叫execve函式執行

我們來使用我們自己寫的殼程式來執行一下,我們之前寫過的waitpid1程式:

看到了,這就是使用了fork和execve寫的一個殼,shellex來執行我們自己的程式。我們沒有做好的就是不回收子程序,接下來我們學習訊號,來彌補這個缺陷。

1.4 訊號


訊號是一種更高層次的軟體形式的異常,它允許程序中斷其他程序。一個訊號就是一個訊息,我們列出Linux系統上30個不同種類的訊號:

正在執行的前臺子程序,當鍵入ctrl-c,傳送序號2(SIGINT);當一個程序傳送訊號9(SIGKILL)就會強制終止另外一個程序;當子程序終止時,就會發送訊號17(SIGCHILD)給父程序。

舉個例子:訊號就像你每天早上起床而設定(呼叫kill函式)的鬧鐘一樣,你接收到這個鬧鐘以後,被強迫要處理這個訊號(一直鬧也沒辦法睡覺啊),這時候你有三種選擇:繼續睡覺(忽略)、關閉鬧鐘(終止)、起床(執行鬧鐘給我的命令:訊號處理程式)。

如何傳送訊號

1> 使用/bin/kill程式傳送訊號(使用完整路徑)

傳送9號(SIGKILL)訊號給程序6279終止該程序,如果使用-6279就是該程序組的所有程序;

2> 從鍵盤傳送訊息(ctrl-c)

外殼程式,允許一個前臺程式和多個(或者0)個後臺程式。形成如下圖的結構:

我們使用:ls | sort

就會建立兩個程序組組成的前臺作業,兩個程序分別是ls和sort,都屬於同一個前臺程序組20。那麼如何使用鍵盤傳送訊號給程序呢?我們使用ctrl-z組合鍵來看看(傳送SIGTSTP掛起前臺作業):我們在命令列輸入top命令:

這是top命令執行時的狀態,注意,當我們按下ctrl-z組合鍵的時候看最下角顯示的內容:

表示接收到了我們使用鍵盤傳送的ctrl-z(SIGTSTP)訊號。掛起top程式。

3> 呼叫kill函式傳送訊號(可以傳送給自己)

當子程序執行到Pause函式的時候,將等待訊號的到達,主程序這時候使用kill函式傳送的是,SIGKILL訊號,這將終止子程序的執行,所以子程序中的printf函式從來不會被執行,介面無任何顯示。

4> 使用alarm函式傳送訊號

主函式main中使用Signal函式將SIGALRM訊號,與處理函式handler繫結,接收到了SIGALRM訊號以後就會跳到handler函式處開始執行。Alarm函式(第19行)傳送一個SIGALRM訊號,在handler函式中非同步處理這個訊號。打印出5個BEEP和一個BOOM!。

接收訊號

當程式執行的時候,如果接收到了訊號,就會把控制權轉移到訊號處理程式中,執行完訊號處理程式以後才返回程式的下一條指令繼續執行,如下圖:

訊號有預定義的預設行為:

程序終止;程序終止並轉儲存器;程序停止直到SIGCONT重啟;忽略;

然而我們可以使用signal修改程式的預設行為:

這裡定義了handler函式,和SIGINT函式繫結,當我們在鍵盤上輸入ctrl-c的時候,打印出一行字“Caught SIGINT”並退出。

我們來看看執行的效果:

我們不做任何輸入的時候,由於main中的pause,將等待訊號的傳送。這時候我們使用鍵盤上輸入ctrl-c組合鍵,就會看到:

列印了一句我們handler函式中的那段話,並退出。

訊號處理原則

我們前面的例子中,程式只是捕獲一個訊號進行處理,當有多個訊號到達時,如何處理,遵循下列原則,請看:

1> 待處理訊號被阻塞:拿我們在接收訊號處理程式中的sigint1中的程式為例,當我們的程式正在處理handler函式時,如果又捕獲到了一個SIGINT訊號,這時候並不會停止handler函式的處理,而是將這個SIGINT訊號放到帶處理程式的位置(阻塞),直到handler函式執行完畢返回以後才接受這個待處理訊號;

2> 待處理訊號不會排隊等待:還是以sigint1函式為例,如果正在處理handler函式,接受到了2個訊號,這時候先到的那個訊號會變成待處理訊號被阻塞,最後的那個訊號直接被丟棄;

3> 系統呼叫可以被中斷:諸如read、wait函式,會阻塞程序一段時間,當處理程式捕獲到一個訊號時,被中斷的系統呼叫在處理程式返回的時候就不會再執行了。

我們之前開發過一個簡單的殼程式,當時我們說由於沒有回收子程序,我們僵死的子程序會佔用記憶體空間,不利於殼程式的長久執行。我們學習了這麼多基礎知識,來嘗試升級我們的殼處理程式:

版本1:

在這個程式中,我們為了讓父程序可以自由做自己想做的事情,就決定對SIGCHLD訊號進行捕獲並處理在handler1函式中回收資源(子程序終止的時候會發送該訊息)。設定好訊號處理程式以後,我們使用for迴圈建立了3個子程序,並打印出子程序的pid號,每個子程序執行1秒並終止。同時父程序將等待終端的一個輸入行,隨後處理它(我們模擬為無限期處理while迴圈)。

我們來看看執行效果:

主程序並沒有阻塞,我們還可以輸入內容,我輸入的sjljf字串。我們注意到很有意思的一點,我們建立的子程序並沒有完全被回收,最後一個pid為6480的子程序沒有被回收,而是變成了一個殭屍程序,這是怎麼回事呢?我們是沒有處理好原則【 待處理訊號不會排隊等待】解釋一下這個過程:當handler1函式正在回收6478號程序的時候,收到了6479號程序的回收請求訊號,這時候6479被加入到待處理訊號位置,又過了1秒鐘,6480程序的回收訊號也來了。由於已經有了待處理訊號6479,所以6480程序的回收訊號將被簡單的丟棄掉。這就是問題所在。

版本2:

改進的核心主要是在handler函式中,我們使用一個while迴圈,儘可能多的回收我們的子程序

我們看看執行效果,已經將所有子程序全部回收完畢

版本3中針對原則3系統呼叫中斷後重啟read系統呼叫問題進行了修正,由於我們沒有Solaris系統,這裡就不做實驗了。更新的程式碼如下:

1.5 併發程式設計初步介紹:同步流(避免併發錯誤)


併發程式設計是一個很深奧且重要的問題,我們將在12章花費一章節講述,這裡我們來初窺一下其中的奧祕介紹其中的:競爭(一個經典的同步錯誤例項)

說明:先執行delete再執行addjob,之間的競爭

1> 父程序呼叫fork函式,建立新子程序並執行該子程序;

2> 在父程序能夠再次執行前,子程序就終止的話,子程序就會變成一個殭屍程序,核心傳遞一個SIGCHLD訊號給父程序;

3> 父程序可執行前會檢測到右SIGCHLD訊號,將跳轉到handler函式中去;

4> handler函式呼叫deletejob函式,卻什麼也做不了,因為主程序中並沒有使用addjob函式;

5> 從handler函式返回以後,父程序再呼叫addjob函式就會新增錯誤的子程序在列表中去。

我們嘗試修復在這個競爭引起的同步錯誤。我們接下來將使用方法阻塞SIGCHLD訊號,使得程式始終保持addjob在前,deletejob在後的狀態。由於子程序程序了父程序中的這種阻塞,所以在子程序中首要任務是解除阻塞。具體修改的程式碼如下:

主要使用Sigprocmask(SIG_BLOCK)阻塞SIGCHLD訊號。

1.6 非本地跳轉(應用層)


本地跳轉是我們非常熟悉的goto語句,然而有些弊端的是不能跳到函式外部去。這時候非本地跳轉的概念就因運而生了。這樣做有一個好處是如果多層呼叫的函式最內層出現了錯誤,可以直接跳轉到特定區域執行我們的錯誤分析函式。來看一個例子:

我們來解釋一下程式碼的意思,由於setjmp和longjmp都要使用到jmp_buf,我們將其設定成全域性變數。setjmp函式第一次呼叫的時候,是儲存當前的呼叫環境到buf的緩衝區,並返回0,作為接下來longjmp跳轉的目標地址;通過if的條件判斷rc == 0成立,就會呼叫foo()函式,foo函式繼續呼叫到bar函式,判斷error2 = 1成立執行一次longjmp跳轉,將跳轉到setjmp儲存的呼叫環境中去。並把2返回給rc。這時候在執行判斷就會輸出錯誤的型別,並將結果打印出來:

非本地跳轉還有一個應用就是實現軟重啟,類似於我們重啟某種服務一樣。我們來看程式碼:

這段程式碼相當簡單,使用的是訊號版本的sigsetjmp和siglongjmp函式。當sigsetjmp函式第一次被呼叫的時候儲存呼叫環境和訊號向量(阻塞和待處理的),並返回0,所以執行if裡面的語句打印出“starting”字串,然後就開始在主函式中的while迴圈中執行了。這時候如果輸入ctrl-c就會捕獲到這個訊號,通過handler處理函式進行了一次siglongjmp跳轉,跳轉的目的地是sigsetjmp處的位置,這時候執行if中的else語句打印出restarting字串,並繼續主函式的執行。結果如下圖: