MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言信号类型的详细解读

2023-03-207.5k 阅读

Linux C 语言信号类型的详细解读

信号基础概念

在 Linux 环境下,信号(Signal)是一种软件中断机制,用于在进程间传递异步事件通知。它是 Linux 系统进程间通信(IPC,Inter - Process Communication)的一种原始的方式。信号可以由内核或其他进程发送给某个进程,通知其发生了特定的事件。进程接收到信号后,会根据信号的类型和进程的设置,采取相应的处理动作。

信号的产生

  1. 用户产生:用户可以通过键盘组合键产生信号,例如 Ctrl + C 产生 SIGINT 信号,通常用于终止前台运行的进程;Ctrl + \ 产生 SIGQUIT 信号,用于终止进程并生成核心转储文件(如果允许的话)。
  2. 内核产生:内核在一些特定情况下会向进程发送信号。比如,当进程执行了非法指令,内核会发送 SIGILL 信号;当进程访问了非法内存地址,会收到 SIGSEGV 信号;当进程试图除以零,内核会发送 SIGFPE 信号。
  3. 进程间产生:一个进程可以使用 kill 函数向另一个进程发送信号。例如,进程 A 知道进程 B 的进程 ID(PID),就可以调用 kill 函数向进程 B 发送特定信号。

信号的处理方式

  1. 默认处理:每个信号都有一个默认的处理方式。例如,SIGTERM 信号的默认处理是终止进程,SIGSTOP 信号的默认处理是停止进程(且该信号不能被捕获或忽略)。
  2. 忽略处理:进程可以选择忽略某些信号。例如,对于 SIGCHLD 信号(当子进程状态改变时发送),如果父进程不关心子进程的状态变化,可以选择忽略该信号。但有些信号不能被忽略,如 SIGKILLSIGSTOP
  3. 捕获处理:进程可以通过设置信号处理函数来捕获并处理信号。当进程接收到特定信号时,会暂停当前执行的代码,转而执行信号处理函数中的代码。处理完成后,根据情况决定是继续执行原来的代码还是采取其他操作。

常见信号类型

1. SIGINT(中断信号,值为 2)

这个信号通常由用户通过按下 Ctrl + C 组合键产生。它的默认处理方式是终止接收信号的进程。

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

void sigint_handler(int signum) {
    printf("Received SIGINT. Exiting gracefully...\n");
    // 在这里可以进行一些清理工作,比如关闭文件描述符等
    _exit(0);
}

int main() {
    signal(SIGINT, sigint_handler);
    printf("Press Ctrl + C to send SIGINT...\n");
    while (1) {
        sleep(1);
        printf("Still running...\n");
    }
    return 0;
}

在上述代码中,通过 signal 函数将 SIGINT 信号的处理函数设置为 sigint_handler。当用户按下 Ctrl + C 时,进程会捕获到 SIGINT 信号,执行 sigint_handler 函数中的代码,打印提示信息并优雅地退出。

2. SIGTERM(终止信号,值为 15)

SIGTERM 信号是一种通用的终止信号。它通常由系统管理员使用 kill 命令(不带 -9 选项时,默认发送 SIGTERM)或者其他进程调用 kill 函数来发送给目标进程。与 SIGKILL 不同,SIGTERM 允许进程捕获并进行清理工作后再退出。

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

void sigterm_handler(int signum) {
    printf("Received SIGTERM. Cleaning up...\n");
    // 模拟清理工作,如关闭数据库连接等
    sleep(2);
    printf("Cleanup completed. Exiting...\n");
    _exit(0);
}

int main() {
    signal(SIGTERM, sigterm_handler);
    printf("Running. Send SIGTERM to terminate...\n");
    while (1) {
        sleep(1);
        printf("Still running...\n");
    }
    return 0;
}

此代码中,进程捕获 SIGTERM 信号后,在处理函数 sigterm_handler 中进行模拟的清理工作,然后退出。

3. SIGKILL(强制终止信号,值为 9)

SIGKILL 信号是一种强制终止进程的信号,它不能被捕获、忽略或阻塞。一旦进程接收到 SIGKILL 信号,内核会立即终止该进程,不会给进程任何机会进行清理工作。通常用于处理那些无法响应正常终止信号(如 SIGTERM)的“失控”进程。例如,在命令行中使用 kill -9 <pid> 就会向指定进程 ID 的进程发送 SIGKILL 信号。

4. SIGSTOP(停止信号,值为 19)

SIGSTOP 信号用于停止进程的执行,就像按下了暂停键。与 SIGKILL 类似,它也不能被捕获、忽略或阻塞。当进程接收到 SIGSTOP 信号,内核会暂停该进程的执行,其 CPU 时间片被剥夺,直到收到 SIGCONT 信号才会恢复执行。这个信号常用于调试或临时挂起一个进程。例如,在调试程序时,调试器可以向目标进程发送 SIGSTOP 信号,以便检查进程的状态。

5. SIGCONT(继续信号,值为 18)

SIGCONT 信号与 SIGSTOP 信号配合使用,用于恢复被 SIGSTOP 停止的进程的执行。当一个进程收到 SIGCONT 信号时,如果它之前处于停止状态,内核会将其状态改为运行状态,并为其分配 CPU 时间片,使其继续执行。

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

