I/O(输入/输出)是指数据在计算机系统与外部环境(例如用户、其他计算机或设备)之间的交换接口。I/O 可以分为系统 I/O 和标准 I/O。下面详细介绍这两种 I/O 机制。
系统 I/O
系统 I/O 是指操作系统提供的用于处理输入和输出操作的系统调用。系统 I/O 操作通常直接与硬件设备交互,具有较高的效率和控制粒度,但编程接口较为复杂。常见的系统 I/O 操作包括文件操作、网络通信和设备管理。
- 低级操作:系统 I/O 提供对底层硬件设备的直接访问,例如磁盘、网络接口和显示器。
- 高速:由于直接与硬件交互,系统 I/O 操作通常比高级抽象层(如标准 I/O)更快速,有种实时交互的感觉,但是效率不高,I/O操作太频繁(因为没有缓冲区)。
- 复杂性:系统 I/O 的编程接口较为复杂,需要处理诸如设备状态、缓冲区管理和错误处理等细节。
常见的系统 I/O 调用:open,close,read,write。
在系统 I/O 中,文件是通过文件描述符(File Descriptor)来操控的。文件描述符是一个整数,操作系统用它来标识一个已经打开的文件或设备。
- 文件描述符是一个非负整数,通常从 0 开始。
- 文件描述符 0、1 和 2 分别对应标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。
- 文件描述符由系统调用
open
、creat
、socket
等创建,并用于后续的read
、write
、close
等系统调用。
标准 I/O
标准 I/O 是 C 标准库提供的一组用于处理输入和输出操作的高级接口。标准 I/O 实际上是基于系统 I/O 构建的。标准 I/O 库(例如 C 标准库中的 stdio.h
提供的函数)在底层仍然调用操作系统提供的系统 I/O 函数来执行实际的文件操作。标准 I/O 提供了一个更高级的抽象层,封装了系统 I/O,提供更便捷的接口和缓冲机制。
- 高级抽象:标准 I/O 提供了一组简洁、易用的函数接口,适合大多数常见的 I/O 操作。
- 缓冲机制:标准 I/O 实现了自动缓冲机制,提高了 I/O 操作的效率,虽然说速度上慢于系统 I/O 操作,但是由于有缓冲机制,所以可以积累一段时间再进行I/O操作,标准 I/O 可以将多个小的 I/O 操作合并为一个较大的操作,减少了I/O操作的次数,总体来说效率高于标准 I/O。这尤其对于处理大量数据时特别有用,有助于提高性能。
- 跨平台:标准 I/O 接口在不同操作系统之间具有良好的可移植性。
常见的标准 I/O 函数:fopen,fclose,fread,fwrite。
在标准 I/O 中,文件是通过文件指针(File Pointer)来操控的。文件指针是一个指向 FILE
结构的指针,FILE
结构包含了文件相关的信息和缓冲区。
- 文件指针是一个
FILE*
类型,指向一个FILE
结构。 - 文件指针由标准 I/O 库函数
fopen
创建,并用于后续的fread
、fwrite
、fclose
等函数。
总结:
在实际开发中,根据具体需求选择合适的 I/O 方式。系统 I/O 适用于需要精细控制和高性能的场景,而标准 I/O 适用于大多数应用程序开发,提供更高的开发效率和跨平台兼容性。实际上,我们都会尽量使用标准 I/O,因为简单并且高效,对于一些只能通过系统 I/O 才能进行的操作,比如网络这块,我们就只能使用系统I/O了。
操作总览(适合复习本文,复习的时候先看这里)
本文中介绍了四种文件基础操作函数,open,close,read,write,在使用的时候主要就是要会对每种函数传参,以及会用他们的返回值来帮助我们查看实际操作结果。其中read和write需要借助自定义的缓冲区。
open的参数是目标打开文件路径,文件状态标志,文件的权限(仅仅文件不存在创建这个文件的时候才起作用),返回值是:打开文件成功返回对应的文件描述符,打开文件失败返回-1,此时我们可以利用错误码来查看打开失败原因。
close参数是文件描述符,关闭成功返回0,失败返回-1。
read的参数是:源文件文件描述符,缓冲区的指针(暂存读取到的内容),期望读取的字节数。返回值是:读取成功返回实际读取到的字节数,读取失败返回-1,同样可以用错误码来获取失败信息。
write的参数是:目标文件文件描述符,缓冲区的指针(从缓冲区中取出内容),期望写入目标文件的字节数(通常就是缓冲区中实际放置的字节数)。返回值是:写入成功返回实际写入的字节数,写入失败返回-1,同样可以用错误码来获取失败信息。对于write来说我们需要每次保证将缓冲区中的字节全部写入到目标文件后才进行下一次从源文件读取到缓冲区的操作。
最后我们讲到了文件的读写位置,介绍了lseek函数可以帮助我们来调整文件的读写位置,从而帮助我们实现更加灵活的读写功能。lseek(fd,基准点,偏移量)。
在本文中我们对应介绍了三个实际的例子,分别是1.测试一个进程中最多打开多少个文件,通过反复打开同一个文件,输出文件描述符的值来得知,以此来加深我们对文件描述符的认识。2.用open,close,read,write实现将一个文件中内容复制到另一个文件的操作,并且每个环节错误之后会用错误码输出错误提示信息。3.通过调整文件的读写位置实现将一个文件中的内容打开一次且输出两遍
文件的open操作
open操作介绍
- open函数有两个版本,当对应的文件存在的时候打开文件需要有两个参数,当对应的文件不存在的时候创建并打开文件需要有三个参数,第三个参数对应的是设置新文件的权限,以8进制表示法设置。
- 模式flags,可以使用位或( | )的方式,来同时指定多个模式,三个互斥的文件访问方式必须指定一个且只能指定一个,其他的可以任意指定多个。位或连接的标志顺序没有影响。
- 模式flags中,O_NOCTTY主要用在后台精灵进程,阻止这些精灵进程拥有控制终端。
- 在创建一个新的文件时,模式必须要用到
O_CREAT
标志,并且需要用第三个参数指定新文件的权限,否则新文件的权限是随机值。如果open的文件是已经存在的,那么即使写上第三个权限参数,也不会改变文件的权限。 O_EXCL标志只能
与O_CREAT
一起使用,一起使用时,如果文件已存在,则返回错误。我们常常创建文件时O_EXCL标志
与O_CREAT
标志一起写上,比如O_RDWR|O_CREAT|O_EXCL(O_RDWR可以换成别的)
open操作使用示例
int main(void)
{
int fd;
// 以下打开方式,都要求文件已存在,否则失败返回
fd = open("a.txt", O_RDWR); // 以可读可写方式打开
fd = open("a.txt", O_RDONLY); // 以只读方式打开
fd = open("a.txt", O_WRONLY); // 以只写方式打开
// 1. 如果文件不存在,则创建该文件,并设置其权限为0644
// 2. 如果文件已存在,则失败返回
fd = open("a.txt", O_RDWR|O_CREAT|O_EXCL, 0644); // 以可读可写方式打开
fd = open("a.txt", O_RDONLY|O_CREAT|O_EXCL, 0644); // 以只读方式打开
fd = open("a.txt", O_WRONLY|O_CREAT|O_EXCL, 0644); // 以只写方式打开
// 1. 如果文件不存在,则创建该文件,并设置其权限为0644
// 2. 如果文件已存在,则清空该文件的原有内容
fd = open("a.txt", O_RDWR|O_CREAT|O_TRUNC, 0644); // 以可读可写方式打开
fd = open("a.txt", O_RDONLY|O_CREAT|O_TRUNC, 0644); // 以只读方式打开
fd = open("a.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); // 以只写方式打开
}
文件的close操作
close操作介绍
- 当不再使用一个文件时,应当关闭该文件,防止系统资源浪费。
- 对同一文件重复执行关闭操作会失败返回,不会有其他副作用。
- 打开文件时,系统会为其分配一个文件描述符并返回,关闭文件时,传入的参数是对应的文件描述符,对应的文件描述符就被回收了,可以被分配给新的文件。
标准库函数中的错误处理
错误码使用介绍
在c语言所有的库函数中,如果调用过程出错了,那么该函数除了会返回一个特定的数据(如-1)来告诉用户这个函数调用失败之外(定位),还都会去修改一个大家共同的全局错误码errno,我们可以通过这个错误码,来进一步确认究竟是什么错误(找具体错误原因)。
示例:
#include <stdio.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h> // 全局错误码声明所在的头文件
int main()
{
int fd = open("a.txt", O_RDWR);
if(fd == -1)
{
// 以下两条语句效果完全一致:输出提示+函数出错的原因
perror("打开a.txt失败");
//perror 函数将输出一个描述性错误消息。消息的内容包括你传递给 perror 的字符串 s,相当于作为提示信息了,后面跟的是系统错误的说明
printf("打开a.txt失败:%s\n", strerror(errno));
}
return 0;
}
- 如果库函数、系统调用出错了,全局错误码 errno 会随之改变
- 如果库函数、系统调用没出错,全局错误码 errno 不会改变
- 一个库函数、系统调用出错后,若未及时处理错误码,则错误码可能会被随后的其他函数修改
提取错误码信息的两种办法:
// 1. 使用perror(),直接输出用户信息和错误信息:
if(open("a.txt", O_RDWR) == -1)
{
perror("打开a.txt失败");
}// 2. 使用strerror(),返回错误信息交给用户自行处理:
if(open("a.txt", O_RDWR) == -1)
{
printf("打开a.txt失败:%s\n", strerror(errno));
}
errno
是整数:表示错误代码,strerror(errno)
函数,可以将errno
的整数值转换为对应的描述性字符串,方便调试和错误处理。
一般而言, perror()用起来更加方便,但使用strerror()可以组合输出一些更加灵活的信息,比如说想在字符串和错误码字符串中间加一点其他输出的东西。
文件描述符
上面我们在open文件和close文件的时候都谈到了文件描述符,现在我们来详细讲一下。
文件描述符(File Descriptor, FD)是操作系统内核用来管理和访问文件的一种抽象标识符。它是一个非负整数,用于表示一个已打开的文件或其他输入/输出资源,如管道、网络套接字等。文件描述符是进程级的资源,每个进程都有自己独立的一组文件描述符。
文件描述符的分配:
- 当一个文件被打开(例如使用
open
系统调用)时,内核会为该文件分配一个文件描述符。 - 文件描述符通常是从 0 开始的最小可用整数。
- 每个进程都有一个文件描述符表,用来记录所有已打开的文件。
默认分配的文件描述符:
- 标准输入(Standard Input,
stdin
):文件描述符为0
- 标准输出(Standard Output,
stdout
):文件描述符为1
- 标准错误(Standard Error,
stderr
):文件描述符为2
这三个标准文件描述符(标准输入、标准输出、标准错误)是每个进程在启动时默认分配的。操作系统为每个新创建的进程自动打开这三个文件,并分配的对应的文件描述符,以便进程可以立即进行输入和输出操作,而无需显式地打开这些文件。
文件描述符常见操作:
通常我们使用变量名fd来表示文件描述符
- 打开文件:使用
open
系统调用,如int fd = open("file.txt", O_RDONLY);
- 读写文件:使用
read
和write
系统调用,如ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
- 关闭文件:使用
close
系统调用,如int result = close(fd);
进程文件描述符的上限代表了这个进程最多同时打开文件数目的上限,这个上限一般默认是1024个,上限可以更改。
在一个进程中,我们可以不同的方式打开同一个文件,每次打开系统会为它分配不同的文件描述符。甚至我们可以相同的方式打开同一个文件,每次打开系统也会为它分配不同的文件描述符。 所以一个进程中,一个打开的文件可以有多个不同的文件描述符。
文件描述符的本质:打开一个文件时,这个文件的打开方式,文件位置指针,以及文件读写位置等信息会被记录到一个结构体变量(文件表项)中,结构体变量对于存储在一文件描述符表中,文件表项下标从0开始记录,这个下标数字就是文件描述符。
测试进程中文件描述符的上限:
#include <pthread.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char **argv) {
if(argc != 2) {
printf("arguments error!\n");
exit(0);
}
int count = 0;
while(1) {
int fd = open(argv[1], O_RDONLY, 0644);
if(fd == -1) {
perror("open error");
break;
}
count++;
}
printf("count : %d\n", count);
return 0;
}
文件的read和write操作
- 参数count是读写字节数的愿望值,实际读写成功的字节数由返回值决定。
- 读取普通文件时,如果当读到了文件尾,read()会返回0。我们可以通过判断能返回值来实际判断文件是否被读取完成。
//要求:将文件 a.txt 中的内容读出来,并显示到屏幕上
int fd = open("a.txt", O_RDWR);//首先打开文件
char buf[100];//定义缓冲区,
int n;
while(1)//持续不断地读取,直到文件内容被读完
{
bzero(buf, 100); //每次读取的时候都清空缓冲区
n = read(fd, buf, 100); // 每次最多读取100个字节,因为缓冲区只能存放100字节
if(n == 0) // 读完退出
break;
printf("%s", buf);
}
close(fd);
复制文件案例
问题描述:利用上面学习的四种文件I/O函数,将一个文件中的内容复制到另一个文件中,要求程序具备完备性,健壮性,能够处理特殊情况。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#define BLKSIZE 200//缓存区大小
int main(int argc, char **argv){
int fd_from, fd_to;
if(argc != 3)
{
printf("对不起,您必须指定两个文件\n");
printf("像这样:./copyFile file1 file2\n");
exit(0);
}
// 打开源文件
fd_from = open(argv[1], O_RDONLY);
if(fd_from == -1)
{
printf("打开文件“%s”失败:%s\n", argv[1], strerror(errno));
exit(0);
}
// 打开目标文件
fd_to = open(argv[2], O_WRONLY|O_CREAT|O_TRUNC, 0644);
if(fd_to == -1)
{
printf("打开文件“%s”失败:%s\n", argv[2], strerror(errno));
exit(0);
}
char buf[BLKSIZE];
char *bp;
int nread, nwrite;//每次实际读取到缓存区中的字节数,每次实际写入到目标文件的字节数
// 循环将数据从文件argv[1]复制到argv[2]中
while(1)
{
// 读取源文件内容,若被信号中断,则重启读操作
while(((nread=read(fd_from, buf, BLKSIZE)) == -1)
&& (errno == EINTR));
// 若遇到错误,则报告错误信息并退出
if(nread == -1)
{
perror("读取源文件失败");
break;
}
// 若读到文件尾,则退出
if(nread == 0)
break;
// 循环将读取到的 nread 个字节写入目标文件中
bp = buf;
while(nread > 0)
{
while(((nwrite=write(fd_to, bp, nread)) == -1)
&& (errno == EINTR));
if(nwrite <= 0)
{
perror("写入失败");
exit(0);
}
nread -= nwrite;
bp += nwrite;
}
}
close(fd_from);
close(fd_to);
return 0;
}
代码逻辑:整个代码分为四块,确保传入程序的参数正确,打开两个文件,将源文件内容复制到另一个文件,关闭两个文件。
1.确保在执行这个程序的时候传入参数正确,有三个参数
2.打开文件时,如果文件打不开,则输出提示信息并且退出程序
3.1while循环重复性地读取源文件内容,里面嵌套一层循环为了让每次读取到缓冲区的内容都能被完整写入到目标文件,最后直到某一次我们读取到的文件内容字节数为0,则读取结束,退出程序。
每次读取可能会出现读取失败的情况,但是读取失败此时read函数返回值为-1也代表了多种情况,有些情况造成的读取错误我们可能希望再次尝试继续读取,而不是直接退出程序,我们可以通过errno来获取详细的错误信息,这样对于某些情况造成的读取失败我们可以继续尝试读取。下面的write也是这样的。while(((nread=read(fd_from, buf, BLKSIZE)) == -1) && (errno == EINTR));
3.2 在将缓冲区中的数据写入目标文件时,我们也用到了一个while循环,虽然说我们指定的写入字节数是我们最近一次read函数读取到缓冲区的字节数,但是实际上写入的字节数可能少于指定的字节数。所以要想将缓冲区中的数据全部写入到目标文件,我们需要while循环写入。在这里我们需要用一个指针bp来追踪缓冲区的当前写入位置,因为write函数中的第二个参数要求的是一个指向缓冲区中即将要写入数据的指针。
4.关闭两个文件。要养成用完文件及时关闭的好习惯。
补充1:有人可能会疑惑这里为什么read和write函数指定的字节数和实际读取/写入的字节数都是不完全匹配的,但是为什么只有写入的时候我们采取了措施来保证缓冲区中的数据能被完全写入到目标文件,因为缓冲区中的数据如果不能保证完全写到目标文件中,那么下一次读取将会将原来缓冲区中的数据覆盖。
对于读取的话,实际读取到缓冲区的和期望的读取数不一样的话,大不了就多读取两次而已,最后的结果不会变。
补充2:还有可能会疑惑的一个点是:在上面的缓冲区中,需要用一个指针bp来追踪缓冲区的当前写入位置,那么对于源文件我们不需要用一个指针来辅助追踪读取到了哪个位置吗?答案是肯定也需要,只不过对于文件来说它提供了一个文件位置参数,自动帮我们实现了这个功能,对于目标文件来说也用到了文件位置参数,这样才保证我们每次能找到合适的追加位置。
文件读写位置
当我们对文件进行读写操作时,系统会为我们记录操作的位置,以便于下次继续进行读写操作的时候,从适当的地方开始。
对文件进行常规的读写操作的时候,系统会自动调整读写位置,以便于让我们顺利地顺序读写文件,但如果有需要,文件的读写位置是可以任意调整的,lseek函数如下:
- lseek函数可以将文件位置调整到任意的位置,可以是已有数据的地方,也可以是未有数据的地方,假设调整到文件末尾之后的某个地方,那么文件将会形成所谓“空洞”。
- lseek函数只能对普通文件调整文件位置,不能对管道文件调整。
- lseek函数的返回值是调整后的文件位置距离文件开头的偏移量,单位是字节。
// 假设文件 a.txt 只有一行
// 内容为:1234567890abcde
char buf[10];int fd = open("a.txt", O_RDWR);
// 读取前面10个阿拉伯数字:
read(fd, buf, 10);// 将文件位置调整到'c'
lseek(fd, 2, SEEK_CUR);// 将文件位置调整到'1'
lseek(fd, 0, SEEK_SET);// 将文件位置调整到'a'
lseek(fd, -5, SEEK_END);
下面我们将通过将通过调整文件位置来将一个文件中的内容输出两遍:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#define BLKSIZE 200
int main(int argc, char **argv){
char buffer[BLKSIZE];
if(argc!=2){
printf("你必须指定一个文件/n");
exit(0);
}
int fd=open(argv[1],O_RDONLY);
if(fd==-1){
printf("打开文件失败/n");
}
int nread;
while(1){
bzero(buffer,BLKSIZE);
nread=read(fd,buffer,BLKSIZE);
if(nread==0){
break;
}
if(nread==-1){
printf("读取文件错误/n");
}
printf("%s",buffer);
}
lseek(fd, 0, SEEK_SET);//没有这行的话,只会输出文件内容一次
while(1){
bzero(buffer,BLKSIZE);
nread=read(fd,buffer,BLKSIZE);
if(nread==0){
break;
}
if(nread==-1){
printf("读取文件错误/n");
}
printf("%s",buffer);
}
return 0;
}
谈一下系统I/O和我们的linux终端中的命令之间的关系
系统调用函数(如open
、close
、read
和write
)与终端中的命令之间有着密切的关系。系统调用是操作系统提供的接口,用于程序与内核进行交互。
终端命令通常是用户级程序,这些程序内部使用了系统调用来实现功能,也就是说我们每使用的一种命令其实也是在调用着一个程序,这些程序都是终端中自带的程序,不需要我们来手动编写这些程序。
比如:
cat
命令:用于显示文件内容。
- 系统调用:
open
打开文件,read
读取文件内容,write
将内容输出到标准输出。
cp
命令:用于复制文件。
- 系统调用:
open
打开源文件和目标文件,read
从源文件读取数据,write
将数据写入目标文件,close
关闭文件。
理解系统调用与终端命令之间的关系,有助于更深入地了解Linux系统的工作原理,以及如何在程序中高效地使用这些调用来完成文件和设备操作。