Linux系统编程:基础IO

目录

1.理解“文件”

1.1狭义理解

1.2广义理解

1.3文件操作的归类认知

1.4系统角度

2.回顾C文件接口

2.1打开文件

当前路径

2.2写文件

2.3读文件

2.4 stdin&&stdout&&stderr

3.系统文件I/O

3.1open

第一个参数:表示要打开或者创建的目标文件

第二个参数:表示打开文件的方式

为什么语言要对系统调用进行封装?

如何做到跨平台?

第三个参数:表示创建文件的默认权限

返回值:返回一个文件描述符

3.2close

3.3write

3.4read

3.5重定向

重定向原理

dup2

在minishell中添加重定向功能

4.理解“一切皆文件”

5.缓冲区

5.1什么是缓冲区

5.2为什么要引入缓冲区机制

5.3缓冲类型

5.4FILE

6.模拟简单实现libc库


1.理解“文件”

1.1狭义理解

1.文件在磁盘里。

2.磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的。

3.磁盘是外设(即是输出设备也是输入设备)。

4.磁盘上的文件 本质是对文件的所有操作,都是对外设的输入和输出 简称IO。

1.2广义理解

Linux下一切皆文件(键盘,显示器,网卡,磁盘.....)

1.3文件操作的归类认知

1.对于0KB的空文件是占用磁盘空间的。

2.文件时文件属性(元数据)和文件内容的集合(文件=属性(元数据)+内容)。

3.所有的文件操作本质是文件内容操作和文件属性操作。

1.4系统角度

1.对文件的操作本质是 进程对文件的操作

2.磁盘的管理者是操作系统

3.文件的读写本质不是通过C语言/C++的库函数来操作的(这些库函数只是为用户提供方便),而是通过文件相关的系统调用接口来实现的。

2.回顾C文件接口

C语言文件操作函数汇总:

2.1打开文件

打开的myfile文件在当前路径下,那系统如何知道程序的当前路径?

通过此命令查看当前正在运行进程的信息

ls /proc/[进程id] -l

cwd:指向当前进程运行目录的一个符号链接

exe:指向启动当前进程的可执行文件(完整路径)的符号链接。

当前路径

所以说,当前路径并不是指我们可执行程序所处的路径,而是指 当这个可执行程序运行成为进程时,我们所处的路径,文件就默认创建在这个路径。(比如说你在可执行程序的上一级目录启动这个可执行文件,文件创建在可执行程序的上一级目录,而不是可执行程序那里)

打开文件,本质是进程打开,所以,进程知道自己在何处,即便文件不带路径,进程也知道。

由此OS也就能知道要创建的文件放在哪。

此外,如果文件已经存在,w方式打开文件会先清空文件内容。

那么如果未来想要清空文件,只需要写一段让文件w方式打开再关闭的代码就可以实现。

例如:main函数加上参数int argc,int *argv[]

当argc==2时,fopen(argv[1],"w");判断打开成功与否后直接fclose

这样我们在命令行输入 ./代码文件 文件名,就能将文件内容清空了

以a方式打开,不作清空,在文件结尾进行追加

其余打开方式总览

echo时以>写入指定文件,是以w方式打开

以>>写入指定文件,是以a方式打开

2.2写文件

2.3读文件

如此我们可以实现一个cat

2.4 stdin&&stdout&&stderr

输出信息到显示屏,你有哪些方法?

printf输出:

ps:向显示器写入12345或者给键盘输入12345,看上去是一个int类型,但输入的其实是1,2,3,4,5char类型,所以显示器和键盘也被叫做字符设备,printf("%d",x)为什么要有占位符,就是为了将输入的整型(比如12345 转换成 1,2,3,4,5 比如3.14转换成3,.,1,4,本质是通过查找ASCII码表转换)格式化输出,这也是为什么printf叫做格式化输出的原因。像这种需要格式化的都是文本文件,二进制文件就不需要格式化。这种区别是由文件本身属性导致的,不同属性的文件调用不同的函数接口,二进制就用fread,fwrite,文本就用fgets,fputs。

标准输入,输出,错误  流:

Linux中的一切东西都可以看作文件,显示器和键盘也可以看作文件。我们能看到显示屏上的数据,是因为我们向“显示屏文件”写入了数据,电脑能获取我们键盘上敲击的字符,也是因为电脑从“键盘文件”中读取了数据。

