Linux进程信号(一):从Ctrl+C到kill、raise、alarm,深入理解信号的概念与产生原理

Linux进程信号(一):从Ctrl+C到kill、raise、alarm,深入理解信号的概念与产生原理


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


文章目录

  • Linux进程信号(一):从Ctrl+C到kill、raise、alarm,深入理解信号的概念与产生原理
  • 一、从生活中的例子理解信号
    • [1.1 用收快递理解信号](#1.1 用收快递理解信号)
    • [1.2 信号到来,不代表立即处理](#1.2 信号到来,不代表立即处理)
    • [1.3 收到信号以后可以怎么处理?](#1.3 收到信号以后可以怎么处理?)
    • [1.4 信号具有异步性](#1.4 信号具有异步性)
  • 二、从技术角度认识Linux信号
    • [2.1 第一个信号实验:Ctrl+C](#2.1 第一个信号实验:Ctrl+C)
    • [2.2 信号是什么?](#2.2 信号是什么?)
  • 三、signal():改变信号的处理方式
    • [3.1 signal函数原型](#3.1 signal函数原型)
    • [3.2 自定义SIGINT处理函数](#3.2 自定义SIGINT处理函数)
    • [3.3 信号处理由谁调用?](#3.3 信号处理由谁调用?)
    • [3.4 为什么使用write而不是cout?](#3.4 为什么使用write而不是cout?)
  • 四、Linux信号的三种处理动作
    • [4.1 默认动作:SIG_DFL](#4.1 默认动作:SIG_DFL)
    • [4.2 忽略信号:SIG_IGN](#4.2 忽略信号:SIG_IGN)
    • [4.3 自定义捕捉](#4.3 自定义捕捉)
    • [4.4 所有信号都能捕捉吗?](#4.4 所有信号都能捕捉吗?)
  • 五、查看Linux支持的信号
    • [5.1 kill -l](#5.1 kill -l)
    • [5.2 使用man查看默认动作](#5.2 使用man查看默认动作)
  • 六、信号产生的第一种方式:终端按键
    • [6.1 Ctrl+C:SIGINT](#6.1 Ctrl+C:SIGINT)
    • [6.2 Ctrl+\:SIGQUIT](#6.2 Ctrl+\:SIGQUIT)
    • [6.3 Ctrl+Z:SIGTSTP](#6.3 Ctrl+Z:SIGTSTP)
    • [6.4 前台进程与后台进程](#6.4 前台进程与后台进程)
  • 七、为什么按下键盘能够产生信号?
    • [7.1 从硬件中断开始理解](#7.1 从硬件中断开始理解)
    • [7.2 硬件中断和信号有什么区别?](#7.2 硬件中断和信号有什么区别?)
  • 八、信号产生的第二种方式:系统命令
    • [8.1 kill命令](#8.1 kill命令)
    • [8.2 实验:向后台进程发送信号](#8.2 实验:向后台进程发送信号)
    • [8.3 为什么kill -SIGSEGV也能造成段错误式终止?](#8.3 为什么kill -SIGSEGV也能造成段错误式终止?)
  • 九、信号产生的第三种方式:kill()、raise()和abort()
    • [9.1 kill()函数](#9.1 kill()函数)
    • [9.2 自己实现一个简单的mykill](#9.2 自己实现一个简单的mykill)
    • [9.3 raise()函数](#9.3 raise()函数)
    • [9.4 abort()函数](#9.4 abort()函数)
  • 十、信号产生的第四种方式:软件条件
    • [10.1 什么是软件条件?](#10.1 什么是软件条件?)
    • [10.2 alarm()函数](#10.2 alarm()函数)
    • [10.3 alarm()的返回值](#10.3 alarm()的返回值)
    • [10.4 alarm(0)是什么意思?](#10.4 alarm(0)是什么意思?)
  • 十一、alarm实验:体会IO对程序效率的影响
    • [11.1 第一组:循环中不断输出](#11.1 第一组:循环中不断输出)
    • [11.2 第二组:循环中不进行IO](#11.2 第二组:循环中不进行IO)
    • [11.3 为什么有IO时效率较低?](#11.3 为什么有IO时效率较低?)
  • 十二、实现重复闹钟与pause()
    • [12.1 alarm默认只安排一次到期通知](#12.1 alarm默认只安排一次到期通知)
    • [12.2 在处理函数中重新设置闹钟](#12.2 在处理函数中重新设置闹钟)
    • [12.3 pause()是什么?](#12.3 pause()是什么?)
    • [12.4 为什么alarm体现了操作系统的定时能力?](#12.4 为什么alarm体现了操作系统的定时能力?)
  • 十三、信号产生的第五种方式:硬件异常
    • [13.1 除零异常与SIGFPE](#13.1 除零异常与SIGFPE)
    • [13.2 非法内存访问与SIGSEGV](#13.2 非法内存访问与SIGSEGV)
    • [13.3 为什么是内核发送信号?](#13.3 为什么是内核发送信号?)
    • [13.4 为什么捕捉SIGSEGV后可能反复收到信号?](#13.4 为什么捕捉SIGSEGV后可能反复收到信号?)
  • [十四、Core Dump:进程为什么会生成core文件?](#十四、Core Dump:进程为什么会生成core文件?)
    • [14.1 什么是Core Dump?](#14.1 什么是Core Dump?)
    • [14.2 哪些信号可能产生Core Dump?](#14.2 哪些信号可能产生Core Dump?)
    • [14.3 查看Core文件大小限制](#14.3 查看Core文件大小限制)
    • [14.4 修改Core Dump限制](#14.4 修改Core Dump限制)
    • [14.5 安全的实验思路](#14.5 安全的实验思路)
    • [14.6 父进程怎样知道子进程是否发生Core Dump?](#14.6 父进程怎样知道子进程是否发生Core Dump?)
  • 十五、总结:Linux信号究竟如何产生?
  • 十六、课后思考
  • 总结

前言:Linux为什么需要信号?

在前面的 Linux 学习中,我们已经掌握了:

  • 进程创建:fork()
  • 进程等待:wait()、waitpid()
  • 程序替换:execvp()
  • 进程间通信:匿名管道、命名管道、共享内存等

通过这些知识,我们已经可以创建进程、控制程序执行,并让多个进程进行数据交换。

但是,还有一个问题:

如果一个进程正在执行某项工作,操作系统或者其他进程希望通知它发生了某个事件,该怎么办?

例如,我们经常在终端执行:

bash 复制代码
./myprocess

当程序陷入死循环时,可以按下:

text 复制代码
Ctrl+C

结束程序。

但是:

  • 为什么按下 Ctrl+C 就能够终止进程?
  • Ctrl+C 与操作系统内核之间是什么关系?
  • 如果进程正在执行其他代码,是否必须立即处理这个事件?
  • 能不能让进程收到 Ctrl+C 后不退出?
  • 除了键盘,操作系统还有哪些产生信号的方式?

这些问题都属于 Linux 进程信号(Signal) 的范畴。

本篇按照课程的讲解顺序,从生活中的例子开始,依次介绍信号概念、信号处理动作、信号产生方式以及 Core Dump。


一、从生活中的例子理解信号

1.1 用收快递理解信号

假设我们在网上购买了几件商品,正在等待快递到达。

虽然快递还没有到,但我们已经知道:

text 复制代码
快递到来以后,需要去领取

也就是说:

在事件真正发生之前,我们就已经知道应该如何处理这个事件。

这与 Linux 进程识别信号非常相似。

进程不需要等到某个信号真正产生后,才知道它是什么。

操作系统早已定义了合法的信号类型及其含义。

1.2 信号到来,不代表立即处理

继续假设:

快递员打电话通知我们:

text 复制代码
快递已经送到楼下

但是此时我们正在完成某项重要工作,决定五分钟以后再去领取。

于是存在三个不同的阶段:

  1. 快递已经到达。
  2. 我们记住了有快递需要领取。
  3. 在合适的时间去领取并处理快递。

对应到 Linux 信号:

text 复制代码
信号产生
    |
    v
信号保存
    |
    v
信号处理

这也是学习 Linux 信号时最重要的一条主线。

信号产生后不一定立即得到处理,但操作系统需要能够记录相关信号状态。

1.3 收到信号以后可以怎么处理?

还是快递的例子。

收到快递后,可以采用三种方式:

  • 默认处理: 按照通常方式打开并使用商品。
  • 自定义处理: 按照自己的特殊需求处理快递。
  • 忽略: 不采取进一步操作。

Linux 信号也有类似的三种处理动作:

text 复制代码
默认动作
忽略信号
自定义捕捉

1.4 信号具有异步性

快递什么时候到来,并不由我们正在执行的工作决定。

同样:

text 复制代码
进程正在执行正常代码

与:

text 复制代码
某个信号什么时候产生

通常也不存在固定的同步关系。

因此,信号是一种典型的异步事件通知机制。


二、从技术角度认识Linux信号

2.1 第一个信号实验:Ctrl+C

先编写一个一直运行的程序。

signal_test.cpp:

cpp 复制代码
#include <iostream>
#include <unistd.h>

using namespace std;

int main()
{
    while (true)
    {
        cout << "I am waiting for signal!" << endl;
        sleep(1);
    }

    return 0;
}

编译:

bash 复制代码
g++ signal_test.cpp -o signal_test

运行:

bash 复制代码
./signal_test

程序会不断输出:

text 复制代码
I am waiting for signal!
I am waiting for signal!
I am waiting for signal!

此时按下:

text 复制代码
Ctrl+C

程序通常就会终止。

问题是:

我们的代码根本没有判断用户是否按下Ctrl+C,为什么程序却能退出?

因为终端驱动会将这一控制键组合解释为相应的信号事件,向前台进程组发送:

c 复制代码
SIGINT

信号。

在常见的 Linux 平台上:

text 复制代码
SIGINT = 2

该信号的默认处理动作是终止进程。

因此,程序即使没有主动检查键盘输入,也可能因为收到 SIGINT 而结束。

2.2 信号是什么?

可以给出一个初步定义:

信号是操作系统用于向进程或线程通知某些事件的一种机制,在经典 Unix/Linux 进程模型中,常被称为软件中断。

这里的"软件中断"主要用于说明它具有异步通知、打断正常执行流程的特点。

但它并不等同于 CPU 的硬件中断。

两者存在联系,也存在不同。

后面我们还会从硬件异常和操作系统中断机制的角度进一步分析。


三、signal():改变信号的处理方式

3.1 signal函数原型

c 复制代码
#include <signal.h>

void (*signal(int signum, void (*handler)(int)))(int);

原型看起来比较复杂,因为这里涉及函数指针。

如果先定义一种函数指针类型,可以更容易理解:

c 复制代码
typedef void (*sighandler_t)(int);

那么在常见的 GNU/Linux 接口中,可以将其理解为:

c 复制代码
sighandler_t signal(
    int signum,
    sighandler_t handler
);

其中:

c 复制代码
signum

表示要设置处理方式的信号编号。

c 复制代码
handler

表示该信号对应的处理动作。

它可以是:

  • 一个自定义处理函数。
  • SIG_DFL,使用默认处理动作。
  • SIG_IGN,忽略信号。

需要特别注意:

调用signal()只是设置处理方式,不代表立即产生或者处理一个信号。

3.2 自定义SIGINT处理函数

下面修改之前的程序。

为了避免在异步信号处理函数中使用不适合的标准 IO 接口,我们采用 write() 输出固定消息。

cpp 复制代码
#include <signal.h>
#include <unistd.h>

void Handler(int signo)
{
    (void)signo;

    const char msg[] = "catch SIGINT\n";

    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}

int main()
{
    signal(SIGINT, Handler);

    while (true)
    {
        sleep(1);
    }

    return 0;
}

编译:

bash 复制代码
g++ signal_test.cpp -o signal_test

运行后按下:

text 复制代码
Ctrl+C

可以看到:

text 复制代码
^Ccatch SIGINT

但是程序不再因为这一次信号而退出。

为什么?

因为我们执行了:

cpp 复制代码
signal(SIGINT, Handler);

将 SIGINT 的处理动作从默认终止,改为了执行自定义函数。

处理函数执行结束以后,如果没有其他原因终止进程,程序就可以继续运行。

3.3 信号处理由谁调用?

这里非常值得注意。

我们并没有主动调用:

cpp 复制代码
Handler(SIGINT);

但是当进程收到相应信号并进行递达时,操作系统可以安排执行该处理函数。

因此:

信号处理函数是一种回调函数。

用户负责注册处理函数,操作系统负责在满足相应条件时安排它执行。

3.4 为什么使用write而不是cout?

课堂演示中经常写:

cpp 复制代码
cout << "catch signal" << endl;

但在异步信号处理函数中,普通 C++ IO 接口不保证具有异步信号安全性。

如果正常执行流程恰好也在操作同一个 IO 对象,信号处理函数再次进入相关库代码,就可能出现不可重入问题。

因此规范的信号处理函数应尽可能简单。

例如:

c 复制代码
write(STDOUT_FILENO, "SIGINT\n", 7);

属于适合此类简单演示的方式。

关于可重入函数的问题,第二篇会详细讨论。


四、Linux信号的三种处理动作

4.1 默认动作:SIG_DFL

可以这样设置:

c 复制代码
signal(SIGINT, SIG_DFL);

表示恢复 SIGINT 的默认处理动作。

对于 SIGINT,默认行为是终止进程。

因此按下:

text 复制代码
Ctrl+C

程序就会退出。

但需要注意,不同信号的默认行为可能不同。

常见默认行为包括:

  • 终止进程。
  • 终止进程并可能产生 Core Dump。
  • 忽略。
  • 停止进程。
  • 恢复已停止的进程。

4.2 忽略信号:SIG_IGN

例如:

c 复制代码
signal(SIGINT, SIG_IGN);

表示忽略 SIGINT。

之后按下:

text 复制代码
Ctrl+C

程序通常不会因此终止。

4.3 自定义捕捉

c 复制代码
signal(SIGINT, Handler);

表示注册自定义处理函数。

当信号递达时执行该函数。

三种处理方式可以总结如下:

处理动作 写法 含义
默认处理 SIG_DFL 交给系统定义的默认动作
忽略 SIG_IGN 忽略对应信号
自定义捕捉 函数指针 运行用户注册的处理函数

4.4 所有信号都能捕捉吗?

不能。

例如:

c 复制代码
SIGKILL
SIGSTOP

这两个信号不能被进程捕捉、忽略或者阻塞。

它们为系统提供了强制终止或停止进程的重要控制手段。

因此不能认为:

c 复制代码
signal(SIGKILL, Handler);

可以成功改变 SIGKILL 的处理方式。


五、查看Linux支持的信号

5.1 kill -l

在终端执行:

bash 复制代码
kill -l

可以看到系统支持的信号名称。

典型信号包括:

信号 Linux常见编号 作用
SIGHUP 1 终端挂起等事件
SIGINT 2 终端中断
SIGQUIT 3 终端退出请求
SIGILL 4 非法指令
SIGTRAP 5 跟踪或断点事件
SIGABRT 6 异常中止
SIGFPE 8 算术异常
SIGKILL 9 强制终止
SIGSEGV 11 非法内存访问
SIGPIPE 13 向没有读端的管道写入
SIGALRM 14 定时器到期
SIGTERM 15 请求终止
SIGCHLD 17 子进程状态发生变化
SIGCONT 18 恢复停止的进程
SIGSTOP 19 强制停止
SIGTSTP 20 终端请求停止进程

以上编号主要针对常见 Linux 架构,不应将所有编号当作跨平台常量。

编写代码时优先使用:

c 复制代码
SIGINT

这样的宏,而不是直接写:

c 复制代码
2

5.2 使用man查看默认动作

执行:

bash 复制代码
man 7 signal

可以看到信号的标准名称、默认处理动作以及相关说明。

课程中将信号主要分为:

text 复制代码
普通信号
实时信号

本篇重点讨论普通信号。

实时信号在排队语义等方面存在不同,后续可以进一步研究。


六、信号产生的第一种方式:终端按键

6.1 Ctrl+C:SIGINT

已经验证过:

text 复制代码
Ctrl+C -> SIGINT

默认终止前台进程组中的相关进程。

6.2 Ctrl+\:SIGQUIT

在终端中按下:

text 复制代码
Ctrl+\

通常产生:

c 复制代码
SIGQUIT

常见 Linux 编号:

text 复制代码
3

默认动作通常是:

text 复制代码
终止进程,并尝试产生Core Dump

可以编写循环程序后直接测试。

如果注册了自定义 SIGQUIT 处理函数,则可能不再执行默认终止动作。

6.3 Ctrl+Z:SIGTSTP

在终端中按:

text 复制代码
Ctrl+Z

通常产生:

c 复制代码
SIGTSTP

它的默认动作是停止对应的前台进程组。

注意:

停止进程不等于进程已经退出。

例如:

bash 复制代码
./signal_test

按下 Ctrl+Z 后,Shell 可能显示:

text 复制代码
[1]+  Stopped    ./signal_test

执行:

bash 复制代码
jobs

可以查看被停止的作业。

可以通过:

bash 复制代码
fg

让对应作业恢复到前台。

也可以使用:

bash 复制代码
bg

尝试让已停止的作业在后台恢复运行。

6.4 前台进程与后台进程

例如:

bash 复制代码
./signal_test &

表示让程序在后台运行。

此时终端输入的 Ctrl+C 通常针对当前终端的前台进程组,而不是简单地发送给所有后台进程。

所以学习终端产生信号时,需要建立:

text 复制代码
终端
  |
  v
前台进程组
  |
  v
相关进程

的概念。

这里涉及更完整的进程组、会话和作业控制机制,后续可以继续展开。


七、为什么按下键盘能够产生信号?

7.1 从硬件中断开始理解

课件专门分析了操作系统如何知道键盘有数据到来。

可以先建立一个简化流程:

  1. 用户按下键盘。
  2. 键盘硬件与相关控制器产生输入事件。
  3. 操作系统通过硬件中断等机制获知输入。
  4. 内核中的输入及终端子系统处理该事件。
  5. 如果事件符合终端控制字符规则,就可以产生相应信号。

例如:

text 复制代码
Ctrl+C

可以被终端驱动解释为中断控制字符,从而向前台进程组产生 SIGINT。

7.2 硬件中断和信号有什么区别?

可以先区分它们面对的对象。

硬件中断

主要面向处理器和操作系统中断处理机制,用于通知硬件事件。

进程信号

主要用于向进程或线程表达某种需要处理的事件。

所以不能直接写成:

text 复制代码
键盘硬件把SIGINT直接发送给用户进程

更合理的理解是:

text 复制代码
硬件输入事件
    |
    v
内核输入处理
    |
    v
终端控制字符识别
    |
    v
产生对应进程信号

两者具有事件通知的相似性,但所处的软件与硬件层次不同。


八、信号产生的第二种方式:系统命令

8.1 kill命令

除了按键以外,我们还可以使用:

bash 复制代码
kill

命令向指定进程发送信号。

例如:

bash 复制代码
kill -SIGTERM 12345

表示向 PID 为 12345 的进程发送 SIGTERM。

这里的 PID 仅为示例,实际操作时必须先确认目标进程。

也可以使用:

bash 复制代码
kill -15 12345

但推荐使用信号名称,便于阅读。

8.2 实验:向后台进程发送信号

首先编译一个循环程序:

cpp 复制代码
#include <iostream>
#include <unistd.h>

int main()
{
    std::cout << "pid = " << getpid() << std::endl;

    while (true)
    {
        sleep(1);
    }
}

编译:

bash 复制代码
g++ signal_test.cpp -o signal_test

后台运行:

bash 复制代码
./signal_test &

可以使用:

bash 复制代码
jobs -l

查看作业及相关进程编号。

假设其 PID 为:

text 复制代码
12345

就可以执行:

bash 复制代码
kill -SIGTERM 12345

结束该实验进程。

注意:

kill命令的本质是发送信号,不一定代表强制杀死进程。

例如:

bash 复制代码
kill -SIGSTOP 12345

用于停止进程。

bash 复制代码
kill -SIGCONT 12345

用于使停止的进程继续执行。

8.3 为什么kill -SIGSEGV也能造成段错误式终止?

课件有一个实验:

向一个正常运行的进程发送:

bash 复制代码
kill -SIGSEGV 12345

如果使用默认处理动作,程序会因 SIGSEGV 终止,并可能产生 Core Dump。

但这里必须区分:

  • 真实内存访问异常: 进程执行了错误内存访问,引发硬件异常并最终形成相应信号。
  • 手工发送SIGSEGV: 通过信号接口产生相同编号的信号。

两者都可能导致进程收到 SIGSEGV。

但是手工发送信号并不意味着程序真的执行了非法内存访问指令。


九、信号产生的第三种方式:kill()、raise()和abort()

9.1 kill()函数

函数原型:

c 复制代码
#include <signal.h>
#include <sys/types.h>

int kill(pid_t pid, int sig);

其中:

c 复制代码
pid

指定信号目标。

c 复制代码
sig

指定信号类型。

对于:

c 复制代码
pid > 0

通常表示向指定 PID 的进程发送信号。

其他 PID 参数形式还涉及进程组及权限判断,暂时不展开。

9.2 自己实现一个简单的mykill

mykill.cpp:

cpp 复制代码
#include <cerrno>
#include <csignal>
#include <cstdlib>
#include <iostream>

using namespace std;

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        cerr << "Usage: " << argv[0]
             << " signal pid" << endl;

        return 1;
    }

    int signo = atoi(argv[1]);
    pid_t pid = static_cast<pid_t>(atoi(argv[2]));

    // 教学版仅允许指定正PID和有效信号编号
    if (pid <= 0 || signo <= 0 || signo >= NSIG)
    {
        cerr << "invalid signal or pid" << endl;
        return 1;
    }

    if (kill(pid, signo) < 0)
    {
        perror("kill");
        return 1;
    }

    return 0;
}

编译:

bash 复制代码
g++ mykill.cpp -o mykill

使用:

bash 复制代码
./mykill 15 12345

即向对应 PID 发送 SIGTERM。

这里使用 atoi() 只是为了教学简化,严格的命令行工具还应使用 strtol() 检查非法字符及整数溢出等问题。

此外,NSIG 为常见 GNU/Linux 扩展宏,不能将其视为全部 POSIX 平台都有的通用接口。

9.3 raise()函数

函数原型:

c 复制代码
#include <signal.h>

int raise(int sig);

作用是向调用者自身发送指定信号。

例如:

c 复制代码
raise(SIGINT);

可以理解为:

text 复制代码
当前执行流向自身产生SIGINT

对于简单单线程实验,可以把它理解成"自己给自己发信号"。

完整代码

c 复制代码
#include <signal.h>
#include <unistd.h>

void Handler(int signo)
{
    (void)signo;

    const char msg[] = "received SIGUSR1\n";

    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}

int main(void)
{
    signal(SIGUSR1, Handler);

    raise(SIGUSR1);

    return 0;
}

运行后能够看到:

text 复制代码
received SIGUSR1

9.4 abort()函数

函数原型:

c 复制代码
#include <stdlib.h>

void abort(void);

它用于让当前进程异常终止,通常通过产生:

c 复制代码
SIGABRT

实现。

在常见 Linux 平台上:

text 复制代码
SIGABRT = 6

例如:

c 复制代码
#include <stdlib.h>

int main(void)
{
    abort();

    return 0;
}

程序会异常终止,并可能生成 Core Dump。

一个特别值得注意的问题是:

即使用户注册了SIGABRT处理函数,只要处理函数正常返回,abort()仍会确保异常终止,而不会像普通函数一样正常返回。


十、信号产生的第四种方式:软件条件

10.1 什么是软件条件?

有些信号并不是直接来自键盘,也不是其他进程主动使用 kill() 发送的。

而是由于某个软件状态满足特定条件,由系统产生相应信号。

例如:

  • 管道没有读端时继续写入,可能产生 SIGPIPE。
  • 设定的定时器到期,产生 SIGALRM。

其中 SIGPIPE 已经在进程间通信的管道部分介绍过。

本节重点学习:

c 复制代码
alarm()

10.2 alarm()函数

函数原型:

c 复制代码
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

它的作用是为当前进程设置一个定时器。

当指定的时间到达后,系统会产生:

c 复制代码
SIGALRM

其默认处理动作通常是终止进程。

例如:

c 复制代码
alarm(5);

表示设置一个大约 5 秒后到期的定时器。

注意,定时器到期与进程最终处理信号的时间,可能存在调度延迟。

10.3 alarm()的返回值

alarm() 的返回值表示之前尚未到期的闹钟大致还剩余多少秒。

如果此前没有设置闹钟:

text 复制代码
返回0

如果已经设置了闹钟,再次调用 alarm() 会替换先前的设置。

例如:

c 复制代码
alarm(30);

经过一段时间后:

c 复制代码
unsigned int left = alarm(15);

这里新的闹钟被设置为 15 秒后到期。

left 则表示旧闹钟剩余的秒数。

10.4 alarm(0)是什么意思?

c 复制代码
alarm(0);

用于取消当前尚未到期的闹钟。

其返回值仍然表示原先闹钟剩余的秒数。


十一、alarm实验:体会IO对程序效率的影响

课件设计了一个非常有代表性的实验:

在约一秒钟内进行大量计数,比较频繁输出与不频繁输出的差异。

11.1 第一组:循环中不断输出

cpp 复制代码
#include <iostream>
#include <unistd.h>

using namespace std;

int main()
{
    alarm(1);

    unsigned long long count = 0;

    while (true)
    {
        cout << count++ << endl;
    }

    return 0;
}

编译:

bash 复制代码
g++ -O0 count_io.cpp -o count_io

运行:

bash 复制代码
./count_io

大约一秒后,程序会被 SIGALRM 终止。

可以观察在终止前打印了多少次。

11.2 第二组:循环中不进行IO

为了避免在信号处理函数中打印,下面让处理函数只设置一个标志。

c 复制代码
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

volatile sig_atomic_t expired = 0;

void Handler(int signo)
{
    (void)signo;
    expired = 1;
}

int main(void)
{
    signal(SIGALRM, Handler);

    volatile unsigned long long count = 0;

    alarm(1);

    while (!expired)
    {
        ++count;
    }

    printf("count = %llu\n", count);

    return 0;
}

编译:

bash 复制代码
gcc -O0 count_no_io.c -o count_no_io

运行:

bash 复制代码
./count_no_io

观察计数结果。

通常情况下,第二组计数次数远多于第一组的逐次终端输出数量。

11.3 为什么有IO时效率较低?

因为:

cpp 复制代码
cout << count << endl;

需要进行字符串转换、流输出等操作。

尤其是:

cpp 复制代码
endl

还会刷新输出缓冲。

终端输出也涉及操作系统和终端设备的处理。

相比之下:

c 复制代码
++count;

只进行简单计算,开销通常更低。

因此这个实验的主要结论是:

在高频循环中进行大量IO,可能显著限制程序的执行效率。

但两组代码的具体计数不能被当成严格的 CPU 性能基准,因为循环内容、编译优化、调度、终端环境等条件都不完全相同。


十二、实现重复闹钟与pause()

12.1 alarm默认只安排一次到期通知

例如:

c 复制代码
alarm(1);

表示大约一秒后产生一次 SIGALRM。

如果希望每隔一秒都获得通知,就需要重新安排定时器。

12.2 在处理函数中重新设置闹钟

c 复制代码
#include <signal.h>
#include <unistd.h>

void Handler(int signo)
{
    (void)signo;

    const char msg[] = "one second passed\n";

    write(STDOUT_FILENO, msg, sizeof(msg) - 1);

    // 再次设置下一次闹钟
    alarm(1);
}

int main(void)
{
    struct sigaction action = {0};

    action.sa_handler = Handler;

    sigemptyset(&action.sa_mask);

    sigaction(SIGALRM, &action, NULL);

    alarm(1);

    while (1)
    {
        pause();
    }

    return 0;
}

运行后大约每秒输出一次:

text 复制代码
one second passed

可以通过:

text 复制代码
Ctrl+C

终止程序。

这里使用 sigaction() 注册处理函数,它会在下一篇中详细讲解。

12.3 pause()是什么?

函数原型:

c 复制代码
#include <unistd.h>

int pause(void);

作用:

使调用线程暂停执行,直到某个信号触发了终止动作,或者执行了捕捉处理函数并返回。

如果捕捉到的信号处理函数返回,pause() 会返回:

text 复制代码
-1

并设置:

c 复制代码
errno = EINTR;

因此:

c 复制代码
while (1)
{
    pause();
}

可以让进程等待信号,而不是一直执行空循环消耗 CPU 时间。

但需要注意,简单地先检查某个标志,再调用 pause(),可能出现竞态条件。后面会在信号同步部分讨论。

12.4 为什么alarm体现了操作系统的定时能力?

因为操作系统需要管理大量定时事件。

例如:

text 复制代码
进程A:3秒后产生通知
进程B:10秒后产生通知
进程C:1秒后产生通知

内核必须维护这些定时器的到期时间,并在适当时机执行对应的处理工作。

课件通过旧版 Linux 内核的 timer_list 等结构说明:

text 复制代码
定时器也需要先描述,再组织

这一操作系统设计思想。

需要注意,现代 Linux 内核的定时器实现比早期版本复杂,不能直接把旧版结构体字段当成当前内核的固定实现。


十三、信号产生的第五种方式:硬件异常

前面介绍的信号,主要来自:

text 复制代码
键盘
系统命令
函数调用
软件条件

但信号还可能来自程序执行过程中出现的硬件异常。

这也是课件中非常重要的部分。

13.1 除零异常与SIGFPE

例如:

c 复制代码
int a = 10;
int b = 0;

int result = a / b;

在某些 Linux/x86 环境下,如果实际执行了整数除零指令,CPU 会产生相应的算术异常。

内核可以将其转换为:

c 复制代码
SIGFPE

发送给相关执行流。

但必须特别说明:

在C/C++语言层面,整数除零属于未定义行为。

因此不应该假设所有编译器、所有优化级别都必然执行除零指令并产生同样的信号。

13.2 非法内存访问与SIGSEGV

例如:

c 复制代码
int* p = NULL;

*p = 100;

这也是未定义行为。

在典型 Linux 环境中,如果程序实际执行了对无效地址的写访问,硬件内存管理机制可能产生访问异常。

内核随后可能向进程发送:

c 复制代码
SIGSEGV

默认结果通常是终止进程并可能生成 Core Dump。

13.3 为什么是内核发送信号?

硬件异常首先进入 CPU 和内核的异常处理流程。

例如:

text 复制代码
程序执行非法内存访问
        |
        v
CPU检测到异常
        |
        v
进入内核异常处理
        |
        v
内核判断异常原因
        |
        v
向相关进程产生SIGSEGV

因此:

硬件并不是直接执行用户编写的信号处理函数,而是先由内核处理异常,再通过进程信号机制安排相应动作。

13.4 为什么捕捉SIGSEGV后可能反复收到信号?

课件通过实验指出:

如果在 SIGSEGV 处理函数中只是打印一条消息,然后返回,却没有修复导致异常的程序状态,进程可能继续执行同一条错误指令。

于是再次发生异常。

例如:

text 复制代码
执行非法访问指令
        |
        v
产生SIGSEGV
        |
        v
执行处理函数
        |
        v
处理函数返回
        |
        v
重新执行原错误指令
        |
        v
再次发生异常

这个现象很适合理解异常与信号的关系。

但它不代表我们应该在真实程序中反复捕捉 SIGSEGV 后继续运行。

对于由未定义行为造成的严重程序错误,普通信号处理函数通常不能安全地恢复程序执行。


十四、Core Dump:进程为什么会生成core文件?

14.1 什么是Core Dump?

当程序发生某些异常并终止时,操作系统可能按照当前配置生成包含进程相关内存状态、寄存器状态等信息的转储文件。

这通常称为:

text 复制代码
Core Dump

它能够帮助开发者在程序退出以后分析错误原因。

例如:

text 复制代码
程序因SIGSEGV异常终止
        |
        v
生成Core Dump
        |
        v
使用调试器检查异常现场

14.2 哪些信号可能产生Core Dump?

常见包括:

text 复制代码
SIGQUIT
SIGILL
SIGABRT
SIGFPE
SIGSEGV

但要注意:

默认动作为Core并不意味着系统一定在当前目录生成一个名为core的文件。

还受到资源限制、系统配置、安全策略与 Core Dump 收集服务等因素影响。

14.3 查看Core文件大小限制

执行:

bash 复制代码
ulimit -c

可以查看当前 Shell 环境中的 Core Dump 文件大小限制。

例如:

text 复制代码
0

通常表示禁止生成传统 Core Dump 文件。

14.4 修改Core Dump限制

在实验终端中:

bash 复制代码
ulimit -c unlimited

允许生成的 Core Dump 大小不再受这个资源限制约束。

但文件是否实际出现,还取决于系统其他配置。

可以查看:

bash 复制代码
cat /proc/sys/kernel/core_pattern

了解内核配置的 Core Dump 输出模式。

14.5 安全的实验思路

可以写一个只用于测试异常终止的程序:

c 复制代码
#include <signal.h>

int main(void)
{
    raise(SIGABRT);
    return 0;
}

编译时保留调试信息:

bash 复制代码
gcc -g core_test.c -o core_test

运行:

bash 复制代码
./core_test

程序将因 SIGABRT 异常终止。

如果系统配置允许并生成了 Core Dump,可以使用调试工具继续分析。

例如:

bash 复制代码
gdb ./core_test core

这里的 core 只是示例文件名,实际文件名和保存位置应以系统配置为准。

注意:Core Dump可能包含程序运行时的敏感数据,不应随意公开或上传。

14.6 父进程怎样知道子进程是否发生Core Dump?

这可以结合我们之前学习的 waitpid()。

例如:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();

    if (pid == 0)
    {
        raise(SIGABRT);
        _exit(0);
    }

    int status = 0;

    if (waitpid(pid, &status, 0) < 0)
    {
        perror("waitpid");
        return 1;
    }

    if (WIFSIGNALED(status))
    {
        printf(
            "signal = %d\n",
            WTERMSIG(status)
        );

#ifdef WCOREDUMP
        printf(
            "core dump flag = %d\n",
            !!WCOREDUMP(status)
        );
#endif
    }

    return 0;
}

这里:

c 复制代码
WIFSIGNALED(status)

判断子进程是否由信号终止。

c 复制代码
WTERMSIG(status)

提取终止信号编号。

部分平台还提供:

c 复制代码
WCOREDUMP(status)

用于检查等待状态中的 Core Dump 标志。

注意它并不是所有 POSIX 平台都具备的标准宏,而且相关标志不能单独保证某个指定路径存在 Core 文件。


十五、总结:Linux信号究竟如何产生?

按照本篇的学习顺序,可以将信号产生方式归纳如下。

产生方式 典型来源 示例
终端控制字符 终端驱动 Ctrl+C、Ctrl+\、Ctrl+Z
系统命令 Shell调用相关接口 kill -SIGTERM PID
函数调用 应用程序主动产生信号 kill()、raise()、abort()
软件条件 定时器、管道状态等 SIGALRM、SIGPIPE
硬件异常 CPU异常及内核异常处理 SIGFPE、SIGSEGV

尽管信号来源不同,但最终都需要通过操作系统管理相关的进程信号状态。

因此,本篇最重要的理解是:

信号是操作系统管理进程的一种事件通知机制;信号产生与信号处理是两个不同阶段。


十六、课后思考

结合之前的进程控制与本篇知识,可以思考以下问题。

  1. signal() 是否会直接执行处理函数?
  2. Ctrl+C 为什么通常影响终端前台进程组?
  3. 忽略 SIGINT 和阻塞 SIGINT 是同一个概念吗?
  4. 如果信号到来时,进程暂时不能处理,操作系统怎样记录它?
  5. SIGKILL 为什么不能被普通进程忽略?
  6. alarm(1) 为什么不会天然变成每秒一次的周期定时器?
  7. 为什么捕捉 SIGSEGV 后简单返回,不能保证程序恢复正常?

总结

本文根据课程的学习顺序,从生活中的收快递例子出发,逐步认识 Linux 信号。

首先,我们建立了:

text 复制代码
信号产生
信号保存
信号处理

这一整体认识。

随后通过:

c 复制代码
signal()

理解了信号的三种处理方式:

text 复制代码
默认处理
忽略
自定义捕捉

接下来,我们依次学习了:

text 复制代码
终端按键
kill命令
kill()、raise()、abort()
alarm()与软件条件
CPU硬件异常

等信号产生方式。

最后,通过 Core Dump 与 waitpid() 的结合,理解了进程因异常信号退出时,父进程如何获得相关终止状态。

不过,我们现在还没有回答一个最关键的问题:

如果信号已经产生,却暂时没有被处理,那么它究竟保存在哪里?

这就需要深入理解:

text 复制代码
Pending
Block
sigset_t
sigprocmask
sigpending

以及信号真正递达和被捕捉的过程。


相关推荐
2603_969579281 小时前
怎样取消电脑上的自动备份?不同来源的关闭方法
学习
学逆向的1 小时前
C 浮点数e(科学计数法)
c语言·开发语言·网络安全
CV@CV1 小时前
世界模型环境预测与内在表征基础原理
人工智能·学习·边缘计算·量子计算·具身智能
MinterFusion1 小时前
如何在开放麒麟(openKylin)下安装GTK(v0.1.0)
linux·操作系统·软件安装·gtk·明德融创·openkylin·linux图形软件开发
知识分享小能手1 小时前
C学习教程,从入门到精通,C语言数组 —— 知识点详解(8)
c语言·学习·算法
Lzg_na1 小时前
FAT文件系统详解
linux
看着博客敲代码1 小时前
enum4linux 报 Server doesn‘t allow session 与 SMB 主机信息枚举
linux·安全·网络安全
重生之小比特1 小时前
【Linux系统】09 环境变量
linux·运维·服务器
卿卿的产品经理日记1 小时前
【AI产品经理实战】Day 34|计算机科学速成②:从水流开关到 Unicode,把整个电脑拆成一长串 0 和 1
人工智能·笔记·科技·学习·产品经理