信号与管道

信号:通知进程产生了某个事件

一.信号接收处理方式:

我们现在演示一下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函数执行:
    1. 打印sig=2SIGINT的编号是 2);
    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和信号编号,发送信号

行为:主动发起信号,给其他进程推送信号,是「信号的发送方」。

执行结果如下:

实际上我们将这两段代码分成了两个部分

  1. 被监听者(左侧输出): 正在运行的可执行程序 main(由 main.c 编译)。

  2. 执行者(右侧终端): 正在运行的自定义工具 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);

这里的逻辑是这样的:
  1. 信号 SIGCHLD 当子进程"去世"的那一刻,内核会自动给父进程发一个信号,名字就叫 SIGCHLD

  2. 处理方式 SIG_IGN IGNIgnore(忽略) 的缩写。

  3. 合起来的意思: 父进程提前告诉内核:"内核大哥,以后我那孩子(子进程)要是走了,你直接帮我把它的后事办了(回收资源)就行,不用通知我,我也懒得管,别让它变僵尸吓唬人。"

有了这一行代码,子进程一死就会被内核立刻回收,干干净净,不会留下僵尸进程。

核心知识点:处理僵死进程 (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 命令创建出来的有名管道文件(注意它的颜色通常不同,代表它是个特殊设备文件)。


阶段二:建立连接(阻塞特性)

看图中的运行顺序:

  1. 左侧窗口执行 ./a:这是写入端。你会发现它并没有立刻让你输入,而是"停"在那里了。

  2. 左侧窗口接着执行 ./b:这是读取端。

  • 原理点拨 :这就是管道的同步阻塞特性 。只有当读、写两端都打开了这个 fifo 文件,连接才算建立,程序才会继续往下走。

阶段三:数据传输(核心交互)

这是最精彩的部分,请对照图中下方的那个小窗口:

  1. 发送数据 :在 ./a(写入端)的提示下,你输入了 hello

  2. 数据流向

    • a 进程调用 write(),把 hello 塞进内核的管道缓冲区。

    • 内核通知正在等待的 b 进程。

  3. 接收数据 :看左侧大窗口的 ./b 下方,立刻跳出了 buff=hello

  • 原理点拨 :这证明了数据已经跨越了进程边界,从进程 a 传到了进程 b

💡 实验总结与笔记(面试核心词)

  • 半双工通信 :数据像单行道一样,从 a 流向 b

  • 有名管道的优势 :你看,ab 是两个完全独立的命令窗口(代表两个无关进程),它们通过一个**路径名(fifo)**就接上了头。

  • 阻塞逻辑

    • 如果没有人读,写端会等;

    • 如果没有数据,读端会等(图中 read 后的蓝字注释)。

最后给你一个面试小技巧 : 如果面试官问:"有名管道和普通管道最大的区别是什么?" 你直接指着这张图说:"普通管道只能在有亲缘关系的父子进程间用;而有名管道只要知道文件名,像图里这两个毫不相关的终端进程也能通信。"

相关推荐
liuyunshengsir2 小时前
huggingface-cli download 断点续传
linux·hugging face·魔塔社区
大聪明-PLUS2 小时前
使用 Shell 脚本生成配置文件的 6 种方法
linux·嵌入式·arm·smarc
脏脏a2 小时前
【Linux】Linux 初探:历史溯源与常用指令速览
linux·运维·服务器·基础指令
I · T · LUCKYBOOM2 小时前
2.1编译安装--单台服务器托管多网站
linux·运维·服务器·网络
Howrun7772 小时前
Linux进程通信---3---System V 共享内存
linux·服务器
大聪明-PLUS2 小时前
Linux 下的 C 语言编程:创建命令行 shell:第二部分
linux·嵌入式·arm·smarc
一个平凡而乐于分享的小比特2 小时前
Linux 用户和组的创建机制
linux·主组·附加组
代码游侠2 小时前
应用——SQLite3 C 编程学习
linux·服务器·c语言·数据库·笔记·网络协议·sqlite
大聪明-PLUS2 小时前
在 Linux 6.8 中创建自定义系统调用
linux·嵌入式·arm·smarc