【Linux】基础 IO(文件描述符fd & 缓冲区 & 重定向)

1. 前言
文件 = 内容 + 属性

访问文件之前必须先打开它,为什么要先打开呢?
访问一个文件的时候,是 进程 在访问它
当文件没有被打开的时候,是保存在 磁盘 中
为啥访问一个文件是进程在访问呢?来看一段代码

#include <stdio.h>
int main()
{
FILE *fp = fopen("log.txt", "w");
if(fp == NULL)
{
perror("fopen");
return 1;
}

const char *message = "hello file\n";
int i = 0;
while(i < 5)
{
fputs(message, fp);
i++;
}

fclose(fp);
return 0;
}

结果如下:

 

我们可以发现:

程序结束之后,会在当前目录下新建 log,txt 文件
查看文件时内容已被写入
这个文件在磁盘中已经被保存好了
💫 那么我们现在有个问题,我们编好了代码,这个文件是不是就打开了 -- 没有,因为我们把代码写好之后,这个还只是一个文本,那是不是把代码编译成可执行程序,文件就打开了 -- 答案也是没有的,把原代码编译成可执行程序仅仅是跑起来了。

🌈 那么什么时候文件才真正被打开呢?

当我们的程序运行的时候,执行到 fopen 函数时并且成功之后,文件才会打开。
此时就知道 foepn 就和 malloc 、new 类似, 属于运行时操作,当程序执行完 fopen ,这个文件才会打开。
因此访问一个文件,不是程序在访问,而是进程在访问。
进程 是在 内存 当中的,进程加载到内存中,最终是由 CPU 去执行,可是进程要进行文件读取操作时,这个文件是在磁盘上的,它们又是咋联系上的呢?

根据 冯诺依曼 体系,一个文件有内容和属性,将来也要被 CPU 所读取,可是进程在内存里,文件在磁盘上的,而CPU 无法直接访问磁盘,就需要先去打开该文件,将文件也加载到内存中,否则进程访问不到,因为 CPU 也访问不到
文件 = 内容 + 属性,因此我们加载到内存的就是 内容 和 属性,我们刚刚讲的都是一个进程可以打开一个文件,此外一个进程也可以打开多个文件。
由于文件需要加载到内存当中,同时我们的文件数目比进程数更多,进程都需要 OS 管理,那么 OS 对于加载到 内存的文件也需要做管理
结论:访问一个文件之前必须先打开它,根据冯诺依曼,无法访问磁盘上的文件,必须加载到内存上

如何管理文件?

先描述再组织
内核中,文件 = 文件的内核数据结构 + 文件的内容
结论:我们研究打开的文件,就是在研究 进程 和 文件 的关系

2. 输出重定向
我们上面 fopen 中的 'w' 是 覆盖式写入,会将文件清空之后再写入。这个就类似于 我们之前学的

 

这个 > 就叫作 输出重定向,写入前把文件先清空。

案例:给上面代码加个 字符数组

int main()
{
FILE *fp = fopen("log.txt", "w");
if(fp == NULL)
{
perror("fopen");
return 1;
}

char buffer[1024];
const char *message = "hello file";
int i = 0;
while(i < 5)
{
snprintf(buffer, sizeof(buffer), "%s:%d\n", message, i);
fputs(buffer, fp);
i++;
}

fclose(fp);
return 0;
}

输出如下:

 

追加写入 -- a

 

同样在 echo 命令中 我们也可以用 >> 来追加式写入

 

3. 标准输入输出流 💦
概念补充:任何一个程序在启动之前默认需要打开三个流

stdin : 标准输入 -- 键盘
stdout :标准输出 -- 显示器
stderr : 标准错误 -- 显示器
但是键盘、显示器不是属于硬件嘛,怎么跟文件流有关系,这个和我们之前学的 Linux 下一切皆文件有关(TODO)

 

一个程序启动时会打开三个流,而其中 C 语言底层所对应的硬件时键盘、显示器,但是它把这个键盘、显示器包装成了文件的样子,最后就可以 File* 的形式来访问文件了。

那么现在有个问题是谁默认打开这三个流的呢?

进程默认会打开这三个输入输出流,毕竟程序只是可执行文件还没有运行,而且访问文件必须先把文件打开,就需要调用 fopen,三个标准输入输出流默认就是进程打开。 同样 C++ 也有三个输入输出流 -- cin、cout、cerr
把打印内容到显示器的 三种方法

#include <stdio.h>

int main()
{
printf("hello world\n");
fputs("aaaa", stdout);
fwrite("bbbb", 1, 4, stdout);
fprintf(stdout, "cccc");
return 0;
}
4. open 函数 🖊
4.1 基本概念
上面的flags 表示打开文件的标记位,以只读或只写等形式打开,mode 表示创建文件权限

① pathname: 要打开或创建的目标文件

② flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags(本质是个 宏)

参数说明:

O_RDONLY 以只读方式打开文件
O_WRONLY 以只写方式打开文件
O_RDWR 以可读写方式打开文件。
上述三种旗标是互斥的,也就是不可同时使用,但可与下列的旗标利用OR(|)运算符组合。
O_CREAT 若欲打开的文件不存在则自动建立该文件。注:需要使用mode选项,来指明新文件的访问权限
O_EXCL 如果O_CREAT 也被设置,此指令会去检查文件是否存在。文件若不存在则建立该文件,否则将导致打开文件错误。此外,若O_CREAT与O_EXCL同时设置,并且欲打开的文件为符号连接,则会打开文件失败。
O_TRUNC 若文件存在并且以可写的方式打开时,此旗标会令文件长度清为0,而原来存于该文件的 资料也会消失。
O_APPEND 当读写文件时会从文件尾开始移动,也就是所写入的数据会以附加的方式加入到文件后面。
③ 参数mode 组合

此为Linux2.2以后特有的旗标,以避免一些系统安全问题。参数mode 则有下列数种组合,只有在建立新文件时才会生效,此外真正建文件时的权限会受到umask值所影响,因此该文件权限应该为(mode-umaks)

④ 返回值

若所有欲核查的权限都通过了检查则返回文件描述符,表示成功,只要有一个权限被禁止则返回-1。

4.2 mode -- 权限
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
open("log.txt", O_WRONLY|O_CREAT);
return 0;
}

运行上面代码,发现创建了log.txt,但是它的权限是乱码的。这是因为我们在最初的时候并没有给其分配权限,修改如下:

open("log.txt", O_WRONLY|O_CREAT, 0666);

此时权限就正常了,但是我们明明指定的权限明明是 666 ,但是这上面为啥显示的是 664 呢,因为系统存在 umask(0002)的默认权限掩码,权限掩码会与我们设置的权限进行位运算。那么我们应该怎么做,才能不让其去掉这个权限呢?如下:

 

此时将代码中的 umask 设置为对应的 0 后,权限掩码就不会给我们去掉 默认的 umask (0002)了,结果就对上了

注意:权限掩码按照就近原则,如果我们有设置默认权限掩码,就用我们设置的,如果没有,就会使用系统默认的。

4.3 close 和 write 函数

int main()
{
int fd1 = open("log.txt", O_WRONLY|O_CREAT, 0666);
if(fd1 < 0)
{
perror("open");
return 1;
}
printf("fd1: %d\n", fd1);

const char* message = "hello world\n";
write(fd1, message, strlen(message));

close(fd1);
return 0;
}

上面是系统调用接口close和write。fd就是open的返回值。

输出如下:

 

我们把message里的内容换成aaa,然后直接运行代码。

const char* message = "aaa";
发现之前的内容还在,旧的内容没被完全清空:

 

这是因为这里的open默认不存在就创建,存在就打开,默认不清空文件
如果我们想像C语言fopen的“w”打开方式一样, 打开就清空文件,就需要再传 O_TRUNC

 

表示 如果文件已经存在,而且是个常规文件,并以写的方式打开,传入这个选项后,他就会把文件清空。

补充: 我们还可以用 O_APPEND 来对内容进行 追加式写入

5. 系统调用和库函数
还记得我们上面写的 fd 作返回值嘛,在认识返回值之前,先来认识一下两个概念:系统调用和库函数

fopen fclose fread fwrite都是C标准库当中的函数,我们称之为库函数(libc)
open close read write lseek 都属于系统提供的接口,称之为系统调用接口

系统调用接口和库函数的关系,一目了然。
所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。

举个例子:

int main()
{
int a = 12345;
write(1, &a, sizeof(a));
return 0;
}
经过输出,我们发现最后输出结果不是 12345

原因: 12345 是整数,但是显示器是个字符设备只认字符
解决如下:

int main()
{
int a = 12345;
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%d", a);
write(1, buffer, strlen(buffer));
return 0;
}
🍋 因此我们可以知道直接把数字打印到显示器用系统调用接口是不行的,必须做相关的转化变成相关字符然后依次地达到显示器上。

🍎 那么我们有个问题,我们已经有了对应的 read 接口向显示器写,为啥还需要提供这么多写入接口呢?

因为很多情况下需要把我们内存级别的二进制数据转化成字符串风格,然后通过 write 打印到显示器上,这个就叫作 格式化 的过程,然后由于系统调用,这个需要用户自己来实现,为了方便,就提供了这些接口.

6. 文件描述符 fd
6.1 基本了解

输出如下:

 

文件描述符就是一个小整数

open 的返回值 fd 是从 3 开始的。因为C语言默认会打开三个输入输出流,

标准输入stdin
标准输出stdout
标准错误stderr

情况一: write 向 1 输出

可以用write配合文件描述符在显示器上打印 ---

int main()
{
const char *message = "hello write\n";
write(1, message, strlen(message)); // 默认提供的
}

// 输出描述:
[lighthouse@VM-8-10-centos File-IO]$ ./filecode
hello write
情况二:read 向 0 读取

int main()
{
char buffer[128];
ssize_t s = read(0, buffer, sizeof(buffer));
if(s > 0){
buffer[s - 1] = 0; // 吞掉最后一个换行符
printf("%s\n", buffer);
}
return 0;
}

// 输出描述:
[lighthouse@VM-8-10-centos File-IO]$ ./filecode
abcd
abcd
情况三:把字符串 \0 写入文件

