C标准是否保证缓冲区不会超出其空终止符而被访问?

43

在标准库的许多字符串函数中,如果提供了缓冲区,在各种情况下是否保证缓冲区不会被修改超出空字符终止符?例如:

char buffer[17] = "abcdefghijklmnop";
sscanf("123", "%16s", buffer);

现在需要将 buffer 设置为 "123\0efghijklmnop" 吗?

另一个例子:

char buffer[10];
fgets(buffer, 10, fp);

如果读入的行只有3个字符长度,那么在调用 fgets 函数之前的第6个字符是否与之前的相同是可以确定的吗?


9
不能想象某件事并不是假设某个编译器编写者或库实现者不能想象它的好理由。如果所有C程序员都有相同的想象力,那将会非常无聊。 - Steve Jessop
2
@Ángel,我认为你没有理解问题的要点。 - Segmented
2
一个更好的问题应该是“为什么这很重要?”你不应该依赖于幕后和未指定的行为。如果需要该行为,则应创建/使用明确旨在以该方式运行的函数。 - Dunk
5
了解黑暗角落的位置可以帮助避免它们。 - caveman
1
在上述情况中,%16s告诉sscanf调用者保证缓冲区至少可以容纳16 + 1个字符。 - caveman
显示剩余10条评论
7个回答

31

C99草案并没有明确说明在这些情况下应该发生什么,但是通过考虑多种情况,您可以证明它必须按照某种方式工作,以便在所有情况下都符合规格。

标准说:

%s - 匹配一个非空白字符序列。252)

如果没有l长度修饰符,则相应的参数应该是指向字符数组初始元素的指针,该数组足够大,可以容纳该序列和自动添加的终止空字符。

以下是一对示例,它们表明它必须按照您提出的方式工作才能符合标准。

示例A:

char buffer[4] = "abcd";
char buffer2[10];  // Note the this could be placed at what would be buffer+4
sscanf("123 4", "%s %s", buffer, buffer2);
// Result is buffer =  "123\0"
//           buffer2 = "4\0"

例子 B:

char buffer[17] = "abcdefghijklmnop";
char* buffer2 = &buffer[4];
sscanf("123 4", "%s %s", buffer, buffer2);
// Result is buffer = "123\04\0"

请注意,sscanf的接口并不能提供足够的信息以真正知道这些是不同的。因此,如果要使示例B正常工作,它就不能干扰示例A中空字符后面的字节。这是因为它必须根据这个规范在两种情况下都能正常工作。

因此,根据规范,它必须像你所说的那样隐含地工作。

类似的论点也可以用于其他函数,但我认为你可以从这个例子中看到这个想法。

注意: 在格式中提供大小限制,例如“%16s”,可能会改变行为。根据规范,sscanf在将数据写入缓冲区之前将其清零是功能上可接受的。在实践中,大多数实现都选择性能,这意味着它们留下其余部分。

当规范的意图是进行这种清零操作时,通常会明确指定。strncpy就是一个例子。如果字符串的长度小于指定的最大缓冲区长度,它将用null字符填充其余空间。这个“字符串”函数返回非终止字符串的事实也使得它成为人们自己编写版本的最常见函数之一。

至于fgets,类似的情况也可能出现。唯一需要注意的是,规范明确指出,如果没有读取任何内容,则缓冲区保持不变。可以通过在清零缓冲区之前检查是否至少有一个字节可读来绕过这个问题。


1
聪明,我喜欢你的想法 =) - Segmented
我越想越觉得,如果您为缓冲区提供大小,那么您的论点不就崩溃了吗?这也是问题的关键所在... 在您的示例中,如果我们为格式提供缓冲区的大小,根据标准,它们必须不重叠,对吧?然后我们就不能再隐含地假设任何东西了... - Segmented
1
@Segmented:fgets明确记录在文档中,在读取错误后,缓冲区的整个内容(即从提供的起始地址到提供的字节数)都是未指定的。如果没有读取错误,则我认为插入0字节后缓冲区的部分将不会被触及。 - rici
@Segmented,我说的是缓冲区的大小,而不是它允许从字符串中写入的字节数。 - Ángel
在C语言中,没有任何函数“知道”它所接收到的缓冲区大小。但是,调用者可以通过参数告诉函数一个最小的缓冲区大小保证。对于sscanf函数,这可以通过格式字符串传递。其效果与显式大小参数相同。 - caveman
显示剩余5条评论

24

缓冲区中的每个字节都是一个对象。除非sscanffgets函数描述的某些部分提到修改这些字节,或者暗示它们的值可能会改变,例如通过说明它们的值变得未指定,否则适用一般规则:(强调我的)

6.2.4 对象的存储期

2 [...] 一个对象存在,有一个固定的地址,并且在其生命周期内保留其最后存储的值。[...]

正是这个原则保证了

#include <stdio.h>
int a = 1;
int main() {
  printf ("%d\n", a);
  printf ("%d\n", a);
}

尝试打印两次1。即使a是全局的,printf可以访问全局变量,并且printf的描述没有提到不修改a

fgetssscanf的描述都没有提到在实际写入的字节之后修改缓冲区(除非出现读取错误),因此这些字节不会被修改。


没有什么阻止实现一个在写入之前清空缓冲区的 fgets 函数,这是从规范角度来看是合法的实现。6.2.4 标准并没有说 fgets 不能这样做。它只是说如果 fgets 更改了缓冲区,它就不会自行再次更改。 - caveman
4
在抽象机器中,fgets函数不改变除了那些明确指定要修改的字节以外的任何字节,这个事实意味着如果fgets的具体实现改变了那些字节,它就无法符合规范。因此,具体的实现必须保持将那些字节留在它们最后存储的值的行为。如果你不同意这一点,那么你对我回答中的例子有什么看法?能够打印出 "1\n2\n" 吗?如果不行,为什么? - user743382
2
这是一个很好的论点,我同意。我认为你的评论比你的答案本身更清晰地阐述了观点。我从你的答案中缺少的主要观点是你将每个字节视为一个对象。因此,它们不会变得不确定、未指定或定义的声明真正澄清了你的意思。 - caveman
1
这可能不是标准的全部内容。定义最后存储的值可能是一个很长的故事,否则您将无法拥有可以由硬件更改的内存映射寄存器(这是许多嵌入式平台上常用的技术)。 - Fizz
2
@RespawnedFluff 标准在此处附注说明:“对于易失性对象,最后一次存储不需要在程序中显式指定。”该脚注由描述“volatile”(6.7.3p6)的规范文本支持。但在OP的情况下,没有易失性对象,因此这不是问题。 - user743382

8
标准在这方面有些模糊,但我认为合理的解读是:是的,在缓冲区中写入的字节数不能超过读取的字节数加上空字符。另一方面,对文本的更严格解读/解释可能得出结论:不,没有保证。以下是公开可用的草案关于fgets的说明:

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

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

如果成功,fgets函数返回s。如果遇到文件结束并且没有将任何字符读入数组,则数组内容保持不变,并返回空指针。如果操作期间发生读取错误,则数组内容未定,并返回空指针。

对于它应该从输入中读取多少内容有保证,即在换行符或EOF处停止读取,并且不要读取超过n-1字节。虽然没有明确说明可以向缓冲区中写入多少内容,但是通常认为fgets的参数n用于防止缓冲区溢出。标准使用模糊的术语“读取”,这可能并不一定意味着gets不能向缓冲区中写入超过n个字节,如果您想在术语上挑剔的话。但请注意,相同的“读取”术语用于两个问题:限制n和EOF /换行符。因此,如果您将与n相关的“读取”解释为缓冲区写入限制,则[为了保持一致性]您可以/应该将其他“读取”也解释为同样的方式,即当字符串比缓冲区短时不写入更多内容。
另一方面,如果您区分短语动词“read into”(=“write”)和仅“read”的用法,那么您不能像读取其他文本那样读取委员会的文本。您可以保证它不会将更多的n字节“read into”(=“write to”)数组,但是如果输入字符串由于换行符或EOF而提前终止,则只保证其余部分(输入)不会被“read”,但是不确定这是否意味着它们不会被“read into”(=“written to”)缓冲区在这种更严格的阅读下。关键问题是关键字是“into”,该关键字已省略,因此问题是我在以下修改的引用中给出的完成是否是预期的解释:

在新行字符(保留)或文件结束后不会再读取任何其他字符[到数组]中。

坦白地说,一个作为公式陈述的单个后置条件(在这种情况下可能非常简短)比我引用的措辞更有帮助...
我懒得尝试分析他们关于*scanf系列的写作方式,因为我怀疑考虑到这些函数中发生的所有其他事情,它可能会更加复杂。他们关于fscanf的写作方式大约有五页长... 但是我怀疑类似的逻辑也适用。

