• Linux C 应用编程学习笔记——(3)深入探究文件 I/O


    《【正点原子】I.MX6U嵌入式Linux C应用编程指南》学习笔记

    Linux 系统如何管理文件

    静态文件与 inode

    文件在没有打开时,都是存放在磁盘等存储设备中并且以一种固定的形式存放,我们把它们称为静态文件。我们在程序中调用 open() 函数是如何找到文件所在的存储位置呢,这里就要提到 inode 的概念了。

    inode 是 UNIX 操作系统中的一种数据结构,其本质是结构体,它包含了与文件系统中各个文件相关的一些重要信息。在 UNIX 中创建文件系统时,同时将会创建大量的 inode 。通常,文件系统磁盘空间中大约百分之一空间分配给了 inode 表。

    下面的定义仅给出了 inode 中所包含的、UNIX 用户经常使用的一些重要信息:
    ● inode 编号
    ● 用来识别文件类型,以及用于 stat C 函数的模式信息
    ● 文件的链接数目
    ● 属主的ID (UID)
    ● 属主的组 ID (GID)
    ● 文件的大小
    ● 文件所使用的磁盘块的实际数目
    ● 最近一次修改的时间
    ● 最近一次访问的时间
    ● 最近一次更改的时间

    ——百度百科

    打开一个文件,系统内部将会进行以下三步:

    1. 系统找到文件对应的 inode 编号;
    2. 根据 inode 编号在 inode table 中查找相应的 inode 结构体;
    3. 根据 inode 结构体确定文件数据所在位置(块),然后读取文件数据。

    返回错误处理与 errno

    平时我们编写代码时,判断函数执行失败后,会使用 return 退出程序,但是很难确定具体出错的原因(一般出错时我们都返回 -1)。Linux 系统下对常见错误编了号,分别对应不同的错误类型,这些错误编号赋值给了 errno 变量。

    errno 是记录系统的最后一次错误代码。代码是一个int型的值,在errno.h中定义。查看错误代码errno是调试程序的一个重要方法。当linux C api函数发生异常时,一般会将errno变量(需include errno.h)赋一个整数值,不同的值表示不同的含义,可以通过查看该值推测出错的原因。在实际编程中用这一招解决了不少原本看来莫名其妙的问题。

    ——百度百科

    下面举个简单的例子,当前目录没有 “test_file” 这个文件,所以会运行 if 内的语句。

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <errno.h>
    
    int main()
    {
    	int fd;
    
    	fd = open("test_file", O_RDWR);
    
    	if(fd < 0)
    	{
    		//printf("test_file open failed.\n");
    		printf("%d\n", errno);
    		return -1;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    这个程序的运行结果为 2(前提是 test_file 文件不存在)。错误码 2 对应的错误类型为 “文件或目录不存在”,错误码对应的含义可以在 /usr/include/asm-generic/errno.herrno-base.h 中查看(ubuntu16.04)。

    在这里插入图片描述

    strerror()

    前面提到的 errno 只是一个数字,如果每次都要查看错误号对应的含义,未免太过麻烦。这里介绍一个 C 库函数 strerror(),该函数可以直接获取错误码对应的错误信息(字符串形式),该函数原型如下:

    #include <string.h>
    char *strerror(int errnum);
    
    • 1
    • 2

    下面是一个简单的测试代码:

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <errno.h>
    #include <string.h>
    
    
    int main()
    {
    	int fd;
    	
    	fd = open("test_file", O_RDWR);
    	if(fd < 0)
    	{
    		//printf("test_file open failed.\n");
    		printf("%s\n", strerror(errno));
    		return -1;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    运行结果:

    在这里插入图片描述

    perror()

    除了 strerror() 函数,perror() 函数也能用来查看错误信息,而且它比 strerror() 更加方便,strerror() 还需要传入 errno 且只能获取字符串,而 perror() 可以直接获取错误信息且将其打印出来。函数原型如下:

    #include <stdio.h>
    void perror(const char *s);
    
    • 1
    • 2

    参数 s 是错误的自定义提示信息。

    简单的测试代码:

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    
    int main()
    {
    	int fd;
    
    	fd = open("test_file", O_RDWR);
    	if(fd < 0)
    	{
    		//printf("test_file open failed.\n");
    		perror("");
    		perror("错误提示");
    		return -1;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    运行结果:

    在这里插入图片描述

    如果 perror() 的参数不为空字符串,那么该函数会自动在提示信息后面加上冒号和空格。

    exit()、_exit()、_Exit()

    在程序遇到错误时,我们有时会使用 return 将程序终止,一般情况下,正常退出使用 return 0,错误返回 return -1。在 Linux 中,进程正常退出除了可以使用 return 外,还能用 exit()、_exit() 及 _Exit()。

    _exit() 和 _Exit()

    这两个函数是等价的,原型分别是:

    #include <unistd.h>
    void _exit(int status);
    
    • 1
    • 2
    #include <stdlib.h>
    void _Exit(int status);
    
    • 1
    • 2

    这两个函数会结束当前进程,并且清除其使用的内存空间,关闭进程的所有文件描述符。

    这里拿 _exit() 来做一个简单测试:

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <unistd.h>
    
    int main()
    {
    	int fd;
    
    	fd = open("test_file", O_RDWR);
    	if(fd < 0)
    	{
    		//printf("test_file open failed.\n");
    		//perror("");
    		perror("错误提示");
    		_exit(-1);
    	}
    	close(fd);
    	_exit(0);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    运行结果,$? 为上一次函数调用的返回值。

    在这里插入图片描述

    exit()

    exit() 是一个标准 C 库函数,而 _exit() 和 _Exit() 是系统调用,执行 exit() 时,最后也会执行 _exit((),只不过 exit() 还会多做一些清理工作。exit() 原型如下:

    #include <stdlib.h>
    void exit(int status);
    
    • 1
    • 2

    该函数用法和 _exit() 相同,但原文推荐我们用 exit()。

    空洞文件

    在UNIX文件操作中,文件位移量可以大于文件的当前长度,在这种情况下,对该文件的下一次写将延长该文件,并在文件中构成一个空洞,这一点是允许的。位于文件中但没有写过的字节都被设为 0。

    如果 offset 比文件的当前长度更大,下一个写操作就会把文件“撑大(extend)”。这就是所谓的在文件里创造“空洞(hole)”。没有被实际写入文件的所有字节由重复的 0 表示。空洞是否占用硬盘空间是由文件系统(file system)决定的。

    主要特点

    • 用ls查看的文件大小是将空洞算在内的。
    • cp命令拷贝的文件,空洞部分不拷贝,所以生成的同样文件占用磁盘空间小
    • 用read读取空洞部分读出的数据是0,所以如果用read和write拷贝一个有空洞的文件,那么最终得到的文件没有了空洞,空洞部分都被0给填充了,文件占用的磁盘空间就大了。不过文件大小不变。
      空洞文件作用很大,例如迅雷下载文件,在未下载完成时就已经占据了全部文件大小的空间,这时候就是空洞文件。下载时如果没有空洞文件,多线程下载时文件就都只能从一个地方写入,这就不是多线程了。如果有了空洞文件,可以从不同的地址写入,就完成了多线程的优势任务

    ——百度百科

    O_APPEND 和 O_TRUNC 标志

    O_APPEND 和 O_TRUNC 是 oepn() 函数的两个标志。

    O_TRUNC 标志

    使用 O_TRUNC 这个标志,文件打开时就会把文件原来的数据丢弃。下面是一个简单的测试代码,(文件 test_file 原来是有数据的)

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <unistd.h>
    
    int main()
    {
    	int fd;
    
    	fd = open("test_file", O_RDWR|O_TRUNC);
    	if(fd < 0)
    	{
    		perror("错误提示");
    		_exit(-1);
    	}
    	close(fd);
    	_exit(0);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    运行结果如下,原本 test_file 内有 2322 字节的数据,使用 O_TRUNC 标志打开文件后,文件数据被清空:

    在这里插入图片描述
    O_APPEND 标志

    如果 oepn 函数使用了 O_APPEND 标志,当使用 write() 函数对文件进行写操作时,文件指针会先被移动到文件末尾。下面是测试代码:

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <unistd.h>
    
    char buff[] = "ccc";
    
    int main()
    {
    	int fd;
    
    	fd = open("test_file", O_RDWR|O_APPEND);
    	if(fd < 0)
    	{
    		perror("错误提示");
    		_exit(-1);
    	}
    
    	/* 写入数据 */
    	write(fd, buff, sizeof(buff));
    
    	close(fd);
    	_exit(0);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    测试结果如下,运行程序,“ccc” 被追加写入到了 test_file 中,但不知为什么总是要隔一个回车(难道文件尾带一个换行?也可能是因为 echo)

    在这里插入图片描述

    小提示:

    • O_APPEND 标志并不会影响读文件(read()),即读文件时依然是从文件头开始读。
    • 使用了 O_APPEND,如果在 write() 函数之前调用 lseek() 来移动文件指针,write() 写入数据依然会从文件末尾开始。

    多次打开同一个文件

    • 一个进程内多次打开同一个文件,那么会得到多个不同的文件描述符,关闭文件时也需要将这些文件描述符都关闭;
    • 一个进程内多次打开同一个文件,在内存中并不会存在多份动态文件;
    • 一个进程内多次打开同一个文件,不同文件描述符所对应的读写偏移量是相互独立的;

    复制文件描述符

    在 Linux 系统中,可以使用 dup() 或 dup2() 这两个系统调用对文件描述符进行复制,复制得到的文件描述符和旧的文件描述符拥有相同的属性。

    dup()

    dup() 用于复制文件描述符,函数原型如下:

    #include <unistd.h>
    int dup(int oldfd);
    
    • 1
    • 2

    参数 oldfd 为要复制的文件描述符。
    函数运行成功时返回一个新的文件描述符,如果复制失败则返回 -1。

    下面是一个简单的测试:

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <string.h>
    
    char buff1[] = "Hi, I am fd1\n";
    char buff2[] = "Hi, I am fd2\n";
    
    int main()
    {
    	int fd1, fd2;
    	int cnt = 0;
    
    	/* 打开文件 */
    	fd1 = open("test_file", O_RDWR|O_TRUNC);
    	if(fd1 < 0)
    	{
    		perror("错误提示");
    		_exit(-1);
    	}
    	else
    		printf("旧的文件描述符为%d\n", fd1);
    
    	/* 复制文件描述符 */
    	fd2 = dup(fd1);
    	if(fd2 < 0)
    	{
    		perror("错误提示");
    		_exit(-1);
    	}
    	else
    		printf("新的文件描述符为%d\n", fd2);
    
    	/* fd1 写入数据 */
    	cnt = write(fd1, buff1, sizeof(buff1));
    	printf("成功写入%d字节\n", cnt);
    
    	/* fd2 写入数据 */
    	cnt = write(fd2, buff2, sizeof(buff2));
    	printf("成功写入%d字节\n", cnt);
    
    	close(fd1);
    	close(fd2);
    	_exit(0);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47

    测试结果如下,两个文件描述符都能向 test_file 写入数据。

    在这里插入图片描述

    dup2()

    dup() 和 dup2() 功能相同,惟一的区别是 dup() 返回的文件描述符由系统分配,而 dup2() 返回的文件描述符可以手动指定。下面是 dup2() 的原型:

    #include <unistd.h>
    int dup2(int oldfd, int newfd);
    
    • 1
    • 2

    oldfd 为需要复制的文件描述符,newfd 为指定函数要返回的文件描述符。函数运行成功返回一个新的文件描述符,如果失败则返回 -1。

    测试代码如下:

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <string.h>
    
    char buff1[] = "Hi, I am fd1\n";
    char buff2[] = "Hi, I am fd2\n";
    
    int main()
    {
    	int fd1, fd2;
    	int cnt = 0;
    
    	/* 打开文件 */
    	fd1 = open("test_file", O_RDWR|O_TRUNC);
    	if(fd1 < 0)
    	{
    		perror("错误提示");
    		_exit(-1);
    	}
    	else
    		printf("旧的文件描述符为%d\n", fd1);
    
    	/* 复制文件描述符 */
    	fd2 = dup2(fd1, 66);
    	if(fd2 < 0)
    	{
    		perror("错误提示");
    		_exit(-1);
    	}
    	else
    		printf("新的文件描述符为%d\n", fd2);
    
    	/* fd1 写入数据 */
    	cnt = write(fd1, buff1, sizeof(buff1));
    	printf("成功写入%d字节\n", cnt);
    
    	/* fd2 写入数据 */
    	cnt = write(fd2, buff2, sizeof(buff2));
    	printf("成功写入%d字节\n", cnt);
    
    	close(fd1);
    	close(fd2);
    	_exit(0);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47

    运行结果如下图所示,新的文件描述符为 dup2() 指定的 66。
    在这里插入图片描述

    文件共享

    文件共享多用于多进程多线程编程环境中,它能减少文件读写时间、提升效率。

    常见的三种文件共享的实现方式:

    1. 同一个进程多次调用 open() 函数打开同一个文件,各数据结构之间的关系如下图所示:
    图片来源:《【正点原子】I.MX6U嵌入式Linux C应用编程指南》

    在这里插入图片描述

    1. 不同进程中分别使用 open() 函数打开同一个文件,其数据结构关系如下图所示:
    图片来源:《【正点原子】I.MX6U嵌入式Linux C应用编程指南》

    在这里插入图片描述

    1. 同一个进程中通过 dup() 或 dup2() 函数对文件描述符进行复制,其数据结构关系如下图所示:
    图片来源:《【正点原子】I.MX6U嵌入式Linux C应用编程指南》

    在这里插入图片描述

    原子操作与竞争冒险

    竞争冒险简介

    本小节给大家竞争冒险这个概念,如果学习过 Linux 驱动开发的读者对这些概念应该并不陌生,也就意味着竞争冒险不但存在于 Linux 应用层、也存在于 Linux 内核驱动层。

    假设有两个独立的进程 A 和进程 B 都对同一个文件进行追加写操作(也就是在文件末尾写入数据),每一个进程都调用了 open 函数打开了该文件,但未使用 O_APPEND 标志,此时,每个进程都有它自己的进程控制块 PCB,有自己的文件表(意味着有自己独立的读写位置偏移量),但是共享同一个 inode 节点(也就是对应同一个文件)。假定此时进程 A 处于运行状态,B 未处于等待运行状态,进程 A 调用了 lseek 函数,它将进程 A 的该文件当前位置偏移量设置为 1500 字节处(假设这里是文件末尾),刚好此时进程 A 的时间片耗尽,然后内核切换到了进程 B,进程 B 执行 lseek 函数,也将其对该文件的当前位置偏移量设置为 1500 个字节处(文件末尾)。然后进程 B 调用 write 函数,写入了 100 个字节数据,那么此时在进程 B 中,该文件的当前位置偏移量已经移动到了 1600 字节处。B 进程时间片耗尽,内核又切换到了进程 A,使进程 A 恢复运行,当进程 A 调用 write 函数时,是从进程 A 的该文件当前位置偏移量(1500 字节处)开始写入,此时文件 1500 字节处已经不再是文件末尾了,如果还从 1500字节处写入就会覆盖进程 B 刚才写入到该文件中的数据。

    以上给大家所描述的这样一种情形就属于竞争状态(也成为竞争冒险),操作共享资源的两个进程(或线程),其操作之后的所得到的结果往往是不可预期的,因为每个进程(或线程)去操作文件的顺序是不可预期的,即这些进程获得 CPU 使用权的先后顺序是不可预期的,完全由操作系统调配,这就是所谓的竞争状态。

    ——原文

    原子操作

    原子操作指的是不会被其他任务打断的一种操作。上面的例子中,在 lseek() 和 write() 执行的过程中可能被打断,所以这两个操作合起来不能称为原子操作。下面介绍一些原子操作举例:

    1. O_APPEND 实现原子操作:这方法可以直接解决上文提到的两个进程竞争问题。
    2. pread() 和 pwrite() :这两个函数可以实现原子操作,相对于 read() 和 write(),它们多了一个参数 offset,用于指定文件当前读写偏移量,且读写完成后不会影响原本的文件指针偏移量。他们的原型如下:
    #include <unistd.h>
    ssize_t pread(int fd, void *buf, size_t count, off_t offset);
    ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
    
    • 1
    • 2
    • 3
    1. 创建一个文件(O_EXCL):使用 O_EXCL 标志创建文件时,也是一个原子操作。假如自己在程序中判断文件是否存在(open() 失败代表文件不存在),然后再创建文件,那么当两个进程都执行这一操作时,就可能会出现竞争冒险问题(参考前面的例子)。

    fcntl 和 ioctrl

    fcntl() 函数

    fcntl() 可以对一个已经打开的文件描述符进行一系列控制操作,如复制文件描述符、获取/设置文件描述符标志、获取/设置文件状态标志等。该函数的原型如下:

    #include <unistd.h>
    #include <fcntl.h>
    int fcntl(int fd, int cmd, ... /* arg */ )
    
    • 1
    • 2
    • 3

    该函数的参数是可变长度,搭配不同的 cmd 有不同的参数选项,具体用法可以使用 man 手册查看:

    在这里插入图片描述
    ioctl()

    ioctl() 是一个文件 IO 操作的杂物箱,一般用于操作特殊文件或硬件外设,原型如下:

    #include <sys/ioctl.h>
    int ioctl(int fd, unsigned long request, ...);
    
    • 1
    • 2

    request 参数没有统一值,可变参数 … 由 request 参数决定。

    该函数后面再研究。

    截断文件

    系统调用 truncate() 和 ftruncate() 可以将普通文件截断为指定字节长度,它们的函数原型如下:

    #include <unistd.h>
    #include <sys/types.h>
    int truncate(const char *path, off_t length);
    int ftruncate(int fd, off_t length);
    
    • 1
    • 2
    • 3
    • 4

    这两个函数只有第一个参数不同,一个是以文件路径为参数,另一个是根据文件描述符匹配文件。调用成功返回 0,失败返回 -1。

    下面是一个简单的测试程序:

    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <string.h>
    
    char buff[] = "hello world, I am test_file";
    char tmp[50];
    
    int main()
    {
    	int fd;
    	int cnt = 0;
    
    	fd = open("test_file", O_RDWR|O_TRUNC);
    	if(fd < 0)
    	{
    		perror("错误提示");
    		_exit(-1);
    	}
    
    	/* 写入数据 */
    	cnt = write(fd, buff, sizeof(buff));
    	printf("成功写入%d字节\n", cnt);
    
    	/* 读取文件内容 */
    	memset(tmp, 0, sizeof(tmp));
    	lseek(fd, 0, SEEK_SET);
    	cnt = read(fd, tmp, sizeof(tmp));
    	printf("成功读取%d字节\n", cnt);
    	printf("截断前文件内容为:%s\n", tmp);
    
    	/* 使用 truncate 将 test_file 截断为 11 字节 */ 
    	truncate("test_file", 11);
    
    	/* 读取文件内容 */
    	memset(tmp, 0, sizeof(tmp));
    	lseek(fd, 0, SEEK_SET);
    	cnt = read(fd, tmp, sizeof(tmp));
    	printf("成功读取%d字节\n", cnt);
    	printf("第一次截断后文件内容为:%s\n", tmp);
    	
    
    	/* 使用 ftruncate 将 test_file 截断为 5 字节 */ 
    	ftruncate(fd, 5);
    
    	/* 读取文件内容 */
    	memset(tmp, 0, sizeof(tmp));
    	lseek(fd, 0, SEEK_SET);
    	cnt = read(fd, tmp, sizeof(tmp));
    	printf("成功读取%d字节\n", cnt);
    	printf("第二次截断后文件内容为:%s\n", tmp);
    
    	close(fd);
    	_exit(0);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57

    测试结果,

    在这里插入图片描述

    每次每次截断后,文件指针不会改变偏移位置,所以需要我们手动调整。

  • 相关阅读:
    单目3D自动标注
    项目实战 Java读取Excel数据
    主图上视频全面开放,亚马逊试验新功能将让跟卖更加猖獗
    【安装SSH服务】ubuntu安装ssh以及开启root用户ssh登录
    【文末附gpt升级方案】数据虚拟化技术的优势
    Android ColorStateList的基本使用
    【一】1D测量 Measuring——1.2 fuzzy_meature_pairing()、fuzzy_meature_pairs()算子
    实验9(交换综合实验)
    【Cpp】位图Bitmap
    浏览器控制台中网络选项看不到请求发送出的url信息解决办法
  • 原文地址:https://blog.csdn.net/weixin_43772810/article/details/125614175