I've had the pleasure to work with very experienced firmware developers; the kind of people who know the size of their red zones and routinely transform coffee into linker scripts and pointer dereferences. In other words, the Mels and Zeus Hammers of the world.
When it comes to the tools of our trade, many of them are curious and experimental. Some of them—very much myself included—explore far enough to leave pragmatism behind and veer into idealism, stubbornly forcing beautiful round pegs into industrial square holes. Hey, maybe they're square for a reason, but it doesn't hurt to try.
The majority of them aren't like that. Your average battle-tested firmware developer has accrued a healthy distrust of the abstract, probably born of watching shiny platonic constructs crash and burn with painfully real and concrete error traces. It is sobering, having to chase a hardfault on a tiny MCU across enough vtables and templated code to make Herb Sutter puke angle brackets. No wonder modern approaches are met with some resistance unless the LOADs and the STOREs are in clear view.
I felt this way too when someone suggested to me, back in 2014, that an up-and-coming language called Rust showed promise in the embedded field. Surely not, I thought, too high level. Even though I had been playing with it already, my profoundly ingrained bit-twiddling instincts told me not to trust a language that supported functional programming, or one that dared to have an opinion on how I managed my memory. Bah! That's how you get philosophers to run out of forks, and your forks to turn into SIGSEGVs.
I was wrong.
Through the past five years of experimentation, I've gone from intrigued, to optimistic, to convinced that Rust is an ideal language to build industrial grade, bulletproof bare metal software. Beyond that, I've come to realize that even the highest level constructs that the base language offers are applicable to firmware development, very much unlike other languages that span a wide range of paradigms (I'm looking at you, C++). There are a few reasons I felt this way:
Rust's safety guarantees and general strictness bring the debug time down significantly, so there's less need to spend time developing mental maps of how high level constructs correspond to hardware primitives.
The type system is great at enforcing local reasoning and preventing leaky abstractions. Building decoupled systems with no runtime cost is easy.
The compiler error messages are worthy of an AI assistant with concerning mind-reading abilities.
Lately, I've had the chance to work on a Rust STM32F412 project in a professional setting, with one of the goals being to foster a Rust knowledge pool at my company. The project, Loadstone, is a 32kb secure bootloader targeting bare metal devices for the medical industry.
While it would've been easier—and much less of a headache to my colleagues—to stick to a subset of Rust more familiar to C developers, with your fors, your *mut u8s and your unsafes, I instead decided not to pull any punches and make liberal use of generics, iterator adapters, typestate programming and other stuff that would've made 2010's cuervo cry blood and hug the closest copy of Kernighan and Ritchie.
The pressures of a real collaborative project have taught me a lot, and many assumptions have been refined thanks to the criticism of several skilled outsiders who, as outsiders often do, had a privileged view on things I took for granted.
A topic that came up frequently in code review is generics. Perhaps still recovering from a SFINAE nightmare, some colleagues were unsure about the use of generics to group behaviour that we'd normally write separate implementations for. The concerns tended to fall in one of three categories:
Runtime performance.
Binary size bloat.
Habitability and readability.
The first is easy to dispel, as it often comes from unfamiliarity with static dispatch. No vtables or heap allocations in anything we're doing, promise! The second concern is valid but I've found it to be negligible in practice; I have plans for another blog post giving some concrete benchmarks.
The last concern is the most subjective and thus the hardest to argue, so I decided to focus on it in this blog series. I'll go over the design process of two similar flash memory drivers, and hopefully show how generic programming can make the job easier and the result more habitable, even in the barren, heapless, rugged world of bare metal firmware.
Compile times are another common—and very valid—argument against liberal use of generics. However, it is not a big problem for low footprint embedded projects like this one.
What is Flash Memory?🔗
Flash memory is electronic non-volatile storage. It's ubiquitous in consumer electronics; any time you switch a small device off and it remembers something—whether it's settings, songs, documents, even its own program—chances are you have flash memory to thank. We'll be looking at two different NOR flash chips, since the first demo port of Loadstone requires us to operate both:
The embedded STM32F412 1MB MCU flash.
The external 128MB Micron N25Q128 flash chip present in the STM32F412ZGT6 Discovery Kit
You probably knew what flash memory is used for already, but what non-firmware developers may not know is that flash memory is quirky. You cannot simply write a byte to a NOR flash address, sir, that would be rude. While a blob of flash memory will happily turn a 1 into a 0, the opposite operation will fail silently.
You can think of every 1 bit (NOR flash's erased state) as a lit candle you can blow out. However, in this metaphor you don't get a lighter to light them back up; you get a flamethrower. Without getting into the hardware principles involved, the design of NOR flash memory requires that you erase (i.e. set to 1) memory in bulk, in chunks often orders of magnitude bigger than the minimum addressable memory. On most chips you even have a three way mismatch: your read, write and erase sizes aren't equal. Ugh.
As you can imagine, this makes writing flash drivers a bit of a pain, particularly because even the smallest write operations turn into read/write cycles. Writing a single byte requires reading the minimum erasable block surrounding the targeted address (which may itself require multiple reads), potentially erasing the entire block, then writing back the original data merged with the desired byte.
As you can also imagine, nobody but the person writing this driver wants to care about this. Even in the minimalistic world of bare metal software, productive collaboration depends on developers filing away these sharp edges, presenting interfaces that uniformize or hide any aspects of hardware irrelevant to the bigger design. As such, a first stab at a flash memory interface should simply offer a way to read and write ranges of memory.
Let's look at some code: