目录
一关于信号
二信号概念
三信号产生
四信号处理
1核心转储
2信号的其它常见概念
3函数实现
五信号捕捉
1捕捉流程
2内核态与用户态
2.1再谈地址空间
2.2键盘输入数据
2.3OS的运行过程
3函数实现
六其它问题
1可重入函数
2.volatile
3SIGCHLD
一关于信号
a.生活中信号随处可见:
在手机上定闹钟,时间一到就响;
过马路时通过红绿灯信号来判断是否能通行;
这次考试考的不好,回到家看到父母脸色阴沉,推断老师肯定是给父母打电话了...
b.信号的产生:前提是你能认识这个信号 ——也就是说:信号和我是异步的
c.我们知道信号产生了:我们认识信号并做出处理
d.但当前我们也许做着更重要的事,就把当前到到来的信号暂时不处理:但前提是:
你要记住这个到来的信号;在合适的时候进行处理...
而在Linux中,这个我就代表了进程;
在Linux中,用指令:kill -l 来查看:本文只考虑1~31的普通信号,其它不考虑
二信号概念
信号:Linux中提供的一种,向指定进程发送特定事件的方式
信号的产生是异步的:
进程在跑的时候,是不知道信号什么时候到来,它一直在忙自己的事...知道信号到来
信号处理有三种方式:
a.默认动作
b.忽略动作
c.自定义捕捉(处理)
而进程在处理信号时都是默认的;默认信号包含:终止自己,暂停,忽略
通过指令:man 7 signal 来进行查看信号的默认动作:
而自定义捕捉通过signal函数来实现:让我们通过简单的代码来实现一下
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;void handler(int sig)
{cout << "get a sig: " << sig << endl;
}int main()
{//对信号的自定义捕捉,我们只要捕捉一次,后续一直有效signal(2, handler); // 信号(也可以写成SIGINT),方法while (true){cout << "hello bit, pid: " << getpid() << endl;sleep(1);}return 0;
}
通过上面简单的例子,我们来回答以四个问题:
1. 如果一直不产生? 捕捉方法就不产生任何作用;直到2号信号的到来 2. 可不可以对更多的信号进行捕捉?
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;void handler(int sig)
{cout << "get a sig: " << sig << endl;
}int main()
{// 对信号的自定义捕捉,我们只要捕捉一次,后续一直有效signal(2, handler); // 信号,方法signal(3, handler);signal(4, handler);signal(5, handler);while (true){cout << "hello bit, pid: " << getpid() << endl;sleep(1);}return 0;
}
可以!! ctrl+c --- 给目标进程发送2号信号
3. 2 SIGINT是什么呢?
把上面的捕捉2号信号去掉来进行演示:
原来:2号信号是键盘的:ctrl+c = 给目标进程发送2号信号
我们也明白了当初学习的时候,如果-==进程出现异常了用ctrl+c:原来是对进程进行终止啊!
浅度理解信号的保存与发送:
进程:task_struct结构体中有一个成员变量,用位图来保存接收到的信号;
如:收到1号信号就把(从右向左从0开始)第一位比特位由0变1,其它类似;
发送信号:修改指定进程PCB的保存信号的位图就完成操作
这些事情都是OS在做:数据都是内核数据,只有OS能进行修改
三信号产生
1.指令:kill SignalNum(-2) 进程pid 来给指定进程发送信号
2.键盘产生信号:ctrl+c(2) ctrl+\(3)
3.系统调用:kill函数(重要)和raise函数(不重要)
raise函数给自己发信号:
abort函数给自己发送6号信号: (即使捕捉了,也会终止进程!!)
用kill函数做测试:
//myprocess.cc
#include <iostream>
#include <unistd.h>
#include<signal.h>
using namespace std;void handler(int sig)
{cout << "get a sig: " << sig << endl;
}int main()
{signal(2,handler);while (true){cout << "hello bit, pid: " << getpid() << endl;sleep(1);}return 0;
}//mykill.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;//./ mykill signalnum pid
int main(int argc, char *argv[])
{if (argc != 3){std::cerr << "Usage: " << argv[0] << " signum pid" << std::endl;return 1;}pid_t pid = stoi(argv[2]);int signum = stoi(argv[1]);kill(pid, signum);//系统调用return 0;
}
现象:
在这里,有两个问题:
a.如果我把所有的信号全部捕捉了呢?进程不就永远不退出了?
事实证明:你考虑到的,别人也考虑到了;
在1~31号信号中,9号信号(还有别的)是不允许被自定义捕捉的
b.如何理解上面的信号发送?
在前面,我们说了:进程退出有三种方式:
代码跑完结果对;代码跑完结果错误;进程出现异常退出
异常退出明显是收到了信号终止进程,那是谁给它发送信号的呢?
只有一个人有(修改位图)的权利:OS!!
那键盘产生信号又是OS又是如何做到的呢?
4.软件条件
在前面学习的管道中,如果读端关闭,写端一直写:OS会判断该管道是broken pipe,会直接进行终止进程;而终止进程就是对读端进程发送13(SIGPIPE)信号,这是软件条件的方式之一;
除了管道,在Linux中还存在一个系统调用:int alarm()也是软件条件的方式
alarm(闹钟):设置未来时间;时间一到,进程就会收到14(SIGALRM)信号终止进程
void handler(int sig)
{cout << "get a sig: " << sig << endl;exit(1);
}int main()
{alarm(5);int cnt=0;signal(14,handler);while(true){cout<<"cnt:"<<cnt++<<endl;}return 0;
}
5s后进程收到14号信号自定义捕捉后退出
通过它,我们来解决下面的三个子问题:
a.验证IO :把cnt设置成全局变量
long long cnt=0;
void handler(int sig)
{cout<<"cnt:"<<cnt<<endl;cout << "get a sig: " << sig << endl;exit(1);
}int main()
{alarm(5);signal(14,handler);while(true){cnt++;}return 0;
}
与前面作对比:
整整相差一千倍!!
在前面每次打印cnt中,要把数据打印到显示器上,显示器也是文件:进行IO交互:IO很慢!
b.理解闹钟
1.生活例子:如果你不使用几天你的电脑,当你再次打开时,时间也还是准确无误?
因为在你电脑里面,有一个‘纽扣电池’:在你不开机时,它在维持着(统计)电脑上的时间
通过时间戳的比对来确定时间的正确性
2.既然alarm是系统提供的:那我想设置多个闹钟,OS是如何进行管理的呢?
先描述,在组织!在底层,alarm是一个结构体对象,里面保存着很多变量:
未来超时时间:time_t expired,进程pid:pid_t pid,回调函数func_t fuc...
通过创建小堆的结构体方式来管理alarm:top()闹钟超时了进行pop()...
管理工作也就变成了对小堆的增删查改!!
c.alarm返回值
int main()
{alarm(5);sleep(2);int n=alarm(0);//alarm(0)取消闹钟cout<<"n:"<<n<<endl;return 0;
}
alarm的返回值:上一个闹钟的剩余时间
5.异常
在学习C/C++的时候,也许你会写出这样的代码:除0错误与野指针错误导致程序出现异常退出:
int main()
{//int a=10;//a/=0;int*p=nullptr;*p=200;return 0;
}
除0:
野指针:
a.程序为什么会崩溃呢?
因为非法操作,非法访问:导致OS给该进程发送信号了:8) SIGFPE 11) SIGSEGV
b.崩溃了为什么会退出?
收到的信号的默认动作是终止
c.可以不退出吗?
可以:进行自定义捕捉;但推荐终止进程
自定义捕捉:
void handler(int sig)
{cout << "get a sig: " << sig << endl;
}
int main()
{// signal(8,handler);// int a=10;// a/=0;signal(11,handler);int*p=nullptr;*p=200;return 0;
}
除0错误:
野指针错误:
d.进程崩溃了,OS发信号,为什么?
e.信号的自定义捕捉为什么是死循环的进行?
要解决这些问题,我们要深入硬件层进行理解
关于除0问题
程序在要进行运算的过程是要CPU来处理的:运算分为逻辑运算与算术运算;
而运算的正确与否只有CPU知道:那CPU是怎么判断的呢?
在CPU内部,有众多寄存器:在eflag寄存器中,有一个溢出标记位:如果程序运算出现错误,标记位由0变1;而最终的结果是要让OS知道的(OS是软硬件资源的管理者):知道了错误结果,但OS又不能直接终止进程(进程可能在进行IO,自己终止会导致数据丢失,OS不背),它就给目标进程发送信号,此时进程中断(CPU中寄存器只有一套,但寄存器的资源是属于每个进程的-->发生硬件的上下文保存与恢复);但用户进行了信号的自定义捕捉,收到信号没有退出,进程(数据)恢复,OS又发现给进程出现异常,又进行发送信号...
造成我们在上面看到的信号循环的进行捕捉
但此时我们推荐终止进程:释放进程上下文数据,包括溢出标记位数据及其它异常数据
关于野指针问题
首先,我们要明白:平时我们所说的地址:都是虚拟地址!!虚拟到物理地址的转化要通过页表来完成:而在页表中的转化主要是:MMU硬件电路来完成;但这个又恰好集成在CPU内部,配合CPU中的CR3寄存器共同完成转化工作;但你传接下来的虚拟地址是错误的,CPU肯定是知道的:就把当前错误的虚拟地址放到CR2寄存器(是页故障性地址寄存器)中;
与上面的流程一样:最终由OS来对目标进程发送信号,告诉进程你可以走’了!!
总结:以上的信号产生的五种方式,本质上都是要由OS来对目标进程发送信号来到达某种目的!
四信号处理
1核心转储
在进程概念里,我们讲了子进程在退出时要把退出码交给父进程来进行回收
但下面的core dump标志还没谈过:现在来进行解答:
信号默认终止动作有两个:Core和Term
其中终止为Core的进程异常终止时会帮我们形成debug文件(进程退出时的镜像数据)
但要先进行打开:ulimit -c 大小
现在我们写一段除0代码,来验证是否有core文件产生:
#include<iostream>
using namespace std;
int main()
{int a=10/0;return 0;
}
结果是:异常退出时形成了core文件以及后缀加进程Pid:多运行几下会发现会又多了几个:
当前机器的版本较老,新版本的机器只会有一个core文件(不加进程Pid)(不管运行几次):原因是怕程序不断的重启产生的core文件是磁盘爆满!!
把core文件加到gdb调试器中,帮助我们锁定错误的信息在哪里,进行修改:
最后,我们让子进程来执行上面的Sum函数,看看子进程退出的信息进行验证:
#include<iostream>
#include<unistd.h>
#include<sys/wait.h>
using namespace std;int Sum(int start, int end)
{int sum = 0;for(int i = start; i <= end; i++){sum /= 0; // coresum += i;}return sum;
}int main()
{// int total = Sum(0, 100);// cout << "total: " << total << endl;pid_t id = fork();if(id == 0){sleep(2);// childSum(0, 100);exit(0);}// fatherint status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id){printf("exit code: %d, exit sig: %d, core dump: %d\n", (status>>8)&0xFF, status&0x7F, (status>>7)&0x1);}return 0;
}
结果: (core dump标志位在不同的版本是不同的)
2信号的其它常见概念
实际执行信号的处理动作称为信号递达(Delivery)(默认处理动作:1默认2忽略3自定义捕捉)
信号从产生到递达之间的状态,称为信号未决(Pending)
进程可以选择阻塞(Block)某个信号:
阻塞一个信号,那么对应的信号一旦产生,永不递达,直到自动解除阻塞!
那么:一个信号如果阻塞和它有没有未决有关系吗?
就像你讨厌一个老师,他给你布置作业,你就让这些作业‘阻塞’在那里(就是不做);但后来上着上着,发现你不讨厌他了:算了,给这些作业取消‘阻塞’吧:开始去完成作业了
你把作业‘阻塞’在那里,是不耽误老师给你布置作业的
同样,信号阻塞了,和它有没有未决没有关系!!
接下来看看信号在内核中的分布图:
其中:block——阻塞信号;pending——信号未决;handler——信号递达是所要执行的方法
pending表和block表大致相同:里面是位图结构构成的:
比特位的位置:表示信号编号
比特位的内容:表示信号是否收到(阻塞)
而handler表就不一样了:它是一张函数指针数组表:(sighandler_t handler[32])
信号的编号表示数组的下标,可以采用数组编号,索引信号处理方法!
这些都是OS在内核中去帮我们为每个进程所维护的!!
我们之前使用:sighandler_t signal(int signum,sighandler_handler) 进行自定义捕捉,其实是修改信号所要执行对应的handler表的方法
结论:两张位图 + 一张函数指针数组 = 进程识别信号
3函数实现
系统为用户提供了sigset_t类型的结构体来对着三张表进行操作
#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);//判断是否包含某个信号
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);//读取或修改进程的信号屏蔽字
返回值:若成功则为0,若出错则为-1how有多个选项:
现在让我们来用上面的函数来实现:
1.把2号信号添加到block表中 2.打印pending表
3.解除对2号信号的阻塞 4.再次打印pending表(观察2号信号由1变0)
#include<iostream>
#include<unistd.h>
#include<sys/wait.h>void handler(int signo)
{std::cout << signo << " Singnal Handler !" << std::endl;}void PrintPending(sigset_t &pending)
{std::cout << "curr process[" << getpid() << "]pending: ";for (int signo = 31; signo >= 1; signo--){if (sigismember(&pending, signo)){std::cout << 1;}else{std::cout << 0;}}std::cout << "\n";
}int main()
{// 0. 捕捉2号信号signal(2, handler); // 自定义捕捉// 1. 屏蔽2号信号sigset_t block_set, old_set;sigemptyset(&block_set);sigemptyset(&old_set);sigaddset(&block_set, SIGINT); // 我们有没有修改当前进行的内核block表呢???1 0// 1.1 设置进入进程的Block表中sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!int cnt = 6;while (true){// 2. 获取当前进程的pending信号集sigset_t pending;sigpending(&pending);// 3. 打印pending信号集PrintPending(pending);cnt--;// 4. 解除对2号信号的屏蔽if (cnt == 0){std::cout << "Relieve Two Signal !" << std::endl;sigprocmask(SIG_SETMASK, &old_set, &block_set);}sleep(1);}
}
现象:
总结:1.解除阻塞,一般会立即处理当前被解除的信号
2.pending位图对应的信号在递达之前被清0(在信号捕捉中再次打印pending可进行验证)
五信号捕捉
1捕捉流程
信号捕捉的过程共有5步:
3到4的过程中,OS能不能直接去进行执行用户的自定义捕捉函数呢?
虽然说OS的权限很大;但如果函数里有用户没有权限做到的事情(删库)这不就利用了OS吗! 所以OS跑过去执行的时候要从内核态转为用户态才行
我们之前说:信号不会在被立即处理,而是在合适的时候进行处理
现在我们把合适的时候进行处理换为:从内核态返回到用户态的时候进行处理
使用草图来理解:信号捕捉的过程:要经历4个状态的切换:
2内核态与用户态
2.1再谈地址空间
以32位机器为例:[0,3]GB地址空间为用户空间;而[3,4]GB地址空间是内核空间(没提过);我们要知道:在刚开始时OS时第一个加载到内存中的(OS也是软件),而一个进程被创建时要有自己的地址空间和页表来映射到地址空间中访问数据;
但如果我们要使用系统调用来做某些事情的,该怎么办?
这时OS就会为我们创建一份内核级页表将OS数据映射到内核空间中:我
们想使用时只需进行跳转就能OS的数据了!!
那如果有多个进程要进行系统调用时,是不是给每个进程都创建一个内核级页表进行映射?
不用:当进程B要用到系统调用时,只需与A公用一份内核级页表即可!!
总结:
1无论进程如何切换,总能找到OS(通过内核级页表映射到地址空间中)
2我们在访问OS,本质上还是在地址空间中进行的,与访问库函数没区别!!
3OS不相信任何用户:在访问[3,4]GB内核空间时,会受到一定的约束
2.2键盘输入数据
在CPU表面:有很多的针脚,用来接收硬件中断号用寄存器来进行保存;
每个硬件(键盘,网卡,磁盘...)都有自己的中断号;
OS在刚开始时是第一个加载到内存中,其中会加载中断向量表(函数指针数组):
当硬件如键盘已经就绪时,进行硬件中断(高低电频的方式)让CPU接收到中断号,再把中断号作为中断向量表的数组下标对应去执行读键盘的方法(系统调用函数),完成对数据的读入工作
看到这里,你有没有会感到很熟悉!这不就是信号吗??
像,但两者是有区别的:
信号:纯软件 中断:软件 + 硬件
信号是通过模拟硬件中断的过程来实现的!!
2.3OS的运行过程
在理解OS的整个运行过程之前,先来认识OS是如何运行?
操作系统本质上是一个死循环(不断调度系统任务),由时钟(前面讲的闹钟)完成进程切换
运行过程之中要用到OS数据(系统调用):如何理解系统调用?
在Linux内核源代码中:所有的系统调用都放在一张函数指针数组中:(数组下标称为系统调用号)
OS会在内存中维护一张中断向量表:上面的系统调用表就放在中断向量表的其中一个位置:对应执行系统调用的方法
我们要想执行任意系统调用,前提:1.有系统调用号;2.找到系统调用方法
那系统调用方法是否也是有自己的中断号呢?
不是:它是由OS直接形成的数字(int 0x80)(陷阱,缺陷),用它找到系统调用表,结合系统调用号执行系统调用
在进程概念那里,我们说了:OS不相信任何人!那么用户不是无法跳转到[3,4]GB的内核空间吗?
可事实是:用户在地址空间中跳转过去执行系统调用了!! 为什么??
这是在只有在特定的条件下才能进行跳转了:如何做到的?
要CPU(硬件)配合:在CPU内部,用一个cs寄存器:保存所有要执行的代码;
其中:有状态标记位(bit位),0表示内核态,3表示用户态;当你要进行跳转到执行系统调用时,它会先判断你当前状态是否能跳转:如果是用户态,它不让你进行跳转,对应的PC指针也跳转不过去:只有修改状态:由3 -> 0才能进行跳转,执行系统调用!!
3函数实现
对应信号捕捉的函数,与信号处理的函数类似:
其中act,oldact是sigaction类型的结构体:
代码证明:当前如果正在对n号信号进行处理,默认n号信号会被自动屏蔽
而对n号信号处理完成的时候,会自动解除对n号信号的屏蔽(前面说过了)
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void Print(sigset_t pending)
{cout << "pending : ";for (int signal = 31; signal > 0; signal--){if (sigismember(&pending,signal))cout << 1;elsecout << 0;}cout << endl;
}void handler(int signal)
{cout << "get a signal" << signal << getpid() << endl;while (true){sigset_t pending;sigpending(&pending);Print(pending);sleep(1);}
}int main()
{struct sigaction act, oldact;act.sa_handler = handler;sigemptyset(&act.sa_mask);//什么用?act.sa_flags=0;sigaction(2, &act, &oldact);while (true){cout << "I am a process" << endl;sleep(1);}return 0;
}
现象:
当前对n号信号进行处理,会对n号信号进行屏蔽(OS为了防止递归,栈溢出)
如果对n号信号屏蔽的同时,想对其它信号也进行屏蔽呢??
sa_mask就起到作用了!!
// sigemptyset(&act.sa_mask);for (int i = 1; i <= 31; i++){sigaddset(&act.sa_mask, i);}
现象:
虽然能怎么做,但OS为了保证不出现“金刚不坏”进程,还是不允许对特定的信号进行屏蔽!
六其它问题
1可重入函数
进行链表的头插时:先把node1->next指向head节点,node1节点就成为head节点;
但如果在头插过程中,要执行head=node1语句时发生中断,另一个节点node2跑过来执行头插操作;执行完后在中断位置继续执行代码:这会导致node2节点的丢失!!
像插入这种操作我们把它叫做不可重入函数;
满足以下条件之一的都是不可重入函数;(反过来就是可重入函数)
1.调用了malloc或free:因为malloc也是用全局链表来管理堆的。
2.调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
2.volatile
来看看一下代码:
int gflag = 0;void changedata(int signo)
{std::cout << "get a signo:" << signo << ", change gflag 0->1" << std::endl;gflag = 1;
}int main() // 没有任何代码对gflag进行修改!!!
{signal(2, changedata);while(!gflag); // while不要其他代码std::cout << "process quit normal" << std::endl;
}
现象:
在CPU中有算逻运算:其中,while(!gflag)属于逻辑运算:
CPU要先从内存中将数据拷贝到ALU寄存器中,进行逻辑运算(判断),得出结果后继续执行其它代码;上面的结果也符合我们的预期
但编译器是会进行优化的:选项:-O0(无优化) -O1 -O2 -O3
尝试将代码进行优化:发现程序退出出去了?
原因是:优化后寄存器屏蔽了内存中变量的真实值
这时我们就要加上volatile关键字来保存内存的可见性
3SIGCHLD
回顾下子进程内容:子进程退出时,要把退出信息交给父进程后才退出;否则会产生僵尸问题;学了信号的内容,我们要知道:子进程退出时是会收到SIGCHLD信号的!!
用代码来验证下:
void notice(int signo)
{std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;while (true)//不一定有只有一个子进程创建{pid_t rid = waitpid(-1, nullptr, 0); //?if (rid > 0){std::cout << "wait child success, rid: " << rid << std::endl;}else if (rid < 0){std::cout << "wait child success done " << std::endl;break;}}
}void DoOtherThing()
{std::cout << "DoOtherThing~" << std::endl;
}int main()
{signal(SIGCHLD, notice);pid_t id = fork();if (id == 0){std::cout << "I am child process, pid: " << getpid() << std::endl;sleep(3);exit(1);}// fatherwhile (true){DoOtherThing();sleep(1);}return 0;
}
现象:
如果有10个子进程,5个退出,5个不退出呢??
waitpid()阻塞了!-> 进行非阻塞等待!!
void notice(int signo)
{std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;while (true){//pid_t rid = waitpid(-1, nullptr, 0); //?pid_t rid = waitpid(-1, nullptr, WNOHANG); // 阻塞啦!!--> 非阻塞方式if (rid > 0){std::cout << "wait child success, rid: " << rid << std::endl;}else if (rid < 0){std::cout << "wait child success done " << std::endl;break;}else{std::cout << "wait child success done " << std::endl;break;}}
}void DoOtherThing()
{std::cout << "DoOtherThing~" << std::endl;
}int main()
{signal(SIGCHLD, notice);pid_t id = fork();for (int i = 0; i < 10; i++){pid_t id = fork();if (id == 0){std::cout << "I am child process, pid: " << getpid() << std::endl;sleep(1);exit(1);}}// fatherwhile (true){DoOtherThing();sleep(1);}return 0;
}
如果我们不想父进程进行回收也不想子进程变成僵尸,有一种方式能实现:
对SIGCHLD信号设置成忽略动作:这样,父进程就摆脱子进程的束缚了!!
int main()
{signal(SIGCHLD, SIG_IGN); // 收到设置对SIGCHLD进行忽略即可pid_t id = fork();if (id == 0){int cnt = 5;while (cnt){std::cout << "child running" << std::endl;cnt--;sleep(1);}exit(1);}while (true){std::cout << "father running" << std::endl;sleep(1);}
}
打开监控脚本:
while :; do ps ajx | head -1 && ps ajx | grep ‘可执行程序名’; sleep 1; done
这样:子进程就顺利的被OS进行回收了!!
以上便是我在学习信号内容的总结,有问题欢迎在评论区指出,感谢您的观看!!