程式碼除錯-入門、實踐到原理

高效能架構探索發表於2022-01-19

在上篇文章中,我們分析了線上coredump產生的原因,其中用到了coredump分析工具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
  • watchpoint
  • catchpoint

breakpoint

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

命令 作用
break [file]:function 在檔案file的function函式入口設定斷點
break [file]:line 在檔案file的第line行設定斷點
info breakpoints 檢視斷點列表
break [+-]offset 在當前位置偏移量為[+-]offset處設定斷點
break *addr 在地址addr處設定斷點
break ... if expr 設定條件斷點,僅僅在條件滿足時
ignore n count 接下來對於編號為n的斷點忽略count次
clear 刪除所有斷點
clear function 刪除所有位於function內的斷點
delete n 刪除指定編號的斷點
enable n 啟用指定編號的斷點
disable n 禁用指定編號的斷點
save breakpoints file 儲存斷點資訊到指定檔案
source file 匯入檔案中儲存的斷點資訊
break 在下一個指令處設定斷點
clear [file:]line 刪除第line行的斷點

watchpoint

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

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

命令 作用
watch variable 設定變數資料斷點
watch var1 + var2 設定表示式資料斷點
rwatch variable 設定讀斷點,僅支援硬體實現
awatch variable 設定讀寫斷點,僅支援硬體實現
info watchpoints 檢視資料斷點列表
set can-use-hw-watchpoints 0 強制基於軟體方式實現

使用資料斷點時,需要注意:

  • 當監控變數為區域性變數時,一旦區域性變數失效,資料斷點也會失效
  • 如果監控的是指標變數p,則watch *p監控的是p所指記憶體資料的變化情況,而watch p監控的是p指標本身有沒有改變指向

最常見的資料斷點應用場景:定位堆上的結構體內部成員何時被修改。由於指標一般為區域性變數,為了解決斷點失效,一般有兩種方法。

命令 作用
print &variable 檢視變數的記憶體地址
watch *(type *)address 通過記憶體地址間接設定斷點
watch -l variable 指定location引數
watch variable thread 1 僅編號為1的執行緒修改變數var值時會中斷

catchpoint

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

命令 含義
catch fork 程式呼叫fork時中斷
tcatch fork 設定的斷點只觸發一次,之後被自動刪除
catch syscall ptrace 為ptrace系統呼叫設定斷點

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

命令列

命令 作用
run arglist 以arglist為引數列表執行程式
set args arglist 指定啟動命令列引數
set args 指定空的引數列表
show args 列印命令列列表

程式棧

命令 作用
backtrace [n] 列印棧幀
frame [n] 選擇第n個棧幀,如果不存在,則列印當前棧幀
up n 選擇當前棧幀編號+n的棧幀
down n 選擇當前棧幀編號-n的棧幀
info frame [addr] 描述當前選擇的棧幀
info args 當前棧幀的引數列表
info locals 當前棧幀的區域性變數

多程式、多執行緒

多程式

GDB在除錯多程式程式(程式含fork呼叫)時,預設只追蹤父程式。可以通過命令設定,實現只追蹤父程式或子程式,或者同時除錯父程式和子程式。

命令 作用
info inferiors 檢視程式列表
attach pid 繫結程式id
inferior num 切換到指定程式上進行除錯
print $_exitcode 顯示程式退出時的返回值
set follow-fork-mode child 追蹤子程式
set follow-fork-mode parent 追蹤父程式
set detach-on-fork on fork呼叫時只追蹤其中一個程式
set detach-on-fork off fork呼叫時會同時追蹤父子程式

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

多執行緒

多執行緒開發在日常開發工作中很常見,所以多執行緒的除錯技巧非常有必要掌握。

預設除錯多執行緒時,一旦程式中斷,所有執行緒都將暫停。如果此時再繼續執行當前執行緒,其他執行緒也會同時執行。

命令 作用
info threads 檢視執行緒列表
print $_thread 顯示當前正在除錯的執行緒編號
set scheduler-locking on 除錯一個執行緒時,其他執行緒暫停執行
set scheduler-locking off 除錯一個執行緒時,其他執行緒同步執行
set scheduler-locking step 僅用step除錯執行緒時其他執行緒不執行,用其他命令如next除錯時仍執行

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

列印輸出

通常情況下,在除錯的過程中,我們需要檢視某個變數的值,以分析其是否符合預期,這個時候就需要列印輸出變數值。

命令 作用
whatis variable 檢視變數的型別
ptype variable 檢視變數詳細的型別資訊
info variables var 檢視定義該變數的檔案,不支援區域性變數
列印字串

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

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

命令 作用
x/s str 列印字串
set print elements 0 列印不限制字串長度/或不限制陣列長度
call printf("%s\n",xxx) 這時列印出的字串不會含有多餘的轉義符
printf "%s\n",xxx 同上
列印陣列
命令 作用
print *array@10 列印從陣列開頭連續10個元素的值
print array[60]@10 列印array陣列下標從60開始的10個元素,即第60~69個元素
set print array-indexes on 列印陣列元素時,同時列印陣列的下標
列印指標
命令 作用
print ptr 檢視該指標指向的型別及指標地址
print *(struct xxx *)ptr 檢視指向的結構體的內容
列印指定記憶體地址的值

使用x命令來列印記憶體的值,格式為x/nfu addr,以f格式列印從addr開始的n個長度單元為u的記憶體值。

  • n:輸出單元的個數
  • f:輸出格式,如x表示以16進位制輸出,o表示以8進位制輸出,預設為x
  • u:一個單元的長度,b表示1byteh表示2bytehalf word),w表示4byteg表示8bytegiant word
命令 作用
x/8xb array 以16進位制列印陣列array的前8個byte的值
x/8xw array 以16進位制列印陣列array的前16個word的值
列印區域性變數
命令 作用
info locals 列印當前函式區域性變數的值
backtrace full 列印當前棧幀各個函式的區域性變數值,命令可縮寫為bt
bt full n 從內到外顯示n個棧幀及其區域性變數
bt full -n 從外向內顯示n個棧幀及其區域性變數
列印結構體
命令 作用
set print pretty on 每行只顯示結構體的一名成員
set print null-stop 不顯示'\000'這種

函式跳轉

命令 作用
set step-mode on 不跳過不含除錯資訊的函式,可以顯示和除錯彙編程式碼
finish 執行完當前函式並列印返回值,然後觸發中斷
return 0 不再執行後面的指令,直接返回,可以指定返回值
call printf("%s\n", str) 呼叫printf函式,列印字串(可以使用call或者print呼叫函式)
print func() 呼叫func函式(可以使用call或者print呼叫函式)
set var variable=xxx 設定變數variable的值為xxx
set {type}address = xxx 給儲存地址為address,型別為type的變數賦值
info frame 顯示函式堆疊的資訊(堆疊幀地址、指令暫存器的值等)

其它

圖形化

tui為terminal user interface的縮寫,在啟動時候指定-tui引數,或者除錯時使用ctrl+x+a組合鍵,可進入或退出圖形化介面。

命令 含義
layout src 顯示原始碼視窗
layout asm 顯示彙編視窗
layout split 顯示原始碼 + 彙編視窗
layout regs 顯示暫存器 + 原始碼或彙編視窗
winheight src +5 原始碼視窗高度增加5行
winheight asm -5 彙編視窗高度減小5行
winheight cmd +5 控制檯視窗高度增加5行
winheight regs -5 暫存器視窗高度減小5行

彙編

命令 含義
disassemble function 檢視函式的彙編程式碼
disassemble /mr function 同時比較函式原始碼和彙編程式碼

除錯和儲存core檔案

命令 含義
file exec_file *# * 載入可執行檔案的符號表資訊
core core_file 載入core-dump檔案
gcore core_file 生成core-dump檔案,記錄當前程式的狀態

啟動方式

使用gdb除錯,一般有以下幾種啟動方式:

  • gdb filename: 除錯可執行程式
  • gdb attach pid: 通過”繫結“程式ID來除錯正在執行的程式
  • gdb filename -c coredump_file: 除錯可執行檔案

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

除錯

可執行檔案

單執行緒

首先,我們先看一段程式碼:

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

上述程式碼比較簡單:

  • 函式fun_int的功能是休眠10s,然後列印其引數
  • 函式fun_string功能是休眠10s,然後列印其引數
  • main函式中,建立兩個執行緒,分別執行上述兩個函式

下面是一個完整的除錯過程:

(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函式。這樣做的目的有如下:

  • 幫助attach捕獲要除錯的程式id
  • 在使用gdb進行除錯的時候,真正的程式碼(即print函式)沒有被執行,這樣就可以從頭開始對子程式進行除錯

可能會有疑惑,上面程式碼以及進入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有以下兩個選項:

  • parent:父程式,mode的預設選項
  • child:子程式,其目的是告訴 gdb 在目標應用呼叫fork之後接著除錯子程式而不是父程式,因為在Linux系統中fork()系統呼叫成功會返回兩次,一次在父程式,一次在子程式
(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程式。

  • info forks: 列印DGB控制下的所有被fork出來的程式列表。該列表包括fork id、程式id和當前程式的位置
  • fork fork-id: 引數fork-id是GDB分配的內部fork編號,該編號可用通過上面的命令info forks獲取

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)
  • pid_t pid:指示 ptrace 要跟蹤的程式
  • void *addr:指示要監控的記憶體地址
  • enum __ptrace_request request:決定了系統呼叫的功能,幾個主要的選項:
    • PTRACE_TRACEME:表示此程式將被父程式跟蹤,任何訊號(除了 SIGKILL)都會暫停子程式,接著阻塞於 wait() 等待的父程式被喚醒。子程式內部對 exec() 的呼叫將發出 SIGTRAP 訊號,這可以讓父程式在子程式新程式開始執行之前就完全控制它
    • PTRACE_ATTACH:attach 到一個指定的程式,使其成為當前程式跟蹤的子程式,而子程式的行為等同於它進行了一次 PTRACE_TRACEME 操作。但需要注意的是,雖然當前程式成為被跟蹤程式的父程式,但是子程式使用 getppid() 的到的仍將是其原始父程式的pid
    • PTRACE_CONT:繼續執行之前停止的子程式。可同時向子程式交付指定的訊號

除錯原理

執行並除錯新程式

執行並除錯新程式,步驟如下:

  • 執行gdb exe
  • 輸入run命令,gdb執行以下操作:
    • 通過fork()系統呼叫建立一個新程式
    • 在新建立的子程式中執行ptrace(PTRACE_TRACEME, 0, 0, 0)操作
    • 在子程式中通過execv()系統呼叫載入指定的可執行檔案

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++filter

因為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的底層原理理解。

作者:高效能架構探索
本文首發於公眾號【高效能架構探索】
個人技術部落格:高效能架構探索

相關文章