Linux第十课---------Linux信号

作者前言

🎂 ✨✨✨✨✨✨🍧🍧🍧🍧🍧🍧🍧🎂

​🎂 作者介绍: 🎂🎂

🎂 🎉🎉🎉🎉🎉🎉🎉 🎂

🎂作者id:老秦包你会, 🎂

简单介绍:🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂

喜欢学习C语言、C++和python等编程语言,是一位爱分享的博主,有兴趣的小可爱可以来互讨 🎂🎂🎂🎂🎂🎂🎂🎂

🎂个人主页::小小页面🎂

🎂gitee页面:秦大大🎂

🎂🎂🎂🎂🎂🎂🎂🎂

🎂 一个爱分享的小博主 欢迎小可爱们前来借鉴🎂


Linux进程信号

信号的开始

注意:信号 和信号量没有任何关系,两者互不干扰。

在现实生活中,无时无刻不存在信号,比如红路灯、喇叭声,或是闹钟铃声等,就拿睡觉来说,睡着睡着突然闹钟响了,然后必须起床,人是进程,睡觉是进程在运行的事情,闹钟铃声是信号,

信号:中断人们正在做的事情,是一种事件的异步通知机制 。信号的产生,相较于进程运行的,是异步的。

信号是发送给进程的。

从睡觉这个例子我们可以知道

  1. 信号的出现,知道怎么处理
  2. 信号处理的时间
  3. 信号处理是被"教育"出来的,OS程序员在进程内部已经内置了信号的识别和处理方式
  4. 进程中的信号源有很多。

位图:位图本质上是一个很长的二进制位数组,每个比特位表示某个元素"有没有""是不是""在不在"。

一个整数表示有32个bit位,如果要创建一个320个比特位的位图,假设要访问第100位置的bit,

如下:

cpp 复制代码
nt arr[10];
int index = 100 /32 = 3
int pos = 100 % 32 = 4
arr[3] >> 4 & 1;

信号的产生

信号产生的方式

键盘产生

在OS中,产生信号的方式有很多中,例如进程运行时,直接Ctrl +c,如下:

cpp 复制代码
#include<iostream>
#include <unistd.h>
using namespace std;
int main(int argc ,char* argv[], char* env[])
{
    int cut = 0;
    while(true)
    {
        cout << "hello world------ " << cut++ << endl;
        sleep(1);
    }
    return 0;
}

如图:

通过键盘,输入ctrl + c,向**目标进程(前台进程)**发送信号,进程接收到信号直接终止。ctrl +c发送哪个信号?

Linux系统下的信号有如下:

bash 复制代码
kill -l

信号1 到信号31是普通信号。信号34到64是实时信号。

普通信号:接收到可以不立刻处理

实时信号:接收到必须立刻处理

进程收到信号后 会在合适的时间进行处理,处理方式如下:

  1. 默认处理
  2. 自定义处理
  3. 忽略处理

上面的进程处理信号是默认处理,如果我们要更改默认处理动作,可以调用系统调用signal:

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

typedef void (*sighandler_t)(int);

 sighandler_t signal(int signum, sighandler_t handler);
signum:信号数字
handler:函数指针,处理信号的函数, 类型是void(*)(int),还有忽略处理SIG_IGN	,和SIG_DFL默认处理

注意 :signal函数仅仅是设置了特定信号的捕捉⾏为处理⽅式,并不是直接调⽤处理动作。如果后续特定信号没有产⽣,设置的捕捉函数永远也不会被调⽤!!

ctrl + c 发送的是信号2,

代码如下:

cpp 复制代码
#include<iostream>
#include <unistd.h>
using namespace std;
#include <signal.h>
void SignalHandle(int sig)
{
    cout << "接收的信号是" << sig << endl;
}
int main(int argc ,char* argv[], char* env[])
{
    signal(2,SignalHandle);
    int cut = 0;
    while(true)
    {
        cout << "hello world------ " << cut++ << endl;
        sleep(1);
    }
    

    return 0;
}

效果如下:

查看每个信号对应的默认处理动作:

bash 复制代码
man 7 signal

term:终止

coer:终止

stop:停止

发送信号的时候,肯定需要知道要发送给谁,

前台进程命令:

bash 复制代码
./mian.exe

后台进程命令:

bash 复制代码
./mian.exe &

可以看到Ctrl + c 只能发送给前台进程,后台进程无影响,原因如下:后台进程无法从标准输入获取内容,键盘只有一个, 而 前台进程也只有一个,而后台进程可以有多个,如果想要进程获取数据,就必须变成前台进程
当我们运行./main.exe的时候,bash进程就会变成后台进程,这也就解释了,为啥往里面输入其他命令会毫无反应,这些数据只会输入给前台进程,后台进程获取不到

启动xshell ,bash进程就是前台进程

补充命令:

bash 复制代码
jobs   //查看所有后台进程

如图:

1:表示任务号

bash 复制代码
fg 任务号 // 后台进程变成前台进程
bash 复制代码
bg 任务号 //后台进程运行
bash 复制代码
Ctrl + z 进程进入后台,暂停运行

当进程获取到信号的时候,并不是一定会立刻处理,首先会先记录下来,怎么记录?

进程的PCB里有一个整数,unsigned sig,如图:

这个整数有32个bit,而普通信号有31个,前31个bit分别表示对于的信号位,收到对应的位置就会由0变成1.

task_struct进程的PCB结构体,也就是内核数据结构,发送信号,进程进行记录,本质就是修改内核数据结构的数据,而修改是操作系统进行修改的,那么发送信号,底层肯定是OS发送 的,使用kill命令,底层肯定会调用系统调用。

啥叫给Linux进程发送信号,本质就是修改进程PCB中的 一个整数,通过位图的方式,把对应的比特位由0变成1

注意: 发送信号,底层是OS发送的

系统调用产生

产生信号的系统调用有很多,这里先介绍kill:

cpp 复制代码
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
pid:接收信号的进程的pid
sig:信号
返回值:成功返回0 ,失败返回-1

下面写一个进程发送信号,一个进程处理信号的代码。如下:

send.cc

cpp 复制代码
#include"shmid.hpp"

using namespace std;
void SigHandle(int sig)
{
    cout << "捕获信号: " << sig << endl;
}
int main(int argc ,char* argv[], char* env[])
{
    //signal(2, SIG_IGN);//忽略信号
    for(int i = 1; i < 32; i++)
    {
        signal(i, SigHandle);
    }
    //创建共享内存
    MemoryShared memory(-1, nullptr);
    memory.CreateMemory(IPC_CREAT | IPC_EXCL | 0666);
    //挂载
    memory.ProcessMap();
    //发送信号
    pid_t * arr = reinterpret_cast<pid_t*>(memory.GetAdder());
    arr[0] = getpid();
    //执行任务
    while(true)
    {
        cout << "运行中" << endl;
        sleep(1);
    }
    
    return 0;
}

Main.cc

cpp 复制代码
#include"shmid.hpp"
int main(int argc ,char* argv[], char* env[])
{
    MemoryShared memory(-1, nullptr);
    memory.CreateMemory(IPC_CREAT);
    //挂载
    memory.ProcessMap();
    pid_t * arr = reinterpret_cast<pid_t*>(memory.GetAdder());
    //发送信号
    int cut = 1;
    while(true)
    {
        cout << "发送信号: " << cut << endl;
        kill(arr[0], cut++);
        sleep(1);
        
    }
    return 0;
}

shmid.hpp

cpp 复制代码
#pragma once
#include<iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/shm.h>


using namespace std;
#define ERR_OR(m,n) \
do \
{\
    perror(m);\
    exit(n);\
} while (0)
#define PATHNAME "./makefile"
#define PROJ_ID 1
#define MEMORYSIZE 4096
class MemoryShared
{
public:
    MemoryShared(const int& shmid, void* arr)
    :_shmid(shmid)
    ,_arr(arr)
    {
        
    }
    void CreateMemory(int flg)
    {
        int key = ftok(PATHNAME,PROJ_ID);
        if(key == -1)
            ERR_OR("ftok error", 1);
        _shmid = shmget(key, MEMORYSIZE, flg);
        if(_shmid == -1)
            ERR_OR("shmget error", 1);
        cout << _shmid << endl;
    }
    void ProcessMap()
    {
        _arr = shmat(_shmid,nullptr, 0);
        if(reinterpret_cast<long long>(_arr) == -1)
            ERR_OR("shmat error", 1);
    }
    void RemoveMap()
    {
        if(shmdt(_arr) == -1)
            ERR_OR("shmdt error", 1);
    }
    void RemoveMe()
    {
        if(shmctl(_shmid,IPC_RMID, nullptr) == -1)
            ERR_OR("shmctl error", 1);
    }
    void* GetAdder()
    {
        return _arr;
    }
    ~MemoryShared()
    {
        RemoveMap();
        RemoveMe();
    }
private:
    int _shmid;
    void * _arr;
};

如图:

注意 :信号9 、19是无法被自定义处理,

除了kill 还有raise和abort,下面介绍raise:

这个系统调用是自己给自己发信号的接口

cpp 复制代码
int raise(int sig);
sig:信号值
返回值:成功返回0,否则返回非0

下面介绍abort:

cpp 复制代码
void abort(void);

该函数直接调用的话,会给自己发送一个信号6,直接让进程终止

硬件异常产生信号

下面写两个代码,一个野指针,一个除0

除0:

cpp 复制代码
void SigHandle(int sig)
{
    cout << "捕获信号: " << sig << endl;
    exit(13);//要写,不然会一直捕获信号
}
int main(int argc ,char* argv[], char* env[])
{
    //signal(2, SIG_IGN);//忽略信号
    for(int i = 1; i < 32; i++)
    {
        signal(i, SigHandle);
    }
    while(true)
    {
        sleep(1);
        cout << "运行中: " <<  getpid() <<endl;
        int a = 1;
        a /= 0;
        
    }
    
    return 0;
}

如图:

野指针:

cpp 复制代码
void SigHandle(int sig)
{
    cout << "捕获信号: " << sig << endl;
    exit(13);//要写,不然会一直捕获信号
}
int main(int argc ,char* argv[], char* env[])
{
    //signal(2, SIG_IGN);//忽略信号
    for(int i = 1; i < 32; i++)
    {
        signal(i, SigHandle);
    }
    while(true)
    {
        sleep(1);
        cout << "运行中: " <<  getpid() <<endl;
        int*arr = nullptr;
        *arr = 100;
        
    }
    
    return 0;
}

如图:

上面的代码会被发送信号的原因如下:

如当前进程执⾏了除以0的指令, CPU的运算单元会产⽣异常, 内核将这个异常解释为SIGFPE信号发送给进程。再⽐如当前进程访问了⾮法内存地址, MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送给进程。

OS发送信号的构想图:

那么OS是怎么判断进程出错了?

进程的运行是在CPU中的,而CPU上是有一堆寄存器,里面存储进程的信息,其中有一个状态寄存器,状态寄存器可以简单理解为⼀个位图,对应着⼀些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调⽤对应的异常处理⽅法。,因为OS是软硬件的管理者,当硬件发生异常,CPU拥有当前进程PCB和上下文,OS就会往这个进程发送信号,

如果自定义处理函数没有写exit这个,就会发现,进程会无限收到信号,原因是:

。

除零异常后,我们并没有清理内存,关闭进程打开的⽂件,切换进程等操作,所以CPU中还
保留上下⽂数据以及寄存器内容,除零异常会⼀直存在,就有了我们看到的⼀直发出异常信
号的现象。访问⾮法内存其实也是如此,

软件条件

软件条件指的是:进程在运行过程中触发了内核定义的某种软件规则或系统状态变化,内核检测到后自动给进程发送信号。,也可以理解,软件条件是信号产生的机制。

就拿管道的读端关闭,然后OS就会往写端发送信号13,终止进程。信号13就是由软件条件产生的信号

下面介绍一个软件条件产生信号的函数alarm:

这个函数的作用就是设置一个闹钟,时间到了就发送信号。

cpp 复制代码
unsigned int alarm(unsigned int seconds);
seconds:传入一个秒数,如果传入0,表示取消闹钟
返回值,第一次调用的返回设置的秒书,多次调用,会返回上一个设置闹钟的时间还剩多少秒。

假设 第一次 alarm(5),返回值就是5, 当过了3秒。再次调用alarm(10),返回剩余的秒数2, 然后从10开始倒数。

代码如下:

cpp 复制代码
int main(int argc ,char* argv[], char* env[])
{
    //signal(2, SIG_IGN);//忽略信号
    for(int i = 1; i < 32; i++)
    {
        signal(i, SigHandle);
    }
    alarm(5);//设置闹钟
    while(true)
    {
        sleep(1);
        cout << "运行中: " <<  getpid() <<endl;
        
    }
    
    return 0;
}

如图:

可以看到这个函数会发送信号14 。除此之外,我们还可以写一个代码,每隔一段时间进行进行运行函数,这里引入一个等待信号系统调用:pause

cpp 复制代码
 int pause(void);//等待信号,如果等不到就会一直阻塞

代码如下:

cpp 复制代码
void SigHandle(int sig)
{
    cout << "捕获信号: " << sig << endl;
    alarm(1);
}
int main(int argc ,char* argv[], char* env[])
{
    int cut = 1;
    signal(14, SigHandle);
    alarm(1);
    while(true)
    {
        pause();
        cout << "运行第" << cut++ <<"次" << endl;
    }

    return 0;
}

如图:

发送信号,处理事情,那么操作系统也是这样一个原理,定时发送信号,操作系统执行任务。

在Linux中创建闹钟,就是创建结构体对象,这里假设结构体结构如下:

cpp 复制代码
struct timer_list 
{
	struct list_head entry;
	unsigned long expires;
	void (*function)(unsigned long);
	unsigned long data;
	struct tvec_t_base_s *base;
};

先描述一个闹钟结构体,然后组织,这个组织我们可以理解为建立堆结构(小堆)

信号保存

补充概念

实际执⾏信号的处理动作称为信号递达 ,这个方式有默认、自定义、忽略

信号从产⽣到递达之间的状态,称为信号未决 (Pending)。信号处于位图中

• 进程可以选择阻塞 (Block )某个信号。

• 被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.

• 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动
作。

阻塞信号:也叫屏蔽信号,就是把信号一直处于信号未决状态,不进行信号递达。直到解除阻塞,才会递达

信号存储的方式

图中就是进程PCB结构体下信号阻塞、信号未决的位图和信号处理数组,也就是前两个的类型是unsigned int,每一个比特位表示信号第几个,值则表示该信号是否被收到,而handler是一个数组,

类型就是sighandler*的,**数组下标表示的第几个信号,**这就可以解释出,系统调用signal传入参数

​可以理解为一个是下标,一个对应下标下的值。如图:

除此之外,信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志 。

图中的例⼦中:

  1. SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
  2. SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。它的处理动作是忽略,
  3. SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数
    sighandler。

注意:Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之前产⽣多次可以依次放在⼀个队列⾥。

在Linux2.6.18的task_struct关于这部分的源码:

cpp 复制代码
struct task_struct
{
.......
	struct signal_struct *signal;
	struct sighand_struct *sighand; //handler表

	sigset_t blocked, real_blocked; //block表
	sigset_t saved_sigmask;		/* To be restored with TIF_RESTORE_SIGMASK */
	struct sigpending pending; //pending表
	......
};


	
struct sighand_struct {
	atomic_t		count;
	struct k_sigaction	action[_NSIG];
	spinlock_t		siglock;
};

