本篇文章会对Linux下的信号进行详细解释。主要内容是什么是信号、信号的产生、核心转储等问题。希望本篇文章会对你有所帮助。
🙋♂️ 作者:@Ggggggtm 🙋♂️
👀 专栏:Linux从入门到精通 👀
💥 标题:信号产生💥
** ❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️**
引入
在Linux系统中,信号是一种轻量级的通信机制,可以用于实现进程之间的协作和通信。每个信号都有一个唯一的编号,通常以SIG开头,例如SIGINT、SIGTERM等。这些信号的含义和行为在Linux系统中是标准化的,但也可以通过自定义信号处理程序来改变它们的行为。
Linux信号在各种情况下都有广泛的应用,从终端用户通过Ctrl+C发送中断信号,到系统管理员使用信号来管理和监控进程,以及进程之间通过信号进行通信和协作。因此,理解Linux信号是系统管理员和开发人员的重要技能,有助于更好地控制和管理Linux系统中的进程。
一、初识信号
1、1 生活中的信号
其实在生活中,我们也经常有意无意的接受信号。比如,玩游戏时队友发送的请求集合、订购外卖时快递员到了你楼下给你打电话,你也收到快递到来的通知等等。古代战争传递信号的方式是烽烟(烽火)。
但是当你收到信号时,你就会立即处理吗?实际上可能并不会。例如,外卖到了但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
我们接收到信号,并且处理时会有很多处理方法。例如我们取回来快递,就要开始处理快递了。而处理快递一般方式有三种:
- 执行默认动作(幸福的打开快递,使用商品);
- 执行自定义动作(快递是零食,你要送给你你的女朋友);
- 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏。
1、2 Linux 下的信号
Linux信号通常由操作系统或其他进程发送给目标进程,可以用于多种目的,例如中断进程、终止进程或请求进程执行某个特定操作。本质是一种通信机制。
标准信号是一组在Linux系统中具有固定编号和含义的信号。举个例子:我们平常在Linux下进程会使用Ctrl+C来终止当前的进程。这个本质就是向进程发送了一个2号信号。Linux下有很多信号,具体如下图:
实际上一共是有62个信号,因为并没有32号和33号信号。本篇文章重点讲解普通信号,也就是1~31号信号。还有一种信号是实时信号。实时信号是一组具有不同编号和含义的信号,通常用于高优先级任务和实时系统。实时信号的编号范围从34到64。
我们这里先给出普通信号的编号、名称和含义,下文也会对一些重点信号进行讲解:
- SIGHUP(1): 挂起信号。
- SIGINT(2): 中断信号。
- SIGQUIT(3): 退出信号。
- SIGILL(4): 非法指令信号。
- SIGTRAP(5): 跟踪/陷阱信号。
- SIGABRT(6): 中止信号。
- SIGBUS(7): 总线错误信号。
- SIGFPE(8): 浮点异常信号。
- SIGKILL(9): 强制终止信号。
- SIGUSR1(10): 用户自定义信号1。
- SIGSEGV(11): 段错误信号。
- SIGUSR2(12): 用户自定义信号2。
- SIGPIPE(13): 管道破裂信号。
- SIGALRM(14): 超时信号。
- SIGTERM(15): 终止信号。
- SIGSTKFLT(16): 协处理器栈错误信号。
- SIGCHLD(17): 子进程状态改变信号。
- SIGCONT(18): 继续执行信号。
- SIGSTOP(19): 停止信号。
- SIGTSTP(20): 终端停止信号。
- SIGTTIN(21): 后台进程尝试读终端信号。
- SIGTTOU(22): 后台进程尝试写终端信号。
- SIGURG(23): 紧急情况信号。
- SIGXCPU(24): 超出CPU时间限制信号。
- SIGXFSZ(25): 超出文件大小限制信号。
- SIGVTALRM(26): 虚拟定时器信号。
- SIGPROF(27): 专用定时器信号。
- SIGWINCH(28): 窗口大小改变信号。
- SIGIO(29): 异步IO信号。
- SIGPWR(30): 电源故障信号。
- SIGSYS(31): 非法系统调用信号。
通过上述Ctrl+C来终止当前的进程,那么这里会有一个疑问:进程为什么能够识别出用户所发送的信号呢?下面会给出一些结论。
1、3 信号+进程所得的初识结论
同我们上述所列举的生活中的信号和Liunx下的信号,我们大概也能知道以下结论:
- 进程要处理信号,必须具备信号“识别”的能力(看到+处理动作)。
- 凭什么进程能够“识别”信号呢?原因是由于操作系统提供了信号处理机制,通过注册和处理信号处理函数,进程可以对不同的信号做出相应的响应和处理。根本上就是程序员已经在底层都处理好了。
- 信号产生是随机的,进程可能正在忙自己的事情,所以,信号的后续处理,可能不是立即处理的!
- 进程会临时的记录下对应的信号,方便后续进行处理。
- 在什么时候处理呢?合适的时候。(下文会详细解释)
- 一般而言,信号的产生相对于进程而言是异步的。
什么是异步呢?异步是指事件的发生和处理是相互独立、不同步进行的。在计算机编程中,异步操作指的是程序在执行某个操作时,不需要等待该操作完成,而可以继续执行下面的代码,在操作完成后通过回调或其他方式得到结果。
举例来说,假设有一个在线聊天应用程序,用户可以发送消息给其他用户。当用户发送一条消息时,常见的做法是通过网络将消息发送给接收方,然后等待接收方的响应,最后再执行下一步操作。
但如果使用异步的方式,则用户在发送消息之后可以继续进行其他操作,而不需要等待对方的响应。一旦对方接收到消息并做出处理,系统会通知发送方消息已经成功发送,或者提供相应的错误信息。
这种异步的方式可以提高用户体验,因为用户不需要一直等待操作的完成,可以同时进行其他操作。同时也可以提高系统的并发性能,充分利用计算资源。
在编程中,常见的异步操作包括网络请求、文件读写、数据库操作等。通过使用异步操作,可以避免因阻塞等待而导致程序性能下降或产生无响应的情况,提升程序的效率和响应速度。
二、信号的产生
我们大概了解信号的概念后,再来看一下信号都是在哪些情况下产生的。在Linux下,信号可以由多种方式产生。以下是一些常见的信号产生方式:
- 用户通过终端输入:例如按下Ctrl+C键产生的SIGINT信号,用于中断进程的执行。
- 硬件异常:当发生硬件故障或错误时,操作系统会发送相应的信号给进程,例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
- 软件条件:进程可以根据满足特定条件时发送信号给自己或其他进程。本篇文章主要介绍alarm函数 和SIGALRM信号。
- 系统调用:某些系统调用可以触发信号。例如,kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
下面会对每种产生信号的方式进行详解。
2、1 用户通过终端输入产生信号
2、1、1 理解组合键变成信号
上述了解到了:Ctrl+C产生(2)SIGINT信号。但是组合键怎么就变成信号了呢?
我们可以简单了理解为:Ctrl+C产生SIGINT信号的行为只是命令行界面中的一种约定。具体是:用户按下Ctrl+C后,键盘输入产生一个硬件中断,被OS获取(OS能识别我们所输入的组合键),解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
在上述的情况中,我们知道了操作系统解释完后将信号发送给了进程。那么信号是保存在哪里呢?答案是对应进程的数据结构位图中!下文也会对此进行详解。那么发送信号的本质是操作系统向进程中写信号,不就是修改对应的进程控制块(PCB)中的内核位图数据结构吗!!!
2、1、2 验证ctrl + c 对应 (2)SIGINT信号 (signal()函数)
在验证之前,我们先来学习一下signal()函数的使用。signal()函数是一个用于处理信号的函数,它允许我们定义信号处理程序来捕获和处理系统中产生的各种信号。具体如下图:
下面是signal()函数的参数解释:signumhandlerSIGINT
参数:
- signum:表示要捕获或处理的信号编号。例如,SIGINT表示键盘中断信号。
- handler:表示信号处理程序的指针。它可以是一个指向函数的指针,也可以是某些特定的常量。我们也可以自定义handler。 - 如果handler的值为SIG_IGN,表示忽略对该信号的处理。- 如果handler的值为SIG_DFL,表示使用默认的信号处理方式。
我们在对第二参数进行解释一下。在上文中也提到过信号的处理方式:1、默认。2、忽略。3、自定义捕捉。当我们传入自定义函数时,该信号就会进行自定义捕捉。
下面是一个详细的示例使用signal函数来验证ctrl + c 对应 (2)SIGINT信号 :
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> void signal_handler(int signum) { printf("Received signal: %d\n", signum); } int main() { // 设置信号处理函数 signal(SIGINT, signal_handler); printf("Signal handling example. Press Ctrl+C to send a SIGINT signal.mypid:%d\n",getpid()); // 进入一个循环,在循环中不进行任何操作,等待信号发生 while(1) { sleep(1); } return 0; }
在这个示例中,我们定义了一个信号处理函数signal_handler,该函数在收到信号时会被调用,并打印接收到的信号编号。
接下来,在主函数main中,我们通过调用signal函数来设置对SIGINT信号(即Ctrl+C)的处理方式。将signal(SIGINT, signal_handler)作为参数传递给signal函数,表示在接收到SIGINT信号时,调用signal_handler函数进行处理。
然后,我们输出一个提示信息,并进入一个无限循环,在循环中不进行任何操作,只是通过sleep函数暂停一秒钟,等待信号的发生。
当我们在运行程序时,按下Ctrl+C组合键,会发送SIGINT信号。这时,由于我们设置了对SIGINT信号的处理方式为调用signal_handler函数,所以程序会输出"Received signal: 2"(2为SIGINT的信号编号)的消息,并且程序也不会终止。表示成功捕获并处理了SIGINT信号。具体如下图:
signal函数,仅仅是修改进程对特定信号的后续处理动作,不是直接调用对应的处理动作。如果后续没有任何SIGINT信号产生,signal_handler会不会被调用呢?答案是永远也不会被调用。当只有SIGINT信号产生时,才会调用signal_handler函数。
2、2 核心转储(拓展)
在Linux下,我们可通过指令:man 7 signal ,来查看信号的详细信息。如下图:
我们直观的看到,Action中有:Term、Core、Ign、Cont、Stop。在其中主要是Term、Core两种。Term 就是终止的意思。那么Core呢?Core也是有终止的意思,但是在终止进程前,还会生成一个核心转储(core dump)文件。
我们在进程等待(进程的控制(进程退出+进程等待))中提到过,但是并没有进行详细解释。具体如下图:
那么回到我们的问题:核心存储是什么呢?用来干什么的呢?我们接着往下看。
核心转储(core dump)是指在计算机系统中,当发生严重错误或异常情况导致程序无法正常运行时,系统会将程序当前的内存状态和相关信息保存到一个磁盘文件中,该文件就被称为核心转储文件(core dump file)。核心转储文件包含了程序崩溃时的堆栈信息、寄存器状态、全局变量值等关键信息。通过分析核心转储文件,可以帮助开发人员或调试人员确定程序崩溃的原因和位置,并进行问题排查和调试。
但是,在我们的云服务器上,核心存储功能是被关闭的。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。
当然,我们可先查看一下是否能够形成核心存储文件。指令为:ulimit -a。如下图:默认核心存储文件最大为0kb。是不可生成的。可以通过指令:ulimit -c 10240 ,来修改默认核心存储文件的最大值。具体如下图:
我们就行 (3) SIGQUIT信号来测试。代码如下:
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> int main() { // 设置信号处理函数 signal(SIGQUIT, SIG_DFL); // 进入一个循环,在循环中不进行任何操作,等待信号发生 while(1) { sleep(1); } return 0; }
信号(3) SIGQUIT 所对应的组合键为 ctrl+‘\’。我们来看一下运行结果:
为了让结果更加直观,我们不放创建子进程,然后用特殊的方式让子进程退出,再将子进程的退出信号和core dump 标志打印出来。具体结合下图和代码理解:
int main() { pid_t id = fork(); if(id == 0) { cout << "i am child:" << getpid()<< endl; sleep(1); int a = 100; a /= 0; exit(0); } cout<<"i am father:"<<getpid()<<endl; int status = 0; waitpid(id, &status, 0); cout << "父进程:" << getpid() << " 子进程:" << id << \ " exit sig: " << (status & 0x7F) << " is core: " << ((status >> 7) & 1) << endl; return 0; }
上述代码中有一个除0错误。而它发生时,会产生(8)SGIGFPE 信号,也会发生核心转储。运行结果如下:
我们看到了退出信号确实为8,且core dump标记为变成1。表示发生了核心转储。那么生成的核心转储文件有什么用呢?我们可通过调试,加载核心转储文件后可直接看到所对应的错误信息。指令是:core-file core.27736。具体如下图:
2、3 系统调用接口产生信号
如何理解系统调用接口产生信号呢?首先是我们用户进行系统调用接口,然后操作系统会执行对应的系统代码。其中操作系统会自动提取我们所传入的参数,再向目标进程写信号。也就是修改对应进程的位图数据结构。最后进程会进行相关的处理操作。
系统调用接口也可用于产生各种不同类型的信号。下面列举了几个常见的系统调用接口,它们可用于产生不同的信号:
- kill(pid, sig): 这个系统调用接口用于向指定进程发送SIGKILL信号。通过指定pid参数为目标进程的进程ID,通过sig参数指定要发送的信号。
- raise(sig): 这个系统调用接口用于向当前进程自身发送信号。通过指定sig参数来选择要发送的信号。
- **abort()**:SIGABRT可以被捕捉,但是捕捉之后依然会让进程终止,这就是SIGABRT的特点就像exit函数一样,abort函数总是会成功的,所以没有返回值。
- sigaction(sig, new_action, old_action): 这个系统调用接口用于设置信号处理程序。通过指定sig参数表示要设置的信号,通过new_action参数传递新的信号处理程序,通过old_action参数获取之前的信号处理程序。
下文我们也会用到系统调用接口产生相应的信号。
2、4 软件条件产生信号
当一个程序通过软件条件产生信号时,它可以通知其他程序或系统内核发生了某个特定的事件或状态的改变。以下是一个例子来详细解释这个过程:
假设我们有一个服务管理程序,该程序负责监控某个服务器上的各种服务的运行情况。服务管理程序需要检查每个服务是否正常运行,如果发现某个服务停止工作,它应该发送一个信号给系统管理员,以便及时采取措施解决问题。
下面我们来看一个用alarm产生信号。代码如下:
typedef function<void ()> func; vector<func> callbacks; uint64_t count = 0; void showCount() { // cout << "进程捕捉到了一个信号,正在处理中: " << signum << " Pid: " << getpid() << endl; cout << "final count : " << count << endl; } void showLog() { cout << "这个是日志功能" << endl; } void logUser() { if(fork() == 0) { execl("/usr/bin/who", "who", nullptr); exit(1); } wait(nullptr); } void catchSig(int signum) { for(auto &f : callbacks) { f(); } alarm(1); } static void Usage(string proc) { cout << "Usage:\r\n\t" << proc << " signumber processid" << endl; } int main(int argc, char *argv[]) { signal(SIGALRM, catchSig); callbacks.push_back(showCount); callbacks.push_back(showLog); callbacks.push_back(logUser); alarm(1); while(true) count++; return 0; }
这段代码是一个示例程序,它使用了信号处理、回调函数和进程控制相关的操作。下面对代码进行详细解释:
- 首先,定义了一个函数指针类型
func
,该类型表示一个无返回值、无参数的函数。- 创建了一个名为
callbacks
的向量(vector),用于存储回调函数。- 定义了一个名为
count
的64位整数变量,初始值为0。- 定义了三个函数:
showCount()
、showLog()
和logUser()
,分别用于显示count
的值、打印日志和查看当前登录用户。catchSig()
函数用于捕获信号,并依次调用存储在callbacks
中的回调函数。在本例中,catchSig()
会被设置成SIGALRM信号的处理函数。Usage()
函数用于显示程序的使用方法。- 在
main()
函数中,首先注册了SIGALRM信号的处理函数为catchSig()
。- 接下来,将
showCount()
、showLog()
和logUser()
这三个函数添加到callbacks
中。- 调用
alarm(1)
函数,设置一个定时器,每隔1秒钟触发一次SIGALRM信号,从而调用catchSig()
函数。- 使用一个无限循环,不断递增
count
的值。整个程序的运行过程如下:
- 注册SIGALRM信号处理函数
catchSig()
。- 将
showCount()
、showLog()
和logUser()
这三个函数添加到callbacks
中。- 调用
alarm(1)
设置定时器,1秒后触发SIGALRM信号,并调用catchSig()
函数。- 在
catchSig()
函数中,依次调用存储在callbacks
中的函数。showCount()
函数会显示当前count
的值,showLog()
函数会打印日志信息,logUser()
函数会通过创建子进程调用/usr/bin/who
命令查看当前登录用户。- 定时器再次启动,继续循环执行。
2、5 由硬件异常产生信号
除0错误就是硬件异常。包括对野指针的访问修改,也是硬件异常产生信号来终止程序的。
为什么说除0是硬件异常错误呢?所有的计算操作都是在cpu中进行的,cpu中有一个状态寄存器(对外是不可见的,也是不可被修改的),寄存器内有对应的状态标记位(溢出标记位)。OS会自动进行计算完毕之后的检测的!如果溢出标记位是1,OS里面识别到有溢出问题,立即只要找到当前谁在运行提取PID,OS完成信号发送的过程,进程会在合适的时候,进行处理即可。
如何理解野指针或者越界问题呢?
首先都必须通过地址,找到目标位置。我们语言上面的地址,全部都是虚拟地址。将虚拟地址转成物理地址。转换的过程中需要通过页表+MMU(Memory Manager Unit,硬件! ! )。在转换时,发现野指针是越界访问或者非法地址。MMU转化的时候,一定会报错!此时就会发出信号来终止程序。
** 注意:**一旦出现硬件异常,进程一定会退出吗?不一定!一般默认是退出,但是我们即便不退出,我们也做不了什么。当我们不退出时,但也并没有对异常进行任何修改,寄存器中任然保留异常。则会进入死循环报错。
三、总结
本篇文章详细解释了信号是怎么产生的。并且知道了写信号的本质就是修该进程控制块内容等等。
** 而我们还留下了一系列问题:在合适的时候会处理信号。这里的合适的时候具体是什么呢?同时信号的保存还有很多细节没有讲解。还有最后的信号处理工作也没有详解。我们会在下篇文章进行详细解释!!!感谢阅读ovo~**
版权归原作者 Ggggggtm 所有, 如有侵权,请联系我们删除。