C中的匯流排錯誤和段錯誤

rexnie發表於2018-05-19

最近寫了個基於linux的日誌系統,中途遇到了兩個錯誤: bus error(core dumped)和segmentation fault(core dumped)。 這兩個錯誤非常的折磨人,錯誤資訊對引起這兩種錯誤的原始碼錯誤並未作簡單的解釋,上面的資訊並未提供如何從程式碼中尋找錯誤的線索。所以往往很難定位到具體出錯在哪裡。

大多數的問題都出於這樣一個事實:錯誤就是作業系統(OS)所檢測到的異常,而這個異常是儘可能地以OS方便的原則來報告的。匯流排錯誤和段錯誤的準確原因在不同的OS版本上各不相同。

當OS檢測到一個有問題的記憶體引用時,就會出現這兩種錯誤。OS通過向出錯的程式傳送一個訊號(signal)與之交流。訊號就是一種事件通知或一個軟體中斷,在預設情況下,程式在收到“匯流排錯誤”或“段錯誤”訊號後將進行資訊轉儲並中止執行。不過也可以為這些訊號設定一個訊號處理程式(signal handler),用於修改程式的預設反應。

1. 匯流排錯誤

事實上,匯流排錯誤幾乎都是由於未對齊的讀或寫引起的。它之所以稱為匯流排錯誤,是因為出現未對齊的記憶體訪問請求時,被阻塞(block)的元件就是地址匯流排。對齊(alignment)的意思就是資料項只能儲存在地址是資料項大小的整數倍的記憶體上。在現代的計算機架構中,尤其是RISC架構,都需要資料對齊,因為與任意的對齊有關的有關的額外邏輯會使整個記憶體系統更大且更慢。通過迫使每個記憶體訪問侷限在一個cache行或一個單獨的頁面內,可以極大地簡化並加速如cache控制器和記憶體管理單元(MMU)這樣的硬體。

我們用地址對齊這個術語來陳述這個問題,而不是直截了當地說是禁止記憶體跨頁訪問,但它們說但是同一回事。例如,訪問一個8位元組的double資料時,地址只允許是8的整數倍。所以一個double資料可以儲存於地址24,地址8008或32768,但不能儲存於地址1006(因為它無法被8整除)。

頁和cache的大小都是經過精心設計的,這樣只要遵守對齊規則就可以保證一個原子資料項不會跨過一個頁或cache塊的邊界。

《c專家程式設計》一書給了個關於匯流排錯誤的例項,

#include<stdio.h>

union {
    char a[10];
    int i;
} u;

int main(void)
{
#if defined(__GNUC__)
# if defined(__i386__)
    /* Enable Alignment Checking on x86 */
    __asm__("pushf\norl $0x40000,(%esp)\npopf");
# elif defined(__x86_64__)
    /* Enable Alignment Checking on x86_64 */
    __asm__("pushf\norl $0x40000,(%rsp)\npopf");
# endif
#endif

    int *p = (int *) (&(u.a[1]));
    
    /**
     * p中未對齊的地址將會引起匯流排錯誤,
     * 因為陣列和int的聯合確保了a是按照int的4位元組來對齊的,
     * 所以“a+1”肯定不是int對齊的
     */
    *p = 17; 
    printf("%d %p %p %p\n", *p, &(u.a[0]), &(u.a[1]), &(u.i));
    printf("%lu %lu\n", sizeof(char), sizeof(int));
    return 0;
}

複製程式碼

執行結果:

Bus error (core dumped)
複製程式碼

main函式開始的那段條件編譯括起來的彙編,是使能x86平臺的對齊核對的,預設x86平臺是不進行對齊核對的。如果把那段程式碼去掉,可執行檔案將不會報錯,得到的執行結果是:

17 0x601030 0x601031 0x601030
1 4
複製程式碼

這是因為x86體系結構會把地址對齊之後,訪問兩次,然後把第一次的尾巴和第二次的頭拼起來。所以造成了不對齊也可以訪問的假象。

測試的過程中發現,如果在編譯的過程中給gcc加上-O3選項,程式碼也可以正常執行。

