> 技术文档 > linux信号的捕捉

linux信号的捕捉

目录

信号的捕捉

用户态和内核

信号处理流程

sigaction

硬件中断

时钟中断

软件中断(软中断)

理解用户态和内核态

可重入函数

volatile关键字

SIGCHLD信号


信号的捕捉

用户态和内核态

内核态是CPU可以执行操作系统内核代码的模式,具有最高的权限级别,可以直接访问操作硬件。

用户态是普通应用程序运行的模式,权限受限,不能直接访问操作硬件。

用户态如何切换到内核态?

系统调用(如调用open,write,fork函数等,这里指软中断),中断(如外部设备输入),异常(如先前的除0错误和野指针,缺页异常等)。

理解用户态和内核态:

每个进程都拥有独立的虚拟地址空间(Virtual Address Space),而“进入内核”本质上是从进程的用户态虚拟地址空间切换到内核态虚拟地址空间的过程。

信号处理流程

解释:

1.

程序以用户态跑起来,当执行到某条指令时,因为中断(如外部设备输入),异常(先前的除0错误和野指针,缺页异常),或系统调用,程序就会陷入内核态,此时OS就会检查进程当中的block,pending以及hanlder表(也就是做信号检查,会调用do_signal()函数),如果信号收到了且没有阻塞,如果是忽略,由内核态返回用户态继续执行后续代码,如果是默认,执行动作(比如杀掉进程等动作,此时是内核态,权限足够!),如果是自定义捕捉,由内核态跳转到用户态,执行自定义捕捉函数,执行完毕,再次返回内核态(动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态),如果此时没有信号要执行了,最终会由内核态再次返回用户态到上次中断的地方继续向后执行。

2.

问题:为什么自定义捕捉的时候,要返回到用户态去执行自定义捕捉?

因为如果不返回用户态,而是以内核态执行自定义捕捉,如果用户在自定义捕捉的函数里面写非法程序,而此时内核态是最高权限,所以必须要返回用户态去执行自定义捕捉。

3.

要进行4次身份切换。

4.任何进程都会不可避免地进入内核态,哪怕没有系统调用、中断、异常等,仍然会进入,因为它要被调度,只有内核态才能访问内核空间的数据结构。

sigaction

  • sigaction函数可以读取和修改与指定信号相关联的处理动作。调⽤成功则返回0,出错则返回-1。 signo是指定信号的编号。若act指针⾮空,则根据act修改该信号的处理动作。若oact指针⾮空,则 通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
  • 将sa_handler赋值为常数SIG_IGN传给sigaction表⽰忽略信号,赋值为常数SIG_DFL表⽰执⾏系统 默认动作,赋值为⼀个函数指针表⽰⽤⾃定义函数捕捉信号,或者说向内核注册了⼀个信号处理函 数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以⽤同⼀ 个函数处理多种信号。显然,这也是⼀个回调函数,不是被main函数调⽤,⽽是被系统所调⽤。

解释:

当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么它会被阻塞到 当前处理结束为⽌。如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀ 些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。sa_flags字段包含⼀些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函 数。

也就是说:

某个信号正在处理,用户又发送了相同信号,后续发送的相同信号都会被阻塞,当第一次处理完成后,阻塞解除,当发送的是别的信号,可以设置sa_mask参数也将别的信号屏蔽!

如果后续发送的相同信号不会被阻塞,那么就会一直递归调用自定义捕捉函数。

所以,OS不允许连续执行同一个信号。

代码使用:

void handler(int signum){ std::cout << \"hello signal: \" << signum <= 1; i--) { if(sigismember(&pending, i)) std::cout << \"1\"; else std::cout << \"0\"; } std::cout << std::endl; sleep(1); } exit(0);}int main(){ struct sigaction act, oact; act.sa_handler = handler; //初始化 sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, 3);//将3号信号添加进来 sigaddset(&act.sa_mask, 4);//将4号信号添加进来 act.sa_flags = 0; // 对2号信号进行了捕捉, 2,3,4都屏蔽 sigaction(SIGINT, &act, &oact); while(true) { std::cout << \"hello world: \" << getpid() << std::endl; sleep(1); } return 0;}

硬件中断

问题:OS怎么知道键盘上面有数据?OS是通过对键盘周期性的检测吗?那有那麽多的外设,OS都需要对它们进行轮询吗?

不,OS不需要对外设进⾏任何周期性的检测或者轮询,而是通过硬件中断。

1.

当某个外设就绪,这个外设就会发送中断号给中断控制器(中断号是用来体现哪个外设发送的信号),中断控制器就会通知cpu,cpu就会访问中断控制器拿到中断号,此时,cpu就知道了哪个外设就绪了,注意,这块是硬件上实现的。

2.

cpu拿到中断号后,就会在OS中,利用中断号,在中断向量表(也叫IDT,本质上是个函数指针数组,中断号就是下标)中,找到对应的处理方法,执行它(比如处理键盘)。注意,这块是软件上实现的。

3.

我们知道cpu无时无刻都在运行,比如,当它拿到中断号时,cpu在跑某个进程,它会先对这个进程做现场保护,将进程的数据,代码,属性等暂存到寄存器中,然后再去执行中断后续动作,执行完毕之后,恢复现场,继续之前的工作。

4.

至此,OS不在关注外设是否就绪,而是外设准备好,会通知OS,由外部设备触发的,中断系统运⾏流程,叫做硬件中断。

5.

OS说白了,就是一个进行软硬件管理的软件,在开机时,计算机会花费1分钟或几分钟将OS嵌入内存,而中断向量表就是操作系统的⼀部分,启动就加载到内存中了,什么数据结构,内核系统调用,中断向量表最终都会被进程映射到内核空间区。

时钟中断

问题:当没有中断时,OS在做什么?什么都不做吗?

没错,是暂停的,cpu会以固定的频率向OS发送特定的中断号(比如1ns),叫做时钟中断。一般时钟源集成至cpu内部,且它永不停歇。

1.

以进程调度举例,cpu会以固定的频率发送特定的中断号,OS就会对进程调度,当进程时间片(task_struct中的counter变量就是时间片)耗尽,OS就会将这个进程从cpu上剥离下来,进程去调度下一个进程,至此,操作系统不就在硬件的推动下,⾃动调度了。

2.

至此,操作系统⾃⼰不做任何事情,需要什么功能,就向中断向量表⾥⾯添加⽅法即可.操作系统的本质:就是⼀个死循环!不断地接收中断号来进行进程调度,内存管理,文件管理等等。

这样,操作系统,就可以在硬件时钟的推动下,⾃动调度了。

软件中断(软中断)

我们先理解之前出现的除0,野指针,指针重复释放。

1.

cpu执行代码进程调度,碰到某行程序错误,以除0为例,cpu寄存器就会溢出,也可以说是硬件出问题了,cpu就会发送中断号,用中断号去访问中断向量表中对应的方法,同时给进程发送信号。

2.

除0,野指针,指针重复释放,缺页异常都是由cpu内部触发的中断,内部寄存器异常,这种称之为异常,但其底层都是中断,所以缺页异常,也叫缺页中断。

底层处理异常代码:

不管是异常还是硬件中断,其实都是被动的中断,有没有主动的中断

有的,先来讲讲系统调用。

1.

其实操作系统中都有一张系统调用指针表,其实就是一个函数指针数组,每个系统调用都有一个唯一的下标,称之为系统调用号,需要调用哪个,我们只需要哪种系统调用号来访问它就行。

2.

软中断是cpu主动触发的一种中断,cpu内部有一系列的指令集(int 0x80或者syscall)(c/c++代码的本质其实就是指令集+数据),当用户在程序中写了接口(int xxx或syscall),cpu就会自动触发一次中断,而0x80其实就是中断号,根据中断号 0x80,CPU 从中断描述符表(IDT)中找到对应的系统调用处理函数。

3.

用户层面怎么使用系统调用?平时使用的不是sys系列的系统调用,而是例如这样open,fork,read的函数等等?

其实OS不提供任何系统调用的接口,只提供系统调用号和系统调用实现方法。像open,fork,read这种函数其实是glibc(也就是c标准库)中封装的。也就是说要使用系统调用离不开c语言,c标准库。

4.

以open函数为例,只需要将对应的系统调用号写入cpu寄存器中,然后触发软中断即可,成功调用一次函数调用。

也就是说,每调用一次函数调用,都会触发一次中断。

OS就是一个基于中断的软件,它不断地被中断信号驱动着。

程序调用系统调用(如open等函数),其实就是一次软中断,它是由软件主动触发的

理解用户态和内核态

每次系统调用(System Call)都会触发一次从用户态到内核态的切换。用户态身份没有权限执行系统调用,内核代码。

以虚拟地址空间的角度看:

1.

在执行一个调用了系统调用的函数,其实就是在内核区和用户区的不断跳转(内核区和代码区的跳转),也是用户态和内核态身份的切换。

2.

用户态:cpu以用户身份,只能访问自己的[0,3GB]

内核态:cpu以内核身份,可以访问[3,4GB],全部空间。

当因为中断,异常,系统调用陷入内核态,就会做身份转换,cpu中有个cs寄存器,它的低2位表示当前特权级(CPL),0表示内核,3表示用户,当陷入内核态时,CPL就会修改成0.

3.

页表被分为内核页表和用户页表,内核页表是来映射内核区[3,4GB]的,用户页表是来映射用户区的。

可重入函数

先看一个现象:

图:

1.

我们发现:第二次头插node2节点造成了内存泄漏。

2.

这个过程中,有main执行流,handler执行流,而insert方法被两个执行流重复进入了,这就叫做函数被重入了,insert函数被称之可重入函数,大部分函数都是不可重入函数。

如果⼀个函数符合以下条件之⼀则是不可重⼊的:

  • 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
  • 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。

一般函数中只有自己的临时变量就是可重入的。

volatile关键字

先看代码:

int flag=0;void handler(int sig){ printf(\"收到信号:%d,改全局变量:flag->%d\\n\",sig,flag); flag=1;}int main(){ signal(2,handler); while(!flag); printf(\"修改后的flag:%d\\n\",flag); std::cout<<\"process quit\"<<std::endl; return 0;}

当flag为0时,一直阻塞在循环处,收到2号信号后,会尝试修改flag值,修改成功,循环退出,进程退出,修改失败,仍然阻塞在循环处。

现象:正常编译时,会修改,进程退出。

编译器优化后,不会修改,阻塞在循环处:

编译器优化后(-O选项,1,2,3表示进一步提升优化),我们看到改不了全局变量。

解释:

1.

cpu跑程序时,会将进程的临时数据从内存上加载到cpu内部寄存器上,去做运算(算术运算,如加减乘除和逻辑运算,如判断真否),运算完毕,再写回内存,当编译器没优化时,cpu每次运算访问数据时,都会从内存访问拿到最新值,也就是总是会拿到修改后的值。

当编译器优化时,cpu每次访问数据,不会从内存拿数据最新值,而是直接拿数据加载到寄存器中的缓存值,也就是说拿的不是最新值,这种优化,速度确实会更快!

2.

volatile的主要作用是防止编译器优化,确保每次访问变量时都从内存中读取最新值,而不是使用之前缓存的副本。

我们可以在flag前加上volatile关键字,不优化这个变量:

volatile int flag=0;//加上volatile 不优化这个变量void handler(int sig){ printf(\"收到信号:%d,改全局变量:flag->%d\\n\",sig,flag); flag=1;}int main(){ signal(2,handler); while(!flag); printf(\"修改后的flag:%d\\n\",flag); std::cout<<\"process quit\"<<std::endl; return 0;}

现象:

SIGCHLD信号

前面知道,子进程退出但是父进程并未回收它,子进程就会变成僵尸进程。

其实,⼦进程在终⽌时,OS会给⽗进程发SIGCHLD信号,所以我们可以使用SIGCHLD信号来回收子进程。

我们再次介绍一下waitpid函数:

代码:

void handler(int sig){ while(true) { pid_t n=waitpid(-1,nullptr,WNOHANG); if(n==0) { std::cout<<\"无子进程退出\"<<std::endl; break; } else if(n<0) { std::cout<<\"waitpid error\"<<std::endl; break; } else { printf(\"等待成功,pid:%d\\n\",n); } }}int main(){ signal(SIGCHLD, handler); for (int i = 0; i < 10; i++) { // 创10个子进程 pid_t id = fork(); if (id == 0) { sleep(1); printf(\"I am child,pid:%d\\n\", getpid()); //6个进程退出,其他进程一直阻塞 if (i <= 6) exit(3); else pause(); } } //父进程 while (true) { printf(\"I am father\\n\"); sleep(1); } return 0;}

因为子进程退出之后,OS会发送SIG_IGN信号,所以,循环回收子进程,且不耽误父进程运行(WNOHANG参数)。

我们也可以这样:

将 SIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终⽌时,OS会⾃动清理掉,不会产⽣僵⼫进程也不会通知⽗进程,也就是说回收工作由用户做变成OS做,但是用户拿不到子进程pid.

代码:

int main(){ //子进程退出之后,设置SIG_IGN,会自动回收 signal(SIGCHLD, SIG_IGN); for (int i = 0; i < 10; i++) { pid_t id = fork(); if (id == 0) { sleep(3); std::cout << \"I am child, exit\" << std::endl; exit(3); } } while (true) { std::cout << \"I am fater, exit\" << std::endl; sleep(1); } return 0;}

至此,信号讲完,我们下期见!