C++ Module 21 – Embedded C++
Fixed-point arithmetic in C++ firmware
Store fractions as scaled integers in Q15 and Q16.16, multiply through a wider type, round and saturate, and get the same bits on a laptop and a Cortex-M.
What you will learn
- Represent fractions as Q15 and Q16.16 integers and read their values back
- Implement fixed-point multiplication and division through a wider type without overflow
- Choose a format, a rounding rule and saturation from the range and resolution a value needs
- Verify that the host and the target compute identical results
Before you start
What you’ll learn · Before you start
What you will learn
- Represent fractions as Q15 and Q16.16 integers and read their values back
- Implement fixed-point multiplication and division through a wider type without overflow
- Choose a format, a rounding rule and saturation from the range and resolution a value needs
- Verify that the host and the target compute identical results
Before you start
On this page
A Cortex-M0 or Cortex-M3 has no floating-point unit, so every float operation in its firmware becomes a call to a
software routine, and the same float code can round its last bit differently under another compiler.
Fixed-point arithmetic avoids both by storing a number with a fraction as an ordinary integer and a scale that the
code agrees on: in Q16.16, an int32_t holds the value times 65,536, so 1.5 is stored as 98,304.
In Q15, an int16_t holds the value times 32,768, which covers −1 up to just below 1. Adding two such numbers is
integer addition. Multiplying needs a type twice as wide for the product, a shift back to the scale, and a rule for
rounding and overflow. Integer operations have one right answer, so the code gives the same bits on a laptop, on the
device and with every compiler.
A Q format fixes where the binary point sits
The figure draws both layouts bit by bit, with the The place in a binary number that separates the integer bits from the fraction bits, as the decimal point does in 1.5. In a fixed-point format it never moves, so the format alone says where it is. marked:
Where the binary point sits in Q16.16 and in Q15
Text description of the diagram
The figure shows two integers as bars of bits, the most significant bit on the left.
Q16.16 in an int32_t, where the value is the raw integer divided by 65,536. Bit 31 is the sign bit of the two's complement integer. The next 15 bits hold the integer part, and the last 16 bits hold the fraction; the binary point sits between them. 1.5 is stored as 98,304, which is 0x0001 8000 in hexadecimal. The range is −32,768 to 32,767.99998, in steps of 1/65,536.
Q15, also written 1.15, in an int16_t, where the value is the raw integer divided by 32,768. Bit 15 is the sign bit, and the binary point sits right after it, so all 15 remaining bits are fraction. 0.5 is stored as 16,384 (0x4000) and −1 as −32,768 (0x8000). The range is −1 to 0.999969, in steps of 1/32,768.
This lesson writes a Q format as Qm.n: m integer bits, the sign bit included, and n fraction bits; the value is
the stored integer divided by 2ⁿ. Arm’s CMSIS-DSP library uses the same convention and
calls its q15_t a “16-bit fractional data type in 1.15 format”. Some datasheets leave out the sign bit and write
Q0.15 for the same layout, so check the convention before trusting a scale.
| Format and type | Range, and the size of one step |
|---|---|
Q15 (1.15) in int16_t |
−1 to 0.999969, in steps of 1/32,768 ≈ 0.0000305 |
Q31 (1.31) in int32_t |
−1 to just below 1, in steps of 1/2,147,483,648 ≈ 0.00000000047 |
Q16.16 in int32_t |
−32,768 to 32,767.99998, in steps of 1/65,536 ≈ 0.0000153 |
Q15 holds a fraction between −1 and 1 in 16 bits, which suits audio samples and filter coefficients. Q31 holds the same range in 32 bits with 65,536 times finer steps. Q16.16 trades fraction bits for an integer part, so it reaches about ±32,768, which suits sensor values and positions. Each bit moved to the integer side doubles both the range and the step.
Programmer Calculator (Bitwise) Enter 0x00018000 to see the bits of 1.5 in Q16.16, or 0x4000 for 0.5 in Q15.Checkpoint
Check: Storing a fraction in a Q format
Adding is integer adding; multiplying needs a wider type
Values in the same format add and subtract as integers, because (a × 2¹⁶) + (b × 2¹⁶) = (a + b) × 2¹⁶: the scale stays the same. Values in different formats are first shifted to a common scale.
A product doubles the scale: (a × 2¹⁶) × (b × 2¹⁶) = (a × b) × 2³², so the raw product has 32 fraction bits and is
shifted right by 16 to return to Q16.16. It also needs room. For 1.5 × 2.25 the raw values are 98,304 and 147,456,
and their product, 14,495,514,624, is more than six times the largest int32_t. In int32_t that multiplication
would overflow, which is undefined behaviour: What a program does when it breaks a rule for which the C++ standard sets no requirements, such as reading past the end of an array. The compiler may assume it never happens, so the result can change with the compiler and the optimisation level. More in “Object lifetime and undefined behaviour in C++” for signed types; in int64_t it
is exact, and the shift gives 221,184, which is 3.375 × 65,536. Division runs the other way: the dividend is widened
and shifted left by 16 first, so that the quotient keeps its fraction bits. Step through that product:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The stored values. 1.5 is stored as 1.5 × 65,536 = 98,304, and 2.25 as 2.25 × 65,536 = 147,456. Each raw value carries one factor of 65,536, the scale of Q16.16.
- Widen first. Both raw values are converted to std::int64_t before they meet. A product of two 32-bit integers needs up to 64 bits, so in 64 bits it is always exact.
- The raw product. 98,304 × 147,456 = 14,495,514,624, about 6.75 times the largest int32_t, 2,147,483,647. Multiplied in int32_t, it would overflow, which is undefined behaviour for a signed type.
- Two scales. The product carries the scale twice: (a × 2¹⁶) × (b × 2¹⁶) = (a × b) × 2³². It has 32 fraction bits, and 14,495,514,624 ÷ 2³² is exactly 3.375.
- Shift back. Shifting right by 16 divides by 65,536 and drops the low 16 bits: 14,495,514,624 >> 16 = 221,184. Here those bits are all zero, so nothing is lost.
- Back to 32 bits. 221,184 fits in an int32_t, and 221,184 ÷ 65,536 = 3.375, exactly 1.5 × 2.25. A result of 32,768 or more would not fit, and narrowing it would wrap round.
Checkpoint
Check: Multiplying through a wider type
Q16.16 products round down, and a result too large wraps
Six products through Q16, whose operators hide the widening and the shift inside a * b:
// A Q16.16 number: a 32-bit integer that stores the value times 65,536.
#include <cmath>
#include <cstdint>
#include <format>
#include <print>
struct Q16 {
std::int32_t raw; // the value times 2 to the power 16
static constexpr int frac_bits = 16;
static constexpr std::int32_t one = std::int32_t{1} << frac_bits; // 65,536 stands for 1.0
static Q16 from(double v) { return {static_cast<std::int32_t>(std::lround(v * one))}; }
double value() const { return static_cast<double>(raw) / one; }
friend Q16 operator+(Q16 a, Q16 b) { return {a.raw + b.raw}; }
friend Q16 operator-(Q16 a, Q16 b) { return {a.raw - b.raw}; }
friend Q16 operator*(Q16 a, Q16 b) {
const std::int64_t wide = std::int64_t{a.raw} * b.raw; // 32 fraction bits now
return {static_cast<std::int32_t>(wide >> frac_bits)}; // back to 16
}
};
int main() {
const double pairs[][2] = {{1.5, 2.25}, {3.14159, 2.71828}, {0.1, 0.1},
{-0.1, 0.1}, {150, 200}, {200, 200}};
std::println("{:>7} {:>7} {:>9} {:>9} {:>14} {:>13} {:>7}",
"a", "b", "a.raw", "b.raw", "Q16.16 a*b", "exact a*b", "steps");
for (const auto& [x, y] : pairs) {
const Q16 a = Q16::from(x), b = Q16::from(y);
const double exact = a.value() * b.value(); // the two stored values, multiplied in double
const double got = (a * b).value();
const double steps = (got - exact) * Q16::one; // the error in units of 1/65,536
std::println("{:>7} {:>7} {:>9} {:>9} {:>14.6f} {:>13.6f} {:>7}", x, y, a.raw, b.raw,
got, exact, std::abs(steps) < 1 ? std::format("{:.2f}", steps) : "wrapped");
}
std::println("1 + 2.25 - 0.5 = {}", (Q16::from(1) + Q16::from(2.25) - Q16::from(0.5)).value());
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -O2 q16.cpp -o q16
./q16
g++-16 -std=c++23 -Wall -Wextra -O2 q16.cpp -o q16
./q16
""") Output
$ clang++ -std=c++23 -Wall -Wextra -O2 q16.cpp -o q16
$ ./q16
a b a.raw b.raw Q16.16 a*b exact a*b steps
1.5 2.25 98304 147456 3.375000 3.375000 0.00
3.14159 2.71828 205887 178145 8.539688 8.539702 -0.89
0.1 0.1 6554 6554 0.009995 0.010001 -0.44
-0.1 0.1 -6554 6554 -0.010010 -0.010001 -0.56
150 200 9830400 13107200 30000.000000 30000.000000 0.00
200 200 13107200 13107200 -25536.000000 40000.000000 wrapped
1 + 2.25 - 0.5 = 2.75
$ g++-16 -std=c++23 -Wall -Wextra -O2 q16.cpp -o q16
$ ./q16
a b a.raw b.raw Q16.16 a*b exact a*b steps
1.5 2.25 98304 147456 3.375000 3.375000 0.00
3.14159 2.71828 205887 178145 8.539688 8.539702 -0.89
0.1 0.1 6554 6554 0.009995 0.010001 -0.44
-0.1 0.1 -6554 6554 -0.010010 -0.010001 -0.56
150 200 9830400 13107200 30000.000000 30000.000000 0.00
200 200 13107200 13107200 -25536.000000 40000.000000 wrapped
1 + 2.25 - 0.5 = 2.75
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 q16_session.py
The column exact a*b multiplies the two stored values in double, so steps is the error of the multiplication
alone, in units of 1/65,536. The shift drops the product’s low 16 bits, and since C++20 a right shift of a negative
value rounds towards minus infinity, so every inexact product here rounded down, by 0.89, 0.44 and 0.56 of a step.
In the last row, 40,000 does not fit below 32,768, and converting the shifted product back to int32_t
keeps its low 32 bits, which is defined since C++20, so 200 × 200 came out as −25,536. A reading that suddenly
changes sign is worse than one that stops at its limit, which is why production code
saturation arithmetic: Arithmetic that stops at the type's limits instead of overflowing: 100 + 100 in std::int8_t gives 127. C++26 adds it to <numeric> as std::saturating_add and its relatives, first named std::add_sat. More in “Integer overflow and signed/unsigned conversions”: CMSIS-DSP’s Q15 and Q31 multiplications clamp results outside
their range. Both compilers printed the same table, because every step is integer arithmetic.
Key idea
A Q16.16 product is exact in 64 bits; the shift rounds it down, and narrowing a result out of range wraps it round.
Checkpoint
Check: Rounding down and wrapping round
Integer results are the same bits everywhere; float results are not
Floating-point operations are rounded, and a compiler may turn a multiplication followed by an addition into one One operation that computes a × b + c and rounds only the result, instead of rounding the product and then the sum., which rounds once instead of twice. A four-digit model shows why:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Four digits. This model keeps four significant decimal digits, where a float keeps 24 binary digits, so that every number can be checked by hand. Take a = 1.234, b = 5.678 and c = −7.006.
- The exact product. a × b = 7.006652 exactly. That needs seven digits, three more than the model keeps.
- Rounded once already. A separate multiplication rounds its product to four digits before anything else happens: 7.007, which is 0.000348 more than the exact product. That error stays in everything computed from it.
- Then the sum. The addition gives 7.007 − 7.006 = 0.001. That fits four digits, so the second rounding changes nothing here: the separate path answers 0.001.
- Rounded only once. A fused multiply-add takes the exact product, 7.006652, adds c to it and rounds only the result: 7.006652 − 7.006 = 0.000652, which fits four digits.
- Two answers. The two paths differ by 0.000348, the error that the first rounding added to the product. Here c cancels most of the digits, so the gap is large; in a float filter it usually shows up in the last bit.
The separate path answered 0.001 and the fused one 0.000652, from the same three numbers. GCC’s manual makes
-ffp-contract=fast the default for C++ on processors with that instruction; Clang’s default, -ffp-contract=on,
fuses within one expression; -ffp-contract=off never fuses. The same float code can therefore change its last bits
with the compiler, the flags or the processor. Integer operations have one right answer, so once the rounding and
overflow rules are written in the code, a fixed-point filter gives the same integers everywhere.
Checkpoint
Check: Why integer results match on every build
Practise
Practice that adapts to you
Practise this lesson
13 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Choosing formats, then rounding, saturating and dividing safely Pro: opens with a Pro, Premium or Ultimate pass
Most fixed-point bugs come from a format chosen without numbers, from dropped bits, from overflow or from division. This part works through each with programs that run, and measures every fix against the failure it prevents.
In this part:
- Choosing a format for each variable, with worked numbers
- A filter that never reaches its input: the dead band, measured
- Saturating instead of wrapping, and counting saturations
- Q16.16 division is a library call even on a Cortex-M3, read from the compiler’s output
1 diagram · 8 runnable examples (C++, Python) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
The same filter on a laptop and a Cortex-M3, bit for bit Pro: opens with a Pro, Premium or Ultimate pass
One low-pass filter, written in float and in Q16.16, is built here with two compilers and several flags and also run on an emulated Cortex-M3. The float results move with the build settings, and the fixed-point results never do.
In this part:
- Float outputs that change with the compiler’s flags, measured
- The Q16.16 filter on four host builds and on QEMU’s Cortex-M3
- What the Cortex-M3 build needs: startup code, a linker script and semihosting
- Your turn: a Fixed template that rounds and saturates
1 diagram · 7 runnable examples (C header, C++, C++ (filter_host.cpp), C++ (filter_target.cpp), LD, Python) · about 8 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What do Q15 and Q16.16 mean, and what range and step does each give?
Both store a fraction as an integer with an agreed scale. Q15, also written 1.15, is an int16_t that holds the value times 32,768: a sign bit and 15 fraction bits, so it covers −1 up to 32,767/32,768, about 0.99997, in steps of about 0.0000305. Q16.16 is an int32_t that holds the value times 65,536: 16 integer bits, the sign included, and 16 fraction bits, so it covers −32,768 up to just below 32,768 in steps of about 0.0000153. Adding works directly on the stored integers; a product needs a type twice as wide, 32 bits for Q15 and 64 for Q16.16, followed by a shift right of 15 or 16 bits. Conventions differ on whether the sign bit is counted, so Q0.15 can name the same layout as Q15.
-
Mid to senior
A Q16.16 low-pass filter, y += alpha * (x - y) with alpha = 0.01, runs on a Cortex-M0. With a constant input of 2.5 it settles 100 steps of 1/65,536 below 2.5 and never gets closer. Explain why, and fix it.
-
Senior to staff
You are porting a PID control loop from float on a PC to a Cortex-M0 without a floating-point unit. How do you choose a fixed-point format for each variable, and how do you show that the port is accurate enough?
-
Mid to senior
A golden-output test compares 1,000 outputs of a float filter bit for bit. It passes on developers' machines and fails on the CI server, which builds the same code with GCC at -O2 for the same processor. What is the likely cause, and what do you change?
Key takeaways
- A Q format is an integer with an agreed scale: Q16.16 stores the value times 65,536, Q15 times 32,768.
- Values in one format add as integers; a product needs a type twice as wide and a shift back.
- The shift rounds a product down, and a result out of range wraps round unless the code saturates.
- Integer results are the same bits on every build, while a fused multiply-add can change a float’s last bit.
References
- Working Draft, Programming Languages: C++ (N4950): [expr.shift], [conv.integral], [expr.mul] (ISO/IEC JTC1/SC22/WG21)
- Working Draft, Programming Languages: C++ (N5050): [numeric.sat] Saturation arithmetic (ISO/IEC JTC1/SC22/WG21)
- P4052R0: Renaming saturation arithmetic functions (ISO/IEC JTC1/SC22/WG21)
- Options That Control Optimization (-ffp-contract) (GNU Project)
- Clang Compiler User's Manual: -ffp-contract (LLVM Project)
- CMSIS-DSP: Generic Types (q15_t, q31_t) (Arm)
- CMSIS-DSP: Vector Multiplication (saturating arm_mult_q15 and arm_mult_q31) (Arm)
- Run-time ABI for the Arm Architecture: long long helper functions (Arm)
- Semihosting for AArch32 and AArch64 (Arm)
- Armv7-M Architecture Reference Manual: the vector table and reset behaviour (Arm)
- Arm GNU Toolchain downloads (Arm)
- Arm MPS2 and MPS3 boards (mps2-an385 and others) (QEMU Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress