3. 标准I/O编程

标准I/O库作为C语言标准库的核心组件,基于系统调用封装,屏蔽了底层硬件与系统差异, 自带缓冲区优化机制,简化了I/O操作流程,兼具跨平台性、易用性和高效性, 是C语言文件操作、数据交互的首选方案。

本章的示例代码目录为:base_linux/file_io/stdio

3.1. 标准I/O库简介

3.1.1. 标准I/O库的定义与起源

标准I/O库(Standard I/O Library)是ANSI C标准定义的通用I/O操作库,头文件为<stdio.h>, 由C语言标准委员会统一规范,所有支持C语言的平台(Linux、Windows、macOS等)均实现了该库, 彻底解决了底层系统调用的跨平台兼容性问题。

标准I/O库的本质是对Linux底层系统调用(read、write、open、close等)的封装, 通过在用户空间构建缓冲区,减少频繁的内核态与用户态切换,降低磁盘I/O次数, 大幅提升数据读写效率,尤其适合大量数据的读写场景。

3.1.2. 标准I/O库的优势

  • 跨平台兼容性:遵循C语言标准,代码无需修改即可在不同操作系统编译运行,摆脱平台依赖;

  • 自带缓冲区优化:内置用户态缓冲区,自动聚合读写数据,减少系统调用次数,提升I/O效率;

  • 操作简洁易用:封装了丰富的高层函数,无需关注底层硬件细节,简化文件打开、读写、关闭等操作;

  • 功能丰富全面:支持字符、字符串、格式化、二进制等多种读写方式,适配各类数据交互场景;

  • 完善的错误处理:搭配全局错误码变量,可精准定位I/O操作失败原因,便于调试排错。

3.1.3. 核心头文件与数据类型

  • 核心头文件:<stdio.h>是标准I/O编程的必备头文件,包含了所有标准I/O函数的声明、宏定义、数据类型定义与结构体原型, 只要程序中使用标准I/O相关函数,就必须包含该头文件,否则会出现函数未定义的编译错误;

  • 核心数据类型:FILE结构体是标准I/O库的核心载体,通常被称为“流”,是对文件、终端、设备等I/O资源的抽象封装, 内部集成了文件描述符、缓冲区起始地址、缓冲区大小、当前读写偏移量、读写状态标志、错误标志等关键信息, 开发者无需关注结构体内部的具体实现细节,只需通过FILE*指针即可完成对I/O资源的所有操作,极大简化了开发流程;

  • 常用宏:EOF(End Of File)是文件结束标志宏,本质值为-1,用于判断文件读取是否完成; NULL是空指针宏,用于初始化指针、判断指针有效性;BUFSIZ是标准I/O默认缓冲区大小宏,通常为4096字节或8192字节, 适配主流文件系统的块大小,是缓冲区操作的常用参考值。

3.2. 标准I/O的缓冲区机制

缓冲区是标准I/O库的核心设计,指在用户空间开辟的一段内存区域,数据先存入缓冲区, 待满足特定条件后,再一次性批量写入内核或磁盘,以此减少系统调用次数,提升程序运行效率。 根据缓冲方式的不同,标准I/O分为全缓冲、行缓冲、无缓冲三类,不同缓冲机制适配不同场景。

3.2.1. 全缓冲

全缓冲只有当缓冲区被填满、执行fflush刷新操作或关闭流时,数据才会从缓冲区写入目标文件/设备, 适合普通磁盘文件的读写操作,是标准I/O操作文件的默认缓冲方式。

触发刷新条件:缓冲区满、调用fflush()、调用fclose()、程序正常退出。

特点:I/O效率最高,适合大量数据的批量读写,但数据实时性较差,缓冲区未刷新前断电会导致数据丢失。

3.2.2. 行缓冲

行缓冲是当缓冲区中出现换行符(\n)、缓冲区被填满、执行刷新操作或关闭流时,数据立即写入目标, 适合标准输入流(stdin)、标准输出流(stdout),适配终端交互场景。

触发刷新条件:遇到换行符、缓冲区满、调用fflush()、调用fclose()、程序正常退出。

特点:兼顾效率与实时性,终端输入输出时,输入回车即可立即处理数据,符合用户交互习惯。

3.2.3. 无缓冲

无缓冲就是不存在缓冲区,数据直接写入目标文件/设备,无任何延迟。 适合标准错误流(stderr),确保错误信息立即输出,便于快速排查程序异常。

特点:数据实时性极强,无丢失风险,但频繁触发系统调用,I/O效率较低。

