DISCLAIMER: This article is as an entertainment piece NOT expert advice. See full disclaimer at the bottom of this article.
I’m an avid diver and Engineer and love tech. So in my spare time I’ve been slowly cracking away at building my own dive computer. For those who aren’t familiar, a dive computer keeps track of how long you’ve been at a particular depth. It then does some fancy calculations to determine if the dissolved nitrogen in your blood is going to boil as your resurface. If it’s not obvious, having bubbles of nitrogen gas flowing through your arteries is pretty bad. At best these bubbles in your blood will make you very sick, at worst very dead. No diver should rely 100% on a dive computer and should be capable of safely resurfacing if their dive computer fails. But it is nice to have a computer keeping track of your dive profile so that you don’t have to do a bunch of calculations underwater. In fact it is pretty common, and usually encouraged to carry two independent dive computers. For a few reasons;
- If one fails you can use the other while you resurface.
- You can compare the calculations/depth between the two computers. If they disagree you know that one of them has malfunctioned and you need to resurface.
- You can use dive computers with different algorithms, and choose the most conservative algorithm from the two computers.
Usually I dive with my Suunto D5 computer/watch and then rent a secondary computer with the rest of my scuba gear. The Suunto D5 is a fantastic watch for recreational use, and has some use in more technical mixed gas diving as well. While the computer that I have is perfectly suitable for diving, I am just naturally curious about what goes into building one. So the dive computer that I am building will become my secondary/backup if my Suunto fails.
So given that I love diving, and death usually prevents people from diving, I a want to be confident that using my dive computer isn’t going to kill me. This is why I’ve chosen the rust programming language to do all those fancy calculations.
Rust is a fantastic low level language that prevents a whole host of common programming bugs. As Rust doesn’t need a runtime, it’s also suitable for writing bare-metal embedded kernels. But let’s be clear, this language doesn’t eliminate all bugs. So I’m going to go through the process I went through to eliminate as many bugs as I possibly could. Using one of the sensors in the dive computer as an example.
One of the integral components of a dive computer is a pressure sensor. In this case I’ve chosen the MS5837 pressure/temperature sensor from Texas Instruments. This is a tiny little surface mount component, that comes with a small groove to fit an O-ring to seal against water ingress. It’s also rated to a depth of 300m which makes it suitable for even the most adventurous scuba expeditions.
The MS5837 is a relatively simple I2C device with 11 commands and 8 registers. The workflow for using this pressure sensor looks something like this;
So let’s go through how I developed this driver, and how I hardened the driver so that I could be sure it wasn’t going to crash.
Developing the driver
The first step that anyone should take when attempting to write code that needs to be reliable is to make sure that your code is tested. I like to do this from the beginning following a process called Test Driven Development (TDD). This process involves;
- Writing a test to ensure that your code is going to function as required.
- Writing minimal code to make your code compiles.
- Updating your code to ensure that the test passes.
- Cleaning up your code to ensure that your code is understandable.
- Rinse repeat.
This process encourages writing modular reusable code that is tested from the beginning. So let’s go through that process here.
So with this initial test we are test we are ensuring that the reset command
works as expected. The mocked i2c handle asserts that the reset command
is sent to i2c address of the pressure sensor
0x76. Of course running this test
will fail, as we haven’t yet implemented the driver. So let’s go ahead and do
cargo test should now complete successfully. Sweet so now our driver
is capable of resetting the pressure sensor.
Testing and coverage
So first thing is first we need to evaluate how well our unit-tests did at exploring all the paths through our code. For this, I like to use a tool called tarpaulin. You can install this tool using the command;
cargo install cargo-tarpaulin
Tarpaulin is a pretty simple tool that is quite similar to the builtin
The major difference is that running
cargo tarpaulin -v will build your tests
instrumenting the code using LLVM’s coverage sanitizer. It then runs the test and
interprets the coverage data. Giving you a nice summary of how much of your
code is run during your tests as well as a list of every line that wasn’t run
during testing. The output might look something like this;
NOTE: This is the output from the testing the driver as of the 1/07/2022.
So we are sitting at about 67% code coverage for our driver at the moment. So what should we be aiming for here?
This depends on what you are going to use your code for. In a lot of cases aiming for 100% code coverage ends up being an anti-pattern as your unit-tests for setters/getters ends up being tightly coupled to your code making things obscenely difficult to change down the line. Plus your tests for setters/getters aren’t doing anything useful. It’s important to remember that a % code coverage is a fundamentally reductive metric. Code coverage is better considered by the context around that specific code section and with line by line coverage. In my opinion around 80-90% code coverage ends up being the sweat spot for most use cases.
However, this is not most use-cases, we are trying to prevent my arteries from turning into a bubble bath, so we are writing the most reliable driver we possibly can. We are going to just deal with the tight coupling that comes with 100% code coverage, and fix up that code coverage.
At this point you might be thinking, what code hasn’t been tested? The answer is usually something to do with error handling. If you don’t mock out an error how could you possibly know how your code is going to behave if there is an error. In fact there is some code in your original example that wasn’t captured in our unit test so let’s go ahead and fix that.
At this point running tarpaulin will result in 100% code coverage. Now we just have to write the rest of the driver! Repeating this process.
Quick tip: Take a look at cargo-watch it’s a massive time saver. To install
cargo install cargo-watch. This tool will keep track of when the files in
your project change and the rerun the specified commands. So I typically use
a command like this.
cargo watch --clear -x check -x build -x 'tarpaulin -v'
I prefer chaining multiple stages like this in terms of the approximate amount of time each step takes. e.g. check is a lot faster that build/coverage analysis.
This tool gives me immediate feedback as to the general quality/soundness of my code.
So while I’m not going to go into the nitty-gritty implementation details of how I’m developing this driver I’m going to share some of the more surprising problems that I ran into when developing in rust.
Numerical calculations can panic 😲
So about a week ago I was trucking away at writing some code that does calculations to convert the raw temperature and pressure into absolute units. This has to be done at every pressure measurement as the pressure needs to be compensated for the pressure. To my surprise while testing this functionality, my test crashed entirely. Not a regular test failure, a full blown panic with a backtrace. The code looks something like this;
NOTE: I am part way through refactoring the driver to use only integer math as the microcontroller I’m using doesn’t have an FPU and soft-floating point ops are needlessly expensive. That’s why I’m mixing float/integer math.
Do you know why this code would panic? I certainly didn’t. It took me some Googling to find out that when rust is compiled in ‘Debug’ mode integer calculations are checked for overflowing and divide by zero. This is great as it meant that I caught a potential bug before I was 20-30m below 🤿.
To be very clear, a scenario where an integer overflows and shows that I am at a depth of 0m or -67000m, when I’m at 25m is pretty unlikely. But in the implementation above it was possible. The consequences of this could be pretty bad. In other words your dive computer could continue working as if everything was fine. But everything won’t be fine as the computer will be miscalculating your decompression time, potentially leading to bubbly-blood.
Inspired to find and fix all the instances where integer math bugs could occur I started looking into ‘cargo-fuzz’. For those not familiar with fuzz testing here is a short excerpt from Wikipedia;
In programming and software development, fuzzing or fuzz testing is an automated software testing technique that involves providing invalid, unexpected, or random data as inputs to a computer program.
Cargo fuzz uses libfuzzer under the hood, and is a coverage guided fuzzer. This differs slightly compared to a regular fuzzer. A coverage guided fuzzer will attempt to generate random data and will use the realtime coverage information to keep/mutate the previous input to maximize the code-coverage. This makes finding bugs significantly faster as the fuzzer will “learn” how to produce inputs that will execute new branches in the program flow.
It’s important to realize here that, coverage guided fuzzing usually won’t exhaustively test every possible code path, with every possible input. It is heuristic guided approach to testing. Writing a fuzz test won’t prove that your code won’t ever crash.
If you aren’t familiar with cargo-fuzz, I recommend reading the book. At least a cursory understanding of cargo-fuzz will help you understand the next section.
So let’s go ahead and write a fuzz test for conversion code. To start off we need to initialise our repository for fuzz testing;
cargo fuzz init
This will create a “fuzz” directly alongside your library that allows you to test your public interface. Now if you look carefully at the code above you’ll see a problem with this already. At the time of writing you can’t easily fuzz private APIs, so we can’t directly fuzz the conversion code without making it public.
So how can we get the fuzzing engine to test the conversion code? Well we point
the fuzzing engine at the public API, and then check that we are getting
test coverage through that section e.g. using
cargo fuzz coverage <target>.
So let’s go ahead and do that. In our case, the only pathway to get fuzzed inputs into the conversion function is via I2C. So let’s create a fake/fuzzed I2C driver that simply passes through the fuzzed input.
Most rust hardware drivers (including this one) make use of the traits defined
embedded-hal crate. The I2C trait used in this driver looks something
So let’s go ahead and create an implement this trait. First let’s create a type containing the fuzzed data;
The next step is to implement the
WriteRead I2c trait for this type;
Great so now we have our fuzzed implementation of the i2c driver. Now we just need to write the fuzz test. So let’s add a new one;
cargo fuzz add read_temperature_and_pressure
This will add a new fuzzing target
fuzz/fuzz_targets/read_temperature_and_pressure.rs. Out of the box this file
will look something like this;
So let’s fill in this test template with some code to excersize our driver;
Finally we can run the fuzz test;
cargo fuzz run read_temperature_and_pressure
After a short period of time we get a crash due to an integer overflow reproducing our original crash.
Now examining the inputs that result in the crash, I can safely say that the pressure sensor should never output those values. However there shouldn’t be a case where my driver can randomly crash based on some arbitrary math logic. Instead a reliable driver would simply return an error e.g. something along the lines of ‘InvalidTemperature’.
So how do we fix this problem, and then how can we be 100% sure that the program can never crash. It turns out the simplest way to do this is to remove the panic/crash function. Doing so will result in a compiler error if there is any way that app code could crash.
So one stackoverflow question later and things are a lot clearer, the integer operations e.g. Add, Multiple, Divide etc. are actually implemented as traits. The default implementations for numerical types is different in ‘Debug’ vs ‘Release’ builds. With checks only being performed under the ‘Debug’ builds.
It turns out that there is a way to do ‘checked’ integer operations that will optionally return a value, but won’t panic if there isn’t a valid result.
There is even a
no_panic crate which will produce a linker error if there is
any possible way that your code could panic.
Tune in to part-2 of this series, where we are going to;
- Reach 100% code coverage library wide.
- Refactor our library to have absolutely zero code that could ever panic.
NOTE: This disclaimer is a modified version of the MIT software licence.
THE ‘INFORMATION IN THIS ARTICLE’ IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE ‘INFORMATION IN THIS ARTICLE’ OR THE USE OR OTHER DEALINGS IN THE ‘INFORMATION IN THIS ARTICLE’.