如何调试大型 Linux C/C++ 项目?
作为接触 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.
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 命令如下图所示:
链接: https://pan.baidu.com/s/1uq3Kzsty3Z26K8QPhAeXDg
密码: kua1
至于阅读代码的话,你可以根据你的个人喜欢选择喜欢自己的 IDE,例如 VSCode、Visual Studio、SourceInsight 等等。
总而言之,熟练掌握 GDB 调试等于拥有了学习优秀 C/C++ 开源项目源码的钥匙,只要可以利用 gdb 调试,再复杂的项目,在不断调试和分析过程中总会有搞明白的一天。
掌握 GDB 本身并不难,对于大型项目,想要熟练调试,还需要掌握很多调试技巧和背景知识,我在我的图书《》第二章以 Redis 为例,详细地介绍了如何利用 GDB 调试大型项目的方方面面。如果你认真学习完这一章,你也可以自己独立调试大型 Linux C++ 项目。
如果你不喜欢 GDB 黑洞洞的界面,书中也介绍了基于 GDB 的一些可视化调试工具,如 CGDB、VisualGDB。以下是图书该章节目录截图:
注意事项
由于受疫情影响,很多面试都改成了线上,一些同学在写完一些算法题时,放到 Linux 机器上调试和运行,如果遇到问题,会不会熟练利用 GDB 调试,高下立判。调试能力也是线上面试重要的考察点。
四、一些关于 Linux C++ 开发有用的资源
我目前在某大厂做 C++ 开发,同时也作为 C++ 面试官。想找我内推大厂开发的可以戳这里:
在你面试之前,建议可以看看下面的面经:
如果你的工作年限不长,建议好好准备一下常见的算法和数据结构题目。我也整理了一套算法题库和常见的大厂算法题与面经:
链接:https://pan.baidu.com/s/1Igq2ZG06cFE0BRMxM_T6Sg
提取码: tjok
通常算法这块的题目并不难,但是一定要在面试前好好准备一下。技术面试中常见的计算机网络题,可以看这里:
轻松搞定技术面试中常见的网络通信问题(www.zhihu.com/lives/922110858308485120)
我也曾在知乎上做过一个关于求职 Linux C++ 后端开发的专题分享,链接如下:
如何求职 C++ 后端开发岗位(www.zhihu.com/lives/1215948129440518144)
最后,祝你学会调试大型 Linux C++ 项目,成为卷神~
写了这么多,如果觉得有帮助,请给 @张小方 点个赞呗~
推荐阅读
原创不易,如果觉得有帮助,请给小方点个赞呗~~