【Linux系统编程】进程信号详解:从入门到深入

本文详细讲解Linux进程信号的核心概念、信号产生、信号保存、信号捕捉,以及底层原理,适合计算机专业学生和Linux开发者系统学习。

    本节重点

  1. 掌握Linux信号的基本概念
  2. 掌握信号产生的一般方式
  3. 理解信号递达和阻塞的概念、原理
  4. 掌握信号捕捉的一般方式
  5. 了解中断过程,理解中断的意义
  6. 掌握操作系统运行、系统调用原理,理解缺页异常等异常的基本原理
  7. 重新了解可重入函数的概念
  8. 了解竞态条件的情景和处理方式
  9. 了解SIGCHLD信号,重新编写信号处理函数的一般处理机制

1. 信号快速认识

1-1 生活角度的信号

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能"识别快递"
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成"在合适的时候去取"
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你"记住了有一个快递要去取"
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:
    1. 执行默认动作(幸福的打开快递,使用商品)
    2. 执行自定义动作(快递是零食,你要送给你的女朋友)
    3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

📌 基本结论:

  • 你怎么能识别信号呢?识别信号是内置的,进程识别信号,是内核程序员写的内置特性。
  • 信号产生之后,你知道怎么处理吗?知道。如果信号没有产生,你知道怎么处理信号吗?知道。所以,信号的处理方法,在信号产生之前,已经准备好了。
  • 处理信号,立即处理吗?我可能正在做优先级更高的事情,不会立即处理?什么时候?合适的时候。
  • 信号到来 | 信号保存 | 信号处理
  • 怎么进行信号处理啊?a.默认 b.忽略 c.自定义,后续都叫做信号捕捉。

1-2 技术应用角度的信号

1-2-1 一个样例
// sig.cc
#include <iostream>
#include <unistd.h>

