Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

qdsp

ci license: MIT language: C99 tests: 66 sanitizers: ASan + UBSan no malloc cross-checked: CMSIS-DSP

A fixed-point DSP library in C99 for resource-constrained targets, with automated tests, sanitizers, reproducible benchmarks and a cross-check against CMSIS-DSP.

What this is

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.

What it is for

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.

Why it exists

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.

Build

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --parallel
ctest --test-dir build --output-on-failure

CMake >= 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-failure

What is inside

M0 - Fixed-point core

include/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.

M1 - FIR

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

M2 - Biquad IIR

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

M3 - FFT and Goertzel

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

M4 - Measurement harness

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_bench

Cost 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.

M5 - CMSIS-DSP cross-check and applied examples

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

Regenerating the data

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/data

Design notes

Every 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.

License

MIT — see LICENSE.

About

Fixed-point DSP library in C99 for microcontrollers - FIR, biquad, FFT and Goertzel, cross-checked against CMSIS-DSP

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages