6、信号的高级应用:实时信号的概念与特点

信号这东西,很多人学完基础就觉得够了。但说实话,标准信号那套机制,在真正的高并发、低延迟场景下,根本不够用。我当年接手一个工业控制项目,设备上报数据频繁丢失,查来查去,问题就出在标准信号排队机制上——信号丢失了。后来换成实时信号,问题才彻底解决。

这一章,咱们就聊聊实时信号。它不是什么新概念,但很多嵌入式工程师用得少,甚至不知道。你想想看,如果面试时你能把实时信号的排队机制讲清楚,面试官会怎么看你?

6.1 实时信号的概念与特点

实时信号(Realtime Signal)是 POSIX.1b 标准引入的。它解决了标准信号的两个核心痛点:信号丢失无法携带数据

标准信号(1~31)有个问题——如果同一个信号多次到达,内核可能只记录一次。为什么?因为标准信号用位图表示,一个位只能表示「有」或「没有」。你发 10 次 SIGUSR1,进程可能只收到 1 次。

实时信号(SIGRTMIN ~ SIGRTMAX)就不一样了。它用队列管理,每个信号都排队。你发 10 次,它就收 10 次,一个不落。

实时信号的核心特点:

  • 排队机制:信号不会丢失,按发送顺序排队
  • 携带数据:可以附带一个整数或指针值
  • 优先级:信号编号越小,优先级越高
  • 可靠交付:内核保证每个信号都被递送

我记得第一次用实时信号时,心里还犯嘀咕:这玩意儿跟标准信号到底差多少?后来做了个压力测试,标准信号在 1000 次发送中丢了 300 多次,实时信号一次没丢。嗯,差距就在这里。

6.2 实时信号的发送:sigqueue

发送实时信号,不能用 kill() 了。得用 sigqueue()。这个函数比 kill 多了一个参数——可以传数据。

#include <signal.h>

int sigqueue(pid_t pid, int sig, const union sigval value);

// union sigval 定义:
union sigval {
    int   sival_int;
    void *sival_ptr;
};

说白了,sigqueue 就是 kill 的升级版。它不仅能发信号,还能带个「小纸条」过去。

我习惯这么用:

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

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "用法: %s <pid> <value>\n", argv[0]);
        exit(1);
    }

    pid_t pid = atoi(argv[1]);
    int val = atoi(argv[2]);

    union sigval sv;
    sv.sival_int = val;

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

    printf("已向进程 %d 发送实时信号,附带值: %d\n", pid, val);
    return 0;
}

小提示:发送实时信号时,建议使用 SIGRTMIN + n 的形式,不要直接用数字。不同系统上 SIGRTMIN 的值可能不同,Linux 上是 34。

6.3 实时信号的接收与排队

接收端怎么处理?用 sigaction(),配合 SA_SIGINFO 标志。这样信号处理函数就能拿到发送方传来的数据。

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

void handler(int sig, siginfo_t *info, void *context) {
    // info->si_value 就是发送方传来的数据
    printf("收到信号: %d, 附带值: %d, 发送方 PID: %d\n",
           sig, info->si_value.sival_int, info->si_pid);
}

int main() {
    struct sigaction act;
    memset(&act, 0, sizeof(act));
    act.sa_sigaction = handler;
    act.sa_flags = SA_SIGINFO;  // 关键!必须加这个标志

    // 阻塞所有实时信号,防止处理函数被重入
    sigfillset(&act.sa_mask);

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

    printf("接收进程 PID: %d\n", getpid());
    printf("等待实时信号...\n");

    // 让进程一直运行
    while (1) {
        pause();
    }

    return 0;
}

这里有个坑,我踩过。如果你不加 SA_SIGINFO,那 sa_sigaction 根本不会被调用,系统会调用 sa_handler。而且你拿不到数据。我曾经因为这个 bug 调试了一下午,最后发现是标志位没设。

关于排队,内核是怎么做的?每个进程有一个实时信号队列。队列长度有限制,可以通过 /proc/sys/kernel/rtsig-max 查看。默认是 65536 个。如果队列满了,sigqueue() 会返回 EAGAIN。

注意:实时信号的排队是有上限的。如果短时间内发送大量信号,队列满了后面的信号会被丢弃。虽然概率低,但高并发场景下要考虑这个边界。

6.4 信号在 IPC 中的应用场景

信号能用来做进程间通信吗?能,但有限制。我一般只在以下场景用:

  • 通知事件:比如子进程告诉父进程「我准备好了」
  • 传递小数据:用实时信号带一个整数,比如文件描述符、状态码
  • 控制命令:比如让进程重新加载配置、暂停、退出

但说实话,信号不适合传大量数据。你想想看,一个信号只能带一个整数或指针,能传多少东西?真要传复杂结构体,还是用共享内存或消息队列吧。

我做过一个项目,用实时信号做心跳检测。主进程每隔 1 秒给工作进程发一个实时信号,附带一个递增的序列号。工作进程收到后回复。如果连续 3 次没收到回复,主进程就认为工作进程挂了,自动拉起。这个方案简单可靠,比用 socket 轻量多了。

6.5 信号驱动 I/O

信号驱动 I/O 是个好东西。它让你不用轮询,不用 select/poll/epoll,文件描述符有数据时,内核直接发信号通知你。

怎么用?三步走:

  1. 设置信号处理函数
  2. fcntl() 设置文件描述符为信号驱动模式
  3. 指定接收信号的进程
#include <stdio.h>
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

int fd;

void sigio_handler(int sig, siginfo_t *info, void *context) {
    char buf[1024];
    ssize_t n;

    // 注意:信号处理函数中要小心使用非异步信号安全的函数
    // read 是安全的
    n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("收到数据: %s", buf);
    }
}

int main() {
    struct sigaction act;
    memset(&act, 0, sizeof(act));
    act.sa_sigaction = sigio_handler;
    act.sa_flags = SA_SIGINFO;
    sigaction(SIGIO, &act, NULL);

    // 打开一个文件或设备
    fd = open("/dev/input/event0", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 设置当前进程为文件描述符的属主
    fcntl(fd, F_SETOWN, getpid());

    // 设置信号驱动 I/O 模式
    int flags = fcntl(fd, F_GETFL);
    fcntl(fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);

    printf("等待输入事件...\n");

    while (1) {
        pause();
    }

    close(fd);
    return 0;
}

信号驱动 I/O 适合那些「数据不频繁但实时性要求高」的场景。比如键盘输入、鼠标事件、串口数据。但要注意,信号处理函数里不能做太多事,否则会影响系统响应。

我的经验:信号驱动 I/O 在嵌入式设备上特别好用。比如一个温度传感器,可能几分钟才上报一次数据。用信号驱动,CPU 可以睡大觉,数据来了再干活。省电又高效。

6.6 SIGCHLD 信号与子进程回收

SIGCHLD 这个信号,搞 Linux 编程的应该不陌生。子进程退出时,内核会给父进程发这个信号。父进程收到后,调用 wait()waitpid() 回收子进程,防止僵尸进程。

但很多人处理 SIGCHLD 时有个误区——以为只处理一次就够了。我见过一个案例,父进程 fork 了 10 个子进程,结果只回收了 3 个,剩下 7 个成了僵尸。为什么?因为 SIGCHLD 信号可能合并。

正确的做法是:在信号处理函数里循环调用 waitpid(),直到没有子进程退出为止。

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

void sigchld_handler(int sig) {
    int status;
    pid_t pid;

    // 循环回收,直到没有子进程退出
    // WNOHANG 表示非阻塞
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        printf("回收子进程 %d, 退出状态: %d\n", pid, WEXITSTATUS(status));
    }
}

int main() {
    struct sigaction act;
    act.sa_handler = sigchld_handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    // SA_NOCLDSTOP 表示子进程暂停时不触发 SIGCHLD
    sigaction(SIGCHLD, &act, NULL);

    // fork 多个子进程
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程
            printf("子进程 %d 启动\n", getpid());
            sleep(2);
            exit(i);
        }
    }

    // 父进程继续做其他事
    while (1) {
        sleep(1);
    }

    return 0;
}

避坑指南:我曾经在 SIGCHLD 处理函数里只调了一次 waitpid(),结果线上服务器跑了一周后,ps 一看,好几百个僵尸进程。从那以后,我养成了「循环回收」的习惯。另外,记得加 SA_NOCLDSTOP,否则子进程被暂停也会触发信号,没必要。

6.7 定时器信号:setitimer

定时器信号,说白了就是让内核到时间了给你发个信号。Linux 提供了 setitimer()alarm() 两种方式。alarm 太简单,只能设一次。setitimer 可以设三种定时器:

定时器类型 信号 行为
ITIMER_REAL SIGALRM 真实时间,进程睡眠也计时
ITIMER_VIRTUAL SIGVTALRM 用户态 CPU 时间
ITIMER_PROF SIGPROF 用户态 + 内核态 CPU 时间

我平时用得最多的是 ITIMER_REAL。比如做超时检测、心跳包发送、定时任务。

#include <stdio.h>
#include <signal.h>
#include <sys/time.h>
#include <unistd.h>
#include <string.h>

void timer_handler(int sig) {
    static int count = 0;
    count++;
    printf("定时器触发 %d 次\n", count);
}

int main() {
    struct sigaction act;
    memset(&act, 0, sizeof(act));
    act.sa_handler = timer_handler;
    sigemptyset(&act.sa_mask);
    sigaction(SIGALRM, &act, NULL);

    struct itimerval timer;

    // 初始间隔:1 秒
    timer.it_value.tv_sec = 1;
    timer.it_value.tv_usec = 0;

    // 重复间隔:2 秒
    timer.it_interval.tv_sec = 2;
    timer.it_interval.tv_usec = 0;

    if (setitimer(ITIMER_REAL, &timer, NULL) == -1) {
        perror("setitimer");
        return 1;
    }

    printf("定时器已启动,1 秒后首次触发,之后每 2 秒触发一次\n");

    while (1) {
        pause();
    }

    return 0;
}

这里有个细节:it_value 是第一次触发的时间,it_interval 是后续的间隔。如果 it_interval 设为 0,就变成一次性定时器。

小技巧:setitimer 的精度是微秒级,但实际精度取决于内核的时钟频率。一般嵌入式 Linux 的时钟频率是 100Hz,也就是 10 毫秒精度。如果你需要更高精度,可以考虑 POSIX 定时器 timer_create()。

6.8 信号的综合案例

说了这么多,咱们来个综合案例。这个案例模拟一个简单的任务调度器:

  • 主进程管理多个工作进程
  • 用实时信号给工作进程派发任务
  • 用 SIGCHLD 回收子进程
  • 用 setitimer 做定时心跳检测
#include <stdio.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

#define WORKER_NUM 3
#define HEARTBEAT_INTERVAL 3

// 工作进程函数
void worker_process(int id) {
    printf("[工作进程 %d] 启动,PID: %d\n", id, getpid());

    // 设置实时信号处理
    struct sigaction act;
    memset(&act, 0, sizeof(act));
    act.sa_sigaction = [](int sig, siginfo_t *info, void *ctx) {
        int task_id = info->si_value.sival_int;
        printf("[工作进程 %d] 收到任务: %d\n", getpid(), task_id);
        // 模拟任务处理
        sleep(1);
        printf("[工作进程 %d] 任务 %d 完成\n", getpid(), task_id);
    };
    act.sa_flags = SA_SIGINFO;
    sigaction(SIGRTMIN, &act, NULL);

    while (1) {
        pause();
    }
}

// 心跳超时处理
void heartbeat_handler(int sig) {
    printf("[主进程] 心跳检测...\n");
    // 这里可以检查工作进程是否存活
}

int main() {
    pid_t workers[WORKER_NUM];

    // 设置 SIGCHLD 处理
    struct sigaction chld_act;
    memset(&chld_act, 0, sizeof(chld_act));
    chld_act.sa_handler = [](int sig) {
        int status;
        pid_t pid;
        while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
            printf("[主进程] 回收子进程 %d\n", pid);
        }
    };
    sigaction(SIGCHLD, &chld_act, NULL);

    // 设置心跳定时器
    struct sigaction timer_act;
    memset(&timer_act, 0, sizeof(timer_act));
    timer_act.sa_handler = heartbeat_handler;
    sigaction(SIGALRM, &timer_act, NULL);

    struct itimerval timer;
    timer.it_value.tv_sec = HEARTBEAT_INTERVAL;
    timer.it_value.tv_usec = 0;
    timer.it_interval.tv_sec = HEARTBEAT_INTERVAL;
    timer.it_interval.tv_usec = 0;
    setitimer(ITIMER_REAL, &timer, NULL);

    // 创建工作进程
    for (int i = 0; i < WORKER_NUM; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            worker_process(i);
            exit(0);
        } else {
            workers[i] = pid;
            printf("[主进程] 创建工作进程 %d, PID: %d\n", i, pid);
        }
    }

    // 模拟派发任务
    int task_id = 0;
    while (1) {
        sleep(5);
        printf("\n[主进程] 派发新任务...\n");
        for (int i = 0; i < WORKER_NUM; i++) {
            union sigval sv;
            sv.sival_int = task_id++;
            sigqueue(workers[i], SIGRTMIN, sv);
        }
    }

    return 0;
}

这个案例把本章的知识点串起来了。你想想看,实际项目中,这种模式很常见——一个主控进程管理多个工作进程,用信号做通信和控制。

我当年做的一个视频监控系统,就是类似的架构。主进程负责调度,工作进程负责采集、编码、存储。用实时信号传递控制命令,用 SIGCHLD 监控子进程状态,用定时器做保活检测。这套方案跑了三年,没出过问题。

总结一下本章的核心:

  • 实时信号解决了标准信号的丢失问题,支持排队和数据传递
  • sigqueue 发送,sigaction 接收,别忘了 SA_SIGINFO
  • 信号驱动 I/O 适合低频率、高实时性的场景
  • SIGCHLD 处理要循环回收,防止僵尸进程
  • setitimer 做定时器,精度微秒级,够用

信号这东西,用好了是利器,用不好是坑。多写多练,慢慢就熟了。


公众号:蓝海资料掘金营,微信deep3321