重点:
-
掌握Linux信号的基本概念。
-
掌握信号产生的一般方式。
-
理解信号递达和阻塞的概念,原理。
-
掌握信号捕捉的一般方式。
-
了解中断过程,理解中断的意义
-
掌握操作系统运行,系统调用原理,理解缺页异常或其他软件异常的基本原理
-
重新了解可重入函数的概念。
-
了解竞态条件的情景和处理方式。
-
了解SIGCHLD信号, 重新编写信号处理函数的一般处理机制。
1. 信号快速认识
1-1 生活角度的信号
| 生活场景 | 对应信号机制 |
|---|---|
| 快递员打电话通知你 | OS向进程发送信号 |
| 你知道怎么处理快递 | 进程的信号处理方法在信号产生前就已注册好 |
| 正在打游戏,5分钟后再取 | 信号不会立即处理,而是在"合适的时候"处理 |
| 记住有快递要取 | 信号被记录在pending位图中 |
| 打开快递/送人/扔掉 | 默认动作/自定义动作/忽略 |
核心理解:信号的本质是异步通知机制。 进程不需要轮询"有没有信号来了",而是信号来了之后,OS会在合适的时机通知进程处理。
1-2 技术应用角度的信号
1-2-1 Ctrl+C 的本质
// sig.cc
#include <iostream>
#include <unistd.h>
int main()
{
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
当你按下 Ctrl+C 时,发生了什么?
- 硬件中断:键盘产生硬件中断信号
- OS接管:中断被OS捕获,OS解释为"用户想终止前台进程"
- 信号生成 :OS生成
SIGINT(2号信号) - 信号投递:OS将信号发送给当前前台进程
- 默认处理:进程收到SIGINT,默认动作是终止
1-2-2 signal函数 - 信号处理的注册
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 信号处理函数(回调函数)
// signumber: 收到的信号编号
void handler(int signumber)
{
std::cout << "我是:" << getpid() << ",我获得了一个信号:" << signumber << std::endl;
}
int main()
{
std::cout << "我是进程:" << getpid() << std::endl;
// 注册信号处理函数
// 当收到SIGINT(2号信号)时,调用handler函数
// 注意:这里只是注册,不是立即调用!
signal(SIGINT/*2*/, handler);
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
输出:
我是进程:212569
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是:212569,我获得了一个信号:2 <-- 按下Ctrl+C后,执行自定义处理
I am a process, I am waiting signal! <-- 进程没有退出,继续运行
关键理解:
signal()只是注册处理方式,不是立即调用- 注册后,只有当信号真正到来时,才会调用handler
- 如果永远不发SIGINT,handler永远不会执行
- 这就像你告诉快递员"快递到了放前台",但快递没到之前,你不会去前台
进程为什么不退出?
因为默认动作被覆盖了!原本SIGINT的默认动作是终止进程,但你用 signal() 把它改成了自定义的handler函数。handler执行完后,进程继续运行。
1-3 信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
1-3-1 信号编号与名称
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN ...
常用信号速查表:
| 信号 | 编号 | 默认动作 | 触发方式 | 用途 |
|---|---|---|---|---|
| SIGHUP | 1 | 终止 | 终端挂起 | 通知守护进程重新加载配置 |
| SIGINT | 2 | 终止 | Ctrl+C | 中断前台进程 |
| SIGQUIT | 3 | 终止+Core | Ctrl+\ | 类似SIGINT,但会生成core文件 |
| SIGFPE | 8 | 终止+Core | 除零错误 | 浮点异常 |
| SIGKILL | 9 | 终止 | kill -9 | 不可捕捉、不可忽略 |
| SIGSEGV | 11 | 终止+Core | 非法内存访问 | 段错误 |
| SIGPIPE | 13 | 终止 | 向无读端的管道写 | 管道破裂 |
| SIGALRM | 14 | 终止 | alarm超时 | 定时器 |
| SIGTERM | 15 | 终止 | kill默认 | 请求进程终止 |
| SIGCHLD | 17 | 忽略 | 子进程状态变化 | 通知父进程 |
| SIGSTOP | 19 | 停止 | Ctrl+Z | 不可捕捉、不可忽略 |
| SIGCONT | 18 | 继续 | fg命令 | 恢复停止的进程 |
编号规律:
- 1-31:普通信号(本章重点)
- 34+:实时信号(本章不讨论)
- 9和19:不可被捕捉和忽略(系统保留的强制手段)
1-3-2 三种信号处理方式
// 信号处理方式的本质:函数指针
typedef void (*__sighandler_t)(int);
// 三个特殊值
#define SIG_DFL ((__sighandler_t) 0) // 默认动作
#define SIG_IGN ((__sighandler_t) 1) // 忽略
// 其他值:用户自定义函数地址
代码演示:
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
std::cout << "我是进程:" << getpid() << std::endl;
// 方式1:忽略信号
signal(SIGINT, SIG_IGN); // Ctrl+C将被忽略
// 方式2:恢复默认(取消之前的自定义处理)
// signal(SIGINT, SIG_DFL); // 恢复默认动作(终止)
// 方式3:自定义处理(见上面的例子)
// signal(SIGINT, handler);
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
我的理解:
signal函数就像一个"注册表",它修改了进程PCB中的信号处理表。当信号到来时,OS查这个表,决定执行什么动作。这个表在信号到来之前就已经存在,所以进程在没有收到信号时,就已经"知道"该怎么处理了。
2. 产生信号

2-1 通过终端按键产生信号

| 按键 | 信号 | 默认动作 | 特点 |
|---|---|---|---|
| Ctrl+C | SIGINT(2) | 终止 | 最常用,可捕捉 |
| Ctrl+\ | SIGQUIT(3) | 终止+Core | 可捕捉,生成core文件用于调试 |
| Ctrl+Z | SIGTSTP(20) | 停止 | 可捕捉,进程挂起到后台 |
前台进程 vs 后台进程:
$ ./myprogram & # &符号让进程在后台运行
$ jobs # 查看后台任务
$ fg %1 # 将任务1调回前台
- 只有前台进程才能接收Ctrl+C等终端信号
- 后台进程运行不受终端控制,但输出可能干扰终端显示
2-2 调用系统命令向进程发信号
# kill命令的多种写法
$ kill -SIGINT 12345 # 使用信号名称
$ kill -2 12345 # 使用信号编号
$ kill -s SIGINT 12345 # 使用-s选项
# killall命令:按进程名发信号
$ killall -SIGINT myprogram
kill的本质: kill命令调用kill()系统调用,向指定进程发送信号。权限检查:
- 超级用户可以向任何进程发信号
- 普通用户只能向自己的进程发信号
2-3 使用函数产生信号
2-3-1 kill函数 - 向指定进程发信号
#include <sys/types.h>
#include <signal.h>
// pid: 目标进程ID
// sig: 信号编号
// 返回值:成功返回0,失败返回-1
int kill(pid_t pid, int sig);
实现自己的kill命令:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
// 使用方式:./mykill -signumber pid
// 例如:./mykill -9 12345
int main(int argc, char *argv[])
{
// 参数检查
if(argc != 3)
{
std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
// 解析信号编号:argv[1]是"-2"这样的格式,+1跳过'-'字符
int number = std::stoi(argv[1]+1);
// 解析目标进程ID
pid_t pid = std::stoi(argv[2]);
// 发送信号
int n = kill(pid, number);
if(n == -1)
{
std::cerr << "kill failed" << std::endl;
return 1;
}
return 0;
}
2-3-2 raise函数 - 给自己发信号
#include <signal.h>
// sig: 信号编号
// 返回值:成功返回0,失败返回非0
int raise(int sig);
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "获取了一个信号:" << signumber << std::endl;
}
int main()
{
// 注册SIGINT的处理函数
signal(2, handler);
while(true)
{
sleep(1);
// 每隔1秒给自己发送SIGINT信号
raise(2);
}
}
输出:
获取了一个信号:2
获取了一个信号:2
获取了一个信号:2
...
raise的等价实现:
int raise(int sig)
{
return kill(getpid(), sig);
}
2-3-3 abort函数 - 异常终止
#include <stdlib.h>
// 无返回值,总是会终止进程
void abort(void);
abort的特点:
-
给自己发送SIGABRT(6号信号)
-
即使捕捉了SIGABRT,abort仍然会终止进程
-
这是abort的设计意图:告诉OS"我要异常退出"
#include <iostream>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{
std::cout << "捕捉到信号:" << sig << ",但我还是要退出!" << std::endl;
}int main()
{
signal(SIGABRT, handler);std::cout << "准备abort..." << std::endl; abort(); // 即使捕捉了,也会终止 // 不会执行到这里 std::cout << "这行不会打印" << std::endl; return 0;}
2-4 由软件条件产生信号
2-4-1 alarm函数 - 定时器
#include <unistd.h>
// seconds: 定时秒数
// 返回值:之前闹钟的剩余秒数,如果没有则返回0
unsigned int alarm(unsigned int seconds);
基本用法:
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber)
{
std::cout << "闹钟响了!count : " << count << std::endl;
exit(0);
}
int main()
{
// 注册SIGALRM的处理函数
signal(SIGALRM, handler);
// 设置1秒后闹钟
alarm(1);
// 快速计数
while (true)
{
count++;
}
return 0;
}
2-4-2 IO效率对比实验
这是alarm的一个经典应用:测试CPU运算速度和IO效率。
// ========== 版本1:有IO(慢)==========
#include <iostream>
#include <unistd.h>
int main()
{
int count = 0;
alarm(1); // 1秒后终止
while(true)
{
std::cout << "count : " << count << std::endl; // IO操作
count++;
}
return 0;
}
// 结果:count约107148(1秒内只能打印10万次)
// ========== 版本2:无IO(快)==========
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber)
{
std::cout << "count : " << count << std::endl;
exit(0);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (true)
{
count++; // 纯CPU运算
}
return 0;
}
// 结果:count约492333713(1秒内运算近5亿次)
结论: IO操作(cout)比纯CPU运算慢约5000倍!
2-4-3 重复闹钟
alarm是一次性的,要实现重复闹钟需要在处理函数中重新设置:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>
using func_t = std::function<void()>;
int gcount = 0;
std::vector<func_t> gfuncs; // 任务列表
void handler(int signo)
{
// 执行所有注册的任务
for(auto &f : gfuncs)
{
f();
}
std::cout << "gcount : " << gcount << std::endl;
// 重设闹钟,返回上一次闹钟的剩余时间
int n = alarm(1);
std::cout << "剩余时间 : " << n << std::endl;
}
int main()
{
// 注册一些任务
gfuncs.push_back([](){ std::cout << "任务1执行" << std::endl; });
gfuncs.push_back([](){ std::cout << "任务2执行" << std::endl; });
// 设置一次性闹钟
alarm(1);
// 注册处理函数
signal(SIGALRM, handler);
while (true)
{
// pause()会阻塞直到有信号到来
pause();
std::cout << "我醒来了..." << std::endl;
gcount++;
}
}
pause函数:
#include <unistd.h>
// 使进程挂起,直到有信号到来
// 如果信号的处理动作是终止,则终止
// 如果信号有处理函数,则执行完处理函数后返回
int pause(void);
2-4-4 内核定时器原理
alarm的本质是OS内核的定时器机制:
// 内核定时器结构体(简化版)
struct timer_list {
struct list_head entry; // 链表节点,用于串联多个定时器
unsigned long expires; // 超时时间(jiffies数)
void (*function)(unsigned long); // 超时回调函数
unsigned long data; // 回调函数的参数
struct tvec_t_base_s *base; // 定时器基表
};
我的理解:
OS维护一个定时器链表,每个定时器记录了"什么时候触发"和"触发后做什么"。当时钟中断到来时,OS检查链表中有没有到期的定时器,如果有就执行对应的回调函数(发送信号)。
这就像你手机上的闹钟APP,OS就是那个APP,定时器链表就是你的闹钟列表。
2-5 硬件异常产生信号
这是信号机制最强大的地方:硬件异常被转化为信号,让进程有机会处理自己的错误。
硬件异常被硬件以某种方式检测到并通知内核 ,然后内核向当前进程发送适当的信号。例如当前 进程执行了除以0的指令, CPU的运算单元会产生异常, 内核将这个异常解释为SIGFPE信号发送给进 程。再比如当前进程访问了非法内存地址, MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送 给进程。
2-5-1 除零异常 → SIGFPE(8)
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
// 注意:这里没有退出,会一直收到信号
// 因为除零异常没有被修复,CPU会一直触发
}
int main()
{
signal(SIGFPE, handler); // 捕捉8号信号
sleep(1);
int a = 10;
a /= 0; // 除零!
while(1); // 这里会一直收到SIGFPE
return 0;
}
为什么捕捉后还会一直收到信号?
因为:
- CPU执行
a /= 0时产生异常 - OS向进程发送SIGFPE
- 进程执行handler
- handler返回后,CPU继续执行
- 但异常现场没有被修复(a还是那个变量,除零状态还在)
- CPU再次检测到异常,再次发送SIGFPE
- 无限循环...
教训: 捕捉SIGFPE后,必须修复问题或退出进程!
2-5-2 野指针异常 → SIGSEGV(11)
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
// 同样会一直收到信号
}
int main()
{
signal(SIGSEGV, handler); // 捕捉11号信号
sleep(1);
int *p = NULL;
*p = 100; // 解引用空指针!
while(1); // 这里会一直收到SIGSEGV
return 0;
}
输出:
catch a sig : 11
catch a sig : 11
catch a sig : 11
...
2-5-3 子进程异常与waitpid

#include <iostream>
#include <string>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
int main()
{
pid_t pid = fork();
if (pid == 0)
{
// 子进程
sleep(1);
int a = 10;
a /= 0; // 除零异常
exit(0); // 不会执行到这里
}
// 父进程
int status = 0;
waitpid(-1, &status, 0); // 等待子进程
// status的低7位:终止信号编号
// status的第8位:是否core dump
printf("exit signal: %d, core dump: %d\n",
status & 0x7F, // 提取低7位
(status >> 7) & 1); // 提取第8位
return 0;
}
输出:
exit signal: 8, core dump: 1 # 8号信号(SIGFPE),产生了core dump
2-5-4 Core Dump - 事后调试利器
什么是Core Dump?
当进程异常终止时,OS可以把进程的内存快照保存到磁盘文件(通常是core或core.PID),这个过程叫Core Dump。
Core文件有什么用?
用GDB加载core文件,可以查看崩溃时的:
-
调用栈(backtrace)
-
变量值
-
寄存器状态
启用core dump
ulimit -c unlimited # 允许生成任意大小的core文件 ulimit -c 1024 # 限制core文件最大1024KB
$ ulimit -a # 查看所有限制使用core文件调试
$ gdb ./myprogram core.12345
(gdb) bt # 查看调用栈
(gdb) print variable # 查看变量值
默认为什么禁用Core Dump?
- core文件可能很大(进程内存的完整副本)
- 可能包含敏感信息(密码、密钥等)
- 生产环境通常禁用
我理解:
Core Dump就像飞机的黑匣子。当程序"坠毁"(崩溃)时,黑匣子记录了最后的状态,让开发者可以事后分析"坠毁原因"。
2-6 总结思考
五个关键问题:
-
为什么必须由OS发送信号?
- OS是进程的管理者,只有OS有权修改进程的PCB
- 硬件异常由OS捕获并转化为信号
- 用户进程不能直接修改其他进程的状态
-
信号是立即处理的吗?
- 不是!信号在"合适的时候"处理
- 什么是"合适"?进程从内核态返回用户态时检查pending
-
信号需要记录在哪里?
- 记录在进程PCB的pending位图中
- 每个信号占1bit(0或1),不记录产生了几次
-
进程在没有收到信号时知道怎么处理吗?
- 知道!信号处理方法在信号产生前就注册好了
- handler表在进程创建时就初始化了(默认动作)
-
完整的信号发送处理流程?
硬件异常/软件条件 ↓ OS检测到异常 ↓ OS在进程PCB的pending位图中标记对应信号 ↓ 进程从内核态返回用户态前,检查pending ↓ 发现有待处理信号 ↓ 查handler表,决定处理方式 ↓ 执行处理(默认/忽略/自定义) ↓ 清除pending标记,继续执行
面试题与详细解答
面试题1:signal函数做了什么?它是如何修改进程行为的?
答:
signal函数注册一个信号处理函数,它修改了进程PCB中的信号处理表(sighand_struct)。
具体来说:
- signal(SIGINT, handler)将SIGINT(2号信号)对应的处理函数指针从SIG_DFL改为handler
- 当OS向进程发送SIGINT时,OS会检查这个表
- 发现SIGINT对应的是handler函数,就执行handler
- handler执行完后,进程继续运行
关键点:signal只是"注册",不是"调用"。如果信号永远不来,handler永远不执行。
面试题2:Ctrl+C和Ctrl+\有什么区别?为什么要有两种中断信号?
答:
| 对比项 | Ctrl+C (SIGINT) | Ctrl+\ (SIGQUIT) |
|---|---|---|
| 信号编号 | 2 | 3 |
| 默认动作 | 终止进程 | 终止进程 + Core Dump |
| 是否可捕捉 | 是 | 是 |
| 用途 | 普通中断 | 调试用中断 |
设计两种信号的原因:
- SIGINT用于正常中断,比如用户想停止一个正在运行的程序
- SIGQUIT用于调试场景,程序崩溃时生成core文件,便于事后分析
在实际开发中,如果你的程序需要处理Ctrl+C(比如做清理工作),可以捕捉SIGINT。但不要轻易捕捉SIGQUIT,它留给系统调试用。
面试题3:kill -9 为什么杀不死进程?什么情况下会杀不死?
答:
SIGKILL(9号信号)的设计就是"强制终止",它有两个特殊属性:
- 不可被捕捉:signal(SIGKILL, handler)会失败
- 不可被忽略:signal(SIGKILL, SIG_IGN)会失败
理论上,kill -9应该能杀死任何进程。但实际中可能杀不死的情况:
-
僵尸进程(Zombie):进程已经终止,但父进程没有wait回收。僵尸进程已经"死了",只是PCB还在,kill无法杀死一个已经死的进程。
- 解决:kill它的父进程,让init收养并回收
-
D状态(Uninterruptible Sleep):进程在等待IO(通常是磁盘IO),处于不可中断睡眠状态。内核设计上,D状态进程不响应任何信号,包括SIGKILL。
- 这是为了保护IO操作的完整性
- 等IO完成后,进程会自动醒来并处理信号
-
内核线程:内核线程不响应用户空间的信号
面试题4:alarm函数实现的定时器精度是多少?为什么不够精确?
答:
alarm的精度是秒级,不够精确的原因:
- 时钟中断粒度:Linux时钟中断通常是1ms或10ms一次,但alarm只精确到秒
- 调度延迟:alarm到期后,进程可能不在CPU上,需要等待调度
- 信号处理延迟:信号不会立即处理,要等到进程从内核态返回用户态
如果需要高精度定时器,应该使用:
timer_create():POSIX定时器,精度纳秒级setitimer():精度微秒级epoll_wait+timerfd:结合IO多路复用的定时器
面试题5:什么是Core Dump?如何利用它调试程序崩溃?
答:
Core Dump是进程异常终止时的内存快照,保存在core文件中。
调试步骤:
# 1. 启用core dump
$ ulimit -c unlimited
# 2. 运行程序,等待崩溃
$ ./myprogram
Segmentation fault (core dumped)
# 3. 用GDB分析core文件
$ gdb ./myprogram core.12345
# 4. 常用GDB命令
(gdb) bt # 查看调用栈
(gdb) frame 3 # 切换到第3帧
(gdb) print var # 查看变量值
(gdb) list # 查看源码
Core Dump的限制:
- 默认禁用(安全考虑)
- 需要足够的磁盘空间
- core文件大小受ulimit限制
面试题6:信号和中断有什么区别和联系?
答:
区别:
| 对比项 | 信号 | 中断 |
|---|---|---|
| 来源 | 软件(OS、进程) | 硬件(外设、CPU) |
| 接收者 | 进程 | CPU |
| 处理方式 | 进程执行handler | CPU执行中断处理程序 |
| 时机 | 进程从内核态返回用户态 | 立即 |
联系:
- 中断可以产生信号:键盘中断 → SIGINT
- 信号机制借鉴了中断思想:异步、保存上下文、处理、恢复
- 信号可以看作"进程级别的中断"
理解:
中断是硬件级别的异步通知,信号是软件级别的异步通知。OS作为中间人,把硬件中断转化为软件信号,让进程能够处理。这就像快递员(硬件)打电话给前台(OS),前台再通知你(进程)有快递。
3. 保存信号

