目录
- 一、硬件中断
-
- [1.1 外设中断](#1.1 外设中断)
- [1.2 时钟中断](#1.2 时钟中断)
- [二、 软中断](#二、 软中断)
- [三、用户态 和 内核态](#三、用户态 和 内核态)
-
- 模拟OS运行
- [其它的信号捕捉函数 sigaction](#其它的信号捕捉函数 sigaction)
- 四、几个子问题
-
- [4.1 可重入函数](#4.1 可重入函数)
- [4.2 volatile 关键字](#4.2 volatile 关键字)
- [4.3 SIGCHLD 信号](#4.3 SIGCHLD 信号)

个人主页:矢望
个人专栏:C++、Linux、C语言、数据结构、Coze-AI、MySQL
一、硬件中断
1.1 外设中断
假如我们有一个获取键盘输入的程序,我们编译运行之后,这个程序就会被scanf阻塞住,它在等待用户输入,但是进程怎么知道键盘数据被按下了呢?键盘属于硬件,归OS管理,OS知道键盘数据准备好之后会通知进程的。所以OS怎么知道键盘被按下了呢?这里有很多做法,比如OS自己主动进行轮询检测硬件是否就绪等。但OS可太忙了,有很多事需要它去处理,如果OS一直轮询检测硬件是否就绪,就相当于白白浪费资源,因此就有了中断这种方法。

如上图,产生的信号也分种类的,比如控制信号,数据信号等,中央处理器可以直接向设备放送消息,而设备也可以直接向中央处理器发送消息,这个消息就是中断。
中断由外部设备触发的,中断系统运行流程,叫做硬件中断。

如上图,外设与CPU本身都支持中断 。CPU中有很多的寄存器,这些寄存器就是进程的硬件上下文,将来CPU会以进程为载体执行一个个的任务。在OS中有很多的外设,这些外设将来准备就绪之后会通过导线向中断控制器发起中断,中断处理器会将中断通知给CPU,CPU收到中断之后,最想知道的是哪一个外设发的中断,外设想让我干什么? 由于外设很多,所以将来每一个外设都会有一个处理方法,但是这些外设的处理方法如果由硬件继承在电路中由不现实,所以将来在OS中会将相关外设的处理方法写入到中断向量表中,你可以认为中断向量表就是一个函数指针数组,例如:
cpp
typedef void(*handler_t)(void);
handler_t IDT[NUM];
而每一个外设都会有自己唯一个中断号 ,所以CPU收到中断之后,就会像中断寄存器获取相关的中断号,然后CPU根据这个中断号间接或者直接调用中断向量表中的相应的方法,调用完毕之后,CPU继续执行自己之前执行的任务。
并且由于CPU不知道什么时候外设会向自己发送中断,所以中断的产生方式是异步的。硬件中断是CPU与外设之间的异步通知机制,而软件信号是内核与进程之间的异步通知机制。
中断到来的时候,CPU可能在执行当前进程的代码,CPU寄存器中都是当前进程的临时数据,所以CPU在执行中断方法之前需要保护现场,也就是将临时数据保存起来,然后CPU再查询中断向量表,执行中断方法,执行完成后,CPU还要继续执行之前进程的代码,所以需要进行恢复现场,将之前的数据恢复出来,继续执行之前的任务。
暂停CPU正在执行的任务,处理硬件突发事件和中断向量表这就是硬件中断,中断向量表是由OS提供的。
计算机世界先有硬件中断,有硬件来完成处理,后来发现进程也需要类似的机制,于是就有了信号。信号机制使用纯软件的方式,模拟中断完成特定的任务处理。它们两个原理类似,但本质完全不同。
中断向量表就是OS的一部分,OS启动时就加载到内存中了。
1.2 时钟中断
进程可以在操作系统的指挥下,被调度,被执⾏,那么操作系统自己被谁指挥,被谁推动执行呢?

再众多外设中,有一个外设被设计的很奇怪,它可以以固定的频率向CPU发送属于自己的中断,CPU收到中断就会处理这个中断号对应的中断方法,如以下结构体中:
cpp
void do_timer()
{
// 执行进程调度、时间片检测、切换、调度等动作!
}
这个设备(例如外部晶振)以固定的频率,比如1ms,发送自己的中断,这样就可以让CPU每个一段时间执行一次这个操作。你的进程是由OS调度运行的,但OS也是软件!谁让OS运行呢? 我们发现上面的方法不就是OS做的吗?
因此,中断向量表属于OS的一部分,CPU可以通过中断的方式定期的执行中断向量表IDT中的方法,这样就可以定期执行OS了!OS没有生命,它是躺在中断集合中的一部分!do_timer方法,你可以理解成OS的执行入口。
所以,时钟中断是唯一一个定期、主动、强制让 OS 获得 CPU 的入口。
问题1:为什么OS能够计算时间呢?
电脑的主板内部有一块纽扣电池,当你断电之后,这个电池还会持续向主板的时间模块供电,所以当你开机之后就可以获取到起始时间,时间戳。 在OS的数据结构中有一个变量,你可以理解为:
cpp
long long tickts = 0; // 累计时钟中断次数
这个变量会累计时钟中断的次数,然后起始时间+次数*中断的间隔就可以计算出当前的时间,所以累计的时钟中断的次数就是时间。
问题2:什么叫做时间片?什么叫做时间片耗尽?
在task_struct结构体中有一个变量叫做long couter;这个变量每次经过时钟中断执行do_timer的时候,如果调用到相关进程,counter就会减1,所以 counter本质就是一个计数器,所以时间片就是一个计数器 。当这个counter减1之后还等于0时,此时进程的时间片就耗尽了,这个时候就会执行进程调度的方法。
因此执行do_timer有两种情况,一种时时间片过期,此时就会执行进程调度;另一种是时间片不过期,此时就return,中断处理什么都没做。
问题3:OS依据什么执行它的调度算法?
OS依据固定时间间隔的始终中断来执行调度算法。
因此中断处理和进程执行的关系是这样的:

中断处理和进程执行之间是串行的关系。进程执行在中断的时间间隔之中。

如上图,在当代CPU下,时钟源已经被设计到CPU的内部了,这样执行时钟中断更快。
我们经常听说的主频3.2GHZ等等就是晶振震荡的频率,这个频率是很快的,CPU处理不了这么快的频率,所以在时钟源内部有两部分组成,一部分是时钟发生器,一部分是时钟计数器 ,前者的频率就是主频的频率,而时钟计数器它会有一个初始的值,例如3.2GHZ,当时钟发生器向时钟计数器中断的时候,这个值就是减1,当这个值减到0,整个时钟源才会触发一次时钟中断,所以依靠这两个部分就实现了分频的功能,这个时钟计数器是可以配置的。这个一般是由硬件工程师配置好的。
所以是硬件中断=时钟源+时钟中断,促使OS运行的!
因此:晶振振动(物理) → 时钟源计数(硬件分频) → 时钟中断信号(电信号) → CPU响应(硬件自动) → 执行IDT中的OS代码(软件) → OS运行 → 调度进程 → 系统运转!!!
这样,操作系统就在硬件的推动下,自动调度了!!!
如果是这样,操作系统不就可以躺平了吗?是的,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质就是一个死循环!
cpp
void main(void)
{
// ...
for (;;)
pause();
}
二、 软中断
在CPU内部还内置了一些特殊的软件指令集,x86-64位下叫syscall,x86-32位下叫int,通过这些指令集,CPU也会自动触发中断的处理流程,因为有这些指令集可以汇编触发中断,所以程序员在代码内部写可以使用这些汇编主动触发中断。这种中断就叫做软中断。

软中断可以用来实现系统调用。

如上图,在内核中存在一张系统调用表,内核层面上,每一个系统调用都有自己的系统调用号,也就是数组下标。
比如如果要执行read方法,如ssize_t read(fd, XXX);在底层是这样工作的:
cpp
mov ebx fd
mov ecx XXX
...
mov eax 3 // 系统调用号
syscall/int 0x80 // 触发软中断

如上图,当调用read时底层触发中断之后,就会处理这个软中断,会调用到set_system_gate函数,然后就会通过read的参数信息,调用系统调用表中的系统调用方法。
所以谁来做软中断之前的所有工作呢?这部分工作是由C语言标准库做的,我们的read系统调用是C库提供的。
那么内部系统调用这么知道有多少个参数?具体是什么呢?我们传递软中断的时候可以传递函数的参数信息,自然也可以传递参数的个数信息。
链条是这样的:C标准库(封装) → 设置系统调用号和参数 → 触发软中断(int 0x80或syscall) → CPU中断机制 → 中断描述符表(IDT) → 系统调用入口(system_call) → 系统调用表(sys_call_table) → 具体内核函数(如sys_read)。
除了正常的硬件中断,软中断之外,还有很多的异常问题,比如缺页中断,除零野指针错误等,因此 OS必须要有异常的中断处理。
CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱 ;CPU内部的软中断,比如除零野指针等,我们叫做异常。
因此操作系统是一个软件块, 是一个基于中断处理的软件集合!操作系统就是躺在中断处理例程上的代码块!
三、用户态 和 内核态

如上图,进程的虚拟地址空间中[0, 3]GB是用户空间,[3, 4]GB是内核空间,有一份页表进行映射就可以了,但图中为了方便表述拆成了两份,用户空间通过用户页表可以映射到物理内存,但不能访问操作系统,而内核空间通过内核页表,就直接映射到了操作系统。
例如,如图中所示,我们要执行read系统调用就会通过软中断的方式,访问内核空间也就是操作系统中的系统调用方法。因此,进程的所有系统调用,都是在自己的虚拟地址空间完成的!
每一个进程都有自己的一套用户级页表,但是内核级页表只有一份,被所有的进程所共享!


如上图,所以虚拟地址空间的[3, 4]GB空间永远映射的就是操作系统,因此进程在调度的时候,想要找到OS,随时都可以找到!!!
换句话说,进程随时都可以找到内核区操作系统的代码,所以不能让进程随意对内核区代码进行访问,这样会有安全风险,所以就有了用户态和内核态。
在用户态的进程不能访问内核区操作系统的代码,而只有内核态的进程才有权力访问内核区OS的代码。
在Linux中会存在很多地方进行权限管理,很明显,用户态和内核态也属于权限管理的范畴。用户态和内核态需要硬件级的支持,用户态和内核态是CPU的两种执行级别 。
在CPU有很多的寄存器,包含了大量的寄存器值,它们时进程的硬件上下文,其中有一个CS寄存器,这个寄存器存在两个比特位,这两个比特位有两个取值,00或者11,00时代表进程处于内核态,11代表进程处于用户态。这个也叫做CPL(current privilege level),当前特权级。
因此,只有进程处于内核态也就是CS寄存器的低两位为00时,此时才可以访问内核操作系统的代码。
在页表中也存在一个权限位,这个权限位的取值也是有00或11两种,它叫做DPL,描述符特权级。
当进程要执行操作系统的系统调用时,内核就会检查CPU(正在执行的进程)中的CPL和页表中的DPL是否符合,如果CPL小于等于DPL就可以执行这部分代码,如果不是这样内核就会直接拒绝。
反过来说通过执行int 0x80或者syscall可以改变进程的身份状态为内核态,这样就可以进入内核,但是进入内核之后,内核会直接读取你的系统调用号,如果你没有系统调用号,那么内核不会让你执行任何事情,所以你在内核中只能做合法的、内核预定义好的操作,比如系统调用。
这里再次呈现信号自定义捕捉的流程图:

那么我们的死循环为什么会被信号终止呢?
cpp
#include <iostream>
int main()
{
while(1)
{
}
return 0;
}
编译运行:

我们这个程序只是一个死循环,不是说,只有内核态到用户态的时候才会进行信号的检测与处理吗?我们的代码没有异常,没有调用系统调用,它这么进入内核态的呢? 因为有时钟中断,时钟中断一直在进行,所以我们程序一直都在陷入内核!
模拟OS运行
我们可以使用alarm来模拟时钟中断,进而模拟操作系统的运行过程。
这里我们会用到一个函数pause。

pause() 函数使进程主动进入睡眠状态,直到任意一个信号递达并完成处理后才返回,其核心作用是让进程在等待信号时完全不消耗CPU时间。
以下代码使用 alarm 信号模拟时钟中断,进而模拟操作系统的进程调度过程:
cpp
#include <iostream>
#include <signal.h>
#include <time.h>
#include <vector>
#include <unistd.h>
static const int defaultcounter = 5;
int id = 0; // 当前正在执行的进程索引
class task_struct
{
public:
task_struct(int pid, int counter = defaultcounter)
:_pid(pid)
,_counter(counter)
{}
int Pid()
{
return _pid;
}
void Run()
{
std::cout << "进程 " << _pid << " 正在运行..." << std::endl;
}
void Desc()
{
_counter--;
}
bool Expired()
{
return _counter <= 0;
}
private:
int _pid; // 进程标识符
int _counter; // 剩余时间片
};
std::vector<task_struct> _tasks;
void do_timer(int signo)
{
if(_tasks[id].Expired()) // 检查当前进程时间片是否耗尽
{
std::cout << "进程 " << _tasks[id].Pid() << " 时间片耗尽,重新调度新进程" << std::endl;
// 随机选择下一个进程执行
id = rand() % _tasks.size();
}
else // 时间片未耗尽,继续执行当前进程
{
_tasks[id].Run();
}
_tasks[id].Desc(); // 减少当前进程的时间片
alarm(1); // 重新设置闹钟,模拟下一次时钟中断
}
int main()
{
alarm(1); // 设置 1 秒后首次触发 SIGALRM
signal(SIGALRM, do_timer); // 注册信号处理函数
srand((unsigned int)time(nullptr) ^ getpid()); // 初始化随机数种子
// 创建 5 个模拟进程
_tasks.emplace_back(1);
_tasks.emplace_back(2);
_tasks.emplace_back(3);
_tasks.emplace_back(4);
_tasks.emplace_back(5);
for(;;)
pause(); // 主进程进入睡眠,等待信号唤醒
return 0;
}
这段代码的核心逻辑是:
- 使用
alarm(1)和signal(SIGALRM, do_timer)模拟时钟中断,每隔1秒触发一次do_timer函数。 - 在
do_timer函数中:- 检查当前进程 (
_tasks[id]) 的时间片是否耗尽(Expired)。 - 若耗尽,则输出提示并随机选择另一个进程执行(模拟进程调度)。
- 若未耗尽,则让当前进程继续"运行"(输出运行信息)。
- 无论是否耗尽,都会减少当前进程的时间片计数(
Desc)。 - 最后再次调用
alarm(1),为下一次"时钟中断"定时。
- 检查当前进程 (
- 主程序初始化
5个模拟进程后,进入无限循环并调用pause(),使进程休眠,完全依赖信号(模拟的中断)来驱动。
通过这种方式,我们模拟了操作系统依靠时钟中断进行进程调度和时间片管理的基本原理。
编译运行:

其中这个代码中,当进程的时间片耗尽之后,还可以给进程重新设置时间片,这样进程就可以继续被执行。
其它的信号捕捉函数 sigaction


sigaction函数也可以进行信号的自定义捕捉,用之前要把struct sigaction结构体进行初始化,否则可能会出现未定义错误。
它和signal不同的是,它在处理一种信号的时候,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时,会取消这个信号的信号屏蔽,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么它会被阻塞不会被递达,直到当前信号处理结束为止。
也就是说如果来了一个2号信号,它会处理,但在它处理期间,会默认把2号信号进行屏蔽,这样,再来就不会递达处理2号信号了,这样就防止了方法的嵌套干扰。
另外如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
我们接下来写一个程序,自定义捕捉一下2号信号,然后在自定义捕捉的函数中死循环打印pending表,默认情况下,2号信号正在处理时,2号信号就被屏蔽了,然后我们在多屏蔽几个信号,查看结果。
代码:
cpp
void handler(int signo)
{
std::cout << "收到一个信号: " << signo << std::endl;
sigset_t pending;
while(true)
{
sigpending(&pending); // 获取当前进程的 pending 表
for(int i = 31; i > 0; i--)
{
if(sigismember(&pending, i))
{
std::cout << '1';
}
else std::cout << '0';
}
std::cout << std::endl;
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
// 初始化
act.sa_flags = 0;
sigemptyset(&(act.sa_mask));
act.sa_restorer = nullptr;
act.sa_handler = handler;
// 添加额外的信号屏蔽
sigaddset(&(act.sa_mask), 3);
sigaddset(&(act.sa_mask), 4);
sigaddset(&(act.sa_mask), 5);
sigaction(SIGINT, &act, &oact);
while(true)
{
std::cout << "进程正在运行..." << getpid() << std::endl;
sleep(1);
}
return 0;
}
上面代码,我们对2号信号进行了自定义捕捉,在自定义捕捉方法中进行了循环获取打印pending表的逻辑,这样就可以看到信号是真的被屏蔽了。我们对3、4、5号信号也进行了屏蔽,编译运行之后,我们预期结果是首先发送2号信号进程就会执行2号的自定义捕捉方法,再次发送2、3、4、5时,这些信号都不会被递达,而是未决状态。
编译运行:

如上图,结果符合我们的预期。
四、几个子问题
4.1 可重入函数

如上图,是一个链表头插的函数,首先main函数正在执行头插的代码,main函数执行完了p->next=head;这一步还没开始执行下一步,此时收到了一个信号,然后进程就转而执行handler方法了,而在这个方法中,它也执行了头插的逻辑,这个头插顺利执行完毕,又退回到main执行流,之后main函数进行了head=node1;。
这样我们发现了问题,执行结束之后,node2不见了!也就是出现了内存泄露问题。在这个过程中一共有两个执行流,main执行流和handler执行流,在main执行流正在执行insert方法的时候,突然进程转而进入handler执行流执行insert方法,所以这个insert方法被重入了。
如果一个函数被重入之后,没有出现问题,我们称这个函数为可重入函数;如果出现了问题,如上面的例子,我们称这个函数为不可重入函数!
可重入函数与不可重入函数是这个函数的特点,而不是这个函数的优缺点!
4.2 volatile 关键字
volatile关键字告诉编译器:这个变量的值可能在任何时候被外部改变,禁止对该变量的访问进行优化(例如缓存到寄存器或重排序),每次使用都必须从内存中重新读取。
我们写一个代码理解一下。
c
int flag = 0;
void change(int signo)
{
flag = 1;
printf("change flag : 0 -> 1\n");
}
int main()
{
signal(2, change);
while(!flag); // flag只会被检测,不会被修改!
printf("进程正常退出\n");
return 0;
}
如上代码我们在信号的捕捉函数中更改了flag的值,然后就会读取到flag更改了,之后程序就应该终止。

上图就是判断流程。
编译运行,不优化时:

如上,进程收到信号之后修改了flag的值,因此进程确实终止了。
但我们的编译器是可以加上优化选项的:

我们带上O1优化再次编译运行:

如上图,我们的flag变量的值绝对在内存中被更改了!但是进程并没有结束。
编译器进行优化时,只分析当前函数(main)的代码流程,在 main 函数内部,确实没有任何代码修改 flag,只是检测了 flag变量,所以编译器认为 flag 在 while 循环期间绝对不会改变。因此编译器将flag的值加载到寄存器中,然后一直检查寄存器,如下图:

这个时候,flag的值加载到了寄存器中,等再检测时,就会直接从寄存器中拿值,所以就出现了上面的运行结果!所以是因为寄存器覆盖了内存,让内存不可见了!
如果解决呢?很简单,告诉编译器这个值在任何时候都可被改变,让编译器一直从内存中拿值就好了,也就是将flag变量声明为volatile的。
cpp
volatile int flag = 0;
再次编译运行:

4.3 SIGCHLD 信号
父进程创建子进程,当子进程退出的时候,就会向父进程发送SIGCHLD信号 。

这个信号的默认处理动作是子进程变成僵尸,等待父进程回收。
所以为了证明,子进程退出的时候,的确会想父进程发送SIGCHLD信号,我们可以将这个信号自定义捕获一下。
相关代码:
cpp
void handler(int signo)
{
printf("进程收到一个信号:%d, pid: %d\n", signo, getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if(id == 0)
{
printf("子进程马上退出,pid: %d\n", getpid());
sleep(5);
exit(10);
}
while(1)
{
printf("父进程正在运行,pid: %d\n", getpid());
sleep(1);
}
return 0;
}
上面的代码中子进程等待五秒就会退出,预期结果是父进程会收到SIGCHLD信号。

如上图,父进程确实收到了SIGCHLD信号。
因此,我们创建子进程之后需要回收子进程,如果父进程不想因为wait、waitpid而卡住,就可以让父进程收到SIGCHLD信号之后回收子进程!
因此在handler函数中设置回收子进程的代码就可以了:
cpp
void handler(int signo)
{
printf("进程收到一个信号:%d, pid: %d\n", signo, getpid());
int status = -1;
waitpid(-1, &status, NULL);
printf("exit code: %d\n", WEXITSTATUS(status));
}
编译运行:

当然,这份代码还有问题,我们的父进程在创建子进程的时候可以创建一批子进程,所以当子进程退出的时候可能有一批子进程全部退出。我们这份代码当有第一个SIGCHLD信号来的时候,父进程就会处理他,但是如果来了有很多个,它只会处理一个,然后pending表记录一个,剩下的都丢弃了,这就会出问题。
所以我们的handler函数内部需要使用循环去等待子进程。另外子进程退出的时间不固定,可能有一部分退出了,另一部分没有退出,那么父进程也有自己的工作要完成,所以此时就不能使用阻塞等待,而是需要使用非阻塞等待。这样才不至于卡住。
所以再次修改代码:
cpp
void handler(int signo)
{
printf("进程收到一个信号:%d, pid: %d\n", signo, getpid());
int cnt = 0;
while(1) // 循环回收
{
int status = -1;
int ret = waitpid(-1, &status, WNOHANG); // 非阻塞等待
if(ret <= 0) break;
else cnt++;
}
printf("回收一批子进程完成, cnt: %d\n", cnt);
}
int main()
{
signal(SIGCHLD, handler);
for (int i = 1; i <= 10; i++) // 创建多个子进程
{
pid_t id = fork();
if (id == 0)
{
if(i == 6)
{
printf("%d 号子进程等一会再退出\n", getpid()); // 模拟一部分退出,一部分运行的情况
sleep(5);
}
else
{
printf("子进程马上退出,pid: %d\n", getpid());
sleep(3);
}
exit(10);
}
}
while (1)
{
printf("父进程正在运行,pid: %d\n", getpid());
sleep(1);
}
return 0;
}
上面代码,创建了10个进程,然后设计了一个进程最后退出,期望是父进程在这中间的过程中不被卡住。
编译运行:

如上图,父进程没有被卡住,正常回收所有子进程。
回收子进程可以阻塞回收、非阻塞回收、基于信号回收等,它们都比较麻烦,其实还有一种很简单的做法,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction/signal将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
也就是在父进程中添加一行对SIGCHLD信号的忽略代码。
cpp
int main()
{
// signal(SIGCHLD, handler);
signal(SIGCHLD, SIG_IGN); // 用户明确忽略
for (int i = 1; i <= 10; i++) // 创建多个子进程
{
pid_t id = fork();
if (id == 0)
{
if(i == 6)
{
printf("%d 号子进程等一会再退出\n", getpid()); // 模拟一部分退出,一部分运行的情况
sleep(5);
}
else
{
printf("子进程马上退出,pid: %d\n", getpid());
sleep(3);
}
exit(10);
}
}
while (1)
{
printf("父进程正在运行,pid: %d\n", getpid());
sleep(1);
}
return 0;
}
编译运行:

如上图,子进程也被全部回收了!

但是,如上图,我们之前看到这个信号的默认处理动作就是忽略呀,系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。
另外这个方法在类UNIX的Linux上可用,但不保证在其他的UNIX系统上都可用。
总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~