【Linux系统编程】基础IO
- [1. 理解"文件"](#1. 理解“文件”)
- [2. 回顾C文件接口](#2. 回顾C文件接口)
- [3. 系统文件I/O](#3. 系统文件I/O)
- [4. 文件重定向](#4. 文件重定向)
- [5. 理解"一切皆文件"](#5. 理解“一切皆文件”)
- [6. 缓冲区](#6. 缓冲区)
1. 理解"文件"
a. 文件 = 文件的内容 + 文件的属性(元数据)--- 未来对文件的操作,就两类操作:1. 内容 2. 属性
b. 访问文件之前,必须要先打开文件,更需要先找到对应的文件
-
"必须先打开文件" 是谁打开文件?怎么打开的?
在C语言中用fopen打开文件,但是我们将文件打开和关闭等代码写好编译成可执行程序时,文件被打开了吗?
文件还没有被打开,只有当该程序运行形成进程时,文件才被打开!
所以文件是动态被打开,程序运行之后打开的!
所以是进程打开的文件,这就是进程和文件的关系。 -
"更需要先找到对应的文件"怎么找对应的文件?
要找到对应的文件必须是路径+文件名 ,但是有时候我们好像没有带路径,为什么?是因为不需要吗?
当然不是,访问任何文件必须要有路径,要么是用户自己提供,要么使用进程的cwd(当前工作路径),当然我们也可以用 chdir 去更改该进程的cwd。
c. 打开文件,其实是在做什么?进程要访问文件的什么?
- 打开文件,其实是在做什么?
打开文件其实是要把文件加载到内存,因为堆文件进行操作,本质是进程通过CPU访问文件,而文件在磁盘中,根据冯诺依曼体系,CPU不直接与外部设备交互,所以要将文件加载到内存中,让CPU访问内存中的文件。 - 进程要访问文件的什么?
如果进程要访问文件的内容就把文件的内容加载到内存,如果要访问文件的属性就把文件的属性加载到内存,都访问就都加载。
d. Linux 存在大量的文件,文件分为:打开的文件和没有被打开的文件!
- 文件从位置上分为:1、内存级被打开的文件;2、磁盘文件
e. Linux系统中,可以同时存在很多被打开的文件!
- OS要不要对这些被打开的文件进行管理呢?
要,如何管理?先描述,再组织。

2. 回顾C文件接口


C语言的文件操作,大家已经很熟悉了,所以我就随便写几个代码让大家回忆一下。
打开文件
c
#include <stdio.h>
int main()
{
const char* filename = "log.txt";
FILE* fp = fopen(filename, "w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
fclose(fp);
return 0;
}

写文件

c
#include <stdio.h>
int main()
{
const char* filename = "log.txt";
FILE* fp = fopen(filename, "w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
int cnt = 1;
while(cnt <= 10)
{
const char* s = "hello world\n";
fputs(s, fp);
++cnt;
}
fclose(fp);
return 0;
}

读文件

c
#include <stdio.h>
int main()
{
const char* filename = "log.txt";
FILE* fp = fopen(filename, "r");
if(fp == NULL)
{
perror("fopen");
return 1;
}
while(1)
{
char buffer[128];
if(!fgets(buffer, sizeof buffer, fp))
break;
printf("from file: %s", buffer);
}
fclose(fp);
return 0;
}

引入命令行参数
c
int main(int argc, char* argv[])
{
if(argc != 2)
{
printf("Usege: %s filename\n", argv[0]);
return 1;
}
FILE* fp = fopen(argv[1], "a");
if(fp == NULL)
{
perror("fopen");
return 1;
}
int cnt = 1;
while(cnt <= 10)
{
const char* s = "hello world\n";
fputs(s, fp);
++cnt;
}
fclose(fp);
return 0;
}

3. 系统文件I/O



打开方式
首先我们写一个程序来引入打开方式。
下面的程序是一种位图的做法,利用二进制中某个位的0/1代表一种状态是否存在,我们想打印什么就把他们按位或起来,在Print函数内部通过对flag与我们设置的状态按位与,以达到我们想要的功能,如果是一个函数调用,就可以用一个Print函数实现很多的功能。
c
#include <stdio.h>
// 只有一个比特位是1
#define ONE (1 << 0) // 0000 0001
#define TWO (1 << 1) // 0000 0010
#define THREE (1 << 2) // 0000 0100
#define FOUR (1 << 3) // 0000 1000
void Print(int flag)
{
if(flag & ONE)
printf("one\n"); // 如果是函数调用,就可以用一个Print函数实现很多的功能
if(flag & TWO)
printf("two\n");
if(flag & THREE)
printf("three\n");
if(flag & FOUR)
printf("four\n");
}
int main()
{
Print(ONE);
printf("\n");
Print(ONE|FOUR);
printf("\n");
Print(ONE|TWO|THREE);
printf("\n");
Print(ONE|TWO|THREE|FOUR);
return 0;
}

在Linux中常用的文件打开方式有:

它们都是宏,我们也可以用按位或让这些操作搭配起来。
打开权限
打开存在的文件,两个参数的open就够了。
如果文件不存在,就要新建文件,新建一个文件需要权限,不过不填权限,新建的文件的权限就是随机的,所以我们要指明打开权限是什么。
下面我们写几段代码来测试一下
c
#include <stdio.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 | O_TRUNC); // 对标fopen中的"w"
const char* msg = "abcdefg";
write(fd, msg, strlen(msg) + 1); // 要不要加1,不需要,\0是C语言的规定,跟文件没有关系
close(fd);
return 0;
}
原本就有log.txt时的现象

没有log.txt时的现象,我们发现新建的log.txt的权限确实是随机的

要解决这个问题,就需要在打开权限里写上我们需要的权限
c
#include <stdio.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 | O_APPEND, 0666); // 对标fopen中的"a"
const char* msg = "abcdefg";
write(fd, msg, strlen(msg) + 1); // 要不要加1,不需要,\0是C语言的规定,跟文件没有关系
close(fd);
return 0;
}
我们设置的文件权限是666,而系统默认的权限掩码umask是002,所以最终就是664。

文件描述符
大家一定很好奇所谓的文件描述符 到底是什么吧?下面我们就探索一下文件描述符。
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
// 读
int fd = open("log.txt", O_RDONLY); // 对标fopen中的"r"
printf("fd: %d\n", fd);
close(fd);
return 0;
}
为什么文件描述符fd是3?我们知道open的返回值,如果打开失败返回-1,打开成功返回文件描述符,既然这个文件描述符是3,那0、1、2呢??

我们再多打开几个文件试试。
c
#include <stdio.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|O_TRUNC, 0666);
int fda = open("loga.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
int fdb = open("logb.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
printf("fd: %d\n", fd);
printf("fda: %d\n", fda);
printf("fdb: %d\n", fdb);
close(fd);
close(fda);
close(fdb);
return 0;
}
通过打印结果,我们得知打开文件时的文件描述符是从小到大顺序给的。

其实我们打开Linux系统时,OS会自动帮我们打开3个文件,分别是标准输入、标准输出、标准错误,它们分别占用着文件描述符0、1、2。
而在C语言中也有3个全局变量stdin、stdout、stderr,它们分别代表标准输入、标准输出、标准错误。
大家在学习C语言的文件操作时,肯定不理解FILE到底是什么?有人说这个文件流、文件句柄,但是这只是一个名词,并不能明确说明它到底是什么。我们现在就有点眉目了,FILE其实就是一个结构体,并且我们也知道,这个结构体里的成员一定有文件描述符fd,所以FILE结构体其实就是对文件描述符fd的封装罢了。

下面我们就打印一下stdin、stdout、stderr的文件描述符。
c
#include <stdio.h>
int main()
{
printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);
return 0;
}

语言的文件操作 vs 系统的文件操作
我们谈了理解"文件"、C语言的文件、系统文件I/O,其实大家会发现,好像C语言的文件操作是对系统的文件操作的封装,fopen封装了open、fwrite封装了write、FILE封装了fd等等。
但是为什么要封装呢?C语言弄了一套、C++兼容C又弄了一套、Java、Python等都有自己的一套文件操作,直接用系统的文件调用不好吗?这是因为Linux、Windows、MacOS等操作系统的文件操作是不一样的,一个语言发明出来必须得有人用,所以以C语言为例,C语言的文件操作封装了这些系统的文件操作,在不同的系统中同样调用fopen接口,底层就会调用对应系统的文件打开操作,这样我们用户在不同的系统下就不用再想一下这个系统的文件操作是什么了,统一用C语言的接口。
文件描述符的本质是什么?

-
文件描述符的本质
文件描述符是一个非负整数,是进程访问其已打开文件的唯一标识 。它的本质是进程文件描述符表 的数组索引。当进程调用
open()成功时,内核会在该进程的文件描述符表中分配一个空闲的索引项,并使其指向一个内核文件对象,最后将该索引作为fd返回给用户。 -
文件打开对象的管理
内核为每一个成功打开的文件创建一个
struct file对象(或称文件打开对象)。该对象记录了文件的动态信息,如:- 读写位置指针
- 访问模式(只读、读写等)
- 引用计数
- 指向文件索引节点 的指针(包含权限、大小、磁盘位置等静态信息)
内核通过"先描述,再组织"的方式管理所有struct file对象,通常将其放入一个全局链表或树中,方便进行增删查改。
-
进程与文件的连接
进程的PCB中包含一个
struct files_struct结构,它核心的功能是维护一个指针数组(即文件描述符表)。数组的每个元素指向一个struct file对象。这样,用户空间的一个fd整数,通过进程内的文件描述符表,最终关联到内核中具体的文件打开对象。 -
标准输入/输出/错误的实现
进程启动时,默认会打开三个文件描述符:
0:标准输入1:标准输出2:标准错误
它们通常初始指向同一个终端设备(如显示器)。这意味着,fd=1和fd=2可能指向同一个struct file对象。该对象内部的引用计数f_count因此为 2。
-
引用计数与文件关闭
close(fd)系统调用并非总是直接关闭文件。其核心操作是:- 将对应
fd的数组项置为NULL,释放该文件描述符。 - 将对应的
struct file对象的引用计数减 1。 - 只有当引用计数减为 0 时 ,内核才会执行真正的清理工作(如将内核缓冲区数据刷入磁盘)并释放该
struct file对象。因此,关闭stdout (fd=1)后,stderr (fd=2)依然可以正常使用。
- 将对应
-
示例
如果进程关闭了
fd=1(标准输出),那么后续调用printf(其输出目标是stdout)将会失败,因为文件描述符 1 已无效。
4. 文件重定向
我们知道1:标准输出,我们可以将我们自己的文件重定向到 1 ,达到理应打印在显示器中的内容,写入到文件中。
打印到显示器中
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
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;
}
const char* msg = "hello linux\n";
int cnt = 5;
while(cnt--)
{
write(1, msg, strlen(msg));
}
close(fd);
return 0;
}
我们只是对log.txt做了打开和关闭,所以文件中什么也没有,并且把msg打印到1,也就是显示器上。

写入到文件中
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(1); // 关闭1:标准输出
const char* filename = "log.txt";
// 此时新建文件,文件的描述符fd就是1
int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
const char* msg = "hello linux\n";
int cnt = 5;
while(cnt--)
{
write(1, msg, strlen(msg));
}
close(fd);
return 0;
}

系统调用dup2
这种文件重定向方法存在诸多局限。要实现多个重定向操作时,需要反复进行文件打开和关闭操作,不仅效率低下,代码可读性也较差。为此,我们建议改用系统调用dup2,它能直接完成文件重定向操作,从而解决上述问题。


利用系统调用dup2,写入到文件中
输出重定向
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
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;
}
dup2(fd, 1);
const char* msg = "hello linux\n";
int cnt = 5;
while(cnt--)
{
write(1, msg, strlen(msg));
}
close(fd);
return 0;
}

