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

Linux C语言信号捕获高级用法

2024-10-025.2k 阅读

信号捕获的基础回顾

在深入探讨高级用法之前,先来回顾一下信号捕获的基础知识。在Linux系统中,信号是一种异步通知机制,用于向进程发送事件信息。例如,当用户在终端按下Ctrl + C组合键时,系统会向当前前台进程发送一个SIGINT信号,通常用于终止进程。

在C语言中,使用signal函数来设置信号的处理方式。其原型如下:

#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);

signum是要捕获的信号编号,handler是一个函数指针,指向信号处理函数。例如,捕获SIGINT信号并打印一条简单信息的代码如下:

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

void sigint_handler(int signum) {
    printf("Caught SIGINT signal!\n");
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        printf("Running...\n");
        sleep(1);
    }
    return 0;
}

在上述代码中,signal(SIGINT, sigint_handler)SIGINT信号的处理函数设置为sigint_handler。当进程接收到SIGINT信号时,就会执行sigint_handler函数。

信号捕获的高级需求

  1. 可重入性:许多信号处理函数可能会在程序执行的任何时刻被调用,这就要求信号处理函数必须是可重入的。可重入函数是指可以被中断,并且在被中断后重新进入而不会导致数据损坏的函数。例如,标准I/O函数(如printf)通常不是可重入的,因为它们使用了内部缓冲区。在信号处理函数中使用非可重入函数可能会导致程序崩溃或出现未定义行为。
  2. 可靠性:传统的signal函数在某些系统上存在一些问题,例如信号处理函数可能会在捕获一次信号后自动恢复为默认处理方式,这可能导致信号丢失。因此,需要更可靠的信号捕获机制。
  3. 信号屏蔽与排队:有时候,我们需要在特定的代码段中屏蔽某些信号,以防止它们干扰正常的程序执行。同时,在多线程环境下,信号的排队和处理也变得更加复杂。

高级信号捕获函数sigaction

为了解决上述问题,Linux提供了sigaction函数,它比signal函数更加灵活和可靠。其原型如下:

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

signum是要捕获的信号编号。act是一个指向struct sigaction结构体的指针,用于指定新的信号处理方式。oldact是一个可选的指针,用于保存旧的信号处理方式。

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);
};
  1. sa_handler:与signal函数中的handler类似,是一个指向信号处理函数的指针。
  2. sa_sigaction:用于设置更复杂的信号处理函数,该函数可以接收更多的信号相关信息。
  3. sa_mask:一个信号集,用于指定在信号处理函数执行期间要屏蔽的信号。
  4. sa_flags:一些标志位,用于控制信号处理的行为。例如,SA_RESTART标志可以使某些系统调用在被信号中断后自动重新启动。
  5. sa_restorer:此成员已过时,不应再使用。

使用sigaction实现可靠的信号捕获

下面是一个使用sigaction捕获SIGINT信号的示例:

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

void sigint_handler(int signum) {
    printf("Caught SIGINT signal!\n");
}

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

    sigaction(SIGINT, &act, NULL);

    while (1) {
        printf("Running...\n");
        sleep(1);
    }
    return 0;
}

在上述代码中,首先初始化struct sigaction结构体actact.sa_handler设置为sigint_handler,这是信号处理函数。sigemptyset(&act.sa_mask)清空信号屏蔽集,意味着在信号处理函数执行期间不屏蔽任何信号。act.sa_flags设置为0,表示使用默认的信号处理行为。然后,调用sigaction(SIGINT, &act, NULL)来设置SIGINT信号的处理方式。

信号处理函数的可重入性

如前所述,信号处理函数必须是可重入的。下面来看一个违反可重入性的例子:

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

char buffer[100];

void sigint_handler(int signum) {
    strcpy(buffer, "Caught SIGINT");
    printf("Buffer: %s\n", buffer);
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        strcpy(buffer, "Running...");
        printf("Buffer: %s\n", buffer);
        sleep(1);
    }
    return 0;
}

在这个例子中,sigint_handler函数和主循环都访问并修改了全局变量buffer。如果在主循环执行strcpy(buffer, "Running...")时,SIGINT信号被触发,sigint_handler函数中的strcpy(buffer, "Caught SIGINT")可能会中断主循环中的strcpy操作,导致数据损坏。

为了保证可重入性,信号处理函数应尽量简单,避免使用非可重入函数和全局变量。例如,可以将上述代码修改为:

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

void sigint_handler(int signum) {
    write(1, "Caught SIGINT\n", 13);
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        write(1, "Running...\n", 10);
        sleep(1);
    }
    return 0;
}

在这个修改后的版本中,sigint_handler函数使用了可重入的write函数,避免了全局变量和非可重入函数的使用。

