Linux 信号

信号量的定义

  • 信号量本质上是一个计数器,用来表示公共资源中可用资源的数量。

  • 主要用于解决多个进程同时访问共享资源时的数据不一致问题。

公共资源与临界资源

  • 公共资源:可以被多个进程同时访问的资源。为了进行进程间的通信,共享资源需要受到保护以防止数据竞争。

  • 临界资源:需要保护的共享资源,因为多个进程访问时可能会导致数据不一致。

临界区与非临界区

  • 临界区:访问和操作临界资源的代码区域。为了避免数据竞争,需要保护临界区。

  • 非临界区:不涉及对共享资源的访问或修改的代码区域,不需要保护。

同步与互斥

  • 同步:多个进程在执行时通过协调顺序来保证数据的一致性。

  • 互斥:防止多个进程同时进入临界区,以保护共享资源。

原子性

  • 指操作的不可分割性:要么完全执行完毕,要么完全不执行。原子操作在多线程环境中是安全的,因为它不会被中断。

信号量的作用

  • 使用信号量来保护临界资源,实现进程间的同步和互斥,以确保数据的正确性。

为什么要信号量 ?

信号量的作用

  • 信号量用于控制对共享资源的访问,防止多个进程同时操作而引起的数据不一致问题。

  • 通过信号量机制,可以对资源进行预定,实现资源的有序使用。

使用信号量的步骤

预定资源:在访问公共资源之前,进程需要首先申请信号量,以表示对该资源的预定。

访问公共资源:获得信号量后,进程可以安全地访问共享资源。

释放资源:在使用完资源后,进程释放信号量,使其他进程可以继续使用该资源。

信号量的特点

  • 信号量是公共资源:所有进程必须看到同一个信号量,才能协调对共享资源的访问。

  • 安全保障:信号量使用原子锁来保证操作的原子性,使得信号量本身不会因并发访问而出现问题。

预定资源 访问公共资源 释放资源 合起来就是 PV 操作

如果一个信号量的初始值为1就是 二元信号量 -- 互斥功能

进程信号

进程信号和 信号量是完全不同的概念

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

信号在OS 中的应用

用户输入命令,在Shell下启动一个前台进程。

用户按下 Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程 . 前台进程因为收到信号,进而引起进程退出

Ctrl + c 发送的是2号进程码

Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行

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

xinchen@chen:~$ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX

【1 - 31】普通信号

【34-64】实时信号

其中每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2

信号处理的三种方式

  • 忽略此信号

  • 执行该信号的默认处理动作

  • 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch)一个信号。

进程如何识别信号的?

  • 程序员编写属性和逻辑的集合--程序编码完成

  • 收到信号程序可能运行更重要的代码,所以信号不一定被及时处理。

  • 进程必须要有对信号的保存能力

信号的发送本质上说是修改 PCB 中的信号位图

【1,31】个比特位 代表信号编号 代表是否收到该信号?

0表示没有 1表示有

struct task_struct
{
    unsigned int signal;
}

PCB的管理者是OS ,本质上发送信号都是OS向目标进程发送的信号。

#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void hrander(int signo)
{
    cout << " -------------------------------"<<signo << endl;
}
​
int main()
{
    signal(2,hrander);
    while(true)
    {
        cout << "cout << I am Running" <<" :: " << getpid()<< endl;
        sleep(1);
    }
    return 0;
}

[chen@RE testLinux]$ make g++ -o mysignal mysignal.cc -std=c++11 [chen@RE testLinux]$ ./mysignal cout << I am Running :: 16702 cout << I am Running :: 16702 ^C -------------------------------2 cout << I am Running :: 16702 cout << I am Running :: 16702 ^C -------------------------------2 cout << I am Running :: 16702 ^C -------------------------------2 cout << I am Running :: 16702 cout << I am Running :: 16702 ^C -------------------------------2 cout << I am Running :: 16702 cout << I am Running :: 16702

手动实现KILL 命令(通过调用系统接口)

