Linux 信号

发布于:2025-08-17 ⋅ 阅读:(15) ⋅ 点赞:(0)

目录

一. 信号基本概念

二. 信号的产生

1. 通过终端按键产生信号

1-1 操作演示

1-2 理解OS如何得知键盘数据

2. 调用系统命令向进程发送信号

2-1 操作演示

3. 使用函数产生信号

3-1 操作演示

4. 软件条件产生信号

4-1 操作演示

4-2 理解闹钟

5. 硬件异常产生信号

6.term 与 core

三. 信号的保存

1. 信号集 sigset_t

2. 信号操作函数

3. 操作演示

四. 信号的处理

五. 操作系统是如何运行的

1. 硬件中断

2. 时钟中断

3. 死循环

4. 软中断

5. 缺页中断,内存碎片处理,除零野指针错误

六.对内核虚拟地址空间的理解


一. 信号基本概念

信号是一种用于进程间通信或系统通知进程发生特定事件的机制。它可以被视为操作系统向进程发送的“消息”,用于告知进程发生了某种异常等需要处理的事情。

信号的生命周期分为三个阶段 信号产生 信号保存 信号处理。接下来我将按照这三个阶段进行一一讲解。首先我们来粗略的了解一下信号的基本概念。

信号的产生方式有5种,可以由操作系统,其他进程,自身进程,硬件等不同的发送方。当信号产生之后,并不会立即作出处理,内核会先将其暂存,存入一张未决信号表当中,此时要区分对于该信号是否阻塞,待进程有空后再进行处理。当进程成功接收到信号后,此时有三种处理方式,忽略处理,默认动作,信号捕捉。

我们使用 kill -l 指令可以查看到所有的信号,我们把前三十一个称为普通信号,它们都是大写的宏,旁边的数字就是它们的对应值。

使用man 7 signal 可以查看到里边大多数信号的默认动作,以及使用的默认效果。

二. 信号的产生

首先我们来认识一个函数 signal

signal:Linux 操作系统用于处理信号的一个系统调用。它允许用户指定一个函数,当接收到特定信号时进行函数调用。这样我们便可以对信号进行自定义处理。

signum:需要处理的信号类型,可以是宏定义的,也可以是对应的数字。

handler:函数指针类型,用于接收需要的信号。参数要包含你一个 int 来接收信号

1. 通过终端按键产生信号

终端按键可以通过终端驱动程序触发特定信号,如我们熟知的 Ctrl + c ,下面我们介绍几个信号同时演示如何通过终端按键产生信号。

1-1 操作演示

Ctrl + c (SIGINT)会产生 2 号信号,默认会进行终止进程的处理,终止的是前台进程(死循环)。

Ctrl + \ (SIGQUIT)会产生3号信号,除了终止进程,还会生成 core dump 文件,主要用于调试使用。

9号信号 (SIGKILL)强制终止进程,该信号不可被捕捉,阻塞。

19号信号(SIGSTOP)用于暂停进程执行信号,它相当于“冻结”进程而非终止,具有强制性。


下面我们就以 2 号信号作为演示:

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;

void handler(int signal)
{
    cout<<"我是信号:"<<signal<<endl;
}

int main()
{
    cout<<"我是进程:"<<getpid()<<endl;
    signal(2,handler);
    while(true)
    {
        cout<<"我是进程:"<<getpid()<<"我正在等待信号"<<endl;
        sleep(2);
    }
    return 0;
}

上述代码,将2号信号进行捕捉后特殊处理,我们来看效果。

当我们Ctrl + c 时,进程不会终止,而是执行了我的函数,我们只有使用 9 号信号才能对其进行终止。同理,其他按键也与其类似。

1-2 理解OS如何得知键盘数据

键盘和其他外部设备都属于硬件,硬件向中断控制器中发送信号,发起中断,这也就是所谓的硬件中断,控制器为其分配一个中断号,确定了唯一标识源。紧接着通知CPU,CPU得知中断获取中断号。CPU 根据中断号,结合中断向量表,执行中断处理方法。

通过上面的过程,我们可以感受到,硬件中断行为和软件中断(信号处理)有着异曲同工之处。其实,信号就是从软件角度,模拟硬件中断的行为。只不过,硬件中断是发送给CPU,软件中断发送给进程。

2. 调用系统命令向进程发送信号

我们可以通过 kill -信号 进程id 的方法,来对进程发送信号。

2-1 操作演示

我们还是使用先前的代码

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;

void handler(int signal)
{
    cout<<"我是信号:"<<signal<<endl;
}

int main()
{
    cout<<"我是进程:"<<getpid()<<endl;
    signal(2,handler);
    while(true)
    {
        cout<<"我是进程:"<<getpid()<<"我正在等待信号"<<endl;
        sleep(2);
    }
    return 0;
}

