在 Linux 编程世界中,“一切皆文件” 是贯穿始终的核心思想,而基础 IO 操作则是与这些 “文件” 交互的关键。无论是日常的文件读写,还是与键盘、显示器等外设通信,甚至是网络数据传输,都离不开对 IO 机制的理解。本文将以 Linux 系统为背景,从文件的本质出发,逐步深入系统调用、文件描述符、重定向等核心概念,最终带你实现自定义 Shell 的重定向功能,帮你彻底打通基础 IO 的知识脉络。
一、重新认识 “文件”:不止是磁盘中的文档
提到 “文件”,多数人的第一反应是磁盘里存储的文档(如.txt、.c 文件),但在 Linux 系统中,文件的概念被极大地扩展了。我们需要从狭义和广义两个维度重新理解它,这是掌握 IO 操作的第一步。
1.1 狭义文件:磁盘上的永久性存储
狭义的文件就是我们最熟悉的 “磁盘文件”,它具有两个核心特征:
永久性存储:磁盘是持久化存储介质,除非主动删除,否则文件会一直存在;
本质是 IO 操作:对文件的读写(如打开、修改、保存),本质是对 “磁盘” 这个外设的输入(从磁盘读数据到内存)和输出(从内存写数据到磁盘)操作,即 IO(Input/Output)。
1.2 广义文件:Linux 下 “一切皆文件”
Linux 系统将所有硬件设备、进程、管道等都抽象成了 “文件”,例如:
键盘(标准输入)、显示器(标准输出 / 错误);
网卡、磁盘、打印机等外设;
管道(Pipe)、套接字(Socket,用于网络通信)。
这种抽象的最大好处是统一接口:无论操作的是磁盘文件还是键盘,都可以使用一套 IO 接口(如 read/write),极大降低了开发复杂度。例如,用 read(0, ...) 可以读取键盘输入,用 write(1, ...) 可以向显示器输出 —— 这里的 0 和 1 就是后续要讲的 “文件描述符”。
1.3 文件的本质:属性 + 内容
无论哪种文件,其本质都是 “属性(元数据)+ 内容” 的集合:
属性:描述文件的信息,如文件名、大小、权限(rwx)、创建时间、所属用户等;
内容:文件实际存储的数据(如文本、二进制指令)。
即使是 0KB 的空文件,也会占用磁盘空间 —— 因为它需要存储 “文件名、权限” 等属性信息。所有文件操作(如 chmod 修改权限、cat 查看内容),本质都是对 “属性” 或 “内容” 的修改。
二、回顾 C 语言文件 IO:库函数的封装与使用
2.1 核心接口:fopen/fwrite/fread/fclose
C 语言通过 FILE* 指针管理打开的文件,核心操作流程如下:
1. 打开文件:fopen
#include <stdio.h>
int main() {
// 以“只写”模式打开 myfile,不存在则创建
FILE *fp = fopen("myfile", "w");
if (!fp) { // 打开失败会返回 NULL
printf("fopen error!\n");
return 1;
}
fclose(fp); // 关闭文件,必须调用!
return 0;
}
路径问题:若不指定路径(如 myfile),文件会创建在 “进程的当前工作目录(CWD)” 下。可以通过 /proc/[进程ID]/cwd 查看进程的当前目录(例如 ls -l /proc/1234/cwd)。
打开模式:"w"(只写,覆盖原有内容)、"r"(只读)、"a"(追加写)等,后续会详细说明。
2. 写文件:fwrite
#include <stdio.h>
#include <string.h>
int main() {
FILE *fp = fopen("myfile", "w");
if (!fp) { printf("fopen error!\n"); return 1; }
const char *msg = "hello bit!\n";
int count = 5;
// 向 fp 写入 5 次 msg(每次写 strlen(msg) 字节)
while (count--) {
fwrite(msg, strlen(msg), 1, fp);
}
fclose(fp);
return 0;
}
fwrite 参数:const void *ptr(待写数据地址)、size_t size(每个数据块大小)、size_t nmemb(数据块数量)、FILE *stream(目标文件指针)。
3. 读文件:fread
#include <stdio.h>
#include <string.h>
int main() {
FILE *fp = fopen("myfile", "r");
if (!fp) { printf("fopen error!\n"); return 1; }
char buf[1024];
const char *msg = "hello bit!\n";
while (1) {
// 每次读 strlen(msg) 字节到 buf
ssize_t s = fread(buf, 1, strlen(msg), fp);
if (s > 0) {
buf[s] = '\0'; // 手动加字符串结束符
printf("%s", buf);
}
if (feof(fp)) { // 判断是否读到文件末尾
break;
}
}
fclose(fp);
return 0;
}
关键注意点:fread 返回实际读取的字节数,若返回 0,需用 feof(fp) 判断是 “文件末尾” 还是 “读取错误”。
2.2 标准流:stdin/stdout/stderr
C 语言程序启动时,会默认打开 3 个标准流(FILE* 类型),对应 3 个常用外设:
stdin(标准输入):对应键盘,文件描述符为 0;
stdout(标准输出):对应显示器,文件描述符为 1;
stderr(标准错误):对应显示器,文件描述符为 2。
例如,向显示器输出的 3 种方式:
#include <stdio.h>
#include <string.h>
int main() {
const char *msg = "hello fwrite\n";
fwrite(msg, strlen(msg), 1, stdout); // 用 fwrite 写 stdout
printf("hello printf\n"); // printf 本质是向 stdout 输出
fprintf(stdout, "hello fprintf\n"); // fprintf 指定 stdout
return 0;
}
2.3 打开模式详解
不同的打开模式决定了文件的操作权限,常见模式如下:
| 模式 | 含义 | 核心特征 |
|---|---|---|
r | 只读打开 | 文件不存在则报错;流定位到文件开头 |
r+ | 读写打开 | 文件不存在则报错;流定位到文件开头 |
w | 只写打开 | 文件不存在则创建,存在则清空;流定位到开头 |
w+ | 读写打开 | 同 w,但支持读操作 |
a | 追加写 | 文件不存在则创建;流定位到文件末尾,每次写都追加到末尾 |
a+ | 追加读 + 写 | 同 a,但支持读(读操作从开头开始,写操作仍追加到末尾) |
三、Linux 系统文件 IO:内核接口的本质
C 库函数(如 fopen)是对 “系统调用” 的封装,而系统调用是操作系统内核提供的、直接操作硬件 / 文件的接口。掌握系统调用,才能真正理解 IO 操作的底层逻辑。
3.1 核心系统调用:open/read/write/close
系统调用的接口风格与 C 库函数不同,例如用 open 代替 fopen,用 int fd(文件描述符)代替 FILE*。
1. 打开文件:open
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
umask(0); // 清除默认权限掩码(否则创建文件权限会被削弱)
// 以“只写+创建”模式打开 myfile,权限为 0644(所有者读写,其他读)
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if (fd < 0) { // 打开失败返回 -1
perror("open"); // 打印错误信息(比 printf 更直观)
return 1;
}
close(fd); // 关闭文件,必须调用!
return 0;
}
关键参数:
pathname:文件路径(绝对 / 相对);
flags:打开标志,用 “按位或” 组合(如 O_WRONLY | O_CREAT);
mode:仅当 flags 包含 O_CREAT 时需要,指定文件的初始权限(如 0644)。
常用 flags:
O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写):三选一;
O_CREAT:文件不存在则创建;
O_TRUNC:文件存在则清空内容;
O_APPEND:追加写(每次写都到文件末尾)。
2. 写文件:write
int main() {
umask(0);
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if (fd < 0) { perror("open"); return 1; }
const char *msg = "hello bit!\n";
int len = strlen(msg);
int count = 5;
while (count--) {
// 向 fd 写入 len 字节(从 msg 地址开始)
write(fd, msg, len);
}
close(fd);
return 0;
}
write 返回值:成功则返回实际写入的字节数,失败返回 -1。
3. 读文件:read
int main() {
int fd = open("myfile", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
char buf[1024];
const char *msg = "hello bit!\n";
while (1) {
// 从 fd 读 strlen(msg) 字节到 buf
ssize_t s = read(fd, buf, strlen(msg));
if (s > 0) {
printf("%s", buf);
} else {
break; // s=0 表示文件末尾,s=-1 表示错误
}
}
close(fd);
return 0;
}
3.2 库函数 vs 系统调用:谁在封装谁?
很多人会混淆 “库函数” 和 “系统调用”,这里用一张图明确它们的关系:
用户层 内核层
+----------+ +----------+
| fopen | | open |
| fread | 封装 | read |
| fwrite |--------->| write |
| fclose | | close |
+----------+ +----------+
C 标准库(libc) 系统调用接口
系统调用:内核提供的底层接口,直接与硬件 / 文件系统交互,权限高但使用繁琐;
库函数:用户层的封装(如 C 标准库),在系统调用基础上增加了 “缓冲区”“错误处理” 等功能,更易用。
例如,fwrite 会先将数据写入 “用户层缓冲区”,当缓冲区满或调用 fflush 时,才会调用 write 系统调用将数据写入内核 —— 这也是后续 “缓冲区” 章节的核心内容。
四、文件描述符(fd):理解重定向的关键
文件描述符(File Descriptor,简称 fd)是系统调用中最核心的概念,它是一个非负整数,代表 “进程打开的文件”。理解 fd,就能理解 “重定向” 的本质。
4.1 为什么 fd 从 0 开始?
Linux 进程启动时,会默认打开 3 个文件描述符:
fd=0:标准输入(stdin),对应键盘;
fd=1:标准输出(stdout),对应显示器;
fd=2:标准错误(stderr),对应显示器。
这三个 fd 对应的文件,在内核中用 struct file 结构体描述(存储文件属性、当前读写位置等)。而进程通过 task_struct(进程控制块)中的 files_struct 结构体管理所有打开的文件,其中包含一个 file* fd_array[] 数组 ——fd 本质就是这个数组的下标!
例如,fd=1 表示 fd_array[1] 指向 “显示器” 对应的 struct file 结构体,write(1, msg, len) 就是向这个结构体对应的文件写入数据。
4.2 fd 的分配规则:最小未使用原则
当进程新打开一个文件时,内核会分配 “当前未使用的最小 fd” 作为新的文件描述符。我们用代码验证这一点:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main() {
// 关闭 fd=0(标准输入)
close(0);
// 新打开文件,此时最小未使用 fd 是 0
int fd = open("myfile", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
printf("fd: %d\n", fd); // 输出:fd: 0
close(fd);
return 0;
}
如果不关闭任何 fd,新打开的文件 fd 会是 3(因为 0、1、2 已被占用)。
4.3 重定向的本质:修改 fd_array 的指向
我们常使用 ./a.out > myfile 将程序输出重定向到文件,其本质就是修改 fd_array[1] 的指向—— 从 “显示器” 的 struct file 改为 “myfile” 的 struct file。
用代码实现简单的重定向:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main() {
close(1); // 关闭原来的 fd=1(显示器)
// 新打开 myfile,此时 fd=1 指向 myfile
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if (fd < 0) { perror("open"); return 1; }
printf("fd: %d\n", fd); // 本应输出到显示器,现在输出到 myfile
fflush(stdout); // 强制刷新缓冲区(否则数据可能留在缓冲区)
close(fd);
return 0;
}
运行后,myfile 中会出现 fd: 1—— 这就是重定向的核心逻辑:通过关闭原有 fd,让新文件占用该 fd,从而改变数据的流向。
4.4 用 dup2 系统调用简化重定向
手动关闭 fd 实现重定向不够灵活,Linux 提供了 dup2 系统调用,直接将 oldfd 的指向复制给 newfd:
#include <unistd.h>
// 功能:让 newfd 指向 oldfd 对应的文件(若 newfd 已打开则先关闭)
int dup2(int oldfd, int newfd);
用 dup2 实现重定向示例:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
// 打开 log 文件,fd 为 3
int fd = open("./log", O_CREAT | O_RDWR, 0644);
if (fd < 0) { perror("open"); return 1; }
// 让 fd=1(stdout)指向 fd=3(log)对应的文件
dup2(fd, 1);
// 后续 printf 会输出到 log 文件
printf("hello redirect\n");
fflush(stdout);
close(fd);
return 0;
}
五、自定义 Shell 新增重定向功能
理解了重定向的本质后,我们可以给自定义 Shell 增加 >(输出重定向)、>>(追加重定向)、<(输入重定向)功能。核心思路是:解析用户命令中的重定向符号,在子进程中通过 dup2 实现重定向,再执行命令。
5.1 核心步骤
- 解析命令:从用户输入的命令(如
ls -l > myfile)中,分离出 “命令部分”(ls -l)和 “重定向部分”(> myfile); - 创建子进程:Shell 本身不执行命令,而是创建子进程执行;
- 子进程中重定向:根据解析出的重定向类型,用
dup2调整 fd 指向; - 执行命令:用
execvpe执行命令(程序替换,不影响重定向结果)。
5.2 关键代码实现
1. 解析重定向符号
#include <cstring>
#include <ctype.h>
// 全局变量:重定向类型和目标文件名
#define NoneRedir 0 // 无重定向
#define InputRedir 1 // < 输入重定向
#define OutputRedir 2// > 输出重定向
#define AppRedir 3 // >> 追加重定向
int redir = NoneRedir;
char *filename = nullptr;
// 去除字符串中的空格
#define TrimSpace(pos) do { \
while (isspace(*pos)) pos++; \
} while (0)
// 解析命令中的重定向符号(从后向前解析,避免被空格干扰)
void ParseRedir(char command_buffer[], int len) {
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 (end > 0 && command_buffer[end - 1] == '>') {
redir = AppRedir;
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;
}
}
end--;
}
}
2. 子进程中执行重定向
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
// 子进程中执行重定向
void DoRedir() {
if (redir == InputRedir) { // < 输入重定向:从文件读,替换 stdin(fd=0)
int fd = open(filename, O_RDONLY);
if (fd < 0) exit(2);
dup2(fd, 0); // fd=0 指向文件
close(fd);
} else if (redir == OutputRedir) { // > 输出重定向:写文件,替换 stdout(fd=1)
int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if (fd < 0) exit(4);
dup2(fd, 1);
close(fd);
} else if (redir == AppRedir) { // >> 追加重定向:追加写文件
int fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
if (fd < 0) exit(6);
dup2(fd, 1);
close(fd);
}
}
// 执行命令(创建子进程)
bool ExecuteCommand(char *gargv[], char *genv[]) {
pid_t id = fork();
if (id < 0) return false;
if (id == 0) { // 子进程
DoRedir(); // 先重定向,再执行命令
execvpe(gargv[0], gargv, genv); // 程序替换,加载命令执行
exit(7); // 若 exec 失败,退出子进程
}
// 父进程等待子进程
int status = 0;
waitpid(id, &status, 0);
return true;
}
3. 主流程整合
int main() {
char command_buffer[1024]; // 存储用户输入的命令
char *gargv[64]; // 存储命令参数(如 ["ls", "-l", NULL])
char *genv[64]; // 存储环境变量
InitEnv(genv); // 初始化环境变量(从父进程继承)
while (true) {
PrintCommandLine(); // 打印 Shell 提示符(如 [user@host dir]$)
if (!GetCommandLine(command_buffer, sizeof(command_buffer))) continue; // 获取用户命令
// 解析命令:先解析重定向,再解析命令参数
ResetCommand(gargv, &redir, &filename); // 重置状态
ParseRedir(command_buffer, strlen(command_buffer));
ParseCommand(command_buffer, gargv); // 解析命令参数(如 "ls -l" -> ["ls", "-l", NULL])
// 执行命令(内建命令如 cd 由 Shell 自己执行,其他命令由子进程执行)
if (CheckBuiltCommand(gargv)) continue;
ExecuteCommand(gargv, genv);
}
return 0;
}
至此,我们的自定义 Shell 就支持重定向功能了。例如,输入 ls -l > myfile,子进程会先将 fd=1 指向 myfile,再执行 ls -l,结果会写入 myfile 而非显示器。
六、缓冲区:为什么 printf 有时不立即输出?
在实际开发中,你可能遇到过这样的问题:printf("hello") 没有立即输出到显示器,直到程序结束才显示 —— 这背后是 “缓冲区” 在起作用。
6.1 缓冲区的本质:减少系统调用,提高效率
缓冲区是内存中的一块临时存储区域,用于缓存输入 / 输出数据。其核心目的是减少系统调用的次数:
若没有缓冲区,每次 printf 都会调用 write 系统调用(涉及用户态→内核态切换,耗时);
有缓冲区后,printf 先将数据写入 “用户层缓冲区”,当缓冲区满、遇到换行符或调用 fflush 时,才会调用 write 将数据写入内核。
6.2 三种缓冲类型
C 标准库的 FILE 结构体支持三种缓冲类型,默认规则如下:
- 全缓冲:缓冲区满时才刷新(如磁盘文件);
- 行缓冲:遇到换行符
\n或缓冲区满时刷新(如显示器stdout); - 无缓冲:不缓存,数据立即写入内核(如
stderr,确保错误信息及时显示)。
示例:验证缓冲类型
#include <stdio.h>
#include <unistd.h>
int main() {
// 情况 1:stdout 是行缓冲,遇到 \n 刷新
printf("hello line buffer\n"); // 有 \n,立即输出
// 情况 2:stdout 重定向到文件后,变为全缓冲
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
printf("hello full buffer"); // 无 \n,缓冲区未满,不输出
// fflush(stdout); // 若打开此句,会强制刷新,数据写入 log.txt
sleep(3); // 等待 3 秒,观察 log.txt 是否有内容
close(fd);
return 0;
}
运行后,log.txt 中没有内容 —— 因为 printf 写入全缓冲后未刷新。若调用 fflush(stdout),数据会立即写入文件。
6.3 FILE 结构体:封装 fd 和缓冲区
C 库的 FILE 结构体不仅封装了文件描述符(_fileno),还包含了缓冲区相关的字段。查看 /usr/include/libio.h 中的定义:
struct _IO_FILE {
int _flags; // 缓冲类型等标志
char* _IO_read_ptr; // 读缓冲区当前指针
char* _IO_write_ptr; // 写缓冲区当前指针
char* _IO_write_end; // 写缓冲区末尾
char _shortbuf[1]; // 小缓冲区
int _fileno; // 封装的文件描述符!
// ... 其他字段
};
_IO_write_ptr:指向当前写入缓冲区的位置;
_IO_write_end:指向缓冲区末尾,当 _IO_write_ptr == _IO_write_end 时,缓冲区满,触发刷新。
这也解释了为什么 fwrite 比 write 多一层缓冲 ——fwrite 操作的是 FILE 结构体中的用户层缓冲区,而 write 直接操作内核。
七、总结:基础 IO 知识体系
通过本文的学习,我们从 “文件概念” 到 “系统调用”,再到 “重定向” 和 “缓冲区”,构建了完整的 Linux 基础 IO 知识体系。核心要点总结如下:
- 文件本质:Linux 下一切皆文件,文件 = 属性 + 内容,操作主体是进程;
- 接口分层:库函数(如
fopen)封装系统调用(如open),系统调用是内核接口; - 文件描述符:fd 是进程
fd_array数组的下标,默认 0(stdin)、1(stdout)、2(stderr); - 重定向:修改 fd 对应的
struct file指向,如dup2(fd, 1)实现输出重定向; - 缓冲区:用户层缓冲区由 C 库提供,分全缓冲 / 行缓冲 / 无缓冲,减少系统调用次数。
2937

被折叠的 条评论
为什么被折叠?



