引言
文件 = 文件内容 + 文件属性
要操作文件,就要先打开文件。根据冯诺依曼体系,只能操作内存中的数据。因此要先把文件内容加载到存储器,即内存中。
文件接口
语言层面的文件接口
FILE* fopen(const char *path, const char *mode);
int fclose(FILE *fp);
size_t fwrite(const void *ptr, size_t size, size_t number, FILE *stream);
size_t fread(void *ptr, size_t size, size_t number, FILE *stream);
模式 | 含义 | 文件不存在时 |
---|---|---|
r | 只读 | 报错 |
w | 只写 | 创建文件 |
a | 追加只写 | 创建文件 |
rb | 二进制只读 | 报错 |
wb | 二进制只写 | 创建文件 |
ab | 二进制追加只写 | 创建文件 |
r+ | 读写 | 报错 |
w+ | 读写 | 创建文件 |
a+ | 追加读写 | 创建文件 |
rb+ | 二进制读写 | 报错 |
wb+ | 二进制读写 | 创建文件 |
ab+ | 二进制追加读写 | 创建文件 |
一个文件不能同时读和写。
struct _iobuf {
char *_ptr; //文件输入的下一个位置
int _cnt; //当前缓冲区的相对位置
char *_base; //指基础位置(即是文件的起始位置)
int _flag; //文件标志
int _fileno; //文件描述符id
int _charbuf; //检查缓冲区状况,如果无缓冲区则不读取
int _bufsiz; //文件缓冲区大小
char *_tmpfname;//临时文件名
};
typedef struct _iobuf FILE;
C语言的文件接口也是借助了操作系统的系统调用接口,封装了一层。
操作系统层面的文件接口
#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: 决定文件打开模式
- mode: 创建文件时文件的权限
- 返回值:文件描述符
man手册里提供了open的两个函数原型,看起来像是重载,但是C语言使不支持函数重载的。实际上是用到了可变参数。
__fortify_function int open (const char *__path, int __oflag, ...)
flags标志位
标志位 | 含义 |
---|---|
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 读写 |
O_APPEND | 追加 |
O_CREAT | 创建文件 |
O_TRUNC | 清空文件 |
实际上flag是一个位图,用32个bit位最多表示32种状态。
通过与操作就能判断是什么标志位,然后执行不同的逻辑。
文件权限
用第一个open接口创建的文件的权限是乱的。需要用第二个接口。
int fd = open("Log.txt", O_WRONLY | O_CREAT, 0666);
mode_t umask(mode_t mask);
但是权限的设置还和权限掩码umask有关。
权限掩码
权限掩码的目的是可以让开发者自定义文件的默认权限。
- 默认目录文件权限,起始权限777,最终默认权限是775,111 111 101
- 默认普通文件权限,起始权限666,最终默认权限是664,110 110 100
涉及权限掩码,umask,默认为0002
只关注后三位002,凡是在权限掩码中出现的权限,都不应该在最终权限中出现。
计算规则:最终权限 = 起始权限 & 权限掩码
目录文件 | 普通文件 | |
---|---|---|
umask | 000 000 010 | 000 000 010 |
~mask | 111 111 101 | 110 110 100 |
默认权限 | 111 111 111 | 110 110 110 |
最终权限 | 111 111 101 | 110 110 100 |
文件描述符
open的返回值是文件描述符,用来描述进程打开的每一个文件,是一个非负整数。
Linux下一切皆文件,文件描述符可以用于IO,也可以用来操作设备,充当套接字等。当进程打开一个文件,内核会为这个文件分配一个最小的可用文件描述符。
一个进程会默认打开三个文件流,分别为标准输入,标准输出,标准错误流。
这就是为什么成功打开文件时所得到的文件描述符是从3开始进程分配的。
文件描述符表
一个进程可以打开多个文件,操作系统如何管理被打开的文件?
先描述再组织,维护内核数据结构,并用链表等管理起来。
task_struct 里有一个 struct files_struct *files,描述一个进程打开的文件集合
files_struct 里有一个 struct file * fd_array[NR_OPEN_DEFAULT],文件描述符表
元素是struct file,即每一个被打开的文件。
struct files_struct {
atomic_t count; // 记录的是共享文件描述符表的进程数量
// 省略大部分
int next_fd;
struct file * fd_array[NR_OPEN_DEFAULT];
};
可以通过ulimit指令调整文件描述符表的大小
打开一个文件,就是把文件的内容和属性从磁盘加载到内存,创建好file结构体,初始化后,从0号下标开始找,找到最小的可用文件描述符,把地址放入到fd_array中。
open函数打开文件成功时数组当中的指针个数增加,这个指针在数组当中的下标被返回。文件打开失败返回-1,因此,成功打开多个文件时返回的文件描述符就是递进的。
重定向
关闭标准流,再打开一个文件,可以实现重定向。或者使用系统接口dup和dup2
dup2(A,B)是将B重定向到A,如dup(fd,1),将原来输出到1中的内容输出到fd中。
umask(0);
int fd = open("Log.txt", O_CREAT | O_RDWR | O_TRUNC, 0666);
dup2(fd, 1); // 用fd替换1
printf("Hello\n");
标准输出和标准错误
标准输出的fd是1,错误是2,本质上都是指向显示器,目的是区分程序的错误提示和正常输出。错误可以打印到日志里,输出可以打印到显示器。且输出可以被重定向,错误不可以。
./a.out 1> output.txt 2> error.txt
缓冲区
缓冲区无处不在,文件IO也有缓冲区。
什么是缓冲区?
缓冲区就是一段内存,把要向指定流输出的内容先存在这段内存里,时机合适就一次性刷新。
为什么需要缓冲区?
因为内存与外设交互速度是非常慢的,进程先把内容放到缓冲区,就不用一直等待和外设交互,多次存储一次输出也可以减少IO的次数。这样做可以提高整体的效率。
缓冲区在哪里?
结果:立即打印write,三秒后才打印printf和fprintf。
而在printf和fprintf中加了换行后,会按顺序立即打印出来。
而printf和fprintf肯定都是封装了write,因此可以判断,库中还有缓冲区。这里是语言级别的缓冲区,实际上肯定还有操作系统级别的缓冲区,当刷新用户缓冲区的数据时,并不是直接将数据刷新到外设,而是先刷新到操作系统的缓冲区。
缓冲区的刷新策略
常规情况
- 无缓冲:每次输入输出都直接系统调用,不经过用户空间的缓冲区。这种方式可以保证数据的实时性,但是系统调用的开销较大,且外设交互效率低。例如write就是无缓冲。
- 行缓冲:每次输入输出都先写入用户空间的缓冲区,当遇到换行符或者缓冲区满时,才调用系统调用将数据传输到内核空间。可以提高效率,但是牺牲了一定的实时性,且需要占用资源,因为数据可能会停留在用户空间的缓冲区一段时间。例如printf和scanf就是行缓冲。
- 全缓冲:每次输入输出操作都先写入用户空间的缓冲区,当缓冲区满时,才调用系统调用将数据传输到内核空间。实时性差,因为数据可能会停留在用户空间的缓冲区更长时间。例如磁盘文件或网络套接字的输入输出操作就是全缓冲的。
特殊情况:
- 进程退出:当进程正常退出时,会自动关闭所有打开的文件描述符,缓冲区会被刷新。但是如果进程是异常终止,例如收到信号,那么缓冲区可能不会被刷新,导致数据丢失或不一致。
- 主动刷新:调用如fflush或者stream的flush方法可以主动刷新缓冲区。
- 缓冲区超时:有些系统或设备可能会设置缓冲区超时的机制,即如果缓冲区在一定时间内没有被刷新,就会强制刷新缓冲区。这样可以避免数据在缓冲区中停留过长时间,影响实时性。例如,在Linux中,对于网络套接字的输出操作,可以使用TCP_NODELAY选项来禁用Nagle算法,这样就可以避免数据在套接字缓冲区中等待合并,而是尽快发送出去。
奇怪的问题
当把fork屏蔽掉之后,没有重复。
原因
显示器是行刷新,磁盘文件是全缓冲。重定向时,三个语言级别函数会内容暂存在语言级别缓冲区,因为不是显示器文件而不能行缓冲,而write是无缓冲,所以顺序最先。
fork创建子进程,父子共享代码,数据以写时拷贝的形式继承,其中包含了语言级别的缓冲区。
进程退出之后,父子进程都要刷新缓冲区,写入到文件中,因此会有重复。
软硬链接
概念
软硬链接是Linux系统中两种不同的链接方式,用于实现文件的共享使用。
硬链接是通过索引节点(index_node)来连接的,一个文件可以有多个硬链接,它们共享同一个inode和数据块。是一个独立文件,有自己的iNode和iNode编号与文件内容,相当于快捷方式。
软链接是通过文件路径来进行连接的,它实际上是一个特殊的文件,其中包含了另一个文件的位置信息。不是独立文件,和目标文件使用同一个iNode。
硬链接和软链接有以下几点区别:
- 硬链接不能跨文件系统,软链接可以。
- 硬链接不能对目录进行链接,软链接可以。
- 硬链接必须要有源文件存在,软链接可以对不存在的文件名进行链接。
- 删除源文件后,硬链接不受影响,软链接失效。
- 删除硬链接后,不影响源文件和其他硬链接,删除软链接后,不影响源文件。
操作
ln命令建立链接,UNlink命令接触链接。-s表示软链接,-h表示硬链接。
在删除了dir中的hello后,test下的硬链接同样能正常运行。删除了两者其中的一个,本质就是减少了一份iNode编号和文件的映射,也就是文件的硬连接数减少。
可以推测mv指令的实现原理。mv之后的iNode数不变。硬链接实际上是一份复制。吗?
实际上是在当前路径下新增一份被硬链接文件的文件名和iNode编号的映射关系。
并且可以看到,随着硬链接增多,被链接文件的硬链接数变成了3,类似引用计数的概念。
那为什么创建普通文件,他的硬连接数默认是1?
因为本身的文件名和iNode有一个映射关系,必定大于等于1。
那为什么目录默认是2?
目录本身是文件,所以会有在当前路径下的文件名和iNode编号的映射,这是1;在进入这个目录后,会有一个与当前这个目录形成映射,才有./exe的这种写法,这是2。
那这个iNode就可以类比成指针。而软链接是一个新的文件,其内容保存的是指向文件的所在路径。
此时dir的硬连接数是2,因为本身dir在当前路径就有一份文件名和iNode编号的映射,进入到dir后,还有一份dir中的 .
在dir1里创建一个目录后,硬链接数多了1,因为目录里一定有一个 … 和dir1链接起来。
所以不去自己硬链接的情况下,可以估算一个目录的下级目录中有多少个子目录,硬连接数-2。
模拟实现C文件接口
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define _bufferNum 512 // 缓冲区大小
#define NONE_FLUSH 0
#define LINE_FLUSH 1
#define FULL_FLUSH 2
typedef struct _myFile
{
int _fileno; // 文件描述符
char _buffer[_bufferNum]; // 语言级别缓冲区
int _end; // 缓冲区内容的下标
int _flushWay; // 刷新策略
}myFile;
myFile* myFopen(const char* fileName, const char* mode)
{
assert(fileName && mode);
int _mode = O_RDONLY;
if(strcmp(mode,"r") == 0)
{}
else if(strcmp(mode,"w") == 0)
{
_mode = O_CREAT | O_WRONLY | O_TRUNC;
}
else if(strcmp(mode,"a") == 0)
{
_mode = O_CREAT | O_WRONLY | O_APPEND;
}
int fileno = open(fileName, _mode, 0666);
if(fileno < 0) return NULL;
myFile* mfp = (myFile*)malloc(sizeof(myFile));
if(mfp == NULL) return NULL;
memset(mfp, 0, sizeof(myFile));
mfp->_fileno = fileno;
mfp->_flushWay |= LINE_FLUSH;
mfp->_end = 0;
return mfp;
}
void myFwrite(const char* buffer, size_t count, myFile* mfp)
{
assert(buffer && mfp && (count > 0));
// 先把内容写到缓冲区
strncpy(mfp->_buffer + mfp->_end, buffer, count);
mfp->_end += count;
// 刷新策略 只写了行缓冲
if(mfp->_flushWay &= LINE_FLUSH)
{
if(mfp->_end > 0 && mfp->_buffer[mfp->_end - 1] == '\n')
{
write(mfp->_fileno, mfp->_buffer, mfp->_end);
mfp->_end = 0;
syncfs(mfp->_fileno);
}
}
}
void myFflush(myFile* mfp)
{
assert(mfp);
if(mfp->_end > 0)
{
write(mfp->_fileno, mfp->_buffer, mfp->_end);
mfp->_end = 0;
syncfs(mfp->_fileno);
}
}
void myFclose(myFile* mfp)
{
myFflush(mfp);
free(mfp);
}
通过系统调用把缓冲区内容写到内核里,不代表写到了硬件上。需要用到syncfs,syncfs是系统调用,作用是将文件系统中的所有缓存的修改(包括文件元数据和数据)写入到底层的文件系统,把数据刷到磁盘上。