Linux修炼:基础IO(一)
Hello大家好!很高兴我们又见面啦!给生活添点passion,开始今天的编程之路!
我的博客:<但凡.
我的专栏:《编程之路》、《数据结构与算法之美》、《C++修炼之路》、《Linux修炼:终端之内 洞悉真理》、《Git 完全手册:从入门到团队协作实战》
感谢您打开这篇博客!希望这篇博客能为您带来帮助,也欢迎一起交流探讨,共同成长。
目录
1、回顾
2、回顾C语言中的文件操作
2.1、C语言文件打开方式
2.2、基本语法
2.3、常用打开模式
2.4、示例代码
2.5、错误处理
2.6、文件关闭
3、系统文件IO
3.1、IO接口
低级IO接口
标准IO库(C语言阶段学习过)
3.2、Linux系统下通过命令访问文件
3.3、格式化输入与输出
3.4、打印到显示器的多种方式
3.5、C语言的文件操作接口与系统调用
3.6、文件的管理方式
3.7、文件描述符的分配规则
3.8、输入重定向和输出重定向
1、回顾
我们在学习C和C++时就接触过IO了,现在我们把过去学习的一些知识再回顾一下。
文件等于内容加属性,我们想访问一个文件,就得打开它,打开文件就是把文件从磁盘加载到内存中。也就是说,如果一个文件没被打开,他就在磁盘上,如果文件被打开了,他就加载到了内存中。
这个打开是由系统执行的。用户通过bash,启动进程,进程通过操作系统,执行系统调用,打开了文件。
我们狭义理解的文件是存储在磁盘中的。磁盘作为外设,每次从磁盘中读取文件都是对外设的输入和输出,简称IO。
尽管我们之前学过C/C++的文件操作,但是C/C++提供的文件操作接口,并不能直接进行文件读写操作,这些接口本质上还是调用了系统调用接口。
2、回顾C语言中的文件操作
2.1、C语言文件打开方式
在C语言中,文件操作主要通过标准库函数 fopen 实现。该函数用于打开文件并返回文件指针,供后续读写操作使用。
2.2、基本语法
文件打开的基本语法如下:
FILE *fopen(const char *filename, const char *mode);
filename:要打开的文件名(包含路径)mode:文件打开模式
2.3、常用打开模式
以下是最常用的文件打开模式:
读取模式
"r":以只读方式打开文本文件,文件必须存在"rb":以只读方式打开二进制文件,文件必须存在
写入模式
"w":以只写方式创建文本文件,若文件存在则清空内容"wb":以只写方式创建二进制文件,若文件存在则清空内容"a":以追加方式打开文本文件,若文件不存在则创建"ab":以追加方式打开二进制文件,若文件不存在则创建
读写模式
"r+":以读写方式打开文本文件,文件必须存在"rb+":以读写方式打开二进制文件,文件必须存在"w+":以读写方式创建文本文件,若文件存在则清空内容"wb+":以读写方式创建二进制文件,若文件存在则清空内容"a+":以读写方式打开文本文件,写入时追加,若文件不存在则创建"ab+":以读写方式打开二进制文件,写入时追加,若文件不存在则创建
2.4、示例代码
#include <stdio.h>int main() {FILE *fp;// 以只读方式打开文本文件fp = fopen("example.txt", "r");if(fp == NULL) {perror("Error opening file");return 1;}// 文件操作代码...fclose(fp); // 关闭文件return 0;
}
2.5、错误处理
fopen 函数在失败时会返回 NULL,因此应该检查返回值:
FILE *fp = fopen("nonexistent.txt", "r");
if(fp == NULL) {perror("Error");// 处理错误
}
2.6、文件关闭
使用 fclose 函数关闭已打开的文件:
fclose(fp);
不关闭文件可能导致数据丢失或资源泄漏。
#include<stdio.h>
#include<unistd.h>int main()
{FILE* fp=fopen("log.txt","w");if(!fp){perror("fopen");return 1;}while(1){sleep(1);}fclose(fp);return 0;
}
每个进程都有自己的当前工作路径,当我们调用fopen时,尽管我们只给了一个文件名,但是fopen内部会先获取当前进程的cwd,然后再当前进程的工作路径中创建文件。如果我们使用chdir更改当前工作路径,那么新建的这个文件就会直接新建到指定路径下。
所以说,想到打开文件,必须要先找到文件,那就需要知道文件的路径和文件名,因此进程必须要有cwd。就比如在C语言阶段,我们就直到#include<>是编译器(进程)现在系统路径找,找不到了再在当前路径找,而#include""直接在当前路径找。
3、系统文件IO
3.1、IO接口
Linux系统提供了多种IO接口,用于处理文件、设备、网络等数据的输入输出操作。这些接口涵盖了从低级到高级的不同层次,满足不同场景的需求。我们这期先介绍低级(底层)系统接口。
低级IO接口
低级IO接口通常直接调用系统调用,提供最基本的文件操作功能:
open():打开或创建文件read():从文件描述符读取数据write():向文件描述符写入数据close():关闭文件描述符lseek():移动文件指针位置
示例代码:
#include <fcntl.h>
#include <unistd.h>int fd = open("file.txt", O_RDWR | O_CREAT, 0644);
char buffer[1024];
read(fd, buffer, sizeof(buffer));
write(fd, "Hello", 5);
close(fd);
我们C语言的文件接口,本质上也是调用了这些系统调用接口。
标准IO库(C语言阶段学习过)
标准IO库(stdio)在低级IO基础上提供了缓冲功能,提高了IO效率(下期会介绍为什么提高了IO效率):
fopen():打开文件流fread():从文件流读取数据fwrite():向文件流写入数据fclose():关闭文件流fseek():移动文件流指针
示例代码:
#include <stdio.h>FILE *fp = fopen("file.txt", "r+");
char buffer[1024];
fread(buffer, 1, sizeof(buffer), fp);
fwrite("World", 1, 5, fp);
fclose(fp);
文件分为文本文件和二进制文件。文件可以看成一维数组,因为文本文件可以被当作一个长字符串,换行无非就是'\n'。所以说,文件的读写位置就可以看作是一维数组的下标。
3.2、Linux系统下通过命令访问文件
在Linux系统中,执行echo "string" > "文件名"命令时,系统会以截断模式(对应C语言"w"模式)打开文件。该操作会清空文件原有内容,再将新字符串写入文件。
使用echo "string" >> "文件名"命令时,系统以追加模式(对应C语言"a"模式)打开文件。该操作会在文件末尾追加新内容,保留原有数据。
当然以上只是用C语言打个比方,echo的底层行为由Shell实现决定,内置版本通常直接调用系统调用,而外部版本可能间接使用stdio库。两者最终均通过内核接口完成输出。
cat命令通过父进程fork出的子进程来实现文件内容输出。子进程调用fget,fread等函数读取文件内容,将数据写入标准输出(显示器)。这种设计遵循Unix的进程模型,实现了高效的文件操作和输出重定向功能。
3.3、格式化输入与输出
键盘和显示器都是字符设备。当我们想输入一个int变量123,实际上我们输入的是'1','2','3'这三个字符,在scanf函数内部,他会把这三个字符合起来存储到一个int变量中。当我们想往显示器打印一个int变量123时,其实是由printf函数,把123这个int类型变量,拆散成'1','2','3'三个字符,然后写入到显示器文件中。这就叫格式化输入与输出。
读写二进制文件时,直接往文件中读写,把每个字节都照搬过来。而读写文本文件时,需要做格式化输入输出。读的是字符,要转换成整数,写的是整数,要转换成字符。这些工作都是由printf和scanf底层去实现的。当然如果我们要是打印字符串的话那就不用转换了。
3.4、打印到显示器的多种方式
向显示器中打印字符串有很多种方法,可以调用printf,fprintf,fputs,fwrite。那么这几种方式有什么区别,又有什么共同点呢?
进程启动的时候,默认会打开三个输入输出流:stdin,stdout,stderr。其实就是打开三个文件。而以上四种打印到显示器的方式无不都是输出到stdout这个文件。其中,fprintf,fputs,fwrite在传参的时候都得显示的传入stdout,而printf默认绑定了stdout。
#include<stdio.h>
#include<string.h>int main()
{const char* s1="hello printf\n";printf(s1); const char* s2="hello fprint\n";fprintf(stdout,s2);const char* s3="hello fputs\n";fputs(s3,stdout);const char* s4="hello fwrite\n";fwrite(s4,strlen(s4),1,stdout);return 0;
}
其实这四个接口有一个共同点,就是都调用了系统调用接口open:

如果文件存在,那就调用两个参数的open,如果这个文件不存在,那就调用三个参数的open。这三个参数分别是文件路径+文件名、打开文件的方式,设定权限(八进制数)。这里的文件打开方式flags其实就是一个整数(是系统定义好的宏)。这个int类型整数有32个比特位,所以一共有32个标志位。
open成功时返回一个非负整数,表示文件描述符(File Descriptor)。该描述符是当前进程未使用的最小描述符,后续对文件的读写操作均通过此描述符进行访问。
open失败时返回-1,同时设置全局变量errno以指示具体错误类型。常见的错误码包括:
ENOENT:文件或路径不存在。EACCES:权限不足。EEXIST:文件已存在(与O_CREAT和O_EXCL标志冲突时触发)。EISDIR:尝试以写方式打开目录文件。
系统提供了几种可传入到flags的宏,每个宏都对应一个数字。这些宏对应的数字有一个特点,就是只有一个比特位为1,换句话说,只有一个标志位。在传参的时候,我们可以把不同的宏通过|(或)的方式组合起来使用。这种传参的方式叫位图传参。
以下是系统提供的几种宏:
O_RDONLY:只读模式。O_WRONLY:只写模式。O_RDWR:读写模式。O_CREAT:如果文件不存在,则创建文件。O_EXCL:与O_CREAT一起使用,确保文件不存在时才创建。O_TRUNC:如果文件已存在且为普通文件,将其长度截断为 0(清空文件)。O_APPEND:以追加模式打开文件。O_NONBLOCK:以非阻塞模式打开文件。O_SYNC:每次写操作都会同步到磁盘。
需要注意的是,我们执行以下代码,他并不会给我们创建文件,因为在只写模式下没有文件创造文件的那是C语言,而这是系统调用接口,如果我们想让他创造文件需要或上O_CREAT。只要是新建文件,就必须指明文件的权限。但是由于umask的影响,如果我们想创建出权限为666的文件,可以暂时把该文件的umask改为0。
#include<stdio.h>
#include<string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{int fd=open("log.txt",O_WRONLY);//umask(0);//int fd=open("log.txt",O_WRONLY|O_CREAT,0666);if(fd<0){perror("open");return 1;}close(fd);return 0;
}
3.5、C语言的文件操作接口与系统调用
为什么C语言要封装文件操作接口呢?
第一,系统调用麻烦,并且需要学习系统的相关知识才可以使用。在C语言的学习阶段我们是不会系统的,如果只有系统调用才能进行文件操作那么文件操作的学习成本太大。
第二,Linux和其他操作系统的系统调用接口不一样!但是由于C语言跨平台的特性,在Linux上写的代码直接复制到其他操作系统上就能直接使用。所以说C语言对系统接口的封装本质上方便了我们程序员。
第三,C语言的文件操作接口通常带有用户态缓冲机制,减少了频繁调用系统调用的开销。换一句通俗的话说,就是提高了IO效率。
那么语言的跨平台性是如何实现的呢?
对于C/C++这种编译型语言来说,他实际上实现了所有平台的系统调用接口的封装,通过#ifdef和#elif来判断当前的平台,什么平台他就编译对应的那一部分代码。
3.6、文件的管理方式
在系统中,每个被打开的文件都被对应的struct file管理。struct file中直接或间接包含了文件的属性和内容。由于系统中存在很多被打开的文件,这些struct file之间通过双链表连接,管理文件就成了对struct file进行增删查改。
这些文件是被多个进程打开的,系统要怎么表示哪些文件是被哪些进程打开的呢?
在进程pcb中,包含一个叫struct files_struct* files的指针,这个指针指向的结构体之中包含了一个指针数组struct file* fd_array[]。这个指针数组之中包含的指针指向了各个files_struct。而文件描述符实际上就是这个指针数组的下标。
在打开文件时,系统还会把这个文件的部分属性和内容加载到内存中,然后在struct file内部用分别用两个指针指向加载到内存中的文件的属性和内容。内容是保存在文件内核缓冲区的。
files_struct内部包含了文件的inode信息,inode包含的关键信息有以下几种:
- 文件类型:普通文件、目录、符号链接等。
- 权限:读、写、执行权限(如
rwxr-xr-x)。- 所有者与组:文件所属用户和组。
- 大小与时间戳:文件大小、最后访问时间(atime)、修改时间(mtime)、元数据变更时间(ctime)。
- 数据块指针:指向存储文件内容的磁盘块。

当我们使用write写入文件时,并不是直接把字符串写入到磁盘中,而是把数据从用户空间拷贝到对应文件的内核缓冲区中。首先,write根据调用他的进程控制块pcb找到files_struct,然后根据文件描述符,找到要写入文件的struct fils_struct,接着把内容拷贝到fils_struct内部指针指向的文件内核缓冲区。内核缓冲区的内容什么时候写入到磁盘中就是由操作系统决定的了。我们进行任何文件增、删、查、改操作,都必须把文件的内容提前加载到文件内核缓冲区。
3.7、文件描述符的分配规则
我们打印几个文件描述符,发现文件描述符是从3开始的,那么为什么不是从0开始呢?
系统内部是通过文件描述符来区分文件的,在C语言中,File*是定义文件的一个结构体,这个结构体中封装了文件描述符。而0,1,2这三个文件描述符(三个下标)被stdin,stdout,stderr这三个文件封装了起来。我们可以通过以下方式访问File*中封装的文件描述符:
int main()
{printf("stdin->%d\n",stdin->_fileno);printf("stdin->%d\n",stdout->_fileno);printf("stdin->%d\n",stderr->_fileno);return 0;
}
在C++中,是cin,cout,cerr这三个类封装了文件描述符0,1,2。
分配描述符时,系统会从文件描述符数组中寻找最小的,没有被使用的下标,作为该文件的文件描述符。
3.8、输入重定向和输出重定向
输入重定向和输出重定向是怎么实现的呢?
经过上面的介绍我们知道分配文件描述符时,会从小到大分配。如果我们现在执行close(0)把0号下标对应的文件关闭了,那么0号下标就空出来的。这时候我们打开文件,这个文件的描述符就被分配成了0号。这时候我们读取内容就是从这个文件中读的。基于此我们就实现了输入重定向。
输出重定向同理,我们把1号下标分配给新打开的文件,这时候printf就把内容打印到了这个文件中,即完成了输出重定向。
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{close(0);int fd=open("log.txt",O_RDONLY);//需要手动创建log文件,并写入内容if(fd<0){perror("fd");return 1;}int a,b,c;scanf("%d %d %d",&a,&b,&c);printf("%d %d %d\n",a,b,c);close(fd);
}
当然,这只是底层实现的原理,如果我们想实现输入输出重定向可以通过系统调用接口实现。接下来我们介绍以下系统调用接口dup2:

我们需要向dup2中传入两个文件描述符,dup2会把oldid中存储的内容(file*)拷贝到newid中,最后,下标newid和下标oldid都存储着原来下标oldid中的内容,也就是我们新打开的文件的file_struct。换句话说,我们想要完成输出重定向,需要执行的是dup2(fd,1);执行之后,我们即可以直接printf向文件中写入内容,也可以通过write向文件中写入内容
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{int fd=open("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);if(fd<0){perror("open");return 0;}dup2(fd,1);printf("hello Linux\n");const char* string="hello linux";write(fd,string,strlen(string));return 0;
}
在创建子进程的时候,子进程同样会把父进程的文件描述表继承下来。子进程会先开辟一个一样的表,再把内容都拷贝过来。但是子进程不需要把文件拷贝过来,子进程和父进程指向的file是一样的。正因此,父子进程printf的时候,会同时打印到同一个显示器文件中。
Bash默认打开了标准输入,标准输出,标准错误,所以Bash的子进程默认也都打开了标准输入,标准输出,标准错误!子进程和父进程共享的文件会通过引用计数的方式来共同使用。只有该文件的引用计数变为0,该文件才会关闭。所以说尽管子进程把1关闭,父进程照样能打印。
好了,今天的内容就分享到这,我们下期再见!

