第7章 输入与输出
程序与外界的交互
一、导读
1.1 本章学习目标
第7章讲解C语言的输入输出机制。程序不是孤立存在的——它需要从用户获取输入、从文件读取数据、向屏幕输出结果、向文件写入数据。C语言提供了两套I/O系统:标准I/O库(<stdio.h>)和底层系统调用。本章主要讲解标准I/O库,它提供了高级的、可移植的、带缓冲的I/O操作。
本章的核心学习目标包括:
- 掌握标准I/O流:
stdin、stdout、stderr三个标准流的概念和使用。 - 精通 `printf` 和 `scanf`:格式化输出和输入的完整语法,包括各种格式说明符和修饰符。
- 掌握字符和行I/O:
getchar、putchar、fgets、fputs的使用。 - 学会文件操作:
fopen、fclose、fread、fwrite的完整流程。 - 理解文件定位:
fseek、ftell、rewind实现文件的随机访问。 - 理解文件缓冲机制:全缓冲、行缓冲、无缓冲的区别和影响。
1.2 为什么本章如此重要
I/O是程序与外界交互的唯一通道。一个不能读写数据的程序毫无用处。C语言的I/O系统看似简单(只有几个函数),但其中隐藏着许多细节和陷阱。
printf 的格式化语法看似直观,但格式说明符与参数类型不匹配是未定义行为——这在64位系统上尤其容易出问题。scanf 的返回值经常被忽略,导致输入错误无法被检测到。文件操作中忘记检查 fopen 的返回值、忘记 fclose、混淆文本模式和二进制模式,都是常见的错误来源。
K&R 在第七章的讲解简洁而全面。标准I/O库是C语言中最常用的库之一,理解它的工作原理——特别是缓冲机制——对于编写高效、正确的I/O程序至关重要。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
printf("%d\n", (int)sizeof(int)) 为什么可能输出错误的值?格式说明符和参数类型不匹配会怎样?
scanf 的返回值是什么?为什么检查返回值很重要?
文本文件和二进制文件在读写时有什么区别?什么时候必须用二进制模式?
为什么 printf 输出到终端时通常不需要手动刷新缓冲区,而写入文件时需要?
fread 和 fwrite 与 fprintf 和 fscanf 有什么区别?什么时候用哪个?
如何实现文件的随机访问?fseek 的各个参数有什么含义?
二、核心概念详解
2.1 标准I/O流
C程序启动时,系统会自动打开三个标准流:
| 流 | 文件指针 | 文件描述符 | 默认连接 |
|---|---|---|---|
| 标准输入 | stdin | 0 | 键盘 |
| 标准输出 | stdout | 1 | 终端屏幕 |
| 标准错误 | stderr | 2 | 终端屏幕 |
#include <stdio.h>
int main(void) {
// 从标准输入读取
fprintf(stdout, "Enter your name: ");
char name[100];
fgets(name, sizeof(name), stdin);
// 输出到标准输出
fprintf(stdout, "Hello, %s", name);
// 输出到标准错误(错误信息应该用stderr)
if (name[0] == '\n') {
fprintf(stderr, "Error: empty input\n");
return 1;
}
return 0;
}为什么错误信息要用 `stderr`? 因为 stdout 通常是行缓冲的(输出到终端时),而 stderr 是无缓冲的。当程序崩溃时,stdout 缓冲区中的内容可能丢失,但 stderr 的内容一定会显示。此外,stdout 可以被重定向到文件,而 stderr 通常保持连接到终端。
./program > output.txt # stdout重定向到文件,stderr仍然显示在终端
./program 2> errors.txt # stderr重定向到文件
./program > out.txt 2>&1 # 两者都重定向到同一文件2.2 printf:格式化输出
2.2.1 格式说明符
printf("format string", arg1, arg2, ...);格式字符串中的 % 后面跟着格式说明符,指定如何输出对应的参数:
| 说明符 | 含义 | 示例 |
|---|---|---|
%d | 有符号十进制整数 | 42 |
%u | 无符号十进制整数 | 42 |
%x / %X | 十六进制(小写/大写) | 2a / 2A |
%o | 八进制 | 52 |
%ld | long 整数 | |
%lld | long long 整数 | |
%f | 浮点数(小数形式) | 3.140000 |
%e / %E | 浮点数(科学计数法) | 3.14e+00 |
%g / %G | 自动选择 %f 或 %e | |
%c | 单个字符 | A |
%s | 字符串 | hello |
%p | 指针地址 | 0x7ffd5e8a |
%% | 百分号本身 | % |
%n | 不输出,将已输出的字符数存入参数 |
2.2.2 格式修饰符
printf("%-10d", x); // 左对齐,最小宽度10
printf("%10d", x); // 右对齐,最小宽度10
printf("%010d", x); // 用0填充,最小宽度10
printf("%+d", x); // 总是显示符号
printf("%.2f", pi); // 小数点后2位
printf("%10.2f", pi); // 最小宽度10,小数点后2位
printf("%*.*f", w, p, pi); // 宽度和精度由参数指定
printf("%zu", sizeof(int)); // size_t 类型(C99)2.2.3 常见陷阱
// 1. 格式说明符与参数类型不匹配
int x = 42;
long y = 100L;
printf("%ld\n", x); // 错误!x是int,应该用%d
printf("%d\n", y); // 错误!y是long,应该用%ld
// 2. 参数个数不匹配
printf("%d %d\n", x); // 未定义行为!缺少第二个参数
// 3. 字符串格式化中的缓冲区溢出
char buf[10];
sprintf(buf, "%s", "this is a very long string"); // 缓冲区溢出!
snprintf(buf, sizeof(buf), "%s", "this is a very long string"); // 安全`printf` 的返回值:成功输出的字符数,出错时返回负值。
2.3 scanf:格式化输入
scanf("format string", &arg1, &arg2, ...);2.3.1 基本用法
int a, b;
float f;
char str[100];
scanf("%d", &a); // 读取整数
scanf("%d %d", &a, &b); // 读取两个整数(空白分隔)
scanf("%f", &f); // 读取浮点数
scanf("%99s", str); // 读取字符串(最多99个字符,留1个给\0)
scanf("%[^\n]", str); // 读取整行(包括空格,直到换行符)注意:scanf 的参数必须是指针(地址),因为 scanf 需要通过指针修改变量的值。忘记 & 是最常见的错误之一。
2.3.2 scanf 的返回值
scanf 返回成功读取并赋值的输入项数。检查返回值是检测输入错误的关键:
int n;
int result = scanf("%d", &n);
if (result == 1) {
printf("Read: %d\n", n);
} else if (result == 0) {
printf("Input was not a number\n");
} else {
printf("EOF or error\n");
}2.3.3 scanf 的常见问题
// 1. 输入缓冲区残留
int n;
char c;
scanf("%d", &n);
scanf("%c", &c); // c会读取到'\n'(上一次输入留下的换行符)
// 解决:在格式字符串中加空格跳过空白
scanf(" %c", &c); // 空格告诉scanf跳过前导空白
// 2. 字符串读取的安全问题
char name[10];
scanf("%s", name); // 如果输入超过9个字符,缓冲区溢出!
scanf("%9s", name); // 安全:限制读取长度
// 3. 匹配失败时的行为
int a, b;
scanf("%d %d", &a, &b); // 如果第一个输入不是数字,a和b都不被赋值2.4 字符I/O
int getchar(void); // 从stdin读取一个字符,返回int(不是char!)
int putchar(int c); // 向stdout输出一个字符
int c;
while ((c = getchar()) != EOF) {
putchar(c); // 原样输出(文件复制)
}为什么 `getchar` 返回 `int`? 因为除了字符值(0-255),它还需要返回 EOF(通常为-1)来表示文件结束或错误。如果用 char 类型接收,可能无法区分某个字符值和 EOF。
2.5 行I/O
char *fgets(char *str, int size, FILE *stream); // 读取一行
int fputs(const char *str, FILE *stream); // 输出一行char line[256];
// 读取一行(包括换行符,如果空间足够)
while (fgets(line, sizeof(line), stdin) != NULL) {
// 去掉末尾的换行符
line[strcspn(line, "\n")] = '\0';
printf("You entered: %s\n", line);
}`fgets` vs `gets`:gets 函数不限制读取长度,存在缓冲区溢出风险,已在C11中被移除。永远不要使用 `gets`,始终使用 fgets。
2.6 文件操作
2.6.1 打开和关闭文件
FILE *fopen(const char *filename, const char *mode);
int fclose(FILE *stream);文件打开模式:
| 模式 | 含义 | 文件不存在 | 文件存在 |
|---|---|---|---|
"r" | 只读(文本) | 失败 | 打开 |
"w" | 只写(文本) | 创建 | 截断 |
"a" | 追加(文本) | 创建 | 追加 |
"r+" | 读写(文本) | 失败 | 打开 |
"w+" | 读写(文本) | 创建 | 截断 |
"rb" | 只读(二进制) | 失败 | 打开 |
"wb" | 只写(二进制) | 创建 | 截断 |
"ab" | 追加(二进制) | 创建 | 追加 |
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("fopen"); // 输出错误信息
exit(1);
}
// ... 使用文件 ...
fclose(fp); // 关闭文件,释放资源始终检查 `fopen` 的返回值! 文件可能不存在、权限不足、磁盘已满等。
2.6.2 格式化文件I/O
// 写入文件
FILE *fp = fopen("output.txt", "w");
fprintf(fp, "Name: %s, Age: %d\n", "Alice", 25);
fprintf(fp, "Score: %.1f\n", 95.5);
fclose(fp);
// 读取文件
fp = fopen("output.txt", "r");
char name[50];
int age;
float score;
fscanf(fp, "Name: %[^,], Age: %d", name, &age);
fscanf(fp, " Score: %f", &score);
fclose(fp);2.6.3 二进制文件I/O
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);// 写入二进制数据
int numbers[] = {1, 2, 3, 4, 5};
FILE *fp = fopen("data.bin", "wb");
fwrite(numbers, sizeof(int), 5, fp);
fclose(fp);
// 读取二进制数据
fp = fopen("data.bin", "rb");
int read_numbers[5];
size_t n = fread(read_numbers, sizeof(int), 5, fp);
fclose(fp);
printf("Read %zu elements: ", n);
for (size_t i = 0; i < n; i++) {
printf("%d ", read_numbers[i]);
}
printf("\n");文本模式 vs 二进制模式:
- 文本模式:在Windows上,
\n会被转换为\r\n(写入)或反过来(读取) - 二进制模式:不做任何转换,数据原样读写
- 在UNIX/Linux上,两种模式没有区别
- 读写非文本数据(结构体、数组等)时,必须使用二进制模式
2.6.4 文件定位
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);FILE *fp = fopen("data.bin", "rb");
// 获取文件大小
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
rewind(fp); // 回到文件开头
// 随机访问
fseek(fp, 100, SEEK_SET); // 移动到第100字节
fseek(fp, 50, SEEK_CUR); // 从当前位置前进50字节
fseek(fp, -10, SEEK_END); // 从文件末尾后退10字节
fclose(fp);whence 参数:
SEEK_SET:从文件开头SEEK_CUR:从当前位置SEEK_END:从文件末尾
2.7 文件缓冲
标准I/O库使用缓冲机制来减少系统调用的次数,提高I/O效率:
| 缓冲类型 | 特点 | 典型场景 |
|---|---|---|
| 全缓冲 | 缓冲区满时才执行I/O | 文件I/O |
| 行缓冲 | 遇到换行符时执行I/O | 终端I/O(stdout) |
| 无缓冲 | 每次操作立即执行I/O | stderr |
// 手动刷新缓冲区
printf("Processing");
fflush(stdout); // 立即输出,不等换行符
// 设置缓冲模式
setvbuf(fp, buffer, _IOFBF, buffer_size); // 全缓冲
setvbuf(fp, buffer, _IOLBF, buffer_size); // 行缓冲
setvbuf(fp, NULL, _IONBF, 0); // 无缓冲缓冲的影响:
// 问题:程序崩溃时,stdout中的内容可能丢失
printf("Step 1\n");
// ... 某些操作 ...
printf("Step 2\n");
// ... 崩溃 ... // "Step 2" 可能没有输出
// 解决:使用stderr或fflush
fprintf(stderr, "Step 1\n"); // stderr无缓冲,立即输出2.8 错误处理
// perror:打印错误信息
FILE *fp = fopen("nonexistent.txt", "r");
if (fp == NULL) {
perror("fopen"); // 输出: fopen: No such file or directory
exit(1);
}
// ferror:检查是否发生错误
if (ferror(fp)) {
fprintf(stderr, "I/O error occurred\n");
}
// feof:检查是否到达文件末尾
if (feof(fp)) {
printf("End of file reached\n");
}
// 正确的文件读取模式
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
// 处理每一行
}
// fgets返回NULL时,用ferror/feof区分是错误还是文件结束
if (ferror(fp)) {
perror("read error");
}三、实战案例
3.1 案例一:文件复制工具
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s source destination\n", argv[0]);
return 1;
}
FILE *src = fopen(argv[1], "rb");
if (src == NULL) {
perror("Cannot open source file");
return 1;
}
FILE *dst = fopen(argv[2], "wb");
if (dst == NULL) {
perror("Cannot open destination file");
fclose(src);
return 1;
}
char buffer[8192];
size_t bytes;
long total = 0;
while ((bytes = fread(buffer, 1, sizeof(buffer), src)) > 0) {
size_t written = fwrite(buffer, 1, bytes, dst);
if (written != bytes) {
fprintf(stderr, "Write error\n");
fclose(src);
fclose(dst);
return 1;
}
total += bytes;
}
if (ferror(src)) {
perror("Read error");
fclose(src);
fclose(dst);
return 1;
}
fclose(src);
fclose(dst);
printf("Copied %ld bytes from %s to %s\n", total, argv[1], argv[2]);
return 0;
}3.2 案例二:CSV文件解析器
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define MAX_FIELDS 10
#define MAX_FIELD_LEN 100
typedef struct {
char fields[MAX_FIELDS][MAX_FIELD_LEN];
int field_count;
} CSVRow;
int parse_csv_line(const char *line, CSVRow *row) {
row->field_count = 0;
const char *start = line;
const char *end;
while (*start && row->field_count < MAX_FIELDS) {
end = strchr(start, ',');
if (end == NULL) {
end = start + strlen(start);
// 去掉换行符
while (end > start && (*(end-1) == '\n' || *(end-1) == '\r'))
end--;
}
int len = end - start;
if (len >= MAX_FIELD_LEN) len = MAX_FIELD_LEN - 1;
strncpy(row->fields[row->field_count], start, len);
row->fields[row->field_count][len] = '\0';
row->field_count++;
start = (*end == ',') ? end + 1 : end;
if (*end == '\0') break;
}
return row->field_count;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s csvfile\n", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "r");
if (fp == NULL) {
perror("Cannot open file");
return 1;
}
char line[1024];
CSVRow row;
int line_num = 0;
while (fgets(line, sizeof(line), fp) != NULL) {
line_num++;
parse_csv_line(line, &row);
printf("Row %d (%d fields): ", line_num, row.field_count);
for (int i = 0; i < row.field_count; i++) {
printf("[%s] ", row.fields[i]);
}
printf("\n");
}
fclose(fp);
return 0;
}3.3 案例三:简单的日志系统
#include <stdio.h>
#include <time.h>
#include <stdarg.h>
typedef enum {
LOG_DEBUG,
LOG_INFO,
LOG_WARNING,
LOG_ERROR
} LogLevel;
static const char *level_names[] = {
"DEBUG", "INFO", "WARNING", "ERROR"
};
static FILE *log_file = NULL;
static LogLevel min_level = LOG_INFO;
void log_init(const char *filename, LogLevel level) {
log_file = fopen(filename, "a");
min_level = level;
}
void log_close(void) {
if (log_file) {
fclose(log_file);
log_file = NULL;
}
}
void log_message(LogLevel level, const char *fmt, ...) {
if (level < min_level || log_file == NULL)
return;
time_t now = time(NULL);
struct tm *tm_info = localtime(&now);
char time_str[20];
strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S", tm_info);
fprintf(log_file, "[%s] [%-7s] ", time_str, level_names[level]);
va_list args;
va_start(args, fmt);
vfprintf(log_file, fmt, args);
va_end(args);
fprintf(log_file, "\n");
fflush(log_file);
}
int main(void) {
log_init("app.log", LOG_DEBUG);
log_message(LOG_INFO, "Application started");
log_message(LOG_DEBUG, "Loading configuration from %s", "config.ini");
log_message(LOG_WARNING, "Configuration file not found, using defaults");
log_message(LOG_ERROR, "Failed to connect to database: %s", "timeout");
log_message(LOG_INFO, "Application exiting");
log_close();
printf("Log written to app.log\n");
return 0;
}四、本章小结
第7章系统讲解了C语言的输入输出机制:
标准I/O流:stdin、stdout、stderr 三个标准流分别对应标准输入、标准输出和标准错误。错误信息应该输出到 stderr。
格式化I/O:printf 和 scanf 提供强大的格式化输入输出能力。格式说明符必须与参数类型匹配,否则是未定义行为。scanf 的返回值用于检测输入错误。
字符和行I/O:getchar/putchar 用于字符级I/O,fgets/fputs 用于行级I/O。永远不要用 gets,始终用 fgets。
文件操作:fopen/fclose 打开和关闭文件。fprintf/fscanf 用于格式化文件I/O,fread/fwrite 用于二进制文件I/O。始终检查 fopen 的返回值。
文件定位:fseek/ftell/rewind 实现文件的随机访问。
缓冲机制:标准I/O使用缓冲减少系统调用。fflush 可以手动刷新缓冲区。stderr 是无缓冲的,确保错误信息立即显示。
I/O是程序与外界交互的桥梁。正确、高效地处理I/O,是编写实用程序的关键。下一章将学习UNIX系统接口——比标准I/O更底层的系统调用。
五、思考与练习
思考题
为什么 printf 的格式说明符与参数类型不匹配是未定义行为,而不是简单地输出错误值?
fgets 读取的字符串包含换行符吗?如何安全地去除它?
文本模式和二进制模式在Windows上有什么区别?在UNIX上呢?
为什么 fread/fwrite 比 fprintf/fscanf 更适合读写结构体等二进制数据?
文件缓冲机制对程序性能有什么影响?什么时候需要手动 fflush?
编程练习
实现 `cat` 命令:读取一个或多个文件的内容并输出到标准输出。支持 -n 选项显示行号。
实现 `wc` 命令:统计文件的行数、单词数和字符数。
编写一个程序:读取一个CSV文件,按某一列排序后输出到新的CSV文件。
实现一个简单的配置文件的读写器:支持 key=value 格式,提供读取和修改配置项的功能。