目录

一.信号快速认识

一).生活角度

二).应用角度

1.信号认识样例

2.系统函数signal

三).信号概念

1.查看信号

2.信号处理

二.信号学习

一).信号产生

1.基本操作

2.理解OS得知键盘有数据

3.系统命令向进程发信号

4.函数产生信号

1).kill函数

2).raise函数

3).abort函数

5.软件条件产生信号

1).alarm验证-IO效率问题

2).如何理解软件条件

3).简单快速理解系统闹钟

6.硬件异常产生信号

1).模拟除0

2).模拟野指针

3).子进程退出core dump​编辑

4).Core Dump - 核心转储

二).信号保存

1.信号其他相关常见概念

2.信号在内核中的表示

3.sigset_t

4.信号操作函数

1).sigprocmask

2).sigpending

三).信号捕捉

1.信号捕捉的流程

2.sigaction

3.操作系统是怎么运行的

1).硬件中断

2).时钟中断

3).软中断

4.理解用户态和内核态

三.可重入函数

四.volatile

五.SIGHLD信号



一.信号快速认识

一).生活角度

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递“。
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”。
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏。
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。

结论:

  • 怎么能识别信号呢?识别信号是内置的,进程识别信号,是内核程序员写的内置特性。
  • 信号产生之后,你知道怎么处理吗?知道。如果信号没有产生,你知道怎么处理信号吗?知道。所以,信号的处理方法,在信号产生之前,已经准备好了。
  • 处理信号,立即处理吗?我可能正在做优先级更高的事情。不会立即处理,什么时候?合适的时候。
  • 怎么进行信号处理?a.默认 b.忽略 c.自定义, 后续都叫做信号捕捉。
  • 信号的产生与进程的运行是异步的。

二).应用角度

1.信号认识样例

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

int main()
{
    while(true){
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}
  • 用户输入执行命令,在Shell下启动一个前台进程
  • 用户按下 Ctrl+C(发送2号信号) ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程。
  • 前台进程因为收到信号,进而引起进程退出

2.系统函数signal

signal函数将signum信号的处理动作修改为handler

参数:

  • signum:信号编号[只需要知道是数字即可]。
  • handler:函数指针,表示更改信号的处理动作,当收到对应的信号,就回调执行handler方法。

Ctrl+C 的本质是向前台进程发送 SIGINT 即 2 号信号

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


void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber <<std::endl;
}

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;
    signal(SIGINT,handler);  //自定义处理
    while(true){
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}

注意:

  • signal函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用!!
  • Ctrl-C 产生的信号只能发给前台进程一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。Shell可以同时运行一个前台进程和任意多个后台进程。
  • (1)jobs:查看所有的后台任务。(2)Ctrl + z:将前台进程切换到后台,并停止进程。(3)fg + 任务号:将特定的进程提到前台。(4)bg + 任务号:让后台进程恢复运行。
  • 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

三).信号概念

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

1.查看信号

使用 kill -l 命名可以查看信号

这里只关系前面31个信号,后面的信号为实时信号不做讨论。 使用 man 7 signal 可以在手册中查看各个信号的默认处理动作。

2.信号处理

可选的处理动作有以下三种:

  • 忽略此信号
#include <iostream>
#include <unistd.h>
#include <signal.h>

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    signal(SIGINT,SIG_IGN); // 设置忽略信号的宏
    while(true){
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}

  • 执行该信号的默认处理动作
#include <iostream>
#include <unistd.h>
#include <signal.h>

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    signal(SIGINT,SIG_DFL); // 设置忽略信号的宏
    while(true){
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}

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

前面已经有例子了,就不再写一遍了。


二.信号学习

我们将从三个阶段学习信号。

一).信号产生

1.基本操作

  • Ctrl + c:给前台进程发送SIGNIT信号,即2号信号,终止进程。
  • Ctrl + \:给前台进程发送SIGQUIT信号,即3号信号,终止进程并生成core dump文件,用于事后调试。
  • Ctrl + z:给前台进程发送SIGTSTP信号,即20号信号,停止进程,将当前前台进程挂起到后台。

2.理解OS得知键盘有数据

信号其实是从软件上模拟硬件中断的行为,只不过硬件中断是发给CPU的,而信号是发给进程的,两者有相似性,但是层级不同

3.系统命令向进程发信号

kill -信号编号 进程pid:向pid进程发送信号编号信号。

