信号:通知进程产生了某个事件
一.信号接收处理方式:
我们现在演示一下Linux 信号处理方式的动态切换:
代码实现的是:「对SIGINT信号(Ctrl+C 触发)的 "单次自定义响应"」------ 第一次按 Ctrl+C 执行自定义逻辑,第二次按 Ctrl+C 恢复默认终止程序的行为。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
void fun(int sig)
{
printf("sig=%d\n",sig);
// 处理完信号后,把信号处理方式改回默认
signal(sig, SIG_DFL);
// signal(SIGINT,SIG_IGN); // 忽略信号(注释状态)
}
int main()
{
// 核心约定:注册SIGINT信号的处理规则
signal(SIGINT, fun);
// 无限循环,模拟进程持续运行
while( 1 )
{
printf("hello\n");
sleep(1);
}
}
我们细致讲解一下这段代码的整个流程:
阶段 1:程序启动,执行signal(SIGINT, fun);(约定阶段)
- 程序运行到
main函数的signal(SIGINT, fun);这一行时:- 它没有调用
fun函数 (fun里的printf不会执行); - 它只是向操作系统 "登记" 了一个规则: "如果后续这个进程收到
SIGINT信号(Ctrl+C 触发),请不要执行默认的'终止进程'操作,而是去调用fun函数处理。"
- 它没有调用
- 这一步就像你给酒店前台留话:"如果有人找我,先打我房间电话,别直接让他进来"------ 留话的瞬间,没人找你,电话也没响,只是达成了一个 "约定"。
阶段 2:程序循环运行,等待信号(约定生效中)
- 程序进入
while(1)循环,每秒打印hello,持续运行; - 此时操作系统记住了之前的 "约定",只要进程没退出,这个约定就一直有效;
- 如果你不按 Ctrl+C,
fun函数永远不会被调用 ------ 因为触发fun的 "事件(收到 SIGINT)" 没发生。
阶段 3:按下 Ctrl+C,触发信号(约定执行)
- 当你按下 Ctrl+C,操作系统会给这个进程发送
SIGINT信号; - 操作系统查到之前的 "约定":"SIGINT 信号要调用 fun 处理",于是主动调用
fun函数; fun函数执行:- 打印
sig=2(SIGINT的编号是 2); - 执行
signal(sig, SIG_DFL);------ 把SIGINT的处理方式改回 "默认行为(终止进程)";
- 打印
fun执行完后,程序回到while循环,继续打印hello。
阶段 4:再次按下 Ctrl+C(约定已失效)
- 因为
fun里已经把SIGINT改回默认处理,所以这次按下 Ctrl+C,操作系统执行默认行为 ------ 直接终止程序。
用来发送信号
二.向指定进程(id号)发送指定信号
cpp
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
// main函数参数说明:argc=参数个数,argv=参数数组(字符串形式)
int main(int argc, char *argv[])
{
// 1. 校验命令行参数:必须传入【进程ID】+【信号编号】2个参数
// 程序名本身占argv[0],所以总参数个数argc必须等于3,否则报错退出
if(argc != 3)
{
printf("main err\n");
exit(1); // 退出程序,终止运行
}
int pid = 0;
int sig = 0;
// 2. 把传入的字符串参数,转换成整数(进程ID、信号编号都是数字)
sscanf(argv[1], "%d", &pid); // argv[1] → 目标进程的PID(要给谁发信号)
sscanf(argv[2], "%d", &sig); // argv[2] → 要发送的信号编号(发什么信号)
// 3. 核心操作:调用mykill函数,给PID为pid的进程,发送sig编号的信号
if(mykill(pid, sig) == -1) // 返回-1代表「信号发送失败」
{
printf("mykill err\n"); // 打印失败提示
}
return 0;
}
这段代码的核心就是:mykill(pid, sig) 就是「发送信号」的执行者
mykill的作用就是给指定的进程发送信号。本质就是调用 Linux 系统kill()系统调用的封装函数
这和之前的信号接收进行对比
这两段代码是信号通信的完整闭环,正好对应你学的「信号 + 管道」核心知识点,帮你彻底串起来:
【之前的代码】→ 信号的「接收端」
signal(SIGINT, fun); // 注册信号处理规则,等待别人给自己发信号
while(1){ printf("hello\n"); sleep(1); }
行为:被动等待信号,收到信号后执行自定义逻辑,是「信号的接收方、处理方」。
【现在的代码】→ 信号的「发送端」
mykill(pid, sig); // 主动指定PID和信号编号,发送信号
行为:主动发起信号,给其他进程推送信号,是「信号的发送方」。
执行结果如下:

实际上我们将这两段代码分成了两个部分
-
被监听者(左侧输出): 正在运行的可执行程序
main(由main.c编译)。 -
执行者(右侧终端): 正在运行的自定义工具
mykill(由mykill.c编译)。
整个过程的描述如下:
1. 寻找目标:锁定 PID
首先运行 ./main,看到屏幕不停滚动 hello pid=4056。
- 知识点: 每一个运行中的进程都有一个 PID。我们必须先知道对方的 PID,才能对其进行精准的操作。
2. 发起攻击(发送信号):./mykill 4056 2
在右侧终端,我们输入了这行命令。
-
动作: 运行
mykill程序,并传入两个参数。 -
内部原理:
mykill内部执行了kill(4056, 2)。这并不是真的要"杀死"对方,而是告诉内核:"请帮我给 4056 号进程发一个 2 号信号。"
3. 产生中断与捕获:sig=2 的出现
观察左侧,原本整齐的 hello 队列中突然插进了一行 sig=2。
-
关键机制:信号捕获 (Signal Catching)。
-
老师提醒: 默认情况下,收到信号 2 程序会直接退出。但这里程序没退,而是打印了
sig=2。这说明在main.c代码里,我们使用了signal(SIGINT, my_handler)函数,重定向了程序的行为。 -
形象理解: 就像老师敲了一下桌子(信号),学生没有被吓跑(退出),而是停下笔抬头看了一眼(执行处理函数),然后低头继续写作业(继续循环)。
我们再看看下面这张图:

我们可以总结一下,进程面对信号的三种态度
上面这两张图中,我们正好观察到了进程处理信号的三种不同方式:
-
捕捉(Catching): 这是第一张图的情况。你在代码里写了一个处理函数,像哨兵一样等着信号
2。信号一来,哨兵接住并执行你的指令(打印sig=2),然后进程继续工作。 -
忽略(Ignoring): 进程也可以选择对某些信号装作没听见(虽然图中没演示,但这是权限之一)。
-
默认动作(Default): 这是第二张图的情况。你发送了信号
15,而进程并没有专门为它写哨兵函数,于是它只能执行系统的默认命令------自尽(Terminate)。
深度解析:为什么第二次会"已终止"?
你要注意看右边终端的操作。当你输入 kill 4072 或者 ./mykill 4073 15 时,你实际上是利用了内核的"强制管理权"。
-
默认编号的陷阱: 很多学生会问,为什么
kill 4072后面没写数字也能杀死进程?因为系统的kill命令默认会附带信号编号15(SIGTERM)。这是一个"优雅的终止信号",它告诉进程:"你该下班了"。 -
身份的更替: 看到左侧那行 "已终止" 了吗?那不是程序打印的,而是 Shell(你的命令行解释器) 打印的讣告。当 Shell 发现它的子进程被信号杀死了,它会向你汇报这个进程的死因。
-
PID 的不可重用性: 进程
4072死了,你重新启动程序,系统分配了4073。这说明在 Linux 内核的进程表中,旧的条目已经注销,新的生命已经开始。
三.补充:如何体面的解决僵死进程(也就是信号处理的"忽略态度")
先看之前我们的代码
cpp
/*
* fork_demo.c
* 演示:使用 fork() 创建子进程,并让父/子进程各自循环打印不同的字符串。
* 要点:
* 1) fork() 调用后会产生两个几乎相同的进程:父进程与子进程。
* 2) 通过 fork() 的返回值区分当前代码是在父进程还是子进程中运行。
* 3) 父/子进程分别设置各自的 n 和 s,然后各自循环打印,输出会交错。
*/
#include <stdio.h> // printf
#include <stdlib.h> // exit, EXIT_SUCCESS/EXIT_FAILURE
#include <unistd.h> // fork, sleep
#include <sys/types.h> // pid_t(不是必须,但包含更清晰)
// 形参:argc 参数个数;argv 参数数组;envp 环境变量指针数组(这里未使用)
int main(int argc, char* argv[], char* envp[])
{
// s 用来指向要打印的字符串;n 表示循环打印的次数
char* s = NULL;
int n = 0;
// ====== 关键:创建子进程 ======
// fork() 成功会返回两次:
// 在"子进程"里返回 0;
// 在"父进程"里返回子进程的 PID(一个正数);
// 失败时返回 -1。
pid_t pid = fork();
// 创建失败的情况,直接退出并返回错误码
if (pid == -1)
{
// 也可以加 perror("fork"); 打印错误原因
exit(EXIT_FAILURE);
}
// ====== 根据返回值区分父子进程 ======
if (pid == 0)
{
// 这里是"子进程"的执行路径
n = 3; // 子进程循环打印 3 次
s = "child"; // 子进程打印的内容
}
else
{
// 这里是"父进程"的执行路径
n = 7; // 父进程循环打印 7 次
s = "parent"; // 父进程打印的内容
}
// ====== 循环打印并暂停 1 秒 ======
// 两个进程会并发执行这段代码,因此输出顺序是不确定的,会交错出现。
for (int i = 0; i < n; i++)
{
printf("s=%s\n", s); // 打印当前进程的标识字符串
sleep(1); // 休眠 1 秒,方便观察交错输出
}
// ====== 正常退出进程 ======
// exit(0) 会进行标准 I/O 刷新(把缓冲区内容写出)。
// 如果你在子进程里不想再次刷新 I/O(例如重定向到文件时避免重复写出),
// 可以换成 _exit(0)(需要 #include <unistd.h>),它不会做缓冲区刷新。
exit(EXIT_SUCCESS);
}
我们现在加上这一句signal(SIGCHLD, SIG_IGN);

