Linux 进程信号

文章目录

Linux 进程信号

信号入门

  1. 生活角度的信号
    • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能 "识别快递"
    • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成 "在合适的时候去取"。
    • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你 "记住了有一个快递要去取"
    • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
    • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
  2. 技术应用角度的信号
    1. 用户输入命令,在Shell下启动一个前台进程。
      • 用户按 ctrl-c,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
      • 前台进程因为收到信号,进而引起进程退出
c 复制代码
// 一个最简单的信号演示:进程持续运行,等待终端 Ctrl-C 产生 SIGINT
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    while (1) {
        printf("I am a process, I am waiting signal!\n");
        sleep(1);
    }
    return 0;
}

注意:

  1. ctrl-c 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 ctrl-c 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 ctrl-c 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

信号基本概念

信号是 Linux 中进程间异步事件通知机制,可视为"软中断";到达时间不可预知,相对正常控制流程是异步的。信号产生后可忽略、执行默认动作或注册处理函数捕捉,由 OS 管理并在适当时机递达。关键是区分产生、未决、阻塞、递达和处理动作。

用kill -l命令可以察看系统定义的信号列表

  • 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义#define SIGINT 2
  • 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

信号的产生方式

通过终端按键产生信号

终端按键是常见信号源。Ctrl-C 产生 SIGINT(默认终止进程),Ctrl-\ 产生 SIGQUIT(终止并可能产生 CoreDump)。因终端键绑定前台进程组,此类信号只能发给前台进程。命令加 & 可后台运行,Shell 不等待,但后台进程通常收不到 Ctrl-C,这就解释了为何后台程序按 Ctrl-C 无反应。

c 复制代码
// 前台运行后按 Ctrl-C,进程会被 SIGINT 终止
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    while (1) {
        printf("running...\n");
        sleep(1);
    }
    return 0;
}

CoreDump 与事后调试

CoreDump 是指进程异常终止时,把进程用户空间的内存数据保存到磁盘文件中的机制,文件名通常为 core 或 core.pid。它常用于事后调试,例如程序发生段错误后,可以通过 gdb 加载 core 文件定位出错位置。默认情况下系统可能不允许产生 core 文件,因为 core 文件可能包含密码等敏感信息。开发调试阶段可以用 ulimit -c 修改 core 文件大小限制。注意 ulimit 修改的是 Shell 进程的资源限制,子进程会继承该限制。

c 复制代码
// 制造段错误,配合 ulimit -c unlimited 可产生 core 文件
#include <stdio.h>

int main(void)
{
    int *p = NULL;
    *p = 100;   // 非法访问,通常触发 SIGSEGV
    return 0;
}

注意:如果运行程序未生成到当前目录上可以输入下面这个命令:

bash 复制代码
# 把 core 文件生成路径改为当前工作目录,文件名就叫 core
sudo sysctl -w kernel.core_pattern=core

除了上述演示以外,我们还可以利用终端按键 Ctrl-/ 来生成core文件。

注意:

  • 生产环境通常关闭 CoreDump,避免敏感信息泄漏和磁盘占用。
  • CoreDump 只保存用户空间内存,不是完整系统快照;调试时还需要可执行文件和符号信息。

调用系统函数发送信号

除了终端按键,进程还可以通过系统调用主动发送信号。

kill 函数可以向指定进程发送指定信号;

raise 函数可以向当前进程自己发送信号;

c 复制代码
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
这两个函数都是成功返回0,错误返回-1。

abort 函数会使当前进程异常终止。

c 复制代码
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。

kill 命令底层通常调用 kill 函数,因此命令行和编程接口在语义上是一致的。使用这些函数时要注意权限:普通进程通常只能向同用户或自己有权限的进程发送信号。

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    printf("pid = %d\n", getpid());

    // 给自己发送 SIGUSR1,若未捕捉则默认终止
    raise(SIGUSR1);

    // 如果 raise 被忽略,可继续执行;这里演示 abort 的异常终止
    abort();

    return 0;
}