int main()
{
int fd1 = open("log.txt", O_WRONLY| O_CREAT | O_APPEND, 0666);
const char* message = "aaa\n";
write(fd1, message, strlen(message) + 1);

close(fd1);
return 0;
}

我们会发现这样的结果,这个是为什么呢? --》 字符串以 \0 结尾,和文件没有关系。

 

🍉 文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了 file 结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针 *files, 指向一张表files_struct,该表最重要的部分就是包含一个指针数组,每个元素都是一个指向打开文件的指针!所以本质上,文件描述符就是该数组的下标,只要拿着文件描述符,就可以找到对应的文件

Linux中一切皆文件,所以0,1,2可以代表键盘,显示器。
在OS内,系统在访问文件的时候,只认文件描述符fd:

FILE* 是C语言提供的结构体类型,里面封装着文件fd
所有的C语言上的文件操作函数,本质都是对系统调用的封装
说到了fd,我们就不得不来区分下 FILE 和 fd

FILE 是C库当中提供的一个结构体,而fd 是系统调用,更加接近于底层,因此 FILE 中必定封装了 fd
我们可以来看看 FILE 的结构体:
typedef struct _IO_FILE FILE; 在 /usr/include/stdio.h 它的结构体中有这么一段

struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

//缓冲区相关
/* 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. */

struct _IO_marker *_markers;

struct _IO_FILE *_chain;

int _fileno;//fd的封装

可以看到 int_fileno 就是对 fd 的封装,在这一部分的开头有一大段跟缓冲区相关的内容,为什么要诺列出它呢,我们来看个例子

int main()
{
printf("stdin: %d\n", stdin->_fileno);
printf("stdout: %d\n", stdout->_fileno);
printf("stderr: %d\n", stderr->_fileno);
// stdin、stdout、stderr、file* 必须用到文件描述符
FILE* fp = fopen("log.txt", "w");
printf("fp: %d\n", fp->_fileno);

return 0;
}

FILE* 结构体中就封装着文件描述符fd
补充:fileno 的了解

用来取得参数stream指定的文件流所使用的文件描述

学了系统调用,我们可以用系统调用接口,也可以用语言提供的文件方法。但还是推荐使用语言提供的方法。因为系统不同,系统调用的接口可能不一样

6.2 深入了解 fd
int main()
{
while(1)
{
printf("%d\n",getpid());
sleep(1);
}
return 0;
}

// 输出:
31926
我们打开另一个终端,查看该进程下的 proc 目录:

 

查看该进程的文件夹,cwd就是当前进程的工作路径。exe指向当前可执行程序的二进制文件。里面还有一个目录fd
进入fd目录,可以看到默认的文件描述符0、1、2是打开的。

打开的设备是dev目录下的pts/3,演示如下:

 

云服务器下, 我们看到的显示器文件一般在 /dev/pts/目录下,即就是我们打开的终端数

 

6.3 read 和 stat
read 函数

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
​​​​​​​fd:文件描述符
buf:写入的缓冲区
count:写的字符长度,也就是看你需要写多少
返回值:
如果顺利write()会返回实际写入的字节数。当有错误发生时则返回-1,错误代码存入errno中
​​​​​​​

stat 函数

#include <sys/stat.h>
#include <unistd.h>
int stat(const char *file_name, struct stat *buf);
函数说明: 通过文件名filename获取文件信息,并保存在buf所指的结构体stat中
返回值: 执行成功则返回0,失败返回-1,错误代码存于errno

错误代码:

ENOENT 参数file_name指定的文件不存在
ENOTDIR 路径中的目录存在但却非真正的目录
ELOOP 欲打开的文件有过多符号连接问题,上限为16符号连接
EFAULT 参数buf为无效指针,指向无法存在的内存空间
EACCESS 存取文件时被拒绝
ENOMEM 核心内存不足
ENAMETOOLONG 参数file_name的路径名称太长
struct stat {
dev_t st_dev; //文件的设备编号
ino_t st_ino; //节点
mode_t st_mode; //文件的类型和存取的权限
nlink_t st_nlink; //连到该文件的硬连接数目,刚建立的文件值为1
uid_t st_uid; //用户ID
gid_t st_gid; //组ID
dev_t st_rdev; //(设备类型)若此文件为设备文件,则为其设备编号
off_t st_size; //文件字节数(文件大小)
unsigned long st_blksize; //块大小(文件系统的I/O 缓冲区大小)
unsigned long st_blocks; //块数
time_t st_atime; //最后一次访问时间
time_t st_mtime; //最后一次修改时间
time_t st_ctime; //最后一次改变时间(指属性)
};
使用如下:

int main()
{
struct stat st;
int n = stat("log.txt", &st);
if(n < 0) return 1;
printf("file size: %lu\n", st.st_size);
int fd = open("log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);

char* file_buffer = (char*)malloc(st.st_size + 1);
n = read(fd, file_buffer, st.st_size);
if(n > 0)
{
file_buffer[n] = '\0';
printf("%s\n", file_buffer);
}
return 0;
}

运行如下:

 

struct stat 是一个内核结构体,可以直接用,stat 的参数2是一个输出型参数,我们把参数传进去后,它会把参数填满然后再传出来
read 的参数 1 指读取的文件fd,参数2是将读取到的内容放到该缓冲区中,参数3是要读取的字节数。

read的返回值:>0

当读取到的字节数=0时表示此时已经读取到文件末尾。

6.4 文件描述符分配规则
还记得 我们上面演示得文件描述符从 3 开始嘛,但是当我们先 close 把文件描述符 0 关掉的时候,又会出现什么情况呢?

int main()
{
close(0);
int fd1 = open("log1.txt", O_WRONLY| O_CREAT | O_APPEND, 0666);
int fd2 = open("log2.txt", O_WRONLY| O_CREAT | O_APPEND, 0666);
int fd3 = open("log3.txt", O_WRONLY| O_CREAT | O_APPEND, 0666);
int fd4 = open("log4.txt", O_WRONLY| O_CREAT | O_APPEND, 0666);

printf("fd1: %d\n", fd1);
printf("fd2: %d\n", fd2);
printf("fd3: %d\n", fd3);
printf("fd4: %d\n", fd4);

close(fd1);
close(fd2);
close(fd3);
close(fd4);

return 0;
}

情况一:把 0 关掉,输出如下:

 

情况二:把 2 关掉,输出如下:

 

因此我们可以得到一个结论:

进程打开文件,需要给文件分配新的 id, 文件描述符的分配规则是从最小的,没有被使用的 fd 开始
注意:文件描述符表中遵循最小未使用分配规则,也就是从表中找寻最小的没有被使用的位置进行存储,因此并不保证多次打开会使用同一个文件描述符

故在进程中多次打开同一个文件返回的文件描述符不一定是一致的
6.5 两个进程打开同一个文件的理解
经过上面所学我们,可以知道,在一个进程中打开一个文件,会在进程内生成文件的描述信息结构,并将其地址添加到pcb中的文件描述信息数组中,最终返回所在位置下标作为文件描述符

两个进程中分别产生生成两个独立的fd
进程数据独有,各自有各自的文件描述信息表,因此各自打开文件会有自己独立的描述信息添加在各自信息表的不同位置,因此fd各自也相互独立​​​​​​​​​​​​​​
两个进程可以任意对文件进行读写操作,操作系统并不保证写的原子性
两个进程打开同一个文件,但是各有各的文件描述信息以及读写位置,互不影响,因此多个进程同时读写有可能会造成穿插覆盖的情况(原子性操作,被认为是一次性完成的操作,操作过程中间不会被打断,通常以此表示操作的安全性)
进程可以通过系统调用对文件加锁,从而实现对文件内容的保护
文件锁就是用于保护对文件当前的操作不会被打断,就算时间片轮转,因为已经对文件加锁,其他的进程也无法对文件内容进行操作,从而保护在本次文件操作过程是安全的。
任何一个进程删除该文件时,另外一个进程不会立即出现读写失败
删除文件实际上只是删除文件的目录项,文件的数据以及inode并不会立即被删除,因此若进程已经打开文件,文件被删除时,并不会影响进程的操作,因为进程已经具备文件的描述信息(可以编写代码进行尝试,在文件打开后,外界删除文件,然后看进程中是否还可以继续写入或读取数据)
.两个进程可以分别读取文件的不同部分而不会相互影响
如果仅仅是读取文件内容,两个不同进程其实都有自己各自的描述信息和读写位置,因此可以同时读取文件数据而不会受到对方的影响。
一个进程对文件长度和内容的修改另外一个进程可以立即感知
因为文件内容的修改是直接反馈至磁盘文件系统中的,因此当文件内容被修改,其他进程因为也是针对磁盘数据的操作,因此可以立即感知到(可以写代码尝试一个进程打开文件后,等其他进程修改了内容后然后再读取文件数据进行测试)
7. 重定向 📕
7.1 基本了解
刚刚我们演示的是把 0 和 2 关掉,那我们把 1 关掉会是怎样的呢?

 

我们却发现连打印结果此时都没有了,原因:

文件描述符 1 是标准输出流,关闭后,就不会在显示器打印了。

 

但是我们发现log.txt创建出来了,但里面什么东西也没有

然后我们用 fflush 来更新缓冲区,做出如下修改:

fflush(stdout); // TODO
函数定义:int fflush(FILE *stream);
函数说明:调用fflush()会将缓冲区中的内容写到stream所指的文件中去.若stream为NULL,则会将所有打开的文件进行数据更新
结果如下:
我们可以发现:本来应该向显示器写入,结果却写到了文件中,我们来看下面的一个图片

 

💖 log.txt 存在磁盘中,当进程启动打开时,就会被加载到内存中。由于我们先关闭了文件描述符1,所以此时 log.txt 的文件描述符就是1。上层的 printf 和 fprintf 都是向 stdout 打印,而 stdout 的描述符是1,OS只认文件描述符,所以最终就向 log.txt 打印了内容。

这个动作我们就叫作 重定向 🍻

重定向的本质:是在内核中改变文件描述符表特定下标的内容,与上层无关 ‼️

🌈 每个文件对象都有对应的内核文件缓冲区,我们写数据都是从上层通过文件描述符1,写到对应的文件缓冲区,然后OS再把内容刷新到磁盘的文件中。

stdin、stdout、stderr 都是 FILE* 结构体,里面除了封装着fd,还有语言级别的文件缓冲区。所以我们通过 printf / fprintf 不是直接写到OS的内部的缓冲区,而是直接写到语言级别的缓冲区中,然后 C语言 再通过 1 号文件描述符 把内容刷新到OS的内核文件缓冲区中。
所以 fflush() 里面是 stdout,这是因为我们是刷新语言级别缓冲区的内容到OS的内核缓冲区中,内核缓冲区的内容由OS进行刷新。

💦 因此由上面可知,最开始没有的 fflush 的时候,log.txt 文件里面啥也没有,是因为内容在语言级别的缓冲区中,还没执行到 return 语句,冲刷内容到内核缓冲区中,log.txt 就被关闭了。

🔥 对重定向更深理解,打个比方:

🌈 假如最开始的时候 1 号文件的内容指向显示器,3 号文件内容指向 log.txt。重定向的本质是将 3 号的内容拷贝给 1 号。所以 1 号就不会再指向显示器了,而是变成指向 log.txt,所以后来往 1 号里写的内容都会变成往 log.txt 里写。

💢 struct file 里还存在一个引用计数,有几个指针指向就是几。如 log.txt 由1号和 3 号指向就是2,显示器就是 0

注意:

如下代码:

int main()
{
close(1);
int fd1 = open("log1.txt", O_WRONLY| O_CREAT | O_APPEND, 0666);
printf("printf, fd1: %d\n", fd1);
fprintf(stdout, "fprintf fd: %d\n", fd1);
//fflush(stdout); // TODO
//close(fd1);
return 0;
}
当我们把close也注释掉之后, log1.txt 中也会有内容,如下:

 

原因:return的时候,语言级别缓冲区的内容就被冲刷到内核文件缓冲区中,此时 log1.txt 也有内容了

结论:当一个进程在退出的时候,会自动刷新自己的缓冲区(所有的FILE对象内部,包括 stdin、stdout、stderr),fclose() -> c 语言 -> 关闭 FILE 的时候,也会自动刷新

因此我们之前 close 没有注释的时候,信息出不来是因为:

close 刷新是在进程退出之后的,当进程正在进行的时候,此时数据就存在 stdout 缓冲区里面,调用 close 的时候进程没有退出,但是却提前把文件描述符关了,因此就没有打印到显示器上,而用 fflush 可以直接帮我们把数据冲刷到内核文件缓冲区中
7.2 dup2
刚刚我们演示的操作是最朴素的,是关闭再打开只有一次操作,那么有没有更直接的操作来演示输出重定向操作的呢?

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
函数功能为将newfd描述符重定向到oldfd描述符,相当于重定向完毕后都是操作oldfd所操作的文件
但是在过程中如果newfd本身已经有对应打开的文件信息,则会先关闭文件后再重定向(否则会资源泄露)
dup用来复制参数oldfd所指的文件描述符。当复制成功是,返回最小的尚未被使用过的文件描述符,若有错误则返回-1.错误代码存入errno中返回的新文件描述符和参数oldfd指向同一个文件,这两个描述符共享同一个数据结构,共享所有的锁定,读写指针和各项全现或标志位
dup2 与 dup 区别 是 dup2 可以用参数newfd指定新文件描述符的数值。

若参数newfd已经被程序使用,则系统就会将newfd所指的文件关闭,若newfd等于oldfd,则返回newfd,而不关闭newfd所指的文件。

dup2 所复制的文件描述符与原来的文件描述符共享各种文件状态。共享所有的锁定,读写位置和各项权限或 flags 等,

本质是文件描述符下标对应内容的拷贝

如果我们要对标准输出进行重定向,把往显示器打印的内容变成往log,txt打印,根据上面的参数解释,参数的填法应该是dup2(fd,1)。也就是把oldfd留下来,拷贝给newfd
代码如下:

int main()
{
int fd = open("log.txt", O_WRONLY| O_CREAT | O_TRUNC, 0666);

dup2(fd, 1);

printf("hello fd: %d\n", fd);
fprintf(stdout, "hello fd: %d\n", fd);
fputs("hello world\n", stdout);

const char*message = "hello fwrite\n";
fwrite(message, 1, strlen(message), stdout);

return 0;
}

运行上面代码,发现不在显示器上打印,而是在log.txt里打印

7.3 追加重定向 ​​​​​​​
把文件打开的方式改成 O_APPEND即可。

 

因此我们可以知道:输出重定向和追加重定向没有区别,只是打开的方式不一样而已。

7.4 输入重定向
先实现一段从标准输入读的代码

int main()
{
char buffer[2048];
size_t s = read(0, buffer, sizeof(buffer));

if(s > 0)
{
buffer[s - 1] = 0;
printf("stdin redir: \n%s\n", buffer);
}

return 0;
}
运行如下:

 

我们加上输入重定向的代码到 main 函数的最上面几行

// 输入重定向:需要文件存在且可读
int fd = open("log.txt", O_RDONLY);
dup2(fd, 0);

🔥 原因:拿 fd 新打开文件输入地址来覆盖 0 ,覆盖 0 之后,由于可是当前已经执行 log.txt 文件了,所以最后读数据会去 log.txt 去读,这就是 输入重定向

🍒 因此我们得到一个结论:其实就是新打开一个文件,然后把 流 做一下 dup2 重定向,在内核当中做一个文件内容的拷贝,拷贝后续代码不变,就会自动更改读取数据的数据源,这也就是 重定向

输出重定向(>) :也就是关闭fd为1下标所指向的内容
输入重定向(<) :同理就是关闭fd为0下标所指向的内容
追加重定向(>>) :后面多一个追加选项
8. 缓冲区 📑
8.1 基本概念
缓冲区就是一段内存空间。
缓冲区由 C语言 维护就叫语言级缓冲区,由 OS 维护就叫内核级缓冲区。
缓冲区存在的意义:OS为语言考虑,语言为用户考虑。给上层提供高效的IO体验,间接提高整体效率。

8.2 缓冲区的刷新策略
立即刷新:(无缓存) fflush(stdout)、fsync(int fd)
行刷新:往显示器上写
全缓冲:往普通文件上写,缓冲区写满才刷新
特殊情况:
进程退出,系统自动刷新
强制刷新
这个刷新策略在内核和用户级别的缓冲区都能用。这里介绍用户级别的

int main()
{
// C 库函数
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* message = "hello fwrite\n";
fwrite(message, 1, strlen(message), stdout);

// 系统调用
const char *w = "hello write\n";
write(1, w, strlen(w));

return 0;
}
运行上面代码,第一次在显示器上打印,第二次重定向到文件打印。发现打印的顺序不同

 

原因如下:

在显示器上打印是行刷新策略,write系统调用没有带缓冲区,就按语句顺序打印,所以第一次打印按顺序。库函数 printf 、fprintf、fwrite 最后都是通过 write 系统调用刷新。
第二次是重定向到普通文件,此时刷新策略变成全缓冲,执行 printf 、fprintf、fwrite 语句时,内容都在缓冲区中,write直接输出,然后程序结束自动把缓冲区刷新,才打印出 printf 、fprintf、fwrite
将上面代码改成如下,让其产生子进程

 

直接运行的结果跟上面的一样,但是当输入到文件中,结果就与上面不一样了,如下:

 

原因如下:

因为是行刷新,所以执行到 fork() 时,缓冲区没内容了。
如果重定向到普通文件,此时是全缓冲, printf 和 fprintf 的内容都在语言级缓冲区中,write是直接写到 内核级缓冲区 中,所以 write 打印在最前面且只打印一次。
到了 fork 产生子进程之后,父子进程都有了语言及缓冲区的内容,所以程序结束时,父子进程的缓冲区的内容都被刷新,就打印两次 printf 和 fprintf
解释:

因为我们进行调用时,走到 fork 时,重复打的都是 C 库函数,和系统调用没有关系,只有 C 语言的库函数被重复打了两次,走到 fork 时上面的函数的确都调完了,但是并不代表 你把数据已经拷贝到了系统内部,虽然我们带的 \n(行刷新),但是由于 重定向了,此时刷新方案变成了全刷新,缓冲区都没写完,因此都写到了 stdout 缓冲区里面,但是没有刷新,后面调用 write 直接写到 OS 里面,此时缓冲区里面就有 三行内容,然后再 fork 时候,父子自己各自结束,各自执行自己的 fflush ,然后彼此之间互不影响,因此 C 库函数就调用了两次
而系统调用本身这个缓冲区属于文件,已经写到 OS 就不管了,就不会受对应的 fflush 影响,因为其已经在缓冲区内部了,因此只会调用一次
那为啥执行结果却只有四行呢?

因为直接打印的时候是向显示器文件打,显示器文件打的是行刷新,刷新出 \n 的内容,此时再进行 fork ,当前缓冲区的内容已经被刷新完了,没有刷的了

比如当我们去掉换行符,没有行刷新输出如下:

int main()
{
printf("hello printf ");
fprintf(stdout, "hello fprintf ");
const char* message = "hello fwrite ";

fwrite(message, 1, strlen(message), stdout);

// 系统调用
const char *w = "hello write\n ";
write(1, w, strlen(w));

// 创建子进程
fork();

return 0;
}

 

9. 手搓 shell 重定向补充 🍻
🍉 看了这么多,那我们可不可以 命令行参数,重定向方式用 符号表示,然后在程序中做判断用哪个重定向,然后再把文件以特定形式打开

[lighthouse@VM-8-10-centos File-IO]$ ./filecode > log.txt
9.1 重定向命令分析
如下:

// 全局遍历 与 重定向有关
#define NoneRedir 0
#define InputRedir 1
#define OutputRedir 2
#define AppRedir 3

int redir = NoneRedir;
char *filename = nullptr;

// " "file.txt 从左向右扫描不是空格的字符
// do while(0) 套壳
#define TrimSpace(pos) do{\
while(isspace(*pos)){\
pos++;\
}\
}while(0)