struct k_sigaction {
	struct __new_sigaction	sa;
	void			__user *ka_restorer;
};
#define __new_sigaction		sigaction
struct sigaction {
	__sighandler_t sa_handler;
	unsigned long sa_flags;
	void (*sa_restorer)(void);
	sigset_t sa_mask;		/* mask last for extensibility */
};
struct sigpending {
	struct list_head list;
	sigset_t signal;
};

关于sigset_t类型:

从上图来看,每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样

表⽰的。因此, 未决和阻塞标志可以⽤相同的数据类型sigset_t来存储,sigset_t称为信号集(简单的理解就是一个整数而已,通过位图存储 ),这个类型可以表⽰每个信号的"有效"或"⽆效"状态, 在阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞, ⽽在未决信号集中"有 效"和"⽆效"的含义是该信号是否处于未决状态。

阻塞信号集 也叫做当前进程的信号屏蔽字 , 这⾥的"屏蔽"应该理解为阻塞⽽不是忽略。

信号保存系统调用

进程PCB是内核结构体,是需要调用系统调用的,下面分别介绍这三个表的系统调用:

系统调用signal就是修改handler数组的系统调用,这里就不介绍了,

下面介绍怎么修改信号集(sigset)

cpp 复制代码
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);

1. 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,也就是每个比特位为0
2. 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,也就是每个比特位为1
3. 函数sigaddset会在set中,对应的signo位置比特位变成1,也叫添加
4. 函数sigdelset会在set中,对应的signo位置比特位变成0,也叫删除
5. sigismember函数,会判断set对应的signo位置比特位是否为1;
返回值:
这四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有效信
号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

修改和获取block表系统调用:

cpp 复制代码
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

how的参数类型有如下:

SIG_BLOCK: block和按位或

SIG_UNBLOCK: 先把set二进制位取反,然后block和取反后的set按位与

SIG_SETMASK:直接把set替换位block

这个参数就是把set按照how的方式对block进行修改,

set:传入一个自己想要修改的信号屏蔽字。这是一个输入型参数

oldset:返回之前旧的block表,这是一个输出型参数。

查看pending:

cpp 复制代码
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1

修改pending表的系统调用

就是信号产生的那些系统调用 比如kill 等,

下面写一个屏蔽信号,然后在解除屏蔽,查看执行动作的时候,信号对应在pending表是否为0:

siganl.hpp

cpp 复制代码
#pragma once
#include"shmid.hpp"
#define SIGNALNUM 2
class Mysiganl
{
public:
    Mysiganl(const sigset_t& block)
    :_block(block)
    ,_old_block()
    ,_pending()
    {

    }
    void BlockSignal(int how)
    {
        if(sigprocmask(how, &_block, &_old_block) == -1)
            ERR_OR("sigprocmask error", 1);
    }
    sigset_t GetPending()
    {
        if(sigpending(&_pending) == -1)
            ERR_OR("sigpending error", 1);
        return _pending;
        
    }
    sigset_t GetOldBlock()
    {
        return _old_block;
    }
    sigset_t GetBlock()
    {
        return _block;
    }
    void EmpSignal()
    {
        sigemptyset(&_block);
    }

     void FillSignal()
    {
        sigfillset(&_block);
    }
     void AddSignal(const int& signalnum)
    {
        sigaddset(&_block,signalnum);
    }
     void DelSignal(const int& signalnum)
    {
        sigdelset(&_block,signalnum);
    }
     void GetSignal(const int& signalnum)
    {
        sigismember(&_block,signalnum);
    }
    void Print()
    {
        GetPending();
         cout << "[" << getpid() <<"]------ ";
         for(int i = 1; i < 32; i++)
         {
             //cout << "信号"<< i<< "是否存在:" <<sigismember(&sig, i) << endl;
             cout << sigismember(&_pending,i);
         }
         cout << endl;
    }
    ~Mysiganl()
    {
        
    }
private:
    sigset_t _block;
    sigset_t _old_block;
    sigset_t _pending;




};

Main.cc

cpp 复制代码
#include"siganl.hpp"
int main(int argc ,char* argv[], char* env[])
{
    //
    int cut = 1;
    sigset_t block;
    Mysiganl sig(block);
    sig.EmpSignal();
    sig.AddSignal(SIGNALNUM);
    sig.BlockSignal(SIG_SETMASK);
    signal(SIGINT,[] (int a) mutable -> void
    {   
        sigset_t pend;
        cout << "######################" << endl ;
        cout << "递达信号:" << a << endl  << "pending: ";
        //恢复
        signal(SIGINT,SIG_DFL);
        //查看
        sigpending(&pend);
        for(int i = 1; i < 32; i++)
        {
            cout << sigismember(&pend,i);
        }
        cout << endl;
        cout << "######################" << endl;
    });
    while(true)
    {
        
        sig.Print();
        sleep(1);
        if(cut++ == 10) //取消阻塞
        {
            cout << "解除阻塞" << endl;
            sigset_t num1 = sig.GetOldBlock();
            sigset_t num2 = sig.GetBlock();
            sigprocmask(SIG_SETMASK, &num1, &num2);
        }
    }
    

    return 0;
}

如图:

core和term的区别

