祖师爷KR的C语言讲解:第6期-输入与输出

告别玄学报错,彻底搞透 C 语言输入与输出(I/O)核心机制

C 语言的输入输出看起来很简单,不过是打印几个字符、读取几个数字,但它其实暗藏玄机。从神秘的"缓冲区滞留",到引发程序崩溃(段错误)的 scanf 陷阱,再到一不小心就清空硬盘数据的文件读写,I/O 章节绝对是无数 C 语言初学者踩坑的重灾区。

这篇博客将带你系统性地扒开 C 语言 I/O 的底层逻辑。抛弃晦涩难懂的比喻,用最直观的代码实例,把庞杂的 I/O 知识拆解为 10 个循序渐进的核心部分。无论你是刚接触 printf 的新手,亦或是在文件操作边缘试探的进阶学习者,都能在这里找到答案。

在这篇万字长文中,你将系统掌握以下核心技能:

  • 底层概念揭秘: 彻底弄懂"流(Stream)"与"缓冲区(Buffer)",告别"按了回车程序却直接结束"的抓狂。

  • 终端交互避坑指南: 深度剖析 printf 的进阶排版与 scanf 的严格匹配机制,手把手教你销毁残留换行符,并学会使用现代标准 fgets 安全处理字符串。

  • 数据持久化实战: 从文本的格式化读写(fprintf / fscanf),到纯正的二进制极速存取(fread / fwrite),一次性搞定文件指针操作与光标定位。

第一部分:C语言I/O核心概念(流与缓冲区)

在学习具体的函数(如 printfscanf)之前,必须先理解C语言是如何看待"数据传输"的。

1. 什么是"流"(Stream)?

在C语言中,所有的输入和输出都被抽象为"流"(Stream)。 流是一个字节序列(Sequence of bytes)。不论你的数据是来自键盘、文件、还是网络,也不论你的数据要输出到屏幕、打印机、还是磁盘,C语言都将它们视为一串连续的字节在流动。

为了使用标准的 I/O 流,我们必须在代码的最前面包含标准输入输出头文件:

cs 复制代码
#include <stdio.h> 
// stdio 代表 Standard Input Output,.h 代表 header(头文件)
2. 三个标准流

当任何一个C程序启动时,操作系统会自动为它打开三个标准的流。你不需要编写代码去打开它们,可以直接使用:

  • stdin (Standard Input - 标准输入) :默认连接到键盘scanf 等函数默认从这里读取数据。

  • stdout (Standard Output - 标准输出) :默认连接到终端/屏幕printf 等函数默认将数据写入这里。

  • stderr (Standard Error - 标准错误) :默认也连接到终端/屏幕。专门用于输出错误信息。

具体实例: 虽然 stdoutstderr 都输出到屏幕,但它们是独立的流。在进阶编程中,你可以通过操作系统的重定向功能,让 stdout 输出到一个文本文件,而让 stderr 依然显示在屏幕上,以便于分离正常程序的运行结果和报错信息。

3. 什么是"缓冲区"(Buffer)?

这是初学者在C语言输入输出中最容易踩坑的地方。 缓冲区本质上是内存中的一块区域,用来临时存放输入或输出的数据。

为什么需要缓冲区?因为CPU处理数据的速度极快,而外部设备(如键盘、硬盘、显示器)的速度极慢。如果CPU每处理一个字节就去和外部设备交互一次,效率会非常低。因此,系统会将数据先攒在缓冲区里,达到一定条件后,再一次性传输。

C语言的缓冲区分为三种模式:

1.全缓冲(Fully Buffered)

机制:只有当缓冲区被填满时,或者程序正常结束时,或者显式调用刷新函数时,数据才会实际进行传输。

场景:对磁盘文件的读写通常是全缓冲的。

程序调用 printffwrite 之类函数时,数据先放进用户态缓冲区,不立刻真正写到设备或文件里。因为系统调用很贵。如果每写一个字符都立刻写磁盘,会非常慢。攒够一批再一次性写,效率高很多。

2.行缓冲(Line Buffered)

机制 :遇到换行符 \n,或者缓冲区满时,数据就会被传输。

场景 :标准输入(stdin,即键盘)和标准输出(stdout,即屏幕)通常是行缓冲的。

具体实例 :当你在键盘上输入 hello 时,C程序其实什么都没读到。只有当你按下回车键(产生了一个换行符 \n),hello\n 这6个字符才会被一次性送入标准输入缓冲区,供你的C程序读取。

终端输出常常是一行一行给人看的,所以用户输入一行、程序输出一行,这种交互体验最好。

3.无缓冲(Unbuffered)

机制 :数据一旦产生,立刻传输,不停留。每次输出请求都尽快交出去,不在 stdio 的用户缓冲区里积累。

场景 :标准错误(stderr)是无缓冲的。因为当程序发生致命错误时,我们需要立刻在屏幕上看到报错信息,哪怕程序在下一微秒就崩溃了。

4. 观察缓冲区的具体实例代码

你可以尝试运行以下代码。这段代码试图在屏幕上打印 "Start",然后程序暂停(睡眠)3秒,最后打印 "End\n"。

cs 复制代码
#include <stdio.h>
#include <unistd.h> // 提供 sleep 函数 (Linux/macOS环境)
// 如果是Windows环境,请包含 <windows.h> 并使用 Sleep(3000);

int main() {
    // 实例 1:没有换行符,数据被滞留在行缓冲区中
    printf("Start"); 
    
    // 此时屏幕上大概率什么都不会显示!
    // 因为 stdout 默认是行缓冲,遇到 \n 才会输出到屏幕。
    
    sleep(3); // 程序暂停运行 3 秒钟
    
    // 实例 2:包含换行符,刷新了缓冲区
    printf("...End\n"); 
    // 3秒后,屏幕会突然同时显示 "Start...End"
    
    return 0;
}

如何强制输出? 如果你希望 Start 立刻显示,即使没有 \n,你可以使用专门的强制刷新函数 fflush

cs 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Start");
    fflush(stdout); // 强制把 stdout 缓冲区里现有的内容推送到屏幕上
    
    sleep(3); 
    
    printf("...End\n"); 
    return 0;
}

第一部分总结:

  • C语言所有I/O依赖 <stdio.h>

  • 数据以 的形式传输,默认有 stdin, stdout, stderr

  • 数据传输经常受到缓冲区 (特别是行缓冲)的影响,按下回车键(\n)是键盘输入生效的关键。

