文件本质、C 接口、系统调用与文件描述符
一、理解"文件"
1-1 狭义理解:文件在磁盘里
- 磁盘是永久性存储介质,所以文件在磁盘上的存储是永久性的;
- 磁盘是外设------它既是输出设备(写文件),也是输入设备(读文件);
- 磁盘上的文件本质:对文件的所有操作,都是对外设的输入和输出,简称 IO。
一句话:文件操作 = 对外设做 IO = 往磁盘读/写数据。
1-2 广义理解:Linux 一切皆文件
Linux 下一切皆文件------键盘、显示器、网卡、磁盘......都是"文件"的抽象。
不管底下的设备多不同,向上统一提供"文件"的接口(open/read/write/close),上层不用关心它到底是磁盘还是网卡:
- 键盘是"只读文件"(读它拿输入);
- 显示器是"只写文件"(写它输出);
- 网卡是"可读可写文件"(收发网络数据)。
ls -l第一列的字符就是文件类型:
字符 类型 例子 -普通文件 myfile、hello.c d目录 /home、io/ l符号链接 cwd -> /home/hyb/io c字符设备 /dev/tty、键盘 b块设备 /dev/sda(硬盘) p管道 命名管道 s套接字 网络通信 类比:
插座统一成"两孔/三孔"标准,不管你插什么电器,接口一致。
Linux 把一切设备统一成"文件"这个标准接口。
1-3 文件操作的归类认知:文件 = 属性 + 内容
两个容易忽略的事实:
- 0KB 的空文件也占用磁盘空间------它占了存放"文件属性"的空间;
- 文件 = 文件属性(元数据)+ 文件内容。
由此推出:
所有的文件操作,本质上是**"文件内容操作"和"文件属性操作"**两类。
操作类型 例子 文件内容操作 读内容、写内容、追加 文件属性操作 改权限 chmod、改时间、改所有者 属性的家是 inode(索引节点)------Linux 磁盘上每个文件对应一个 inode,专门保存元信息:
inode 里的属性 例子 文件类型 普通文件、目录、符号链接... 权限 rwxr-xr-x 属主/属组 wxx / wxx 大小 0 字节 时间戳 创建/修改/访问时间 数据块指针 指向文件内容在磁盘的哪些块
cpp一个文件的磁盘布局: ┌───────────────┐ ┌──────────────────────────────┐ │ inode │ │ 数据块(内容) │ │ 属性元信息 │ ─────► │ "hello bit!\nhello bit!\n..." │ │ 数据块指针 │ │ │ └───────────────┘ └──────────────────────────────┘
- 属性操作(chmod/chown/touch)→ 改 inode;
- 内容操作(read/write)→ 读写数据块;
- 目录 = 一张"文件名 → inode 编号"的映射表,目录本身也是文件(有自己的 inode)。
类比:
图书馆里一本书 = 索书卡(属性:书名/作者/位置)+ 书页(内容)。
inode 就是索书卡,数据块就是书页。
1-4 系统角度
- 对文件的操作,本质是进程对文件的操作------文件是给进程用的,内核里一切文件行为都挂在某个进程名下;
- 磁盘的管理者是操作系统------应用不能直接操作磁盘,必须通过操作系统提供的接口(系统调用)间接操作。
这就解释了为什么 C 的 fopen 最终会调到内核的 open------中间隔着一层"操作系统管理磁盘"的墙,应用只能走系统调用。
二、回顾 C 文件接口
先复习学过的 C 文件操作,后面才能对比出"库函数 vs 系统调用"。
2-1 hello.c 打开文件:文件创建在哪?
cpp#include <stdio.h> int main() { FILE *fp = fopen("myfile", "w"); if(!fp){ printf("fopen error!\n"); } while(1); fclose(fp); return 0; }问题:myfile 文件创建在哪个路径下?
答:
程序的当前路径下。
那系统怎么知道进程的当前路径?
用
ls /proc/[进程id] -l查看运行中进程的信息:
cpp$ ps ajx | grep myProc 506729 533463 533463 506729 pts/249 533463 R+ 1002 7:45 ./myProc $ ls /proc/533463 -l ... lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 cwd -> /home/hyb/io lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 exe -> /home/hyb/io/myProc dr-x------ 2 hyb hyb 0 Aug 26 16:54 fd ...两个关键符号链接:
- cwd:指向当前进程运行目录的符号链接;
- exe:指向启动当前进程的可执行文件(完整路径)的符号链接。
结论:
打开文件,本质是进程打开。
进程知道自己在哪里(cwd),也知道自己是谁(exe)。
即便文件不带路径,进程也知道该把文件创建到哪------OS 由此确定文件位置。
2-2 hello.c 写文件
cpp#include <stdio.h> #include <string.h> int main() { FILE *fp = fopen("myfile", "w"); if(!fp){ printf("fopen error!\n"); } const char *msg = "hello bit!\n"; int count = 5; while(count--){ fwrite(msg, strlen(msg), 1, fp); // 写 1 个"元素",元素大小 strlen(msg) } fclose(fp); return 0; }2-3 hello.c 读文件(注意坑)
cpp#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){ // 注意返回值和参数,此处有坑,仔细查看 man 手册 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 的坑(返回值语义):
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
- 返回值是成功读取的元素个数(nmemb 数量),不是字节数;
- 实际读到的字节数 = 返回值 × size;
写法 size nmemb 返回值含义 坑 fread(buf, 1, 1024, fp)1 1024 恰好 = 字节数 无坑,推荐 fread(buf, 4, 256, fp)4 256 读到 N 个"4字节元素",字节数=N×4 直接当字节数用就错了 fread(buf, 1024, 1, fp)1024 1 要么 1(读满 1024 字节),要么 0 读不满 1024 就返回 0,字节数信息丢失 结论:
初学统一用
fread(buf, 1, sizeof(buf), fp),返回值即字节数,最不容易错。feof 的坑:
feof(fp)返回真说明之前某次读取已经撞到了文件尾------它是"事后确认",不是"提前预测":
cpp// ❌ 错误示范:把 feof 当"还没到末尾"用 while(!feof(fp)){ // 可能多读一次,多打印脏数据 fread(buf,1,sizeof(buf),fp); printf("%s", buf); }正确姿势:先判断返回值(s > 0 就处理),再用 feof 确认退出原因是 EOF。
2-4 稍作修改,实现简单 cat 命令
cpp#include <stdio.h> #include <string.h> int main(int argc, char* argv[]) { if (argc != 2) { printf("argv error!\n"); return 1; } FILE *fp = fopen(argv[1], "r"); if(!fp){ printf("fopen error!\n"); return 2; } char buf[1024]; while(1){ int s = fread(buf, 1, sizeof(buf), fp); // 一次读满缓冲区 if(s > 0){ buf[s] = 0; printf("%s", buf); } if(feof(fp)){ break; } } fclose(fp); return 0; }用法:
./mycat hello.c------和系统cat一样把文件内容打到屏幕。这是"用库函数实现命令"的启蒙。
2-5 输出信息到显示器,你有多少种方法?
cpp#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 fprintf(stdout, "hello fprintf\n"); // ③ fprintf 指定 stdout return 0; }三种方法殊途同归------都是往"显示器文件"写。
区别只是接口形态不同。
2-6 stdin & stdout & stderr
- C 默认会打开三个输入输出流:
stdin、stdout、stderr;- 仔细观察:这三个流的类型都是
FILE*------和 fopen 的返回值类型完全一致。
cpp#include <stdio.h> extern FILE *stdin; extern FILE *stdout; extern FILE *stderr;
流 对应设备 作用 stdin 键盘 标准输入 stdout 显示器 标准输出 stderr 显示器 标准错误 2-7 打开文件的方式(fopen mode 全表)
模式 含义 文件不存在 文件存在 r只读 失败 从头读 r+读写 失败 从头 w只写 创建 截断为 0 w+读写 创建 截断为 0 a追加写 创建 末尾追加 a+读+追加 创建 末尾追加(读从开头) 可加
b(二进制):
"rb"、"wb"------Linux 上无实际区别,但为跨平台(Windows 区分)建议写上。还有
fseek、ftell、rewind三个定位函数,C 部分学过,自行复习。
函数原型 功能 返回值 FILE *****fopen(const char *filename, const char *mode);打开文件 成功返回 FILE*,失败 NULL int****fclose(FILE *stream);关闭文件 成功 0,失败 EOF int fgetc(FILE *stream);从文件读取 1 个字符 返回字符 ASCII,到末尾 / 出错返回 EOF int fputc(int c, FILE *stream);向文件写入 1 个字符 成功返回字符,失败 EOF char *fgets(char *s, int size, FILE *stream);读取一行字符串 成功返回 s,失败 / 末尾 NULL int fputs(const char *s, FILE *stream);写入字符串 成功非负,失败 EOF size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);二进制块读 读到的数据块个数 size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);二进制块写 写入的数据块个数 int fprintf(FILE *stream, const char *format, ...);格式化输出到文件 成功字符数,失败负数 int fscanf(FILE *stream, const char *format, ...);从文件格式化读取 成功读到数据项个数,失败 EOF long ftell(FILE *stream);获取当前文件指针位置 当前偏移量,失败 - 1L int fseek(FILE *stream, long offset, int whence);移动文件指针 成功 0,失败非 0 void rewind(FILE *stream);文件指针回到开头 无返回值 int feof(FILE *stream);判断是否到达文件末尾 到末尾返回非 0,否则 0 int ferror(FILE *stream);判断文件流是否出错 出错返回非 0,否则 0 void clearerr(FILE *stream);清除流错误标志、EOF 标志 无返回值 FILE *tmpfile(void);创建临时文件 成功文件指针,失败 NULL 2-8 双层缓冲:为什么 printf 不带 \n 不显示
这是本节重点"理解文件和内核文件缓冲区"的核心。
数据从应用写到磁盘,中间要过两层缓冲:
cpp应用 │ printf("hello"); ← 写入用户态 stdio 缓冲区(行缓冲) ▼ 用户态缓冲(stdio buffer) ← 攒够一行/满/fflush/exit 才往下走 │ ▼ write 系统调用 ▼ 内核缓冲(页缓存 page cache)← 内核把数据攒在内存,适时才写磁盘 │ ▼ 磁盘第一层:
用户态 stdio 缓冲(C 库管的)
缓冲类型 触发冲刷条件 谁在用 全缓冲 缓冲区满 / fflush / exit 普通文件 行缓冲 遇到 \n / fflush / exit 终端(stdout) 无缓冲 立即写 stderr 所以:
cppprintf("hello"); // 没 \n,卡在用户态缓冲 while(1); // 程序不退出 → 屏幕什么都不显示 printf("hello\n"); // 有 \n,行缓冲冲刷 → 立即显示这就是为什么进程控制篇里
printf("hello"); _exit(0);什么都不打印------
_exit直接进内核,不冲刷用户态缓冲;
exit(0)会先冲刷再退出。第二层:
内核缓冲(页缓存)------write 系统调用把数据交给内核,内核先放进页缓存,并不立刻写磁盘(写磁盘很慢),等合适时机(攒够、sync、关闭文件)再落盘。
类比:
食堂打饭。你(应用)把菜倒进餐盘(用户态缓冲),端到窗口(write),师傅(内核)把菜装进保温桶(页缓存),等一桌人齐了再一起端上桌(写磁盘)。
经典面试题:
为什么
fopen写文件后立刻fread能读到?****------因为读写都走同一个 FILE 的缓冲;为什么程序崩溃可能丢数据?****------因为用户态缓冲和内核页缓存都没落盘。
三、系统文件 I/O
打开文件的方式不止 fopen 这种语言层方案,系统才是打开文件最底层的方案。
学习系统 IO 前,先学一个贯穿始终的技巧:如何给函数传"标志位"。
3-1 一种传递标志位的方法(位图思想)
cpp#include <stdio.h> #define ONE 0001 // 0000 0001 #define TWO 0002 // 0000 0010 #define THREE 0004 // 0000 0100 void func(int flags) { if (flags & ONE) printf("flags has ONE! "); if (flags & TWO) printf("flags has TWO! "); if (flags & THREE) printf("flags has THREE! "); printf("\n"); } int main() { func(ONE); func(THREE); func(ONE | TWO); func(ONE | THREE | TWO); return 0; }思想拆解:
- 每个选项占独立的二进制位:ONE=0001、TWO=0010、THREE=0100,互不重叠;
- 传参用
|组合:ONE|TWO= 0011,一个 int 同时表达"两个选项都开";- 判断用
&检测:flags & ONE非 0 就说明 ONE 这位被置上。这就是 open 的 flags 参数的底层原理:一个 int,每位代表一个开关。
3-2 hello.c 写文件(系统接口版)
cpp#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main() { umask(0); int fd = open("myfile", O_WRONLY|O_CREAT, 0644); if(fd < 0){ perror("open"); return 1; } int count = 5; const char *msg = "hello bit!\n"; int len = strlen(msg); while(count--){ write(fd, msg, len); } close(fd); return 0; }write 参数逐个解释:
cppssize_t write(int fd, const void *buf, size_t count);
参数 含义 fd 文件描述符(后面详细讲) buf 要写的数据缓冲区首地址 len 本次期望写入多少个字节 返回值 实际写了多少字节(可能小于期望值) 为什么开头
umask(0)?进程创建文件时,实际权限 = 传入权限 & ~umask。
umask 默认 022 会把组/其他的写权限屏蔽掉;
umask(0)让传入的 0644 原样生效。umask 计算推导:
cpp实际权限 = 传入 mode & ~umask umask(022); // 常见默认值 open("myfile", O_CREAT, 0644); // 实际 = 0644 & ~022 0644 = 110 100 100 022 = 000 010 010 → ~022 = 111 101 101 相与 = 110 100 100 = 0644 (不变) 若 mode = 0666: 0666 = 110 110 110 & ~022 = 111 101 101 = 110 100 100 = 0644 (组和其他被去掉写权限)结论:
umask 是"默认帮你去掉哪些权限位"。
0666 建出来的普通文件通常变成 0644,就是因为 umask 022。
3-3 hello.c 读文件(系统接口版)
cpp#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main() { int fd = open("myfile", O_RDONLY); if(fd < 0){ perror("open"); return 1; } const char *msg = "hello bit!\n"; char buf[1024]; while(1){ ssize_t s = read(fd, buf, strlen(msg)); // 类比 write if(s > 0){ printf("%s", buf); }else{ break; } } close(fd); return 0; }read 与 write 对称:
ssize_t read(int fd, void *buf, size_t count);
返回值 含义 处理 > 0 实际读到 n 字节 正常,继续/累计 == 0 读到 EOF 结束循环 == -1 出错(errno 有值) perror 后处理 0 < s < count 部分读(信号打断等) 要循环重试凑满 和 fread 的"坑"对比:
系统 read 的返回值就是字节数,没有 size/nmemb 的换算问题------系统接口更底层、更直白。
3-4 open 接口详解(man open)
cpp#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int open(const char *pathname, int flags); // 两个参数 int open(const char *pathname, int flags, mode_t mode); // 三个参数(要创建文件时)
参数 含义 pathname 要打开或创建的目标文件 flags 打开方式,用多个常量"或"运算组合 mode 创建新文件时的访问权限(用三个参数版本时) flags 常量全表:
常量 含义 场景 O_RDONLY只读打开 三选一 O_WRONLY只写打开 三选一 O_RDWR读写打开 三选一 O_CREAT若文件不存在则创建,需用 mode 指明权限 创建文件 O_TRUNC打开时截断为 0 相当于 C 的 "w" O_APPEND追加写(每次写都到末尾) 日志 O_EXCL与 O_CREAT 连用,文件已存在则 open 失败 防覆盖/锁文件 关键规则:
O_RDONLY / O_WRONLY / O_RDWR 这三个必须指定一个,且只能指定一个(它们占用同一组位,互相冲突)。
C 模式与 flags 的对应关系(理解封装的关键):
fopen 模式 底层 open flags rO_RDONLY r+O_RDWR wO_WRONLY | O_CREAT | O_TRUNC w+O_RDWR | O_CREAT | O_TRUNC aO_WRONLY | O_CREAT | O_APPEND a+O_RDWR | O_CREAT | O_APPEND 对照着看,fopen 的每个模式底层都是一组 open flags 的组合------库函数就是对系统调用的封装,眼见为实。
返回值:
成功:新打开的文件描述符 失败:-1具体用两参数还是三参数?
cpp目标文件不存在、需要 open 创建 → 用三参数版,mode 表示创建文件的默认权限 目标文件已存在、只是打开 → 用两参数版3-5 系统调用 vs 库函数(含执行链路)
在认识"文件描述符"之前,先分清系统调用和库函数:
概念 例子 归属 库函数 fopen、fclose、fread、fwrite、printf C 标准库(libc) 系统调用接口 open、close、read、write、lseek 操作系统提供的接口 f# 系列函数,都是对系统调用的封装,方便二次开发。
系统调用的执行流程:用户态 → 内核态
cpp用户态 内核态 ┌──────────────┐ ┌──────────────────────┐ │ 应用代码 │ │ │ │ fopen("..") │ │ sys_open() │ │ └─调用open─┼──陷阱──►│ 查 fd_table │ │ 你(软中断/ │ │ 分配 fd │ │ syscall│ │ 建 file 对象 │ │ 指令) │◄──返回──│ 返回 fd │ └──────────────┘ └──────────────────────┘
- 每次系统调用都要用户态切到内核态再切回来(保存上下文、切换特权级);
- 这个切换有固定开销;
- 库函数在用户态做缓冲,减少系统调用次数------这就是"库函数比直接系统调用快"的原因。
系统调用失败怎么排查:
int fd = open("myfile", O_RDONLY); if(fd < 0){ perror("open"); // 打印: open: No such file or directory printf("%d\n", errno); // errno = 2(错误码存全局变量) }
perror(前缀):打印前缀 + ": " + 错误描述;errno:整数错误码(2=文件不存在,13=权限不够);strerror(errno):把错误码转成字符串。
四、文件描述符 fd
4-1 标准输入输出错误:0 & 1 & 2
Linux 进程默认情况下会有 3 个缺省打开的文件描述符:
文件描述符 名称 对应物理设备 0 标准输入 stdin 键盘 1 标准输出 stdout 显示器 2 标准错误 stderr 显示器 所以输入输出还可以用系统接口直接写:
cpp#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> int main() { char buf[1024]; ssize_t s = read(0, buf, sizeof(buf)); // 从键盘(0号)读 if(s > 0){ buf[s] = 0; write(1, buf, strlen(buf)); // 写到显示器(1号) write(2, buf, strlen(buf)); // 写到标准错误(2号) } return 0; }输入输出不只有 scanf/printf------一切皆文件,直接对 0、1、2 号文件描述符做 read/write 也一样。
4-2 fd 的本质:内核"三张表"
通过对 open 的学习,我们知道文件描述符就是从 0 开始的小整数。
但为什么 open 返回的偏偏是个小整数?
因为完整的访问链路是三层结构:
cpp进程A 内核全局 ┌─────────────────┐ │ fd 表(每进程一张) │ 文件表(全局,每打开一次就有一项) inode 表(全局,每文件一项) │ ┌─────────────┐ │ ┌──────────────────────┐ ┌───────────────────┐ │ │ 0 →───────┼─┼─┼──────► │ file 对象 │ │ inode │ │ │ 1 →───────┼─┼─┼──────► │ • 文件偏移量 offset │──────► │ • 文件属性(元信息) │ │ │ 2 →───────┼─┼─┼──────► │ • 引用计数 refcount │ │ • 数据块指针 │ │ │ 3 →───────┼─┼─┼──────► │ • 指向 inode 的指针 │ │ │ │ └─────────────┘ │ └──────────────────────┘ └───────────────────┘ └─────────────────┘
表 作用域 内容 fd 表 每进程一张 下标 → 指向文件表项 文件表 内核全局 每 open 一次创建一个 file 对象:记录当前读写位置 offset、引用计数、指向 inode inode 表 内核全局 每文件一个:文件属性 + 数据块位置(磁盘上的元数据,加载进内存) 打开一个文件,内核做了什么:
如果 inode 不在内存,加载 inode;
创建一个 file 对象(文件表项),记录 offset、引用计数、指向 inode;
在进程 fd 表找一个最小未用下标,把 file 指针放进去;
返回下标------这就是 fd。
fd=3 ──► 查进程 fd 表第 3 项 ──► 得到 file 对象指针 ──► 拿到 offset 和 inode ──► 找到磁盘数据
文件偏移量 offset 存哪?谁移动它?
- 存在文件表项的 file 对象里(不在 fd 表、不在 inode);
- 每次 read/write 后,offset 自动后移读写的字节数------所以连续两次 read 会接着上次的位置读,不会从头再来;
- 想跳着读?用 lseek 改 offset(下篇课程讲)。
类比:书签。file 对象里的 offset 就是书签,每次读几页,书签就往后移几页。
fork 之后 fd 表会发生什么(预告):
子进程会拷贝一份父进程的 fd 表,但指向同一批文件表项------父子共享 file 对象和 offset。
所以 fork 后父子交替写同一个文件,写的位置是连续的(共享书签)。
这也是 shell 里管道/重定向能跨进程工作的基础(下篇讲)。
fd 数量上限:
一个进程能打开的文件描述符数量有限(
ulimit -n,一般 1024)。超过上限 open 失败(EMFILE);
服务器程序大量连接会撞这堵墙,所以要 close 及时释放。
内核源码验证(想深挖的路径):
cppstruct task_struct : /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/sched.h struct files_struct : /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fdtable.h struct file : /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fs.h(内核版本用
uname -a查看;sched.h 里 task_struct 有 files 字段,fdtable.h 里 files_struct 有 fd_array,fs.h 里 file 结构体保存 inode 元信息。)4-3 文件描述符的分配规则
先看代码:
cpp#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { int fd = open("myfile", O_RDONLY); if(fd < 0){ perror("open"); return 1; } printf("fd: %d\n", fd); close(fd); return 0; }输出发现是
fd: 3------因为 0、1、2 已经被系统占用了,新文件从 3 开始。那如果先关掉 0 呢?
cpp#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { close(0); //close(2); int fd = open("myfile", O_RDONLY); if(fd < 0){ perror("open"); return 1; } printf("fd: %d\n", fd); close(fd); return 0; }发现结果是
fd: 0或fd: 2。文件描述符的分配规则:
在 files_struct 数组中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
完整推演:
五、本篇小结
cpp1. 文件 = 属性(inode) + 内容(数据块);Linux 一切皆文件;文件操作本质是进程对文件的操作 2. C 接口:fopen/fwrite/fread(FILE*,库函数),系统通过 cwd/exe 定位文件 3. 双层缓冲:用户态 stdio(行/全/无缓冲)+ 内核页缓存 → printf 不带 \n 不显示;exit 冲刷、_exit 不冲刷 4. 系统接口:open/read/write/close(fd,系统调用),flags 用位图传参 5. fopen 每个模式 = 一组 open flags(w=O_TRUNC、a=O_APPEND) 6. umask:实际权限 = mode & ~umask;系统调用失败返回 -1 + errno 7. 库函数是系统调用的封装:f# 系列底层都调 open/read/write 8. 内核三张表:fd 表(进程)→ 文件表(全局,含 offset)→ inode 表(全局) 9. 默认 0/1/2 被占用;分配规则 = 最小未使用下标 (下一篇:close(1) 后 open → 重定向的本质 → dup2 → minishell 加 >/>>/<)
面试题
一、理解"文件"
Q1. 狭义和广义的"文件"分别指什么?
A:
狭义------文件在磁盘里,磁盘是永久性存储介质,文件存储是永久性的,文件操作本质是对外设的 IO;
广义------Linux 一切皆文件,键盘、显示器、网卡、磁盘都抽象成文件,统一用 open/read/write/close 操作。
Q2. inode 是什么?"0KB 文件占空间"具体占在哪?
A:
inode 是文件的索引节点,存放文件属性元信息(类型、权限、属主、大小、时间戳、数据块指针)。
0KB 文件内容为 0,但 inode 本身要占磁盘空间,所以空文件也占空间。
Q3. 目录是文件吗?里面存什么?
A:
**是。**目录本身也是文件(有自己的 inode),内容是一张"文件名 → inode 编号"的映射表。 ls 就是读这张表。
Q4. "一切皆文件"到底指什么?
A:
普通文件、目录、符号链接、字符设备(键盘/显示器)、块设备(硬盘)、管道、套接字------七类对象统一提供 open/read/write/close 接口,上层无需区分底层设备。
这是 Linux 统一设备访问的核心设计。
Q5. 为什么说"对文件的操作本质是进程对文件的操作"?
A:
文件是给进程用的,内核中一切文件行为都挂在某个进程名下(如 fd 表属于进程);且磁盘的管理者是操作系统,应用不能直接操作磁盘,只能通过进程发起系统调用来访问文件。
二、C 文件接口
Q6. fopen("myfile","w") 创建的文件在哪?系统怎么知道的?
A:
在进程的当前路径下。
系统通过
/proc/[pid]下的符号链接知道进程位置:cwd 指向进程运行目录,exe 指向可执行文件完整路径。打开文件本质是进程打开,进程知道自己在哪里,OS 就知道文件放哪。
Q7. fread 的返回值有什么坑?
A:
fread(ptr, size, nmemb, stream)返回的是成功读取的元素个数(nmemb 数量),不是字节数;实际字节数 = 返回值 × size。
若 size≠1,把返回值当字节数就错了。
建议 size=1、nmemb=sizeof(buf),让返回值直接等于字节数。
Q8. feof 的正确用法?
A:
feof 是"事后确认":之前某次读撞到 EOF 才为真,不能预测下次。
正确姿势:循环里先判断 read/fread 返回值(>0 就处理),再用 feof 确认退出原因是 EOF。
别写 while(!feof(fp)),会多读一次脏数据。
Q9. stdin/stdout/stderr 是什么类型?
A:
都是
FILE*,和 fopen 的返回类型一致。C 默认打开这三个流:stdin(键盘)、stdout(显示器)、stderr(显示器)。
Q10. r、r+、w 的区别?
A:
r 只读,从文件头开始;
r+ 读写,从文件头开始(文件必须存在);
w 截断为 0 或创建,只写。
w+ 是读写+创建/截断;
a 追加写(不存在则创建);
a+ 读+追加写,初始读位置在开头但输出总是追加到末尾。
Q11. 为什么 printf("hello") 不输出?和 exit/_exit 有什么关系?
A:
stdout 连终端时是行缓冲,没 \n 就攒在用户态缓冲不冲刷。
exit() 会冲刷缓冲区,_exit() 直接进内核不冲刷------所以 printf("hello");
_exit(0); 什么都不打印。
Q12. 双层缓冲指哪两层?程序崩溃为什么可能丢数据?
A:
用户态 stdio 缓冲(C 库,行缓冲/全缓冲/无缓冲)和内核页缓存(page cache)。
数据要穿过两层才落盘。
程序崩溃丢数据 = 两层缓冲都没写盘。
三、系统文件 IO
Q13. 用位图方式传"标志位"的原理?
A:
每个选项占一个独立的二进制位(ONE=0001、TWO=0010、THREE=0100),互不重叠。
传参用
|组合(一个 int 表达多个选项),判断用&(非 0 即命中该位)。open 的 flags 就是这种设计。
Q14. open 的 flags 有哪些?为什么 O_RDONLY/O_WRONLY/O_RDWR 只能选一个?
A:
O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)三选一且必须指定一个,因为三者占用同一组位、互相冲突;
O_CREAT(不存在则创建,需 mode 指定权限)、O_TRUNC(截断为0)、O_APPEND(追加)、O_EXCL(与 O_CREAT 连用防覆盖)等可组合**。**
要创建文件用三参数版
open(path, flags, mode)。
Q15. fopen 的 "w" 和 "a" 底层对应哪些 flags?
A:
w = O_WRONLY|O_CREAT|O_TRUNC(截断为0);
a = O_WRONLY|O_CREAT|O_APPEND(追加)。
这就是"库函数是系统调用封装"的直接证据。
Q16. O_TRUNC 和 O_APPEND 的区别?
A:
O_TRUNC 打开瞬间把文件内容清空(截断为 0);
O_APPEND 不清空,每次写都定位到末尾追加。
一个是"清空重写",一个是"接着写"。
Q17. write/read 的参数和返回值?
A:
write(fd, buf, count)期望写 count 字节,返回实际写入字节数;
read(fd, buf, count)返回实际读到字节数,0=读到 EOF,-1=出错。返回值可能小于期望值(部分读写),必须循环重试。
Q18. 库函数和系统调用的区别?fopen 和 open 什么关系?
A:
库函数(fopen/fread/fwrite/printf)属于 C 标准库 libc;
系统调用(open/read/write/close)是操作系统提供的接口。
f# 系列函数都是对系统调用的封装:fopen 底层调 open,fread 底层调 read,方便二次开发(带缓冲、跨平台)。
Q19. 为什么系统调用比库函数"慢"?
A:
每次系统调用要用户态→内核态切换(保存恢复上下文、切换特权级),有固定开销。
库函数在用户态缓冲数据、减少系统调用次数,所以"看起来快"。
Q20. 系统调用失败怎么知道原因?
A:
返回 -1,原因在全局变量 errno(整数错误码),perror() 打印"前缀+错误描述",
strerror(errno) 把错误码转字符串。
Q21. umask 022 时 open(mode=0666) 实际权限?推导一下。
A:
实际 = 0666 & ~022 = 110 110 110 & 111 101 101 = 110 100 100 = 0644。
umask 的每一位为 1 就"屏蔽"对应权限位;
umask(0) 让传入 mode 原样生效。
四、文件描述符
Q22. 什么是文件描述符?它的本质是什么?
A:
open 返回的小整数(从 0 开始)。
本质是进程 files_struct 中
file* fd_array[]数组的下标------打开文件时内核创建 file 对象放进数组空位,把下标返回给用户;之后拿着 fd 就能通过 fd_arrayfd 找到对应文件。
Q23. 0、1、2 分别是什么?
A:
Linux 进程默认打开的三个文件描述符:0=标准输入(键盘)、1=标准输出(显示器)、2=标准错误(显示器)。
所以
read(0,...)读键盘、write(1,...)写显示器。
Q24. 文件描述符的分配规则是什么?
A:
在 fd_array 数组中,找到当前未被使用的最小下标作为新文件描述符。
默认 0/1/2 被占,所以第一次 open 得到 3;
close(0) 后 open 得到 0。
Q25. 内核"三张表"分别是什么?
A:
fd 表(每进程一张,fd→文件表项)、
文件表(内核全局,每 open 一次建一个 file 对象,含 offset 和引用计数)、
inode 表(内核全局,每文件一个,存属性+数据块指针)。
fd 是 fd 表下标,逐层找到文件。
Q26. 文件偏移量 offset 存在哪?谁移动它?
A:
存在文件表项的 file 对象里(不在 fd 表、不在 inode)。
每次 read/write 后内核自动把 offset 后移读写字节数,所以连续读会接着上次位置。lseek 可手动改。
Q27. fork 后父子进程的 fd 是什么关系?
A:
子进程拷贝一份父进程的 fd 表,但指向同一批文件表项------父子共享 file 对象和 offset。
所以 fork 后父子写同一文件时写的位置连续,这是管道/重定向跨进程工作的基础。
Q28. 为什么服务器进程要小心 fd 泄漏?
A:
进程 fd 数量有上限(ulimit -n,常为 1024)。
每 accept 一个连接占一个 fd,不 close 就会耗尽,新连接 open 失败(EMFILE)。
所以用完必须 close。
Q29. 打开一个文件,内核会创建哪些对象?
A:
① 如果 inode 不在内存则加载 inode;
② 创建一个 file 对象(文件表项,含 offset 和引用计数);
③ 在进程 fd 表找一个最小未用下标,把 file 指针放进去;
④ 返回下标即 fd。
Q30. 为什么说"重定向的本质是换 fd 指向"?
A:
fd 只是下标,指向谁由 fd 表决定。
close(1) 后让新打开的文件占下标 1,fd 表里 1 号就从"显示器 file 对象"变成"目标文件 file 对象"------往 1 号写就是写文件。这就是下篇重定向(dup2、
>/>>/<)的地基。
【Linux】IO(上)全篇:文件本质、C 接口、系统调用与文件描述符
Brilliantwxx2026-10-20 8:51
相关推荐
承渊政道1 小时前
Linux网络学习【深入理解应用层HTTP协议、Cookie与Session机制及HTTPS协议原理】北风toto1 小时前
Linux 服务器通过 tgz 离线部署 Docker古法老登瞎折腾1 小时前
大数据智能运维实战(三):实现第一个运维 MCP 工具——查询节点与组件状态fengkai45451 小时前
2、Kubernetes 介绍+安装+卸载重生之小比特1 小时前
【Linux系统】13 基础IO(上)CCCCCCCCharlie1 小时前
从0到1:一个字节的磁盘之旅羑悻的小杀马特1 小时前
超越Bridge:全面掌握Docker网络隔离与通信的底层逻辑!运维小斌1 小时前
麒麟 V10 ARM64 离线部署 OceanBase:8 步实施手册着迷不白1 小时前
生产环境标准的Linux系统安装到服务器






