复习C文件IO相关操作
认识文件相关系统调用接口
认识文件描述符,理解重定向
对比fd和FILE,理解系统调用和库函数的关系
铺垫概念
先来段代码回顾C文件接口,先来写文件
#include
int main()
{
FILE* fp = fopen("./log.txt", "w");//以写方式打开
if(fp == NULL)
{
perror("fopen fail");
return -1;
}
//文件操作
fclose(fp);
return 0;
}
此时我们就发现以"w"方式打开文件,原本没有文件,当执行了程序后就出现了该文件,现在我们再向该文件写一点数据,写数据可以使用fputs函数。
运行结果:
此时我们就能看到向log.txt写入了内容,现在我们再来做一个测试,将我们刚刚的向文件写入数据的那行代码注释掉。
运行结果:
此时我们又发现log.txt里面的内容不见了,我们上面的代码操作仅仅是打开然后再关闭,为什么文件的内容没有了呢?现在我们直接打开文件log.txt,直接写入,然后运行程序会怎么样呢?
我们发现即使我们向里面写入了数据,但是当我们运行上面的程序文件里面的内容照样没有了,难道说只要我们以"w"的方式打开文件然后再关闭文件,文件的内容都会被清空,如果我们打开文件没有任何操作,文件内容会被清空,如果打开文件向文件写入新内容,那么之前的内容就会被覆盖,可是为什么呢?我们先来看看fopen的几种打开方式。
文件使用方式 | 含义 | 如果指定文件不存在 |
“r”(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
“w”(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
“a”(追加) | 向文本文件尾添加数据 | 建立一个新的文件 |
“r+”(读写) | 为了读和写,打开一个文本文件 | 出错 |
“w+”(读写) | 为了读和写,新建一个新的文件 | 建立一个新的文件 |
“a+”(读写) | 打开一个文件,在文件尾进行读写 | 建立一个新的 |
我们来看看w介绍哪里Truncate file to zero length or create text file for writing.的意思:将文件截断为零长度或创建用于写入的文本文件。它这个意识就是当我们以"w"的方式打开文件时,该文件就会被自动清空,我们再来看看之前学习的重定项。
重定向的本质也是向文件里面写入,写入也就意味着要打开文件,上面我们向log.txt重定项写入两次字符串,但是第二次写入之后,第一次写入的字符串就不见了。这就说明我们在输出重定项的时候,首先需要先打开文件,第二次我们需要再次写入,说明此时是以"w"的方式打开,此时log.txt里面的内容就会被清空,以便能显示第二次输入的字符串。
当我们输出重定项什么也不带的时候,此时也就相当于打开文件什么也不做,此时我们也能观察到文件的内容被清空了。然后我们再来看看以"a"的方式打开文件。
#include
int main()
{
FILE* fp = fopen("./log.txt", "a");//以a方式打开
if(fp == NULL)
{
perror("fopen fail");
return -1;
}
//fputs("hello file!\n", fp);
fclose(fp);
return 0;
}
运行结果:
我们以"a"的当时打开文件是在文件末尾写入数据,也就意味着我们不能清空原始数据,必须保留才能在末尾写入数据,上面的运行结果能很好的证明这一点。
此时我们可以观察到追加重定项">>"也就是以"a"的方式打开文件进行文件内容追加。
fwrite
和 fread
是 C 语言中用于文件 I/O(输入/输出)的函数,它们通常用于对文件进行二进制数据的读写。
fwrite 函数:
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
ptr
:指向要写入的数据块的指针。size
:每个数据项的大小(以字节为单位)。count
:要写入的数据项的数量。stream
:文件指针,指向要写入的文件。count
。ptr
指向的数据块写入到由 stream
指向的文件中,每个数据项的大小为 size
字节,共写入 count
个数据项。#include
#include
int main()
{
FILE *fp = fopen("log.txt", "w");
if(!fp)
{
perror("fopen error!");
return -1;
}
const char *msg = "hello world!\n";
int count = 5;
int n = 0;
while(count--)
{
n = fwrite(msg, strlen(msg), 1, fp);
printf("write %d block\n",n);
}
fclose(fp);
return 0;
}
运行结果:
我们上面提到当我们以"w"的方式打开一个文件的时候,当文件不存在的时候并且此时我们不带路径,此时就会在当前工作目录下新建该文件,那什么叫做当前路径???新创建的文件为什么会在当前路径下创建呢?此时我们来写一个测试代码!
#include
#include
#include
#include
int main()
{
FILE *fp = fopen("log.txt", "w");
if(!fp)
{
perror("fopen error!");
return -1;
}
const char *msg = "hello world!\n";
int count = 5;
int n = 0;
while(count--)
{
n = fwrite(msg, strlen(msg), 10, fp);
printf("write %d block, pid:%d\n",n,getpid());
sleep(20);
}
fclose(fp);
return 0;
}
随后我们执行程序并执行:
运行结果:
随后我们就能发现此时在proc下该进程存在一个cwd,进程在启动的时候,会自动记录自己启动时所在的路径,我们把这个路径称之为:当前路径。当我们在程序在新建的文件时,此时操作系统会自动拼上当前路径给我们的新建文件,此时我们的文件就会建在当前进程运行的同级目录下。如果我们现在去修改当前运行进程的cwd,那么新建的文件的建立的路径也必然会随之变化。
运行结果:
当前工作路径发生改变
并且在当前程序所在路径查询不到log.txt文件,而能在刚刚修改的那个工作路径下查询到log.txt文件已被建立,但是此时文件大小却为0,因为此时我们的程序还在运行当中,此时写入的内部呗写到缓冲区了。
当程序运行完的时候,此时文件的内容已经被写入了。
fread 函数:
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
ptr
:指向存储读取数据的缓冲区的指针。size
:每个数据项的大小(以字节为单位)。count
:要读取的数据项的数量。stream
:文件指针,指向要读取的文件。stream
指向的文件中读取数据块到 ptr
指向的缓冲区,每个数据项的大小为 size
字节,共读取 count
个数据项。#include
#include
int main()
{
FILE *fp = fopen("myfile", "r");
if(!fp){
printf("fopen error!\n");
}
char buf[1024];
const char *msg = "hello world!\n";
while(1){
//注意返回值和参数,此处有坑,仔细查看man手册关于该函数的说明
ssize_t s = fread(buf, strlen(msg), 1, fp);
if(s > 0){
buf[s] = 0;//把文件内容当做字符串
printf("%s", buf);
}
}
fclose(fp);
return 0;
}
str
是一个指向字符数组的指针,用于存储读取的字符串数据。n
是要读取的最大字符数(包括 null 字符)。stream
是指向 FILE 结构的指针,表示要读取的文件流。fgets
从指定的文件流中读取一行数据,直到达到指定的字符数 n
、遇到换行符('\n')或者到达文件末尾。读取的数据存储到 str
中,并且自动在末尾添加 null 字符('\0'),以表示字符串的结束。
函数返回值是成功读取的字符串的指针,如果发生错误或者到达文件末尾,则返回 NULL。
#include
int main()
{
FILE* fp = fopen("/home/xyc/log.txt","r");
if(fp == NULL)
{
perror("fopen fail!\n");
return -1;
}
char buffer[64];
while(1)
{
char* r = fgets(buffer,sizeof(buffer),fp);
if(!r) break;
printf("%s",buffer);//写文件带了'\n',这里输出就不用带了。
}
return 0;
}
运行结果:
stdin & stdout & stderr
stdin(标准输入):
stdin
是标准输入流,通常用于从键盘或其他输入设备读取数据。键盘上输入信息,你有哪些方法?
stdout(标准输出):
stdout
是标准输出流,通常用于向屏幕或其他输出设备输出数据。输出信息到显示器,你有哪些方法?
stderr(标准错误):
stderr
是标准错误流,用于输出错误消息,通常用于将错误信息输出到屏幕或日志文件。我们的c程序能不能直接把数据写在硬件显示器上吗,c程序能不能直接从键盘上读取数据呢?这肯定是不可以的,在一般的情况下,C程序本身不能直接操控硬件设备,包括显示器和键盘,操作系统是软硬件资源的管理者,所以操作系统不允许应用层的进程绕过操作系统直接访问硬件,所以这就决定了我们在文件读写的同时,必定要贯穿操作系统,此时操作系统必须要为上层语言提供访问文件的系统调用接口,然后才有了我们c语言访问文件的接口,除了上述C接口(当然,C++也有接口,其他语言也有),我们还可以采用系统接口来进行文件访问。
#include
#include
#include
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
pathname: 要打开或创建的目标文件
flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
参数:
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写
O_TRUNC: 如果文件存在且是可写的,则截断文件为零长度。
mode : 仅在 O_CREAT 被设置时才有效,用于指定新创建文件的权限。
在大多数情况下,可以使用八进制表示的权限值,例如 0644。
返回值:
成功:新打开的文件描述符
失败:-1
我们先来介绍一下flag参数,它的参数类型是int,我们通过手册可以知道flag可以传入多个选项,但是一个整型只能保存一个,那怎么办呢?此时可以利用int类型的32个比特位,再加上按位或操作即可,利用不同的比特位进行多个传参,我们来写一个测试代码。
#include
#define ONE 1
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
#define FIVE (1<<4)
void Print(int flag)
{
if(flag & ONE) printf("1\n");
if(flag & TWO) printf("2\n");
if(flag & THREE) printf("3\n");
if(flag & FOUR) printf("4\n");
if(flag & FIVE) printf("5\n");
}
int main()
{
Print(ONE);
Print(ONE|TWO);
Print(ONE|TWO|THREE);
Print(ONE|TWO|THREE|FOUR);
Print(ONE|TWO|THREE|FOUR|FIVE);
return 0;
}
Print函数接受一个整数参数 flag
,并通过按位与运算 (&
) 判断 flag
的哪些位被设置为 1。如果某个位为 1,就使用 printf
打印对应的数字。因此,Print
函数的作用是根据传递的参数打印出对应位上为 1 的数字。例如,第二次调用 Print(ONE|TWO)
会打印出 "1" 和 "2",因为在二进制表示中,ONE
和 TWO
对应的位都被设置为 1。通过上面的测试,我们就知道可以通过设置比特位来向我们的目标函数同时传递多个标记位,通过多个宏进行按位或操作组合式的同时通过位图向Print函数多个传参,我们的flag设置的原理也是这样。
运行结果:
因为我们上面的程序仅仅只是打开文件,并没有指定没有该文件要怎么办?所以我们想如果没有该文件的是时候就创建该文件。
运行结果:
此时就创建了该文件,但是我们发现这个文件的权限乱码了,因为我们创建文件的时候没有设置文件的权限,此时就需要open接口的第三个参数传入文件权限。
运行结果:
我们上面设置的权限是666,但是我们此时创建的文件的权限是664,为什么呢?因为我们在新建文件的时候还受umask权限掩码的约束,系统默认的umask是2,所以创建的文件权限是664,如果我们不想使用系统的权限掩码,我们可以自己设置。
运行结果:
此时文件的权限就是666了,所以未来我们在使用open函数的时候,如果我们打开的文件已经创建好了,我们就不需要传入第三个参数了。
open 函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要open创建,则第三个参数表示创建文件 的默认权限,否则,使用两个参数的open。
如果我们现在不想使用该文件了,就需要关闭该文件,
然后我们就可以向文件进行写入了,这里使用我们的write接口。
fd
是文件描述符,表示要写入数据的目标文件或设备。buf
是一个指向要写入数据的缓冲区的指针。count
是要写入的字节数。write
函数返回写入的字节数,如果出现错误,则返回 -1。写入的实际字节数可能少于请求的字节数,这是正常的。
运行结果:
然后我们可以测试一下拷贝'\0'会怎样
运行结果:
现在我们向字符串重新写入内容aaaa,后面先不带'\n',看看结果会怎样。
运行结果:
我们发现之前写入的字符串并没有被清空,而是在之前的字符串上进行了覆盖操作,所以我们这里flag还要再传入一个参数:O_TRUNC
运行结果:
上面的open就模拟c语言fopen函数实现了以"w"的方式清空文件,现在我们想要以"a"的方式呢?
运行结果:
在认识返回值之前,先来认识一下两个概念: 系统调用 和 库函数
系统调用接口和库函数的关系,一目了然。 所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。
我们发现上面几乎的都需要传入fd,fd到底是什么呢?我们来看看文件写入成功fd是多少?
运行结果:
那我们多次打开文件呢?fd的值又该如何呢?
运行结果:
根据上面的运行结果,我们知道一个文件如果被成功打开,那么返回值fd肯定是大于0的,并且它是连续的小整数,那么问题来啰,0、1和2去哪里了?
#include
#include
#include
#include
#include
int main()
{
char buf[1024];
ssize_t s = read(0, buf, sizeof(buf));
if (s > 0) {
buf[s] = 0;
write(1, buf, strlen(buf));
write(2, buf, strlen(buf));
}
return 0;
}
根据C语言的只知识吗,我们可以知道stdin它是FILE这个结构体的指针,那么我们就可以通过 【->】去访问结果体内部的元素,看看内部是否存在fd这个整型变量。
运行结果:
此时我们就知道一个新的结论,我们标准输入0,标准输出1和标准错误2文件描述符,在操作系统启动的时候,需要在底层把这三个文件描述符打开,然后为我们构建stdin,stdout和stderr这样的FILE类型的结构体,把标准输入0,标准输出1和标准错误2文件描述符分别传入这个FILE结构体里,不仅仅系统调用函数进行封装,同时还对系统调用返回值fd进行了封装,这样我们就在C语言上对类型做了全面封装。那问题又来啰,为什么我们要对系统调用接口进行封装呢?感觉上面的系统调用接口也不复杂呀!这主要是为了可移植性(跨平台性)!!!保证各个平台能访问同样的函数,不用关心底层操作系统的差异性!!!
上面这个fd到底是什么呢?为什么要从0开始呢?我们的数组下标也是从0开始,那是不是这个fd有可能是数组的下标呢?
而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
这也就解释了为什么要系统调用接口的函数都要传入fd参数,因为系统调用需要通过fd下标去找到对应的文件。文件描述符的本质就是数组下标!!!如何理解Linux下,一切皆文件!
在上层我们通过file结构体就可以去对底层硬件进行读取,并且还屏蔽了底层硬件的差距,通过使用每一个硬件的file对象,就可以做到对底层硬件的读取,Linux下,一切皆文件!是我们站在文件层,对底层硬件的读取有函数指针去屏蔽底层硬件的差异,通过file结构体就可以统一去对底层硬件进行读取,就可以做到Linux下,一切皆文件!我们来看一下Linux的源码,看看是否存在我们上面提到的结构,首先我们肯定是要从struct task_struct中去找。
#include
#include
#include
#include
int main()
{
int fd = open("log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
输出发现是 fd: 3,关闭0或者2,在看
#include
#include
#include
#include
int main()
{
close(0);
//close(2);
int fd = open("log.txt", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
发现是结果是: fd: 0 或者 fd 2,可见,文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
那如果关闭1呢?看代码:
#include
#include
#include
#include
#include
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0644);
if (fd < 0) {
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}
运行结果:
此时,我们发现,本来应该输出到显示器上的内容,输出到了文件 log.txtx 当中,其中,fd=1。这种现象叫做输出 重定向。常见的重定向有:>, >>, < 那重定向的本质是什么呢?我们的printf默认是向stdout打印,并且我们上面也验证了stdout->_fileno = 1,当我们通过系统调用把文件标识符关掉,此时下标为1的标识符就不再指向显示器文件了,而是指向我们的"log.txt"文件,由于我们仅仅只是对底层指向做了修改,但是上层C语言printf并不关心底层变化,它只认识 fd = 1,依然拿着fd所指向的文件去写入,刚好就写到了log.txt里面,所以我们所谓的重定向本质就是更改数组所指向的文件,就可以达到重定向的功能。
我们现在就来模拟一下输入重定向:< ,读取时不再从键盘上读取,而是从我们的log.txt文件中读取。
直接看代码:
运行结果:
此时我们就不需要从键盘上读取了,scanf直接从文件上读取到了我们的数据。那怎么能少了我们的输出重定向:> 呢?
运行结果:
那还有追加重定向:>>呢?我也不能少。
运行结果:
但是上面的写法对用户不太友好,用户必须要了解文件描述符的规则才能更好的使用,所以上面的方法不推荐,OS为了方便用户使用,提供了一个dup2系统调用接口去拷贝,从而达到文件的新指向。
函数原型如下:
#include
int dup2(int oldfd, int newfd);
oldfd
:要复制的文件描述符。newfd
:指定的新文件描述符。dup2
的作用是将 oldfd
复制到 newfd
,如果 newfd
已经打开,则先关闭 newfd
。这个函数通常用于重定向标准输入、标准输出或标准错误。
这里的dup2传入的参数有点浑人,我们来解释一下,我们是要完成输出重新项,所以最后1号描述符的所指向的文件肯定就是fd了,那么最后导致1号拷贝之后也变成fd指向的文件了,所以最后两个都是fd,那么最终只剩下oldfd,所以fd即使oldfd。
运行结果: