目录

一产生信号

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

1-1-1 基本操作

1)Ctrl+C (SIGINT) 

2)Ctrl+\(SIGQUIT)   

3)Ctrl+Z(SIGTSTP)

1-1-2 理解OS如何得知键盘有数据

1-1-3 初步理解信号起源

2-1 调用系统命令向进程发信号

2-1-1例子

2-1-2kill

2-1-3raise

2-1-4abort

3-1由软件条件产生信号

3-1-1基本alarm验证-体会IO效率问题

3-1-2设置重复闹钟

3-1-2-1 代码执行流程

3-1-3如何理解软件条件

4-1硬件异常产生信号

4-1-1模拟除0

4-1-2模拟野指针

4-1-3子进程退出core dump

5-1总结思考


你有没有在终端里按下 Ctrl+C 强行终止卡死程序的经历?有没有用 kill 命令 "终结" 过不听话的后台进程?甚至有没有遇到过程序突然崩溃、抛出 "段错误" 的惊魂时刻?

这些我们每天都在使用的操作背后,都藏着同一个幕后英雄 ——Linux 信号机制。它就像操作系统给进程发的 "短信",可以通知进程 "该下班了"、"出问题了"、"有新消息了"。而理解信号如何产生,就是读懂这套 "进程间通信语言" 的第一步。

本文我们就从最基础的源头讲起,带你拆解键盘按键、系统命令、软件定时器、硬件异常这四大信号来源,让你彻底搞懂:那个让程序戛然而止的信号,到底是从哪里冒出来的?

信号处理的基本流程示意图:

“信号产生” 对应着某个事件的发生(例如用户按下 Ctrl+C 产生 SIGINT 信号,或者程序除以零产生 SIGFPE 信号);“信号保存” 对应着内核将信号挂起在该进程的任务结构中(如果进程暂时不处理,信号会“排队”或保持为待处理状态);“信号处理” 则对应着进程在合适的时机(从内核态返回用户态时)执行对该信号的处理动作——可以是忽略默认操作(如终止进程)或者调用自定义处理函数

一产生信号

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

当你按下一个特殊的终端按键组合时(比如最常见的 Ctrl+C),这相当于触发了“信号产生”这一环节——终端驱动程序接收到按键信息,并将其转化为一个特定的 Linux 信号(例如 SIGINT)也可以当作是通过键盘产生信号

1-1-1 基本操作

1)Ctrl+C (SIGINT) 
2)Ctrl+\(SIGQUIT)   

可以发送终止信号并生成core dump文件,用于事后调试

#include <signal.h>
#include <unistd.h>

#include <iostream>
void handler(int signumber) {
  std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber
            << std::endl;
}
int main() {
  signal(SIGINT /*2*/, SIG_DFL);  // 设置忽略信号的宏
  while (true) {
    std::cout << "I am a process, I am waiting signal!" << std::endl;
    sleep(1);
  }
}

3)Ctrl+Z(SIGTSTP)

可以发送停止信号,将当前前台进程挂起到后台等。

#include <signal.h>
#include <unistd.h>

#include <iostream>
void handler(int signumber) {
  std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber
            << std::endl;
}
int main() {
  std::cout << "我是进程: " << getpid() << std::endl;
  signal(SIGTSTP /*20*/, handler);
  while (true) {
    std::cout << "I am a process, I am waiting signal!" << std::endl;
    sleep(1);
  }
}

1-1-2 理解OS如何得知键盘有数据

键盘发信号的过程,本质是一个“硬件中断 + 操作系统接管”的链条:

  1. 按键触发:你按下键盘,键盘向 CPU 发出硬件中断信号(“高电压”)。

  2. CPU 响应:CPU 检测到中断,立刻暂停手头工作,跳转到操作系统预置的代码。

  3. 数据搬运:操作系统代码执行,将键盘的按键数据读取并存入内存。

  4. 后续处理:数据存好后,操作系统根据具体按键决定如何处理——如果是 Ctrl+C,就产生 Linux 信号发送给前台进程;如果是普通字符,就交给当前程序读取。

一句话总结:
键盘不是直接把数据塞给 CPU,而是按响“门铃”(中断),等 CPU 腾出手后亲自来取。

键盘正是基于中断(Interrupt)机制工作的。键盘采用中断方式,只有在用户按键时才会“打扰”CPU,让 CPU 专心处理,不用一直空转等待。这也是为什么键盘输入响应很及时,而 CPU 不用为了等待它而浪费算力。

1-1-3 初步理解信号起源

• 信号其实是从纯软件角度,模拟硬件中断的行为
• 只不过硬件中断是发给CPU,而信号是发给进程
• 两者有相似性,但是层级不同

2-1 调用系统命令向进程发信号

2-1-1例子

#include <signal.h>
#include <unistd.h>

#include <iostream>
int main() {
  while (true) {
    sleep(1);
  }
}

首先在后台执行死循环程序(看到./sys.out进程运行)

然后用kill命令给它发SIGSEGV信号(看到./sys.out进程退出,并有segmentation fault报错)

指定发送某种信号的kill 命令可以有多种写法,上面的命令还可以写成kill -SIGSEGV 1019557 , 11 是信号SIGSEGV 的编号。以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。

2-1-2kill

kill 命令是调用 kill 函数实现的。kill 函数可以给一个指定的进程发送指定的信号。kill() 函数用于向指定的进程 ID (PID) 发送一个信号 (Signal),通常用于结束进程、暂停进程或执行其他进程间通信

kill() 函数的参数和返回值如下:

  • 参数

    1. pid:指定要发送信号的进程 ID。可以是正数、0、-1 或负数,决定发送的目标。

    2. sig:要发送的信号编号(如 SIGKILL 或 SIGTERM)。如果为 0,则不发送信号,仅用于检查进程是否存在。

  • 返回值

    • 成功:返回 0

    • 失败:返回 -1,并设置 errno 错误码(例如 ESRCH 表示进程不存在)。

实现自己的kill 命令:

#include <signal.h>
#include <unistd.h>

#include <iostream>
#include <string>

int main(int argc, char* argv[]) {
  if (argc != 3) {
    std::cout << "\n\t" << "./mykill sig pid" << std::endl;
    exit(1);
  }

  pid_t pid = std::stoi(argv[2]);
  int sig = std::stoi(argv[1]);

  kill(pid, sig);
  return 0;
}

看到我们用kill()函数kill了进程

2-1-3raise

raise() 函数是 C 语言标准库 <signal.h> 中用于向当前进程自身发送信号的函数。

参数

  • sig:要发送的信号编号(如 SIGINTSIGTERMSIGKILL 等,可以使用宏定义,也可以直接使用整数值)。

 返回值

  • 成功:返回 0

  • 失败:返回 非零值(如果信号发送失败)。

 作用与 kill 的区别

  • raise(sig) 等价于 kill(getpid(), sig) —— 即向当前进程自己发送信号。

  • 相比之下,kill() 可以向任意进程发送信号(需要权限),而 raise() 只能发给调用它的进程本身。

例子:

#include <signal.h>
#include <unistd.h>

#include <iostream>

void handler(int signumber) {
  std::cout << "获取了一个信号: " << signumber << std::endl;
}

int main() {
  signal(2, handler);  
  while (true) {
    sleep(1);
    raise(2);
  }
}

我们看到每隔1S,自己给自己发送2号信号

2-1-4abort

abort 函数使当前进程接收到信号而异常终止。

 参数与返回值

  • 参数:无(void)。

  • 返回值无返回值。调用 abort() 后,进程会立即终止,不会返回。

 核心行为

abort() 会向当前进程发送 SIGABRT 信号。它的特点如下:

  1. 不可被忽略:即使你自定义了 SIGABRT 的信号处理函数,并试图忽略该信号,abort() 在信号处理函数返回后仍会强制终止进程

  2. 产生核心转储 (Core Dump):如果系统允许,abort() 会生成一个核心转储文件,这在调试程序崩溃时非常有用。

  3. 刷新缓冲区:它会刷新所有已打开的输出流缓冲区,确保日志信息被写入

示例:

#include <signal.h>
#include <unistd.h>

#include <iostream>

void handler(int signumber) {
  std::cout << "获取了一个信号: " << signumber << std::endl;
}

int main() {
  signal(SIGABRT,handler);  
  while (true) {
    sleep(1);
    abort();
  }
}

abort给自己发送的是固定6号信号,虽然捕捉了,但是还是要退出Aborted

与 exit() 的区别

功能 abort() exit()
行为         异常终止 正常终止
信号 发送6号信号 不发信号
C++析构函数 不调用 调用
库清理 仅刷新缓冲区 执行所有注冊的
]atexit和库清理
返回值 无返回 (非正常退出) 返回一个状态码 (通常 0表示成功)

3-1由软件条件产生信号

SIGPIPE 是一种由软件条件产生的信号。本节主要介绍alarm 函数和SIGALRM 信号。

调用 alarm 函数可以设定一个闹钟,也就是告诉内核在seconds 秒之后给当前进程发SIGALRM 信号,该信号的默认处理动作是终止当前进程。
• 这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以
前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数
的返回值仍然是以前设定的闹钟时间还余下的秒数。

3-1-1基本alarm验证-体会IO效率问题

// IO 少
#include <signal.h>
#include <unistd.h>

#include <iostream>
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++;
  }
  return 0;
}
// IO 多
#include <signal.h>
#include <unistd.h>

#include <iostream>
int main() {
  int count = 0;
  alarm(1);
  while (true) {
    std::cout << "count : " << count << std::endl;
    count++;
  }
  return 0;
}

