GDB 調試 - 從入門實踐到原理

一直有讀者在問,能不能寫一篇關於 gdb 調試方面的文章,今天藉助此文,分享一些工作中的調試經驗,希望能夠幫到大家。

寫在前面

在我的工作經歷中,前幾年在 Windows 上進行開發,使用 Visual Studio 進行調試,簡直是利器,各種斷點等用鼠標點點點就能設置;大概從 12 年開始轉 Linux 開發了,所以調試都是基於 GDB 的。本來這篇文章也想寫寫 Windows 下調試相關,奈何好多年沒用了,再加上工作太忙,所以本文就只寫了 Linux 下 GDB 調試相關,對於 Windows 開發人員,實在對不住了😃。

這篇文章,涉及的比較全面,總結了這些年的 gdb 調試經驗 (都是小兒科😁),經常用到的一些調試技巧,希望能夠對從事 Linux 開發的相關人員有所幫助

背景

作爲 C/C++ 開發人員,保證程序正常運行是最基本也是最主要的目的。而爲了保證程序正常運行,調試則是最基本的手段,熟悉這些調試方式,可以方便我們更快的定位程序問題所在,提高開發效率。

在開發過程,如果程序的運行結果不符合預期,第一時間就是打開 GDB 進行調試,在對應的地方設置斷點,然後分析原因;當線上服務出了問題,第一時間查看進程在不在,如果不在的話,是否生成了coredump文件,如果有,則使用 gdb 調試 coredump 文件,否則通過dmesg來分析內核日誌來查找原因。

概念

GDB 是一個由 GNU 開源組織發佈的、UNIX/LINUX 操作系統下的、「基於命令行的、功能強大的程序調試工具」

GDB 支持斷點、單步執行、打印變量、觀察變量、查看寄存器、查看堆棧等調試手段。在 Linux 環境軟件開發中,GDB 是主要的調試工具,用來調試 C 和 C++ 程序 (也支持 go 等其他語言)。

常用命令

斷點

斷點是我們在調試中經常用的一個功能,我們在指定位置設置斷點之後,程序運行到該位置將會暫停,這個時候我們就可以對程序進行更多的操作,比如查看變量內容,堆棧情況等等,以幫助我們調試程序。

以設置斷點的命令分爲以下幾類:

breakpoint

可以根據行號、函數、條件生成斷點,下面是相關命令以及對應的作用說明:

3hnPJl

watchpoint

watchpoint 是一種特殊類型的斷點,類似於正常斷點,是要求 GDB 暫停程序執行的命令。區別在於 watchpoint沒有駐留某一行源代碼中,而是指示 GDB 每當某個表達式改變了值就暫停執行的命令。

watchpoint 分爲硬件實現和軟件實現兩種。前者需要硬件系統的支持;後者的原理就是每步執行後都檢查變量的值是否改變。GDB 在新建數據斷點時會優先嚐試硬件方式,如果失敗再嘗試軟件實現。

5D9ldR

使用數據斷點時,需要注意:

最常見的數據斷點應用場景:「定位堆上的結構體內部成員何時被修改」。由於指針一般爲局部變量,爲了解決斷點失效,一般有兩種方法。

gOuUTh

catchpoint

從字面意思理解,是捕獲斷點,其主要監測信號的產生。例如 c++ 的 throw,或者加載庫的時候,產生斷點行爲。

wYR1L5

command命令後加斷點編號,可以定義斷點觸發後想要執行的操作。在一些高級的自動化調試場景中可能會用到。

命令行

lNi8zD

程序棧

my5ZGP

多進程、多線程

多進程

GDB 在調試多進程程序(程序含fork調用)時,默認只追蹤父進程。可以通過命令設置,實現只追蹤父進程或子進程,或者同時調試父進程和子進程。

n9Gcfq

在調試多進程程序時候,默認情況下,除了當前調試的進程,其他進程都處於掛起狀態,所以,如果需要在調試當前進程的時候,其他進程也能正常執行,那麼通過設置set schedule-multiple on即可。

多線程

多線程開發在日常開發工作中很常見,所以多線程的調試技巧非常有必要掌握。

默認調試多線程時,一旦程序中斷,所有線程都將暫停。如果此時再繼續執行當前線程,其他線程也會同時執行。

x6hYBL

如果只關心當前線程,建議臨時設置 scheduler-lockingon,避免其他線程同時運行,導致命中其他斷點分散注意力。

打印輸出

通常情況下,在調試的過程中,我們需要查看某個變量的值,以分析其是否符合預期,這個時候就需要打印輸出變量值。

