| 目录 |

29 信号处理

在开始之前,我干脆建议你大体上忽略这一整章,改用你的操作系统(很可能)更高级的信号处理函数。类 Unix 系统有 sigaction() 一族函数,而 Windows 有的是……它自己那套东西171。

把这个放一边,我们先说说信号是什么?

29.1 什么是信号?

在各种外部事件发生时,会引发一个信号。你可以把程序配置成被中断以处理该信号,并且(可选地)在处理完信号之后从刚才中断的地方继续。

可以把它想成一个在这些外部事件之一发生时被自动调用的函数。

这些事件是什么?在你的系统上,它们可能有很多,但在 C 规范里只有寥寥几个:

信号 说明
SIGABRT 异常终止——调用 abort() 时发生的情况。
SIGFPE 浮点异常。
SIGILL 非法指令。
SIGINT 中断——通常是按下 CTRL-C 的结果。
SIGSEGV “段错误”:非法的内存访问。
SIGTERM 请求终止。

你可以用 signal() 函数把程序设置为忽略、处理或允许每种信号的默认动作。

29.2 用 signal() 处理信号

signal() 调用接受两个形参:所讨论的信号,以及该信号被引发时要采取的动作。

这个动作可以是三种东西之一:

我们来写一个没法用 CTRL-C 退出的程序。(别担心——在下面这个程序里,你还可以按 RETURN,它就会退出。)

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

int main(void)
{
    char s[1024];

    signal(SIGINT, SIG_IGN);    // 忽略由 ^C 引发的 SIGINT

    printf("Try hitting ^C... (hit RETURN to exit)\n");

    // 等待一行输入,免得程序直接就退出了
    fgets(s, sizeof s, stdin);
}

看看第 8 行——我们让程序忽略 SIGINT,也就是按下 CTRL-C 时引发的中断信号。无论你按多少次,这个信号都会被忽略。如果把第 8 行注释掉,你就会发现自己可以肆无忌惮地 CTRL-C,当场退出程序。

29.3 编写信号处理函数

我提到过,你也可以写一个在信号被引发时被调用的处理函数。

这些函数相当直接,但就规范而言,它们的能力也非常有限。

在开始之前,我们来看看 signal() 调用的函数原型:

void (*signal(int sig, void (*func)(int)))(int);

很容易读懂,对吧?

错了! :)

我们花点时间把它拆开来练练手。

signal() 接受两个实参:一个表示信号的整数 sig,以及一个指向处理函数的指针 func(该处理函数返回 void,并接受一个 int 作为实参),下面高亮显示:

                sig          func
              |-----|  |---------------|
void (*signal(int sig, void (*func)(int)))(int);

基本上,我们要传入自己关心、想要捕获的信号编号,还要传入一个指向如下形式函数的指针:

void f(int x);

由它来完成实际的捕获工作。

现在——原型剩下的部分呢?那基本上全是返回类型。你看,成功时 signal() 会返回你作为 func 传入的那个东西……也就是说,它返回的是一个指针,指向“返回 void 并接受一个 int 实参”的函数。

returned
function    indicates we're              and
returns     returning a                  that function
void        pointer to function          takes an int
|--|        |                                   |---|
void       (*signal(int sig, void (*func)(int)))(int);

另外,出错时它还可能返回 SIG_ERR。

我们做一个例子,让程序必须按两次 CTRL-C 才能退出。

我要说明一点:这个程序在几处地方涉及未定义行为。但它多半在你那儿能正常工作,而且要想出可移植又不平凡的演示实在很难。

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

int count = 0;

void sigint_handler(int signum)
{
    // 编译器有权运行:
    //
    //   即 signal(signum, SIG_DFL)
    //
    // 此时处理函数被调用。所以我们在这里重置处理函数:
    signal(SIGINT, sigint_handler);

    (void)signum;   // 消除未使用变量的警告

    count++;                       // 未定义行为
    printf("Count: %d\n", count);  // 未定义行为

    if (count == 2) {
        printf("Exiting!\n");      // 未定义行为
        exit(0);
    }
}

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

    printf("Try hitting ^C...\n");

    for(;;);  // 在此永远等待
}

你会注意到的一件事是,我们在第 14 行重置了信号处理函数。这是因为 C 允许在运行你的自定义处理函数之前,先把信号处理函数重置为其 SIG_DFL 行为。换句话说,它可能是一次性的。所以我们第一件事就是重置它,这样下一个信号我们还能继续处理。