这里的逻辑是这样的:
-
信号
SIGCHLD: 当子进程"去世"的那一刻,内核会自动给父进程发一个信号,名字就叫SIGCHLD。 -
处理方式
SIG_IGN:IGN是 Ignore(忽略) 的缩写。 -
合起来的意思: 父进程提前告诉内核:"内核大哥,以后我那孩子(子进程)要是走了,你直接帮我把它的后事办了(回收资源)就行,不用通知我,我也懒得管,别让它变僵尸吓唬人。"
有了这一行代码,子进程一死就会被内核立刻回收,干干净净,不会留下僵尸进程。
核心知识点:处理僵死进程 (Zombie Process)
1. 为什么要解决?(面试重点)
-
现象:子进程先于父进程退出,父进程如果没有及时读取子进程状态,子进程会变成"僵尸状态"。
-
危害 :僵尸进程虽然不占 CPU,但会占用 PID(进程号)资源。如果 PID 被占满,系统将无法创建新进程。
2. 代码如何解决?(准确表达)
signal(SIGCHLD, SIG_IGN); 是一行"最优解":
-
信号 (SIGCHLD):子进程终止时,内核会自动向父进程发送该信号。
-
动作 (SIG_IGN) :告诉内核,父进程主动忽略该信号。
-
结果 :在 Linux 下,父进程显式忽略
SIGCHLD后,子进程结束时会由内核直接回收资源,不再转为僵尸进程。
四.进程间通信-管道
我们先列举出来 进程间通信的所有方法,如下表所示:
| 通信方式 | 核心特点 | 简点笔记 |
|---|---|---|
| 管道 (Pipe) | 半双工,数据单向流动。 | 常用于父子进程间,或命令行中的 ` |
| 信号量 (Semaphore) | 实际上是一个计数器。 | 不用于传数据,而是用于同步 和互斥,防止多个进程抢资源。 |
| 共享内存 (Shared Memory) | 速度最快。 | 多个进程直接读写同一块物理内存。由于没拷贝过程,效率最高。 |
| 消息队列 (Message Queue) | 按消息类型读取。 | 像一个存放在内核里的链表,数据被分成一个个有类型的消息。 |
| 套接字 (Socket) | 跨机器通信。 | 唯一能让运行在不同电脑(网络中)的进程互相说话的方式。 |
a往文件中写数据 b从文件中读取
下面这段代码是,有名管道的写入
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
int main()
{
// 以只写模式打开有名管道文件 fifo
int fd = open("./fifo", O_WRONLY);
if ( fd == -1 )
{
exit(1);
}
printf("fd=%d\n", fd);
printf("input:\n");
char buff[128] = {0};
// 从标准输入获取数据
fgets(buff, 128, stdin);
// 将数据写入管道
write(fd, buff, strlen(buff));
// 关闭文件描述符
close(fd);
return 0;
}
下面这段是 有名管道读取端的代码
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
// 以只读模式打开有名管道文件 fifo
int fd = open("fifo", O_RDONLY);
if ( fd == -1 )
{
exit(1);
}
char buff[128] = {0};
// 读取管道内容,如果没有数据,程序会在此阻塞等待
read(fd, buff, 127);
// 打印读取到的内容
printf("buff=%s\n", buff);
// 关闭文件描述符
close(fd);
exit(0);
}
现在我们展示**有名管道(FIFO)**在两个独立终端之间是如何进行数据传递的。

阶段一:环境搭建(背景准备)
观察图中顶部的 ls 输出:
-
a.c/b.c:分别是刚才我们识别的"写入端"和"读取端"源代码。 -
a/b:编译后的可执行文件。 -
fifo:这是你提前用mkfifo fifo命令创建出来的有名管道文件(注意它的颜色通常不同,代表它是个特殊设备文件)。
阶段二:建立连接(阻塞特性)
看图中的运行顺序:
-
左侧窗口执行
./a:这是写入端。你会发现它并没有立刻让你输入,而是"停"在那里了。 -
左侧窗口接着执行
./b:这是读取端。
- 原理点拨 :这就是管道的同步阻塞特性 。只有当读、写两端都打开了这个
fifo文件,连接才算建立,程序才会继续往下走。
阶段三:数据传输(核心交互)
这是最精彩的部分,请对照图中下方的那个小窗口:
-
发送数据 :在
./a(写入端)的提示下,你输入了hello。 -
数据流向:
-
a进程调用write(),把hello塞进内核的管道缓冲区。 -
内核通知正在等待的
b进程。
-
-
接收数据 :看左侧大窗口的
./b下方,立刻跳出了buff=hello。
- 原理点拨 :这证明了数据已经跨越了进程边界,从进程
a传到了进程b。
💡 实验总结与笔记(面试核心词)
-
半双工通信 :数据像单行道一样,从
a流向b。 -
有名管道的优势 :你看,
a和b是两个完全独立的命令窗口(代表两个无关进程),它们通过一个**路径名(fifo)**就接上了头。 -
阻塞逻辑:
-
如果没有人读,写端会等;
-
如果没有数据,读端会等(图中
read后的蓝字注释)。
-
最后给你一个面试小技巧 : 如果面试官问:"有名管道和普通管道最大的区别是什么?" 你直接指着这张图说:"普通管道只能在有亲缘关系的父子进程间用;而有名管道只要知道文件名,像图里这两个毫不相关的终端进程也能通信。"