ha

目录

导入:
典型的嵌入式产品就是基于Linux系统在硬件上跑起来(系统移植工作),第二步基于Linux系统来开发应用程序实现产品功能。

什么是应用编程?
基于Linux去做系统编程,其实就是通过调用Linux的系统API来实现应用需要完成的任务。

什么是文件IO ?
文件IO就是文件的读写。

1· 文件操作的主要接口API

1·1 什么是操作系统的API?
API是一些函数,这些函数是Linux系统提供,由应用层程序来使用。应用层程序通过调用API来调用操作系统中的各个功能 以此实现目的。

学习一个操作系统,就是学习使用这个操作系统的API.

1·2 Linux常用文件IO接口(API)

open \ close \ write \ read \ lseek

1·3 文件操作的一般步骤
在Linux系统中操作一个文件,首先,打开一个文件(一定要打开成功);其次对文件进行操作;最后一定要关闭文件,否则可能会对文件造成文件损坏。

文件平时存放在块设备中 的 文件系统中,我们把这种文件叫静态文件。当我们去open打开一个文件时,Linux内核做的操作:内核在进程中建立了一个打开文件的数据结构,记录我们打开的这个文件;内核在内存中申请一段内存,并且将静态文件的内容从块设备中读取到内存中特定地址管理存放(叫动态文件)。
打开文件后,以后对这个文件的读写操作都是针对内存中这一份动态文件,而不是针对静态文件。当我们对动态文件进行读写后,此时内存中的动态文件和块设备中静态文件就不同步了,当我们close关闭动态文件时,close内部 内核将内存中的动态文件的内容更新到(同步)块设备中的静态文件中去。

为什么这样设计?
因为块设备本身有读写限制(回忆NandFlash,SD等块设备的读写特征),所以对块设备进行操作非常不灵活。而内存可以按字节为单位来操作,而且可以随机操作(内存就叫RAM,random),很灵活。所以内核设计文件操作时就分 静态文件 和动态文件相结合的方法 设计。

1·4 文件描述符
(1)文件描述符就是一个数字,这个数字在一个进程中所以:文件描述符的作用域就当前进程)表示一个特定的含义,当我们open一个文件时,操作系统在内存中构建了一些数据结构来表示这个动态文件,然后返回给应用程序一个数字作为文件描述符,这个数字就和我们内存中维护这个动态文件的这些数据结构绑定了,以后我们应用程序如果操作这个动态文件,只需要用这个文件描述符进行区分。

(2)一句话总结就是:文件描述符是用来区分一个程序打开的 多个文件。

提示:查询 man手册
man 1 xxx :查询Linux shell 命令
man 2 xxx:查询API
man 3 xxx :查询库函数

2· 一个简单的文件读写实例

2·1 Linux系统中的文件描述符fd的合法范围是0 或者一个正整数,不可能是一个负数。

2·2 open返回的fd 必须记录好,以后对这个文件的所有操作都要靠这个fd去找这个文件,最后文件的关闭也需要指定这个文件的fd。如果在我们关闭文件前fd丢失,那这个文件就没法关闭,没法读写。

2·3 ssize_t read(int fd, void *buf, size_t count)

fd:读取哪个文件,一般由前面的open返回得到
buf:是由于程序直接提供的一段内存缓冲区,用来存储读出的内容
count:需要读取的字节数(理想状态)
返回值ssize_t:是Linux系统中内核用typedef重定义的一个类型(其实就是int),返回值表示成功读取的字节数(实际状态)。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main (void)
{
	// 第一,打开一个文件
	int fd = -1;
	char buf[30] = {0};
	int ret = -1;
	fd = open("new_file.txt",O_RDWR);

	if(-1 == fd)
	{
		printf("open error!\n");
		_exit(-1);//正式结束异常进程函数
	}
	else
	{
		printf("fd = %d\n", fd);
	}

	// 第二,读写操作

	ret = read(fd,buf,5);
	if(-1 == ret)
	{
		printf("read error!\n");
		_exit(-1);
	}
	else 
	{

		printf("实际读取%d个字节\n", ret);
		printf("文件内容是[%s]\n",buf );

	}


	// 第三,关闭文件
	close(fd);

	return 0;
}

结果:
fd = 3
实际读取5个字节
文件内容是[okkkk]


推测:
ret = read(fd,buf,40);// 我们需要read读40个字节
但是new_file.txt文件里面就6个字符,
那结果:
fd = 3
实际读取6个字节
文件内容是[okkkkk]


3· open函数的flag详解

3·1 读写权限:O_RDONLY , O_WRONLY , O_RDWR

(1)Linux系统中文件有读写权限,我们在open打开文件时也可以附带权限说明
比如:
O_RDONLY :只读打开
O_WRONLY:只写打开
O_RDWR:可读可写打开

3·2 打开存在并且有内容的文件:O_APPEND , O_TRUNC

(1)新内容会代替原来内容(原来内容丢失)。-------- O_TRUNC
O_TRUNC属性去打开一个有内容的文件时,则原来的内容会被丢弃。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main (void)
{
	// 第一,打开一个文件
	int fd = -1;
	char buf[30] = {0};
	int ret = -1;
	fd = open("new_file.txt",O_RDWR | O_TRUNC);// 省略了检测open是否异常
	printf("fd = %d\n", fd);


	// 第二,读写操作
	ret = read(fd,buf,5);
	printf("实际读取%d个字节\n", ret);
	printf("文件内容是[%s]\n",buf );


	// 第三,关闭文件
	close(fd);

	return 0;
}


结果:
fd = 3
实际读取0个字节
文件内容是[]

从结果可以看出,加了O_TRUNC 权限之后打开一个有内容的文件会让原来内容丢失。

(2)新内容附加在后面,原来的内容还存在于前面。— O_APPEND
O_APPEND属性去打开文件时,如果这个文件中本来是有内容的,则新写入的内容会接续到原来内容的后面。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main (void)
{
	// 第一,打开一个文件
	int fd = -1,ret = -1,ret2 = -1;
	char read_buf[30] = {0};
	char writter_buf[10] = "caojiajia!";

	fd = open("new_file.txt",O_RDWR | O_APPEND);// 省略了检测open是否异常
	printf("fd = %d\n", fd);


	// 第二,读写操作

	ret2 = write(fd,writter_buf,9);
	if(-1 == ret2)
	{

		printf("wite error!\n");
		_exit(-1);
	}
	else
	{

		printf("writ success!ret2 = %d\n",ret2);
	}
	


	ret = read(fd,read_buf,5); // 省略了检测read是否异常
	printf("实际读取%d个字节\n", ret);
	printf("文件内容是[%s]\n",read_buf );


	// 第三,关闭文件
	close(fd);

	return 0;
}

结果:

fd = 3
writ success!ret2 = 9
实际读取0个字节
文件内容是[]

但是打开这个文件查看文件内容时:
在这里插入图片描述
发现writ 是成功了的,只是read这个文件的时候出了问题。

原因:当我们open一个空文件后对这个文件进行write操作,这个时候的文件指针是处于 write完后的内容最后(因为是open的一个空文件,所以文件指针就是指向的文件底部) ,所以我们再去read的时候,就会从当前文件指针的后面开始读入,但是当前文件指针已经位于文件底部了,所以最后打印不出来文件内容,却可以vi 进去文件查看到正确的完好的文件内容。

具体的lseek函数会在下面讲到。

(3)如果O_APPEND 与 O_TRUNC 同时出现会怎样?
O_TRUNC 会把 O_APPEND 的效果给屏蔽了。

3·3 exit , _exit , _Exit 退出进程

(1)当我们程序在前面步骤 操作失败导致后面程序都没有可能进行下去时,应该在前面的错误监测中结束整个程序,不应该让程序继续运行下去了。
(2)我们如何退出程序?
第一种:在main函数中用return,一般默认return 0;表示程序正常终止,return -1;表示程序异常终止。
第二种:正式终止进程(程序)应该用exit 或者 _exit 或者 _Exit 之一。

3·4 当打开一个不存在的文件的时候:O_CREAT , O_EXCL

(1) 当open一个不存在的文件时,会出现文件打开错误,fd = -1.为了避免这种情况,我们可以加一个 O_CREAT 权限。可以创建一个文件并成功打开整个文件。

(2) 当open时使用了O_CREAT ,但是文件已经存在的情况下会怎样?
open 中加入O_CREAT后,不管原来这个文件存在与否都能打开成功,如果这个文件不存在则创建一个空的新文件,如果原来文件存在则会重新创建这个文件,原来的内容会被消除掉(优点类似于先删除再创建一个新的同名文件)

这样可能会带来一个问题:
原来老文件内容会被丢失。
解决方法:加一个 O_EXIT权限。O_CREAT , O_EXCL这两个标志结合使用。当这两个标志结合使用时,如果没有这个文件就会创建,有这个文件就会提示该文件已经存在,不可以再次被创建,避免已存在文件内容被清除的风险。

3·5 open函数使用O_CREAT 标志去创建文件时,可以使用第三个参数来指定要创建文件的权限。mode使用4个数字来指定权限的,其中后面三个很重要,对应我们要创建的这个文件的权限标志。比如:可读可写不可执行:0666,可读可写可执行:0777.

3·6 O_NONBLOCK 非阻塞式标志(应用层)
(1)阻塞 与 非阻塞
阻塞:函数要完成的事情条件不具备,当前不可以执行,需要等待条件达到后再执行。
非阻塞:函数会立即返回,但是函数有没有执行成功却不一定。

(2)阻塞 与 非阻塞 是两种设计思路,没有绝对的好坏之分。总的来说,阻塞式的结果有保障,但是时间没有保证;非阻塞式的时间有保障但是结果没有保证。

(3)操作系统提供的API和API封装的库函数,有很多是设计成阻塞式或者非阻塞式,所以在调用这些函数的时候心里必须非常清楚。一般open一个文件就是阻塞式,如果向让它变成非阻塞式就需要加O_NONBLOCK 标志。

(4)只适用于设备文件,而不用于普通文件。

3·7 O_SYNC 标志(访问硬件的时候,缓冲区)
(1)O_SYNC 这个标志本身就是阻塞性质的。write 阻塞 等待底层 完成写入才返回到应用层。
(2)无O_SYNC 时,write 只是将内容写入 底层缓冲区 就返回,然后底层(操作系统中负责实现open,write这些操作的那些代码,也包含os中读写硬盘等底层硬件的代码)在合适的时候(比如说缓冲区满了就可以写入硬盘了;需要写入的内容足够了可以写入硬盘了)会将缓冲区的内容一次性的同步到硬盘中。这种设计是为了提升硬件操作性能,提升硬件寿命;但是有时候我们希望硬件不要等待,直接将我们的内容写入硬盘中,这时候就可以用O_SYNC 标志。

4· 文件读写的一些细节

4·1 errno 与 perror
(1)errno 就是错误号码。Linux系统中对各种常见错误做了个编号,当函数执行错误时,函数会返回一个特定的errno编号来告诉我们足够函数哪里出问题了。
(2)errno是由os来维护的全局变量,任何os内部函数可以通过设置errno来告诉上层调用者发生了什么错误。
(3)errno就是一个数字,这个数字就会对应一个错误。我们只看errno时只能得到一个错误编号,除非能记住几百个错误编号所对应的错误信息。所以不适合人看。
解决方法:Linux系统提供了一个函数perror,perror函数内部会读取errno,并且将错误编码对应的错误信息打印出来。

int main (void)
{
	// 第一,打开一个文件
	int fd = -1,ret = -1,ret2 = -1;
	char read_buf[30] = {0};
	char writter_buf[10] = "chahahha!";

 
	fd = open("./new_file.txt",O_RDWR |O_CREAT);

	if(fd ==-1)
	{

		//printf("open error!\n");
		perror("open");
		_exit(-1);
	}
	else
	{

		printf("fd = %d\n", fd);
	}
	
	close(fd);

	return 0;
}

	

4·2 read , write 的 count
(1) ssize_t read(int fd, void *buf, size_t count);
count 表示需要读多少字节数(理想的,我们想让read读多少字节内容);read 的返回值 表示 成功读到多少字节(实际的,read实际真正读到多少字节)。

(2)count 与阻塞式结合起来。如果一个函数是阻塞式的,我们需要读取30个字符,结果暂时只有20个字符就会被阻塞住,等待剩下的10个可以读。

(3)当我们写正式程序时,需要读取一个很庞大的文件(比如2MB),我们不可能把count设置为210241024,而是应该把count设置为一个合适的数字(比如2048,4096),然后通过一个循环来读,直到下一次读入的大小不是2048或者4096就表示这个文件经过多次读取已经全部读完了。

4·3 文件IO 效率和 标准IO
(1)文件IO就是指open , close , write , read等API函数构成的一套用来读写程序文件的体系,这套体系虽然能很好的完成文件操作,但是效率不是最高的。

应用层C语言库提供了一些用来做文件读写的函数列表,叫做标准IO。标准IO由一系列的c库函数构成(fopen , fclose , fwrite , fread),这些标准IO函数其实是由文件IO封装而来的(fopen内部还是调用open )。文件IO加了封装之后主要是为了在应用层添加一个缓冲机制这样我们用fwrite写入的内容不是字节进入内核中的缓冲区而是先进入应用层标准IO库字节维护的缓冲区中,然后标准IO库自己根据操作系统单次write的最佳count来选择好的时机完成write到内核中的缓冲区,然后内核中的缓冲区在根据一个合适的时间把内容写入最终目的地—硬盘。

在这里插入图片描述

在这里插入图片描述

5· Linux系统如何管理文件

5·1 硬盘中的静态文件 和 inode(i节点)

(1)文件平时都是存放在硬盘中的 ,硬盘存储的文件以一种固定的形式存放— 静态文件。
(2)一块硬盘中可以分为两块区域:一个是硬盘内容管理表项,另一个是真正的存储内容区域。操作系统访问硬盘不是直接访问存储文件的存储内容,而是先在内容管理表项 寻找 需要访问文件的扇区级别信息然后再根据这个信息去查询真正存储内容的区域,最后得到我们要的文件。

(3)过程:第一步,操作系统去查询硬盘内容管理表项,这个管理表中以文件为单位记录了各个文件的各种信息,每一个文件有一个信息列表(也就是inode,i节点,本质就是一个结构体,这个结构体有很多元素,每个元素记录了这个文件的一些信息,包括文件名,文件在硬盘上对应的扇区号,块号等等)。
注意:硬盘内容管理表项 是以文件为单位,每一个文件就是一个inode,每个inode有一个数字编号,对应一个结构体,结构体记录了各种信息。

5·2 内存中被打开的文件和vnode(v节点)

(1)一个程序的运行就是一个进程,我们在程序中打开的文件就属于某个进程。每个进程都有一个数据结构来记录这个进程的所有信息(叫做进程信息表),表中有一个指针会指向一个文件管理表,文件管理表中记录了当前进程打开的所有文件以及相关信息。文件管理表中用来索引各个打开的文件的index就是文件描述符fd,我们最终找到的就是一个已经被打开的文件的管理结构体vnode。

(2)一个vnode中就记录了一个被打开的文件的各种信息,而且我们只要知道这个文件的fd,就可以很容易的找到这个文件的vnode进而对这个文件进行各种操作。

5·3 文件与 流 的概念

(1)流(stream):文件类似于一个大包裹,里面装的一堆字符,但是文件被读出或者写入时都只能一个字符一个字符的进行,而不能一下全部写或者读,那么,一个文件中N个字符被挨个挨个的读出或者写入时,这些字符就构成了一个流。

(2)流这个概念是动态的。

(3)编程中提到的流,一般都是和IO相关的。所以经常叫IO流。文件操作时就构成了一个IO流。

6· lseek详解

(1)lseek函数介绍
当我们对一个文件进行读写操作时,就一定要打开这个文件,所以我们读写的文件都是动态文件。动态文件在内存中的存在形态就是文件流的形式。

(2)文件指针:就是文件光标。 表示当前我们所操作 文件流的位置。Linux系统用lseek函数访问这个文件指针。

(3)当打开一个空文件时,默认情况下文件指针指向文件流的开始。所以在这个时候去write时写入就是从文件开头开始的。write和read函数本身自带移动文件指针的功能。如果需要人为的随意更改指针,那就只能通过lseek函数。

off_t lseek(int fd, off_t offset, int whence);

参数 offset :偏移量 。

参数 whence:参照物
SEEK_SET :光标以文件顶部为参照

SEEK_CU:光标以 当前地方 为参照

SEEK_END:光标以 文件底部 为参照

RETURN VALUE
Upon successful completion, lseek() returns the resulting offset loca‐
tion as measured in bytes from the beginning of the file. On error,
the value (off_t) -1 is returned and errno is set to indicate the
error.
返回值就是:当前文件指针距离文件顶部的偏移量。

例子:
lseek( fd, 19, SEEK_CU); // 以当前光标所在位置为参照,将光标定位在 当前位置向后偏移19个字节的地方。
lseek( fd, 0, SEEK_SET ); // 以文件顶部为参照,将光标定位在顶部向后偏移0个字节的地方(就是文件顶部)
lseek( fd, 13, SEEK_END);// 以文件底部为参照,将光标定位在文件底部向后偏移13个字节的地方(就是超出这个文件内容了)

思考:偏移量取正数 表示 向后偏移(向文件底部偏移),那如果偏移量取负数是不是表示向前偏移(向前偏移=向文件顶部偏移)。
结果:是的。

(4)read 和 write函数都是从当前的 文件指针处开始操作。当我们用lseek显式的将文件指针移动后,那再去read或者write文件时,就是从移动过后的文件指针的位置开始操作。

(5)用lseek计算文件长度

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int cal_file(const char *pathname)
{
	int fd = -1;
	int ret = -1;

	fd  = open(pathname,O_RDONLY);
	if(fd < 0)
	{
		perror("open");
		_exit(-1);
	}

	// 此时文件指针指向文件开头
	// 我们使用lseek将文件指针移到文件末尾,
	// 返回值就是文件指针距离文件开头的字节数(也就是偏移量)

	ret = lseek(fd , 0 ,SEEK_END);
	printf("文件长度 = %d\n",ret);

	return ret;
}

int main (int argc,char *argv[])
{
	if(2 != argc)
	{
		printf("usage: %s filename\n",argv[0]);
		_exit(-1);
	}
	cal_file(argv[1]);
	
	return 0;
}


结果:
cjj@cjj-virtual-machine:/mnt/hgfs/sharefile/C/3.1$ gcc 3.1.3.c 

cjj@cjj-virtual-machine:/mnt/hgfs/sharefile/C/3.1$ ./a.out 

usage:./a.out filename

cjj@cjj-virtual-machine:/mnt/hgfs/sharefile/C/3.1$ ./a.out new_file.txt 

文件长度 = 23

cjj@cjj-virtual-machine:/mnt/hgfs/sharefile/C/3.1$ cat new_file.txt 

chahahha!hhhhhhhhhhhhh

注意:这个方法只能适用于当前路径下的文件的长度计算。
不适合其他路径的文件长度计算。

(6)用lseek构建空洞文件
空洞文件就是这个文件有一段是空的。(回车其实算有内容,只是被不可视化了)

普通文件中间是不能有空的,因为write一个文件时文件指针是依次从前到后取移动的,不可能绕过前面直接到后面。

当我们打开一个文件后,用lseek往后跳过一段,再write写入一段,就会构成一个空洞文件。

7· 多次打开同一文件 与 O_APPEND

O_APPEND标志可以让write 和 read 函数内部多做一件事情,就是移动自己的文件指针的同时也去把别人的文件指针同时移动。也就是说即使加了O_APPEND,fd1 和 fd2 还是各自拥有一个独立的文件指针,但是这两个文件指针关联起来了,一个动另一个也会动。

O_APPEND 对文件指针的影响:对文件的读写是原子的。
原子操作的含义:整个操作一旦开始是不会被打断的,必须直到操作结束 其他代码才得以调度运行,这就叫原子操作。每种操作系统中都有一些机制来实现原子操作,以保证那些原子操作的任务可以运行。

8· 文件共享的实现方式(多个文件描述符指向一个文件)

8·1 什么是文件共享?
文件共享就是同一个文件(同一个文件指的是同一个inode,同一个pathname)被多个独立的读写体(可以理解为多个文件描述符)去同时操作(一个打开尚未关闭的同时另一个去操作)。

