C 编程中 5 个常见错误以及如何修复它们

使您的 C 程序更具弹性和可靠性的五种方法。
49 位读者喜欢这篇文章。
Bug tracking magnifying glass on computer screen

Pixabay, testbytes, CC0

即使是最优秀的程序员也可能创建编程错误。根据您的程序的功能,这些错误可能会引入安全漏洞、导致程序崩溃或产生意外行为。

C 编程语言有时会因为不像 Rust 等较新的编程语言那样具有内存安全性而声名狼藉。但是,通过添加少量额外的代码,您可以避免最常见和最严重的 C 编程错误。以下是五个可能破坏您的应用程序的错误以及如何避免它们的方法

1. 未初始化的变量

程序启动时,系统会为其分配一块内存,程序使用该内存来存储数据。这意味着您的变量将获得程序启动时内存中的任何随机值。

某些环境会在程序启动时有意“清零”内存,因此每个变量都以零值开头。并且在您的程序中假设所有变量都将从零开始可能很诱人。但是,C 编程规范指出系统不会初始化变量。

考虑一个使用少量变量和两个数组的示例程序

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

int
main()
{
  int i, j, k;
  int numbers[5];
  int *array;

  puts("These variables are not initialized:");

  printf("  i = %d\n", i);
  printf("  j = %d\n", j);
  printf("  k = %d\n", k);

  puts("This array is not initialized:");

  for (i = 0; i < 5; i++) {
    printf("  numbers[%d] = %d\n", i, numbers[i]);
  }

  puts("malloc an array ...");
  array = malloc(sizeof(int) * 5);

  if (array) {
    puts("This malloc'ed array is not initialized:");

    for (i = 0; i < 5; i++) {
      printf("  array[%d] = %d\n", i, array[i]);
    }

    free(array);
  }

  /* done */

  puts("Ok");
  return 0;
}

该程序未初始化变量,因此它们以系统当时在内存中的任何值开头。在我的 Linux 系统上编译并运行此程序,您会看到某些变量恰好具有“零”值,但其他变量则没有

These variables are not initialized:
  i = 0
  j = 0
  k = 32766
This array is not initialized:
  numbers[0] = 0
  numbers[1] = 0
  numbers[2] = 4199024
  numbers[3] = 0
  numbers[4] = 0
malloc an array ...
This malloc'ed array is not initialized:
  array[0] = 0
  array[1] = 0
  array[2] = 0
  array[3] = 0
  array[4] = 0
Ok

幸运的是,ij 变量从零开始,但 k 的起始值为 32766。在 numbers 数组中,大多数元素也恰好以零开头,除了第三个元素,它的初始值为 4199024。

在不同的系统上编译相同的程序进一步显示了未初始化变量的危险性。不要假设“全世界都运行 Linux”,因为有一天,您的程序可能会在不同的平台上运行。例如,这是在 FreeDOS 上运行的同一程序

These variables are not initialized:
  i = 0
  j = 1074
  k = 3120
This array is not initialized:
  numbers[0] = 3106
  numbers[1] = 1224
  numbers[2] = 784
  numbers[3] = 2926
  numbers[4] = 1224
malloc an array ...
This malloc'ed array is not initialized:
  array[0] = 3136
  array[1] = 3136
  array[2] = 14499
  array[3] = -5886
  array[4] = 219
Ok

始终初始化程序的变量。如果您假设变量将以零值开头,请添加额外的代码将零分配给该变量。这种预先输入的额外代码将为您节省以后的头痛和调试。

2. 数组越界

在 C 语言中,数组从数组索引零开始。这意味着长度为十个元素的数组从 0 到 9,或者长度为一千个元素的数组从 0 到 999。

一些程序员有时会忘记这一点,并引入“差一”错误,即他们从一开始就引用数组。在长度为五个元素的数组中,程序员打算在数组元素“5”处找到的值实际上不是数组的第五个元素。相反,它是内存中的其他一些值,与数组完全无关。

这是一个很好地超出数组边界的示例。该程序从一个只有五个元素长的数组开始,但引用了该范围之外的数组元素

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

