Table of Contents
1. How I wrote the vdebugger, a debugger in Rust for Linux Programs ?
As a passionate developer always looking to expand my skills, I recently embarked on an exciting project: creating a debugger in Rust for Linux programs. This article chronicles my journey, from setting up a reproducible development environment to implementing the core functionality of the debugger.
You can check the project at : the vdebugger repo
1.1. Setting Up the Development Environment
I began by creating a robust and reproducible development environment using Nix. Here's how I set it up…
It's a classic flake.nix file that contains
flake-utilsto automatically generate outputs and devShells for various systems- Employed
rust-overlayto pull the latest Rust version directly.
That's all ! The final nix file looks like this :
{
description = "A nix file for my homemade debugger";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rustVersion = pkgs.rust-bin.stable.latest.default;
rustPlatform = pkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
vdebuggerBuild = rustPlatform.buildRustPackage {
pname = "vdebugger";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
};
in
{
defaultPackage = vdebuggerBuild;
devShell = pkgs.mkShell {
buildInputs = [ (rustVersion.override { extensions = [ "rust-src" ]; }) ];
};
});
}
To run the project and start programming I simply the command nix develop that permits to access my devShell.
The rust overlay feature permit to automatically build a rust development platform with cargo and the rust compiler. Pinning the nix version to the 24 permit to always pull the same version of the rust compiler which is the 1.79 in this case.
1.2. Laying the Groundwork
With the environment ready, I dove into the core concepts of debugger development. I quickly realized that a debugger operates as a parent process controlling a child process(the debugee). This led me to search for a Rust crate that could provide functionality similar to the C fork() system call.
My search led me to the nix crate, which offers Rust bindings for many POSIX APIs, including most C system call functions. This crate became instrumental in my project
1.3. Implementing Basic Debugger Functionality
1.3.1. Process Creation and Control
In my main function, I implemented the following steps:
- Created a new process using the
fork()function from thenixcrate. - Used the
traceme()function (equivalent to theptrace(PTRACE_TRACEME, ...)in C) to permit to the parent function to trace the child. - Executed the program to be debugged in the child process using the
execv()function.
1.3.2. The Debugger Struct
To organize the debugger's functionality and have a better management of the parent process flow, I created a Debugger struct. The fields of this struct are :
prog_name: The name of the program being debuggedpid: The process ID of the child process we're controlling
1.3.3. The Run Function
The heart of our debugger is the run function. Here's how it operates:
- It waits for the child process to start using the
waitpidfunction from thenixcrate. This ensures that we don't begin debugging until the child process is ready. - Once the child process is running, it creates a command-line prompt using the
linefeedcrate. This crate not only handles user input but also provides history functionality, enhancing the user experience.
1.3.4. Command Handling
To manage the various debugging commands, I implemented the following system:
- Created an enum called
Commandwith variants representing different debugging actions (e.g., continue, step, break, …). - Utilized the
strum_macroscrate to easily translate string input from the prompt into the correspondingCommandvariant. (a command break/Break/BREAK/bReak is translated without taking into account the case to the variant BREAK) - Implemented a
handle_commandfunction that:- Attempts to parse the user input into a
Commandvariant (like in the point 2) - If successful, calls the appropriate function for that command
- If the input doesn't match any know command, it informs the user that the command doesn't exist
- Attempts to parse the user input into a
This approach provides a flexible and extensible way to add new debugging commands in the future.
1.3.5. Implementing Commands
- Continue
When the user enters the "continue" command,
handle_commandrecognizes it and calls thecontinuecommand. The continue function uses thecontfunction from the nix crate to send a CONTINUE instruction to the debuggee program with its pid. (PTRACECONT request sent to the tracee)This allows the debugged program to continue execution until the next breakpoint or until it terminates.
- break
A crucial feature of any debugger is the ability to set and manage breakpoints. In my implementation, I've created a Breakpoint structure that encapsulates all necessary information for each breakpoint. Let's delve into the details of how this is implemented and used within the debugger.
- The brekpoint structure
The Breakpoint structure contains the following fields:
pub struct Breakpoint { pid: Pid, addr: *mut c_void, saved_data: i64, enabled: bool, }- pid: The process ID of the debuggee.
- addr: The memory address where the breakpoint is set.
- saveddata: The original byte of data at the breakpoint address.
- enabled: A boolean flag indicating whether the breakpoint is currently active.
(It may be slightly different in the code because I used dependency injection to be able to test data retrieval).
- Setting a breakpoint
The process of setting a breakpoint involves several steps:
- User Input: The debugger prompts the user to enter the address where they want to set a breakpoint, typically provided as a hexadecimal string.
Reading Original Data: Before setting the breakpoint, the debugger reads the original instruction at the specified address using the
readfunction, which corresponds to theptrace(PTRACE_PEEKDATA,...)function in C.let current_line = ptrace::read(self.pid, addr);Inserting the Breakpoint: The debugger replaces the least significant byte of the instruction with the int3 interrupt instruction (encoded as 0xCC). This sends a SIGTRAP signal to the process when executed.
let int3 = 0xcc; let breakpoint_line = (current_line & !0xff) | int3; self.saved_data = (current_line & 0xff) as i64;Writing the Breakpoint: The modified instruction is written back to the address using the write function from the nix crate.
ptrace::write(self.pid, addr as *mut c_void, breakpoint_line as *mut c_void);
- Disabling a Breakpoint
The process of disabling a breakpoint essentially reverses the steps taken to set it:
Reading Current Data: Read the current data at the breakpoint address.
let line = ptrace::read(self.pid, self.addr as *mut c_void)?;Restoring Original Instruction: Compute the restored line by combining the original saved byte with the rest of the current instruction.
let restored_line = (line & !0xff) | self.saved_data as i64;Writing Restored Data: Write the restored instruction back to the address.
ptrace::write(self.pid, self.addr as *mut c_void, restored_line as *mut c_void)?;
- Adjusting Program Counter: After disabling the breakpoint, the program counter is adjusted to go back to the previous line, allowing execution to continue from the correct point.
- Software vs. Hardware Breakpoints
This implementation uses software breakpoints, which offer several advantages:
- Unlimited Breakpoints: Unlike hardware breakpoints, which are limited in number by the CPU architecture, software breakpoints allow for an unlimited number of breakpoints.
- Portability: Software breakpoints work consistently across different hardware architectures.
- Flexibility: They can be easily set and unset dynamically during the debugging process.
While hardware breakpoints can be more efficient in some cases, their limited number (often 4 or 8 on most CPUs) makes them less suitable for complex debugging scenarios that require many breakpoints.
- Challenges and Considerations
Implementing breakpoints in this way comes with several challenges:
- Atomic Operations: Ensuring that the process of reading, modifying, and writing instructions is atomic to prevent race conditions.
- Instruction Alignment: Careful handling is required to ensure that breakpoints are set at the beginning of instructions, especially in architectures with variable-length instructions.
- Performance Impact: Software breakpoints modify the program's code, which can impact performance, especially with many breakpoints.
- Multi-threading : Special care is needed when dealing with multi-threaded programs to ensure breakpoints work correctly across all threads.
- Handling Signals : Proper handling of the SIGTRAP signal is crucial for the debugger to function correctly.
- The brekpoint structure
- register
To enable the continuation of execution after hitting a breakpoint, it is essential to reset the program counter to the previous instruction. This involves implementing functionality to manipulate the registers of a program, particularly the program counter.
- Register Functions Implementation
The register module provides functionality to interact with the CPU registers of the debugged program. The key components are the Reg enum, the RegDescriptor struct, and functions to get, set, and manipulate register values.
#[derive(Debug, Clone, Copy, PartialEq, EnumString)] #[strum(ascii_case_insensitive)] pub enum Reg { /*...*/ } #[derive(Debug, PartialEq, Clone, Copy)] pub struct RegDescriptor { /*...*/ } pub static REGISTERS_DESCRIPTORS: &[RegDescriptor] = &[ /*...*/ ];- Reg : enumerates all the registers available in the CPU
- RegDescriptor : contains metadata about each register, including its name and DWARF index.
- Key functions
Register value getters and setters :
pub fn get_register_value(pid: Pid, r: Reg) -> Result<u64, nix::Error> { /*...*/ } pub fn set_register_value(pid: Pid, r: Reg, value: u64) -> Result<(), nix::Error> { /*...*/ }
These functions interact with the actual register values of the debugged process, permitting to modify their values or to retrieve them.
Another way of retrieving register value :
pub fn get_register_value_from_dwarf_register(pid: Pid, reg_num: i32) -> Result<u64, nix::Error> { /*...*/ }
This function permit to get the register value using its dwarf number.
- Modifying the debugger core logic
Introducing a new stepoverbreakpoint function :
fn step_over_breakpoint(&mut self) { /*...*/ }
This function permits to step over a breakpoint. I start by moving back the program counter to the previous line (because the line where the breakpoint is needs to also be executed and the execution of the line with int3 interruption moves the program counter to the next line). Then I disable the breakpoint which restores the original lines where the breakpoint was (removing the int3 instruction). I finally send a step signal to the debuggee and at the end of the program when the main debugger program takes the control back, I re-enable the breakpoint.
Modifying the continueexecution function :
To perform the correct logic, I've added a call to the stepoverbreakpoint function in the beginning ot the continueexecution. Because the stepoverbreakpoint perform verification about the existence of a breakpoint before applying its logic this doesn't affect the code in case there's no breakpoints.
Adding new register manipulation commands :
To be able to manipulate registers in the debugger, I introduced new commands like
register read(to get a particular register value),register write(to set a particular register value), andregister dump(to display all registers values).
- Challenges and Learnings
Developing this debugger presented several challenges:
- Understanding the intricacies of process control and the ptrace system call.
- Mapping C concepts and functinos to their Rust equivalents.
- Implementing a robust command parsing and handling system.
However, shese challenges provided valuable learning experiences. I gained a deeper understanding of:
- Low-level system interactions in Rust
- The architecture of debugging tools
- Effective use of Rust crates to simplify complex tasks
- Register Functions Implementation
1.4. Conclusion
Creating a debugger in Rust for Linux programs has been an enlightening journey. It has deepened my understanding of both Rust and the underlying mechanics of debugging tools. The project showcases the power of Rust in systems programming, demonstrating how it can be used to create robust, safe, and efficient tools for low-level system interactions.
This experience has not only improved my Rust programming skills but also given me a newfound appreciation for the complexity and ingenuity behind the development tools we use every day.