目錄
1.信號的產生
(1)kill
(2)raise、abort
2.對block、pending、handler表的管理
(1)信號集(sigset_t)
(2)block表的管理
①操作相關的函數
②sigprocmask
(3)pending表的管理
(4)handler表的管理
3.操作系統的原理
(1)硬件中斷
①中斷控制器
②中斷號
③保護現場、恢復現場
(2)時鐘源
①主頻
②時間片
(3)異常處理
(4)軟中斷
①軟中斷和硬件中斷
②系統調用表
③缺頁中斷
④異常、陷阱
(5)用戶態和內核態(重點)
①用戶區、內核區
②用戶態和內核態之間的切換
③函數跳轉
(6)捕捉信號流程
①用戶態和內核態的切換
②對狀態切換的理解
③對執行流概念的深化
(7)volatile
①可重入函數
②volatile、編譯器優化
4.信號拓展
(1)SIGCHLD
(2)SIGALRM
1.信號的產生
(1)kill
信號究竟是如何產生的?我們已經知道信號的各種上層規則,但對于信號的來源還不是很熟悉。
看一下下面的代碼,就能很快理解了。
kill本身就是個系統調用,而不僅僅是一個指令。int kill(pid_t pid, int sig);就可以實現對特定的pid進程發送sig信號,當信號發送成功時返回0,失敗時返回-1。
也就是說所謂的kill指令,底層還是kill函數系統調用(指令->系統調用)。因此我們可以理解,當bash進程要kill掉其子進程時,就是調用的kill函數實現的。如當管道的讀端關閉,系統會直接殺掉進程,就是使用的kill發送SIGPIPE信號
(2)raise、abort
int raise( int )意思是誰調用,就給自己發送這個信號,也就是說raise(9)可以殺掉自己。
void abort( void )意思是誰調用,就給自己發送SIGABRT 6號終止信號。相當于raise(6)
2.對block、pending、handler表的管理
在介紹了三張表的功能和調用流程之后,我們需要進一步講講如何修改這三張表,因為信號從接收到發送的全過程都由這三張表控制,管理這三張表本質上就是在管理信號的處理。
(1)信號集(sigset_t)
未決(pending)和阻塞(block)表都可用相同類型來存儲,因為它們本質都是位圖。這個位圖的結構體是sigsei_t,也稱為信號集,這個類型在兩張表有不同含義:在pending表表示是否有接收到該信號,在block表表示是否阻塞該信號。阻塞(block)信號集也叫做當前進程的信號屏蔽字(Signal Mask),這里的屏蔽是指阻塞。
我們只需記住信號集的本質是位圖,是未決(pending)和阻塞(block)表中位圖的專屬類型。?
(2)block表的管理
注意以下的函數都是針對block表的信號集!
①操作相關的函數
首先,我們需要自己創建一個sigset_t的變量,再對這個位圖進行如下處理:
int sigemptyset(sigset_t *set);將位圖清0;?int sigfillset(sigset_t *set);將位圖填為全1
int sigaddset(sigset_t *set, int signum);添加對signum號信號的屏蔽(本質就是修改位圖,將對應位改為1);int sigdelset(sigset_t *set, int signum);刪除對signum號信號的屏蔽
上述返回值都是0為成功,-1為錯誤
②sigprocmask
接下來我們要讓我們的改動生效,上述所有的改動都是用戶自己的修改,并沒有寫到內核中去。
int sigprocmask(int how, const sigset_t *newset, sigset_t *oldset);
how由如下宏定義決定功能:SIG_BLOCK增加傳入的newset中狀態為1的信號的阻塞;SIG_UNBLOCK解除傳入的newset中狀態為1的信號的阻塞;SIG_SETMASK直接用newset覆蓋block表中的位圖。注意這個newset就是剛才我們進行各種處理后的sigset_t變量(輸入型參數)。而oldset是輸出型參數,是修改前的位圖,幫助我們恢復原來的位圖。
除了上述用法之外,sigprocmask(0, NULL, &oldset)可以獲取當前的block表
下面的代碼,我利用阻塞表阻塞了2號信號的處理,使得信號無法被處理
(3)pending表的管理
int sigpending(sigset_t *set);用于獲取pending表,set是一個輸出型參數,該函數只提供內核中的pending表而不提供修改。其返回值0表示成功,-1表示失敗。
int sigismember(const sigset_t *set, int signum);?判斷signum對應信號是否有效。pending和block表都可以用,因為它們的位圖的數據類型一致。我們需要手動傳入sigset_t,這需要我們使用sigpromask、sigpending獲取當前block、pending表。其返回值是1為真,0為假,-1為錯誤
(4)handler表的管理
signal函數可以進行信號捕捉,進而修改handler表,這里提一句即可。
int sigaction(int signum, const struct sigaction* newact, struct sigaction* oldact);
對于這個結構體,通常flag設置為0,sa_handler設置為指定的void(int)函數指針,使用為sigaction(2, &act, &oldact),即將信號2的默認處理方式替換為自定義處理,并且得到一個oldact用于備份。
struct sigaction的sa_mask成員還可以順便幫我們添加要屏蔽的信號,我們自己設置sigset_t,在后續調用sigaction函數時,除了相應pending表被修改,block表也會加上sa_mask里面的幾個屏蔽的信號。
3.操作系統的原理
要進一步理解信號,我們要對OS進一步深挖,來解釋為什么我們鍵盤輸入信號后OS能夠及時的處理?是否需要OS一直等待鍵盤輸入?同步異步是如何實現的?
(1)硬件中斷
OS怎么知道鍵盤輸入數據了?是否需要OS一直等待鍵盤輸入?事實上,OS是啟動的第一個軟件,它不會輪詢設備,設備數量太多了,鍵盤、磁盤、顯示屏等,而是外設提醒OS后OS再來處理。
①中斷控制器
以鍵盤輸入為例,鍵盤按下會首先觸發硬件中斷(由硬件電路實現),這個硬件中斷的信號不會直接傳給CPU(物理上鍵盤并沒有直連CPU),而是傳給中斷控制器(和每個設備直連),中斷控制器再向CPU發送信息,進而被OS獲取。
注意中斷控制器可不會關心鍵盤按什么鍵,它只會告訴CPU發生了中斷,CPU也不會關心,它收到中斷后只會告訴OS有外設準備好了,之后在OS的管理下,鍵盤給CPU傳信息,進而實現給OS傳信息。至于鍵盤輸入了什么,那是OS之后需要干的事。
再以磁盤為例,當磁盤想要給進程發信息時,雖然磁盤的尋址需要時間,要定位要準備,但這段時間OS不管,繼續執行自己的任務,僅當磁盤準備好后中斷控制器直接向CPU發送信號,執行讀取操作即可。這樣,硬件和OS實現了并行執行。
這里我們就會發現,信號和硬件中斷有相似之處,信號是純軟件模擬中斷的行為,硬件中斷是靠軟硬結合實現的(絕大部分是硬件)。
②中斷號
對于中斷控制器而言,每個設備都對應一個中斷號,中斷控制器利用中斷號觸發高電平告訴CPU誰中斷了。這些中斷號OS明白對應哪些設備,此時OS就回去調用對應的處理方法。要管理這些處理方法,OS有一個中斷向量表IDT,這是一個函數指針數組,中斷號就相當于數組下標。當中斷控制器告訴OS中斷號后,OS直接由IDT調用中斷服務處理中斷。這些中斷服務包括讀取硬盤、網卡等。方法內容由OS自定或安裝驅動確定,同時我們也要知道中斷號和中斷處理方法是相對固定的,是軟硬結合共同維護的。
③保護現場、恢復現場
在OS接收到中斷控制器的信號,確定要處理中斷之后,OS會將當前CPU各種寄存器的數據保存在中斷的上下文,方便后續處理。保存好寄存器數據后,OS就會根據中斷向量表找方法,執行中斷處理例程(利用中斷號查表)。處理完后就會恢復現場,繼續執行任務。
OS通過中斷實現了不主動輪詢外設。所有外設(不含內存,它是存儲器)都是這么處理的。
(2)時鐘源
進程可以在OS指揮下調度執行,那么OS自己被誰指揮呢?時鐘源。
①主頻
我們已經知道了所有外設的信息是如何進入進程被處理的了,其中有一個外設,每隔一段極短時間就會給CPU發硬件中斷,這個外設就是時鐘源,它具有一個固定的中斷號,以及一個固定的中斷服務:進程調度。時鐘源會一直推動OS進行進程調度,OS就是基于不斷中斷,調用中斷向量表工作的。現在時鐘源已經被集成在CPU內部,時鐘源的觸發頻率就是CPU主頻的概念(如14900K的6GHZ)。主頻速度越快,處理任何操作都會更頻繁。
OS在時鐘的推動下自行調度,相當于說OS可以不做任何事,啟動之后直接讓自己進入死循環,利用時鐘和其它外設觸發中斷推著它進行運行,執行方法。
總結:對于OS來說,只要完全沒有進程,它就陷在死循環中,但只要有進程,就會一直忙著調度,這是時鐘源的中斷推著它進行的。我們因此可以說操作系統就是躺在中斷中運行的。與此同時,OS也會自己去fork一些進程,這是內核固定進程,會定期去檢查OS運行狀態,定期把內核緩沖區數據進行刷新、檢查鬧鐘等操作。?
②時間片
時鐘中斷是固定時間的,假設為1ns。進程的調度都設置了時間片,輪詢著調度以盡量保持公平。OS要實現時間片,只需設置int count,當count == 1000(1微秒),每次時鐘源中斷觸發進程調度都會讓count--,count減到0后直接切換進程。
時間片就是在主頻下的計數器,是以時鐘中斷為基礎構建的。
(3)異常處理
我們已經知道,程序的崩潰就是靠信號終止的。
其中野指針觸發段錯誤,操作系統直接用11號信號殺掉進程,我們捕捉信號后發現OS一直在觸發信號。
同理,a / 0也會崩潰,會觸發13號信號SIGFPE終止進程。
為什么OS知道我們的進程的內部出錯了,為什么OS會一直觸發信號?
以a / 0為例,CPU中有寄存器,a -> eax,0 -> ebx, 計算結果 -> ecx。同時還有個狀態寄存器Eflags,有溢出標記位,默認為0,保證會把結果正常返回。但當溢出標記位為1,OS就知道CPU硬件內部出錯了,OS找到對應的進程后就要用信號殺掉損壞CPU的進程。
當我們捕獲信號之后,我們的進程沒有退出,直到時間片到了,進行進程切換時會OS會保存寄存器數據,以便下次調用恢復。其中Eflags的標記位也被保存了,OS不會修改它。由此以來,每當調度到這個進程時,CPU都會顯示異常,OS會一直嘗試殺我們的進程,這導致了死循環調用信號處理。
同理,OS怎么判斷野指針呢?CR3寄存器保存頁表起始地址,虛擬?-> 物理地址的轉換是MMU內存管理單元操作的,它集成在CPU中。當訪問野指針(虛擬地址)時,MMU轉換去讀0,發現無法訪問,于是觸發了硬件錯誤,OS知道后發送信號嘗試殺掉進程。當后續再輪換調用時,MMU的錯誤被完整地繼承了下來,所以OS會循環發送信號。
(4)軟中斷
①軟中斷和硬件中斷
上述都是外部硬件中斷,需要硬件設備觸發。有沒有僅靠軟件進行中斷的呢?
為了讓OS支持系統調用,CPU專門設計了對應的匯編指令(int或者syscall),在沒有外設下,可以讓CPU內部觸發中斷邏輯。這些匯編指令就可以寫在軟件中,通過匯編指令 + 中斷號推動CPU執行中斷向量表方法,這就叫軟中斷。硬件中斷和軟中斷僅僅是觸發方式變為匯編調用,其余流程一致,這很好理解。
兩種觸發方式最終都是利用中斷號調用中斷向量表中的函數。例如,int N 指令會觸發中斷號為 N 的軟中斷,操作系統會根據該中斷號來找到對應的處理函數(范圍從 0x00 到 0xFF(即從 0 到 255))
②系統調用表
系統調用也是通過中斷完成的。OS系統調用都在系統調用表,集中管理,是由“下標”調用的。系統調用表特別底層且固定,用戶無法查看這個表的存在。這個表中各個函數的下標叫系統調用號,在中斷向量表中設置一個系統調用的入口函數,用系統調用號調用。
要調用系統調用,就要想辦法軟中斷,int 0x080就是軟中斷到執行系統調用入口函數,進入固定例程。系統調用號會提前寫到寄存器中,可以直接被獲取。
系統調用的過程:先把要調用的系統調用號用寄存器存起來,再觸發軟中斷陷入內核,OS根據中斷向量表開始進入中斷服務,根據寄存器里的系統調用號自動查系統調用表,執行對應方法。
OS提供的系統調用接口根本不是C函數,而是系統調用號 + 約定傳遞的參數,通過觸發軟中斷、進行中斷服務調用的方式實現系統調用功能的。系統調用的真正的底層實際上是匯編。
GNU、glibc給系統進行C語言封裝,給我們提供C語言的系統調用。所以我們用的所有系統調用都是C的,是它對該平臺的系統調用進行了統一的上層封裝,這也是C具有跨平臺性的本質。C語言的上層封裝還保證了我們系統調用的安全性,不會導致對系統造成傷害。
③缺頁中斷
缺頁異常意思就是有虛擬地址,但物理內存沒有申請(分批加載)。當OS需要用到相應空間,但發現還沒有物理內存時,就會觸發中斷,申請并加載物理內存。
④異常、陷阱
OS就是躺在中斷例程上的代碼塊,幾乎所有操作都會轉為中斷。缺頁中斷、內存碎片處理、除零、野指針錯誤都是轉為軟中斷,OS會設置中斷號走中斷例程。異常、陷阱都會轉為軟中斷來處理。
如果發生了軟中斷且不是因為出錯,就是單純為了陷入中斷(系統調用),這叫做陷阱。
而像除零、野指針這種觸發錯誤導致軟中斷的叫做異常。
OS會根據不同錯誤或者陷阱由中斷號進行調用。
(5)用戶態和內核態(重點)
①用戶區、內核區
在32位操作系統下,進程的地址空間劃分了4G,其中這塊虛擬的地址空間包含了我們已知的堆棧段、代碼段、數據段、以及命令行參數列表等。所有的這一切所在的內存區域都屬于用戶區。用戶區從0G ~ 3G,共3G的大小。
3G ~ 4G這塊叫做內核區,這塊內核區也是虛擬的,它擁有共同的內核頁表,同用戶頁表一樣,都是映射到物理內存中的。不過需要注意的是,不同進程的內核區都會映射到同一塊物理內存。
這里需要注意的是,用戶頁表每個進程一份,因為虛擬地址和物理地址都要根據不同進程實際情況映射。;而內核頁表則是整個系統一份,無論是虛擬地址還是映射的物理地址都是同一份。對于任何進程來說,無論如何調度,它們都能找到同一個OS,訪問同一張中斷向量表和系統調用表。
無論調用任何函數(庫、系統調用),都是在我們自己用戶區進行調用的(代碼段在用戶區)。而被調用的系統調用方法的執行是在內核區進行的。
本質上說,內核區映射到同一塊空間是因為OS只有一個,那么如果使得進程映射多塊空間,就相當于啟動了兩個系統,這就是內核虛擬機的思路,當然還有用戶虛擬機,這里提一句。
②用戶態和內核態之間的切換
前面我們已經知道用戶區和內核區了,這兩個區域只能被具有對應的身份的人訪問,標記身份的就是用戶態和內核態。
如果我們要進入內核區,我們需要將我們的身份進行改變,即用戶態 -> 內核態;同理,如果要訪問用戶區,我們也應該將內核態 -> 用戶態。?
標記用戶態和內核態的是CPU的CS段寄存器。它有兩個bit位的標志(CPL):00表示內核,11表示用戶。修改用戶內核態的本質就是修改標志位。我們都知道MMU是集成在CPU內部,負責進行虛擬地址 -> 物理地址的,只要CPL不允許訪問,MMU也自然不會給我們轉換地址,我們也自然訪問不了對應的空間。用戶態和內核態的權限管理是硬件層面的。
當我們在用戶區調用系統調用時,會使用int (中斷號) 和 syscall (中斷號) 觸發軟中斷,進到中斷向量表里面去執行系統調用入口函數,進而訪問系統調用表,執行系統調用方法,最后回到調用處繼續執行代碼。在整個過程中,當觸發軟中斷時就會第一次切換狀態,將權限標志位CPL改為0,進入內核態。執行完后返回調用處又會切換一次,CPL改為3,進入用戶態。
③函數跳轉
在上述流程中,我們會有疑問:函數是如何跳轉的,跳轉回來時如何找到最開始的地址的?A函數調用B,A會把調用B的下一個地址先入棧,后面出棧后就能夠找到下一句地址。由此以來就能正確地在內核區和用戶區之間進行跳轉。匯編層面所有操作都是基于寄存器、地址的。
(6)捕捉信號流程
有了前面知識的積累,我們能夠更底層地理解信號捕捉的流程了。
①用戶態和內核態的切換
如果來了一個信號,有可能當前進程正在做更重要的事,我們要把信號保存到pending表中。當進程從內核態切換回用戶態的時候,進程就會進行信號檢查do_signal(),檢測當前的pending和block表決定是否處理信號。
當要處理信號時,會根據不同處理信號的方式決定走向。如果是DFL和IGN的情況,那么進程會不急著返回用戶態,會在內核態把方法執行完成后再返回(DFL和IGN的代碼都在內核區);如果我們自定義捕捉了信號,這些代碼都在用戶區保存,所以我們要切換回用戶態執行處理代碼。處理之后,會回到內核態,執行sys_sigreturn()返回主程序,回到用戶態。
②對狀態切換的理解
為什么執行自定義函數時要做權限切換?直接用內核態執行不行嗎?自定義函數存在用戶區,需要用戶態,而內核態權限更高,切換回用戶態是為了避免安全風險,防止內核態被利用。同時內核態進入用戶態后執行自定義函數,意味著用戶態的操作后果自負!
③對執行流概念的深化
我們執行完自定義函數后,想要回到主程序。為什么不直接回去,反而還要進入內核態呢?實際上自定義函數和主程序沒有任何關系,在程序中不存在任何調用關系,信號處理和主程序是兩個完全不同的執行流,因此只能先返回到內核。當觸發硬件中斷,OS會保護現場,存儲各個寄存器的狀態。其中pc寄存器就保存下一條指令的地址,在這里就是主執行流的下一條指令的地址。因此回到用戶態,需要在內核態調用sys_sigreturn()函數,恢復上下文,才可以回到初始時的在主程序執行流。
到這里,我們也能徹底理解信號處理沒有新開一個進程,主執行流和信號捕捉執行流,信號捕捉執行流沒有在主執行流中被調用,而只是一個主執行流中的分支,進程調用過程中兩個分支不會沖突。
下面是執行流切換的過程,仔細體會
(7)volatile
①可重入函數
若一個函數被兩個以上執行流同時進入,就有可能遇到下面這種情況,這是單執行流的情況下遇不到的。
因此實例的insert函數是不可重入函數。相對的,還有可重入函數,也就是可以被被兩個以上執行流同時進入同時不發生錯誤的函數。怎么判斷呢?
只要使用了全局資源的,new了空間的,基本都是不可重入的,使用的都是局部變量的就可能是可重入的。基本上STL都不可重入,大部分函數也都不可重入。但也有專門設計的系統接口帶_r,表示可重入。
②volatile、編譯器優化
gcc有優化選項 -O0基礎優化,-O1,-O2,-O3優化級別依次增加。
對于主程序執行流和信號處理執行流來說,由于它們從語法上沒有任何聯系,但實際上可以通過全局資源聯系,編譯時就有可能被優化出bug。
若在主執行流中對一個全局變量沒有修改,而實際上這個全局變量在信號捕捉執行流中被處理。對于高優化的編譯器來說,這個全局變量在信號捕捉執行流中的處理無效。我們要從底層來理解優化。
當編譯器優化很大時,一些主執行流沒有修改的變量會變成寄存器變量,這個變量永遠無法被修改。當我們使用這個變量來進行判斷時,邏輯判斷會直接用寄存器里面的值進行判斷而不會使用內存的數據,這就導致信號捕捉執行流中修改變量不會生效,寄存器 + 優化屏蔽了內存的可見性。
如果擔心被優化導致出現bug,我們可以使用volatile關鍵字修飾全局變量,volatitl int flag = 0;就不會被寄存器屏蔽了,這個關鍵字相當于告訴編譯器不要對該變量進行任何優化,以保持內存可見性。
4.信號拓展
(1)SIGCHLD
父進程一般關心子進程什么時候死,這樣好回收,以免出現孤兒或者僵尸。事實上,子進程退出時都會向父進程發送SIGCHLD信號,告訴父進程自己結束了。不過默認情況下SIGCHLD的Action是Ign(忽略)。我們可以手動捕獲這個信號,當父進程收到信號時再去wait,這樣就能實現父子進程異步執行,而不是讓父進程一直阻塞在原地。
但是有個問題,即信號處理過程中最多允許再接收一個信號,后續的沒意義(pending已經為1了),所以每次需要waitpid + WNOHANG循環,用返回值來判斷是沒等完還是等完了。
僅Linux下,signal(SIGCHLD, SIG_IGN)處理后,這樣fork出來的子進程在終止時會自動清理掉,沒有僵尸,不過父進程也得不到status。這里可認為是Linux的特殊處理,僅僅用來處理僵尸的情況。
(2)SIGALRM
alarm(seconds)函數可以設置一個一次性鬧鐘信號(只會響一次,重復使用需要多次調用),鬧鐘時間到了之后會發送一個14號SIGALRM信號,默認就是Term終止。我們可以捕獲它,利用alarm寫一個統計服務器1s執行某種操作多少次的代碼,可以用alarm來對比IO對效率的影響有多大等操作。
alarm(0)取消鬧鐘,其返回值是鬧鐘剩余時間。如果鬧鐘自己響了,那返回值就是0。