很容易想象,在某些类型的操作系统中,允许覆盖第一行末尾之外的信息[但在指定的空间范围内]可以提高性能。如果缓冲区为128字节,读取128字节的时间小于读取一个字节的两倍,并且相对fseek的时间类似,则除了长度小于四个字符的行之外,读取128字节,扫描换行符,然后根据需要进行回溯可能比逐个读取字节更快。 - supercat
@supercat:嗯,是的,你可以这样想,但是在任何规范中,常识是函数不会有未在标准中指定的(用户可见的)副作用。在C标准的情况下,保证这个一般的、常识性原则的措辞已经在hvd的答案中指出。 - Fizz
我同意,由于标准没有授权实现程序在结尾后写入数据,因此 fgets 不应该期望客户端代码容忍这种行为。我的观点是,如果规范的编写者选择这样做,将缓冲区内容描述为未指定的空字节之后可能会带来潜在的性能优势。 - supercat

4

缓冲区是否保证不会在空终止符之外被修改?

没有保证。

现在的缓冲区是否必须等于“123\0efghijklmnop”?

是的。但这仅因为你已经正确使用了与字符串相关的函数参数。如果你弄乱了缓冲区长度、输入修改器到sscanf等,则程序将编译。但它在运行时很可能会失败。

如果读取的行只有3个字符长,那么可以确定第6个字符与调用fgets之前相同吗?

可以。一旦fgets()发现你有一个3个字符的输入字符串,它就会将输入存储在提供的缓冲区中,并且完全不关心提供的剩余空间。


2
你自相矛盾,如果没有保证,那么你提供的两个答案应该都是“不”。 - Segmented
@Segmented:请再次阅读我的回答(已编辑)。Igor S.K.刚刚解释了现实(有时与标准不同)。 - VolAnd
@Segmented,对于“许多字符串函数”的“各种情况”,“不”是相当常见的答案。想象一下strcpy(dst,src)src将完全保持不变,但写入dst可能会导致缓冲区溢出。或者它可能只是部分修改,保留dst[strlen(src)]之后的所有内容。我还为您提供了具体示例的答案。 - Igor S.K.

1

现在需要将缓冲区设置为"123\0efghijklmnop"吗?

这里的buffer只包含保证以NUL结尾的123字符串。

是的,为数组buffer分配的内存不会被释放,但是您确保/限制您的字符串buffer最多只能有16个字符元素,您可以随时读取它。现在取决于您是写入单个字符还是最大值buffer可以接受的。

例如:

char buffer[4096] = "abc";` 

实际上在下面执行某些操作,
memcpy(buffer, "abc", sizeof("abc"));
memset(&buffer[sizeof("abc")], 0, sizeof(buffer)-sizeof("abc"));

标准要求,如果char数组的任何部分被初始化,那么在遵守内存边界之前,这就是它所包含的所有内容。

2
超出终止符的内存并不会消失。我很好奇标准规定了什么,而不是在终止符之前。 - Segmented
是的,您可以随时读取它们,这就是为什么我很好奇标准在终止符之外的额外空间方面的规定。 - Segmented

0

这取决于使用的函数(以及其实现程度)。sscanf会在遇到第一个非空格字符时开始写入,并继续写入,直到遇到第一个空格字符,在此处添加一个结束的0并返回。但是像strncpy这样的函数(众所周知)会将缓冲区的其余部分清零。

然而,在C标准中没有规定这些函数的行为。


0

标准库中没有任何保证,因此建议使用函数sscanffgets(要考虑缓冲区的大小)如您在问题中所示(与gets相比,使用fgets被认为是更可取的)。

然而,一些标准函数在工作中使用了空字符结束符,例如strlen(但我想您是在询问字符串修改方面)

编辑:

在您的例子中

fgets(buffer, 10, fp);

保证在第10个字符之后不会被修改(fgets 不考虑 buffer 的内容和长度)

编辑2:

此外,在使用 fgets 时,请记住 '\n' 将存储在缓冲区中。例如:

 "123\n\0fghijklmnop"

而不是预期的

 "123\0efghijklmnop"

1
我不是那个点踩者,但你似乎没有理解原始问题,并且你没有引用标准。 - Segmented
1
标准没有关于在fgets的第二个参数中允许使用未更改的字符的保存方式的规定...所以我的回答是“不保证”。 - VolAnd

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