欢迎光临
我们一直在努力

深度解析:从原理到实战,一文吃透 Linux 信号机制(下)

🔥个人主页:Cx330🌸

❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》

《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔

《Git深度解析》:版本管理实战全解

🌟心向往之行必能至


🎥Cx330🌸的简介:


目录

前言:

一、前置基础:先搞懂3个核心概念​

二、信号的保存:内核如何“暂存”未决信号?

三、信号集操作函数(实操必备)

3.1 核心信号集操作函数(6 个常用)

3.2 sigprocmask 函数详解(修改阻塞信号集)

3.3 sigpending 函数(读取未决信号集)

四、实操案例:验证信号的阻塞与未决状态

4.1 代码实现

五、信号的捕捉:自定义信号的处理逻辑

5.1 信号捕捉的底层流程

5.2 话题穿插:操作系统 是怎么运行的(硬件中断,时钟中断,软中断,缺页中断)

5.2.1 硬件中断

5.2.2 时钟中断

5.2.3 软中断

5.2.4 缺页中断

5.3 再次理解内核态和用户态

5.4 sigaction 函数(推荐,功能更强)

核心现象

六. 可重入函数 && volatile && SIGCHLD信号

6.1 可重入函数

6.2 volatile

6.3 SIGCHLD信号

结语:


前言:

在Linux系统中,信号是进程间异步通信的核心机制,本质是软件层面的“中断”,能够打断进程的正常执行流,触发预设处理逻辑。我们日常使用的Ctrl+C终止进程、kill命令发送信号,背后都是信号机制在工作。而信号的保存、阻塞与捕捉,是掌握信号机制的核心,也是面试和开发中的高频考点。本文将从底层逻辑出发,结合实操代码,一步步拆解这三个核心概念,全程贴合实战,新手也能轻松看懂。


一、前置基础:先搞懂3个核心概念​

在学习信号的保存、阻塞与捕捉前,必须先明确三个基础概念,避免后续理解混淆,这也是很多人入门时的误区:​

  • 信号递达(Delivery):信号实际被进程处理的动作(比如执行默认终止、自定义函数等);​
  • 信号未决(Pending):信号已产生,但由于被阻塞等原因,暂时无法递达的状态,会被临时保存起来;​
  • 信号阻塞(Block):进程主动“屏蔽”某个信号,被阻塞的信号即使产生,也不会立即递达,而是处于未决状态,直到阻塞解除。​

💡关键提醒:阻塞 ≠ 忽略!阻塞是“暂时不处理”,信号会被保存(未决);忽略是“处理了但不做任何动作”,信号不会被保存,也不会触发任何行为。

二、信号的保存:内核如何“暂存”未决信号?