信号屏蔽与解除屏蔽

  1. 信号屏蔽:在某些情况下,我们需要在特定的代码段中屏蔽某些信号,以防止它们干扰正常的程序执行。可以使用sigprocmask函数来实现信号屏蔽。其原型如下:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

how参数指定了如何修改信号屏蔽集,有以下几种取值: - SIG_BLOCK:将set中的信号添加到当前信号屏蔽集中。 - SIG_UNBLOCK:将set中的信号从当前信号屏蔽集中移除。 - SIG_SETMASK:将当前信号屏蔽集设置为setset是一个指向信号集的指针,oldset是一个可选的指针,用于保存旧的信号屏蔽集。

下面是一个屏蔽SIGINT信号的示例:

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

void sigint_handler(int signum) {
    printf("Caught SIGINT signal!\n");
}

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

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

    sigprocmask(SIG_BLOCK, &set, NULL);

    printf("SIGINT is blocked. Press Ctrl + C to test.\n");
    sleep(5);

    printf("Unblocking SIGINT.\n");
    sigprocmask(SIG_UNBLOCK, &set, NULL);

    while (1) {
        printf("Running...\n");
        sleep(1);
    }
    return 0;
}

在上述代码中,首先设置SIGINT信号的处理函数。然后,创建一个信号集set并将SIGINT信号添加到其中。通过sigprocmask(SIG_BLOCK, &set, NULL)屏蔽SIGINT信号。在屏蔽期间,即使按下Ctrl + C,SIGINT信号也不会被处理。5秒后,通过sigprocmask(SIG_UNBLOCK, &set, NULL)解除对SIGINT信号的屏蔽。

  1. 检查挂起的信号:可以使用sigpending函数来检查当前进程有哪些信号处于挂起状态(即已经发送但由于信号屏蔽而未被处理的信号)。其原型如下:
#include <signal.h>
int sigpending(sigset_t *set);

set是一个指向信号集的指针,用于存储当前挂起的信号。

以下是一个示例,展示如何检查挂起的信号:

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

void print_pending_signals() {
    sigset_t pending_set;
    sigpending(&pending_set);

    printf("Pending signals: ");
    if (sigismember(&pending_set, SIGINT)) printf("SIGINT ");
    if (sigismember(&pending_set, SIGTERM)) printf("SIGTERM ");
    // 可以继续检查其他信号
    printf("\n");
}

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

    sigprocmask(SIG_BLOCK, &set, NULL);

    printf("SIGINT is blocked. Sending SIGINT.\n");
    kill(getpid(), SIGINT);

    print_pending_signals();

    printf("Unblocking SIGINT.\n");
    sigprocmask(SIG_UNBLOCK, &set, NULL);

    print_pending_signals();

    return 0;
}

在这个示例中,首先屏蔽SIGINT信号,然后向自身发送SIGINT信号。此时,SIGINT信号处于挂起状态。通过print_pending_signals函数检查并打印挂起的信号。接着,解除对SIGINT信号的屏蔽,再次检查挂起的信号,此时SIGINT信号应该不再挂起。

实时信号与信号排队

  1. 实时信号:在Linux中,信号分为两类:标准信号(也称为非实时信号)和实时信号。标准信号的编号范围是1到31,实时信号的编号范围是32到64。实时信号与标准信号的主要区别在于实时信号支持排队,而标准信号不支持。这意味着如果多次发送同一个标准信号,在信号屏蔽解除后,该信号只会被处理一次。而对于实时信号,多次发送同一个实时信号,在信号屏蔽解除后,每个信号都会被依次处理。

  2. 使用实时信号:使用sigaction函数同样可以处理实时信号。下面是一个简单的示例,展示如何捕获实时信号SIGRTMIN

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

void rt_signal_handler(int signum) {
    printf("Caught real - time signal %d\n", signum);
}

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

    sigaction(SIGRTMIN, &act, NULL);

    printf("Sending real - time signal SIGRTMIN.\n");
    kill(getpid(), SIGRTMIN);

    sleep(1);

    return 0;
}

在上述代码中,设置了SIGRTMIN信号的处理函数,并向自身发送SIGRTMIN信号。当信号被捕获时,会打印相应的信息。

  1. 信号排队:为了演示实时信号的排队特性,以下是一个更复杂的示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void rt_signal_handler(int signum, siginfo_t *info, void *context) {
    printf("Caught real - time signal %d from process %d\n", signum, info->si_pid);
}

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

    sigaction(SIGRTMIN, &act, NULL);

    printf("Sending multiple real - time signals SIGRTMIN.\n");
    for (int i = 0; i < 5; i++) {
        kill(getpid(), SIGRTMIN);
    }

    sleep(1);

    return 0;
}