#include <iostream>
#include <signal.h>
using namespace std;
void noUsing()
{
    cout << " kill [options] <pid>|<name>..." << endl;
}
int main(int argc,char *argv[])
{
    if(argc != 3){
        noUsing();
        exit(0);
    }
    pid_t pid = stoi(argv[1]);
    pid_t signo_id = stoi(argv[2]);
​
    int n = kill(pid,signo_id);
    if(n)
    {
        perror("kill");
    }
    return 0;
}
  • kill() 可以向任意进程发送任意信号

  • raise() 给自己发送任意信号 kill(getpid(),xxx)

  • abort() 给自己发送指定信号 SIGABRT

进程信号行为理解:

大多数情况进程收到大部分信号 默认的处理动作都是终止进程

进程信号的意义:信号的不同,代表不同的事件但是对事件发生后的处理动作可以一样

信号产生方式

信号的产生,不一定非得用户显示的发送

硬件异常产生信号

为什么除0会终止进程,操作系统终止 SIGFPE?

硬件异常的定义

  • 硬件异常是由硬件检测到的错误或特殊情况,硬件会将这些异常通知操作系统的内核。

  • 内核接收到异常后,会将其解释为适当的信号发送给当前运行的进程。

常见硬件异常及其对应信号

  1. 除以零异常:

    • 当一个进程执行除以零操作时,CPU的运算单元会检测到非法操作并产生异常。

    • 操作系统内核接收到此异常,并将其解释为SIGFPE(浮点异常)信号发送给进程。

  2. 非法内存访问:

    • 如果进程试图访问未分配或受保护的内存地址,内存管理单元(MMU)会检测到非法访问并产生异常。

    • 内核将该异常解释为SIGSEGV(段错误)信号发送给进程。

为什么除以零会终止进程?

/0会出现运算异常 状态寄存器的溢出标志位为1 ,CPU表示运算异常告诉OS OS会发送相应的终止信号给进程

操作系统对SIGFPE信号的处理

  • 当内核发送SIGFPE信号给进程时,默认的行为是终止进程。这是一种保护机制,确保程序不会继续执行错误的操作。

  • 进程可以通过自定义的信号处理函数来捕获并处理SIGFPE信号,以防止进程被终止。但是,如果没有处理机制,操作系统将按照默认策略终止进程。

软件异常产生信号

SIGPIPE是一种由软件条件产生的信号

alarm(1) ;类似于闹钟唤醒

操作系统是如何管理闹钟的呢?

struct alarm
{
    uint64 when ;//  未来的超时时间
    int type;    // 闹钟类型一次性还是周期性
    task_struct *p; 
    struct alarm_next;
};

每次检查使用小根堆进行维护数据结构 每次取出最小的时间进行执行

每次curr_timestamp > alarm.when 超时OS 发送SLGALARM信号到 -> alarm.p 使用函数指针达到多态

信号的产生与处理分析

为什么由操作系统(OS)来处理信号?

  • 操作系统是进程的管理者:负责所有进程的创建、调度、内存管理以及资源分配。由于信号是进程间的通信方式或对进程异常的响应,操作系统最适合来管理和处理这些信号。

  • 硬件异常的桥梁:当硬件检测到异常时,它通知操作系统,而操作系统将异常解释为信号并发送给相应的进程进行处理。

信号处理是否是立即执行的?

  • 不是立即处理的:信号的处理并非总是立即进行,而是安排在适当的时机。操作系统会在进程的某个安全点(如进程主动让出CPU时间片、系统调用结束、或进程进入内核态时)检查是否有待处理的信号。

  • 信号的优先级:一些信号可能会有较高的优先级,系统会优先处理这些信号。

信号需要暂时记录吗?记录在哪里?

  • 需要暂时记录:当信号发送到进程时,如果当时不能立即处理,操作系统需要将信号暂时记录下来,以便稍后在适当的时机进行处理。

  • 记录的位置:信号通常记录在进程的进程控制块(PCB)中,PCB是操作系统用来管理进程信息的数据结构,其中包含了进程的状态、资源信息、寄存器内容等。

进程是否能提前知道如何处理合法信号?

  • 是的,进程可以设置信号处理方式:

    每个进程可以定义自己的信号处理函数来处理某些信号。

    进程可以忽略某些信号(如SIG_IGN)、捕获并处理信号(自定义处理函数),或者使用系统默认的处理方式。

    对于一些关键信号(如SIGKILL),进程不能忽略或自定义处理,操作系统总是执行默认的终止行为。

操作系统向进程发送信号的完整过程

信号的产生:

  • 信号可以由多种原因产生,如硬件异常(除以零、非法内存访问)、用户输入(如键盘中断)、操作系统事件(计时器超时)或其他进程发送的信号。

信号的传递:

  • 操作系统在接收到信号的触发事件后,会将该信号添加到目标进程的信号队列中,并记录在进程的PCB中。

检查信号:

  • 当目标进程运行到安全点(如系统调用结束或上下文切换时),操作系统会检查该进程是否有待处理的信号。

处理信号:

  • 如果进程有自定义的信号处理函数,操作系统会调用该函数。

  • 如果没有定义处理函数,则按照默认行为处理信号(如终止进程)。

  • 某些信号(如SIGSTOPSIGKILL)总是执行默认行为,无法被捕获或忽略。

恢复进程:

  • 信号处理完毕后,操作系统会恢复进程的状态,使其继续执行或终止。

进程退出的时候的核心转储问题

核心转储(Core Dump)简介

核心转储(Core Dump)是指在进程异常终止时,将进程的用户空间内存数据保存到磁盘上的文件,通常命名为core。它主要用于事后调试(Post-mortem Debug),帮助开发者使用调试器分析进程崩溃的原因,如段错误或非法内存访问导致的异常。

核心转储文件的大小受限于进程的资源限制(Resource Limit),该信息存储在进程控制块(PCB)中。默认情况下,系统不允许生成核心转储文件以保护敏感信息(如用户密码)。在调试阶段,可以使用ulimit命令修改资源限制,允许生成核心转储文件,例如设定最大大小为1024K。

当进程出现异常情况的时候,我们将进程在对应的时刻,在内存中的有效数据转储到磁盘中--核心转储

如何使用|支持调试?

在GDB 上下文 使用命令 core-file core.xxx

进程信号的状态

  • 信号递达:(Delivery)当一个信号发送给进程时,真正执行信号处理的动作称为“信号递达”。这是信号从产生到处理的最终步骤。

  • 信号未决:(Pending)信号产生后,如果暂时没有被处理(递达),那么这个信号就处于“未决”状态。它在等待适当的时机来执行处理。

  • 信号阻塞:(Block)进程可以选择暂时阻止某个信号的处理,称为“信号阻塞”。被阻塞的信号虽然会产生,但会保持在未决状态,不会被处理,直到进程解除对该信号的阻塞,信号才会递达。

阻塞和忽略不同。阻塞是延迟处理信号,而忽略是在信号递达之后,选择不做任何处理。

进程信号在内核中的表示

struct task_struct 
{
    
    // 使用位图的方式查看某一个信号的比特位的方式是否阻塞了对应的信号
    unsigned int pending = 0; 
    unsigned int block = 0; // 信号屏蔽字
    handler[];
};

阻塞伪代码

if(1 << (signo - 1) & pcb->block)
{
    // signo 信号是被阻塞的,不抵达
}
else{
    if(1 << (signo - 1) & pcb -> pending)
    {
        //  递达该信号
    }
}

typedef void *(hanlder_t)(int signo);// 函数指针

handler_t handler[32] = {0};

这是一个包含32个元素的函数指针数组,每个元素存储对应信号的处理函数。

  • 如果某个信号递达后,会调用对应的处理函数handler[signo]来执行信号处理。

  • 默认情况下,数组元素初始化为0,表示没有设置处理函数,系统可能会执行默认的信号处理行为(如终止进程)。

信号的捕捉流程

信号产生的时候不会被立即处理,是在合适的时候。

从内核态返回用户态的时候,进行处理

用户为了访问内核或者硬件资源,必须通过系统调用完成访问。

实际进行系统调用的是进程 但身份其实是内核。

但是往往系统调用比较费时间(尽量减少系统调用次数)

CPU寄存器分为可见寄存器 和不可见寄存器 和当前进程相关的 上下文数据都是在寄存器中

Cpu中 CR3 寄存器表示当前进程运行级别 使用INT80 陷入 内核操作切换

0代表内核态

3代表用户态

进程如何到OS中执行方法呢?

task_struct 进程 访问 mm_struct 虚拟地址空间 通过用户级别页表访问 物理内存 每个进程都有独立用户级别的页表。

每一个进程都有独立的地址空间(用户空间独占) 内核空间被映射到每个进程的(3-4)G 和共享空间类似。

进程访问OS的接口只需要在自己的进程地址空间上进行跳转就可以了

当一个进程需要执行操作系统的方法时,它要从“用户态”进入“内核态”,就像进程从自己专属的房间进入一个公共的操作系统大厅。

进程的独立地址空间:每个进程都有自己的虚拟地址空间,就像每个人有自己的房间,其中用户空间是私有的。虽然每个房间的布局不一样,但它们都有一个通向公共区域(内核空间)的门,这个门允许访问操作系统的功能。

进程访问操作系统的方法

  • 操作系统的方法(比如系统调用)可以被看作是一组公共服务。进程可以通过跳转到自己房间的门口,进入公共区域,来请求操作系统服务。

  • 这些“公共服务”(内核代码)是共享的,映射到每个进程的地址空间,因此每个进程都可以通过相应的入口点来调用。

什么是信号捕捉?

信号捕捉指的是当一个信号递达时,如果信号的处理动作是用户自定义的处理函数(而不是系统默认的处理方式),系统会暂时打断原来的程序执行,转而去执行这个用户定义的处理函数。这种过程被称为捕捉信号

信号捕捉的流程

  1. 进入内核:当程序在执行过程中发生中断、异常,或者执行系统调用时,程序会切换到内核态,进入内核模式。

  2. 检查待递达的信号:在内核处理完中断、异常或系统调用后,准备返回用户态时,会检查当前进程是否有未决的信号需要递达。

  3. 执行信号处理函数:如果有信号需要递达,并且该信号有用户定义的处理函数(自定义信号处理动作),内核会让程序转到用户定义的信号处理函数执行,而不是继续原来的程序主控制流程。

  4. 处理信号:信号处理函数会执行相应的操作,例如打印信息、清理资源等。这些代码是运行在用户空间的,与主程序使用不同的堆栈空间。

  5. 特殊系统调用恢复主流程:当信号处理函数完成后,会通过一个特殊的系统调用(如sigreturn)再次进入内核态。

  6. 返回主程序:内核检查是否有新的信号需要递达,如果没有,则返回到用户模式,继续执行之前被打断的主程序。

如何实现信号的捕捉

  1. 注册信号处理函数:用户程序需要通过signalsigaction等函数注册自定义的信号处理函数,这个函数会在信号递达时被调用。

  2. 信号的产生和递达:当程序运行时,可能会因为各种原因(如用户按键、软件中断、异常)产生信号,操作系统会将这些信号递达给进程。

  3. 内核切换到信号处理函数:当信号递达时,如果有自定义的处理函数,内核会安排程序去执行该函数,而不是继续主控制流程。

通俗总结

信号捕捉就像程序被打断去执行一个紧急任务。比如,你正在看电影(主程序),突然接到一个电话(信号)。你暂停电影,接电话(信号处理函数)。接完电话后,回到之前的位置继续看电影。这整个过程就是信号捕捉的实现方式。

信号集操作函数

sigset_t

信号集及信号集操作函数:信号集被定义为一种数据类型:

typedef struct{
  unsigned long sig[_NSIG_WORDS];
} sigset_t;

sigprocmask

更改进程的pending表

#include <signal.h>
int sigprocmask(int how, const sigset_t *restrict set,sigset_t *restrict oset);
  • 如果oset为非空指针。读取进程的当前信号屏蔽字通过oset参数传出

  • 如果set为非空指针则更改进程信号的屏蔽字参数how 指示如何更改。

  • 如果oset 和 set 都是非空指针 将原来的信号屏蔽字备份到oset里面然后根据set 和 how 参数更改信号屏蔽字

how 参数的可选值 如果信号屏蔽字为mask

SIG_BLOCK : mask = mask | set

SIG_UNBLOCK : mask = mask &~ set

SIG_SETMASK : mask = set

返回值成功为0出错为-1

sigpending

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

检查pending信号集,通过set参数传出

调用成功则返回0,出错则返回-1。

实验代码

