目錄
一、信號速識
??生活中的信號
??技術上的信號
??信號的發送和記錄
??信號處理概述
二、產生信號
??通過終端產生信號
??通過函數發送信號
??通過軟件產生信號
??通過硬件產生信號
一、信號速識
??生活中的信號
- 你在網上買了很多件商品,再等待不同商品快遞的到來。但即便快遞沒有到來,你也知道快遞來臨時,你該怎么處理快遞。也就是你能“識別快遞”
- 當快遞員到了你樓下,你也收到快遞到來的通知,但是你正在打游戲,需5min之后才能去取快遞。那么在在這5min之內,你并沒有下去去取快遞,但是你是知道有快遞到來了。也就是取快遞的行為并不是一定要立即執行,可以理解成“在合適的時候去取”。
- 在收到通知,再到你拿到快遞期間,是有一個時間窗口的,在這段時間,你并沒有拿到快遞,但是,你知道有一個快遞已經來了。本質上是你“記住了有一個快遞要去取”
- 當你時間合適,順利拿到快遞之后,就要開始處理快遞了。而處理快遞一般方式有三種:1.執行默
- 認動作(幸福的打開快遞,使用商品)2.執行自定義動作(快遞是零食,你要送給你你的女朋友),3.忽略快遞(快遞拿上來之后,扔掉床頭,繼續開一把游戲)
- 快遞到來的整個過程,對你來講是異步的,你不能準確斷定快遞員什么時候給你打電話
??技術上的信號
我們寫個代碼來看看:
#include <stdio.h>
#include <unistd.h>
#include <stdbool.h>
int main() {while(true) {printf("I am a process, I am waiting signal!\n");sleep(1);}return 0;
}
這個代碼是一個死循環,我們最好的終止代碼的方式是ctrl+c。
為什么我們的進程被終止了呢?
實際上這是因為我們給進程發送了一個信號,這個信號就是我們的ctrl+c的動作,只不過這個行為被操作系統翻譯成了2號信號了,然后操作系統給目標前臺進程發送了這個信號,前臺進程收到了2號信號之后就會退出了。
我們可以使用signal函數對信號進行捕捉,以說明我們的ctrl+c操作使進程收的的確是2號信號,這里簡單介紹一下這個signal函數。下面是函數的原型:
typedef void (*sighandler_t)(int);sighandler_t signal(int signum, sighandler_t handler);
參數說明:
第一個參數signum,指的是需要我們捕捉的信號。
第二個參數handler,指的是對信號的處理方法,也就是可以傳一個參數是int,返回值是void的函數指針。
我們可以對上面的代碼進行一下改寫,對2號信號進行捕捉,當進程運行起來之后,如果進程收到了2號信號,那么就可以打印出相關的信息了。
這個時候我們運行我們的代碼:
這也就證明了,當我們按下ctrl+c的時候進程的確是收到了2號信號。
敲黑板:
??信號的發送和記錄
這里我們可以使用kill -l命令來查看我們的信號列表:
- ctrl+c產生的信號只能是發給前臺進程的,在一個命令后面加上一個&就可以將其放到后臺來運行了,這樣就可以接受新的命令,開啟新的進程了。
- shell只能運行一個前臺進程,但是它可以同時運行多個后臺進程。我們這里可以看到我們可以隨時按下ctrl+c來產生一個信號給琴臺進程終止,也就是說信號相對進程是異步的。
這里我們要解釋一下,1~31號信號是普通信號,34~64號信號是實時信號,這兩種信號各有31個。
那么信號是怎么記錄下來的呢?
實際上我們的進程接收到某種信號后,該信號是被記錄在了該進程的進程控制塊中的,進程控制塊的本質就是一個結構體變量,對于信號而言我們就是記錄某種信號是否產生,因此我們使用32位的位圖來記錄信號是否產生的。
其中比特位的位置就是代表信號的編號,而比特位的內容就是是否收到了這個信號。
信號是怎么產生的?
實際上我們也是應該能推測出來的,進程收到了信號本質上就是進程內對應位置的信號位圖被修改了,也就是進程數據被修改了,而只有操作系統才有資格修改進程的數據,這也就說明了信號的產生就是操作系統取修改了進程PCB的信號位圖。
??信號處理概述
信號默認會執行其默認操作,處理信號函數實質上是就是要求內核在處理信號是切換到用戶太來執行信號函數,這種行為就是catch(捕捉)。
我們可以在man手冊中查看一下各個信號的默認處理行為:
?
man 7 signal
這里簡單的說明一下:
我們為了方便說明這一章的知識點,我們的思路如下:
- Term:表示終止(Terminate),這個信號會導致進程終止。
- Core:表示生成核心轉儲(Core Dump),通常表示程序崩潰時將內存狀態寫到文件中,便于調試。
- Ign:表示忽略(Ignore),即進程會忽略該信號。
- Cont:表示繼續(Continue),用來恢復進程的執行,通常用在進程暫停后。
- Stop:表示暫停(Stop),讓進程暫停執行,常見于SIGSTOP。
二、產生信號
當前階段:
??通過終端產生信號
我們這里還是用我們之前的代碼:
#include <stdio.h>
#include <unistd.h>
#include <stdbool.h>
int main() {while(true) {printf("hello signal!\n");sleep(1);}return 0;
}
實際上我們除了可以使用ctrl+c終止進程之外,我們還可以使用ctrl+\來終止進程。
那么這兩個操作有什么區別呢?
實際上,ctrl+c/是向進程發送2號信號SIGINT,而ctrl+\實際上發送的是3號信號SIGQUIT。其實之前的表格里面也是有展示的:
他們兩個一個行為是Term(2號信號),一個是Core(3號信號)。Term是將進程終止,而Core則是表示核心轉儲。
那么什么是核心轉儲呢?
在云服務器之中,核心轉儲默認是關閉的,我們可以通過使用ulimit -a命令來查看當前的資源限制的設定。
我們可以看到第一行顯示的是core文件的大小是0,也就表示我們的核心轉儲是關閉的。
我們可以是用命令ulimit -c size來設置core文件的大小。
設置好了之后,就相當于是將核心轉儲的功能打開了,這個時候我們再使用ctrl+\來對進程進行終止。這個時候我們就會在在當前路徑下面生成一個core文件(沒有生成的話可以檢查一下這個路徑:/proc/sys/kernel/core_pattern,然后echo "core.%e.%p" > /proc/sys/kernel/core_pattern
),這里的文件后綴的一串數字實際上是發生這次核心轉儲的進程的PID。
核心轉儲的作用是什么呢?
其實核心轉儲主要是為了我們方便調試代碼的,如果我們代碼出現了問題,我們最關心的就是我們的代碼是什么原因出錯的,當我們的程序運行過程中崩潰了,我們一般會通過調試來進行逐步的查找程序的崩潰的原因。而在一些特殊情況下我們就會用到核心轉儲,核心轉儲就是我們的操作系統在進程收到信號終止以后,將進程地址空間的內容以及有關的進程狀態的其他信息轉而存儲到了一個磁盤文件當中,這個磁盤文件也叫做核心轉儲文件。
如何調試呢?
這里我們寫個錯誤的代碼:
#include <stdio.h>
#include <unistd.h>int main() {printf("I am running...\n");sleep(3);int r = 10 / 0;return 0;
}
很明顯這個代碼會執行崩潰的,我們可以在當前目錄下面看到核心轉儲是生成的core文件。
使用gdb可以對當前的可執行程序調試,我們直接使用生成的core文件,在gdb中執行命令core-file + core文件的命令。
core dump標志
我們之前在說進程等待的時候,用到了一個函數叫waitpid:
pid_t waitpid(pid_t pid, int *status, int options);
我們當時重點介紹了這個函數的第二個參數,這個參數是一個輸出型參數,是用來獲取子進程的退出狀態的,status是一個整型變量,但是事實上我們不是將它當成一個整型而是一個位圖(我們只關注了低的16位):
若進程是正常退出的,那么status的次低8位就是進程的退出狀態,即退出碼。若進程是被信號所殺,那么status的低7位表示的就是終止信號了,而第8位就是core dump標志位,即進程終止時是否有進行核心轉儲操作。
我們這里可以寫個代碼來驗證一下,這里我們還是用父子進程來舉例子,我們在代碼中父進程創建出一個子進程,子進程的執行過程中出現除0異常,這個時候就會被操作系統終止進行核心轉儲。此時父進程使用waitpid等待子進程退出,使用ststus來獲取出相關信息:
代碼如下:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>int main() {if(fork() == 0) {// 子進程printf("I am running...\n");int t = 100 / 0;exit(0);}// 父進程int status = 0;waitpid(-1, &status, 0);printf("exitcode:%d, core dump:%d, signal:%d\n", (status >> 8), (status >> 7), (status & 0x7f));return 0;
}
我們運行之后可以發現我們的代碼是進行了核心轉儲的,所以說core dump標志就是用來表示進程崩潰時候進行核心轉儲的。
小擴展
我們可以通過下面的代碼來看看我們的組合按鍵對應的型號類型,也就是使用signal函數來捕捉對應的信號。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void handler(int signal) {printf("我是%d, 我獲得了一個信號%d\n", getpid(), signal);
}int main() {for(int i = 1; i <= 31; i++) {signal(i, handler);}while(1) {sleep(1);}return 0;
}
這個時候,我們就可以知道我們的組合鍵ctrl + c、ctrl + \和ctrl + z的組合鍵給前臺進程發送的幾號信號了。
這個時候可能就有人問了,我們在這樣的情況下該如何退出呢?
實際上我們只要發送9號信號就可以是進程退出了:
敲黑板:
我們的信號里面有一些信號是不能被捕捉的,比如這里的9號信號,這樣做主要是為了安全性考慮。
??通過函數發送信號
kill函數
實際上我們之前調用的kill命令就是通過調用系統函數kill來實現的,函數的原型如下:
int kill(pid_t pid, int sig);
參數說明:
kill函數用來向進程ID為pid的進程發送sig信號,如果信號發送成功了,返回0,否則發送-1。
我們可以使用kill函數來寫個模擬kill命令的代碼:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <signal.h>void usage(char* s) {printf("usage: %s pid signal!\n", s);
}int main(int argc, char* argv[]) {if(argc != 3) {usage(argv[0]);exit(1);}pid_t pid = atoi(argv[1]);int signal = atoi(argv[2]);kill(pid, signal);return 0;
}
我們這里為了更加的美觀,可以將當前路徑設置進環境變量PATH中去。
此時我們就可以模擬實現一個kill命令了:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <signal.h>void usage(char* s) {printf("usage: %s pid signal!\n", s);
}int main(int argc, char* argv[]) {if(argc != 3) {usage(argv[0]);exit(1);}pid_t pid = atoi(argv[1]);int signal = atoi(argv[2]);kill(pid, signal);return 0;
}
我們使用mykill 進程ID 進程編號就可以實現和kill命令一樣的效果了。
raise函數
這個函數是用來給當前進程發送信號的,函數的原型如下:
int raise(int sig);
參數說明:
參數就是要發送的信號。
返回值說明:
如果信號發送成功了就返回0,否則就返回一個非零值。
下面我們寫個代碼來見一見:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdbool.h>void handler(int signal) {printf("我是%d, 我獲得了一個信號%d\n", getpid(), signal);
}int main() {signal(2, handler);while(true) {sleep(1);raise(2);}return 0;
}
運行的結果就是每秒鐘都會收到一個2號信號,只不過觸發的是信號函數。
abort函數
這個函數比較單一,它是給當前進程發送6號信號(SIGABRT)的,使得當前進程終止,函數的原型如下:
void abort(void);
參數說明:
這是一個無參無返回值的函數。
下面我們寫個代碼來見一見:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <stdbool.h>void handler(int signal) {printf("我是%d, 我獲得了一個信號%d\n", getpid(), signal);
}int main() {signal(6, handler);while(true) {sleep(1);abort();}return 0;
}
這里我們會發現一個很神奇的現象,代碼并沒有像我們預期的那樣一直打印我們的函數調用的內容,而是終止了:
敲黑板:
這里的abort函數是通過信號機制終止進程的,即使捕捉了這個信號,進程仍然會被終止掉,因為這個信號的默認行為就是調用abort函數的內部處理程序使進程退出。
??通過軟件產生信號
SIGPIPE信號
這個信號就是一個由軟件產生的信號,我們使用管道通信的時候,讀端進程將讀端關閉,而寫端進程還在向管道中寫入,這個時候寫端進程就會收到SIGPIPE信號而被操作系統終止。
我們可以寫個代碼來模擬一下上面這個過程:
?
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>int main() {int fd[2] = {0};if(pipr(fd) < 0) {perror("pipe error");exit(1);}pid_t id = fork();if(id == 0) {// 子進程close(fd[0]);const char* message = "hello father, I am child...";int count = 10;while(count--) {write(fd[1], message, strlrn(message));sleep(1);}close(fd[1]);exit(0);}// 父進程close(fd[1]);close(fd[0]);int status = 0;waitpid(id, &status, 0);printf("child get signal:%d\n", status & 0x7F);return 0
}
運行之后我們發現子進程推出的時候收到了13號信號,就是SIGPIPE信號。
SIGALRM信號
我們可以調用alarm函數給進程設定一個鬧鐘,經過設置的時間之后操作系統就可以發送一個SIGALRM信號給當前的進程,alarm函數的函數原型如下:
unsigned int alarm(usingned int seconds);
參數說明:
參數就是設置秒數
返回值說明:
- 調用該函數之前,如果進程已經設置了鬧鐘,就返回上一個鬧鐘的剩余時間,并且本次的時間會覆蓋掉上一次的時間。
- 調用該函數之前,如果進程沒有設置鬧鐘,就返回0值。
接下來我們可以寫一個代碼來見一見:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdbool.h>int main() {int count = 0;alarm(1);while(true) {count++;printf("count = :%d\n", count);}return 0;
}
這里我們可以看到,我們的服務器在一秒內加5萬余次,然后就收到信號終止了。
這里也許有人認為5萬已經是很大的數了,其實不然,我們在做算法題的時候經常要限制時間在1秒內,否則就會TLE,我們一般籠統的認為一秒內計算機執行的操作是一億次,所以說這里的5萬實際上是很小的。那么為什么呢?這是因為我的代碼中存在了大量的IO操作,也就是打印,同時因為是云服務器,網絡傳輸也需要消耗時間,所以這個數才會比較小。
下面是我們改進之后的代碼:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdbool.h>
int count = 0;void handler(int signal) {printf("我是%d, 我獲得了一個信號%d\n", getpid(), signal);printf("count = :%d\n", count);exit(1);
}int main() {signal(SIGALRM, handler);alarm(1);while(true) {count++;}return 0;
}
這里我們重新運行之后,結果一下子就變形成了5億。
??通過硬件產生信號
我們其實會好奇,為什么我們的程序會崩潰呢?實際上是因為我們的進程收到了來自操作系統發來的信號兒終止的,那么操作系統是怎么識別的呢?
這個問題實際上就是計算機組成原理的基本常識了,我們知道,CPU 內部包含多個寄存器,當我們需要對兩個數進行算術運算時,首先會將這兩個操作數分別放入兩個寄存器中,然后執行運算,并將結果寫回寄存器。此外,CPU 中還有一組寄存器稱為狀態寄存器,用于記錄當前指令執行結果的各種狀態信息,例如是否發生了進位、溢出等情況。
操作系統作為軟硬件資源的管理者,負責在程序運行過程中進行資源調度和異常處理。當操作系統檢測到 CPU 內某個狀態標志位被設置,并且該標志位是由于某種除以零的錯誤引起時,操作系統能夠識別出是哪個進程引發了該錯誤。接著,操作系統將該硬件錯誤封裝成信號,并發送給目標進程。具體來說,操作系統會通過查找該進程的 task_struct
結構體,識別出出錯的進程,并向該進程的信號位圖中寫入 8 號信號(即除0錯誤信號)。一旦信號被寫入,進程會在適當的時機被終止,從而避免繼續執行錯誤的操作。
下面我們寫一個野指針錯誤的代碼來見一見:
#include <stdio.h>
#include <unistd.h>int main() {printf("I am running...\n");sleep(3);int *p = NULL;*p = 100;return 0;
}
運行效果:
這里我們都知道我們的地址呢實際上是通過頁表映射到了,從虛擬內存映射到了物理地址的。從硬件的角度,這個操作實際上是由MMU所做的,它是一個負責處理CPU的內存訪問請求的計算機硬件,也就是說MMU是虛擬地址到物理地址映射的中間件,但是這個硬件單元不僅僅是做映射的,還需要有相對應的狀態信息,當我們訪問到了不屬于我們的虛擬地址的時候,MMU在虛擬地址映射的時候就會出錯,然后將錯誤寫到自己的狀態信息里面,操作系統就會識別到這個信息,于是就會給進程發送SIGSEGV信號了。