Floating Point, Endianness, and Bit-Packing (Verifying with Python)

TL;DR


1. IEEE-754 basics you actually need

float (32-bit)

double (64-bit)

The important practical facts:


2. Why a double becomes two .word values on RV32

RV32 has 32-bit general-purpose registers, and many toolchains represent constants in 32-bit chunks.

So a compiler might emit:

1
2
3
.LC0:
  .word  962072674
  .word  1083394740

That is simply 64 bits, split into low 32 bits and high 32 bits (because of little-endian layout).


3. Hands-on: produce a double constant in assembly

Create:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/double_const.c
#include "types.h"
#include "uart.h"

static const double g = 1234.676;

__attribute__((noinline))
double f(void) {
  return g;
}

int main(void) {
  union {
    double d;
    u32 w[2];
  } u;

  u.d = f();
  uart_puts("lo=");
  uart_puthex32(u.w[0]);
  uart_puts(" hi=");
  uart_puthex32(u.w[1]);
  uart_putc('\n');
  return 0;
}

Compile to assembly:

1
2
3
4
5
6
7
8
9
riscv64-unknown-elf-gcc -S -O0 -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 \
  -o build/double_const.s src/double_const.c

sed -n '1,200p' build/double_const.s

riscv64-unknown-elf-gcc -O0 -g -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 \
  -T src/link.ld src/start.s src/uart.c src/double_const.c -o build/double_const.elf

qemu-system-riscv32 -M virt -nographic -bios none -kernel build/double_const.elf

Look for .LC* labels and the emitted words.


4. Verify the exact bits using Python (struct)

Pack a double into bytes

1
2
3
4
import struct
x = 1234.676
b = struct.pack('<d', x)  # little-endian double
print(b.hex())

Split into two 32-bit words (little-endian)

1
2
3
4
5
6
import struct
x = 1234.676
b = struct.pack('<d', x)
lo, hi = struct.unpack('<II', b)   # two unsigned 32-bit ints
print('lo =', lo)
print('hi =', hi)

If your assembly shows:

…then you have a perfect match.


5. Going the other way: reconstruct the double from the words

If you only have two .word values from assembly:

1
2
3
4
5
6
import struct
lo = 962072674
hi = 1083394740
b = struct.pack('<II', lo, hi)
x = struct.unpack('<d', b)[0]
print(x)

That yields the exact double value represented by those bits.


6. Quick endian sanity check

If you pack with big-endian by mistake:

1
2
3
import struct
x = 1234.676
print(struct.pack('>d', x).hex())

You’ll see the bytes reversed compared to the little-endian representation. This is a very common source of confusion when correlating hexdumps with numeric values.


7. What about floating-point instructions on RV32?

RISC-V floating point depends on ISA extensions:

If you compile with an ABI that expects float registers (like ilp32d), the calling convention changes for floating-point parameters/returns.

In many embedded cases you’ll be using soft-float (floating operations implemented in software), which impacts performance and disassembly shape.


8. Practical reverse-engineering use cases

A fast heuristic:


Exercises

  1. Repeat the experiment for a negative double (e.g., -0.125) and verify the sign bit effect.
  2. Take a pair of .word constants from your own firmware/ELF and try reconstructing the double with Python.
  3. Create a float constant and verify its 32-bit representation with struct.pack('<f', x) and struct.unpack('<I', ...).
  4. Dump .rodata bytes with objdump -s -j .rodata and try decoding the first 3 double values you find.

How to test your answers


Summary

You learned how floats/doubles become bytes, why RV32 often emits 64-bit constants as two .words, and how to verify everything with Python bit-packing.

Next: debugging with QEMU + GDB-we’ll combine what you learned so far to inspect registers, memory, and control flow in a disciplined way.