基础IO
狭义上的文件是指磁盘上的文件
广义上,在linux中一切皆文件
用户需要访问文件一定需要通过系统调用,所有编程语言的文件访问函数都是库函数,底层一定封装了系统级调用
所有的程序默认打开了三个文件:标准输入(stdin)、标准输出(stdout)、标准错误(stderr)
文件的两大类:“内存级”(被打开)文件、磁盘级文件
常见的输出内容到显示器的方法:
1
printf("Hello printf\n");
1
fprintf(stdout, "Hello fprintf\n");
1
2const 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 | int a = 1234; |
如果希望将非文本类型以文本方式写入:
1 | int a = 1234; |
文本写入或二进制写入是语言层的定义,对于OS,两者没有区别
read
读文件
1 | ssize_t read(int fd, void *buf, size_t count); |
return:读取成功返回字节数,小于零表示失败,等于零表示读到文件结尾
文件描述符
在OS层面,只认识文件描述符
对于C语言,FILE结构体对文件描述符进行了封装,可以通过FILE*变量的_fileno访问文件描述符
例如:
1 | stdin->_fileno; // 0 |
文件描述符分配原则:最小的,没有被使用的,作为新的fd给用户
文件描述符的数值,对应的是进程中文件描述符表(fd_array)的数组下标
read函数本质是内核到用户空间的拷贝函数:
- read函数通过fd在fd_array索引,得到文件指针,找到对应的文件(struct file)链表
- OS将文件内容从磁盘预加载到文件的buffer(缓冲区)中
- 将文件内容从文件缓冲区中拷贝到read函数的缓冲区中
对文件做任何操作,都必须先把文件加载(磁盘->内存的拷贝)到对应的文件缓冲区中
重定向
在上层语言中,打印到显示器文件的函数只认**fd为1**的文件
使用close函数关闭stdout文件:close(1);
打开一个文件,此时根据文件描述符的分配原则,新打开的文件的fd会被分配为1
此时程序中所有指向fd为1的输出都会输出到新打开的文件
1 | close(1); // 关闭stdout |
重定向的思想,就是更改文件描述符数组的指针指向
dup2
用于重定向系统调用的函数
1 | int dup2(int oldfd, int newfd); |
按上述的手动关闭和打开文件实现重定向,中间的过程可能被其他程序捷足先登,所以最安全的方式是系统调用
dup2所做的事情是将 oldfd 的指针覆盖到**newfd中,使得系统访问newfd的操作都被指向了oldfd所指的文件**
重定向成功返回newfd,失败返回-1
使用该系统调用实现输出重定向:
1 | int fd = open("log.txt", O_CREAT | O_WDONLY, 0666); |
对于上述方式实现的重定向会发现原本的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 | close(1); |
编译运行后,打印 log.txt 的内容
1 | Hello write |
此时会发现文件中只有系统调用写入的内容,而库函数没有成功写入
原因是:
C语言中提供了一个语言层缓冲区,所有的库函数的IO实际上是向该缓冲区中进行
这个缓冲区的内容满足以下条件时才会从缓冲区写入到文件中:
1. 强制刷新 1. 满足刷新条件 1. 进程退出当程序执行到 close() 函数时,进程还没有退出就将文件关闭了,导致缓冲区无法刷新,所以库函数写的数据都存在了缓冲区中
而系统调用是直接写入文件的,不需要经过语言层缓冲区
采用 fflush() 函数,强制刷新即可解决问题
1 | close(1); |
语言层缓冲区刷新
在前文 “IO和缓冲区的问题” 中提到了3个从缓冲区写入到文件的条件
- 强制刷新:就是采用
fflush()函数手动对语言层缓冲区刷新 - 满足刷新条件:
- 立即刷新 — 无缓冲 — 写透模式WT(Write Through)
- 缓冲区满了 — 全缓冲(普通文件一般采用这种方式)
- 行刷新 — 行缓冲(显示器用)
- 进程退出:进程结束,自动刷新缓冲区
OS级缓冲区刷新
在系统层面,也存在一个缓冲区,总体的刷新策略与 3.0.1中提到的基本一致,但是具体的调用时机和方式一般由操作系统自行决定
实际上系统级缓冲区的刷新策略可以使用系统调用来干涉,在后文中会进行讲解
程序将数据交给OS,就相当于交给了硬件
数据交给OS,交给硬件的本质就是:拷贝
计算机数据流动的本质:一切皆拷贝
程序讲解
以下是 **a.cc **文件
1 |
|
调用 ./a 时,运行结果如下:
1 | Hello printf |
使用重定向:
1 | ./a > log.txt |
查看 log.txt 中内容:
1 | Hello write |
- 现象:log.txt 和直接运行 ./a 得到的结果中,系统调用write出现的顺序前者在第一行,后者在最后一行
直接运行
./a代码从上往下执行,采用行刷新重定向后,系统调用 write 优先级被放在首位
接下来对上述程序进行修改:
1 |
|
在系统调用之后,增加了一行 fork() 创建子进程
调用 ./a 时,运行结果如下:
1 | Hello printf |
使用重定向:
1 | ./a > log.txt |
查看 log.txt 中内容:
1 | Hello write |
- 现象:程序直接运行的结果与修改前一样,log文件中系统调用的内容只出现了一次,库函数调用的内容出现了两次
子进程会共享父进程的所有资源,但是子进程的代码拷贝自父进程调用
fork()以后直接执行程序,父进程正常执行,子进程没有代码内容
重定向后,系统调用的IO不经过缓冲区直接写入文件,所以只出现了一次;而库函数的IO数据都在语言层缓冲区,子进程的缓冲区与父进程共享,进程结束会自动刷新缓冲区,所以父子进程各写入了一次数据,从而在文件中出现了两次
深入理解
在上一节中,直接执行程序系统采用的是行刷新,也就是向显示器写入,而重定向是向文件写入
重定向更改了系统缓冲区的刷新方式
值得注意的是,即使是系统调用 write() 函数,其刷新策略仍然被操作系统掌控,也就是说write不能保证将数据立刻刷新,因为刷新策略需要结合操作系统自身进行灵活的调整,感性上来说可以认为是随机的
如果希望数据一定能够立刻写入磁盘,需要使用以下系统调用
fsync
将内核数据直接从缓冲区刷新至磁盘的系统调用,即同步,英文全程(synchronize)
int fsync(int fd)
具体使用代码示例见: /ubuntu-cloud-server-backup/linux_class/Basic_IO/mystdio
这种将内存数据写入磁盘的行为,称为持久化,或落盘
同样的,在linux命令中也有一个类似的命令:sync