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;
}
相关推荐
北风toto2 小时前
Linux 服务器如何判断 CPU 是否支持 x86-64-v2 指令集
java·linux·服务器
程序员-Benothing2 小时前
Linux抓包分析教程:tcpdump、Wireshark、tshark实战
linux·运维·服务器
QMCY_jason2 小时前
ubuntu x86_64架构的编译 aarch64架构的程序
linux·运维·ubuntu
拂拉氏2 小时前
【知识讲解】 Linux进程间通信之管道与共享内存
linux·进程间通信
其实防守也摸鱼2 小时前
Linux文本处理命令理论学习
linux·服务器·前端·数据库·sql·学习·安全
无足鸟ICT2 小时前
【RHCA+】rpm命令(安装、升级、卸载和查询)
linux·运维·服务器
吹什么轩2 小时前
linux系统复习:从冯·诺依曼到操作系统:一切的开场
linux·运维·服务器
程序员-Benothing2 小时前
SSH从入门到精通:密钥登录、config配置、跳板机
linux·运维·服务器·ssh
H.莓飛3 小时前
【Linux】自主 Shell
linux·运维·服务器·c语言·开发语言·visual studio