Many people who have written C code for Arduino have at least heard of the
millis()
function at some point. It returns the number of
milliseconds since the program started running.
In avr-hal (a Rust library for AVR microcontrollers) there currently is no equivalent for it. Instead of waiting on someone to add that, let's see what it takes to build our own!
The final code for this blog post can be found in avr-hal
here: examples/arduino-uno/src/bin/uno-millis.rs
What does millis()
do?
The Arduino documentation has this to say about the function:
Description
Returns the number of milliseconds passed since the Arduino board began running the current program. This number will overflow (go back to zero), after approximately 50 days.
That is a bit vague but at least gives us a clear statement about what the function should be doing when looking from the outside. To implement our own, however, we need to know how it does its job. For this, we need to dive into the code behind its C/C++ implementation. Luckily, it's all available online: https://github.com/arduino/ArduinoCore-avr
In particular, we're after the definition of millis()
. It can be found in
cores/arduino/wiring.c:65 and looks like this:
unsigned long millis()
{
unsigned long m;
uint8_t oldSREG = SREG;
// disable interrupts while we read timer0_millis or we might get an
// inconsistent value (e.g. in the middle of a write to timer0_millis)
cli();
m = timer0_millis;
SREG = oldSREG;
return m;
}
Further, timer0_millis
is defined as a volatile (ugh!) global:
volatile unsigned long timer0_millis = 0;
Essentially, the function boils down to this:
- Save the current interrupt state (whether interrupts are enabled or disabled)
- Disable interrupts with a
cli
instruction - Read the global
timer0_millis
which seems to contain the magic we're after - Restore interrupt state
Luckily, steps 1, 2, and 4 are already readily available in Rust in the form of
avr_device::interrupt::free()
. So, let's start with
a skeleton:
fn millis() -> u32 {
avr_device::interrupt::free(|cs| {
// TODO: Magic
unimplemented!()
})
}
Interrupt Service Routine
Okay, that was the easy part. Now, we need to find out who is writing to this
timer0_millis
global variable. Just above the definition of millis()
we can
find it: It is a weirdly defined function which seems to increment the global
by some amount every time it is executed (cores/arduino/wiring.c:42):
#define MILLIS_INC (MICROSECONDS_PER_TIMER0_OVERFLOW / 1000)
ISR(TIMER0_OVF_vect)
{
// copy these to local variables so they can be stored in registers
// (volatile variables must be read from memory on every access)
unsigned long m = timer0_millis;
unsigned char f = timer0_fract;
m += MILLIS_INC;
f += FRACT_INC;
if (f >= FRACT_MAX) {
f -= FRACT_MAX;
m += 1;
}
timer0_fract = f;
timer0_millis = m;
timer0_overflow_count++;
}
What is this all about? Well, ISR()
is a magic macro which defines
what is called an Interrupt Service Routine. This is a special function that
is "called" by the CPU upon a certain hardware interrupt triggering. In this
case, it's the service routine for the TIMER0_OVF
interrupt which I'm going to
explain in more detail later. For now, let's just assume that this interrupt
triggers in a certain fixed interval.
Writing such an ISR/interrupt handler in Rust is also possible. But before doing that, a few words of caution:
That all said, let's try our hands on writing an ISR anyway. In Rust, it will look like this:
#[avr_device::interrupt(atmega328p)]
fn TIMER0_COMPA() {
// TODO: Increment our global
}
(We're using TIMER0_COMPA
instead of TIMER0_OVF
for reasons explained
later.)
Because interrupts on AVR are still experimental, you also need to enable this compiler feature in your project:
#![feature(abi_avr_interrupt)]
Sharing Data
Next step is somehow translating that global variable to Rust. The dirty
solution is just using a static mut
and hoping for the best:
// BAD
static mut TIMER0_MILLIS: u32 = 0;
// ...
unsafe { TIMER0_MILLIS += 1 };
But this rightfully needs a ton of unsafe
and is super easy to get wrong.
Remember my warning from above ...
Instead, we can use a safe abstraction for sharing data between the ISR and our
millis()
function. Said abstraction comes in the form of
a Mutex
which enforces access to happen inside a critical
section - a span of code where interrupts are disabled. This means that no ISR
can run during that time and possibly read out a half-modified value.
Actually, we already added a critical section in our millis()
function by use
of avr_device::interrupt::free()
. So adding the mutex isn't
too much more effort:
use core::cell;
const MILLIS_INCREMENT: u32 = /* TODO */;
static MILLIS_COUNTER: avr_device::interrupt::Mutex<cell::Cell<u32>> =
avr_device::interrupt::Mutex::new(cell::Cell::new(0));
#[avr_device::interrupt(atmega328p)]
fn TIMER0_COMPA() {
avr_device::interrupt::free(|cs| {
let counter_cell = MILLIS_COUNTER.borrow(cs);
let counter = counter_cell.get();
counter_cell.set(counter + MILLIS_INCREMENT);
})
}
fn millis() -> u32 {
avr_device::interrupt::free(|cs| {
MILLIS_COUNTER.borrow(cs).get()
})
}
cs
is a critical section token which we pass to the mutex. Think of it as
a "key" that is needed for unlocking. This way, the mutex can be certain that
it is only ever unlocked inside a critical section.
Inside the mutex, we store a core::cell::Cell
. This is necessary
so that we can mutate the value which the mutex contains as it only gives us an
immutable reference. For more complex cases,
a core::cell::RefCell
might be needed but for simple values
like our integer, fortunately Cell
is enough. You can read more about the
differences in the core::cell
documentation.
With this all in place, we have recreated the global in only safe Rust. This means that we can be certain there are no memory safety violations we might have missed! For making it actually work, though, we still have some steps remaining:
The Timer
Hardware timers are very common to find in microcontrollers as they make anything related to time and timing much easier to handle. Wikipedia describes these Timers like this:
[...] These are typically digital counters that either increment or decrement at a fixed frequency, which is often configurable, and which interrupt the processor when reaching zero. [...]
More-sophisticated timers may have comparison logic to compare the timer value against a specific value, set by software, that triggers some action when the timer value matches the preset value.
Essentially this means a timer/counter simply has a value which it increments by
one whenever the clock signal it's connected to "ticks". When the timer reaches
its maximum value (called MAX
or TOP
), it "overflows" and resets back to
zero. You can configure it to trigger an interrupt everytime this happens.
Here is a small diagram to demonstrate this:
counter value:
MAX +-----+ +-----+
| | | |
+-----+ | +-----+ |
| | | |
+-----+ | +-----+ | +-----
| | | | |
+-----+ | +-----+ | +-----+
| | | | |
0 --+ +-----+ +-----+
clock signal:
HIGH +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+
| | | | | | | | | | | | | | | | | | | | | | | |
LOW --+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--
overflow interrupt:
^ ^
+-fires +-fires
The microcontroller used on most Arduino boards, ATmega328P, has
3 such timers: TC0
, TC1
, and TC2
. Each has slightly different features
but for this purpose that does not matter much. What we want is just our
interrupt triggering periodically, in fixed intervals. Let's choose TC0
as
that's also what Arduino uses.
The AVR timers run off the core clock which is usually 16 MHz for Arduino boards. A prescaler (also called clock divider) allows slowing it down by a factor of 8, 64, 256, or 1024. The timer will overflow whenever it counts "past" its maximum value which by default is 255. This means, the timer has 256 steps or counts (including 0) per overflow. One can also configure a lower maximum, in which case the number of counts is smaller and thus overflows happen more quickly.
Putting it into a formula, the time between two overflows can be calculated as
Tovf = Prescale * Counts / Fcore
We're lucky because the available configurations allow us to get an overflow
interval that is an exact multiple of 1 millisecond. This means we don't have
to plage ourselves with fractional parts as can be seen in the Arduino code.
Here is a table to show the possibilities (for TC0
):
Prescale Factor | Timer Counts (MAX + 1) | Overflow Interval |
---|---|---|
64 | 250 | 1 ms |
256 | 125 | 2 ms |
256 | 250 | 4 ms |
1024 | 125 | 8 ms |
1024 | 250 | 16 ms |
Depending on your needs you can choose a shorter interval at the cost of more CPU
time being used for serving the overflow interrupts or a longer interval at the cost of
millis()
accuracy.
Armed with this knowledge, we can already fill in the constant value that is added on each overflow interrupt:
const PRESCALER: u32 = 1024;
const TIMER_COUNTS: u32 = 125;
const MILLIS_INCREMENT: u32 = PRESCALER * TIMER_COUNTS / 16000;
Hardware Configuration
Finally, we need to configure the timer with our settings. Going back to the
C implementation, this is done in the init()
function and roughly
looks like this:
// set "WGM" to fast PWM
sbi(TCCR0A, WGM01);
sbi(TCCR0A, WGM00);
// set timer 0 prescale factor to 64
sbi(TCCR0B, CS01);
sbi(TCCR0B, CS00);
// enable timer 0 overflow interrupt
sbi(TIMSK0, TOIE0);
It is not important to understand what exactly is going on here. In simple
terms, this code writes some values to peripheral registers which can be thought
of as "memory locations" for configuring/interacting with the timer. sbi()
is
an instruction to set single bits in those registers but we need not care about
that. The important part is the overall picture of what is happening:
-
Configure the overflow behavior of the timer in the
TCCR0A
register. Arduino configures the timer to overflow upon reaching 255 (called "fast PWM" mode) but we want it to overflow earlier (onTIMER_COUNTS
from the table above). Thus we will need a different mode here which is called CTC. That's short for "Clear Timer on Compare Match".Choosing CTC mode also means we have to use a different ISR:
TIMER0_COMPA
instead ofTIMER0_OVF
. That's because the latter only triggers when the timer reaches 255 which it never will in CTC mode. The former triggers upon reaching our new "maximum" which is what we want.With CTC mode, we'll also need to pass the new "maximum" to the timer. This is not shown above, but is done by simply writing the value to the
OCR0A
register. -
Configure the prescaling factor in the
TCCR0B
register. Arduino chooses 64; we possibly need to adapt for the other prescaler values. -
Enable the overflow interrupt in the
TIMSK0
register. From this point onwards, whenever the timer overflows and interrupts are enabled globally, our ISR will run.
Doing these register writes in Rust looks quite different from C. A full
explanation of the Rust mechanism is too much for this post, but the Embedded
Rust WG has excellent resources explaining it in detail. Check out the
Embedded Rust Book and the svd2rust
API documentation.
The code looks like this:
let tc0 = dp.TC0;
tc0.tccr0a.write(|w| w.wgm0().ctc());
tc0.ocr0a.write(|w| unsafe { w.bits(TIMER_COUNTS as u8) });
tc0.tccr0b.write(|w| match PRESCALER {
8 => w.cs0().prescale_8(),
64 => w.cs0().prescale_64(),
256 => w.cs0().prescale_256(),
1024 => w.cs0().prescale_1024(),
_ => panic!(),
});
tc0.timsk0.write(|w| w.ocie0a().set_bit());
Finally, we should reset the counter value, and enable interrupts globally to let our ISR start updating:
avr_device::interrupt::free(|cs| {
MILLIS_COUNTER.borrow(cs).set(0);
});
unsafe { avr_device::interrupt::enable() };
With this, we have all the pieces in place and can start using this homegrown
millis()
! I've pushed the full code as an example in avr-hal
on GitHub:
examples/arduino-uno/src/bin/uno-millis.rs
I hope this helps! If you have any questions, feel free to reach out via mail or open an issue on GitHub!