上面为啥会用 do while(0) 来封装 宏

目的是为了解决宏定义在使用时可能引发的一些问题,例如宏定义中的分号和大括号的使用。 }while (0),将你的代码写在里面,里面可以定义变量而不用考虑变量名会同函数之前或者之后的重复。 ,允许在宏定义中使用局部变量。 总而言之, do {} while (0) 的作用是为了解决宏定义在使用时可能引发的一些问题,确保宏定义可以作为单个语句使用,并且在逻辑上看起来像是一个语句。

函数实现如下:

void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令
{
(void)len; // 避免不使用的时候告警
// 虽然定义的是全局默认为0,但是由于这些工作都是重复去做的,为保证安全性,需要局部初始为0
memset(gargv, 0, sizeof(gargv));
gargc = 0;

// 重定向
redir = NoneRedir;
filename = nullptr;

printf("command start: %s\n", command_buffer);

// "ls -a -b -c -d " > hello.txt
// "ls -a -b -c -d " >> hello.txt
// "ls -a -b -c -d " < hello.txt
int end = len - 1;
while(end >= 0)
{
if(command_buffer[end] == '<') // 输入重定向
{
redir = InputRedir;
// 拿到干净的文件名
command_buffer[end] = 0;
filename = &command_buffer[end] + 1;
TrimSpace(filename); // 跳过空格部分
break;
}
else if(command_buffer[end] == '>')
{
if(command_buffer[end - 1] == '>') // 追加重定向
{
redir = AppRedir;
command_buffer[end] = 0;
command_buffer[end - 1] = 0;
filename = &command_buffer[end] + 1;
TrimSpace(filename);
break;
}
else // 输出重定向
{
redir = OutputRedir;
command_buffer[end] = 0;
filename = &command_buffer[end] + 1;
TrimSpace(filename);
break;
}
}
else
{
end--;
}
}

// 拆分读取的字符串
// "ls -a -l -n"
const char *sep = " "; //分隔符

for(char* ch = strtok(command_buffer, sep); (bool) ch; ch = strtok(nullptr, sep))
{
gargv[gargc++] = ch;
}
}

