你好,我是雨樂!
在上篇文章中,我們分析了線上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-locking
為 on
,避免其他線程同時運行,導致命中其他斷點分散注意力。
打印輸出
通常情況下,在調試的過程中,我們需要查看某個變量的值,以分析其是否符合預期,這個時候就需要打印輸出變量值。
命令 | 作用 |
---|---|
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
表示1
個byte
,h
表示2
個byte
(half word
),w
表示4
個byte
,g
表示8
個byte
(giant 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
在上述調試過程中:
-
b 27 在第27行加上斷點
-
b test.cc:32 在第32行加上斷點(效果與b 32一致)
-
info b 輸出所有的斷點信息
-
r 程序開始運行,並在第一個斷點處暫停
-
c 執行c命令,在第二個斷點處暫停,在第一個斷點和第二個斷點之間,創建了兩個線程t1和t2
-
info threads 輸出所有的線程信息,從輸出上可以看出,總共有3個線程,分別為main線程、t1和t2
-
thread 2 切換至線程2
-
bt 輸出線程2的堆棧信息
-
c 直至程序結束
-
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)
在上述命令中,我們做了如下操作:
- show follow-fork-mode:通過該命令來查看當前處於什么模式下,通過輸出可以看出,處於parent即父進程模式
- set follow-fork-mode child:指定調試子進程模式
- r:運行程序,直接運行程序,此時會進入子進程,然后執行while循環
- ctrl + c:通過該命令,可以使得GDB收到SIGINT命令,從而暫停執行while循環
- n(next):繼續執行,進而進入到while循環的條件判斷處
- 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:繼續運行之前停止的子進程。可同時向子進程交付指定的信號
- PTRACE_TRACEME:表示此進程將被父進程跟蹤,任何信號(除了
調試原理
運行並調試新進程
運行並調試新進程,步驟如下:
- 運行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的底層原理理解。
參考
(https://www.codetd.com/en/article/13107993)
https://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
作者:高性能架構探索
掃描下方二維碼關注公眾號【高性能架構探索】,回復【pdf】免費獲取計算機必備經典書籍