注意:

  • kill 不是"杀死"进程的同义词,它只是发送信号,具体行为取决于信号类型和处理动作。
  • abort 总是使进程异常终止,不返回;不要误以为它可以像 exit 一样返回退出码给调用者。
  • 发送信号需要权限,向其他用户进程发送信号可能失败并返回 -1。

由软件条件产生信号

软件条件也可以产生信号,例如 alarm 函数可以设置一个定时器,在指定秒数后向当前进程发送 SIGALRM 信号。SIGALRM 默认动作是终止当前进程。alarm 的返回值是 0 或者之前设置的闹钟剩余秒数;如果传入 0,表示取消之前的闹钟。这个机制常用于实现简单的超时控制。需要注意 alarm 以秒为单位,精度有限,不适合高精度定时。

c 复制代码
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动
作是终止当前进程。
c 复制代码
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    int count = 14;
    alarm(1);   // 1 秒后发送 SIGALRM,默认终止进程

    for (;; count++) {
        printf("count = %d\n", count);
        // sleep(1); 可以先不带sleep运行一次,然后再带sleep来观察一下
    }
    return 0;
}

注意:

  • alarm 只保证"至少大约 seconds 秒后"发送信号,不是精确周期定时器。
  • 如果程序中已经设置了闹钟,再次调用 alarm 会覆盖旧闹钟,并返回旧闹钟剩余时间。

硬件异常产生信号

硬件异常也可能被内核解释为信号发送给进程。例如执行除以 0 的指令时,CPU 产生异常,内核可能将其解释为 SIGFPE 信号;访问非法内存地址时,MMU 产生异常,内核可能将其解释为 SIGSEGV 信号。这类信号通常表示程序存在严重错误。虽然可以通过信号处理函数捕捉它们,但捕捉后并不代表错误消失,程序往往仍处于不一致状态,因此不建议随意忽略硬件异常信号。

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int sig)
{
    printf("catch a sig: %d\n", sig);
}

int main(void)
{
    signal(SIGSEGV, handler);   // 捕捉段错误信号,仅用于演示

    int *p = NULL;
    *p = 100;                   // 触发 SIGSEGV

    while (1) {
        sleep(1);
    }
    return 0;
}

注意:

  • 捕捉 SIGSEGV 后直接返回,可能再次执行非法指令,导致反复触发信号。
  • 硬件异常信号表示程序错误,正确处理方式通常是修复代码,而不是长期忽略。

信号的阻塞、未决与递达

递达、未决、阻塞的基本概念

信号处理经历:产生 → 未决 (产生到递达之间)→ 递达 (执行处理动作)。进程可阻塞 信号,使其保持未决直至解除;忽略则是信号已递达但不处理,二者不同。内核为每个信号维护阻塞标志、未决标志和处理函数指针。常规信号递达前多次产生只计一次,实时信号可排队(本章主要讨论常规信号)。

阻塞一个信号不等于忽略它,解除阻塞后它仍可能递达。

未决标志通常只表示"有没有",不表示"产生了多少次",常规信号会丢失重复产生信息。

在内核中的表示

每个信号在内核中有三要素:阻塞(block)标志 、未决(pending)标志 和处理动作函数指针。信号产生时置位未决标志,递达时清除。以下通过三个例子说明状态:

  • SIGHUP:未阻塞也未产生过,递达时执行默认动作。
  • SIGINT :产生过但被阻塞,暂不能递达。注意:即使其处理动作是"忽略",在解除阻塞前也不能忽略,因为进程仍有机会修改处理动作后再解除阻塞。
  • SIGQUIT :未产生过,一旦产生将被阻塞,其处理动作是用户自定义函数 sighandler 。在解除阻塞前若产生多次:POSIX 允许系统递送一次或多次;Linux 的规则是常规信号只计一次,实时信号排队(本章不讨论实时信号)。

sigset_t