常用的为 kill -9 pid,该命令用于杀掉pid进程,并且9号信号(SIGKILL)不能进行信号自定义捕捉

4.函数产生信号

1).kill函数

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

参数:

  • pid:发送信号的目标进程pid号。
  • sig:发送的信号的编号。
#include <iostream>
#include <unistd.h>
#include <signal.h>


void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber <<std::endl;
}


int main()
{
    signal(SIGINT, handler);
    while(true)
    {
        std::cout << "I'm a process, I am waiting signal! " << std::endl;
        int n = kill(getpid(), 2);
        sleep(1);        
    }
    return 0;
}

自身循环向本进程发送2号信号。 

2).raise函数

raise 函数可以给当前进程发送指定的信号(自己给自己发信号)

参数:

  • sig:要发送的信号编号。

返回值:

  • 发送成功返回0,发送失败返回非0。
#include <iostream>
#include <unistd.h>
#include <signal.h>


void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber <<std::endl;
}


int main()
{
    signal(2,handler);// 先对2号信号进行捕捉
    // 每隔1s,自己给自己发送2号信号
    while(true){
        sleep(1);
        raise(2);
    }
}

每隔1s,自己给自己发送2号信号

3).abort函数

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

就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。

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


void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber <<std::endl;
}

int main()
{
    signal(SIGABRT, handler);
    while(true)
    {
        sleep(1);
        abort();
    }
}

abort给自己发送的是固定6号信号,虽然捕捉了,但是还是要退出。

5.软件条件产生信号

SIGPIPE 是一种由软件条件产生的信号,在“管道”中已经认识过了。现在主要介绍 alarm 函数SIGALRM 信号。

调用该函数的进程过seconds秒发送一个SIGALRM(14号)信号

参数:

  • seconds:seconds表示秒数。若 seconds 为 0,表示取消上次的闹钟,返回值为上次闹钟剩余时间。

返回值:

  • 返回上一个alarm函数剩余的秒数,如果上一个 alarm 没有剩余时间,则返回0。

调用 alarm 函数可以设定一个闹钟,告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号,该信号的默认处理动作使终止当前进程。

这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

1).alarm验证-IO效率问题

程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止。

//text1.cc
//IO多

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

int main()
{
    int count=0;
    alarm(1);
    while(true){
        std::cout << "count : "<< count << std::endl;
        count++;
    }
    return 0;
}
//text2.cc
//IO少

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

long long count = 0;
 
void handler(int signumber)
{
    std::cout << "count: " << count << std::endl;
    exit(0);
}
 
int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while(true)
    {
        count++;
    }
    return 0;
}

第一份代码是每次count++都进行打印,是IO操作多的代码。第二份代码是count++ 一秒后,打印一次。

2).如何理解软件条件

       在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向读端已关闭的管道写数据产生的 SIGPIPE 信号)。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生。

3).简单快速理解系统闹钟

所谓的系统闹钟在内核当中就是一种数据结构,设置闹钟就是创建闹钟对象,每个闹钟都有自己的过期时间,并且有当闹钟超时对应的的执行方法。

内核中的定时器数据结构:

struct timer_list 
{
    struct list_head entry;
    unsigned long expires;    //过期时间
    void (*function)(unsigned long);    //对应执行方法
    unsigned long data;
    struct tvec_t_base_s *base;
};

可以使用一个最小堆的结构来维护闹钟对象,堆顶每次都是过期时间最早的闹钟对象,当闹钟对象超时的时候,将该对象从堆顶取出,执行对应方法。 

6.硬件异常产生信号

硬件异常本质是硬件错误被硬件检测以某种方式检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为 SIGFPE 信号发送给进程。再比如当前访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程。

1).模拟除0
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdio>

 
void handler(int signum)
{
    printf("catch a sig: %d\n", signum);
}
 
int main()
{
    signal(8, handler);  // 8 SIGFPE
    sleep(1);
    int a = 10;
    a /= 0;
 
    while(true);
    return 0;
}

当执行到除0指令时,会发送8号信号,但是因为修改了8号信号的默认处理动作,所以进程没有退出,当信号的处理动作执行完毕之后,除0指令会被再次执行,所以会循环的发送8号信号,导致上述结果。

