如何調試大型 Linux C/C++ 項目?

一、調試 Redis

將 Redis 源碼從官網下載下來以後,使用喜歡的代碼閱讀器進行管理(例如 VSCode、CLion、XCode 等)。我這裏使用的是 Visual Studio,如下圖所示:

圖片

在大致瞭解了 Redis 有哪些代碼模塊以後,我們把代碼拷貝到 Linux 平臺,然後編譯並使用 GDB 調試器跑起來。如下圖所示:

圖片

然後按 CTRL+C 將 GDB 中斷下來,輸入 info threads 查看當前程序的所有線程:

圖片

接着挨個使用 thread + 線程編號 和 bt 命令去查看每個線程的上下文調用堆棧:

圖片

對照每個線程的上下文堆棧,搞清楚其邏輯,並結合主線程,看看每個線程是在何時啓動的,端口在何時啓動偵聽的,等等。做完這一步,關於 redis-server 的框架也基本清楚了。

接着我們可以選擇一個自己感興趣的命令,搞清楚 redis-cli 與 redis-server 命令的交互流程。

最後,如果對 redis-server 源碼中各種數據結構和細節感興趣,我們可以進一步深入到具體的代碼細節。

當然,不熟悉 GDB 的讀者看筆者這段操作流程比較困難,這是正常的,說明如果想通過調試去研究 Redis 這一款開源軟件,你需要去補充一點 GDB 調試的知識。

二、調試 Nginx

Nginx 的功能點比較多,涉及到的新概念和設計思路對於新手也不是特別友好,我建議在瞭解一些了 Nginx 的一些基礎知識之後,通過調試來學習 Nginx 源碼。

2.1 下載 Nginx 源碼

從 Nginx 官網下載最新的 Nginx 源碼,然後編譯安裝(回答此問題時,nginx 最新穩定版本是 1.18.0)。

 ## 下載nginx源碼
 [root@iZbp14iz399acush5e8ok7Z zhangyl]# wget http://nginx.org/download/nginx-1.18.0.tar.gz
 --2020-07-05 17:22:10--  http://nginx.org/download/nginx-1.18.0.tar.gz
 Resolving nginx.org (nginx.org)... 95.211.80.227, 62.210.92.35, 2001:1af8:4060:a004:21::e3
 Connecting to nginx.org (nginx.org)|95.211.80.227|:80... connected.
 HTTP request sent, awaiting response... 200 OK
 Length: 1039530 (1015K) [application/octet-stream]
 Saving to: ‘nginx-1.18.0.tar.gz’
 
 nginx-1.18.0.tar.gz                            100%[===================================================================================================>]   1015K   666KB/s    in 1.5s    
 
 2020-07-05 17:22:13 (666 KB/s) - ‘nginx-1.18.0.tar.gz’ saved [1039530/1039530]
 
 ## 解壓nginx
 [root@iZbp14iz399acush5e8ok7Z zhangyl]# tar zxvf nginx-1.18.0.tar.gz
 
 ## 編譯nginx
 [root@iZbp14iz399acush5e8ok7Z zhangyl]# cd nginx-1.18.0
 [root@iZbp14iz399acush5e8ok7Z nginx-1.18.0]# ./configure --prefix=/usr/local/nginx
 [root@iZbp14iz399acush5e8ok7Z nginx-1.18.0]make CFLAGS="-g -O0"
 
 ## 安裝,這樣nginx就被安裝到/usr/local/nginx/目錄下
 [root@iZbp14iz399acush5e8ok7Z nginx-1.18.0]make install

注意:使用 make 命令編譯時我們爲了讓生成的 Nginx 帶有調試符號信息同時關閉編譯器優化,我們設置了"-g -O0"選項。

2.2 調試 Nginx

可以使用如下兩種方式對 Nginx 進行調試:

方法一

啓動 Nginx:

 [root@iZbp14iz399acush5e8ok7Z sbin]# cd /usr/local/nginx/sbin
 [root@iZbp14iz399acush5e8ok7Z sbin]# ./nginx -c /usr/local/nginx/conf/nginx.conf
 [root@iZbp14iz399acush5e8ok7Z sbin]# lsof -i -Pn | grep nginx
 nginx      5246            root    9u  IPv4 22252908      0t0  TCP *:80 (LISTEN)
 nginx      5247          nobody    9u  IPv4 22252908      0t0  TCP *:80 (LISTEN)