我们启动进程,当我们使用 kill 对进程发送2号进程时,屏幕打印出信息,执行了函数。


3. 使用函数产生信号

kill 命令是调用 kill 函数实现的,kill 函数可以给一个指定的进程发送指定的信号

参数:

pid:发送信号进程的pid

sig:发送的信号

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


raise:给当前进程发送指定的信号

参数:

sig:发送的信号

返回值:成功返回0,失败返回 non-zero

abort:使当前进程接收到信号而异常终止

3-1 操作演示

首先我们写一个系统调用 kill 函数的程序

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;

int main(int argc,char* argv[])
{
    if(argc != 3)
    {
        cerr<<"Usage:"<<argv[0]<<"-signalnumber pid"<<endl;
    }

    int number = stoi(argv[1]+1);
    pid_t id = stoi(argv[2]);

    int n = kill(id,number);
    
    return n;
}

接着再来一个测试代码

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;

int main()
{
    while(true)
    {
        cout<<"我是进程:"<<getpid()<<endl;
        sleep(3);
    }
    return 0;
}

我们来看一下运行结果

当我们调用2号信号后,测试用例终止。


4. 软件条件产生信号

由软件逻辑或用户行为触发的信号。下面我们来介绍一下一些软件条件下产生的信号。

13号信号(SIGPIPE),用于处理管道中的写入错误,例如当读端进程关闭,写端进程仍向管道写入,就会触发 SIGPIPE 。

14号信号(SIGALRM),用于通知进程的预设时间已到。我们通常用 alarm 函数来触发该信号。


alarm:设置一个定时器,second秒后触发信号

参数:

seconds:秒数

返回值:若先前设置了定时器,返回剩余秒数,否则返回0。

4-1 操作演示

下面我们来测试一下闹钟

我们来看下面的代码

#include <iostream>
#include <signal.h>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
using namespace std;

void handler(int signum)
{
    cout << "接收到信号:" << signum << endl;
    exit(1);
}

int main()
{
    int cnt = 1;
    alarm(1);
    signal(14, handler);
    while(true)
    {
        cout<<cnt<<endl;
        cnt++;
    }
    return 0;
}

运行结果:

当闹钟响起时接收到14号信号,最终返回。


4-2 理解闹钟

在底层,alarm函数与内核定时器机制相关联,当alarm被调用之后,内核会生成一个软定时器。当定时器到期后,内核会发送SIGALRM信号。当进程中多出用到alarm时候,OS 会借助最小堆,判断要先向谁发送信号。

5. 硬件异常产生信号

硬件异常被硬件以某种方式通过硬件检测通知内核,内核向进程发送适当信号。例如当前进程执行了除0的指令,CPU 运算单元产生异常,内核会将这个异常解释为 SIGFPE 8号信号发送给进程。或者进程访问了野指针,MMU 会产生异常,会向进程发送 SIGSEGV 11号信号。

关于野指针问题

这个问题与 CPU 内部的MMU,CR2 ,CR3有关联。MMU和页表用来管理虚拟内存从虚拟地址到物理地址的转换。CR3用于切换不同进程的页表,CR2用于存储当前页表的错误虚拟地址。当MMU无法将虚拟地址与物理地址进行关联时,CR2会存储该虚拟地址,并产生异常信号,向当前进程发送 11号信号。

6.term 与 core

termcore 是信号默认动作的表示。

1. term 是terminate 的缩写,表示默认终止进程。

2. core 动作在终止进程的同时,还会生成一个core dump文件,这个文件用于调试。

当进程退出时,core dump为0表示没有异常退出,如果是1表示异常退出。


综上所述,产生信号的方法有5种,但本质其实只有三种,硬件触发如键盘或硬件异常等,软件触发如闹钟管道,用户对系统调用kill命令等。所有的信号产生最终都是由 OS 进行执行,产生的信号不会立即作出处理,而是在合适的时候进行。所以说,这些暂时没有执行的信号就会进行保存,那么接下来我们就来理解一下信号的保存。

三. 信号的保存

操作系统用三张表来对信号进行管理。

Block表(阻塞表):一个位图,用于表示哪些信号被阻塞,阻塞为1,未阻塞为0

Pending表(未决表):未决即信号已经产生,但是暂时未被进程处理。当信号处于未决时,位图表为1。

Handler表(处理函数表):一个函数指针数组,用于表示信号接收后的处理动作。可以是默认函数,忽略函数,自定义函数。

有阻塞不一定有未决,但有未决一定是因为阻塞。