int
main()
{
  int i;
  int numbers[5];
  int *array;

  /* test 1 */

  puts("This array has five elements (0 to 4)");

  /* initalize the array */
  for (i = 0; i < 5; i++) {
    numbers[i] = i;
  }

  /* oops, this goes beyond the array bounds: */
  for (i = 0; i < 10; i++) {
    printf("  numbers[%d] = %d\n", i, numbers[i]);
  }

  /* test 2 */

  puts("malloc an array ...");

  array = malloc(sizeof(int) * 5);

  if (array) {
    puts("This malloc'ed array also has five elements (0 to 4)");

    /* initalize the array */
    for (i = 0; i < 5; i++) {
      array[i] = i;
    }

    /* oops, this goes beyond the array bounds: */
    for (i = 0; i < 10; i++) {
      printf("  array[%d] = %d\n", i, array[i]);
    }

    free(array);
  }

  /* done */

  puts("Ok");
  return 0;
}

请注意,该程序初始化了数组的所有值,从 0 到 4,但随后尝试读取 0 到 9 而不是 0 到 4。前五个值是正确的,但之后您不知道这些值会是什么

This array has five elements (0 to 4)
  numbers[0] = 0
  numbers[1] = 1
  numbers[2] = 2
  numbers[3] = 3
  numbers[4] = 4
  numbers[5] = 0
  numbers[6] = 4198512
  numbers[7] = 0
  numbers[8] = 1326609712
  numbers[9] = 32764
malloc an array ...
This malloc'ed array also has five elements (0 to 4)
  array[0] = 0
  array[1] = 1
  array[2] = 2
  array[3] = 3
  array[4] = 4
  array[5] = 0
  array[6] = 133441
  array[7] = 0
  array[8] = 0
  array[9] = 0
Ok

在引用数组时,始终跟踪其大小。将其存储在变量中;不要硬编码数组大小。否则,当您稍后更新程序以使用不同的数组大小时,您的程序可能会超出数组边界,但您忘记更改硬编码的数组长度。

3. 字符串溢出

字符串只是另一种类型的数组。在 C 编程语言中,字符串是 char 值的数组,其中零字符表示字符串的结尾。

因此,与数组一样,您需要避免超出字符串的范围。这有时称为字符串溢出

溢出字符串的一种简单方法是使用 gets 函数读取数据。gets 函数非常危险,因为它不知道可以在字符串中存储多少数据,并且它天真地从用户读取数据。如果您的用户输入像 foo 这样的短字符串,这很好,但是当用户输入的值对于您的字符串值来说太长时,可能会是灾难性的。

这是一个使用 gets 函数读取城市名称的示例程序。在此程序中,我还添加了一些未使用的变量,以显示字符串溢出如何影响其他数据

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

int
main()
{
  char name[10];                       /* Such as "Chicago" */
  int var1 = 1, var2 = 2;

  /* show initial values */

  printf("var1 = %d; var2 = %d\n", var1, var2);

  /* this is bad .. please don't use gets */

  puts("Where do you live?");
  gets(name);

  /* show ending values */

  printf("<%s> is length %d\n", name, strlen(name));
  printf("var1 = %d; var2 = %d\n", var1, var2);

  /* done */

  puts("Ok");
  return 0;
}

当您测试类似的短城市名称时,例如伊利诺伊州的 Chicago 或北卡罗来纳州的 Raleigh,该程序运行良好

var1 = 1; var2 = 2
Where do you live?
Raleigh
<Raleigh> is length 7
var1 = 1; var2 = 2
Ok

威尔士小镇 Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch 拥有世界上最长的地名之一。这个字符串有 58 个字符,远远超出了 name 变量中保留的 10 个字符。因此,程序将值存储在内存的其他区域中,包括 var1var2 的值

var1 = 1; var2 = 2
Where do you live?
Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch
<Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch> is length 58
var1 = 2036821625; var2 = 2003266668
Ok
Segmentation fault (core dumped)

在中止之前,程序使用长字符串覆盖了内存的其他部分。请注意,var1var2 不再具有其起始值 12

避免使用 gets,并使用更安全的方法来读取用户数据。例如,getline 函数将分配足够的内存来存储用户输入,因此用户不会因为输入过长的值而意外溢出字符串。

4. 两次释放内存

良好的 C 编程规则之一是,“如果您分配了内存,则应该释放它。” 程序可以使用 malloc 函数为数组和字符串分配内存,该函数保留一块内存并返回指向内存中起始地址的指针。稍后,程序可以使用 free 函数释放内存,该函数使用指针将内存标记为未使用。

