深入探讨 Linux 信号:产生、阻塞与捕捉(信号集、可重入函数、原子操作)

1. 理解 信号

1.1 什么是信号

  1. 信号(Signal)是操作系统提供的一种有限的、异步的通信机制,用于向进程传递事件通知。
  2. 信号在 Unix 系统中首次引入,是进程间通信(IPC)的一部分。它们可以由内核、用户或进程本身发出,用于通知进程发生了某些事件,例如硬件异常、非法内存访问、终止请求等。

1.2 信号的基本性质

  1. 异步性

    • 信号是异步发送和接收的,这意味着信号可以在任意时刻被发送和处理,而不必等待进程完成当前执行的操作。
  2. 有限性

    • 信号类型的数量是有限的,每种信号都有特定的编号和用途。 例如,SIGINT 通常用于中断进程(例如用户按下 Ctrl+C),SIGKILL 用于强制终止进程。
  3. 影响对象

    • 信号可以发送给单个进程、进程组或广播给所有相关进程。 发送信号的典型方式是使用 kill 命令或系统调用。
  4. 处理方式

    • 每个信号有默认的处理方式,但进程可以选择忽略某些信号,或者捕获并自定义处理这些信号。

1.3 常见信号类型

以下是一些常见的信号及其用途:

信号名称默认动作描述
SIGHUP1终止挂起检测
SIGINT2终止中断(通常来自 Ctrl+C)
SIGQUIT3核心转储并终止退出(通常来自 Ctrl+\)
SIGILL4核心转储并终止非法指令
SIGABRT6核心转储并终止异常终止
SIGFPE8核心转储并终止浮点异常
SIGKILL9终止强制终止(不可捕获或忽略)
SIGSEGV11核心转储并终止无效内存引用
SIGPIPE13终止向无读进程的管道写数据
SIGALRM14终止计时器到期
SIGTERM15终止请求终止
SIGUSR110终止用户定义信号 1
SIGUSR212终止用户定义信号 2
SIGCHLD17忽略子进程停止或终止
SIGCONT18继续执行继续执行停止的进程
SIGSTOP19停止停止进程(不可捕获或忽略)
SIGTSTP20停止终端停止信号(通常来自 Ctrl+Z)
SIGTTIN21停止后台进程读取终端输入
SIGTTOU22停止后台进程写入终端输出

具体可以通过 kill -l 查看系统定义的信号列表:

在这里插入图片描述


1.4 信号的处理方式

下面是一些常用的信号处理方式:

  1. 默认处理

    • 每个信号都有一个默认的处理动作,例如终止进程、忽略信号或生成核心转储文件。
  2. 忽略信号

    • 进程可以通过设置信号处理函数为 SIG_IGN 来忽略某些信号。
  3. 捕捉信号

    • 进程可以通过设置自定义的信号处理函数来捕捉并处理信号。例如,可以使用 signalsigaction 函数来注册信号处理程序。

示例代码

下面的示例展示了如何捕捉和处理 SIGINT 信号:

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

void handle_signal(int signal) {
    if (signal == SIGINT) {
        printf("Caught SIGINT (Ctrl+C)! Exiting...\n");
        exit(0);
    }
}

int main() {
    // 注册信号处理器
    if (signal(SIGINT, handle_signal) == SIG_ERR) {
        perror("Unable to catch SIGINT");
        exit(1);
    }

    // 无限循环以等待信号
    while (1) {
        printf("Running... Press Ctrl+C to exit.\n");
        sleep(1);
    }

    return 0;
}

执行结果
在这里插入图片描述


2. 产生信号的方式

在Linux系统中,有几种方式可以产生信号:

  1. 使用kill命令可以使用kill命令向指定进程发送信号。例如,使用以下命令向进程ID为12345的进程发送SIGINT信号:

    kill -SIGINT 12345
    
  2. 按键盘组合键:在终端中,可以使用Ctrl+C来发送SIGINT信号,通常用于终止正在运行的程序。

  3. 软件中使用库函数 :在C/C++程序中,可以使用库函数raisekill来发送信号。例如,以下代码将向自身进程发送SIGUSR1信号:

    #include <signal.h>
    int main() {
        raise(SIGUSR1);
        return 0;
    }
    
  4. 硬件中断:硬件设备(如定时器或串口)可以通过向操作系统发送中断请求来产生信号。

  5. 其他辅助工具:还有其他一些辅助工具和技术可以产生信号,例如使用GDB调试器发送信号或使用特殊设备文件(如/dev/input/eventX)模拟输入事件产生信号。


3. 阻塞信号

