人人都寫過的5個Bug!

  • 2021 年 10 月 28 日
  • 筆記

大家好,我是良許。

電腦專業的小夥伴,在學校期間一定學過 C 語言。它是眾多高級語言的鼻祖,深入學習這門語言會對電腦原理、作業系統、記憶體管理等等底層相關的知識會有更深入的了解,所以我在直播的時候,多次強調大家一定要好好學習這門語言。

但是,即使是最有經驗的程式設計師也會寫出各種各樣的 Bug。本文就盤點一下學習或使用 C 語言過程中,非常容易出現的 5 個 Bug,以及如何規避這些 Bug。

這篇文章主要面向初學者,老鳥可以忽略哈(其實不少老鳥依然還會犯這些低級錯誤哦)~

1. 變數未初始化

當程式啟動時,系統會給它自動分配一塊記憶體,程式可以用它來存儲數據。所以如果你在定義一個變數時,在未初始化的情況下,它的值有可能是任意的。

但這也不是絕對的,有些環境就會在程式啟動時自動將記憶體「清零」,因此每個變數默認值都是零。考慮到可移植性,最好要將變數進行初始化,這是一名合格軟體工程師應該養成的好習慣。

我們來看下下面這個使用幾個變數和兩個數組的示常式序:

#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;
}

這段程式沒有對變數進行初始化,所以變數的值有可能是隨機的,不一定是零。在我的電腦上它的運行結果如下 :

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 的值剛好是 0,但 k 值為 32766。 在 numbers 數組中,大多數元素也恰好是零,除了第三個(4199024)。

在不同的作業系統上編譯這段相同的程式,運行的結果有可能又是不一樣的。所以千萬不要覺得你的結果就是正確唯一的,一定要考慮可移植性。

例如,這是在 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. 數組越界

在電腦世界裡,都是從 0 開始計數,但總有人有意無意忘記這點。比如一個數組長度為 10 ,想要獲取最後一個元素的值,總有人用 array[10] ……

別問,問就是我寫過……

新手朋友犯這種低級錯誤特別多。我們來看下數組越界會發生什麼。

#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;
}

請注意,程式初始化了數組 numbers 所有元素的值(0~4),但是越界讀取了第 0~9 元素的值。可以看出來,前五個值是正確的,但之後鬼都不知道這些值會是什麼:

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

所以大家在寫程式碼過程中,一定要知道數組的邊界。像這種數據讀取的還好,如果一旦對這些記憶體進行寫操作,直接就 core dump !

3. 字元串溢出

在 C 程式語言中,字元串是一組 char 值,也可以將其視為數組。因此,你也需要避免超出字元串的範圍。如果超出,則稱為字元串溢出

為了測試字元串溢出,一種簡單方法是使用 gets 函數讀取數據。gets 函數非常危險,因為它不知道接收它的字元串中可以存儲多少數據,只會天真地從用戶那裡讀取數據。

如果用戶輸入字元串比較短那很好,但如果用戶輸入的值超過接收字元串的長度,則可能是災難性的。

下面我們來演示一下這個現象:

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

int main()
{
  char name[10];                       /* Such as "Beijing" */
  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;
}

在這段程式碼里,接收數組的長度為 10 ,所以當輸入數據長度小於 10 的話,程式運行就沒問題。

例如,輸入城市 Beijing ,長度為 7 :

var1 = 1; var2 = 2
Where do you live?
Beijing
<Beijing> 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

所以我們需要使用更安全的方法來讀取用戶數據。例如,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 函數時出現 core dump 錯誤:

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

那麼怎麼避免多次調用 free 函數呢?一個最簡單的方法就是將 mallocfree 語句放在一個函數里。

如果你將 malloc 放在一個函數里,而將 free 放在另一個函數里,那麼,在使用的過程中,如果邏輯設計不恰當,都有可能出現 free 被調用多次的情況。

5. 使用無效的文件指針

文件是作業系統里一種非常常見的數據存儲方式。例如,您可以將程式的配置資訊存儲在名為 config.dat 文件里,程式運行時,就可以調用這個文件,讀取配置資訊。

因此,從文件中讀取數據的能力對所有程式設計師都很重要。但是,如果你要讀取的文件不存在怎麼辦?

在 C 語言中,要讀取文件一般是先使用 fopen 函數打開文件,然後該函數返回指向文件的流指針。

如果您要讀取的文件不存在或您的程式無法讀取,則 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;
}

當你運行這個程式時,如果 FILE.TXT 這個文件不存在,那麼 pfile 將返回 NULL。在這種情況下我們還對 pfile 進行寫操作的話,會立刻導致 core dump :

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

所以,我們要始終檢查文件指針是否有效。例如,在調用 fopen 函數打開文件後,使用 if (pfile != NULL) 以確保指針是可以使用的。

小結

再有經驗的程式設計師都有可能犯錯誤,所以寫程式碼的時候我們要嚴謹再嚴謹。但是,如果你養成一些良好的習慣,並添加一些額外的程式碼來檢查這五種類型的錯誤,則可以避免嚴重的 C 編程錯誤。

上面介紹的 5 種常見錯誤,你都寫過哪些 Bug 呢?留言跟大家交流哦,看看誰是 Bug 王!