3.2.4. 缓冲区相关操作函数

3.2.4.1. fflush函数

fflush函数功能是强制刷新指定流的缓冲区,将缓冲区数据立即写入目标。

1
2
3
4
5
6
7
8
// 所需头文件
#include <stdio.h>

// 函数原型
int fflush(FILE *stream);

// 参数:stream-待刷新的流指针,传入NULL则刷新所有打开的流
// 返回值:成功返回0,失败返回EOF

3.2.4.2. setbuf/setvbuf函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 所需头文件
#include <stdio.h>

// setbuf函数原型
void setbuf(FILE *stream, char *buf);

// setvbuf函数原型(更灵活,推荐使用)
int setvbuf(FILE *stream, char *buf, int mode, size_t size);

//参数:
// stream参数:指向FILE对象的指针,该FILE对象标识了一个打开的流
// buffer参数:分配给用户的缓冲,它的长度至少为BUFSIZ字节,BUFSIZ是一个宏常量,表示数组的长度
// mode参数:_IOFBF(全缓冲)、_IOLBF(行缓冲)、_IONBF(无缓冲)
// size参数:自定义缓冲区大小

必须在流打开后、未进行任何读写操作前设置缓冲区。自定义缓冲区需全局声明或动态分配,避免栈内存释放导致的野指针问题。

3.3. 标准输入输出与错误

Linux系统启动后,会默认打开三个标准流,对应终端的输入、输出、错误提示设备, 无需手动打开/关闭,可直接调用标准I/O函数操作,是程序与用户交互的核心通道。

3.3.1. 三大标准流说明

三大标准流如下:

标准流名称

FILE指针

缓冲类型

作用

标准输入流

stdin

行缓冲

接收用户输入的数据

标准输出流

stdout

行缓冲

向终端输出正常数据、提示信息、运行结果

标准错误流

stderr

无缓冲

向终端输出程序错误信息、异常提示

3.3.2. 标准流的重定向

Linux支持标准流重定向,可将默认的终端输入/输出改为文件、管道等其他设备,不修改代码即可实现数据流向切换,常用重定向符号:

  • >:标准输出重定向,例:./test > output.txt;

  • >>:标准输出追加重定向,例:./test >> output.txt;

  • 2>:标准错误重定向,例:./test 2> error.txt;

  • <:标准输入重定向,例:./test < input.txt;

  • &>:标准输出+标准错误一并重定向,例:./test &> all.log。

3.3.3. 标准流常用操作函数

3.3.3.1. 格式化输出函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 所需头文件
#include <stdio.h>

//  printf默认输出至stdout
int printf(const char *format, ...);

// fprintf可指定输出流,stdout/stderr/文件流
int fprintf(FILE *stream, const char *format, ...);

// 参数:
// stream:指向FILE对象的指针,该FILE对象标识了流
// format 字符串,包含了要被写入到流stream中的文本

// 返回值:如果成功,则返回写入的字符总数,否则返回一个负数

格式化输出函数支持%d、%s、%f、%c等多种格式符,可灵活拼接变量与字符串, 实现格式化数据输出,其中fprintf相比printf更灵活,可指定任意流作为输出目标, 是区分正常输出与错误输出的常用函数。

3.3.3.2. 格式化输入函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 所需头文件
#include <stdio.h>

// scanf:默认从stdin读取数据
int scanf(const char *format, ...);

// fscanf:可指定输入流
int fscanf(FILE *stream, const char *format, ...);

// 参数:
// stream:指向FILE对象的指针,该FILE对象标识了流
// format 字符串,包含了以下各项中的一个或多个:
//             空格字符、非空格字符和format说明符

// 返回值:如果成功,该函数返回成功匹配和赋值的个数。如果到达文件末尾或发生读错误,则返回EOF

格式化输入函数需严格匹配格式符与变量类型,且变量需传入地址符,否则会出现内存访问异常; fscanf可实现从文件读取格式化数据,是解析配置文件、结构化文本的常用函数。

3.4. 标准I/O库核心操作函数

标准库实际是对系统调用再次进行了封装,使用C标准库编写的代码,能方便地在不同的系统上移植。 例如Windows系统打开文件操作的系统API为OpenFile,Linux则为open,C标准库都把它们封装为fopen,Windows下的C库会通过fopen调用OpenFile函数实现操作, 而Linux下则通过glibc调用open打开文件。用户代码如果使用fopen,那么只要根据不同的系统重新编译程序即可,而不需要修改对应的代码。

