1. 程式人生 > >【Linux】Linux程序訊號詳解

【Linux】Linux程序訊號詳解

一、引入訊號概念

訊號其實我們也見過,當我們在shell上寫出一個死迴圈退不出來的時候,只需要一個組合鍵,ctrl+c,就可以解決了,這就是一個訊號,但是真正的過程並不是那麼簡單的。

1、當用戶按下這一對組合鍵時,這個鍵盤輸入會產生一個硬體中斷,如果CPU正在執行這個程序的程式碼時,則該程序的使用者程式碼先暫停執行,使用者從使用者態切換到核心態處理硬體中斷

2、終端驅動程式將這一對組合鍵翻譯成一個SIGINT訊號記在該程序的PCB中(也就是傳送了一個SIGINT訊號給該程序)

3、當某個時刻要從核心態回到該程序的使用者·空間程式碼繼續執行之前,首先處理PCB中的訊號,發現有一個SIGINT訊號需要處理,而這個訊號的預設處理方式是終止程序,所以直接終止程序,不再返回使用者空間執行程式碼。

注意:ctrl+c只能終止前臺程序。一個命令可以加&可以將程序放在後臺執行,這樣shell就不必等待程序結束就可以接收新的命令,啟動新的程序

2、shell可以同時執行一個前臺程序和多個後臺程序,只有前臺程序才能收到ctrl+c這種組合鍵產生的訊號

3、前臺程序在 執行過程中使用者可以隨時按下ctrl+c產生一個訊號也就是說前臺程序的使用者空間程式碼執行到任意一個時刻都可能接收到SIGINT訊號而終止,所以訊號對於程序的控制流來說是非同步的。

二、訊號介紹

在bash上執行命令kill -l便可看到系統定義的所有訊號


我們只研究前31個訊號,後面31個是實時訊號這裡不做研究

每個訊號都有一個編號和一個巨集定義名稱,這些巨集定義都可以在signal.h中找到,在man手冊中還可以找到各種訊號的詳細資訊

man 7 signal


這裡具體介紹了訊號在什麼時候產生,處理的動作是什麼

三、產生訊號的方式

1、通過鍵盤的組合鍵產生,比如ctrl+c產生SIGINT訊號,ctrl+\產生SIGQUIT訊號,ctrl+z產生SIGTSTP訊號

2、硬體異常產生訊號,這些條件由硬體檢測並通知核心,然後核心向程序傳送適當的訊號,比如執行了除以零的指令,程序訪問了非法記憶體地址,cpu的運算單元都會產生異常,核心將這個異常解釋成一個個訊號傳送給程序

3、一個程序呼叫kill(2)函式可以傳送訊號給另一個程序。可以用kill(1)傳送訊號給某一個程序kill(1)也是用kill(2)實現的如果不清楚指定訊號則傳送SIGTERM訊號,該訊號的預設處理動作是終止程序,當核心檢測到軟體條件發生時也可以通過訊號通知程序例如鬧鐘超時,會產生SIGALRM訊號,向讀端已經關閉的管道檔案寫資料時產生SIGPIPE訊號,如果不想按照預設動作處理訊號,使用者可以呼叫sigaction(2)函式告訴核心如何處理某種訊號

4、軟體條件產生

四、訊號常見處理方式

1、忽略該訊號

2、執行訊號的預設處理動作

3、提供一個訊號處理函式,要求核心在處理訊號時切換到使用者態執行這個處理函式,這種方式稱為捕捉一個異常

五、訊號產生具體過程

1、通過終端按鍵來產生訊號

SIGINT的預設處理動作是終止程序,SIGQUIT的預設處理動作是終止程序並Core Dump,我們在Linux環境下來驗證一下,

先來了解一下什麼是Core Dump

當一個程序要異常終止時,可以選擇把程序的使用者空間記憶體資料全部儲存在磁碟上,檔名通常是core,這叫做Core Dump。程序異常終止通常是因為有BUG,比如非法訪問記憶體導致段錯誤,事後可以用偵錯程式檢查core檔案以查清楚錯誤原因,這叫做事後除錯,一個程序允許產生多大的core檔案取決於程序的Resource Limit(這個資訊儲存在PCB中),預設是不允許改變這個限制,允許產生core檔案。首先用ulimit命令來改變shell程序的Resource Limit,允許core檔案最大為1024k

ulimit -c 1024


寫一個死迴圈程式


編譯並執行程式:


看到的現象是先打印出pid然後一直在死迴圈,按下組合鍵ctrl+\後退出並提示core dumped

test程式也會core  dump的原因是我們先修改了shell的Resource Limit值,而test程序是由shell產生的所以test程序的PCB也是由shell複製而來,所以test程序和shell就具有相同的Resource Limit值,所以就會產生core  dump了。


如圖所示就是產生的core檔案

我們來使用core檔案


2、呼叫系統函式來向程序發訊號

首先在後臺執行一個死迴圈程式,然後用kill 命令給它發訊號


說明:我們之所以要多按一次回車,是因為3035程序終止掉之前已經回到了shell提示符等待使用者輸入下一條命令,shell不希望錯誤資訊和使用者命令混在一起,所以先等使用者輸入後再顯示

指定傳送某種訊號的kill命令可以有多種,上面的命令還可以寫成kill -11 3035,11是訊號SIGSEGV訊號的編號。以往遇到的段錯誤都是由非法記憶體訪問引起的,而這個程式本來也沒錯誤,給它傳送一個SIGSEGV訊號也能引起段錯誤

kill命令是由kill函式實現的,kill函式可以給一個指定的程序傳送指定的訊號,raise函式可以給當前程序傳送指定的訊號(自己給自己發訊號)

下來來介紹一下這兩個函式

函式原型:


引數解釋:

第一個引數程序id

第二個引數訊號標號

返回值:成功返回0失敗返回-1

引數解釋:

訊號標號

返回值:成功返回0失敗返回-1


函式功能:

使當前程序接收到訊號而異常終止

引數:

無引數

返回值;

無返回值

和exit函式一樣abort函式總是會成功,所以無返回值

3、由軟體條件產生的訊號

軟體條件產生的訊號我們已經見過一種,就在我們學習程序間通訊的時候,訊號SIGPIPE就被我們介紹過,我們在這裡不再多加介紹,我們接下來要介紹一種有趣的訊號和產生這種訊號的函式,我們可以想想,有一種聲音我們每個人最不想聽到的一種聲音是什麼,當然是每天的鬧鐘聲了,我們介紹的這個訊號就和現實中的鬧鐘很像,

今天要介紹的訊號就是SIGALRM訊號,以及產生這種訊號的函式alarm

先來看一下函式原型:


函式功能:

設定一個鬧鐘,告訴核心在seconds秒後給當前程序傳送一個SIGALRM訊號,該訊號的預設處理動作是終止當前程序

函式引數解釋:

鬧鐘的時間是多少秒

函式返回值:

這個函式的返回值是0或者鬧鐘剩下的秒數,當你一直不修改鬧鐘,直到鬧鐘響這時的返回值是0,當在設定的秒數之內修改了鬧鐘的秒數就會返回上個鬧鐘剩下的時間,將seconds值設為零表示取消鬧鐘

來段程式碼來測試一下吧:


執行結果如下:


程式碼中設定一個鬧鐘和一個計數器,在鬧鐘響前,count一直++,並輸出count值直到鬧鐘響,接收到SIGALRM訊號才結束程序

六、阻塞訊號

1、訊號其他相關概念

實際執行訊號的動作叫做訊號遞達

訊號從產生到遞達過程中的狀態叫做訊號未決,

程序可以選擇阻塞某個訊號

被阻塞的訊號將處於未決狀態,直到程序解除對訊號的阻塞,才執行遞達的動作

注意:阻塞和忽略是不同的,只要訊號被阻塞就不會被遞達,而忽略是在遞達之後所選擇的一種處理動作

2、訊號在核心中的表示示意圖


解釋說明:每個訊號都有兩個標誌位分別表示阻塞和未決,還有一個函式指標表示處理動作,訊號產生時,核心在程序控制塊中設定該訊號的未決標誌,直到訊號遞達才會清除該標誌。如果程序解除對某訊號的阻塞之前該訊號產生過很多次,將如何處理?OPSIX.1允許遞達該訊號一次或多次。Lunix是這樣實現的,常規訊號在遞達之前產生多次只記一次,而實時訊號在遞達之前產生多次可以依次放在一個佇列裡。在這裡,不討論實時訊號。

3、sigset_t

由上圖可知每個訊號都只有一個bit的未決狀態,不是0就是1,阻塞標誌也是一樣。因此阻塞和未決可以用相同的資料型別sigset_t來儲存,sigset_t稱為訊號集,這個型別可以用來表示訊號的有效和無效狀態,阻塞訊號集也叫作當前程序的訊號遮蔽字,這裡的遮蔽應理解為阻塞而不是忽略。

4、訊號集操作函式

一下就是我們常用的訊號集操作函式:

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo); 

sigemptyset函式初始化set指向的訊號集,使其中的所有訊號對應的bit位清零,表示該訊號集不包含任何有效訊號。

sigfillset函式初始化set指向的訊號集,使其中所有的訊號對應的bit位置位,表示該訊號集的有效訊號包括系統支援的所有訊號。

注意:在使用sigset_t型別的變數之前一定要用sigemptyset函式和sigfillset函式初始化是訊號集處於確定的狀態,初始化之後就可以使用sigaddset函式和sigdelset函式在該訊號集中增加或者刪除有效訊號。

以上四個函式,成功返回0,失敗返回-1

最後一個,sigismember是一個bool函式用於判斷一個訊號集的有效訊號中是否包括某種訊號,若包含返回1,若不包含返回0,若出錯返回-1

還有一個函式

sigprocmask函式;函式原型

函式功能:可以用該函式讀取或更改該程序的訊號遮蔽字(阻塞訊號集)。

引數解釋:

如果oset是非空指標則讀取程序的訊號遮蔽字通過oset引數傳出,如果set是非空指標,則更改程序的訊號遮蔽字,引數how指示如何修改。如果oset和set都是非空指標則先將原來的訊號遮蔽字被分到oset裡然後根據set和how更改訊號遮蔽字。假設當前程序的訊號遮蔽字是mask,下面是how引數的三個可選值:

SIG_BLOCK  set包含了我們希望新增到當前訊號遮蔽字的訊號,相當於mask=mask|set;

SIG_UNBLOCK:set包含了我們希望從當前訊號遮蔽字中解除阻塞的訊號相當於mask=mask&~set

SIG_SETMASK:設定當前訊號遮蔽字為set所指向的值,相當於mask=set

如果呼叫了sigprocmask解除了對當前若干個未決訊號的阻塞,則在sigprocmask返回前,至少講一個訊號遞達。

還有一個函式:sigpending函式

函式原型:

函式功能:

讀取當前訊號的未決訊號集,通過set引數傳出。呼叫成功反悔0,失敗返回-1.下面使用前面介紹的幾個函式來寫一段簡單的小程式:


執行結果如下:

七、捕捉訊號

訊號捕捉示意圖


1、核心如何實現訊號的捕捉呢?

(1)首先在使用者正常執行主控制流程由於中斷,異常或系統呼叫而直接進入核心態進行處理處理這種異常,

(2)核心處理完異常就準備返回使用者態了,在這之前會看當前程序有沒有可以抵達的訊號,如果有就對可遞達的訊號進行處理,

(3)如果訊號的處理函式是使用者自定義的就返回使用者態去執行使用者自定義的訊號處理函式

(4)訊號處理函式執行完之後,會呼叫一個特殊的系統呼叫函式sigreturn而再一次進入核心態,執行這個系統呼叫

(5)這個系統呼叫完成之後,就會返回主控制流程被中斷的地方繼續執行下面的程式碼

(6)執行主控制流程的時候如果再次遇到異常、中斷或系統呼叫就繼續回到(1),繼續執行下面的流程

2、下來介紹幾個重要的函式

(1)sigaction

函式原型:

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact); 

函式功能:

可以讀取和修改與指定訊號相關聯的處理動作

函式引數解釋:

引數一:指定訊號的編號

引數二引數三:若act指標非空,則根據act修改該訊號的處理動作。若oact為非空指標,則通過oact傳出該訊號原來的處理動作,act和oact指向sigaction結構體

說明:將sahandler賦值為常數SIGIGN傳給sigaction表示忽略訊號,賦值為常數SIG_DFL標示執行系統預設動作,賦值為一個函式指標表示用自定義函式捕捉訊號,或者說向核心註冊了一個訊號處理函式,該函式返回值是void,可以帶一個int引數,通過該引數可以知道該訊號的編號,,這樣就可以用一個函式處理多種訊號。顯然這也是一個回撥函式,不是被main函式呼叫,而是被系統呼叫