题目 1: 在C语言中,当你调用

cs 复制代码
printf("Error occurred!")

数据默认被发送到了哪个流?如果程序在此行代码后立刻崩溃,屏幕上大概率能看到这句话吗?为什么?

解析: 数据默认发送到 stdout(标准输出)。屏幕上大概率看不到 这句话。因为 stdout 默认是行缓冲 ,字符串中没有换行符 \n,数据会被滞留在内存的缓冲区中,程序崩溃导致缓冲区没有被正常刷新。

如果想确保看到报错,应该输出到无缓冲的 stderr,或者手动调用 fflush(stdout);,或者在字符串末尾加上 \n

如果程序在这句后面立刻崩溃,数据可能还留在 stdout 的缓冲区里,来不及真正显示到屏幕,因此屏幕上很可能看不到这句话

第二部分:单个字符的输入与输出(getcharputchar

在处理复杂的文本或数字之前,最基础的 I/O 操作是每次只处理一个字符 (也就是一个字节)。<stdio.h> 提供了两个专门用于单字符读写的轻量级函数。

1. 字符输出:putchar

putchar (put character) 的作用非常简单:将一个字符输出到标准输出(stdout,即屏幕)。

cs 复制代码
#include <stdio.h>

int main() {
    char letter = 'C';
    
    // 输出变量 letter 中的字符
    putchar(letter); 
    
    // 输出一个直接量字符
    putchar('+');    
    putchar('+');
    
    // 输出一个换行符,刷新行缓冲区
    putchar('\n');   
    
    return 0;
}
// 运行结果屏幕显示:C++
2. 字符输入:getchar

getchar (get character) 的作用是:从标准输入(stdin,即键盘)读取一个字符。

【深度剖析:为什么 getchar 的返回值是 int 而不是 char?】 这是C语言面试中最常考的基础题之一。 我们看它的函数原型:int getchar(void);

为什么不用 char 来接收字符?

因为 getchar 不仅需要返回读到的字符(ASCII码范围 0~127 或 0~255),它还需要一种方式来告诉你:"读取失败了 "或者"已经读到文件末尾了(EOF - End Of File)"。

在C语言中,EOF 通常被定义为 -1。如果用普通的 char 去接收返回值,在某些系统中(无符号的 char 范围是 0~255),它根本无法表示 -1 这个负数。

因此,C语言规定 getchar 返回一个容量更大的 int 类型,这样既能装下所有的字符,又能安全地装下 -1 (EOF)。

具体实例:正确的字符读取方式

cs 复制代码
#include <stdio.h>

int main() {
    int c; // 必须使用 int 类型来接收 getchar 的返回值
    
    printf("请输入一个字符: ");
    c = getchar(); 
    
    if (c != EOF) {
        printf("你输入的字符是: ");
        putchar(c);
        putchar('\n');
    } else {
        printf("读取失败或到达文件末尾。\n");
    }
    
    return 0;
}
3. 核心陷阱:getchar 与行缓冲区的碰撞

结合第一部分的知识,当我们使用 getchar 时,最容易遇到所谓的"幽灵字符"问题。

看下面这个例子:

cs 复制代码
#include <stdio.h>

int main() {
    int c1, c2;
    
    printf("请输入第一个字符: ");
    c1 = getchar();
    
    printf("请输入第二个字符: ");
    c2 = getchar();
    
    printf("第一个字符的ASCII码是: %d\n", c1);
    printf("第二个字符的ASCII码是: %d\n", c2);
    
    return 0;
}

运行过程: 程序运行,屏幕显示 请输入第一个字符: 你在键盘上按下字母 A,然后按下回车键

意想不到的现象发生了: 程序根本没有停下来等你输入第二个字符!它直接结束了,并打印出:

cs 复制代码
第一个字符的ASCII码是: 65  (这是字母'A')
第二个字符的ASCII码是: 10  (这是换行符'\n')

发生了什么?

  1. 当你按下 A回车 时,标准输入缓冲区里实际上有了两个字符:'A''\n'

  2. 第一个 getchar() 就像一个从传送带上拿东西的机械臂,它拿走了 'A'。此时缓冲区里还剩下一个 '\n'

  3. 代码执行到第二个 getchar() 时,它去缓冲区一看,发现里面还有东西('\n'),于是它立刻把 '\n' 拿走了,根本不需要等待用户敲击键盘。

解决办法: 在需要读取新的字符前,通常需要把缓冲区里的换行符"吃掉":

cpp 复制代码
c1 = getchar();
getchar(); // 单独调用一次,把刚才敲的回车符读取并丢弃掉
c2 = getchar(); // 这时才能正常等待新的输入

题目 2: 请看以下代码:

cpp 复制代码
#include <stdio.h>
int main() {
    int ch;
    while ((ch = getchar()) != '\n') {
        putchar(ch);
    }
    return 0;
}

如果你运行这段程序,并在键盘上输入 Hello 然后按下回车,这段代码的具体执行流程是什么?它最终会在屏幕上输出什么?

解析: >

  1. 用户输入 Hello 和回车,缓冲区内装入:H, e, l, l, o, \n

  2. while 循环开始。第一次 getchar() 读到 H,赋值给 chch 不等于 \n,条件成立,执行 putchar('H')

  3. 循环继续,依次读取并打印 e, l, l, o

  4. 最后一次循环,getchar() 读取到 \n,赋值给 ch。此时 ch != '\n' 条件不成立,循环结束。

  5. 结果是:屏幕上原样输出了 Hello,但没有换行 (因为读取到 \n 时循环终止了,没有执行 putchar)。这段代码常用于清空输入缓冲区中的单行剩余字符。

第三部分:格式化输出基础(printf 基本用法)

printf 中的 "f" 代表 formatted (格式化的)。它的核心能力不仅仅是把文字推送到屏幕上,更重要的是它能把内存中以二进制存储的数据 (如整数、浮点数)翻译成人类可读的字符,并按照你规定的排版格式输出。

1. printf 的基本语法结构

printf 的结构看起来很简单,但暗藏玄机。它的标准形式是:

cpp 复制代码
printf("格式控制字符串", 输出列表);
  • 格式控制字符串 :这是 printf 的灵魂,必须用双引号 "" 括起来。它告诉程序"打印什么"以及"怎么打印"。

  • 输出列表 :这是你要打印的实际变量或数据,多个数据之间用逗号 , 隔开。这里的数量必须和前面的占位符数量一一对应

2. 格式控制字符串的三种成分

在双引号内部,你可以放入三种完全不同的东西,printf 会对它们区别对待:

第一种:普通字符(原样输出) 你在双引号里写的绝大多数常规字母、数字、标点符号,printf 都会原封不动地输出到屏幕上。

第二种:转义字符(控制格式) 以反斜杠 \ 开头的特殊字符。它们不会显示为原本的字母,而是执行特定的排版动作。

  • \n:换行(Newline)。将光标移动到下一行的开头。

  • \t:水平制表符(Tab)。跳到下一个 Tab 位置,常用于对齐数据。

  • \\:输出一个真正的反斜杠。

  • \":输出一个真正的双引号(因为双引号本来是用来包裹字符串的,想打印它必须转义)。

第三种:格式占位符(数据替换) 以百分号 % 开头。它们就像是在句子里挖的"坑",后面输出列表里的数据会依次填入这些坑中。

3. 基础格式占位符(最常用的四大天王)

C语言是一种强类型语言,这意味着你必须告诉 printf 变量的具体类型,它才能正确地把二进制翻译出来。

  • %d (Decimal):用于输出十进制的有符号整数int 类型)。

  • %f (Float):用于输出浮点数floatdouble 类型),默认保留小数点后 6 位。

  • %c (Character):用于输出单个字符char 类型)。

  • %s (String):用于输出字符串(字符数组或字符指针)。