bash 复制代码
man 7 signal

可以看到,一些信号的默认处理方式是core和term,但是终止,为啥要有两个不一样的,

原因是:

core: 当当前进程异常退出(除0或者野指针),会在当前的路径下,形成一个文件core(在磁盘中),文件内容是进程中的内存核心数据,叫做核心转存,然后直接进程退出

term:直接进程退出

为啥这个文件会看不见,在云服务器中,是被禁止的,怎么打开:

查看core文件

bash 复制代码
ulimit -a

打开core文件

bash 复制代码
ulimit -c 文件大小

然后运行除0错误后,就会生成一个core文件,如图

里面的内容是二进制的,如果要使用这个core文件的 话,需要进入到gdb模式,使用命令

bash 复制代码
core-file core

如图:

可以看到,直接出来了,不用一步步调试处理,如果后面遇见发现不了的问题,可以尝试使用core文件来定位错误。

这个就是进程等待waitpid中status参数的位图结构,如果进程是core退出的就会在这个core dump标记位变成1.

捕捉信号

捕捉过程

我们知道,进程收到普通信号,不会立刻被处理,而是在合适的时候去处理,这个合适的时候是啥时候,下面提供一个图:

大致情况就是

  1. 当主程序在运行到某一行代码的时候,触发了异常或者中断又或者调用系统调用的时候,进入到操作系统内部,
  2. 在操作系统内部处理完或者调用完后,就要返回主程序运行的时候,由于操作系统的规则,在返回之前会检查该进程是否有信号,是否被阻塞,如果有信号,并且没有阻塞,OS就会调用对应的处理函数,
  3. OS检查进程信号的是会调用系统调用do_siganl,如果发现信号没有block,就会信号递达,当执行自定义函数要结束的时候,就会调用系统调用sigretum,再次进入到内核中,
  4. 再次进入到内核中,再次检查信号,如果没有新的信号要递达,从内核返回到主程序中继续执行用户代码
  5. 执行自定义函数的时候,会以用户身份(用户态)运行,当该函数运行返回的时候,这个返回地址不是原来main函数的地址而是sigretum函数的地址(涉及到函数栈帧的问题)

下面的图,是自己理解:

合适的时候: 内核态 转变成用户态的时候,会进行信号递达。

为啥我的进程会进入内核,如果没有异常或者系统调用是否也会?

答案:会,因为进程会被调度,cpu根据时间片进行轮转,时间片没到时,就会执行用户代码,时间片到了,进程就会强制进入内核态了,执行内核代码。

用户态和内核态

进入内核态的方式

硬件中断

前面我们写过一个scanf的代码,当运行到scanf这个代码的时候,进程会阻塞,进程状态从R变成S,然后进程的PCB进入到键盘的等待队列里,当输入数据后,回车,就会进程PCB进入到CPU的运行队列里,进程状态变成R,

那么这个过程,OS是怎么知道键盘有数据的,难道是定时检测键盘,然后进程通过键盘文件的缓冲区获取到的吗?答案并不是,而是通过硬件中断。

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

硬件中断的过程:

在计算机中,有许多的外设,比如键盘,每一个外设都有自己的控制器,当对应的外设准备好了,就会往这些控制器针脚发送高电频,当控制器识别到对应的外设信号,就会在内部形成一个中断号 ,大部分外设或者控制器乃至内存都有自己的寄存器 ,这个中断号就保存在寄存器中,然后控制器就会往cpu的针脚发送高电频,CPU就会访问控制器的寄存器,获取到这个中断号,cpu就会知道这个设备好了,

而在操作系统里,会有一张中断向量表(IDT),CPU会通过获取到的中断号,去查找中断向量表,找到对应的处理程序入口地址。执行中断方法。执行完后,cpu继续执行之前的任务。

为了更好理解,我们可以把中断向量表相当于函数指针数组,中断号相当于下标

这个过程跟信号产生、保存和处理很像, 发送中断=发送信号 获取中断号=保存信号, 执行中断方法=信号处理函数

中断向量表就是操作系统的⼀部分,开机就加载到内存中了

如图:

时钟中断

当没有中断的时候,操作系统在干嘛?

答案是:操作系统是暂停的, 那么操作系统是让进程被调度的呢?

在中断向量表中,会注册一个进程调度的方法,在计算机硬件中,会有一个时钟源 ,这个时钟源会以固定的特定频率发送中断,中断控制器生成对应的中断号,CPU就会根据中断号去查找中断向量表,执行对应的进程调度方法,

如图:

由于时钟源会跟外设抢占中断控制器,现在的时钟源都是集中在CPU内部的,由时钟源进行发送中断给CPU。也就有了CPU频率:每秒钟CPU时钟中断的频率 ,主频 是 CPU 内部时钟信号的振荡频率,如图:

所谓的时间片,也就是一个计数器,当计数器为0,就会进程切换了。

那么我们可以感觉到

操作系统:基于中断,进行工作的软件

操作系统⾃⼰不做任何事情,需要什么功能,就向中断向量表⾥⾯添加⽅法即可.操作系统的本质:就是⼀个死循环!

cpp 复制代码
while (1) {
    i++;
}