CPU中存在标志寄存器 EFLAGS,是由 32 或 64 个 bit 位组成,其中有一个 bit 位表示 CPU 当前的计算是否溢出。当发生除 0 错误的时候,操作系统识别到 CPU 中的标志寄存器有溢出错误,从而给当前进程发送 8 号信号。所以除 0 错误是由于硬件异常从而产生信号导致程序崩溃的。

2).模拟野指针
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdio>

void handler(int signum)
{
    printf("catch a sig: %d\n", signum);
}
 
int main()
{
    signal(SIGSEGV, handler);
    sleep(1);
    int *p = nullptr;
    *p = 100;
    
    while(1);
    return 0;
}

这里将指针p初始化为空指针,尝试对空指针进行解引用操作并赋值,但空指针并不指向有效的内存地址,对其进行解引用操作会访问非法内存,这种行为就会触发段错误。便会向该进程发送 SIGSEGV (11)信号。

CPU 中有个CR3寄存器,该寄存器保存的是页目录表的物理地址。CPU中还有一个内存管理单元MMU,其中有一个功能就是进行虚拟地址到物理地址的转换。用户层使用的都是虚拟地址,上述错误是 CPU 将虚拟地址给到 MMU ,然后 MMU 通过 CR3 中的页表进行地址转换,然后因为页表中并没有 0 号地址的映射关系,所以转换失败,操作系统识别到 MMU 硬件报错,所以给当前进程发送 11 号信号,导致进程崩溃。

3).子进程退出core dump

wait和waitpid都有一个status参数,该参数是一个输出型参数。如果传递NULL,表示不关心子进程的退出状态信息。status不能简单的当作整型来看待,可以当作位图来看待,具体如上图(只研究status低16位)。

  • 进程正常退出时只有8-15位有效,表示进程退出码。
  • 进程异常退出或是被信号杀掉时,0-6位表示信号编号,而第7位为core dump 标志位。当core dump 标志位为 1 时,表示进程异常退出后会在工作目录下产生 core 文件,用于事后调试,当 core dump 标志位为 0 时,表示不会产生 core 文件。
4).Core Dump - 核心转储

SIGINT(2)信号默认处理动作是终止进程,SIGQUIT(3)信号默认处理动作是终止进程并Core Dump。

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到当前路径下,文件名通常是core,这就叫做Core Dump。事后可以用调试器检测 core 文件以查清错误原因,这叫做事后调试(Post-mortem Debug)。 使用 -g 选项进行编译,然后使用 gdb 进行调试,使用命名 core-file [core文件名] 可以直接定位到出错的行号。

因为云服务器大部分情况下都是生产环境,大部分程序一出现问题就会自动重启,这样就会产生大量的 core 文件,会很浪费磁盘空间,所以在云服务器上,core dump 功能默认是禁止的。

 一个进程允许产生多大的 core 文件取决于进程的 Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息。

在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生core文件。ulimit -a 命令用于显示当前用户的所以资源限制设置ulimit -c [size] 命令可以改变core文件的大小为size,就可以打开 core dump 功能。

总结:上述所说的所有信号产生,最终都要由操作系统来进行执行,因为操作系统是进程的管理者,也是软硬件资源的管理者。 

二).信号保存

1.信号其他相关常见概念

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

2.信号在内核中的表示

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针数组,表示每个信号的处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

如上图所示。SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作 SIG_DFL。SIGINT 信号产生过,但是正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但是没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。

如果在进程解除对某信号的阻塞之前这种信号产生过多次,POSIX.1 允许系统递送该信号一次或多次。而在 Linux 系统中,常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

3.sigset_t

从上图看,每个信号只有一个 bit 的未决标志,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储。

sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞;在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),凡是在信号屏蔽字中的信号,都处于阻塞状态,不能被递达。

sigset_t 也可以简单的理解一个整型,每个bit位的0,1表示该 bit 位对应的信号是否在该信号集中。

4.信号操作函数

sigset_t 类型对于每种信号用一个bit表示“有效”或“无效”状态,使用者只能调用以下函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量没有任何意义。

sigemptyset函数
头文件:
    #include <signal.h>
 
原型:
    int sigemptyset(sigset_t* set);
 
参数说明:
    set:指向一个信号集。
 
返回值:
    成功时返回0,失败返回-1。
 
功能:
    sigemptyset函数初始化set指向的信号集,使其中所有信号的对应bit位清零,表示该信号集不包含 
    任何有效信号
sigfillset函数
头文件:
    #include <signal.h>
 
