Linux 文件I/O(open,close,read,write,lseek,fcntl)



UNIX/Linux 的一个基本哲学是 一切皆文件 。不仅普通的文件,甚至连各种 字符设备、块设备、套接字 等都被当成文件对待,尽管它们的类型差异很大,但 UNIX/Linux 为它们提供的操作界面却是相同的。


1、Linux 的文件 I/O

Linux 把大部分系统资源当作文件并呈现给用户,用户只需按照文件 I/O 的方式,就能完成数据的输入输出。Linux 文件按其代表的具体对象,可大致分类为:

序号第一个字符表示含义描述
1-普通文件一般意义上的文件、磁盘文件
2b块设备-
3c字符设备-
4d目录-
5l链接文件-
6p命名管道一种特殊文件,常用于进程间通信
7sSocket 文件主要用在网络通信方面

文件 I/O 的常用操作方法有“打开”、“关闭”、“读”和“写”等。只要是文件,都可以用这套方法操作。系统提供了文件 I/O 的应用程序接口(API),以函数的形式提供给应用程序调用。
打开文件 对应的函数是 open(),读文件对应的函数是 read(),写文件对应的函数是 write(),关闭文件对应的函数是 close(),这些是文件常用函数。

Linux 系统提供的文件 I/O 接口函数,是以最基本的系统服务形式提供的,又称它们为基本 I/O 函数。这些函数有个共同的特点:它们都通过 文件描述符(file descriptor)来完成对指定文件的 I/O 操作。

2、文件描述符 fd(file descriptor)

文件描述符 fd(file descriptor)是进程中用来表示某个文件的整数,有的文献资料中又称它为 文件句柄(file handle)。

文件描述符的作用,类似于在生活中排队取的号牌,业务员(进程)通过叫号(引用 文件描述符)就能找到来办事的人(打开的文件)。

有效的文件描述符 取值范围 从 0 开始,直到系统定义的某个极限值。这些指定范围的整数,实际上是 进程文件描述符表 的索引。文件描述符表 是进程用来保存它所打开的文件信息的、由操作系统维护的一个登记表,用户程序不能直接访问该表。

文件描述符 的 取值范围,反映了 文件描述符表 的大小,表示这个进程最多可以同时打开多少个文件。在大多数 Linux系统中,可通过命令ulimit -n查询到这个数值的大小,如图:
在这里插入图片描述
对于内核而言,进程所打开的文件都由 文件描述符 引用。当进程 打开一个现存文件 或 创建一个新文件时,内核返回 一个文件描述符 给进程。当读、写一个文件时,先调用 open() 或 creat() 函数取得代表该文件的 文件描述符 fd,然后将 fd 作为参数传递给 read() 或 write() 等 文件操作函数。

通常情况下,文件描述符 0、1、2 在进程启动时已被占用,代表进程在启动过程中打开的文件。文件描述符 0、1、2 在桌面系统与嵌入式系统上,通常代表的文件如表:

序号文件描述符含义桌面 / 服务器 Linux嵌入式 Linux
1STDIN_FILENO 0标准输入(stdin)键盘串口终端
2STDOUT_FILENO 1标准输出(stdout)终端屏幕串口终端
3STDERR_FILENO 2标准错误(stderr)终端屏幕串口终端

文件描述符文件指针 的区别:

序号分类描述
1文件描述符在Linux系统中,打开文件就会获得 文件描述符,它是个很小的正整数。
每个进程在 PCB(Process Control Block)中保存着一份 文件描述符表,文件描述符 就是这个表的索引,每个表项 都有一个 指向已打开文件 的指针
2文件指针C语言中,使用文件指针 做为I/O的句柄。文件指针 指向进程用户区中的一个被称为 FILE结构的数据结构。
FILE结构 包括一个缓冲区和一个文件描述符。而 文件描述符 是 文件描述符表 的一个索引,因此从某种意义上说 文件指针 就是 句柄的句柄(在Windows系统上,文件描述符 被称作 文件句柄)

3、常用文件 I/O 操作和函数

在 C 语言下进行文件 I/O 编程,一般要包含下表所列的头文件,这些头文件定义了文件 I/O 用到的数据类型函数原型及其它要用到的符号常量

序号头文件描述
1<sys/types.h>定义数据类型,如 ssize_toff_t
2<fcntl.h>定义 open()creat() 等函数原型,创建文件权限的符号常量 S_IRUSR 等
3<unistd.h>定义 read()write()close()lseek() 等函数原型
4<errno.h>与全局变量 errno 相关的定义
5<sys/ioctl.h>定义 ioctl() 函数原型

3.1 open 函数

open函数 可以打开或创建一个文件。
新打开文件返回文件描述符表中未使用的最小文件描述符。

#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);

返回值:成功返回新分配的文件描述符,出错返回-1并设置errno。

序号参数描述
1pathname要打开或创建的文件名,和 fopen 一样,pathname 既可以是 相对路径 也可以是 绝对路径
2flags有一系列常数值可供选择,可以同时选择多个常数用 按位或运算符|连接起来,所以这些常数的宏定义都以O_开头,表示or

flags 参数:
必选项:以下三个常数中必须指定一个,且仅允许指定一个。

序号常数描述
1O_RDONLY只读打开
2O_WRONLY只写打开
3O_RDWR可读可写打开

以下可选项可以同时指定 0个 或 多个,和必选项 按位或起来 作为 flags 参数。可选项有很多,这里只介绍一部分,其它选项可参考 open(2) 的 Man Page:

序号常数描述
1O_APPEND表示追加。如果文件已有内容,这次打开文件所写的数据附加到文件的末尾,而不覆盖原来的内容。
2O_CREAT若此文件不存在,则创建它。使用此选项时需要提供第三个参数mode,表示该文件的访问权限
3O_EXCL如果同时指定了 O_CREAT,并且文件已存在,则出错返回。
4O_TRUNC如果文件已存在,并且以只写或可读可写方式打开,则将其长度截断(Trun-cate)为0字节
5O_NONBLOCK对于设备文件,以O_NONBLOCK 方式打开可以做非阻塞I/O(Nonblock I/O)

3.2 close 函数

close() 函数 关闭一个已打开的文件:

#include <unistd.h>

int close(int fd);

返回值:成功返回 0,出错返回 -1,并设置errno 。

参数 fd 是要关闭的 文件描述符。需要说明的是,当一个进程终止时,内核 对该进程所有尚未关闭的 文件描述符 调用 close() 关闭,所以即使用户程序不调用 close(),在终止时,内核 也会自动关闭它打开的所有文件。但是对于一个长年累月运行的程序(比如 网络服务器),打开的 文件描述符 一定要记得关闭,否则随着打开的文件越来越多,会占用 大量文件描述符系统资源

由 open() 返回的 文件描述符 一定是该进程尚未使用的 最小描述符。由于程序启动时自动打开文件描述符 0、1、2,因此第一次调用 open() 打开文件通常会返回 描述符3,再调用 open() 就会返回4。可以利用这一点在 标准输入、标准输出或标准错误输出上 打开一个新文件,实现重定向的功能。例如,首先调用 close() 关闭文件描述符1,然后调用 open() 打开一个常规文件,则一定会返回 文件描述符1,这时候标准输出就不再是终端,而是一个常规文件了,再调用 printf() 就不会打印到屏幕上,而是写到这个文件中了。后面要讲的 dup2 函数 提供了另外一种办法在指定的文件描述符上打开文件。

3.3 最大打开文件个数

查看当前系统允许打开最大文件个数:

cat /proc/sys/fs/file-max

当前默认设置最大打开文件个数1024:

ulimit -a		#1024

修改默认设置最大打开文件个数为4096:

ulimit -n 4096

3.4 read/write 函数

read() 函数从打开的设备或文件中读取数据。

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

返回值:成功 返回 读取的字节数,出错 返回-1,并设置errno,如果在调 read() 之前已到达文件末尾,则这次 read() 返回 0。

