【Linux系统编程】进程信号 - 信号产生
目录
信号的概念
信号产生
键盘产生
信号的处理流程
指令产生
系统调用产生
软件条件
异常
信号的概念
生活中是存在信号的,像狼烟、红绿灯、铃声、闹钟等都是信号。在计算机中,信号一种用户、OS、其他进程,向目标进程发送异步事件的一种方式!
异步是什么?假设当前我是老师,正在上课,突然收到了快递到了的信息,我让同学去帮忙取,然后我继续上课,这就是异步,任务发起后立即继续,不等待结果。若我停止上课,亲自去取快递,取完回来再继续上课,这是同步,任务顺序执行,必须等待前一个完成。
信号是否也属于进程间通信的一种方式?
宽泛地讲,是的,严格地讲,不是,因为它和我们之前所说的IPC属于不同的机制,具体表现在内核中的实现是不一样的。
预备知识:
- 进程能识别信号吗?是可以的,进程识别信号是内置的特性。
- 信号产生之后,进程知道要如何处理这个信号吗?知道。如果没有信号产生,进程知道如何处理信号吗?知道,正常执行即可!信号的处理方式,在信号产生之前,已经准备好了!
- 进程处理信号是收到信号就立即处理吗?不是,进程在做自己的事情,若这个事情的优先级很高,进程会等到合适的时候再去处理信号。若进程收到了信号,但是没有立即处理,那么在收到信号和处理信号之间,进程需要将收到的信号记录下来。
- 进程怎么处理信号?a. 默认行为 b. 忽略信号 c. 自定义行为
进程处理信号的方式有3种,无论哪一种,都是处理信号,信号处理也叫信号捕捉。如闹钟响了,默认行为是起床,忽略信号是继续睡,自定义行为是跳个舞。
信号产生
键盘产生
我们写一个一直死循环的程序。Signal.cc:
int main()
{while(true){std::cout << "hello world" << std::endl;sleep(1);}return 0;
}
直接运行起来的程序是一个前台程序
可以加上&让其变成一个后台程序
后台进程是允许向显示器打印的,但是尽量让其往后端文件中打。上面看到的[1]是作业号
前台进程输入一些命令是没有用的,因为当前的终端是被sig这个进程占用的,所以shell进程就无法接受命令行输入了,我们输入的所有命令,都是发给前台进程的,也就是说刚刚发的top等都是发给sig的。ctrl+c是终止进程的,更准确地说,是终止前台进程的。
而后台进程,我们输入相应的指令是可以跑的。所以,启动一个前台进程时,前台进程就由bash变成了启动的这个进程,所以在命令行输入是输入给启动的这个前台进程的;启动一个后台进程时,前台进程仍然是bash,所以命令行输入仍然是输入给bash进程,bash进程依然可以进行命令行解析。后台进程是无法使用ctrl+c杀掉的,可以用另一个shell使用信号杀掉。
nohup是让后面的后台程序的结果放到一个默认生成的nohup.out文件中。杀掉这个进程同样在另一个终端使用kill。另外,也可以使用fg,然后ctrl + c,fg + 作业号:将这个作业号对应的进程从后台放到前台。
此时进程变成前台进程,但是打印的信息仍然是往nohup.out中打印的
结论:ctrl+c只能终止前台进程,若要终止后台进程,可以使用信号,或者将其变成前台进程。
ctrl+c是键盘组合键,在命令行输入时,是发送给当前的前台进程的,在命令行输入ctr+c后,就会被OS转换成信号,并将信号发给前台进程。我们来验证一下ctrl + c确实是会被OS转换成信号,并发送给前台进程。
系统调用signal可以对一个指定的信号,设定自定义捕捉方法。我们现在想验证ctrl+c确实会被弄成信号并发送给进程,刚刚的ctr+c会让进程终止,我们可以自定义一个,也就是收到信号后不让进程终止,而是执行指定的方法。
// 这是定义了一个函数指针类型,并且这个函数返回值是void,参数是int
// 将void (*)(int) 重命名为 sighandler
typedef void (*sighandler_t)(int);
Linux支持的常见信号
1-31是普通信号,重点学习;34-64是实时信号,不作说明。
signal的第一个参数既可以传递信号编号,也可以传递信号名称,信号名称实际上就是一个宏,第二个参数是函数的地址。未来收到信号,不要执行默认动作,而是执行传进来的函数。signal就是信号捕捉,更准确地说是信号自定义捕捉。
ctrl + c会被OS接收,解释为2号信号,并发送给前台进程。
void Handler(int signo)
{// 当对应的信号被触发时,内核会将对应的信号编号,传递给自定义方法std::cout << "Get a signal, signal number is: " << signo << std::endl;
}int main()
{signal(SIGINT, Handler);while(true){std::cout << "hello world" << std::endl;sleep(1);}return 0;
}
可以看到,此时ctrl+c后,不会终止进程,而是调用了上面的函数,因为我们修改了2号信号的处理方法,讲默认终止,改成了执行自定义方法:Handler
可以使用man 7 signal查看更详细的信号手册
第三列Action就是信号的默认动作。可以看到,2号信号是Term,就是终止进程;3号信号是Core,也是终止进程。Term和Core都是终止进程,有什么区别后面说。通过上面的内容会发现,大部分的信号都是终止进程。
键盘不仅仅只能产生2号信号,可以通过ctrl + \ 产生3号信号。
void Handler(int signo)
{// 当对应的信号被触发时,内核会将对应的信号编号,传递给自定义方法std::cout << "Get a signal, signal number is: " << signo << std::endl;
}int main()
{signal(SIGQUIT, Handler);while(true){std::cout << "hello world" << std::endl;sleep(1);}return 0;
}
a.为什么signal不放在循环里?只需要设置1次即可,设置后进程就都能够记住了
b.这里对3号信号进行了设置,若没有产生3号信号呢?则Handler方法永远不会调用
所以,signal只是类似于向进程注册一下,注册完成后就不用管了。
我们会发现,普通信号只有31个,我们是不是可以将所有信号都进行捕捉,这样进程就杀不死了呢?并不是。因为不是所有的信号都能够被捕捉。1-31号信号中,9和19是无法被捕捉的。
对于信号捕捉,有3种方式,前面我们所使用的都是自定义捕捉,SIG_IGN是忽略
int main()
{::signal(2, SIG_IGN);while(true) ;return 0;
}
此时ctrl + c是没有效果的,因为这个信号被忽略了。忽略就是一种信号捕捉的方式,只是动作是忽略。
SIG_DFL是默认
int main()
{::signal(2, SIG_DFL);while(true) ;return 0;
}
#define SIG_DFL ((void (*)(int))0) // 默认处理
#define SIG_IGN ((void (*)(int))1) // 忽略信号
这两个东西本质上就是宏,将0和1强转成了函数指针的类型。所以,我们自定义时传入的函数指针的地址一定不是0和1,而是在我们的代码块中编好的一个虚拟地址。
信号的处理流程
这里根据上面提到的内容,笼统地介绍一下。整体是流程:键盘 -> OS -> 进程。
软件
OS是硬件的管理者,硬件的任何行为OS都会知道的。键盘输入后,会被OS识别到,若是信号的组合键,OS就会将对应的信号发送给进程。OS将信号发送给进程后,进程不一定是立即处理信号的,进程若没有立即处理,就需要先记录下信号,那进程如何记录信号呢?会发现,信号的编号是1-31,是连续的,所以可以使用位图。所以,可以在进程的task_struct当中,创建一个位图来记录信号。所以,发送信号的本质是修改目标进程的task_struct中的信号位图,将对应位置由0变成1。所以,发送信号更准确地说是写入信号。uint32_t是C/C++中一种精确宽度(exact-width)的无符号整数数据类型,uint32_t表示32位无符号整数,有符号版本是int32_t。位图就可以是unit32_t类型的对象,比特位的位置就是信号的编号,比特位的内容(0/1)就表示是否收到了信号。OS有没有权利去修改PCB的位图呢?有权利,因为OS是进程的管理者,并且是唯一管理者。所以,无论我们使用什么方式产生信号,最终信号的发送者只能是OS,让OS在进程的PCB中写入信号。
信号被进程保存后,后序是如何处理信号的呢?
实际上,进程中还有一张表sighanlder_tarr[32],这是一个函数指针数组,这个表就指向各个信号的处理方法。当我们使用signal修改一个信号的处理方法时,就会修改这张表。所以,未来要处理信号时,首先去检测位图,看需要处理那些信号,然后拿着信号,根据下标去这张表中找到对应的方法并执行即可。
预备中的1,如何识别信号,正是因为有信号位图。预备中的2,信号产生之后怎么处理,进程是知道的,因为有这张表预备中的3,可以不立即处理,因为可以先保存在位图当中。
硬件
上面我们理解了OS->进程的部分,那OS是怎么知道键盘上面有数据的呢?是通过硬件中断。
硬件上有一个概念叫硬件中断,实际上,键盘是与CPU连接的。通过冯诺依曼体系结构可以看到,键盘就是输入设备,在数据信号方面,键盘是不与CPU连接的,但是在控制信号方面,键盘是直接与CPU连接的。当我们向键盘输入后,键盘会通过硬件中断通知CPU,中央处理器会收到中断,并告诉OS有外设准备好了,OS就会将外设的数据拷贝到内存当中。这样,OS就不需要再去轮询所有的外设了,只需要等待中央处理器的通知即可。读写磁盘也是同样的,当OS告诉磁盘要访问的LBA地址时,磁盘内部就会将LBA地址转成CHS地址,并做各种定位,将数据读取出来,读取完成后,磁盘就会通过硬件中断通知CPU,然后CPU通知OS。OS给磁盘LBA地址后,OS是不会等待的,而是去做自己的事情,直到收到CPU的通知。所以,有了硬件中断后,硬件和OS可以并行执行了。
OS可以通过中断的方式去管理所有的硬件,同样可以使用类似于中断的方式去管理软件,于是就有了信号。所以,信号和硬件中断是两个不同的东西。信号是纯软件,是模拟中断的。硬件中断是纯硬件的。
指令产生
可以使用kill向一个指定的进程发送信号。前面已经使用过了,这里就不过多赘述了。
系统调用产生
kill
kill不仅仅是一个指令,同样也是一个系统调用。它可以向一个指定的进程发送信号。若调用成功,返回0;若调用失败,返回-1,并设置errno。
我们来自己实现一个kill指令
void Usage(std::string proc)
{std::cout << "Usage: " << proc << " Signumber processid" << std::endl;
}int main(int argc, char *argv[])
{if(argc != 3){Usage(argv[0]);exit(1);}int signumber = std::stoi(argv[1]);pid_t id = std::stoi(argv[2]);int n = ::kill(id, signumber);if(n < 0){perror("kill");exit(2);}return 0;
}
我们直接运行一个cat,因为cat运行起来后就会变成一个进程
然后在另外一个终端
此时就成功实现了一个kill指令
raise
raise是一个标准库函数,是给调用raise的进程发送指定信号。
int main()
{int cnt = 5;while(true){std::cout << "hello world" << std::endl;cnt --;if(cnt <= 0)raise(9);}return 0;
}
abort
abort是一个标准库函数。让调用abort的进程终止,是给自己发送6号信号。
int main()
{int cnt = 5;while(true){std::cout << "hello world" << std::endl;cnt --;if(cnt <= 0)abort();sleep(1);}return 0;
}
raise和abort底层调用的就是系统调用kill。abort和exit类似,都是终止进程,但是又有些不一样,
exit是让进程正常终止,会调用析构函数,会刷新缓冲区等,abort是强制进程终止,不会调用析构函数,也不会刷新缓冲区。
软件条件
我们之前说过,当一个管道的读端关闭后,写端若还要向管道中写入数据,是会被终止的,这是因为OS向写端的这个进程发送了13号信号。管道就是文件,文件就是软件,所以管道就是软件,当管道的写入条件不具备时,这就是叫做软件条件不具备,若此时还要做非法的事情,就会向对应的进程发送信号,在这里就是直接终止进程。在OS当中,某些软件没有准备好,或者条件不具备,或者是因为一些软件的问题,是可以向目标进程发送信号的。此时向写端所在进程发送信号仍然是由OS发送的。
看看其他的软件条件
系统调用alarm,是设定一个指定秒数的闹钟,当秒数到了,OS(因为是系统调用所以是OS发送)会给调用了alarm的进程发送14号信号来终止进程,返回值是闹钟剩余时间。14号信号的默认行为是Term。
int main()
{// 当前进程1秒后,会收到SIGALRM信号alarm(1);int number = 0;while(true){std::cout << "count: " << number ++ << std::endl;}return 0;
}
这个程序统计了我的服务器1s可以将计数器累加到多少。注意:每次执行累加到的数字会有一些区别,由于进程的竞争,被CPU挂起的次数是不一定的。
会发现1秒才累加到了7万,非常地慢。
int number = 0;void die(int signo)
{std::cout << "get a sig: " << signo << ", count: " << number << std::endl;exit(0);
}int main()
{// 当前进程1秒后,会收到SIGALRM信号alarm(1);signal(SIGALRM, die);while(true){number ++;// std::cout << "count: " << number ++ << std::endl;}return 0;
}
会发现此时累加到的指相对于之前变化了非常多。因为现在没有cout,没有IO,只是单纯的CPU计算。所以,10的效率相对于计算是非常低的。
如何理解闹钟?
OS是认识时间的。设置了一个闹钟,就相当于在OS层面设置了一个定时器。前面我们说了OS会进行进程管理,会进行文件管理,实际上,操作系统还会进行定时管理。每个进程都可以设置闹钟,所以OS内部可能会有非常多个定时器,所以OS需要管理定时器,先描述,再组织。所谓定时器,实际上就是一个结构体对象。
struct timer
{int who; // 哪一个进程设置的task_struct* t; // 设置进程的task_struct指针uint64_t expired; // 是一个时间戳,表示过期时间// 这个定时器对应的方法,当到达了过期时间就执行这个方法,如给目标进程发送14号信号func_t f;struct timer* next;
};
OS真正是如何对定时器的结构体进行组织的,这里不讨论,只是给出几种可能的做法
a.可以使用链表,但是这样并不好,因为需要不断的轮询,查看有没有定时器到达过期时间了
b.仍然使用链表,但是根据过期时间进行排序
c.根据过期时间使用小根堆
所以,调用alarm就是获取当前时间,并加上我们设置的秒数,然后在OS内部创建一个定时器,当到达过期时间,就像目标进程发送14号信号,并将这个结点删掉。
关于alarm返回值的理解:假设我们设置了1个3秒的闹钟,过了1秒我们想删除闹钟,可以使用int n=alarm(0)。alarm(0)就是删除闹钟,此时返回值是2,表示删除的闹钟剩余的时间。
int main()
{alarm(3);sleep(1);int n = alarm(0);std::cout << "n: " << n << std::endl;return 0;
}
定时器是OS先描述,再组织的数据结构,属于软件,当软件条件就绪时,比如超时,OS就可以向目标进程发送信号。这就是软件条件,与硬件无关。
写一段关于闹钟的代码:我们要完成一个定时器的代码,让程序每隔1秒执行1次任务,并且不使用sleep,而使用alarm。
using func_t = std::function<void()>;int gcount = 0;
std::vector<func_t> gfuncs;void hanlder(int signo)
{for(auto &f : gfuncs){f();}std::cout << "gcount: " << gcount << std::endl;
}int main()
{gfuncs.push_back([](){std::cout << "我是一个日志任务" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个下载任务" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个MySQL任务" << std::endl;});alarm(1);signal(SIGALRM, hanlder);while(true)gcount++;return 0;
}
可以看到,确实是1秒之后才执行任务,但是执行1次之后就没了。因为使用alarm设置的闹钟是一次性闹钟,闹钟响后就没了。正确的做法是,闹钟响后,执行完闹钟对应的函数,应该再设置1个新闹钟。
using func_t = std::function<void()>;int gcount = 0;
std::vector<func_t> gfuncs;void hanlder(int signo)
{for(auto &f : gfuncs){f();}std::cout << "gcount: " << gcount << std::endl; alarm(1);
}int main()
{gfuncs.push_back([](){std::cout << "我是一个日志任务" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个下载任务" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个MySQL任务" << std::endl;});alarm(1);signal(SIGALRM, hanlder);while(true)gcount++;return 0;
}
系统调用pause是等待一个信号。
using func_t = std::function<void()>;int gcount = 0;
std::vector<func_t> gfuncs;void hanlder(int signo)
{for(auto &f : gfuncs){f();}std::cout << "gcount: " << gcount << std::endl; alarm(1);
}int main()
{gfuncs.push_back([](){std::cout << "我是一个内核刷新操作" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << std::endl;});alarm(1);signal(SIGALRM, hanlder);while(true){pause();std::cout << "我醒来了..." << std::endl;gcount ++;}return 0;
}
可以使用这个方法,等信号来了再去执行任务。
所以,只要有类似的任务,我们可以使用信号来模拟OS的行为。并且,只要将信号换成硬件中断,这就是OS的运行原理。OS就是一直pause,直到有硬件中断的到来,到来后就去执行对应的方法,然后继续pause。
现在讲的4中产生信号的方式,最终都会由OS发送对应的进程,其实就是修改进程PCB中的位图
异常
之前在写C/C++代码时,当出现除0或者野指针时,程序就会崩溃,程序为什么会崩溃?
int main()
{int* p = nullptr;*p = 77;return 0;
}
可以看到,程序直接就崩溃了。程序崩溃的原因是发生了野指针后,OS会识别到这个进程有问题,就发送信号让这个进程终止了。发送的是11号信号,默认行为是Core。
void handler(int signo)
{std::cout << "get a signo: " << signo << std::endl;
}int main()
{signal(11, handler);int* p = nullptr;*p = 77;while(true);return 0;
}
可以看到,确实是收到了11号信号
除0会导致进程收到8号信号
void handler(int signo)
{std::cout << "get a signo: " << signo << std::endl;
}int main()
{signal(8, handler);int a = 10;a /= 0;while(true){std::cout << "hello world" << std::endl;}return 0;
}
可以看到,确实是收到了8号信号。
所以,C/C++中,常见的异常会导致程序崩溃,这是因为OS给进程发送了对应的错误信号,而这些信号的默认行为都是让进程退出,所以会导致进程退出。
来看两个问题:
1. OS怎么知道进程出错了?为什么会死循环?
a. 除0
当我们计算a/=0时,会有三个步骤,先将a从内存拷贝到CPU中,然后计算a/0,再将结果赋值给a。计算是CPU的工作,当我们计算a/0时,会将a的值放在eax寄存器中,0放在ebx寄存器中,然后将计算的结果放在ecx寄存器中,CPU中还有一个状态寄存器Eflags,作用是存储CPU最近一次运算结果的状态信息(如进位、溢出、零标志等),为后续指令提供条件判断和流程控制的依据。其中就有一个溢出标记位,计算完成后,若溢出标记位为0,说明没有溢出,结果可靠,将ecx中的结果写回内存,此时就是赋值,完成计算;但若是溢出标记位为1,说明CPU内部计算出错了。CPU内部计算出错了,并且CPU是一个硬件,而OS是硬件的管理者,所以OS一定会知道CPU出错了,OS知道CPU出错了,就一定能够知道具体是哪一个进程让CPU计算出错的,因为就是当前正在调度的这个进程,OS就会通过信号终止这个进程。
可是为什么我们刚刚的代码不会退出呢?因为我们的代码对8号信号进行了信号捕捉。若想让其退出,可以再handler中加上一个exit。既然进程没有退出,那么进程仍然会被调度,因为一直在while(true)中执行。CPU内寄存器只有一套,下一次调度这个进程时,会使用这个进程的上下文去覆盖寄存器的值,根据上下文信息,状态寄存器的值又会是1,所以OS又会向进程发送信号,所以会一直循环。所以,进程触发了异常,可以不退出,但是不退出也没什么用,只要硬件错误一直存在,OS仍然会一直杀进程,虽然我们捕捉了信号,让OS杀不掉进程,但是进程也什么都做不了
b. 野指针
虚拟地址到物理地址的转换不是由OS完成的,OS只维护页表,转换的工作是由MMU这个硬件完成的。现在,MMU已经被集成到了CPU的内部。CR3寄存器保存的是页表的起始物理地址。EIP寄存器用于存储下一条待执行指令的内存地址,这里是虚拟地址。当要执行下一条指令之前,需要从EIP寄存器中拿到下一条指令的内存地址,所以就会涉及到地址解析(虚拟地址->内存地址),地址解析就是MMU配合CR3寄存器。当我们今天传入的虚拟地址是0,这是虚拟内存的起始地址,是没有权利访问的,MMU在转化时会发现无法转化,MMU这个硬件就会报错了,此时就是CPU内部出错了,就与上面一样了。并且MMU里面也有类似于状态寄存器的东西,也会保存在进程的上下文信息中,所以,若对信号进行了捕捉,导致11号信号无法终止进程,进程下一次被调度时,恢复上下文信息,MMU仍然会直接出错,所以会一直循环。
总结:OS怎么知道我们的进程出错了?程序内部的错误,都会表现在硬件错误上。OS作为硬件的管理者,就会知道,然后给目标进程发送信号。
2. Core VS Term
Core和Term都是让进程终止,有什么区别呢?Term就是普通的终止,Core会多做一些事情。
对于Core有一个核心转储的概念。当一个程序崩溃了,OS会向这个进程发送信号,但是作为户,我们更加关心的是这个进程为什么会崩溃。所以,进程退出有两种方式,Term是正常的退出,不需要进行debug;Core会在当前目录下形成一个文件,这个文件一般叫做core.pid,在CentOS7和Ubuntu下有点不同,OS会在进程崩溃的时候,将进程在内存中的部分信息保存起来,放在这个文件中,方便后序调试。但是我们刚刚并没有看到这个core文件,这是因为云服务器一般是关闭core功能的。
可以看到,系统默认将core file的大小设置为0。我们可以手动设置这个文件的大小。
int main()
{int a = 10;a /= 0;return 0;
}
可以看到,确实生成了一个core.timer.454673,这是Ubuntu下。在CentOS7下,这个文件的名称是core.pid。
为什么云服务器一般是关闭core功能的呢?
因为当一个程序崩溃挂掉之后,一般会在后端重启或者由软件重启,而这个程序因为有错误,所以一重启就由会马上挂掉,如果在半夜挂掉,然后一直重启,第二天会创建出非常多个core文件,可能导致OS崩溃。较新的Linux内核对于core文件的命名是统一的,可以防止重生成多个core文件,此时只会不断地更新core文件。并且云服务器为了保证安全,同时还关闭了生成core文件的选项。
生成的这个core文件有什么用呢?
int main()
{std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;int a = 10;a /= 0;std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;std::cout << "hello world" << std::endl;return 0;
}
编译时加入-g就可以对程序进行调试了
BIN=timer
CC=g++
SRC=$(shell ls *.cc)
OBJ=$(SRC:.cc=.o)$(BIN):$(OBJ)$(CC) -o $@ $^ -std=c++11%.o:%.cc$(CC) -c $< -g -std=c++11.PHONY:clean
clean:rm -f $(BIN) $(OBJ)
此时可以使用gdb来调试代码,我们如果想知道是在哪一行出错的,是因为什么原因出错的,可以一行一行往下调试,但是现在有core文件就不用这么麻烦了。
在gdb timer后,进入了gdb,直接core-file core,就可以直接看到是哪里出错了。
core-file core就是事后调试。需要先形成核心转储,才能事后调试。当有时候很难找到错误时,可以尝试使用事后调试。
我们刚刚看到的都是我们的进程异常退出的情况,如果是子进程异常了呢?
第8个比特位为0或为1,表示的是进程若异常,是否发生core dump
int main()
{if(fork() == 0){sleep(1);int a = 10;a /= 10;exit(0);}int status = 0;waitpid(-1, &status, 0);std::cout << "exit signal: " << (status&0x7F) << ", core dump :" << ((status>>7)&1) << std::endl; return 0;
}
发生了core dump,并创建了core
所以,是否会触发core dump标志,取决于:退出信号是否终止动作是core&&服务器是否开启core功能。