probe-rs: Making Embedded Development Feel Like Userspace Programming
Table of Contents
If you've ever developed embedded firmware, you know the pain: cryptic flashing tools, primitive debugging methods, and a workflow that feels decades behind modern software development. You write code, compile it, flash it to hardware through a complex toolchain, and then… hope it works. When it doesn't, you're stuck with blinking LEDs and printf debugging over UART. probe-rs fundamentally changes this experience by bringing the productive, familiar workflow of userspace development to the embedded world.
The Traditional Embedded Development Pain
Let's consider what a typical embedded development cycle looks like without probe-rs. You want to run a simple program that blinks an LED and prints "Hello, World!" to a console:
// Traditional embedded approach
#![no_std]
#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
// Initialize hardware (complex, target-specific)
let peripherals = init_hardware();
loop {
// Blink LED
toggle_led(&peripherals);
// Can't just println! - need complex UART setup
// or resort to blinking patterns to communicate state
delay_ms(1000);
}
}
To get this running, you need to:
- Set up a complex build system with linker scripts
- Configure OpenOCD or similar tool for your specific debug probe
- Flash the binary using yet another tool
- Debug by either blinking LEDs in patterns or setting up UART communication
- Repeat this cycle every time you want to test a change
The debugging experience is particularly painful. Want to know what your variables contain? Add some LEDs. Want to understand program flow? More LEDs. Need to inspect memory? Break out the JTAG debugger with its arcane GDB commands.
probe-rs: Embedded Programming That Just Works
probe-rs eliminates these pain points by providing a unified, modern toolchain. The same "Hello, World!" program becomes:
#![no_std]
#![no_main]
use panic_probe as _;
use cortex_m_rt::entry;
use rtt_target::{rprintln, rtt_init_print};
#[entry]
fn main() -> ! {
rtt_init_print!();
rprintln!("Hello, World! from embedded Rust");
let mut counter = 0;
loop {
rprintln!("Counter: {}", counter);
counter += 1;
// Set breakpoint here in VSCode just like any other program
cortex_m::asm::delay(8_000_000);
}
}
Now your development cycle becomes:
# Just like any other Rust program
cargo run
That's it. probe-rs handles flashing, starts execution, and shows the RTT output in your terminal in real-time. You see "Hello, World!" and the incrementing counter just as if you were running a desktop application.
Real-Time Transfer: Bridging the Communication Gap
The magic behind this seamless experience is RTT (Real-Time Transfer). Traditional embedded debugging requires you to sacrifice pins for UART communication, configure baud rates, and deal with buffer overflows. RTT uses the debug probe's existing connection to create a high-speed, bidirectional communication channel with zero hardware overhead.
use rtt_target::{rprintln, rprint};
// Print formatted output just like println!
rprintln!("Sensor reading: {} degrees", temperature);
// Even works with complex data structures
#[derive(Debug)]
struct SensorData {
temperature: f32,
humidity: f32,
timestamp: u32,
}
let data = SensorData { /* ... */ };
rprintln!("Data: {:?}", data);
This output appears instantly in your development terminal, with no additional hardware setup required. The experience is indistinguishable from debugging a userspace application.
Professional Debugging in VSCode
Perhaps most remarkably, probe-rs brings full-featured debugging to embedded development. Set up is minimal - just add a launch configuration:
{
"version": "0.2.0",
"configurations": [
{
"type": "probe-rs-debug",
"request": "launch",
"name": "Debug Embedded App",
"cwd": "${workspaceFolder}",
"chip": "STM32F411RETx",
"flashingConfig": {
"flashingEnabled": true,
"resetAfterFlashing": true
}
}
]
}
Now you can:
- Set breakpoints by clicking in the gutter
- Step through code line by line
- Inspect variables in real-time
- Evaluate expressions in the debug console
- View call stacks and memory contents
The debugging experience is identical to debugging any other application, except your code is running on a microcontroller.
Testing: Making Embedded Testing Feel Like Regular Unit Testing
One of the most painful aspects of traditional embedded development is testing. Running tests typically means manually flashing different test binaries, resetting the device between tests, and manually verifying results - often through blinking LEDs or UART output. The probe-rs ecosystem addresses this with embedded-test, which brings the familiar experience of cargo test
to embedded systems.
Traditional Embedded Testing Pain
Without proper tooling, embedded testing looks like this:
// Traditional approach - manual test runner
#![no_std]
#![no_main]
fn test_sensor_reading() {
// Test code here
// Results communicated via LEDs or UART
}
fn test_motor_control() {
// Another test
// No isolation between tests
}
#[entry]
fn main() -> ! {
// Manually call each test
test_sensor_reading();
test_motor_control();
// Hope nothing interferes between tests
loop {}
}
This approach has numerous problems:
- No isolation between tests
- Manual test orchestration
- Difficult result reporting
- No support for test attributes like
#[should_panic]
or#[ignore]
- Time-consuming manual process for each test run
embedded-test: Bringing cargo test
to Embedded
With embedded-test and probe-rs, the same tests become:
#![no_std]
#![no_main]
#[cfg(test)]
#[embedded_test::tests]
mod tests {
use stm32f7xx_hal::pac::Peripherals;
// Optional init function called before each test
#[init]
fn init() -> Peripherals {
Peripherals::take().unwrap()
}
// Tests receive the state from init
#[test]
fn test_sensor_reading(peripherals: Peripherals) {
let sensor = setup_sensor(&peripherals);
let reading = sensor.read();
assert!(reading > 0);
}
#[test]
#[should_panic]
fn test_invalid_config(peripherals: Peripherals) {
// This test expects a panic
configure_invalid_setting(&peripherals);
}
#[test]
#[ignore]
fn test_long_running_operation(peripherals: Peripherals) {
// Skipped by default, run with --ignored
perform_calibration(&peripherals);
}
#[test]
#[timeout(10)]
fn test_with_timeout(peripherals: Peripherals) {
// Fails if takes longer than 10 seconds
quick_operation(&peripherals);
}
}
Running these tests is identical to any Rust project:
# Run all tests
cargo test
# Run specific test
cargo test test_sensor_reading
# Run ignored tests
cargo test -- --ignored
# Filter tests by name
cargo test sensor
How It Works: Intelligent Test Orchestration
The embedded-test framework provides sophisticated test orchestration through probe-rs:
- Single Flash: All tests are compiled into one binary and flashed once
- Test Discovery: probe-rs queries the device for available tests via semihosting
Individual Execution: For each test:
- Device is reset to ensure clean state
- Specific test is selected via semihosting commands
- Test runs with optional timeout
- Results reported back via semihosting
- Result Aggregation: All results collected and presented in familiar
cargo test
format
This approach provides true test isolation - each test runs on a freshly reset device with clean hardware state, eliminating issues with shared global state or hardware configuration conflicts.
Advanced Features
embedded-test supports sophisticated testing patterns:
#[embedded_test::tests]
mod advanced_tests {
// Async tests (with embassy feature)
#[test]
async fn test_async_operation() {
let result = async_sensor_read().await;
assert_eq!(result, 42);
}
// Tests that return Results for better error reporting
#[test]
fn test_with_error_context() -> Result<(), &'static str> {
let config = load_config()
.ok_or("Failed to load configuration")?;
validate_config(&config)
.ok_or("Configuration validation failed")?;
Ok(())
}
// Conditional compilation of tests
#[test]
#[cfg(feature = "debug-mode")]
fn test_debug_features() {
// Only compiled when debug-mode feature is enabled
}
}
IDE Integration
Since embedded-test is libtest
compatible, it integrates seamlessly with development environments:
- VSCode: Click the "Run Test" button next to any test function
- IntelliJ/CLion: Individual test execution through the gutter icons
- Command Line: Standard
cargo test
filtering and options work identically
This integration means embedded developers get the same productive testing workflow as userspace developers, with instant feedback and easy test isolation.
The combination of probe-rs and embedded-test transforms embedded testing from a manual, error-prone process into an automated, reliable development practice that feels exactly like testing any other Rust code.
Technical Architecture: How probe-rs Achieves This Magic
probe-rs accomplishes this by implementing a complete debug ecosystem built around modern protocols and smart abstractions.
Unified Probe Abstraction
Traditional embedded toolchains require different tools for different debug probes. probe-rs provides a unified interface that works with:
- CMSIS-DAP probes (including the popular Black Magic Probe)
- ST-Link (ST's proprietary debugger)
- J-Link (Segger's professional solution)
- FTDI-based probes
- Their own open-source rusty-probe
The image above shows a CH32 custom WCH-Link debugger attached to a CH32V307 development board, demonstrating probe-rs's support for diverse hardware ecosystems beyond the traditional ARM Cortex-M space.
The same code works with any probe - no configuration changes needed when switching hardware.
Target Database and Flash Algorithms
probe-rs ships with comprehensive support for hundreds of ARM Cortex-M and RISC-V targets. Each target includes:
- Memory layout information
- Flash programming algorithms
- Reset and halt sequences
- Architecture-specific debug features
Missing a target? probe-rs can generate target descriptions from CMSIS-Pack files automatically, or you can write custom flash algorithms using their provided templates.
Debug Adapter Protocol Integration
Rather than creating yet another proprietary debugging interface, probe-rs implements the Debug Adapter Protocol (DAP). This means it works out-of-the-box with:
- VSCode
- Vim (via vimspector)
- Emacs (via dap-mode)
- Any editor supporting DAP
While Emacs has excellent DAP support through dap-mode, there's currently no direct probe-rs integration like VSCode's dedicated extension. This means Emacs users need to configure dap-mode manually to work with probe-rs's DAP server. Creating a dedicated Emacs package that provides seamless probe-rs integration - similar to what exists for VSCode - would be a valuable contribution to the embedded Rust ecosystem and something worth investigating.
This standards-based approach ensures probe-rs fits into your existing development workflow.
Remote Development: Hardware Anywhere, Development Everywhere
One of probe-rs's most powerful capabilities is remote debugging. Your embedded hardware can be physically located anywhere - in a test lab, deployed in the field, or even in another country - while you develop and debug as if the hardware were on your desk.
This works through probe-rs's built-in gdb server mode:
# On the machine connected to hardware
probe-rs gdb-server --chip STM32F411RETx
# From your development machine anywhere on the network
arm-none-eabi-gdb target/thumbv7em-none-eabihf/debug/my-firmware
(gdb) target remote remote-host:1337
This capability enables entirely new development workflows:
- Distributed teams can share expensive hardware setups
- Developers can debug deployed devices without physical access
- Automated test systems can be controlled remotely
- Hardware-in-the-loop testing becomes practical at scale
Remote Server: Centralizing Hardware Access
Beyond the gdb server approach, probe-rs now includes a built-in remote server that provides a more sophisticated solution for sharing hardware across teams. This server exposes probe-rs's full functionality over a network, allowing multiple developers to access shared hardware resources without requiring SSH access or complex port forwarding.
Note that at the time of writing (May 2025), this remote server functionality is not prominently featured in the official probe-rs documentation, as it's a relatively new addition that was merged in February 2025. The feature is stable and production-ready, but you may need to discover its capabilities through exploration or community resources.
Building with Remote Support
The remote server functionality requires enabling the `remote` feature during compilation:
cargo install probe-rs-tools --features remote
This feature is currently behind a feature flag as it adds additional dependencies for the web server and RPC functionality.
Server Setup and Authentication
Setting up the remote server requires creating a configuration file at `~/.probe-rs.toml` that defines user tokens:
[[server.users]]
name = "alice"
token = "alice-secret-token"
[[server.users]]
name = "bob"
token = "bob-different-token"
The authentication system uses a challenge-response mechanism where the server generates a random string, and the client must hash it with their token. This prevents tokens from being transmitted in plaintext while keeping the implementation simple.
Start the server with:
probe-rs serve
This starts both the RPC server and a web interface at `http://localhost:3000` that shows connected probes and basic status information.
Remote Client Usage
Once the server is running, clients can connect from anywhere on the network:
# Flash firmware to remote hardware
cargo run --host ws://localhost:3000 --token alice-secret-token
# List remote probes
probe-rs list --host ws://localhost:3000 --token alice-secret-token
# Get chip information from remote target
probe-rs chip --host ws://localhost:3000 --token alice-secret-token
The remote functionality currently supports most probe-rs commands including:
info
- Discover and identify connected deviceslist
- Show available debug probesdownload
- Flash binaries to targetsrun
- Flash and execute programs with RTT outputattach
- Connect to running targetsread/write
- Memory operationsreset
- Target reset operationserase
- Flash memory erasureverify
- Verify flash contents
Transparent Operation
From the developer's perspective, using remote hardware is nearly transparent. The same cargo run
command that works locally will work with remote hardware by simply adding the host and token parameters. RTT output, error messages, and progress indicators all function identically whether the hardware is local or remote.
This approach provides several advantages over traditional solutions:
- No SSH required: Users don't need shell access to the hardware server
- Fine-grained access control: Tokens can be per-user and easily revoked
- Web monitoring: The built-in web interface provides visibility into hardware usage
- Native integration: No need for VPNs or complex network configuration
The remote server represents probe-rs's philosophy of making embedded development as friction-free as possible, extending that philosophy to distributed development scenarios.
Beyond Development: Production APIs
While the developer experience improvements are impressive, probe-rs also provides clean APIs for building production tooling:
use probe_rs::{Permissions, Probe, Session};
// Connect to target
let mut probe = Probe::list_all()[0].open()?;
let mut session = probe.attach("STM32F411RETx", Permissions::default())?;
// Read/write memory
let mut core = session.core(0)?;
let data = core.read_32(0x2000_0000)?;
core.write_32(0x2000_0000, 0xDEADBEEF)?;
// Flash programming
session.target().flash_loader().load_elf_data(&mut session, elf_data)?;
// Set breakpoints and control execution
core.set_hw_breakpoint(0x0800_1000)?;
core.run()?;
This API enables:
- Custom flashing utilities
- Hardware-in-the-loop test systems
- Automated validation frameworks
- Manufacturing test equipment
The Philosophical Shift: Hardware as Just Another Target
probe-rs represents more than just better tooling - it represents a fundamental shift in how we think about embedded development. Instead of treating embedded systems as a separate discipline requiring specialized knowledge and primitive tools, probe-rs brings embedded development into the mainstream of software engineering.
This philosophical shift has practical benefits:
- Faster onboarding: Developers familiar with modern tooling can be productive immediately
- Better code quality: Modern debugging tools lead to better understanding and fewer bugs
- Increased productivity: Less time fighting tools means more time solving actual problems
- Lower barrier to entry: Embedded development becomes accessible to a broader range of developers
The result is embedded development that feels like what software development should be: focused on solving problems rather than wrestling with tooling limitations.
Conclusion
probe-rs eliminates the artificial barriers that have long separated embedded development from mainstream software engineering. By providing modern tooling, seamless debugging, and a standards-based approach, it transforms embedded development from a specialized craft into an accessible engineering discipline.
Whether you're blinking your first LED or building complex IoT systems, probe-rs provides the foundation for a productive, enjoyable development experience. The future of embedded development looks a lot like the present of userspace development and probe-rs is making that future available today.