作者前言
🎂 ✨✨✨✨✨✨🍧🍧🍧🍧🍧🍧🍧🎂
🎂 作者介绍: 🎂🎂
🎂 🎉🎉🎉🎉🎉🎉🎉 🎂
🎂作者id:老秦包你会, 🎂
简单介绍:🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂🎂
喜欢学习C语言、C++和python等编程语言,是一位爱分享的博主,有兴趣的小可爱可以来互讨 🎂🎂🎂🎂🎂🎂🎂🎂
🎂个人主页::小小页面🎂
🎂gitee页面:秦大大🎂
🎂🎂🎂🎂🎂🎂🎂🎂
🎂 一个爱分享的小博主 欢迎小可爱们前来借鉴🎂
Linux进程信号
信号的开始
注意:信号 和信号量没有任何关系,两者互不干扰。
在现实生活中,无时无刻不存在信号,比如红路灯、喇叭声,或是闹钟铃声等,就拿睡觉来说,睡着睡着突然闹钟响了,然后必须起床,人是进程,睡觉是进程在运行的事情,闹钟铃声是信号,
信号:中断人们正在做的事情,是一种事件的异步通知机制 。信号的产生,相较于进程运行的,是异步的。
信号是发送给进程的。
从睡觉这个例子我们可以知道
- 信号的出现,知道怎么处理
- 信号处理的时间
- 信号处理是被"教育"出来的,OS程序员在进程内部已经内置了信号的识别和处理方式
- 进程中的信号源有很多。
位图:位图本质上是一个很长的二进制位数组,每个比特位表示某个元素"有没有""是不是""在不在"。
一个整数表示有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是实时信号。
普通信号:接收到可以不立刻处理
实时信号:接收到必须立刻处理
进程收到信号后 会在合适的时间进行处理,处理方式如下:
- 默认处理
- 自定义处理
- 忽略处理
上面的进程处理信号是默认处理,如果我们要更改默认处理动作,可以调用系统调用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
下面写一个进程发送信号,一个进程处理信号的代码。如下:
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;
}
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传入参数
可以理解为一个是下标,一个对应下标下的值。如图:

除此之外,信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志 。
图中的例⼦中:
- SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
- SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。它的处理动作是忽略,
- 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;
};
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.
捕捉信号
捕捉过程
我们知道,进程收到普通信号,不会立刻被处理,而是在合适的时候去处理,这个合适的时候是啥时候,下面提供一个图:

大致情况就是
- 当主程序在运行到某一行代码的时候,触发了异常或者中断又或者调用系统调用的时候,进入到操作系统内部,
- 在操作系统内部处理完或者调用完后,就要返回主程序运行的时候,由于操作系统的规则,在返回之前会检查该进程是否有信号,是否被阻塞,如果有信号,并且没有阻塞,OS就会调用对应的处理函数,
- OS检查进程信号的是会调用系统调用do_siganl,如果发现信号没有block,就会信号递达,当执行自定义函数要结束的时候,就会调用系统调用sigretum,再次进入到内核中,
- 再次进入到内核中,再次检查信号,如果没有新的信号要递达,从内核返回到主程序中继续执行用户代码
- 执行自定义函数的时候,会以用户身份(用户态)运行,当该函数运行返回的时候,这个返回地址不是原来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
信号捕捉的过程
- 主程序运行过程中由于中断、异常(除0)或者系统调用接口,进入到操作系统内部,如果是中断,CPU获取到中断号,查询操作系统中中断向量表(IDT),执行其中的处理方法, 如果是系统调用接口,CPU会获取到系统调用号,查询系统调用表,调用系统调用,并把CS段寄存器中的CPL改为0,
- 当处理完毕后,CPU会检查当前进程的三张表 blcok 、pending 、handler、如果有信号,并且没有被阻塞,就会调用对应的处理方法,
- 如果这个方法是自定义函数,把CS段寄存器中的CPL改为3,以用户身份执行自定义函数,执行完自定义函数,并调用系统调用,CPU就会CS段寄存器中的CPL改为0。
- 如果没有信号。 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);
};
- 为啥会有这个sa_mask信号集,如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。
- 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;
}