学习C语言的时候,有文件操作的知识,但那只是学到了操作函数,然后直接用即可,至于原理如何,并不清楚。这篇会逐渐写出来文件的通用知识。
当文件没被打开的时候,文件是存储在磁盘中的;当操作者使用函数打开文件后,文件会被加载进内存里。文件是由内容+属性构成的,所以文件的什么会被加载进内存?不管是什么,属性是一定要有的。加载进内存这个过程,是操作系统做的,让他这样做的是操作者引起的进程。进程令系统加载文件,那么内存中一定不会只有一个文件,多个文件在内存中的时候,操作系统对它们会进行管理,管理的方式就是先描述再组织,如同系统管理内存数据一样,对于文件,系统也会用结构体struct file来存储它们的各项数据,比如属性,以及还有指向下一个文件的指针和其他指针,所以对多个文件的管理就变成了链表的管理。这里也体现出了Linux里一切皆文件的思想。
文件描述符
在C语言中有一系列文件操作函数,Linux也一样。
和fopen有所不同,返回数据的类型并不是FILE*而int。如果执行无误那就返回文件描述符,错误则返回-1。描述符先不管,先看函数
用man查看,会发现有很多参数,函数先不管,先想一个问题,操作系统如何了解操作者传给它的标志位,也就是函数参数这些?传多个标志位的时候,我们会多设几个位置,但是如果标志位太多这样做也不行,而系统对它的处理就是把这个标志位当做位图结构,一个int的参数有32个比特位,每个比特位就表示一个传过来的参数,也就是一个标志位。
写一段相对应的代码
#include <stdio.h>
#define one 0x1
#define two 0x2
#define three 0x4
#define four 0x8
#define five 0x10
void Print(int flags)//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);
Print(two);
Print(three);
Print(four | five);
Print(one | four | five);
Print(two | three | four | five);
//上面也就是二进制位的计算
return 0;
}
现在回到myfile.c,写文件相关的代码
return 0;
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define LOG "log.txt"
int main()
{
int fd = open(LOG, O_WRONLY | O_CREAT);
if(fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
close(fd);
return 0;
}
两个参数的意思就是只读,如果读不了那就创建。
但是创建出来的文件的权限不够好,可以添加第三个参数,表示文件权限应该是多少。
int main()
{
umask(0);
int fd = open(LOG, O_WRONLY | O_CREAT, 0666);
还可以使用umask改一下权限编码。
然后往文件里写东西
int main()
{
umask(0);
int fd = open(LOG, O_WRONLY | O_CREAT, 0666);
if(fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
const char* msg = "hello world";
int cnt = 5;
while(cnt)
{
char line[128];
snprintf(line, sizeof(line), "%s, %d\n", msg, cnt);
write(fd, line, strlen(line) + 1);
cnt--;
}
close(fd);
return 0;
}
往缓冲区里写,然后write读取。正常地运行,但是如下图
在这里插入图片描述
还有一个问题,O_CREAT会延续之前的内容,不会清空文件而继续写。
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
所以当进行文件写入时,底层会传递O_等选项。C语言还有a用来追加,Linux也有O_APPEND用来追加,但必须先写文件才能追加。
int fd = open(LOG, O_WRONLY| O_CREAT | O_APPEND, 0666);
读取文件O_RDONLY,int fd = open(LOG, O_RDONLY);
int main()
{
umask(0);
//int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd = open(LOG, O_RDONLY);
if(fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = '\0';
printf("%s\n", buffer);
}
close(fd);
return 0;
}
所以像fopen,fclose,open,close这些,就是库函数和系统调用,库函数封装了系统调用,fopen里就封装了系统的open。
----------------------------
无论是打开还是写入文件,都是用户层在访问硬件,通过系统调用来访问,他们不能越过操作系统去访问。无论哪个语言,文件操作的时候都是调用OS的open,write等函数,只不过各个语言封装形式有所不同。
---------------------------
刚才这个程序,如果正常运行,fd会得到返回值,但是为什么是3?
任何一个进程在启动的时候都会默认打开3个文件,标准输入,标准输出,标准错误,它们本质上都是文件,而它们在语言层面的表现就是像C语言中stdin,stdout,stderr,C++中cin,cout, cerr一样,不过C++里那是一个类。
#include <iostream>
#include <cstdio>
int main()
{
//Linux里一切皆文件,向显示器打印本质就是说向文件写入
//C
printf("printf->stdin\n");
fprintf(stdout, "printf->stdout\n");
fprintf(stderr, "printf->stderr\n");
//C++
std::cout << "cout->cout" << std::endl;
std::cerr << "cerr->cerr" << std::endl;
}
所有打印的语句都会出现在显示器上。三个标准文件是这样的
标准输入—设备文件–>键盘文件
标准输出—设备文件–>显示器文件
标准错误—设备文件–>显示器文件
所以3之前的012对应的就是标准输入、输出、错误,分别占据0 1 2,所以我们得到的就是3。这几个数字,也就是文件描述符,也就是open返回值,是数组的下标。这里为什么会出现数组?
在系统底层,用户通过进程命令操作系统打开文件,会建立一个进程的pcb,在内存中也会建立文件的struct file,只要找到struct file,就能找到文件的内容和属性。一个进程可以打开多个文件,进程会再建立一个files_struct结构体,里面有一个struct file类型的指针数组,每一个元素都是struct file的指针,用来找到这个进程打开的文件,进程pcb里又有一个struct files_struct* 类型的指针指向files_struct结构体,所以进程就可以找到所有自己打开的文件了,数组下标也就能够解释了。
当用write往文件写入内容时,这个进程会找到用户给的文件描述符所对应的文件,每个文件会链接一个缓冲区,write函数把要写的内容拷贝到这个缓冲区,至于缓冲区什么时候刷新到文件里,由系统决定。所以这些IO类write等函数,本质上是在用户空间和内核空间进行数据的来回拷贝。
如何理解“一切皆文件”
一个进程pcb通过一个结构体,结构体里有数组,通过下标访问每个文件的结构体struct file。file有文件的内容+属性,还有缓冲区。不止这些,像键盘,显示器,网卡,显卡等这些外设,它们都有自己的IO函数,比如读写函数,而Linux下每个文件结构体里都有一些函数指针,来指向这些外设自己的IO函数。在上层,系统有read,write等函数,前面写到过,它们的本质是向缓冲区里拷贝数据,然后函数指针调用底层不同设备的方法,那么就可以把数据放到设备里。这就是一切皆文件。底层无论有什么差异,最终都变成一个公式,函数指针调用函数去操作数据,把缓冲区的数据放到设备里。
继续深入
FILE是什么?为什么C语言中文件操作函数的类型是FILE?
FILE是一个结构体,是由C语言的库提供的。
底层有系统创建的文件结构体,再往上一层就是系统调用接口,比如read,write等接口,再往上就是语言库,C语言里有FILE相关的结构体,里面有在库中文件的所有属性。C语言想用fopen函数,就需要FILE结构体,想要调用open函数就得需要fd。所以FILE结构体必定封装了fd,这样才能用系统调用接口。
同理,其他语言的文件库也必须有文件描述符。
回到描述符的问题,虽然现在已经知道这是数组下标了,但还是有问题的。打开多个文件,依次打印3456789后,如果把3这个文件给去掉,再添加一遍,那么还会是3吗?还是10?
写一些代码
int fd1 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd2 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd3 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd4 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("%d\n", fd1);
printf("%d\n", fd2);
printf("%d\n", fd3);
printf("%d\n", fd4);
在这之前加上这一句fclose(stdin);
0就出来了。再关闭stderr就会这样
进程中,文件描述符的分配规则:在文件描述符表中,最小的,没有被使用的数组元素,分配给新文件。
现在针对1做一些操作
关闭标准输出
没有输出,并且这时候LOG,也就是log.txt的内容却是you can see me!。本来要打印到显示器的内容打印到了文件了,也就是发生了重定向。在语言层上,printf是向标准输出打印,而这时候标准输出关掉了,取而代之的是log.txt,所以就发生了重定向。而这个过程上层并不知道。
所以重定向的原理就是在上层无法感知的情况下,在OS内部,更改进程对应的文件描述符表中特定下标的指向。
除了输入和输出重定向,还有追加重定向。只要O_TRUNC改成O_APPEND就可。
像stdout,cout都是向1号描述符对应的文件打印,而cerr则是2;输出重定向只改的是1号对应的指向,2号不影响。
现在写个代码,把常规信息打印到log.normal,异常消息打印到log.error中。
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define LOG "log.txt"
#define LOGN "lognormal.txt"
#define LOGE "logerror.txt"
int main()
{
close(1);
open(LOGN, O_WRONLY | O_CREAT | O_APPEND, 0666);
close(2);
open(LOGE, O_WRONLY | O_CREAT | O_APPEND, 0666);
printf("hello printf->stdout\n");
printf("hello printf->stdout\n");
fprintf(stdout, "fprintf->stdout\n");
fprintf(stdout, "fprintf->stdout\n");
fprintf(stderr, "fprintf->stderr\n");
fprintf(stderr, "fprintf->stderr\n");
return 0;
}
要是想都放到一个文件里,./a.out > log.txt >2&1
但是这样不方便。有dup2函数可以重定向,两个参数为oldfd,newfd,newfd会是oldfd的拷贝,所以最终只有oldfd这个数值,比如要把1重定向到fd上,那么oldfd就是fd,1就是newfd。
int fd = open(LOGN, O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd, 1);
printf("11111111\n");
close(fd);
如何理解缓冲区
int main()
{
fprintf(stdout, "hello fprintf\n");
const char* msg = "hello write\n";
write(1, msg, strlen(msg));
return 0;
}
如果在最后加上fork()。
int main()
{
fprintf(stdout, "hello fprintf\n");
const char* msg = "hello write\n";
write(1, msg, strlen(msg));
fork();
return 0;
}
为什么会出现三行?去掉fork就是正常的两行,所以一定是fork影响了这里的结果。
在操作系统里,有进程pcb,文件结构体,指向标准输入输出等的指针,而在这上层,有C语言库。当我们自己的程序调用fprintf后,会通过struct FILE调用系统调用接口,C库会封装fd,也还有给我们准备好的缓冲区。当我们写入数据,会先把数据放到C的缓冲区里,然后函数返回,暂时还没有把数据放到系统内部的缓冲区。至于数据如何刷新给系统,C库有自己的策略,按照自己的策略去调用write给对应的文件缓冲区里。
C库常有的策略有:
1、无缓冲(也就是直接调用write把数据放到系统中)
2、行缓冲(遇到\n就把\n这之前的数据放到系统中)
3、全缓冲(全部写完后在放到系统中)
显示器采用行缓冲。普通文件采用全缓冲。
缓冲区能够节省调用者的时间,让它有更多的时间去做别的事,因为系统调用是有时间代价的,这样交给C库缓冲区后,进程可以继续忙自己的事。而C库会把所有要缓冲的数据都输入进来后,在送给系统,节省时间。
那么C的缓冲区在哪里?缓冲区在FILE结构体里,比较复杂。在操作者使用fopen等函数时,FILE结构体就会在库中建立好。
在上面的代码中,write是系统调用,所以和C的缓冲区无关,它直接去找系统。像显示器打印时,两条语句都是带\n的,所以在fork之前,fprintf的内容就已经通过行缓冲打印出来了,那么fork就无用;而当重定向到文件里后,就是全缓冲策略了,fprintf的内容会在C的缓冲区里,当创建子进程后,刚才的就是父进程写入的数据,而子进程又会写一遍,当刷新时,就出来了两次fprintf的内容。
模拟实现FILE
链接: main.c
链接: mystdio.c
链接: mystdio.h
mystdio.h
#pragma once
#include <stdio.h>
#define NUM 1024
#define BUFF_NONE 0x1
#define BUFF_LINE 0x2
#define BUFF_ALL 0x4
typedef struct _MY_FILE
{
int fd;
char outputbuffer[NUM];
int flags;
int current;
}MY_FILE;
MY_FILE* my_fopen(const char* path, const char* mode);
size_t my_fwrite(const void* ptr, size_t size, size_t nmemb, MY_FILE* stream);
int my_fclose(MY_FILE* fp);
int my_fflush(MY_FILE* ff);
main.c
#include "mystdio.h"
#include <string.h>
#include <unistd.h>
#define MYFILE "log.txt"
int main()
{
MY_FILE* fp = my_fopen(MYFILE, "w");
if(fp == NULL) return 1;
const char* str = "hello myfwrite";
int cnt = 500;
//操作文件
while(cnt)
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%s:%d", str, cnt--);
size_t size = my_fwrite(buffer, strlen(buffer), 1, fp);
sleep(1);
printf("成功写入: %lu个字节\n", size);
//my_fflush(fp);]
if(cnt % 5 == 0)
{
my_fwrite("\n", strlen("\n"), 1, fp);
}
}
my_fclose(fp);
return 0;
}
mystdio.c
#include "mystdio.h"
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <malloc.h>
#include <unistd.h>
#include <assert.h>
MY_FILE* my_fopen(const char* path, const char* mode)
{
//1、识别标志位
int flag = 0;
if(strcmp(mode, "r") == 0) flag |= O_RDONLY;
else if(strcmp(mode, "w") == 0) flag |= (O_CREAT | O_WRONLY | O_TRUNC);
else if(strcmp(mode, "a") == 0) flag |= (O_CREAT | O_WRONLY | O_APPEND);
else
{
//r+, w+, a+
;
}
//2、尝试打开文件
mode_t m = 0666;
int fd = 0;
if(flag & O_CREAT) fd = open(path, flag, m);
else fd = open(path ,flag);
if(fd < 0) return NULL;
//3、给用户返回MY_FILE对象,需要先进行构建
MY_FILE* mf = (MY_FILE*)malloc(sizeof(MY_FILE));
if(mf == NULL)
{
close(fd);
return NULL;
}
//4、初始化MY_FILE对象
mf->fd = fd;
mf->flags = BUFF_LINE;
memset(mf->outputbuffer, '\0', sizeof(mf->outputbuffer));//初始化缓冲区
mf->current = 0;
//5、返回打开的文件
return mf;
}
//返回的是一次实际写入的字节数
size_t my_fwrite(const void* ptr, size_t size, size_t nmemb, MY_FILE* stream)
{
//1、缓冲区满就写入
if(stream->current == NUM) my_fflush(stream);
//2、根据缓冲区剩余情况进行数据拷贝
size_t user_size = size * nmemb;
size_t my_size = NUM - stream->current;
size_t writen = 0;
if(my_size >= user_size)
{
memcpy(stream->outputbuffer + stream->current, ptr, user_size);
//3、更新计数器字段
stream->current += user_size;
writen = user_size;
}
else//有多少空间拷多少
{
memcpy(stream->outputbuffer + stream->current, ptr, my_size);
//3、更新计数器字段
stream->current += my_size;
write = my_size;
}
//4、开始计划刷新
//不发生刷新的本质就是不进行写入,IO,调用系统调用。所以写函数调用就会非常快,数据会暂时保存在缓冲区中
//可以在缓冲区积压多份数据,统一进行刷新写入,本质就是一次IO可以IO更多的数据
if(stream->flags & BUFF_ALL)
{
if(stream->current == NUM) my_fflush(stream);
}
else if(stream->flags & BUFF_LINE)
{
if(stream->outputbuffer[stream->current - 1] == '\n') my_fflush(stream);
}
else
{
;
}
return writen;
}
int my_fflush(MY_FILE* ff)
{
assert(ff);
//将用户缓冲区中的数据,通过系统调用接口,冲刷给OS
write(ff->fd, ff->outputbuffer, ff->current);
ff->current = 0;
fsync(fp->fd);
return 0;
}
int my_fclose(MY_FILE* fp)
{
assert(fp);
//1、冲刷缓冲区
if(fp->current > 0) my_fflush(fp);
//2、关闭文件
close(fp->fd);
//3、释放堆空间
free(fp);
//4、指针置为NULL
fp = NULL;
return 0;
}
当使用文件函数时,系统会先把数据放到缓冲区里,系统调用接口会根据缓冲区刷新策略用fd把数据放到系统内部的文件缓冲区里,最后根据策略把数据从缓冲区刷新到磁盘。
用户也可以强制刷新,把内核的文件缓冲区的数据刷新出来。fsync接口。
结束。