測試環境:Ubuntu 12.04.5 LTS, x86_64, gcc (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3

2. 段錯誤

段錯誤是由於記憶體管理單元(MMU)的異常導致的,而該異常通常是由於引用一個未初始化或非法值的指標引起的。如果指標引用一個並不在程式地址空間的地址,便會引發該錯誤。

《c專家程式設計》一書給了個關於段錯誤最簡單的例項,

int *p = 0;
*p = 17;
複製程式碼

指標p指向了一個空地址,所以該賦值語句是空地址寫入17,所以會報Segmentation fault。

一個微妙之處是,導致指標具有非法值通常是由於不同的程式設計錯誤引起的。

一個更糟糕的情況是,如果未初始化的指標恰好具有非對齊的值,它將會產生匯流排錯誤,而不是段錯誤。對於絕大多數架構的計算機而言確實如此,因為cpu先看到地址,然後再把它傳送給MMU。

通常導致段錯誤的幾個直接原因:

  • 引用一個包含非法值的指標

  • 引用一個空指標(常常是由於從函式中返回空指標,未經檢查就使用造成的)

  • 在未得到正確的許可權時進行訪問。例如,試圖往一個只讀的文字段儲存值就會引起段錯誤。

  • 用完了堆或棧空間。

以發生頻率為序,最終可能導致段錯誤的常見程式設計錯誤是:

  • 壞指標值錯誤

    • 未給指標賦值,就引用指標指向的記憶體
    • 向庫函式傳遞一個壞指標
    • 對指標指向的記憶體釋放了之後再訪問改記憶體。可以像下面這樣做,這樣在指標釋放之後繼續使用該指標的話,至少程式能在終止之前進行core dump。
      free(p);
      p = NULL;
      複製程式碼
  • overwrite錯誤

    • 越過陣列邊界使用該指標

    • 在動態分配的記憶體兩端之外寫入資料,比如,動態分配的記憶體,使用者得到的記憶體地址p前面包含heap管理的資料結構,如果往地址p前面一點寫入值,很可能會破壞heap管理結構。或者在地址p之後寫入值,導致heap中下一塊記憶體被破壞。這兩種情況都會導致heap內部出錯。

      p = malloc(256);
      p[-1] = 0;
      p[256] = 0
      複製程式碼
  • 指標釋放引起的錯誤

    • free同一塊記憶體兩次

    • free一塊不是使用malloc分配的記憶體

    • free仍在使用中的記憶體

    • free一個無效的指標

      比如,像下面這樣迭代一個連結串列時,在下一次迴圈迭代時,程式對已經釋放的記憶體進行再次引用時,會發生不可預料的結果。

      for(p=start;p;p=p->next)
        free(p);
      複製程式碼

      應該引入一個tmp指標儲存。

      for(p=start;p;) {
         tmp = p;
         p = p->next;
         free(tmp);
      }
      複製程式碼

下面再舉個我遇到的一個段錯誤的例子:

#include <stdio.h>

#define SZ (64*1024*1024)

void func(void)
{
    char buf[SZ];
    printf("sizeof buf=%lu\n", sizeof(buf));
}

int main(void)
{
    func();
    return 0;
}
複製程式碼

這個是棧空間用盡的錯誤。

其實我當初遇到的問題沒有這麼明顯,我分配的空間SZ沒有那麼大,所以一般情況下是正常的。當我程式執行過程中,資料越來越多,超過這個SZ的時候,再把資料寫到buf就報段錯誤了。所以嚴格來說,我的這個錯誤是破壞了棧空間。

3. 怎麼排查這種難纏的錯誤

這種錯誤非常難排查,記得當初沒有經驗的時候,通過在原始碼裡不斷加printf來除錯,現在想想這種方法是有多低效,如果遇到概率性的問題,那就基本沒有辦法。後來查閱過後才知道充分利用core檔案。核心轉儲的最大好處是能夠儲存問題發生時的狀態。即使問題沒有復現,只要獲取核心轉儲,也能除錯,通過可執行檔案和核心轉儲,就可以知道程式當時的狀態,知道發生問題時的現場,甚至定位到出問題的語句。

在Ubuntu下,預設是不開啟core dump的。

3.1 開啟core檔案

可通過在終端輸入下面的命令檢視:

ulimit -c
複製程式碼

顯示為零,表示core檔案的大小限制在0,即不生成core檔案。

設定core file size限制為1G blocks,可在終端輸入:

ulimit -c 1073741824
複製程式碼

或者不限制core file size:

ulimit -c unlimited
複製程式碼

3.2 通過gdb除錯

在終端輸入下面的命令即可除錯

gdb executable-file core-file
複製程式碼

以上面往空地址賦值17的例子為例,當在當前目錄下生產core檔案後,在終端輸入

gdb ./a.out core
複製程式碼

即可得到下面的結果

...
[New LWP 6950]

warning: Can not read pathname for load map: Input/output error.

warning: no loadable sections found in added symbol-file system-supplied DSO at 0x7ffe32533000
Core was generated by ./a.out.
Program terminated with signal 11, Segmentation fault.
#0  0x00000000004005bb in main () at segmentation_error.c:14
14	    *p = 17;
(gdb) 

複製程式碼

非常厲害的是,你可以重新執行並除錯該可執行檔案,設定斷點,檢視變數,非常方便。

詳細的關於core檔案設定的,可參考coredump設定方法

參考:

  1. 《The C Programming Language中文版(第2版.新版)》
  2. 《C專家程式設計》
  3. ubuntu core dump設定方法

相關文章