4. 具体实例演练

我们把上面的概念组合起来看一段具体的代码:

cs 复制代码
#include <stdio.h>

int main() {
    int age = 25;
    float weight = 65.5;
    char grade = 'A';
    
    // 实例 1:只有普通字符和转义字符
    printf("Hello, World!\n"); 
    // 输出: Hello, World! (并换行)

    // 实例 2:一个占位符
    printf("我的年龄是 %d 岁。\n", age); 
    // 输出: 我的年龄是 25 岁。 (并换行)

    // 实例 3:多个占位符混合使用
    printf("体重: %f kg, 评级: %c\n", weight, grade);
    // 输出: 体重: 65.500000 kg, 评级: A (并换行)

    // 实例 4:输出百分号本身
    // 如果你想在屏幕上打印 % 这个符号,必须连写两个 %%
    printf("完成度: 100%%\n");
    // 输出: 完成度: 100%

    return 0;
}
5. 核心陷阱:类型不匹配与数量不匹配

这是初学者在使用 printf 时最常犯的错误,且 C 语言编译器有时不会报错,只会输出乱码。

错误示范 A:类型不匹配

cpp 复制代码
int num = 97;
printf("%f", num); // 错误!试图用 %f 解析一个 int 类型的内存

后果 :由于整数和浮点数在内存中的二进制存储规则完全不同,这里通常会输出 0.000000 或一个极其离谱的错误数字。

错误示范 B:数量不匹配

cs 复制代码
int a = 10, b = 20;
printf("a = %d, b = %d, c = %d\n", a, b); // 错误!挖了3个坑,只填了2个萝卜

后果 :前两个 %d 会正常输出 10 和 20,第三个 %d 会去内存里随便抓取一段未知的数据(垃圾值)打印出来。

题目 3: 阅读以下 C 语言代码片段:

cpp 复制代码
#include <stdio.h>

int main() {
    int x = 5;
    int y = 2;
    printf("运算结果:\n");
    printf("%d / %d = %f\n", x, y, 2.5);
    return 0;
}

这段代码的输出结果具体是什么格式的?屏幕上会原样显示哪些字符?

解析:

运算结果: 5 / 2 = 2.500000

为什么不是 2.5 而是 2.500000? 因为 %f 只要一出马,默认就会老老实实地打印出 6位小数 。哪怕后面全是 0,它也会补齐。同时,第一句 printf 自带了 \n,所以会换行显示。

第四部分:格式化输出进阶(printf 格式控制与底层细节)

在基础部分,我们只用到了 %d%f。但实际上,占位符 % 和类型字母 d/f 之间,还可以插入很多"高级指令"。

printf 占位符的完整解剖图长这样:

%[标志][最小宽度][.精度][长度修饰符]类型

我们不搞长篇大论,直接看日常编程中最实用的三大进阶技巧:

1. 控制小数位数(.精度

这是最常用的功能,专门对付 %f 默认 6 位小数的毛病。

  • 语法 :在 %f 之间加上 .数字

  • 具体实例

cs 复制代码
#include <stdio.h>

int main() {
    double pi = 3.14159265;
    
    printf("默认输出: %f\n", pi);      // 输出: 3.141593 (默认6位,且最后一位会四舍五入)
    printf("保留两位: %.2f\n", pi);    // 输出: 3.14
    printf("不要小数: %.0f\n", pi);    // 输出: 3
    
    return 0;
}
2. 控制排版对齐与宽度(最小宽度- 标志)

当你需要打印表格或者对齐数据时,这个功能简直是救星。

  • 数字 n(最小宽度) :指定该数据至少占用 n 个字符的位置。默认是右对齐,不够的地方在左边补空格。

  • 减号 -(左对齐标志) :加上减号,就变成了左对齐,不够的地方在右边补空格。

具体实例: 我们尝试对齐打印两行数据(注意看空格的位置)。

cpp 复制代码
#include <stdio.h>

int main() {
    int id1 = 12, id2 = 3456;
    
    // 默认情况(不对齐,很难看)
    printf("%d\n", id1);   // 输出: 12
    printf("%d\n", id2);   // 输出: 3456
    
    // 占 5 个字符宽度,默认右对齐(常用于数字列队)
    printf("[%5d]\n", id1); // 输出: [   12] (左边补了3个空格)
    printf("[%5d]\n", id2); // 输出: [ 3456] (左边补了1个空格)
    
    // 占 5 个字符宽度,左对齐(常用于文字或名字排版)
    printf("[%-5d]\n", id1); // 输出: [12   ] (右边补了3个空格)
    printf("[%-5d]\n", id2); // 输出: [3456 ] (右边补了1个空格)
    
    return 0;
}

(注:这里我加上方括号 [] 纯粹是为了让你看清楚空格到底在哪,实际编程中不需要加。)

3. 补零排版与正负号强制显示(0+ 标志)
  • 数字 0(补零标志) :常用于时间、日期或固定位数的工号显示。用 0 代替空格来补齐宽度。

  • 加号 +(正负号标志) :默认情况下,正数不显示 + 号,负数才显示 - 号。加上 + 标志后,正数也会强制带上 + 号。

cpp 复制代码
#include <stdio.h>

int main() {
    int month = 5;
    int day = 8;
    int temperature = 25;
    
    // 补零实例:打印日期格式 05-08
    printf("日期: %02d-%02d\n", month, day); 
    // %02d 表示:至少占2位,如果不够,在前面补 0。输出: 日期: 05-08
    
    // 强制显示正负号
    printf("今日气温: %+d 度\n", temperature);
    // 输出: 今日气温: +25 度
    
    return 0;
}
4. 长度修饰符(处理极大或极小的数据)

随着学习深入,普通的 int (通常最大到二十多亿) 可能不够用了。

  • %ld :打印 long int (长整型)。

  • %lld :打印 long long int (超长整型)。

  • %lf :在 printf 中,%f 可以兼职打印 double,但养成好习惯,打印双精度浮点数 double 使用 %lf

题目 4: 如果我写了这行代码:

cpp 复制代码
printf("[%08.2f]", 3.14159);

请一步步拆解这个占位符 %08.2f 的指令,并告诉我最终屏幕上连同方括号会原样输出什么? (提示:小数点也算占用一个字符宽度哦)

解释

格式串 %08.2f

  • %f:按浮点数格式输出

  • .2:保留 2 位小数 ,所以 3.14159 变成 3.14

  • 8:整个字段宽度至少 8 个字符

  • 0:如果不足 8 个字符,用前导 0 补齐

即:[00003.14]

第五部分:格式化输入基础(scanf 基本用法)

如果说 printf 是把内存里的数据翻译成字符显示在屏幕上,那么 scanf (scan formatted) 就是它的逆过程:它负责读取你在键盘上敲下的字符,并把它们转换成二进制数据,存入指定的内存空间中。

1. scanf 的基本语法结构与致命区别

scanf 的语法看起来和 printf 几乎一模一样,但有一个极其重要的区别:

cs 复制代码
scanf("格式控制字符串", &变量1, &变量2, ...);

核心区别:

取地址符 & (Ampersand)printf 中,我们直接写变量名(比如 age),因为 printf 只需要知道这个变量里面的值是多少。

但在 scanf 中,我们必须在变量名前加上 &(比如 &age)。& 的意思是"取地址"。

为什么必须加 &

因为 scanf 的任务是修改 内存。当你输入一个数字时,scanf 必须知道它应该把这个数字存放到内存的哪个具体位置

&age 就是告诉 scanf:"请把用户输入的数据,存放到 age 这个变量所在的内存地址里。"

2. 常用的格式占位符

scanf 的占位符和 printf 高度重合,但有一个严格的规定必须牢记:

  • %d :读取十进制整数,存入 int

  • %f :读取浮点数,存入 float

  • %lf (Long Float):严格注意! 如果你要把数据存入 double(双精度浮点数)变量,scanf必须 使用 %lf,绝不能用 %f。这与 printf%f 可以兼容打出 double)不同。

  • %c :读取单个字符,存入 char

3. 具体实例演练

实例 1:读取单个数据

cpp 复制代码
#include <stdio.h>

int main() {
    int year;
    printf("请输入你出生的年份: ");
    
    // 程序运行到这里会暂停,等待键盘输入
    scanf("%d", &year); 
    
    printf("你出生于 %d 年。\n", year);
    return 0;
}

实例 2:一次性读取多个数据(默认的空格/回车分隔)

当你在一行代码中连续读取多个数值时,C语言默认使用空白字符(空格、Tab、回车键)作为不同数据之间的分割符。

cpp 复制代码
#include <stdio.h>

int main() {
    int weight;
    double height;
    
    printf("请输入你的体重(kg)和身高(m),用空格隔开: ");
    
    // 连续读取一个 int 和一个 double
    scanf("%d %lf", &weight, &height); 
    
    printf("你的体重是 %d kg, 身高是 %lf m。\n", weight, height);
    return 0;
}

执行过程 :如果你在键盘上输入 70 1.75 然后按回车,scanf 看到空格,就知道 70 是第一个数,1.75 是第二个数。即使你输入 70,然后按回车 ,再输入 1.75,再按回车,scanf 也能正确读取,因为它把回车也当作分隔符。

4. scanf 的两大初学者陷阱

陷阱 A:忘记写 &

cpp 复制代码
int age;
scanf("%d", age); // 致命错误!缺少 &

后果 :由于没有 &scanf 会把 age 里面原本存着的未知垃圾值当成一个内存地址,试图把数据强行塞进那个随机的地址里。这通常会导致程序触发操作系统的保护机制,直接崩溃(Segmentation Fault / 段错误)

陷阱 B:在 scanf 的双引号里加了 \n 或者其他多余字符 这是极为常见的手误,很多人习惯了 printf 结尾加 \n,就顺手写到了 scanf 里:

cpp 复制代码
int num;
scanf("%d\n", &num); // 错误用法!

后果scanf 双引号里的非占位符字符代表"强制匹配"

你写了 \nscanf 就会死死地等待用户输入一个空白字符(如回车),而且它会一直吃掉后续所有的回车和空格,直到用户输入一个非空白字符为止。这会导致你的程序看起来像是"卡死"了,一直按回车都没反应。

原则:scanf 的双引号里,尽量只写占位符,不要写任何多余的字和换行符。

题目 5: 请看下面这段极其常见的错误代码:

cpp 复制代码
#include <stdio.h>

int main() {
    int month, day;
    printf("请输入月份和日期: ");
    
    // 注意双引号里的逗号
    scanf("%d,%d", &month, &day); 
    
    printf("你输入的是: %d 月 %d 日\n", month, day);
    return 0;
}

假设程序运行后,你在键盘上输入了 5 8 (也就是数字5,一个空格,数字8),然后按了回车。

请问 monthday 最终会读到什么值?为什么? (提示:回忆一下刚才说的"强制匹配"原则。)

解析:严格匹配原则

