Hardware Interrupts Are Not That Scary


Learn about hardware timer interrupts on an embedded chip. You will lean how to use them to make a high-speed, high-precision data logging device with a micro controller. It will log a heart rate as an example, but can be made to log pretty much anything you can find a sensor for.


Important: The code in this tutorial is licensed under the GNU 3.0 open source license and you are free to modify and redistribute the code, given that you give others you share the code with the same right, and cite my name (use citation format below). You are not free to redistribute or modify the tutorial itself in any way. By reading on you agree to these terms. If you disagree, please navigate away from this page.
Citation format
van Gent, P. (2018). Hardware Interrupts Are Not That Scary. A tech blog about fun things with Python and embedded electronics. Retrieved from: http://www.paulvangent.com/2018/03/27/hardware-interrupts-are-not-that-scary/


Introduction
This tutorial will explain how hardware interrupts work to create a primitive form of multithreading. As an example we will deal with building a usb-datalogger device that has a stable logging rate (emphasis on the latter), although hardware interrupts can be used for a lot more. I will show you how to do it on the ATMega328p (the ATTiny45 will follow soon). Most tutorials out there for these kinds of loggers make use of the delay() function in the Arduino IDE. This is actually something you should avoid at all costs if you want to measure at a high sampling rate (let’s say over 50Hz), for several reasons.
Delay() is a blocking function. This means that when you tell the microcontroller to wait for 10ms, nothing will happen for that time, including that no inputs will be registered. Secondly, even though these chips are single threaded, there’s clever ways to approximate multi threading through efficient time sharing of CPU resources, which you cannot use if you’re using delay() since it blocks everything. Delay() could theoretically also interfere with other libraries such as servo controllers. This can be time-critical, for example if the controllers that need to keep a DIY-drone in the air don’t respond for a second because they’re waiting for delay() to complete, you could be in trouble.
In short, using delay() can cause unintended or unexpected behaviour at higher speeds and in combination with other functionality. It is also not a very reliable timer for short time intervals, meaning delay(1) might actually not result in exactly 1 ms of inactivity for several reasons. This is a problem if you want to sample a time-series signal evenly, and it gets more critical the higher your sampling speed gets. Let’s get to a proper solution then, shall we?


Getting started
The grocery list is short for this one. You can use this tutorial with any sensor you can hook up to the chip. For the tutorial, you’ll need:

  • A microcontroller. I provide examples for the ATMega328p, the ATMega328p is used on many Arduino (-compatible) boards;
  • A serial <-> USB converter, for example this one;
  • A heart rate sensor, I use one of these, but any analog sensor should do for the examples here.

Why interrupting is not rude
A common approach for an embedded data logger is to have a buffer that fills up with sensor data. Once it’s full you transmit the buffer’s contents or store them on an SD card, empty it, and start over. But what about continuous logging at high speeds? Transmitting or saving takes time. The higher the sampling rate gets, the less time you have to transmit before it’s time to poll a sensor for its value again. This poses a problem for continuous logging where timing accuracy is crucial. Assuming 1,000 Hz, transmitting even a small 1-second chunk from the buffer will likely take more than 1 ms.
To take the above as an example. Assuming compact 16bit integers, 1000 of them take 16,000 bits (2 Kb) of space. On fast serial speeds of 250,000 BAUD (= max symbol rate/second, fast in terms of the ATMega328p), transmission of 16,000 bits will take (16,000 / 250,000) 64 miliseconds. You will miss 64 data points each transmission cycle. Writing to SD will definitely take more (10-80ms, depending on card speed and quality), since SD cards also suffer from latency issues that offset their higher data transfer speeds when writing small chunks of data. Wouldn’t it be great if you could interrupt data transfer to poll a sensor, write the value to a buffer, and continue on with transferring data? That’s exactly what interrupts can do!
As you might imagine we’re not the first to run into this problem, and hardware engineers have long since put useful functions in processors. One of these functions is called an interrupt. Basically what happens when you send an interrupt to the processor, is that it saves the current state of its registers (in essence it ‘memorises’ where it was in the code and what it was doing), performs some priority task as defined in the interrupt function, then restores its previous state and continues execution of the main program.
This also enables longer running computations to be spread out in between sensor polling without requiring a second processor to run in parallel. Even if you do not want to perform any tasks in between, using interrupts will guarantee a very stable sampling rate, using delay() does not. Useful, right? Let’s get started.


Timer interrupt details
A so-called ‘timer interrupt’ is simply a counter in the processor. When the counter reaches a value you set, it fires an interrupt and does something. How you define the timer interrupt routine depends on the type of microcontroller you’re using. For the details you can refer to the manufacturers datasheet. Check the datasheet for the 328p here. Yes it’s a 660-page beast, no you don’t need to read it all. I will tell you where to look in the next section.
The basic tenets are the same in all cases. Every processor clock cycle, a counter is incremented and compared to some value. If the values match, an interrupt is generated and whatever you defined should happen then, happens. There is a caveat, however. Notice I said “every clock cycle”? At 16MHz the ATMega328p will not become your next A.I. overlord any time soon, but even so the counter will still make 16,000,000 increments in one second. The problem is that the counter registers on the chip are either 8-bit or 16-bit, meaning they can hold values up to 255 and 65535, respectively. This means that 62,500 interrupts will be generated per second in the 8-bit case, and approximately 244 in the 16-bit case! That is likely not what we want.
Luckily, again the electrical engineers that designed the chip have done the work for us. It’s called a prescaler. What it does is update the counter not every clock cycle, but every ‘n’ clock cycles. Usually the prescaler can be set to values 1, 8, 64, 256 or 1,024. This means that if we set it to 1,024, the counter will be incremented by one every 1,024 clock cycles. This is every 64 microseconds at 16MHz, which gives an interrupt at most every ~4.2 seconds (65,536 * 64) for the 16-bit timer.


Setting the timer on the ATMega328p
Here we get to the practical part. The ATMega328p has three timer registries: timer0, timer1 and timer2. They are 8, 16 and 8-bit respectively. We will use timer1 in this tutorial, since certain arduino libraries (delay(), millis(), micros()) rely on timer0. I don’t want you pasting this code into one of your projects without reading the tutorial and having it break unexpectedly. Be advised that the servo library relies om timer1, so don’t use this code in conjunction with this library.
The code can be a bit daunting at first because we’re writing to registries using fancy address names, but it is not that hard. Let’s look at the code first, then explain what happens below:

cli();
TCCR1A = 0;// set entire TCCR2A register to 0
TCCR1B = 0;// same for TCCR2B
TCNT1  = 0;//initialize counter value to 0, see datasheet pp113, 16.3
TCCR1B |= (1 << WGM12); //turn on CTC mode, see datasheet pp132, table 16-4
TCCR1B |= (1 << CS01) | (1 << CS00); //set prescaler, refer to datasheet table 15-9, pp108
TIMSK1 |= (1 << OCIE1A); //set timer compare match interrupt
OCR1A = 125;//timer interrupt value, see text below
sei();

So, what’s happening here? Let’s go over it line by line:
Line 1: cli(): this is the clear interrupts flag. We are going to change the behaviour of the timers and and interrupt flags, so to be safe we turn interrupts off while we’re editing them.
Line 3 and 4: we zero the registers for timer1 settings. Because we will be editing single-bit values and will *not* be updating all register bits, we zero the entire register first. This way we will not get any strange behaviours due to previous settings carrying over.
Line 5: TCNT1 is the name of timer1, let’s set it to 0 to be sure the first interrupt run will not start at any other value the timer may previously have reached.
Line 6: We turn on CTC mode (Clear timer On Compare), meaning that once we reach the desired timer value and generate an interrupt, we reset the timer back to zero. We do so by writing to single bits in the device registers.  We write a 1-value to bit WGM12 in the register TCCR1B. Can you find how I came to these register addresses by looking at table 16-4 in pp132?
Hint: The format for writing bit values here is “register_name |= (bit_value << bit_address_name )”. By giving the bit_address_name (WGM12 here), we let the compiler figure out where to write the 1 value. Note that alternatively, we could also write a full byte to fill the entire register at once using the format: “register_name |= 00001000” (see 16.11.2 on pp133).
Line 7: We set the prescaler value. Can you find which I set it to? Hint: refer to datasheet table 15-9, pp108. We use the same register writing format as outlined above for line 6.
Line 8: We set the timer mode to “compare match interrupt”. This instructs the microcontroller to generate an interrupt once the timer matches our predefined value.
Line 9: We set compare value for timer1-A, OCR1A, to the desired value. But how do we calculate this? Let’s take a little look in the next section.
Line 11: We turn normal interrupt behaviour back on.


Defining your interrupt frequency
You now know how to set up interrupts on the ATMega328p, but how do we go about determining the prescaler value and the compare register value (OCR1A)? Remember that the timer increments every clock cycle by default, or every n clock cycles based on the prescaler registry value. Calculating the setting for the OCR (timer interrupt value) is easy:

Re-arranging this to compute the CompareMatchValue:

So assuming we want to log at 2,000 Hz, let’s figure out the Prescaler value. The clock speed is 16MHz, the OCR is 2,000. Assuming a prescaler of 1 (no prescaling):

This would give us a CompareMatchValue of 7,999. This is fine if we use the 16-bit Timer1 (remember, 16-bit means a max timer value of 65,535). But what if we use the 8-bit timer0 or timer2?

Would give us 124, which fits in an 8-bit timer (max 255). This means a prescaler of 64 would be a good option here.
Note that you need an integer. If you get a float (e.g. 124.3) for the CompareMatchValue your timer will be imprecise, since the timer increments with 1 each interval. If this small discrepancy is ok for your project, don’t worry about it, otherwise tune the values to best approximate your desired sampling rate. Equally important to remember is that the prescaler can usually not be set to any arbitrary value. 1, 8, 64, 256 and 1,024 are the common settable values. Check the datasheet of whatever chip you want to use to be sure.
For a more detailed look into the technical workings of timer interrupts, see an excellent overview here.


Buffering……………………………………………….
Let’s work towards a standard example that reads an analog sensor value into a buffer, and transmits the contents over serial whenever a buffer is full. We run into the first problem: since data transmission takes time as well, we will run into the likely situation where we need to read a sensor value whenever the transfer is still in progress. Luckily this is an example of what interrupts are for, and you know how to use them now! However, once we time-share sensor reading with data transmission, where do we put the read value? The buffer is full after all.
To combat this we use a common ‘double switching buffer’ set-up. This means after we fill up buffer0, we mark it as ‘dirty’ and switch the logging to buffer1. Once the contents of buffer0 are transmitted, we clear it and mark it ‘clean’. If the description is a bit abstract for you, visualise it as such:

Note that if buffer0 is not clean when buffer1 fills up, we run into a buffer overflow. This means the sampling rate is too fast for this application. Fix this by either increasing the buffer size to give more time for the transfer, or reducing the sampling rate.
Alternatively you could use a circular buffer or circle buffer. Here we use the buffer together with an index counter. Once the index counter equals the buffer size (you’ve written the final piece of data into the final buffer slot), you begin transmission and reset the index counter. The only requirement here is that the transmission is faster than the total collection time, since you don’t want the index counter to ‘catch up’ to the transmission cycle. We will not be implementing this here, but you should probably be able to figure it out yourself with the info in here.


Putting the code together – ATMega328p
Putting it all together and implementing the sensor reading and double switching buffer, we get something like this:

int hrpin = 0; //I hooked the HR sensor up to analog0
struct dataBuffers {
  //initialise two buffers of 25 items each
  int16_t hrdata0[25] = {0};
  int16_t hrdata1[25] = {0};
  int16_t counter = 0; //buffer index to write values to
  int16_t bufferMarker = 0; //0 for buffer 0, 1 for buffer 1
  int16_t buffer0State = 0; //0 if clean, 1 if dirty
  int16_t buffer1State = 0;
};
struct dataBuffers dataBuf;
void setup()
{
  Serial.begin(115200);
  //set timer interrupt as defined in tutorial
  cli();
  TCCR1A = 0;
  TCCR1B = 0;
  TCNT1  = 0;
  TCCR1B |= (1 << WGM12);
  TCCR1B |= (1 << CS01) | (1 << CS00);
  TIMSK1 |= (1 << OCIE1A);
  OCR1A = 124;
  sei();
}
void updateSensors(struct dataBuffers &dataBuf)
{
  if (dataBuf.bufferMarker == 0) {
    dataBuf.hrdata0[dataBuf.counter] = analogRead(hrpin);
  } else {
    dataBuf.hrdata1[dataBuf.counter] = analogRead(hrpin);
  }
  dataBuf.counter++;
}
ISR(TIMER1_COMPA_vect)
{
  updateSensors(dataBuf);
}
void loop()
{
  if ((dataBuf.counter >=  24) && (dataBuf.bufferMarker == 0)) { //time to switch buffer0 to buffer1
    if(dataBuf.buffer1State == 1)  //check if buffer1 is dirty before switching
    {
      Serial.println("buffer0 overflow"); //report error if dirty
      delay(20); //give the processor some time to finish error transmit before halting
      exit(0); //halt processor
    } else { //if switching is possible
      dataBuf.buffer0State = 1; //mark buffer0 dirty
    }
    dataBuf.bufferMarker = 1; //set buffer flag to buffer1
    dataBuf.counter = 0; //reset datapoint counter
    for (int i = 0; i < 24; i++) { //transmit contents of buffer0
      Serial.println(dataBuf.hrdata0[i]);
    }
    dataBuf.buffer0State = 0; //release buffer0 after data tranmission, mark as clean
    //here follows same as above, except with reversed buffer order
  } else if ((dataBuf.counter >= 24) && (dataBuf.bufferMarker == 1)) {
    if(dataBuf.buffer0State == 1)
    {
      Serial.println("buffer1 overflow");
      delay(20);
      exit(0);
    } else {
      dataBuf.buffer1State = 1;
    }
    dataBuf.bufferMarker = 0;
    dataBuf.counter = 0;
    for (int i = 0; i < 24; i++) {
      Serial.println(dataBuf.hrdata1[i]);
    }
    dataBuf.buffer1State = 0;
  }
}

The one thing we did not discuss is how to handle interrupts. In this case it happens in line 40-42. This is a so-called ISR (Interrupt Service Routine) function, which is passed the timer compare registry A from hardware timer1. It compares the current counter value to the desired counter value. If they match, an interrupt is fired and the contents of the function are called.
The major thing to keep in mind with interrupts is to keep them short and fast fast fast. That means to never ever use a blocking function or a lengthy instruction set there. If interrupts take long to execute and start overlapping, you will get all sorts of inexplicable behaviour which inevitably results in major headaches for you. The current set-up logs at 2KHz and sends data every 25 measurements (12.5 milliseconds). A bigger buffer means less data transmission events but more RAM usage, and remember this chip only has 2048 bytes, so be careful with buffer sizes.
Using my setup, here is a PPG signal sampled for a few seconds at 2KHz:

That’s pretty impressive for a 16MHz, 15mAh processor! I tested up to 4KHz without issues (in short measurement intervals at least..), you could probably go higher.


Rounding up
See, I told you hardware interrupts are not that scary. Along the way you learned not only how to set them, but also how to manipulate the chipset’s registry values directly, and how to find which to change in the chip datasheet. That’s pretty advanced stuff that gets useful in a lot of embedded projects, so give yourself a pat on the back for reaching it this far!
As always, if you have any questions, let me know in the comments below.
I hope you liked this tutorial. Making content takes time and effort. If you like the work here, feel free to buy me a beer or support the blog.


Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.