一、信号的基本概念
注意:信号和信号量是两个不同的概念,没有任何关系!
信号是在操作系统中用于通知进程发生某个事件的一种机制。它是一种异步的通信方式,可以用于进程间的通信和进程与操作系统之间的通信。
- 信号的产生:信号可以由多种事件触发,例如用户按下某个特定的键、通过系统调用向进程发信号、软件错误、硬件异常等。当事件发生时,操作系统会向相应的进程发送一个信号。
- 信号的发送和接收:信号的发送是由操作系统负责的,而信号的接收是由进程负责的。进程具有识别信号并执行相应动作的能力,进程可以通过注册信号处理函数
signal
来指定对某个特定信号的处理方式。 - 信号的处理:进程接收到信号后,可以采取不同的处理方式。常见的处理方式包括忽略信号、执行默认的信号处理动作、执行自定义的信号处理函数等。需要注意的是,信号是一种异步的通信方式,进程无法预测信号何时到达。进程可能正在处理优先级更高,更重要的任务,所以信号的处理工作可能不是立即执行的。进程会临时记录下对应的信号,方便后续进行处理。
操作系统提供了一些函数和系统调用来处理信号,例如signal函数用于注册信号处理程序,kill函数用于向进程发送信号。
进程如何记录接收到的信号?
- 在进程PCB内部保存了信号位图字段,用于记录该进程是否收到了对应信号。
- 信号位图在进程PCB中,而PCB属于内核数据结构,所以最终只能由操作系统进行信号位图的写入。
- 发送信号的本质:操作系统直接修改目标进程PCB中的信号位图字段,将对应信号的比特位由0置1,完成信号的发送。
二、常见的信号
以下是一些常见的信号及其编号:
SIGHUP
:挂起信号(1)SIGINT
:中断信号,通常由Ctrl+C触发(2)SIGQUIT
:退出信号,通常由Ctrl+\触发(3)SIGILL
:非法指令信号(4)SIGABRT
:异常终止信号(6)SIGFPE
:浮点异常信号(8)SIGKILL
:强制终止信号,无法被忽略或捕获(9)SIGSEGV
:段错误信号(11)SIGPIPE
:管道破裂信号(13)SIGALRM
:定时器到期信号(14)SIGTERM
:终止信号,用于正常终止进程(15)SIGUSR1
:用户自定义信号1(10)SIGUSR2
:用户自定义信号2(12)
如何查看信号?
-
使用
kill -l
命令可以查看系统定义的信号列表提示:其中前[1,31]是普通信号,没有32,33信号,[34,64]是实时信号,共有62个信号。
-
使用
man 7 signal
命令查看信号的详细描述
三、信号的产生
3.1 通过终端按键产生信号
3.1.1 常用的组合键及其对应的信号
通过终端按键产生信号可以使用特定的组合键来发送信号给正在前台运行的进程。以下是一些常用的组合键及其对应的信号:
- Ctrl+C:发送SIGINT(2)信号,通常用于中断(Interrupt)进程。
- Ctrl+\:发送SIGQUIT(3)信号,通常用于退出(Quit)进程并生成core文件。
- Ctrl+Z:发送SIGTSTP(20)信号,通常用于挂起暂停(Suspend)进程,可以被忽略或捕捉。
- Ctrl+S:发送SIGSTOP(19)信号,用于挂起暂停(Stop)进程的执行,无法被忽略、处理或阻塞。
- Ctrl+Q:发送SIGCONT(18)信号,用于继续(Continue)被19, 20号信号停止的进程。
注意:
- 通过终端按键产生的信号只能发给前台进程。一个命令后面加个
&
可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。 - Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像Ctrl+C这种按键产生的信号。
在Linux中,按下Ctrl+D键组合会发送一个特殊的字符(ASCII码为0x04)给正在运行的程序。这个字符被称为EOF(End of File)字符,它表示输入流的结束。注意,EOF是特殊字符,不是信号!
举例说明:
快捷键Ctrl+C可以中断前台运行的进程,其工作原理其实是向目标进程发送了SIGINT(2)信号,使进程退出。
- 操作系统通过硬件中断的方式接收键盘输入
- 操作系统识别并解释组合键Ctrl+C
- 查找在前台运行的进程
- 操作系统将SIGINT(2)信号写入到进程PCB中的信号位图字段。
- 目标进程接收到SIGINT(2)信号,执行进程退出的逻辑。
测试程序:
void CatchSig(int signum){
printf("[%d]: %s signum:%d\n", getpid(), "进程捕捉到了一个信号,正在处理中...", signum);
}
int main(){
//测试一:
//注册信号处理程序
signal(SIGINT, CatchSig);
while(true)
{
printf("[%d]: 进程正在运行...\n", getpid());
sleep(1);
}
}
运行结果:
3.1.2 signal函数
在Linux系统中,signal函数用于注册信号处理程序,它允许进程捕获和处理接收到的信号。该函数的原型如下:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数:
-
signum
表示要处理的信号编号 -
handler
表示信号处理程序的函数指针,可以是以下值之一:-
SIG_DFL ((__sighandler_t) 0)
:默认信号处理程序 -
SIG_IGN ((__sighandler_t) 1)
:忽略信号 -
函数指针:自定义的信号处理程序(回调函数)
-
返回值:
- 成功:返回之前的信号处理程序的函数指针(sighandler_t)
- 失败:返回
SIG_ERR ((__sighandler_t) -1)
,并设置errno错误码。
注意:
- signal函数只是用于注册信号处理程序,并不会立即调用信号处理程序。
- 当信号发生时,信号处理程序才会被调用。处理程序可以执行特定的操作,如记录日志、清理资源或修改程序行为。
- 需要注意的是,信号处理程序应该尽可能地简单和快速,以避免可能的竞态条件和死锁问题。
3.1.3 core dump核心转储
在3.1.1的测试程序中,SIGINT(2)信号和SIGQUIT(3)信号都可用于终止进程,他们有什么不同呢?可以发现,按下Ctrl+\
进程接收到SIGQUIT(3)
信号终止运行,并发生了core dump。
所谓core dump,即核心转储。在Linux中,当进程崩溃时,操作系统会将进程的内存映像写入到核心转储文件中。核心转储文件可以用于分析程序崩溃的原因。
其他的core dump信号:
一般而言,核心转储功能用于开发测试环境。而生产环境(例如:云服务器)的核心转储功能是被关闭的。因为在生产环境下,程序崩溃后会自动重启,会导致多次生成核心转储文件,从而导致占用大量的磁盘空间。同时生成核心转储文件需要一定的系统资源,包括CPU和磁盘IO。关闭核心转储功能可以减少系统资源的消耗,提高系统的性能和响应速度。
要启用核心转储文件的生成,可以使用以下命令:
ulimit -c unlimited
上述命令将ulimit的core file size限制设置为无限制,这样当进程崩溃时,核心转储文件将会生成。其他选项:
0
:表示禁用核心转储文件。unlimited
:表示核心转储文件大小没有限制。- 其他数字:表示核心转储文件大小的限制,以字节为单位。
另外,可以使用
ulimit -c
命令来查看当前系统的核心转储文件大小限制。
生成的核心转储文件可以使用gdb进行调试。可以使用以下命令来加载核心转储文件:
gdb <可执行文件> <核心转储文件>
gdb加载核心转储文件后,会直接跳转定位到导致进程崩溃的语句。然后可以使用gdb的各种命令来分析核心转储文件,如查看堆栈信息、查看变量的值等。
测试程序:
int main(){
//测试二:
cout << "pid: " << getpid() << endl;
int x = 4;
x/=0; //除0错误
return 0;
}
运行结果:
3.1.4 进程退出状态中的core dump标志位
进程退出状态status的第8位表示core dump标志,用于标识进程是否因为信号终止并生成了核心转储文件。
测试程序:
int main(){
//测试三:
pid_t id = fork();
if(id == 0)
{
int x = 4;
x/=0; //除0错误
exit(1);
}
int status = 0;
waitpid(id, &status, 0);
if(WIFEXITED(status))
{
printf("exit_code: %d\n", WEXITSTATUS(status));
}
else
{
printf("exit_signal: %d\tcore_dump: %d\n", status&(0x7f), (status>>7)&1);
}
}
运行结果:
core dump标志位置1的条件:
- 系统的核心转储功能是打开的。
- 进程收到core dump信号。
以上两个条件必须全部满足,否则core dump标志位依旧为0。
3.2 通过系统调用发送信号
3.2.1 kill命令 && kill函数
kill
命令用于向指定进程发送信号,以控制进程的行为。其基本语法如下:
kill [options] <pid> ...
常用的选项包括:id
-l
:列出支持的信号列表。<PID>
:不指定信号,默认发送9号信号终止进程-<signal> <PID>
:向指定进程发送指定信号。-<signal> -<PGID>
:向指定进程组中的所有进程发送信号-<signal> %jobid
:向指定作业中的所有进程发送信号
例如,要向进程ID为1234的进程发送SIGTERM
信号,可以使用以下命令:
kill -15 1234
需要注意的是,kill
命令只是向进程发送信号,如果进程没有正确处理信号,则可能会导致进程异常退出或产生其他问题。因此,在使用kill
命令发送信号时,需要确保目标进程正确处理了对应的信号。
常用的终止进程命令还有pkill(-g向指定进程组或 -P指定父进程的所有子进程发信号),killall(杀掉所有进程名匹配的进程)
在Linux系统中,kill
函数用于向指定进程发送信号。其函数原型如下:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数:
pid
参数:指定要发送信号的进程ID。sig
参数:指定要发送的信号编号。
例如,要向进程ID为1234的进程发送SIGTERM
信号,可以使用以下代码:
kill(1234, SIGTERM);
返回值:
- 成功:返回0
- 失败:返回-1,并设置errno错误码。
需要注意的是,kill
函数只是向进程发送信号,如果进程没有正确处理信号,则可能会导致进程异常退出或产生其他问题。因此,在使用kill
函数发送信号时,需要确保目标进程正确处理了对应的信号。
测试程序(mykill):
void Usage(){
cout << "./mykill <signum> <pid>" << endl;
}
int main(int argc, char** argv){
if(argc != 3)
{
Usage(); //使用说明
exit(1);
}
int signum = stoi(argv[1]);
int procid = stoi(argv[2]);
kill(procid, signum);
return 0;
}
运行结果:
3.2.2 raise函数
在Linux中,raise
函数用于向当前进程发送信号。它的函数原型如下:
#include <signal.h>
int raise(int sig);
参数:
raise
函数接受一个整数参数sig
,表示要发送的信号编号。它会向当前进程发送指定的信号。
返回值:
- 成功: 返回0
- 失败:返回非0
例如,要向当前进程发送SIGTERM
信号,可以使用以下代码:
#include <signal.h>
int main() {
raise(SIGTERM);
return 0;
}
需要注意的是,raise
函数只能向当前进程发送信号,无法发送给其他进程。如果需要向其他进程发送信号,可以使用kill
函数。同时,与kill
函数一样,使用raise
函数发送信号时,也需要确保当前进程正确处理了对应的信号。
3.2.3 abort函数
在Linux中,abort(6)
函数用于异常终止程序的执行。它的函数原型如下:
#include <stdlib.h>
void abort(void);
abort
函数会向当前进程发送SIGABRT(6)
信号,这是一个终止信号,通常用于表示程序遇到了严重错误,需要立即终止。
当调用abort
函数时,会触发以下行为:
abort
函数会向当前进程发送SIGABRT(6)
信号。- 默认情况下,接收到
SIGABRT
信号的进程会终止执行,并生成一个核心转储文件(core dump),用于调试。
需要注意的是,abort
函数的使用应该谨慎,因为它会立即终止程序的执行,可能导致未完成的操作无法正确处理。通常情况下,应该通过合理的错误处理机制来处理异常情况,而不是直接调用abort
函数。
3.3 软件条件产生信号
操作系统先识别到某种软件条件触发或不满足,再将对应的信号发送给指定的进程。
3.3.1 SIGPIPE信号
如果读端fd被关闭:则write操作会产生SIGPIPE信号,进而可能导致写入进程退出(异常退出)。
测试程序:
int main(){
//测试五:
int pipefd[2] = {0};
pipe(pipefd);
pid_t id = fork();
if(id == 0)
{
close(pipefd[0]);
char wbuff[128] = "hello world!";
while(true) //写端死循环
{
//大约5秒后write操作会产生SIGPIPE信号,进而可能导致写入进程退出
write(pipefd[1], wbuff, strlen(wbuff));
sleep(1);
}
close(pipefd[1]);
exit(0);
}
close(pipefd[1]);
char rbuff[128];
int cnt = 5;
while(cnt--) //读端读5秒结束,并关闭读端
{
ssize_t sz = read(pipefd[0], rbuff, sizeof(rbuff)-1);
if(sz > 0)
{
rbuff[sz] = 0;
cout << "chlid process: " << rbuff << endl;
}
sleep(1);
}
close(pipefd[0]); //关闭读端
//等待子进程退出
int status = 0;
waitpid(id, &status, 0);
if(WIFEXITED(status))
{
printf("cpid: %d\texit_code: %d\n", id, WEXITSTATUS(status));
}
else
{
printf("cpid: %d\texit_signal: %d\tcore_dump: %d\n", id, status&(0x7f), (status >> 7) & 1);
}
return 0;
}
运行结果:
3.3.2 alarm函数 和 SIGALRM信号
在Linux中,alarm
函数用于设置一个定时器,当定时器到期时,会向当前进程发送SIGALRM
信号。它的函数原型如下:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
参数:
alarm
函数接受一个以秒为单位的参数seconds
,表示定时器的时间长度。当定时器到期时,会向当前进程发送SIGALRM(14)
信号。
返回值:
alarm
函数的返回值为上一个定时器剩余的时间,如果之前没有设置定时器,则返回0。
注意:
- 需要注意的是,
alarm
函数只能设置一个全局的定时器,如果之前已经设置了一个定时器,调用alarm
函数会取消之前的定时器,并设置一个新的定时器。如果要取消定时器,可以将seconds
参数设置为0。 - 当定时器到期时,会向当前进程发送
SIGALRM(14)
信号并解除定时器。如果需要循环计时,可以在信号处理程序返回前再次设置定时器。 - 另外,如果定时器到期时进程正在执行系统调用,定时器会在系统调用返回后触发,而不是中断系统调用。
测试程序:
typedef function<void()> func;
vector<func> callbacks;
int cnt = 0;
void CatchSig(int signum){
printf("[%d]: %s signum:%d\n", getpid(), "进程捕捉到了一个信号,正在处理中...", signum);
for(auto& f : callbacks)
{
f();
}
alarm(3);
}
void Func1(){
cout << "执行功能1...." << endl;
}
void Func2(){
cout << "执行功能2...." << endl;
}
void Func3(){
cout << "执行功能3...." << endl;
}
void Load(){
callbacks.push_back(Func1);
callbacks.push_back(Func2);
callbacks.push_back(Func3);
}
int main(){
//测试六:
Load();
//注册信号处理程序
signal(SIGALRM, CatchSig);
//设置一个定时器
alarm(3);
while(true)
{
printf("[%d]: 进程正在运行...\n", getpid());
sleep(1);
}
return 0;
}
运行结果:
3.4 硬件异常产生信号
硬件异常:进程产生的某种错误被硬件检测到并通知内核,然后内核(操作系统)向当前进程发送对应的硬件异常信号。
3.4.1 SIGFPE信号
例如当前进程执行了除以0的指令,CPU的状态寄存器(硬件)会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。
测试程序:
void handler(int signum)
{
printf("[%d]: %s signum:%d\n", getpid(), "进程捕捉到了一个信号!", signum);
sleep(1);
}
int main(){
//测试七:
signal(SIGFPE, handler);
int x = 0;
x /= 0; //除0错误
return 0;
}
运行结果:
3.4.2 SIGSEGV信号
再比如当前进程访问了非法内存地址,在将虚拟地址转化为物理地址的过程中,内存管理单元MMU(硬件)会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
测试程序:
void handler(int signum)
{
printf("[%d]: %s signum:%d\n", getpid(), "进程捕捉到了一个信号!", signum);
sleep(1);
}
int main(){
//测试七:
signal(SIGSEGV, handler);
int* pi = nullptr;
*pi = 100; //野指针错误
return 0;
}
运行结果:
注意:
- 硬件异常一般是无法被解决的,所以如果使用signal函数捕获到了硬件异常,程序就会陷入死循环。因为硬件异常一直存在。
- 因此,出现硬件异常的默认处理方法就是终止该进程。大多数情况下我们也只能这样做。