Menu

C 语言 scanf 用法:读取输入、& 规则与常见失效原因

如何在 C 语言中用 scanf 读取输入:为什么必须写 &,空白字符如何处理,%s 的缓冲区溢出及用宽度修复,检查返回值,残留换行符的 bug,以及更稳妥的替代方案 fgets。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

scanf 是 printf 的镜像:同一个头文件,相似的格式字符串,方向相反。它从标准输入读取文本,按照格式进行转换,再通过你提供的指针把结果存起来。

它也是 C 语言中让初学者最困惑的函数,因为它有三种行为出人意料,其中一种还是安全漏洞。本页把这四点都讲清楚。

基本形式,以及为什么要写 &

运行时输入一个数字并按回车。&age 不是装饰。C 语言的所有参数都是按值传递的,所以如果你写成 scanf("%d", age),函数收到的只是 age 当前(垃圾)值的副本,根本无法访问变量本身。&age 传入的是变量的地址,scanf 再通过这个地址写入。完整的解释请参阅指针。

漏写 & 是最常见的 scanf 错误。开启 -Wall 的编译器能发现它(format '%d' expects argument of type 'int *');不开启警告时,它能编译通过,然后破坏某个随机地址上的内存。

读取多个值

一次调用可以读取多个值。输入中的空白字符(空格、制表符、换行符)把它们分隔开,数值格式说明符会跳过任意数量的空白。

"%d %d %d" 中的空格其实是多余的,因为 %d 本来就会跳过开头的空白,但它们让格式更易读。不多余的是检查返回值,下文会讲到。

格式中的非空白字符必须和输入完全匹配,这就是读取结构化输入的方法:

格式中的 : 要求输入中也有一个 :。输入 9:30 就能解析;输入 9 30,读完 9 之后就失败了。

一定要检查返回值

scanf 返回的是成功赋值的项数,不是字符数,也不是表示成功与否的标志。如果在转换任何内容之前输入就结束了,它返回 EOF。

在提示处输入 hello。scanf 返回 0,n 保持不变,仍然是最初的垃圾值,而那段出错的文本还留在输入流中。正是最后这一点,让一次错误输入变成了死循环:

/* BUG: spins forever on non-numeric input */
while (scanf("%d", &n) != 1) {
    printf("Try again: ");
}

scanf 从不消耗它无法转换的文本,所以下一次调用会在相同的字符上再次失败,永无止境。要恢复过来,你必须自己丢弃这一行剩下的内容:

检查 EOF 很重要:当输入流结束时(用户按下 Ctrl+D,或者管道传入的文件读完了),scanf 之后会一直返回 EOF,所以只判断 != 1 的循环会无休止地重试。把“错误输入”和“没有更多输入”当作两种不同的情况处理。做到这一点后,任何用 scanf 读取人工输入的程序都值得保留 discard_line 这个辅助函数。

%s 的缓冲区溢出

%s 把字符读入一个 char 数组。注意这里没有 &:数组名本身就会退化为指向首元素的指针,所以 &name 的类型是错的。

问题在于,单纯的 %s 完全不知道你的数组有多大:

char name[10];
scanf("%s", name);      /* DANGEROUS: types 40 characters, writes 41 bytes */

没有任何东西会在数组末尾停止写入。它会一直写进内存中紧随其后的东西,比如其他变量、返回地址,这就是经典的缓冲区溢出,历史上被利用最多的一类 C 语言 bug。解决办法是在 % 和 s 之间加一个宽度,比数组大小小 1,为结尾的 '\0' 留出空间:

宽度必须以字面量的形式写在格式字符串里,这让它很难和数组大小保持同步,这也是大多数生产环境中的 C 代码改用 fgets 的又一个原因。

%s 的第二个意外:它遇到第一个空白字符就停止。输入 Ada Lovelace,name 里只有 Ada;Lovelace 留在输入流中等待下一次读取。%s 读取的是一个单词,而不是一行。

残留换行符的 bug

这就是人人都会遇到的“scanf 不起作用”。数值格式说明符会跳过开头的空白,%c 不会。

没有那个空格时会发生什么:你输入 30 并按回车。%d 读走 3 和 0,在换行符处停下,把 '\n' 留在缓冲区里。接下来的 scanf("%c", &initial) 把这个换行符当作字符读走,然后立即返回:提示刚出现就被跳过了。

scanf 格式字符串中的空格表示“在这里跳过任意数量的空白”,所以 " %c" 会跳过多余的换行符,等待一个真正的字符。只有在 %c 和 %[ 之前才需要这个空格;其他格式说明符都会自己跳过空白。

读取浮点数和字符

格式说明符大多和 printf 相同,但有一个重要区别:scanf 需要知道目标变量的确切大小,因为它是通过指针写入的。

试试输入 1.5 2.25 7 x。

%f 读取 float,%lf 读取 double。在 printf 中两者打印效果一样,因为 float 在调用前会被提升为 double;但在 scanf 中没有提升,用 %f 配合 double * 只会往一个八字节的变量里写入四个字节,导致它的值被破坏。这里的 %c 前面不需要空格,因为它前面的 %d 停在了一个空格处,而不是换行符处。(完整表格见格式说明符。)

fgets:更稳妥的替代方案

凡是人输入的内容,都先读取整行,再进行解析。fgets 把缓冲区大小作为参数,所以根本不可能溢出;当这一行放得下时,它还会把换行符一起读走,不会留下任何东西干扰下一次读取。(比缓冲区还长的行,其末尾部分会留着没读,你读到的内容中缺少 '\n' 就是判断的依据。)

关于这种写法有三点:

  • sizeof line 传入的是真实大小,所以修改数组大小时不需要改动其他地方,这一点和 %19s 字面量不同。
  • fgets 会保留换行符(如果这一行放得下的话),所以才需要用 strcspn 去掉它。这也是检测一行内容对缓冲区来说太长的方法:读到的内容里没有 '\n'。
  • 之后再解析。 整数用 strtol(它还能告诉你解析停在了哪里),浮点数用 strtod,或者用 sscanf(line, "%d %d", &a, &b),对已经安全拿到手的字符串套用 scanf 格式。

sscanf 值得单独一提:它就是从字符串而不是输入流读取的 scanf,和 fgets 搭配得天衣无缝。你既能享受 scanf 方便的解析,又没有它那些输入流方面的问题。

常见错误

  • 非数组参数忘了写 &。编译时加上 -Wall。
  • %s 没有宽度。一个等着长输入来触发的缓冲区溢出。
  • 忽略返回值。结果读取了一个 scanf 从未赋值的变量。
  • scanf 失败后循环重试却不丢弃输入。死循环。
  • 读取数字后,%c 前面没加空格。提示被跳过的 bug。
  • 用 %f 读取 double。悄无声息的数据破坏;scanf 需要 %lf。
  • 以为 %s 能读取一整行。它只读取一个以空白分隔的单词。

常见问题

C 语言中 scanf 为什么需要 &?

因为 C 语言按值传递参数。scanf 必须修改你的变量,所以它需要的是变量的地址,而不是其值的副本:scanf("%d", &age)。唯一的例外是字符数组,它的名字本身就是地址,所以 scanf("%19s", name) 不加 &。

为什么 scanf 读取 char 或字符串时跳过了我的输入?

前一个 scanf("%d", ...) 把回车键产生的换行符留在了输入缓冲区里。%d 和 %f 会跳过开头的空白字符,但 %c 不会,所以它直接读走了那个换行符,而不是等待输入。解决办法是在格式中加一个空格:scanf(" %c", &ch),它告诉 scanf 先跳过空白字符。

C 语言中怎样用 scanf 安全地读取字符串?

给 %s 指定一个比缓冲区小 1 的最大宽度:char name[20]; scanf("%19s", name);。没有宽度时,用户输入多少字符,%s 就写入多少,毫不在意地越过数组末尾。另外记住,%s 遇到第一个空白字符就停止,所以它无法读取中间带空格的全名。

C 语言中应该用 scanf 还是 fgets?

凡是人输入的内容都用 fgets。它把一整行读入一个指定大小的缓冲区,不会溢出,并且(当这一行放得下时)连换行符一起读走,所以经典的混合输入 bug 都消失了。之后再用 sscanf 或 strtol 解析这一行。scanf 适合小练习和格式规范的机器输入。

Coddy 编程语言插图

和 Coddy 一起学编程

开始