探索 Linux 信號機制:有效管理進程間通信
在 Linux 的世界裏,進程就像生活在城市中的人,它們需要相互溝通來協調行動。而信號機制呢,就像是一種神奇的 “信號彈”,用於進程之間的交流。當一個進程有重要消息要傳達給另一個進程時,就會發射出這樣的 “信號彈”。這就是 Linux 信號機制,它是管理進程間通信的一把 “金鑰匙”,讓我們一起深入瞭解它是如何發揮作用的吧。
一、概述
Linux 的信號機制作爲進程間通信的重要方式,發揮着關鍵作用。它本質上是一種軟件中斷,能夠異步地通知進程發生了特定事件。信號的全稱爲軟中斷信號,簡稱軟中斷,在頭文件 <signal.h> 中定義了 64 種信號,這些信號的名字都以 SIG 開頭,且都被定義爲正整數,稱爲信號編號。可以用 “kill -l” 命令查看信號的具體名稱。
其中,編號爲 1~31 的信號爲早期 Linux 所支持的信號,是不可靠信號(非實時的),編號爲 34~63 的信號時後來擴充的,稱爲可靠信號(實時信號)。不可靠信號與可靠信號的區別在於前者不支持排隊,可能會造成信號丟失,而後者的註冊機制是每收到一個可靠信號就會去註冊這個信號,不會丟失。
信號機制可以類比爲硬件中斷,當某個事件發生時,就像硬件中斷一樣,能夠打斷進程的正常執行流,迫使進程去處理特定的事件。例如,當用戶在終端按下 Ctrl+C 時,會產生 SIGINT 信號,表示進程應被終止;當控制終端被關閉時,會發送 SIGHUP 信號,常用於通知守護進程重新讀取配置。信號機制爲進程間的通信和交互提供了一種靈活且有效的方式,使得不同進程能夠在特定事件發生時做出相應的反應。
二、信號基本原理
信號機制是 UNIX 系統最古老的機制之一,它不僅是內核處理程序在運行時發生錯誤的方式,還是終端管理進程的方式,並且還是一種進程間通信機制。信號機制由三部分構成,首先是信號是怎麼產生的,或者說是誰發送的,然後是信號是怎麼投遞到進程或者線程的,最後是信號是怎麼處理的。下面我們先看一張圖:
從圖中我們可以看到信號的產生方式也就是發送方有三種。首先是終端發送,比如我們在終端裏輸入 Ctrl+C 快捷鍵時,終端會給當前進程發送 SIGINT 信號。其次是內核發送,這裏的內核發送是指內核裏的異常處理的信號發送,比如進程非法訪問內存,在異常處理中就會給當前線程發送 SIGSEGV 信號。最後是進程發送,也就是一個進程給另一個進程發送或者是進程自己給自己發送。這裏有很多接口函數可以選擇,有的可以發給線程,有的可以發給進程,有的可以發給進程組甚至會話組。
下一個過程就是信號是如何從發送方發送到目標進程或者線程的信號隊列裏的,這個過程叫做投遞。不同的發送方,其發送方式和投遞過程是不同的,這個後面會展開講。
最後是信號的處理過程,這個最複雜牽涉問題最多。信號發送可以發送給進程或者線程,但是信號的處理是在線程中進行的,因爲線程是代碼執行的單元。線程首先處理自己隊列裏的信號,自己的處理完了再去處理進程隊列裏的信號。處理的時候要考慮信號掩碼 (mask),被掩碼阻塞的信號暫時不處理,還放回原隊列中去。信號處理方式有三種,如果程序什麼也沒設置的話,走默認處理(default) 方式。默認處理有五種情況,不同的信號,其默認處理方式不同。這五種情況分別是 ignore(忽略)、term(終結進程也就是殺死進程)、core(coredump 內存轉儲並殺死進程)、stop(暫停進程)、cont(continue 恢復執行進程)。還有兩種方式是進程提前通過接口函數 signal 或者 sigaction 設置了處理方式,設置 IGN 來忽略信號,或者設置一個信號處理函數 handler 來處理信號。大家注意,默認處理中的忽略和進程主動設置的忽略,兩者的邏輯是不同的,一個是默認處理是忽略,一個是進程主動要求要忽略。你想要忽略一個默認處理不是忽略的信號,就必須要主動設置忽略。
三、信號的分類與產生
我們明白了信號的基本原理之後,就要進一步追問,系統都有哪些信號呢,這些信號有什麼不同呢?剛開始的時候,UNIX 系統只有 1-31 總共 31 個信號,這些信號每個都有特殊的含義和特定的用法。這些信號的實現有一個特點,它們是用 bit flag 實現的。這就會導致當一個信號還在待決的時候,又來了一個同樣的信號,再次設置 bit 位是沒有意義的,所以就會丟失一次信號。爲了解決這個問題,後來 POSIX 規定增加 32-64 這 33 個信號作爲實時信號,並規定實時信號不能丟失,要用隊列來實現。我們把之前的信號 1-31 叫做標準信號,由於標準信號會丟失,所以標準信號也叫做不可靠信號,由於標準信號是用 bit flag 實現的,所以標準信號也叫做標記信號 (flag signal)。由於實時信號不會丟失,所以實時信號也叫作可靠信號,由於實時信號是用隊列實現的,所以實時信號也叫做排隊信號 (queue signal)。我們平常遇到的 SIGSEGV、SIGABRT 等信都是標準信號。
3.1 信號的分類
可靠信號與不可靠信號、實時信號與非實時信號在很多方面存在區別。
可靠信號與不可靠信號:不可靠信號主要來自早期的 Unix 系統,其存在一些問題。例如,進程每次處理完信號後,系統會自動將該信號的處理方式恢復爲默認操作,這就需要在信號處理函數的末尾再次調用 signal() 函數重新綁定處理函數,增加了編程複雜性。而且,不可靠信號可能會丟失,當進程正在處理一個信號時,如果相同類型的另一個信號到達,第二個信號可能會被直接丟棄。而可靠信號支持排隊,即使進程在處理某個信號時有新的信號到達,這些信號也不會丟失,而是被加入隊列,待當前信號處理完成後再依次處理。Linux 引入了新的信號發送函數 sigqueue() 和信號綁定函數 sigaction() 來增強信號處理的靈活性和可靠性。
實時信號與非實時信號:非實時信號一般指編號在 1 到 31 之間的信號,不支持排隊,處理時沒有嚴格的順序保證,且如果在處理某個信號時有相同類型的新信號到達,後者可能會被忽略或丟失,所以也被稱爲不可靠信號。實時信號是編號在 34 到 64 之間的信號,支持排隊,即使在處理某個信號期間有新的相同類型的信號到達,這些信號也不會被丟棄,而是按照到達的順序依次處理,因此被稱爲可靠信號。
信號是單線程時代的產物。在單線程時代,一個進程就只有一個線程 (就是主線程),所以進程就是線程,線程就是進程。信號所有的屬性既是進程全局的又是線程私有的,因爲這兩者沒有區別。但是到了多線程時代,這兩者就有區別了,進程是資源分配與管理的單元,線程是程序執行的單元。一個進程往往有多個線程,那麼信號的這些屬性究竟應該是進程全局的還是線程私有的呢?這還真不好處理的。經過一番慎重的分析與思考,UNIX 系統做出瞭如下的決定。
信號的發送既可以發送給進程,也可以發送給線程,但是同步信號 (也就是和當前線程執行相關而產生的信號) 應當發送給當前線程。進程發送信號可以選擇不同的接口函數,有的接口是發給進程的,有的接口是發給線程的。線程信號隊列中的信號只能由線程自己處理,進程信號隊列中的信號由進程中的線程處理,具體是由哪個線程處理是不確定的。
-
信號掩碼 (mask) 的設置是線程私有的,每個線程都可以設置不同的信號掩碼。
-
信號處理方式的設置是進程全局的,後面線程設置的方式會覆蓋前面線程的設置。
-
信號處理的效果是進程全局的。
我們先說默認處理的幾種情況:忽略一個信號是指整個進程忽略這個信號,而不是說某個線程忽略了其它線程還可以去處理。終結是終結的整個進程,而不只終結一個線程。內存轉儲是整個進程進行內存轉儲並終結整個進程。Stop 是暫停整個進程而不是隻暫停一個線程。Cont 是恢復執行整個進程而不是隻恢復執行一個線程。
非默認處理有兩種情況:如果進程設置了忽略某個信號,則是整個進程都忽略這個信號,而不是某個線程忽略這個信號。如果進程設置了信號處理函數 handler,則 handler 的執行效果是進程全局的。這點怎麼理解呢?可以從兩方面來理解,一是如果信號是發送給進程的,則每個線程都有可能來執行這個 handler;二是 handler 雖然是在某個線程中執行的,但是對於線程來說,只有線程棧是線程私有的,其它內存是整個進程共享的,handler 對線程棧的影響是線程私有的,handler 返回之後它的棧幀就銷燬了,handler 只有對全局內存的影響纔會留下來,所以它的影響是進程全局的。
我們再來總結一下:信號可以發送給進程也可以發送給線程。發送給線程的信號只能由線程處理,如果線程阻塞了信號則信號會一直 pending,直到線程解除阻塞然後就會去處理該信號。發送給進程的信號可以由該進程中的任意一個未阻塞該信號的線程來處理,具體哪個線程是不確定的,如果所有線程都阻塞該信號,則該信號一直 pending,直到任一線程解除阻塞。信號無論是怎麼發送和處理的,信號的處理效果都是進程全局的。
3.2 信號類型詳解
⑴標準信號與實時信號的區別
我們知道信號分爲標準信號和實時信號,它們之間最大的區別就是在信號處於待決的狀態下又來了同樣的信號會怎麼處理。除此之外,它們還有以下三點不同。
-
實時信號如果使用接口 sigqueue 發送的話,可以攜帶一個額外的整數信息或者指針信息。
-
實時信號有優先級,數值越小優先級越高,優先級高的優先處理,同等優先級的按照先來後到的順序處理。
-
標準信號都是預定義信號,每個信號都有特定的含義,而實時信號則沒有預定義的含義。
根據特點 3,兩個進程可以使用實時信號來達到進程間通信的目的。因爲實時信號沒有特定的含義,所以系統不會使用實時信號,進程之間可以自行約定某個信號的含義。而且不同的進程之間可以約定不同的含義而不會相互影響。不過 glibc 的 pthread 實現使用了 32、33 這兩個實時信號,所以大家不要用這兩個實時信號。
⑵信號的屬性特徵
可阻塞:我們可以通過某些接口來阻塞 (暫時屏蔽) 一個信號。但是有的信號可以阻塞,有的信號無法阻塞。有的信號雖然可以成功設置阻塞,但是其信號會被強制發送,所以最終還是阻塞不了。比如內核在異常處理時會強制發送信號,所以是阻塞不了的。但是同樣的信號你用 kill 來發,阻塞還是生效的,因爲 kill 不是強制發送。信號阻塞,有很多地方會叫做信號屏蔽,兩者都是一樣的。但是屏蔽容易被人和忽略理解混了,所以本文裏用阻塞。阻塞,含義明確,就是阻塞住了,後面不阻塞了信號還是會到來的。
可忽略:有些信號默認處理就是忽略的,但是有些信號默認處理不是忽略。如果我們想忽略這些信號的話,可以通過一些接口設置來忽略它。有些信號是可以設置忽略的,但是有些接口無法設置忽略。有的信號雖然可以設置忽略成功,但是內核在異常處理時會強制發送信號,這時忽略是無效的。不過同樣的信號用 kill 來發,忽略就是有效的,因爲 kill 不是強制發送。大家注意忽略和阻塞不同,阻塞是暫時不處理,而忽略其實也是一種處理,相當於是空處理。
可捕獲:我們可以通過一些接口來設置信號處理函數 handler 來處理信號,這個行爲叫做捕獲。有些信號是能捕獲的,有些信號是不能捕獲的。與可阻塞和可忽略不同的是,強制發送的信號也是可捕獲的。但是可捕獲存在一個特殊情況,有些時候是不能二次捕獲的。有兩個信號 SIGSEGV、SIGABRT 是不能二次捕獲的,後面會進行講解。
默認處理:默認處理是當我們沒有設置忽略和捕獲函數時,內核對信號的默認處理方式。前面已經介紹過有五種處理方式,這裏就不再贅述了。由於大部分的信號處理是 terminate 或者 coredump,都是會導致進程死亡的,所以信號發送命令叫做 kill。其實 kill 並不會殺死進程,它只是給進程送了個信號而已。
發送者:這裏指的是信號在一般情況是從哪裏發送的,表明了信號使用的場景。
發給:這裏是指信號一般情況下是發給進程還是線程,表明了信號是和整個進程相關還是和某個線程相關。一般由某個線程自己觸發的信號會發送給這個線程自己,讓它自己來處理,但是這個信號的含義如果是進程全局的就會發送給進程來處理,進程裏的任何一個線程都有可能會被選擇來處理。無論是發送給進程還是線程,信號的處理效果都是進程全局的。
含義:這個信號的含義,代表什麼時候該使用它,如果收到了它就意味着遇到了什麼情況。
⑶標準信號詳解
下面讓我們通過一張圖來看看所有信號的相關信息:
我們先來解釋一下信號 0,其實 0 不算是一個信號,但是也可以算作是半個信號。因爲發送信號 0 給一個進程或者線程,它會走發送檢測過程,但是並不會真的投遞給進程或者線程。檢測流程會檢測發送者是否有權限發送、進程是否存在,如果遇到問題就返回錯誤值。所以發送信號 0 可以用作檢測進程是否存在的方法。
我們再來看一下實時信號,因爲實時信號沒有特定的含義,所以比較簡單。實時信號的默認處理是終結進程,相關屬性是可阻塞,可忽略,可捕獲。它的一般使用方法都是進程發給其它進程或者線程來作爲進程間通信的方法。其中 32-33 被 glibc 的 pthread 使用了。
標準信號一共有 1-31 共 31 個,我們按照它們的特點不同分類進行講解:
首先說一下 SIGKILL 和一些暫停、繼續相關的信號。其中 SIGKILL 和 SIGSTOP 是 POSIX 標準規定的不可阻塞、不可忽略、不可捕獲的信號,它們的語義一定會得到執行。SIGCONT 信號官方沒有特別規定,它的實現上是不可阻塞、不可忽略的,雖然能捕獲,但是相當於沒捕獲。因爲捕獲的意思是執行其信號處理函數就不再執行其默認處理了,但是 SIGCONT 的默認語義一定會得到執行。其它三個暫停信號 SIGTSTP、SIGTTIN、SIGTTOU 是不能阻塞的,但是可以忽略可以捕獲,忽略或者捕獲之後,它們的默認語義暫停程序就不會得到執行。
SIGSTOP、SIGCONT,進程在想要暫停、恢復執行其它進程的時候可以發送這兩個信號,內核裏面再需要暫停、恢復執行進程的時候也會發送這兩個信號。SIGTSTP 是當在終端輸入 Ctrl+Z 快捷鍵時,終端驅動會給當前進程發送這個信號。SIGTTIN 是當後臺進程讀取終端的時候,終端會向進程發送的。SIGTTOU 是在後臺進程想要向終端輸出的時候,終端會向進程發送的。這幾個信號都是直接發送給進程的,因爲它們的語義就是要操作整個進程。
下面我們再來看 6 個標記紫色的信號,這幾個信號都是和當前線程正在執行時發生異常有關。內核裏單獨把這 6 個信號放在一起成爲同步信號。因爲它們都是強制發送的,會忽略阻塞和忽略設置,所以圖中把它們都看做是不可忽略不可阻塞的。但是它們是可以捕獲的,讓它們可以捕獲的原因是因爲這樣可以讓進程知道自己出錯的原因,讓進程可以在臨死之前可以做一些記錄工作,爲程序員解 BUG 多提供一些信息。捕獲了之後,原先默認的語義就不會執行,所以信號函數執行完之後它們還會繼續執行。
但是一般情況下這麼做是沒有意義的,所以一般都會在信號函數里退出進程。SIGSEGV 的可捕獲前面加了個 [不],代表的是不能二次捕獲,也就是說如果在信號處理函數里面又發生了 SIGSEGV,則這個 SIGSEGV 就不可捕獲了,會走默認語義發生 coredump 並殺死進程。這些信號的發送方都是內核裏異常處理相關的代碼,信號都會發送給線程,因爲是這些線程引起的這些問題,放到原線程裏去處理比較好。
我們再接着看 SIGABRT 信號,這個信號比較特殊。它的目的是給庫程序來用的。當庫程序發現程序出現了不可挽回的錯誤,就會調用函數 abort,這個函數會給當前線程發送信號 SIGABRT。SIGABRT 信號本身沒什麼特殊的,但是 abort 函數比較特殊。POSIX 規範要求 abort 函數執行完成之後,進程一定要被殺死。於是 abort 函數的實現就是這樣的,先取消阻塞 SIGABRT 信號,然後給當前線程發信號 SIGABRT。無論 SIGABRT 信號是被忽略還是被捕獲了,最後還是要返回到 abort 函數里面,然後 abort 函數就把 SIGABRT 信號的處理方式設置爲默認,然後再發一個 SIGABRT,這下進程就一定會死了。
也就是說你可以捕獲 SIGABRT 信號,但是進程最後還是一定會死。所以上圖裏說 SIGABRT 是不可阻塞、不可忽略、不可二次捕獲的 ([不] 可捕獲代表的是不可二次捕獲)。SIGABRT 的不可二次捕獲和 SIGSEGV 的不可二次捕獲情形不太一樣。如果是手工發送的 SIGABRT 信號,它就是一個普通的信號,沒有前面說的邏輯。不過手工發送 SIGABRT 信號沒有意義,一般都是使用 abort 函數來發送。其實遇到 abort 函數的 SIGABRT 信號也不是必死,有一種不規範的做法可以避免一死,那就是在信號處理函數中使用 longjmp。但是這種做法沒有意義,因爲程序現在已經處於不一致狀態了,coredump 之後結束進程,然後好好地解 bug 纔是最好的選擇。
下面我們再看一下與終端相關的 4 個信號,SIGINT、SIGHUP、SIGQUIT、SIGTERM。你在終端上輸入 Ctrl+C,終端驅動就會給當前進程發送 SIGINT,默認處理是殺死進程。你用 kill 命令給一個進程發信號,默認發的就是 SIGTERM 信號,默認處理也是殺死進程。當終端脫離進程的時候會給進程發 SIGHUP,默認處理也是殺死進程。脫離終端有三種情況:一是物理終端與大型機斷開了連接,現在已經沒有物理終端了,所以這種情況不會有了;二是終端模擬器 (也就是命令行窗口) 被關閉了;三是我們通過 ssh 等工具連接到了網絡終端,如果此時網絡斷了或者客戶端程序死了。這三種情況終端驅動都會給關聯的進程發送 SIGHUP 信號。最後一個信號是 SIGTERM,當你在終端輸入 Ctrl+\ 的時候,終端驅動就會給當前進程發送 SIGTERM 信號,默認處理是 coredump 並殺死進程。
3.3 信號的產生來源
⑴硬件來源
比如我們按下 Ctrl+C,會產生 SIGINT 信號。當用戶在終端按下某些鍵時,終端驅動程序會發送信號給前臺進程。這是一種常見的硬件來源產生信號的方式。
硬件故障也可能產生信號,例如內存訪問錯誤等情況可能會產生相應的信號,如 SIGBUS(非法地址,包括內存地址對齊出錯)、SIGSEGV(試圖訪問未分配給自己的內存,或試圖往沒有寫權限的內存地址寫數據)等信號。
⑵軟件來源
調用系統函數
kill 函數可以給一個指定的進程發送指定的信號。例如,kill(pid_t pid, int sig),其中 pid 爲進程的 pid,你要向哪個進程發送信號,就寫哪個進程的 pid;sig 就是你要發送的信號的編號。成功返回 0,失敗返回 -1。
raise 函數可以給當前進程發送指定的信號(自己給自己發信號)。
abort 函數使當前進程接收到信號而異常終止。
用戶命令:通過命令向進程發送信號。例如在一個終端下,可以使用 kill -9 <進程的 PID> 向指定的進程發送信號 9(SIGKILL),這個信號的默認功能是停止進程。
軟件條件:主要介紹 alarm 函數和 SIGALRM 信號。調用 alarm(unsigned int seconds) 函數可以設定一個鬧鐘,也就是告訴內核在 seconds 秒後給當前進程發送 SIGALRM 信號,該信號的默認處理動作是終止當前進程。這個函數的返回值是 0 或者是以前設定的鬧鐘時間還餘下的秒數。
四、信號的發送
現在我們來看一下信號發送,主要是看發送場景。具體的發送過程在下一章信號的投遞裏面講解。信號發送場景比較典型的有三種,一是終端發送,也就是我們在命令行運行程序時會遇到的情況;二是內核發送,內核也很龐大,裏面的情況也很多,我們這裏主要講的是異常處理發送信號;三是進程發送,就是一個進程給另一個進程發。
4.1 終端發送
我們看一下僞終端是如何發送信號的:linux-src/drivers/tty/pty.c
/* Send a signal to the slave */
static int pty_signal(struct tty_struct *tty, int sig)
{
struct pid *pgrp;
if (sig != SIGINT && sig != SIGQUIT && sig != SIGTSTP)
return -EINVAL;
if (tty->link) {
pgrp = tty_get_pgrp(tty->link);
if (pgrp)
kill_pgrp(pgrp, sig, 1);
put_pid(pgrp);
}
return 0;
}
linux-src/drivers/tty/sysrq.c
static void sysrq_handle_term(int key)
{
send_sig_all(SIGTERM);
console_loglevel = CONSOLE_LOGLEVEL_DEBUG;
}
/*
* Signal sysrq helper function. Sends a signal to all user processes.
*/
static void send_sig_all(int sig)
{
struct task_struct *p;
read_lock(&tasklist_lock);
for_each_process(p) {
if (p->flags & PF_KTHREAD)
continue;
if (is_global_init(p))
continue;
do_send_sig_info(sig, SEND_SIG_PRIV, p, PIDTYPE_MAX);
}
read_unlock(&tasklist_lock);
}
linux-src/drivers/tty/tty_io.c
static void __tty_hangup(struct tty_struct *tty, int exit_session)
{
refs = tty_signal_session_leader(tty, exit_session);
}
linux-src/drivers/tty/tty_jobctrl.c
int tty_signal_session_leader(struct tty_struct *tty, int exit_session)
{
struct task_struct *p;
int refs = 0;
struct pid *tty_pgrp = NULL;
read_lock(&tasklist_lock);
if (tty->ctrl.session) {
do_each_pid_task(tty->ctrl.session, PIDTYPE_SID, p) {
spin_lock_irq(&p->sighand->siglock);
if (p->signal->tty == tty) {
p->signal->tty = NULL;
/*
* We defer the dereferences outside of
* the tasklist lock.
*/
refs++;
}
if (!p->signal->leader) {
spin_unlock_irq(&p->sighand->siglock);
continue;
}
__group_send_sig_info(SIGHUP, SEND_SIG_PRIV, p);
__group_send_sig_info(SIGCONT, SEND_SIG_PRIV, p);
put_pid(p->signal->tty_old_pgrp); /* A noop */
spin_lock(&tty->ctrl.lock);
tty_pgrp = get_pid(tty->ctrl.pgrp);
if (tty->ctrl.pgrp)
p->signal->tty_old_pgrp =
get_pid(tty->ctrl.pgrp);
spin_unlock(&tty->ctrl.lock);
spin_unlock_irq(&p->sighand->siglock);
} while_each_pid_task(tty->ctrl.session, PIDTYPE_SID, p);
}
read_unlock(&tasklist_lock);
if (tty_pgrp) {
if (exit_session)
kill_pgrp(tty_pgrp, SIGHUP, exit_session);
put_pid(tty_pgrp);
}
return refs;
}
這是終端驅動發送信號的幾個場景,代碼就不具體分析了。
4.2 內核發送
我們最常遇到的信號 SIGSEGV,一般都是在缺頁異常裏,如果我們訪問的虛擬內存是未分配的虛擬內存,則會發生 SIGSEGV。下面我們看一下代碼。
X86 的缺頁異常的代碼如下:linux-src/arch/x86/mm/fault.c
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
unsigned long address = read_cr2();
irqentry_state_t state;
prefetchw(¤t->mm->mmap_lock);
if (kvm_handle_async_pf(regs, (u32)address))
return;
state = irqentry_enter(regs);
instrumentation_begin();
handle_page_fault(regs, error_code, address);
instrumentation_end();
irqentry_exit(regs, state);
}
static __always_inline void
handle_page_fault(struct pt_regs *regs, unsigned long error_code,
unsigned long address)
{
trace_page_fault_entries(regs, error_code, address);
if (unlikely(kmmio_fault(regs, address)))
return;
if (unlikely(fault_in_kernel_space(address))) {
do_kern_addr_fault(regs, error_code, address);
} else {
do_user_addr_fault(regs, error_code, address);
local_irq_disable();
}
}
static inline
void do_user_addr_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
struct vm_area_struct *vma;
struct task_struct *tsk;
struct mm_struct *mm;
vm_fault_t fault;
unsigned int flags = FAULT_FLAG_DEFAULT;
tsk = current;
mm = tsk->mm;
if (unlikely((error_code & (X86_PF_USER | X86_PF_INSTR)) == X86_PF_INSTR)) {
/*
* Whoops, this is kernel mode code trying to execute from
* user memory. Unless this is AMD erratum #93, which
* corrupts RIP such that it looks like a user address,
* this is unrecoverable. Don't even try to look up the
* VMA or look for extable entries.
*/
if (is_errata93(regs, address))
return;
page_fault_oops(regs, error_code, address);
return;
}
/* kprobes don't want to hook the spurious faults: */
if (WARN_ON_ONCE(kprobe_page_fault(regs, X86_TRAP_PF)))
return;
/*
* Reserved bits are never expected to be set on
* entries in the user portion of the page tables.
*/
if (unlikely(error_code & X86_PF_RSVD))
pgtable_bad(regs, error_code, address);
/*
* If SMAP is on, check for invalid kernel (supervisor) access to user
* pages in the user address space. The odd case here is WRUSS,
* which, according to the preliminary documentation, does not respect
* SMAP and will have the USER bit set so, in all cases, SMAP
* enforcement appears to be consistent with the USER bit.
*/
if (unlikely(cpu_feature_enabled(X86_FEATURE_SMAP) &&
!(error_code & X86_PF_USER) &&
!(regs->flags & X86_EFLAGS_AC))) {
/*
* No extable entry here. This was a kernel access to an
* invalid pointer. get_kernel_nofault() will not get here.
*/
page_fault_oops(regs, error_code, address);
return;
}
/*
* If we're in an interrupt, have no user context or are running
* in a region with pagefaults disabled then we must not take the fault
*/
if (unlikely(faulthandler_disabled() || !mm)) {
bad_area_nosemaphore(regs, error_code, address);
return;
}
/*
* It's safe to allow irq's after cr2 has been saved and the
* vmalloc fault has been handled.
*
* User-mode registers count as a user access even for any
* potential system fault or CPU buglet:
*/
if (user_mode(regs)) {
local_irq_enable();
flags |= FAULT_FLAG_USER;
} else {
if (regs->flags & X86_EFLAGS_IF)
local_irq_enable();
}
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS, 1, regs, address);
if (error_code & X86_PF_WRITE)
flags |= FAULT_FLAG_WRITE;
if (error_code & X86_PF_INSTR)
flags |= FAULT_FLAG_INSTRUCTION;
#ifdef CONFIG_X86_64
/*
* Faults in the vsyscall page might need emulation. The
* vsyscall page is at a high address (>PAGE_OFFSET), but is
* considered to be part of the user address space.
*
* The vsyscall page does not have a "real" VMA, so do this
* emulation before we go searching for VMAs.
*
* PKRU never rejects instruction fetches, so we don't need
* to consider the PF_PK bit.
*/
if (is_vsyscall_vaddr(address)) {
if (emulate_vsyscall(error_code, regs, address))
return;
}
#endif
/*
* Kernel-mode access to the user address space should only occur
* on well-defined single instructions listed in the exception
* tables. But, an erroneous kernel fault occurring outside one of
* those areas which also holds mmap_lock might deadlock attempting
* to validate the fault against the address space.
*
* Only do the expensive exception table search when we might be at
* risk of a deadlock. This happens if we
* 1. Failed to acquire mmap_lock, and
* 2. The access did not originate in userspace.
*/
if (unlikely(!mmap_read_trylock(mm))) {
if (!user_mode(regs) && !search_exception_tables(regs->ip)) {
/*
* Fault from code in kernel from
* which we do not expect faults.
*/
bad_area_nosemaphore(regs, error_code, address);
return;
}
retry:
mmap_read_lock(mm);
} else {
/*
* The above down_read_trylock() might have succeeded in
* which case we'll have missed the might_sleep() from
* down_read():
*/
might_sleep();
}
vma = find_vma(mm, address);
if (unlikely(!vma)) {
bad_area(regs, error_code, address);
return;
}
if (likely(vma->vm_start <= address))
goto good_area;
if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
bad_area(regs, error_code, address);
return;
}
if (unlikely(expand_stack(vma, address))) {
bad_area(regs, error_code, address);
return;
}
/*
* Ok, we have a good vm_area for this memory access, so
* we can handle it..
*/
good_area:
if (unlikely(access_error(error_code, vma))) {
bad_area_access_error(regs, error_code, address, vma);
return;
}
/*
* If for any reason at all we couldn't handle the fault,
* make sure we exit gracefully rather than endlessly redo
* the fault. Since we never set FAULT_FLAG_RETRY_NOWAIT, if
* we get VM_FAULT_RETRY back, the mmap_lock has been unlocked.
*
* Note that handle_userfault() may also release and reacquire mmap_lock
* (and not return with VM_FAULT_RETRY), when returning to userland to
* repeat the page fault later with a VM_FAULT_NOPAGE retval
* (potentially after handling any pending signal during the return to
* userland). The return to userland is identified whenever
* FAULT_FLAG_USER|FAULT_FLAG_KILLABLE are both set in flags.
*/
fault = handle_mm_fault(vma, address, flags, regs);
if (fault_signal_pending(fault, regs)) {
/*
* Quick path to respond to signals. The core mm code
* has unlocked the mm for us if we get here.
*/
if (!user_mode(regs))
kernelmode_fixup_or_oops(regs, error_code, address,
SIGBUS, BUS_ADRERR,
ARCH_DEFAULT_PKEY);
return;
}
/*
* If we need to retry the mmap_lock has already been released,
* and if there is a fatal signal pending there is no guarantee
* that we made any progress. Handle this case first.
*/
if (unlikely((fault & VM_FAULT_RETRY) &&
(flags & FAULT_FLAG_ALLOW_RETRY))) {
flags |= FAULT_FLAG_TRIED;
goto retry;
}
mmap_read_unlock(mm);
if (likely(!(fault & VM_FAULT_ERROR)))
return;
if (fatal_signal_pending(current) && !user_mode(regs)) {
kernelmode_fixup_or_oops(regs, error_code, address,
0, 0, ARCH_DEFAULT_PKEY);
return;
}
if (fault & VM_FAULT_OOM) {
/* Kernel mode? Handle exceptions or die: */
if (!user_mode(regs)) {
kernelmode_fixup_or_oops(regs, error_code, address,
SIGSEGV, SEGV_MAPERR,
ARCH_DEFAULT_PKEY);
return;
}
/*
* We ran out of memory, call the OOM killer, and return the
* userspace (which will retry the fault, or kill us if we got
* oom-killed):
*/
pagefault_out_of_memory();
} else {
if (fault & (VM_FAULT_SIGBUS|VM_FAULT_HWPOISON|
VM_FAULT_HWPOISON_LARGE))
do_sigbus(regs, error_code, address, fault);
else if (fault & VM_FAULT_SIGSEGV)
bad_area_nosemaphore(regs, error_code, address);
else
BUG();
}
}
static void
__bad_area_nosemaphore(struct pt_regs *regs, unsigned long error_code,
unsigned long address, u32 pkey, int si_code)
{
struct task_struct *tsk = current;
if (likely(show_unhandled_signals))
show_signal_msg(regs, error_code, address, tsk);
set_signal_archinfo(address, error_code);
if (si_code == SEGV_PKUERR)
force_sig_pkuerr((void __user *)address, pkey);
else
force_sig_fault(SIGSEGV, si_code, (void __user *)address);
local_irq_disable();
}
處理用戶空間缺頁異常的函數是 do_user_addr_fault,在這個函數里面會檢測各種錯誤情況並最終調用函數__bad_area_nosemaphore 給當前線程發送信號 SIGSEGV。
4.3 進程發送
進程如果想要向另外一個進程 \ 線程或發送信號的話,可以使用系統提供的一些接口函數。如下所示:
我們最常用的接口函數就是 kill,它有兩個參數,一個是進程標識符 pid,一個是信號的值 sig,就是把信號 sig 發給進程 pid。raise 函數給自己也就是當前線程發信號,它只有一個參數 sig。killpg 是給整個進程組發信號,在實現上是給進程組的每個進程都發信號。pthread_kill 是給同一個進程中的某個線程發信號。tgkill 可以給其它進程中的某個線程發信號。sigqueue 是用來發實時信號的,實時信號可以多帶一個附加數據,當然可以用來發普通信號,但是這樣附加數據就會被忽略。
五、信號的投遞
5.1 信號待決隊列
每個進程都有一個信號隊列,每個線程也有一個信號隊列。信號隊列的數據結構如下所示:linux-src/include/linux/signal_types.h
struct sigpending {
struct list_head list;
sigset_t signal;
};
可以看到信號隊列非常簡單,sigset 是個 bit flag,代表當前隊列裏有哪些信號,list 是信號列表的頭指針。下面我們來看一下信號隊列裏的條目。
struct sigqueue {
struct list_head list;
int flags;
kernel_siginfo_t info;
struct ucounts *ucounts;
};
每發送一次信號都會生成一個 sigqueue,sigqueue 裏面包含了很多和信號相關的信息。
在 Linux 裏面,每個 task_struct 都代表一個線程,裏面包含了一個 sigpending 。Linux 裏面沒有直接代表進程的結構體,但是一個進程的所有線程都共享同一個 signal_struct。signal_struct 裏面也包含了一個 sigpending,這個 sigpending 代表進程的信號隊列。
5.2 信號投遞流程
我們前面說了很多發送信號的方法,總體上可以分爲兩類,普通發送和強制發送。異常處理發送信號都是用的強制發送,其它的基本上都是用的普通發送,但也有一些其它情況用的是強制發送。這兩類方法方法最終都會調用同一個函數來發送信號,我們來看一下:linux-src/kernel/signal.c
static int send_signal(int sig, struct kernel_siginfo *info, struct task_struct *t,
enum pid_type type)
{
/* Should SIGKILL or SIGSTOP be received by a pid namespace init? */
bool force = false;
if (info == SEND_SIG_NOINFO) {
/* Force if sent from an ancestor pid namespace */
force = !task_pid_nr_ns(current, task_active_pid_ns(t));
} else if (info == SEND_SIG_PRIV) {
/* Don't ignore kernel generated signals */
force = true;
} else if (has_si_pid_and_uid(info)) {
/* SIGKILL and SIGSTOP is special or has ids */
struct user_namespace *t_user_ns;
rcu_read_lock();
t_user_ns = task_cred_xxx(t, user_ns);
if (current_user_ns() != t_user_ns) {
kuid_t uid = make_kuid(current_user_ns(), info->si_uid);
info->si_uid = from_kuid_munged(t_user_ns, uid);
}
rcu_read_unlock();
/* A kernel generated signal? */
force = (info->si_code == SI_KERNEL);
/* From an ancestor pid namespace? */
if (!task_pid_nr_ns(current, task_active_pid_ns(t))) {
info->si_pid = 0;
force = true;
}
}
return __send_signal(sig, info, t, type, force);
}
static int __send_signal(int sig, struct kernel_siginfo *info, struct task_struct *t,
enum pid_type type, bool force)
{
struct sigpending *pending;
struct sigqueue *q;
int override_rlimit;
int ret = 0, result;
assert_spin_locked(&t->sighand->siglock);
result = TRACE_SIGNAL_IGNORED;
if (!prepare_signal(sig, t, force))
goto ret;
pending = (type != PIDTYPE_PID) ? &t->signal->shared_pending : &t->pending;
/*
* Short-circuit ignored signals and support queuing
* exactly one non-rt signal, so that we can get more
* detailed information about the cause of the signal.
*/
result = TRACE_SIGNAL_ALREADY_PENDING;
if (legacy_queue(pending, sig))
goto ret;
result = TRACE_SIGNAL_DELIVERED;
/*
* Skip useless siginfo allocation for SIGKILL and kernel threads.
*/
if ((sig == SIGKILL) || (t->flags & PF_KTHREAD))
goto out_set;
/*
* Real-time signals must be queued if sent by sigqueue, or
* some other real-time mechanism. It is implementation
* defined whether kill() does so. We attempt to do so, on
* the principle of least surprise, but since kill is not
* allowed to fail with EAGAIN when low on memory we just
* make sure at least one signal gets delivered and don't
* pass on the info struct.
*/
if (sig < SIGRTMIN)
override_rlimit = (is_si_special(info) || info->si_code >= 0);
else
override_rlimit = 0;
q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit, 0);
if (q) {
list_add_tail(&q->list, &pending->list);
switch ((unsigned long) info) {
case (unsigned long) SEND_SIG_NOINFO:
clear_siginfo(&q->info);
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_USER;
q->info.si_pid = task_tgid_nr_ns(current,
task_active_pid_ns(t));
rcu_read_lock();
q->info.si_uid =
from_kuid_munged(task_cred_xxx(t, user_ns),
current_uid());
rcu_read_unlock();
break;
case (unsigned long) SEND_SIG_PRIV:
clear_siginfo(&q->info);
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_KERNEL;
q->info.si_pid = 0;
q->info.si_uid = 0;
break;
default:
copy_siginfo(&q->info, info);
break;
}
} else if (!is_si_special(info) &&
sig >= SIGRTMIN && info->si_code != SI_USER) {
/*
* Queue overflow, abort. We may abort if the
* signal was rt and sent by user using something
* other than kill().
*/
result = TRACE_SIGNAL_OVERFLOW_FAIL;
ret = -EAGAIN;
goto ret;
} else {
/*
* This is a silent loss of information. We still
* send the signal, but the *info bits are lost.
*/
result = TRACE_SIGNAL_LOSE_INFO;
}
out_set:
signalfd_notify(t, sig);
sigaddset(&pending->signal, sig);
/* Let multiprocess signals appear after on-going forks */
if (type > PIDTYPE_TGID) {
struct multiprocess_signals *delayed;
hlist_for_each_entry(delayed, &t->signal->multiprocess, node) {
sigset_t *signal = &delayed->signal;
/* Can't queue both a stop and a continue signal */
if (sig == SIGCONT)
sigdelsetmask(signal, SIG_KERNEL_STOP_MASK);
else if (sig_kernel_stop(sig))
sigdelset(signal, SIGCONT);
sigaddset(signal, sig);
}
}
complete_signal(sig, t, type);
ret:
trace_signal_generate(sig, info, t, type != PIDTYPE_PID, result);
return ret;
}
send_signal 做了一些簡單的處理,然後直接調用__send_signal。__send_signal 先調用 prepare_signal,prepare_signal 對暫停恢復類的信號先做了一下預處理,然後查看信號是否被忽略。然後根據 PID 類型決定是把信號放到進程隊列裏還是線程隊列裏。然後會判斷信號是不是傳統信號 (也就是標準信號), 對於傳統信號,如果信號隊列裏已經有一個了,就不再接收了,這麼做是爲了兼容過去。然後調用__sigqueue_alloc 分配一個信號條目 sigqueue,分配好之後填充各種數據,然後把它加入到隊列中去。最後調用 complete_signal,此函數會選擇一個合適的線程來喚醒,一般會喚醒當前線程。喚醒的線程很可能醒來就去進行信號處理。
①強制發送:強制發送的入口函數是 force_sig_info_to_task,它會先把信號的阻塞和忽略取消掉,然後再調用函數 send_signal 進行發送。代碼如下:linux-src/kernel/signal.c
static int
force_sig_info_to_task(struct kernel_siginfo *info, struct task_struct *t,
enum sig_handler handler)
{
unsigned long int flags;
int ret, blocked, ignored;
struct k_sigaction *action;
int sig = info->si_signo;
spin_lock_irqsave(&t->sighand->siglock, flags);
action = &t->sighand->action[sig-1];
ignored = action->sa.sa_handler == SIG_IGN;
blocked = sigismember(&t->blocked, sig);
if (blocked || ignored || (handler != HANDLER_CURRENT)) {
action->sa.sa_handler = SIG_DFL;
if (handler == HANDLER_EXIT)
action->sa.sa_flags |= SA_IMMUTABLE;
if (blocked) {
sigdelset(&t->blocked, sig);
recalc_sigpending_and_wake(t);
}
}
/*
* Don't clear SIGNAL_UNKILLABLE for traced tasks, users won't expect
* debugging to leave init killable. But HANDLER_EXIT is always fatal.
*/
if (action->sa.sa_handler == SIG_DFL &&
(!t->ptrace || (handler == HANDLER_EXIT)))
t->signal->flags &= ~SIGNAL_UNKILLABLE;
ret = send_signal(sig, info, t, PIDTYPE_PID);
spin_unlock_irqrestore(&t->sighand->siglock, flags);
return ret;
}
內核又封裝了幾個函數來輔助強制發送,分別是 force_sig_info、force_sig、force_fatal_sig、force_exit_sig、force_sigsegv、force_sig_fault_to_task、force_sig_fault,它們的代碼就不再具體介紹了。
②普通發送:do_send_sig_info 先對 send_signal 進行了一次封裝,然後 do_send_specific、group_send_sig_info 又分別對其進行了封裝。do_send_specific 代表發送到線程,group_send_sig_info 代表發送到進程。給線程發信號的接口函數最終都是調用的 do_send_specific。給進程發信號的接口函數最終都是調用的 group_send_sig_info。下面我們看一下 kill 和 tgkill 的調用流程。
先看 kill 接口函數的流程:linux-src/kernel/signal.c
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
struct kernel_siginfo info;
prepare_kill_siginfo(sig, &info);
return kill_something_info(sig, &info, pid);
}
static int kill_something_info(int sig, struct kernel_siginfo *info, pid_t pid)
{
int ret;
if (pid > 0)
return kill_proc_info(sig, info, pid);
/* -INT_MIN is undefined. Exclude this case to avoid a UBSAN warning */
if (pid == INT_MIN)
return -ESRCH;
read_lock(&tasklist_lock);
if (pid != -1) {
ret = __kill_pgrp_info(sig, info,
pid ? find_vpid(-pid) : task_pgrp(current));
} else {
int retval = 0, count = 0;
struct task_struct * p;
for_each_process(p) {
if (task_pid_vnr(p) > 1 &&
!same_thread_group(p, current)) {
int err = group_send_sig_info(sig, info, p,
PIDTYPE_MAX);
++count;
if (err != -EPERM)
retval = err;
}
}
ret = count ? retval : -ESRCH;
}
read_unlock(&tasklist_lock);
return ret;
}
static int kill_proc_info(int sig, struct kernel_siginfo *info, pid_t pid)
{
int error;
rcu_read_lock();
error = kill_pid_info(sig, info, find_vpid(pid));
rcu_read_unlock();
return error;
}
int kill_pid_info(int sig, struct kernel_siginfo *info, struct pid *pid)
{
int error = -ESRCH;
struct task_struct *p;
for (;;) {
rcu_read_lock();
p = pid_task(pid, PIDTYPE_PID);
if (p)
error = group_send_sig_info(sig, info, p, PIDTYPE_TGID);
rcu_read_unlock();
if (likely(!p || error != -ESRCH))
return error;
/*
* The task was unhashed in between, try again. If it
* is dead, pid_task() will return NULL, if we race with
* de_thread() it will find the new leader.
*/
}
}
下面再來看一下 tgkill 函數的流程:linux-src/kernel/signal.c
SYSCALL_DEFINE3(tgkill, pid_t, tgid, pid_t, pid, int, sig)
{
/* This is only valid for single tasks */
if (pid <= 0 || tgid <= 0)
return -EINVAL;
return do_tkill(tgid, pid, sig);
}
static int do_tkill(pid_t tgid, pid_t pid, int sig)
{
struct kernel_siginfo info;
clear_siginfo(&info);
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_TKILL;
info.si_pid = task_tgid_vnr(current);
info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
return do_send_specific(tgid, pid, sig, &info);
}
六、信號的儲存與處理
6.1 信號的存儲方式
在 Linux 內核中,信號的存儲主要通過三張表來實現:pending 表、block 表和 handler 表。
Pending 表是通過位圖來儲存的,一共有 31 位,每個比特位代表信號編號,比特位的內容代表信號是否收到。當進程收到信號但未遞達時,對應編號的比特位就會由 0 改爲 1。
Block 表也是通過位圖來儲存,其結構與 Pending 表類似。每個比特位代表信號編號,比特位的內容代表信號是否阻塞。如果某個信號被阻塞,那麼阻塞位圖結構中對應的比特位(信號編號)就會置爲 1,在此信號阻塞未被解除之前,會一直處於信號未決狀態。
Handler 表是一個函數指針數組。數組的下標是對應的信號編號,數組下標中的內容就是對應信號的處理方法(函數指針)。當調用 signal(signo,handler) 時,就會把信號對應的處理方法設置爲自定義方法,內核中就是將數組下標(信號編號)中的內容(處理方法)設置爲自定義方法的函數指針,從而在遞達後執行處理方法。
sigset_t 類型是 Linux 給用戶提供的一個用戶級的數據類型,禁止用戶直接修改位圖。每個信號只有一個 bit 的未決標誌,非 0 即 1,不記錄該信號產生了多少次,阻塞標誌也是這樣表示的。因此,未決和阻塞標誌可以用相同的數據類型 sigset_t 來存儲,sigset_t 稱爲信號集,這個類型可以表示每個信號的 “有效” 或 “無效” 狀態,在阻塞信號集中 “有效” 和 “無效” 的含義是該信號是否被阻塞,而在未決信號集中 “有效” 和 “無效” 的含義是該信號是否處於未決狀態。阻塞信號集也叫做當前進程的信號屏蔽字,這裏的 “屏蔽” 應該理解爲阻塞而不是忽略。
6.2 信號的阻塞與未決狀態
信號的阻塞、未決和遞達是理解 Linux 信號機制的重要概念。執行信號的處理動作稱爲信號遞達(Delivery),信號從產生到遞達之間的狀態,稱爲信號未決(Pending)。進程可以選擇阻塞(Block)某個信號。被阻塞的信號產生時將保持在未決狀態,直到進程解除對此信號的阻塞,才執行遞達的動作。注意,阻塞和忽略是不同,只要信號被阻塞就不會遞達,而忽略是在遞達之後可選的一種處理動作。
信號在內核中的表示可以看作是這樣的:在 PCB 進程控制塊中有信號屏蔽狀態字(block)、信號未決狀態字(pending)以及是否忽略標誌(或是信號處理函數)。block 狀態字和 pending 狀態字都是 64bit。信號屏蔽狀態字(block)中,1 代表阻塞、0 代表不阻塞;信號未決狀態字(pending)的 1 代表未決,0 代表信號可以抵達了。它們都是每一個 bit 代表一個信號,比如,bit0 代表信號 SIGHUP。
可以使用信號集操作函數來操作信號集。例如:
-
int sigemptyset(sigset_t *set);:將信號集清空,共 64bits。
-
int sigfillset(sigset_t *set);:將信號集置 1。
-
int sigaddset(sigset_t *set, int signum);:將 signum 對應的位置爲 1。
-
int sigdelset(sigset_t *set, int signum);:將 signum 對應的位置爲 0。
-
int sigismember(const sigset_t *set, int signum);:判斷 signum 是否在該信號集合中,如果集合中該位爲 1,則返回 1,表示位於在集合中。
還有一個函數可以讀取更改屏蔽狀態字的 API 函數 int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);。參數 how 有下面三種取值:
-
SIG_BLOCK:將參數 set 指向的信號集中設置的信號添加到現在的屏蔽狀態字中,設置爲阻塞。
-
SIG_UNBLOCK:將參數 set 指向的信號集中設置的信號添加到現在的屏蔽狀態字中,設置爲非阻塞,也就是解除阻塞。
-
SIG_SETMASK:將參數 set 指向的信號集直接覆蓋現在的屏蔽狀態字的值。如果 oset 是非空指針,則讀取進程的當前信號屏蔽字通過 oset 參數傳出。若成功則爲 0,若出錯則爲 -1。
還有一個函數可以讀取未決狀態字(pending)信息:int sigpending(sigset_t *set);。它讀取當前進程的未決信號集,通過 set 參數傳出。調用成功則返回 0,出錯則返回 -1。
6.3 信號的捕捉與阻塞
在 Linux 中,可以使用 signal 和 sigaction 系統調用來自定義信號處理函數,實現對特定信號的捕捉和處理。
signal 函數較爲簡單,其函數原型爲 typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);。它主要用於處理前 32 種非實時信號,不支持信號的傳遞信息。例如,當使用 signal(SIGINT, my_func) 函數調用時,其中 my_func 是自定義函數。應用進程收到 SIGINT 信號時,會跳轉到自定義處理信號函數 my_func 處執行。在 Linux 系統中,signal 函數已被改寫,由 sigaction 函數封裝實現。
sigaction 函數則更加強大,它可以讀取和修改與指定信號相關聯的處理動作。函數原型爲 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)。其中,signum 代表指定信號的編號;若 act 指針非空,則根據 act 修改該信號的處理動作;若 oldact 指針非空,則通過 oldact 傳出該信號原來的處理動作。struct sigaction 結構體成員解釋如下:
-
sa_handler:如果爲 SIG_IGN,表示忽略信號;如果爲 SIG_DFL,表示執行系統默認動作;如果爲自定義的函數指針,表示用自定義函數捕捉信號,即向內核註冊了一個信號處理函數。所註冊的信號處理函數的返回值爲 void,參數爲 int,通過參數可以得知當前信號的編號,這樣就可以用同一個函數處理多種信號。
-
sa_mask:當某個信號的處理函數被調用,內核自動將當前信號加入進程的信號屏蔽字,當信號處理函數返回時自動恢復原來的信號屏蔽字。如果在調用信號處理函數時,除了當前信號被自動屏蔽之外,還希望自動屏蔽另外一些信號,則用 sa_mask 字段說明這些需要額外屏蔽的信號。
-
sa_flags:包含一些選項,通常設置爲 0,表示使用默認屬性。
例如,以下代碼用 sigaction 函數對 2 號信號進行了捕捉,將 2 號信號的處理動作改爲了自定義的打印動作,並在執行一次自定義動作後將 2 號信號的處理動作恢復爲原來默認的處理動作:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
struct sigaction act, oact;
void handler(int signo) {
printf("get a signal:%d\n", signo);
sigaction(2, &oact, NULL);
}
int main() {
// 先把兩個結構體變量的成員都初始化爲 0
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(2, &act, &oact);
while (1) {
printf("I am a process\n");
sleep(1);
}
return 0;
}
6.4 異步信號安全
我們可以通過設置信號處理函數來捕獲信號,那信號處理函數能像普通函數一樣什麼接口函數都能調用嗎?不能,我們只能調用異步信號安全的函數。很多常用的函數都不是信號安全函數,不能在信號處理函數里面調用,比如 printf。那要是想在信號處理函數里面輸出數據該咋辦呢?可以使用 write 接口函數,這個函數是異步信號安全的。
6.5 信號處理流程
信號處理是在線程從內核空間返回用戶空間的時候處理的。而從內核空間返回用戶空間是和架構相關的,所以這一部分的代碼是在架構代碼裏面的。下面我們以 x86 爲例講解一下 (代碼進行了刪減)。
linux-src/kernel/entry/common.c
static unsigned long exit_to_user_mode_loop(struct pt_regs *regs, unsigned long ti_work)
{
while (ti_work & EXIT_TO_USER_MODE_WORK) {
if (ti_work & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL))
handle_signal_work(regs, ti_work);
}
return ti_work;
}
static void handle_signal_work(struct pt_regs *regs, unsigned long ti_work)
{
if (ti_work & _TIF_NOTIFY_SIGNAL)
tracehook_notify_signal();
arch_do_signal_or_restart(regs, ti_work & _TIF_SIGPENDING);
}
linux-src/arch/x86/kernel/signal.c
void arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
{
struct ksignal ksig;
if (has_signal && get_signal(&ksig)) {
handle_signal(&ksig, regs);
return;
}
restore_saved_sigmask();
}
可以看出線程在返回到用戶空間之前不斷地檢查有沒有信號要處理。如果有的話就使用函數 get_signal 取出一個信號,然後在函數 handle_signal 裏面去執行。get_signal 的代碼我們就不貼出來了,在這裏講一下它的大概邏輯。
get_signal 會先看有沒有 STOP 相關的信號,如果有的話執行處理。然後去取一個信號出來,先取同步信號,同步信號只從當前線程的信號隊列裏去取,這裏的同步信號是指前面講的異常處理的 6 個信號。
如果沒有同步信號的話就去取其它信號,其它信號先從線程的信號隊列裏面去取,如果沒有的話就再去進程的信號裏面去取。如果取到的信號的處理設置是忽略,或者是默認處理但默認處理方式也是忽略,則繼續取下一個信號。
如果取到的信號沒有設置信號處理函數,則在這裏執行其默認處理,終結進程或者 coredump 之後再終結進程。如果沒有取到信號則 get_signal 返回值爲 0,如果取到了信號,且信號設置了信號處理函數則返回值爲 1,且輸出參數 ksig 會包含相應信號的相關的信息。然後把 ksig 傳遞給函數 handle_signal 來處理。下面我們看一下 handle_signal 函數的實現,linux-src/arch/x86/kernel/signal.c
static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
bool stepping, failed;
struct fpu *fpu = ¤t->thread.fpu;
if (v8086_mode(regs))
save_v86_state((struct kernel_vm86_regs *) regs, VM86_SIGNAL);
/* Are we from a system call? */
if (syscall_get_nr(current, regs) != -1) {
/* If so, check system call restarting.. */
switch (syscall_get_error(current, regs)) {
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs->ax = -EINTR;
break;
case -ERESTARTSYS:
if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
regs->ax = -EINTR;
break;
}
fallthrough;
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
}
}
/*
* If TF is set due to a debugger (TIF_FORCED_TF), clear TF now
* so that register information in the sigcontext is correct and
* then notify the tracer before entering the signal handler.
*/
stepping = test_thread_flag(TIF_SINGLESTEP);
if (stepping)
user_disable_single_step(current);
failed = (setup_rt_frame(ksig, regs) < 0);
if (!failed) {
/*
* Clear the direction flag as per the ABI for function entry.
*
* Clear RF when entering the signal handler, because
* it might disable possible debug exception from the
* signal handler.
*
* Clear TF for the case when it wasn't set by debugger to
* avoid the recursive send_sigtrap() in SIGTRAP handler.
*/
regs->flags &= ~(X86_EFLAGS_DF|X86_EFLAGS_RF|X86_EFLAGS_TF);
/*
* Ensure the signal handler starts with the new fpu state.
*/
fpu__clear_user_states(fpu);
}
signal_setup_done(failed, ksig, stepping);
}
這段代碼雖然看起來不太複雜,但是實際上卻非常難以理解。setup_rt_frame 爲了使線程返回用戶空間後能執行信號處理函數便開始僞造用戶線程棧幀。棧幀首先保存一些線程當前的狀態到棧上,然後再僞造出彷彿是一個蹦牀函數調用了信號處理函數一樣。然後再僞造出彷彿是信號處理函數通過系統調用進入了內核一樣。
然後線程從內核返回用戶空間就會執行信號處理函數,信號處理函數執行完返回的時候時候會返回到蹦牀函數。蹦牀函數會調用 sigreturn 系統調用進入內核,sigreturn 會讀取蹦牀函數的棧幀,因爲這上面保持的是之前的線程執行信息。然後把這些信息進行恢復,這樣線程再回到用戶空間的時候就又回到了線程之前執行的地方。
七、信號處理的同步化
對於異步信號來說,有很多的問題,比如你不確定你正在幹啥的時候它來了,還有就是在異步信號的處理函數里面有很多的函數不能調用。爲此我們可以把異步信號轉化爲同步信號。我們前面說過,同步信號、異步信號是指信號的發送是同步的還是異步的,那異步信號肯定不可能轉化爲同步信號啊。我們此處所說的轉化是指把信號的處理從異步轉化爲同步。
轉化的方法就是用一個函數來等信號,這樣信號和線程執行的相對性就是固定的了,就相當於是同步信號了。等的方式有兩種,一種是等待信號被處理,信號還是走前面所說的處理流程,另一種是等待信號並截獲信號,信號被我們偷走了,不會再走前面所說的信號處理流程了。
7.1 信號等待
信號等待的接口函數有兩個 pause 和 sigsuspend,它們的接口是:
int pause(void);
int sigsuspend(const sigset_t *mask);
7.2 信號截獲
除了等待信號被處理之外,我們還可以等待並截獲信號,信號就不會走正常的處理流程,我們可以對截獲到的信號進行相應的處理。信號截獲一共有四個接口函數,我們先來講三個。
int sigwait(const sigset_t *restrict set, int *restrict sig);
int sigwaitinfo(const sigset_t *restrict set, siginfo_t *restrict info);
int sigtimedwait(const sigset_t *restrict set, siginfo_t *restrict info, const struct timespec *restrict timeout);
接口函數 sigwait 有兩個參數,第一個參數是要等待的信號集,第二個參數是輸出參數,是等待並截獲到的信號。函數返回之後,我們就可以根據 sig 的值進行相應的處理。接口函數 sigwaitinfo 也有兩個參數,第一個參數和前面的是一樣的,第二個參數是輸出參數,類型是 siginfo_t,能獲得更多信號相關的信息。接口函數 sigtimedwait 和 sigwaitinfo 差不多,只是多個了時間參數,如果等了這麼長時間之後還沒有等來信號就會直接返回。
還有一個接口函數,它把要等待的信號信息轉化爲了 fd,等信號直接變成了 read fd 的操作。其接口如下:
int signalfd(int fd, const sigset_t *mask, int flags);
第二個參數代表要等待的信號集。第一個參數如果是 - 1,代表要創建一個新的 fd,如果是一個已有的 signalfd,代表修改已經 fd 的信號集。然後我們就可以對這個 fd 進行 read 操作了,read 的緩存區至少要有 sizeof(struct signalfd_siginfo) 個字節。Read 每次返回都會讀取若干個 struct signalfd_siginfo 結構體。最關鍵的是我們還可以對這個 fd 進行 select、poll 操作。
八、應用場景與總結
8.1 應用場景舉例
⑴使用 “ctrl+c” 中止程序
當用戶在終端運行程序時,按下 “Ctrl+C” 會產生 SIGINT 信號。這個信號通常會被髮送給前臺進程,以請求終止進程。例如,在一個長時間運行的計算任務中,如果用戶發現結果不符合預期或者想要提前終止程序,就可以通過按下 “Ctrl+C” 來發送 SIGINT 信號。當進程接收到這個信號後,會根據其對 SIGINT 信號的處理方式來做出響應。如果進程沒有自定義信號處理函數,那麼通常會採用默認的處理動作,即終止進程。
⑵kill 命令殺進程
在 Linux 系統中,kill 命令是一個常用的工具,用於向進程發送信號以終止它們。例如,kill -9 <進程的 PID> 會向指定的進程發送 SIGKILL 信號。SIGKILL 信號是一種強制終止信號,無法被捕捉、忽略或阻塞。當進程接收到 SIGKILL 信號時,會立即終止。這種方式通常用於終止那些無法正常退出的進程,或者在系統出現問題時強制關閉某些進程以恢復系統的穩定性。
除了終止進程,信號機制還可以用於進程間的通信。例如,一個進程可以向另一個進程發送特定的信號,以通知它某個事件的發生。這種通信方式雖然比較簡單,但在某些情況下非常有用。
8.2 總結信號機制的重要性
Linux 信號機制在編寫健壯程序中具有至關重要的意義。首先,它提供了一種靈活的方式來處理異步事件。在複雜的多進程或多線程環境中,程序可能會面臨各種不可預測的情況,如硬件故障、用戶輸入、系統資源變化等。通過信號機制,程序可以及時響應這些事件,採取適當的措施,避免出現不可預料的錯誤或崩潰。
其次,信號機制使得進程間的通信更加多樣化。相比於傳統的管道、共享內存等通信方式,信號通信更加輕量級和高效。它可以用於簡單的事件通知,讓不同的進程之間能夠協調工作,提高系統的整體性能和穩定性。
深入理解信號機制還可以幫助程序員更好地調試和優化程序。當程序出現異常情況時,通過分析信號的產生和處理過程,可以快速定位問題所在。同時,合理地利用信號機制可以優化程序的資源管理,例如在程序退出時及時清理資源,避免資源泄漏。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/fV8Y30_yPY0DkQIa6wuIIg