如上所示,Nginx 默認會開啓兩個進程,在我的機器上以 root 用戶運行的 Nginx 進程是父進程,進程號 5246,以 nobody 用戶運行的進程是子進程,進程號 5247。我們在當前窗口使用gdb attach 5246命令將 gdb 附加到 Nginx 主進程上去。

 [root@iZbp14iz399acush5e8ok7Z sbin]# gdb attach 5246
 ...省略部分輸出信息...
 0x00007fd42a103c5d in sigsuspend () from /lib64/libc.so.6
 Missing separate debuginfos, use: yum debuginfo-install glibc-2.28-72.el8_1.1.x86_64 libxcrypt-4.1.1-4.el8.x86_64 pcre-8.42-4.el8.x86_64 sssd-client-2.2.0-19.el8.x86_64 zlib-1.2.11-10.el8.x86_64
 (gdb)

此時我們就可以調試 Nginx 父進程了,例如使用 bt 命令查看當前調用堆棧:

 (gdb) bt
 #0  0x00007fd42a103c5d in sigsuspend () from /lib64/libc.so.6
 #1  0x000000000044ae32 in ngx_master_process_cycle (cycle=0x1703720) at src/os/unix/ngx_process_cycle.c:164
 #2  0x000000000040bc05 in main (argc=3, argv=0x7ffe49109d68) at src/core/nginx.c:382
 (gdb) f 1
 #1  0x000000000044ae32 in ngx_master_process_cycle (cycle=0x1703720) at src/os/unix/ngx_process_cycle.c:164
 164             sigsuspend(&set);
 (gdb) l
 159                 }
 160             }
 161
 162             ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "sigsuspend");
 163
 164             sigsuspend(&set);
 165
 166             ngx_time_update();
 167
 168             ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,
 (gdb)

使用 f 1 命令切換到當前調用堆棧#1,我們可以發現 Nginx 父進程的主線程掛起在src/core/nginx.c:382處。

此時你可以使用 c 命令讓程序繼續運行起來,也可以添加斷點或者做一些其他的調試操作。

再開一個 shell 窗口,使用gdb attach 5247 將 gdb 附加到 Nginx 子進程:

 [root@iZbp14iz399acush5e8ok7Z sbin]# gdb attach 5247
 ...部署輸出省略...
 0x00007fd42a1c842b in epoll_wait () from /lib64/libc.so.6
 Missing separate debuginfos, use: yum debuginfo-install glibc-2.28-72.el8_1.1.x86_64 libblkid-2.32.1-17.el8.x86_64 libcap-2.26-1.el8.x86_64 libgcc-8.3.1-4.5.el8.x86_64 libmount-2.32.1-17.el8.x86_64 libselinux-2.9-2.1.el8.x86_64 libuuid-2.32.1-17.el8.x86_64 libxcrypt-4.1.1-4.el8.x86_64 pcre-8.42-4.el8.x86_64 pcre2-10.32-1.el8.x86_64 sssd-client-2.2.0-19.el8.x86_64 systemd-libs-239-18.el8_1.2.x86_64 zlib-1.2.11-10.el8.x86_64
 (gdb)

我們使用 bt 命令查看一下子進程的主線程的當前調用堆棧:

 (gdb) bt
 #0  0x00007fd42a1c842b in epoll_wait () from /lib64/libc.so.6
 #1  0x000000000044e546 in ngx_epoll_process_events (cycle=0x1703720, timer=18446744073709551615, flags=1) at src/event/modules/ngx_epoll_module.c:800
 #2  0x000000000043f317 in ngx_process_events_and_timers (cycle=0x1703720) at src/event/ngx_event.c:247
 #3  0x000000000044c38f in ngx_worker_process_cycle (cycle=0x1703720, data=0x0) at src/os/unix/ngx_process_cycle.c:750
 #4  0x000000000044926f in ngx_spawn_process (cycle=0x1703720, proc=0x44c2e1 <ngx_worker_process_cycle>, data=0x0, name=0x4cfd70 "worker process", respawn=-3)
     at src/os/unix/ngx_process.c:199
 #5  0x000000000044b5a4 in ngx_start_worker_processes (cycle=0x1703720, n=1, type=-3) at src/os/unix/ngx_process_cycle.c:359
 #6  0x000000000044acf4 in ngx_master_process_cycle (cycle=0x1703720) at src/os/unix/ngx_process_cycle.c:131
 #7  0x000000000040bc05 in main (argc=3, argv=0x7ffe49109d68) at src/core/nginx.c:382
 (gdb) f 1
 #1  0x000000000044e546 in ngx_epoll_process_events (cycle=0x1703720, timer=18446744073709551615, flags=1) at src/event/modules/ngx_epoll_module.c:800
 800         events = epoll_wait(ep, event_list, (int) nevents, timer);
 (gdb)

