拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言字符串输入输出全解析:从scanf到fgets的避坑指南

C语言字符串输入输出全解析:从scanf到fgets的避坑指南

入坑C语言的人,十有八九都被"字符串的输入输出"这堂课劝退过一次。在Python里一个 input() 搞定的事情,在C语言里要跟字符数组、'\0'、缓冲区、换行符打交道,稍不留神程序不是崩溃就是输出一串乱码。我在带新人、帮人改代码的这些年里,发现绝大多数字符串相关的bug,根子都出在最基础的输入输出环节。所以这篇文章专门把"字符串的输入输出"这件事拆开揉碎讲清楚:先搞懂底层存储,再搞懂 printf、scanf、fgets、puts 各自的脾气,接着演示缓冲区残留换行符这个最经典的翻车现场,最后补上逆序、比较、排序等紧接着要用的基本功。内容全部从零讲起,适合刚学完变量、分支和循环的初学者,也适合那些"scanf 用了一年但说不清为什么有时会出bug"的朋友。

1. 先把地基打好:C语言的"字符串"到底是个什么东西

1.1 字符串不是基本类型,而是一条"字符数组 + 结束标记"的约定

写代码前先把这个观念建立起来:C语言里不存在像 int、double 那样的字符串类型。你看到的"字符串",本质是一个 char 数组,加上一个约定——数组里必须有一个值为0的字符 '\0'(转义后的ASCII码0)放在有效字符的末尾,用来标记字符串结束。

我习惯用一个生活化的比喻:'\0' 就是快递盒上的"到此为止"封条。printf、strlen 这类函数根本不知道你定义的数组有多大,它们的完成依赖一个共识:从头开始处理字符,遇到 '\0' 就停下来。没有这张封条,它们就会一路走下去,直到读取到内存中随机出现的某个0字节才停,这就是经典的"越界读"和"野指针式乱码"来源之一。

1.2 两种写法看似相同,一个能改,一个改了会死

初学者最容易在这上面翻车:

char s1[] = "hello"; // 数组版本 char *s2 = "hello"; // 指针版本
  • char s1[] = "hello";会在栈上分配一个长度为6的字符数组(5个字母 + 1个'\0'),里面的内容可以随便改,s1[0] = 'H'完全合法。
  • char *s2 = "hello";则是指针指向内存中只读区域里的字符串字面量。你执行s2[0] = 'H',在不少环境下会直接段错误崩溃。

这是C语言的经典陷阱:凡是看到char *指向一个字符串字面量,默认就不要去修改它;凡是明确要改内容的,就用数组。判断一句代码是否安全,先看它写在数组里还是指针里,这个习惯值得从第一天养成。

1.3 sizeof 和 strlen:一组几乎人人混淆的概念

string.h 里的 strlen 和关键字 sizeof 经常出现在同一道题里,但含义完全相反:

char s1[] = "hello"; // sizeof 结果是 6,strlen 结果是 5 char s2[100] = "hi"; // sizeof 结果是 100,strlen 结果是 2
  • sizeof(s1)问的是"这块内存盒子总共多大",包含'\0',甚至包含你没用到的部分。
  • strlen(s1)问的是"在'\0'之前装了多少个有效字符",不看盒子大小,只数实际内容。

如果考试或面试里出现sizeof("hello"),答案是 6 而不是 5,这就是最经典的扣分点。把"盒子大小"和"内容长度"分开记,后面学 strcpy、memcpy 缓冲区问题时,你才能避免 50% 以上的复制越界。

2. 输出字符串:printf 顺手,但 puts 和格式化细节值得单独学

2.1 printf("%s") 的工作逻辑:一路打印到'\0'为止

printf("%s", str);是大多数人认识的第一个字符串输出方式。它的执行逻辑是:从 str 指向的首地址开始,一个字符一个字符地送到标准输出,直到读到一个 '\0' 才停下。操作系统的 printf 实现还会把数据先写入 stdout 的缓冲区,在遇到换行或缓冲满时刷新,这一点第四章我们还会深入讲。

2.2 puts 和 fputs:换行符到底谁来加

很多人只知道 printf,其实还有两个专门输出字符串的函数:

puts(str); // 输出字符串后自动补一个 '\n',相当于 printf("%s\n", str) fputs(str, stdout); // 只输出字符串,不自动换行

