Linux信号

理论铺垫

  1. 进程能够识别并处理信号。
  2. 进程即便没收到信号,也知道哪些信号该怎么处理,这属于进程内置功能的一部分
  3. 当进程收到具体信号时,可能不会立即处理这个信号,而是在合适的时候处理
  4. 从信号产生到信号被处理有一时间窗口,所以进程具有保存哪些信号已经发生了的能力。

问题引入:在终端按下"ctrl+c"是如何终止进程的?

标题键盘如何将输入数据给到用户

正常情况下键盘输入传到用户缓冲区的顺序如下:

  1. 用户写入数据,按下enter,键盘中断触发
  2. 中断处理程序把字符存入TTY缓冲区
  3. 此时数据在内核的TTY缓冲区里
  4. 此前进程调用read(0, buf, n) 正在睡眠等待
  5. 数据到达后,内核唤醒该进程
  6. 把数据从TTY缓冲区拷贝到用户空间的 buf
  7. read()返回,进程继续执行

如果输入的是Ctrl+C ,OS能够判断这是信号,则第二步后,Ctrl+C不存入缓冲区,而是被发送到前台进程, 进程收到信号后终止。(注意,后台进程无法接收到终端输入的Ctrl+C,前台进程只能有一个,而后台进程可以有多个)

前台进程在运行过程中用户随时可能按下Ctrl+C这种控制键产生 Ctrl-C 而产生一个信号,也就是说该进程的随时可能执行SIGINT信号而终止**,所以信号相对进程的控制流程是异步的。**


信号

信号是进程之间事件异步通知的一种方式,属于软中断

在终端输入kill -l查看所有信号:

信号分为普通信号 (1-31号)和实时信号(32号到64号),普通信号可以暂存处理,而实时信号必须立即处理。

当进程收到信号时,有三种处理方式**:默认动作、忽略、自定义动作** ,Ctrl+C对应的2号信号的默认动作就是进程终止,可通过signal接口实现自定义动作

signal接口

参数1:信号数字
参数2:自定义动作函数

signal可将指定信号的默认动作转为执行自定义动作函数,但不是所有信号都可以被signal捕捉的(如信号9和19)


下面演示2号信号执行自定义动作:

cpp 复制代码
#include <signal.h>
#include<iostream>

using namespace std;

void handler(int num){
    cout<<"I got a signal:"<<num<<endl;
    exit(0);
}

int main(){
    signal(2, handler);//当进程收到信号2时不会直接退出而是执行handler
    while(true){
        cout<<"I can't stop,can anyone help me?"<<endl;
        sleep(1);
    }
    return 0;
}

信号的产生

信号的产生方法:

  • 键盘组合键(如Ctrl+C)
  • kill命令
  • 系统调用kill、raise、abort
  • 异常
  • 软件条件

kill

参数1:要将信号传递给哪个进程,就填哪个进程的pid

参数2:要传的信号

kill可以将指定信号传给指定进程,内核根据运行可执行程序用户的权限拒绝或接收这个信号传递请求。


下面演示进程A将7号信号传给进程B:

cpp 复制代码
#include <signal.h>
#include<iostream>
#include<string.h>
using namespace std;

int main(int argc,char*argv[]){
    //argv[1]->pid argv[2]->signal
    if(argc==3){
        int pid=stoi(argv[1]);
        int signal=stoi(argv[2]);
        kill(pid,signal);
        cout<<getpid()<<"已发出信号"<<signal<<endl;
    }
    else{
        cout<<getpid()<<"未发出信号"<<endl;
    }
    return 0;
}
cpp 复制代码
#include<iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int num){
    cout<<"I got a signal:"<<num<<endl;
    exit(0);
}

int main(){
    signal(7, handler);
    int cnt=10;
    while(cnt--){
        cout<<"I am a process,my pid:"<<getpid()<<endl;
        sleep(5);
    }
    return 0;
}

意:无论信号如何产生,最后都由操作系统发送给进程。


raise

参数:指定的信号

raise被调用时,会给当前进程发送指定信号。


abort

无参数,被调用时立即终止当前进程,并生成core dump。


异常

硬件检测 :当CPU执行指令时,其内部的**算术逻辑单元(ALU)或内存管理单元(MMU)**会实时检测非法操作。例如:

  • 除零错误:执行除法指令时,若除数为0,ALU会立即检测到并触发异常。

  • 非法内存访问:访问无效内存地址时,MMU会检测到并触发"页错误"异常

一旦检测到异常,CPU会立即暂停当前指令,并切换到内核态。将异常类型、指令地址等关键信息压入内核栈,然后根据异常类型查询中断向量表(IDT) ,跳转到内核中对应的异常处理程序入口

内核的异常处理程序接管后,会执行以下操作:

  1. 分析异常:处理程序通过分析CPU压入栈的信息和异常类型,准确判断发生了什么问题。

  2. 翻译为信号:内核将具体的硬件异常翻译成对应的标准信号。

  3. 发送信号 :最后,内核会找到触发异常的进程,修改其进程控制块(PCB) 中的未决信号集,将信号发送给该进程。进程会在合适的时机处理这个信号。


补充:

1.cpu状态寄存器

发生除零错误时,cpu状态寄存器上的溢出标志位由0->1,由于硬件受操作系统管理,所以操作系统也能得知这一变化,因此能主动查询溢出标志位,从而得知是否发生了除0错误。

cpu每次执行的是单一进程的上下文,所以无需担心多个进程对溢出标志位的修改造成冲突。

2.内存管理单元(MMU)

页表不是软件维护的 KV 表,而是由MMU电路直接通过虚拟地址查询物理地址的硬件数据结构

软件条件(闹钟)

alarm接口

参数:在多少秒后向当前进程发送14号信号 ,即SIGALRM

返回值:上一次设置闹钟剩余的时间

系统中有很多闹钟要管理起来,采用堆结构保证所有闹钟的剩余时间都大于等于堆顶闹钟的剩余时间,进而保证没有闹钟超时。


补充知识

core dump

进程等待wait的status参数的第八位是core dump标志,下面是core dump具体产生机制:

  1. 进程运行中遇到严重错误(如段错误、收到8号信号SIGFPE等)
  2. 内核标记 core dump 标志位为1
  3. 把进程当时的 内存数据、寄存器状态、堆栈信息 全部写入磁盘
  4. 生成一个名为 core.<pid> 的文件,用gdb可以直接加载这个文件,用bt命令能直接看到崩溃时的调用栈,定位到具体出错的代码行。这就是所谓的"事后调试"。

ulimit -c命令开启core dump功能,core文件的大小 ≈ 进程当时的内存占用,可能把内存吃空,所以该功能在云服务器上默认是关闭的


发送信号的本质

信号是向进程PCB发送的,task struct 存有signal成员(int类型)其32个比特位代表所有普通信号

  • 比特位的内容是0还是1,表明是否收到
  • 比特位的位置(第几个),表示信号的编号
  • 所谓的"发信号",本质就是OS去修改task_struct的信号位图对应的比特位。

普通信号无论发几次进程都会等到那个合适时机才处理,不需计数,所以可用位图存储。

信号的保存

进程的task_struct中有两个位图pending、block以及一张方法表handler,当进程收到信号时,将pending中相应位置置为1,代表有信号要处理,而block位图用于表示屏蔽哪些信号,屏蔽某信号,仍可以收到该信号,但不会去处理该信号。一旦解除屏蔽,仍会处理该信号。handler数组存放每种信号对应处理函数的指针,pending& ~blocked后对应位若为1,执行对应的函数。

注意:

  1. 前文提到的signal函数要求给定信号和函数指针,正是用于替换信号(本质是数字)对应下标handler里面的函数指针
  2. 不能屏蔽9和19号信号
  3. 信号产生 = 信号进入pendin g,而信号递达 = 信号被处理

sigset_t信号结构体

cpp 复制代码
typedef struct {
    unsigned long sig[2];   // 通常就是一个位图数组
} sigset_t;

用户可以用信号集操作函数定义信号结构体,用于设置对应的pending和block,这些函数本质本质就是位操作的封装。

cpp 复制代码
#include <signal.h>
sigemptyset(&set);                // 清空(全置0)
sigfillset(&set);                 // 填满(全置1)
sigaddset(&set, SIGINT);          // 添加一个信号
sigdelset(&set, SIGINT);          // 删除一个信号
sigismember(&set, SIGINT);        // 判断是否包含

sigprocmask

cpp 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
//成功则为0,若出错则为-1 

参数1:选择方式

SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,相当于mask=set

参数2:输出型参数,想要设置的新的信号集指针。

参数3:输出型参数,保存旧的block表,用信号集指针接收。


sigpending

cpp 复制代码
#include <signal.h>
sigpending(sigset_t *set)
//调用成功则返回0,出错则返回-1。

用于获取 当前进程中已产生但被阻塞、尚未递达的信号集合(pending)。

参数:用于接收结果的信号集指针(set)


下面代码演示打印pending表、信号屏蔽以及解除信号屏蔽后产生的现象:

cpp 复制代码
#include <signal.h>
#include<iostream>
#include<string.h>
#include<unistd.h>
using namespace std;

void handler(int num){
    cout<<"I got a signal:"<<num<<endl;
}

//遍历打印信号集
void printsignal(sigset_t*input_set){
    for(int i=32;i>0;i--){
        if(sigismember(input_set, i))cout<<'1';
        else cout<<'0';
    }
    cout<<endl<<endl;    
    return ;
}

int main(){
    //用signal函数转换二号信号用于测试
    signal(2,handler);
    //打印初始pending
    int cnt1=10;
    sigset_t iset,oset;
    while(cnt1--){
        sigpending(&iset);
        printsignal(&iset);
        sleep(1);
    }
    //向pending中添加2号信号
    //在终端发信号后查看新pending的异同
    int cnt2=10;
    sigaddset(&iset, 2); 
    //设置block第二位为1,继续在终端发送信号
        //预期效果,自定义动作未得到执行,同时观察到pending第二位为1
    sigprocmask(SIG_SETMASK, &iset, &oset);
    cout<<"block被设置,10秒状态保持"<<endl;
    while(cnt2--){
        sigpending(&iset);
        printsignal(&iset);
        sleep(1);
    }
    //将block回溯,可观察到信号不再被屏蔽
    sleep(10);
    sigprocmask(SIG_SETMASK, &oset, &iset); 
    cout<<"block已回溯"<<endl;
    sleep(10);
    return 0;
}

信号处理

进程地址空间进一步深入:用户空间与内核空间

进程地址空间分为用户空间和内核空间,各个进程的内核空间都是固定的。

所以在进程看来,调用系统的方法是在自己的地址空间中执行的;而在操作系统看来,由于总有进程运行,随时都可以调用系统方法

用户态与内核态

前文提到过,操作系统并不信任用户,用户只能通过操作系统接口来调用操作系统中的内容,而cpu只知道按指令执行,如何区分这些情景呢?

cpu内有一CS寄存器, CS寄存器的低两位 存储CPL。在x86架构下,CPL0是内核态,CPL3是用户态。用户程序通过系统调用指令 触发特权级切换,CPU从用户态切换到内核态,由内核代码处理请求,完成后再切换回用户态,所以在用户态下无法对内核中的资源操作。

下面说明两种状态的切换流程:

  1. 正常执行:CPU用户态,运行用户代码。

  2. 触发事件 (中断、异常、syscall指令)→ CPU硬件自动切到内核态,跳转到内核入口(中断向量表)

  3. 内核处理

    • 若是中断/正常系统调用:内核执行完毕,准备返回用户态。

    • 若是可修复异常(如缺页):内核修复,准备返回用户态。

    • 若是不可修复异常(如非法地址):内核标记递送信号(SIGSEGV)。

  4. 返回前检查 :内核在恢复用户态寄存器之前,检查进程是否有待处理信号

    • 若无信号:直接恢复上下文,iret/sysret切回用户态继续执行。

    • 若有信号:

      • 默认动作(忽略/终止/停止) :内核直接执行默认操作(如终止进程),不回原执行流。

      • 自定义动作 :内核修改用户态栈和指令指针(PC),让它指向用户自定义的信号处理函数。然后切回用户态,执行该函数。

  5. 自定义函数执行完 :函数末尾调用sigreturn,再次陷入内核态

  6. 最终恢复 :内核根据之前保存在用户栈的原始上下文,恢复所有寄存器,再次切回用户态,回到原先被中断的位置继续运行代码。


信号捕捉补充

sigaction

除前文提到的signal接口外,sigaction也可用于捕捉信号。

参数1:信号

参数2:输出型参数,sigaction结构体,其中包含自定义动作函数指针。

参数3:输出型参数,保留原sigaction结构体,如不需保留可填NULL。


下面代码验证pending位图是在信号处理前改变:

cpp 复制代码
#include <signal.h>
#include<iostream>
#include<string.h>
#include<unistd.h>
using namespace std;

void Pending_print(sigset_t input){
    for(int i=31;i>0;i--){
        if(sigismember(&input,i))cout<<'1';
        else cout<<'0';
    }
    cout<<endl;
}

void handler(int num){
    cout<<"进入handler后:"<<endl;
    sigset_t nowset;
    sigpending(&nowset);
    Pending_print(nowset);
    int cnt=5;
    while(cnt--){
        cout<<"I got a signal:"<<num<<' '<<cnt<<endl;
        sleep(1);
    }
}

//目的,使用sigaction结构体捕捉信号,验证pending表从1->0的时机(处理前/后)
int main(){
    //自定义动作
    struct sigaction sig;
    sigset_t newset;
    sigset_t oldset;
    sigset_t nowset;
    sig.sa_handler=&handler;
    sigaction(2,&sig,NULL);
    sigaddset(&newset,2);
    sigprocmask(SIG_SETMASK, &newset,&oldset);
    //先打印阻塞的pending表
    int cnt=5;
    while(cnt--){
        //终端发送二号信号,让自定义动作死循环,观察此时的pending
        sigpending(&nowset);
        cout<<"pid:"<<getpid()<<endl;
        Pending_print(nowset);
        sleep(1);
    }
    //解除阻塞,进入信号处理函数
    sigprocmask(SIG_SETMASK, &oldset, NULL);
    return 0;
}

sigaction结构体的第三个成员sa_mask:

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字 ,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。


扩展知识

可重入函数

定义: 一个函数在执行过程中被中断,中断处理程序中再次调用该函数,两次调用都能正确完成、互不影响,就称该函数是可重入的

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

  1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  2. 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

应用场景:

可重入函数多用于中断和信号处理 。CPU在执行函数A的过程中,可能被中断打断,而中断处理程序(或信号处理函数)又调用了A。如果A内部依赖了全局/静态数据共享缓冲区,两次调用就会互相踩踏,产生不确定的结果。


volatile关键字

在某执行流中定义一个变量后续不进行改动,编译器可能误以为该变量后续不做修改,于是为节省时间直接将该变量存入cpu寄存器中,即便后续在其它执行流修改,改变的只是内存中的变量,cpu认为该变量值不变。volatile关键字可防止这种请情况,下面演示应用场景:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
 
int flag = 0;
 
void handler(int sig)
{
    printf("chage flag 0 to 1\n");
    flag = 1;
}
 
int main()
{
    signal(2, handler);
    while(!flag);
    printf("process quit normal\n");
    return 0;
}

在main函数中的flag可能被编译器优化掉,即便收到2号信号也无法结束死循环,使用volatile关键字修饰flag即可解决此问题。

volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量 的任何操作,都必须在真实的内存中进行操作

此外,在编译选项中有-O选项,-O1/2/3代表不同的优化级别。


SIGCHLD信号

子进程退出时,会向父进程发送SIGCHLD信号,基于此,父进程可以不再显式等待子进程并回收,而是捕捉到该信号时执行回收动作

若多个子进程同时退出,父进程只能收到其中一个子进程的SIGCHLD信号(其它的被阻塞了)因此采用轮询的方式回收子进程子进程


下面代码演示采用异步方式回收多个子进程

cpp 复制代码
// 采用异步方式回收多个子进程
void handler(int num)
{
    cout<<"----------------------------------------"<<endl;
    cout << "父进程接收到" << num << "号信号" << endl;
    // 每次在handler中用非阻塞轮询的方式回收已经退出的子进程 
    while (1)
    {
        int status;
        pid_t pid = waitpid(-1, &status, WNOHANG);
        if (pid > 0)
        {
            cout << "子进程" << pid << "被回收" << endl;
        }
        else if (pid == 0)
        {
            cout<<"还有进程没跑完"<<endl;
            break;
        }
        else
        {
            cout << "等待发生错误或子进程全部跑完" << endl;
            break;
        }
    }
}

int main()
{
    // 创建多个子进程,父进程捕捉17号信号,子进程随机时间退出
    // 父进程死循环
    // 观察所有子进程是否都被回收
    signal(17, handler);
    int cnt = 10;
    while (cnt--)
    {
        pid_t pid = fork();
        switch (pid)
        {
        case -1:
            perror("fork");
            exit(EXIT_FAILURE);
        case 0:
        {   
            srand(getpid());
            int randnum = rand() % 20;
            sleep(randnum);
            cout<<"子进程"<<getpid()<<"已退出"<<endl;
            exit(EXIT_SUCCESS);
        }
        default:
            printf("New child is PID %jd\n", (intmax_t)pid);
            break;
        }
    }
    //父进程死循环等待
    while(true){
        ;
    }
    return 0;
}

补充:

如果把17号信号自定义动作设置为忽略 ,那么父进程根本就不需要回收子进程,子进程也不可能成为僵尸进程,但可能成为孤儿进程(父进程先退出的情况下)所以最好保证父进程最后退出。这个方法只在linux上适合,并只适合于不需要知道子进程退出结果的场景