int main()
{
    while(true) {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
$ g++ sig.cc -o sig
$ ./sig
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C
  • 用户输入命令,在Shell下启动一个前台进程
  • 用户按下 Ctrl+C,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
  • 前台进程因为收到信号,进而引起进程退出
1-2-2 一个系统函数
NAME
    signal - ANSI C signal handling
SYNOPSIS
    #include <signal.h>
    typedef void (*sighandler_t)(int);
    sighandler_t signal(int signum, sighandler_t handler);

参数说明:

  • signum:信号编号[后面解释,只需要知道是数字即可]
  • handler:函数指针,表示更改信号的处理动作,当收到对应的信号,就回调执行handler方法

💡 而其实,Ctrl+C 的本质是向前台进程发送 SIGINT2号信号,我们证明一下:

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

void handler(int signumber)
{
    std::cout << "我是:" << getpid() << ",我获得了一个信号:" << signumber << std::endl;
}

int main()
{
    std::cout << "我是进程:" << getpid() << std::endl;
    signal(SIGINT/*2*/, handler);
    while(true) {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

运行结果:

$ g++ sig.cc -o sig
$ ./sig
我是进程:212569
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是:212569,我获得了一个信号:2
I am a process, I am waiting signal!
^C我是:212569,我获得了一个信号:2
I am a process, I am waiting signal!

📌 思考:

  • 这里进程为什么不退出?
  • 这个例子能说明哪些问题?信号处理,是自己处理
  • 请将生活例子和 Ctrl+C 信号处理过程相结合,解释一下信号处理过程? 进程就是你,操作系统就是快递员,信号就是快递,发信号的过程就类似给你打电话

⚠️ 注意:

  • 要注意的是,signal 函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作。如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用!!
  • Ctrl+C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  • Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl+C 这种控制键产生的信号。
  • 前台进程在运行过程中用户随时可能按下 Ctrl+C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是**异步(Asynchronous)**的。
  • 可以渗透 &nohup

1-3 信号概念

信号是进程之间事件异步通知的一种方式,属于软中断

1-3-1 查看信号
whb@bite:~/code/test$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signal.h 中找到,例如其中有定义:

#define SIGHUP        1   /* Hangup. */
#define SIGINT        2   /* Interactive attention signal. */
#define SIGQUIT       3   /* Quit. */
#define SIGILL        4   /* Illegal instruction. */
#define SIGTRAP       5   /* Trace/breakpoint trap. */
#define SIGABRT       6   /* Abnormal termination. */
#define SIGBUS        7   /* Bus error. */
#define SIGFPE        8   /* Erroneous arithmetic operation. */
#define SIGKILL       9   /* Killed. */
#define SIGUSR1      10   /* User-defined signal 1. */
#define SIGSEGV      11   /* Invalid access to storage. */
#define SIGUSR2      12   /* User-defined signal 2. */
#define SIGPIPE      13   /* Broken pipe. */
#define SIGALRM      14   /* Alarm clock. */
#define SIGTERM      15   /* Termination request. */

📌 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在 signal(7) 中都有详细说明:man 7 signal

常见信号说明:

信号编号默认动作说明
SIGHUP1Term挂起终端或控制进程结束
SIGINT2Term终端中断(Ctrl+C)
SIGQUIT3Core终端退出(Ctrl+\)
SIGILL4Core非法指令
SIGFPE8Core浮点异常
SIGKILL9Term杀死进程(不可捕获/阻塞)
SIGSEGV11Core段错误(无效内存引用)
SIGPIPE13Term管道破裂
SIGALRM14Term闹钟超时
SIGTERM15Term终止信号
SIGCHLD17Ign子进程停止或终止
SIGCONT18Cont继续暂停的进程
SIGSTOP19Stop暂停进程(不可捕获/阻塞)
SIGTSTP20Stop终端暂停(Ctrl+Z)
1-3-2 信号处理

sigaction 函数稍后详细介绍,可选的处理动作有以下三种:

  1. 忽略此信号
#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int signumber)
{
    std::cout << "我是:" << getpid() << ",我获得了一个信号:" << signumber << std::endl;
}

int main()
{
    std::cout << "我是进程:" << getpid() << std::endl;
    signal(SIGINT/*2*/, SIG_IGN);  // 设置忽略信号的宏
    while(true) {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
$ ./sig
我是进程:212681
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C^C^C^C^CI am a process, I am waiting signal!  // 输入ctrl+c毫无反应
I am a process, I am waiting signal!
  1. 执行该信号的默认处理动作
signal(SIGINT/*2*/, SIG_DFL);
  1. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为自定义捕捉(Catch)一个信号

📌 注意看源码:

#define SIG_DFL ((__sighandler_t) 0)  /* Default action. */
#define SIG_IGN ((__sighandler_t) 1)  /* Ignore signal.  */
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);

其实 SIG_DFLSIG_IGN 就是把0,1强制转为函数指针类型


2. 产生信号

信号生命周期:信号产生 → 信号保存 → 信号处理

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

2-1-1 基本操作
  • Ctrl+C(SIGINT):中断信号,终止进程
  • Ctrl+\(SIGQUIT):发送终止信号并生成core dump文件,用于事后调试
  • Ctrl+Z(SIGTSTP):发送停止信号,将当前前台进程挂起到后台
#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int signumber)
{
    std::cout << "我是:" << getpid() << ",我获得了一个信号:" << signumber << std::endl;
}

int main()
{
    std::cout << "我是进程:" << getpid() << std::endl;
    signal(SIGQUIT/*3*/, handler);  // 注册SIGQUIT信号
    while(true) {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
2-1-2 理解OS如何得知键盘有数据
  1. 键盘按下,硬件向CPU发送硬件中断
  2. CPU识别到硬件中断信号,暂停当前工作
  3. CPU执行操作系统中处理键盘数据的代码逻辑
  4. 操作系统从外设读入内存,等待进一步处理

📌 注意:

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

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

$ g++ sig.cc -o sig
$ ./sig &
$ ps ajx | head -1 && ps ajx | grep sig

 PPID    PID    PGID    SID TTY    TPGID STAT UID  TIME COMMAND
211805 213784 213784 211805 pts/0  213792 S  1002 0:00 ./sig
$ kill -SIGSEGV 213784
$ // 多按一次回车
[1]+  Segmentation fault      ./sig
  • 213784sig 进程的pid。之所以要再次回车才显示 Segmentation fault,是因为在213784进程终止掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望 Segmentation fault 信息和用户的输入交错在一起,所以等用户输入命令之后才显示。
  • 指定发送某种信号的 kill 命令可以有多种写法,上面的命令还可以写成 kill -11 21378411 是信号 SIGSEGV 的编号。

2-3 使用函数产生信号

2-3-1 kill函数
NAME
    kill - send signal to a process

SYNOPSIS
    #include <sys/types.h>
    #include <signal.h>
    int kill(pid_t pid, int sig);

RETURN VALUE
    On success (at least one signal was sent), zero is returned.
    On error, -1 is returned, and errno is set appropriately.

样例:实现自己的kill命令

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

// mykill -signumber pid
int main(int argc, char *argv[])
{
    if(argc != 3) {
        std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
        return 1;
    }
    int number = std::stoi(argv[1]+1);  // 去掉-
    pid_t pid  = std::stoi(argv[2]);

    int n = kill(pid, number);
    return n;
}
2-3-2 raise函数

raise 函数可以给当前进程发送指定的信号(自己给自己发信号)。

NAME
    raise - send a signal to the caller

SYNOPSIS
    #include <signal.h>
    int raise(int sig);

RETURN VALUE
    raise() returns 0 on success, and nonzero for failure.

样例:

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

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

int main()
{
    signal(2, handler);   // 先对2号信号进行捕捉
    // 每隔1s,自己给自己发送2号信号
    while(true) {
        sleep(1);
        raise(2);
    }
}
2-3-3 abort函数

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

NAME
    abort - cause abnormal process termination

SYNOPSIS
    #include <stdlib.h>
    void abort(void);

RETURN VALUE
    The abort() function never returns.

实验可以得知,abort 给自己发送的是固定6号信号(SIGABRT),虽然捕捉了,但是还是要退出。

2-4 由软件条件产生信号

2-4-1 alarm函数与SIGALRM信号
NAME
    alarm - set an alarm clock for delivery of a signal

SYNOPSIS
    #include <unistd.h>
    unsigned int alarm(unsigned int seconds);

RETURN VALUE
    alarm() returns the number of seconds remaining until any previously
    scheduled alarm was due to be delivered, or zero if there was no previ-
    ously scheduled alarm.
  • 调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终止当前进程。
  • 这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。

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

程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止。

IO多(频繁打印)IO少(纯计算)
cpp int main() { int count = 0; alarm(1); while(true) { std::cout << "count : " << count << std::endl; count++; } return 0; } cpp 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效率低,无IO效率高(纯计算1秒能数49亿次!)
2-4-2 设置重复闹钟
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>

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++;
    }
}

📌 结论:

  • 闹钟设置一次,起效一次
  • 重复设置的方法:在handler里面重新调用alarm
  • 如果时间允许,可以测试一下 alarm(0)
2-4-3 如何理解软件条件

在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于:

  • 定时器超时(如 alarm 函数设定的时间到达)
  • 软件异常(如向已关闭的管道写数据产生的 SIGPIPE 信号)

当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。

2-4-4 如何简单快速理解系统闹钟

现代Linux是提供了定时功能的,内核中的定时器数据结构是:

struct timer_list {
    struct list_head entry;
    unsigned long expires;
    void (*function)(unsigned long);
    unsigned long data;
    struct tvec_t_base_s *base;
};

操作系统管理定时器,采用的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构"。

2-5 硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如:

  • 当前进程执行了除以0的指令 → CPU运算单元产生异常 → 内核解释为 SIGFPE 信号发送给进程
  • 当前进程访问了非法内存地址 → MMU会产生异常 → 内核将这个异常解释为 SIGSEGV 信号发送给进程
2-5-1 模拟除0
#include <stdio.h>
#include <signal.h>

void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
}

int main()
{
    //signal(SIGFPE, handler);  // 8) SIGFPE
    sleep(1);
    int a = 10;
    a /= 0;

    while(1);
    return 0;
}
2-5-2 模拟野指针
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++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。

⚠️ 注意: 发现一直有8号信号产生被我们捕获,这是为什么呢?上面我们只提到CPU运算异常后,如何处理后续的流程。实际上OS会检查应用程序的异常情况,其实在CPU中有一些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位。OS会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法。

2-5-4 Core Dump

什么是Core Dump? 当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这叫做Core Dump。

进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)

$ ulimit -c 1024   # 允许core文件最大为1024K
$ ulimit -a
core file size          (blocks, -c) 1024

使用core文件调试:

$ gdb ./test
(gdb) core-file core.4506
Program terminated with signal 11, Segmentation fault.
#0  0x080483ad in main () at test.c:8
8       *p = 100;

3. 保存信号

3-1 信号其他相关常见概念

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)
  • 进程可以选择阻塞 (Block) 某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

3-2 在内核中的表示

每个信号都有两个标志位分别表示阻塞(block)未决(pending),还有一个函数指针表示处理动作。

信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

📌 内核数据结构(2.6.18版本):

struct task_struct {
    ...
    /* signal handlers */
    struct sighand_struct *sighand;
    sigset_t blocked;
    struct sigpending pending;
    ...
};

struct sigpending {
    struct list_head list;
    sigset_t signal;
};

3-3 sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。

因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的"有效"或"无效"状态:

  • 在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞
  • 在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态

阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。

3-4 信号集操作函数

#include <signal.h>
int sigemptyset(sigset_t *set);   // 初始化信号集,清空所有位
int sigfillset(sigset_t *set);    // 初始化信号集,置所有位
int sigaddset(sigset_t *set, int signo);   // 添加信号到集合
int sigdelset(sigset_t *set, int signo);   // 从集合删除信号
int sigismember(const sigset_t *set, int signo);  // 判断是否包含信号
3-4-1 sigprocmask

调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值: 若成功则为0,若出错则为-1
how参数说明
SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于 `mask = mask
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask & ~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于 mask = set
3-4-2 sigpending
#include <signal.h>
int sigpending(sigset_t *set);
// 读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1

完整实验代码:

#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>

void PrintPending(sigset_t &pending)
{
    std::cout << "curr process[" << getpid() << "]pending: ";
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo)) {
            std::cout << 1;
        } else {
            std::cout << 0;
        }
    }
    std::cout << "\n";
}

void handler(int signo)
{
    std::cout << signo << " 号信号被递达!!!" << std::endl;
    std::cout << "-----------------------------" << std::endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    std::cout << "-----------------------------" << std::endl;
}

int main()
{
    // 0. 捕捉2号信号
    signal(2, handler);

    // 1. 屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT);

    // 1.1 设置进入进程的block表
    sigprocmask(SIG_BLOCK, &block_set, &old_set);

    int cnt = 15;
    while (true)
    {
        // 2. 获取当前进程的pending信号集
        sigset_t pending;
        sigpending(&pending);

        // 3. 打印pending信号集
        PrintPending(pending);
        cnt--;

        // 4. 解除对2号信号的屏蔽
        if (cnt == 0) {
            std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
            sigprocmask(SIG_SETMASK, &old_set, &block_set);
        }
        sleep(1);
    }
}

运行结果:

$ ./run
curr process[448336]pending: 0000000000000000000000000000000
^Ccurr process[448336]pending: 0000000000000000000000000000010
curr process[448336]pending: 0000000000000000000000000000010
...
解除对2号信号的屏蔽!!!
2 号信号被递达!!!

4. 捕捉信号

4-1 信号捕捉的流程

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号

由于信号处理函数的代码是在用户空间的,处理过程比较复杂:

  1. 用户程序注册了 SIGQUIT 信号的处理函数 sighandler
  2. 当前正在执行 main 函数,这时发生中断或异常切换到内核态
  3. 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达
  4. 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数
  5. sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态
  6. 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了

💡 理解: sighandlermain 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

4-2 sigaction

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
  • sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。
  • signo 是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oact 指针非空,则通过 oact 传出该信号原来的处理动作。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

4-3 穿插话题 - 操作系统是怎么运行的

4-3-1 硬件中断
  • 中断向量表就是操作系统的一部分,启动就加载到内存中了
  • 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
  • 由外部设备触发的,中断系统运行流程,叫做硬件中断
4-3-2 时钟中断

💡 问题:进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?

答案:时钟中断!

这样,操作系统不就在硬件的推动下,自动调度了么!!!

// Linux 内核0.11
void sched_init(void)
{
    ...
    set_intr_gate(0x20, &timer_interrupt);  // 修改中断控制器屏蔽码,允许时钟中断
    outb(inb_p(0x21) & ~0x01, 0x21);
    // 设置系统调用中断门
    set_system_gate(0x80, &system_call);
}
4-3-3 死循环

如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。

操作系统的本质:就是一个死循环!

void main(void)
{
    ...
    for (;;)
        pause();
}

这样,操作系统就可以在硬件时钟的推动下,自动调度了。

所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?主频可以作为OS调度执行速度的参考之一。

4-3-4 软中断
  • 上述外部硬件中断,需要硬件设备触发
  • 有没有可能,因为软件原因,也触发上面的逻辑?有!
  • 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。

💡 问题:

  • 用户层怎么把系统调用号给操作系统? → 寄存器(比如EAX)
  • 操作系统怎么把返回值给用户? → 寄存器或者用户传入的缓冲区地址
  • 系统调用的过程,其实就是先 int 0x80syscall 陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
  • 系统调用号的本质:数组下标!
// 系统调用函数指针表。用于系统调用中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = {
    sys_setup, sys_exit, sys_fork, sys_read,
    sys_write, sys_open, sys_close, sys_waitpid, ...
};
4-3-5 缺页中断?内存碎片处理?除零野指针错误?
  • 缺页中断?内存碎片处理?除零野指针错误?这些,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。
  • 有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进程发送信号,杀掉进程等等。

📌 所以:

  • 操作系统就是躺在中断处理例程上的代码块!
  • CPU内部的软中断,比如 int 0x80 或者 syscall,我们叫做陷阱
  • CPU内部的软中断,比如除零/野指针等,我们叫做异常。(所以,能理解"缺页异常"为什么这么叫了吗?)

4-4 如何理解内核态和用户态

结论:

  • 操作系统无论怎么切换进程,都能找到同一个操作系统!换句话说操作系统系统调用方法的执行,是在进程的地址空间中执行的!
  • 用户态就是执行用户 [0,3G] 时所处的状态
  • 内核态就是执行内核 [3,4G] 时所处的状态
  • 区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。
  • 一般执行 int 0x80 或者 syscall 软中断,CPL会在校验之后自动变更

5. 可重入函数

什么是可重入函数?

insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入

insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数

🤔 为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了 mallocfree,因为 malloc 也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

6. volatile 关键字

该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下。

标准情况下(无优化):

#include <stdio.h>
#include <signal.h>

int flag = 0;

void handler(int sig)
{
    printf("chage flag 0 to 1\n");
    flag = 1;
}

int main()
{
    signal(2, handler);
    while(!flag);
    printf("process quit normal\n");
    return 0;
}

运行结果:

$ ./sig
^Cchage flag 0 to 1
process quit normal

优化情况下(-O2优化):

当使用 -O2 编译优化时,CPU检测到 flag 不会在循环中被修改,会把 flag 优化到CPU寄存器中,即使信号处理函数修改了内存中的 flag,CPU读取的还是寄存器中的旧值。

解决方案:volatile

volatile int flag = 0;

📌 volatile 作用: 保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。


7. SIGCHLD信号

进程一章讲过用 waitwaitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。

其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。

测试代码:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void handler(int sig)
{
    pid_t id;
    while( (id = waitpid(-1, NULL, WNOHANG)) > 0) {
        printf("wait child success: %d\n", id);
    }
    printf("child is quit! %d", getpid());
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t cid;
    if((cid = fork()) == 0) { // child
        printf("child : %d\n", getpid());
        sleep(3);
        exit(1);
    }
    while(1) {
        printf("father proc is doing some thing!\n");
        sleep(1);
    }
    return 0;
}

💡 事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigactionSIGCHLD 的处理动作置为 SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。


8. 附录 - 课外阅读

用户态和内核态

  • CPU 指令集:是CPU实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条CPU 指令,而非常非常多的CPU指令在一起,可以组成一个、甚至多个集合,指令的集合叫CPU 指令集。

CPU把指令集操作的权限由高到低划为4级:

  • ring 0:权限最高,可以使用所有CPU 指令集 → 内核态
  • ring 1:权限较高
  • ring 2:权限较低
  • ring 3:权限最低,仅能使用常规CPU 指令集 → 用户态

要知道的是,Linux系统仅采用ring 0 和ring 3这2个权限。

用户态与内核态的切换:

什么情况会导致用户态到内核态切换?

  1. 系统调用:用户态进程主动切换到内核态的方式
  2. 异常:当CPU在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中
  3. 中断:当CPU在执行用户态的进程时,外围设备完成用户请求的操作后,会向CPU发出相应的中断信号

切换流程:

  1. 从用户态切换到内核态时,首先用户态可以直接读写寄存器,将寄存器的状态保存到对应的内存中
  2. 从用户态切换到内核态需要提权,CPU切换指令集操作权限级别为ring 0
  3. 提权后,切换内核栈。然后开始执行内核方法,相应的方法栈帧保存在内核栈中
  4. 当内核方法执行完毕后,CPU切换指令集操作权限级别为ring 3,然后利用之前写入的信息来恢复用户栈的执行

📝 总结

阶段核心概念关键函数
信号产生终端按键、系统命令、函数调用、软件条件、硬件异常kill, raise, abort, alarm
信号保存未决(pending)、阻塞(block)、信号屏蔽字sigemptyset, sigaddset, sigprocmask, sigpending
信号处理默认动作、忽略、自定义捕捉signal, sigaction, sigreturn

🎯 核心思想: 信号是进程间异步通信的一种方式,本质是操作系统对硬件中断的软件模拟。理解信号的关键在于理解它的异步性——信号可以在进程执行的任意时刻产生,而处理则可以延迟到"合适的时候"执行。

Logo

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

更多推荐