Most microcontrollers spend their lives on numbers with a fractional part — audio samples, sensor readings, control-loop gains — and many have no floating-point unit at all. Even the Cortex-M4F's FPU handles only single precision. Fixed point stores a real number as a scaled integer and does all the arithmetic with integer instructions; the M4's DSP extension adds instructions built exactly for that.
Need the bit foundations first? The optional number-representation course covers binary, hex, one's and two's complement, sign extension and overflow.
1. Q formats
Qm.n means: a two's-complement integer whose value is raw / 2ⁿ, with
n fraction bits and m integer bits (sign excluded).
Converting is scaling and rounding: raw = round(x · 2ⁿ), x = raw / 2ⁿ.
#include <cmath>
#include <cstdint>
#include <cstdio>
using q15 = std::int16_t;
q15 to_q15(float x) { return static_cast<q15>(std::lroundf(x * 32768.0f)); }
float from_q15(q15 r) { return r / 32768.0f; }
int main() {
for (float x : {0.5f, -0.25f, 0.1f, -1.0f, 0.99997f}) {
q15 r = to_q15(x);
std::printf("%9.5f -> %6d -> %.7f\n", x, r, from_q15(r));
}
}Note what's missing: +1.0. Two's complement has one more negative value than positive, so Q15 can represent −1 but not +1.
2. Add, multiply, shift back
Addition of two numbers in the same format is plain integer addition. Multiplication adds the fraction bits:
Q15 × Q15 → Q30 (fits in int32) → shift right by 15 → Q15
Q16.16 × Q16.16 → Q32.32 (needs int64) → shift right by 16 → Q16.16
Q31 × Q31 → Q62 (needs int64) → shift right by 31 → Q31The widening matters. a * b on two int32_t is computed in 32 bits and
overflows before you get to widen it; write std::int64_t{a} * b. On the
M4 that compiles to a single smull (32 × 32 → 64), and narrowing the shifted
result needs only lsrs + orr (the asm-count item counts them).
3. Rounding
>> on a negative number is an arithmetic shift: it rounds toward −∞.
That's not symmetric — every result is low by half a unit on average:
#include <cstdio>
#include <initializer_list>
int main() {
for (int x : {5, 3, 1, -1, -3, -5}) std::printf("%3d >> 1 = %2d (x+1) >> 1 = %2d\n", x, x >> 1, (x + 1) >> 1);
}Adding half a unit before the shift — (p + (1 << (n−1))) >> n — rounds half
up and removes the bias. In a filter, the bias is a DC offset; in a decaying
feedback loop it's worse: negative values never reach zero (a limit cycle —
see the check item).
4. Saturation
Two's-complement overflow wraps: two loud positive samples add up to a loud negative one — a click.
30000 + 10000 = 40000 → int16 wraps to 40000 − 65536 = −25536 (−0.78!)
saturated: 32767 (+0.99997)Saturation clamps to the representable range instead. The M4's DSP extension does it in one instruction:
| instruction | intrinsic (<arm_acle.h>) |
does |
|---|---|---|
ssat rd, #n, rm |
__ssat(x, n) |
clamp to a signed n-bit range |
usat rd, #n, rm |
__usat(x, n) |
clamp to [0, 2ⁿ − 1] |
qadd / qsub |
__qadd(a, b), __qsub |
32-bit add/subtract, saturating |
qadd16 |
__qadd16(a, b) |
two 16-bit saturating adds in one register |
GCC also turns the obvious clamp x > 32767 ? 32767 : x < -32768 ? -32768 : x
into ssat. Every saturating instruction sets the sticky Q flag in APSR
when it clamps — handy for counting overflows in testing.
#include <arm_acle.h>
#include <cstdint>
#include <cstdio>
int main() {
std::int16_t a = 30000, b = 10000;
std::printf("wrapped %d, saturated %d\n",
static_cast<std::int16_t>(a + b), static_cast<std::int16_t>(__ssat(a + b, 16)));
std::int16_t m = -32768; // -1.0: the one Q15 product that overflows
std::int32_t p = std::int32_t{m} * m;
std::printf("-1 * -1: wrapped %d, saturated %d\n",
static_cast<std::int16_t>(p >> 15), static_cast<std::int16_t>(__ssat(p >> 15, 16)));
}5. Two multiplies per instruction
The DSP extension treats a 32-bit register as two 16-bit halves. The workhorse of 16-bit filters is the dual multiply-accumulate:
smlad rd, rn, rm, ra: rd = ra + rn.lo·rm.lo + rn.hi·rm.hi (32-bit accumulator)
smlald rdlo, rdhi, rn, rm: {rdhi:rdlo} += rn.lo·rm.lo + rn.hi·rm.hi (64-bit accumulator)
smulbb / smultt / smlabb: one 16×16 multiply (bottom/top halves), optional accumulatePack two Q15 samples into a word (lo in the bottom half), two coefficients into another, and one instruction does two taps of a filter:
#include <arm_acle.h>
#include <cstdint>
#include <cstdio>
std::uint32_t pack2(std::int16_t lo, std::int16_t hi) {
return static_cast<std::uint16_t>(lo) | (std::uint32_t{static_cast<std::uint16_t>(hi)} << 16);
}
int main() {
std::int16_t x[4] = {16384, -8192, 4096, 32767}; // 0.5, -0.25, 0.125, ~1
std::int16_t h[4] = {16384, 16384, 16384, 16384}; // four taps of 0.5
std::int64_t acc = 0;
for (int k = 0; k < 4; k += 2) acc = __smlald(pack2(x[k], x[k + 1]), pack2(h[k], h[k + 1]), acc);
std::printf("acc = %lld (Q30), y = %d (Q15) = %.4f\n", static_cast<long long>(acc),
static_cast<int>((acc + (1 << 14)) >> 15), ((acc + (1 << 14)) >> 15) / 32768.0);
}Use smlald (64-bit) unless you've proved the sum fits 32 bits: each Q15
product can be 2³⁰, so three full-scale taps already overflow smlad's int32
accumulator — and it wraps (only setting the Q flag).
6. Overflow analysis for an FIR filter
An FIR filter is y[i] = Σₖ h[k]·x[i−k]. Count bits before you choose types:
Saturating inside the loop changes the result (it clips intermediate sums that later taps would bring back into range); the exact sum then one final saturation is what a reference model computes. If the coefficients are known — say Σ|h[k]| ≤ 1 — the output can't exceed full scale and the analysis tightens, but a generic filter can't assume that.
A moving average is the simplest FIR (all taps 1/N). With N a power of two the division is a shift, and keeping a running sum (add the new sample, subtract the one leaving the window) makes it O(1) per sample.
7. Is the FPU the better tool?
The Cortex-M4F's FPv4-SP unit does single precision in hardware:
vadd.f32, vmul.f32, and a fused vfma.f32, each single-cycle throughput.
For scalar control-loop maths that's as fast as fixed point and much easier to
get right. Three caveats:
doubleis software. Every double operation is a call —bl __aeabi_dadd,__aeabi_dmul, tens of cycles each (asm-count item). A stray1.0instead of1.0fpromotes a whole expression.- Interrupt latency. An ISR that touches the FPU makes the core stack the FPU registers too (lazy stacking reserves the space; using them pays for 17 more words).
- Packed 16-bit data — audio, ADC streams — is where fixed point wins outright: two samples per register, two MACs per instruction.
Checklist
- Know the format of every variable (write it in the type name or a comment): Q15, Q31, Q16.16.
- Widen before multiplying; shift back by the fraction bits.
- Round half up: add half a unit before the shift.
- Saturate where results can leave the range — and remember −1 × −1.
- Count accumulator bits: taps × 2³⁰ for Q15 products.
- Use
float, neverdouble, if you use the M4F's FPU.