从上图来看,每个信号只有 1 bit 的未决标志(非 0 即 1),不记录产生次数;阻塞标志也是如此。因此,未决和阻塞标志可以用相同的数据类型 sigset_t(信号集)来存储,该类型用来表示每个信号的"有效"或"无效"状态。

需要注意的是,"有效/无效"在不同信号集中含义不同:

  • 阻塞信号集 :表示该信号是否被阻塞。
  • 未决信号集 :表示该信号是否处于未决状态。

下一节将详细介绍信号集的操作。阻塞信号集也叫做当前进程的信号屏蔽字 (Signal Mask),注意这里的"屏蔽"应理解为"阻塞"而不是"忽略"。

信号集操作函数

sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统 实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做 任何解释,比如用printf直接打印sigset_t变量是没有意义的

c 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • 函数 sigemptyset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 清零,表示该信号集不包含 任何有效信号。
  • 函数 sigfillset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 置位,表示 该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用 sigset_t 类型的变量之前,一定要调 用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdel set 在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask修改信号屏蔽字

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

c 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

sigprocmask 用于读取或更改进程的信号屏蔽字(阻塞信号集)。参数 how 决定修改方式:SIG_BLOCK(加入阻塞)、SIG_UNBLOCK(移出阻塞)、SIG_SETMASK(直接设置);若 oldset 非空则传出原屏蔽字,若 set 非空则按 how 修改,成功返回 0,失败返回 -1。需注意,解除对未决信号的阻塞后,在 sigprocmask 返回前至少会递达一个信号。该函数常用于保护临界区,避免关键代码段被信号打断。

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int main(void)
{
    sigset_t set, oldset;

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

    // 阻塞 SIGINT
    sigprocmask(SIG_BLOCK, &set, &oldset);

    printf("SIGINT blocked, sleep 5 seconds\n");
    sleep(5);   // 此期间按 Ctrl-C,SIGINT 处于未决状态

    // 解除阻塞,未决的 SIGINT 可能立即递达
    sigprocmask(SIG_SETMASK, &oldset, NULL);

    printf("SIGINT unblocked\n");
    return 0;
}

第二种使用Ctrl-c,此时终端向进程发送 SIGINT 信号。因为 SIGINT 被屏蔽了,它无法递达(不能杀死进程),只能变成 "未决状态"(Pending ),挂起在操作系统的信号队列里,而 sleep 函数依然在安静地睡。5 秒后,代码执行 sigprocmask(SIG_SETMASK, &oldset, NULL)。

系统发现要解除 SIGINT 的阻塞,并且检查到有一个未决的 SIGINT。系统立刻把 SIGINT 递达给进程。SIGINT 的默认动作是终止进程。所以,程序在 sigprocmask 这一行内部就被杀死了,根本没有机会执行到下一行的 printf("SIGINT unblocked\n");。

sigpending读取未决信号集

c 复制代码
#include <signal.h>
int sigpending(sigset_t *set);

读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 下面用刚学的几个函数做个实验。程序如下:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void print_pending(sigset_t *set)
{
    for (int i = 1; i < 32; i++) {
        if (sigismember(set, i) == 1) {
            putchar('1');
        } else {
            putchar('0');
        }
    }
    putchar('\n');
}

int main(void)
{
    sigset_t set, pending;

    sigemptyset(&set);
    sigaddset(&set, SIGINT);
    sigprocmask(SIG_BLOCK, &set, NULL);   // 阻塞 SIGINT

    while (1) {
        sigpending(&pending);
        print_pending(&pending);
        sleep(1);
    }
    return 0;
}

注意:

  • 未决信号集不是"所有产生过的信号历史记录",信号递达后标志会清除。
  • 常规信号重复产生可能只保留一个未决标志,不能依赖未决集统计次数。

信号捕捉与内核处理流程

内核如何实现信号捕捉

如果信号处理动作是用户自定义函数,信号递达时内核会安排执行该函数,即"捕捉信号"。典型过程:程序执行 main 时发生中断/异常进内核,处理完准备返回用户态前,若检查到有信号递达且设有自定义处理函数,内核不会恢复 main 的上下文,而是执行该处理函数;函数返回后执行 sigreturn 再次进内核,若无新信号再恢复 main 继续执行。

