Writing a simple mcumgr client with Rust

Firmware engineer Frank Buss shares his recent experience of Rust for firmware development

Writing a simple mcumgr client with Rust

I work as a firmware developer at Vouch.io, where my job is to write software for microcontrollers. We chose mcuboot and the mcumgr protocol with CDC ACM (virtual USB COM port) for firmware updates, because DFU has some issues on Windows, like needing special drivers and bugs which prevents it from working with some microcontrollers. Plus, mcumgr has a bunch of useful commands for managing and configuring devices, not just updating firmware.

The Problem

The current mcumgr Go client is slow and has bugs. Our new mcumgr-client, written in Rust, is more than 10 times faster than the Go version.

This isn't Go's fault, since benchmarks show that Go can be as fast as Rust. But there are long-standing open issues in the Go mcumgr project, like this one, which causes that uploading firmware take minutes longer than needed. I even fixed this important bug, where the timeout value was ignored, but it still hasn't been merged after more than a month, so the project might be abandoned as well. And in my opinion, the whole thing is just too complicated, spread across three repositories and 133 Go files in 46,549 lines of code, which makes it tough to change or add anything.

The Solution

So, we decided to create our own simple mcumgr client, currently 5 Rust files, in 696 lines of code. At Vouch, we mostly use Clojure for backend stuff and C for firmware development, along with some Python, Ruby, and shell scripts. But for a tool that needs to be fast, reliable, and easy to distribute to customers, Rust is a great choice. It compiles everything into a single executable file, and Cargo takes care of all the multi-platform headaches that usually come with C.

I've written Rust programs before, but I'm still a beginner. What I really like is the macro system and reflection capabilities. For example, with the clap crate, I just wrote this for command-line parsing:

clap

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
/// device name
#[arg(short, long, value_name = "NAME")]
pub device: String,

/// slot number
#[arg(short, long, default_value_t = 1)]
pub slot: u8,

/// verbose mode
#[arg(short, long)]
pub verbose: bool,

/// maximum timeout in seconds
#[arg(short, long, default_value_t = 10)]
pub timeout: u32,

/// maximum length per line
#[arg(short, long, default_value_t = 128)]
pub linelength: usize,

/// maximum length per request
#[arg(short, long, default_value_t = 512)]
pub mtu: usize,

/// baudrate
#[arg(short, long, default_value_t = 115_200)]
pub baudrate: u32,

#[command(subcommand)]
pub command: Commands,
}

#[derive(Subcommand)]
pub enum Commands {
/// list slots on the device
List,

/// upload a file to the device
Upload { filename: PathBuf },
}

And the /// line doc comments automatically get pulled into the help page, creating a nice-looking output like this:

MCUMGR Client Help

serde

There are many high-quality crates that make it easy to write even high-level programs in Rust. Another great example is serde, and related crates like serde_cbor. You can serialize structures by hand in Rust, like I did for a simple header struct used in the protocol:

pub fn serialize(&self) -> Result<Vec<u8>, bincode::Error> {
let mut buffer = Vec::new();
buffer.write_u8(self.op as u8)?;
buffer.write_u8(self.flags)?;
buffer.write_u16::<BigEndian>(self.len)?;
buffer.write_u16::<BigEndian>(self.group as u16)?;
buffer.write_u8(self.seq)?;
buffer.write_u8(self.id)?;
Ok(buffer)
}

But if the structure changes, you have to manually update the deserialize function too, and it can cause compatibility issues between different versions.

CBOR is a good alternative to it, similar to JSON but using a binary protocol, that helps with this. The serialized byte stream includes field names (like in protobuf), so you can add or remove fields without breaking compatibility. With Rust, you just need to add a few annotations like this:

#[derive(Debug, Serialize, Deserialize)]
pub struct ImageUploadReq {
#[serde(rename = "data", with = "serde_bytes")]
pub data: Vec<u8>,
#[serde(rename = "image")]
pub image_num: u8,
#[serde(rename = "len", skip_serializing_if = "Option::is_none")]
pub len: Option<u32>,
#[serde(rename = "off")]
pub off: u32,
#[serde(
rename = "sha",
skip_serializing_if = "Option::is_none",
with = "serde_bytes"
)]

pub data_sha: Option<Vec<u8>>,
#[serde(rename = "upgrade", skip_serializing_if = "Option::is_none")]
pub upgrade: Option<bool>,
}

Then serde handles the rest. For example create a new object in Rust as usual:

let req = ImageUploadReq {
image_num,
off: off as u32,
len: Some(len),
data_sha: Some(Sha256::digest(&data).to_vec()),
upgrade: None,
data: chunk,
}

And with just one line, let data = serde_cbor::to_vec(&req), it gets serialized into a byte vector, using only the fields that aren't set to None.

Conclusion

To wrap it up, building this program was pretty straightforward and it will be helpful for firmware developers. mcuboot and mcumgr can also be used with Zephyr. To give back to the community, we made our program open source. We'd love to see PRs, whether it's to fix my beginner Rust code, or add more mcumgr protocol functions, or to make the percentage display more modern with a progress bar, estimated rest time etc.