哪怕运行代码无任何中断、异常、系统调用,时钟源会每隔几毫秒产生一次时钟中断,CPU 执行完当前那条指令后,硬件会强制暂停用户态进程,保存现场,把 CPL 从 3 切到 0,然后跳到内核的中断处理程序。也就是进入了内核态。

软中断(系统调用接口底层会封装)

软中断:没有外部硬件设备,由软件或程序触发的错误,通过特定指令发出的中断信号

异常:

前面的代码,比如除0错误,在CPU会有一个标记位寄存器,除0导致这个寄存器里面的数据溢出,CPU就会生成一个中断号,存储到寄存器中,操作系统就会停下来进行中断处理,这个处理就是給当前进程发送信号。这个处理是异常处理 ,这是由软件出错而导致硬件出错。

所以,操作怎么知道硬件异常,就是通过为中断知道的 。这是软件错误引发的硬件异常,

软中断

CPU除了从外部获取中断,其本身也会有中断。

在CPU内也可以让软件触发中断。为了让CPU能通过软件来主动中断,会提供一些命令:

bash 复制代码
_x86:int  //32位机器用的指令
_x86_64:syscall   //64位机器使用的指令

在CPU中会有自己的指令集 ,上面就是指令集中的其中一个,既然是中断,那么也是需要通过中断号在中断向量表找到对应的软中断的处理,这个号就是0x80,汇编指令如下:

bash 复制代码
syscall 0x80

这个软中断的处理方法的大致结构如下:

cpp 复制代码
xxxx()
{
1. 获取系统调用号n
	move eax eax
3. 调用系统方法
	sys_call_table[n] 
}

在操作系统中,所有的系统调用会写在一张表中,叫做系统调用表,如下

cpp 复制代码
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};

每个系统调用都有对应的下标,这个下标就是系统调用号

我们平时在用户层面进行调用的系统调用接口,比如open、fork等这些都是被封装的,OS不提供系统调用接口,OS只提供系统调用号 ,用户层面使用的系统调用接口都是被封装的 ,也就是C标准库提供的

以write系统调用接口为例子,底层大致如下:

cpp 复制代码
mov eax, 4      ; 系统调用号:sys_write
mov ebx, fd     ; 第1个参数
mov ecx, buf    ; 第2个参数
mov edx, len    ; 第3个参数
int 0x80        ; 触发软件中断,进入内核

这也就解释了,为啥调用系统调用接口会进入操作系统内部,因为底层进行了软中断

缺页中断

我们知道,在虚拟地址中开辟空间,不一定会在物理内存上开辟空间

当程序访问的虚拟地址,在物理地址上不存在,页表上的物理到虚拟的关系不存在,进而导致MMU在转化时出现异常,进而发送中断,CPU通过中断号,在中断向量表CPU找到对应的处理方法,检查地址是否合法,合法就会补全页表的映射关系。

这个也是软中断中的异常

cpp 复制代码
	操作系统就是躺在中断处理例程上的代码块!