进程对该信号进行阻塞,若用户一直不传递该信号,该信号就不会进入未决状态。若该信号进入了未决状态,说明该信号已经被阻塞了,正在等待进程处理。


操作系统通过两张位图 + 一张函数指针表 ,就完成了让进程识别不同的信号。

1. 信号集 sigset_t

sigset_t 是一种数据类型,用于接收阻塞和未决标志专门设置的数据类型。可以用来表示该数据的有效或者无效。

2. 信号操作函数

sigset_t 存储在内部,从使用者的角度我们不必关心,我们只需要能调用它的封装函数来对 sigset_t 进行操作。

#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);

参数:

set:指向信号集

signo:某种信号

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

sigemptyset:将指向的信号集所有信号对应的bit清空。

sigfillset:将指向的信号集所有的信号bit置为1.

sigaddset:向信号集某一个信号的bit置为1.

sigdelset:向信号集某一个信号的bit置为0.

sigimember:判断一个信号集中是否包含某种信号,包含返回1,不包含返回-1


sigprocmask:读取或更改进程的信号屏蔽字

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

参数:

how:如何修改当前的信息掩码

{SIG_BLOCK:将信号集中的所有信号进行阻塞

SIG_UNBLOCK:将信号集中的所有信号解除阻塞

SIG_SETMASK:将信号集更新为set,并且返回oldset}

set:新的更改过的信号集

old:更改前的信号集。

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


sigpending:读取当前进程的未决信号集,通过set传出

#include <signal.h>
int sigpending(sigset_t *set);

参数:

set:接收的信号集

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


3. 操作演示

我们设计一个程序,对2号信号进行阻塞,然后不断打印未决表观察,在10次轮询后,解除对2号信号阻塞,再对未决表进行观察。

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;

//先屏蔽2号信号,轮询10次解除屏蔽

void PrintPending(sigset_t &pending)
{
    cout<<"["<<getpid()<<"]:";
    for(int i = 31;i>=1;i--)
    {
        if(sigismember(&pending,i))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout<<endl;
}

void headler(int signo)//打印信号表
{
    cout<<"接收到信号:"<<signo<<endl;
    cout<<"----------------------"<<endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    cout<<"----------------------"<<endl;
}

int main()
{
    //初始化
    sigset_t new_set,old_set;
    signal(2,headler);
    sigemptyset(&new_set);
    sigemptyset(&old_set);
    sigaddset(&new_set,2);
    
    //设置阻塞表
    sigprocmask(SIG_BLOCK,&new_set,&old_set);

    int cnt = 10;

    while(true)
    {
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);

        if(cnt == 0)
        {
            sigprocmask(SIG_SETMASK,&old_set,&new_set);
            cout<<"解除对2号信号屏蔽"<<endl;
        }
        cnt--;
        sleep(1);
    }
    return 0;
}

运行结果:

我们发现当我们发送2号信号时,未决表接收到信号,但是因为阻塞所以进程接收不到。当解除信号屏蔽后,先前发送的2号信号就立马被接受到了,此时再发送信号就不会出现阻塞。

四. 信号的处理

当我们进程接收到信号后,有三种处理方式

1. 忽略信号:忽略该信号,不对其做任何处理

2. 捕捉信号:进程可以注册一个对于该信号的一个特殊捕捉函数,当该信号触发时,进程接收信号并做出相应处理。

3. 默认处理:若进程没有对该信号进行注册,系统会按照默认的方法进行处理。

signal(2, handler);
signal(2, SIG_IGN);
signal(2, SIG_DFL);

handler:自定义函数

SIG_IGN:忽略信号

SIG_DFL:默认处理


我们从上文得知,信号并不会被立即处理,而是会先储存等待合适的时机再进行处理。那合适的时机是什么时候呢?

先说结论,是从内核态切换为用户态的时候。

简单讲,当我们执行自己的代码,访问自己的数据就是用户态;进入系统调用,以操作系统的身法运行就是内核态。

当程序出现中断,异常或者系统调用的时候,我们会从用户态转为内核态。在内核态中以操作系统的身份完成工作后,将会从内核态切换回用户态,此时操作系统会对未决的信号进行检测处理(通过三张表判断),因为信号的三张表存储在PCB(进程控制块)中,需要调用三张表就必须处在内核态。若信号执行的是默认或者忽略,此时已经完成了工作。若信号执行的是自定义函数,此时返回用户态之后会处理信号函数,并且返回sys_sigreturn()使其进入内核态。最后在内核态和用户态切换的间隙处理函数,最后返回到主流程被中断的地方,这就完成了信号的捕捉流程。

也就是说,如果信号执行的是自定义函数,那么就需要4次的用户态和内核态的切换

简化一下:


我们再来认识一个函数

