1.信号的认识
生活中例如闹钟,红绿灯,电话铃声等都属于信号,所白了信号就是中断我们正在做的事情,属于进行事件异步通知机制。
在Linux中信号是发给进程的,信号的产生相较于进程是异步的。
信号的相关知识点:
1.进程在信号没有产生的时候,已经知道了相关信号的处理方式
2.对信号的处理可以延迟
3.进程内置了对于信号识别和处理的方式
4.给进程产生信号的信号源非常多
1.1 如何处理信号
进程受到信号进行的处理方式一共三种(处理的时间可以选择合适的时间):
1.默认处理动作
2.自定义信号处理动作
3.忽略处理
1.2 理解给进程发送信号
信号产生后进程并不一定要立刻处理,所以这里要求进程要把信号记录下来,通过位图记录在struct task_struct,这是属于os内的数据结构对象,修改位图本质是修改内核数据,这是os自己实现的,并且是os自己提供发送信号的系统调用(kill)。
2.信号的种类
在Linux中可用通过指令:kill -l来查看信号的种类
它列出了所有标准信号和实时信号的总范围(标准信号+实时信号)为64种
3.信号的处理过程
sighandler signal(int signum,sighandler_t handler) 是C标准库中用于注册信号处理的函数
//函数原型
#include <signal.h>
typedef void (*sighandler_t)(int); // 信号处理函数的类型定义sighandler_t signal(int signum, sighandler_t handler);
参数:1.signum:要捕获的信号编号(如SIGINT,SIGTERM)
2.handler:信号处理函数指针: (1)SIG_IGN:忽略该信号
(2)SIG_DFL:恢复系统默认处理方式
返回值:
成功时返回旧的信号处理函数指针
失败返回SIG_ERR
核心用途:
捕获Ctrl + C(当用户按下Ctrl+C时,会触发handle_sigint函数)
#include <stdio.h>
#include <signal.h>
#include <unistd.h>void handle_sigint(int sig) {
printf("捕获到信号 %d(SIGINT),退出程序\n", sig);
_exit(0); // 立即终止程序
}int main() {
// 注册信号处理函数
signal(SIGINT, handle_sigint);while (1) {
sleep(1); // 模拟程序运行
}
return 0;
}
4.信号的产生
4.1 前台和后台进程
进程分为前台进程(./XXX)和后台进程(./YYY&),只有一个前台进程,但后台进程有很多。
两者的核心区别:
前台进程能从键盘获取标准输入(键盘只有一个,输入数据一定给一个确定的进程
后台进程无法从标准输入中获取内容
常用操作命令:
1.命令行末尾+&:进程在后台启动
sleep 60 & //启动一个后台任务(休眠60s)
2. 查看后台进程:jobs
jobs -l //显示所有后台进程及作业号
3.前后台切换
前台转后台:
(1)先暂停前台进程:按Ctrl + Z
(2) 再放入后台继续运行:bg%任务号(jobs来查看)
后台转前台:
fg%任务号
4.终止进程:
前台进程:Ctrl +C
后台进程:
kill%1 //通过作业号终止
kill 12345 //通过进程ID终止
4.2 键盘产生信号
键盘可以产生很多信号,以下是常用的信号:
组合键 | 对应信号 | 默认行为 |
ctrl + C | SIGINT | 终止前台进程 |
ctrl + Z | SIGTSTP | 暂停前台进程 |
ctrl +\ | SIGQUIT | 强制终止前台进程并生成核心转储 |
ctrl + D | EOF | 输入流结束 |
4.3系统调用产生信号
系统调用产生信号通过函数kill实现
函数原型:
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int sig);
参数:
(1) pid:目标进程的ID
(2)sig:要发送的信号,如果为0则只检查进程是否存在
返回值:
成功返回0,失败返回-1
核心功能:
1.向进程(组)发送信号
kill(12345,SIGTERM);
2.检查进程是否存在:
if(kill(12345,0)==0)
{
printf("进程12345存在\n");}
常用信号:
信号 | 值 | 作用 |
---|---|---|
SIGTERM | 15 | 请求终止进程(允许优雅退出) |
SIGKILL | 9 | 强制终止进程(不可被处理) |
SIGINT | 2 | 中断进程(如 Ctrl+C 触发) |
SIGSTOP | 19 | 暂停进程(不可被捕获) |
SIGCONT | 18 | 恢复被暂停的进程 |
4.4 硬件异常产生信号
当硬件发生异常的时候os也会产生信号
注意部分信号不可捕获(如SIGKILL和SIGSTOP)
示例:
除以零触发SIGFPE
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>void handler(int sig)
{
printf("捕获到信号 %d (SIGFPE)\n", sig);
exit(1);
}int main()
{
signal(SIGFPE, handler);
int a = 1 / 0; // 触发硬件异常
return 0;
}
运行结果:
捕获到信号
8 (SIGFPE)
4.4.1 系统是如何知道进程有没有犯错
OS通过CPU,寄存器,状态寄存器等硬件来判断进程是否出错,具体过程就不论述了,如有兴趣可自行了解。
4.5 软件条件
软件条件信号是由程序内部逻辑或系统调用触发的信号,场景有:
1.定时器到期(如alarm触发的SIGALRM)
2.进程间通信(如kill触发的SIGTERM)
3.管道或套接字异常(如SIGPIPE)
其中最常见的软件条件产生信号的场景就是定时器到期
alarm函数的原型
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
核心功能
1.定时触发信号:alarm(seconds),经过seconds秒后向进程发送SIGALRM信号
2.覆盖前次定时:如果之前设置了为未触发的alarm,新调用会取消原定时器,并替换为新的seconds时间
alarm(10); // 设置 10 秒定时器
sleep(3); // 等待 3 秒
alarm(5); // 此时取消原定时器(剩余 7 秒),新定时器在 5 秒后触发
3.取消定时器:调用alarm(0)会取消之前设置的定时器,并返回剩余时间
返回值
返回之前定时器的剩余秒数,如果未触发定时器则返回0
(调用alarm(10),5s后调用alarm(3),返回5)
典型用法
1.结合信号处理
配合signal捕获SIGALRM,实现超时机制:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void handler(int sig)
{
printf("Alarm triggered!\n");
}int main()
{
signal(SIGALRM, handler); // 注册信号处理函数
alarm(3); // 3 秒后触发 SIGALRM
pause(); // 挂起进程等待信号(防止3s内进程就已经退出了,alarm还没有触发信号的发送)
return 0;
}
以下是对pause()函数的介绍:
函数原型
#include <unistd.h>
int pause(void);
核心行为
挂起进程:调用pause()后,进程会进入休眠状态,直到收到任意信号
信号触发唤醒:当进程收到未被忽略的信号,会先执行对应的信号,然后返回pause()
返回值:总是返回-1
2.超时控制
设置超时,对阻塞进行超时操作
5.信号的保存
5.1 基本的概念
1.实际执行信号的处理动作称为信号递达(自定义,默认,忽略)(常规信号在递达之前产生多次只计一次)
2.信号从产生到递达之间的状态,称为信号未决(此时信号存储在位图内)
3.进程可以选择阻塞某个信号
4.被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达动作
5.阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
5.2 信号管理的核心机制
进程的task_struct主要分为三张表,Linux提供信号操作一定是围绕这三张表展开
5.2.1 block (阻塞信号掩码)
数据结构:表格形式,标记哪些信号被阻塞
比特位的位置:表示的是第几个信号
比特位的内容:是否阻塞(0表示未被阻塞,1表示信号被阻塞暂存在pending中)
(SIGKILL和SIGSTOP不可阻塞的信号)
5.2.2 pending(待处理信号集)
数据结构:32位无符号整数,每个位对应一个信号
比特位的位置:表示的是第几个信号
比特位的内容:以信号2为例,比特为的内容为1,表示信号SIG_IGN已收到但未处理
作用:记录已发送但未被递送的信号
(当我们准备递达的时候要把pending信号集中对应的信号位图1->0)
5.2.3 handler(信号处理函数表)
数据结构:函数指针数组,每个元素对应一个信号的处理方式
常见值:
SIG_DFL:默认处理
SIG_IGN:忽略信号
用户自定义函数地址:捕获信号并执行自定义逻辑
5.2.4 常用的函数
5.2.4.1 sigprocmask
OS中用于管理信号掩码的核心函数,通过控制信号的阻塞与解除,决定进程是否接受特定的信号
函数原型
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数解析
参数 | 说明 |
how | 操作类型,(具体下面会介绍) |
set | 指向信号集的指针,表示要添加/移除/设置的信号,如果为NULL,则how无效 |
oldset | 输出参数,保存修改前的信号掩码,如果为NULL,不保留旧掩码 |
how的取值:
SIG_BLOCK
:将 set
中的信号加入阻塞列表(原掩码 ∪ set
)
SIG_UNBLOCK
:将 set
中的信号移出阻塞列表(原掩码 - set
)
SIG_SETMASK
:直接设置信号掩码为 set
(覆盖原掩码)
核心功能
1.阻塞信号
临时屏蔽某些信号,使其在解除阻塞前不会递送
sigset_t mask;
sigemptyset(&mask); //用来初始化一个信号集(sigset_t类型),将其设置为空集合
sigaddset(&mask, SIGINT); // 添加 SIGINT 到信号集
sigprocmask(SIG_BLOCK, &mask, NULL); // 阻塞 SIGINT
2.解除阻塞
sigprocmask(SIG_UNBLOCK, &mask, NULL); // 解除阻塞 SIGINT
返回值
成功:返回 0
失败:返回-1
示例代码
#include <signal.h>
#include <stdio.h>
#include <unistd.h>int main()
{
sigset_t new_mask, old_mask;// 初始化信号集:阻塞 SIGINT 和 SIGTERM
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
sigaddset(&new_mask, SIGTERM);// 应用阻塞(保存旧掩码到 old_mask)
sigprocmask(SIG_BLOCK, &new_mask, &old_mask);printf("SIGINT/SIGTERM 已被阻塞,尝试 Ctrl+C 无效\n");
sleep(5); // 模拟关键操作// 恢复原信号掩码
sigprocmask(SIG_SETMASK, &old_mask, NULL);
printf("已解除阻塞,Ctrl+C 可终止进程\n");
pause(); // 等待信号
return 0;
}
5.2.4.2 sigpending
获取当前进程的未决信号集,即已发送但被阻塞的信号
函数原型
#include <signal.h>
int sigpending(sigset_t *set); // set 为输出参数,保存未决信号集
参数:set
为指向 sigset_t
类型的指针,用于返回挂起信号集。
返回值:成功返回 0,失败返回 -1 并设置 errno、
使用场景
1.阻塞信号后检查:阻塞SIGINT后,调用sigpending可检查是否有未处理的SIGINT
2.信号管理:结合sigprocmask和sigpending,可实现自定义信号处理逻辑
示例
sigset_t newmask, oldmask, pendmask;
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);
sigprocmask(SIG_BLOCK, &newmask, &oldmask); // 阻塞 SIGINT// ...(在此期间若收到 SIGINT,会被挂起)
sigpending(&pendmask); // 检查挂起信号
if (sigismember(&pendmask, SIGINT)) {
// 存在挂起的 SIGINT
}sigprocmask(SIG_SETMASK, &oldmask, NULL); // 解除阻塞,处理 SIGINT
5.2.5 Core和Term类信号
类别 | 默认行为 | 典型信号示例 |
Core | 终止进程+生成Core dump文件 | SIGSEGV (段错误)、SIGABRT (主动终止)、SIGQUIT (Ctrl+\) |
Term | 仅终止进程 |
|
Core dump文件:程序崩溃时生成的内存快照文件,记录进程终止时的内存,寄存器,堆栈等状态信息,用于事后调试和分析崩溃原因
6.信号的处理
信号的处理,不是立即处理,而是在合适的时候进行信号的处理
6.1 什么时候是合适的时候
首先先介绍一下信号捕捉的流程:
由上图的可知处理信号的过程分为两个状态,用户态(User Mode)和 内核态(Kernel Mode)
所谓合适的时候就是进程从内核态返回到用户态的时候,在这个过程中包含着信号检查步骤(检查是否有待处理的信号需要递送给进程)
以下是信号捕捉过程中的关键环节:
步骤切换 | 隐含的最关键环节 |
---|---|
1→2 | 保存上下文(保存进程的寄存器状态)、信号队列检查(遍历pending,检查是否有未阻塞需要递送的信号)、处理方式决策(默认和忽略直接在内核中处理,标记) |
2→3 | 构建用户态堆栈帧(插入信号处理函数参数和返回地址)、权限降级(CPU从内核态到用户态) |
3→4 | 触发sigreturn(重新进入内核态) 、恢复原上下文(恢复原用户态程序寄存器状态)、清理堆栈 |
4→5 | 载入原寄存器状态(让保存的寄存器状态载入CPU,准备恢复执行)、解除信号阻塞(将已处理的信号从pending中移除) |
下面是流程中状态的切换:
步骤 | 操作 | 权限切换方向 |
---|---|---|
1→2 | 主流程因系统调用/中断进入内核 | 用户态→内核态(升级) |
2→3 | 内核递送信号,执行用户态处理函数 | 内核态→用户态(降级) |
3→4 | 信号处理函数调用 sigreturn 返内核 | 用户态→内核态(升级) |
4→5 | 内核恢复主流程,返回用户态 | 内核态→用户态(降级) |
6.2 内核态和用户态
中断对于内核态和用户态之间的切换有着重要的作用
6.2.1 操作系统硬件中断是如何运行的
硬中断:外部设备主动通知CPU处理紧急任务
下图是硬件中断的流程
操作系统是遵守冯诺依曼体系的,在之前的文章介绍过,但现在可以补充一点中央处理器并非跟输入设备无联系,在操作系统执行中断的时候外部设备通过中断控制器向CPU发送信号,有着间接联系
6.2.2 软中断
软中断是由软件主动触发的中断
核心作用:
- 下半部机制(Bottom Half):在硬件中断处理的上半部(快速响应)之后,处理耗时的任务(如网络协议栈解析、磁盘I/O队列调度)。
- 内核线程协作:例如Linux中的
ksoftirqd
线程批量处理软中断队列,避免长时间关闭硬件中断。
常见类型:- 定时器软中断(如
TIMER_SOFTIRQ
):驱动进程调度和时间管理。 - 网络收发包软中断(如
NET_RX_SOFTIRQ
/NET_TX_SOFTIRQ
):处理网络数据包的分发和传输。
- 定时器软中断(如
6.2.2.1 系统如何触发软中断?
(1)首先用户程序通过syscall或int 0x80来触发系统调用,从而切换到内核态
( 2) 在例如磁盘读写时,硬件完成I/O后触发硬件中断,内核再通过软中断处理协议栈或唤醒进程
(3)中断向量表(如 0x80)是桥梁,将中断号映射到具体处理函数(如system_call)
特性 | **syscall 指令** | **int 0x80 软中断** |
---|---|---|
架构支持 | x86-64 专用 | x86 32 位 |
性能 | 更高效(无堆栈切换) | 较慢(需查中断向量表) |
参数传递 | 寄存器直接传递 | 寄存器传递(ebx , ecx 等) |
兼容性 | 仅 64 位程序 | 兼容 32/64 位 |
syscall和int 0x80是属于CPU指令集中的指令,当程序运行时被加载到内存中,由CPU的指令解码器进行解析和执行
6.2.2 如何理解用户态和内核态
1.在Linux中每个进程的虚拟空间大小为4GB,其中分用户态空间(0-3GB,只能以用户的身份进行访问)和内核态空间(3-4GB,只能以内核的身份进行访问)
2.在CPU内存在一个代码段寄存器(cs),该寄存器存储了特权级(CPL)信息,从而反映系统目前是处于用户态还是内核态:
CPL=0:os处于内核态
CPL=3:os处于用户态
3.内核页表:操作系统只有一份,所有进程共享,负责将内核态空间的虚拟地址映射到物理内存
用户页表:每个进程有独立的用户页表,负责映射用户态空间的虚拟地址到物理内存
6.3 可重入函数
什么是可重入函数?
简单理解:相当于你在超市排队,当有人插队的时候,如果收银员操作步骤不好,可能会把你和别人的商品搞混从而导致账单混乱,而可重入函数就像一个“防插队收银流程”,即使有人插队,也能保证最终结果的正确
下图就是不可重入函数的例子:
-
插入
node1
时:- 步骤1:
node1->next = head
(假设原head
为空,node1->next = NULL
) - 步骤2:
head = &node1
→ 链表变为head → node1
- 步骤1:
-
插入
node2
时被中断:- 执行步骤1:
node2->next = head
(此时head
指向node1
,所以node2->next = node1
) - 中断发生:假设此时另一个信号处理函数也调用了
insert
,插入node3
:node3->next = head
(head
仍指向node1
)head = &node3
→ 链表变为head → node3 → node1
- 中断返回后继续执行步骤2:
head = &node2
- 最终链表:
head → node2
,但node3
的next
仍指向node1
,导致链表断裂
- 执行步骤1:
为什么是不可重入?
- 依赖全局变量
head
:多个执行流(主程序和信号处理函数)共享同一全局指针。 - 操作不原子化:两步操作(修改
next
和更新head
)之间可能被中断,导致中间状态暴露。 - 破坏数据一致性:中断插入的
node3
会导致node2
的next
指向过期的head
值。
判断是否可重入口诀:
可重入:只用参数和局部变量,操作独立不共享
不可重入:依赖全局/静态数据,操作共享资源非原子(原子化操作指一个操作在执行的时候不会被中断)
6.4 volatile
volatile属于关键字,其核心作用是避免外部(例如信号处理函数)对变量随意的修改
运用场景:
1.防止编译器误判
比如一个全局变量flag,主循环在while(1)中,而信号处理函数会修改它,如果没有volatile,编译器可能优化为:主循环里的flag似乎没变过,直接缓冲到寄存器,导致永远读不到信号修改的值
2. 强制"内存访问"
volatile要求每次读写变量都直接访问内存,确保信号处理函数的修改对主程序可见
典型代码模式:
在下面代码中volatile每次都强制编译器每次访问flag时都从内存中读取最新值
volatile sig_atomic_t flag = 0; // 必须加 volatile!
void signal_handler(int sig)
{
flag = 1; // 信号处理函数修改变量
}int main()
{
signal(SIGINT, signal_handler);
while (1){
if (flag){ // 每次都会从内存读取 flag
// 处理信号...
flag = 0;
}
}
}
补充:跟const一样都有约束的感觉,但二者有着本质的区别
const约束程序员("别修改这个变量")
volatile约束编译器(“别优化这个变量”)
6.5 SIGCHLD信号
SIGCHLD信号就是当子进程结束时,这个信号就会发送给父进程告诉父进程子进程死了,让子进程退出从而避免子进程变成僵尸状态
代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>// 处理 SIGCHLD 信号的函数
void handle_sigchld(int sig) {
// 回收所有已终止的子进程
while (waitpid(-1, NULL, WNOHANG) > 0);
}int main() {
// 注册信号处理函数
signal(SIGCHLD, handle_sigchld);// 创建一个子进程
pid_t pid = fork();
if (pid == 0) {
printf("子进程开始运行,3秒后退出...\n");
sleep(3);
exit(0); // 子进程退出
} else {
printf("父进程等待子进程结束...\n");
while (1) sleep(1); // 父进程不阻塞,继续做其他事
}
return 0;
}