3-1 信号相关核心概念
在理解信号保存机制之前,必须先搞清楚四个概念:
| 概念 | 英文 | 含义 | 类比 |
|---|---|---|---|
| 信号产生 | Signal Generation | 事件发生,信号被创建 | 快递员取件 |
| 信号未决 | Pending | 信号已产生,但还没被处理 | 快递在路上 |
| 信号递达 | Delivery | 信号被实际处理 | 你收到快递并处理 |
| 信号阻塞 | Block | 信号被暂时"挂起",不递达 | 你说"先别送,我忙" |
阻塞 vs 忽略的关键区别:
阻塞:信号产生 → 保持在pending状态 → 永远不递达(直到解除阻塞)
忽略:信号产生 → 递达 → 执行忽略动作(SIG_IGN)
关键区别:
- 阻塞:信号根本不会被处理,一直在pending队列里等着
- 忽略:信号被处理了,只是处理动作是"什么都不做"
生活类比:
- 阻塞:你告诉快递员"我正在开会,先别打电话,等我开完会再说"。快递一直在快递站等着。
- 忽略:快递到了,你签收了,但直接扔垃圾桶。
3-2 内核中的信号三表
这是信号机制最核心的数据结构!

task_struct (进程控制块)
│
├── blocked (信号屏蔽字)
│ ┌──────────────────────────────────┐
│ │ 信号编号 状态
│ │ SIGHUP(1) 0 ← 未阻塞
│ │ SIGINT(2) 1 ← 被阻塞!
│ │ SIGQUIT(3) 1 ← 被阻塞!
│ │ ... ...
│ │ SIGKILL(9) 0 ← 无法阻塞
│ └──────────────────────────────────┘
│ 作用:标记哪些信号被阻塞
│ 每个信号占1bit
│
├── pending (未决信号集)
│ ┌──────────────────────────────────┐
│ │ 信号编号 状态
│ │ SIGHUP(1) 0 ← 没有信号
│ │ SIGINT(2) 1 ← 有信号!
│ │ SIGQUIT(3) 0 ← 没有信号
│ │ ... ...
│ └──────────────────────────────────┘
│ 作用:标记哪些信号已经产生但还没递达
│ 每个信号占1bit
│
└── sighand (信号处理函数表)
┌──────────────────────────────────┐
│ 信号编号 处理方式
│ SIGHUP(1) SIG_DFL (默认)
│ SIGINT(2) → handler函数
│ SIGQUIT(3) SIG_IGN (忽略)
│ ... ...
│ SIGKILL(9) SIG_DFL (不可改)
└──────────────────────────────────┘
作用:记录每个信号的处理方式
存储在用户空间(3G-4G的内核映射区)
信号处理的判断流程:
信号SIGINT(2)产生
↓
在pending表中标记:pending[2] = 1
↓
检查blocked表:blocked[2] == ?
↓
┌───┴───┐
│ │
blocked=1 blocked=0
│ │
↓ ↓
保持在 检查sighand表
pending sighand[2] == ?
状态 ┌────┼────┐
(等待) │ │ │
↓ ↓ ↓
DFL IGN handler
│ │ │
↓ ↓ ↓
终止 忽略 执行handler
│ │
↓ ↓
清除pending标记
继续执行
代码验证三表结构:
// 查看task_struct中的信号相关字段(2.6.18内核)
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand; // 信号处理函数表(用户空间)
sigset_t blocked; // 信号屏蔽字
struct sigpending pending; // 未决信号集
...
};
struct sigpending {
struct list_head list; // 链表(用于实时信号)
sigset_t signal; // 未决信号位图
};
理解:
这三张表就像一个"信号管理系统":
- blocked:黑名单,列出哪些信号暂时不处理
- pending:待办清单,列出哪些信号等着处理
- sighand:操作手册,列出每种信号该怎么处理
当信号产生时,OS先把它加到pending清单,然后检查blocked黑名单,最后查sighand手册决定怎么处理。
3-3 sigset_t - 信号集
为什么需要sigset_t?
每个信号只需要1bit来表示"有/无",但31个信号需要31bit。sigset_t就是用来存储这些bit的位图。
// sigset_t的定义(通常是一个包含unsigned long的结构体)
typedef struct {
unsigned long sig[1]; // 对于32位系统,1个unsigned long就够了
} sigset_t;
重要特性:
- 每个信号只占1bit,不记录信号产生了多少次
- 如果同一信号产生多次,pending中只记录一次
- 这意味着信号可能丢失(不排队)
简单提一下,信号屏蔽字(Signal Mask):
blocked表也叫做"信号屏蔽字",这里的"屏蔽"应该理解为"阻塞",不是"忽略"。
信号屏蔽字 = blocked表 = 阻塞信号集
3-4 信号集操作函数
由于sigset_t是位图,直接操作bit比较麻烦,OS提供了一组函数:
#include <signal.h>
// 初始化信号集
int sigemptyset(sigset_t *set); // 清空所有bit(初始化为空集)
int sigfillset(sigset_t *set); // 置位所有bit(初始化为满集)
// 增删信号
int sigaddset(sigset_t *set, int signo); // 添加某个信号
int sigdelset(sigset_t *set, int signo); // 删除某个信号
// 查询
int sigismember(const sigset_t *set, int signo); // 判断某个信号是否在集合中
使用示例:
#include <iostream>
#include <signal.h>
int main()
{
sigset_t set;
// 初始化(必须!)
sigemptyset(&set);
// 添加SIGINT(2)和SIGQUIT(3)
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);
// 查询
std::cout << "SIGINT in set: " << sigismember(&set, SIGINT) << std::endl; // 1
std::cout << "SIGQUIT in set: " << sigismember(&set, SIGQUIT) << std::endl; // 1
std::cout << "SIGTERM in set: " << sigismember(&set, SIGTERM) << std::endl; // 0
// 删除
sigdelset(&set, SIGINT);
std::cout << "SIGINT in set: " << sigismember(&set, SIGINT) << std::endl; // 0
return 0;
}
注意: 在使用sigset_t之前,必须调用sigemptyset或sigfillset初始化!
3-4-1 sigprocmask - 修改信号屏蔽字
#include <signal.h>
// how: 操作方式
// set: 要操作的信号集
// oset: 输出原来的屏蔽字(可选)
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
| how | 含义 | 等价操作 |
|---|---|---|
| SIG_BLOCK | 添加到屏蔽字 | mask = mask | set |
| SIG_UNBLOCK | 从屏蔽字中移除 | mask = mask & ~set |
| SIG_SETMASK | 直接设置屏蔽字 | mask = set |
示例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
int main()
{
sigset_t block_set, old_set;
// 初始化
sigemptyset(&block_set);
sigemptyset(&old_set);
// 添加SIGINT到block_set
sigaddset(&block_set, SIGINT);
// 屏蔽SIGINT,并保存原来的屏蔽字到old_set
sigprocmask(SIG_BLOCK, &block_set, &old_set);
std::cout << "SIGINT已被屏蔽,Ctrl+C将无效" << std::endl;
sleep(5);
// 恢复原来的屏蔽字
sigprocmask(SIG_SETMASK, &old_set, &block_set);
std::cout << "SIGINT已解除屏蔽" << std::endl;
return 0;
}
3-4-2 sigpending - 读取未决信号集
#include <signal.h>
// 读取当前进程的未决信号集,通过set参数传出
int sigpending(sigset_t *set);
完整实验:观察信号从产生到递达的全过程
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
// 打印pending信号集(31个信号,从高到低)
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; // 该信号在pending中
}
else
{
std::cout << 0; // 该信号不在pending中
}
}
std::cout << "\n";
}
// 信号处理函数
void handler(int signo)
{
std::cout << signo << " 号信号被递达!!!" << std::endl;
}
int main()
{
// 0. 捕捉2号信号(SIGINT)
signal(SIGINT, handler);
// 1. 屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
int cnt = 15;
while (true)
{
// 2. 获取当前进程的pending信号集
sigset_t pending;
sigpending(&pending);
// 3. 打印pending信号集
PrintPending(pending);
cnt--;
// 4. 15秒后解除对2号信号的屏蔽
if (cnt == 0)
{
std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
sigprocmask(SIG_SETMASK, &old_set, &block_set);
}
sleep(1);
}
}
运行过程分析:
前15秒,SIGINT被屏蔽:
curr process[448336] pending: 0000000000000000000000000000000 ← 没有信号
curr process[448336] pending: 0000000000000000000000000000000
用户按下Ctrl+C(此时SIGINT被屏蔽):
curr process[448336] pending: 0000000000000000000000000000010 ← SIGINT在pending中!
↑第2位是1,表示SIGINT待处理
curr process[448336] pending: 0000000000000000000000000000010 ← 保持pending
...
(一直显示pending,因为被阻塞,无法递达)
15秒后,解除屏蔽:
解除对2号信号的屏蔽!!!
2 号信号被递达!!! ← SIGINT被处理!
关键观察:
- 信号被阻塞时,会一直保持在pending状态
- 解除阻塞后,信号立即递达
- 处理函数执行后,pending标记被清除
理解:
这就像你告诉快递员"先别送",快递一直在快递站(pending状态)。当你解除阻塞(说"可以送了"),快递立即被送来(递达)。
4. 捕捉信号

