跳过正文

GDB调试多线程案例分析

GDB Multi Threads
目录

在软件开发的复杂世界里,高效的调试工具是解决问题的关键利器。今天,我们将深入探讨强大的调试工具 —— GDB(GNU Debugger)。GDB 为开发者提供了一种深入程序内部运行机制、查找错误和优化性能的有效途径。让我们一同开启 GDB 的调试之旅,解锁代码中的奥秘。

GDB调试工具
#

GDB(GNU Debugger)是强大的调试工具,在软件开发过程中起着至关重要的作用。它可以帮助开发者快速定位和解决程序中的问题。

GDB做以下4 件主要的事情来帮助您捕获程序中的bug:

  • 在程序启动之前指定一些可以影响程序行为的变量或条件
  • 在某个指定的地方或条件下暂停程序
  • 在程序停止时检查已经发生了什么
  • 在程序执行过程中修改程序中的变量或条件,这样就可以体验修复一个 bug 的成果,并继续了解其他 bug

启动 GDB 主要有以下两种方法:

  1. 直接启动
  • gdb:单独输入此命令启动 GDB,启动后需借助file或者exec-file命令指定要调试的程序
  • gdb test.out:如果有一个名为test.out的可执行文件,可以直接使用这个命令启动 GDB 并加载该程序进行调试
  • gdb test.out core:当程序发生错误并生成core文件时,可以使用这个命令启动 GDB,以便对错误进行分析
  1. 动态链接:gdb test.out pid,这种方式可以将 GDB 链接到一个正在运行中的进程中去,其中pid就是进程号,可以使用ps aux命令查看对应程序的进程号。

要准备调试的程序,首先需要用gcc-g参数生成可执行文件。这样才能在可执行文件中加入源代码信息以便调试,但这并不是将源文件嵌入到可执行文件中,所以调试时必须保证 GDB 能找到源文件。例如,编译程序时可以使用gcc -g main.c -o test.out这样的命令来生成带有调试信息的可执行文件。

GDB调试技巧
#

  1. 条件断点

条件断点在调试过程中非常实用。设置条件断点可以利用break if命令,例如(gdb)break 666 if testsize==100123123。条件断点的优势在于可以在特定条件满足时才使程序停止,这对于排查异常情况非常有帮助。比如在一个循环中,当某个变量达到特定值时才中断程序,这样可以更精准地定位问题。

  1. 断点命令

断点命令不仅可以让程序在特定位置停止,还可以编写对到达断点响应的脚本,实现更复杂的调试功能。例如,可以在断点处设置一些打印变量值、检查特定条件等操作,以更好地了解程序的运行状态。

  1. 转储二进制内存

GDB 提供了多种方式查看内存。内置支持的x命令可以查看内存地址中的值,其语法为x/<n/f/u> <addr>,其中n是显示内存的长度,f表示显示的格式,u表示从当前地址往后请求的字节数。例如(gdb) x/16xw 0x7FFFFFFFE0F8可以以十六进制、四字节为单位显示从地址0x7FFFFFFFE0F8开始的 16 个单位的内存内容。此外,也可以使用自定义的hexdump命令来查看内存,更加灵活地控制输出格式。

  1. 行内反汇编

使用disassemble/s命令可以查看与函数源代码对应的指令,这有助于了解程序在 CPU 指令级别上的情况。例如,disas main可以显示main函数对应的汇编代码。通过查看汇编代码,可以更深入地理解程序的执行过程,对于分析性能问题、理解底层实现等非常有帮助。

  1. 反向调试

反向调试是 GDB 的一个强大功能。它可以让程序实现上一步上一步的操作,即反向运行。反向调试在一些情况下非常有用,比如调试过程中不小心多执行了一次命令,或者想再次查看刚刚程序执行的过程。反向调试不适用 IO 操作,并且需要 GDB7.0 以上的版本。相关指令有rcreverse-continue反向运行程序,直到碰到一个能使程序中断的事件;rs或reverse-step反向运行程序到上一次被执行的源代码行等。通过查看寄存器值等方式,可以深入了解程序在反向运行过程中的状态变化。

GDB调试方法
#

  1. 编译及启动调试

在编译代码时,加上 -g 选项是非常重要的,这可以确保在可执行文件中包含调试信息,以便在使用 GDB 进行调试时能够获取更多的程序内部状态信息。例如,使用 gcc -g main.c -o main.out 这样的命令编译代码,生成的 main.out 可执行文件就可以被 GDB 有效地调试。

启动调试代码有多种方式。可以直接使用 gdb main.out 来启动调试一个可执行文件,然后在 GDB 环境中使用 run 命令来运行程序。如果程序在启动时需要命令行参数,可以在进入 GDB 后使用 run arg1 arg2... 的方式来提供参数并启动调试。

另外,还可以调试正在运行的程序。

  • 首先找到程序的进程号,可以使用 ps aux | grep program_namepidof program_name 来获取进程号。
  • 然后使用 gdb attach pid 或者 gdb -p pid 命令将 GDB 附加到正在运行的程序上进行调试。
  1. 调试命令

GDB 有许多强大的调试命令。比如 list 命令可以显示源代码:

  • list 会打印当前行后面的代码
  • list - 显示当前行前面的代码
  • list lineNumber 打印出行第 lineNumber 行前后的代码
  • list FunctionName 打印出行函数 FunctionName 前后的代码

break 命令用于设置断点,可以在指定的行号或函数处设置断点:

  • break <function> 在进入指定函数时停止运行
  • break <lineNumber> 在指定代码行打断点
  • break filename:lineNumber 在指定文件的特定行设置断点
  • break filename:function 在指定文件的函数入口处设置断点

还可以设置条件断点,如 break... if <condition>,当条件成立时程序停止运行。

next 命令执行下一条语句,如果该语句为函数调用,不会进入函数内部执行。

step 命令执行下一条语句,如果该语句为函数调用,则进入函数执行其中的第一条语句。

continue 命令继续程序的运行,直到遇到下一个断点。

printdisplay 命令用于打印变量 / 表达式的值,print 只输出一次,display 跟踪查看某个变量,每次停下来都显示它的值。可以以不同格式打印变量,如 p /f variable,其中 f 可以是 x(十六进制格式)、d(十进制格式)、u(十六进制格式显示无符号整型)等。

watch 命令在程序运行过程中监视变量值的变化,如果有变化,马上停止程序运行,如 watch variable 当变量 variable 有变化时,停止程序运行,还有 rwatch 和 awatch 分别在变量被读取和被读或被写时停止程序运行。

  1. 调试段错误

调试段错误的一种快捷方法是生成 core 文件并使用 GDB 加载分析。首先,可以使用 ulimit -c unlimited 命令将 core 文件生成设置为不限制大小。这样,当程序发生段错误时,会生成 core 文件。

然后,使用 GDB 加载这个 core 文件进行调试。可以使用 gdb program core 的方式,其中 program 是可执行程序名称,core 是生成的 core 文件。在 GDB 中,可以使用 backtrace 命令查看函数调用栈,找到出错的位置。还可以使用 frame 命令查看特定栈帧的信息,使用 print 命令打印变量的值,以确定问题所在。例如,如果在调试过程中发现某个变量的值为空指针,可能是内存分配失败导致的,可以进一步检查相关的内存分配代码。

GDB使用其他要点
#

调试参数列表
#

GDB 拥有丰富的调试参数,以下是一些常见的命令及其用途:

启动程序:使用 gdb [可执行文件名] 启动 GDB 并加载要被调试的可执行文件。例如 gdb test.out。还可以使用 gdb file [可执行文件名] 的方式启动,如 gdb file test.out。另外,若要调试正在运行的程序,可以使用 gdb attach [进程号]gdb -p [进程号]

  1. 设置断点:
  • break [行号]:在指定行设置断点,如 break 10。
  • break [函数名]:在函数入口处设置断点,如 break main。
  • break [文件名:行号]:在指定文件的特定行设置断点,如 break test.c:20。
  • break… if [条件]:设置条件断点,当条件成立时程序停止运行,如 break 666 if testsize==100123123。
  • info breakpoints:显示当前程序的断点设置情况。
  • delete breakpoints [断点号]:删除指定断点,不指定断点号则删除所有断点。
  • disable [断点号]:暂停指定断点。
  • enable [断点号]:开启指定断点。
  • clear [行号]:清除指定行的断点。
  1. 单步执行:
  • next(简写为 n):逐过程调试,执行下一行,当遇到函数调用时,会一次性执行完该函数,不进入函数体内部。
  • step(简写为 s):单步调试,执行下一行,当遇到函数调用时,会进入函数体内部。
  • continue(简写为 c):继续执行程序,直到下一个断点处或程序结束。
  • until:当厌倦在一个循环体内单步跟踪时,这个命令可以运行程序直到退出循环体。until+行号:运行至某行,可用于跳出循环。
  • finish:运行程序,直到当前函数完成返回,并打印函数返回时的堆栈地址和返回值及参数值等信息。
  • call [函数(参数)]:调用程序中可见的函数,并传递参数,如 call gdb_test(55)。
  1. 查看信息:
  • info registers:显示所有寄存器的内容,可查看特定寄存器,如 info registers rbp 显示 rbp 寄存器的值,info registers rsp 显示 rsp 寄存器的值。
  • info stack:显示堆栈信息。
  • info args:显示当前函数的参数列表。
  • info locals:显示当前函数的局部变量列表。
  • info function:查询函数。
  • info breakpoints:显示当前程序的断点设置情况。
  • info watchpoints:列出当前所设置的所有观察点。
  • info line [行号/函数名/文件名:行号/文件名:函数名]:查看源代码在内存中的地址。

查看内存单元值
#

在 GDB 中,可以使用 examine 命令(简写是 x)来查看内存地址中的值。其格式为 x/<n/f/u> <addr>,其中:n是一个正整数,表示显示内存的长度,从当前地址向后显示几个地址的内容。例如 x/16xb 0x7FFFFFFFE0F8 表示以单字节为单位显示从地址 0x7FFFFFFFE0F8 开始的 16 个字节的内容。

f表示显示的格式,可取如下值:

  • x:按十六进制格式显示变量。
  • d:按十进制格式显示变量。
  • u:按十进制格式显示无符号整型。
  • o:按八进制格式显示变量。
  • t:按二进制格式显示变量。
  • a:按十六进制格式显示变量。
  • i:指令地址格式。
  • c:按字符格式显示变量。
  • f:按浮点数格式显示变量。

u表示一个地址单元的长度,可用以下字符代替:

  • b表示单字节。
  • h表示双字节。
  • w表示四字节。
  • g表示八字节。

查看源程序
#

在 GDB 中,可以使用 list(简写为 l)命令查看源程序,有以下几种方式:

  • list:显示当前行后面的源程序,默认每次显示 10 行,按回车键继续看余下的。
  • list [行号]:将显示当前文件以 “行号” 为中心的前后 10 行代码,如 list 12。
  • list [函数名]:将显示 “函数名” 所在函数的源代码。

栈帧相关
#

GDB 中有一些与栈帧相关的命令:

  • info frame:打印当前栈帧的详细信息,包括当前函数、参数和局部变量等。例如:(gdb) info frame会显示诸如 Stack level 0, frame at [地址]: pc = [程序计数器值] in [函数名] ([文件名]:[行号]); saved pc [保存的程序计数器值]等信息。
  • up和down:在栈帧之间上下移动。up命令将切换到上一个栈帧,而down命令将切换到下一个栈帧。
  • info locals:显示当前函数的局部变量列表,帮助开发者了解当前栈帧中的局部变量情况。

