一、文件操作
1. 文件预备知识
我们在学习下面文件的内容之前,先预备一些基础的文件知识:
- 文件 = 内容 + 属性,对文件的操作就是对文件内容和文件属性的操作。
- 当文件没有被操作的时候,文件一般都是在磁盘上存放。
- 当我们对文件操作时,文件都会被提前加载到内存中,加载的内容至少得有属性。
- 当文件被加载到内存中时,在Linux下并不一定只有你一个人在打开文件,内存中一定存在大量的不同文件的属性。
- 因此,打开文件的本质就是将文件属性加载到内存中,OS中一定存在大量被打开的文件,操作系统对这些被打开的文件进行管理需要
先描述、再组织
,所以需要先构建在内存中的文件结构体(struct file
)。- 文件可以被分为两部分:
磁盘文件
和被打开的文件(内存文件)
。- 文件是被OS所打开的,是被用户所创建的进程让OS打开的。
- 我们之前所有的文件操作,都是进程(
struct task_struct
) 和被 打开文件(struct file
) 的关系。
2. 回顾C文件操作
我们曾经学过C语言的文件操作,那么是不是只有C语言有文件操作呢?答案显然是否定的,因为无论哪一门语言(Python、Java、php、go...)他们都有对应的文件操作。无论上层语言如何变化,该语言对应的库函数底层都必须调用文件级别的系统调用来完成对文件的操作。
下面我们来回顾一下C语言的文件操作:
这里我们需要强调一点:如果没有指明路径,则默认在当前路径下进行文件操作。💕 w
的方式打开,向文件中写入数据(==以写的方式打开文件,如果文件不存在则创建文件==)
💕 r
的方式从文件中读取数据(==以读的方式打开文件,如果文件不存在则报错==)
💕 a
的方式打开,向文件中追加数据(==以追加的方式打开文件,如果文件不存在则打开失败==)
3. 文件操作的系统调用
操作文件,除了上述C接口(当然,C++也有接口,其他语言也有),我们还可以采用系统接口来进行文件访问:
上面的fopen fclose fread fwrite
都是C标准库当中的函数,我们称之为==库函数(libc)==。而, open close read write lseek
都属于系统提供的接口,称之为==系统调用接口==。
#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: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
返回值:
成功:新打开的文件描述符
失败:-1
参数:
O_RDONLY
: 只读打开O_WRONLY
: 只写打开O_RDWR
: 读,写打开
这三个常量,必须指定一个且只能指定一个O_CREAT
: 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限O_APPEND
: 追加写
标志位的传递
在C语言中我们经常用一个整形来传递选项,但是如果如果选项较多时,就会造成空间的浪费,这里我们可以通过使用一个比特位表示一个标志位,这样一个int就可以同时传递至少32个标志位,此时的flag就可以看待成位图
的数据类型。
#include <stdio.h>
//每个宏只占用一个比特位,该比特位为1说明选项成立。
#define ONE 0x1
#define TWO 0x2
#define THREE 0x4
#define FOUR 0x8
#define FIVE 0x10
// 0000 0000 0000 0000 0000 0000 0000 0000
//按位与的结果为1,说明flags对应的比特位为1
void Print(int flags)
{
if(flags & ONE) printf("hello 1\n"); //充当不同的行为
if(flags & TWO) printf("hello 2\n");
if(flags & THREE) printf("hello 3\n");
if(flags & FOUR) printf("hello 4\n");
if(flags & FIVE) printf("hello 5\n");
}
int main()
{
printf("--------------------------\n");
Print(ONE);
printf("--------------------------\n");
Print(TWO);
printf("--------------------------\n");
Print(FOUR);
printf("--------------------------\n");
Print(ONE|TWO);
printf("--------------------------\n");
Print(ONE|TWO|THREE);
printf("--------------------------\n");
Print(ONE|TWO|THREE|FOUR|FIVE);
printf("--------------------------\n");
return 0;
}
open
open
函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要open创建,则第三个参数表示创建文件的默认权限,否则,使用两个参数的open。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>
#include <unistd.h>
#define LOG "log.txt"
int main()
{
int fd = open(LOG, O_WRONLY|O_CREAT);
if(fd < 0)
{
perror("open");
return 1;
}
close(fd); // close关闭文件,参数是对应的文件描述符
return 0;
}
这里我们发现创建出来的文件权限是乱的,这里我们就需要传入open的第三个参数了,我们可以先使用umask系统调用设置为0,通过手动设置当前进程的权限掩码,从而不使用父进程继承过来的umask。
write
write表示向文件中写入数据,下面我们来举例看一下:
ssize_t write(int fd, const void* buf, size_t count);
# 头文件:<unistd.h>
# fd:目标文件的文件描述符
# buf:要写入数据的来源
# count:要写入数据的字节数
# ssize_t:函数返回值,写入成功返回成功写入的字节数,写入失败返回-1
这里我们需要注意几点:
- 向文件中写入数据时,==如果不指定O_TRUNC选项,新的数据就会覆盖原来的数据==。
- C语言中字符串是以’\0‘结尾的,==但是文件中的字符串并不以'\0'结尾==,所以当我们向文件中写字符串时,==strlen(str)后面不需要+1==,如果要是加上的话就会出现乱码。
如果我们想要每次都向文件中追加内容怎么办呢?
read
read表示从文件中读取数据
ssize_t read(int fd, void* buf, size_t count);
# 头文件:<unistd.h>
# fd:目标文件的文件描述符
# buf:读取数据存放的位置
# count:要读取数据的字节数
# ssize_t:函数返回值,读取成功返回读取写入的字节数,读到文件末尾返回0,读取失败返回-1
由于C语言字符串以'\0'
结尾,但是文件字符串数据并不包含'\0',所以这里我们需要预留一个位置,这样的话无论如何buf数组的末尾都能够存放'\0'。
二、文件描述符
1. 文件描述符的理解
进程可以打开多个文件,这也就意味着系统中一定会存在大量的被打开的文件,然而被打开的文件则需要被操作系统管理,我们知道,管理的本质就是先描述在组织,所以操作系统为了管理对应的打开文件,操作系统必定要为文件创建对应的内核数据结构来标识文件,这个内核数据结构就是struct file{}结构体(与C语言的FILE没有关系哦);包含了文件的大部分属性。
而进程和被打开的文件如何关联,也就是说进程和被打开文件的关系是如何维护的?
通过文件打开(open)的返回值和文件描述符进行联系。下面我们通过代码来看一看返回值究竟是多少。
这里我们可以看到文件描述符是从3开始的,C 语言程序会默认打开三个流:stdin(标准输入流:键盘)、stdout(标准输出流:显示器)和 stderr(标准错误流:显示器)。这三个流的类型都是FILE,而FILE是结构体。C 语言进行文件操作是使用的是`FILE`,而操作系统使用的是文件描述符fd,==那么结构体FILE中肯定包含文件描述符fd==。所以 0、1、2 就被这三个流使用了。
进程的tast_struct里面有一个struct files_struct files 指针变量,它指向一个该进程的数据结构对象struct files_struct,该对象中包含了一个指针数组 struct file fd_array[],即进程的文件描述符,数组里面的每一个元素都是一个指针,指向一个struct file对象,这个数组的下标就是我们所得到的用户描述符fd。所以,文件描述符是从0开始的小整数,本质上是文件描述符表中的数组下标。
进程会通过进程控制块(
task_struct
)中的files变量找到 files_struct结构体,==再通过files_struct 中的文件描述符找到具体文件的内核数据结构file,从而实现数据的读取和写入。==
当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
💕 如何理解Linux下一切皆文件?
当我们打开一个文件时,操作系统会将该文件加载到内存,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。file结构体中不仅有文件的各种属性(文件的权限、文件的大小...
),还有自己的缓冲区,当然了,最后面还有两个函数指针,那么这两个函数指针又是做什么的呢?
其实由于Linux操作系统出现的比C++要早,所以当时没有面向对象的语言程序设计,所以这里使用函数指针来保存函数的地址从而实现面向对象的功能。每一个外设都有对应的IO读写方法,在我们的操作系统的硬件之上有一个该外设对应的驱动程序,该驱动程序层中保存了对应了各个IO设备的读写方法。file结构体中的函数指针就保存了该方法的地址。如果我们需要写入或者读取文件中的信息,只需要通过struct file结构体中的函数指针找到方法的地址调用对应的方法即可。
2. 文件描述符的分配规则
上面我们已经了解了文件描述符,那么他又是如何给我们的文件进行分配的呢?
为什么文件描述符是从数组下标为3的地方开始的呢?数组下标0,1,2又去哪儿了呢?其实,这三个下标是被默认打开的三个标准流所占用了——标准输入流stdin、标准输出流stdout 和 标准错误流stderr
。他们分别对应键盘文件、显示器文件和显示器文件。因此我们默认打开其他文件时候是从3号开始分配的。
int main()
{
printf("stdin->fd:%d\n",stdin->_fileno);
printf("stdtout->fd:%d\n",stdout->_fileno);
printf("stderr->fd:%d\n",stderr->_fileno);
return 0;
}
我们知道,C语言中的fopen接口的返回值是FILE* ,其中FILE是一个结构体类型,因为fopen底层是调用了open接口的,所以FILE中一定封装了一个变量来表示fd,这个变量是_fileno
。
这里,当我们将0和2号文件描述符关闭后,系统将会自动将他们分配给我们新打开的文件。==所以,文件描述符的分配规则是,从小到大依次搜寻,寻找未被使用的最小的fd作为新打开文件的fd。==
三、重定向
1. 重定向的本质
int main()
{
close(1);
int fd = open(LOG,O_WRONLY|O_CREAT,O_TRUNC,0666);
assert(fd != -1);
printf("hello world\n");
fprintf(stdout,"hello chenjiale\n");
fflush(stdout);
close(fd);
return 0;
}
1号文件描述符对应的是标准输出(显示器)文件。printf默认是向显示器打印的,当我们将1号文件描述符关闭后,1号文件描述符将会分配给我们新打开的文件,所以数据就会被打印到log.txt
文件里。
这种现象就是 重定向
,常见的重定向有输出重定向>
,输入重定向<
和追加重定向>>
。重定向的本质就是:上层使用的fd不变,在内核中更改fd对于struct file*
的地址(同一个fd指向不同的file对象)。
2. dup2系统调用
Linux操作系统中为我们提供了一个系统调用接口dup2来让我们直接进行冲定向。
int dup2(int oldfd, int newfd);
# 头文件:<unistd.h>
# oldfd:旧的文件描述符
# newfd:新的文件描述符
# int:函数返回值,成功返回 newfd,失败返回-1
💕 输出重定向
int main()
{
int fd = open(LOG,O_WRONLY|O_CREAT,O_TRUNC,0666);
assert(fd != -1);
int ret = dup2(fd,1);
if(ret == -1){
return -1;
}
printf("hello,fd:%d\n",fd);
fprintf(stdout,"hello,fd:%d\n",fd);
fflush(stdout);
close(fd);
return 0;
}
这里我们需要注意的是,dup2的系统调用让newfd成为old的一份拷贝,本质就是将oldfd下标里面存放的file对象的地址拷贝到newfd下标的空间中,拷贝的是fd对应空间中的数据,而并不是两个fd数字之间进行拷贝。
💕 追加重定向
追加重定向和输出重定向的不同点在于在打开文件的时候将O_TRUNC
选项去掉,然后换成O_APPEND
选项。
int main()
{
int fd = open(LOG,O_WRONLY|O_CREAT,O_APPEND,0666);
assert(fd != -1);
int ret = dup2(fd,1);
if(ret == -1){
return -1;
}
const char* msg = "hello I want to IDM\n";
write(1,msg,strlen(msg));
fflush(stdout);
close(fd);
return 0;
}
💕 输入重定向
int main()
{
int fd = open(LOG,O_RDONLY);
assert(fd != -1);
int ret = dup2(fd,0);
if(ret == -1){
return -1;
}
char buf[64];
while(fgets(buf,sizeof(buf) - 1,stdin)!=NULL)
{
buf[strlen(buf)] = '\0';
printf("%s",buf);
}
close(fd);
return 0;
}
四、缓冲区
进程向磁盘文件中写数据时,由于磁盘属于外设,进程直接向磁盘文件中写数据的效率非常低,所以有了缓冲区,进程可以将自己的数据拷贝到缓冲区中,再由缓冲区将数据写入到磁盘文件中去。在计算机中,缓冲区的意义是节省进程进行数据 IO 的时间。虽然我们认为 fwrite 是将数据写入到文件的函数,但 fwrite 本质上是进行数据拷贝的函数,因为 fwrite 函数只是将数据从进程拷贝到缓冲区中,并没有真正将数据写入到磁盘文件中。
1. 缓冲区的刷新策略
💕 三种刷新策略:
- 立即刷新 (
无缓冲
): 缓冲区中一出现数据就立马刷新,这种很少出现;- 行刷新 (
行缓冲
): 每拷贝一行数据就刷新一次,显示器采用的就是这种刷新策略,因为显示器是给人看了,而按行刷新符合人的阅读习惯,同时刷新效率也不会太低;- 缓冲区满 (
全缓冲
): 待数据把缓冲区填满后再刷新,这种刷新方式效率最高,一般应用于磁盘文件。
💕 两种特殊情况:
- 用户使用 fflush 等函数强制进行缓冲区刷新
- 进程退出时一般都要进行缓冲区刷新
int main() {
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
const char* msg = "hello write\n";
write(1, msg, strlen(msg));
return 0;
}
当我们在程序结尾使用fork函数创建一个子进程后:
int main() {
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
const char* msg = "hello write\n";
write(1, msg, strlen(msg));
fork();
return 0;
}
这是什么原因呢?为什么 write 函数重定向了一次,但是其他三个函数却重定向了两次呢?下面我们会细细解释一下。
2. 缓冲区的位置
我们谈论的所有缓冲区都不在操作系统内核中,而是位于用户级语言层面;实际上,对于C语言来说,缓冲区位于 FILE 结构体中。
对于C语言的printf、fwrite、fputs 等库函数会自带缓冲区,而 write 系统调用没有带缓冲区;同时,我们这里所说的缓冲区,都是用户级缓冲区。那这个缓冲区谁提供呢? printf、fwrite、fputs 是库函数, write 是系统调用,库函数在系统调用的 “上层”, 是对系统调用的 “封装”,但是 write 没有缓冲区,而 printf、fwrite、fputs 有,足以说明该缓冲区是二次加上的,又因为是C库函数,所以是由C标准库提供的。
显示器采用行缓冲,所以在 fork 之前 printf、fprintf、fputs 三条语句的数据均已刷新到显示器上了,而对于进程数据来说,如果数据位于缓冲区内,那么该数据属于进程,此时 fork 子进程也会指向该数据;但如果该数据已经写入到磁盘文件了,那么数据就不属于进程了,此时 fork 子进程也不在指向该数据了;所以,这里 fork 子进程不会做任何事情。
我们使用重定向指令将本该写入显示器文件的数据写入到磁盘文件中,而磁盘文件采用全缓冲,所以 fork 子进程时 printf、fprintf、fputs 的数据还存在于缓冲区中 (缓冲区没满,同时父进程还没有退出,所以缓冲区没有刷新),也就是说,此时数据还属于父进程,那么 fork 之后子进程也会指向该数据;而 fork 之后紧接着就是进程退出,父子进程某一方先退出时会刷新缓冲区,由于刷新缓冲区会清空缓冲区中的数据,为了保持进程独立性,先退出的一方会发生 写时拷贝,然后向磁盘文件中写入 printf、fprintf、fputs 三条数据;然后,后退出的一方也会进行缓冲区的刷新;所以,最终 printf、fprintf、fputs 的数据会写入两份 (父子进程各写入一份),且 write 由于属于系统调用没有缓冲区,所以只写入一份数据且最先写入。
3. 简单模拟实现缓冲区
mystdio.h
#pragma once
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define SIZE 1024 // 缓冲区大小
#define SYNC_NOW 1 // 无缓冲
#define SYNC_LINE 2 // 行缓冲
#define SYNC_FULL 4 // 全缓冲
typedef struct FILE_
{
int flags; // 缓冲区刷新策略
int fileno; // 文件描述符
int size; // buffer当前的使用量
int capacity; // buffer的总容量
char buffer[SIZE]; //缓冲区
}FILE_;
FILE_* fopen_(const char* pathname, const char* mode);
void fwrite_(const void* ptr, int num, FILE_* fp);
void fflush_(FILE_* fp);
void fclose_(FILE_* fp);
mystdio.c
#include "myStdio.h"
FILE_* fopen_(const char* pathname, const char* mode)
{
int flags = 0;
int defaultMode = 0666; // 默认创建权限
if(strcmp(mode, "r") == 0)
{
flags |= O_RDONLY;
}
else if(strcmp(mode, "w") == 0)
{
flags |= (O_WRONLY | O_CREAT | O_TRUNC);
}
else if(strcmp(mode, "a") == 0)
{
flags |= (O_WRONLY | O_CREAT | O_APPEND);
}
else
{
// TODO:r+, w+...
}
int fd = 0;
if(flags & O_RDONLY) fd = open(pathname, flags);
else fd = open(pathname, flags, defaultMode);
if(fd < 0)
{
const char* err = strerror(errno);
write(2, err, strlen(err));
return NULL; // 打开文件失败返回NULL的原因
}
FILE_* fp = (FILE_*)malloc(sizeof(FILE_));
assert(fp != NULL);
fp->flags = SYNC_LINE; // 默认设置成行刷新
fp->fileno = fd;
fp->size = 0;
fp->capacity = SIZE;
memset(fp->buffer, 0, SIZE);
return fp; // 打开文件成功返回FILE*的原因
}
void fwrite_(const void* ptr, int num, FILE_* fp)
{
// 数据写入到缓冲区
memcpy(fp->buffer + fp->size, ptr, num); // 这里不考虑缓冲区溢出的问题
fp->size += num;
// 是否刷新缓冲区
if(fp->flags & SYNC_NOW)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0; // 清空缓冲区
}
else if(fp->flags & SYNC_LINE)
{
// 不考虑abcd\nef的情况
if(fp->buffer[fp->size - 1] == '\n')
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0; // 清空缓冲区
}
}
else if(fp->flags & SYNC_FULL)
{
if(fp->size == fp->capacity)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0; // 清空缓冲区
}
}
else
{
return;
}
}
void fflush_(FILE_* fp)
{
if(fp->size > 0) write(fp->fileno, fp->buffer, fp->size);
fsync(fp->fileno); // 强制刷新内核缓冲区,将数据刷新到外设中
fp->size = 0; // 清空缓冲区
}
void fclose_(FILE_* fp)
{
fflush_(fp);
close(fp->fileno);
free(fp);
}
main.c
#include "myStdio.h"
#include <stdio.h>
int main()
{
FILE_* fp = fopen_("log.txt", "w");
if(fp == NULL)
{
return 1;
}
int cnt = 10;
const char* msg = "hello world\n";
while(1)
{
--cnt;
fwrite_(msg, strlen(msg), fp);
sleep(1);
printf("count:%d\n", cnt);
if(cnt == 0) break;
}
fclose_(fp);
return 0;
}
监控脚本
while :; do cat log.txt ; sleep 1; echo "------------------"; done
我们向外设中写入数据其实一共分为三个步骤 – 先通过 fwrite 等语言层面的文件操作接口将进程数据拷贝到语言层面的缓冲区中,然后再根据缓冲区的刷新策略 (无、行、全) 通过 write 系统调用将数据拷贝到 file 结构体中的内核缓冲区中,最后再由操作系统自主将数据真正的写入到外设中。(所以 fwrite 和 write 其实叫做拷贝函数更合适)。==这里操作系统的刷新策略比我们应用层 FILE 中的缓冲区的刷新策略要复杂的多,因为操作系统要根据不同的整体内存使用情况来选择不同的刷新策略,而不仅仅是死板的分为分行缓冲、全缓冲、无缓冲这么简单。==
操作系统提供了一个系统调用函数 fsync
,其作用就是将内核缓冲区中的数据立刻直接同步到外设中,而不再采用操作系统的刷新策略。