Writing a driver in Rust
Last updated: 2021-04-04
What to do if you want to use some sensor or other device, but there is no driver available? Write one yourself!
Some time ago I bought a cheap breakout board with a battery-backed RTC chip, NXP PCF8563. I wanted to use it in some Rust-based project, but I couldn’t find a driver crate (Rust speak for “library”) for it. So I ended up writing my own driver!
How to talk to an I2C device?
The manufacturer’s datasheet is really well written and it all seemed pretty straightforward and not too complicated, to I decided to try writing my own driver, from scratch. Luckily in the open source world you don’t always need to do things “from scratch”, as there’s a lot of great code already available to be borrowed and adapted to what you need, and that’s what is so great about it!
First of all, I wanted to understand how to interact with the chip, in order to get any kind of response from it. After watching this excellent tutorial on DigiKey’s YouTube channel, I tried something like this:
// define and set up all what's necessary: clocks, pins, I2C bus etc.
const PCF_ADDR: u8 = 0x51; // 7-bit address of the module
const PCF_REG: u8 = 0x02; // time registers start at this address
let mut buffer = [0u8;3]; // empty buffer to hold seconds, minutes and hours
loop {
// read three bytes starting with PCF_REG address
i2c.write_read(PCF_ADDR, &[PCF_REG], &mut buffer).unwrap();
// values are stored in Binary Coded Decimal system
// you need some helper function to decode them to actual minutes, hours etc.
let secs = decode_bcd(buffer[0]);
let mins = decode_bcd(buffer[1]);
let hrs = decode_bcd(buffer[2]);
// print what we just read to serial
writeln!(tx, "{:02}:{:02}:{:02}\r", hrs, mins, secs).unwrap();
}
This worked perfectly, and I was able to read raw values from the registers of my choice. In order to write, for example, to set hours to 7, you do something like this:
// write 0x07 to register 0x04 (HOURS)
// (please note that the value has to be encoded into BCD format)
i2c.write(PCF_ADDR, &[0x04,0x07]);
What if you want to, say, enable the clock output on the dedicated pin, and set the frequency to 32Hz?
// writes the binary value 10000010 to the register 0x0D (CLKOUT_control);
// the highest bit enables the clock output, the lowest two define frequency
i2c.write(PCF_ADDR, &[0x0D,0b1000_0010]);
What do I need a driver for, anyway?
It works perfectly, and could be enough to use a simple device like an RTC. After all, it only needs to be set up every now and then, and after that it’s mainly reading single registers. If you want to make a clock that just displays current time, you don’t really need a driver, the code shown above will do. But I wanted something that others could easily use in their projects, just like I use drivers for various sensors, displays and such.
Encouraged by this success, I thought: what if I took some existing Rust driver for some other RTC chip, and tried to adapt it to work with the PCF8563? As I already knew this DS1307 driver, which is very well documented, it seemed like the right place to start.
The whole interaction with an I2C device is basically reading and writing entire registers, and clearing or setting single bits. You also need to clear and/or set groups of bits, which can be accomplished using bit masks. So I started my lib.rs
file by copying the following functions for my PCF8563
struct:
new
to create a new instance of my device,destroy
to return the I2C bus to the application if necessary,write_register
andread_register
,set_register_bit_flag
andclear_register_bit_flag
for operations on single bits,is_register_bit_flag_high
for checking the status of a single bit
All these are based on the embedded_hal
crate, and are the cornerstone of almost all the other functions in my driver.
I also copied the Error
enum, to handle the possible errors, which in this case can either be I2C bus errors (when something goes wrong in the communication), or invalid input data, e.g. when you try to set the month to something more than 12. Writing this driver is a sort of learning-by-doing experience for me!
Making a driver, piece by piece.
After adding the addresses of various registers, and the main address of the I2C device, I was basically ready to start writing actual functions to interact with my RTC.
I followed the modular example of Eldruin’s driver, and created separate modules:
datetime
to handle reading and setting the timealarm
to set the alarm, read the settings, enable/disable the alarm, and enable/disable alarm interrupttimer
to set and read the timer, enable/disable it, enable/disable timer interrupt, and set timer frequencyclkout
to enable/disable the clock output, and set its frequency- and finally
control
to handle all the other settings, like starting/stopping the clock, etc.
For the datetime
module I copied and adapted the DateTime
struct. It’s a convenient way to hold the content of all the date and time components. And here’s where it turned out that when you’re writing a driver (or any code, for that matter), decisions have to be made! :O So I decided to do the following:
- read and set the date and time only all at once, without separate
read_hours
orset_month
functions. This follows the recommendations in the NXP’s datasheet, - use only the 0-99 values for years, without specifying whether it’s 1855 or 2755. This way all the elements of the struct can be unsigned 8-bit values, and the user can handle the year formatting in the application as they prefer.
- keep the
century
setting out of theDateTime
struct, it only indicates whether it’s “century N” or “century N+1”, useful for the transition between the year xx99 and xx00; as I’m writing this driver in 2021, I believe this will work fine :)
After some thinking I decided to add a second struct, Time
, for applications where you only need the clock, but not the calendar. I only created a set_time
function, though, to set hours, minutes and seconds in one go. When you need to read the time, there’s already the get_datetime
function, which will return the DateTime
struct, and you can simply discard the irrelevant parts of it.
How to get the right numbers?
With the datetime
module completed, I already had a working driver that could be used to set the time, and read it. Well, I actually had to add two helper functions, to encode and decode Binary Coded Decimal values. The BCD seems weird at first, but makes more sense if you consider the applications. For example, with a segment display you don’t need to figure out how to separate 2 from 5 in the number 25. To display one segment as 2 and another one as 5, you just read the relevant pieces of the byte and that’s it. It doesn’t really matter that much now, but it used to. :) The confusing part is that in the case of this particular RTC, various BCD-formatted registers (date, time and alarm) all seem to be different. If you take a closer look, though, you’ll find out that:
- the 7th bit is best to be discarded, as it may contain other information, irrelevant for this purpose
- the four lower bits contain the single digits (digit 5 in our 25 example)
- the remaining three bits [4:6] contain the tens (digit 2 in our example)
This is what my decode_bcd
does: using bit masks and bit-shifting, it first discards the most significant bit, then gets the digits and the tens. The encode_bcd
does the whole operation in reverse (care must be taken when writing such encoded value to the register, to keep the current contents of the 7th bit).
There’s more than just the clock and the calendar!
If you just want the basic clock and calendar functions, that’s pretty much it! PCF8563 makes it easy by not having the AM/PM function, no need to worry about setting that (and it can be easily handled in the applicaton anyway). It does have other interesting features, though, so I created all the necessary functions to read and set the alarm, timer and clock output.
Here’s another design choice I made. I’m not sure whether it is better or worse than a different approach, and I’m looking forward to hearing others’ opinions on this. At first I created enable_feature()
and disable_feature()
for every “switch”, such as enabling and disabling the timer, the alarms, or stopping and starting the main clock. But then I thought, that’s an awful lot of functions, what if I make it differently? I decided to create control_feature(arg)
functions instead, where the argument is one of the two possible values of the Control
enum: On
or Off
. So to enable the timer you need to do this:
// import the whole crate into the namespace
use pcf8563::*;
// create a new instance of the PCF8563 device
rtc = pcf8563.new(i2c);
// enable the timer
rtc.control_timer(Control::On).unwrap();
Almost there!
I’m almost done with it, but some things remain to be taken care of:
- test it with different targets. So far I’ve only tested it with STM32L4xx HAL
- add another example, using a different MCU and possibly some different feature, e.g. do something when the RTC generates an interrupt
- add functions to read the clock output frequency, timer frequency and timer interrupt mode. I need to decide what the best output of such function would be. For example, maybe the value of the relevant bits could be returned.
- publish the crate to crates.io!
I really learned a lot about Rust and about embedded devices working on this driver: enums, structs, pattern matching, error handling. It all makes much more sense now! I also learned a few things about documenting the code, for example the importance of keeping a changelog or adding tags to the repository. And I gained even more appreciation for the authors of the crates and libraries and modules I’ve been using. It’s not just about knowing how things work and how to make the software talk to the hardware, but also about doing so in a user-friendly manner, so that users like me can just take a driver or some other component and start using it right away.
I hope this will encourage other newbies like myself to take on some more advanced experiments.
And if you’re an Embedded Rustacean, please let me know whether it all works, and if you don’t mind, contribute some other examples for other MCUs, maybe a Linux example for a RasPi?
Happy coding!
Categories: rust embedded