目录

linux-基础IO之操作与文件描述符全解析从C语言到系统调用底层实现

linux - 基础IO之操作与文件描述符全解析:从C语言到系统调用底层实现


1.回顾c语言中所学的文件

https://i-blog.csdnimg.cn/direct/15e69ca7532945b3841694d929d1e95a.png

进行文件操作,前提是我们的程序在执行了,所谓的文件的打开和关闭,是cpu在执行代码,比如执行fopen(),才将文件打开的。

当一个代码执行的时候已经变成了一个 进程 ,所以在 建立文件时 ,他就会默认的接在当前 进程所处路径 后,拼上所创建的文件名,创建的这个文件。

(进程启动时所处的路径:当前进程的当前工作路径。)

2.提炼对文件的理解(linux基础io第一阶段的学习)

a.在操作系统内部,一个进程和一个被打开的文件,他们到后面会变成两种对象之间的指针关系。

  • 打开文件,本质是进程打开文件。
  • 当文件本就存在时,文件没有被打开的时候,存在磁盘上
  • 一个进程可以打开多个文件。
  • 系统当中可以存在多个进程,大多数情况下,OS内部,一定存在大量被打开的文件,每个进程可能都打开很多文件。
  • 文件未被打开时处于磁盘上,处于一个硬件上,因此只能被操作系统(操作系统是硬件的管理者)打开(用的是c语言接口),因此操作系统会对这些被打开的文件进行管理(先描述,再组织)。
  • 每一个被打开的文件,在os内部,一定会存在对应的描述文件属性的结构体,类似于PCB。

b.文件 = 属性 + 内容

当我在磁盘上新建一个文本文件,但并不打开,并不往其中填写任何数据,他在磁盘上所占据的大小是0KB,此时他是否会占据磁盘空间?会占据,文件的名字、文件建立的时间、文件的大小等等文件的属性就已经占据了磁盘空间了。0kb指的是内容为0。结构体放的就是文件的属性。

1.打开文件 2.并向文件 写入3.再关闭。

https://i-blog.csdnimg.cn/direct/166de9e755fa4737a22e8292899eed3a.png

1.打开文件  — w ,不存在就在当前路径下创建指定文件。 2.并向文件 写入3.再关闭。都是进程让cpu在执行自己的代码。通过这样的方式发开文件,访问文件。

c.在c语言中,以w的方式打开文件,默认打开文件的时候,就会先把目标文件给清空。a则是追加

只以写的方式打开文件:

https://i-blog.csdnimg.cn/direct/ced0cefcdd8644a7b3b537e78b2410ff.png

延续上面的操作本来log.txt中是有内容的,现在被清空了

https://i-blog.csdnimg.cn/direct/601cd5c1b8214ff88d3a27950d5a54ac.png

d.输出重定向  ‘  > ’—>先清空再写入(以w方式打开文件),追加重定向 ‘»‘具体可以看博客

3.理解文件

a,操作文件、本质:进程在操作文件。进程和文件的关系

b.文件 –> 磁盘(外设)—> 硬件  —-> 向文件中写入,本质是向硬件中写入。 —->  用户没有权利直接向硬件写入  —->  硬件的管理者是操作系统 ——>  用户无法绕过操作系统去处理硬件(嵌入式除外)—-> 用户必须通过OS来写入 —->  操作系统给用户提供系统调用 —-> c语言 / c++ …都是对系统调用接口的封装   —->

访问文件,就可以用系统调用

https://i-blog.csdnimg.cn/direct/b4664036f64c4ee6b9b08bf4542852c9.png

c++写入文件:

#include<iostream>
#include<string>
#include<fstream>

#define FILENAME "log.txt"

using namespace std;

int main()
{
  std::ofstream out(FILENAME, std::ios::binary);
  if(!out.is_open()) return 1;
  string message("hello c++\n");
  out.write(message.c_str(), message.size());

  out.close();
  return 0;
}

open()系统调用函数

       #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

类型: const char*

作用:要打开或创建的文件路径(绝对或相对路径)。

flags

类型: int

作用:指定文件的打开方式,多个标志可通过按位或( | )组合。