标准I/O库的文件操作遵循固定的标准化流程:打开流->读写流->定位流->关闭流,每个环节都对应专属的标准I/O函数, 支持字符、字符串、格式化、二进制等多种数据读写方式,可适配不同业务场景的I/O需求。

3.4.1. 打开流

3.4.1.1. fopen/fdopen函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 所需头文件
#include <stdio.h>

// 通过文件路径打开文件流,是实际开发中最常用的打开方式
FILE *fopen(const char *path, const char *mode);

// 将底层系统调用的文件描述符,转换为标准I/O流指针
FILE *fdopen(int fd, const char *mode);

// 参数:
// path:待打开文件的路径字符串,支持相对路径与绝对路径
// mode:文件打开模式字符串,指定读写权限、操作模式
// fd:底层系统调用获取的有效文件描述符,int类型

// 返回值:
// 成功打开流,返回对应的FILE*类型流指针;
// 失败返回NULL,可通过errno获取具体错误原因

mode可以是以下表格中的值:

模式

功能

文件不存在

文件存在

r

只读,文本模式

报错,返回NULL

正常打开,从文件开头读取

r+

读写,文本模式

报错,返回NULL

正常打开,支持读写,不清空原有内容

w

只写,文本模式

自动创建新文件

清空原有内容,从文件开头写入

w+

读写,文本模式

自动创建新文件

清空原有内容,支持读写操作

a

追加只写,文本模式

自动创建新文件

保留原有内容,从文件末尾追加写入

a+

追加读写,文本模式

自动创建新文件

保留原有内容,写入默认在末尾,读取可任意定位

在基础打开模式后追加b字符(如rb、wb、ab),表示切换为二进制模式,专门用于处理图片、音频、视频、可执行文件等非文本文件, 避免文本模式下对换行符、特殊字符的自动转换;Linux系统对文本模式与二进制模式不做区分,两种模式行为一致, 但Windows系统存在明显差异,开发跨平台程序时必须严格区分。

3.4.2. 关闭流

3.4.2.1. fclose函数

fclose函数用于自动刷新流缓冲区,将残留数据写入目标文件,释放流占用的内存资源,关闭文件。

1
2
3
4
5
6
7
8
9
// 所需头文件
#include <stdio.h>

// 函数原型
int fclose(FILE *stream);

// 参数:stream 待关闭的有效文件流指针,FILE*类型

// 返回值:成功关闭流,返回0;失败,返回EOF

fclose函数是标准I/O编程中不可或缺的收尾操作,若遗漏关闭流,不仅会造成进程内存资源泄漏,长期运行会耗尽系统资源, 还会导致缓冲区残留数据无法刷入文件,造成数据丢失。

3.4.3. 读写操作函数

3.4.3.1. 单字符读写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 所需头文件
#include <stdio.h>

// 从指定流读取单个字符
int fgetc(FILE *stream);

// 向指定流写入单个字符
int fputc(int c, FILE *stream);

// 参数:
// stream:目标操作流指针,FILE*类型,fgetc对应读流,fputc对应写流
// c:待写入的字符,int类型,传入时会自动转换为unsigned char类型

// 返回值:
// fgetc:读取成功返回读取的字符(int型),读取失败/到达文件尾返回EOF;
// fputc:写入成功返回写入的字符(int型),写入失败返回EOF

单字符读写函数适合逐字符处理文本、解析特殊字符场景,操作粒度细,返回值需严格判断,避免EOF导致的逻辑异常,是基础字符处理的核心函数。

3.4.3.2. 字符串读写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 所需头文件
#include <stdio.h>

// 从指定流读取指定长度的字符串,自动追加字符串结束符'\0'
char *fgets(char *s, int size, FILE *stream);

// 向指定流写入字符串,不自动追加换行符,灵活可控
int fputs(const char *s, FILE *stream);

// 参数:
// s:字符数组/缓冲区指针,fgets用于存储读取的字符串,fputs为待写入的字符串首地址
// size:读取的最大字符数,int类型,实际读取size-1个字符,预留位置存储结束符
// stream:目标操作流指针,FILE*类型,fgets对应读流,fputs对应写流

// 返回值:
// fgets:读取成功返回存储字符串的缓冲区指针s,读取失败/到达文件尾返回NULL
// fputs:写入成功返回非负值,写入失败返回EOF

fgets会限制读取长度,彻底杜绝缓冲区溢出漏洞,相比已弃用的gets函数更安全; fputs无自动换行,可精准控制输出格式,适合拼接字符串输出场景,二者是文本字符串读写的最优组合。

3.4.3.3. 二进制读写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 所需头文件
#include <stdio.h>

// 二进制读函数:从指定流读取批量二进制数据,适用于结构体、数组等非格式化数据
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);

// 参数:
// ptr:数据存储缓冲区地址,fread为读取数据的存储区,fwrite为待写入数据的起始地址
// size:单个数据单元的字节数,size_t无符号整型,如单字节char为1,整型int为4
// nmemb:期望读写的数据单元个数,size_t无符号整型
// stream:目标操作流指针,FILE*类型,需以对应读写模式打开

// 返回值:成功读写的数据单元个数,出错或到达文件末尾返回小于nmemb的数值

二进制读写函数以数据块为单位操作,不做任何数据转换,适合结构体、数组、二进制资源等非格式化数据的读写, 效率远高于格式化读写,是存储结构化数据、二进制文件的核心选择。

3.4.4. 流定位函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 所需头文件
#include <stdio.h>

// 获取当前流的读写偏移量,可用于计算文件大小
long ftell(FILE *stream);

// 设置流的读写偏移量,实现文件随机读写
int fseek(FILE *stream, long offset, int whence);

// 重置读写偏移量至文件开头,等价于fseek(stream, 0, SEEK_SET),无返回值
void rewind(FILE *stream);

// 参数:
// stream:待定位的文件流指针,FILE*类型
// offset:偏移量,long类型,正数向后偏移,负数向前偏移
// whence:偏移基准位置,可选值:
//      SEEK_SET(以文件开头为基准偏移)、SEEK_CUR(以当前偏移量为基准偏移)、SEEK_END(以文件末尾为基准偏移)

// 返回值:
// ftell:成功返回当前读写偏移量,失败返回-1L;
// fseek:成功返回0,失败返回非0值;
// rewind:无返回值,执行后流偏移量重置为文件开头,同时清除流错误标志

流定位函数打破了文件顺序读写的限制,实现随机读写,适合大文件定位修改、结构化数据随机访问场景, ftell可配合fseek实现文件大小计算、偏移量回溯,是高级文件操作的必备函数。

3.4.5. 错误处理函数

3.4.5.1. ferror函数

ferror函数用于检测指定的文件流是否发生了读写错误。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 所需头文件
#include <stdio.h>

// 函数原型
int ferror(FILE *stream);

// 参数:stream 指向已打开文件流的指针,用于指定要检测的目标流

// 返回值:
// 非零值:该流上发生过错误
// 0     :该流上未发生错误

3.4.5.2. feof函数

feof函数用于检测文件流的文件结束标志(EOF)是否被置位,用于判断是否已读取到文件末尾。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 所需头文件
#include <stdio.h>

// 函数原型
int feof(FILE *stream);

// 参数:stream 指向已打开文件流的指针,用于指定要检测的目标流

// 返回值:
//   非零值 - 已到达文件末尾(EOF标志被置位)
//   0      - 尚未到达文件末尾

3.4.5.3. clearerr函数

clearerr函数用于清除指定文件流的错误标志和文件结束标志(EOF),使流恢复到正常状态。

1
2
3
4
5
6
7
// 所需头文件
#include <stdio.h>

// 函数原型
void clearerr(FILE *stream);

// 参数:stream 指向已打开文件流的指针,用于指定要重置的目标流

3.4.5.4. perror函数

perror函数用于将上一个系统调用或库函数产生的错误原因,以人类可读的方式打印到标准错误流(stderr)。

1
2
3
4
5
6
7
// 所需头文件
#include <stdio.h>

// 函数原型
void perror(const char *s);

// 参数:s 自定义的提示字符串。若不为NULL,函数会先打印该字符串,后跟一个冒号和空格,再打印错误描述;若为NULL,直接打印错误描述

3.4.5.5. strerror函数

strerror函数用于将整型的错误码转换为对应的错误描述字符串,适用于自定义日志系统。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 所需头文件
#include <string.h>
#include <errno.h>

// 函数原型
char *strerror(int errnum);

// 参数:errnum 错误码,通常直接传入全局变量errno

// 返回值:
//   成功:返回一个指向静态错误描述字符串的指针(char*类型),该字符串由系统管理,无需手动释放
//   失败:若传入的错误码无效,返回一个指向"Unknown error"(或类似描述)的字符串指针

3.5. 标准I/O示例

3.5.1. 文件操作实验

3.5.1.1. 程序源码

