一、Linux 信号基本概念
1.1 生活角度理解信号
我们可以把进程比作等待快递的人,信号就像快递:
- 识别信号:就像我们知道快递来了该 怎么处理,进程对信号的识别是内核程序员预先编写的内置特性,即使信号没产生,进程也知道该如何处理。
- 信号延迟处理:比如正在打游戏时收到快递通知,会等游戏结束再去取,进程收到信号后,若在执行优先级更高的任务,会在合适的时候处理信号。
- 信号记录:从收到快递通知到取到快递的这段时间,我们会记住有快递要取,进程收到信号后,在未处理前也会暂时记录信号。
- 信号处理方式:处理快递有打开使用(默认动作)、送给他人(自定义动作)、扔在一边(忽略)三种方式,进程处理信号也有默认、自定义、忽略三种方式,自定义处理信号也叫信号捕捉。
1.2 技术角度理解信号
信号是进程之间事件异步通知的一种方式,属于软中断。比如在 Shell 下启动一个前台进程,当我们按下Ctrl+C
,会产生一个硬件中断,被操作系统获取后解释成SIGINT
(2 号信号)发送给前台进程,前台进程收到信号后会退出,这就是信号的实际应用。
1.3 查看信号
在 Linux 系统中,我们可以通过相关命令查看信号,每个信号都有对应的编号和宏定义名称,这些宏定义可在signal.h
中找到。例如:
SIGINT
(2 号信号):来自键盘的中断信号。SIGQUIT
(3 号信号):来自键盘的退出信号,会生成 core dump 文件。SIGKILL
(9 号信号):杀死进程的信号,无法被捕捉和忽略。
我们也可以通过man 7 signal
命令查看每个信号的产生条件和默认处理动作,部分常见信号如下表:
Signal | Standard | Action | Comment |
---|---|---|---|
SIGABRT | P1990 | Core | 来自 abort (3) 的中止信号 |
SIGALRM | P1990 | Term | 来自 alarm (2) 的定时器信号 |
SIGBUS | P2001 | Core | 总线错误(不良内存访问) |
SIGCHLD | P1990 | Ign | 子进程停止或终止 |
SIGINT | P1990 | Term | 来自键盘的中断 |
二、信号产生的一般方式
2.1 通过终端按键产生信号
- Ctrl+C(SIGINT,2 号信号):发送中断信号,默认终止前台进程。
- 示例:编写一个简单的循环程序,运行后按下
Ctrl+C
,进程会退出。
- 示例:编写一个简单的循环程序,运行后按下
#include <iostream>
#include <unistd.h>
int main() {while (true) {std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);}return 0;
}
编译运行:g++ sig.cc -o sig && ./sig
,按下Ctrl+C
,进程终止
- Ctrl+\(SIGQUIT,3 号信号):发送退出信号,默认终止进程并生成 core dump 文件,用于事后调试。
- 示例:修改上述程序,捕捉
SIGQUIT
信号。
- 示例:修改上述程序,捕捉
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber) {std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber << std::endl;
}
int main() {std::cout << "我是进程: " << getpid() << std::endl;signal(SIGQUIT/*3*/, handler);while (true) {std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);}return 0;
}
编译运行后,按下Ctrl+\
,会输出信号编号,若注释掉信号捕捉代码,按下Ctrl+\
,进程会退出并生成 core dump 文件。
- Ctrl+Z(SIGTSTP,20 号信号):发送停止信号,默认将当前前台进程挂起到后台。
- 示例:运行上述未捕捉
SIGTSTP
信号的循环程序,按下Ctrl+Z
,进程会被挂起,使用jobs
命令可查看后台进程,使用fg
命令可将后台进程调回前台。
2.1.2 实操:使用signal函数自定义SIGINT信号的处理方式
以下是一个小实验,大家可以练下手加深理解
signal
函数用于设置信号的处理方式,它的原型是:
#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);
signum
:要设置处理方式的信号编号,比如SIGINT
。handler
:指向信号处理函数的指针。信号处理函数的原型是void handler(int signum)
,其中signum
是接收到的信号编号。
代码解释
首先,我们包含了必要的头文件:
stdio.h
(用于输入输出)、signal.h
(用于信号相关操作)、unistd.h
(用于sleep
函数)。定义了自定义的信号处理函数
sigcb
,它接收一个int
类型的参数signum
(即接收到的信号编号),在函数内部打印出接收到的信号值。在
main
函数中,使用signal(SIGINT, sigcb)
来设置SIGINT
信号的处理函数为sigcb
。这样,当进程接收到SIGINT
信号时,就会调用sigcb
函数而不是默认的终止进程操作。然后打印提示信息,接着通过一个无限循环
while (1)
让程序保持运行,sleep(1)
是为了避免程序过度占用 CPU。编译与运行
编译代码:使用
gcc
编译器,在终端中输入命令gcc -o sig_demo sig_demo.c
(假设代码文件名为sig_demo.c
)。运行程序:在终端中输入
./sig_demo
,程序会开始运行并打印提示信息。测试信号:按下
Ctrl + C
,此时会触发SIGINT
信号,程序会调用sigcb
函数,打印出类似接收到信号,信号值为:2
(SIGINT
的值通常为 2)的信息,而不是终止程序。执行结果截图说明
编译运行后,终端会显示 “程序正在运行,按下ctrl+c发送SIGINT信号”。
当按下
Ctrl + C
时,终端会打印 “接收到信号为:2”,然后程序继续运行(因为我们的处理函数没有终止进程,而是让程序继续在循环中运行)。如果多次按下Ctrl + C
,会多次打印该信息。
SIGINT
(Ctrl+C)被我们自定义处理了,但系统还有其他信号可以终止进程,比如SIGQUIT
(通常由Ctrl+\
触发)。
通过这个例子,你可以清楚地看到如何使用 signal
函数来自定义信号的处理方式,以及 SIGINT
信号的触发和处理过程。
2.1.3 实操2:使用sigaction函数自定义SIGINT信号的处理方式
sigaction
函数是比 signal
函数更强大、更可移植的信号处理接口。它可以更精细地控制信号的处理行为。sigaction
函数的原型如下:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum
:要设置处理方式的信号编号,比如SIGINT
。act
:指向struct sigaction
结构体的指针,该结构体包含了新的信号处理方式等信息。oldact
:指向struct sigaction
结构体的指针,用于保存原来的信号处理方式(可以为NULL
,表示不保存)。
struct sigaction
结构体的定义大致如下:
struct sigaction {void (*sa_handler)(int);sigset_t sa_mask;int sa_flags;void (*sa_sigaction)(int, siginfo_t *, void *);
};
sa_handler
:指向信号处理函数的指针,和signal
函数中的处理函数类似,处理函数原型为void handler(int signum)
。sa_mask
:指定在信号处理函数执行期间,需要阻塞的信号集合。sa_flags
:用于设置信号处理的一些标志,比如SA_SIGINFO
等。sa_sigaction
:当sa_flags
中设置了SA_SIGINFO
标志时,使用该函数指针所指向的函数来处理信号,这个函数可以获取更多关于信号的信息,原型为void handler(int signum, siginfo_t *info, void *context)
。
代码实现
我们编写一个 C 程序,使用 sigaction
函数来自定义 SIGINT
信号的处理方式:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>// 自定义的信号处理函数
void sigcb(int signum) {printf("接收到信号,信号值为:%d\n", signum);
}int main() {struct sigaction act;// 设置信号处理函数为 sigcbact.sa_handler = sigcb;// 清空 sa_mask,即处理信号期间不阻塞其他信号sigemptyset(&act.sa_mask);// 设置 sa_flags 为 0,使用默认的标志act.sa_flags = 0;// 使用 sigaction 函数设置 SIGINT 信号的处理方式sigaction(SIGINT, &act, NULL);printf("程序正在运行,按下 Ctrl + C 发送 SIGINT 信号\n");// 让程序保持运行,以便我们可以发送信号while (1) {sleep(1);}return 0;
}
代码解释
首先包含必要的头文件:
stdio.h
(输入输出)、signal.h
(信号相关操作)、unistd.h
(sleep
函数)。定义自定义的信号处理函数
sigcb
,功能是打印接收到的信号值。在
main
函数中,定义struct sigaction
类型的变量act
。设置
act
的sa_handler
成员为我们自定义的处理函数sigcb
。使用
sigemptyset
函数清空sa_mask
成员,这样在处理SIGINT
信号期间,不会阻塞其他信号。将
sa_flags
成员设置为0
,使用默认的标志。调用
sigaction
函数,将SIGINT
信号的处理方式设置为act
所指定的方式,第三个参数为NULL
,表示不保存原来的信号处理方式。打印提示信息后,通过无限循环让程序保持运行,
sleep(1)
避免程序过度占用 CPU。编译与运行
编译代码:使用
gcc
编译器,在终端中输入命令gcc -o sigaction_demo sigaction_demo.c
(假设代码文件名为sigaction_demo.c
)。运行程序:在终端中输入
./sigaction_demo
,程序开始运行并打印提示信息。测试信号:按下
Ctrl + C
,触发SIGINT
信号,程序会调用sigcb
函数,打印出类似接收到信号,信号值为:2
(SIGINT
的值通常为 2)的信息,而不是终止程序。执行结果截图说明
编译运行后,终端会显示 “程序正在运行,按下 Ctrl + C 发送 SIGINT 信号”。
当按下
Ctrl + C
时,终端会打印 “接收到信号,信号值为:2”,然后程序继续运行(因为我们的处理函数没有终止进程,程序在循环中继续运行)。多次按下Ctrl + C
,会多次打印该信息。
通过这个例子,你可以掌握 sigaction
函数的使用方法,以及如何更精细地控制信号的处理方式。
2.2 调用系统命令向进程发信号
我们可以使用kill
命令向指定进程发送信号,例如:
- 后台运行一个死循环程序:
./sig &
(sig
为上述编译生成的可执行文件)。- 查看进程 ID:
ps ajx | grep sig
,获取进程的 PID。- 向进程发送
SIGSEGV
(11 号信号,段错误信号):kill -SIGSEGV PID
(或kill -11 PID
),进程会因段错误终止。
2.3 使用函数产生信号
2.3.1 kill 函数
kill
函数可以给一个指定的进程发送指定的信号,函数原型如下:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
- 参数说明:
pid
:目标进程的 PID。sig
:要发送的信号编号。
- 返回值:成功返回 0,失败返回 - 1,并设置
errno
。 - 示例:实现自己的 kill 命令
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
// mykill -signumber pid
int main(int argc, char *argv[]) {if (argc != 3) {std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;return 1;}int number = std::stoi(argv[1] + 1); // 去掉“-”pid_t pid = std::stoi(argv[2]);int n = kill(pid, number);return n;
}
编译生成mykill
后,可像kill
命令一样使用,例如./mykill -2 PID
,向指定 PID 的进程发送 2 号信号。
2.3.2 raise 函数
raise
函数可以给当前进程发送指定的信号(自己给自己发信号),函数原型如下:
#include <signal.h>
int raise(int sig);
- 参数说明:
sig
:要发送的信号编号。 - 返回值:成功返回 0,失败返回非 0。
- 示例
#include <iostream> #include <unistd.h> #include <signal.h> void handler(int signumber) {// 整个代码就只有这一处打印std::cout << "获取了一个信号: " << signumber << std::endl; } int main() {signal(2, handler); // 先对2号信号进行捕捉// 每隔1S,自己给自己发送2号信号while (true) {sleep(1);raise(2);}return 0; }
编译运行后,每隔 1 秒会输出 “获取了一个信号: 2”。
2.3.3 abort 函数
abort
函数使当前进程接收到信号而异常终止,函数原型如下:
#include <stdlib.h>
void abort(void);
- 说明:
abort
函数总是会成功,没有返回值,它会给当前进程发送SIGABRT
(6 号信号)。 - 示例
#include <iostream> #include <unistd.h> #include <stdlib.h> #include <signal.h> void handler(int signumber) {// 整个代码就只有这一处打印std::cout << "获取了一个信号: " << signumber << std::endl; } int main() {signal(SIGABRT, handler);while (true) {sleep(1);abort();}return 0; }
编译运行后,会输出 “获取了一个信号: 6”,然后进程异常终止,即使捕捉了SIGABRT
信号,进程也会退出。
2.4 由软件条件产生信号
以alarm
函数和SIGALRM
(14 号信号)为例,alarm
函数可以设定一个闹钟,告诉内核在指定秒数后给当前进程发SIGALRM
信号,该信号的默认处理动作是终止当前进程,函数原型如下:
- 参数说明:
seconds
:闹钟时间,单位为秒。若seconds
为 0,表示取消以前设定的闹钟。 - 返回值:返回 0 或者以前设定的闹钟时间还余下的秒数。
2.4.1 基本 alarm 验证 - 体会 IO 效率问题
- IO 多的情况
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main() {int count = 0;alarm(1);while (true) {std::cout << "count : " << count << std::endl;count++;}return 0;
}
编译运行后,1 秒内输出的计数较少,因为std::cout
是 IO 操作,效率较低。
- IO 少的情况
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber) {std::cout << "count : " << count << std::endl;exit(0);
}
int main() {signal(SIGALRM, handler);alarm(1);while (true) {count++;}return 0;
}
编译运行后,1 秒内输出的计数会非常大,因为仅进行变量自增操作,IO 操作少,效率高。
2.4.2 设置重复闹钟
#include <iostream>
#include <unistd.h>
#include <signal.h>
int gcount = 0;
void handler(int signo) {std::cout << "gcount : " << gcount << std::endl;gcount++;int n = alarm(1); // 重设闹钟,会返回上一次闹钟的剩余时间std::cout << "剩余时间 : " << n << std::endl;
}
int main() {std::cout << "我的进程pid是: " << getpid() << std::endl;alarm(1); // 一次性的闹钟,超时alarm会自动被取消signal(SIGALRM, handler);while (true) {pause(); // 等待信号std::cout << "我醒来了..." << std::endl;}return 0;
}
编译运行后,每隔 1 秒会输出计数和剩余时间,实现了重复闹钟的功能。pause
函数会使调用进程(或线程)睡眠,直到收到一个终止进程的信号或一个导致调用信号捕捉函数的信号。
2.5 硬件异常产生信号
硬件异常被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如:
- 除零异常:当进程执行除以 0 的指令时,CPU 的运算单元会产生异常,内核将这个异常解释为
SIGFPE
(8 号信号)发送给进程。 - 示例
#include <stdio.h> #include <signal.h> void handler(int sig) {printf("catch a sig : %d\n", sig); } int main() {signal(SIGFPE, handler); // 8) SIGFPEsleep(1);int a = 10;a /= 0;while (1);return 0; }
编译运行后,会不断输出 “catch a sig : 8”,因为除零异常一直存在,内核会持续发送SIGFPE
信号。
- 非法内存访问:当进程访问了非法内存地址,MMU(内存管理单元)会产生异常,内核将这个异常解释为
SIGSEGV
(11 号信号)发送给进程。- 示例
#include <stdio.h>
#include <signal.h>
void handler(int sig) {printf("catch a sig : %d\n", sig);
}
int main() {signal(SIGSEGV, handler);sleep(1);int *p = NULL;*p = 100;while (1);return 0;
}
编译运行后,会不断输出 “catch a sig : 11”,因为非法内存访问的异常一直存在。
三、信号递达和阻塞的概念与原理
3.1 相关概念
- 信号递达(Delivery):实际执行信号的处理动作。
- 信号未决(Pending):信号从产生到递达之间的状态。
- 信号阻塞(Block):进程可以选择阻塞某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
3.2 在内核中的表示
在进程控制块(task_struct
)中,有三个与信号相关的重要部分:
block
:信号屏蔽字,用sigset_t
类型表示,每个 bit 代表对应信号是否被阻塞。pending
:未决信号集,同样用sigset_t
类型表示,每个 bit 代表对应信号是否处于未决状态。handler
:信号处理动作数组,每个元素是一个函数指针,指向该信号的处理函数,若为SIG_DFL
表示默认处理动作,SIG_IGN
表示忽略该信号。
例如,对于SIGINT
(2 号信号),若block
中对应的 bit 为 1,表示该信号被阻塞;若pending
中对应的 bit 为 1,表示该信号处于未决状态;handler
中对应的函数指针指向该信号的处理函数。
3.3 信号集操作函数
sigset_t
类型用于表示信号集,我们不能直接操作sigset_t
变量的内部数据,需要使用专门的函数:
3.3.1 初始化和修改信号集
sigemptyset
:初始化信号集,使其中所有信号的对应 bit 清零,表示该信号集不包含任何有效信号。
#include <signal.h>
int sigemptyset(sigset_t *set);
sigfillset
:初始化信号集,使其中所有信号的对应 bit 置位,表示该信号集包含系统支持的所有信号。
#include <signal.h>
int sigfillset(sigset_t *set);
sigaddset
:在信号集中添加某种有效信号。
#include <signal.h>
int sigaddset(sigset_t *set, int signo);
sigdelset
:在信号集中删除某种有效信号。
#include <signal.h>
int sigdelset(sigset_t *set, int signo);
- 返回值:以上四个函数成功返回 0,出错返回 - 1。
3.3.2 判断信号是否在信号集中
sigismember
:判断一个信号集的有效信号中是否包含某种信号,若包含则返回 1,不包含则返回 0,出错返回 - 1。
#include <signal.h>
int sigismember(const sigset_t *set, int signo);
3.3.3 读取或更改进程的信号屏蔽字
sigprocmask
:读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
- 参数说明:
how
:指示如何更改信号屏蔽字,取值如下:
SIG_BLOCK
:set
包含了我们希望添加到当前信号屏蔽字的信号,相当于mask = mask | set
。SIG_UNBLOCK
:set
包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask = mask & ~set
。SIG_SETMASK
:设置当前信号屏蔽字为set
所指向的值,相当于mask = set
。set
:若非空指针,则根据how
更改进程的信号屏蔽字;若为空指针,则不更改信号屏蔽字。oset
:若非空指针,则读取进程的当前信号屏蔽字通过oset
参数传出;若为空指针,则不读取信号