3.1 信号状态

  1. 未决(Pending)

    • 当信号被发送到进程(通过kill命令、键盘中断等方式),但尚未被进程处理(例如进程当前正在运行或信号被阻塞)时,信号处于未决状态。
    • 一个信号在未决状态时不会重复排队,即使同一信号被发送多次,未决信号列表中也只会有一个该信号的实例。
  2. 递达(Delivery)

    • 当信号被进程接收并处理时,称为信号递达。处理动作可以是执行一个信号处理函数、终止进程、忽略信号等。

3.2 信号操作(signal、sigpromask、sigaction)

  1. 阻塞(Block)

    • 进程可以选择阻塞某个信号,使其在未解除阻塞之前保持在未决状态。即使信号被发送到进程,也不会立即递达。
    • 阻塞信号可以通过sigprocmask函数来实现,例如:
      sigset_t newmask, oldmask;
      sigemptyset(&newmask);
      sigaddset(&newmask, SIGINT); // 阻塞SIGINT信号
      sigprocmask(SIG_BLOCK, &newmask, &oldmask);
      
  2. 解除阻塞(Unblock)

    • 当进程解除对某个信号的阻塞时,如果该信号在未决状态下,则会立即递达并处理。
    • 解除阻塞可以通过sigprocmask函数来实现,例如:
      sigprocmask(SIG_SETMASK, &oldmask, NULL); // 解除阻塞
      
  3. 忽略(Ignore)

    • 在信号递达之后,进程可以选择忽略该信号,不做任何处理。这与阻塞不同,阻塞是在信号递达之前的控制,而忽略是在信号递达之后的处理动作。
    • 忽略信号可以通过signalsigaction函数来设置,例如:
      signal(SIGINT, SIG_IGN); // 忽略SIGINT信号
      

3.3 示例代码(信号阻塞 & 解除阻塞)

下面的示例程序演示了信号阻塞和解除阻塞的过程:

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

// 定义SIGINT信号的处理函数
void handle_sigint(int sig) {
    printf("Received signal %d\n", sig);
}

int main() {
    sigset_t newmask, oldmask;

    // 设置SIGINT信号处理函数
    signal(SIGINT, handle_sigint);

    // 初始化信号集
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGINT);

    // 阻塞SIGINT信号
    if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) < 0) {
        perror("sigprocmask");
        exit(EXIT_FAILURE);
    }

    printf("SIGINT signal blocked\n");

    // 模拟长时间运行
    sleep(10);

    // 解除阻塞
    if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0) {
        perror("sigprocmask");
        exit(EXIT_FAILURE);
    }

    printf("SIGINT signal unblocked\n");

    // 再次等待以演示信号处理
    sleep(10);

    return 0;
}

在这个示例中,程序首先设置SIGINT的处理函数。然后将SIGINT信号阻塞,并等待10秒。在这段时间内,如果按下Ctrl+C,信号会被阻塞并保持未决状态。10秒后,程序解除对SIGINT信号的阻塞,此时如果之前有未决的SIGINT信号,会立即递达并调用处理函数。随后,程序再次等待10秒,这时按下Ctrl+C会立即触发信号处理函数。


3.4 信号在内核中的表示

在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block):未决(pending),还有一个函数指针表示处理动作
    • 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
    • 在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,暂时不能递达。
    • 虽然其处理动作是忽略,但在未解除阻塞之前不能忽略该信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler
    • 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
    • POSIX.1允许系统递送该信号一次或多次。
    • 对于linux:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里

4. 信号集操作函数

4.1 sigset_t

在介绍信号集操作函数前,首先需要了解sigset_t:

sigset_t 是一个用于表示信号集的数据类型。它通常用于在信号处理中设置和管理需要阻塞或处理的信号。

C语言中,sigset_t 是一个位向量,每个位对应一个特定的信号。通过在 sigset_t 中设置或清除特定的位,可以实现对信号集的操作,例如将某个信号添加到集合中、从集合中移除某个信号等。

下面会介绍一些与sigset_t相关的操作函数:


4.2 操作函数

函数原型功能返回值
sigemptysetint sigemptyset(sigset_t *set);初始化信号集,使其不包含任何信号。成功返回 0,失败返回 -1 并设置 errno
sigfillsetint sigfillset(sigset_t *set);初始化信号集,使其包含所有信号。成功返回 0,失败返回 -1 并设置 errno
sigaddsetint sigaddset(sigset_t *set, int signum);将指定的信号添加到信号集中。成功返回 0,失败返回 -1 并设置 errno
sigdelsetint sigdelset(sigset_t *set, int signum);从信号集中删除指定的信号。成功返回 0,失败返回 -1 并设置 errno
sigismemberint sigismember(const sigset_t *set, int signum);检查指定的信号是否在信号集中。信号在信号集中返回 1,不在信号集中返回 0,出错返回 -1 并设置 errno
sigprocmaskint sigprocmask(int how, const sigset_t *set, sigset_t *oldset);更改进程的信号屏蔽字(即阻塞和解除阻塞的信号)。成功返回 0,失败返回 -1 并设置 errno
sigpendingint sigpending(sigset_t *set);获取当前未决信号集(即已经产生但尚未处理的信号)。成功返回 0,失败返回 -1 并设置 errno
sigsuspendint sigsuspend(const sigset_t *mask);替换进程的信号屏蔽字并挂起进程,直到收到一个信号。总是返回 -1,并将 errno 设置为 EINTR

