【Linux】IO(上)全篇:文件本质、C 接口、系统调用与文件描述符


文件本质、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 文件操作的归类认知:文件 = 属性 + 内容

两个容易忽略的事实:

  1. 0KB 的空文件也占用磁盘空间------它占了存放"文件属性"的空间;
  2. 文件 = 文件属性(元数据)+ 文件内容。

由此推出:

所有的文件操作,本质上是**"文件内容操作"和"文件属性操作"**两类。

操作类型 例子
文件内容操作 读内容、写内容、追加
文件属性操作 改权限 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

所以:

cpp 复制代码
printf("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 参数逐个解释:

cpp 复制代码
ssize_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
r O_RDONLY
r+ O_RDWR
w O_WRONLY | O_CREAT | O_TRUNC
w+ O_RDWR | O_CREAT | O_TRUNC
a O_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 表 内核全局 每文件一个:文件属性 + 数据块位置(磁盘上的元数据,加载进内存)

打开一个文件,内核做了什么:

  1. 如果 inode 不在内存,加载 inode;

  2. 创建一个 file 对象(文件表项),记录 offset、引用计数、指向 inode;

  3. 在进程 fd 表找一个最小未用下标,把 file 指针放进去;

  4. 返回下标------这就是 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 及时释放。

内核源码验证(想深挖的路径):

cpp 复制代码
struct 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 数组中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。

完整推演:


五、本篇小结

cpp 复制代码
1. 文件 = 属性(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、>/>>/<)的地基。


相关推荐
承渊政道1 小时前
Linux网络学习【深入理解应用层HTTP协议、Cookie与Session机制及HTTPS协议原理】
linux·网络·学习·http·cookie·session
北风toto1 小时前
Linux 服务器通过 tgz 离线部署 Docker
linux·服务器·docker
古法老登瞎折腾1 小时前
大数据智能运维实战(三):实现第一个运维 MCP 工具——查询节点与组件状态
java·大数据·运维·人工智能·spring ai·mcp
fengkai45451 小时前
2、Kubernetes 介绍+安装+卸载
运维·容器·kubernetes
重生之小比特1 小时前
【Linux系统】13 基础IO(上)
linux·运维·服务器
CCCCCCCCharlie1 小时前
从0到1:一个字节的磁盘之旅
linux·开发语言·c++
羑悻的小杀马特1 小时前
超越Bridge:全面掌握Docker网络隔离与通信的底层逻辑!
运维·网络·docker·容器
运维小斌1 小时前
麒麟 V10 ARM64 离线部署 OceanBase:8 步实施手册
linux·数据库·oceanbase·麒麟
着迷不白1 小时前
生产环境标准的Linux系统安装到服务器
linux·云计算