当使用较短的缓冲区时,fgets() 返回NULL是否符合规范?

16
在单元测试中,当缓冲区大小n < 2时,测试包含fgets()函数的函数遇到了意外结果。 显然,这样的缓冲区大小是愚蠢的,但该测试正在探索边界情况。
简化的代码:
#include <error.h>
#include <stdio.h>

void test_fgets(char * restrict s, int n) {
  FILE *stream = stdin;
  s[0] = 42;
  printf("< s:%p n:%d stream:%p\n", s, n, stream);
  char *retval = fgets(s, n, stream);
  printf("> errno:%d feof:%d ferror:%d retval:%p s[0]:%d\n\n",
    errno, feof(stream), ferror(stream), retval, s[0]);
}

int main(void) {
  char s[100];
  test_fgets(s, sizeof s);  // Entered "123\n" and works as expected
  test_fgets(s, 1);         // fgets() --> NULL, feof() --> 0, ferror() --> 0
  test_fgets(s, 0);         // Same as above
  return 0;
}

令人惊讶的是,fgets() 返回 NULL,而 feof()ferror() 都不是 1
下面的 C 规范似乎对这种罕见情况保持沉默。
问题:
  • 返回未设置 feof()ferror()NULL 是否符合规范行为?
  • 不同的结果是否符合规范行为?
  • 如果 n 是 1 或小于 1,会有什么区别吗?

Platform: gcc版本4.5.3,目标:i686-pc-cygwin
以下是来自C11标准的摘要,部分内容已加粗:
7.21.7.2函数fgets fgets函数最多读取由n指定的字符数减一 [...]。
如果成功,则fgets函数返回s。如果在操作期间遇到文件结束符并且未读入任何字符,则数组的内容保持不变,并返回空指针。如果在操作期间发生读取错误,则数组内容不确定,并返回空指针。

相关帖子
如何在 C 的 minishell 中使用 feof 和 ferror
在 C 中创建 shell 时出现问题(Seg-Fault 和 ferror)
关于 fputs()、fgets() 和 ferror() 的问题及其在 C++ 中的对应物
fgets() 的返回值


[编辑] 对答案的评论

@Shafik Yaghmour清晰地阐述了整个问题:由于C规范没有提到当它在(n <= 0)时既不读取任何数据也不写入任何数据到s时应该做什么,因此这是未定义行为。因此,任何合理的响应都应该是可以接受的,例如返回NULL,不设置任何标志,保留缓冲区。

至于当n==1时应该发生什么,@Oliver Matthews的答案和@Matt McNabb的评论表明C规范在考虑n == 1的缓冲区时缺乏明确性。C规范似乎倾向于一个n == 1的缓冲区应该返回具有s[0] == '\0'的缓冲区指针,但不够明确。


1
值得注意的是,引用C11规范可能是错误的——尽管它在C11中可能没有改变,但GCC 4.5不支持任何C11规范。 - Oliver Matthews
1
此外,在gcc 4.8中的行为是不同的 - (s,1)返回errno:0 feof:0 ferror:0 retval:0x7fff183c87a0 s[0]:0(s,0)返回errno:0 feof:0 ferror:0 retval:(nil) s[0]:42 - Oliver Matthews
@Oliver Matthews 1) 关于可能错误的引用 - 我的引用是从一个_C11_草案中缩短的。2) 是的,尽管引用是C11风格的,但编译不是在C11中完成的。我认为是C99 - 稍后会检查。 - chux - Reinstate Monica
@OliverMatthews,更好的说法可能是行为在您更近期的发行版中包含的gcc 4.8中包含的glibc中不同。很可能这是glibc中已经修复的错误。 - Shahbaz
1
有趣的是,fgets() 的签名是 char *fgets(char * restrict s, int n, FILE * restrict stream);(参见 POSIX fgets()),其中大小使用的是 int 而不是 size_t。这意味着 n 可以是负数,但我不确定如果是负数会发生什么。由于没有空间来存放空字节,所以它不应该写入任何内容。返回 NULL 是合理的。POSIX 可以设置 errno,但它没有提到最适当的 EINVAL - Jonathan Leffler
显示剩余4条评论
3个回答

8
对于较新版本的glibc,对于n == 1,其返回s表示成功,这不是对The fgets function2段所述的7.19.7.2的不合理解释。该段在C99和C11中相同,强调如下:

char *fgets(char * restrict s, int n, FILE * restrict stream);

fgets函数从由stream指向的流中读取最多n-1个字符到由s指向的数组中。在新行字符(保留)或文件结束后不会再读取其他字符。在数组中写入最后一个字符之后立即写入空字符。

这并不是非常有用,但也没有违反标准中的任何规定,它将最多读取0个字符并添加空字符。因此,您看到的结果似乎是在后来的glibc版本中修复的错误。它也显然不是文件结束或读取错误,因为第三段中已经涵盖了这些情况:

[...]如果遇到文件结束,并且没有将任何字符读入数组,则数组的内容保持不变,并返回空指针。如果操作过程中发生读取错误,则数组内容是不确定的,并返回空指针。

至于最后一种情况,即n == 0,这似乎只是未定义的行为。草案C99标准第4.符合性2段如下所述(强调我的):

如果违反了出现在约束条件之外的“应”或“不应”要求,则行为是未定义的。本国际标准中通过单词“未定义行为”或省略任何明确行为定义来表示未定义行为。这三者之间没有强调差异;它们都描述了“未定义行为”。

C11中的措辞相同。无法读取最多-1个字符,也不是文件结束或读取错误。因此,在这种情况下,我们没有明确定义行为。看起来是一个缺陷,但我找不到任何涵盖此问题的缺陷报告。

2
虽然我同意你的结论,即 test_fgets(s, 0); 是未定义行为,但我认为它不是“不可能读取最多-1个字符”。不读取任何字符是读取任何负数字符的唯一方法。微妙的语义问题在于:负数能否描述“字符数”?如果不能,则对于负值,行为未定义;如果可以,则存在另一个有趣的边界情况:test_fgets(s, INT_MIN); - chqrlie
@chqrlie: test_fgets(s, INT_MIN); 看起来会通过指针算术下溢来调用未定义的行为。 s 可能小于 INT_MAX - Joshua

3
C标准(C11 n1570草案)指定fgets()的方式如下(一些强调是我的):

7.21.7.2 The fgets function

Synopsis

   #include <stdio.h>
   char *fgets(char * restrict s, int n,
               FILE * restrict stream);

Description

The fgets function reads at most one less than the number of characters specified by n from the stream pointed to by stream into the array pointed to by s. No additional characters are read after a new-line character (which is retained) or after end-of-file. A null character is written immediately after the last character read into the array.

Returns

The fgets function returns s if successful. If end-of-file is encountered and no characters have been read into the array, the contents of the array remain unchanged and a null pointer is returned. If a read error occurs during the operation, the array contents are indeterminate and a null pointer is returned.

短语“最多读取比n指定的字符数少一个字符”不够精确。负数不能表示“字符数”,但0表示“没有字符”。似乎不可能“最多读取-1个字符”,因此当n <= 0时,该情况未被标准定义,并且具有未定义的行为。
对于n = 1,fgets被指定为最多读取0个字符,除非流无效或处于错误状态,否则应成功。短语“在数组中读入最后一个字符后立即写入空字符”是模棱两可的,因为没有字符被读入数组,但将这种特殊情况解释为意味着s[0] = '\0';是有意义的。gets_s的规范提供了相同的阅读,具有相同的不精确性。同样,行为没有明确定义,因此它是未定义的。

snprintf的规范更加精确,明确指定了n = 0的情况,并附有有用的语义。不幸的是,对于fgets,无法实现这样的语义:

7.21.6.5 The snprintf function

Synopsis

#include <stdio.h>
int snprintf(char * restrict s, size_t n,
     const char * restrict format, ...);

Description

The snprintf function is equivalent to fprintf, except that the output is written into an array (specified by argument s) rather than to a stream. If n is zero, nothing is written, and s may be a null pointer. Otherwise, output characters beyond the n-1st are discarded rather than being written to the array, and a null character is written at the end of the characters actually written into the array. If copying takes place between objects that overlap, the behavior is undefined.

get_s()的规范还澄清了n = 0的情况,并将其作为运行时约束违规:

K.3.5.4.1 The gets_s function

Synopsis

#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
char *gets_s(char *s, rsize_t n);

Runtime-constraints

s shall not be a null pointer. n shall neither be equal to zero nor be greater than RSIZE_MAX. A new-line character, end-of-file, or read error shall occur within reading n-1 characters from stdin.

If there is a runtime-constraint violation, s[0] is set to the null character, and characters are read and discarded from stdin until a new-line character is read, or end-of-file or a read error occurs.

Description

The gets_s function reads at most one less than the number of characters specified by n from the stream pointed to by stdin, into the array pointed to by s. No additional characters are read after a new-line character (which is discarded) or after end-of-file. The discarded new-line character does not count towards number of characters read. A null character is written immediately after the last character read into the array.

If end-of-file is encountered and no characters have been read into the array, or if a read error occurs during the operation, then s[0] is set to the null character, and the other elements of s take unspecified values.

Recommended practice

The fgets function allows properly-written programs to safely process input lines too long to store in the result array. In general this requires that callers of fgets pay attention to the presence or absence of a new-line character in the result array. Consider using fgets (along with any needed processing based on new-line characters) instead of gets_s.

Returns

The gets_s function returns s if successful. If there was a runtime-constraint violation, or if end-of-file is encountered and no characters have been read into the array, or if a read error occurs during the operation, then a null pointer is returned.

您正在测试的C库似乎对这种情况有漏洞,这已在后来的glibc版本中得到修复。返回NULL应意味着某种失败条件(与成功相反):文件结束或读取错误。其他情况,如无效流或未打开读取的流,或多或少被明确描述为未定义行为。 n = 0n < 0的情况未定义。返回NULL是一个明智的选择,但是在标准中澄清fgets()的描述要求n > 0gets_s一样将会很有用。
请注意,fgets还存在另一个规范问题:n参数的类型应该是size_t而不是int,但是这个函数最初是由C作者指定的,当时size_t甚至还没有被发明,因此在第一个C标准(C89)中保持不变。然而,更改它被认为是不可接受的,因为他们试图标准化现有的用法:签名的更改将在C库之间创建不一致性,并破坏使用函数指针或未经保护的函数的良好编写的现有代码。

1 C标准在4. 符合性的第2段中指定,如果违反了约束或运行时约束之外出现的“必须”或“不得”的要求,则其行为未定义。在本国际标准中,通过“未定义行为”或省略任何明确的行为定义来表示未定义行为。这三者之间没有强调的区别;它们都描述“未定义的行为”。


对于n = 1,fgets被指定为最多读取0个字符,除非流处于错误状态,否则它应该成功。不清楚您是否指的是_error indicator_。如果先前的读取设置了_error indicator_,则没有任何规定设置指示器会影响后续读取的返回值 - 只有“如果在操作期间发生读取错误...”。另一方面,_error indicator_也具有其模糊的规范性,探究这一点可能是另一个问题。 - chux - Reinstate Monica
@chux:我的意思是:缓冲区没有足够的空间来存储任何字符,因此不会尝试从流中读取字节,因此不存在输入失败的可能性。但是,如果设置了错误或文件结束标志,则返回“NULL”将与流的状态一致。否则,返回“s”似乎是合理的选择。 - chqrlie
如果流处于任何读取都会失败的状态,我认为在甚至查看n之前测试这种情况并返回NULL不应被视为违反标准,但是也不应该查看n,得出结论不需要实际读取任何内容,并返回成功。 - supercat
@supercat,我同意你的结论,即“处于任何(后续)读取都会失败的状态”。然而,并不需要有这样的“错误状态”——ferror()可以简单地报告之前读取的累积错误结果,例如奇偶校验错误,而不是影响未来读取的状态。ferror()的结果不必影响下一次输入。如果由于先前的fgets()输入错误而返回NULL,则下一个fgets()可能会返回非错误的结果。无论如何,C规范缺乏关于此行为的详细信息,特别是当n==1时。 - chux - Reinstate Monica
@chqrlie 完全同意关于“文件结束指示器”的更明确的说明。 “错误指示器”和ferror()是我评论所涉及的棘手部分。 - chux - Reinstate Monica
显示剩余6条评论

3

简而言之:对于n=1的情况,那个版本的glibc存在一个bug,对于n<1的情况,规范有(可以说是)一个歧义;但我认为更新的glibc采取了最合理的选项。

所以,c99规范基本相同。

test_fgets(s, 1)的行为是错误的。glibc 2.19给出了正确的输出(retval!=null, s[0]==null)。

test_fgets(s,0)的行为是未定义的,真的是这样。它不成功(你不能读取至多-1个字符),但它也没有达到两个'return null'标准之一(EOF& 0 read; read error)。

然而,GCC的行为可以说是正确的(返回指向未更改的s的指针也可以)——feof没有设置,因为它还没有达到eof;ferror没有设置,因为没有读取错误。

我怀疑gcc中的逻辑(手头上没有源代码)在顶部附近有一个'if n<=0 return null'。

[编辑:]

反思之后,我认为glibc对于n=0的行为是它能够给出的最正确的响应:

  • 没有读取到eof,所以feof() == 0
  • 没有读取,也就不可能发生读取错误,所以ferror = 0

现在关于返回值 - fgets 不能读取-1个字符(这是不可能的)。如果fgets将传入的指针返回回来,看起来就像是一个成功的调用。 - 忽略这种情况,fgets保证返回一个以null结束的字符串。如果在这种情况下它没有这样做,你就无法依赖它。但是fgets会将数组中读取的最后一个字符的下一个字符设置为null。假设我们在此次调用中读取了-1个字符(显然),那么它就会将第0个字符设置为null?

因此,在我看来,最明智的选择是返回null


1
在n1256中,它说:“如果成功,fgets函数返回s”。然而,它没有定义什么是“成功”。对我来说,这似乎是标准上的缺陷。 - M.M
2
不太相信这一点。我认为“成功”应该被解释为能够执行其定义的任务(即从s中读取最多n-1个字符并在结果中添加空字符)。 - Oliver Matthews

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接