信号的保存,本质就是将“已产生但未递达”的信号,存储到进程的未决信号集中,等待阻塞解除后递达处理。其底层逻辑与进程控制块(task_struct)密切相关——内核在task_struct中通过三个关键指针维护信号相关状态,决定信号的保存与处理流程:

  • block指针:对应阻塞信号集,标记哪些信号被阻塞(0=非阻塞,1=阻塞);

  • pending指针:对应未决信号集,标记哪些信号已产生但未递达(信号产生时,对应位设为1;信号递达后,对应位设为0);

  • handler指针:记录信号的处理方式(默认、忽略、自定义捕捉)。

  • // 内核结构 2.6.18
    // 每个进程的 task_struct 包含与信号处理相关的字段
    struct task_struct {
    // … 其他字段 …

    /* signal handlers */
    struct sighand_struct *sighand; // 指向信号处理函数描述符的指针
    sigset_t blocked; // 当前被阻塞的信号集
    struct sigpending pending; // 挂起信号队列(等待处理的信号)
    // …
    };

    // 信号处理函数描述符,通常由多个进程共享(如克隆出的线程)
    struct sighand_struct {
    atomic_t count; // 引用计数,表示有多少个 task_struct 共享此结构
    struct k_sigaction action[_NSIG]; // 信号动作数组,_NSIG 通常为 64,索引为信号编号
    spinlock_t siglock; // 保护该结构及关联进程信号状态的自旋锁
    };

    // 新风格的信号动作定义(用户空间可见的 sigaction 结构的内核表示)
    struct __new_sigaction {
    __sighandler_t sa_handler; // 信号处理函数指针(SIG_IGN, SIG_DFL 或用户函数)
    unsigned long sa_flags; // 信号处理标志(如 SA_RESTART, SA_SIGINFO 等)
    void (*sa_restorer)(void); // 恢复函数(某些架构上用于从信号处理返回,Linux/SPARC 未使用)
    __new_sigset_t sa_mask; // 在信号处理函数执行期间需要额外阻塞的信号集
    };

    // 内核使用的信号动作表示,包含用户空间的 sa 和可能的重设恢复函数
    struct k_sigaction {
    struct __new_sigaction sa; // 实际的信号动作
    void __user *ka_restorer; // 可选的恢复函数指针(用户空间地址)
    };

    /* Type of a signal handler. */
    typedef void (*__sighandler_t)(int); // 标准信号处理函数类型,接受一个整型信号编号

    // 挂起的信号队列
    struct sigpending {
    struct list_head list; // 挂起信号的链表头,链接 sigqueue 结构
    sigset_t signal; // 当前挂起信号集(所有待处理信号的位掩码)
    };

    信号保存的核心逻辑

    • 信号产生时,内核先检查进程的阻塞信号集:
      • 若该信号未被阻塞(block 对应 bit=0),则设置未决信号集对应 bit=1,等待递达;
      • 若该信号已被阻塞(block 对应 bit=1),则仅设置未决信号集对应 bit=1,不递达;
    • 进程从内核态切换到用户态前,会检查未决信号集:
      • 若有未被阻塞的信号(pendingbit=1 且 blockbit=0),则执行递达(调用处理动作);
      • 递达后,清除未决信号集对应 bit=0。

    三、信号集操作函数(实操必备)

    内核提供了一套sigset_t相关函数,用于操作阻塞信号集和未决信号集,这是实战的核心。

    3.1 核心信号集操作函数(6 个常用)

    函数原型

    核心功能

    关键说明

    int sigemptyset(sigset_t *set)

    清空信号集,所有信号位设为0

    初始化信号集的必用函数,避免随机值干扰

    int sigfillset(sigset_t *set)

    将所有信号加入信号集,所有位设为1

    很少直接使用,多用于批量阻塞所有信号

    int sigaddset(sigset_t *set, int signum)

    将指定信号(signum)加入信号集

    用于添加要阻塞或关注的信号

    int sigdelset(sigset_t *set, int signum)

    将指定信号从信号集中删除

    用于解除某个信号的阻塞

    int sigismember(const sigset_t *set, int signum)

    判断信号是否在信号集中(返回1=存在,0=不存在)

    用于查询信号是否被阻塞或处于未决状态

    int sigpending(sigset_t *set)

    获取当前进程的未决信号集

    核心用于查看“被保存的未决信号”,成功返回0

    3.2 sigprocmask 函数详解(修改阻塞信号集)

    sigprocmask是控制阻塞的核心,通过how参数指定修改方式:

    #include <signal.h>
    int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

    • how:修改方式(3 种):
      • SIG_BLOCK:添加阻塞(block = block | set);
      • SIG_UNBLOCK:解除阻塞(block = block & ~set);
      • SIG_SETMASK:直接设置阻塞集(block = set);
    • set:新的信号集(NULL 表示仅读取);
    • oset:保存原来的阻塞集(NULL 表示不保存)。

    3.3 sigpending 函数(读取未决信号集)

    用于读取当前进程的未决信号集,验证信号是否处于未决状态:

    #include <signal.h>
    int sigpending(sigset_t *set); // 成功返回0,失败返回-1


    四、实操案例:验证信号的阻塞与未决状态

    4.1 代码实现

    #include <bits/types/sigset_t.h>
    #include <csignal>
    #include <iostream>
    #include <cstdio>
    #include <ostream>
    #include <signal.h>
    #include <unistd.h>

    // 打印未决信号集(直观查看哪些信号处于未决状态)
    void Printf(sigset_t& pending)
    {
    for(int signum = 31; signum >= 1; signum–)
    {
    if(sigismember(&pending, signum))
    {
    printf("1");
    }
    else {
    printf("0");
    }
    }
    printf("\\n");
    }

    void handler(int sig)
    {
    std::cout << "处理了" << sig << "号信号" << std::endl;

    // 测试: pending信号被递达的时候,是跑handler之前还是之后1 -> 0
    std::cout << "***************" <<std::endl;
    sigset_t pending;
    sigemptyset(&pending); // 不会对后续有影响,只是在栈上
    int m = sigpending(&pending);
    (void)m;
    Printf(pending);
    std::cout << "***************" <<std::endl;
    }
    int main()
    {
    std::cout << "pid: " << getpid() << std::endl;
    // 0. 自定义捕获2号信号
    signal(SIGINT, handler);
    // 1. 定义&&初始化信号集(定义变量而已,没多高级,在用户栈上)
    sigset_t block, old_block;
    sigemptyset(&block); // 全设为0先
    sigemptyset(&old_block);

    for(int signum = 1; signum <= 31; signum++)
    {
    sigaddset(&block, signum);
    }
    // 2. 向block信号集添加2号信号
    // sigaddset(&block, SIGINT);

    // 3. 屏蔽2号信号
    int n = sigprocmask(SIG_SETMASK, &block, &old_block);
    (void)n;

    // 4. 不断获取pending信号集 && 打印出来观察
    sigset_t pending;
    int cnt = 0;
    while(true)
    {
    sigemptyset(&pending);

    // 获取pending信号集
    int m = sigpending(&pending);
    (void)m;

    // 打印pending信号集进行观察
    Printf(pending);

    sleep(1);
    // 尝试恢复
    cnt++;
    if(cnt == 20)
    {
    std::cout << "恢复2号信号, 解除屏蔽" << std::endl;
    sigprocmask(SIG_SETMASK, &old_block,nullptr); // 恢复
    }
    }
    }


    五、信号的捕捉:自定义信号的处理逻辑

    信号捕捉是指进程自定义信号的处理动作(而非默认动作或忽略),核心依赖signalsigaction函数,其中sigaction是更推荐的标准接口。

    5.1 信号捕捉的底层流程

    当进程注册了自定义处理函数后,信号递达的流程如下(用户态↔内核态切换 ):

    • 进程在用户态执行主流程,因中断 / 异常 / 系统调用进入内核态;
    • 内核处理完中断后,准备返回用户态前,检查未决信号集;
    • 若有可递达的信号(未阻塞且有自定义处理函数),内核不直接返回主流程,而是切换到用户态执行自定义处理函数;
    • 处理函数执行完毕后,自动调用sigreturn系统调用再次进入内核态;
    • 内核确认无其他未递达信号后,返回用户态,恢复主流程的执行。

    5.2 话题穿插:操作系统 是怎么运行的(硬件中断,时钟中断,软中断,缺页中断)

    5.2.1 硬件中断
    • 中断向量表就是操作系统的⼀部分,启动就加载到内存中了
    • 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
    • 由外部设备触发的,中断系统运行流程,叫做硬件中断

    // Linux 内核 0.11 源码片段,带详细注释

    void trap_init(void)
    {
    int i;

    // 设置陷阱门(trap gate),用于处理 CPU 异常
    // 除零错误
    set_trap_gate(0, &divide_error);
    // 单步调试
    set_trap_gate(1, &debug);
    // 非屏蔽中断 NMI
    set_trap_gate(2, &nmi);
    // 以下 3-5 号陷阱门允许从任何特权级调用(系统门)
    set_system_gate(3, &int3); // int3 断点
    set_system_gate(4, &overflow); // 溢出
    set_system_gate(5, &bounds); // 边界检查

    // 继续设置陷阱门
    set_trap_gate(6, &invalid_op); // 无效操作码
    set_trap_gate(7, &device_not_available); // 设备不可用
    set_trap_gate(8, &double_fault); // 双重故障
    set_trap_gate(9, &coprocessor_segment_overrun); // 协处理器段越界
    set_trap_gate(10, &invalid_TSS); // 无效 TSS
    set_trap_gate(11, &segment_not_present); // 段不存在
    set_trap_gate(12, &stack_segment); // 栈段错误
    set_trap_gate(13, &general_protection); // 一般保护异常
    set_trap_gate(14, &page_fault); // 页错误
    set_trap_gate(15, &reserved); // 保留
    set_trap_gate(16, &coprocessor_error); // 协处理器错误

    // 将 int 17~47 的陷阱门暂时都设置为 reserved(保留处理)
    // 后续各个硬件初始化时会重新设置自己的中断向量
    for (i = 17; i < 48; i++)
    set_trap_gate(i, &reserved);

    // 设置协处理器中断(IRQ13,中断号 45)
    set_trap_gate(45, &irq13);

    // 允许主 8259A 芯片的 IRQ2 中断请求(用于级联从片)
    outb_p(inb_p(0x21) & 0xfb, 0x21);
    // 允许从 8259A 芯片的 IRQ13 中断请求(协处理器)
    outb(inb_p(0xA1) & 0xdf, 0xA1);

    // 设置并行口的陷阱门(IRQ7 或 中断号 39,具体取决于硬件)
    set_trap_gate(39, &parallel_interrupt);
    }

    void rs_init(void)
    {
    // 设置串行口1的中断门,硬件 IRQ4 对应中断号 0x24
    set_intr_gate(0x24, rs1_interrupt);
    // 设置串行口2的中断门,硬件 IRQ3 对应中断号 0x23
    set_intr_gate(0x23, rs2_interrupt);

    // 初始化 tty 表对应串行口1和串行口2的读队列端口号
    init(tty_table[1].read_q.data); // 串行口1
    init(tty_table[2].read_q.data); // 串行口2

    // 允许主 8259A 芯片的 IRQ3 和 IRQ4 中断请求(分别对应串口2和串口1)
    outb(inb_p(0x21) & 0xE7, 0x21);
    }

    5.2.2 时钟中断

    问题:

    进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?

    外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?

    • 回答:操作系统不是一个独立的“进程”,它本质上是硬件的“管家”。当用户程序运行时,CPU 在执行用户代码;只有发生硬件中断(如时钟中断、键盘中断)、异常(如缺页)或系统调用(程序主动请求内核服务)时,CPU 才会切换到内核态,执行操作系统代码。也就是说,操作系统是靠硬件事件和程序主动调用来“唤醒”的。没有事件时,CPU 会执行一个特殊的“空闲进程”(idle),什么都不做,等待下一次中断。
    • 回答:有,最常见的是定时器(Timer)。例如 PC 上的可编程间隔定时器(PIT)可以设置一个时间间隔,每隔固定时间(比如 10 毫秒)自动向 CPU 发送一个时钟中断。操作系统利用这个中断来切换进程、统计时间等。这类设备不需要人为触发,只要通电并按设定运行,就会一直自己“滴答”地产生中断。

    // Linux 内核 0.11
    // main.c
    sched_init(); // 调度程序初始化(加载了任务0 的 tr, ldtr) (kernel/sched.c)

    // 调度程序的初始化子程序。
    void sched_init(void)
    {
    // … 其他初始化代码 …

    // 设置时钟中断门,中断号 0x20(对应硬件 IRQ0)
    set_intr_gate(0x20, &timer_interrupt);

    // 修改中断控制器屏蔽码,允许时钟中断(清除 IRQ0 的屏蔽位)
    outb(inb_p(0x21) & ~0x01, 0x21);

    // 设置系统调用中断门,中断号 0x80(int 0x80 用于系统调用)
    set_system_gate(0x80, &system_call);

    // …
    }

    // system_call.s 中的汇编代码片段
    _timer_interrupt:
    // … 保存现场等操作 …
    ; // do_timer(CPL) 执行任务切换、计时等工作,在 kernel/sched.c 第305行实现
    call _do_timer // do_timer(long CPL) 负责所有定时器相关处理,最终可能调用 schedule()
    // … 恢复现场、iret 等 …

    // 调度入口函数(kernel/sched.c)
    void do_timer(long cpl)
    {
    // … 更新进程时间片、处理定时器等 …
    schedule(); // 调用主调度函数
    }

    // 进程调度函数
    void schedule(void)
    {
    // … 选择下一个要运行的任务(next) …
    switch_to(next); // 切换到任务号为 next 的任务,并运行之
    }

    void main(void) /* 这里确实是void,并没错。 */
    { /* 在startup 程序(head.s)中就是这样假设的。 */
    // … 前面的初始化代码 …

    /*
    * 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到一个信号才会返
    * 回就绪运行态,但任务0(task0)是唯一的意外情况(参见'schedule()'),因为任
    * 务0 在任何空闲时间里都会被激活(当没有其它任务在运行时),
    * 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运行,如果没
    * 有的话我们就回到这里,一直循环执行'pause()'。
    */
    for (;;)
    pause(); // 任务0进入空闲循环,当没有其他任务时被激活,不断检查是否有任务可运行
    } // end main

    • 这样,操作系统,就可以在硬件时钟的推动下,自动调度了.
    • 所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?主频可以作为OS调度执行速度的参考之一

    5.2.3 软中断
    • 上述外部硬件中断,需要硬件设备触发。
    • 有没有可能,因为软件原因,也触发上面的逻辑?有!
    • 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。

    问题:

    • 用户层怎么把系统调用号给操作系统? – 寄存器(比如EAX)
    • 操作系统怎么把返回值给用户?- 寄存器或者用户传入的缓冲区地址
    • 系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
    • 系统调用号的本质:数组下标!

    // sys.h 中定义的系统调用函数指针表,用于 int 0x80 中断处理程序跳转
    extern int sys_setup(); // 系统启动初始化设置
    extern int sys_exit(); // 程序退出
    // … 其他系统调用声明省略 …

    // 系统调用函数指针表(跳转表),按系统调用号索引
    fn_ptr sys_call_table[] = {
    sys_setup, sys_exit, sys_fork, sys_read,
    sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
    sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
    sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
    sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
    sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
    sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
    sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
    sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
    sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
    sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
    sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
    sys_setreuid, sys_setregid
    };

    // 调度程序初始化
    void sched_init(void)
    {
    // …
    // 设置系统调用中断门,中断号 0x80,入口为 system_call 汇编例程
    set_system_gate(0x80, &system_call);
    }

    // system_call.s 中的汇编代码(简化)
    _system_call:
    // 检查系统调用号(eax)是否超出范围
    cmp eax, nr_system_calls-1
    ja bad_sys_call

    // 保存用户态段寄存器及参数
    push ds
    push es
    push fs
    push edx // 第3个参数
    push ecx // 第2个参数
    push ebx // 第1个参数

    // 设置 ds, es 指向内核数据段
    mov edx, 10h
    mov ds, dx
    mov es, dx
    // 设置 fs 指向用户数据段(局部描述符表)
    mov edx, 17h
    mov fs, dx

    // 通过系统调用号索引跳转表,调用对应的 C 函数
    // 等价于 call sys_call_table[eax*4]
    call [_sys_call_table + eax*4]

    // 返回值已在 eax,将其压栈以备后续处理
    push eax

    // 取当前任务(进程)结构指针
    mov eax, _current

    // 检查当前进程状态:如果 state != 0 或 counter == 0,则重新调度
    cmp dword ptr [state+eax], 0
    jne reschedule
    cmp dword ptr [counter+eax], 0
    je reschedule

    ret_from_sys_call:
    // 从系统调用返回后,会检查信号并恢复现场,最终 iret 回到用户态
    // …

    系统调用流程简要说明:

  • 用户程序执行 int $0x80,传入系统调用号(eax)和参数(ebx, ecx, edx)。
  • CPU 通过中断门跳转到 system_call 汇编入口。
  • 保存现场,将段寄存器指向内核段,fs 指向用户段(便于访问用户空间)。以系统调用号为索引,从 sys_call_table 中取出对应函数地址并调用。
  • C 函数执行完毕,返回值存于 eax
  • 检查当前进程是否需要重新调度(状态或时间片),若需要则切换进程。
  • 最后恢复现场,iret 返回用户态继续执行。
  • 可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用上层的函数的啊?
  • 那是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了。
  • // 源代码路径:linux-2.6.18\\linux-2.6.18\\include\\asm-x86_64\\unistd.h
    // 该头文件定义了 x86_64 架构的系统调用号及对应的内核函数入口

    /* at least 8 syscall per cacheline */ // 注释:每个缓存行至少包含 8 个系统调用(优化缓存局部性)

    // 系统调用号从 0 开始递增,每个宏定义分配一个唯一的编号
    #define __NR_read 0 // 系统调用号 0:读取文件
    __SYSCALL(__NR_read, sys_read) // __SYSCALL 宏将系统调用号与内核实现函数关联(用于构建系统调用表)

    #define __NR_write 1 // 系统调用号 1:写入文件
    __SYSCALL(__NR_write, sys_write)

    #define __NR_open 2 // 系统调用号 2:打开文件
    __SYSCALL(__NR_open, sys_open)

    #define __NR_close 3 // 系统调用号 3:关闭文件
    __SYSCALL(__NR_close, sys_close)

    #define __NR_stat 4 // 系统调用号 4:获取文件状态(通过路径)
    __SYSCALL(__NR_stat, sys_newstat) // 使用新的 stat 结构(sys_newstat)

    #define __NR_fstat 5 // 系统调用号 5:获取文件状态(通过文件描述符)
    __SYSCALL(__NR_fstat, sys_newfstat)

    #define __NR_lstat 6 // 系统调用号 6:获取链接文件状态(不跟随符号链接)
    __SYSCALL(__NR_lstat, sys_newlstat)

    #define __NR_poll 7 // 系统调用号 7:poll 多路复用 I/O
    __SYSCALL(__NR_poll, sys_poll)

    #define __NR_lseek 8 // 系统调用号 8:重定位文件读写偏移
    __SYSCALL(__NR_lseek, sys_lseek)

    #define __NR_mmap 9 // 系统调用号 9:内存映射
    __SYSCALL(__NR_mmap, sys_mmap)

    #define __NR_mprotect 10 // 系统调用号 10:修改内存保护属性
    __SYSCALL(__NR_mprotect, sys_mprotect)

    #define __NR_munmap 11 // 系统调用号 11:解除内存映射
    __SYSCALL(__NR_munmap, sys_munmap)

    #define __NR_brk 12 // 系统调用号 12:调整数据段(堆)大小
    __SYSCALL(__NR_brk, sys_brk)

    #define __NR_rt_sigaction 13 // 系统调用号 13:实时信号处理函数设置
    __SYSCALL(__NR_rt_sigaction, sys_rt_sigaction)

    #define __NR_rt_sigprocmask 14 // 系统调用号 14:实时信号屏蔽字操作
    __SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask)

    #define __NR_rt_sigreturn 15 // 系统调用号 15:从实时信号处理函数返回
    __SYSCALL(__NR_rt_sigreturn, stub_rt_sigreturn) // 使用汇编桩(stub)实现

    // … 后续还有更多系统调用定义 …

    5.2.4 缺页中断
    • 缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。

    5.3 再次理解内核态和用户态

    不会不安全

    5.4 sigaction 函数(推荐,功能更强)

    signal函数简单但兼容性差,sigaction支持更精细的控制(如设置额外阻塞信号、保留信号上下文),原型如下:

    #include <signal.h>
    int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

    struct sigaction 结构体解析:

    struct sigaction {
    void (*sa_handler)(int); // 信号处理函数(与signal兼容)
    void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数
    sigset_t sa_mask; // 处理信号时,额外阻塞的信号集
    int sa_flags; // 选项(0表示默认)
    void (*sa_restorer)(void); // 已废弃,不用关注
    };

    关键特性:

    • sa_mask:处理信号时,内核会自动将当前信号加入阻塞集,同时阻塞sa_mask中的信号,避免嵌套处理;
    • sa_flags:常用SA_RESTART(被信号中断的系统调用自动重启)。

    实战 :用 sigaction 捕捉信号

    #include <bits/types/sigset_t.h>
    #include <csignal>
    #include <iostream>
    #include <unistd.h>
    #include <cstdio>
    #include <signal.h>

    void Printf(sigset_t& pending)
    {
    for(int signum = 31; signum >= 1; signum–)
    {
    if(sigismember(&pending, signum))
    {
    printf("1");
    }
    else {
    printf("0");
    }
    }
    printf("\\n");
    }

    void handler(int sig)
    {
    std::cout << "get a signal " << sig << std::endl;

    // 在处理2号信号期间,2号信号被自动blcok了!
    while(true)
    {
    sigset_t pending;
    sigpending(&pending);

    Printf(pending);
    sleep(1);
    }
    // exit(0);
    }

    int main()
    {
    struct sigaction act, old_act;
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigemptyset(&(act.sa_mask)); // ??? 进程收到了大量的重复信号?
    sigaddset(&(act.sa_mask), 1);
    sigaddset(&(act.sa_mask), 3);
    sigaddset(&(act.sa_mask), 4);
    sigaddset(&(act.sa_mask), 5);

    sigaction(2, &act, &old_act); // signal

    while(true)
    {
    pause(); //等待
    }
    }

    核心现象

    • 信号处理期间,该信号会被自动屏蔽(2 号信号)
    • sa_mask 中添加的信号也会被屏蔽(1、3、4、5 号信号)
    • 被屏蔽的信号会进入 pending 状态,对应位显示为 1

    这样就直观展示了信号处理期间的屏蔽机制。


    六. 可重入函数 && volatile && SIGCHLD信号

    6.1 可重入函数

    重入问题示例:

    • main 函数调用 insert 向链表插入节点,刚完成第一步时发生硬件中断。
    • 进程切回用户态前发现待处理信号,执行信号处理函数 
    • sighandler 也调用了 insert 插入另一个节点,并完整执行了两步。
    • 返回 main 后继续完成之前未完成的第二步插入操作。
    • 结果:两个不同控制流先后插入节点,但链表最终只成功插入了一个节点(数据被覆盖或丢失)。

    重入与可重入定义:

    • 重入:同一个函数在第一次调用尚未返回时,又被另一个控制流程再次调用。
    • 不可重入函数:函数内部访问了全局或静态数据结构(如上述 insert 操作的全局链表),可能因重入导致数据错乱。
    • 可重入函数:函数只使用自身的局部变量或参数,不依赖共享资源,可以被多个控制流安全地同时调用。

    为什么局部变量不会出错?

    每次调用函数时,局部变量和参数都会在栈上分配独立的空间,不同调用之间互不干扰,因此不会出现交叉污染。

    典型不可重入函数的特征 如果一个函数满足以下任一条件,则通常为不可重入:

    • 调用了 malloc free(因为它们使用全局堆管理链表)。
    • 调用了标准 I/O 库函数(如 printffread 等,这些实现常使用全局缓冲区)。

    6.2 volatile

    • 该关键字在C/C++当中我们已经有所涉猎,今天我们站在信号的角度重新理解⼀下
    • volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

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

    // int flag = 0;
    volatile int flag = 0;

    void handler(int signum)
    {
    (void) signum;
    flag = 1;
    printf("更改flag 0->1\\n");
    }

    int main()
    {
    signal(2, handler);

    while(!flag);

    printf("进程正常退出\\n");
    return 0;
    }

    6.3 SIGCHLD信号

    • 介绍一下在 UNIX/Linux 系统中处理僵尸进程的两种方法:(这个比我们以前那种等待方式好)

    方法一:使用 SIGCHLD 信号 + wait 函数族

    • 背景:子进程终止时会向父进程发送 SIGCHLD 信号,默认处理是忽略。
    • 实现方式:父进程自定义 SIGCHLD 的信号处理函数,在函数中调用 wait 或 waitpid 回收子进程。
    • 优点:父进程无需阻塞等待子进程,也无需主动轮询,可以专心处理自己的工作,子进程终止时会自动触发回收。

    方法二:将 SIGCHLD 设置为 SIG_IGN(忽略)

    • 实现方式:父进程使用 sigaction 将 SIGCHLD 的处理动作显式设置为 SIG_IGN
    • 效果:子进程终止后会被系统自动清理,不会产生僵尸进程,也不会向父进程发送通知。
    • 注意:
      • 这是 UNIX 历史上的一个特例,系统默认的忽略与显式设置为 SIG_IGN 在此处行为不同。
      • 该方法在 Linux 上可用,但在其他 UNIX 系统上不一定支持。

    结语:

    综上,Linux信号的保存、阻塞与捕捉,是进程异步通信的核心,更是Linux开发与面试的必备知识点。从信号集的底层维护,到sigprocmask、sigaction等系统调用的实操,再到避坑指南的梳理,核心围绕“阻塞暂存、捕捉自定义、未决递达”的逻辑展开。希望本文的实操代码、示意图与表格,能帮你跳出概念混淆的误区,真正理解信号机制的底层逻辑,将理论转化为实战能力。学习信号机制的关键的是多动手调试代码,多思考场景应用,后续若遇到具体问题,可结合本文知识点逐一排查。愿你在Linux学习的道路上稳步前行,吃透每一个核心考点,筑牢底层基础!

    赞(0)
    未经允许不得转载:171主机测评 » 深度解析:从原理到实战,一文吃透 Linux 信号机制(下)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址