目录
信号入门
1. 生活角度的信号
2. 技术应用角度的信号
3. 注意
4. 信号概念
5. 用kill -l命令可以察看系统定义的信号列表
6. 信号处理常见方式概览
产生信号
1. 通过终端按键产生信号
2. 调用系统函数向进程发信号
3. 由软件条件产生信号
4. 硬件异常产生信号
核心转储(调试技巧)
总结
阻塞信号
1. 信号其他相关常见概念
2. 在内核中的表示
3. sigset_t
4. 信号集操作函数
sigprocmask
sigpending
捕捉信号
2. sigaction
可重入函数,以及volatile
SIGCHLD信号 - 了解
信号入门
1. 生活角度的信号
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需 5min 之后才能去取快递。那么在在这5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“ 在合适的时候去取“快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
进程是怎么识别信号的? 认识+动作
如果信号要保存,那么应该保存在哪里呢?学习过之前的知识,我们可以推测到可能保存到了PCB中了,因为OS要管理进程,就必须要先描述,再组织。而其实 发送信号的本质其实是修改PCB中的位图而已,我们可以通过 kill -l来查看信号 :如果是31个信号的话,我们就可以使用位图来表示,每个比特位表示一个信号状态。这样我们通过修改位图的方式就可以发送信号了。但是想要修改位图,我们就必须要改变通过OS,但是OS又不信任我们用户,所以就会提供系统调用接口。这样就完全和前面的知识都串联起来了。
2. 技术应用角度的信号
1. 用户输入命令,在Shell下启动一个前台进程。
用户按下 Ctrl-C , 这个键盘输入 产生一个硬件中断 ,被 OS 获取,解释成信号,发送给目标前台进程前台进程因为收到信号,进而引起进程退出这里的Ctrl - C等于2号信号,我们可以通过改变2号的方式,让Ctrl-C不再能退出
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void helper(int signo)
{
cout<<"捕捉到一个信号,其信息编号是"<<signo<<endl;
}
int main()
{
signal(2,helper);
while(true)
{
cout<<"我是一个进程,我的id是:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
结果:我们没有办法使用ctrl -c来结束
3. 注意
1. Ctrl - C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行 , 这样 Shell 不必等待进程结束就可以接受新的命令, 启动新的进程。2. Shell 可以同时运行一个前台进程和任意多个后台进程 , 只有 前台进程才能接到像 Ctrl-C 这种控制键产生的信号。3. 前台进程在运行过程中用户随时可能按下 Ctrl - C 而产生一个信号 , 也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止 , 所以 信号相对于进程的控制流程来说是异步(Asynchronous) 的。
4. 信号概念
5. 用kill -l命令可以察看系统定义的信号列表
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
这些信号各自在什么条件下产生, 默认的处理动作是什么 , 在 signal(7) 中都有详细说明 : man 7 signal
6. 信号处理常见方式概览
处理动作有以下三种 :1. 忽略此信号。2. 执行该信号的默认处理动作。3. 提供一个信号处理函数 , 要求内核在处理该信号时切换到用户态执行这个处理函数 , 这种方式称为捕捉(Catch)一个信号。
产生信号
1. 通过终端按键产生信号
前面我们已经通过ctrl + c的方式终止了进程,这就是其中的一个方法,当然还有其他的快捷点替代其他的信号。
2. 调用系统函数向进程发信号
我们先看看系统调用的3个函数可以对进程发信号:1.2的头文件都是: #include <signal.h>
1.kill :可以想任意进程发送任意信号int kill(pid_t pid, int signo);2.raise:给自己 发送 任意信号 等价于 kill(getpid(), 任意信号)int raise(int signo);3.abort : 给自己 发送 指定的信号SIGABRT 等价于 kill(getpid(), SIGABRT)#include <stdlib.h>void abort(void);我们可以看到其实kill的系统调用函数很强大,另外两个都可以通过kill来模拟实现。
#include <iostream>
using namespace std;
#include <string>
#include <unistd.h>
#include <cassert>
#include <sys/types.h>
#include <signal.h>
void Usage(const string& proc)
{
cout<<"Usage: "<<"pid signo"<<endl;
exit(1);
}
int main(int argc,char*argv[])
{
if(argc != 3)
{
Usage(argv[0]);
}
pid_t pid = atoi(argv[1]);
int signo = atoi(argv[2]);
int n = kill(pid,signo);
assert(n == 0);
return 0;
}
结果:
int main(int argc,char*argv[])
{
int cnt = 0;
while(cnt < 10)
{
cout<<"我是一个进程,我的id以及次数cnt是:"<<getpid()<<" "<<cnt++<<endl;
if(cnt == 5)
{
raise(9);
}
sleep(1);
}
}
int main(int argc,char*argv[])
{
int cnt = 0;
while(cnt < 10)
{
cout<<"我是一个进程,我的id以及次数cnt是:"<<getpid()<<" "<<cnt++<<endl;
if(cnt == 5)
{
//raise(9);
abort();
}
sleep(1);
}
}
结果和raise的是一样的
3. 由软件条件产生信号
这里主要介绍 alarm函数 和SIGALRM信号#include <unistd.h>unsigned int alarm(unsigned int seconds);调用 alarm 函数可以设定一个闹钟 , 也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号 , 该信号的默认处理动作是终止当前进程。
int count = 1;
void catchsig(int signo)
{
cout<<"捕捉到一个信号 signo: "<<signo<<"count:"<<count<<endl;
exit(1);
}
int main(int argc,char*argv[])
{
signal(SIGALRM,catchsig);
alarm(1);
while(true)
{
//cout<< "我是一个进程,正在打印count的值"<<count++<<endl;
count++;
}
}
看看结果:
通过对比这两次的结果我们不难发现,cpu的运行速度远超打印的速度。这里也体现的alarm的使用
可能很多进程都需要使用的闹钟,所以闹钟需要被OS系统进行管理,如何管理?先组织再描述。使用struct结构体去封装,然后通过特定的数据结构进行管理,那么操作系统对闹钟的管理其实就变成了对数据结构的增删查改。
4. 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。 例如当前进程执行了除以0 的指令 ,CPU 的运算单元会产生异常 , 内核将这个异常解释 为 SIGFPE 信号发送给进程。再比如当前进程访问了非法内存地址,,MMU 会产生异常 , 内核将这个异常解释为 SIGSEGV 信号发送给进程。
我们先通过上面的例子来验证一下:
然后我们看看这个错误是哪个信号造成的:
然后我们再通过代码来验证一下:
void catchsig(int signo)
{
cout<<"捕捉到一个信号 signo: "<<signo<<"count:"<<count<<endl;
exit(1);
}
int main(int argc,char*argv[])
{
signal(SIGFPE,catchsig);
int a = 10;
a /= 0;
}
看看结果:
我们可以通过画图来理解一下:
如果是空指针的解引用呢?
画图理解:
在之前学习进程地址空间的时候,我们就知道操作系统不信任何人,所以我们写的代码如果有错误是不会在真实的物理内存上运行的。
核心转储(调试技巧)
假设我们写了一个数组越界的代码:
如果我们正常运行是有可能报错,也有可能不报错的,因为对于C语言的数组的检测方式是抽查,而不像C++vector的暴力检查。
如果出现段错误,那么我们基本可以肯定的是我们的代码可能是越界访问了。
我们可以看到这个文件的大小为0,我们可以通过下面指令来扩大其size
然后我们再运行一下刚刚那个错误代码:
当进程出现异常的时候,我们将进程在对应的时刻,在内存中的有效数据转储到磁盘中,这就是核心转储,那为什么要核心转储呢?是为了方便我们调试,怎么支持调试?我们可以在gdb的上下文中输入code-file core.xxxx
在看过之前我们使用signal进行了信号行为的替换之后,我们可能有这样的想法:如果把每个信号都替换了,那这个进程不就杀不死了吗?真的如此吗?我们可以试试:
void catchsig(int signo)
{
cout<<"捕捉到一个信号 signo: "<<signo<<endl;
//exit(1);
}
int main(int argc,char*argv[])
{
for(int signo = 1;signo<=31;++signo)
{
signal(signo,catchsig);
}
while(true)
{
cout<<"hello world"<<endl;
sleep(1);
}
}
看看结果:
从这里我们可以看出来,操作系统是不允许出现杀不死进程这种情况的,我们可以通过kill -9 杀死。
我们看看另外一个问题:看看这段代码:
void catchsig(int signo)
{
cout<<"捕捉到一个信号 signo: "<<signo<<endl;
//exit(1);
}
int main()
{
signal(8,catchsig);
while(1)
{
cout<<"我是一个进程,正在运行 "<<getpid()<<endl;
sleep(1);
int a = 10;
a/=0;
}
return 0;
}
我们会发现这里一直打印我们替换了函数的内容,为什么呢?
因为我们收到信号并没有退出,而CPU之后一个,每次进程使用后cpu的时候,都被cpu的寄存器检测到了那个异常的比特位,所以就会进行打印。而进程并没有退出,所以就会有无数次进程的状态被寄存器保存和恢复。
总结
1.上面所说的所有信号产生,最终都要有 OS 来进行执行,为什么? OS 是进程的管理者2.信号的处理是否是立即处理的?在合适的时候,可能进程在更重要的事情。3.信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?需要被进程暂时记录下来,记录在PCB中最合适4.一个进程在没有收到信号的时候,能否知道,自己应该对合法信号作何处理呢?肯定知道,因为只有知道了,接受后才会采取相应的措施5.如何理解 OS 向进程发送信号?能否描述一下完整的发送处理过程?OS向进程发送信号的本质其实就是在进程的PCB中的位图中的比特位由0置为1
阻塞信号
1. 信号其他相关常见概念
实际执行信号的处理动作称为信号递达 (Delivery)信号从产生到递达之间的状态,称为信号未决 (Pending) 。进程可以选择阻塞 (Block ) 某个信号。被阻塞的信号产生时将保持在未决状态 , 直到进程解除对此信号的阻塞 , 才执行递达的动作 .注意 , 阻塞和忽略是不同的 , 只要信号被阻塞就不会递达 , 而 忽略是在递达之后可选的一种处理动作 。
2. 在内核中的表示
因为信号的发送其实本质是在进程的PCB中把其中的位图由0置为1。
那么我们常用的信号有31种,我们只需要一个int大小的位图就可以实现对信号的保存。
这样我们就可以通过改变pending或者block位图中的bit就可以对信号进行接受或者屏蔽。
通过大致上面的代码我们就可以大致了解了信号是怎么被处理的
3. sigset_t
未决和阻塞标志可以用相同的数据类型sigset_t来存储 ,sigset_t 称为信号集 , 这个类型可以表示每个信号的“ 有效 ” 或 “ 无效 ” 状态 , 在阻塞信号集中 “ 有效 ” 和 “ 无效 ” 的含义是该信号是否被阻塞 , 而在未决信号集中 “ 有效” 和 “ 无效 ” 的含义是该信号是否处于未决状态
4. 信号集操作函数
#include <signal.h>int sigemptyset(sigset_t *set);int sigfillset(sigset_t *set);int sigaddset (sigset_t *set, int signo);int sigdelset(sigset_t *set, int signo);int sigismember ( const sigset_t *set, int signo);函数sigemptyset初始化 set 所指向的信号集 , 使其中所有信号的对应 bit 清零 , 表示该信号集不包含 任何有效信号。函数 sigfillset 初始化 set 所指向的信号集 , 使其中所有信号的对应 bit 置位 , 表示 该信号集的有效信号包括 系统支持的所有信号 。注意 , 在使用 sigset_ t 类型的变量之前 , 一定要调 用 sigemptyset 或 sigfillset 做初始化 , 使信号集处于确定的状态。初始化sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset在该信号集中添加或删除某种有效信 号。这四个函数都是成功返回 0, 出错返回 -1。sigismember 是一个布尔函数 , 用于判断一个信号集的有效信号中是否包含某种 信号, 若包含则返回 1, 不包含则返回 0, 出错返回 -1 。
sigprocmask
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);返回值 : 若成功则为 0, 若出错则为 -1如果 oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出 。如果 set是非空指针,则 更改进程的信号屏蔽字 , 参数 how 指示如何更改。 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里 , 然后根据set 和 how 参数更改信号屏蔽字。
how参数:
SIG_BLOCK是屏蔽信号使用的,SIG_UBLOCK是解除屏蔽的信号,SIG_SETMASK是将当前的set改成你自己想要的set;
sigpending
读取当前进程的未决信号集 , 通过 set 参数传出。调用成功则返回 0, 出错则返回 -1 。
我们可以利用上述学的函数可以写个小代码:
思路:我们可以在进程运行的时候查看信号的状态,我们可以在前10s内将某个或者多个信号屏蔽,然后通过使用发送信号,然后查看信号是否被屏蔽
代码实现:
#include <iostream>
#include <vector>
#include <unistd.h>
#include <signal.h>
using namespace std;
#define MAX_SIG 31 // 信号的个数
// 创建一个全局的数组,这里可以屏蔽多个信号
static vector<int> sig_arr = {2};
void printsig(const sigset_t &set)
{
for (int i = MAX_SIG; i > 0; --i)
{
// 判断第几号信号是否在pending位图中
if (sigismember(&set, i))
{
cout << "1";
}
else cout << "0";
}
cout << endl;
}
void myhander(int signo)
{
cout << signo << "号信号已经执行" << endl;
}
int main()
{
// 我们可以把信号执行的方法改一下,方便更好的观察
for (const auto &sig : sig_arr)
{
signal(sig, myhander);
}
// 设置屏蔽的指定信号
sigset_t block, oblock, pending;
// 初始化
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
// 添加屏蔽的信号
for (const auto &sig : sig_arr)
{
sigaddset(&block, sig);
}
// 把屏蔽的信号设置进内核
sigprocmask(SIG_SETMASK, &block, &oblock);
// 然后通过观察pending位图中的01序列即可知道是否完成屏蔽
// 这里10s后就取消屏蔽
int cnt = 10;
while (true)
{
// 初始化,这里其实没有必要
sigemptyset(&pending);
// 获取
sigpending(&pending);
// 自己写一个打印函数去观察
printsig(pending);
//1s打印一次
sleep(1);
if (cnt-- == 0)
{
// 取消屏蔽然后再观察,因为前面已经保存了之前的状态,这里反过来重新设置即可
sigprocmask(SIG_SETMASK, &oblock, &block);
// 这里屏蔽信号之后会立刻处理至少一个的信号
cout << "取消屏蔽信号" << endl;
}
}
return 0;
}
看看结果:
因为当我们取消屏蔽这个信号之后,OS至少要处理一个信号,如果这里我们没有把信号处理的默认方法改了,那么我们就无法看到取消屏蔽信号这句话了。
捕捉信号
我们通过上面这段代码应该有这样的疑问:我们在使用系统调用接口的时候,身份应该要改变才能执行系统的代码才对,那么我们是怎么样由用户态转换成内核态的呢?
这里我们的CPU就通过一个寄存器来实现内核态和用户态身份的转换,有一个CR3的寄存器来存储当前进程的运行级别,0表示内核态,3表示用户态,我们在执行系统内部的代码的时候OS是要看进程的身份才能执行的。因为OS不相信任何人。如果我们大量使用系统调用那么必然导致的一个问题就是效率大大降低,因为身份转换是需要时间的,所以为了保证效率,我们要尽可能的少点使用系统接口。
那么还有一个问题:既然是一个进程在执行,怎么会跑到OS去执行了呢?怎么做到的?
在之前我们学习进程地址空间的时候其实还有一部分内容没有完全学到:
通过上图我们就可以知道实际上我们的进程地址空间其实有1G就内核级的。
那么刚刚的代码的信号捕捉的过程就怎么样的呢?
我们可以通过上图来理解,首先我们在用户态,然后执行系统调用我们需要进行一次身份切换,然后找PCB中的block位图已经pending位图,如果用户有对应的方法,我们就需要 回答用户态执行对应的方法,那么有个问题:此时我们是否需要进行身份切换呢?答案是一定要的,因为如果我们以内核态来执行对应的代码就有可能对OS的内部信息做修改,这是OS不允许发生的事情,所以这里必然也有一次身份切换,执行完用户态代码,我们就回到了内核态,然后通过特定的系统调用返回用户态继续执行接下来的代码。所以其中总共有4次的身份切换。
2. sigaction
#include <signal.h>int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0, 出错则返回 - 1 。 signo是指定信号的编号。若act 指针非空 , 则根据 act 修改该信号的处理动作。若 oact 指针非 空 , 则通过 oact 传出该信号原来的处理动作。act 和 oact 指向 sigaction 结构体 :
这个结构体中比较重要的的就是这个sa_mask,通过这个可以设置其他的信号也会被屏蔽,
当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就 保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止 , 当 信号处理函数返回时自动恢复原来的信号屏蔽字
总之就是在一个信号接受的时候会自动屏蔽同类信号,知道该信号处理完毕才会恢复成原来的情况,如果这里我们会2号信号进行多次的发送,这里只会有2次2号信号的处理:
void Count(int n)
{
//用来计数
while(n)
{
printf("count: %2d\r",n);
fflush(stdout);
--n;
sleep(1);
}
cout<<endl;
}
void handler(int signo)
{
cout<<"接受到一个信号,signo:"<<signo<<endl;
Count(10);
}
int main()
{
struct sigaction act,oact;
act.sa_flags = 0;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
//我们也可以把其他信号屏蔽放进来
sigaddset(&act.sa_mask,3);
sigaction(2,&act,&oact);
while(1)
{
//cout<<"mypid :"<<getpid()<<endl;
sleep(1);
}
return 0;
}
我们可以看看结果:
为什么是这样的结果呢?
很简单,首先这里2号信号被屏蔽了,当我们再次发送2号信号的时候只能改一次pending位图,所以只能接受一次信号,而我们上面是已经屏蔽了3号信号的,所以,当2号信号执行完成之后,就会处理3号信号,进程就会退出。
可重入函数,以及volatile
如果一个函数符合以下条件之一则是不可重入的 :调用了 malloc 或 free, 因为 malloc 也是用全局链表来管理堆的。调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。以上两个条件决定了大部分的函数都是不可重入的函数,我们可以看看下面这个例子,来了解这个概念:
int n = 0;
void handler(int signo)
{
cout<<n<<endl;
++n;
cout<<n<<endl;
}
int main()
{
signal(2,handler);
while(!n)
{
//这里什么都不做,如果没有退出就说明有问题
}
cout<<"我是一个正常退出的进程"<<endl;
return 0;
}
这是正常情况下的:
当我们把编译器优化开到O3的时候,这个问题就体现出来了:
但是如果我们使用了C语言的volatile关键字就不会出现这个问题:
这个关键字的作用就是保证内存的可见性,那么上述问题为什么会这样呢,我们画图分析:
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
SIGCHLD信号 - 了解
进程一章讲过用 wait 和 waitpid 函数清理僵尸进程 , 父进程可以阻塞等待子进程结束 , 也可以非阻 塞地查询是否有子进程结束等待清理( 也就是轮询的方式 ) 。采用第一种方式 , 父进程阻塞了就不 能处理自己的工作了 ; 采用第二种方式 , 父进程在处理自己的工作的同时还要记得时不时地轮询一 下, 程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号 , 该信号的默认处理动作是忽略 , 父进程可以自 定义 SIGCHLD 信号的处理函数, 这样父进程只需专心处理自己的工作 , 不必关心子进程了 , 子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可 。
由于 UNIX 的历史原因 , 要想不产生僵尸进程还有另外一种办法 : 父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程 。系统默认的忽略动作和用户用sigaction 函数自定义的忽略 通常是没有区别的 , 但这是一个特例。 此方法对于Linux可用,但不保证在其它UNIX系统上都可 用 。OS会自动回收子进程。
void Count(int n)
{
//用来计数
while(n)
{
printf("count: %2d\r",n);
fflush(stdout);
--n;
sleep(1);
}
cout<<endl;
}
int main()
{
//显示设置,这样OS可以自动的回收子进程
signal(SIGCHLD,SIG_IGN);
cout<<"我是父进程,我的ID是:"<<getpid()<<endl;
int n = fork();
if(n == 0)
{
printf("我是一个子进程,我的id是 %d,父进程id是: %d\n",getpid(),getppid());
Count(10);
exit(1);
}
//父进程什么都不做,不需要回收子进程,
while(true)
sleep(1);
return 0;
}