1 文件fd
1.1 文件基础
1、文件=内容+属性
2、文件分为打开的文件和没打开的文件 (如c中的fopen和fclose)
3、打开的文件是由进程打开的,所以研究打开的文件本质上就是研究进程和文件的关系
4、研究没打开的文件关键在于文件如何被有序地放置,使用户快速找到文件并进行相关的增删查改工作
1.2 被打开的文件
文件要被打开,必然要先被加载到内存中,一个进程可能打开多个文件,一个文件也可能被多个进程打开。所以在操作系统内部一定存在大量被打开的文件,操作系统必须按照先描述再组织的方式把被打开的文件管理起来。
1.3 C语言关于文件的操作
1.3.1 文件的打开和关闭
int main()
{
// 以写的方式打开文件log.txt,如果不存在就创建log.txt
FILE *fp = fopen("log.txt","w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
fclose(fp);
return 0;
}
问题:先被加载到内存的是文件的属性还是文件的内容?
答:属性先被加载进去了, 至于内容需不需要被加载,关键看你有没有通过一些接口来对该文件进程增删查改。
1.3.2 文件的增删查改
int main()
{
FILE *fp = fopen("log.txt","w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
const char *message = "Hello Linux message HYQ";
// 往文件里面写什么,写多长的数据进去,写多少个进去,往哪里写
fwrite(message,strlen(message),1,fp);
fclose(fp);
return 0;
}
w:在写入之前,会对文件进行清空处理
a:在文件的结尾追加写
echo重定向方式写入也会先清空文件,所以底层必然也是w形式
strlen默认是不加/0,如果加1带上/0,此时会打出一个乱码,这个乱码是什么并不重要,重要的是/0居然也是一个可以被写进去的字符。字符串以/0结尾,是C语言的规定,跟文件、跟操作系统没有任何关系。
1.4 过渡文件系统调用
文件在硬盘上,所以我们想要访问文件其实就是访问硬件,几乎所有的库想要访问硬件设备,就必须封装系统调用。
- O_WRONLY:仅允许写入操作,不允许读取。文件必须已存在,否则会失败,不会清空文件原有内容,写入位置从文件开头开始
- O_WRONLY | O_CREAT:允许写入,如果文件不存在则创建,需要指定文件权限(通过第三个参数),不会自动清空文件内容
- O_TRUNC:打开文件时将其长度截断为0,通常与O_WRONLY或O_RDWR一起使用,谨慎使用,数据不可恢复。
- O_APPEND:所有写入操作都在文件末尾进行,不会影响文件原有内容。
// 需要修改一个已存在的文件但不需要读取它的内容时使用。例如,更新配置文件但不需要先读取它
int fd = open("existing_file.txt", O_WRONLY);
// 当不确定文件是否存在,但希望确保可以写入时使用。例如,日志记录系统通常会使用这种模式
int fd = open("possibly_new_file.txt", O_WRONLY | O_CREAT, 0644);
// 需要完全重写一个文件时使用。例如,程序初始化时需要清空旧的日志文件
int fd = open("file_to_overwrite.txt", O_WRONLY | O_TRUNC);
// 需要在文件末尾添加内容而不修改已有数据时使用。例如,多进程同时写入日志文件
int fd = open("log_file.txt", O_WRONLY | O_APPEND);
组合使用:
// 安全地打开日志文件(不存在则创建,存在则追加)
int log_fd = open("application.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
// 创建新配置文件(如果存在则清空)
int config_fd = open("config.ini", O_WRONLY | O_CREAT | O_TRUNC, 0644);
📌 基本访问模式(必选其一)
标志位 | 描述 | 底层操作 |
---|---|---|
O_RDONLY | 只读模式(默认) | 触发读缓存机制 |
O_WRONLY | 只写模式 | 直接写磁盘(除非启用缓冲) |
O_RDWR | 读写模式 | 同时维护读写指针 |
✨ 文件创建与状态控制
标志位 | 描述 | 典型使用场景 |
---|---|---|
O_CREAT | 文件不存在时创建 | `open(“new.log”, O_WRONLY |
O_EXCL | 与O_CREAT联用,文件存在则失败 | 文件锁实现 |
O_TRUNC | 打开时清空文件 | 配置文件重置 |
O_APPEND | 强制追加写入(原子操作) | 多进程日志记录 |
⚡ 高级性能控制
标志位 | 作用 | 注意事项 |
---|---|---|
O_DIRECT | 绕过页缓存直接IO | 需要内存对齐(512字节倍数) |
O_SYNC | 同步写入(保证数据落盘) | 性能下降但可靠性高 |
O_DSYNC | 仅数据同步(不含元数据) | 比O_SYNC稍快 |
O_NOATIME | 不更新访问时间 | 提升SSD寿命 |
🔒 文件锁相关
标志位 | 功能 | 使用示例 |
---|---|---|
O_NONBLOCK | 非阻塞模式 | 管道/FIFO操作 |
O_ASYNC | 信号驱动IO | 网络套接字常用 |
🛡️ 安全控制
标志位 | 用途 | 系统支持 |
---|---|---|
O_CLOEXEC | exec时自动关闭文件描述符 | Linux 2.6.23+ |
O_TMPFILE | 创建无名临时文件 | Linux 3.11+ |
O_PATH | 仅获取文件路径标识(不访问内容) | Linux 2.6.39+ |
1.4.1 比特位方式的标志位传递原理
状态的组合方式有很多种,但是为什么操作系统只用一个int类型就可以表明这些情况,因为有位图的存在,即:用int类型的32个bit位来表示各种不同的组合。
例子:
#define ONE (1<<0) // 1
#define TWO (1<<1) // 2
#define THREE (1<<2) // 4
#define FOUR (1<<3) // 8
void show(int flags)
{
if(flags & ONE) printf("hello function1\n");
if(flags & TWO) printf("hello function2\n");
if(flags & THREE) printf("hello function3\n");
if(flags & FOUR) printf("hello function4\n");
}
int main()
{
printf("------------------------------------\n");
show(ONE);
printf("------------------------------------\n");
show(TWO);
printf("------------------------------------\n");
show(ONE|TWO);
printf("------------------------------------\n");
show(ONE|TWO|THREE);
printf("------------------------------------\n");
show(THREE|FOUR);
printf("------------------------------------\n");
return 0;
}
通过位图的方式一次向一个调用传递多个标记位,这是操作系统传递参数的一种方式,本质上是在外部用 | 的方式组合在内部的方式用 & 的方式检测。
1.4.2 理解文件描述符fd
一个进程在打开的时候默认会打开3个文件:标准输入文件(键盘)、标准输出文件(显示器)、标准错误文件(显示器) 他们的fd分别为 0 、1、 2
我们在学C语言的时候也知道C程序会默认打开3个流,标准输入流、标准输出流、标准错误流。其实这并不是C语言的特性,而是操作系统的特性。
问题:为什么一定要打开这三个流?
因为我们的电脑开机的时候,我们的操作系统就默认检测到了显示器、键盘这类的设备,所以进程打开的时候就必然需要有这些,因为程序员天然需要通过键盘、显示器来观察结果。
1.43 三个流的理解
int main()
{
printf("stdin->fd: %d\n",stdin->_fileno); // stdin->fd: 0
printf("stdout->fd: %d\n",stdout->_fileno); // stdout->fd: 1
printf("stderr->fd: %d\n",stderr->_fileno); // stdout->fd: 2
return 0;
}
如果关闭1号文件,则显示器什么都不显示,因为printf默认就是往stdin里面写
int main()
{
close(1);
printf("stdin->fd: %d\n",stdin->_fileno);
printf("stdout->fd: %d\n",stdout->_fileno);
printf("stderr->fd: %d\n",stderr->_fileno);
return 0;
}
int main()
{
close(1);
int n = printf("stdin->fd: %d\n",stdin->_fileno);
printf("stdout->fd: %d\n",stdout->_fileno);
printf("stderr->fd: %d\n",stderr->_fileno);
fprintf(stderr, "printf ret: %d\n",n); // printf ret: 13
return 0;
}
printf是C库函数,底层封装的时候默认向 stdout的1号描述符里写入,所以如果把1号给关了,printf底层调用write这个函数会失败,但是printf本身并不知道,所以他是有返回值的。而fprintf的优点是可以指定我们想要输出的流,所以当我们向stderr的2号描述符写入的时候,恰好也是指向显示器文件,所以就会被打印出来!
2 理解重定向
int main()
{
const char *filename = "log.txt"; // 明确定义文件名
close(1);
int fd = open(filename,O_CREAT|O_WRONLY|O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
const char *msg = "hello Linux HYQ\n";
int cnt = 5;
while(cnt--)
{
// 向1号文件写,msg,写多长
// 实际上是往
write(1,msg,strlen(msg));
}
close(fd);
return 0;
}
我们把他1号关了,然后又打开了一个新的文件, 发现数据本来应该写到1号的标准输出文件里的,但是写到了log.txt文件,这说明在close将1号位置置空后,该文件就补上了这个位置,说明文件描述符的放置规则是从0下标开始,寻找最小的、未使用的数组位置,他的下标就是新的文件描述符。
之前学的输出重定向就是这个原理,输出重定向的本质就是将文件的输出描述符对应的指针信息替换成文件的指针信息。
问题:如果我们要进行重定向,是不是每次都要,先把1号文件关闭了再打开新的文件?
答:肯定不是,因为重定向的本质是将新文件的指针覆盖掉原来1号位置的指针就行了,系统提供了一个接口叫dup来帮助我们解决这个问题。所以输出重定向和追加重定向底层肯定使用了dup接口。
用oldfd覆盖掉newfd ,用前面的覆盖后面的
int main()
{
const char *filename = "log.txt";
int fd = open(filename,O_CREAT|O_WRONLY|O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
// 重定向, 用fd覆盖1
dup2(fd,1);
close(fd);
const char *msg = "hello Linux HYQ\n";
int cnt = 5;
while(cnt--)
{
// 实际上是向fd写
write(1,msg,strlen(msg));
}
close(fd);
return 0;
}
2.1 输入重定向
int main()
{
const char *filename = "log.txt";
int fd = open(filename,O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
// 重定向 fd覆盖到0号文件
dup2(fd,0);
close(fd);
char inbuffer[1024];
// 本来是从0号文件键盘获取数据,现在是从fd获取数据
// 获取的数据先放到缓冲区inbuffer里面,减1是防止溢出
ssize_t s = read(0,inbuffer,sizeof(inbuffer)-1);
// 现在inbuffer里面实际上是:
// "hello Linux\nhello Linux\nhello Linux\nhello Linux\nhello Linux\n"
if(s > 0) // 说明读取成功了
{
inbuffer[s] = '\0'; // 末尾加'\0'
printf("echo# %s\n",inbuffer); // 上面那一串添加到这里,遇到'\n'就换行
}
return 0;
}
2.2 重定向的本质写法(为什么要有stderr)
将程序的运行结果分别重定向到两个不同的文件(这样可以把运行结果放到正常文件里,然后把错误的信息放到错误的文件里,方便观察,这就是为什么要有stderr的原因)
// 运行mytest文件,1重定向到normal.log文件,2重定向到err.log文件
./mytest 1>normal.log 2>err.log
// 重定向的正确写法,平常不写fd,默认是将1号文件的内容重定向。
如果我想将两个文件的结果都写到一个文件:./mytest 1>all.log 2>&1
,其中1是可以省略的。这个意思是1号文件的地址变成了all.log 然后2也被写入到原来1的位置,所以最后其实都被写到了all.log文件里面。
3 缓冲区深入理解
3.1 先看现象
现象1
int main()
{
const char *fstr = "Hello fwrite\n";
const char *str = "Hello write\n";
// C接口
printf("hello printf\n");
fprintf(stdout, "Hello fprintf\n");
fwrite(fstr,strlen(fstr),1,stdout);
// 系统接口
write(1,str,strlen(str));
return 0;
}
现象2
int main()
{
const char *fstr = "Hello fwrite\n";
const char *str = "Hello write\n";
// C接口
printf("hello printf\n");
fprintf(stdout, "Hello fprintf\n");
fwrite(fstr,strlen(fstr),1,stdout);
// 系统接口
write(1,str,strlen(str));
fork();
return 0;
}
现象3
int main()
{
const char *str = "Hello write\n";
// 系统接口
write(1,str,strlen(str));
close(1);
return 0;
}
3.2 解释现象
通过现象3,我们发现close之后,就什么都不打印了,而close作为系统调用接口不可能不在关闭文件之前刷新缓冲区,这说明他根本看不到这个缓冲区。(有两个缓冲区,C缓冲区和操作系统缓冲区,其中C缓冲区不在操作系统内部)
库函数接口是先把内容放到一个C提供的缓冲区,当需要刷新的时候,才会去调用write函数进行写入,close后刷新不出来的原因就是,进程退出后,想要刷新,但文件描述符被关了,所以即使调了write也写不进去,缓冲区的数据被丢弃了。(进程退出的时候是会刷新缓冲区的)
缓冲区的刷新策略
- 无缓冲——>直接刷新——>fflush函数
- 行缓冲——>遇到/n刷新——>显示器文件
- 全缓存——>写满才刷新——>普通文件
问题1:为什么要有这些不同的方案?
- 全缓冲(写满刷新)——效率优先
- 普通文件(如磁盘文件)默认采用全缓冲,数据会先存到缓冲区,写满或主动调用fflush时才刷新。这种策略能最大限度减少系统调用(如write)的次数,大幅提升I/O效率,适合对实时性要求不高的场景(比如日志写入、大文件操作)
- 行缓冲(遇换行符刷新)——交互友好
- 显示器(如stdout)通常使用行缓冲,遇到\n或缓冲区满时才会刷新。这样既避免了频繁I/O带来的性能损耗,又符合人类阅读习惯(我们更习惯按行阅读内容)。例如,printf(“Hello\n”)会立即显示,而printf(“Hello”)可能不会,直到程序退出或缓冲区满。
- 无缓冲(立即刷新)——实时性优先
- 像stderr这类错误输出通常无缓冲,确保错误信息能立刻显示,不被延迟。虽然牺牲了效率,但对调试和紧急日志至关重要。
问题2:解释现象1
当程序从终端输出重定向到文件时,输出顺序改变是因为缓冲策略发生了变化:终端默认使用行缓冲(遇到\n立即刷新),而文件采用全缓冲(缓冲区满或进程退出才刷新)。write是系统调用,直接写入文件,因此最先显示;而printf、fwrite等C库函数的内容会暂存缓冲区,直到程序结束时才输出。
问题3:解释现象2
当程序将输出重定向到文件时,C标准库函数(如printf/fprintf/fwrite)的输出会先暂存在缓冲区中(全缓冲模式),而系统调用write会直接写入文件。调用fork()时,父子进程会共享这个未刷新的缓冲区。当任一进程退出并尝试刷新缓冲区时,操作系统会触发写时复制(Copy-On-Write),导致缓冲区内容被复制。最终,父子进程各自刷新自己的缓冲区副本,造成C库函数的输出被重复打印。这就是为什么在log.txt中会看到两遍C接口输出的原因。
注意:是先刷新C的缓冲区到系统的缓冲区(如果系统缓冲区有内容,就C缓冲区的内容就到后面排队去),在刷新系统缓冲区到屏幕上,所以系统接口write先打印。
3.3 为什么要有缓冲区
- 解决速度矛盾:CPU处理速度极快,但磁盘、网络等I/O设备速度慢。缓冲区就像"快递中转站",先把数据攒一批再统一发送,避免让CPU等I/O设备。
- 减少系统开销:每次直接读写硬件都要触发耗时的系统调用。缓冲区把多次小操作合并成一次大操作,大幅减少系统调用次数。
- 适配不同场景
- 显示器用行缓冲(遇到\n就刷新),让人能即时看到输出
- 文件用全缓冲(攒满才写磁盘),提高效率
- 错误信息用无缓冲(如stderr),确保紧急消息不延迟
本质:用内存空间换时间效率,是计算机中经典的"批处理"优化思想。
3.4 补充
- C标准库的跨平台奥秘:不同操作系统,比如Linux和windows底层的系统调用接口肯定是不一样的,但是我们在语言层面使用的库函数是一样的!说明在C库底层实现这些接口的时候,不仅写了Linux系统接口的C函数,也写了windows系统接口的C函数,然后通过条件编译的方式在不同的平台裁掉另外一部分,这样我们无论在什么平台都可以使用被封装好的C接口。
- C语言具有跨平台性、可移植性(对底层做了封装,虽然不同平台底层都不一样,但是上层的接口一模一样的 ,库帮我们解决了移植性的问题
- 缓冲区的高效秘诀:通过系统调用把数据拷贝到操作系统是有时间成本的,所以缓冲区的存在可以让我们尽量减少和操作系统的交互,就能提高我们使用C库函数的效率。