As a sort of prologue to this overly long answer...
This question got me deeply captivated with the problem of interrupt
latency, to the point of losing sleep in counting cycles instead of
sheep. I am writing this response more for sharing my findings
than for just answering the question: most of this material may actually
not be at a level suitable for a proper answer. I hope it will be
useful, however, for readers that land here in search of solutions for
latency problems. The first few sections are expected to be useful to a
wide audience, including the original poster. Then, it gets hairy along
the way.
Clayton Mills already explained in his answer that there is some latency
in responding to interrupts. Here I will focus on quantifying the
latency (which is huge when using the Arduino libraries), and on the
means to minimize it. Most of what follows is specific to the hardware
of the Arduino Uno and similar boards.
Minimizing the interrupt latency on the Arduino
(or how to get from 99 down to 5 cycles)
I will use the original question as a working example, and restate the
problem in terms of interrupt latency. We have some external event that
triggers an interrupt (here: INT0 on pin change). We need to take some
action when the interrupt is triggered (here: read a digital input). The
problem is: there is some delay between the interrupt being triggered
and our taking the appropriate action. We call this delay "interrupt
latency". A long latency is detrimental in many situations. In this
particular example, the input signal may change during the delay, in
which case we get a faulty reading. There is nothing we can do to avoid
the delay: it is intrinsic to the way interrupts work. We can, however,
try to make it as short as possible, which should hopefully minimize the
bad consequences.
The first obvious thing we can do is take the time-critical action,
inside the interrupt handler, as soon as possible. This means calling
digitalRead()
once (and only once) at the very beginning of the
handler. Here is the zeroth version of the program upon which we will
build:
#define INT_NUMBER 0
#define PIN_NUMBER 2 // interrupt 0 is on pin 2
#define MAX_COUNT 200
volatile uint8_t count_edges; // count of signal edges
volatile uint8_t count_high; // count of high levels
/* Interrupt handler. */
void read_pin()
{
int pin_state = digitalRead(PIN_NUMBER); // do this first!
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (pin_state == HIGH) count_high++;
}
void setup()
{
Serial.begin(9600);
attachInterrupt(INT_NUMBER, read_pin, CHANGE);
}
void loop()
{
/* Wait for the interrupt handler to count MAX_COUNT edges. */
while (count_edges < MAX_COUNT) { /* wait */ }
/* Report result. */
Serial.print("Counted ");
Serial.print(count_high);
Serial.print(" HIGH levels for ");
Serial.print(count_edges);
Serial.println(" edges");
/* Count again. */
count_high = 0;
count_edges = 0; // do this last to avoid race condition
}
I tested this program, and the subsequent versions, by sending it trains
of pulses of varying widths. There is enough spacing between the pulses
to ensure that no edge is missed: even if the falling edge is received
before the previous interrupt is done, the second interrupt request will
be put on hold and eventually serviced. If a pulse is shorter than the
interrupt latency, the program reads 0 on both edges. The reported
number of HIGH levels is then the percentage of correctly read pulses.
What happens when the interrupt is triggered?
Before trying to improve the code above, we will take a look at the
events that unfold right after the interrupt is triggered. The hardware
part of the story is told by the Atmel documentation. The software part,
by disassembling the binary.
Most of the time, the incoming interrupt is serviced right away. It may
happen, however, that the MCU (meaning "microcontroller") is in the
middle of some time-critical task, where interrupt servicing is
disabled. This is typically the case when it is already servicing
another interrupt. When this happens, the incoming interrupt request is
put on hold and serviced only when that time-critical section is done.
This situation is hard to avoid completely, because there are quite a
few of those critical sections in the Arduino core library (which I will
call "libcore" in the following). Fortunately, these sections are
short and run only every so often. Thus, most of the time, our interrupt
request will be serviced right away. In the following, I will assume
that we do not care about those few instances when this is not the case.
Then, our request is serviced immediately. This still involves a lot of
stuff that can take quite a while. First, there is a hardwired sequence.
The MCU will finish executing the current instruction. Fortunately, most
instructions are single-cycle, but some can take up to four cycles.
Then, the MCU clears an internal flag that disables further servicing of
interrupts. This is intended to prevent nested interrupts. Then, the PC
is saved into the stack. The stack is an area of RAM reserved for this
kind of temporary storage. The PC (meaning "Program Counter") is an
internal register holding the address of the next instruction the MCU is
about to execute. This is what allows the MCU to know what to do next,
and saving it is essential because it will have to be restored in order
for the main program to resume from where it was interrupted. The PC is
then loaded with a hardwired address specific to the request received,
and this is the end of the hardwired sequence, the rest being
software-controlled.
The MCU now executes the instruction from that hardwired address. This
instruction is called an "interrupt vector", and is generally a "jump"
instruction that will bring us to a special routine called an ISR
("Interrupt Service Routine"). In this case, the ISR is called
"__vector_1", a.k.a. "INT0_vect", which is a misnomer because it is
an ISR, not a vector. This particular ISR comes from libcore. Like any
ISR, it starts with a prologue that saves a bunch of internal CPU
registers on the stack. This will allow it to use those registers and,
when it is done, restore them to their previous values in order not to
disturb the main program. Then, it will look for the interrupt handler
that was registered with attachInterrupt()
, and it will call that
handler, which is our read_pin()
function above. Our function will
then call digitalRead()
from libcore. digitalRead()
will look into
some tables in order to map the Arduino port number to the hardware I/O
port it has to read and the associated bit number to test. It will also
check whether there is a PWM channel on that pin that would need to be
disabled. It will then read the I/O port... and we are done. Well, we
are not really done servicing the interrupt, but the time-critical task
(reading the I/O port) is done, and it is all that matters when we are
looking at latency.
Here is a short summary of all the above, together with the associated
delays in CPU cycles:
- hardwired sequence: finish current instruction, prevent nested
interrupts, save PC, load address of vector (≥ 4 cycles)
- execute interrupt vector: jump to ISR (3 cycles)
- ISR prologue: save registers (32 cycles)
- ISR main body: locate and call user-registered function (13 cycles)
- read_pin: call digitalRead (5 cycles)
- digitalRead: find the relevant port and bit to test (41 cycles)
- digitalRead: read the I/O port (1 cycle)
We will assume the best case scenario, with 4 cycles for the
hardwired sequence. This gives us a total latency of 99 cycles, or
about 6.2 µs with a 16 MHz clock. In the following, I will
explore some tricks that can be used to lower this latency. They come
roughly in increasing order of complexity, but they all need us to
somehow dig into the internals of the MCU.
Use direct port access
The obvious first target for shortening the latency is digitalRead()
.
This function provides a nice abstraction to the MCU hardware, but it is
too inefficient for time-critical work. Getting rid of this one is
actually trivial: we just have to replace it with digitalReadFast()
,
from the digitalwritefast
library. This cuts the latency almost by half at the cost of a small
download!
Well, that was too easy to be any fun, I will rather show you how to do
it the hard way. The purpose is to get us started into low-level stuff.
The method is called "direct port access" and is nicely documented on
the Arduino reference at the page on Port
Registers. At this
point, it is a good idea to download and take a look at the ATmega328P
datasheet.
This 650-page document may seem somewhat daunting at first look. It is,
however, well organised into sections specific to each of the MCU
peripherals and features. And we only need to check the sections
relevant to what we are doing. In this case, it is the section named
I/O ports. Here is a summary of what we learn from those readings:
- The Arduino pin 2 is actually called PD2 (i.e. port D, bit 2) on the
AVR chip.
- We get the whole port D at once by reading a special MCU register called
"PIND".
- We then check bit number 2 by doing a bitwise logical and (the C ‘&’
operator) with
1 << 2
.
So, here is our modified interrupt handler:
#define PIN_REG PIND // interrupt 0 is on AVR pin PD2
#define PIN_BIT 2
/* Interrupt handler. */
void read_pin()
{
uint8_t sampled_pin = PIN_REG; // do this first!
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (sampled_pin & (1 << PIN_BIT)) count_high++;
}
Now, our handler will read the I/O register as soon as it is called. The
latency is 53 CPU cycles. This simple trick saved us 46 cycles!
Write your own ISR
The next target for cycle-trimming is the INT0_vect ISR. This ISR is
needed for providing the functionality of attachInterrupt()
: we can
change interrupt handlers at any time during program execution. However,
although nice to have, this is not really useful for our purpose. Thus,
instead of having the libcore's ISR locate and call our interrupt
handler, we will save a few cycles by replacing the ISR by our
handler.
This is not as hard as it sounds. ISRs can be written like normal
functions, we just have to be aware of their specific names, and define
them using a special ISR()
macro from avr-libc. At this point it would
be good to have a look at the avr-libc's documentation on
interrupts,
and at the datasheet section named External Interrupts. Here is the
short summary:
- We have to write a bit in a special hardware register called EICRA
(External Interrupt Control Register A) in order to configure the
interrupt to be triggered on any change of the pin value. This will be
done in
setup()
.
- We have to write a bit in another hardware register called EIMSK
(External Interrupt MaSK register) in order to enable the INT0
interrupt. This will also be done in
setup()
.
- We have to define the ISR with the syntax
ISR(INT0_vect) { ... }
.
Here is the code for the ISR and setup()
, everything else is
unchanged:
/* Interrupt service routine for INT0. */
ISR(INT0_vect)
{
uint8_t sampled_pin = PIN_REG; // do this first!
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (sampled_pin & (1 << PIN_BIT)) count_high++;
}
void setup()
{
Serial.begin(9600);
EICRA = 1 << ISC00; // sense any change on the INT0 pin
EIMSK = 1 << INT0; // enable INT0 interrupt
}
This comes with a free bonus: since this ISR is simpler than the one it
replaces, it needs less registers to do its job, then the
register-saving prologue is shorter. Now we are down to a latency of 20
cycles. Not bad considering that we started close to 100!
At this point I would say we are done. Mission accomplished. What
follows is only for those who are not afraid of getting their hands
dirty with some AVR assembly. Otherwise you can stop reading here, and
thank-you for getting so far.
Write a naked ISR
Still here? Good! For proceeding further, it would be helpful to have at
least some very basic idea of how assembly works, and to take a look at
the Inline Assembler
Cookbook
from the avr-libc documentation. At this point, our interrupt entry
sequence looks like this:
- hardwired sequence (4 cycles)
- interrupt vector: jump to ISR (3 cycles)
- ISR prologue: save regs (12 cycles)
- first thing in the ISR body: read the IO port (1 cycle)
If we want to do better, we have to move the reading of the port into
the prologue. The idea is the following: reading the PIND register will
clobber one CPU register, thus we need to save at least one register
before doing that, but the other registers can wait. We then need to
write a custom prologue that reads the I/O port right after saving the
first register. You have already seen in the avr-libc interrupt
documentation (you have read it, right?) that an ISR can be made
naked, in which case the compiler will emit no prologue or epilogue,
allowing us to write our own custom version.
The problem with this approach is that we will probably end up writing
the whole ISR in assembly. Not a big deal, but I would rather have the
compiler write those boring prologues and epilogues for me. So, here is
the dirty trick: we will split the ISR in two parts:
- the first part will be a short assembly fragment that will
- save a single register to the stack
- read PIND into that register
- store that value into a global variable
- restore the register from the stack
- jump to the second part
- the second part will be regular C code with compiler-generated
prologue and epilogue
Our previous INT0 ISR is then replaced by this:
volatile uint8_t sampled_pin; // this is now a global variable
/* Interrupt service routine for INT0. */
ISR(INT0_vect, ISR_NAKED)
{
asm volatile(
" push r0 \n" // save register r0
" in r0, %[pin] \n" // read PIND into r0
" sts sampled_pin, r0 \n" // store r0 in a global
" pop r0 \n" // restore previous r0
" rjmp INT0_vect_part_2 \n" // go to part 2
:: [pin] "I" (_SFR_IO_ADDR(PIND)));
}
ISR(INT0_vect_part_2)
{
if (count_edges >= MAX_COUNT) return; // we are done
count_edges++;
if (sampled_pin & (1 << PIN_BIT)) count_high++;
}
Here we are using the ISR() macro to have the compiler instrument
INT0_vect_part_2
with the required prologue and epilogue. The compiler
will complain that "‘INT0_vect_part_2’ appears to be a misspelled
signal handler", but the warning can be safely ignored. Now the ISR has
a single 2-cycle instruction before the actual port read, and the total
latency is only 10 cycles.
Use the GPIOR0 register
What if we could have a register reserved for this specific job? Then,
we would not need save anything before reading the port. We can actually
ask the compiler to bind a global variable to a
register.
This, however, would require us to recompile the whole Arduino core and
libc in order to make sure the register is always reserved. Not really
convenient. On the other hand, the ATmega328P happens to have three
registers that are not used by the compiler nor any library, and are
available for storing whatever we want. They are called GPIOR0, GPIOR1
and GPIOR2 (General Purpose I/O Registers). Although they are mapped
in the I/O address space of the MCU, these are actually not I/O
registers: they are just plain memory, like three bytes of RAM that
somehow got lost in a bus and ended up in the wrong address space. These
are not as capable as the internal CPU registers, and we cannot copy
PIND into one of these with the in
instruction. GPIOR0 is interesting,
though, in that it is bit-addressable, just like PIND. This will allow
us to transfer the information without clobbering any internal CPU
register.
Here is the trick: we will make sure that GPIOR0 is initially zero (it
is actually cleared by hardware at boot time), then we will use the
sbic
(Skip next instruction if some Bit in some I/o register is Clear)
and the sbi
(Set to 1 some Bit in some I/o register) instructions as
follows:
sbic PIND, 2 ; skip the following if bit 2 of PIND is clear
sbi GPIOR0, 0 ; set to 1 bit 0 of GPIOR0
This way, GPIOR0 will end up being 0 or 1 depending on the bit we wanted
to read from PIND. The sbic instruction takes 1 or 2 cycles to execute
depending on whether the condition is false or true. Obviously, the PIND
bit is accessed on the first cycle. In this new version of the code, the
global variable sampled_pin
is not useful anymore, since it is
basically replaced by GPIOR0:
/* Interrupt service routine for INT0. */
ISR(INT0_vect, ISR_NAKED)
{
asm volatile(
" sbic %[pin], %[bit] \n"
" sbi %[gpio], 0 \n"
" rjmp INT0_vect_part_2 \n"
:: [pin] "I" (_SFR_IO_ADDR(PIND)),
[bit] "I" (PIN_BIT),
[gpio] "I" (_SFR_IO_ADDR(GPIOR0)));
}
ISR(INT0_vect_part_2)
{
if (count_edges < MAX_COUNT) {
count_edges++;
if (GPIOR0) count_high++;
}
GPIOR0 = 0;
}
It should be noted that GPIOR0 has to always be reset in the ISR.
Now, the sampling of the PIND I/O register is the very first thing done
inside the ISR. Total latency is 8 cycles. This is about the best we can
do before getting stained with terribly sinful kludges. This is again a
good opportunity to stop reading...
Put the time-critical code in the vector table
For those still here, here is our current situation:
- hardwired sequence (4 cycles)
- interrupt vector: jump to ISR (3 cycles)
- ISR body: read the IO port (on 1st cycle)
There is obviously little room for improvement. The only way we could
shorten the latency at this point is by replacing the interrupt vector
itself by our code. Be warned that this should be immensely distasteful
to anyone who values clean software design. But it is possible, and I
will show you how.
The layout of the ATmega328P vector table can be found in the datasheet,
section Interrupts, subsection Interrupt Vectors in ATmega328 and
ATmega328P. Or by disassembling any program for this chip. Here is how
it looks like. I am using avr-gcc and avr-libc's conventions (__init
is vector 0, addresses are in bytes) which are different from Atmel's.
address │ instruction │ comment
────────┼─────────────────┼──────────────────────
0x0000 │ jmp __init │ reset vector
0x0004 │ jmp __vector_1 │ a.k.a. INT0_vect
0x0008 │ jmp __vector_2 │ a.k.a. INT1_vect
0x000c │ jmp __vector_3 │ a.k.a. PCINT0_vect
...
0x0064 │ jmp __vector_25 │ a.k.a. SPM_READY_vect
Each vector has a 4-byte slot, filled with a single jmp
instruction.
This is a 32-bit instruction, unlike most AVR instructions which are
16-bit. But a 32-bit slot is too small to hold the first part of our
ISR: we can fit the sbic
and sbi
instructions, but not the rjmp
.
If we do that, the vector table ends up looking like this:
address │ instruction │ comment
────────┼─────────────────┼──────────────────────
0x0000 │ jmp __init │ reset vector
0x0004 │ sbic PIND, 2 │ the first part...
0x0006 │ sbi GPIOR0, 0 │ ...of our ISR
0x0008 │ jmp __vector_2 │ a.k.a. INT1_vect
0x000c │ jmp __vector_3 │ a.k.a. PCINT0_vect
...
0x0064 │ jmp __vector_25 │ a.k.a. SPM_READY_vect
When INT0 fires, PIND will be read, the relevant bit will be copied into
GPIOR0, and then the execution will fall through to the next vector.
Then, the ISR for INT1 will be called, instead of the ISR for INT0. This
is creepy, but since we are not using INT1 anyway, we will just "hijack"
its vector for servicing INT0.
Now, we just have to write our own custom vector table to override the
default one. It turns out it is not so easy. The default vector table is
provided by the avr-libc distribution, in an object file called
crtm328p.o that is automatically linked with any program we build.
Unlike library code, object-file code is not meant to be overridden:
trying to do that will give a linker error about the table being defined
twice. This means we have to replace the whole crtm328p.o with our
custom version. One option is to download the full avr-libc source
code, do our
custom modifications in
gcrt1.S,
then build this as a custom libc.
Here I went for a lighter, alternative approach. I wrote a custom
crt.S, which is a simplified version of the original from avr-libc. It
lacks a few rarely used features, like the ability to define a "catch
all" ISR, or to be able to terminate the program (i.e. freeze the
Arduino) by calling exit()
. Here is the code. I trimmed the repetitive
part of the vector table in order to minimize scrolling:
#include <avr/io.h>
.weak __heap_end
.set __heap_end, 0
.macro vector name
.weak \name
.set \name, __vectors
jmp \name
.endm
.section .vectors
__vectors:
jmp __init
sbic _SFR_IO_ADDR(PIND), 2 ; these 2 lines...
sbi _SFR_IO_ADDR(GPIOR0), 0 ; ...replace vector_1
vector __vector_2
vector __vector_3
[...and so forth until...]
vector __vector_25
.section .init2
__init:
clr r1
out _SFR_IO_ADDR(SREG), r1
ldi r28, lo8(RAMEND)
ldi r29, hi8(RAMEND)
out _SFR_IO_ADDR(SPL), r28
out _SFR_IO_ADDR(SPH), r29
.section .init9
jmp main
It can be compiled with the following command line:
avr-gcc -c -mmcu=atmega328p silly-crt.S
The sketch is identical to the previous one except that there is no
INT0_vect, and INT0_vect_part_2 is replaced by INT1_vect:
/* Interrupt service routine for INT1 hijacked to service INT0. */
ISR(INT1_vect)
{
if (count_edges < MAX_COUNT) {
count_edges++;
if (GPIOR0) count_high++;
}
GPIOR0 = 0;
}
To compile the sketch, we need a custom compile command. If you have
followed so far, you probably know how to compile from the command line.
You have to explicitly request silly-crt.o to be linked to your program,
and add the -nostartfiles
option to avoid linking in the original
crtm328p.o.
Now, the reading of the I/O port is the very first instruction executed
after the interrupt triggers. I tested this version by sending it short
pulses from another Arduino, and it can catch (although not reliably)
the high level of pulses as short as 5 cycles. There is nothing more we
can do to shorten the interrupt latency on this hardware.
pin
,x
andtest_array
definition, and alsoloop()
method; it would enable us to see if this can be a concurrency issue when accessing variables modified bytest_func
.if (digitalRead(pin) == HIGH) ... else ...;
or, better yet, this single-line ISR:test_array[x++] = digitalRead(pin);
.