目录
1.信号概念
2.信号产生
2.1 终端
2.2 系统调用
2.3 硬件异常
2.4 软件条件
2.5 小结
3. 进程退出时的核心转储问题
4. 信号捕捉初识
5. 阻塞信号
5.1 相关概念
5.2 在内核中的表示
6. 信号捕捉
6.1 知识铺垫
6.2 信号捕捉流程
6.3 sigset_t
6.4 信号集操作函数
6.5 sigaction
7. 可重入函数
8. 关键字volatile
1.信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。例:用户输入命令,在Shell下启动一个前台进程。用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。这里的ctrl+c就被OS解释成为了一种信号
- 用kill -l命令可以察看系统定义的信号列表
- 当进程收到信号的时候,进程可能正在执行更重要的代码,信号不一定会被立即处理--因为信号可以随时产生(异步)
- 进程本身必须要有对信号的保存能力
- 进程在处理信号时,有三种动作--默认动作、自定义动作、忽略动作
每个信号都有一个编号和宏定义 -- [1, 31]:普通信号 [34, 64]:实时信号
一个共识就是:信号是发送给信号的,进程是被保存到哪里了呢?-- task_strut中。当进程收到信号后,会修改PCB中的信号位图,unsigned int signal -- 其有32个比特位,比特位的位置代表编号,比特位的内容表示是否收到信号:0(未收到)、1(收到)
由上得出,信号的发送就是修改PCB中的位图结构,只有OS有权修改。本质就是OS向目标进程发送信号,所以会提供一系列的系统调用,使得用户可以通过OS发送信号。
2.信号产生
2.1 终端
通过终端的按键产生信号,ctrl+c 和 ctrl+\ 默认动作都是终止进程,分别对应2和3号信号
2.2 系统调用
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数:pid:目标进程pid
sig:发送几号信号
返回值:成功返回0,失败返回-1
可以向任意进程发送任意信号
demo代码:
mysignal.cc/
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <string>
using namespace std;
static void Usage(const string& proc)
{
cout << "\nUsage:" << proc << " pid sino\n" << endl;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
// 1.通过键盘发送信号
// 2.通过系统调用发送信号
pid_t pid = atoi(argv[1]);
int signo = atoi(argv[2]);
int n = kill(pid, signo);
if(n != 0)
{
perror("kill");
}
return 0;
}
///mytest.cc
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
int main()
{
while(1)
{
cout << "我是一个正在运行的进程,pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
上面代码的目的是,先运行mytest进程,进程mysignal通过命令行参数找到mytest进程来终止该进程
int raise(int sig); -- 给自己发送任意信号
//mysignal.cc/
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <string>
int main(int argc, char *argv[])
{
int cnt = 0;
while(cnt <= 10)
{
cout << "signal ss" << cnt++ << endl;
if(cnt >= 5) raise(9);
}
return 0;
}
由上述现象可以看到,raise在循环五次后发送9号信号,杀死进程。
#include <stdlib.h>
void abort(void); -- 使当前进程接收到信号而异常终止,abort函数总是会成功的 -- 6号信号
下面两个接口都可以通过kill接口实现
对信号处理行为的理解:
- 很多情况,进程收到大部分的信号默认处理动作都是终止进程
- 信号的意义:信号的不同,代表不同的事件,但对事件发生之后的处理都做都可以一样。
2.3 硬件异常
如:当前进程执行了除 以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。还有野指针问题
野指针问题:
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
signal(SIGSEGV, handler);
sleep(1);
int *p = NULL;
*p = 100;
while(1);
return 0;
}
若不进行信号捕捉怎么发生段错误
信号捕捉以后会捕捉到11号信号打印:
2.4 软件条件
2.4.1 管道
SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。在管道中,读端关闭,写段一直写,会发生一场,OS向写端发送SIGPIPE信号:13号信号
2.4.2 定时器软件条件
#include <unistd.h>
unsigned int alarm(unisegned int seconds);
参数:seconds:秒数 -- 若为0,则意味着取消闹钟
返回值:提前唤醒时,会返回剩余秒数
告诉内核在seconds秒后,给当前进程发SIGALRM信号
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <string>
using namespace std;
int main(int argc, char *argv[])
{
int cnt = 0;
alarm(1);
while(1)
{
cnt++;
cout << "cnt = " << cnt++ << endl; // 外设打印 拖慢了计算的节奏
}
return 0;
}
这段代码帮我们统计了计算机在一秒内可以累加并打印多少次。
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <string>
using namespace std;
int cnt = 0;
void catchSignal(int signo)
{
cout << "获得一个信号,信号编号为:" << cnt << endl;
// exit(1);
}
int main(int argc, char *argv[])
{
alarm(1);
while(1)
{
cnt++;
}
return 0;
}
这段代码为何与前一段代码打印出来的结果相差如此之大呢,是因为计算机进行打印(IO)是很费时的。
2.5 小结
-
所有的信号产生方式都是由OS来执行的,因为OS是进程的管理者
-
信号并不是立即处理的,是在合适的时间
-
信号需要被保存在PCB中
-
进程若没有收到信号的时候,进程就知道如何对信号处理
-
OS向进程发信号就是OS修改目标进程的PCB中的信号位图
3. 进程退出时的核心转储问题
进程退出时有两种方式:Term、Core,以core方式退出的进程可以利用核心转储来快速定位错误
ulimit -a : 用于显示当前shell的各种资源限制(ulimits)
ulinit -c 1024, 打开云服务器的core file选项,将size设置为1024
demo代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <string>
int main()
{
while(1)
{
int a[10];
a[10000] = 106;
}
return 0;
}
当打开core file选项前:
当打开core file选项后结果为下,并且当前目录下会生成一个core.pid文件:
core dumped:核心转储—当进程出现异常时,我们将进程在对应的时刻,在内存中的有效数据转储到磁盘中;那么它存在的意义是什么呢?为了支持调试,如何支持?--gdb 文件
在gdb上下文中输入: core-file core.pid 即可找到异常位置 ,如下图所示,就能找到上面代码中的问题。这种方式称为事后调试
4. 信号捕捉初识
前文提到过,信号是可以被自定义捕捉的,siganl函数就是来进行信号捕捉的
#include <signal.h>
sighandler_t signal(int signum, sighandler_t handler);
参数:signum:信号编号或宏定义
handler:回调函数,用来如何处理这个信号
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
signal(2, handler); //前文提到过,信号是可以被自定义捕捉的,siganl函数就是来进行信号捕捉的,提了解一下
while(1);
return 0;
}
可以看到,一开始,代码进入死循环,当我们在命令行按 ctrl+c 时,进程不会退出,而是执行了我们自己定义的动作。-- 信号处理的自定义动作
对所有的[1, 31]信号捕捉后,是否这个进程就无法杀死了呢? -- 不会 kill -9 pid 会杀死任意进程
5. 阻塞信号
5.1 相关概念
- 实际执行信号的处理动作叫做信号递达(Delivery)
- 信号从产生到递达之间的状态,叫做信号未决(Pending)
- 进程可以选择阻塞(Block)某个信号
- 被阻塞的信号产生时将保持在未决的状态,直到进程解除对此信号的阻塞,才执行递达的处理
- 阻塞和未决不是一种状态,是不同的
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
5.2 在内核中的表示
block(block又称为信号屏蔽字)中位置内容为1的信号不会被递达,除非阻塞解除。即使没有收到某一个信号,也可以将该信号设置为阻塞状态--设置block表。若一个信号在产生前被设置为阻塞状态,当该信号产生后,不会被递达,直到阻塞解除。下面为一个伪代码:
在内核中除了两个位图外还有:typedef void(*handler_t)(int signo); -- 函数指针
handler_t handler[32] -- 数组内容为指针,指向对每个信号的处理方法(函数)。
结论:
- 如果一个信号没有产生,并不妨碍其先被阻塞
- 进程通过三种结构的结合来识别信号
-
POSIX.1允许系统递送该信号一次 或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里。
6. 信号捕捉
6.1 知识铺垫
用户态:处于用户态的 CPU 只能访问受限资源,不能直接访问内存等硬件设备,不能直接访问内存等硬件设备,必须通过「系统调用」陷入到内核中,才能访问这些特权资源。
内核态:处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态
用户为了访问内核或者硬件资源,必须通过系统调用完成
用户无法以用户态的身份执行系统调用,那么执行系统调用的是进程,但是身份是内核态身份
(系统调用是比较费时的,应尽量避免频繁的系统调用)
CPU中存在指向页表的寄存器、指向task_struct的寄存器、CR3寄存器:表征当前进程的运行级别,0内核态,3用户态
看上图,OS中还有唯一的一个内核级页表,将不同进程的内核空间映射到物理内存的同一块区域,那么访问OS的接口,只需要在自己的地址空间进行跳转就可以了
6.2 信号捕捉流程
信号在产生时,不是被立即处理的,是从内核态返回用户态的时候进行处理的,那么是什么时候进入的内核态呢?-- 系统调用/进程切换
信号捕捉流程如下:
- 进程由于中断/异常进入内核态再返回用户态之前会检查当前进程PCB中的block、pending、handler表;
- 先查看blockblock表若blok为1无论是否产生信号都不处理,直接返回;为0,继续检查pending表,查看信号是否产生;
- 若一个位置block为0,pending为1,则继续查handler表匹配的方法,执行对应的处理方法;
- 若handler表中的方法是自定义方法,由于自定义方法处于用户态,此时进程还要通过特定的调用从内核态变为用户态执行对应的方法(注意这里是无法在内核态执行用户态代码的)
- 执行完处理方法后,再返回内核态(不能直接执行完处理方法后直接返回带用户态的代码处),继续返回到用户态执行到的对应的代码处。
如果信号的处理方法为自定义的那么一定涉及到四次状态的切换
6.3 sigset_t
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
6.4 信号集操作函数
#include <signal.h>int sigemptyset(sigset_t *set); // 初始化,将所有信号对应的比特位清0int sigfillset(sigset_t *set); // 初始化,将所有信号对应的比特位置1int sigaddset (sigset_t *set, int signo); // 将signo信号的比特位置为1int sigdelset(sigset_t *set, int signo); // 将signo信号的比特位置0int sigismember(const sigset_t *set, int signo); // 判断set中是否包含signo信号
int sigprocmask(int how, const sigset_t* set, sigset_t* oset);
参数:how:指示如何更改
SIG_BLOCK:mask = mask | set -- set包含了我们希望添加到当前信号屏 蔽字的信号
SIG_UNBLOCK:mask = mask & ~set -- set包含了我们希望从当前信号 屏蔽字中解除阻塞的信号
SIG_SETMASK:mask = set -- 设置当前信号屏蔽字为set所指向的值
set:按照how更改信号屏蔽字
oset:读取当前进程的信号屏蔽字
返回值:成功返回0,失败返回-1
int sigpending(sigset_t *set); // 获取当前进程的pending位图/未决信号集
成功返回0,失败返回-1
对上述接口使用的demo代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#define MAXSIGNUM 31
using namespace std;
static vector<int> sigarr = {2};
void printPending(sigset_t *pending)
{
for(int i = MAXSIGNUM; i >= 1; i--)
{
if(sigismember(pending, i)) cout << "1";
else cout << "0";
}
cout << endl;
}
int main()
{
// 1.先尝试屏蔽指定的信号
sigset_t block, oblock, pending;
// 1.1 初始化
sigemptyset(&block); // 将位图结构中都置为0
sigemptyset(&oblock); // 将位图结构中都置为0
sigemptyset(&pending); // 将位图结构中都置为0
// 1.2 添加要屏蔽的信号
//for(const auto &e:sigarr)
sigaddset(&block, 2);
// 1.3 开始屏蔽
sigprocmask(SIG_SETMASK, &block, &oblock);
// 2.遍历打印pending的信号集
int cnt = 10;
while(1)
{
// 2.1 初始化pending信号集
sigisemptyset(&pending);
// 2.2 获取当前进程的未决信号集
sigpending(&pending);
// 2.3 打印
printPending(&pending);
sleep(1);
if(cnt-- <= 0)
{
cout << "恢复对信号的屏蔽,不屏蔽任何信号\n" << endl;
sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦解除信号的屏蔽,且此时信号已经处于未决状态时,会立马把该信号递达,执行对应的处理动作
}
}
return 0;
}
这段代码实现的功能是,阻塞2号信号(ctrl+c),打印pending表,在10s后解除对所有信号的屏蔽。
若在10s内收到2号信号时,不会直接处理,在10s后解除对2号信号的屏蔽后,执行2号信号的处理方法,默认为终止程序。结果如下:
6.5 sigaction
struct sigaction:
其中sigset_t sa_mask:当正在处理某种信号时,想顺便屏蔽其他信号,就可以添加到这个 sa_mask
int sigaction(int signo, const struct sigaction* act, struct sigaction* oact);
参数:signo:
act:输入性参数
oact: 输出型参数:获取对应信号旧的处理方法
返回值:成功返回0,失败返回-1
#include <iostream>
#include <signal.h>
#include <cstdio>
#include <unistd.h>
using namespace std;
void Count(int cnt)
{
while(cnt)
{
printf("cnt: %2d\r", cnt--);
fflush(stdout);
sleep(1);
}
cout << endl;
}
void handler(int signo)
{
cout << "正在处理" << signo << "号信号" << endl;
Count(20);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaction(SIGINT, &act, &oact);
// sigaction(3, &act, &oact);
while(1)
{
cout << "我是一个进程" << endl;
sleep(1);
}
return 0;
}
上述代码想要实现的功能是,在处理2号信号期间同时屏蔽掉3号信号
当我们在递达一个信号期间,同类型的信号无法被递达。
当前信号正在被捕捉,系统会将当前信号加入到进程的信号屏蔽字block,完成捕捉动作,系统又会自动解除对该信号的屏蔽。一般一个信号被解除屏蔽时,如果该信号已经被pending,会自动递达当前信号
7. 可重入函数
-
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因 为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函 数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了
-
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数;反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数
(1).一般而言,认为main执行流和信号捕捉执行流是两个执行流
(2).如果在main中和在sighandler中,某函数被重复的进入,出问题--该函数为不可重入函数;若未出问题则为可重入函数
可重入/不可重入不是一个问题,也不需要解决。目前大部分情况都为不可重入函数
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
8. 关键字volatile
关键字volatile:保持内存可见性,避免编译器优化导致的错误
#include <stdio.h>
#include <signal.h>
volatile int quit = 0;
void handle(int signo)
{
printf("%d号信号,正在被捕捉!\n", signo);
printf("quit: %d", quit);
quit = 1;
printf(" -> quit: %d", quit);
}
int main()
{
signal(2, handle);
while (!quit)
{
printf("正在循环!\n");
sleep(1);
};
printf("注意,我是正常退出的!\n");
return 0;
}