目录
一、回顾C文件
文件 = 内容 + 属性
- 对文件的操作:a.对内容操作 b.对属性操作
- 内容是数据,属性其实也是数据 —— 存储文件,必须既存储内容,又存储属性数据 —— 默认文件是在磁盘中的。
- 要访问一个文件的时候,要先把这个文件打开。
把含有打开文件的代码(fopen)编译成可执行程序,并将程序运行起来,在执行fopen函数时才会打开这个文件。进程是Linux中最常见的文件打开者。 - 文件打开前,是普通的磁盘文件。打开后,文件被加载到内存。
- 一个进程可以打开多个文件、多个进程可以打开多个文件。加载到内存中被打开的文件,可能会存在多个。
- 加载磁盘上的文件,一定会涉及到磁盘设备,该操作由OS执行。
- 操作系统在运行中,可能会打开很多个文件,也需要管理很多文件,就需要“先描述再组织”
- 一个文件要被打开,一定要先在内核中形成被打开的文件对象,该对象包含文件的很多属性。例如 struct file
- 于是对打开文件的管理,就变成了对链表的增删查改。
- 对文件管理的学习,就要研究被打开的文件在内存中的表现,和没有被打开的文件在磁盘中如何存储。
fopen函数
#include <stdio.h>
FILE *fopen(const char *filename, const char *mode);
参数:
- filename:指向包含文件路径的字符串指针。
- mode:指向包含文件打开模式的字符串指针。
文件打开模式:
- r:以读取方式打开文件,文件必须存在。
- w:以写入方式打开文件,如果文件已存在,会将文件长度截断成0,从文件开头处写入;文件不存在则创建文件。类似echo "..." > log.txt
- a:以追加方式打开文件,写入数据将添加到文件末尾。类似echo "..." >> log.txt
- +:允许读写文件。
- b:以二进制模式打开文件(通常用于非文本文件)。
- t:以文本模式打开文件(默认模式)。
二、系统文件I/O
综上所述,文件是在磁盘中的,要被访问就要先被打开,打开文件的本质是将文件加载到内存里,加载的过程由操作系统完成,因此一个进程需要通过操作系统打开文件。操作系统管理硬件,会提供许多系统调用接口。C语言打开文件的接口,底层一定封装了系统调用接口。
w 和 a 方式打开文件,底层调用的就是open。
2.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);参数:
- pathname:指向要打开或创建的目标文件的字符串指针。
- flags:标志位,指定打开文件的选项。
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写。
O_TRUNC:如果文件已存在,则截断文件内容。- mode:指定文件权限的模式,仅在 O_CREAT 标志被设置时使用。
返回值:
- 文件成功打开,返回一个非负整数的文件描述符。
- 文件打开失败,返回 -1,并且可以通过 errno 获取错误信息。
注:
- open 函数具体使用哪个,和具体应用场景相关。如目标文件不存在,需要open创建,此时第三个参数表示创建文件的默认权限,否则使用两个参数的open。
- open 创建打开文件时如果flags设置为O_WRONLY | O_CREAT | O_TRUNC,且没有设置mode权限,文件被创建为具有默认权限的文件,通常是 0644。
- 创建文件时,权限为:mode & ~umask(权限掩码),可使用系统调用umask()设置文件创建时的权限掩码。可以直接设置umask(0),这样设置的mode就是新创建文件的权限。但是这种方法不推荐,推荐使用系统默认的umask。
#include <sys/types.h>
#include <sys/stat.h>mode_t umask(mode_t mask);
fopen的w方式打开相当于open选项的O_WRONLY | O_CREAT | O_TRUNC,即按照写方式打开,如果文件不存在就创建它,打开时会先清空文件内容。
fopen的a方式打开相当于open选项的O_WRONLY | O_CREAT | O_APPEND
由此可见,C语言提供的库函数会封装某些底层的系统调用。
open创建新文件时先有struct file,再去同步磁盘
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
int main()
{
//FILE *fp = fopen("log.txt","w");
//...
//fclose(fp);
//umask(0);
int fd = open("log.txt",O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
const char* msg = "aaaaaaaa\n";
write(fd, msg, strlen(msg));//strlen不需要+1
int fd1 = open("log1.txt",O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd2 = open("log2.txt",O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd3 = open("log3.txt",O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd4 = open("log4.txt",O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd1 : %d\n",fd1);
printf("fd2 : %d\n",fd2);
printf("fd3 : %d\n",fd3);
printf("fd4 : %d\n",fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
2.2 标志位传参
一般标志位是两态的数据(0/1),那么就可以用 flags 中32个不同的比特位传参。这样就可以实现同时传递多个比特位。
#include <stdio.h>
#define Print1 1 //0001
#define Print2 (1<<1) //0010
#define Print3 (1<<2) //0100
#define Print4 (1<<3) //1000
void Print(int flags)
{
if(flags&Print1) printf("hello 1\n");
if(flags&Print2) printf("hello 2\n");
if(flags&Print3) printf("hello 3\n");
if(flags&Print4) printf("hello 4\n");
}
int main()
{
Print(Print1);
Print(Print1 | Print2);
Print(Print1 | Print2 | Print3);
Print(Print1 | Print2 | Print3 | Print4);
return 0;
}
2.3 系统调用 write
write :用于向文件描述符指定的文件或设备写入数据。
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
参数:
- fd:文件描述符,用于指定要写入的文件或设备。
- buf:指向要写入数据的缓冲区的指针。
- count:要写入的字节数。
返回值:
- 写入成功,返回实际写入的字节数。
- 写入失败,返回 -1,并且可以通过 errno 获取错误信息。
注:
- 当我们想向一个文件中写入字符串的时候,count的值不需要strlen()+1,因为 \0 结尾是C语言的规定,不是文件的规定!
- 使用默认的写方式打开文件(O_WRONLY),write写入内容时,没有将文件原内容清空,而是覆盖式的写入。
- 如果想写入文件时把文件清空,需要在标志位选项上加上O_TRUNC,表示截断文件内容。
2.4 文件描述符fd
文件描述符fd(file descriptor)是一个非负整数,用于标识一个打开的文件。
内存中有许多进程和进程打开的文件,于是操作系统就要解决进程和打开文件对应关系的维护问题!
struct file 是被打开文件的描述结构体。操作系统在内核中,为进程设置了一个指针files,指向结构体 struct files_struct,该结构体包含一个指针数组struct file* fd_array[],数组中的每个元素都是一个指向 struct file 的指针,当进程打开一个文件时(open),OS创建了一个struct file,将该file结构体的地址填入到指针数组中,此时open会向上层返回填入数组位置的下标,这个下标就是文件描述符,files_struct就是进程文件描述符表。
使用write时,第一个参数就是fd,就可以根据fd找到对应的 struct file。
在上面示例的fd数字为4、5、6、7,(3是第一个fd)没有0、1、2。
原因:
进程在运行时,默认打开3个文件:
- 标准输入(键盘) stdin 0
- 标准输出(显示器) stdout 1
- 标准错误(显示器) stderr 2
它们在进程文件描述符表中以及占据了前3个,所以后续文件从下标3开始。
stdin、stdout、stderr是 FILE* 类型,在C语言中 FILE 是一个结构体类型。因为操作系统访问文件只认文件描述符,所以FILE结构体中也必定要封装文件描述符。结果是FILE结构体中确实有文件描述符,名称为 _fileno。
printf("stdin->fd: %d\n",stdin->_fileno);
printf("stdout->fd: %d\n",stdout->_fileno);
printf("stderr->fd: %d\n",stderr->_fileno);
FILE *fp = fopen("log.txt","w");
printf("fp->fd: %d\n",fp->_fileno);
fclose(fp);
OS/C语言为什么默认要把0,1,2,stdin,stdout,stderr打开呢?
答:是为了让程序员默认进行标准的输入输出代码编写!
如何理解“一切皆文件”?
在Linux中,所有的输入和输出都被抽象为文件。这意味着无论是从键盘输入、从文件读取,还是向屏幕输出、向文件写入,都使用文件描述符来处理。
那么如何使用文件描述符来处理? ——每个文件描述符对应着一个文件,即对应着一个strcut file。strcut file封装了与文件操作相关的函数指针,如 read、write,分别指向对应硬件的读和写方法(硬件被操作系统“先描述再组织”,每个硬件设备对应的结构体有它自己的读和写方法,因为硬件的作用就是为了实现某些读或者写操作)。从此之后读取硬件不需要使用硬件的读写方法,而是使用 file 结构体中的函数指针调用。这一结构体层称作VFS(虚拟文件系统),用户不再需要关注底层硬件的差异,只用关注文件(因为读写方法全都一样)。这种操作类似于C++中的多态。虚拟文件系统看作基类,硬件对应子类,基类是一层虚方法,不实现具体的方法,而是由子类实现具体的方法。
补充:
- 文件操作系统调用:所有的文件操作,如读取(read)、写入(write)、打开(open)、关闭(close)等,都是通过系统调用进行的。这些系统调用与文件操作密切相关。
- 用户空间和内核空间:文件操作接口在用户空间和内核空间之间提供了一个统一的接口。用户空间程序通过文件描述符和系统调用与内核空间交互,实现文件操作。
- 设备驱动程序:设备驱动程序是内核的一部分,它们将硬件设备与文件系统中的设备文件关联起来。驱动程序通过文件操作接口与用户空间程序交互。
通过“一切皆文件”的概念,Linux系统提供了一个统一的方式来处理不同的输入和输出,这使得系统更加模块化、易于管理和扩展。需要注意的是,虽然从用户空间的角度来看,所有输入和输出都是通过文件描述符处理的,但在内核中,这些操作的实现可能会依赖于特定的设备驱动程序和硬件接口。
虚拟文件系统(Virtual File System,VFS)是内核的一个抽象层,它提供了一个统一的接口,使得不同的文件系统可以被内核和用户空间应用程序以一致的方式访问。VFS的主要作用是将底层的文件系统实现细节抽象化,从而允许用户空间应用程序和内核中的其他模块无需关心具体文件系统的实现细节。
2.5 struct file
在Linux内核中,struct file 是一个关键的数据结构,用于表示打开的文件。它包含了与文件操作相关的信息,如文件操作函数指针、文件状态、文件访问权限等。struct file 是内核中文件系统抽象层(VFS)的一部分,它为用户空间程序和内核中的其他模块提供了一个统一的接口,用于执行文件操作。
类似于task_struct,struct file 在磁盘中不存在,而是打开文件时,操作系统在内核中创建的struct file的节点,属于内核数据结构,专门用来管理被打开文件。这个文件不止指磁盘文件,还包括磁盘设备本身、键盘显示器等。
struct file 结构体包含了指向文件缓冲区的指针(f_mapping 字段),这些缓冲区用于缓存文件操作的数据,以提高文件访问的效率。
当内核要读取磁盘中的文件数据,根据冯诺依曼体系结构,需要先把文件数据加载到文件缓冲区(内存)。内核需要读取文件数据时,它会首先检查文件缓冲区中是否已经缓存了所需的数据。
- 如果读取文件数据时发现数据不在文件缓冲区中,此时发生缺页中断,将磁盘中的数据加载到缓冲区中。
- 如果数据在缓冲区中,内核可以直接从缓冲区中读取,而无需触发缺页中断。
- 无论读写,都要先把数据加载到文件缓冲区中。
- 我们在应用层进行数据的读写,本质是将内核缓冲区中的数据进行来回拷贝!
文件操作方法集 f_op
f_op 是一个struct file 中的字段,它指向一个 struct file_operations 数据结构。这个数据结构包含了与文件操作相关的函数指针集合。
2.6 fd的分配规则
- 进程默认已经打开了0,1,2,我们可以直接使用0,1,2进行数据的访问!
- 文件描述符的分配规则是,寻找最小的,没有被使用的数据的位置,分配给指定的打开文件!(文件描述符可以被复用)
- 文件关闭时,fd_array中对应的数据被清空。内核会将其标记为可复用,并在下次需要打开文件时重新分配给新的文件。
- 当一个进程通过 fork 创建一个子进程时,子进程会继承父进程的文件描述符表。子进程可以使用与父进程相同的文件描述符来访问相同的文件。
2.7 重定向
2.7.1 基本原理:
文件描述符1删除时,无法打印内容到显示器。新创建的文件的fd为1,再向1打印就是向这个文件中打印。(这个过程叫做输出重定向)
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n",fd);
printf("stdout->fd: %d\n",stdout->_fileno);
fflush(stdout);
close(fd);
现象:不向显示器打印,而是向文件打印。
原因: close(1)后,新打开的文件的fd为1,printf函数只认标准输出(stdout),而stdout的_fileno为1,所以printf函数向1号文件描述符对应的文件打印,不管1号文件对应的是不是显示器。
(注:printf("fd: %d\n",fd); 相当于 fprintf(stdout, "fd: %d\n",fd); )
所以完成重定向功能,只要更改fd_array内容(下标不变)即可。
重定向的本质,其实就是修改文件描述符表特定数组下标的内容。
如果没有 fflush() ,打印的内容不会打印到对应的文件中。因为C语言提供了两种缓冲区:用户级缓冲区和内核级缓冲区。使用printf、fprintf时,要打印的数据先拷贝在用户级缓冲区,然后fflush()再将缓冲区数据拷贝到fd指定文件里。(在后面缓冲区部分讲述)
如果将上面open的选项改为:O_CREAT | O_WRONLY | O_APPEND ,代码的工作就变成了追加重定向
输入重定向如下:(本来应该从键盘上输入)
close(0);
int fd = open("log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
char buffer[1024];
fread(buffer, 1, sizeof(buffer), stdin);
printf("%s\n",buffer);
close(fd);
以上的重定向操作都需要关闭原本的文件、再打开新的文件,这种操作有些繁琐而且还容易出错。
2.7.2 系统调用 dup2
Linux中提供了一个系统调用 dup2 ,用于复制一个已存在的文件描述符到另一个已存在的文件描述符。实现文件描述符表级别的数组内容的拷贝。
#include <unistd.h>
int dup2(int oldfd, int newfd, int flags);
参数:
- oldfd:要复制的文件描述符。
- newfd:目标文件描述符,dup2 会将 oldfd 的内容复制到这个位置。
- flags:可选参数,用于指定复制行为。通常使用 0,表示复制后关闭 oldfd。
返回值:
- 成功,dup2 返回新的文件描述符值。
- 失败,返回 -1,并且可以通过 errno 获取错误信息。
功能:
- 将 oldfd 指向的文件描述符的内容复制到 newfd 指向的位置,如果 newfd 已经打开,它会先关闭 newfd。这意味着 oldfd 和 newfd 最终指向同一个文件描述符。
// 打开文件
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1) {
perror("open failed");
return 1;
}
printf("%d\n",stdout->_fileno);
// 将标准输出重定向到文件描述符
if (dup2(fd, stdout->_fileno ) == -1) {
perror("dup2 failed");
close(fd);
return 1;
}
// 关闭原始文件描述符
close(fd);
// 输出内容到文件
printf("Hello, World!\n");
// 关闭文件描述符
close(stdout->_fileno);
我们向标准输出写入一些内容,这些内容实际上会被写入 log.txt 文件,因为标准输出现在指向这个文件。
2.8 标准错误
在Linux中,标准错误(stderr)通常用于输出错误消息和警告信息,而不是程序的常规输出。
- 标准错误流被分配文件描述符2
- 可以通过重定向操作符 2> 或 2>> 将标准错误重定向到文件
- 标准错误流和标准输出流是独立的,可以分别重定向到不同的目的地。
如果要将标准错误重定向到标准输出,可以使用 2>&1 。
类似于 ls -a -l > log.txt ,将打印的内容重定向输出到log.txt文件,可以理解成
ls -a -l 1>log.txt ,即把输入到 1(stdout) 的内容输入到 log.txt。那么 2>&1 就是把要输出到2(stderr)的内容重定向输入到1对应的内容中。./mybin 1 > log.txt 2>&1 或 ./mybin > log.txt 2>&1
结果:把mybin的标准错误和标准输出都重定向到 log.txt 中
为什么要有标准错误?
答:
- 区分正常输出和错误输出:标准错误用于输出程序执行过程中的警告和错误信息,而标准输出用于输出程序的正常输出和结果。这样的区分使得用户和开发者可以更容易地识别和处理程序的错误,而不需要区分正常输出和错误输出。
- 多目的地输出:标准错误允许程序将错误输出重定向到不同的目的地,如文件或终端。例如,如果程序的输出被重定向到一个文件,而错误信息仍然需要显示在终端上。
- 调试和日志记录:标准错误流通常被用于输出调试信息和日志记录。由于标准错误输出可以被重定向到文件,这使得记录程序执行过程中的错误和警告信息变得更加容易。
- 交互式和脚本环境:在交互式环境中,标准错误输出通常会显示在终端上,这有助于用户立即识别和处理程序执行过程中的问题。在脚本环境中,标准错误输出可以被重定向到一个日志文件,以便在脚本执行后查看错误信息。
- 多进程和子进程:子进程会继承父进程的标准错误流。这意味着如果父进程的标准错误被重定向,子进程的标准错误也会被重定向到相同的位置。