C语言输入输出实战:用唐诗项目搞懂fgets、scanf与字符编码

发布时间:2026/9/15 8:07:08
C语言输入输出实战:用唐诗项目搞懂fgets、scanf与字符编码 作为一个常年用C语言写东西的老家伙我最近带了一批刚入门的学员给他们的第一个综合练习就是“用C语言实现唐诗输入输出”。这个题目听起来很简单——把一首诗读进来再打印出去好像谁都会。但实际上手之后C语言的输入输出细节足够让人翻一晚上的车。它适合两类人看一类是刚接触C语言想系统搞明白printf、scanf、fgets、getchar这些输入输出函数到底怎么配合使用的初学者另一类是平时做嵌入式、写命令行小工具需要频繁处理控制台和文件交互的开发者。唐诗这个载体选得很巧妙它是多行中文文本包含逗号、句号、空格、长短句变化几乎把一个基础输入输出程序能遇到的考点全占了。所以这篇文章不打算泛泛讲函数手册而是用“唐诗输入输出”这个小项目作为主线把C语言输入输出的设计思路、实现细节、踩坑经验一次讲透。1. 内容整体设计与思路拆解1.1 为什么选“唐诗”作为输入输出练习的载体很多人入门C语言时写的第一个输入输出程序是读整数、读字符比如让用户输入一个数字再原样输出。这种练习能跑通但会把初学者带偏让他们误以为scanf加printf就是输入输出的全部。实际上真实项目里的输入很少是“一个整数”这种干净数据。你拿到的往往是整行文本、带空格的句子、多行文件、有编码差异的中文字符串。唐诗恰好把这些复杂性都集中到一起了它是多行文本天然需要循环读取不能指望一次scanf搞定。每行长度不固定短到“床前明月光”长到“故人西辞黄鹤楼烟花三月下扬州”这考验对数组边界和缓冲区上限的处理。带有中文标点、空格直接暴露scanf遇空格即停的问题。涉及编码问题中文在GBK和UTF-8下占用的字节数不同处理不当就是乱码。你可以把“读入一首诗—原样输出—格式化输出—统计字数—文件读写”设计成一连串递进的小任务。做完这一套C语言的输入输出基本就算真正入门了。1.2 输入输出的三层形态标准流、文件流、字符串流C语言的输入输出经常被初学者理解成“scanf和printf”这是对它的严重窄化。从底层看C语言的输入输出实际上是在处理三种数据流形态标准输入输出流stdin/stdout程序默认和终端交互用户从键盘敲进去程序往屏幕打印出来。这是最常见的形态适合做交互式小工具。文件输入输出流FILE *通过fopen打开磁盘上的文件用fscanf、fgets、fprintf、fputs读写。这是程序间交换数据的主要方式也是实际项目里最常用的。字符串流sscanf/sprintf不直接和终端或文件打交道而是把格式化数据从字符串里解析出来或者把多个变量格式化成字符串。这种形态常用于数据预处理、日志拼装。我用一个生活化的类比标准输入输出是你在饭店前台直接点菜文件流是你拿着一张菜单回家慢慢看字符串流则是把菜单内容拆开加工成一份新的采购清单。三条路径的底层都是字节流但使用场景完全不同。做“唐诗输入输出”这个题目时我建议至少实现标准输入版本和文件版本各一套。只会在控制台里printf跟工程里要求的“从配置读入、写到日志”差距还是很大的。1.3 方案选型读一行到底用 scanf、getchar 还是 fgets这是做这个项目最先要做的选择。很多初学者第一反应是用scanf(%s, str)去读结果运行后发现问题很大scanf按%s读取时遇到空白字符空格、制表符、换行就停下来。你输入“床前明月光”它只读到“床前明月光”没问题但如果输入“故人西辞黄鹤楼烟花三月下扬州”它只读一个“故人西辞黄鹤楼烟花三月下扬州”直到空格不对它会在“故人西辞黄鹤楼烟花三月下扬州”之间的逗号处并不停但遇到空格和换行会停。所以输入带空格的句子scanf %s直接歇菜。有人会说用scanf(%[^\n], str)不就能读整行了吗语法上确实可以但这个格式串容易在缓冲区处理上出岔子换行符会残留在输入流里下一次读可能直接跳过。对新手来说这个写法不是首选。fgets才是读整行最稳妥的函数。它接受三个参数目标缓冲区、最大读取长度、文件流。它能完整读入包含空格的一行而且自动在末尾加字符串结束符\0安全性比gets好了太多。缺点就是它会连换行符一起读进来需要你判断并手动去掉。这个“去换行”的操作虽然小但它牵涉到字符串长度计算、指针操作恰恰是练习的重点。getchar适合另一个场景不需要按行缓冲而是一个字符一个字符地读。比如你要实现“逐字输出”特效或者要统计字符频率用getchar反而更直接。在后面做格式化输出时会用到。所以在方案上我的结论是主体用fgets按行读取特殊场景用getchar/putchar逐字符处理避免用scanf处理带空格的文本行。选型背后最重要的一句话是看你要处理的输入单位到底是什么行级就用fgets字符级就用getchar字段级才考虑scanf。2. 核心细节解析与实操要点2.1 单字符输入输出getchar 与 putchar 的正确使用姿势getchar从标准输入读取一个字符返回的是int而不是char这一点很多人忽略。它返回int的原因是不仅要容纳0到255的字符值还要用EOF通常是-1表示流结束。如果你把返回值直接塞进char变量在某些平台上遇到EOF时会发生截断导致判断失效。在唐诗输入输出项目里getchar有两个典型用法。一个是实现“逐字输出”的效果比如把整首诗一个字一个字打印出来中间加适当延时看起来像打字机动画另一个是统计整首诗里某个字出现的次数比如统计“月”字在《静夜思》里出现几次这个时候按字符遍历就很自然。putchar则是单字符输出。它和printf相比的优势是不用处理格式字符串输出单个字符时开销小在写底层调试工具时很常用。用putchar配合一个循环可以灵活控制每个字符后面的间隔符号比如在每两个汉字之间加空格排版。我用一个实例说明假设有一行字符串“床前明月光”你要实现每输出一个汉字后自动换行用putchar遍历字符串就非常方便不需要用printf的循环拼接。2.2 行级输入fgets 的三个参数到底意味着什么fgets的原型是char *fgets(char *str, int n, FILE *stream)。很多初学者只是背了签名不理解为什么n要传字符串数组的大小以及它和“读取一行”是什么关系。实际上fgets最多读取n-1个字符然后自动在末尾补\0。它这样做是为了保证就算输入的行非常长也不会把目标缓冲区写爆。如果一行内容超出了n-1个字符fgets不会报错而是读走前n-1个字符剩下的内容还留在流里等待下次读取。这既是安全机制也是一个隐藏的坑——你以为读完了一整行实际还有半行留在缓冲区里。我用一首长唐人绝句测试过如果缓冲区大小是64而一行有100个字符第一次fgets读走63个字符加\0第二次fgets继续读后面的字符。所以如果你是要“按行读完整文本”fgets要配合循环使用到行尾没读到换行符就继续下一轮读取直到遇到换行或EOF为止。在“唐诗输入输出”这个场景下绝大多数唐诗绝句每行不会超过几十个字符缓冲区开成512完全够用。我会在后面的代码里演示设置一个足够大的缓冲区再根据fgets返回值判断是否读到流末尾。2.3 中文字符编码为什么输出唐诗会乱码这是做中文项目绕不开的一个问题。C语言标准本身不关心字符串是什么编码它只把它们当字节序列处理。你printf一个中文串本质上是在向标准输出写入若干字节最终显示成什么取决于终端和编码表。中文常见的编码有两种GBK和UTF-8。在GBK编码里一个汉字占2个字节在UTF-8里一个汉字通常占3个字节。如果你的源码文件保存为GBK编译后的程序在UTF-8终端下运行printf的中文就会因为字节序列无法匹配而显示乱码。我建议的方式是源码文件统一用UTF-8保存终端也切到UTF-8这样可以避免绝大多数乱码。如果你在Windows命令行下运行默认代码页可能是GBK可以用chcp 65001切到UTF-8或者在程序里调用setlocale(LC_ALL, )让程序跟随系统区域设置。还有一个更隐蔽的问题统计“字数”时一个汉字占2字节还是3字节统计的结果会完全不同。如果只是按字节统计UTF-8下统计出的“字符数”会比人眼看到的汉字数多。要按“字符”统计需要了解编码规则按字节流解析出有效字符边界。在下面的代码里我会给出一个简单的UTF-8中文字符计数函数。这个知识点在日常开发中非常实用不光唐诗项目做日志分析、文本处理时也经常遇到。2.4 去掉fgets带换行符的经典操作fgets读取一行后如果这一行以换行符结尾那么换行符会出现在字符串末尾。我们往终端输出时puts和fputs会再追加一个换行导致输出出现空行在统计字数时如果那个换行符没去掉也会把\n数进去。去除换行的常见做法是先判断字符串最后一个字符是否为\n如果是就把它替换成\0。用代码写出来是size_t len strlen(str); if (len 0 str[len - 1] \n) { str[len - 1] \0; }这里要注意两点一是必须先判断len 0防止空字符串时访问str[-1]二是要养成习惯不要上来就写str[strlen(str) - 1] \0万一读到的本就是空行或者文件最后一行没有换行这个索引就是负数或者指向\0本身。如果一行特别长第一次fgets没读完整行那么末尾可能不是换行符而是普通字符此时直接改末尾会把一个有效字符覆盖掉。所以正确逻辑应该是只有当最后一个是\n时才移除它。这个细节是“去换行”操作里的经典考点每次都要留意。3. 实操过程与核心环节实现3.1 基础版代码从控制台输入一首唐诗并原样输出我先给出一个最直接的版本功能是从键盘输入一首诗以“end”作为结束标记然后原样输出。这个版本覆盖了fgets按行读取、去换行、判断结束三个核心点。#include stdio.h #include string.h #define MAX_LINE 256 int main(void) { char lines[100][MAX_LINE]; int count 0; printf(请输入唐诗内容每行一句输入end结束\n); while (count 100) { if (fgets(lines[count], MAX_LINE, stdin) NULL) { break; } size_t len strlen(lines[count]); if (len 0 lines[count][len - 1] \n) { lines[count][len - 1] \0; } if (strcmp(lines[count], end) 0) { break; } count; } printf(\n你输入的诗是\n); for (int i 0; i count; i) { printf(%s\n, lines[i]); } return 0; }这段代码有两个设计点可以说明一下。第一为什么用二维数组char lines[100][MAX_LINE]存多行因为要“先全部输入完再统一输出”那就必须把每一行都暂存下来。100行对唐诗绝句来说绰绰有余如果做《全唐诗》级别的项目这个方案就不合适得换成动态内存或链表。作为入门练习固定大小的二维数组是最容易理解的存储方式。第二为什么要判断fgets的返回值当用户在终端里按CtrlDUnix/Linux或CtrlZWindows结束输入时fgets返回NULL。程序不能假装还能继续读必须及时退出。很多初学版本不检查这个返回值项目一旦跑起来就出现死循环或者读到脏数据。3.2 文件版从poem.txt读入并输出到poem_out.txt标准输入输出的交互形式适合演示但实际工程里更多是把数据放在文件里处理。我改造上面程序让它从poem.txt按行读取再写入poem_out.txt。#include stdio.h #include string.h #define MAX_LINE 256 int main(void) { FILE *fin fopen(poem.txt, r); // 只读模式打开 FILE *fout fopen(poem_out.txt, w); // 只写模式打开 char line[MAX_LINE]; if (fin NULL || fout NULL) { printf(文件打开失败请检查 poem.txt 是否存在。\n); return 1; } while (fgets(line, MAX_LINE, fin) ! NULL) { size_t len strlen(line); if (len 0 line[len - 1] \n) { line[len - 1] \0; } fputs(line, fout); fputc(\n, fout); } fclose(fin); fclose(fout); printf(处理完成输出写入了 poem_out.txt。\n); return 0; }这里我想着重说一下fopen和fclose的配对使用。文件操作不像键盘输入一份文件被打开后操作系统会为它分配文件描述符和缓冲区。如果反复打开文件不关闭轻则文件内容没写入磁盘重则达到系统文件描述符上限导致后续open失败。所以fclose一定要和fopen成对出现最好在写代码时先写fclose再回头写中间逻辑。这是我在实际开发里养成的习惯推荐给你。输出端我用了fputs加fputc的组合而没有用fprintf原因是为了演示“行级输出”的粒度fputs负责写整行字符串fputc负责补一个换行符。它们在功能上等价于fprintf(fout, %s\n, line)但更接近底层操作运行效率和代码逻辑都更清晰。3.3 功能扩展统计全诗字数、输出每行长度学会了基础的输入输出之后可以往上加功能。我把唐诗输入输出程序扩展成一个“小工具”让它输出每行的字符长度和全诗总字数。这里就能把之前编码部分的知识点用上。假设输入文件是UTF-8编码一个汉字占3个字节。按字节算长度会把每个汉字都算成3肯定不对。所以要写一个函数按UTF-8编码规则统计中文字符数。UTF-8的多字节字符有固定特征首字节的高位连续1的个数表示这个字符占几个字节比如1110xxxx开头表示3字节字符后续字节都以10xxxxxx开头。#include stdio.h #include string.h #define MAX_LINE 256 static int utf8_count(const char *s) { int count 0; const unsigned char *p (const unsigned char *)s; while (*p) { if (*p 0x80) { count; p; } else if ((*p 0xE0) 0xC0) { count; p 2; } else if ((*p 0xF0) 0xE0) { count; p 3; } else if ((*p 0xF8) 0xF0) { count; p 4; } else { p; } } return count; } int main(void) { FILE *fin fopen(poem.txt, r); char line[MAX_LINE]; int line_num 0; int total 0; if (fin NULL) { printf(文件打开失败。\n); return 1; } while (fgets(line, MAX_LINE, fin) ! NULL) { size_t len strlen(line); if (len 0 line[len - 1] \n) { line[len - 1] \0; } int chinese_count utf8_count(line); total chinese_count; line_num; printf(第%d行%-30s 字符数%d\n, line_num, line, chinese_count); } printf(全诗总字符数%d\n, total); fclose(fin); return 0; }运行输出类似第1行床前明月光 字符数5 第2行疑是地上霜 字符数5 第3行举头望明月 字符数5 第4行低头思故乡 字符数5 全诗总字符数20这个扩展程序的难点不在输入输出而在“你怎么定义字数的统计粒度”。实际项目里数据清洗经常遇到这种事——同一个字段按字节算、按字符算、按视觉宽度算结果完全不同。在C语言里strlen是字节长度utf8_count是字符数如果要做终端对齐还得计算显示宽度因为英文是半角、中文是全角。我在代码里给出的是最基础的UTF-8解析熟悉了这个套路以后再接触ICU、libiconv这类库就会轻松很多。3.4 格式化输出用printf控制排版输出唐诗时排版决定了体验。如果只是傻瓜式地一行一个puts遇到用户输入时行尾多了一个空格或者有些行短有些行长输出就会很乱。printf支持的格式控制可以帮上忙。printf的格式串里%-30s表示左对齐并占30个字符宽度%5d表示右对齐并占5个字符宽度。但要注意这里“宽度”计算的是字节数不是视觉宽度。中文字符在UTF-8下占3字节你写%-30s时C语言会认为一个汉字占了3个宽度排版结果会和终端实际显示宽度不一致。想要真正对齐中文必须自己计算“显示宽度”然后把中文字符宽度按2来算西文字符按1来算再动态生成格式控制。在入门阶段我建议先不做复杂排版而是把注意力放在“输入输出逻辑是否正确”上。等把基础打通再考虑做类似表格对齐的工具。因为对齐问题一展开又是一个大坑终端宽度、制表符、中英文混排、全角半角每一项都可以写一整篇。4. 常见问题与排查技巧实录4.1 中文乱码源码、程序、终端、文件编码不一致乱码是我在带人做这个项目时出现频率最高的问题。我在Windows上遇到过这样的情况代码用Visual Studio默认保存成GBK文件里的poem.txt用记事本保存成ANSI程序在命令行运行一切正常但同样一份代码拿到Linux下编译文件换成了UTF-8输出就乱了。排查顺序我建议固定下来先看源码文件保存编码再看输入文件编码再看终端代码页。三者不一致必乱无疑。最简单稳妥的组合是开发工具保存成UTF-8输入文件保存成UTF-8终端也切到UTF-8Windows下chcp 65001。4.2 scanf 和 fgets 混用导致第二次读取失败下面这段代码是经典的翻车现场int num; char line[100]; scanf(%d, num); fgets(line, 100, stdin);运行时会发现fgets好像什么都没读到程序直接就往下走了。原因是scanf读取整数后输入缓冲区里还留着一个换行符那个\n没有消耗掉紧接着的fgets就把这个看似是“空行”的数据读走了。解决思路有两个一个是在scanf后面加一个while (getchar() ! \n);把残留字符清掉另一个是统一用fgets读每一行再用sscanf把行里的数字解析出来。我倾向于后者因为清空缓冲区的写法在读到EOF时容易死循环处理起来要额外小心。在“唐诗输入输出”这个项目里我干脆建议全程不要用scanf只使用fgets和getchar。要么把输入当作“行”来处理要么把输入当作“字符流”来处理混用数据粒度不同的读取函数是缓冲区问题的根源。4.3 fgets只读了一半缓冲区太小导致断行如果你把MAX_LINE开得很小比如8字节读取“床前明月光”时fgets只取前7个字符加\0剩下的“光”和换行符还留在输入流里。下一次调用fgets会继续读到剩余部分而不是“下一行”。表面现象就是一首诗被打散成好几行输出注意观察才发现每行数量不对。遇到这种问题最快定位方式是临时在循环里加打印观察strlen返回的字节长度是否接近缓冲区上限。如果每次都等于上限减1说明缓冲区太小行没有被完整读完。解决方式是增大缓冲区或者改用动态分配内存来匹配行长度。4.4 常见问题速查表现象可能原因快速排查方法解决方案输出中文变乱码源码/终端/文件编码不一致用十六进制工具查看文件字节统一使用UTF-8fgets没读到内容缓冲区有scanf残留的换行打印读入字符串的十六进制统一用fgets或清空残留每行多一个空行未移除换行符查看len和line[len-1]判断末尾是否为\n诗歌被打散成多行缓冲区太小检查strlen接近上限减1增大缓冲区或动态分配fputs输出文件为空fopen用错模式或未fclose检查文件权限和关闭时机用w模式并成对关闭统计字数偏多按字节统计中文字符输出strlen结果对比用UTF-8解析函数统计这张表是给新手排障用的也可以当成交付前的自查清单。每一条都是我或者学员真实踩过的坑尤其是头两条几乎每次都有人遇到。5. 从练习到工程输入输出还能怎么扩展5.1 项目变体把唐诗输入输出改成一个小工具基础版做完之后可以朝两个方向扩展锻炼工程能力。一个是“输入侧扩展”。把“从键盘输入”换成“从文件输入”再换成“从管道输入”。在Unix/Linux环境下你可以用cat poem.txt | program这种方式把一段文本喂给程序。这背后是标准输入的重定向理解了stdin就是文件流以后写过滤器类工具就很顺。甚至可以进一步支持命令行参数比如program poem.txt out.txt用argv接收文件名而不是在代码里写死。另一个是“输出侧扩展”。把纯文本输出改成JSON格式比如输出{title:静夜思,author:李白,content:[...]}。JSON虽然格式简单但要处理引号转义、数组拼接对输入输出能力是不错的锻炼。也可以做HTML格式输出把每行诗包进p标签。这个扩展对理解“输出数据供其他程序消费”很有帮助。5.2 代码组织的工程化建议当项目超过两个文件时我建议把所有输入输出相关的函数拆到一个独立模块里比如poem_io.h和poem_io.c。头文件里声明函数原型源码文件里实现。这样主程序只需要关注逻辑输入输出细节被封在模块内部。封装时注意一点输入输出函数要尽量减少对全局变量的依赖。比如读取函数考虑传入文件指针而不是默认读stdin。这样同一个函数既能处理键盘输入也能处理文件输入测试时还可以传入已打开的测试文件流。这是一个非常简单的“依赖注入”思路但很多初学者写代码时容易忽略把stdin写死导致后面想换成文件时得大改代码。5.3 我的实际体会带过几批人做这个唐诗输入输出项目我最深的感受是C语言的输入输出真不是一个printf、一个scanf就能概括的。它需要你在“行”和“字符”两个粒度之间自由切换需要你理解缓冲区、编码、流状态。学完这些后面再去碰文件读写、socket收发、嵌入式串口调试会顺非常多。如果你也想练手我建议把标准输入版本、文件版本、统计字数版本都独立写一遍再把其中某一个功能改造成API接口。三个版本写完输入输出这道坎基本就真正迈过去了。