CodeRigor

FREE LESSON

Fixed-point arithmetic and the M4 DSP instructions

Q15, Q31 and Q16.16: conversion and rounding, widening multiplies, saturation, the Cortex-M4's DSP instructions (ssat, qadd, smulbb, smlad, smlald), when the single-precision FPU is the better tool and why double is software, and FIR filters that don't overflow.

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

Where the binary point sits
Same bits, different meaning: Q15 puts all 15 value bits after the point (0.5 is 16384), Q16.16 splits 15 integer bits and 16 fraction bits (1.5 is 0x00018000). Q31 is Q15 with 31 fraction bits.
Where the binary point sits
Same bits, different meaning: Q15 puts all 15 value bits after the point (0.5 is 16384), Q16.16 splits 15 integer bits and 16 fraction bits (1.5 is 0x00018000). Q31 is Q15 with 31 fraction bits.

Converting is scaling and rounding: raw = round(x · 2ⁿ), x = raw / 2ⁿ.

C++
#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:

Text
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 → Q31
0.5 × 0.5 in Q15
The product of two Q15 values has 30 fraction bits; shifting right by 15 brings it back to Q15. Widen first: the int32 product exists only if you compute it in 32 bits.
0.5 × 0.5 in Q15
The product of two Q15 values has 30 fraction bits; shifting right by 15 brings it back to Q15. Widen first: the int32 product exists only if you compute it in 32 bits.

The 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:

C++
#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.

Text
30000 + 10000 = 40000  →  int16 wraps to 40000 − 65536 = −25536   (−0.78!)
saturated:                                     32767   (+0.99997)
Wrapping vs saturating an int16 sum
Past 32767 the wrapped result jumps to the most negative value — a loud click. The saturated one stays at full scale.
Wrapping vs saturating an int16 sum
Past 32767 the wrapped result jumps to the most negative value — a loud click. The saturated one stays at full scale.

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.

C++
#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:

Text
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 accumulate
smlald: two taps in one instruction
First iteration of the example: x0·h0 + x1·h1 = 16384·16384 + (−8192)·16384 = 2²⁸ − 2²⁷ = 134217728, added to the 64-bit accumulator. Bottom half (lane 0) on the right.
smlald: two taps in one instruction
First iteration of the example: x0·h0 + x1·h1 = 16384·16384 + (−8192)·16384 = 2²⁸ − 2²⁷ = 134217728, added to the 64-bit accumulator. Bottom half (lane 0) on the right.

Pack two Q15 samples into a word (lo in the bottom half), two coefficients into another, and one instruction does two taps of a filter:

C++
#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:

Counting bits for a 64-tap Q15 FIR
Budget the accumulator before choosing its type, and saturate exactly once — at the end.
Counting bits for a 64-tap Q15 FIR
Budget the accumulator before choosing its type, and saturate exactly once — at the end.

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:

  • double is software. Every double operation is a call — bl __aeabi_dadd, __aeabi_dmul, tens of cycles each (asm-count item). A stray 1.0 instead of 1.0f promotes 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, never double, if you use the M4F's FPU.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

University lecture on fixed point for microcontrollers: formats, multiply and the shifts that keep precision.

Clear introduction to fixed-point representation and conversion.

Fixed-point multiplication and division and where precision is lost.

C++26 saturating arithmetic (add_sat, sub_sat, mul_sat): clamping instead of wrapping — the semantics fixed-point DSP code wants.

Two's-complement words from first principles. Hardware wrapping is distinct from C++ signed arithmetic overflow, which remains undefined behavior.