linux-信号

信号

信号的目的不是传送数据,而是通知事件是否发生,这也是它跟管道,共享内存等的区别。

ctrl+c

  • 我们前面说过ctrl+c会终止一个进程,但是我们当时不知道它的原理,实际上crtrl+c就是向一个前台进程发送一个2号信号,而2号信号的默认动作就是终止进程。
  • 一个bash同一时间只能有一个前台进程,当我们将一个进程放到前台时,bash将无法接收命令行参数。因为此时bash变成了后台进程。ctrl+c这类通过键盘发送信号也无法杀死一个后台进程。我们可以通过&将前台进程变成后台进程。也可以通过

bg/fg ./proc #进程的前后台切换

  • 如果有很多后台进程向显示器输入数据,我们发现显示器显得很乱。这是因为此时显示器变成了一个临界资源,我们没有对显示器进行保护。

  • 我们怎么证明ctrl+c发送的就是2号进程呢?我们使用signal函数,
    signal

  • signal函数第一个参数是信号的编号,第二个参数是一个函数指针,这个指针指向该号信号的处理方式。如果我们自己写一个函数,然后让signal去调用这个函数,那么我们就修改了对2号信号的处理方式。这里要说明的是,即使我们不发送信号,进程也知道收到信号该怎么做,因为执行信号的方法是提前植入的。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signum){
	printf("get signal num : %d\n", signum);
}
int main(){
	signal(2, handler); //修改对2号信号的处理方式
	while(1){
		printf("I am running........\n");
		sleep(1);
	}
	return 0;
}
  • 此时ctrl+c不再终止进程,而是向显示器输出get sugnal num 2.这说明我们ctrl+c绑定的就是2号信号。但是ctrl+c是键盘的数据,而信号是软件层面的数据,硬件不可能之间操控进程,所以这其中只有操作系统有权利这样做,因为操作系统是硬件和进程的管理者。操作系统会先收到ctrl+c的数据,然后将它解释为2号信号,然后发送给前台进程。
  • 而且我们还注意到在进程运行的任意时间我们使用ctrl+c,无论多少次,进程都会收到2号信号。这说明信号的处理是异步的,而且你可以发送无数次信号,直到你达成目的。

我们使用kill -l列举出所有的信号。

kill -l

  • 其中31到34之间没有信号,所以只有62个信号。
  • 前31个是普通信号,后31个是实时信号。我们主要研究前31个。
  • 9号信号无法被signal改变处理方式。因为9号信号是系统最强的信号,而且为了保证系统的安全一定需要一把最锋利的矛。收到9号信号就一定会干掉进程。

处理信号的方式:

  • 1,忽略该信号。
  • 2,执行默认的处理方式。(大部分都是终止进程)
  • 3,使用我们用户自定义的处理函数,要求内核在处理该信号时,切换到用户形态执行该函数。这种方式也叫做信号的捕捉(catch。)

考虑这样的情况,快递员向你发送一个信号,说你的快递到楼下了。此时你在打LOL,最后一波团。你肯定会选择打完再去楼下取快递,此时你对快递员发送的信号的处理方式就延后了,也就是说你不会立刻去处理信号,你对信号的接收和信号的处理之间还有一部分时间,在这段时间里你需要记住这个信号,记住你有快递要取。在计算机里也类似。

信号

  • 我们就这三个主题说一说。

产生信号

  • 产生信号有下面几种方式:

  • 1 :通过键盘等硬件设施,通过操作系统发送信号。ctrl+c发送的是2号,SIGINT信号。而ctr+\也可以发送3号信号,SIGQUIT,这个信号的默认动作不仅会终止进程还会core dump。

  • 2 :通过系统调用函数帮助我们发送信号。

#include <signal.h>
int kill(pid_t pid, int signo); //向pid号进程发送编号为signo的信号。
int raise(int signo); //向自己发送signo信号
//这两个函数都是成功返回0,失败返回-1.

使用

