GNU GDB之tracepoint使用举例

  之前写了篇《GNU GDB调试手册》,对整个GDB手册过了一遍之后,让自己不禁感叹道这个调试工具是如此之强大,虽然使用断点、打印变量已经比那些只会printf的程序员要高级不少,但是对GDB调试器有个较为全面的了解,按照项目特点充分利用GDB的相应功能,将会大大增加软件开发和问题跟踪的效率。
  个人对GDB的tracepoint跟踪点印象还是比较深刻的,只是当时没有进行实战操练,所以文档那一部分相关知识整理的也比较粗糙,刚好这几日逛到一个GDB开发者的博客,介绍了tracepoint的相关东西,个人感到比较收益,于是照着他的描述尝试了一把确实可行,并在本文记录之。

一、软件调试

  软件调试是每个程序员所必须面对的问题,当然其中涉及到的工具、环境、需求等各项因素也比较多,虽然最终的目的就是要在和机器、测试部和自己那份执着内心之博弈下,让软件满足功能、性能、可用性的各项指标下运行起来,但是软件调试的过程还是有艺术所寻的。
  软件调试粗略来说分本地环境和线上环境,前者说来较为的普通和简单,因为自己的开发环境可以随意折腾,自然不必多说,但线上调试的复杂度就会高很多:生产环境可以调用的资源有限,而且操作必须格外谨慎;有些问题必须在线上环境下才会复现或者偶现;很多情况下生产环境不会停止业务让你跟踪问题的,所以调试的效率、对现有业务的入侵必须做到最小。虽然个人在这方面经验有限,但也尝试着就自己的眼界和观点做出一些解释和建议,也请各位有经验的大拿能不吝赐教!
  说在最先,关键业务的线上环境都应该具有主备切换/负载均衡的机制,这不仅有助于业务的伸缩和平滑升级增加运维效率,而且可以让有问题的机器安全下线保留现场。还有,规模较大的互联网企业现在也讲求灰度发布机制,开始用小规模的业务流量来试探新服务,那么软件的BUG也容易较早的暴露出来,同时对整个业务的影响也有限。然后,服务遇到可以按照下面的方法试试看:
  (1). 使用tcpdump抓包
  在网络相关的开发中,一般遇到问题的时候大多会想到祭出Wireshark神器抓包查看,生产环境的tcpdump也可以达到类似的效果。在之前已经提到过,线上问题可以设置对应过滤条件后开启tcpdump抓包,然后观察日志或者业务情况,当问题复现的时候就可以停止抓包了,通过数据包分析网络通信是否异常,进而帮助排查问题。
  另外感觉tcpcopy也是个不错的数据,虽然使用起来稍微有点麻烦。这货可以将数据包进行复制转发,收集到这些业务上真实的数据流不仅可以用于调试,也可以用于线上环境模拟、流量放大、压力测试等工作。
  (2). strace跟踪系统调用和信号
  这个工具因为小巧好用,所以在系统跟踪中出现的概率还是比较高的。通过他,程序运行中的所有系统调用及其参数、接受到的信号事件都可以配以时间戳被跟踪显示,在问题出现的时候也可以看到其简短的系统调用上下文作为参考。
  (3). 资源泄漏
  C/C++程序内存泄漏是极为严重的问题,其关系到服务能否长久运行,当然文件描述符、socket、命名的全局资源(进程间通信)问题也比较重要。内存泄漏检测工具不得不提大名鼎鼎的Valgrind,其功能强大不过问题是跟踪开始需要启动进程、退出后才给出结论报告,而且其运行程序的速度会至少降低一个数量级。所以,Valgrind也只能适合线下非生产环境的跟踪了。
  memleax是一个小巧的工具,在某些情况下可能会更加的便捷有用,他的原理是基于一个可设定的时间长度,如果在这个时间长度内存还没有释放,则报告为可疑的泄漏目标。他独立启动运行,可以attach到任何已经运行的进程上面,输出的结论也是动态的,Ctrl-C随时可疑放弃监测操作而不用停止原进程,对原程序运行的性能影响也很有限。可能对于复杂的内存问题能力有限,但是可以线上运行,对现有业务也没有什么损失和干扰,尤其针对动态申请资源的短连接或者KeepAlive之类的特定应用模型,还是推荐试一试的。
  内存中如果发生堆错误基本是无力回天的,但是对于发生栈错误(Stack corruption)还是可以跟踪到的,编译器gcc-4就支持栈错误的监测,其编译选项有-fstack-protector和-fstack-protector-all,前者检查所覆盖到的函数比较少(~2%),后者检查的全面但是对程序性能会有较大影响,所以通常会使用Google贡献出的-fstack-protector-strong选项,将会在前面两者之间得到一个较好的平衡点。
  文件描述符、套接字资源通过fuser、netstat可以查看到,对于不太容易查看的泄露源的话,通常就需要通过对程序的open、create、socket、close、pipe等调用添加hook的方式(interposition library)来进行跟踪了。
  对于IPC的全局资源,可能就只能通过重启操作系统来进行释放了吧!
  (4). GDB attach
  gdb和gdb server工具从可执行程序加载了符号之后,既可以通过run/start创建新的进程然后进行调试,也可以attach到一个已经运行的程序上面去进行调试。当GDB attach到一个已经运行着的进程上会导致该进程被暂停,通过continue可以让其继续进行,Ctrl-C可以在任意时刻挂起进程。在多线程的程序中,默认情况下是all-stop模式,这种模式在某个线程暂停执行的时候其他所有线程都会被挂起,可以查看较为全面的整个进程状态,但是线上业务会被阻塞;此外还可以以non-stop模式工作,虽然相比前者只能跟踪当前线程,而且有很多功能不能用,但是可以保证其他线程继续工作,对业务影响也较小。
  除了我们常用的普通断点之外,我们还应当善于使用条件断点、监视点(watchpoints)、捕获点(catchpoints),这些工具都被设计出来快速帮助我们定位问题使用的。
  (5). gdb coredump
  这也算是一个简单粗暴的方式,通常服务环境通常都会开启coredump的功能,这样程序挂掉的时候会保留一个现场,虽然不能够借尸还魂,但是对于事后跟踪问题算是最有价值的线索了。当然让程序挂掉来收集镜像代价高昂,其实可以使用GDB先attach到这个进程,然后通过generate-core-file|gcore命令产生镜像,然后在detach让程序继续正常工作,也可以收集到当时的镜像文件。
  线上能直接跟踪当然最好,如果是拿回来线下跟踪的话,最重要的就是确保程序以来的库文件和产生镜像的环境是一致的,通过设置solib类型的变量可以指定库的搜索路径信息以获得比较好的调试体验吧。
  (6). 死锁
  今天早上发现自己的业务停止响应了,attach上去通过info threads一看,所有的工作线程都阻塞在lll_lock_wait()函数的调用上面,于是随便选择了一个线程info stack一看,都是停留在pthread_mutex_lock()上面。自己在写工作线程的时候已经特别小心防止死锁了,但悲剧还是发生了,立马generate-core-file后先重启进程恢复服务,壮观的线程让我忍不住拍照留念了一下。