TuXSSw

打印字符串

使用x/s命令打印ASCII字符串,如果是寬字符字符串,需要先看寬字符的長度 print sizeof(str)

如果長度爲2,則使用x/hs打印;如果長度爲4,則使用x/ws打印。

gSEkvs

打印數組

3h6Q6j

打印指針

36JVev

打印指定內存地址的值

使用x命令來打印內存的值,格式爲x/nfu addr,以f格式打印從addr開始的n個長度單元爲u的內存值。

AsqDYU

打印局部變量

W4M1kR

打印結構體

wqGTaq

函數跳轉

xbUjKe

其它

圖形化

tui 爲terminal user interface的縮寫,在啓動時候指定-tui參數,或者調試時使用ctrl+x+a組合鍵,可進入或退出圖形化界面。

K3wzLp

彙編

K6CAtS

調試和保存 core 文件

cnJHuJ

啓動方式

使用 gdb 調試,一般有以下幾種啓動方式:

在下面的幾節中,將分別對上述幾種調試方式進行講解,從例子的角度出發,使得大家能夠更好的掌握調試技巧。

調試

可執行文件

單線程

首先,我們先看一段代碼:

#include<stdio.h>

void print(int xx, int *xxptr) {
  printf("In print():\n");
  printf("   xx is %d and is stored at %p.\n", xx, &xx);
  printf("   ptr points to %p which holds %d.\n", xxptr, *xxptr);
}

int main(void) {
  int x = 10;
  int *ptr = &x;
  printf("In main():\n");
  printf("   x is %d and is stored at %p.\n", x, &x);
  printf("   ptr points to %p which holds %d.\n", ptr, *ptr);
  print(x, ptr);
  return 0;
}

這個代碼比較簡單,下面我們開始進入調試:

gdb ./test_main
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-114.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/test_main...done.
(gdb) r
Starting program: /root/./test_main
In main():
   x is 10 and is stored at 0x7fffffffe424.
   ptr points to 0x7fffffffe424 which holds 10.
In print():
   xx is 10 and is stored at 0x7fffffffe40c.
   xxptr points to 0x7fffffffe424 which holds 10.
[Inferior 1 (process 31518) exited normally]
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64

在上述命令中,我們通過 gdb test 命令啓動調試,然後通過執行 r(run 命令的縮寫) 執行程序,直至退出,換句話說,上述命令是一個完整的使用 gdb 運行可執行程序的完整過程 (只使用了 r 命令),接下來,我們將以此爲例子,介紹幾種比較常見的命令。

斷點
(gdb) b 15
Breakpoint 1 at 0x400601: file test_main.cc, line 15.
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000400601 in main() at test_main.cc:15
(gdb) r
Starting program: /root/./test_main
In main():
   x is 10 and is stored at 0x7fffffffe424.
   ptr points to 0x7fffffffe424 which holds 10.

Breakpoint 1, main () at test_main.cc:15
15   print(xx, xxptr);
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64
(gdb)
backtrace
(gdb) backtrace
#0  main () at test_main.cc:15
(gdb)

backtrace 命令是列出當前堆棧中的所有幀。在上面的例子中,棧上只有一幀,編號爲 0,屬於 main 函數。

(gdb) step
print (xx=10, xxptr=0x7fffffffe424) at test_main.cc:4
4   printf("In print():\n");
(gdb)

接着,我們執行了 step 命令,即進入函數內。下面我們繼續通過 backtrace 命令來查看棧幀信息。

(gdb) backtrace
#0  print (xx=10, xxptr=0x7fffffffe424) at test_main.cc:4
#1  0x0000000000400612 in main () at test_main.cc:15
(gdb)

從上面輸出結果,我們能夠看出,有兩個棧幀,第 1 幀屬於 main 函數,第 0 幀屬於 print 函數。

每個棧幀都列出了該函數的參數列表。從上面我們可以看出,main 函數沒有參數,而 print 函數有參數,並且顯示了其參數的值。

有一點我們可能比較迷惑,在第一次執行 backtrace 的時候,main 函數所在的棧幀編號爲 0,而第二次執行的時候,main 函數的棧幀爲 1,而 print 函數的棧幀爲 0,這是因爲_與棧的向下增長_規律一致,我們只需要記住_編號最小幀號就是最近一次調用的函數_。

frame

棧幀用來存儲函數的變量值等信息,默認情況下,GDB 總是位於當前正在執行函數對應棧幀的上下文中。

在前面的例子中,由於當前正在 print() 函數中執行,GDB 位於第 0 幀的上下文中。可以通過 frame 命令來獲取當前正在執行的上下文所在的幀。

(gdb) frame
#0  print (xx=10, xxptr=0x7fffffffe424) at test_main.cc:4
4   printf("In print():\n");
(gdb)

下面,我們嘗試使用 print 命令打印下當前棧幀的值,如下:

(gdb) print xx
$1 = 10
(gdb) print xxptr
$2 = (int *) 0x7fffffffe424
(gdb)

如果我們想看其他棧幀的內容呢?比如 main 函數中 x 和 ptr 的信息呢?假如直接打印這倆值的話,那麼就會得到如下:

(gdb) print x
No symbol "x" in current context.
(gdb) print xxptr
No symbol "ptr" in current context.
(gdb)

在此,我們可以通過_frame num_來切換棧幀,如下:

(gdb) frame 1
#1  0x0000000000400612 in main () at test_main.cc:15
15   print(x, ptr);
(gdb) print x
$3 = 10
(gdb) print ptr
$4 = (int *) 0x7fffffffe424
(gdb)

多線程

爲了方便進行演示,我們創建一個簡單的例子,代碼如下:

#include <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <vector>

int fun_int(int n) {
  std::this_thread::sleep_for(std::chrono::seconds(10));
  std::cout << "in fun_int n = " << n << std::endl;
  
  return 0;
}

int fun_string(const std::string &s) {
  std::this_thread::sleep_for(std::chrono::seconds(10));
  std::cout << "in fun_string s = " << s << std::endl;
  
  return 0;
}

int main() {
  std::vector<int> v;
  v.emplace_back(1);
  v.emplace_back(2);
  v.emplace_back(3);

  std::cout << v.size() << std::endl;

  std::thread t1(fun_int, 1);
  std::thread t2(fun_string, "test");

  std::cout << "after thread create" << std::endl;
  t1.join();
  t2.join();
  return 0;
}

上述代碼比較簡單:

下面是一個完整的調試過程:

(gdb) b 27
Breakpoint 1 at 0x4013d5: file test.cc, line 27.
(gdb) b test.cc:32
Breakpoint 2 at 0x40142d: file test.cc, line 32.
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x00000000004013d5 in main() at test.cc:27
2       breakpoint     keep y   0x000000000040142d in main() at test.cc:32
(gdb) r
Starting program: /root/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".

Breakpoint 1, main () at test.cc:27
(gdb) c
Continuing.
3
[New Thread 0x7ffff6fd2700 (LWP 44996)]
in fun_int n = 1
[New Thread 0x7ffff67d1700 (LWP 44997)]

Breakpoint 2, main () at test.cc:32
32   std::cout << "after thread create" << std::endl;
(gdb) info threads
  Id   Target Id         Frame
  3    Thread 0x7ffff67d1700 (LWP 44997) "test" 0x00007ffff7051fc3 in new_heap () from /lib64/libc.so.6
  2    Thread 0x7ffff6fd2700 (LWP 44996) "test" 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
* 1    Thread 0x7ffff7fe7740 (LWP 44987) "test" main () at test.cc:32
(gdb) thread 2
[Switching to thread 2 (Thread 0x7ffff6fd2700 (LWP 44996))]
#0  0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
(gdb) bt
#0  0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
#1  0x00007ffff7097cc4 in sleep () from /lib64/libc.so.6
#2  0x00007ffff796ceb9 in std::this_thread::__sleep_for(std::chrono::duration<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
#3  0x00000000004018cc in std::this_thread::sleep_for<long, std::ratio<1l, 1l> > (__rtime=...) at /usr/include/c++/4.8.2/thread:281
#4  0x0000000000401307 in fun_int (n=1) at test.cc:9
#5  0x0000000000404696 in std::_Bind_simple<int (*(int))(int)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (this=0x609080)
    at /usr/include/c++/4.8.2/functional:1732
#6  0x000000000040443d in std::_Bind_simple<int (*(int))(int)>::operator()() (this=0x609080) at /usr/include/c++/4.8.2/functional:1720
#7  0x000000000040436e in std::thread::_Impl<std::_Bind_simple<int (*(int))(int)> >::_M_run() (this=0x609068) at /usr/include/c++/4.8.2/thread:115
#8  0x00007ffff796d070 in ?? () from /lib64/libstdc++.so.6
#9  0x00007ffff7bc6dd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007ffff70d0ead in clone () from /lib64/libc.so.6
(gdb) c
Continuing.
after thread create
in fun_int n = 1
[Thread 0x7ffff6fd2700 (LWP 45234) exited]
in fun_string s = test
[Thread 0x7ffff67d1700 (LWP 45235) exited]
[Inferior 1 (process 45230) exited normally]
(gdb) q