注意:

  • 信号处理函数并非 main 主动调用,而是内核在递达时"插入"执行的;其返回需经过 sigreturn 才能恢复原流程,这比普通函数调用复杂。
  • 信号处理函数与 main 使用不同的堆栈空间,并非普通调用关系,而是两个独立的控制流。

sigaction 函数

c 复制代码
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

sigaction 用于读取和修改指定信号的处理动作,比 signal 更可控。signum 是信号编号;act 非空时根据它修改处理动作;oldact 非空时传出原来的处理动作。成功返回 0,失败返回 -1。

struct sigaction 中包含 sa_handler (处理函数)、sa_mask (处理期间额外屏蔽的信号集)和 sa_flags (选项)。sa_handler 可以设为 SIG_IGN 表示忽略,设为 SIG_DFL 表示默认动作,设为函数指针表示自定义捕捉。当某个信号的处理函数被调用时,内核会自动把当前信号加入信号屏蔽字,处理函数返回后恢复,从而避免同一信号在处理期间再次递达。sa_mask 可以额外指定在处理期间要屏蔽的信号。

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

void handler(int sig)
{
    printf("sigaction catch: %d\n", sig);
}

int main(void)
{
    struct sigaction act, oldact;

    memset(&act, 0, sizeof(act));
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, SIGQUIT);   // 处理 SIGINT 时额外屏蔽 SIGQUIT
    act.sa_flags = 0;

    sigaction(SIGINT, &act, &oldact);

    while (1) {
        sleep(1);
    }
    return 0;
}

注意:

  • 不要忘记初始化 struct sigaction,导致 sa_flags 或 sa_mask 含随机值。
  • sa_mask 并不是永久屏蔽;它只在处理函数执行期间生效,返回后恢复原屏蔽字。

可重入函数与 volatile

可重入函数的概念

可重入函数是指一个函数在被某个控制流程调用且尚未返回时,可以再次被另一个控制流程安全进入,而不会造成数据错乱。信号处理函数可能在 main 函数执行到任意位置时插入执行,如果信号处理函数调用了正在被 main 使用全局数据的函数,就可能造成重入问题。典型例子是链表插入:main 调用 insert 插入 node1,刚完成一部分时被信号打断,信号处理函数又调用 insert 插入 node2,两个流程交错修改同一个链表,最终可能只插入一个节点或破坏链表。只访问自己的局部变量或参数的函数通常是可重入的。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
c 复制代码
// 不可重入示例:insert 访问全局链表 head
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

struct node {
    int data;
    struct node *next;
};

struct node *head = NULL;

void insert(struct node *p)
{
    p->next = head;
    // 如果这里被信号打断,信号处理函数也调用 insert,就可能破坏链表
    head = p;
}

void handler(int sig)
{
    static struct node node2 = {2, NULL};
    insert(&node2);
}

int main(void)
{
    static struct node node1 = {1, NULL};

    signal(SIGINT, handler);
    insert(&node1);

    while (1) {
        sleep(1);
    }
    return 0;
}

volatile 关键字与编译器优化

  • 该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下

在信号处理场景中,主循环可能通过一个全局标志判断是否退出,信号处理函数负责修改该标志。编译器优化可能把标志变量缓存到 CPU 寄存器中,导致主循环一直读取寄存器旧值,看不到信号处理函数对内存的修改。volatile 告诉编译器该变量可能被程序之外的因素改变,不允许把它优化到寄存器中,每次访问都必须从真实内存读取。它解决的是"内存可见性"和编译器优化问题,不是线程同步问题。

没有加volatile的情况:

c 复制代码
#include <stdio.h>
#include <signal.h>

int flag = 0;

void handler(int sig)
{
    flag = 1;
}

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

    while (!flag) {
        // 编译器可能优化为只读取一次 flag
    }

    printf("process quit normal\n");
    return 0;
}

