Linux 信号(四):信号捕捉、可重入函数、volatile与 SIGCHLD 原理
在前面的三篇文章中,我们已经依次介绍了:
- 信号的概念
- 信号的产生
- 信号的保存(block / pending / sigset_t)
到这里我们已经知道,信号从产生到递达之间,会被进程暂时保存起来,并不会立即被处理,而是在“合适的时候”处理。
那么这个“合适的时候”究竟是在什么时候?
先给出结论:这个“合适的时候”并不是由进程自己决定的,而是由内核决定的。
本文将带你从内核执行角度,理解信号处理到底发生在什么时候,以及内核是如何完成这一过程的。
1 信号捕捉
1.1 信号捕捉的概念
信号到达进程后,其处理方式主要有三种:
- 默认处理(Default)
- 忽略处理(Ignore)
- 自定义处理(Catch)
其中,默认处理和忽略处理都是系统预定义行为,而自定义处理则是由用户通过注册信号处理函数来完成的。
在Linux中,当进程为某个信号设置了自定义处理函数时,这种行为称为信号捕捉(signal catching)。
信号捕捉的本质是修改该信号的默认处理动作。
1.2 信号捕捉的流程

如果信号的处理动作是默认处理,那么执行完第 4 步的时候,会继续在内核态执行该信号的默认处理方法,然后恢复被打断的用户上下午,返回用户态继续执行被打断的位置。
如果信号的处理动作是忽略,那么执行完第 4 步的时候,会直接恢复被打断的用户上下午,返回用户态继续执行被打断的位置。
至此,我们能够理解 “合适的时候” 是指内核态返回用户态的时候。
那么新的问题接踵而至,我们的进程是如何陷入内核态呢?
这个问题将在操作系统是怎么运行的小节中为你揭晓。
信号捕捉流程的简化理解

由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
• 用户程序注册了SIGQUIT 信号的处理函数sighandler 。
• 当前正在执行main 函数,这时发生中断或异常切换到内核态。
• 在中断处理完毕后要返回用户态的main 函数之前检查到有信号SIGQUIT 递达。
• 内核决定返回用户态后不是恢复main 函数的上下文继续执行,而是执行sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
• sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
• 如果没有新的信号要递达,这次再返回用户态就是恢复main 函数的上下文继续执行了。
一张简略图描述信号捕捉的全流程

1.3 操作系统是怎么运行的
1.3.1 硬件中断

这张图描述的是:外设触发中断后,CPU 中断进程运行,进入内核处理中断,最终回到用户态继续运行的全过程。
让我们逐层分析一下这张图
外设层(左边)
外设设备 + 中断控制器
外设在发生事件时,会通过中断控制器向 CPU 发送中断请求。
该过程发生在硬件层面:外设通过中断线或 MSI/MSI-X 机制向中断控制器发送中断请求。中断控制器对中断进行仲裁与编号,生成对应的中断号,并通知 CPU 进入中断响应流程。
例如:键盘按键、网卡接收数据、定时器到期等事件,都会触发相应的硬件中断。
内核层(右边)
中断控制器通知 CPU 之后,CPU 依次做三件事情。
- 保存寄存器现场(图中 CPU寄存器)
- 暂停用户态程序
- 切换到内核态
CPU 不再继续执行用户态的程序,而是被强制打断进入内核处理流程。
CPU 切换到内核态之后,CPU 获取中断控制器的中断号,根据中断号在中断向量表中找对应的中断处理函数入口,执行中断处理函数,内核处理完事件后,恢复用户态程序上下午数据,切换到用户态继续执行。
补充:
中断向量表就是操作系统内置的,启动操作系统的时候就加载到内存了。
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。
由外部设备触发,中断系统运行流程被称作硬件中断。
由此可以引出,信号实现思想:硬件中断。本质是用软件来模拟硬件中断的。
发中断 ---- 发信号
保存中断号 ---- 保存信号
处理中断 ---- 处理信号
思考:
当没有中断到来的时候,操作系统在做什么?
操作系统什么也没有做,一直处于暂停状态,等待中断的唤醒。
操作系统的本质就是基于中断进行运行的软件。
1.3.2 时钟中断
基于对硬件中断的认识,我们该如何理解时钟中断呢?让我们迈入本小节的内容。
在进程调度的章节中,我们知道操作系统是基于时间片来完成进程调度的。
那问题来了:
进程可以在操作系统的指挥下,被调度、被执行,而操作系统本身“并不会主动运行”,那么操作系统是如何“定期被唤醒”的呢?
外部设备确实可以触发硬件中断,但这类中断依赖外部事件驱动,例如键盘输入、磁盘IO等,它们的发生时间是不确定的。
那么问题进一步升级:
有没有一种不依赖外部设备、可以周期性触发的中断源?
在早期的计算机体系结构中,时钟中断是由独立的定时硬件外设实现的,例如可编程定时器(PIT)。
这种设备的特点是:
- 由晶振提供稳定频率
- 可以配置“每隔固定时间产生一次中断”
- 通过中断控制器向 CPU 发送中断请求
也就是说:
时钟中断本质上也是一种“外设中断”,只是它是周期性主动触发的外设。
随着 CPU 体系的发展,现代处理器已经将定时器功能直接集成到 CPU 内部,例如:
- Local APIC Timer(x86)
- ARM Generic Timer
此时:
- 不再依赖外部芯片
- 时钟源直接由 CPU 内部寄存器和计数器实现
- 到达时间后直接在 CPU 内部触发中断信号
因此可以理解为:
时钟中断已经从“外设驱动的中断”,演化为“CPU 内部自带的周期性中断源”
时钟中断本质上仍然属于硬件中断,只不过它从“外部定时器设备”演化为“CPU内部集成的定时中断机制”。
正是依赖时钟中断,操作系统才能周期性获得执行机会,从而实现时间片轮转调度机制。
1.3.3 死循环
操作系统的本质就是一个死循环,基于中断运行的软件,当中断到来,根据中断向量表查询中断处理方法进行执行。
如果需要添加什么功能,只需要向中断向量表中添加对应中断号的方法即可。
void main(void) /* 这里确实是void,并没错。 */
{
/* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到一个信号才会返
* 回就绪运行态,但任务0(task0)是唯一的意外情况(参见'schedule()'),因为任
* 务0 在任何空闲时间里都会被激活(当没有其它任务在运行时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运行,如果没
* 有的话我们就回到这里,一直循环执行'pause()'。
*/
for (;;)
pause();
} // end main
1.3.4 软中断
上面我们所讲的中断都是基于硬件设备触发的中断。
有没有可能由于软件原因,也能触发上面的逻辑? 肯定有
操作系统的设计者,为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。