在上述調試過程中:

  1. b 27 在第 27 行加上斷點

  2. b test.cc:32 在第 32 行加上斷點 (效果與 b 32 一致)

  3. info b 輸出所有的斷點信息

  4. r 程序開始運行,並在第一個斷點處暫停

  5. c 執行 c 命令,在第二個斷點處暫停,在第一個斷點和第二個斷點之間,創建了兩個線程 t1 和 t2

  6. info threads 輸出所有的線程信息,從輸出上可以看出,總共有 3 個線程,分別爲 main 線程、t1 和 t2

  7. thread 2 切換至線程 2

  8. bt 輸出線程 2 的堆棧信息

  9. c 直至程序結束

  10. q 退出 gdb

多進程

同上面一樣,我們仍然以一個例子進行模擬多進程調試,代碼如下:

#include <stdio.h>
#include <unistd.h>

int main()
{
    pid_t pid = fork();
    if (pid == -1) {
       perror("fork error\n");
       return -1;
    }
  
    if(pid == 0) { // 子進程
        int num = 1;
        while(num == 1){
          sleep(10);
         }
        printf("this is child,pid = %d\n", getpid());
    } else { // 父進程
        printf("this is parent,pid = %d\n", getpid());
      wait(NULL); // 等待子進程退出
    }
    return 0;
}

在上面代碼中,包含兩個進程,一個是父進程 (也就是 main 進程),另外一個是由 fork() 函數創建的子進程。

在默認情況下,在多進程程序中,GDB 只調試 main 進程,也就是說無論程序調用了多少次 fork()函數創建了多少個子進程,GDB 在默認情況下,只調試父進程。爲了支持多進程調試,從 GDB 版本 7.0 開始支持單獨調試 (調試父進程或者子進程) 和同時調試多個進程。

那麼,我們該如何調試子進程呢?我們可以使用如下幾種方式進行子進程調試。

attach

首先,無論是父進程還是子進程,都可以通過 attach 命令啓動 gdb 進行調試。我們都知道,對於每個正在運行的程序,操作系統都會爲其分配一個唯一 ID 號,也就是進程 ID。如果我們知道了進程 ID,就可以使用 attach 命令對其進行調試了。

在上面代碼中,fork() 函數創建的子進程內部,首先會進入 while 循環 sleep,然後在 while 循環之後調用 printf 函數。這樣做的目的有如下:

可能會有疑惑,上面代碼以及進入 while 循環,無論如何是不會執行到下面 printf 函數。其實,這就是 gdb 的厲害之處,可以通過 gdb 命令修改 num 的值,以便其跳出 while 循環

使用如下命令編譯生成可執行文件 test_process

g++ -g test_process.cc -o test_process

現在,我們開始嘗試啓動調試。

gdb -q ./test_process
Reading symbols from /root/test_process...done.
(gdb)

這裏需要說明下,之所以加 - q 選項,是想去掉其他不必要的輸出,q 爲 quite 的縮寫。

(gdb) r
Starting program: /root/./test_process
Detaching after fork from child process 37482.
this is parent,pid = 37478
[Inferior 1 (process 37478) exited normally]
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) attach 37482
//符號類輸出,此處略去
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb)
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8       while(num==10){
(gdb)

在上述命令中,我們執行了 n(next 的縮寫),使其重新對 while 循環的判斷體進行判斷。

(gdb) set num = 1
(gdb) n
12       printf("this is child,pid = %d\n",getpid());
(gdb) c
Continuing.
this is child,pid = 37482
[Inferior 1 (process 37482) exited normally]
(gdb)

爲了退出 while 循環,我們使用 set 命令設置了 num 的值爲 1,這樣條件就會失效退出 while 循環,進而執行下面的 printf() 函數;在最後我們執行了 c(continue 的縮寫) 命令,支持程序退出。

如果程序正在正常運行,出現了死鎖等現象,則可以通過 ps 獲取進程 ID,然後根據 gdb attach pid 進行綁定,進而查看堆棧信息

指定進程

默認情況下,GDB 調試多進程程序時候,只調試父進程。GDB 提供了兩個命令,可以通過 follow-fork-mode 和 detach-on-fork 來指定調試父進程還是子進程。

follow-fork-mode

該命令的使用方式爲:

(gdb) set follow-fork-mode mode

其中,mode 有以下兩個選項:

(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "parent".
(gdb) set follow-fork-mode child
(gdb) r
Starting program: /root/./test_process
[New process 37830]
this is parent,pid = 37826

^C
Program received signal SIGINT, Interrupt.
[Switching to process 37830]
0x00007ffff72b3e10 in __nanosleep_nocancel () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb) n
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8       while(num==10){
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "child".
(gdb)

在上述命令中,我們做了如下操作:

  1. show follow-fork-mode: 通過該命令來查看當前處於什麼模式下,通過輸出可以看出,處於 parent 即父進程模式

  2. set follow-fork-mode child: 指定調試子進程模式

  3. r: 運行程序,直接運行程序,此時會進入子進程,然後執行 while 循環

  4. ctrl + c: 通過該命令,可以使得 GDB 收到 SIGINT 命令,從而暫停執行 while 循環

  5. n(next): 繼續執行,進而進入到 while 循環的條件判斷處

  6. show follow-fork-mode: 再次執行該命令,通過輸出可以看出,當前處於 child 模式下

detach-on-fork

如果一開始指定要調試子進程還是父進程,那麼使用 follow-fork-mode 命令完全可以滿足需求; 但是如果想在調試過程中,想根據實際情況在父進程和子進程之間來回切換調試呢?

GDB 提供了另外一個命令:

(gdb) set detach-on-fork mode

其中 mode 有如下兩個值:

on: 默認值,即表明只調試一個進程,可以是子進程,也可以是父進程

off: 程序中的每個進程都會被記錄,進而我們可以對所有的進程進行調試

如果選擇關閉detach-on-fork模式 (mode 爲 off),那麼 GDB 將保留對所有被 fork 出來的進程控制,即可用調試所有被 fork 出來的進程。可用 使用info forks命令列出所有的可被 GDB 調試的 fork 進程,並可用使用 fork 命令從一個 fork 進程切換到另一個 fork 進程。

coredump

當我們開發或者使用一個程序時候,最怕的莫過於程序莫名其妙崩潰。爲了分析崩潰產生的原因,操作系統的內存內容(包括程序崩潰時候的堆棧等信息)會在程序崩潰的時候 dump 出來(默認情況下,這個文件名爲 core.pid,其中 pid 爲進程 id),這個 dump 操作叫做 coredump(核心轉儲),然後我們可以用調試器調試此文件,以還原程序崩潰時候的場景。

在我們分析如果用 gdb 調試 coredump 文件之前,先需要生成一個 coredump,爲了簡單起見,我們就用如下例子來生成:

#include <stdio.h>

void print(int *v, int size) {
  for (int i = 0; i < size; ++i) {
    printf("elem[%d] = %d\n", i, v[i]);
  }
}

int main() {
  int v[] = {0, 1, 2, 3, 4};
  print(v, 1000);
  return 0;
}

編譯並運行該程序:

g++ -g test_core.cc -o test_core
./test_core

輸出如下:

elem[775] = 1702113070
elem[776] = 1667200115
elem[777] = 6648431
elem[778] = 0
elem[779] = 0
段錯誤(吐核)

如我們預期,程序產生了異常,但是卻沒有生成 coredump 文件,這是因爲在系統默認情況下,coredump 生成是關閉的,所以需要設置對應的選項以打開 coredump 生成。

針對多線程程序產生的 coredump,有時候其堆棧信息並不能完整的去分析原因,這就使得我們得有其他方式。

18 年有一次線上故障,在測試環境一切正常,但是在線上的時候,就會 coredump,根據 gdb 調試 coredump,只能定位到了 libcurl 裏面,但卻定位不出原因,用了大概兩天的時間,發現只有在超時的時候,纔會 coredump,而測試環境因爲配置比較差超時設置的是 20ms,而線上是 5ms,知道 coredump 原因後,採用逐步定位縮小範圍法,逐步縮小代碼範圍,最終定位到是 libcurl 一個 bug 導致。所以,很多時候,定位線上問題需要結合實際情況,採取合適的方法來定位問題。

配置

配置 coredump 生成,有臨時配置 (退出終端後,配置失效) 和永久配置兩種。

臨時

通過ulimit -a可以判斷當前有沒有配置 coredump 生成:

ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0

從上面輸出可以看出 core file size 後面的數爲 0,即不生成 coredump 文件,我們可以通過如下命令進行設置

ulimit -c size

其中 size 爲允許生成的 coredump 大小,這個一般儘量設置大點,以防止生成的 coredump 信息不全,筆者一般設置爲不限。

ulimit -c unlimited

需要說明的是,臨時配置的 coredump 選項,其默認生成路徑爲執行該命令時候的路徑,可以通過修改配置來進行路徑修改。

永久

上面的設置只是使能了 core dump 功能,缺省情況下,內核在 coredump 時所產生的 core 文件放在與該程序相同的目錄中,並且文件名固定爲 core。很顯然,如果有多個程序產生 core 文件,或者同一個程序多次崩潰,就會重複覆蓋同一個 core 文件。

過修改 kernel 的參數,可以指定內核所生成的 coredump 文件的文件名。使用下面命令,可以實現 coredump 永久配置、存放路徑以及生成 coredump 名稱等。

mkdir -p /www/coredump/
chmod 777 /www/coredump/

/etc/profile
ulimit -c unlimited

/etc/security/limits.conf
*          soft     core   unlimited

echo "/www/coredump/core-%e-%p-%h-%t" > /proc/sys/kernel/core_pattern
調試

現在,我們重新執行如下命令,按照預期產生 coredump 文件:

./test_coredump

elem[955] = 1702113070
elem[956] = 1667200115
elem[957] = 6648431
elem[958] = 0
elem[959] = 0
段錯誤(吐核)

然後使用下面的命令進行 coredump 調試:

gdb ./test_core -c /www/coredump/core_test_core_1640765384_38924 -q

輸出如下:

#0  0x0000000000400569 in print (v=0x7fff3293c100, size=1000) at test_core.cc:5
5     printf("elem[%d] = %d\n", i, v[i]);
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb)

可以看出,程序 core 在了第 5 行,此時,我們可以通過where命令來查看堆棧回溯信息。

在 gdb 中輸入 where 命令,可以獲取堆棧調用信息。當進行 coredump 調試時候,這個是最基本且最有用處的命令。where 命令輸出的結果包含程序中 的函數名稱和相關參數值。

通過 where 命令,我們能夠發現程序 core 在了第 5 行,那麼根據分析源碼基本就能定位原因。

需要注意的是,在多線程運行的時候,core 不一定在當前線程,這就需要我們對代碼有一定的瞭解,能夠保證哪塊代碼是安全的,然後通過 thread num 切換線程,然後再通過 bt 或者 where 命令查看堆棧信息,進而定位 coredump 原因。

原理

在前面幾節,我們講了 gdb 的命令,以及這些命令在調試時候的作用,並以例子進行了演示。作爲 C/C++ coder,要知其然,更要知其所以然。所以,藉助本節,我們大概講下 GDB 調試的原理。

gdb 通過系統調用 ptrace 來接管一個進程的執行。ptrace 系統調用提供了一種方法使得父進程可以觀察和控制其它進程的執行,檢查和改變其核心映像以及寄存器。它主要用來實現斷點調試和系統調用跟蹤。

ptrace 系統調用定義如下:

#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data)

調試原理

運行並調試新進程

運行並調試新進程,步驟如下:

attach 運行的進程

可以通過 gdb attach pid 來調試一個運行的進程,gdb 將對指定進程執行 ptrace(PTRACE_ATTACH, pid, 0, 0) 操作。

需要注意的是,當我們 attach 一個進程 id 時候,可能會報如下錯誤:

Attaching to process 28849
ptrace: Operation not permitted.

這是因爲沒有權限進行操作,可以根據啓動該進程用戶下或者 root 下進行操作。

斷點原理

實現原理

當我們通過 b 或者 break 設置斷點時候,就是在指定位置插入斷點指令,當被調試的程序運行到斷點的時候,產生 SIGTRAP 信號。該信號被 gdb 捕獲並 進行斷點命中判斷。

設置原理

在程序中設置斷點,就是先在該位置保存原指令,然後在該位置寫入 int 3。當執行到 int 3 時,發生軟中斷,內核會向子進程發送 SIGTRAP 信號。當然,這個信號會轉發給父進程。然後用保存的指令替換 int 3 並等待操作恢復。

命中判斷

gdb 將所有斷點位置存儲在一個鏈表中。命中判定將被調試程序的當前停止位置與鏈表中的斷點位置進行比較,以查看斷點產生的信號。

條件判斷

在斷點處恢復指令後,增加了一個條件判斷。如果表達式爲真,則觸發斷點。由於需要判斷一次,添加條件斷點後,是否觸發條件斷點,都會影響性能。在 x86 平臺上,部分硬件支持硬件斷點。不是在條件斷點處插入 int 3,而是插入另一條指令。當程序到達這個地址時,不是發出 int 3 信號,而是進行比較。特定寄存器的內容和某個地址,然後決定是否發送 int 3。因此,當你的斷點位置被程序頻繁 “通過” 時,儘量使用硬件斷點,這將有助於提高性能。

單步原理

這個 ptrace 函數本身就支持,可以通過 ptrace(PTRACE_SINGLESTEP, pid,...) 調用來實現單步。

 printf("attaching to PID %d\n", pid);
    if (ptrace(PTRACE_ATTACH, pid, 0, 0) != 0)
    {
        perror("attach failed");
    }
    int waitStat = 0;
    int waitRes = waitpid(pid, &waitStat, WUNTRACED);
    if (waitRes != pid || !WIFSTOPPED(waitStat))
    {
        printf("unexpected waitpid result!\n");
        exit(1);
    }
   
    int64_t numSteps = 0;
    while (true) {
        auto res = ptrace(PTRACE_SINGLESTEP, pid, 0, 0);
    }

上述代碼,首先接收一個 pid,然後對其進行 attach,最後調用 ptrace 進行單步調試。

其它

藉助本文,簡單介紹下筆者工作過程中使用的一些其他命令或者工具。

pstack

此命令可顯示每個進程的棧跟蹤。pstack 命令必須由相應進程的屬主或 root 運行。可以使用 pstack 來確定進程掛起的位置。此命令允許使用的唯一選項是要檢查的進程的 PID。

這個命令在排查進程問題時非常有用,比如我們發現一個服務一直處於 work 狀態(如假死狀態,好似死循環),使用這個命令就能輕鬆定位問題所在;可以在一段時間內,多執行幾次 pstack,若發現代碼棧總是停在同一個位置,那個位置就需要重點關注,很可能就是出問題的地方;

以前面的多線程代碼爲例,其進程 ID 是 4507(在筆者本地),那麼通過

pstack 4507 輸出結果如下:

Thread 3 (Thread 0x7f07aaa69700 (LWP 45708)):
#0  0x00007f07aab2ee2d in nanosleep () from /lib64/libc.so.6
#1  0x00007f07aab2ecc4 in sleep () from /lib64/libc.so.6
#2  0x00007f07ab403eb9 in std::this_thread::__sleep_for(std::chrono::duration<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
#3  0x00000000004018cc in void std::this_thread::sleep_for<long, std::ratio<1l, 1l> >(std::chrono::duration<long, std::ratio<1l, 1l> > const&) ()
#4  0x00000000004012de in fun_int(int) ()
#5  0x0000000000404696 in int std::_Bind_simple<int (*(int))(int)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) ()
#6  0x000000000040443d in std::_Bind_simple<int (*(int))(int)>::operator()() ()
#7  0x000000000040436e in std::thread::_Impl<std::_Bind_simple<int (*(int))(int)> >::_M_run() ()
#8  0x00007f07ab404070 in ?? () from /lib64/libstdc++.so.6
#9  0x00007f07ab65ddd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007f07aab67ead in clone () from /lib64/libc.so.6
Thread 2 (Thread 0x7f07aa268700 (LWP 45709)):
#0  0x00007f07aab2ee2d in nanosleep () from /lib64/libc.so.6
#1  0x00007f07aab2ecc4 in sleep () from /lib64/libc.so.6
#2  0x00007f07ab403eb9 in std::this_thread::__sleep_for(std::chrono::duration<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
#3  0x00000000004018cc in void std::this_thread::sleep_for<long, std::ratio<1l, 1l> >(std::chrono::duration<long, std::ratio<1l, 1l> > const&) ()
#4  0x0000000000401340 in fun_string(std::string const&) ()
#5  0x000000000040459f in int std::_Bind_simple<int (*(char const*))(std::string const&)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) ()
#6  0x000000000040441f in std::_Bind_simple<int (*(char const*))(std::string const&)>::operator()() ()
#7  0x0000000000404350 in std::thread::_Impl<std::_Bind_simple<int (*(char const*))(std::string const&)> >::_M_run() ()
#8  0x00007f07ab404070 in ?? () from /lib64/libstdc++.so.6
#9  0x00007f07ab65ddd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007f07aab67ead in clone () from /lib64/libc.so.6
Thread 1 (Thread 0x7f07aba80740 (LWP 45707)):
#0  0x00007f07ab65ef47 in pthread_join () from /lib64/libpthread.so.0
#1  0x00007f07ab403e37 in std::thread::join() () from /lib64/libstdc++.so.6
#2  0x0000000000401455 in main ()

在上述輸出結果中,將進程內部的詳細信息都輸出在終端,以方便分析問題。

ldd

在我們編譯過程中通常會提示編譯失敗,通過輸出錯誤信息發現是找不到函數定義,再或者編譯成功了,但是運行時候失敗 (往往是因爲依賴了非正常版本的 lib 庫導致),這個時候,我們就可以通過 ldd 來分析該可執行文件依賴了哪些庫以及這些庫所在的路徑。

用來查看程式運行所需的共享庫, 常用來解決程式因缺少某個庫文件而不能運行的一些問題。

仍然查看可執行程序 test_thread 的依賴庫,輸出如下:

ldd -r ./test_thread
 linux-vdso.so.1 =>  (0x00007ffde43bc000)
 libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f8c5e310000)
 libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f8c5e009000)
 libm.so.6 => /lib64/libm.so.6 (0x00007f8c5dd07000)
 libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f8c5daf1000)
 libc.so.6 => /lib64/libc.so.6 (0x00007f8c5d724000)
 /lib64/ld-linux-x86-64.so.2 (0x00007f8c5e52c000)

