文章目录
- 前言
- [1. 背景知识](#1. 背景知识)
- [2. 理解文件](#2. 理解文件)
-
- [2.1 狭义理解](#2.1 狭义理解)
- [2.2 广义理解](#2.2 广义理解)
- [2.3 系统角度](#2.3 系统角度)
- [3. 回顾C语言文件IO接口](#3. 回顾C语言文件IO接口)
-
- [3.1 文件的打开与关闭(fopen和fclose)](#3.1 文件的打开与关闭(fopen和fclose))
- [3.2 向文件写入内容(清空写)](#3.2 向文件写入内容(清空写))
- [3.3 向文件写入内容(追加写)](#3.3 向文件写入内容(追加写))
- [3.4 Linux的输出重定向和追加重定向](#3.4 Linux的输出重定向和追加重定向)
- [3.5 读文件](#3.5 读文件)
- [4. 补充](#4. 补充)
- [5. stdin & stdout & stderr](#5. stdin & stdout & stderr)
- [6. 系统文件IO](#6. 系统文件IO)
-
- [6.1 open](#6.1 open)
- [6.2 close](#6.2 close)
- [6.3 O_CREAT(必须设置权限)](#6.3 O_CREAT(必须设置权限))
- [6.4 write](#6.4 write)
- [6.5 扩展了解](#6.5 扩展了解)
前言
C语言的时候我们学习过文件操作
学了很多文件操作的库函数。
这些库函数我们把它叫做C 语言的文件 I/O 接口。(当然其它编程语言诸如C++、python也都有自己的文件操作相关的接口)
但是,这都是在语言层面,仅凭这些内容,我们对文件的理解其实是非常肤浅的。
仅仅停留在会用C语言进行一些文件操作,甚至这里面的大部分接口我们也都忘记了,因为学过去之后就很少再使用它们了。
那么接下来的几篇文件,我们将深入到系统层面,对文件进行一个更深的理解。
1. 背景知识
之前我们讲过,文件=内容+属性(元数据):
即使我们在磁盘上创建一个空文件,没有写入任何内容,我们看到它的大小是0kb
他也依然要占用磁盘空间,因为只要这个文件被创建,它就一定具有相应的属性,诸如文件名、修改日期、文件类型和文件大小等。
那属性也是数据,也需要被保存起来。
所以,对文件的操作无非就两种,对内容的操作和对属性的操作。
第二点,我们要访问一个文件,必须先把文件打开,所以有了fopen这样的函数。
那如何理解文件的"打开"呢?
要访问一个文件,必须先把文件打开,所以有了fopen这样的函数,打开文件 其实就是先把文件"加载"(建立程序与文件数据之间的通道,让文件内容在合适的时候能够被加载到内存中,具体细节后面详谈)到内存,而我们访问一个文件,无非就是对文件的数据进行增删查改,最终是CPU执行我们的代码去完成相应的操作,而冯诺依曼体系结构规定了CPU只能直接和内存打交道,不能直接访问磁盘,所以要访问一个文件,必须先把文件"加载"到内存,即打开文件。
那没打开的文件呢?
没有被打开的文件,那它就完整躺在磁盘上嘛。
(下面我们会先讨论被打开的文件,磁盘上的文件我们等后面讲文件系统的时候探讨)
那我们平时说的打开一个文件,到底是谁去打开了呢?
🆗,我们应该理解成是进程打开了文件 。
因为我们打开一个文件,其实是我们在代码中使用某些文件操作的接口。最终程序被编译链接形成可执行文件,可执行文件被执行变成进程,然后CPU执行进程中的指令(底层调用系统调用,fopen底层封装了系统调用,后面会讲,然后陷入内核,操作系统完成相关工作)完成了文件的"打开"。
所以,是我们启动的进程 "打开了文件"。
操作系统内核协助完成打开操作,但主动发起者是进程
那系统中可能同时会有多个进程,多个进程也可以同时打开多个文件,那这么多被打开的文件,要不要被管理起来呢?
当然!
如何管理?
先描述,再组织!(后面详谈)
所以:
研究"打开的文件",本质上就是研究进程与文件之间的动态关系(建立、维持和解除?后面详谈)。
2. 理解文件
2.1 狭义理解
- 文件存放在磁盘里
- 磁盘是非易失性存储介质,文件在磁盘上可以持久保存
- 磁盘是外设(即是输出设备也是输入设备)
- 对文件的所有操作,本质都是对外设的输入和输出,简称 I/O
2.2 广义理解
Linux下一切皆文件(键盘、显示器、网卡、磁盘...),后面会讲解如何理解
2.3 系统角度
- 文件操作本质是进程对文件的操作
文件是静态的,只有进程(运行中的程序)才能发起对文件的读写、打开、关闭等动作。没有进程,文件只是磁盘上的数据。
我们命令行执行的各种操作文件的命令,比如cat查看文件内容,或者chmod更改文件权限,最终这些命令不也变成了进程嘛。
- 磁盘的管理者是操作系统
普通进程不能直接访问磁盘硬件(如读特定扇区),必须通过操作系统提供的系统调用(如 read/write),由内核中的磁盘驱动程序配合文件系统来管理磁盘的读写、分配、回收等。
- 文件的读写并不是通过 C/C++ 的库函数来操作的,而是通过文件相关的系统调用接口来实现的;库函数只是为了用户使用方便,其底层也封装了系统调用
C 标准库的 fread/fwrite 等函数最终会调用操作系统提供的系统调用(如 read/write)。库函数提供了缓冲和便捷接口,但底层的真正工作是由系统调用完成,再由内核操作磁盘
3. 回顾C语言文件IO接口
我们这里复习这些接口,肯定不会像C语言那篇文章讲的那么详细了,想要全面复习大家可以看之前的那篇文章,我们这里就挑一些与后面讲的内容有关联的接口和知识点重点复习一下。
3.1 文件的打开与关闭(fopen和fclose)
fopen和fclose接口
打开文件使用
fopen
第一个参数接收文件名/路径,第二个参数接收打开模式。
调用成功返回一个文件指针(FILE* 类型)
写下代码
以写方式打开一个文件,不存在则新建;我们没带路径,则默认在当前路径创建。
最后,不用这个文件了,使用fclose接口关闭文件
参数接收要关闭文件的文件指针。
先写这么多。
那我们只写方式打开一个文件,然后关闭。目前当前路径下不存在这个文件
所以,指向目前这段代码,应该会在当前所在路径下新建一个名为log.txt的文件
写方式打开文件,不存在则在当前路径下新建,那我们进程调用fopen它如何知道当前路径是哪里呢?
添加几行代码
我们看到,程序启动之后,就变成了一个进程,然后调用fopen函数,就在当前目录下创建了log.txt文件。
我们之前讲过:
可以通过 /proc 系统文件夹查看进程信息。
/proc 目录是 Linux 系统中的一个特殊目录,是一个 虚拟文件系统(procfs),由内核在内存中动态生成,不占用实际磁盘空间,提供了有关当前运行进程和内核状态的信息
一个进程被创建好,操作系统会自动在proc目录下创建一个以新增进程的PID命名的文件夹/目录
该目录中包含了当前进程的相关属性信息
我们进去
其中,cwd------Current Working Directory,它不就记录了进程的当前路径嘛,所以上面的例子中,进程调用fopen的时候,如果不存在要创建文件,就能够知道当前所处的路径。
那这样的话:
如果我们更改进程的当前工作路径,那以写方式打开一个不存在的文件,它创建的路径就也会随之改变。
来试一下,我们程序的位置不变,现在我们在程序中,调用fopen之前,我们来更改一下当前工作目录
上篇文章我们讲了一个系统调用chdir,它可以改变当前进程的工作目录
没问题。
所以:
打开文件,本质是进程打开。
因此即便文件不带路径,进程知道自己在哪里(CWD)。由此fopen以写方式打开文件时候OS就能知道要创建的文件放在哪里。
当 fopen / open 使用相对路径(如 "data.txt" 或 ".../log/out.txt")时,操作系统内核会以 CWD 为起点拼接相对路径,从而找到或创建文件。
3.2 向文件写入内容(清空写)
上面我们复习了文件的打开和关闭,那下面我们可以向打开的文件中写入点内容。
当然向文件写入内容也有很多接口,我们这里选择一个就行了,我们来用下
fwrite(详细介绍大家自行复习)
返回实际写入的元素个数(不是字节数)
下面我们来写代码:
向文件中写入一个字符串hello world。
看看效果
没问题。
如果我们连续多运行几次我们的程序
会发现文件的内容一直都是一个hello world字符串。
那我现在手动打开文件,往里面多写入一下内容
然后再来运行我们的程序(现在本身就存在这个文件,那就不会新建,而是直接以写方式打开)
我们会发现:
我们目前的写入是一种覆盖式写入 ,之前的内容被清空了。
而当前我们使用的打开模式是w
翻译:将文件截断为零长度(已存在),或创建文本文件以供写入(不存在)。流定位在文件的开头(即从开头开始写入)。
那如果现在我们就想专门写一个清空文件的工具,怎么写呢?
很简单,以w方式打开文件(不写入任何内容),任何关闭,就把它清空了。
看看效果
没有问题
3.3 向文件写入内容(追加写)
现在修改上面的代码:
把打开的模式由w改成a
然后同样写入一个hello world字符串
这次我们再来看运行效果:
执行前先删除之前的文件
成功地写入了hello world
那我们再来多执行几次
我们发现,这次是一个追加式的写入
翻译:可进行追加操作(在文件末尾写入)。如果文件不存在,则创建该文件。流定位在文件末尾。
文件可以看作一个"一维数组",文件的读写位置就是下标!
3.4 Linux的输出重定向和追加重定向
之前我们讲解Linux基本命令的时候,我们讲过
输出重定向
本来我们直接echo "hello world"
默认输出到显示器
但可以这样
这叫做输出重定向
重新确定了输出的方向,把本应该输出到显示器的信息写入到了log.txt文件中
甚至还可以这样
这不就把这个文件清空了嘛。
那这种输出重定向不就对应我们上面演示的覆盖式地向文件写入内容(w模式打开文件)
当然还有追加重定向:
这不就对应上面的追加式地向文件中写入内容(a模式打开文件)
3.5 读文件
下面我们来自己实现一个cat命令
那就读取文件的内容然后打开就行了嘛。
读文件当然也有很多接口,那我们这里使用fread
返回值:
试一下
没问题。
4. 补充
向显示器打印数据,无论什么类型,底层都会转换成一个个字符,然后输出到显示器。
即printf 会将所有数据(无论是整数、浮点数、字符串还是指针)转换成对应的字符序列,然后输出到显示器(或其他标准输出设备)
显示器本身只能显示字符图形,所以这种转换是必须的。
printf 根据格式字符串(如 %d、%f、%s)将参数转换为人类可读的字符表示
例如:
整数 123 → 字符 '1'、'2'、'3'
所以,printf也叫做格式化输出函数。
同样地:
我们通过键盘输入数据的时候,站在我们的角度,可能输入了一个整数或者字符串啥的,但本质也是一个个的字符。
scanf 从标准输入(通常是键盘)读取字符流,根据格式字符串(如 %d、%f)将这些字符转换为相应的数据类型(整数、浮点数等)并存入变量。
例如,int x;输入 "123\n",scanf("%d", &x) 会读取字符 '1'、'2'、'3',然后将其转换为整数 123
scanf叫做格式化输入函数。
所以:
键盘、显示器通常也叫做字符设备
5. stdin & stdout & stderr
C语言程序在启动的时候,默认打开了3个流(C语言文件操作讲过)
stdin- 标准输入流 ,在大多数的环境中从键盘 输⼊,scanf函数就是从标准输入流中读取数据
stdout- 标准输出流 ,大多数的环境中输出至显示器,printf函数就是将信息输出到标准输出流中。
stderr- 标准错误流,大多数环境中输出到显示器 界⾯。
其实就是三个文件指针 :
看到它们的类型都是FILE*
而在C++中,cin、cout、cerr分别对应标准输入流对象、标准输出流对象、标准错误流对象
结论:任何一个程序启动时,都会默认打开三个流:标准输入、标准输出、标准错误。
那为什么呢?
我们启动的进程,基本都是为了完成某项任务,利用CPU资源做计算处理数据的。
那就需要先拿到数据,然后计算完毕还需要输出结果,结果有可能正确或者出错。
所以打开这三种流就被设定成了一种默认行为。
任何一个程序启动时都默认打开三个标准流,是为了实现"开箱即用"的 I/O 能力、支持管道与重定向、分离错误信息,并且不增加程序员的负担。
6. 系统文件IO
上面我们提过:
我们平时说的打开一个文件,本质是进程打开了文件,主动发起者是进程,但是底层必须由操作系统内核协助完成打开操作。
文件的读写并不是通过 C/C++ 的库函数来操作的,而是通过文件相关的系统调用 接口来实现的;库函数只是为了用户使用方便,其底层也封装了系统调用
即:
高级语言的文件 I/O 接口底层必定封装了操作系统提供的系统调用接口
那下面我们就来学习一下系统的文件IO接口
6.1 open
第一个系统调用------open
open 是 Unix/Linux 系统中用于打开或创建文件的一个基础系统调用。它返回一个文件描述符,供后续的 read、write、lseek、close 等操作使用。
有两个版本。
参数:
pathname接收要打开或创建的文件路径
flags用来指定文件的打开方式,待会我们详细来讲
mode用来给新创建的文件指定权限
如果我们打开的是一个已经存在的文件,使用两个参数的版本即可;如果打开的文件不存在,使用三个参数的版本,给文件指定一个权限。
返回值:
成功:返回最小的未使用的文件描述符(非负整数)。
失败:返回 -1,并设置 errno 以指示错误原因。
那什么又是文件描述符呢,我们后面详细介绍
那么接下来,我们先来学习一下flags这个参数
flags参数详解
首先查文档我们会发现这个flags参数有很多的选项:
(没截完)
当然现在我们还不是很了解这些选项的含义,也不懂到底该如何传递。
那flags这个参数的类型是int,是一个整数,但我们不能简单地当作一个整数看待。
lags 的本质:一组开关的"位掩码"
flags 是一个整数(32bit),但它的每一位(bit)代表一个独立的"开关" 。
通过"按位或"(|)运算符,可以将多个开关同时打开。
刚才上面我们列出的就是开关的选项,每个选项本质都是一个宏 。
大多数选项只有一个比特位为1,通过|可以同时选中多个比特,就表示同时使用多种打开方式。
位掩码(bitmask)传参示例
这种通过一个整数的不同二进制位(bit)来表示多个独立选项/标志,然后通过|同时传递多个选项的方式,通常被称为位掩码(bitmask)传参,有时也通俗地叫做位图传参。
当然目前讲到这里大家可能还是比较懵的,下面我们来写一个例子帮助大家理解这种传参方式
我们来看这样一段代码
写这样一个函数。
然后:
通过|就可以同时选中多个选项
运行看看效果
原理
理解了这个例子,就理解了open 系统调用中 flags 参数的设计原理。
演示
那下面我们就来使用一下open系统调用
以只写方式打开一个文件(不存在的文件),open返回文件描述符(非负整数)。
O_WRONLY:只写方式打开文件
那最后我们一定要关闭文件,所以下面学习一下关闭文件的系统调用------close
6.2 close
准确点说,close 是 Unix/Linux 系统中用于关闭一个已打开的文件描述符的系统调用。
它会释放该文件描述符所关联的内核资源,并可能触发一些清理动作(如释放文件锁、刷新缓冲区等)
参数传入要关闭文件的文件描述符即可
返回值:
成功:返回 0。
失败:返回 -1,并设置 errno 以指示错误原因。
看看效果:
现在我们只是打开一个文件,然后关闭。并且这个文件不存在,那按照我们之前C语言文件接口的理解,只写方式打开一个文件,不存在应该会新建。
运行
怎么回事,我们看到这里打开文件失败了,错误信息:没有这个文件或者目录。
为什么没有新建呢?
🆗,之前我们只写方式打开一个文件,不存在会新建,那是C语言的文件IO接口,而我们现在用的是操作系统的系统调用!
6.3 O_CREAT(必须设置权限)
那如果打开一个不存在的文件,想要新建,有办法吗?
当然!
只不过需要我们再多传一个选项。
O_CREAT:如果文件不存在,则创建它
此时再来运行我们的代码
确实创建了,但是,这个文件好像有点奇怪!
我们之前学过文件的基本权限是RWX,这里怎么看到一个T
原因在于,我们没有给新文件设置权限。
O_CREAT 被使用时必须提供 mode 参数,否则行为是未定义的(通常可能会导致栈上随机值被当作权限位使用,从而产生意外权限)。
删掉文件再次运行
我们发现文件的权限又是一个结果。
所以:O_CREAT如果文件不存在,则创建它。必须同时提供 mode 参数指定权限。
那我们C语言使用fopen并没有指定权限啊?
那是因为底层调用open的时候,以一个默认值自动指定权限。
这个权限值通常是 0666(所有用户读写)。而最终生效的权限,是0666再经过进程的umask(权限掩码) 处理后的结果
我们之前讲过
所以,我们上面也说了:
如果打开的文件不存在,使用三个参数的版本,给文件指定一个权限。
权限的八进制表示法,我们之前也讲过
这次再来运行
成功,创建了。
但是权限是666吗?
怎么是664呢?
因为最终权限 = 起始权限 & (~umask)
之前我们讲过的东西都不是白讲的!
如果想让我们设置的权限不受umask的影响,可以将umask改成000
使用umask系统调用即可更改umask的值
这次我们设置666,最终文件的权限就是666。
6.4 write
那向打开的文件中写入内容呢?------write系统调用
返回值
写一下代码
看看效果
然后修改代码:
修改写入的字符串,然后再次执行(不删除存在的文件)
怎么回事呢?
为什么是这样一个结果。
没有清空之前的内容,覆盖写入,但是是一个部分覆盖 ,能覆盖多少覆盖多少,没覆盖到的还是原来的内容。
那现在想让他清空再写入(向C语言接口那样的完全覆盖式写入),怎么做呢?
清空写
那我们就再传一个选项:
O_TRUNC:如果文件已存在且是以写(O_WRONLY 或 O_RDWR)方式打开,则将文件长度截断为 0。相当于清空文件内容。
这次,再来运行
就只有我们新写入的123了(没加换行)。
现在这个效果不就和我们上面C语言接口演示的清空写一样了嘛,其实它的底层就是这样的。
追加写
那在看看系统文件IO接口的追加写
只需要把上面选项中的
O_TRUNC换成O_APPEND即可。
O_APPEND:每次 write 时,自动将文件偏移量移到文件末尾,再写入。也就是说,所有写入永远追加在最后。
看看效果
这次就是追加写入
6.5 扩展了解


















































