那么为什么我们向“显示屏文件”写入数据或者从“键盘文件”中读取数据,不需要我们先打开对应文件呢?值得注意的是,打开文件一定是进程运行时打开的,而任何进程在启动时会默认打开三个流:标准输入流,标准输出流,标准错误流。对应到C语言当中就是stdin,stdout,stderr,第一个对应“键盘”文件,后俩个对应“显示器文件”。

通过man手册也可以知道这三个流是FILE*类型的,也就是说stdin,stdout,stderr和我们自己打开文件得到的文件指针是同一概念。

当我们C程序运行起来时,操作系统就会默认使用C语言的相关接口将这三个流文件打开,然后我们就能使用 scanf/printf 函数对 键盘/显示器 进行输入输出操作。

ps:标准输入流,标准输出流,标准错误流,在C中是stdin,stdout,stderr,在C++中是cin,cout,cerr,在其他语言中都有类似概念,这种特性由操作系统支持,并不是语言特有的。

3.系统文件I/O

打开文件的方式不仅仅是fopen,ifstream等流式,语言层的方案。其实系统才是打开文件最底层的方案。语言的库函数只是对系统调用接口进行了封装。

3.1open

系统调用接口中使用open函数打开文件,open的函数原型如下:

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

参数介绍:

第一个参数:表示要打开或者创建的目标文件

  • 若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建。
  • 若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。(注意当前路径的含义)

第二个参数:表示打开文件的方式

常用选项如下,

如果要传入多个参数,参数间以“  |  ”间隔开,比如要以写的方式打开文件,但要创建这个文件

O_WRONLY | O_CREAT

PS:系统接口open的第二个参数flags其实就是整型,所谓的参数选项不过是宏定义,flags共有32个比特位(标志位),特点是32个标志位只有一个标志位是1(除了O_RDONLY选项全是0,且为默认选项),如此一来,在open函数内部就能通过“与”运算判断是否设置了某一选项

int open(arg1, arg2, arg3){
	if (arg2&O_RDONLY){
		//设置了O_RDONLY选项
	}
	if (arg2&O_WRONLY){
		//设置了O_WRONLY选项
	}
	if (arg2&O_RDWR){
		//设置了O_RDWR选项
	}
	if (arg2&O_CREAT){
		//设置了O_CREAT选项
	}
	//...
}

ps:open并不像C语言那样默认打开文件时清空内容,如果要打开前清空内容就要再传一个O_TRUNC,只传一个O_WRONLY只是覆盖式写。

所以说库里的函数都是对系统调用的封装

为什么语言要对系统调用进行封装?

首先肯定是系统调用麻烦,因为你用系统接口你还要知道这些宏啊,掩码啊之类的。

其次更重要的就是跨平台性,可移植性。说人话就是你的语言在别的平台也可以用,不同系统比如mac os,windows,Linux的系统调用接口都不一样,不同语言在不同平台下运行它语言的代码,就有对 对应平台系统调用接口  的封装,你封装了才能在这个系统下面使用你的语言,如果不行那这语言肯定没太多人用。文件相关的系统调用是所有语言文件操作的根,只要理解系统调用,语言的文件操作只需熟悉即可。

如何做到跨平台?

所以为啥语言更新一次都是几年一更新,就是因为发布前需要全部平台都兼容。。

第三个参数:表示创建文件的默认权限

例如,将Mode设置为0666,期望中文件创建出来的权限如下:后三个数字分别对应用户,组,其他的权限

但实际创建出的其实是0664,这是因为创建出来的文件的权限还会收到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)

umask的默认值为0002,目的就是限制others随意修改文件。所以当我们设置mode是0666时,创建出的文件真实权限其实是0664.

如果想要创建出的文件权限值不受umask的影响,可以使用umask函数将文件默认掩码设置为0

umask(0);   //将文件默认掩码设置为0

ps:不需要创建文件时,open的第三个可以不设置。

返回值:返回一个文件描述符

文件描述符本质就是一个指针数组的下标,数组中的每一个指针指向被打开文件的信息,open成功时数组指针个数就加1,所以当你一次打开多个文件然后打印他们的返回值时,会发现他们是以1递增的,而且是从3开始。如果打开失败,返回的是-1。而0,1,2其实对应的是标准输入流,标准输出流,标准错误流。这三个文件描述符是默认缺省打开的。

ps:当你手动将0,文件关闭后,你会发现新建的第一个文件描述符就是0了。所以文件描述符的分配规则:在files_struct数组中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。

 OS内一定是存在大量被打开文件的,操作系统当然需要对这些文件进行管理。何谓管理,先描述再组织,依据文件的属性一定是创建了struct file类似的结构体,然后不同结构体里通过指针如同双链表一般链接起来,如此对文件的管理转换成对具体数据结构的管理,这里都转换成了对链表的增删查改。

为了知道文件是哪些进程打开的,所以进程结构体PCB中肯定有一个struct file_struct*file来存储进程打开的文件。

源码:

所以说文件描述符本质是数组下标。这也是为啥write,close要传fd,就是为了方便OS识别打开的文件。我们有了文件描述符就知道要对哪些文件进行操作了。

3.2close

int close(int fd);

关闭某文件传入文件的文件描述符即可。文件关闭失败会返回-1.

3.3write

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

解释:buf位置开始向后count字节的数据写入文件描述符为fd的文件当中。

  • 如果数据写入成功,实际写入数据的字节个数被返回。
  • 如果数据写入失败,-1被返回。

写入文件代码展示:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	const char* msg = "hello syscall\n";
	for (int i = 0; i < 5; i++){
		write(fd, msg, strlen(msg));
	}
	close(fd);
	return 0;
}

3.4read

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

从文件描述符为fd的文件中读取count字节内容到buf里

  • 如果数据读取成功,实际读取数据的字节个数被返回。
  • 如果数据读取失败,-1被返回。

读取操作演示:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	int fd = open("log.txt", O_RDONLY);
    if (fd < 0){
		perror("open");
		return 1;
	}
	char ch;
	while (1){
		ssize_t s = read(fd, &ch, 1);
		if (s <= 0){
			break;
		}
		write(1, &ch, 1); //向文件描述符为1的文件写入数据,即向显示器写入数据
	}
	close(fd);
	return 0;
}

3.5重定向

重定向原理

在明确了文件描述符的概念及其分配规则后,现在我们已经具备理解重定向原理的能力了。重定向的本质就是修改文件描述符对应的struct file*的内容。

输出重定向就是,说人话就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中。

例如,如果我们想让本应该输出到“显示器文件”的数据输出到myfile文件当中,那么我们可以在打开myfile文件之前将文件描述符为1的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开myfile文件时所分配到的文件描述符就是1。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(1);
int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}

运行后我们发现,本该printf到显示器上的内容输入到了myfile文件里,其fd=1,这种情况就叫做输出重定向。常见的重定向有:>, >> , <

解释:
printf函数是默认向stdout输出数据的,而stdout指向的是一个struct FILE类型的结构体,该结构体当中有一个存储文件描述符的变量,而stdout指向的FILE结构体中存储的文件描述符就是1,因此printf实际上就是向文件描述符为1的文件输出数据。
C语言的数据并不是立马写到了内存操作系统里面,而是写到了C语言的缓冲区当中,所以使用printf打印完后需要使用fflush将C语言缓冲区当中的数据刷新到文件中。

或者例如,如果我们想让本应该从“键盘文件”读取数据的scanf函数,改为从log.txt文件当中读取数据,那么我们可以在打开log.txt文件之前将文件描述符为0的文件关闭,也就是将“键盘文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是0。

​
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	close(0);
	int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	char str[40];
	while (scanf("%s", str) != EOF){
		printf("%s\n", str);
	}
	close(fd);
	return 0;
}

​

想要追加重定向的话就给open第二个参数再传O_APPEND,打开文件前close(1)即可。

dup2

要完成重定向,我们可以将fd_array数组中的元素拷贝,将我们的文件拷贝到指定下标位置即可。比如我们要将fd_aeeay[3]当中元素拷贝到fd_array[1]当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了我们自己的文件中。

而在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向。

int dup2(int oldfd, int newfd);

功能:看参数就很好理解,dup2会将fd_array[oldfd]内容拷贝到fd_array[newfd]当中,如果有必要的话我们需要先使用关闭文件描述符为newfd的文件。

