Linux信號處理

Linux 軟件 數據結構 linux內核 2019-06-24

什麼是信號

信號本質上是在軟件層次上對中斷機制的一種模擬,其主要有以下幾種來源:

  • 程序錯誤:除零,非法內存訪問等。
  • 外部信號:終端 Ctrl-C 產生 SGINT 信號,定時器到期產生SIGALRM等。
  • 顯式請求:kill函數允許進程發送任何信號給其他進程或進程組。

目前 Linux 支持64種信號。信號分為非實時信號(不可靠信號)和實時信號(可靠信號)兩種類型,對應於 Linux 的信號值為 1-31 和 34-64。

信號是異步的,一個進程不必通過任何操作來等待信號的到達。事實上,進程也不知道信號到底什麼時候到達。一般來說,我們只需要在進程中設置信號相應的處理函數,當有信號到達的時候,由系統異步觸發相應的處理函數即可。如下代碼:

#include <signal.h>
#include <unistd.h>
#include <stdio.h>
void sigcb(int signo) {
switch (signo) {
case SIGHUP:
printf("Get a signal -- SIGHUP\n");
break;
case SIGINT:
printf("Get a signal -- SIGINT\n");
break;
case SIGQUIT:
printf("Get a signal -- SIGQUIT\n");
break;
}
return;
}
int main() {
signal(SIGHUP, sigcb);
signal(SIGINT, sigcb);
signal(SIGQUIT, sigcb);
for (;;) {
sleep(1);
}
}

運行程序後,當我們按下 Ctrl+C 後,屏幕上將會打印 Get a signal -- SIGINT。當然我們可以使用 kill -s SIGINT pid命令來發送一個信號給進程,屏幕同樣打印出 Get a signal -- SIGINT 的信息。

信號實現原理

接下來我們分析一下Linux對信號處理機制的實現原理。

信號處理相關的數據結構

在進程管理結構 task_struct 中有幾個與信號處理相關的字段,如下:

struct task_struct {
...
int sigpending;
...
struct signal_struct *sig;
sigset_t blocked;
struct sigpending pending;
...
}

成員 sigpending 表示進程是否有信號需要處理(1表示有,0表示沒有)。成員 blocked 表示被屏蔽的信息,每個位代表一個被屏蔽的信號。成員 sig 表示信號相應的處理方法,其類型是 struct signal_struct,定義如下:

#define _NSIG 64
struct signal_struct {
atomic_t count;
struct k_sigaction action[_NSIG];
spinlock_t siglock;
};
typedef void (*__sighandler_t)(int);
struct sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void);
sigset_t sa_mask;
};
struct k_sigaction {
struct sigaction sa;
};

可以看出,struct signal_struct 是個比較複雜的結構,其 action 成員是個 struct k_sigaction 結構的數組,數組中的每個成員代表著相應信號的處理信息,而 struct k_sigaction 結構其實是 struct sigaction 的簡單封裝。

我們再來看看 struct sigaction 這個結構,其中 sa_handler 成員是類型為 __sighandler_t 的函數指針,代表著信號處理的方法。

最後我們來看看 struct task_struct 結構的 pending 成員,其類型為 struct sigpending,存儲著進程接收到的信號隊列,struct sigpending 的定義如下:

struct sigqueue {
struct sigqueue *next;
siginfo_t info;
};
struct sigpending {
struct sigqueue *head, **tail;
sigset_t signal;
};

當進程接收到一個信號時,就需要把接收到的信號添加 pending 這個隊列中。

發送信號

可以通過 kill() 系統調用發送一個信號給指定的進程,其原型如下:

int kill(pid_t pid, int sig);

參數 pid 指定要接收信號進程的ID,而參數 sig 是要發送的信號。kill() 系統調用最終會進入內核態,並且調用內核函數 sys_kill(),代碼如下:

asmlinkage long
sys_kill(int pid, int sig)
{
struct siginfo info;
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info.si_pid = current->pid;
info.si_uid = current->uid;
return kill_something_info(sig, &info, pid);
}

sys_kill() 的代碼比較簡單,首先初始化 info 變量的成員,接著調用 kill_something_info() 函數來處理髮送信號的操作。kill_something_info() 函數的代碼如下:

static int kill_something_info(int sig, struct siginfo *info, int pid)
{
if (!pid) {
return kill_pg_info(sig, info, current->pgrp);
} else if (pid == -1) {
int retval = 0, count = 0;
struct task_struct * p;
read_lock(&tasklist_lock);
for_each_task(p) {
if (p->pid > 1 && p != current) {
int err = send_sig_info(sig, info, p);
++count;
if (err != -EPERM)
retval = err;
}
}
read_unlock(&tasklist_lock);
return count ? retval : -ESRCH;
} else if (pid < 0) {
return kill_pg_info(sig, info, -pid);
} else {
return kill_proc_info(sig, info, pid);
}
}

kill_something_info() 函數根據傳入pid 的不同來進行不同的操作,有如下4中可能:

  • pid 等於0時,表示信號將送往所有與調用 kill() 的那個進程屬同一個使用組的進程。
  • pid 大於零時,pid 是信號要送往的進程ID。
  • pid 等於-1時,信號將送往調用進程有權給其發送信號的所有進程,除了進程1(init)。
  • pid 小於-1時,信號將送往以-pid為組標識的進程。

我們這裡只分析 pid 大於0的情況,從上面的代碼可以知道,當 pid 大於0時,會調用 kill_proc_info() 函數來處理信號發送操作,其代碼如下:

inline int
kill_proc_info(int sig, struct siginfo *info, pid_t pid)
{
int error;
struct task_struct *p;
read_lock(&tasklist_lock);
p = find_task_by_pid(pid);
error = -ESRCH;
if (p)
error = send_sig_info(sig, info, p);
read_unlock(&tasklist_lock);
return error;
}

kill_proc_info() 首先通過調用 find_task_by_pid() 函數來獲得 pid 對應的進程管理結構,然後通過 send_sig_info()函數來發送信號給此進程,send_sig_info() 函數代碼如下:

int
send_sig_info(int sig, struct siginfo *info, struct task_struct *t)
{
unsigned long flags;
int ret;
ret = -EINVAL;
if (sig < 0 || sig > _NSIG)
goto out_nolock;
ret = -EPERM;
if (bad_signal(sig, info, t))
goto out_nolock;
ret = 0;
if (!sig || !t->sig)
goto out_nolock;
spin_lock_irqsave(&t->sigmask_lock, flags);
handle_stop_signal(sig, t);
if (ignored_signal(sig, t))
goto out;
if (sig < SIGRTMIN && sigismember(&t->pending.signal, sig))
goto out;
ret = deliver_signal(sig, info, t);
out:
spin_unlock_irqrestore(&t->sigmask_lock, flags);
if ((t->state & TASK_INTERRUPTIBLE) && signal_pending(t))
wake_up_process(t);
out_nolock:
return ret;
}

send_sig_info() 首先調用 bad_signal() 函數來檢查是否有權發送信號給進程,然後調用 ignored_signal() 函數來檢查信號是否被忽略,接著調用 deliver_signal() 函數開始發送信號,最後如果進程是睡眠狀態就喚醒進程。我們接著來分析 deliver_signal() 函數:

static int deliver_signal(int sig, struct siginfo *info, struct task_struct *t)
{
int retval = send_signal(sig, info, &t->pending);
if (!retval && !sigismember(&t->blocked, sig))
signal_wake_up(t);
return retval;
}

deliver_signal() 首先調用 send_signal() 函數進行信號的發送,然後調用 signal_wake_up() 函數喚醒進程。我們來分析一下最重要的函數 send_signal():

static int send_signal(int sig, struct siginfo *info, struct sigpending *signals)
{
struct sigqueue * q = NULL;
if (atomic_read(&nr_queued_signals) < max_queued_signals) {
q = kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
}
if (q) {
atomic_inc(&nr_queued_signals);
q->next = NULL;
*signals->tail = q;
signals->tail = &q->next;
switch ((unsigned long) info) {
case 0:
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_USER;
q->info.si_pid = current->pid;
q->info.si_uid = current->uid;
break;
case 1:
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_KERNEL;
q->info.si_pid = 0;
q->info.si_uid = 0;
break;
default:
copy_siginfo(&q->info, info);
break;
}
} else if (sig >= SIGRTMIN && info && (unsigned long)info != 1
&& info->si_code != SI_USER) {
return -EAGAIN;
}
sigaddset(&signals->signal, sig);
return 0;
}

send_signal() 函數雖然比較長,但邏輯還是比較簡單的。在 信號處理相關的數據結構 一節我們介紹過進程管理結構 task_struct 有個 pending 的成員變量,其用於保存接收到的信號隊列。send_signal() 函數的第三個參數就是進程管理結構的 pending 成員變量。

send_signal() 首先調用 kmem_cache_alloc() 函數來申請一個類型為 struct sigqueue 的隊列節點,然後把節點添加到 pending 隊列中,接著根據參數 info 的值來進行不同的操作,最後通過 sigaddset() 函數來設置信號對應的標誌位,表示進程接收到該信號。

signal_wake_up() 函數會把進程的 sigpending 成員變量設置為1,表示有信號需要處理,如果進程是睡眠可中斷狀態還會喚醒進程。

至此,發送信號的流程已經完成,我們可以通過下面的調用鏈來更加直觀的理解此過程:

kill() 
| User Space
---------------------------------------------------------------------------------------
| Kernel Space
sys_kill()
|---> kill_something_info()
|---> kill_proc_info()
|---> find_task_by_pid()
|---> send_sig_info()
|---> bad_signal()
|---> handle_stop_signal()
|---> ignored_signal()
|---> deliver_signal()
|---> send_signal()
| |---> kmem_cache_alloc()
| |---> sigaddset()
|---> signal_wake_up()

內核觸發信號處理函數

上面介紹了怎麼發生一個信號給指定的進程,但是什麼時候會觸發信號相應的處理函數呢?為了儘快讓信號得到處理,Linux把信號處理過程放置在進程從內核態返回到用戶態前,也就是在 ret_from_sys_call 處:

// arch/i386/kernel/entry.S
ENTRY(ret_from_sys_call)
...
ret_with_reschedule:
...
cmpl $0, sigpending(%ebx) // 檢查進程的sigpending成員是否等於1
jne signal_return // 如果是就跳轉到 signal_return 處執行
restore_all:
RESTORE_ALL
ALIGN
signal_return:
sti // 開啟硬件中斷
testl $(VM_MASK),EFLAGS(%esp)
movl %esp,%eax
jne v86_signal_return
xorl %edx,%edx
call SYMBOL_NAME(do_signal) // 調用do_signal()函數進行處理
jmp restore_all

由於這是一段彙編代碼,有點不太直觀(大概知道意思就可以了),所以我在代碼中進行了註釋。主要的邏輯就是首先檢查進程的 sigpending 成員是否等於1,如果是調用 do_signal() 函數進行處理,由於 do_signal() 函數代碼比較長,所以我們分段來說明,如下:

int do_signal(struct pt_regs *regs, sigset_t *oldset)
{
siginfo_t info;
struct k_sigaction *ka;
if ((regs->xcs & 3) != 3)
return 1;
if (!oldset)
oldset = &current->blocked;
for (;;) {
unsigned long signr;
spin_lock_irq(&current->sigmask_lock);
signr = dequeue_signal(&current->blocked, &info);
spin_unlock_irq(&current->sigmask_lock);
if (!signr)
break;

上面這段代碼的主要邏輯是通過 dequeue_signal() 函數獲取到進程接收隊列中的一個信號,如果沒有信號,那麼就跳出循環。我們接著來分析:

		ka = &current->sig->action[signr-1];
if (ka->sa.sa_handler == SIG_IGN) {
if (signr != SIGCHLD)
continue;
/* Check for SIGCHLD: it's special. */
while (sys_wait4(-1, NULL, WNOHANG, NULL) > 0)
/* nothing */;
continue;
}

上面這段代碼首先獲取到信號對應的處理方法,如果對此信號的處理是忽略的話,那麼就直接跳過。

		if (ka->sa.sa_handler == SIG_DFL) {
int exit_code = signr;
/* Init gets no signals it doesn't want. */
if (current->pid == 1)
continue;
switch (signr) {
case SIGCONT: case SIGCHLD: case SIGWINCH:
continue;
case SIGTSTP: case SIGTTIN: case SIGTTOU:
if (is_orphaned_pgrp(current->pgrp))
continue;
/* FALLTHRU */
case SIGSTOP:
current->state = TASK_STOPPED;
current->exit_code = signr;
if (!(current->p_pptr->sig->action[SIGCHLD-1].sa.sa_flags & SA_NOCLDSTOP))
notify_parent(current, SIGCHLD);
schedule();
continue;
case SIGQUIT: case SIGILL: case SIGTRAP:
case SIGABRT: case SIGFPE: case SIGSEGV:
case SIGBUS: case SIGSYS: case SIGXCPU: case SIGXFSZ:
if (do_coredump(signr, regs))
exit_code |= 0x80;
/* FALLTHRU */
default:
sigaddset(&current->pending.signal, signr);
recalc_sigpending(current);
current->flags |= PF_SIGNALED;
do_exit(exit_code);
/* NOTREACHED */
}
}
...
handle_signal(signr, ka, &info, oldset, regs);
return 1;
}
...
return 0;
}

上面的代碼表示,如果指定為默認的處理方法,那麼就使用系統的默認處理方法去處理信號,比如 SIGSEGV 信號的默認處理方法就是使用 do_coredump() 函數來生成一個 core dump 文件,並且通過調用 do_exit() 函數退出進程。

如果指定了自定義的處理方法,那麼就通過 handle_signal() 函數去進行處理,handle_signal() 函數代碼如下:

static void
handle_signal(unsigned long sig, struct k_sigaction *ka,
siginfo_t *info, sigset_t *oldset, struct pt_regs * regs)
{
...
if (ka->sa.sa_flags & SA_SIGINFO)
setup_rt_frame(sig, ka, info, oldset, regs);
else
setup_frame(sig, ka, oldset, regs);
if (ka->sa.sa_flags & SA_ONESHOT)
ka->sa.sa_handler = SIG_DFL;
if (!(ka->sa.sa_flags & SA_NODEFER)) {
spin_lock_irq(&current->sigmask_lock);
sigorsets(&current->blocked,&current->blocked,&ka->sa.sa_mask);
sigaddset(&current->blocked,sig);
recalc_sigpending(current);
spin_unlock_irq(&current->sigmask_lock);
}
}

由於信號處理程序是由用戶提供的,所以信號處理程序的代碼是在用戶態的。而從系統調用返回到用戶態前還是屬於內核態,CPU是禁止內核態執行用戶態代碼的,那麼怎麼辦?

答案先返回到用戶態執行信號處理程序,執行完信號處理程序後再返回到內核態,再在內核態完成收尾工作。聽起來有點繞,事實也的確是這樣。下面通過一副圖片來直觀的展示這個過程(圖片來源網絡):

Linux信號處理

為了達到這個目的,Linux經歷了一個十分崎嶇的過程。我們知道,從內核態返回到用戶態時,CPU要從內核棧中找到返回到用戶態的地址(就是調用系統調用的下一條代碼指令地址),Linux為了先讓信號處理程序執行,所以就需要把這個返回地址修改為信號處理程序的入口,這樣當從系統調用返回到用戶態時,就可以執行信號處理程序了。

所以,handle_signal() 調用了 setup_frame() 函數來構建這個過程的運行環境(其實就是修改內核棧和用戶棧相應的數據來完成)。我們先來看看內核棧的內存佈局圖:

Linux信號處理

圖中的 eip 就是內核態返回到用戶態後開始執行的第一條指令地址,所以把 eip 改成信號處理程序的地址就可以在內核態返回到用戶態的時候自動執行信號處理程序了。我們看看 setup_frame() 函數其中有一行代碼就是修改 eip 的值,如下:

static void setup_frame(int sig, struct k_sigaction *ka,
sigset_t *set, struct pt_regs * regs)
{
...
regs->eip = (unsigned long) ka->sa.sa_handler; // regs是內核棧中保存的寄存器集合
...
}

現在可以在內核態返回到用戶態時自動執行信號處理程序了,但是當信號處理程序執行完怎麼返回到內核態呢?Linux的做法就是在用戶態棧空間構建一個 Frame(幀)(我也不知道為什麼要這樣叫),構建這個幀的目的就是為了執行完信號處理程序後返回到內核態,並恢復原來內核棧的內容。返回到內核態的方式是調用一個名為 sigreturn() 系統調用,然後再 sigreturn() 中恢復原來內核棧的內容。

怎樣能在執行完信號處理程序後調用 sigreturn() 系統調用呢?其實跟前面修改內核棧 eip 的值一樣,這裡修改的是用戶棧 eip 的值,修改後跳轉到一個執行下面代碼的地方(用戶棧的某一處):

popl %eax 
movl $__NR_sigreturn,%eax
int $0x80

從上面的彙編代碼可以知道,這裡就是調用了 sigreturn() 系統調用。修改用戶棧的代碼在 setup_frame() 中,代碼如下:

static void setup_frame(int sig, struct k_sigaction *ka,
sigset_t *set, struct pt_regs * regs)
{
...
err |= __put_user(frame->retcode, &frame->pretcode);
/* This is popl %eax ; movl $,%eax ; int $0x80 */
err |= __put_user(0xb858, (short *)(frame->retcode+0));
err |= __put_user(__NR_sigreturn, (int *)(frame->retcode+2));
err |= __put_user(0x80cd, (short *)(frame->retcode+6));
...
}

