Note: The mercurial server is disabled at the moment while I investigate whether it can run with an acceptably low CPU load – Mike.

Interrupt latency – an experiment (Digital Systems)

Copyright © 1993–2025 J. M. Spivey
Jump to navigation Jump to search

[Needs editing to match micro:bian conventions.]

Let's define interrupt latency to be the time delay between a physical event happening to cause an interrupt and the beginning of the specific code we write to deal with it. To measure this delay, we will need some cooperation from the hardware, but luckily the timers on the Nordic chip provide everything we need. We will program a timer to request an interrupt when it overflows, and also set a GPIO pin low – predictably enough, one of the pins on the micro:bit that are connected to LEDs. Then we can set the GPIO pin high again in the interrupt handler, or in the process connected to the interrupt, and measure the pulse width on the GPIO pin to find the interrupt latency in each case.

Simpler microcontrollers than the Nordic chip have specialised hardware for using a timer to control a GPIO pin, because that is a common method for producing a pseudo-analog output using pulse width modulation (PWM). On the Nordic chip, things are a bit more complicated, and we will need to chain together the GPIO pin with a GPIO tasks and events (GPIOTE) channel, a programmable peripheral interconnect (PPI) channel and the timer to achieve the same effect. I've included the exact setup code below in case you want to perform a similar experiment.

We will actually configure two GPIO pins in this way, and reset one of them in a specialised interrupt handler and the other in a micro:bian device driver process: that will allow us to assess the difference in latency between the two approaches. Here's the code for the interrupt handler:

void timer2_handler(void) {
    // Reset one of the GPIO pins
    GPIOTE_OUT[0] = 1;

    // Send INTERRUPT message to the driver
    disable_irq(TIMER2_IRQ);
    interrupt(DRIVER);
}

The first line of actual code triggers the GPIOTE task a second time to reset the pin, and the other two lines of code are equivalent to the standard micro:bian interrupt handler: disable the interrupt so it does not continue to fire, then send an INTERRUPT message to the driver.

The driver process itself is simple: in a loop, it waits for an interrupt, then turns off the second I/O pin and resets the interrupt.

void timer_task(int arg) {
    message m;

    GPIO_DIRSET = 0xfff0;
    GPIO_OUT = 0x3ff0;

    // Set up the timer (see below)

    while (1) {
        receive(HARDWARE, &m);

        // Reset the second GPIO pin
        GPIOTE_OUT[1] = 1;

        // Acknowledge the interrupt
        TIMER2_COMPARE[0] = 0;
        reconnect(TIMER2_IRQ);
    }
}

Running the program produces negative-going pulses on both I/O pins, but of very different lengths. The horizontal scale is 5μs per division.

Latency for interrupt handler and driver process
Latency for interrupt handler and driver process

The upper trace shows the pulse that is finished by the interrupt handler; it is about 1.44μs long, or 23 clock cycles at 16MHz. Interrupts on the nRF51 have a published latency of 16 cycles, and examining the machine code for the interrupt handler suggests that it will take an additional 8 cycles to set the GPIO line high again. The comes to 24 cycles rather than 23, but there is a one-cycle latency in the PPI module at the start of the pulse, so that agrees nicely.

The lower trace shows the performance of the device driver process, with a much longer latency of about 20μs. That is still fast enough for many purposes, but for very time-sensitive events, the direct interrupt handler clearly has a significant advantage.

Timings

  • To start of interrupt handler: 1.44 mu = 23 cycles
  • Immediately after start: 1.82 mu = 29 cycles
    • In interrupt, before message send: 4.89 mu = 78 cycles
    • In interrupt, after make_ready call: 8.89 mu = 142 cycles
    • At end of interrupt: 10.45 mu = 167 cycles
  • End of interrupt handler: 10.89 mu = 174 cycles
  • Context switch
    • Save context ...
      • Start of pendsv_handler: 11.89 mu = 190 cycles
      • After isave: 13.76 mu = 220 cycles
    • Start of cxtswitch: 14.20 mu = 227 cycles
    • After make_ready(current): 15.57 mu = 249 cycles
    • After choose_proc(): 17.39 mu = 278 cycles
    • Restore context ...
      • After cxtswitch: 18.01 mu = 288 cycles
      • After irestore: 19.57 mu = 313 cycles
  • In driver process: 20.64 mu = 330 cycles

Setting up the hardware

  • We first connect timer 2 to a 16MHz clock and arrange for it to reset itself with a TIMER2_COMPARE[0] event once every 16,000 counts or 1ms. The resetting is accomplished by enabling the 'shortcut' TIMER_COMPARE0_CLEAR – a shortcut because the same effect could be achieved with an interrupt handler or with the PPI system we are about to introduce. We also set the timer to request an interrupt on the COMPARE[0] event.
    TIMER2_STOP = 1;
    TIMER2_MODE = TIMER_Mode_Timer;
    TIMER2_BITMODE = TIMER_16Bit;
    TIMER2_PRESCALER = 0; // 16 MHz
    TIMER2_CLEAR = 1;
    TIMER2_CC[0] = 16000; // Period 1 ms
    TIMER2_SHORTS = BIT(TIMER_COMPARE0_CLEAR);
    TIMER2_INTENSET = BIT(TIMER_INT_COMPARE0);
  • Next, we will configure two channels of the PPI system to connect the COMPARE[0] event of the timer to tasks that toggle the two GPIO pins. Each PPI channel has an event endpoint EEP that we connect to the timer, and a task endpoint that we connect to the GPIOTE hardware. After connecting them, we must enable the two channels.
    PPI_CH[0].EEP = &TIMER2_COMPARE[0];
    PPI_CH[0].TEP = &GPIOTE_OUT[0];
    PPI_CH[1].EEP = &TIMER2_COMPARE[0];
    PPI_CH[1].TEP = &GPIOTE_OUT[1];
    PPI_CHENSET = BIT(0) | BIT(1);
  • Third, we must configure the two GPIOTE channels. Each is in task mode (rather than event mode), is connected to a certain GPIO pin (PSEL), and toggles the pin from 1 to 0 or 0 to 1 when triggered. (It's a bit of a flaw of the nRF51's GPIOTE system that the POLARITY setting 0-to-1 and 1-to-0 are pretty useless, because there's no easy way of setting the state back again.) The initial state of each pin is 1.
    // GPIOTE channels 0 and 1 toggle the two pins
    GPIOTE_CONFIG[0] =
        FIELD(GPIOTE_CONFIG_MODE, GPIOTE_Mode_Task)
        | FIELD(GPIOTE_CONFIG_PSEL, 4)
        | FIELD(GPIOTE_CONFIG_POLARITY, GPIOTE_Polarity_Toggle)
        | FIELD(GPIOTE_CONFIG_OUTINIT, 1);
    GPIOTE_CONFIG[1] =
        FIELD(GPIOTE_CONFIG_MODE, GPIOTE_Mode_Task)
        | FIELD(GPIOTE_CONFIG_PSEL, 5)
        | FIELD(GPIOTE_CONFIG_POLARITY, GPIOTE_Polarity_Toggle)
        | FIELD(GPIOTE_CONFIG_OUTINIT, 1);

Now we are ready to connect the driver process to the interrupt and start the timer.

    connect(TIMER2_IRQ);
    TIMER2_START = 1;