在 Linux 系統中,信號是一種用於進程間通信的機制,通常用於通知進程某些事件的發生。以下是常見的信號類型及其對應的快捷鍵和用途:

常見信號類型

  • SIGHUP (1): 通知進程終端掛起或連接斷開。
  • SIGINT (2): 中斷信號,通常由 Ctrl+C 觸發,用於終止進程。
  • SIGQUIT (3): 退出信號,通常由 Ctrl+\ 觸發,終止進程並生成核心轉儲文件。
  • SIGKILL (9): 強制終止進程,無法被捕獲或忽略。
  • SIGTERM (15): 請求進程終止,進程可以捕獲並處理此信號。
  • SIGSTOP (19): 停止進程,無法被捕獲或忽略,類似於 Ctrl+Z。
  • SIGTSTP (20): 暫停進程,通常由 Ctrl+Z 觸發,可被捕獲和處理。
  • SIGCHLD (17): 子進程狀態改變時通知父進程。
  • SIGALRM (14): 定時器到期信號,常用於 alarm() 函數。
  • SIGUSR1 (10) 和 SIGUSR2 (12): 用户自定義信號,可用於進程間自定義通信。

常用快捷鍵

  • Ctrl+C: 發送 SIGINT 信號,用於終止當前運行的進程。
  • Ctrl+Z: 發送 SIGTSTP 信號,將當前進程掛起到後台。
  • **Ctrl+**: 發送 SIGQUIT 信號,終止進程並生成核心轉儲文件。

信號處理方式

  • 忽略信號: 進程可以選擇忽略某些信號,例如 SIGCHLD。
  • 捕獲信號: 使用 signal() 或 sigaction() 註冊自定義處理函數。
  • 默認處理: 如果未定義處理方式,系統將採用默認動作,例如終止進程或生成核心轉儲。

示例代碼

以下代碼展示如何捕獲 SIGINT 信號並自定義處理邏輯:

#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int sig) {
std::cout << "捕獲到信號: " << sig << std::endl;
}

int main() {
signal(SIGINT, handler); // 捕獲 SIGINT 信號
while (true) {
std::cout << "運行中,按 Ctrl+C 發送 SIGINT 信號..." << std::endl;
sleep(1);
}
return 0;
}

 

運行後,按下 Ctrl+C 將觸發自定義的信號處理函數,而不是直接終止進程。

注意事項

  • SIGKILL 和 SIGSTOP 無法被捕獲或忽略,主要用於強制控制進程。
  • 信號處理函數應儘量簡單,避免調用不可重入的函數(如 malloc 或 printf),以防止數據競爭或死鎖問題。
  • 使用 sigprocmask 可以阻塞或解除阻塞特定信號,確保信號在適當時機遞達。

通過合理使用信號和快捷鍵,可以更高效地管理和控制 Linux 系統中的進程。

 

 

0.預備知識

  0.1.基本概念

  0.2.信號的捕捉 

  0.3.理解信號的發送與保存

 1.信號的產生的方式(1-5)(階段一)

  1.通過kill命令,向指定進程發送指定的信號

  2.通過終端按鍵產生信號:ctrl+c(信號2),ctrl+\(信號3)。

  3.通過系統調用:

  4.由軟件條件產生信號

          接下來我們來認識一個新的案例--alarm(鬧鐘)函數。

  5.硬件異常產生信號

  6.關於term和core

 2.信號的保存(階段二)

3.信號的處理(階段三)

  1.信號集sigset_t    

  2.信號集操作函數

  3.sigprocmask(操作block表的函數)

  4.sigpending(檢查pending信號集,獲取當前進程pending位圖)

  5.代碼樣例

  6.再談信號的捕捉

  7.對內核態和用户態的進一步理解

 4.關於信號捕捉的細節部分(sigaction函數)

5使用信號可能導致的問題

  1.可重入函數 

   2.使用信號對全局變量進行操作出現的問題(volatile)

  3.SIGCHLD信號


 

0.預備知識

信號的三個階段,信號產生,信號保存,信號處理


0.1.基本概念

        信號,又稱為軟中斷信號,是Linux系統響應某些條件而產生的一個事件。它是操作系統向一個進程或者線程發送的一種異步通知,用於通知該進程或線程某種事件已經發生,需要做出相應的處理。

當進程接收到信號時,可以採取以下處理方式:

  1. 忽略信號:進程可以選擇忽略某些信號,即不對該信號進行任何處理。
  2. 捕捉信號(自定義處理):進程可以註冊一個信號處理函數來捕捉特定的信號,並在接收到該信號時執行相應的處理邏輯。
  3. 默認處理:如果進程沒有註冊信號處理函數且沒有選擇忽略信號,則系統會按照默認的處理方式來處理該信號。通常情況下,默認處理方式會導致進程終止或停止。

        使用kill -l就可以看到所有的信號 ,我們把前三十一個稱為普通信號(也稱為不可靠信號),都是大寫的宏,前面的數字就是宏對應的值

信號_#include

使用man 7 signal往下翻就可以看到一張表,包含了,我們的大多數信號所對應的默認動作

大多數都是終止。

信號_#include_02

 


0.2.信號的捕捉 ——函數也就是操作方式

        捕捉信號是操作系統中進程處理信號的一種方式,允許進程在接收到信號時執行自定義的操作,而不是執行信號的默認動作。

引入系統調用signal:

        signal接口是Unix和Linux系統中用於處理信號的一個系統調用。它允許程序指定一個函數,當接收到特定信號時,該函數會被自動調用。通過這種方式,程序可以對信號進行自定義處理,或者忽略某些信號。

信號_#include_03

  • signum:要處理的信號類型,它是一個整數,對應於某個具體的信號。
  • handler:函數指針類型,用來接收自定義的函數。執行調用的函數就是執行自定義的操作。

使用示例;

        對信號的自定義捕捉,我們只要捕捉一次,後續就一直有效。(在這裏我們使用2號信號來做測試,2默認動作就是終止,就是CTRL+c)

#include <iostream>
#include <signal.h>
#include <unistd.h>
 
void hander(int sig)
{
  std::cout<<"get a sig"<<sig<<std::endl;
}
 
int main()
{
  signal(2,hander); 
  while(true)
  { 
    std::cout << "hello bit, pid: " << getpid() << std::endl;
    sleep(1); 
  }
}

 

現象:我們每發送一次2號信號就執行一次hander函數。

信號_自定義_04

當然你可以捕捉更多其它的信號。

你也可以使用ctrl+c發送2號信號:

信號_寄存器_05

    9號信號是不允許被捕捉的,是用來異常終止的。為了維護系統的安全性和穩定性,某些命令可能不允許或限制用户自定義信號捕捉。


0.3.理解信號的發送與保存

那麼信號又是如何保存的呢?        

Linux內核為每個進程維護了與信號相關的數據結構,主要包括三張表:

  • Block表(阻塞表):一個位圖,用於表示進程當前阻塞了哪些信號。每個信號都對應一個比特位,如果該位為1,則表示該信號被阻塞;為0則表示該信號未被阻塞。
  • Pending表(未決表):同樣是一個位圖,用於表示進程當前有哪些信號處於未決狀態。當一個信號產生且未被處理時,Pending表中對應的位會被設置為1。
  • Handler表(處理函數表):一個函數指針數組,用於表示當信號遞達時應該執行的處理動作。每個信號都對應一個處理函數指針,可以是默認處理函數、忽略函數或用户自定義的處理函數。

當信號產生時,內核會執行以下操作來保存信號:

  • 設置Pending表:在進程控制塊(PCB)的Pending表中,將對應信號位的值從0設置為1,表示該信號已經產生且處於未決狀態。
  • 檢查Block表:內核會檢查Block表,查看該信號是否被阻塞。如果被阻塞(即Block表中對應位為1),則信號會保持在未決狀態,不會被立即處理。
  • 更新Handler表(如果需要):如果進程為該信號設置了自定義處理函數,內核會更新Handler表中對應信號的處理函數指針為用户自定義的函數地址。

那麼發送信號:就是修改進程pcb中的信號指定的位圖,由0設置為1.

pcb是內核數據結構對象,只有操作系統有權限去修改。


信號的產生主要有下面幾種方式;

1.通過kill命令,向指定進程發送指定的信號

2.通過終端按鍵產生信號:ctrl+c(信號2),ctrl+\(信號3)。

3.通過系統調用:kill函數/raise函數

這裏引入系統調用kill

信號_寄存器_06

向指定信號發送指定信號。

  • pid:指定接收信號的進程的ID。如果pid小於-1,那麼信號將被髮送到進程組ID等於abs(pid)的所有進程。如果pid等於0,那麼信號將被髮送到與發送進程屬於同一進程組的所有進程,且發送進程的進程ID不等於sig。如果pid等於-1,那麼信號將被髮送到有權限發送信號給的所有進程(通常是發送進程有寫權限的所有進程)。
  • sig:要發送的信號。

我們藉助kill函數,模擬實現kill命令:

#include <iostream>
#include <cstdlib>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
 
int main(int argc, char *argv[])
{
  if(argc != 3)
  {
    std::cerr << "Usage: " << argv[0] << " signum pid" << std::endl;
    return 1;
  }
  
  pid_t pid = std::stoi(argv[2]); 
  int signum = std::stoi(argv[1]);
 
  kill(pid, signum);
}


調用kill函數,將轉換後的pidsignum作為參數,向指定的進程發送信號。

接着我們在寫一個測試進程,我們通過自己寫的kill命令向改進程發送指定的信號

#include <iostream>
#include <unistd.h>
#include <signal.h>
 
int main()
{ 
  while (true)
  {
    std::cout << "hello bit, pid: " << getpid() << std::endl;
    sleep(1); 
  }
}

 

現象:成功通過自己寫的kill命令終止了該進程。

信號_#include_07

raise函數是一個系統調用,用於向當前進程發送一個信號。

信號_#include_08

  • 參數:
  • sig:要發送的信號編號。例如,SIGTERM(信號編號為15)通常用於請求程序正常終止。
  • 返回值:
  • 如果成功,返回0。
  • 如果失敗,返回-1並設置errno以指示錯誤。

raise函數在單線程環境中類似於kill(getpid(), sig),即發送信號給當前進程;而在多線程環境中,其行為可能類似於pthread_kill(pthread_self(), sig),但這不是嚴格等價的,因為pthread_kill允許發送信號給特定線程。

還有abort,向當前進程發生6號信號,異常終止進程:

信號_自定義_09

void handler(int sig)
{
    std::cout << "get a sig: " << sig << std::endl;
}

int main()
{
    signal(6, handler);

    while (true)
    {
        sleep(1);
        std::cout << "hello bit, pid: " << getpid() << std::endl;
        abort();
    }
}

 

現象:我們發現abort可以被捕捉,但還是會終止進程(例外情況)

信號_#include_10

        9號信號是不允許被捕捉的,也是用來異常終止的。為了維護系統的安全性和穩定性,某些命令可能不允許或限制用户自定義信號捕捉。

4.由軟件條件產生信號——很容易被忘記

        我們都知道,在管道通信時,讀端關閉,寫端一直進行,寫就沒有意義了,這時候操作系統,就會向進程發送SIGPIPE(13號信號),終止進程。

        接下來我們來認識一個新的案例--alarm(鬧鐘)函數。

  alarm()是一個用於設置一個定時器的庫函數,該定時器在指定的秒數後發送一個SIGALRM信號給調用進程。

信號_#include_11

        參數seconds指定了定時器在多少秒後到期。如果seconds為0,則任何當前設置的定時器都會被取消,並返回剩餘的秒數直到前一個定時器到期(如果定時器已經被取消或未設置,則返回0)。

        當alarm()設置的定時器到期時,如果進程沒有忽略SIGALRM(14號)信號,也沒有為該信號指定一個處理函數(信號處理程序),那麼進程的行為是未定義的。通常,進程會終止,但這不是必須的。

        為了處理SIGALRM信號,你可以使用signal()來設置一個信號處理函數。當定時器到期時,這個函數就會被調用。

先看一下案例:1s後,

int main()
{
    int cnt = 1;
    alarm(1); // 設定1S後的鬧鐘 -- 1S --- SIGALRM
    // signal(SIGALRM, handler);
    while (true)
    {
        std::cout << "cnt:" << cnt << std::endl;
        cnt++;
    }
}

 

信號_自定義_12

 

#include <iostream>
#include <cstdlib>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
exit(1);
}
 
int main()
{
 
int cnt=1;
alarm(1);// 設定1S後的鬧鐘 -- 1S --- SIGALRM
signal(SIGALRM, handler);
while(true)
{
std::cout<<"cnt:"<<cnt<<std::endl;
cnt++; 
}
 
}

 

信號_#include_13

如何理解鬧鐘?

        在底層,alarm 函數通常與內核的定時器機制(如 ITIMER_REAL 類型的 POSIX 定時器)相關聯。當 alarm 被調用時,內核會設置一個軟定時器(soft timer),該定時器在指定的秒數後到期。當定時器到期時,內核會生成 SIGALRM 信號,並將其發送給調用 alarm 的進程。當操作系統中多處要用到alarm的時候,OS就會藉助最小堆,進行判斷,要先向誰發送SIGALRM信號。

鬧鐘的返回值   

  • 如果之前已經設置了一個 alarm:如果在調用 alarm 函數之前已經為進程設置了一個鬧鐘時間(即之前已經調用過 alarm),那麼 alarm 函數將返回之前鬧鐘時間的剩餘秒數。這個返回值表示的是從調用 alarm 函數的時刻起,到之前設置的鬧鐘時間到期還剩下的秒數。
int main()
{
    int cnt = 1;
    alarm(10);
    sleep(4);

    int n = alarm(2); // 上一個鬧鐘的剩餘時間
    std::cout << "n:" << n << std::endl;
}

 

信號_#include_14

  • 如果之前沒有設置 alarm:如果調用 alarm 函數之前沒有為進程設置過鬧鐘時間(即這是第一次調用 alarm),那麼 alarm 函數將返回 0。
  • 錯誤情況:在正常情況下,alarm 函數不會返回錯誤,因為它只是簡單地設置或取消鬧鐘時間。然而,在極少數情況下,如果系統資源不足(如內存不足),那麼 alarm 函數可能會失敗,但這種情況在實際應用中很少發生。如果 alarm 函數失敗,它將返回一個錯誤碼,但標準的 alarm 函數並沒有定義特定的錯誤碼;實際上,它通常只是返回 -1 來表示錯誤,但具體的錯誤原因需要通過查看 errno 來確定。
  • alarm(0)表示取消鬧鐘

5.硬件異常產生信號

比如,空指針解引用問題,除0問題。

int main()
{
    // int *p = nullptr;
    // *p = 100;

    int a = 10;
    a /= 0;

    while (true)
    {
        std::cout << "hello bit, pid: " << getpid() << std::endl;
        sleep(1);
    }
}

以下操作都是非法操作或訪問數據導致的:

除0的報錯:收到了SIGFPE(8號信號),被異常終止

信號_#include_15

 

空指針解引用的報錯:收到了SIGSEGV(11號信號),被迫異常終止

信號_寄存器_16

 