参数 count 是请求读取的字节数,读上来的数据保存在缓冲区 buf 中,同时文件的当前读写位置向后移。注意这个读写位置和使用C标准I/O库时的读写位置有可能不同,这个读写位置是记在内核中的,而使用C标准 I/O 库时的读写位置是用户空间 I/O 缓冲区中的位置。比如用 fgetc 读一个字节,fgetc 有可能从内核中预读 1024 个字节到 I/O 缓冲区 中,再返回第一个字节,这时该文件在内核中记录的读写位置是1024,而在 FILE 结构体 中记录的读写位置是1。

注意,返回值类型是 ssize_t,表示 有符号的 size_t,这样既可以返回正的字节数 0(表示到达文件末尾)也可以返回负值-1(表示出错)。read() 函数返回时,返回值说明了 buf 中前多少个字节是刚读上来的。有些情况下,实际读到的字节数(返回值)会小于请求读的字节数 count。

例如:
(1)读常规文件时,在读到 count 个字节之前已到达文件末尾。例如,距文件末尾还有 30个字节 而请求读 100个字节,则 read() 返回30,下次 read() 将返回0。
(2)从终端设备读,通常以行为单位,读到换行符就返回了。
(3)从网络读,根据不同的传输层协议和内核缓存机制,返回值可能小于请求的字节数,后面 socket 编程部分会详细讲解。

write() 函数 向打开的设备或文件中写数据。

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

返回值:成功返回写入的字节数,出错返回-1并设置errno。
常规文件 时,write() 的返回值 通常等于请求写的字节数 count,而向 终端设备网络写,则不一定。

4、阻塞和非阻塞

常规文件是不会阻塞的,不管读多少字节,read() 一定会在有限的时间内返回。从终端设备网络读 则不一定。如果从终端输入的数据没有换行符,调用 read() 读终端设备就会阻塞,如果网络上没有接收到数据包,调用 read() 从网络读就会阻塞,至于会阻塞多长时间也是不确定的,如果一直没有数据到达就一直阻塞在那里。
同样,写常规文件是不会阻塞的,而向终端设备或网络写则不一定。

现在明确一下阻塞(Block)这个概念。
当进程调用一个阻塞的系统函数时,该进程被置于 睡眠(Sleep)状态,这时 内核调度其它进程运行,直到该进程等待的事件发生了(比如网络上接收到数据包,或者调用sleep指定的睡眠时间到了)它才有可能继续运行。

与 睡眠状态 相对的是 运行(Running)状态,在Linux内核中,处于 运行状态 的进程分为两种情况:
(1)正在被调度执行。CPU 处于该进程的上下文环境中,程序计数器(eip)里保存着该进程的指令地址,通用寄存器 里保存着该进程运算过程的中间结果,正在执行该进程的指令,正在读写该进程的地址空间。
(2)就绪状态(Ready)。该进程不需要等待什么事件发生,随时都可以执行,但 CPU 暂时还在执行另一个进程,所以该进程在一个 就绪队列 中等待被 内核调度

系统中可能同时有多个就绪的进程,那么该调度谁执行呢?
内核的调度算法(Schedule) 是基于优先级时间片的,而且会根据每个进程的运行情况动态调整它的优先级时间片,让每个进程都能比较公平地得到机会执行,同时要兼顾用户体验,不能让和用户交互的进程响应太慢。


4.1 阻塞 读终端

下面这个小程序从终端读数据 再写回终端

#include <unistd.h>
#include <stdlib.h>
int main(void)
{
	char buf[10];
	int n;
	
	n = read(STDIN_FILENO, buf, 10);
	if (n < 0) {
		perror("read STDIN_FILENO");
		exit(1);
	}
	write(STDOUT_FILENO, buf, n);
	return 0;
}

