一、Linux中的信号
1、Linux中的信号
使用kill -l查看所有信号。使用信号时,可使用信号编号或它的宏。
1、Linux中信号共有61个,没有0、32、33号信号。
2、【1,31】号信号称为普通信号,【34,64】号信号称为实时信号。
以普通信号为例,进程task_struct结构体中存在unsigned int signal变量用以存放普通信号。(32个比特位中使用0/1存储、区分31个信号——位图结构)
那么发送信号就是修改进程task_struct结构体中的信号位图。当然,有权限改动进程PCB的,也只有操作系统了。
2、进程对信号的处理
1、进程本身是程序员编写的属性和逻辑的集合;
2、信号可以随时产生(异步)。但是进程当前可能正在处理更为重要的事情,当信号到来时,进程不一定会马上处理这个信号;
3、所以进程自身必须要有对信号的保存能力;
4、进程在处理信号时(信号被捕捉),一般有三种动作:默认、自定义、忽略。
3、信号的释义
man 7 signal查看信号详细信息的命令
Trem:正常结束;Core:异常退出,可以使用核心转储功能定位错误,见本文第四节;Ign:内核级忽略。
2)SIGINT 终止信号,即键盘输入ctrl+c
3)SIGQUIT 终止信号,即键盘输入ctrl+\
6)SIGABRT 终止信号 调用abort即可收到该信号
8)SIGFPE 终止信号 除0错误即可收到该信号
11)SIGSEGV 终止信号 段错误即可收到该信号
13)SIGPIPE 终止信号 匿名管道读端关闭,写端即可收到该信号
14)SIGALRM 终止信号 alarm()函数(定时器)
17)SIGCHLD 内核级忽略信号 子进程退出时会向父进程发送该信号
18)SIGURG 继续进程(进程切换至后台运行,通过9号信号杀掉)
19)SIGSTOP 暂停进程
可以发现,有挺多信号的功能都是一样的。这是因为不同的信号,可以代表发生了不同的事件,但处理结果可以一致。
二、信号的捕捉
1、信号的捕捉signal()
SIGNAL(2)
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);//signum:被捕捉的信号编号;handler:对指定的信号设置自定义动作
handler设置为SIG_DFL表示信号默认处理方式,SIG_ING设置为忽略处理
#include <iostream>
#include <unistd.h>
#include <signal.h>
void hancler(int signo)
{
//这里写自定义内容,捕获到signo信号后即可执行自定义代码
std::cout<<"进程捕捉到信号"<<signo<<std::endl;
}
int main()
{
signal(2,hancler);//外部需要对该进程发送信号
while(1)
{
std::cout<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
外部需要对该进程发送信号,才能被signal接口捕捉。上面例子中,外部发送kill -2 PID或者键盘ctrl+c都行。
当捕捉到指定信号后,将会执行自定义函数。可用于信号功能的替换。
9号和19号信号无法被捕捉。kill -9乱杀进程,kill -19暂停进程。
2、信号的捕捉sigaction()
SIGACTION(2)
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
signum:信号;act:结构体对象;oldact:输出型参数,记录原来的act对象
struct sigaction {
void (*sa_handler)(int);//回调方法
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;//阻塞信号集
int sa_flags;
void (*sa_restorer)(void);//用于支持旧版本的sigaction函数的信号处理函数地址,一般不使用。
};
Sigaction()在成功时返回0; 在错误时返回 -1,并设置 errno。
当一个信号正在被递达执行期间,pending位图由1置0,同时该信号将被阻塞。
如果这时再接收到这个信号,发现该信号被阻塞,同时pending位图由0置1,保存这个信号。
若同一时间再接收到该信号,由于pending已存满,多余的该信号将被丢失。
当首个信号被捕捉完毕,操作系统会立即解除对该信号的屏蔽,因为pending位图对应的比特位是1,所以立即执行新的捕捉动作,同时pending位图该信号位由1清零。
这就是上图执行结果出现两次2号信号捕捉的原因。
三、信号如何产生?
1、kill()用户调用kill向操作系统发送信号
通过命令行参数模仿写一个kill命令
有一个系统调用kill,用户使用kill函数让操作系统向进程发送信号。
KILL(2)
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);//pid:目标进程的pid。sig:几号信号
成功时(至少发送了一个信号) ,返回零。出现错误时,返回 -1设置errno
mysignal.cc
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
void Usage(const std::string& proc)
{
std::cout<<"Usige:"<<getpid()<< "Signno\n"<<std::endl;
}
int main(int argc,char* argv[])//运行main函数时,需要先进行传参
{
if(argc!=3)//如果传入main函数的参数个数不为3
{
Usage(argv[0]);
exit(1);
}
pid_t pid=atoi(argv[1]);//获取第一个命令行参数,作为pid
int signo=atoi(argv[2]);//获取第二个命令行参数,作为signo
int n=kill(pid,signo);//需要发送信号的进程/发送几号信号
if(n==-1)//kill()失败返回-1
{
perror("kill");
}
while(1)
{
std::cout<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
2、test.cc
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main()
{
while(1)
{
std::cout<<"这是一个正在运行的进程"<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
2、raise()进程自己给自己发任意信号(实际上是操作系统->进程)
RAISE(3)
#include <signal.h>
int raise(int sig);//sig:信号编号
raise()在成功时返回0,在失败时返回非0。
raise(signo)等于kill(getpid,signo);
//当计数器运行到5时,进程会因3号进程退出
int main(int argc,char* argv[])//运行main函数时,需要先进行传参
{
int cnt=0;
while(cnt<=10)
{
std::cout<<cnt++<<std::endl;
sleep(1);
if(cnt>=5)
{
raise(3);
}
}
return 0;
}
3、abort()进程自己给自己发6号信号
ABORT(3)
#include <stdlib.h>
void abort(void);
函数 abort()永远不会返回
abort()等于kill(getpid,SIGABRT);
4、硬件异常产生信号
硬件异常指非人为调用系统接口等行为,因软件问题造成的硬件发生异常。操作系统通过获知对应硬件的状态,即可向对应进程发送指定信号。
4.1八号信号SIGFPE(除零错误可引发)
例如出现除0错误,操作系统将会发送8号信号SIGFPE。
此时使用signal()捕捉这个信号,就会发现8号信号一直在被捕捉。这是因为状态寄存器是由CPU进行维护的,当8号信号被捕捉,进程并没有退出,根据时间片轮转,当进程被切换/剥离至CPU时,会读取和保存当前寄存器的上下文信息,所以我们就看到了8号信号被死循环捕捉。
4.2十一号信号SIGSEGV(段错误可引发)
5、软件条件产生异常
5.1十三号信号SIGPIPE(匿名管道读端关闭,写端收到该信号)
例如匿名管道读端关闭,操作系统会向写端发送13号信号SIGPIPE终止写端。
5.2十四号信号SIGALRM(定时器)
设置alarm函数是在告诉操作系统,将在设定的时间到来时,向进程发送14号信号终止进程。
ALARM(2)
#include <unistd.h>
unsigned int alarm(unsigned int seconds);//seconds延时几秒
返回值为定时器剩余的秒数(可能会被提前唤醒)
alarm(0)表示取消之前设定的闹钟
//设置一个cnt,用于测试代码在指定时间跑了多少
void hancler(int signo)
{
//这里写自定义内容,捕获到signo信号后即可执行自定义代码
std::cout<<"进程捕捉到信号"<<signo<<" "<<cnt<<std::endl;//检测到5秒后cnt为多少
alarm(5);//循环捕捉闹钟
}
int main()
{
signal(14,hancler);
alarm(1);//定时1秒
alarm(5);//定义新的闹钟,旧闹钟会失效哦
while(1)
{cnt++;}
return 0;
}
闹钟是由软件实现的。任何一个进程,都可以通过alarm函数设定闹钟,所以操作系统需要通过先描述再组织的方式管理这些闹钟。
6、信号相关问答
所有信号产生,最终都要有操作系统来进行执行,因为操作系统是进程的管理者 。
信号的处理是否是立即处理的?见下文~
信号如果没有被立即处理,那么信号将被保存至pending位图中
一个进程在没有收到信号的时候,能否知道,自己应该对合法信号作何处理呢? 能,程序员写好了对应信号的处理方式(你没走人行道但你知道红灯停,绿灯行)
如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?操作系统直接修改进程pcb中的信号位图。
四、进程退出时的核心转储
信号旁边写着Core的信号,都可以使用核心转储功能。
1、核心转储的定义
核心转储:当进程出现异常时,将进程在对应时刻的有效数据由内存存储至磁盘。
云服务器默认关闭了核心转储。在终端输入ulimit -a显示操作系统各项资源上限;使用ulimit -c 1000允许操作系统最大设置1000个block大小的数据块。
2、核心转储的意义
将程序异常的原因转储至磁盘,支持后续调试。
五、信号的保存(位图结构)
1、相关概念铺垫
1、**信号递达(Delivery) **:实际执行信号的处理动作;
2、**信号未决(Pending)**:信号从产生到递达之间的状态
3、进程可以选择**阻塞 (Block )**某个信号。
4、信号被阻塞时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
5、阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2、信号在内核中的表示
例如signal捕获信号的流程就是通过signo编号修改handler[signo]中的函数指针指向用户自定义的信号处理方法。当收到信号时,将pending位图中对应的比特位修改为1,若block位图中没有阻塞该信号,该信号被递达时就会执行该信号的处理方法。
对于普通信号,pending位图同时间只能保存一次同个信号,若该信号处于未递达状态,后续再次收到该信号将无法被保存(丢失)。
六、信号的处理
1、再谈进程地址空间
博主首篇进程地址空间传送门:【Linux】进程地址空间
1.1用户态->内核态
1.2进程如何从用户态切换至内核态并执行内核代码
每个进程的虚拟地址空间中有一块1G大小的内核空间,通过内核级页表映射的方式找到物理内存中内核代码进行执行。
由于内核级页表中对应物理地址的映射关系是一样的,所以每个进程都可以使用相同的内核级页表,无论进程如何切换,均可使用同一张内核级页表进行映射调用。
在进行用户态->内核态的切换过程中,首先通过CR3寄存器将进程状态由用户态修改为内核态(陷入内核),在本进程的内核空间中找到物理内存中的内核代码进行执行,执行完毕后将结果返回给进程。
2、信号的捕捉流程
信号的自定义捕捉:信号在产生的时候,不会被立刻处理,而是从内核态返回用户态的时候,对信号进行处理。
进程首先因为中断、异常、系统调用陷入内核,以内核态的身份运行内核代码,通过进程控制块中的信号位图分析当前信号的处理方式。
若为自定义处理,则需要进程回到用户态去执行用户设定的handler方法。为什么进程不能以内核态的身份直接执行handler方法?这是因为进程处于内核态,权限非常高,操作系统是没有能力识别代码的逻辑的,若handler被人为植入恶意代码,原先部分没有权限的代码因为执行身份的变化而被提权,所以操作系统必须让进程先回到用户态,降低进程的权限。
执行完handler方法后,进程需要重新回到内核态去执行一些系统调用,才能回退回用户态。
3、sigset_t信号集(调库,用于处理block和pending位图中的01)
每个信号只有一个bit的未决/阻塞标志,非0即1,不记录该信号产生了多少次。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
#include <signal.h>
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
int sigemptyset(sigset_t *set);
函数sigfifillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfifillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
4、sigprocmask(调用该函数可读取或更改阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字并通过oset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
how:如何屏蔽信号集
SIG_BLOCK
set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | set
SIG_UNBLOCK
set包含了我们希望从当前信号屏蔽字解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK
设置当前信号屏蔽字为set所指向的值,相当于mask=set
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。没有手动捕捉的话,一般信号都是终止的,所以递达了,进程大概率也就寄了。
5、sigpending(获取当前进程的pending信号集)
SIGPENDING(2)
#include <signal.h>
int sigpending(sigset_t *set);//set:输出型参数,输出当前进程pending位图
sigending()在成功时返回0,在错误时返回-1。在发生错误时,将 errno 设置。
6、屏蔽信号并实时打印pending位图(运用上方三个接口)
默认情况所有的信号是不被阻塞的,如果一个信号被屏蔽了,那么这个信号不会被递达。
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>
// #define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
using namespace std;
// static vector<int> sigarr = {2,3};
static vector<int> sigarr = {2};
static void show_pending(const sigset_t &pending)
{
for(int signo = MAX_SIGNUM; signo >= 1; signo--)
{
if(sigismember(&pending, signo))
{
cout << "1";
}
else cout << "0";
}
cout << "\n";
}
static void myhandler(int signo)
{
cout << signo << " 号信号已经被递达!!" << endl;
}
int main()
{
for(const auto &sig : sigarr) signal(sig, myhandler);
// 1. 先尝试屏蔽指定的信号
sigset_t block, oblock, pending;
// 1.1 初始化
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
// 1.2 添加要屏蔽的信号
for(const auto &sig : sigarr) sigaddset(&block, sig);
// 1.3 开始屏蔽,设置进内核(进程)
sigprocmask(SIG_SETMASK, &block, &oblock);
// 2. 遍历打印pengding信号集
int cnt = 10;
while(true)
{
// 2.1 初始化
sigemptyset(&pending);
// 2.2 获取它
sigpending(&pending);
// 2.3 打印它
show_pending(pending);
// 3. 慢一点
sleep(1);
if(cnt-- == 0)
{
sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
cout << "恢复对信号的屏蔽,不屏蔽任何信号\n";
}
}
}
七、可重入函数
main函数调用insert函数向一个链表head中插入节点P1,插入操作分为两步,刚执行完第一句代码,此时硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作执行完毕后,sighandler返回内核态,再次回到用户态就从main函数继续执行刚才剩余的代码。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有P1真正插入链表中,P2这个节点谁都找不到了。发生内存泄漏。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱。像这样的函数称为不可重入函数,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
不可重入函数:调用了malloc或free,因为malloc也是用全局链表来管理堆的。 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
八、volatile关键字
优化后,通过信号自定义方法handler修改全局q,但是程序不会退出。
O3优化时:编译器认为q在main执行流中没有被修改,所以编译器对q做了优化,直接将q放在了寄存器中,这样后续执行时就不用再去内存中读取q了,提高了程序运行效率。虽然handler中修改了内存中的q,但是寄存器中的q值一直是1(寄存器中的q值是临时值,操作系统没有对其进行修改),所以会发生上图效果。
解决方法:给q加volatile关键字,让q通过内存读取而不是寄存器,保持变量q的内存可见性。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
volatile int q=1;//保持内存可见性
void handler(int signo)
{
q=0;
}
int main()
{
signal(2,handler);
while(q!=0);
return 0;
}
当程序结果与预期偏离时,可以尝试使用volatile关键字,万一就是编译器过度优化造成的程序逻辑异常呢?
九、SIGCHLD信号
1、子进程退出,会向父进程发送17号信号SIGCHLD;
2、由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
//忽略子进程发出的17号信号
signal(SIGCHLD,SIG_IGN);
sigaction(SIGCHLD,act,oldact);//act中忽略17号信号
系统默认的忽略动作和用户用signal/sigaction函数自定义的忽略 通常是没有区别的,但这里是一个特例。
虽然信号SIGCHID的默认动作也是忽略,但这个忽略是实实在在的无视了这个信号;我们手动在handler方法中使用SIG_IGN,子进程退出时发送给父进程的信号将会被父进程忽略,但子进程会被操作系统回收,这就是区别所在。
版权归原作者 蒋灵瑜的笔记本 所有, 如有侵权,请联系我们删除。