從 0 開始學習 Linux 系列之「21.信號 Signal」

信號(Signal)簡介

軟中斷信號 Signal,簡稱信號,用來通知進程發生了異步事件,進程之間可以互相通過系統調用 kill 等函數來發送軟中斷信號。內核也可以因爲內部事件而給進程發送信號,通知進程發生了某個事件,但是要注意信號只是用來通知進程發生了什麼事件,並不給該進程傳遞任何數據,例如終端用戶鍵入中斷鍵,會通過信號機制停止當前程序。

Linux 中每個信號都有一個以 SIG 開頭的名字,例如 (終止信號)SIGINT,退出信號(SIGABRT),信號定義在 bits/signum.h 頭文件中,每個信號都被定義成整數常量。

一些重要的信號概念

信號是許多重要的應用程序都需要使用的技術,有些非常重要的概念我們必須瞭解。

信號處理的 3 個過程

信號處理有 3 個過程:
1. 發送信號:有發送信號的函數
2. 接收信號:有接受信號的函數
3. 處理信號:有處理信號的函數

信號處理的 3 種方式

在某個信號出現時,可以告訴內核按照下面 3 種方式之一來處理:
1. 忽略此信號:大多數信號都可以忽略,但是 SIGKILLSIGSTOP 不能忽略
2. 捕捉信號:通知內核在某種信號發生時,調用用戶的函數來處理事件
3. 執行系統默認動作:大多數信號的系統默認動作是終止改進程,使用 man 7 signal 查看默認動作

常用信號

信號有很多種,可以使用 kill - l 列出系統支持的信號:

kill -l
# 結果
1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL      10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX    

這麼多的信號也不可能都記得很清楚,只需要知道常用的即可,常用的信號有下面這些:

  1. SIGHUP :終端結束信號
  2. SIGINT :鍵盤中斷信號(Ctrl - C)
  3. SIGQUIT:鍵盤退出信號(Ctrl - \)
  4. SIGPIPE:浮點異常信號
  5. SIGKILL:用來結束進程的信號
  6. SIGALRM:定時器信號
  7. SIGTERM:kill 命令發出的信號
  8. SIGCHLD:標識子進程結束的信號
  9. SIGSTOP:停止執行信號(Ctrl - Z)

信號分類

信號也有 2 種分類:不可靠信號,可靠信號。

1. 不可靠信號

Linux 繼承了早期 UNIX 的一些信號,這些信號有些缺陷:在發送給進程的時候可能會丟失,也稱爲不可靠信號,其中信號值小於 34) SIGRTMIN 都是不可靠信號

2. 可靠信號

後來 Linux 改進了信號機制,增加了一些可靠信號:支持排隊,信號不會丟失34) SIGRTMIN - 64) SIGRTMIX 爲可靠信號。

信號集合(signal set)

可以用信號集(Signal Set)來表示多個信號,例如可以用來告訴內核不允許發生該信號集中的信號。用 sigset_t 可以定義一個信號集,之後便可以用信號集操作函數來增加,刪除特定的信號。

信號操作一:發送 Signal

發送信號多種方式,例如向進程本身發送信號,向其他進程發送信號,發送特殊信號,我們來一一學習。

向自身發送信號

調用 raise 來向當前進程或線程發送一個信號:

#include <signal.h>

/*
 * sig:信號編號
 * return:成功返回 0,失敗返回非 0
 */
int raise(int sig);

我們來向當前進程發送 SIGKILL 或者 SIGSTOP 信號來結束它:

// test_raise.c

#include <stdio.h>
#include <signal.h>

int main() {
    raise(SIGKILL);
    //raise(SIGSTOP);
    printf("process run ok\n");
    return 0;
}

編譯運行可以看到一啓動就結束了:

# 發送 SIGKILL
Killed

# 發送 SIGSTOP
[1]+  Stopped                 ./raise

向別的進程發送信號