在这个例子里,我们忽略了 signal() 的返回值。如果我们之前把它设成了另一个处理函数,它就会返回指向那个处理函数的指针,我们可以这样取到它:

// old_handler 的类型是 "指向函数的指针,该函数接受一个
// int 形参,并返回 void":

void (*old_handler)(int);

old_handler = signal(SIGINT, sigint_handler);

话虽如此,我并不确定这有什么常见用途。但如果你出于某种原因需要旧的处理函数,就可以这样拿到它。

关于第 16 行再快速说一句——那只是告诉编译器,不要因为我们没使用这个变量而发出警告。这就好比说:“我知道我没用它,你不用提醒我。”

最后你会看到,我在几处标出了未定义行为。下一节再细说。

29.4 我们到底能做什么?

事实证明,在信号处理函数里能做什么、不能做什么,限制相当多。这也是我说你根本不该费这个劲、而应该改用操作系统的信号处理功能(例如类 Unix 系统上的 sigaction())的原因之一。

维基百科甚至说,你唯一真正可移植的做法就是用 SIG_IGN 或 SIG_DFL 调用 signal(),仅此而已。

下面这些是我们不能以可移植方式做到的:

最后那一小点——sig_atomic_t——就是你从信号处理函数里往外送数据的通行证。(除非你想用无锁原子对象,那不在本节讨论范围内172。)它是一种整数类型,可能是有符号的,也可能是无符号的。而且它的范围受限于你能往里塞什么。

你可以在 SIG_ATOMIC_MIN 和 SIG_ATOMIC_MAX 这两个宏里查看允许的最小值和最大值173。

令人困惑的是,规范还说,你不得引用“具有静态或线程存储期、且不是无锁原子对象的任何对象,除非是给声明为 volatile sig_atomic_t 的对象赋值 […]”

我的理解是:凡不是无锁原子对象的东西,你既不能读也不能写。另外,你可以给 volatile sig_atomic_t 的对象赋值。

但你能从它读取吗?说实话,我看不出为什么不行,只是规范在提到赋值时措辞非常明确。可如果你必须读取它,并据此做出任何判断,那就可能给某种竞争条件留下可乘之机。

考虑到这一点,我们可以把“连按两次 CTRL-C 才退出”的代码改写得稍微可移植一些,尽管输出没那么啰嗦。

我们把 SIGINT 的处理函数改成:除了递增一个类型为 volatile sig_atomic_t 的值之外,什么都不做。这样它就能统计 CTRL-C 被按下的次数。

然后在主循环里,我们检查那个计数器是否超过 2,如果超过就退出。

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

volatile sig_atomic_t count = 0;

void sigint_handler(int signum)
{
    (void)signum;                    // 未使用变量的警告

    signal(SIGINT, sigint_handler);  // 重置信号处理函数

    count++;                         // 未定义行为
}

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

    printf("Hit ^C twice to exit.\n");

    while(count < 2);
}

又是未定义行为?据我理解是的,因为要递增并存储这个值,就必须先读取它。另一个线程可能会来乱动 count,把我们搞疯。但在这种简单情形下,并没有别的线程这么做,所以我们可以对这种越界行为睁一只眼闭一只眼,开开心心地看演示。

如果我们只想把退出推迟一次 CTRL-C 的时间,那还不算太麻烦。但若想再推迟更多次,就得搞一些荒唐的函数链式调用了。

我们要做的是只处理一次,而处理函数会把该信号重置为默认行为(也就是退出):

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

void sigint_handler(int signum)
{
    (void)signum;                      // 未使用变量的警告
    signal(SIGINT, SIG_DFL);           // 重置信号处理函数
}

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

    printf("Hit ^C twice to exit.\n");

    while(1);
}

等后面讲到无锁原子变量时,我们会看到修复 count 版本的办法(前提是你的系统确实支持无锁原子变量)。

这就是为什么我在开头建议你去了解操作系统内置的信号系统,它很可能是更好的替代方案。

29.5 朋友不会让朋友用 signal()

再说一遍,请使用操作系统内置的信号处理机制或类似的东西。它不在规范里,可移植性也没那么好,但功能很可能强大得多。况且你的操作系统多半还定义了一批 C 规范里没有的信号。再说,用 signal() 本来就很难写出可移植的代码。


| 目录 |