常用标志:

必选标志 (三选一):

O_RDONLY :

只读模式、

O_WRONLY :

只写模式、

O_RDWR :

读写模式。

可选标志 :(O代表open的意思)

O_CREAT :

文件不存在时创建新文件,需配合 mode 参数设置权限。

O_TRUNC :

若文件存在且为普通文件,将其长度截断为0。

O_APPEND :

追加写入(每次写操作前移动到文件末尾)。

O_EXCL :

O_CREAT 联用时,若文件已存在则返回错误(用于原子性创建文件)。

O_NONBLOCK :

非阻塞模式(对设备文件或管道有效)。

mode

类型: mode_t

作用:创建文件时的权限(仅当使用 O_CREAT 时需指定)。

常见值(八进制表示):

0644
用户可读写,组和其他用户只读。
0755
用户可读写执行,组和其他用户可读执行。

注意:实际权限为 mode & ~umaskumask 用于过滤权限位)。

返回值

  • 成功 :返回文件描述符(非负整数),用于后续操作(如 read , write )。
  • 失败 :返回 -1 ,并设置 errno 指示错误类型(如 ENOENT 文件不存在、 EACCES 权限不足)

打开现有文件(只读)

当已经存在文件时,可以不传权限的参数

#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open failed");
        return 1;
    }
    close(fd);
    return 0;
}

独占创建文件(避免竞态条件)

int fd = open("unique.txt", O_WRONLY | O_CREAT | O_EXCL, 0644);
if (fd == -1) {
    perror("file already exists");
    return 1;
}

创建新文件(读写权限,若存在则清空)

创建新文件时,一定要传权限码,例如:0666:用户、组、其他,默认都是可读可写的权限

-rwxr-xr--  1 user  group  4096 Jun 1 10:00 file.txt
▲ ▲▲▲ ▲▲▲ ▲▲▲ 
│ │││ │││ │││ 
│ └──────┬─────── 权限(用户u、组g、其他o)
└───────── 文件类型(`-`普通文件,`d`目录)

八进制:

数字表示(八进制):

  • r=4, w=2, x=1,三者相加:

    • rwxr-xr--7(4+2+1) 5(4+0+1) 4(4+0+0) → 权限数字 754
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd == -1) {
    perror("open");
    return 1;
}

没有传 权限码 就会出现权限处乱码:

https://i-blog.csdnimg.cn/direct/807493632a1d471fb88f65883d804c6f.png

传了0666:

https://i-blog.csdnimg.cn/direct/58a2776aca4548138dc583e59dad9317.png

这是因为:最终权限 = 默认最大权限

