由于操作系统是进程的管理者,因此所有信号都必须经过操作系统发出。
就像打铃通知我们下课一样。
信号就是事件发生的通知,通知进程哪些事件要发生了。即便信号没发生,进程也知道如何处理这个信号,所以设置信号、捕捉信号等处理信号的动作由进程完成。
通过kill -l
指令可以查看信号的种类,其中前31个为普通信号,后31个为实时信号。普通信号和实时的区别在于前者不支持排队,可能会造成信号丢失,而后者不会。
使用man 7 signal
或cman 7 signal
可以查看其用法和产生条件:
可以看到当这个程序运行起来以后,输入除了Ctrl c
之外的其他命令是没法运行的,这是因为这个程序是前台进程
,系统在一个终端中只允许有一个前台进程。因此,当这个程序在前台运行起来以后,bash
也是一个进程,此时其处在后台的,所以接收不到我们发送的其他命令。
但是Ctrl c
指令是通过硬件的输入方式中断进程,它的本质也是通过系统向进程发送信号。
所以如果把这个程序放在后台运行,Ctrl c
指令就无法终止整个程序。此时bash
处在前台,可以接收到我们输入的其他命令。
当进程被设置为后台进程时,我们在命令行输入的消息流会和后台进程的信息混合在一起,这是因为bash
进程是在前台的,我们可以输入信息,但是显示器只有一个,被两个进程同时使用,说明他是临界资源,而这个临界资源又没有被保护,因此它的数据会发生混乱。
此时输入fg 进程路径
就可以让进程从后台回到前台。
这个接口能够捕捉并重定向一个信号的默认处理动作,使信号不执行原来的动作,而是执行我们自定义的动作
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum:对应信号的编号(普通信号编号1-31,实时信号编号34-64)
handler:回调函数(函数指针),传一个函数的地址。这个函数就是我们自定义的处理动作。
ctrl c
发送的是2号信号:
#include
#include
#include
void handler(int signo){
printf("catch a signal:%d\n",signo);
}
int main(){
while(1){
signal(2,handler);//收到2号信号就执行我们设定的动作
printf("I am running..!\n");
sleep(1);
}
}
由于我们修改了其默认处理动作,所以输入Ctrl c
是不会退出的,而是执行我们设定好的动作。
此时可以使用Ctrl \
来退出,这个指令是发送的是3号信号SIGQUIT
9号信号SIGKILL和19号信号 SIGSTOP是不能被signal函数捕捉并修改默认动作的。原因也很简单:如果所有信号都可以被捕捉,病毒可以将所有信号捕捉更改掉,系统就瘫痪了,因此需要这俩信号不能被捕捉,即系统始终拥有对进程的终止能力。
Ctrl c
产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。Ctrl c
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程来说是异步的。当进程收到信号以后,可选的处理动作有以下三种:
信号从产生到处理的过程中是有时间窗口的,在这个时间窗口之内信号是要排队的,所谓的排队是将这个信号记录或保存下来。
信号的第一种产生方式是通过键盘,常见的有以下几种:
ctrl+c 2号信号 SIGINT
ctrl+z 20号信号 SIGTSTP
ctrl+\ 3号信号 SIGQUIT
#include
#include
int kill(pid_t pid, int sig);
pid:代表目标进程的pid
sig:代表要发送几号信号
返回值:
调用成功返回0,失败返回-1
采用命令行参数的方式给进程发送信号:
#include
#include
#include
#include
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(int argc,char*argv[]){
if(argc==3){
kill(atoi(argv[1]),atoi(argv[2])); }
}
这里的argv是char *类型,而kill的参数是int型,所以需要用atoi函数进行转化。
#include
int raise(int sig);
sig:给自己发送信号的名称
返回值:
调用成功返回0,失败-1
自己给自己发送2号信号来验证这个系统调用函数:
#include
#include
#include
#include
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
signal(2,handler);
while(1){
sleep(1);
raise(2);
}
}
#include
void abort(void);
这个系统调用函数给自己的进程发6号信号,所以没有参数
并且这个函数总是能调用成功,因此也没有返回值
#include
#include
#include
#include
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
signal(6,handler);
while(1){
sleep(1);
abort();
}
}
这个信号虽然被捕捉了,但是该信号原来的动作还是被执行了。
alarm相当于系统的闹钟,在seconds秒后告诉内核,给当前进程发送一个SIGALRM信号,该信号默认处理动作是终止当前进程
#include
unsigned int alarm(unsigned int seconds);
seconds:秒数,如果seconds值为0,表示取消以前设定的闹钟,
函数的返回值仍然是以前设定的闹钟时间还余下的秒数
返回值:
0或者是以前设定的闹钟时间还余下的秒数
通过计数器来验证这个函数:
#include
#include
#include
#include
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
alarm(1);//1秒以后给自己发闹钟信号
int count=0;
while(1){
printf("count:%d\n",count++);
}
}
在一秒之中count打印了两万次左右,一秒钟到了就被14号SIGALRM
信号终止。
一个操作系统中可能有很多个闹钟,因此需要将它管理起来,因此clarm底层也会有对应的数据结构对它进行描述组织当闹钟时间到了,操作系统中也有计时器,比如有个链表,链表中存储着计时器,每隔一秒就将计时器减一,当计数器为0时,则找到对应的进程发送信号。
这种提前设置好时间点,时间点到了操作系统自动发送信号,这种发送信号的方式就叫做软件条件产生信号。
上面那个程序count被打印了两万次,如果我们只打印一次呢?
可以看到count是一个非常大的数字。
这是因为,count++操作是在CPU中进行的,之前每次输出都是I/O操作(将内存中的数据输出到显示器(外设)上),只有最后一次输出的I/O操作。
可见I/O是非常影响效率的。
硬件的异常被检测到,并且通知内核,内核会向当前进程发送适当的信号。
例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为8号SIGFPE
信号发送给进程。再比如当前进程访问了非法内存地址(野指针),MMU会产生异常,内核将这个异常解释为11号SIGSEGV
信号发送给进程。
#include
#include
#include
#include
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
signal(11,handler);
int*p;
*p=100;
return 0;
}
虚拟地址访问数据,虚拟地址需要先转换到物理地址,如果是野指针,那么在页表之个找不到对应的映射关系,这个地址转化就会发生错误。
MMU硬件转化该错误,操作系统就能识别到,然后发送信号,终止此进程。
如果是除0操作,CPU在运算时会发现这个错误(CPU的状态寄存器会记录数据有没有溢出),此时操作系统就会给进程发送8号信号。
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做
Core Dump
。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit
命令改变这个限制,允许产生core文件。 首先用ulimit
命令改变Shell进程的Resource Limit
,允许core文件最大为1024K:ulimit -c 1024
总结起来就是:因为操作系统是给进程发信号的,操作系统发现进程要崩溃了,在崩溃之前会将内存中的重要信息转储到磁盘上。并且会把core dump标志位设置为1。
云服务器由于是线上环境,这个功能是关闭的,并且生成的文件大小也是关闭的。这是因为:
- 保护服务器
云服务器默认是关闭Core Dump的,这是因为当发生段错误时,会在磁盘之中生成临时文件,而我们的服务器出现了错误,一般都是先让服务器先恢复使用再进行错误的调式。
如果服务器重启就发生错误,这样会导致生成很多临时文件,那么生成的临时文件将磁盘堆满,甚至将系统盘堆满,那么我们的系统就会出错,导致无法第一时间恢复使用。- 并不是所有的信号都需要Core Dump
我们进程退出的时候,会有一个输出型参数status,低8位中的低7位表示退出信号,第8位表示Core Dump,如果为0则表示不需要,为1表示需要。比如我们的kill -9
号信号,系统直接终止进程,是不需要调式的。
此时core file size
这个文件的大小是0,通过ulimit -c 10240
指令可以设置其大小为10240:
通过下面代码来验证,由于对空指针解引用,因此会在第5行崩溃:
这里其实可以看到从操作系统发现异常到发信号终止这个程序,这期间是有时间窗口的,进程并没有立即处理这个信号。
这个文件后面的数字代表的是形成这个core文件的进程pid。
这个文件是给调试器看的,在调试时输入core-file core文件
就可以定位到出错的位置和收到的信号了。这种调试方式叫做事后调试。
先对上面的内容进行小结:
信号是给进程发的,而进程中有task_struct
结构体,该结构体中存在一个32位的位图(默认初始化为0),如果操作系统想给该进程发送信号,只需要将该进程的位图指定的为修改成1即可:
我们的内核之中有两张结构一样的位图,来表示当前信号是否被阻塞,和是否处于未决状态,还有一个函数指针表示处理的动作信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达,信号标志才会被清除
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
在上图的例子中:
SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT3号信号会被阻塞,当前没有产生,如果信号产生了,并且解除阻塞,它的处理动作是用户自定义的处理动作。
信号也有可能处于未被阻塞(block表为0),但是未决(pending表为1)的状态,因为进程收到信号并不是立即被处理的。
综上,一个进程是通过三张表来完成一个信号处理的:前两张表是位图结构,后一张表示数组结构。
由于普通信号是由位图记录的,只能记录一次,并不能记录个数,因此会发生信号的丢失。
而实时信号是由链表保存的,实时性比较强,信号来了就会立即处理,并且链表也会被管理起来,因此不易丢失
每个信号的阻塞或未决都是由一个比特位来表示的,不是0就是1,因此未决和阻塞标志可以使用同一样数据类型sigset_t来进行存储
sigset_t被称为信号集,表示每个信号是有效还是无效
因此用户可以通过函数修改这个信号集然后填到block表或pending表中,修改这两个表。
在阻塞状态中,有效(1)、无效(0)表示是否被阻塞,阻塞信号集(block表)也被叫做当前进程的信号屏蔽字
在未决状态中,有效(1)、无效(0)表示信号是否处于未决状态
sigset_t是表示信号有效、无效状态的信号集,这个类型的实现是由系统来决定的,因此使用者只能调用下列函数的接口来操作sigset_t变量,而不是直接对其内部数据进行解释(比如,直接打印sigset_t类型变量,或者做位运算操作,这些都是没有意义的):
#include
int sigemptyset(sigset_t *set);//清空信号集,使其比特位全部置为0,表示该信号集不包含 任何有效信号
int sigfillset(sigset_t *set);//把所有比特位全部置为1,表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset (sigset_t *set, int signo);//向信号集中添加signo信号(将比特位由0设置为1)
int sigdelset(sigset_t *set, int signo);//向信号集中删除signo信号(将比特位由1设置为0)
int sigismember(const sigset_t *set, int signo);//判断signo信号是否在信号集中
所有信号的处理动作不能使用按位与按位或这样的动作(因为不同的系统sigset_t的实现方式不同),必须使用这些操作函数。
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how:一共会被设置为三种值:
1.SIG_BLOCK:set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set(老位图|新位图)
2.SIG _UNBLOCK: set包含了,当前希望从信号屏蔽字解除阻塞的信号,想当于mask=mask&~set
3.SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于mask=set
set:
新的信号屏蔽字位图
oldset:
输出型参数,老的位图,用来备份的
返回值:
成功返回0,失败返回-1
#include
int sigpending(sigset_t *set);
set:输出型参数,获取当前信号集的pending位图
返回值:
成功返回0,失败返回-1
以下面的代码演示这俩函数:
#include
#include
#include
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
void show_pending(sigset_t* pending){
int sig=1;
for(;sig<=31;sig++){
if(sigismember(pending,sig)){//如果sig信号在pending中
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
int main(){
signal(2,handler);//捕捉并自定义2号信号
sigset_t pending;
sigset_t block,oblock;
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block,2);//向block信号集中添加2号信号
sigprocmask(SIG_SETMASK,&block,&oblock);//将当前的信号屏蔽字设置为2号
int count=1;
while(1){
sigemptyset(&pending);//清理信号集
sigpending(&pending);//获取当前pending信号集
show_pending(&pending);//显示pending信号
sleep(1);
count++;
if(count==10){
//5秒后接触2号信号的阻塞
printf("recover sig mask!\n");
sigprocmask(SIG_SETMASK,&oblock,NULL);
}
}
}
操作系统向进程发出信号,进程并不是立即执行信号的,而是在合适的时候,这个合适的时候是信号被递达的时候。
一个信号递达的时间点,是在内核态切换回用户态时,进行信号相关检测的时候。
每个进程都有自己的用户区,用户区指向的映射区域是不一样的。但是内核区的代码和数据只有一份,所有进程的内核区映射是一样的,因为操作系统只有一个。
用户态:
当进程执行我们自己写的代码的时候,比如在栈上定义一个变量,写一个while循环,这时候进程就处于用户态
内核态:
当调用系统函数接口的时候,当前进程时间片到了,开辟一个空间分配内存等等都会切换到内核态。
用户态切换到内核态的三种情况:
当一个进程执行系统调用接口时,该进程的状态会由用户态变成内核态,该进程不再是当前的用户进程,而是操作系统。换句话说,当处于内核态时,进程其实只是一个外壳,本质是操作系统。
CPU如何切换用户态和内核态:
CPU中有一个标志字段,标志着线程的运行状态。用户态和内核态对应着不同的值,用户态为3,内核态为0。所以当执行系统调用时,操作系统会将CPU的执行模式由用户改成内核。
为什么需要这样来回的切换呢?
用户切内核:
因为操作系统的代码用户是没有权限去执行的,比如我们调用函数printf,但是底层实际是系统调用接口write在用户层面只能执行用户级页表映射的区域,而操作系统的内核页表映射的区域,用户态是无法访问的。
内核切用户:
内核是有权限执行用户态的代码的,如果signal信号处理动作是非法操作,而内核态的权限又很高,操作系统和其他程序就无法终止这个动作。
信号是如何检测的?
内核态(操作系统)的壳是当前进程,使用的是当前进程的的地址空间。因此可以通过当前地址空间找到当前进程,找到当前进程的PCB,然后访问block、pending位图,获取信号信息
这也就解释了当代码在上面就已经发生了段错误时,为什么还是以执行下面的输出语句呢?
这是因为,上面的代码都在用户区,发生错误的时候操作系统发送信号,信号的处理并不是及时的信号的处理,而是在内核态转化成用户态的时候被处理。
而printf
的底层也调用了操作系统接口wirte
,是在内核态时进行处理的,因此将“run here”能够被打印出来。
这个函数和signal
函数的定位是一样的,只是这个函数的功能更丰富一些。
#include
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
signum:要捕捉信号的编号
act:捕捉到信号后的重定向动作
oldact:输出型参数,保存老的信号处理动作
struct sigaction {
void (*sa_handler)(int);//重定向动作
void(*sa_sigaction)(int, siginfo_t *, void *);//实时信号
sigset_t sa_mask;//当处理某个信号时,屏蔽的信号集,默认用函数设置为0
int sa_flags;//默认设置为0
void (*sa_restorer)(void);
};
#include
#include
void handler(int signo){
printf("catch a signal: %d\n",signo);
}
int main(){
struct sigaction act,oact;
act.sa_handler=handler;
act.sa_flags=0;
sigemptyset(&act.sa_mask);//使用函数情况信号集
sigaction(2,&act,&oact);
while(1){
}
return 0;
}
可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入操作系统调度下去执行另外一段代码,而返回控制时不会出现什么错误。
不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。比如链表插入的过程中收到信号要执行另一个链表插入的动作,信号处理完以后原来的插入节点可能会丢失,导致内存泄漏。
常见不可重入函数:
在C语言中volatile关键字这篇博客中提到过volatile这个关键字
#include
#include
int quit=0;
void handler(int sig){
quit=1;
printf("quit is alrady set to:%d\n",sig);
}
int main(){
signal(2,handler);
while(!quit);//只要quit为0,取反就为真,则一直执行while循环
printf("end process!\n");
}
标准情况下,键入CTRL C
,2号信号被捕捉,执行自定义动作,修改 quit=1 , while 条件不满足,退出循环,进程退出。
gcc中指定优化级别的参数有:-O0、-O1、-O2、-O3、-Og、-Os、-Ofast。
优化情况下,键入CTRL C
,2号信号被捕捉,执行自定义动作,修改 quit=1 ,但是 while 条件依旧满足,进程继续运行!这说明 while 循环检查的quit,并不是内存中最新的quit,而是寄存器中的quit。这就存在了数据二异性的问题。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
子进程退出会向父进程发送SIGCHLD信号,此时如果父进程如果通过signal或sigaction设置忽略这个信号(signal(SIGCHLD, SIG_IGN)
),子进程终止时自动释放自己的资源,父进程不必再等待子进程(linux下可用,不保证其它系统上可用)或者自定义SIGCHLD信号处理动作,调用wait等资源释放函数,也可以完成子进程资源的释放:
#include
#include
#include
#include
#include
#include
void handler(int sig){
//wiat(NULL);//直接忽略该信号也可以实现资源的释放。
pid_t id;
while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
printf("wait child success: %d\n", id);
}
printf("child is quit! %d\n", getpid());
}
int main(){
signal(SIGCHLD, handler);
pid_t pid;
if((pid = fork()) == 0){//child
printf("child : %d\n", getpid());
printf("child running!\n");
sleep(3);
exit(1);
}
while(1){
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
SIGHUP: 当用户退出shell时,由该shell启动的所有进程将收到这个信号,默认动作为终止进程
SIGINT:当用户按下了
SIGQUIT:当用户按下
SIGILL:CPU检测到某进程执行了非法指令。默认动作为终止进程并产生core文件
SIGTRAP:该信号由断点指令或其他 trap指令产生。默认动作为终止里程 并产生core文件
SIGABRT: 调用abort函数时产生该信号。默认动作为终止进程并产生core文件。
SIGBUS:非法访问内存地址,包括内存对齐出错,默认动作为终止进程并产生core文件。
SIGFPE:在发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等所有的算法错误。默认动作为终止进程并产生core文件。
SIGKILL:无条件终止进程。本信号不能被忽略,处理和阻塞。默认动作为终止进程。它向系统管理员提供了可以杀死任何进程的方法。
SIGUSE1:用户定义 的信号。即程序员可以在程序中定义并使用该信号。默认动作为终止进程。
SIGSEGV:指示进程进行了无效内存访问。默认动作为终止进程并产生core文件。
SIGUSR2:另外一个用户自定义信号,程序员可以在程序中定义并使用该信号。默认动作为终止进程。
SIGPIPE:Broken pipe向一个没有读端的管道写数据。默认动作为终止进程。
SIGALRM: 定时器超时,超时的时间 由系统调用alarm设置。默认动作为终止进程。
SIGTERM:程序结束信号,与SIGKILL不同的是,该信号可以被阻塞和终止。通常用来要示程序正常退出。执行shell命令Kill时,缺省产生这个信号。默认动作为终止进程。
SIGSTKFLT:Linux早期版本出现的信号,现仍保留向后兼容。默认动作为终止进程。
SIGCHLD:子进程结束时,父进程会收到这个信号。默认动作为忽略这个信号。
SIGCONT:如果进程已停止,则使其继续运行。默认动作为继续/忽略。
SIGSTOP:停止进程的执行。信号不能被忽略,处理和阻塞。默认动作为暂停进程。
SIGTSTP:停止终端交互进程的运行。按下
SIGTTIN:后台进程读终端控制台。默认动作为暂停进程。
SIGTTOU: 该信号类似于SIGTTIN,在后台进程要向终端输出数据时发生。默认动作为暂停进程。
SIGURG:套接字上有紧急数据时,向当前正在运行的进程发出些信号,报告有紧急数据到达。如网络带外数据到达,默认动作为忽略该信号。
SIGXCPU:进程执行时间超过了分配给该进程的CPU时间 ,系统产生该信号并发送给该进程。默认动作为终止进程。
SIGXFSZ:超过文件的最大长度设置。默认动作为终止进程。
SIGVTALRM:虚拟时钟超时时产生该信号。类似于SIGALRM,但是该信号只计算该进程占用CPU的使用时间。默认动作为终止进程。
SGIPROF:类似于SIGVTALRM,它不公包括该进程占用CPU时间还包括执行系统调用时间。默认动作为终止进程。
SIGWINCH:窗口变化大小时发出。默认动作为忽略该信号。
SIGIO:此信号向进程指示发出了一个异步IO事件。默认动作为忽略。
SIGPWR:关机。默认动作为终止进程。
SIGSYS:无效的系统调用。默认动作为终止进程并产生core文件。
(34)SIGRTMIN ~ (64) SIGRTMAX:LINUX的实时信号,它们没有固定的含义(可以由用户自定义)。所有的实时信号的默认动作都为终止进程。