个人主页:平凡的小苏
学习格言:命运给你一个低的起点,是想看你精彩的翻盘,而不是让你自甘堕落,脚下的路虽然难走,但我还能走,比起向阳而生,我更想尝试逆风翻盘。
C++专栏:Linux内功修炼
家人们更新不易,你们的点赞和⭐关注⭐真的对我真重要,各位路 过的友友麻烦多多点赞关注。欢迎你们的私信提问,感谢你们的转发! 关注我,关注我,关注我,你们将会看到更多的优质内容!!
前台进程:
我们编写以下程序并运行:
#include
#include
#include
using namespace std;
int main()
{
while (1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
此进程在当前状态下就是前台进程。我们知道该程序的运行结果就是死循环地进行打印,而对于死循环来说,最好的方式就是使用Ctrl+C(前台进程适用)对其进行终止。
后台进程:
我们在./myproc命令后加上 & 符号,就可以把此命令放到后台执行,也就是切换至后台进程:
从图中可以看出在命令后面加了&后,会发现按ctrl+c无法终止。但是按命令是有效果的,因为这是后台进程,不会影响命令。如果想要查看该后台进程,可以按jobs,看到当前后台进程:
如果想要将后台进程变为前台进程,可以按fg [jobs中对应的号],这里对应的是1,就是fg 1。这时,按ctrl+c就可以结束进程了。
我们可以通过jobs命令查看所有创建的后台进程,这里把3号进程转换为前台进程(fg 3),并ctrl + c结束进程:
如果现在把5号进程提到前台(fg 5),但是我如果后悔了,我们就可以先按ctrl + z键,此时会发现5号进程处于Stopped暂停状态,再输入bg 5命令,即可将5号进程running状态,并且是后台进程:
现在来总结前台、后台进程的相关指令:
注意:
用户按下Ctrl-C,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
信号是进程之间事件异步通知的一种方式,属于软中断。
我们可以使用kill -l命令查看Linux当中的信号列表
实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下:
#include
#include
int kill(pid_t pid, int sig);
上文说到信号是会被进程记住的(有没有产生 + 什么信号产生)。实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块PCB中的。我们都知道进程控制块本质就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生。因此,我们可以用一个32位的位图来记录信号是否产生:
task_struct
{
uint32_t sig; //位图
};
task_struc是内核的数据结构,所以只有OS操作系统有直接修改这个task_struct的数据位图的权利。因为OS是进程的管理者,进程的所有的属性的获取和设置只能由OS来操作。所以无论信号怎么产生,最终一定只能是OS来进行信号的设置。
注意: 信号只能由操作系统发送,但信号发送的方式有多种。
当面对下面的死循环程序时,我们都知道可以按ctrl-c来终止该进程。
#include
#include
#include
using namespace std;
int main()
{
while (1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
但实际上除了按ctrl-c之外,按ctrl-\也可以终止该进程:
按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。
#include
#include
#include
using namespace std;
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
}
int main()
{
signal(SIGINT,myhandler);//收到这个信号才能调用这个函数
//信号的产生和我们自己的代码的运行是异步的
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
return 0;
}
此时会发现无论怎么按ctrl-c都无法结束该进程,原因是都会去调用自定义处理动作。为了杀掉此进程,此时我们可以先使用如下的命令查看进程的pid信息,换一种杀掉进程的信号,因为2号信号已经被我们自定义捕捉了。
总结用户层产生信号的方式:键盘产生
问:OS是如何发送信号的?
- OS能找到每个进程的take_struct,也能找到当前显示器上前台进程的take_struct,每一个进程的take_struct内部都有一个位图,OS在拿到了对应的信号后,将这个对应的位置由0设为1,OS就完成了信号的发送(OS发送信号,也可以说成是写入信号)
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发送SIGALRM14号信号,该信号的默认处理动作是终止当前进程,alarm函数的函数原型如下:
#include
unsigned int alarm(unsigned int seconds);
alarm函数的返回值:
进程崩溃的本质就是进程在运行过程中收到了操作系统发来的信号而被终止。那么操作系统是如何识别到一个进程触发了某种问题呢?
除零
越界 && 野指针
先前我们在学习进程等待的时候,说到过进程等待时,父进程在必要时需要获取子进程的退出状态,我们需要用到status参数,调用waitpid函数完成:
其中次低8位代表的是进程退出时的退出状态(进程退出码),低7位代表的是进程是否收到信号(异常终止),其中有一个标记位(第8位)叫核心转储core dump。
问:什么是核心存储core dump?
ulimit -a
命令查看当前资源限制的设定:其中,第一行显示的core文件大小为0,即表示核心转储是被关闭的。所以这也就是为什么我上面获得的core dump为0,因为我core文件默认是关闭的为0啊。但是我们可以通过ulimit -c size
命令来设置core文件的大小
core文件的大小设置完毕后,就相当于将核心转储的功能打开了。
总结:
- 当我们一个进程在异常退出时,如果收到了某些信号, 某些异常是系统的,为了便于调试,它会在你异常退出时,触发core dump核心转储机制,像一些内部的错误,进程在异常终止后,core dump标记位给你置1,并且会在当前路径下给你生成一个大文件,上面的core.22218文件就是的(后面的数字代表此进程pid),像一些外部的错误则跟我没关系了
- core dump会把进程在运行中,对应的异常上下文数据,core dump核心转储到磁盘上,方便调试,并且会把当前退出的status的core dump标志位给置为1。
core dump有何用呢?
看如下的代码测试:
#include
using namespace std;
int main()
{
cout << "begin ..." << endl;
int *p = nullptr;
*p = 1000;
cout << "end ..." << endl;
return 0;
}
很明显,上述代码发生了野指针错误,测试如下:
此时我们在当前目录下可以看到核心转储时生成的core文件:
现在我们使用gdb来调试此可执行程序,然后直接使用 core-file core文件 命令加载core文件,即可判断该程序在第7行发生了段错误,并在进曾终止时收到了11号信号,且定位到了产生该错误的具体位置的代码:
注意:事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。如上就是core dump的好处(便于调试)
问:为什么core dump一般默认是关掉的?
- 虽然core dump的好处很明显**(便于调试,直接定位错误)**,但是假象一下,如果有一天你的代码本身发生了错误,不是外部错误,万一有些解决策略就是把服务不断重启,那么就会出现一个问题,一运行就挂,每次重启就core dump一下,且赠送你一个几百kb左右大小的core文件,若重启了一晚上,那么你的磁盘全是core文件,磁盘上全是垃圾文件,那么OS就可能收到影响。若扩大到企业级那风险可就大了,即使你限制了core文件的大小,但这些垃圾文件总归是不好的。
递达
:实际执行信号的处理动作称为信号递达(Delivery),也叫信号处理(执行默认动作,忽略,自定义捕捉)未决
:信号从产生到递达之间的状态,称为信号未决(Pending)阻塞
:进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
信号在内核中的表示示意图如下:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针数组(handler)表示处理动作。
block:阻塞信号集,和pending都是位图,对应的比特位为1,就会拦截对应的信号去递达对应的方法,即使pending为1收到了信号也没用。
pending:用来识别信号中对应信号的位置,若为1,就说明收到信号,为0,说明没收到信号。
handler:用来处理信号,信号的编号就作为这个函数指针的数组下标,直接可以访问到对应的自定义的方法,或者系统默认的处理方法。
这三个应该先看pending位,如果pending为1,再去看block,如果block为0,再去看handler。
解释上图:
信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号的pendging位为1,说明收到2号信号。但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
根据信号在内核中的表示方法,每个信号只有一个bit的未决标志,非0即1,不会记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,在我当前的云服务器中,sigset_t类型的定义如下:(不同OS实现sigset_t的方案可能不同)
sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞
而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
block阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include
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);
解释上述函数:
sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有号。
sigaddset函数:在set所指向的信号集中添加某种有效信号。
sigdelset函数:在set所指向的信号集中删除某种有效信号。
sigemptyset、sigfillset、sigaddset、sigdelset函数都是成功返回0,出错返回-1。
sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。
sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
参数说明:
如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改
如果oset和set都是非空指针,则先将原来的信号屏蔽字被分到oset里,然后根据set和how参数更改信号屏蔽字
假设当前的信号屏蔽字为mask,下标说明了how参数的可选值及其含义:
返回值说明:
注意: 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:
#include
int sigpending(sigset_t *set);
sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。
来看如下的实验:不断的获取当前进程的pending信号集,以2号信号为例,如果我们直接获取2号信号,会因为递达的太快,我们还没等看到打印出来,就已经结束了,所以这里我们利用信号屏蔽字,把2号信号屏蔽,具体操作如下:
先用上述的函数将2号信号进行屏蔽(阻塞)
使用kill命令或组合按键向进程发送2号信号
此时2号信号会一直被阻塞,并一直处于pending(未决)状态
使用sigpending函数获取当前进程的pending信号集进行验证
#include
#include
#include
using namespace std;
void PrintPending(sigset_t& pending)
{
for(int signo = 31; signo >= 1; signo--)
{
if(sigismember(&pending,signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl << endl;
}
int main()
{
//对2号信号忽略
signal(2,SIG_IGN);
//1.先对2号信号进行屏蔽
sigset_t bset,oset;
sigemptyset(&bset);
sigemptyset(&oset);
sigaddset(&bset,2);
//调用系统调用,将数据设置进内核
sigprocmask(SIG_SETMASK,&bset,&oset);//这里才是把2号信号进行屏蔽了
//重复打印当前进程的pending
sigset_t pending;
int cnt = 0;
while(true)
{
//获取
int n = sigpending(&pending);
//打印
PrintPending(pending);
sleep(1);
cnt++;
//解除阻塞,pending会由1变成0
if(cnt == 10)
{
cout << "2号进行解除屏蔽" << endl;
sigprocmask(SIG_SETMASK,&oset,nullptr);
}
}
//发送2号信号
return 0;
}
根据测试结果得知:程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending表一直是0,当我们使用kill命令向该进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1。
问:如果我们把信号全部屏蔽了会怎么样呢?
进程处理信号,不是立即处理的时候,是在合适的时候处理的。那么这个合适的时候是什么时候呢?
问1:何为内核态?何为用户态?
首先,每一个进程都有自己的进程地址空间,该进程地址空间由内核空间(3G4G)和用户空间(03G)组成:
用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
内核空间存储的实际上是OS代码和数据,通过内核级页表与物理内存之间建立映射关系。
内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是OS的代码和数据,所有进程看到的都是一样的内容。用户级页表(03G)每一个进程都有一份,而且大家的用户级页表都是不一样的。内核级页表(3G4G)所有进程共享,只有一份,前提是你有权利访问。一个进程是如此,多个进程亦然如此:
问2:如何理解进程切换?
在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。
回到一开始的问题:何为内核态与用户态?
进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候。
看如下的图:
当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
上述信号捕捉的过程较为复杂,我们可以借助下图来帮助我们记忆:(∞)
其中,该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着进行信号检测。
捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉(设置对特定信号的特定处理的动作),sigaction函数的函数原型如下:
#include
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:
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_handler:
结构体的第二个成员sa_sigaction:
结构体的第三个成员sa_mask:
结构体的第四个成员sa_flags:
结构体的第五个成员sa_restorer:
示例:(下面我们用sigaction函数对2号信号进行了捕捉,对2号信号的处理动作依次进行测试)
#include
#include
#include
#include
using namespace std;
void handler(int signo)
{
cout << "我的自定义捕捉信号: " << signo << endl;
}
int main()
{
struct sigaction act,oact;
memset(&act, 0, sizeof act);
memset(&oact, 0, sizeof oact);
act.sa_handler = handler;
sigaction(2,&act,&oact);
while(1)
{
sleep(1);
}
return 0;
}
先前我们学习链表的时候,都清楚链表头插的过程:(如下带哨兵位头节点的单链表)
下面主函数中调用insert函数向链表中插入节点node1,此时某信号处理函数也调用了insert函数向链表中插入节点node2,乍一看好像没什么问题:
下面我们来分析一下,对于下面这个链表:
1、首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数:
2、而sighandler函数中也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:
3、当结点node2插入的两步操作都做完之后从sighandler返回内核态,此时链表的布局如下:
4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作:
最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏。
- 像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。
- 而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
#include
#include
int flags = 0;
void handler(int signo)
{
printf("更改flags:0->1\n");
flags = 1;
}
int main()
{
signal(2, handler);
while (!flags);
printf("进程正常退出的!\n");
return 0;
}
这里的结果和我i们的预期是相同的,因为发送了2号信号,就将flags由0置1了。但是这时在gcc编译器中,如果在别的编译器(高优化级别的)中就不一定是这种效果了。
此时我们增加了编译器的优化级别,flags就很可能会被设置进寄存器里头,从此while循环检测的时候,其只从寄存器中读取数据,但是我们后续修改的flags是内存中的,不是寄存器中的,所以寄存器中的flags恒为0,此进程就会一直陷入死循环。
结果如下:
面对这种情况(编译器把flags优化到寄存器),我们就可以使用volatile关键字对flags变量进行修饰,告诉编译器,对flags变量的任何操作都必须真实的在内存中进行(不准对flags做任何优化),即保持了内存的可见性:
#include
#include
volatile int flags = 0;
void handler(int signo)
{
printf("更改flags:0->1\n");
flags = 1;
}
int main()
{
signal(2, handler);
while (!flags);
printf("进程正常退出的!\n");
return 0;
}
此时就算我们编译代码时携带-O2
选项,当进程收到2号信号将内存中的flags变量置1时,main函数执行流也能够检测到内存中flags变量的变化,进而跳出死循环正常退出。