GDB调试多线程及多进程
本文档主要参看«Debugging with GDB» Tenth Edition, for gdb version 8.0.1
,本节我们主要讲述一下使用GDB来调试多线程及多进程程序。
1. 调试多线程
1.1 概念介绍
在有一些操作系统上,比如GNU/Linux与Solaris,一个进程可以有多个执行线程。线程的精确语义因操作系统不同而有一些区别,但一般来说一个程序的线程类似与多进程,除了多线程是共享同一个地址空间之外。另一方面,每一个线程都有其自己的寄存器(registers)和执行栈(execution stack),并可能拥有其自己的私有内存。
GDB提供了如下的一些facilities
来用于支持多线程的调试:
-
新线程的自动通知
-
thread thread_id: 用于在线程之间切换的命令
-
info threads: 用于查询当前存在的线程信息
-
thread apply [thread-id-list] [all] args: 对一系列的线程应用某一个命令
-
thread-specific breakpoints
-
set print thread-events: 控制是否打印线程启动、退出消息
-
set libthread-db-search-path path: 假如默认的选择不兼容当前程序的话,让用户选择使用那个
thread-db
上面的线程调试facility
使得你可以在程序运行期间观察到所有的线程,但是无论在什么时候只要被gdb接管控制权,只会有一个线程处于focus状态。该线程被称为current thread
。GDB调试命令都是以当前线程(current thread)的视角来显示程序信息。
当GDB在程序中检测到有一个新的线程,其都会打印该线程在目标系统的标识信息,格式为[New systag]
, 这里systag
是一个线程标识,其具体的形式可能依系统不同而有些差异。例如在GNU/Linux操作系统上,当GDB检测到有一个新的线程时,你可能会看到:
[New Thread 0x41e02940 (LWP 25582)]
相反,在一些其他的系统上,systag
可能只是一个很简单的标识,例如process 368
。
用于调试目的,GDB会用其自己的线程号与每一个“线程inferior”相关联。在同一个inferior
下,所有线程之间的标识号都是唯一的;但是不同inferior
下,线程之间的标识号则可能不唯一。你可以通过inferior-num.thread-num
语法来引用某一个inferior
中的指定线程(这被称为qualified Thread ID
)。例如,线程2.3
引用inferior 2
中线程number为2的线程。假如你省略inferior number
的话,则GDB默认引用的是当前inferior
中的线程。
在你创建第二个inferior
之前,GDB并不会在thread IDs
部分显示inferior number
。
有一些命令接受以空格分割的thread ID
列表作为参数,一个列表元素可以是:
1) 'info threads' 命令显示的'thread ID'可能包含inferior标识符,也可能不包括。例如: '2.1'或者'1' 2) 指定线程数范围,格式为 'inf.thr1-thr2' 或者 'thr1-thr2'。例如: '1.2-4'或'2-4' 3) 一个 'inferior'中的所有线程,可以通过'*'通配符来指定。格式为 'inf.*'或者 '*'。前者指定某个inferior中的所有线程; 后者指定当前inferior中的所有线程
例如,假如当前的inferior
是1,inferior 7
有一个线程,其ID为7.1
,则线程列表1 2-3 4.5 6.7-9 7.*
表示inferior 1
中的线程1至线程3,inferior 4
中的线程5,inferior 6
中的线程7至线程9, 以及inferior 7
中的所有线程。
从GDB的视角来看,一个进程至少有一个线程。换句话说,GDB会为程序的主线程指定一个thread number
,即使在该程序并不是多线程的情况下。参看如下:
编译调试:
假如GDB检测到程序是多线程的,假如某个线程在断点处暂停时,其就会打印出该线程的ID及线程的名称:
Thread 2 "client" hit Breakpoint 1, send_message () at client.c:68
相似的,当程序收到一个信号之后,其会打印如下的信息:
Thread 1 "main" received signal SIGINT, Interrupt.
1.2 GDB线程相关命令
- info threads [thread-id-list]: 用于显示一个或多个线程的信息。假如并未指定参数的话,则显示所有线程的信息。你可以指定想要显示的线程列表。GDB会按如下方式显示每一个线程:
例如:
(gdb) info threads Id Target Id Frame * 1 process 35 thread 13 main (argc=1, argv=0x7ffffff8) 2 process 35 thread 23 0x34e5 in sigpause () 3 process 35 thread 27 0x34e5 in sigpause () at threadtest.c:68
假如当前你正在调试多个inferiors
,则GDB会使用限定的inferior-num.thread-num
这样的格式来显示thread IDs
。否则的话,则只会显示thread-num
。
假如指定了-gid
选项,那么在执行info threads
命令时就会显示每一个线程的global thread ID
:
(gdb) info threads Id GId Target Id Frame 1.1 1 process 35 thread 13 main (argc=1, argv=0x7ffffff8) 1.2 3 process 35 thread 23 0x34e5 in sigpause () 1.3 4 process 35 thread 27 0x34e5 in sigpause () * 2.1 2 process 65 thread 1 main (argc=1, argv=0x7ffffff8)
- thread thread-id: 使
thread-id
所指定的线程为当前线程。该命令的参数thread-id
是GDB所指定的thread ID
,即上面info threads
命令显示的第一列。通过此命令切换之后,GDB会打印你所选中的线程的系统标识和当前的栈帧信息:
(gdb) thread 2 [Switching to thread 2 (Thread 0xb7fdab70 (LWP 12747))] #0 some_function (ignore=0x0) at example.c:8 8 printf ("hello\n");
类似于在创建线程时打印出的[New ...]
这样的消息,Switching to
后面的消息打印也依赖于你所使用的系统
-
thread apply [thread-id-list | all [-ascending]] command
: 本命令允许你在一个或多个线程上应用指定的command
。如果要在所有线程上按降序的方式应用某个command
,那么使用 ‘thread apply all command’; 如果要在所有线程上按升序的方式应用某个command
,那么使用’thread apply all -ascending command’; -
thread name [name]: 本命令用于为当前线程指定一个名称。假如并未指定参数的话,那么任何已存在的由用户指定的名称都将被移除。命名后线程的名称会出现在
info threads
的显示信息中。 -
thread find [regexp]: 用于查询名称或
systag
匹配查询表达式的线程。例如:
(gdb) thread find 26688 Thread 4 has target id ’Thread 0x41e02940 (LWP 26688)’ (gdb) info thread 4 Id Target Id Frame 4 Thread 0x41e02940 (LWP 26688) 0x00000031ca6cd372 in select ()
- set libthread-db-search-path [path]: 假如本变量被设置,那么GDB将会使用所设置的路径(路径目录之间以’:’分割)来查找
libthread_db
。假如执行此命令时,并不指定path,那么将会被重置为默认值(在GNU/Linux及Solaris系统下默认值为$sdir:$pdir
,即系统路径和当前进程所加载线程库的路径)。而在内部,默认值来自于LIBTHREAD_DB_SEARCH_PATH
宏定义。
在GNU/Linux以及Solaris操作系统上,GDB使用该辅助libthread_db
库来获取inferior中线程的信息。GDB会使用’libthread-db-search-path’来搜索libthread_db
。假如’set auto-load libthread-db’被启用的话,GDB首先会搜索该inferior所加载的线程调试库。
在使用libthread-db-search-path搜索libthread_db时,有两个特定的路径: $sydir、$pdir 1) $sdir: 搜索共享库的默认的系统路径。本路径是唯一不需要通过'set auto-load libthread_db'命令来启用的 2) $pdir: 指示inferior process加载libpthread库的位置
假如在上述目录中找到了libpthread_db
库,那么GDB就会尝试用当前inferior process来初始化。假如初始化失败的话(一般在libpthread_db与libpthread版本不匹配的情况),GDB就会卸载该libpthread_db,然后尝试继续从下一个路径搜索libpthread_db。假如最后都没有找到适合的版本,GDB会打印相应的警告信息,接着线程调试将会被禁止。
注意: 本命令只在一些特定的平台上可用。
-
show libpthread-db-search-path: 用于显示当前
libpthread_db
的搜索路径 -
set debug libpthread-db / show debug libpthread-db: 用于启用或关闭
libpthread-db
相关的事件信息的打印。1为启用, 0为关闭。 -
set scheduler-locking mode: 用于设置
锁定线程的模式
(scheduler locking mode)。其适用于程序正常执行、record mode以及重放模式。
1) mode为off时,则不锁定任何线程,即所有线程在任何时间都可以被执行; 2) mode为on时,则锁定其他线程,只有当前线程执行; 3) mode为step时,则当在进行单步调试(single-stepping)时只有当前线程会运行,其他的线程将不会获得运行的机会,这样就可以使得调试的焦点 只集中于当前线程。但是假如执行的时'continue'、'until'、'finish'这样的非单步调试命令的话,则其他的线程也会运行。 一般来说,除非一个线程在其运行时间片内遇到断点(breakpoint),否则GDB一般并不会从当前调试线程切换到该线程。
- show scheduler-locking: 用于显示当前的锁定模式
1.3 多线程调试示例
- 示例源代码
如下是我们所采用的调试示例源代码test.c
:
- 编译运行
# gcc -c -g test.c gcc -c -g test.c -Wno-int-to-pointer-cast # gcc -o test test.o -lpthread # ps -aL | grep test 40900 40900 pts/0 00:00:00 test 40900 40901 pts/0 00:00:00 test 40900 40902 pts/0 00:00:00 test
然后我们再通过如下命令查看主线程和两个子线程之间的关系:
# pstree -p 40900 test(40900)─┬─{test}(40901) └─{test}(40902)
再接着通过pstack
来查看线程栈结构:
# pstack 40900 Thread 3 (Thread 0x7fd44f426700 (LWP 40901)): #0 0x00007fd44f4e566d in nanosleep () from /lib64/libc.so.6 #1 0x00007fd44f4e5504 in sleep () from /lib64/libc.so.6 #2 0x0000000000400757 in pthread_run1 (arg=0x0) at test.c:14 #3 0x00007fd44f7efdc5 in start_thread () from /lib64/libpthread.so.0 #4 0x00007fd44f51e73d in clone () from /lib64/libc.so.6 Thread 2 (Thread 0x7fd44ec25700 (LWP 40902)): #0 0x00007fd44f4e566d in nanosleep () from /lib64/libc.so.6 #1 0x00007fd44f4e5504 in sleep () from /lib64/libc.so.6 #2 0x0000000000400794 in pthread_run2 (arg=0x0) at test.c:30 #3 0x00007fd44f7efdc5 in start_thread () from /lib64/libpthread.so.0 #4 0x00007fd44f51e73d in clone () from /lib64/libc.so.6 Thread 1 (Thread 0x7fd44fc0c740 (LWP 40900)): #0 0x00007fd44f7f0ef7 in pthread_join () from /lib64/libpthread.so.0 #1 0x00000000004007ff in main (argc=1, argv=0x7ffdbfa69a38) at test.c:46
- GDB调试多线程程序
1) 启动gdb调试,并在上述代码a++
处加上断点
# gdb -q ./test Reading symbols from /root/workspace/test...done. (gdb) b test.c:14 Breakpoint 1 at 0x400749: file test.c, line 13.
2) 运行并查看inferiors及threads信息
(gdb) r Starting program: /root/workspace/./test [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib64/libthread_db.so.1". [New Thread 0x7ffff77ff700 (LWP 41362)] [Switching to Thread 0x7ffff77ff700 (LWP 41362)] Breakpoint 1, pthread_run1 (arg=0x0) at test.c:13 13 a++; Missing separate debuginfos, use: debuginfo-install glibc-2.17-157.el7.x86_64 (gdb) info inferiors Num Description Executable * 1 process 41788 /root/workspace/./test (gdb) info threads Id Target Id Frame 3 Thread 0x7ffff6ffe700 (LWP 41793) "test" 0x00007ffff7835480 in sigprocmask () from /lib64/libc.so.6 * 2 Thread 0x7ffff77ff700 (LWP 41792) "test" pthread_run1 (arg=0x0) at test.c:14 1 Thread 0x7ffff7fe3740 (LWP 41788) "test" 0x00007ffff7bc9ef7 in pthread_join () from /lib64/libpthread.so.0
从上面我们看到当前停在我们设置的断点处。
接着我们执行如下:
(gdb) s 15 sleep(1); (gdb) s 12 while(runflag) (gdb) s Breakpoint 1, pthread_run1 (arg=0x0) at test.c:14 14 a++; (gdb) s 15 sleep(1); (gdb) s 12 while(runflag) (gdb) p b $1 = 4
上面我们看到当我们在单步调试pthread_run1
的时候,pthread_run2
也在执行。但是当我们暂停在断点处时,pthread_run2
是不在执行的。
如果我们想在调试一个线程时,其他线程暂停执行,那么可以使用set scheduler-locking on
来锁定。例如:
# gdb -q ./test Reading symbols from /root/workspace/test...done. (gdb) b test.c:14 Breakpoint 1 at 0x400742: file test.c, line 14. (gdb) r Starting program: /root/workspace/./test [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib64/libthread_db.so.1". [New Thread 0x7ffff77ff700 (LWP 41951)] [Switching to Thread 0x7ffff77ff700 (LWP 41951)] Breakpoint 1, pthread_run1 (arg=0x0) at test.c:14 14 a++; Missing separate debuginfos, use: debuginfo-install glibc-2.17-157.el7.x86_64 (gdb) set scheduler-locking on (gdb) p b $1 = 0 (gdb) s 15 sleep(1); (gdb) s s 12 while(runflag) (gdb) s Breakpoint 1, pthread_run1 (arg=0x0) at test.c:14 14 a++; (gdb) s 15 sleep(1); (gdb) s 12 while(runflag) (gdb) s Breakpoint 1, pthread_run1 (arg=0x0) at test.c:14 14 a++; (gdb) s 15 sleep(1); (gdb) s 12 while(runflag) (gdb) s Breakpoint 1, pthread_run1 (arg=0x0) at test.c:14 14 a++; (gdb) s 15 sleep(1); (gdb) s 12 while(runflag) (gdb) p b $2 = 0 (gdb)
2. 调试多进程
2.1 基本概念
在大多数系统上,GDB对于通过fork()
函数创建的子进程的调试都没有专门的支持。当一个程序fork()之后,GDB会继续调试父进程,而子进程仍会畅通无阻的运行。假如你在代码的某个部分设置了断点,然后子进程执行到该位置时,则子进程会受到一个SIGTRAP
信号并导致子进程退出(除非子进程catch了该信号)。
然而,假如你想要调试子进程的话,也有一种相对简单的取巧方法。就是在执行完fork之后,在进入子进程代码时调用sleep()
方法。这里可以根据某个环境变量是否设置或者某个文件是否存在来决定是否进入sleep()
,这样就可以使得我们在非调试状态下避免休眠。当子进程进入sleep状态时,我们就可以通过ps
命令查看到子进程的进程ID。接着可以通过使用GDB并attach到该子进程,然后就可以像调试普通程序一样进行调试了。
在有一些系统上,GDB对使用fork()
或vfork()
函数创建的子进程的调试提供了支持。在GNU/Linux平台上,从内核2.5.46
版本开始该特性就被支持。
默认情况下,当一个程序forks之后,GDB会继续调试父进程,而对子进程没有任何的影响。
假如你想要跟随子进程而不是父进程,那么可以使用set follow-fork-mode
命令:
- set follow-fork-mode mode: 设置GDB调试器如何对
fork
或者vfork
进行响应。参数mode
的取值可以为
parent: 表示跟随父进程。这是默认情况 child: 表示跟随子进程
- show follow-fork-mode: 显示当前的跟随模式
在Linux上,假如parent
进程与child
进程都想要调试的话,那么可以使用set detach-on-fork
命令。
- set detach-on-fork mode: 用于告诉GDB在fork()之后是否分离其中的一个进程,或者同时保持对他们的控制。mode可取值为
on: 子进程或者父进程将会被分离(取决于follow-fork-mode),使得该进程可以独立的运行。这是默认值 off: 子进程和父进程都会在GDB的控制之下。其中一个进程(取决于follow-fork-mode)可以像平常那样进行调试,而另一个进程处于挂起状态
- show detach-on-fork: 用于显示
detach-on-fork
模式的值
假如你选择设置detach-on-fork
的值为off,那么GDB将会将会保持对所有fork进程的控制(也包括内部fork)。你可以通过使用info inferiors
命令来查看当前处于GDB控制之下的进程,并使用inferior
命令来进行切换。
如果要退出对其中一个fork进程的调试,你可以通过使用detach inferiors
命令来使得该进程独立的运行,或者通过kill inferiors
命令来将该进程杀死。
假如你使用GDB来调试子进程,并且是在执行完vfork
再调用exec
,那么GDB会调试该新的target直到遇到第一个breakpoint。另外,假如你在orginal program的main函数中设置了断点,那么在子进程的main函数中也会保持有该断点。
在有一些系统上,当子进程是通过vfork()
函数产生的,那么在完成exec
调用之前,你将不能对父进程或子进程进行调试。
假如在执行完exec
调用之后,你通过运行run
命令,那么该新的target
将会重启。如果要重启父进程的话,使用file
命令并将参数设置为parent executable name
。默认情况下,当一个exec执行完成之后,GDB会丢弃前一个可执行镜像的符号表。你可以通过set follow-exec-mode
命令来改变这一行为:
- set follow-exec-mode mode: 当程序调用
exec
之后,GDB相应的行为。exec
调用会替换一个进程的镜像。mode取值可以为:
1) new: GDB会创建一个新的inferior,并将该进程重新绑定到新的inferior。在执行exec
之前的所运行的程序可以通过重启原先的inferior(original inferior)来进行 重启。例如:
2) same: GDB会将exec之后的新镜像加载到同一个inferior
中,以替换原来的镜像。在执行exec之后如果要重启该inferior,那么可以通过运行run
命令。这是默认模式。例如:
2.2 调试示例
- 调试子进程
1) 示例源码
2) 调试步骤
首先执行下面的命令进行编译:
# gcc -g -c test.c # gcc -o test test.o
在调试多进程程序时,GDB默认会追踪处理父进程。例如:
上面我们看到,子进程很快就打印出了hello,world!
,说明GDB并没有控制住子进程。而在父进程中,我们通过单步执行到第18行的return,然后父进程返回退出。
如果要调试子进程,要使用如下的命令: set follow-fork-mode child
。例如:
上面我们看到程序执行到第20行: 子进程打印出hello,world!
.
- 同时调试父进程和子进程
1) 示例源码
2) 调试步骤
首先通过执行下面的命令执行编译:
# gcc -g -c test.c # gcc -o test test.o
从前面我们知道,GDB默认情况下只会追踪父进程的运行,而子进程会独立运行,GDB不会控制。
如果同时调试父进程和子进程,可以使用set detach-on-fork off
(默认值是on)命令,这样GDB就能同时调试父子进程,并且在调试一个进程时,另一个进程处于挂起状态。例如:
上面在使用set detach-on-fork off
命令之后,使用info inferiors
命令查看进程状态,可以看到父进程处在被GDB调试的状态(前面显示*
表示正在被调试)。当父进程退出后,用inferior infno
切换到子进程去调试。
此外,如果想让父子进程同时运行,可以使用set schedule-multiple on
(默认值为off)命令,仍以上述代码为例:
可以看到打印出了Child
,证明子进程也在运行了。
3. 设置用于返回的书签
在许多操作系统上,GDB能够将程序的运行状态保存为snapshot,这被称为checkpoint
,后续我们就可以通过相应的命令返回到该checkpoint。
回退到一个checkpoint
,会使得所有发生在该checkpoint之后的操作都会被做undo。这包括内存的修改、寄存器的修改、甚至是系统的状态(有一些限制)。事实上,类似于回到保存checkpoint的时间点。
因此,当你在单步调试程序,并且认为快接近有错误的代码点时,你就可以先保存一个checkpoint。然后,你继续进行调试,假如碰巧错过了该关键代码段,这时你就可以回退到该checkpoint并从该位置继续进行调试,而不用完全从头开始来调试整个程序。
要使用checkpoint/restart
方法来进行调试的话,需要用到如下命令:
-
checkpoint: 将调试程序的当前执行状态保存为一个snapshot。本命令不携带任何参数,但是其实GDB内部对于每一个checkpoint都会指定一个整数ID,这有些类似于breakpoint ID.
-
info checkpoints: 列出当前调试session所保存的checkpoints。对于每一个checkpoint,都会有如下信息被列出
Checkpoint ID Process ID Code Address Source line, or label
- restart checkpoint-id: 重新装载
checkpoint-id
位置的程序状态。所有的程序变量、寄存器、栈帧等都会被恢复为在保存该checkpoint时的状态。实际上,GDB类似于将时间拨回到保存该checkpoint的时间点。
注意,对于breakpoints、GDB variables、command history等,在执行恢复到某个checkpoint时并不会受到影响。一般来说,checkpoint只存储调试程序的信息,而并不存储调试器本身的信息。
- delete checkpoint checkpoint-id: 删除以前保存的某个checkpoint
返回到前一个保存的checkpoint时,将会恢复该调试程序的用户状态,也会恢复一部分的操作系统状态,包括文件指针。恢复时,并不会对一个文件中的数据执行un-write
操作,但是会将文件指针恢复到原来的位置,因此之前所写的数据可以被overwritten
。对于那些以读模式打开的文件,文件指针将会恢复到原来所读的位置。
当然,对于那些已经发送到打印机(或其他外部设备)的字符将不能够snatched back
,而对于从外部设备(例如串口设备)接收到字符则从内部程序缓冲中移除,但是并不能push back
回串行设备的pipeline中。相似的,对于文件的数据发生了实质性的更改这一情况,也是不能进行恢复。
然而,即使有上面的这些限制,你还是可以返回到checkpoint处开始进行调试,此时可能还可以调试一条不同的执行路径。
最后,当你回退到checkpoint时,程序会回退到上次保存时的状态,但是进程ID会发生改变。每一个checkpoint都会有一个唯一的进程ID,并且会与原来程序的进程ID不同。假如你所调试的程序在本地保存了进程ID的话,则可能会出现一些潜在的问题。
3.1 使用checkpoint的潜在优势
在有一些系统上,比如GNU/Linux,通常情况下由于安全原因每一个新进程的地址空间都是随机的。这就使得几乎不太可能在一个绝对的地址上设置一个breakpoint或者watchpoint,因为在程序下一次重启时,程序中symbol的绝对路径可能发生改变。
然而一个checkpoint,等价于一个进程拷贝。因此假如你在main的开始就创建一个checkpoint,后续返回到该checkpoint而不是重启程序,这就可以避免受到重启程序地址随机这一情况的影响。通过返回checkpoint,可以使得程序的symbols
仍保持在原来的位置
3.2 checkpoint使用示例
1) 示例程序
2) 调试技巧
首先采用如下的命令编译程序:
# gcc -g -c test.c # gcc -o test test.o
下面我们进行调试,在ret += func1()
前保存一个checkpoint:
然后使用next
步进,并每次调用完毕,打印ret的值:
结果发现,在调用func2()
后,ret的值变为了1。可是此时,我们已经错过了调试fun2()
的机会。如果没有checkpoint
,就需要再次从头调试了。对于这个问题从头调试很容易,但是对于很难复现的bug可能就会比较困难了。
下面我们使用checkpoint恢复:
上面我们看到,GDB恢复到了保存checkpoint时的状态了。上面restart 1
中1为checkpoint的ID号。
从上面我们看出checkpoint的用法很简单,但是很有用。就是在平时的简单的bug修复中,也可以加快我们的调试速度,毕竟减少了不必要的重现bug的时间。
[参看]