• CPU内部的软中断,⽐如int 0x80或者syscall,我们叫做 陷阱
• CPU内部的软中断,⽐如除零/野指针等,我们叫做 异常。(所以,能理解"缺⻚异
常"为什么这么叫了吗?)

理解用户态和内核态

图中就是进程地址空间图。要理解用户态和内核态,先要理解进程地址空间

进程地址空间中,用户空间是3G,内核空间是1G,在用户空间中,通过用户页表和物理内存进行映射,而操作系统本质也是软件,那么也会在内存中 ,而这个内核空间,就是和操作系统所在物理内存之间通过内核页表进行映射的。

操作系统的相关数据,在内存中只会存在一份。所有的进程共享。其中的内核页表也是一样的。也就是所有进程只需要一份内核页表就行;说明,不管进程如何调度,都能找到操作系统

注意:用户页表每个进程拥有一个(一个进程一个用户页表),而内核页表只有一个,所有进程共享。

相关图:

用户和内核都在同一个进程地址空间里,用户只要有地址,就可以访问对应的变量、函数或者是内核中的代码和数据了。

但是OS为了保护自己,任何进程必须用系统调用的方式进行访问,这是一个矛盾,为了解决这个矛盾,引入了用户态和内核态

用户态:以用户的身份,只能访问进程地址空间中用户空间的数据

内核态:以内核的身份,运行你系统调用的方式,进行访问内核空间中的数据

那么当前进程是怎么知道自己啥时候处在用户态,啥时候处于内核态?

在CPU中会存在一个寄存器叫做CS段寄存器,里面会存储代码段地址,且它的低 2 位比特位直接决定了 CPU 当前的特权级(是用户态还是内核态)这个位置叫做CPL,例如,CS寄存器的CPL是3(用户态),CPU通过获得内核空间中的地址(CS段寄存器中的地址),去寻址,去调用对应系统调用,发现当前是用户态,CPU就会终止进程

bash 复制代码
0:表示内核态
3:表示用户态

int 0x80,本质就是改变CS段寄存器中存储的代码段地址和权限变成0

所以说,用户态就是CPU的CS段寄存器中的CPL是3 ,内核态就是CS段寄存器中的CPL是3

信号捕捉的过程

  1. 主程序运行过程中由于中断、异常(除0)或者系统调用接口,进入到操作系统内部,如果是中断,CPU获取到中断号,查询操作系统中中断向量表(IDT),执行其中的处理方法, 如果是系统调用接口,CPU会获取到系统调用号,查询系统调用表,调用系统调用,并把CS段寄存器中的CPL改为0,
  2. 当处理完毕后,CPU会检查当前进程的三张表 blcok 、pending 、handler、如果有信号,并且没有被阻塞,就会调用对应的处理方法,
  3. 如果这个方法是自定义函数,把CS段寄存器中的CPL改为3,以用户身份执行自定义函数,执行完自定义函数,并调用系统调用,CPU就会CS段寄存器中的CPL改为0。
  4. 如果没有信号。 CPU就会CS段寄存器中的CPL改为3。继续执行主程序后面的代码

系统调用

前面除了siganl系统调用接口可以修改信号处理方法,这里还有一个系统调用接口sigaction:

cpp 复制代码
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
act:输入型参数,传入一个结构体对象
oldact:输出型参数,返回旧的act

该系统调用接口作用: 当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。

好处:,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么 它会被阻塞到当前处理结束为⽌。

sigaction结构体如下:

cpp 复制代码
struct sigaction 
{
	void     (*sa_handler)(int);//处理函数指针
	void     (*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t   sa_mask; //信号集
	int        sa_flags;
	void     (*sa_restorer)(void);
};
  1. 为啥会有这个sa_mask信号集,如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。
  2. sa_flags字段包含⼀些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数

写一个使用sigaction修改处理函数,然后发送信号,正在运行处理函数,再发送信号,pend表是对应的信号位是否为1:

cpp 复制代码
void SigHandle(int sig)
{
    static int cut = 0;
    sigset_t pend;
    for(int i = 0; i < 10; i++)
    {
        sigpending(&pend);//获取pending表
        cout << "捕获信号次数: " << cut << " pend: ";
        for(int i = 1; i < 32; i++)
        {
            cout << sigismember(&pend,i);
        }
        sleep(1);
        cout << endl;
        
    }
    cout << "------------------------" << endl;
}

int main(int argc ,char* argv[], char* env[])
{
    //先设置一个信号信号屏蔽字   2
    sigset_t block;
    sigset_t oldblock;
    sigset_t pend;
    sigemptyset(&block);
    sigaddset(&block,3);
    //sigprocmask(SIG_SETMASK, &block, &oldblock);//修改block表
    //raise(SIGINT);
    sigpending(&pend);//获取pending表
    for(int i = 1; i < 32; i++)
    {
        cout << sigismember(&pend,i);
    }
    cout << endl;
    //使用sigaction修改信号处理方式
    struct sigaction btable;
    struct sigaction oldbtable;
    int cut = 0;
    btable.sa_handler = SigHandle;
    btable.sa_mask = block;
    btable.sa_flags = 0;
    sigaction(SIGINT, &btable, &oldbtable);
    while(true)
    {
        cout << "运行中" << endl;
        sleep(1);
        // if(cut++ == 5)
        //     sigprocmask(SIG_SETMASK, &oldblock, &block);//解除信号阻塞
        // else if(cut == 10)
        //    sigaction(SIGINT, &oldbtable, &btable);
    }
    return 0;
}

可以看到,先发送信号2两次,信号3发送一次。

使用sigaction修改信号2处理函数,执行信号处理函数时,再次发送信号2会被阻塞,当运行完成处理函数,才会再一次信号递达。当所有信号2处理函数执行完毕后,才会执行信号3处理函数

可重入函数和不可重入函数

假设在主执行流(main函数)执行链表的头插,进入到头插函数p->next = head,突然发生了时钟中断,CPU通过中断号,查找中断向量表,执行中断处理方法后,操作系统会检查进程的三张表,发现有信号递达,就会调用信号处理函数sighandler,这个处理函数里面也是一个头插函数,执行完后,head指向node2,返回内核态,无信号递达,然后进程变成用户态,继续执行main函数中代码,head 指向了node1,这个时候就会发生了内存泄漏了。

insert函数被两个以上的执行流重复进入了,

有可能在第⼀次调⽤还没返回时就再次进⼊该函数,这称为重⼊

有可能因为重⼊⽽造成错乱,像这样的函数称为 不可重⼊函数,

有可能因为重⼊不造成错乱,像这样的函数称为 可重⼊函数,

一般我们可以以该函数是否需要全局数据判断,需要就是不可重入,不需要就是可重入

volatile

这个是C语言32个关键字的其中之一。关于编译器优化的,

在编译器中,会有自己的优化选项,命令如下:

bash 复制代码
man g++/gcc

优化级别有-O0、-O1、-O2、-O3等,默认情况是-O0

下面写一个编译器不进行优化的代码:

cpp 复制代码
int cut = 0;
void SigHandle(int sig)
{
    cout << "cut :" << cut << "-> 1" << endl;
    cut = 1;
}
int main(int argc ,char* argv[], char* env[])
{
    signal(SIGINT,SigHandle);
    while(!cut);
    cout << "process quit" << endl;
    return 0;
}

使用默认优化级别:

bash 复制代码
g++ send.cc -o send.exe -std=c++11 -g

如图:

使用优化级别-O1:

bash 复制代码
 g++ send.cc -O1

如图:

可以看到,优化后的会一直死循环。主要就是main函数执行流的cut没有被更改,

如图:

编译器没有优化的时候,在main里每循环一次会直接从内存获取到cut值,保存到寄存器中,然后在寄存器中进行计算,

编译器优化后:发现main里的循环中不会更改cut,就会给cut加一个建议性关键字 register,直接把cut加载到寄存器里,不会再从内存中获取数据,从而提高运行速度,而信号处理的执行修改后的cut在内存上是1,CPU不会从内存获取cut值,直接使用寄存器中cut的值,这就是导致循环的原因

为了防止这种情况出现,就会使用volatie修饰cut,

cpp 复制代码
volatile int cut = 0;
void SigHandle(int sig)
{
    cout << "cut :" << cut << "-> 1" << endl;
    cut = 1;
}
int main(int argc ,char* argv[], char* env[])
{
    signal(SIGINT,SigHandle);
    while(!cut);
    cout << "process quit" << endl;
    return 0;
}

如图:

SIGCHLD信号

使用fork系统调用接口创建子进程,⽤wait和waitpid函数清理僵⼫进程,⽗进程可以阻塞等待⼦进程结束,也可以⾮阻 塞地查询是否有⼦进程结束等待清理(也就是轮询的⽅式)。采⽤第⼀种⽅式,⽗进程阻塞了就不 能处理⾃⼰的⼯作了;采⽤第⼆种⽅式,⽗进程在处理⾃⼰的⼯作的同时还要记得时不时地轮询⼀ 下,程序实现复杂。

为了既能让父进程执行自己的任务,又能不写轮询的方法,

其实,⼦进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以⾃ 定义

SIGCHLD信号的处理函数,这样⽗进程只需专⼼处理⾃⼰的⼯作,不必关⼼⼦进程了,⼦进程 终⽌时会通

知⽗进程,⽗进程在信号处理函数中调⽤wait清理⼦进程即可。

写一个:⽗进程fork出⼦进程,⼦进程调⽤exit(1)终⽌,⽗进程⾃定 义SIGCHLD信号的处理函数, 在其中调⽤wait获得⼦进程的退出状态并打印。代码如下:

cpp 复制代码
void SigHandle(int sig)
{
    int statu = 0;
    cout << "child signal : " << sig << endl;
    if(wait(&statu) < 0) //如果有多个子进程同时发送信号,就会导致有一些子进程是僵尸,
        cout << "child wait error" << endl;
    else
        cout << "exit code :" << ((statu>>8)&0xff) << " siganl code:" << (statu&0x7f) << endl;
    
}
int main(int argc ,char* argv[], char* env[])
{
    pid_t id = fork();
    if(id < 0)
        cerr << "fork error" << endl;
    else if(id == 0)
    {
        cout << "I am is child process" << endl;
        exit(1);
    }
    signal(SIGCHLD,SigHandle);
    while(true)
    {
        cout << getpid() << "---运行中" << endl;
        sleep(1);
    }
    return 0;
}

如图:

如果是创建多个子进程的话,有可能一下子进程多个退出,导致部分子进程无法回收,使用上面的处理方法就会有可能产生僵尸进程,我们可以修改处理函数。

cpp 复制代码
void SigHandle(int sig)
{
    int statu = 0;
    cout << "child signal : " << sig << endl;
    while(true)
    {
        pid_t n = waitpid(-1,&statu, WNOHANG);//如果有多个子进程同时发送信号,就会导致有一些子进程是僵尸,
        if( n < 0) 
        {
            cout << "child wait error" << endl;
            break;//子进程回收完了
        }  
        else if(n > 0)
            cout << "exit code :" << ((statu>>8)&0xff) << " siganl code:" << (statu&0x7f) << endl;
        else
            break; 
    }
   
    
}

上面虽然好,但是写起来麻烦,我们可以使用sigaction系统调用接口,

⽗进程调 ⽤sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终⽌时会⾃动清理掉,不 会产⽣僵⼫进程,由系统直接回收。

cpp 复制代码
int main(int argc ,char* argv[], char* env[])
{
    struct sigaction btable;
    struct sigaction oldtable;
    btable.sa_handler = SIG_IGN;
    btable.sa_flags = 0;
    // signal(SIGCHLD,SigHandle);
    sigaction(SIGCHLD,&btable, &oldtable);
    for(int i = 0; i < 10 ; i++)
    {
        pid_t id = fork();
        if(id < 0)
            cerr << "fork error" << endl;
        else if(id == 0)
        {
            cout << "I am is child process :" << getpid() << endl;
            sleep(3);
            exit(1);
        }
    }
   
    while(true)
    {
        cout << getpid() << "---运行中" << endl;
        sleep(1);
    }


    return 0;
}
相关推荐
且从容.2 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道2 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
且从容.5 小时前
ubuntu安装flink
linux·ubuntu·flink
爱和冰阔落7 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨7 小时前
WSL2的安装与使用
linux·wsl
微小的鱼7 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng7 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮8 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
Linux-lucky20 小时前
45-Linux学习之旅之防火墙与WEB加固
linux·前端·网络·学习·ubuntu
禁默20 小时前
启元 Q1 二次开发实践:Ubuntu 部署与仿真验证,从 PrimeConsole 到 ROS 2 Humble + RViz
linux·运维·ubuntu