文件IO系统

文件系统概述

文件

狭义上的文件:指普通的文本文件或二进制文件,包括源代码、Word文档、压缩包、图片、视频文件等。

广义上的文件:除了狭义上的文件外,几乎所有可操作的设备或接口都可视为文件,包括键盘、鼠标、硬盘、串口、触摸屏、显示器等,以及网络通讯端口、进程间通讯管道等抽象概念。

因此在Linux系统中,我们将文件分成了如下几类:

Linux系统中文件的分类

在Linux中,文件总共被分成了7种:

  1. 普通文件:存在于外部存储器中,用于存储普通数据。
  2. 目录文件:用于存放目录项,是文件系统管理的重要文件类型。
  3. 管道文件:一种用于进程间通信的特殊文件,也称为命名管道FIFO。
  4. 套接字文件:一种用于网络间通信的特殊文件。
  5. 链接文件:用于间接访问另外一个目标文件,相当于Windows快捷方式。
  6. 字符设备文件:字符设备在应用层的访问接口。
  7. 块设备文件:块设备在应用层的访问接口。

通过 ls -l 命令,可以看到每个文件信息的最左边一栏,是各种文件类型的缩写:

  • -(regular)普通文件
  • d(directory)目录文件
  • p(pipe)管道文件(命名管道)
  • s(socket)套接字文件(Unix域/本地域套接字)
  • l(link)链接文件(软链接)
  • c(character)字符设备文件
  • b(block)块设备文件

系统IO和标准IO

对文件的操作基本上就是输入输出,因此也一般称为IO接口:

  • 系统IO:是操作系统提供的专门针对文件操作的一部分接口,特点是简洁且功能单一,没有缓冲区,因此对海量数据的操作效率较低,但套接字Socket、设备文件的访问只能使用系统IO。
  • 标准IO:是标准C库提供的专门针对文件操作的一部分接口,功能丰富且有缓冲区,因此对海量数据的操作效率高。在编程开发中尽量选择标准IO,但在某些特定场合只能使用系统IO。

在实际编程开发中,两组函数接口都是基本开发技能,并且经常会用到:

  • 系统IO:适用于需要直接与操作系统交互的场景,如套接字和设备文件的操作。
  • 标准IO:适用于需要高效处理海量数据的场景,提供丰富功能和缓冲区。

系统IO

系统IO基本API

文件打开和关闭

功能:

用于打开一个指定的文件并获得文件描述符,或者创建一个新文件。

通用API接口:

//头文件:
#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);

头文件的作用

<sys/types.h> 包含了各种数据类型的定义,这些数据类型通常用于系统调用和文件操作。常见定义为:

  • pid_t:进程ID类型
  • uid_t:用户ID类型
  • gid_t:组ID类型
  • off_t:文件偏移量类型
  • size_t:表示对象大小的类型

<sys/stat.h>头文件定义了获取文件状态信息的函数和文件类型、权限等相关常量。主要内容包括:

  • struct stat:包含文件信息的结构体
  • S_IFMT:文件类型的位掩码
  • S_IFREG:常规文件
  • S_IFDIR:目录
  • S_IFCHR:字符设备
  • S_IFBLK:块设备

<fcntl.h>头文件包含了文件控制相关的函数和常量,主要用于文件的打开、锁定等操作。主要内容包括:

  • 文件打开模式常量:
  • O_RDONLY:只读模式
  • O_WRONLY:只写模式
  • O_RDWR:读写模式
  • O_CREAT:文件不存在时创建文件
  • O_EXCL:与O_CREAT一起使用,文件存在时返回错误
  • O_TRUNC:打开文件时清空文件
  • O_APPEND:追加模式

参数说明:

  • pathname:要打开的文件路径。

  • flags:文件打开模式,可以使用位或(|)组合多个选项。

    • O_RDONLY:只读方式打开文件
    • O_WRONLY:只写方式打开文件
    • O_RDWR:读写方式打开文件
    • O_CREAT:如果文件不存在,则创建该文件
    • O_EXCL:与O_CREAT一同使用,如果文件已存在,则返回错误
    • O_NOCTTY:当文件为终端时,阻止该终端成为进程的控制终端
    • O_TRUNC:如果文件已存在,则删除文件中原有数据
    • O_APPEND:以追加方式打开文件,写操作附加在文件末尾
  • mode:文件权限(在创建新文件时使用),八进制表示方式(如0644)。

0644 表示文件权限:类似于chmod 644

例子:

#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(void) {
    int fd;

    // 以不同方式打开已存在的文件
    fd = open("a.txt", O_RDWR);   // 可读可写方式打开
    if (fd == -1) perror("打开文件失败");

    fd = open("a.txt", O_RDONLY); // 只读方式打开
    if (fd == -1) perror("打开文件失败");

    fd = open("a.txt", O_WRONLY); // 只写方式打开
    if (fd == -1) perror("打开文件失败");

    // 如果文件不存在,则创建该文件,并设置其权限为0644
    fd = open("a.txt", O_RDWR | O_CREAT | O_EXCL, 0644); // 可读可写方式打开
    if (fd == -1) perror("创建文件失败");

    // 如果文件不存在, 则创建文件。如果文件已存在,则清空该文件的原有内容
    fd = open("a.txt", O_RDWR | O_CREAT | O_TRUNC, 0644); // 可读可写方式打开
    if (fd == -1) perror("打开文件失败");

    // 以追加方式打开文件
    fd = open("a.txt", O_RDWR | O_APPEND, 0644); // 可读可写方式追加文件内容
    if (fd == -1) perror("打开文件失败");

    // 关闭文件
    if (close(fd) == -1) {
        perror("关闭文件失败");
    }

    return 0;
}

通过perror()输出本质上和printf没什么区别,但是perror会维护一个全局错误码,如果系统调用出问题,全局错误码error会随之改变。使用perror除了会输出我们想要的内容外,还会把它自己维护的错误码对应的信息给打印出来

错误码提取的两种方式:

// 1. 使用perror(),直接输出用户信息和错误信息:
if(open("a.txt", O_RDWR) == -1)
{
    perror("打开a.txt失败");
}

// 2. 使用strerror(),返回错误信息交给用户自行处理:
if(open("a.txt", O_RDWR) == -1)
{
    printf("打开a.txt失败:%s\n", strerror(errno));
}

文件描述符本质

文件描述符是一个整数:当我们在程序中调用 open() 函数打开一个文件时,操作系统内核会返回一个整数,这个整数就是文件描述符。它的值是从0开始的,并且每打开一个新的文件,这个值就会递增。

这个整数实际上是操作系统内核中一个数组(称为 fd_array)的下标。fd_array 是一个存放指向 file 结构体指针的数组。每个打开的文件都会对应一个 file 结构体,这个结构体包含了文件的各种信息(如文件位置指针、访问模式等)。

当我们打开一个文件时,内核会创建一个新的 file 结构体,并将其指针放入 fd_array 数组中。文件描述符就是这个指针在数组中的位置。通过文件描述符,程序可以间接地访问到对应的 file 结构体,从而对文件进行操作。
在每个进程开始时会默认打开三个文件:

  • 0:标准输入(通常是键盘)
  • 1:标准输出(通常是屏幕)
  • 2:标准错误输出(通常也是屏幕)

如果一个文件被多次打开,每次打开操作都会返回一个新的文件描述符,并且在内核中生成一个新的 file 结构体。即使它们指向的是同一个物理文件,不同的文件描述符仍然对应不同的 file 结构体,独立管理各自的文件状态。

用户程序调用 open()
   |
   V
+---------+
| fd = 0  | <-- 文件描述符
+---------+
   |
   V
+---------+       +------------------+
| fd_array[0] --->| struct file {...}|
+---------+       +------------------+
| fd_array[1] |
+---------+
| fd_array[2] |
+---------+
     .
     .
     .

举个例子:

int fd1 = open("file1.txt", O_RDONLY);
int fd2 = open("file2.txt", O_RDONLY);

在这个例子中,假设这是程序运行时第一次打开文件,那么 fd1 可能是 3(因为 0, 1, 2 通常是标准输入、标准输出和标准错误),而 fd2 可能是 4。文件描述符 3 和 4 分别指向 file1.txt 和 file2.txt 的 file 结构体。

文件读写操作

读写函数:

int read(fd, buf, count)
int write(fd, buf, count)
  • read(fd, buf, count):从文件描述符fd对应的文件中读取数据到缓冲区buf,最多读取count个字节,返回实际读取的字节数。如果返回0表示读取到文件尾,返回-1表示读取失败。
  • write(fd, buf, count):将缓冲区buf中的数据写入文件描述符fd对应的文件中,最多写入count个字节,返回实际写入的字节数,返回-1表示写入失败。

例子:

int main()
{
int fd = open("a.txt", O_RDWR);

char buf[100];
int n;
while(1)
{
    bzero(buf, 100);
    n = read(fd, buf, 100); // 每次最多读取100个字节

    if(n == 0) // 读完退出
        break;

    printf("%s", buf);
}
close(fd);
}
文件读写位置
  1. 文件读写位置的概念
  • 操作系统为每个打开的文件维护一个文件读写位置,这个位置记录了文件的当前读写点。
  • 每当调用open()打开一个文件时,系统会初始化文件读写位置为文件的开头。
  • 后续的读写操作会更新文件读写位置,使得下一次读写从更新后的位置开始。
  1. 同一文件描述符的读写操作
  • 对同一个文件描述符进行读写操作时,共享同一套文件读写信息,影响的是同一个位置参数。
  • 读写操作会按照顺序进行,并更新同一个文件描述符的读写位置。
  1. 不同文件描述符的读写操作
  • 对同一个文件进行多次open()操作,会得到不同的文件描述符。
  • 不同文件描述符之间的读写位置是独立的,互不影响。
  • 这意味着不同的文件描述符对同一文件的读写操作会有不同的读写位置,可能导致文件内容的错乱。

例子:

#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <strings.h>

int main(int argc, char **argv) // ./main a.txt
{
    int fd = open(argv[1], O_RDWR);
    char buf[100];

    // 读出1个字节,读完后读写操作位置是第2个字节
    bzero(buf, 100);
    read(fd, buf, 1);
    printf("%s\n", buf);

    // 写入2个字节,写完后读写操作位置是第4个字节
    bzero(buf, 100);
    write(fd, "xx", 2);

    // 读出3个字节,读完后读写操作位置是第7个字节
    bzero(buf, 100);
    read(fd, buf, 3);
    printf("%s\n", buf);

    close(fd);
    return 0;
}

不同文件标识符对同一文件进行操作的情况。

#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <strings.h>

int main(int argc, char **argv) // ./main a.txt
{
    // 假设文件中的原始内容是:abcdefghijk
    int fd1 = open(argv[1], O_RDWR);
    int fd2 = open(argv[1], O_RDWR);
    char buf[100];

    // 读出1个字节,读完后读写操作位置是第2个字节
    // 此时影响的是fd1,对fd2没有影响
    bzero(buf, 100);
    read(fd1, buf, 1);
    printf("%s\n", buf); // 输出a

    // 写入2个字节,读完后读写操作位置是第3个字节
    // 与上述读操作没有关系
    bzero(buf, 100);
    write(fd2, "xy", 2); // ab被覆盖,原文件变成xycdefghijk

    // 读出3个字节,读完后读写操作位置是第5个字节
    // 此时影响的是fd1,对fd2没有影响
    bzero(buf, 100);
    read(fd1, buf, 3);
    printf("%s\n", buf); // 输出ycd

    close(fd1);
    close(fd2);
    return 0;
}

lseek函数

同时我们也可以自己设置读写位置:

通过lseek()来调节位置, 调整后的文件位置可以是文件已有数据的位置,也可以是超出文件末尾形成“空洞”的位置。

头文件:

#include <sys/types.h>
#include <unistd.h>

原型函数:

off_t lseek(int fd, off_t offset, int whence);
  • fd:要调整位置偏移量的文件描述符。
  • offset:新位置偏移量相对基准点的偏移量。
  • whence:基准点,可以是以下三个值:
    • SEEK_SET:文件开头。
    • SEEK_CUR:当前读写位置。
    • SEEK_END:文件末尾。

注意点:

  • lseek 函数只能对普通文件进行位置调整,不能用于管道文件。
  • 调整到文件末尾之后的某个位置时,文件会形成“空洞”。

例子:

int main(void)
{
    // 假设文件 a.txt 只有一行
    // 内容如下:
    //
    // 1234567890abcde 
    //

    int fd = open("a.txt", O_RDWR);

    // 读取前面10个阿拉伯数字:
    char buf[10];
    read(fd, buf, 10);

    // 将文件位置调整到'c'
    lseek(fd, 2, SEEK_CUR);

    // 将文件位置调整到'1'
    lseek(fd, 0, SEEK_SET);

    // 将文件位置调整到'a'
    lseek(fd, -5, SEEK_END);

    close(fd);
}

所谓文件空洞如下所示:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>

int main()
{
    int fd = open("testfile.txt", O_RDWR | O_CREAT, 0644);
    if(fd == -1)
    {
        perror("open");
        return 1;
    }

    // 获取文件写入前的大小
    struct stat st;
    if (stat("testfile.txt", &st) == -1) {
        perror("stat");
        close(fd);
        return 1;
    }
    printf("File size before writing: %lld bytes\n", (long long) st.st_size);

    // 将位置调整到末尾10KB处
    off_t new_pos = lseek(fd, 10 * 1024, SEEK_END);
    if(new_pos == -1)
    {
        perror("lseek");
        close(fd);
        return 1;
    }

    // 写入数据
    const char *data = "Hello, this is a test for file holes.";
    ssize_t bytes_written = write(fd, data, strlen(data));
    if (bytes_written == -1) {
        perror("write");
        close(fd);
        return 1;
    }

    // 获取文件写入后的大小
    if (stat("testfile.txt", &st) == -1) {
        perror("stat");
        close(fd);
        return 1;
    }
    printf("File size after writing: %lld bytes\n", (long long) st.st_size);

    // 关闭文件
    close(fd);

    return 0;
}

File size before writing: 0 bytes
File size after writing: 10277 bytes

由于形成了文件空洞,导致文件变得很大。

系统IO常用API

对文件的操作,除了最基本的打开、关闭、读、写、定位之外,还有很多特殊的情况,比如用于沟通应用层与底层驱动之间的ioctl、万能工具箱fcntl、内存映射mmap等等,熟练使用这些API,是日常开发的必备技能。

ioctl

ioctl() 函数是连接应用层和驱动层的关键工具,底层开发人员在为硬件设备编写驱动时,通常将特定的操作封装为函数,并为这些接口提供命令字。应用层开发者可以通过 ioctl() 函数结合这些命令字,直接与驱动层通信,绕过操作系统的中间层调用对应功能。

从这个角度来看,ioctl() 函数就像一个软件开发者和硬件开发者打通的通道,经过交流沟通后, 定义好命令字等参数,仅提供函数调用路径,具体功能由命令字决定。以下是函数的接口规范说明:

  • request 就是所谓的命令字。
  • 底层驱动开发者可以自定义命令字。
  • 对于某些常见硬件设备的常见功能,系统提供了标准的命令字。

例子:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/videodev2.h>

#define LEDOP 0x1234  // 假设 LEDOP 是底层开发者自定义的命令字
#define VIDIOC_STREAMON _IOW('V', 18, int)  // 示例命令字,实际值需根据系统定义

int main(void)
{
    // 打开一盏LED灯
    int led = open("/dev/Led", O_RDWR);

    // 通过命令字 LEDOP 及其携带的0/1参数,控制LED灯的亮灭
    // 此处,LEDOP 是底层开发者自定义的命令字
    ioctl(led, LEDOP, 1);  // 打开LED灯
    ioctl(led, LEDOP, 0);  // 关闭LED灯

    // 打开一个摄像头
    int cam = open("/dev/video0", O_RDWR);

    // 通过命令字 VIDIOC_STREAMON 及其携带参数 vtype 启动摄像头
    enum v4l2_buf_type vtype = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    ioctl(cam, VIDIOC_STREAMON, &vtype);

    // 关闭设备
    close(led);
    close(cam);

    return 0;
}

dup和dup2

dup 是 duplicate(复制)的缩写,这两个函数用于复制文件描述符。它们的接口规范如下:

//会将指定的旧文件描述符 oldfd 复制一份,并返回一个当前系统未使用的最小的新文件描述符。
dup(int oldfd) 
// 类似于 dup(),但可以指定新文件描述符的具体数值 newfd。
dup2(int oldfd, int newfd)

例子:

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

int main()
{
    // 打开文件 a.txt,获得其文件描述符 fd1
    int fd1 = open("a.txt", O_RDWR);

    // 复制文件描述符 fd1,默认得到最小未用的文件描述符
    int fd2 = dup(fd1);

    // 复制文件描述符 fd1,并指派为 100,这时候返回值为100
    int fd3 = dup2(fd1, 100);

    // 关闭文件描述符
    close(fd1);
    close(fd2);
    close(fd3);

    return 0;
}

dup2的应用:
dup2还有一个重定向的作用,就是说将一个文件描述符复制到了另一个文件描述符上面,这时候就可以将对原来文件描述符的操作重定向到新的描述符上面去。如:dup2(fd, 1),这就将标准输出printf的内容全部重定向到fd的文件里去了

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

int main()
{
    // 打开文件 a.txt,获得其文件描述符 fd1
    int fd1 = open("a.txt", O_RDWR | O_CREAT, 0644);

    // 将文件描述符 fd1 复制到标准输出文件描述符 1
    dup2(fd1, 1);

    // 现在标准输出会被重定向到 a.txt 文件中
    printf("This will be written to a.txt\n");

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

    return 0;
}

在这里插入图片描述

fcntl函数

fcntl() 函数是 file control 的缩写,用于“控制”文件。与 ioctl() 类似,fcntl() 的“控制”含义广泛,由其第二个参数 cmd 决定。其接口规范如下:

  • fcntl 是一个变参函数,前两个参数是固定的,后续参数个数和类型取决于 cmd 的具体数值。
  • 第二个参数 cmd 是命令字,决定了具体的控制操作。
  • 常用的命令字包括:
命令字功能说明
F_DUPFD类似 dup()dup2(),复制文件描述符。
F_GETFL获取文件状态标志。
F_SETFL设置文件状态标志。
F_SETOWN设置套接字信号属主。
F_GETOWN获取套接字信号属主。

设置文件非阻塞属性

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

int main()
{
    // 打开文件
    int fd = open("a.txt", O_RDWR);

    // 获取文件描述符 fd 的标签属性
    int flag = fcntl(fd, F_GETFL);

    // 在其原有属性上,增添非阻塞属性
    flag |= O_NONBLOCK;
    fcntl(fd, F_SETFL, flag);

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

    return 0;
}

设置套接字信号属主

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

int main()
{
    // 假设 sockfd 是一个已经创建的套接字文件描述符
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    // 将套接字 sockfd 的信号属主设置为本进程
    fcntl(sockfd, F_SETOWN, getpid());

    // 关闭套接字文件描述符
    close(sockfd);

    return 0;
}

详细说明

  1. 复制文件描述符

    • F_DUPFD 命令用于复制文件描述符,功能类似于 dup()dup2()
  2. 获取和设置文件状态标志

    • F_GETFL 命令用于获取文件描述符的状态标志。
    • F_SETFL 命令用于设置文件描述符的状态标志,如非阻塞属性(O_NONBLOCK)。
  3. 获取和设置套接字信号属主

    • F_GETOWNF_SETOWN 命令用于获取和设置套接字信号属主。在网络编程中,当套接字处于异步通信状态并收到远端数据时,内核会产生一个 SIGIO 信号,可以通过这些命令设置信号的接收者。

fcntl() 函数提供了一种灵活的方式来控制文件和套接字的各种属性和行为。通过 fcntl() 函数,开发者可以实现文件描述符复制、设置文件非阻塞模式、设置套接字信号属主等功能,这在文件操作和网络编程中非常常见和实用。

mmap()