GDB多线程调试
#

GDB 多线程调试基础
#

  1. 基本命令介绍

在 GDB 多线程调试中,有许多常用命令。例如设置断点可以使用 (gdb) break function_name,通过这个命令可以在特定的函数处设置断点,当程序执行到该函数时会暂停。删除断点则可以使用 (gdb) delete breakpoints。查看线程信息可以使用 (gdb) info threads,这个命令会列出所有可调试的线程信息,包括 GDB 分配的线程 ID、系统级的线程标识符以及线程的栈信息等。切换线程可以使用 (gdb) thread thread_id,通过指定线程 ID 可以快速切换到对应的线程进行调试。此外,设置监视点可以使用 (gdb) watch variable_name,用于观察某个变量的值是否有变化,一旦变化程序会立即暂停。删除监视点则是 (gdb) delete watchpoints。

  1. 编译多线程程序

在进行多线程调试之前,我们需要先编译多线程程序。通常,我们可以使用 gcc 编译器来编译多线程程序。例如,对于以下多线程程序代码:

#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 5
void * thread_func(void * thread_id) {
    long tid = (long)thread_id;
    printf("Hello World! It's me, thread #%ld!", tid);
    pthread_exit(NULL);
}
int main() {
    pthread_t threads[NUM_THREADS];
    int rc;
    long t;
    for (t = 0; t < NUM_THREADS; t++) {
        printf("In main: creating thread %ld", t);
        rc = pthread_create(&threads[t], NULL, thread_func, (void *)t);
        if (rc) {
            printf("ERROR; return code from pthread_create() is %d", rc);
            return -1;
        }
    }
    pthread_exit(NULL);
}

我们可以将上述代码保存至一个名为 multithread.c 的文件中,并使用以下命令进行编译:$ gcc -g -pthread -o multithread multithread.c。其中,-g 选项用于在可执行文件中加入调试信息,这样在使用 GDB 进行调试时可以获取更多的程序信息;-pthread 选项则用于引入多线程库,确保程序能够正确地使用多线程功能。

多线程调试案例分析
#

  1. 简单多线程程序调试

假设我们有一个如下的简单多线程程序:

#include <stdio.h>
#include <pthread.h>

