🏠关于专栏:Linux的浅学到熟知专栏用于记录Linux系统编程、网络编程等内容。
🎯每天努力一点点,技术变化看得见
文章目录
信号的内核表示与信号阻塞
信号的相关概念
●信号递达:实际执行信号的处理动作。
●信号未决:信号从产生到递达之间的状态。
★ps:进程可以选择阻塞某个信号,被阻塞的信号产生时将保持在未决状态,直到进行解除对信号的阻塞,才会执行递达的动作。
★ps:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达时可选的一种处理动作。
信号在内核中的表示
系统中的信号是发送给进程的,对于进程而言,信号是给进程的PCB发的,PCB使用位图结构记录收到的信号,这样进程执行时就知道自己收到什么信号了。
信号为什么要保存呢?进程收到信号之后,可能不会立即处理。这个信号从产生到递达之间的时间窗口内,进程需要记录该信号已经产生了,等到处理的时候才能知道哪些信号已经发生了。
- 比特位的内容为1或0,表示是否收到某个信号
- 比特位的位置(第几个),表示信号的编号c
- 所谓的“发信号”,本质就是操作系统去修改task_struct的信号位图的对应比特位,发送信号其实就是写信号。
操作系统是进程的管理者,只有它才有资格去修改task_struct内部的属性,即操作系统需要提供相应的系统调用以实现信号的发送、阻塞等功能。
task_struct中针对信号,包含了2张位图和1张函数指针表,分别是block位图、pending位图、handler处理函数指针表↓↓↓
block位图记录某个信号是否被阻塞,如果某个信号对应的比特为1表示该信号被阻塞,为0表示没有阻塞;对于被阻塞的信号,即使该信号产生了,进程也不会对该信号做任何处理。
pending位图记录是否收到某个信号,如果收到某个信号,则会将对应的比特位置1;处理完某个信号后,会将对应的比特位置0。由于每个比特位只能表示信号的有无,若在信号产生到信号递达的时间窗口内,重复收到多个同样的信号,最终也只会递达一次。
handler函数指针数组用于记录对各个信号的处理方式。如果设置为SIG_DFL表示执行系统默认处理函数,设置为SIG_IGN表示忽略该信号;设置为用户空间的某个函数时,待信号递达时,则会从内核态切换回用户态以执行该部分代码。
对于每个信号都有两种标志位分别表示是否阻塞和是否已经处理,还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的pending表对应比特位为未决状态,即比特位置1,直到信号递达才清除该标志,即将对应比特位置0。
对于上图,penging表记录了当前进程收到了2号到6号信号,而1号信号并没有收到。block表记录了,当前进程对3号和5号信号进行了阻塞。
对于2号信号,penging表记录了该信号已经产生,block表记录了该信号没有被阻塞,故进程会执行该信号对应的handler函数指针数组中的操作,即系统的默认操作。
对于4号信号,pending表记录了该信号已经发送,block表记录了该信号没有被阻塞,故该进程会执行对应的操作,即对该信号进行忽略。
对于6号信号,pending表记录了该信号已经发生,block表记录了该信号没有被阻塞,故该进程会执行对应的操作,即指定用户指定的SigHandler函数,此时会发生内核态到用户态的转换。
5号SIGSTAP信号产生过,但正在被阻塞,暂时不能递达。虽然它的默认处理动作是忽略,但是没有解除阻塞之前,不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
3号SIGQUIT信号产生过,一旦解除对该信号的屏蔽,则它就会被递达,因为它的pending表对应比特位尚未被置0。一旦解除对该信号的屏蔽,则会执行SIG_DFL默认动作。
★ps:如果在进程解除对某信号的阻塞之前,这个信号产生过多次,将如何处理呢?POSIX.1允许系统递送该信号一次或多次,Linux是这样实现的:常规信号在递达之前产生多次只记录一次,而实时信号在递达之前产生多次可以依次放在队列里,这里对实时信号不做讨论。
信号集操作函数
从上面的讨论可知,对于阻塞信号集和pending信号集来说,都可以使用同一种类型来实现,只要能够标识某个信号是否被阻塞(或者是否已经发生)即可。故系统提供了一种统一的类型sigset_t来处理这两个位图结构。但为了保证该类型的易用性、可移植性等问题,故用户不能直接操作,而需要使用系统提供的接口。且若是sigset_t指针,指向PCB结构的sigset_t字段时,则不能直接对该结构进行位操作,因为只有操作系统有权限修改PCB的属性信息。↓↓↓
★ps:阻塞信号集(block位图)也就做当前进程的信号屏蔽字,这里的“屏蔽”应该理解为阻塞,而不是忽略。
上图各个系统调用接口的用法如下表所示↓↓↓
系统调用接口 | 用途 |
---|---|
sigemptyset | 将传入的set位图的各个位清零 |
sigfillset | 将传入的set位图的各个位置1 |
sigaddset | 将signum信号在set中对应的比特位置1 |
sigdelset | 将signum信号在set中对应的比特位置0 |
sigismember | 查看signum对应的比特位在set中是否为1,为1则返回1,否则返回0 |
除了sigismember外,其他的调用接口成功返回0,失败返回-1。
注意,在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后,可以调用sigaddset和sigdelset在该信号集中添加或删除某种信号。
★ps:使用时不应该对sigset_t的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
上述接口只是对sigset_t这个位图结构做操作,并未对block和pending位图直接做操作,下面介绍关于这两个位图的系统调用函数↓↓↓
sigprocmask
调用函数sigprocmask可以读取或修改进程的信号屏蔽字(阻塞信号集)。关于上述各个参数的设置,即how的可选数值如下标所示↓↓↓
how取值 | 用法及含义 |
---|---|
SIG_BLOCK | set包含我们希望添加到当前信号屏蔽字中的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
第三个参数,在用户传入非空的sigset_t类型的变量时,则会返回执行当前sigprocmask之前的进程信号屏蔽字。
★ps:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回之前,至少将其中一个信号递达(如果收到解除屏蔽的信号的前提下)。
下面代码演示了如何获取当前进程的信号屏蔽字↓↓↓
#include <stdio.h>
#include <signal.h>
int main()
{
sigset_t set;
sigprocmask(SIG_BLOCK, NULL, &set);
int i = 1;
for(; i < NSIG; i++)
{
if(sigismember(&set, i))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
return 0;
}
★ps:NSIG的取值是最大的信号数+1。
下面代码演示使用sigprocmask设置1号到8号信号的信号屏蔽,后解除1到5号信号的信号屏蔽,最终将信号屏蔽字改为仅屏蔽11到15号信号↓↓↓
#include <stdio.h>
#include <signal.h>
void PrintSig(sigset_t set)
{
int i = 1;
for(;i < NSIG; i++)
{
if(sigismember(&set, i)) printf("1");
else printf("0");
}
printf("\n");
}
int main()
{
sigset_t set;
sigemptyset(&set);
int i = 1;
for(; i <= 8; i++)
{
sigaddset(&set, i);
}
sigset_t oldset;
sigprocmask(SIG_BLOCK, &set, &oldset);
printf("初始的信号屏蔽字:");
PrintSig(oldset);
sigemptyset(&set);
i = 1;
for(; i <= 5; i++)
{
sigaddset(&set, i);
}
sigprocmask(SIG_UNBLOCK, &set, &oldset);
printf("屏蔽1到8号信号屏蔽字:");
PrintSig(oldset);
sigemptyset(&set);
i = 11;
for(; i <= 15; i++)
{
sigaddset(&set, i);
}
sigprocmask(SIG_SETMASK, &set, &oldset);
printf("解除对1到5号信号的屏蔽:");
PrintSig(oldset);
sigprocmask(SIG_SETMASK, NULL, &oldset);
printf("改为仅屏蔽11到15号信号:");
PrintSig(oldset);
return 0;
}
sigpending
传入sigset_t类型的变量,sigpending会返回pending位图。
下面代码中,使用sigprocmask阻塞1到8号信号,并且每个一秒打印一次pending位图↓↓↓
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void PrintSet(sigset_t set)
{
int i = 1;
for(; i < NSIG; i++)
{
if(sigismember(&set, i)) printf("1");
else printf("0");
}
printf("\n");
}
int main()
{
sigset_t set;
sigemptyset(&set);
int i = 1;
for(; i <= 8; i++)
{
sigaddset(&set, i);
}
sigprocmask(SIG_BLOCK, &set, NULL);
while(1)
{
sigpending(&set);
PrintSet(set);
sleep(1);
}
return 0;
}
执行上述程序,并执行shell脚本for sig in {1..9} do kill -"$sig" [程序pid]; sleep 2; done;
,给执行上述程序的进程每隔2秒发送信号(分别发送1、2、3…到9)↓↓↓
由于上述的1到8号信号均被屏蔽,即使pending位图记录了它们,但在这些信号被解除屏蔽前,这些信号并不会被递达,故收到1到8号信号后,1到8号信号对应的pending位图比特位被置1。
信号的捕捉(详谈)
内核实现信号捕捉
如果信号的处理动作是用户自定义函数(调用signal函数自定义处理函数),在信号递达时就调用这个函数,这称为信号捕捉。由于信号处理函数的代码是在用户空间的,处理过程比较复杂↓↓↓
在程序执行过程中,由于中断、异常或系统调用而陷入到内核;当处理完相应的工作(中断、异常或系统调用引起的工作)后,当前执行流将会检查信号对应的block、pending位图,如果有信号需要处理,则会顺带处理,这样可以减少用户态到内核态转换的开销;如果处理的方式不是SIG_DFL或SIG_IGN的话,则需要到用户态执行对应的信号处理函数。
在执行对应的处理函数时,会将pending位图中信号对应的比特位清0,并且内核会自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次发生,它会被阻塞到当前处理结束为止。
当处理结束后,需要通过系统调用,再次陷入内核;陷入内核后,会再检查一次block和pending位图,如果仍有信号需要处理,则会再处理其他信号;如果所有信号均处理完毕,则会返回到主执行流中,继续向下执行。
举例:用户程序注册了SIGQUIT信号的处理函数SigHandler的前提下。当前正在执行main函数,这时发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的main函数之前检测到有信号SIGQUIT递达,内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行SigHandler函数,SigHandler函数使用户态的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。SigHandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
★ps:信号处理函数调用堆栈、主执行流调用堆栈和内核调用堆栈是相互独立的,不存在调用和被调用关系。
若某个信号的处理函数位于用户空间,且当前只有该信号需要递达,没有其他信号的前提下。一次信号处理过程中,总共会发生2次用户态到内核态的转化,2次内核态到用户态的转化。(绿色箭头表示用户态转内核态,黄色箭头表示内核态转用户态)↓↓↓
★ps:如果收到2号信号时收到3号信号,执行完2号信号处理函数后,由于此时2号信号的信号屏蔽字为1,此时会处理3号信号。因而,在整个信号处理过程中可能存在交替处理不同信号的情况,但不会出现连续重复处理单种类信号的情况。
下面代码用于验证,在处理当前信号时,当前信号的信号屏蔽字为1,即被阻塞↓↓↓
#include <stdio.h>
#include <signal.h>
void SigHandler(int signo)
{
printf("catch a signo = %d\n", signo);
sigset_t set;
sigemptyset(&set);
sigprocmask(SIG_BLOCK, NULL, &set);
if(sigismember(&set, 2)) printf("block bit is 1\n");
else printf("block bit is 0\n");
}
int main()
{
signal(2, SigHandler);
while(1)
{}
return 0;
}
下面代码验证,在执行处理函数前,该信号的pending位图标记为已经被置0↓↓↓
#include <stdio.h>
#include <signal.h>
void SigHandler(int signo)
{
sigset_t set;
sigemptyset(&set);
sigpending(&set);
printf("catch a signo = %d\n", signo);
if(sigismember(&set, signo)) printf("sig's pending bit is 1\n");
else printf("sig's pending bit is 0\n");
}
int main()
{
signal(2, SigHandler);
while(1)
{}
return 0;
}
sigaction
sigaction也是一种信号捕捉函数,但相比于signal函数,它具有更大的灵活性。其中signum参数指定了要设置或获取处理程序的信号编号。act参数是一个指向sigaction结构体的指针,用于设置处理程序。oldact参数也是一个指向sigaction结构体的指针,用于获取先前的处理程序。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);
};
其中,sa_handler 和 sa_sigaction 是用于指向处理信号的函数指针;sa_mask 是一个屏蔽信号的集合,在调用信号捕捉函数之前,这个集合中的信号会被添加到进程的信号屏蔽字中;sa_flags 用于指定处理程序的一些行为(如下表所示,下表仅列举部分sa_flags取值↓↓↓)
sa_flags取值 | 行为描述 |
---|---|
0 | 使用标准的、未经修改的信号处理行为 |
SA_NODEFER | 表示当信号处理函数正在进行时,不会阻塞对于该信号的处理 |
SA_RESETHAND | 表示当信号处理函数被执行过一次后,该信号的处理函数会被重置为系统默认的处理函数 |
上述sigaction结构体中的sa_restorer在现代的系统中,通常已经不再使用,而是被设置为 NULL。
对于sa_handler 和 sa_sigaction,两者选择其中一个即可,不需要均设置。下面代码中设置了sa_hander,设置了sa_hander的信号处理函数只能接收一个整型参数(即信号编号)↓↓↓
#include <stdio.h>
#include <signal.h>
void SigHandler(int signo)
{
sigset_t set;
sigemptyset(&set);
sigprocmask(SIG_BLOCK, NULL, &set);
printf("catch a signo = %d\n", signo);
int i = 1;
for(; i < NSIG; i++)
{
if(sigismember(&set, i)) printf("1");
else printf("0");
}
}
int main()
{
struct sigaction sa;
sa.sa_handler = SigHandler;
sa.sa_flags = 0;
//在执行2号信号的处理函数时,阻塞1到8号信号
sigemptyset(&sa.sa_mask);
int i = 1;
for(; i <= 8; i++)
{
sigaddset(&sa.sa_mask, i);
}
sigaction(2, &sa, NULL);
while(1)
{}
return 0;
}
★ps:如果设置sigaction信号处理函数为sa_sigaction的话,则可以给信号处理函数传递3个参数,这里对该用法不再做出演示,信号编号,siginfo_t结构体指针,void*类型指针。siginfo_t结构体如下所示↓↓↓
struct siginfo_t {
int si_signo; /* 信号编号 */
int si_errno; /* 错误码(如果有的话) */
int si_code; /* 信号代码,用于区分信号的具体原因 */
pid_t si_pid; /* 发送信号的进程ID */
uid_t si_uid; /* 发送信号的用户ID */
int si_status; /* 子进程终止状态(如果是由于子进程终止) */
clock_t si_value; /* 信号值(如果是实时信号) */
void *si_ptr; /* 信号指针(未使用) */
/* 以下字段是特定于某些信号的 */
int si_addr; /* 导致错误的内存地址(如果是由于某些信号,如 SIGSEGV) */
long si_band; /* 带号(如果是由于 SIGPOLL) */
int si_fd; /* 文件描述符(如果是由于 SIGPOLL 或 SIGIO) */
/* ... 可能还有其他字段 ... */
};
可重入函数
在介绍可重入函数之前,先抛出可重入和不可重入函数的概念:如果一个函数,被重复进入的情况下,出错了,或者可能出错,则该函数为不可重入函数;否则为可重复函数。
若实现一个给带头结点单向链表头插一个新结点的insert函数,则在头插结点时需要完成以下几步↓↓↓
①创建一个新结点newnode;
②让newnode的next域指向head结点的next域;
③让head结点的next域指向newnode。
在执行上述操作时,如果A执行流执行insert到第二步时发生中断,此时由B执行流执行;在B执行流执行完毕后,A执行流从中断代码行处继续向下执行时,导致了B新创建的结点没有链入链表中,进而导致内存泄漏问题。(整个过程如下图所示↓↓↓)
向上面这种,insert函数被不同的执行流调用,有可能在第一个执行流调用还没返回时就有另一个执行流进入的该函数,这称为重入。insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,称为可重入函数。
如果一个函数复合以下条件之一则是不可重入的:
●调用了malloc和free,因为malloc也是用全局链表来管理堆的。(malloc每申请一个新空间,就会将该空间链入全局变量)
●调用了标准I/O库函数。标准I/O库的很多实现都以不可重入方式编写,因为它使用了全局的数据结构。
volatile关键字
在介绍该关键字前,我们一起看一段代码↓↓↓
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
int flags = 1;
void SigHandler(int signo)
{
printf("catch a signo = %d\n", signo);
printf("change falgs from 1 to 0\n");
flags = 0;
}
int main()
{
signal(2, SiHandler);
while(flags);
printf("Hi...\n");
return 0;
}
使用gcc -o volatile volatile.c
编译上述程序,执行时发送2号信号后,结果如下↓↓↓
但为了使得代码效率得以提升,我们可以给gcc带上优化选项,这样,gcc就会对程序进行优化。各优化选项及其优化描述如下表所示↓↓↓
gcc优化选项 | 优化描述 |
---|---|
-O0 | 这是默认的优化级别,表示不执行任何优化。编译器主要关注代码的正确性,而不是性能。 |
-O1 | 这是第一个优化级别,开启了一些简单的优化,如常量折叠、死代码删除等。这个级别通常不会对代码进行大量的重排或转换。 |
-O2 | 这是第二个优化级别,包含了-O1的所有优化,并增加了更多的优化,如函数内联、循环展开、死循环删除等。这个级别通常会提高代码的运行速度,但可能会增加代码大小。 |
-O3 | 这是第三个优化级别,包含了-O2的所有优化,并增加了更多的高级优化,如循环向量化、更复杂的函数内联等。这个级别通常会进一步提高代码的运行速度,但可能会进一步增加代码大小,并可能导致代码更难调试。 |
-Os | 这个选项主要关注代码大小,而不是运行速度。它会尽量减小生成的代码大小,可能会牺牲一些运行速度。 |
-Og | 这个选项主要用于调试优化。它会尽量保持代码的原始结构,以便在调试时更容易理解。这个选项通常与-g(生成调试信息)一起使用。 |
下面我们尝试使用-O3选项对上述程序做优化,即gcc -O3 -o volatile volatile.c
为什么这时候给该进程发送2号信号,将flags从1变0,但while循环一直没有终止呢?
为了提高效率,编译将flags变量从内存放置于寄存器中。当SigHandler修改flags,修改的是内存中的数值,而寄存器中的数值并未改变。因而while循环并不会终止。
如果需要避免该问题,我们需要使用volatile修饰flags变量。volatile的作用是,让程序执行时,每次都从对应的内存空间存取数据,而不将变量直接优化在寄存器中。
优化后的代码及结果如下↓↓↓
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
volatile int flags = 1; //volatile!!!
void SigHandler(int signo)
{
printf("catch a signo = %d\n", signo);
printf("change falgs from 1 to 0\n");
flags = 0;
}
int main()
{
signal(2, SiHandler);
while(flags);
printf("Hi...\n");
return 0;
}
SIGCHLD详谈
在前面文章中谈论过,父进程通过wait和waitpid来清理僵尸进程,父进程可以阻塞等待子进程结束,可以非阻塞查询子进程是否处于僵尸状态(也就是轮询方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现较为复杂。
其实,子进程在终止时会给父进程发送SIGCHILD信号,该信号地默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数。这样,父进程只需要专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid清理子进程即可。
下面使用代码验证:子进程退出确实会给父进程发送SIGCHLD信号↓↓↓
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <signal.h>
void SigHandler(int signo)
{
printf("catch a signo = %d\n", signo);
}
int main()
{
signal(SIGCHLD, SigHandler);
pid_t id = fork();
if(id == 0)
{
printf("I am child process, pid = %d, ppid = %d\n", getpid());
exit(0);
}
while(1)
{
printf("I am parent process, pid = %d\n", getpid());
sleep(1);
}
return 0;
}
从上图中可以看出,子进程确实给父进程发送了SIGCHLD信号。在父进程退出前查看当前进程运行状态可以发送,虽然SIGCHLD已经被递达,但由于父进程没有调用wait/waitpid对子进程资源进行回收,故子进程此时处于僵尸状态。↓↓↓
下面,编写一个程序,捕捉SIGCHILD信号,处理子进程的僵尸状态(在收到SIGCHILD之前,父进程继续执行自己的工作)↓↓↓
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
typedef void(*func_t)();
void task1()
{
printf("回防高地\n");
}
void task2()
{
printf("准备集合\n");
}
void task3()
{
printf("保护我方王昭君\n");
}
void loadTask(func_t tasks[])
{
tasks[0] = task1;
tasks[1] = task2;
tasks[2] = task3;
}
void SigChildHander(int signo)
{
int status = 0;
if(WIFEXITED(status))
{
printf("wait success, child pid = %d, exit code = %d\n", ret, WEXITSTATUS(status));
}
}
int main()
{
func_t tasks[3];
loadTask(tasks);
signal(SIGCHLD, SigChildHander);
pid_t id = fork();
if(id == 0)
{
for(int i = 0; i < 3; i++)
{
printf("child process is running, pid = %d\n", getpid());
sleep(1);
}
exit(88);
}
while(1)
{
for(int i = 0; i < 3; i++)
{
tasks[i]();
usleep(500000);
}
}
return 0;
}
如果父进程不关心子进程的退出状态,则可以将SIGCHLD的捕捉函数设置为SIG_IGN。这样设置后,系统会自动回收子进程资源,不需要父进程进行回收。↓↓↓
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <signal.h>
int main()
{
signal(SIGCHLD, SIG_IGN;
pid_t id = fork();
if(id == 0)
{
printf("I am child process, pid = %d, ppid = %d\n", getpid());
exit(0);
}
while(1)
{
printf("I am parent process, pid = %d\n", getpid());
sleep(1);
}
return 0;
}
★ps:如果将SIGCHLD的处理函数设置为SIG_DFL,则不会释放子进程资源,子进程退出后会进入僵尸状态;只有将SIGCHLD处理函数设置为SIG_IGN,才表示当前父进程不关心子进程状态,子进程退出由系统自动释放其资源,父进程不需要调用wait/waitpid来获取子进程退出信息。但如果父进程关心子进程的退出信息,建议使用信号捕捉函数来获取子进程退出信息。
🎈欢迎进入从浅学到熟知Linux专栏,查看更多文章。
如果上述内容有任何问题,欢迎在下方留言区指正b( ̄▽ ̄)d