A continuation from the previous post, on writing a debugger in Rust for x86_64 GNU/Linux…
So far, we have been working inside the main.rs file supplied to us when creating a Rust project with cargo new. We are already creeping up on 250 lines in this single file, and to add to this, not all of these lines look very professional. Let’s take small detour and try to break up our logic into a library, apply some design patterns, as well as clean up the amateur mistakes made thus far!
Creating an Accompanying Library Crate
The basic compilation unit seen by the Rust compiler is the crate. Crates come in two forms, binary and library. Binary crates are executable programs that you can run and operate. Library crates are, of course, libraries. Shareable pieces of code that provide functionality for binary crates. For any given Rust package (yes, packages != crates), it has to have at least one crate, any number of binaries, but one library crate. That library crate acts as the shareable portion of code.
Now, our project is definitely something that should have a binary crate, given that it is something that is executable. For the sake of organization and cleanliness, an accompanying library crate might be fitting. Let’s move everything that is not the main() function to a library crate. There are a few ways we can do this, but here I will separating the code into another directory, and including a mod.rs file. I separated my debugger loop, main logic into dbg.rs, and the command code into command.rs. In preparation for future, included some files for both debug symbols and breakpoints.
src
├── main.rs
└── traceedb // separate your resusable code here in logical ways
├── breakpoint.rs
├── command.rs
├── dbg.rs
├── mod.rs // library level definition, what do we ship?
└── symbol.rs//mod.rs
pub mod breakpoint;
pub mod command;
pub mod dbg;
pub mod symbol;
// main.rs
mod traceedb;
use traceedb::dbg;
use traceedb::command;
Recovering Gracefully, Stopping the panic!()
In Rust, there are, generally speaking, two ways we can represent failure of an operation. They are called panics and results.
A panic in the typical sense is a process of killing an entire thread, unwinding stack, invoking destructors, releasing resources, and stopping the program. It is supposed to be used in the event of an irrecoverable situation, a crash-and-burn response to a catastrophic error in the program. They can be programmed anywhere within Rust with the panic!() macro. They are also baked into the standard library in meaningful ways, such as failed assert statements.
A result, or Result<T, E>, in Rust is a type represents a value that could optionally hold a value Ok(T), or the other variant of Err(E). This is the Rust version of an exception. They are also strewn about the std in the ways one would expect, such as attempting to open a file, connect with a socket, firing off a system call from the Nix crate. This is a very common type in Rust, along with it’s sister, Option<T>.
One of the major differences between an amateur programmer and an experienced programmer in Rust is how many times their piece of code panics. Panics should not be triggered in any part of the code without good reason. If a piece of code can immediately die in a variety of ways, then it is the furthest thing from a robust program. This is not to say that Rust novices intentionally litter panic!() everywhere, rather they invoke it implicitly without thinking. For example, taking the value out of an option which is None, equivalent to dereferencing a null pointer, results in a panic. Taking the value out of a result that is Err(E)? Panic. It is my understanding that a significant portion of panics in bad Rust code are the result of not knowing how to work with results or options.
I am rather guilty of this myself with this codebase so far, but I do not use unwrap(). I use expect(), which at the very least provides an explicit error message, but still panics if that value is not Ok(T).
Ok(WaitStatus::Stopped(_, Signal::SIGTRAP))
| Ok(WaitStatus::Stopped(_, Signal::SIGSTOP)) => match accept_user_input() {
Command::Quit => {
ptrace::kill(target_pid) // ptrace returns a result Result<(), Errno>
.expect("Failed to kill process!");
// expect triggers a panic if result is Err(E), not Ok(()) here
break 'outer;
}
Command::Step => {
ptrace::step(target_pid, None) // Similar thing down here
.expect("single step ptrace message failed!");
continue 'outer;
}Any ptrace system call can fail. It returns a result. This means, that most of my commands have the possibility to stop the debugger dead in its tracks! Not very robust. Solving this problem requires stopping one from using unwrap() or expect(), and using a match, an if-let, or more nuanced control based on result variants. Alternatively, propagating a result up the stack is also a solution in some cases.
Command::Quit => {
if let Err(errno) = ptrace::kill(target_pid) {
eprintln!("Err on kill: ....")
}
break 'outer;
}
Command::Step => {
if let Err(errno) = ptrace::step(target_pid, None) {
eprintln!("Err on step...")
}
continue 'outer;
}
This is a step towards writing more robust code. Avoid lazily implicit panics, and use powerful pattern matching to control the flow of code that represents paths where things do not go as planned. We will return to our commands later in this post.
Leveraging More Functional Rust
Rust, like C, identifies itself as a low-level imperative language. Everything works under zero cost abstractions due to the fact, at some point in the code, we instruct the machine to do exactly what we say. However, Rust also embraces certain aspects of the functional programming–A wonderful world of lambdas, iterators, monads, and high-order functions. With this paradigm, it’s less about how you do something, and more about what you do. This amounts to orchestrating existing functionality, stringing identifying functions, methods, and closures together in a transparent way. By leveraging such Rust, we can create code that is a bit more succinct, a bit less repetitive, but still quite transparent.
As an example, think of everywhere in the current codebase that uses the common Rust types Option<T>, Result<T, E>, and any iterator implementing the Iterator trait. Within the interface for these types, there is a wealth of high-order functions that allow us to pass closures that do a variety of things within these types. Let’s look at the parsing for the read command that uses the conditional if let … destructuring.
"r" | "read" => {
if let Some(input_str) = args_iter.nth(0) {
if let Ok(addr) = usize::from_str_radix(input_str, 16) {
Ok(Box::new(ReadWord {
addr: addr as *mut c_void,
}))
} else {
Err("Failed to parse address: please supply hex value!")
}
} else {
Err("Missing the address to read from!")
}
}There is nothing inherently wrong with the above code. You can see that it simply handles two cases where a parsed read command has 1) a lack of an argument specifying an address to read from (args_iter.nth(0) is None) and 2) a failure to parse the hex address using usize::from_str_radix. This code is imperative, and more concerned with the how. Note also that there is both a Rust Option and Result at play here. Tapping into the functional paradigms gives us…
"r" | "read" => {
let result = args_iter
.nth(0)
// get the first argument passed to read
.ok_or("Missing the address to read from")
// transpose that Option to a Result, Some(n) -> Ok(n), None -> str
.and_then(|arg| {
// If that Result is Ok(val) -> Try to parse a
// produce another Result with closure, or propagate error down.
usize::from_str_radix(arg, 16)
.map_err(|_| "Failed to parse: please supply hex value!")
});
// Handle the result
match result {
Ok(addr) => Ok(Box::new(ReadWord {
addr: addr as *mut c_void,
})),
Err(err_msg) => Err(err_msg),
}
}The resulting code is less indented, and less repetitive with condition handling. In fact, the only visible control flow is the match at the bottom. Consider the write command as well, which is a bit more cluttered due to the fact that we parse two arguments instead of one.
"w" | "write" => {
if let (Some(write_addr), Some(write_word)) = (args_iter.next(), args_iter.next()) {
if let (Ok(parsed_addr), Ok(parsed_word)) = (
usize::from_str_radix(write_addr, 16),
usize::from_str_radix(write_word, 16),
) {
WriteWord {
addr: parsed_addr as *mut c_void,
val: parsed_word as *mut c_void,
}
} else {
Err("Failed to parse args for writing word!")
}
} else {
Err("Insufficient arguments for command!")
}
}
// Versus
"w" | "write" => {
let mut res = args_iter
.take(2)
// Only two operands for write command are needed, ignore the rest
.map(|arg| usize::from_str_radix(arg, 16));
// apply closure to 2 elements, producing Results from parsing for hex
match (res.next(), res.next()) {
(Some(Ok(addr)), Some(Ok(val))) => WriteWord {
addr: addr as *mut c_void,
val: val as *mut c_void,
},
_ => Err("Failed to parse args for writing word!"),
}
}Applying functional rust to the write command parsing results in half the lines of code! Note how functions stack onto each other, where the result of one is the caller of the next. It is not unknown for a Rust statement to spans multiple lines due to this stacking of function calls. Consider the parsing of a PID for run_get_pid_dialogue:
pub fn run_get_pid_dialogue() -> Pid {
let mut input = String::new();
let mut pid: Result<Pid, &str>;
loop {
print!("Please enter a target PID: ");
stdout().flush().unwrap();
stdin()
.read_line(&mut input)
.expect("Failed to read in line from input!");
pid = input.as_str()
.trim() // trim off leading, trailing whitespace
.parse::<i32>() // attempt to parse produce Result
.map(Pid::from_raw) // If result successful, apply function
.map_err(|_| "Please supply an integer for PID!")
// Otherwise, parse failed, map to string error
.and_then(|pid| {
// If first parse was successful, check to see if the
// process exists, producing another Result
if let Ok(_) = kill(pid, None) {
Ok(pid)
} else {
Err("Process does not exist!")
}
});
if let Ok(_) = pid { break; }
input.clear();
}
pid.unwrap()
}This type of function composition is common in Rust code. One will usually stack these line by line, forming a patchwork of functions that execute on basis of whether an option is Some or None, or whether a result is Ok or Err. Functions like map() and and_then(), accept functions, closures that are run to either “map” the value of Some or Ok to another value, or replace the option or result with another one entirely. Less indentation, less blocks and explicit condition handling, and better readability. As a Rust developer, one should think of how to compose code in this way. I find it quite addictive write stack function calls in this way myself!
Encapsulating Debugger Logic in an Internally Mutable Struct
If one has any background programming in an object-oriented programming language like Python, C++, Java and the like, one might wonder to themselves if there is any benefit to encapsulating certain pieces of the code in objects and applying whatever dogma they adhere to when it comes to OOP. Such a thought comes to a halt when one realizes that Rust does not have the typical OOP toolset. Inheritance, classes, multi-level abstractions–all of these things we take for granted in modern programming languages do not exist in Rust!
While some might see this as a flaw, the language was designed with this intention in mind. Although OOP has its acolytes, there is quite a bit of contrarian push back on the paradigm, and it is steadily gaining traction. I won’t delve into such religious contentions in this post, but I will touch on how one might abstract a debugger into a Rust object with a flat interface.
If we defined our debugger in terms of simple struct, we might come up with some basics, such as the executable we want to run, the debug symbols, etc. These are realized as fields. Giving functions to the objects, whether they may be instance or class methods, is achieved by just implementing them with impl.
pub struct TraceeDbg {
program: Option<String>,
breakpoints: ...
symbols: ...,
}
impl TraceeDbg {
pub fn run(self) {
//...
}
fn run_target(self, prog_name: &CStr) {
//...
}
fn run_debugger(self, target_pid: Pid) {
//...
}
fn prompt_user_cmd(&self, target_pid: Pid) -> Command {
//...
}
// Whatever else you want to add
}We can take the logic we have thus far and place it here in the impl block, where the run function can hold the forking and creation of tracer and tracee, which call the owned methods run_target and run_debugger. Note the first argument to these functions is self. Not dissimilar to Python, it is name of the object that calls the function. In this case, the method consumes the object, receiving full ownership of self. One cannot run another method on self again after having run the debugger! Ownership can be passed down to the private methods as well. If we were to add methods that did not take ownership, references of self, &self and &mut self would be used instead. Furthermore, if one were to create a method tied to no instance at all (think class method), the self argument would be omitted entirely, and invocation would be done via TraceeDbg::<non-instance-method-name>().
This is quite straightforward, but there is one thing to consider here: an abstraction of a debugger over a struct is likely to have a lot of mutable state. During runtime, breakpoints will be freely added and removed by user, PIDs selected, debugging symbols loaded. These are typical events that may happen over the course of run(). Objects in Rust must be explicitly declared as mutable with mut, otherwise they are immutable and cannot be mutated.
One can, however, move this static analysis done during compilation to runtime with the help of std::cell. Cells in Rust are special objects that can be freely mutated at will, even with they are not declared mut. It enforces the same rules of borrowing and mutability that the compile enforces by moving them to runtime. Meaning there can be any number of immutable references to the cell, or only one mutable reference to the cell at any given time while the program runs. If these rules are violated, a panic!() is triggered, and the process dies. Using cells is a good fit here, since our debugger is bound to have internally mutable state.
use std::cell::RefCell;
pub struct TraceeDbg {
program: RefCell<Option<String>>,
breakpoints: RefCell<Vec<Breakpoint>>,
symbols: RefCell<...>,
}
// One can create a debugger object either as immutable or mutable
// The result is still the same, RefCell'd fields can be mutated regardless
let debugger = TraceeDbg::new(...)
runtime_tracked_ref_to_breakpoints: RefMut<'_, Vec<Breakpoint>> =
debugger.breakpoints.borrow_mut()
ref_to_breakpoints.insert(new_breakpoint);
// And so on and so forth for fields of the debugger structUsing the Builder Design Pattern
As we continue to evolve the composition of this debugger struct, it grows increasingly apparent that the call sites for creating it become quite lengthy. Not exactly something we want to type out on the daily just to create a debugger object. Consider when we are constructing it from arguments passed from the command line.
let target_program = env::args().map(|arg| CString::new(arg).unwrap()).nth(1);
dbg = TraceeDbg {
program: match target_program {
Some(prog) RefCell::new(Some(prog)),
None RefCell::new(None)
}
breakpoints: RefCell::new(vec!()),
// ...with more of the same following, more RefCell::new()
}This is a bit cumbersome to use in our binary, so let’s make it a bit more simple by adding a constructor. While a standard ::new() Rust constructor would work fine here, there is a design pattern that has been used by a great deal of Rust code that takes advantage of the Default trait and functional programming that makes constructing complex objects a cinch! It is called the builder pattern, discussed on the unofficial rust design patterns Github page.
The final builder I have composes a few new fields that will be touched on later in the next blog post, like a hashmap of breakpoints, and DWARF references. If you want to view it, click here.
Implementing Dynamically Dispatched Commands with Trait Objects
After abstracting away the main logic loop of our debugger into a struct with an interface, one of the next places to turn out attention to would be be the commands. Currently, the debugger parsers commands using string slice matching, and returns a value from the Command enumeration. Depending on what value we return, we then execute based on the match for that return value.
match prompt_user_cmd() {
Command::Quit => {
ptrace::kill(target_pid)
.expect("Failed to kill process!");
break 'outer;
}
Command::Read(addr) => {
ptrace::read(//...);
}
// The rest of seven paths of execution follow
Command::Unknown
}But imagine for a minute we had a better abstraction for this logic. Perhaps something that hid the functionality of each of the variants of the command enumeration, but at the same time provided a means of executing them universally. These commands would return a Result, where an attempt to execute them is made, but it is possible for some of them to fail (read and write commands for example). Parsing could also be given a similar treatment resulting in result chaining with the and_then function.
match prompt_user_cmd() // Parse result for user input, could fail
{
Ok(cmd) => {
match cmd.execute() {
Ok(_) => // handle after execution, coud require wait()
Err(msg) => // handle possible error of execution
}
}
Err(msg) => // handle error of parsing, missing args, unknown command
}
// Or better yet!
match prompt_user_cmd().and_then(|cmd| cmd.execute()) {
Ok(_) => // Handle the success of accepting, parsing and executing cmd
Err(msg) => // Handle the error at any of those stages
}The beautiful thing about the last match clause is that it handles errors from every portion of the command workflow. From parsing the command the user inputs, to the execution of said command. In this manner, we handle erroneously entered commands that lack arguments, non-existent commands, and commands that fail to correctly execute. The only requirement for this is that prompt_user_cmd() and cmd.execute() must have the same Err invariant. Meaning the first cannot return Result<T, int> while the other returns a Result<T, &’static str>.
Imagine for a minute the medium we were programming in was an OOP language like C++. One might implement this Command abstraction using a abstract class, say, Command and derive implementations of execute() via inheritance.
class Command {
public:
virtual void execute(pid_t target_pid) = 0;
}
class Step : public virtual Command {
public:
void execute(pid_t target_pid) {
// Implementation here for step command
}
}
class Continue : public virtual Command {
public:
void execute(pid_t target_pid) {
// Implementation here for step command
}
}
// And so on and so forth...
//Invoking any command becomes an exercise of
Command cmd = prompt_user_cmd();
cmd.execute();Simple right? In this manner, we can simply inherit from the abstract class and implement our execute in the manner we desire. Execution of the command becomes what is termed dynamic dispatch. Given some pointer to an object that may be a number of different objects, we resolve which function to return (step? continue? kill?) at runtime.
While Rust has no concept of classes or inheritance, it does have dynamically dispatched pointers based on traits, dubbed trait objects. What this amounts to is creating an interface that reflects the abstract class we made above under a user-defined trait. With this trait we define the interface, and with a few aptly named structs, we can define the implementation with impl syntax. Here I provide such an interface. On execution, we can succeed and get the tracee’s status, or fail and get a simple error message. Using a result, we can stop the panic! and create a means of recovering from command execution error in the main loop.
pub enum TargetStat {
AwaitingCommand,
Running,
Killed,
}
pub trait Execute {
fn execute(&self, pid: Pid) -> Result<TargetStat, &'static str>;
}
// To implement a new command, one simply implements
// for the command::Execute trait
// Structs are allowed to have no fields, so called "singleton structs"
// Good fit for a command that has no arguments
#[derive(Debug)]
pub struct Step;
impl Execute for Step {
fn execute(&self, pid: Pid) -> Result<TargetStat, &'static str> {
ptrace::step(pid, None)
.map(|_| TargetStat::Running)
.map_err(|err_no| {
eprintln!("ERRNO {}", err_no);
"failed to PTRACE_SINGLESTEP"
})
}
}
#[derive(Debug)]
pub struct Quit;
impl Execute for Quit {
fn execute(&self, pid: Pid) -> Result<TargetStat, &'static str> {
ptrace::kill(pid)
.map(|_| TargetStat::Killed)
.map_err(|err_no| {
eprintln!("ERRNO {}", err_no);
"failed to terminate target process"
})
}
}
// The rest of the commands follow, the commands that have arguments,
// i.e. read and write, have fields for addresses and values passed to ptrace()So we have the implementations of the commands, how do we set up the parser to return any one of these commands? Rust has incredibly robust typing, but thankfully, we do not have to type out every possible type of command that implements the Execute trait. We only need to make use of three things, a pointer type, dyn keyword (meaning a dynamically dispatched pointer) and the trait name. Here a chose the simple heap pointer Box<T> that represents a value T on heap.
// In english, this a possibly fallible return, on success its
// a heap pointer to a trait object implementing the Execute interface,
// on failure, a static string error message.
fn prompt_user_cmd(&self) -> Result<Box<dyn Execute>, &'static str> {
print!("> ");
stdout().flush().unwrap();
let mut user_input = String::new();
while let Err(_) = stdin().read_line(&mut user_input) {
eprintln!(
"Err: Failed to read user input, please enter a proper command!"
);
user_input.clear();
}
let mut term_iter = user_input.split_whitespace();
let (command, mut args_iter) = (term_iter.nth(0).unwrap(), term_iter);
match command {
// Commands with no operands
"reg" | "registers" => Ok(Box::new(ViewRegisters)),
"s" | "step" => Ok(Box::new(Step)),
"c" | "continue" => Ok(Box::new(Continue)),
"q" | "quit" => Ok(Box::new(Quit)),
"h" | "help" => Ok(Box::new(HelpMe)),
// Commands with a single operand
"r" | "read" => {
let result = args_iter
.nth(0)
.ok_or("Missing the address to read from")
.and_then(|arg| {
usize::from_str_radix(arg, 16)
.map_err(|_|
"Failed to parse: please supply hex value!"
)
});
match result {
Ok(addr) => Ok(Box::new(ReadWord {
addr: addr as *mut c_void,
})),
Err(str) => Err(str),
}
}
// Commands with two operands
"w" | "write" => {
let mut res = args_iter
.take(2)
.map(|arg| usize::from_str_radix(arg, 16));
match (res.next(), res.next()) {
(Some(Ok(addr)), Some(Ok(val))) => Ok(Box::new(WriteWord {
addr: addr as *mut c_void,
val: val as *mut c_void,
})),
_ => Err("Failed to parse args for writing word!"),
}
}
}
}
// Note that the "dyn" keyword can work with just about every
// pointer type in Rust! Consider all the ways:
//
// - reference counted smart pointers
Rc<dyn Execute>, Arc<dyn Execute>
// - statically validated references
& dyn Execute, &mut dyn Execute
// - runtime validated references
Ref<'_, dyn Execute>, RefMut<'_, dyn Execute>
// - even raw pointers!
*const dyn Execute, *mut dyn ExecuteAnd there you have it! The result is something that is infinitely more extensible and organized. Note that there are runtime trade-offs with dynamic dispatch. Rust went out of it’s way allocating a keyword just for the sake of this transparency (and for people to weigh the option of static dispatch instead.) However, execution of commands is not bound in a tight loop; users could take seconds before entering the next command. In addition, dynamic dispatch may allow for the debugger to allow the user to define their own commands, but this is a blog post for another time.
Moving On
After this exhaustive refactor (commit here), I think the codebase is situated in such a way that we can move forward to implement some of the more nuanced debugger features. To be specific:
- Code is organized into binary and library crate. No longer a single file, capable of further division based on features and responsibilities.
- Functional Rust is leveraged, turning indented, verbose portions of the code to single indent, syntactically sweet function chains.
- The debugger itself is encapsulated in a internally mutable Rust object that, when consumed with run, forks, runs the infinite loop of the tracer, as well as the tracee process. Constructing said object accomplished with builder pattern.
- Commands are freely extensible and do not require modifications to the main loop and general debugger logic. Dynamically dispatched trait objects sharing the Execute implementation. They also do not panic when they fail, instead return results on execution.
In the next blog post, we will tackle the standard breakpoint implementation on x86_64 GNU/Linux. A single new command that makes for quite the study!
Leave a Reply