4-1 信号捕捉的完整流程(重点!)
这是信号机制最核心的流程图:

用户态 内核态
┌──────────────┐ ┌──────────────────┐
│ int main() │ │ │
│ { │ ──中断/异常──→ │ 1. 进入内核态 │
│ while(1) │ │ (系统调用/ │
│ ... │ │ 时钟中断/ │
│ } │ │ 异常) │
│ │ │ │
│ │ │ 2. 内核处理完异常 │
│ │ │ 准备返回用户态 │
│ │ │ 之前,检查 │
│ │ │ pending信号 │
│ │ │ │
│ │ ←──do_signal()──│ 3. 发现有待处理 │
│ │ │ 信号,调用 │
│ │ │ do_signal() │
│ │ │ │
│ void │ │ 4. 检查handler表 │
│ sighandler() │ ←──────────────│ 发现是自定义 │
│ { │ │ 函数,修改 │
│ ... │ │ 返回地址为 │
│ 处理信号 │ │ sighandler │
│ } │ │ │
│ │ ──sigreturn──→ │ 5. sighandler执行 │
│ │ │ 完毕,执行 │
│ │ │ sigreturn │
│ │ │ 系统调用 │
│ │ ←──────────────│ 6. 返回用户态 │
│ main继续执行 │ │ 从上次被中断 │
│ │ │ 的地方继续 │
└──────────────┘ └──────────────────┘
详细步骤分解:
-
用户态执行:进程在用户态正常运行(比如执行while循环)
-
进入内核态:因为以下原因进入内核态:
- 系统调用(如read、write)
- 时钟中断(调度器)
- 异常(如缺页、除零)
-
内核处理:内核处理完相应的事件
-
检查pending :在返回用户态之前,内核调用
do_signal()检查是否有待处理的信号 -
信号递达:如果有待处理信号,根据handler表决定处理方式:
- SIG_DFL:执行默认动作
- SIG_IGN:忽略
- 自定义函数:修改返回地址为handler函数
-
执行handler:返回用户态,执行handler函数
-
sigreturn:handler执行完后,执行sigreturn系统调用,再次进入内核态
-
恢复执行:内核恢复原来的上下文,从被中断的地方继续执行
关键理解:
信号处理不是"立即"的,而是在"返回用户态之前"检查并处理。这就是为什么:
- 在死循环中不调用任何系统调用,信号可能永远不会被处理
- 信号处理函数是在用户态执行的,不是在内核态