代码回顾: scanf("%d,%d", &month, &day); 你的输入: 5 8 (数字5,一个空格,数字8,然后回车)

实际发生的内部执行过程:

  1. scanf 看到格式控制字符串里的第一个 %d,于是它去输入缓冲区里找数字。它找到了 5,成功把 5 存入 month 变量。

  2. 接下来,scanf 看到格式控制字符串里有一个逗号 , 。在 scanf 的规则里,双引号里出现的任何非占位符字符,都代表强制要求用户一模一样地输入

  3. 于是,scanf 满怀期待地去看输入缓冲区,寻找逗号。但是,它看到了一个空格 (因为你输入的是 5 8)。

  4. 匹配失败! 逗号和空格不匹配。scanf 一旦发现匹配失败,会立刻罢工,停止读取后续的所有内容。

  5. 结果:8 被留在了缓冲区里根本没被读取。day 变量没有获取到任何新值,它会保持在内存中原本的随机垃圾值(也叫未初始化值)。

正确做法:

  • 如果代码是 scanf("%d,%d", ...),你键盘上必须 输入 5,8(中间带逗号)。

  • 如果你希望用空格隔开(输入 5 8),代码应该老老实实写成 scanf("%d %d", ...),或者直接 scanf("%d%d", ...),因为 %d 默认会自动跳过前置的空格和回车。

第六部分:scanf 的深入剖析与常见陷阱

我们主要解决 scanf 留下的两个最大麻烦:残留换行符错误输入处理

1. 终极陷阱:残留换行符(幽灵字符重现)

