【Linux】一切皆文件:深入理解文件与文件IO

一、理解文件
1.1、文件的概念
文件存储在磁盘上。(狭义)

Linux中一切皆文件,即把所有需要交互的资源全部抽象成为文件:普通文件,目录文件,设备文件,管道文件...。(广义)

1.2、文件的认知
文件 = 内容 + 属性。

内容:文件存储的数据,如文本中的文字,程序二进制代码。

属性:文件的信息,包括文件名、大小、创建时间、权限、所有者等。

‼️对于0KB 的文件,即没有任何内容,但由于属性数据,所以占磁盘空间。

💦从系统角度看:对文件的操作其实是进程对文件的操作。

二、回顾C文件
2.1、C文件接口
FILE *fopen(const char *path, const char *mode);
// 写文件
int fputc(int character,FILE* stream);
int fputs(const char *s, FILE *stream);
int fprintf(FILE *stream, const char *format, ...);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

// 读文件
int fgetc(FILE* stream);
char *fgets ( char *str, int num, FILE * stream );
int fscanf(FILE* stream, const char* format, ...);
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

int fclose(FILE *stream);

2.2、实现cat 指令
当我们执行 cat log.txt 指令,其实就是读 log.txt 文件。

#include<stdio.h>
#include<string.h>
// argv[0]:./cat
// argv[1]:文件名
int main(int argc, char* argv[])
{
if(argc != 2)
{
printf("cat error\n");
return 1;
}
FILE* fp = fopen(argv[1], "r"); // 打开文件
if(fp == NULL)
{
perror("fopen");
return 2;
}
char buff[1024];
while(1)
{
int ch = fread(buff, 1, sizeof(buff), fp); // 将文件内容写入数组
if(ch > 0)
{
buff[1024] = 0;
printf("%s", buff);
}
if(feof(fp)) break;
}
fclose(fp);
return 0;
}

2.3、stdin & stdout & stderr
C语言程序在启动的时候,默认打开了3个流:

• stdin-标准输⼊流:在大多数的环境中从键盘输入,scanf函数就是从标准输入流中读取数据。

• stdout-标准输出流:大多数的环境中输出至显示器界面,printf函数就是将信息输出到标准输出 流中。

• stderr-标准错误流:大多数环境中输出到显示器界面。

三、系统文件IO
系统文件IO 就是通过系统调用的方式实现文件的读和写,C语言文件读写的接口就是通过底层封装Linux系统调用接口实现的。C语言读写文件通过w,r,a等选项确定,而系统调用则是通过传递标志位的方式实现的。

3.1、传递标志位的方法
定义几个宏,每个宏代表一个标志位,而标志位就是只有一个二进制位为1的数,当我们将参数flags与标志位进行按位与&操作时,只有当flags的对应二进制与标志位同时为1时,才执行相应的操作。

将文件读写的各种方式都设置一个对应的标志位,通过这种方式我们就可以控制对文件的读和写。

// 标志位
#define FIRST_FLAGS (1<<0)
#define SECOND_FLAGS (1<<1)
#define THIRD_FLAGS (1<<2)
#define FORTH_FLAGS (1<<3)

void Print(int flags)
{
if(flags & FIRST_FLAGS)
printf("FIRST_FLAGS : %d\n", FIRST_FLAGS);
if(flags & SECOND_FLAGS)
printf("SECOND_FLAGS : %d\n", SECOND_FLAGS);
if(flags & THIRD_FLAGS)
printf("THIRD_FLAGS : %d\n", THIRD_FLAGS);
if(flags & FORTH_FLAGS)
printf("FORTH_FLAGS : %d\n", FORTH_FLAGS);
}
int main()
{
Print(FIRST_FLAGS);
Print(FIRST_FLAGS | SECOND_FLAGS);
Print(FIRST_FLAGS | SECOND_FLAGS | THIRD_FLAGS);
Print(FIRST_FLAGS | SECOND_FLAGS | THIRD_FLAGS | FORTH_FLAGS);
return 0;
}

常用的标志位:
创建:O_CREAT,如对应c文件在以 "w" 方式打开,当文件不存在就会新建。

写:O_WRONLY,对应 "w"。

读:O_RDONLY,对应 "r"。

追加:O_APPEND,对应 "a"。

清空:O_TRUNC,当以"w" 方式写文件时,就会将文件的内容先清空。

3.2、系统调用接口
1、open——打开文件

参数:

(1)pathname:文件名(路径可带也可不带);

(2)flags:标志位;

• 当我们读文件即为:O_RDONLY;

• 写文件:O_CREAT | O_WRONLY | O_TRUNC;

• 追加写文件:O_CREAT | O_WRONLY | O_APPEND。

(3)mode:权限位。

如果不传该参数,且文件不存在,创建的文件默认初始权限为:-r- xr- x--

 

 

所以如果文件不存在,我们就需要设置权限位,而我们前面已经介绍过,修改文件权限可以直接用八进制数。通过0666就可以正常设置普通文件的权限。

 

 

返回值:文件描述符。

2、close——关闭文件

参数即为要关闭文件的文件描述符,open函数的返回值。

3、write——写文件

write() 函数会从指针 buf 指向的缓冲区中,向文件描述符 fd 所引用的文件写入最多 count 个字节的数据。而且系统其实并不关心写入数据的类型。

 

 

4、read——读文件

read() 函数尝试从文件描述符 fd 所指向的文件中,读取最多 count 个字节的数据,并将其存入以 buf 为起始地址的缓冲区中。

 

 

3.3、文件描述符
open函数返回值即为创建或打开文件的文件描述符,但我们注意到这个数字是3而不是其他。

这是为什么?

当启动一个 Shell(比如 Bash)时,Shell 进程本身会默认打开这三个标准流,即标准输入流,标准输出流和标准错误流。而后续在这个 Shell 中启动的子进程,也会继承这三个已打开的流。

这三个流其实也是文件,对应文件描述符为0,1和2。

标准输入(stdin, fd=0) → 默认绑定到键盘
标准输出(stdout, fd=1) → 默认绑定到显示器
标准错误(stderr, fd=2) → 默认绑定到显示器
后面再打开文件,自然就对应3,4... 了。

而我们在c语言中打开文件时,是用FILE* 指针指向我们打开的文件,但是在底层一个整数即代表打开的文件。0,1,2,3... 是不是跟数组下标又有什么关系?这就涉及到操作系统对文件的管理

当进程打开多个文件,怎么管理:先描述,再组织。

将文件的特性用一个结构体进行描述,用一个指针指向 file 结构体,然后将这个指针放在文件描述符表中,就可以进行管理了。

// 路径:include/linux/fs.h
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path; // 文件的路径(包含 dentry 和 vfsmount)
const struct file_operations *f_op; // 文件操作方法(read/write 等)
spinlock_t f_lock; // 保护该结构体的自旋锁
atomic_long_t f_count; // 引用计数(被多少进程打开)
unsigned int f_flags; // 文件打开时的标志(O_RDONLY/O_WRONLY/O_APPEND 等)
fmode_t f_mode; // 文件的访问模式(读/写/执行权限)
loff_t f_pos; // 当前读写位置(文件指针)
struct fown_struct f_owner; // 信号异步 IO 相关的所有者信息
const struct cred *f_cred; // 文件的安全凭证
struct file_ra_state f_ra; // 预读状态
void *private_data;// 驱动/文件系统的私有数据
};

此时,我们也就懂了为什么log.txt文件的文件标识符为3了。

文件描述符分配规则:
创建文件后,操作系统遍历文件标识符表,找到的第一个空位置就用来存放指向该文件file结构体的指针。

验证:我们手动关闭标准输入流文件,然后创建log.txt文件,观察log.txt文件的文件标识符。

 

 

3.4、重定向
回顾以前的重定向操作:

• 输入重定向 < :将键盘读入改成从文件读入。

cat < log.txt

• 输出重定向:将向显示器输出改为向文件输出,> 覆盖写入和 >> 追加写入。

echo "hello Linux" > temp.txt
echo "hello Linux" >> temp.txt

现在我们已经知道fd_array[0] 指向标准输入流文件,fd_array[1] 指向标准输出流文件,fd_array[2] 指向标准错误流文件。所以,我们只需要让原来指向对应流的指针指向我们的文件即可,因此:

输入重定向:

 

输出重定向:

 

重定向函数——dup2
dup2为系统接口,用来将oldfd 重定向到newfd。

 

对于输入重定向:oldfd = fd,newfd = 0;即 dup2(fd,0)

