为什么可以在 scanf 的转换说明符中嵌入 null 字符?

Why can a null character be embedded in a conversion specifier for scanf?

提问人:William Pursell 提问时间:2/2/2021 最后编辑:William Pursell 更新时间:2/3/2021 访问量:601

问:

也许我误解了我的结果,但是:

#include <stdio.h>

int
main(void)
{
    char buf[32] = "";
    int x;
    x = scanf("%31[^\0]", buf);
    printf("x = %d, buf=%s", x, buf);
}
$ printf 'foo\n\0bar' | ./a.out
x = 1, buf=foo

由于字符串文本包含一个嵌入的 null,因此似乎应该将其视为 ,并且编译器应该抱怨 是不匹配的。事实上,如果你把字符串换成字面意思,clang 会给出:"%31[^\0]""%31[^"[

warning: no closing ']' for '%[' in scanf format string [-Wformat]

为什么在传递给 scanf 的字符串文本中嵌入 null 字符是有效的?

--编辑--

以上是未定义的行为,只是碰巧“工作”。

c 扫描

评论

3赞 Eugene Sh. 2/2/2021
也许编译器(它的静态分析器)只是没有“训练”来解释内部字符串文字?它不必具有完整格式的字符串解释器。\0
0赞 Steve Summit 2/2/2021
我会说它不起作用,因为你说的原因。
3赞 KamilCuk 2/3/2021
什么 C 库?油嘴滑舌?最有可能的是,当丢失时,它会继续解析。不过,这是你的错——这是 ub。我得到了它,它用 glibc 代码检查出来。]x = 0, buf=

答:

3赞 Steve Summit 2/3/2021 #1

这是一个相当奇怪的情况。我认为有几件事正在发生。

首先,根据 C 的定义,C 中的字符串在第一个 .你总是可以嘲笑这个规则,例如,通过编写一个字符串文字,中间有一个显式。但是,当您这样做时,后面的字符大多是不可见的。很少有标准库函数能够看到它们,因为当然,几乎所有解释 C 字符串的东西都会在它找到的第一个时停止。\0\0\0\0

但是:您作为第一个参数传递的字符串通常会被解析两次 - 我所说的“解析”是指实际上被解释为可能包含特殊序列的 scanf 格式字符串。它总是会在运行时由 C 运行时库中的实际副本进行解析。但它通常也由编译器在编译时进行解析,以便编译器可以在 % 序列与你调用它的实际参数不匹配时发出警告。(当然,运行时库代码无法执行此检查。scanf%scanfscanf

当然,这里有一个相当重要的潜在问题:如果编译器执行的解析在某种程度上与运行时库中实际代码执行的解析不同怎么办?这可能会导致令人困惑的结果。scanf

而且,令我相当惊讶的是,编译器中的 scanf 格式解析代码似乎可以(在某些情况下确实)做一些特殊和意想不到的事情。Clang 没有(它根本没有抱怨格式错误的字符串),但 GCC 说“%[”格式“和”格式中嵌入的 \0“都没有结束 ']'”。所以它注意到了。

这是可能的(尽管仍然令人惊讶),因为编译器至少可以看到整个字符串的文字,并且能够注意到 null 字符是程序员插入的显式字符,而不是编译器附加的更常见的隐式字符。事实上,gcc 发出的警告“在格式中嵌入了'\0'”,这证明 gcc 至少肯定是为了适应这种可能性而编写的。(请参阅下面的脚注,详细了解编译器“查看”整个字符串文本的能力。

但第二个问题是,为什么它(似乎)在运行时工作?C 库中的实际代码在做什么?scanf

至少,该代码无法知道 是显式的,并且后面有“真实”字符。该代码必须在它找到的第一个代码处停止。所以它的运行方式就好像格式字符串是\0\0

"%31[^"

当然,这是一个格式错误的字符串。运行时库代码不需要执行任何合理的操作。但我的副本和你的副本一样,能够读取完整的字符串“foo”。这是怎么回事?

我的猜测是,在看到 和 和 和 之后,并决定它要扫描与某个集合不匹配的字符,它完全愿意,实际上,推断出缺失的字符,并从扫描集中导航匹配的字符,最终没有排除的字符。%[^]

我通过尝试变体来测试这一点

    x = scanf("%31[^\0o]", buf);

这也匹配并打印了“foo”,而不是“f”。

当然,显然,事情并不能保证像这样工作。@AnttiHaapala已经发布了一个答案,显示他的 C RTL 拒绝使用格式错误的扫描字符串扫描“foo”。


脚注: 大多数时候,嵌入在字符串中确实过早地结束了它。大多数时候,后面的所有内容实际上是不可见的,因为在运行时,每一段字符串解释代码都会在它找到的第一个代码处停止,无法知道它是由程序员显式插入的还是由编译器隐式附加的。但正如我们所看到的,编译器可以分辨出区别,因为编译器(显然)可以看到整个字符串的字面量,与程序员输入的完全相同。证据如下:\0\0\0

char str1[] = "Hello, world!";
char str2[] = "Hello\0world!";

printf("sizeof(str1) = %zu, strlen(str1) = %zu\n", sizeof(str1), strlen(str1));
printf("sizeof(str2) = %zu, strlen(str2) = %zu\n", sizeof(str2), strlen(str2));

通常,在字符串文本上,会给你一个比 大的数字 1。但此代码打印:sizeofstrlen

sizeof(str1) = 14, strlen(str1) = 13
sizeof(str2) = 13, strlen(str2) = 5

只是为了好玩,我还尝试了:

char str3[5] = "Hello";

不过,这一次,给出了一个更大的数字:strlen

sizeof(str3) = 5, strlen(str3) = 10

我有点幸运。 没有尾随,既不是我插入的,也不是编译器附加的,所以从末尾航行,并且可以很容易地数出数百或数千个字符,然后在内存中的某个地方找到一个随机的字符,或者崩溃。str3\0strlen\0

评论

1赞 Antti Haapala -- Слава Україні 2/3/2021
让我们面对现实吧,clang 的诊断:D更糟糕
4赞 Antti Haapala -- Слава Україні 2/3/2021 #2

首先,Clang 完全无法在这里输出任何有意义的诊断,而 GCC 确切地知道发生了什么 - 所以 GCC 1 - 0 Clang。

至于格式字符串 - 好吧,它不起作用。format 参数 to 是一个字符串。字符串以终止 null 结尾,即您给出的格式字符串是scanfscanf

scanf("%31[^", buf);

在我的计算机上,编译程序可以得到

% gcc scanf.c
scanf.c: In function ‘main’:
scanf.c:8:20: warning: no closing ‘]’ for ‘%[’ format [-Wformat=]
    8 |     x = scanf("%31[^\0]", buf);
      |                    ^
scanf.c:8:21: warning: embedded ‘\0’ in format [-Wformat-contains-nul]
    8 |     x = scanf("%31[^\0]", buf);
      |                     ^~

扫描集必须具有右括号,否则转换说明符无效。如果转化说明符无效,则行为未定义。]

而且,在我运行它的计算机上,

% printf 'foo\n\0bar' | ./a.out
x = 0, buf=

Q.E.D.公司

评论

0赞 Eugene Sh. 2/3/2021
问题不就是“为什么编译器没有检测到嵌入的字符串无效”吗?也许我误解了它。\0
0赞 Antti Haapala -- Слава Україні 2/3/2021
@EugeneSh。然而,这与行为不匹配,它似乎正在 OP 计算机上做某事......
0赞 Eugene Sh. 2/3/2021
我猜编辑是实际的QED:)-Wformat-contains-nul
0赞 William Pursell 2/3/2021
我的问题最初是“为什么它有效”,正确答案是“这是未定义的行为,只是看起来有效”。
0赞 Antti Haapala -- Слава Україні 2/3/2021
@WilliamPursell也许你应该添加操作系统
1赞 chux - Reinstate Monica 2/3/2021 #3

为什么可以在 scanf 的转换说明符中嵌入 null 字符?

不能直接将 null 字符指定为扫描集的一部分,因为字符串的解析以第一个 null 字符结束。"%31[^\0]"

"%31[^\0]"被解析为好像是 .由于它是一个无效的说明符,因此 UB 可能会随之而来。编译器可以提供比所看到的更多内容的诊断。scanf()"%31[^"scanf()scanf()


空字符可以是扫描集的一部分,如 中所示。这将读取除 ."%31[^\n]"'\n'

在读取 null 字符的异常情况下,要确定读取扫描的字符数,请使用 。"%n"

int n = 0;
scanf("%31[^\n]%n", buf, &n);
scanf("%*1[\n]"); // Consume any 1 trailing \n
if (n) {
  printf("First part of buf=%s, %d characters read ", buf, n);
}

评论

1赞 chux - Reinstate Monica 2/3/2021
@SteveSummit 不同意假设性和可能的用途。IAC,OP当然不是新手。这回答了 OP 可以处理的水平。
0赞 John Bollinger 2/3/2021
@SteveSummit,我不是 C 新手,我有时会用它来做一些事情。我不会特别频繁地处理带有嵌入 null 字符的字符数据,但我会很乐意这样做。我敢说,当 C 语言是这项工作的好工具时,专家会很乐意使用它。这可能很少见,但并不罕见到假设的地步。scanfscanf
0赞 Steve Summit 2/3/2021
@JohnBollinger,chux:好的,我撤回评论。