A fixed-point DSP library in C99 for resource-constrained targets, with automated tests, sanitizers, reproducible benchmarks and a cross-check against CMSIS-DSP.
Digital filters and spectral analysis are usually written with floating-point maths. That works on a PC, and it works on a microcontroller with an FPU. On the parts that actually ship in high volume, a Cortex-M0, an AVR, the cheap end of the Cortex-M range; there is no FPU, so every floating-point operation is emulated in software and costs tens of cycles instead of one.
The alternative is fixed-point arithmetic: represent fractional values as plain integers with an implied binary point, so the CPU only ever does integer multiplies and adds. The catch is that nothing is automatic any more. Scaling, overflow, saturation and rounding all become the programmer's problem, and getting them wrong does not produce a compiler error, it produces a filter that clicks on loud signals, drifts with a DC offset, or oscillates forever on silence.
qdsp implements the four building blocks that most embedded signal chains
are made of, in Q15 fixed point and in float for reference:
| Block | What it does | Typical use |
|---|---|---|
| FIR | Linear-phase filtering, no feedback | Anti-aliasing, decimation, pulse shaping |
| Biquad IIR | Filtering with far fewer coefficients | Audio EQ, band-pass, DC removal |
| FFT | Full spectrum of a block of samples | Vibration analysis, spectral features |
| Goertzel | Power of one frequency, in O(N) | Tone detection, DTMF, lock-in measurement |
Everything runs with no dynamic allocation, no dependency beyond
<stdint.h>, and coefficients that can live in flash while state lives in
RAM — the constraints that a real microcontroller imposes.
Two worked examples ship with the library, both running end to end in Q15, each checking its own result against the ground truth in its dataset:
- Ultrasonic ranging — a 40 kHz echo buried in noise and mains hum; the arrival time is recovered with a sliding Goertzel and turned into a distance. Measured error: 0.5 mm at 0.514 m.
- Heart rate from PPG — a photoplethysmogram whose baseline wander is larger than the pulse itself; a band-pass biquad plus adaptive peak detection recovers the rate. Measured error: 0.6 BPM at 72 BPM.
CMSIS-DSP already does all of this, and does it faster. This library is not trying to replace it, and the comparison in M5 exists precisely to be honest about that.
What the repository is really about is method: every design decision is
written down with the trade-off behind it, every claim has a measurement
attached, and the numbers were obtained rather than assumed. That includes the
parts that did not go smoothly, two undefined-behaviour bugs that only the
sanitizer caught, a benchmark whose own measurement error looked like a broken
filter, a structural choice that had to be reversed after measuring it, and
one deviation from CMSIS-DSP that is documented as unexplained because it has
not been isolated. Those are in docs/design.md, not hidden.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --parallel
ctest --test-dir build --output-on-failureCMake >= 3.16 and a C99 compiler are all that is needed. Unity is fetched automatically by CMake, so there are no submodules to initialise.
Sanitizer build, as run in CI:
CC=clang CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g" \
cmake -S . -B build-san -DCMAKE_BUILD_TYPE=Debug
cmake --build build-san --parallel
ctest --test-dir build-san --output-on-failureinclude/qdsp/fixed.h — all static inline, no dynamic allocation, no
dependency beyond <stdint.h> (float support is excluded with
-DQDSP_NO_FLOAT):
- explicit saturation on add, subtract, negate and shift;
- Q15 multiplication with round-to-nearest;
- full-precision Q15 x Q15 -> Q31 product;
- 64-bit MAC accumulator, saturating only at the output;
- float <-> fixed conversions for tests and offline tooling.
17 unit tests cover the edge cases: (-1.0)x(-1.0), -(-1.0), rounding
ties, sign extension in shifts, and quantisation error within 1 LSB across
50,000 random samples with a fixed seed.
Linear-phase filter with a circular delay line, in float and Q15. State and coefficients are supplied by the caller (coefficients in flash, state in RAM), with no allocations anywhere.
Reference filter: 63-tap low-pass, fs = 8 kHz, fc = 1 kHz, Hamming window.
| Metric | Value |
|---|---|
| Attenuation at 2600 Hz | 55.2 dB |
| Q15 SNR vs float64 reference | 86.7 dB |
| Max per-sample error (Q15) | <= 1 LSB |
Cascade of second-order sections. The two versions deliberately use
different structures: direct form II transposed in float, direct form I
in Q15 (the measurement behind that choice is in docs/design.md).
Reference filter: 4th-order Butterworth low-pass, fs = 8 kHz, fc = 1 kHz,
2 sections, post_shift = 1.
| Metric | Value |
|---|---|
| Attenuation at 2800 Hz | 55.6 dB |
| Q15 SNR vs float64 reference | 83.8 dB |
| Limit cycle after the impulse | 1 LSB |
| Saturations with full-scale input | 0 out of 2000 |
Radix-2 decimation-in-time complex FFT, in place, with twiddles precomputed
in a const table (so they live in flash). Sizes from 2 to 1024 points share
a single table. Goertzel computes the power of one bin in O(N) with no
tables at all.
| Metric | Value |
|---|---|
| Q15 FFT SNR, 256 points, vs float64 | 55.7 dB |
| Goertzel selectivity (float) | 152.9 dB |
| Goertzel Q15 vs float deviation | < 0.001 dB |
| Saturations with full-scale input | 0 |
cmake -S . -B build-bench -DCMAKE_BUILD_TYPE=Release -DQDSP_PROFILE=ON -DQDSP_BUILD_TESTS=OFF
cmake --build build-bench --parallel
./build-bench/bench/qdsp_benchCost per sample (1024-sample block):
| Kernel | mac | round | ns/sample* |
|---|---|---|---|
| fir_q15 (63 taps) | 63 | 1 | 56.0 |
| biquad_q15 (2 sections) | 10 | 2 | 10.3 |
| fft_q15 (256 points) | 16 | 8 | 19.6 |
| goertzel_q15 | 1 | 0 | 2.2 |
Q15 chain quality, single tone at 312.5 Hz aligned to an FFT bin:
| Kernel | SNR | SFDR |
|---|---|---|
| fir_q15 | 82.9 dB | 95.1 dB |
| biquad_q15 | 73.6 dB | 92.3 dB |
* The mac/round columns are exact and reproducible, and CI watches them.
The nanoseconds are indicative only: they depend on the measuring
machine, and an x86 says nothing precise about a Cortex-M. Real cycle counts
need Renode or hardware and are out of scope for this milestone; the
reasoning is in docs/design.md.
The same inputs are pushed through qdsp and through the official CMSIS-DSP fixed-point kernels, and the outputs are compared.
| Kernel | Max difference | SNR vs CMSIS | RMS error vs ideal: qdsp / CMSIS |
|---|---|---|---|
| FIR Q15, 63 taps | 10 LSB | 61.6 dB | 0.28 / 5.01 LSB |
| Biquad Q15, 2 sections | 4 LSB | 70.7 dB | 0.60 / 2.63 LSB |
| CFFT Q15, 256 points | 7 LSB | 48.5 dB | — |
Bit-exactness is not the goal, and claiming it would be dishonest: CMSIS
truncates the accumulator while qdsp rounds to nearest. What the comparison
establishes is that there is no structural disagreement — no sign error, no
off-by-one in the delay line, no different scaling convention. Part of the
residual gap is explained and part of it is not; see docs/design.md.
Two applied examples, both running entirely in Q15 and both self-checking (they exit non-zero if they miss the ground truth in their dataset, so CI runs them as tests):
ultrasonic time-of-flight heart rate from PPG
distance 0.514 m 71.4 BPM (true 72.0)
(true 0.514 m), error 0.5 mm error 0.6 BPM
Every generated header is committed, so neither the build nor CI depends on Python. Regenerate only when the reference filters or datasets change:
python3 tools/gen_twiddles.py > src/twiddles.c
python3 tools/gen_fir_vectors.py > tests/vectors/fir_lowpass.h
python3 tools/gen_biquad_vectors.py > tests/vectors/biquad_lowpass.h
python3 tools/gen_fft_vectors.py > tests/vectors/fft_ref.h
python3 tools/gen_cmsis_reference.py > tests/vectors/cmsis_ref.h # pip install cmsisdsp
python3 tools/gen_example_data.py examples/dataEvery non-obvious decision, the trade-off behind it and the measurement that
justifies it are in docs/design.md — including the bugs
found along the way and the one deviation that is still unexplained.
MIT — see LICENSE.