int main() {
    printf("Press Ctrl + Z to send SIGTSTP (a form of SIGSTOP for foreground processes).\n");
    printf("Then use 'kill -CONT <pid>' to send SIGCONT.\n");
    while (1) {
        sleep(1);
        printf("Still running...\n");
    }
    return 0;
}

在这个示例中,当用户在前台运行该程序并按下 Ctrl + Z 时,进程会收到 SIGTSTP(类似 SIGSTOP 用于前台进程)信号而停止。之后,可以使用 kill -CONT <pid> 命令发送 SIGCONT 信号使进程继续运行。

6. SIGCHLD(子进程状态改变信号,值为 17)

当一个子进程终止、停止或恢复执行时,父进程会收到 SIGCHLD 信号。父进程可以通过捕获这个信号来处理子进程的退出状态,例如回收子进程占用的资源(避免僵尸进程的产生)。

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

void sigchld_handler(int signum) {
    int status;
    pid_t pid = waitpid(-1, &status, WNOHANG);
    if (pid > 0) {
        if (WIFEXITED(status)) {
            printf("Child %d exited with status %d\n", pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Child %d terminated by signal %d\n", pid, WTERMSIG(status));
        }
    }
}

int main() {
    signal(SIGCHLD, sigchld_handler);
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程
        printf("Child process is running. PID: %d\n", getpid());
        sleep(2);
        exit(42);
    } else if (pid > 0) {
        // 父进程
        printf("Parent process is running. PID: %d\n", getpid());
        while (1) {
            sleep(1);
            printf("Parent is still running...\n");
        }
    } else {
        perror("fork");
        return 1;
    }
    return 0;
}

在上述代码中,父进程捕获 SIGCHLD 信号,在处理函数 sigchld_handler 中使用 waitpid 函数获取子进程的退出状态。子进程睡眠 2 秒后退出,父进程在接收到 SIGCHLD 信号时,会打印子进程的退出状态信息。

7. SIGSEGV(段错误信号,值为 11)

当进程访问了非法的内存地址,比如访问未分配的内存、访问已释放的内存或者越界访问数组等情况,内核会向该进程发送 SIGSEGV 信号。其默认处理方式是终止进程并生成核心转储文件(如果允许生成的话),核心转储文件可以用于调试,帮助开发者定位内存错误的位置。

#include <stdio.h>

int main() {
    int *ptr = NULL;
    *ptr = 10; // 试图向空指针指向的地址写入数据,会引发 SIGSEGV
    return 0;
}

上述代码会导致 SIGSEGV 信号,因为它试图向空指针 ptr 指向的地址写入数据。在实际开发中,这种错误可能很难发现,尤其是在大型程序中,需要借助调试工具如 gdb 结合核心转储文件来排查问题。

8. SIGFPE(浮点运算错误信号,值为 8)

当进程进行了非法的浮点运算,例如除以零、溢出等情况时,内核会发送 SIGFPE 信号。以下是一个简单的示例:

#include <stdio.h>

int main() {
    double result = 1.0 / 0.0; // 浮点除法除以零,会引发 SIGFPE
    printf("Result: %lf\n", result);
    return 0;
}

在这个例子中,进行了浮点除法除以零的操作,会导致 SIGFPE 信号。进程接收到该信号后,默认会终止。开发者可以通过捕获 SIGFPE 信号来处理浮点运算错误,例如记录错误日志并进行适当的错误处理。

9. SIGILL(非法指令信号,值为 4)

当进程执行了非法的机器指令时,内核会向该进程发送 SIGILL 信号。这通常发生在程序代码损坏、试图执行数据段中的内容或者使用了不支持的指令集等情况下。例如,在 32 位系统上尝试执行 64 位特定指令,或者代码被恶意篡改导致指令格式错误等。

#include <stdio.h>

// 模拟一个可能导致非法指令的情况(这里只是示意,实际情况可能更复杂)
void illegal_instruction() {
    __asm__ __volatile__ ("ud2"); // "ud2" 是 x86 架构下的未定义指令,会引发 SIGILL
}

int main() {
    illegal_instruction();
    return 0;
}

在上述代码中,通过嵌入汇编指令 ud2(x86 架构下的未定义指令)来模拟非法指令的执行,会导致进程收到 SIGILL 信号。

信号的阻塞与解除阻塞

在 Linux C 语言编程中,进程可以选择阻塞某些信号,即暂时不处理这些信号,将它们放入信号队列中。当进程解除对这些信号的阻塞时,信号队列中的信号会被依次处理。

1. 信号集的操作

在处理信号阻塞时,需要用到信号集(sigset_t)。以下是一些常用的信号集操作函数:

  • sigemptyset:初始化一个信号集,使其不包含任何信号。
sigset_t set;
sigemptyset(&set);
  • sigfillset:初始化一个信号集,使其包含所有信号。