问题:
用户层怎么把系统调用号给操作系统的?
用户态并不会直接“调用内核函数”,而是将系统调用号和参数放入 CPU 规定的寄存器中,然后通过 syscall 指令陷入内核态。CPU 进入内核后,从寄存器中读取系统调用号,并通过系统调用表 sys_call_table 找到对应的内核处理函数执行。
操作系统怎么把返回值给用户?
内核在执行完系统调用函数后,把返回值放入特定寄存器,然后通过 sysret / iretq 返回用户态,用户程序从寄存器中读取返回值。
系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执
行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法。可是为什么我们用的系统调用,从来没有见过什么int 0x80 或者syscall 呢?都是直接调用上层的函数的啊?
那是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了。系统调用号的本质:数组下标
1.3.5 如何理解内核态和用户态
在前面的内容中,我们不断提到“进入内核态”“返回用户态”“在内核中处理信号”等概念。
但这些说法如果没有一个统一的理解框架,很容易变成“机械记忆”。
因此我们有必要先从本质上理解:
用户态与内核态到底是什么?它们的区别是什么?操作系统为什么要这样设计?
对于用户态和内核态,很多人容易误解:
用户态 = 用户程序 内核态 = 内核程序
这样理解是不对的,它们的本质是:
CPU 的两种权限级别
在x86 架构中:
- 用户态:CPL = 3
- 内核态:CPL = 0
用户态做什么?
用户态运行的是:
- 普通应用程序
- 例如 shell / vim / your program
特点:
- 不能访问硬件
- 不能访问内核数据结构
- 不能直接执行特权指令
👉 例如不能:
- 直接读磁盘
- 直接操作网卡
- 直接修改进程调度
用户态的本质是只能使用虚拟地址空间上的[0, 3GB]的虚拟地址,不能使用[3GB, 4GB]的虚拟地址。
内核态做什么?
内核态运行的是:
- 操作系统内核代码
负责:
- 进程调度
- 内存管理
- 文件系统
- 网络协议栈
- 信号处理
👉 本质一句话:
内核态 = 系统资源的唯一管理者
内核态的本质是运行的是虚拟地址空间上的[0, 4GB]的虚拟地址。
为什么必须分层?
如果没有用户态/内核态:
- 任意程序可以直接操作硬件
- 任意程序可以修改内存
- 系统完全不可控
所以操作系统通过 CPU 提供的特权级机制实现:
隔离 + 保护 + 统一管理
总结
用户态与内核态的本质区别,并不是“代码运行在哪”,而是 CPU 是否具备访问系统资源的权限。
操作系统通过特权级隔离,实现了“用户程序不能直接控制硬件,但可以通过内核间接使用系统资源”的结构。
而信号机制、系统调用、中断机制,本质上都是在这套权限体系下完成的不同入口路径。
在前面的内容中,我们为了说明“用户态为什么会进入内核态”,引入了硬件中断、异常、时钟中断以及系统调用等机制,并从操作系统的运行方式角度进行了分析。
这些内容虽然看似分散,但实际上都在回答同一个问题:
进程在什么时机从用户态进入内核态?
然而在信号机制中,这些内容只是“背景条件”,而不是信号本身。
1.4 sigaction
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
功能:更改进程对指定信号的处理动作,使其不调用默认处理,而是调用用户自己写的处理方法
比 signal() 更标志、更强大。
参数说明:
signum:信号编号
act:新的处理方式
sa_handler:信号处理函数
sa_mask:在执行 handler 时,阻塞某些信号 -> 防止在进行信号处理时,由于中断陷入内核,执行其他信号处理方法。
这里不再介绍另外三个参数。
oldact:输出型参数,保存之前的信号处理方式
返回值:成功返回 0, 失败返回 -1
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
基本用法:
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
2 可重入函数与不可重入函数
样例:

main 函数调用 insert 函数向一个链表 head 中插入节点 node1 ,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler 函数, sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2 ,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是 main 函数和 sighandler 先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样, insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入, insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。
可重入函数的特点
一个函数如果满足下面条件,通常就是可重入的:
1. 不使用全局/静态变量
所有状态都来自参数或栈上局部变量
2. 不修改共享资源
比如不写文件、不操作全局结构
3. 不调用不可重入函数
4. 只使用局部变量(栈空间)
对于上面的样例很不合理,但对于可重入函数与不可重入函数,我们在线程中依旧会见到。
先给出结论:可重入函数一定线程安全 不可重入函数可能线程不安全,需要用锁的方式进行保护
3 volatile
3.1 volatile 是什么?
volatile 是 C/C++ 中一个非常容易被误解,但在操作系统/嵌入式/信号处理中很重要的关键字。
volatile 作用:告诉编译器,这个变量可能在“程序之外”被改变,不要优化它的访问。
3.2 为什么需要 volatile
编译器为了优化,会做一件事情:经过编译器检测,把某些变量优化到寄存器中,程序对该变量的访问不是从物理内存中取数据,而是在寄存器中取数据,如果物理内存和寄存器中数据不一致,就会导致程序出现问题。
样例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int flag = 1;
void handler(int signum)
{
flag = 0;
}
int main()
{
signal(SIGINT, handler);
while(flag)
{
printf("##########\n");
sleep(1);
}
return 0;
}
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ gcc sig.c -o sig
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ ./sig
##########
##########
##########
^C[lt@iZ2zebg189x52z3hbfc0t4Z code]$
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ g++ test.cpp -o test -O1
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ ./test
########
########
########
########
^C########
########
优化情况下,按下 Ctrl + C ,2号信号被捕捉,执行自定义动作,修改 flag=0 ,但是 while 条
件依旧满足,进程继续运行!原因:while循环检查的 flag ,并不是物理内存中最新的 flag,而是 CPU 寄存器存放的过期 flag。
解决办法:volatile int flag = 1;
意思:每次使用 flag,都必须从内存中重新获取。
3.3 volatile 不能解决的问题
不能保证原子性
不能解决并发竞争
不能替代锁
不能保证指令顺序
本质是因为 volatile 只是禁止编译器优化,不是解决并发问题。
4 SIGCHLD 信号
在进程控制章节中,我们讲过用 wait 和 waitpid 函数回收子进程,其中父进程可以阻塞等待子进程结束,也可以非阻塞轮询查看是否有子进程结束等待回收。采用第一种方式,父进程阻塞等待就不能处理自己的工作了,采用第二种方式,父进程在处理自己的工作的同时还要时不时地轮询一下,程序实现相当复杂。
其实子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。
#include <stdio.h>
#include <sys/wait.h>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
while (waitpid(-1, nullptr, WNOHANG) > 0)
{
// 回收所有已退出子进程
}
}
int main()
{
signal(SIGCHLD, handler);
if (fork() == 0)
{
sleep(2);
_exit(0);
}
while (1)
{
sleep(1);
}
}
事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将SIGCHLD 的处理动作置为 SIG_IGN ,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
int main()
{
// 关键点:忽略 SIGCHLD
struct sigaction sa;
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGCHLD, &sa, nullptr);
pid_t pid = fork();
if (pid == 0)
{
// 子进程
std::cout << "child running: " << getpid() << std::endl;
sleep(1);
std::cout << "child exit\n";
_exit(0);
}
else if (pid > 0)
{
std::cout << "parent: " << getpid() << std::endl;
// 父进程不 wait,直接睡很久
sleep(10);
std::cout << "parent exit\n";
}
return 0;
}
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)