Linux-应用编程-学习笔记(16):文件IO

Linux-应用编程-学习笔记(16):文件IO

前言:基于LINUX做应用编程,其实就是通过调用LINUX的系统API来实现需要完成的任务。通过LINUX对文件进行操作的API称为文件IO(input和output),也就是读写文件。

一、文件操作的主要API接口

API是一些函数,这些函数是由linux系统提供支持的,由应用层程序来使用。应用层程序通过调用API来调用操作系统中的各种功能,来干活。

1. 基本概念

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

联系实际:
1、当我们打开一个较大的文件时会比较慢:这是因为当open这个文件时,内核会申请内存并将这个大文件(静态文件)读取到内存中,读取的这段时间就是卡的原因。
2、当我们编辑完一个文件没有保存直接退出后,下次打开时会发现修改的部分不存在:这是因为没有用修改后的动态文件去同步静态文件,下次打开的静态文件还是修改前的那个。

为什么要弄一个静态文件和动态文件,不直接去读取静态文件?
答:因为块设备本身有读写限制,本身对块设备进行操作非常不灵活,而且频繁的读写块设备会造成设备损坏。而内存可以按字节为单位来操作,而且可以随机操作(内存就叫RAM,random),很灵活。所以内核设计文件操作时就这么设计了。

文件描述符
文件描述符其实实质是一个数字,这个数字本质上是进程表中文件描述符表的一个表项,进程通过文件描述符作为index去索引查表得到文件表指针,再间接访问得到这个文件对应的文件表。
当我们open打开一个文件时,操作系统在内存中构建了一些数据结构来表示这个动态文件,然后返回给应用程序一个数字作为文件描述符,这个数字就和我们内存中维护这个动态文件的这些数据结构挂钩绑定上了,以后我们应用程序如果要操作这一个动态文件,只需要用这个文件描述符进行区分
简单来说:在linux系统中要操作一个文件,一般是先open打开一个文件,得到一个文件描述符,然后对文件进行读写操作(或其他操作),最后close关闭文件即可。文件描述符就是用来区分一个程序打开的多个文件,文件描述符的作用域就是当前进程,出了当前进程这个文件描述符就没有意义了。

linux中的文件描述符fd的合法范围是0或者一个正正数,不可能是一个负数。因此我们可以通过判断文件描述符的大小是否小于零来判断文件是否成功打开。
文件描述符这个数字是open系统调用内部由操作系统自动分配的,操作系统规定,fd从0开始依次增加。fd也是有最大限制的,在linux的早期版本中(0.11)fd最大是20,所以当时一个进程最多允许打开20个文件。linux中文件描述符表其实就是一个数组,fd是index,文件表指针是value。
当我们去open时,内核会从文件描述符表中挑选一个最小的未被使用的数字给我们返回。也就是说如果之前fd已经占满了0-9,那么我们下次open得到的一定是10.(但是如果上一个fd得到的是9,下一个不一定是10,这是因为可能前面更小的一个fd已经被close释放掉了,比如5被释放掉了,那下一次就是5)。

fd中0、1、2已经默认被系统占用了,因此用户进程得到的最小的fd就是3了。这三个文件对应的fd就是0、1、2。这三个文件分别叫stdin、stdout、stderr。也就是标准输入、标准输出、标准错误。
标准输入一般对应的是键盘(可以理解为:0这个fd对应的是键盘的设备文件),标准输出一般是LCD显示器(可以理解为:1对应LCD的设备文件)。printf函数其实就是默认输出到标准输出stdout上了。stdio中还有一个函数叫fpirntf,这个函数就可以指定输出到哪个文件描述符中。

2. linux常用文件IO接口

文件IO就指的是我们当前在讲的open、close、write、read等API函数构成的一套用来读写文件的体系,这套体系可以很好的完成文件读写,但是效率并不是最高的。

(1)open函数
该函数的作用是用来打开一个文件,并为这个文件在内存中构建一些数据结构来表示这个动态文件,同时给应用程序返回一个数字作为文件描述符。
使用这些API时,可以查阅man手册man 1 xx查linux shell命令,man 2 xxx查API, man 3 xxx查库函数

//头文件包含
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
//代码用法
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
//使用举例
fd = open("a.txt", O_RDWR | O_APPEND | O_TRUNC, 0666);
if (-1 == fd)		// 有时候也写成: (fd < 0)
{
	printf("文件打开错误\n");
	// return -1;
	_exit(-1);
}
else
{
	printf("文件打开成功,fd = %d.\n", fd);
}

返回值:打开的该文件的文件描述符fd
pathname:为待打开的文件路径和文件名
flags权限说明:O_RDONLY(只读), O_WRONLY(只写), O_RDWR(可读可写)
在flag还可以通过位或的方式加上其他功能。(默认什么模式都不写的情况下,原来的内容保持不变)
O_TRUNC(如果这个文件中本来是有内容的,则原来的内容会被丢弃)
O_APPEND(如果这个文件中本来是有内容的,则新写入的内容会接续到原来内容的后面)
注:当O_APPEND和O_TRUNC一起使用是,O_TRUNC起作用。
O_CREAT(open中加入O_CREAT后,不管原来这个文件存在与否都能打开成功,如果原来这个文件不存在则创建一个空的新文件,如果原来这个文件存在则会重新创建这个文件,原来的内容会被消除掉)
O_EXCL(当与O_CREAT一起用时,则没有文件时创建文件,有这个文件时会报错提醒我们)
O_NONBLOCK(以非阻塞的方式打开文件)
注:如果一个函数是阻塞式的,则我们调用这个函数时当前进程有可能被卡住(阻塞住,实质是这个函数内部要完成的事情条件不具备,当前没法做,要等待条件成熟),函数被阻塞住了就不能立刻返回;如果一个函数是非阻塞式的那么我们调用这个函数后一定会立即返回,但是函数有没有完成任务不一定。
O_SYNC(有时候我们希望硬件不要等待,直接将我们的内容写入硬盘中,这时候就可以用O_SYNC标志)
mode文件权限标志:在使用O_CREAT标志去创建文件时,可以使用第三个参数mode来指定要创建的文件的权限。mode使用4个数字来指定权限的,其中后面三个很重要,对应我们要创建的这个文件的权限标志。譬如一般创建一个可读可写不可执行的文件就用0666

(2)write函数
该函数的作用是用来向一个文件写内容,通过open函数返回的文件描述符来索引待写入文件。

//头文件包含
#include <unistd.h>
//代码用法
ssize_t write(int fd, const void *buf, size_t count);
//使用举例
ret = write(fd, writebuf, strlen(writebuf));
if (ret < 0)
{
	//printf("write失败.\n");
	perror("write失败");
	_exit(-1);
}
else
{
	printf("write成功,写入了%d个字符\n", ret);
}

返回值:ssize_t类型是linux内核用typedef重定义的一个类型(其实就是int),返回值表示成功写入的字节数。
fd:表示要写入哪个文件,fd一般由前面的open返回得到。
buf:待写入的内容缓冲区。
count:写入的字节数。

(3)read函数
该函数的作用是读取一个文件中的内容,通过open函数返回的文件描述符来索引待读取文件。

//头文件包含
#include <unistd.h>
//代码用法
ssize_t read(int fd, void *buf, size_t count);
//使用举例
ret = write(fd, writebuf, strlen(writebuf));
ret = read(fd, buf, 5);
if (ret < 0)
{
	printf("read失败\n");
	_exit(-1);
}
else
{
	printf("实际读取了%d字节.\n", ret);
	printf("文件内容是:[%s].\n", buf);
}

返回值:ssize_t类型是linux内核用typedef重定义的一个类型(其实就是int),返回值表示成功读取的字节数。
fd:表示要读取哪个文件,fd一般由前面的open返回得到。
buf:是应用程序自己提供的一段内存缓冲区,用来存储读出的内容。
count:读取的字节数。

