Linux C 開發中的一些常用的調試技巧

  1. 調試相關的宏

在 Linux 使用 gcc 編譯程序的時候,對於調試的語句還具有一些特殊的語法。

gcc 編譯的過程中,會生成一些宏,可以使用這些宏分別打印當前源文件的信息,主要內容是當前的文件、當前運行的函數和當前的程序行。

具體宏如下:

__FILE__  當前程序源文件 (char*)
__FUNCTION__  當前運行的函數 (char*)
__LINE__  當前的函數行 (int)

這些宏不是程序代碼定義的,而是有編譯器產生的。這些信息都是在編譯器處理文件的時候動態產生的。

「測試示例:」

#include <stdio.h>

int main(void)
{
    printf("file: %s\n", __FILE__);
    printf("function: %s\n", __FUNCTION__);
    printf("line: %d\n", __LINE__);

    return 0;
}
  1. 字符串化操作符


在 gcc 的編譯系統中,可以使用 #將當前的內容轉換成字符串。

「程序示例:」

#include <stdio.h>

#define DPRINT(expr) printf("<main>%s = %d\n", #expr, expr);

int main(void)
{
    int x = 3;
    int y = 5;

    DPRINT(x / y);
    DPRINT(x + y);
    DPRINT(x * y);
    
    return 0;
}

「執行結果:」

deng@itcast:~/tmp$ gcc test.c 
deng@itcast:~/tmp$ ./a.out  
<main>x / y = 0
<main>x + y = 8
<main>x * y = 15

#expr 表示根據宏中的參數 (即表達式的內容),生成一個字符串。該過程同樣是有編譯器產生的,編譯器在編譯源文件的時候,如果遇到了類似的宏,會自動根據程序中表達式的內容,生成一個字符串的宏。

這種方式的優點是可以用統一的方法打印表達式的內容,在程序的調試過程中可以方便直觀的看到轉換字符串之後的表達式。

具體的表達式的內容是什麼,有編譯器自動寫入程序中,這樣使用相同的宏打印所有表達式的字符串。

//打印字符
#define debugc(expr) printf("<char> %s = %c\n", #expr, expr)
//打印浮點數
#define debugf(expr) printf("<float> %s = %f\n", #expr, expr)
//按照16進制打印整數
#define debugx(expr) printf("<int> %s = 0X%x\n", #expr, expr);

由於 #expr 本質上市一個表示字符串的宏,因此在程序中也可以不適用 %s 打印它的內容,而是可以將其直接與其它的字符串連接。

因此,上述宏可以等價以下形式:

//打印字符
#define debugc(expr) printf("<char> #expr = %c\n", expr)
//打印浮點數
#define debugf(expr) printf("<float> #expr = %f\n", expr)
//按照16進制打印整數
#define debugx(expr) printf("<int> #expr = 0X%x\n", expr);

「總結:」

#是 C 語言預處理階段的字符串化操作符,可將宏中的內容轉換成字符串。

  1. 連接操作符


在 gcc 的編譯系統中,## 是 C 語言中的連接操作符,可以在編譯的預處理階段實現字符串連接的操作。

「程序示例:」

#include <stdio.h>

#define test(x) test##x

void test1(int a)
{
    printf("test1 a = %d\n", a);
}

void test2(char *s)
{
    printf("test2 s = %s\n", s);
}

int main(void)
{
    test(1)(100);

    test(2)("hello world");
    
    return 0;
}

上述程序中,test(x) 宏被定義爲 test##x, 他表示 test 字符串和 x 字符串的連接。

在程序的調試語句中,## 常用的方式如下

#define DEBUG(fmt, args...) printf(fmt, ##args)

替換的方式是將參數的兩個部分以 ## 連接。## 表示連接變量代表前面的參數列表。使用這種形式可以將宏的參數傳遞給一個參數。args… 是宏的參數,表示可變的參數列表,使用 ##args 將其傳給 printf 函數.

「總結:」

是 C 語言預處理階段的連接操作符,可實現宏參數的連接。

  1. 調試宏第一種形式

一種定義的方式:

#define DEBUG(fmt, args...)             \
    {                                   \
    printf("file:%s function: %s line: %d ", __FILE__, __FUNCTION__, __LINE__);\
    printf(fmt, ##args);                \
    }

「程序示例:」

#include <stdio.h>

#define DEBUG(fmt, args...)             \
    {                                   \
    printf("file:%s function: %s line: %d ", __FILE__, __FUNCTION__, __LINE__);\
    printf(fmt, ##args);                \
    }


int main(void)
{
    int a = 100;
    int b = 200;

    char *s = "hello world";
    DEBUG("a = %d b = %d\n", a, b);
    DEBUG("a = %x b = %x\n", a, b);
    DEBUG("s = %s\n", s);
    
    return 0;
}

「總結:」

上面的 DEBUG 定義的方式是兩條語句的組合,不可能在產生返回值,因此不能使用它的返回值。

  1. 調試宏的第二種定義方式

調試宏的第二種定義方式

#define DEBUG(fmt, args...)             \
    printf("file:%s function: %s line: %d "fmt, \
    __FILE__, __FUNCTION__, __LINE__, ##args)

程序示例

#include <stdio.h>

#define DEBUG(fmt, args...)             \
    printf("file:%s function: %s line: %d "fmt, \
    __FILE__, __FUNCTION__, __LINE__, ##args)


int main(void)
{
    int a = 100;
    int b = 200;

    char *s = "hello world";
    DEBUG("a = %d b = %d\n", a, b);
    DEBUG("a = %x b = %x\n", a, b);
    DEBUG("s = %s\n", s);
    
    return 0;
}

「總結:」

fmt 必須是一個字符串,不能使用指針,只有這樣纔可以實現字符串的功能。

  1. 對調試語句進行分級審查

即使定義了調試的宏,在工程足夠大的情況下,也會導致在打開宏開關的時候在終端出現大量的信息。而無法區分哪些是有用的。

這個時候就要加入分級檢查機制,可以定義不同的調試級別,這樣就可以對不同重要程序和不同的模塊進行區分,需要調試哪一個模塊就可以打開那一個模塊的調試級別。

一般可以利用配置文件的方式顯示,其實 Linux 內核也是這麼做的,它把調試的等級分成了 7 個不同重要程度的級別,只有設定某個級別可以顯示,對應的調試信息纔會打印到終端上。

可以寫出一下配置文件

[debug]
debug_level=XXX_MODULE

解析配置文件使用標準的字符串操作庫函數就可以獲取 XXX_MODULE 這個數值。

int show_debug(int level)
{
    if (level == XXX_MODULE)
    {
        #define DEBUG(fmt, args...)             \
        printf("file:%s function: %s line: %d "fmt, \
        __FILE__, __FUNCTION__, __LINE__, ##args)       
    }
    else if (...)
    {
        ....
    }
}
  1. 條件編譯調試語句

在實際的開發中,一般會維護兩種源程序,一種是帶有調試語句的調試版本程序,另外一種是不帶有調試語句的發佈版本程序。

然後根據不同的條件編譯選項,編譯出不同的調試版本和發佈版本的程序。

在實現過程中,可以使用一個調試宏來控制調試語句的開關。

#ifdef USE_DEBUG
        #define DEBUG(fmt, args...)             \
        printf("file:%s function: %s line: %d "fmt, \
        __FILE__, __FUNCTION__, __LINE__, ##args)  
#else
  #define DEBUG(fmt, args...)

#endif

如果 USE_DEBUG 被定義,那麼有調試信息,否則 DEBUG 就爲空。

如果需要調試信息,就只需要在程序中更改一行就可以了。

#define USE_DEBUG
#undef USE_DEBUG

定義條件編譯的方式使用一個帶有值的宏

#if USE_DEBUG
        #define DEBUG(fmt, args...)             \
        printf("file:%s function: %s line: %d "fmt, \
        __FILE__, __FUNCTION__, __LINE__, ##args)  
#else
  #define DEBUG(fmt, args...)

#endif

可以使用如下方式進行條件編譯

#ifndef USE_DEBUG
#define USE_DEBUG 0
#endif
  1. 使用 do…while 的宏定義

使用宏定義可以將一些較爲短小的功能封裝,方便使用。宏的形式和函數類似,但是可以節省函數跳轉的開銷。

如何將一個語句封裝成一個宏,在程序中常常使用 do…while(0) 的形式。

#define HELLO(str) do { \
printf("hello: %s\n", str); \
}while(0)

「程序示例:」

int cond = 1;
if (cond)
    HELLO("true");
else
    HELLO("false");
  1. 代碼剖析

對於比較大的程序,可以藉助一些工具來首先把需要優化的點清理出來。接下來我們來看看在程序執行過程中獲取數據並進行分析的工具:代碼剖析程序。

「測試程序:」

#include <stdio.h>


#define T 100000

void call_one()
{
    int count = T * 1000;
    while(count--);
}

void call_two()
{
    int count = T * 50;
    while(count--);
}

void call_three()
{
    int count = T * 20;
    while(count--);
}


int main(void)
{
    int time = 10;

    while(time--)
    {
        call_one();
        call_two();
        call_three();
    }
    
    return 0;
}

編譯的時候加入 - pg 選項:

deng@itcast:~/tmp$ gcc -pg  test.c -o test

執行完成後,在當前文件中生成了一個 gmon.out 文件。

deng@itcast:~/tmp$ ./test  
deng@itcast:~/tmp$ ls
gmon.out  test  test.c
deng@itcast:~/tmp$

「使用 gprof 剖析主程序:」

deng@itcast:~/tmp$ gprof test
Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  ms/call  ms/call  name    
 95.64      1.61     1.61       10   160.68   160.68  call_one
  3.63      1.67     0.06       10     6.10     6.10  call_two
  2.42      1.71     0.04       10     4.07     4.07  call_three

其中主要的信息有兩個,一個是每個函數執行的時間佔程序總時間的百分比,另外一個就是函數被調用的次數。通過這些信息,可以優化核心程序的實現方式來提高效率。

當然這個剖析程序由於它自身特性有一些限制,比較適用於運行時間比較長的程序,因爲統計的時間是基於間隔計數這種機制,所以還需要考慮函數執行的相對時間,如果程序執行時間過短,那得到的信息是沒有任何參考意義的。

「將上訴程序時間縮短:」

#include <stdio.h>


#define T 100

void call_one()
{
    int count = T * 1000;
    while(count--);
}

void call_two()
{
    int count = T * 50;
    while(count--);
}

void call_three()
{
    int count = T * 20;
    while(count--);
}


int main(void)
{
    int time = 10;

    while(time--)
    {
        call_one();
        call_two();
        call_three();
    }
    
    return 0;
}

「剖析結果如下:」

deng@itcast:~/tmp$ gcc -pg test.c -o test
deng@itcast:~/tmp$ ./test  
deng@itcast:~/tmp$ gprof test
Flat profile:

Each sample counts as 0.01 seconds.
 no time accumulated

  %   cumulative   self              self     total           
 time   seconds   seconds    calls  Ts/call  Ts/call  name    
  0.00      0.00     0.00       10     0.00     0.00  call_one
  0.00      0.00     0.00       10     0.00     0.00  call_three
  0.00      0.00     0.00       10     0.00     0.00  call_two

因此該剖析程序對於越複雜、執行時間越長的函數也適用。

那麼是不是每個函數執行的絕對時間越長,剖析顯示的時間就真的越長呢?可以再看如下的例子

#include <stdio.h>


#define T 100

void call_one()
{
    int count = T * 1000;
    while(count--);
}

void call_two()
{
    int count = T * 100000;
    while(count--);
}

void call_three()
{
    int count = T * 20;
    while(count--);
}


int main(void)
{
    int time = 10;

    while(time--)
    {
        call_one();
        call_two();
        call_three();
    }
    
    return 0;
}

「剖析結果如下:」

deng@itcast:~/tmp$ gcc -pg test.c -o test
deng@itcast:~/tmp$ ./test  
deng@itcast:~/tmp$ gprof test
Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  ms/call  ms/call  name    
101.69      0.15     0.15       10    15.25    15.25  call_two
  0.00      0.15     0.00       10     0.00     0.00  call_one
  0.00      0.15     0.00       10     0.00     0.00  call_three

「總結:」

在使用 gprof 工具的時候,對於一個函數進行 gprof 方式的剖析,實質上的時間是指除去庫函數調用和系統調用之外,純碎應用部分開發的實際代碼運行的時間,也就是說 time 一項描述的時間值不包括庫函數 printf、系統調用 system 等運行的時間。

這些實用庫函數的程序雖然運行的時候將比最初的程序實用更多的時間,但是對於剖析函數來說並沒有影響。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pTpjJiDRmWAU03NKrAHuWQ