(整合)Linux下的多进程编程(获取PID、fork、vfork进程创建,exit进程中止,wait进程等待,exec族 程序替换)

一、进程

1、进程的定义

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

2、进程的概念

进程是操作系统中资源分配的最小单位,而线程是调度的最小单位。
一个进程,主要包含三个元素:

一个可以执行的程序   

和该进程相关联的全部数据(包括变量,内存空间,缓冲区等等)

程序的执行上下文(execution context)

进程是程序的一个具体实现,每当我们执行一个程序时,对于操作系统来讲就创建了一个进程,进程是执行程序的过程,同一个程序可以执行多次,每次都可以在内存中开辟独立的空间来装载,从而产生多个进程。不同的进程还可以拥有各自独立的IO接口。操作系统的一个重要功能就是为进程提供方便,比如说为进程分配内存空间,管理进程的相关信息等等。

进程和程序的区别: 程序是静态的,它是一些保存在磁盘上得指令的有序集合,没有任何执行的概念。 进程是一个动态的概念,它是程序执行的过程,包括创建、调度和消亡。

3、进程的特征

1.动态性:进程的实质是程序在多道程序系统中的一次执行过程,进程是动态产生,动态消亡的。

2.并发性:任何进程都可以同其他进程一起并发执行。

3.独立性:进程是一个能独立运行的基本单位,同时也是系统分配资源和调度的独立单位;

4.异步性:由于进程间的相互制约,使进程具有执行的间断性,即进程按各自独立的、不可预知的速度向前推进。

5.结构特征:进程由程序、数据和进程控制块三部分组成。

多个不同的进程可以包含相同的程序:一个程序在不同的数据集里就构成不同的进程,能得到不同的结果;但是执行过程中,程序不能发生改变。

4、进程切换

进行进程切换就是从正在运行的进程中收回处理器,然后再使待运行进程来占用处理器。

这里所说的从某个进程收回处理器,实质上就是把进程存放在处理器的寄存器中的中间数据找个地方存起来,从而把处理器的寄存器腾出来让其他进程使用。那么被中止运行进程的中间数据存在何处好呢?当然这个地方应该是进程的私有堆栈。

让进程来占用处理器,实质上是把某个进程存放在私有堆栈中寄存器的数据(前一次本进程被中止时的中间数据)再恢复到处理器的寄存器中去,并把待运行进程的断点送入处理器的程序指针PC,于是待运行进程就开始被处理器运行了,也就是这个进程已经占有处理器的使用权了。

在切换时,一个进程存储在处理器各寄存器中的中间数据叫做进程的上下文,所以进程的切换实质上就是被中止运行进程与待运行进程上下文的切换。在进程未占用处理器时,进程的上下文是存储在进程的私有堆栈中的。

5、进程控制块 (Process control block)

进程在操作系统中都有一个户口,用于表示这个进程。这个户口操作系统被称为进程控制块PCB(process control block),在Linux中具体实现是 task_struct数据结构,它记录了一下几个类型的信息:

进程描述信息:
进程标识符用于唯一的标识一个进程(pid,ppid)
进程控制信息:
进程当前状态 //如这个进程处于可执行状态,休眠,挂起等。
进程优先级
程序开始地址
各种计时信息
通信信息

资源信息:
占用内存大小及管理用数据结构指针
交换区相关信息
I/O设备号、缓冲、设备相关的数结构
文件系统相关指针
资源的限制和权限

现场保护信息(cpu进行进程切换时):
寄存器
PC
程序状态字PSW
栈指针

对于操作系统来说PCB即找到整个过程。

在这里插入图片描述

6、进程标识符(Process Identifiers)

进程的ID是可重用的,如果一个进程被终止,那么它的进程ID会被系统回收,但是会延迟使用,防止该进程ID标识的新进程被误认为是以前的进程。

7、三个特殊ID的进程

三个特殊ID的进程:

Linux下有3个特殊的进程,idle进程(PID=0), init进程(PID=1)和kthreadd(PID=2)

Process ID 0:调度者进程,内核进程。
Process ID 1:init进程,内核引导程序最后启动,负责启动Unix系统。对应系统文件/sbin/init。
Process ID 2:页守护进程(pagedaemon),负责虚拟内存的页管理。

idle进程由系统自动创建,运行在内核态

idle进程其pid=0,其前身是系统创建的第一个进程,也是唯一一个没有通过fork或者kernel_thread产生的进程。完成加载系统后,演变为进程调度、交换

kthreadd进程由idle通过kernel_thread创建,并始终运行在内核空间,负责所有内核线程的调度和管理

kthreadd (pid = 2, ppid = 0)

它的任务就是管理和调度其他内核线程kernel_thread,会循环执行一个kthread的函数,该函数的作用就是运行kthread_create_list全局链表中维护的kthread,当我们调用kernel_thread创建的内核线程会被加入到此链表中,因此所有的内核线程都是直接或者间接的以kthreadd为父进程

  • init进程由idle通过kernel_thread创建,在内核空间完成初始化后,加载init程序,并最终用户空间创建

init 进程 (pid = 1, ppid = 0)

init进程由0进程创建,完成系统的初始化.是系统中所有其它用户进程的祖先进程

Linux中的所有进程都是有init进程创建并运行的。首先Linux内核自行启动(已经被载入内存,开始运行,并已初始化所有的设备驱动程序和数据结构等)之后,然后在用户空间中启动init进程,完成引导进程的启动。在系统启动完成后,init将变为守护进程监视系统其他进程,所以,init始终是第一个进程(其进程编号始终为1)

内核会在过去曾使用过init的几个地方查找它,它的正确位置(对Linux系统来说)是/sbin/init。如果内核找不到init,它就会试着运行/bin/sh,如果运行失败,系统的启动也会失败。

详细请点击访问

8、Linux下3个特殊的进程

在Linux操作系统中,进程在内存里有三部分的数据,就是“数据段”、“堆栈段”和“代码段”。

简单的说“代码段”,顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。

堆栈段存放的就是子程序的返回地址、子程序的参数以及程序的局部变量。而数据段则存放程序的全局变量,常数以及动态数据分配的数据空间(比如用malloc之类的函数取得的空间)。

9、写入时拷贝(Copy-on-write)机制

传统fork:直接把当前线程数据直接全部复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据或许可以共享。更糟糕的是,如果新进程打算立即执行一个新的映像(执行exec),那么所有的拷贝都将前功尽弃。

如今fork的写时拷贝技术:所以Linux的fork()使用写时拷贝(copy-on-write COW)页实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下—例如,fork()后立即执行exec(),地址空间就无需被复制了fork()的实际开销就是复制父进程的页表(使子进程虚拟空间和父进程一样,物理空间共用一个)以及给子进程创建一个进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化,可以避免拷贝大量根本就不会被使用的数据,导致写时复制技术很牛逼,这就回答了进程是如何被创建的。

在这里插入图片描述
内核fork()时并不复制整个进程地址空间,而是让父子进程共享一个地址空间,只有在需要写入时,数据才会被复制,从而使各个进程拥有各自的拷贝数据。
也就是说,只有在需要写入的时候才复制资源,在此之前,以只读方式共享。

写时复制技术:内核只为新生成的子进程创建虚拟空间结构,它们复制于父进程的虚拟空间结构,但是不为这些段分配物理内存,它们共享父进程的物理空间,当父子进程中有更改相应的段的行为发生时,再为子进程相应的段分配物理空间。在这里插入图片描述
vfork的做法更加简单粗暴,内核连子进程的虚拟地址空间也不创建了,直接共享了父进程的虚拟空间,当然了,这种做法就顺水推舟的共享了父进程的物理空间
在这里插入图片描述

二、函数

1、获取进程标识符PID函数

所需头文件:

#include <sys/types.h>
#include <unistd.h>

函数原型:

pid_t getpid(void) ; //获取目前调用进程的进程ID
pid_t getppid(void) ; //获取目前调用进程的父进程ID
uid_t getuid(void) ; //获取目前调用进程的实际用户ID
uid_t geteuid(void); //调用进程的有效用户ID 
gid_t getgid(void) ; //获取目前调用进程的实际组ID
gid_t getegid(void);  //调用进程的有效组ID 
//函数参数: 无
//函数说明及返回值:许多程序利用取到的id来建立临时文件, 以避免临时文件相同带来的问题。

2、创建进程 fork() 函数

所需头文件:

#include<unistd.h>  
#include<sys/types.h>   

函数原型:

pid_t fork(void);
//若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1
//pid_t 是一个宏定义,其实质是int 被定义在#include

fork函数被调用一次,但返回两次。两次返回的唯一区别是子进程的返回值是0,而父进程的返回值则是新子进程的进程ID。

将子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,但是没有一个函数能使一个进程可以获得其所有子进程的进程ID。

fork使子进程获得返回值为0的原因:一个进程只会有一个父进程,所以子进程总是可以调用getppid用来获得其父进程的进程ID。

子进程和父进程继续执行fork调用之后的指令(原因在文章的后面部分会解释)。子进程是父进程的副本。子进程可以获得父进程的数据空间、堆和栈的副本,但是父子进程并不共享这些存储空间,父子进程共享这些正文段。

示例代码

暂无

在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。解释一下pid的值为什么在父子进程中不同。“其实就相当于链表,进程形成了链表,父进程的pid(p 意味point)指向子进程的进程id, 因为子进程没有子进程,所以其pid为0.

fork出错可能有两种原因:
1.当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。
2.系统内存不足,这时errno的值被设置为ENOMEM。

创建新进程成功后,系统中出现两个基本完全相同的进程,子进程从父进程处继承了整个进程的地址空间,包括进程上下文、代码段、进程堆栈、内存信息、打开的文件描述符、信号控制设定、进程优先级、进程组号、当前工作目录、根目录、资源限制和控制终端, 父子进程不共享这些存储空间部分,父子进程共享正文段。这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。

注意以下几点

1.在Linux系统中创建进程有两种方式:一是由操作系统创建,二是由父进程创建进程(通常为子进程)。系统调用函数fork()是创建一个新进程的唯一方式,当然vfork()也可以创建进程,但是实际上其还是调用了fork()函数。fork()函数是Linux系统中一个比较特殊的函数,其一次调用会有两个返回值。

2.调用fork()之后,父进程与子进程的执行顺序是我们无法确定的(即调度进程使用CPU),意识到这一点极为重要,因为在一些设计不好的程序中会导致资源竞争,从而出现不可预知的问题。

3.fork产生子进程的表现就是它会返回2次,一次返回0,顺序执行下面的代码。这是子进程。一次返回子进程的pid,也顺序执行下面的代码,这是父进程。

4.进程创建成功之后,父进程以及子进程都从fork() 之后开始执行,知识pid不同。fork语句可以看成将程序切为A、B两个部分。(在fork()成功之后,子进程获取到了父进程的所有变量、环境变量、程序计数器的当前空间和值)。

一般来说,fork()成功之后,父进程与子进程的执行顺序是不确定的。这取决于内核所使用的调度算法,如果要求父子进程相互同步,则要求某种形式的进程间通信。

3、创建进程 vfork() 函数

所需头文件:

#include<unistd.h>  
#include<sys/types.h>   

函数原型:

pid_t vfork(void);
//若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1
//pid_t 是一个宏定义,其实质是int 被定义在#include

也用于创建一个进程,返回值与fork()相同。

vfork和fork的不同点:

函数目的:vfork创建的子进程是为了让子进程执行一个新的程序

复制操作:不复制父进程的地址空间,而是直接运行在父进程的地址空间中,直到子进程调用exec或者exit

效率:所以vfork的执行效率比fork要高,因为它没有copy操作

不确定的结果:但是如果子进程修改了数据、调用函数或者没有调用exec和exit方法,则会造成不确定的结果

子进程先运行:vfork保证子进程先运行

vfork()创建子进程—子进程与父进程共用同一块虚拟地址空间,
为了防止调用栈混乱,因此阻塞父进程直到子进程调用exit()退出或者进行程序替换

vfork创建的子进程不能在main函数中return 0;退出,因为释放资源后,父进程陷入混乱崩溃

示例代码

暂无

4、父子进程建数据共享问题

父子进程建数据共享问题:.data .bss .text .heap的数据都不共享 ,但是父子进程之间共享 fork 之前打开的文件描述符, 并且父子进程共用文件读写偏移量。原理如图所示:
在这里插入图片描述
所以, 在执行逻辑上, fork 之前打开的文件, 要 close 两次!!

4、进程退出和僵尸(孤儿)进程(Zombie)

在调用fork()执行后,可能会产生孤儿进程和僵尸进程,让我们一起看看到底什么是孤儿进程和僵尸进城以及怎么解决他们。

孤儿进程: 父进程结束, 子进程依旧存在。 那么子进程就被称为孤儿进程。 系统会将所有的孤儿进程挂载到 init 下。 init 进程的 pid = 1

孤儿进程: 父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为 init 进程,称为 init 进程领 养孤儿进程。

僵尸进程: 1,进程结束, 但是 PCB 没释放,2,子进程结束, 父进程未结束, 并且父进程未获取子进程的退出状态

处理僵尸进程方法:

1、 结束其父进程。

2、 父进程获取子进程的退出状态: 在父进程中调用wait()函数,但是wait 函数会阻塞运行, 直到第一个子进程退出。

3、在子进程结束时,子进程调用exit()函数给父进程发一个信号(告知父进程我已经结束了)父进程接收到信号后在对子进程做处理

~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~ ~~
正常退出:三个函数exit,

如果子进程不正常退出,则内核保证记录该进程的异常退出状态,该进程的父进程可以通过调用wait或者waitpid函数获取该子进程的异常退出状态。

如果父进程在子进程之前终止,则init进程成为该子进程的父进程。从而保证每个进程都有父进程。

如果子进程先终止(异常终止或者正常退出),内核会保存该子进程的部分信息,包括进程pid,进程终止时的状态和该进程占用的CPU时间,同时内核会清除该进程占用的内存,关闭所有已经打开的文件描述符。父进程可以通过检查该信息获取子进程的终止情况。

如果子进程先终止,而没有父进程调用waitpid获取该子进程的信息,那么这种进程被成为僵尸进程。使用ps命令可以看到僵尸进程的相关信息。

如果父进程为init进程,那么子进程异常终止并不会成为僵尸进程,因为init进程会对它的所有子进程调用wait函数获取子进程的终止状态。

5、进程中止 进程退出 exit(),_exit() _Exit(), atexit()函数

exit()函数

所需头文件:

#include <stdlib.h>

函数原型:

void exit(int status);
//1.函数作用:在调用后会让进程正常退出;并且在进程退出时会刷新缓冲区数据
//2.函数形参:
//(1)status:进程退出时的状态值,即在使用时给它一个无符号的整型数
//,并且要在0-255范围内,否则将自动默认为未定义退出状态值
//3.函数返回值:无

_exit() _Exit()函数

所需头文件:

#include <stdlib.h>   //_Exit()
#include <unistd.h>  //exit();

函数原型:

//两个函数是一个的,不过不在头一个头文件
#include <unistd.h>

void _exit(int status);

#include <stdlib.h>

void _Exit(int status);

//1.函数作用:使用此函数将立即终止一个进程,并把它的状态值返回,
//进程死亡时会发出一个信号;SIGCHLD告知系统回收该进程的资源;并且退出时不刷新缓冲区
//2.函数形参://(1)status:进程退出时的状态值,即在使用时给它一个无符号的整型数,
//并且要在0-255范围内,否则将自动默认为//未定义退出状态值

//3.函数返回值:无

