索引
- 一.初始信号
- 1.什么是信号
- 2.前后台进程
- 3.信号的种类
- 4.信号的管理
- 二.信号产生前
- 1.验证键盘是可以产生信号的
- 2.通过系统调用接口发送信号
- 3.由软件条件产生信号
- 4.硬件异常产生信号
- 5.总结
- 6.core dump
- 信号产生中
- 1.信号在内核中的表示
- 2.信号集操作函数
- 信号产生后
- 1.了解内核态和用户态
- 2.内存如何实现信号的捕捉
- 3.sigaction
一.初始信号
1.什么是信号
生活的角度: 红绿灯,闹钟,下课铃等
1.我们是如何得知这些东西的?有人教,(能够认识这些场景下的信号以及其表示的含义)也就是能够识别这些信号
2.我们提前知道这些信号产生时要做什么也就是我们已经提前知道了信号处理的方法
从上述可以看出
即使信号没有产生,我们已经具备了处理信号的能力!
因此:信号是给进程发送的,进程要具备处理信号的能力
1.该能力一定是预先已经早就有了的
2.进程能够识别对应的信号
3.进程能够处理对应的信号
这个能力是OS给我们提供的
对于进程来讲,即使信号还没有产生,我们进程已经具有识别和处理这个信号的能力了
2.前后台进程
while (true){sleep(1);}return 0;
当我们直接运行上述程序时,该程序会变成一个前台进程,此时直接ctrl+C
可以直接终止,是因为ctrl+C
可以发送一个信号给前台进程,使得该进程退出。
但当我们将前台进程变成后台进程时,其接不到类似ctrl + C
的信号,也就无法退出了
理解用户按下
Ctrl + C
,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
注意:
- 1.只有前台进程才能收到Ctrl+C产生的信号,后台进程无法收到,一个运行进程的命令后面+&可以使得前台进程转化成后台进程,转化为后台进程之后shell不必等到进程结束才可以接受新的命令,可以直接启动新进程
- 2.前台进程在运行过程中用户随时按下
ctrl+C
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能受到SIGINT
(就是Ctrl+C
)而终止,所以信号相对于进程的控制流程来说是异步的。
3.信号的种类
kill -l 可以显示信号列表
数字是信号编号,右侧是宏,二者一个意思
1-31是分时信号,产生信号了不用立即处理
34-64是实时信号,信号产生了就必须处理。
我们学习的是1-31的普通信号。
4.信号的管理
那么进程又是如何管理信号的呢?
是在进程的PCB中
eg:
task_struct {
uint32_t sig;//位图 0000 0000
…
}
位图的内容表示有没有该信号,位图的位置表示是哪一个信号,由于PCB是在内核数据结构,所以只有OS有资格修改位图,OS是进程的管理者,进程的所有属性的获取和设置只能又操作系统来设置,因此无论信号怎么产生,最终一定是OS帮我们进行信号的设置
下面我将从三个部分:信号产生前,信号产生中,信号产生后来叙述进程间信号
二.信号产生前
上述可以了解到,信号在OS中是由位图表示的,所以信号的产生OS发送给进程的时候不如说是写入信号。
1.验证键盘是可以产生信号的
sighandler_t signal(int signum, sighandler_t handler);
对信号设置回调
#include <signal.h>typedef void (*sighandler_t)(int);sighandler_t signal(int signum, sighandler_t handler);
验证Ctrl + C
是2号信号,我们先对
-定义捕捉动作当我们在键盘上按Ctrl+C
的时候,如果调用了我们的自定义函数,验证成功!
void handler(int signo)
{cout << "i am a process ,我获取了一个信号: " << signo << endl;
}
int main()
{signal(SIGINT, handler);sleep(3);cout << "自定义信号捕捉函数设置完毕" << endl;while (true){cout << "我是一个正在运行的进程" << endl;sleep(1);}return 0;
}
signal(SIGINT, handler);
这里不是在调用handler
方法,只有信号产生的时候,才会调用handler
方法.
实验结果如下
因此可以得出结论:Ctrl + C :本质就是给前台进程发送2号信号给目标进程,目标进程默认对2号信号的处理动作就是终止自己,然而现在我们设置了用户对信号的自定义处理动作。
Ctrl + C 产生2号信号
Ctrl +\ 产生3号信号,同样也是终止进程
注意:9号信号是不能设置自定义的,即使设置了,kill -9 PID 照样也可以杀死进程,因此9号信号也叫做管理员信号
2.通过系统调用接口发送信号
int kill(pid_t pid, int sig);
不仅是一个命令,还是一个系统调用接口,表示对某个进程发送某个信号
我自己写一个mykill
进程,该进程是调用了kill
这个函数的,可以得出结论,代码如下
mykill.cc
static void Usage(const string &proc)
{cerr << " Usage :\n\t" << proc << " signo pid " << endl;
}
int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}if (kill(static_cast<pid_t>(atoi(argv[2])), atoi(argv[1])) == -1){cerr << "kill :" << strerror(errno) << endl;}}
myproc.cc
while (true){sleep(1);cout << "我的PID是: " << getpid() << endl;}
int raise(int sig);
给自身发信号
NAMEabort - cause abnormal process terminationSYNOPSIS#include <stdlib.h>void abort(void);//直接终止进程
abort()直接终止进程
3.由软件条件产生信号
NAMEalarm - set an alarm clock for delivery of a signalSYNOPSIS#include <unistd.h>unsigned int alarm(unsigned int seconds);
调用alarm
函数可以设定一个闹钟,也就是告诉内核在seconds
秒之后给当前进程发送SIGALRM
信号,该信号的默认处理动作是终止当前进程。函数的返回值是0或者是设定闹钟时间的剩余秒数。
int cnt = 0;void handler(int signo)
{cout << "i am a process ,我获取了一个信号: " << signo << endl;cout << "cnt: " << cnt << endl;
}
int main()
{signal(SIGALRM, handler);alarm(1);while (1){cnt++;}
}
一秒钟之后就会捕捉SIGALRM
信号
4.硬件异常产生信号
下面先列举两个崩溃的进程
猜想一下:上述两个进程崩溃的本质也是收到了某个信号,验证一下,先将每个信号都设置自定义动作,然后再运行。
进程崩溃的本质:是该进程收到了异常信号,因为硬件异常,而导致OS向目标进程发信号,进而导致进程终止的现象。
除0:
CPU内部,状态寄存器,当我们除0的时候,CPU内的状态寄存器会被设置成为有报错:浮点数越界,CPU内部寄存器(硬件),OS就会识别到CPU内有报错->1,然后OS就会向目标进程发信号,目标进程在合适的时候处理信号,终止进程
越界&野指针
我们在语言层面使用的地址(指针),其实都是虚拟地址->物理地址->物理内存->读取对应的数据和代码,如果虚拟地址有问题,而地址转化的工作是由MMU(硬件)+页表(软件)构成,转化过程就会引起问题->表现在硬件MMU上->OS就会发现硬件出了问题->OS向目标进程发送信号->目标进程在合适的时候处理信号->终止进程
由此可得出结论:我们在C/C++
中除0,内存越界等异常,在系统层面上是被当成信号处理的。
5.总结
- 上面所说的信号的产生,信号的发送,最终都是要由OS来执行的,因为OS是进程的管理者
- 信号不是被立即执行,而是在进程合适的时候
- 信号不是被立即执行的,那么信号就会被记录下来,记录在进程的
PCB
中 - 一个进程在没有收到信号的时候,能否知道自己应该对合法信号做何处理?能,处理方式已经由之前的程序员写在内核了
6.core dump
Coredump叫做核心转储
,是进程在运行时突然崩溃的一个内存快照。
pid_t wait(int *status);pid_t waitpid(pid_t pid, int *status, int options);
wait 与waitpid
都有一个status
参数,该参数是一个输出型参数,由OS填充,其他比特位在我之前的博客有提过,当生成Core dump
文件的时候,该标记位会设置成1.
由上可以看出,并不是所有的信号都会生成core
文件的,只有程序自身内部出了问题才会产生core
文件。
int main()
{pid_t id = fork();if (id == 0) // 子进程{int *p = nullptr;*p = 10000; // 野指针问题exit(1);}int status = 0;waitpid(id, &status, 0);printf("exitcode: %d,signo: %d, core dump flag: %d \n", (status >> 8) & 0xff, status * 0x7f, (status >> 7) & 0x1);
}
运行结果
可以子进程的退出信号是11,符合野指针出错信号,但此时的core dump
标记位还是0,为什么呢?
因为我是线上的的云服务器,厂商设置的默认core
文件个数就是0,也就是说禁止生成core
文件。
设置之后此时允许生成core
文件。
但这不是真正的用途,此时我们要生成可调试版本的程序。
为什么线上的云服务器将core dump文件关闭?
因为云服务器上的产品一般都是release
版本,但是我们生成的core
文件是可调式版本的,并且如果线上的产品挂掉了,最重要的不是找bug而是重启,并且一旦服务挂掉了,会直接重启,eg一秒钟重启一万次的话,每次都有core
文件的话,此时磁盘占据大量的文件,磁盘被打满了会危急到操作系统,很危险!
总结一下:
信号从产生到递达之间的状态称为信号未决
进程可以选择阻塞某个信号,当进程阻塞信号时,信号无法被抵达
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
阻塞和忽略是不同的,只要信号被阻塞那么信号即使产生了也不会被递达,忽略是信号处理的一个动作
信号产生中
1.信号在内核中的表示
信号在内核中的表示
信号在内核中task_struct指向三张表,三张表都是用
位图表示的。
pending:未决信号集,表示该信号是否产生
block:阻塞信号集,表示该信号是否被阻塞
handler:指向的是每个信号的自定义函数.
以上述的SIGQUIT
为例,此时该信号未产生,一旦产生该信号,他的处理动作是用户的自定义函数sighandler
,但是由于此时该信号被阻塞了,此时该信号不会抵达,除非接触对该信号的阻塞,才会抵达。
同时,如果一个信号最初未被阻塞,但是在某信号抵达之前,也可以说是该信号正在处理的时候,如果继续产生该信号,该信号也还是只会被记录一次,实时信号在抵达之前可以产生多次,这里不讨论。
2.信号集操作函数
从上图来看,每个信号只有一个bit
的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也一样,因此,未决和阻塞标志可以用相同的数据类型sigset_t
来储存。
sigset_t
类型对于每种信号用一个bit
表示有效
或无效
,这个类型内部如何储存这些bit
则依赖于系统实现,我们不必关心,我们只要会使用如下几个函数就可以了。
typedef __sigset_t sigset_t;
......
typedef struct{unsigned long int __val[_SIGSET_NWORDS];} __sigset_t;
根据上述源码可以看到sigset_t
的实现与系统自身有关,所以我们不必关心。
int sigemptyset(sigset_t *set)
初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
int sigfillset(sigset_t *set)
使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号
int sigaddset(sigset_t *set, int signo)
该函数允许将一个指定的信号添加到一个自定义信号集中,也就是将该信号的标准为设为1,表示阻塞该信号。
int sigdelset(sigset_t *set,int signo)
与上述函数相反,表示解除该信号的阻塞
int sigismember(const sigset_t *set, int signo)
判断一个信号集的有效信号中是否包含某种信号,也就是检查是否屏蔽该信号,如果包含则返回1,反之0
int sigpending(sigset_t *set);
获取当前进程(谁调用,获取谁)的pending信号集,通过set
参数传出,调用成功返回0,失败返回-1
int sigpromask(int how, const sigset_t *set, sigset_t *oset
成功返回0,失败返回-1
若oset非空:当前进程的信号屏蔽字通过oset传出
set非空:更改进程的信号屏蔽字
how:指示如何更改
如果oset 和 set都非空,则将原来的信号屏蔽字备份到oset,然后根据set和how参数更改信号屏蔽字。 假设当前的信号屏蔽字为mask,下述说明了how参数的可选值
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号 |
---|---|
SIG_UNBLOCK | set包含了从当前信号屏蔽字接触的·信号 |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值 |
V1.1版本的代码先指示将2号信号添加到信号屏蔽字中
预期结果:初始的pending信号集都是0,当我们向进程发送2号信号后,pending信号集中表示2号信号的比特位变成1
#include <unistd.h>
#include <iostream>
#include <signal.h>
using namespace std;void showpending(sigset_t *pendings)
{for (int sig = 1; sig <= 31; sig++){if (sigismember(pendings, sig)){cout << "1";}else{cout << "0";}}cout << endl;
}
void handler(int signo)
{cout << "我是一个进程,刚刚获得了一个信号: " << signo << endl;
}
int main()
{// 2.屏蔽掉2号信号sigset_t bsig, obsig;sigemptyset(&bsig);sigemptyset(&obsig);// 2.1添加2号信号到信号屏蔽字中sigaddset(&bsig, 2);// 2.2 设置用户级的信号屏蔽字到内核中,让当前进程屏蔽2号信号sigprocmask(SIG_SETMASK, &bsig, &obsig);cout << "pid: " << getpid() << endl;signal(SIGINT, handler);// 1.不断获取当前进程的pending信号集sigset_t pendings;int cnt = 0;while (true){// 1.1清空信号集sigemptyset(&pendings);// 1.2获取当前进程pending信号集(谁调用就获取谁)if (sigpending(&pendings) == 0){// 1.3打印一下当前进程的pending信号集sleep(1);showpending(&pendings);cnt++;}cout << "cnt: " << cnt << endl;}return 0;
}
V2.0
先将所有的信号都屏蔽,在20秒之后解除2号和3号信号
下面只贴部分更改的代码
sigset_t bsig, obsig;sigemptyset(&bsig);sigemptyset(&obsig);// 2.1添加1-31号信号到信号屏蔽字中for (int sig = 1; sig < 32; sig++){sigaddset(&bsig, sig);}// 2.2 设置用户级的信号屏蔽字到内核中,让当前进程屏蔽2号信号sigprocmask(SIG_SETMASK, &bsig, &obsig);cout << "pid: " << getpid() << endl;signal(SIGINT, handler);signal(3, handler);// 1.不断获取当前进程的pending信号集sigset_t pendings;int cnt = 0;while (true){// 1.1清空信号集sigemptyset(&pendings);// 1.2获取当前进程pending信号集(谁调用就获取谁)if (sigpending(&pendings) == 0){// 1.3打印一下当前进程的pending信号集sleep(1);showpending(&pendings);cnt++;}if (cnt == 20){// sigprocmask(SIG_SETMASK,&obsig,nullptr); 直接用该方法可以直接解除所有信号集sigset_t sigs;sigemptyset(&sigs);sigaddset(&sigs, 2);sigaddset(&sigs, 3);sigprocmask(SIG_UNBLOCK, &sigs, nullptr);}cout << "cnt: " << cnt << endl;}
根据上述结果可以看出,当我们将信号添加到信号集之后,我们向进程发送信号时,此时代表该信号的比特位由0 --> 1
解除信号屏蔽之后,就会重新由1 --> 0
但是根据结果可以看出 9号信号即使被屏蔽了还是可以杀死进程
信号产生后
1.了解内核态和用户态
上述提到信号产生后,OS系统是在什么时候处理信号呢?
实在合适的时候,那合适的时候具体是什么时候呢?
当 当前进程从内核态切换回用户态的时候进行信号的检测与处理!
每个进程都有自己的task_struct
指向其虚拟地址,虚拟地址到物理地址的转化是通过页表实现的,而每个进程对于自己的用户空间3G是独立的,还有一份公共的内核页表,如下所示.
那么OS在不在内存中被加载?答案是肯定的
无论进程如何切换,我们都可以找到内核的代码和数据,前提是你要有足够的权利进行访问!
那么当前的进程如何具备权利访问内核页表乃至访问内核数据呢?
要进行身份切换。我们要让OS知道此时访问数据的是内核还是页表
CPU内有对应的状态寄存器CR3寄存器,当比特位是0的时候表示内核态,当比特位是3的时候表示用户态:
用户态:只能访问用户级页表
内核态:既能访问内核级页表也能访问用户级页表
内核态相比于用户态拥有更高的权限
那么一般什么时候会从用户态切换回内核态呢?
- 系统调用的时候
- 时间片到了进行进程间切换等
2.内存如何实现信号的捕捉
我们必须要了解一个知识:
我们的程序,会无数次直接或者间接的访问系统级软硬件资源(管理是OS),本质上,你并没有去操作这些软硬件资源,而是必须通过OS–>无数次陷入内核(1.切换身份 2.切换页表) -->调用内核的代码–>完成访问的动作–>结果返回给用户(1.切换身份 2.切换页表)–>得到结果
eg:
while(1);
仅仅是这一行代码存在从用户态切换成内核态吗?
一定是有的,因为每个进程都有自己的时间片,当时间片到了,需要转换成内核态然后更换内核级页表 -->为了保护上下文,执行调度算法–>切换新的进程–>恢复新进程的上下文–>再切换成用户态–>CPU执行的就是新进场的代码!
下面一个场景,当我们调用完系统调用之后,返回内核态时,检测出了错误;
快速记忆
3.sigaction
该函数可以读取和修改指定信号相关联的处理动作,成功返回0,失败返回-1.
#include<signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
参数解析
signum:指定信号编号
act:非空,根据指针act修改该信号的处理动作
oldact:非空,通过其传出该信号原来的处理动作
act和oldact都是sigaction结构体
上述有该结构体的具体组成
sa_handler:表示该信号的处理动作
当某个信号正在被处理时,内核会自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复成原来的信号屏蔽字,这样的话,当前进程正在被处理时,如果这个信号再次产生,该信号会被阻塞直到当前信号处理结束。
如果在调用信号处理函数时,除了希望自动屏蔽当前信号,还希望自动屏蔽其他信号,则用sa_mask
字段说明这些需要额外屏蔽的信号,当信号处理函数返回时恢复成原来的状态
sa_flags设为0.
实验一:先不给sa_mask添加信号
void handler(int signo)
{cout << "我是一个进程,刚刚获得了一个信号: " << signo << endl;sigset_t pending;// 此时会永远在处理某个信号while (true){sigpending(&pending);for (int i = 1; i <= 31; i++){if (sigismember(&pending, i))cout << "1";elsecout << "0";}cout << endl;sleep(1);}
}
int main()
{cout << "my pid: " << getpid() << endl;struct sigaction act, oldact;act.sa_handler = handler;act.sa_flags = 0;sigemptyset(&act.sa_mask);// sigaddset(&act.sa_mask, 3);sigaction(2, &act, &oldact);while (true){cout << "main running" << endl;sleep(1);}return 0;
}
实验二:给sa_mask添加信号
sigaddset(&act.sa_mask, 3);