一、信号的概念:
①进程必须识别+能够处理信号,信号没有产生,也要具备处理信号的能力---信号的处理能力属于进程内置功能的一部分
②进程即便是没有收到信号,也能知道哪些信号该怎么处理。
③当进程真的受到了一个具体的信号的时候,进程可能并不会立即处理这个信号,而会在合适的时候处理。
④一个进程必须当信号产生到信号开始被处理,就一定会有时间窗口,要求进程具有临时保存哪些信号已经发生了的能力。
一点五、详谈ctrl c:
1、Ctrl c为什么能够杀掉我们前台进程呢?
Linux中一次登录中,一个终端一般会配上一个bash进程,每一次登录只允许一个进程为前台进程,可允许多个进程是后台进程。而前后台进程的区别,取决于谁来获取键盘输入(获取键盘输入的为前台进程)。
ctrl c本质是被进程解释成为收到了信号(是2号信号,默认动作就是终止自己)。
2、从硬件的角度,谈谈键盘数据是如何输入给内核的?ctrl c又是如何变成信号的?
①
a.键盘读取本质上是把外设数据拷贝到键盘文件对应的缓冲区中(输入的过程),操作系统可以通过库函数(如read/scanf/fget)通过文件把数据读到对应的进程缓冲区/用户缓冲区中。
b.键盘在硬件上可以给CPU发送硬件中断,每一个中断都有中断号,通过各中断单元向CPU特定针脚发送中断后,CPU会保存相应的中断号,从而完成了键盘向CPU,通知其数据已就绪的任务(显示器,网卡等设备同理)。
c.在操作系统内,都会在操作系统开机启动的时候,形成一张中断向量表,存储直接访问外设方法(主要是磁盘,还有显示器键盘等设备)的地址。其中,中断表的下标是中断号。
CPU中的寄存器保存数据的本质上是对CPU寄存器充放电的过程。
②
对于Ctrl c这样的组合键呢,OS会判断输入的是数据还是控制,将ctrl c这样的预定义信号转化为2号信号发送给进程。
综上所述,操作系统就是通过不断向外设接收中断,从而处理外设的。而我们学习的信号就是用软件的方式对进程模拟的硬件中断。
(信号是进程之间事件异步通知的一种方式,属于软中断。)
二、信号的产生:
1. 键盘组合键:
ctrl c 发送2号信号。
ctrl \ 发送3号信号。
ctrl z 发送19号信号。
不是所有的信号都是可以被signal捕捉的,9和19号无法被捕捉。这两个进程都跟进程的执行有关。
2. kill命令:kill -signo pid
3、系统调用接口:
设置对特定进程的自定义处理方法(信号编号,自定义动作)(返回函数指针类型)
①修改特定进程对于信号的处理动作:
//mysignal.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;
//int: 收到了哪一个信号
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
// exit(1);
}
int main()
{
signal(SIGINT, myhandler); // 只需要设置一次,往后都有效!
signal(3, myhandler);
signal(19, myhandler); // 无法被捕捉
// 信号的产生和我们自己的代码的运行是异步的
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
}
② 给任意进程发任意信号:
//mykill.cc
#include <iostream>
#include <string>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;
void Usage(string proc)
{
cout << "Usage:\n\t" << proc << " signum pid\n\n";
}
// mykill signum pid
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signum = stoi(argv[1]);
pid_t pid = stoi(argv[2]);
int n = kill(pid, signum);
if(n == -1)
{
perror("kill");
exit(2);
}
return 0;
}
③给调用者发送指定信号:
#include <iostream>
#include <string>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;
int main()
{
int cnt = 5;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
cnt--;
if(cnt == 0) raise(2); // 相当于kill(getpid(), sig)
}
}
④引起一个正常进程进行终止:(跟直接kill -6 pid的效果不一样)
#include <iostream>
#include <string>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;
int main()
{
int cnt = 5;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
cnt--;
if(cnt == 0) abort(); // 效果好和kill(getpid(), 6)不一样:不仅发送6号信号,还强行让进程终止。
}
}
4、异常:
为什么除0/野指针会让进程崩溃呢?
为什么除0/野指针会给进程发信号呢?
①当前进程在执行时发生除0操作时,CPU中状态寄存器中的溢出标志位由无效改为有效。
进程在被调度期间,CPU寄存器里储存的都是当前进程的上下文。一旦出现异常,对应的状态寄存器由0置1,故该进程是否出异常与进程切换无关,不会影响其他进程。该设计从调度和硬件层面上保证了进程之间的独立性。
任何异常只会影响进程本身,不会波及到操作系统。但操作系统必须要知道该进程出异常,因为CPU是硬件,操作系统是硬件的管理者。故操作系统向进程发信号,使该进程崩溃。
②该进程指向进程地址空间通过其页表访问其代码和数据。
前几篇文章我们谈过,页表是一个软件结构,通过虚拟与物理地址的关系映射找到物理内存。为了提高效率,页表查表的过程并不是由软件完成,而是由一个MMU(内存管理单元)来完成的(MMU在当代芯片当中已经被集成在了CPU内部)。
访问野指针时,虚拟地址到物理地址转换失败,将把转化失败的地址放入CPU中的某寄存器中。CPU内部会出现硬件报错,这也能被OS识别到。
当然,如果进程不崩溃,那它就要一直被调度运行。从而实现在上层给用户更大的宽容度。
//mysignal.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signo)
{
cout << "...get a sig, number: " << signo << endl; // 仅起到捕捉打印作用
exit(1);
}
// 此处我们会观察到信号一直被触发
int main()
{
// signal(SIGFPE, hand ler);
signal(SIGSEGV, handler);
// cout << "div before" << endl;
cout << "point error before" << endl;
sleep(5);
// int a = 10;
// a /= 0; // 异常:Floating point exception(信号8)
int *p = nullptr; // 野指针
*p = 100; //段错误: Segmentation fault(信号11)
// cout << "div after" << endl;
cout << "point error after" << endl;
sleep(1);
return 0;
}
5. 软件条件:异常不只会由硬件产生。
①软件资源不就绪的情况:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signo)
{
cout << "...get a sig, number: " << signo << endl; // 仅起到捕捉打印作用
exit(1);
}
// 此处我们会观察到信号一直被触发
int main()
{
signal(SIGSEGV, handler);
sleep(5);
char buffer[1024];
int n = 1024;
n = read(4, buffer, n); // 4号文件默认不打开
printf("n = %d\n", n);
perror("read"); // read: Bad file descriptor
sleep(1);
return 0;
}
②特殊事件:闹钟(返回值为剩余时间)。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void work()
{
cout << "print log..." << endl;
}
void handler(int signo)
{
work();
cout << "...get a sig, number: " << signo << endl; // 仅起到捕捉打印作用
}
// 此处我们会观察到信号一直被触发
int main()
{
signal(SIGALRM, handler);
int n = alarm(5); // 闹钟不是异常,只响一次
while(1)
{
cout << "proc is running..." << endl;
sleep(1);
}
sleep(1);
return 0;
}
③再谈进程等待:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
// 再谈进程等待
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
int cnt = 500;
while(cnt)
{
while(cnt)
{
cout << "i am a child prcess, pid: " << getpid() << " cnt: " << cnt << endl;
sleep(1);
cnt--;
}
exit(0);
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
cout << "child quit info, rid: " << rid << " exit code: " <<
((status>>8)&0xFF) << "exit signal: " << (status&0x7F) <<
" core dump: " << ((status>>7)&1) << endl;
}
}
}
// Makefile
mysignal:mysignal.cc
g++ -o $@ $^ -g -std=c++11
.PHPNY:clean
clean:
rm -f mysignal
虚拟机在进行除零错误的时候,一段程序崩溃,对应的当前目录下会产生一个临时文件xxx.core,而默认云服务器上面的core功能是被关闭的(保证服务重启的功能一直有效,会形成code dump文件来冲击磁盘,影响服务)。打开系统的core domp功能,一旦进程出异常,OS会将进程在内存中的运行信息dump(转储)到进程的当前目录(磁盘)中,形成core.pid文件:核心转储(core dump)。这样做能够直接复现问题之后,直接定位到出错行。这种先运行再生成core-file称为事后调试。
总结:这些都是信号产生的方式,但是无论信号如何发生,最终一定是OS发送给进程的。因为OS是进程的管理者。
三、信号的发送与保存:
1、对于普通信号而言,对于进程而言,自己有还是没有,收到哪一个信号,是给进程的PCB发。
2、OS用比特位的位置来表示是哪个信号:用01来描述信号,用位图来管理信号。
①比特位的内容是0还是1,表示是否收到。
②比特位的位置(第几个),表示信号的编号。
③所谓“发信号”,本质就是OS去修改task_struct的信号位图对应的比特位。
2、信号保存的原因:进程收到信号之后可能不会立即处理这个信号。信号不会被处理,就要有一个时间窗口。
3、信号的范围[1,31],每一种信号都要有自己的一种处理方法。Handler数组中的函数指针指向操作系统提供的默认方法方法。如果用户自己提供了方法,那么,就将用户提供方法的地址填入该数组(signal方法)。
4、阻塞信号:
①常见概念:
· 实际执行信号的处理动作称为信号递达(Delivery)
· 信号从产生到递达之间的状态,称为信号未决(Pending)。
· 进程可以选择阻塞 (Block )某个信号。
· 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
· 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
②内核表示:
pending表表示,当前进程是否信收到了信号以及收到了哪些信号。
block表表示,进程信号是否被屏蔽。
hander表表示,每种信号对应的处理关系。
我们通过以上所述的三张表和两张位图,和一个函数指针数组就可以,实现对普通信号的记录、保存相关的管理工作了。
· 调用函数sigpromask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigemptyset(sigset_t *set); // 初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
int sigfillset(sigset_t *set); // 初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
// 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
· sigpending:读取当前进程的未决信号集。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(sigset_t &pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n\n";
}
void handler(int signo)
{
cout << "catch a signo: " << signo <<endl;
}
int main()
{
// 0、对2号信号进行自定义捕捉
signal(2, handler);
// 1、先对2号信号进行屏蔽——数据预备
sigset_t bset, oset; // 在用户区栈上开辟的空间
sigemptyset(&bset);
sigemptyset(&oset);
sigaddset(&bset, 2); // 此处还未将2号信号屏蔽,因为并没有将数据设置进入进程的task_struct
// 1.2 调用系统调用,将数据设置进内核
sigprocmask(SIG_SETMASK, &bset, &oset); // 此处已将2号信号屏蔽
// 2、重复打印当前进程的pending 00000000000000000000000000000000
sigset_t pending;
int cnt = 0;
while (true)
{
// 2.1 获取
int n = sigpending(&pending);
if (n < 0)
continue;
// 2.2 打印
PrintPending(pending);
sleep(1);
cnt++;
// 2.3 解除阻塞
if (cnt == 20)
{
cout << "unblock 2 signo" << endl;
sigprocmask(SIG_SETMASK, &oset, nullptr);
}
}
// 3、发送2号 00000000000000000000000000000010
return 0;
}
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(sigset_t &pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n\n";
}
void handler(int signo)
{
cout << "catch a signo: " << signo <<endl;
}
int main()
{
// 4、 思考:如果我们将所以信号全部屏蔽,那么信号不就都不会处理了吗?9和19号信号不仅不可被捕捉,也不可被屏蔽。
sigset_t bset, oset;
sigemptyset(&bset);
sigemptyset(&oset);
for(int i = 1; i <= 31; i++)
{
sigaddset(&bset, i); // 此处还未将所有信号屏蔽,因为并没有将数据设置进入进程的task_struct
}
sigprocmask(SIG_SETMASK, &bset, &oset);
sigset_t pending;
while(true)
{
// 2.1 获取
int n = sigpending(&pending);
if (n < 0)
continue;
// 2.2 打印
PrintPending(pending);
sleep(1);
}
return 0;
}
四、信号的处理:
1、信号的处理方式:
①默认动作。
②忽略。
③自定义动作(信号的捕捉)。
2、信号是什么时候被处理的?
· 当我们的进程从内核态返回到用户态的时候,进行信号的检测与处理。典型的例子就是调用系统调用(此时操作系统是自动会做身份切换的)。
· CPU可以响应来自外部的中断,让操作系统执行。它自己也可以在它自己内部直接产生中断,称为int 80:x86英特尔CPU下,从用户态陷入内核态的汇编语句。
3、信号是如何被处理的?(重谈地址空间3)
· 内核空间映射的是操作系统的代码和数据。
和用户空间一样的是,内核空间也需要内核级页表和操作系统中的代码和数据建立映射。(不过和用户空间不一样的是,内核空间和物理内存中操作系统的代码和数据也可以直接建立映射。)
· 假设有多个进程,有几个进程就有几份用户级页表---因为进程具有独立性。而内核级页表只有一份。所以每一个进程看到的3~4GB的东西都是一样的。所以进程再怎么切换,3~4GB空间的内容是不变的。
站在进程角度,在调用系统调用方法时,相当于在自己的地址空间里调用该方法,调用后直接返回到自己的地址空间里。
站在操作系统角度,任何一个时刻都有进程执行,用户想执行操作系统的代码就可以随时执行。
操作系统的本质:基于时钟中断的一个死循环。计算机硬件中有一个时钟芯片,每个很短的时间,向计算机发送时钟中断。当操作系统检测到时钟信号到来时,CPU就会执行时钟中断所对应的方法。
· CPU有一个ecs寄存器(代码段寄存器)。当它最低的两个比特位(低两位权限位)为00时则为内核态,允许访问操作系统的代码和数据;为11时,则为用户态,只能访问用户自己的代码。CPU为用户提供了int 80(陷入内核)方法来更改CPU的工作级别。
上图整个过程进行4次身份切换,1次信号检测。
4、信号捕捉:
//mysignal.cc
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
using namespace std;
//问题1: pending位图,什么时候从1->0. 执行信号捕捉方法之前,先清0,在调用
//问题2: 信号被处理的时候,对应的信号也会被添加到block表中,防止信号捕捉被嵌套调用
void PrintPending()
{
sigset_t set;
sigpending(&set);
for (int signo = 1; signo <= 31; signo++)
{
if (sigismember(&set, signo))
cout << "1";
else
cout << "0";
}
cout << "\n";
}
void handler(int signo)
{
cout << "catch a signal, signal number : " << signo << endl;
while (true)
{
PrintPending();
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
act.sa_handler = handler; // SIG_IGN SIG_DFL
sigaction(2, &act, &oact);
while (true)
{
cout << "I am a process: " << getpid() << endl;
sleep(1);
}
return 0;
}
5、可重入函数:
现象:insert函数被main和handler执行流重复进入(重入)了。导致节点丢失,内存泄漏。这被称为不可重入函数,否则为可重入函数。(目前学到的大部分函数都是不可重入的。)
6、volatile关键字:防止编译器过度优化,保持内存的可见性:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>
using namespace std;
volatile int flag = 0;
void handler(int signo)
{
cout << "catch a signal: " << signo << endl;
flag = 1;
}
int main()
{
signal(2, handler);
// 在优化条件下, flag变量可能被直接优化到CPU内的寄存器中
while(!flag); // flag 0, !falg 真
cout << "process quit normal" << endl;
return 0;
}
7、SIGCHID:子进程退出时,会主动向父进程发送SIGCHID17号信号。
· 子进程在进行等待的时候,我们可以采用基于信号的方式进行等待。
· 等待的好处:
①获取子进程的退出状态,释放子进程的僵尸。
②虽然不知道父子谁先进行,但是我们清楚,一定是父进程最后退出。
· 不过还是要调用wait/waitpid这样的接口,父进程必须保证自己是一直在运行的。
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
// 必须要等待吗?必须要调用wait吗?
// void handler(int signo)
// {
// sleep(5);
// pid_t rid;
// while ((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
// {
// cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;
// }
// }
int main()
{
signal(17, SIG_IGN); // SIG_DFL -> action -> IGN
// srand(time(nullptr));
// signal(17, handler);
// 如果我们有10个子进程同时退出呢?
// 如果退出一半呢?
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
sleep(5);
break;
}
cout << "child quit!" << endl;
exit(0);
}
// sleep(rand()%5+3);
sleep(1);
}
// father
while (true)
{
cout << "I am father process: " << getpid() << endl;
sleep(1);
}
return 0;
}