可以發現子進程掛起在src/event/modules/ngx_epoll_module.c:800的 epoll_wait 函數處。我們在 epoll_wait 函數返回後(src/event/modules/ngx_epoll_module.c:804)加一個斷點,然後使用 c 命令讓 Nginx 子進程繼續運行。

 800         events = epoll_wait(ep, event_list, (int) nevents, timer);
 (gdb) list
 795         /* NGX_TIMER_INFINITE == INFTIM */
 796
 797         ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,
 798                        "epoll timer: %M", timer);
 799
 800         events = epoll_wait(ep, event_list, (int) nevents, timer);
 801
 802         err = (events == -1) ? ngx_errno : 0;
 803
 804         if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
 (gdb) b 804
 Breakpoint 1 at 0x44e560: file src/event/modules/ngx_epoll_module.c, line 804.
 (gdb) c
 Continuing.

接着我們在瀏覽器裏面訪問 Nginx 的站點,我這裏的 IP 地址是我的雲主機地址,讀者實際調試時改成自己的 Nginx 服務器所在的地址,如果是本機就是 127.0.0.1,由於默認端口是 80,所以不用指定端口號。

 http://你的IP地址:80

等價於

 http://你的IP地址

此時我們回到 Nginx 子進程的調試界面發現斷點被觸發:

 Breakpoint 1, ngx_epoll_process_events (cycle=0x1703720, timer=18446744073709551615, flags=1) at src/event/modules/ngx_epoll_module.c:804
 804         if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
 (gdb) 

使用 bt 命令可以獲得此時的調用堆棧:

 (gdb) bt
 #0  ngx_epoll_process_events (cycle=0x1703720, timer=18446744073709551615, flags=1) at src/event/modules/ngx_epoll_module.c:804
 #1  0x000000000043f317 in ngx_process_events_and_timers (cycle=0x1703720) at src/event/ngx_event.c:247
 #2  0x000000000044c38f in ngx_worker_process_cycle (cycle=0x1703720, data=0x0) at src/os/unix/ngx_process_cycle.c:750
 #3  0x000000000044926f in ngx_spawn_process (cycle=0x1703720, proc=0x44c2e1 <ngx_worker_process_cycle>, data=0x0, name=0x4cfd70 "worker process", respawn=-3)
     at src/os/unix/ngx_process.c:199
 #4  0x000000000044b5a4 in ngx_start_worker_processes (cycle=0x1703720, n=1, type=-3) at src/os/unix/ngx_process_cycle.c:359
 #5  0x000000000044acf4 in ngx_master_process_cycle (cycle=0x1703720) at src/os/unix/ngx_process_cycle.c:131
 #6  0x000000000040bc05 in main (argc=3, argv=0x7ffe49109d68) at src/core/nginx.c:382
 (gdb) 

使用 info threads 命令可以查看子進程所有線程信息,我們發現 Nginx 子進程只有一個主線程:

 (gdb) info threads
   Id   Target Id                                Frame 
 * 1    Thread 0x7fd42b17c740 (LWP 5247) "nginx" ngx_epoll_process_events (cycle=0x1703720, timer=18446744073709551615, flags=1) at src/event/modules/ngx_epoll_module.c:804
 (gdb) 

Nginx 父進程不處理客戶端請求,處理客戶端請求的邏輯在子進程中,當單個子進程客戶端請求數達到一定數量時,父進程會重新 fork 一個新的子進程來處理新的客戶端請求,也就是說子進程數量可以有多個,你可以開多個 shell 窗口,使用 gdb attach 到各個子進程上去調試。

然而,方法一存在一個缺點,即程序已經啓動了,我們只能使用 gdb 觀察程序在這之後的行爲,如果我們想調試程序從啓動到運行起來之間的執行流程,方法一可能不太適用。有些讀者可能會說:用 gdb 附加到進程後,加好斷點,然後使用 run 命令重啓進程,這樣就可以調試程序從啓動到運行起來之間的執行流程了。問題是這種方法不是通用的,因爲對於多進程服務模型,有些父子進程有一定的依賴關係,是不方便在運行過程中重啓的。這個時候方法二就比較合適了。

方法二

gdb 調試器提供一個選項叫 follow-fork,通過 set follow-fork mode 來設置:當一個進程 fork 出新的子進程時,gdb 是繼續調試父進程(取值是 parent)還是子進程(取值是 child),默認是父進程(取值是 parent)。

 # fork之後gdb attach到子進程
 set follow-fork child
 # fork之後gdb attach到父進程,這是默認值
 set follow-fork parent

