使用 GDB 调试 Rust 应用程序

更新于 2026-01-17

Mario Zupan 2021-02-09

根据你之前接触过的编程语言和生态系统,调试可能对你来说要么是完全不需要的操作,要么是你开发流程中不可或缺的一部分。

例如,在 Java(以及 Kotlin 和其他基于 JVM 的技术)生态系统中,由于其悠久而成熟的工具链历史,许多人(包括我自己)在日常开发周期中都会依赖调试器。而在许多动态类型语言中,这种工作流却并不普及。

当然,这些只是泛泛之谈。几乎每种编程语言都有某种调试机制,但开发者是否使用调试器似乎取决于工具的质量与易用性,以及他们所执行的任务类型。

无论如何,拥有良好的调试支持是开发流程中的关键一环。在本篇 Rust GDB 教程中,我们将向你展示如何使用目前最优秀的 Rust 调试工具之一 —— GNU 项目调试器(GDB)来调试 Rust 应用程序。

什么是 GDB?

GNU 项目调试器(GDB)是一个非常古老的程序,由自称“GNU 项目的首席 GNUisance”的理查德·斯托曼(Richard Stallman)于 1986 年编写。GDB 支持多种语言,如 C/C++,同时也支持 Go 和 Rust 等现代语言。

GDB 是一个命令行应用程序,但它有许多图形界面(GUI)前端和集成开发环境(IDE)插件可用。例如,gdbgui 就是一个现代化的、基于浏览器的实现。在本教程中,我们将坚持使用命令行界面,因为它可以在任何地方运行,无需额外依赖,并且对于我们要完成的任务而言足够简单。

GDB 可在 Linux、macOS 和 Windows 上运行,并且大多数常见的 Linux 发行版都已预装。你可以查阅 GDB 文档以获取适用于你平台的安装说明。

GDB 功能极其强大且复杂,因此本教程不会深入探讨其所有细节。我们将专注于基本功能,例如设置断点、运行程序、单步执行、打印变量等。

在 Rust 中设置 GDB

要跟随本教程操作,你只需要一个相对较新的 Rust 安装(1.39+)和一个较新的 GDB 安装(8.x+)。此外,像 netcat 这样的用于发送 TCP 数据包的工具也可能有用。

另外,请确保在你的 rustc 可执行文件所在目录中存在一个名为 rust-gdb 的可执行文件。如果你使用 Rustup 安装并更新 Rust,这应该是默认就有的。

首先,创建一个新的 Rust 项目:

cargo new rust-gdb-example
cd rust-gdb-example

接下来,编辑 Cargo.toml 文件并添加所需的依赖项:

[dependencies]
tokio = { version = "1.1", features=["full"] }

这里我们只添加了 Tokio 作为依赖项,因为我们将构建一个非常基础的异步 TCP 示例,以展示我们可以像调试“普通”函数一样调试异步函数。

将以下代码添加到 src/lib.rs 中:

#[derive(Clone, Debug)]
pub enum AnimalType {
    Cat,
    Dog,
}

#[derive(Clone, Debug)]
pub struct Animal {
    pub kind: AnimalType,
    pub name: String,
    pub age: usize,
}

#[derive(Clone, Debug)]
pub struct Person {
    pub name: String,
    pub pets: Vec<Animal>,
    pub age: usize,
}

这些只是我们在示例程序中用于调试的一些基础类型。

什么是 rust-gdb?

rust-gdb 是一个随 Rust 安装(例如通过 Rustup)自动安装的预构建二进制文件。

本质上,rust-gdb 是一个包装器,它会将外部的 Python pretty-printing(美化打印)脚本加载到 GDB 中。这在调试更复杂的 Rust 程序时非常有用(甚至可以说是必要的),因为它能显著改善 Rust 数据类型的显示效果。

例如,启用美化打印后,一个 Vec<Animal> 看起来是这样的:

Vec(size=3) = {rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Chip", age: 4}, rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Nacho", age: 6}, rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Dog, name: "Taco", age: 2}}

而没有美化打印时,它看起来是这样的:

alloc::vec::Vec<rust_gdb_example::Animal> {buf: alloc::raw_vec::RawVec<rust_gdb_example::Animal, alloc::alloc::Global> {ptr: core::ptr::unique::Unique<rust_gdb_example::Animal> {pointer: 0x5555555a1480, _marker: core::marker::PhantomData<rust_gdb_example::Animal>}, cap: 3, alloc: alloc::alloc::Global}, len: 3}

