Files
Embedded-Hacking/WEEK05/WEEK05-BN.md
T
Kevin Thomas 55251a2d52 WEEK05-BN: add Binary Ninja lesson for Week 5 (float + double IEEE 754)
New WEEK05-BN.md/.pdf mirroring WEEK04-BN: build/flash/symbol map, load the raw
.bin, dynamic (GDB MI break + live double hack) then static (resolve functions,
patch the 42.5->99.0 one-word and 42.52525->99.99 two-word constants, export,
convert, flash) for both 0x000e float and 0x0011 double. README links it.
2026-10-03 18:50:25 -04:00

115 KiB

Week 5-BN: Binary Ninja Personal — Decode, Hack, and Patch IEEE 754 Floats and Doubles (Raw .bin)


LEGAL DISCLAIMER: The information, tools, and code provided in this repository and course are strictly for educational, research, and defensive purposes only.

You are explicitly prohibited from using any materials contained herein to access, test, modify, or exploit any device, network, or system that you do not own 100% or for which you do not have explicit, documented, and legally binding authorization to interact with.

By using this repository and course, you acknowledge and agree that:

  1. Any illegal, unauthorized, or malicious use of this information is solely your responsibility.
  2. The author(s) and contributor(s) of this repository and course shall not be held liable for any damages, legal repercussions, criminal charges, or unauthorized actions resulting from the use, misuse, or abuse of the contents herein.
  3. You will comply with all applicable local, state, national, and international laws regarding cybersecurity and computer fraud.

IF YOU DO NOT AGREE WITH THESE TERMS, DO NOT USE THIS REPOSITORY AND COURSE.


What You'll Learn This Week

  • Build the two lesson projects with Release and get both an .elf and a raw .bin
  • Dump the ELF symbol map with arm-none-eabi-nm and use it as ground truth
  • Load the raw .bin into Binary Ninja at 0x10000000
  • Read how a float/double constant is materialized from the compiler's literal pool
  • Break at main on live silicon, even though main moves between these two programs
  • Reconstruct a 64-bit double from the ABI register pair r2:r3 and hack it live
  • Resolve the functions in the Binary Ninja GUI using the ELF symbol map, including the pico_double formatting helpers printf pulls in
  • Patch the constant bytes, export the image, convert to UF2, and flash it
  • Prove why 42.5 -> 99.0 is a one-word patch but 42.52525 -> 99.99 needs two

How This Guide Works

The build produces two files for each project:

File What it is How we use it
.elf The linked image with a full symbol table Ground truth for every function address and name
.bin The raw flash image, no headers, no symbols The image we load into Binary Ninja and reverse

The .bin is built from the .elf, so the ELF tells you exactly what is at every address. We use the ELF symbol map to resolve functions in Binary Ninja, and we reverse-engineer the raw .bin the way a real extracted firmware image is reversed.

Build Release, not Debug. Every address in this guide matches the Week 5 lesson, and the Week 5 lesson is a Release build. Release optimizes the code the same way the original lesson was built: it folds the float/double initializer into the literal pool and links the pico_double formatting helpers straight into the printf path. If you build Debug, the SDK function addresses move and the float/double helpers are laid out differently, so nothing lines up. Always build Release for this lesson.

The order is dynamic first, static second, twice — once per project:

  1. Break on the live target and prove what the code does.
  2. Hack it live in the debugger and watch the output change.
  3. Resolve the functions in Binary Ninja using the ELF symbol map.
  4. Patch the bytes, export, convert, and flash.
Project Prints The hack
0x000e_floating-point-data-type fav_num: 42.500000 change the printed double 42.5 to 99.0 (one word)
0x0011_double-floating-point-data-type fav_num: 42.525250 change the printed double 42.52525 to 99.99 (two words)

Addresses come from your build. Every address here is from the Release build produced in Step 3. Confirm against your own .elf with the command in Step 4.

main is not at the same address in both projects this week. It is 0x10000234 in Project 1 and 0x10000238 in Project 2, because each program materializes its constant slightly differently. We still anchor to main through the one byte-identical place that always names it: the middle blx in platform_entry (Step 12).


Part 1: Build, Flash, and Get the Symbol Map

Step 1: Install the toolchain

Windows x64

  • Install the Raspberry Pi Pico extension in VS Code. It installs the ARM GNU toolchain, CMake, Ninja, and the Pico SDK.
  • Install Binary Ninja Personal and complete its license activation.
  • Install PuTTY for the serial monitor.

macOS Apple Silicon

brew install cmake ninja
  • Install Binary Ninja Personal and complete its license activation.
  • Install the Arm GNU Toolchain, or let the VS Code Pico extension manage it.

Linux x64

sudo apt install cmake ninja-build gcc-arm-none-eabi libnewlib-arm-none-eabi git python3 openocd minicom
  • Install Binary Ninja Personal and complete its license activation.

Step 2: Verify your tools are the right architecture (do not skip this)

On macOS Apple Silicon, the most common failure is an Intel x86_64 tool on your PATH:

zsh: bad CPU type in executable: cmake

You may have two Homebrews: the arm64 one at /opt/homebrew and the Intel one at /usr/local. If /usr/local/bin wins, every brew tool is x86_64. Check:

file "$(which cmake)"
file "$(which ninja)"
file "$(which arm-none-eabi-gdb)"
file "$(which arm-none-eabi-nm)"
file "$(which openocd)"
file "$(which telnet)"

All must report arm64. If any is x86_64, put the Apple Silicon prefix first for the session and check again:

export PATH="/opt/homebrew/bin:$PATH"
hash -r
file "$(which cmake)"

To make it permanent, add that export to ~/.zshrc. Do not use Rosetta as a fix; OpenOCD and GDB are exactly the kind of programs where a translation layer produces failures that look like debugger bugs.

telnet is special — and optional. The GDB MI workflow does not need it; it is only used by the command-port fallback. macOS no longer ships telnet, and the Homebrew build is often the Intel one, so telnet 127.0.0.1 4444 fails with bad CPU type in executable. Your brew command itself may also be the Intel one: if brew install telnet fails with .../portable-ruby/.../ruby: Bad CPU type in executable, you are running the Intel Homebrew. Call the Apple Silicon Homebrew explicitly:

/opt/homebrew/bin/brew install telnet

If you would rather not install anything, macOS ships an arm64 nc, which can connect to the same OpenOCD port:

nc 127.0.0.1 4444

Windows x64 and Linux x64 do not have this problem. Skip to Step 3.

Step 3: Build the two projects with Release

Run this once inside 0x000e_floating-point-data-type/ and once inside 0x0011_double-floating-point-data-type/:

cmake -B build -G Ninja -DPICO_BOARD=pico2 -DPICO_PLATFORM=rp2350 -DCMAKE_BUILD_TYPE=Release
cmake --build build

Point Binary Ninja at this repository (once). Every console snippet below reads the repo root from ~/.embedded-hacking-repo, so Binary Ninja never needs a database open and nothing is hardcoded. From the repo root, run once:

macOS / Linux:

pwd > ~/.embedded-hacking-repo

Windows (PowerShell):

(Get-Location).Path | Set-Content "$env:USERPROFILE\.embedded-hacking-repo"

Then build from the Binary Ninja console, so the whole build -> patch -> flash loop stays inside Binary Ninja. The console inherits a minimal PATH — on macOS just /usr/bin:/bin:/usr/sbin:/sbin — so it does not see Homebrew; add your package manager's bin first, then run plain cmake.

macOS Apple Silicon:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()
os.environ["PATH"] = "/opt/homebrew/bin:" + os.environ["PATH"]  # the console's PATH omits Homebrew
for name in ("0x000e_floating-point-data-type", "0x0011_double-floating-point-data-type"):
    proj = os.path.join(root, name)
    subprocess.run(["cmake", "-B", "build", "-G", "Ninja", "-DPICO_BOARD=pico2",
                    "-DPICO_PLATFORM=rp2350", "-DCMAKE_BUILD_TYPE=Release"], cwd=proj)
    subprocess.run(["cmake", "--build", "build"], cwd=proj)

Linux x64:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()
for name in ("0x000e_floating-point-data-type", "0x0011_double-floating-point-data-type"):
    proj = os.path.join(root, name)
    subprocess.run(["cmake", "-B", "build", "-G", "Ninja", "-DPICO_BOARD=pico2",
                    "-DPICO_PLATFORM=rp2350", "-DCMAKE_BUILD_TYPE=Release"], cwd=proj)
    subprocess.run(["cmake", "--build", "build"], cwd=proj)

Windows x64:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()
for name in ("0x000e_floating-point-data-type", "0x0011_double-floating-point-data-type"):
    proj = os.path.join(root, name)
    subprocess.run(["cmake", "-B", "build", "-G", "Ninja", "-DPICO_BOARD=pico2",
                    "-DPICO_PLATFORM=rp2350", "-DCMAKE_BUILD_TYPE=Release"], cwd=proj)
    subprocess.run(["cmake", "--build", "build"], cwd=proj)

Each build directory now contains the pair we need:

  • 0x000e_floating-point-data-type/build/0x000e_floating-point-data-type.elf and .bin — .bin is 15308 bytes (0x3bcc)
  • 0x0011_double-floating-point-data-type/build/0x0011_double-floating-point-data-type.elf and .bin — .bin is 15324 bytes (0x3bdc)

If the ARM toolchain is not on your PATH, add -DPICO_TOOLCHAIN_PATH=...:

OS Typical toolchain path
Windows x64 C:/Program Files/Arm GNU Toolchain arm-none-eabi/14.2 rel1/bin
macOS Apple Silicon ~/.pico-sdk/toolchain/14_2_Rel1/bin
Linux x64 /usr

This guide's toolchain lives at ~/.pico-sdk/toolchain/14_2_Rel1/bin. All of arm-none-eabi-nm, arm-none-eabi-objdump, and arm-none-eabi-gdb resolved in this document come from there. If your install is elsewhere, which arm-none-eabi-nm tells you where to point.

Step 4: Dump the ELF symbol map

This is the ground truth for the whole lesson. Run arm-none-eabi-nm on each ELF and keep the output in a terminal or a text file:

macOS Apple Silicon / Linux x64:

arm-none-eabi-nm -n --defined-only build/0x000e_floating-point-data-type.elf | grep -E ' [Tt] '
arm-none-eabi-nm -n --defined-only build/0x0011_double-floating-point-data-type.elf | grep -E ' [Tt] '

Windows x64:

arm-none-eabi-nm -n --defined-only build\0x000e_floating-point-data-type.elf | Select-String ' [Tt] '
arm-none-eabi-nm -n --defined-only build\0x0011_double-floating-point-data-type.elf | Select-String ' [Tt] '

Each line is address type name. The T/t type is a function. Here are the functions this lesson uses. The signatures come from the ELF's DWARF debug info queried with arm-none-eabi-gdb -batch -ex "ptype <name>", so they are exact.

Project 1 — 0x000e_floating-point-data-type:

Address ELF symbol Signature Role
0x1000015c _reset_handler void _reset_handler(void) reset entry
0x10000186 platform_entry void platform_entry(void) calls runtime_init, main, exit
0x1000019a data_cpy void data_cpy(void*, void*, void*) copies .data from flash to SRAM
0x100001e4 _init void _init(void) runs .init_array
0x10000210 frame_dummy void frame_dummy(void) C runtime boilerplate
0x10000234 main int main(void) the lesson function
0x10000254 gpio_set_function void gpio_set_function(uint, gpio_function_t) SDK GPIO helper
0x10000da8 time_us_64 uint64_t time_us_64(void) SDK microsecond clock
0x10000e18 uart_init uint uart_init(uart_inst_t*, uint) SDK UART init
0x10002ca8 vfctprintf int vfctprintf(void (*)(char, void*), void*, const char*, va_list) printf format engine
0x10002d04 exit void exit(int) C runtime exit
0x10002d0c runtime_init void runtime_init(void) SDK runtime init
0x10002d38 stdio_out_chars_crlf void stdio_out_chars_crlf(stdio_driver_t*, const char*, int) CRLF output driver
0x10002e48 stdio_put_string int stdio_put_string(const char*, int, bool, bool) buffered string output
0x10002f34 stdio_set_driver_enabled void stdio_set_driver_enabled(stdio_driver_t*, bool) enable the UART driver
0x10002f5c stdio_init_all bool stdio_init_all(void) SDK serial init
0x10003028 __wrap_vprintf int __wrap_vprintf(const char*, va_list) printf core
0x100030ec __wrap_printf int __wrap_printf(const char*, ...) the printf wrapper
0x100032a8 stdio_uart_init void stdio_uart_init(void) SDK UART stdio init
0x100033e8 strlen size_t strlen(const char*) C runtime string length

Project 1 also links in the pico_double formatting helpers that printf's %f path calls. These are reachable from main through __wrap_printf:

Address ELF symbol Signature Role
0x10001384 __wrap___aeabi_dadd double __wrap___aeabi_dadd(double, double) double add
0x100013ac __wrap___aeabi_dsub double __wrap___aeabi_dsub(double, double) double subtract
0x100013d4 __wrap___aeabi_dmul double __wrap___aeabi_dmul(double, double) double multiply
0x10001420 __wrap___aeabi_ddiv double __wrap___aeabi_ddiv(double, double) double divide
0x100014bc __wrap___aeabi_i2d double __wrap___aeabi_i2d(int) int -> double
0x100014e0 __wrap___aeabi_ui2d double __wrap___aeabi_ui2d(unsigned) unsigned -> double
0x10001504 __wrap___aeabi_d2iz int __wrap___aeabi_d2iz(double) double -> int
0x10001528 __wrap___aeabi_d2uiz unsigned __wrap___aeabi_d2uiz(double) double -> unsigned
0x1000154c __wrap___aeabi_dcmpun int __wrap___aeabi_dcmpun(double, double) unordered compare
0x10001570 __wrap___aeabi_dcmplt int __wrap___aeabi_dcmplt(double, double) less-than compare
0x10001598 __wrap___aeabi_dcmple int __wrap___aeabi_dcmple(double, double) less-or-equal compare
0x100015c0 __wrap___aeabi_dcmpge int __wrap___aeabi_dcmpge(double, double) greater-or-equal compare
0x100015e8 __wrap___aeabi_dcmpgt int __wrap___aeabi_dcmpgt(double, double) greater-than compare
0x1000160c _out_rev unsigned _out_rev(out_fct_type, char*, size_t, size_t, const char*, size_t, unsigned, unsigned) reversed-digit output
0x100016a8 _ntoa_format unsigned _ntoa_format(out_fct_type, char*, size_t, size_t, char*, size_t, bool, unsigned, unsigned, unsigned, unsigned) number formatter
0x1000187c _out_char void _out_char(char, void*, size_t, size_t) single-char sink
0x10001890 _ftoa unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int) fixed-point float formatter
0x10001d50 _etoa unsigned int _etoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int) exponential float formatter
0x100022c4 _vsnprintf int _vsnprintf(out_fct_type, char*, size_t, const char*, va_list) the format dispatcher
0x10000dbc busy_wait_us void busy_wait_us(uint64_t) UART timing loop
0x10000fec clock_get_hz unsigned long clock_get_hz(clock_handle_t) UART clock lookup

Project 2 — 0x0011_double-floating-point-data-type:

Address ELF symbol Signature Role
0x1000015c _reset_handler void _reset_handler(void) reset entry
0x10000186 platform_entry void platform_entry(void) calls runtime_init, main, exit
0x1000019a data_cpy void data_cpy(void*, void*, void*) copies .data from flash to SRAM
0x100001e4 _init void _init(void) runs .init_array
0x10000210 frame_dummy void frame_dummy(void) C runtime boilerplate
0x10000238 main int main(void) the lesson function
0x1000025c gpio_set_function void gpio_set_function(uint, gpio_function_t) SDK GPIO helper
0x10000db0 time_us_64 uint64_t time_us_64(void) SDK microsecond clock
0x10000e20 uart_init uint uart_init(uart_inst_t*, uint) SDK UART init
0x10002cb0 vfctprintf int vfctprintf(void (*)(char, void*), void*, const char*, va_list) printf format engine
0x10002d0c exit void exit(int) C runtime exit
0x10002d14 runtime_init void runtime_init(void) SDK runtime init
0x10002d40 stdio_out_chars_crlf void stdio_out_chars_crlf(stdio_driver_t*, const char*, int) CRLF output driver
0x10002e50 stdio_put_string int stdio_put_string(const char*, int, bool, bool) buffered string output
0x10002f3c stdio_set_driver_enabled void stdio_set_driver_enabled(stdio_driver_t*, bool) enable the UART driver
0x10002f64 stdio_init_all bool stdio_init_all(void) SDK serial init
0x10003030 __wrap_vprintf int __wrap_vprintf(const char*, va_list) printf core
0x100030f4 __wrap_printf int __wrap_printf(const char*, ...) the printf wrapper
0x100032b0 stdio_uart_init void stdio_uart_init(void) SDK UART stdio init
0x100033f0 strlen size_t strlen(const char*) C runtime string length

Project 2 uses the same pico_double formatting helpers as Project 1, shifted by eight bytes because main moved:

Address ELF symbol Signature Role
0x1000138c __wrap___aeabi_dadd double __wrap___aeabi_dadd(double, double) double add
0x100013b4 __wrap___aeabi_dsub double __wrap___aeabi_dsub(double, double) double subtract
0x100013dc __wrap___aeabi_dmul double __wrap___aeabi_dmul(double, double) double multiply
0x10001428 __wrap___aeabi_ddiv double __wrap___aeabi_ddiv(double, double) double divide
0x100014c4 __wrap___aeabi_i2d double __wrap___aeabi_i2d(int) int -> double
0x100014e8 __wrap___aeabi_ui2d double __wrap___aeabi_ui2d(unsigned) unsigned -> double
0x1000150c __wrap___aeabi_d2iz int __wrap___aeabi_d2iz(double) double -> int
0x10001530 __wrap___aeabi_d2uiz unsigned __wrap___aeabi_d2uiz(double) double -> unsigned
0x10001554 __wrap___aeabi_dcmpun int __wrap___aeabi_dcmpun(double, double) unordered compare
0x10001578 __wrap___aeabi_dcmplt int __wrap___aeabi_dcmplt(double, double) less-than compare
0x100015a0 __wrap___aeabi_dcmple int __wrap___aeabi_dcmple(double, double) less-or-equal compare
0x100015c8 __wrap___aeabi_dcmpge int __wrap___aeabi_dcmpge(double, double) greater-or-equal compare
0x100015f0 __wrap___aeabi_dcmpgt int __wrap___aeabi_dcmpgt(double, double) greater-than compare
0x10001614 _out_rev unsigned _out_rev(out_fct_type, char*, size_t, size_t, const char*, size_t, unsigned, unsigned) reversed-digit output
0x100016b0 _ntoa_format unsigned _ntoa_format(out_fct_type, char*, size_t, size_t, char*, size_t, bool, unsigned, unsigned, unsigned, unsigned) number formatter
0x10001884 _out_char void _out_char(char, void*, size_t, size_t) single-char sink
0x10001898 _ftoa unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int) fixed-point float formatter
0x10001d58 _etoa unsigned int _etoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int) exponential float formatter
0x100022cc _vsnprintf int _vsnprintf(out_fct_type, char*, size_t, const char*, va_list) the format dispatcher
0x10000dc4 busy_wait_us void busy_wait_us(uint64_t) UART timing loop
0x10000ff4 clock_get_hz unsigned long clock_get_hz(clock_handle_t) UART clock lookup

A literal pool is still a pool. The float/double initializer does not survive as a C variable, but the compiler still has to place the IEEE-754 bit pattern somewhere. It parks the 32-bit word (or word pair) right after main's code and reaches it with a PC-relative ldr/ldrd. That is why the value you patch is a .word in the image, not a stack store.

Step 5: Flash Project 1 and confirm fav_num: 42.500000

A .bin has no headers, so OpenOCD must be told the base address 0x10000000. From the repository root:

macOS Apple Silicon / Linux x64:

./flash.sh 0x000e_floating-point-data-type/build/0x000e_floating-point-data-type.bin

Windows x64 (PowerShell):

.\flash.ps1 -Bin 0x000e_floating-point-data-type\build\0x000e_floating-point-data-type.bin

Or flash from the Binary Ninja console (the console reads the repo root from the marker file, so it works with no database open):

macOS Apple Silicon / Linux x64:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
bin_path = os.path.join(root, "0x000e_floating-point-data-type", "build", "0x000e_floating-point-data-type.bin")
log = os.path.join(os.path.dirname(bin_path), "flash.log")
subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # free the probe first
subprocess.Popen([os.path.join(root, "flash.sh"), bin_path],
                 stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
print("flashing in the background; log:", log)

Windows x64:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
bin_path = os.path.join(root, "0x000e_floating-point-data-type", "build", "0x000e_floating-point-data-type.bin")
log = os.path.join(os.path.dirname(bin_path), "flash.log")
subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"])  # free the probe first
subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File",
                  os.path.join(root, "flash.ps1"), "-Bin", bin_path],
                 stdout=open(log, "w"), stderr=subprocess.STDOUT)
print("flashing in the background; log:", log)

Wait for wrote 15308 bytes ... and ** Verified OK **. Open a serial monitor at 115200 baud:

  • Windows x64: PuTTY -> Connection type Serial, the Pico's COM port, speed 115200.
  • macOS Apple Silicon: screen /dev/tty.usbmodem* 115200 (quit with Ctrl-A then K).
  • Linux x64: minicom -D /dev/ttyACM0 -b 115200.
fav_num: 42.500000
fav_num: 42.500000
fav_num: 42.500000
...

The program prints 42.500000 because printf with %f defaults to six decimal places.

Step 6: Flash Project 2 and confirm fav_num: 42.525250

# macOS / Linux
./flash.sh 0x0011_double-floating-point-data-type/build/0x0011_double-floating-point-data-type.bin
# Windows
.\flash.ps1 -Bin 0x0011_double-floating-point-data-type\build\0x0011_double-floating-point-data-type.bin

Or flash from the Binary Ninja console (same form as Step 5, pointing at the Project 2 .bin):

macOS / Linux:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
bin_path = os.path.join(root, "0x0011_double-floating-point-data-type", "build", "0x0011_double-floating-point-data-type.bin")
log = os.path.join(os.path.dirname(bin_path), "flash.log")
subprocess.run(["pkill", "-TERM", "-f", "openocd"])
subprocess.Popen([os.path.join(root, "flash.sh"), bin_path],
                 stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
print("flashing in the background; log:", log)

Windows:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
bin_path = os.path.join(root, "0x0011_double-floating-point-data-type", "build", "0x0011_double-floating-point-data-type.bin")
log = os.path.join(os.path.dirname(bin_path), "flash.log")
subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"])
subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File",
                  os.path.join(root, "flash.ps1"), "-Bin", bin_path],
                 stdout=open(log, "w"), stderr=subprocess.STDOUT)
print("flashing in the background; log:", log)

Wait for wrote 15324 bytes .... The serial monitor shows:

fav_num: 42.525250
fav_num: 42.525250
fav_num: 42.525250
...

42.52525 has a repeating binary fraction, so its 52 mantissa bits are not all zero. Remember that: it is why this value needs two words patched, and 42.5 needs only one.

Part 2: Load the Raw .bin into Binary Ninja

Start from a fresh Binary Ninja state. If you already have a .bndb for this lesson, close it and start over; a stale database keeps old names and patches.

Step 7: Bring the raw .bin into Binary Ninja

A raw .bin has no headers, so Binary Ninja cannot know where it belongs or what architecture it is. You must supply both. If you just double-click the .bin, Binary Ninja may load it at address 0x0 with a guessed architecture, and every address in this lesson will be wrong.

  1. Choose File -> Open with Options... (do not use plain File -> Open).
  2. Select 0x000e_floating-point-data-type/build/0x000e_floating-point-data-type.bin.
  3. In the loader options, set:
    • Architecture: thumb2 (the ARMv7-M / ARMv8-M Thumb-2 architecture, which covers the Cortex-M33)
    • Platform: thumb2
    • Base Address: 0x10000000 (the XIP flash base)
  4. Click Open.

Binary Ninja analyzes the image and opens the linear view.

Verify the load before going further. Press G, type 0x10000000, and read the first two words:

0x10000000   0x20082000   initial stack pointer
0x10000004   0x1000015d   reset vector (bit 0 = Thumb)

If you instead see data at 0x00000000, or a vector word without bit 0 set, close the tab and repeat with Open with Options. The Cortex-M33 only executes Thumb-2, so thumb2 is the only correct architecture.

Console equivalent:

load("0x000e_floating-point-data-type/build/0x000e_floating-point-data-type.bin",
     options={"loader.imageBase": 0x10000000, "loader.platform": "thumb2"})

Step 8: Save it as a Binary Ninja database (.bndb)

Binary Ninja never writes back into the .bin. Your names, comments, types, and patches live in a separate .bndb database. Save one now, before you make any changes:

  1. Choose File -> Save As....
  2. Save it next to the image as 0x000e_floating-point-data-type.bndb.
  3. From now on, save with File -> Save (Cmd+S on macOS, Ctrl+S on Windows/Linux) whenever you rename or patch.

The two files have different roles:

File Role
0x000e_floating-point-data-type.bin the raw firmware image; Binary Ninja never modifies it
0x000e_floating-point-data-type.bndb your analysis database: names, types, comments, and patches

When you come back later, open the .bndb, not the .bin; that restores all your work. If a database gets messy, delete the .bndb and re-import the .bin from Step 7 — the firmware is never at risk. You export the patched image out of this view later, in Step 19.

Step 9: The views you will use

  • Linear view: the disassembly listing. You navigate, read, and patch here.
  • Graph view: the control-flow graph of the current function.
  • Decompiler (HLIL): the pseudo-C decompilation.
  • Hex view: raw bytes, used for patching.
  • Function list: the sidebar list of every detected function.

Navigation: G go to address, N rename, Y set type or signature, ; add a comment. Breakpoints are set from the GUI through the GDB MI adapter — see Step 13.

macOS function keys: the top-row F keys are usually mapped to system functions. Every step here uses menu paths that work without them.


Part 3: Dynamic — Break at main and Hack Live (Project 1)

Step 10: Start OpenOCD as a live debug server

Make sure no other OpenOCD is running; a forgotten server holds port 3333.

macOS / Linux:

ps aux | grep -i openocd

Windows (PowerShell):

Get-Process | Where-Object { $_.ProcessName -like '*openocd*' }

Stop any leftover server gracefully:

macOS / Linux:

pkill -TERM -f openocd

Windows (PowerShell):

Get-Process openocd -ErrorAction SilentlyContinue | Stop-Process

Start the server parked at main:

macOS Apple Silicon / Linux x64:

BP_ADDR=0x10000234 ./debug-server.sh

Windows x64 (PowerShell):

$env:BP_ADDR="0x10000234"; .\debug-server.ps1

Or start it from the Binary Ninja console, freeing the probe first and launching the server in the background so the console returns immediately:

macOS Apple Silicon / Linux x64:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # stop any running server first
log = os.path.join(root, "openocd.log")
p = subprocess.Popen([os.path.join(root, "debug-server.sh")], cwd=root,
                     env=dict(os.environ, BP_ADDR="0x10000234"),
                     stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
print("OpenOCD started (pid", p.pid, "); log:", log)

Windows x64:

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"])  # stop any running server first
log = os.path.join(root, "openocd.log")
p = subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File",
                      os.path.join(root, "debug-server.ps1")], cwd=root,
                     env=dict(os.environ, BP_ADDR="0x10000234"),
                     stdout=open(log, "w"), stderr=subprocess.STDOUT)
print("OpenOCD started (pid", p.pid, "); log:", log)

Popen returns in a few milliseconds; the server keeps running in the background. Check openocd.log for Listening on port 3333, then connect in Step 11.

Wait for:

Info : [rp2350.dap.core0] Examination succeed
Startup breakpoint at 0x10000234 (2-byte hardware execute, one-shot).
Info : starting gdb server for rp2350.dap.core0 on 3333
Info : Listening on port 3333 for gdb connections

BP_ADDR parks the core at main before any client connects. The script arms a 2-byte hardware breakpoint and then does the startup reset run, so the core runs from the vector table and stops at your address with no debugger attached yet. When Binary Ninja connects a moment later, the first thing it reads is already the truth: Stopped at 0x10000234. This is the whole reason the lab works cleanly — you never have to drive a reset from outside the GUI.

Use the address you actually want to stop at:

What you want to stop at Project 1 0x000e Project 2 0x0011 Command
main (once per reset) 0x10000234 0x10000238 BP_ADDR=0x10000234 ./debug-server.sh
The loop — the printf call, hit every iteration 0x10000244 0x1000024a BP_ADDR=0x10000244 ./debug-server.sh
BP_ADDR=0x10000234 ./debug-server.sh   # park at main
BP_ADDR=0x10000244 ./debug-server.sh   # park in the loop instead
$env:BP_ADDR="0x10000234"; .\debug-server.ps1   # park at main
$env:BP_ADDR="0x10000244"; .\debug-server.ps1   # park in the loop

Note the loop address differs from Week 4 and between the two projects. In Project 1 the bl __wrap_printf sits at 0x10000244; in Project 2 it sits at 0x1000024a, because Project 2 loads the pair with ldrd first. Both were verified against the Release .elf with arm-none-eabi-objdump and confirmed live on hardware.

This startup stop is single-use. OpenOCD flushes breakpoints when a client connects, so this one is gone once Binary Ninja attaches — fine for main, which only runs once per reset. Every breakpoint after that is set from the Binary Ninja GUI (Step 13) and is repeatable. To stop at main again, restart the server with BP_ADDR and reconnect.

Exactly one core. The line must say core0 and must not mention core1. Core1 is never started by this firmware; exposing it makes Binary Ninja read core1's reset-state registers, which are not real addresses, and OpenOCD floods the log with Failed to read memory at 0xf0000000. The scripts already use USE_CORE=0; do not change it.

Windows driver note: the Debug Probe must use the WinUSB driver. If OpenOCD reports unable to open CMSIS-DAP device, install it with Zadig (select Debug Probe (CMSIS-DAP) -> WinUSB).

Step 11: Connect Binary Ninja to the GDB server

  1. Make sure the image is open and analyzed (Part 2) and the server from Step 10 is running (parked at main).
  2. Choose Debugger -> Connect to Remote Process.
  3. In the adapter dropdown, select GDB MI.
  4. In the connect settings group, set IP Address to 127.0.0.1 and Port to 3333.
  5. Set Full GDB Executable Path to your arm-none-eabi-gdb. On macOS the build that works is the 14.2.rel1 toolchain:
    ~/.pico-sdk/toolchain/14_2_Rel1/bin/arm-none-eabi-gdb
    
  6. Click Accept.

Use the GDB MI adapter. It launches a real arm-none-eabi-gdb --interpreter=mi2 and lets Binary Ninja drive it, so breakpoints and stepping go through real GDB — which sends the correct 2-byte breakpoint length and handles step-over itself. Verified working end to end: connect, GUI breakpoints (Add Hardware Breakpoint..., hardware execute), Step Into / Step Over, and register edits. Stops are reported as Breakpoint (not SingleStep).

Do NOT have any breakpoints set in Binary Ninja before you connect. With the GDB MI adapter, attaching while Binary Ninja already has a breakpoint hangs the session. Start the server parked with BP_ADDR (Step 10), connect, and only add hardware breakpoints after the connection is up. This is a Binary Ninja bug; it is the single most common GDB MI failure.

The GDB executable path matters. Use the 14.2.rel1 build above. The 13.3.rel1 build did not connect in testing.

Do not pick Corellium. Binary Ninja's adapter dropdown also lists Corellium, which is for Corellium's virtual devices and expects an API token, not a local OpenOCD server. It is not the adapter for this lab. The dropdown is a combo box, so an accidental arrow-key press can land on it — always read the label back and confirm it says GDB MI before clicking Accept.

The adapter and port are not saved in the .bndb. Every time you relaunch Binary Ninja you must re-select GDB MI, re-enter port 3333, and re-set the GDB path.

Watch for an off-screen error dialog. When a connection fails, Binary Ninja pops a Binary Ninja critical alert window that can be positioned mostly outside the main window, which makes it look like nothing happened. If the connect seems to do nothing, check your other display.

The target keeps running. Open the Registers tab (bug icon) and confirm you see live values. pc inside 0x10003xxx and sp just below 0x20082000 are healthy.

If pc is 0x00000088, 0x000000ec, or sp is 0xf0000000, the session is bad. Restart the server, then restart Binary Ninja (a server restart while attached leaves Binary Ninja in a stale session), and connect again.

Step 12: Find main without relying on its address

main moves between these programs (0x10000234 vs 0x10000238), so we do not guess it. We follow the one fixed path to it. Press G and go to 0x10000000:

0x10000000   0x20082000   initial stack pointer (top of SRAM)
0x10000004   0x1000015d   reset vector

Bit 0 of a vector is the Thumb bit, so 0x1000015d means "start at 0x1000015c". That is _reset_handler. Follow the reset path to 0x10000186, platform_entry:

10000186: 4914   ldr  r1, [pc, #80] ; @ 0x100001d8
10000188: 4788   blx  r1            ; runtime_init
1000018a: 4914   ldr  r1, [pc, #80] ; @ 0x100001dc
1000018c: 4788   blx  r1            ; main  <-- the fixed anchor
1000018e: 4914   ldr  r1, [pc, #80] ; @ 0x100001e0
10000190: 4788   blx  r1            ; exit
10000192: be00   bkpt 0x0000

The middle blx at 0x1000018c is the call to main. platform_entry is byte-identical in both projects, so 0x1000018c catches main no matter where the linker placed it. The literal pool at 0x100001dc holds main | 1; clearing bit 0 gives 0x10000234 for Project 1 and 0x10000238 for Project 2.

Step 13: Set a hardware breakpoint in the GUI

With the GDB MI adapter, Binary Ninja sets breakpoints through real GDB, which sends the correct 2-byte length, so you set them in the UI. There is no command port here.

Why older drafts used the command port. Binary Ninja's GDB RSP adapter is its own minimal RSP client and sends a 1-byte breakpoint (Z0,<addr>,1); the Cortex-M33 FPB comparators need 2 bytes, so OpenOCD rejected it with only breakpoints of two bytes length supported. The old workaround was to arm breakpoints by hand over telnet. The GDB MI adapter does not have this problem — it drives real arm-none-eabi-gdb, which sends the right length. So everything below is done in the GUI. The command port still exists as a fallback (see the end of this step), but you do not need it.

Where you can stop

You want to stop at Address How Repeatable?
main Project 1 0x10000234, Project 2 0x10000238 The server starts parked there with BP_ADDR (Step 10), so Binary Ninja is already stopped at main when it connects. No — main runs once per reset.
The loop (printf call) Project 1 0x10000244, Project 2 0x1000024a Set a hardware breakpoint in the GUI, then click Resume. Yes — fires on every iteration.

Set the loop breakpoint in the GUI

  1. Press G, type the loop address (0x10000244 for Project 1, 0x1000024a for Project 2), and press Enter.
  2. Set a hardware execution breakpoint at that address, either way:
    • Debugger -> Add Hardware Breakpoint... — a hardware execute (HE) breakpoint. Use this one.
    • click the line and press F2 (Debugger -> Toggle Breakpoint) — a software breakpoint. It will not work here: the code is in read-only flash, so GDB cannot install it and the core just keeps running.
  3. Click Resume. The core is already running the loop, so the breakpoint fires on the next iteration. Binary Ninja stops with the PC at the loop address and reports it as a Breakpoint — verified: Stopped (Breakpoint) at 0x10000244.

No breakpoints before you connect. With GDB MI, a breakpoint set before the connection hangs the session (Step 11). Start parked with BP_ADDR, connect, then add breakpoints.

Stepping

With the target halted at the breakpoint, Step Into (F7) and Step Over (F8) run through real GDB and move the PC. Verified: 0x10000244 -> 0x100030ec -> 0x100030ee -> ....

Step Over on the raw .bin steps into calls. The raw image has no symbol for __wrap_printf, so Step Over at the printf call behaves like Step Into. When the lab needs to execute the call and then stop, it moves the breakpoint to the return site and clicks Resume instead (Step 14 shows this).

Never use Binary Ninja's Restart button. On RP2350 it resets and halts inside the boot ROM (pc=0x88, sp=0xf0000000). To reset cleanly, restart the server with BP_ADDR and reconnect.

If you ever need the command port. It is still there — nc 127.0.0.1 4444, and bp <addr> 2 hw still arms a breakpoint, rbp <addr> / rbp all still remove them. It is the fallback if you switch back to the GDB RSP adapter, whose 1-byte breakpoints the GUI cannot set. With GDB MI you do not need it for this lab.

Step 14: HACK IT LIVE — change the printed double

Project 1's main sets r4 = 0, loads the high word of the double into r5, and on every iteration copies them into the r2:r3 argument pair before calling printf. We break on that call and change the value live:

  1. Press G, go to 0x10000244 (the bl __wrap_printf).

  2. Set a hardware execute breakpoint there: Debugger -> Add Hardware Breakpoint.... (Do not use F2 — that is a software breakpoint and will not work on read-only flash.)

  3. Click Resume in Binary Ninja. The target is already running the loop, so the breakpoint fires on the next iteration. Binary Ninja stops with the program counter at 0x10000244, r2 = 0x00000000, and r3 = 0x40454000.

  4. Open the Registers widget (bug icon -> Registers).

  5. The ABI passes the promoted double in r2:r3 — r2 is the low 32 bits, r3 the high 32 bits. Together they are 0x40454000_00000000, the IEEE-754 encoding of 42.5.

  6. Set r3 to 0x4058C000 — the high word of 99.0 — from Binary Ninja's Python console (Plugins -> Python Console):

    dbg.set_reg_value("r2", 0x00000000)  # low word of the 99.0 double (unchanged)
    dbg.set_reg_value("r3", 0x4058C000)  # high word of the 99.0 double
    

    dbg.set_reg_value(name, value) writes one register (returns True on success). You can also right-click a register in the Registers widget, press E (edit), type the hex value, and press Enter. The widget may not repaint, but the write reaches the target — you confirm it by the printed output in the next steps.

  7. Move the breakpoint past the call. You want printf to run once and then stop, so move the breakpoint from 0x10000244 to the instruction after the call, 0x10000248 (the b.n that closes the loop): remove the breakpoint at 0x10000244 and set a hardware breakpoint at 0x10000248. Two reasons not to just click Step Over here: a breakpoint left on the current PC re-traps the step, and Binary Ninja's Step Over steps into __wrap_printf on this raw .bin because the image carries no symbol for the call. Moving the breakpoint to the return site is deterministic.

  8. Click Resume in Binary Ninja. The core executes bl __wrap_printf with r2:r3 = 0x4058C000_00000000, so this iteration prints fav_num: 99.000000, then stops at 0x10000248.

  9. Look at your serial monitor — the screen session on the Pico's USB serial port — and at the Target tab in Binary Ninja:

    fav_num: 99.000000
    

You changed a running program's output without touching the binary.

Step 14b: HACK THE STRING LIVE — change fav_num: to myvalue:

The text "fav_num: %f\r\n" lives in flash (.rodata) at 0x100034a8, and flash is read-only at runtime — a debugger write there does not stick. So instead of overwriting the text in place, redirect the pointer: at the printf call, r0 holds the string address, so point r0 at a replacement string you place in RAM.

  1. Arm the breakpoint at the printf call and hit it, exactly as in Step 14 steps 1-3. At the stop, r0 = 0x100034a8, r2 = 0x00000000, and r3 = 0x40454000.
  2. Put the replacement string into free RAM at 0x20080000 from Binary Ninja's Python console (Plugins -> Python Console) — no command port needed:
    dbg.write_memory(0x20080000, b"myvalue: %f\r\n\x00")
    
    dbg.write_memory(address, bytes) is Binary Ninja's debugger memory-write API; it returns True on success. That writes the bytes 6d 79 76 61 6c 75 65 3a 20 25 66 0d 0a 00 = "myvalue: %f\r\n\0".
  3. Point r0 at that string:
    dbg.set_reg_value("r0", 0x20080000)
    
    (Or right-click r0 in the Registers widget, press E, type 0x20080000, and press Enter.)
  4. If you want the value hack too, set r3 to 0x4058C000 as in Step 14. Then move the breakpoint past the call in the GUI (remove it at 0x10000244, set one at 0x10000248) and click Resume. The core runs printf with r0 pointing at your RAM string and r2:r3 holding 99.0, so this iteration prints:
    myvalue: 99.000000
    
    then stops at 0x10000248.

Like the value hack, this is one iteration only: the loop reloads r0 (and r2/r3) from flash/literals on every pass, so the next line is fav_num: 42.500000 again. The permanent version is the static patch in Step 18b.

Step 15: Why the hack reverts (and why we patch next)

Press Resume. The loop branches back to 0x1000023e, which copies r4 and r5 into r2 and r3 again. r4 and r5 were set once before the loop (movs r4, #0, ldr r5, [pc, #12]), so your r3 edit is overwritten and the next line is fav_num: 42.500000. The live edit changed one iteration only. There is no variable in memory to change; the value is baked into the instruction/literal pool. To make 99.0 permanent we must patch the constant. That is the static pass.

Press Pause to stop the output flood.

Step 15b: Kill the debugger and OpenOCD

The live hack is done. Do this before the static pass.

  1. In the Debugger sidebar, click the X (Kill) (or Debugger -> Kill) to disconnect Binary Ninja.

  2. Kill does not stop the OpenOCD process — debug-server.sh started it separately, and it keeps running and holding the probe. Stop it from the Binary Ninja console:

    macOS / Linux:

    import subprocess
    subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # stop the debug server, free the probe
    

    Windows:

    import subprocess
    subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"])  # stop the debug server, free the probe
    
  3. Confirm nothing is left: pgrep -fl openocd (macOS/Linux) prints nothing.

From a terminal it is the same: pkill -TERM -f openocd, or Get-Process openocd | Stop-Process on Windows.

Part 4: Static — Resolve the Functions in Binary Ninja and Patch (Project 1)

Step 16: Resolve the functions in the Binary Ninja GUI

We now name the functions in Binary Ninja using the ELF symbol map from Step 4. Binary Ninja loaded the raw .bin with no symbols, so every function shows as sub_<addr> — resolving means giving each one its real name and signature.

Three keys do all the work:

Key Binary Ninja action Use it for
G Go to address Jump to a function's address
Y Change Type Set the function's signature. The dialog shows the full prototype, so this sets the name and the type in one step.
N Rename Rename only, when you just want the name and not the type

For each function below: G to its address, then Y (Change Type) and type the prototype from the table.

How to resolve a function in Binary Ninja (Y)

Y is the Change Type key, and it is what actually resolves the function — it turns void sub_10002f5c() into bool stdio_init_all(void). The Change Type dialog shows the full declaration (name and type), so typing the prototype sets both:

  1. G to the function's address. The cursor lands on the function.
  2. Press Y. In the Change Type dialog, type the prototype from the table exactly — for example bool stdio_init_all(void) — and press Enter.

The decompiler header then shows the real prototype, and calls to the function read cleanly instead of sub_<addr>(). N is only for renaming without touching the type; Y alone sets both the name and the type.

If Y seems to do nothing, confirm the cursor is on the function, or right-click it and pick Change Type.... Binary Ninja parses what you type and silently keeps the old type if it does not parse, so glance at the header after each Y.

Worked example: main

  1. Press G, type 0x10000234, press Enter. The view jumps there; the cursor lands on sub_10000234.
  2. Press Y (Change Type), type int main(void), press Enter. That sets the name to main and the type to int(void).

Binary Ninja shows int32_t where Ghidra shows int. After you set int main(void), the decompiler header may read int32_t main(void). That is the same type — on this platform int is 32 bits and Binary Ninja's parser normalises it to int32_t. Do not fight it; it is not an error.

Worked example: stdio_init_all

  1. G -> 0x10002f5c.
  2. Y -> bool stdio_init_all(void).

It returns bool, not void — the ELF says _Bool stdio_init_all(void). Our main ignores the return value, so the decompiler still reads cleanly.

Worked example: uart_init

  1. G -> 0x10000e18.
  2. Y -> uint uart_init(uart_inst_t *uart, uint baudrate).

Worked example: __wrap_printf

  1. G -> 0x100030ec.
  2. Y -> int __wrap_printf(const char *fmt, ...). Keep the ... — printf is variadic.

printf in our source is __wrap_printf in the binary. The SDK links our printf calls to its __wrap_printf wrapper.

Worked example: _ftoa (the double formatter)

  1. G -> 0x10001890.
  2. Y -> unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int).

This is the function that actually turns the double into the 42.500000 text. It is why the pico_double __aeabi_* helpers are in the image at all.

The rest of the chain is the same two keystrokes per function (G, then Y). This is our code plus the library functions it actually calls — not the whole SDK. main only calls stdio_init_all and printf, so we follow that chain down: stdio_init_all pulls in the stdio/UART setup, and printf lands in the SDK's __wrap_printf, which reaches the pico_double formatter.

The call chain for this project:

main
├── stdio_init_all
│   └── stdio_uart_init ── gpio_set_function, uart_init, stdio_set_driver_enabled
│                              └── uart_init ── clock_get_hz, busy_wait_us
└── __wrap_printf ── __wrap_vprintf
    ├── vfctprintf ── _vsnprintf
    │   ├── _ftoa / _etoa ── __wrap___aeabi_* (the pico_double helpers)
    │   └── _ntoa_format / _out_rev
    ├── stdio_out_chars_crlf
    └── time_us_64

Project 1 — resolve every function in that chain:

Address Rename to (N) Signature (Y)
0x1000015c _reset_handler void _reset_handler(void)
0x10000186 platform_entry void platform_entry(void)
0x1000019a data_cpy void data_cpy(void*, void*, void*)
0x100001e4 _init void _init(void)
0x10000210 frame_dummy void frame_dummy(void)
0x10000234 main int main(void)
0x10002ca8 vfctprintf int vfctprintf(void (*)(char, void*), void*, const char*, va_list)
0x10002d04 exit void exit(int)
0x10002d0c runtime_init void runtime_init(void)
0x10002d38 stdio_out_chars_crlf void stdio_out_chars_crlf(stdio_driver_t*, const char*, int)
0x10002e48 stdio_put_string int stdio_put_string(const char*, int, bool, bool)
0x10002f34 stdio_set_driver_enabled void stdio_set_driver_enabled(stdio_driver_t*, bool)
0x10002f5c stdio_init_all bool stdio_init_all(void)
0x10003028 __wrap_vprintf int __wrap_vprintf(const char*, va_list)
0x100030ec __wrap_printf int __wrap_printf(const char*, ...)
0x100032a8 stdio_uart_init void stdio_uart_init(void)
0x100033e8 strlen size_t strlen(const char*)
0x10000254 gpio_set_function void gpio_set_function(uint, gpio_function_t)
0x10000e18 uart_init uint uart_init(uart_inst_t*, uint)
0x10000da8 time_us_64 uint64_t time_us_64(void)

Project 1 — resolve the pico_double formatting helpers printf reaches:

Address Rename to (N) Signature (Y)
0x10001384 __wrap___aeabi_dadd double __wrap___aeabi_dadd(double, double)
0x100013ac __wrap___aeabi_dsub double __wrap___aeabi_dsub(double, double)
0x100013d4 __wrap___aeabi_dmul double __wrap___aeabi_dmul(double, double)
0x10001420 __wrap___aeabi_ddiv double __wrap___aeabi_ddiv(double, double)
0x100014bc __wrap___aeabi_i2d double __wrap___aeabi_i2d(int)
0x100014e0 __wrap___aeabi_ui2d double __wrap___aeabi_ui2d(unsigned)
0x10001504 __wrap___aeabi_d2iz int __wrap___aeabi_d2iz(double)
0x10001528 __wrap___aeabi_d2uiz unsigned __wrap___aeabi_d2uiz(double)
0x1000154c __wrap___aeabi_dcmpun int __wrap___aeabi_dcmpun(double, double)
0x10001570 __wrap___aeabi_dcmplt int __wrap___aeabi_dcmplt(double, double)
0x10001598 __wrap___aeabi_dcmple int __wrap___aeabi_dcmple(double, double)
0x100015c0 __wrap___aeabi_dcmpge int __wrap___aeabi_dcmpge(double, double)
0x100015e8 __wrap___aeabi_dcmpgt int __wrap___aeabi_dcmpgt(double, double)
0x1000160c _out_rev unsigned _out_rev(out_fct_type, char*, size_t, size_t, const char*, size_t, unsigned, unsigned)
0x100016a8 _ntoa_format unsigned _ntoa_format(out_fct_type, char*, size_t, size_t, char*, size_t, bool, unsigned, unsigned, unsigned, unsigned)
0x1000187c _out_char void _out_char(char, void*, size_t, size_t)
0x10001890 _ftoa unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)
0x10001d50 _etoa unsigned int _etoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)
0x100022c4 _vsnprintf int _vsnprintf(out_fct_type, char*, size_t, const char*, va_list)
0x10000dbc busy_wait_us void busy_wait_us(uint64_t)
0x10000fec clock_get_hz unsigned long clock_get_hz(clock_handle_t)

A void return type may not stick — here is the fix. Binary Ninja treats void as low-confidence, and its analysis can override it with an inferred type — most often int32_t on this 32-bit target. It is most visible on _reset_handler (a hand-written assembly entry that never returns normally), but it can happen to any function whose return type Binary Ninja thinks it can infer.

Setting the full signature with Y reproduces the unwanted int32_t, and fn.return_type = ... fails too. What works is the return-value setter:

from binaryninja import ReturnValue, Type
fn = bv.get_function_at(0x1000015c)
if fn is not None:
    fn.return_value = ReturnValue(Type.void())

That holds _reset_handler at void even after reanalysis. If it still will not stick, leave it — it does not affect the rest of the lesson.

__wrap_printf is the real symbol. printf in our source compiles to the SDK's __wrap_printf (which forwards to __wrap_vprintf). Rename it printf if you prefer the lesson's shorthand, but __wrap_printf is what the ELF says.

stdio_init_all returns bool, not void — _Bool stdio_init_all(void) in the ELF.

Shortcut — resolves name and type for every function. Instead of doing N + Y by hand, paste this into Binary Ninja's Python console (Plugins -> Python Console). It sets each function's name and signature programmatically:

from binaryninja import Symbol, SymbolType
# The raw .bin has no headers, so these SDK types don't exist. set_user_type()
# re-parses each signature as C, so an undefined name raises
# "SyntaxError: unknown type name '...'". Define them first.
sdk = bv.parse_types_from_string("""
typedef unsigned int uint;
typedef char* va_list;
typedef unsigned long clock_handle_t;
typedef void (*out_fct_type)(char, void*, size_t, size_t);
struct stdio_driver;
typedef struct stdio_driver stdio_driver_t;
struct uart_inst;
typedef struct uart_inst uart_inst_t;
enum gpio_function {
    GPIO_FUNC_XIP = 0, GPIO_FUNC_SPI = 1, GPIO_FUNC_UART = 2, GPIO_FUNC_I2C = 3,
    GPIO_FUNC_PWM = 4, GPIO_FUNC_SIO = 5, GPIO_FUNC_PIO0 = 6, GPIO_FUNC_PIO1 = 7,
    GPIO_FUNC_GPCK = 8, GPIO_FUNC_USB = 9, GPIO_FUNC_NULL = 0x1f,
};
typedef enum gpio_function gpio_function_t;
""")
for name, ty in sdk.types.items():
    bv.define_user_type(name, ty)

# address: (name, signature)
funcs = {
    0x1000015c: ("_reset_handler",            "void _reset_handler(void)"),
    0x10000186: ("platform_entry",            "void platform_entry(void)"),
    0x1000019a: ("data_cpy",                  "void data_cpy(void*, void*, void*)"),
    0x100001e4: ("_init",                     "void _init(void)"),
    0x10000210: ("frame_dummy",               "void frame_dummy(void)"),
    0x10000234: ("main",                      "int main(void)"),
    0x10002ca8: ("vfctprintf",                "int vfctprintf(void (*)(char, void*), void*, const char*, va_list)"),
    0x10002d04: ("exit",                      "void exit(int)"),
    0x10002d0c: ("runtime_init",              "void runtime_init(void)"),
    0x10002d38: ("stdio_out_chars_crlf",      "void stdio_out_chars_crlf(stdio_driver_t*, const char*, int)"),
    0x10002e48: ("stdio_put_string",          "int stdio_put_string(const char*, int, bool, bool)"),
    0x10002f34: ("stdio_set_driver_enabled",  "void stdio_set_driver_enabled(stdio_driver_t*, bool)"),
    0x10002f5c: ("stdio_init_all",            "bool stdio_init_all(void)"),
    0x10003028: ("__wrap_vprintf",            "int __wrap_vprintf(const char*, va_list)"),
    0x100030ec: ("__wrap_printf",             "int __wrap_printf(const char*, ...)"),
    0x100032a8: ("stdio_uart_init",           "void stdio_uart_init(void)"),
    0x100033e8: ("strlen",                    "size_t strlen(const char*)"),
    0x10000254: ("gpio_set_function",         "void gpio_set_function(uint, gpio_function_t)"),
    0x10000e18: ("uart_init",                 "uint uart_init(uart_inst_t*, uint)"),
    0x10000da8: ("time_us_64",                "uint64_t time_us_64(void)"),
    0x10001384: ("__wrap___aeabi_dadd",       "double __wrap___aeabi_dadd(double, double)"),
    0x100013ac: ("__wrap___aeabi_dsub",       "double __wrap___aeabi_dsub(double, double)"),
    0x100013d4: ("__wrap___aeabi_dmul",       "double __wrap___aeabi_dmul(double, double)"),
    0x10001420: ("__wrap___aeabi_ddiv",       "double __wrap___aeabi_ddiv(double, double)"),
    0x100014bc: ("__wrap___aeabi_i2d",        "double __wrap___aeabi_i2d(int)"),
    0x100014e0: ("__wrap___aeabi_ui2d",       "double __wrap___aeabi_ui2d(unsigned)"),
    0x10001504: ("__wrap___aeabi_d2iz",       "int __wrap___aeabi_d2iz(double)"),
    0x10001528: ("__wrap___aeabi_d2uiz",      "unsigned __wrap___aeabi_d2uiz(double)"),
    0x1000154c: ("__wrap___aeabi_dcmpun",     "int __wrap___aeabi_dcmpun(double, double)"),
    0x10001570: ("__wrap___aeabi_dcmplt",     "int __wrap___aeabi_dcmplt(double, double)"),
    0x10001598: ("__wrap___aeabi_dcmple",     "int __wrap___aeabi_dcmple(double, double)"),
    0x100015c0: ("__wrap___aeabi_dcmpge",     "int __wrap___aeabi_dcmpge(double, double)"),
    0x100015e8: ("__wrap___aeabi_dcmpgt",     "int __wrap___aeabi_dcmpgt(double, double)"),
    0x1000160c: ("_out_rev",                  "unsigned _out_rev(out_fct_type, char*, size_t, size_t, const char*, size_t, unsigned, unsigned)"),
    0x100016a8: ("_ntoa_format",              "unsigned _ntoa_format(out_fct_type, char*, size_t, size_t, char*, size_t, bool, unsigned, unsigned, unsigned, unsigned)"),
    0x1000187c: ("_out_char",                 "void _out_char(char, void*, size_t, size_t)"),
    0x10001890: ("_ftoa",                     "unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)"),
    0x10001d50: ("_etoa",                     "unsigned int _etoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)"),
    0x100022c4: ("_vsnprintf",                "int _vsnprintf(out_fct_type, char*, size_t, const char*, va_list)"),
    0x10000dbc: ("busy_wait_us",              "void busy_wait_us(uint64_t)"),
    0x10000fec: ("clock_get_hz",              "unsigned long clock_get_hz(clock_handle_t)"),
}
for addr, (name, sig) in funcs.items():
    bv.define_user_symbol(Symbol(SymbolType.FunctionSymbol, addr, name))
    f = bv.get_function_at(addr)
    if f is not None:
        f.set_user_type(sig)

SDK type names (stdio_driver_t, gpio_function_t, uart_inst_t, plus uint, va_list, out_fct_type, and clock_handle_t) are not in the raw .bin. set_user_type re-parses each signature as C, so an undefined name raises SyntaxError: unknown type name '...' and stops the loop — it is not harmless. The sdk block above defines them first (an opaque struct/enum/typedef is enough to parse). If you add a function that uses another SDK type, add a definition for it to that block too.

Step 17: Read main in the decompiler

Open the Decompiler view on main. It reads:

int32_t main(void)
{
    stdio_init_all();
    do
    {
        __wrap_printf("fav_num: %f\r\n", 0, 0x40454000);
    } while (true);
}

The trailing pair is the promoted double: r2 = 0, r3 = 0x40454000. Binary Ninja already knows the calling convention, so once __wrap_printf is typed int __wrap_printf(const char*, ...), the pair is shown as data. Now make the hack permanent.

Step 18: Patch 0x40454000 to 0x4058C000 in the GUI

Go to 0x1000024c:

1000024c: 40454000   .word 0x40454000

That word is the high half of the double 42.5. Its bytes, little-endian, are 00 40 45 40. Change them to 00 c0 58 40, the high half of 99.0:

Project Address Before After Effect
0x000e 0x1000024c 00 40 45 40 00 c0 58 40 high word of 42.5 -> 99.0

Option A — Hex view:

  1. Switch to the Hex view (View -> Hex).
  2. Toggle the lock off so editing is enabled.
  3. Go to 0x1000024c and change 00 40 45 40 to 00 c0 58 40.
  4. Return to the linear view, right-click the function -> Reanalyze.

Option B — Python console:

bv.write(0x1000024c, bytes.fromhex("00c05840"))
print(bv.read(0x1000024c, 4))  # -> b'\x00\xc0X@'

After reanalysis the literal reads 0x4058C000, and the decompiler shows __wrap_printf("fav_num: %f\r\n", 0, 0x4058c000).

Step 18b: Patch the string fav_num: to myvalue: in the GUI

The format string "fav_num: %f\r\n" starts at 0x100034a8. Its first eight bytes are 66 61 76 5f 6e 75 6d 3a (fav_num:). Change them to 6d 79 76 61 6c 75 65 3a (myvalue:), leaving the %f\r\n tail untouched, so the line prints myvalue: 99.000000.

Project Address Before After Effect
0x000e 0x100034a8 66 61 76 5f 6e 75 6d 3a 6d 79 76 61 6c 75 65 3a fav_num: -> myvalue:

Option A — Hex view:

  1. Switch to the Hex view (View -> Hex).
  2. Go to 0x100034a8 and change the eight bytes 66 61 76 5f 6e 75 6d 3a to 6d 79 76 61 6c 75 65 3a.
  3. Return to the linear view and reanalyze.

Option B — Python console:

bv.write(0x100034a8, b"myvalue:")
print(bv.read(0x100034a8, 15))  # -> b'myvalue: %f\r\n\x00'

Keep the replacement exactly eight bytes. If you use a shorter string you must pad it, or %f shifts and printf reads the wrong argument. A longer string would overwrite the %f tail.

Step 19: Export the patched .bin

import os
seg = next(s for s in bv.segments if s.data_length)  # the loadable image segment
data = bv.read(seg.start, seg.data_length)  # base + size come from the view itself
out = os.path.join(os.path.join(root, "0x000e_floating-point-data-type", "build"), "0x000e_floating-point-data-type-h.bin")
open(out, "wb").write(data)
print(len(data), out)  # -> 15308 /.../build/0x000e_floating-point-data-type-h.bin

Where the two numbers come from — nothing is hardcoded:

  • seg.start is the image base Binary Ninja loaded the .bin at (0x10000000), the same value you pass to uf2conv --base.
  • seg.data_length is the segment's size in the file (0x3bcc = 15308). Exactly one segment carries data (the image); every peripheral and synthetic segment has data_length == 0, so next(...) picks the image.
  • Reading seg.start for seg.data_length bytes therefore grabs exactly the image.

Two gotchas this avoids:

  • No relative path. Binary Ninja's Python console runs with a read-only working directory (inside the app bundle), so open("0x000e_floating-point-data-type-h.bin", "wb") fails with OSError: [Errno 30] Read-only file system. root (from ~/.embedded-hacking-repo, Step 3) is the repo, so the file is written into the project's build/ — no machine-specific path and no database needed.
  • Read the image, not the whole view. bv.read(bv.start, bv.length) spans the entire mapped range, which is not the image. The segment's data_length is the image size.

A different size means you exported a partial view.

Step 20: Convert to UF2

Run from the project directory:

macOS Apple Silicon / Linux x64:

python3 ../uf2conv.py 0x000e_floating-point-data-type-h.bin \
  --base 0x10000000 --family 0xe48bff59 --output hacked.uf2

Windows x64:

python ..\uf2conv.py 0x000e_floating-point-data-type-h.bin ^
  --base 0x10000000 --family 0xe48bff59 --output hacked.uf2

Or convert from the Binary Ninja console — it is a normal Python interpreter, so you never have to leave the app. chdir to a writable directory first (the default one is read-only), then run the script:

import os, sys, runpy
os.chdir(os.path.join(root, "0x000e_floating-point-data-type", "build"))   # the project build dir (writable)
sys.argv = ["uf2conv.py", "0x000e_floating-point-data-type-h.bin",
            "--base", "0x10000000", "--family", "0xe48bff59", "--output", "hacked.uf2"]
runpy.run_path("../../uf2conv.py", run_name="__main__")   # path to your uf2conv.py

This writes hacked.uf2 next to the .bin, ready to drag onto the Pico.

Step 21: Flash and verify fav_num: 99.000000

Hold BOOTSEL, plug in the Pico 2, and drag hacked.uf2 onto the RP2350 drive. Open the serial monitor:

myvalue: 99.000000
myvalue: 99.000000
myvalue: 99.000000
...

42.5 became 99.0, permanently, with one 32-bit word changed and no source code.

Faster: flash over the Debug Probe (no BOOTSEL). The repo's flash.sh writes the raw .bin straight into XIP flash over SWD (program <bin> 0x10000000 verify reset exit), so you never touch BOOTSEL or a UF2. Run it from a terminal (./flash.sh <bin>), or from the Binary Ninja console without freezing it — use subprocess.Popen, which returns immediately, and send OpenOCD's output to a log file. (subprocess.run blocks the console until the flash finishes; do not use it here.)

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()
bin_path = os.path.join(os.path.join(root, "0x000e_floating-point-data-type", "build"), "0x000e_floating-point-data-type-h.bin")
log = os.path.join(os.path.join(root, "0x000e_floating-point-data-type", "build"), "flash.log")
subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # free the probe first
p = subprocess.Popen([os.path.join(root, "flash.sh"), bin_path],
                     stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
print("flashing in the background; log:", log)

The pkill frees the probe first; on Windows use subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"]).

The console is free the moment this returns. Check it with print(p.poll()) (None = still running, 0 = done) or read flash.log — success ends with ** Verified OK **.

The same non-blocking form without the script:

import os, subprocess
ocd = os.path.expanduser("~/.pico-sdk/openocd/0.12.0+dev")
bin_path = os.path.join(os.path.join(root, "0x000e_floating-point-data-type", "build"), "0x000e_floating-point-data-type-h.bin")
log = os.path.join(os.path.join(root, "0x000e_floating-point-data-type", "build"), "flash.log")
subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # free the probe first
p = subprocess.Popen([f"{ocd}/openocd", "-s", f"{ocd}/scripts",
    "-f", "interface/cmsis-dap.cfg", "-f", "target/rp2350.cfg",
    "-c", "adapter speed 5000",
    "-c", f"program {bin_path} 0x10000000 verify reset exit"],
    stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
print("flashing in the background; log:", log)

The Debug Probe is single-owner. If Binary Ninja is still attached (the debug-server.sh OpenOCD is running), the flash cannot grab the probe. Detach in Binary Ninja and stop that OpenOCD first:

# macOS / Linux
pkill -TERM -f openocd
# Windows
Get-Process openocd -ErrorAction SilentlyContinue | Stop-Process

Success looks like Programming Finished -> Verified OK -> Resetting Target. On Windows use flash.ps1 (.\flash.ps1 -Bin <path>) the same way.


Part 5: Dynamic — Break at main and Hack Live (Project 2)

Step 22: Reflash Project 2 and reload Binary Ninja

Part 4 left the Pico running the patched Project 1 image. Put the original Project 2 back and start a fresh session.

  1. Stop any running debug server so the flash script can use the probe:

    # macOS / Linux
    pkill -TERM -f openocd
    
    # Windows
    Get-Process openocd -ErrorAction SilentlyContinue | Stop-Process
    
  2. Flash the original Project 2 image:

    # macOS / Linux
    ./flash.sh 0x0011_double-floating-point-data-type/build/0x0011_double-floating-point-data-type.bin
    
    # Windows
    .\flash.ps1 -Bin 0x0011_double-floating-point-data-type\build\0x0011_double-floating-point-data-type.bin
    

    Or do steps 1-2 from the Binary Ninja console (the active view is still Project 1, so take the repo root from the marker file and point at the Project 2 .bin):

    macOS / Linux:

    import os, subprocess
    root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
    bin_path = os.path.join(root, "0x0011_double-floating-point-data-type", "build", "0x0011_double-floating-point-data-type.bin")
    log = os.path.join(os.path.dirname(bin_path), "flash.log")
    subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # free the probe first
    subprocess.Popen([os.path.join(root, "flash.sh"), bin_path],
                     stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
    print("flashing Project 2 in the background; log:", log)
    

    Windows:

    import os, subprocess
    root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()  # set once (Step 3)
    bin_path = os.path.join(root, "0x0011_double-floating-point-data-type", "build", "0x0011_double-floating-point-data-type.bin")
    log = os.path.join(os.path.dirname(bin_path), "flash.log")
    subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"])  # free the probe first
    subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File",
                      os.path.join(root, "flash.ps1"), "-Bin", bin_path],
                     stdout=open(log, "w"), stderr=subprocess.STDOUT)
    print("flashing Project 2 in the background; log:", log)
    
  3. Start the debug server again (Step 10) and wait for Listening on port 3333.

  4. Load Project 2 and save its database — see Step 22b.

  5. Connect Binary Ninja again (Step 11): adapter GDB MI, IP 127.0.0.1, port 3333.

Confirm the Pico prints fav_num: 42.525250.

Step 22b: Load Project 2 into Binary Ninja and save the database

Exactly like Steps 7-8, but for Project 2. Use File -> Open with Options... (not plain File -> Open), select 0x0011_double-floating-point-data-type/build/0x0011_double-floating-point-data-type.bin, and set:

  • Architecture: thumb2
  • Platform: thumb2
  • Base Address: 0x10000000

Click Open. Then press G, type 0x10000000, and confirm the first two words:

0x10000000   0x20082000   initial stack pointer
0x10000004   0x1000015d   reset vector (bit 0 = Thumb)

If you see data at 0x00000000, close the tab and redo it with Open with Options.

Save it with File -> Save As... as 0x0011_double-floating-point-data-type.bndb (next to the .bin). From now on open the .bndb, not the .bin; save with Cmd+S / Ctrl+S after every rename or patch.

Console equivalent:

load("0x0011_double-floating-point-data-type/build/0x0011_double-floating-point-data-type.bin",
     options={"loader.imageBase": 0x10000000, "loader.platform": "thumb2"})

Then resolve the functions for Project 2 the same way as Project 1 — Step 26.

Step 23: Break at main

main is at 0x10000238 in this project. The GUI sets breakpoints fine (Step 13); the only caution is not to drive reset run from the port while Binary Ninja is attached (it desyncs Binary Ninja's view). Use BP_ADDR, which arms main before Binary Ninja connects:

  1. Stop the server (Ctrl-C), then start it parked at main:

    # macOS / Linux
    BP_ADDR=0x10000238 ./debug-server.sh
    
    # Windows
    $env:BP_ADDR="0x10000238"; .\debug-server.ps1
    

    Or restart it from the Binary Ninja console — kill any running server, then start it parked at main:

    macOS / Linux:

    import os, subprocess
    root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()
    log = os.path.join(root, "openocd.log")
    subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # kill any running server first
    subprocess.Popen([os.path.join(root, "debug-server.sh")], cwd=root,
                     env=dict(os.environ, BP_ADDR="0x10000238"),
                     stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
    print("OpenOCD restarted parked at main; log:", log)
    

    Windows:

    import os, subprocess
    root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()
    log = os.path.join(root, "openocd.log")
    subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"])  # kill any running server first
    subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File",
                      os.path.join(root, "debug-server.ps1")], cwd=root,
                     env=dict(os.environ, BP_ADDR="0x10000238"),
                     stdout=open(log, "w"), stderr=subprocess.STDOUT)
    print("OpenOCD restarted parked at main; log:", log)
    
  2. Connect Binary Ninja (Step 11): adapter GDB MI, IP 127.0.0.1, port 3333.

The target is already halted at main when Binary Ninja connects, and the sidebar reads Stopped at 0x10000238.

If you instead want to reach main on an already-connected session, you must Detach, send reset run from the port, then reconnect. Arming main and resetting while attached leaves the sidebar showing a stale address.

main loads the whole 64-bit double from a single literal-pool address, then loops: copy the pair into r2:r3 and call printf. The whole thing is one function:

10000238: b538         push  {r3, r4, r5, lr}
1000023a: a506         add   r5, pc, #24      ; adr r5, 0x10000254
1000023c: e9d5 4500    ldrd  r4, r5, [r5]     ; r4 = low word, r5 = high word
10000240: f002 fe90    bl    0x10002f64      ; stdio_init_all
10000244: 4622         mov   r2, r4
10000246: 462b         mov   r3, r5
10000248: 4801         ldr   r0, [pc, #4]     ; -> 0x10000250 = 0x100034b0 (format string)
1000024a: f002 ff53    bl    0x100030f4      ; __wrap_printf
1000024e: e7f9         b.n   0x10000244
10000250: 100034b0     .word 0x100034b0
10000254: 645a1cac     .word 0x645a1cac
10000258: 4045433b     .word 0x4045433b

Notice the ldrd r4, r5, [r5] — a single 64-bit load from the literal pool at 0x10000254, which fills both halves of the double at once. That is the structural difference from Project 1, where the low half was a register zero (movs r4, #0) and only the high half lived in the pool.

Look at the Registers widget at 0x1000024a: r2 = 0x645A1CAC (low) and r3 = 0x4045433B (high). Together that is 0x4045433B_645A1CAC, the IEEE-754 encoding of 42.52525.

Step 24: Inspect the double argument live

The double crosses the ABI in two registers. At the printf call the pair is exactly the literal-pool word pair:

Register Value Role
r2 0x645A1CAC low 32 bits of 42.52525
r3 0x4045433B high 32 bits of 42.52525

Step Over through 0x10000244 (mov r2, r4) and 0x10000246 (mov r3, r5) and watch r2/r3 populate from r4/r5. This is C's variadic rule in action: printf's %lf receives a 64-bit double, and on this target a double argument travels in r2:r3.

Why r2:r3, not r0:r1? The first variadic argument goes after the named format pointer, so printf(fmt, value) puts fmt in r0 and the promoted double in r2:r3. That leaves r1 unused here, which is why the format string pointer is r0 and the number is r2:r3.

Step 25: HACK IT LIVE — change the printed double

  1. Press G, go to 0x1000024a (the bl __wrap_printf).

  2. Set a hardware execute breakpoint at 0x1000024a in the GUI (Debugger -> Add Hardware Breakpoint...; not F2). Note 0x1000024a — Project 2's loop sits at a different address than Project 1's.

  3. Click Resume in Binary Ninja. The target is already looping, so the breakpoint fires on the next pass. Binary Ninja stops with r2 = 0x645A1CAC, r3 = 0x4045433B.

  4. Overwrite both halves with the encoding of 99.99 (0x4058FF5C_28F5C28F):

    dbg.set_reg_value("r2", 0x28F5C28F)  # low word of the 99.99 double
    dbg.set_reg_value("r3", 0x4058FF5C)  # high word of the 99.99 double
    

    (Or right-click each register in the Registers widget, press E, type the hex value, and press Enter.)

  5. Move the breakpoint past the call. 0x1000024e is the instruction right after the bl __wrap_printf. Remove the breakpoint at 0x1000024a and set a hardware breakpoint at 0x1000024e, then click Resume. The core runs printf with r2:r3 = 0x4058FF5C_28F5C28F and stops at 0x1000024e. (Not Step Over — it steps into the call on this symbol-less .bin, and a breakpoint left on the current PC re-traps the step; Step 13 explains both.)

  6. Look at your serial monitor and the Target tab:

    fav_num: 99.990000
    

Press Resume and the next iteration prints fav_num: 42.525250 again, because the loop reloads r2/r3 from r4/r5 each pass. The live hack is temporary; the static patch makes it permanent.

Step 25b: HACK THE STRING LIVE — change fav_num: to myvalue:

Same idea as Project 1, different addresses. Here the format string is at 0x100034b0 and the printf call is at 0x1000024a.

  1. Hit the breakpoint at 0x1000024a as in Step 25. At the stop, r0 = 0x100034b0, r2 = 0x645A1CAC, r3 = 0x4045433B.
  2. Write the replacement string to free RAM at 0x20080000 from the Python console (dbg.write_memory — no command port needed):
    dbg.write_memory(0x20080000, b"myvalue: %lf\r\n\x00")
    
    Bytes 6d 79 76 61 6c 75 65 3a 20 25 6c 66 0d 0a 00 = "myvalue: %lf\r\n\0".
  3. Point r0 at that string:
    dbg.set_reg_value("r0", 0x20080000)
    
    (Or right-click r0 in the Registers widget, press E, type 0x20080000, and press Enter.)
  4. If you want the value hack too, set r2 = 0x28F5C28F and r3 = 0x4058FF5C as in Step 25. Then move the breakpoint past the call in the GUI (remove it at 0x1000024a, set one at 0x1000024e) and click Resume. This iteration prints:
    myvalue: 99.990000
    
    then stops at 0x1000024e. One iteration only — the loop reloads r0 (and r2/r3) each pass. The permanent version is the static patch in Step 28b.

Step 25c: Kill the debugger and OpenOCD

Same as Step 15b: click the X (Kill) in the Debugger sidebar (or Debugger -> Kill), then stop OpenOCD from the Binary Ninja console:

macOS / Linux:

import subprocess
subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # stop the debug server, free the probe

Windows:

import subprocess
subprocess.run(["taskkill", "/F", "/IM", "openocd.exe"])  # stop the debug server, free the probe

Part 6: Static — Resolve the Functions and Patch (Project 2)

Step 26: Resolve the functions in the Binary Ninja GUI

Same two keys as Step 16 — G to the address, then Y (Change Type) to set the prototype — using the Project 2 ELF symbol map from Step 4.

The mechanics are identical to Step 16, so here are the worked examples for the functions that are specific to this project.

main

  1. G -> 0x10000238.
  2. Y -> int main(void) (Binary Ninja shows int32_t main(void) — the same 32-bit int).

stdio_uart_init

  1. G -> 0x100032b0.
  2. Y -> void stdio_uart_init(void).

_ftoa and _etoa

Same formatters as Project 1, eight bytes higher: _ftoa at 0x10001898 (unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)) and _etoa at 0x10001d58 (same prototype).

stdio_init_all and __wrap_printf

Same as Project 1, different addresses: stdio_init_all at 0x10002f64 (bool stdio_init_all(void)), and __wrap_printf at 0x100030f4 (int __wrap_printf(const char *fmt, ...)).

Then work down the table the same way.

Same idea as Project 1: our code plus what it calls, not the whole SDK. The call chain here is identical to Project 1 — because %lf and %f both route into the same pico_double formatter:

main
├── stdio_init_all
│   └── stdio_uart_init ── gpio_set_function, uart_init, stdio_set_driver_enabled
│                              └── uart_init ── clock_get_hz, busy_wait_us
└── __wrap_printf ── __wrap_vprintf
    ├── vfctprintf ── _vsnprintf
    │   ├── _ftoa / _etoa ── __wrap___aeabi_* (the pico_double helpers)
    │   └── _ntoa_format / _out_rev
    ├── stdio_out_chars_crlf
    └── time_us_64

One thing in this project has no separate call, because the compiler emitted it as a single instruction: the 64-bit literal load is the ldrd r4, r5, [r5] you see inside main. There is no helper function to rename for it — it is two .words in the pool at 0x10000254.

Project 2 — resolve every function in that chain:

Address Rename to (N) Signature (Y)
0x1000015c _reset_handler void _reset_handler(void)
0x10000186 platform_entry void platform_entry(void)
0x1000019a data_cpy void data_cpy(void*, void*, void*)
0x100001e4 _init void _init(void)
0x10000210 frame_dummy void frame_dummy(void)
0x10000238 main int main(void)
0x10002cb0 vfctprintf int vfctprintf(void (*)(char, void*), void*, const char*, va_list)
0x10002d0c exit void exit(int)
0x10002d14 runtime_init void runtime_init(void)
0x10002d40 stdio_out_chars_crlf void stdio_out_chars_crlf(stdio_driver_t*, const char*, int)
0x10002e50 stdio_put_string int stdio_put_string(const char*, int, bool, bool)
0x10002f3c stdio_set_driver_enabled void stdio_set_driver_enabled(stdio_driver_t*, bool)
0x10002f64 stdio_init_all bool stdio_init_all(void)
0x10003030 __wrap_vprintf int __wrap_vprintf(const char*, va_list)
0x100030f4 __wrap_printf int __wrap_printf(const char*, ...)
0x100032b0 stdio_uart_init void stdio_uart_init(void)
0x100033f0 strlen size_t strlen(const char*)
0x1000025c gpio_set_function void gpio_set_function(uint, gpio_function_t)
0x10000e20 uart_init uint uart_init(uart_inst_t*, uint)
0x10000db0 time_us_64 uint64_t time_us_64(void)

Project 2 — resolve the pico_double formatting helpers printf reaches:

Address Rename to (N) Signature (Y)
0x1000138c __wrap___aeabi_dadd double __wrap___aeabi_dadd(double, double)
0x100013b4 __wrap___aeabi_dsub double __wrap___aeabi_dsub(double, double)
0x100013dc __wrap___aeabi_dmul double __wrap___aeabi_dmul(double, double)
0x10001428 __wrap___aeabi_ddiv double __wrap___aeabi_ddiv(double, double)
0x100014c4 __wrap___aeabi_i2d double __wrap___aeabi_i2d(int)
0x100014e8 __wrap___aeabi_ui2d double __wrap___aeabi_ui2d(unsigned)
0x1000150c __wrap___aeabi_d2iz int __wrap___aeabi_d2iz(double)
0x10001530 __wrap___aeabi_d2uiz unsigned __wrap___aeabi_d2uiz(double)
0x10001554 __wrap___aeabi_dcmpun int __wrap___aeabi_dcmpun(double, double)
0x10001578 __wrap___aeabi_dcmplt int __wrap___aeabi_dcmplt(double, double)
0x100015a0 __wrap___aeabi_dcmple int __wrap___aeabi_dcmple(double, double)
0x100015c8 __wrap___aeabi_dcmpge int __wrap___aeabi_dcmpge(double, double)
0x100015f0 __wrap___aeabi_dcmpgt int __wrap___aeabi_dcmpgt(double, double)
0x10001614 _out_rev unsigned _out_rev(out_fct_type, char*, size_t, size_t, const char*, size_t, unsigned, unsigned)
0x100016b0 _ntoa_format unsigned _ntoa_format(out_fct_type, char*, size_t, size_t, char*, size_t, bool, unsigned, unsigned, unsigned, unsigned)
0x10001884 _out_char void _out_char(char, void*, size_t, size_t)
0x10001898 _ftoa unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)
0x10001d58 _etoa unsigned int _etoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)
0x100022cc _vsnprintf int _vsnprintf(out_fct_type, char*, size_t, const char*, va_list)
0x10000dc4 busy_wait_us void busy_wait_us(uint64_t)
0x10000ff4 clock_get_hz unsigned long clock_get_hz(clock_handle_t)

Python console shortcut (resolves name and type):

from binaryninja import Symbol, SymbolType
# The raw .bin has no headers, so these SDK types don't exist. set_user_type()
# re-parses each signature as C, so an undefined name raises
# "SyntaxError: unknown type name '...'". Define them first.
sdk = bv.parse_types_from_string("""
typedef unsigned int uint;
typedef char* va_list;
typedef unsigned long clock_handle_t;
typedef void (*out_fct_type)(char, void*, size_t, size_t);
struct stdio_driver;
typedef struct stdio_driver stdio_driver_t;
struct uart_inst;
typedef struct uart_inst uart_inst_t;
enum gpio_function {
    GPIO_FUNC_XIP = 0, GPIO_FUNC_SPI = 1, GPIO_FUNC_UART = 2, GPIO_FUNC_I2C = 3,
    GPIO_FUNC_PWM = 4, GPIO_FUNC_SIO = 5, GPIO_FUNC_PIO0 = 6, GPIO_FUNC_PIO1 = 7,
    GPIO_FUNC_GPCK = 8, GPIO_FUNC_USB = 9, GPIO_FUNC_NULL = 0x1f,
};
typedef enum gpio_function gpio_function_t;
""")
for name, ty in sdk.types.items():
    bv.define_user_type(name, ty)

# address: (name, signature)
funcs = {
    0x1000015c: ("_reset_handler",            "void _reset_handler(void)"),
    0x10000186: ("platform_entry",            "void platform_entry(void)"),
    0x1000019a: ("data_cpy",                  "void data_cpy(void*, void*, void*)"),
    0x100001e4: ("_init",                     "void _init(void)"),
    0x10000210: ("frame_dummy",               "void frame_dummy(void)"),
    0x10000238: ("main",                      "int main(void)"),
    0x10002cb0: ("vfctprintf",                "int vfctprintf(void (*)(char, void*), void*, const char*, va_list)"),
    0x10002d0c: ("exit",                      "void exit(int)"),
    0x10002d14: ("runtime_init",              "void runtime_init(void)"),
    0x10002d40: ("stdio_out_chars_crlf",      "void stdio_out_chars_crlf(stdio_driver_t*, const char*, int)"),
    0x10002e50: ("stdio_put_string",          "int stdio_put_string(const char*, int, bool, bool)"),
    0x10002f3c: ("stdio_set_driver_enabled",  "void stdio_set_driver_enabled(stdio_driver_t*, bool)"),
    0x10002f64: ("stdio_init_all",            "bool stdio_init_all(void)"),
    0x10003030: ("__wrap_vprintf",            "int __wrap_vprintf(const char*, va_list)"),
    0x100030f4: ("__wrap_printf",             "int __wrap_printf(const char*, ...)"),
    0x100032b0: ("stdio_uart_init",           "void stdio_uart_init(void)"),
    0x100033f0: ("strlen",                    "size_t strlen(const char*)"),
    0x1000025c: ("gpio_set_function",         "void gpio_set_function(uint, gpio_function_t)"),
    0x10000e20: ("uart_init",                 "uint uart_init(uart_inst_t*, uint)"),
    0x10000db0: ("time_us_64",                "uint64_t time_us_64(void)"),
    0x1000138c: ("__wrap___aeabi_dadd",       "double __wrap___aeabi_dadd(double, double)"),
    0x100013b4: ("__wrap___aeabi_dsub",       "double __wrap___aeabi_dsub(double, double)"),
    0x100013dc: ("__wrap___aeabi_dmul",       "double __wrap___aeabi_dmul(double, double)"),
    0x10001428: ("__wrap___aeabi_ddiv",       "double __wrap___aeabi_ddiv(double, double)"),
    0x100014c4: ("__wrap___aeabi_i2d",        "double __wrap___aeabi_i2d(int)"),
    0x100014e8: ("__wrap___aeabi_ui2d",       "double __wrap___aeabi_ui2d(unsigned)"),
    0x1000150c: ("__wrap___aeabi_d2iz",       "int __wrap___aeabi_d2iz(double)"),
    0x10001530: ("__wrap___aeabi_d2uiz",      "unsigned __wrap___aeabi_d2uiz(double)"),
    0x10001554: ("__wrap___aeabi_dcmpun",     "int __wrap___aeabi_dcmpun(double, double)"),
    0x10001578: ("__wrap___aeabi_dcmplt",     "int __wrap___aeabi_dcmplt(double, double)"),
    0x100015a0: ("__wrap___aeabi_dcmple",     "int __wrap___aeabi_dcmple(double, double)"),
    0x100015c8: ("__wrap___aeabi_dcmpge",     "int __wrap___aeabi_dcmpge(double, double)"),
    0x100015f0: ("__wrap___aeabi_dcmpgt",     "int __wrap___aeabi_dcmpgt(double, double)"),
    0x10001614: ("_out_rev",                  "unsigned _out_rev(out_fct_type, char*, size_t, size_t, const char*, size_t, unsigned, unsigned)"),
    0x100016b0: ("_ntoa_format",              "unsigned _ntoa_format(out_fct_type, char*, size_t, size_t, char*, size_t, bool, unsigned, unsigned, unsigned, unsigned)"),
    0x10001884: ("_out_char",                 "void _out_char(char, void*, size_t, size_t)"),
    0x10001898: ("_ftoa",                     "unsigned int _ftoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)"),
    0x10001d58: ("_etoa",                     "unsigned int _etoa(out_fct_type, char*, size_t, size_t, double, unsigned int, unsigned int, unsigned int)"),
    0x100022cc: ("_vsnprintf",                "int _vsnprintf(out_fct_type, char*, size_t, const char*, va_list)"),
    0x10000dc4: ("busy_wait_us",              "void busy_wait_us(uint64_t)"),
    0x10000ff4: ("clock_get_hz",              "unsigned long clock_get_hz(clock_handle_t)"),
}
for addr, (name, sig) in funcs.items():
    bv.define_user_symbol(Symbol(SymbolType.FunctionSymbol, addr, name))
    f = bv.get_function_at(addr)
    if f is not None:
        f.set_user_type(sig)

The decompiler now shows main loading the double pair and looping. We make two changes:

  • Change the printed value from 42.52525 to 99.99 by patching both literal words.
  • Rename the label fav_num: to myvalue: by patching the format string.

Step 27: Patch 1 — the low word 0x645A1CAC to 0x28F5C28F

99.99 is 0x4058FF5C_28F5C28F, so the low word changes from 0x645A1CAC to 0x28F5C28F. At 0x10000254 the stored bytes are ac 1c 5a 64; change them to 8f c2 f5 28:

Project Address Before After Effect
0x0011 0x10000254 ac 1c 5a 64 8f c2 f5 28 low word of 42.52525 -> 99.99

Hex view: lock off, go to 0x10000254, change ac 1c 5a 64 to 8f c2 f5 28, reanalyze. Or the Python console:

bv.write(0x10000254, bytes.fromhex("8fc2f528"))
print(bv.read(0x10000254, 4))  # -> b'\x8f\xc2\xf5('

Step 28: Patch 2 — the high word 0x4045433B to 0x4058FF5C

At 0x10000258 the stored bytes are 3b 43 45 40; change them to 5c ff 58 40:

Project Address Before After Effect
0x0011 0x10000258 3b 43 45 40 5c ff 58 40 high word of 42.52525 -> 99.99

Hex view: go to 0x10000258, change 3b 43 45 40 to 5c ff 58 40, reanalyze. Or the Python console:

bv.write(0x10000258, bytes.fromhex("5cff5840"))
for addr in (0x10000254, 0x10000258):
    print(hex(addr), bv.read(addr, 4).hex())
# -> 0x10000254 8fc2f528
# -> 0x10000258 5cff5840

Both words are required. 42.52525 has a repeating binary fraction, so its low word is non-zero (0x645A1CAC). Patching only the high word leaves the low 20 mantissa bits from 0.52525, and printf prints a wrong hybrid. Compare Project 1, where 42.5 is exact and the low word was already 0x00000000, so one word sufficed.

Step 28b: Patch the string fav_num: to myvalue:

The format string starts at 0x100034b0; change its first eight bytes 66 61 76 5f 6e 75 6d 3a (fav_num:) to 6d 79 76 61 6c 75 65 3a (myvalue:):

Project Address Before After Effect
0x0011 0x100034b0 66 61 76 5f 6e 75 6d 3a 6d 79 76 61 6c 75 65 3a fav_num: -> myvalue:
bv.write(0x100034b0, b"myvalue:")
print(bv.read(0x100034b0, 15))  # -> b'myvalue: %lf\r\n\x00'

Exactly eight bytes, same rule as Project 1: a shorter string must be padded, a longer one overwrites the %lf tail.

Step 29: Export, convert, and flash

import os
seg = next(s for s in bv.segments if s.data_length)  # the loadable image segment
data = bv.read(seg.start, seg.data_length)  # base + size from the view itself
out = os.path.join(os.path.join(root, "0x0011_double-floating-point-data-type", "build"), "0x0011_double-floating-point-data-type-h.bin")
open(out, "wb").write(data)
print(len(data), out)  # -> 15324 /.../build/0x0011_double-floating-point-data-type-h.bin

Same as Project 1: seg.start is the load base and seg.data_length is the image size (here 0x3bdc = 15324) — both read from the view, and no relative path (the console's CWD is read-only).

macOS Apple Silicon / Linux x64:

python3 ../uf2conv.py 0x0011_double-floating-point-data-type-h.bin \
  --base 0x10000000 --family 0xe48bff59 --output hacked.uf2

Windows x64:

python ..\uf2conv.py 0x0011_double-floating-point-data-type-h.bin ^
  --base 0x10000000 --family 0xe48bff59 --output hacked.uf2

Or run the conversion from the Binary Ninja console, exactly as in Step 20 (os.chdir to the build dir, then runpy.run_path("../../uf2conv.py", run_name="__main__") with sys.argv set to the arguments above).

Hold BOOTSEL, plug in the Pico 2, drag hacked.uf2 onto the RP2350 drive. Or flash the .bin over the Debug Probe with SWD — no BOOTSEL — from the console, exactly as in Step 21 (stop any running OpenOCD first, and use Popen, not run, so the console is not blocked):

import os, subprocess
root = open(os.path.expanduser("~/.embedded-hacking-repo")).read().strip()
bin_path = os.path.join(os.path.join(root, "0x0011_double-floating-point-data-type", "build"), "0x0011_double-floating-point-data-type-h.bin")
log = os.path.join(os.path.join(root, "0x0011_double-floating-point-data-type", "build"), "flash.log")
subprocess.run(["pkill", "-TERM", "-f", "openocd"])  # free the probe first
p = subprocess.Popen([os.path.join(root, "flash.sh"), bin_path],
                     stdout=open(log, "w"), stderr=subprocess.STDOUT, start_new_session=True)
print("flashing in the background; log:", log)

Step 30: Verify

Open the serial monitor:

myvalue: 99.990000
myvalue: 99.990000
myvalue: 99.990000
...

We changed the printed value and relabeled the line, with ten bytes and no source code. 42.52525 became 99.99 because both halves of the double moved together.

Cheatsheet

Binary Ninja GUI actions

Action How
Go to address G
Rename function/symbol N
Set type or signature Y
Add comment ;
Open Hex view View -> Hex
Enable hex editing Toggle the lock in the status bar
Reanalyze after a patch Right-click function -> Reanalyze
Edit a register live dbg.set_reg_value("r3", 0x4058C000) in the Python console (or right-click the register, press E, type hex, Enter)
Write a RAM string live dbg.write_memory(0x20080000, b"myvalue: %f\r\n\x00")
Set a breakpoint Debugger -> Add Hardware Breakpoint... (hardware execute). Do not use F2 — software breakpoints cannot be written to read-only flash.
Move a breakpoint Remove it and set it at the new address in the GUI (command-port fallback: rbp <old addr> then bp <new addr> 2 hw)
Confirm what is armed The Breakpoints widget lists it (command-port fallback: mdw 0xE0002000 8, each armed breakpoint shows as <addr | 1>)
Apply the ELF symbol map Paste the Python snippet from Step 16 / 26 into the Python Console

OpenOCD server and reset

The server runs with gdb_breakpoint_override hard so that flash-writes are never attempted. Breakpoints in this lab are set in the Binary Ninja GUI through the GDB MI adapter (Step 13). The command-port rows below are the fallback if you use the GDB RSP adapter instead.

Action Command
Connect to the OpenOCD prompt (fallback) nc 127.0.0.1 4444 (or telnet 127.0.0.1 4444)
Reset and run (command port) reset run
Check core state (command port) targets
Set a breakpoint in the GUI Debugger -> Add Hardware Breakpoint... (hardware execute; F2 software breakpoints do not work on flash)
(fallback) Add a breakpoint without the GUI bp <addr> 2 hw
Remove one breakpoint rbp <addr> — address only, no length, no hw
Remove every breakpoint rbp all
Start the server parked at main macOS/Linux: BP_ADDR=0x10000234 ./debug-server.sh (Project 2: 0x10000238) — Windows: $env:BP_ADDR="0x10000234"; .\debug-server.ps1 (one-shot)
Start the server parked in the loop macOS/Linux: BP_ADDR=0x10000244 ./debug-server.sh — Windows: $env:BP_ADDR="0x10000244"; .\debug-server.ps1 (0x1000024a for Project 2)
Break on the loop in a running target set a hardware breakpoint in the GUI at the loop address, then Resume — repeatable
Make Binary Ninja stepping work rp2350.dap.core0 configure -rtos none (already in the scripts)
Step without re-trapping move the breakpoint off the current PC first, then Step Into/Step Over
Reset without desyncing Binary Ninja Detach, reset run on the port, reconnect — never reset run while attached

Every address and byte we changed

Project Address Before After Effect
0x000e 0x1000024c 00 40 45 40 00 c0 58 40 high word of the double: 42.5 -> 99.0
0x0011 0x10000254 ac 1c 5a 64 8f c2 f5 28 low word of the double: 42.52525 -> 99.99
0x0011 0x10000258 3b 43 45 40 5c ff 58 40 high word of the double: 42.52525 -> 99.99
0x000e 0x100034a8 66 61 76 5f 6e 75 6d 3a 6d 79 76 61 6c 75 65 3a string prints myvalue: instead of fav_num:
0x0011 0x100034b0 66 61 76 5f 6e 75 6d 3a 6d 79 76 61 6c 75 65 3a string prints myvalue: instead of fav_num:

IEEE 754 Quick Reference for the Values in This Lesson

Value Double Hex High Word (r3) Low Word (r2)
42.5 0x4045400000000000 0x40454000 0x00000000
42.52525 0x4045433B645A1CAC 0x4045433B 0x645A1CAC
99.0 0x4058C00000000000 0x4058C000 0x00000000
99.99 0x4058FF5C28F5C28F 0x4058FF5C 0x28F5C28F

Raw image facts

Item Value
Build type Release
Load base address 0x10000000
Project 1 size 15308 bytes (0x3bcc)
Project 2 size 15324 bytes (0x3bdc)
Fixed main anchor (both projects) 0x1000018c (reset handler middle blx)
main, Project 1 0x10000234
main, Project 2 0x10000238
printf call / return, Project 1 0x10000244 / 0x10000248
printf call / return, Project 2 0x1000024a / 0x1000024e
double argument registers r2 (low) : r3 (high)
RP2350 UF2 family ID 0xe48bff59

Troubleshooting

Binary Ninja hangs or crashes when you connect (macOS 27)

Three different causes have been seen on this setup; check them in this order.

  • A breakpoint set before connecting. With the GDB MI adapter, if the binary view already has a breakpoint, the session hangs. Start parked with BP_ADDR, connect, then add breakpoints (see the next entry).
  • The wrong GDB executable. Point Full GDB Executable Path at the 14.2.rel1 toolchain (Step 11). The 13.3.rel1 build did not connect in testing.
  • The LLDB adapter. A crash report with libdebuggercore.dylib -> std::terminate() -> abort() and liblldb in the stack is the LLDB adapter, not GDB MI. Avoid LLDB on this setup.

Use GDB MI, with the 14.2.rel1 path above. If it still fails, fall back to plain arm-none-eabi-gdb against the same server — the addresses and register values are identical to the GUI steps.

If Binary Ninja hangs, force-quit it; the connect dialog has no working Cancel. The static steps (resolve, patch, export, flash) never touch the debugger and always work.

The GUI refuses to set a breakpoint (GDB RSP adapter only)

If you are on the GDB RSP adapter, the GUI cannot set breakpoints on this target. That adapter is Binary Ninja's own minimal RSP client and sends a 1-byte breakpoint (Z0,<addr>,1); the Cortex-M33 comparators need 2 bytes, so OpenOCD answers only breakpoints of two bytes length supported. It affects every address, both Toggle Breakpoint and Add Hardware Breakpoint, and the dialog's Size field is disabled. gdb_breakpoint_override makes no difference.

Fix: use the GDB MI adapter (Step 11). It drives real GDB, which sends the correct length, so GUI breakpoints just work. If you must stay on GDB RSP, arm breakpoints from the command port after connecting (bp <addr> 2 hw) — but the lab uses GDB MI and does not need that.

GDB MI hangs when you connect (a breakpoint already existed)

With the GDB MI adapter, if Binary Ninja already has a breakpoint set when you connect, the session hangs. This is a Binary Ninja bug. The working order is:

  1. Start the server parked, e.g. BP_ADDR=0x10000234 ./debug-server.sh (Windows: $env:BP_ADDR="0x10000234"; .\debug-server.ps1).
  2. Connect with the GDB MI adapter.
  3. Only then set hardware breakpoints in the UI.

Never have a breakpoint in the binary view before the GDB MI connection. If it hangs, quit Binary Ninja, restart the server with BP_ADDR, and connect again before adding any breakpoints.

Step Into / Step Over does nothing (PC never moves)

Two causes have been seen on this target.

  1. A breakpoint on the current PC re-traps the step. OpenOCD's step-over-breakpoint logic fails with Duplicate Breakpoint address and the PC stays put. Fix: move the breakpoint off the current PC (in the GUI), then step.
  2. The hwthread RTOS (GDB RSP adapter only). With the GDB RSP adapter, OpenOCD can log fake step thread 0 and reply without stepping, because the RP2350 config's -rtos hwthread makes the current thread id 1 while Binary Ninja sends thread id 0. Fix: rp2350.dap.core0 configure -rtos none (the launcher scripts already pass this). GDB MI does not hit this.

To tell them apart, turn on OpenOCD logging (log_output /tmp/ocd.log, then debug_level 3 on the command port) and look for fake step versus Duplicate Breakpoint.

zsh: bad CPU type in executable: cmake

An Intel x86_64 tool is on your PATH on Apple Silicon. Run Step 2: export PATH="/opt/homebrew/bin:$PATH", then hash -r. Add it to ~/.zshrc to make it permanent.

My addresses do not match this guide

You probably built Debug. This lesson is a Release build. Re-run Step 3 with -DCMAKE_BUILD_TYPE=Release. A Debug build moves the SDK functions and lays the pico_double helpers out differently, so Project 2's main is not at 0x10000238.

A breakpoint never fires

First, confirm you actually set one, and that it is a hardware breakpoint. With the GDB MI adapter, Debugger -> Add Hardware Breakpoint... (hardware execute) should land in the Breakpoints widget. If nothing lands, or the core keeps running, you probably used F2 (Toggle Breakpoint) — that is a software breakpoint and cannot be written to read-only flash, so it never installs. Also check you are on GDB MI, not GDB RSP (the GDB RSP adapter cannot set breakpoints on this target at all).

Then check the order and the state:

  • Arm it only after Binary Ninja is connected. OpenOCD flushes every breakpoint when a client attaches, so anything armed earlier is gone. This also applies to BP_ADDR on the startup command line.
  • Verify it is armed: mdw 0xE0002000 8. You should see your address with the low bit set (0x10000234 -> 0x10000235). All zeros means nothing is armed — re-read this first, because it distinguishes "not armed" from "armed but never reached".
  • Is the core running? poll on the command port should not report a halt. If it is stopped, click Resume.
  • Does the address get reached again? main runs once per reset, so use BP_ADDR at startup (Step 10) rather than reset run while attached. Loop addresses such as 0x10000244 fire on the next pass with no reset — arm them and click Resume in Binary Ninja.
  • With GDB MI the stop is reported as Breakpoint and appears in the Breakpoints widget, because GDB really did set it.

I edit r2/r3 (or another register) and it reverts

Both mains reload the argument pair at the top of every loop iteration — mov r2, r4 / mov r3, r5 run right before the printf call. So your edit is only live for the instant between the write and the next pass; then the pair is reloaded from r4/r5 (Project 1) or from the literal pool via ldrd (Project 2). The edit sticks only if the core is genuinely stopped at the breakpoint and stays stopped.

If it keeps reverting, the core is running, which almost always means the breakpoint is not installed — usually because it is a software breakpoint (F2) that cannot be written to read-only flash. Use Debugger -> Add Hardware Breakpoint... (hardware execute).

The Registers widget is a snapshot, not a live view. Binary Ninja reads the registers at each stop and shows that snapshot; it does not poll the target, and there is no "refresh registers" command. So a value changed outside Binary Ninja will not appear until the next stop.

The serial capture is garbage on macOS

Reading /dev/cu.usbmodem* with a bare read() returns garbage. Set raw termios at 115200 first: clear canonical/echo flags, set CLOCAL|CREAD, and B115200 on input and output. screen /dev/cu.usbmodem* 115200 does all of this for you; a script must call tcsetattr itself. Once set, the capture reads clean fav_num: 42.500000 lines.

It worked for a second, then stopped (Binary Ninja's view desyncs)

This is the most common failure, and it has one main cause: driving the core from the OpenOCD command port while Binary Ninja is connected.

  • If you send reset run from the port while attached, the core resets, runs, and halts at your breakpoint — but Binary Ninja never receives the stop event. Its sidebar keeps showing the previous location, so Step and Resume act on a stale PC and appear to do nothing.
  • If the OpenOCD process dies (or you restart it) while attached, Binary Ninja keeps believing it is connected: the sidebar stays, but the menu shows Pause enabled and Resume/Step disabled because Binary Ninja last saw the target running.

Recovery: Detach, then reconnect. If Detach does nothing (the connection is already dead), restart Binary Ninja — its menu still shows a session that no longer exists.

Prevention:

  • Stop at main with BP_ADDR on a fresh server start, not with reset run while attached.
  • For loop addresses, set the breakpoint in the GUI and click Resume. Let Binary Ninja be the thing that starts the core.
  • If you must reset, Detach first, reset run, then reconnect.
  • Never leave a breakpoint on the PC you are about to step or resume from.

The target "blows past" main and stops at 0x10003214 instead

0x10003214 is inside stdio_uart_out_flush:

10003210: 4b02   ldr  r3, [pc, #8]  ; @ 0x1000321c
10003212: 681a   ldr  r2, [r3]
10003214: 6993   ldr  r3, [r2, #24] ; the core sits here while the UART drains
10003216: 071b   lsls r3, r3, #28
10003218: d4fc   bmi.n 0x10003214
1000321a: 4770   bx lr
1000321c: 20000850 .word 0x20000850

That is the UART transmit-FIFO drain loop inside printf, so the core is running main's loop and simply spends nearly all its time there. The breakpoint at main did not fire because main's entry runs exactly once per reset. If you arm the breakpoint after the reset, or set it while the target is already running and just resume, the core is already past main and will never re-execute it. Either arm the breakpoint before resetting, or break inside the loop at the printf call, which fires every iteration.

0x10003214 is not a function. It is one instruction inside stdio_uart_out_flush, which starts at 0x10003210. If Binary Ninja has created a function at 0x10003214 (for example because the debugger stopped at that PC), the decompiler shows garbage. Delete that bogus function (right-click it -> Delete Function, or put the cursor on it and press U to undefine) and reanalyze. The real function is stdio_uart_out_flush at 0x10003210. (In Project 2 the same drain loop is at 0x1000321c.)

The console floods with Failed to read memory at 0xf0000000

Core1 is exposed. The scripts must run with USE_CORE=0. Stop the server, confirm only core0 is reported, restart, then restart Binary Ninja.

Connect to Remote Process is greyed out and Pause does nothing

Binary Ninja is in a stale session, usually because the debug server restarted while attached. Quit and reopen Binary Ninja (or the .bndb) and connect again.

The decompiler still shows the old value after patching

Right-click the function and choose Reanalyze.

Project 2 prints a wrong, hybrid number after patching

You patched only one of the two literal words. 42.52525 has a non-zero low word, so 0x10000254 and 0x10000258 must both change. Project 1's 42.5 is the opposite case: its low word is 0x00000000, so only 0x1000024c changes.

The double does not print as 99.99 after patching

Confirm you wrote the bytes little-endian. 0x28F5C28F is stored 8f c2 f5 28, and 0x4058FF5C is stored 5c ff 58 40. If you typed the words in big-endian order the value is nonsense. The Python form bytes.fromhex("8fc2f528") is already in file order.


Fallback: do the dynamic steps with GDB (macOS 27)

If Binary Ninja's debugger crashes on attach on macOS 27 (see Troubleshooting), you can still do the live hack with the ARM GDB from the toolchain, against the same OpenOCD server. The addresses and register values are identical to the GUI steps.

Start the debug server (Step 10), then in a new terminal:

arm-none-eabi-gdb

At the (gdb) prompt:

set architecture armv8-m.main
target extended-remote :3333
hbreak *0x10000244
continue

Do not run monitor reset run before hbreak. 0x10000244 is inside main's loop, so the breakpoint fires on the next iteration with no reset. If you reset first, the core runs main and you will not catch it.

GDB stops at the printf call. Confirm the pair, change it, and let it run:

info registers pc r2 r3  # pc = 0x10000244, r2 = 0x00000000, r3 = 0x40454000
set $r3 = 0x4058C000
stepi
continue

The serial monitor prints fav_num: 99.000000 for the iteration you changed — the same temporary live hack as editing r3 in the Binary Ninja Registers widget. When you are done, press Ctrl-C, then detach and quit.

If you specifically want to stop at main (0x10000234), remember its entry runs only once per reset, so the breakpoint must be armed before the reset:

monitor reset halt
hbreak *0x10000234
continue

If you instead set it while the target is running and just continue, you will "blow past" main and catch the core inside printf — in this build at 0x10003214, the stdio_uart_out_flush UART-drain loop.

Project 2 is the same with the other call site and pair:

hbreak *0x1000024a
continue
info registers pc r2 r3  # pc = 0x1000024a, r2 = 0x645A1CAC, r3 = 0x4045433B
set $r2 = 0x28F5C28F
set $r3 = 0x4058FF5C
stepi

hbreak sets a hardware breakpoint, which is required for read-only flash. It works from plain GDB because GDB sends the 2-byte length the Cortex-M33 comparators need. Binary Ninja's GDB MI adapter goes through the same GDB, so its GUI breakpoints work too; the old GDB RSP adapter was the one that sent a 1-byte length and could not set breakpoints here.

Glossary

Term Definition
.bss Section for uninitialized global variables; zeroed by startup code
.data Section for initialized global variables; copied from flash to SRAM at boot
.elf Linked image with the symbol table; the ground truth for addresses and names
.rodata Read-only section for constants and string literals; stays in flash
Bias Constant added to an IEEE 754 exponent (127 for float, 1023 for double)
Double 64-bit IEEE 754 floating-point type (1 sign, 11 exponent, 52 mantissa)
Float 32-bit IEEE 754 floating-point type (1 sign, 8 exponent, 23 mantissa)
GPIO General Purpose Input/Output — controllable pins on the microcontroller
Hardware breakpoint A breakpoint serviced by the CPU comparators, required for read-only flash
IEEE 754 The standard that defines binary floating-point encoding
Inlining The optimizer replacing a function call with the function body
Literal pool A block of 32-bit constants that Thumb-2 code reaches with PC-relative ldr/ldrd
Mantissa The fractional significand bits (23 for float, 52 for double)
MMIO Memory-mapped I/O — hardware registers accessed as memory addresses
Promotion C's automatic float -> double conversion for variadic arguments
Register pair Two 32-bit registers (r2:r3) that together hold a 64-bit value
SIO Single-cycle I/O — the fast GPIO block in the RP2350, at 0xd0000000
Thumb bit Bit 0 of a Cortex-M function pointer; selects Thumb instruction mode
UF2 USB Flashing Format — the file format the Pico 2 bootloader accepts
Vector table The first words of flash: initial stack pointer and exception vectors

Remember: the ELF tells you what every address is, and the .bin is what you actually patch. Prove the behavior dynamically by reading r2:r3, resolve the names from the ELF, then patch the constant bytes — one word for a clean fraction like 42.5, two words for a repeating one like 42.52525 — and flash.