📌 结论:
• 闹钟会响一次,默认终止进程
• 有IO效率低,有IO的次数比无IO少数倍

3-1-2设置重复闹钟

#include <signal.h>
#include <unistd.h>
#include <functional>
#include <iostream>
#include <vector>
using func_t = std::function<void()>;
int gcount = 0;
std::vector<func_t> gfuncs;
// 把信号 更换 成为 硬件中断
void hanlder(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 << "我是一个内核刷新操作" << std::endl;});
  // gfuncs.push_back([](){ std::cout <<
  // "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << std::endl;
  // }); gfuncs.push_back([](){ std::cout <<
  // "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << std::endl;
  // });alarm(1);  // 一次性的闹钟,超时alarm会自动被取消
  signal(SIGALRM, hanlder);
  while (true) {
    pause();
    std::cout << "我醒来了..." << std::endl;
    gcount++;
  }
}
3-1-2-1 代码执行流程
  1. alarm(1):设定 1 秒后触发闹钟。

  2. while(true) 进入循环:

    • 调用 pause():进程陷入睡眠,等待信号。

    • 1 秒后,闹钟触发,SIGALRM 信号到达。

    • 信号打断了 pause(),跳转到 hanlder 函数执行。

  3. hanlder(int signo)

    • 遍历 gfuncs 并执行所有注册的 func_t 函数(暂时注释掉了)。

    • 打印当前 gcount(即被唤醒的次数)。

    • 关键点int n = alarm(1);

      • 重新设置了一个 1 秒的闹钟。

      • alarm() 会返回上一次闹钟的剩余时间(由于当前闹钟刚触发,剩余时间为 0)。

    • 打印剩余时间 n(这里通常是 0)。

  4. hanlder 执行完毕

    • 信号处理结束,pause() 被信号打断后会返回,继续执行 std::cout << "我醒来了..." << std::endl;

    • gcount++,记录一次唤醒。

    • 循环回到顶部,再次调用 pause() 等待下一个 1 秒的闹钟。

#include <signal.h>
#include <stdio.h>
void handler(int sig) { printf("catch a sig : %d\n", sig); }
// v1
int main() {
  // signal(SIGFPE, handler); // 8) SIGFPE
  sleep(1);
  int a = 10;
  a /= 0;
  while (1);
  return 0;
}

alarm(1) 是一次性的。要想实现周期性定时,必须在信号处理函数中再次调用 alarm(1)

3-1-3如何理解软件条件

在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生。软件条件 = 完全由代码逻辑判断和计算所达到的一种可以触发后续动作的状态。在这段代码中,alarm(1) 的倒计时归零,就是由操作系统管理的一个典型软件条件

4-1硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令, CPU的运算单元会产生异常, 内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址, MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

4-1-1模拟除0

#include <signal.h>
#include <stdio.h>
void handler(int sig) { printf("catch a sig : %d\n", sig); }
// v1
int main() {
  signal(SIGFPE, handler); // 8) SIGFPE
  sleep(1);
  int a = 10;
  a /= 0;
  while (1);
  return 0;
}

4-1-2模拟野指针

#include <signal.h>
#include <stdio.h>
void handler(int sig) { printf("catch a sig : %d\n", sig); }
int main() {
  signal(SIGSEGV, handler);
  sleep(1);
  int* p = NULL;
  *p = 100;
  while (1);
  return 0;
}

由此可以确认,我们在C/C++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。

4-1-3子进程退出core dump

例子:

#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

#include <iostream>
#include <string>
int main() {
  if (fork() == 0) {
    sleep(1);
    int a = 10;
    a /= 0;
    exit(0);
  }
  int status = 0;
  waitpid(-1, &status, 0);
  printf("exit signal: %d, core dump: %d\n", status & 0x7F, (status >> 7) & 1);
  return 0;
}

Term (Terminate):终止进程。进程直接“死掉”。

Core (Core Dump):终止进程并生成核心转储文件。进程不但“死掉”,还会在磁盘上留下“遗书”(内存快照),方便程序员查案。

5-1总结思考

• 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
• 信号的处理是否是立即处理的?在合适的时候
• 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
• 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
• 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?

6-1结语

到这里,我们已经完整走完了 Linux 信号的 "诞生之路"。

从按下键盘的那一刻,到内核把信号递送到进程手中,这中间每一步都藏着操作系统的设计智慧。信号机制之所以能成为 Linux 系统的基石,正是因为它用最简单的模型,解决了复杂的异步通信问题 —— 它让内核可以随时干预进程,让进程之间可以互相通知,也让软件可以优雅地应对各种突发状况。

当然,信号的故事才刚刚讲了三分之一。信号产生之后,内核如何保存?进程又在什么时候、以什么方式处理信号?这些我们会在后续的文章中继续拆解。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