#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
​
#define BLOCK_SIGNAL 2 // 定义要屏蔽的信号号(在这里是信号2,对应SIGINT)
#define MAX_SIGNUM 31  // 最大信号号,用于遍历所有信号
​
// 显示待处理的信号集
static void show_pending(const sigset_t &pending) {
    for(int signo = MAX_SIGNUM; signo >= 1; signo--) {
        if(sigismember(&pending, signo)) {
            cout << 1; // 如果信号集包含该信号,则输出1
        }
        else {
            cout << 0; // 如果信号集不包含该信号,则输出0
        }
    }
    cout << endl;
}
​
// 信号处理函数
void handler(int signo) {
    cout << " xxxx 调用成功 " << signo << endl; // 当接收到信号时,打印信号编号
}
​
int main() {
    // 声明信号集,用于屏蔽和恢复
    sigset_t block, oblock, pending;
​
    // 注册信号处理函数,处理BLOCK_SIGNAL(信号2)
    signal(BLOCK_SIGNAL, handler);
​
    // 初始化信号集
    sigemptyset(&block);   // 清空block信号集
    sigemptyset(&oblock);  // 清空oblock信号集
​
    // 添加要屏蔽的信号
    sigaddset(&block, BLOCK_SIGNAL); // 将BLOCK_SIGNAL添加到block信号集中
​
    // 设置屏蔽信号集
    sigprocmask(SIG_BLOCK, &block, &oblock);
    // sigprocmask函数用于更改信号屏蔽集:
    // 第一个参数SIG_BLOCK表示将block信号集加入到当前屏蔽集。
    // 第二个参数是要设置的信号集。
    // 第三个参数保存之前的信号屏蔽集(用于后续恢复)。
​
    // 循环遍历打印待处理的信号集
    int cnt = 5;
    while(true) {
        // 初始化
        sigemptyset(&pending); // 清空pending信号集
        
        // 获取当前待处理的信号集
        sigpending(&pending); // 获取当前阻塞的信号,并存储在pending中
​
        // 打印待处理的信号集
        show_pending(pending);
​
        // 暂停1秒
        sleep(1);
​
        // 递减计数器
        cnt--;
​
        if(!cnt) {
            cout << " 恢复对信号的屏蔽 " << endl;
            sigprocmask(SIG_SETMASK, &oblock, &block);
            // sigprocmask函数在这里用于恢复屏蔽状态:
            // SIG_SETMASK表示将屏蔽集设置为oblock(之前的屏蔽集)。
        }
    }
​
    return 0;
}
​

可重入函数

80%的函数是不可

重入发生在同一个函数被不同的控制流程同时调用时。比如在执行某个函数的过程中,如果遇到中断或信号,系统可能会再次调用该函数。

这种情况下,如果函数在第一次调用时尚未完成,就可能再次进入执行。

什么是重入

main函数调用函数执行后收到中断信号 ,中断进程信号进程切换 信号处理函数sighandler,并调用main函数相同的函数

可重入与不可重入的判断

  • 不可重入函数:如果函数访问全局数据或共享资源(如全局链表、堆内存、标准I/O库函数等),就可能因重入导致数据错乱。

  • 可重入函数:如果函数只访问局部变量或参数,则不会受到重入的影响,因为这些数据存储在独立的栈帧中,彼此独立。

volatile

C关键字

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

在编译器 O2 O3 O1 优化的情况下 可能会导致一些其他的问题

SIGCHLD信号

  • SIGCHLD信号是在子进程终止时发送给其父进程的信号。默认处理动作是忽略。父进程可以通过自定义SIGCHLD信号的处理函数来清理子进程,避免产生僵尸进程。

处理僵尸进程的方式

  1. 等待子进程结束:使用waitwaitpid函数阻塞等待子进程结束并清理它。这会阻塞父进程的执行。

  2. 轮询查询子进程状态:父进程在处理自己的任务时定期检查子进程是否结束,这种方式实现较为复杂。

利用SIGCHLD信号避免轮询

  • 子进程终止时会给父进程发送SIGCHLD信号。父进程可以自定义SIGCHLD的处理函数,在信号处理函数中调用wait来清理子进程。

  • 这样,父进程可以专注于自身的任务,不必主动检查子进程状态。子进程终止时会通知父进程,触发信号处理函数来进行清理。

通过忽略SIGCHLD信号避免僵尸进程

  • 父进程可以通过sigactionSIGCHLD的处理动作设置为SIG_IGN(忽略),这样子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

  • 在Linux系统上,这种方法通常有效,但在其他UNIX系统上可能不完全兼容。