一、信号的概念:
①进程必须识别+能够处理信号,信号没有产生,也要具备处理信号的能力---信号的处理能力属于进程内置功能的一部分
②进程即便是没有收到信号,也能知道哪些信号该怎么处理。
③当进程真的受到了一个具体的信号的时候,进程可能并不会立即处理这个信号,而会在合适的时候处理。
④一个进程必须当信号产生到信号开始被处理,就一定会有时间窗口,要求进程具有临时保存哪些信号已经发生了的能力。

一点五、详谈ctrl c:

1、Ctrl c为什么能够杀掉我们前台进程呢?
Linux中一次登录中,一个终端一般会配上一个bash进程,每一次登录只允许一个进程为前台进程,可允许多个进程是后台进程。而前后台进程的区别,取决于谁来获取键盘输入(获取键盘输入的为前台进程)。
ctrl c本质是被进程解释成为收到了信号(是2号信号,默认动作就是终止自己)。

2、从硬件的角度,谈谈键盘数据是如何输入给内核的?ctrl c又是如何变成信号的?

a.键盘读取本质上是把外设数据拷贝到键盘文件对应的缓冲区中(输入的过程),操作系统可以通过库函数(如read/scanf/fget)通过文件把数据读到对应的进程缓冲区/用户缓冲区中。
b.键盘在硬件上可以给CPU发送硬件中断,每一个中断都有中断号,通过各中断单元向CPU特定针脚发送中断后,CPU会保存相应的中断号,从而完成了键盘向CPU,通知其数据已就绪的任务(显示器,网卡等设备同理)。
c.在操作系统内,都会在操作系统开机启动的时候,形成一张中断向量表,存储直接访问外设方法(主要是磁盘,还有显示器键盘等设备)的地址。其中,中断表的下标是中断号。
CPU中的寄存器保存数据的本质上是对CPU寄存器充放电的过程。

对于Ctrl c这样的组合键呢,OS会判断输入的是数据还是控制,将ctrl c这样的预定义信号转化为2号信号发送给进程。

综上所述,操作系统就是通过不断向外设接收中断,从而处理外设的。而我们学习的信号就是用软件的方式对进程模拟的硬件中断。
(信号是进程之间事件异步通知的一种方式,属于软中断。)

二、信号的产生:

1. 键盘组合键:
ctrl c   发送2号信号。
ctrl \   发送3号信号。
ctrl z   发送19号信号。
不是所有的信号都是可以被signal捕捉的,9和19号无法被捕捉。这两个进程都跟进程的执行有关。

2. kill命令:kill -signo pid

3、系统调用接口: 

设置对特定进程的自定义处理方法(信号编号,自定义动作)(返回函数指针类型)

①修改特定进程对于信号的处理动作:

//mysignal.cc

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

using namespace std;

//int: 收到了哪一个信号
void myhandler(int signo)
{
    cout << "process get a signal: " << signo << endl;
    // exit(1);
}

int main()
{
    signal(SIGINT, myhandler); // 只需要设置一次,往后都有效!
    signal(3, myhandler);
    signal(19, myhandler); // 无法被捕捉 
    // 信号的产生和我们自己的代码的运行是异步的

    while(true)
    {
        cout << "I am a crazy process" << endl;
        sleep(1);
    }
}

② 给任意进程发任意信号:

 

//mykill.cc

#include <iostream>
#include <string>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

using namespace std;

void Usage(string proc)
{
    cout << "Usage:\n\t" << proc << " signum pid\n\n";
}

// mykill signum pid
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int signum = stoi(argv[1]);
    pid_t pid = stoi(argv[2]);

    int n = kill(pid, signum);
    if(n == -1)
    {
        perror("kill");
        exit(2);
    }

    return 0;
}

 ③给调用者发送指定信号:

 

#include <iostream>
#include <string>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

using namespace std;

int main()
{
    int cnt = 5;
    while(true)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        sleep(1);
        cnt--;
        if(cnt == 0) raise(2); // 相当于kill(getpid(), sig)
    }
}

 ④引起一个正常进程进行终止:(跟直接kill -6 pid的效果不一样)

#include <iostream>
#include <string>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

using namespace std;

int main()
{
    int cnt = 5;
    while(true)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        sleep(1);
        cnt--;
        if(cnt == 0) abort(); // 效果好和kill(getpid(), 6)不一样:不仅发送6号信号,还强行让进程终止。
    }
}

4、异常:
为什么除0/野指针会让进程崩溃呢?
为什么除0/野指针会给进程发信号呢?

①当前进程在执行时发生除0操作时,CPU中状态寄存器中的溢出标志位由无效改为有效。
进程在被调度期间,CPU寄存器里储存的都是当前进程的上下文。一旦出现异常,对应的状态寄存器由0置1,故该进程是否出异常与进程切换无关,不会影响其他进程。该设计从调度和硬件层面上保证了进程之间的独立性。
任何异常只会影响进程本身,不会波及到操作系统。但操作系统必须要知道该进程出异常,因为CPU是硬件,操作系统是硬件的管理者。故操作系统向进程发信号,使该进程崩溃。

②该进程指向进程地址空间通过其页表访问其代码和数据。
前几篇文章我们谈过,页表是一个软件结构,通过虚拟与物理地址的关系映射找到物理内存。为了提高效率,页表查表的过程并不是由软件完成,而是由一个MMU(内存管理单元)来完成的(MMU在当代芯片当中已经被集成在了CPU内部)。
访问野指针时,虚拟地址到物理地址转换失败,将把转化失败的地址放入CPU中的某寄存器中。CPU内部会出现硬件报错,这也能被OS识别到。
当然,如果进程不崩溃,那它就要一直被调度运行。从而实现在上层给用户更大的宽容度。

//mysignal.cc

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void handler(int signo)
{
    cout << "...get a sig, number: " << signo << endl; // 仅起到捕捉打印作用
    exit(1);
}

// 此处我们会观察到信号一直被触发
int main()
{
    // signal(SIGFPE, hand  ler);
    signal(SIGSEGV, handler);
    // cout << "div before" << endl;
    cout << "point error before" << endl;

    sleep(5);

    // int a = 10;
    // a /= 0; // 异常:Floating point exception(信号8)

    int *p = nullptr; // 野指针
    *p = 100; //段错误: Segmentation fault(信号11)
    // cout << "div after" << endl;
    cout << "point error after" << endl;

    sleep(1);
    return 0;
}

5. 软件条件:异常不只会由硬件产生。

①软件资源不就绪的情况:

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void handler(int signo)
{
    cout << "...get a sig, number: " << signo << endl; // 仅起到捕捉打印作用
    exit(1);
}

// 此处我们会观察到信号一直被触发
int main()
{
    signal(SIGSEGV, handler);

    sleep(5);


    char buffer[1024];
    int n = 1024;
    n = read(4, buffer, n); // 4号文件默认不打开
    printf("n = %d\n", n);
    perror("read"); // read: Bad file descriptor

    sleep(1);
    return 0;
}

②特殊事件:闹钟(返回值为剩余时间)。

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void work()
{
    cout << "print log..." << endl;
}

void handler(int signo)
{
    work();
    cout << "...get a sig, number: " << signo << endl; // 仅起到捕捉打印作用
}

// 此处我们会观察到信号一直被触发
int main()
{
    signal(SIGALRM, handler);
    int n = alarm(5); // 闹钟不是异常,只响一次

    while(1)
    {
        cout << "proc is running..." << endl;
        sleep(1);
    }
    
    sleep(1);
    return 0;
}

③再谈进程等待: 

 

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

// 再谈进程等待
int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        int cnt = 500;
        while(cnt)
        {
            while(cnt)
            {
                cout << "i am a child prcess, pid: " << getpid() << " cnt: " << cnt << endl;
                sleep(1);
                cnt--; 
            }

            exit(0);
        }

        // father
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if(rid == id)
        {
            cout << "child quit info, rid: " << rid << " exit code: " << 
                ((status>>8)&0xFF) << "exit signal: " << (status&0x7F) << 
                " core dump: " << ((status>>7)&1) << endl;
        }
    }
}
// Makefile

mysignal:mysignal.cc
	g++ -o $@ $^ -g -std=c++11
.PHPNY:clean
clean:
	rm -f mysignal

虚拟机在进行除零错误的时候,一段程序崩溃,对应的当前目录下会产生一个临时文件xxx.core,而默认云服务器上面的core功能是被关闭的(保证服务重启的功能一直有效,会形成code dump文件来冲击磁盘,影响服务)。打开系统的core domp功能,一旦进程出异常,OS会将进程在内存中的运行信息dump(转储)到进程的当前目录(磁盘)中,形成core.pid文件:核心转储(core dump)。这样做能够直接复现问题之后,直接定位到出错行。这种先运行再生成core-file称为事后调试。

总结:这些都是信号产生的方式,但是无论信号如何发生,最终一定是OS发送给进程的。因为OS是进程的管理者。

三、信号的发送与保存:

1、对于普通信号而言,对于进程而言,自己有还是没有,收到哪一个信号,是给进程的PCB发。

2、OS用比特位的位置来表示是哪个信号:用01来描述信号,用位图来管理信号。
①比特位的内容是0还是1,表示是否收到。
②比特位的位置(第几个),表示信号的编号。
③所谓“发信号”,本质就是OS去修改task_struct的信号位图对应的比特位。

2、信号保存的原因:进程收到信号之后可能不会立即处理这个信号。信号不会被处理,就要有一个时间窗口。

3、信号的范围[1,31],每一种信号都要有自己的一种处理方法。Handler数组中的函数指针指向操作系统提供的默认方法方法。如果用户自己提供了方法,那么,就将用户提供方法的地址填入该数组(signal方法)。

4、阻塞信号:

①常见概念:
 · 实际执行信号的处理动作称为信号递达(Delivery)
 · 信号从产生到递达之间的状态,称为信号未决(Pending)。
 · 进程可以选择阻塞 (Block )某个信号。
 · 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
 · 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

②内核表示:

pending表表示,当前进程是否信收到了信号以及收到了哪些信号。
block表表示,进程信号是否被屏蔽。
hander表表示,每种信号对应的处理关系。
我们通过以上所述的三张表和两张位图,和一个函数指针数组就可以,实现对普通信号的记录、保存相关的管理工作了。

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

③s igset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
④信号级操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set); // 初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
int sigfillset(sigset_t *set); // 初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
// 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
 · sigprocmask: 读取或更改进程的信号屏蔽字 ( 阻塞信号集 )

 ·  sigpending:读取当前进程的未决信号集。

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending(sigset_t &pending)
{
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << "\n\n";
}

void handler(int signo)
{
    cout << "catch a signo: " << signo <<endl;
}

int main()
{
    // 0、对2号信号进行自定义捕捉
    signal(2, handler);
    // 1、先对2号信号进行屏蔽——数据预备
    sigset_t bset, oset; // 在用户区栈上开辟的空间
    sigemptyset(&bset);
    sigemptyset(&oset);
    sigaddset(&bset, 2); // 此处还未将2号信号屏蔽,因为并没有将数据设置进入进程的task_struct

    // 1.2 调用系统调用,将数据设置进内核
    sigprocmask(SIG_SETMASK, &bset, &oset); // 此处已将2号信号屏蔽

    // 2、重复打印当前进程的pending 00000000000000000000000000000000
    sigset_t pending;
    int cnt = 0;
    while (true)
    {
        // 2.1 获取
        int n = sigpending(&pending);
        if (n < 0)
            continue;
        // 2.2 打印
        PrintPending(pending);

        sleep(1);
        cnt++;
        // 2.3 解除阻塞
        if (cnt == 20)
        {
            cout << "unblock 2 signo" << endl;
            sigprocmask(SIG_SETMASK, &oset, nullptr);
        }
    }
    // 3、发送2号 00000000000000000000000000000010

    return 0;
}
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending(sigset_t &pending)
{
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << "\n\n";
}

void handler(int signo)
{
    cout << "catch a signo: " << signo <<endl;
}

int main()
{
    // 4、 思考:如果我们将所以信号全部屏蔽,那么信号不就都不会处理了吗?9和19号信号不仅不可被捕捉,也不可被屏蔽。
    sigset_t bset, oset;
    sigemptyset(&bset);
    sigemptyset(&oset);
    for(int i = 1; i <= 31; i++)
    {
        sigaddset(&bset, i); // 此处还未将所有信号屏蔽,因为并没有将数据设置进入进程的task_struct
    }
    sigprocmask(SIG_SETMASK, &bset, &oset);

    sigset_t pending;
    while(true)
    {
        // 2.1 获取
        int n = sigpending(&pending);
        if (n < 0)
            continue;
        // 2.2 打印
        PrintPending(pending);

        sleep(1);
    }

    return 0;
}

 四、信号的处理:

1、信号的处理方式:
①默认动作。
②忽略。
③自定义动作(信号的捕捉)。

2、信号是什么时候被处理的?
 · 当我们的进程从内核态返回到用户态的时候,进行信号的检测与处理。典型的例子就是调用系统调用(此时操作系统是自动会做身份切换的)。
 · CPU可以响应来自外部的中断,让操作系统执行。它自己也可以在它自己内部直接产生中断,称为int 80:x86英特尔CPU下,从用户态陷入内核态的汇编语句。