deadlock
  然后通过《Linux Applications Debugging Techniques/Deadlocks》文中所描述的方式,通过打印r8寄存器地址对象的方式,查看pthread_mutext对象的状态,发现所有工作线程请求的mutex都是同一个,而该mutex的 _owner都指向主线程,因此是主线程和工作线程之间的死锁。由于工作线程没有采用session stick,session对象的操作是在所有工作线程之间互斥共享的,所以主线程hold住了这个互斥量导致所有工作线程后续陆续接收到这个session的对话后都依次被阻塞,整个服务就不可用了。后来通过代码检查,才发现主线程会定时剔除不活跃的会话,导致了这种临界条件的发生(当然代码写的有问题是根本原因)。如果业务逻辑复杂的话,可能又要通过写interposition library来hook住pthread_mutex的调用来调试了。
  (7). gdb tracepoint
  跟踪点tracepoint是在对线上业务最小化侵入的情况下,搜集线上真实、实时环境下的数据,从而帮助跟踪和解决问题的工具,这是下面一节重点描述的内容。

二、tracepoint原理和使用

  这里相关tracepoint的基础知识,强烈建议先参考GDB的官方手册,或者是本人之前所整理的《GNU GDB调试手册》之内容。

2.1 tracepoint的工作原理

  常言道之“兵贵神速”!在调试中强调的速度不仅仅是强调效率,而是针对如果调试器负载过重会导致程序执行的速率大幅降低,那么在复杂环境下这一差异可能会改变程序执行的流程,乃至最终可能会影响到程序的正确性(比如Bug不能重现等)。
  GDB具有两种方式的tracepoint:Normal和Fast模式。
  Normal模式:该模式和通常的程序调试一样使用近似断点的技术来实现的,在遇到tracepoint的时候会引发TRAP,程序执行流交给调试器,调试器进行数据的收集,然后再将执行流恢复给原来的程序。这种模式的好处是比较的传统和成熟,通常环境不会有太大的竞争条件情况下是可以使用的,但是缺点就是执行流的切换消耗会比较大,和真实执行环境会有些偏差。
  Fast模式:使用In-Process Agent (IPA)和remote target(gdbserver)相结合的技术实现快速收集数据的操作,其原理就是讲程序中的原始指令替换成一个5字节的JMP指令,通过他跳转到内存的一个特殊jump-pad位置,在该位置首先执行寄存器压栈、调用collector之后,就地执行原先被JMP替换掉的原程序指令(executes the displaced instruction out-of-line),接着再跳转回程序原先的执行流程。如果看不清楚那么请忽略这些技术细节,在使用上就拿ftrace替换trace就可以了。
  不过,在我的环境(Ubuntu 16.04)下ftrace没法使用,启用ftrace时候gdb会报错,这锅也不知道该甩给谁,下面的实验使用常规的trace操作的。

2.2 tracepoint使用示例

  首先根据你的系统是Deb系还是RedHat系,需要安装systemtap-sdt-dev或者systemtap-sdt-devel开发包,不过可能以后调试和跟踪会使用(剧透了哈),建议还是把systemtap给安装上。虽然SystemTap擅长的是Linux内核的跟踪,但是现在的版本都实现了对用户态程序跟踪的功能。
  如果想要目标程序支持SDT,只需要包含 头文件就可以了,而且目标程序不需要链接任何的库文件,其实只是在ELF文件中添加了一些额外的段信息。同时为了装饰的专业一点,在CMake的配置中添加了USE_SDT的宏开关,然后在config.h.in中根据宏开关切换CHECK_POINTx为STAP_PROBEx的别名还是为空操作,此后就可以在程序中使用CHECK_POINT在任意位置插入了。关于CHECK_POINT这个名字,是当初在看PhxPaxos源代码的时候见到这个词,虽然里面的操作都被删除了,但是该宏遍及源代码的各个重要位置,是个指的学习的编码习惯。
  比如,在我的代码中加入了这两个监测点,并收集了两个局部变量,第一个参数我就填了模块名(项目中除了netd还有其他模块),第二个参数就取自己觉得有意义的名字。完成后重新编译程序即可。SDT最多支持12个整形变量的收集。

1
2
CHECK_POINT2(netd, msgrcv, pAiml->data_count, pAiml->data_buf);
CHECK_POINT2(netd, msgroute, pAiml->session_id, idx);

2.2.1 静态断点使用

  即使不关心tracepoint收集数据的功能,也可以将其作为位置无关location-independent的断点标记来使用。通常来说GDB是比较智能的,在你修改源代码的时候是会自动帮助调整断点的位置的,对于函数名指定的断点还无所谓,但是对于使用行号这样标识的断点使用起来会比较麻烦,所以如果在关键位置插入tracepoint,就可以使用这些名字来引用具体位置了!

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb) info probes 
Type Provider Name Where Semaphore Object
stap netd msgrcv 0x00000000005a488c /home/v5kf/remote_build/aimlsrvd_raw/build/aimlsrvd
stap netd msgroute 0x00000000005a52cc /home/v5kf/remote_build/aimlsrvd_raw/build/aimlsrvd
(gdb) break -probe-stap msgrcv
Breakpoint 1 at 0x5a488c
(gdb) break -probe-stap msgroute
Breakpoint 2 at 0x5a52cc
(gdb) info b
Num Type Disp Enb Address What
1 breakpoint keep y 0x00000000005a488c -probe-stap msgrcv
2 breakpoint keep y 0x00000000005a52cc -probe-stap msgroute
(gdb) run

  啪啪啪,程序运行到断点后会停下来,此时你既可以访问tracepoint的特殊变量$_probe_argX,也可以像通常断点一样访问寄存器、局部变量、全局变量等任何有意义的信息。

1
2
3
4
5
6
7
8
9
Thread 2 "aimlsrvd" hit Breakpoint 1, 0x00000000005a488c in aimlsrv::netd::msgConnect::process_msg_v4 (this=0x7fffbc000b80, pdata=0x7fffbc008ce0 "4")
at /home/v5kf/remote_build/aimlsrvd_raw/source/Netd/msgConnect.cpp:321
321 CHECK_POINT2(netd, msgrcv, pAiml->data_count, pAiml->data_buf);
(gdb) p $_probe_arg0
$1 = 219
(gdb) p $_probe_arg1
$2 = 140736347524544
(gdb) p (char *)$_probe_arg1
$3 = 0x7fffbc004dc0 "4"

2.2.2 gdbserver方式使用

  动态收集数据是tracepoint的主打功能,不过该功能目前只支持remote targets,所以可以通过gdbserver的方式使用,该功能的优势说过好多次了,这里就不在赘述了,反正这种方式收集到的数据才比较真实和有价值。
  (1) 采集数据
  服务端操作
  开启gdbserver后基本就可以处于不用管的状态了:

1
2
3
➜  build gdbserver :3001 ./aimlsrvd 
Process ./aimlsrvd created; pid = 15834
Listening on port 3001

  客户端操作
  通过gdb aimlsrvd启动gdb后,会自动加载调试符号信息,然后通过使用target remote连接到gdbserver服务端上面去。一旦连接上去后,远程的程序处于挂起状态,此时通过trace命令可以创建SDT的监测点,然后通过actions命令可以指明各个监测点所需要执行的操作(主要是所需收集的信息),具体的操作在前面的《GNU GDB调试手册》中已经描述的很详细了。
  PS:这个时候使用info probes会打印出比较多的探测点,很多是程序启动的桩函数上面,可见使用remote target模式下的probe功能会更加完善!
probes
  还有就是在使用收集数据的过程中,有些$_probe_argX变量是不可收集的,会提示 ‘bx’ is a pseudo-register; GDB cannot yet trace its contents. 的信息,难道是被优化到寄存器了?(因为事后我打印变量发现_probe_arg0和$rbx寄存器的值是一样的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(gdb) target remote :3001
Remote debugging using :3001
...
(gdb) trace -probe-stap msgrcv
Tracepoint 1 at 0x5a488c
(gdb) trace -probe-stap msgroute
Tracepoint 2 at 0x5a52cc
(gdb) info tracepoints
Num Type Disp Enb Address What
1 tracepoint keep y 0x00000000005a488c -probe-stap msgrcv
collect $regs
collect $_probe_argc
collect $_probe_arg1
not installed on target
2 tracepoint keep y 0x00000000005a52cc -probe-stap msgroute
collect $locals
collect $_probe_argc
collect $_probe_arg0
collect $_probe_arg1
not installed on target

  到这里,所有的准备工作就都完成了。通过tstart开启监测,然后continue命令让程序正常执行下去。任意时候,你可以通过Ctrl-C发送SIGINT信号,此时gdb默认会挂起程序的执行,我们此时使用tstop停止监测,数据就被收集下来了。
  使用tstatus命令可以看到,总共收集到10个frame,通过tsave可以将这些数据保存到文件系统中放别的机器或者后来再次加载使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(gdb) tstart
(gdb) continue
Continuing.
...
Thread 1 "aimlsrvd" received signal SIGINT, Interrupt.
pthread_cond_wait@@GLIBC_2.3.2 () at ../sysdeps/unix/sysv/linux/x86_64/pthread_cond_wait.S:185
185 ../sysdeps/unix/sysv/linux/x86_64/pthread_cond_wait.S: No such file or directory.
(gdb) tstop
(gdb) tstatus
Trace stopped by a tstop command ().
Collected 10 trace frames.
Trace buffer has 5234260 bytes of 5242880 bytes free (0% full).
Trace will stop if GDB disconnects.
Not looking at any trace frame.
Trace started at 1488960800.236546 secs, stopped 50.833416 secs later.
(gdb) tsave traceinfo.gdb
Trace data saved to file 'traceinfo.gdb'.

  (2) 结果查看和分析
  分析查看就是基本的tfind命令了,不带参数的tfind会按照顺序依次查看下一个frame。GDB将frame对应的tracepoint的上下文信息都显示的很详细了,这对稍懂一些GDB调试操作的人,使用起来起都毫无压力吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) tfind start
Found trace frame 0, tracepoint 1
#0 0x00000000005a488c in aimlsrv::netd::msgConnect::process_msg_v4 (this=<unavailable>, this@entry=<error reading variable: PC not available>,
pdata=<unavailable>, pdata@entry=<error reading variable: PC not available>) at /home/v5kf/remote_build/aimlsrvd_raw/source/Netd/msgConnect.cpp:321
321 CHECK_POINT2(netd, msgrcv, pAiml->data_count, pAiml->data_buf);
(gdb) p $_probe_arg0
$1 = 222
(gdb) p $_probe_argc
$2 = 2
(gdb) tfind
Found trace frame 1, tracepoint 2
#0 0x00000000005a52cc in aimlsrv::netd::msgConnect::deal_receive_string_v4 (this=<unavailable>, this@entry=<error reading variable: PC not available>,
pAiml=...) at /home/v5kf/remote_build/aimlsrvd_raw/source/Netd/msgConnect.cpp:456
456 CHECK_POINT2(netd, msgroute, pAiml->session_id, idx);
(gdb) p $_probe_arg0
$10 = 4444
(gdb) p $_probe_arg1
$11 = 1
(gdb)

本文完!

参考文献