我們可以使用 show follow-fork mode 查看當前值:

 (gdb) show follow-fork mode
 Debugger response to a program call of fork or vfork is "child".

我們還是以調試 Nginx 爲例,先進入 Nginx 可執行文件所在的目錄,將方法一中的 Nginx 服務停下來:

 [root@iZbp14iz399acush5e8ok7Z sbin]# cd /usr/local/nginx/sbin/
 [root@iZbp14iz399acush5e8ok7Z sbin]# ./nginx -s stop

Nginx 源碼中存在這樣的邏輯,這個邏輯會在程序 main 函數處被調用:

 //src/os/unix/ngx_daemon.c:13行
 ngx_int_t
 ngx_daemon(ngx_log_t *log)
 {
     int  fd;
 
     switch (fork()) {
     case -1:
         ngx_log_error(NGX_LOG_EMERG, log, ngx_errno, "fork() failed");
         return NGX_ERROR;
     
     //fork出來的子進程走這個case
     case 0:
         break;
     
     //父進程中fork返回值是子進程的PID,大於0,因此走這個case
     //因此主進程會退出
     default:
         exit(0);
     }
 
     //...省略部分代碼...
 }

如上述代碼中註釋所示,爲了不讓主進程退出,我們在 Nginx 的配置文件中增加一行:

 daemon off;

這樣 Nginx 就不會調用 ngx_daemon 函數了。接下來,我們執行gdb nginx,然後通過設置參數將配置文件 nginx.conf 傳給待調試的 Nginx 進程:

 Quit anyway? (y or n) y
 [root@iZbp14iz399acush5e8ok7Z sbin]# gdb nginx 
 ...省略部分輸出...
 Reading symbols from nginx...done.
 (gdb) set args -c /usr/local/nginx/conf/nginx.conf
 (gdb) 

接着輸入 run 命令嘗試運行 Nginx:

 (gdb) run
 Starting program: /usr/local/nginx/sbin/nginx -c /usr/local/nginx/conf/nginx.conf
 [Thread debugging using libthread_db enabled]
 ...省略部分輸出信息...
 [Detaching after fork from child process 7509]

如前文所述,gdb 遇到 fork 指令時默認會 attach 到父進程去,因此上述輸出中有一行提示”Detaching after fork from child process 7509“,我們按 Ctrl + C 將程序中斷下來,然後輸入 bt 命令查看當前調用堆棧,輸出的堆棧信息和我們在方法一中看到的父進程的調用堆棧一樣,說明 gdb在程序 fork 之後確實 attach 了父進程:

 ^C
 Program received signal SIGINT, Interrupt.
 0x00007ffff6f73c5d in sigsuspend () from /lib64/libc.so.6
 (gdb) bt
 #0  0x00007ffff6f73c5d in sigsuspend () from /lib64/libc.so.6
 #1  0x000000000044ae32 in ngx_master_process_cycle (cycle=0x71f720) at src/os/unix/ngx_process_cycle.c:164
 #2  0x000000000040bc05 in main (argc=3, argv=0x7fffffffe4e8) at src/core/nginx.c:382
 (gdb)

如果想讓 gdb 在 fork 之後去 attach 子進程,我們可以在程序運行之前設置 set follow-fork child,然後使用 run 命令重新運行程序。

 (gdb) set follow-fork child 
 (gdb) run
 The program being debugged has been started already.
 Start it from the beginning? (y or n) y
 Starting program: /usr/local/nginx/sbin/nginx -c /usr/local/nginx/conf/nginx.conf
 [Thread debugging using libthread_db enabled]
 Using host libthread_db library "/lib64/libthread_db.so.1".
 [Attaching after Thread 0x7ffff7fe7740 (LWP 7664) fork to child process 7667]
 [New inferior 2 (process 7667)]
 [Detaching after fork from parent process 7664]
 [Inferior 1 (process 7664) detached]
 [Thread debugging using libthread_db enabled]
 Using host libthread_db library "/lib64/libthread_db.so.1".
 ^C
 Thread 2.1 "nginx" received signal SIGINT, Interrupt.
 [Switching to Thread 0x7ffff7fe7740 (LWP 7667)]
 0x00007ffff703842b in epoll_wait () from /lib64/libc.so.6
 (gdb) bt
 #0  0x00007ffff703842b in epoll_wait () from /lib64/libc.so.6
 #1  0x000000000044e546 in ngx_epoll_process_events (cycle=0x71f720, timer=18446744073709551615, flags=1) at src/event/modules/ngx_epoll_module.c:800
 #2  0x000000000043f317 in ngx_process_events_and_timers (cycle=0x71f720) at src/event/ngx_event.c:247
 #3  0x000000000044c38f in ngx_worker_process_cycle (cycle=0x71f720, data=0x0) at src/os/unix/ngx_process_cycle.c:750
 #4  0x000000000044926f in ngx_spawn_process (cycle=0x71f720, proc=0x44c2e1 <ngx_worker_process_cycle>, data=0x0, name=0x4cfd70 "worker process", respawn=-3)
     at src/os/unix/ngx_process.c:199
 #5  0x000000000044b5a4 in ngx_start_worker_processes (cycle=0x71f720, n=1, type=-3) at src/os/unix/ngx_process_cycle.c:359
 #6  0x000000000044acf4 in ngx_master_process_cycle (cycle=0x71f720) at src/os/unix/ngx_process_cycle.c:131
 #7  0x000000000040bc05 in main (argc=3, argv=0x7fffffffe4e8) at src/core/nginx.c:382
 (gdb) 