在上述輸出中:

在有時候,我們通過 ldd 查看依賴庫的時候,會提示找不到庫,如下:

ldd -r test_process
 linux-vdso.so.1 =>  (0x00007ffc71b80000)
 libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fe4badd5000)
 libm.so.6 => /lib64/libm.so.6 (0x00007fe4baad3000)
 libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fe4ba8bd000)
 libc.so.6 => /lib64/libc.so.6 (0x00007fe4ba4f0000)
 /lib64/ld-linux-x86-64.so.2 (0x00007fe4bb0dc000)
  liba.so => not found

比如上面最後一句提示,liba.so 找不到,這個時候,需要我們知道 liba.so 的路徑,比如在 / path/to/liba.so,那麼可以有下面兩種方式:

LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/

這樣在通過 ldd 查看,就能找到對應的 lib 庫,但是這個缺點是臨時的,即退出終端後,再執行 ldd,仍然會提示找不到該庫,所以就有了另外一種方式,即通過修改 / etc/ld.so.conf,在該文件的後面加上需要的路徑,即

include ld.so.conf.d/*.conf
/path/to/

然後通過如下命令,即可永久生效

 /sbin/ldconfig

c++filt

因爲 c++ 支持重載,也就引出了編譯器的name mangling機制,對函數進行重命名。

我們通過 strings 命令查看 test_thread 中的函數信息 (僅輸出 fun 等相關)

strings test_thread | grep fun_
in fun_int n =
in fun_string s =
_GLOBAL__sub_I__Z7fun_inti
_Z10fun_stringRKSs

可以看到_Z10fun_stringRKSs 這個函數,如果想知道這個函數定義的話,可以使用 c++filt 命令,如下:

 c++filt _Z10fun_stringRKSs
fun_string(std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)

通過上述輸出,我們可以將編譯器生成的函數名還原到我們代碼中的函數名即 fun_string。

結語

GDB 是一個在 Linux 上進行開發的一個必不可少的調試工具,使用場景依賴於具體的需求或者遇到的具體問題。在我們的日常開發工作中,熟練使用 GDB 加以輔助,能夠使得開發過程事半功倍。

本文從一些簡單的命令出發,通過舉例調試可執行程序 (單線程、多線程以及多進程場景)、coredump 文件等各個場景,使得大家能夠更加直觀的瞭解 GDB 的使用。GDB 功能非常強大,筆者工作中使用的都是非常基本的一些功能,如果想深入理解 GDB,則需要去官網進行閱讀了解。

本文從構思到完成,大概用了三週時間,寫作過程是痛苦的 (需要整理資料以及構建各種場景,以及將各種現場還原),同時又是收穫滿滿的。通過本文,進一步加深了對 GDB 的底層原理理解。

參考

https://www.codetd.com/en/article/13107993

https://www.codetd.com/en/article/13107993https://users.ece.utexas.edu/~adnan/gdb-refcard.pdf 

https://www.cloudsavvyit.com/10921/debugging-with-gdb-getting-started/ 

https://blog.birost.com/a?ID=00650-b03e2257-94bf-41f3-b0fc-d352d5b02431 

https://www.cnblogs.com/xsln/p/ptrace.html

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