目录
1. 进程信号概念
2. 信号的产生方式
2.1. 键盘产生
2.2. 调用系统函数向进程发信号
2.3. 由软件条件产生信号
2.4. 程序崩溃产生信号
3. waitpid接收信号
3.1. core dump
3.2. status获取信号
4.信号产生
4.1. 信号产生
4.2. sigset_t
4.3. sigprocmask
4.4. sigpending
5. 信号发送后
5.1. 用户态与内核态
5.2. 信号捕捉
5.2.1 signal、sigaction
5.3. volatile
5.4. SIGCHLD信号
1. 进程信号概念
信号是进程之间事件异步通知的一种方式,属于软中断,本质也是数据 。
信号是给进程发的,进程在收到信号后,会在合适的时候执行对应的命令。
进程具有识别信号并处理信号的能力。进程收到信号,不一定会立即处理信号,在合适的时候处理,信号保存在进程PCB中。
信号是操作系统发送给进程的。
查看信号
kill -l
linux中共有62个信号,前31个为普通信号,34到64为实时信号(不学习)。
以前,我们在使用ctrl c 结束进程时,本质是向指定进程发送2号信号。
验证:
使用接口
signal:捕捉信号,修改进程对信号的默认处理动作(具体原理后面文章后面讲)
#include <signal.h>
typedef void (*sighandler_t)(int); // 函数指针
sighandler_t signal(int signum, sighandler_t handler);
代码:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int signo)
{
printf("get a signal:signal no:%d, pid: %d\n", signo, getpid());
}
int main()
{
signal(2, handler); // 通过signal捕捉信号,对2号信号的处理动作改成自定义的方式,且只有信号到来的时候这个信号才会被调用
while(1)
{
printf("hello world!, pid:%d\n", getpid());
sleep(1);
}
return 0;
}
运行代码,然后一直按键盘ctrl+c,尝试终止进程,但是由于我们改变了2号信号的处理方式,这里不会终止进程,只能通过其他信号终止。
键盘可以产生信号,键盘产生的信号只能用来终止前台进程,后台进程可以使用命令 kill -9 pid 杀掉。
9号信号不可被捕捉(自定义)。
一般而言,进程收到信号的处理方式有三种:
默认动作:一部分是终止自己,暂停等。
忽略动作:是一种处理方式,只不过动作就是什么也不干。
自定义动作(捕捉信号):即修改信号的默认处理动作。
2. 信号的产生方式
2.1. 键盘产生
键盘产生信号上面已经验证了这里就不再验证。
2.2. 调用系统函数向进程发信号
系统调用接口:
kill:发送一个信号给其他进程
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
raise:给自己发送信号
#include <signal.h>
int raise(int sig);
例如:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>
#include<sys/types.h>
static void Usage(const char* proc)
{
printf("./test signo who\n");
}
int main(int argc, char* argv[])
{
if(argc != 3) // 判断命令行参数
{
Usage(argv[0]);
return 1;
}
int signo = atoi(argv[1]);
int who = atoi(argv[2]);
kill(who, signo); // 向指定进程发送信号
return 0;
}
然后在命令行上产生一个sleep进程,然后运行test程序,向该进程发送9号信号结束该进程。
2.3. 由软件条件产生信号
通过某种软件(OS),来触发信号发送,系统层面设置定时器,或者某种操作而导致条件不就绪的场景。
例如在进程间通信中:当读端不读,而且关闭了读端fd,但是写端一直在写,最终写进程会收到sigpipe(13)信号。
例如:
alarm:一定秒数后向进程发送14号信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 返回值为0或还剩多长时间闹钟结束
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<stdlib.h>
void handler(int signo)
{
printf("get a signal:signal no:%d\n", signo);
exit(0);
}
int main()
{
int i = 1;
for(i = 1; i <= 31; ++i)
{
signal(i, handler);
}
alarm(3); // 3秒后向进程发送14号信号
while(1)
{
printf("I am a proccess\n");
sleep(1);
}
return 0;
}
2.4. 程序崩溃产生信号
在Windows和Linux下进程崩溃的本质,是进程收到了对应的信号,然后进程执行信号的默认处理动作(杀死进程)
使用下面这段代码,显然可以看出这里对空指针进行了解引用操作,这里肯定会发生程序错误。
#include<stdio.h>
int main()
{
while(1)
{
int *p = NULL;
*p = 10;
sleep(1);
}
return 0;
}
确实从从结果中也可以看到出现了段错误(segmentation fault)
再来对信号捕捉一下,看看这个错误是哪个信号?
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int signo)
{
printf("get a signal:signal no:%d, pid: %d\n", signo, getpid());
sleep(1);
}
int main()
{
int i = 1;
for(i = 1; i <= 31; ++i)
{
signal(i, handler);
}
while(1)
{
int *p = NULL;
*p = 10;
sleep(1);
}
return 0;
}
这里的硬件异常的本质是:程序中对空指针或者野指针的解引用,会去访问这块内存,而在页表中没有映射关系,相对应的硬件MMU就会出现异常。
软件上面的错误,通常会体现在硬件或者其他软件上!
总结:
信号产生的方式虽然不同,但是最终一定都是通过OS向目标进程发送的信号!
由于收到信号后可能不会立即执行对应操作,在Linux内核中使用变量会保存信号。
进程中,采用位图来标识进程是否收到信号。
所以OS发送信号的本质是向指定进程的task_struct中的信号位图写入比特为1,所以信号的发送也可称为信号的写入。
3. waitpid接收信号
在Linux中,当一个进程退出的时候,它的退出码和退出信号都会被设置。
当一个进程异常的时候,进程的退出信号会被设置,表明当前进程的退出原因。
3.1. core dump
如果必要,操作系统会设置退出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘中,还会记录程序在哪里异常,方便后期调试。
在云服务器上,将数据转储到磁盘上的功能默认是被关掉的。
查看:ulimit -a
打开该功能:ulimit -c 空间大小
如果现在运行,上面会导致程序崩溃的代码,core dump 就会起作用了,并在当前目录下形成core文件:
而在gdb调试时,通过使用dump保存的文件,就会给出崩溃原因和在哪里崩溃:这种调试方式称为事后调试。
不一定所有的退出信号都会被core dump 例如:9号信号。 ?
3.2. status获取信号
在进程控制章节,我们讲过waipid系统调用的参数status的低8位保存的是进程退出时的信号。
来验证一下:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>
#include<sys/types.h>
int main()
{
if(fork() == 0)
{
while(1)
{
printf("I am child hahaha\n");
int a = 10;
a /= 0;
}
}
int status = 0;
waitpid(-1, &status, 0);
printf("exit code: %d, exit sig: %d, core dump: %d\n", (status>>8)&0xFF, status&0x7F, (status>>7)&1);
return 0;
}
4.信号产生
4.1. 信号产生
信号在产生时可能存在一下几种情况:
-
实际执行信号的处理动作称为信号递达(即包括上面提到的三种处理方式:自定义捕捉、默认、忽略)。 -
信号从产生到递达之间的状态,称为信号未决(即信号被暂存在task_struct信号位图中)。 -
进程可以选择阻塞某个信号(本质进程暂时屏蔽指定信号)。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
信号在内核中的表示:
pending:保存的时已经收到,但是还没有被递达的信号。
OS发送信号的本质:修改目标进程的pending位图。
block:状态位图,表示哪些信号不应该被递达,直到解除阻塞。
handler:函数指针数组,每个信号的编号就是该数组下标,里面放的是默认、忽略、自定义处理放式的函数指针。
阻塞信号集也叫做当前进程的信号屏蔽字,这里的“屏蔽”应该理解为阻塞而不是忽略 。
4.2. sigset_t
这是操作系统设置的类型,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态, 。
sigset_t类型的变量不能单独使用,必须要配合特定的系统调用接口使用。
信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set); // 将信号集所有的位清0
int sigfillset(sigset_t *set); // 初始化位图,将信号集所有的位置为1
int sigaddset (sigset_t *set, int signo); // 添加信号到信号集
int sigdelset(sigset_t *set, int signo); // 从信号集中删除信号
int sigismember(const sigset_t *set, int signo); // 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
4.3. sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集) 。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
//返回值:若成功则为0,若出错则为-1
// set:输入型参数
// oset:输出型参数
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
how参数的可选值 :
SIG_BLOCK:将set中的信号添加到信号屏蔽字中
SIG_UNBLOCK:将set中的信号从信号屏蔽字中解除阻塞
SIG_SETMASK:将信号屏蔽字设置为set
演示代码:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
int main()
{
sigset_t set, oset;
sigemptyset(&set); // 初始化
sigemptyset(&oset);
sigaddset(&set, 2); // 向set中添加2号信号
sigprocmask(SIG_BLOCK, &set, &oset); // 将set中的信号阻塞
while(1)
{
printf("hello world!\n");
sleep(1);
}
return 0;
}
代码中将2号信号阻塞了,那么运行代码后,发送2号信号会被阻塞。
4.4. sigpending
该系统调用不对pending表修改,而仅仅是获取进程的pending位图。
#include <signal.h>
int sigpending(sigset_t *set); // 参数为输出型参数
演示代码:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
void show_pending(sigset_t *set)
{
int i = 1;
for(i = 1; i <= 31; ++i)
{
if(sigismember(set, i))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
void handler(int signo)
{
printf("2号信号已被递达,已经处理完成!\n");
}
int main()
{
signal(2, handler); // 捕捉2号信号
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_BLOCK, &set, &oset); // 阻塞2号信号
sigset_t pending;
int count = 0;
while(1)
{
sigemptyset(&pending);
sigpending(&pending); // 获取pending位图
show_pending(&pending); // 打印pending位图
sleep(1);
count++;
if(count == 10) // 10秒后解除2号信号的阻塞
{
sigprocmask(SIG_SETMASK, &oset, NULL); // 恢复2号信号
printf("2号信号恢复,可以被递达!!!\n");
}
}
return 0;
}
5. 信号发送后
信号什么时候被处理?
当进程从内核态返回到用户态的时候,进行信号检测并处理信号。
5.1. 用户态与内核态
用户态:用户代码和数据被访问或者执行的时候,所处的状态。自己写的代码全部都是在用户态执行。
内核态:执行OS的代码和数据时,进程所处的状态。OS的代码的执行全部都是在内核态执行(例如系统调用)。
主要区别:权限大小,内核态权限远远大于用户态。
用户态使用的是用户级页表,只能访问用户数据和代码;内核态使用的是内核级页表,只能访问内核数据和代码。
CPU内有寄存器保存了当前进程的状态。
所谓系统调用:就是进程的身份转化成为内核,然后根据内核页表找到对应函数执行。
5.2. 信号捕捉
信号捕捉本质是修改handler表中的内容。
内核实现信号捕捉的过程大致是下图这样:
上图可简化抽象为:
5.2.1 signal、sigaction
signal方法文章开始已经讲解,这里不再演示。
#include <signal.h>
typedef void (*sighandler_t)(int); // 函数指针
sighandler_t signal(int signum, sighandler_t handler);
sigaction:类似signal方法,捕捉信号,自定义信号
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
// 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);
};
//当某个信号的处理函数被调用时, 内核自动将当前信号加入进程的信号屏蔽字, 当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时, 如果这种信号再次产生, 那么它会被阻塞到当前处理结束为止(即同一个信号不能被嵌套使用)。
//如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号, 则用sa_mask字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。
//sa_flags字段包含一些选项, 本章的代码都把sa_flags设为0, sa_sigaction是实时信号的处理函数, 本章不详细解释这两个字段
演示代码:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<string.h>
void handler(int signo)
{
printf("get a signo: %d\n", signo);
}
int main()
{
struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_handler = handler;
// 本质是修改当前进程的handler函数指针数组的特定内容
sigaction(2, &act, NULL); // 捕捉2号信号
while(1)
{
printf("hello world!\n");
sleep(1);
}
return 0;
}
5.3. volatile
volatile 作用:
保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
这句话是什么意思呢?我们用下面这段代码来解释:
#include<stdio.h>
#include<signal.h>
int flag = 0;
//volatile int flag = 0;
void handle(int signo)
{
flag = 1; // 将flag改为1
printf("change flag 0 to 1!\n");
}
int main()
{
signal(2, handle);
while(!flag); // flag为0时死循环,flag为1时退出循环
printf("该进程正常退出!\n");
return 0;
}
正常编译情况下,该程序是死循环的,如果向进程发送2号信号,循环退出,进程结束。
但是如果,在编译时加上优化,例如gcc -O3 选项,会对该程序进行优化
优化过程:
首先,flag是个全局变量,会为他在内存上开辟空间,并且while循环条件的判断室友CPU完成的。
在main函数中的while循环中,编译认为没有地方会对flag变量进行修改,那么它就将flag变量直接放入CPU的寄存器中,下一次循环时将不会再从内存中寻找flag变量加载到CPU的寄存器上进行判断,而是直接在CPU的寄存器中判断flag。导致内存上的flag发生改变时,寄存器中的flag不会改变,所以程序的运行结果可能就会出现问题。
所以就算向进程发送了2号信号,进程也不会终止:
在flag变量前加上volatile就可防止这种情况的发生。
5.4. SIGCHLD信号
子进程在退出时其实会向e'eee父进程发送SGCHLD信号,表示自己退出了。
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<stdlib.h>
void handle(int signo)
{
printf("get a signo: %d\n", signo);
}
int main()
{
signal(SIGCHLD, handle);
pid_t pid = fork();
if(pid==0)
{//child
int count = 5;
while(count--)
{
printf("I am child, running!\n");
sleep(1);
}
exit(0);
}
while(1);
return 0;
}
并且,现在在子进程退出时,我们可以不用使用父进程等待子进程;
而可以直接将SIGCHLD信号捕获,将它的处理方式改为忽略:
signal(SIGCHLD, SIG_IGN); // 显式设置忽略17号信号,当子进程退出后,自动释放僵尸进程
|