追加重定向
追加重定向就不再多说了,其实就是打开文件时的打开方式,把O_TRUNC改成O_APPEND
输入重定向
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
const char* filename = "log.txt";
int fd = open(filename, O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd, 0);
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof buffer - 1);
if(n > 0)
{
buffer[n] = '\0';
printf("buffer: %s\n", buffer);
}
return 0;
}
不再向键盘中读取数据,直接向log.txt文件中读取数据,这就是输入重定向。

给shell程序增加重定向
c
#include <stdio.h>
#include <ctype.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <iostream>
#include <string>
#define MAXSIZE 128
#define MAXARGS 32
// shell自己内部维护的第一张表:命令行参数表
// 故意设计成全局的
// 命令行参数表
char* gargv[MAXARGS];
int gargc = 0;
const char* gsep = " ";
// 环境变量表
char* genv[MAXARGS];
int genvc = 0;
// 我们shell自己所处的工作路径
char cwd[MAXSIZE];
// 最近一个命令执行完毕,退出码
int lastcode = 0;
// ls -a -l > XX.txt -> "ls -a -l" && "XX.txt" && 重定向方式
// 表面重定向的信息
#define NoneRedir 0
#define InputRedir 1
#define AppRedir 2
#define OutputRedir 3
int redir_type = NoneRedir; // 记录正在执行的命令,重定向方式
char* filename = NULL; // 保存重定向的目标文件
#define TrimSpace(start) do{\
while(isspace(*start)) ++start;\
} while(0)
static std::string rfindDir(const std::string& p)
{
if(p == "/")
return p;
const std::string psep = "/";
auto pos = p.rfind(psep);
if(pos == std::string::npos)
return std::string();
return p.substr(pos + 1);
}
void LoadEnv()
{
// 正常情况,环境变量表内部是从配置文件来的
// 这里我们从父进程拷贝
extern char** environ;
for(; environ[genvc]; ++genvc)
{
genv[genvc] = new char[4096];
strcpy(genv[genvc], environ[genvc]);
}
genv[genvc] = NULL;
//printf("Load env: \n");
//for(int i = 0; i < genvc; ++i)
// printf("genv[%d]: %s\n", i, genv[i]);
}
const char* GetUserName()
{
char* username = getenv("USER");
if(username == NULL)
return "None";
return username;
}
const char* GetHostName()
{
char* hostname = getenv("HOSTNAME");
if(hostname == NULL)
return "None";
return hostname;
}
const char* GetPwd()
{
char* pwd = getenv("PWD");
//char* pwd = getcwd(cwd, sizeof(cwd));
if(pwd == NULL)
return "None";
return pwd;
}
void PrintCommandLine()
{
printf("[%s@%s %s]# ", GetUserName(), GetHostName(), rfindDir(GetPwd()).c_str()); // 用户名 @ 主机名 当前路径
fflush(stdout);
}
int GetCommand(char commandline[], int size)
{
if(NULL == fgets(commandline, size, stdin))
return 0;
// 2.1 用户输入的时候,至少会摁一下\n,abcd\n
commandline[strlen(commandline) - 1] = '\0';
return strlen(commandline);
}
// ls -a -l > XX.txt || ls -a -l >> XX.txt || cat < XX.txt || ls -a -l
void ParseRedir(char commandline[])
{
redir_type = NoneRedir;
filename = NULL;
char* start = commandline;
char* end = commandline + strlen(commandline);
while(start < end)
{
if(*start == '>')
{
if(*(start + 1) == '>')
{
// 追加重定向
*start = '\0';
++start;
*start = '\0';
++start;
TrimSpace(start); // 去掉前面的空格
redir_type = AppRedir;
filename = start;
break;
}
// 输出重定向
*start = '\0';
++start;
TrimSpace(start); // 去掉前面的空格
redir_type = OutputRedir;
filename = start;
break;
}
else if(*start == '<')
{
// 输入重定向
*start = '\0';
++start;
TrimSpace(start); // 去掉前面的空格
redir_type = InputRedir;
filename = start;
break;
}
else
{
// 没有重定向
++start;
}
}
}
int ParseCommand(char commandline[])
{
gargc = 0;
memset(gargv, 0, sizeof gargv);
gargv[gargc] = strtok(commandline, gsep);
while((gargv[++gargc] = strtok(NULL, gsep)));
//printf("gargc: %d\n", gargc);
//int i = 0;
//for(; gargv[i]; ++i)
// printf("gargv[%d]: %s\n", i, gargv[i]);
return gargc;
}
// return val:
// 0 : 不是内建命令
// 1 :内建命令
int CheckBuiltinExecute()
{
if(strcmp(gargv[0], "cd") == 0)
{
// 内建命令
if(gargc == 2)
{
// 新的目标路径:argv[1]
// 1. 更改进程内核中的路径
chdir(gargv[1]);
// 2. 得到更改的路径
char pwd[1024];
getcwd(pwd, sizeof(pwd));
// 3. 更改环境变量
snprintf(cwd, sizeof(cwd), "PWD=%s", pwd);
putenv(cwd);
lastcode = 0;
}
return 1;
}
else if(strcmp(gargv[0], "echo") == 0)
{
if(gargc == 2)
{
if(gargv[1][0] == '$')
{
if(strcmp(gargv[1] + 1, "?") == 0)
{
printf("%d\n", lastcode);
}
else if(strcmp(gargv[1] + 1, "PATH") == 0)
{
printf("%s\n", getenv("PATH"));
}
}
lastcode = 0;
}
return 1;
}
return 0;
}
int ExecuteCommand()
{
// 不能让你的bash自己去执行命令,必须创建子进程
//execvp(gargv[0], gargv);
pid_t id = fork();
if(id < 0) return -1;
else if(id == 0)
{
// child
// 让子进程去执行命令
// 重定向
int fd = -1;
if(redir_type == NoneRedir)
{
// Do Nothing
}
else if(redir_type == InputRedir)
{
fd = open(filename, O_RDONLY);
dup2(fd, 0);
}
else if(redir_type == OutputRedir)
{
fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);
}
else if(redir_type == AppRedir)
{
fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);
dup2(fd, 2);
}
else
{
// 不可能到这里
// 如果到这里就有bug
}
//execvp(gargv[0], gargv);
execvpe(gargv[0], gargv, genv); // 程序替换不会影响进程打开的文件
exit(1);
}
else
{
// father
// 等待子进程,回收子进程
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
//printf("wait child process success\n");
lastcode = WEXITSTATUS(status);
}
}
return 0;
}
int main()
{
// 0. 从配置文件中获取环境变量配置环境变量表
LoadEnv();
char command_line[MAXSIZE] = {0};
while(1)
{
// 1. 打印命令行字符串
PrintCommandLine();
// 2. 获取用户输入
if(0 == GetCommand(command_line, sizeof(command_line)))
continue;
//printf("%s\n", command_line);
// 3. 解析字符串中的重定向
// ls -a -l > XX.txt || ls -a -l >> XX.txt || cat < XX.txt || ls -a -l
// ls -a -l > XX.txt -> "ls -a -l" && "XX.txt" && 重定向方式
ParseRedir(command_line);
//printf("command: %s\n", command_line);
//printf("redir type: %d\n", redir_type);
//printf("filename: %s\n", filename);
// 4. 解析字符串 -> "ls -a -l" -> "ls" "-a" "-l"
// 命令行解释器,就要对用户输入的命令字符串首先进行解析!
ParseCommand(command_line);
// 5. 判断该命令是让父进程执行(内建命令),还是让子进程执行
if(CheckBuiltinExecute())
continue;
// 6. 让子进程执行这个命令
ExecuteCommand();
}
return 0;
}
5. 理解"一切皆文件"

a. 为什么外设可以被看作文件?
在 Linux 中,CPU 和内存之外的大部分硬件设备,例如磁盘、显示器、键盘、网卡等,都属于外设。这些设备虽然功能各异,但都有一个共同点:都需要进行输入/输出(I/O)操作,即某种形式的"读"和"写"。
- 磁盘既能读也能写。
- 显示器主要是"写"(输出显示),不需要"读"。
- 键盘主要是"读"(输入按键),不需要"写"。
- 网卡则需要同时进行读(接收数据)和写(发送数据)。
尽管不同外设的物理读写方式差异很大,但它们都可以被抽象为必须进行I/O操作的对象。Linux 内核通过设备驱动程序来具体实现每种外设的底层读写功能。这种"都需要I/O"的共性,为统一抽象提供了基础。
b. Linux 如何实现"文件"抽象:虚拟文件系统(VFS)
为了统一管理这些抽象实体,Linux 采用了"先描述,再组织 "的管理策略,其核心是虚拟文件系统(VFS) 机制。
-
抽象为文件对象
每个被打开的设备(或普通文件)在内核中都会由一个结构体(如
struct file)来表示,这个结构体可以被视为一个"文件对象 "。它不仅记录了文件的元信息(如访问权限、当前读写位置等),还包含一个至关重要的指针------指向一个操作函数集结构体 (例如struct file_operations)。 -
统一的操作接口
这个操作函数集结构体内包含了多个函数指针,例如
read和write。对于不同的设备类型,这些指针会指向各自驱动程序中的具体实现函数。例如,硬盘的write函数和显示器的write函数在底层实现完全不同,但对上层来说,它们都通过同一个write函数指针来调用。 -
组织与管理
内核中所有打开的文件对象通过特定的数据结构(如链表、树等)组织起来,形成一个统一的视图,这就是虚拟文件系统(VFS)。
c. 进程如何访问"文件":文件描述符的作用
进程是资源的拥有者,它如何与这些文件对象建立联系呢?答案是通过文件描述符(File Descriptor, fd)。
-
进程的视角
每个进程在其进程控制块(PCB) 中都有一个文件描述符表。这个表可以看作一个数组,数组的每个索引号就是一个文件描述符(如 0, 1, 2 分别代表标准输入、标准输出、标准错误),而数组的元素则指向内核中对应的文件对象。
-
访问流程
当进程需要访问一个外设时,例如进行读操作:
- 进程调用
read(fd, ...),传入文件描述符fd。 - 内核通过
fd在进程的文件描述符表中找到对应的文件对象。 - 通过文件对象找到操作函数集结构体,并调用其中的
read函数指针。 - 该函数指针最终指向设备驱动的具体读函数,从而完成对硬件的操作。
- 进程调用
d. 深刻意义:统一接口与多态
这种设计体现了高超的软件抽象思想,带来了巨大优势:
- 对上层应用的简化 :应用程序开发者无需关心底层是何种硬件。无论操作的是硬盘上的文件,还是通过网络套接字发送数据,亦或是向打印机发送指令,都可以使用统一的系统调用接口(
open,read,write,close)。这极大地降低了编程的复杂度和学习成本。 - C语言层面的"多态" :这本质上是面向对象编程中"多态 "思想的体现。
read和write是统一的接口(或称为"协议"),但具体执行哪个设备的代码,则由文件描述符fd所最终关联的设备驱动决定,即"指向谁,就调用谁"。这实现了上层应用与底层硬件驱动的彻底解耦。 - 面向对象的设计哲学:Linux 内核虽然主要用 C 语言开发,但它通过这种精妙的设计实践了面向对象的核心思想。开发者只需关注接口,而无需深究具体实现。这种高层次的抽象和封装,是构建复杂且稳定系统的关键。
总结
简单来说,理解 Linux 的"一切皆文件",可以抓住三个关键点:统一的抽象 (VFS和文件对象)、统一的句柄 (文件描述符 fd)和统一的接口 (read/write等系统调用)。正是通过这一整套机制,Linux 成功地将种类繁多的外部设备以一种一致、简洁的方式呈现给用户和应用程序,赋予了系统极大的灵活性和一致性。
6. 缓冲区
什么是缓冲区?
缓冲区是内存空间的⼀部分。也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间⽤来缓冲输⼊或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输⼊设备还是输出设备,分为输⼊缓冲区和输出缓冲区。
为什么要引入缓冲区机制?
读写⽂件时,如果不会开辟对⽂件操作的缓冲区,直接通过系统调⽤对磁盘进⾏操作(读、写等),那么每次对⽂件进⾏⼀次读写操作时,都需要使⽤读写系统调⽤来处理此操作,即需要执⾏⼀次系统调⽤,执⾏⼀次系统调⽤将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下⽂的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执⾏效率造成很⼤的影响。
为了减少使⽤系统调⽤的次数,提⾼效率,我们就可以采⽤缓冲机制。⽐如我们从磁盘⾥取信息,可以在磁盘⽂件进⾏操作时,可以⼀次从⽂件中读出⼤量的数据到缓冲区中,以后对这部分的访问就不需要再使⽤系统调⽤了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作⼤⼤快于对磁盘的操作,故应⽤缓冲区可⼤⼤提⾼计算机的运⾏速度。
⼜⽐如,我们使⽤打印机打印⽂档,由于打印机的打印速度相对较慢,我们先把⽂档输出到打印机相应的缓冲区,打印机再⾃⾏逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是⼀块内存区,它⽤在输⼊输出设备和CPU之间,⽤来缓存数据。它使得低速的输⼊输出设备和⾼速的CPU能够协调⼯作,避免低速的输⼊输出设备占⽤CPU,解放出CPU,使其能够⾼效率⼯作。
缓冲类型
标准I/O提供了3种类型的缓冲区。
- 全缓冲区:这种缓冲⽅式要求填满整个缓冲区后才进⾏I/O系统调⽤操作。对于磁盘⽂件的操作通常使⽤全缓冲的⽅式访问。
- ⾏缓冲区:在⾏缓冲情况下,当在输⼊和输出中遇到换⾏符时,标准I/O库函数将会执⾏系统调⽤操作。当所操作的流涉及⼀个终端时(例如标准输⼊和标准输出),使⽤⾏缓冲⽅式。因为标准I/O库每⾏的缓冲区⻓度是固定的,所以只要填满了缓冲区,即使还没有遇到换⾏符,也会执⾏I/O系统调⽤操作,默认⾏缓冲区的⼤⼩为1024。
- ⽆缓冲区:⽆缓冲区是指标准I/O库不对字符进⾏缓存,直接调⽤系统调⽤。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。
除了上述列举的默认刷新⽅式,下列特殊情况也会引发缓冲区的刷新:
- 缓冲区满时;
- 执⾏flush语句;
示例如下:
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.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文件中,但是我们发现,程序允许结束后,文件中并没有被写入内容