void *printNumbers(void *arg) {
    int i;
    for (i = 0; i < 10; i++) {
        printf("Thread: %d\n", i);
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    pthread_create(&thread1, NULL, printNumbers, NULL);
    pthread_create(&thread2, NULL, printNumbers, NULL);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    return 0;
}

我们可以使用以下步骤进行 GDB 调试:

  • 首先,编译程序:$ gcc -g -pthread -o simple_thread simple_thread.c
  • 然后启动 GDB:$ gdb simple_thread
  • 在 main 函数处设置断点:(gdb) break main
  • 运行程序:(gdb) run,程序会停在 main 函数的断点处。
  • 接着,我们可以使用 (gdb) info threads 查看当前的线程信息。可以看到有两个线程正在运行,一个是主线程,一个是其中一个子线程。
  • 使用 (gdb) thread thread_id 切换到子线程,然后进行单步执行操作,如 (gdb) next,可以观察到子线程的执行过程。
  1. 复杂多线程程序调试

对于更复杂的多线程程序,比如多个线程之间存在交互和同步问题的程序,调试会更加具有挑战性。

例如,有一个多线程程序,多个线程同时对一个共享资源进行读写操作,可能会出现竞争条件和数据不一致的问题。

在这种情况下,我们可以使用 GDB 的以下技巧来处理:

  • 使用 (gdb) break function_name 在关键的同步函数处设置断点,如互斥锁的加锁和解锁函数。
  • 通过 (gdb) info threads 随时查看线程状态,确定哪个线程正在持有共享资源,哪个线程在等待资源。
  • 使用 (gdb) thread apply all bt 查看所有线程的调用堆栈,以了解每个线程的执行路径和当前状态。
  • 设置条件断点,例如 (gdb) break function_name if condition,当特定条件满足时才触发断点,以便在复杂的交互场景中更精确地定位问题。

例如,假设我们有一个多线程的银行账户管理程序,多个线程同时进行存款和取款操作,我们可以在存款和取款函数处设置断点,并根据账户余额等条件设置条件断点,以便在出现异常情况时能够快速定位问题所在。

多线程调试技巧
#

  1. 线程锁定与并发控制

在 GDB 中,可以使用 set scheduler-locking 命令来控制线程的执行顺序和并发程度。这个命令有三个值,分别是 on、step 和 off。

  • set scheduler-locking on:可以用来锁定当前线程,只观察这个线程的运行情况,锁定这个线程时,其他线程处于暂停状态。在当前线程执行 next、step、until、finish、return 命令时,其他线程是不会运行的。需要注意的是,在使用这个选项时要确认当前线程是否是我们期望锁定的线程,如果不是,则可以使用 thread + 线程编号 切换到我们需要的线程,再调用 set scheduler-locking on 锁定。
  • set scheduler-locking step:也用来锁定当前线程,当且仅当使用 nextstep 命令做单步调试时会锁定当前线程,如果使用 until、finish、return 等线程内的调试命令(它们不是单步控制命令),则其他线程还是有机会运行的。与 on 选项的值相比,step 选项的值为单步调试提供了更加精细化的控制,因为在某些场景下,我们希望单步调试时其他线程不要对所属的当前线程的变量值造成影响。
  • set scheduler-locking off:用于释放锁定当前线程。

我们还可以使用 show scheduler-locking 命令来显示线程的 scheduler-locking 状态。

  1. 命令组合与高效调试

一些常用的 GDB 命令组合可以提高多线程调试的效率。例如:

  • info threads + thread thread_id + bt:先使用 info threads 查看当前进程的所有线程信息,然后使用 thread thread_id 切换到特定线程,再使用 bt 查看该线程的函数调用堆栈,以便分析该线程的执行逻辑。
  • break function_name + condition + run + next/step:先使用 break function_name if condition 在特定函数处设置条件断点,然后使用 run 运行程序,当条件满足时程序会停在断点处,接着使用 nextstep 进行单步调试。
  • thread apply all command:可以让所有被调试线程执行特定的 GDB 命令,例如 thread apply all bt 可以查看所有线程的调用堆栈。
  1. 常见问题与解决方案

在多线程调试过程中,可能会遇到以下常见问题:

线程死锁:如果程序出现死锁,可以使用 GDB 的以下步骤进行分析。

首先,使用 gdb 启动程序,然后在程序死锁处按 ctrl+c 暂停程序。接着,使用 info threads 查看当前节点上线程状态,使用 thread thread_id 切换线程,使用 bt 查看线程堆栈,并查处死锁位置。多切换几个线程,全面分析死锁的原因。一般来说,首先检查使用频率最高的锁在所有函数出口上是否已解锁。如果是第一轮出现死锁,则可检查锁配对和可能的程序出口上是否进行了开锁。如果多轮运行后出现,且基本确认函数出口均解锁,则需要判断是否是内存越界,可以使用工具 valgrind 进行内存越界诊断。

无法确定当前调试的线程:可以使用 info threads 命令查看当前可调试的所有线程,每个线程会有一个 GDB 为其分配的 ID,前面有 * 的是当前调试的线程。也可以使用 thread thread_id 切换到特定线程进行确认。

多线程程序调试效率低下:可以使用前面提到的命令组合和线程锁定功能,有针对性地调试特定线程或在特定条件下进行调试,提高调试效率。同时,可以将程序中的线程数量减少至 1 进行调试,观察是否正确,然后逐步增加线程数量,调试线程的同步是否正确。

相关文章

一张图解释 8 种流行网络协议
HTTPS TCP Network
如何释放Windows 的C 盘空间
Windows 11 Space
20 个非常有用的Windows 11快捷键
Windows 11 Shortcut