我們接着按 Ctrl + C 將程序中斷下來,然後使用 bt 命令查看當前線程調用堆棧,結果顯示確實是我們在方法一中子進程的主線程所在的調用堆棧,這說明 gdb 確實 attach 到子進程了。

我們可以利用方法二調試程序 fork 之前和之後的任何邏輯,是一種較爲通用的多進程調試方法,建議讀者掌握。

總結起來,我們可以綜合使用方法一和方法二添加各種斷點調試 Nginx 的功能,慢慢就能熟悉 Nginx 的各個內部邏輯了。

三、掌握 GDB 調試是調試大型 Linux C++ 項目的基礎

不知道你看出來沒有,如果你想搞清楚一個大型 Linux C/C++ 項目, 一定要熟練使用 gdb 調試。GDB 並不難學,你實際操作一下相信幾分鐘就學會了,常見的 GDB 命令如下圖所示:

圖片

圖片

GDB 網絡上有很多教程但都不繫統,這裏給大家推薦一本國外的圖書《 Debugging with GDB ——the GNU Source Level Debugger 》,寫的非常通俗易懂,網絡上有人分享出來:

鏈接: https://pan.baidu.com/s/1uq3Kzsty3Z26K8QPhAeXDg 
密碼: kua1

至於閱讀代碼的話,你可以根據你的個人喜歡選擇喜歡自己的 IDE,例如 VSCode、Visual Studio、SourceInsight 等等。

總而言之,熟練掌握 GDB 調試等於擁有了學習優秀 C/C++ 開源項目源碼的鑰匙,只要可以利用 gdb 調試,再複雜的項目,在不斷調試和分析過程中總會有搞明白的一天。

掌握 GDB 本身並不難,對於大型項目,想要熟練調試,還需要掌握很多調試技巧和背景知識,我在我的圖書《C++ 服務器開發精髓》第二章以 Redis 爲例,詳細地介紹瞭如何利用 GDB 調試大型項目的方方面面。如果你認真學習完這一章,你也可以自己獨立調試大型 Linux C++ 項目。

如果你不喜歡 GDB 黑洞洞的界面,書中也介紹了基於 GDB 的一些可視化調試工具,如 CGDB、VisualGDB。以下是圖書該章節目錄截圖:

圖片

圖片

注意事項

由於受疫情影響,很多面試都改成了線上,一些同學在寫完一些算法題時,放到 Linux 機器上調試和運行,如果遇到問題,會不會熟練利用 GDB 調試,高下立判。調試能力也是線上面試重要的考察點。

四、一些關於 Linux C++ 開發有用的資源

我目前在某大廠做 C++ 開發,同時也作爲 C++ 面試官。想找我內推大廠開發的可以戳這裏:

需要內推大廠的同學看這裏(含內推聯繫方式)

在你面試之前,建議可以看看下面的面經:

同事內推的那位Linux C/C++後端開發同學面試沒過......

如果你的工作年限不長,建議好好準備一下常見的算法和數據結構題目。我也整理了一套算法題庫和常見的大廠算法題與面經:

鏈接:https://pan.baidu.com/s/1Igq2ZG06cFE0BRMxM_T6Sg
提取碼: tjok

通常算法這塊的題目並不難,但是一定要在面試前好好準備一下。技術面試中常見的計算機網絡題,可以看這裏:

輕鬆搞定技術面試中常見的網絡通信問題(www.zhihu.com/lives/922110858308485120)

我也曾在知乎上做過一個關於求職 Linux C++ 後端開發的專題分享,鏈接如下:

如何求職 C++ 後端開發崗位(www.zhihu.com/lives/1215948129440518144)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章