文章目录
- 1、什么是信号?
- 2、信号的产生
- 2.1 通过键盘产生信号
- 2.2 通过系统调用产生信号
- 2.3 硬件异常产生的信号
- 2.4 由软件条件产生的信号
- 2.5 进程的核心转储
- 3、信号的保存
- 4、信号的捕捉
- 4.1 用户态和内核态
- 4.2 用户态到内核态的切换
- 4.3 信号捕捉过程
- 5、信号集操作函数以及测试
1、什么是信号?
在生活上
比方说到了的外卖的提醒,这是一种信号。 我们对于这种信号有着自己的处理方式和意识,这就是处理信号。
值得注意的是,信号到来的时候,可能会因为有更加紧急的事,促使我们会先忽略这个信号,并且先保留这个信号。
在处理信号的时候,可能不同的个体会有着不同的处理动作。(比如红绿灯信号,一般人都是红停绿走,但是在一些可能存在的特殊群体下他们会有不一样的动作。)
在计算机上
首先,信号是给进程发的。(比如kill -9 pid)
进程如果需要识别信号,就一定要对信号有认识,并且有处理动作。
当进程收到信号可能有更重要的代码要执行,所以信号不一定会被处理。
那么进程本身就必须有对信号进行保存的能力。
进程捕捉信号再对信号进行处理,一般有三种动作(默认动作,自定义动作,忽略动作)
通过 kill -l 可以看到所有信号,一个信号的数字对应其宏名。
其中 1–31号称为普通信号,34–64号称为实时信号。这里不关注实时信号。
通过man 7 signal 查看signal手册
再通过/Standard signals 可以查看所有信号对应的内容
如果一个信号发给进程,进程应该保存在哪呢?
可以推测:
信号将会保存在进程的task_struct{… unsigned int signal; …}。
通过位图的结构,每一个bit位置代表一种信号,0和1代表是否受到信号,0无,1收到。
发送信号的本质就是修改PCB中signal的位图,
而对应PCB是由操作系统通过数据结构进行管理的,所以如果需要修改信号,就需要通过OS修改。
也就是说信号发送的方式,本质就是OS向目标进程发生信号。
修改OS数据只能通过操作系统接口来修改。
那么操作系统就应该提供发送信号的接口,而kill命令一定是调用了某种系统接口实现的。
下面就来证明这些推测。
2、信号的产生
进程可以通过以下四种方式收到信号。
2.1 通过键盘产生信号
系统提供的发送信号的接口 signal
这个接口用来捕捉信号,也就是当进程收到操作系统发送的sig信号后,将会由原来的默认动作转换成执行func函数中的自定义动作。
如果没有收到信号sig,将不会执行。
参数 sig : 对应信号名
参数 func 对应信号的三个处理动作:
SIG_DFL 默认的信号处理程序。
SIG_IGN 忽视信号。
或者传用户写的对应自定义动作的函数指针。
下面是一个捕捉2号信号和3号信号的例子:
2号信号的默认动作是在用户键盘输入ctrl+c时会退出前台进程。
3号信号的默认动作是在用户键盘输入ctrl+\时会退出前台进程。
#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;
using std::endl;
void handler(int signal)
{
cout << signal << ":自定义处理\n" << endl;
exit(0);
}
int main()
{
signal(2, handler); //2号信号SIGINT ctrl+c 触发
//signal(3, handler); //3号信号SIGQUIT ctrl+\ 触发
while(true)
{
cout << "hello world" << endl;
sleep(1);
}
return 0;
}
2.2 通过系统调用产生信号
操作系统是有能力向目标进程发送信号,但是得由用户来操作。
向一个pid进程发送sig信号。成功返回0,失败返回-1。
kill()可以向任意进程发送任意信号。
可以通过kill调用来模拟kill命令,只要向sig中发送9号信号就完成了kill命令。
//mysignal.cc
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cassert>
#include <sys/types.h>
using std::cout;
using std::endl;
static void Usage(const std::string& proc)
{
cout << "\nUsage: " << proc << " signo pid\n" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signo = atoi(argv[1]);
pid_t pid = atoi(argv[2]);
int n = kill(pid, signo);
assert(n == 0);
return 0;
}
/
//mytest.cc
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main()
{
while(true)
{
printf("我是一个正在运行的进程, pid: %d\n", getpid());
sleep(2);
}
return 0;
}
raise调用
raise()函数发送一个任意信号给调用者
sig指明信号。
举例
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
int main()
{
int cnt = 0;
while(cnt++ < 10)
{
printf("cnt : %d\n", cnt);
if(cnt == 5) raise(9); //kill(getpid(), sig);
}
return 0;
}
abort调用
abort向当前进程发送 6) SIGABRT 信号,中止当前进程。
#include <iostream>
#include <stdlib.h>
int main()
{
int cnt = 0;
while(cnt++ < 10)
{
printf("cnt : %d\n", cnt);
if(cnt == 5) abort(); //kill(getpid(), SIGABRT)
}
return 0;
}
关于信号的处理行为的理解:
很多情况,进程收到大部分的信号,都是为了中止进程。
因为信号的不同,代表不同的事件,但结果是可以相同的。
2.3 硬件异常产生的信号
CPU异常产生的信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
void catchSig(int signo)
{
printf("捕获信号:%d\n", signo);
sleep(2);
}
int main()
{
//捕获 8)SIGFPE 信号
//Floating point exception
signal(8, catchSig);
int n = 10/0; //
return 0;
}
当代码执行后,由于除0的错误被操作系统捕获,向该进程发送了8号信号,通过signal捕获后,确认是8号信号,并且发现一个怪事。
为什么捕获信号后,一直打印?
这是因为处理器在处理10/0时,由于除0造成数据溢出,将一个状态寄存器的溢出标记位置为1。
CPU向OS发送运算异常的信息,根据寄存器上下文确定进程,OS就对进程发送了信号。这个信号默认是中止进程,被捕获后改成了打印。
由于进程收到信号后没有退出,因为进程切换,无数次寄存器当再次加载这个进程上下文,就让OS识别到了CPU内部状态寄存器溢出位是1。
MMU异常产生的信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
void catchSig(int signo)
{
printf("捕获信号:%d\n", signo);
exit(0);
}
int main()
{
signal(11, catchSig);
int* p = nullptr;
*p = 100; //野指针访问 OS发送 11) SIGSEGV信号
return 0;
}
代码中p访问的是虚拟地址空间的地址,虚拟地址映射到页表,再通过MMU(内存管理单元,集成在CPU当中)读取页表地址放入到对应物理地址。
p解引用访问0号地址,MMU因为非法访问的原因发生异常,操作系统识别到异常将11号信号发生给进程。
所以以上就是由硬件自发的让OS给进程发送信号。
OS给进程发送信号虽然结果一样,但是由于种类不同,也能让进程知道发送了何种错误。
2.4 由软件条件产生的信号
在之前管道通信中,如果一个管道只写不读,就会浪费空间。
操作系统面对这种情况就会给进程发送一个SIGPIPE信号。
这种当fds[0]关闭,fds[1]存在的条件判断所造成的信号,就是软件产生的信号。
下面的alarm 函数调用也会产生一个14号信号。
闹钟功能,seconds秒后,向进程发送一次SIGALRM信号。
这个信号默认也是中止进程。
下面看两段代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
static int cnt = 0;
void catchSig(int signo)
{
printf("捕获信号:%d, 最终cnt:%d\n", signo, cnt);
exit(0);
}
int main()
{
signal(14, catchSig);
alarm(1); // 14) SIGALRM
while(true)
{
cnt++;
printf("cnt:%d\n", cnt);
}
return 0;
}
#include <iostream>
#include <unistd.h>
#include <signal.h>
static int cnt = 0;
void catchSig(int signo)
{
printf("捕获信号:%d, 最终cnt:%d\n", signo, cnt);
alarm(1);
}
int main()
{
signal(14, catchSig);
alarm(1); // 14) SIGALRM
while(true)
{
cnt++;
}
return 0;
}
首先第一段代码,展示了数据通过外设和网络打印是多快。
第二段代码,没有打印数据,单纯反应了cpu的计算力,并且通过两个alarm函数简单的实现了sleep()的功能。
那么为什么alarm函数能看作一个软件条件产生的信号呢?
因为alarm本质就是通过用户层上数据结构实现的,是一种软件。
任何进程都可以使用alarm系统调用在内核中设置闹钟,这么多的闹钟就一定需要被OS进行结构体描述后,通过相应数据结构管理。
(比如通过堆结构来管理闹钟,通过闹钟的里到点最近时间建立最小堆)
2.5 进程的核心转储
下面运行这个代码
很明显,它会因为非法内存引用报错。
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
while(true)
{
int arr[10];
arr[10000] = 10;
}
return 0;
}
在之前看信号的标准内容的时候,发现每个信号有着自己的Action,而这其中有Term和Core两种类型。
这两个主要的区别是,core类型的信号在信号中止异常进程后,会在本地文件生成一个核心文件,这个文件在对应时刻存储着进程的有效数据。
Term类型的信号中止进程后就不会有这个步骤。
core file文件在云服务器默认关闭,通过ulimit -a可以看默认限制的资源,看到core file默认是关闭的。
通过ulimit -c 相应size 可以设置core文件。
再次运行程序:
与之前不同的是运行结果多了个(core dumped),意为核心转储
核心转储:当进程出现异常时,进程在对应时刻,将内存中的有效数据转储在磁盘中,也就是下面的core.14626(对应pid)文件中。
通过gdb 调试,输入core-file 对应核心文件名,就可以生成进程具体的异常原因。
看到由于11号信号,发送了段错误。
3、信号的保存
前面介绍了信号产生的四种情况,那么进程在收到信号后具体是怎么保存的呢?
首先了解一些概念
- 进程实际执行信号处理的动作称为信号递达(Delivery)。
- 信号从产生到处理的中间的状态,称为信号未决(Pending)
- 进程可以选择阻塞某个信号。(可以在信号传递之前,选择阻塞这个信号,当信号到来时,阻塞信号)
- 当进程选择阻塞的信号到来时,信号处于未决状态。直到接触对这个信号的阻塞,才执行递达动作。
- 忽略和阻塞不同,阻塞是在执行递达动作前可以选择的,而忽略是在递达动作后可以选择的一种动作。
在进程的PCB中,其实有三种数据结构:
其中:
pending表(未决表): 其作为一个位图结构用来保存收到了哪些信号,通过bit位置表示具体信号,通过1/0来表示是否收到信号。(比如从右到左第一个bit位为1,代表收到了1号信号)
block表(阻塞表): 其作为一个位图结构用来保存阻塞了哪些信号,通过bit位置表示具体信号,通过1/0来表示是否阻塞信号。
handler表: 其作为一个函数指针数组,每个数组下标对应一种信号,通过下标可以调用对应信号的处理函数。(比如通过下标1,调用1号信号的处理函数)
如果一个信号对应pending表位置为1,block表位置为1,说明这个信号被阻塞,处于未决状态。
如果一个信号对应pending表位置为1,block表位置为0,说明这个信号抵达。
如果在信号抵达前,收到了多次信号,Linux下对普通信号只抵达一次,而如果是实时信号,将会放入一个队列中,这里不讨论实时信号。
综上,信号是由PCB保存的,发送信号修改PCB,因为PCB也是操作系统数据,而操作系统只能通过操作系统自身修改,也就是说信号是由操作系统发送的。
操作系统拥有发送信号的能力,但是它无法自己使用这个能力,这就需要用户来运用它的能力,也就是说需要用户进程通过相应系统调用发送信号。
4、信号的捕捉
在之前,我们知道了信号是由进程通过系统调用修改PCB对应数据结构所产生,是由OS发送的,知道了信号如何产生,如何保存,那么信号是如何被进程接收处理的呢?
首先进程收到信号也在没有阻塞信号下,不会立马处理信号,而是在合适的情况下,由内核态到用户态再处理。
那么什么是内核态和用户态呢?
4.1 用户态和内核态
用户态和内核态是两种运行级别。
用户态是最低的级别,处于用户态的进程,会有一些命令的限制,不能访问内核资源和硬件。
内核态是最高的级别,处于内核态的进程,可以访问内核资源以及硬件。
值得注意的是,内核态是可以访问用户态程序的数据,也是在理论上可以访问用户态程序代码的,但是操作系统不允许这种情况发送,因为会出现安全性问题!
如果进程需要通过系统调用访问内核资源或者硬件,就一定需要从用户态切换到内核态。
(比如用户态的进程访问系统内核:waitpid、getpid,访问硬件:printf、fopen、write、read。再比如C++STL中vector的扩容需要访问内存,但是其扩容机制也一定避免了多次调用系统调用。)
系统调用也因此相比普通调用,耗时更多,因此尽量少使用系统调用。
用户态和内核态怎么表示的?
其实进程运行的时候,进程对应上下文被加载到CPU的寄存器中,CPU内有些寄存器指向进程有关的结构,比如PCB,页表等。
其中有一个CR3寄存器,表示当前CPU的运行级别,0代表内核态,3代表用户态。
4.2 用户态到内核态的切换
前面说了,进程如果需要系统调用访问内核资源或硬件,就需要从用户态切换到内核态。
这之中有个问题:
进程又是如何找到对应系统调用的呢?
进程通过用户级页表将虚拟地址空间数据映射到物理内存,每个进程都有自己的虚拟地址空间和自己的用户级页表,这样就能确保进程间的独立性,使得物理内存出现不一样的数据。
当操作系统加载到物理内存的时候,操作系统也为进程准备了一个内核级页表,内核级页表是为了维护在虚拟和物理之间的操作系统的代码而构成的内核级映射表。开机时会将操作系统加载到内存,因此操作系统在物理空间只存在一份,这就决定了内核级页表在内核中只有一份就够了。同时在CPU内也有一个寄存器一直指向这个内核级页表。内核级页表将物理内存中系统代码,在进程地址空间对应的3-4G空间进行映射,所以进程就可以通过内核级页表访问系统调用。
也就是说,在进程运行的时候,对应上下文会加载到寄存器中,这就使得一些寄存器能找到进程的PCB与页表,并且也能找到内核级页表,那么当需要调用系统接口的时候,与动态链接类似就可以直接从虚拟地址空间进行跳转,再通过内核级页表从物理内存找到对应代码,再返回到用户空间。
并且,每一个进程的虚拟地址3-4G空间都共享同一个内核级页表,所以无论进程怎么切换,进程对应3-4G空间是不会变的。因此,进程是可以随意访问系统调用的。
那么用户凭什么能访问内核呢?
其实进程以用户态调用系统接口一开始也是用户态的,Linux在系统调用接口初始位置有一个从用户态到内核态的转换(通过汇编指令int 80进行陷入内核),因此进程能以内核态的身份访问内核资源或硬件资源。
4.3 信号捕捉过程
接着前面 首先进程收到信号也在没有阻塞信号下,不会立马处理信号,而是在合适的情况下,由内核态到用户态再处理。 这也说明,此前是处于内核态的。那么什么时候会进入内核态呢?系统调用和进程切换。
从整个信号捕捉过程来看:
5、信号集操作函数以及测试
前面说的信号保存的数据结构都是内核中的,而在用户层可以通过一些手段访问它们。
比如:
sigset_t是一个信号集,它是一个位图结构,可以表示每个信号的有效和无效状态。在阻塞信号集中有效和无效对应阻塞和未阻塞,在未决信号集中有效和无效对应是否处于未决状态。阻塞信号集也称为当前进程的信号屏蔽字,这里的屏蔽是阻塞的意思而不是忽略。
在用户层有以下函数调用可以用来设置这个信号集:
详细可以通过man 3 手册查看
#include <signal.h>
int sigemptyset(sigset_t *set); //初始化信号集,全部清0
int sigfillset(sigset_t *set); // 填充信号集 全部置1
int sigaddset (sigset_t *set, int signo); // 往信号集里添加指定信号
int sigdelset(sigset_t *set, int signo); // 往信号集里删除指定信号
int sigismember(const sigset_t *set, int signo);// 判断信号集里是否有指定信号
除此以外还有以下函数调用可以修改内核相应信号结构:
sigprocmask
sigprocmask针对的是对PCB中block结构(阻塞位图)进行修改。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
参数部分:
how有三种选择:
SIG_BLOCK 代表在原来的基础上添加set信号集中的信号。
SIG_UNBLOCK 代表在原来的基础上删除set信号集中的信号。
SIG_SETMASK 代表直接将block位图修改成set。
set代表需要传的信号集
oset 代表block位图修改前的信号集
sigpending
sigpending就是将PCB当前的pending位图传给set信号集。
set参数作为一个输出型参数。
#include <signal.h>
int sigpending(sigset_t *set);
知道了上面,就可以做一个小小实验。
大概内容如下:
创建一个阻塞的信号集,可以将2、3号信号添加进去,然后再添加到block位图中,再给进程发送对应信号,打印pending位图,看是否阻塞了该信号。
(并且添加解除阻塞功能,再捕获信号将对应默认处理改成自定义处理)
代码如下:
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>
using std::cout;
using std::endl;
#define MAX_SIGNO 31
static std::vector<int> blocks = {2, 3};
static void show_pending(const sigset_t& pending)
{
for(int signo = MAX_SIGNO; signo > 0; --signo)
{
if(sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
static bool pendingIsEmpty(const sigset_t& pending)
{
for(int signo = MAX_SIGNO; signo > 0; --signo)
{
if(sigismember(&pending, signo))
{
return false;
}
}
return true;
}
static void myhandler(const int signo)
{
cout << signo << " 号信号已经被捕捉" << endl;
}
int main()
{
//对信号做捕捉 自定义操作
for(const int& signo : blocks) signal(signo, myhandler);
//1 添加阻塞信号
sigset_t block, oblock, pending;
//1.1 初始化信号集
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
//1.2 添加阻塞信号到阻塞信号字
for(const int& signo : blocks) sigaddset(&block, signo);
//1.3 设置阻塞信号 (进入内核 直接将阻塞修改成block信号集)
sigprocmask(SIG_SETMASK, &block, &oblock);
//2 打印pending信号 显示收到信号但是没有做出处理动作,意味着被阻塞
int cnt = 5;
while(true)
{
//2.1 接收pending信号集
sigpending(&pending);
//2.2 打印pending表
show_pending(pending);
sleep(1); //缓慢打印
if(cnt-- == 0)
{
if(pendingIsEmpty(pending))
{
cout << "未收到任何信号" << endl;
break;
}
else
{
//2.3 解除信号的阻塞,完成自定义动作后,再次阻塞信号
cout << "解除信号的阻塞" << endl;
sigprocmask(SIG_SETMASK, &oblock, &block);//解除后信号由内核到用户态执行自定义处理方法
sigprocmask(SIG_SETMASK, &block, &oblock);
cnt = 5;
}
}
}
return 0;
}
实验结果:
本章完~