收到這些信號,進程必須退出嗎?不是,可以捕捉以上的異常信號,但是我們推薦終止進程,為什麼呢?

        1.除0問題

        關於進程中的計算問題,一般都是交由cpu完成的,在計算的過程中,難免會出現錯誤的計算,比如説除0,那麼cpu又是如何知道的呢?

        這就要提到cpu中的寄存器了,cpu中是有很多的寄存器的,其中有一個寄存器:EFLAGS寄存器(狀態寄存器)。該寄存器中有很多狀態標誌:這些標誌表示了算術和邏輯操作的結果,如溢出(OF)、符號(SF)、零(ZF)、進位(CF)、輔助進位(AF)和奇偶校驗(PF)。

        除0操作就會觸發溢出,就會標定出來運算在cpu內部出錯了。OS是軟硬件資源的管理者!OS就會處理這種硬件問題,向目標進程發送信號,默認終止進程。

        我們要知道cup內部是隻有一套寄存器的,寄存器中的數據是屬於每一個進程的,是需要對進程上下文進行保存和恢復的。

        如果進程因為除0操作而被操作系統標記為異常狀態,但沒有被終止,那麼它可能會被掛起,等待操作系統的進一步處理。

        當操作系統決定重新調度這個進程時,會進行上下文切換,即將當前進程的上下文保存到其PCB(進程控制塊)中,並加載異常進程的上下文到CPU寄存器中。

        上下文切換是一個相對耗時的過程,包括保存和恢復寄存器、堆棧等信息。當切換回這個進程的時候,溢出標誌位的錯誤信息同樣會被恢復,會頻繁的導致除0異常而觸發上下文切換,會大大增加系統的開銷。

        為什麼推薦呢?因為要釋放進程上下文的數據,包括溢出標誌數據或其他的異常數據。

        2.空指針解引用(野指針)問題

        這個問題就與頁表,MMU及CR2,CR3寄存器有關聯了。

        MMU和頁表是操作系統實現虛擬內存管理和內存保護的關鍵機制,它們通過虛擬地址到物理地址的轉換來確保程序的正確運行和內存安全。CR2和CR3寄存器在內存管理和錯誤處理中扮演着重要角色。CR3寄存器用於切換不同進程的頁表,而CR2寄存器則用於存儲引起頁錯誤的虛擬地址,幫助操作系統定位和處理錯誤。

        CR2寄存器用於存儲引起頁錯誤的線性地址(即虛擬地址)。當MMU無法找到一個虛擬地址對應的物理地址時(例如,解引用空指針或野指針),會觸發一個頁錯誤(page fault)。此時,CPU會將引起頁錯誤的虛擬地址保存到CR2寄存器中,併產生一個異常,此時就會向進程發送11號信號。 

6.關於term和core

termcore是某些信號默認動作的一種表示。它們之間的區別如下

  1. 默認動作
  • term:這是“terminate”的縮寫,表示信號的默認動作是終止進程。例如,SIGTERM(編號15)信號的默認操作就是請求進程正常退出。這給了進程一個機會來清理並正常終止。
  • core:這個動作表示在終止進程的同時,還會生成一個core dump文件。這個文件包含了進程在內存中的信息,通常用於調試。例如,SIGQUIT(編號3)和SIGSEGV(編號11)等信號的默認動作就是終止進程並生成core dump。
  1. 文件生成
  • 當一個進程因某個信號而term(終止)時,通常不會生成額外的文件。
  • 但當進程因某個信號而core(終止並轉儲核心,這個動作在雲服務器下是被默認關掉的)時,會生成一個core dump文件。這個文件包含了進程在內存中的狀態信息,對於程序員來説是非常有用的調試工具。
  1. 使用場景
  • term動作通常用於請求進程正常退出,比如當你想要優雅地關閉一個服務時。
  • core動作則更常用於在進程崩潰時生成調試信息,幫助程序員找出崩潰的原因。(以gbd為例,先使用gdb打開目標文件,然後將core文件加載進來,就直接可以定位到錯誤在哪一行)                                      
                        

信號_寄存器_17

         

       4 信號示例:

  1. SIGTERM(編號15):默認動作為term,即請求進程正常退出。
  2. SIGQUIT(編號3)和SIGSEGV(編號11):默認動作為core,即終止進程並生成core dump。

        當進程退出時,如果core dump為0就表示沒有異常退出,如果是1就表示異常退出了。

        

信號_自定義_18

eg:關於core dump的演示:

        如果你是雲服務器,那麼就需要手動的將core dump功能打開

信號_自定義_19

#include <iostream>#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>

int Sum(int start, int end)
{

    sleep(100);
    int sum = 0;
    for (int i = start; i <= end; i++)
    {
        sum /= 0; // core
        sum += i;
    }
    return sum;
}

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        sleep(1);
        // child
        Sum(0, 10);

        exit(0);
    }

    // father

    int status = 0;
    pid_t rid = waitpid(id, &status, 0);

    if (rid == id)
    {
        printf("exit code: %d, exit sig: %d, core dump: %d\n", (status >> 8) & 0xFF, status & 0x7F, (status >> 7) & 0x1);
    }

    return 0;
}

 

core dump為1,異常退出了。

信號_自定義_20

  1. 可更改性:
  • 這些默認動作可以通過編程來改變。例如,使用signal函數或sigaction函數來註冊自定義的信號處理函數,從而改變信號的行為。

兩張位圖+一張函數指針數組 == 讓進程識別信號!

        信號保存是指在信號產生後,將其狀態和信息保存起來,以便在適當的時機進行遞達和處理。這一階段確保了信號不會丟失,並且能夠在目標進程準備好時被正確處理。

信號_寄存器_21

 

在這個階段有以下幾種情況:

信號未決:信號產生後,在未被處理之前,處於未決狀態。這意味着信號已經被髮送,但目標進程尚未對其作出響應。操作系統會檢查目標進程的Pending表,確定哪些信號處於未決狀態。(每個進程都有一個Pending位圖,用於記錄哪些信號處於未決狀態。這個位圖由32個比特位組成,分別代表32個不同的信號,如果對應的比特位為1,表示該信號已經產生但尚未處理。)

信號_#include_22

 

信號阻塞:如果目標進程阻塞了某些信號,那麼這些信號會保持在未決狀態,直到進程解除對這些信號的阻塞。(與Pending位圖類似,Block位圖用於記錄哪些信號被進程阻塞。當信號被阻塞時,對應的比特位會被設置為1。)

handler表:是一個函數指針數組,每個下標都是一個信號的執行方式(有31個普通信號,信號的編號就是數組的下標,可以採用信號編號,索引信號處理方法!)如signal函數在進行信號捕捉的時候,其第二個參數就是,提供給handler的

        如果進程選擇阻塞某個信號,操作系統會在block表中設置對應信號的比特位為1。此時,即使信號已經產生(pending表中對應比特位為1),進程也不會立即處理該信號。

        被阻塞的信號將保持在pending表中,直到進程解除對該信號的阻塞(即block表中對應比特位被重置為0)。

        注意,阻塞和忽略是不同的,只要信號被阻塞就不會遞達,而忽略是在遞達之後可選的一種處理動作。

 

