Home

cdeveloper

读书,写作

Linux 高级编程 - 信号 Signal


版权声明:本文为 cdeveloper 原创文章,可以随意转载,但必须在明确位置注明出处!

信号(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:信号的处理函数
 * return:???
 */
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」

cdeveloper at 08/06/17