原型:
    int sigfillset(sigset_t* set);
 
参数说明:
    set:指向一个信号集。
 
返回值:
    成功返回0,失败返回-1。
 
功能:
    初始化set所指向的信号集,使其中所有信号的对应bit位置1,表示该信号集的有效信号
    包括系统支持的所有信号
sigaddset函数
头文件:
    #include <signal.h>
 
原型:
    int sigaddset(sigset_t* set, int signo);
 
参数说明:
    set:指向一个信号集。
    signo:信号编号。
 
返回值:
    成功返回0,失败返回-1。
 
功能:
    给set指向的信号集中,signo信号的bit位置1,表示给信号集中加入signo信号。
sigdelset函数
头文件:
    #include <signal.h>
 
原型:
    int sigdelset(sigset_t* set, int signo);
 
参数说明:
    set:指向一个信号集。
    signo:信号编号。
 
返回值:
    成功返回0,失败返回-1。
 
功能:
    给set指向的信号集中signo信号的对应bit位置0,表示将signo信号从该信号集中去除。
sigismemeber函数
头文件:
    #include <signal.h>
 
原型:
    int sigismember(const sigset_t* set, int signo);
 
参数说明:
    set:指向一个信号集。
    signo:信号编号。
 
返回值:
    如果该信号对应的bit位在信号集中为1,返回1,反之,返回-1。
 
功能:
    测试signo信号是否在信号集中,如果signo在set指向的信号集中,返回1,如果不在返回-1。

注意:在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

1).sigprocmask

调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。就是谁调用就获取、设置、更新谁的block表。

参数:

  • how:表示信号集合中的信号被设置之后的状态。
  • set:指向被设置的信号集合。
  • oset:指向修改之前的信号集合。

返回值:

  • 成功返回0,失败返回-1。

下表说明how参数的可选值。mask 指的是当前进程 block 表示的信号屏蔽字,set 表示待设置的信号集。

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

2).sigpending

读取当前进程的未决信号集,通过set参数传出。就是获取penging信号集,修改方法就是信号产生。

参数:

  • set:set指向一个信号集

返回值:

  • 成功返回0,出错返回-1
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>


void PrintPending(sigset_t& pending)
{
    std::cout << "current process[" << getpid() << "]的pending: ";
    //打印每一个bit位信号情况
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }
    std::cout << std::endl;
}

void handler(int signum)
{
    std::cout << signum << "号信号被递达!!!" << std::endl;
    std::cout << "-----------------------" << std::endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    std::cout << "-----------------------" << std::endl;
}


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

    // 1.屏蔽2号信号
    sigset_t block_set,old_set;

    // 将信号集初始化为没有任何信号
    sigemptyset(&block_set);
    sigemptyset(&old_set);

    sigaddset(&block_set,SIGINT); // 给block_set信号集添加2号信号

    // 将信号集合设置到进程的block表中,修改当前进程的内核block表,完成对2号信号的屏蔽
    // 并且将原先进程的信号集给到old_set中
    sigprocmask(SIG_BLOCK,&block_set,&old_set);

    int cnt=10;
    while(true){

        // 2.获取当前进程的pending信号集
        sigset_t pending;
        sigpending(&pending);

        // 3.打印pending信号集
        PrintPending(pending);

        cnt--;
        // 4.解除对2号信号的屏蔽
        if(cnt==0){
            std::cout << "解除对2号信号的屏蔽!!!" << std::endl;

            // 将原先的信号集合(没有任何信号),设置掩码
            // 就是不给任何信号设置掩码,等于解除任何信号的信号屏蔽字
            sigprocmask(SIG_SETMASK, &old_set, &block_set);
        }
        sleep(1);
    }
}

上述代码首先对2号信号进行捕捉,修改2号信号的处理方式,然后将2号信号进行屏蔽。每秒打印该进程对应的pending表。当2号信号被屏蔽的时候,使用Ctrl - c 发送2号信号,可以pending表的次低位会被置为1,当解除2号信号的屏蔽的时候,2号信号的处理动作还没有做完就会修改pending表,后续再次触发2号信号打印的pending表次低位都为0,表示2号信号没有处于未决状态。

当10秒后解除2号信号的屏蔽的时候,2号信号立即被递达,在2号信号处理方式还未结束的时候 pending 表的次低位就被置为0,所以 pending 表的改变在信号被递达的时候就改变,并不是处理方法执行完才改变。

三).信号捕捉

1.信号捕捉的流程

由于信号自定义的处理函数的代码在用户空间,处理过程比较复杂。一个程序被操作系统高频调度,只有操作系统才能进行调度,所以进程调度需要用户态和内核态之间的转换。而且程序中会涉及到很多的系统调用,如prinf函数需要向显示器打印信息,而显示器是一种硬件资源,只能由操作系统进行操作,所以prinf函数中肯定是封装了系统调用的。而进行系统调用的时候,需要切换到内核态才能调用。所以程序在运行的时候是高频的不断的在用户态和内核态之间进行转换的。

在执行主控制流程的某条指令时,因为中断、异常或系统调用进入内核,处理完异常等准备回到用户模式之前,会先处理当前进程中可以递送的信号。所以如果处理的信号的处理动作是用户自定义的,需要从内核态切换到用户态。

  • 当前进程执行的某条指令遇到中断、异常或系统调用时,进程会陷入到内核中。
  • 陷入内核后,处理完异常等之后,会进行 pending 表和 block 表的查看,处理当前可以递达的信号。
  • 如果该信号的处理方式是用户自定义的,则会从内核态切换到用户态,执行信号处理函数。
  • 执行完信号处理函数后,执行特殊的系统调用 sigreturn 再次进入内核。
  • 在内核中返回上次用户模式下中断的地方,从内核态切换到用户态,并继续向下执行。

2.sigaction

将signum信号的处理动作修改为act指向的处理动作,并将原本的处理动作返回给oact。sigaction的作用和signal差不多,都是修改信号对应的处理方式。

参数:

  • signum:信号编号。
  • act:指向signaction结构体,若act指针非空,则根据act修改该信号的处理动作。
  • oact:(输出型参数)指向signaction结构体,若oact非空,则通过oact传出该信号原来的处理动作。

返回值:

  • 成功返回0,失败返回-1。

当使用 sigaction 函数对信号的处理动作进行修改后,该信号被捕捉后,就会把进程的中该信号 block 表中的bit为置为1,表示阻塞该信号。这样就能保证,在进行该信号处理的时候,不会有相同的信号再次被递达,被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了想自动屏蔽当前信号,还希望自动屏蔽另外一些信号,则可以用 sa_mask  字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
 
void handler(int signum)
{
    std::cout << "hello signal: " << signum << std::endl;
    while(true)
    {
        sigset_t pending;
        sigpending(&pending);
        for (int i = 31; i >= 1; i--)
        {
            if (sigismember(&pending, i))
            {
                std::cout << "1";
            }
            else
            {
                std::cout << "0";
            }
        }
        std::cout << std::endl;
        sleep(1);
    }
    // exit(0);
}
 
int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigaction(SIGINT, &act, &oact);
 
    while(true)
    {
        std::cout << "hello world" << std::endl;
        sleep(1);
    }
    
    return 0;
}

在上述函数中,将 2 号信号的处理动作改为循环打印 pending 表,当第一次发送 2 号信号的时候,触发处理动作,打印 pending 表应该为全0,这是 2 号信号的处理动作还未完成,再次发送 2 号信号,此时 2 号信号就会处于未决状态

3.操作系统是怎么运行的

1).硬件中断

从前面的学习我们知道。

如果我们的键盘输入了数据,cpu是怎么知道的。

在计算机主板上会集成一个中断控制器,用于管理外部设备发出的中断号。

从键盘上输入一个字符到显示器上,就是外部设备键盘向中断控制器发送了一个中断号,然后中断控制器就会通知 CPU,CPU 接到通知之后,会停下当前的动作,并保护当前调用进程的上下文,CPU 会根据操作系统中的中断向量表(类似函数指针数组)查询该中断的处理方式,然后进行中断处理,处理完毕之后,恢复之前进程的上下文,继续之前的工作。

由外部设备触发的中断系统运行流程,叫做硬件中断

注意:

  • 中断向量表就是操作系统的一部分,启动就加载到内存中了。
  • 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询,由外部中断控制器通知CPU外部设备的状态。
  • 寄存器不仅在CPU中存在,其实在很多外设上也存在对应的寄存器。比如磁盘,在和内存交互的时候,内存将对应的地址和数据给到磁盘的控制器中(内置寄存器)然后又磁盘中的控制器将数据写到磁盘对应位置。
