你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不
是一定要立即执行,可以理解成“在合适的时候去取”。
在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
根据 生活的例子我们可以推导出下列结论:
进程具有识别信号并处理信号的能力,远远早于信号的产生。
进程收到某种信号的时候,对于进程来说是异步的行为,所以并不是立即处理的,而是在合适的时候。
进程收到信号之后,需要先将信号保存起来,以供在“合适”的时候处理!
信号本质也是:数据!信号的发送–>往进程task_struct内写入信号数据!task_struct是一个内核数据结构,定义进程对象。内核不相信任何人,只信息自己!无论我们的信号在何时发送,本质都是在底层通过os发送的。
用户按Crt+c、这个键盘输入产生一个硬件中断,被OS获取,解释成信号,该信号为2号信号,发送给目标前台进程前台进程因为收到信号,进而引起进程退出。
#include
#include
#include
int main()
{
while(1)
{
printf("hello\n");
sleep(1);
}
return 0;
}
从这个例子可以看出,进程就是你,操作系统就是快递员,信号就是快递。
ctr+c 本质就是给进程发送2号信号
编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h
中找到 。
每个信号都有一个对应的默认处理行为,它决定了当传递信号时进程的行为。
信号默认的行为:
行为 | 行为描述 |
---|---|
Term | 默认操作是终止进程。 |
Ign | 默认操作是忽略该信号。 |
Core | 默认操作是终止进程并转储核心(参见核心(5))。 |
Stop | 默认操作是停止该过程。 |
Cont | 默认操作是,如果进程当前已停止,则继续该进程。 |
Signal Value Action Comment
──────────────────────────────────────────────────────────────────────
SIGHUP 1 Term Hangup detected on controlling terminal
or death of controlling process
SIGINT 2 Term Interrupt from keyboard
SIGQUIT 3 Core Quit from keyboard
SIGILL 4 Core Illegal Instruction
SIGABRT 6 Core Abort signal from abort(3)
SIGFPE 8 Core Floating point exception
SIGKILL 9 Term Kill signal
SIGSEGV 11 Core Invalid memory reference
SIGPIPE 13 Term Broken pipe: write to pipe with no
readers
SIGALRM 14 Term Timer signal from alarm(2)
SIGTERM 15 Term Termination signal
ps: 不能捕获、阻止或忽略信号SIGKILL和SIGSTOP。
详细可以查看 man 7 signal。点这里
ps:用户态与内核态将在下面介绍请留意!
如何证明 Ctr+c 就是给进程发送2号信号?
进程收到2号信号并在合适时间处理信号,默认的处理方式是Term,如果我们捕获信号,并执行自定义处理方法。
如何实现?
我们可以使用signal函数对2号信号进行捕捉,证明当我们按Ctrl+C时进程确实是收到了2号信号。
函数功能:sigal函数用来修改进程对信号的默认处理动作。
#include < signal.h >
void ( * signal( int signo, void ( * func)( int ))( int );
参数说明:
signo: 信号名, 如SIGINT.
func: 对应signo的信号处理函数的函数名, 这个函数没有返回值, 有一个整型参数,。这是捕捉信号的情况, 当然也可以是以下三种宏:
三个宏定义:
#define SIG_ERR (void (*)()) -1 // 错误编号
#define SIG_DFL (void (*)()) 0 // 默认动作编号
#define SIG_IGN (void (*)()) 1 // 忽略编号
返回值:
代码如下:
#include
#include
#include
void handler(int signo)
{
printf("signumber:%d\n",signo);// 打印捕获的信号编号
exit(1);
}
int main()
{
signal(2,handler);
while(1)
{
printf("hello\n");
sleep(1);
}
return 0;
}
代码运行结果:ctr+c后打印signumber:2后退出进程;
注意:signal(2,handler) 注册函数的时候,不是调用handler函数,只有当信号到来的时候,这个函数,才会被调用。handler函数必须没有返回值,并且参数为int(否则报错)。
我们对普通信号都进行捕捉,其他终端按键会产生什么信号?
#include
#include
#include
void handler(int signo)
{
printf("signumber:%d\n",signo);
}
int main()
{
int i=1;
for( i=1;i<=31;i++)
{
signal(i,handler);
}
while(1)
{
printf("hello\n");
sleep(1);
}
return 0;
}
运行结果:
信号的自定处理方法,我们还可以根据不同信号,进行对应处理
如下代码演示:
#include
#include
#include
#include
void handler(int signo)
{
switch(signo)
{
case 2:
printf("hello girl signumber:%d\n",signo);
break;
case 3:
printf("hello boy signumber:%d\n",signo);
break;
case 9:
printf("hello ..... signumber:%d\n",signo);
break;
default :
exit(1);// 方便退出我们默认退出
}
}
int main()
{
int i=1;
for( i=1;i<=31;i++)
{
signal(i,handler);
}
while(1)
{
printf("hello\n");
sleep(1);
}
return 0;
}
有些伙伴会担心进程运行后无法终止,程序运行后我们可以输入kill -9 pid
向进程发送9号信号,虽然signal可以捕抓信号,但是系统不会让9号信号被捕捉到。
注意:
1.一般而言,进程收到信号的处理方法有3种情况
a.默认动作 ( 一部分是终止自己,暂停等)
b.忽略动作是一种信号处理的方式,只不过动作就是什么也不干。
c.自定义动作我们刚刚用signal方法,就是在修改信号的处理动作:默认动作变为自定义动作 。
2.9号信号是不可被捕捉的。为什么?我们后面讲。
当程序死循环时,我们可以crt+c或者crt+\ 都能终止进程。
Crt+c
与Ctr+\
的区别是什么?
Crt+c
为SIGINT
信号Ctr+\
为SIGQUIT
号信号,查看两个信号的默认处理动作,Ctr+\
的处理动作为 Core, Ctr+c
的处理动作为Term。
Term和Core都能终止进程,但是Core在终止进程的时候会进行核心转储。
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。
什么是核心转储(Core Dump)?
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。
通过下面的代码来分析:
在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K:
其中,第一行显示core文件的大小为0,即表示核心转储是被关闭的。
我们可以通过ulimit -c size
命令来设置core文件的大小。
说明一下: ulimit命令改变的是Shell进程的Resource Limit,但myproc进程的PCB是由Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。
核心转储有什么用?
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。
下面我们写一份的错误代码进行演示,如下带所示:
#include
#include
#include
#include
int main()
{
while(1)
{
printf("runing pid:%d\n",getpid());
int a=10/0;
}
return 0;
}
运行结果:
说明一下: 事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。
如何知道是否生成了core dump 文件?
还记得进程等待函数waitpid函数的第二个参数吗:
pid_t waitpid(pid_t pid, int *status, int options);
status的作用在于status的比特位可以存储多个信息,我们关注的是低16位比特位,如图:
若进程是正常终止的,那么status的次低8位就表示进程的退出状态,即退出码。若进程是被信号所杀,那么status的低7位表示终止信号,而第8位比特位是core dump标志,即进程终止时是否进行了核心转储。
下面我们来写一份野指针问题的代码,代码运行必然会核心转储,并且我们把status的三个信息分别打印出来,代码如下:
#include
#include
#include
#include
#include
int main()
{
if (fork() == 0){
//child
printf("I am running...\n");
int *p = NULL;
*p = 100;
exit(0);
}
//father
int status = 0;
waitpid(-1, &status, 0);
printf("exitCode:%d, coreDump:%d, signal:%d\n",
(status >> 8) & 0xff, (status >> 7) & 1, status & 0x7f);
return 0;
}
运行结果:
core dump 其实就是表示进程是否进行了核心转储;
大胆猜测一下做oj题时,平台是如何知道进程崩溃了?
方式一、
kill 命令向进程发送信号,`kill -信号编号 pid `
方式二、
kill函数
实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下:
int kill(pid_t pid, int sig);
我们模拟kill命令
#include
#include
#include
#include
void Usage(char* proc)
{
printf("Usage: %s pid signo\n", proc);
}
int main(int argc, char* argv[])
{
if (argc != 3){
Usage(argv[0]);
return 1;
}
pid_t pid = atoi(argv[1]);
int signo = atoi(argv[2]);
kill(pid, signo);
return 0;
}
raise函数
raise函数可以给当前进程发送指定信号,即自己给自己发送信号,raise函数的函数原型如下:
int raise(int sig);
raise函数用于给当前进程发送sig
号信号,如果信号发送成功,则返回0,否则返回一个非零值。
例如,下列代码当中用raise函数每隔一秒向自己发送一个2号信号。
#include
#include
#include
void handler(int signo)
{
printf("get a signal:%d\n", signo);
}
int main()
{
signal(2, handler);
while (1){
sleep(1);
raise(2);
}
return 0;
}
// 每秒打印 get a signal:2
abort函数
raise函数可以给当前进程发送SIGABRT
信号,使得当前进程异常终止,abort函数的函数原型如下:
void abort(void);
abort函数是一个无参数无返回值的函数。
例如,下列代码当中每隔一秒向当前进程发送一个SIGABRT
信号。
#include
#include
#include
#include
void handler(int signo)
{
printf("get a signal:%d\n", signo);
}
int main()
{
signal(6, handler);
while (1){
sleep(1);
abort();
}
return 0;
}
与之前不同的是,虽然我们对SIGABRT
信号进行了捕捉,并且在收到SIGABRT
信号后执行了我们给出的自定义方法,但是当前进程依然是异常终止了。
说明一下: abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT
信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的。
SIGPIPE信号
SIGPIPE
是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE
信号进而被操作系统终止。
#include
#include
#include
#include
#include
#include
int main()
{
int fd[2] = { 0 };
if (pipe(fd) < 0){ //使用pipe创建匿名管道
perror("pipe");
return 1;
}
pid_t id = fork(); //使用fork创建子进程
if (id == 0){
//child
close(fd[0]); //子进程关闭读端
//子进程向管道写入数据
const char* msg = "hello father, I am child...";
int count = 10;
while (count--){
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]); //子进程写入完毕,关闭文件
exit(0);
}
//father
close(fd[1]); //父进程关闭写端
close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)
int status = 0;
waitpid(id, &status, 0);
printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号
return 0;
}
运行代码后,即可发现子进程在退出时收到的是13号信号,即SIGPIPE
信号。
alarm()函数,SIGALRM信号
alarm函数是设置一个计时器, 在计时器超时的时候, 产生SIGALRM
信号. alarm也称为闹钟函数,一个进程只能有一个闹钟时间。如果不忽略或捕捉此信号, 它的默认操作是终止调用该alarm函数的进程。
alarm函数的作用就是,让操作系统在seconds秒之后给当前进程发送SIGALRM
信号,SIGALRM
信号的默认处理动作是终止进程。
alarm函数的返回值:
例如,我们可以用下面的代码,测试自己的计算一秒能运行多少次
#include
#include
#include
#include
int count = 0;
void handler(int signo)
{
printf("signumber:%d\n",signo);
printf("count:%d",count);
exit(1);
}
int main()
{
alarm(1);
signal(SIGALRM,handler);
while (1){
count++;
}
return 0;
}
当我们程序当中出现类似于除0、野指针、越界之类的错误时,为什么程序会崩溃?本质上是因为进程在运行过程中收到了操作系统发来的信号进而被终止,
第一种情况:
CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等
第二种情况:
首先我们必须知道的是,当我们要访问一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,MMU也会记录相应的状态,当进程访问的不属于自己的空间时,虚拟地址转换物理地址会发生错误,然后将对应的错误记录下来。
OS是软硬件的管理者,OS会检测硬件的状态,例如上述的两种状态,当出现错误时,OS会向导致错误的进程发送信号。
信号产生的方式种类虽然非常多,但是无论产生信号的方式千差万别,但是最终,一定都是通过OS向目标进程发送信号的!
如何理解OS给进程发送信号?
我们发现信号的编号是有规律的,task_struct包含进程的各种属性,其中有用来保存信号位图的。给进程发信号本质就是OS给task_struct 中的信号位图写入比特位1,即完成信号的发生信号的发送(信号写入)
如图:
上述第3位为1就代表写入了3号信号,进程只需要遍历32位比特位就知道是否有收到信号。
- 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
- 信号的处理是否是立即处理的?在合适的时候
- 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
- 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
- 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
实际执行信号的处理动作称为信号递达(Delivery),其处理信号的三种方法自定义、默认、忽略
信号从产生到递达之间的状态,称为信号未决(Pending)。------> 本质是这个信号被暂存在task_struct 信号位图中
进程可以选择阻塞 (Block )某个信号。--------->
本质是OS ,运行进程暂时屏蔽指定的信号
1.该信号依旧是未决的
2.该信号不会被递达,直到解除阻塞!方可递达
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同 的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的表示示意图如下:
SIGHUP
信号未阻塞也未产生过,不需要做任何处理。SIGINT
信号产生后,正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再去除阻塞。SIGQUIT
信号未产生过,但一旦产生SIGQUIT
信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。SIG_DEL与 SIG_IGN
其实就0和1只是被强制转换了,如果是0那么OS就去处理默认动作,1就去执行忽略动作。捕捉函数signal第二个参数也可以使用该宏定义;
总结一下:
从3.2的图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。
注意:阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
函数解释:
ps:使用sigset_t类型时,需要先调用sigempty或sigfillset进行初始化。
例如:
#include
#include
int main()
{
sigset_t s;
sigemptyset(&s);// 初始化
sigfillset(&s);// 初始化
sigaddset(&s, SIGQUIT);// 3号位置置为有效
sigdelset(&s, SIGQUIT);// 3号位置置为无效
sigismember(&s, SIGQUIT);// 检查3号位置置是否有效
return 0;
}
sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:
选项 | 含义 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
返回值:若成功则为0,若出错则为-1
注意: 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:
int sigpending(sigset_t *set);
sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 下面用刚学的几个函数做个实验。程序如下:
如果我的进程预先屏蔽掉2号信号,不断的获取当前进程的pending位图,并打印显示(00000000000)然后手动发生2号信号,因为2号信号不会被抵达,所以,不断的获取当前进程的pending位图,并打印显示(01000000000)。
#include
#include
#include
void printPending(sigset_t *pending)
{
int i = 1;
for (i = 1; i <= 31; i++){
if (sigismember(pending, i)){
printf("1 ");
}
else{
printf("0 ");
}
}
printf("\n");
}
int main()
{
// 初始化
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2); // 标志2号位
sigprocmask(SIG_SETMASK, &set, &oset);// 阻塞二号信号
// 初始化,打印进程的pending图
sigset_t pending;
sigemptyset(&pending);
while (1){
sigpending(&pending); // 获取未决图
printPending(&pending);// 打印未决图
sleep(1);
}
return 0;
}
运行结果:
不要只认为有接口才算是system call,也要意识到:OS也会给用户提供,数据类型,配合系统调用来完成;pending图的修改交给OS,handle图当用户调用signal时,OS间接的修改handle图。
信号发送后,进程会在“合适”的时候进行处理。因为信号的产生是异步的,当前进程可能正在做更重要的事情。信号延时处理(取决于OS和进程)。
什么是 “合适”的时候?
结论:从内核态切换会用户态的时候进行信号检测和信号的处理。
感性理解:
因为信号被保存在task_struct 中,pending 位图里面,什么时候处理其实就是什么时候进行检测pending位图,如果pending位图有信号就进行递达(默认,忽略,自定义)
感性理解两种状态:
内核态:执行OS的代码和数据时,计算机所处的状态就叫做内核态。OS的代码的执行全部都是在内核态。
用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码全部都是在用户态执行的!
用户态与内核态的区别?
主要区别:在于权限
实际我们在写代码时,我们不断的在用户态与内核态间进行切换,其中最典型的表现就是系统调用,我们不仅要进入到内核还要从用户态转变成内核态。
结论:用户调用系统函数的时候,除了进入函数,身份也会发生变化,用户身份变成内核身份。
较为理性的认识两种状态:
用户的身份是以进程为代表的
进程地址空间里有内核空间与用户空间,用户空间里的内容都会通过用户级页表映射到物理内部内存。用户的数据和代码一定要被加载到内存中,os的数据和代码也要加载到内存中。
OS的代码是怎么被执行到的呢?假设只有一个CPU
操作系统启动后也会给操作系统开辟一个内核级页表,内核页表在整个操作系统中只有一份,每个进程的地址空间都有1个G的内核空和3个G的用户空间,每个进程要访问OS代码必须,通过同一个系统级页表进行映射,所以内核页表被所有进程共享。
内核页表被所有进程共享,那么就一定都能访问内存数据吗?
我们需要一个权限或者身份认证,来验证当前你的进程是属于那种工作模式,这种工作模式,在我们的进程里面有相关的数据进行标识,这个标识会被加载到CPU里面,在我们CPU里有一个寄存器叫做CR3,如果为0的话就是内核模式,如果为3的话就普通用户模式,换句话说进程在执行时它是怎么知道是用户态还是内核态完完全全的就是去查找CR3。具体是怎样的我们不管,肯定是有方法的,并且数据会在进程的上下文中保存,进程在此被调度时,把上下文加载到CPU中,想要判别进程的身份继续判别CR3即可。
结论:进程具有了地址空间是能够看到用户和内核的所有内容的,但不一定能访问,这要由用户态和内核态决定。进程无论如何切换都能访问同一个OS。
CPU内有寄存器保存了当前的状态
用户态使用的是,用户级页表,只能访问用户数据和代码
内核态使用的是,内核级页表,只能访问内核级的数据和代码
如何理解系统调用?
OS给进程提供了一个方法(接口),在你实际访问该接口时,OS系统就可以直接把你的身份切换成内核态,这又是通过另一种技术,终端还有汇编int80h直接陷入内核进行身份切换,函数的实现没有,但是我们有地址,然后通地址空间和页表找到函数。
结论:所谓的系统调用就是进程的身份转化成为内核,然后根据内核页表找到系统函数。所以在大部分情况下,实际上OS都是可以在进程的上下文中直接运行的。例如:进程CPU时间片到了,进程会去执行OS的代码和数据。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
为何一定要切换成为用户态,才能执行信号捕捉方法?
理论是可以的,OS不相信任何人!OS因为身份特殊,不能直接执行用户的代码!例如:如果自定义捕捉handler函数里写了一些恶意代码例如 rm -rf / ,我们不能保证它一定可以,但是这会极大的威胁OS的安全,所以OS不让你这么干,执行用户代码必须先切换成用户态;
记忆技巧
每个红圈代表切换一次,绿色圆圈交汇点用来检查信号,如果有信号并且是自定义的那么需要切换用户态执行,否则无需切换执行完后切户成用户态返回到主控制流。
捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。
参数说明:
其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:
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的成员:
1.sa_handler,存储处理函数地址
赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
2.sa_sigaction
3.sa_mask
首先需要说明的是,当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。
4.sa_flags
5.选学
代码演示:
#include
#include
#include
#include
struct sigaction act, oact;
void handler(int signo)
{
printf("get a signal:%d\n", signo);
sigaction(2, &oact, NULL);// 恢复原来处理动作
}
int main()
{
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));//初始化化
act.sa_handler = handler;// 赋值自定义函数地址
act.sa_flags = 0;
sigemptyset(&act.sa_mask);// 不附加阻塞信号,默认阻塞当前处理信号
sigaction(2, &act, &oact);// 捕捉信号
while (1){
printf("I am a process...\n");
sleep(1);
}
return 0;
}
运行过程:
第一次crtl+c、信号处理时恢复原先处理动作,
第二次crtl+c、默认处理信号。终止进程
代码二、
捕捉2号信号并屏蔽20号信号,按crtl+c后,运行自定义函数,并且在这期间对2号信号进行阻塞,并且我们设置了20号信号也被阻塞,那么在此使用按键发生信号时,2号信号与20号信号不会被抵达,也就是进程不断的在打印get a signal 2 语句。
#include
#include
#include
#include
struct sigaction act, oact;
void handler(int signo)
{
while(1)
{
printf("get a signal:%d\n", signo);
sleep(1);
}
}
int main()
{
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler;
act.sa_flags = 0;
sigaddset(&act.sa_mask,20);
sigaction(2, &act, &oact);
while (1){
printf("I am a process...\n");
sleep(1);
}
return 0;
}
为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。
例如,下面代码中对SIGCHLD信号进行了捕捉,并将在该信号的处理函数中调用了waitpid函数对子进程进行了清理。
#include
#include
#include
#include
#include
void handler(int signo)
{
printf("get a signal: %d\n", signo);
int ret = 0;
while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){
printf("wait child %d success\n", ret);
}
}
int main()
{
signal(SIGCHLD, handler);
if (fork() == 0){
//child
printf("child is running, begin dead: %d\n", getpid());
sleep(3);
exit(1);
}
//father
while (1);
return 0;
}
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
例如,下面代码中调用signal函数将SIGCHLD信号的处理动作自定义为忽略。
#include
#include
#include
#include
int main()
{
signal(SIGCHLD, SIG_IGN);
if (fork() == 0){
//child
printf("child is running, child dead: %d\n", getpid());
sleep(3);
exit(1);
}
//father
while (1);
return 0;
}
如下图我们以单链表插入为例,
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函
数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从
sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步
之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的: