7 基础IO
7.1 文件
7.1.1 狭义上对于文件的定义
- 文件储存在磁盘中
- 因为磁盘是永久储存介质,所以文件能够永久保存在磁盘中
- 磁盘是一个计算机硬件,是一个计算机外设
- 所以访问文件本质上就是对硬盘这个硬件的访问,即对硬盘设备的输入输出,即IO
7.1.2 广义上对于文件的定义
- 广义上认为,操作系统中一切皆文件,包括硬件,对于操作系统的角度看,硬件被抽象为一个个文件
7.1.3 关于文件的一些问题
- 文件的本质是: 内容+属性
- 所以即便文件是新创建的,还没有对文件进行任何修改,此时文件依然会占据一部分磁盘空间,因为空文件也会有属性
- 所以对于文件的操作其实就是操作文件的内容和属性
7.1.4 文件与编程语言与操作系统
-
我们知道,在编程语言中,打开一个文件首先需要调用类似于
fopen的函数,意味着如果要使用或者修改文件,必须要打开文件 -
并且,在操作系统中一切皆文件,所以我们在执行某个可执行程序的时候,也会打开该文件,因为需要读取其内容到内存中
-
如何理解打开这个动作?
-
事实上,"打开"这个动作,其实就是将文件从磁盘中拷贝到内存里,此时我们就可以读取该文件的内容,或者说,打开该文件的进程就可以读取该文件的内容,或者对文件进行修改,并可以写回到磁盘中
-
但我们知道,
fopen这个函数是一个C语言标准库函数,既然它可以做到从上自下地跨越式地沟通磁盘,这意味着fopen这个函数的定义中一定包含了系统调用接口,然后由系统帮助进程拷贝文件到内存中 -
为什么
fopen就应该是一个C语言标准库函数而不是系统调用呢? -
这是处于对程序的可移植性的考量,如果两个系统之间,打开文件的函数标准不同,或者是函数名不同,程序的可移植性就会很差,移植的时候就需要手动修改函数名
-
而操作系统运行起来后,肯定不会只打开一个文件,一定会打开很多个文件,此时操作系统就需要将这些文件管理起来,即"先描述,再组织"
-
此外,磁盘是存储介质,内存也是存储介质,那么文件就既可以存在磁盘中,也可以存在硬盘中
-
即内存级文件和磁盘级文件,本章只讨论内存级文件,磁盘级文件会在文件系统章节中讨论
7.2 文件操作的回顾
7.2.1 对文件写入
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>
// write to file
int main()
{
FILE* pfile = fopen("other.txt", "w");
const char* str = "oldking is H\n";
int cnt = 0;
while(cnt < 10)
{
fwrite(str, strlen(str), 1, pfile);
cnt++;
}
fclose(pfile);
return 0;
}
-
如果你重复执行该程序,你会发现写入会使被打开的文件的内容被覆盖,这是因为我们以写入方式打开文件时,文件会被重置,或者说被清空,然后才开始写入内容
-
我们之前在使用输出重定向时,也会面临这样的问题,我们可以来看看
$ cat other.txt
$ ls
Makefile other.txt test test.c
$ cat other.txt
$ echo "aaaa" > other.txt
$ cat other.txt
aaaa
$ echo "bbb" > other.txt
$ cat other.txt
bbb
-
所以不论
shell帮我们做了什么,在输出重定向的时候它一定打开了文件,并且一定是以写入模式打开的文件,所以该文件会先被清空,然后才是被写入,所以本质上对文件输出重定向就是对文件进行写入 -
所以如果我们想在文件末尾追加内容,就不能使用写入模式,即: "w"模式
-
而应该使用追加模式,即: "a"模式, "append"模式
-
test.c:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>
// append to file
int main()
{
FILE* pfile = fopen("other.txt", "a");
const char* str = "oldking is H\n";
int cnt = 10;
while(cnt--)
{
fwrite(str, strlen(str), 1, pfile);
}
fclose(pfile);
return 0;
}
- 所以当我们重复执行该程序的时候,你会发现文件中的数据被追加了
$ ./test
$ cat other.txt
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
$ ./test
$ cat other.txt
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
oldking is H
- 所以我们也有追加重定向这种东西,本质依然是以追加模式打开文件
$ echo "HHH" >> other.txt
$ cat other.txt
HHH
$ echo "HHH" >> other.txt
$ cat other.txt
HHH
HHH
7.2.2 从文件读取与模拟实现cat
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>
// read from file & mycat prograss
int main(int argc, char* argv[])
{
if(argc != 2)
{
exit(1);
}
if(argc == 2)
{
FILE* pfile = fopen(argv[1], "r");
if(pfile == NULL)
{
perror("fopen err\n");
exit(2);
}
char buf[6];
while(1)
{
//fread的返回值实际上是当前读取的字符个数,即5个字符
ssize_t s = fread(buf, 5, 1, pfile);
if(s > 0)
{
buf[5] = '\0';
printf("%s", buf);
}
//feof用于判断当前打开的文件是否已经到文件末尾
if(feof(pfile))
{
break;
}
}
fclose(pfile);
}
return 0;
}
7.3 向屏幕打印的三种方式
-
既然在Linux中一切皆文件,连硬件都被抽象为文件,我们就可以通过向某个文件输出以达到向屏幕输出的目的
-
而这个文件就是标准输入输出的三个文件
| 文件名 | 用途以及对应硬件 | 类型 |
|---|---|---|
stdin | 用于从键盘文件中读取内容 | 标准输入流 |
stdout | 用于向屏幕文件中写入内容 | 标准输出流 |
stderr | 用于向屏幕文件中写入内容 | 标准输出流 |
- 这三个文件会默认被编译器打开,以作为程序的默认输入输出手段,用于获取数据和输出数据
- 让程序员自己打开的话会比较麻烦,所以设计成默认打开的文件
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>
// print to screen
int main()
{
printf("this is printf\n");
fprintf(stdout, "this is fprintf\n");
const char* str = "this is fwrite\n";
fwrite(str, strlen(str), 1, stdout);
return 0;
}
7.4 系统文件IO
7.4.1 标志符
- 标志符一般是一个整型变量,以某种特殊形式传入某个接口中,让该接口实现特定功能
7.4.1.1 标志符的设计
-
标志符的设计将会改善一个接口传参的问题,即:仅用一个整型变量控制接口的若干种功能的组合,同时还保持一定的可读性
-
一般情况下,我们控制接口功能无非就是
bool类型,或者说是,int类型,比方说这个接口bool func(int flag) -
传
1进接口就是功能1,传2进去就是功能2,但这样做不到各个功能之间的组合,例如我想将让该接口在完成功能1的同时也完成功能2,这在该接口是做不到的 -
于是我们可以新增多个形参,以达到控制接口丰富功能的要求,例如:
bool func(int flag_1, int flag_2, int flag_3, int flag_4) -
但这样面临着传参过多的问题
-
而且还有一个隐性的问题,在操作系统这种非常看重资源的程序中,这个问题不能被忽视,即:使用
int控制函数功能会造成空间的浪费,同时函数调用效率也会变低 -
比方说用
int flag_1控制某个功能的开与关,实际上只需要用0和1来表示就行了,以至于剩余的没有用到的位数就全部被浪费了 -
既然功能的启用与否仅需要用
0和1来表示,那是不是就可以只用一个int类型的变量就可以控制接口的所有功能了呢? -
于是位图就被使用在了标志符上,用于控制函数的多种功能的组合同时还能保持一定的可读性
7.4.1.2 模拟实践标志符
test.c:
#include<stdio.h>
#define MODE_1 (1 << 0)
#define MODE_2 (1 << 1)
#define MODE_3 (1 << 2)
#define MODE_4 (1 << 3)
void func_1(int flags)
{
if(flags & MODE_1)
{
printf("mode_1\n");
}
if(flags & MODE_2)
{
printf("mode_2\n");
}
if(flags & MODE_3)
{
printf("mode_3\n");
}
if(flags & MODE_4)
{
printf("mode_4\n");
}
printf("\n");
return ;
}
int main()
{
func_1(MODE_1 | MODE_2); // mode1 and mode2
func_1(MODE_1 | MODE_3); // mode1 and mode3
func_1(MODE_1 | MODE_2 | MODE_4); // mode1 and mode2 and mode4
func_1(MODE_2); // mode2
func_1(MODE_1 | MODE_2 | MODE_3 | MODE_4); // mode1 and mode2 and mode3 and mode4
return 0;
}
$ ./test
mode_1
mode_2
mode_1
mode_3
mode_1
mode_2
mode_4
mode_2
mode_1
mode_2
mode_3
mode_4
7.4.2 文件IO相关接口的使用
7.4.2.1 open()
open()是一个系统调用,用于打开文件
SYNOPSIS
#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: 用于设置文件的权限(我们会着重再提这个问题) -
关于返回值,如果文件打开成功,就会返回该文件的描述符(关于描述符,我们后再提,现在我们只需要知道,它会返回一个大于0的数就行了),如果没有打开成功,就会返回-1,并且设置
errno -
关于
open()的"重载"问题的解释: -
open()是一个glibc库中的函数,意味着其语言为C语言,明明C语言不支持函数重载,但他却可以实现类似于重载的功能,这是活用可变参数曲线救国完成的 -
在用户调用的层面,我们看到的仅仅是手册中给的几个函数头,实际上底层的函数头可能不是这样写的,而是类似于
int open(const char *pathname, int flags, ...);的写法,那此时最后一个参数实际上就变成可变的了,既可以留空,也可以传参 -
关于标志符
flags可以控制的常用的接口功能:O_RDONLY: 只读模式O_WRONLY: 只写模式O_RDWR: 读写模式O_CREAT: 自动新建文件O_TRUNC: 在进入写入模式时清空文件("truncated":截短)O_APPEND: 追加模式
-
关于
mode,因为open()需要支持自动新建文件,但新建文件就需要设定文件的权限,所以mode用于设定文件权限,一般传一个三位八进制数字,例如0666就是设置权限为-rw-rw-rw-,不过这个地方还会有个坑,我们在umask()部分谈论
7.4.2.2 umask()
- 一般来讲,创建文件的权限还需要经过一层权限掩码,以实现部分默认权限的设置,不受用户更改权限的影响,这个系统调用会更改当前进程的
umask值
SYNOPSIS
#include <sys/types.h>
#include <sys/stat.h>
mode_t umask(mode_t mask);
-
这个系统调用一般都会执行成功,几乎不存在调用失败这一说,所以返回值一般就是传进去的
umask -
不难猜到,该进程的
umask值是当前进程独有的,属于该进程属性的一部分,我们于是我们又可以猜测,这个属性可能存储在task_struct的某个位置 -
所以我们可以看看源码
-
task_struct
struct task_struct {
//...
/* filesystem information */
struct fs_struct *fs; //这个结构用于存储进程有关文件的属性和信息,它的定义就包含umask
/* open file information */
struct files_struct *files;
//...
}
fs_struct
struct fs_struct {
atomic_t count;
rwlock_t lock;
int umask; //这个就是该进程属性的权限掩码,接口 umask() 就是用于设置PCB中的这个值
struct dentry * root, * pwd, * altroot;
struct vfsmount * rootmnt, * pwdmnt, * altrootmnt;
};
7.4.2.3 close()
close()也是系统调用,用于关闭文件
SYNOPSIS
#include <unistd.h>
int close(int fd);
- 用于关闭该描述符对应的文件
- 执行成功会返回0,否则返回-1,并设置
errno
7.4.2.4 read()
()也是系统调用,用于读文件
SYNOPSIS
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
-
和接口
fread()差不多的老三样,fd即文件描述符,用于找到对应文件,buf就是类似于缓冲区,用于暂存传出的字符串,count用于计数,设置单次传出的字符数量 -
返回值则跟
fread()十分相似,返回读到的字符个数,但实际使用的时候,两者的语义还是有一些差距的 -
(来自deepseek)
| 特性 | read | fread |
|---|---|---|
| 返回值语义 | 返回读取到的字节数,返回0则代表已经读到了EOF | 返回读取到的字节数的大小,如果返回值小于请求的值,需要使用feof()或者ferror()判断具体情况 |
| 缓冲机制 | 无缓冲,直接使用系统接口 | 有用户态缓冲,fread()内部可能多次调用read填充缓冲区 |
| 错误处理 | 出现错误返回-1并设置errno | 需要使用feof()和ferror()判断错误情况 |
| 非阻塞场景行为 | 可能返回-1并设置errno=EAGAIN或 EWOULDBLOCK | 通常不直接支持非阻塞模式,依赖底层实现 |
7.4.2.5 write()
write()也是系统调用,用于写文件
SYNOPSIS
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
- 调用失败返回
-1,并设置errno,成功会返回已经写入的数据大小 - 使用方式同样和
fwrite类似,就不多赘述了
7.4.2.6 creat()
SYNOPSIS
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int creat(const char *pathname, mode_t mode);
- 用于创建一个文件,其实就是接口
open()的部分功能 - 返回值也还是描述符,就不多赘述了
7.4.2.7 lseek()
SYNOPSIS
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
-
该接口用于设置已经打开的文件的偏移量(即光标或者指针),也是一个系统调用接口
-
offset是偏移量具体的值,正数就是后移,负数就是前移 -
whence即从哪里开始,常用的包括:SEEK_SET:即从文本开头开始数偏移量SEEK_CUR:即从当前偏移量位置开始数偏移量SEEK_END:即从文件结尾位置开始数偏移量
7.4.2.7 实操
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main()
{
umask(0000);
creat("other1.txt", 0666); //创建文件"other1.txt",设置权限为: rw-rw-rw- (如果没有修改umask的话,就不会这样设置,而是设置为rw-rw-r--)
int desc1 = open("other1.txt", O_RDWR); //以读写方式打开该文件,并保存描述符
//设置要输入的字符串
const char* str1 = "oldking is HHH\n";
const char* str2 = "oldking is h\n";
//写入这两个字符串
write(desc1, str1, strlen(str1));
write(desc1, str2, strlen(str2));
//设置偏移量为文件开头
if(lseek(desc1, 0, SEEK_SET) == -1)
{
perror("lseek error\n");
exit(1);
}
//新建并以只写方式打开文件"other2.txt"
int desc2 = open("other2.txt", O_WRONLY | O_CREAT, 0666);
//初始化缓冲区
char buf[6];
int cnt = 2;
while(cnt--)
{
//从"other1.txt"读内容,每次读5个字符
if(read(desc1, buf, sizeof(buf) - 1) == 0)
{
perror("read error\n");
exit(1);
}
buf[5] = '\0';
//向"other2.txt"写入内容,每次写5个字符
if(write(desc2, buf, strlen(buf)) == -1)
{
perror("write error\n");
exit(1);
}
}
//关闭这两个文件
close(desc1);
close(desc2);
return 0;
}
$ ./test
$ ls
Makefile other1.txt other2.txt test test.c
$ cat other1.txt
oldking is HHH
oldking is h
$ cat other2.txt
oldking is
$ ll
total 28
-rw-rw-r-- 1 oldking oldking 134 Feb 27 20:21 Makefile
-rw-rw-rw- 1 oldking oldking 28 Feb 27 20:52 other1.txt
-rw-rw-rw- 1 oldking oldking 10 Feb 27 20:52 other2.txt
-rwxrwxr-x 1 oldking oldking 8816 Feb 27 20:52 test
-rw-rw-r-- 1 oldking oldking 2247 Feb 27 20:52 test.c
7.4.2.8 文件描述符
- 我们可以先看看文件描述符里的都是些啥
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
//desc就是后文的fd,这俩是同一个东西
int main()
{
int desc1 = open("other1.txt", O_CREAT | O_RDWR, 0666);
int desc2 = open("other2.txt", O_CREAT | O_RDWR, 0666);
int desc3 = open("other3.txt", O_CREAT | O_RDWR, 0666);
int desc4 = open("other4.txt", O_CREAT | O_RDWR, 0666);
printf("desc1: %d\n", desc1);
printf("desc2: %d\n", desc2);
printf("desc3: %d\n", desc3);
printf("desc4: %d\n", desc4);
close(desc1);
close(desc2);
close(desc3);
close(desc4);
return 0;
}
$ ./test
desc1: 3
desc2: 4
desc3: 5
desc4: 6
- 事实上,他其实就是这个文件打开后对于该进程的编号
- 但为什么这个编号是从3开始的呢?
- 因为进程会默认打开三个文件,即我们先前提到的:
stdin,stdout,stderr - 这三个文件会以文件流的形式作为全局变量存在,类型为
FILE* - 而
FILE*其实是一个指向FILE结构体的指针,这个结构体中一定包含这个文件的信息,并且值得肯定的是一定包含文件描述符 - 这是因为
fopen()这样的语言级接口一定会使用系统调用open(),但系统调用其实只认识文件描述符,FILE是C语言设计的东西,Linux系统调用的接口设计完全不同,所以C语言为了适配Linux平台,或者说适配各个平台,对这些平台的系统调用做了封装,在语言层面,用户的使用来看,就全部是FILE了 - 我们可以验证一下看看
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main()
{
printf("stdin: %d\n", stdin->_fileno);
printf("stdout: %d\n", stdout->_fileno);
printf("stderr: %d\n", stderr->_fileno);
int desc1 = open("other1.txt", O_CREAT | O_RDWR, 0666);
int desc2 = open("other2.txt", O_CREAT | O_RDWR, 0666);
int desc3 = open("other3.txt", O_CREAT | O_RDWR, 0666);
int desc4 = open("other4.txt", O_CREAT | O_RDWR, 0666);
printf("desc1: %d\n", desc1);
printf("desc2: %d\n", desc2);
printf("desc3: %d\n", desc3);
printf("desc4: %d\n", desc4);
close(desc1);
close(desc2);
close(desc3);
close(desc4);
return 0;
}
$ ./test
stdin: 0
stdout: 1
stderr: 2
desc1: 3
desc2: 4
desc3: 5
desc4: 6
- 所以说,文件描述符肯定是从0开始数的,只不过0,1,2代表的都是默认会打开的文件
7.5 文件管理
-
既然打开文件就是将文件加载到内存,并且操作系统不仅仅需要打开一个文件,很有可能需要打开很多个文件,于是我们便可以猜测,操作系统一定需要管理这些文件,于是一定会采用"先描述,再组织"的方法论
-
于是,文件管理便有了和管理进程类似的设计
- 我们甚至可以看一下源码是怎么写
struct file的
struct file {
/*
* fu_list becomes invalid after file_free is called and queued via
* fu_rcuhead for RCU freeing
*/
union {
struct list_head fu_list;
struct rcu_head fu_rcuhead;
} f_u;
struct dentry *f_dentry;
struct vfsmount *f_vfsmnt;
const struct file_operations *f_op;
atomic_t f_count;
unsigned int f_flags;
mode_t f_mode;
loff_t f_pos;
struct fown_struct f_owner;
unsigned int f_uid, f_gid;
struct file_ra_state f_ra;
unsigned long f_version;
void *f_security;
/* needed for tty driver, and maybe others */
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links;
spinlock_t f_ep_lock;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
};
- 所以,现在我们就知道了,
fd(上一小节的示例重用的是desc这个名字)其实是指的是fd_array的下标,而第0,1,2号位置,则是stdin,stdout,stderr的struct file的指针
7.6 重定向的原理
-
我们知道,诸如
printf()这类接口,默认是向文件stdout打印的,所以我们断定,printf()中一定有诸如write()这类系统调用,而write()只认识fd,意味着,printf()中,传入write()的fd应该是写死的,否则怎么会默认就向stdout输出呢? -
于是我们可以大胆猜测,假设我们更改
fd为1对应的文件,是不是就可以实现修改默认输出文件的操作了呢? -
当然,在实验之前,我们需要了解一个机制,就是一个文件被打开了,这个新打开文件的
struct file*指针就会占据fd_array下标最小的位置
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main()
{
int fd1 = open("other1.txt", O_RDWR | O_CREAT, 0666);
int fd2 = open("other2.txt", O_RDWR | O_CREAT, 0666);
int fd3 = open("other3.txt", O_RDWR | O_CREAT, 0666);
printf("fd1: %d\n", fd1);
printf("fd2: %d\n", fd2);
printf("fd3: %d\n", fd3);
close(fd2);
printf("\n\n");
int fd4 = open("other4.txt", O_RDWR | O_CREAT, 0666);
printf("fd4: %d\n", fd4);
return 0;
}
$ ./test
fd1: 3
fd2: 4
fd3: 5
fd4: 4
- 好了,现在我们就可以试着修改一下默认输出文件了
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
//注意:这里没有关闭文件`other1.txt`,具体原因需要了解缓冲区的机制才能知道
int main()
{
close(1);
int fd = open("other1.txt", O_WRONLY | O_CREAT, 0666);
printf("%d", fd);
return 0;
}
$ ./test
$ cat other1.txt
1
$ ls
Makefile other1.txt test test.c
-
可以见得,本来应该输出在屏幕上的
fd却输出在了文件other2.txt中 -
当然,还有系统调用接口可以让重定向更加优雅
SYNOPSIS
#include <unistd.h>
int dup2(int oldfd, int newfd);
-
用这个接口可以替换
fd_array的指针,注意:这个接口的语义是有些坑爹的,被替换的指针的下标是newfd,替换的指针的下标是oldfd -
返回值:如果调用成功,则会返回
newfd,否则会返回-1 -
于是我们可以对我们的代码进行修改
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main()
{
//close(1);
//使用dup2的好处在于,不需要手动关闭文件了
int fd = open("other1.txt", O_WRONLY | O_CREAT, 0666);
if(dup2(fd, 1) == -1) exit(1);
printf("%d", fd);
//至于下标为3的位置,还是需要我们手动关闭的,所以此时fd_array中有两个指针指向同一个文件
return 0;
}
$ ./test
$ ls
Makefile other1.txt test test.c
$ cat other1.txt
3
7.7 关于重定向的一个有趣现象的分享和解决方案,以及打开文件的机制
- 首先,在
shell的语法中,是允许这么使用重定向的
$ ./test 1>log.txt
-
意思是将默认输出文件
stdout改成log.txt,即修改fd_array中下标为1的指针指向的struct file -
现在我写一个程序
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main()
{
printf("hello oldking\n");
perror("hello error\n");
return 0;
}
- 现在我在shell执行以下命令:
$ ./test 1>log.txt 2>log.txt
$ cat log.txt
hello oldking
Success
-
很神奇的是,这里
perror()输出的内容直接消失不见了 -
我们来复盘一下
shell在执行test的代码之前做了什么:- 首先
shell会对指令进行文本分析 - 然后创建子进程,并且因为
shell发现我们想进行输出重定向,于是他会在创建子进程之后修改子进程的fd_array中下标为1和2的指针的指向(这一条留个悬念) - 然后将子进程的代码和数据替换成可执行程序
test的代码和数据 - 执行
test的代码
- 首先
-
既然打开文件这个操作在执行
test的代码之前,按理来讲数据不应该在执行test代码的时候被替换啊?为什么此时还是被替换了呢? -
在解决这个问题之前,首先我们需要了解一下
open()和dup2的机制和系统会做的事情 -
我们知道,系统会维护一张链表用于管理文件,那么问题来了,当一个文件在不同进程打开时,
struct file会有多少份?所有进程都会共享这个struct file吗? -
要解释这个问题,也很简单,我们只需要了解以下
struct file里到底存了什么东西 -
其中有一个成员非常特殊,即管理偏移量的成员
f_pos -
假设
struct file被共享,那意味着f_pos也会被共享,那此时某个进程更改了偏移量,其他的进程关于这个文件的偏移量也一并被更改了,这怎么可以???这还怎么保持进程之间的独立性??? -
所以
struct file实际上并不是被所有进程共享的!! -
而什么时候会创建一个
struct file?答案是在open()被调用的时候!! -
但这和这个问题有啥关系呢?
-
注意了:
shell在修改fd_array对应下标指向的文件之前,会打开该文件!!即调用open()! -
所以这会造成什么问题?即一个文件在一个进程中被打开了两次!!这个进程关联了两份
struct file,这两份struct file却管理的是同一份文件!同一个文件在该进程中的偏移量竟然有两个??!!! -
注意了:形如
./test 1>log.txt 2>log.txt这种指令,重定向两次却重定向到同一个文件时,shell调用两次dup2()时,这两次的oldfd不是同一个oldfd,即便他俩代表的是同一个文件!
- 注意了:如果调用了
dup2()并且oldfd没有被close()的话,就会出现这种情况,struct file中也会有引用计数!用于记录有多少个struct file*指针指向自己
-
注意了:同一个文件被多次打开,其缓冲区是被共享的!
-
我们回到代码:
hello oldking\n一共是14个字符长度hello error\n一共是12个字符长度,但接口perror()默认会在字符串末尾加上: Success\n,所以一共是hello error\n: Success\n
-
然而
hello error\n: Success\n先被输出(这句话其实并不准确,涉及到缓冲区问题),因为同一个文件的偏移量不共享,所以第二次输出时,hello oldking\n,会从偏移量0开始输出,于是就会覆盖掉前面的14个字符,得到hello oldking\nSuccess\n,即:
hello oldking
Success
- 那么如何解决这个问题呢
- 我们只需要使用追加重定向就行
$ ./test 1>>log.txt 2>>log.txt
$ cat log.txt
hello error
: Success
hello oldking
-
那么问题来了,追加重定向是怎么解决一个进程两次关联同一个文件而导致的偏移量有两个的问题呢?
-
答案其实是
write()帮我们调整了偏移量! -
接口
write()会检测struct file中的一个成员f_flags,f_flags就是用来存储文件的打开方式的,如果在f_flags中明确了该文件是以追加模式打开的,那么write()会在成员address_space找到成员inode,inode中有个成员叫i_size,这个i_size就是文件的文本长度,然后直接将f_pos修改成i_size,这就直接移动偏移量到文件末尾了
7.8 重新理解"一切皆文件"
-
我们知道,"一切皆文件"是Linux的重要设计哲学之一,它能降低开发者的学习成本
-
但具体一切皆文件是怎样实现的呢?
-
我们知道,OS通过结构
struct file管理文件,进程通过向缓冲区读写内容,以达到向硬件输入输出内容的目的 -
这个用于管理
struct file的数据结构我们就称为VFS即虚拟文件系统,类似于虚拟地址空间的设计 -
问题是:进程需要关注打开的文件所代表的是什么硬件吗?答案是根本不需要的!因为在进程看来,所有东西都是文件,任何
struct file中一定包含两个函数指针,这两个函数指针一定指向驱动程序提供的两个接口,即write()和read(),所有硬件驱动提供的这两个函数的参数,返回值都一样,所以对于进程来说,它们见到的都是文件,它们只用传参就行了,但他们不知道的是,他们见到的所有文件都是通过硬件抽象得来的,但无所谓,反正传参就对了 -
进程是这样的,进程只需要传参就可以了,可是驱动程序考虑的事情就多了
-
我们来举一个例子强化理解
-
我们对一个普通的
.txt文件写入内容,有没有调用硬件?当然有!因为修改.txt文件就是访问硬盘,所以每一个普通文件都关联着硬盘,所以它们其实和stdout这种文件在不涉及底层机制的角度看来没有本质区别! -
我们在系统调用看到的接口
write()其实底层就是调用某个文件标识符fd所代表的struct file中的void (*write)(int fd, char*, int)所指向的某个硬件驱动提供的write()接口(长难句,得仔细看QAQ) -
我们来阅读一下源码
struct file {
/*
* fu_list becomes invalid after file_free is called and queued via
* fu_rcuhead for RCU freeing
*/
union {
struct list_head fu_list;
struct rcu_head fu_rcuhead;
} f_u;
struct dentry *f_dentry;
struct vfsmount *f_vfsmnt;
const struct file_operations *f_op; // file_operations即文件操作集,里面都是对文件可做的操作,以函数指针的形式使用
atomic_t f_count;
unsigned int f_flags;
mode_t f_mode;
loff_t f_pos;
struct fown_struct f_owner;
unsigned int f_uid, f_gid;
struct file_ra_state f_ra;
unsigned long f_version;
void *f_security;
/* needed for tty driver, and maybe others */
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links;
spinlock_t f_ep_lock;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping; //这个指向的结构用于管理文件缓冲区
};
- 我们还可以看一看
file_operations的源码定义
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); // 这个就是用于指向硬件驱动提供的read()接口的指针
ssize_t (*aio_read) (struct kiocb *, char __user *, size_t, loff_t);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); // 这个也是同理
ssize_t (*aio_write) (struct kiocb *, const char __user *, size_t, loff_t);
int (*readdir) (struct file *, void *, filldir_t);
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 *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
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 *);
ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*sendfile) (struct file *, loff_t *, size_t, read_actor_t, void *);
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 (*dir_notify)(struct file *filp, unsigned long arg);
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);
};
7.9 缓冲区
7.9.1 缓冲区是什么
- 简单来说,缓冲区就是一片空间,一片允许临时存放数据的空间,他的设计思想其实非常类似于内存最初的设计思想
7.9.2 为什么需要缓冲区
-
我们回顾一下内存最初的设计思想
-
古早计算机没有内存的设计,导致输入所有需要的值后才能输出结果,但CPU速度太快了,导致计算机在没有输入值的时候就是在空转,毫无效率可言,计算又很迅速,计算机就会长时间没事干,如果一个计算机出现很多人同时使用的情况,就需要排队,此时效率就很低
-
缓冲区的设计理念也是如此,因为CPU要干的事情太多了,我们不能指望每次需要
write()的时候,CPU就直接把数据输出到文件,这样会让调度更加混乱,效率会很低,于是就只能攒一攒,攒到一定程度在交给CPU一次性处理 -
类似于内存池的概念,申请内存是一个非常费时间的过程,所以我们可以一次性申请很多空间,暂时空着不用,等到真正要使用这么多空间的时候,就不用再耽误CPU的时间了
7.9.3 C语言库函数中缓冲区的机制
-
我们先来看一个现象
-
test.c版本1:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<string.h>
int main()
{
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
printf("oldking is H\n");
printf("oldking is H\n");
printf("oldking is H\n");
const char* str = "i love oldking\n";
write(fd, str, strlen(str));
close(fd);
return 0;
}
- 执行:
$ ./test
$ ls
log.txt Makefile test test.c
$ cat log.txt
i love oldking
test.c版本2:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<string.h>
int main()
{
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
printf("oldking is H\n");
printf("oldking is H\n");
printf("oldking is H\n");
const char* str = "i love oldking\n";
write(fd, str, strlen(str));
//close(fd);
return 0;
}
- 执行:
$ ./test
$ ls
log.txt Makefile test test.c
$ cat log.txt
i love oldking
oldking is H
oldking is H
oldking is H
-
为什么明明看上去正常关闭文件却会导致
printf()无法重定向输出呢? -
我们需要了解一下
printf()的机制 -
printf()中也会存在缓冲区,我们可以称其为用户层缓冲区 -
printf()内部其实会调用系统调用接口write(),这点我们心知肚明,但这个缓冲区中的内容什么时候会传递给write()这点其实我们从来没提过 -
用户层缓冲区的刷新需要满足以下条件之一:
- 进程结束 -- 顾名思义
- 强制刷新 -- 使用
fflush()接口 - 刷新条件满足,包括:
- 立即刷新 -- 无缓冲 -- 执行速度最快,不拖拉
- 行刷新 -- 行缓冲 -- 一般给显示器用,因为符合人类阅读习惯
- 缓冲区满了 -- 全缓冲 -- 效率最高,因为每次都刷新最多量的数据,一般文件都会用这种方式
-
当我们没有
close()的时候,数据能够输出是因为用户层缓冲区中的数据因为进程结束而刷新到输出文件的缓冲区中 -
但如果我们在进程结束之前关闭了文件,那么等到进程结束的时候,程序想要刷新用户层缓冲区到文件,结果发现文件已经关闭,此时
printf()内write()接口的调用就会出错,因为fd所代表的文件根本就没有打开(1号文件),就无法刷新缓冲区,此时数据就丢失了 -
所以我们可以强制刷新一下用户层缓冲区
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<string.h>
int main()
{
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
printf("oldking is H\n");
printf("oldking is H\n");
printf("oldking is H\n");
fflush(stdout);
const char* str = "i love oldking\n";
write(fd, str, strlen(str));
close(fd);
return 0;
}
- 执行:
$ ./test
$ cat log.txt
oldking is H
oldking is H
oldking is H
i love oldking
-
为什么需要用户层缓冲区?究其原因还是因为操作系统太忙了,调度很费时间,所以就只能自己先存着
-
这个缓冲区存在哪里?
-
下面这个叫做
_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
};
- 对于进程而言,只要认为数据移交给了系统,我们就认为数据已经给到硬件手上了,因为对于进程而言,系统一定会帮它干这件事,进程只需要给数据就行了,而本质上,数据的转移只有拷贝
7.9.4 系统调用接口中缓冲区的机制
-
系统调用中缓冲区的机制其实也是老三样:
- 立即刷新 -- 无缓冲 -- 执行速度最快,不拖拉
- 行刷新 -- 行缓冲 -- 一般给显示器用,因为符合人类阅读习惯
- 缓冲区满了 -- 全缓冲 -- 效率最高,因为每次都刷新最多量的数据,一般文件都会用这种方式
-
但因为涉及到系统自己的调度问题,系统通常会自己决定使用哪种方式(比如说如果内存严重不足,可能就会强制使用直接刷新,并且只会给缓冲区留极少甚至不留空间),这会导致我们分析其机制十分复杂,这里就不做讨论了,讨论这个问题也没有那么重要
7.9.5 缓冲区与fork进程
- 我们先来看一下这个程序
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* str1 = "hello fwrite\n";
fwrite(str1, strlen(str1), 1, stdout);
const char* str2 = "hello write\n";
write(1, str2, strlen(str2));
return 0;
}
$ ./test
hello printf
hello fprintf
hello fwrite
hello write
$ ls
Makefile test test.c
$ ./test > log.txt
$ ls
log.txt Makefile test test.c
$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite
-
此时还非常正常,没啥问题
-
但是,只要我调一个接口,那么大的就来了
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* str1 = "hello fwrite\n";
fwrite(str1, strlen(str1), 1, stdout);
const char* str2 = "hello write\n";
write(1, str2, strlen(str2));
//我在这里创建了一个子进程
fork();
return 0;
}
$ ls
Makefile test test.c
$ ./test
hello printf
hello fprintf
hello fwrite
hello write
$ ./test > log.txt
$ ls
log.txt Makefile test test.c
$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite
hello printf
hello fprintf
hello fwrite
-
你会发现重定向之后输出的内容竟然重复了一部分,这是为什么?
-
其实猜也能猜得出来,肯定和缓冲区有关系,我们可以做个试验,调用完C标准库的输出接口之后,进行一次强制刷新缓冲区
$ cat test.c
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* str1 = "hello fwrite\n";
fwrite(str1, strlen(str1), 1, stdout);
const char* str2 = "hello write\n";
write(1, str2, strlen(str2));
fflush(stdout);
fork();
return 0;
}
$ ./test > log.txt
$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite
-
神奇的事情发生了,刷新缓冲区之后就没问题了
-
我们回顾一下,用户层缓冲区的刷新机制,即:
- 进程结束
- 缓冲区无空间了
- 触发缓冲区刷新条件
-
而很明显,当前用户层缓冲区应该是因为进程结束而导致的缓冲区刷新
-
而我们
fork()了一个子进程,子进程会拷贝父进程的代码和资源,而没有刷新的用户层缓冲区也算资源,所以这个缓冲区就会一并拷贝给子进程,等到进程结束就会强制刷新,这就导致了C库中输出接口会重复输出第二份,而因为系统调用是使用的系统级缓冲区,不是算作进程资源,就不会拷贝,所以系统调用输出的内容就会只有一份
7.9.6 模拟实现glibc中的用户层输出接口
~/.local/share/nvim/swap//:
1. %home%oldking%code%code_25_3_5%my_stdio%mystdio.c.swo
owned by: oldking dated: Thu Mar 6 16:11:07 2025
file name: ~oldking/code/code_25_3_5/my_stdio/mystdio.c
modified: YES
user name: oldking host name: iZwz9b2bj2gor4d8h3rlx0Z
process ID: 25411
2. %home%oldking%code%code_25_3_5%my_stdio%mystdio.c.swp
- 如有问题或者有想分享的见解欢迎留言讨论