Linux —— 信号(4)
- 信号的处理
- 用户态和内核态
- 信号的捕捉
- sigaction
- sa_mask字段
- volatile
- SIGCHLD信号
我们今天接着来看信号:
信号的处理
信号的处理简单一句话就是在内核态处理的。
用户态和内核态
用户态和内核态是操作系统和计组中的概念,我们这里提及一下:
用户态(User Mode)和内核态(Kernel Mode) 是操作系统的两种不同运行级别,它们在访问权限、可执行代码、以及运行环境上有所区别,以确保系统的稳定性和安全性。下面是用户态和内核态的主要区别:
- 访问权限:
- 内核态:在内核态下,进程可以直接访问所有的系统资源,包括内存、I/O设备、系统核心数据等。这是因为内核态具有较高的权限,能够执行特权指令,比如修改内存管理单元(MMU)的设置、直接操作硬件等。
- 用户态:相比之下,用户态下的进程权限较低,只能访问受限的资源,主要是自己的地址空间。用户态进程不能直接执行特权指令,也无法直接访问内核地址空间或硬件资源。
- 执行的代码:
- 内核态:当执行操作系统内核代码时,CPU处于内核态。这包括驱动程序、系统调用服务例程、中断和异常处理程序等。
- 用户态:当执行用户程序的代码时,CPU处于用户态。大多数应用程序,如浏览器、文本编辑器等,都在用户态下运行。
- 切换方式:
- 用户态到内核态:切换通常发生在以下情况:系统调用(用户程序主动请求操作系统服务)、硬件中断(如键盘输入、网络数据到达)、异常(如除零错误、非法内存访问)。这些事件都会导致控制权从用户态转移到内核态,以便操作系统可以处理这些请求或事件。
- 安全性:
- 限制用户态程序的权限有助于保护系统稳定性,防止恶意或错误的用户程序破坏操作系统或其他用户的数据。内核态提供了必要的隔离和保护机制。
- 内存访问:
- 内核态可以访问整个内存空间,包括用户空间和内核空间;而用户态只能访问用户空间的内存,尝试访问内核空间的内存会触发硬件异常,进而可能导致进程被操作系统终止或产生其他错误响应。
通过这种区分,操作系统能够有效地管理资源、保护系统安全并提供稳定的服务环境。
我们不是学过进程地址空间吗?
我们知道,每一个进程都会有自己的进程地址空间:
大家也看到了,我们的进程地址空间被划分成了两个部分,一个是用户空间,一个是内核空间。
一般来说,我们写的东西都是在用户空间上,然后我们会有一张用户页表把我们进程地址空间上的东西映射到相应的物理内存上:
同时,如果我们的代码要访问一些内核的东西,我们内核空间也有自己的页表来映射到相应的内存上:
一般来说,内核的东西是不会变的,所以内核页表一般也只会有一张。
所以,如果我们要访问一些内核的东西,就要把自己切换为内核态然后通过内核页表去访问。
那么对于信号来说是怎么处理的呢?
首先,我们在用户态发现了信号,会先切换为内核态:
如果我们自定义了信号的行为,会回到用户态:
之后会重新回到内核态:
重新回到内核态会调用sigreturn:
上面是一个大概的流程,如果搭建还不是很了解,可以看看这张图片:
用一张图表示的话,整个过程会有四次状态变化:
中间的交点可以进行信号捕捉:
信号的捕捉
sigaction
sigaction函数可以通过修改handle表,定义自己的handle行为:
sigaction
是Unix/Linux系统中用于管理信号的一个函数,它是POSIX标准的一部分,提供了比传统signal
函数更强大和灵活的信号处理机制。sigaction
允许程序注册对特定信号的处理动作,以及配置与信号处理相关的额外选项,如信号掩码(暂时阻塞哪些信号)和是否重新设置信号处理函数为默认行为等。
基本用法如下:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
signum
:要处理的信号的编号,例如SIGINT
(对应Ctrl+C中断)。act
:指向一个struct sigaction
结构体的指针,用于设置新的信号处理行为。这个结构体通常包含信号处理函数的指针(sa_handler
或sa_sigaction
),一个信号掩码(sa_mask
)表示在处理信号时应临时阻塞哪些其他信号,以及其他标志位。oldact
:(可选)指向另一个struct sigaction
结构体的指针,用于保存之前对该信号的处理方式。如果对旧的行为不感兴趣,可以传入NULL
。
struct sigaction
的定义大致如下:
struct sigaction {
void (*sa_handler)(int); // 信号处理函数指针,兼容旧式信号处理
void (*sa_sigaction)(int, siginfo_t *, void *); // 新式信号处理函数指针,提供更多信号信息
sigset_t sa_mask; // 处理信号时应阻塞的信号集合
int sa_flags; // 信号处理的标志,如SA_RESTART, SA_NODEFER等
/* 其他可能的填充字段,取决于具体实现 */
};
使用sigaction
而非signal
的主要优势包括:
- 更细粒度的控制,比如能够控制在处理信号期间哪些其他信号应该被阻塞。
- 支持传递附加信息给信号处理函数,通过
sa_sigaction
和siginfo_t
结构体。- 更可靠,因为它保证了信号处理函数的安装是原子操作,避免了race condition。
- 可以设置信号处理函数是否被重新安装(SA_RESETHAND等标志)。
因此,sigaction
函数常用于需要精确控制信号处理流程的高级编程中。
下面是以使用的例子:
#include <iostream>
#include <signal.h>
#include <unistd.h> // 添加头文件以使用getpid函数
// 自定义信号处理函数
void handle(int signum) {
std::cout << "get a sign: " << signum << std::endl;
}
int main() {
// 定义两个sigaction结构体变量,用于设置新的信号处理行为和保存原来的信号处理行为
struct sigaction act, oact;
// 配置act结构体,设置handle函数为信号2(SIGINT,默认为Ctrl+C)的处理函数
act.sa_handler = handle;
// 使用sigaction系统调用,将信号2的处理方式改为由handle函数处理
// 同时,保存原先的信号处理方式到oact结构体中,尽管在这个示例中并未使用oact
sigaction(SIGINT, &act, &oact);
// 主循环,让进程持续运行并输出PID
while (1) {
std::cout << "process is running, PID: " << getpid() << std::endl;
// 让进程暂停1秒,避免无休止的输出占据终端
sleep(1);
}
}
sa_mask字段
这里要说明一下,如果我们正在处理某个信号,中间再次发送该信号,该信号会被屏蔽:
#include <iostream>
#include <signal.h>
#include <unistd.h> // 添加头文件以使用getpid函数
void PrintOpending(const sigset_t& opending);
// 打印当前待处理信号集的函数
void PrintOpending(const sigset_t& opending) {
for(int i = 1; i <= 31; ++i) { // 遍历常见的信号编号
if(sigismember(&opending, i)) { // 检查该信号是否在待处理集合中
std::cout << "1"; // 是,则输出1
} else {
std::cout << "0"; // 否,则输出0
}
}
std::cout << std::endl; // 换行
}
// 自定义信号处理函数
void handle(int signum) {
sleep(1); // 等待一秒模拟信号处理时间
std::cout << "catch a sign: " << signum << std::endl; // 输出接收到的信号编号
while(true) { // 进入循环持续检查待处理信号
sigset_t opending; // 创建一个信号集用于存放待处理的信号
sigpending(&opending); // 获取当前进程的待处理信号集合
PrintOpending(opending); // 打印当前待处理的信号状态
sleep(1); // 每秒检查一次
}
}
int main() {
std::cout << "prcess is running PID: " << getpid() << std::endl; // 输出当前进程的PID
struct sigaction act, oact; // 定义两个sigaction结构体变量
// 配置act结构体,准备将handle函数设置为SIGINT信号的处理函数
act.sa_handler = handle;
// 使用sigaction系统调用,更改SIGINT信号的处理方式为handle函数
// 同时,原SIGINT的处理方式被保存在oact中,但本例中并不使用这个信息
sigaction(SIGINT, &act, &oact);
// 主循环,让进程持续运行,但实际上由于没有具体执行内容,这里会一直占用CPU
while (true) {
}
}
我们运行这段代码:
此时我们如果再按Ctrl + C:
我们看到第二位已经变成了1,说明2号信号处于未决,说明2号信号已经被屏蔽了。
如果我们在处理2号信号时,不想让3号信号干扰,我们就要利用sa_mask添加另外的信号:
#include <iostream>
#include <signal.h>
#include <unistd.h> // 添加头文件以使用getpid函数
void PrintOpending(const sigset_t& opending);
// 打印当前待处理信号集的函数
void PrintOpending(const sigset_t& opending) {
for(int i = 1; i <= 31; ++i) { // 遍历常见的信号编号
if(sigismember(&opending, i)) { // 检查该信号是否在待处理集合中
std::cout << "1"; // 是,则输出1
} else {
std::cout << "0"; // 否,则输出0
}
}
std::cout << std::endl; // 换行
}
// 自定义信号处理函数
void handle(int signum) {
sleep(1); // 等待一秒模拟信号处理时间
std::cout << "catch a sign: " << signum << std::endl; // 输出接收到的信号编号
while(true) { // 进入循环持续检查待处理信号
sigset_t opending; // 创建一个信号集用于存放待处理的信号
sigpending(&opending); // 获取当前进程的待处理信号集合
PrintOpending(opending); // 打印当前待处理的信号状态
sleep(1); // 每秒检查一次
}
}
int main() {
std::cout << "prcess is running PID: " << getpid() << std::endl; // 输出当前进程的PID
struct sigaction act, oact; // 定义两个sigaction结构体变量
// 配置act结构体,准备将handle函数设置为SIGINT信号的处理函数
act.sa_handler = handle;
// 使用sigaction系统调用,更改SIGINT信号的处理方式为handle函数
// 同时,原SIGINT的处理方式被保存在oact中,但本例中并不使用这个信息
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3); //同时将3号信号加入
sigaction(SIGINT, &act, &oact);
// 主循环,让进程持续运行,但实际上由于没有具体执行内容,这里会一直占用CPU
while (true) {
}
}
此时,3号信号也被加入屏蔽集了。
不过,这种情况不是很常见,大家了解即可。
volatile
该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下:
我们这里了解一下gcc,g++编译时候带的优化选项:
在使用g++
编译C++代码时,可以通过添加优化选项来提高生成的可执行文件的执行效率。这些优化选项能够指导编译器以不同级别对代码进行优化,以减少程序的执行时间或占用的空间。以下是一些常用的g++
编译时优化选项:
- -O1:进行基本的优化,提供了代码大小和执行速度之间的平衡。这是一个比较保守的优化级别,适合于调试和开发阶段。
- -O2:比
-O1
更进一步的优化,通常会提供更好的执行性能,可能会增加代码大小。这是推荐的优化等级,适用于大多数生产环境。- -O3:这是最高的优化级别,提供了最积极的优化,可能会显著提升程序的运行速度,但也可能导致编译时间延长和代码体积增大。适合追求极致性能的应用。
使用示例:
g++ -O2 -DNDEBUG main.cpp -o optimized_program
这条命令编译main.cpp
,使用-O2
进行优化,关闭调试信息(-DNDEBUG
),最终生成名为optimized_program
的可执行文件。
我们这里有这么一段代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
如果我们正常编译,是没啥问题的:
但是如果带上-O2:
程序直接退出,因为编译器对flag做了优化,处理了死循环,如果我们不想让它优化,我们得使用volatile关键字:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
volatile int flag = 0; //带上volatile
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
其实volatile是保证内存可见性。
SIGCHLD信号
如果我们查看信号,会有这么一个信号:
SIGCHLD信号是在类Unix操作系统中,当一个子进程终止或者停止时,操作系统发送给其父进程的一种信号。这个信号的主要用途是通知父进程有关子进程状态的变化,以便父进程可以采取相应的行动,比如收集子进程的退出状态、资源清理等。以下是关于SIGCHLD信号的一些关键点:
- 默认行为:如果不特别设置,SIGCHLD信号的默认处理动作是忽略。这意味着父进程不会自动执行任何操作来响应子进程的终止,这可能导致子进程成为僵尸进程(zombie process)。
- 避免僵尸进程:父进程可以通过注册一个SIGCHLD信号处理函数,并在该函数中调用
wait()
或waitpid()
系统调用来回收子进程的状态信息,从而防止子进程变为僵尸进程。这样做可以让操作系统释放与子进程相关的资源。- 自动重aping(Auto-reaping):如果父进程将SIGCHLD信号的处理设置为
SIG_IGN
(忽略),子进程在终止时会被内核自动清理,而不会生成僵尸进程。这种做法适用于那些不需要关注子进程具体退出状态的场景,例如某些高性能服务器。- 非叠加性:SIGCHLD信号是不可累积的,也就是说,如果有多个子进程相继终止,父进程只会接收到一个SIGCHLD信号,而不是每个子进程一个。因此,在信号处理函数中可能需要使用循环调用
wait()
或waitpid()
来处理所有已终止的子进程。- 信号处理策略:在编写多进程应用程序时,合理处理SIGCHLD信号非常重要,既可以避免资源泄露,又可以确保程序的健壮性。开发者可以根据应用的需求选择合适的处理方式,比如主动等待子进程结束、忽略信号或结合其他机制。
- 并发服务器中的应用:在并发服务器设计中,由于频繁创建和销毁子进程,正确处理SIGCHLD信号尤为重要,以防止系统中积累大量僵尸进程,影响系统性能。
#include<iostream>
#include<unistd.h>
#include<sys/wait.h>
void handler(int signum)
{
std::cout << "catch a sign: "<< signum << std::endl;
}
int main()
{
signal(17,handler);
pid_t id = fork();
if(id == 0)
{
std::cout << "child is running Pid:" << getpid() << std::endl;
sleep(10);
exit(0);
}
int cnt = 5;
while(cnt--)
{
sleep(1);
}
wait(nullptr);
}
综上所述,SIGCHLD信号是管理子进程生命周期的关键机制,理解并正确处理它对于编写高效、稳定的多进程程序至关重要。