文件共享的意义有很多:用文件共享来实现多线程同操作同一个大文件,以减少文件读写时间,提升效率。

文件共享的核心:如何让多个文件描述符指向同一个文件。

8·2 文件共享的3种实现方式
(1)第一种:同一个进程中多次使用open打开同一个文件。

(2)第二种:在不同进程中去分别使用open打开同一个文件,这时因为两个fd再不同进程中,所以两个fd数字可以相同也可以不同。

(3)第三种:Linux系统提供了dup 和 dup2两个API来让进程复制文件描述符。(只能接续写,不能分别写)

fd 为 0 1 2 分别表示 标准输入,标准输出,标准错误 三个文件。

9· 文件描述符的复制

9·1 dup 和 dup2

int dup(int oldfd);

RETURN VALUE
On success, these system calls return the new file descriptor. On
error, -1 is returned, and errno is set appropriately.

int dup2(int oldfd, int newfd);

// dup 函数:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define FILENAME "new_file.txt"

int main (void)
{
	int fd1 = -1,fd2 = -1;
	fd1 = open(FILENAME,O_RDWR | O_CREAT | O_EXCL);
	if(fd1 < 0)
	{
		perror("open");
		_exit(-1);
	}

	printf("open fd1 = %d\n",fd1);
	fd2 = dup(fd1);
	if(fd2 < 0)
	{
		perror("dup fd2");
		_exit(-1);
	}

	printf("dup fd2 = %d\n",fd2);
	close(fd1);
	return 0;
}

结果:
open fd1 = 3
dup fd2 = 4


(1)dup的缺陷:不能自己指定复制后得到的 fd 的数值,而是由操作系统内部自动分配,分配原则遵循 fd 分配的原则(当前进程所打开的文件数,所打开文件的文件描述符从3开始,到最大值。从3开始的原因是 fd =0 1 2 分别表示标准输入文件,标准输出文件,标准错误文件。)

(2)为解决dup 函数的缺陷,于是dup2 函数出现了。dup2 函数就可以指定文件描述符复制后的 fd 的数值是多少。

(3)练习:
1· 我们可以关闭标准输入,标准输出,标准错误 文件描述符fd,然后使用dup 函数重新分配得到 1 这个标准输出通道给绑定起来,这就叫标准输出重定位。
(先close(1);之后,再dup结果一定会得到dup的返回值等于 1。)

(4)dup2 复制的文件描述符,和原来的文件描述符虽然数字上不一样,但是这两个指向同一个打开的文件。再交叉写入的时候,接续写。

10· fcntl函数介绍

10·1 作用:多功能文件管理的工具箱。
(1) int fcntl(int fd, int cmd, … /* arg */ );
fd :表示要操作哪个文件
cmd : 表示要进行哪种命令操作
… : 表示变参,用来传递参数,要配合cmd来使用

(2)fcntl常用cmd :F_xxx
只需要弄明白一个作为案例,搞清楚,其他类推。
1· F_DUPFD (int) :作用是复制文件描述符,类似于dup 与 dup2. 这个命令的功能就是从可用的fd数字列表中找一个比arg大或者和arg一样大的数字作为oldfd的一个复制的fd。和dup2像但是有不同。
fcntl( fd , F_DUPFD , 9 ); // 复制fd,找一个比9大或者和9一样大的数字作为新的fd。

11· 标准IO库介绍

11·1 标准IO和文件IO的区别
(1)标准库IO 是c库函数,二年间IO 是Linux系统的API。

(2)C语言库函数是由API封装而来的。库函数内部于是调用API来完成操作。但是库函数因为多了一层封装,所以要比API更好用一点。

(3)库函数比API还有一个优势就是:API在不同的操作系统之间是不能通用的,但是c库函数在不同操作系统之间几乎一样。所以c库函数具有可移植性但是API不具有。

(4)性能和易用性来看,c库函数一般要好一些。比如IO,文件IO 是不带缓存的,而标准IO带缓存,因此标准IO 比文件IO性能要更高。

11·2 常用标准IO函数介绍
(1)fopen , fclose , fwrite , fread
(2)具体查看man手册

Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