信号是操作系统发给进程的通知,用于处理突发事件,如用户中断或程序错误,控制进程行为。信号的产生不影响进程的运行,二者是异步的。
信号的产生与处理
基本概念
给进程产生信号的信号源非常多,主要有四种,一是键盘,在进程运行时按下Ctrl C终止进程,本质上是键盘向目标进程发送了信号,而进程面对大部分信号的处理方式就是让自己终止;二是异常,在程序运行出错时由操作系统内核自动产生,如除0或段错误,与CPU有关,在后面讲解;三是软件条件,这个比较复杂,在文章末尾会进行讲解;四是其它进程通过调用系统调用向指定进程发送信号,例如在代码中调用系统调用接口kill、raise等,常用的kill命令底层调用的就是kill接口。
使用命令 kill -l 可以查看全部62个信号及其编号,注意没有32和33号。其中34号到64号为实时信号。一旦接收到实时信号,进程必须立即处理,而前面的1到31号的普通信号则不需要立即处理。我们在进程运行时按下Ctrl C就是发送2号信号。若没有特别说明,下面说的信号主要指普通信号。

进程在接收到信号后, 不一定立即处理,而是在合适的时候进行处理,处理方式分三种,一是默认处理动作,即终止进程;二是自定义信号处理动作;三是忽略处理,即不处理。进程对信号的处理方式是提前设定好的,在信号没有产生时进程就知道该如何处理了。
默认处理动作
默认处理动作可以在手册中查看,使用命令 man 7 signal 并向下翻找,可以看到各个信号以及对应的说明,这里是用字母排序。

第三列的Action即是进程的默认处理方式,Term表示终止进程,Ign表示忽略信号,Stop表示暂停,Cont表示继续运行。
Core表示核心转储然后终止,执行该操作会在当前路径下形成一个文件,里面是进程异常退出时在内存中的核心数据,这就是核心转储(Core Dump)。但在云服务器上,为了防止大量的文件占据空间,这个功能默认是被禁止的。不过可以使用命令 ulimit -c [文件大小] 暂时开启。文件大小设为0表示关闭该功能。当我们实在找不到代码中哪里出错时,可以开启core dump,再让代码运行崩溃,此时会生成核心转储文件(一般命名为core),然后使用gdb调试,输入core-file [核心转储文件名],这样会帮我门直接定位到出错行,这就是事后调试。
在wait函数和waitpid函数( 详见(Linux)进程控制-CSDN博客)从参数status返回的退出信息中,core dump标志就是表示是否core dump,终止信号部分就是位图,标记了进程接收到的信号。

自定义信号处理动作
信号的处理动作可以在代码中使用接口signal更改,也就是自定义处理动作。signal的第一个参数signum是信号编号,用于指定修改哪个信号的处理动作。第二个参数handler是一个函数指针,定义在下图中间那行。我们实现好处理信号的函数后,将其指针传入即可。

完成之后进程在接收到相同的信号时就会调用handler指向的函数。要注意的是9号信号是无法修改的,这是为了防止恶意程序无法强制终止。
信号保存
基本概念
实际执行信号的处理动作称为信号递达(Delivery),信号从产生到递达之间的状态称为信号未决(pending),进程接收到信号后,除了执行递达的动作,还可以阻塞信号。被阻塞的信号会保持在未决状态,直到进程解除对其的阻塞才会执行递达的动作。阻塞与忽略并不相同,只要信号被阻塞就不会被处理,而忽略是进程对信号的一种处理动作。比起阻塞,更贴切的说法是“屏蔽”。因为是否阻塞信号并不是在收到信号后才决定的,在信号产生之前,进程就已经确定哪些信号要阻塞了。
由于接收到信号后,进程并不一定会立即处理,所以进程会把信号保存在task_struct中。其中与信号相关的成员变量主要是这三张表:block、pending和handler。Linux提供的信号操作都是通过这三张表实现的。

接收到的信号就保存在pending中,它的类型为unsigned int。这个成员是当作位图使用的,第k位上为1就表示接收到了k号信号。普通信号在处理前无论接收到了几次都只算一次,所以使用位图记录即可。发送信号的本质就是修改进程task_struct中的位图,由于task_struct位于操作系统内核,只有操作系统有权修改,因此发送信号只能通过操作系统。
block也是位图,它的第k位表示是否阻塞k号信号,值为1即表示阻塞,为0则不阻塞。简单来说,就是个“黑名单”,里面标为1的都是被屏蔽的信号。默认情况下,进程不会屏蔽任何信号。
handler是函数指针数组,它下标为k的位置上存储了k号信号的处理方法,前面的signal函数就是通过修改它来修改信号处理方法。
从上图来看,每个信号只有⼀个bit的未决标志,非0即1, 不记录该信号产生了多少次,阻塞标志也是这样表示的。因此, 未决和阻塞标志可以用统一的数据类型sigset_t来存储,sigset_t称为信号集,这个类型的变量也是当作位图使用,每一个bit可以表示是否被标记了对应信号,在阻塞信号集中,是否被标记的含义是该信号是否被阻塞,而在未决信号集中是否被标记的含义是该信号是否处于未决状态。
相关接口
即将要介绍的几个接口,都离不开信号集。而对信号集的操作有一套专门的接口,如下
#include <signal.h>
int sigemptyset(sigset_t *set);//将set所指向的信号集的所有bit全部置为0,即清空
int sigfillset(sigset_t *set);//与上面相反,这个接口会将所有bit全部置1
int sigaddset(sigset_t *set, int signo);//将对应bit置为1,即添加对应信号
int sigdelset(sigset_t *set, int signo);//将对应bit置为0,即除去对应信号
int sigismember(const sigset_t *set, int signo);//判断是否包含对应信号
下面介绍相关接口,位图block可以借助函数sigprocmask进行操作

第二个参数set是信号集,sigprocmask会根据其中的内容对block进行修改,具体如何修改与第一个参数how有关,how是选项,用于确定进行什么操作,有如下三种选择

how选择第一个选项时,sigprocmask会向block添加set中标记的信号,选择第二个选项则是去掉set中标记的信号,如果要将整个block换成set,就使用第三个选项。
最后一个参数oldset是输出型参数,sigprocmask会将修改前的block传出,可以用来回退修改。
函数sigpending则用于获取pending,set是输出型参数,pending的拷贝通过set传出。

接口sigaction用于读取和修改与指定信号相关的处理动作

第一个参数signum是信号编号,第二个参数用于选择如何处理信号,稍后讲解,oldact是输出型参数,会把修改前的处理动作传出,用于回退修改。结构体sigaction的定义如下
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_handler和第三个成员 sa_mask。之前我们提到使用signal接口可以修改信号处理动作,如果我们定义一个sigaction结构体变量,将实现好的信号处理动作赋值给成员sa_handler,其它成员不用处理,将结构体的指针传入参数act,也可以实现相同的效果。
sa_mask的使用比较复杂,它也是信号集。当某个信号的处理函数被调用时,内核会自动暂时屏蔽该信号,当信号处理函数返回时再恢复,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。防止同一个信号重复递达。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外⼀些信号,则用sa_mask标记这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
信号捕捉
信号捕捉的流程如下图所示,内核态和用户态我们后面再讲解,现在暂时以这种简单的方式进行理解:要执行用户编写的程序时,CPU就会进入用户态,要执行操作系统内核的程序时,CPU就会进入内核态。只要是处于内核态转向用户态的临界点,就会对信号进行捕捉。
在进程运行时,会经常因为中断、异常等原因被暂停,让操作系统先处理完。下图就展示了每次进程被暂停时的流程,在这个过程中包含了两次CPU从内核态到用户态的转换,因此会进行两次信号捕捉。
比较特殊的是进行到下图第3步时,如果该信号没有自定义处理动作,那么就会直接在内核态下执行默认处理动作,亦或者进程没有收到信号,操作系统处理完中断或异常后,CPU切回用户态,执行之前暂停的代码,这样就会少了一次从内核态转到用户态的过程。
其次,如果同时捕捉了多个信号,通常会按照信号编号顺序依次执行信号处理动作。