3、信号是如何被处理的?(重谈地址空间3)
 · 内核空间映射的是操作系统的代码和数据。
和用户空间一样的是,内核空间也需要内核级页表和操作系统中的代码和数据建立映射。(不过和用户空间不一样的是,内核空间和物理内存中操作系统的代码和数据也可以直接建立映射。)
 · 假设有多个进程,有几个进程就有几份用户级页表---因为进程具有独立性。而内核级页表只有一份。所以每一个进程看到的3~4GB的东西都是一样的。所以进程再怎么切换,3~4GB空间的内容是不变的。
站在进程角度,在调用系统调用方法时,相当于在自己的地址空间里调用该方法,调用后直接返回到自己的地址空间里。
站在操作系统角度,任何一个时刻都有进程执行,用户想执行操作系统的代码就可以随时执行。
操作系统的本质:基于时钟中断的一个死循环。计算机硬件中有一个时钟芯片,每个很短的时间,向计算机发送时钟中断。当操作系统检测到时钟信号到来时,CPU就会执行时钟中断所对应的方法。
 · CPU有一个ecs寄存器(代码段寄存器)。当它最低的两个比特位(低两位权限位)为00时则为内核态,允许访问操作系统的代码和数据;为11时,则为用户态,只能访问用户自己的代码。CPU为用户提供了int 80(陷入内核)方法来更改CPU的工作级别。

上图整个过程进行4次身份切换,1次信号检测。 

4、信号捕捉:

//mysignal.cc

#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>

using namespace std;

//问题1: pending位图,什么时候从1->0. 执行信号捕捉方法之前,先清0,在调用
//问题2: 信号被处理的时候,对应的信号也会被添加到block表中,防止信号捕捉被嵌套调用

 void PrintPending()
 {
     sigset_t set;
     sigpending(&set);

     for (int signo = 1; signo <= 31; signo++)
     {
         if (sigismember(&set, signo))
             cout << "1";
         else
             cout << "0";
     }
     cout << "\n";
 }

 void handler(int signo)
 {
     cout << "catch a signal, signal number : " << signo << endl;
     while (true)
     {
         PrintPending();
         sleep(1);
     }
 }

 int main()
 {
      struct sigaction act, oact;
      memset(&act, 0, sizeof(act));
      memset(&oact, 0, sizeof(oact));

      sigemptyset(&act.sa_mask);
      sigaddset(&act.sa_mask, 1);
      sigaddset(&act.sa_mask, 3);
      sigaddset(&act.sa_mask, 4);
      act.sa_handler = handler; // SIG_IGN SIG_DFL
      sigaction(2, &act, &oact);

      while (true)
      {
          cout << "I am a process: " << getpid() << endl;
          sleep(1);
      }

     return 0;
 }

5、可重入函数:

现象:insert函数被main和handler执行流重复进入(重入)了。导致节点丢失,内存泄漏。这被称为不可重入函数,否则为可重入函数。(目前学到的大部分函数都是不可重入的。)

6、volatile关键字:防止编译器过度优化,保持内存的可见性:

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>

using namespace std;

volatile int flag = 0;

void handler(int signo)
{
    cout << "catch a signal: " << signo << endl;
    flag = 1;
}

int main()
{
    signal(2, handler);
    // 在优化条件下, flag变量可能被直接优化到CPU内的寄存器中
    while(!flag); // flag 0, !falg 真

    cout << "process quit normal" << endl;
    return 0;
}

7、SIGCHID:子进程退出时,会主动向父进程发送SIGCHID17号信号。
 · 子进程在进行等待的时候,我们可以采用基于信号的方式进行等待。
 · 等待的好处:
①获取子进程的退出状态,释放子进程的僵尸。
②虽然不知道父子谁先进行,但是我们清楚,一定是父进程最后退出。
 · 不过还是要调用wait/waitpid这样的接口,父进程必须保证自己是一直在运行的。

#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

// 必须要等待吗?必须要调用wait吗?
// void handler(int signo)
// {
//     sleep(5);
//     pid_t rid;
//     while ((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
//     {
//         cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;
//     }
// }

int main()
{
    signal(17, SIG_IGN); // SIG_DFL -> action -> IGN

    // srand(time(nullptr));
    // signal(17, handler);
    // 如果我们有10个子进程同时退出呢?
    // 如果退出一半呢?
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            while (true)
            {
                cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
                sleep(5);
                break;
            }
            cout << "child quit!" << endl;
            exit(0);
        }
        // sleep(rand()%5+3);
        sleep(1);
    }
    // father
    while (true)
    {
        cout << "I am father process: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