puts适合偷懒,比如打印提示信息;fputs适合需要严格控制换行位置的场合。两者都只接受字符串参数,不能像 printf 那样拼格式,所以日常主力还是 printf。但我一直建议初学者把 puts 练熟,因为刷题时很多输出格式要求"逐行输出",puts 恰好能少写一个转义字符。

2.3 printf 的宽度与精度:格式化输出比你想的更强大

printf("%20s\n", str); // 右对齐,总宽度至少20个字符 printf("%-20s\n", str); // 左对齐,总宽度至少20个字符 printf("%.5s\n", str); // 最多输出前5个字符,后面的不打印 printf("%20.5s\n", str); // 组合使用:宽度20、最多5个字符

这在输出对齐表格、限定长度显示时非常实用。特别是%.5s,它能在不截断原数组的情况下只输出前缀,原理就是 printf 的内部循环在数到第5个字符时手动停下,外界根本看不出来你处理过数据。

2.4 缺少 '\0' 的翻车现场

看下面的代码:

char s[5] = {'h', 'e', 'l', 'l', 'o'}; printf("%s\n", s);

这个数组根本没有 '\0',printf 不知道在哪里停,就会继续打印旁边内存里的内容,直到碰上一个随机的0字节才停,结果就是乱码甚至崩溃。解决办法很简单:要么多放一个元素存 '\0',要么手动补上。所有字符串的越界和乱码,一半以上是因为"忘了给'\0'留位置"。记住这句话,能帮你省下很多排查时间。

3. 输入字符串:scanf 的短板、gets 的消失,以及值得信任的 fgets

3.1 scanf("%s") 最大的问题:一到空格就"罢工"

char buf[100]; scanf("%s", buf);

当输入hello world时,buf 只装下hello,world 留在缓冲区,下一次读取会直接拿到它。因为%s的读取规则是:跳过前导空白,然后一直读到下一个空白字符为止。换句话说,它天生就不支持含空格的字符串。

扛不住空格只是小问题,更要命的是它默认不做边界检查。只要你输入超过数组容量,scanf 会一路写下去,把相邻内存踩烂,轻则变量被改、重则程序崩溃。你在刷题网站看到的安全写法,一般都会带宽度限制:

scanf("%19s", buf); // 最多读19个字符,留1个给'\0'

数组声明为char buf[100];,宽度就写99,永远不要把宽度填满,因为 scanf 会自动在末尾补 '\0'。

3.2 gets():为什么它从 C11 开始被废弃、C17 被直接移除

很久以前的教材会让你用 gets(buf) 读带空格的整行。它虽然方便,但有个致命的缺陷:它不知道缓冲区有多大,也没有任何参数可以限制长度。用户输入多长,它就往内存里塞多长。当年著名的缓冲区溢出攻击,大量就是通过 gets 这类函数做的。

C11 标准把它标记为废弃(obsolescent),C17 干脆从标准库里删掉了。现在的编译环境里使用 gets,要么编译报错,要么弹出一堆 warning。我的建议是:不管编译器允许不允许,永远不要在正式代码里用 gets。它的合法替代品,就是我们下面讲的 fgets。

3.3 fgets:既安全又会"留着换行符"

fgets 是实际工程里读取字符串的首选:

char buf[100]; fgets(buf, sizeof(buf), stdin);

它的行为有三点要记住:

  • 最多读取size - 1个字符,然后自动补 '\0',不会越界。
  • 如果在一行内读到了 '\n',会把'\n'也存进来,之后再加 '\0'。
  • 如果输入超过缓冲区,它只保留前size - 1个字符,多余部分留在缓冲区里。

第二点正是初学者最容易困惑的地方:用 fgets 读到的字符串结尾往往带着一个换行符。如果你要做字符串比较或者逆序输出,这个 '\n' 会捣乱。清理的办法很多,最顺手的是:

buf[strcspn(buf, "\n")] = '\0'; // 把第一个换行符换成结束符

strcspn返回 buf 中第一个出现 '\n' 的位置下标,没有就返回整个字符串长度。所以这一句能兼容"有换行"和"没换行"两种情况,是我个人最推荐的一种清理手法。

3.4 四种输入方式对比

方式支持空格自动补'\0'是否保留'\n'边界安全目前态度
scanf("%s", buf)否是否不安全只能读单词
scanf("%19s", buf)否是否相对安全读单词可用
gets(buf)是是丢弃非常危险已废弃/移除
fgets(buf, size, stdin)是是保留安全推荐首选

一句话总结选型:能读单词的场合用scanf("%19s", ...),能读整行的场合用fgets,别去碰 gets。

4. 经典翻车实录:scanf 留下的"幽灵换行符"怎么排查与解决

4.1 问题现场:数字读完之后,字符串怎么读都是空的

这是我在帮新人改代码时遇到最多的组合错误:

int n; char name[100]; scanf("%d", &n); // 输入 123 后按回车 fgets(name, sizeof(name), stdin); // 你以为能读名字,实际上什么都没读到 printf("name=[%s]\n", name);

运行结果 name=[] 或者只输出一个换行。原因不是 fgets 坏了,而是 scanf 读完数字123后,你按下的那个回车键'\n'还留在输入缓冲区里。fgets 一上来就把这个 '\n' 当成了"一行读完了",于是直接返回,一行真正的文字根本没机会读进去。

4.2 排查链路:从"看到空字符串"到"锁定残留换行"

当年我排查这类问题,顺序基本固定,你也照着做就行:

  1. 在读取后立刻打印printf("[%s]\n", name);,用方括号把内容括起来,空字符串和"只有换行"立刻现形。
  2. 检查 fgets 的返回值。fgets 返回指针,成功读到内容返回 buf,读到文件尾或出错返回 NULL。如果第一次调用就返回 NULL,说明缓冲区里没能构成一行有效输入。
  3. 打印缓冲区的每个字符,例如printf("%d ", name[0]);,如果第一个字符值恰好是 10('\n' 的 ASCII 码),就实锤了残留换行问题。

第3步是最直观的证据。很多新人卡在"明明调用了函数为什么什么都没干"的困惑里,一旦看到 name[0] 等于 10,马上就能理解整个机制。

4.3 清理缓冲区的常见三招

清理残留的空格、换行、或者一行没读完的残留数据,可以这样:

// 方法1:一个个吃掉字符,直到换行或文件结束 int c; while ((c = getchar()) != '\n' && c != EOF) ; // 方法2:用 fgets 把残留的一行读走 char temp[256]; fgets(temp, sizeof(temp), stdin); // 方法3:干脆别用 scanf,数字也改用 fgets + sscanf char line[256]; fgets(line, sizeof(line), stdin); sscanf(line, "%d", &n);

方法1是最干净的通用做法,注意c必须声明为 int 而不是 char,因为 EOF 通常是一个负数,用 char 可能把它截断;方法2在某些题目里会被误解为"多读一行",需要小心;方法3是我现在最推荐的正统方案,后面单独讲。

4.4 统一方案:数字和字符串都用 fgets 读,再各自解析

既然 scanf 和 fgets 混用会踩坑,不如统一都走 fgets 路线:

char line[256]; int n; char name[100]; fgets(line, sizeof(line), stdin); // 读整行 sscanf(line, "%d", &n); // 从行里解析数字 fgets(name, sizeof(name), stdin); // 再读一行,不会受残留影响 name[strcspn(name, "\n")] = '\0'; // 去掉结尾换行

这样做的核心思想是:每一次输入都按"整行"为单位消费,读完一行就是一行,scanf 那种"只消费一部分、留一部分"的问题自然就不存在了。代价是稍微多写两行代码,但对于初学者来说,稳定的心智模型远比少打字重要。我的建议是,从学会 fgets 那天起,把所有 scanf("%d") 都替换成 fgets + sscanf 的组合,习惯之后你会感谢这个决定。

5. 输入输出之后紧接着要掌握的基本功:逆序、比较、排序

5.1 字符串长度与逆序输出的黄金搭档

学了 strlen 以后,逆序输出一个字符串几乎是所有教材和OJ题的标配:

char s[128]; fgets(s, sizeof(s), stdin); s[strcspn(s, "\n")] = '\0'; // 先去掉换行,否则逆序时会多出一个 '\n' int len = strlen(s); for (int i = len - 1; i >= 0; i--) { putchar(s[i]); } putchar('\n');

注意先去 '\n' 再算 strlen,否则逆序结果末尾会带着一个换行符。至于为什么很多人的循环写成for (i = len; i >= 0; i--),那是笔误,正确的下标范围是len - 1到0,因为第 len 个位置是 '\0'。

