Linux进程信号
信号的概念
基本概念
在Linux和其他类Unix操作系统中, 信号(Signal) 是一种用于 进程间通信(Inter-process Communication, IPC) 的方法。信号是一种异步通知机制,可以向进程传递事件或指令,使进程能够在执行过程中处理这些事件,属于软中断。
使用 kill -l
来查看Linux的信号:
1. 信号的定义:
- 信号是内核发送给进程的通知,用来表示某种事件的发生。
- 信号可以由内核、用户或进程自身发出。
2. 几个常见的信号:
- SIGINT (2):中断信号,通常由用户按Ctrl+C键产生,终止前台进程。
- SIGTERM (15):终止信号,默认用于请求进程终止。
- SIGKILL (9):杀死信号,强制终止进程,进程不能捕获或忽略此信号。
- SIGSTOP (19):停止信号,暂停进程执行,进程不能捕获或忽略此信号。
- SIGHUP (1):挂起信号,通常在终端断开时发送给会话首进程。
3. 信号的处理方式:
- 默认处理:进程没有特别处理某信号时,内核将按照默认方式处理该信号,如终止进程或忽略信号。
- 捕获信号:进程可以通过设置信号处理程序来捕获并处理特定信号。例如,进程可以在收到SIGINT信号时执行清理操作而不是直接终止。
- 忽略信号:进程可以选择忽略某些信号,不进行任何处理。
捕获信号
signal函数是C标准库中的一个函数,用于设置信号处理程序。通过这个函数,程序可以定义当接收到特定信号时应该执行的处理程序(handler
)。程序可以定义当接收到特定信号时执行的处理程序。下面是signal函数的基本用法和概念:
#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);
参数
- int sig:指定要捕获和处理的信号编号。例如,SIGINT表示中断信号,SIGTERM表示终止信号。
- void (*func)(int):指向信号处理函数的指针。该函数接受一个整数参数,即信号编号。
返回值
- 成功时,返回之前为该信号设置的处理函数指针。
- 失败时,返回SIG_ERR,并设置errno以指示错误类型。
演示:
上面代码使用了signal
函数来对2 3 4 5 号信号进行捕获,并且执行hander
,我们通过getpid()
获取该进程的pid
,再通过另外一个终端来发送信号,即可验证。
经过上面几个图我们可以看到,当我们向我们的程序发送 2 3 4 信号的时候都进行了捕获处理,并且在我们准备使用 ctr + c
来终止进程的时候也被捕获了,打印^CI get sig 2
,那也就是说2号信号 -> 2) SIGINT
就是 ctr + c
,最后使用9号信号成功杀死进程。
如何理解异步概念:
异步(Asynchronous)是指程序在执行某个任务时,不必等待任务完成就可以继续执行其他任务。一旦任务完成,程序会收到通知或处理结果。
生活中的例子 点餐和用餐:
- 同步:你到餐厅点餐,站在柜台前等着厨师做完你的饭,再拿到桌子上吃。这时你什么都不能做,只能等着。
- 异步:你到餐厅点餐,点完餐后拿一个号,然后坐下继续看书或聊天。厨师在后台做饭,当饭做好后,服务员会叫你的号,这时你再去拿饭吃。在等待饭菜期间,你可以做其他事情。
信号的记录:
上述的前31个信号是普通信号,34-64信号是实时信号。
在Linux系统中,信号通过 位图(bitmap) 来表示和管理。每个进程都有一个信号位图,称为信号集(signal set),用于跟踪进程当前接收到的信号和屏蔽的信号。
- 位图大小:通常有32或64位,每一位对应一个信号(如SIGINT、SIGTERM等)。
- 位图状态:
- 1 表示信号已接收或阻塞。
- 0 表示信号未接收或未阻塞。
示例:
假设有一个32位的位图:
pending signals: 00000000 00000000 00000000 00000010 (第2位表示SIGINT)
blocked signals: 00000000 00000000 00000000 00000100 (第3位表示SIGQUIT)
在这个例子中,pending signals的第2位(从右边数起)为1,表示SIGINT信号已接收但尚未处理;blocked signals的第3位为1,表示SIGQUIT信号被阻塞。
这里先仅为了解大体上的概念,后续将继续解释。
信号处理常见方式概览
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
- 忽略该信号。
在Linux当中,我们可以通过man手册查看各个信号默认的处理动作。
qq@iZ0jlfpoxttp4cv9zev6h2Z:~/bt111/Linux/8_05$ man 7 signal
在Linux系统中,信号的 动作(Action) 描述了当进程接收到特定信号时,默认会执行的操作。以下是表中一些信号的默认动作解释:
1. Core
- 生成核心转储文件:当进程收到这种信号时,会终止并生成一个核心转储文件(core dump)。核心转储文件包含进程的内存映像,可以用于调试和分析程序崩溃的原因。
- SIGABRT:由
abort()
函数产生,用于中止进程。 - SIGBUS:总线错误,通常由于错误的内存访问引起。
- SIGFPE:浮点运算异常,如除零错误。
- SIGABRT:由
2. Term
- 终止进程:当进程收到这种信号时,会立即终止。
- SIGALRM:由
alarm()
函数产生,用于定时器到期。 - SIGEMT:模拟器陷阱,通常用于一些特定的硬件错误。
- SIGHUP:挂起信号,表示终端挂起或控制进程死亡。
- SIGALRM:由
3. Ign
- 忽略信号:当进程收到这种信号时,会被忽略,不会进行任何操作。
- SIGCHLD:子进程停止或终止时发送给父进程。
- SIGCLD:SIGCHLD的同义词,作用相同。
4. Cont
- 继续进程:当进程收到这种信号时,如果进程处于停止状态,将继续运行。
- SIGCONT:继续执行已停止的进程。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void handle_signal(int signal) {
switch(signal) {
case SIGALRM:
printf("Received SIGALRM\n");
break;
case SIGCHLD:
printf("Received SIGCHLD\n");
break;
case SIGHUP:
printf("Received SIGHUP\n");
break;
default:
printf("Received signal %d\n", signal);
}
}
int main() {
// 设置信号处理程序
signal(SIGALRM, handle_signal);
signal(SIGCHLD, handle_signal);
signal(SIGHUP, handle_signal);
printf("my pid -> %d",getpid());
// 设置定时器
alarm(2);
// 等待信号
while (1) {
printf("Running...\n");
sleep(1);
}
return 0;
}
产生信号
通过终端按键产生信号
终端输入:用户在终端中使用特定的键盘组合可以产生信号。
-
Ctrl+C:产生SIGINT信号,用于中断前台进程。
-
Ctrl+Z:产生SIGTSTP信号,用于暂停前台进程。
-
Ctrl+\ :产生SIGQUIT 信号,生成核心转储文件并终止进程。
这里的Core默认操作是终止进程并生成核心转储文件。
核心转储文件(Core Dump)
核心转储文件(core dump) 是操作系统在程序崩溃时生成的一种文件。它包含了程序在崩溃时的内存内容、寄存器状态和其它相关信息。核心转储文件用于调试和分析程序的崩溃原因。
核心转储文件的作用 :
- 调试:开发人员可以使用核心转储文件查看程序崩溃时的内存状态、堆栈信息、寄存器内容等,以便找出导致崩溃的原因。
- 错误分析:通过分析核心转储文件,可以发现程序中的错误和漏洞,并进行修复。
如何生成核心转储文件
核心转储文件通常在程序接收到某些信号时生成,如SIGSEGV(段错误)、SIGABRT(由abort()函数产生)、SIGQUIT(由Ctrl+\产生)等。
我本人的系统是
Linux内核版本是:
然而在云服务器上核心转储是默认被关掉的,我们可以通过使用ulimit -a
命令查看当前资源限制的设定。
我们可以通过ulimit -c size
命令来设置core文件的大小。
我们使用下面的 /0
代码来测试
编译后运行:
因为读到了/0
的代码,报错Floating point exception
即触发了SIGFPE
信号,所以生成了core文件。
使用gdb对当前可执行程序进行调试,然后直接使用core-file core
文件命令加载core文件,即可判断出该程序在终止时收到了SIGFPE
号信号,并且定位到了产生该错误的具体代码。
这种查找错误的方式叫做事后调试,生成的core文件是协助我们进行debug的文件!
那为什么云服务器一般都默认关闭核心转储的呢?
因为在其他的Linux系统下,每次出现问题导致程序崩溃时,每次都会生成核心转储(core
dump)文件,如上图,核心转储文件通常非常大,尤其是对于内存使用量大的应用程序。频繁生成核心转储文件会迅速消耗大量磁盘空间,可能导致服务器存储资源耗尽。所以才会默认关闭。
core dump标志
pid_t waitpid(pid_t pid, int *status, int options);
waitpid函数的第二个参数是一个输出线参数,即status
,我们之前通过对status的解读可以得到程序的的相关信息。
若进程是正常终止的,那么status
的次低8位就表示进程的退出状态,即退出码。
若进程是被信号所杀,那么status
的低7位表示终止信号,而第8位比特位是core dump标志,即进程终止时是否进行了核心转储。
所以我们可以通过通过创建进程,让子进程对野指针进行写入以被系统终止并且进行核心转储,同时使用父进程来等待子进程,并且打印出第8位的core dump
标志。
可以看到程序被11号进程终止,并且core dump被设置为1,并且生成了核心转储文件。
因此,core dump标志实际上就是用于表示程序崩溃的时候是否进行了核心转储。
调用系统函数向进程发信号
kill函数
这个系统调用函数可以给指定的进程发送信号。
演示:
我们首先启动一个死循环进程,然后通过另外一个窗口来对使用kill函数来终止死循环进程。
通过kill函数向指定进程发送信号,终止进程。
当然我们也可以直接使用kill命令来发送信号以终止进程:
raise函数
raise
函数向调用者发送信号,就相当于kill
函数的第一个参数设置为getpid()
,其余使用基本一致。
abort函数
abort
函数可以给当前进程发送SIGABRT
信号,使得当前进程异常终止。
SIGABRT
信号被捕捉后依然会异常终止调用程序。
信号虽然被捕捉了,但是该信号依旧异常终止了进程。
由软件条件产生信号
SIGPIPE信号
由之前学过的管道知识可以知道,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
SIGPIPE信号实际上就是一种由软件条件产生的信号。
下面这个代码示范了使用fork创建子进程,让子进程来写,父进程读,但是父进程直接关闭读端:
可以看到进程接受到了软件条件产生的信号SIGPIPE,从而被系统终止。
SIGALRM信号
alarm
函数是一个倒计时定时器, 参数是一个seconds。
使用alarm
函数可以给调用的进程发送SIGALRM
信号,该信号会默认终止进程。
下面这个代码我们传入3s,即自从alarm函数被调用,经过3s后该函数向进程发送SIGALRM
信号。
验证IO是很慢的
我们首先设定一个1s的闹钟,然后在main中定义变量cnt
,然后再死循环里面打印cnt
并且每次都++
可以看到结果为cnt增加到了22739.
那如果吧cnt
改为全局变量,并且在main函数中只继续++
,而在捕获SIGALRM
的handler
中进行打印cnt呢?
可以看到结果为cnt增加到了588025787。
为什么有这么大差距?
因为第一段代码中,有向显示器打印的动作,而显示器是一个外设,所以第一段代码有大量的IO操作,而第二段就只有cnt++,这是一个内存级别的++动作。
而且我自己当前使用的时云服务器,计算都是在云服务器上计算的,而向本地打印是跨网络的,所以第一个代码比第二个代码的cnt小,并且验证了IO很慢!
为什么台式电脑在关闭电源后,下次启动电脑还能保持正确的时间呢?
台式电脑在关闭电源后还能保持正确时间的原因是主板上有一个小的纽扣电池(通常是CR2032电池),这个电池为主板上的实时时钟(RTC)和CMOS存储器供电。
- 实时时钟(RTC):
主板上有一个RTC芯片,它负责保持系统时间,即使在电脑断电的情况下。RTC芯 片通过电池供电,因此可以在断电的情况下继续运行,并保持正确的时间。- CMOS存储器:
CMOS存储器用于存储BIOS设置,包括系统时间、硬件配置和启动顺序等。CMOS
储器也是由这颗纽扣电池供电的,因此即使在断电后,这些设置也不会丢失。所以:主板上的纽扣电池为RTC和CMOS存储器提供电力,使得系统时间和BIOS设置在断电的情况下依然能够保持。这就是为什么台式电脑在电源关闭后,重新启动时系统时间仍然是正确的。
理解OS如何管理闹钟
首先时间的格式为XXXX/XX/XX但是通常使用时间戳来保存一个时间,这是一个线性增长的东西。
其次操作系统需要对闹钟(定时器)进行管理,以便在设定的时间点上执行相应的任务。这个管理过程通常涉及使用结构体来描述每个闹钟的属性,然后使用数据结构(例如堆)来高效地管理这些闹钟。
结构体描述闹钟:
- 操作系统使用一个结构体来描述每个闹钟的属性。这个结构体通常包含以下信息:
-超时时间(expired):表示闹钟何时触发。
-进程ID(pid):需要通知的进程ID。
-回调函数(f):当闹钟触发时要执行的函数。
数据结构管理闹钟:
- 为了高效地管理和调度闹钟,操作系统通常使用堆或其他优先级队列数据结构。堆是一种树状数据结构,可以快速找到最小(或最大)元素,因此非常适合用于管理定时任务。
- 在这种堆结构中,堆顶元素总是最早触发的闹钟,这样操作系统可以快速找到下一个需要处理的闹钟。
如何理解alarm的返回值:
下面代码首先传入alarm(5),后再sleep,再alarm(0),通过n的值了解:
alarm(0)为取消闹钟,并且返回上一个闹钟的剩余时间。
alarm每设置一次,就触发一次。
我们再handler里面再进行设置alarm,那也就是说第一次捕捉后在捕捉函数再次设置闹钟,故而再次被捕捉,要是都能够打印出来get sig,即可验证alarm每设置一次,就触发一次。
程序中实现的闹钟机制被称为周期性闹钟(或定期闹钟)。这种闹钟在每次信号处理函数(handler)被调用时,会再次设置闹钟,以便在指定的时间间隔后再次触发。
由硬件异常产生信号
硬件的异常被检测到,并且通知内核,内核会向当前进程发送适当的信号。
除0错误(Division by Zero)
当当前进程执行除以0的指令时,CPU的运算单元会产生异常。执行除法指令时,CPU检测到除数为0,并在状态寄存器中设置相应的标志位。此时,CPU触发异常处理机制,保存当前执行状态并跳转到操作系统内核的异常处理入口。内核读取状态寄存器,确定异常类型为除0,并将其解释为SIGFPE信号。内核查找当前进程的进程控制块(task_struct),向该进程发送SIGFPE信号,并标记到进程的信号位图中。如果进程没有自定义的信号处理程序,默认行为是终止进程并生成核心转储文件。
访问非法内存地址(野指针)
类似地,当当前进程访问非法内存地址时,内存管理单元(MMU)会产生异常。MMU在翻译虚拟地址到物理地址的过程中,检测到地址无效,并在状态寄存器中设置标志位。CPU触发异常处理机制,生成段错误异常,保存执行状态并跳转到内核的异常处理入口。内核读取状态寄存器,确定异常类型为段错误,将其解释为SIGSEGV信号,并向进程发送SIGSEGV信号。如果进程没有自定义信号处理程序,默认行为是终止进程并生成核心转储文件。
阻塞信号
信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery).
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)谋个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的表示
我们在上文已经说过了,在Linux系统中,信号通过位图(bitmap)来表示和管理。每个进程都有一个信号位图,称为信号集(signalset),用于跟踪进程当前接收到的信号和屏蔽的信号。分别是:pending signals
和 blocked signals
。
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
对于 signal(2, handler)
信号的编号,就是数组的下标,可以采用信号编号,索引I信号处理方法!
这里的
handler
是一张函数指针数组 。
两张位图+一张函数指针数组 == 让进程识别信号!
一个信号如果阻塞和和他有没有未决有关系吗?
一个信号的阻塞状态与其是否在未决信号集中 是有关系但独立的。 当一个信号被阻塞时,它不会立即被处理,而是被记录到未决信号集中,等待解除阻塞后再处理。未决信号集反映了信号已经到达但尚未被处理的状态,而不管信号是否被阻塞。即使信号被解除阻塞,如果信号未被发送过,它不会出现在未决信号集中。阻塞信号决定了信号到达时是否立即处理,而未决信号表示信号已到达但尚未处理。两者共同确保信号处理的灵活性和准确性。
sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态.
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞.
- 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
sigset_t是Linux系统提供的一种数据类型,用于表示一组信号。其只能适用于Linux系统。
在上面我们说了信号的发送就是对其位图的修改,但是不建议直接手动修改位图,而是使用系统给定的函数来对信号进行操作。
信号集操作函数
#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。
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参数的可选值。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
#include <signal.h>
sigpending
//读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
下面演示,使用上述函数来对2号信号屏蔽,并且使用kill命令来发送2号信号,2号信号被阻塞,一直pending,如何解除阻塞,捕获2号信号。
void PrintPending(sigset_t &pending)
{
std::cout << "curr process[" << getpid() << "]pending: ";
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << "\n";
}
void handler(int signo)
{
std::cout << signo << " 号信号被递达!!!" << std::endl;
std::cout << "-------------------------------" << std::endl;
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
std::cout << "-------------------------------" << std::endl;
}
int main()
{
// 0. 捕捉2号信号
signal(2, handler); // 自定义捕捉
// signal(2, SIG_IGN); // 忽略一个信号
// signal(2, SIG_DFL); // 信号的默认处理动作
// 1. 屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT); // 我们有没有修改当前进行的内核block表呢???1 0
// 1.1 设置进入进程的Block表中
sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!
int cnt = 15;
while (true)
{
// 2. 获取当前进程的pending信号集
sigset_t pending;
sigpending(&pending);
// 3. 打印pending信号集
PrintPending(pending);
cnt--;
// 4. 解除对2号信号的屏蔽
if (cnt == 0)
{
std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
sigprocmask(SIG_SETMASK, &old_set, &block_set);
}
sleep(1);
}
}
- 接触屏蔽,一般会立即处理并且当前被接触的信号(前提是被pending)
- 在抵达之前,先对于pending位图的对应信号先置0,再执行对应操作。
捕捉信号
内核态和用户态
在Linux系统中,内核态(Kernel Mode)和用户态(User Mode)是两种操作系统运行模式,主要用来区分不同的权限级别和安全级别。它们之间的区别和切换是操作系统设计的重要部分,确保系统的稳定性和安全性。
-
用户态(User Mode)
- 权限:用户态运行的是应用程序,它们运行在受限的权限下。这意味着它们不能直接访问硬件或内核内存空间。
- 特征:用户态程序只能访问自己所属的内存空间,不能直接执行特权指令(如I/O操作、内存管理)。
- 安全性:由于用户态程序在受限的权限下运行,即使出现错误(如非法访问内存),也不会直接影响整个系统的稳定性。
-
内核态(Kernel Mode)
- 权限:内核态运行的是操作系统内核,它拥有最高的权限,可以执行任何指令,访问任何硬件和内存空间。
- 特征:内核态下,操作系统可以管理硬件资源、调度进程、处理系统调用等。
- 安全性:由于内核态拥有最高权限,一旦内核代码出现错误,可能会导致系统崩溃或严重的安全漏洞。
再谈地址空间
每个进程都有自己的task_struct
,其中指向一个struct mm_struct
虚拟地址空间,上图以32位机器为例表示了物理内存和虚拟地址空间的映射关系。
32位机器中低地址[0, 3]G为用户空间,高地址[3, 4]G为内核空间。两者都有其自己的页表与物理内存相对于。
系统中不止一个进程,每个进程都有其自己的虚拟地址空间,每个虚拟地址都有其自己的用户级页表,但是共用同一个内核级页表,其也映射同一个内核空间。
所以:
- 处于用户态的程序只能访问用户级的内存地址,而无法访问内核地址。
- 但是处于内核态的程序可以访问整个地址空间。
内核态和用户态的切换
- 系统调用: 用户态程序通过系统调用进入内核态。例如,用户程序要进行文件操作、网络通信等,需要通过系统调用进入内核态,由内核代为执行。
- 中断和异常: 当发生硬件中断或异常(如除零错误、页面错误)时,CPU会自动切换到内核态,以便操作系统处理这些事件。
- 返回用户态: 内核完成系统调用或中断处理后,通过返回指令将控制权交还给用户态程序。
信号捕捉流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
下面我们通过图来理解一下:
-
用户态进程正在执行正常的程序指令,因为中断、异常或系统调用,CPU从用户态切换到内核态。
-
进入内核态后,内核处理发生的中断或异常。 处理完成后准备将控制权返回用户态,在返回用户态之前,内核调用do_signal()函数,检查并处理当前进程的信号队列。
-
- 如果有待处理的信号,内核会调用相应的信号处理程序。
- 如果该信号有自定义的处理函数(信号处理程序),内核会准备执行这个处理程序。并且内核将控制权转移到用户态,执行信号处理程序,而不是返回到主控制流程。
-
在用户态中,执行用户定义的信号处理程序
sighandler(int)
。信号处理程序执行完毕后,通过调用sigreturn系统调用再次进入内核态。 -
内核态中,处理
sys_sigreturn
系统调用。这个系统调用的作用是恢复被中断时的进程状态,并且核将控制权返回到用户态,从被中断的地方继续执行主控制流程。
为什么要从内核态转换到用户态再去指向handler函数?
信号处理程序需要在用户态执行而不是直接在内核态处理,是因为用户态和内核态的隔离设计保障了系统的安全性和稳定性。内核态具有高权限,直接执行用户态代码可能会引发安全问题。通过将信号处理程序转移到用户态执行,内核可以确保信号处理程序在受限的用户态环境中运行,避免对内核态的干扰和潜在的安全风险。
为什么不能直接从handler回到main呢?而是要先到内核态再回去用户态?
信号处理程序执行完后需要进入内核态,通过 sigreturn 系统调用恢复上下文并确保系统一致性和安全性,然后再返回用户态继续执行主程序。这种机制使得内核能够有效管理进程状态,避免潜在的安全风险和系统不一致问题。
而且sighandler
和main
函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。
信号捕捉函数 sigaction
sigaction
是一个用于信号处理的系统调用,提供了更为强大和灵活的信号处理机制,相比于旧的 signal
函数。它允许程序定义信号处理函数,并指定在信号处理过程中应该采取的具体行为。
sigaction 函数原型:
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
- signum:要处理的信号编号,比如 SIGINT,SIGTERM 等。
- act:指向一个 struct sigaction 结构体,用于指定新的信号处理行为。
- oldact:如果不为 NULL,则保存以前的信号处理行为。
struct sigaction 结构体:
struct sigaction {
void (*sa_handler)(int); // 指向信号处理函数的指针
void (*sa_sigaction)(int, siginfo_t *, void *); // 指向信号处理函数的指针
sigset_t sa_mask; // 在处理该信号时要屏蔽的信号集
int sa_flags; // 控制信号处理行为的标志
void (*sa_restorer)(void); // 已废弃
};
- sa_handler:信号处理函数的指针,可以是
SIG_DFL
(默认处理)、SIG_IGN
(忽略信号) 或自定义处理函数。 - sa_sigaction:用于指定带更多信息的信号处理函数,需将
sa_flags
设为SA_SIGINFO
。 - sa_mask:在处理该信号时要暂时阻塞的信号集。
- sa_flags:控制信号处理行为的标志,例如
SA_RESTART
、SA_NOCLDSTOP
、SA_SIGINFO
等。
使用示例
以下是一个简单的示例程序,使用 sigaction 设置 SIGINT 的处理函数:
// 信号处理函数
void handle_sigint(int sig) {
std::cout << "Caught signal " << sig << std::endl;
}
int main() {
struct sigaction sa;
// 指定信号处理函数
sa.sa_handler = handle_sigint;
// 初始化信号集为空
sigemptyset(&sa.sa_mask);
// 使用默认标志
sa.sa_flags = 0;
// 设置 SIGINT 的信号处理行为
if (sigaction(SIGINT, &sa, nullptr) == -1) {
perror("sigaction");
return EXIT_FAILURE;
}
// 模拟一个长时间运行的进程
while (true) {
std::cout << "Running..." << std::endl;
sleep(1);
}
return 0;
}
可重入函数
- main函数调用insert函数向一个链表head中插入节点node1。
- 插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,
- sighandler也调用insert函数向同一个链表head中插入节点node2,
- 插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,
- 先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。
volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flag由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flag置1才能够正常退出。
int gflag = 0;
void changedata(int signo)
{
std::cout << "get a signo:" << signo << ", change gflag 0->1" << std::endl;
gflag = 1;
}
int main() // 没有任何代码对gflag进行修改!!!
{
signal(2, changedata);
while(!gflag); // while不要其他代码
std::cout << "process quit normal" << std::endl;
}
按下 Ctrl+C 将会触发信号处理程序,改变 gflag 的值,导致 while 循环终止,最终输出 “process quit normal” 并正常退出程序。
再Linux的g++中,gcc中指定优化级别的参数有:-O0、-O1、-O2、-O3、-Og、-Os、-Ofast。
如果使用-O1来编译呢?
当捕获2号信号之后并未修改gflage的值,为什么呢?
因为main函数和handler函数是两个独立的执行流,编译器编译时只能检测到在main函数中对flag变量的使用。
此时编译器检测到在main函数中并没有对flag变量做修改操作,在编译器优化级别较高的时候,就直接吧gflag的值0直接存到了寄存器中,而handler执行流只是将内存中flag的值置为1,因此,会退出进程。
在编译代码时携带 -O1 选项使得编译器的优化级别最高,此时再运行该代码,就算向进程发生2号信号,该进程也不会终止。
所以为了避免这种过度的优化,一般采用关键字volatile即可避免过度优化:
SIGCHLD信号
为了避免僵尸进程,父进程必须使用 wait 或 waitpid 函数处理子进程的结束。父进程可以阻塞等待子进程结束,也可以非阻塞地轮询以检查子进程状态。阻塞等待会使父进程无法处理其他任务,而非阻塞轮询则需不断检查,增加程序复杂度。
实际中,子进程终止时会发送 SIGCHLD 信号给父进程。默认情况下,SIGCHLD 信号被忽略。父进程可以自定义 SIGCHLD 的处理函数,这样父进程专注于自己的任务,信号处理函数中调用 wait 或 waitpid 以清理子进程。
例如,以下代码捕捉 SIGCHLD 信号,并在处理函数中调用 waitpid 来清理子进程。
void notice(int signo)
{
std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
while (true)
{
pid_t rid = waitpid(-1, nullptr, WNOHANG); // 阻塞啦!!--> 非阻塞方式
if (rid > 0)
{
std::cout << "wait child success, rid: " << rid << std::endl;
}
else if (rid < 0)
{
std::cout << "wait child success done " << std::endl;
break;
}
else
{
std::cout << "wait child success done " << std::endl;
break;
}
}
}
void DoOtherThing()
{
std::cout << "DoOtherThing~" << std::endl;
}
int main()
{
signal(SIGCHLD, notice);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "I am child process, pid: " << getpid() << std::endl;
sleep(3);
exit(1);
}
}
// father
while (true)
{
DoOtherThing();
sleep(1);
}
return 0;
}
为什么waitpid(-1, nullptr, WNOHANG)的参数是-1和WNOHANG
- -1 参数让 waitpid 检查所有子进程,而不是指定的一个。
- WNOHANG 标志使 waitpid 非阻塞,允许父进程继续执行其他任务,而不是在等待子进程结束时阻塞。