前言
信號從產生到處理,可以分為信號產生、信號保存、信號捕捉三個階段;了解了信號產生和保存,現在來深入了解信號捕捉。
信號捕捉
對于1-31
號普通信號,進程可以立即處理,也可以不立即處理而是在合適的時候處理;
在合適的時候處理信號,什么時候合適呢?
信號捕捉的流程
要了解信號捕捉的流程,先要了解內核態和用戶態;
簡單來說,內核態就是以操作系統的身份去運行;而用戶態就是以用戶的身份去運行。(后面再詳細說明)
這里直接來看信號捕捉的流程:
我們的進程在正常執行,在執行到某條指令,因為系統調用、中斷或異常從而進入內核;
而內核處理完異常之后,準備回到用戶之前,就會處理當前進程可以遞達的信號;
處理信號,執行
so_signal
方法,如果進程對于信號是自定義捕捉,處理信號就要從內核態回到用戶態處理信號;自定義捕捉完信號之后,就要再回到內核態,然后由內核態再回到用戶態,從上次被中斷的地方繼續向下執行。
以自定義捕捉為例,信號捕捉的流程如下圖所示:
所以,在信號捕捉的整個流程中,存在4
次用戶態和內核態的轉換;簡化成以下圖:
簡單總結描述信號捕捉流程:
- 用戶進程執行
- 進程在用戶空間正常執行代碼
- 進入內核
- 發生系統調用/中斷/異常 → CPU自動切換到內核態
- 內核處理事件
- 內核完成系統調用/中斷/異常的處理
- 信號檢查
- 內核返回用戶態前檢查信號:
有未處理且未阻塞的信號? → 繼續
無信號 → 直接返回用戶態- 準備信號處理(針對自定義信號)
- 內核在用戶棧創建"信號棧幀"(包含):
- 信號處理函數地址
- 原始執行狀態(寄存器值)
rt_sigreturn
系統調用地址- 第一次返回用戶態
- 內核修改CPU狀態:
- 指令指針 → 信號處理函數
- 棧指針 → 新信號棧幀
- 切換到用戶態執行信號處理函數
- 信號處理完成
- 信號處理函數執行結束(return語句)
- 自動跳轉到
rt_sigreturn
系統調用- 第二次進入內核
- 執行
rt_sigreturn
系統調用 → 進入內核態- 內核從信號棧幀恢復原始狀態
- 最終返回用戶態
- 內核切換回用戶態
- 進程從當初被中斷的位置繼續執行
操作系統運行
要了解操作系統是如何運行的,就要先了解一些硬件相關知識
硬件中斷
硬件中斷是外部硬件設備(如鍵盤、鼠標、硬盤、網卡、定時器芯片等)向 CPU 發出的一種緊急通知信號,意思是“我有重要的事情需要你馬上處理!”
就像OS
是如何知道鍵盤上有數據那樣,并不是OS
定期去排查,而是鍵盤給CPU
發送中斷,從而讓CPU
執行OS
中對應的方法。
如上圖所示,存在一個中斷控制器,其中每一個中斷號都對應一個外部設備;
- 當外部設備就緒時,就會向中斷控制器發送中斷,中斷控制器就會通知
CPU
存在中斷;(向CPU
對應針腳發送高低電頻)CPU
就會獲取中斷號,然后中斷當前工作并保護現場(保存臨時數據等);- 在
OS
中存在中斷向量表,其中存儲了對于每一個中斷號的對應處理方法;CPU
就會根據中斷號,去執行中斷向量表這對應的中斷處理方法。
中斷向量表是操作系統的一部分,在啟動時就會加載到內存;
通過外部硬件中斷,操作系統就不需要對外設進行周期性檢測;而是當外部設備觸發中斷時,CPU
就會執行對應的中斷處理方法。
這種由外部設備觸發,中斷系統運行流程,稱為硬件中斷
時鐘中斷
有了硬件中斷,操作系統就無序去對外設進程周期性檢測;
而操作系統不光要管理硬件資源,也要進行進程調度;那能否按照硬件中斷的原理,定期的向CPU
發送中斷,從而定期的執行操作系統的進程調度方法。
所以,就有了時鐘源(當代已經集成在CPU
內部);就會定期的向CPU
發送中斷,CPU
通過中斷號去執行中斷向量表中對應的進程調度方法。
那這樣,定期的向CPU
發送中斷,也就是定期執行進程調度方法;那進程的時間片,本質上就是一個計數器了,每次調度進程就讓進程的時間片計數器--
,當減到0
時就說明進程時間片用完,就指定進程調度算法,執行下一個進程。
而CPU
存在主頻,主頻指的就是時鐘源向CPU
發送中斷的頻率,主頻越快,CPU
單位時間內就能夠完成更多的操作;CPU
就越快。
死循環
有了硬件中斷和時鐘中斷,那操作系統只需要將對應功能添加到中斷向量表中,那操作系統還需要干什么呢?
操作系統的本質:就是死循環
void main()
{//......for(;;)pause();
}
通過查看內核,我們也能夠發現,操作系統在做完內存管理等任務之后,就是死循環。
軟中斷
上述硬件中斷、時鐘中斷都是由硬件觸發的中斷;除此之外呢,也可能因為軟件原因觸發上述中斷。
為了讓操作系統支持進行系統調用,CPU
中也設計了匯編指令int
(或者syscall
),讓CPU
內部觸發中斷邏輯。
在這里就要了解一下系統調用了,在之前的認知中,系統調用是由操作系統通過的,我們是直接調用系統調用;
但是,在操作系統中,所有的系統調用都存儲在一張系統調用表當中;(這張系統調用表用于系統調用中中斷處理程序)
我們所調用的系統調用
open
、write
等等,都是由glibc
封裝的;而想要讓
CPU
執行對應的方法,就要讓CPU
直到對應的系統調用號;
CPU
根據系統調用號,然后查表才能調用對應的方法。
通過觀察,我們也可以發現在glibc
的封裝實現,是先將系統調用號寫入寄存器eax
;然后再syscall
觸發軟中斷,讓CPU
根據eax
寄存器中的系統調用號執行對應的方法。
內核態和用戶態
在信號捕捉流程中,存在一個概念就是:內核態和用戶態;
我們知道在進程運行時,通過系統調用或者中斷等等陷入內核,進入內核態;而在進行自定義處理時,再有內核態回到用戶態;自定義處理完成之后,再通過特定的系統掉用再進入內核態;最后才回到最初中斷的位置,由內核態進入用戶態。
那內核態和用戶態是什么呢?
簡單來說,內核態就是以操作系統的身份執行;用戶態就是以用戶的身份執行。
在虛擬地址空間(進程地址空間中),[0,3]GB
是用戶空間,我們程序的代碼數據、動態庫等等都在用戶這3GB
中;而[3,4]GB
是內核空間;
在我們的程序中,我們可以返回自己實現的方法、可以調用庫函數;這都是在
[0,3]GB
用戶空間內進行跳轉的。
執行對應的代碼時,使用用虛擬地址通過頁表(用戶頁表)映射物理地址處,就可以找到對應的代碼和數據。而在我們調用系統調用時,在進程地址空間中,就要從
[0,3]GB
用戶空間跳轉到[3,4]GB
的內核空間;這樣在執行時,通過內核頁表映射,找到對應內核的代碼運行。
當然,在內核中存在許多進程,這些進程都可能會調用系統調用;而在每一個進程的進程地址空間中的[3,4]GB
都是內核空間,都可以通過頁表(內核頁表)映射,找到內存中操作系統的代碼。
所以,我們在進行系統調用時,不用去擔心進程能否在內存中找到對應的地址,因為在進程
[3,4]GB
內核空間中,有了虛擬地址,通過內核頁表映射,就能夠在內存找到對應的物理地址。所以,系統調用的執行就是在進程地址空間中進行的。
說了這么多,簡單總結就是:
- 用戶態就是,在進程地址空間中,通過
[0,3]GB
用戶空間的虛擬地址,進行頁表映射,執行用戶自己的代碼 - 內核態就是,通過
[3,4]GB
內核空間的虛擬地址,進行頁表映射,執行操作系統的代碼
問題:如何知道虛擬地址是
[0,3]GB
用戶空間的地址還是[3,4]GB
內核空間的地址?(CPU
執行時如何知道是用戶態還是內核態)在頁表當中,記錄的不僅僅是虛擬地址和物理地址的映射關系,還用權限(
r
、w
)以及當前身份。此外,在硬件上也存在對應標志:
CPU
中的Cs
段寄存器對應標志位:00
(二進制)代表內核、11
(二進制)代表用戶。
可重入函數
可重入函數是指可以被多個執行流(例如線程、中斷處理程序、信號處理程序)同時調用,而不會產生錯誤或意外結果的函數。
簡單來說就是:
一個可重入函數在執行過程中,如果被另一個執行流打斷并再次進入該函數,當恢復執行時,它仍然能夠正確完成其任務,不會破壞自身的數據或全局狀態。
如上圖所示,在調用insert
時,執行至某位置,進程收到信號轉而去執行handler
方法,而在handler
方法中有調用了insert
方法;這樣導致了最終的結果不符合我們的預期。
對于一個可重入函數,該函數要滿足:
- 不使用靜態(全局)或非常量靜態局部變量: 這些變量在內存中只有一份拷貝,如果多個執行流同時修改它們,會導致數據不一致。
- 不返回指向靜態數據的指針: 調用者可能會修改這些數據,影響其他執行流。
- 僅使用調用者提供的數據或自己棧上的局部變量: 每個執行流(線程/函數調用實例)都有自己的棧空間,局部變量是獨立的。
- 不調用不可重入的函數: 如果它調用的函數本身是不可重入的(比如使用了全局狀態),那么它自己也就變得不可重入了。
- 不修改自身的代碼: 通常這不是問題,但某些特殊場景(如自修改代碼)需要考慮。
- 不依賴外部硬件狀態(除非以原子方式訪問): 比如多個執行流同時操作同一個硬件寄存器可能造成沖突。
volatile
volatile
是C
語言中的一個關鍵字,這個關鍵字的在之前的學習中并沒有使用過;
volatile
關鍵字用來修飾一個變量,其作用就是,告訴編譯器該變量的值可能會變化,讓編譯器不要對其進程優化,讓CPU
每次訪問該變量的值都從內存中獲取。
#include <iostream>
#include <signal.h>
#include <unistd.h>int flag = 0;
void handler(int signum)
{std::cout << "change flag 0 -> 1" << std::endl;flag = 1;
}
int main()
{signal(2, handler);int cnt = 0;while (!flag){std::cout << "flag :" << flag << std::endl;sleep(1);}return 0;
}
在上述代碼中,main
函數while(!falg)
,當flag = 0
時,循環一直在進行;
當進程收到2
號信號時,執行自定義處理handler
方法,修改falg
;
預期結果就是:進程在收到2
號信號時,flag
修改為1
,循環就結束了。
正常來說,
CPU
在執行進程時,訪問flag
變量都是從內存中讀取;而在main
函數中并沒有修改flag
變量,一些編譯器就會對其進行優化,將flag
變量直接寫入CPU
寄存器中。而
volatile
修飾變量就是告訴編譯器不要進行優化,每次都從內存中讀取變量的值。
SIGCHLD信號
這里簡單了解一些SIGCHLD
信號;
SIGCHLD
信號是子進程退出時,操作系統給父進程發送的一個信號。
我們知道,子進程在退出時,會進入僵尸狀態,等待父進程回收退出信息;就要父進程等待子進程。
而如果我們不關心子進程的退出信息,我們就可以將父進程對于SIGCHILD
信號的處理方式設置成SIG_IGN
;
這樣子進程在退出時,操作系統給父進程發送SIGCHLD
信號,父進程SIG_IGN
,此時子進程的task_struct
就會立即被回收,不需要父進程等待。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{signal(SIGCHLD, SIG_IGN);int id = fork();if(id < 0)exit(1);else if(id == 0){printf("child process pid : %d\n",getpid());sleep(1);exit(1);}int cnt = 3;while(cnt--){printf("parent process pid : %d\n",getpid());sleep(1);}return 0;
}
可以看到,子進程退出后,父進程沒有等待wait
;子進程也沒有出現僵尸狀態。
但是,可以看到進程對于SIGCHLD
信號的處理方式是Ign
;那為什么不調用signal(SIGCHLD, SIG_IGN)
,父進程不等待,子進程就要進入僵尸狀態呢?
這里,進程對于
SIGCHLD
信號的處理方式是默認處理SIG_DFL
,而默認處理的方式是Ign
。和
SIG_IGN
不一樣,操作系統設置成默認處理SIG_DFL
,默認處理的方式是Ign
;這樣在子進程退出后,父進程就可以隨時獲取子進程的退出信息,回收子進程了。