4-2 sigaction - 更强大的信号注册
#include <signal.h>
// signo: 信号编号
// act: 新的处理方式(输入)
// oact: 旧的处理方式(输出,可选)
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
struct sigaction {
void (*sa_handler)(int); // 处理函数(与signal的handler相同)
sigset_t sa_mask; // 处理期间额外屏蔽的信号
int sa_flags; // 标志位(通常设为0)
void (*sa_sigaction)(int, siginfo_t *, void *); // 高级处理函数(本章不讨论)
};
sa_mask的作用:
当信号处理函数执行时,当前信号会被自动屏蔽(防止嵌套)。sa_mask可以指定额外屏蔽的信号。
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
std::cout << "进入handler,信号 " << signo << " 被处理" << std::endl;
sleep(5); // 模拟处理耗时
std::cout << "handler执行完毕" << std::endl;
}
int main()
{
struct sigaction act;
// 初始化
sigemptyset(&act.sa_mask);
// 在处理SIGINT时,额外屏蔽SIGQUIT
sigaddset(&act.sa_mask, SIGQUIT);
act.sa_handler = handler;
act.sa_flags = 0;
// 注册信号处理
sigaction(SIGINT, &act, NULL);
while(true)
{
pause(); // 等待信号
}
return 0;
}
运行效果:
$ ./a.out
^C进入handler,信号 2 被处理
^\handler执行完毕 # Ctrl+\被屏蔽,不会中断handler
sigaction vs signal:
| 特性 | signal | sigaction |
|---|---|---|
| 可移植性 | 不同系统行为可能不同 | POSIX标准,行为一致 |
| 信号屏蔽 | 处理期间自动屏蔽当前信号 | 可以自定义额外屏蔽 |
| 高级功能 | 不支持 | 支持sa_sigaction |
| 推荐使用 | 简单场景 | 生产环境 |
4-3 操作系统是怎么运行的(重要背景知识)
要理解信号捕捉,必须理解OS的运行机制!
4-3-1 硬件中断