umask 值 (实际是位运算 默认权限 & ~umask

https://i-blog.csdnimg.cn/direct/eb6de28262314bfd9467d284752d2bea.png

如何一次性创建好需求权限的文件:

umask()系统调用

 #include <sys/types.h>
       #include <sys/stat.h>
       mode_t umask(mode_t mask);

修改原代码:将系统掩码设置为0,没设置的时候就用系统默认的

  umask(0);
  int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

输出:

https://i-blog.csdnimg.cn/direct/4f689398a390464c9c7246a5a4da8fb9.png

补充:文件权限计算

  • 默认最大权限: 666 (二进制 110 110 110

  • umask 值: 002 (二进制 000 000 010

  • 实际权限: 666 - 002 = 664

    (即 rw-rw-r-- ,用户和组可读写,其他只读)

将这个整数参数看作是一张位图

https://i-blog.csdnimg.cn/direct/d3bc6254a04d4627b200671d776f200b.png

设计传递位图标记位的函数

通过设计一个传递位图标记位的函数来理解os 设计很多系统调用接口的方法:

#include <stdio.h> 
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define ONE    1     // 1 0000 0001
#define TWO   (1<<1) // 2 0000 0010
#define THREE (1<<2) // 4 0000 0100
#define FOUR  (1<<3) // 8 0000 1000

void print(int flag)
{
    if(flag & ONE)
        printf("one\n");
    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(TWO);
    printf("\n");

    print(ONE | TWO);
    printf("\n");

    print(ONE | TWO | THREE);
    printf("\n");

    print(ONE | FOUR);
    printf("\n");

    print(ONE | TWO | THREE | FOUR);
    printf("\n");
    return 0;
}

通过标志位来让我们实现对应的功能,向指定的函数传递多种标记位的方法,标记位传参

https://i-blog.csdnimg.cn/direct/65f5f877abf04c91a90e336411dd0064.png https://i-blog.csdnimg.cn/direct/bd9a83601c5149218d17be772b5e7153.png

close()系统调用函数

close() 的作用

释放资源

  • 每个打开的文件描述符都会占用系统资源(如内核中的文件表项、缓冲区等)。
  • 调用 close() 后,系统会释放这些资源。

刷新缓冲区

  • 如果文件是以写入模式打开的, close() 会确保所有缓冲区的数据写入磁盘(类似于 fflush() )。

解除文件描述符的绑定

  • 关闭后,文件描述符不再与任何文件或资源关联,可以被重新用于其他文件。

避免资源泄漏

  • 如果不关闭文件描述符,可能会导致文件描述符耗尽(每个进程有文件描述符数量限制)。

close()参数:

#include <unistd.h>

int close(int fd);
  • fd :要关闭的文件描述符(通常由 open()socket() 等函数返回)。

返回值:

  • 成功时返回 0
  • 失败时返回 -1 ,并设置 errno 表示错误原因。

write()系统调用函数

一、函数原型与头文件

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

参数说明:

fd (文件描述符)

  • 已打开文件的描述符(由 open()socket() 等函数返回)。
  • 必须具有 可写权限 (例如以 O_WRONLYO_RDWR 模式打开)。

buf (数据缓冲区)

  • 指向用户空间缓冲区的指针,包含待写入的数据。
  • 可以是任意类型的数据(如字符串、二进制数据)。

count (写入字节数)

  • 指定从 buf 中写入的字节数。
  • 实际写入的字节数可能小于 count (需检查返回值)。

返回值:

成功时: 返回实际写入的字节数( 0 ≤ 返回值 ≤ count )。

  • 返回值为 0 表示未写入数据(例如写入到已满的管道)。
  • 返回值小于 count 表示部分写入(需处理剩余数据)。

失败时:

  • 返回 -1 ,并设置全局变量 errno 表示错误类型

write()fwrite() 对比

特性write()fwrite()
接口层级系统调用(底层)标准库函数(高层)
缓冲无缓冲(直接写入内核)带用户空间缓冲区
错误处理通过 errno 和返回值通过返回值与 ferror()
适用场景需要精细控制的场景(如非阻塞)常规文件操作(更便捷)

什么叫做fd(文件描述符):

strlen 函数:只用写入有效字符串

https://i-blog.csdnimg.cn/direct/d0f4ff3b6fb9475086218b8668725c61.png

输出:

https://i-blog.csdnimg.cn/direct/aa0d6a259c8a4d2e955efed77b6d35d5.png

很显然第二次的写入是在上一次的基础上从头开始写的。

以写的方式打开,不存在就创建,并且先清空文件内容

int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);

open()的返回值fd是什么?这里我创建了四个文件并记录他们的返回值

https://i-blog.csdnimg.cn/direct/96c755799cb44639aef12658227dc94c.png

输出结果: https://i-blog.csdnimg.cn/direct/e239a5a7261147ecafbbad02b9c23add.png

为什么不返回 0、1、2?

因为:

  • 1:标准输入 - 键盘
  • 2:标准输出 - 显示器
  • 3:标准错误 - 显示器

这里和c语言的进行对比

https://i-blog.csdnimg.cn/direct/ea7b404219924226b15a76fafc174848.png

stdin、stdout、stderr对应的类型都是文件指针,与c语言的fopen、fdopen、freopen的返回值是一样的

https://i-blog.csdnimg.cn/direct/edf6a96b4af24f0cb7c766e106a8b0e5.png

这些说明,在c语言中我们把键盘显示器也是当做文件来看的

也就是说,由于write()是根据open返回的fd,来查找文件并写入的,那我直接往1中写入不就是往显示器文件中写吗:

https://i-blog.csdnimg.cn/direct/6d77e46b398d45f18f38ddfeb0df9b2a.png

编译运行:

https://i-blog.csdnimg.cn/direct/bc69e8debfa545888b3c83e5ce0ee191.png

fd  —>   文件描述符   —>  文件描述符的本质是什么?

fd的本质是什么?

1.在打开文件的时候,会在操作系统中创建一个 struct file ,文件的内核数据结构所包含的是文件的属性(权限,什么方式被打开,标记位),所有被打开的文件,他们的内核数据结构以双链表的形式被链接起来,操作系统对文件的管理转变成对链表的增删查改,每一个struct file内部都有一个指向与 该文件所对应的文件内核级的缓存的 指针,操作系统给文件申请的内存。

一个磁盘上的文件,会经过属性struct file内核数据结构 初始化,内容直接存到这个文件的缓存当中。未来直接从缓存当中读写修改。

https://i-blog.csdnimg.cn/direct/d2a28bd1fb2e49559ef5c57bf07c93b4.png

os中有多个进程,每个进程都有可能打开多个文件,进程和文件的关系是1:n,进程的内核数据结构中存在一个struct files_struct file属性。os中还会存在一个struct file_struct内核数据结构,整个结构中会包含一个指针数组struct file fd_array[N]。

想让进程和对应的文件产生关系:

将描述文件的结构体变量的地址(文件属性的地址),依次填入到 fd_array[],特定的数组中,因此一个进程想要找到对应的文件,只需要把对应文件数组的下标返回给上层比如说 int fd,就可以访问文件了。 每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件

https://i-blog.csdnimg.cn/direct/ba999dcf58434450b6e7e99fdd80cde9.png

fd的本质是:内核的进程:文件映射关系的数组的下标

读的本质是:将文件缓冲区的内容拷贝到需要的文件当中去,如果对应的内容不在缓冲区里,os就会把这个打开文件的进程阻塞住,挂起,os再将磁盘中的数据搬到缓存当中,再唤醒进程。

写和修改内容:没有内容的时候上层就直接将内容拷贝到缓冲区,当文件中本来就有内容的时候,现将内容拷贝到缓冲区,再在内存当中修改,定期由os再刷新

1.无论读写,都必须让os把内容读到对应的文件缓冲区内,在内存中修改,再刷新到磁盘

2.open()在:

a.创建file

b.开辟文件缓冲区的空间,加载文件数据(延后)

c.查进程的文件描述符表

d.将file地址,填入对应的表中

e.返回下标

3.write()、read()函数的本质是拷贝函数

一切皆文件

像是键盘、鼠标、显示器、网卡、磁盘这些外设,他们可以由一个设备结构体来记录他们的属性,但是,他们每一个的操作方法都不同,这是通过驱动来控制的。对每一个设备os都会构建一个struct file,里面就会包含他们的读写的函数指针,再指向驱动层的方法。使用同一个类,其中包含的读写函数指针指向不同的设备,因此我们就不用再管底层的差异了,因为底层外设的方法—>归于函数指针  —》 一切皆文件

这就像c++中的多态

https://i-blog.csdnimg.cn/direct/38b38f1448b842bd9f6b06a64afd36cb.png

https://i-blog.csdnimg.cn/direct/8f1f5a3714e24769a12c4d70e35ace97.png

https://i-blog.csdnimg.cn/direct/9fb0eee2573b4f409f40284dac848ac6.png

这是一个指针指向一张操作表:

https://i-blog.csdnimg.cn/direct/64e966478a1b425ebd65c70394116a8a.png

每一个被打开的文件还会有一张,操作底层方法的指针表

https://i-blog.csdnimg.cn/direct/f47703f91dbd4a4dba7053820a002a3f.png

在操作系统中,这就叫做虚拟文件系统:virtual file system

在操作系统中,系统访问文件时只认文件描述符fd

如何理解 c语言 通过FILE* 访问文件? 这个FILE是一个结构体

https://i-blog.csdnimg.cn/direct/7c9678a9d60f47108fc83c68b0d25929.png

https://i-blog.csdnimg.cn/direct/47ad1faf171946939856f6558c891c41.png

因此这个FILE里面一定封装了fd文件描述符

c语言上的文件操作函数,本质底层都是对系统调用的封装

写如下代码:

  FILE* fp = fopen("log.txt", "w");
  
  if(fp == NULL)
  {
    perror("fopen");
    return 1;
  }
  printf("fd: %d\n",fp->_fileno);

  fwrite("hello\n", 5, 1, fp);

  fclose(fp);

运行结果:这就证明了我们的FILE结构体中封装了fd文件描述符, https://i-blog.csdnimg.cn/direct/558c9c2ef38d4e068d11c0eb8c6663e2.png

更进一步:

代码:

  FILE* fp1 = fopen("log1.txt", "w");
  
  if(fp1 == NULL)
  {
    perror("fopen");
    return 1;
  }
  printf("fd1: %d\n",fp1->_fileno);


  FILE* fp2 = fopen("log2.txt", "w");
  
  if(fp2 == NULL)
  {
    perror("fopen");
    return 1;
  }
  printf("fd2: %d\n",fp2->_fileno);

  FILE* fp3 = fopen("log3.txt", "w");
  
  if(fp3 == NULL)
  {
    perror("fopen");
    return 1;
  }
  printf("fd3: %d\n",fp3->_fileno);

  fclose(fp1);
  fclose(fp2);
  fclose(fp3);

输出结果:

https://i-blog.csdnimg.cn/direct/6ff4df5e28b54dc48bdb0ed14f244bbe.png

c语言的stdout:stdin:stderr:


  printf("stdin ->fd: %d\n", stdin->_fileno);
  printf("stdout->fd: %d\n", stdout->_fileno);
  printf("stderr->fd: %d\n", stderr->_fileno);

最后输出

https://i-blog.csdnimg.cn/direct/7219c4f128f345eba563bbff54dba732.png

c语言为什么要这么做:

本来可以使用系统调用,也可以使用语言提供的文件方法

系统不一样,系统调用接口就不一样,代码不具有跨平台性,而为什么c语言、c++….等所有的语言都具有跨平台性的原因和作用我们现在就知道了。

如图:

https://i-blog.csdnimg.cn/direct/4aaff288a9964cdcb70e4b7009f2031d.png

所有的语言要对不同的平台的系统调用进行封装,不同语言封装时,文件接口就有差别了

在c++中的cin、cout、cerr可以向文件,显示器都写,我们称他们为流,但cin、cout、cerr在c++中都叫做类,他们内部一定包含了文件描述符。

通过进程pid找到fd

https://i-blog.csdnimg.cn/direct/d05315dae1184f7eb05bf2b6fd37a3f7.png 终端文件:

https://i-blog.csdnimg.cn/direct/589bb79191ac48d1bf9bc1722d63ebb4.png

这个终端也是属于一个文件,因此实际上我也可以向这个终端直接写东西:

https://i-blog.csdnimg.cn/direct/eaaa59435dcd4d5bbd0223ffcff29a86.png

运行结果: https://i-blog.csdnimg.cn/direct/fb43d24d7a3e48bfbd3b91f6e40acb58.png

结语:

随着这篇关于题目解析的博客接近尾声,我衷心希望我所分享的内容能为你带来一些启发和帮助。学习和理解的过程往往充满挑战,但正是这些挑战让我们不断成长和进步。我在准备这篇文章时,也深刻体会到了学习与分享的乐趣。

在此,我要特别感谢每一位阅读到这里的你。是你的关注和支持,给予了我持续写作和分享的动力。我深知,无论我在某个领域有多少见解,都离不开大家的鼓励与指正。因此,如果你在阅读过程中有任何疑问、建议或是发现了文章中的不足之处,都欢迎你慷慨赐教。

你的每一条反馈都是我前进路上的宝贵财富。同时,我也非常期待能够得到你的点赞、收藏,关注这将是对我莫大的支持和鼓励。当然,我更期待的是能够持续为你带来有价值的内容,让我们在知识的道路上共同前行。