count和返回值的关系。count参数表示我们想要写或者读的字节数,返回值表示实际完成的要写或者读的字节数。实现的有可能等于想要读写的,也有可能小于(说明没完成任务)。
有时候我们写正式程序时,我们要读取或者写入的是一个很庞大的文件(譬如文件有2MB),我们不可能把count设置为210241024,而应该去把count设置为一个合适的数字(譬如2048、4096),然后通过多次读取来实现全部读完

(4)close函数
该函数的作用是配合open函数,一个是打开,这个表示关闭。通过close关闭文件之后,会用动态文件同步静态文件。

//头文件包含
#include <unistd.h>
//代码用法
int close(int fd);
//使用举例
close(fd);
3. errno与perror

errno就是error number,意思就是错误号码。linux系统中对各种常见错误做了个编号,当函数执行错误时,函数会返回一个特定的errno编号来告诉我们这个函数到底哪里错了
errno是由OS来维护的一个全局变量,任何OS内部函数都可以通过设置errno来告诉上层调用者究竟刚才发生了一个什么错误。
errno本身实质是一个int类型的数字,每个数字编号对应一种错误。当我们只看errno时只能得到一个错误编号数字(譬如-37),不适应于人看。
linux系统提供了一个函数perror(意思print error),perror函数内部会读取errno并且将这个不好认的数字直接给转成对应的错误信息字符串,然后print打印出来。

if (-1 == fd)		// 有时候也写成: (fd < 0)
{
	//printf("\n");
	perror("文件打开错误");
	// return -1;
	_exit(-1);
}
else
{
	printf("文件打开成功,fd = %d.\n", fd);
}

在这里插入图片描述
此时如果文件存在,就会返回一个文件存在的errno,然后用perror进行解析,即可知道是哪里发生了错误。

4. 标准IO(C库函数)

应用层C语言库函数提供了一些用来做文件读写的函数列表,叫标准IO。标准IO由一系列的C库函数构成(fopen、fclose、fwrite、fread),这些标准IO函数其实是由文件IO封装而来的(fopen内部其实调用的还是open,fwrite内部还是通过write来完成文件写入的)。标准IO加了封装之后主要是为了在应用层添加一个缓冲机制,这样我们通过fwrite写入的内容不是直接进入内核中的buf,而是先进入应用层标准IO库自己维护的buf中,然后标准IO库自己根据操作系统单次write的最佳count来选择好的时机来完成write到内核中的buf(内核中的buf再根据硬盘的特性来选择好的时机去最终写入硬盘中)。
C库函数(标准IO)比API(linux系统文件IO)还有一个优势就是:API在不同的操作系统之间是不能通用的,但是C库函数在不同操作系统中几乎是一样的。所以C库函数具有可移植性而API不具有可移植性。

文件IO返回的是文件描述符,而标准库返回的是一个文件类型的指针。

使用举例:

#include <stdio.h>		// standard input output
#include <stdlib.h>
#include <string.h>

#define FILENAME	"1.txt"

int main(void)
{
	FILE *fp = NULL;
	size_t len = -1;
	//int array[10] = {1, 2, 3, 4, 5};
	char buf[100] = {0};
	
	fp = fopen(FILENAME, "r+");
	if (NULL == fp)
	{
		perror("fopen");
		exit(-1);
	}
	printf("fopen success. fp = %p.\n", fp);
	
	// 在这里去读写文件
	memset(buf, 0, sizeof(buf));
	len = fread(buf, 1, 10, fp);
	printf("len = %d.\n", len);
	printf("buf is: [%s].\n", buf);

#if 0	
	fp = fopen(FILENAME, "w+");
	if (NULL == fp)
	{
		perror("fopen");
		exit(-1);
	}
	printf("fopen success. fp = %p.\n", fp);
	
	// 在这里去读写文件
	//len = fwrite("abcde", 1, 5, fp);
	//len = fwrite(array, sizeof(int), sizeof(array)/sizeof(array[0]), fp);
	len = fwrite(array, 4, 10, fp);
	printf("len = %d.\n", len);
#endif	
	
	fclose(fp);
	return 0;
}

二、linux管理文件的方法

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

文件平时都在存放在硬盘中的,硬盘中存储的文件以一种固定的形式存放的,我们叫静态文件
一块硬盘中可以分为两大区域:一个是硬盘内容管理表项,另一个是真正存储内容的区域。操作系统访问硬盘时是先去读取硬盘内容管理表,从中找到我们要访问的那个文件的扇区级别的信息,然后再用这个信息去查询真正存储内容的区域,最后得到我们要的文件。
操作系统最初拿到的信息是文件名,最终得到的是文件内容。第一步就是去查询硬盘内容管理表,这个管理表中以文件为单位记录了各个文件的各种信息,每一个文件有一个信息列表(我们叫inode,i节点,其实质是一个结构体,这个结构体有很多元素,每个元素记录了这个文件的一些信息,其中就包括文件名、文件在硬盘上对应的扇区号、块号那些东西·····)
强调:硬盘管理的时候是以文件为单位的,每个文件一个inode,每个inode有一个数字编号,对应一个结构体,结构体中记录了各种信息

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

一个程序的运行就是一个进程,我们在程序中打开的文件就属于某个进程。每个进程都有一个数据结构用来记录这个进程的所有信息(叫进程信息表),表中有一个指针会指向一个文件管理表,文件管理表中记录了当前进程打开的所有文件及其相关信息。文件管理表中用来索引各个打开的文件的index就是文件描述符fd,我们最终找到的就是一个已经被打开的文件的管理结构体vnode。
一个vnode中就记录了一个被打开的文件的各种信息,而且我们只要知道这个文件的fd,就可以很容易的找到这个文件的vnode进而对这个文件进行各种操作
在这里插入图片描述
引用自:https://blog.csdn.net/wwwlyj123321/article/details/100298377

3. 文件与流的概念

文件操作中,文件类似是一个大包裹,里面装了一堆字符,但是文件被读出/写入时都只能一个字符一个字符的进行,而不能一股脑儿的读写,那么一个文件中N多的个字符被挨个依次读出/写入时,这些字符就构成了一个字符流(stream)。
流这个概念是动态的,不是静态的。编程中提到流这个概念,一般都是IO相关的。所以经常叫IO流。文件操作时就构成了一个IO流。

文件指针
当我们要对一个文件进行读写时,一定需要先打开这个文件,所以我们读写的所有文件都是动态文件。动态文件在内存中的形态就是文件流的形式。
就好比word中,许多个字在一起就像是一条线,我们通过光标来调整我们要修改的位置。根据流的概念,我们可以知道文件中的内容也相当于一条线,因此访问这条线上的某个位置就用到了文件指针的概念。
所谓文件指针,就是我们文件管理表这个结构体里面的一个指针。所以文件指针其实是vnode中的一个元素。这个指针表示当前我们正在操作文件流的哪个位置。这个指针不能被直接访问,linux系统用lseek函数来访问这个文件指针。

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

lseek功能
(1)计算文件长度
用lseek将文件指针移动到末尾,然后返回值就是文件指针距离文件开头的偏移量,也就是文件的长度了。

ret = lseek(fd, 0, SEEK_END);	//文件指针移动到文件末尾
return ret;

(2)构建空洞文件
空洞文件就是这个文件中有一段是空的。空洞文件方法对多线程共同操作文件是及其有用的。有时候我们创建一个很大的文件,如果从头开始依次构建时间很长。有一种思路就是将文件分为多段,然后多线程来操作每个线程负责其中一段的写入。
创建空洞文件的思路:我们打开一个文件后,用lseek往后跳过一段,再write写入一段,就会构成一个空洞文件。跳过的那段不占用内存

ret = lseek(fd, 10, SEEK_SET); //将文件指针移动到10字节的位置处

三、文件其他操作

1. 多次打开同一文件与O_APPEND

(1)重复打开同一个文件读取
一个进程中两次打开同一个文件,然后分别读取时。也就是用两个open打开同一个文件并获得两个文件描述符fd1和fd2,分别读取文件。
结果证明:由于fd1和fd2是两个独立的描述符,即fd1和fd2所对应的文件指针是不同的2个独立的指针,所以读取的状态是分别读。文件指针是包含在动态文件的文件管理表中的,所以可以看出linux系统的进程中不同fd对应的是不同的独立的文件管理表。

(2)重复打开同一文件写入
与上面读的道理相同,因为fd1和fd2是两个独立的指针,所以写入的情况也是分别写,所以后写入的会把之前写入的冲掉。

(3)O_APPEND的实现原理和其原子操作性说明
O_APPEND为什么能够将分别写改为接续写?关键的核心的东西是文件指针。分别写的内部原理就是2个fd拥有不同的文件指针,并且彼此只考虑自己的位移。但是O_APPEND标志可以让write和read函数内部多做一件事情,就是移动自己的文件指针的同时也去把别人的文件指针同时移动。(也就是说即使加了O_APPEND,fd1和fd2还是各自拥有一个独立的文件指针,但是这两个文件指针关联起来了,一个动了会通知另一个跟着动)
O_APPEND对文件指针的影响,对文件的读写是原子的
原子操作的含义是:整个操作一旦开始是不会被打断的,必须直到操作结束其他代码才能得以调度运行,这就叫原子操作。每种操作系统中都有一些机制来实现原子操作,以保证那些需要原子操作的任务可以运行。

2. 文件共享方法

文件共享就是同一个文件(同一个文件指的是同一个inode,同一个pathname)被多个独立的读写体(几乎可以理解为多个文件描述符)去同时(一个打开尚未关闭的同时另一个去操作)操作。
文件共享的意义有很多:譬如我们可以通过文件共享来实现多线程同时操作同一个大文件,以减少文件读写时间,提升效率。文件共享的核心就是怎么弄出来多个文件描述符指向同一个文件

三种实现文件共享的方式
(1)同一个进程中多次使用open打开同一个文件
在这里插入图片描述
(2)在不同进程中去分别使用open打开同一个文件
在这里插入图片描述
(3)linux系统提供了dup和dup2两个API来让进程复制文件描述符
在这里插入图片描述

3. 文件描述符的复制

(1)dup
dup系统调用对fd进行复制,会返回一个新的文件描述符(譬如原来的fd是3,返回的就是4)。

fd2 = dup(fd1);	//对文件描述符fd1进行复制,返回一个新的文件描述符fd2

dup自己不能指定复制后得到的fd的数字是多少,而是由操作系统内部自动分配的,分配的原则遵守fd分配的原则。
dup返回的fd和原来的oldfd都指向oldfd打开的那个动态文件,操作这两个fd实际操作的都是oldfd打开的那个文件。实际上构成了文件共享。
dup返回的fd和原来的oldfd同时向一个文件写入时,结果为接续写

这里有个技巧:我们可以通过close(1)关闭标准输出,然后使用dup进行文件描述符的复制,这样系统会默认分配最小的(刚刚关闭的1),这时候就把oldfd打开的这个文件和我们1这个标准输出通道给绑定起来了。这就叫标准输出的重定位,因此通过标准输出的内容都会被保存在oldfd打开的那个文件中。

(2)dup2
dup2和dup的作用是一样的,都是复制一个新的文件描述符。但是dup2允许用户指定新的文件描述符的数字。

fd2 = dup2(fd1, 16);	//对fd1进行复制,得到fd2并指定fd2的值为16

同样fd1和fd2交叉写入的方式为接续写

注:在命令行中,通常可以通过一个快捷方式来实现标准输出的重定位:xxx > 1.txt。这个>的实现原理,其实就是利用open+close+dup,open打开一个文件1.txt,然后close关闭stdout,然后dup将1和1.txt文件关联起来即可。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值