生成exe文件命令:

bash 复制代码
gcc -O2 test.c -o test

此时运行test时并终端按Ctrl-C,程序没有任何反应,彻底卡死。

这是因为在 -O2 优化下,编译器发现 while(!flag) 循环里没有人修改 flag,于是它只读取了一次内存(此时 flag=0),把它放在 CPU 寄存器里,之后的死循环就在比对寄存器。你按 Ctrl-C,handler 确实把内存里的 flag 改成了 1,但主循环只认寄存器里的旧值,根本看不见内存的变化。

退出方法也很简单:Ctrl-\ 或者 kill -9 [pid]

加了volatile的情况:

c 复制代码
#include <stdio.h>
#include <signal.h>

volatile int flag = 0;

void handler(int sig)
{
    flag = 1;
}

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

    while (!flag) {
        // 每次循环都从内存读取 flag
    }

    printf("process quit normal\n");
    return 0;
}

操作和上述一致。

此时你按 Ctrl-C,终端会立刻打印出 process quit normal,程序正常退出。

因为volatile 告诉了编译器:"这个变量随时可能在你看不见的地方(比如信号处理函数、硬件中断)被修改,所以每次使用它,都必须去它真实的内存地址取值,绝不允许放进寄存器缓存"。

SIGCHLD 信号与子进程回收

使用 SIGCHLD 避免轮询等待子进程

子进程终止时,父进程会收到 SIGCHLD 信号,该信号默认动作是忽略。父进程可以自定义 SIGCHLD 处理函数,在处理函数中调用 wait 或 waitpid 回收子进程,从而避免父进程阻塞等待或忙轮询。这样父进程可以继续处理自己的工作,子进程退出时再通过信号通知父进程清理。需要注意,多个子进程可能几乎同时退出,SIGCHLD 信号可能合并,因此处理函数中通常要用循环加 WNOHANG 回收所有已退出子进程。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>

void handler(int sig)
{
    pid_t id;
    (void)sig;

    // 循环回收所有已退出的子进程,避免多个子进程退出时信号合并导致漏回收
    while ((id = waitpid(-1, NULL, WNOHANG)) > 0) {
        printf("wait child success: %d\n", id);
    }
}

int main(void)
{
    signal(SIGCHLD, handler);

    pid_t cid = fork();
    if (cid == 0) {
        printf("child: %d\n", getpid());
        sleep(3);
        exit(1);
    }

    while (1) {
        printf("father proc is doing something\n");
        sleep(1);
    }
    return 0;
}

注意:

  • SIGCHLD 可能因为多个子进程同时退出而合并,处理函数中必须循环 waitpid。
  • 若将 SIGCHLD 处理动作设为 SIG_IGN,在 Linux 上子进程退出后可能自动清理,不产生僵尸进程;但这是 Linux 特性,不保证所有 Unix 系统一致。

完

相关推荐
^乘风破浪^1 小时前
Kali配置i3wm教程1
linux·kali·i3wm
qeen871 小时前
【Linux】动静态库的链接与加载过程
linux·运维·系统编程
好啊好啊郝!2 小时前
计算机网络最基础知识点
服务器·网络·计算机网络
重生之小比特2 小时前
【Linux系统】17 库制作与原理(上)
linux·运维·服务器
wzg20162 小时前
ubuntu 离线安装 docker-compose
linux·ubuntu·docker
Shadow(⊙o⊙)2 小时前
仿muduo实现高并发服务器——EchoServer完成
运维·服务器
行走的陀螺仪2 小时前
Claude Code 从入门到实战(三):Hooks、Skills、SubAgents 高级扩展
linux·编辑器·vim
Brilliantwxx3 小时前
【STM32】 内部 Flash 的 HAL 库函数详解与自定义封装实战
linux·服务器·网络
时间售卖铺3 小时前
Docker镜像构建全解析:从原理到实战优化
运维·docker·云原生·容器