如果在执行自定义信号处理方法的过程中,因为调用系统调用或收到中断等又转为内核态再转回用户态,则会再次捕捉信号,如果真的捕捉到了没有被屏蔽的信号,则会暂停当前的信号处理动作,执行新信号的处理动作,完成后再返回继续执行。要注意的是在执行信号处理动作时会暂时屏蔽同种信号,执行完了才会恢复。
硬件中断
由外部设备触发的中断系统运行流程叫做硬件中断。以键盘为例,当进程执行到scanf函数时,会进入阻塞状态等待。那么当键盘按下回车完成输入时,进程是怎么知道的呢?

事实上,除了通过内存,所有的外部设备还通过中断控制器与CPU间接相连,只是不会从这里直接发送数据到CPU。以键盘为例,键盘输入完成准备就绪后,会向中断控制器发送中断请求,中断控制器会通过针脚识别出是哪个设备发送的,并将其编号转换为中断号,然后通知CPU,CPU接收到后会立即暂停当前正在执行的任务,将寄存器中的数据存入栈区(在内存)。接着,CPU会从中断控制器中获取中断号。而操作系统维护了一张中断向量表(IDT),这张表可以简单地看成函数指针数组,里面是各个中断处理的方法,通过中断号,CPU就能在表中找到并运行对应的中断处理方法,从而读取键盘数据,调用中断处理方法,实际上就是运行操作系统的程序。完成后,CPU会继续执行之前的任务。总的来看,就是键盘准备就绪后发送硬件中断,最终使操作系统读取键盘数据。
操作系统并不会周期性地检测外部设备或管理资源,而是一直处于暂停状态(使用接口pause就能做到),收到中断就运行一次,然后继续暂停,不断循环。但并不是没有外部设备准备就绪,操作系统就不运行了。有一个硬件名为时钟源,它一般集成在CPU内部,会以固定的频率发送特定的中断——时钟中断,接收到这个中断后,CPU同样会通过中断向量表运行中断方法,从软件层面上看就是让操作系统执行一系列操作,其中就包括了进程调度(详见(Linux)进程-CSDN博客)。因此,操作系统是在硬件时钟中断的驱动下进行进程调度的。实际上,操作系统就是基于中断进行工作的软件,而中断触发的频率,就是CPU的主频。
我们知道,操作系统会根据每个进程的时间片分配CPU资源。在进程的task_struct中,有一个计数器叫时间片,每次操作系统收到时钟源的中断时,就会让当前正被调度的进程的时间片减1,当时间片为0时,操作系统就会执行进程调度,将该进程重新放回调度队列,转而调度其它进程。
CPU本身也会发送中断,当CPU运行进程的代码(指令)出现除0、指针重复释放等错误时,CPU也会生成中断号,此时操作系统执行的中断服务就是给进程发送信号。
软件中断
前面提到操作系统是基于中断进行工作的,我们平时使用的系统调用接口也是借助中断实现的。与硬件中断不同的是,这种中断是由程序主动触发的,被称为软件中断,或简称软中断。
虽然我们一般将系统调用当作函数使用,但系统调用的过程与函数调用有很大的不同。所有系统调用接口都维护在一张系统调用表中,它本质就是个函数指针数组。因此每一个系统调用都唯一对应一个下标,这个下标叫做系统调用号。当程序调用系统调用,会将操作系统提供的系统调用号放入eax寄存器,参数放入其它寄存器。接着执行软件中断指令int 0x80,触发软件中断,调用中断向量表中对应的中断处理函数——系统调用处理程序,这个程序(其实就是操作系统)会根据eax寄存器中的系统调用号在系统调用表中找到对应的函数并执行。
可以看出,操作系统并没有直接提供系统调用的方法,我们调用的open、close等接口其实是C语言封装的,在其内部会通过宏定义将函数名转换成系统调用号,正如前面所说,这个系统调用号是操作系统提供的。当然,软件中断指令也是操作系统提供的。
现代CPU有更高效的syscall/svc指令,但整体上逻辑是相同的,只是不再需要传统的中断向量表。
CPU内部因指令执行引发的硬中断(如除0、缺页等)被称为异常。而CPU内部的软中断(如int 0x80或syscall)被称为陷阱。
不难发现,操作系统提供的资源管理、硬件管理和系统调用等所有的操作和服务都是通过中断触发的。
用户态与内核态

