🎬 个人主页:谁在夜里看海.
📖 个人专栏:《C++系列》《Linux系列》《算法系列》
⛰️ 道阻且长,行则将至
目录
📚前言:一切皆文件
📚一、C语言的文件接口
📖1.文件打开
🔖语法
🔖本质
🔖示例
📖2.文件读取
🔖语法
🔖示例
📖3.文件写入
🔖语法
🔖示例
📖4.文件关闭
🔖语法
🔖作用
📖5.默认流指针
📚二、系统调用接口
📖1.文件打开
🔖语法
🔖示例
📖2.文件读取
🔖语法
🔖示例
📖3.文件写入
🔖语法
🔖示例
📖4.文件关闭
🔖语法
📚三、底层调用&上层封装
📖1.底层调用
📖2.上层封装
🔖3.示例
✅4.总结
📚四、文件描述符fd
📖1.工作原理
🔖示例
📖2.分配原则
📚五、重定向
📖1.常见的重定向
📖2.本质
📖3.dup2系统调用
🔖语法
🔖示例
📚六、总结
📚前言:一切皆文件
在正式开始文件操作的介绍之前,我们先来解决一个问题,什么是文件?
我们常见的文件有:文本文件(如.txt,.cpp),二进制文件(如编译后的可执行文件),图像文件等等,我们和这些文件打交道,无非就是对文件写入和对文件读取,然而我们是怎么实现对文件的写入和读取的呢?其实操作系统为我们提供了这一切,我们告诉系统要访问哪个文件,调用系统提供的方法,就实现了对文件的操作。
但文件的概念并不仅仅局限于磁盘上的存储内容,在操作系统中,几乎所有资源都可以通过类似“文件”的方式来进行访问和操作。无论是硬盘上的数据,还是连接计算机的外设设备,操作系统都通过类似文件的机制来统一管理他们。这是操作系统设计的一个重要思想——一切皆文件。
在这个框架下,设备(如键盘、鼠标、网络接口、内存等)不再是与文件不同的资源,而是被抽象为一种特殊类型的文件,通过统一的系统调用接口,我们可以像操作普通文件那样,操作这些设备,这种设计方式使得我们能够以一种一致的方式访问硬件资源。
下面我们来介绍操作系统具体是如何对文件进行操作,以及如何以“文件”的方式管理各种设备的。
📚一、C语言的文件接口
任何对文件的操作都可以看成对数据的访问、读取和写入,系统为我们提供了这些操作的接口,下面我们就来看看C语言下的文件接口:
📖1.文件打开
🔖语法
C语言提供了标准库函数 fopen() 用于打开文件:
FILE *fopen(const char *filename, const char *mode);
① 参数1:filename,表示文件名,指定要打开的文件路径,可以是绝对路径也可以是相对路径
② 参数2:mode,文件打开模式,指定打开文件的方式(文件操作的权限),常见的有:
"r",只读方式打开文件,文件必须存在
"w",只写方式打开文件,文件不存在则创建,存在则清空文件
"a",追加模式,文件不存在则创建,存在则数据追加到文件末尾
"rb",以二进制模式读取文件
"rw",以二进制模式写入文件
③ 返回值类型:FILE*,文件指针,用于标记当前打开的文件
🔖本质
fopen文件访问其实是做了以下工作:
1. 定位当前文件
我们打开一个文件的本质其实是向系统申请指定文件的描述符(FILE*指针),通过这个描述符系统就能定位文件,才能完成后续的读写操作。所以对文件操作之前一定要先打开文件(其实就是获取文件描述符)
在C语言中,文件描述符以指针的形式存在,FILE * 是一个指向文件对象的指针,它是一个结构体,内部包含了文件操作的状态(如文件位置、访问模式等)。
2.设置文件访问模式
打开文件时,需要指定文件的“访问模式”(如读取、写入、追加等),这告诉操作系统你希望如何使用文件:是否允许读取文件内容,是否可以修改文件,文件是否追加数据,如果文件不存在是否需要创建。
3.定位文件指针
当文件被打开时,操作系统会初始化一个文件指针,指示文件中当前可以进行读写操作的位置。在文件读取或写入时,文件指针会根据操作而前进或后退。例如,当你读一个文件时,文件指针会向前移动,直到读到文件的末尾(EOF)。当你写一个文件时,文件指针通常会向文件的结尾移动,或者在追加模式下继续从文件的末尾写入。
🔖示例
FILE *fp = fopen("myfile", "w");
if(!fp){
printf("fopen error!\n"); // 访问失败返回空指针
}
这里以"w"只写的方式打开"myfile"文件(文件不存在则创建,存在则清空),并返回一个文件指针, 如果该文件没有写权限时,打开失败,返回空指针。
📖2.文件读取
🔖语法
C语言提供了标准库函数 fread() 用于读取文件数据到缓冲区中:
ssize_t fread(void *ptr, size_t size, size_t count, FILE *stream);
① 参数1:ptr,指向存储读取数据的缓冲区的指针,读取的数据会存放到该缓冲区
② 参数2:size,读取的单个数据元素的大小(单位为字节)
③ 参数3:count,读取的元素个数
④ 参数4:stream,文件指针(FILE *,就是前面 fopen 的返回值)
⑤ 返回值类型:size_t,返回成功读取的元素个数(count)
🔖示例
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *fp = fopen("numbers.dat", "rb");
if (fp == NULL) {
perror("Error opening file");
return 1;
}
int numbers[100];
size_t elementsRead = fread(numbers, sizeof(int), 100, fp);
if (elementsRead != 100) {
if (feof(fp)) {
printf("Reached end of file.\n");
} else {
perror("Error reading file");
}
}
for (size_t i = 0; i < elementsRead; i++) {
printf("%d ", numbers[i]);
}
printf("\n");
fclose(fp);
return 0;
}
fread() 这里用于读取 numbers.dat 文件的100个整数,如果文件中少于100个整数,fread() 会读取到文件结束,并返回实际读取的文件个数。
使用 feof() 检查文件是否到达文件末尾,到达返回1,否则返回0。
📖3.文件写入
C语言提供了标准库函数 fwrite() 用于文件写入,与 fread() 相对应:
🔖语法
ssize_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
① 参数1:ptr,指向写入数据指针,可以是数组、结构体、字符串等
② 参数2:size,写入的单个数据元素的大小(单位为字节)
③ 参数3:count,写入的元素个数
④ 参数4:stream,文件指针(FILE *,就是前面 fopen 的返回值)
⑤ 返回值类型:size_t,返回成功写入的元素个数(count)
可以看出 fwrite() 和 fread() 的函数构造是一样的。
🔖示例
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *fp = fopen("numbers.dat", "wb");
if (fp == NULL) {
perror("Error opening file");
return 1;
}
int numbers[] = {1, 2, 3, 4, 5};
size_t elementsWritten = fwrite(numbers, sizeof(int), 5, fp);
if (elementsWritten != 5) {
perror("Error writing file");
}
fclose(fp);
return 0;
}
fwrite() 将整数数组 numbers 中的5个整数写入文件 number.dat,如果写入的元素个数小于预期,程序会打印错误信息
❗️注意:
写入文件时必须使用 "wb" 或 "w" 模式打开文件;使用 "wb" 或 "w" 打开文件时,会清空文件的现有内容(如果文件已经存在)。如果你希望追加数据,而不是覆盖原文件,可以使用 "a" 或 "ab"模式打开文件。
📖4.文件关闭
fclose() 函数用于关闭 fopen() 打开的文件,并释放文件的资源。关闭文件后,不能再通过该文件指针访问文件内容:
🔖语法
#include <stdio.h>
int fclose(FILE *stream);
① 参数:stream,指向FILE对象的指针,表示要关闭的文件
② 返回值类型:int,关闭成功返回0,失败返回 EOF,可以通过 perror() 获取错误信息。
🔖作用
1.冲刷缓冲区:如果文件是以写方式打开的,fclose() 会保证缓冲区的数据被刷新到磁盘,如果有任何未写入的数据,都会被写入目标文件。
2.释放资源:关闭文件后,操作系统会释放与该文件相关的资源(例如文件描述符)。这对于防止资源泄漏非常重要。
3.文件指针失效:文件关闭后,文件指针不再有效。若再次访问该指针,将导致未定义行为。
📖5.默认流指针
fopen()返回的文件指针我们又称之为文件流指针,因为文件本质上是一个数据流,它可以从文件中读取数据,也可以向文件中写入数据。在这种抽象下,文件操作就像处理一个数据流,而文件流指针则是指向这个流的一个句柄。
在C语言中,有三个默认的文件流指针,分别指向标准输入、标准输出和标准错误输出,使得我们无需显式地打开文件即可进行常见的文件操作:
① stdin 是标准输入流,指向键盘输入,可以使用 scanf() 从标准输入读取数据,也可以通过这个流指针,将键盘输入的数据存储到磁盘文件中;
② stdout 是标准输流,指向终端或控制台,可以使用 printf() 将数据输出到标准输出,也可以通过流指针将磁盘文件内容输出到标准输出中;
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg = "hello fwrite\n";
fwrite(msg, strlen(msg), 1, stdout);
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
return 0;
}
③ stderror 是标准错误流,用于输出错误信息。也指向终端或控制台。
📚二、系统调用接口
在操作系统中,文件操作不仅仅是通过标准库函数如 fopen()
, fread()
, fwrite()
, 和 fclose()
实现的,还可以通过系统调用接口直接进行。系统调用提供了低级别、直接的操作系统资源访问方式,包括对文件的操作。这些系统调用通常用于底层编程,它们绕过标准库函数,直接与操作系统内核交互。
📖1.文件打开
在 Linux 系统中,文件的打开操作是通过系统调用 open()
完成的。open()
函数会返回一个文件描述符(而不是 FILE*
指针),这是操作文件的基础:
🔖语法
int open(const char *pathname, int flags, mode_t mode);
① 参数1:pathname,文件路径,指定要打开的文件。
② 参数2:flags,指定文件的打开模式,如:
O_RDONLY
:只读模式
O_WRONLY
:只写模式
O_RDWR
:读写模式
O_CREAT
:如果文件不存在则创建
O_APPEND
:追加模式
③ 参数3:mode,文件的默认权限设置,仅在创建新文件时有效,通常为0644权限位:
0表示当前数字为八进制,我们在设置权限时,要考虑三类用户:所有者,所有组以及其他用户
644表示所有者权限为可读可写不可执行,所有组和其他用户仅可读,不可写不可执行。
④ 返回值:int,打开成功时返回一个非负整数,表示文件描述符;打开失败返回-1。int类型的文件描述符和FILE*指针作用一样,都可以指向文件,前者可以看作数组下标,后者作为指针指向。
🔖示例
#include <fcntl.h>
#include <unistd.h>
int main()
{
int fd1 = open("myfile_1", O_RDONLY); // mode可缺省
int fd2 = open("myfile_2", O_WRONLY, 0664);
}
📖2.文件读取
系统调用 read() 用于从已打开的文件描述符中读取数据:
🔖语法
ssize_t read(int fd, void *buf, size_t count);
① 参数1:fd,文件描述符,通过 open()
获取。
② 参数2:buf,缓冲区,存储读取的数据。
③ 参数3:要读取的字节数。
④ 返回值:ssize_t,成功时,返回实际读取的字节数;失败时,返回 -1(所以这里不能使用size_t作为返回值,而是ssize_t)
🔖示例
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
const char *msg = "hello bit!\n";
char buf[1024];
while(1){
ssize_t s = read(fd, buf, strlen(msg));//类比write
if(s > 0){
printf("%s", buf);
}else{
break;
}
}
close(fd);
return 0;
}
📖3.文件写入
系统调用 write()
用于将数据写入文件:
🔖语法
ssize_t write(int fd, const void *buf, size_t count);
🔖示例
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
umask(0);
int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
if(fd < 0){
perror("open");
return 1;
}
int count = 5;
const char *msg = "hello bit!\n";
int len = strlen(msg);
while(count--)
write(fd, msg, len);
close(fd);
return 0;
}
✅umask()是Linux中设置权限掩码的系统调用,用于控制文件创建的默认权限,调用 umask(0)
将文件创建掩码设置为 0
,意味着没有权限被去除,系统会允许最大权限的创建。
如果调用 umask(002)
,则创建的文件会去掉 2 (即 0002
),那么文件权限将变成 664
,目录权限将变成 775
,即去除其他用户的写权限。
📖4.文件关闭
系统调用 close()
用于关闭打开的文件描述符,释放相关资源:
🔖语法
int close(int fd);
① 参数:fd,文件描述符,通过 open()
获取。
② 返回值:int,成功时,返回 0;
失败时,返回 -1
。
作用与fclose相同,也是冲刷缓冲区以及释放资源。
📚三、底层调用&上层封装
❓C语言标准库函数与系统调用函数都可以实现对文件的访问操作,那么它们之间有什么关联呢?
✅C语言标准库函数是对系统调用的上层封装
📖1.底层调用
底层调用即系统调用,是操作系统提供的接口,允许用户程序与操作系统内核进行交互。当程序需要进行文件操作时,实际上是通过调用操作系统内核提供的系统调用接口完成的,常见的系统调用接口有 open(), write(), read(), close() 等,这些系统调用直接与操作系统的文件系统进行交互。
📖2.上层封装
C语言标准库函数 fopen(), fread(), fwrite(), fclose() 是对操作系统提供的系统调用的封装,它们提供了更高层次的接口,使得使用者不需要直接与操作系统底层交互,能够更便捷地进行文件操作。标准库函数内部实现了文件描述符的管理、缓冲区的操作等,屏蔽了底层的细节。
🔖3.示例
open()
是一个系统调用,直接与操作系统交互,返回一个文件描述符。这个文件描述符可以用于进一步的 read()
、write()
等操作。其实现较为底层,涉及操作系统的文件系统和内存管理。
fopen()
是 C 语言标准库函数,它的内部实现使用了 open()
系统调用来打开文件。除了 open()
,fopen()
还管理了缓冲区的初始化等工作,简化了文件操作过程。fopen()
返回的是一个文件指针(FILE*
),它在标准库内部使用该指针来进行文件操作,而不是直接暴露文件描述符。
✅4.总结
特性 | 系统调用 open() / read() / write() | 系统调用 open() / read() / write() |
功能 | 直接与操作系统交互,底层文件操作 | 提供高层接口,封装底层系统调用 |
返回值 | 文件描述符(int) | 文件指针(FILE* ) |
管理缓冲区 | 不负责缓冲区管理 | 自动管理文件缓冲区(提高效率) |
使用难度 | 较低层,涉及操作系统管理 | 较高层,易于使用,屏蔽底层细节 |
适用场景 | 需要精细控制文件操作的底层程序 | 一般的文件操作,简洁高效的接口 |
📚四、文件描述符fd
文件描述符(File Descriptor,简称fd)是操作系统用来表示已打开文件的整数。它是系统用来跟踪打开文件的标识符,与标准流、系统调用的接口密切相关。
📖1.工作原理
每当程序调用 open()
函数打开一个文件,操作系统会为该文件分配一个文件描述符。文件描述符是一个非负整数,用于在后续的系统调用中标识该文件。
操作系统通常会为每个进程维护一个文件描述符表,其中每个文件描述符对应一个打开的文件或设备。在 Linux 系统中,文件描述符通常从 0 开始分配。0、1、2 是系统默认的标准输入、标准输出和标准错误输出流,而其他文件描述符则用于指向程序显式打开的文件。
🔖示例
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("Error opening file");
return 1;
}
// 使用文件描述符fd读取文件内容
char buffer[100];
ssize_t bytesRead = read(fd, buffer, sizeof(buffer));
if (bytesRead > 0) {
write(1, buffer, bytesRead); // 输出到标准输出
}
close(fd); // 关闭文件描述符
return 0;
}
在这个例子中,程序通过 open()
获取文件描述符 fd
,然后用 read()
读取文件内容,最后用 close()
关闭文件描述符。文件描述符 fd
在操作系统内部对应于打开的文件或设备,操作系统会根据它来执行读取操作。
📖2.分配原则
文件描述符的分配原则是怎么样的呢?来看看下面这段代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
此时fd是3,如果我将0或者2关闭呢:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
//close(2);
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
发现此时fd为0(或者2),由此可以得到文件描述符fd的分配原则:
在files_struct数组当中,找到当前没有被使用的 最小的一个下标,作为新的文件描述符。
📚五、重定向
重定向(Redirection)是操作系统提供的一种机制,允许将程序的输入和输出从默认设备(通常是终端或控制台)重定向到其他设备或文件。重定向通常通过操作系统提供的文件描述符来实现。
例如还是上面那段代码,我们关闭1:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(1);
int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}
此时我们发现,本应该输出到显示器上的内容输出到了文件myfile中,其中fd=1,这种现象叫做输出重定向。常见的重定向有:>, >>, <:
📖1.常见的重定向
🔖>
(输出重定向):
功能: 将命令的标准输出重定向到一个文件中。如果目标文件已经存在,则会覆盖文件内容。
echo "Hello, World!" > output.txt
这会将 "Hello, World!" 输出到 output.txt
文件中,覆盖文件原有内容。
🔖>>
(追加输出重定向):
功能: 将命令的标准输出追加到文件末尾。如果目标文件不存在,则会创建文件。
echo "New line of text" >> output.txt
这会将 "New line of text" 追加到 output.txt
文件的末尾。
🔖<
(输入重定向):
功能: 将文件的内容作为标准输入传递给命令。
sort < input.txt
这会将 input.txt
文件的内容传递给 sort
命令进行排序。
这三种重定向符号是最常见的,用于控制数据流向文件或从文件读取数据。在复杂的脚本或命令行操作中,它们非常有用,能够帮助用户将输出存储到文件中或从文件中读取数据。
📖2.本质
重定向的本质是改变数据流的方向,每个文件描述符(如 0
, 1
, 2
)都关联一个 file_struct
(文件结构体)。当进行重定向操作时,操作系统需要首先清空当前文件描述符的相关信息,然后修改文件描述符的指向,例如将2重定向到1时:
① 清除 2 指向的文件结构体内容;
② 修改 2 的指向,使其指向 1 所指向的文件结构体内容。
📖3.dup2系统调用
dup2
是一个用于文件描述符复制的系统调用,它的作用是将一个现有的文件描述符复制到另一个文件描述符上,替换掉目标文件描述符原有的内容。
🔖语法
int dup2(int oldfd, int newfd);
① oldfd:源文件描述符,表示要复制的现有文件描述符;
② newfd:目标文件描述符,表示复制到该文件描述符。如果该文件描述符已经打开,则它会被关闭,然后复制 oldfd
的内容。
🔖示例
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
int fd = open("./tmp.txt", O_RDWR|O_CREAT, 0664);
if (fd < 0)
return -1;
dup2(fd, 1);
printf("i like linux!\n");
return 0;
}
这里我们将标准输出重定向到文件tmp.txt中,执行结果:
📚六、总结
在 C 语言中,标准库函数提供了较高层次的抽象,使得文件操作变得简便易用。我们通过 fopen()
打开文件,利用 fread()
和 fwrite()
进行读写操作,并通过 fclose()
关闭文件。这些操作的实现背后,实际上是依赖于操作系统提供的低级系统调用,如 open()
、read()
、write()
和 close()
。这些系统调用直接与操作系统内核进行交互,提供了更精细的控制。
通过对比系统调用与标准库函数的使用场景,我们可以更清楚地理解它们各自的优势和适用范围。标准库函数封装了底层细节,适合一般的文件操作,而系统调用则提供了更低层次、更精细的操作,适合需要高性能和底层控制的场景。
以上就是【文件操作的艺术——从基础到精通】的全部内容,欢迎指正~
码文不易,还请多多关注支持,这是我持续创作的最大动力!