这些操作函数可以对 sigset_t 类型的信号集进行各种操作,从而有效地管理信号的阻塞和处理。


5. 信号的捕捉

在这里插入图片描述

信号捕捉(Signal Handling) : 是指在程序中捕获并处理操作系统发送的信号。当进程收到信号时,可以选择执行自定义的信号处理函数,以响应该信号的发生。


5.1 内核如何实现信号捕捉

在操作系统内核中,实现信号的捕捉(signal handling)涉及以下步骤与概念:

  1. 信号表(Signal Table)

    • 内核维护一个信号表,用于记录每种信号的处理方式(处理函数或默认操作),以及信号当前的状态(是否被阻塞等)。
  2. 注册信号处理函数

    • 进程可以使用系统调用 sigaction 或类似的接口向内核注册信号处理函数。这个函数通常是一个用户空间定义的回调函数,用于在接收到信号时执行特定的操作。
  3. 信号发送和传递

    • 当进程或系统中的某些事件发生时(例如用户按下 Ctrl+C),内核会生成相应的信号,并将其传递给适当的进程。
  4. 信号的状态管理

    • 内核负责管理信号的当前状态,包括信号是否被阻塞、是否有未决的信号等。这些状态确保了信号的可靠传递和处理。
  5. 信号处理过程

    • 当进程接收到一个信号时,内核首先检查该信号的处理方式。如果进程已注册了处理函数,内核会调用该处理函数。如果进程忽略了该信号或未注册处理函数,则执行默认的处理操作。
  6. 信号的阻塞和解除阻塞

    • 进程可以使用 sigprocmask 系统调用来阻塞或解除阻塞特定的信号。当信号被阻塞时,进程暂时不会接收到这些信号,直到解除阻塞为止。
  7. 信号的传递方式

    • 不同的信号可以以不同的方式传递给进程,例如同步传递(发送信号的线程会被阻塞,直到信号被处理)和异步传递(信号处理完全由内核完成,进程可以在任何时刻接收到信号)。

5.2 signal

signal 函数是一个比较简单的信号处理机制,用于设置一个信号处理函数来响应特定的信号。尽管 signalsigaction 更容易使用,但它在一些平台上存在不可靠性和不一致性,因此通常推荐使用 sigaction 进行更加可靠的信号处理。但是,对于简单的应用场景,signal 仍然可以有效地使用。

signal 函数原型

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
  • signum:要捕捉或处理的信号编号。
  • handler:指向信号处理函数的指针,或者特殊值 SIG_IGN(忽略信号)和 SIG_DFL(恢复默认处理)。

常见的信号处理函数类型:

  • SIG_IGN:忽略信号。
  • SIG_DFL:使用信号的默认处理。
  • 自定义的信号处理函数:用户定义的函数,用于处理信号。

使用实例:

下面的一个小实例,演示了如何捕捉 SIGINT 信号(即 Ctrl+C)并注册相应的信号处理函数:

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

// 信号处理函数
void signal_handler(int signum) {
    printf("Caught signal %d (Ctrl+C pressed)\n", signum);
}

int main() {
    // 注册 SIGINT 的信号处理函数
    if (signal(SIGINT, signal_handler) == SIG_ERR) {
        perror("signal");
        return 1;
    }

    printf("Press Ctrl+C to send a SIGINT signal\n");

    while (1) {
        // 等待信号
        sleep(1);
    }
    return 0;
}

运行这个示例程序时,按下 Ctrl+C(发送 SIGINT 信号),就会触发注册的信号处理函数,并打印出相应的消息。

注意事项:

  • 在某些系统上,使用 signal 注册的信号处理函数可能会因为其他信号的到来而被覆盖。因此,sigaction 通常是更好的选择。
  • signal 函数可能在不同的系统和编译器实现中表现出不一致的行为,特别是在处理复杂信号交互时。

5.3 sigaction

sigaction 是一个更为灵活和可靠的方式来处理信号,相比于 signal 函数,它提供了更多的控制和选项。主要的优点包括:

  1. 可靠性sigaction 提供了更可靠的信号处理机制,避免了某些信号在处理期间被重复触发或丢失的情况。

  2. 更多选项sigaction 允许你指定更详细的操作和标志,例如指定信号处理函数的行为、设置信号的掩码(mask)、以及提供更复杂的信号处理选项。

sigaction 函数原型:

#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signum:要捕捉或处理的信号编号。
  • act:指向 struct sigaction 结构的指针,定义了新的信号处理方式(处理函数、信号掩码等)。
  • oldact:可选参数,如果不为 NULL,则用来存储旧的信号处理方式。

struct sigaction 结构

struct sigaction {
    void (*sa_handler)(int);   // 指定信号处理函数
    sigset_t sa_mask;          // 指定在处理信号时要阻塞的信号集
    int sa_flags;              // 设置信号处理的行为选项
    void (*sa_sigaction)(int, siginfo_t *, void *);  // 另一种信号处理函数,可以接收额外的信息
};

使用示例:

下面是一个简单的示例演示如何使用 sigaction 来捕捉 SIGINT 信号,并注册相应的信号处理函数:

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

// 信号处理函数
void signal_handler(int signum) {
    printf("Caught signal %d (Ctrl+C pressed)\n", signum);
    exit(signum); // 退出程序,返回信号值作为退出码
}

int main() {
    struct sigaction sa;

    // 设置 sa_handler 为 signal_handler 函数
    sa.sa_handler = signal_handler;
    // 清空 sa_mask,即在处理信号时不阻塞任何其他信号
    sigemptyset(&sa.sa_mask);
    // 设置 sa_flags 为 0,不需要特殊的标志
    sa.sa_flags = 0;

    // 使用 sigaction 捕捉 SIGINT 信号
    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    printf("Press Ctrl+C to send a SIGINT signal\n");

    // 持续运行,等待信号
    while (1) {
        sleep(1);
    }

    return 0;
}

通过使用 sigaction,可以更精细地控制信号的处理方式,包括设置信号处理函数、阻塞其他信号以及配置更复杂的信号处理选项,从而实现更为可靠和灵活的信号处理机制。


6. 可重入函数

可重入函数 :是指在函数调用过程中,即使在多个线程同时调用或者在同一线程内被中断,它仍然能够正确执行而不会产生不良影响或错误的函数。这种函数通常满足以下条件:

  1. 无全局或静态变量:函数不使用任何全局变量或静态变量,因为这些变量可能会在函数调用间共享,导致竞态条件或不确定行为。

  2. 不使用静态数据结构:避免使用静态分配的数据结构,因为它们在不同调用之间可能会共享,也可能会导致线程安全问题。

  3. 不调用不可重入函数:函数内部不调用其他可能不是可重入的函数。例如,使用了全局变量或静态变量的函数通常不是可重入的。

  4. 使用局部变量或参数:只使用函数的参数和局部变量,这些变量对于每个函数调用都是独立的,因此不会被其他线程或调用中断影响。

  5. 使用线程安全的函数调用:如果必须调用其他函数,确保这些函数是线程安全的,或者采取适当的同步措施来保护共享资源。

  6. 不使用动态分配内存:尽量避免在函数内部使用动态分配的内存,因为动态分配的内存在不同的函数调用之间可能会导致竞态条件或内存泄漏。

例子:

// 可重入函数的示例
int add(int a, int b) {
    return a + b;
}

// 非可重入函数的示例(使用了静态变量)
int non_reentrant() {
    static int counter = 0;
    counter++;
    return counter;
}

在实际编程中,编写可重入函数对于多线程环境或者在信号处理函数中尤为重要,因为这些情况下函数的执行可能会在任何时刻被中断,如果函数不是可重入的,可能会导致数据损坏或逻辑错误。


6.1 原子操作

原子操作 是不可分割的操作步骤,它要么完全执行成功,要么完全不执行,没有中间状态。在并发编程中,原子操作对于确保数据的一致性和避免竞态条件(race condition)至关重要。

原子操作通常具备以下特征 :

  1. 不可分割性:原子操作在执行期间不会被中断。在多线程或并发环境下,不会发生其他线程同时访问该操作的情况。

  2. 完整性:原子操作要么完全执行成功,要么不执行(即执行失败或回滚)。它不会因为部分执行而留下不一致或不完整的结果。

  3. 独占性:在执行原子操作期间,其他线程无法访问或修改相关资源。这可以通过硬件机制(如处理器提供的原子指令)或者软件机制(如锁或信号量)来实现。

  4. 可见性:原子操作对所有线程都是透明的,即其他线程能够立即看到原子操作的影响,而不会看到中间状态。

原子操作一般用于 :

  • 多线程编程:确保共享数据的操作是安全的,避免数据竞争和并发错误。
  • 信号处理器:处理信号时需要确保信号处理函数的执行是原子的,以避免中断处理的不一致性。
  • 硬件级操作:如处理器提供的原子指令,用于实现原子操作,如原子增减、比较交换等操作。

如C++11及后版本,提供了 std::atomic 类模板,用于封装原子类型的操作,可以安全地在多线程环境中使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值