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

Linux C语言信号捕获与处理基础

2024-10-185.2k 阅读

信号基础概念

在Linux系统中,信号(Signal)是一种软件中断,用于通知进程发生了异步事件。这些事件可以是来自系统内核,如用户按下 Ctrl+C 组合键,也可以是进程之间相互发送信号。信号为进程提供了一种异步处理机制,使得进程在不阻塞主执行流程的情况下,能够对特定事件做出响应。

信号有不同的编号和名称,在 <signal.h> 头文件中定义。例如,信号 SIGINT 对应的编号通常是2,它表示用户通过键盘输入中断信号(Ctrl+C)。每个信号都有一个默认的处理动作,常见的默认处理动作包括:

  1. 终止进程:如 SIGTERM 信号,默认情况下会终止接收信号的进程。
  2. 忽略信号:某些信号,如 SIGCHLD,默认情况下进程会忽略它。SIGCHLD 信号在子进程状态发生改变(如终止、暂停等)时发送给父进程。
  3. 产生核心转储并终止进程:例如 SIGSEGV 信号,当进程访问非法内存地址(如空指针解引用)时会收到该信号,默认处理是产生核心转储文件并终止进程,核心转储文件可以用于调试分析进程崩溃时的状态。
  4. 停止进程SIGSTOP 信号会停止进程的执行,进程进入暂停状态。
  5. 恢复停止的进程SIGCONT 信号用于恢复被 SIGSTOP 或其他信号停止的进程。

信号的产生

  1. 用户通过终端产生信号
    • 当用户在终端中按下 Ctrl+C 时,会向当前前台进程组中的所有进程发送 SIGINT 信号。例如,在运行一个简单的C语言程序时,用户按下 Ctrl+C 就可以中断程序的执行。
    • 按下 Ctrl+\ 会发送 SIGQUIT 信号,该信号不仅会终止进程,还会产生核心转储文件(如果设置了相应的系统参数允许产生核心转储)。
  2. 系统内核产生信号
    • 当进程执行出现错误,如访问非法内存地址,内核会向该进程发送 SIGSEGV 信号。例如下面的代码:
#include <stdio.h>

int main() {
    int *ptr = NULL;
    *ptr = 10; // 试图向空指针指向的地址写数据,会触发SIGSEGV信号
    return 0;
}
  • 当子进程终止时,父进程会收到 SIGCHLD 信号。下面是一个简单的父子进程示例,展示子进程终止时父进程收到 SIGCHLD 信号的情况:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <signal.h>

void sigchld_handler(int signum) {
    printf("Parent received SIGCHLD signal\n");
}

int main() {
    pid_t pid;
    signal(SIGCHLD, sigchld_handler);
    pid = fork();
    if (pid == 0) {
        printf("Child process is exiting\n");
        exit(0);
    } else if (pid > 0) {
        while (1) {
            printf("Parent process is running\n");
            sleep(1);
        }
    } else {
        perror("fork");
        return 1;
    }
    return 0;
}

在这个例子中,父进程通过 signal 函数注册了 SIGCHLD 信号的处理函数 sigchld_handler。当子进程调用 exit 函数终止时,父进程会收到 SIGCHLD 信号并执行 sigchld_handler 函数中的代码。 3. 进程之间发送信号

  • 可以使用 kill 函数向其他进程发送信号。kill 函数的原型为 int kill(pid_t pid, int sig);,其中 pid 是目标进程的ID,sig 是要发送的信号编号。例如,下面的代码实现了一个进程向另一个进程发送 SIGTERM 信号:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
    pid_t target_pid = 1234; // 假设目标进程ID为1234
    if (kill(target_pid, SIGTERM) == -1) {
        perror("kill");
        return 1;
    }
    printf("SIGTERM signal sent to process %d\n", target_pid);
    return 0;
}