2).时钟中断

进程可以在操作系统的指挥下被调度、被执行,那么操作系统也是软件,操作系统是被谁推动着运行的呢?

当没有中断到来的时候,操作系统什么都没做。其实在主板上还集成了一个时钟源,当代的时钟源已经集成到了CPU中,这个时钟源以固定的频率发送特定的中断号,这种通过时钟源发送中断号的方式叫做时钟中断。

每次时钟发送中断信号,驱动着操作系统运行,然后操作系统运行起来后才进行进程之间的自动调度或其他工作。所以操作系统是躺在时钟中断上的一款软件。

注意

  • CPU中的主频就表示的是该时钟源的频率。 时钟源产生的时钟信号频率决定了CPU执行指令的速度,所以主频越高,在单位时间内CPU能够完成的指令数通常就越多,CPU的运算速度相对就越快。但是CPU的执行速度不仅由主频决定,还和执行的指令集有关。
  •  一个进程的时间片,就是一个进程在这次被调用的时候使用多少个时钟周期。
  • 操作系统本身不做任何事情,需要什么功能就向中断向量表里面添加方法即可,所以操作系统的本质就是一个死循环,由时钟进行驱动,对应的功能都在中断向量表中
3).软中断

硬件中断和时钟中断,都是由硬件设备进行触发的。当然还有因为软件原因触发的中断,称为软中断

软中断是由软件条件在 CPU 内部产生中断号,然后查询中断向量表执行对应的中断处理。

操作系统不提供任何系统调用接口,操作系统只提供系统调用号。我们用到的系统调用接口都是glibc封装的。系统调用函数中封装了 int 0x80 或者 syscall 这种指令集,系统调用的过程,其实就是先执行指令 int 0x80 或者 syscall 陷入内核,本质就是触发软中断,然后 CPU 就会自动执行系统调用函数的处理方法,而这个方法会根据系统调用号(数组下标),自动查系统调用表,执行对应的方法。

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

  • 通过 CPU 主动触发的中断叫做软中断,比如系统调用中的 int 0x80 或者 syscall 叫做陷阱。
  • 由于除 0 错误导致的计算溢出、野指针导致的地址转换失败以及缺页中断由软件导致的硬件出错叫做异常。

4.理解用户态和内核态

这里以 32 位的机器为例,用户的进程地址空间的范围是 [0, 4GB]。其中 [0, 3GB] 是用户空间,[3, 4GB] 是内核空间。

  • 在物理内存中,每一个进程都有自己的代码和数据通过用户页表就可以将自己在物理内存中的代码和数据映射到自己进程的虚拟地址空间中。
  • 操作系统也是软件,所以当计算机启动的时候,会将操作系统加载到内存当中。每个进程虚拟地址空间中的内核空间就是通过内核页表,将操作系统映射到每一个进程的虚拟地址空间中。所以每一个进程都可以找到操作系统。这里多进程对操作系统的映射,就和多进程对动态库的映射一样,在物理内存中只有一份操作系统的代码和数据,但是通过内核页表映射到每一个进程的地址空间中。
  • 所以进程访问用户空间的时候,就处于用户态,当进程访问内核空间的时候,就处于内核态
  • 在 CPU 中有一个 cs 段寄存器,其中该寄存器中的低两位,如果都是 0,表示处于用户态,如果都是 1,表示处于内核态。当进程执行系统调用的时候,执行 int 0x80 或者 syscall 指令的时候,将 cs 段寄存器的低两位置 1,此时就代表陷入内核。

三.可重入函数

如上图,在 main 函数中,调用 insert 函数向一个全局链表 head 中通过头插方式插入节点 node1,插入操作分两步,第一步是将 node1 的 next 指针指向 head 指向的链表,第二步是将 head 指向 node1节点。当刚做完第一步的时候,因为中断或者异常等使进程陷入内核,处理完之后返回用户态之前检测到有信号待处理,于是切换到用户自定义的处理函数 sighandler 中,在 sighandler 中也调用 insert 函数向同一个 head 链表中插入 node2 节点,两步都做完之后从sighandler 返回内核态,再返回用户态,从 main 函数中 insert 的第二步往下执行。这样就会导致只有 node1 节点插入到了链表当中。而 node2 没有在链表中就会导致内存泄漏问题。 

