这个项目来自 Stanford CS110L: Safety in Systems Programming.
课程地址: https://reberhardt.com/cs110l/spring-2020/
实验说明地址:https://reberhardt.com/cs110l/spring-2020/assignments/project-1/
我的实现:https://github.com/csBenClarkson/the-deet-debugger
项目介绍
使用 Rust 写一个小型 C 程序调试器 the Deet Debugger. 主要实现 GDB 中的断点、继续执行和回溯功能。该项目旨在帮助熟悉 Rust 语法,接触 Rust 中的 Error Handling,体会 Rust 如何在系统编程中增强安全性。当然,项目中不可避免地使用了 unsafe 代码来修改程序内存、寄存器以实现断点等功能。
开工
项目结构
首先来看看 starter code 吧!
在 src 目录下,我们有以下文件:
| 文件 | 作用 |
|---|---|
main.rs | 主程序入口 |
debugger.rs | 实现了 Debugger 结构体,用于初始化调试,解析输入,执行调试功能等 |
debugger_command.rs | 定义调试器支持的指令 |
dwarf_data.rs | 用于解析文件调试信息(无需修改) |
gimli_wrapper.rs | 同上,调用 gimli 库完成调试信息解析(无需修改) |
inferior.rs | 与被调试程序直接交互,包括修改内存,设置信号等,暴露接口供 Debugger 调用 |
在 samples 目录下,有一些样例 c 代码,在根目录下 make 构建程序后可用于测试调试器。
依赖适配
在项目开始前,推荐对 starter code 中的依赖进行适配升级,毕竟 Rust 发展迅速,新的库对项目速度、安全性等或有提升。本次实验参照 @fung-hwang 的工作进行适配。
看实验手册
课程的讲师 Ryan Eberhardt 和 助教 Armin Namavari 编写了详细的实验手册,把这个实验分成了几个里程碑,为完成实验提供了巨大的帮助,非常适合 Rust 新手阅读。接下来说一下每个里程碑里面需要注意的地方。
Milestone 1: Run the inferior
第一个里程碑比较简单,需要把被调试的程序跑起来,并为跑起来的进程加点料以便后续调试。加料其实就是要在 fork 之后和 exec 之前,使用 ptrace 系统调用来为进程打上一个调试的标记 PTRACE_TRACEME。 然后进程会在程序开始执行时发出 SIGTRAP 信号并暂停运行,这时被调试进程就可以任由调试器摆布了🥵
在 inferior.rs 中的 Inferior 结构体,我们需要实现 new 函数。Rust 把 fork 和 exec 封装在了 std::process::Command 中,我们只需要在 Command 上调用 spawn 即可创建子进程。此外,其还提供了 pre_exec 函数,可以定义在 fork 之后,exec 之前的操作,这里我们把 starter code 提供的 child_traceme 放进去。
最后用返回的子进程 handle 构建一个 Inferior 结构体,并调用提供的 wait 函数“接收”子进程发出的 SIGTRAP 信号即可。这里 wait 函数返回的是一个 Result,那么我们可以用下面的语法来进行 error handling,在 wait 失败时直接在当前函数中返回 None。
inferior.wait(None).ok()?接下来,我们需要通过返回的 wait 状态检查子进程发出的信号是否为 SIGTRAP。 这里用 if let 语法作 error handling。
let stat = inferior.wait(None).ok()?;
if let Status::Stopped(Signal::SIGTRAP, _) = stat {}
else { return None }然后我们需要让暂停的程序继续运行。这里为 Inferior 结构体定义一个新函数 go。 我们只需要调用 ptrace::cont 函数并把子进程的 pid(储存在 Inferior 结构体中)传进去即可。不要忘记再次调用 wait 阻塞 debugger 进程,捕获子进程发出的下一个信号。
在 debugger.rs 的 run 函数中构建 Inferior 并调用 go 函数,程序就可以在第一个 SIGTRAP 信号(因为前面的 PTRACE_TRACEME)暂停后继续运行了。
Take Away: 为什么不用裸的 fork 和 exec
Unix 提供的 fork 和 exec 为开发带来了极大的灵活性,这套简单而强大的机制主打一个相信程序员。然而,绝大多数情况 exec 会紧接着 fork 被调用,仅仅为了调用其它 binary 完成任务;且当涉及到多线程和复杂逻辑时,对这套机制的不熟悉会导致许多 bug,比如:在另一个线程更新某个数据结构的过程中 fork,导致子进程中的那个数据结构损坏;又或者创建子进程后忘记 wait 子进程结束,等等。
因此,我们可以看到许多语言都推出了类似的封装,以简化创建子进程的 common case,同时提供另外的接口来定制高级功能。而在操作系统方面, Windows 更是直接把创建子进程的过程合并为了一个巨大的 API。
Milestone 2: Stopping, resuming, and restarting the inferior
这个里程碑需要我们实现使用 Ctrl-C 暂停程序,和继续执行的功能。经常 Ctrl-C 的朋友都知道,这会对当前进程发出 SIGINT 信号。通常,这会使当前进程结束(SIGSTOP),但如果被标记了 PTRACE_TRACEME,进程会直接暂停执行。main.rs 注册了 SIGINT 信号的 handler,因此我们按下 Ctrl-C 后,子进程会接收到 SIGINT 并暂停,然后主进程会打印 prompt 等候用户输入命令。
cargo run samples/sleepy_print
(deet) r 10
0
1
^CChild stopped at 0x7f2c4248c78a (signal SIGINT)
(deet) 接下来我们要实现继续执行程序的命令。这在直觉上很简单,因为在上一个里程碑我们实现了 go 函数来让程序继续执行。我们只需要在 debugger_command.rs 中定义新命令 continue,然后在 debugger.rs 的 run 函数中 match 到 continue 命令时调用 go 函数即可。另外需要注意当程序还没通过 run 命令运行时,我们不应调用 go 函数。
DebuggerCommand::Continue => {
if self.inferior.is_none() {
println!("The program is not being run.");
continue;
}
Debugger::print_status(self.inferior.as_mut().unwrap().go().ok());
},这里的 print_status 函数是用来打印进程状态的。注意 go 函数中会调用 wait,因此它会在下一次子进程出发信号之后才返回。这样我们就基本实现了 continue 命令。
但是!还有一些 edge case 没有考虑:
如果在暂停程序后输入 run 命令会怎样?这会重新开启一个子进程运行程序,而原来的子进程没有被回收。因此在执行 run 命令之前,我们要检查 inferior 是否为 Some,并调用 kill 函数结束进程。
如果在程序暂停时输入 quit 命令退出 debugger 呢?这也会导致子进程未被回收,因此我们需要在处理 quit 命令的部分加入类似上述检查。然而这带来了另一个问题,如果子进程正常结束了,但此时 inferior 还是有东西的,这又会导致 kill 掉一个不存在的进程而报错。因此我们要在调用 go 函数后检查子进程发出的信号是否为 Exited,是的话要把 inferior 设为 None。 在处理 run 命令和 continue 命令的代码中都需要更改为下面的逻辑。
let stat = self.inferior.as_mut().unwrap().go().ok();
if let Some(Status::Exited(_)) = stat {
self.inferior = None;
}
Debugger::print_status(stat);如果需要检查子进程是否残留,可以使用下面的命令。
ps aux | grep sleepy_print其中 sleepy_print 是被调试的程序名。
这样我们就实现了暂停-继续程序的功能。
Milestone 3: Printing a backtrace
这个里程碑要我们完成打印回溯(backtrace)的功能,即程序暂停的地方的函数调用栈和对应函数在源代码中的位置。
我们知道函数名作为 symbol 和函数第一个指令的地址在编译时就被放进 C 程序可执行文件的符号表中了,而当我们以 -g 选项编译 C 程序时,这些符号信息与源代码的联系(debugging symbols)得以完整保留。因此,我们可以通过函数调用的地址找到对应的函数名,然后再找到其对应的文件名和行号,一起打印出来即可。
那么我们怎么找到函数调用的地址呢?这就要用到进程栈的信息了。栈(stack)是进程地址空间中的一个内存区域,一般由高地址向低地址增长。实际上,进程中的每一个线程都有一个栈供其使用,这里我们只考虑单线程且假设栈由高地址向低地址增长以简化讨论。
栈中的内容是一个个栈帧(stack frame),在 x86 架构中,栈和栈帧由两个指针 %ebp/%rbp (base pointer) 和 esp/rsp (stack pointer) 维护。如下图,%ebp/%rbp 指向当前函数栈帧的头部 + 一个字长的位置,而那一个字长储存着函数的返回地址; %ebp/%rbp 整个栈的栈顶。每个栈帧储存着当前函数的返回地址、当前函数调用者的栈帧地址(saved %ebp/%rbp)和本地变量(在函数内声明于栈上的变量)。
函数调用时,首先把返回地址推入栈,然后把调用者的 %ebp/%rbp 保存推入栈,把 %ebp/%rbp 更新为当前栈顶,最后把 esp/rsp 往下移动,使得栈可以容纳函数的所有本地变量。函数返回时,首先把 esp/rsp 移到 %ebp/%rbp 的位置销毁所有本地变量,再把之前保存的调用者的栈帧地址 pop 出来给 %ebp/%rbp,最后把返回地址 pop 出来给 PC 移交控制权。这样栈就回到了函数调用前的状态,就当无事发生(当然如果 buffer overflow 把栈捅穿了另计……
了解栈的结构之后我们就可以开始实现 backtrace 了。我们只需要用一个循环找到当前函数到 main 函数之间(包括 main 函数)所有栈帧的信息并打印出来即可。
首先按照实验手册,我们需要在 Debugger 结构体中加上一个 debug_data 来存放程序调试信息,并在其 new 函数中解析程序。
let debug_data = match DwarfData::from_file(target) {
Ok(val)=> val,
Err(DwarfError::ErrorOpeningFile) => {
println!("Could not open file {}", target);
std::process::exit(1);
}
Err(DwarfError::DwarfFormatError(err)) => {
println!("Could not debugging symbols from {}: {:?}", target, err);
std::process::exit(1);
}
};然后在 Inferior 中定义一个 print_backtrace 函数供 Debugger 调用,返回类型为 Result<(), nix::Error>,这样可以把函数中出现的错误通过 ? 提前返回并传递给调用者处理。把上面的 debug_data 作为参数传入函数。starter code 为我们提供了通过当前指令地址(%rip 寄存器)获取函数名和在源码中行号的函数 get_function_from_addr 和 get_line_from_addr。我们只需要用 ptrace::getregs 获取当前 %rip 的值,然后在 debug_data 上调用这两个函数就可以得到函数名和对应的行号了。然后我们需要跳转到上一个栈帧,这里我们获取 %rbp 的值,注意到函数的返回地址储存在 %rbp + 8(64 位机器上字长为 8)的位置,我们只需要把 %rip 更新到内存中 %rbp + 8 地址上的值即可跳转到上一个函数。而上一个栈帧的地址恰好储存在 %rbp 指向的内存上,因此我们需要把 %rbp 更新到内存中 %rbp 地址上的值。而读取进程的内存我们需要借助 ptrace::read 函数。这样我们就可以在循环中打印出所有栈帧的信息了。下面为 print_backtrace 函数的实现。
pub fn print_backtrace(&self, debug_data: &DwarfData) -> Result<(), nix::Error> {
let regs = ptrace::getregs(self.pid())?;
let mut %rip = regs.%rip as usize;
let mut %rbp = regs.%rbp as usize;
loop {
let func = debug_data.get_function_from_addr(%rip)
.unwrap_or(String::from("Unrecognized function"));
let line = debug_data.get_line_from_addr(%rip)
.unwrap_or(Line {file: String::from("Unrecognized file"), number: 0, address: 0 });
println!("{} ({})", func, line);
if func == "main" { break }
%rip = ptrace::read(self.pid(), (%rbp + 8) as ptrace::AddressType)? as usize;
%rbp = ptrace::read(self.pid(), %rbp as ptrace::AddressType)? as usize;
}
Ok(())
}注意 Rust 中的基础类型转换需要使用 as Type 显式声明,循环在遇到 main 函数时 break。因为函数返回类型为 Result<(), nix::Error>,别忘了在最后返回 Ok(())。这样我们就实现了打印回溯的功能。
接下来我们运行 samples/segfault 测试一下。这个程序会在调用 func1 和 func2 后 segmentation fault,我们在这个时候打印 backtrace 看看效果。
cargo run samples/segfault
(deet) r
Calling func2
About to segfault... a=2
Child stopped (signal SIGSEGV)
Stopped at /deet/samples/segfault.c:5
(deet) bt
func2 (/deet/samples/segfault.c:5)
func1 (/deet/samples/segfault.c:12)
main (/deet/samples/segfault.c:15)
(deet) 可以看到我们成功打印出了回溯信息。
Take Away: 关于 Rust 中的 ? 操作符
? 操作符是 Rust 中的一个语法糖,它可以在当前函数的返回类型为 Result 或 Option 时提前返回错误或 None。比如下面的代码:
fn read_file(file: &str) -> Result<String, io::Error> {
let mut f = File::open(file)?; // 如果 File::open 出现错误则提前返回对应的 Err
let mut s = String::new();
f.read_to_string(&mut s)?; // 同上
Ok(s)
}在 File::open 和 f.read_to_string 出现错误时会提前返回错误。这样的代码更加简洁易读。
另外,我们可以在 Result 类型中使用 ok 函数把 Result 类型转换为 Option 类型,这样当 Result 类型为 Err 时会转换为 None。如下面的代码:
let stat = inferior.wait(None).ok()?; // 如果 inferior.wait 返回 Err 则转换为 None 并提前返回Aside: 为什么使用 loop 而不是 while true
考虑下面的代码:
let x;
loop { x = 1; break; }
println!("{}", x)let x;
while true { x = 1; break; }
println!("{}", x)第一段代码中的 loop 确保至少执行一次,因此在编译期间 rustc 可以保证 x 一定被初始化。而在第二段代码中, while 语句的语义为 while expr { stmt },rustc 不能在编译期保证 expr 一定为真,从而不能保证循环至少被执行一次,于是判断 x 可能没有被初始化而在下面的 println! 使用,引发安全错误。而如果把 while true 当作特例,则破坏了语言的一致性,因此 Rust 选择了现在的设计。
另外,loop 语句为 expression 而 while 语句为 statement。因此 loop 语句一定有一个返回值,在下面的代码中,while true 不能代替 loop 语句。
let x = loop { break 1; }因此,Rust 推荐在无限循环中使用 loop 语句而非 while true 语句,编译器也会在使用 while true 时给出 warning。
Aside: 关于函数调用栈
在现代 CPU 中,有硬件实现的返回地址栈来储存函数的返回地址,为 CPU 的分支预测提供信息。当执行 ret 指令时,分支预测模块直接从返回地址栈中 pop 出返回地址给 PC,提高分支预测的准确性。
Milestone 4: Print stopped location
这个里程碑的内容相对简单,要求我们在打印程序暂停的位置(对应指令的地址)。我们只需要在前面提到的 print_status 函数中为 Status::Stopped 状态的处理加上这个信息即可。要利用了上个里程碑的 debug_data 获取指令所在的文件和其位置。注意 match 的语法,Some 里面为 Status 的类型,而匹配到的两个变量 sig 和 rip 可以在对应的 block 里面使用。
fn print_status(&self, status: Option<Status>) -> Option<Status> {
match status {
Some(Status::Exited(code)) => { println!("Child exited (status {})", code); return status; },
Some(Status::Stopped(sig, rip)) => {
println!("Child stopped (signal {})", sig);
if let Some(line) = self.debug_data.get_line_from_addr(rip) {
println!("Stopped at {}", line);
}
return status;
}
None => { println!("continue fails!"); None }
_ => { None } // other cases
}
}Milestone 5: Setting breakpoints
在这个里程碑里,我们需要实现地址断点(breakpoint)的功能。用户可以在某些地址上设置断点,程序运行到这些地址时就会暂停。
首先我们在 debugger_command.rs 里面加上断点命令,返回输入的断点地址,之后我们在 Debugger 的命令处理部分再作检查和解析。注意到在程序开始之前和程序运行之中(使用 Ctrl-C 暂停)用户都可以设置断点,我们需要把断点位置保存起来,这里我们使用一个 Vec::usize。
那么我们如何在程序中设置断点呢?方法十分简单粗暴,我们直接把对应地址的指令换成一个 INT3 指令(0xcc)即可,程序运行到这个指令时会中断并发出 SIGTRAP 信号,从而被 Debugger 捕捉到后暂停。因为 INT3 指令只有一个字节,而 nix 库提供的 ptrace 不能单独写入一个字节,所以需要把整个字(8 个字节)读出来,通过位运算换掉指定位置的字节,再写入。stater code 为我们提供了一个写入指定地址内存字节的函数 write_byte。
这样我们的实现思路就很清晰了:首先通过命令获取断点地址,储存在一个 Vec 中,在生成 Inferior 的时候安装断点,即对于每个断点位置,把对应地址换成 0xcc,如果在程序运行过程中用户指定了新的断点,就再把 Vec 中的断点安装一次即可。这时候你可能有疑问,我们把指令换掉,程序不就没法正确运行了吗?是的,而解决的方法也很简单,我们需要把换掉的字节储存起来,在合适的时候换回去,这个从断点继续的功能将在下一个里程碑中实现。
下面是代码实现。
设置断点命令,把跟着的断点地址们返回。
"b" | "break" | "breakpoint" => { Some(Breakpoint(tokens[1..].iter().map(|s| s.to_string()).collect::<String>())) }检查并解析断点地址,地址应该以 * 开头。然后把地址 push 进 Vec::usize 类型的 self.break_points 中。
DebuggerCommand::Breakpoint(target) => {
if target.starts_with('*') {
if let Some(addr) = Debugger::parse_address(&target[1..]) {
self.break_points.push(addr);
println!("Set breakpoint {} at {:#x}", self.break_points.len()-1, addr);
}
else { println!("Invalid address."); }
}
else { println!("Invalid breakpoint target."); }
}在 Inferior 的 new 函数和 go 函数中安装断点。这里的 breakpoints 是前面 Vec 的引用,因此在后续程序暂停时如果 Debugger 加入新的断点,Inferior 里面也是可见的。
breakpoints.iter().for_each(|&x| { Inferior::write_byte(inferior.pid(), x, 0xccu8).ok(); })另外,之前提到的 debug_data 提供一个 print 函数,可以打印解析到的调试信息。下面是手册的样例。
------
samples/segfault.c
------
Global variables:
Functions:
* main (declared on line 14, located at 0x4005b4, 21 bytes long)
* func1 (declared on line 9, located at 0x400571, 67 bytes long)
* Variable: a (int, located at FramePointerOffset(-20), declared at line 9)
* func2 (declared on line 3, located at 0x400537, 58 bytes long)
* Variable: a (int, located at FramePointerOffset(-20), declared at line 3)
Line numbers:
* 3 (at 0x400537)
* 4 (at 0x400542)
* 5 (at 0x400558)
* 6 (at 0x400562)
* 7 (at 0x40056e)
* 9 (at 0x400571)
* 10 (at 0x40057c)
* 11 (at 0x400588)
* 12 (at 0x4005b1)
* 14 (at 0x4005b4)
* 15 (at 0x4005b8)
* 16 (at 0x4005c7)Milestone 6: Continuing from breakpoints
这个里程碑我们需要实现从断点继续的功能。上面为了实现断点,我们把程序对应地址指令的第一个字节改成了 INT3,现在我们需要在程序继续执行后恢复原来的指令,并确保执行那条被改掉的指令。因此我们需要把断点处换下来的字节储存起来以便后续恢复,这里我们使用一个 HashMap<usize, u8> 储存断点地址和原字节的映射。
在安装断点时,write_byte 函数会返回原字节,我们直接 insert 到 HashMap 里面即可。
inferior.breakpoint_map.insert(x, Inferior::write_byte(inferior.pid(), x, 0xccu8).ok().unwrap());这里 insert 函数返回一个 Result,我们用 .ok().unwrap() 来解开,这会导致 Result 为 Err 时 panic。不用 ? 是因为 for_each 函数要求里面的闭包返回 (),而打了问号之后会返回一个 Option,不符合要求。但是我还是想用这样的函数式编程风格😋️。从另一个角度看,如果哈希表插不进去也是时候 panic 了。
接下来我们看看如何恢复原来的指令并继续运行程序。我们首先需要把原来的字节换回去,但是这样我们设置的断点就没了,如果断点设置在循环或者函数里面,这个断点在这之后就失效了。因此我们需要在恢复原来的指令后执行那一条指令,再把那一条指令换成 INT3 指令(相当于再安装一次断点),然后再让程序继续执行。
现在我们修改 go 函数来处理从断点继续的情况。注意到 INT3 指令只有一个字节,因此 CPU 在执行完中断之后会把 %rip 寄存器往前移一个字节,这样在获取寄存器值之后,如果我们能从上面的 HashMap 中找到一个断点地址使得 %rip - 1 == 断点地址,就说明这次的 go 函数调用是否来自 continue 命令。
let mut regs = ptrace::getregs(self.pid())?;
if let Some((&addr, &byte)) = self.breakpoint_map.get_key_value(&((regs.rip - 1) as usize))然后我们需要把原来的字节用 write_byte 函数换回去。
unsafe { Inferior::write_byte(self.pid(), addr, byte)? }接下来我们要执行这一条指令。注意到现在 %rip 寄存器在断点地址 + 1 的位置上,更换完字节后 %rip 寄存器指向原指令的第二个字节,因此我们需要重置 %rip 寄存器到 %rip - 1 的位置,再用 ptrace::setregs 函数写入寄存器。
注意x86架构支持变长的指令,为了确保INT3换掉的是原指令的第一个字节,用户指定的断点地址也必须是指令的第一个字节,否则程序会在重置%rip寄存器后解码到错误指令而无法恢复运行。
regs.rip = regs.rip - 1;
ptrace::setregs(self.pid(), regs)?;上面提到我们还需要把断点安装回去。因此我们用 ptrace::step 单步执行原指令,等待程序暂停,再把 0xcc 换回去。然后再调用 ptrace::cont 即可正常继续执行程序,注意最后要调用 wait 函数接收程序发出的下一个信号。
ptrace::step(self.pid(), None)?;
// 这个 match 写成了 if let 更简洁,懒得改了😋️
match self.wait(None) {
Ok(Status::Stopped(Signal::SIGTRAP, _)) => {
Inferior::write_byte(self.pid(), addr, 0xccu8)?;
},
_ => {},
// no need to handle Exited since next cont is called.
}
ptrace::cont(self.pid(), None)?;
self.wait(None)最后在 debugger.rs 的 run 函数中加上 continue 命令的处理即可,注意程序未在运行和程序结束的情况。
DebuggerCommand::Continue => {
if self.inferior.is_none() {
println!("The program is not being run.");
continue;
}
let status = self.inferior.as_mut().unwrap().go().ok();
if let Some(Status::Exited(_)) = self.print_status(status) {
self.inferior = None;
}
},这样我们就实现了从断点继续运行的功能。
Milestone 7: Setting breakpoints on symbols
这个里程碑我们需要实现在函数名和行号上设置断点的功能。我们只需要从调试信息中找到函数名或行号对应的地址即可。starter code 为我们提供了 get_addr_for_line 和 get_addr_for_function 两个函数来找到对应的地址。
接下来我们需要解析用户输入的断点目标,直接看代码。
DebuggerCommand::Breakpoint(target) => {
if target.starts_with('*') {
if let Some(addr) = Debugger::parse_address(&target[1..]) {
self.break_points.push(addr);
println!("Set breakpoint {} at {:#x}", self.break_points.len()-1, addr);
}
else { println!("Invalid address."); }
}
else if let Ok(line_no) = target.parse::<usize>() {
if let Some(addr) = self.debug_data.get_addr_for_line(None, line_no) {
self.break_points.push(addr);
println!("Set breakpoint {} at {:#x}", self.break_points.len()-1, addr);
}
}
else if let Some(function) = self.debug_data.find_function(target) {
if let Some(addr) = self.debug_data.get_addr_for_function(None, &function) {
self.break_points.push(addr);
println!("Set breakpoint {} at {:#x}", self.break_points.len()-1, addr);
}
}
else { println!("Invalid breakpoint target."); }
if self.inferior.is_some() {
self.inferior.as_mut().unwrap().install_breakpoints(&self.break_points);
}
}第一个 if 检查 target 是否为地址;第二个 if 检查 target 能否 parse 为一个数字,能的话当成行号处理;如果前两个都不符合,第三个 if 把 target 当成函数名,并调用 find_function 函数检查调试信息中有没有这个函数。find_function 的实现如下,该函数位于 dwarf_data.rs 文件的 impl DwarfData 中。
pub fn find_function(&self, function_name: String) -> Option<String> {
self.files.iter().find_map(|file| -> Option<String> {
file.functions.iter().find_map(|func| -> Option<String> {
if func.name == function_name {
Some(function_name.clone())
}
else { None }
})
})
}其中的 find_map 函数会在参数中提供的闭包返回 Some 时返回指定类型,否则返回 None。
这样我们就完成了在函数名和行号上设置断点的功能。
收工
至此我们完成了所有的里程碑,手册中还有一些 Optional 的功能可以添加到我们的 debugger 中。
完结撒花!🎉


































































































