在实际使用中,需要将 target_pid 替换为真实的目标进程ID。另外,只有具有相应权限的进程才能向其他进程发送信号。通常,普通用户只能向自己启动的进程发送信号,而超级用户(root)可以向任何进程发送信号。

信号的捕获与处理

  1. 使用signal函数
    • signal 函数用于设置信号的处理方式。其原型为 void (*signal(int signum, void (*handler)(int)))(int);,该函数接受两个参数:信号编号 signum 和信号处理函数指针 handlerhandler 可以是一个自定义的函数,也可以是 SIG_IGN(表示忽略该信号)或 SIG_DFL(表示恢复该信号的默认处理方式)。
    • 下面是一个捕获 SIGINT 信号并进行自定义处理的示例:
#include <stdio.h>
#include <signal.h>

void sigint_handler(int signum) {
    printf("Caught SIGINT signal. Program will not terminate immediately.\n");
}

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

在这个例子中,通过 signal(SIGINT, sigint_handler) 注册了 SIGINT 信号的处理函数 sigint_handler。当用户在终端按下 Ctrl+C 时,会触发 SIGINT 信号,程序会执行 sigint_handler 函数中的代码,而不是默认的终止进程操作。 2. 使用sigaction函数

  • sigaction 函数提供了比 signal 函数更强大和灵活的信号处理设置方式。其原型为 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);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};
  • sa_handler 是一个函数指针,指向信号处理函数,与 signal 函数中的 handler 类似。sa_sigaction 也是一个函数指针,用于更复杂的信号处理,当 sa_flags 中设置了 SA_SIGINFO 标志时会使用这个函数指针。sa_mask 是一个信号集,用于指定在执行信号处理函数期间需要阻塞的信号。sa_flags 是一些标志位,用于设置信号处理的各种选项。
  • 下面是一个使用 sigaction 函数捕获 SIGINT 信号的示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sigint_handler(int signum) {
    printf("Caught SIGINT signal. Program will not terminate immediately.\n");
}

int main() {
    struct sigaction act;
    act.sa_handler = sigint_handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;

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

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

在这个示例中,首先初始化 struct sigaction 结构体 act,设置 sa_handler 为自定义的信号处理函数 sigint_handler,清空 sa_mask 表示在处理 SIGINT 信号时不阻塞其他信号,sa_flags 设置为0表示使用默认的信号处理选项。然后通过 sigaction 函数设置 SIGINT 信号的处理方式。

信号集操作

  1. 信号集的定义
    • 在Linux C语言中,信号集(sigset_t)是一种数据类型,用于表示一组信号。它可以包含0个或多个信号。信号集常用于设置在信号处理过程中需要阻塞的信号集合,或者检查当前进程的信号掩码(即当前阻塞的信号集合)。
    • 要使用信号集相关的函数,需要包含 <signal.h> 头文件。
  2. 信号集操作函数
    • sigemptyset:用于清空一个信号集,使其不包含任何信号。函数原型为 int sigemptyset(sigset_t *set);,成功时返回0,失败返回 -1。例如:
sigset_t my_set;
sigemptyset(&my_set);
  • sigfillset:用于将一个信号集中包含所有的信号。函数原型为 int sigfillset(sigset_t *set);,成功返回0,失败返回 -1。例如:
sigset_t my_set;
sigfillset(&my_set);
  • sigaddset:用于向一个信号集中添加一个信号。函数原型为 int sigaddset(sigset_t *set, int signum);,其中 set 是信号集指针,signum 是要添加的信号编号。成功返回0,失败返回 -1。例如:
sigset_t my_set;
sigemptyset(&my_set);
sigaddset(&my_set, SIGINT);
  • sigdelset:用于从一个信号集中删除一个信号。函数原型为 int sigdelset(sigset_t *set, int signum);,成功返回0,失败返回 -1。例如:
sigset_t my_set;
sigemptyset(&my_set);
sigaddset(&my_set, SIGINT);
sigdelset(&my_set, SIGINT);
  • sigismember:用于检查一个信号是否在信号集中。函数原型为 int sigismember(const sigset_t *set, int signum);,如果信号 signum 在信号集 set 中,返回1;否则返回0;出错返回 -1。例如:
sigset_t my_set;
sigemptyset(&my_set);
sigaddset(&my_set, SIGINT);
if (sigismember(&my_set, SIGINT)) {
    printf("SIGINT is in the set\n");
} else {
    printf("SIGINT is not in the set\n");
}
  1. 信号掩码操作
    • 进程有一个信号掩码,它定义了当前被阻塞的信号集合。当一个信号被阻塞时,它不会被立即传递给进程,而是处于等待状态,直到该信号被解除阻塞。
    • sigprocmask:用于获取或修改进程的信号掩码。函数原型为 int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
    • how 参数指定操作类型,常见的值有:
      • SIG_BLOCK:将 set 中的信号添加到当前信号掩码中,即阻塞这些信号。
      • SIG_UNBLOCK:将 set 中的信号从当前信号掩码中移除,即解除阻塞这些信号。
      • SIG_SETMASK:将当前信号掩码设置为 set
    • set 是一个指向信号集的指针,用于指定要操作的信号集合。oldset 是一个可选参数,如果不为 NULL,则会保存原来的信号掩码。
    • 下面是一个示例,展示如何阻塞 SIGINT 信号一段时间,然后再解除阻塞:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int main() {
    sigset_t my_set;
    sigemptyset(&my_set);
    sigaddset(&my_set, SIGINT);

    // 阻塞SIGINT信号
    sigprocmask(SIG_BLOCK, &my_set, NULL);
    printf("SIGINT is blocked. Press Ctrl+C, it won't terminate the program now.\n");
    sleep(5);

    // 解除阻塞SIGINT信号
    sigprocmask(SIG_UNBLOCK, &my_set, NULL);
    printf("SIGINT is unblocked. Press Ctrl+C to terminate the program.\n");
    while (1) {
        sleep(1);
    }
    return 0;
}

在这个例子中,首先创建一个信号集 my_set 并添加 SIGINT 信号。然后通过 sigprocmask 函数以 SIG_BLOCK 方式阻塞 SIGINT 信号,此时用户按下 Ctrl+C 不会终止程序。5秒后,通过 sigprocmask 函数以 SIG_UNBLOCK 方式解除对 SIGINT 信号的阻塞,用户再次按下 Ctrl+C 就可以终止程序。

可重入函数与异步信号安全

  1. 可重入函数概念
    • 可重入函数是指可以被多个执行流(如多线程或信号处理函数与主程序并发执行)安全调用的函数。一个可重入函数在执行过程中被中断,然后在另一个执行流中再次调用,不会出现数据损坏或其他未定义行为。
    • 可重入函数具有以下特点:
      • 不使用静态或全局的非常量数据。因为静态或全局数据在多个执行流中共享,如果一个执行流修改了这些数据,另一个执行流可能会得到不一致的结果。例如下面的函数就不是可重入的:
int global_var = 0;
int non_reentrant_func(int num) {
    global_var += num;
    return global_var;
}

在多线程或信号处理函数与主程序并发调用 non_reentrant_func 时,由于 global_var 是全局变量,可能会导致数据竞争和不一致的结果。 - 不调用不可重入的函数。如果一个函数调用了其他不可重入的函数,那么它本身也不是可重入的。例如,malloc 函数通常不是可重入的,因为它内部使用了静态数据结构来管理内存分配。如果一个函数调用了 malloc,那么在多线程或信号处理函数与主程序并发执行时可能会出现问题。 - 不持有静态状态。除了不使用静态数据外,函数也不应在多次调用之间保留内部状态。例如,一个函数使用静态变量来记录调用次数:

int call_count = 0;
int non_reentrant_count_func() {
    call_count++;
    return call_count;
}

这个函数不是可重入的,因为 call_count 是静态变量,在多次调用之间保留状态,可能会在并发调用时出现问题。 2. 异步信号安全函数

  • 异步信号安全函数是指可以在信号处理函数中安全调用的函数。所有异步信号安全函数都是可重入的,但并非所有可重入函数都是异步信号安全的。
  • 异步信号安全函数在Linux系统中有明确的定义,常见的异步信号安全函数包括 _exit(用于直接终止进程,不同于 exitexit 不是异步信号安全的,因为它可能会调用清理函数,这些清理函数可能不是可重入的)、write(在一定条件下,如不涉及复杂的缓冲区管理时)等。
  • 在编写信号处理函数时,应尽量使用异步信号安全函数。例如,在信号处理函数中打印信息,应使用 write 而不是 printf,因为 printf 不是异步信号安全的,它可能会涉及复杂的缓冲区操作和静态数据结构。下面是一个使用 write 在信号处理函数中输出信息的示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

void sigint_handler(int signum) {
    const char *msg = "Caught SIGINT signal.\n";
    write(STDOUT_FILENO, msg, strlen(msg));
}

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

在这个例子中,sigint_handler 信号处理函数使用 write 函数输出信息,确保了在信号处理过程中的安全性。

信号处理中的竞态条件

  1. 竞态条件概念
    • 在信号处理的上下文中,竞态条件是指由于信号的异步特性,导致信号处理函数和主程序之间在访问共享资源时可能出现的竞争情况,从而产生不可预测的结果。
    • 例如,考虑一个简单的计数器程序,主程序递增一个计数器变量,同时有一个信号处理函数在接收到某个信号时打印计数器的值。
#include <stdio.h>
#include <signal.h>

volatile int counter = 0;

void sigint_handler(int signum) {
    printf("Counter value: %d\n", counter);
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        counter++;
    }
    return 0;
}

在这个例子中,虽然 counter 被声明为 volatile 以防止编译器优化导致的问题,但仍然存在竞态条件。因为 counter++ 操作不是原子的,它实际上包含读取 counter 的值、增加1、再写回 counter 三个步骤。在主程序执行这三个步骤的过程中,如果信号处理函数被触发,可能会读取到一个不完整的 counter 值,导致打印出错误的计数器值。 2. 避免竞态条件的方法

  • 使用互斥锁:在多线程环境中,可以使用互斥锁(pthread_mutex_t)来保护共享资源。在信号处理的场景下,虽然不能直接使用标准的线程互斥锁,但可以使用 sigprocmask 来模拟类似的效果。例如,在访问共享资源前阻塞信号,访问完后再解除阻塞。
#include <stdio.h>
#include <signal.h>

volatile int counter = 0;

void sigint_handler(int signum) {
    sigset_t old_set;
    sigset_t block_set;
    sigemptyset(&block_set);
    sigaddset(&block_set, SIGINT);
    sigprocmask(SIG_BLOCK, &block_set, &old_set);
    printf("Counter value: %d\n", counter);
    sigprocmask(SIG_SETMASK, &old_set, NULL);
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        sigset_t old_set;
        sigset_t block_set;
        sigemptyset(&block_set);
        sigaddset(&block_set, SIGINT);
        sigprocmask(SIG_BLOCK, &block_set, &old_set);
        counter++;
        sigprocmask(SIG_SETMASK, &old_set, NULL);
    }
    return 0;
}

在这个改进的例子中,无论是主程序访问 counter 还是信号处理函数访问 counter,都先阻塞 SIGINT 信号,访问完后再恢复原来的信号掩码,从而避免了竞态条件。

  • 使用原子操作:现代的处理器通常提供一些原子操作指令,在C语言中,可以使用 <stdatomic.h> 头文件中的原子类型和操作函数来进行原子操作。例如,可以将 counter 声明为 _Atomic(int) 类型,并使用原子操作函数来递增和读取 counter 的值。
#include <stdio.h>
#include <signal.h>
#include <stdatomic.h>

_Atomic(int) counter = 0;

void sigint_handler(int signum) {
    printf("Counter value: %d\n", atomic_load(&counter));
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        atomic_fetch_add(&counter, 1);
    }
    return 0;
}

在这个例子中,atomic_fetch_add 函数以原子方式递增 counter 的值,atomic_load 函数以原子方式读取 counter 的值,从而避免了竞态条件。

信号处理的高级应用

  1. 使用SIGALRM实现定时器
    • SIGALRM 信号可以用于实现简单的定时器功能。alarm 函数用于设置一个定时器,当定时器超时后,会向进程发送 SIGALRM 信号。alarm 函数的原型为 unsigned int alarm(unsigned int seconds);,它会返回之前设置的定时器剩余的秒数,如果之前没有设置定时器,则返回0。
    • 下面是一个使用 SIGALRM 信号实现每隔1秒打印一条消息的示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sigalrm_handler(int signum) {
    printf("Timer expired\n");
    alarm(1); // 重新设置定时器为1秒
}

int main() {
    signal(SIGALRM, sigalrm_handler);
    alarm(1); // 设置定时器为1秒
    while (1) {
        pause(); // 暂停进程,等待信号
    }
    return 0;
}

在这个例子中,通过 signal(SIGALRM, sigalrm_handler) 注册了 SIGALRM 信号的处理函数 sigalrm_handler。然后使用 alarm(1) 设置定时器为1秒,当1秒后定时器超时,会发送 SIGALRM 信号,进程执行 sigalrm_handler 函数中的代码,在函数中再次调用 alarm(1) 重新设置定时器为1秒,然后通过 pause 函数暂停进程,等待下一个 SIGALRM 信号。 2. 信号驱动I/O

  • 信号驱动I/O允许进程在I/O操作完成时收到信号,而不是阻塞等待I/O操作完成。以套接字I/O为例,可以使用 SIGIO 信号实现信号驱动I/O。
  • 首先,需要设置套接字为非阻塞模式,并使用 fcntl 函数设置套接字的所有者为当前进程,以便接收 SIGIO 信号。然后注册 SIGIO 信号的处理函数,在处理函数中进行I/O操作。
  • 下面是一个简单的TCP客户端示例,使用信号驱动I/O接收服务器数据:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <signal.h>
#include <fcntl.h>

#define PORT 8080
#define SERVER_IP "127.0.0.1"
#define BUFFER_SIZE 1024

void sigio_handler(int signum) {
    int sockfd;
    char buffer[BUFFER_SIZE] = {0};
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    servaddr.sin_addr.s_addr = inet_addr(SERVER_IP);

    connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    int valread = read(sockfd, buffer, BUFFER_SIZE);
    buffer[valread] = '\0';
    printf("Received: %s\n", buffer);
    close(sockfd);
}

int main() {
    int sockfd;
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    servaddr.sin_addr.s_addr = inet_addr(SERVER_IP);

    connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    fcntl(sockfd, F_SETOWN, getpid());
    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);

    signal(SIGIO, sigio_handler);

    while (1) {
        sleep(1);
    }
    close(sockfd);
    return 0;
}

在这个示例中,首先创建一个TCP套接字并连接到服务器。然后使用 fcntl 函数设置套接字的所有者为当前进程,并设置为非阻塞和异步模式。通过 signal(SIGIO, sigio_handler) 注册 SIGIO 信号的处理函数 sigio_handler。在主程序的 while 循环中,进程可以继续执行其他任务,当有数据到达套接字时,会发送 SIGIO 信号,进程执行 sigio_handler 函数中的代码来读取数据。

通过对以上Linux C语言信号捕获与处理相关知识的学习,开发者可以更好地编写健壮、灵活且能够有效处理异步事件的程序。无论是简单的终端交互程序,还是复杂的网络服务器程序,信号处理都是重要的组成部分。