在这个示例中,act.sa_sigaction被设置为rt_signal_handler,并且act.sa_flags设置为SA_SIGINFO,这使得信号处理函数可以接收更多的信号信息,如发送信号的进程ID。通过循环发送5个SIGRTMIN信号,由于实时信号支持排队,信号处理函数会被调用5次,每次打印出捕获到的信号和发送信号的进程ID。

多线程环境下的信号处理

在多线程程序中,信号处理变得更加复杂。在Linux中,信号是发送到进程的,而不是特定的线程。但是,一个线程可以注册信号处理函数,并且可以选择处理哪些信号。

  1. 线程特定的信号屏蔽:每个线程都有自己的信号屏蔽集。可以使用pthread_sigmask函数来设置线程的信号屏蔽集,其原型如下:
#include <pthread.h>
#include <signal.h>
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);

howsetoldset的含义与sigprocmask函数类似,但是作用于特定的线程。

  1. 线程信号处理示例:下面是一个简单的多线程程序,展示如何在不同线程中处理信号:
#include <stdio.h>
#include <pthread.h>
#include <signal.h>
#include <unistd.h>

void *thread_function(void *arg) {
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);

    pthread_sigmask(SIG_BLOCK, &set, NULL);

    printf("Thread is running and SIGINT is blocked.\n");
    sleep(5);

    printf("Thread is unblocking SIGINT.\n");
    pthread_sigmask(SIG_UNBLOCK, &set, NULL);

    while (1) {
        printf("Thread is running...\n");
        sleep(1);
    }
    return NULL;
}

void sigint_handler(int signum) {
    printf("Caught SIGINT in main thread!\n");
}

int main() {
    pthread_t thread;
    pthread_create(&thread, NULL, thread_function, NULL);

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

    sigaction(SIGINT, &act, NULL);

    printf("Main thread is running. Press Ctrl + C to test.\n");
    while (1) {
        printf("Main thread is running...\n");
        sleep(1);
    }
    return 0;
}

在上述代码中,创建了一个新线程。在线程中,屏蔽了SIGINT信号,5秒后解除屏蔽。在主线程中,设置了SIGINT信号的处理函数。这样,在5秒内,即使在终端按下Ctrl + C,信号也不会被线程处理,而主线程可以正常处理信号。5秒后,线程也可以处理SIGINT信号。

信号与系统调用的交互

  1. 系统调用的中断:当一个进程正在执行系统调用时,如果接收到一个信号,系统调用可能会被中断。例如,readwriteaccept等系统调用在被信号中断后,可能会返回错误EINTR。传统上,应用程序需要检查这些错误并重新发起系统调用。例如:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>

void sigint_handler(int signum) {
    printf("Caught SIGINT!\n");
}

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

    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    char buffer[100];
    ssize_t bytes_read;
    do {
        bytes_read = read(fd, buffer, sizeof(buffer));
        if (bytes_read == -1 && errno == EINTR) {
            printf("read was interrupted by a signal. Retrying...\n");
        }
    } while (bytes_read == -1 && errno == EINTR);

    if (bytes_read > 0) {
        buffer[bytes_read] = '\0';
        printf("Read: %s\n", buffer);
    }

    close(fd);
    return 0;
}

在上述代码中,read系统调用可能会被SIGINT信号中断。如果read返回 -1且errnoEINTR,则重新尝试read操作。

  1. 自动重启系统调用:为了简化这种处理,可以使用sigaction函数的SA_RESTART标志。当设置了SA_RESTART标志后,被信号中断的系统调用会自动重新启动,而不需要应用程序手动检查和重试。例如:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>

void sigint_handler(int signum) {
    printf("Caught SIGINT!\n");
}

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

    sigaction(SIGINT, &act, NULL);

    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    char buffer[100];
    ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
    if (bytes_read > 0) {
        buffer[bytes_read] = '\0';
        printf("Read: %s\n", buffer);
    }

    close(fd);
    return 0;
}

在这个示例中,设置act.sa_flagsSA_RESTART,这样即使read系统调用被SIGINT信号中断,它也会自动重新启动,应用程序不需要手动处理EINTR错误。

信号处理中的竞争条件

  1. 竞争条件的产生:在信号处理过程中,竞争条件是一个常见的问题。例如,当一个信号处理函数和主程序共享某些资源时,如果没有适当的同步机制,可能会导致数据不一致或程序崩溃。考虑以下示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int shared_variable = 0;

void sigint_handler(int signum) {
    shared_variable++;
    printf("Shared variable in handler: %d\n", shared_variable);
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        if (shared_variable > 0) {
            shared_variable--;
            printf("Shared variable in main: %d\n", shared_variable);
        }
        sleep(1);
    }
    return 0;
}

