You could use an internal timer.
You are using interrupts, and you are probably aware that interrupt
handling inevitably incurs some jitter. Thus, if you do not mind an
extra single CPU cycle of jitter, you could reconfigure the timer on
each interrupt in order to control the average interrupt period.
Here is how I would do it, with Timer 1 running in normal mode (CTC mode
would require an extra operation):
// Interrupt period in units of 1/2^16 CPU cycles.
// For 2.4 kHz, that is 16000/2.4*65536 = 436906666.666...
uint32_t interrupt_period = 436906667;
ISR(TIMER1_COMPA_vect) {
// Update the timer for the next interrupt.
static uint32_t accumulator;
accumulator += interrupt_period;
OCR1A = accumulator >> 16;
// The periodic task goes here...
}
void setup() {
// Configure Timer 1.
TCCR1A = 0; // normal mode
TCCR1B = _BV(CS10); // clock @ F_CPU
TIMSK1 = _BV(OCIE1A); // enable COMPA interrupt
}
void loop(){}
Here, accumulator
is a 32-bit extension of the timer's output compare
register. The upper 16 bits are copied into the actual timer
register, whereas the lower 16 bits ensure that the accumulated
error never exceeds one cycle.
This way of updating the timer should only take about 2 µs per
interrupt. It is a constant time operation, so it doesn't add extra
jitter to your task, other than the single cycle of jitter induced by
rounding to the next edge of the CPU clock.
There are a couple of caveats though:
- This can only work on an Arduino clocked off a crystal (e.g., a
Micro). Those clocked off a ceramic resonator (Uno...) would be too
unstable in frequency to meet your 10 ppm requirement.
- Even though a crystal is more than stable enough to meet your
requirement, it is unlikely to be accurate enough. You will then need
to calibrate it, e.g. by outputting a PWM signal (
analogWrite()
)
into a frequency meter. Then adjust the variable interrupt_period
according to the measured clock frequency.
Edit: I added the timer configuration to the code, and here comes an
explanation of how this code works.
Think of the timer as a digital alarm clock. It measures time in units
of CPU cycles instead of hours and minutes. It rolls over every
216 cycles instead of every 1440 minutes. But it is
otherwise a regular alarm clock. It has two alarms called “COMPA” and
“COMPB”, but you only need one. In order to perform a periodic task, you
turn the alarm on and, every time it rings, you advance the alarm time
by the required period. That is, you set:
new alarm time = previous alarm time + task period
Note that the addition has to be computed modulo the roll-over period of
your clock : 1440 minutes for a regular alarm clock,
216 cycles for the timer. Thankfully, the arithmetics on
unsigned numbers do this automatically.
Now, lets say the task period is 43 minutes and 35 seconds,
but the alarm can only be set with a resolution of one minute. What you
can do is compute the “ideal” alarm time on paper (this is the
accumulator
variable), and truncate it to the resolution of the clock
when setting the alarm. If you start at 00:00:00, you will compute the
next ideal time to be 00:43:35 and set the alarm to 00:43. When it
rings, you add the period to the previously computed time, you get
01:27:10, and you set the alarm to 01:27. Etc. Here is the list of the
alarm times as computed and as actually set on the clock:
computed set
───────────────
00:00:00 00:00
00:43:35 00:43
01:27:10 01:27
02:10:45 02:10
...
Note that, even though the limited resolution of the alarm setting
creates some jitter, the average task period is still exactly
43 minutes and 35 seconds.
The code I wrote above does exactly the same. The “ideal” alarm time is
computed as a fractional number of CPU cycles, written down in binary
fixed point, with 16-bits on each side of the radix point. In hex, it is
1A0A.AAAB CPU cycles. The computed alarm times are multiples of this
period, whereas the times actually written to OCR1A are the same
truncated to an integer:
computed set
───────────────
1A0A.AAAB 1A0A
3415.5556 3415
4E20.0001 4E20
682A.AAAC 682A
...