目录
前言:
文件操作对于不少编程初学者来说都是一件头疼的事情,不管你学习的是什么编程语言,C/C++/Java/Go/Python ,因为我们学习到的文件相关的操作对你来说都是一个黑盒,我们只是知道我们应该怎么使用相应的文件函数进行操作,而对于内部的实现方式的一无所知,这导致很多初学者对于文件操作都很恐惧。
其实这不是你的问题,因为文件操作涉及了许多操作系统相关的知识,我们只有站在操作系统的角度才能对文件操作有比较深刻的理解,所以本篇文章我们来学习一下Linux下的基础IO操作,来加深我们对于语言级别文件操作以及系统的理解。
一、关于文件的预备知识
文件操作的本质是什么?
- 针对文件的操作都是对文件内容和属性的操作,语言层面的文件操作就是直接使用库函数,而事实上,文件操作是系统层面的问题,就像进程管理一样,系统也会通过
先描述,再组织
的方式对文件进行管理、操作。
只有 C/C++
这种偏底层的语言才有文件操作吗?
- 并不是,其他语言也支持文件操作,如
Java。
在进行文件操作时,不同语言使用方法可能有所不同,但 本质上都是在调用系统级接口进行操作
文件由什么构成?一般文件放在哪里?
- 文件 =
内容
+属性
- 当我们没有打开文件时,文件位于磁盘中,当文件被打开时,文件就要被操作系统加载到内存中。这也是冯诺依曼体系结构对我们文件操作的限制!
- 本文讨论的是已被加载至内存文件的相关操作
文件是由谁打开的?
- 由用户创建进程,调用系统级接口,再交给
OS
完成文件打开任务,文件写入与读取时也是同理
系统是如何管理文件的?
- 实际进程在运行的过程中肯定会打开多个文件,对于多个文件我们操作系统要将它们组织起来以便于管理和维护,会为被打开的文件创建相应的内核数据结构。
- 像使用
task_struct
管理进程一样,通过struct file
存储文件属性进行管理 struct file
结构体包含了文件的各种属性和链接关系
调用库函数进行文件操作时的流程:
二、C语言文件操作
1. fopen
FILE *fopen(const char *path, const char *mode);
fopen 函数返回值类型为 FILE 。参数列表中, path 为文件路径, mode 为打开方式。
注意: 若参数1直接使用文件名,则此文件需要位于当前程序目录下,如果想指定目录存放,可以使用绝对路径
打开文件的方式有如下几种:
r | 打开文本文件进行阅读。若文件不存在,会打开失败 流位于文件的开头。 |
r+ | 开放读和写。 流位于文件的开头 |
w | 打开文件进行写入。如果文件不存在,则创建该文件,写入前,会先清空内容 流位于文件的开头。 |
w+ | 开放读和写。如果文件不存在,则创建该文件,否则文件被清空。 流位于文件的开头。 |
a | 打开以追加(在文件末尾写入),追加前不会清空内容 如果文件不存在,则创建该文件。 流位于文件的末尾。 |
a+ | 打开以进行读取和追加(在文件末尾写入)。 如果文件不存在,则创建该文件。用于读取的初始文件位置位于文件的开头, 但输出始终附加到文件末尾。 |
//打开文件进行操作
//在当前目录中打开文件 log.txt
//注意:同一个文件,可以同时多次打开
FILE* fp1 = fopen("log.txt", "w"); //只读
FILE* fp2 = fopen("log.txt", "a"); //追加
FILE* fp3 = fopen("log.txt", "r"); //只写,文件不存在会打开失败
FILE* fp4 = fopen("log.txt", "w+"); //可读可写
FILE* fp5 = fopen("log.txt", "a+"); //可读可追加
FILE* fp6 = fopen("log.txt", "r+"); //可读可写,文件不存在会打开失败
若文件打开失败,会返回空 NULL
,可以在打开后判断是否成功
2. fclose
文件打开并使用后需要关闭,就像动态内存申请后需要释放一样
int fclose(FILE *stream);
关闭已打开文件,只需通过 FILE*
指针进行操作即可。只能对已打开的文件进行关闭,若文件不存在,会报错
//对上面打开的文件进行关闭
//无论以哪种方式打开,关闭方法都一样
fclose(fp1);
fclose(fp2);
fclose(fp3);
fclose(fp4);
fclose(fp5);
fclose(fp6);
3. 文件写入
C语言
对于文件写入有这几种方式:fputc
、fputs
、fwrite
、fprintf
和 snprintf
int fputc(int character, FILE *stream);
int fputs(const char *str, FILE * stream ;
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
int fprintf(FILE *stream, const char *format, ...);
int snprintf(char *s, size_t n, const char *format, ...);
前几种方式比较简单,无非就是 逐字符写入、逐行写入 与 格式化写入,这里来介绍一下 fprintf、snprintf 。
3.1 fprintf
int fprintf(FILE *stream, const char *format, ...);
与 printf 不同, printf 默认向显示器打印消息,而 fprintf则可以指定文件流,向指定文件打印。
#include <stdio.h>
#define LOG "log.txt" // 自定义文件
int main()
{
FILE* fp = fopen(LOG, "w");// 写方式打开文件
if(fp == NULL)
{
perror("fopen");
return 1;
}
const char* msg = "good morning";
int cnt = 3;
while(cnt)
{
fprintf(fp, "%s: %d\n", msg, cnt);
//fputs(msg, fp);
cnt--;
}
fclose(fp);
return 0;
}
也可以使用 fprintf 函数向显示器文件里打印,以实现和 printf 函数相同的效果:
3.2 snprintf
int snprintf(char *str, size_t size, const char *format, ...);
snprintf
是 sprintf
的优化版,增加了读取字符长度控制,更加安全
- 参数1:缓冲区,常写做
buffer
数组,可以把内容打印到缓冲区里 - 参数2:缓冲区的大小,通过设置参数 size 控制打印的长度。
- 参数3:格式化输入
使用 snprintf
函数写入数据至缓冲区后,可以通过 fputs
函数,将缓冲区中的数据真正写入文件中:
while(cnt)
{
char buffer[256];
snprintf(buffer, sizeof(buffer), "%s: %d\n", msg, cnt);//写入数据至缓冲区
fputs(buffer, fp);//将缓冲区中的内容写入文件中
cnt--;
}
三、系统文件操作
1. open
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
我们打开man
手册在2
号目录里面可以看到 open
的函数原型以及介绍:
- 参数1:
pathname
待操作文件名,和fopen
一样,文件路径可以是相对路径,也可以是绝对路径 - 参数2:flags为打开选项,
open
以位图形式传递选项信号,用一个int
至多可以表示32
个选项 - 参数3:
mode
权限设置,文件起始权限为0666
- 返回值:如果打开成功会返回一个大于或等于0整数,这个整数叫做文件描述符,用来标定一个文件,如果打开失败会返回-1表示打开失败。下面会详细解释
参数2解释:使用了 位图 的方式进行多参数传递
可以利用这个特性,写一个关于位图的小demo
#include <stdio.h>
#include <stdlib.h>
#define ONE 0x1
#define TWO 0x2
#define THREE 0x4
void Test(int flags)
{
//模拟实现三种选项传递
if(flags & ONE)
printf("This is one\n");
if(flags & TWO)
printf("This is two\n");
if(flags & THREE)
printf("This is three\n");
}
int main()
{
Test(ONE | TWO | THREE);
printf("-----------------------------------\n");
Test(ONE); //位图使得选项传递更加灵活
return 0;
}
函数 open
中的参数2正是位图,其参数有很多个,这里列举部分
- O_RDONLY 只读打开 (英文read only的缩写)
- O_WRONLY 只写打开 (英文write only的缩写)
- O_CREAT 文件不存在就创建该文件 (英文creat)
- O_TRUNC 文件每次打开都会进行清空 (英文truncate)
- O_APPEND 文件写入时以追加的方式进行写入 (英文append)
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define LOG "log.txt"
int main()
{
// 三种参数组合,就构成了 fopen 中的 w
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC);
// 成功与否打印信息
if(fd == -1)
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
else
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
close(fd);
return 0;
}
O_CREAT 为如果文件不存在,则自动创建该文件。 O_WRONLY 为以只读模式打开文件。需要注意的是,他们不会对原始文件内容做清空,下一次写入时,虽然还是从开头开始写,但是原本的内容没被覆盖的部分仍然会残留。
打开文件所返回的文件描述符是 3 。文件描述符下面会做详细解释
参数3解释:
先看上述代码结果:
第一次打开:
这里新创建的文件打开的权限有一点奇怪,我们删除再重新运行程序:
新创建的文件打开的权限还是有一点奇怪,而且和上一次的创建的文件权限竟然不同,我们再次删除再重新运行程序:
为什么每次创建文件的权限都是随机的呢?
因为我们使用的第一个open
函数是没有指明创建后的文件权限是什么的,所以创建出的文件权限是一个随机值,这时我们就要考虑第二个open
函数了!
- 第三个参数 :
mode_t
是一种无符号整形,我们的第三个参数可以以8进制的方式传入我们创建的文件的权限。
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
最终的结果并不是:-rw-rw-rw-
这是为什么呢?
这是因为我们Linux
的普通用户的权限掩码影响了我们文件创建的权限,我们可以使用系统调用umask()
来进行设置我们进程的权限掩码。
umask(0);
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
刚刚被创建的 log.txt 文件的权限为 666 。
总结:假若文件不存在,open
中的参数3最好进行设置,否则创建出来的文件权限为随机值。
C语言 中的 fopen 调用 open 函数,其中的选项对应关系如下
- w -> O_WRONLY | O_CREAT | O_TRUNC
- a -> O_WRONLY | O_CREAT | O_APPEND
- r -> O_RDONLY
- .......
所以只要我们想使用 open 时,也能做到 只读方式 打开 不存在的文件,也不会报错,加个 O_CREAT 参数即可
2. close
close
函数根据文件描述符关闭文件
int close(int fildes);
Linux 下一切皆文件
包括这三个标准流: stdin
、stdout
、stderr
它们的文件描述符依次为:0
、1
、2
,也可以通过 close(1)
的方式,关闭标准流
3. write
write
函数是写入函数,通过此函数我们能向文件中写入数据。
ssize_t write(int fd, const void *buf, size_t count);
- 第一个参数是文件标识符,也就是要写入的文件。
- 第二个参数是一个指针,指向的是要写入的数据
- 第三个参数是一个变量,表示最多写入多少个字节的数据
- 返回值:实际写入的字节个数。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define LOG "log.txt"
int main()
{
umask(0);
// 三种参数组合,就构成了 fopen 中的 w
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
// 成功与否打印信息
if(fd == -1)
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
else
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
const char *msg = "good morning\n";
int cnt = 3;
while(cnt--)
{
// 也可以写入缓冲区中
//char line[128]
//snprintf(line, sizeof(line), "%s, %d\n", msg, cnt);
//write(fa, line, strlen(line));
write(fd, msg, strlen(msg));// 长度不用+1
}
close(fd);
return 0;
}
注意:通过系统级函数 write
写入字符串时,不要刻意加上 '\0'
,因为对于系统来说,调用写入时要的只是数据,但不包括'\0'
,
这也只是一个普通的字符(因为'\0'
作为字符串结尾只是 C语言
的规定,但不是系统的规定)
4. read
read
函数是读取函数,可以从文件中将数据读进变量中。
ssize_t read(int fd, void *buf, size_t count);
现在我们的文件是存在数据的
编写如下代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define LOG "log.txt"
int main()
{
umask(0);
// 只读取文件
int fd = open(LOG, O_RDONLY);
// 成功与否打印信息
if(fd == -1)
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
else
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
char buffer[1024];
// 读取整个文件
ssize_t n = read(fd, buffer, sizeof(buffer)-1);
if(n>0)
{
buffer[n] = '\0';//注意在读取到的字符串后面加'\0'才是C语言风格的字符串
printf("%s\n", buffer);
}
close(fd);
return 0;
}
注意:
- 使用系统接口来进行IO的时候,一定要注意 '\0' 的问题。
read
函数会读取换行符(\n
)并将其作为普通字符处理,只有在读取到指定的字节数或者遇到文件的结束才会停止读取,所以读取到的结果就有一行换行,
这些系统级函数成功使用的前提是文件描述符合法
四、C文件接口与系统文件IO的关系
- fopen、fclose、fwrite、fputs、fread、fgets 都是C标准库当中的函数,我们称之为库函数(libc)。
- open、close、write、read都属于系统提供的接口,称之为系统调用接口。
可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。
五、文件描述符
int open(const char *pathname, int flags, mode_t mode);
open 函数的返回值是一个整数,这个整数就是文件描述符。关于 open 函数返回值的介绍如下:
任何一个进程,在启动的时候,默认会打开当前进程的三个文件: 标准输入 、 标准输出 、 标准错误 。分别对应:
- C语言中的 stdin 、 stdout 、 stderr
- 与C++中的 cin 、 cout 、 cerr
1. 理解文件描述符
我们知道C语言打开一个文件之后,会返回一个 FILE* 的指针,我们的C的三个标准流也是 FILE*的指针,并且它们分别指向了 键盘文件 、显示器文件、显示器文件。
当我们尝试去向这标准输出流或者是标准错误流中去输入数据,其实就是向显示器文件进行数据写入;当我们尝试向标准输入流中读取数据,其实就是对键盘文件进行数据读取。
既然是文件,那么在Linux下进行文件操作必须要有文件描述符,那么我们C程序(以及其他编程语言写的程序)默认打开的三个流,也要有文件描述符,只有有了文件描述符我们系统才能找到对应的文件。实际上C程序(以及其他编程语言写的程序)形成的进程默认打开的三个流在Linux
中对应的文件描述符都是:
流 | 文件描述符 |
---|---|
标准输入流 | 0 |
标准输出流 | 1 |
标准错误流 | 2 |
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define LOG "log.txt"
int main()
{
umask(0);
// 只读取文件
int fd = open(LOG, O_RDONLY);
// 成功与否打印信息
if(fd == -1)
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
else
{
printf("fd: %d, error: %d, errstring: %s\n", fd, errno, strerror(errno));
}
}
为啥是3捏? 实际上文件描述符的本质是数组的下标:
根据 先描述、再组织 原则,OS
将所有的文件都统一视为 file
对象,获取它们的 file*
指针,然后将这些指针存入指针数组中,可以进行高效的随机访问和管理,这个数组为 file*
fd_array[]
,而数组的下标就是神秘的 文件描述符 fd。
一个进程可以打开多个文件,OS为了让进程快速的找到文件,在内核中创建了一种结构体 files_struct 。进程的PCB结构体 task_struct 中有一个指针 *file 指向这个文件结构体files_struct 。 files_struct 之中包含了一个指针数组 struct file* fd_array[ ] ,我们打开文件时,操作系统就会在指针数组 struct file* fd_array[ ] 中从前往后遍历,找到当前没有被使用的最小的一个下标,作为新的文件描述符,并把该下标的指针指向新打开文件的文件对象 file ,最后向上层返回该下标。
注:文件被打开后,并不会加载至内存中(这样内存早爆了),而是静静的躺在磁盘中,等待进程与其进行 IO。
所以当一个程序启动时,OS 会默认打开 标准输入、标准输出、标准错误 这三个文件流,将它们的 file* 指针依次存入 fd_array 数组中,显然,下标 0、1、2 分别就是它们的文件描述符 fd;后续再打开文件流时,新的 file* 对象会存入当前未被占用的最小下标处,所以用户自己打开的 文件描述符一般都是从 3 开始。
2. 文件描述符的分配规则
fd
的分配规则为:先来后到,优先使用当前最小的、未被占用的 fd
存在下面两种情况:
- 直接打开文件
file.txt
,分配fd
为3
- 先关闭标准输入
stdin
中原文件执行流(键盘),再打开文件file.txt
,分配fd
为0
,因为当前0
为最小的,且未被占用的fd
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <assert.h>
#define LOG "log.txt"
int main()
{
//先打文件
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
assert(fd != -1); //存在打开失败的情况
printf("单纯打开文件 fd: %d\n", fd);
close(fd); //记得关闭
//先关闭,再打开
close(0); //关闭键盘文件执行流
fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("先关闭键盘文件执行流,再打开文件 fd: %d\n", fd);
close(fd);
return 0;
}
注意: 假若将标准输出 stdout
中的原文件执行流(显示器)关闭了,那么后续的打印语句将不再向显示器上打印,而是向此时 fd
为 1
的文件流中打印
close(1);// 关闭显示器文件执行流
fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("先关闭显示器文件执行流,再打开文件 fd: %d\n", fd);
close(fd);
这其实就是 重定向 的基本操作
六、再次理解一切皆文件
学习过Linux的人大多数都听过一句话:“Liunx下一切皆文件”,这一点对于文件操作非常重要,下面我们来谈一谈对于这句话的理解。
我们知道像:键盘,显示器,网卡… 这些都是硬件,根本不是文件,为什么我们能把它们当成文件去看待呢?
首先,我们在操作硬件时都是通过驱动程序进行操作硬件的,而对于不同的硬件它们的驱动程序肯定是不同的,例如键盘只能被读取而不能被写入,显示器只能被写入,而不能被读取,网卡既可以被读取又可以被写入 …
尽管它们的驱动是不同点,但是我们可以设计一个类,对于它们的进行封装,封装完以后,上面的调用者就看不到底层的内容了,上面的调用者只需要对于这个类进行操作就可以达到同样的效果,这样对于调用者来说,它的操作都是在操作这个类,它接触不到底层的驱动以及硬件,当然对于它来说它看到的是什么那就是什么了。
于是Linux用struct file结构体封装这些驱动的函数指针,对于不同的硬件若不需读或写操作方法,则只需将对应的函数指针置空就行,这样进程在使用键盘,显示器时就像是在使用文件一样,这样一来不管是普通文件还是硬件,对于进程来说都是文件。
而我们用户使用操作系统又是通过进程的方式来使用操作系统的,所以我们用户的视角和进程的视角是一样的,在我们用户来看想要让磁盘帮我们保存一些数据,我们只需要保存一下文件就行了,我们没有必要把磁盘拿出来,然后通过一些物理手段向磁盘中刻入数据,进程也是和我们用户一样,它也认为我只要保存一下文件就能完成任务了,所以对于我们用户和进程来说一切都是文件,我们也只能接触到文件,所以Linux下一切皆文件。
七、重定向
1. FILE
在谈论重定向之前我们先来谈论一下C语言中的FILE
我们使用C语言进行打开文件时,系统都会给我们一个FILE指针那这个FILE指针是什么呢?是谁给我们提供的呢?
答案是:是C语言给我们提供的,这个FILE其实就是一个C库给我们封装的一个结构体,而且这个结构体内部一定要有文件描述符 fd,因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过 fd访问的。所以C库当中的FILE结构体内部,必定封装了 fd。
在C库的内部源代码中有这样一些源代码:
通过这段源代码,我们知道FILE
内部有一个叫 _fileno
的文件描述符,那么我们就可以将stdin
stdout
stderr
的文件描述符打印出来,看看与我们上面的结论是不是一样的。
打印三个标准流的文件描述符
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#define LOG "mylog.txt"
int main()
{
//打印出文件标识符
printf("%d\n", stdin->_fileno);
printf("%d\n", stdout->_fileno);
printf("%d\n", stderr->_fileno);
FILE *fp = fopen(LOG, "w");
printf("%d\n", fp->_fileno);
fclose(fp);
return 0;
}
2. 重定向的本质
前面说过,OS 在进行 IO 时,只会根据标准输入、输出、错误对应的文件描述符 0、1、2 来进行操作,也就是说 OS 作为上层不必关心底层中具体的文件执行流信息(fd_array[] 中存储的对象) 因此我们可以做到 “偷梁换柱”,将这三个标准流中的原文件执行流进行替换,这样就能达到重定义的目的了
3. 利用指令重定向
下面直接在命令行中实现输出重定向,将数据输出至指定文件中,而非屏幕中
echo you can see me > file.txt
可以看到数据直接输出至文件 file.txt
中,当然也可以 从 file.txt
中读取数据,而非键盘
cat < file.txt
现在可以理解了,>
可以起到将标准输出重定向为指定文件流的效果,而 <
则是从指定文件流中,标准输入式的读取出数据。
除此之外,我们还可以利用程序进行操作,在运行后进行重定向即可
#include <iostream>
using namespace std;
int main()
{
cout << "标准输出 stdout" << endl;
cerr << "标准错误 stderr" << endl;
return 0;
}
直接运行的结果,此时的标准输出和标准错误都是向显示器上打印
利用命令行只对 标准输出 进行重定向,file.txt
中只收到了来自 标准输出 的数据,这是因为 标准输出 与 标准错误 是两个不同的 fd
,现在只重定向了 标准输出 1
对 标准错误 也进行 重定向,打印内容至 file.txt
4. 输出重定向
看下面一段代码,我们就可以尝试如果我们关闭1号文件描述符,然后我们再打开一个文件,之后我们向stdout
里面输入一些数据,看一看会发生什么?还是打印到显示器上面吗?
int main()
{
close(1); // fclose(stdout);
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("you can see me?\n");
return 0;
}
答案是并没有打印到显示器中,而是打印到了文件中,相信有了前面的基础你已经明白了,我们将stdout
关闭后,新打开的文件占据了1号文件描述符,而我们的printf
函数只认识1号文件描述符,所以向1号文件描述符指向的文件输入内容,就导致数据输入到了文件里面:
5. 输入重定向
同理,我们把0
号文件标识符给关闭,然后打开我们的新文件进行scanf
,那么我们应该会从新打开的文件中读取数据,我们看一看结果:
int main()
{
close(0); // fclose(stdin);
int fd = open(LOG, O_RDONLY);
char str[100];
scanf("%s", str);
printf("%s\n", str);
close(fd);
return 0;
}
当执行 scanf 函数时,并没有要从键盘输入数据,而是从默认 0 号的键盘文件读取数据也就变成了从现在的 0 号文件 mylog.txt 中读取数据:
6. 追加重定向
追加重定向的原理很简单,我们只需要将文件的打开方式加上O_APPEND
去掉O_TRUNC
。
例如对于刚才的文件进行重定向:
close(1);
int fd = open(LOG, O_WRONLY | O_CREAT | O_APPEND, 0666);
if (fd == -1)
{
perror("Failed to open log file");
return 1;
}
printf("this is append!!!!!\n");
追加成功:
根据这些原理我们来实现一个需求:将标准输出流与标准错误流的信息进行分流。
- 标准输入(
stdin
)-> 设备文件 -> 键盘文件 - 标准输出(
stdout
)-> 设备文件 -> 显示器文件 - 标准错误(
stderr
)-> 设备文件 -> 显示器文件
标准输出 与 标准错误 都是向显示器中输出数据,为什么不合并为一个?
- 因为在进行排错时,可能需要单独查看错误信息,若是合并在一起,查看日志时会非常麻烦,导致我们难以找到错误所在;但如果分开后,只需要将 标准错误 重定向后,即可在一个单独的文件中查看错误信息
我们可以使用重定向进行分流,我们先关闭1号文件描述符,然后新打开一个文件normal.txt,然后关闭2号文件描述符,再打开一个新的文件error.txt这样我们再使用标准输入或标准错误流时,信息会被写入两个不同的文件中,我们关心错误信息就可以打开error.txt进行查看,关心正确信息,就可以打开normal.txt进行查看。
原本不分流时是乱的:
fprintf(stdout, "stdout->normal\n");
fprintf(stderr, "stderr->error\n");
fprintf(stdout, "stdout->normal\n");
fprintf(stderr, "stderr->error\n");
fprintf(stderr, "stderr->error\n");
fprintf(stderr, "stderr->error\n");
fprintf(stdout, "stdout->normal\n");
进行分流:
close(1);
open("normal.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
close(2);
open("error.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
fprintf(stdout, "stdout->normal\n");
fprintf(stderr, "stderr->error\n");
fprintf(stdout, "stdout->normal\n");
fprintf(stderr, "stderr->error\n");
fprintf(stderr, "stderr->error\n");
fprintf(stderr, "stderr->error\n");
fprintf(stdout, "stdout->normal\n");
这样就把常规消息与异常消息区分开在不同的文件里了,我们C语言中的 printf 与 perror ,C++中的 cout 与 cerr ,所做的就是把不同的消息进行分类,方便查找。
或者不同消息打印的位置可以通过指令的方式来实现,注释掉close(1)和close(2):
以上只是简单演示一下如何通过命令行进行 重定向,在实际开发中进行重定向操作时,使用的是函数 dup2
7. 函数重定向
每次重定向都要先关闭一个文件的话,操作太过于麻烦,所以可以使用系统调用 dup2 来简化操作:
#include <unistd.h>
int dup2(int oldfd, int newfd);
函数解读:将老的 fd 重定向为新的 fd,参数1 oldfd 表示新的 fd,而 newfd 则表示老的 fd,重定向完成后,只剩下 oldfd,因为 newfd 已被覆写为 oldfd 了;如果重定向成功后,返回 newfd,失败返回 -1
参数设计比较奇怪,估计作者认为 newfd 表示重定向后,新的 fd
下面来直接使用,模拟实现报错场景,将正常信息输出至 normal.txt,错误信息输出至 error.txt 中
int fdnor = open("normal.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fderr = open("error.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
// 重定向
dup2(fdnor, 1);
dup2(fderr, 2);
fprintf(stdout, "stdout->normal\n");
fprintf(stderr, "stderr->error\n");
fprintf(stdout, "stdout->normal\n");
fprintf(stderr, "stderr->error\n");
fprintf(stderr, "stderr->error\n");
fprintf(stderr, "stderr->error\n");
fprintf(stdout, "stdout->normal\n");
close(fdnor);
close(fderr);
八、缓冲区
我们以前学习C语言的文件操作时,我们都知道FILE
里面应该是有缓冲区的,现在我们学习操作系统时我们又知道操作系统内核里面也是有缓冲区的,那这两个缓冲区是一样的吗?
对于这个问题我们现在不好回答,我们只能先给出结论:是不一样的,FILE
是C库提供给我们的一个结构体,里面的缓冲区对应的是用户态的缓冲区,Linux
内核中的缓冲区,对应的是内核态的缓冲区。
我们先看下面的代码,根据现象我们来分析问题,最后再来理解一下缓冲区。
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{
fprintf(stdout, "hello fprintf!\n");
const char* str = "hello write!\n";
write(1, str, strlen(str));
//创建子进程
fork();
return 0;
}
我们发现当我们直接运行和重定向后的结果是不同的,而且fprintf
会比write
多一次打印,这时为什么呢?
其实呢这与C库的缓冲区有关系!缓冲区在哪里?在你用C库函数打开文件的时候,你会得到FILE结构体,缓冲区就在这个FILE结构体中
函数 fprintf 的返回值类型为 FILE* ,在调用 fprintf 函数时,会在内存中 malloc 出一个结构体 FILE 并返回其地址。结构体 FILE 中包含文件描述符与缓冲区。当用户使用 fprintf 向 stdout 打印字符串时,其实只是把字符串放进了C库的缓冲区里,暂时还没有把用户层缓冲区里的数据拷贝到操作系统内核的缓冲区里。C库会结合一定的刷新策略,调用 write 函数将用户层缓冲区中的数据写入给OS。
C库的刷新策略有如下三种:
- 无缓冲:数据直接刷新到OS内核
- 行缓冲:数据先放到用户层缓冲区中,如果碰到了 '\n' ,就把 '\n' 之前的所有数据刷新到OS 内核。
- 全缓冲:数据先放到用户层缓冲区中,如果用户层缓冲区被写满了,就把所有数据刷新到OS 内核。
当直接运行程序时,默认是向显示器刷新,采取的是行缓冲。遇到 '\n' ,直接把用户缓冲区里 '\n' 之前的内容刷新到了OS中,这就导致在程序最后执行 fork();时,用户缓冲区里已经没有数据了,我们观测到的结果就是正常显示。
当运行程序并重定向到普通文件时,是向普通文件刷新,采取的是全缓冲。而我们打印的内容还不足以将缓冲区填满,所以到程序最后执行 fork();时,用户缓冲区里的数据仍然没有被刷新,父子进程各自持有一份数据,程序结束时一起刷新到OS中,就打印了两次。
因为打印 "hello write" 使用的是系统调用 write ,是直接向OS写入数据的,无需用户层刷新,所以不管有没有重定向,都只打印一次。
为什么C库的
FILE
里面要有缓冲区呢?
答案是:节省调用者的时间! 如果我们想直接把数据写到操作系统内核中就需要调用系统调用,而系统调用的使用代价是要比普通函数大的多的,因此为了尽量少的使用系统调用,尽量一次IO能够读取和写入更多的数据,所以FILE
内部才有了缓冲区。
操作系统有缓冲区吗?
操作系统实际上也是有缓冲区的,当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统缓冲区,然后再由操作系统将数据刷新到磁盘或是显示器上。(操作系统有自己的刷新机制,我们不必关系操作系统缓冲区的刷新规则)
因为操作系统是进行软硬件资源管理的软件,根据下面的层状结构图,用户区的数据要刷新到具体外设必须经过操作系统。