mmap() 函数是 memory map(内存映射)的缩写,用于将文件映射到内存中,通过操作内存来间接操作文件。以下是一些关键点和示例代码说明。

  • mmap() 函数的 flags 参数有很多选择,下表只列出了最基本的几个,详细信息请查阅 man 手册。
  • mmap() 理论上可以对任意文件进行内存映射,但通常用于映射一些特别的设备文件,如液晶屏。
  • 在较旧的 Linux 内核中,可以直接使用 mmap() 给 LCD 设备映射内存;在较新的 Linux 内核(中,需要经由 DRM 统一管理,不能直接 mmap 映射显存。

参数说明

参数含义
addr映射内存的起始地址,通常设置为 NULL 由系统决定
length映射内存的长度(字节数)
prot映射内存的保护方式(读/写权限)
flags映射内存的标志(共享/私有)
fd关联的文件描述符
offset文件映射的起始偏移量

常用的保护方式(prot

标志含义
PROT_READ页内容可以被读取
PROT_WRITE页内容可以被写入
PROT_EXEC页内容可以被执行
PROT_NONE页不可访问

常用的标志(flags

标志含义
MAP_SHARED共享映射,对内存的修改会影响文件
MAP_PRIVATE私有映射,对内存的修改不会影响文件

映射普通文件

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>

int main()
{
    // 以读写方式打开一个文件
    int fd = open("a.txt", O_RDWR);

    // 申请一块大小为1024字节的映射内存,并将之与文件fd相关联
    char *p = mmap(NULL, 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

    // 将该映射内存的内容打印出来(即其相关联文件fd的内容)
    printf("%s\n", p);

    // 通过操作内存,间接修改文件内容
    p[0] = 'x';
    printf("%s\n", p);

    // 解除映射
    munmap(p, 1024);
    close(fd);
    return 0;
}

映射液晶屏LCD文件

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <string.h>

int main()
{
    // 打开液晶屏文件
    int lcd = open("/dev/fb0", O_RDWR);

    // 给LCD设备映射一块内存(或称显存)
    char *p = mmap(NULL, 800 * 480 * 4, PROT_WRITE, MAP_SHARED, lcd, 0);

    // 通过映射内存,将LCD屏幕的每一个像素点涂成红色
    int red = 0x00FF0000;
    for (int i = 0; i < 800 * 480; i++)
        memcpy(p + i * 4, &red, 4);

    // 解除映射
    munmap(p, 800 * 480 * 4);
    close(lcd);
    return 0;
}

mmap() 函数提供了一种高效的方式将文件映射到内存中,方便对文件内容进行操作。通过正确设置 protflags 参数,可以灵活地控制内存映射的权限和方式。

标准IO

系统IO:打开文件得到的是一个整数,称为文件描述符。
标准IO:打开文件得到的是一个指针,称为文件指针。

如下图:
在这里插入图片描述
在调用fopen()后我们会得到一个FILE指针,这个指针指向FILE结构体,这些结构体的参数作用如下:
好的,以下是 FILE 结构体成员的详细表格:

FILE 结构体成员表格

成员变量类型说明
int _flagsint文件状态标志位,用于标识文件的各种状态
char* _IO_read_ptrchar*指向当前读位置的指针
char* _IO_read_endchar*指向读缓冲区末尾的指针
char* _IO_read_basechar*指向读缓冲区起始位置的指针
char* _IO_write_basechar*指向写缓冲区起始位置的指针
char* _IO_write_ptrchar*指向当前写位置的指针
char* _IO_write_endchar*指向写缓冲区末尾的指针
char* _IO_buf_basechar*指向通用缓冲区起始位置的指针
char* _IO_buf_endchar*指向通用缓冲区末尾的指针
int _filenoint文件描述符,唯一标识打开的文件,用于系统调用
off_t _old_offsetoff_t文件的旧偏移量,用于记录文件位置
unsigned short _cur_columnunsigned short当前列位置,用于格式化输出函数(如 printf)记录当前位置
char _shortbuf[1]char[1]短缓冲区,用于存储少量数据
_IO_lock_t *_lock_IO_lock_t*用于文件锁定的指针,确保多线程访问时的安全
off64_t _offsetoff64_t文件的偏移量,用于大文件的读写操作

文件打开和关闭

fopen

  • 功能: 获取指定文件的文件指针
  • 头文件: #include <stdio.h>
  • 原型: FILE *fopen(const char *path, const char *mode);
  • 参数:
    • path: 要打开的文件路径
    • mode: 打开文件的模式(如上表)
  • 返回值:
    • 成功: 文件指针
    • 失败: NULL

fclose

  • 功能: 关闭指定的文件并释放资源
  • 头文件: #include <stdio.h>
  • 原型: int fclose(FILE *fp);
  • 参数:
    • fp: 要关闭的文件指针
  • 返回值:
    • 成功: 0
    • 失败: EOF

常见的模式:

模式描述
r以只读方式打开文件,要求文件必须存在。
r+以读写方式打开文件,要求文件必须存在。
w以写入方式打开文件,文件如果不存在则创建文件,如果存在则清空文件。
w+以读写方式打开文件,文件如果不存在则创建文件,如果存在则清空文件。
a以追加方式打开文件,文件如果不存在则创建文件,且文件位置偏移量自动定位到文件末尾。
a+以读写追加方式打开文件,文件如果不存在则创建文件,且文件位置偏移量自动定位到文件末尾。

文件操作函数

示例代码

#include <stdio.h>

int main() {
    // 以可读可写的方式打开文件,且要求文件必须存在
    FILE *fp = fopen("a.txt", "r+");
    if (fp == NULL) {
        perror("fopen失败");
        return 1;
    }

    // 关闭文件指针,并释放文件所关联的缓冲区内存
    fclose(fp);
    return 0;
}

注意事项

  1. 总共有6种打开模式,不能使用其他模式,如“rw”是非法的。
  2. fclose函数涉及内存释放,不可对同一个文件多次关闭。

文件读写

这一部我们可以从四个角度来进行分类,分别是:

  1. 按字节读写文件
  2. 按行读写文件
  3. 按指定格式读写文件
  4. 按块来读写文件

按字节来读写文件

按字节来读写文件,每次只能读写一个字符,下面是一些对应的API接口

函数描述参数返回值
fgetc(FILE *fp)从文件读取一个字符fp - 文件指针读取的字符或EOF
getc(FILE *fp)从文件读取一个字符(宏)fp - 文件指针读取的字符或EOF
fputc(int c, FILE *fp)将字符写入文件c - 要写入的字符,fp - 文件指针写入的字符或EOF
putc(int c, FILE *fp)将字符写入文件(宏)c - 要写入的字符,fp - 文件指针写入的字符或EOF
getchar(void)从标准输入读取一个字符读取的字符或EOF
putchar(int c)将字符写入标准输出c - 要写入的字符写入的字符或EOF

示例代码:

int main() {
    FILE *fp;
    int ch;

    fp = fopen("example.txt", "r");
    if (fp == NULL) {
        perror("无法打开文件");
        return 1;
    }
    printf("使用fgetc从文件读取字符:\n");
    while ((ch = fgetc(fp)) != EOF) {
        putchar(ch);
    }
    if (feof(fp)) {
        printf("\n文件内容已读完.\n");
    }
    if (ferror(fp)) {
        printf("\n读操作遇到错误.\n");
    }
    fclose(fp);


    return 0;
}

总结一下就是:

  1. fgetc() 与 getc() 功能完全一样,区别是 fgetc 是函数,而 getc 是宏。
  2. fputc() 与 putc() 功能完全一样,区别是 fputc 是函数,而 putc 是宏。
  3. getchar() 和 putchar() 只能针对键盘输入和屏幕输出,不能指定别的文件。

它们返回的字符都是int型,因为EOF对应的是-1,而char无法表示-1的字符。

按行读写文本文件

以下API接口都是标准IO针对按行读取文本的代码进行的封装。

函数描述参数返回值
fgets(char *str, int n, FILE *fp)从文件读取一行数据str - 存储读取数据的缓冲区,n - 读取的最大字符数,fp - 文件指针成功返回str,失败返回NULL
gets(char *str)从标准输入读取一行数据(不安全)str - 存储读取数据的缓冲区成功返回str,失败返回NULL
fputs(const char *str, FILE *fp)将字符串写入文件str - 要写入的字符串,fp - 文件指针成功返回非负值,失败返回EOF
puts(const char *str)将字符串写入标准输出str - 要写入的字符串成功返回非负值,失败返回EOF

例子:

int main() {
    char buf[100];
    FILE *fp = fopen("a.txt", "r+");

    fgets(buf, 100, fp);
    gets(buf);

    fputs("abcd", fp);
    puts("abcd");
}

注意点:

  1. fgets() 可以读取指定的任意文件,而 gets() 只能从键盘读取。
  2. fgets() 有内存边界判断,而 gets() 没有,因此后者是不安全的,不建议使用。
  3. fgets() 在任何情形下都按原样读取数据,但 gets() 会自动去除数据末尾的 ‘\n’。
  4. fputs() 可以将数据写入指定的任意文件,而 puts() 只能将数据输出到屏幕。
  5. fputs() 在任何情形下都按原样写入数据,但 puts() 会自动给写入数据的末尾加上 ‘\n’。

关于如何看待gets()的不安全性:

fgets允许我们设置一次获取的长度n,一定程度上可以防止出现越界错误

按指定格式读写文本文件

在我们给文件写入或者读取字符串时,我们为了代码的可读性往往会通过格式化写入和格式化读取,下面是一些API

函数描述参数返回值
fprintf(FILE *fp, const char *format, ...)按格式写入文件fp - 文件指针,format - 格式字符串,… - 变量参数成功返回写入的字符数,失败返回负值
printf(const char *format, ...)按格式写入标准输出format - 格式字符串,… - 变量参数成功返回写入的字符数,失败返回负值
sprintf(char *str, const char *format, ...)按格式写入字符串str - 存储写入数据的缓冲区,format - 格式字符串,… - 变量参数成功返回写入的字符数,失败返回负值
snprintf(char *str, size_t size, const char *format, ...)按格式写入字符串(安全)str - 存储写入数据的缓冲区,size - 缓冲区大小,format - 格式字符串,… - 变量参数成功返回写入的字符数,失败返回负值
fscanf(FILE *fp, const char *format, ...)按格式从文件读取数据fp - 文件指针,format - 格式字符串,… - 变量参数成功返回读取的项目数,失败返回EOF
scanf(const char *format, ...)按格式从标准输入读取数据format - 格式字符串,… - 变量参数成功返回读取的项目数,失败返回EOF
sscanf(const char *str, const char *format, ...)按格式从字符串读取数据str - 源字符串,format - 格式字符串,… - 变量参数成功返回读取的项目数,失败返回EOF

上面几个API我们可以这样分类:

  1. 按读取和写入文件分得到fprintf()和fscanf()
  2. 按读取和写入标准化输入输出分得到:printf()和scanf()
  3. 按读取和写入数据缓冲区(即字符数组)分可以得到: sprintf(),snprintf()和sscanf()

示例代码:

#include <stdio.h>

int main() {
    FILE *fp = fopen("example.txt", "w");
    if (fp == NULL) {
        perror("无法打开文件");
        return 1;
    }

    int num = 100;
    float pi = 3.14;
    char str[] = "Hello, World!";
    fprintf(fp, "数字: %d, 圆周率: %.2f, 字符串: %s\n", num, pi, str);

    fclose(fp);
    return 0;
}

按数据块读写文件

缓冲区是文件I/O操作中用于暂存数据的内存区域,它在读写文件时起到重要作用。缓冲区的使用可以提高文件读写的效率,减少直接磁盘访问的次数。下面是一些标准IO提供的带有缓存区操作的API接口。

函数描述参数返回值
fread(void *ptr, size_t size, size_t nmemb, FILE *fp)从文件读取数据块ptr - 存储读取数据的缓冲区,size - 每块数据大小,nmemb - 读取的块数,fp - 文件指针成功返回读取的块数,失败返回小于请求的块数
fwrite(const void *ptr, size_t size, size_t nmemb, FILE *fp)向文件写入数据块ptr - 要写入的数据缓冲区,size - 每块数据大小,nmemb - 写入的块数,fp - 文件指针成功返回写入的块数,失败返回小于请求的块数

示例代码:
fread()

#include <stdio.h>
#include <stdlib.h>

int main() {
    FILE *fp;
    char buffer[100];
    size_t bytesRead;

    // 打开文件以读取模式
    fp = fopen("example.txt", "rb");
    if (fp == NULL) {
        perror("无法打开文件");
        return 1;
    }

    // 从文件读取数据到缓冲区
    bytesRead = fread(buffer, sizeof(char), sizeof(buffer) - 1, fp);
    if (bytesRead < sizeof(buffer) - 1 && ferror(fp)) {
        printf("读取文件时遇到错误\n");
        fclose(fp);
        return 1;
    }

    // 确保缓冲区以NULL结尾
    buffer[bytesRead] = '\0';

    printf("读取的数据: %s\n", buffer);

    // 关闭文件
    fclose(fp);
    return 0;
}

fwrite()

#include <stdio.h>
#include <stdlib.h>

int main() {
    FILE *fp;
    const char *data = "这是要写入文件的测试数据。";

    // 打开文件以写入模式
    fp = fopen("example.txt", "wb");
    if (fp == NULL) {
        perror("无法打开文件");
        return 1;
    }

    // 将数据写入文件
    if (fwrite(data, sizeof(char), strlen(data), fp) != strlen(data)) {
        printf("写入文件时遇到错误\n");
        fclose(fp);
        return 1;
    }

    printf("数据已成功写入文件\n");

    // 关闭文件
    fclose(fp);
    return 0;
}

数据缓冲区

标准IO是系统IO的封装,通过将系统IO的文件描述符填入结构体 FILE 中,并为文件分配缓冲区和设置缓冲区类型,提供更高效的文件读写操作。

按缓存区的类型可以分成三类,以下是关于标准IO缓冲区类型的图表,总结了不同缓冲区类型及其特性:

缓冲区类型描述默认应用场景何时冲洗数据
不缓冲类型数据一旦有,即刻冲洗到文件标准错误输出每次写操作后立刻冲洗
全缓冲类型缓冲区填满时冲洗到文件,程序正常退出、调用 fflush() 等情况冲洗普通文件缓冲区满、程序退出、fflush()、关闭文件、读取文件内容
行缓冲类型遇到换行符 \n 时冲洗到文件,同全缓冲类型其他条件冲洗标准输出遇到换行符、缓冲区满、程序退出、fflush()、关闭文件

标准输出与标准出错的缓冲行为

#include <stdio.h>

int main(void) {
    // 标准输出是行缓冲,因此 "abcd" 不会立刻显示
    fprintf(stdout, "abcd");

    // 标准出错是不缓冲,因此 "1234" 会立刻显示
    fprintf(stderr, "1234");

    // 强制刷新标准输出缓冲区
    fflush(stdout);

    return 0;
}

执行结果

1234abcd

普通文件的全缓冲行为

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *fp = fopen("a.txt", "w+");
    if (fp == NULL) {
        perror("无法打开文件");
        return 1;
    }

    // 普通文件默认是全缓冲,因此 "abcd" 将滞留在缓冲区
    fputs("abcd\n", fp);

    // 强制终止程序,缓冲区的数据未冲洗到文件
    abort();

    // 关闭文件(不会执行到这里)
    fclose(fp);

    return 0;
}

注意:由于程序被中断,数据未冲洗到文件,文件 a.txt 中没有 “abcd”。

修改文件缓冲区类型为行缓冲

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *fp = fopen("a.txt", "w+");
    if (fp == NULL) {
        perror("无法打开文件");
        return 1;
    }

    // 设置文件缓冲区类型为行缓冲
    char buf[100];
    setvbuf(fp, buf, _IOLBF, sizeof(buf));

    // 行缓冲且有 '\n',数据将被直接冲洗到文件
    fputs("abcd\n", fp);

    // 强制终止程序,但数据已经写入文件
    abort();

    // 关闭文件(不会执行到这里)
    fclose(fp);

    return 0;
}

注意:虽然程序被中断,但数据已经写入文件,文件 a.txt 中有 “abcd”。

总结

标准IO通过缓冲区机制提高文件读写效率,根据不同应用场景选择合适的缓冲区类型:

  • 不缓冲:适用于即时性要求高的输出,如标准错误输出。
  • 全缓冲:适用于普通文件读写,提高写效率。
  • 行缓冲:适用于标准输出等场景,每行输出一次。

正确使用缓冲区和理解缓冲区行为有助于提高程序的健壮性,避免数据丢失。通过 setbufsetvbuf 函数,可以灵活调整缓冲区类型和大小,满足不同需求。

  • 31
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值