在之前的Linux《進程信號(上)》當中我們已經了解了進程信號的基本概念以及知道了信號產生的方式有哪些,還了解了信號是如何進行保存的,那么接下來在本篇當中就將繼續之前的學習了解信號是如何處理的。除此之外還會了解到中斷的概念,以及中斷實際運行的原理是什么樣的,在中斷的學習當中將引入用戶態和內核態相關對的概念,相信通過本篇的學習會讓你對操作系統有更深的理解,一起加油吧!!!
1. 信號捕捉
之前我們已經學習了信號的產生以及信號的保存,那么接下來就到了信號處理的階段。
首先我要知道的是信號的處理實際上不是立即處理的,而是要等到合適的時候再處理的,但是問題就來了,這里提到的合適的時候具體是什么呢?
如果信號的處理動作是自定義處理的,那么這時抵達調用該函數的時候就稱為捕捉信號。
但實際上信號的處理具體的流程是較為復雜的,實際上信號的的處理流程是如下所示的:
例如當中用戶注冊了SIGQUIT信號的處理函數sighandler時,那么接下來當程序在執行main函數的時候,這時就會因為中斷或者異常進入到內核態當中,那么接下來在內核當中就會處理信號的自定義處理動作,但是這時候執行具體的函數又需要回到用戶態當中,將函數執行完之后再回到內核當中,最后將處理的結果返回給程序,程序會從原來中斷的地方重新執行下去。
因此以上的執行流程圖就簡單來說就可以描述為以下的形式:
以上的流程圖就很類似一個數學當中的∞符號,之后記憶信號執行的流程我們就可以通過以上的圖來進行。
實際上除了處理自定義信號的捕捉方法之外,其余的忽略或者執行原來默認的方法就簡單多了,在內核當中即可完成函數的執行,之后直接將結果返回給用戶態即可。
以上就大致的了解了處理信號的流程是什么樣的,但是實際上我們還是對以上的一些概念不是很了解,就例如用戶態和內核態就是之前沒有聽過的概念,具體用戶態和內核態之間的切換是什么樣的我們現在也還是不知道如何進行的,那么是不是接下來機需要來了解這些概念了呢?
接下來去確實是會講解以上提到的問題,但是在這之前我們需要先來了解中斷相關的概念
2. 中斷
在之前的學習當中我們就了解到了操作系統實際上可以看作一個死循環,并且這個死循環還是暫停住的,只有當有任務被調度到的時候操作系統才會運行起來。
void main(void) /* 這?確實是void,并沒錯。 */
{ /* 在startup 程序(head.s)中就是這樣假設的。 */
...
/*
* 注意!! 對于任何其它的任務,'pause()'將意味著我們必須等待收到?個信號才會返
* 回就緒運?態,但任務0(task0)是唯?的意外情況(參?'schedule()'),因為任
* 務0 在任何空閑時間?都會被激活(當沒有其它任務在運?時),
* 因此對于任務0'pause()'僅意味著我們返回來查看是否有其它任務可以運?,如果沒
* 有的話我們就回到這?,?直循環執?'pause()'。
*/for (;;)pause();
}
但是在之前我們沒有了解到對應的硬件是如何通知操作系統要調度對應的進程的,實際上是通過中斷來實現的,來看以下的圖:
2.1 硬件中斷
實際上在計算機當中當硬件出現對應的異常或者需要CPU執行對應的程序時,是會先向中斷控制器當中發送對應的中斷號,接下來中斷控制器會將該中斷號再發送給CPU,CPU在得到中斷號之后再根據中斷號從操作系統當中的中斷向量表當中啟動對應的中斷服務。
綜上就會發現在中斷執行的流程當中,簡單來說就是從硬件->中斷控制器->CUP->操作系統中的中斷向量表->CUP執行對應的中斷方法。
在馮諾依曼體系當中,CPU是進行所有操作的核心,這就要求所有的硬件設備都需要和CPU建立連接才能進行交互,那么在早期的計算機當中大多數硬件都是有和CPU有線連接起來的,就是為了能讓CPU能直接的和其他的硬件進行交互。
但是到了現代的計算機當中實際上已經比直接將硬件和CPU直接連接的方式要復雜的多,多數外設不會直接連 CPU,而是通過 總線(PCIe、USB、SATA 等)連接。但是我們依舊可以理解硬件是直接和CPU進行連接的。
注:中斷向量表本質上就是一個存儲對應中斷方法的數組,在操作系統運行起來的時候就已經被加載到內存當中了。
那么當中斷沒到來的時候操作系統是不是就是一直在暫停呢?實際上就是這樣的,因此就可以說操作系統是在硬件的時鐘中斷驅動下進行調度的。操作系統就是基于中斷,進行工作的軟件。
到這里你可能就會蒙了,這里提到的時鐘中斷又是什么,怎么之前都沒聽過呢。
其實以上我們提到的硬件設備產生的中斷就是硬件中斷,而時鐘中斷又是硬件中斷當中的一種,發送時鐘中斷的硬件就是時鐘源,發送的時鐘中斷就會以合適的時間間隔進行進程的調度。
實際上計算機當中的時鐘源為了能更快的完成時鐘中斷都是將時鐘源直接集成到CPU當中,這樣時鐘源距可以以更加小的代價將對應的時鐘中斷發送給CPU,簡單來說時鐘源就是進行計數的,而時鐘中斷就是進行通知CPU。當CUP接收到對應的時鐘中斷之后就會將當前進行的進程進行切換的工作,將進程的寄存器當中的上下文數據保存到PCB當中,此時進行的工作就是“CPU保護現場”。
在早期的計算機當中其實是可以通過對應的時鐘中斷的快慢得到對應的CPU頻率的,這里的CPU頻率就是一般我進行電腦選配的的時候看到的CPU主頻。
注:但是在當代的計算機當中實際上計算機當中的時鐘中斷和CPU的頻率實際上已經解耦了,這兩個之間無法在通過簡單的換算進行轉換。
時鐘中斷除了有以上的作用之外還可以讓計算機當中能時刻的存儲當前的時間戳,這樣計算機就能在離線的情況依然能知道當前的運行時間是什么樣的。
因為在CPU執行進程的時候是不會記錄具體運行的時間的,而是通過時鐘源當中的計數器來獲取目前的時間,時鐘源每發送一次中斷對應的計數器就會進行調整,而每次的時鐘中斷的發送間隔又是固定的,那么這時計算機就可以得到對應的具體時間了。
2.2 軟中斷
以上我們了解到了計算機當中的硬件是可以想CPU發送硬件中斷的,那么接下來是否存在軟件條件觸發中斷的情況呢?
實際上是存在的,除了硬件可以發送中斷之外,還有軟件條件也是可以引發中斷的。在此軟中斷就是由軟件觸發的中斷。軟中斷的形式有一下幾種:
1.程序員顯示觸發
2.異常
3.陷阱以上第一種程序員顯示觸發就是使用對應的INT?n指令來實現,Linux當中的就是通過INT 0x80實現。
在CPU當中檢測到異常的時候就會拋出異常,在此該方式也屬于“軟中斷”。
除此之外以上的陷阱就是當使用到SYSCALL系統調用等能讓CPU陷入內核。
軟中斷執行的具體流程就如下圖所示:
首先CPU會接收到對應的異常之后會通過系統當中的中斷向量表來得到軟中斷要執行的系統調用,沒有系統調用本質上就是函數指針數組當中的一個元素。因此實際上我們平時在調用系統調用的時候本質就是觸發軟中斷,就是調用中斷向量表當中的指定元素當中的元素,系統調用號就是數組下標。
之前在信號產生當中的學習我們就知道了野指針或者除零等軟件的異常是可以產生信號的,但是那時候我們不知道出現異常之后CPU是如何知道當前的進程出現了異常,但是現在了解了中斷當中軟中斷的概念以及運行的原理之后,就可以知道實際上當以上的CPU當中的異常(本質上就是中斷)產生之后內核就會進行處理這個異常,并將其轉化為對應的信號投遞給用戶進程。
其實可以這么比喻:
中斷/異常 = 警察發現你闖紅燈(硬件發現錯誤)。
信號 = 警察給你開罰單(內核把錯誤傳達給用戶進程)。
實際上無論是缺頁中斷、內存碎片處理、除零野指針錯誤、其實這些問題,全部都會被轉換成為CPU內部的軟中斷,然后走中斷處理例程,完成所有處理。有的是進行申請內存,填充頁表,進行映射的。有的是用來處理內存碎片的,有的是用來給目標進行發送信號,殺掉進程等等。
綜我們就可以總結出操作系統就是躺在中斷處理例程上的代碼塊。
3.理解用戶態和內核態
以上我們在中斷當中提到了陷入內核的概念,那么這時我們就要思考這里的內核和用戶實際上是什么關系的呢?而用戶態和內核態又是什么樣的關系呢?
其實之前我們了解到了虛擬內存到物理內存之間的映射表示圖實際上只是真正情況下的一部分,之前了解的本質上都是用戶態
通過以上的圖示就可以看出實際上虛擬內存是被分為兩個部分的,分別是[0,3GB]的用戶區和[3,4GB]的內核區,用戶區就是我們之前學習的虛擬地址空間,這段空間是使用對應的頁表與物理內存進行映射的,在此就不進行講解了,但是以上的內核區就需要來講解看看了。
操作系統當中的進程可能會同時存在多個的,我們知道每個進程都會又自己的虛擬地址空間,實際上在虛擬地址空間當中的內核區與中斷向量表進行映射的頁表是所有進程共用的。本質上這樣設計就是為了讓進程在任何的時候都能找到操作系統。
而之前提到的陷入內核實際上就是將當前的進程從用戶區跳轉到內核區,但是在這里要注意的是用戶是無法直接訪問[3,4GB]中的內核區的,這是因為如果用戶可以隨意地訪問那么不就會出現讓用戶更改一些核心的數據嗎?所以實際上能訪問內核區的只有操作系統。在內核區和用戶區之間進行跳轉的時候操作系統是會進行身份的切換的,無論是用戶還是OS在用戶區當中就是屬于用戶態;在內核區當中就是屬于內核態。
但是這時又有問題了,那就是使用什么來標識當前用戶或者是OS是處于用戶態還是內核態的呢?
實際上在操作系統當中會存在一個CS段寄存器內的數據來標識,其中CPL為0的時候表示內核,為3的時候表示用戶。當進程在調用對應的系統調用的時候就會將程序從用戶區跳轉到內核區,之后就再中斷向量表當中找到指定下標的系統調用。整個的過程是和之前再動態庫的加載之后,調用動態庫當中的函數類似的,本質上都是在虛擬地址空間上進行跳轉。
4. 可重新入函數
以上的insert函數當中使用在函數調用的過程當中如果先程序發送了信號,那么如果將該信號的處理方法進行自定義之后,將信號的處理自定義為再次調用insert函數,那么這時候不就會出現調用insert的同時又調用了insert函數。
main函數調用insert函數向?個鏈表head中插入節點node1,插入操作分為兩步,剛做完第?步的時候,因為硬件中斷使進程切換到內核,再次回用戶態之前檢查到有信號待處理,于是切換到sighandler函數,sighandler也調用insert函數向同?個鏈表head中插入節點node2,插入操作的兩步都做完之后從sighandler返回內核態,再次回到用戶態就從main函數調用的insert函數中繼續往下執行,先前做第?步之后被打斷,現在繼續做完第?步。結果是,main函數和sighandler先后 向鏈表中插?兩個節點,?最后只有?個節點真正插入鏈表中了。
以上就展示了可重入函數是什么樣的,那么在操作系統當中具體函數是不是可重入函數是如何進行區分的呢?
實際上只要函數滿足一下其中之一的條件就是不可重入函數:
? 調用了malloc或free,因為malloc也是用全局鏈表來管理堆的。? 調用了標準I/O庫函數。標準I/O庫的很多實現都以不可重入的方式使用全局數據結構。
5. volatile
首先我們先來看以下的代碼:
#include<stdio.h>
#include<signal.h>int flag=0;void handler(int args)
{printf("flag 0 to 1!\n");flag=1;}int main()
{signal(2,handler);while(!flag);printf("process exit!\n");return 0;
}
makefile:
test:test.ccgcc -o $@ $^ -O1
.PHONY:clean
clean:rm -f test
在以上的代碼當中我們是將二號進行自定義捕捉捕捉之后,那么運行編譯之后的程序就可以得到當從鍵盤當中輸入Ctrl+c 之后接下來程序就會因為flag的改變而使得while循環結束使得程序結束。
實際上我們實現的代碼是可以讓編譯器進行優化的,優化有以下的級別:
-O0(默認)不做優化,編譯速度快,便于調試。-O1基本優化,比如刪除無用代碼、簡單循環優化。-O2在 -O1 的基礎上,做更多優化,包括:更激進的寄存器分配內聯函數展開循環展開與合并公共子表達式消除死代碼消除指令調度(提高流水線利用率)更少的內存訪問(變量緩存到寄存器)
那么如果讓編譯器進行進行-O1級別的優化以上的代碼又會變成什么樣的呢?
將makefile修改為以下的形式:
test:test.ccgcc -o $@ $^ -O1
.PHONY:clean
clean:rm -f test
運行程序之后發送信號就會發現發送2號信號之后無法將進程殺掉了,那么這時候出現這樣的原因是什么呢?
實際上當編譯器進行優化的時候當出現要從內存當中一直拿一個指定的變量的值時候,這時編譯器為了提高效率那么就會會將該變量的值保存到指定的寄存器當中,之后再要獲取該變量的值就直接從寄存器中獲取,而不再從內存當中讀取。這種方式確實能提高代碼執行的效率,但是這時也就會存在對應的問題,就例如以上的代碼當中在程序執行起來之后會將flag當中的值保存到寄存器當中,但是這時flag的值還是0,即使當用戶向進程發送出2號信號之后flag的值雖然改變了,但是寄存器當中的flag值依舊是0,那么這就會出現while循環在得到信號之后依舊在執行的現象。
那么在以上編譯器進行優化的時候如何禁止編譯器將變量優化進寄存器,每次訪問都要從內存中取值,在此提供了volatile來實現。
在 C/C++ 里,volatile
的作用主要是 告訴編譯器不要對這個變量的訪問做優化。
將以上代碼當中的flag加上volatile之后運行程序:
#include<stdio.h>
#include<signal.h>volatile int flag=0;void handler(int args)
{printf("flag 0 to 1!\n");flag=1;}int main()
{signal(2,handler);while(!flag);printf("process exit!\n");return 0;
}
這時就會發現程序能被2號信號殺掉了。
注:volatile
不是原子性的,也 不能保證多線程同步,但是目前我們還沒了解線程相關的概念,這時還是無法理解原子性是什么,這些等到接下來學習完線程之后就能理解了。
6. 信號補充知識
6.1 sigaction
在之前我們已經了解了進行信號自定義捕捉的函數signal,那么接下來載來學習一個相比signal功能更加強大的函數sigaction。
#include <signal.h>int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);參數:
signum:要處理的信號(如 SIGINT、SIGTERM)。
act:新的信號處理動作。
oldact:如果非空,則保存舊的動作。
struct sigaction {void (*sa_handler)(int); // 簡單的信號處理函數void (*sa_sigaction)(int, siginfo_t *, void *); // 復雜處理函數sigset_t sa_mask; // 信號屏蔽字,處理函數執行期間要屏蔽的信號集合int sa_flags; // 行為控制標志
};sa_handler:指定一個處理函數(比如 handler(int signo))。
sa_sigaction:如果設置了 SA_SIGINFO 標志,就用這個函數,能拿到更多信息(比如信號來源、附加數據)。
sa_mask:在處理這個信號時,額外屏蔽哪些信號。
sa_flags:常見的有:SA_RESTART:被信號中斷的系統調用會自動重啟。SA_SIGINFO:啟用 sa_sigaction 方式。
通過以上就可以看出sigaction函數的相比signal的使用會較為復雜,在此該函數的第一個參數就是要進行自定義捕捉的信號,第二個參數是是一個結構體的指針,實際上就是將要執行的信號捕捉方法傳給該函數,在該結構體當中sa_handler就和之前handler函數一樣,除此之外還可以選擇使用sa_sigaction,相比原來的sa_handler能發送信號附帶的信息。
其實在sigaction當中相比signal效果最明顯的是結構體當中的sa_mask,該變量是是信號屏蔽字,作用就是當對應的信號在進行處理的時候就將sa_mask當中的信號都加入到Block表當中,讓該信號在執行的時候這些信號會保持阻塞的狀態直到信號處理結束的時候,會將block修改為處理該信號之前的樣式。
例如以下的使用示例:
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>void PrintPeding(sigset_t s)
{std::cout<<"當前進程pid"<<getpid()<<",進程peding表:";for(int i=31;i>=1;i--){if(sigismember(&s,i)){std::cout<<1;}else{std::cout<<0;}}std::cout<<std::endl;
}void handler(int args)
{printf("收到2號信號!\n");while(1){sigset_t s;sigpending(&s);PrintPeding(s);sleep(1);}}int main()
{struct sigaction act;sigemptyset(&act.sa_mask);act.sa_handler=handler;sigaddset(&act.sa_mask,SIGTSTP);sigaction(SIGINT,&act,nullptr);while(1);return 0;}
以上的代碼當中就是sigaction將2號信號進行自定義的捕捉,并且在2號信號在進行處理的時候讓20號信號阻塞等待,并且讓進程接收到2號信號之后讓進程循環的打印當前進程的peding位圖表,那么接下來再該進程發送20號信號也就是使用鍵盤使用Ctrl+z?發送,接下來當得到對應的信號之后觀察peding位圖是否會修改,且觀察當前的進程是否會被殺掉來檢測20號信號是否被遞達。
運行程序效果如下所示:
通過以上的效果就可以看出確實在接受到對應的20號信號之后會將Peding位圖修改,但是不會將該進程“殺掉”。
6.2 SIGCHLD信號
之前在學習進程相關的概念的時候我們就已經了解了創建子子進程之后,若子進程先退出那么子進程就會變為僵尸進程,只有父進程進行進程等待之后將子進程進行回收才不會造成內存泄漏,并且我們在了進程等待的時候還知道了進程等待的方式有阻塞等待和非阻塞等待。
父進程可以阻塞等待子進程結束,也可以非阻塞地查詢是否有子進程結束等待清理(也就是輪詢的?式)。采用第?種方式,父進程阻塞了就不 能處理自己的工作了;采?第?種方式,父進程在處理自己的工作的同時還要記得時不時地輪詢? 下,程序實現復雜。
實際上在子進程退出的時候是會發送SIGCHLD信號的,那么這時就可以對該信號進行自定義捕捉,在對應的捕捉函數當中實現對子進程的等待,這樣就可以讓父進程不再需要阻塞的等待而轉為非阻塞的等待。
例如以下的示例:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include<sys/wait.h>
void handler(int sig)
{pid_t id;while ((id = waitpid(-1, NULL, WNOHANG)) > 0){printf("wait child success: %d\n", id);}printf("child is quit! %d\n", getpid());
}int main()
{signal(SIGCHLD, handler);pid_t cid;if ((cid = fork()) == 0){ // childprintf("child : %d\n", getpid());sleep(3);exit(1);}while(1){printf("father proc is doing some thing!\n");sleep(1);}return 0;
}
在以上的代碼當中就使用fork創建出對應的子進程之后,接下來父進程繼續的執行,子進程在運行3秒之后停止了,接下來子進程就會向父進程發送對應的SIGCHLD信號,由于我們對該信號進行了自定義的捕捉,那么接下來就可以在對應的捕捉方法handler當中執行對子進程的等待,那么這樣就可以實現父進程非阻塞式的將子進程從僵尸進程狀態釋放。
但是在Linux當中實際上還提供了使用sigaction將SIGCHLD處理動作修改為SIG_IGN,那么這時子進程就不會再在退出的時候將對應的退出信息保存到其task_struct當中并且不會將子進程退出之后進入到僵尸狀態,這時父進程就不需要在進程子進程的進程等待。
例如以下示例:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>int main()
{struct sigaction act;sigemptyset(&act.sa_mask);act.sa_handler = SIG_IGN;sigaction(SIGINT, &act, nullptr);pid_t cid;if ((cid = fork()) == 0){ // childprintf("child : running!\n");sleep(3);printf("child : exit!\n");exit(1);}else{printf("parent : running!\n");sleep(3);printf("parent : exit!\n");exit(1);}return 0;
}
以上代碼當中就使用sigaction將SIGCHLD的處理動作修改為SIG_IGN,那么這時機不再需要再對子進程進行進程等待。
以上就是本篇的所有內容,感謝你的觀看