....

// 测试代码如下:
int main()
{
InitEnv(); // 初始化环境变量表
char command_buffer[basesize];
while(true) // 不断重复该工作
{
PrintCommandLine(); // 1. 命令行提示符

// command_buffer -> output(输出型参数),把 ls -a -l 看作一个字符串
if(!GetCommandLine(command_buffer, basesize)) // 2. 获取用户命令
{
continue;
}
//printf("%s\n", command_buffer); //测试

// ls -a -b -c 解析每个指令 > "ls" "-a" "-b" "-c" 拆成一个一个字符串
// 重定向格式
ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令

// 检测
printf("redir: %d\n", redir);
printf("filename: %s\n", filename);
printf("command end: %s\n", command_buffer);

if(CheckAndExecBuiltCommand())
{
continue;
}

ExecuteCommand(); // 4. 执行命令
}
return 0;
}

输出如下:

 

现在我们就完成了基本的分析,接下来就可以在后续执行代码之前来进行我们的重定向,需要实现对子进程的重定向(因为命令是由子进程来做),需要解决程序替换对重定向的影响

9.2 重定向命令执行
// 在 shell 中
// 有些命令,必须由子进程来执行
// 有些命令,不能由子进程来执行,由shell 自己执行 --- 内建命令
bool ExecuteCommand() // 4. 执行命令
{
// 让子进程进行执行
pid_t id = fork();
if(id < 0) return false;
if(id == 0)
{
// 1. 重定向应该让子进程自己做
// 2. 程序替换会不会影响重定向

if(redir == InputRedir) // 输入重定向
{
if(filename)
{
int fd = open(filename, O_RDONLY);
if(fd < 0) // 子进程打开失败
{
exit(2);
}
dup2(fd, 0);
}
else
{
exit(1);
}
}
else if(redir == OutputRedir) // 输出重定向
{
if(filename)
{
int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0) // 子进程打开失败
{
exit(4);
}
dup2(fd, 1);
}
else
{
exit(3);
}
}
else if(redir == AppRedir) // 追加重定向
{
if(filename)
{
int fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0) // 子进程打开失败
{
exit(6);
}
dup2(fd, 1);
}
else
{
exit(5);
}
}
else
{
// 没有重定向,Do Nothing
}

// 子进程
// 1. 执行命令
//execvp(gargv[0], gargv);
execvpe(gargv[0], gargv, genv); // 把我们的环境变量传递给子进程了

// 2. 退出
exit(1); // 要进行程序替换,只有子进程失败,才会 exit。

}

int status = 0;
pid_t rid = waitpid(id, &status, 0); // 阻塞等待
if(rid > 0)
{
if(WIFEXITED(status)) // 等待成功获取退出信息
{
lastcode = WEXITSTATUS(status);
}
else
{
lastcode = 100; //非正常退出
}
return true;
}
return false;
}

运行结果如下:

 

10. 封装简单库
mystdio.h 封装

#pragma once

#define SIZE 1024

#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2

struct IO_FILE
{
int flag; // 刷新方式
int fileno; // 文件描述符
char outbuffer[SIZE];
// 缓冲区
int cap; // 容量
int size; // 大小
// TODO
};

typedef struct IO_FILE mFILE;

mFILE *mfopen(const char *filename, const char*mode); //打开文件的操作
int mfwrite(const void *ptr, int num, mFILE *stream);
void mfflush(mFILE *stream);
void mfclose(mFILE *stream);

先实现第一个操作,如下:

#include "my_stdio.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

mFILE *mfopen(const char *filename, const char*mode)
{
int fd = -1;
if(strcmp(mode, "r") == 0)
{
fd = open(filename, O_RDONLY);
}
else if(strcmp(mode, "w") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
}
else if(strcmp(mode, "a") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
}
if(fd < 0) return NULL;

mFILE *mf = (mFILE*)malloc (sizeof(mFILE));
if(!mf)
{
close(fd);
return NULL;
}

mf->fileno = fd;
mf->flag = FLUSH_LINE;
mf->size = 0;
mf->cap = SIZE;

return mf;
}

