从“快递管家”说起:sigaction的故事

你有没有过这样的经历?在终端里运行一个程序,随手按了Ctrl+C,程序“嗖”地一下就退出了——这背后其实是Linux的“信号机制”在干活。就像快递员敲门送快递,进程收到“信号”后,要么按默认规则处理(比如Ctrl+C对应“终止进程”),要么忽略,要么执行你指定的操作。

但如果只是简单处理,用signal函数就够了——可它就像个临时快递员,只会说“要么收要么扔”,遇到复杂情况就慌了:比如处理快递时要不要拦住其他快递?收完快递要不要通知邻居?能不能让快递员带点额外东西?这时候,就需要sigaction这位“专业快递管家”出场了——它能把所有规则制定得明明白白,还能应对各种特殊需求。

今天咱们就从“管家的日常工作”入手,一点点揭开sigaction的面纱:先搞懂它是干啥的,再拆明白它的“工作手册”(结构体),然后看它怎么处理不同“快递”(信号),最后上手实操几个例子,让你彻底学会用它管好进程的“信号快递”。

一、sigaction是谁?能干啥?——基本介绍与用途

1. 一句话定位:信号处理的“全能管家”

sigaction本质是Linux内核提供的系统调用(不是库函数),主要干两件事:

  • 给某个信号“定规矩”:比如“收到SIGINT(Ctrl+C)时,别终止进程,打印一句提示”;
  • 查某个信号的“老规矩”:比如“看看现在SIGCHLD的处理方式是啥,我之后要恢复回来”。

它比signal函数强在哪?举个例子:signal就像你跟快递员说“快递放门口”,而sigaction会跟快递员说“快递放门口,放的时候别让其他快递员来敲门,放完给我发个短信,要是我不在家就明天再来”——细节拉满,稳定性也更高。

2. 常见“工作场景”:这些时候必须找它

sigaction不是“花瓶”,在实际开发中,只要涉及信号的复杂处理,基本都离不开它。比如:

场景1:终端程序“抗造”——捕获Ctrl+C不退出

你写了个命令行工具(比如文件下载器),用户按Ctrl+C可能是误操作,这时候不能直接退出,得提示“确定要退出吗?未完成的任务会丢失”。这时候就用sigaction设置SIGINT的处理函数,替代默认的“终止进程”动作。

场景2:多进程“不闹鬼”——处理子进程退出避免僵尸

父进程创建子进程后,如果子进程先退出,父进程没及时“收尸”,子进程就会变成“僵尸进程”(Z状态),占着PID不释放。这时候子进程退出会发SIGCHLD信号,用sigaction处理这个信号,在处理函数里调用waitpid回收子进程,就能避免僵尸。

场景3:进程间“传小纸条”——用实时信号传递数据

普通信号只能“打个招呼”(比如“我要终止你”),但实时信号(32-64号)能附带数据——比如传感器进程给控制进程发信号时,顺带把“温度=25℃”的整数数据传过去。这时候必须用sigaction的sa_sigaction处理函数,才能接收这些附加信息。

场景4:系统调用“不中断”——让read/write自动续上

比如程序用read从键盘等输入,这时候收到信号,read会被中断,返回-1并设置errno=EINTR。如果用sigaction设置SA_RESTART标志,read会自动“续上”,不用你手动写代码重试——省了不少麻烦。

3. 对比signal:为啥选sigaction?

可能有人会问:“我用signal也能处理信号,为啥非要学sigaction?”咱们用“快递服务”对比一下就懂了:

功能点 signal(临时快递员) sigaction(专业管家)
处理期间拦其他快递? 只拦当前快递,不能选 想拦哪个拦哪个(sa_mask)
快递员中断送货后重试? 看心情(不同系统行为不同) 明确设置(SA_RESTART)
带附加东西(数据)? 不行,只能传个信 可以(实时信号+sa_sigaction)
查之前的规则? 得自己记,麻烦 自动存到oldact,随时查
跨系统能用? 不一定(Linux和BSD不一样) 能(符合POSIX标准)

简单说:signal适合“临时用用”,比如简单忽略某个信号;sigaction适合“正经开发”,尤其是多进程、实时系统、终端工具这些场景——稳定、灵活、兼容性强。

二、找sigaction之前:得知道它在哪——声明与来源

要请“管家”干活,得先知道它的“办公地址”(头文件)和“所属公司”(标准/库),不然编译时会报错。

1. 头文件:必须包含<signal.h>

sigaction的函数声明、struct sigaction结构体定义、还有各种信号宏(比如SIGINT、SIGCHLD),都在<signal.h>这个头文件里。所以写代码时,第一行必须加:

#include <signal.h>

少了这个,编译器会报“undefined reference to sigaction”或者“unknown type name struct sigaction”——相当于没找到管家的办公室,自然没法干活。

