目录
信号背景:
在生活中处处都存在的信号,比如信号灯,要想处理信号,我们就必须具备两种条件,首先是能够识别信号,第二个是处理信号的能力,也就知道如何处理信号,对于一个进程来说也是如此,必须提前满足两个条件,第一个就是要能够识别信号,第二个就是知道如何处理信号。
那么进程是如何记住信号的呢,即信号有没有产生,什么信号产生?
内核数据结构使用位图来标记各个信号是否产生,这个位图只能由操作系统去修改。
对于信号的处理有三种方式:
第一种是忽略,第二种是默认的处理方式是由操作系统提供,最后一种是用户自定义的信号处理方式。(注意忽略也算作已对信号的处理,不过是什么也不做)
信号对于进程的控制流程来说是异步的,进程运行中随时可能产生,所以对于进程来说,它可能在控制流程的任何一个地方产生信号。
使用kill -l查看所有信号列表:
1-31号信号是普通信号,34-64号是实时信号。
对于进程识别和处理信号而言,将其分成三个阶段来理解,为信号产生前和信号产生中,信号产生后。
信号产生前
首先是信号产生前,信号产生前谈的是信号的产生方式,主要有四种:
首先是通过终端按键,比如:
ctrl+c 发送SIGINT 2号信号给进程
ctrl+\ 发送SIGQUIT 3号信号给进程
可以通过信号捕捉函数signal进行代码验证,验证发现九号信号是不可以捕捉的,进程收到9号信号会直接退出:
测试:
使用命令行kill -信号编号/信号名给信号发信号:
kill命令其实就是进程产生信号的第二种方式即使用系统调用,kill程序的实现就是调用系统调用kill函数,使用kill函数给指定进程发送信号。
使用库函数调用abort函数给当前进程发送SIGABRT信号终止进程。
还可以使用raise函数给当前进程发送指定信号。
第三种产生信号的方式是软件条件产生,调用alarm函数可以像定闹钟一样,当时间到了给进程发送SIGALRM信号:
OS是也一款软件,一启动就不停息地运行,那么是谁在推动操作系统做一系列的事情呢?是硬件,硬件不断给操作系统发送时钟中断,让操作系统不断自然运行。
还有SIGPIPE
还一种产生信号的方式是硬件异常产生,比如说数组越界访问,空指针解引用等问题,操作系统会给进程发送指定的信号,标识进程运行中出现了错误,中断进程。
在数组越界的时候,访问指定元素的地址,通过页表进行虚拟地址转化到物理地址时,这个过程主要是由软件页表和硬件MMU内存管理单元完成的工作,当进行地址转化的过程中,内存管理单元出现错误,是一种硬件异常,操作系统就能够识别到硬件的异常,给指定的进程发送指定的信号。
数组越界进程收到信号终止,其实是收到了SIGSEGV信号,表示段错误。
比如除零错误cpu内部有状态寄存器,当进行运算时出现错误状态,寄存器就会发生变化,记录错误操作系统,同时也能够识别到这个错误,给进程发送SIGFPE信号,表示浮点数异常。
即使是硬件异常也不一定会终止进程,这取决于用户是否自定义捕捉了这个信号给这个信号自定义处理方法来决定的。
Core Dump
使用man 7 signal命令可以在手册中可以看到不同的信号,在处理的过程中,有一些是中断,有一些会产生核心转储文件:
这个核心转储文件,本质上就是进程,在运行的过程中出现了异常,操作系统将进程运行的上下文数据保存在磁盘文件中,这个文件就是核心转储文件,使用GCC的-g选项以debug模式编译程序,程序产生的核心转储文件才可以通过gdb调试,通过gdb调试可以定位到代码某一行发生错误。
实际运用中,因为核心转储这个功能是要占据许多磁盘空间的,每一次发生错误都会产生一个核心转储文件,但并不是能够及时的排查错误,所以在应用中通常会关闭这个选项,让进程在说到信号是不产生核心转储文件。
使用ulimit -a可以查看core文件大小:
可以看到默认的core文件大小限定为0,意味着不生产core文件,使用ulimit -c 文件大小 可以改变core文件大小,从而使程序生成core文件。
信号产生中
第二个信号的阶段是信号产生中。
需要引入两个概念,首先是信号的递达,指的是在处理信号的时候做的动作。第二个是信号的未决,表示信号从产生到信号递达之前的状态叫做信号处于未决状态。
信号的阻塞表示让信号保持在未决状态,直到解除对信号的阻塞为止。
操作系统内部实现有三张表来对信号的处理而产生进行管理,首先是阻塞信号,阻塞指定信号不能防止收到对应信号,但是能阻止信号的抵达,而且当信号被阻塞时,进程多次收到相同信号,但只会被抵达一次,因为内部收到信号的标识位是由位图实现的,第二个表是pending表,它是表示进程是否收到信号的位图,最后handler表是是一个函数指针数组,自定义信号处理方法的本质上就是修改函数指针数组内的函数指针变量。
源代码中可以看到三张表在task_struct结构体中:
画图理解:
那么如何操作这三张表?
通过signal函数可以捕获信号,并为该信号自定义处理函数,还可以通过调用sigaction函数自定义信号的处理方法:
其中参数类型struct sigaction具有如下成员,包含成员sa_handler函数指针变量,通过创建并修改结构体的该成员,再调用sigaction将其设置进内核中即可。
比如要为2号信号自定义处理方法:
void handler(int sig)
{
//....
}
int main()
{
struct sigaction act, oact;
//可以自定义处理方法,也可以用系统提供的宏将处理动作设置为忽略(无行为),或默认(进程退出)
act.sa_handler = handler;
// act.sa_handler = SIG_IGN;
// act.sa_handler = SIG_DFL;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(2, &act, &oact);//将原本的sigaction拷贝进oact中,可供恢复
}
对block和pending表的描述,OS统一使用sigset_t结构体,修改block表只要创建并操作sigset_t结构体然后调用sigprocmask修改阻塞信号集(即block表又称信号屏蔽字)即可。
sigprocmask函数,第一个参数传入宏
第一个参数宏
SIG_SETMASK 表示要设置阻塞信号集,将原信号集直接设置成我们传入的信号集set
SIG_UNLOCK 表示解除信号屏蔽字,将原信号集中含有我们传入的set中的比特位移除
SIG_BLOCK 表示添加到当前信号屏蔽字的信号,相当于原信号集或上我们的set
sigprocmask函数,第二个参数传入要设置的信号集,第三参数表示将原来的信号屏蔽字备份到oset里。
要读取pending表使用sigpending函数即可,该函数的参数就是输出型参数。
要操作sigset_t结构体使用信号集操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set);清空信号集,将所有比特位置0
int sigfillset(sigset_t *set);将信号集所有比特位置1
int sigaddset(sigset_t *set, int signum);添加signum信号到信号集,将对应比特位置1
int sigdelset(sigset_t *set, int signum);删除信号集中的signum信号,将对应比特位置0
int sigismember(const sigset_t *set, int signum); 判断信号集是否包含signum信号(比特位是否为1)
注意:当进程在处理信号的函数中并同时收到信号时,该信号会被阻塞。
信号产生后
对于4G的内存而言,地址空间被划分成用户地址空间和内核地址空间,其通过用户级页表和内核级页表分别与物理地址建立映射。
用户级页表:0-3G 每一个进程都有一份用户级页表,大家的页表都不同
内核级页表:3-4G:所有进程共享,所以无论进程如何切换,都能找到内核的代码和数据
可见每个进程都能看到OS,但是并不是进程随时能进行访问的,需要具备访问的权利。如何具备访问的权利,访问内核数据?
需要以内核态的身份才可以访问。在CPU中的CR3寄存器中有比特位标识进程状态处于内核态还是用户态,状态区别在于访问权限不同。
用户态 :只能访问用户级页表
内核态:能访问内核和用户页表
何时会切换成内核态呢?
执行系统调用或时间片到了(中断)或异常,进程间切换时会身份切换更改成内核态。
因为pending表和block表是内核的数据结构,修改其只有内核态才有权限,所以在内核态返回用户态前OS便顺便对进程PCB中pending和block表进行检查。
如果发现有信号产生且信号未被阻塞 ,就查看handler表,如果不是用户自定义的处理方法在内核态执行其方法就可以了,如果是用户自定义处理方法 ,就切换回用户态,切换会用户态是因为要保护OS,防止恶意代码利用内核态的身份做本无权限做的事情。在执行自定义方法处理后,再返回内核态,不直接返回用户态是因为比如系统调用时需要准备函数的返回值等,最后再切换回用户态 。
画图理解:
简图快速记忆方法:无穷大,一线4交点
其他概念
不可重入函数
重入:被多个执行流重复进入
不可重入函数:对于重入时导致发生访问全局变量造成错误的函数。
反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入函数。
当符合下面条件之一的函数是不可重入的:
调用了malloc,free函数
调用了IO库函数,标准IO库的实现常以全局数据结构的不可重入方式实现
举例理解不可重入函数:
volatile关键字
volatile为c语言关键字,意为保持内存可见性。
正常情况下从内存读取数据到CPU,但在编译器的高优化级别下变量会被优化到寄存器中
使用volatile关键字声明变量,就相当于告诉编译器不对变量做任何优化,每次CPU拿内存中的数据都必须从内存中读取,即为保持内存可见性。
为何需要这个特性呢?因为在高优化级别下,有时候访问变量,因为变量被优化到寄存器,所以变量在内存的修改不能马上同步到寄存器上,这时访问寄存器上的值就和内存中不同,就照成了访问数据出错的问题。
SIGCHLD 17号信号
子进程退出或暂停或继续时会给父进程发送SICHLD信号 ,该信号的处理动作是SIG_IGN,也就是什么都不做的忽略动作。
在命令行使用kill -19 pid给进程发送暂停信号,kill -18 pid给进程发送继续信号。
SIGCHLD信号用途
之前提到子进程退出,父进程不等待,会导致子进程变成僵尸进程照成内存泄漏。为解决僵尸状态问题可以利用这个信号。父进程捕捉SIGCHLD信号,在信号处理函数中等待子进程即可。
捕获信号可以使用signal或sigaction函数,使用signal函数时,将SIGCHLD设置成忽略SIG_IGN,本来SIGCHLD信号的处理动作就是SIG_IGN,看起来没区别,但是设置之后子进程终止时会自动释放不会进入僵尸状态,这时Linux下的特例,在其他平台不保证如此行为。