void abort(void)
使得当前进程收到6号信号终止。像exit函数一样,abort函数一定会成功,所以没有返回值。

  • 3 : 通过软件条件产生信号。即是由于软件的某些事件发生而发送的信号。例如管道中,读端先于写端退出,那么系统就会发送13号信号杀死写端进程。而我们还有一个函数,alarm,
    alarm

  • 在规定的秒后,发送14号信号,默认终止进程。像这种时间到,或者某种条件满足,由操作系统发送信号的,就叫做软件条件。

  • 4 : 硬件异常产生信号。想一下我们常说的C,C++中的错误,有空指针,越界等内存错误。而这些内存都是虚拟内存,当你使用空指针解引用或者越界时,在页表的mmu转换成物理内存时,操作系统就会从mmu这个硬件上识别错误,然后直接发送信号给进程,终止它。而你的除零错误等错误,cpu上会有标识位,操作系统接收到cpu的报错,然后就发送信号给进程。在C++等语言中的异常的捕获,try,catch等等底层都是信号。
    8号信号

  • 我们修改了8号信号的处理方式,而8号就是对除零错误的处理,默认方式是终止进程。但是我们发现,终端不停的执行handler函数,因为cpu中有上下文数据保存了当前进程的运行情况,所以错误信息一直没有清除,操作系统就会一直给进程发送8号信号,而终止进程才会清除数据。

从上面的4种信号的产生方式,我们会发现,所有的信号都必须经过操作系统的手。为什么呢?因为信号会对进程实施某些操作,甚至是杀死它,而杀死一个进程只有操作系统才有权利这样干。因为操作系统是进程的管理者。而我们的4种方法仅仅是触发了信号,是信号产生的条件。而操作系统才会真正的去执行信号。

操作系统如何发送信号给进程?一共只有31种信号,而每种信号只有发送和未发送两种状态。所以我们使用位图。在每个进程的pcb中保存一个信号位图,这个位图最多只需要31个位。如果发送了某种信号,操作系统先找到对应的pcb,然后将对应的位由0变成1即可。

core dump

  • core dump叫做核心转储,我们在windows下使用的编译器,如果出现错误,那么就会弹出一个错误窗口,告诉我们错误出现出现在哪里,什么错误,而linux也想要这个功能,就有了核心转储。当你的进程出现错误时,操作系统会发送信号强行终止进程,在终止前,操作系统会将该进程在用户空间生成的内存数据保存在磁盘中,这个文件名字通常叫做core,这就是core dump。不过为了节省磁盘资源和保护用户安全(core文件中有用户的安全信息),core dump一般是关闭的。你可以通过命令ulimit -a来查看core dump的文件大小上限,然后使用-c选项修改core文件的大小上限。
    ulimit

  • 进程异常终止的时候,一般都是有bug出现,这种时候我们可以使用gdb调试core文件找到信息。这种在问题发生后调试叫做事后调试。
    gdb

  • 不是所有信号都会产生core文件,9号信号就不会产生。

  • 云服务器也不会生成core文件,因为很占用磁盘空间。

  • 在waitpid的status中,第8位就存储着core dump的开关,如果该位为1,表示开启core dump。如果为0,表示关闭core dump。

小总结

  1. 所有信号的产生都要经过OS之手,为什么? 因为OS是进程的管理者。
  2. 信号的处理是立即处理的吗? 不是,在合适的时候处理。
  3. 信号不是被立即处理,那么信号是否需要被暂时记录下来?记录在哪里最合适呢?
  4. 一个进程在没有收到信号的时候,能否知道如何对合法信号怎么样处理呢? 知道,就像你没有看见红绿灯也知道怎么做一样,对信号的处理是实现植入的。即使没有收到信号,进程也知道怎么做。
  5. 如果理解OS向进程发送信号?能否描述一下完整的发送处理过程?

主要说一下3和5.OS如何给进程发送信号呢?一共只有31种信号,每种信号只有是否被发送两种状态。我们可以使用位图来发送,

struct task_struct{
unsigned int sigbitmap[32] = {0}; //信号位图
}

