文章目錄
- 1. 信號入門
- 1.1 進程與信號的相關知識
- 1.2 技術應用角度的信號
- 1.3 注意
- 1.4 信號概念
- 1.5 信號處理常見方式概覽
- 2. 產生信號
- 2.1 通過終端按鍵產生信號
- 2.2 調用系統函數向進程發信號
- 2.3 由軟件條件產生信號
- 2.4 硬件異常產生信號
- 2.5 信號保存
- 3. 阻塞信號
- 3.1 信號其他相關常見概念
- 3.2 在內核中的表示
- 3.3 sigset_t
- 3.4 信號集操作函數
- sigprocmask
- sigpending
1. 信號入門
1.1 進程與信號的相關知識
進程 必須 識別+能夠處理信號(信號沒有產生,也要具備處理信號的能力)信號的處理能力,屬于進程內置功能的一部分
進程即便是沒有收到信號,也能知道哪些信號該怎么處理
當進程真的收到了一個具體的信號的時候,進程可能并不會立即處理這個信號,需要等到合適的時候
一個進程,當信號產生到信號開始被處理,就一定會有時間窗口,進程具有臨時保存哪些信號已經發生了的能力
1.2 技術應用角度的信號
用戶輸入命令,在Shell下啟動一個前臺進程。
用戶按下Ctrl+C ,這個鍵盤輸入產生一個硬件中斷,被OS獲取,解釋成信號,發送給目標前臺進程
前臺進程因為收到信號,進而引起進程退出
ctrl+c為什么能夠殺掉我們前臺進程呢?
Linux中,一次登陸中,一個終端一般會配上一個bash,每一個登陸,只允許一個進程是前臺進程,可以允許多個進程是后臺進程。
鍵盤輸入首先是被前臺進程收到的。(這是前臺進程和后臺進程的本質區別)
ctrl +c本質是被進程解釋成為收到了信號。ctrl+c 會觸發SIGINT信號(信號編號2),然后終端驅動程序捕獲這個按鍵組合,將SIGINT信號發送給前臺進程組的所有進程。
前臺進程特性
與終端關聯
能夠接收終端輸入
屬于當前終端的前臺進程組
只能終止前臺進程的原因
終端只與前臺進程組關聯
后臺進程組收不到終端產生的信號
關鍵點:ctrl+c 本質是通過信號機制來終止進程的,而不是直接"殺死"進程。
1-31是普通信號,34-64是實時信號。
信號的處理方式:
默認動作
忽略
自定義動作(信號的捕捉)
例如紅燈亮了就等綠燈是默認動作,不管紅燈闖紅燈就是忽略,紅燈了唱歌跳舞就是自定義動作。
進程收到2號信號的默認動作,就是終止自己。
不是所有的信號都是可以被signal捕捉的,比如:9,19。
但是無論信號如何產生,最終一定是誰發送給進程的?
OS,因為OS是進程的管理者。
1.3 注意
- Ctrl+C 產生的信號只能發給前臺進程。一個命令后面加個&可以放到后臺運行,這樣Shell不必等待進程結束就可以接受新的命令,啟動新的進程。
- Shell可以同時運行一個前臺進程和任意多個后臺進程,只有前臺進程才能接到像 Ctrl+C 這種控制鍵產生的信號。
- 前臺進程在運行過程中用戶隨時可能按下 Ctrl+C 而產生一個信號,也就是說該進程的用戶空間代碼執行到任何地方都有可能收到 SIGINT 信號而終止,所以信號相對于進程的控制流程來說是異步(Asynchronous)的。
1.4 信號概念
信號是進程之間事件異步通知的一種方式,屬于軟中斷。
1.5 信號處理常見方式概覽
(sigaction函數稍后詳細介紹),可選的處理動作有以下三種:
- 忽略此信號。
- 執行該信號的默認處理動作。
- 提供一個信號處理函數,要求內核在處理該信號時切換到用戶態執行這個處理函數,這種方式稱為捕捉(Catch)一個信號。
2. 產生信號
2.1 通過終端按鍵產生信號
SIGINT的默認處理動作是終止進程,SIGQUIT的默認處理動作是終止進程并且Core Dump。
Core Dump
首先解釋什么是Core Dump。當一個進程要異常終止時,可以選擇把進程的用戶空間內存數據全部 保存到磁盤上,文件名通常是core,這叫做Core Dump。
進程異常終止通常是因為有Bug,比如非法內存訪問導致段錯誤,事后可以用調試器檢查core文件以查清錯誤原因,這叫做Post-mortem Debug(事后調試)。一個進程允許產生多大的core文件取決于進程的Resource Limit(這個信息保存 在PCB中)。
默認是不允許產生core文件的,因為core文件中可能包含用戶密碼等敏感信息,不安全。在開發調試階段可以用ulimit命令改變這個限制,允許產生core文件。
2.2 調用系統函數向進程發信號
首先在后臺執行死循環程序,然后用kill命令給它發SIGSEGV信號。
kill命令是調用kill函數實現的。kill函數可以給一個指定的進程發送指定的信號。
raise函數可以給當前進程發送指定的信號(自己給自己發信號)。
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
這兩個函數都是成功返回0,錯誤返回-1。
abort函數使當前進程接收到信號而異常終止。
#include <stdlib.h>
void abort(void);
就像exit函數一樣,abort函數總是會成功的,所以沒有返回值。
2.3 由軟件條件產生信號
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
調用alarm函數可以設定一個鬧鐘,也就是告訴內核在seconds秒之后給當前進程發SIGALRM信號, 該信號的默認處理動作是終止當前進程。
2.4 硬件異常產生信號
硬件異常被硬件以某種方式被硬件檢測到并通知內核,然后內核向當前進程發送適當的信號。以下是幾種常見的硬件異常及其對應的信號:
- 除零異常(SIGFPE):
當程序執行除以0的操作時,CPU的算術邏輯單元會檢測到這個異常。例如:
int a = 1;
int b = 0;
int c = a / b; // 觸發SIGFPE信號
- 段錯誤(SIGSEGV):
當程序訪問了非法內存地址時,內存管理單元(MMU)會產生異常。例如:
int *p = NULL;
*p = 1; // 訪問空指針,觸發SIGSEGV信號int arr[10];
arr[10000] = 1; // 數組越界,可能觸發SIGSEGV信號
- 非法指令(SIGILL):
當CPU執行了非法指令時產生此信號:
void (*bad_func_ptr)() = (void (*)())0x12345678;
bad_func_ptr(); // 執行非法地址的代碼,觸發SIGILL信號
- 總線錯誤(SIGBUS):
當訪問未對齊的內存地址時可能產生此信號:
char *ptr = (char *)0x12345;
int *iptr = (int *)ptr;
*iptr = 1; // 可能觸發SIGBUS信號
在系統層面,這些硬件異常的處理流程是:
- 硬件檢測到異常
- 觸發CPU中斷
- CPU切換到內核態
- 內核將硬件異常轉換為相應的信號
- 內核向進程發送信號
- 如果進程注冊了信號處理函數,則執行該函數
- 如果沒有注冊處理函數,則執行信號的默認處理動作(通常是終止進程)
這就是為什么C/C++中的很多運行時錯誤(如除零、空指針解引用、數組越界等)最終都表現為進程收到信號并終止。這種機制讓操作系統能夠及時發現并處理程序中的嚴重錯誤,防止錯誤程序繼續運行可能造成的更大危害。
2.5 信號保存
為什么要信號保存?
進程收到信號之后,可能不會立即處理這個信號。信號不會被處理,就要有一個時間窗口。
3. 阻塞信號
3.1 信號其他相關常見概念
實際執行信號的處理動作稱為信號遞達(Delivery)
信號從產生到遞達之間的狀態,稱為信號未決(Pending)。
進程可以選擇阻塞 (Block )某個信號。
被阻塞的信號產生時將保持在未決狀態,直到進程解除對此信號的阻塞,才執行遞達的動作.
注意,阻塞和忽略是不同的,只要信號被阻塞就不會遞達,而忽略是在遞達之后可選的一種處理動作。
3.2 在內核中的表示
信號在內核中的表示示意圖
每個信號都有兩個標志位分別表示阻塞(block)和未決(pending),還有一個函數指針表示處理動作。信號產生時,內核在進程控制塊中設置該信號的未決標志,直到信號遞達才清除該標志。在上圖的例子中,SIGHUP信號未阻塞也未產生過,當它遞達時執行默認處理動作。
SIGINT信號產生過,但正在被阻塞,所以暫時不能遞達。雖然它的處理動作是忽略,但在沒有解除阻塞之前不能忽略這個信號,因為進程仍有機會改變處理動作之后再解除阻塞。
SIGQUIT信號未產生過,一旦產生SIGQUIT信號將被阻塞,它的處理動作是用戶自定義函數sighandler。 如果在進程解除對某信號的阻塞之前這種信號產生過多次,將如何處理?POSIX.1允許系統遞送該信號一次或多次。Linux是這樣實現的:常規信號在遞達之前產生多次只計一次,而實時信號在遞達之前產生多次可以依次放在一個隊列里。本章不討論實時信號。
3.3 sigset_t
從上圖來看,每個信號只有一個bit的未決標志,非0即1,不記錄該信號產生了多少次,阻塞標志也是這樣表示的。
因此,未決和阻塞標志可以用相同的數據類型sigset_t來存儲,sigset_t稱為信號集,這個類型可以表示每個信號的“有效”或“無效”狀態,在阻塞信號集中“有效”和“無效”的含義是該信號是否被阻塞,而在未決信號集中“有效”和“無效”的含義是該信號是否處于未決狀態。下一節將詳細介紹信號集的各種操作。 阻塞信號集也叫做當前進程的信號屏蔽字(Signal Mask),這里的“屏蔽”應該理解為阻塞而不是忽略。
3.4 信號集操作函數
sigset_t類型對于每種信號用一個bit表示“有效”或“無效”狀態,至于這個類型內部如何存儲這些bit則依賴于系統實現,從使用者的角度是不必關心的,使用者只能調用以下函數來操作sigset_ t變量,而不應該對它的內部數據做任何解釋,比如用printf直接打印sigset_t變量是沒有意義的
#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做初始化,使信號集處于確定的狀態。初始化sigset_t變量之后就可以在調用sigaddset和sigdelset在該信號集中添加或刪除某種有效信號。
這四個函數都是成功返回0,出錯返回-1。sigismember是一個布爾函數,用于判斷一個信號集的有效信號中是否包含某種 信號,若包含則返回1,不包含則返回0,出錯返回-1。
sigprocmask
調用函數sigprocmask可以讀取或更改進程的信號屏蔽字(阻塞信號集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功則為0,若出錯則為-1
如果oset是非空指針,則讀取進程的當前信號屏蔽字通過oset參數傳出。如果set是非空指針,則 更改進程的信號屏蔽字,參數how指示如何更改。如果oset和set都是非空指針,則先將原來的信號 屏蔽字備份到oset里,然后根據set和how參數更改信號屏蔽字。假設當前的信號屏蔽字為mask,下表說明了how參數的可選值。
如果調用sigprocmask解除了對當前若干個未決信號的阻塞,則在sigprocmask返回前,至少將其中一個信號遞達。
sigpending
#include <signal.h>
sigpending
讀取當前進程的未決信號集,通過set參數傳出。調用成功則返回0,出錯則返回-1。
代碼:
void printsigset(sigset_t *set)
{// 打印信號集中的信號狀態,用1表示信號在集合中,0表示不在for(int i=1; i<32; i++) {if (sigismember(set, i)) { // 判斷信號i是否在信號集set中putchar('1');} else {putchar('0');}}puts("");
}int main()
{sigset_t s, p;sigemptyset(&s); // 初始化信號集s為空集sigaddset(&s, SIGINT); // 將SIGINT信號添加到信號集s中,Ctrl+Csigprocmask(SIG_BLOCK, &s, NULL); // 設置信號屏蔽字,阻塞SIGINT信號while(1) {sigpending(&p); // 獲取未決信號集printsigset(&p); // 打印未決信號集sleep(1);}return 0;
}
程序運行時,每秒鐘把各信號的未決狀態打印一遍,由于我們阻塞了SIGINT信號,按Ctrl+C將會使SIGINT信號處于未決狀態。按Ctrl+\仍然可以終止程序,因為SIGQUIT信號沒有阻塞。
代碼:
// 打印未決信號集的函數
void PrintPending(sigset_t &pending)
{// 從31號信號到1號信號逐個檢查for (int signo = 31; signo >= 1; signo--){if (sigismember(&pending, signo))cout << "1"; // 信號處于未決狀態elsecout << "0"; // 信號不在未決集中}cout << "\n\n";
}// 信號處理函數
void handler(int signo)
{cout << "catch a signo: " << signo << endl;
}int main()
{// 4. 屏蔽所有可屏蔽信號sigset_t bset, oset;sigemptyset(&bset); // 清空信號集sigemptyset(&oset); // 清空舊信號集for (int i = 1; i <= 31; i++){sigaddset(&bset, i); // 將所有信號添加到屏蔽集}sigprocmask(SIG_SETMASK, &bset, &oset); // 設置信號屏蔽字// 循環檢測未決信號sigset_t pending;while (true){int n = sigpending(&pending); // 獲取未決信號集if (n < 0)continue;PrintPending(pending); // 打印未決信號集sleep(1);}// // 0. 對2號信號進行自定義捕捉// signal(2, handler);// // 1. 先對2號信號進行屏蔽 --- 數據預備// sigset_t bset, oset; // 在哪里開辟的空間???用戶棧上的,屬于用戶區// sigemptyset(&bset);// sigemptyset(&oset);// sigaddset(&bset, 2); // 我們已經把2好信號屏蔽了嗎?并沒有設置進入到你的進程的task_struct// // 1.2 調用系統調用,將數據設置進內核// sigprocmask(SIG_SETMASK, &bset, &oset); // 我們已經把2好信號屏蔽了嗎?ok// // 2. 重復打印當前進程的pending 0000000000000000000000000// sigset_t pending;// int cnt = 0;// while (true)// {// // 2.1 獲取// int n = sigpending(&pending);// if (n < 0)// continue;// // 2.2 打印// PrintPending(pending);// sleep(1);// cnt++;// // 2.3 解除阻塞// if(cnt == 20)// {// cout << "unblock 2 signo" << endl;// sigprocmask(SIG_SETMASK, &oset, nullptr); // 我們已經把2好信號屏蔽了嗎?ok// }// }// // 3 發送2號 0000000000000000000000010return 0;
}
被注釋的代碼:
// 0. 設置2號信號(SIGINT)的處理函數
signal(2, handler);// 1. 先對2號信號進行屏蔽 --- 數據預備
sigset_t bset, oset; // 在用戶棧上創建信號集
sigemptyset(&bset); // 初始化為空集
sigemptyset(&oset); // 保存舊的信號屏蔽字
sigaddset(&bset, 2); // 只添加2號信號到屏蔽集
// 調用系統調用,將數據設置進內核
sigprocmask(SIG_SETMASK, &bset, &oset);// 2. 監控未決信號狀態
// 重復打印當前進程的pending 0000000000000000000000000
sigset_t pending;
int cnt = 0;
while (true)
{int n = sigpending(&pending); // 獲取未決信號if (n < 0)continue;PrintPending(pending); // 打印未決信號狀態sleep(1);cnt++;// 20秒后解除2號信號的屏蔽if(cnt == 20){cout << "unblock 2 signo" << endl;// 恢復原來的信號屏蔽字,即解除屏蔽sigprocmask(SIG_SETMASK, &oset, nullptr);}
}
// 3 發送2號 0000000000000000000000010
兩個場景的區別:
- 當前執行的代碼:
- 屏蔽所有可屏蔽信號
- 持續監控所有信號的未決狀態
- 信號會一直保持在未決狀態
- 注釋掉的代碼:
- 只屏蔽SIGINT(2號)信號
- 設置了SIGINT的自定義處理函數
- 20秒后解除屏蔽,讓信號能夠被處理
- 可以觀察到SIGINT信號從未決變為已處理的過程
注釋中的重要說明:
task_struct
:進程描述符,在內核中保存進程的信號屏蔽字- 信號集雖然在用戶棧上定義,但實際的屏蔽操作是在內核中完成
- 通過注釋分步驟展示了信號屏蔽、監控和解除屏蔽的完整流程