绪论
“Do one thing at a time, and do well.”,本章开始Linux系统其中信号是学习操作系统的基本下面将会讲到什么是信号、信号的多种产生方式、信号如何保存的、信号如何处理的、以及一些信号的细节。话不多说安全带系好,发车啦(建议电脑观看)。
1.信号的概念
日常生活中有很多类似于信号的东西,如红绿灯、下课铃声、闹钟声音等
他相当于是一种信息,当在传递给你时你需要对他有认识并且知道其信号的意义是什么。这也表明了我们需要提前存储这些信号的概念,对传来的信号能进行识别处理。
信号就是一种向目标进程发送通知消息的一种机制。
所以一个进程处理信号需要:
- 当信号没有产生时,就已经知道该怎么处理这个信号。
- 信号的到来我们并不清楚,所以信号到来时我可能正在进行的代码,所信号的产生和进程是异步。
1. 异步:可以理解为有两个执行流,各自执行自己的执行流(他跑他的、你跑你的) - 进程能暂时保存好到来的信号
kill命令:
- 向指定进程发信号:kill signum(信号编号) pid(进程的pid)
具体serce的实现过程见 2.3信号能通过键盘输入产生的第一处代码! - 查看所有信号:kill -l
1. 其中没有0号信号!(一个进程就两种退出信息,包括信号和退出码(0/非0)),因为0号信号表示没有收到信号,为了标识进程的正常结束。
2. 没有32、33信号,普通信号:1 ~ 31、实时信号:34 ~ 64(收到信号时立即处理,不会出现信号丢失)。
3. 所有信号都是一个个宏,所以信号可以用数字和字符串表示。
下面对所有普通信号我们边学边认识
2.信号的产生
进程运行时分为:
- 前台(==./xxx ==在前台执行进程)
1. 前台进程只能有一个(键盘只有一个)
2. 能接受键盘用户输入的指令 - 后台(./xxx & 在后台启动该进程)
1. 后台可以有多个进程
2. 一般放耗时较长的任务
3. 反之不能接受来自键盘的指令
其中:[1]表示后台任务编号;20103是进程的pid
通过指令查看后台任务:jobs
后台任务不影响前台任务。
把进程从后台放回前台:fg 后台任务编号(jobs第一列)
通过上图分析得:
3. 把编号1的任务放到前台时发现pwd指令用不成了,说明原本的shell是一个前台进程,也证明前台进程只能有一个(1号任务代替了shell进程所以shell指令也随之用不了了)
4. ^ C表示的是:ctrl + c 能终止前台的进程
5. 当终止掉当前的前台进程后发现shell自动启动了,所以shell是能被自动的提到前台的进程!
2.1OS接收键盘的数据?通过中断技术
中断技术:很多外设都能对CPU(CPU的针脚)发送中断信息(光电信号)表示数据就绪,发送来的光电信息就会被8259(一个板子)给到CPU的针脚(有编号的,又称中断号),发送就绪信息保存到寄存器就能被程序读取,就从硬件到了软件就能去读取信息了。
所以每个进程都有中断向量表,数组的下标就和信号的编号是强相关的。
2.2处理信号方法有
- 忽略处理
- 默认处理
- 自定义捕捉处理
2.3通过系统调用发送信号
-
捕获信号的函数:
sighandler_t signal(int signum, sighandler_t handler);
头文件:#include <signal.h>
参数:
1. signum:是信号编号,每个信号都有对应的编号
2. handler:是一个函数指针 typedef void (* sighandler_t)(int)
1. 自定义的方法,这个函数的类型就是:void ( * )(int)
其中我们需要知道该进程是否需要被终止掉,当需要终止时我们需要终止(exit(1 ))进程,否则进程就不会被终止,将导致进程一直运行。
2. 默认方法:SIG_DFL
3. 忽略方法:SIG_IGN
其中SIG_DFL、SIG_IGN本质就是宏(本质也是函数指针):
把0强转为SIG_DFL,把1强转为SIG_IGN,最终在handler内部再转为int判断是否为0/1再分别处理。
-
给指定进发送指定信号:
int kill(pid_t pid, int sig);
头文件: #include <sys/types.h>、#include <signal.h>
参数:
1. pid:进程pid
2. sig:信号编号 -
给自己发送指定信号:
1. int raise(int sig);
头文件: #include <signal.h>
参数:
1. sig:信号编号
代码:
void handler(int signo)
{cout << "捕获到信号第: "<< signo << "号信号"<< endl;sleep(1);exit(1);
}int main(int argc,char* argv[])
{signal(2,handler);raise(2);//给自己发送2号信号sleep(10);//若没有被终止则会休眠10s
}
- 正常终止当前进程:
void abort(void);
原理其实是:向自己发送六号信号(SIGABRT)
头文件:#include <stdlib.h>
2.4信号能通过键盘输入产生
键盘的输入的数据可能是数据也可能是组合键表示信号
- ctrl+c:发送二号信号(SIGINT)终止进程自己,从键盘输入后通过中断技术在中断向量表中找到直接所要执行的方法,所以也表明二号信号会终止进程自己。
- 1.ctrl z:发送20号信号默认停止进程自己(放回到后台暂停,后台任务继续运行:bg 后台编号)
- == ctrl \:发送3号信号默认终止进程==
当我们从键盘输入就能产生信号给前台进程
证明:当键盘输入ctrl+c时就会产生二号信号给当前前台进程(图中^C就是从键盘输入的ctrl c)
代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void handler(int signo)//自定义处理函数handler
{cout << "接收到" << signo << "号信号" << endl;//打印是几号信号
}int main()
{signal(2, handler);//捕获信号的函数,捕获到产生的信号并进行自定义处理函数handlerwhile (true){std::cout << "runing ... ,pid:" << getpid() <<std::endl;sleep(1);}return 0;
}
2.4.1信号的存储
操作系统向目标进程发送信号,实际上就是对pcb结构体中的位图成员进行改写
也就是pcb结构体中有一个位图的成员变量
struct task_struct
{//其余成员变量...uint32_t sigmap;//信号位图
}
进程中会有一个信号的位图的成员,存着接收到的信号!
位图:0000 0000 … 0000 (1 ~ 31普通信号31位)
所以实际上发信号其实就是OS找到进程pcb修改位图(也就是写入信号:如1号信号的写入0000 … 0001)!
所以总结来说每个进程都会有
- 函数指针数组(中断向量表)
- 信号位图
有了这两个东西才能让进称对信号正常的接收和处理
所以我们从键盘到信号生效,也就明了了:
接收到中断信息后存储产生的信号,然后要再通过存储的信号在中断向量表中找到对应的方法就能让信号产生对进程的对应效果!
而只有OS能发送信号(因为只有OS才能修改进程内的成员)
查看信号的默认处理方式:man 7 signal(处理方法只有三种:默认,忽略,自定义)
打开后往下翻找到
其中默认处理方法就是Action一列:
只有少量的信号不能被自定义捕捉,如:9号信号,即使写了自定义方法也仍然会执行默认的Term(终止进程)
写一个通过中终端控制向目标进程发送信号的程序:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <string>
using namespace std;int main(int argc,char* argv[])
{if(argc != 3){cout << "格式错误,因为为:./process signal processpid" << endl;exit(0);}int signum = stoi(argv[1]+1);//传进来的是-9int processpid= stoi(argv[2]);kill(processpid,signum);//+1跳过-得到数字return 0;
}
//编译时加上-std=c++11
SIGCONT 18:从后台放回前台继续运行
2.5异常产生信号:
- 当是除0错误时,会向该进程发送8号信号(SIGFPE)。
从虚拟地址中获取数据给到在内存中的进程,在通过进程给到CPU处理,CPU处理的过程中会有一个溢出标记位,记录是否出现了错误,若出现错误了OS就会处理该错误,也就是发送信号给当前进程。
2.访问野指针错误(段错误),会向进程发送11号信号(SIGSEGV)。
附:我们在学异常时实质上:抛出异常的主要目的并不是处理异常,而是让程序执行流正常结束(还能打印错误)。
2.6软件条件产生信号:
- 管道软件产生信号:
匿名管道,当读关闭掉时,就会向写端发送信号(SIGPIPE)终止。
为什么说管道是一个软件:因为他并没有涉及到底层硬件的处理,管道空间属于是我打开的文件而文件的本质就是软件,通过OS判断管道文件的数据结构,当OS识别内核数据结构不满足条件时就会杀死掉进程。
此处:判断管道软件的读端被关闭了(底层是通过读文件描述符查看,并没有查看CPU寄存器的硬件,所以也就是软件问题),当OS发现读端被关闭(管道引用计数为1了)所以软件条件不满足,所以就会OS就会发送SIGPIPE给进程终止。 - 闹钟软件产生信号:
unsigned int alarm(unsigned int seconds);//闹钟函数
功能:在second秒后给当前进程发送SIGALRM信号(14号信号),默认动作是终止进程。
头文件:#include<unistd.h>
闹钟本质上就是OS中描述的一个结构体(也就是一个软件),其中包含了许多成员变量(信息),结构体成员中肯定有一个存着时间的成员记录着当前闹钟的是否超时,超时了就会响(也就是发送信号给指向的进程)。(而这些闹钟的时间的存储就可能是在一个小堆中,通过不断查看堆顶元素是否超时,超时就pop出堆来进行闹钟的运行)。
当这个时间一超时就会先闹钟中结构体存的成员:进程的pid发送信号。
alarm(0)是取消上一个闹钟,并返回他剩余的时间。
为什么软件也能产生信号:
本质上是因为OS是软硬件资源的管理者,软件出问题了时,软件也能产生信号给OS让他处理进程。
2.7操作系统的一些更加深入的底层知识:
- 在计算机内部有个纽扣电池一直在给某些硬件(计数器)供电记录时间,所以最后开机时在计算处正确的时间。
- 所有用户的行为最终都是以进程的形式在OS中表现的(打开如何东西)。所以操作系统只用把进程调度好,就能完成所有的用户任务。
- CMOS,能向CPU周期性的,高频率的发送时钟中断。(CPU的主频概念的产生)
- 硬件CMOS通过向CPU内发送超高频中断信息才让操作系统运行起来并执行其内部的代码(调度方法),所以操作系统本质也是代码程序也是通过硬件发送中断信息才能被执行对应的程序的!。
- OS的本质就是个死循环while(1) { pause();//暂停进程,只到来信号 },来保证操作系统在开机后一直运行。
总结:产生信号的方式可以有很多,但是发送信号只能由OS发送(写信号)
2.8Core Dump(核心转储)
core dump核心转储就是把进程退出的原因存储进硬盘,Core Dump会形成一个 以在运行时代码中的崩溃处的核心上下文数据 的文件(也就是问题存储进磁盘中),在文件下形成core.pid命名的文件(指定进程pid)。
查看基本配置项(包括了core dump产生的文件大小):
指令:ulimit -a
其中core file size项就表示了当前Core Dump产生文件的大小
ulimit -a 修改 core file size项(并且这个修改只针对当前的shell进程)
在gdb模式下查看core file文件内容
通过在gdb模式下:core-file + 对应的文件名直接查看转储错误的地方
core dump保存东西较多文件比较大,并且每当运行一次程序出现问题就会产生一个,所以为了防止一些特殊进程会不断重启(异常,重启循环不断的生成core file文件),所以云服务器就默认关闭了,所以若想启动的话就去修改基础配置中core file的文件大小即可。
而Action中:Term终止和Core终止的区别:
报Core错误的才是真正的异常错误,并且Core比较严重,用户还需自己进一步排查代码哪里错(Term反之),并且若有错误并且开启了core file size非0则会生成core file文件。
重要知识总结:
1. 信号的产生都是经过操作系统的,OS是进程的管理者
2. 信号并不是立即处理,在合适的时候处理
3. 信号需要被暂时记录下来(位图)
4. 进程还没收到信号就知道如何处理信号(维护一张函数指针数组表)
5. OS本质并不是发信号,而是写信号!
3.信号的保存(阻塞)
信号的常见名称(概念):
- 信息递达:实际执行信号的处理动作(也就是合适的时候处理信号)
处理信号的方法:- 信号的忽略
- 自定义捕捉
- 信号的默认
其中可以用signal函数:
sighandler_t signal(int signum, sighandler_t handler);(可见于2.3.1节处)。
- 信号未决:信号从产生到递达之间的状态(也就回把信号保存在pcb的信号位图中,是还未被执行时)。(理解为:记录了老师布置的作业,此时只是记录还未处理)。
- 信号阻塞:阻塞时是表示一直处于未决状态,暂时不递达,直到解除对信号的阻塞。
- 忽略:本身没有阻塞,他是一种未决状态(属于递达的一种,没来得及处理),所以说信号未决,信号不一定是阻塞。
即使没有收到某个信号,也能设置信号的状态,
进程结构体中会有三张表
也就是上面阻塞、未决、递达三个概念对应的表
- pending(未决位图表):OS发送信号时本质就是在此处写信号。
- handler(递达的函数指针数组):对应信号的处理方法,相当于signal的第二个参数。
- block(阻塞位图表):是否对特定的信号进行屏蔽(阻塞)
因为每个进程都提前有了上面的三个表,所以也就让进程能认识信号(处理信号) ,其中每一行就对应着一个信号的处理。
对于上面的表,每个信号当产生信号时OS修改pending位图为1,当处理后pending由1变回0,若block为1就被阻塞了无法执行(即使pending为1),一旦block阻塞解除了就需要立即递达!
允许OS向进程发送多次信号,但其实本质也只记录一次信号(普通信号,实时信号则相反是通过一个队列来处理每一次信号)。
3.1sigset_t(信号集)
sigset_t称为信号集。在未决表中每个信号只有一个bit的未决标志,非0即1,同理阻塞也是这样。因此未决和阻塞标志可以用相同的数据类型sigset_t来存储,这个类型由系统提供可以表示每个信号的有效或无效状态,所以还能把block表和pending表称为阻塞信号集和未决信号集,block表还能称为信号屏蔽字(Signal Mask)。
3.2信号集操作函数
下面是对set信号集进行处理的函数
- 在信号集set中全置空0:int sigemptyset(sigset_t *set);
- 在信号集set中全充满1:int sigfillset(sigset_t *set);
- 在信号集set中添加信号signo(比特位处置为1):int sigaddset (sigset_t *set, int signo);
- 在信号集中删除指定信号signo(置为0):int sigdelset(sigset_t *set, int signo);
- 判断指定的signo信号是否在该set信号集中:int sigismember(const sigset_t *set, int signo);
头文件:#include <signal.h>
而其中sigset_t其实也就是个类型,由系统提供为了形参的一个结构体方便我们的用于系统调用。
3.3读取或修改信号屏蔽字(阻塞信号集)的函数:
int sigprocmask(int how, const sigset_t * set, sigset_t * oset);
头文件:#include <signal.h>
返回值:若成功则为0,若出错则为-1
参数:
- how有三个选项标识符
1. SIG_BLOCK:set包含了我们希望添加到当前信号屏蔽字的信号,相当mask=mask|set
2. SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set(把原本的位图mask按位与上传进来的set的取反,意味着在set中的就会从mask中去除)
3. SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于mask=set(也就是把传进来的位图set(参数二)替换掉原有的位图) - set根据参数一,来自定义自己的set位图表传递进去
- oset是一个输出型参数,返回老的mask位图(老的block表的位图)
在信号屏蔽时,有两个信号时无法进行屏蔽的,他们称为管理员信号
分别是9号和19号信号(其余可自行测试是能被屏蔽的)
测试代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <string>
using namespace std;
void handler(int signo)
{cout << "signo:"<<signo << endl;
}
int main()
{cout << "getpid:" << getpid() << endl;signal(2,handler);sigset_t block,oblock;sigemptyset(&block);sigemptyset(&oblock);for(int signo = 1; signo < 32 ;signo++){sigaddset(&block,signo);}sigprocmask(SIG_SETMASK,&block,&oblock);//此处才修改了系统的信号屏蔽字cout << "已经屏蔽所有信号" << endl;while(true){sleep(1);}return 0;
}
指令操作以及结果:
3.3读取未决信号集的函数:
#include <signal.h>
sigpending(sigset_t set)
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1
set也就还是一个输出型参数
实操代码及结果:
void handler(int signo)
{cout << "signo:"<<signo << endl;
}
void PrintPending(const sigset_t& pending)
{for(int signo = 31; signo > 0 ;signo--){if(sigismember(&pending,signo)){cout << "1";}else{cout << "0";}}cout << endl;
}int main()
{signal(2,handler);cout << "getpid:" << getpid() << endl;sigset_t set,oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set,2);sigprocmask(SIG_BLOCK,&set,&oset);sigset_t pending;int cnt = 0;while(true){sigpending(&pending);PrintPending(pending);sleep(1);if(cnt == 5){cout<< "解除屏蔽" <<endl;sigprocmask(SIG_SETMASK,&oset,nullptr);}cnt++;}return 0;
}
信号在要处理方法前pending位图就会先变成0后才执行调用对应方法
4.信号的处理(递达)
4.1信号在合适的时候处理
进程从内核态返回到用户态的时候,进行信号的检测和处理,也就是:进程从内核态返回用户态的时候,进行信号的检测和信号的处理
- 用户态是一种受控的状态,能够访问的资源时有限的
- 内核态是一种操作系统的工作状态,能够访问大部分系统资源
- 用户只能访问用户空间(0~3G)
- 内核态:可以让用户以OS的身份访问内核空间(3~4G)
其中系统调用就是身份的变化在底层工作原理如下图:
- OS共用一个内核页表,所以无论调度那个进程,CPU都能很好的找到操作系统。
- 所有的代码的执行都能在自己的进程地址空间内执行通过地址 跳转的方式,进行调用和返回!!
信号处理的流程:
它分为四步,能忽略细节的看成:
通过上图我们能总结出来:- 会有四次状态的交换(进程从内核态返回用户态的时候,进行信号的检测和信号的处理)
- 即使没有系统调用也同样会有状态的改变(进程切换过程中会从内核到用户来执行对应的代码)
信号捕捉处理函数(针对于handler表):
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- 要处理的信号:signum
- 新修改后的方法:act
- 输出型参数,返回修改前的act
返回值:调用成功则返回0,出错则返回- 1
头文件:#include<signal.h>
act类型是sigaction他是个结构体他的具体类型:
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_handler:就是对应的信号的handler方法
2. sa_mask:处理某个信号时,用来屏蔽额外的信号
3. 其他sa_sigaction、sa_flags、sa_restorer暂时不考虑
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。
总结就是:某一种信号在执行时,若再次产生该信号,产生的信号将被屏蔽 不被处理,他并不会多次处理。
如果要不止屏蔽执行的信号,而且还要屏蔽其他信号,那么就设置sa_mask同步来屏蔽其余的信号。
实践证明sa_handler就是改变handler表内的方法:
void handler(int signo)
{cout << "signo:"<<signo << endl;
}int main()
{struct sigaction act,oact;act.sa_handler = handler;//修改act的sa_handlersigaction(2,&act,&oact);//对对应signum信号通过act修改handler表while(true){sleep(1);}return 0;
}
实践证明sa_mask的使用:当把三号信号加入sa_mask中后,在执行二号信号时,不仅会屏蔽执行着的二号信号,还会屏蔽sa_mask中的三号信号。
代码:
void Print(const sigset_t& pending)
{for(int signo = 31 ; signo > 0 ;signo--){if(sigismember(&pending,signo))//查看signo信号是否在pending位图中{cout << "1";}else{cout << "0";}}cout << endl;
}void handler(int signo)
{cout << "signo:" << signo << endl;while(true){sigset_t pending;sigpending(&pending);//返回得到pending位图Print(pending);sleep(1);}
}int main()
{cout << "getpid:" << getpid() << endl;struct sigaction act,oact;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask,3);//对3号信号进行了屏蔽act.sa_handler = handler;//修改act的sa_handlersigaction(2,&act,&oact);//对对应signum信号通过act修改handler表while(true) sleep(1);return 0;
}
若同时存在好几个信号被堵塞,那么当解除堵塞后,将会在一次信号检测过程中将多个信号进行处理。
附:
系统调用的原理:将系统调用编号存在寄存器,通过这个编号后去函数调用的函数指针数组中去找对应方法。
5.信号的其他补充话题
5.1可重入函数
- 可重入函数:允许被多个执行流重复进入
- 不可重入函数:不允许被多个执行流重复进入(百分之90自定义的函数都是不可重入函数)
下图中出现的问题就是因为多执行流重入而导致头插错误(在执行完1后,收到一个信号后进行立即处理,并且里面也有插入函数从而导致错误,node2找不到内存泄漏),所以下面的函数就是不可重入函数。
一般而言只要函数用到了全局的变量或者容器数据结构,而我们调用的malloc/free他们都是通过全局链表来管理堆的,还有常用的标准I/O库函数(printf…),他们都是不可重入函数方式使用全局数据结构。
5.2volatile
通过下面代码产生的问题来解释volatile的用处:
int flag = 0;void handler(int signo)
{cout << "signo:" << signo << endl;flag = 1;cout << "change flag to:" << flag << endl;
}int main()
{signal(2,handler);cout << "getpid:" <<getpid()<< endl;while(!flag);return 0;
}
此处因为:在main函数中,flag只进行了读,所以当提升优化级别时,将会把flag的存进CPU寄存器的过程,只进行一次(优化),所以当我们产生信号时修改的仅仅只是内存里的,CPU内的将不会改变,所以将不能成功退出。
所以为了避免这种情况我们可以对变量加上volatile,让其内存可见性(这样当内存的变量改变时,CPU内部也会跟着改变)
volatile int flag = 0;//修改即可!!!
5.3SIGCHLD信号
子进程退出会僵尸需要父进程wait他,他退出时会向父进程发送信号(SIGCHLD信号)
证明代码:
#include<sys/wait.h>//wait的头文件void handler(int signo)
{cout << "signo:" << signo << endl;
}int main()
{signal(SIGCHLD,handler);pid_t id= fork();if(id == 0){//子进程sleep(5);exit(1);}int cnt = 10;while(cnt--){cout << cnt << endl;sleep(1);}wait(NULL);return 0;
}
附:
若要一次性退出多个进程:
==原理是在接收到信号后handler函数内进行子进程的循环等待,这样就能在接收到信号后处理。==但注意的是可能同时处理多个子进程,因为子进程可能同时结束并发送SIGCHLD信号,回顾前面信号当同时发来多个相同的信号时,他不会同时处理相同的信号(会被屏蔽),所以通过循环的方式来处理,让同时传进来的信号的子进程都被等待成功。
void handler(int signo)
{cout << "signo:" << signo << endl;pid_t id;while(id = waitpid(-1,nullptr,WNOHANG))//-1接收所有子进程,WNOHANG非阻塞,返回<=0表示等待不成功{if(id <= 0) break;cout << "回收进程:" << id << endl;}
}int main()
{signal(SIGCHLD,handler);for(int i = 0; i < 10;i++){pid_t id= fork();if(id == 0){//子进程sleep(1);exit(1);}}int cnt = 15;while(cnt--){cout << cnt << endl;sleep(1);}wait(NULL);return 0;
}
但其实Linux操作系统上对SIGCHLD信号进行了特殊的处理,Linux支持手动忽略SIGCHLD,后来所有子进程都不要父进程等待了,退出会自动回收。
具体如下:
int main()
{signal(SIGCHLD,SIG_IGN);//Linux支持手动忽略SIGCHLD,后来所有子进程都不要父进程等待了,退出会自动回收for(int i = 0; i < 10;i++){pid_t id= fork();if(id == 0){//子进程// sleep(5);exit(1);}}int cnt = 10;// while(cnt--)// {// cout << cnt << endl;// sleep(1);// }while(true);wait(NULL);return 0;
}
本章完。预知后事如何,暂听下回分解。
如果有任何问题欢迎讨论哈!
如果觉得这篇文章对你有所帮助的话点点赞吧!
持续更新大量Linux细致内容,早关注不迷路。