
C程序设计教程:用几个小项目练习文件操作和结构体
文件操作和结构体常常一起出现在实际程序中:结构体描述一条记录,文件负责把记录保存下来,供程序下次启动时读取。成绩管理、库存清单、运行日志等,都可以从这两个基础能力入手。
本文通过三个小项目练习文件处理:统计文本文件的字节数和行数;将学生记录写入二进制文件并读回;用文本文件保存简单的库存清单。示例使用标准 C 库,面向 C11,代码可以分别保存为源文件编译运行。重点不仅是“怎样读写”,还包括文件打开失败、输入格式、二进制兼容性和资源释放等容易被忽略的边界。
开始之前:打开文件、检查结果、关闭文件
C 标准库通过 FILE * 表示文件流。调用 fopen 时应检查返回值:如果文件不存在、权限不足或路径无效,返回值可能是空指针。读写结束后应调用 fclose;它也可能报告写入或刷新失败,因此重要数据不能无条件忽略其返回值。
| 模式 | 用途 | 要点 |
|---|---|---|
"r" | 读取文本 | 文件必须已存在。 |
"w" | 写入文本 | 会创建文件;若文件已存在,原内容会被截断。 |
"a" | 追加文本 | 写入位置在文件末尾;文件不存在时会尝试创建。 |
"rb"、"wb" | 读取、写入二进制数据 | 明确以二进制方式打开,适合按字节处理文件。 |
文本模式和二进制模式在某些系统上可能有换行转换等差异,因此处理原始字节时使用带 b 的模式。路径的具体写法、权限和可访问范围由操作系统及运行环境决定;标准 C 并不提供一套跨平台的目录管理或访问控制方案。
项目一:统计文本文件的字节数和行数
这个小工具读取指定文件,统计实际读到的字节数和行数。它适合检查日志、源代码或普通文本文件的规模,也能练习最基础的逐字节读取。
这里把一行定义为:每个换行符结束一行;文件末尾若还有没有换行符结束的内容,也算一行。因此空文件有 0 行,只有一个换行符的文件有 1 行。程序按字节计数,不统计 Unicode 字符数量;UTF-8 汉字通常占多个字节。
#include <stdio.h>
int main(int argc, char **argv)
{
FILE *fp;
int ch;
unsigned long long bytes = 0;
unsigned long long lines = 0;
int has_unterminated_line = 0;
if (argc != 2) {
fprintf(stderr, "用法: %s 文件名\n", argv[0]);
return 1;
}
fp = fopen(argv[1], "rb");
if (fp == NULL) {
perror("无法打开文件");
return 1;
}
while ((ch = fgetc(fp)) != EOF) {
++bytes;
if (ch == '\n') {
++lines;
has_unterminated_line = 0;
} else {
has_unterminated_line = 1;
}
}
if (ferror(fp)) {
perror("读取文件失败");
fclose(fp);
return 1;
}
if (has_unterminated_line) {
++lines;
}
if (fclose(fp) != 0) {
perror("关闭文件失败");
return 1;
}
printf("字节数: %llu\n", bytes);
printf("行数: %llu\n", lines);
return 0;
}
在支持 GCC 或 Clang 的环境中,可以这样编译和运行:
cc -std=c11 -Wall -Wextra -pedantic file_stats.c -o file_stats
./file_stats notes.txt
fgetc 返回类型是 int,而不是 char。这是为了既能表示所有可能的字节值,也能表示特殊的 EOF。如果先把返回值存入 char,再判断是否等于 EOF,某些字节值可能和文件结束标记混淆。
这个程序对文件逐字节扫描,时间复杂度与文件大小成正比。它不把整个文件读入内存,因而适合较大的普通文件;但计数器在理论上仍有上限,极端大文件可能造成无符号计数器回绕。对常规练习文件不必过度处理,对有严格规模要求的程序则应增加溢出检查或采用更合适的计数类型。
项目二:用结构体保存学生记录
结构体把相关字段组合在一起,比用几个平行数组分别保存学号、姓名和成绩更容易维护。下面的程序把两条学生记录写入二进制文件,再读回并显示。记录中的姓名使用定长字符数组,因此要确保字符串长度小于数组容量,并以空字符结尾。
#include <stdio.h>
#include <string.h>
#define STUDENT_COUNT 2
#define NAME_CAPACITY 32
typedef struct {
int id;
char name[NAME_CAPACITY];
double score;
} Student;
int main(void)
{
const char *path = "students.dat";
Student input[STUDENT_COUNT] = {
{1001, "Lin", 88.5},
{1002, "Ming", 92.0}
};
Student output[STUDENT_COUNT];
FILE *fp;
size_t count;
size_t i;
fp = fopen(path, "wb");
if (fp == NULL) {
perror("无法创建文件");
return 1;
}
count = fwrite(input, sizeof input[0], STUDENT_COUNT, fp);
if (count != STUDENT_COUNT) {
fprintf(stderr, "写入记录失败\n");
fclose(fp);
return 1;
}
if (fclose(fp) != 0) {
perror("关闭输出文件失败");
return 1;
}
fp = fopen(path, "rb");
if (fp == NULL) {
perror("无法读取文件");
return 1;
}
count = fread(output, sizeof output[0], STUDENT_COUNT, fp);
if (count != STUDENT_COUNT) {
fprintf(stderr, "记录数量不足或文件读取失败\n");
fclose(fp);
return 1;
}
if (fclose(fp) != 0) {
perror("关闭输入文件失败");
return 1;
}
for (i = 0; i < STUDENT_COUNT; ++i) {
if (memchr(output[i].name, '\0', NAME_CAPACITY) == NULL) {
fprintf(stderr, "第 %zu 条记录的姓名格式无效\n", i);
return 1;
}
printf("学号: %d, 姓名: %s, 成绩: %.1f\n",
output[i].id,
output[i].name,
output[i].score);
}
return 0;
}
编译运行后,程序会在当前工作目录创建 students.dat。注意,当前工作目录不一定是源文件所在目录,应以运行程序时所在的位置为准。
cc -std=c11 -Wall -Wextra -pedantic students.c -o students
./students
fwrite 和 fread 的第三个参数表示要处理多少个对象,返回值是实际成功处理的对象数量。二者都可能少于请求数量,不能仅因为函数没有崩溃就认定操作成功。示例在读取后检查姓名数组是否含有结束符,避免直接用 %s 输出一个不含结束符的字符数组而越界读取。
直接写结构体到文件的边界
这个示例适用于同一程序、同一环境下的入门练习,不应把这种文件格式当成跨平台的永久数据格式。结构体可能包含编译器填充字节;int 的宽度、字节序、浮点数表示和结构体布局也可能随平台或编译选项变化。换一台机器、换一个编译器或调整结构体定义后,旧文件未必还能正确读取。
更重要的是,二进制文件中的字段不应被无条件信任。示例中的记录由本程序刚写入,因此格式受控;如果文件来自用户上传、网络下载或其他未知来源,读取程序应先验证文件大小、记录数量、每个字段的合法范围和字符串终止情况,再使用数据。对外部输入,不能只靠“文件读进结构体成功”来证明内容可信。
如果需要长期保存或跨系统交换数据,应定义明确的文件格式,例如逐字段编码固定宽度整数、规定字节序和版本号,或采用具有明确规范的序列化格式。不要通过把结构体指针转换成字符指针,就假设内存布局天然等于稳定的文件格式。
项目三:用文本文件保存库存清单
对于需要人工查看和简单编辑的数据,文本文件往往比二进制文件直观。下面的程序用一个结构体表示库存商品,并将记录保存为“编号|数量|名称”格式。程序会读取一行,按分隔符切开,再检查编号和数量是否为合法整数。
为了让示例保持简单,名称中不允许包含竖线、换行符或回车符;文件也不支持引号转义。这不是完整的 CSV 实现。实际数据需要这些能力时,应明确扩展格式,或使用成熟的 CSV 解析器,而不是用简单的字符串切分假装支持任意 CSV。
#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define NAME_CAPACITY 64
#define LINE_CAPACITY 256
typedef struct {
int id;
int quantity;
char name[NAME_CAPACITY];
} Item;
static int parse_int(const char *text, int *value)
{
char *end;
long parsed;
errno = 0;
parsed = strtol(text, &end, 10);
if (text == end || errno == ERANGE ||
parsed < INT_MIN || parsed > INT_MAX) {
return 0;
}
while (*end != '\0' && isspace((unsigned char)*end)) {
++end;
}
if (*end != '\0') {
return 0;
}
*value = (int)parsed;
return 1;
}
int main(void)
{
const char *path = "inventory.txt";
Item items[] = {
{101, 12, "Notebook"},
{205, 7, "Pencil"}
};
const size_t item_count = sizeof items / sizeof items[0];
FILE *fp;
size_t i;
char line[LINE_CAPACITY];
size_t line_number = 0;
fp = fopen(path, "w");
if (fp == NULL) {
perror("无法创建库存文件");
return 1;
}
for (i = 0; i < item_count; ++i) {
if (strchr(items[i].name, '|') != NULL ||
strchr(items[i].name, '\n') != NULL ||
strchr(items[i].name, '\r') != NULL) {
fprintf(stderr, "商品名称包含当前格式不支持的字符\n");
fclose(fp);
return 1;
}
if (fprintf(fp, "%d|%d|%s\n",
items[i].id,
items[i].quantity,
items[i].name) < 0) {
perror("写入库存文件失败");
fclose(fp);
return 1;
}
}
if (fclose(fp) != 0) {
perror("关闭库存文件失败");
return 1;
}
fp = fopen(path, "r");
if (fp == NULL) {
perror("无法读取库存文件");
return 1;
}
while (fgets(line, sizeof line, fp) != NULL) {
char *first;
char *second;
char *name;
size_t length;
Item item;
++line_number;
length = strlen(line);
if (length > 0 && line[length - 1] == '\n') {
line[--length] = '\0';
} else if (!feof(fp)) {
fprintf(stderr, "第 %zu 行超过缓冲区限制\n", line_number);
fclose(fp);
return 1;
}
first = strchr(line, '|');
if (first == NULL) {
fprintf(stderr, "第 %zu 行缺少分隔符\n", line_number);
fclose(fp);
return 1;
}
*first = '\0';
second = strchr(first + 1, '|');
if (second == NULL) {
fprintf(stderr, "第 %zu 行缺少名称字段\n", line_number);
fclose(fp);
return 1;
}
*second = '\0';
name = second + 1;
if (strchr(name, '|') != NULL ||
!parse_int(line, &item.id) ||
!parse_int(first + 1, &item.quantity) ||
item.id <= 0 || item.quantity < 0 ||
name[0] == '\0' ||
strlen(name) >= sizeof item.name) {
fprintf(stderr, "第 %zu 行的数据无效\n", line_number);
fclose(fp);
return 1;
}
item.name[0] = '\0';
strcpy(item.name, name);
printf("编号: %d, 数量: %d, 名称: %s\n",
item.id, item.quantity, item.name);
}
if (ferror(fp)) {
perror("读取库存文件失败");
fclose(fp);
return 1;
}
if (fclose(fp) != 0) {
perror("关闭库存文件失败");
return 1;
}
return 0;
}
cc -std=c11 -Wall -Wextra -pedantic inventory.c -o inventory
./inventory
程序第一次以 "w" 打开文件,会覆盖已有的 inventory.txt。这是演示创建数据文件,不是通用的“保存修改”策略。若需要保留旧内容,可以使用追加模式,但追加可能产生重复记录;若要更新某一条记录,常见做法是读取并校验全部数据,再写入临时文件,确认成功后再以平台适用的方式替换原文件。
strtol 比 atoi 更适合解析输入,因为它可以区分无法转换和超出范围等情况。示例先重置 errno,检查转换是否发生、是否溢出,并确认数字后面没有非空白的多余字符。读入数字后还进一步验证业务范围:编号必须为正,数量不能为负。具体范围要由应用需求决定,不能仅凭数据类型允许取值就视为业务上合理。
这里的 fgets 读取一整行,避免了对输入长度没有限制的字符串读入方式。行缓冲区容量是 256 字节,不含结尾空字符;超过容量的行会被拒绝,而不是悄悄把一条记录拆成多次处理。若修改缓冲区大小,应同时检查字段容量和格式约束。
常见错误与检查清单
- 忘记检查
fopen。打开失败后继续调用读写函数,会导致无效指针被使用。每次打开都要处理失败分支。 - 认为
"w"是安全的追加写入。"w"会截断已有文件。需要保留原数据时,应先明确采用追加、临时文件替换或其他保存策略。 - 忽略短读和短写。磁盘空间不足、文件损坏或其他 I/O 错误都可能让实际处理数量小于预期。检查
fread、fwrite的返回值,并在读取循环结束后检查ferror。 - 把
fgetc的结果存进char再比较 EOF。应先用int接收返回值。 - 把文本文件和字符数混为一谈。字节数不等于字符数,也不等于屏幕上显示的符号数量;多字节编码需要专门的编码处理。
- 输出不可信的字符数组。用
%s输出的字符数组必须有结束符。读取二进制记录或外部数据后,要验证边界和字符串终止情况。 - 把结构体二进制文件当成通用格式。内存布局、类型表示和字节序都可能变化。教学示例与长期存储格式是不同问题。
- 不检查文本解析结果。缺字段、多余分隔符、超长行、非法数字和不符合业务范围的值都应有明确处理。
- 忽略关闭文件的结果。写入可能在关闭时才暴露失败。对重要输出,检查
fclose的返回值。
怎样从练习走向实际程序
完成这三个项目后,可以逐步增加功能:给库存文件增加记录数量上限;提供新增、查询和修改命令;将学生记录按成绩排序;为文件格式加入版本号;在写入前验证所有数据;测试空文件、缺少换行符的末行、超长行、缺字段和无法打开文件等情况。
文件操作的核心不是某一个函数,而是把输入、格式、资源和失败路径一起设计好。结构体适合组织程序中的数据,但不自动解决持久化兼容问题;文本格式便于查看,但仍需定义分隔规则和字段约束。先明确数据从哪里来、会保存多久、是否可能被其他程序读取,再选择合适的文件格式,才能把练习代码稳妥地发展为真实功能。









