V3.0
王道C++班级参考资料<br />——<br />Linux部分卷3文件系统编程<br/>节3无缓冲文件流<br/><br/>最新版本V3.0
<br>王道C++团队<br/>COPYRIGHT ⓒ 2021-2024. 王道版权所有有缓冲文件流无缓冲文件流(重点)打开文件openopen的形参列表close函数简单的演示demoread函数非阻塞read: 读硬盘文件阻塞read: 读设备文件write函数读写二进制文件和文本文件中场练习:实现无缓冲IO文件复制一次性复制小文件循环复制大文件性能比较ftruncate截断文件The End
Gn!
文件流早在C语言阶段我们就已经学习过了,学的是标准C语言的库函数,比如fopen、fread、fwrite等函数。
这种文件流,更准确的称呼,应该是——带有用户态缓冲区的文件流。(包括我们刚学的目录流,也属于这个范畴)
所谓带有用户态缓冲区的文件流,指的是在读写文件的过程中,标准C语言库在用户内存空间中预留一段区域,用来作为文件读写的缓冲区,以减少IO次数,减少系统调用次数,提高整体IO操作的效率。
之前我们画过类似的图:
这个图是一个简化的版本,实际上任何与外部设备(比如硬盘)的交互,都不可能绕开操作系统内核,所以更准确的画法是这样的:
以往我们学习的文件流,即操作FILE *指针的那一套方案,文件数据的流向是:
读:磁盘数据 --> 内核态缓冲区 --> 用户态缓冲区 --> 用户进程(一般用一个buff数组存放)
写:用户进程(一般用一个buff数组存放) --> 用户态缓冲区 --> 内核态缓冲区 --> 磁盘数据
也就是说,在这个过程中,"用户态缓冲区"起到了一个"二道贩子"的作用:
读的过程中,用户态缓冲区会一次性从内核态缓冲区中读"一大块"文件数据。这个过程由于要和内核交互,于是就涉及到系统调用。
写的过程中,用户进程先将数据写入用户态缓冲区中,用户态缓冲区会"积攒"这些数据,然后一次性写"一大块"文件数据给内核态缓冲区,进而写入磁盘文件。这个过程仍然要和内核交互,仍然涉及到系统调用。
系统调用是一种远比函数调用,数据在用户空间复制代价要昂贵的多的操作,因为此过程首先要将进程状态由用户态切换到内核态,然后执行相关操作,最后进程还要回归用户态。
总之,我们不难得出一个结论:
用户态缓冲区的存在,能够有效的减少系统调用的次数,也就减少了实际磁盘IO的次数,提升了整体的IO效率
如果没有这个用户态缓冲区的存在,直接让用户进程和内核缓冲区进行系统调用级别的交互,就很有可能导致系统调用次数的增多,进而导致IO次数增多,降低整体的性能。
这就是我们之前学过的文件流,所谓的——带有用户态缓冲区的文件流。
Gn!
今天这个小节的标题就叫做"无缓冲文件流",但如果描述的更精确一些,应该是——无用户态缓冲区,用户进程直接和内核态缓冲区进行系统调用交互的文件流。
根据我们初步的理解,无缓冲文件流的模型可以参考下图:
这个模型正确吗?
这个模型显然是错误的。
内核区域显然不是普通用户进程应该直接交互访问的区域。如果内核态缓冲区也是直接返还一个指针给用户进程直接交互,那就太危险了——这意味着用户进程可以直接修改内核区域的数据,这么设计的操作系统,就太不安全,太不稳定了。
那怎么办呢?
谨记一句名言:如果计算机领域出现了解决不了的问题,那就加一层。
内核态缓冲区不能直接给一个指针让用户进程交互,那就加一层,让用户进程去交互其它的结构。
于是这里我们就要讲解一个非常重要的概念——内核区域文件缓冲区的数据结构。如下图所示:
在内核区域当中,维护了一个名为"文件对象"的数据结构,内核缓冲区就在这个文件对象中,但此文件对象不能直接给到用户空间,也不能直接交互用户空间。
于是内核区域就维护了一个索引指针数组,该指针数组中存储文件对象的地址,指向这个文件对象。这个索引数组是有下标的,下标从0,1,2,3...(非负数,最大1024)
这个索引数组的下标就被称之为"文件描述符"。
于是:
当用户进程以无缓冲文件流(系统调用)的形式打开一个文件时,内核区域就会创建一个该文件文件对象
然后内核会将指向此文件对象的索引指针元素的下标返还给用户进程,用户进程只能得到一个非负整数,这个整数叫做"文件描述符"
紧接着用户进程就通过文件描述符整数,与内核空间进行系统调用级别的交互。
在这个过程中,用户进程全程没有获得内核区域的任何地址,没有和内核数据进行任何的直接交互,这样就保证了内核空间的安全,从而保证了操作系统的安全性和稳定性。
Rd!
OK,到此为此无缓冲文件流的模型大家就都搞清楚了,那么接下来我们就要开始正式的学习代码了。
下面我们所讲解的所有函数,全部都属于Linux系统调用函数。
Gn!
首先,我们学习第一个系统调用函数——open函数。
调用open函数的作用是打开一个已存在的文件或创建一个新文件,此时内核区域会创建一个文件对象,并且将此文件对象的文件描述符,返还给函数调用者,该文件描述符可用于后续的读写操作。
此函数的声明如下:
xxxxxxxxxx
51234int open(const char *pathname, int flags);
5int open(const char *pathname, int flags, mode_t mode);
需要注意:open函数可以传两个参数也可以传入三个参数,这种语法在C++中称之为"函数重载",但C语法不支持这种语法。之所以这么可以这么做,那你就要去问C语言的开发者了。(实际上该函数历史悠久,是由C语言的初代开发者们完成的)
返回值:
成功时,
open
返回一个非负整数,即新打开或创建文件的文件描述符。失败时,返回
-1
并设置errno
以指示错误类型。对于此函数的调用,最重要的是要理解它的形式参数列表,下面我们仔细讲解一下。
Gn!
首先第一个参数,没啥可说的,就是你要打开/新建的文件的路径名。下面重点介绍
flags
参数和mode
参数。第二个参数
flags
非常重要,它指定了打开文件的方式。它是由一个或多个标志的组合,常见的文件打开的标志有:
标志 描述/含义 O_RDONLY 以只读的方式打开 O_WRONLY 以只写的方式打开 O_RDWR 以可读可写的方式打开 O_CREAT 如果文件不存在,则创建文件。如果不添加此标志,那么文件不存在时,将打开失败 O_EXCL 仅与O_CREAT连用,单独使用无意义。如果文件已存在,则open失败 O_TRUNC 如果文件已存在且成功以写入模式打开,则将其长度截断为 0,即删除文件内容。 O_APPEND 以追加模式打开文件,不能和O_RDONLY或者O_TRUNC连用。 那么这些所谓的标志,应该如何使用呢?
要真正搞懂怎么用,我们还需要理解flags是如何存储这些标志的,具体如下:
flags
一般是一个32位无符号整数,对于上述表格中的某一个标志而言,每一个标志都意味着这个flags
的位模式的某一位是1,其余位都是0。比如
O_CREAT
标志,该标志的就对应着32位的位模式的某一位是1,也就是说:
只要32位的该位值是1,那么就表示如果文件不存在,则创建文件
如果32位的该位置是0,那么就表示文件不存在将打开失败
但有三个标志是比较特殊的,它们固定利用32位整数的低两位:
O_RDONLY :read only,只读模式。在Linux系统中,只读模式一般就直接用整数0来表示它。
O_WRONLY :write only,只写模式。在Linux系统中,只写模式一般就直接用整数1来表示它。
O_RDWR:read write,读写模式。在Linux系统中,可读可写模式一般就直接用整数2来表示它。
这样设计使得这三个标志是互斥的,在打开文件选择模式时,必须进行三选一!
除了这三个标志三选一外,其余标志可以进行自由的拼接组合,如何组合呢?
由于其余标志都是某位为1表示打开标志,为0表示关闭标志,且利用的是非低两位的更高未,也不会互斥,所以只需要用"按位或|"运算符拼接这些标志就可以了。
比如:
O_WRONLY | O_CREAT | O_EXCL
:以只写模式打开文件,如果文件不存在就创建新文件,如果文件存在则报错。这个标志组合可以避免覆盖已有文件。
O_RDONLY | O_CREAT
:以只读模式打开文件,但如果文件不存在就创建一个新文件。但也不是这些标志在二进制位上不互斥,就能够随意组合了,因为有些标志在含义作用上是互斥的。所以要注意以下:
O_RDONLY、O_WRONLY、O_RDWR是互斥的,只能三选一使用,而且必须三选一。
O_EXCL仅能和O_CREAT一起使用,单独使用或者和其余选项连用,都没有意义。
O_APPEND表示写入操作将始终从文件末尾开始,即使文件指针移动到文件的其他位置也不例外。注意它和O_RDONLY或者O_TRUNC连用是没有意义的
最后还有一个参数
mode_t mode
:
这个参数本身我们很熟悉了,一般情况下我们可以使用一个八进制整数用于指定文件的权限。
这个参数只在使用了
O_CREAT
标志时才需要带上,因为这意味着文件不存在时会创建新文件,创建新文件则需要指定新文件的权限!一旦选择使用了
O_CREAT
标志,就必须加上该参数,open函数要变为三参的,否则新建文件的权限将是未知的。但没有O_CREAT
标志,不要加上此参数!!思考如果是"O_CREAT | O_EXCL"标志组合时还需要这个参数吗?答:当然需要!
补充:fopen和open的关系
实际上,fopen这个C语言的标准库函数,它的各个打开模式就是基于open系统调用函数的,各个标志去实现的。
你可以自己使用
man 3 fopen
指令去查看,如下图所示:显然fopen的底层系统调用,在Linux平台下就是
open
函数。
Gn!
既然有open函数,那就有close函数,但系统调用的close函数和传统库函数文件流、目录流当中的close有非常大的区别。
close函数表示关闭文件描述符,而不是立刻释放文件对象的资源。关于这里的细节,我们先按住不讲,待到后面的小节中补充。
这个函数的声明如下:
xxxxxxxxxx
212int close(int fd);
形参列表:
fd
: 要关闭的文件描述符返回值:
成功:返回 0。
失败:返回 -1,并且
errno
被设置为非0值,并指示错误的原因,该函数一般不需要进行错误处理。
Gn!
以下有几个演示demo:
xxxxxxxxxx
101int main(int argc, char *argv[]){
2ARGS_CHECK(argc,2);
3// 以读写模式打开文件,若文件不存在,没有权限等会打开失败
4int fd = open(argv[1],O_RDWR);
5ERROR_CHECK(fd, -1, "open");
6printf("fd = %d\n", fd); // 打印文件描述符
7
8close(fd);
9return 0;
10}
建议日常使用就以"读写模式"打开文件,这个用起来比较方便。
第二个demo:
xxxxxxxxxx
101int main(int argc, char *argv[]){
2ARGS_CHECK(argc,2);
3// 以只读模式打开文件,若文件不存在会创建一个新文件
4int fd = open(argv[1],O_RDONLY | O_CREAT, 0666);
5ERROR_CHECK(fd, -1, "open");
6printf("fd = %d\n", fd); // 打印文件描述符
7
8close(fd);
9return 0;
10}
若文件不存在,这里会创建一个权限为
0664
的新文件,这里同样是因为掩码的影响导致的。第三个demo:
xxxxxxxxxx
111int main(int argc, char *argv[]){
2ARGS_CHECK(argc,2);
3// 以读写模式打开文件,文件不存在就创建一个新文件,若文件存在则清空此文件内容
4// 类似fopen的w+模式打开文件
5int fd = open(argv[1],O_RDWR | O_CREAT | O_TRUNC, 0666);
6ERROR_CHECK(fd, -1, "open");
7printf("fd = %d\n", fd); // 打印文件描述符
8
9close(fd);
10return 0;
11}
大家课下可以自由的练习这些标志的组合,以实现不同的功能,但要注意一些选项不能连用互斥的特点。
Gn!
搞清楚无缓冲文件流的模型后,那么读写这个流就非常简单了:
读,使用read函数,它表示用户进程从内核区域中直接将数据读取出来,数据从内核区域流向用户空间。
写,使用write函数,它表示用户进程直接将内存数据写入到内核区域中,数据从用户进程流向内核区域。
注意:这个读写的过程仍然是一个流模型,我们只需要搞清楚数据流向以及通过函数调用来实现功能就可以了。至于其它问题:
数据究竟如何流动?
数据如何在外部设备和内核区域间交互?什么时机下进行?
...
诸如此类问题,我们只需要知道它们全程都由硬件和操作系统共同来完成就可以,具体的细节我们就不要去纠结了。
read函数的声明如下:
xxxxxxxxxx
212ssize_t read(int fd, void *buf, size_t count);
形式参数:
fd
: 文件描述符,它标识了一个已打开的文件流。
buf
: 用户进程用于接收从内核缓冲区读取到的数据的一块内存区域,可以是数组,也可以是别的。
count
: 指定最多读取的字节数。返回值:
成功时,
read
返回实际读取的字节数。可能等于count的值,也可能在最后一次读文件内容时,返回一个小于count的值。如果到达文件末尾(EOF),则返回 0。
出错时,
read
返回 -1,并设置全局变量errno
来指示错误类型。接下来看几个使用的demo:
xxxxxxxxxx
191// 读文本文件
2int main(int argc, char* argv[]){
3ARGS_CHECK(argc, 2);
4// 以读写的形式打开文件
5int fd = open(argv[1], O_RDWR);
6ERROR_CHECK(fd, -1, "open");
7
8// read函数读数据的单位是字节
9// 所以为了方便计算读取字节的数量,常用char数组作为数组buf中转站
10// 为了避免随机值导致输出结果中产生莫名其妙的字符出现,所以将buf数组元素初始化默认零值
11char buf[1024] = { 0 };
12ssize_t sret = read(fd, buf, sizeof(buf));
13ERROR_CHECK(sret, -1, "read");
14// 打印读到的实际字节数以及读到的字符信息
15printf("sret = %ld, buf = %s\n", sret, buf);
16
17close(fd);
18return 0;
19}
下面我们将buf数组改小一点,然后多读几次:
xxxxxxxxxx
251int main(int argc, char* argv[]){
2ARGS_CHECK(argc, 2);
3int fd = open(argv[1], O_RDWR);
4ERROR_CHECK(fd, -1, "open");
5
6char buf[6] = { 0 };
7ssize_t sret = read(fd, buf, sizeof(buf) - 1);
8ERROR_CHECK(sret, -1, "read");
9printf("sret = %ld, buf = %s\n", sret, buf);
10
11// 将buf数组内存区域清零
12memset(buf, 0, sizeof(buf));
13sret = read(fd, buf, sizeof(buf) - 1);
14ERROR_CHECK(sret, -1, "read");
15printf("sret = %ld, buf = %s\n", sret, buf);
16
17// 将buf数组内存区域清零
18memset(buf, 0, sizeof(buf));
19sret = read(fd, buf, sizeof(buf) - 1);
20ERROR_CHECK(sret, -1, "read");
21printf("sret = %ld, buf = %s\n", sret, buf);
22
23close(fd);
24return 0;
25}
上述代码中使用了一个memset函数,它是一个C 语言标准库函数。用于将一段内存中的所有字节都设置为特定的值,它经常被用于初始化数组或者清零内存区域。
此函数的声明如下:
xxxxxxxxxx
212void *memset(void *s, int c, size_t n);
其中的三个参数的含义是:
s
:指向要设定特定值的内存块的指针。此参数是一个传入传出参数,传入后同时还会作为返回值。
c
:是一个int
类型的值,但memset
实际上将该值转换为unsigned char
类型,然后用这个字节值来填充指定的内存块。
n
:指定要设定特定值的字节数,即要把内存块s
的多少个字节的内存空间设置为值c
。返回值:
memset
函数返回指向填充的内存块的指针,即参数s
的值。在利用一个缓冲区数组读写文件的过程中,为了避免存在脏数据,可以选择使用"memset"函数来清零缓冲区数据,但具体需不需要这么做要看实际应用场景。
Gn!
紧接着上面的一个代码实现,我们很容易就发现上面的代码中存在一个问题:存在重复代码,于是可以用循环简化一下代码实现。
于是我们就可以写出以下简化代码:
xxxxxxxxxx
211int main(int argc, char* argv[]){
2ARGS_CHECK(argc, 2);
3int fd = open(argv[1], O_RDWR);
4ERROR_CHECK(fd, -1, "open");
5
6char buf[6] = { 0 };
7ssize_t read_count;
8
9// 循环读取文件直到文件末尾或出错
10// sret为0时表示读到文件末尾,sret为-1时表示read出错,这两种情况都会结束循环
11while ((read_count = read(fd, buf, sizeof(buf) - 1)) > 0) {
12printf("read_count = %ld, buf = %s\n", read_count, buf);
13memset(buf, 0, sizeof(buf)); // 清空缓冲区以准备下一次读取
14}
15// 检查是否是因为读取出错而退出循环
16ERROR_CHECK(sret, -1, "read");
17
18// 关闭文件
19close(fd);
20return 0;
21}
这样的代码,我们写起来非常轻松自然,那么考虑一下为什么可以这么写呢?
这是因为read读磁盘文件的时候,是一个没有阻塞的读过程。这个过程中,数据始终从内核区域流向用户区域:
一开始磁盘文件的待读取数据还比较充足,read函数能够正常读到参数count个字节。
最后一次read数据,读到文件末尾时,read函数已经无法读到参数count个字节了。
read到达末尾后,继续read,read并不会阻塞,而是返回0。
如果read的过程中出错,read也不会阻塞,而是返回-1。
所以依据依据read磁盘文件这样的特点,我们可以以
> 0
作为一个循环的条件,轻松实现循环读完一整个文件中的数据。
Gn!
既然有非阻塞read,那么自然就有阻塞read,最常见的就是read设备文件。
在上面的内容中,我们发现:
在利用系统调用函数open打开一个磁盘文件时,系统默认分配的文件描述符是3,那么0、1、2去哪里了呢?
当然是被系统默认保留了,在 类 Unix 系统中,当一个进程启动时,它会自动打开三个特殊的文件描述符,这三个文件描述符分别是:
0
:表标准输入(stdin),默认是键盘输入。
1
:代表标准输出(stdout),默认是输出到屏幕。
2
:代表标准错误(stderr),它用于输出错误消息和诊断信息,默认是输出到屏幕。在
/dev
目录下,这三个标准流被指向了对应的设备文件,如下图所示:计算机的外部设备也被视为了一种设备文件,这体现了Linux的设计哲学,万物皆是文件。
利用这
0
这个保留的文件描述符,我们可以实现从键盘接收输入:xxxxxxxxxx
81int main(void){
2char buf[1024] = {0};
3// 用文件描述符0表示从标准输入中读
4ssize_t sret = read(0, buf, sizeof(buf));
5ERROR_CHECK(sret,-1,"read");
6printf("sret = %ld, buf = %s\n", sret, buf);
7return 0;
8}
这一段代码你应该非常熟悉了,这充分体现了Linux设计的精妙之处,哪怕是对一个外部设备进行read操作,至少从代码形式上来说,和read真正的磁盘文件是一致的。
然而,读取磁盘文件和设备文件在行为上存在以下区别:
非阻塞和阻塞read:
读磁盘文件数据时是非阻塞read,因为磁盘文件的数据是持久存储的,
read
操作可以连续进行,直到文件末尾。于是在read磁盘文件时,我们可以正常的使用while循环,因为循环会有明确的结束标志,不会导致死循环。但从设备文件(例如键盘或网络套接字)的read操作一般是阻塞的。如果没有数据可读了(如用户尚未完成输入、等待网络连接),
read
函数会阻塞进程执行,直到数据到达,此时read函数就变成了一个阻塞函数。在这种情况下,如果仍然使用循环进行read,就非常容易导致循环无法退出,成为一个阻塞的死循环。不确定性:
磁盘文件的数据访问是确定性的,以一致且可预测的方式进行,使得读取操作简单且可靠。
对于设备文件,数据的到来与否很多时候取决于外部因素,如用户输入或网络数据传输,这增加了读取操作的不确定性和复杂性。
总之,要理解读写磁盘文件和外部设备文件的区别,这里就相当于给后续课程打一个基础吧。
Gn!
read函数会比较麻烦复杂一些,但write函数的使用就非常简单了:我们仅关心数据从用户进程输出到内核区域,就够了。
write函数的声明如下:
xxxxxxxxxx
212ssize_t write(int fd, const void *buf, size_t count);
形式参数:
fd
: 文件描述符,它标识了一个open函数打开的文件流对象。
buf
: 指向一个内存区域,该区域包含了要写入的数据。
count
: 要写入文件的字节数。返回值:
在成功时,
write
返回实际写入的字节数,基本上它就等于count
。如果发生错误,
write
返回-1
,并且通过errno
提供错误信息。一个演示的代码如下:
xxxxxxxxxx
111int main(int argc, char* argv[]){
2ARGS_CHECK(argc, 2);
3// 读写模式打开文件
4int fd = open(argv[1], O_RDWR);
5ERROR_CHECK(fd, -1, "open");
6// 将数据段中的字面值字符串写到文件中
7ssize_t sret = write(fd, "howareyou", 9);
8printf("sret = %ld\n", sret);
9close(fd);
10return 0;
11}
这段代码非常简单的演示了write函数的作用。
Rd!
总结read/write函数:
它们读写操作的第一个参数都是文件描述符,通过文件描述符间接和内核空间进行数据交互。
它们读写操作的第二个参数都是通用指针类型,但区别是:
read函数的第二个参数buf通用指针,没有用const修饰,这说明read函数要修改buf指针指向的数据区域。这符合read函数读操作的作用,它会把内核区域的数据读到用户进程的buf内存块中。
write函数的第二个参数buf通用指针,使用const进行修饰,这说明write函数不需要修改buf指针指向的数据区域。这也符合write函数写操作的作用,它会把当前用户进程中的buf内存块中的数据输出到内核区域中。
它们读写操作的第三个参数都是count,都是一个整数值,表示读写操作的字节数量。但区别是:
read函数指定了最大读取的字节数量是count,并通过返回值返回实际读到的字节数量。而这个返回值,完全可能小于count。
write函数指定了这一次写操作需要输出的字节数量是count,并通过返回值返回实际输出写的字节数量。而这个返回值,通常都是等于count的。
Gn!
文本文件:
文本文件由一系列的字符组成,这些字符要遵循特定的编码集(如ASCII或UTF-8等)
文本文件是人类肉眼可以看懂的文件
二进制文件:
二进制文件包含了一系列的字节,这些字节可以代表任何类型的数据,不限于文本字符。
二进制文件的内容不遵循任何字符编码集规则,可以包含任意的字节序列。因此,人类肉眼不能直接看懂。
除此之外,文本文件由于采用特殊的编码形式,所以:
同等内存大小的数据承载能力往往不如二进制文件。比如我想存一个整数"32767",如果以文本形式存储,那么需要5个字节。而如果使用二进制文件存储,仅需要2个字节即可。
文本文件的数据往往不能直接用于计算,而需要转换。以文本形式存储整数"32767"参与运算,是它的编码值参与运算,需要转换成整型后才是整数本身参与运算。
所以文本文件的目的是方便人类阅读和编辑,二进制文件则可以节省空间,并且转换效率高。
在实际使用read和write函数读写文本文件时:
我们一般会选择直接使用一个char数组,作为读写的媒介,此时操作的就是文本数据。
而如果操作的数据存在非字符串类型,比如int、double、结构体等其他类型,就是操作二进制数据,此时我们要遵循的原则是:
按什么类型写入,就按什么类型读出!
以下举了一些例子来证明上述的结论:
代码1:写文本数据到文件中
xxxxxxxxxx
121int main(int argc, char *argv[]){
2ARGS_CHECK(argc,2);
3// 以读写模式打开
4int fd = open(argv[1],O_RDWR);
5ERROR_CHECK(fd,-1,"open");
6
7// 写字符串数据
8char str[] = "123456";
9write(fd,str,strlen(str));
10close(fd);
11return 0;
12}
这个程序会把"123456"这个数组以字符串的形式写入文件,此时文件中存放的是该字符串的编码存储。可以用
xxd -p 文件名
这个指令展示此文件的十六进制编码,此时输出的结果是:313233343536 (即存储123456的ASCII码值)
并且该文件会占6个字节的内存空间。这种做法的好处是:直观,人肉眼可以直接读文件
但缺点也很明显:文件体积会偏大以及无法直接当成int类型使用,需要先转换一下。一个读文本文件中的文本数据,然后转换类型的代码示例如下:
xxxxxxxxxx
171int main(int argc, char *argv[]){
2ARGS_CHECK(argc, 2);
3int fd = open(argv[1], O_RDWR);
4ERROR_CHECK(fd, -1, "open");
5char buf[1024] = {0};
6int count = read(fd, buf, sizeof(buf)-1); // 确保将buf数组读成一个字符串
7ERROR_CHECK(count, -1, "read");
8
9puts(buf);
10// string -> int
11int num;
12sscanf(buf, "%d", &num);
13printf("num = %d\n", num);
14
15close(fd);
16return 0;
17}
下面我们再看用二进制的方式来尝试写同样的数据到文件中,也就是直接写一个
int num = 123456
到文件中,直接把二进制数据写到文件中,这个操作的参考代码如下:xxxxxxxxxx
121int main(int argc, char *argv[]){
2ARGS_CHECK(argc,2);
3int fd = open(argv[1],O_RDWR);
4ERROR_CHECK(fd,-1,"open");
5
6// 直接将int的二进制数据写入文件
7int i = 123456;
8int ret =write(fd, &i, sizeof(i));
9ERROR_CHECK(ret, -1, "write");
10close(fd);
11return 0;
12}
输出的文件,若直接用
cat
指令打印结果是:@7gd
显然人肉眼是看不懂的,但也有好处:文件体积会更小且读到内存中可以直接当成int使用,不需要转换。
当然你仍然可以用
xxd -p 文件名
这个指令展示此文件的十六进制编码,此时输出的结果是:40e20100
思考一下为什么是这样的呢?(考虑小端存储法)
所以这种写数据的方式,就是直接将内存中的数据写到外存的文件里。
最后这种二进制数据若想从新读取,那就需要重新read,遵循:按什么类型写入,就按什么类型读出!
参考代码如下:
xxxxxxxxxx
141int main(int argc, char *argv[]){
2ARGS_CHECK(argc,2);
3int fd = open(argv[1],O_RDWR);
4ERROR_CHECK(fd,-1,"open");
5
6int i;
7// 用int写,就用int读
8int ret = read(fd, &i, sizeof(i));
9ERROR_CHECK(ret, -1, "read");
10// 可以直接把数据读出来当成int使用,效率更高
11printf("num + 100 = %d\n", (i + 100));
12close(fd);
13return 0;
14}
那么实际的场景中,有哪些场景需要用二进制文件,哪些场景需要用文本文件呢?
比如日志文件,需要直接进行阅读,那当然用文本文件。
如果是应用程序的配置信息文件,游戏的存档文件等这些不需要也不应该让用户直接读内容的文件,则可以考虑用二进制文件。
总之,工作中可以灵活使用。
Gn!
首先我们还是思考一下利用read和write函数实现文件复制的模型。如下图所示:
在这个过程中,用户空间里使用一个buf数组来作为中转站,整体的实现还是比较简单。
Gn!
如果文件比较小,比如小于1024个字节,那我们可以写一个程序来一次性把它全部复制到目标文件。
参考代码如下:
xxxxxxxxxx
231int main(int argc, char *argv[]){
2ARGS_CHECK(argc,3);
3int fdr = open(argv[1],O_RDONLY);
4ERROR_CHECK(fdr, -1, "open read");
5// 打印src文件对象的描述符,一般是3
6printf("%d\n", fdr);
7int fdw = open(argv[2],O_WRONLY|O_CREAT|O_TRUNC,0666);
8ERROR_CHECK(fdw, -1, "open write");
9// 打印dest文件对象的描述符,一般是4
10printf("%d\n", fdw);
11
12// 使用char数组的原因是方便控制内存大小,因为char元素固定1个字节。
13// 注意不要以为这里是复制文本文件,任何文件的数据都是字节书库
14char buf[1024] = {0};
15// 假设文件小于1024字节,一次性读写完成
16ssize_t sret = read(fdr, buf, sizeof(buf));
17// 读多少字节写多少字节,不要写多了。这里最好就写sret个字节
18write(fdw, buf, sret);
19
20close(fdr);
21close(fdw);
22return 0;
23}
这个实现还是非常简单的。
尤其要注意,write的时候要使用read的返回值,真正读到了多少个字节,就write多少个字节,否则有可能会出现在文件末尾多写数据的问题。
Gn!
如果一个文件比较大,一个非常自然的想法就是用循环的方式去完成复制。
xxxxxxxxxx
2812int main(int argc, char *argv[]){
3ARGS_CHECK(argc,3);
4int fdr = open(argv[1],O_RDONLY);
5ERROR_CHECK(fdr, -1, "open read");
6int fdw = open(argv[2],O_WRONLY|O_CREAT|O_TRUNC,0666);
7ERROR_CHECK(fdw, -1, "open write");
8
9char buf[BUFFER_SIZE] = {0}; // 使用char数组的原因是方便控制内存大小
10// while(1){
11// memset(buf,0,sizeof(buf));
12// ssize_t sret = read(fdr,buf,sizeof(buf));
13// if(sret == 0){
14// break;
15// }
16// write(fdw,buf,sret);
17// }
18// 上面代码可以写成下面的while循环
19ssize_t read_count;
20// 注意这里的括号,不要多,也不要少
21while ((read_count = read(fdr, buf, sizeof(buf))) > 0) {
22// 如果read成功,但是返回的sret小于等于0,那么就没有必要继续了
23write(fdw, buf, read_count);
24}
25close(fdr);
26close(fdw);
27return 0;
28}
总的来说,这段代码和我们以往实现的文件流文件复制,从代码上来看没有什么本质上的区别,模式是差不多的。
Gn!
文件复制的操作,我们早在文件系统编程的第一天就讲解过了,当时我们使用的是文件流——fopen、fread、fwrite。
那么我们不由得就想到一个问题:
无缓冲文件流和文件流在实现文件复制时,它们的性能孰优孰劣呢?
实际上,在相当多的情况下,使用无缓冲文件流的效率还赶不上普通有用户缓冲区的文件流。
当你选择使用无缓冲文件流时,文件复制的效率主要看buf数组的大小,如果该数组小或者很小,那么效率是非常差的:
xxxxxxxxxx
11char buf[100] = {0}; // buf如果是100甚至更小时,文件复制的效率非常低
这是什么原因呢?
当buf数组很小的时候, 用户空间的buf数组需要频繁和内核区域进行数据交互,而每一次交互都是巨大的性能损耗,因为这个过程是系统调用。
系统调用涉及到用户进程的状态切换:"用户态" --> "内核态" --> "用户态",这个过程涉及到保存用户态的上下文、加载内核态的上下文、执行内核代码,然后再切换回用户态,这个过程性能损耗极大。
尤其是"用户态 --> 内核态"的切换,在系统调用中,这个过程被称之为"陷入(trap)",占据系统调用性能损耗的大部分。
总之如果使用的buf数组比较小,就会导致系统调用次数增多,进而导致进程频繁切换用户态和内核态,应用整体的性能就会骤降。
相比之下,使用带有用户空间缓冲的文件流(如通过
fopen
、fread
和fwrite
使用的标准 I/O)可以减少系统调用的次数。比如:
当你向文件写数据时,实际上是先把数据从进程内存写到文件输出流缓冲区中
而只有当缓冲区填满,或者用户主动刷新缓冲区时,缓冲区才和内核区域交互,进行系统调用
这样的一番操作就减少了系统调用的次数。
带缓冲区的文件流实际上是一种"防呆"机制,照顾那些编程经验不丰富、思考不深入的程序员,让这些程序员在多数场景下都能获得一个较好的性能。所以如果比平均性能的话,往往带缓冲区的文件流会更好一些。
那么什么时候
read/write
的效率会比fread/fwrite
更高呢?或者说哪些场景下用会read/write
更好一些呢?主要有以下场景:
实时性要求高的场景:
read()
和write()
提供了更为直接的磁盘访问,能够让你更精确地控制数据的读写时机。在需要即时处理数据的实时系统中,使用带缓冲的读写显然是不合适的。系统编程:Linux系统编程中有很多操作都只支持read、write,这些场景中我们必须用read和write。比如:读写某些设备文件,管道的进程间通信,网络通信等操作,很多时候只能使用无缓冲文件IO。
相对应的:
一般的,普通的文件操作
需要跨平台的文件操作
处理文本文件的操作,可以使用fgets/fputs这样读写一整行的函数,符合人类习惯。
这些场景下,还是
fread/fwrite
会更好用一些。附注:获取当前时间戳毫秒值的函数:
xxxxxxxxxx
8123
4long long current_time_millis(void) {
5struct timespec ts;
6clock_gettime(CLOCK_REALTIME, &ts); // 获取当前时间
7return (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000; // 转换为毫秒
8}
你可以将这个函数分别在文件复制的首尾两端调用,最后相减,从而大致得出文件复制所需要的时间(毫秒值)。
Gn!
ftruncate
是一个文件相关的系统调用函数,用于改变已打开文件的长度,这个函数允许你增大或减小文件的大小:
如果文件大小增加,新增的部分将会被填充为零字节。
如果文件大小减小,超出新长度部分的数据将会被丢弃,也就是会从文件的末尾开始进行截断。
ftruncate函数的声明如下:
xxxxxxxxxx
212int ftruncate(int fd, off_t length);
形参列表:
fd
:文件描述符,它是通过打开文件的系统调用返回的。
length
:是想要改变到的新的文件大小,单位是字节。返回值:
成功时,
ftruncate
返回 0。失败时,返回 -1 并设置错误码
errno
来指示错误的类型。
ftruncate
函数常见的使用场景如下:
减小文件大小:如果你想要删除文件的一部分内容,可以通过
ftruncate
将文件截断到指定的长度,超出的部分将会被去除。增大文件大小:如果你需要预留文件空间或者为文件追加固定长度的空白部分,可以使用
ftruncate
增加文件的大小。新增的部分会用零字节填充。一个参考代码举例:
xxxxxxxxxx
111int main(int argc, char *argv[]){
2ARGS_CHECK(argc,2);
3int fd = open(argv[1],O_RDWR);
4ERROR_CHECK(fd,-1,"open");
5
6// 改变文件的大小
7int ret = ftruncate(fd,100);
8ERROR_CHECK(ret,-1,"ftruncate");
9close(fd);
10return 0;
11}
注意,一旦截断减小文件大小,丢掉的数据是不能恢复的。
stat指令
stat
是一个shell指令,它用于显示文件的详细信息,比如文件类型、文件大小、权限、所有权、最后访问时间、最后修改时间等。比较需要关注的数据项有:
Size: 文件的实际大小,以字节为单位。
Blocks: 文件所占用的512字节磁盘块的数量,这个数值的大小代表了文件实际占用磁盘空间的大小。
在stat指令当中,块作为单位存在时,默认指的是512个字节。通过stat指令,我们发现磁盘最少要分配8个块给一个文件,也就是说Linux系统下一个文件实际占用磁盘空间的大小是以4096为单位的(固态硬盘的最小块是4096字节)。
比如:
一个文件即便本身只有100个字节,但磁盘还是会给它分配8个块,即占用磁盘空间4096个字节
一个文件本身的大小是20000个字节,那么磁盘就会给它分配40个块,即占用磁盘空间20480个字节
总之一般而言,一个文件所占磁盘空间的大小要比文件的实际大小要大。
但是,当我们使用ftruncate函数给一个文件分配特别大的字节数时,会发现文件本身的大小竟然会大于分配的磁盘空间大小,相当于反过来了。
这就意味着,文件已经占用了文件系统当中的空间,已经在文件系统中标记了这一整块区域,但是底层磁盘还没有为其分配真正的磁盘数据块,这种现象就是文件空洞。
文件空洞的主要作用是用来快速的占位,因为文件系统分配速度很快,而磁盘真正分配数据则很慢。
一个经典的场景就是下载,在下载任务开始前,可以通过
ftruncate
函数预先为文件分配一个特定大小的空间。这样做的好处是可以确保有足够的磁盘空间来存储即将下载的数据,避免在下载过程中因为磁盘空间不足而失败。总之ftruncate函数,基于文件空洞的原理,实现了高效地预留文件空间,这在很多场景下都很有用。