进程间通信详解(三):Linux进程信号深度解析
文章目录
- 一、Linux进程信号核心概念
- 1.1 信号本质
- 1.2 关键术语
- 1.3 Linux 信号机制的核心流程:
- 二、信号产生机制全景
- 2.1 通过终端按键产生信号
- 2.1.1 基本操作
- 2.2 调用系统命令向进程发信号
- 2.2.1 kill 命令:向指定进程发送信号
- 2.2.2 killall 命令:按进程名发送信号
- 2.2.3 pkill 命令:按进程名或属性发送信号
- 2.2.4 发送信号的实际场景
- 2.2.5 查看信号列表
- 2.3 使用函数产生信号
- 2.3.1 kill
- 2.3.2 raise() 函数:向自身发送信号
- 2.3.3 sigqueue() 函数:发送带数据的信号(实时信号)
- 2.3.4 信号发送的错误处理与注意事项
- 2.3.5 总结
- 2.4 由软件条件产生信号
- 2.4.1 alarm
- 2.4.2 如何简单快速理解系统闹钟
- 2.5 硬件异常产生信号
- 2.5.1 常见的硬件异常信号
- 2.5.2 硬件异常的处理流程
- 2.5.3 调试硬件异常信号
- 三、保存信号
- 3.1 信号其他相关常见概念
- 3.2 在内核中的表示
- 3.3 sigset_t(信号集)
- 3.4 信号集操作函数
- 3.4.1 sigprocmask
- 总结
- 3.4.2 sigpending
- 四、捕获信号
- 4.1 信号捕捉的流程
- 4.2 sigaction
- 4.3 操作系统是怎么运行的
- 4.3.1 硬件中断
- 4.3.2 时钟中断
- 4.3.3 死循环
- 4.3.4 软中断
- 4.3.5 缺页中断?内存碎片处理?除零野指针错误?
- 4.4 如何理解内核态和用户态
- 五、可重入函数
- 5.1 什么是可重入函数?
- 5.2 不可重入的典型场景与风险
- 5.3 可重入函数的设计原则
- 5.4 可重入函数的实现示例
- 5.5 五、信号处理中的可重入性
- 六、volatile
- 6.1 volatile 的本质与作用
- 6.2 信号处理函数中的全局变量
- 七、SIGCHLD 信号:Linux 进程管理的 “子进程通知机制”
- 7.1 SIGCHLD 信号的本质与作用
- 7.2 SIGCHLD 的默认行为与问题
- 7.3 处理 SIGCHLD 的三种方式
- 7.4 调试与监控
- 7.5 常见误区与注意事项
- 7.6 总结
一、Linux进程信号核心概念
1.1 信号本质
* 异步通信机制:事件驱动的进程间通知
* 信号类型:预定义整数(1-31为常规信号,34+为实时信号)
* 生命周期:产生 → 保存 → 处理
1.2 关键术语
术语 | 描述 | 内核表示 |
---|---|---|
递达(Delivery) | 信号实际处理过程 | task_struct->ksigaction |
未决(Pending) | 信号产生到递达间的状态 | task_struct->signal->pending |
阻塞(Block) | 进程主动屏蔽的信号 | task_struct->blocked 位图 |
1.3 Linux 信号机制的核心流程:
信号产生 — 信号保存 — 信号处理
二、信号产生机制全景
2.1 通过终端按键产生信号
2.1.1 基本操作
Ctrl + C
(SIGINT
)
向当前正在运行的前台进程发送中断信号,使进程立即终止运行。不过,若进程对SIGINT
信号进行了特殊处理,如捕获并忽略该信号,那么按下Ctrl + C
可能无法终止进程。同时,Ctrl + C
仅对前台进程有效,后台进程不会受其影响Ctrl + \
(SIGOUT
)
不仅会终止进程,还会让进程生成 核心转储文件(core dump),用于调试程序崩溃问题Ctrl + Z
(SIGSTP
)
将当前前台进程暂停(挂起) 并放入后台,使其暂时停止运行但不终止。
2.2 调用系统命令向进程发信号
2.2.1 kill 命令:向指定进程发送信号
基本语法:
kill [-信号名称/编号] <进程ID>
常用信号选项:
-9
或-SIGKILL
:强制终止进程(不可被捕获或忽略)。
-15
或-SIGTERM
:正常终止进程(默认选项,可被捕获并执行清理)。
-1
或-SIGHUP
:重新加载配置(常用于守护进程,如nginx
)。
-2
或-SIGINT
:中断进程(等价于Ctrl + C
)。
-3
或-SIGQUIT
:终止进程并生成core
文件(等价于Ctrl + \
)。
-19
或-SIGSTOP
:暂停进程(等价于Ctrl + Z
,不可被忽略)。
-18
或-SIGCONT
:恢复被暂停的进程。
示例:
# 正常终止进程(先尝试清理)
kill 1234# 强制终止进程(不执行清理)
kill -9 1234# 向多个进程发送信号
kill -15 1234 5678 9012# 发送自定义信号(如 SIGUSR1,编号 10)
kill -10 1234
2.2.2 killall 命令:按进程名发送信号
基本语法:
killall [-信号名称/编号] <进程名>
示例:
# 终止所有名为 "nginx" 的进程
killall nginx# 强制终止所有名为 "cpp" 的进程
killall -9 cpp# 重新加载所有名为 "httpd" 的进程的配置
killall -HUP httpd
2.2.3 pkill 命令:按进程名或属性发送信号
基本语法:
pkill [-信号名称/编号] [-选项] <匹配模式>
常用选项:
-u <用户>
:按用户名筛选进程。-t <终端>
:按终端会话筛选进程。-f
:匹配进程全名(包括命令行参数)。
示例:
# 终止用户 "test" 运行的所有 "bash" 进程
pkill -u test bash# 暂停当前终端的所有 "vim" 进程
pkill -STOP -t pts/0 vim# 终止包含 "python script.py" 的进程
pkill -f "python script.py"
2.2.4 发送信号的实际场景
优雅重启服务
# 重新加载 Nginx 配置(不中断现有连接)
kill -HUP $(cat /run/nginx.pid)
批量管理进程
# 暂停所有用户 "alice" 的进程
pkill -STOP -u alice# 恢复所有被暂停的进程
pkill -CONT -u alice
终止顽固进程
# 先尝试正常终止(给进程清理资源的机会)
kill 1234# 若 5 秒后仍未终止,强制杀死
sleep 5 && kill -9 1234
2.2.5 查看信号列表
通过 kill -l
命令可查看系统支持的所有信号:
编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)
中都有详细说明:man 7 signal
2.3 使用函数产生信号
2.3.1 kill
函数原型:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数说明:
pid
:目标进程 ID(pid > 0
),或特殊值:pid = 0
:向当前进程组的所有进程发送信号。pid = -1
:向所有有权限发送的进程发送信号。sig
:要发送的信号(如SIGINT
、SIGKILL
,或自定义信号如SIGUSR1
)。
返回值:
- 成功返回
0
,失败返回-1
(错误原因可通过errno
获取)。
代码示例:向指定进程发送 SIGTERM 信号
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <errno.h>
#include <string.h>int main(int argc, char *argv[]) {if (argc != 3) {printf("用法: %s <进程ID> <信号编号>\n", argv[0]);return 1;}pid_t target_pid = atoi(argv[1]);int signal_num = atoi(argv[2]);if (kill(target_pid, signal_num) == -1) {perror("kill 失败");printf("错误码: %d, 错误信息: %s\n", errno, strerror(errno));return 1;}printf("已向进程 %d 发送信号 %d\n", target_pid, signal_num);return 0;
}
编译与使用:
gcc -o kill_demo kill_demo.c
# 向进程1234发送SIGTERM(信号15)
./kill_demo 1234 15
2.3.2 raise() 函数:向自身发送信号
函数原型:
#include <signal.h>
int raise(int sig);
参数说明:
sig
:要发送的信号(等价于kill(getpid(), sig)
)。
返回值:
- 成功返回
0
,失败返回非零值。
代码示例:程序自中断(等价于 Ctrl + C)
#include <stdio.h>
#include <signal.h>
#include <unistd.h>void sigint_handler(int sig) {printf("捕获到SIGINT信号,程序即将退出\n");exit(0); // 调用 exit 终止进程
}int main() {// 注册SIGINT信号处理函数signal(SIGINT, sigint_handler);printf("程序运行中,3秒后自发送SIGINT信号...\n");sleep(3);// 向自身发送SIGINT信号raise(SIGINT);printf("该语句不会执行,因为进程已处理信号并退出\n");return 0;
}
2.3.3 sigqueue() 函数:发送带数据的信号(实时信号)
函数原型:
#include <sys/types.h>
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);
参数说明:
pid
:目标进程 ID。sig
:要发送的信号(推荐使用实时信号,如SIGRTMIN + n
)。value
:包含整数或指针数据的联合体,可随信号传递给目标进程。
返回值:
- 成功返回
0
,失败返回-1
。
代码示例:发送带数据的实时信号
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>// 目标进程(接收信号)
void target_process() {// 注册信号处理函数struct sigaction sa;memset(&sa, 0, sizeof(sa));sa.sa_flags = SA_SIGINFO; // 支持接收信号附带的数据sa.sa_sigaction = [](int sig, siginfo_t *info, void *context) {if (sig == SIGRTMIN) {printf("接收实时信号SIGRTMIN,附带数据:%d\n", info->si_int);}};sigaction(SIGRTMIN, &sa, NULL);printf("目标进程运行中,等待信号...\n");while (1) sleep(1);
}// 发送信号的进程
void sender_process(pid_t target_pid) {union sigval value;value.sival_int = 100; // 附带整数数据if (sigqueue(target_pid, SIGRTMIN, value) == -1) {perror("sigqueue 失败");exit(1);}printf("已向进程 %d 发送带数据的SIGRTMIN信号\n", target_pid);
}int main(int argc, char *argv[]) {if (argc != 2) {printf("用法: %s <0(目标)/1(发送者)>\n", argv[0]);return 1;}int mode = atoi(argv[1]);if (mode == 0) {target_process();} else if (mode == 1) {pid_t target_pid = 1234; // 替换为实际目标进程IDsender_process(target_pid);} else {printf("模式错误,需输入0或1\n");}return 0;
}
2.3.4 信号发送的错误处理与注意事项
常见错误:
- **权限不足:**普通用户只能向自己的进程发送信号,向其他用户进程发送信号需 root 权限。
- **进程不存在:**目标进程已终止或 PID 错误时,
kill
会返回ESRCH
错误。 - **信号被阻塞:**目标进程若阻塞了该信号,信号会暂存直至阻塞解除。
推荐方式:
- 先检查进程是否存在:使用
kill(pid, 0)
可在不发送信号的情况下检查进程是否存在(sig=0
为 “空信号”)。 - 区分信号类型:
- 非实时信号(如
SIGINT
):若多次发送且未处理,仅保留最后一次。 - 实时信号(如
SIGRTMIN
):会排队等待处理,适合需要可靠传递的场景。
- 非实时信号(如
- **避免滥用
SIGKILL
:**优先使用SIGTERM
让进程优雅退出,仅在必要时使用SIGKILL
。
2.3.5 总结
函数 | 用途 | 核心参数 | 适用场景 |
---|---|---|---|
kill() | 向任意进程发送信号 | pid (进程 ID)、sig (信号) | 进程控制、常规信号发送 |
raise() | 向自身发送信号 | sig (信号) | 程序自中断、自定义退出 |
sigqueue() | 发送带数据的实时信号 | pid 、sig 、value (数据) | 进程间通信、需传递数据场景 |
2.4 由软件条件产生信号
常见的软件信号
- SIGALRM(闹钟信号)
- 触发条件:通过
alarm()
或setitimer()
函数设置的定时器到期。 - 应用场景:实现超时控制、周期性任务(如心跳检测)。
- 触发条件:通过
- SIGUSR1/SIGUSR2(用户自定义信号)
- 触发条件:通过
kill()
、raise()
或sigqueue()
函数手动发送。 - 应用场景:进程间自定义通信(如通知配置更新、优雅重启)。
- 触发条件:通过
- SIGPIPE(管道破裂信号)
- 触发条件:向已关闭的管道或套接字写入数据。
- 应用场景:网络编程中检测连接状态。
- SIGALRM/SIGVTALRM(虚拟定时器信号)
触发条件:通过setitimer()设置的用户态或内核态 CPU 时间到期。
应用场景:性能分析、CPU 时间统计。
SIGPIPE
是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm
函数和SIGALRM
信号。
2.4.1 alarm
- 基本功能与原型
函数原型:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
参数说明:
seconds
:设置的定时器秒数。seconds = 0
:取消之前设置的闹钟。seconds > 0
:在seconds
秒后触发SIGALRM
信号。
返回值:
- 返回之前设置的闹钟剩余秒数(若之前未设置,则返回
0
)。
- 默认行为与信号处理
- 默认行为: 当定时器到期时,进程会收到
SIGALRM
信号,默认行为是终止进程。 - 自定义处理: 可通过
signal()
或sigaction()
注册信号处理函数,避免进程被终止。
- 应用场景
- 超时控制: 例如等待用户输入或网络请求时设置超时。
- 周期性任务: 结合信号处理实现简单的定时器。
- 资源监控: 定时检查系统资源使用情况。
- 代码示例
示例 1:基本超时控制
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>void timeout_handler(int sig) {printf("超时!程序已运行超过5秒\n");exit(1);
}int main() {// 注册SIGALRM信号处理函数signal(SIGALRM, timeout_handler);// 设置5秒后触发SIGALRM信号alarm(5);printf("程序开始运行,等待5秒...\n");sleep(10); // 尝试休眠10秒,但会在5秒后被中断printf("该语句不会执行,因为进程已被信号中断\n");return 0;
}
示例 2:非阻塞超时读取用户输入
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>volatile int input_received = 0;void alarm_handler(int sig) {printf("\n超时!请加快输入\n");input_received = 1;
}int main() {char buffer[100];// 注册信号处理函数signal(SIGALRM, alarm_handler);// 设置3秒超时alarm(3);printf("请在3秒内输入内容:");fgets(buffer, sizeof(buffer), stdin);// 取消闹钟(如果用户在超时前输入)alarm(0);if (!input_received) {printf("你输入了:%s", buffer);}return 0;
}
示例 3:实现周期性任务
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void periodic_task(int sig) {printf("执行周期性任务:每秒打印一次\n");alarm(1); // 重新设置1秒后触发
}int main() {// 注册信号处理函数signal(SIGALRM, periodic_task);// 启动第一个闹钟alarm(1);printf("程序运行中,按Ctrl+C终止...\n");while (1) {// 主循环保持程序运行pause(); // 等待信号}return 0;
}
- 注意事项
- 每个进程只能有一个闹钟:多次调用
alarm()
会覆盖之前的设置。 - 时间精度有限:
alarm()
基于秒级计时,不适合毫秒级精度场景。 - 信号处理函数应简洁:避免在信号处理函数中执行复杂操作,可能导致重入问题。
- 与 sleep() 冲突:
alarm()
会中断sleep()
、pause()
等系统调用。
- 每个进程只能有一个闹钟:多次调用
2.4.2 如何简单快速理解系统闹钟
系统闹钟,其实本质是OS必须自身具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术。
现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:
#include <linux/timer.h>struct timer_list {struct list_head entry; // 内核链表结构unsigned long expires; // 到期时间(jiffies)struct tvec_base *base; // 内部使用的定时器基数void (*function)(unsigned long); // 回调函数unsigned long data; // 传递给回调函数的参数int slack; // 定时器执行的松弛时间// ...其他字段(内核版本不同可能有差异)
};
操作系统管理定时器,采用的是时间轮的做法。
核心原理:将时间划分为固定槽位,每个槽存储到期时间相同的定时器。指针随时间移动,到期时触发对应槽的定时器。
这里就简单提一下,感兴趣的自己去了解一下。如果难以理解,你就将它理解为一个时间轴,谁的过期时间更近那么就先调度谁。
2.5 硬件异常产生信号
2.5.1 常见的硬件异常信号
SIGSEGV(段错误,信号 11)
- 触发原因: 进程访问未分配给它的内存(如空指针解引用、数组越界)。
- 硬件机制: MMU(内存管理单元)检测到非法地址,触发页错误(Page Fault)。
- 示例场景:
int *ptr = NULL;
*ptr = 10; // 触发SIGSEGV
SIGFPE(浮点异常,信号 8)
- 触发原因: 数学运算错误(如除零、溢出)。
- 硬件机制: CPU 的浮点运算单元(FPU)检测到错误。
- 示例场景:
int a = 1 / 0; // 触发SIGFPE(整数除零)
double b = 1.0 / 0.0; // 可能触发(取决于编译器和硬件)
SIGILL(非法指令,信号 4)
- 触发原因: CPU 执行了无效指令(如未实现的指令、错误的操作码)。
- 硬件机制: 指令解码器检测到非法指令。
- 示例场景:
// 手动构造非法指令(示例仅示意,实际不可执行)
unsigned char code[] = {0xFF, 0xFF, 0xFF}; // 无效操作码
((void (*)())code)(); // 触发SIGILL
SIGBUS(总线错误,信号 7)
- 触发原因: 硬件访问错误(如未对齐内存访问、物理内存损坏)。
- 硬件机制: 内存总线检测到错误。
- 示例场景:
// 在某些架构上,访问未对齐的内存可能触发SIGBUS
struct {int a;char b;
} __attribute__((packed)) s;
int *p = (int*)&s.b; // 未对齐的指针
*p = 10; // 可能触发SIGBUS
2.5.2 硬件异常的处理流程
- 异常发生:CPU 执行指令时检测到错误(如除零、无效内存访问)。
- 硬件中断:CPU 切换到内核模式,执行对应的中断处理程序。
- 信号生成:内核识别异常类型,构造对应的信号(如
SIGSEGV
)。 - 信号传递:内核将信号添加到目标进程的未决信号队列。
- 进程响应:
- 默认行为:终止进程,生成核心转储文件(
core dump
)。 - 自定义处理:若进程通过
signal()
或sigaction()
注册了处理函数,则执行该函数。
- 默认行为:终止进程,生成核心转储文件(
2.5.3 调试硬件异常信号
子进程退出 core dump
核心转储文件(core dump)
- 作用:保存进程崩溃时的内存状态,用于事后分析。
- 启用方法:
ulimit -c unlimited # 允许生成core文件
- 分析工具:
gdb ./program core # 用GDB加载程序和core文件
GDB 调试技巧
# 设置信号处理方式(捕获但不终止)
(gdb) handle SIGSEGV nostop print# 运行程序直到崩溃
(gdb) run# 查看堆栈跟踪
(gdb) backtrace# 查看变量值
(gdb) print variable
三、保存信号
3.1 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
3.2 在内核中的表示
示意图:
task_struct 中的信号字段
每个进程的描述符(task_struct
)包含以下信号相关字段:
struct task_struct {// 信号掩码(当前阻塞的信号)sigset_t blocked;// 未决信号(pending)struct signal_struct *signal;// 信号处理函数表struct k_sigaction ksigaction[_NSIG];// 其他字段...
};
signal_struct 结构
struct signal_struct {atomic_t count; // 引用计数struct sigpending pending; // 未决信号队列spinlock_t siglock; // 保护锁struct sigaction action[_NSIG]; // 用户空间信号处理函数// 其他字段...
};
sigpending 结构
struct sigpending {struct list_head list; // 未决信号链表sigset_t signal; // 未决信号位图
};
信号处理流程
- 信号产生:内核 / 其他进程通过系统调用(如
kill()
)发送信号,标记pending
对应位为1
。 - 检查阻塞:进程调度或从内核态返回用户态时,检查
block
,若信号被阻塞(block=1
),则跳过处理,维持pending=1
;若未阻塞(block=0
),进入下一步。 - 执行处理动作:根据 handler 配置,执行默认动作(
SIG_DFL
)、忽略(SIG_IGN
)或自定义函数(sighandler
),处理后清零pending
对应位。
3.3 sigset_t(信号集)
sigset_t
本质上是一个 位图(Bitmap),每个位对应一个信号编号:
- 位数:通常为 64 位(对应 64 个信号)。
- 实现:内核中定义为
unsigned long
数组:
typedef struct {unsigned long sig[_NSIG_WORDS]; // _NSIG_WORDS 通常为 2(64位系统)
} sigset_t;
信号表示
- 若信号集中包含信号
sig
,则对应位被置为1
。 - 例如:信号集包含
SIGINT(2)
,则第 2 位为1
。
3.4 信号集操作函数
初始化与修改
#include <signal.h>// 清空信号集(所有位设为 0)
int sigemptyset(sigset_t *set);// 填充信号集(所有位设为 1)
int sigfillset(sigset_t *set);// 添加信号到集合
int sigaddset(sigset_t *set, int signum);// 从集合中移除信号
int sigdelset(sigset_t *set, int signum);// 检查信号是否在集合中
int sigismember(const sigset_t *set, int signum);
信号掩码操作
// 设置当前进程的信号掩码(阻塞/解除阻塞信号)
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how
参数:SIG_BLOCK
:添加set
中的信号到当前掩码(阻塞这些信号)。SIG_UNBLOCK
:从当前掩码中移除set
中的信号(解除阻塞)。SIG_SETMASK
:用set
替换当前掩码。
3.4.1 sigprocmask
函数原型
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明
how
:操作类型,可选值:SIG_BLOCK
:将set
中的信号添加到当前掩码(阻塞这些信号)。SIG_UNBLOCK
:从当前掩码中移除set
中的信号(解除阻塞)。SIG_SETMASK
:用set
完全替换当前掩码。
set
:信号集指针,指定要操作的信号。若为NULL
,则不修改掩码,仅获取当前掩码到oldset
。oldset
:用于保存修改前的信号掩码(若不为NULL
),可用于后续恢复。
返回值
- 成功:返回
0
。 - 失败:返回
-1
,并设置errno
(如EFAULT
、EINVAL
)。
信号掩码与未决信号
信号掩码(Signal Mask)
- 本质是一个位图,每位对应一个信号(如
SIGINT
、SIGTERM
)。 - 被掩码标记的信号不会被进程接收,而是进入 未决状态(
Pending
)。
未决信号(Pending Signals)
- 已产生但被阻塞的信号会暂存到进程的未决队列。
- 掩码解除后,未决信号会被立即处理(非实时信号可能合并,实时信号支持排队)。
总结
pending信号集记录已生成但未处理的信号,阻塞信号集决定哪些信号会被延迟处理。当信号产生时:
- 若阻塞信号集对应位为
0
(未阻塞),无论pending集
状态如何,信号都会被立即递送- 若阻塞信号集对应位为
1
(阻塞),信号会被加入pending集
(pending位设为1),保持未决状态当进程通过
sigprocmask()
解除信号阻塞(将阻塞位设为0)后:
- 内核检查
pending集
- 若对应信号位为
1
(存在未决信号)- 在下次进程从内核态返回用户态的执行上下文中
- 该信号会被递送处理
关键点说明
- 递送时机:信号处理发生在进程从内核态返回用户态时,这是Linux信号设计的核心机制
- 系统调用返回时
- 硬件中断处理完成后
- 进程上下文切换时
- 特殊情形:
- 连续多次阻塞同一信号:只有第一次会进入pending(标准信号)
SIGKILL
和SIGSTOP
不能被阻塞- 实时信号(RT信号)会排队,不丢失(FIFO)
3.4.2 sigpending
函数原型
#include <signal.h>int sigpending(sigset_t *set);
参数
set
:指向sigset_t
类型的指针,用于存储当前未决信号集合。
返回值:
- 成功:返回
0
,并将未决信号集复制到set
中。 - 失败:返回
-1
,并设置 errno(通常为EFAULT
,表示set
指针无效)。
四、捕获信号
4.1 信号捕捉的流程
信号捕捉时,进程执行主控制流遇中断、异常或系统调用进入内核态;内核处理完相关事务准备回用户态前,经 do_signal()
检查当前进程可递送信号,若为自定义处理函数的信号,内核保存进程主控制流上下文,让 CPU 跳转到用户态执行信号处理函数;处理函数返回时借 sigreturn
再次陷入内核,内核通过 sys_sigreturn
恢复进程之前保存的主控制流上下文,最终进程回到用户态,从主控制流上次被中断处继续执行 ,实现异步信号的 “中断 - 处理 - 恢复” 流程,保障主逻辑被打断后可无缝续行。
这条水平线就是用户层代码和内核底层逻辑的 “分界线”,程序在不同权限、功能区域执行时,会以此为界完成切换,是理解信号处理中用户态与内核态交互的基础标识。
4.2 sigaction
函数原型
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数:
signum
:要操作的信号编号(如SIGINT
、SIGTERM
、SIGCHLD
等 ,SIGKILL
、SIGSTOP
这类内核强制处理的信号无法通过它修改行为 )。act
:指向struct sigaction
结构体的指针,**设置新的信号处理行为 **。若为NULL
,则不修改信号行为,仅用于查询。oldact
:指向struct sigaction
结构体的指针,用于保存信号原来的处理行为 。若为NULL
,则不保存。
返回值:
- 成功返回
0
,失败返回-1
并设置errno
(如信号编号无效、指针参数非法等)。
功能:
允许进程设置、查询特定信号的处理逻辑,定义进程收到信号时应执行的操作,比如:
- 捕获信号并执行自定义处理函数(如程序崩溃时记录日志)。
- 恢复信号的默认行为(如让
SIGINT
恢复 “终止进程” 的默认动作 )。 - 忽略特定信号(如忽略
SIGCHLD
避免子进程变成僵尸进程的场景优化 )。
4.3 操作系统是怎么运行的
4.3.1 硬件中断
- 中断向量表就是操作系统的一部分,启动就加载到内存中了
- 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
- 由外部设备触发的,中断系统运行流程,叫做硬件中断
4.3.2 时钟中断
问题:
- 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
- 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
答:
- 操作系统的执行依赖于硬件引导流程与中断驱动机制,本质是 “被动响应事件” 的系统,而非被外部实体 “指挥”。
- 定时器设备通过周期性中断为操作系统提供时间基准,是实现进程调度、时间管理的核心硬件基础,其作用如同系统的 “心跳”。理解这两点,有助于深入掌握计算机系统的底层运行逻辑。
这样操作系统就能在硬件的推动下,自动调度了。
4.3.3 死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质:就是一个死循环!
- 这样,操作系统,就可以在硬件时钟的推动下,自动调度了。
- 所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?
答:
- 时间片是多任务系统分配 CPU 时间的基本单位,决定了进程切换的粒度。
- 主频是 CPU 每秒的时钟周期数,直接影响指令执行的理论上限(每秒指令数 = 主频 / CPI(指令周期))。
- 主频越快 CPU 越快的前提是 CPI 不变,但实际性能还受架构、缓存、指令集等因素影响。
4.3.4 软中断
- 上述外部硬件中断,需要硬件设备触发。
- 有没有可能,因为软件原因,也触发上面的逻辑?有!
- 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(
int
或者syscall
),可以让CPU内部触发中断逻辑。
所以:
问题:
- 用户层怎么把系统调用号给操作系统?-寄存器(比如EAX)
- 操作系统怎么把返回值给用户?-寄存器或者用户传入的缓冲区地址
- 系统调用的过程,其实就是先
intOx80
、syscall
陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法 - 系统调用号的本质:数组下标!
- 可是为什么我们用的系统调用,从来没有见过什么
intOx80
或者syscall
呢?都是直接调用上层的函数的啊? - 那是因为Linux的 gnu C 标准库,给我们把几乎所有的系统调用全部封装了。
4.3.5 缺页中断?内存碎片处理?除零野指针错误?
- 缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
所以:
- 操作系统就是躺在中断处理例程上的代码块!
- CPU内部的软中断,比如
int 0x80
或者syscall
,我们叫做陷阱 - CPU内部的软中断,比如除零/野指针等,我们叫做异常。(所以,能理解“缺页异常”为什么这么叫了吗?)
4.4 如何理解内核态和用户态
结论:
- 操作系统无论怎么切换进程,都能找到同一个操作系统!换句话说系统调用的内核代码在共享的内核地址空间执行,但会访问当前进程的用户地址空间资源,并使用该进程的内核栈!
- 关于特权级别,涉及到段,段描述符,段选择子,DPL,CPL,RPL等概念,而现在芯片为了保证兼容性,已经非常复杂了,进而导致OS也必须得照顾它的复杂性,这块我们不做深究了。
- 用户态就是执行用户[0,3]GB时所处的状态
- 内核态就是执行内核[3,4]GB时所处的状态
- 区分就是按照CPU内的CPL决定,CPL的全称是Current PrivilegeLevel,即当前特权级别。
- 一般执行
int0x80
或者syscall
软中断,CPL会在校验之后自动变更
五、可重入函数
5.1 什么是可重入函数?
可重入函数是指在多个执行流同时调用时不会产生副作用的函数。其核心特点是:
- 线程安全:在多线程环境下被并发调用时,不会因共享资源(如全局变量)导致数据竞争。
- 信号安全:在信号处理函数中被调用时,不会破坏程序状态(如正在执行的操作被中断)。
5.2 不可重入的典型场景与风险
不可重入函数在多线程或信号处理中可能引发以下问题:
- 全局变量或静态变量污染
// 不可重入函数示例:依赖全局变量
int total = 0;
int add_to_total(int value) {total += value; // 多线程访问时可能导致数据竞争return total;
}
风险:若两个线程同时调用add_to_total
,可能因线程切换导致计算错误(如两个线程各加 1,但结果只加了 1)。
- 标准库函数的不可重入性
许多标准库函数依赖静态缓冲区或状态,如:
strtok()
:使用静态指针保存分割位置。gmtime()/localtime()
:返回静态缓冲区的指针。
示例:
// 不可重入函数示例:使用静态缓冲区
char* format_time(void) {time_t now = time(NULL);return ctime(&now); // ctime()返回静态缓冲区,多线程调用会覆盖结果
}
- 信号处理中的不可重入风险
若信号处理函数调用不可重入函数,可能导致:
- 主程序正在执行的操作被中断,数据结构被破坏。
- 信号处理函数与主程序同时修改共享资源,引发竞态条件。
5.3 可重入函数的设计原则
- 避免共享资源
- 不使用全局变量或静态变量。
- 若必须使用,通过互斥锁(如
pthread_mutex_t
)保护。
- 使用局部变量和栈
所有数据存储在栈上(如函数参数、局部变量),每个调用独立拥有副本。 - 避免调用不可重入函数
例如:- 用
strtok_r()
替代strtok()
(带_r
后缀的通常是可重入版本)。 - 用
gmtime_r()
替代gmtime()
。
- 用
5.4 可重入函数的实现示例
// 可重入版本:使用线程局部存储(TLS)
#include <pthread.h>// 线程局部变量,每个线程独立拥有副本
__thread int thread_total = 0;int add_to_total(int value) {thread_total += value; // 线程安全:每个线程使用自己的副本return thread_total;
}// 可重入版本:使用互斥锁保护全局变量
#include <pthread.h>int global_total = 0;
pthread_mutex_t total_mutex = PTHREAD_MUTEX_INITIALIZER;int add_to_total_safe(int value) {pthread_mutex_lock(&total_mutex); // 加锁global_total += value;pthread_mutex_unlock(&total_mutex); // 解锁return global_total;
}
5.5 五、信号处理中的可重入性
在信号处理函数中,仅能调用可重入函数(如write()
、_exit()
),避免调用:
- 标准 IO 函数(如
printf()
、fprintf()
)。 - 内存分配函数(如
malloc()
、free()
)。 - 浮点运算函数(如
sin()
、cos()
)。
六、volatile
6.1 volatile 的本质与作用
volatile
是 C/C++ 中的一个类型修饰符,用于告诉编译器:
- 不要对变量进行优化(如缓存到寄存器或重排序)。
- 每次访问变量时都直接从内存读取,写入时立即刷新到内存。
其核心作用是确保变量的访问与物理内存直接交互,而非编译器的临时缓存。
6.2 信号处理函数中的全局变量
- 场景:在信号处理函数中修改主程序使用的全局变量。
- 原因:信号可能在任意时刻触发,编译器不能假设变量不变。
- 示例:
volatile sig_atomic_t signal_received = 0;void signal_handler(int signo) {signal_received = 1; // 原子操作,确保可见性
}int main() {signal(SIGINT, signal_handler);while (!signal_received) { // 每次检查都从内存读取// 主程序工作...}return 0;
}
七、SIGCHLD 信号:Linux 进程管理的 “子进程通知机制”
7.1 SIGCHLD 信号的本质与作用
SIGCHLD
(信号编号 17)是 Linux 系统中由内核自动发送给父进程的信号,用于通知以下事件:
- 子进程终止(正常退出或被信号终止)。
- 子进程暂停(如收到
SIGSTOP
信号)。 - 子进程恢复(如收到
SIGCONT
信号)。
其核心作用是让父进程能够异步处理子进程状态变化,避免父进程持续轮询(如通过wait()
阻塞等待)。
7.2 SIGCHLD 的默认行为与问题
- 默认行为:忽略(进程收到信号后无动作)。
- 潜在问题:若父进程未处理
SIGCHLD
,子进程终止后会变成僵尸进程(Zombie Process),占用系统资源(如进程表项)。
7.3 处理 SIGCHLD 的三种方式
- 忽略信号(最简单但有风险)
// 忽略SIGCHLD信号,子进程终止后直接释放资源
signal(SIGCHLD, SIG_IGN); // 或使用sigaction// 子进程代码
if (fork() == 0) {// 子进程执行...exit(0); // 退出后不会变成僵尸进程
}
注意:Linux 中忽略 SIGCHLD
会让内核自动回收子进程资源,但某些 UNIX 系统可能不支持,建议使用方式 2 或 3。
- 捕获信号并调用 wait ()/waitpid ()
#include <signal.h>
#include <sys/wait.h>void sigchld_handler(int signo) {int status;// 非阻塞等待所有子进程,避免wait()阻塞while (waitpid(-1, &status, WNOHANG) > 0) {// 处理子进程退出状态if (WIFEXITED(status)) {printf("子进程正常退出,状态码: %d\n", WEXITSTATUS(status));} else if (WIFSIGNALED(status)) {printf("子进程被信号终止,信号: %d\n", WTERMSIG(status));}}
}int main() {// 注册信号处理函数struct sigaction sa;sa.sa_handler = sigchld_handler;sigemptyset(&sa.sa_mask);sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // SA_NOCLDSTOP忽略暂停/恢复信号sigaction(SIGCHLD, &sa, NULL);// 创建子进程pid_t pid = fork();if (pid == 0) {// 子进程执行...sleep(2);exit(42);}// 父进程继续执行...return 0;
}
关键点:
- 使用
waitpid(-1, &status, WNOHANG)
非阻塞回收多个子进程。 SA_RESTART
标志避免系统调用被信号中断。SA_NOCLDSTOP
忽略子进程暂停 / 恢复事件,仅关注终止。
- 使用 sigaction 的 SA_NOCLDWAIT 标志(现代方式)
struct sigaction sa;
sa.sa_handler = SIG_IGN; // 或自定义处理函数
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_NOCLDWAIT; // 内核自动回收子进程,不产生僵尸
sigaction(SIGCHLD, &sa, NULL);
优势:内核自动释放子进程资源,无需手动调用wait()
。
7.4 调试与监控
查看僵尸进程:
ps aux | grep Z # 显示状态为Z的僵尸进程
跟踪信号处理:
strace -e signal your_program # 跟踪信号处理系统调用
7.5 常见误区与注意事项
- 竞态条件
若父进程未捕获SIGCHLD
,可能导致:- 子进程已终止,但父进程未及时回收,变成僵尸。
- 父进程调用
wait()
时,子进程尚未终止,导致阻塞。
- 信号丢失
非实时信号(如SIGCHLD
)不排队,若多个子进程同时终止,可能只收到一个信号。需在处理函数中循环调用waitpid()
回收所有子进程。 - 与 fork ()/exec () 的关系
fork()
创建的子进程继承父进程的SIGCHLD
处理方式。exec()
后,子进程保留SIGCHLD
的处理方式(除非设置了SA_RESETHAND
)。
7.6 总结
- SIGCHLD 的核心价值:
提供异步机制让父进程感知子进程状态变化,避免轮询或阻塞等待。 - 最佳实践:
- 优先使用
SA_NOCLDWAIT
自动回收子进程。 - 若需获取子进程状态,在信号处理函数中循环调用
waitpid()
。
- 优先使用
- 应用场景:
- 守护进程(如
init
进程管理所有子进程)。 - 多进程服务器(如 Web 服务器
fork
子进程处理请求)。
- 守护进程(如