信號阻塞和未決的區別

  • 信號阻塞(Blocking):是一個開關動作,指的是阻止信號被處理,但不是阻止信號產生。它使得系統暫時保留信號留待以後發送。阻塞只是暫時的,通常用於防止信號打斷敏感的操作。
  • 信號未決(Pending):是一種狀態,指的是從信號的產生到信號被處理前的這一段時間。信號產生後,如果未被處理且沒有被阻塞,則處於未決狀態,等待被處理。

3.信號的處理(階段三)

1.信號集sigset_t    

信號_#include_23

        在階段二我們瞭解到,每個信號只有一個bit的未決標誌,非0即1,不記錄該信號產生了多少次,阻塞標誌也是這樣表示的。因此,未決和阻塞標誌可以用相同的數據類型sigset_t來存儲,sigset_t稱為信號集,這個類型可以表示每個信號的“有效”或“無效”狀態,在阻塞信號集中“有效”和“無效”的含義是該信號是否被阻塞,而在未決信號集中“有效”和“無效”的含義是該信號是否處於未決狀態。阻塞信號集也叫做當前進程的信號屏蔽字(Signal Mask),這裏的“屏蔽”應該理解為阻塞而不是忽略。(該類型只在Linux系統上有效,是Linux給用户提供的一個用户級的數據類型)

2.信號集操作函數

從使用者的角度是不必關心的,使用者只能調用以下函數來操作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。

3.sigprocmask(操作block表的函數)

調用函數sigprocmask可以讀取或更改進程的信號屏蔽字(阻塞信號集)

#include <signal.h> 
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);//返回值:若成功則為0,若出錯則為-1
  • how:指定對信號屏蔽集的操作方式,有以下幾種方式:
  • SIG_BLOCK:將set所指向的信號集中包含的信號添加到當前的信號屏蔽集中,即信號屏蔽集和set信號集進行邏輯或操作。
  • SIG_UNBLOCK:將set所指向的信號集中包含的信號從當前的信號屏蔽集中刪除,即信號屏蔽集和set信號集的補集進行邏輯與操作。
  • SIG_SETMASK:將set的值設定為新的進程信號屏蔽集,即set直接對信號屏蔽集進行了賦值操作。
  • set:指向一個sigset_t類型的指針,表示需要修改的信號集合。如果只想讀取當前的屏蔽值而不進行修改,可以將其置為NULL
  • oldset:指向一個sigset_t類型的指針,用於存儲修改前的內核阻塞信號集。如果不關心舊的信號屏蔽集,可以傳遞NULL
         如果oset是非空指針,則讀取進程的當前信號屏蔽字通過oset參數傳出。如果set是非空指針,則 更改進程的信號屏蔽字,參數how指示如何更改。如果oset和set都是非空指針,則先將原來的信號 屏蔽字備份到oset裏,然後根據set和how參數更改信號屏蔽字。假設當前的信號屏蔽字為mask,下表説明了how參數的可選值。

信號_#include_24

如果調用sigprocmask解除了對當前若干個未決信號的阻塞,則在sigprocmask返回前,至少將其中一個信號遞達。
 

4.sigpending(檢查pending信號集,獲取當前進程pending位圖)

#include <signal.h>
int sigpending(sigset_t *set);

 

  • 參數:set 是一個指向 sigset_t 類型的指針,用於存儲當前進程的未決信號集合。
  • 返回值:函數調用成功時返回 0,失敗時返回 -1,並設置 errno 以指示錯誤原因。

5.代碼樣例

        基於上面的操作方法我們來做一個實驗:我們把2號信號block對應的位圖置為1,那麼2號信號就會被屏蔽掉了,此時我們給當前進程發送2號信號,但2號信號已經被屏蔽了,2號信號永遠不會遞達,發完之後我們再不斷的獲取當前進程的pending表,我們就能肉眼看見2號信號被pending的效果:驗證

1.第一步實現對2號信號的屏蔽 

int main()
{
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT); // 向block_set信號集中添加SIGINT信號(編號為2)。

    // 1.屏蔽2號信號
    // 1.1 設置進入進程的Block表中
    sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改當前進行的內核block表,完成了對2號信號的屏蔽!

    while (true)
        sleep(1);
}

 

        當我們運行程序的時候,對進程發送2號信號是沒有作用的,因為2號信號此時已經被屏蔽了。

信號_寄存器_25

2.下一步我們打印pending表,之後我們給該進程發送2號信號

void PrintPending(sigset_t &pending)
{
    std::cout << "curr process[" << getpid() << "]pending: ";
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo)) // 如果存在就返回1
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }
    std::cout << "\n";
}

int main()
{
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT); // 向block_set信號集中添加SIGINT信號(編號為2)。

    // 1.屏蔽2號信號
    //  1.1 設置進入進程的Block表中
    sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改當前進行的內核block表,完成了對2號信號的屏蔽!

    while (true)
    {
        // 2.獲取當前進程的pending信號集
        sigset_t pending;
        sigpending(&pending);

        // 3.打印pending信號集
        PrintfPending(pending);

        sleep(1);
    }
}

 

對該進程發送2號信號,pending表對應位置被置為1

信號_寄存器_26

3.解除對2號信號的屏蔽,並且捕捉2號信號,我們來看一下現象:

#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>

void PrintPending(sigset_t &pending)
{
    std::cout << "curr process[" << getpid() << "]pending: ";

    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo)) // 如果存在就返回1
        {
            std::cout << 1;
        }

        else
        {
            std::cout << 0;
        }
    }
    std::cout << "\n";
}

void handler(int signo)
{
    std::cout << signo << " 號信號被遞達!!!" << std::endl;
    std::cout << "-------------------------------" << std::endl;

    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);

    std::cout << "-------------------------------" << std::endl;
}

int main()
{
    // 0. 捕捉2號信號
    signal(2, handler); // 自定義捕捉
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT); // 向block_set信號集中添加SIGINT信號(編號為2)。

    // 1.屏蔽2號信號
    //  1.1 設置進入進程的Block表中
    sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改當前進行的內核block表,完成了對2號信號的屏蔽!

    int cnt = 10;

    while (true)
    {
        // 2.獲取當前進程的pending信號集
        sigset_t pending;
        sigpending(&pending);

        // 3.打印pending信號集
        PrintPending(pending);

        // 4.解除對2號信號的屏蔽
        cnt--;
        if (cnt == 0)
        {

            std::cout << "解除對2號信號的屏蔽!!!" << std::endl;

            // 使用直接重置的方法
            // 我們之前是保存了old_set,老的屏蔽字,直接使用就行了
            sigprocmask(SIG_SETMASK, &old_set, &block_set);
        }

        sleep(1);
    }
}


 

        我們不難發現,解除屏蔽後,信號會立即遞達,pending對應位置由1置為0(這個過程,是在執行handler方法之前完成的,也就是在信號遞達之前,位圖就由1轉為0了)。

信號_自定義_27

6.再談信號的捕捉

關於信號捕捉有三種方式:


signal(2, handler); // 自定義捕捉 signal(2, SIG_IGN); // 忽略一個信號 signal(2, SIG_DFL); // 信號的默認處理動作


 

信號_#include_28

SIG_IGN是一個特殊的宏,用於指示系統忽略該信號。

信號可能不會被立即處理,而是在合適的時候處理,那麼合適的時候是什麼時候呢?

先給結論:從進程的內核態返回到用户態的時候,進行處理。

        簡單來説,執行自己的代碼,訪問自己的數據,這就叫做用户態。

        當我們進入系統調用時,我們以操作系統的身份來執行時,此時就進入了內核態,操作系統把我們的底層工作做完,做完這些工作後返回到我們的調用處,繼續執行下面的代碼,但是操作系統,由內核態返回到用户態時,在返回的這個時候信號的檢測和處理

  • 這是因為管理信號的數據結構(也就是我們的三張表)都位於進程的控制塊(PCB)內,而PCB屬於內核數據。因此,信號的檢測和處理必須在內核態下進行。
  • 當進程從內核態返回用户態時,內核會檢查是否有待處理的信號,並根據信號的處理方式(默認處理、忽略或自定義處理)進行相應的操作。但操作系統不能直接轉過去執行用户提供的handler方法,這是出於對安全性的考慮。

信號_寄存器_29

簡化一下:

信號_寄存器_30

7.對內核態和用户態的進一步理解

1.再談地址空間:

        在操作系統中,虛擬內存地址空間被劃分為內核態和用户態兩個主要部分。這種劃分是出於安全性和管理便利性的考慮。       

        在32位系統上,內核態通常佔據虛擬地址空間的高地址部分,如從0xC000 0000到0xFFFF FFFF(共1GB),用户態則是後面的3GB。在64位系統上,這個範圍要大得多,但基本概念相似。

        無論進程如何的切換,我們總能找到OS!我們訪問OS,其實還是在我們的地址空間中進行的!和我們訪問庫函數沒區別!OS不相信任何人,用户訪問內核態的地址空間時,要收到一定的約束,只能通過系統調用訪問!

 

2.談談鍵盤輸入數據的過程:     

        當我們在鍵盤上輸入命令或數據時,鍵盤上的電路會檢測到按鍵的按下或釋放,並生成相應的電信號。這些電信號隨後被轉化為中斷信號,通過硬件連線(如總線)傳遞到CPU的中斷控制器。中斷控制器根據信號的優先級和當前CPU的狀態,決定是否向CPU發送中斷請求。

        CPU通過中斷處理機制來響應來自硬件設備的中斷請求。在保護模式下,CPU會維護一箇中斷描述符表(IDT),該表包含了所有可能的中斷向量及其對應的中斷服務例程的地址。當中斷髮生時,CPU會根據中斷向量在IDT中找到對應的中斷服務例程的地址,並跳轉到該地址執行中斷處理程序。

        一旦CPU接收到來自鍵盤的中斷請求,它會暫停當前正在執行的程序(保存當前的狀態,如程序計數器、寄存器值等),然後跳轉到特定的中斷處理程序或中斷服務例程來響應這個中斷。中斷處理程序會執行必要的操作來處理該中斷,這可能包括讀取鍵盤的狀態、更新數據、發送響應等。處理完中斷後,CPU會恢復之前保存的狀態,並繼續執行原來的程序。

 

3.時鐘中斷

定義:Linux時鐘中斷是指在Linux操作系統中,系統定時器週期性地觸發中斷,這個中斷被稱為時鐘中斷。時鐘中斷源於硬件定時器,通常由計算機的主板芯片或處理器芯片提供,通過定時器計數器來實現定時中斷功能。

