Linux 服务器高阶编程(11):信号基础——触发、未决、屏蔽与处理方式
一、信号是什么,与 Qt 信号槽有什么联系
信号是一种通知机制。先从几个直观效果引入:结束进程、暂停进程、让停止的进程继续运行,以及通知进程某些条件已经发生。
注意“通知发生”和“最终怎么做”不是同一件事。以 SIGINT 为例,默认设置下它会结束进程,但程序可以修改相应处理方式,让这个信号到来时执行自定义函数。
因此围绕三层问题展开:
- 什么事件使信号产生?
- 信号是否可以立即递达?
- 递达后采用什么处理方式?
二、查看信号列表:名字与编号要一起记
2.1 kill -l
kill -l这里是小写字母 l,表示列出信号,不是数字 1。
重点说明:
1~31:主要讨论的 UNIX 经典信号。34~64:环境中列出的实时信号范围。32、33:未直接显示,为 NPTL 线程实现保留使用。
2.2 反复使用的信号
| 名称 | 编号 | 用途 |
|---|---|---|
SIGINT | 2 | Ctrl+C;后续屏蔽、忽略和捕捉的主要对象 |
SIGQUIT | 3 | Ctrl+\,观察退出与核心转储提示 |
SIGABRT | 6 | 介绍 abort() 时提到 |
SIGBUS | 7 | 总线错误相关异常 |
SIGFPE | 8 | 运算异常 |
SIGKILL | 9 | 指定目标进程结束 |
SIGSEGV | 11 | 非法内存访问相关异常 |
SIGPIPE | 13 | 管道没有读端后继续写入 |
SIGALRM | 14 | alarm() 到期通知 |
SIGCONT | 18 | 继续已经停止的进程 |
SIGSTOP | 19 | 停止进程,与终端暂停信号对照 |
SIGTSTP | 20 | Ctrl+Z 暂停前台作业 |
三、第一种触发方式:终端组合键
3.1 先运行一个不会立即结束的程序
创建 while.c,核心是一个无限循环。作用不是完成业务,而是让程序一直留在前台,给终端信号留出测试时间。
核心片段:
while(1);编译运行后,Shell 暂时不能继续处理新的命令输入;结束或暂停前台程序后,提示符才会重新出现
3.2 三组按键与三个信号
| 操作 | 信号 | 效果 |
|---|---|---|
Ctrl+C | SIGINT | 终止前台作业 |
Ctrl+\ | SIGQUIT | 终止,并观察到核心转储相关提示 |
Ctrl+Z | SIGTSTP | 停止运行,但进程还存在 |
Ctrl+\ 的第二个键是反斜杠,不是正斜杠 /。
对照 Ctrl+C 与 Ctrl+\ 的输出,说明“都结束程序”不代表默认动作完全一样。核心转储可为后续调试提供材料;实际是否生成文件,还取决于系统设置,不能仅凭某条提示推断当前目录必然出现 core 文件。
3.3 暂停不是结束
R:运行或可运行。T:停止。+:在本次观察中用于识别终端前台进程组成员。
3.4 jobs、fg、bg
恢复运行:
jobs查看当前 Shell 管理的作业及其编号,再根据需要恢复到前台或后台
fg %1bg %1%1 是作业号引用,不是 PID
fg:让作业在前台继续运行,Shell 通常继续等待它。bg:让停止的作业在后台继续运行,Shell 可继续接收命令。
3.5 为什么快捷键不能直接结束那个后台作业
终端按键没有让用户输入 PID,但仍知道信号该发给谁,因为它针对这个终端的前台对象。
把作业转到后台后,再按 Ctrl+C,不能期待原后台作业像先前一样结束
3.6 线路规程示意
普通字符输入与具有特殊含义的终端字符,不一定走相同的处理路线。
终端输入 ├─ 普通字符 → 作为输入数据供程序读取 └─ 特殊控制字符 → 终端驱动/线路规程识别 → 产生相应信号这是用来解释为什么终端里的 Ctrl+C 通常不是普通“复制”。同一组键在不同应用上下文里,可能具有不同作用。
3.7 当场验证
kill -3 目标PID这里的“目标PID”是占位说明,实际输入时替换为自己的测试进程编号
四、第二、三种触发方式:命令与函数
4.1 kill 命令不是只能“杀”
kill -signo pidsigno 表示信号编号,pid 表示目标进程。它的核心功能是发送信号,最终是否终止,要由这个信号及其处理方式决定。
4.2 kill() 与命令的参数顺序不同
命令写“信号、目标”,函数则写“目标、信号”。
标准接口补充:
#include <signal.h>
int kill(pid_t pid, int sig);以指定正 PID 为主理解,成功返回 0,失败返回 -1。“任意进程、任意信号”概括选择能力,但仍受权限与目标存在性限制,不能据此认为可以无条件控制任何进程。
4.3 raise() 与 abort()
| 函数 | 重点 |
|---|---|
kill(pid, sig) | 选择目标及信号 |
raise(sig) | 向调用者自身发送指定信号 |
abort() | 与自身 SIGABRT 异常终止有关 |
标准声明是 void abort(void);,头文件为 <stdlib.h>
自己发信号并不一定没用;若某个可捕捉信号绑定了处理函数,可以利用通知触发处理
但后面的 SIGINT,不是完成一个基于 abort() 的定时业务框架
五、第四种触发方式:硬件或执行异常
5.1 SIGSEGV:从只读内存写入理解
以对只读内存进行写操作说明非法访问。程序违反访问约束时,可能收到 SIGSEGV,并出现段错误与核心转储提示。
地址是否有效、对象是否仍存活、是否越过合法访问范围、是否向不允许写入的区域写入。
5.2 SIGBUS:不能机械等同所有越界
越界、栈和野地址问题与总线错误联系起来,目的是帮助建立“异常信息能缩小排错范围”的意识
数组越界、栈溢出或野指针属于不合法访问或未定义行为,不能保证统一产生 SIGBUS
也可能出现 SIGSEGV、没有立即崩溃或其他结果
5.3 SIGFPE:除零演示
设置整数变量后进行除零计算。编译器给出警告,继续运行后观察到运算异常相关结果。
开发者未必会故意写一个除零表达式,但环形队列等代码中,若参与取模的大小意外为 0,也可能走到相似错误。
5.4 看错误信息能做什么
它不能自动指出唯一一行错误,但可以给出排查方向:
- 内存类异常:检查指针、对象生存期、权限和边界。
- 运算异常:检查除数、取模分母、输入与计算条件。
- 保留并分析编译警告,不要因为能生成可执行文件就认为代码正确。
六、第五种触发方式:软条件与 alarm()
6.1 管道条件与 SIGPIPE
管道读端关闭后,写入者继续向管道写入,会触发 SIGPIPE,默认可能终止写进程
6.2 alarm() 到期不等于直接调用自己的函数
定时到期后,系统产生 SIGALRM。如果没有改变默认处理,它会终止进程。因此只调用 alarm(),并不会自动获得一个“时间到后正常继续”的业务定时器。
从这里联系 sleep 的效果:定时、等待、唤醒以及对信号处理方式的设置,需要放在同一个逻辑中理解
6.3 alarm.c 核心片段
int main(void){ int cont = 0; alarm(1); while(1) printf("++cont = %d\t",++cont);}这是代码片段,不是声称只有这些行便是完整源文件。alarm() 和 printf() 分别需要相应声明。
程序不断计数并打印,约一秒后因默认 SIGALRM 行为结束。不同机器和运行条件下,最后显示的数值不同。
6.4 为什么不能拿最后的计数当作 CPU 基准
循环还包含格式化输出、缓冲和终端显示等开销,最后可见的计数不等于处理器每秒能做多少次纯算术操作。
应当得出的结论是“这个混合了输出的程序不是准确的 CPU 性能测试”,而不是根据某一次显示值判断电脑真假或好坏
对于这段代码,++cont 本身也在循环内,不能进一步假定它实际增长到了一个未显示出来的任意巨大数值
6.5 alarm() 返回值
接口:
#include <unistd.h>unsigned int alarm(unsigned int seconds);“剩余未定时完的秒数”解释返回值,但容易与阻塞等待函数混淆。
alarm() 设置后立即返回;返回的是此前闹钟被替换或取消时剩余的秒数,没有此前闹钟则返回 0。alarm(0) 可取消原闹钟。返回 0 不表示刚设的闹钟已经等到时间,不能套一个反复调用的循环来等待它返回 0。
七、信号从产生到处理:未决、屏蔽、递达
7.1 先区分状态和处理策略
- 产生:发生了应当通知目标的事件。
- 未决:信号已经产生,尚未递达处理。
- 递达:开始根据当前处理方式处置信号。
“已经发送”不等于“接收方已经执行了处理函数”。阻塞某个信号时,通知可以先处于未决状态。
7.2 SIGINT
SIGINT 产生 ↓存在待处理通知:处于未决 ↓检查是否阻塞 SIGINT ├─ 阻塞 → 暂不递达,保持等待 └─ 允许 → 递达,按当前处理方式执行 ├─ 默认 ├─ 忽略 └─ 自定义捕捉用“两扇门之间”描述被阻塞的未决信号。这是概念图,不是说内核真的有一个让信号对象通过两个数组格子的空间。
7.3 相同经典信号为什么不会累计成无限队列
以连续多次产生相同信号说明:已有一个未决的经典信号时,后续同类通知不按发生次数分别排队。
所以连续产生十次,不应直接推断以后会执行十次处理函数。更适合记为“标准信号可能合并”,而不是把它设计成可靠事件计数器。
对比实时信号,用连续按“下一首”的类比解释:某些事件需要记录多次发生,因此需要队列语义。
标准信号与实时信号的队列语义不同;不能把“信号都不排队”推广到全部信号。
7.4 想屏蔽与已经来了,是两个问题
要查看“我配置了要屏蔽哪些信号”,还是“哪些信号已经产生但尚未处理”?
- 看屏蔽集合:知道阻塞策略,不能证明通知已经发生。
- 看未决集合:知道哪些通知在等待,不能仅凭它判断全部阻塞策略。
7.5 默认集合不一定永久全零
不能把图当成所有进程在任何时刻的状态保证。信号屏蔽状态涉及继承和运行中的修改;多线程场景中屏蔽掩码还具有线程属性
八、设置屏蔽字:创建、初始化、设置、替换
8.1 替换原则
先准备新的对象,再替换旧对象;旧对象可以保存,便于恢复,也可以不保存。
对应到屏蔽集合:
- 声明新的
sigset_t对象。 - 初始化。
- 把需要阻塞的信号加入集合。
- 用
sigprocmask()将其应用到当前执行上下文。 - 若后续要恢复,提前保存旧集合。
不要只声明并修改一个局部集合,就认为系统屏蔽策略已经改变;最后的应用步骤不能遗漏。
8.2 信号集操作函数
| 接口 | 作用 |
|---|---|
sigemptyset(&set) | 初始化为空集合 |
sigfillset(&set) | 初始化为包含信号的集合 |
sigaddset(&set, signo) | 加入某信号 |
sigdelset(&set, signo) | 移除某信号 |
sigismember(&set, signo) | 查询是否属于集合 |
这些操作需要 <signal.h>。sigismember() 的正常结果是 1 或 0,错误时为 -1;不能只把所有返回值都理解为一个合法位码。
特别注意,sigdelset() 从一个局部集合移除信号,并不自动修改系统当前的屏蔽字;只有把修改后的集合应用回去,才完成了这套设置流程。
8.3 sigprocmask() 的三个位置
sigprocmask(SIG_SETMASK, &newset, &oldset);- 第一个参数:采用替换集合的方式。
- 第二个参数:新的集合地址。
- 第三个参数:接收此前集合的地址;不保存时传
NULL。
传 NULL 表示不向用户变量输出旧值,不是将某个内核对象粗暴“扔掉”。若以后需要恢复,保存 oldset 的意义就在于不用猜测原来有哪些信号被阻塞。
8.4 屏蔽 SIGINT 的画面代码
保留 newset、oldset 及 NULL 的写法:
#include <signal.h>//屏蔽sigint(2)int main(void){ /*声明屏蔽字 newset , oldset*/ sigset_t newset, oldset; sigemptyset(&newset);//初始化 sigaddset(&newset, SIGINT); //设置屏蔽2信号 sigprocmask(SIG_SETMASK,&newset, NULL);//替换进程屏蔽字,使用newset while(1);}这虽然声明了 oldset,实际调用却传 NULL,所以并没有保存旧集合。不能仅看变量存在,就说已经完成了恢复逻辑。
8.5 测试观察与清理
运行后按 Ctrl+C,程序不会像此前的无限循环那样立即退出,因为 SIGINT 被阻塞,不能按默认方式递达。
while(1); 的作用是留下测试时间。若程序立即结束,便来不及按键验证,不能据此认为屏蔽没有作用。
测试时还要准备结束这个程序的办法,例如另一个终端对自己的测试 PID 发送未被阻塞的终止信号。不要因为 Ctrl+C 无效就把系统误判为失去响应。
SIGKILL 与 SIGSTOP 不能被阻塞、忽略或捕捉
九、三种处理方式与默认方式下的五种动作
9.1 三种方式
| 处理方式 | 如何设置理解 | 递达后发生什么 |
|---|---|---|
| 默认 | SIG_DFL | 执行该信号预设的默认动作 |
| 忽略 | SIG_IGN | 不执行通常的终止等动作 |
| 捕捉 | 安装处理函数 | 调用用户定义的信号处理函数 |
“三种方式”不是说一个信号到来后依次把三种都执行一次,而是根据当前设置选择对应的处理。
9.2 默认方式并不只有终止
| 默认动作名称 | 含义 | 关联 |
|---|---|---|
Term | 终止 | SIGINT 的默认结果 |
Core | 终止并涉及核心转储 | SIGQUIT 与一些异常信号 |
Stop | 停止 | SIGTSTP、SIGSTOP |
Cont | 继续 | SIGCONT |
Ign | 默认忽略 | 通知不会按终止或停止处理 |
这是默认动作分类,不是五种额外的用户回调接口
9.3 “忽略方式”和“默认忽略动作”
先决定走默认、忽略还是捕捉分支;只有走默认分支时,才查看该信号默认属于哪类动作。
因此 SIG_DFL 不等于“必定终止”。如果某信号本来默认忽略,选择默认方式仍可能是不做终止动作。也不要据此认为把任何信号显式设置为 SIG_IGN,都和保持其默认方式在所有细节上相同。
十、sigaction():像换屏蔽字一样换处理方式
10.1 仍然是替换原则
- 声明新的
struct sigaction。 - 配置本次要采用的处理方式。
- 初始化相关集合和选项。
- 对指定信号安装新设置。
- 按需要保存旧设置,便于恢复。
这与屏蔽集合的流程相似,但替换对象不同:一个决定是否允许递达,一个决定递达后如何处理。
10.2 三个重点成员
| 成员 | 设置 |
|---|---|
sa_handler | SIG_DFL、SIG_IGN 或处理函数名三选一 |
sa_flags | 设置为 0 |
sa_mask | 使用 sigemptyset() 初始化 |
文档画面曾用 SIG_DFL|SIG_IGN|sig_do 并列展示三个选择
10.3 安装与保存旧方式
实际变量名为 act、oact:
sigaction(SIGINT,&act,&oact);SIGINT:要修改哪个信号。&act:新的行为设置。&oact:取得旧设置;若确实不需要,可按接口约定不保存。
通过同一个测试文件依次修改 sa_handler,不是要求每种方式都另建一套完整工程。
10.4 三次测试分别修改什么
第一次:
act.sa_handler = SIG_DFL;运行后按 Ctrl+C,仍按 SIGINT 默认方式结束。
第二次:
act.sa_handler = SIG_IGN;运行后按 Ctrl+C,程序不因该信号结束。又使用 Ctrl+\ 结束测试
第三次:
act.sa_handler = sig_do;信号到来时执行自己定义的处理函数,观察函数输出,程序不因原来的默认终止动作而退出。
十一、屏蔽、忽略、捕捉:外观看似相同,内部意义不同
这三种设置都可能表现为“按 Ctrl+C 后程序没有退出”,但不能据此说它们是一回事。
| 比较项 | 屏蔽 | 忽略 | 捕捉 |
|---|---|---|---|
| 改变什么 | 允许递达的时机 | 信号处理方式 | 信号处理方式 |
| 是否只是暂缓 | 是,信号可保持未决 | 不是用屏蔽来等待 | 不是用屏蔽来等待 |
| 是否执行用户处理函数 | 不因单纯屏蔽而执行 | 不执行自定义处理 | 执行已安装的处理函数 |
| 为什么没按默认动作退出 | 信号尚不能递达 | 没有采用原来的终止动作 | 改为执行用户函数 |
| 后续要注意 | 解除阻塞可能使未决信号递达 | 不可把它当成暂存通知 | 仍受信号合并与处理函数约束影响 |
用“是否已经处理”突出区别:屏蔽是延迟,忽略和捕捉则不是将同一条通知长期卡在原来的等待位置。
如果这篇文章对你有帮助,欢迎分享给更多人!