回顾进程地址空间的内容,在地址空间的3G到4G的部分属于内核空间。与用户空间不同的是,这一部分是通过内核页表进行虚拟地址到物理地址的转换的。且由于操作系统内核只有一个,所以也只需要一个内核页表。而用户空间使用的用户页表因为要保证进程间的独立性,所以存在多个。
将内核空间映射到进程的地址空间,使得CPU收到中断后,能直接通过进程的地址空间找到操作系统,进而调用中断服务。
可以明确的是,用户(即我们写的程序)是不能访问内核空间的。CPU有两种状态,内核态和用户态,只有处于内核态才能访问内核空间,否则进程会被CPU直接终止。在CPU的寄存器cs中就记录了CPU处于哪种状态,0表示内核态,3表示用户态。
相关内容补充
前台进程与后台进程
进程有前台进程和后台进程两种,键盘产生的信号只能发给前台进程。当我们使用 ./[文件名] 直接运行程序时,默认会在前台运行,可以从标准输入中获取内容,即获取键盘输入的内容,这种进程被称为前台进程。相应的,使用 ./[文件名] & 可以创建后台进程,它在后台运行,无法从标准输入中获取内容。
前台进程只能有一个,这是因为键盘只有一个,只能向一个进程中输入数据。而后台进程则可以有多个。解析命令的bash就是前台进程,当我们输入命令后创建前台进程后,bash就会被操作系统换到后台。另外,当前台进程运行时,按下Ctrl Z会暂停前台进程,此时该进程会被操作系统换到后台,将bash换回前台,否则用户就无法操作了。
使用命令jobs可以查看当前的后台进程
![]()
方框中的数字即为任务号,使用命令 fg [任务号] 即可将对应的后台进程换至前台。相应的,使用命令 bg [任务号] 可以将对应的前台进程换至后台。
软件条件、定时器
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。
alarm就是一种定时器,在代码使用alarm可以定时给自己发送SIGALRM信号。它的返回值是之前设定的定时器的剩余时间,例如在代码中调用了两次alarm,第一次定时10秒,3秒后又调用了一次alarm,定时为15秒,那么之前的定时器剩下时间是7秒,则这里会返回7。但是7秒后内核不会发送信号,因为在第二次调用alarm时被重置为15秒后发送了。如果之前没有设置定时器,则会返回0。
由于操作系统内也会有很多定时器,所以操作系统也需要将定时器管理起来,管理的方式仍旧是用先描述再组织。它在内核中的数据结构如下。
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s *base;
};
其中值得注意的是expires和function,expires存储的是 定时器剩余时间+1000,低于1000即为超时,操作系统每次会从所有定时器中选出超时最久的定时器,然后通过上面的函数指针function执行对应的功能。
volatile关键字
我们看下面这段死循环代码,如果要终止循环,只有在进程收到2号信号然后执行handler,将全局变量flag改为1,但在main函数中不会对flag进行修改,在编译器优化级别较高的情况下可能会将全局变量flag直接优化到寄存器中,每次访问时直接访问寄存器中的值。而进程收到了2号信号的处理方法只会修改位于在内存中的flag,从而使得程序收到2号信号后没有像意料中那样退出。
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
为此可以在定义变量flag时加上volatile关键字,如此一来,CPU每次访问flag的值时都会从内存中调取。
volatile int flag = 0;
2262

被折叠的 条评论
为什么被折叠?