這幾行代碼比較難懂,其實就是修改信號程序程序返回後要執行代碼的地址。修改後如下圖:

Linux信號處理

這樣執行完信號處理程序後就會調用 sigreturn(),而 sigreturn() 要做的工作就是恢復原來內核棧的內容了,我們來看看 sigreturn() 的代碼:

asmlinkage int sys_sigreturn(unsigned long __unused)
{
struct pt_regs *regs = (struct pt_regs *) &__unused;
struct sigframe *frame = (struct sigframe *)(regs->esp - 8);
sigset_t set;
int eax;
if (verify_area(VERIFY_READ, frame, sizeof(*frame)))
goto badframe;
if (__get_user(set.sig[0], &frame->sc.oldmask)
|| (_NSIG_WORDS > 1
&& __copy_from_user(&set.sig[1], &frame->extramask,
sizeof(frame->extramask))))
goto badframe;
sigdelsetmask(&set, ~_BLOCKABLE);
spin_lock_irq(&current->sigmask_lock);
current->blocked = set;
recalc_sigpending(current);
spin_unlock_irq(&current->sigmask_lock);
if (restore_sigcontext(regs, &frame->sc, &eax))
goto badframe;
return eax;
badframe:
force_sig(SIGSEGV, current);
return 0;
}

其中最重要的是調用 restore_sigcontext() 恢復原來內核棧的內容,要恢復原來內核棧的內容首先是要指定原來內核棧的內容,所以先要保存原來內核棧的內容。保存原來內核棧的內容也是在 setup_frame() 函數中,setup_frame() 函數把原來內核棧的內容保存到用戶棧中(也就是上面所說的 幀 中)。restore_sigcontext() 函數就是從用戶棧中讀取原來內核棧的數據,然後恢復之。保存內核棧內容主要由 setup_sigcontext() 函數完成,有興趣可以查閱代碼,這裡就不做詳細說明了。

這樣,當從 sigreturn() 系統調用返回時,就可以按原來的路徑返回到用戶程序的下一個執行點(比如調用系統調用的下一行代碼)。

設置信號處理程序

最後我們來分析一下怎麼設置一個信號處理程序。

用戶可以通過 signal() 系統調用設置一個信號處理程序,我們來看看 signal() 系統調用的代碼:

asmlinkage unsigned long
sys_signal(int sig, __sighandler_t handler)
{
struct k_sigaction new_sa, old_sa;
int ret;
new_sa.sa.sa_handler = handler;
new_sa.sa.sa_flags = SA_ONESHOT | SA_NOMASK;
ret = do_sigaction(sig, &new_sa, &old_sa);
return ret ? ret : (unsigned long)old_sa.sa.sa_handler;
}

代碼比較簡單,就是先設置一個新的 struct k_sigaction 結構,把其 sa.sa_handler 字段設置為用戶自定義的處理程序。然後通過 do_sigaction() 函數進行設置,代碼如下:

int
do_sigaction(int sig, const struct k_sigaction *act, struct k_sigaction *oact)
{
struct k_sigaction *k;
if (sig < 1 || sig > _NSIG ||
(act && (sig == SIGKILL || sig == SIGSTOP)))
return -EINVAL;
k = &current->sig->action[sig-1];
spin_lock(&current->sig->siglock);
if (oact)
*oact = *k;
if (act) {
*k = *act;
sigdelsetmask(&k->sa.sa_mask, sigmask(SIGKILL) | sigmask(SIGSTOP));

if (k->sa.sa_handler == SIG_IGN
|| (k->sa.sa_handler == SIG_DFL
&& (sig == SIGCONT ||
sig == SIGCHLD ||
sig == SIGWINCH))) {
spin_lock_irq(&current->sigmask_lock);
if (rm_sig_from_queue(sig, current))
recalc_sigpending(current);
spin_unlock_irq(&current->sigmask_lock);
}
}
spin_unlock(&current->sig->siglock);
return 0;
}

這個函數也不難,我們上面介紹過,進程管理結構中有個 sig 的字段,它是一個 struct k_sigaction 結構的數組,每個元素保存著對應信號的處理程序,所以 do_sigaction() 函數就是修改這個信號處理程序。代碼 k = &current->sig->action[sig-1] 就是獲取對應信號的處理程序,然後把其設置為新的信號處理程序即可。

相關推薦

推薦中...