Linux:基础IO
Linux基础IO
文件概念
文件在磁盘里,磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的。磁盘属于计算机外围设备(外设),同时兼具输入设备与输出设备的功能。针对磁盘文件开展的所有读写等操作,本质都是对磁盘这个外设进行输入、输出交互,这类交互操作简称为IO操作。
对于 0KB 的空文件是占用磁盘空间的。文件是文件属性(元数据)和文件内容的集合(文件 = 属性(元数据)+ 内容)。所有的文件操作本质是文件内容操作和文件属性操作。
从系统层面看,所有文件操作的执行主体都是进程,是进程发起了针对文件的各类操作。磁盘这类硬件存储设备,由操作系统全权负责管理。C/C++ 语言的文件库函数并非文件读写的底层实现,它们只是面向用户的便捷封装接口;文件读写的真正底层实现,依赖和文件相关的操作系统的系统调用接口来完成。
系统文件IO
man 2 open 打开手册,open是打开文件的系统调用。

参数pathname为打开文件路径,参数flags为打开文件操作,参数mode为文权限。

open的返回值是文件描述符,如果出现错误返回-1并设置错误码。
O_RDONLY 、O_WRONLY 、O_RDWR 、O_APPEND 、O_CREAT 、O_TRUNC 是打开文件操作的常用选项,这些本质是宏。flags采用的是位图,通过位图的标志位选项来表示这些选项,节省参数。
下面代码,用按位或叠加多个标志位,用按位与判断某个标志位是否开启。

使用如下代码,调用open,创建并打开文件选项。可以看到log.txt的权限是乱的。

如果要新建文件,就必须把权限带上。把参数mode设置成八进制数0666,再结合系统权限掩码umask的处理,得到如下权限的log.txt。

在代码中也可以使用系统调用umask,此时mode与新设定的umask进行运算得出新建文件的权限。如umask(0),新建log.txt的权限如下。

close 传入文件描述符,关闭文件。

write 是系统调用,往文件中写入内容。


不需要 strlen(msg) + 1 ,字符串末尾带\0是编程语言的规定,在此不必考虑。
把字符串改为 msg = "XXX" ,并将cnt改为1,可以看到write是不会清空log.txt,而是直接覆盖写入。

这与C语言的文件操作是不一样的,如果我们想要让write清空后写入,调用open时需要加选项 O_TRUNC 。

去掉 O_TRUNC ,增加选项 O_APPEND ,实现追加写入。

write的参数buf的类型是const void*,这意味着可以进行文本写入和二进制写入。
数字 12345678 在内存里是机器二进制存储,不是人类可读的文字。文本编辑器(vim / 记事本)只会把每一个字节当成ASCII 字符解析,很多字节不在可见字符范围,就会出现乱码。

如下代码能正确写入12345678。

read 从文件中读数据,如果读取成功返回被读取的字节数,失败返回-1,读到文件末尾时返回0。

使用示例如下。

读文件不需要新建等,只用选项 O_RDONLY 即可。从log.txt中读取数据,把数据读到缓冲区buf中,一次读取sizeof(buf)-1个字节。要提前预留一个位置放置\0,使printf能正常打印。
调用open打开4个文件,观察文件描述符fd。

可以看到fd的值是3、4、5、6,0、1、2是默认打开的标准输入、标准输出、标准错误。系统接口层面只认文件描述符,所以C语言的FILE结构体必然封装了文件描述符。stdin、stdout、stderr是C语言的标准输入流、标准输出流、标准错误流,都是FILE*类型,stdin -> _fileno 可以查看对应的文件描述符。

编程语言为何要封装独立的文件操作接口?不同操作系统提供的底层文件操作系统调用互不兼容,以 C 语言标准库为例:标准库内部预实现了 Linux、Windows 等多操作系统的适配逻辑;程序在不同平台编译运行时,libc通过条件编译机制,仅保留当前系统对应的底层实现,自动屏蔽其他平台的不兼容代码;开发者统一调用 C 语言标准文件 API,库函数内部自动转发适配对应操作系统原生接口;对上层开发者屏蔽底层系统差异,大幅降低跨平台开发成本,赋予代码可移植性。

磁盘中存在文件 log.txt,每调用一次 open() 打开文件,内核就会创建一个独立的 struct file 对象,所有 struct file 通过链表串联统一管理。
- 进程控制块
task_struct内持有指针struct files_struct *files,指向进程专属文件管理对象; struct files_struct内部维护文件描述符表struct file *fd_array[],本质是指针数组;- 数组下标就是程序中使用的文件描述符
fd,数组内每个指针都对应一个struct file对象; struct file存储单次打开文件的全部上下文:权限、当前读写偏移、内核文件缓冲区、文件操作函数集、链表指针等。
基于冯・诺依曼硬件架构,磁盘属于外部 IO 硬件,用户程序无权直接操作硬件;对文件的一切读写操作,数据都必须先载入内核缓冲区:
- 调用
open("log.txt",...):内核新建struct file,将磁盘log.txt的数据按需加载到该对象绑定的内核文件缓冲区; - 调用
read(fd, buffer, size):内核根据fd作为下标检索文件描述符表,找到对应struct file,再将内核缓冲区的数据拷贝到用户自定义buffer; - 文件写入、修改操作逻辑同理,所有磁盘 IO 都以内核缓冲区作为中间载体。
文件描述符的分配规则:在数组fd_array当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。

如图,关闭文件描述符为0的文件,此时fd_array的0下标就空出来了,打开文件log.txt,可以看到它的文件描述符为0。
清理文件myfile、log.txt。标准输出的文件描述符为1,close(1),可以看到屏幕没有打印出 fd: 1 ,文件log.txt中被写入了 fd: 1 。

close(1)后,指针fd_array[1]不再指向标准输出,接着调用open打开log.txt,fd_array[1]指向log.txt。


此时 stdout -> _fileno 仍是1,所以printf仍往文件描述符为1的文件打印,所以文件log.txt就被printf写入内容。这就是重定向的原理。重定向即更改文件描述符表的指针指向。
dup2 能直接实现上述输出重定向过程。

把fd_array[oldfd]的指针值赋值到fd_array[newfd]。

关于打印内容的顺序:程序自上而下执行时,printf、fprintf 属于 C 标准库 IO,当标准输出 fd=1 指向普通文件时 stdout 为全缓冲模式,输出内容仅暂存在用户态缓冲区,不会即时写入文件;而write是无用户缓冲的原生系统调用,执行瞬间就将数据写入内核缓冲区并推进文件读写偏移,因此它输出的hello world先出现在文件最上方;等到所有代码执行完毕、进程退出前,libc 才自动刷新 stdout 缓冲区,按代码书写顺序把三条打印内容追加到文件后方,最终形成文件内先write内容、后三条 printf 内容的文本顺序。
int fd = open("log.txt",O_CREAT | O_WRONLY | O_APPEND); dup2(fd,1) 即可实现追加重定向。
实现输入重定向。从键盘读变成从log.txt读。

所以,重定向又可以总结为:打开文件的方式 + dup2 。
在myshell中添加重定向。
//...
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
int redir = NONE_REDIR;
char* filename = NULL;
void Trimspace(char* cmd,int& end)//从end位置开始去空格
{
while(isspace(cmd[end]))
end++;
}
void RedirCheck(char* command)
{
redir = NONE_REDIR;
filename=NULL;
int start=0;
int end=strlen(command)-1;
while(start<end) // 让end找到 > 、>> 、<
{
if(command[end]=='<')
{
command[end++]='\0';
Trimspace(command,end);
redir=INPUT_REDIR;
filename=command+end;
break;
}
else if(command[end]=='>')
{
if(command[end-1]=='>')
{
command[end-1]='\0';
redir=APPEND_REDIR;
}
else
{
redir=OUTPUT_REDIR;
}
command[end++]='\0';
Trimspace(command,end);
filename=command+end;
break;
}
else
end--;
}
}
//...
int Execute()
{
pid_t id = fork();
if(id<0)
exit(1);
else if(id==0)
{
int fd=-1;
if(redir==INPUT_REDIR)
{
fd = open(filename,O_RDONLY);
if(fd<0)
exit(1);
dup2(fd,0);
close(fd);
}
else if(redir==OUTPUT_REDIR)
{
fd = open(filename,O_CREAT | O_WRONLY | O_TRUNC,0666);
if(fd<0)
exit(1);
dup2(fd,1);
close(fd);
}
else if(redir==APPEND_REDIR)
{
fd = open(filename,O_CREAT | O_WRONLY | O_APPEND,0666);
if(fd<0)
exit(1);
dup2(fd,1);
close(fd);
}
else
{}
execvp(g_argv[0],g_argv);
exit(1);
}
int status=0;
pid_t rid=waitpid(id,&status,0);
if(rid>0)
{
last_exit_code = WEXITSTATUS(status);
}
return 0;
}
//...
int main()
{
//初始化环境变量表
InitEnv();
//PrintG_env();
while(1)
{
//输出命令行提示符
PrintCommandLinePrompt();
//获取命令
char command[COMMAND_SIZE];
if(!GetCommand(command,sizeof(command)))
continue;
//重定向分析 "ls -al > file.txt" --- "ls -al" "file.txt"
RedirCheck(command);
//命令解析
if(!CommandParse(command))
continue;
//PrintG_argv();
//内建命令与别名
if(CheckAndExecBuilt()) //内建命令返回true,不创建子进程
continue;
//执行命令
Execute();
}
return 0;
}

运行如下stream.cc,输出重定向的写法应该是 ./stream 1 > log.txt ,我们常常省略文件描述符。

直接运行程序时标准输出流std::cout、stdout与标准错误流std::cerr、stderr都会打印到终端屏幕,看似输出效果一致,但二者底层绑定了完全不同的文件描述符,标准输出对应fd=1,标准错误固定对应fd=2,并非共用同一个文件描述符;只是默认场景下fd1与fd2都指向终端设备文件,才造成输出混杂在一起的视觉假象,二者数据流相互独立,使用输出重定向>仅能捕获fd1的内容,无法拦截fd2的错误输出。

./stream 1> log.normal 2> log.error 正常信息和错误信息分别打印到两个文件。fd应该紧跟> 。

系统单独提供标准错误流 stderr,是为了实现标准输出与错误信息分流的能力:程序正常运行的打印内容(printf、std::cout)走标准输出 fd=1,程序报错、异常提示(perror、std::cerr)走标准错误 fd=2;二者相互独立,支持分开重定向保存,便于区分普通日志与故障信息,排查问题时不会混淆正常输出和报错内容。
将正常信息和错误信息打印到同一个文件中。

理解“一切皆文件”
Linux中,磁盘、显示器、键盘这样硬件设备和管道、socket套接字等被抽象成了文件,可以使用访问文件的方法访问它们获得信息。这样做最明显的好处是,开发者仅需要使用一套 API 和开发工具,即可调取 Linux 系统中绝大部分的资源。如Linux 中几乎所有读(读文件,读系统状态,读PIPE)的操作都可以用 read 函数来进行;几乎所有更改(更改文件,更改系统参数,写 PIPE)的操作都可以用 write 函数来进行。
当打开一个文件时,操作系统为了管理所打开的文件,都会为这个文件创建一个 struct file 结构体。其中的 f_op 指针指向了一个 file_operations 结构体,这个结构体中的成员除了 struct module* owner 其余都是函数指针。
struct file_operations
{
//指向拥有该模块的指针。
struct module *owner;
//llseek 用作改变文件中的当前读/写位置, 并且新位置作为(正数)返回值。
loff_t (*llseek) (struct file *, loff_t, int);
// 用来从设备中获取数据。在这个位置的一个空指针导致 read 系统调用以
// -EINVAL("Invalid argument") 失败. 一个非负返回值代表了成功读取的字节数(返回值是一个
// "signed size" 类型, 常常是目标平台本地的整数类型).
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
//发送数据给设备. 如果 NULL, -EINVAL 返回给调用 write 系统调用的程序. 如果非负,
//返回值代表成功写的字节数.
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
//...
};
file_operations 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用。读取 file_operations 中相应的函数指针,接着把控制权转交给函数,从而完成了Linux设备驱动程序的工作。

上图中的外设,每个设备都可以有自己的read、write,但一定是对应着不同的操作方法。通过 struct file 下 file_operation 中的各种函数回调,让开发者只用file便可调取 Linux 系统中绝大部分的资源。
缓冲区
缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。
读写文件时,如果不开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行读写等操作,那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用。执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。
为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。如我们从磁盘里取信息,在磁盘进行文件操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作快于对磁盘的操作,故应用缓冲区可提高计算机的运行速度。


观察如上代码和运行结果,关闭标准输出,往log.txt中写内容。printf/fprintf/fputs... 等库函数带有C标准库提供的用户级缓冲区,调用这些库函数输出内容时,会把这些内容先放在这个缓冲区中,当强制刷新或满足刷新条件或进程退出时,才会把这些内容刷新出缓冲区。刷新出这个缓冲区需要fd和系统调用,第一份代码调用 close(fd) ,使进程退出前fd失效,所以log.txt只有 hello write 。注释 close(fd) 或调用 fflush(stdout) 强制刷新可以正常打印。
C语言的 FILE 是通过 typedef _IO_FILE FILE ,其中包含了指向这个缓冲区的指针。vim /usr/include/libio.h 。

三种缓冲策略:
- 全缓冲(普通磁盘文件默认):缓冲区写满才自动刷新,效率最高;
- 行缓冲(stdout 终端显示器默认):遇到换行
\n、缓冲区满、读输入时刷新; - 无缓冲(stderr 标准错误):数据直接下发内核,实时输出。
对于内核缓冲区,数据进入内核缓冲即代表交付系统,由操作系统自主调度刷入磁盘硬件;无需程序手动管控。
计算机数据流动的本质:一切皆拷贝。
前面代码修改,注释 close(fd) ,在进程结束前调用 fork() ,log.txt会记录两次printf的内容。

用户缓冲区属于进程内存,fork 复制整个进程地址空间(写时拷贝),连带未刷新的缓存一起复制。write 系统调用无用户缓冲,数据直接进入内核,不受 fork 复制缓存影响。stdout 绑定普通文件log.txt时是全缓冲,换行不刷新,缓存会留存到 fork;stdout 绑定终端时是行缓冲,换行自动刷空。进程正常退出会自动 fflush 所有标准 IO 缓存,父子进程分别刷新各自副本,造成内容重复。
所以,如果代码不调用 close(1) 关闭标准输出,而是输出重定向 ./test > log.txt ,此时效果和原理同理。
下面实现自定义MyStdio。模拟实现文件打开、写入、刷新、关闭。
创建文件 mystdio.h ,声明结构体、函数。
#pragma once
#include <stdio.h>
//刷新方式
#define NONE_FLUSH (1<<0) //1不刷新
#define LINE_FLUSH (1<<1) //2行缓冲
#define FULL_FLUSH (1<<2) //4全缓冲
#define MAX 1024
typedef struct IO_FILE
{
int fileno; //文件描述符fd
int flag; //文件打开方式
int flush_method; //刷新方式
char outbuffer[MAX]; //缓冲区
int bufferlen; //缓冲区大小
}MyFile;
MyFile* MyFopen(const char* path,const char* mode);
int MyFwrite(MyFile* pf,const void* str,int len);
void MyFFlush(MyFile* pf);
void MyFclose(MyFile* pf);
创建文件 mystdio.c ,实现函数。
MyFopen() 打开文件,参考 fopen ,传入要打开的文件的路径和操作,通过匹配操作来控制flag,然后使用系统调用 open ,最后返回指向打开文件的MyFile指针。
BuyFile() 打开文件后创建一个指针指向MyFile对象,填入打开文件的文件描述符、打开方式等信息。static修饰 BuyFile() 后把创建 MyFile 对象的底层函数私有化,只能在 mystdio.c 内部调用,禁止外部用户随意调用。
MyFwrite() 把要写入的内容拷贝到MyFile的缓冲区,再判断是否需要行刷新。
MyFFlush() 使用系统调用 write 把MyFile缓冲区的内容写入文件,此时这些内容会先进入内核级缓冲区,再使用系统调用 fsync 强制把在内核缓冲区的内容写入磁盘,最终实现刷新。
MyFclose() 模拟 fclose ,fclose关闭文件后会进行刷新,所以要调用 MyFFlush 。
#include "mystdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
static MyFile* BuyFile(int fd,int flag)
{
MyFile* file = (MyFile*)malloc(sizeof(MyFile));
if(file == NULL)
return NULL;
file->fileno = fd;
file->flag = flag;
file->flush_method = LINE_FLUSH;
file->bufferlen = 0;
memset(file->outbuffer,0,sizeof(file->outbuffer));
return file;
}
MyFile* MyFopen(const char* path,const char* mode)
{
int fd = -1;
int flag = 0;
if(strcmp(mode,"r")==0)
{
flag = O_RDONLY;
fd = open(path,flag,0666);
}
else if(strcmp(mode,"w")==0)
{
flag = O_CREAT | O_WRONLY | O_TRUNC;
fd = open(path,flag,0666);
}
else if(strcmp(mode,"a")==0)
{
flag = O_CREAT | O_WRONLY | O_APPEND;
fd = open(path,flag);
}
else
{
//...
}
if(fd<0)
return NULL;
return BuyFile(fd,flag);
}
int MyFwrite(MyFile* pf,const void* str,int len)
{
//拷贝到缓冲区
memcpy(pf->outbuffer + pf->bufferlen,str,len);
pf->bufferlen+=len;
//判断是否行刷新
if((pf->flush_method & LINE_FLUSH) && (pf->outbuffer[pf->bufferlen-1] == '\n'))
MyFFlush(pf);
return 0;
}
void MyFFlush(MyFile* pf)
{
if(pf->bufferlen<=0) //缓冲区无数据
return;
write(pf->fileno,pf->outbuffer,pf->bufferlen); //缓冲区数据写入内核缓冲区
fsync(pf->fileno);
pf->bufferlen=0;
}
void MyFclose(MyFile* pf)
{
if(pf->fileno<0)
return;
MyFFlush(pf);
close(pf->fileno);
free(pf);
}
测试代码。
#include "mystdio.h"
#include <string.h>
#include <unistd.h>
int main()
{
MyFile* pf = MyFopen("./log.txt","w");
if(pf==NULL)
{
printf("MyFopen error!\n");
return 1;
}
const char* msg = "Hello MyFile!!!";
int cnt=10;
while(cnt--)
{
MyFwrite(pf,msg,strlen(msg));
printf("%s\n",pf->outbuffer);
sleep(1);
}
MyFclose(pf);
return 0;
}
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)