可以調用 kill 來向一個指定進程發送指定信號:

#include <sys/types.h>
#include <signal.h>

/*
 * pid:進程 PID
 * sig:信號編號
 * return:成功返回 0,失敗返回 -1
 */
int kill(pid_t pid, int sig);

我們來編寫一個死循環程序,然後 kill 掉它:

// test_loop.c

#include <stdio.h>
#include <unistd.h>

int main() {
    int x = 0;
    while (1) {
        x++;
        sleep(1);
    }

    return 0;
}

編譯運行它,生成可執行文件 loop

gcc test_loop.c -o loop

# 運行
./loop

然後查看 loop 進程 PID:

ps -aux | grep loop

# 我的輸出
orange   18051  0.0  0.0   4212   688 pts/7    S+   20:52   0:00 ./loop

查到 loop 進程的 PID = 18051,下面來編寫 kill 程序:

// kill_loop.c

#include <stdio.h>
#include <signal.h>
#include <sys/types.h>

int main() {
    kill(18051, SIGKILL);
    printf("Has kill\n");
    return 0;
}

編譯運行,即可看到 loop 被幹掉了:

./loop

Killed

kill 使用起來也比較簡單,再來看一個特殊的發送信號函數 alarm。

發送鬧鐘信號 alarm

可以使用 alarm 來定時 seconds 發送一個 SIGALRM 信號,該信號的默認動作是終止進程:

#include <unistd.h>

/*
 * seconds:定時時間,如果爲 0 則取消所有綁定的定時器
 * return:返回鬧鐘的剩餘時間,如果沒有設置返回 0
 */
unsigned int alarm(unsigned int seconds);

我們來定時 3 s 然後終止當前進程:

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

int main() {    
    alarm(3);

    while (1);

    printf("main exit\n");
    return 0;
}

編譯運行,可以發現 3 s 後進程被終止:

./test_alarm

# 3 s 之後被終止
Alarm clock

信號操作二:接收(註冊)信號

Linux 給我們提供下面這個 signal 函數來接收(註冊)一個信號:

#include <signal.h>

typedef void (*sighandler_t)(int);

/*
 * signum:要註冊的信號編號
 * handler:信號的處理函數
 */
sighandler_t signal(int signum, sighandler_t handler);

這個函數的第二個參數和返回值都是 void (*)(int) 類型的函數指針,需要特別注意,目前不推薦使用這個函數了!目前推薦使用 sigaction 來註冊,後面有介紹。

信號操作三:處理信號

處理信號又可以進一步分爲忽略信號,默認處理,自定義處理

屏蔽信號

如果在接收一個信號時設置 handlerSIG_IGN 則忽略這個信號,例如下面的代碼忽略 SIGQUIT 信號:

signal(SIGQUIT, SIG_IGN);

缺省處理信號

通過在接受信號時設置 handlerSIG_DFL 缺省處理這個信號,信號的缺省處理方式取決於這個信號,可以查看 man 7 signal 中對信號默認處理方式的介紹。下面的代碼在接收 SIGQUIT 信號時,採用系統的缺省處理方式:退出

signal(SIGQUIT, SIG_DFL);

老式信號處理 signal

通過指定我們自己編寫的 void (*sighandler_t)(int) 類型的函數來自己處理一個信號:

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

// 信號處理函數
void sig_handler(int sig_no) {
    if (SIGINT == sig_no)
        printf("\nGet (Ctrl - C)SIGINT\n");
    else if (SIGQUIT == sig_no)
        printf("\nGet (Ctrl - \\)SIGQUIT\n");
    else
        ;// do nothing...
}

int main() {
    printf("wait for signal...\n");
    // Ctrl - C
    signal(SIGINT, sig_handler);

    // Ctrl - '\'
    signal(SIGQUIT, sig_handler);

    pause();
    return 0;
}

編譯運行,當鍵入 Ctrl - CCtrl - \ 時可以看到打印的提示信息:

# 測試 SIGINT
wait for signal...
^C
Get (Ctrl - C)SIGINT

# 測試 SIGQUIT
wait for signal...
^\
Get (Ctrl - \)SIGQUIT

重點:使用 sigaction 處理信號

sigaction 函數檢查或修改與指定信號相關聯的處理動作,這個函數取代了早期 UNIX 使用的 signal 函數,主要是因爲早期的 UNIX 實現會在接收到一個信號後重置信號處理函數,現在推薦使用 sigaction 來進行信號處理,來看看它的定義:

#include <signal.h>

/*
 * signum:信號編號
 * act:如果非 NULL,則信號 signum 被安裝到 act 中
 * oldact:如果非 NULL,則舊的信號被保存到 oldact 中
 * return:成功返回 0,失敗返回 -1,並設置 erron
 */
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

// 用 sigaction 結構取代了單一的 sighandler_t 函數指針
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 <stdio.h> 
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>

void sig_handler(int sig_no) {
    if (SIGINT == sig_no)
        printf("\nGet (Ctrl - C)SIGINT\n");
    else if (SIGQUIT == sig_no)
        printf("\nGet (Ctrl - \\)SIGQUIT\n");
    else
        ;// do nothing...
}

int main() {
    printf("wait for signal...\n");

    struct sigaction act;

    // 初始化信號結構
    memset(&act, 0, sizeof(act));
    // 設置信號處理函數
    act.sa_handler = sig_handler;

    // 註冊 SIGINT 信號
    if (sigaction(SIGINT, &act, NULL) < 0) {
        perror("sigaction");
        exit(1);
    }

    // 註冊 SIGQUIT 信號
    if (sigaction(SIGQUIT, &act, NULL) < 0) {
        perror("sigaction");
        exit(1);
    }

    pause();
    return 0;
}

編譯運行之後的效果跟使用 signal 是一樣的。注意:在信號處理程序中要保證調用的函數都是可重入函數,即信號安全函數。什麼是可重入函數?

可重入函數

一個可重入函數簡單來說就是可以被中斷的函數,可以在這個函數執行的任何時刻中斷它讓 CPU 去執行另外一段代碼,而返回時不會出現任何錯誤;而不可重入的函數會由於使用了一些系統資源,比如全局變量區,中斷向量表等,所以它如果被中斷,返回可能會出現問題。

例如在信號處理函數中要注意:
1. 不要使用帶有全局靜態數據結構的函數
2. 不要調用 malloc 和 free
3. 不要調用標準 IO 函數

最後再來了解下信號在內核中的基本實現原理,多學點沒有壞處,學習技術瞭解點底層的原理可以加深理解,但不需要多精通。

拓展:Signal 在內核中的實現

Linux 的信號實際上是一個軟件中斷,內核中的原理還是比較複雜的,這裏只是對信號的一個大體過程的介紹,幫助你更好的理解,而不是非要掌握內核中的實現原理。先來看看內核中信號的數據結構。

signal_struct

信號在內核中用 signal_struct 結構來表示:

// Linux 3.4: include/linux/sched.h
struct signal_struct { ... };

因爲我們的信號是發送給進程的,所以進程的結構體中自然就包含了這個信號結構:

// Linux 3.4: include/linux/sched.h
struct task_struct {
    ...
    /* signal handlers */
    struct signal_struct *signal;
    ...
}

發送信號

一個 Send 進程發送信號給另外一個接受信號進程 Rec簡要過程是:內核將要設置的發送的信號 sig 放到一個分配的信號隊列 sigqueue 中,然後將這個隊列加到要 Rec 進程的信號集 sigpending 鏈表中。之後在 Rec 進程觸發下面 2 個狀態時,內核檢查信號併發送給 Rec 進程,然後 Rec 進程處理信號:
1. 中斷返回:進程被系統中斷後,系統也會檢查信號
2. 系統調用返回:進程在系統調用後從內核態返回用戶態時要檢查信號