下面我们使用C标准库进行文件操作实验,如下所示。

文件操作实验-C标准库(base_linux/file_io/stdio1/stdio.c文件)
 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
#include <stdio.h>
#include <string.h>

//要写入的字符串
const char buf[] = "filesystem_test:Hello World!\n";
//文件描述符
FILE *fp;
char str[100];


int main(void)
{
    //创建一个文件
    fp = fopen("filesystem_test.txt", "w+");
    //正常返回文件指针
    //异常返回NULL
    if(NULL == fp){
        printf("Fail to Open File\n");
        return 0;
    }
    //将buf的内容写入文件
    //每次写入1个字节,总长度由strlen给出
    fwrite(buf, 1, strlen(buf), fp);

    //写入Embedfire
    //每次写入1个字节,总长度由strlen给出
    fwrite("Embedfire\n", 1, strlen("Embedfire\n"),fp);

    //把缓冲区的数据立即写入文件
    fflush(fp);

    //此时的文件位置指针位于文件的结尾处,使用fseek函数使文件指针回到文件头
    fseek(fp, 0, SEEK_SET);

    //从文件中读取内容到str中
    //每次读取100个字节,读取1次
    fread(str, 100, 1, fp);

    printf("File content:\n%s \n", str);

    fclose(fp);

    return 0;
}

如果之前有学习过C语言的文件操作,本实验代码非常容易理解, 它的流程就是使用fopen创建文件、使用fwrite写入内容, 使用fflush确保缓冲区的内容写到文件, 然后使用fseek重置文件位置指针,使用fread把文件的内容读出,最后调用fclose关闭文件。

其中的fopen函数调用时使用了参数“w+”,表示每次都创建新的空文件,且带上读权限打开,函数调用后得到文件描述符fp,在它后面的fwrite、fread、fflush等函数都是通过这个fp文件描述符访问该文件的。

3.5.1.2. Makefile说明

Makefile是跟工程目录匹配的,本实验仅有一个C文件,且与Makefile处于同级目录。

Makefile内容如下所示:

文件操作实验-C标准库(base_linux/file_io/stdio1/Makefile文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#定义变量
TARGET = stdio
#定义编译器
CC = gcc
#定义头文件的位置()
CFLAGS = -I.
#定义头文件
DEPS = 
#定义目标文件
OBJS = $(TARGET).o

#目标文件
$(TARGET): $(OBJS)
	$(CC) -o $@ $^ $(CFLAGS)

#*.o文件的生成规则
%.o: %.c $(DEPS)
	$(CC) -c -o $@ $< $(CFLAGS)

#伪目标
.PHONY: clean
#make clean清除编译结果
clean:
	rm -f $(TARGET) $(TARGET).o

3.5.1.3. 编译及测试

使用如下步骤进行编译测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#编译程序
make

#执行程序
./stdio

#信息输出如下
File content:
filesystem_test:Hello World!
Embedfire

#查看创建的文件
cat filesystem_test.txt

#信息输出如下
filesystem_test:Hello World!
Embedfire

3.5.2. 标准输入输出终端交互实验

3.5.2.1. 程序源码

下面我们使用C标准库进行标准输入输出终端交互实验,如下所示。

标准输入输出终端交互实验(base_linux/file_io/stdio2/stdio.c文件)
 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
#include <stdio.h>

int main() {
    // 定义存储用户信息的变量
    char name[30];
    int age;

    // 标准输出打印提示信息,行缓冲遇换行符刷新,实时显示
    printf("===== 用户信息录入系统 =====\n");
    printf("请输入姓名:");

    // 从标准输入读取姓名,限制长度避免缓冲区溢出
    fgets(name, sizeof(name), stdin);

    // 处理fgets读取的换行符,优化输出效果
    for (int i = 0; name[i] != '\0'; i++) {
        if (name[i] == '\n') {
            name[i] = '\0';
            break;
        }
    }

    printf("请输入年龄:");

    // 从标准输入读取年龄,接收返回值用于校验
    int ret = scanf("%d", &age);

    // 错误处理:输入格式错误,通过标准错误流实时输出
    if (ret != 1) {
        // 标准错误流无缓冲,信息立即打印
        fprintf(stderr, "输入错误:年龄必须为合法整数!\n");
        return -1;
    }
    
    // 年龄合法性校验
    if (age < 0 || age > 150) {
        fprintf(stderr, "输入错误:年龄超出合法范围(0-150)!\n");
        return -1;
    }

    // 打印最终录入结果
    printf("\n===== 录入结果 =====\n");
    printf("姓名:%s\n", name);
    printf("年龄:%d\n", age);
    printf("信息录入成功!\n");

    return 0;
}

以上基于三大标准流,实现用户信息录入与校验,从键盘读取用户姓名、年龄,通过标准输出打印录入结果,参数异常时通过标准错误流实时输出提示。

3.5.2.2. Makefile说明

Makefile与stdio1示例的Makefile一致,此处不重复说明。

3.5.2.3. 编译及测试

使用如下步骤进行编译测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#编译程序
make

#执行程序,依次输入姓名和年龄
./stdio

#信息输出如下
===== 用户信息录入系统 =====
请输入姓名:张三
请输入年龄:18

===== 录入结果 =====
姓名:张三
年龄:18
信息录入成功!

3.5.3. 二进制文件读写实验

3.5.3.1. 程序源码

下面我们使用C标准库进行二进制文件读写实验,如下所示。

二进制文件读写实验(base_linux/file_io/stdio3/stdio.c文件)
 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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 定义学生信息结构体,用于存储结构化数据
typedef struct {
    char name[20];
    int id;
    float score;
} Student;

int main() {
    // 初始化待存储的学生数据
    Student stu1 = {"张三", 1001, 95.5};
    Student stu2;  // 用于存储读取的学生数据

    // 1. 以二进制只写模式打开文件,存储结构体数据
    FILE *fp = fopen("student.bin", "wb");
    if (fp == NULL) {
        perror("文件打开失败");
        exit(-1);
    }
    // 二进制写入结构体数据,1表示写入1个数据单元
    fwrite(&stu1, sizeof(Student), 1, fp);
    printf("学生信息二进制写入成功!\n");
    fclose(fp);  // 关闭写流

    // 2. 以二进制只读模式打开文件,读取结构体数据
    fp = fopen("student.bin", "rb");
    if (fp == NULL) {
        perror("文件读取失败");
        exit(-1);
    }
    // 二进制读取数据,存入stu2变量
    fread(&stu2, sizeof(Student), 1, fp);
    // 打印解析后的结构化数据
    printf("===== 学生信息读取结果 =====\n");
    printf("学号:%d\n姓名:%s\n成绩:%.1f\n", stu2.id, stu2.name, stu2.score);
    fclose(fp);

    return 0;
}

以上基于fread、fwrite二进制读写函数,实现结构体数据的文件存储与读取,解决结构化数据持久化问题。

3.5.3.2. Makefile说明

Makefile与stdio1示例的Makefile一致,此处不重复说明。

3.5.3.3. 编译及测试

使用如下步骤进行编译测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#编译程序
make

#执行程序
./stdio

#信息输出如下
学生信息二进制写入成功!
===== 学生信息读取结果 =====
学号:1001
姓名:张三
成绩:95.5

3.6. 如何决择

标准I/O库与底层系统调用的区别如下:

对比维度

标准I/O库

底层系统调用

依赖关系

依赖C标准库,跨平台通用

依赖操作系统内核,平台专属

缓冲区

自带用户态缓冲区,效率更高

无用户态缓冲区,直接操作内核

操作单位

以流(FILE*)为操作单位

以文件描述符(int)为操作单位

适用场景

常规文件读写、数据交互、跨平台程序

底层开发、高性能I/O、内核相关程序

既然C标准库和系统调用都能够操作文件,那么应该选择哪种操作呢?考虑的因素如下:

  • 使用系统调用会影响系统的性能。执行系统调用时,Linux需要从用户态切换至内核态, 执行完毕再返回用户代码,所以减少系统调用能减少这方面的开销。 如库函数写入数据的文件操作fwrite最后也是执行了write系统调用, 如果是写少量数据的话,直接执行write可能会更高效,但如果是频繁的写入操作, 由于fwrite的缓冲区可以减少调用write的次数,这种情况下使用fwrite能更节省时间。

  • 硬件本身会限制系统调用本身每次读写数据块的大小。如针对某种存储设备的write函数每次可能必须写4kB的数据 那么当要写入的实际数据小于4kB时,write也只能按4kB写入,浪费了部分空间, 而带缓冲区的fwrite函数面对这种情况,会尽量在满足数据长度要求时才执行系统调用,减少空间开销。

  • 也正是由于库函数带缓冲区,使得我们无法清楚地知道它何时才会真正地把内容写入到硬件上, 所以在需要对硬件进行确定的控制时,我们更倾向于执行系统调用。