Fixed-Point Arithmetic on FPU-Less Microcontrollers: A Practical Q-Format Guide
On microcontrollers without a hardware FPU, float math is emulated in software and costs dearly. This guide shows how Q-format fixed-point arithmetic works, with a complete Q15 sensor-filter example, overflow analysis, and on-target verification steps.
18 Aug 2025, 13:48 UTC

Your sensor filter runs fine on the dev board with a Cortex-M4, but the production design uses a Cortex-M0 to save cost. Now the same float math that felt free is eating your control-loop budget, because the M0 has no floating-point unit. Every * and + on a float becomes a call into a software emulation library — often tens to hundreds of cycles per operation, plus a few kilobytes of library code in flash.
The standard fix is fixed-point arithmetic: represent fractional values as scaled integers and do all math with native integer instructions. This post walks through the Q-format convention, a runnable filter example, and the overflow traps that make fixed-point unforgiving if you're careless.
What Q-format actually means
Q-format is a naming convention for fixed-point numbers. "Q15" (also written Q1.15) means a 16-bit signed integer where 1 bit is the sign and 15 bits are the fraction. The stored integer is the real value multiplied by 215 (32768). So 0.5 becomes 16384, and the representable range is roughly −1.0 to +0.99997. Q31 uses a 32-bit integer with 31 fraction bits for more precision at the same conceptual range.
One warning up front: Q-notation is not fully standardized. Some vendors count the sign bit, some don't. State your convention in a comment at every interface, e.g. /* Q1.15: value = raw / 32768.0 */. Mixed assumptions here are a classic source of bugs that compile cleanly and fail silently.
The arithmetic rules that matter
Addition and subtraction are easy but strict: both operands must share the same Q-format. Adding a Q15 to a Q7 without rescaling one of them produces garbage, not an error.
Multiplication is where the scaling bites. Two Q15 values multiplied give a Q30 result, which needs 32 bits (31 magnitude bits plus sign). To return to Q15 you shift right by 15. That right shift truncates toward negative infinity, so the usual practice is to add half an LSB (16384 for Q15) first, giving round-to-nearest behavior.
The dangerous part is range. Floats degrade gracefully — lose precision, keep going. Fixed-point overflows silently: an intermediate that exceeds 32 bits wraps around and corrupts the result with no warning. You must know the maximum magnitude of every intermediate in your calculation, either by analysis or by saturating arithmetic.
Worked example: a first-order sensor filter in Q15
Take the common exponential smoothing filter y = 0.75·x + 0.25·y_prev. In Q15, 0.75 is 24576 and 0.25 is 8192 (both times 32768, rounded). The integer version:
#include <stdint.h>
/* Q1.15 convention: real = raw / 32768 */
#define Q15_ONE_HALF_LSB (1 << 14) /* 16384, for rounding */
int16_t filter_step_q15(int16_t x_q15, int16_t y_prev_q15)
{
/* 32-bit accumulator holds the Q30 intermediate safely */
int32_t acc = (int32_t)24576 * x_q15
+ (int32_t)8192 * y_prev_q15
+ Q15_ONE_HALF_LSB; /* round-to-nearest */
return (int16_t)(acc >> 15);
}Why this is safe: each product is at most about 24576 × 32767 ≈ 8.05 × 108, and the sum of both fits comfortably in a signed 32-bit accumulator (max ≈ 2.15 × 109). The coefficients sum to 1.0, so the output can never exceed the input range. If your coefficients summed to more than 1.0, you'd need to re-analyze the accumulator headroom or saturate the result.
On a Cortex-M0 this compiles to two integer multiplies, adds, and a shift — a handful of cycles, versus a software __aeabi_fmul/__aeabi_fadd call sequence for the float version. You can confirm this yourself: build both versions, then check the map file or disassembly for software float library symbols. If you see __aeabi_fmul pulled in, you're paying the emulation cost.
If you're on any Cortex-M, don't hand-roll everything: ARM's CMSIS-DSP library provides tested fixed-point types (q15_t, q31_t) and kernels like arm_mult_q15 and full FIR/IIR filters, with saturation handled for you.
The honest trade-offs
Fixed-point buys you deterministic timing (no variable-latency library calls), small code size, and big speedups on FPU-less parts. It costs you engineering effort in three places:
- Range analysis. Every intermediate needs a proven bound. One missed overflow wraps silently — no NaN, no exception, just wrong data.
- Format bookkeeping. Mixed Q-formats must be explicitly rescaled. Conventions differ between teams and libraries; document yours.
- Portability of nicer syntax. ISO/IEC TR 18037 defines native fixed-point C types (
_Fract,_Accum) that let the compiler handle scaling, but toolchain support is uneven. Verify your compiler before relying on it; the explicit-integer style above works everywhere.
Also note that exact cycle savings vary by MCU, compiler, and library — treat any specific number you read (including rough ones here) as illustrative, and measure on your target.
Verify it on real hardware
Don't trust the math on paper alone. Three quick checks:
- Equivalence: run the Q15 filter and a float reference on the same input sequence (on-target or in a host unit test) and confirm outputs match within one Q15 LSB (~0.00003).
- Timing: toggle a GPIO around the filter call and measure pulse width on a scope or logic analyzer, or read the cycle counter if your core has one (note: Cortex-M0 lacks the DWT cycle counter, so GPIO is the way there). Compare against the float build.
- Boundaries: feed maximum and minimum representable inputs, check half-LSB rounding cases, and deliberately probe intermediate overflow with worst-case coefficient/input combinations.
The actionable takeaway: if your target lacks an FPU and your math is a bounded, well-understood pipeline — filters, PID loops, sensor scaling — convert it to Q15 or Q31, prove your intermediate ranges, and measure the win. Keep floats for anything with wide dynamic range or where development time matters more than cycles.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.