目录
一.信号的概念
1.生活中的信号
红绿灯就是一种信号,因为我们
- 知道它的特征,能识别
- 知道对应的灯亮,意味着什么,要做什么
- 我为什么认识这个信号?因为有人提前告诉过我,所以信号没有产生的时候,我们已经能知道怎么处理这个信号,信号到来,我也能立即识别出来
- 信号的到来,我们并不清楚具体什么时候,即信号到来相对于我正在做的工作,是异步产生的
- 信号产生了,我们不一定要立即处理它,而是在合适的时候处理,所以我要有一种能力将已经到来的信号暂时保存
2.什么是信号
信号是向目标进程发送通知消息的一种机制。
目标进程能识别信号,并且知道怎么处理
二.信号的产生
1.前台进程与后台进程
进程分为前台和后台(./xxx &),前台进程在命令行操作时只能有1个,后台进程可以有多个
前台和后台的本质区别是前台进程能够接受用户输入,后台进程不能接受用户输入,所以前台进程只能有1个。键盘输入ctrl + c,前台进程接收(通过接收信号的方式来间接接收)后终止。
当我们启动一个前台进程,shell无法接收指令,因为它被操作系统提到后台。终止前台进程后,操作系统将shell提到前台,可以接收指令。shell也是一个进程,但是不能被ctrl+c终止。
- ctrl + c :终止前台进程
- jobs :查看后台进程
- fg [number] :将后台进程提到前台
- ctrl + z :将前台进程暂停,前台进程如果被暂停,会立即被操作系统提到后台,shell提到前台,否则键盘会失效
- bg [number] :将后台暂停的进程在后台启动(一定是后台暂停的进程,因为只有shell在前台运行才能接收你的指令)
- 终止一个后台进程:(1)fg [number] , ctrl+c (2)kill -9 pid
2. 中断
操作系统怎么知道键盘有输入?
CPU和外设有针脚相连(间接相连),不同外设可以向特定针脚发送电信号。给每个针脚一个编号,叫做中断号。当某个外设数据就绪时时,向针脚发送高电平,CPU识别到某个针脚的高电平,就将中断号写到寄存器,操作系统就可以读取到这个编号。
计算机启动时,操作系统会将一个函数指针数组加载到内存,这个数组叫做中断向量表,内容是各种外设的读取方法,数组下标就是对应外设的中断号。外设向CPU发送中断号后,操作系统会立即停止手头的工作,从寄存器读取中断号,到中断向量表寻找读取方法,将数据从外设读取到内存。
操作系统怎么知道键盘有输入?
当用户按下键盘,键盘向CPU发送电信号,CPU写入中断号,通知操作系统,操作系统提取中断号,执行中断向量表中的键盘驱动读取方法,将数据从外设拷贝到指定的缓冲区中。
信号就是用软件来模拟中断的行为,中断是外设和操作系统之间的信息通知,信号是进程和进程之间的信息通知。
3.操作系统中的信号
man 7 signal :查询信号
我们既可以使用信号编号,也可以使用信号名称,实际上这些名称就是一个个宏。
细节:
- 没有0号信号。因为进程的退出信息有退出信号和退出码,如果退出信号为0,表示进程不是因为信号而退出,是正常终止的,所以没有0号信号是为了标识进程未收到信号正常退出。
- 1~31号信号是普通信号,34~64是实时信号,我们只谈普通信号
- 每个进程都有函数指针数组,信号编号和数组下标强相关(信号从1开始,下标从0开始)
4.产生信号的四种方式
(1)键盘输入产生信号
例如键盘输入ctrl+c,键盘向CPU触发中断,CPU通知操作系统,操作系统读取中断号,从中断向量表中调用键盘的驱动读取方法。键盘的数据分为普通数据和控制数据,操作系统解析”ctrl+c“为控制数据,所以不会把数据写到缓冲区,而是转化为向前台进程发送2号信号。
类似地,键盘输入ctrl+z 转化为向前台进程发送19号信号,该信号的默认处理方法是暂停进程。
键盘输入ctrl+\ 转化为向前台进程发送3号信号,该信号的默认处理方法是终止进程。
(2)系统调用产生信号
功能:向指定的进程发送指定的信号
功能:向本进程发送指定的信号
//代码实例:实现kill指令对应的可执行程序
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
using namespace std;
void Usage(const string& proc)
{
cout << "\nUsage: " << proc << "signo processid" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return 0;
}
int signo = stoi(argv[1] + 1);
int processid = stoi(argv[2]);
kill(processid, signo);
}
(3)硬件异常产生信号
除0错误
CPU执行进程的代码,例如10/0,状态寄存器中的溢出标志位被置1,CPU通知操作系统自己出现异常,操作系统将硬件异常,转换为向导致该异常的进程发送8号信号SIGFPE(float point exception),该信号的默认处理方法是终止进程。
void handler(int signo)
{
cout << "signo: " << signo << endl;
sleep(1);
}
int main()
{
signal(8, handler);
int a = 5 / 0;
return 0;
}
以上代码会陷入死循环:CPU执行进程代码引发异常,不再向后执行,通知操作系统,操作系统让CPU调度其它进程,同时向目标进程发送8号信号,但由于用户对8号信号自定义捕捉,进程没有退出,当它重新被CPU调度时,硬件上下文加载到CPU中,再次引发异常。
解引用空指针
空指针就是进程地址空间中的0号地址,是一个无效地址,页表中没有映射关系。当CPU访问0号地址,通过MMU(内存管理单元,一种硬件,集成在CPU中),查询页表从虚拟地址转化为物理地址,MMU转化失败产生异常,通知操作系统,操作系统将硬件异常转化为向目标进程发送11号信号SIGSEGV(Segmentation Violation),该信号的默认处理行为是终止进程。
小结
进程出现异常,和语言没有关系,与操作系统和硬件有关。操作系统是软硬件的管理者,硬件异常是进程引起的,所以操作系统杀掉进程,硬件上下文数据也就没有了,硬件恢复健康状态。操作系统允许用户自定义捕捉异常相关的信号,是想让用户自定义一些行为,例如打印一些错误消息,但不终止进程的行为是不恰当的。
(4)软件条件产生信号
管道的读端关闭,写端仍然向管道发送数据,操作系统向写端进程发送13号信号SIGPIPE,该信号的默认处理方法是终止进程。这就是一种由软件异常,用户也可以自己设置软件条件是操作系统向进程发送常规信号。
每调用一次alarm,系统会在内核中创建一个内核数据结构,该结构用于描述设定的闹钟, 其中肯定包含了时间戳,以及设定闹钟的进程id等信息。有如此多闹钟,操作系统如何得知哪些闹钟超时了呢?操作系统将这些闹钟按照时间戳,用小根堆存储,只需对比堆顶闹钟的时间戳是否超时,假如超时就pop,没有超时说明所有的闹钟都没有超时。
闹钟超时操作操作系统向进程发送14号信号SIGALRM,默认处理方法是终止进程。
alarm的返回值
一个进程不允许同时设置多个闹钟,若旧闹钟的时间还没到,新闹钟会覆盖旧闹钟,同时返回旧闹钟剩余的秒数。
//每隔两秒闹钟响一次
void handler(int signo)
{
cout << "signo: " << signo << ", 闹钟响了" << endl;
alarm(2);
}
int main()
{
signal(14, handler);
alarm(2);
while (true)
{
sleep(1);
cout << "I am running, pid: " << getpid() << endl;
}
return 0;
}
总结
产生信号的方式有很多种,但最终都是操作系统向目标进程发送的信号,因为操作系统才是进程的直接管理者。操作系统是如何向进程发送信号的呢?下一部分信号的保存会详细讲解。
5.操作系统中的时间(补充)
- 所有用户行为都是以进程的行为在操作系统中表现的
- 操作系统只需要把进程调度好,合理把CPU,磁盘等资源分配给进程,就能完成用户任务
- 计算机中有一种硬件CMOS,周期性地高频率地向CPU发送时钟中断。
- CPU收到时钟中断后,CPU直接去中断向量表中执行操作系统的调度方法,去调度进程,也就是说,是时钟中断推着操作系统去调度进程的。
- 所以操作系统的运行是基于硬件中断的死循环,只不过有些中断是间歇性的,比如键盘,有些是持续性的,比如CMOS。
6.core和term
core和term都会终止进程,但是core在终止进程的同时还会core dump(核心转储),即将进程出错时的上下文数据转储到磁盘当中,命名为core.[pid],方便用户调试,定位错误位置。
查看core dump的设置,云服务器默认core dump文件大小限制为0,即关闭core dump
更改core dump文件大小限制(仅在当前shell终端有效)
core dump文件使用:
- 编译器编译时带上-g选项,生成debug版可执行程序。
- 运行可执行程序,程序终止并core dump
- 用gdb打开可执行程序,然后输入core-file [core dump file name]指令,即可定位错误的代码
三.信号的保存
1.信号未决,递达,阻塞
- 执行信号的处理方法叫做信号递达(delivery)
- 信号从产生到递达之间的状态叫做信号未决(pending)
- 进程可以阻塞某个信号,被阻塞的信号不能被递达,直到解除阻塞
信号的处理方法有三种:
- 默认处理方法
- 忽略(忽略也算处理信号,和阻塞不同)
- 自定义捕捉
void handler(int signo)
{
cout << "singo: " << signo << endl;
}
int main()
{
/*
typedef void(*sighandler_t)(int);
#define SIG_DFL ((sighandler_t)(0));
#define SIG_IGN ((sighandler_t)(1));
*/
signal(2, handler); //对2号信号自定义捕捉handler方法
signal(2, SIG_IGN); //对2号信号的处理方法设置成忽略
signal(2, SIG_DFL); //对2号的处理方法设置成默认
while (1)
{
cout << "I am runnint, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
举个栗子:
将古代皇帝批阅奏折比作递达信号,奏折送到皇帝的办公桌上,皇帝可能现在有更重要的事情要处理,比如朝会,所以奏折处于未决状态。
假如皇帝非常讨厌某个大臣,吩咐贴身太监,如果有他的奏折,就单独放在一边暂时不看,这叫做奏折被阻塞了。
如果有一天皇帝对那个大臣的印象转变,将它从黑名单删除,迫不及待地查看它以前写的奏折,这叫做奏折被解除阻塞,立即递达。
皇帝批阅奏折的方式也有多种,比如写上“知道了”,这是奏折的默认处理方法;看完后什么也不写,留中不发,这是忽略该奏折;看完后非常高兴,做出具体的答复,这叫奏折自定义捕捉。
未决的信号一定被被阻塞了吗?不一定,可能是该信号还没来得及处理
假如收到了信号,且该信号被阻塞了,则该信号一定是未决的?正确
没有收到信号,可以阻塞信号吗?可以
2.内核中关于信号的三张表
操作系统向进程发送信号,即将进程PCB中pending(未决)表相应位置的比特位由0置1。
因为有handler表,进程知道怎么处理对应的信号。因为有pending表,进程能保存信号,并在合适的时候处理。handler表是函数指针数组,pending表和block表是两张规模一样的位图,只不过比特位的内容表达的含义不同。
3.修改,查询阻塞和未决信号集
(1)系统提供给用户的数据类型sigset_t
系统中的block和pending表都是位图,让用户直接操作位图难度较大,所以操作系统将这种规模的位图typedef成一种数据类型sigset_t,并提供提供操作这种数据结构的系统调用。
在用户层,我们将pending位图叫做未决信号集,将block位图叫做阻塞信号集或者信号屏蔽字。信号屏蔽字类似于文件权限中的权限掩码。
(2)系统调用
#include <signal.h>
//更改本地用户区的信号集(不是修改内核中的信号集)
int sigemptyset(sigset_t *set); //将信号集的全部比特位置0
int sigfillset(sigset_t *set); //将信号集的比特位全部置1
int sigaddset (sigset_t *set, int signo); //将指定信号添加到信号集中(置1)
int sigdelset(sigset_t *set, int signo); //将指定的信号从信号集中去除(置0)
int sigismember(const sigset_t *set, int signo); //判断指定信号是否在信号集中
//用本地的信号集设置内核中的block表
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
//返回值:若成功则为0,若出错则为-1
//how:
//1. SIG_BLOCK 将本地信号中集的信号阻塞,即mask = set | mask
//2. SIG_BLOCK 将本地信号集中的信号解除阻塞,即mask = set & (~mask)
//3. SIG_SETMASK 将本地信号集赋值给内核中信号集,即mask = set
//set:输入型参数
//oldset:输出型参数,返回内核中旧的信号屏蔽字
//获取内核中的未决信号集
#include <signal.h>
int sigpending(sigset_t *set);
//读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
注意:9号信号SIGKILL和19号信号SIGSTOP不可被屏蔽,也不可被自定义捕捉或忽略!!!
//运行以下代码,向该进程发送2号信号,由于2号信号被屏蔽,
//2号信号将处于未决状态,15s后,取消屏蔽,2号信号被递达,
//观察pending表变化
void handler(int signo)
{
cout << "signo: " << signo << endl;
}
void PrintPending(const sigset_t& pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << '1';
}
else
{
cout << '0';
}
}
cout << endl;
}
int main()
{
signal(2, handler);
//1.屏蔽2号信号
sigset_t block, oblock;
sigemptyset(&block);
sigaddset(&block, 2);
sigprocmask(SIG_BLOCK, &block, &oblock);
//2.让进程不断获取当前进程的pending信号集
int count = 0;
while (true)
{
if (count == 15)
{
cout << "即将解除对2号信号屏蔽" << endl;
sigprocmask(SIG_SETMASK, &oblock, nullptr); //解除对2号信号屏蔽;
}
cout << "pid: " << getpid() << endl;
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
sleep(1);
count++;
}
return 0;
}
四.信号的处理
信号在和合适的时候处理,“合适”是什么时候?进程从内核态返回到用户态的时候,进行信号的检测和处理。
1.用户态和内核态
用户态是一种受控的状态,能够访问的资源有限,内核态是一种操作系统的状态,能够访问大部分系统资源,系统调用背后就包含了身份的变化。
(1)用户级页表和内核级页表
每个进程都有自己的地址空间,以32位机器为例,[0,3GB]是用户空间,[3,4GB]是内核空间。用户可以随意访问自己的用户空间,例如代码,数据,关联的动态库,环境变量和命令行参数等。每个进程都有自己的用户及页表,用于用户空间的虚拟地址向物理地址的映射。
操作系统有代码吗?有:系统调用,进程调度等,有数据吗?有:各种数据结构,如PCB,地址空间,文件对象,页表等。CPU如何快速找到操作系统的数据?事实上,系统中存在一张内核级页表,用于进程内核空间的虚拟地址向物理地址的映射(这种映射关系比用户级页表简单得多),所有进程共用这一张页表,因为操作系统在进程内核空间的位置是一样的。CPU直接通过进程内核空间和内核级页表就能找到操作系统,所以CPU可以在任意时刻找到操作系统。
进程的所有代码的执行,都可以在地址空间内跳转和返回。用户自己的代码在代码段,静态库在代码段,动态库在共享区,系统调用在内核区。
(2)用户态和内核态的标志
CPU当中的CS寄存器有两个标志位,1表示内核态,3表示用户态。所谓的切换用户态到内核态,就是将寄存器中的标志位由3置1。CPU中还有CR3寄存器,用于保存CPU正在调度的进程的用户级页表的地址(物理地址)。CR1寄存器保存最近一次引发缺页中断的虚拟地址。
系统调用入口处,操作系统先将CPU中的标志位由3改到1,表示此时CPU的背后是操作系统,所以CPU才能跳转到地址空间的内核区,执行操作系统的代码。 系统调用完成后,操作系统还要把信息从内核空间返回到用户空间,将内核态切换到用户态,
2.处理信号
操作系统将CPU从内核态切换到用户态之前,会检查信号集中是否有信号需要处理。加入pending集中某个信号为1,block集中为0,该信号就会被递达。若处理方法是忽略,只需将pending集中的比特位置0;如果是默认方法,也只需先置0,执行操作系统提供的代码。值得探讨的是自定义捕捉呢?
自定义捕捉方法是用户提供的,执行用户的代码必须将状态切换到用户态!!!因为操作系统不相信用户,不能把访问内核空间的机会留给用户。以用户态执行完自定义捕捉方法后,还要通过系统调用sigreturn从用户态切换到内核态,返回到系统调用中,把返回信息返回给用户空间,再将内核态切换到用户态。
3.系统调用
sa_mask
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时,自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。也即,操作系统不允许自定义捕捉嵌套调用。
递达中的信号被自动屏蔽,用户还可以通过设置sa_mask,在处理过程中屏蔽其它信号,当处理结束,信号自动解除屏蔽。
4.细节
操作系统检查信号集,发现有信号要处理,会先将pending表中比特位置0,再去递达信号
当有多个信号要处理,操作系统会先将所有信号都置0,然后按照一定优先级执行处理方法,最后统一返回用户态
操作系统递达信号时,会自动将本信号阻塞,使其无法嵌套递达,递达结束后,自动解除阻塞
五.信号的其他补充问题
1.函数重入
将一个结点头插到链表需要两步:
//p=&node1
p->next = head; //(1)
head = p; //(2)
将这两步封装成一个insert函数。假如进程收到了一个信号,由于没有从内核态返回用户态的的契机,信号暂时没有递达。
当main函数调用insert插入node1,主执行流执行完第(1)步时,恰好进程的时间片到了,CPU调度其它进程。
当CPU收到时钟中断,操作系统再次调度该进程,内核态向用户态切换,处理信号。而信号的处理方法恰好也调用了insert函数,插入node2。
完成两步之后,回到主执行流继续执行,最后的结果就是node2结点丢失了,最终造成内存泄漏!!!
insert函数被两个执行流(主执行流,处理信号的执行流)重复进入了,简称被重入了。因为该函数被重入,导致了内存泄漏。
如果一个函数被重入而不会引发问题,叫做可重入函数,否则叫不可重入函数。
是否可重入只是函数的一个特性,不应以它来判断一个函数的优劣。实际上大部分函数都是不可重入的(用到全局变量),这种函数放在多执行流下才有可能出现问题。
2.关键字volatile
VS下release版本下编译代码,编译器会做一些优化。gcc/g++同样如此,只不过需要指令设定。加上-O[n],n越大,优化程度越高,默认是0,没有优化。
g++ -o process process.c -std=c++11 -O1
int flag = 0;
void handler(int signo)
{
cout << "signo: " << signo << endl;
flag = 1;
cout << "change flag to : " << flag << endl;
}
int main()
{
signal(2, handler);
cout << "pid: " << getpid() << endl;
while (!flag)
{}
cout << "正常退出" << endl;
return 0;
}
加上-O1之前,运行得到的程序,按下ctrl+c进程正常退出。加上之后,再次编译运行,按下ctrl+c进程没有退出。
这就是编译器优化的缘故,编译器判定while循环内没有对flag作修改,所以转化成汇编代码就成为,第一次将flag的数据从内存读取到寄存器,以后的判断直接从寄存器取值,而不再读取内存的数据。但编译器没有料到还有信号处理方法更改了这一全局变量。
volatile:修饰变量,阻止编译器优化,保持CPU对内存的可见性
3. SIGCHLD信号
子进程退出的时候,会给父进程发送17号信号SIGCHLD
(1)基于信号回收子进程
//基于信号回收子进程
void handler(int signo)
{
cout << "signo: " << signo << endl;
waitpid(-1, nullptr, 0); //阻塞式等待
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
cout << "child is running" << endl;
sleep(5);
exit(10);
}
int cnt = 10;
while(cnt--)
{
sleep(1);
}
}
//产生的问题:多个子进程同时退出,向父进程发送SIGCHLD信号
//但未决信号集中只能保存一个信号,waitpid只能执行1次
//解决方案:基于信号,非阻塞循环等待子进程
void handler(int signo)
{
cout << "signo: " << signo << endl;
pid_t id = 0;
while ((id = waitpid(-1, nullptr, WNOHANG)) != 0) //如果还有子进程没有退出,waitpid就会返回0
{
if (id == -1) //如果没有子进程了,waitpid就会返回-1
{
break;
}
cout << "回收进程" << id << endl;
}
}
(2)手动忽略SIGCHLD
signal(SIGCHLD, SIG_IGN);
效果:子进程退出时不再给父进程发送信号,父进程也无需wait回收资源,僵尸进程自动被操作系统释放(该方法只在Linux系统有效)
注意:这里我们手动设置SIG_IGN是一个特例,和我们之前讲的信号处理方法中的忽略不同。事实上,操作系统对于该信号默认的处理动作就是我们之前的SIG_IGN。
总结
- 生活中的信号特点类比操作系统中的信号
- 产生信号的四种方式:键盘,系统调用,硬件异常,软件条件
- 保存信号的三张表:pending,block,handler,以及皇帝批奏折的例子
- 信号处理过程的一张“无穷”图,用户态和内核态理解
- 函数重入概念初步了解
- volatile关键字用法
- SIGCHLD信号的应用——基于信号循环回收子进程和忽略子进程信息回收