51 KiB
📘 Week 5: Integers and Floats in Embedded Systems: Debugging and Hacking Integers and Floats w/ Intermediate GPIO Output Assembler Analysis
🎯 What You'll Learn This Week
By the end of this tutorial, you will be able to:
- Understand how integers and floating-point numbers are stored in memory
- Know the difference between signed and unsigned integers (
uint8_tvsint8_t) - Understand how floats and doubles are represented using IEEE 754 encoding
- Use inline assembly to control GPIO pins directly at the hardware level
- Debug numeric data types using GDB and the OpenOCD debugger
- Hack integer values by modifying registers at runtime
- Hack floating-point values by understanding and manipulating their binary representation
- Reconstruct 64-bit doubles from two 32-bit registers
Part 1: Understanding Integer Data Types
What is an Integer?
An integer is a whole number without any decimal point. Think of it like counting apples: you can have 0 apples, 1 apple, 42 apples, but you can't have 3.5 apples (that would be a fraction!).
In C programming for embedded systems, we have special integer types that tell the compiler exactly how much memory to use:
+-----------------------------------------------------------------+
| Integer Types - Different Sizes for Different Needs |
| |
| uint8_t: 1 byte (0 to 255) - like a small box |
| int8_t: 1 byte (-128 to 127) - can hold negatives! |
| uint16_t: 2 bytes (0 to 65,535) - medium box |
| uint32_t: 4 bytes (0 to 4 billion) - big box |
| |
+-----------------------------------------------------------------+
Signed vs Unsigned Integers
The difference between uint8_t and int8_t is whether the number can be negative:
| Type | Prefix | Range | Use Case |
|---|---|---|---|
uint8_t |
u |
0 to 255 | Ages, counts, always positive |
int8_t |
none | -128 to 127 | Temperature, can be negative |
The Integer Variables
Let's say a program declares two integer variables that demonstrate the difference between signed and unsigned types:
uint8_t age = 43;
int8_t range = -42;
The variable age is a uint8_t - an unsigned 8-bit integer that can only hold values from 0 to 255. Since age is always a positive number, unsigned is the right choice. The variable range is an int8_t - a signed 8-bit integer that can hold values from -128 to 127. The signed type allows it to represent negative numbers like -42. Under the hood, negative values are stored using two's complement encoding: the CPU flips all the bits of 42 (0x2A) and adds 1, producing 0xD6, which is how -42 lives in a single byte of memory.
Part 2: Understanding Floating-Point Data Types
What is a Float?
A float is a number that can have a decimal point. Unlike integers which can only hold whole numbers like 42, a float can hold values like 42.5, 3.14, or -0.001. In C, the float type uses 32 bits (4 bytes) to store a number using the IEEE 754 standard.
+-----------------------------------------------------------------+
| IEEE 754 Single-Precision (32-bit float) |
| |
| +------+----------+---------------------------+ |
| | Sign | Exponent | Mantissa (Fraction) | |
| | 1bit | 8 bits | 23 bits | |
| +------+----------+---------------------------+ |
| |
| Value = (-1)^sign * 2^(exponent-127) * 1.mantissa |
| |
| Example: 42.5 |
| Sign: 0 (positive) |
| Exponent: 10000100 (132 - 127 = 5) |
| Mantissa: 01010100000000000000000 |
| Full: 0 10000100 01010100000000000000000 |
| Hex: 0x422A0000 |
| |
+-----------------------------------------------------------------+
How to Compute This by Hand (42.5 -> IEEE 754)
Use this exact process any time you need to encode a decimal float manually.
- Determine the sign bit.
42.5is positive, sosign = 0.
- Convert the number to binary.
- Integer part:
42 = 101010 (base 2) - Fractional part: use repeated multiply-by-2 on the fraction.
- Start with
0.5 0.5 * 2 = 1.0-> integer part is1(this is the first binary fractional bit)- Remaining fractional part is now
0.0, so we stop. - Therefore
0.5 = 0.1 (base 2).
- Start with
- Combined:
42.5 = 101010.1 (base 2)
- Normalize to the form
1.xxxxx * 2^n.
101010.1 (base 2) = 1.010101 (base 2) * 2^5- So the true exponent is
n = 5.
- Compute the stored exponent (bias 127 for float).
stored exponent = n + 127 = 5 + 127 = 132132in binary is10000100(8 bits).
Tip: Why 127? The exponent field is 8 bits wide, giving
2^8 = 256total values. Half of that range should represent negative exponents and half positive. The midpoint is(2^8 / 2) - 1 = 127. So a stored exponent of127means a real exponent of 0, values below127are negative exponents, and values above127are positive exponents. Doubles use an 11-bit exponent field so their midpoint (bias) is( 2^{11} / 2) - 1 = 1023instead.
- Build the mantissa (fraction bits).
- Take bits after the leading
1.from1.010101->010101 - Pad with zeros to 23 bits:
01010100000000000000000
- Assemble all fields.
sign | exponent | mantissa0 | 10000100 | 01010100000000000000000- Full 32-bit pattern:
01000010001010100000000000000000
- Convert the 32-bit binary to hex (group by 4 bits).
0100 0010 0010 1010 0000 0000 0000 00004 2 2 A 0 0 0 0- Final result:
0x422A0000
Quick decode check (reverse direction, fully expanded):
Given the 32-bit pattern:
0 | 10000100 | 01010100000000000000000
Decode it field by field:
- Sign bit
- Sign bit is
0-> number is positive. - So the sign multiplier is
(+1).
- Exponent field
- Exponent bits are
10000100. - Convert to decimal:
10000100 (base 2) = 132. - Float bias is
127, so true exponent is: 132 - 127 = 5.
- Mantissa field
- Stored mantissa bits are
01010100000000000000000. - IEEE 754 normal numbers use an implicit leading
1, so significand becomes: 1.010101 (base 2).
- Rebuild the value
- Formula:
value = (+1) * 1.010101 (base 2) * 2^5. - Shift binary point right by 5:
1.010101 * 2^5 = 101010.1 (base 2).
- Convert
101010.1 (base 2)to decimal
- Integer part:
101010 = 32 + 8 + 2 = 42 - Fraction part:
.1 = 1/2 = 0.5 - Total:
42 + 0.5 = 42.5
So the decoded value is exactly 42.5.
Float vs Integer - Key Differences
| Property | Integer (uint8_t) |
Float (float) |
|---|---|---|
| Size | 1 byte | 4 bytes |
| Precision | Exact | ~7 decimal digits |
| Range | 0 to 255 | ±3.4 × 10^38 |
| Encoding | Direct binary | IEEE 754 (sign/exp/mantissa) |
| printf | %d |
%f |
Our Floating-Point Program
Let's look at a simple program that uses a float variable:
File: 0x000e_floating-point-data-type.c
#include <stdio.h>
#include "pico/stdlib.h"
int main(void) {
float fav_num = 42.5;
stdio_init_all();
while (true)
printf("fav_num: %f\r\n", fav_num);
}
Note:
fav_numis declared insidemain, so by C rules it is an automatic (stack) variable. In optimized embedded builds, the compiler may avoid creating a real stack slot and instead materialize the value from read-only constant storage (typically.rodataand/or an ARM literal pool).An ARM literal pool is a small table of constants that the assembler places near code in memory. Instead of encoding a large immediate value directly in an instruction, the CPU executes a load instruction (such as
ldr) that reads the constant from that nearby table. That is why Ghidra can show constant loads rather than a classic stack local.
What this code does:
- Declares a
floatvariablefav_numand initializes it to42.5 - Initializes the serial output
- Prints
fav_numforever in a loop using the%fformat specifier
Tip: Why
%finstead of%d? The%dformat specifier tellsprintfto expect an integer. The%fspecifier tells it to expect a floating-point number. Using the wrong one would print garbage!
Step 1: Flash the Binary to Your Pico 2
- Hold the BOOTSEL button on your Pico 2
- Plug in the USB cable (while holding BOOTSEL)
- Release BOOTSEL - a drive called "RPI-RP2" appears
- Drag and drop
0x000e_floating-point-data-type.uf2onto the drive - The Pico will reboot and start running!
Step 2: Verify It's Working
Open your serial monitor (PuTTY) and you should see:
You should see:
fav_num: 42.500000
fav_num: 42.500000
fav_num: 42.500000
...
The program is printing 42.500000 because printf with %f defaults to 6 decimal places.
🔧 Part 2.5: Setting Up Ghidra for Float Analysis
Step 3: Start Ghidra
Open a terminal and type:
ghidraRun
Ghidra will open. Now we need to create a new project.
Step 4: Create a New Project
- Click File -> New Project
- Select Non-Shared Project
- Click Next
- Enter Project Name:
0x000e_floating-point-data-type - Click Finish
Step 5: Import the Binary
- Open your file explorer
- Navigate to the
Embedded-Hackingfolder - Find
0x000e_floating-point-data-type.bin - Select Cortex M Little Endian 32
- Select Options and set up the .text and offset 10000000
- Drag and drop the
.binfile into Ghidra's project window
Step 6: Configure the Binary Format
A dialog appears. The file is identified as a "BIN" (raw binary without debug symbols).
Click the three dots (...) next to "Language" and:
- Search for "Cortex"
- Select ARM Cortex 32 little endian default
- Click OK
Click the "Options..." button and:
- Change Block Name to
.text - Change Base Address to
10000000(the XIP address!) - Click OK
Step 7: Open and Analyze
- Double-click on the file in the project window
- A dialog asks "Analyze now?" - Click Yes
- Use default analysis options and click Analyze
Wait for analysis to complete (watch the progress bar in the bottom right).
🔍 Part 2.6: Navigating and Resolving Functions
Step 8: Find the Functions
Look at the Symbol Tree panel on the left. Expand Functions.
You'll see function names like:
FUN_1000019aFUN_10000210FUN_10000234
These are auto-generated names because we imported a raw binary without symbols!
Step 9: Resolve Known Functions
From our previous chapters, we know what some of these functions are:
| Ghidra Name | Actual Name | How We Know |
|---|---|---|
FUN_1000019a |
data_cpy |
From Week 3 boot analysis |
FUN_10000210 |
frame_dummy |
From Week 3 boot analysis |
FUN_10000234 |
main |
This is where our code is! |
Step 10: Update Main's Signature
For main, let's also fix the return type:
- Right-click on
mainin the Decompile window - Select Edit Function Signature
- Change to:
int main(void) - Click OK
🔬 Part 2.7: Analyzing the Main Function
Step 11: Examine Main in Ghidra
Click on main (or FUN_10000234). Look at the Decompile window:
You'll see something like:
int main(void)
{
undefined4 uVar1;
undefined4 extraout_r1;
undefined4 uVar2;
undefined4 extraout_r1_00;
FUN_10002f5c();
uVar1 = DAT_1000024c;
uVar2 = extraout_r1;
do {
FUN_100030ec(DAT_10000250,uVar2,0,uVar1);
uVar2 = extraout_r1_00;
} while( true );
}
Step 12: Resolve stdio_init_all
- Click on
FUN_10002f5c - Right-click -> Edit Function Signature
- Change to:
bool stdio_init_all(void) - Click OK
Step 13: Resolve printf
- Click on
FUN_100030ec - Right-click -> Edit Function Signature
- Change to:
int printf(char *format,...) - Check the Varargs checkbox (printf takes variable arguments!)
- Click OK
Step 14: Understand the Float Encoding
Look at the decompiled code after resolving functions:
int main(void)
{
undefined4 uVar1;
undefined4 extraout_r1;
undefined4 uVar2;
undefined4 extraout_r1_00;
stdio_init_all();
uVar1 = DAT_1000024c;
uVar2 = extraout_r1;
do {
printf(DAT_10000250,uVar2,0,uVar1);
uVar2 = extraout_r1_00;
} while( true );
}
Where's float fav_num = 42.5? It's been optimized into an immediate value!
The compiler replaced our float variable with constants passed directly to printf. But wait - we see two values: 0x0, in r2 and DAT_1000024c or 0x40454000, in r3. That's because printf with %f always receives a double (64-bit), not a float (32-bit). The C standard requires that float arguments to variadic functions like printf are promoted to double.
A 64-bit double is passed in two 32-bit registers:
| Register | Value | Role |
|---|---|---|
r2 |
0x00000000 |
Low 32 bits |
r3 |
0x40454000 |
High 32 bits |
Together they form 0x40454000_00000000 - the IEEE 754 double-precision encoding of 42.5.
Step 15: Verify the Double Encoding
We need to decode 0x4045400000000000 field by field. The two registers give us the full 64-bit value:
r3 (high 32 bits): 0x40454000 = 0100 0000 0100 0101 0100 0000 0000 0000
r2 (low 32 bits): 0x00000000 = 0000 0000 0000 0000 0000 0000 0000 0000
Laid out as a single 64-bit value with every bit numbered:
Bit: 63 62-52 (11 bits) 51-32 (20 bits) 31-0 (32 bits)
+---+-----------------------+------------------------------------------+----------------------------------+
| 0 | 1 0 0 0 0 0 0 0 1 0 0 | 0 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 | 00000000000000000000000000000000 |
+---+-----------------------+------------------------------------------+----------------------------------+
Sign Exponent (11) Mantissa high 20 bits Mantissa low 32 bits
(from r3 bits 19-0) (from r2, all zero)
Step-by-step field extraction:
1. Sign bit
In IEEE 754, the sign bit is the very first (leftmost) bit of the 64-bit double. In the full 64-bit layout we call it bit 63:
64-bit double: [bit 63] [bit 62 ... bit 0]
^
sign bit
But we don't have a single 64-bit register - we have two 32-bit registers. The high register r3 holds bits 63-32 of the double. So bit 63 of the double is the same physical bit as bit 31 of r3 (the topmost bit of r3):
r3 holds bits 63-32 of the double
r2 holds bits 31-0 of the double
Now let's check it. IEEE 754 uses a simple rule for the sign bit:
| Sign bit | Meaning |
|---|---|
0 |
Positive |
1 |
Negative |
r3 = 0x40454000 = 0100 0000 0100 0101 0100 0000 0000 0000
^
r3 bit 31 = 0 -> sign = 0 -> Positive number
The topmost bit of r3 is 0, so the number is positive. If that bit were 1 instead (e.g. 0xC0454000), the number would be negative (-42.5).
2. Exponent - bits 62-52 of the 64-bit value = bits 30-20 of r3
Extract bits 30-20 from 0x40454000:
0x40454000 in binary: 0 10000000100 01010100000000000000
sign exponent mantissa (top 20 bits)
Exponent bits: 10000000100
Convert to decimal: 2^{10} + 2^{2} = 1024 + 4 = 1028
But 1028 is not the actual power of 2 yet. IEEE 754 stores exponents with a bias - a fixed number that gets added during encoding so that the stored value is always positive (no sign bit needed for the exponent). For doubles, the bias is 1023.
Tip: Why 1023? The exponent field is 11 bits wide, giving
2^{11} = 2048total values. Half of that range should represent negative exponents and half positive. The midpoint is(2^{11} / 2) - 1 = 1023. So a stored exponent of1023means a real exponent of 0, values below1023are negative exponents, and values above1023are positive exponents.
To recover the real exponent, we subtract the bias:
\text{real exponent} = \text{stored exponent} - \text{bias}
\text{real exponent} = 1028 - 1023 = \mathbf{5}
This means the number is scaled by 2^5 = 32. In other words, the mantissa gets shifted left by 5 binary places.
3. Mantissa - bits 51-0 of the 64-bit value
- High 20 bits of mantissa (bits 51-32) = bits 19-0 of r3:
r3 bits 19-0: 0 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0
- Low 32 bits of mantissa (bits 31-0) = all of r2:
r2 = 0x00000000 -> 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
Full 52-bit mantissa:
0 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 | 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
<- top 20 bits from r3 -> <- bottom 32 bits from r2 (all zero) ->
IEEE 754 always prepends an implied leading 1, so the actual value represented is:
1.010101 00000... (the 1. is implicit, not stored)
4. Reconstruct the value
1.010101\text{ (base 2)} \times 2^5
Shift the binary point 5 places right:
101010.1\text{ (base 2)}
Now convert each bit position to decimal:
| Bit position | Power of 2 | Value |
|---|---|---|
1 (bit 5) |
2^5 |
32 |
0 (bit 4) |
2^4 |
0 |
1 (bit 3) |
2^3 |
8 |
0 (bit 2) |
2^2 |
0 |
1 (bit 1) |
2^1 |
2 |
0 (bit 0) |
2^0 |
0 |
1 (bit -1) |
2^{-1} |
0.5 |
32 + 8 + 2 + 0.5 = \mathbf{42.5}
Step 16: Examine the Assembly
Look at the Listing window (assembly view). Find the main function:
*************************************************************
* FUNCTION
*************************************************************
int __stdcall main (void )
int r0:4 <RETURN>
main+1 XREF[1,1]: 1000018c (c) , 1000018a (*)
main
10000234 38 b5 push {r3,r4,r5,lr}
10000236 02 f0 91 fe bl stdio_init_all bool stdio_init_all(void)
1000023a 00 24 movs r4,#0x0
1000023c 03 4d ldr r5,[DAT_1000024c ] = 40454000h
LAB_1000023e XREF[1]: 10000248 (j)
1000023e 22 46 mov r2,r4
10000240 2b 46 mov r3,r5
10000242 03 48 ldr r0=>s_fav_num:_%f_100034a8 ,[DAT_10000250 ] = "fav_num: %f\r\n"
= 100034A8h
10000244 02 f0 52 ff bl printf int printf(char * format, ...)
10000248 f9 e7 b LAB_1000023e
1000024a 00 ?? 00h
1000024b bf ?? BFh
DAT_1000024c XREF[1]: main:1000023c (R)
1000024c 00 40 45 40 undefine 40454000h
DAT_10000250 XREF[1]: main:10000242 (R)
10000250 a8 34 00 10 undefine 100034A8h * -> 100034a8
💡 Key Insight: The
mov.w r2, #0x0loads the low 32 bits (all zeros) andldr r3, [DAT_...]loads the high 32 bits (0x40454000) of the double. Together,r2:r3=0x40454000_00000000=42.5as a double.
Step 17: Find the Format String
In the Listing view, click on the data reference to find the format string:
s_fav_num:_%f_100034a8 XREF[1]: main:10000242 (*)
100034a8 66 61 76 ds "fav_num: %f\r\n"
5f 6e 75
6d 3a 20
This confirms printf is called with the format string "fav_num: %f\r\n" and the double-precision value of 42.5.
✏️ Part 2.8: Patching the Float - Changing 42.5 to 99.0
Step 18: Calculate the New IEEE 754 Encoding
We want to change 42.5 to 99.0. First, we need to figure out the double-precision encoding of 99.0:
Step A - Convert the integer part (99) to binary:
| Division | Quotient | Remainder |
|---|---|---|
| 99 ÷ 2 | 49 | 1 |
| 49 ÷ 2 | 24 | 1 |
| 24 ÷ 2 | 12 | 0 |
| 12 ÷ 2 | 6 | 0 |
| 6 ÷ 2 | 3 | 0 |
| 3 ÷ 2 | 1 | 1 |
| 1 ÷ 2 | 0 | 1 |
Read remainders bottom-to-top: 99 (base 10) = 1100011 (base 2)
Step B - Convert the fractional part (.0) to binary:
There is no fractional part - .0 is exactly zero, so the fractional binary is just 0.
Step C - Combine:
99.0\text{ (base 10)} = 1100011.0\text{ (base 2)}
Step D - Normalize to IEEE 754 form (move the binary point so there's exactly one 1 before it):
1100011.0\text{ (base 2)} = 1.100011\text{ (base 2)} \times 2^6
We shifted the binary point 6 places left, so the exponent is 6.
Step E - Extract the IEEE 754 fields:
- Sign:
0(positive) - Exponent:
6 + 1023 = 1029 = 10000000101\text{ (base 2)} - Mantissa:
1000110000000000...(everything after the1., padded with zeros to 52 bits) - Full double:
0x4058C00000000000
| Register | Old Value | New Value |
|---|---|---|
r2 |
0x00000000 |
0x00000000 |
r3 |
0x40454000 |
0x4058C000 |
Since r2 stays 0x00000000, we only need to patch the high word loaded into r3.
Step 19: Find the Value to Patch
Look in the Listing view for the data that loads the high word of the double:
1000024c 00 40 45 40 undefined4 40454000h
This is the 32-bit constant that gets loaded into r3 - the high word of our double 42.5.
Step 20: Patch the Constant
- Click on Window -> Bytes
- Click on the Pencil icon to enable byte editing
- At address
1000024c, overwrite00 40 45 40with00 C0 58 40(little-endian for0x40454000 -> 0x4058C000) - Press Enter
This changes the high word from 0x40454000 (42.5 as double) to 0x4058C000 (99.0 as double).
🚀 Part 2.9: Export and Test the Hacked Binary
Step 21: Export the Patched Binary
- Click File -> Export Program
- Set Format to Raw Bytes
- Navigate to your build directory
- Name the file
0x000e_floating-point-data-type-h.bin - Click OK
Step 22: Convert to UF2 Format
Open a terminal and navigate to your project directory:
cd C:\Users\flare-vm\Desktop\Embedded-Hacking-main\0x000e_floating-point-data-type
Run the conversion command:
python ..\uf2conv.py build\0x000e_floating-point-data-type-h.bin --base 0x10000000 --family 0xe48bff59 --output build\hacked.uf2
Step 23: Flash the Hacked Binary
- Hold BOOTSEL and plug in your Pico 2
- Drag and drop
hacked.uf2onto the RPI-RP2 drive - Open your serial monitor
You should see:
fav_num: 99.000000
fav_num: 99.000000
fav_num: 99.000000
...
🎉 BOOM! We hacked the float! The value changed from 42.5 to 99.0!
Part 3: Understanding Double-Precision Floating-Point Data Types
What is a Double?
A double (short for "double-precision floating-point") is like a float but with twice the precision. While a float uses 32 bits, a double uses 64 bits (8 bytes), giving it roughly 15-16 significant decimal digits of precision compared to a float's ~7.
+-----------------------------------------------------------------+
| IEEE 754 Double-Precision (64-bit double) |
| |
| +------+-----------+--------------------------------------+ |
| | Sign | Exponent | Mantissa (Fraction) | |
| | 1bit | 11 bits | 52 bits | |
| +------+-----------+--------------------------------------+ |
| |
| Value = (-1)^sign * 2^(exponent-1023) * 1.mantissa |
| |
| Example: 42.52525 |
| Sign: 0 (positive) |
| Exponent: 10000000100 (1028 - 1023 = 5) |
| Mantissa: 0101010000110011101101100100010110100001110010101100 |
| Hex: 0x4045433B645A1CAC |
| |
+-----------------------------------------------------------------+
Float vs Double - Key Differences
| Property | Float (float) |
Double (double) |
|---|---|---|
| Size | 4 bytes (32 bits) | 8 bytes (64 bits) |
| Precision | ~7 decimal digits | ~15 decimal digits |
| Exponent | 8 bits (bias 127) | 11 bits (bias 1023) |
| Mantissa | 23 bits | 52 bits |
| Range | ±3.4 × 10^38 | ±1.8 × 10^308 |
| printf | %f |
%lf |
| ARM passing | Promoted to double | Native in r2:r3 |
Tip: Why does precision matter? With a
float, the value42.52525might be stored as42.525249due to rounding. Adoublecan represent it as42.525250with much higher fidelity. For scientific or financial applications, that extra precision is critical!
Our Double-Precision Program
Let's look at a program that uses a double variable:
File: 0x0011_double-floating-point-data-type.c
#include <stdio.h>
#include "pico/stdlib.h"
int main(void) {
double fav_num = 42.52525;
stdio_init_all();
while (true)
printf("fav_num: %lf\r\n", fav_num);
}
What this code does:
- Declares a
doublevariablefav_numand initializes it to42.52525 - Initializes the serial output
- Prints
fav_numforever in a loop using the%lfformat specifier
Tip:
%lfvs%f: Whileprintfactually treats%fand%lfidentically (both expect adouble), using%lfmakes your intent clear - you're explicitly working with adouble, not afloat. It's good practice to match the format specifier to your variable type.
Step 1: Flash the Binary to Your Pico 2
- Hold the BOOTSEL button on your Pico 2
- Plug in the USB cable (while holding BOOTSEL)
- Release BOOTSEL - a drive called "RPI-RP2" appears
- Drag and drop
0x000A_intro-to-doubles.uf2onto the drive - The Pico will reboot and start running!
Step 2: Verify It's Working
Open your serial monitor (PuTTY) and you should see:
You should see:
fav_num: 42.525250
fav_num: 42.525250
fav_num: 42.525250
...
The program is printing 42.525250 because printf with %lf defaults to 6 decimal places.
🔧 Part 3.5: Setting Up Ghidra for Double Analysis
Step 3: Start Ghidra
Open a terminal and type:
ghidraRun
Ghidra will open. Now we need to create a new project.
Step 4: Create a New Project
- Click File -> New Project
- Select Non-Shared Project
- Click Next
- Enter Project Name:
0x000A_intro-to-doubles - Click Finish
Step 5: Import the Binary
- Open your file explorer
- Navigate to the
Embedded-Hackingfolder - Find
0x0011_double-floating-point-data-type.bin - Select Cortex M Little Endian 32
- Select Options and set up the .text and offset 10000000
- Drag and drop the
.binfile into Ghidra's project window
Step 6: Configure the Binary Format
A dialog appears. The file is identified as a "BIN" (raw binary without debug symbols).
Click the three dots (...) next to "Language" and:
- Search for "Cortex"
- Select ARM Cortex 32 little endian default
- Click OK
Click the "Options..." button and:
- Change Block Name to
.text - Change Base Address to
10000000(the XIP address!) - Click OK
Step 7: Open and Analyze
- Double-click on the file in the project window
- A dialog asks "Analyze now?" - Click Yes
- Use default analysis options and click Analyze
Wait for analysis to complete (watch the progress bar in the bottom right).
🔍 Part 3.6: Navigating and Resolving Functions
Step 8: Find the Functions
Look at the Symbol Tree panel on the left. Expand Functions.
You'll see function names like:
FUN_1000019aFUN_10000210FUN_10000238
These are auto-generated names because we imported a raw binary without symbols!
Step 9: Resolve Known Functions
From our previous chapters, we know what some of these functions are:
| Ghidra Name | Actual Name | How We Know |
|---|---|---|
FUN_1000019a |
data_cpy |
From Week 3 boot analysis |
FUN_10000210 |
frame_dummy |
From Week 3 boot analysis |
FUN_10000238 |
main |
This is where our code is! |
Step 10: Update Main's Signature
For main, let's also fix the return type:
- Right-click on
mainin the Decompile window - Select Edit Function Signature
- Change to:
int main(void) - Click OK
🔬 Part 3.7: Analyzing the Main Function
Step 11: Examine Main in Ghidra
Click on main (or FUN_10000234). Look at the Decompile window:
You'll see something like:
int main(void)
{
undefined4 uVar1;
undefined4 uVar2;
undefined4 extraout_r1;
undefined4 uVar3;
undefined4 extraout_r1_00;
uVar2 = DAT_10000258;
uVar1 = DAT_10000254;
FUN_10002f64();
uVar3 = extraout_r1;
do {
FUN_100030f4(DAT_10000250,uVar3,uVar1,uVar2);
uVar3 = extraout_r1_00;
} while( true );
}
Step 12: Resolve stdio_init_all
- Click on
FUN_10002f64 - Right-click -> Edit Function Signature
- Change to:
bool stdio_init_all(void) - Click OK
Step 13: Resolve printf
- Click on
FUN_100030f4 - Right-click -> Edit Function Signature
- Change to:
int printf(char *format,...) - Check the Varargs checkbox (printf takes variable arguments!)
- Click OK
Step 14: Understand the Double Encoding
Look at the decompiled code after resolving functions:
int main(void)
{
undefined4 uVar1;
undefined4 uVar2;
undefined4 extraout_r1;
undefined4 uVar3;
undefined4 extraout_r1_00;
uVar2 = DAT_10000258;
uVar1 = DAT_10000254;
stdio_init_all();
uVar3 = extraout_r1;
do {
printf(DAT_10000250,uVar3,uVar1,uVar2);
uVar3 = extraout_r1_00;
} while( true );
}
Where's double fav_num = 42.52525? It's been optimized into immediate values!
This time we see two non-zero values: 0x645a1cac and 0x4045433b. Unlike the float example where the low word was 0x0, a double with a fractional part like 42.52525 needs all 52 mantissa bits - so both halves carry data.
A 64-bit double is passed in two 32-bit registers:
| Register | Value | Role |
|---|---|---|
r2 |
0x645A1CAC |
Low 32 bits |
r3 |
0x4045433B |
High 32 bits |
Together they form 0x4045433B645A1CAC - the IEEE 754 double-precision encoding of 42.52525.
💡 Key Difference from Float: In the float example,
r2was0x00000000because42.5has a clean fractional part. But42.52525has a repeating binary fraction, so the low 32 bits are non-zero (0x645A1CAC). This means both registers matter when patching doubles with complex fractional values!
Step 15: Verify the Double Encoding
We need to decode 0x4045433B645A1CAC field by field. The two registers give us the full 64-bit value:
r3 (high 32 bits): 0x4045433B = 0100 0000 0100 0101 0100 0011 0011 1011
r2 (low 32 bits): 0x645A1CAC = 0110 0100 0101 1010 0001 1100 1010 1100
Laid out as a single 64-bit value with every bit numbered:
Bit: 63 62-52 (11 bits) 51-32 (20 bits) 31-0 (32 bits)
+---+-----------------------+------------------------------------------+------------------------------------------+
| 0 | 1 0 0 0 0 0 0 0 1 0 0 | 0 1 0 1 0 1 0 0 0 0 1 1 0 0 1 1 1 0 1 1 | 01100100010110100001110010101100 |
+---+-----------------------+------------------------------------------+------------------------------------------+
Sign Exponent (11) Mantissa high 20 bits Mantissa low 32 bits
(from r3 bits 19-0) (from r2)
Step-by-step field extraction:
1. Sign bit
The sign bit is bit 63 of the 64-bit double, which is bit 31 of r3 (the high register holds bits 63-32):
r3 = 0x4045433B = 0100 0000 0100 0101 0100 0011 0011 1011
^
r3 bit 31 = 0 -> sign = 0 -> Positive number ✓
2. Exponent - bits 62-52 = bits 30-20 of r3
Extract bits 30-20 from 0x4045433B:
0x4045433B in binary: 0 10000000100 01010100001100111011
sign exponent mantissa (top 20 bits)
Exponent bits: 10000000100
Convert to decimal: 2^{10} + 2^{2} = 1024 + 4 = 1028
Subtract the bias (same formula as Part 2 - the bias is 1023 for all doubles):
\text{real exponent} = 1028 - 1023 = \mathbf{5}
This means the mantissa gets shifted left by 5 binary places (i.e. multiplied by 2^5 = 32).
3. Mantissa - bits 51-0
Unlike the 42.5 example where r2 was all zeros, both registers contribute non-zero bits here:
- High 20 bits of mantissa (bits 51-32) = bits 19-0 of r3:
r3 bits 19-0: 0 1 0 1 0 1 0 0 0 0 1 1 0 0 1 1 1 0 1 1
- Low 32 bits of mantissa (bits 31-0) = all of r2:
r2 = 0x645A1CAC -> 0 1 1 0 0 1 0 0 0 1 0 1 1 0 1 0 0 0 0 1 1 1 0 0 1 0 1 0 1 1 0 0
Full 52-bit mantissa:
0 1 0 1 0 1 0 0 0 0 1 1 0 0 1 1 1 0 1 1 | 0 1 1 0 0 1 0 0 0 1 0 1 1 0 1 0 0 0 0 1 1 1 0 0 1 0 1 0 1 1 0 0
<- top 20 bits from r3 -> <- bottom 32 bits from r2 ->
IEEE 754 always prepends an implied leading 1, so the actual value represented is:
1.0101010000110011101101100100010110100001110010101100 (the 1. is implicit, not stored)
4. Reconstruct the value
1.0101010000110011101101100100...\text{ (base 2)} \times 2^5
Shift the binary point 5 places right:
101010.10000110011101101100100010110100001110010101100\text{ (base 2)}
Integer part (101010):
| Bit position | Power of 2 | Value |
|---|---|---|
1 (bit 5) |
2^5 |
32 |
0 (bit 4) |
2^4 |
0 |
1 (bit 3) |
2^3 |
8 |
0 (bit 2) |
2^2 |
0 |
1 (bit 1) |
2^1 |
2 |
0 (bit 0) |
2^0 |
0 |
32 + 8 + 2 = \mathbf{42}
Fractional part (.10000110011101101...):
| Bit position | Power of 2 | Decimal value |
|---|---|---|
1 (bit -1) |
2^{-1} |
0.5 |
0 (bit -2) |
2^{-2} |
0 |
0 (bit -3) |
2^{-3} |
0 |
0 (bit -4) |
2^{-4} |
0 |
0 (bit -5) |
2^{-5} |
0 |
1 (bit -6) |
2^{-6} |
0.015625 |
1 (bit -7) |
2^{-7} |
0.0078125 |
0 (bit -8) |
2^{-8} |
0 |
0 (bit -9) |
2^{-9} |
0 |
1 (bit -10) |
2^{-10} |
0.0009765625 |
1 (bit -11) |
2^{-11} |
0.00048828125 |
1 (bit -12) |
2^{-12} |
0.000244140625 |
| ... | ... | (remaining 35 bits add smaller and smaller fractions) |
First 12 fractional bits sum: 0.5 + 0.015625 + 0.0078125 + 0.0009765625 + 0.00048828125 + 0.000244140625 \approx 0.5251
The remaining 35 fractional bits refine this to \approx 0.52525. This is because 0.52525 is a repeating fraction in binary - it can never be represented with a finite number of bits, so double precision stores the closest possible 52-bit approximation.
42 + 0.52525 = \mathbf{42.52525} \checkmark
Step 16: Examine the Assembly
Look at the Listing window (assembly view). Find the main function:
*************************************************************
* FUNCTION
*************************************************************
int __stdcall main (void )
int r0:4 <RETURN>
main+1 XREF[1,1]: 1000018c (c) , 1000018a (*)
main
10000238 38 b5 push {r3,r4,r5,lr}
1000023a 06 a5 adr r5,[0x10000254 ]
1000023c d5 e9 00 45 ldrd r4,r5,[r5,#0x0 ]=>DAT_10000254 = 645A1CACh
= 4045433Bh
10000240 02 f0 90 fe bl stdio_init_all bool stdio_init_all(void)
LAB_10000244 XREF[1]: 1000024e (j)
10000244 22 46 mov r2,r4
10000246 2b 46 mov r3,r5
10000248 01 48 ldr r0=>s_fav_num:_%lf_100034b0 ,[DAT_10000250 ] = "fav_num: %lf\r\n"
= 100034B0h
1000024a 02 f0 53 ff bl printf int printf(char * format, ...)
1000024e f9 e7 b LAB_10000244
DAT_10000250 XREF[1]: main:10000248 (R)
10000250 b0 34 00 10 undefine 100034B0h ? -> 100034b0
DAT_10000254 XREF[1]: main:1000023c (R)
10000254 ac 1c 5a 64 undefine 645A1CACh
DAT_10000258 XREF[1]: main:1000023c (R)
10000258 3b 43 45 40 undefine 4045433Bh
💡 Key Insight: Notice that both
r2andr3are loaded from data constants usingldr. Compare this to the float example wherer2was loaded withmov.w r2, #0x0. Because42.52525requires all 52 mantissa bits, neither word can be zero - the compiler must store both halves as separate data constants.
Step 17: Find the Format String
In the Listing view, click on the data reference to find the format string:
s_fav_num:_%lf_100034b0 XREF[1]: main:10000248 (*)
100034b0 66 61 76 ds "fav_num: %lf\r\n"
5f 6e 75
6d 3a 20
This confirms printf is called with the format string "fav_num: %lf\r\n" and the double-precision value of 42.52525.
✏️ Part 3.8: Patching the Double - Changing 42.52525 to 99.99
Step 18: Calculate the New IEEE 754 Encoding
We want to change 42.52525 to 99.99. First, we need to figure out the double-precision encoding of 99.99:
99.99 = 1.5623... \times 2^6 = 1.100011111111...\text{ (base 2)} \times 2^6- Sign:
0(positive) - Exponent:
6 + 1023 = 1029 = 10000000101\text{ (base 2)} - Mantissa:
1000111111010111000010100011110101110000101000111... (base 2) - Full double:
0x4058FF5C28F5C28F
| Register | Old Value | New Value |
|---|---|---|
r2 |
0x645A1CAC |
0x28F5C28F |
r3 |
0x4045433B |
0x4058FF5C |
Unlike the float example, both registers change! The value 99.99 has a repeating binary fraction, so both the high and low words are different.
Step 19: Find the Values to Patch
Look in the Listing view for the two data constants:
Low word (loaded into r2):
10000254 ac 1c 5a 64 undefined4 645A1CACh
High word (loaded into r3):
10000258 3b 43 45 40 undefined4 4045433Bh
Step 20: Patch Both Constants
Patch the low word:
- Click on the data at address
10000254containing645A1CAC - Open the Bytes window and enable byte editing (Pencil icon)
- Overwrite bytes
ac 1c 5a 64with8f c2 f5 28(little-endian for0x645A1CAC -> 0x28F5C28F) - Press Enter
Patch the high word:
- Click on the data at address
10000258containing4045433B - Keep byte editing enabled in the Bytes window
- Overwrite bytes
3b 43 45 40with5c ff 58 40(little-endian for0x4045433B -> 0x4058FF5C) - Press Enter
This changes the full 64-bit double from 0x4045433B645A1CAC (42.52525) to 0x4058FF5C28F5C28F (99.99).
💡 Key Difference from Float Patching: When we patched the float
42.5, we only needed to change one word (the high word inr3) because the low word was all zeros. With42.52525 -> 99.99, both words change. Always check whether the low word is non-zero before patching!
🚀 Part 3.9: Export and Test the Hacked Binary
Step 21: Export the Patched Binary
- Click File -> Export Program
- Set Format to Raw Bytes
- Navigate to your build directory
- Name the file
0x0011_double-floating-point-data-type-h.bin - Click OK
Step 22: Convert to UF2 Format
Open a terminal and navigate to your project directory:
cd C:\Users\flare-vm\Desktop\Embedded-Hacking-main\0x0011_double-floating-point-data-type
Run the conversion command:
python ..\uf2conv.py build\0x0011_double-floating-point-data-type-h.bin --base 0x10000000 --family 0xe48bff59 --output build\hacked.uf2
Step 23: Flash the Hacked Binary
- Hold BOOTSEL and plug in your Pico 2
- Drag and drop
hacked.uf2onto the RPI-RP2 drive - Open your serial monitor
You should see:
fav_num: 99.990000
fav_num: 99.990000
fav_num: 99.990000
...
🎉 BOOM! We hacked the double! The value changed from 42.52525 to 99.99!
📊 Part 3.95: Summary - Float and Double Analysis
What We Accomplished
- Learned about IEEE 754 - How floating-point numbers are encoded in 32-bit (float) and 64-bit (double) formats
- Discovered float-to-double promotion -
printfwith%falways receives adouble, even when you pass afloat - Decoded register pairs - 64-bit doubles are split across
r2(low) andr3(high) - Patched a float value - Changed
42.5to99.0by modifying only the high word - Patched a double value - Changed
42.52525to99.99by modifying both words - Understood the key difference - Clean fractions (like
42.5) have a zero low word; complex fractions (like42.52525) require patching both words
IEEE 754 Quick Reference for Common Values
| Value | Double Hex | High Word (r3) | Low Word (r2) |
|---|---|---|---|
| 42.0 | 0x4045000000000000 |
0x40450000 |
0x00000000 |
| 42.5 | 0x4045400000000000 |
0x40454000 |
0x00000000 |
| 42.52525 | 0x4045433B645A1CAC |
0x4045433B |
0x645A1CAC |
| 43.0 | 0x4045800000000000 |
0x40458000 |
0x00000000 |
| 99.0 | 0x4058C00000000000 |
0x4058C000 |
0x00000000 |
| 99.99 | 0x4058FF5C28F5C28F |
0x4058FF5C |
0x28F5C28F |
| 100.0 | 0x4059000000000000 |
0x40590000 |
0x00000000 |
| 3.14 | 0x40091EB851EB851F |
0x40091EB8 |
0x51EB851F |
The Float/Double Patching Workflow
+-----------------------------------------------------------------+
| 1. Identify the float/double value in the decompiled view |
| - Look for hex constants like 0x40454000 or 0x4045433B |
+-----------------------------------------------------------------+
| 2. Determine if it's float (32-bit) or double (64-bit) |
| - printf promotes floats to doubles! |
| - Check if value spans r2:r3 (double) or just r0 (float) |
+-----------------------------------------------------------------+
| 3. Check if the low word (r2) is zero or non-zero |
| - Zero low word = only patch the high word |
| - Non-zero low word = patch BOTH words |
+-----------------------------------------------------------------+
| 4. Calculate the new IEEE 754 encoding |
| - Convert your desired value to IEEE 754 |
| - Split into high/low words |
+-----------------------------------------------------------------+
| 5. Patch the constant(s) in Ghidra |
| - Edit bytes in the Bytes window (Pencil mode) |
| - Replace the old encoding with the new one |
+-----------------------------------------------------------------+
| 6. Export -> Convert to UF2 -> Flash -> Verify |
| - Same workflow as integer patching |
+-----------------------------------------------------------------+
Tip: Key takeaway: Hacking doubles is the same process as hacking floats - find the IEEE 754 constant, calculate the new encoding, patch it. The only extra step is checking whether the low word (
r2) is also non-zero. Clean values like42.5only need one patch; messy fractions like42.52525need two!
💡 Key Takeaways
-
Integers have fixed sizes -
uint8_tis 1 byte (0-255),int8_tis 1 byte (-128 to 127). Theuprefix means unsigned. -
IEEE 754 encodes floats in binary - Sign bit, exponent (with bias), and mantissa form the encoding for both 32-bit floats and 64-bit doubles.
-
printf promotes floats to doubles - Even when you pass a
float,printfreceives a 64-bitdoubledue to C's variadic function rules. -
64-bit values span two registers - On ARM Cortex-M33, doubles use
r2(low 32 bits) andr3(high 32 bits). -
Clean fractions have zero low words - Values like
42.5have0x00000000in the low word; complex fractions like42.52525have non-zero low words. -
Inline assembly controls hardware directly - The
mcrrcoprocessor instruction talks to the GPIO block without any SDK overhead. -
Binary patching works on any data type - Integers, floats, and doubles can all be patched in Ghidra using the same workflow.
📖 Glossary
| Term | Definition |
|---|---|
| Bias | Constant added to the exponent in IEEE 754 (127 for float, 1023 for double) |
| Double | 64-bit floating-point type following IEEE 754 double-precision format |
| Exponent | Part of IEEE 754 encoding that determines the magnitude of the number |
| Float | 32-bit floating-point type following IEEE 754 single-precision format |
| FUNCSEL | Function Select - register field that assigns a GPIO pin's function (e.g., SIO) |
| GPIO | General Purpose Input/Output - controllable pins on a microcontroller |
| IEEE 754 | International standard for floating-point arithmetic and binary encoding |
| Inline Assembly | Assembly code embedded directly within C source using __asm volatile |
| int8_t | Signed 8-bit integer type (-128 to 127) |
| IO_BANK0 | Register block at 0x40028000 that controls GPIO pin function selection |
| Mantissa | Fractional part of IEEE 754 encoding (23 bits for float, 52 bits for double) |
| mcrr | ARM coprocessor register transfer instruction used for GPIO control |
| PADS_BANK0 | Register block at 0x40038000 that controls GPIO pad electrical properties |
| Promotion | Automatic conversion of a smaller type to a larger type (float -> double) |
| Register Pair | Two 32-bit registers (r2:r3) used together to hold a 64-bit value |
| UF2 | USB Flashing Format - file format for Pico 2 firmware |
| uint8_t | Unsigned 8-bit integer type (0 to 255) |
📚 Additional Resources
GPIO Coprocessor Reference
The RP2350 GPIO coprocessor instructions:
| Instruction | Description |
|---|---|
mcrr p0, #4, Rt, Rt2, c0 |
Set/clear GPIO output |
mcrr p0, #4, Rt, Rt2, c4 |
Set/clear GPIO output enable |
RP2350 Memory Map Quick Reference
| Address | Description |
|---|---|
0x10000000 |
XIP Flash (code) |
0x20000000 |
SRAM (data) |
0x40028000 |
IO_BANK0 (GPIO control) |
0x40038000 |
PADS_BANK0 (pad control) |
0xd0000000 |
SIO (single-cycle I/O) |
IEEE 754 Encoding Formula
+-----------------------------------------------------------------+
| Float (32-bit): [1 sign] [8 exponent] [23 mantissa] |
| Double (64-bit): [1 sign] [11 exponent] [52 mantissa] |
| |
| Value = (-1)^sign * 2^(exponent - bias) * (1 + mantissa) |
| |
| Float bias: 127 |
| Double bias: 1023 |
+-----------------------------------------------------------------+
Remember: Every binary you encounter in the real world can be analyzed and understood using these same techniques. Whether it's an integer, a float, or a double - it's all just bits waiting to be decoded. Practice makes perfect!
Happy hacking! 🎉