【轉】無調試器調試--使用調試宏

轉自:https://www.hahack.com/wiki/tools-makefile.html#

調試器的出現固然極大地改善了可憐的程序員們的生活水平,然而調試器也並不總是扮演救世主的角色,例如,在有複雜競爭條件的多線程程序或者分佈式程序中,調試器所能起的作用通常都不大。另外,調試運行和正常運行的程序實際上是有一定的差異的,有些神奇的 bug,當你以正常方式運行程序時,它跑出來作威作福,可以當你以調試模式運行程序的時候,它就躲得無影無蹤了。更爲極端的情況是沒有調試器可以用,如果 gdb 的開發人員需要用 gdb 來調試 gdb 都還可以接受的話,那麼 Linux kernel 的開發人員就真的是悲劇了。

因此,很多時候,我們需要在沒有調試器的情況下進行調試,幸運的是,在這樣的情況下,也是有一些約定的方法可以遵循的。

core dump

在 Linux 下,程序如果出現段錯誤退出,會產生 core dump 文件,默認情況下被 ulimit 禁用了這個功能,運行下面這個命令:

1
ulimit -c 5000

將允許系統產生 5kB 以內的 core dump 文件,可以根據自己的需求調整大小,並寫到 shell 的啓動腳本里。系統生成的 core dump 文件通常就是叫做 core,包含了程序出錯時的整個狀態,用 gdb 加載 core 文件:

1
gdb program core

就可以進行一些事後分析,例如,可以通過 backtrace 命令查出出錯時的調用棧,並查看一些變量的值等,通常對於定位 bug 有很大的幫助。

printf 調試

printf 調試泛指通過記錄程序執行狀態來做調試的方法。具體來說,通常我們對於程序的行爲和狀態都有一些期望的值,通過將程序運行時的實際值打印出來,與期望的情況進行對比,就可以逐漸找到問題的所在。然而這種方法操作起來卻有一些相當繁瑣的地方:

首先,由於不知道問題出在哪裏,又不能在所有的地方都添加輸出語句(輸出信息太多的話,要找到問題就變得困難了),所以通常會在可疑的地方添加輸出語句,!如果結果發現猜錯了,就需要換一個地方或者擴大範圍,修改代碼,重新編譯,運行,再查看新的輸出結果。對於編譯時間很長(例如,有很多模版代碼的 C++程序)的情況,整個過程會變得相當痛苦,因爲可能需要重複很多次,並且許多時間都是在做無聊的等待。 其次,如果找到了問題所在,是不是要刪除那些狀態輸出語句呢?過多的輸出是會影響程序運行性能的,特別是打印到終端上。這些輸出語句可能遍佈代碼的各個角落,要全部清除也不容易,而且,萬一以後遇到了類似的 bug 呢?可能還要再寫一遍這些類似的輸出語句。另一個選擇就是把他們註釋掉。但是,無論如何,代碼會被改得越來越亂。

避免讓代碼變亂的解決方案是使用標準化的工具,例如,最簡單的情況,可以使用下面這樣的一個宏:

1
2
3
4
5
#ifdef DEBUG
#define LOG(args) printf args
#else
#define LOG(args) ((void) 0)
#endif /* DEBUG */

需要記錄信息的時候,使用

1
LOG(("a = %d, b = %d\n", a, b));

就可以了(注意雙重括號是必要的),需要調試的時候,只要定義 DEBUG,就可以得到調試輸出, 而調試結束之後可以直接去掉 DEBUG 的定義, 這樣 LOG 宏在編譯的時候就會變成空語句,也就不會產生任何輸出了。即使想要移除這些調試語句,由於它們都有統一的格式,因此也可以方便地進行自動化處理。

對於更爲複雜的項目,可以使用一些第三方的成熟的日誌庫來滿足更復雜的需求,實現更靈活的控制。

總的來說, printf 調試主要用在兩種情況下:

  • 過於簡單的情況:懶得啓動調試器了。
  • 過於複雜的情況:調試器已經無能爲力了,例如一些分佈式的程序。

assert 斷言

程序一般分爲 Debug 版本和 Release 版本, Debug 版本用於內部調試, Release 版本發行給用戶使用。

斷言 assert 是僅在 Debug 版本起作用的宏,它用於檢查“不應該”發生的情況,爲程序增加診斷功能。

1
void assert(int expression)

assert(expression)執行時,如果表達式的值爲0,那麼 assert 宏將在標準出錯輸出流 stderr 輸出一條如下所示的信息:

1
Assertion failed: 表達式, file 文件名, line nnn

然後調用 abort 終止執行。其中的源文件名和行號來自於預處理程序宏 __FILE__ 和 __LINE__ 。

如果在頭文件 assert.h 被包含時定義了宏 NDEBUG,那麼宏 assert 將被忽略。

下例是一個內存複製函數。在運行過程中,如果 assert 的參數爲假,那麼程序就會中止(一般地還會出現提示對話,說明在什麼地方引發了 assert)。

1
2
3
4
5
6
7
8
9
void *memcpy(void *pvTo, const void *pvFrom, size_t size)
{
    assert((pvTo != NULL) && (pvFrom != NULL)); // 使用斷言
    byte *pbTo = (byte *) pvTo; 	// 防止改變 pvTo 的地址
    byte *pbFrom = (byte *) pvFrom; 	// 防止改變 pvFrom 的地址
    while(size -- > 0 )
        *pbTo ++ = *pbFrom ++ ;
    return pvTo;
}