int main()
{
char buf[1024] = {0};
// 1. 打开要作为输入的文件
int fd = open("input.txt", O_RDONLY);
if (fd == -1) {
perror("open failed");
exit(1);
}
// 2. 核心:把 stdin(fd=0)重定向到 input.txt
// dup2(oldfd, newfd):将 newfd 指向 oldfd 对应的文件
dup2(fd, 0);

// 3. 关闭原文件描述符(fd 已复制到 0,无需保留)
close(fd);

// 4. 从 stdin 读取(实际从 input.txt 读)
fread(buf, sizeof(buf), 1, stdin);
printf("%s", buf);
return 0;
}

 

输出重定向:oldfd = fd,newfd = 1;即 dup2(fd,1)

int main()
{
// 打开文件
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);

// 关键操作:将标准输出文件重定向到log.txt文件
dup2(fd, 1);
// 向显示器输出改为向log.txt文件输出
printf("xxxxxxxxx\n");
fprintf(stdout, "aaaaaaaaaa\n");
close(fd);
return 0;
}

 

💡💡根据文件描述符分配规则,如果我们先关闭标准输出流,则新创建的文件的 fd = 1,此时我们再向标准输出流打印数据,实际上也会重定向到该文件。

int main()
{
// 关闭标准输出流
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
printf("xxxxxxxxxx\n");
fprintf(stdout, "sssssssssss\n");
// close(fd);
return 0;
}

注意:有一个细节,我们最后不关闭fd文件,这里涉及缓冲区刷新的问题,下面会讲到。

补充:标准错误重定向
💦 直接看效果:标准输出流重定向 ./myfie > log.txt ,其实真正的写法为:./myfile 1 > log.txt

只是把1 省略不写。

int main()
{
printf("wwwwwwww\n");
fprintf(stdout, "ssssssssss\n");
const char *s = "hello Linux\n";
fwrite(s, strlen(s), 1, stdout);
return 0;
}

💦 标准错误流与标准输出流一样,都和显示器绑定。因此,重定向操作为:

./myfile 2>log.err

注意:2>log.err 之间没有空格。

int main()
{
std::cerr << "hello cerr\n"; // c++标准错误流
perror("hello stderr"); // c标准错误流
return 0;
}

💦 将标准输出流内容与标准错误流的内容全部重定向到一个文件:

int main()
{
// 标准输出流
printf("hello printf\n");
fprintf(stdout,"%s", "hello fprintf\n");

// 标准错误流
std::cerr << "hello cerr" << std::endl;
perror("hello pereor");
return 0;
}

❌️ ./myfile 1>log.txt 2>log.txt :第二次重定向时即第二次打开文件,先清空之前内容再写入,无法将所有内容重定向到 log.txt 文件。

 

✔️ ./myfile 1>>log.txt 2>>log.txt :追加重定向。

 

💡还有一种写法:./myfile 1>log.txt 2>&1

 

3.5、理解一切皆文件
Linux中键盘,显示器,磁盘,网卡等外设,也被抽象为文件,来方便操作系统管理(先描述,再组织)。但是对于不同的外设,读写方式不同,而在 file 结构体中还有一个东西:f_op指针

其类型为 const struct file_operations,在struct file_operations结构体中的成员除了struct module* owner 其余都是函数指针,这些函数指针可以指向不同外设的读写的函数。

file_operation 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都 对应着一个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从而 完成了Linux设备驱动程序的工作。

struct file
{
// ...
const struct file_operations *f_op; // 文件操作方法(read/write 等)
// ...
};

struct file_operations
{
struct module *owner;
//指向拥有该模块的指针;

loff_t (*llseek) (struct file *, loff_t, int);
//llseek 方法用作改变文件中的当前读/写位置, 并且新位置作为(正的)返回值.

ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
//用来从设备中获取数据

ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
//发送数据给设备. 如果 NULL, -EINVAL 返回给调用 write 系统调用的程序. 如果非负,
返回值代表成功写的字节数.

ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long,
loff_t);
//初始化一个异步读 -- 可能在函数返回前不结束的读操作.

ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned
long, loff_t);
//初始化设备上的一个异步写.

int (*readdir) (struct file *, void *, filldir_t);
//对于设备文件这个成员应当为 NULL; 它用来读取目录, 并且仅对**文件系统**有用.

unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
//mmap 用来请求将设备内存映射到进程的地址空间. 如果这个方法是 NULL, mmap 系统调用
返回 -ENODEV.
int (*open) (struct inode *, struct file *);
//打开一个文件

int (*flush) (struct file *, fl_owner_t id);
//flush 操作在进程关闭它的设备文件描述符的拷贝时调用;

int (*release) (struct inode *, struct file *);
//在文件结构被释放时引用这个操作. 如同 open, release 可以为 NULL.

int (*fsync) (struct file *, struct dentry *, int datasync);
//用户调用来刷新任何挂着的数据.

int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
//lock 方法用来实现文件加锁; 加锁对常规文件是必不可少的特性, 但是设备驱动几乎从不实
现它.
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *,
int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned
long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t
*, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *,
size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
};

甚至管道,也是文件;将来我们要学习网络编程中的socket(套接字)这样的东西, 使用的接口跟文件接口也是一致的。

四、缓冲区
4.1、什么是缓冲区?
缓冲区(Buffer) 是内存中开辟的一块临时存储区域,核心作用是协调两个速度不匹配的设备 / 组件之间的数据传输,通过 “批量读写” 替代 “逐字节读写” 减少高频交互的开销,提升整体效率。

缓冲区根据其对应的是输入设备还是输出设 备,分为输入缓冲区和输出缓冲区

4.2、为什么要有缓冲区?
⚠️你往硬盘写数据(硬盘速度:MB/s 级),内存速度是 GB/s 级,两者速度差上万倍;如果逐字节写,内存要等硬盘每次写完,大部分时间都在闲置;

先把数据写到内存缓冲区,攒够一批再一次性写入硬盘,内存不用频繁等待,硬盘也能批量处理,整体效率大幅提升。

⚠️我们向显示器打印,如果每调用一次 fprintf 就对应调用一次系统调用write,那么系统调用的频率就会大大增加。我们知道操作系统是非常忙的,而这样就会降低操作系统的效率。

所以在用户层,设置用户态缓冲区(C 标准库封装,针对FILE*,C 标准库(<stdio.h>)为每个FILE*对象(如stdin/stdout/stderr)维护的一块内存区域);可以看看FILE结构体:

// 在/usr/include/stdio.h
typedef struct _IO_FILE FILE;

// 在/usr/include/libio.h
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags.*/
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area
*/
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

当调用库函数(fopen/fwrite/printf/fputs等)时,先将内容存储到缓冲区,然后按照一定的规则进行批量化的刷新,将数据刷新到文件内核缓冲区,就会大大降低系统调用的次数,从而提高效率。

行缓冲(stdout 默认):缓冲区遇到换行符\n、缓冲区满、调用fflush()/fclose()时,才会把数据批量传给内核;
无缓冲(stderr 默认):数据不经过缓冲区,调用fprintf(stderr)时直接传给内核,这也是错误信息能实时输出的原因;
全缓冲(普通文件默认):只有缓冲区满或调用fflush()/fclose()时,才会批量传给内核(缓冲区大小一般为 4KB/8KB)。
计算机数据流动的本质:拷贝!!!

深入了解缓冲区
我们来看这样一段代码:

int main()
{
// 库函数
printf("hello printf\n");
fprintf(stdout,"hello fprintf\n");
const char *s = "hello fwrite\n";
fwrite(s, strlen(s), 1, stdout);

//系统调用
const char* ss = "hello write\n";
write(1, ss, strlen(ss));

fork();
return 0;
}

我们发现 printf 和 fwrite (库函数)都输出了2次,而 write 只输出了一次(系统调用)。为 什么呢?肯定和fork有关!

• 一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。

• printf fwrite 库函数+会自带缓冲区,当发生重定向到普通文 件时,数据的缓冲方式由行缓冲变成了全缓冲。

• 而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后。

• 但是进程退出之后,会统一刷新,写入文件当中。

• 而fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。

• write 没有变化,说明没有所谓的缓冲。
————————————————
版权声明:本文为CSDN博主「Sunday不上发条」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/Miun123/article/details/157322023

阅读剩余
THE END
阿里云ECS特惠活动
阿里云ECS服务器 - 限时特惠活动

云服务器爆款直降90%

新客首单¥68起 | 人人可享99元套餐,续费同价 | u2a指定配置低至2.5折1年,立即选购享更多福利!

新客首单¥68起
人人可享99元套餐
弹性计费
7x24小时售后
立即查看活动详情
阿里云ECS服务器特惠活动