返回值:调用成功返回newfd,否则返回-1.

使用dup2时,我们需要注意以下两点:

  1. 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
  2. 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。

例如,我们将打开文件log.txt时获取到的文件描述符fd和1传入dup2函数,那么dup2将会把fd_arrya[fd]的内容拷贝到fd_array[1]中,本应该输出到显示器的数据就会重定向输出到log.txt文件当中。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
	int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	close(1);
	dup2(fd, 1);
	printf("hello printf\n");
	fprintf(stdout, "hello fprintf\n");
	return 0;
}

在minishell中添加重定向功能

添加重定向步骤大致如下:

1.对于获取到的命令进行判断,若命令当中包含重定向符号>、>>或是<,则该命令需要进行处理,即将符号左边解析为命令,右边解析为文件。
2.设置type变量,type为0表示命令当中包含输出重定向,type为1表示命令当中包含追加重定向,type为2表示命令当中包含输入重定向。
3.重定向符号后面的字段标识为目标文件名,若type值为0,则以写的方式打开目标文件;若type值为1,则以追加的方式打开目标文件;若type值为2,则以读的方式打开目标文件。
4.若type值为0或者1,则使用dup2接口实现目标文件与标准输出流的重定向;若type值为2,则使用dup2接口实现目标文件与标准输入流的重定向。

4.理解“一切皆文件”

简单来说,就是一些不是文件的外设设备(键盘,显示器,磁盘等)可以抽象为文件,你可以用访问文件的方式去访问他们的一切信息。如此以来,开发者仅需要使用一套API和开发工具,即可调取Linux系统中绝大部分资源。比如,Linux中几乎所有读操作(读文件,读系统状态)都能用read函数进行,几乎所有更改(更改文件,更改系统参数)的操作都可以用write函数进行。

之前我们说过,当打开一个文件时,操作系统为了管理所打开的文件,就会为这个文件创建一个file结构体,该结构体定义在/usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fs.h下。值得关注的是,struct file中的f_op指针指向了一个file_operations结构体,这个结构体中的成员除了struct module* owner其余都是函数指针。该结构和struct file都在fs.h下,struct file中有指向该结构的指针。

所以系统调用后的流程是这样的:用户调用系统调用,内核根据fd找到struct file(task_struct->file找到文件描述符表,然后再通过fd索引找到struct file),利用file->f_op找到file_operation结构体,然后检查f_op->【对应系统调用名称】是否存在,存在就调用这个函数。

struct file_operations {
struct module *owner;
//指向拥有该模块的指针;
loff_t (*llseek) (struct file *, loff_t, int);
//llseek ⽅法⽤作改变⽂件中的当前读/写位置, 并且新位置作为(正的)返回值.
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
//⽤来从设备中获取数据. 在这个位置的⼀个空指针导致 read 系统调⽤以 -
EINVAL("Invalid argument") 失败. ⼀个⾮负返回值代表了成功读取的字节数( 返回值是⼀个
"signed size" 类型, 常常是⽬标平台本地的整数类型).
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
//发送数据给设备. 如果 NULL, -EINVAL 返回给调⽤ write 系统调⽤的程序. 如果⾮负,
返回值代表成功写的字节数.
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long,
loff_t);
//初始化⼀个异步读 -- 可能在函数返回前不结束的读操作.
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned
long, loff_t);
//初始化设备上的⼀个异步写.
int (*readdir) (struct file *, void *, filldir_t);
//对于设备⽂件这个成员应当为 NULL; 它⽤来读取⽬录, 并且仅对**⽂件系统**有⽤.
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
//mmap ⽤来请求将设备内存映射到进程的地址空间. 如果这个⽅法是 NULL, mmap 系统调⽤
返回 -ENODEV.
int (*open) (struct inode *, struct file *);
//打开⼀个⽂件
int (*flush) (struct file *, fl_owner_t id);
//flush 操作在进程关闭它的设备⽂件描述符的拷⻉时调⽤;
int (*release) (struct inode *, struct file *);
//在⽂件结构被释放时引⽤这个操作. 如同 open, release 可以为 NULL.
int (*fsync) (struct file *, struct dentry *, int datasync);
//⽤⼾调⽤来刷新任何挂着的数据.
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
//lock ⽅法⽤来实现⽂件加锁; 加锁对常规⽂件是必不可少的特性, 但是设备驱动⼏乎从不实
现它.
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *,
int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned
long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t
*, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *,
size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
};

file_operation就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用,读取file_operation中相应的函数指针,接着把控制权转交给函数,从而完成了Linux设备驱动程序的工作。

每个设备都可以有自己的read,write,但一定是对应着不同的操作方法。比如我的显示器只需要实现写方法,读方法函数内部是空的。

通过struct file下file_operation中的各种函数回调,让我们开发者只用file便可调取Linux系统中绝大部分的资源,这便是Linux下一切皆文件的核心理解。

ps:其实这就是VFS虚拟文件系统:(给进程封装文件)

VFS(Virtual File System,虚拟文件系统) 是 Linux 内核中的一个核心抽象层,它通过统一接口屏蔽不同文件系统(如 ext4、NTFS、procfs)和硬件设备(如磁盘、USB、网络存储)的差异,让用户和应用程序能够以一致的方式访问所有文件资源。其核心思想是**“一切皆文件”**。

计算机里的一切问题,都可以通过添加一层软件层解决。这也是为啥进程和内存间需要有进程虚拟地址空间,如果进程直接对应内存就需要维护具体的物理地址,一旦代码和数据发生变化,那么程序对应的地址就会变化,很容易会造成野指针问题,而且自己维护加载到内存的具体物理地址会特别麻烦,所以进程和内存之间添加一层软件层,也就是进程虚拟地址空间,解耦进程与内存的直接依赖。

ps:另外我们还可以发现,这个文件和外设的关系就类似基类和派生类,指针指向什么就调用什么函数,这是多态,只不过Linux是用C语言写的,所以只能用函数指针。

5.缓冲区

打开文件,OS会创建一个strutc file ,其内容又分为三类:文件属性,操作方法集,文件缓冲区。磁盘中存储文件的属性和内容,属性加载到文件属性,内容加载到文件缓冲区,对应文件读写方法加载到操作方法集。未来调用read的时候,文件拷贝到文件缓冲区,拷贝成功后再调用操作方法集里的内容将数据内容刷新到外设。

5.1什么是缓冲区

缓冲区分为文件缓冲区(FILE缓冲区,也叫用户缓冲区),和内核缓冲区,缓冲区的本质其实就是一段内存空间。注意一下,下边提及到的“缓冲区”是指用户缓冲区。

缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区依据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。

5.2为什么要引入缓冲区机制

读写文件时,如果不会开辟堆文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读,写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问会对程序的执行效率造成很大影响。

为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。

又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们CPU就可以处理别的事。可以看出,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放CPU!使其能够高效率的工作。

引入缓冲区还可以提高IO函数的效率,比如像printf把数据格式化后,拷贝到文件缓冲区后就可以立即返回,然后处理余下代码了。(printf流程:数据格式化->拷贝到文件缓冲区->检查是否刷新(因为默认是行缓冲区,检测是否有换行符))

5.3缓冲类型

标准I/O提供了3种类型的缓冲区

1.全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。

2.行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及到一个终端时(例如标准输入和标准输出),使用行缓冲方式。(比如我们将命令打印到显示器上就是行缓冲)因为标准I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/O系统调用操作,默认行缓冲区的大小为1024.

3.无缓冲区:无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。注意这里的不带缓冲区是指不带用户缓冲区(文件缓冲区,也就是FILE缓冲区),系统调用会直接将数据拷贝到内核缓冲区。

所谓库函数不过是封装了系统调用,所有刷新到用户缓冲区的内容最终都会刷新到内核缓冲区。

除了上述列举的默认刷新方式,下列特殊情况也会引发用户缓冲区的刷新:

1.缓冲区满时

2.执行flush语句(用户缓冲区刷新到内核缓冲区)

3.进程结束

ps:fsync(fd) 内核刷新到外设

示例如下:

之前我们也有一个问题就是sleep期间printf的数据去哪了,答案就是在缓冲区中,

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) {
perror("open");
return 0;
}
printf("hello world: %d\n", fd);
close(fd);
return 0;
}

我们本想使用重定向思维,让本应该打印在显示屏上的内容写到“log.txt”文件中(printf函数默认向fd=1的文件输出),但我们发现,程序运行结束后,文件中并没有被写入内容:

这是由于我们将1号描述符重定向到磁盘文件后,缓冲区的刷新方式成为了全缓冲。而我们写入的内容并没有填满整个缓冲区,导致并不会将缓冲区的内容刷新到磁盘文件中。怎么办呢?可以使用fflush强制刷新下缓冲区。

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) {
perror("open");
return 0;
}
printf("hello world: %d\n", fd);
fflush(stdout);
close(fd);
return 0;
}

还有一种方法,刚好可以解释stderr是无缓冲区的

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
close(2);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) {
perror("open");
return 0;
}
perror("hello world");
close(fd);
return 0;
}

这种方式便可以将2号文件描述符重定向至文件,由于stderr没有缓冲区,“hello world”不用fflush语句也可以写入文件

5.4FILE

上述提到的缓冲区都是FILE缓冲区(用户缓冲区),不是内核缓冲区。

因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。所以C库当中的FILE结构体内部,必定封装了fd。

ps:struct FILE本质是一个结构体,里边封装了这些东西

我们研究一下下面这段代码:

#include <stdio.h>
#include <string.h>
int main()
{
const char *msg0="hello printf\n";
const char *msg1="hello fwrite\n";
const char *msg2="hello write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg0), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
return 0;
}

运行结果

如果将输出重定向到我们的文件,再cat打开,会发现运行结果变成了这样

write(系统调用)运行一次,printf和fwrite(库函数)运行了两次。原因肯定和fork有关!

一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。

printf fwrite库函数自己维护了一段用户缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲(遇到\n刷新缓冲区)变成了全缓冲。(缓冲区满才刷新)(普通文件默认全缓冲)

而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后也不立即刷新,但是进程退出后会统一刷新,写入文件当中。

但fork时,用户态缓冲区(由printf/fwrite维护)也会在fork()时复制给子进程,父进程和子进程各自退出后,都会刷新缓冲区内的内容导致数据被重复写入文件。

而write作为系统调用不会维护缓冲区,也就是无缓冲区模式。

综上:printf fwrite库函数都会自己维护缓冲区,而write系统调用则没有缓冲区。我们所说的缓冲区,都是用户级缓冲区(FILE缓冲区)。

其实为了提升整机性能,OS也会提供相关内核级缓冲区。当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统缓冲区,然后再由操作系统将数据刷新到磁盘或是显示器上。(操作系统有自己的刷新机制,我们不必关系操作系统缓冲区的刷新规则)

那么库函数的缓冲区是谁提供的?库函数在系统调用上层,库函数是系统调用的封装,但是系统调用没有缓冲区,库函数有缓冲区,所以库函数的缓冲区是二次加上的,又因为是C,所以由C标准库提供。

常说printf默认打到stdout里,其实像标准输入流,标准输出流都是FILE*类型的指针,他们自己都会维护缓冲区。

ps:标准错误流没有用户缓冲区,直接刷新到内核缓冲区,这是为了错误信息即使打印,以及正常输出和错误输出的分离。

ps:./a.out >ok.txt其实是./a.out 1>ok.txt的简写,因为>是 输出 重定向呗

ps:也可以 newfd>&oldfd 的形式重定向,将oldfd的内容拷贝到newfd。

未来debug时看error.txt就行了

FILE结构体中有一大段缓冲区信息,感兴趣可以看看

//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr;   /* Current read pointer */
char* _IO_read_end;   /* End of get area. */
char* _IO_read_base;  /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr;  /* Current put pointer. */
char* _IO_write_end;  /* End of put area. */
char* _IO_buf_base;   /* Start of reserve area. */
char* _IO_buf_end;    /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base;  /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */

6.模拟简单实现libc库

mystdio.h

  1 #ifndef __MYSTDIO_H__
  2 #define __MYSTDIO_H__
  3 
  4 #define FLUSH_NONE 1
  5 #define FLUSH_LINE 2
  6 #define FLUSH_FULL 4
  7 
  8 #define SIZE 4096
  9 #define UMASK 0666
 10 
 11 #define FORCE 1
 12 #define NORMAL 2                                              
 13 
 14 typedef struct _MY_IO_FILE
 15 {
 16     int fileno;
 17     int flag;
 18     char outbuffer[SIZE];
 19     int curr;
 20     int cap;
 21 }MyFILE;
 22 
 23 MyFILE *my_fopen(const char*filename,const char*mode);
 24 void my_fclose(MyFILE*fp);
 25 int my_fwrite(const char*s,int size,MyFILE*fp);
 26 void my_fflush(MyFILE*fp);
 27 
 28 #endif

mystdio.c

  1 #include"mystdio.h"
  2 #include<stdlib.h>
  3 #include<string.h>
  4 #include<sys/types.h>
  5 #include<sys/stat.h>
  6 #include<fcntl.h>
  7 #include<unistd.h>
  8 MyFILE*my_fopen(const char*filename,const char*mode)
  9 {
 10   int fd=-1;
 11   if(strcmp(mode,"w")==0)
 12   {
 13     fd=open(filename,O_CREAT|O_WRONLY|O_TRUNC,UMASK);
 14   }
 15   else if(strcmp(mode,"r")==0)
 16   {
 17     fd=open(filename,O_RDONLY);
 18   }
 19   else if(strcmp(mode,"a")==0)
 20   {
 21     fd=open(filename,O_CREAT|O_WRONLY|O_APPEND,UMASK);
 22   }
 23   else if(strcmp(mode,"a+")==0)
 24   {
 25     fd=open(filename,O_CREAT|O_RDWR|O_APPEND,UMASK);
 26   }
 27   else 
 28   {
 29     //...
 30   }
 31   
 32   //打开文件失败
 33   if(fd<0)return NULL;
 34   MyFILE*fp=(MyFILE*)malloc(sizeof(MyFILE));
 35   if(!fp)return NULL;
 36   
 37   //打开文件就是在创建FILE对象                                         
 38   fp->fileno=fd;
 39   fp->flag=FLUSH_LINE;
 40   fp->curr=0;
 41   fp->cap=SIZE;
 42   fp->outbuffer[0]=0;
 43 
 44   return fp;
 45 }
 46 void my_fclose(MyFILE*fp)
 47 {
 48     if(fp->fileno>=0)                                                  
 49     {
 50       //强制刷新一下
 51       my_fflush(fp);
 52       fsync(fp->fileno);
 53       close(fp->fileno);
 54       free(fp);
 55     }
 56 
 57 }
 58 
 59 static void my_fflush_core(MyFILE*fp,int force)
 60 {
 61   if(fp->curr<=0)return;
 62   if(force==FORCE)
 63   { 
 64     write(fp->fileno,fp->outbuffer,fp->curr);
 65     fp->curr=0;
 66   }
 67   else
 68   {
 69     if( (fp->flag&FLUSH_LINE) && fp->outbuffer[fp->curr-1]=='\n')
 70     {
 71       //刷新,也就是系统调用
 72       write(fp->fileno,fp->outbuffer,fp->curr);
 73       fp->curr=0;
 74     }
 75     else if((fp->flag&FLUSH_FULL)&&fp->curr==fp->cap)
 76     {
 77       write(fp->fileno,fp->outbuffer,fp->curr);
 78       fp->curr=0;
 79     }
 80     else 
 81     {
 82       write(fp->fileno,fp->outbuffer,fp->curr);
 83       fp->curr=0;
 84     }
 85   }
 86 }
 87 int my_fwrite(const char*s,int size,MyFILE*fp)
 88 {
 89   //fwrite的本质:拷贝
 90   memcpy(fp->outbuffer+fp->curr,s,size);
 91   fp->curr+=size;
 92 
 93   //检测刷新,必须符合刷新条件 
 94   my_fflush_core(fp,NORMAL);
 95   return size;
 96 }
 97 void my_fflush(MyFILE*fp)
 98 {
 99   my_fflush_core(fp,FORCE);
100 }

test.c

  1 #include"mystdio.h"
  2 #include<string.h>
  3 int main()
  4 {
  5   MyFILE*fp=my_fopen("log.txt","w");
  6   if(fp==NULL)
  7   {
  8     return 1;
  9   }
 10 
 11   const char *s="hello myfile\n";
 12   int cnt=20;
 13   while(cnt--)
 14   {
 15     my_fwrite(s,strlen(s),fp);
 16   }
 17 
 18   my_fclose(fp);
 19   return 0;                                      
 20 }

此篇完

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_dindong

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值