美化打印脚本为大多数广泛使用的 Rust 构造(如 VecOptionResult 等)提供了格式化支持,隐藏了它们的内部结构,直接显示出实际的 Rust 类型——这正是我们大多数时候关心的内容。

这也是当前 Rust 调试方法的一个明显局限性。如果你有复杂的嵌套数据类型,你可能需要了解它们的内部结构,或者借助某种“黑魔法”才能正确检查值。这种情况未来会逐步改善,但就目前而言,如果你用这种方法调试复杂的现实世界软件,可能会遇到一些问题。

设置完成后,让我们从一个示例程序开始,并用它启动 rust-gdb

rust-gdb 示例

我们从一个如何在 Rust 中使用 GDB 的基本示例开始。

在你的项目中创建一个 examples 文件夹,并添加一个名为 basic.rs 的文件,内容如下:

use rust_gdb_example::*;

fn main() {
    let animals: Vec<Animal> = vec![
        Animal {
            kind: AnimalType::Cat,
            name: "Chip".to_string(),
            age: 4,
        },
        Animal {
            kind: AnimalType::Cat,
            name: "Nacho".to_string(),
            age: 6,
        },
        Animal {
            kind: AnimalType::Dog,
            name: "Taco".to_string(),
            age: 2,
        },
    ];

    get_chip(&animals);
}

fn get_chip(animals: &Vec<Animal>) {
    let chip = animals.get(0);

    println!("chip: {:?}", chip);
}

这个非常简单的程序初始化了一个动物列表,并在最后调用一个函数来打印列表中的第一个动物。

要调试它,我们需要先构建它,然后使用生成的二进制文件运行 rust-gdb。请确保你是在调试模式下构建的,而不是发布模式。

cargo build --example basic
Finished dev [unoptimized + debuginfo] target(s) in 0.28s

rust-gdb target/debug/examples/basic

如果你不是构建示例而是构建二进制程序,那么二进制文件将位于 target/debug 目录下。

运行 rust-gdb 后,GDB 会显示几行欢迎信息,并给出一个输入提示符 (gdb)

如果你以前从未使用过 GDB,这份 GDB 速查表 可能会有所帮助。

让我们设置一个断点,可以使用 break 命令,或者简写为 b

(gdb) b get_chip
Breakpoint 1 at 0x13e3c: file examples/basic.rs, line 26.
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000013e3c in basic::get_chip at examples/basic.rs:26

我们可以按行号(例如 basic.rs:17)或提供函数名来设置断点。使用 info b 可以查看断点信息,它会显示断点的位置、编号(如果你想删除、禁用或启用它)、以及是否启用(Enb)。

info 命令还可以与其他标志一起使用,例如 info locals 显示局部变量,info args 显示传入的函数参数,还有许多其他选项。

现在我们已经设置了断点,可以通过执行 run(或简写为 r)来运行程序:

(gdb) r
Starting program: /home/zupzup/dev/oss/rust/rust-gdb-example/target/debug/examples/basic
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, basic::get_chip (animals=0x7fffffffd760) at examples/basic.rs:26
26            let chip = animals.get(0);

程序启动后,我们在定义的断点处停止,即 get_chip 函数的第一行。在这里,我们可以查看函数的参数并尝试打印它们:

(gdb) info args
animals = 0x7fffffffd760
(gdb) p animals
$1 = (*mut alloc::vec::Vec<rust_gdb_example::Animal>) 0x7fffffffd760
(gdb) p *animals
$2 = Vec(size=3) = {rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Chip", age: 4}, rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Nacho", age: 6}, rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Dog, name: "Taco", age: 2}}

info args 命令提供了传入参数的概览。当我们使用 pprint 也可以)打印 animals 时,GDB 告诉我们这是一个指向 Vec<Animal> 的指针,但没有显示该 Vec 内容的相关信息,因为它只是一个指针。

你也可以使用 display 来打印变量,并且还有格式化选项(如字符串、指针、整数等)。printdisplay 的区别在于,使用 display 后,每次执行单步指令后都会再次打印该值。这对于监控某个值的变化非常有用。

好了,我们现在在哪里?让我们执行 fframe 来查看当前位置:

(gdb) f
#0  basic::get_chip (animals=0x7fffffffd760) at examples/basic.rs:26
26            let chip = animals.get(0);

对,我们在第一个断点处。要是有一种方法可以在图形界面上看到我们在源代码中的位置就好了……

布局(Layouts)和状态检查

GDB 中的布局(layouts)可以帮助你看到自己在 Rust 源代码中的位置。使用 layout src 命令会打开一个命令行界面:

Layout GDB SRC Command Line Interface

我们的命令提示符仍然在其下方。这样我们就永远不会搞混自己在哪里了。还有其他布局,例如 layout split,它会同时显示源代码和对应的汇编代码:

GDB Layout Split Visual

很酷吧。如果你想退出布局,可以使用 CTRL+X a。如果渲染出现问题,可以使用 CTRL+L 刷新(这种情况偶尔会发生)。

和其他调试器一样,我们可以使用 n(或 next)逐行执行代码,或者使用 s(或 step)进入当前行上的函数。如果你想重复上一条命令,只需按回车即可。

让我们再单步执行一行,看看在对 animalsVec 调用 .get 之后 chip 变量里面是什么:

(gdb) n
28            println!("chip: {:?}", chip);
(gdb) p chip
$3 = core::option::Option<&rust_gdb_example::Animal>::Some(0x5555555a1480)
(gdb) print *(0x5555555a1480 as &rust_gdb_example::Animal)
$4 = rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Chip", age: 4}

我们执行了 n,现在到了第 28 行。在这里,我们尝试打印 chip,发现它是一个包含对 Animal 引用的 Option。不幸的是,GDB 只向我们显示了地址;我们需要将该地址转换为 &rust_gdb_example::Animal 才能看到动物的实际值。

一件很棒的事情是,大多数内容都支持自动补全。所以如果你开始输入 rust_gd,按 TAB 键就会自动补全。同样,AnimalType 以及其他作用域内的类型、函数和变量也支持自动补全。

我们还可以打印函数定义:

(gdb) p get_chip
$11 = {fn (*mut alloc::vec::Vec<rust_gdb_example::Animal>)} 0x555555569370 <basic::get_chip>

如果我们想运行到当前函数的末尾并向上返回到调用点,可以使用 finish。如果我们完成了当前断点的调试,可以使用 continue(或 c)继续执行程序——在这种情况下,程序将直接运行到结束:

(gdb) finish
Run till exit from #0  basic::get_chip (animals=0x7fffffffd760) at examples/basic.rs:28
chip: Some(Animal { kind: Cat, name: "Chip", age: 4 })
0x0000555555567d87 in basic::main () at examples/basic.rs:22
22            get_chip(&animals);
(gdb) c
Continuing.
[Inferior 1 (process 61203) exited normally]

非常好。这些就是调试 Rust 程序所需的基本知识。让我们来看另一个示例,并探索一些更高级的技术。

操纵状态和观察点(Watchpoints)

首先,让我们在 examples 文件夹中创建另一个示例文件 nested.rs

use rust_gdb_example::*;

fn main() {
    let animals: Vec<Animal> = vec![
        Animal {
            kind: AnimalType::Cat,
            name: "Chip".to_string(),
            age: 4,
        },
        Animal {
            kind: AnimalType::Cat,
            name: "Nacho".to_string(),
            age: 6,
        },
        Animal {
            kind: AnimalType::Dog,
            name: "Taco".to_string(),
            age: 2,
        },
    ];

    let mut some_person = Person {
        name: "Some".to_string(),
        pets: animals,
        age: 24,
    };
    println!("person: {:?}", some_person);
    some_person.age = 100;
    some_person.name = some_func(&some_person.name);
}

fn some_func(name: &str) -> String {
    name.chars().rev().collect()
}

这次我们仍然创建了一个动物列表,但还创建了一个 Person,并将动物设为其宠物。此外,我们打印了这个人,将其年龄设置为 100,并反转了其名字(这就是 some_func 的作用)。

在调试此程序之前,我们需要再次构建它,并使用生成的二进制文件启动 rust-gdb

cargo build --example nested
rust-gdb target/debug/examples/nested

很好。让我们在第 22 行和第 27 行设置断点,然后运行程序:

(gdb) b nested.rs:22
Breakpoint 1 at 0x17abf: file examples/nested.rs, line 22.
(gdb) b nested.rs:27
Breakpoint 2 at 0x17b13: file examples/nested.rs, line 27.
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000017abf in nested::main at examples/nested.rs:22
2       breakpoint     keep y   0x0000000000017b13 in nested::main at examples/nested.rs:27
(gdb) r
Starting program: /home/zupzup/dev/oss/rust/rust-gdb-example/target/debug/examples/nested
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, nested::main () at examples/nested.rs:22
22            let mut some_person = Person {

我们在第一个断点处,也就是创建 person 的地方。让我们继续执行到打印语句。然后,我们将在 some_person.age 上设置一个所谓的观察点(watchpoint)。每当 some_person.age 发生变化时,这个观察点都会通知我们:

(gdb) c
(gdb) watch some_person.age
Hardware watchpoint 3: some_person.age
(gdb) n
person: Person { name: "Some", pets: [Animal { kind: Cat, name: "Chip", age: 4 }, Animal { kind: Cat, name: "Nacho", age: 6 }, Animal { kind: Dog, name: "Taco", age: 2 }], age: 24 }
28            some_person.age = 100;
(gdb) n

Hardware watchpoint 3: some_person.age

Old value = 24
New value = 100
0x000055555556bba8 in nested::main () at examples/nested.rs:28
28            some_person.age = 100;

GDB 向我们展示了触发的是哪个观察点,以及旧值和新值。

让我们通过再次调用 run 并确认要重新运行程序。这一次,当我们在第二个断点处时,让我们使用 set 手动更改值:

(gdb) set some_person.age = 22
(gdb) p some_person
$1 = rust_gdb_example::Person {name: "Some", pets: Vec(size=3) = {rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Chip", age: 4}, rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Nacho", age: 6},
    rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Dog, name: "Taco", age: 2}}, age: 22}

如你所见,我们可以使用 set ..args 来操纵变量的状态。这对基本类型效果很好,但对于复杂值(如 Rust 标准库或外部 crate 的类型)则会变得棘手。这是另一个缺点,但希望未来会有所改进。

我们可以尝试的另一个不错的功能是执行函数并查看其返回值:

(gdb) p some_func("Hello")
$3 = "olleH"
(gdb) p some_func("Debug")
$4 = "gubeD"
(gdb) p some_func(some_person.name)
$5 = "emoS"
(gdb) set some_person.name = some_func(some_person.name)
(gdb) p some_person
$6 = rust_gdb_example::Person {name: "emoS", pets: Vec(size=3) = {rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Chip", age: 4}, rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Cat, name: "Nacho", age: 6},
    rust_gdb_example::Animal {kind: rust_gdb_example::AnimalType::Dog, name: "Taco", age: 2}}, age: 22}

我们可以调用作用域内的 some_func 函数,并传入字面量字符串。我们也可以用 some_person.name 调用它,并使用 set 将人的名字设置为反转后的值。

这非常强大,让你可以在调试时检查表达式和函数的结果,有助于发现问题。当然,这在简单情况下效果很好,但如果你试图执行涉及 I/O 或其他更复杂操作的函数,可能会遇到障碍。不过对于 99% 的情况,现有的功能已经足够。

说到 I/O,让我们来看最后一个示例:如何使用 GDB 调试 Rust 中的异步网络应用程序。

调试异步网络应用程序

最后但同样重要的是,我们将尝试调试一个在 Tokio 异步运行时上运行的异步网络应用程序。

让我们在 examples 文件夹中创建 tokio.rs

use std::io;
use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream};

#[tokio::main]
async fn main() -> io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Accepting TCP on port 8080");

    loop {
        let (socket, _) = listener.accept().await?;
        tokio::spawn(async move { process(socket).await });
    }
}

async fn process(mut socket: TcpStream) {
    socket
        .write_all(b"Hello")
        .await
        .expect("can write to socket");
}

这个非常简单的程序在本地端口 8080 上启动一个 TCP 监听器,并为每个传入的连接异步调用 process 函数来处理请求。

process 函数只是简单地回写 "Hello",这使其成为最简单的“网络应用程序”。

然而,我们在这里寻找的并不是复杂性,而是想确定在调试异步程序(如 Web 服务器)时,使用 GDB 的工作流程是否会发生变化。

让我们编译该示例,并使用生成的二进制文件启动 rust-gdb

cargo build --example tokio
rust-gdb target/debug/examples/tokio

到目前为止一切顺利。

让我们在 process 函数开头的第 17 行设置一个断点:

(gdb) b tokio.rs:17
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   <MULTIPLE>
1.1                         y   0x000000000009aa87 in tokio::process::{{closure}} at examples/tokio.rs:17
1.2                         y   0x00000000000a57fa in tokio::process at examples/tokio.rs:17

有趣的是,断点被分成了 1.1 和 1.2。这些在 GDB 中称为位置(locations)。这可能是由于优化(例如内联)导致的,GDB 会在函数被内联或模板化的每个位置都设置一个断点。我推测这是由于 tokio::main 宏将所有代码包装在 Tokio 运行时中所致。

如果需要,我们可以禁用其中任一位置,但在这个例子中这并不重要。让我们运行程序:

(gdb) r
Starting program: /home/zupzup/dev/oss/rust/rust-gdb-example/target/debug/examples/tokio
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff7c1e700 (LWP 55035)]
[New Thread 0x7ffff7a1d700 (LWP 55036)]
[New Thread 0x7ffff781c700 (LWP 55037)]
[New Thread 0x7ffff761b700 (LWP 55038)]
[New Thread 0x7ffff741a700 (LWP 55039)]
[New Thread 0x7ffff7219700 (LWP 55040)]
[New Thread 0x7ffff7018700 (LWP 55041)]
[New Thread 0x7ffff6e17700 (LWP 55042)]
Accepting TCP on port 8080

我们的监听器已启动并运行,我们甚至可以看到 Tokio 运行时在后台生成的线程。

让我们从另一个终端会话使用 netcat 向该端点发送一些数据:

nc 127.0.0.1 8080

这会触发 process 中的断点:

[Switching to Thread 0x7ffff6e17700 (LWP 55041)]

Thread 9 "tokio-runtime-w" hit Breakpoint 1, tokio::process::{{closure}} () at examples/tokio.rs:18
18            socket

(gdb) p socket
$4 = tokio::net::tcp::stream::TcpStream {io: tokio::io::poll_evented::PollEvented<mio::net::tcp::stream::TcpStream> {io: core::option::Option<mio::net::tcp::stream::TcpStream>::Some(mio::net::tcp::stream::TcpStream {inner: mio::io_source::IoSource<std::net::tcp::TcpStream> {state: mio::sys::unix::IoSourceState, inner: std::net::tcp::TcpStream (std::sys_common::net::TcpStream {inner: std::sys::unix::net::Socket (std::sys::unix::fd::FileDesc {fd: 11})}), selector_id: mio::io_source::SelectorId {id: core::sync::atomic::AtomicUsize {v: core::cell::UnsafeCell<usize> {value: 1}}}}}), registration: tokio::io::driver::registration::Registration {handle: tokio::io::driver::Handle {inner: alloc::sync::Weak<tokio::io::driver::Inner> {ptr: core::ptr::non_null::NonNull<alloc::sync::ArcInner<tokio::io::driver::Inner>> {pointer: 0x55555573a560}}}, shared: tokio::util::slab::Ref<tokio::io::driver::scheduled_io::ScheduledIo> {value: 0x55555573ec20}}}}

(gdb) c

当断点被触发时,GDB 通知我们这是在运行时生成的某个线程中发生的,并且我们有 socket 变量可供检查。

socket 是一个 Tokio 的 TcpStream,但仅通过打印它我们无法说出太多信息。其中有一个文件描述符编号为 11,代表打开的网络连接,其余部分似乎是 Tokio 和 mio 的内部实现。

无论如何,它成功了——我们成功地在一个多线程中的异步处理器中设置了断点。这意味着同样的方法也同样适用于运行 Actix 或 warp Web 服务器的情况,在其中一个处理器函数中设置断点以检查传入的 HTTP 请求数据。

在我们使用 c 继续执行后,第二个终端中会收到 "Hello" 响应:

nc 127.0.0.1 8080
Hello

至此,我们使用 GDB 调试 Rust 应用程序的旅程就结束了。

你可以在 GitHub 上找到完整的示例代码。

结论

在本篇 Rust 调试教程中,我们演示了如何使用 GDB 调试 Rust 应用程序。在大多数情况下,它都能很好地工作,特别是配合 rust-gdb 的美化打印扩展,当你只需要通过断点逐步执行程序并检查程序状态时。

至于你可能在其他语言的高级 GUI 调试器中习惯的更复杂功能,这是 Rust 生态系统中一个活跃发展的领域,我完全相信 Rust 的调试生态系统会不断改进。但要花多长时间,以及整体调试体验最终能达到 Java 和 C/C++ 世界中顶级调试器的水平,这很难说,将取决于 Rust 社区对这类工具的需求程度。

本教程的目标是为你提供使用最少额外工具或知识就能对 Rust 程序进行基本调试的技能。这一背景知识应该能覆盖你在入门 Rust 时遇到的大多数情况。