2. 标准与库:POSIX亲儿子,glibc自带

  • 标准归属:sigaction是POSIX.1-2001标准定义的系统调用,所以在所有符合POSIX的系统上都能用(比如Linux、macOS、FreeBSD),不用担心跨系统兼容性问题。
  • 库依赖:在Linux上,sigaction由glibc(GNU C库)封装,编译时不需要额外加链接参数(比如-lm这种),直接用gcc编译就行。比如:
    gcc -o sigdemo sigdemo.c -Wall
    
    这里-Wall是显示警告,建议加上,能帮你发现比如“没初始化结构体”这种小问题。

3. 函数原型:三个参数,一个返回值

先看sigaction的“名片”(函数原型):

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

咱们先简单扫一眼:

  • 返回值是int:成功返回0,失败返回-1(同时设置errno);
  • 三个参数:
    1. signum:要处理的“快递编号”(信号编号,比如SIGINT=2);
    2. act:新的“处理规则”(如果非NULL,就是给信号定新规矩);
    3. oldact:旧的“处理规则”(如果非NULL,就把原来的规矩存这里,方便之后恢复)。

这里最核心的是struct sigaction——这是sigaction的“工作手册”,里面写满了处理信号的各种细节。接下来咱们就拆了这本手册,看看里面都有啥。

三、sigaction的“工作手册”:struct sigaction结构体详解

如果把sigaction比作管家,那struct sigaction就是它的“工作手册”——里面每一项都规定了“怎么处理这个信号”。咱们先看手册的“目录”(结构体定义):

struct sigaction {
    // 1. 处理函数:二选一
    void (*sa_handler)(int);                  // 普通处理函数(大部分情况用这个)
    void (*sa_sigaction)(int, siginfo_t *, void *); // 带附加信息的处理函数(实时信号用)
    
    // 2. 信号屏蔽清单:处理信号时要拦住的其他信号
    sigset_t sa_mask;
    
    // 3. 行为开关:控制处理信号的细节
    int sa_flags;
    
    // 4. 已废弃:不用管,glibc自动处理
    void (*sa_restorer)(void);
};

注意:sa_handlersa_sigaction是“二选一”——不能同时用,选哪个由sa_flags里的SA_SIGINFO标志决定。另外,sa_restorer是历史遗留字段,现在不用设置,设了也没用,甚至可能出错,直接忽略就行。

下面咱们逐页拆解这本“工作手册”:

1. 处理函数:sa_handler vs sa_sigaction——选哪个?

这俩是“信号处理的核心动作”,相当于管家说“收到这个快递,我要做什么”。

(1)普通处理函数:sa_handler——日常够用

sa_handler是个函数指针,格式是:

void (*sa_handler)(int sig);
  • 参数sig:收到的信号编号(比如SIGINT=2),方便一个处理函数处理多个信号;
  • 取值有三种:
    SIG_DFL:用“默认动作”——比如SIGINT默认是“终止进程”,SIGQUIT默认是“终止进程+生成core dump文件”;
    SIG_IGN:“忽略这个信号”——比如设置SIGINT为SIG_IGN,按Ctrl+C就没用了;
    自定义函数指针:比如写个void my_handler(int sig),收到信号就执行这个函数。

举个例子:想让程序忽略Ctrl+C,就这么设:

struct sigaction act;
memset(&act, 0, sizeof(act)); // 先清空手册
act.sa_handler = SIG_IGN;     // 忽略SIGINT
sigaction(SIGINT, &act, NULL); // 应用规则
(2)带附加信息的处理函数:sa_sigaction——复杂场景用

sa_sigaction也是函数指针,但参数更多,格式是:

void (*sa_sigaction)(int sig, siginfo_t *info, void *ucontext);

sa_handler多了两个参数,专门用来处理“需要详细信息”的场景(比如实时信号):

  • sig:还是信号编号;
  • info:指向siginfo_t结构体,里面存满了信号的“附加信息”——比如谁发的信号(info->si_pid)、信号原因(info->si_code)、附带的数据(info->si_value);
  • ucontext:指向进程上下文(ucontext_t结构体),一般用不上,除非要恢复信号中断前的CPU状态(比如做调试工具)。

要想用sa_sigaction,必须在sa_flags里加SA_SIGINFO标志——相当于告诉管家“我要用高级模式,给我更多信息”。

举个简单的sa_sigaction函数:

void my_rt_handler(int sig, siginfo_t *info, void *ucontext) {
    printf("收到信号:%d\n", sig);
    printf("发送信号的进程PID:%d\n", info->si_pid); // 打印谁发的信号
    printf("附带的整数数据:%d\n", info->si_value.sival_int); // 打印附加数据
}