5.2 字符串比较:为什么 "abc" == "abc" 是错到离谱的写法

初学者特别容易写出if (s == "abc")这种代码,而且编译还能通过。问题是这里的s和"abc"都是地址,比较的是"两个地址是否相等",而不是"内容是否相等"。两个不同数组即使内容完全相同,地址也必然不同。

正确做法是使用 strcmp:

#include <string.h> if (strcmp(s, "abc") == 0) { // 字符串内容完全相等 } else if (strcmp(s, "abc") < 0) { // s 在字典序上小于 "abc" } else { // s 在字典序上大于 "abc" }

strcmp 按照字符的 ASCII 码逐个比较,返回值是负数、0 或正数,分别对应"小于""等于""大于"。注意判断相等一定要写== 0,很多人随手写if (strcmp(s, "abc")),那判断的是"不相等",语义正好反了。

5.3 字符串排序:本质就是 strcmp + 交换

有了 strcmp,排序字符串数组就很简单了,拿最直观的选择排序举例:

char words[5][20] = {"banana", "apple", "cherry", "date", "elderberry"}; for (int i = 0; i < 4; i++) { for (int j = i + 1; j < 5; j++) { if (strcmp(words[i], words[j]) > 0) { char tmp[20]; strcpy(tmp, words[i]); strcpy(words[i], words[j]); strcpy(words[j], tmp); } } }

二维字符数组words[5][20]本身就有隐患:每一行只能存19个字符,超了会越界。更灵活的方案是用char *words[5]的指针数组,交换时只交换指针,不做字符串拷贝,效率更高,但问题是字符串字面量不可修改。实际项目里更常用的是qsort,不过对刚入门的人,先把 strcmp + 交换这个逻辑吃透,面试问"手写字符串排序"时才能从容写出来。

5.4 中文处理与多字节编码的提醒

严格来说,C语言的标准字符串操作都是按"字节"进行的,不关心字符编码。在 UTF-8 环境下,一个汉字占3个字节:

char s[] = "你好"; printf("字节数: %zu\n", strlen(s)); // 输出 6,而不是 2 printf("%s\n", s); // 能正常显示"你好"

strlen、strcpy 这类函数能正确处理中文,只是因为它们不区分"字符"和"字节",按字节搬运罢了。真正的问题是逆序、截断这类"按字符"操作:你不能靠s[0]捞出一个"你"来,因为它是3个字节拼出来的。初学者阶段,你只需要记住:中文没问题,但做逆序、比较、取中间字符时,要先确认编码和字节数,否则结果大概率是乱的。等到后面学到宽字符 wchar_t 和字符编码转换,再回头解决中文按字符处理的完整方案也不迟。

6. 记一些让我少踩坑的实操习惯

到最后一个章节,想分享几条这些年总结出来的个人习惯,写代码时照着做,能省去大量低级调试时间。

字符串一律分配足够的空间并主动写 '\0'。凡是定义字符数组,养成"多留一字节"的肌肉记忆。数组长度是 100,字符串最大有效长度就按 99 算。凡是手动填字符,末尾一定记得补 '\0',别让程序去赌运气。

输入优先 fgets,解析辅助 sscanf。我几乎不再用 scanf 直接读数字,因为混用带来的换行符残留问题太折磨人。统一用 fgets 读一行、再 sscanf 解析,逻辑简单,还顺手把输入校验做了。

调试时打印"能看到边界"的格式。printf("[%s]\n", name);和printf("%d\n", name[0]);这两招,能快速区分"空字符串""换行符残留""乱码"三种几乎一样的外在表现。我见过太多人对着乱码猜半天,其实打印一下第一个字节的值立刻就有答案。

把练习题的坑记在笔记本上。牛客、PTA、浙大翁恺的练习题里有大量字符串题,比如逆序输出、删除指定字符、统计单词数、字符串排序,它们本质上都在考本章讲的几个函数和 '\0' 意识。每踩一个新坑,就把"现象 -> 原因 -> 解决方案"三行写下来,半年后回头看,你会发现自己调错的速度快了一倍不止。

我个人在带新人的时候经常说:字符串的输入输出是C语言的"第一道分水岭",跨过去之后,指针、动态内存、结构体都会顺很多。以上这些技巧不复杂,但都是我在实际调试里一条条试出来的,希望能让你的入门路少几个晚上熬夜查bug的记忆。

返回列表