像上述一样,一个函数被不同的控制流程调用,在第一次调用的时候还没有返回就再次进入该函数,这就称为重入。 

  • 如果该函数执行的时访问一个全局资源,有可能因为重入而造成错乱,这种函数就称为不可重入函数。
  • 如果一个只访问自己的局部变量或参数,则称为可重入函数。

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

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

四.volatile

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

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


int flag = 0;
 
void handler(int signum)
{
    std::cout << "更改全局变量flag -> 1" << std::endl;
    flag = 1;
}
 
int main()
{
    signal(2, handler);
 
    while(!flag);
    std::cout << "process quit normal! " << std::endl;
    return 0;
}

这段代码表示,程序启动会进入一个死循环,然后发送 2 号新号处理自定义捕捉动作之后,修改全局变量 flag 的值,程序跳出 while 循环正常结束程序。

  • 标准默认情况下,发送 2 号信号, 修改 flag = 1,跳出 while 循环,程序正常退出

  • 编译器优化情况下,发送 2 号信号,修改 flag = 1,但是没有跳出 while 循环,进程继续运行不退出。为什么会这样呢?原因是 while 循环检查 flag 并不是内存中最新的被修改过的 flag,这就存在了数据二义性的问题。while 检测的 flag 其实已经因为优化被放在了 CPU 的寄存器中了,因为在编译的时候,编译器识别到该 while 循环代码块中没有对 flag 进行修改,所以编译之后,flag 就直接被放到了 CPU 寄存器中,而每次 while 循环检测的时候,不从内存中读取 flag 的值,而是用寄存器中的 flag 进行判断。

使用 gcc 中 O1 的优化级别,就会出现优化后的现象。优化级别 O1 < O2 < O3。

使用 volatile 修饰全局变量 flag,这样之后就算优化级别开到最大,每次访问 flag 变量的时候都会进行访存。 


五.SIGHLD信号

子进程在终止时会给父进程发送 SIGCHLD 信号,进程对该信号的默认处理动作是SIG_DFL(所有信号的默认处理动作都是 SIG_DFL),而该信号的SIG_DFL 对应的是 Ign

验证子进程终止时会向父进程发送 SIGCHLD 信号

#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
 
void handler(int signum)
{
    std::cout << "signum: " << signum << std::endl;
}
 
int main()
{
    signal(SIGCHLD, handler);
 
    pid_t id = fork();
    if (id == 0)
    {
        std::cout << "I am child" << std::endl;
        sleep(1);
        exit(1);
    }
 
    waitpid(id, nullptr, 0);
    return 0;
}

使用信号处理的方式对子进程进行回收

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/types.h>
#include <sys/wait.h>
 
void handler(int signum)
{
    while(true)
    {
        pid_t n = waitpid(-1, nullptr, WNOHANG);    //若回收的进程不处于僵尸状态,则不会阻塞在此处,返回值为0
        if (n == 0) 
        {
            break;
        }
        else if (n < 0)
        {
            std::cout << "waitpid error" << std::endl;
            break;
        }
    }
}
 
int main()
{
    signal(SIGCHLD, handler);
 
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            sleep(3);
            std::cout << "I am child" << std::endl;
            if (i < 6)
                exit(1);
            else
                pause();
        }
    }
 
    while(true)
    {
        std::cout << "I am father" << std::endl;
        sleep(1);
    }
    return 0;
}

上述代码会在 3 秒后回收前 6 个子进程,后续子进程可以通过 kill -9 命令进行手动终止。 

想要不产生僵尸进程还有另外一种方式:父进程调用 sigaction 将 SIGCHLD 的处理动作设置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉。此方法对于 Linux 可用,但不保证在其他 UNIX 系统上可用。

但是 SIGCHLD 的默认处理动作就是忽略,这里设置 SIG_IGN 也是忽略。这里要说明一下,SIGCHLD 信号的 SIG_DFL 是 Ign 和SIG_IGN 不是同一个处理动作。

所以在没有设置 SIG_IGN 之前,SIGCHLD 的处理动作是 SIG_DFL,而这个信号的 SIG_DFL 是 Ign,设置之后, SGICHLD 的处理动作是 SIG_IGN。

Logo

纵情码海钱塘涌,杭州开发者创新动! 属于杭州的开发者社区!致力于为杭州地区的开发者提供学习、合作和成长的机会;同时也为企业交流招聘提供舞台!

更多推荐