在这个示例中,主程序和信号处理函数都访问并修改shared_variable。如果在主程序检查shared_variable > 0之后,但在执行shared_variable--之前,SIGINT信号被触发,信号处理函数会增加shared_variable的值,导致主程序的逻辑出现错误。

  1. 解决竞争条件:可以使用互斥锁(pthread_mutex_t)来解决这种竞争条件。以下是修改后的代码:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <pthread.h>

int shared_variable = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void sigint_handler(int signum) {
    pthread_mutex_lock(&mutex);
    shared_variable++;
    printf("Shared variable in handler: %d\n", shared_variable);
    pthread_mutex_unlock(&mutex);
}

int main() {
    signal(SIGINT, sigint_handler);
    while (1) {
        pthread_mutex_lock(&mutex);
        if (shared_variable > 0) {
            shared_variable--;
            printf("Shared variable in main: %d\n", shared_variable);
        }
        pthread_mutex_unlock(&mutex);
        sleep(1);
    }
    pthread_mutex_destroy(&mutex);
    return 0;
}

在修改后的代码中,使用pthread_mutex_lockpthread_mutex_unlock来保护对shared_variable的访问,确保在同一时间只有一个线程(或信号处理函数)可以修改shared_variable,从而避免了竞争条件。

信号处理与进程间通信

  1. 使用信号进行进程间通信:信号可以作为一种简单的进程间通信(IPC)机制。例如,一个进程可以向另一个进程发送特定的信号,以通知其执行某些操作。以下是一个简单的示例,展示如何通过信号进行进程间通信:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void sigusr1_handler(int signum) {
    printf("Received SIGUSR1. Performing some action.\n");
}

int main() {
    signal(SIGUSR1, sigusr1_handler);

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // Child process
        sleep(2);
        printf("Child process sending SIGUSR1 to parent.\n");
        kill(getppid(), SIGUSR1);
        exit(0);
    } else {
        // Parent process
        printf("Parent process waiting for SIGUSR1.\n");
        while (1) {
            sleep(1);
        }
    }
    return 0;
}

在上述代码中,父进程注册了SIGUSR1信号的处理函数。子进程在启动2秒后,向父进程发送SIGUSR1信号。父进程在接收到信号后,执行信号处理函数,打印相应的信息。

  1. 信号与其他IPC机制的结合:信号可以与其他IPC机制(如管道、共享内存等)结合使用,以实现更复杂的进程间通信。例如,一个进程可以通过共享内存来传递数据,并使用信号来通知其他进程数据已准备好。以下是一个简单的示例,展示信号与共享内存的结合使用:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/ipc.h>
#include <sys/types.h>
#include <stdlib.h>

#define SHM_SIZE 100

void sigusr1_handler(int signum) {
    key_t key = ftok(".", 'a');
    int shmid = shmget(key, SHM_SIZE, 0666);
    if (shmid == -1) {
        perror("shmget");
        return;
    }

    char *shared_memory = (char *)shmat(shmid, NULL, 0);
    if (shared_memory == (void *)-1) {
        perror("shmat");
        return;
    }

    printf("Received SIGUSR1. Data in shared memory: %s\n", shared_memory);

    if (shmdt(shared_memory) == -1) {
        perror("shmdt");
    }
}

int main() {
    signal(SIGUSR1, sigusr1_handler);

    key_t key = ftok(".", 'a');
    int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }

    char *shared_memory = (char *)shmat(shmid, NULL, 0);
    if (shared_memory == (void *)-1) {
        perror("shmat");
        return 1;
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // Child process
        sprintf(shared_memory, "Hello from child!");
        printf("Child process writing to shared memory and sending SIGUSR1.\n");
        kill(getppid(), SIGUSR1);
        if (shmdt(shared_memory) == -1) {
            perror("shmdt");
        }
        exit(0);
    } else {
        // Parent process
        printf("Parent process waiting for SIGUSR1.\n");
        while (1) {
            sleep(1);
        }
    }

    if (shmctl(shmid, IPC_RMID, NULL) == -1) {
        perror("shmctl");
    }
    return 0;
}

在这个示例中,父进程和子进程共享一块内存。子进程在共享内存中写入数据,并向父进程发送SIGUSR1信号。父进程在接收到信号后,读取共享内存中的数据并打印。

通过以上内容,全面深入地介绍了Linux C语言信号捕获的高级用法,涵盖了信号捕获的可靠性、可重入性、信号屏蔽、实时信号、多线程处理、与系统调用的交互、竞争条件以及与进程间通信的结合等方面,希望能帮助开发者更好地理解和应用信号机制。