【Linux】基础IO(一)------文件、上层C库文件操作、底层linux原生IO
文章目录
- 【Linux】基础IO(一)------文件、上层C库文件操作、底层linux原生IO
-
- [一、 什么是文件?](#一、 什么是文件?)
- [二、 上层:C 标准库文件操作(stdio)](#二、 上层:C 标准库文件操作(stdio))
-
- [1、 进程的 cwd :当前工作目录](#1、 进程的 cwd :当前工作目录)
- [2、 fopen 六种打开模式](#2、 fopen 六种打开模式)
- [3、 C 库写文件](#3、 C 库写文件)
- [4、 C 库读文件](#4、 C 库读文件)
- [5、 自定义cat 命令(读取文件并打印到屏幕)](#5、 自定义cat 命令(读取文件并打印到屏幕))
- [6、 标准流:stdin stdout stderr](#6、 标准流:stdin stdout stderr)
- [三、 底层:Linux 原生系统调用 IO](#三、 底层:Linux 原生系统调用 IO)
-
- [1、 open函数](#1、 open函数)
- [2、 系统调用 write 写文件](#2、 系统调用 write 写文件)
- [3、 系统调用 read 读文件](#3、 系统调用 read 读文件)
- [4、 文件描述符 fd](#4、 文件描述符 fd)
-
- [(1)什么是 fd?](#(1)什么是 fd?)
- (2)fd底层原理
- [(3) FILE* 和 fd 的关系(串联 C 库与系统调用)](#(3) FILE* 和 fd 的关系(串联 C 库与系统调用))
- [(4) 其他重要问题](#(4) 其他重要问题)
一、 什么是文件?
提到文件 ,我们脑海中第一反应可能是磁盘上的txt、cpp等这类文档,这是我们日常生活中对文件的理解:它们保存在磁盘这种外设硬件上,就算断电,数据也不会丢失 。我们对磁盘上文档做读写,本质上就是对外设做输入输出,也就是我们常说的 IO(input,output)
但是在Linux中,文件的概念就要宽泛多了,在Linux下一切皆文件
不止普通磁盘文档,键盘、显示器、网卡、管道这些硬件和内核对象,都会被系统统一抽象成 "文件" 。上层程序不需要区分自己操作的是硬盘、键盘还是网络,全部复用同一套 IO 系统接口完成读写,这也是 Linux 设计非常巧妙的地方
搞懂概念之后,我们再来思考一个问题:一个完整的文件,到底由什么东西构成?
文件 = 文件内容(业务数据) + 文件属性(元数据)
文件内容就是我们真正存进去的数据,而文件属性,包含文件大小、访问权限、创建时间、所属用户、inode 编号等等描述信息
也正是因为文件分为内容和属性两部分,所有针对文件的操作,归根到底就只有两大类:
- 操作文件内容:也就是读写里面的数据
- 操作文件属性:比如修改权限、获取文件大小,并不改动文件里面的内容
一个小细节:哪怕是 0KB 的空文件,也会占用磁盘空间。 因为操作系统依然需要磁盘空间,用来存储这份文件属性
二、 上层:C 标准库文件操作(stdio)
了解完文件的基础概念之后,我们先从大家写 C 代码最熟悉的部分入手:C 标准库的文件操作
C 语言为我们提供了一套跨平台的标准 IO 库,头文件就是 stdio.h,它操作文件的核心句柄是 FILE * 文件指针
Windows 叫句柄,Linux 下叫文件描述符fd ,本质是同一个思想。句柄不是指针,不是真正的文件地址,是操作系统给应用程序发的一张编号凭证,程序拿这个数字告诉操作系统:帮我操作这个资源
Linux 中的fd含义,0:标准输入,1:标准输出,2:标准错误,这三个就是最常见的句柄(文件描述符)
不管我们要读还是写文件,有一条硬性规则:使用文件前必须先打开它,我们就得依靠路径 + 文件名找到目标文件
1、 进程的 cwd :当前工作目录
写代码的时候,我们经常见到 fopen("myfile", "w") 这种写法(myfile是要打开的文件名),这里没有写完整的绝对路径,不代表打开文件不需要路径
每一个正在运行的进程,内核内部都会维护一个叫做 cwd(current working directory,当前工作目录)的信息 。当我们给 open /fopen 传入相对路径文件名时,系统就会以这个 cwd 作为基准去寻找文件(所以写文件名,默认就在当前工作目录读写文件)
cwd知识补充:
-
cwd 和程序所在目录不是一回事 !
程序二进制文件放在哪 ≠ cwd 就是哪
你在 /home/aaa 目录下执行 ./bin/test,程序本体在bin,但进程的 cwd 是 /home/aaa
-
修改 cwd 的系统调用是 chdir()
头文件:#include <unistd.h>
bash
int chdir(const char *path);
直接修改当前进程内核里面的 cwd
这里串联上篇文章讲的自定义myshell 的知识:cd 之所以必须做成内建命令,不能 fork 子进程执行,因为子进程修改 cwd 只会影响自己,不会改动父 shell 进程的 cwd
- 如果传绝对路径,cwd 直接被忽略
比如 fopen("/etc/passwd", "r"),直接从根目录开始找,完全不看 cwd 的值 - C 库函数 getcwd() 可以拿到进程此刻的 cwd 字符串
头文件:#include <unistd.h>
bash
char *getcwd(char *buf, size_t size);
2、 fopen 六种打开模式
fopen 是 C 标准库函数,作用:打开一个文件,得到文件指针FILE * ,后续读写文件都靠这个指针
头文件:#include <stdio.h>
bash
FILE *fopen(const char *filename, const char *mode);
-
filename :文件名,可以是相对路径或者绝对路径
相对路径 :以进程的 cwd(当前工作目录)为基准查找文件
绝对路径:直接从根目录开始找,不受 cwd 影响
-
mode :打开模式字符串,决定读、写、是否新建、是否清空文件
第二个参数模式
| 模式 | 含义 |
|---|---|
| r | 只读打开;文件必须存在,读写位置在文件开头 |
| r+ | 读写打开;文件必须存在,读写位置在文件开头 |
| w | 只写打开;不存在则创建,存在直接清空原有内容,读写位置在开头 |
| w+ | 读写打开;不存在则创建,存在直接清空原有内容 |
| a | 追加只写;不存在则创建,写入永远追加到文件末尾 |
| a+ | 追加读写;读取起始在文件开头,写入永远追加到末尾 |
重点提醒:w 和 w+ 只要打开,就会直接清空旧文件内容,千万不要误操作把重要数据清空
- 返回值
成功 :返回 FILE* 文件指针(这是 C 库封装出来的结构体,不是文件描述符 fd)
失败:返回 NULL,必须做判空!常见失败原因:文件不存在、权限不足
fopen通常要配套fclose,fclose能:
- 刷新用户层缓冲区(fflush)
fwrite的数据存在 C 库的内存缓冲区,还没调用write系统调用,没真正进内核
如果不 fclose,程序异常退出,缓冲区残留数据直接丢失,文件内容残缺 - 释放 FILE 结构体内存
fopen 会在堆上分配FILE*结构体,不 fclose 就内存泄漏 - 归还底层的文件描述符 fd
FILE内部封装了 fd,不 close,fd 不会释放,文件描述符泄漏
进程打开文件数量是有上限,反复 fopen 不 fclose,后面再打开文件会失败
虽然正常结束return 0,操作系统也会回收该进程全部资源,fd就被回收,缓冲区也会刷新。但这个是操作系统收拾的烂摊子,不是代码规范。不能依靠程序退出自动回收,写代码一定要手动fclose
3、 C 库写文件
fwrite
头文件:#include <stdio.h>
bash
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
-
功能 :把内存里的数据,整块写入到文件中,属于二进制块写入函数
-
ptr:数据源,要写出去的数据的起始地址
-
size:单个数据块的字节大小
-
nmemb:要写多少个这样的数据块
-
stream:FILE* 文件指针,写到哪个文件流
-
返回值
返回成功写完的块的个数(nmemb 的个数),不是字节数!
fwrite 是 C 库函数,数据先写到C 库用户缓冲区,不会立刻落到磁盘
等到缓冲区满 / fclose(fp) / fflush(fp),才会底层调用write()系统调用交给内核
接下来写一段实际代码,使用 fopen 打开文件,往里面循环写入字符串
cpp
#include<stdio.h>
#include<string.h>
int main()
{
FILE *fp=fopen("myfile","w");
if(!fp)
{
printf("fopen error!\n");
return 1;
}
const char *msg="hello world\n";
int count = 5;
while(count--)
{
fwrite(msg,strlen(msg),1,fp);
}
fclose(fp);
return 0;
}

4、 C 库读文件
模式"r"只读,文件必须存在,否则 fopen 直接返回 NULL
fread
头文件:#include <stdio.h>
bash
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
-
作用:从文件里读取一块二进制数据,放到内存缓冲区,和 fwrite 是一对
-
ptr:接收数据的缓冲区,读到的数据存到这里
-
size:单个元素的字节大小
-
nmemb:想要读取多少个元素
-
stream:文件指针 FILE*
-
返回值
返回实际读到的元素个数(不是字节数!)
正常读到数据:返回读到的元素数量,小于等于 nmemb
读到文件末尾 / 发生错误:返回 0
feof
bash
int feof(FILE *stream);
作用:判断上一次文件读取操作,是不是已经读到文件末尾了
返回值:
- 非 0:已经到达文件末尾
- 0:还没到文件末尾
写完之后,我们再以读模式打开文件,把文件内容读取到内存缓冲区打印出来
c
#include<stdio.h>
#include<string.h>
int main()
{
//只读模式打开myfile文件
FILE* fp=fopen("myfile","r");
if(!fp)
{
printf("fopen error\n");
return 1;
}
//读取缓冲区,每次最多读1024字节
char buf[1024];
while(1)
{
//一共读取sizeof(buf)个元素
//s:实际读到的字节数量
size_t s = fread(buf,1,sizeof(buf),fp);
if(s>0)
{
//fread读来的是二进制字节,不会自动补字符串结束符'\0'
//手动在有效数据末尾加上 \0,才能用printf打印
buf[s]='\0';
printf("%s",buf);
}
//检测是否到达文件末尾
if(feof(fp))
{
break;
}
}
fclose(fp);
return 0;
}

5、 自定义cat 命令(读取文件并打印到屏幕)
Linux中有一个cat命令,可以读取指定文件,把文件内容打印到标准输出屏幕
我们可以利用 C 库 IO,自己实现一个极简版本的 cat:
cpp
#include<stdio.h>
#include<string.h>
int main(int argc,char *argv[])
{
//判断命令行参数,程序执行格式:./a.out 文件名
if(argc!=2)
{
printf("argv error!\n");
return 1;
}
// 只读模式打开传入的文件名字 argv[1]
FILE* fp=fopen(argv[1],"r");
if(!fp)
{
printf("fopen error!\n");
return 2;
}
char buf[1024];
while(1)
{
// 每次最多读1024字节
size_t s = fread(buf, 1, sizeof(buf), fp);
if (s > 0)
{
buf[s] = '\0'; // 手动补字符串结束符
printf("%s", buf);
}
// 上一次IO碰到文件末尾,退出死循环
if (feof(fp))
{
break;
}
}
fclose(fp);
return 0;
}

6、 标准流:stdin stdout stderr
我们写printf打印到显示器,并没有调用 fopen 打开显示器,为什么可以直接输出?
当一个 C 程序启动的时候,C 标准库会默认帮我们打开三个FILE*类型的文件流,不需要程序员手动 fopen
cpp
extern FILE *stdin; // 标准输入,对应键盘
extern FILE *stdout; // 标准输出,对应显示器
extern FILE *stderr; // 标准错误,对应显示器
也就是说,显示器、键盘,在 C 库层面同样被当成文件。我们完全可以把之前的文件输出函数,直接作用到 stdout 上,实现打印效果
fprintf
头文件:#include <stdio.h>
bash
int fprintf(FILE *stream, const char *format, ...);
作用 :格式化输出,把拼接好的字符串,写到你指定的文件流 stream
printf = fprintf 的特殊版本,固定输出到 stdout(屏幕)
cpp
#include<stdio.h>
#include<string.h>
int main()
{
const char *msg="hello fwrite\n";
//使用fwrite往标准输出流stdout写数据
fwrite(msg,strlen(msg),1,stdout);
printf("hello printf\n");
//fprintf指定输出流为stdout,打印字符串
fprintf(stdout,"hello fprintf\n");
return 0;
}

到这里,我们熟悉的 C 标准库 IO 就看完了
前面我们学习了 C 标准库的fopen/fprintf/fclose,这些都是用户态库函数
真正和操作系统内核打交道的,是 Linux 提供的系统调用:open / write / read / close
三、 底层:Linux 原生系统调用 IO
C 语言标准库文件函数,它只是用户态的封装层,真正向 Linux 操作系统发起文件操作,依靠的是一组系统调用
| C 库函数(上层) | Linux 底层系统调用 | 作用 |
|---|---|---|
| fopen() | open() | 打开文件,获取操作凭证 |
| fread() | read() | 读取文件数据 |
| fwrite() / fprintf() | write() | 写入文件数据 |
| fclose() | close() | 关闭文件,释放资源 |
C语言标准库文件函数: 内部封装了系统调用返回的文件描述符 fd,还自带用户态缓冲区
系统调用 :操作系统内核提供接口,没有用户缓冲区,直接和内核打交道
操作流程:我们调用fwrite数据先放到 C 库缓冲区,满足条件后,内部自动调用write系统调用交给内核
1、 open函数
想要操作文件,第一步依旧是打开文件。Linux 系统调用open,用来向内核申请打开一个文件
头文件必须包含:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
open 有两种函数原型:
cpp
// 不需要创建文件,两个参数版本
int open(const char *pathname, int flags);
// 需要创建文件时,第三个mode参数指定新建文件的权限
int open(const char *pathname, int flags, mode_t mode);
返回值
- open函数的返回值我们称为fd,fd是文件描述符,一个普通 int 整数
- 作用 :相当于操作系统给你的文件编号。后面 read、write、close,就传这个编号,告诉内核操作哪个文件
- open成功:返回一个非负整数(>=0)
- open失败:返回 -1
第三个参数mode,代表期望新建文件的权限 ,仅在O_CREAT标志存在时生效 ,必须使用八进制书写
受进程 umask 掩码影响,磁盘上真实权限不等于 mode
当文件不存在 ,需要新建文件时,mode 指定这个新文件的初始权限
文件已经存在的情况下,mode 完全不起作用,不会修改旧文件权限
【linux】Shell 原理与用户权限解析-CSDN博客具体可以看这篇文章,详细讲了权限问题
第二个参数flags 常用宏
flags 是整数,采用位图标记设计,多个功能通过 |(按位或)叠加
必选三选一(只能选一个)
- O_RDONLY:只读打开
- O_WRONLY:只写打开
- O_RDWR:读写打开
可选附加标志,可以多个叠加
- O_CREAT:文件不存在,则创建该文件;文件已存在,无效果
- O_TRUNC:打开文件的时候,把原有文件内容全部清空
- O_APPEND:追加模式,所有写入都往文件末尾添加
可能会有人疑惑:多个标志为什么用|,不能用逗号隔开?
这里就要理解位图标记的设计思想:每一个宏,占用二进制里面独立的一个 bit 位
cpp
#include <stdio.h>
#define ONE 0001
#define TWO 0002
#define THREE 0004
void func(int flags)
{
if (flags & ONE) printf("flags has ONE! ");
if (flags & TWO) printf("flags has TWO! ");
if (flags & THREE) printf("flags has THREE! ");
printf("\n");
}
int main()
{
func(ONE);
func(ONE | TWO);
func(ONE | TWO | THREE);
return 0;
}

原理
- 每一个功能宏,独占二进制里面独立的一个 bit,互相不干扰
- 按位或 |:用来组合多个开关标志
- 按位与 & :用来校验某个标志是否被开启
open的O_XXX系列宏,和示例ONE/TWO/THREE设计思想完全一致。内核内部就是通过&运算,判断传入的 flags 开启了哪些功能
举个对应关系 :C 库fopen("w"),底层等价调用
open("myfile", O_WRONLY | O_CREAT | O_TRUNC, 0666);
w模式的创建、清空行为,就是O_CREAT与O_TRUNC提供的
2、 系统调用 write 写文件
write是系统调用,没有用户态缓冲区,调用就直接向内核提交 IO 请求
bash
ssize_t write(int fd, const void *buf, size_t count);
- fd:文件描述符,open 返回的整数凭证
- buf:要写入的数据缓冲区
- count:希望写入多少字节
- 返回值:成功返回实际写入字节数,出错返回-1
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
umask(0); // 修改文件掩码,方便观察新建文件权限
int fd = open("myfile", O_WRONLY | O_CREAT | O_TRUNC, 0664);
if (fd < 0)
{
perror("open");
return 1;
}
const char *msg = "hello world!\n";
int count = 5;
int len = strlen(msg);
while (count--)
{
write(fd, msg, len);
}
close(fd);
return 0;
}

看ls -l输出,可以看到文件权限是 -rw-rw-r--,正好对应0664

write 和 C库 fwrite 对比
| 项目 | 库函数 fwrite | 系统调用 write |
|---|---|---|
| 缓冲区 | 有 C 库用户缓冲区 | 无用户缓冲区,直接交给内核 |
| 格式化 | 配合 fprintf 可以格式化字符串 | 只能搬运原始字节,不会格式化 |
| 头文件 | stdio.h | unistd.h |
| 凭证 | FILE* 文件流指针 | int 类型文件描述符 fd |
3、 系统调用 read 读文件
bash
ssize_t read(int fd, void *buf, size_t count);
- fd:文件描述符
- buf:接收读取数据的缓冲区
- count:期望读取的字节数
- 返回值:
返回正数(>0):返回值 = 本次实际读到的字节数
0:已经读到文件末尾 EOF,没有更多数据可读
-1:发生错误
同样,read读到的是原始二进制,不会自动补\0,当做字符串打印,需要手动添加结束标记
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if (fd < 0)
{
perror("open");
return 1;
}
char buf[1024];
ssize_t s;
// sizeof(buf)-1,预留一个位置手动放'\0'
while ((s = read(fd, buf, sizeof(buf)-1)) > 0)
{
buf[s] = '\0';
printf("%s", buf);
}
close(fd);
return 0;
}

4、 文件描述符 fd
(1)什么是 fd?
调用open打开文件成功之后,返回一个小小的整数,这个整数就是文件描述符 fd (file descriptor)
Windows 叫做句柄 Handle;Linux 叫做文件描述符 fd,二者是等价,是内核给到用户程序访问资源的凭证
进程刚启动的时候,内核会默认预先打开 3 个 fd:
- fd = 0:标准输入 stdin,对应键盘
- fd = 1:标准输出 stdout,对应显示器
- fd = 2:标准错误 stderr,对应显示器
也就是说,我们不需要 open,就可以直接使用 0、1、2 这三个整数,直接用系统调用读写控制台
cpp
#include<unistd.h>
#include<string.h>
int main()
{
const char* str="直接使用fd=1输出到屏幕\n";
write(1,str,strlen(str));
return 0;
}

(2)fd底层原理
每一个进程在内核里,都有自己的进程 PCB------task_struct
task_struct里面有成员 * files,指向一张文件描述符表files_struct,这张表本质就是数组file * fd_array\[\]
而fd 文件描述符,就是fd_array\[\]这个数组的下标
具体流程:
-
我们在代码调用open(),要打开磁盘上的文件
内核会 :
(1)在磁盘找到对应文件,在内核创建一个file内核对象,里面保存读写偏移、状态标记,关联磁盘的 inode 元数据
(2) 在当前进程files_struct的fd_array\[\]数组里,找一个空闲下标(比如 3),把刚才新建内核file对象的指针填进fd_array3
(3)将数组下标3返回给我们的用户程序,这个数字就是 fd
进程刚启动的时候,数组下标 0、1、2 就已经填好了:fd=0标准输入、fd=1标准输出、fd=2标准错误
-
接下来做读写 ,调用系统调用read(fd, ...)、write(fd, ...),只把这个下标数字传给内核
-
内核拿到 fd 下标,去当前进程专属的fd_array\[\]数组(文件描述符表),按下标取出内核file*指针,找到内核真实的文件对象,执行真正读写
注意:我们用户层拿到的仅仅只是一个整数下标 fd,fd 本身不是文件,不存文件内容;真正文件状态、读写位置全部保存在内核的file对象里面
打开文件使用完毕,一定要执行close(fd)
close的作用 :把这个下标归还内核,释放内核里面对应的文件对象资源
如果忘记close(fd),就会产生文件描述符泄漏,每个进程fd_array\[\]数组大小有限,允许打开 fd 有上限。不停open不关闭,空闲下标会被全部占光,之后再调用open直接失败

(3) FILE* 和 fd 的关系(串联 C 库与系统调用)
上面说的是内核、系统调用层面的知识。用户直接操作整数 fd 写代码不够方便,于是 C 标准库在用户空间做了一层封装,也就是FILE*结构体 。结构体内部就保存这个 fd 整数,还额外带上 C 库的用户缓冲区
我们现在有两套文件操作方式:
- 系统调用 :open/read/write/close ,靠fd 整数操作文件,内核提供接口
- C 标准库 :fopen/fread/fwrite/fclose,靠FILE*指针操作文件,语言库提供接口
调用层级:
- fopen() :底层会调用系统调用open()拿到 fd ,把 fd 封装进 FILE 结构体,返回 FILE * 指针给用户
- fread / fwrite :并不会立刻去调用系统调用,先读写 C 库的用户缓冲区 ;等到缓冲区满 / 满足刷新条件,才在底层调用read/write系统调用,真正和内核打交道。
- fclose():首先把缓冲区残留的数据刷新出去,再在内部调用close(fd)归还文件描述符
注意 :C 库 IO 是对系统调用 IO 的上层封装,多了一层用户缓冲区。read/write系统调用本身,没有用户缓冲区
用户缓冲区 = C 库在你的应用程序内存里,开辟的一块普通数组内存,属于用户空间,不是内核内存
如果要往文件写很多小段文字:
- 没有缓冲区(直接 write 系统调用):写一小段,就要跑一趟操作系统,频繁来回,开销大、慢
- 有用户缓冲区(FILE * ):
- 你调用fprintf,数据先写到程序自己内存的缓冲区数组里,不碰操作系统
- 不断攒数据,等缓冲区装满 / 遇到换行 /fclose/fflush,才一次性把一大块数据调用write交给内核写入文件
目的:减少系统调用的次数,提高 IO 效率。系统调用是有开销的,能少跑就少跑
这里只是简单讲一下,后边的文章会着重展开说
(4) 其他重要问题
C++ 为什么还有 ifstream/ofstream,C++ 不是兼容 C 吗?
C++ 完全可以直接使用 C 的 fopen 系列函数。新增文件流是面向对象封装:
- RAII:构造打开文件,析构自动关闭,避免忘记 close 资源泄漏
- 运算符重载 >> <<,自动识别数据类型,不用手动格式化字符串
- 底层依旧封装系统调用,只是上层面向对象封装
既然有 open/write 系统调用,为什么还要 C 标准库 IO?
- 跨平台兼容:open 是 Linux 专属系统调用,Windows 没有这套接口(Windows 使用 CreateProcess、CreateFile)。C 标准库 IO 由标准规定,在不同操作系统下,库内部适配对应系统调用,一套代码可以在 Linux、Windows 编译运行
- 用户缓冲区优化:C stdio 库在用户层维护缓冲区,多次 fprintf 数据会先写到缓冲区,攒够数据再一次性调用 write再写进内核,减少用户态和内核态切换,提升 IO 效率
- 封装简化:write 只能写入原始字节,printf 封装数字、字符串格式化逻辑,开发更简单
打开文件到底做了什么?
磁盘速度很慢,打开文件时,操作系统会把磁盘上文件的内容、属性加载到内核内存,生成 struct file 对象;后续进程读写,本质是 CPU 访问内存中的文件数据,减少磁盘交互