这是由于我们将1号描述符重定向到磁盘文件后,缓冲区的刷新方式成为了全缓冲。而我们写入的内容并没有填满整个缓冲区,导致并不会将缓冲区的内容刷新到磁盘文件中。怎么办呢?可以使用fflush强制刷新缓冲区。
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.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;
}

还有一种解决方法,刚好可以验证一下strerr是不带缓冲区的,代码如下:
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.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就可以写入文件。

标准输出 vs 标准错误
c
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
// 标准输出:1
printf("这是一个正常的消息\n");
fprintf(stdout, "这是一个正常的日志信息\n");
const char* msg = "这是一个正常的消息,write\n";
write(1, msg, strlen(msg));
// 标准错误:2
fprintf(stderr, "这是一个错误的日志信息\n");
const char* err = "这是一个错误的消息,write\n";
write(2, err, strlen(err));
perror("perror: ");
return 0;
}

FILE
- 因为IO相关函数与系统调⽤接⼝对应,并且库函数封装系统调⽤,所以本质上,访问⽂件都是通过fd访问的。
- 所以C库当中的FILE结构体内部,必定封装了fd。
下面我们写一段代码研究一下
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.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;
}
运行结果

但如果对进程实现输出重定向呢?./operator > log.txt,我们发现结果变成了:

我们发现 printf 和 fwrite (库函数)都输出了2次,而write(系统调用)只输出了一次。为什么呢?肯定和 fork 有关。
⼀般C库函数写⼊⽂件时是全缓冲的,⽽写⼊显示器是⾏缓冲。printf 和 fwrite 库函数会⾃带缓冲区,当发⽣重定向到普通⽂件时,数据的缓冲⽅式由⾏缓冲变成了全缓冲。⽽我们放在缓冲区中的数据,就不会被⽴即刷新,甚⾄fork之后也不会刷新。但是进程退出之后,会统⼀刷新,写⼊⽂件当中。但是fork的时候,⽗⼦数据会发⽣写时拷⻉,所以当⽗进程准备刷新的时候,⼦进程也就有了同样的⼀份数据,随即产⽣两份数据。write 没有变化,说明没有所谓的缓冲。
综上:printf 和 fwrite 库函数会⾃带缓冲区,⽽ write 系统调⽤没有带缓冲区。另外,我们这⾥所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。
那这个缓冲区谁提供呢?printf 和 fwrite 是库函数,write 是系统调⽤,库函数在系统调⽤的"上层",是对系统调⽤的"封装",但是 write 没有缓冲区,⽽ printf 和 fwrite 有,足以说明,该缓冲区是⼆次加上的,⼜因为是C,所以由C标准库提供。
简单设计一下libc库
mystdio.h
c
#pragma once
#include <stdio.h>
#define SIZE 1024
#define NON_BUFFER 1 // 1
#define LINE_BUFFER 2 // 10
#define FULL_BUFFER 4 // 100
#define MODE 0666
typedef struct _myFILE
{
int fd; // 文件描述符
int flags; // 打开方式
int flush_mode; // 刷新方式
char outbuffer[SIZE]; // 输出缓冲区
int pos; // 输出缓冲区下标
int cap; // 输出缓冲区容量
}myFILE;
myFILE* myfopen(const char* pathname, const char* mode); // r, w, a, r+, w+, a+
int myfputs(const char* str, myFILE* fp);
void myfflush(myFILE* fp);
void myfclose(myFILE* fp);
mystdio.c
c
#include "mystdio.h"
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#define TRY_FLUSH 1
#define MUST_FLUSH 2
myFILE* myfopen(const char* pathname, const char* mode) // r, w, a, r+, w+, a+
{
int fd = -1;
int flags = 0;
if(strcmp(mode, "r") == 0)
{
flags = O_RDONLY;
fd = open(pathname, flags);
}
else if(strcmp(mode, "w") == 0)
{
flags = O_WRONLY | O_CREAT | O_TRUNC;
fd = open(pathname, flags, MODE);
}
else if(strcmp(mode, "a") == 0)
{
flags = O_WRONLY | O_CREAT | O_APPEND;
fd = open(pathname, flags, MODE);
}
else
{}
if(fd < 0)
return NULL;
myFILE* fp = (myFILE*)malloc(sizeof(myFILE));
if(fp == NULL)
return NULL;
fp->fd = fd;
fp->flags = flags;
fp->flush_mode = LINE_BUFFER;
fp->cap = SIZE;
fp->pos = 0;
return fp;
}
void myfflush(myFILE* fp)
{
// 写到内核中
write(fp->fd, fp->outbuffer, fp->pos);
fp->pos = 0; // 清空缓冲区
}
static void myfflushcode(myFILE* fp, int flag)
{
if(fp->pos == 0)
return;
if(flag == TRY_FLUSH)
{
if(fp->flush_mode & LINE_BUFFER)
{
// "abcd\n"
if(fp->outbuffer[fp->pos - 1] == '\n')
{
myfflush(fp);
}
}
else if(fp->flush_mode & FULL_BUFFER)
{
//if(fp->pos == fp->cap)
}
else if(fp->flush_mode & NON_BUFFER)
{
//write()
}
}
else if(flag == MUST_FLUSH)
{
myfflush(fp);
}
}
int myfputs(const char* str, myFILE* fp)
{
if(strlen(str) == 0)
return 0;
// step 1:向文件流里面写,本质是:文件缓冲区 -> 拷贝
memcpy(fp->outbuffer + fp->pos, str, strlen(str));
fp->pos += strlen(str);
// step 2:如果条件允许,可以自己刷新
myfflushcode(fp, TRY_FLUSH);
return strlen(str);
}
void myfclose(myFILE* fp)
{
// 1. 强制刷新到内核
myfflushcode(fp, MUST_FLUSH);
// 1.2 强制刷新到磁盘
fsync(fp->fd); // 不是必须的
// 2. 关闭文件
close(fp->fd);
// 3. free
free(fp);
}
main.c
c
#include "mystdio.h"
#include <unistd.h>
int main()
{
myFILE* fp = myfopen("log.txt", "w");
if(fp == NULL)
{
printf("myfopen failed\n");
return 1;
}
const char* msg = "hello world";
int cnt = 10;
while(cnt--)
{
myfputs(msg, fp);
sleep(1);
printf("debug: outbuffer: %s, pos: %d\n", fp->outbuffer, fp->pos);
}
myfclose(fp);
return 0;
}