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:

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:

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:

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:

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:

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 

Comments

comments powered by Disqus