Jump to Table of Contents Pop Out Sidebar

C 风格字符串缺陷及改进

More details about this document
Create Date:
Publish Date:
Update Date:
2024-04-22 01:43
Creator:
Emacs 29.2 (Org mode 9.6.15)
License:
This work is licensed under CC BY-SA 4.0

当人们谈论 C 的问题时,“字符串”的概念永远是首要缺陷之一。你已经用过它们,并且我也谈论过它们的种种缺陷,但是对为什么 C 字符串拥有缺陷,以及为什么一直是这样没有明确的解释。—— Learn C the hard way

本文指出了 C 语言字符串的一些问题,并对如何改进操纵字符串提出了一些建议和方案。

1. 为什么 C 风格字符串十分糟糕

C 风格的字符串是一个以 '\0' 结尾的字符数组,它的长度并不是显式给出的,或者说是不确定的。这是一个约定,而不是一个强制性的要求,C 语言中并没有真正的字符串类型。

这一点导致了:任何处理无效 C 风格字符串的循环都是死循环(或导致缓冲区溢出)。因为 C 字符串没有确定的长度,所以检查它是不是正确字符串的唯一方法就是遍历它并判断循环是否终止。你不可能编写出验证字符串正确与否的函数,因为对无效的字符串的遍历过程永远不会结束(实际上会因为内存中有很多的 0 而终止)。“检验 C 风格字符串是否有效”的问题与“停机问题”是等价的,后者是一个著名的不可解问题。

在 bString 的库文档中,还列出了以下问题:

  1. 使用 '\0' 标明字符串的结尾意味着取得字符串长度的操作时间复杂度为 O(n),它本可以是 O(1)。
  2. gets() 的使用会将应用暴露在缓冲区溢出的问题之下。
  3. strtok() 会修改它解析的字符串,因此,它在要求可重入和多线程的程序中可能不可用。
  4. fgets 有着“忽视”出现在在换行符 '\n' 出现之前的空字符 '\0' 的奇怪语义。
  5. 没有内存管理机制,像是 strcpy,strcat 和 sprintf 的使用都是缓冲区溢出问题的常见出处。
  6. 在某些情况下, strncpy() 不会将目标字符串以 '\0' 结尾。
  7. 将 NULL 传给 C 库中的字符串函数会导致未定义的空指针访问。
  8. 参数混叠(parameter aliasing) (重叠,或称为自引用)在大多数 C 库函数中都是未定义行为。
  9. 许多 C 库字符串函数都带有受限范围的整数参数。传递超出这些范围的整数的行为不会被检测到,并会造成未定义行为。

关于其中的几点,我来进行一些探究

1.1. gets() 函数的问题

gets() 的函数原型如下:

char * gets(char *);

它的功能是:从输入缓冲区中读取一个字符串存储到字符指针变量所指向的内存空间,并删除换行符。

gets() 没有限制它的读取长度,非常容易溢出。如果溢出,多出来的字符将被写入到堆栈中,这就覆盖了堆栈原先的内容,破坏一个或多个不相关变量的值。

在 C11 标准中,gets() 函数被废除,以 gets_s() 取而代之。

1.2. fgets() 函数的问题

fgets() 的函数原型为:

char * fgets(char *str, int n, FILE *stream);

fgets() 从指定的流中读取一行,并将内容存储在字符数组 str 中,当读取 (n-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止。

相比于 gets(),fgets() 无疑安全了很多,它的第二个参数限制了读取字符个数,可以确保缓冲区的安全。它对换行符的保留体现了与 gets() 的不同之处。

对于 bString 文档中提出的问题,我使用下面的代码进行了实验:

#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    FILE * fp = NULL;

    if ((fp = fopen("1.txt", "w")) == NULL) exit(1);

    char a[10] = { '\0' };
    a[0] = a[1] = 'y';
    for (int i = 0; i < 10; i++) fputc(a[i], fp);
    fputc('\n', fp);
    fclose(fp);

    if ((fp = fopen("1.txt", "r")) == NULL) exit(2);

    char b[20];
    for (int i = 0; i < 20; i++) b[i] = 1;

    fgets(b, 20, fp);

    for (int i = 0; i < 20; i++) printf("%d ", b[i]);

    fclose(fp);

    return 0;
}

输出结果为 121 121 0 0 0 0 0 0 0 0 10 0 1 1 1 1 1 1 1 1 。作者的原话如下:

fgets has the unusual semantic of ignoring '\0's that occur before '\n's are consumed.

这句话的意思我不是太明白,但有一个问题是显而易见的:这样一来,换行符肯定是不可见的,换行符前面的除了第一个空字符之外的其他空字符也是不可见的。

1.3. strncpy() 的问题

该函数的原型为:

char * strncpy ( char * destination, const char * source, size_t num );

参考 www.cplusplus.com[1] 可以得到以下类容: No null-character is implicitly appended at the end of destination if source is longer than num. Thus, in this case, destination shall not be considered a null terminated C string (reading it as such would overflow). 翻译过来即:如果源字符串的长度大于参数 num 的值,那么空字符不会被加到目标字符串的结尾。因此,在这种情况下,目标字符串不应该被当作以空字符结尾的 C 字符串。

这显然是一个坑,如果把得到的目标字符串直接当作正常字符串处理,将很可能会导致程序的崩溃。

1.4. NULL 参数问题

将 NULL 或者其他不合法的字符串地址传递给字符串处理函数会导致对内存的非法操作,将直接导致程序的崩溃。这个问题不是 C 库中的字符串函数所独有的。但是,这些字符串函数并没有通过返回值或者其他方式来反馈这些错误的出现,这些错误是没有检查的。除非使用调试器,否则我们只会看到程序的崩溃,而不知道具体的错误信息。