2. 信号屏蔽清单:sa_mask——处理时别被打扰

sa_masksigset_t类型(信号集),相当于“处理这个信号时,要拦住的其他快递清单”——避免处理过程中被其他信号打断,导致逻辑错乱。

比如:处理SIGINT时,要是再按一次Ctrl+C,会再次触发处理函数,导致“重入”(函数还没执行完又被调用),可能会修改全局变量出错。这时候就把SIGINT加入sa_mask,处理期间再收到SIGINT,会暂时“存起来”(进入未决信号集),等当前处理完再处理。

怎么操作sa_mask?用这几个函数:
  • sigemptyset(&sa_mask):清空清单(初始必须做,不然有随机值);
  • sigaddset(&sa_mask, SIGXXX):把SIGXXX加入清单(拦住这个信号);
  • sigdelset(&sa_mask, SIGXXX):把SIGXXX从清单中删除(不拦了);
  • sigfillset(&sa_mask):把所有信号加入清单(拦住所有信号,谨慎用);
  • sigismember(&sa_mask, SIGXXX):检查SIGXXX是否在清单里(返回1是,0不是)。

举个例子:处理SIGINT时,拦住SIGINT和SIGQUIT(避免被Ctrl+\打断):

struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_handler = my_handler;

// 初始化屏蔽清单
sigemptyset(&act.sa_mask);       // 清空
sigaddset(&act.sa_mask, SIGINT); // 拦住SIGINT(避免重入)
sigaddset(&act.sa_mask, SIGQUIT); // 拦住SIGQUIT(避免干扰)

act.sa_flags = SA_RESTART;
sigaction(SIGINT, &act, NULL);

注意:sa_mask只在“处理当前信号期间”有效——处理完信号后,会自动恢复原来的屏蔽清单,不用你手动改回来。

3. 行为开关:sa_flags——细节控的福音

sa_flags是整数,相当于“管家的行为开关”——每个开关控制一个细节,多个开关用“|”(或运算)组合。比如SA_RESTART | SA_SIGINFO就是同时打开两个开关。

下面是最常用的几个开关,必须记住:

(1)SA_RESTART:系统调用被中断后自动重启

这是最常用的开关之一!比如程序用read(STDIN_FILENO, buf, 1024)从键盘读输入,这时候收到信号,read会被中断,返回-1并设置errno=EINTR。如果没设SA_RESTART,你得手动判断errno,然后重试read

// 没设SA_RESTART的情况,需要手动重试
ssize_t n;
while ((n = read(STDIN_FILENO, buf, 1024)) == -1 && errno == EINTR) {
    // 啥也不用干,继续循环重试
}

但设了SA_RESTART后,read会自动“续上”——被中断后不用你管,内核会帮你重新调用read,直到成功或真的出错(比如文件结束)。省了不少代码!

(2)SA_NOCLDWAIT:处理SIGCHLD时自动回收子进程

父进程处理SIGCHLD时,设了这个开关,子进程退出后会被内核自动回收,不会变成僵尸进程——甚至不用在处理函数里调用waitpid

举个例子:

struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_handler = SIG_IGN; // 甚至可以忽略SIGCHLD
act.sa_flags = SA_NOCLDWAIT; // 关键开关
sigaction(SIGCHLD, &act, NULL);

这样一来,子进程退出后,内核直接回收,父进程连waitpid都不用写——特别适合“父进程不管子进程退出状态”的场景。

注意:设了SA_NOCLDWAIT后,调用waitpid会返回-1,errno=ECHILD(表示没有子进程可回收),这是正常的。

(3)SA_SIGINFO:用sa_sigaction处理函数

前面说过,sa_handlersa_sigaction二选一——要想用sa_sigaction,必须设这个开关。不然sigaction会默认用sa_handler,哪怕你赋值了sa_sigaction也没用。

(4)SA_NODEFER:处理信号时不屏蔽当前信号

默认情况下,处理某个信号时,内核会自动把这个信号加入sa_mask(哪怕你没加),避免重入。但设了SA_NODEFER后,就不屏蔽当前信号了——处理期间再收到这个信号,会再次触发处理函数。

非常危险! 比如处理SIGINT时设了这个开关,按两次Ctrl+C就会导致处理函数重入,如果函数里修改全局变量,很可能会出问题。除非有特殊需求(比如实时系统需要快速响应),否则别用。

(5)SA_RESETHAND:处理完信号后恢复默认动作

设了这个开关,第一次收到信号时执行自定义函数,处理完后,信号的处理方式会自动恢复成SIG_DFL——第二次收到信号就会执行默认动作。

比如:

act.sa_handler = my_handler;
act.sa_flags = SA_RESETHAND; // 设这个开关

