基础IO

狭义上的文件是指磁盘上的文件
广义上,在linux中一切皆文件

用户需要访问文件一定需要通过系统调用,所有编程语言的文件访问函数都是库函数,底层一定封装了系统级调用

所有的程序默认打开了三个文件:标准输入(stdin)、标准输出(stdout)、标准错误(stderr)

文件的两大类:“内存级”(被打开)文件、磁盘级文件

常见的输出内容到显示器的方法:

  1. 1
    printf("Hello printf\n");
  2. 1
    fprintf(stdout, "Hello fprintf\n");
  3. 1
    2
    const char* msg = "Hello fwrite";
    fwrite(msg, strlen(msg), 1, stdout);

    向显示器打印,本质上就是向显示器文件写入

举例语言级打开文件函数接口:

1
fopen(FILE* fp, const char* mod);

mod:

- w	写文件(先清空,不存在就创建)
- a	追加
- r	读文件
- w+	读写(不存在就创建)
- a+	读和追加(不存在就创建)
- r+	读写

以读写方式打开,如果写了内容想回读,需要使用**fseek()**函数移动指针
特别的是,写入字符串不要写入\0,因为该字符是c语言的设计,在文件中无意义

如果程序没有使用序列化和反序列化,很难将数据按照预想的方式从文件读取,然而将数据写在结构体中以二进制文件读写,可以容易实现

系统级文件调用

open

创建或打开文件或设备

1
int open(const char* pathname, int flags);
1
int open(const char* pathname, int flags, mode_t mode);

文件已存在,使用两个参数的;文件可能不存在,需要创建,使用三个参数的

pathname:相对路径/绝对路径/当前目录下文件名

flag 用法
O_RDONLY 只读
O_WRONLY 只写
O_RDWR 读写
O_CREAT 创建文件
O_APPEND 追加
O_TRUNC 清空

mode

  • mode:文件的权限位,3位8进制数
  • 权限位的值会默认执行 &(~umask)
  • 如果希望完全按照mode设置权限,可以在open前调用**umask(0)**临时修改mask值

return:打开成功,返回新的文件描述符;错误,返回-1

close

关闭文件

1
int close(int fd);

fd就是open的返回值,即文件描述符

write

写文件

1
ssize_t write(int fd, const void *buf, size_t count);

buf:写入的buffer
count:写入的长度
返回值:实际写入的字节数,失败返回-1

可以实现二进制写入:

1
2
int a = 1234;
write(fd, &a, sizeof(a));

如果希望将非文本类型以文本方式写入:

1
2
3
int a = 1234;
char buffer[16];
snprintf(buffer, sizeof(buffer), "%d", a);

文本写入或二进制写入是语言层的定义,对于OS,两者没有区别

read

读文件

1
ssize_t read(int fd, void *buf, size_t count);

return:读取成功返回字节数,小于零表示失败,等于零表示读到文件结尾

文件描述符

在OS层面,只认识文件描述符

对于C语言,FILE结构体对文件描述符进行了封装,可以通过FILE*变量的_fileno访问文件描述符

例如:

1
2
3
stdin->_fileno;		// 0
stdout->_fileno; // 1
stderr->_fileno; // 2

文件描述符分配原则:最小的,没有被使用的,作为新的fd给用户
文件描述符的数值,对应的是进程中文件描述符表(fd_array)的数组下标

read函数本质是内核到用户空间的拷贝函数:

  1. read函数通过fd在fd_array索引,得到文件指针,找到对应的文件(struct file)链表
  2. OS将文件内容从磁盘预加载到文件的buffer(缓冲区)中
  3. 将文件内容从文件缓冲区中拷贝到read函数的缓冲区中

对文件做任何操作,都必须先把文件加载(磁盘->内存的拷贝)到对应的文件缓冲区中

重定向

在上层语言中,打印到显示器文件的函数只认**fd为1**的文件

使用close函数关闭stdout文件:close(1);
打开一个文件,此时根据文件描述符的分配原则,新打开的文件的fd会被分配为1
此时程序中所有指向fd为1的输出都会输出到新打开的文件

1
2
3
close(1);												// 关闭stdout
int fd = open("log.txt", O_CREAT | O_WDONLY, 0666); // 打开新文件,fd被分配为1
printf("Hello World"); // 向fd为1的文件输出

