Fractional numbers in binary represent values less than one by assigning negative powers of two (1/2, 1/4, 1/8) to the bit positions immediately right of the binary point. If you have ever watched a PID-controlled motor hunt endlessly around a setpoint, or wondered why your microcontroller's analog readings drift at specific voltages, you have collided with the physical limits of binary fractions. Understanding how microcontrollers handle sub-integer math is not just academic; it directly dictates the voltage resolution of your ADCs, the step size of your DAC outputs, and the stability of your digital control loops.
The Core Concept: Binary Beyond the Decimal Point
In the decimal system, numbers to the right of the decimal point represent negative powers of ten ($10^{-1}$, $10^{-2}$, etc.). Binary works exactly the same way, but the base is two. The 'binary point' separates the integer bits from the fractional bits.
For an 8-bit fixed-point number with 4 integer bits and 4 fractional bits (often written as Q4.4 format), the bit weights from left to right are:
8, 4, 2, 1 . 0.5, 0.25, 0.125, 0.0625
This mathematical reality is what changes everything in a real circuit. When an Analog-to-Digital Converter (ADC) samples a 3.3V signal, it cannot output '1.65 Volts'. It outputs an integer (like 2048). To get back to a real-world voltage in your code, you multiply that integer by a binary fraction representing the voltage per step. If your code relies on imprecise binary fractions to scale sensor data or calculate PWM duty cycles, you introduce quantization error that manifests as physical jitter in motors, audible noise in audio DACs, or thermal oscillation in power supplies.
Worked Numeric Example: Converting 0.6875 to Binary
Let's look at the exact math of converting a decimal fraction into a binary fraction. We will convert 0.6875 using the repeated multiplication-by-2 method.
- Multiply by 2: $0.6875 \times 2 = 1.375$. The integer part is 1. Keep the fractional part (0.375).
- Multiply by 2: $0.375 \times 2 = 0.75$. The integer part is 0. Keep the fractional part (0.75).
- Multiply by 2: $0.75 \times 2 = 1.5$. The integer part is 1. Keep the fractional part (0.5).
- Multiply by 2: $0.5 \times 2 = 1.0$. The integer part is 1. The fractional part is 0, so we stop.
Reading the integer parts from top to bottom, our binary fraction is 0.1011.
Verification:
$(1 \times 2^{-1}) + (0 \times 2^{-2}) + (1 \times 2^{-3}) + (1 \times 2^{-4})$
$= 0.5 + 0 + 0.125 + 0.0625 = \mathbf{0.6875}$.
This is a 'clean' conversion. But what happens when the number doesn't resolve neatly? That is where embedded systems fail.
Where You Meet This in Practice: ADCs, DACs, and PWM
You interact with binary fractions every time you configure hardware peripherals on a microcontroller like the ESP32 or Arduino.
- ADC Resolution: A 12-bit ADC on an ESP32-S3 yields 4096 discrete steps. If your reference voltage is 3.3V, the fractional weight of the Least Significant Bit (LSB) is $3.3 / 4095 \approx 0.0008058V$. You cannot measure a voltage change of 0.0005V; it simply does not exist in the binary domain of that chip.
- PWM Duty Cycle: When you set an LED to 50% brightness, you are setting a binary fraction of 0.5. But if you want 30% brightness (0.3), you hit a wall.
0.0001100110011... Just as 1/3 is 0.333... in decimal, 1/10 cannot be perfectly represented in a finite number of binary bits. If your embedded C code uses fixed-point 8-bit math to represent 0.1, it will actually store 0.09765625. Over thousands of control loop iterations, this missing 0.00234375 adds up to massive physical errors.
Real-World Scenario Walkthrough: The PID Motor Controller Glitch
To see how this breaks a real project, let's look at a closed-loop DC motor speed controller I debugged recently.
The Setup: A maker was building a motor controller using an ATmega328P (Arduino Nano). To save processing cycles and avoid the overhead of the float data type, they wrote the PID control loop using 8-bit fixed-point fractional math. The target speed was exactly 30% of maximum, represented as the decimal fraction 0.3.
The Numbers: In an 8-bit fractional register, the smallest step is $1/256$ (0.00390625). To store 0.3, the microcontroller multiplies $0.3 \times 256 = 76.8$. Because an 8-bit register can only hold integers, the 0.8 is truncated. The register stores 76.
When the code reads this back as a fraction: $76 / 256 = \mathbf{0.296875}$.
The Outcome: The motor refused to hold a steady speed. It would accelerate slightly, overshoot, brake, undershoot, and oscillate audibly around the setpoint. The serial plotter showed a classic 'hunting' sine wave.
What Went Wrong: The persistent truncation error of $0.003125$ ($0.3 - 0.296875$) was fed into the Integral (I) term of the PID loop. Because the target (0.3) and the actual mathematical representation (0.296875) could never match, the integral windup continuously accumulated this tiny error. The controller thought the motor was always slightly too slow, so it kept injecting corrective PWM pulses, causing the oscillation. The fix: We switched the error calculation to 32-bit IEEE 754 floating-point variables, which provided enough mantissa bits to push the rounding error below the physical noise floor of the motor encoder.
Fixed-Point vs. Floating-Point: What Makers Commonly Confuse
The most common confusion on the bench is treating 'binary fractions' (fixed-point) and 'floating-point' as the same thing. They are fundamentally different hardware implementations. For a deeper look at the architectural differences, All About Circuits provides an excellent breakdown of fixed vs. floating-point arithmetic.
| Feature | Fixed-Point (Binary Fractions) | Floating-Point (IEEE 754) |
|---|---|---|
| Bit Layout | Fixed binary point (e.g., 16 integer bits, 16 fractional bits) | Sign bit, Exponent, Mantissa (e.g., 32-bit float) |
| Hardware Cost | Very low; uses standard integer ALU | High; requires dedicated FPU (Floating Point Unit) |
| Speed on AVR/ARM | Extremely fast (1-2 clock cycles) | Slow on AVR (software emulated); fast on ARM/ESP32 |
| Precision Behavior | Uniform step size across the entire range | Variable step size (denser near zero, sparser at high values) |
| Best Use Case | PID loops, GPS coordinates, complex sensor fusion |
When you declare a float in Arduino C++, you are not using simple binary fractions. You are using the IEEE 754 standard, which dynamically shifts the binary point based on the exponent. As noted in the official Arduino float documentation, floats on 8-bit AVR boards are incredibly slow because the chip lacks a hardware FPU, forcing the compiler to emulate the math in software.
FAQ: Binary Fractions on the Bench
Q: Why does my serial monitor print 0.30000001 when I assign 0.3 to a float?
A: Because 0.3 is a repeating fraction in binary, a 32-bit IEEE 754 float cannot store it perfectly. It stores the closest possible binary approximation, which translates back to 0.30000001192... in decimal. The serial monitor simply reveals the hidden rounding error of the binary representation.
Q: How do I avoid binary fraction rounding errors in embedded C without using slow floats?
A: Scale your math to integers. Instead of calculating $0.3 \times 5000$ RPM, calculate $3 \times 5000$ and divide the final result by 10. By keeping the intermediate values as pure integers, you completely bypass the binary point and eliminate fractional truncation errors until the final display step.
Q: Does the ESP32 ADC handle binary fractions linearly?
A: The original ESP32 (Xtensa LX6) has a notoriously non-linear ADC, particularly below 0.1V and above 3.1V. The binary fractions mapping to those voltage ranges do not correspond to real-world voltages accurately. If you need precise fractional voltage mapping, use the ESP32-S3, which features a heavily improved, linear 12-bit ADC, or use an external I2C ADC like the ADS1115.