返回值:

成功返回0,失敗返回-1

補充:當某個訊號的處理函式被呼叫時,,核心將該訊號加入程序的訊號遮蔽字,當訊號處理函式返回時自動恢復原來的訊號遮蔽字,這樣就保證了在處理某種訊號時,如果這種訊號再次產生,那麼它會被阻塞直到當前處理完成,如果在呼叫訊號處理函式時,除了當前訊號被自動遮蔽之外,還希望自動遮蔽另一個訊號則用samask欄位說明這些需要額外遮蔽的訊號,,當訊號處理函式返回時自動恢復原來的訊號遮蔽字saflags欄位包括一些選項,在這裡我們常擦saflags設為0

(2)pause函式

函式原型:

#include <unistd.h>
int pause(void);

函式功能:

是呼叫程序掛起直到訊號遞達

函式引數:

無引數

返回值:

如果訊號的處理動作是終止程序,則程序終止,pause函式沒有機會返回;如果訊號的處理動作是忽略,則程序繼續處於掛起狀態,pause不返回,如果訊號的處理動作是捕捉訊號,則呼叫了訊號處理函式之後pause返回-1,errno設定為EINTR,所以pause只有出錯的返回值(EINTR表示“被訊號中斷”)。

下面我們用alarm函式和pause函式來實現一個mysleep

執行結果:


執行程式碼的現象是每五秒列印一句話。

下來對程式碼進行簡要分析:


八、可重入函式


向上例這樣,insert函式被不同的控制流程呼叫,有可能在第一次呼叫沒返回時就再次進入該函式,這叫做重入,insert函式訪問一個全域性連結串列,有可能因為重入而造成錯亂,像這樣的函式稱為不可重入函式,反之,如果一個函式只訪問自己的區域性變數或引數,則稱為可重入函式。

說明:如果一個函式符合下列條件之一則該函式是不可重入的:

1、呼叫了malloc或free,因為malloc也是用全域性變數來管理堆的

2、呼叫了標準I/O庫函式。標準I/O庫函式的很多實現都是以不可重入的方式使用全域性資料結構。

九、關鍵字volatile

這個關鍵字在我們學習c語言的時候就已經學到了,其功能是保證記憶體的可見性,保證執行的時候直接在記憶體取資料而不在暫存器中取。

它是一個限定符,在上面一個例子中main函式和訊號執行函式都呼叫insert函式則有可能導致連結串列的錯亂,其根本原因是連結串列的插入操作分為兩步,而且不是原子的(要麼不做,要麼全做),如果這兩步一起完成,中間不會被打斷,就不會出現錯亂了。    

我們來考慮一種情況,如果對全域性的訪問只有一行程式碼會不會是原子的呢?我們來測試一下:


使用gcc test2.c -g命令加上除錯資訊,然後使用objdump -dS a.out顯示彙編程式碼:


我們擷取main函式中對a賦值的彙編程式碼:


通過觀察就算是這一條語句也需要兩步來完成,所以不是原子操作,可以根據上面insert函式的例子,要是在第一步完成之後,出現一個異常,而返回的時候出現一個訊號,訊號的執行函式也是對a賦值,那麼變數a的賦值就會造成混亂,如果上述語句在64位機上執行就是原子操作,在32位機上,a的資料型別是int也是原子操作,而在16位機上就不行了,為了在各個平臺下都能實現賦值的原子操作,c標準定義了一個型別sigatomict,在不同平臺下就取不同的型別例如在32位機上就取的是int,這個型別    在使用時還需要注意一些問題。

看下面一個例子:


然後使用和上例相同的方式將彙編程式碼打印出來,


分析彙編程式碼:

將全域性變數a從記憶體讀到暫存器,對eax和eax做AND運算,若結果為0則跳過迴圈開頭,再次從記憶體中讀出變數a的值,可見這三條指令等價於c程式碼中的while(!a);迴圈。


再看彙編程式碼:


第一條指令將全域性變數a的記憶體單元與0比較,如果相等,則第二條指令成了一個死迴圈,注意,這時一個真正的死迴圈:即sighandler將a改為1,只要沒有影響0標誌位,回到main函式後仍然死在第二條指令上,因為不會在記憶體讀取變數a的值。

這實際上並不是編譯器的錯,如果程式只有單一的執行流程,只要當前執行流程沒有改變a的值,a的值就沒有理由會變,,不需要從記憶體讀取,因此上面的兩條指令和while(!a);迴圈是等價的,並且優化之後減少了每次迴圈訪問記憶體的次數,效率會很高,,只是編譯器無法識別程式中存在的多個執行流程    。之所以存在多個執行流程,是因為呼叫了特定平臺上的特定庫函式,比如sigaction、pthread_create,這些並不是c語言本身的規範,不歸編譯器管,程式設計師應該自己處理這些問題

c語言提供了volatile限定符如果將上述變數的定義改為

volatile sigatomict a =0;那麼即使指定優化級別,編譯器也不會優化掉對a記憶體單元的讀寫

volatile關鍵字適用場景:

1、程式中存在多個執行流程訪問同一個全域性變數的情況

2、變數的記憶體單元中的資料不需要寫操作就可以自己發生變化,每次讀上來的值都可能不一樣

3、即使多次向變數的記憶體單元中寫資料,只寫不讀,也並不是在做無用功,而是也有意義的,這樣的記憶體一般都不是普通的記憶體單元而是對映到記憶體地址空間的硬體暫存器例如串列埠接收暫存器,和傳送暫存器

說明:sig_atomic_t型別的變數總是加上volatile限定符,因為要使用sig_atomic_t型別的理由也是使用volatile限定符的理由

九、競態條件與sigsuspend函式

我們再來考慮一下以前寫的mysleep函式,我們使用alarm函式設定鬧鐘之後呼叫pause函式進行等待,可是SIGALRM訊號已經處理完了還在等什麼呢?

出現這個問題的根本原因是系統執行的時序並不像我們寫程式時想得那樣,雖然alarm函式設定鬧鐘後,後面緊跟的是pause函式,但是不能保證pause函式一定會在nsecs秒之內被呼叫。由於非同步事件在任何時候都可能發生(非同步指出現更高優先順序的程序),如果我們寫程式的時候考慮不周,就有可能會產生時序問題而導致錯誤,這就叫做競態條件。

解決這種問題一般有兩種思路,一種是在呼叫pause之前遮蔽SIGALRM訊號使它不能提前遞達就好了

我們將程式碼執行過程分為四步

(1)遮蔽SIGALRM訊號

(2)alarm(nsecs)設定鬧鐘

(3)解除遮蔽

(4)pause();

這樣的話SIGALRM訊號也可能在解除遮蔽和呼叫pause之間的時間間隔內遞達

我們又可以設想將解除訊號遮蔽放在pause()函式呼叫之後,執行過程就變為:

(1)遮蔽SIGALRM訊號

(2)alarm(nsecs)設定鬧鐘

(3)pause();

(4)解除遮蔽

這樣更不行還沒有解除遮蔽就呼叫pause,pause根本不可能等到SIGALRM訊號,經過這兩步的分析我,我們最想得到的就是將解除遮蔽和等待放在一起,讓他們中間不要間斷的執行,也就是這兩條程式碼的執行是原子的。sigsuspend函式的功能就是這個

對時序要求嚴格的都應該呼叫sigsuspend函式而不是pause

接下來介紹一下這個函式:

函式原型:


引數解釋:

用來指定訊號遮蔽字

返回值:

沒有成功返回值,只有執行了一個訊號處理函式後才會返回,返回-1,errno設為EINTR

十、SIGCHILD訊號

前面我們知道清除殭屍程序的方法就是使用wait和waitpid函式父程序可以阻塞等待子程序結束,也可以非阻塞的查詢是否有子程序需要被清理(輪詢),第一種方式父程序阻塞就不能做其他事情了,第二種,父程序不斷去詢問,程式碼實現比較複雜

其實子程序在終止時會給父程序發一個SIGCHILD訊號,預設處理動作是忽略,使用者可以自定義SIGCHILD的處理函式,這樣父程序就可以專心處理自己的事情,不用關心子程序了,子程序退出時會通知父程序,父程序在訊號處理函式中呼叫wait來處理子程序就可以了    

補充:想不產生殭屍程序還有另外一種方法:父程序呼叫sigaction將SIGCHILD處理動作置為SIG_IGN這樣fork出的子程序在終止時會自動清理掉也不會通知父程序系統預設的忽略和使用者自定義的忽略一般是沒有區別的,但這是一個特例,對於Linux可以用,在其他unix系統上不一定能用。