但是,您应该只使用 free 函数一次。第二次调用 free 将导致意外行为,这可能会破坏您的程序。这是一个简短的示例程序来展示这一点。它分配内存,然后立即释放它。但是,像一个健忘但有条理的程序员一样,我也在程序结束时释放了内存,导致同一块内存被释放了两次

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

int
main()
{
  int *array;

  puts("malloc an array ...");

  array = malloc(sizeof(int) * 5);

  if (array) {
    puts("malloc succeeded");

    puts("Free the array...");
    free(array);
  }

  puts("Free the array...");
  free(array);

  puts("Ok");
}

运行此程序会导致第二次使用 free 函数时发生严重故障

malloc an array ...
malloc succeeded
Free the array...
Free the array...
free(): double free detected in tcache 2
Aborted (core dumped)

避免对数组或字符串多次调用 free。避免两次释放内存的一种方法是将 mallocfree 函数放在同一个函数中。

例如,单人纸牌程序可能会在 main 函数中为一副牌分配内存,然后在其他函数中使用该副牌来玩游戏。在 main 函数中释放内存,而不是在其他函数中释放。将 mallocfree 语句放在一起有助于避免多次释放内存。

5. 使用无效的文件指针

文件是存储数据的便捷方式。例如,您可以将程序的配置数据存储在名为 config.dat 的文件中。Bash shell 从用户主目录中的 .bash_profile 读取其初始脚本。GNU Emacs 编辑器查找文件 .emacs 以获取其起始值。Zoom 会议客户端使用 zoomus.conf 文件来读取其程序配置。

因此,从文件中读取数据的能力对于几乎所有程序都很重要。但是,如果您要读取的文件不存在怎么办?

要在 C 语言中读取文件,您首先使用 fopen 函数打开文件,该函数返回指向文件的流指针。您可以将此指针与其他函数一起使用来读取数据,例如 fgetc 一次读取文件中的一个字符。

如果您要读取的文件不存在或程序无法读取,则 fopen 函数将返回 NULL 作为文件指针,这表示文件指针无效。但是,这是一个示例程序,它天真地不检查 fopen 是否返回 NULL,并尝试读取文件

#include <stdio.h>

int
main()
{
  FILE *pfile;
  int ch;

  puts("Open the FILE.TXT file ...");

  pfile = fopen("FILE.TXT", "r");

  /* you should check if the file pointer is valid, but we skipped that */

  puts("Now display the contents of FILE.TXT ...");

  while ((ch = fgetc(pfile)) != EOF) {
    printf("<%c>", ch);
  }

  fclose(pfile);

  /* done */

  puts("Ok");
  return 0;
}

当您运行此程序时,第一次调用 fgetc 会导致严重故障,并且程序立即中止

Open the FILE.TXT file ...
Now display the contents of FILE.TXT ...
Segmentation fault (core dumped)

始终检查文件指针以确保其有效。例如,在调用 fopen 打开文件后,使用类似 if (pfile != NULL) 的语句检查指针的值,以确保指针是您可以使用的。

我们都会犯错误,编程错误发生在最优秀的程序员身上。但是,如果您遵循这些准则并添加少量额外的代码来检查这五种类型的错误,则可以避免最严重的 C 编程错误。预先添加几行代码来捕获这些错误可以为您节省以后数小时的调试时间。

接下来阅读什么
标签
photo of Jim Hall
Jim Hall 是一位开源软件倡导者和开发人员,以 GNOME 中的可用性测试以及 FreeDOS 的创始人兼项目协调员而闻名。

2 条评论

很棒的文章,Jim!

回复:4. 两次释放内存

为了扩展这个技巧,在调用 free() 将内存释放回堆后,应立即将指针设置为 NULL。

不将指针设置为 NULL 会导致两个潜在问题

1) 在同一地址上两次调用 free(),导致堆损坏。

2) 解引用指向已释放内存的指针,该内存可能正在被程序的其他部分使用,可能会损坏数据或检索不正确的数据。

解决方案很简单;

要么总是

//...
free(array);
array = NULL;
//...

许多程序员忘记这样做,或者更好的方法是

void *free2(void *ptr) // 或其他名称
{
free(ptr);
return NULL;
}

并始终如一地使用新函数,如下所示

array = free2(array);

这解决了上述两个问题。

解引用 NULL 指针应导致段错误,表明指针存在问题,并且在 NULL 指针上调用 free() 无效。

Creative Commons 许可本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 许可。
© . All rights reserved.