学习时的第一个信号实验很短。程序每隔一秒打印一次,终端里按下 Ctrl+C,它马上退出。给 SIGINT 安装处理函数以后再按一次,终端只多出一行收到信号的提示,程序还活着。

刚看到这里时,我把信号记成了几个按键和一张编号表。等我把 killraisealarmabort 和非法内存访问放进同一个程序里,信号产生的过程才连了起来。进程原本在执行自己的代码,某个事件发生后,内核给它递交一条通知,原来的控制流随时可能因此改变。

信号来之前,处理办法已经在那里了

我现在把一次信号过程先分成下面三段。

产生信号  →  信号等待递达  →  执行信号的处置动作

进程收到信号以后,会采用默认、忽略或自定义处理函数中的一种处置。处理办法往往早已设置好,信号出现时才决定采用哪一种。SIGKILLSIGSTOP 是两个例外,它们不能被捕捉、阻塞或忽略,否则一个失控进程可能连内核强制控制它的手段也屏蔽掉。

信号产生也不等于处理函数在产生位置立刻执行。中间有时间差。外部信号可能先处于未决状态,等到合适的时机再递达。这两个阶段混在一起以后,后面学习信号屏蔽字时很容易把 pending 和 blocked 当成一回事。

课件把信号称为异步通知,这对 Ctrl+C、其他进程调用 kill、定时器到期都很好理解。进程无法从当前这行 C++ 代码预测它们在哪一刻出现。非法指令和非法内存访问稍有不同,它们由当前正在执行的指令触发,通常归在同步故障一类。两类事件最后都能借助 Linux 信号机制影响进程,产生原因并不完全相同。

平时可以用 kill -l 查看当前系统的信号名称。代码里更适合写 SIGINTSIGALRMSIGSEGV 这些宏,不要把某台机器上的数字写死。文末实测输出中的 2、6、10、11、14 来自这次 WSL 环境,换到别的平台时应当以系统头文件为准。

Ctrl+C 这一小段比我原先想得长

参考文章把键盘输入解释成操作系统收到硬件中断,再把组合键变成信号。这个方向能帮助理解,不过从程序能观察到的边界来看,更关键的一层是终端驱动。

在常见终端设置中,Ctrl+C 对应控制字符 VINTRCtrl+\ 对应 VQUITCtrl+Z 对应 VSUSP。终端的 ISIG 标志打开时,这些字符会分别产生 SIGINTSIGQUITSIGTSTP。它们通常不会作为普通字符交给程序的 read

还有一处很容易写成“发给当前进程”。终端控制字符面向的是前台进程组。同一条管道命令中的多个前台进程可以属于同一个进程组,按下 Ctrl+C 时,它们都有机会收到 SIGINT。Shell 会启动可执行文件,也要维护会话、进程组和前后台作业。

Ctrl+C
   ↓
终端驱动识别 VINTR
   ↓
内核产生 SIGINT
   ↓
递交给终端的前台进程组

文末程序的 keyboard 模式捕捉了这三个信号。手动运行后按一次组合键,处理函数打印固定字符串并返回,主流程看到 g_last_signal 已经改变后正常结束。

./signal_lab keyboard

这里捕捉的是 SIGTSTP,所以按 Ctrl+Z 后程序不会采用默认的暂停动作。SIGTSTPSIGSTOP 名字相近,能力却有差别。前者可以捕捉,后者始终由内核保留。

kill、raise 和 abort 需要分开记

kill 这个名字容易让我先想到“杀进程”,它实际可以向目标进程或进程组发送指定信号。发送 SIGUSR1 时,目标只会按照 SIGUSR1 的处置方式行动,不会因为调用者用了 kill 就必然退出。

int kill(pid_t pid, int sig);

pid 大于 0 时指向一个具体进程。它取 0 或负数时还能表示进程组,具体范围要连同调用者权限一起判断。返回 0 表示至少成功发送给一个目标,返回 -1 时再检查 errno。常见错误包括目标不存在的 ESRCH、权限不足的 EPERM 和信号编号无效的 EINVAL

我在实验里先调用了下面这一句。

kill(child, 0);

信号编号 0 不会递交真实信号。它仍然检查进程是否存在,也会检查当前用户有没有发送权限。这个办法适合做轻量探测,不过检查通过之后目标也可能马上退出,因此它不能消除并发变化。

随后父进程发送 SIGUSR1,子进程的处理函数被调用,最后正常退出。为了避免父进程发得太早,代码用一根匿名管道传递就绪字节。子进程先安装处理函数,再通知父进程。少了这一步,父进程可能在子进程完成 sigaction 之前就发送 SIGUSR1,子进程会执行默认终止动作,实验结果也会时好时坏。

raise 的目标固定为调用者。在单线程程序中,可以把它理解成 kill(getpid(), sig)。多线程程序里它针对调用线程,Linux 的 C 库通常会落到线程定向的实现上。raise 属于 C 标准库接口,把它直接称为系统调用不够准确。

我的运行结果里,raise(SIGUSR1) 先进入处理函数,处理函数返回以后,raise 才回到主流程。这个顺序很适合验证“给自己发信号”并不等于新开一条执行流。

abort 也属于库函数,而且它比 raise 更强硬。它会解除对 SIGABRT 的阻塞,再向调用者产生 SIGABRT。即使进程忽略了 SIGABRT,或者处理函数执行后正常返回,abort 仍会恢复默认处置并让进程异常终止。

这次我让子进程安装一个会返回的 SIGABRT 处理函数,输出中先出现 handler 的消息,父进程随后仍然观察到子进程由 6 号信号终止。这正好补上只看一次 handler 输出时容易漏掉的后半段。

alarm 到点以后,内核补上一记 SIGALRM

alarm 对我理解“软件条件产生信号”最有帮助。调用者给内核设置一个以秒为单位的闹钟,时间到后,内核向这个进程产生 SIGALRM。这个信号的默认动作是终止进程。

unsigned int alarm(unsigned int seconds);

每个进程只有一个这样的闹钟。再次调用 alarm 会取消旧设置并换成新设置,返回值是旧闹钟剩余的秒数。传入 0 会取消当前闹钟,同时仍返回原先剩余的时间。

实验先设置 3 秒闹钟,经过约 1.1 秒后改成 1 秒。第二次 alarm 返回 2,随后只收到一次 SIGALRM。处理函数里没有再次调用 alarm,所以它不会自动形成周期定时器。

参考代码常在 handler 中使用 coutprintf。普通演示有时看不出问题,异步信号到达的时机却无法预测。主程序可能正改到标准 IO 的内部缓冲区,handler 又进入同一套非可重入状态,结果没有可靠保证。POSIX 给出了一组异步信号安全函数,write 在其中。文末处理函数只用 write 输出预先准备好的固定文本,并把信号编号记录进 volatile sig_atomic_t。格式化输出和流程判断都放回主程序完成。

还有一个实际边界值得记住。alarmsetitimer 会共享计时资源,一些 sleep 实现也可能依赖 SIGALRM。把它们混在同一段程序里会相互干扰。实验用 nanosleep 等待,并把 EINTR 当成正常的信号打断情况处理。

硬件异常、退出状态和 core dump

进程访问没有映射或没有权限的地址时,处理器产生异常并进入内核。Linux 可以把这类故障转换成发给当前进程的 SIGSEGV。整数除零常见的现象是 SIGFPE,这个名字里虽然有 floating point,它覆盖的情况并不限于浮点运算。

参考文章里的 4/0 是编译期就能看见的常量表达式。C++ 对整数除零没有定义,编译器可以警告、拒绝某种写法或做出优化,不能把某一次运行现象当成语言标准保证。用 raise(SIGFPE) 又只能证明软件可以主动产生同名信号,无法证明处理器执行除法时发生了异常。

文末实验选择在子进程中写空指针。空指针解引用同样属于 C++ 未定义行为,这次 WSL 和编译选项下观察到了 SIGSEGV,这个结论只描述 Linux 实测。把危险操作留在子进程中,父进程还能继续运行并读取它的终止原因。

这类故障不适合随手捕捉后继续执行。假如处理函数返回,而引发异常的指令和地址都没有修复,进程很可能回到原指令再次故障,handler 便会反复进入。实验保留 SIGSEGV 的默认处置,让子进程终止,再由父进程分析状态。

父进程没有直接拆 status 的二进制位,而是按顺序使用这些宏。

if (WIFSIGNALED(status)) {
    int signo = WTERMSIG(status);
#ifdef WCOREDUMP
    bool dumped = WCOREDUMP(status);
#endif
}

只有 WIFSIGNALED 为真时,WTERMSIGWCOREDUMP 的含义才成立。这样写也比 status & 0x7F 更容易看出代码要判断正常退出、信号终止还是其他状态变化。

man 7 signal 中的 TermCore 都会终止进程,Core 还带有生成核心转储的默认动作。看到 Segmentation fault (core dumped) 这一行还不够。当前目录未必出现名为 core 的文件,先要看进程的 RLIMIT_CORE,目录权限、磁盘空间和同名文件也会影响写入。/proc/sys/kernel/core_pattern 还可能改名或转交转储,有些系统会把它交给 systemd-coredump 一类服务。

core 文件还可能包含进程内存中的口令、令牌和业务数据,生产环境不能只为了调试方便就无限打开。实验子进程主动把 RLIMIT_CORE 设为 0,只验证终止信号,不在工作区落下 core 文件。所以这次 abort 和空指针实验的 core-flag 都是 0。若要练习事后调试,应当在专门目录中单独调整限制,再核对 core_pattern 和系统的转储服务。

一次跑完后的记录

源码在 WSL 中使用下面的选项编译,没有产生编译警告。

g++ -std=c++11 -Wall -Wextra -Wpedantic signal_lab.cc -o signal_lab
./signal_lab all

这次实际输出如下。PID 每次都会变化,信号宏对应的数字也应当以运行平台为准。

[kill] parent pid=417
[kill] kill(pid, 0) passed existence and permission checks
[kill] sending SIGUSR1 to child pid=556
[handler] received SIGUSR1
[parent] kill-demo child pid=556 exited, code=0

[raise] caller pid=417, raising SIGUSR1 for itself
[handler] received SIGUSR1
[raise] raise returned after the handler, observed signal=10

[alarm] scheduling an alarm for 3 seconds
[alarm] replaced it with a 1-second alarm, old remaining=2
[handler] received SIGALRM
[alarm] one SIGALRM arrived, current alarm(0) returned=0

[abort] child installs a SIGABRT handler that returns
[abort-child] calling abort()
[handler] received SIGABRT and will return
[parent] abort-demo child pid=557 terminated by signal=6, core-flag=0

[segv] child writes through a null pointer
[segv-child] about to access address 0
[parent] segv-demo child pid=558 terminated by signal=11, core-flag=0

交互模式也用 SIGINT 补测过一次,handler 返回后主流程读到了信号编号 2。

[keyboard] pid=635, press Ctrl+C, Ctrl+\, or Ctrl+Z once
[handler] received SIGINT
[keyboard] handler returned, main observed signal=2

完整源代码

下面三个文件就是这次编译和运行使用的全部源码。run_demo.sh 会先重新编译,再依次运行不需要手工按键的五组实验。

signal_lab.cc

#include <cerrno>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>

#include <sys/resource.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

namespace {

volatile sig_atomic_t g_last_signal = 0;

void SignalHandler(int signo)
{
    g_last_signal = signo;

    const char *message = "[handler] received an unexpected signal\n";
    std::size_t length = sizeof("[handler] received an unexpected signal\n") - 1;

    switch (signo)
    {
    case SIGINT:
        message = "[handler] received SIGINT\n";
        length = sizeof("[handler] received SIGINT\n") - 1;
        break;
    case SIGQUIT:
        message = "[handler] received SIGQUIT\n";
        length = sizeof("[handler] received SIGQUIT\n") - 1;
        break;
    case SIGTSTP:
        message = "[handler] received SIGTSTP\n";
        length = sizeof("[handler] received SIGTSTP\n") - 1;
        break;
    case SIGUSR1:
        message = "[handler] received SIGUSR1\n";
        length = sizeof("[handler] received SIGUSR1\n") - 1;
        break;
    case SIGALRM:
        message = "[handler] received SIGALRM\n";
        length = sizeof("[handler] received SIGALRM\n") - 1;
        break;
    case SIGABRT:
        message = "[handler] received SIGABRT and will return\n";
        length = sizeof("[handler] received SIGABRT and will return\n") - 1;
        break;
    default:
        break;
    }

    const ssize_t ignored = write(STDOUT_FILENO, message, length);
    (void)ignored;
}

bool InstallHandler(int signo)
{
    struct sigaction action;
    std::memset(&action, 0, sizeof(action));
    action.sa_handler = SignalHandler;
    sigemptyset(&action.sa_mask);
    action.sa_flags = 0;

    if (sigaction(signo, &action, nullptr) == -1)
    {
        std::cerr << "sigaction failed: " << std::strerror(errno) << std::endl;
        return false;
    }
    return true;
}

bool SleepFor(long seconds, long nanoseconds)
{
    struct timespec request = {seconds, nanoseconds};
    struct timespec remaining = {0, 0};

    while (nanosleep(&request, &remaining) == -1)
    {
        if (errno != EINTR)
        {
            std::cerr << "nanosleep failed: " << std::strerror(errno) << std::endl;
            return false;
        }
        request = remaining;
    }
    return true;
}

bool WaitForSignalOrTimeout(int signo, int timeout_seconds)
{
    struct timespec request = {0, 100 * 1000 * 1000};

    for (int elapsed = 0; elapsed < timeout_seconds * 10; ++elapsed)
    {
        if (g_last_signal == signo)
        {
            return true;
        }

        struct timespec remaining = {0, 0};
        while (nanosleep(&request, &remaining) == -1 && errno == EINTR)
        {
            if (g_last_signal == signo)
            {
                return true;
            }
            request = remaining;
        }
        request = {0, 100 * 1000 * 1000};
    }

    return g_last_signal == signo;
}

int WaitAndReport(pid_t child, const char *label)
{
    int status = 0;
    pid_t result = -1;

    do
    {
        result = waitpid(child, &status, 0);
    } while (result == -1 && errno == EINTR);

    if (result == -1)
    {
        std::cerr << "waitpid failed: " << std::strerror(errno) << std::endl;
        return 1;
    }

    std::cout << "[parent] " << label << " child pid=" << child;
    if (WIFEXITED(status))
    {
        std::cout << " exited, code=" << WEXITSTATUS(status);
    }
    else if (WIFSIGNALED(status))
    {
        std::cout << " terminated by signal=" << WTERMSIG(status);
#ifdef WCOREDUMP
        std::cout << ", core-flag=" << (WCOREDUMP(status) ? 1 : 0);
#endif
    }
    else
    {
        std::cout << " changed state, raw-status=" << status;
    }
    std::cout << std::endl;

    return 0;
}

bool DisableCoreDump()
{
    const struct rlimit limit = {0, 0};
    return setrlimit(RLIMIT_CORE, &limit) == 0;
}

int RunKeyboardDemo()
{
    g_last_signal = 0;
    if (!InstallHandler(SIGINT) || !InstallHandler(SIGQUIT) || !InstallHandler(SIGTSTP))
    {
        return 1;
    }

    std::cout << "[keyboard] pid=" << getpid()
              << ", press Ctrl+C, Ctrl+\\, or Ctrl+Z once" << std::endl;

    while (g_last_signal == 0)
    {
        if (!SleepFor(1, 0))
        {
            return 1;
        }
    }

    std::cout << "[keyboard] handler returned, main observed signal="
              << g_last_signal << std::endl;
    return 0;
}

int RunKillDemo()
{
    int ready_pipe[2];
    if (pipe(ready_pipe) == -1)
    {
        std::cerr << "pipe failed: " << std::strerror(errno) << std::endl;
        return 1;
    }

    std::cout << "\n[kill] parent pid=" << getpid() << std::endl;
    const pid_t child = fork();
    if (child == -1)
    {
        std::cerr << "fork failed: " << std::strerror(errno) << std::endl;
        close(ready_pipe[0]);
        close(ready_pipe[1]);
        return 1;
    }

    if (child == 0)
    {
        close(ready_pipe[0]);
        g_last_signal = 0;

        if (!InstallHandler(SIGUSR1))
        {
            _exit(2);
        }

        const char ready = 'R';
        if (write(ready_pipe[1], &ready, 1) != 1)
        {
            _exit(3);
        }
        close(ready_pipe[1]);

        while (g_last_signal != SIGUSR1)
        {
            pause();
        }
        _exit(0);
    }

    close(ready_pipe[1]);
    char ready = 0;
    ssize_t read_result = -1;
    do
    {
        read_result = read(ready_pipe[0], &ready, 1);
    } while (read_result == -1 && errno == EINTR);
    close(ready_pipe[0]);

    if (read_result != 1 || ready != 'R')
    {
        std::cerr << "child did not become ready" << std::endl;
        kill(child, SIGKILL);
        return WaitAndReport(child, "kill-demo");
    }

    if (kill(child, 0) == 0)
    {
        std::cout << "[kill] kill(pid, 0) passed existence and permission checks"
                  << std::endl;
    }
    else
    {
        std::cerr << "kill(pid, 0) failed: " << std::strerror(errno) << std::endl;
        kill(child, SIGKILL);
        return WaitAndReport(child, "kill-demo");
    }

    std::cout << "[kill] sending SIGUSR1 to child pid=" << child << std::endl;
    if (kill(child, SIGUSR1) == -1)
    {
        std::cerr << "kill(SIGUSR1) failed: " << std::strerror(errno) << std::endl;
        kill(child, SIGKILL);
        return WaitAndReport(child, "kill-demo");
    }

    return WaitAndReport(child, "kill-demo");
}

int RunRaiseDemo()
{
    g_last_signal = 0;
    if (!InstallHandler(SIGUSR1))
    {
        return 1;
    }

    std::cout << "\n[raise] caller pid=" << getpid()
              << ", raising SIGUSR1 for itself" << std::endl;
    if (raise(SIGUSR1) != 0)
    {
        std::cerr << "raise failed" << std::endl;
        return 1;
    }

    std::cout << "[raise] raise returned after the handler, observed signal="
              << g_last_signal << std::endl;
    return g_last_signal == SIGUSR1 ? 0 : 1;
}

int RunAlarmDemo()
{
    g_last_signal = 0;
    if (!InstallHandler(SIGALRM))
    {
        return 1;
    }

    std::cout << "\n[alarm] scheduling an alarm for 3 seconds" << std::endl;
    alarm(3);
    if (!SleepFor(1, 100 * 1000 * 1000))
    {
        alarm(0);
        return 1;
    }

    const unsigned int remaining = alarm(1);
    std::cout << "[alarm] replaced it with a 1-second alarm, old remaining="
              << remaining << std::endl;

    if (!WaitForSignalOrTimeout(SIGALRM, 3))
    {
        std::cerr << "SIGALRM did not arrive in time" << std::endl;
        alarm(0);
        return 1;
    }

    std::cout << "[alarm] one SIGALRM arrived, current alarm(0) returned="
              << alarm(0) << std::endl;
    return 0;
}

int RunAbortDemo()
{
    std::cout << "\n[abort] child installs a SIGABRT handler that returns"
              << std::endl;
    const pid_t child = fork();
    if (child == -1)
    {
        std::cerr << "fork failed: " << std::strerror(errno) << std::endl;
        return 1;
    }

    if (child == 0)
    {
        if (!DisableCoreDump())
        {
            _exit(2);
        }
        if (!InstallHandler(SIGABRT))
        {
            _exit(3);
        }
        const char message[] = "[abort-child] calling abort()\n";
        (void)write(STDOUT_FILENO, message, sizeof(message) - 1);
        abort();
    }

    return WaitAndReport(child, "abort-demo");
}

int RunSegvDemo()
{
    std::cout << "\n[segv] child writes through a null pointer" << std::endl;
    const pid_t child = fork();
    if (child == -1)
    {
        std::cerr << "fork failed: " << std::strerror(errno) << std::endl;
        return 1;
    }

    if (child == 0)
    {
        if (!DisableCoreDump())
        {
            _exit(2);
        }

        const char message[] = "[segv-child] about to access address 0\n";
        (void)write(STDOUT_FILENO, message, sizeof(message) - 1);
        volatile int *bad_address = nullptr;
        *bad_address = 7;
        _exit(0);
    }

    return WaitAndReport(child, "segv-demo");
}

void PrintUsage(const char *program)
{
    std::cerr << "Usage: " << program
              << " {keyboard|kill|raise|alarm|abort|segv|all}" << std::endl;
}

} // namespace

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        PrintUsage(argv[0]);
        return 1;
    }

    const std::string mode = argv[1];
    if (mode == "keyboard")
    {
        return RunKeyboardDemo();
    }
    if (mode == "kill")
    {
        return RunKillDemo();
    }
    if (mode == "raise")
    {
        return RunRaiseDemo();
    }
    if (mode == "alarm")
    {
        return RunAlarmDemo();
    }
    if (mode == "abort")
    {
        return RunAbortDemo();
    }
    if (mode == "segv")
    {
        return RunSegvDemo();
    }
    if (mode == "all")
    {
        int result = 0;
        result |= RunKillDemo();
        result |= RunRaiseDemo();
        result |= RunAlarmDemo();
        result |= RunAbortDemo();
        result |= RunSegvDemo();
        return result;
    }

    PrintUsage(argv[0]);
    return 1;
}

Makefile

CXX ?= g++
CXXFLAGS ?= -std=c++11 -Wall -Wextra -Wpedantic
TARGET := signal_lab

.PHONY: all clean run

all: $(TARGET)

$(TARGET): signal_lab.cc
	$(CXX) $(CXXFLAGS) $< -o $@

run: $(TARGET)
	./$(TARGET) all

clean:
	rm -f $(TARGET)

run_demo.sh

#!/usr/bin/env bash

set -eu

make clean
make
./signal_lab all
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