Dušan Čorlija · Writing

Where a Q31 filter quietly loses 60 dB

Low-cutoff IIR filters in fixed point look fine in a frequency plot and are wrong in practice. Measurements, and the one-line fix that recovers most of it.

  • DSP
  • Fixed point
  • Cortex-M
  • C

A 32-bit fixed-point filter sounds like it should be good for well over 150 dB of dynamic range. For a low-pass with its cutoff near the bottom of the band, a plain Q31 biquad delivers far less, and nothing in the usual design plots warns you.

I measured this while writing qfx-dsp, a small fixed-point DSP library for Cortex-M. The setup: a second-order Butterworth low-pass at fs = 48 kHz in Direct Form I with Q31 data, a −6 dBFS test tone, and a double-precision reference that uses the same quantised coefficients. That last detail matters. Without it you measure coefficient error and arithmetic error mixed together, which is how my first version of the test got misleading results.

The numbers

CutoffRoundedError feedbackGain
10 Hz99.7 dB163.2 dB+63.5 dB
30 Hz116.9 dB168.1 dB+51.2 dB
100 Hz135.7 dB174.2 dB+38.6 dB
1 kHz165.6 dB183.3 dB+17.6 dB
10 kHz187.9 dB186.2 dB−1.7 dB

At 10 kHz the plain filter is excellent. At 10 Hz it has lost almost 90 dB against what Q31 can represent.

Why low cutoffs hurt

A low cutoff puts both poles very close to z = 1: within about 0.09 % for 10 Hz at 48 kHz. At low frequencies every rounding step inside the feedback loop is amplified by 1 / (1 + a1 + a2), and for this filter that factor is about 6 × 10⁵. That is roughly 115 dB of noise gain, applied to an error of up to half an LSB per sample.

It also shows up as a bias. With a constant input, the rounding error doesn't average out; it settles into a fixed offset (a dead band). For a 50 Hz low-pass fed a DC level, the plain Q31 output sat 11 510 LSB away from the correct value. In a measurement chain that is a calibration error nobody put there on purpose.

The fix: feed the error back

Instead of throwing the rounding error away, keep it and add it to the next sample's accumulator before rounding again:

uint64_t acc = (uint64_t)err;          // error left over from the last sample
acc += (uint64_t)((int64_t)b0 * x0);
acc += (uint64_t)((int64_t)b1 * x1);
acc += (uint64_t)((int64_t)b2 * x2);
acc -= (uint64_t)((int64_t)a1 * y1);
acc -= (uint64_t)((int64_t)a2 * y2);

int64_t sacc = (int64_t)acc;
int64_t yq   = sacc >> shift;          // truncate
err          = sacc - yq * (1LL << shift);

This is first-order noise shaping. The quantisation noise gets a zero at DC, which is exactly where the poles amplify it most. It costs one 64-bit addition per sample. The DC bias in the test above drops from 11 510 LSB to 0.13 LSB.

Error spectrum of a 30 Hz Q31 low-pass, rounded versus error feedback
Output error spectrum for a 30 Hz low-pass. Error feedback pushes the noise down by 50–60 dB exactly where the signal of a low-pass lives.

Two side notes from the same code:

  • **The accumulator is uint64_t on purpose.** Partial sums of five Q62 products can overflow int64_t even when the final result fits. Signed overflow is undefined behaviour in C; unsigned wrap-around is not, and modular arithmetic makes the transient overflow harmless.
  • The test suite checks block-size invariance. Processing a signal in blocks of 1, 7, 64 or 1000 samples must give bit-identical output, or the carried error is being lost at block boundaries.

What it does not fix

Error feedback fixes arithmetic noise, not coefficient quantisation. Measured against the ideal coefficients, the 10 Hz filter still only reaches 75 dB, because its numerator coefficients are around 4 × 10⁻⁷ and keep only about nine significant bits in Q31. Below roughly fs/1000 the better move is structural: decimate first and filter at a lower rate, or use a coupled-form or state-variable section whose coefficients don't crowd around 1.

The general lesson I keep coming back to: a frequency-response plot of the quantised coefficients says almost nothing about how a fixed-point filter behaves. Measure the error against a reference, with a realistic signal, at the cutoff you actually use.