在 C 中读取用户输入的陷阱:关于 scanf 和 Stdin 的故事

发布: (2025年12月12日 GMT+8 05:40)
5 min read
原文: Dev.to

Source: Dev.to

C 中读取用户输入的陷阱:关于 scanfstdin 的故事

我最近需要写一段 C 代码,从 stdin 读取输入,忽略换行符,丢弃超出缓冲区的内容,并在循环中重复此操作。

初始尝试

#include <stdio.h>

void take_input(void)
{
    char buf[20];
    printf("> ");
    scanf("%19[^\n]", buf);
    printf("input=`%s`\n", buf);
}

int main(void)
{
    for (int i = 0; i < 5; ++i) {
        take_input();
    }
    return 0;
}
$ ./main
> hello world↵
input=`hello world`
> input=`hello world`
> input=`hello world`
> input=`hello world`
> input=`hello world`
$ █

它只读取了一次字符串,却把同一个值打印了五次。为什么?

栈行为

虽然 char buf[20] 是局部变量,但每次调用 take_input() 时使用的正是同一个栈内存地址。

/* ... */
printf("address=%p, input=`%s`\n", (void*)buf, buf);
/* ... */
$ ./main
> hello world
address=0x7ffcb96c49d0, input=`hello world`
> address=0x7ffcb96c49d0, input=`hello world`
> address=0x7ffcb96c49d0, input=`hello world`
...

因为缓冲区没有被初始化,后续的 scanf 调用未能覆盖它,旧内容(“幽灵数据”)保持不变。用零初始化缓冲区可以解决这个特定问题:

char buf[20] = {0};

现在每次缓冲区都会被重置,但 scanf 仍然表现异常:

$ ./main
> hello world
address=0x7ffea5c2d840, input=`hello world`
> address=0x7ffea5c2d840, input=``
> address=0x7ffea5c2d840, input=``
...

真正的问题:%[^\n] 并不消费换行符

扫描集 %19[^\n] 读取最多 19 个 不是 换行符的字符,但它会把换行符留在输入流中。

用 GDB 检查 stdin 时可以看到换行符仍然待处理:

$ gcc -g3 -O0 main.c -o main
$ gdb main
(gdb) break 8
Breakpoint 1 at 0x11d3: file main.c, line 8.
(gdb) run
...
> hello world↵
...
(gdb) p *stdin
$1 = {
    _flags = -72539512,
    _IO_read_ptr = 0x55555555972b "\n",
    _IO_read_end = 0x55555555972c "",
    _IO_read_base = 0x555555559720 "hello world\n",
    
}

stdin->_IO_read_ptr 仍指向换行符。当循环再次运行时,scanf("%19[^\n]", buf) 立刻看到换行符,匹配到零字符并中止。缓冲区保持为空(或全零),循环重复相同的输出。

可行的解决方案

1. 使用额外的 scanf 调用

强制消费剩余的行以及换行符:

void take_input(void)
{
    char buf[20];
    printf("> ");
    scanf("%19[^\n]", buf);
    /* 丢弃行的其余部分(如果超过 19 个字符) */
    scanf("%*[^\n]");
    /* 丢弃换行符 */
    scanf("%*c");
    printf("input=`%s`\n", buf);
}

看起来有点乱,但它在空白、短输入以及被截断的长输入情况下都能可靠工作:

$ ./main
> hey
input=`hey`
> hello world
input=`hello world`
> a b c d e f g h i j k l m o p q r s t u v w x y z
input=`a b c d e f g h i j k l m o p q r s t u v w x y z`
>      /* 5 个空格 */
input=`     `
> abcdefghijklmnpoqrstuvwxyz
input=`abcdefghijklmnpoqrs`

2. fgets —— 标准做法

更可预测、更明确的办法是使用 fgets 读取整行,然后去除换行符:

#include <stdio.h>
#include <string.h>

void take_input_2(void)
{
    char buf[20];
    printf("> ");

    if (fgets(buf, sizeof(buf), stdin)) {
        char *newline_ptr = strchr(buf, '\n');
        if (newline_ptr) {
            /* 用 \0 替换 \n 进行修剪 */
            *newline_ptr = '\0';
        } else {
            /* 未找到换行符 —— 输入被截断;
               继续消费该行的其余部分 */
            int c;
            while ((c = getchar()) != '\n' && c != EOF);
        }
    }
    printf("input=`%s`\n", buf);
}

3. getline —— 堆上方式

getline(POSIX,非 ISO C)读取整行,并在堆上分配(或重新分配)缓冲区。使用完后必须自行释放内存:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void take_input_3(void)
{
    char *line = NULL;      /* 初始化指针 */
    size_t cap = 0;          /* 容量,由 getline 更新 */
    ssize_t n;

    printf("> ");

    n = getline(&line, &cap, stdin);
    if (n > 0) {
        /* 删除尾部换行符 */
        if (line[n-1] == '\n') {
            line[n-1] = '\0';
        }
        printf("input=`%s`\n", line);
    }

    free(line);  /* 始终释放堆内存 */
}

结论

这次练习提醒我们,C 中的输入处理出奇地棘手。scanf 看似方便,但在遇到空白和换行符的边界情况时,“便利”会变成负担。

  • 对于可预测、可移植、以行为单位且需要截断处理的输入,fgets 几乎总是更好的选择。
  • 如果你可以使用 POSIX 并且愿意在堆上分配,getline 提供了更大的灵活性。
  • scanf 留给那些你完全了解输入格式的场景。
Back to Blog

相关文章

阅读更多 »

继续编码

介绍 我们都有这样的日子:本以为是个简单的修改去修复一个 bug,结果却花了一整天的时间去解决它。如果…