功能:

  1. 維護系統時間:每當一個時鐘中斷髮生時,內核會更新系統時間的計數值。這個計數值可以是自世界時間開始的毫秒數,也可以是自系統啓動以來的滴答數(tick)。通過定時更新系統時間,系統可以保持時間的準確性,為用户提供可靠的時間信息。
  2. 任務調度:在多任務操作系統中,內核需要決定哪個進程將獲得CPU的控制權。時鐘中斷提供了一個計時器,每當中斷髮生時,內核會檢查當前運行的進程是否到達了它應該運行的時間片。如果一個進程的時間片用完了,內核就會重新選擇下一個要運行的進程,並切換上下文,將控制權交給新的進程。這樣保證了系統中進程的公平調度,提高了系統的整體性能。
  3. 計算進程執行時間:每當一個進程或線程被搶佔,切換到另一個進程或線程時,時鐘中斷記錄下了搶佔發生的時間。通過記錄不同進程和線程的執行時間,可以分析其調度情況,瞭解系統中進程的運行情況,為性能優化提供依據。

4.OS時如何正常運行的:

        操作系統的本質就是一個死循環+時鐘中斷(不斷調度系統的任務):操作系統中的進程調度依賴於時鐘來分配處理器時間。時鐘中斷定期觸發,使操作系統能夠檢查當前進程的運行狀態,並根據需要進行進程切換或調整進程的優先級。時鐘通過產生時鐘中斷來實現進程的時間片管理。每個進程被分配一個固定的時間片來執行,當時鍾中斷髮生時,如果當前進程的時間片已經用完,則操作系統會將其掛起,並選擇另一個進程來執行。這種方式確保了每個進程都有機會獲得處理器資源,從而提高了系統的整體性能。因此,時鐘通過提供穩定的時間基準、實現進程調度、處理中斷以及提高系統穩定性與可靠性等方面來推動操作系統的運行。它是操作系統中不可或缺的一部分,對於保證系統的正常運行和任務的有序執行具有重要意義。

 

5.如何理解系統調用:

        我們只要找到特定數組下標的方法,就能執行系統調用了。

信號_#include_31

 

        用户程序在代碼中調用系統調用時,會執行一個特殊的中斷指令,如int 0x80(在x86架構中)或syscall指令。在執行中斷指令前,將系統調用號放入特定的寄存器中(如eax寄存器)。CPU暫停當前執行的代碼,根據中斷的中斷號,在中斷向量表中找到對應的中斷處理程序(如Linux中的system_call),並調用它。中斷處理程序會檢查系統調用號的有效性,並從系統調用表中找到相應的系統調用函數進行調用。

信號_#include_32

 


信號_自定義_33

  • signum:指定要設置或獲取處理程序的信號編號。可以指定SIGKILL和SIGSTOP以外的所有信號。
  • act:指向sigaction結構體的指針,用於指定新的信號處理方式。如果此參數非空,則根據此參數修改信號的處理動作。
  • oldact:如果非空,則通過此參數傳出該信號原來的處理動作。(如果你想恢復以前的方式,此參數就是保存之前的操作方式)

sigaction結構體

struct sigaction
{
    void (*sa_handler)(int); // 指向信號處理函數的指針,接收信號編號作為參數
    void (*sa_sigaction)(int, siginfo_t *, void *); // 另一個信號處理函數指針,支持更豐富的信號信息
    sigset_t sa_mask; // 設置在處理該信號時暫時屏蔽的信號集
    int sa_flags; // 指定信號處理的其他相關操作
    void (*sa_restorer)(void); // 已廢棄,不用關心
};


        我們暫時只考慮第一個和第三個參數。

一個示例:

#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signum)
{
    std::cout << "get a sig" << signum << std::endl;
    exit(1);
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;

    sigaction(2, &act, &oact);

    while (true)
    {
        std::cout << "I am a process,pid: " << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

 

信號_寄存器_34

細節:當前如果正在對2號信號進行處理,默認2號信號會被自動屏蔽,對2號信號處理完成的時候,會自動解除對2號信號的屏蔽。為什麼?這是因為,操作系統不允許同一個信號被連續處理。

        此段代碼,用於觀測,pending表的變化。

void Print(sigset_t &pending)
{
    for (int sig = 31; sig > 0; sig--)
    {
        if (sigismember(&pending, sig))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }

    std::cout << std::endl;
}

void handler(int signum)
{
    std::cout << "get a sig" << signum << std::endl;
    while (true)
    {
        sigset_t pending;
        sigpending(&pending);
        Print(pending);
        sleep(1);
    }

    exit(1);
}

        我們發現,當我們使用兩次CTRL後,pending由0置為1了。如果2號信號處理完畢後,會自動解除對2號信號的屏蔽

信號_#include_35

        如果你還想在處理2號信號(OS對2號自動屏蔽0),同時,對其它型號也進行屏蔽,你可以設置sa_mask變量。

下面是一段示例:

void Print(sigset_t &pending)
{
    for (int sig = 31; sig > 0; sig--)
    {
        if (sigismember(&pending, sig))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }

    std::cout << std::endl;
}

void handler(int signum)
{
    std::cout << "get a sig" << signum << std::endl;

    while (true)
    {
        sigset_t pending;

        sigpending(&pending);

        Print(pending);

        sleep(1);
    }

    exit(1);
}

int main()
{

    struct sigaction act, oact;
    act.sa_handler = handler;

    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 3);

    act.sa_flags = 0;
    sigaction(2, &act, &oact);

    while (true)
    {
        std::cout << "I am a process,pid: " << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}


 

我對三號信號也同時做了屏蔽,此時發送三號信號,pending值也是由0置為1的。

信號_自定義_36

能否將所有的信號給屏蔽掉?

        當然是不可以的,有些信號默認是不可以被屏蔽的(例如9號信號)


5使用信號可能導致的問題


1.可重入函數 

信號_自定義_37

  • main函數調用insert函數向一個鏈表head中插入節點node1,插入操作分為兩步,剛做完第一步的 時候,因為硬件中斷使進程切換到內核,再次回用户態之前檢查到有信號待處理,於是切換 到sighandler函數,sighandler也調用insert函數向同一個鏈表head中插入節點node2,插入操作的 兩步都做完之後從sighandler返回內核態,再次回到用户態就從main函數調用的insert函數中繼續 往下執行,先前做第一步之後被打斷,現在繼續做完第二步。結果是,main函數和sighandler先後 向鏈表中插入兩個節點,而最後只有一個節點真正插入鏈表中了。
  • 像上例這樣,insert函數被不同的控制流程調用,有可能在第一次調用還沒返回時就再次進入該函數,這稱為重入,insert函數訪問一個全局鏈表,有可能因為重入而造成錯亂,像這樣的函數稱為 不可重入函數,反之,如果一個函數只訪問自己的局部變量或參數,則稱為可重入(Reentrant) 函數。想一下,為什麼兩個不同的控制流程調用同一個函數,訪問它的同一個局部變量或參數就不會造成錯亂?

如果一個函數符合以下條件之一則是不可重入的:(大部分函數是不可被重入的,可重入或者不可重入,描述的是函數的特徵)

  • 調用了malloc或free,因為malloc也是用全局鏈表來管理堆的。
  • 調用了標準I/O庫函數。標準I/O庫的很多實現都以不可重入的方式使用全局數據結構。

 


 2.使用信號對全局變量進行操作出現的問題(volatile)

int gflag = 0;
void changedata(int signo)
{
    std::cout << "get a signo:" << signo << ", change gflag 0->1" << std::endl;
    gflag = 1;
}

int main() // 沒有任何代碼對gflag進行修改!!!
{
    signal(2, changedata);
    while (!gflag)
        ; // while不要其他代碼

    std::cout << "process quit normal" << std::endl;
}

 

        我們使用信號捕捉了2號信號,當我們執行了2號信號後,全局變量gflag就會被更改為1,那麼main函數中的while就會停止執行,因為cpu在執行while循環的時候,實時的從內存中取gflag來進行比較,但在這裏我們對編譯進行優化,這會讓cpu保存之前在內存中取的gflag的值,只在內存中取最開始的一次值,這就會導致gflag的變化無法被在while中實時更新,導致while循環無法結束:g++ -o test test.cc -O1(O1,是基礎優化)

信號_自定義_38

        如果想讓gflag在此優化下生效,就要使用volatile(volatile關鍵字可以確保變量的可見性(即確保變量每次訪問時都直接從內存中讀取)關鍵字調整後,程序成功退出:

volatile int gflag = 0;

信號_自定義_39


3.SIGCHLD信號

子進程退出時,不是靜悄悄的退出的,會給父進程發送信號--SIGCHLD信號。
下面是一段示例:

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

void notice(int signo)
{
    std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
    while (true)
    {
        pid_t rid = waitpid(-1, nullptr, WNOHANG); // 阻塞啦!!--> 非阻塞方式
        if (rid > 0)
        {
            std::cout << "wait child success, rid: " << rid << std::endl;
        }
        else if (rid < 0)
        {
            std::cout << "wait child success done " << std::endl;
            break;
        }
        else
        {
            std::cout << "wait child success done " << std::endl;
            break;
        }
    }
}

void DoOtherThing()
{
    std::cout << "DoOtherThing~" << std::endl;
}

int main()
{

    signal(SIGCHLD, notice);

    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();

        if (id == 0)
        {
            std::cout << "I am child process, pid: " << getpid() << std::endl;
            sleep(3);
            exit(1);
        }
    }

    // father
    while (true)
    {
        DoOtherThing();
        sleep(1);
    }

    return 0;
}

 

        這段代碼創建了多個子進程,並在子進程結束時通過SIGCHLD信號進行處理。

        當SIGCHLD信號被捕獲時,notice函數會被調用。這個函數會進入一個無限循環,嘗試使用waitpid以非阻塞方式(WNOHANG)等待任何已終止的子進程。這是合理的,因為它允許父進程在子進程終止時及時回收資源,同時不阻塞父進程的其他操作。

 signal(SIGCHLD, SIG_IGN); 這行代碼的作用是設置信號處理函數,以便當子進程結束時(即發送SIGCHLD信號給父進程時),父進程忽略這個信號。通常,當子進程結束時,父進程需要處理這個信號以回收子進程的資源,但在這裏,通過將其設置為SIG_IGN,父進程選擇忽略這個信號,這意味着子進程的資源將由操作系統自動回收(這通常被稱為“殭屍進程”的避免,儘管在這種情況下,由於子進程正常退出並設置了退出碼,它實際上不會成為殭屍進程,因為操作系統會注意到並清理它)。(如果你不關心子進程的退出信息就可以使用這種方法,否則還是要進行等待)

int main()
{
    signal(SIGCHLD, SIG_IGN); // 收到設置對SIGCHLD進行忽略即可
    pid_t id = fork();

    if (id == 0)
    {
        int cnt = 5;
        while (cnt)
        {
            std::cout << "child running" << std::endl;
            cnt--;
            sleep(1);
        }
        exit(1);
    }

    while (true)
    {
        std::cout << "father running" << std::endl;
        sleep(1);
    }
}