示例代码

/*
 *关于exit()与_exit()两个函数测试
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{

	//创建一个子进程
	pid_t id = fork();
	
	//创建失败,直接退出整个进程
	if(id<0)
	{
		perror("fork failed");
		return -1;
	}

	//如果是子进程,打印26字母
	else if(id==0)
	{
		printf("good good study");
		//退出子进程并且刷新缓冲区的数据
		_exit(0);
	}

	//不加换行符,一般需要进程运行结束才会刷新缓冲区
	printf("hello world");

	//退出程序并且刷新缓冲区
	exit(0);
}

在这里插入图片描述
运行结果:

(1)子进程的因为是立即终止进程非正常退出,因此没有刷新缓冲区的数据,所以没有往屏幕打印“good good study”,

(2)父进程使用的是exit是正常退出该进程,所以可以在进程被exit结束进程后打印“hello world”

atexit函数:

所需头文件:

#include <stdlib.h>

函数原型:

int atexit(void (*function)(void));
//1.函数作用:注册一个进程退出处理函数,在进程正常退出(at normal exit)
//后再执行一个自己写的程序(与_exit一起使用无效),并且子进程会继承父进程
//注册的进程退出处理函数

//2.函数形参:(1)void (*function)(void):本质是一个函数指针,用于接受一个函数名,
//在执行结束后执行该函数的代码

//3.函数返回值:如果函数成功注册,则该函数返回零,否则返回一个非零值。

示例代码

#include <stdio.h>
#include <stdlib.h>

void functionA ()
{
   printf("这是函数A\n");
}

int main ()
{
   /* 注册终止函数 */
   atexit(functionA );
   
   printf("启动主程序...\n");

   printf("退出主程序...\n");

   return(0);
}

让我们编译并运行上面的程序,这将产生以下结果:

启动主程序…
退出主程序…
这是函数A

总结

1.使用exit函数是会结束进程后自动刷新缓冲区,且是正常退出

2.使用_exit函数是不会在进程结束后刷新缓冲区,且是立即终止进程(非正常退出)

3.atexit函数只由进程在正常退出情况下才能使用,因此atexit能与exit一起使用,但不能与_exit一起使用

6、wait()和waitpid()函数

子进程终止,内核会向父进程发送SIGCHLD信号。父进程默认的行为是忽略该信号,父进程也可以设置一个信号处理函数,当捕捉到该信号时,调用该处理函数,在后面的相关章节会介绍信号相关的概念。

本节介绍的wait和waitpid函数的作用是:

如果子进程在运行,则阻塞;
如果子进程终止,并且子进程的终止状态被父进程获取,则该函数立刻返回该终止状态;
如果该进程没有任何子进程,则返回错误。

需要注意的一点是,如果我们在接收到SIGCHLD信号后,调用wait函数,则该函数会立刻返回。在其他情况下调用wait函数,则会阻塞。

所需头文件:

#include <sys/types.h>
#include <sys/wait.h>

函数原型:

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
//返回:进程ID如果正常,则返回0,或出错时返回-1 

两个函数之间的区别:

wait函数会阻塞,一直到一个子进程终止;waitpid函数的参数options可以指定不阻塞;
waitpid函数可以选择不阻塞,并且可以指定等待某一个子进程终止。

函数细节:
如果一个子进程终止并成为了僵尸进程,wait函数立刻返回该子进程的状态;
如果一个进程调用wait()函数并阻塞,并且有多个子进程,则当有一个子进程终止时,wait()函数返回;
参数statloc是一个整型指针,如果该参数不为null,则子进程的终止状态被保存在该参数指向的整型中;如果我们不关心进程的终止状态,statloc传入null就行;

返回值检查:
使用四个宏来检查wait和waitpid函数来获取子进程的终止状态(terminated status),如退出状态,信号值等信息。

四个宏的具体说明见下表所示:
在这里插入图片描述

pid的取值对waitpid函数行为的影响:

pid == -1:行为和wait相同,等待任意一个子进程终止
pid > 0:等待进程号为pid的进程终止
pid ==0:等待进程组号和调用进程的进程组号相同的任意一个子进程终止
pid < -1:等待进程组号等于pid的任意一个子进程终止

参数option的取值:

在这里插入图片描述
在这里插入图片描述
问题可以参考:点击访问

waitpid函数提供了三个wait没有的特性:

waitpid可以让我们等待某一个特定的进程;
waitpid提供了不阻塞版本的wait函数;
option参数WCONTINUED和WUNTRACED为系统的任务控制(job control)提供了支持。

7、exec族 程序替换

替换一个进程所正在运行的程序--------重新加载其他程序到内存,重新映射虚拟地址空间与内存的映射位置到新的程序地址上;(代码段修改映射位置,数据段重新初始化)

我们用fork函数创建新进程后,经常会在新进程中调用exec函数去执行另外一个程序。当进程调用exec函数时,该进程被完全替换为新程序。因为调用exec函数并不创建新进程,所以前后进程的ID并没有改变。

进程重新从main函数开始调度运行
重新更新页表信息,映射地址信息
更改程序计数器到main函数的起始位置,重新开始执行
在这里插入图片描述
这些函数原型看起来很容易混,但只要掌握了规律就很好记。

#include <unistd.h>
extern char **environ;

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
//返回值:
//exec函数族的函数执行成功后不会返回,调用失败时,会设置errno并返回-1,
//然后从原程序的调用点接着往下执行。

参数说明:

path:可执行文件的路径名字
arg:可执行程序所带的参数,第一个参数为可执行文件名字,没有带路径且arg必须以NULL结束
file:如果参数file中包含/,则就将其视为路径名,否则就按 PATH环境变量,在它所指定的各目录中搜寻可执行文件。

exec族函数参数极难记忆和分辨,函数名中的字符会给我们一些帮助:
l : 使用参数列表
p:使用文件名,并从PATH环境进行寻找可执行文件
v:应先构造一个指向各参数的指针数组,然后将该数组的地址作为这些函数的参数。
e:多了envp[]数组,使用新的环境变量代替调用进程的环境变量

这些函数原型看起来很容易混,但只要掌握了规律就很好记。

l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量

l和v的区别:传参的区别

l是程序运行参数使用函数的实参平铺的形式赋予 execl(ls ,ls ,-l -a ,NULL)
v是程序运行参数使用字符串指针数组赋予 argv[0]=ls argv[1]=-1 execl(ls , argv)

带p和不带p区别:

带p:程序名称可以不带路径,直接区PATH环境变量所制定的路径下找程序 Execlp(ls , …)
不带p:程序名称必须带路径 execl(/bin/ls)

带e和不带e的区别:

带e: 给进程自定义环境变量 env[0]=”myenv=100”execle(ls , ……, NULL,env)
不带e:继承原有默认的环境变量。 Execl(ls ,…);

参考请点击这里

版权声明:本博所提供的内容均为互联网整理而来,仅供学习参考,如有侵犯您的版权,请联系博主删除。

参考资料来自(感谢):
https://www.jianshu.com/p/2007a20fdef8
https://www.cnblogs.com/CodingUniversal/p/7396671.html
https://www.cnblogs.com/tgycoder/p/5263644.html
https://www.cnblogs.com/lengender-12/p/7054896.html
https://blog.csdn.net/u010710458/article/details/79617395
https://blog.csdn.net/jobbofhe/article/details/82192092
https://www.jianshu.com/p/2007a20fdef8
https://blog.csdn.net/weixin_30340819/article/details/95970274
https://blog.csdn.net/weixin_41537785/article/details/81157031?https://blog.csdn.net/weixin_41537785/article/details/81157031
https://blog.csdn.net/takashi77/article/details/108077328
https://www.cnblogs.com/black-mamba/p/6886434.html
https://blog.csdn.net/hkhl_235/article/details/78653345
https://blog.csdn.net/liuyuchen282828/article/details/89785426

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值