如果在 open() 一个设备时指定了 O_NONBLOCK 标志,read/write 就不会阻塞。以 read() 为例,如果设备暂时没有数据可读就返回-1,同时置errno为EWOULDBLOCK(或者EAGAIN,这两个宏定义的值相同),表示本来应该阻塞在这里(would block,虚拟语气),事实上并没有阻塞而是直接返回错误,调用者应该试着再读一次(again)。这种行为方式称为轮询(Poll),调用者只是查询一下,而不是阻塞在这里死等,这样可以同时监视多个设备:

while(1) {
	非阻塞read(设备1);
	if(设备1有数据到达)
	处理数据;
	
	非阻塞read(设备2);
	if(设备2有数据到达)
	处理数据;
	...
}

如果 read(设备1) 是阻塞的,那么只要设备1没有数据到达就会一直阻塞在设备1的 read 调用上,即使设备2有数据到达也不能处理,使用 非阻塞I/O 就可以避免设备2 得不到及时处理。

非阻塞 I/O 有一个缺点,如果所有设备都一直没有数据到达,调用者需要反复查询做无用功。如果阻塞在那里,操作系统可以调度别的进程执行,就不会做无用功了。在使用非阻塞I/O 时,通常不会在一个 while 循环中一直不停地查询(这称为 Tight Loop),而是每延迟等待一会儿来查询一下,以免做太多无用功,在延迟等待的时候可以调度其它进程执行。

while(1) {
	非阻塞read(设备1);
	if(设备1有数据到达)
	处理数据;
	
	非阻塞read(设备2);
	if(设备2有数据到达)
	处理数据;
	...
	sleep(n);
}

这样做的问题是,设备1有数据到达时可能不能及时处理,最长需延迟n秒才能处理,而且反复查询还是做了很多无用功。以后要学习的 select(2) 函数可以阻塞地同时监视多个设备,还可以设定阻塞等待的超时时间,从而圆满地解决了这个问题。

4.2 非阻塞 读终端

以下是一个非阻塞I/O的例子。目前学过的可能引起阻塞的设备只有终端,所以用终端来做这个实验。程序开始执行时在0、1、2文件描述符上自动打开的文件就是终端,但是没有O_NONBLOCK 标志。
可以重新打开一遍设备文件 /dev/tty(表示当前终端),在打开时指定 O_NONBLOCK标志。

#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
int main(void)
{
	char buf[10];
	int fd, n;
	fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
	if(fd<0) {
		perror("open /dev/tty");
		exit(1);
	}
	
tryagain:
	n = read(fd, buf, 10);
	if (n < 0) {
		if (errno == EAGAIN) {
			sleep(1);
			write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
			goto tryagain;
		}
		perror("read /dev/tty");
		exit(1);
	}
	write(STDOUT_FILENO, buf, n);
	close(fd);
	return 0;
}

4.3 非阻塞读终端 和等待超时

以下是用非阻塞I/O实现等待超时的例子。既保证了超时退出的逻辑又保证了有数据到达时处理延迟较小。

#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
#define MSG_TIMEOUT "timeout\n"
int main(void)
{
	char buf[10];
	int fd, n, i;
	fd = open("/dev/tty", O_RDONLY|O_NONBLOCK);
	if(fd<0) {
		perror("open /dev/tty");
		exit(1);
	}
	for(i=0; i<5; i++) {
		n = read(fd, buf, 10);
		if(n>=0)
		break;
		if(errno!=EAGAIN) {
			perror("read /dev/tty");
			exit(1);
		}
		sleep(1);
		write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
	}
	
	if(i==5)
		write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT));
	else
		write(STDOUT_FILENO, buf, n);
		
	close(fd);
	return 0;
}

5、lseek

每个打开的文件都记录着当前读写位置,打开文件时读写位置是0,表示文件开头,通常读写多少个字节就会将读写位置往后移多少个字节。但是有一个例外,如果以 O_APPEND 方式打开,每次写操作都会在文件末尾追加数据,然后将读写位置移到新的文件末尾。
lseek 和标准I/O库的 fseek 函数类似,可以移动当前读写位置(或者叫偏移量)。