用户程序执行中
↓
外部设备(键盘、磁盘、网卡等)产生中断信号
↓
CPU暂停当前执行,跳转到中断向量表
↓
执行中断处理程序(OS代码)
↓
中断处理完毕,恢复原程序执行
中断向量表:
- 启动时加载到内存
- 包含所有中断处理程序的入口地址
- 类似函数指针数组
4-3-2 时钟中断
问题: 外部中断需要用户或设备触发,OS自己怎么被"推动"执行?
答案: 时钟中断!

CPU内部有一个定时器,每隔固定时间(如1ms)产生一次中断
↓
时钟中断 → 调度器被调用
↓
调度器决定:继续当前进程 or 切换到其他进程
↓
进程切换发生!
这就是进程"并发执行"的本质: 不是真正的并行,而是时钟中断驱动的快速切换!
4-3-3 OS的本质:死循环
// Linux内核主循环(简化)
void main(void)
{
for (;;) // 死循环
pause(); // 等待中断
}
OS不主动做任何事情,它只是:
- 初始化各种数据结构
- 注册中断处理程序
- 进入死循环等待中断
- 中断来了就执行对应的处理程序
4-3-4 软中断(系统调用)
问题: 用户程序怎么主动请求OS服务?
答案: 软中断(int 0x80 或 syscall)

