7C程序设计语言(第2版)

输入与输出

程序与外界的交互

标准I/O格式化输出文件操作

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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程序启动时,系统会自动打开三个标准流:

流文件指针文件描述符默认连接
标准输入stdin0键盘
标准输出stdout1终端屏幕
标准错误stderr2终端屏幕
c
#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 通常保持连接到终端。

bash
./program > output.txt    # stdout重定向到文件,stderr仍然显示在终端
./program 2> errors.txt   # stderr重定向到文件
./program > out.txt 2>&1  # 两者都重定向到同一文件

2.2 printf:格式化输出

2.2.1 格式说明符

c
printf("format string", arg1, arg2, ...);

格式字符串中的 % 后面跟着格式说明符,指定如何输出对应的参数:

说明符含义示例
%d有符号十进制整数42
%u无符号十进制整数42
%x / %X十六进制(小写/大写)2a / 2A
%o八进制52
%ldlong 整数
%lldlong long 整数
%f浮点数(小数形式)3.140000
%e / %E浮点数(科学计数法)3.14e+00
%g / %G自动选择 %f 或 %e
%c单个字符A
%s字符串hello
%p指针地址0x7ffd5e8a
%%百分号本身%
%n不输出,将已输出的字符数存入参数

2.2.2 格式修饰符

c
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 常见陷阱

c
// 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:格式化输入

c
scanf("format string", &arg1, &arg2, ...);

2.3.1 基本用法

c
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 返回成功读取并赋值的输入项数。检查返回值是检测输入错误的关键:

c
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 的常见问题

c
// 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

c
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

c
char *fgets(char *str, int size, FILE *stream);  // 读取一行
int fputs(const char *str, FILE *stream);         // 输出一行
c
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 打开和关闭文件

c
FILE *fopen(const char *filename, const char *mode);
int fclose(FILE *stream);

文件打开模式:

模式含义文件不存在文件存在
"r"只读(文本)失败打开
"w"只写(文本)创建截断
"a"追加(文本)创建追加
"r+"读写(文本)失败打开
"w+"读写(文本)创建截断
"rb"只读(二进制)失败打开
"wb"只写(二进制)创建截断
"ab"追加(二进制)创建追加
c
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
    perror("fopen");  // 输出错误信息
    exit(1);
}

// ... 使用文件 ...

fclose(fp);  // 关闭文件,释放资源

始终检查 `fopen` 的返回值! 文件可能不存在、权限不足、磁盘已满等。

2.6.2 格式化文件I/O

c
// 写入文件
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

c
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);
c
// 写入二进制数据
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 文件定位

c
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);
c
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/Ostderr
c
// 手动刷新缓冲区
printf("Processing");
fflush(stdout);  // 立即输出,不等换行符

// 设置缓冲模式
setvbuf(fp, buffer, _IOFBF, buffer_size);  // 全缓冲
setvbuf(fp, buffer, _IOLBF, buffer_size);  // 行缓冲
setvbuf(fp, NULL, _IONBF, 0);              // 无缓冲

缓冲的影响:

c
// 问题:程序崩溃时,stdout中的内容可能丢失
printf("Step 1\n");
// ... 某些操作 ...
printf("Step 2\n");
// ... 崩溃 ...  // "Step 2" 可能没有输出

// 解决:使用stderr或fflush
fprintf(stderr, "Step 1\n");  // stderr无缓冲,立即输出

2.8 错误处理

c
// 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 案例一:文件复制工具

c
#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文件解析器

c
#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 案例三:简单的日志系统

c
#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 格式,提供读取和修改配置项的功能。

7/8输入与输
0%