目录
一. 进程信号概述
1.1 生活中的信号
1.2 进程信号
1.3 信号的查看
二. 信号发送的本质
三. 信号产生的四种方式
3.1 按键产生信号
3.2 通过系统接口发送信号
3.2.1 kill -- 向指定进程发送信号
3.2.2 raise -- 当自身发送信号
3.2.3 abort -- 向自身发送进程终止信号
3.3 软件条件产生信号
3.3.1 管道通信读端关闭
3.3.2 时钟问题
3.4 硬件异常产生信号
3.4.1 除0错误
3.4.2 野指针、越界访问问题
四. Core dump问题
4.1 什么是core dump
4.2 进程退出状态中的core dump标记位
4.3 用core文件调试代码
五. 总结
在生活中,信号作为信息通知的一种手段无处不在,我们看到信号后,需要根据信号的种类,来对信号做出特定的反应。如:红绿灯、起床闹钟、警铃、电话铃声等,都属于信号的范畴。
我们能对信号做出相应的反应,是因为我们能够识别信号,并且知道对应的处理动作。按照日常经常,对于生活中的信号,做出以下的特性总结:
进程信号与生活中的信号,都是用于信息通知的。我们可以这样理解进程信号:进程信号是一种信息通知机制,由用户或操作系统发送给特定的进程,通知进程发生了某事件,进程可以稍后处理。
进程对于信号的处理,与生活中的信号处理策略本质相同,可归为以下三类:
同样,我们也可以推断出进程信号应用的特性如下:
在Linux系统中,通过kill -l指令,可以查看系统中所有内置信号的编号与对应名称,如图1.1所示。
其中2号信号SIGINT,就对应我们经常使用的 Ctrl + C 终止进程。
观察图1.1,我们发现,信号的最高编号为64,没有0号信号,没有32、33号信号,Liunx总共定义有62个信号。其中:
普通信号用于OS通过正常的运行调度队列调度的进程,即:进程轮番拿到CPU上去运行,每个进程每次在CPU上运行特定的时间片长度,然后切换运行进程。
实时信号只用于极少数的生产环境,这是OS要求要对某个进程运行完毕才可以从CPU上拿下,且进程不应在调度队列中等待调度,而是应当立马调度运行。如:车载系统接收到刹车指令,不可以等待其他进程运行,也必须一次完成运行。
通过man 7 signal指令,下翻查找,我们可以看到每种信号的默认处理方式。
要理解信号发送的本质,首先要理解进程如何保存信号。对信号进行保存,需要记录:a.哪一种信号,b.特定信号是否产生。
这让我们很容易想到位图这种数据结构,位图中通过对特定比特位设置0/1值,来表示某件事情是否发生或某对象是否存在。进程PCB中存有位图这种数据结构来保存信号,每种信号都有其对应的编号,每个编号对应位图中的一个bit位,1表示产生并保存了某信号,0表示没有保存某信号。
如果进程收到了某信号但暂时不对信号做出响应,那么就应当将位图中对应的bit位由1置0,等待进程做出了响应之后,在置回0。
总结信号发送的本质为:OS向目标进程写信号 -> OS改变进程中记录信号状态的位图中的特点bit位 -> 完成信号发送过程。
Crtl + C 组合键终止进程最终也是改变进程中位图的bit位值,键盘中特定槽位的按键按下,键盘驱动就会将按键按下的信息传给OS,OS就会将按键信息转换为信号,然后发送给进程。
通过组合键的方式,可以向进程发送信号,如:
其中2号信号的默认处理方式为进程终止,不进行核心转储,3号信号的默认处理方式为终止进程,进程核心转储。(本文后面会对核心转储进行讲解)
如代码和图3.1所示,运行一个间隔1s输出进程pid的进程,通过Ctrl + C组合键和Ctrl + \组合键向这个死循环进程发送信号,进程都终止掉了,即使没有运行到最后的代码。
代码3.1:死循环打印进程pid
#include
#include
int main()
{
while(true)
{
std::cout << "This is a process, pid:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
函数原型:int kill(pid_t id, int sig)
头文件:#include
、#include 函数功能:向特定进程发送指定信号
返回值:发送成功返回0,失败发挥-1
编写信号发送程序的源文件kill.cc如代码3.2所示,编译生成可执行文件取名为kill,在命令行参数中输入 ./kill [id] [sig],即可向指定进程发送特定信号。如图3.2所示,通过kill向死循环进程发送8号信号,进程被终止掉了。
8号信号SIGFPE:浮点数错误,常发生于 x/0 的情况(除0错误)
代码3.2:信号发送kill.cc
#include
#include
#include
int main(int argc, char** argv)
{
// 发送信号,必须要有三个命令行参数
if(argc != 3)
{
std::cout << "参数个数错误" << std::endl;
exit(1);
}
int id = atoi(argv[1]); // 获取结束信号进程的id
int sig = atoi(argv[2]); // 获取信号编号
kill(id, sig); // 发信号
return 0;
}
函数原型:int raise(int sig)
头文件:#include
函数功能:向自身发送指定编号的信号
返回值:发送成功返回0,失败发挥-1
代码3.3在通过fork创建子进程,在子进程中循环5次后,通过raise向子进程本身发送8号信号,父进程阻塞等待子进程退出,接收子进程的返回信号并输出到屏幕,代码运行结果见图3.3。
代码3.3:raise向自身发信号
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
exit(1);
}
else if(id == 0)
{
// 子进程代码
int count = 0;
while(true)
{
std::cout << "[Child process]# [" << getpid() << \
"], count:" << ++count << std::endl;
if(count == 5)
{
raise(SIGFPE); //向吱声发送8号信号
}
sleep(1);
}
exit(0);
}
int status = 0;
pid_t n = waitpid(id, &status, 0);
std::cout << "signal:" << (status & 0x7F) << std::endl;
return 0;
}
函数原型:void abort()
头文件:#include
函数功能:向自身发送6号终止信号SIGABRT
代码3.3:abort向自身发送进程终止信号
#include
#include
#include
#include
#include
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
exit(1);
}
else if(id == 0)
{
// 子进程代码
while(true)
{
std::cout << "[Child process]# pid:" << getpid() << ", ppid:" << getppid() << std::endl;
abort(); // 向子进程自身发送进程终止信号
}
exit(0);
}
int status = 0;
pid_t n = waitpid(id, &status, 0);
std::cout << "signal:" << (status & 0x7F) << std::endl;
return 0;
}
软件条件产生信号,就是OS检测到某种软件条件触发或者不满足,由OS向指定进程发送信号。如:a. 管道通信中,读段关闭,写端进程退出 b.时钟问题
管道通信,是一种提供访问控制的进程间通信方式,一端进程负责写数据(写端),另一端进程负责读数据(读端),如果读端关闭,那么写端就不再有意义,OS会强制终止写端进程。OS终止写端进程的方法就是发送13号信号SIGPIPE。
代码3.4对上述现象进行复现,a. 通过fork创建子进程 -> b. 父进程读数据,子进程写数据 -> c. 父子进程进行一段时间的通信 -> d.关闭父进程读端管道 -> e.父进程阻塞等到子进程退出,输出退出状态信息。运行代码,可见子进程因接收到了13号SIGPIPE信号而终止退出。
代码3.4:管道通信中因软件条件产生信号
#include
#include
#include
#include
#include
#include
int main()
{
// 1.创建管道
int pipefd[2] = {0};
int n = pipe(pipefd); // 管道创建
if(n == -1)
{
perror("pipe");
exit(1);
}
// 2.创建子进程
pid_t id = fork();
// 让子进程写数据, 父进程读数据
if(id == 0)
{
// 子进程发送数据
// 3. 关闭不需要的fd
close(pipefd[0]);
// 4. 写数据
// char send_buffer[1024] = {0};
std::string message = "[Child process]# Send message to you";
while(true)
{
// 子进程间隔一秒发送数据
ssize_t sz = write(pipefd[1], message.c_str(), message.size());
if(sz > 0) sleep(1);
else break;
}
close(pipefd[1]);
exit(1);
}
// 父进程读取数据
// 3. 关闭不需要的fd
close(pipefd[1]);
// 4. 开始读取数据
char read_buffer[1024] = {0};
int count = 0;
while(true)
{
ssize_t sz = read(pipefd[0], read_buffer, 1024);
if(sz > 0)
{
read_buffer[sz] = '\0';
std::cout << "[" << getpid() << "]" << read_buffer << ", count:" << ++count << std::endl;
if(count == 5)
{
// 关掉读端
// 终止读取死循环
close(pipefd[0]);
break;
}
}
else break;
}
// 5. 父进程阻塞等待子进程退出
int status = 0;
waitpid(id, &status, 0);
std::cout << "child process exit, write end, exit signal:" \
<< (status & 0x7F) << std::endl;
return 0;
}
首先要了解一下时钟接口函数alarm。
alarm函数:
函数原型:unsigned int alarm(unsigned int second)
头文件:#include
功能:调用时钟函数second秒后,向进程发送14号SIGALRM信号,在倒计时期间进程继续运行不受影响。
返回值:如果之前有alarm函数被调用,返回上一个时钟倒计时还有几秒,如果之前没有alarm函数被调用,返回0。
代码3.5为算力评估函数,通过时钟函数,自定义处理14号SIGALRM信号的方法,计算1s内CPU能够进行多少次的++操作,在自定义信号处理方法中输出。
代码3.5:通过alarm函数进行算力评估
#include
#include
#include
uint64_t count = 0;
void handler(int sig) // 自定义信号处理函数
{
std::cout << "count end, final count:" << count << std::endl;
exit(0);
}
int main()
{
signal(SIGALRM, handler);
alarm(1); // 倒计时1秒
while(true) ++count;
return 0;
}
硬件异常,被硬件以某种方式记录(通常为寄存器中的状态标记信息),由OS检测到,并产生信号发送给进程。比如:除0错误被CPU检查并记录,OS检测到并产生SIGFPE信号发送给进程,再比如野指针、越界访问内存的问题,MMU检测到了非法访问,由硬件记录异常状态,OS检测到后发送SIGSEGV信号给进程。
Linux中越界访问、野指针等问题通常都被记录为段错误Segmentation fault,对应11号SIGSEGV信号。
代码3.6自定义了对SIGFPE信号的处理方法handler_signal,出现异常程序不会退出终止,在主函数中,先人为写出除0错误,然后while(true)死循环。
按照我们的一般认知,进程在接收到信号后只会执行一次对应的自定义处理动作,但是,如图3.6所示,handler_signal函数在不断地被运行,这是为什么?更加奇怪的是,如果将代码中发生/0错误的部分注释掉,通过命令行kill -8的方法,人为向进程发送8号信号,handler_signal就只会被执行一次!
代码3.6:除0错误引发硬件异常
#include
#include
#include
void handler_signal(int sig)
{
std::cout << "recieve a signal:" << sig << std::endl;
sleep(1);
}
int main()
{
signal(SIGFPE, handler_signal);
int a = 10;
int b = a/0;
while(true) {}
return 0;
}
自定义handler_signal后,除零错误产生的现象:
产生这种现象的原因就在于,代码本身产生的除0错误为硬件异常,而人为kill -8发送信号不经由硬件,直接被OS处理后将信号发送给进程。
我们可以以如下的方式理解硬件产生的除0异常和上面的奇怪现象:
当某个进程需要访问物理内存资源时,需要先拿到进程地址空间的虚拟地址,再通过 页表 + MMU(Memery Manager Unit,内存管理模块) 映射到物理内存的方式来访问。
我们获取认为页表映射是OS所进行的软件行为,其实不是,试想一下,通过页表映射物理内存是进程运行过程中大量、高频执行的操作,如果有OS来进行这一操作,就严重延缓了计算机设备的整机效率。
实际的情况是:OS将页表和虚拟地址信息,以二进制的方式写入到硬件中去,通过虚拟地址 + 页表 映射物理内存的方法,是由硬件来完成的,那么出现越界、野指针等问题,也理应属于硬件异常。
我们可以按照以下方式理解野指针、越界访问的问题:
出现野指针、越界访问等问题在Linux中属于段错误,OS会向进程发送11号SIGSEGV信号。
代码3.7模拟了野指针问题产生的硬件异常行为,代码运行结果与3.6一致,都是不间断执行信号处理函数signal_handler,因为OS在对硬件寄存器中的信息进行轮巡检测。
代码3.7:野指针问题引发硬件异常
#include
#include
#include
void handler_signal(int sig)
{
std::cout << "recieve a signal:" << sig << std::endl;
sleep(1);
}
int main()
{
signal(SIGSEGV, handler_signal);
int* pa = nullptr;
*pa = 10;
while(true) {}
return 0;
}
通过man 7 signal,查看每种信号对应的默认处理方法,如果为Core,那么默认会产生Core文件,OS会将当前进程内存中的核心数据全部保存到Core文件中,Core文件是用来调试的。
如:3号SIGQUIT信号、8号SIGFPE信号、11号SIGSEGV信号,默认触发响应都会产生Core文件,而2号SIGINT、9号SIGKILL信号,默认不会产生Core文件。
一般而言,生产环境、云服务器的core dump功能都是关闭的,如果想要在响应信号的时候产生core文件,还必须保证core dump功能打开。
通过wait/waitpid函数,可以实现父进程对子进程的等待,wait/waitpid函数都可以接收int*类型的输出型参数,用于记录子进程的退出状态,我们称之为int status,status在进程正常退出和异常退出的状态下,如图4.2所示根据退出状态和status比特位的划分,对应子进程退出状态。
代码4.1创建进行死循环的子进程,并且waitpid阻塞等待子进程退出,输出信号编号和core dump标记位,在代码运行起来后,通过在命令行中向子进程发送3号信号(kill -3 [pid]),强制子进程终止并在进程终止后通过ll指令查看当前路径下的文件及属性,可以看到生成的core文件。
代码4.1:子进程信号编号和core dump标志的获取
#include
#include
#include
#include
int main()
{
// 1. 创建子进程
pid_t id = fork();
if(id < 0)
{
perror("fork");
exit(1);
}
else if(id == 0)
{
// 2. 子进程代码
while(true)
{
std::cout << "[Child process]# pid:" << getpid() << ", getppid:" << getppid() << std::endl;
sleep(1);
}
exit(0);
}
// 3. 父进程阻塞等待子进程,并获取退出状态
int status = 0;
waitpid(id, &status, 0);
//4. 检查子进程退出状态
if(WIFEXITED(status))
{
std::cout << "子进程正常终止" << std::endl;
std::cout << "exit code: " << WEXITSTATUS(status) << std::endl;
}
else
{
std::cout << "子进程被信号所杀" << std::endl;
std::cout << "signal:" << (status & 0x7F) << ", core dump:" << ((status >> 7) & 1) << std::endl;
}
return 0;
}
Linux下的gcc/g++编译器默认采用Release版本发布可执行程序,若要对代码进行调试,就需要Debug版本的可执行程序,这就需要在使用gcc/g++编译源文件的时候添加-g选项。
代码4.2人为制造除0错误,这样就会有8号信号发给进程,8号信号默认会产生core文件。编译代码,生成Debug版本的可执行程序。使用gdb启动调试之后,在命令行中输入指令core-file [core文件名],就会显示出出现异常的行数,以及其他的错误信息。
gdb指令:core-file [core文件名] -- 引入core文件调试代码,定位出错位置,查看错误信息
代码4.2:出现异常并生成Core文件的测试代码
#include
#include
int main()
{
int a = 10;
int b = a/0;
sleep(1);
std::cout << "run here" << std::endl;
return 0;
}