然后编译生成 .o 文件,如下:

[lighthouse@VM-8-10-centos stdio]$ gcc -c my_stdio.c
🌈 然后我们再新建一个文件夹,将我们的头文件和之前生成 .o 文件移到当前目录下,然后在当前目录下新建 main.c 文件,用给定的东西来进行以下操作:

#include "my_stdio.h"
#include <stdio.h>

int main()
{
mFILE *fp = mfopen("./log.txt", "w");
printf("%d, %d, %d, %d\n", fp->fileno, fp->flag, fp->cap, fp->size);

return 0;
}
🔥 然后生成 .o 文件,然后将两个 .o 文件链接生成可执行程序:

 

🍉 运行可执行程序,结果如下:

 

完整my_stduo,c函数实现

#include "my_stdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

mFILE *mfopen(const char *filename, const char*mode)
{
int fd = -1;
if(strcmp(mode, "r") == 0)
{
fd = open(filename, O_RDONLY);
}
else if(strcmp(mode, "w") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
}
else if(strcmp(mode, "a") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
}
if(fd < 0) return NULL;

mFILE *mf = (mFILE*)malloc (sizeof(mFILE));
if(!mf)
{
close(fd);
return NULL;
}

mf->fileno = fd;
mf->flag = FLUSH_LINE;
mf->size = 0;
mf->cap = SIZE;

return mf;
}

void mfflush(mFILE *stream)
{
if(stream->size > 0) // 缓冲区里面有内容
{
write(stream->fileno, stream->outbuffer, stream->size);

// 刷新到外设
stream->size = 0;
}
}

int mfwrite(const void *ptr, int num, mFILE *stream)
{
// 1. 拷贝
memcpy(stream->outbuffer + stream->size, ptr, num);
stream->size += num;

// 2. 检测缓冲区是否要刷新
if(stream->flag == FLUSH_LINE && stream->size > 0 && stream->outbuffer[stream->size - 1] == '\n')
{
mfflush(stream);
}
return num;
}

void mfclose(mFILE *stream)
{
// 刷新之前,先判断缓冲区是否有内容
// 将内容刷新到缓冲区
if(stream->size > 0)
{
mfflush(stream);
}
close(stream->fileno); // 进行文件刷新
}

测试:

#include "my_stdio.h"
#include <string.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
mFILE *fp = mfopen("./log.txt", "a");
if(fp == NULL)
{
return 1;
}
int cnt = 3;
while(cnt)
{
printf("write: %d\n", cnt);
char buffer[64];
snprintf(buffer, sizeof(buffer), "hello message, number is : %d\n", cnt);
cnt--;

mfwrite(buffer, strlen(buffer), fp);

sleep(1);
}
mfclose(fp);
}

 

当我们把 snprintf 中 的 \n 去掉,加上一行代码,我们可以看看下面的过程:

 

从上面我们可以知道:说明我们在多次写入时,没有写到内核级缓冲区,而是写到了 my_file 结构当中,但是我们可以自己用fflush()强制刷新,

 

我们也可以把 fsnyc 写到我们实现 my_stdio.c 文件的 mfflush 中来实现这个效果

​​​​​​​

fsync将文件描述符 FD 所引用的文件的所有修改的核心数据(即修改后的缓冲区缓存页)传输(“刷新”)到该文件所在的磁盘设备(或其他永久存储设备)。调用会阻塞,直到设备报告传输已完成。它还会刷新与文件关联的元数据信息。
调用 fsync并不一定能确保包含该文件的目录中的条目也已到达磁盘。为此,还需要在目录的文件描述符上显式 调用fsync。
fsync会确保一直到写磁盘操作结束才会返回。
void mfflush(mFILE *stream)
{
if(stream->size > 0) // 缓冲区里面有内容
{
// 写到内核文件的文件缓冲区中!!
write(stream->fileno, stream->outbuffer, stream->size);

// 刷新到外设
fsync(stream->fileno);
stream->size = 0;
}
}

小结 📖
我们这篇博客主要讲了关于 文件描述符 和 缓冲区的概念,大家可以多多理解,方便我们后面的学习

文件描述词是Linux编程中的一个术语。当一个文件打开后,系统会分配一部分资源来保存该文件的信息,以后对文件的操作就可以直接引用该部分资源了。文件描述词可以认为是该部分资源的一个索引,在打开文件时返回。在使用fcntl函数对文件的一些属性进行设置时就需要一个文件描述词参数。
缓冲区就是一段内存空间。由 C语言 维护就叫语言级缓冲区,由 OS 维护就叫内核级缓冲区。
————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/island1314/article/details/142905076

阅读剩余
THE END