sigaction:自定义信号处理方式

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

struct sigaction 
{
    void (*sa_handler)(int);  // 信号处理函数(类似 signal() 的回调)
    sigset_t sa_mask;         // 信号处理期间临时阻塞的信号集
    int sa_flags;             // 信号处理的标志位(如 SA_RESTART、SA_NODEFER 等)
    void (*sa_sigaction)(int, siginfo_t *, void *);  // 高级处理函数(需配合 SA_SIGINFO 志)
};

参数:

signum:信号数字

act:结构体定义信号行为,传入的新的行为

old:旧的行为

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

五. 操作系统是如何运行的

1. 硬件中断

当我们在键盘上输入命令或数据时,键盘的电路会检测按键的按下和释放,并产生对应的电信号。电信号随后转换为中断信号,通过硬件连线传递到CPU的中断控制器。中断控制器根据信号的优先级和当前CPU的状态,决定是否向CPU发送中断请求。

CPU通过中断机制来响应中断请求。在保护下,CPU会维护一个中断描述符表,该表包含中断服务相应的地址信息。当中断发生后,会根据该表来找到对应执行方法的地址。

一旦CPU接收到来自键盘的中断请求,就会暂停当前正在执行的程序,然后跳转到特定的中断处理服务地址,处理对应的方法。中断处理操作会执行必要的操作,如读取键盘状态,更新数据,发送响应等。处理完中断后,CPU会恢复之前保存的状态,并继续执行原来的程序。

2. 时钟中断

进程是在操作系统的指挥下被调度运行的,而操作系统是由时钟中断进行管理的。

时钟中断源于硬件定时器,有计算机的主板芯片或处理器芯片提供,通过定时器计数器来实现中断功能。

功能:

1. 维护系统时间:每当时钟中断发生,内核系统会重新维护一个时间,通过更新系统保证时间的准确性,为用户提供可靠的时间信息。

2. 任务调度:在多进程中,时钟会为每一进程分配时间片。当一个时间片用完了,内核就会重新选择下一个要运行的进程,并切换上下文,这样就可以保证公平的进行进程调度。

3. 计录进程执行时间:每当进程或线程被抢占或者切换时,时钟会记录下抢占时间,方便我们后序了解系统的运行情况。

3. 死循环

操作系统在启动之后就会变成一个死循环,但这个循环是可控循环。会进行事件驱动,动态调度,资源管理等操作。通过时钟中断,可以为每一个进程进行时间分配,这样可以确保每个进程都有机会获得处理器资源。

4. 软中断

软中断是软件主动触发“中断”,从而进入内核态执行特权操作。也被称为“陷阱”,被用作内核态与用户态直接的通信桥梁。

软硬中断的具有共性,中断之后需要进行状态切换(从用户态到内核态),上下文保存(保存用户态寄存器,计数器等信息),跳转执行(根据中断号,跳转处理),处理返回(执行完内核逻辑后返回)。

x86下32位机器通常用 int 0x80指令实现调用;在x86下64位机器通常用syscall指令执行调用。


通过软中断,我们从用户态到了内核态,这样我们就可以实现系统调用。在内核态中,所有的系统调用其实都是被封装好的一个个函数指针数组,我们通过对应的下标,就可以对其进行访问。

这些被系统调用的系统函数会以数组下标的形式存储在 寄存器 中,通过下标找对应的系统调用表找到对应的处理函数并调用。执行完后再从内核态返回用户态,恢复上下文。这样就完成了系统调用。

紧急着,上层对于这些系统调用接口进行封装,这样我们用户就无法直接调用系统接口,保证了安全性。

5. 缺页中断,内存碎片处理,除零野指针错误

这些问题会被CPU内部转化为软中断,走中断处理例程进行相应处理。


六.对内核虚拟地址空间的理解

我们这里只对内核的虚拟地址空间进行讨论。

内核虚拟地址空间存储在虚拟地址空间的高地址处,且每一份进程共享这一份内核页表,用于对操作系统进行管理。通常用户态在【0-3G】而内核区在【3-4G】处。

CPU通过 int 0x80 或 syscall 指令进行用户态到内核态的切换,将当前状态记录在 CPL 中,其中0代表内核,3代表用户。在内核区中存储了内核页表,存储着内核处理中断异常硬件等各种方法的函数指针。


总结:

信号是操作系统中一种轻量级的异步通信机制,通过预定义的信号编号传递事件通知,实现进程对异常、外部请求等事件的响应。其核心是 “事件触发 - 处理” 模型,兼具灵活性(自定义处理)和强制性(关键信号不可忽略),是进程间通信和系统异常处理的基础工具。


网站公告

今日签到

点亮在社区的每一天
去签到