第一次按Ctrl+C,执行my_handler;第二次按Ctrl+C,就会执行默认动作(终止进程)。适合“只需要处理一次信号”的场景。

4. 已废弃的sa_restorer:别碰!

sa_restorer是早期UNIX系统用来恢复栈的字段,现在glibc会自动处理,用户根本不用管。如果你给它赋值,编译器可能会报警告,甚至运行出错。所以写代码时,直接忽略这个字段——初始化结构体时用memset清空就行,不用特意赋值。

四、sigaction的“反馈机制”:返回值与错误码

管家干活完了,会给你一个“反馈”——返回值。成功还是失败,失败的话是啥原因,都能从返回值和errno里看出来。

1. 返回值含义:0成功,-1失败

  • 返回0:说明sigaction成功执行了——要么成功设置了新规则,要么成功把旧规则存到了oldact里。
  • 返回-1:说明失败了,这时候必须看errno才能知道原因(需要包含<errno.h>头文件)。

2. 常见错误码:为啥会失败?

sigaction失败的原因不多,但都很常见,必须记住:

(1)EINVAL:信号编号无效或标志位错误

这是最常见的错误,原因有两种:

  • signum无效:比如传了0(信号编号从1开始)、传了大于SIGRTMAX(通常是64)的数,或者传了SIGKILL(9号)、SIGSTOP(19号)——这两个信号是“皇帝的圣旨”,不能修改处理方式,只能执行默认动作;
  • sa_flags里有无效的标志位:比如自己定义了一个不存在的标志(比如0x1234)。

举个错误例子:

struct sigaction act;
act.sa_handler = SIG_IGN;
// 错误:试图修改SIGKILL的处理方式
if (sigaction(SIGKILL, &act, NULL) == -1) {
    perror("sigaction failed"); // 会打印:sigaction failed: Invalid argument
    exit(EXIT_FAILURE);
}
(2)EFAULT:act或oldact指针无效

比如act是野指针(没初始化的指针),或者指向只读内存(比如字符串常量),内核没法读写actoldact的内容,就会报这个错。

错误例子:

struct sigaction *act = NULL; // 野指针
// 错误:act是NULL,内核没法写
if (sigaction(SIGINT, act, NULL) == -1) {
    perror("sigaction failed"); // 打印:sigaction failed: Bad address
}
(3)EACCESS:没有权限修改处理方式

除了SIGKILLSIGSTOP,还有一种情况会报这个错:如果进程在“强制模式”下运行(比如用prctl设置了PR_SET_NO_NEW_PRIVS),可能会没有权限修改某些信号的处理方式。不过这种情况很少见,大部分时候还是因为改了SIGKILL/SIGSTOP

3. 如何正确处理返回值?

必须检查返回值! 很多新手写代码不检查,出了错都不知道为啥。正确的做法是:

struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
act.sa_handler = my_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_RESTART;

// 检查返回值
if (sigaction(SIGINT, &act, &oldact) == -1) {
    // 用perror打印错误信息(自动拼接errno对应的描述)
    perror("Failed to set SIGINT handler");
    exit(EXIT_FAILURE); // 退出程序,或做其他错误处理
}

perror会把错误码转换成人类能看懂的字符串,比如Invalid argument(EINVAL)、Bad address(EFAULT),方便排查问题。

五、sigaction的“操作指南”:参数详解

前面咱们看了函数原型,现在再详细拆解三个参数——知道每个参数能传啥、不能传啥,才能避免踩坑。

1. 第一个参数:signum——信号的“身份证号”

signum是要处理的信号编号,相当于快递的“单号”——必须准确,不然管家不知道要处理哪个快递。

(1)信号编号的范围

Linux的信号分两类:

  • 普通信号:1~31号,比如SIGINT(2)、SIGTERM(15)、SIGCHLD(17);
  • 实时信号:3264号(`SIGRTMIN`SIGRTMAX),比如SIGRTMIN(32)、SIGRTMIN+1(33)。

注意:不同系统的SIGRTMIN可能不一样(比如Solaris是33),所以别硬编码32,用SIGRTMIN宏更通用。

(2)不能碰的信号:SIGKILL(9)和SIGSTOP(19)

这两个信号是“内核的底线”——无论进程做什么,收到SIGKILL就必须终止,收到SIGSTOP就必须暂停,不能修改处理方式(不能忽略,不能自定义函数)。哪怕你调用sigaction修改,也会返回-1,errno=EINVAL。

记住:“kill -9 进程号”是强制终止进程的终极手段,就是因为SIGKILL不能被拦截。

(3)常见信号编号与含义

为了方便你用,我整理了开发中最常用的几个信号:

信号名 编号 含义与默认动作 常用场景
SIGINT 2 终端中断(Ctrl+C)→
Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