OS在发送信号的时候,只需要先找到pcb,然后找到pcb种的信号位图,然后将对应的位置由0改成1,这就表示发送信号完毕!现在来看,发送信号这个词语甚至不太合适,我们应该使用写信号。信号被写入以后,信号的任务就完成了,剩下的就交给进程去执行了。

存储信号

几个概念

  • 实际执行信号的处理动作称为信号递达。(Delivery)
  • 信号从产生到递达之间的状态,成为信号未决。(Pending)
  • 进程可以选择阻塞(Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,知道进程解除对此信号的阻塞,才执行递达的动作。
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是一种递达动作。

阻塞

内核的存储结构

信号

  • 既然需要信号,那么内核就会给信号创建数据结构。在pcb种保存着关于信号的信息。两张位图和一张指针数组。
  • BlockBitMap,即堵塞表。这张位图的数组下标代表第几号信号,而位图的内容代表该信号是否被堵塞。为1表示堵塞,为0表示未堵塞。
  • PendingBitMap,未决表。这张位图的数组下标表示第几号信号,内容表示该信号是否未决。为1表示信号已经递达,为0表示信号未决。
  • 最后一张handler指针数组,代表每个信号的递达方式。每个指针指向一个处理方法。如果我们想使用自定义的递达方式,实质就是改变这张数组的内容。
  • 每个信号都使用两个标识位标识堵塞和未决。信号产生时,内核在进程的pcb种设置该信号的未决标志,信号递达后清除该标志。

在上图中,1号信号未被阻塞也未产生。2号信号虽然产生了,但是被阻塞,无法递达,只能未决状态等待,但是不能忽略这个信号。3号进程没有产生,但是是阻塞状态。每次,即使一个信号未被产生,它也可以被阻塞。而且3号进程如果递达,那么执行的是自定义的动作。

  • 如果信号在解除阻塞前被产生多次,如何处理?POSIX.1允许系统递送该信号1次或多次。Linux的实现:普通信号只会计算1次。而实时信号会将多次信号放在一个队列里。

sigset_t

  • 从信号的存储结构可以看出来,信号的阻塞和未决的判断都是依靠一张位图,而且这两张位图的结构和大小都类似,所以可以为这两张位图规定一个数据类型,这个类型就是sigset_t,这个类型就是为了存储信号而存在的,所以叫做信号集。这个类型的每一位标识有效或无效状态,在阻塞信号集中,有效和无效代表是否被阻塞,在未决信号集中,有效和无效代表是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的屏蔽应该理解为阻塞而非忽略。

信号集操作函数

  • sigset_t作为信号专用的数据类型,我们只需要知道它的作用是判断每一位的有效和无效。至于内部如何实现,依赖操作系统。我们只能调用关于sigset_t的函数来对信号集进行操作,不应该对它做printf,&之类的操作,因为这没有意义。
#include <signal.h>
int sigemptyset(sigset_t *set); //初始化清空set指向的集,全设为0,代表全部无效
int sigfillset(sigset_t *set); // 初始化将set指向的集中所有位变成1,代表全部有效
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或者sigfillset初始化信号集。上面4个函数都是成功返回0,出错返回-1.sigismember是一个布尔函数,如果被设置则返回1,未被设置返回0,出错返回-1.

操作信号

sigprocmask函数

sigprocmask

  • sigprocmask可以设置进程中的阻塞信号集(信号屏蔽字)。如果成功返回0,出错返回-1.
    -最后一个参数oldset指针如果非空,则将当前进程的信号屏蔽字通过oldset参数传出。
    如果set非空,则按照how的方式修改当前进程的信号屏蔽字。
    如果oldset和set都非空,则先将信号屏蔽字拷贝到oldset中,然后按照set和how来修改信号屏蔽字。
    假设当前的信号屏蔽字为mask,下面说明了how的可选值。
    how

sigpending函数

sigpending

  • sigpending用于获得当前进程的未决信号集。通过set传出。成功返回0,出错返回-1.

一个栗子

  • 下面是一个栗子,用于练习上面的几个函数。
  1 #include <stdio.h>                                                                                                           
  2 #include <unistd.h>
  3 #include <signal.h>
  4 
  5 void sigshowset(sigset_t *set){  // 打印set集
  6   int i = 1;
  7   for(; i <= 31; ++i){
  8     if(sigismember(set, i)){
  9       printf("1");
 10     }
 11     else{
 12       printf("0");
 13     }
 14   }
 15   printf("\n");
 16 }
 17 
 18 void handler(int signo){  // 一个自定义的信号捕捉方式
 19   printf("get a signal : %d\n", signo);
 20 }
 21 int main(){
 22     signal(2, handler); // 自定义2号信号的捕捉
 23 
 24   sigset_t pending;
 25   sigemptyset(&pending); // 清空pending集
 26 
 27   sigset_t set, oset;
 28   sigaddset(&set, 2);   // 将set集的第2位变成有效。
 29   sigprocmask(SIG_BLOCK, &set, &oset); // 屏蔽2号信号,将屏蔽前的信号屏蔽字通过oset传出。
 30 
 31   int count = 20;
 32   while(count--){           
 33   sigpending(&pending);   //  获得当前进程的pending集  
 34   sigshowset(&pending);   //  打印当前进程的pending集
 35   sleep(1);
 36   }
 37   sigprocmask(SIG_SETMASK, &oset, NULL); // 用oset集替代当前进程的信号屏蔽字
 38   sigpending(&set);   // 获取当前的pending集,通过set传出
 39   sigshowset(&set);   // 打印set集。
 40   printf("recover\n");
 41 }                       

ret

  • 现象是当我们没有发送2号信号时屏幕打印全零的pending集,因为此时没有信号,也就没有信号处于未决状态。当我们按下ctrl+c,进程收到2号信号,但是2号信号被堵塞,所以2号信号处于未决状态。当打印20次以后,我们取消了信号屏蔽字对2号信号的堵塞,但是我们又自定义了2号信号的捕捉方法,所以就会打印我们想要的内容。

捕捉信号

  • 前面我们说过进程获取信号时不会立刻递达,而是在合适的时候再进行处理信号,那么什么时候合适呢?
  • 一个信号被递达是在由内核态切换成用户态时进行信号检测。我们写的进程大部分都会进程内核态与用户态的切换。因为大部分函数底层都封装系统调用接口,printf封装write,scanf封装read,一个进程很难避开内核态。

我们写的代码分为两部分,用户代码和内核代码。用户级别的代码在用户态下就可以执行,但是内核代码无法在用户态下执行,因为用户没有执行内核代码的权限。用户的权限很小,而内核的权限很大。(有点类似root用户和普通用户)但是我们的代码不可避免的要使用系统调用接口,就必须进行内核态和用户态之间的切换,那么操作系统是如何实现的呢?利用页表。

页表

  • 我们前面所说的进程地址空间只是用户空间的3G,而每个进程的虚拟地址空间都会有1G的内核空间。所以进程的内核空间会映射到同一块物理内存,因为操作系统只有1个。进程的用户级代码就会放在用户空间,内核级代码就会放在内核空间。当需要执行用户代码时,用通过用户的虚拟地址,通过用户级页表进程映射。同样的,需要执行内核级代码时,就通过内核级页表进行映射,然后执行。

  • 操作系统如何识别用户态和内核态?在cpu的寄存器中存储着这个消息。

  • 我们说过信号递达是在由内核转换成用户态时进行信号检测,那么如何进入内核态呢?比如系统调用接口,中断或者异常都会进入。一般是处理完异常或者执行完接口就会返回用户态,但是返回前还会进行信号检测,如何检测呢?此时该进程是在内核态,有权利查看当前进程的pcb,操作系统找到其中的跟信号有关的两张页表,然后检测是否收到信号且未被阻塞,如果有,那么就信号递达,再检测第三张函数指针表,如果有自定义的函数,就返回用户态执行这个捕捉信号的函数,执行完毕,再返回内核态,然后信号检测完毕,返回用户态。
    用户态和内核态

  • 为什么3->4的时候还要切换到用户态?内核态有足够的权限去执行用户态的代码,为什么还要切换到用户态去执行代码呢?或者说为什么要保证在用户态执行用户代码呢?不能在内核态执行吗?

  • 因为内核态的权限太大。如果说用户的代码是非法代码,那么在用户态下无法执行,因为信号捕捉就可能被内核态处理,导致错误。

信号捕捉小总结

  • 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

  • 由此我们可以解释下面的代码:

#include <stdio.h>

int main(){
	int arr[100];
	int i = 0;
	while(i < 200){
		arr[i] = i;
		++i;
	}
	printf("hello!\n");
}
  • 这个代码显然的越界了,但是我们发现最后一句话依然打印了。这是因为收到信号不会立刻处理,而是等到printf函数执行的时候,进程陷入内核态才开始处理。

sigaction函数

  • sigaction函数也是一个捕捉信号的函数,跟signal效果类似,但是比起signal更强大一些。
    sigaction
  • 第一个参数是信号的编号。第二个和第三个参数是一个struct sigcation类型的指针,那么这个结构体是什么呢?
    struct sigaction
  • 这里的第二个和最后一个函数指针是处理实时信号的,我们不用管。第一个结构体成员就是我们自定义的方法。第四个参数我们也不管。
  • 主要来说一说sa_mask。一个普通信号在被处理的时候,如果又接收到该信号,那么系统会怎么办呢?难道继续执行一遍这个信号吗?这是有极大风险的,操作系统不允许一个普通信号再被执行的时候再来一个普通信号,所以当执行该信号的时候,系统会将该信号的信号屏蔽字设为有效。但是系统允许同一时间执行不同的不同信号,而sa_mask是用来设置执行当前信号的时候你不想被执行的信号,比如你在执行2号信号,那么你不想4号信号跟我一块,所以你就可以通过sa_mask去屏蔽4号信号。

sigaction_test

  • 上面的小程序用来使用sigaction捕捉2号信号。

可重入函数

  • 如果一个函数可被不同的执行流同时执行且不出错,那么这就是可重入函数。反之就是不可重入函数。
  • 一般的,使用malloc和free的函数都是不可重入函数,因为系统管理内存使用全局链表管理。
  • 如果调用了标准IO,很多系统调用接口也都是不可重入的,因为使用了全局的数据结构。

volatile

  • volatile涉及到编译器的优化问题。
    volatile

  • 我们定义了一个全局的flag为0,当flag为0时,main函数就会一直死循环。但是signal会去将flag变成1。也就是说当我们发送2号信号的时候,main函数会退出。

  • 但是我们使用-O2对代码进行优化,发现flag雀氏变成了1,但是进程没有退出。因为编译器检测到你的main不会改变flag的值,(编译器没有能力知道你的代码逻辑,它不会知道signal跟你的main执行流有关联。)所以它将flag直接优化到了寄存器中,这样就能加快读取效率。而你改变的flag是改变的内存中的flag。

  • volatile可以解决数据的不同造成的矛盾。volatile保证了内存的可见性,你可以理解为volatile保证了读取数据必须去真实的内存中去读取,不允许优化,不允许去任何缓存中去读取该数据。

SIG_CHLD信号

  • 一个子进程在退出的时候会有资源未释放,等待父进程wait之后才释放。子进程退出的时候还会为父进程发送一个SIG_CHLD信号,这个信号的默认处理动作是忽略。
  • 但是我们可以利用捕捉信号是与主控制流程无关的特征,我们在捕捉信号的自定义函数中wait,这样就不必拖累父进程。
  • 一般来说系统的默认忽略和自定义的忽略是一样的。但是在这里SIG_CHLD是个例外,如果你自定义捕捉信号SIG_CHLD的方法是忽略,那么子进程去世的时候就会自动回收资源,自动清理,不会通知父进程,也不会产生僵尸进程。但是这种方式只适用于linux,Unix不一定管用。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值