在程序裏許多地方插入斷言也沒有關係,斷言在正常的時候並不會產生輸出,而且在去掉調試選項之後,斷言會編譯爲空語句,不會影響最終程序的性能。另外,斷言通常是對程序狀態的一個客觀描述,還可以起到註釋的作用。因此在代碼中保留合適的斷言是比較推薦的做法。

斷言出錯之後立即退出,而 printf 則需要事後再去分析和尋找問題。然而太過於暴力也算是斷言的一個缺點,因爲 bug 有大小疾緩,有時候讓程序能持續穩定地運行也是很重要的,因此除非特別嚴重的時候,人們通常會傾向於使用更加溫和的記錄日誌的方式來記錄下潛在的 bug,而不是直接結束程序。

使用斷言的規則:

  1. 使用斷言捕捉不應該發生的非法情況。不要混淆非法情況與錯誤情況之間的區別,後者是必然存在的並且是一定要作出處理的。
  2. 在函數的入口處,使用斷言檢查參數的有效性(合法性)。
  3. 在編寫函數時,要進行反覆的考查,並且自問:“我打算做哪些假定?”一旦確定了的假定,就要使用斷言對假定進行檢查。
  4. 一般教科書都鼓勵程序員們進行防錯設計,但要記住這種編程風格可能會隱瞞錯誤。當進行防錯設計時,如果“不可能發生”的事情的確發生了,則要使用斷言進行報警。

func 變量

在打印調試信息時除了文件名和行號之外還可以打印出當前函數名,C99引入一個特殊的標識符__func__支持這一功能。這個標識符應該是一個變量名而不是宏定義,不屬於預處理的範疇,但它的作用和__FILE____LINE__類似,所以放在一起講。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

void myfunc(void)
{
    printf("%s\n", __func__);
}

int main(void)
{
    myfunc();
    printf("%s\n", __func__);
    return 0;
}

輸出:

1
2
3
4
$ gcc main.c
$ ./a.out 
myfunc
main

調試宏

Mongrel 的作者 Zed A. Shaw 編寫了一個更爲實用的調試宏,內容只有如下短短几行:

#ifndef __dbg_h__
#define __dbg_h__

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

#ifdef NDEBUG
#define debug(M, ...)
#else
#define debug(M, ...) fprintf(stderr, "DEBUG %s:%d: " M "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#endif

#define clean_errno() (errno == 0 ? "None" : strerror(errno))

#define log_err(M, ...) fprintf(stderr, "[ERROR] (%s:%d: errno: %s) " M "\n", __FILE__, __LINE__, clean_errno(), ##__VA_ARGS__)

#define log_warn(M, ...) fprintf(stderr, "[WARN] (%s:%d: errno: %s) " M "\n", __FILE__, __LINE__, clean_errno(), ##__VA_ARGS__)

#define log_info(M, ...) fprintf(stderr, "[INFO] (%s:%d) " M "\n", __FILE__, __LINE__, ##__VA_ARGS__)

#define check(A, M, ...) if(!(A)) { log_err(M, ##__VA_ARGS__); errno=0; goto error; }

#define sentinel(M, ...)  { log_err(M, ##__VA_ARGS__); errno=0; goto error; }

#define check_mem(A) check((A), "Out of memory.")

#define check_debug(A, M, ...) if(!(A)) { debug(M, ##__VA_ARGS__); errno=0; goto error; }

#endif

 

將它保存爲 dbg.h 就可以在需要調試的地方引入該文件然後調用預定義的幾個調試函數:

  • debug:當沒有預定義 NDEBUG 宏時,調用形如 debug("format", arg1, arg2) 的語句將可以像 fprintf 一樣輸出內容到 stderr。如果預定義了 NDEBUG ,則調用 debug 函數將不會產生任何輸出;
  • clean_errno:獲得一個更安全且可讀的 errno 版本。通常作爲其他幾個調試函數的參數;
  • log_errlog_warnlog_info:產生日誌輸出。和 debug 函數類似,但是不能通過設置 NDEBUG 來跳過執行;
  • check:非常有用的宏,可以檢查條件 A 是否成立。如果不成立,將錯誤 M 輸出到日誌(利用 log_err 宏),並跳轉到函數的錯誤處理部分(使用 error: 標號標記的語句段)。
  • sentinel:另一個實用的宏。用於放到一個不該被執行的函數裏面。如果該函數被執行,則會打印一個錯誤信息,並跳轉到函數的錯誤處理部分 error: 。常用的用法是將它放到 if 語句或 switch 語句中不該執行的邊界條件裏,例如 default: 語句段中;
  • check_mem:確保一個指針是有效的指針(不是空指針),如果該指針爲空,則提示“Out of memory.”錯誤信息;
  • check_debug:和 check 類似,但是底層執行的是 debug 宏而非 log_err 宏,因此可以通過設置 NDEBUG 來禁用這些輸出,但仍然會進行錯誤檢查和處理。

總結

  1. 在絕大多數的情況下,使用調試宏來診斷和修復跟邏輯語句相關的錯誤。
  2. 使用 Valgrind 來捕捉所有跟內存相關的錯誤;
  3. 對於上面兩個工具無法解決的詭異問題,或者在某些緊急的場合被逼儘可能多的獲取錯誤相關信息的時候,才使用 gdb 。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章