Floating-Point to Hex Algorithms: Building an Accurate IEEE 754 Single-Precision Web Converter

The "Just Use toString(16)" Trap

Picture this: you're three hours into debugging a firmware issue. A temperature sensor downstream from your microcontroller keeps reporting garbage values — 4.2°C one second, -8192°C the next. The sensor spits its readings over a UART link as raw four-byte IEEE 754 single-precision floats, and you need to verify what's actually arriving on the wire.

So you fire up your browser console and type: (3.14).toString(16). You get "3.23d70a3d70a3d" back. You paste that into your protocol analyzer, compare it against the bytes on the bus, and — nothing matches. You stare at the screen. You refill your coffee. You try again with a different number. Still garbage.

Here's the mistake: toString(16) converts the *value* of the number into a hexadecimal representation of that value. It does not — and this is the part that bites everyone — give you the raw bit pattern of the IEEE 754 encoding. Those are two fundamentally different things. The hex you see in your serial dump is the memory layout of the float, not a hex rendering of its decimal magnitude. And if you're building a web-based converter for your team's debugging toolkit, you need to operate on the bits, not the arithmetic value.

Why Your Float-to-Hex Output Is Lying to You

JavaScript doesn't make this easy. Every number in JavaScript is a 64-bit double-precision float. There is no native 32-bit single-precision type. So when you write 3.14 in your converter, you're already holding a double — 64 bits of precision — and you need to deliberately squeeze it down to 32 bits before you can extract a meaningful single-precision hex pattern.

The common shortcut people reach for is something like:

function floatToHex(f) {
  return f.toString(16);
}

That function is technically not wrong — it's just answering a question nobody asked. It tells you the hex representation of the number 3.14. Your serial bus, however, is asking for the hex representation of the four bytes that encode 3.14 as an IEEE 754 single-precision float. Those bytes are 0x4048F5C3. Notice how "3.23d70a3..." and "4048F5C3" don't even share a single character? That's the gap you need to bridge.

Understanding the IEEE 754 Single-Precision Layout

Before writing any conversion code, let's look at what we're actually assembling. A single-precision float is 32 bits, carved into three fields:

The Three Fields That Matter

Bit 31 — Sign: One bit. Zero means positive, one means negative. Straightforward.

Bits 30–23 — Exponent: Eight bits, stored with a bias of 127. If your number is 1.5 × 2¹, the stored exponent is 1 + 127 = 128, which is 0x80.

Bits 22–0 — Mantissa (Fraction): Twenty-three bits. The leading 1 before the binary point is implied (for normalized numbers) and not stored, so you only keep the fractional part.

Walking Through 3.14

Let's convert 3.14 by hand so the algorithm makes sense later:

1. Sign: Positive → 0 2. Binary of 3.14: 11.00100011110101110000101... (it doesn't terminate cleanly — that's floats for you) 3. Normalize: 1.10010001111010111000010... × 2¹ 4. Exponent: 1 + 127 = 128 → binary 10000000 5. Mantissa: Drop the leading 1, keep 23 bits of the fraction → 10010001111010111000010 6. Assemble: 0 | 10000000 | 10010001111010111000010 7. Hex: Group into nibbles → 0100 0000 0100 1000 1111 0101 1100 0011 → 0x4048F5C3

That's the value you should see in your serial dump when the sensor reports 3.14°C.

Building the Conversion Algorithm Step by Step

Now let's turn that manual process into something a browser can run. The trick is using a DataView over an ArrayBuffer — this gives you low-level control over how bits are packed and read.

Step 1: Allocate a Four-Byte Buffer

const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);

This gives you a raw four-byte window into memory. Think of it as your own personal serial buffer.

Step 2: Write the Float, Read the Integer

view.setFloat32(0, value, true);  // true = little-endian
const intValue = view.getUint32(0, true);
const hex = intValue.toString(16).toUpperCase().padStart(8, '0');

That's the core. You write the float into the buffer using setFloat32, which forces JavaScript to perform the single-precision rounding internally. Then you read those same four bytes back as an unsigned 32-bit integer. The bit pattern is identical — you're just changing how you interpret it. Finally, convert to hex and pad to eight characters.

For 3.14, this produces "4048F5C3". Finally — something that matches your protocol analyzer.

Step 3: Handle Endianness Explicitly

Here's where half the bugs live. Your microcontroller might send bytes in big-endian order while your browser's DataView defaults to little-endian. The true parameter in setFloat32 and getUint32 controls this. If your serial dump shows C3 F5 48 40 instead of 40 48 F5 C3, you've got an endianness mismatch, not a math error. Build a toggle into your converter UI so engineers can flip between the two without rewriting code.

Edge Cases That Will Ruin Your Debugging Session

If your converter only handles normal numbers like 3.14 and -0.5, it'll work fine — until someone feeds it a denormalized value or an infinity. Then the output goes sideways and you're back to staring at the screen.

Zero, Negative Zero, and Infinity

Positive zero is 0x00000000. Negative zero is 0x80000000. Yes, they're different — and if your converter treats them identically, you'll misdiagnose a sign-bit bug in your firmware. Infinity is 0x7F800000 (positive) or 0xFF800000 (negative). The good news: the DataView approach handles all of these correctly because it delegates to the hardware's native float implementation. You don't need special-case logic.

Denormalized Numbers

When the exponent field is all zeros but the mantissa is non-zero, you're looking at a denormalized (subnormal) number. These represent values smaller than 1.175 × 10⁻³⁸. They're rare in temperature sensor data but common in accelerometer readings and audio DSP. The DataView method handles these too, but if you ever implement the bit manipulation manually (for educational purposes or a non-JavaScript environment), you'll need to skip the implicit-leading-1 assumption for this case.

NaN

Not-a-Number is 0x7FC00000 (quiet NaN) in most implementations, though the mantissa bits can vary. If your converter outputs 7FC00000 when someone types "NaN", you're in good shape. If it crashes or returns 00000000, you've got input validation work to do.

Assembling the Final Web Converter

Here's the thing about building tools for engineers: the algorithm is half the battle. The other half is making the tool survive real-world use. Your converter needs to accept decimal input, scientific notation (1.5e-3), hex input for reverse conversion, and the occasional garbage paste from a spreadsheet. It needs to show both the hex value and the raw byte breakdown so someone can match it against a logic analyzer trace byte-by-byte.

The conversion itself — once you're using DataView — is about six lines of code. The surrounding input parsing, endianness toggle, and output formatting will be another sixty. That's fine. That ratio is what separates a quick hack from a tool people bookmark.

One last tip: display the binary representation alongside the hex. When you're chasing a sign-bit error or an off-by-one exponent bias, seeing 0 10000000 10010001111010111000010 laid out visually is worth a thousand console logs. Your fellow engineers will thank you — and more importantly, your debugging sessions will get shorter.