A surprising amount of firmware debugging comes down to one question: what number do these bytes actually represent? A temperature sensor returns FF 9C. A Modbus device sends a float as four bytes across two registers. A value logged over serial looks fine until it’s negative. This guide covers the handful of concepts behind all of those, using the number base converter to check the answers.
Bases are just notation
Hex, binary and decimal are different ways to write the same value. I use hex for anything byte-oriented because each hex digit is exactly four bits: 0x9C is 1001 1100. The converter keeps all four bases in sync, accepts prefixes like 0x and 0b and underscores as separators, and tells you which digit is invalid when you paste something like 102 into the binary field.
Two’s complement
Microcontrollers store signed integers in two’s complement. For a given width, the top bit has a negative weight. At 16 bits, 0xFF9C is:
unsigned: 65436
signed: -100
Same bits, two interpretations. That’s the classic sensor bug: a register holds -100 (−1.00 °C in hundredths), your code reads it into a uint16_t or an int on a platform where int is 32 bits, and you get 65436 instead.
The fix in C is to read into the right-width signed type:
int16_t raw = (int16_t)((buf[0] << 8) | buf[1]);
In the converter, choose 16-bit, turn on “signed”, and type either form. Typing -100 in decimal shows the bit pattern 0xFF9C; typing FF9C in hex shows -100. If a value doesn’t fit the width you picked — 300 in an 8-bit unsigned field — it says so and shows the truncated result, instead of wrapping silently like C does.
Endianness
Multi-byte values have to be stored in some byte order:
- Big-endian (most significant byte first): network protocols, Modbus registers, many sensor datasheets.
- Little-endian (least significant byte first): the memory layout of ARM, ESP32, AVR and x86.
0x12345678 is 12 34 56 78 big-endian and 78 56 34 12 little-endian. If you memcpy bytes received big-endian straight into a uint32_t on an ESP32, you get 0x78563412. The converter shows both byte orders and the byte-swapped value for every width, so a suspicious number can be checked in seconds.
How a float32 is stored
IEEE 754 single precision packs a float into 32 bits: 1 sign bit, 8 exponent bits (biased by 127) and 23 mantissa bits with an implied leading 1. Some values worth recognising:
1.0 → 0x3F800000
-2.0 → 0xC0000000
3.14 → 0x4048F5C3
When a device sends a float as four bytes, reassemble them in the right order, then reinterpret the bits — never convert the integer value:
uint32_t bits = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) | ((uint32_t)b[2] << 8) | b[3];
float f;
memcpy(&f, &bits, sizeof f); // not (float)bits
Modbus devices add a twist: a float spans two 16-bit registers, and some devices send the low word first (“word swap”). If the value looks absurd, try the other register order. The converter’s float view decodes any 8-digit hex pattern, so you can try both orders quickly.
0.1 is not 0.1
Type 0.1 into the float view and the converter shows what is actually stored:
0.1 as float32 → 0x3DCCCCCD
exact value 0.100000001490116119384765625
Most decimal fractions have no exact binary representation, so the nearest float is stored instead. That’s why 0.1f + 0.2f == 0.3f is false, why summing 0.1 ten thousand times drifts, and why money should never be a float.
Integers have a limit too. A float32 has 24 bits of precision, so above 16,777,216 not every integer can be represented. 16777217 rounds to 16777216. If you store milliseconds since boot in a float, it stops counting individual milliseconds after about 4.6 hours.
Special values
The converter labels these explicitly:
- ±0:
0x00000000and0x80000000. They compare equal, but1/-0is-Infinity. - ±Infinity: exponent all ones, mantissa zero (
0x7F800000). You get it from overflow or dividing by zero. - NaN: exponent all ones, mantissa non-zero. The top mantissa bit separates quiet NaNs (
0x7FC00000) from signalling ones. NaN is never equal to anything, including itself, so check it withisnan(). - Subnormals: exponent all zeros. They extend the range towards zero with reduced precision, and on some microcontrollers without an FPU they’re dramatically slower.
Moving bytes between languages
The last conversion I do constantly is moving byte arrays between a Python test script, C firmware and a C# desktop tool. The converter’s bytes tab reads any of these and writes the others:
Python b'\x01\x03\x00\x00\x00\n'
C {0x01, 0x03, 0x00, 0x00, 0x00, 0x0A}
C# new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A }
Note Python prints 0x0A as \n — easy to miss when you’re comparing frames by eye. If those bytes are a protocol frame, the CRC calculator accepts the same notations and appends the right checksum.
Cheat sheet
- Read signed registers into
int8_t/int16_t/int32_tof the right width. - Check byte order against the datasheet; swap before interpreting.
- Reinterpret float bits with
memcpy, never a cast. - Don’t compare floats with
==; don’t store money or large counters infloat. - Watch for NaN with
isnan().
Keep the number base converter open next to your serial monitor and most of these bugs take seconds, not hours. For turning images into byte arrays rather than numbers, see the image to C array tool.