重定向的思想,就是更改文件描述符数组的指针指向

dup2

用于重定向系统调用的函数

1
int dup2(int oldfd, int newfd);

按上述的手动关闭和打开文件实现重定向,中间的过程可能被其他程序捷足先登,所以最安全的方式是系统调用
dup2所做的事情是将 oldfd 的指针覆盖到**newfd中,使得系统访问newfd的操作都被指向了oldfd所指的文件**
重定向成功返回newfd,失败返回-1

使用该系统调用实现输出重定向:

1
2
int fd = open("log.txt", O_CREAT | O_WDONLY, 0666);
dup2(fd, 1);

对于上述方式实现的重定向会发现原本的1被覆盖后无法恢复,这种情况适合用于子进程中的重定向
例如在myshell(在进程章节实现的自定义shell)中,如果需要对于pwd、echo之类的内键命令实现重定向,需要对shell的标准文件进行修改:

如下实现内键命令输出重定向

		1. 先创建临时文件t
		2. 使用dup,将1覆盖至t
		3. 使用dup,将要重定向的文件覆盖至1
		4. 重定向输出之后,将t覆盖至1

重定向的问题

当在shell中执行 ./a.out 1>log.txt 时,会发现程序的标准错误输出仍然会打印到屏幕上,这是为什么?

造成这种现象的原因是,在 file_struct 中每个文件都存在一个引用计数,用于统计文件被打开的进程数量,由此得出一个文件可以被多个 **fd **指向的。实际上 **stderr **就是如此,stderr指向的也是 stdout,因此重定向仅仅覆盖了 fd==1 的指针,而stderr的输出仍然可以被打印到屏幕上

从而引出了下一个问题:明明最终输出的文件都指向了同一个文件,为什么c++要同时设置 stdout 和 stderr 两个标准?

因为这样的架构可以通过重定向的能力,实现 常规消息错误消息 的分离,方便日志的形成

理解“一切皆文件”

在 Linux 下,一切皆文件是一种设计的结果
除了在Windows中被当作文件的.txt、.doc等文件格式,其他不被Windows当作文件的格式在Linux中仍然被抽象为了文件

linux 是如何做到这一点的?

先描述,再组织

在linux中,OS不需要关心底层的硬件如何运作,所有的文件统一使用名为 struct_file 的结构体来管理。这个结构体为所有硬件提供了统一的函数接口指针、属性、状态等成员,只需要在运作相关功能时将 struct_file 的接口和成员提供给进程来调用,在用户层面来看就是一切皆文件。(函数指针类型、命名、参数都一样)

所以准确来讲,是在 struct_file 层以上,一切皆文件

缓冲区

什么是缓冲区

缓冲区是内存中的一块空间,用来缓冲输入和输出的数据

为什么需要缓冲区

读写文件时,如果不开辟对文件操作的缓冲区,直接通过系统调用对磁盘操作,那么每一次读写的系统调用都要涉及一次CPU的状态切换,这将会损耗一定的CPU时间,频繁的操作对效率影响很大

采用缓冲机制可以一次从磁盘读出大量数据到缓冲区,此后对这部分内容的访问就不需要系统调用了,等缓冲区的数据取完后再去磁盘读,从而减少磁盘读写次数,同时计算机对缓冲区的读写速度更快,提高使用者的效率(谁用提高谁的效率)

此外,需要理解的是缓冲区作用在输入输出之间,使得低速的输入输出设备可以和高速的CPU协调工作,避免占用CPU

IO和缓冲区的问题

下面这段代码:

1
2
3
4
5
6
7
8
9
close(1);
// fd = 1
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
// 库函数
printf("Hello printf\n");
// 系统调用
const char* msg = "Hello write";
write(fd, msg, strlen(msg));
close(fd);

编译运行后,打印 log.txt 的内容

1
Hello write

此时会发现文件中只有系统调用写入的内容,而库函数没有成功写入

原因是

C语言中提供了一个语言层缓冲区,所有的库函数的IO实际上是向该缓冲区中进行

这个缓冲区的内容满足以下条件时才会从缓冲区写入到文件中:

1. 强制刷新
1. 满足刷新条件
1. 进程退出

当程序执行到 close() 函数时,进程还没有退出就将文件关闭了,导致缓冲区无法刷新,所以库函数写的数据都存在了缓冲区中

而系统调用是直接写入文件的,不需要经过语言层缓冲区

采用 fflush() 函数,强制刷新即可解决问题

1
2
3
4
5
6
7
8
9
10
11
close(1);
// fd = 1
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
// 库函数
printf("Hello printf\n");
// 强制刷新
fflush(stdout);
// 系统调用
const char* msg = "Hello write";
write(fd, msg, strlen(msg));
close(fd);

语言层缓冲区刷新

在前文 “IO和缓冲区的问题” 中提到了3个从缓冲区写入到文件的条件

  1. 强制刷新:就是采用 fflush() 函数手动对语言层缓冲区刷新
  2. 满足刷新条件:
    1. 立即刷新 — 无缓冲 — 写透模式WT(Write Through)
    2. 缓冲区满了 — 全缓冲(普通文件一般采用这种方式)
    3. 行刷新 — 行缓冲(显示器用)
  3. 进程退出:进程结束,自动刷新缓冲区

OS级缓冲区刷新

在系统层面,也存在一个缓冲区,总体的刷新策略与 3.0.1中提到的基本一致,但是具体的调用时机和方式一般由操作系统自行决定

实际上系统级缓冲区的刷新策略可以使用系统调用来干涉,在后文中会进行讲解

程序将数据交给OS,就相当于交给了硬件

数据交给OS,交给硬件的本质就是:拷贝
计算机数据流动的本质:一切皆拷贝

程序讲解

以下是 **a.cc **文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>

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));

return 0;
}

调用 ./a 时,运行结果如下:

1
2
3
4
Hello printf
Hello fprintf
Hello fwrite
Hello write

使用重定向:

1
2
./a > log.txt
cat log.txt

查看 log.txt 中内容:

1
2
3
4
Hello write
Hello printf
Hello fprintf
Hello fwrite
  • 现象:log.txt 和直接运行 ./a 得到的结果中,系统调用write出现的顺序前者在第一行,后者在最后一行

直接运行 ./a 代码从上往下执行,采用行刷新

重定向后,系统调用 write 优先级被放在首位

接下来对上述程序进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>

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;
}

在系统调用之后,增加了一行 fork() 创建子进程

调用 ./a 时,运行结果如下:

1
2
3
4
Hello printf
Hello fprintf
Hello fwrite
Hello write

使用重定向:

1
2
./a > log.txt
cat log.txt

查看 log.txt 中内容:

1
2
3
4
5
6
7
Hello write
Hello printf
Hello fprintf
Hello fwrite
Hello printf
Hello fprintf
Hello fwrite
  • 现象:程序直接运行的结果与修改前一样,log文件中系统调用的内容只出现了一次,库函数调用的内容出现了两次

子进程会共享父进程的所有资源,但是子进程的代码拷贝自父进程调用 fork() 以后

直接执行程序,父进程正常执行,子进程没有代码内容

重定向后,系统调用的IO不经过缓冲区直接写入文件,所以只出现了一次;而库函数的IO数据都在语言层缓冲区,子进程的缓冲区与父进程共享,进程结束会自动刷新缓冲区,所以父子进程各写入了一次数据,从而在文件中出现了两次

深入理解

在上一节中,直接执行程序系统采用的是行刷新,也就是向显示器写入,而重定向是向文件写入

重定向更改了系统缓冲区的刷新方式

值得注意的是,即使是系统调用 write() 函数,其刷新策略仍然被操作系统掌控,也就是说write不能保证将数据立刻刷新,因为刷新策略需要结合操作系统自身进行灵活的调整,感性上来说可以认为是随机的

如果希望数据一定能够立刻写入磁盘,需要使用以下系统调用

fsync

将内核数据直接从缓冲区刷新至磁盘的系统调用,即同步,英文全程(synchronize)

int fsync(int fd)

具体使用代码示例见: /ubuntu-cloud-server-backup/linux_class/Basic_IO/mystdio

这种将内存数据写入磁盘的行为,称为持久化,或落盘

同样的,在linux命令中也有一个类似的命令:sync