我们在讲 getchar 的时候提到过换行符 \n 的问题,这个问题在 scanf 中表现得更为致命,尤其是当你把读取数字(%d, %f 读取单个字符(%c)混用的时候。

具体实例: 请看下面这段代码,尝试读取一个年龄(整数)和一个性别评级(字符 A/B/C)。

cpp 复制代码
#include <stdio.h>

int main() {
    int age;
    char grade;
    
    printf("请输入年龄: ");
    scanf("%d", &age);
    
    printf("请输入评级(A/B/C): ");
    scanf("%c", &grade);
    
    printf("结果 -> 年龄: %d, 评级: %c\n", age, grade);
    
    return 0;
}

运行:

  1. 屏幕显示:请输入年龄:

  2. 你输入 20,并按下回车键

  3. 此时,缓冲区里有三个字符:2, 0, \n

  4. 第一个 scanf("%d") 读取了 20。它非常聪明,知道数字读完了,于是停下来。但是!它把换行符 \n 留在了缓冲区里

  5. 屏幕显示:请输入评级(A/B/C):

  6. 第二个 scanf("%c") 准备工作。注意,%c 是一个毫无原则的占位符,它不管缓冲区里是字母、空格还是换行符,只要是个字符它就全盘接收。

  7. 它一眼看到了缓冲区里残留的 \n,直接把它读取并存入了 grade

  8. 程序根本没有等你输入评级,直接结束了! 输出结果可能是换行的乱码。

完美解决方案:在 %c 前面加一个空格

只需要改动一个字符,就能解决这个难题:

cpp 复制代码
// 注意 %c 前面的空格!
scanf(" %c", &grade);

原理 :在 scanf 的格式字符串中,空格 是一个神奇的指令。它告诉 scanf:"去缓冲区里,把所有的空白字符(空格、回车 \n、Tab)全部跳过、吃掉、扔掉,直到遇到第一个非空白字符 为止,再开始读取。" 加上这个空格,残留的 \n 就被完美销毁了。

2. 进阶技巧:利用 scanf 的返回值进行安全检查

很多初学者不知道,scanf 其实是有返回值的。它返回的是成功匹配并赋值的变量个数 。 如果用户捣乱,让你输入数字你非要输入字母,scanf 就会读取失败。

具体实例:防止非法输入

cpp 复制代码
#include <stdio.h>

int main() {
    int weight;
    printf("请输入体重(kg): ");
    
    // 我们用一个整数变量 result 来接收 scanf 的返回值
    int result = scanf("%d", &weight);
    
    if (result == 1) {
        printf("读取成功!体重是: %d kg\n", weight);
    } else {
        printf("输入错误!你输入的不是合法的数字。\n");
        // 注意:如果读取失败,那个捣乱的字母还会留在缓冲区里,
        // 在复杂的程序中,你需要用 getchar() 循环把它清空,防止死循环。
    }
    
    return 0;
}
  • 如果你输入 70result 会是 1(成功读取1个)。

  • 如果你输入 abc%d 匹配失败,weight 不会被赋值,result 会是 0

题目 6: 阅读以下代码片段:

cpp 复制代码
int a, b;
int count = scanf("%d %d", &a, &b);

如果在程序运行时,用户在键盘上输入了 100 Hello 并回车。 请问:

  1. count 的值会是多少?

  2. a 的值会是多少?

  3. b 成功获取到值了吗?

执行过程:

  1. scanf 开始执行,看到第一个 %d,去缓冲区寻找数字。它顺利找到了 100,读取成功,100 赋值给了变量 a

  2. 接着,scanf 跳过中间的空格,看到了第二个 %d。它满怀期待地去缓冲区找第二个数字,结果迎面撞上了一个字母 H(来自 Hello)。

  3. 关键点来了: %d 只能认数字,遇到字母 H,它立刻意识到"类型不匹配"。scanf 非常"胆小且死板",一旦遇到不匹配,它会立刻停止工作(半途而废),后面的什么都不管了。

  4. 结果:b 没有成功获取到值 ,它依然保持着内存里原本的垃圾值。而 Hello\n 这几个字符,被永远留在了输入缓冲区里(这可能会导致你程序后面的输入函数直接读到乱码)。

  5. 最后,scanf 会清点自己今天的"战利品":一共成功读取并赋值了几个变量?答案是 1 个(只有 a 成功了)。所以,count 的值是 1

这就是 scanf 的脆弱之处,也是我们在实际开发中需要严加防范的地方。

第七部分:字符串的输入输出(gets的废弃与 fgets / puts

在 C 语言中,没有原生的 String 类型,字符串本质上是字符数组(char[] 。 处理字符串时,虽然可以用 printf("%s")scanf("%s"),但它们各有各的局限性。C 标准库为我们提供了专门的字符串处理函数。

1. 字符串输出:puts (Put String)

putsprintf 的极简替代品,专门用来输出字符串。

核心特点: 它会自动在字符串的末尾追加一个换行符 \n 。你不需要像 printf 那样手动写 \n

cpp 复制代码
#include <stdio.h>

int main() {
    char greeting[] = "Hello, C Language!";
    
    // 使用 printf 需要手动换行
    printf("%s\n", greeting); 
    
    // 使用 puts 自动换行,代码更简洁
    puts(greeting); 
    puts("这是第二行。");
    
    return 0;
}
2. 为什么不用 scanf("%s") 读取字符串?

scanf%s 占位符有两个致命缺陷:

  1. 遇到空格就停 :如果你想输入你的全名 Bruce Waynescanf("%s") 读到 Bruce 后面的空格就停止了,Wayne 会被留在缓冲区里。它没法读取包含空格的完整句子。

  2. 不安全(缓冲区溢出) :如果你定义了一个长度为 10 的字符数组,但用户丧心病狂地输入了 100 个字符,scanf 会毫不犹豫地把这 100 个字符塞进去,把数组撑爆,导致程序崩溃甚至引发安全漏洞。

3. 历史的眼泪:被废弃的 gets

为了解决"遇到空格就停"的问题,早期的 C 语言提供了一个函数叫 gets (Get String)。 它可以读取包含空格的整行输入,直到遇到回车键为止。

但是! gets 同样存在上述的第二个缺陷(不安全) 。它完全不检查你的数组有多大。 由于它引发了历史上无数的黑客攻击(著名的莫里斯蠕虫病毒就是利用了类似的缓冲区溢出漏洞),在最新的 C 语言标准(C11及以后)中,gets 已经被彻底从标准库中删除(废弃)了。 任何现代编译器看到你用 gets 都会给出严重警告甚至报错。

结论:永远、绝对不要在你的代码里使用 gets

4. 现代的标准答案:fgets (File Get String)

为了安全地读取一整行字符串(包含空格),我们现在统一使用 fgets。它的设计非常严谨:

基本语法: fgets(存储字符串的数组名, 最多读取的字符数, 读取的来源流);

具体实例:

cpp 复制代码
#include <stdio.h>
#include <string.h> // 需要用到处理字符串的辅助函数

int main() {
    // 定义一个能容纳 20 个字符的数组 (实际最多存 19 个可见字符 + 1个结束符 '\0')
    char fullName[20]; 
    
    printf("请输入你的全名 (包含空格): ");
    
    // 使用 fgets 安全读取
    // 参数1: 存入 fullName
    // 参数2: 最多只读 20 个字节,绝不溢出!
    // 参数3: 从标准输入 (键盘 stdin) 读取
    fgets(fullName, 20, stdin);
    
    printf("你输入的名字是: %s", fullName);
    
    return 0;
}

fgets 的一个重要"怪癖"(核心注意点): 当你输入 Bruce Wayne 并按下回车键 时,fgets 会连同那个回车符 \n 一起读取进去 (只要数组还有空间)。 所以,fullName 数组里实际存的是:Bruce Wayne\n\0。 当你用 printf 打印它时,它会自带一个换行效果。这在后续处理字符串时常常会造成困扰,我们通常需要手动把那个多余的 \n 替换成字符串结束符 \0

题目 7: 假设你正在编写一个记录城市名称的程序。

cpp 复制代码
char city[50];

你需要让用户输入城市名(比如 New YorkLos Angeles)。 请问,如果你用 scanf("%s", city); 来读取,当用户输入 New York 并回车时,city 数组里实际存下的是什么?为什么这不符合我们的需求?

当你输入 New York 时,scanf 读到 New 后面的那个空格,就会立刻停下来。结果是:

  1. city 数组里只存了 "New"

  2. 更糟糕的是," York\n" 被原封不动地留在了输入缓冲区里,这会直接导致你程序里接下来的输入函数"发疯"(读到脏数据)。

所以,读取带空格的完整句子,一定要用 fgets

现在,我们终于要告别"阅后即焚"的黑框框终端,进入能够让数据永久保存的领域。

第八部分:文件I/O基础(文件指针与 fopen / fclose

到目前为止,我们所有的数据都存在内存里。一旦程序运行结束,或者电脑断电,数据就灰飞烟灭了。如果想让数据"活下来",就必须把它们写进硬盘里的文件中。

在 C 语言中,操作文件其实和操作屏幕/键盘非常相似(还记得第一部分说的"流"吗?文件也是一种流)。

1. 核心概念:文件指针(FILE *

这是文件操作的灵魂。 在 C 语言中,你不能直接对硬盘说"给我读这个文件"。你必须先让操作系统在内存里建立一个代表该文件的"档案袋"(结构体 FILE),然后通过一根"线"(指针)牵着这个档案袋来进行操作。

这根线,就是文件指针 。它的类型固定写为 FILE *(注意 FILE 全部大写,它定义在 <stdio.h> 中)。

cpp 复制代码
FILE *fp; // 声明一个名叫 fp 的文件指针 (fp 是 file pointer 的缩写)
2. 打开文件:fopen (File Open)

在对文件进行任何读写之前,必须先打开 它。fopen 函数负责连接你的程序和硬盘上的文件。

cpp 复制代码
FILE *fopen("文件名", "打开模式");

返回值 :如果打开成功,它返回一个指向该文件的 FILE * 指针;如果打开失败(比如文件不存在、没有权限),它会返回 NULL(空指针)。

打开模式(非常重要,决定了你能对文件做什么):

  • "r" (Read):只读模式 。文件必须已经存在,否则打开失败(返回 NULL)。

  • "w" (Write):只写模式 。如果文件存在,会把原文件内容全部清空(危险!);如果文件不存在,会创建一个新文件。

  • "a" (Append):追加模式 。也是写文件,但不会清空原内容,而是把新数据写在文件的最末尾。如果文件不存在,也会创建新文件。

3. 关闭文件:fclose (File Close)

"有借有还,再借不难"。文件是操作系统的宝贵资源,打开文件会占用内存(特别是文件缓冲区)。当你操作完毕后,必须 调用 fclose 关闭文件。

为什么必须关闭? 如果你用 "w" 模式往文件里写了数据,但忘记调用 fclose,程序结束时,数据可能还滞留在内存的缓冲区里,没有真正写入硬盘!关闭文件的动作,会强制刷新缓冲区,确保数据安全落地。

4. 具体实例演练:文件操作的"标准骨架"

无论你以后写多复杂的文件处理程序,下面这个"打开 -> 检查 -> 操作 -> 关闭"的骨架是永远不变的:

cpp 复制代码
#include <stdio.h>

int main() {
    // 1. 声明文件指针
    FILE *filePtr; 
    
    // 2. 尝试以只写模式 ("w") 打开(或创建)一个名叫 data.txt 的文件
    filePtr = fopen("data.txt", "w");
    
    // 3. 极其重要的一步:检查文件是否成功打开!
    if (filePtr == NULL) {
        printf("发生错误:无法打开文件!\n");
        return 1; // 返回非0值,告诉操作系统程序非正常退出
    }
    
    printf("文件打开成功!\n");
    
    // ---------------------------------------------------
    // 4. 在这里进行文件的读写操作 (我们将在第九部分讲解)
    // ---------------------------------------------------
    
    // 5. 操作完毕,必须关闭文件
    fclose(filePtr);
    printf("文件已安全关闭。\n");
    
    return 0;
}

题目 8: 假设你的电脑桌面上有一个文本文件 log.txt,里面原本写着一句话:"今天是星期一。"

现在,你写了一段 C 语言程序,使用了下面这行代码来打开这个文件:

cpp 复制代码
FILE *fp = fopen("log.txt", "w");
// 假设这里没有任何写入操作
fclose(fp);

请问,当这段程序运行结束后,你再去桌面上双击打开 log.txt,里面会看到什么内容?为什么?

解析:

"w"(只写模式)只要文件被它打开,原本里面的所有内容就会在瞬间被清零。如果你只是想在"今天是星期一。"后面加上一句"明天是星期二。",你必须使用 "a"(追加模式)

第九部分:文件的格式化与字符读写(fprintf, fscanf, fputc, fgetc

好消息是,如果你已经完全掌握了前面的 printf / scanfputchar / getchar,这一部分对你来说将没有任何难度。

C 语言的设计非常优雅,文件读写函数和屏幕/键盘读写函数几乎是一模一样的 ,唯一的区别是: 文件读写函数的名字前面多了一个 f (代表 file),并且参数列表里多了一个指定目标的 FILE *(文件指针)。

1. 单个字符的文件读写(fputcfgetc
  • 写入一个字符:fputc(字符, 文件指针); (相当于 putchar,但写进了文件里)

  • 读取一个字符:int 变量 = fgetc(文件指针); (相当于 getchar,但从文件里读。同样注意,必须用 int 接收 ,因为要处理 EOF。)

具体实例:文件的完美复制 这段代码展示了如何一个字符一个字符地把 source.txt 的内容复制到 copy.txt 中。这展示了 fgetc 最经典的用法:一直读,直到遇到 EOF (End Of File,文件末尾标志)。

cpp 复制代码
#include <stdio.h>

int main() {
    FILE *fpIn = fopen("source.txt", "r"); // 只读模式打开源文件
    FILE *fpOut = fopen("copy.txt", "w");  // 只写模式打开目标文件
    
    if (fpIn == NULL || fpOut == NULL) {
        printf("文件打开失败!\n");
        return 1;
    }

    int ch; // 必须是 int
    // 经典循环:每次读一个字符,只要不是 EOF,就写到新文件里
    while ((ch = fgetc(fpIn)) != EOF) {
        fputc(ch, fpOut);
    }

    printf("文件复制完成!\n");
    
    fclose(fpIn);
    fclose(fpOut);
    return 0;
}
2. 格式化文件输出:fprintf (File Print Formatted)

当我们想把整数、浮点数拼凑成人类可读的句子存入文件时,就用 fprintf

基本语法: fprintf(文件指针, "格式控制字符串", 变量列表);

生成一份成绩单文件:

cpp 复制代码
#include <stdio.h>

int main() {
    FILE *fp = fopen("report.txt", "w");
    if (fp == NULL) return 1;

    char name[] = "张三";
    int score = 95;
    
    // 用法和 printf 完全一样,只是把结果输出到了 fp 指向的文件里
    fprintf(fp, "姓名: %s\n", name);
    fprintf(fp, "得分: %d\n", score);
    
    fclose(fp);
    return 0;
}

运行后,你的硬盘上会多出一个 report.txt,里面整整齐齐地写着两行文字。

3. 格式化文件输入:fscanf (File Scan Formatted)

这是从具有特定格式的文本文件中提取数据的利器。它就像一个高度精确的"文本解析器"。

基本语法: fscanf(文件指针, "格式控制字符串", &变量列表);

致命陷阱重申:scanf 一样,fscanf 也遵循"严格匹配原则",并且绝不能忘记写取地址符 &

具体实例:读取刚才生成的成绩单 假设 report.txt 里的内容是:

cpp 复制代码
姓名: 张三
得分: 95

你想把"张三"和"95"提取到程序的变量里:

cpp 复制代码
#include <stdio.h>

int main() {
    FILE *fp = fopen("report.txt", "r"); // 这次是 "r" 模式
    if (fp == NULL) return 1;

    char name[20];
    int score;
    
    // 注意格式字符串必须和文件里的文字一模一样!
    fscanf(fp, "姓名: %s\n", name);  // 读字符串,不需要加 &
    fscanf(fp, "得分: %d\n", &score); // 读整数,必须加 &
    
    printf("成功从文件读取:玩家 %s 获得了 %d 分!\n", name, score);
    
    fclose(fp);
    return 0;
}

注意:在实际开发中,直接用 fscanf 解析带空格或复杂格式的文本很容易因为一点点格式错乱就彻底崩溃,更稳妥的做法是用 fgets 读出一整行,然后再用字符串处理函数去慢慢拆解。但作为基础,掌握 fscanf 是必须的。

题目 9: 假设你有一个名为 data.txt 的文件,里面只有一行内容,写着: 2026/10/01

现在你想把这三个数字分别存入三个 int 变量 year, month, day 中。 请补全下面代码中 fscanf 的语句:

cpp 复制代码
FILE *fp = fopen("data.txt", "r");
int year, month, day;

// 请写出正确的 fscanf 语句:
// __________________________________________________

fclose(fp);

解析:精确提取文本

正确答案是:

cpp 复制代码
fscanf(fp, "%d/%d/%d", &year, &month, &day);

详细拆解:

  1. fp:告诉函数去哪个文件里读。

  2. "%d/%d/%d" :这是格式控制字符串。第一个 %d 读取 2026,然后遇到了 /,它就会在文件里严格比对 ,发现文件里紧接着也是 /,匹配成功!接着用第二个 %d 读取 10,再次匹配 /,最后用第三个 %d 读取 01

  3. &year, &month, &day :因为这三个都是整数(int)变量,所以必须加上取地址符 &,把读到的数字存入它们对应的内存地址中。

掌握了这个,你就能用 C 语言处理大部分有规律的文本数据了!

第十部分:文件的二进制读写与定位(fread, fwrite, fseek, ftell

前面第九部分讲的 fprintffscanf 处理的都是文本文件(Text File)。文本文件的特点是:人类用记事本打开能看懂,但计算机读写起来效率较低,因为它需要不断在"二进制"和"字符"之间进行翻译。

如果你的数据不需要给人看(比如游戏存档、图像数据、加密文件),只是为了让程序下次运行能快速读取,我们通常使用二进制文件(Binary File)。

1. 二进制的打开模式

在 Windows 系统下,处理二进制文件时,必须在打开模式后面加一个字母 b (binary)

  • "wb":以只写模式打开或创建二进制文件。

  • "rb":以只读模式打开二进制文件。

  • "ab":以追加模式打开或创建二进制文件。

2. 二进制读写核心函数:fwritefread

这两个函数的作用极其简单粗暴:直接把内存里的一大块数据,原封不动地"拷贝"到硬盘上;或者把硬盘上的数据,原封不动地"拷贝"回内存。 不需要任何格式化翻译,速度极快。

基本语法(两者参数完全一致):

cpp 复制代码
// 写入
fwrite(数据的内存地址, 每个数据块的大小, 数据块的数量, 文件指针);

// 读取
fread(存储数据的内存地址, 每个数据块的大小, 数据块的数量, 文件指针);

具体实例:极速保存和读取一个数组

假设你有一个包含 5 个整数的数组,你想把它存起来。

cpp 复制代码
#include <stdio.h>

int main() {
    // -----------------------------------------
    // 第一步:将数组以二进制形式写入文件
    // -----------------------------------------
    int numbers[5] = {10, 20, 30, 40, 50};
    
    FILE *fpWrite = fopen("data.bin", "wb"); // wb: Write Binary
    if (fpWrite != NULL) {
        // 参数1: 数组名本身就是内存地址 (numbers)
        // 参数2: 每个元素的大小 (sizeof(int))
        // 参数3: 有 5 个这样的元素
        // 参数4: 写入 fpWrite 指向的文件
        fwrite(numbers, sizeof(int), 5, fpWrite);
        fclose(fpWrite);
        printf("二进制数据写入成功!\n");
    }

    // -----------------------------------------
    // 第二步:从二进制文件中读取数据到新数组
    // -----------------------------------------
    int read_numbers[5] = {0}; // 创建一个全为0的新数组准备接收数据
    
    FILE *fpRead = fopen("data.bin", "rb"); // rb: Read Binary
    if (fpRead != NULL) {
        fread(read_numbers, sizeof(int), 5, fpRead);
        fclose(fpRead);
        
        printf("读取到的数据: ");
        for(int i = 0; i < 5; i++) {
            printf("%d ", read_numbers[i]);
        }
        printf("\n");
    }

    return 0;
}

注意:如果你用记事本打开生成的 data.bin,你只会看到一堆乱码,因为那是纯粹的内存二进制数据。

3. 操控文件光标:fseekftell

在读写文件时,系统内部有一个看不见的"光标"(文件位置指针)。你读写到哪里,光标就移动到哪里。但有时,我们需要随意移动这个光标,比如直接跳到文件末尾,或者重新回到文件开头。

  • ftell(文件指针):告诉你当前光标在文件的第几个字节。

  • fseek(文件指针, 偏移量, 起始位置):强行移动光标。

fseek 的第三个参数(起始位置)有三个标准选项:

  1. SEEK_SET:文件开头

  2. SEEK_CUR:光标当前位置

  3. SEEK_END:文件末尾

具体实例:如何利用光标获取文件的大小(字节数)? 这是一个极其经典且实用的固定用法:

cpp 复制代码
#include <stdio.h>

int main() {
    // 假设我们要测量刚才生成的 data.bin 的大小
    FILE *fp = fopen("data.bin", "rb");
    if (fp == NULL) return 1;

    // 1. 把光标直接移动到文件最末尾
    // 参数: fp, 移动0个字节, 以文件末尾为基准
    fseek(fp, 0, SEEK_END); 
    
    // 2. 问问 ftell 当前光标在第几个字节?这就是文件的大小!
    long fileSize = ftell(fp); 
    
    printf("这个文件的大小是: %ld 字节\n", fileSize);
    
    // 3. 如果你接下来还要读文件,记得把光标移回开头!
    // fseek(fp, 0, SEEK_SET); 或者用 rewind(fp);
    
    fclose(fp);
    return 0;
}

全系列总结

到此为止,我们已经完成了 C 语言"输入与输出"章节的全部十次内容,我们从最底层的流和缓冲区出发,学习了单字符、格式化文本、字符串的处理,最终跨越到了外部文件的文本读写与二进制直接操作。这几乎涵盖了 C 语言 I/O 的所有核心考点和实际开发需求。

相关推荐
小鸡脚来咯2 小时前
SQL表连接
java·开发语言·数据库
大鹏说大话2 小时前
消息队列 Kafka/RabbitMQ/RocketMQ 怎么选?业务场景对比指南
开发语言
IT WorryFree2 小时前
OpenClaw 对接飞书 Debug 指南
开发语言·php·飞书
码云数智-大飞2 小时前
JVM 调优实战:内存溢出、GC 频繁问题定位思路
开发语言
AsDuang2 小时前
Python 3.12 MagicMethods - 48 - __rmatmul__
开发语言·python
lsx2024062 小时前
Django 视图 - FBV 与 CBV
开发语言
不会写DN2 小时前
如何让两个Go程序远程调用?
开发语言·qt·golang
froginwe112 小时前
MongoDB 关系
开发语言
ん贤5 小时前
Go channel 深入解析
开发语言·后端·golang