目录
- 一.信号的产生
- 1.信号的产生(预备)
- 2.异常
- (1).硬件异常
- (2).core dump
- (3).软件条件产生信号
- 二.信号的保存
- 1.信号的发送
- 2.block.pending.handler(保存)
- (1).sigset_t类型
- 三.信号的捕捉处理
- 1.什么时候捕捉
- 2.三顾进程地址空间
- 3.如何处理信号
- 4.附加知识
- 5.volatile
- 6.可重入函数
一.信号的产生
1.信号的产生(预备)
在了解信号产生之前我们先行观察一个现象:
我们先编写一个循环打印的代码,接着运行起来:
他就会像这样每隔一秒在屏幕上打印一行信息,这时候如果我们想要这个进程别打印了给我停止退出,我们常会用到ctrl+c操作:
这样这个进程就如我所愿退出了。
我们再来看一个现象:
我们在运行程序时,在指令后面加上&,这次跑起来的程序就无法被ctrl+c退出了。
其实啊:Linux中,一次登录中,一个终端,一般会配上一个bash,每一个登陆,只允许一个进程是前台进程(谁来获取键盘输入,谁就是前台进程),可以运行多个进程是后台进程。
在正常运行程序时其可以被ctrl+c退出的,因为其是前台进程,当我们带上&将其在后台运行,这时允许你在启动一个程序的同时继续使用终端会话进行其他操作。
那我们又想知道了,为什么ctrl+c能够杀掉我们的前台进程呢:本质其实是ctrl+c被进程解释为了收到了信号。(2号信号)
我们可以用kill -l指令查看信号:
共有62个信号,其中1到31称为普通信号,34到64称为实时信号。
接下来我们来学习一个信号捕捉的函数:
- signum代表的用户想要捕捉几号信号
- handler是个函数指针,代表捕捉到的信号要执行的方法(用户自定义的)
下面我们写一个演示代码捕捉二号信号并且执行我们自定义的方法:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <cstdio>
#include <cstdlib>
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo <<" pid:"<<getpid()<<endl;
}
int main()
{
signal(2,myhandler);
while(true)
{
cout << "I am a process : " << getpid() << endl;
sleep(1);
}
return 0 ;
}
需要注意的是signal函数只需要设置一次即可,代表进程再收到2号信号时执行myhandler方法
运行起来后:
我们可以看到ctrl+c并不能再让进程退出了,而是执行了我们自定义的方法:打印收到的信号和当前的pid。
那又想到了,我们是否可以把所有普通信号都捕捉了呢?届时又会发生什么呢:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <cstdio>
#include <cstdlib>
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo <<" pid:"<<getpid()<<endl;
}
int main()
{
//signal(2,myhandler);
for(int i = 1;i<=31;i++)
{
signal(i,myhandler);
}
while(true)
{
cout << "I am a process : " << getpid() << endl;
sleep(1);
}
return 0 ;
}
可以看到给这个进程再发送信号时就会执行用户自定义的方法了:那么难道说真的所有的信号都能被signal捕捉并让用户设定自定义的方法吗?
其实不是的,9号和19号信号是无法被捕捉的,这是OS为了安全设置(不能什么信号都能让用户乱搞的)
继续学习几个函数:
参数表示对pid进程发送sig号信号:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <cstdio>
#include <cstdlib>
using namespace std;
int main()
{
int cnt =0;
while(true)
{
cout << "I am a process : " << getpid() << endl;
sleep(1);
cnt++;
if(cnt==5)
{
kill(getpid(),2);
}
}
return 0;
}
如我们所料在打印五次后,kill函数向当前进程发送二号信号终止了进程。
raise函数即是向自己的进程发送sig信号:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <cstdio>
#include <cstdlib>
using namespace std;
int main()
{
int cnt =0;
while(true)
{
cout << "I am a process : " << getpid() << endl;
sleep(1);
cnt++;
if(cnt==5)
{
//kill(getpid(),2);
raise(2);
}
}
return 0;
}
和kill(getpid(),2)的运行效果一样。
abort 函数是 C 标准库中的一个函数,用于异常终止程序。调用 abort 会导致程序立即终止,且不执行任何清理操作。这里就不演示了
根据以上我们需要知道:无论信号如何产生,一定是OS发送给进程的,因为OS是进程的管理者。
2.异常
(1).硬件异常
这个我们先写一个除零错误,程序运行起来:
#include <iostream>
using namespace std;
int main()
{
int a =1;
a/=0;
return 0 ;
}
出现八号信号错误SIGFPE(浮点异常)
#include <iostream>
using namespace std;
int main()
{
int *p =nullptr;
*p=20;
return 0 ;
}
再编写一个野指针错误,运行程序:
出现11号信号段错误SIGSEGV,是我们最常见的错误。
OS之所以向我们进程发送错误信号(抛异常),是为了我们完成后续总结工作,并不是让我们解决的。
那硬件层面上的异常是如何产生的呢:
当OS根据cpu上的状态寄存器的溢出标志位,检测出代码出现异常时 溢出标志位0变1,就会向进程发送异常信号。
野指针问题即是MMU与cpu分析虚拟地址转换成物理地址发生错误。
上面我们要知道被进程包裹的异常只会影响自己不会波及OS,任何异常都只会影响进程本身。
(2).core dump
在进程等待中我们讲过子进程退出码的8到15位代表着退出状态,0到7位是终止信号,第八位是core dump标志,他标识着收到的终止信号是否为core(核心转储)。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 500;
while(cnt)
{
cout << "i am a child process, pid: " << getpid() << " cnt: " << cnt << endl;
sleep(1);
cnt--;
}
exit(0);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
cout << "child quit info, rid: " << rid << " exit code: " << ((status>>8)&0xFF) << " exit signal: " << (status&0x7F) <<" core dump: " << ((status>>7)&1) << endl;
}
}
以上是一个演示代码:
这样我们能观察到:
产生了core.pid形式的临时文件,当我们打开系统的core dump功能后,一旦进程出现异常,OS会将进程再内存中的运行信息,转储到当前目录下(磁盘),成为核心转储。通常,这个文件会包含程序的内存、寄存器状态和栈信息,帮助开发人员了解程序崩溃时的状态。
可以利用其进行事后调试。
在Linux系统中,SIGTERM是请求程序正常终止的信号,而CORE是程序崩溃时生成的转储文件。这两者都是处理和调试程序的重要工具,但它们的作用和用途是不同的。
(3).软件条件产生信号
在 Linux 下,alarm 函数是一个用于设定定时器的系统调用,通常用于在指定的时间后发送 SIGALRM 信号(14号信号)。这个函数可以帮助我们在程序中实现定时操作或超时机制,但在使用时需要注意以下几点:
- seconds 是设定定时器的时间(秒)
- 返回以前设置的定时器时间(如果有的话),或者 0(如果之前没有设置定时器)
一个简单的使用代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void settime(int signum)
{
cout<<"signum is :"<<signum<<" pid is: "<<getpid()<<endl;
alarm(2);
}
int main()
{
signal(SIGALRM,settime);
alarm(2);
while(1)
{
sleep(1);
cout<<"my pid is :"<<getpid()<<endl;
}
return 0;
}
二.信号的保存
1.信号的发送
对于普通信号而言,对于进程而言,收到哪一个信号自己有还是没有,是给进程的PCB发。
1.比特位的内容是0还是1表明是否收到
2.比特位的位置(第几个),表示信号的编号
3.所谓的“发信号”本质就是OS去修改task struct的信号位图对应的比特位。
- 普通信号:有限、固定优先级、可能丢失、处理方式预定义。
- 实时信号:数量更多、支持优先级和顺序、信号队列、可以携带附加数据。
2.block.pending.handler(保存)
首先先了解些信号的概念:
实际执行信号的处理动作称为信号递达 信号从产生到递达之间的状态,称为信号未决。 进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
信号在内核中的表示图:
- block表也是位图 比特位代表是否屏蔽(阻塞)
- pending位图表收到哪个信号。即那些已经发送给进程但尚未处理的信号。
- handler表存放在接收到信号时执行的函数
(1).sigset_t类型
sigset_t 是一种信号集类型 ,方便对三个表进行操作。
上图为其常用的函数:
#include <signal.h>
int sigemptyset(sigset_t *set);
sigemptyset 是一个用于操作 sigset_t 类型的函数,它用于初始化一个信号集合为空集。
#include <signal.h>
int sigaddset(sigset_t *set, int signum);
sigaddset 将指定的信号 signum 添加到信号集合 set 中。如果该信号已经在集合中,则集合保持不变。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
sigprocmask 是一个用于操作进程信号掩码的函数。信号掩码用于指定当前被阻塞的信号集。通过操纵信号掩码,可以控制哪些信号被阻塞,哪些信号可以被传递到进程。
- how:用于指定如何修改信号掩码,取值可以是以下三种:
SIG_BLOCK:将 set 中的信号添加到当前的信号掩码中,即阻塞这些信号。
SIG_UNBLOCK:从当前的信号掩码中移除 set 中的信号,即解除对这些信号的阻塞。
SIG_SETMASK:将当前的信号掩码设置为 set 中的信号集,即完全替换当前的信号掩码 - set:指向一个 sigset_t 类型的信号集合,表示要修改的信号集。可以为 NULL,表示不修改当前的信号掩码
- oldset:指向一个 sigset_t 类型的变量,用于存储调用函数前的旧信号掩码。如果不需要保存旧信号掩码,可以设置为 NULL。
#include <signal.h>
int sigpending(sigset_t *set);
sigpending 将当前被阻塞但尚未处理的信号存储到 set 中。这样可以检查哪些信号已经被阻塞,并且尚未处理。即pending表里面的内容
下面我们用一段代码演示下,信号的保存:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(sigset_t &pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n\n";
}
int main()
{
先对2号信号进行屏蔽 --- 数据预备
sigset_t bset, oset; //在哪里开辟的空间???用户栈上的,属于用户区
sigemptyset(&bset);
sigemptyset(&oset);
sigaddset(&bset, 2); //1.2 调用系统调用,将数据设置进内核
sigprocmask(SIG_SETMASK, &bset, &oset); //我们已经把2好信号屏蔽了吗?ok
sigset_t pending;
int cnt = 0;
while (true)
{
int n = sigpending(&pending);
if (n < 0)
continue;
// 打印pending表
PrintPending(pending);
sleep(1);
cnt++;
//2解除阻塞
if(cnt == 10)
{
cout << "unblock 2 signo" <<" pid: "<<getpid()<< endl;
sigprocmask(SIG_SETMASK, &oset, nullptr);
}
}
return 0;
}
结果与我们预想的一想,刚开是的pending表没有被阻塞但尚未处理的信号,当我们ctrl+c发送二号信号后,因为事先二号信号已经被添加到block表(即被阻塞了),所以这是pending表上第二位比特位置1,即收到2号信号但是被阻塞尚未处理的信号。五秒后再次通过sigprocmask函数接触对2号信号的阻塞。这时将处理2号信号即终止进程。
还有一点需要明确的在之前我们了解到signal捕捉不了9号和19号信号因为OS考虑到安全问题,所以这里一样也不能阻塞9号和19号信号
还有一点需要注意的:如果一个信号没有被阻塞,我们多次给进程发送这个信号时,进程在处理此信号时会暂时将此信号的block置1,即阻塞
三.信号的捕捉处理
1.什么时候捕捉
当我们进程从内核态(允许访问OS的代码和数据)返回到用户态(只允许访问用户自己的代码和数据)的时候,进行信号的检测和处理,如果有信号,就进行处理。
2.三顾进程地址空间
用户页表有几个进程,就有几份用户级页表,而内核页表只有1份,每一个进程看到的3~4GB的东西都是一样的
进程视角:我们调用系统中的方法,就是在我自己的地址空间中进行执行的。
操作系统的本质:基于时钟中断的一个死循环!计算机硬件中,有一个时钟芯片,每个很短的时间,向计算机发送时钟中断。
3.如何处理信号
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction 是一个用于设置信号处理程序的函数,相较于早期的 signal 函数,它提供了更细粒度的控制,并避免了一些 signal 函数中的缺陷。在 Linux 和其他 Unix 系统中,sigaction 被广泛使用来替代 signal
- signum:要捕捉或处理的信号编号。
- act:一个指向 struct sigaction 结构的指针,用于指定新的信号处理行为。如果这个指针是 NULL,则不会修改当前的信号处理行为。
- oldact:一个指向 struct sigaction 结构的指针,用于存储之前的信号处理行为。如果这个指针是 NULL,则不保存之前的信号处理行为
struct sigaction结构
struct sigaction {
void (*sa_handler)(int); // 信号处理函数或以下三个宏之一: SIG_DFL, SIG_IGN, SIG_ERR
void (*sa_sigaction)(int, siginfo_t *, void *); // 使用 SA_SIGINFO 标志时的信号处理函数
sigset_t sa_mask; // 在处理该信号时需要阻塞的信号集
int sa_flags; // 修改信号行为的标志
void (*sa_restorer)(void); // 已废弃,不应使用
};
目前我们只需要了解sa_handler和sa_flags即可。
sa_handler指向处理方法的函数,sa_flags信号处理程序执行期间需要被阻塞的信号集,可添加多个信号。
下面我们来用段代码演示功能:
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void PrintPending()
{
sigset_t set;
sigpending(&set);
for (int signo = 31; signo>= 1; signo--)
{
if (sigismember(&set, signo))
cout << "1";
else
cout << "0";
}
cout << "\n";
}
void handler(int signo)
{
cout << "catch a signal, signal number : " << signo << endl;
while (true)
{
PrintPending();
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact))
act.sa_handler = handler;
sigaction(2, &act, &oact);
while (true)
{
cout << "I am a process: " << getpid() << endl;
sleep(1);
}
return 0;
}
由上图我们可以总结:
- pending位图,什么时候从1->0. 是在执行信号捕捉方法之前,先清0,在调用
- 信号被处理的时候,对应的信号也会被添加到block表中,防止信号捕捉被嵌套调用(处理一个信号时会将block置1 禁止重复调用)
4.附加知识
子进程在退出时会向父进程发送17号SIGCHLD信号
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
options:控制 waitpid 行为的选项。常见选项包括: WNOHANG:如果没有子进程结束,则立即返回,不阻塞。
WUNTRACED:也返回已停止的子进程的状态。 WCONTINUED:也返回如果子进程继续执行的状态(适用于已停止的子进程)。
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void handler(int signo)
{
sleep(5);
pid_t rid;
while ((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
{
cout << "I am proccess: " << getpid() << " catch a signo: " << signo << " child process quit: " << rid << endl;
}
}
int main()
{
srand(time(nullptr));
//signal(17, SIG_IGN); // SIG_DFL -> action -> IGN
signal(17, handler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
sleep(5);
break;
}
cout << "child quit!!!" << endl;
exit(0);
}
sleep(rand()%5+3);
sleep(1);
}
//father
while (true)
{
cout << "I am father process: " << getpid() << endl;
sleep(1);
}
return 0;
}
所以在等待子进程退出时,我们可以基于信号的方式进行等待:17号信号的默认处理动作时SIG_DFL缺省—>什么都不做
5.volatile
防止编译器过度优化,保持内存可见性:
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int flag = 0;
void handler(int signo)
{
cout << "catch a signal: " << signo << endl;
flag = 1;
}
int main()
{
signal(2, handler);
//在优化条件下, flag变量可能被直接优化到CPU内的寄存器中
while(!flag); //flag 0, !falg 真
cout << "process quit normal" << endl;
return 0;
}
在默认的编译器优化程度下是正常运行的:
mysignal2:mysignal2.cc
g++ -o $@ $^ -O3 -g -std=c++11
但是我们将编译器的优化程度拉满到O3时候:
就会出现这样的情况,其原因是在编译器优化条件下, flag变量可能被直接优化到CPU内的寄存器中,代码数据中更改flag的值自然就影响不到寄存器中的flag了。
这时候就需要关键字volatile登场了:
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
volatile int flag = 0;
void handler(int signo)
{
cout << "catch a signal: " << signo << endl;
flag = 1;
}
int main()
{
signal(2, handler);
//在优化条件下, flag变量可能被直接优化到CPU内的寄存器中
while(!flag); //flag 0, !falg 真
cout << "process quit normal" << endl;
return 0;
}
这时候即使在最高程度的编译器优化下,也可以正常完成代码逻辑了。
6.可重入函数
如果一个函数,被重复进入的情况下,出错了,或者可能出错,不可入函数!否则,叫做可重入函数。