在上一篇文章Linux文件操作之标准I/O简单讲了文件操作标准I/O的一些基本知识,这里我们继续来看LInux下文件操作的又一利器:文件I/O
Linux中的文件主要可以分为6类:
和标准I/O不同的是,文件I/O是不带缓冲的。
不带缓冲直达的read和write都调用系统中相应的系统调用。不带缓冲的I/O函数不是ANSI C的组成部分,但是是POSIX和XPG3的组成部分。
Linux的文件系统有两层结构构成:第一层是虚拟文件系统(VFS),第二种是各种不同的具体的文件系统。
VFS就是把各种具体的文件系统的公共部分抽取出来,形成一个抽象层,是系统内核的一部分。它位于·用户程序·和·文件系统·之间。VFS对用户提供标准的文件系统调用接口,对具体的文件系统(如:ext2/FAT32等,可用cat /proc/filesystems指令查看),它通过一系列的对不同文件系统的通用的函数指针来调用对应的文件系统函数,完成相应的操作。
任何文件系统的程序必须通过这层(VFS)接口来访问具体的文件。通过这样的方式,VFS就对用户屏蔽了底层文件系统实现细节和差异。
VFS不仅可以对具体的文件系统的数据结构进行抽象,以一种统一的数据接口进行管理,还可以接受用户层的系统调用,如open()、read()、write()、stat()、link()等。此外它还支持多种具体的文件系统之间的相互访问,接受内核其他子系统的操作请求,例如,内存管理
和进程调度
。
通过下图,我们可以看到VFS在linux系统的作用和各部分的关系:
那么内核是如何区分和引用特定的文件呢?答案是文件描述符(file description)。对于Linux而言,所有设备和文件的操作都是通过文件描述符(fd)进行的。
文件描述符是一个非负的整数,它是一个索引值,并指向在内核中打开文件的记录表。当打开一个现存的文件或创建一个新文件时,内核就向进程返回一个文件描述符;读写文件时,需要把文件描述符作为参数传递给相应的函数。
基于文件描述符的I/O操作虽然不能直接移植到类Linux以外的系统上去(如Windows),但它往往是实现某些I/O操作唯一的途径。比如,Linux中低层文件操作函数、多路I/O、TCP/IP套接字编程接口等。
接下来我们来总结一下,标准I/O和文件I/O的区别:
文件I/O又称为低级磁盘I/O,遵循POSIX相关标准。任何兼容POSIX标准的操作系统上都支持文件I/O。
标准I/O又被称为高级磁盘I/O,遵循ANSI C相关标准。只要开发环境中有标准C库,标准I/O 就可以使用。
Linux中的glibc,它是标准C库的超集,它不仅包含ANSI C中定义的函数,还包括POSIX标准中定义的函数。因此,Linux下既可以使用标准I/O,也可以使用文件I/O。
通过文件I/O读写文件时,每次操作都会执行相关系统调用。这样处理的好处是直接读写实际文件,坏处是频繁的系统调用会增加系统开销。标准I/O可以看作在文件I/O的基础上封装了缓冲机制。先读写缓冲区,必要时再访问实际文件,从而减少了系统调用次数,减少了系统开销。
文件I/O中用文件描述符表示一个打开的文件,可以访问不同类型的文件如普通文件、设备文件和管道文件等。而标准I/O中用**FILE(流)**表示一个打开的文件,通常只能用来访问普通文件。
open()
函数用于创建或打开文件,在打开或创建文件时可以指定问文件打开方式及文件的访问权限。
open()
返回的文件描述符一定是最小的未被使用的描述符数字。
open()
可以打开设备文件(注意和普通文件区分,如字符设备文件,块设备文件,这里又一次验证了LInux下一切皆文件的哲学思想),但是不能创建设备文件,创建设备文件使用mkmod()
。
所需头文件
#include
#include
函数原型
int open(const char *pathname, int flags,int perms);
参数
pathname : 被打开的文件名(可包括路径名)
flags: 文件打开的方式
O_RDONLY: 以只读的方式打开文件
O_WRONLY: 以只写的方式打开文件
O_RDWR: 以读写方式打开文件
O_CREAT: 如果该文件不存在,就创建一个新文件,并用第三个参数为其设置权限
O_EXCL: 如果使用O_CREAT时文件存在,则可返回错误消息。这一参数可测试文件是否已存在。
O_NOCTTY: 使用本参数时,若打开的是终端文件,那么该终端不会为当前进程的控制终端。
O_TRUNC: 若文件已存在,那么会删除文件的全部原有数据,并且设置问文件大小为0
O_APPEND: 以添加的方式打开文件,在写文件时,文件读写位置自动指向文件的末尾,即将写入的数据添加到文件的末尾
perms : 新建文件的存取权限
可以用一组宏定义:S_I(R/W/X)(USR/GRP/OTH)
其中:R/W/X分别表示读、写、执行权限
USR/GRP/OTH分别表示分别表示文件所有者/文件所属组/其他用户
函数返回值
成功:返回文件描述符
失败:-1
注意:在open()函数中,flags参数可通过“|”组合构成。perm可以用宏定义表示也可以用八进制表示。
所需头文件
#include
函数原型
int close(int fd);
函数输出值
fd: 文件描述符
函数返回值
成功 : 0
失败或出错 : -1
- 当一个进程终止时,该进程打开的所有文件由内核自动关闭
- 关闭一个文件的同时也释放该进程加在该文件上的所有记录锁
read()函数从文件中读取数据存放到缓冲区中,并返回实际读取的字节数。若返回0,则表示没有数据可读,即已达到文件末尾。返回-1,表示出错。通过errno设置错误码。
读操作从文件的当前读写位置开始读取内容,当前读写位置会自动后移。在成功返回之前该位移量增加成功读取的字节数。
所需头文件
#include
函数原型
ssize_t read(int fd, void *buf, size_t count);
参数
fd: 文件描述符
buf: 指定存储器读出数据的缓冲区
count: 指定读出的字节数
函数返回值
成功: 读到的字节数
0: 已到达文件尾
-1: 出错
在读普通文件时,若读到要求的字节数之前已到达问文件的末尾,则返回的字节数会小于指定读出的字节数。
所需头文件
#include
函数原型
ssize_t write(int fd, void *buf, size_t count);
参数
fd : 文件描述符
buf: 指定存储器写出数据的缓冲区
count: 指定写入的字节数
函数返回值
成功: 已写的字节数
-1: 出错
- write返回值通常与count不同,因此需要循环将全部代写的数据全部写入指定文件
- write出错的常见原因:磁盘已满或者超出了给定进程的文件长度限制
- 对于普通文件,写操作从文件的当前位移量开始,如果打开文件时指定了O_APPEND,那么会在文件的末尾开始写。文件实际的位移量为文件实际写入的字节数。
现在我们使用read()函数来举一个实际使用的例子方便我们的理解。
getchar()是头文件stdio.h中定义的一个宏,下面我们使用read()来对其进行重写。
版本1:从标准输入(文件描述符为0)中读取一个字符来实现无缓冲输入
int getchar(void)
{
char c;
return (read(0, &c, 1) ? (unsigned char) c : EOF;
/*这里使用unsigned char类型对c进行强转,是为了消除符号扩展的问题*/
}
版本2:每次读入一组字符,但是每次只输出一个字符
#define BUF_SZ 128
int getchar(void)
{
static char buf[BUF_SZ];
static char *bufp = but;
static int n = 0;
if(n == 0) /* 如果缓冲区为空 */
{
n = read(0, buf, sizeof(buf));
}
return (--n >= 0) ? (unsigned char) *bufp++ : EOF;
}
试想一下,第二种是不是效率更高,因为它减少了系统调用。一次读取多个字节的数据,每次调用它就返回一个。但是弊端也是有的,实时性会变差。之前的文章标准IO里面提到的相关IO操作接口就是利用了类似的原理减少了系统调用,即增加了缓冲区。
lseek()函数对文件当前读写位置进行定位。它只能对可定位(可随机访问)文件操作。
管道、套接字和大部分字符设备不支持此类操作
。
所需头文件
#include
#include
函数原型
off_t lseek(int fd, off_t offset , int whence);
参数
fd:文件描述符
offse:相对于基准点whence的偏移量。以字节为单位,正数表示向前移动,负数表示向后移动
whence:当前位置的基点
SEEK_SET:文件的起始位置
SEEK_CUR:文件当前读写位置
SEEK_END:文件结束位置
函数返回值
成功:文件当前读写位置
-1:出错
- 没打开一个文件,都有一个与之相关的“当前文件位移量”,它是一个非负整数,用于度量文件从开始处计算的字节数。
- 通常,读写操作都从当前文件位移量处开始,在读写调用成功后,是位移量增加所读写的字节数。
- 文件位移量可以大于当前文件的长度,这种情况下对该文件的写操作会延长文件,并形成空洞。
很多时候我们是在多进程的环境下打开文件的,那么当多个进程同时操作一个文件的时候,我们该如何解决对共享资源的竞争问题呢?答案是给文件上锁。
文件锁包含建议锁和强制锁。
建议锁
要求每个进程在访问文件之前检查是否有锁存在,并尊重已有的锁。但是在我们平时的开发环境中你不能保证每个程序员都这么有素质,访问文件前都查询锁。所以一般情况下,不建议使用建议锁。一般用接口lockf()
来实现,也可以用fcntl()
。
强制锁
由内核执行的锁,当一个文件被上锁,进行写入操作的时候,内核将阻止其他任何程序对该文件进行读写操作。采用强制性锁对性能的影响较大,每次读写都需要操作内核检查是否有锁存在。使用fcntl()接口来实现。
可以看到给文件上锁的函数有两个:lockf()
和fcntl()
,lockf()只能用于建议性锁,而fcntl()既可以加建议性锁也可以加强制锁。同时,fcntl()还能对文件的某一记录上锁,也就是记录锁。
记录锁有可分为读记录锁和写记录锁,其中读取锁又称为共享锁,多个同时执行的程序允许在文件的同一部分建立读取锁。而写入锁又称为排斥锁,在任何时刻只能有一个程序在文件的某个部分上建立写入锁。显然,文件的同一部分不能同时建立读取锁和写入锁。
fcntl()
接口功能比较丰富,它可以对一打开的文件进行各种操作。不仅能够管理文件锁,还可以获取和设置文件相关标识以及复制文件描述符等。
fcntl()
所需头文件
#include
#include
#include
函数原型
int fcntl(int fd, int cmd, ...);
参数
fd:文件描述符
cmd
F_GETLK:检测文件锁状态
F_SETLK:设置lock描述的文件锁
F_SETLKW:这是F_SETLK的阻塞版本(W表示等待)在无法获取锁时,会进入睡眠状态;
如果可以获取锁或者捕获到信号就会返回
函数返回值
成功:文件当前读写位置
-1:出错
这里只介绍了fcntl()的部分特性,并没有做进一步的探讨。限于篇幅,后续再对文件锁和文件相关的操作做进一步的探讨。