跟蹤 kill 函數

我們以 kill 爲例來跟蹤 Linux 3.4 內核是如何發送一個信號的(不同的內核可能會有差異),我這裏總體分爲 12 個步驟:

sendsignal

發送信號的整個過程還是有點複雜的,但也都是大體的執行過程:
1. 上層調用 kill 等發送信號的函數,並傳遞信號編號 sig,和 Rec 進程的 pid
2. 之後調用到內核中的 do_tkill,仍然帶有 Rec 進程的 pid
3. 繼續調用 do_send_specific(pid)
4. 繼續調用 do_send_sig_info(pid)
5. 在do_send_sig_info(pid) 函數中根據 pid查找 Rec 進程的進程結構體task_struct p = find_task_by_vpid(pid);
6. 繼續調用 do_send_sig_info(p),注意這時傳遞的參數之一是進程結構體,不再是 pid 了
7. 繼續調用 send_signal(p)
8. 繼續調用 __send_signal(t = p),這也是最後一個函數了,這裏將 p 改名爲了 t,然後在這個函數中進行下面的步驟 9 - 12
9. 得到 Rec 進程的信號集合鏈表 struct sigpending *pending = &t->pending
10. 爲要發送的信號 sig 分配信號隊列 struct sigqueue *q = __sigqueue_alloc(sig)
11. 將信號隊列加到 Rec 進程的信號集合鏈表中 list_add_tail(&q->list, &pending->list);
12. 初始化信號隊列

這些發送信號的過程還有很多細節沒有介紹,建議你實際跟蹤 kernel/signal.c,加深理解。

註冊信號

這裏分析 sigaction 函數的註冊一個信號的過程:glibc 中的函數進行系統調用,將轉換後的信號傳遞給內核,然後內核調用 do_sigaction 來將該信號從當前進程的信號掩碼集 mask 中刪除。因爲mask 中存儲的是不允許當前進程發生的信號,所以刪除在 mask 中的指定信號,就代表允許當前進程接收這個指定的信號,從而實現註冊該信號。

這是 8 個過程,我分析的是 glibc 2.21Linux 3.4 版本的源碼:

regsignal

  1. 調用 sigaction(sig) 註冊信號
  2. 調用底層 glibc 庫中的 __libc_sigaction(sig) 函數
  3. 由於上層信號和內核信號有些不同,所以內核將信號轉換成內核 kernel_sigaction,但是基本的成員是差不多的
  4. 調用系統調用,陷入內核
  5. 調用內核的 do_sigaction 函數來註冊信號
  6. 因爲是註冊到當前進程,所以先得到當前進程的結構體 t
  7. 將要註冊的信號 sig 加到當前進程的 mask 中
  8. 然後將 mask 從當前進程的信號集鏈表中刪除,即刪除不允許接收的信號 sig,從而允許接收(註冊)信號 sig

處理信號

信號處理的 2 個時刻前面已經介紹了:系統調用返回和中斷返回。因爲進程本身存儲了已經註冊的信號的相關信息,包括最終要調用的信號處理函數,所以處理過程就是回調這個信號處理函數,裏面的操作邏輯是我們自己定義的操作,這樣的函數也被稱爲「回調函數」。

結語

信號的基本原理和操作就介紹到這裏,學習信號必須要理解信號的本質:「信號就是一個軟中斷」,另外要清楚信號的註冊方法,在掌握基本的使用方法後再去了解信號在內核中的實現機制會幫你更好的理解信號,這裏因爲能力有限不能深入的分析內核的信號機制,希望你在學習的時候能認真實踐,也歡迎一起交流。

感謝你的閱讀,我們下次再見 :)

本文原創發佈於微信公衆號「cdeveloper」,編程、職場,人生,關注並回復關鍵字「linux」、「機器學習」等獲取免費學習資料。


發佈了59 篇原創文章 · 獲贊 36 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章