【Linux系统编程】进程信号
目录
一,信号的概念
1,查看信号
2,处理信号
二,信号的产生
1,通过终端按键产生信号
2,前台进程和后台进程:
3,使用系统命令向线程发送信号
4,使用函数产生信号
kill系统调用
raise系统调用
abort函数
5,硬件异常产生信号
6,由软件条件产生信号
三,信号的保存
1,信号及其他相关概念补充:
2,在内核中的表示
3,sigset_t
4,信号集操作函数
5,sigprocmask
6,sigpending
四,信号的捕捉
1,信号的捕捉流程
2,操作系统是如何运行的
硬件中断
时钟中断
死循环
软中断
缺页中断&&内存碎片处理&&除零野指针错误
五,用户态与内核态的切换
一,信号的概念
信号是进程之间事件异步通知的一种方式,属于软中断。
1,查看信号
查看信号的命令:kill -l
信号的本质就是一个数字,数字后面的内容表示定义出来的宏。
前31个信号表示普通信号,其他信号表示实时信号。当进程收到一个普通信号,可以不着急处理,之后再处理。而当收到一个实时信号,就必须立即处理。
这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
可以看出大多数信号都是用来终止进程的(Core Or Term)
2,处理信号
当一个进程收到信号后,有三种处理动作:默认处理动作,自定义信号处理动作,忽略处理。
需要通过系统调用来修改信号的处理动作:signal
我们可以自己定义某个信号的处理函数,然后调用该函数,就可以修改该信号的默认处理动作,转而来执行我们的函数。当然也可以设置成忽略处理。
执行该信号的默认处理动作的代码示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{std::cout << "我是进程: " << getpid() << std::endl;signal(SIGINT/*2*/, SIG_DFL);//默认处理动作while(true){std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);
}
提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为自定义捕捉(Catch)一个信号。代码示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{std::cout << "我是进程: " << getpid() << std::endl;signal(SIGINT/*2*/, handler);//收到2号信号后,执行handler这个方法while(true){std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);
}
忽略此信号,代码示例:
#include <iostream>
1
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{std::cout << "我是进程: " << getpid() << std::endl;signal(SIGINT/*2*/, SIG_IGN); // 设置忽略信号的宏while(true){std::cout << "I am a process, I am waiting signal!" << std::endl;sleep(1);
}
一个进程收到信号大致分为如下过程:
二,信号的产生
1,通过终端按键产生信号
Ctrl+C (SIGINT) ,终止信号,向对应进程发送SIGNI信号。
Ctrl+\(SIGQUIT)可以发送终止信号并生成core dump文件,用于事后调试(gdb调试)。
Ctrl+Z(SIGTSTP)可以发送终止信号,将当前前台进程挂起到后台。
2,前台进程和后台进程:
前台进程(Foreground Process):
-
控制终端:前台进程在启动它的终端上运行,并且控制该终端。
-
输入/输出:前台进程可以从终端接收输入(如键盘输入)并将输出(如标准输出和标准错误)显示在终端上。
-
交互性:用户可以与前台进程直接交互。
-
信号:前台进程会接收来自终端的信号,例如当用户按下
Ctrl+C
(发送SIGINT信号)或Ctrl+Z
(发送SIGTSTP信号)时。 -
阻塞:在终端中,同一时间只能有一个前台进程。也就是说,在启动一个前台进程后,终端会被该进程占据,直到它结束或挂起,用户才能再次输入命令。
后台进程(Background Process):
-
运行方式:后台进程在后台运行,不会占据终端。
-
输入/输出:默认情况下,后台进程仍然会将其输出显示在启动它的终端上。但为了避免干扰,通常会将输出重定向到文件或/dev/null。
-
交互性:用户无法直接与后台进程交互(例如,无法通过键盘输入数据给后台进程)。
-
信号:后台进程不会接收来自终端的键盘信号(如
Ctrl+C
和Ctrl+Z
)。但是,用户可以使用kill
命令发送信号给后台进程。 -
终端断开:如果终端关闭(比如用户退出登录),后台进程可能会收到SIGHUP信号而终止(除非使用
nohup
命令启动,或者使用disown
命令,或者设置忽略SIGHUP信号)。
相关命令:
前台进程启动方式:./XXX
后台进程启动方式:./XXX &
查看作业列表(后台进程)命令:jobs
将某个后台进程提到前台命令:fg 作业号
挂起当前的前台进程:CTRL+Z
将挂起的作业提到后台继续运行:bg 作业号
示例:
# 启动后台进程
$ sleep 300 &
[1] 12345# 启动另一个后台进程
$ sleep 200 &
[2] 12346# 查看作业列表
$ jobs -l
[1]- 12345 Running sleep 300 &
[2]+ 12346 Running sleep 200 &# 将作业1调到前台
$ fg %1
sleep 300 # 现在占用终端# 挂起当前前台进程
^Z # 按 Ctrl+Z
[1]+ Stopped sleep 300# 将挂起的作业调到后台继续运行
$ bg %1
[1]+ sleep 300 &
3,使用系统命令向线程发送信号
Kill+信号数字+进程id
4,使用函数产生信号
kill系统调用
kill 命令是调用 kill 函数实现的。 kill 函数可以给一个指定的进程发送指定的信号。
NAMEkill - send signal to a process
SYNOPSIS#include <sys/types.h>#include <signal.h>int kill(pid_t pid, int sig);RETURN VALUEOn success (at least one signal was sent), zero is returned. On error,-1 is returned, and errno is set appropriately.
样例:实现自己的kill命令
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>// mykill -signumber pid
int main(int argc, char *argv[])
{if(argc != 3){std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;return 1;}int number = std::stoi(argv[1]+1);pid_t pid = std::stoi(argv[2]);int n = kill(pid, number);return n;
}
raise系统调用
raise 函数可以给当前进程发送指定的信号(自己给自己发信号)。
NAMEraise - send a signal to the caller
SYNOPSIS#include <signal.h>int raise(int sig);
RETURN VALUEraise() returns 0 on success, and nonzero for failure.
样例:
#include <iostream>
#include <unistd.h>
#include <signal.h>void handler(int signumber)
{// 整个代码就只有这⼀处打印std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{signal(2, handler);// 先对2号信号进行捕捉// 每隔1S,自己给自己发送2号信号while(true){sleep(1);raise(2);}
}
abort函数
abort 函数使当前进程接收到信号而异常终止,这个信号是6号信号。如果对6号信号进行了自定义处理,这个函数会先调用这个自定义处理,然后将这个进程给终止掉。所以,无论如何,这个进程最后是会被终止掉的。
NAMEabort - cause abnormal process termination
SYNOPSIS#include <stdlib.h>void abort(void);
RETURN VALUEThe abort() function never returns.
// 就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。
样例:
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{// 整个代码就只有这⼀处打印std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{signal(SIGABRT, handler);while(true){sleep(1);abort();}
}
5,硬件异常产生信号
硬件异常被硬件以某种方式检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令, CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
所以我们在C/C++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。
core dump
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump。
首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。
⼀个进程允许产生多大的 core文件取决于进程的 Resource Limit (这个信息保存在PCB中)。默认是不允许产生core文件的,因为 core文件中可能包含用户密码等敏感信息,不安全。
在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。如允许core文件最大为1024K: $ ulimit -c 1024
6,由软件条件产生信号
一个例子:在使用匿名管道进行通信的时候,一个进程读,一个进程写。读端的进程一退出,如果写端进程还要写,而该管道没有读端了,此时操作系统就会像该进程发送一个SIGPIPE(13)的信号,将该进程终止掉。
alarm函数和SIGALRM信号
NAMEalarm - set an alarm clock for delivery of a signal
SYNOPSIS#include <unistd.h>unsigned int alarm(unsigned int seconds);
RETURN VALUEalarm() returns the number of seconds remaining until any previouslyscheduled alarm was due to be delivered, or zero if there was no previ‐ously scheduled alarm.
调用alarm函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发SIGALRM 信号,该信号的默认处理动作是终止当前进程。闹钟会响一次,默认终止进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
如何理解软件条件?
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生。
三,信号的保存
某个进程收到信号,本质是操作系统将信号写入到进程的task_struct中,task_struct中有一个位图结构(pending表),就用来保存收到的信号,对应的比特位为1表示收到信号。
1,信号及其他相关概念补充:
-
实际执行信号的处理动作称为信号递达(Delivery)。根据前面的介绍,可以得出有三种递达方式,默认,自定义和忽略。
-
信号从产生到递达之间的状态,称为信号未决(Pending)。此时信号保存在task_struct中,还未处理 。
-
进程可以选择阻塞 /屏蔽(Block)某个信号。在进程的task_struct中还有一个位图,表示阻塞某个信号,如果某个信号对应比特位为1,如果收到该信号,就会屏蔽该信号,不对该信号进行递达,也就是不处理。
-
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
-
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2,在内核中的表示
其中block和pending是两张位图,block表示信号是否被阻塞/屏蔽了,而pending中保存了进程收到并且还没处理的信号。
而handler是一个函数指针数组,将内部表示信号对应的处理方法,默认,自定义或忽略。所以,之前在对信号做自定义处理的时候,我们在用户层自己写了一个处理函数,通过signal系统调用就可以将信号的处理动作更改,本质是更改这个handler表。
-
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块(task_struct)中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
-
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
-
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
-
如果在进程解除对某信号的阻塞之前这种信号产生过多次,POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
3,sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态, 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
4,信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
-
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
-
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
-
注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
-
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
5,sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
//返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:
6,sigpending
#include <signal.h>
int sigpending(sigset_t *set);
//读取当前进程的未决信号集(pending信号集),通过set参数传出。
//调用成功则返回0,出错则返回-1
四,信号的捕捉
当一个进程收到普通信号(1~31)后,不是立即处理,而是等到合适的时候再处理,这个合适的时候就是:当进程从用户态返回到内核态的时候。
1,信号的捕捉流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。
-
当前正在执行main函数,这时发生中断或异常切换到内核态。
-
在中断处理完毕后要返回用户态的main函数之前检查到有信号 SIGQUIT 递达。
-
内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
-
sighandler 函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
-
如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
2,操作系统是如何运行的
操作系统其实就是一个基于中断的软件,其内部一直在进行死循环,当某个外部设备就绪了,就会产生对应的中断号,CPU获取到中断号后,会执行操作系统的中断向量表中对应的方法。
硬件中断
中断向量表就是操作系统的一部分,启动就加载到内存中了。
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。
由外部设备触发的,中断系统运行流程,叫做硬件中断。
时钟中断
进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
如下图所示,除了外部设备可以发起中断,我们还可以使用一个时钟中断源,以特定的频率向CPU发起中断(比如1ns发起一次),当CPU获取到中断号后,就会转而去执行中断向量表中的方法,所以操作系统就在中断的驱动下运行起来,一会进行进程调度,一会进行异常处理。
如果一个进程的时间片耗尽,需要保存进程的上下文,然后切换进程。假设一个进程的时间片为10ns,可以理解为在进程的控制块(task_struct)中有一个变量count=10,触发一次时钟中断的时间间隔为1ns,count--,当触发10次时钟中断后,该进程的count=0,表示时间片耗尽。
死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统⾃⼰不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质:就是一个死循环!
for (;;)pause();
这样,操作系统,就可以在硬件时钟的推动下,自动调度了。
软中断
上述外部硬件中断,需要硬件设备触发。
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 0x80或者 syscall),可以让CPU内部触发中断逻辑。
如下图,操作系统中,将所有的系统调用使用一个数组来维护起来,每个系统调用的下标就对应它的系统调用号。
当我们在代码中使用一个系统调用接口open,其内部核心会做两件事,首先会使用一个寄存器来保存这个系统调用好,move eax 5,将5这个数字保存在eax寄存器中。然后再执行int 0x80(64位下执行syscall),CPU执行到这个指令的时候,就会触发中断(软中断),然后在中断向量表中执行对应的方法,这个方法内部也会做两件事,首先使用一个变量n来获取eax寄存器的值,这样就可以获取到n=5。然后执行对应的系统调用sys_call_table[n]()。这是一个系统调用真正的执行过程。
但是我们在linux下使用的系统调用,从来没见过int 0x80或syscall,而是直接调用上层函数,那是因为linux的gun C标准库,给我们讲这些操作都封装了。
缺页中断&&内存碎片处理&&除零野指针错误
缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
所以,操作系统就是躺在中断处理例程上的代码块!
CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱(陷入内核)。
CPU内部的软中断,比如除零/野指针等,我们叫做异常。
缺页异常和缺页中断
缺页异常(Page Fault):当程序试图访问的页面不在物理内存中(即该页面仅存在于虚拟内存中)时,就会发生缺页异常。这是一种内存访问错误状态,表示所需页面未在物理内存中,需要从磁盘加载。
缺页中断(Page Fault Interrupt):当缺页异常发生时,操作系统会触发一个中断信号,通知 CPU 暂停当前程序的执行,转而去处理缺页问题,这个中断信号就是缺页中断。
处理流程:
-
暂停当前进程执行,保存 CPU 现场。
-
操作系统查找该页面在磁盘中的位置。
-
若内存已满,使用置换算法(如 LRU)换出一个页面到磁盘。
-
将所需页面从磁盘加载到物理内存,并更新页表。
-
恢复进程执行,重新执行引发缺页的指令。
五,用户态与内核态的切换
什么情况会导致用户态到内核态切换??
1,系统调用 :用户态进程主动切换到内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作,例如 fork()就是⼀个创建新进程的系统调用。
操作系统提供了中断指令int 0x80来主动进入内核,这是用户程序发起的调用访问内核代码的唯一方式。调用系统函数时会通过内联汇编代码插入int 0x80的中断指令,内核接收到int0x80中断后,查询中断处理函数地址,随后进人系统调用。
2,异常 :当 CPU 在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。
3,中断 :当 CPU 在执行用户态的进程时,外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下⼀条即将要执行的指令,转到与中断信号对应的处理程序去执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。
切换流程:
-
从用户态切换到内核态时,首先用户态可以直接读写寄存器,用户态操作CPU,将寄存器的状态保存到对应的内存中,然后调用对应的系统函数,传入对应的用户栈地址和寄存器信息,方便后续内核方法调用完毕后,恢复用户方法执行的现场。
-
从用户态切换到内核态需要提权。
-
提权后,切换内核栈,然后开始执行内核方法。
-
当内核方法执行完毕后,然后利用之前写入的信息来恢复用户栈的执行。
上述流程可以看出用户态切换到内核态的时候,会牵扯到用户态现场信息的保存以及恢复,还要进行一系列的安全检查,还是比较耗费资源的。