例如:

strcpy (p = malloc (13 * sizeof (char)), "Hello,");
strcat (p, " World");

这种写法非常的不安全,p 可能因为内存耗尽而得到一个空指针值,从而直接引起非法内存访问。

1.5. 重叠问题

当向函数传递两个参数,其中一个指向的数据结构与另一个在内存上重叠时,重叠问题便出现了。

这对于只读函数来说不是个问题,但向内存中进行写入操作的函数会因此而发生错误。这在 C 标准库中是一个普遍的问题,尤其是在字符串库中。

C 的字符串是一个字符接着一个字符的连续形式,在某些场景下实现重叠安全是很容易的。但是 C 的字符串函数一般是不会进行重叠检测的,因此随便举出由重叠而导致的未定义行为的例子是非常容易的。

最简单的例子,例如:

char a[20] = "Hello";
strcat(a, a);
puts(a);

编译并运行,你并不会看到 HelloHello,而是程序的崩溃退出。strcat 的第二参数表达的源字符串要保证在函数执行期间保持不变,这样才能正确运行。而在上面的例子中,第一参数恰恰指向需要改变的空间。既要不变,又需要改变,这就像是拉着自己的头发把自己提起来一样,结果显然是不可预料的。

1.6. 线程安全问题

有关多线程有一个笑话:“之前我有一个问题,使用了多线程之后,现在两个题问我有”。文档作者提到 strtok() 因为会对字符串参数进行修改而不具有线程安全性,这可能说明对字符串进行修改的 C 库中的字符串函数都不具有线程安全性。

1.7. 内存管理问题

C 中没有真正的字符串类型,它连像是 fopen/fclose 的管理机制都没有提供,对字符串的操作不可避免的会涉及到手动内存管理,而这正是 C 程序中许多耗时 bug 的主要来源。

1.8. 问题总结

我至少可以这样总结:C 风格字符串没有线程安全,没有内存管理机制,字符串函数操作没有错误检查,函数接口设计不合理,函数功能杂乱,等等…… 它唯一的优点似乎就是速度了。这些缺陷很大程度上来自历史原因,毕竟 C 语言出生的时候还没有多线程这种东西。在这些函数大量使用的时候, C 语言还没有一个严谨统一的标准。 The C Standard Library 中这样写到:

大多数人可能不会想到这些函数有一些设计缺陷。<string.h> 中声明的函数不是共同设计努力的结果。相反,它们是由很多程序员在很长的时间力做出的贡献积累起来的。到 C 标准化开始的时候,“修整”他们已经太晚了,太多的程序对函数的行为已经有了明确的定义。

如果不以相当谨慎的态度来面对这些函数的话,在编程的过程中很容易一脚一个坑。顾虑太多会使得心智负担太重,这对于编程效率会带来非常大的影响。相比于直接使用这些老旧的函数,使用更新更安全的库无疑是明智之举。

2. 如何改进

2.1. 弃用一些危险的函数

正如 The Standard C Library 中所言,<string.h> 中的函数接口并不是精心设计的,使用有明显缺陷的函数会增加程序出现 bug 的可能性。

bString 在 bsafe.h 中列出了几个“危险”的函数,它们分别是 strcpy(),strcat(),gets(),strncpy(),strncat(),strtok() 和 strdup()。其中的几个上面已经有过分析。

2.2. 使用更加安全的字符串函数库

相比于 <string.h> 近乎于裸奔的字符串函数,另外的一些函数库在安全性上要大大的优于它。在这里我对 Microsoft 的 strsafe 和上文中提到的 bstring 进行简单的介绍,并分析它们相对于 C 库字符串函数的优点。

2.2.1. strsafe

微软的官方文档对它的优点进行了简要说明,翻译如下:

  1. 目标缓冲区的大小总是被作为参数提供给函数,以此确保函数不会在超出缓冲区的地方进行写入。
  2. 缓冲区保证以空字符结尾,即便函数会对原来应有的结果进行截断。
  3. 所有的函数会返回一个 HRESULT 类型的值,只有一个成功码 (S_OK)。

相比于 C 库函数,Strsafe 对缓冲区长度使用参数进行了检查,对待错误不是直接崩溃而是返回错误码,且保证得到的字符串是标准字符串。相比而言,这是一个巨大的改进。

Strsafe 的具体内容,可参阅微软[2]的专门文档。

2.2.2. bString

bString 提供了它自己定义的 bString 类型

struct tagbstring {
    int mlen;
    int slen;
    unsigned char * data;
};
typedef struct tagbstring * bstring;

它直接将字符串的长度和字符串实际占用的字节长度添加到了结构中,这样的一个优势便是:求取字符串长度的操作具有 O(1) 的时间复杂度,而不是 O(n)。

结构体中的 data 部分指向一块内存,bString 保证如果只使用 bString 中的函数操纵 bString 字符串的话, data 部分的字符串会以空字符结尾。显然,这样有利于与 C 风格字符串的相互转化。

bString 中的函数都对参数的合理性进行了检查,如果参数有问题,函数直接返回错误值。

bString 进行了简单的内存管理,这样减少了手动管理内存出现问题的可能性。

bString 是一个开源的字符串库,详细的文档和源代码可以在 sourceforge[3] 和 github[4] 上找到。

3. 参考资料

Learn C the hard way — Zed A. Shaw

The C Standard Library — P.J.Plauger

References

[1]

http://www.cplusplus.com/reference/cstring/strncpy/

[2]

https://docs.microsoft.com/en-us/windows/win32/menurc/strsafe-ovw

[3]

http://bstring.sourceforge.net/

[4]

https://github.com/websnarf/bstrlib