#include <sys/types.h>
#include <unistd.h>

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

参数offset和whence的含义和fseek函数完全相同。只不过第一个参数换成了文件描述符。和fseek一样,偏移量允许超过文件末尾,这种情况下对该文件的下一次写操作将延长文件,中间空洞的部分读出来都是0。

若lseek成功执行,则返回新的偏移量,因此可用以下方法确定一个打开文件的当前偏移量:

off_t currpos;
currpos = lseek(fd, 0, SEEK_CUR);

这种方法也可用来确定文件或设备是否可以设置偏移量,常规文件都可以设置偏移量,而设备一般是不可以设置偏移量的。如果设备不支持 lseek,则 lseek返回-1,并将errno设置为ESPIPE。注意fseek和lseek在返回值上有细微的差别,fseek成功时返回0失败时返回-1,要返回当前偏移量需调用ftell,而 lseek成功时返回当前偏移量失败时返回-1。

6、fcntl

先前我们以read终端设备为例介绍了非阻塞I/O,为什么我们不直接对STDIN_FILENO做非阻塞read,而要重新open一遍/dev/tty呢?因为STDIN_FILENO在程序启动时已经被自动打开了,而我们需要在调用open时指定O_NONBLOCK标志。这里介绍另外一种办法,可以用fcntl函数改变一个已打开的文件的属性,可以重新设置读、写、追加、非阻塞等标志(这些标志称为File Status Flag),而不必重新open文件。

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd);
int fcntl(int fd, int cmd, long arg);
int fcntl(int fd, int cmd, struct flock *lock);

这个函数和open一样,也是用可变参数实现的,可变参数的类型和个数取决于前面的cmd参数。下面的例子使用F_GETFL和F_SETFL这两种fcntl命令改变STDIN_FILENO的属性,加上O_NONBLOCK 选项,实现和例 28.3 “非阻塞读终端”同样的功能。

6.1 用 fcntl 改变 File Status Flag

#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
int main(void)
{
	char buf[10];
	int n;
	int flags;
	flags = fcntl(STDIN_FILENO, F_GETFL);
	flags |= O_NONBLOCK;
	if (fcntl(STDIN_FILENO, F_SETFL, flags) == -1) {
		perror("fcntl");
		exit(1);
	}
	
tryagain:
	n = read(STDIN_FILENO, buf, 10);
	if (n < 0) {
		if (errno == EAGAIN) {
			sleep(1);
			write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
			goto tryagain;
		}
		perror("read stdin");
		exit(1);
	}
	
	write(STDOUT_FILENO, buf, n);
	return 0;
}

7、ioctl

ioctl 用于向设备发控制和配置命令,有些命令也需要读写一些数据,但这些数据是不能用read/write读写的,称为Out-of-band数据。也就是说,read/write 读写的数据是in-band数据,是I/O操作的主体,而ioctl命令传送的是控制信息,其中的数据是辅助的数据。例如,在串口线上收发数据通过read/write操作,而串口的波特率、校验位、停止位通过ioctl设置,A/D转换的结果通过read读取,而A/D转换的精度和工作频率通过ioctl设置。

#include <sys/ioctl.h>

int ioctl(int d, int request, ...);

d是某个设备的文件描述符。request是ioctl的命令,可变参数取决于request,通常是一个指向变量或结构体的指针。若出错则返回-1,若成功则返回其他值,返回值也是取决于request。

以下程序使用TIOCGWINSZ命令获得终端设备的窗口大小。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ioctl.h>
int main(void)
{
	struct winsize size;
	if (isatty(STDOUT_FILENO) == 0)
	exit(1);
	
	if(ioctl(STDOUT_FILENO, TIOCGWINSZ, &size)<0) {
		perror("ioctl TIOCGWINSZ error");
		exit(1);
	}
	
	printf("%d rows, %d columns\n", size.ws_row, size.ws_col);
	return 0;
}

在图形界面的终端里多次改变终端窗口的大小并运行该程序,观察结果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值