sigset_t set;
sigfillset(&set);
  • sigaddset:将一个信号添加到信号集中。
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
  • sigdelset:从信号集中删除一个信号。
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigdelset(&set, SIGINT);
  • sigismember:检查一个信号是否在信号集中。
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
int is_member = sigismember(&set, SIGINT);
if (is_member) {
    printf("SIGINT is in the set.\n");
} else {
    printf("SIGINT is not in the set.\n");
}

2. 阻塞信号

使用 sigprocmask 函数可以设置进程的信号掩码,从而阻塞或解除阻塞信号。

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

void sigint_handler(int signum) {
    printf("Received SIGINT. Exiting gracefully...\n");
    _exit(0);
}

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

    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);

    // 阻塞 SIGINT 信号
    sigprocmask(SIG_BLOCK, &set, NULL);

    printf("SIGINT is blocked. Press Ctrl + C. It won't be handled immediately.\n");
    sleep(5);

    // 解除阻塞 SIGINT 信号
    sigprocmask(SIG_UNBLOCK, &set, NULL);

    printf("SIGINT is unblocked. Press Ctrl + C to terminate.\n");
    while (1) {
        sleep(1);
    }
    return 0;
}

在上述代码中,首先初始化一个信号集并添加 SIGINT 信号,然后使用 sigprocmask 函数阻塞 SIGINT 信号。在阻塞期间,即使按下 Ctrl + CSIGINT 信号也不会被立即处理。5 秒后,解除对 SIGINT 信号的阻塞,此时按下 Ctrl + C 就会触发信号处理函数。

实时信号

在 Linux 中,除了前面介绍的常规信号外,还有实时信号。实时信号提供了更可靠的信号传递机制,并且可以携带更多的信息。

1. 实时信号的特点

  • 编号范围:实时信号的编号从 SIGRTMINSIGRTMAX。不同的系统可能对 SIGRTMINSIGRTMAX 的值有所不同,但一般 SIGRTMIN 为 34,SIGRTMAX 为 64。
  • 排队机制:常规信号不排队,即如果一个进程多次收到同一个常规信号,在信号处理函数执行前,只会记录一次。而实时信号会排队,进程会按照收到的顺序依次处理实时信号。
  • 携带信息:实时信号可以携带更多的信息,例如发送信号的进程 ID、附加数据等。

2. 发送和接收实时信号

使用 sigqueue 函数可以发送实时信号,它可以携带额外的数据。接收实时信号时,可以在信号处理函数中获取这些数据。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/siginfo.h>

void realtime_signal_handler(int signum, siginfo_t *info, void *context) {
    printf("Received real - time signal %d from process %d\n", signum, info->si_pid);
    printf("Additional data: %d\n", info->si_value.sival_int);
}

int main() {
    struct sigaction sa;
    sa.sa_sigaction = realtime_signal_handler;
    sa.sa_flags = SA_SIGINFO;
    sigemptyset(&sa.sa_mask);

    if (sigaction(SIGRTMIN, &sa, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    pid_t pid = getpid();
    union sigval sv;
    sv.sival_int = 42;

    if (sigqueue(pid, SIGRTMIN, sv) == -1) {
        perror("sigqueue");
        return 1;
    }

    printf("Sent real - time signal to myself.\n");
    while (1) {
        sleep(1);
    }
    return 0;
}

在上述代码中,通过 sigaction 函数设置了实时信号 SIGRTMIN 的处理函数 realtime_signal_handler,并设置 sa_flagsSA_SIGINFO 以获取信号携带的信息。然后使用 sigqueue 函数向自身发送 SIGRTMIN 信号,并携带一个整数值 42。在信号处理函数中,打印出发送信号的进程 ID 和携带的附加数据。

信号处理的注意事项

  1. 异步信号安全:信号处理函数应尽可能简单,只做必要的操作。因为信号处理函数是异步执行的,可能在进程的任何位置被调用。在信号处理函数中调用非异步信号安全的函数(如 printf 在某些情况下可能不是异步信号安全的)可能会导致未定义行为。常见的异步信号安全函数有 _exitsigemptysetsigaddset 等。

  2. 重入性:信号处理函数应是可重入的,即可以在函数执行过程中被再次调用。如果信号处理函数使用了共享资源(如全局变量),并且没有适当的同步机制,可能会导致数据竞争和不一致。

  3. 信号掩码:在信号处理函数中,要注意信号掩码的变化。当信号处理函数被调用时,系统会自动将该信号添加到进程的信号掩码中,防止在处理该信号时再次收到相同信号。处理函数返回后,信号掩码会恢复到原来的状态。但如果在处理函数中手动修改了信号掩码,可能会影响进程对其他信号的处理。

  4. 可移植性:不同的系统对信号的实现可能略有不同,在编写跨平台代码时,要注意信号相关函数的可移植性。例如,实时信号的编号范围在不同系统上可能有差异,应避免直接使用具体的实时信号编号,而是使用 SIGRTMINSIGRTMAX 等宏。

通过深入理解 Linux C 语言中的信号类型及其处理机制,开发者可以更好地编写健壮、可靠的程序,处理各种异步事件,提高程序的稳定性和交互性。无论是编写服务器程序、守护进程还是交互式应用程序,信号处理都是一个重要的组成部分。