; 系统调用过程(x86 32位)
mov eax, 4 ; 系统调用号(write = 4)
mov ebx, 1 ; 文件描述符(stdout)
mov ecx, buffer ; 缓冲区地址
mov edx, length ; 长度
int 0x80 ; 触发软中断,进入内核态
; 返回值在eax中
系统调用的本质:
- 用户把参数放到寄存器
- 执行int 0x80,CPU进入内核态
- OS根据系统调用号查表,执行对应函数
- 把返回值放到寄存器,返回用户态
系统调用号 = 数组下标!
4-3-5 缺页异常
缺页不是"错误",是正常机制!
程序访问虚拟地址
↓
MMU查页表,发现该页不在物理内存中
↓
触发缺页异常(软中断)
↓
OS缺页处理程序被调用
↓
从磁盘加载该页到物理内存
↓
更新页表,重新执行访问指令
除零、野指针也是类似:
- 除零:CPU运算单元异常 → 软中断 → OS处理 → 发送SIGFPE
- 野指针:MMU地址转换失败 → 软中断 → OS处理 → 发送SIGSEGV
4-4 内核态和用户态
虚拟地址空间布局(32位Linux)
┌────────────────────────────────┐ 4GB
│ │
│ 内核空间(1GB) │
│ 只有内核态可以访问 │
│ 所有进程共享同一份内核代码 │
│ │
├────────────────────────────────┤ 3GB (0xC0000000)
│ │
│ 栈(向下增长) │
│ ... │
│ 堆(向上增长) │
│ BSS段 │
│ 数据段 │
│ 代码段 │
│ │
│ 用户空间(3GB) │
│ 每个进程独立 │
│ │
└────────────────────────────────┘ 0GB
CPL(Current Privilege Level):
- CPU中有一个寄存器标志当前特权级
- ring 0:内核态,可以执行所有指令
- ring 3:用户态,只能执行普通指令
用户态 → 内核态的三种情况:
| 情况 | 触发方式 | 示例 |
|---|---|---|
| 系统调用 | int 0x80 / syscall | read、write、fork |
| 异常 | CPU自动触发 | 除零、缺页、野指针 |
| 外部中断 | 硬件设备触发 | 键盘、磁盘、时钟 |
切换流程详解:
用户态执行
↓
触发原因(系统调用/异常/中断)
↓
CPU自动完成:
1. 保存用户态寄存器到内核栈
2. 切换CPL为0(内核态)
3. 跳转到中断处理程序
↓
执行内核代码
↓
准备返回用户态
↓
检查pending信号(do_signal)
↓
恢复用户态寄存器
切换CPL为3(用户态)
↓
继续用户程序执行
我的理解:
内核态和用户态就像两个世界:
- 用户态是普通人的世界,只能做普通事情
- 内核态是管理员的世界,可以做任何事情
进入内核态需要"门"(系统调用/异常/中断),出来时OS会检查"有没有待办事项"(pending信号)。这就是为什么信号处理发生在返回用户态之前。
4-5 信号捕捉的时机
什么时候检查pending信号?
答案:每次从内核态返回用户态之前!
系统调用返回
时钟中断返回
异常处理返回
↓
检查pending信号(do_signal)
↓
有信号? → 递达处理 → 返回用户态(执行handler)
↓
无信号? → 直接返回用户态(继续原程序)
这解释了一个现象:
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
// 处理信号
}
int main()
{
signal(SIGINT, handler);
// 死循环,没有系统调用
while(1)
{
// 纯CPU计算,不调用任何系统调用
// 信号可能永远不会被处理!
}
}
为什么信号不被处理?
因为while循环中没有系统调用,没有中断,CPU一直在用户态执行。OS没有机会检查pending信号。
解决方法:
while(1)
{
// 方法1:调用pause(),主动让出CPU,等待信号
pause();
// 方法2:调用其他系统调用,如read、write
// write(1, ".", 1);
}
面试题与详细解答
面试题1:信号的阻塞和忽略有什么区别?
答:
阻塞(Block)和忽略(Ignore)是完全不同的概念:
| 对比项 | 阻塞 | 忽略 |
|---|---|---|
| 信号状态 | 保持在pending | 立即递达 |
| 处理动作 | 不处理,等待解除阻塞 | 执行SIG_IGN(什么都不做) |
| pending位 | 置1,解除阻塞后才清除 | 递达后立即清除 |
| 代码设置 | sigprocmask设置blocked表 | signal(sig, SIG_IGN)设置handler表 |
// 阻塞:信号来了但不处理,等着
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL);
// 忽略:信号来了,处理方式是"什么都不做"
signal(SIGINT, SIG_IGN);
面试题2:信号什么时候被处理?为什么说信号是"异步"的?
答:
信号处理时机: 每次从内核态返回用户态之前,OS调用do_signal()检查并处理pending信号。
信号是异步的原因:
-
信号产生时机不确定:用户可能在任何时候按Ctrl+C
-
信号处理时机不确定:信号不会立即处理,要等到返回用户态
-
信号处理函数打断正常流程:main函数执行到一半,突然跳到handler
异步的体现:
main: 语句1 → 语句2 → [信号到来] → 跳转handler → handler执行完 → 返回语句2继续
这就是"异步"------事件的发生和处理不是同步的,中间有时间窗口。
面试题3:为什么在信号处理函数中调用某些函数是不安全的?
答:
这涉及到"可重入函数"的概念(下一批会详细讲)。
不安全的函数:
- 使用了全局数据结构(如malloc的链表)
- 使用了静态局部变量
- 标准IO函数(printf、fprintf等)
不安全的原因:
int main()
{
// 正在调用malloc分配内存
ptr = malloc(100); // 步骤1完成
// 此时信号到来!跳转到handler
// ...
}
void handler(int sig)
{
// handler中也调用malloc
ptr2 = malloc(200); // 可能破坏malloc的内部数据结构!
}
解决方案:
- 在handler中只调用"异步信号安全"的函数(man 7 signal-safety)
- 或者设置标志位,在main循环中检查
面试题4:如何用sigaction实现更安全的信号处理?
答:
#include <iostream>
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t g_flag = 0; // 信号安全的全局变量
void handler(int signo)
{
g_flag = 1; // 只设置标志,不做复杂操作
}
int main()
{
struct sigaction act;
sigemptyset(&act.sa_mask);
act.sa_handler = handler;
act.sa_flags = 0;
// 使用sigaction注册,更可靠
sigaction(SIGINT, &act, NULL);
while(true)
{
// 主循环检查标志
if(g_flag)
{
std::cout << "收到信号,处理业务逻辑" << std::endl;
g_flag = 0;
}
// 正常业务
sleep(1);
}
return 0;
}
关键点:
- 使用
volatile sig_atomic_t类型的全局变量 - handler中只设置标志,不做复杂操作
- 主循环中检查标志并处理
面试题5:为什么fork出来的子进程会继承父进程的信号处理方式?
答:
因为fork会复制父进程的PCB,包括:
- blocked表(信号屏蔽字)
- pending表(通常为空)
- sighand表(信号处理函数指针)
但要注意:
-
子进程的pending表是空的(父进程的pending不会继承)
-
子进程的blocked表和sighand表与父进程相同
-
exec系列函数会将自定义handler重置为SIG_DFL(因为代码段被替换)
#include <iostream>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{
std::cout << "进程 " << getpid() << " 收到信号 " << sig << std::endl;
}int main()
{
signal(SIGINT, handler);if(fork() == 0) { // 子进程继承了handler std::cout << "子进程PID: " << getpid() << std::endl; sleep(5); exit(0); } // 父进程 wait(NULL); return 0;}
运行结果:
$ ./a.out
^C子进程PID: 12345 # 子进程也处理了SIGINT!
进程 12345 收到信号 2
进程 12344 收到信号 2
面试题6:时钟中断和进程调度的关系是什么?
答:
时钟中断是进程调度的"发动机":
-
时钟中断触发:每隔固定时间(如1ms或10ms)产生一次时钟中断
-
调度器被调用:时钟中断处理程序中会调用调度器
-
时间片检查:调度器检查当前进程的时间片是否用完
-
进程切换:如果时间片用完,调度器选择下一个进程运行
-
上下文切换:保存当前进程的寄存器,加载下一个进程的寄存器
时钟中断 → 调度器 → 检查时间片 → 进程切换 → 恢复执行
没有时钟中断会怎样?
如果没有时钟中断,一个进程可能永远占用CPU(除非它主动让出)。这就是为什么早期的协作式多任务系统(如Windows 3.1)容易"卡死"------一个程序不配合,整个系统就挂了。
现代OS使用抢占式多任务,时钟中断强制剥夺CPU控制权,保证每个进程都能公平运行。
第二批讲解完成!涵盖:
核心知识点:
- 信号三表:blocked、pending、sighand
- 信号处理流程:产生→pending→检查blocked→递达
- 信号集操作函数
- sigprocmask修改屏蔽字
- sigpending读取未决集
- 信号捕捉流程(用户态/内核态切换)
- OS运行机制(中断、时钟、死循环)
- 内核态/用户态
面试题(6道):
- 阻塞 vs 忽略的区别
- 信号处理时机和异步性
- 信号处理函数的安全性
- sigaction的使用
- fork与信号继承
- 时钟中断与调度