操作系统C++实践:深入理解标准IO与文件IO

IO操作(标准IO与文件IO)

本文语言不保证严谨,内容也不保证完全,欢迎指正错误、补充

一、前言

C语言提供了两套主要的IO系统:标准IO和文件IO。理解这两种IO能帮助我们理解到程序如何与操作系统交互。

二、标准IO与文件IO

  • 标准IO:使用系统提供的C标准库函数进行IO操作,通过FILE指针作为文件的句柄(用于标识某个资源或对象的唯一标识符)。

  • 文件IO:基于系统调用的IO操作,每进行一次系统调用,进程会从用户空间向内核空间进行一次切换,而进程会挂起,从而导致进程执行效率降低。

  • 二者主要区别

    标准IO相较于文件IO而言提供了缓冲机制,以及一些格式化操作(fprintf, fscanf等)和较好的跨平台性;文件IO较为底层,系统相关性较强,需要使用者处理更多细节。

  • 相关接口:

    • 标准IO:printf/scanf, fopen/fclose, fread/fwrite, fprintf/fscanf, fgetc/fputc, fgets/fputs, fseek/ftell/rewind
    • 文件IO:open/close, read/write, lseek

三、标准IO

3.1 FILE结构体

  • 标准IO的一个重要部分,是系统提供的用于描述一个文件全部信息的结构体
  • FILE结构体核心字段
1
2
3
4
5
6
7
struct FILE
{
char * _IO_buf_base; // 缓冲区起始地址
char * _IO_buf_end; // 缓冲区终止地址

int _fileno; // 文件描述符,用于系统调用(文件IO)
};

​ 实际的FILE结构体比较复杂,这里仅仅展示了核心字段的简化版本。

  • 特殊的三个FILE指针:针对于终端文件而言的
    • stderr:标准错误指针
    • stdin:标准输入指针
    • stdout:标准输出指针

3.2 常用接口

​ 接下来是常用的一些接口(此处不再过多介绍,参考API手册或百度/bing/google即可)

1
2
3
4
5
6
7
8
9
10
11
12
13
打开关闭文件:fopen/fclose

字符读写:fgetc/fputc

字符串读写:fgets/fputs

格式化读写:fprintf/fscanf

格式串转字符串:sprintf/sscanf

模块读写:fread/fwrite

光标移动:fseek/ftell/rewind

3.3 关于缓冲区

  • 三种缓冲区(在大多数Linux系统中)

    • 缓冲区大小可能因系统而异,这里给出的是常见值。
    • 行缓存:与终端(标准输入输出)相关的缓冲区,大小为1024字节,用于stdin, stdout
    • 全缓存:与文件相关的缓冲区,大小为4096字节,用于文件
    • 无缓存:与标准错误相关的缓冲区,大小为0字节,用于stderr
  • 缓冲机制

    标准IO的缓冲机制是:当调用相关函数时,实际上是先将这些数据信息放入缓冲区中,等待缓冲区时机到了后,统一进行系统调用,将数据刷入内核空间,减少系统调用次数,提高效率

  • 缓冲区的刷新时机

    • 手动刷新:fflush

    int fflush(FILE *stream) 可刷新给定文件指针对应的缓冲区

    • 自动刷新

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      行缓存刷新时机:
      1.程序结束时
      2.遇到换行时
      3.输入输出发生切换时
      4.关闭行缓存对应的文件指针时
      5.缓冲区满了后
      全缓存刷新时机:
      1.程序结束时
      2.输入输出发生切换时
      3.关闭全缓存对应的文件指针时
      4.缓冲区满了后
      不缓存刷新时机:放入立马刷新
    • 演示

      行缓存演示

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      #include <stdio.h>

      int main(int argc, const char *argv[])
      {
      /*
      // 1. 程序结束前不会显示,因为没有换行符且程序被阻塞
      printf("hello world"); // 终端不显示hello world
      while(1); // 阻塞程序,防止结束
      */

      /*
      // 2. 程序结束时自动刷新缓冲区
      printf("hello world"); // 程序自动结束后,终端上显示hello world
      */

      /*
      // 3. 遇到换行符时立即刷新
      printf("hello world\n"); // 终端显示hello world
      while(1); // 阻塞程序,防止结束
      */

      /*
      // 4. 输入输出切换时刷新缓冲区
      int num = 0;
      printf("请输入>>>");
      scanf("%d", &num); // 输入输出发生切换
      */

      /*
      // 5. 关闭文件指针时刷新
      printf("hello world");
      fclose(stdout); // 关闭行缓存对应的文件指针
      while(1); // 阻塞程序,防止结束
      */

      /*
      // 6. 缓冲区满时自动刷新
      for(int i=0; i<1025; i++) // 超过1024字节了
      {
      printf("A");
      }
      while(1);
      */

      /*
      // 7. 手动刷新缓冲区
      printf("hello world");
      fflush(stdout);
      while(1);
      */

      return 0;
      }

      全缓存演示

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      #include<stdio.h>

      int main(int argc, const char *argv[])
      {
      // 打开一个文件进行读写操作
      FILE *fp = NULL;
      if((fp = fopen("./aa.txt", "r+")) == NULL) //读写的形式
      {
      perror("fopen error");
      return -1;
      }

      /*
      // 1. 普通写入不会立即刷新到文件
      fputs("hello world", fp); // 不刷新
      while(1);
      */

      /*
      // 2. 换行符对全缓存无效
      fputs("hello world\n", fp); // 换行不刷新
      while(1);
      */

      /*
      // 3. 程序结束时自动刷新
      fputs("hello world\n", fp); // 程序结束刷新
      */

      /*
      // 4. 输入输出切换时刷新
      fputs("hello world\n", fp); // 向文件中输出一个字符串
      fgetc(fp); // 输入输出发生切换
      while(1);
      */

      /*
      // 5. 关闭文件指针时刷新
      fputs("hello world", fp);
      fclose(fp); // 关闭缓冲区对应的文件指针
      while(1);
      */

      /*
      // 6. 手动刷新缓冲区
      fputs("hello world", fp);
      fflush(fp); // 手动刷新
      while(1);
      */

      /*
      // 7. 缓冲区满时自动刷新
      for(int i=0; i<4097; i++)
      {
      fputc('A', fp); // 超过4096字节了
      }
      while(1);
      */

      fclose(fp);
      return 0;
      }

      无缓存演示

      1
      2
      3
      4
      5
      6
      7
      8
      9
      #include <stdio.h>

      int main(int argc, const char *argv[])
      {
      // 标准错误输出立即显示,无需等待程序结束
      fputs("A", stderr);

      while(1); // 阻塞程序,防止结束
      }

3.4 关于错误码

  • 经常见到函数返回值介绍中“置位错误码”,这是什么意思?

当内核提供的函数出错后,内核空间会向用户空间反馈一个错误信息,由于错误信息比较多 也比较复杂,系统就给每种不同的错误信息起了一个编号,这就是系统提供的全局变量 errno ,是一个大于或等于0的整数

  • 基础的常用的errno(不完全)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
		define		errno	explain
#define EPERM 1 Operation not permitted
#define ENOENT 2 No such file or directory
#define ESRCH 3 No such process
#define EINTR 4 Interrupted system call
#define EIO 5 I/O error
#define ENXIO 6 No such device or address
#define E2BIG 7 Argument list too long
#define ENOEXEC 8 Exec format error
#define EBADF 9 Bad file number
#define ECHILD 10 No child processes
#define EAGAIN 11 Try again
#define ENOMEM 12 Out of memory
#define EACCES 13 Permission denied
#define EFAULT 14 Bad address
#define ENOTBLK 15 Block device required
#define EBUSY 16 Device or resource busy
#define EEXIST 17 File exists
#define EXDEV 18 Cross-device link
#define ENODEV 19 No such device
#define ENOTDIR 20 Not a directory
#define EISDIR 21 Is a directory
#define EINVAL 22 Invalid argument
#define ENFILE 23 File table overflow
#define EMFILE 24 Too many open files
#define ENOTTY 25 Not a typewriter
#define ETXTBSY 26 Text file busy
#define EFBIG 27 File too large
#define ENOSPC 28 No space left on device
#define ESPIPE 29 Illegal seek
#define EROFS 30 Read-only file system
#define EMLINK 31 Too many links
#define EPIPE 32 Broken pipe
#define EDOM 33 Math argument out of domain of func
#define ERANGE 34 Math result not representable
  • 关于错误码的函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <errno.h>

//将错误码对应的错误信息转换处理
#include <string.h>

char *strerror(int errnum);
功能:将错误码转换为错误信息描述
参数:错误码
返回值:错误信息字符串描述

//只打印错误码对应的错误信息的函数
#include <stdio.h>

void perror(const char *s);
功能:输出当前错误码对应的错误信息
参数:提示符号,会原样打印出来,并且会在提示数据后面加上冒号,并输出完后,自动换行
返回值:无
  • 演示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <errno.h>
#include <string.h>
#include <stdio.h>

int main(int argc, const char *argv[])
{
// 定义文件指针
FILE *fp = NULL;
fp = fopen("./notFound.txt", "r"); // 打开不存在的文件,制造错误
if(fp == NULL)
{
printf("fopen error: %d, errmsg:%s\n", errno, strerror(errno)); // 打印错误信息
perror("fopen error"); // 更简洁的错误输出方式
return -1;
}
printf("fopen success\n");

fclose(fp);
return 0;
}

3.5 拷贝文件演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdio.h>

// 方法1:逐字符拷贝(效率最低,但最安全)
int fget_fput_c_method(FILE *srcfp, FILE *destfp)
{
char ch = 0;
while(1)
{
ch = fgetc(srcfp); // 读取一个字符
if(ch == EOF) // 检查是否到达文件末尾
{
break;
}
fputc(ch, destfp); // 写入一个字符
}
return 0;
}

// 方法2:按行拷贝(适合文本文件)
int fget_fput_s_method(FILE *srcfp, FILE *destfp)
{
char buf[128] = "";
while(1)
{
char *ret = fgets(buf, sizeof(buf), srcfp); // 读取一行
if(ret == NULL) // 检查是否读取失败或到达文件末尾
{
break;
}
fputs(buf, destfp); // 写入一行
}
return 0;
}

// 方法3:块拷贝(效率最高)
int fread_fwrite_method(FILE *srcfp, FILE *destfp)
{
char buf[128] = "";
int res;
while((res = fread(buf, 1, sizeof(buf), srcfp)) > 0) // 读取数据块
{
if(fwrite(buf, 1, res, destfp) != res) // 写入数据块,检查写入字节数
{
printf("写入失败\n");
return -1;
}
}
return 0;
}

int main(int argc, const char *argv[])
{
if(argc != 3)
{
printf("input file error\n");
printf("usage: ./filename.out srcfile destfile\n");
return -1;
}

// 只读的方式打开源文件,只写的方式打开目标文件
FILE *srcfp = NULL;
FILE *destfp = NULL;
if((srcfp = fopen(argv[1], "r")) == NULL)
{
perror("srcfile open error");
return -1;
}
if((destfp = fopen(argv[2], "w")) == NULL)
{
perror("destfile open error");
fclose(srcfp); // 记得关闭已打开的文件
return -1;
}

// 将源文件内容拷贝到目标文件(选择其中一种方法)
// fget_fput_c_method(srcfp, destfp); // 逐字符拷贝
// fget_fput_s_method(srcfp, destfp); // 按行拷贝
fread_fwrite_method(srcfp, destfp); // 块拷贝

// 关闭文件
fclose(srcfp);
fclose(destfp);

printf("文件拷贝完成\n");
return 0;
}

四、文件IO

​ 通过系统调用实现,使用一次文件IO接口,进程就会向内核空间进行一次切换。标准IO的相关接口中也调用了文件IO的操作,因为没有缓冲区,所以操作效率较低。

4.1 文件描述符

  • 本质是一个大于等于0的整数,使用open函数打开文件时,会产生一个用于操作文件的句柄,这就是文件描述符

  • 一个进程中,能够打开的文件描述符是有限制的,一般是1024个[0, 1023],可通过指令 ulimit -a 进行查看,若要更改这个限制,可通过 ulimit -n 数字 进行更改

  • 文件描述符的使用原则一般是最小未分配原则

  • 特殊的文件描述符:0、1、2,这三个文件描述符在一个进程启动时就默认被打开了,分别表示标准输入 stdin 、标准输出 stdout 、标准错误 stderr 。通过以下程序验证

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #include <stdio.h>

    int main(int argc, const char *argv[])
    {
    printf("stdin->_fileno = %d\n", stdin->_fileno); // 0
    printf("stdout->_fileno = %d\n", stdout->_fileno); // 1
    printf("stderr->_fileno = %d\n", stderr->_fileno); // 2

    return 0;
    }

4.2 常用接口

​ 接下来是常用的一些接口(此处不再过多介绍,参考API手册或百度/bing/google即可)

1
2
3
4
5
6
打开关闭文件:open/close
读写操作:read/write
文件定位:lseek
文件状态:stat/fstat
目录操作:opendir/readdir/closedir
文件权限:chmod/chown

4.3 拷贝文件演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, const char *argv[])
{
if(argc != 3)
{
printf("input file error\n");
printf("usage: ./a.out srcfile destfile\n");
return -1;
}

int sfd = -1, dfd = -1;

// 打开源文件(只读方式)
if((sfd = open(argv[1], O_RDONLY)) == -1)
{
perror("srcfile open error");
return -1;
}

// 打开目标文件(写入方式,如果不存在则创建,如果存在则清空)
// 0644表示文件权限:所有者可读写,组和其他用户只读
if((dfd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644)) == -1)
{
perror("destfile open error");
close(sfd); // 确保已打开的文件被关闭
return -1;
}

char buf[128] = "";

// 循环读取和写入数据
while(1)
{
int res = read(sfd, buf, sizeof(buf)); // 读取数据到缓冲区
if(res == 0) // 读取到文件末尾
{
break;
}
if(res == -1) // 读取出错
{
perror("read error");
break;
}

// 写入数据,检查写入字节数是否正确
if(write(dfd, buf, res) != res)
{
perror("write error");
break;
}
}

// 关闭文件描述符
close(sfd);
close(dfd);

printf("文件拷贝完成\n");
return 0;
}

五、选择建议

标准IO适用场景:

  • 频繁的小数据读写(因为有缓冲区,减少系统调用)
  • 格式化输入输出时(因为有fprintf, fscanf等函数)
  • 文本文件处理(因为方便)
  • 跨平台(因为方便,C标准库提供了兼容性)

文件IO适用场景:

  • 大块数据读写(因为无缓冲区,每次都要系统调用)
  • 需要精确控制IO行为(因为是基于系统调用)
  • 系统级编程(因为是基于系统调用)
  • 性能需求(因为偏底层,可自行避免标准IO中额外的操作)

到此就结束了,总结就是:

  • 标准IO提供了便利的缓冲机制和格式化功能,适合大多数情况。
  • 文件IO则提供了更底层的控制能力,适合系统编程和对性能有要求的情况。

希望大家喜欢,内容不多但都有用心 (´▽`ʃ♡ƪ)