Linux 进程信号(上):信号产生方式与闹钟机制
1. 理解信号是什么,为什么要有?
1.1 信号的本质与分类
信号(Signal)是操作系统向进程发送的通知消息。它本质上是一种异步的通信机制。所谓异步,意味着通知的到来与进程当前的执行状态不同步——就像你正在打游戏,外卖小哥敲门送饭,这个敲门声就是一个异步通知,你不需要停下来等外卖员准备好再开始游戏。
在 Linux 中,信号分为普通信号和实时信号。通过 kill -l 可以查看系统支持的信号列表。常用的主要是编号 1 到 31 的普通信号,以及 34 到 64 的实时信号(带 RT 后缀)。我们日常开发中主要关注前 31 个。
1.2 信号的处理机制
进程收到信号后,不会立即处理,而是根据预设的策略来决定行为。这就像红绿灯:红灯亮时,默认是停车;如果你选择忽略,可能继续行驶;或者自定义一个动作,比如跳舞。具体来说,信号处理有三种默认策略:
- 默认(Default):如终止进程、忽略或 Core Dump。
- 忽略(Ignore):明确丢弃该信号。
- 自定义(Custom):注册信号处理函数(Signal Handler)来接管逻辑。
由于信号可能随时到达,而进程可能正忙(比如在处理高优先级任务),所以内核必须为每个进程提供临时保存信号的能力。这个能力通常体现在进程的 PCB(进程控制块)中的位图里,只有操作系统有权修改这些内核数据结构。
1.3 信号的识别与存储
信号在代码中通常用宏定义表示,如 SIGINT、SIGKILL 等,对应特定的整数编号。头文件 <signal.h> 中定义了这些常量。为了高效管理多个信号是否发生,内核使用位图(Bitmap)记录,每一位代表一个信号编号,值为 1 表示已到达,0 表示未到达。
1.4 如何自定义捕捉信号?
我们可以通过 signal() 函数来设置特定信号的处理动作。例如,将 2 号信号 SIGINT(通常由 Ctrl+C 触发)的处理方式从默认的终止改为自定义函数。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig) {
printf("Caught signal %d\n", sig);
}
int main() {
// 注册信号处理函数,当 SIGINT 发生时调用 handler
signal(SIGINT, handler);
while(1) {
sleep(1);
}
return 0;
}
注意,有些信号是不能被捕捉或忽略的,比如 9 号信号 SIGKILL。这是为了防止用户误操作导致无法终止进程。另外,6 号信号 SIGABRT 虽然可以注册处理器,但最终仍会导致进程异常终止。
2. 信号的产生方式
让操作系统给目标进程写入信号,必须经过 OS 权限验证。信号的产生主要有以下几种途径:
2.1 常见产生方式
- 系统命令:使用
kill命令向指定 PID 发送信号。kill -2 [pid] - 键盘输入:组合键如
Ctrl+C会触发终端驱动产生中断,OS 将其转换为信号发送给前台进程组。 - 系统调用:程序内部调用
kill()、raise()或abort()主动发送信号。raise():自己给自己发信号。abort():发送 6 号信号,用于异常终止并生成 Core Dump。
- 硬件/软件异常:如除零错误会触发
SIGFPE,非法内存访问触发SIGSEGV(11 号信号)。 - 定时器到期:如
alarm()设置的闹钟时间到,发送SIGALRM。
2.2 键盘输入与进程组
键盘本身是硬件,它通过中断通知 CPU 有数据输入。操作系统解释快捷键(如 Ctrl+C)后,需要知道发给谁。这就涉及到了进程组和会话的概念。
- 进程组:一组相关进程的集合,通常由同一个 Shell 启动。
- 会话(Session):包含一个或多个进程组,拥有独立的终端文件。
- 前台与后台:在一个会话中,同一时刻只能有一个进程组处于前台,能接收键盘输入。后台进程无法读取终端输入,一旦尝试会被暂停。
当你按 Ctrl+C 时,OS 只会把信号发送给当前会话中的前台进程组。这就是为什么后台进程(如 sleep 100 &)不会被 Ctrl+C 杀死,除非使用 kill -9 强制终止。
2.3 Alarm:设置闹钟
alarm() 函数用于设置一个一次性定时器,超时后向进程发送 SIGALRM 信号。它的返回值很有讲究:如果之前已经设置了闹钟,新调用会返回剩余时间;如果设为 0,则取消闹钟。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
volatile int counter = 0;
void alarm_handler(int sig) {
printf("Alarm triggered! Counter: %d\n", counter++);
// 重新设置闹钟以实现周期性触发
alarm(2);
}
int main() {
signal(SIGALRM, alarm_handler);
alarm(2); // 启动第一个闹钟
while(1) {
pause(); // 等待信号
}
return 0;
}
这里有个关键点:alarm 是一次性的。如果想实现周期性效果,必须在信号处理函数中再次调用 alarm()。如果在主循环中直接调用,上一个闹钟还没响就被覆盖了,导致后续收不到信号。
2.4 应用场景:看门狗
利用 alarm 和共享内存,可以实现简单的看门狗机制。一个进程定期'喂狗'重置计数器,另一个进程监控计数器,若超时未重置则执行关机或重启操作。
// 看门狗进程示例
void watchdog_handler(int signum) {
(*counter)--;
if(*counter <= 0) {
system("shutdown -h now");
exit(0);
}
alarm(1); // 重置计时
}
这种机制常用于守护进程的健康检查,确保服务在长时间运行后依然存活。
信号机制是 Linux 进程间通信和异常处理的核心基础。理解其产生方式、处理流程以及与其他机制(如进程组、定时器)的配合,对于编写健壮的系统级程序至关重要。


