Files
Embedded-Hacking/WEEK07/WEEK07.md
2026-03-15 10:40:20 -04:00

46 KiB

Week 7: Constants in Embedded Systems: Debugging and Hacking Constants w/ 1602 LCD I2C Basics

🎯 What You'll Learn This Week

By the end of this tutorial, you will be able to:

  • Understand the difference between #define macros and const variables
  • Know how constants are stored differently in memory (compile-time vs runtime)
  • Understand the I²C (Inter-Integrated Circuit) communication protocol
  • Configure I²C peripherals and communicate with LCD displays
  • Understand C structs and how the Pico SDK uses them for hardware abstraction
  • Use GDB to examine constants, structs, and string literals in memory
  • Hack constant values and string literals using a hex editor
  • Patch LCD display text without access to source code

📚 Part 1: Understanding Constants in C

Two Types of Constants

In C, there are two ways to create values that shouldn't change:

Type Syntax Where It Lives When Resolved
#define #define FAV_NUM 42 Nowhere (replaced) Compile time
const const int NUM = 1337; Flash (.rodata) Runtime

Preprocessor Macros (#define)

A preprocessor macro is a text replacement that happens BEFORE your code is compiled:

#define FAV_NUM 42

printf("Value: %d", FAV_NUM);
// Becomes: printf("Value: %d", 42);

Think of it like a "find and replace" in a text editor. The compiler never sees FAV_NUM - it only sees 42!

┌─────────────────────────────────────────────────────────────────┐
│  Preprocessor Macro Flow                                        │
│                                                                 │
│  Source Code          Preprocessor         Compiler             │
│  ┌──────────┐        ┌──────────┐        ┌──────────┐           │
│  │ #define  │        │ Replace  │        │ Compile  │           │
│  │ FAV_NUM  │ ─────► │ FAV_NUM  │ ─────► │ binary   │           │
│  │ 42       │        │ with 42  │        │ code     │           │
│  └──────────┘        └──────────┘        └──────────┘           │
│                                                                 │
│  FAV_NUM doesn't exist in the final binary!                     │
│  The value 42 is embedded directly in instructions.             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Const Variables

A const variable is an actual variable stored in memory, but marked as read-only:

const int OTHER_FAV_NUM = 1337;

Unlike #define, this creates a real memory location in the .rodata (read-only data) section of flash:

┌─────────────────────────────────────────────────────────────────┐
│  Const Variable in Memory                                       │
│                                                                 │
│  Flash Memory (.rodata section)                                 │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │ Address: 0x10001234                                        │ │
│  │ Value:   0x00000539 (1337 in hex)                          │ │
│  │ Name:    OTHER_FAV_NUM (in debug symbols only)             │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
│  The variable EXISTS in memory and can be read at runtime.      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Comparison: #define vs const

Feature #define const
Type checking None (just text) Yes (compiler enforced)
Memory usage None (inlined) Uses flash space
Debugger visible No Yes (with symbols)
Can take address No (&FAV_NUM fails) Yes (&OTHER_FAV_NUM works)
Scope Global (from definition) Normal C scoping rules

📚 Part 2: Understanding I²C Communication

What is I²C?

I²C (pronounced "I-squared-C" or "I-two-C") stands for Inter-Integrated Circuit. It's a way for chips to talk to each other using just TWO wires!

┌─────────────────────────────────────────────────────────────────┐
│  I²C Bus - Two Wires, Many Devices                              │
│                                                                 │
│     3.3V                                                        │
│       │                                                         │
│       ┴ Pull-up    ┴ Pull-up                                    │
│       │            │                                            │
│  SDA ─┼────────────┼───────────────────────────────────────     │
│       │            │                                            │
│  SCL ─┼────────────┼───────────────────────────────────────     │
│       │            │            │            │                  │
│   ┌───┴────┐    ┌──┴───┐   ┌────┴──┐   ┌─────┴───┐              │
│   │ Pico   │    │  LCD │   │Sensor │   │ EEPROM  │              │
│   │(Master)│    │ 0x27 │   │ 0x48  │   │ 0x50    │              │
│   └────────┘    └──────┘   └───────┘   └─────────┘              │
│                                                                 │
│  Each device has a unique address (0x27, 0x48, 0x50...)         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

The Two I²C Wires

Wire Name Purpose
SDA Serial Data Carries the actual data bits
SCL Serial Clock Timing signal that synchronizes data

Why Pull-Up Resistors?

I²C uses open-drain signals, meaning devices can only pull the line LOW. They can't drive it HIGH! Pull-up resistors are needed to bring the lines back to HIGH when no device is pulling them down.

The Pico 2 has internal pull-ups that we can enable with gpio_pull_up().

I²C Addresses

Every I²C device has a unique 7-bit address. Common addresses:

Device Type Typical Address
1602 LCD with PCF8574 0x27 or 0x3F
Temperature sensor 0x48
EEPROM 0x50
Real-time clock 0x68

I²C Communication Flow

┌─────────────────────────────────────────────────────────────────┐
│  I²C Transaction                                                │
│                                                                 │
│  1. Master sends START condition                                │
│  2. Master sends device address (7 bits) + R/W bit              │
│  3. Addressed device sends ACK (acknowledge)                    │
│  4. Data is transferred (8 bits at a time)                      │
│  5. Receiver sends ACK after each byte                          │
│  6. Master sends STOP condition                                 │
│                                                                 │
│  START ──► Address ──► ACK ──► Data ──► ACK ──► STOP            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

📚 Part 3: Understanding C Structs

What is a Struct?

A struct (short for "structure") is a way to group related variables together under one name. Think of it like a form with multiple fields:

// A struct definition - like a template
struct student {
    char name[50];
    int age;
    float gpa;
};

// Creating a variable of this struct type
struct student alice = {"Alice", 16, 3.8};

Why Use Structs?

Instead of passing many separate variables:

void print_student(char *name, int age, float gpa); // Messy!

You pass one struct:

void print_student(struct student s); // Clean!

The typedef Keyword

Writing struct student everywhere is tedious. The typedef keyword creates an alias:

typedef struct student student_t;

// Now you can write:
student_t alice; // Instead of: struct student alice;

Forward Declaration

Sometimes you need to tell the compiler "this struct exists" before defining it:

typedef struct i2c_inst i2c_inst_t; // Forward declaration + alias

// Later, the full definition:
struct i2c_inst {
    i2c_hw_t *hw;
    bool restart_on_next;
};

📚 Part 4: Understanding the Pico SDK's I²C Structs

The i2c_inst_t Struct

The Pico SDK uses a struct to represent each I²C controller:

struct i2c_inst {
    i2c_hw_t *hw;           // Pointer to hardware registers
    bool restart_on_next;   // SDK internal flag
};

What each member means:

Member Type Purpose
hw i2c_hw_t * Pointer to the actual hardware registers
restart_on_next bool Tracks if next transfer needs a restart

The Macro Chain

When you write I2C_PORT in your code, here's what happens:

┌─────────────────────────────────────────────────────────────────┐
│  Macro Expansion Chain                                          │
│                                                                 │
│  In your code:     #define I2C_PORT i2c1                        │
│                           │                                     │
│                           ▼                                     │
│  In i2c.h:         #define i2c1 (&i2c1_inst)                    │
│                           │                                     │
│                           ▼                                     │
│  In i2c.c:         i2c_inst_t i2c1_inst = {i2c1_hw, false};     │
│                           │                                     │
│                           ▼                                     │
│  In i2c.h:         #define i2c1_hw ((i2c_hw_t *)I2C1_BASE)      │
│                           │                                     │
│                           ▼                                     │
│  In addressmap.h:  #define I2C1_BASE 0x40098000                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

So I2C_PORT eventually becomes a pointer to a struct that contains a pointer to hardware registers at address 0x40098000!

The Hardware Register Pointer

The i2c_hw_t *hw member points to the actual silicon:

┌─────────────────────────────────────────────────────────────────┐
│  Memory Map                                                     │
│                                                                 │
│  Address 0x40098000: I²C1 Hardware Registers                    │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │ Offset 0x00: IC_CON (Control register)                     │ │
│  │ Offset 0x04: IC_TAR (Target address register)              │ │
│  │ Offset 0x10: IC_DATA_CMD (Data command register)           │ │
│  │ ...                                                        │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
│  The i2c_hw_t struct maps directly to these registers!          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

📚 Part 5: The ARM Calling Convention (AAPCS)

How Arguments Are Passed

On ARM Cortex-M, the ARM Architecture Procedure Call Standard (AAPCS) defines how functions receive arguments:

Register Purpose
r0 First argument
r1 Second argument
r2 Third argument
r3 Fourth argument
Stack Fifth+ arguments
r0 Return value

Example: i2c_init(i2c1, 100000)

i2c_init(I2C_PORT, 100000);

In assembly:

ldr r0, [address of i2c1_inst]   ; r0 = pointer to struct (first arg)
ldr r1, =0x186A0                 ; r1 = 100000 (second arg)
bl  i2c_init                     ; Call the function

📚 Part 6: Setting Up Your Environment

Prerequisites

Before we start, make sure you have:

  1. A Raspberry Pi Pico 2 board
  2. A Raspberry Pi Pico Debug Probe
  3. OpenOCD installed and configured
  4. GDB (arm-none-eabi-gdb) installed
  5. Python installed (for UF2 conversion)
  6. A serial monitor (PuTTY, minicom, or screen)
  7. A 1602 LCD display with I²C backpack (PCF8574)
  8. A hex editor (HxD, ImHex, or similar)
  9. The sample project: 0x0017_constants

Hardware Setup

Connect your LCD like this:

LCD Pin Pico 2 Pin
VCC 3.3V or 5V
GND GND
SDA GPIO 2
SCL GPIO 3
┌─────────────────────────────────────────────────────────────────┐
│  I²C LCD Wiring                                                 │
│                                                                 │
│  Pico 2                        1602 LCD + I²C Backpack          │
│  ┌──────────┐                  ┌──────────────────────┐         │
│  │          │                  │                      │         │
│  │ GPIO 2   │─────── SDA ─────►│ SDA                  │         │
│  │ (SDA)    │                  │                      │         │
│  │          │                  │    ┌────────────┐    │         │
│  │ GPIO 3   │─────── SCL ─────►│ SCL│  Reverse   │    │         │
│  │ (SCL)    │                  │    │Engineering │    │         │
│  │          │                  │    └────────────┘    │         │
│  │ 3.3V     │─────── VCC ─────►│ VCC                  │         │
│  │          │                  │                      │         │
│  │ GND      │─────── GND ─────►│ GND                  │         │
│  │          │                  │                      │         │
│  └──────────┘                  └──────────────────────┘         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Project Structure

Embedded-Hacking/
├── 0x0017_constants/
│   ├── build/
│   │   ├── 0x0017_constants.uf2
│   │   └── 0x0017_constants.bin
│   ├── 0x0017_constants.c
│   └── lcd_1602.h
└── uf2conv.py

🔬 Part 7: Hands-On Tutorial - Constants and I²C LCD

Step 1: Review the Source Code

Let's examine the constants code:

File: 0x0017_constants.c

#include <stdio.h>
#include <string.h>
#include "pico/stdlib.h"
#include "hardware/i2c.h"
#include "lcd_1602.h"

#define FAV_NUM 42
#define I2C_PORT i2c1
#define I2C_SDA_PIN 2
#define I2C_SCL_PIN 3

const int OTHER_FAV_NUM = 1337;

int main(void) {
    stdio_init_all();
    
    i2c_init(I2C_PORT, 100000);
    gpio_set_function(I2C_SDA_PIN, GPIO_FUNC_I2C);
    gpio_set_function(I2C_SCL_PIN, GPIO_FUNC_I2C);
    gpio_pull_up(I2C_SDA_PIN);
    gpio_pull_up(I2C_SCL_PIN);
    
    lcd_i2c_init(I2C_PORT, 0x27, 4, 0x08);
    lcd_set_cursor(0, 0);
    lcd_puts("Reverse");
    lcd_set_cursor(1, 0);
    lcd_puts("Engineering");
    
    while (true) {
        printf("FAV_NUM: %d\r\n", FAV_NUM);
        printf("OTHER_FAV_NUM: %d\r\n", OTHER_FAV_NUM);
    }
}

What this code does:

  1. Lines 7-10: Define preprocessor macros for constants and I²C configuration
  2. Line 12: Define a const variable stored in flash
  3. Line 15: Initialize UART for serial output
  4. Lines 17-21: Initialize I²C1 at 100kHz, configure GPIO pins, enable pull-ups
  5. Lines 23-27: Initialize LCD and display "Reverse" on line 0, "Engineering" on line 1
  6. Lines 29-32: Infinite loop printing both constant values to serial terminal

Step 2: Flash the Binary to Your Pico 2

  1. Hold the BOOTSEL button on your Pico 2
  2. Plug in the USB cable (while holding BOOTSEL)
  3. Release BOOTSEL - a drive called "RPI-RP2" appears
  4. Drag and drop 0x0017_constants.uf2 onto the drive
  5. The Pico will reboot and start running!

Step 3: Verify It's Working

Check the LCD:

  • Line 1 should show: Reverse
  • Line 2 should show: Engineering

Check the serial monitor (PuTTY/screen):

FAV_NUM: 42
OTHER_FAV_NUM: 1337
FAV_NUM: 42
OTHER_FAV_NUM: 1337
...

🔬 Part 8: Debugging with GDB (Dynamic Analysis)

🔄 REVIEW: This setup is identical to previous weeks. If you need a refresher on OpenOCD and GDB connection, refer back to Week 3 Part 6.

Starting the Debug Session

Terminal 1 - Start OpenOCD:

openocd ^
  -s "C:\Users\flare-vm\.pico-sdk\openocd\0.12.0+dev\scripts" ^
  -f interface/cmsis-dap.cfg ^
  -f target/rp2350.cfg ^
  -c "adapter speed 5000"

Terminal 2 - Start GDB:

arm-none-eabi-gdb build\0x0017_constants.elf

Connect to target:

(gdb) target remote :3333
(gdb) monitor reset halt

Step 4: Examine Main Function

Let's examine the main function. Disassemble from the entry point:

x/54i 0x10000234

You should see output like:

   0x10000234 <main>:   push    {r3, lr}
   0x10000236 <main+2>: bl      0x100037fc <stdio_init_all>
   0x1000023a <main+6>: ldr     r1, [pc, #104]  @ (0x100002a4 <main+112>)
   0x1000023c <main+8>: ldr     r0, [pc, #104]  @ (0x100002a8 <main+116>)
   0x1000023e <main+10>:        bl      0x10003cdc <i2c_init>
   0x10000242 <main+14>:        movs    r1, #3
   0x10000244 <main+16>:        movs    r0, #2
   0x10000246 <main+18>:        bl      0x100008f0 <gpio_set_function>
   0x1000024a <main+22>:        movs    r1, #3
   0x1000024c <main+24>:        mov     r0, r1
   0x1000024e <main+26>:        bl      0x100008f0 <gpio_set_function>
   0x10000252 <main+30>:        movs    r2, #0
   0x10000254 <main+32>:        movs    r1, #1
   0x10000256 <main+34>:        movs    r0, #2
   0x10000258 <main+36>:        bl      0x1000092c <gpio_set_pulls>
   0x1000025c <main+40>:        movs    r2, #0
   0x1000025e <main+42>:        movs    r1, #1
   0x10000260 <main+44>:        movs    r0, #3
   0x10000262 <main+46>:        bl      0x1000092c <gpio_set_pulls>
   0x10000266 <main+50>:        movs    r3, #8
   0x10000268 <main+52>:        movs    r2, #4
   0x1000026a <main+54>:        movs    r1, #39 @ 0x27
   0x1000026c <main+56>:
    ldr r0, [pc, #56]   @ (0x100002a8 <main+116>)
   0x1000026e <main+58>:        bl      0x100002bc <lcd_i2c_init>
   0x10000272 <main+62>:        movs    r1, #0
   0x10000274 <main+64>:        mov     r0, r1
   0x10000276 <main+66>:        bl      0x100006f4 <lcd_set_cursor>
   0x1000027a <main+70>:
    ldr r0, [pc, #48]   @ (0x100002ac <main+120>)
   0x1000027c <main+72>:        bl      0x100007f0 <lcd_puts>
   0x10000280 <main+76>:        movs    r0, #1
   0x10000282 <main+78>:        movs    r1, #0
   0x10000284 <main+80>:        bl      0x100006f4 <lcd_set_cursor>
   0x10000288 <main+84>:
    ldr r0, [pc, #36]   @ (0x100002b0 <main+124>)
   0x1000028a <main+86>:        bl      0x100007f0 <lcd_puts>
   0x1000028e <main+90>:        movs    r1, #42 @ 0x2a
   0x10000290 <main+92>:
    ldr r0, [pc, #32]   @ (0x100002b4 <main+128>)
   0x10000292 <main+94>:        bl      0x1000398c <__wrap_printf>
   0x10000296 <main+98>:        movw    r1, #1337       @ 0x539
   0x1000029a <main+102>:
    ldr r0, [pc, #28]   @ (0x100002b8 <main+132>)
   0x1000029c <main+104>:       bl      0x1000398c <__wrap_printf>
   0x100002a0 <main+108>:       b.n     0x1000028e <main+90>
   0x100002a2 <main+110>:       nop
   0x100002a4 <main+112>:       strh    r0, [r4, #52]   @ 0x34
   0x100002a6 <main+114>:       movs    r1, r0
   0x100002a8 <main+116>:       lsls    r4, r5, #24
   0x100002aa <main+118>:       movs    r0, #0
   0x100002ac <main+120>:       subs    r6, #232        @ 0xe8
   0x100002ae <main+122>:       asrs    r0, r0, #32
   0x100002b0 <main+124>:       subs    r6, #240        @ 0xf0
   0x100002b2 <main+126>:       asrs    r0, r0, #32
   0x100002b4 <main+128>:       subs    r6, #252        @ 0xfc
   0x100002b6 <main+130>:       asrs    r0, r0, #32
   0x100002b8 <main+132>:       subs    r7, #12
   0x100002ba <main+134>:       asrs    r0, r0, #32

Step 5: Set a Breakpoint at Main

b *0x10000234
c

GDB responds:

Breakpoint 1 at 0x10000234: file C:/Users/flare-vm/Desktop/Embedded-Hacking-main/0x0017_constants/0x0017_constants.c, line 16.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) c
Continuing.

Thread 1 "rp2350.cm0" hit Breakpoint 1, main ()
    at C:/Users/flare-vm/Desktop/Embedded-Hacking-main/0x0017_constants/0x0017_constants.c:16
16          stdio_init_all();

⚠️ Note: If GDB says The program is not being run. when you type c, the target hasn't been started yet. Use monitor reset halt first, then c to continue to your breakpoint.

Step 6: Find the #define Constant (FAV_NUM)

Step through to the printf call and examine the registers:

x/20i 0x1000028e

Look for:

...
0x1000028e <main+90>:        movs    r1, #42 @ 0x2a
...

The #define constant is embedded directly as an immediate value in the instruction!

Step 7: Find the const Variable (OTHER_FAV_NUM)

Continue examining the loop body:

(gdb) x/5i 0x10000296

Look for this instruction:

...
0x10000296 <main+98>:   movw    r1, #1337       @ 0x539
...

Surprise! The const variable is ALSO embedded as an immediate value — not loaded from memory! The compiler saw that OTHER_FAV_NUM is never address-taken (&OTHER_FAV_NUM is never used), so it optimized the const the same way as #define — as a constant embedded directly in the instruction.

The difference is the instruction encoding:

  • FAV_NUM (42): movs r1, #0x2a — 16-bit Thumb instruction (values 0-255)
  • OTHER_FAV_NUM (1337): movw r1, #0x539 — 32-bit Thumb-2 instruction (values 0-65535)

💡 Why movw instead of movs? The value 1337 doesn't fit in 8 bits (max 255), so the compiler uses movw (Move Wide) which can encode any 16-bit immediate (0-65535) in a 32-bit instruction.

Step 8: Examine the Literal Pool

The literal pool after the loop contains addresses and constants that are too large for regular instruction immediates. Let's examine it:

(gdb) x/6wx 0x100002a4
0x100002a4 <main+112>:  0x000186a0      0x2000062c      0x10003ee8      0x10003ef0
0x100002b4 <main+128>:  0x10003efc      0x10003f0c

These are the values that ldr rN, [pc, #offset] instructions load:

Literal Pool Addr Value Used By
0x100002a4 0x000186A0 I2C baudrate (100000)
0x100002a8 0x2000062C &i2c1_inst (I2C struct in RAM)
0x100002ac 0x10003EE8 "Reverse" string address
0x100002b0 0x10003EF0 "Engineering" string address
0x100002b4 0x10003EFC "FAV_NUM: %d\r\n" format str
0x100002b8 0x10003F0C "OTHER_FAV_NUM: %d\r\n" fmt

💡 Why does the disassembly at 0x100002a4 show strh r0, [r4, #52] instead of data? Same reason as Week 6 — GDB's x/i tries to decode raw data as instructions. Use x/wx to see the actual word values.

Step 9: Examine the I²C Struct

Find the i2c1_inst struct address loaded into r0 before i2c_init:

x/2wx 0x2000062c

You should see:

0x2000062c <i2c1_inst>: 0x40098000      0x00000000

Step 10: Examine the LCD String Literals

Find the strings passed to lcd_puts:

x/s 0x10003ee8

Output:

0x10003ee8: "Reverse"
x/s 0x10003ef0

Output:

0x10003ef0: "Engineering"

Step 11: Step Through I²C Initialization

Use si to step through instructions and watch the I²C setup:

si
i r r0 r1

🔬 Part 9: Understanding the Assembly

Now that we've explored the binary in GDB, let's make sense of the key patterns we found.

Step 12: Analyze #define vs const in Assembly

From GDB, we discovered something interesting — both constants ended up as instruction immediates!

For FAV_NUM (42) — a #define macro:

0x1000028e:    movs r1, #42 @ 0x2a

The value 42 is embedded directly in a 16-bit Thumb instruction. This is expected — #define is text replacement, so the compiler never sees FAV_NUM, only 42.

For OTHER_FAV_NUM (1337) — a const variable:

0x10000296:    movw r1, #1337 @ 0x539

The value 1337 is ALSO embedded directly in an instruction — but this time a 32-bit Thumb-2 movw because the value doesn't fit in 8 bits.

Why wasn't const stored in memory? In theory, const int OTHER_FAV_NUM = 1337 creates a variable in the .rodata section. But the compiler optimized it away because:

  1. We never take the address of OTHER_FAV_NUM (no &OTHER_FAV_NUM)
  2. The value fits in a 16-bit movw immediate
  3. Loading from an immediate is faster than loading from memory

💡 Key takeaway for reverse engineering: Don't assume const variables will appear as memory loads. Modern compilers aggressively inline constant values. The C keyword const is a source-level concept — the compiler may or may not honor it in the final binary.

Step 13: Analyze the I²C Struct Layout

In GDB, we examined the i2c1_inst struct at 0x2000062c:

(gdb) x/2wx 0x2000062c
0x2000062c <i2c1_inst>: 0x40098000      0x00000000

This maps to the i2c_inst_t struct:

┌─────────────────────────────────────────────────────────────────┐
│  i2c_inst_t at 0x2000062c                                       │
│                                                                 │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │ Offset   Type          Name              Value             │ │
│  │ 0x00     i2c_hw_t *    hw                0x40098000        │ │
│  │ 0x04     bool          restart_on_next   0x00 (false)      │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

The first member (hw) points to 0x40098000 — the I²C1 hardware register base. This is the end of the macro chain: I2C_PORTi2c1&i2c1_insthw0x40098000.

Step 14: Locate the String Literals

We found the LCD strings in flash memory:

(gdb) x/s 0x10003ee8
0x10003ee8: "Reverse"

(gdb) x/s 0x10003ef0
0x10003ef0: "Engineering"

These are stored consecutively in the .rodata section. Note the addresses — we'll need them for patching.


🔬 Part 10: Hacking the Binary with a Hex Editor

Now for the fun part — we'll patch the .bin file directly using a hex editor!

💡 Why a hex editor? GDB cannot write to flash memory — the 0x10000000+ address range where program instructions and read-only data live. Trying set *(char *)0x1000028e = 0x2b in GDB gives Writing to flash memory forbidden in this context. To make permanent patches that survive a power cycle, we edit the .bin file directly with a hex editor and re-flash it.

Step 15: Open the Binary in a Hex Editor

  1. Open HxD (or your preferred hex editor: ImHex, 010 Editor, etc.)
  2. Click FileOpen
  3. Navigate to C:\Users\flare-vm\Desktop\Embedded-Hacking-main\0x0017_constants\build\
  4. Open 0x0017_constants.bin

Step 16: Calculate the File Offset

The binary is loaded at base address 0x10000000. To find the file offset of any address:

file_offset = address - 0x10000000

For example:

  • Address 0x1000028e → file offset 0x28E (654 in decimal)
  • Address 0x10003ee8 → file offset 0x3EE8 (16104 in decimal)

Step 17: Understand FAV_NUM Encoding (movs — 16-bit Thumb)

From our GDB analysis, we know the instruction at 0x1000028e is:

movs r1, #0x2a    →    bytes: 2a 21

In HxD, navigate to file offset 0x28E and verify you see the byte 2A followed by 21.

🔍 How Thumb encoding works: In movs r1, #imm8, the immediate value is the first byte, and the opcode 21 is the second byte. So the bytes 2a 21 encode movs r1, #0x2a (42). If you wanted to change this to 43, you'd change 2A to 2B.

Step 18: Understand OTHER_FAV_NUM Encoding (movw — 32-bit Thumb-2)

From GDB, we found the movw r1, #1337 instruction at 0x10000296. Examine the exact bytes:

(gdb) x/4bx 0x10000296
0x10000296 <main+98>:   0x40    0xf2    0x39    0x51

This is the 32-bit Thumb-2 encoding of movw r1, #0x539 (1337). The bytes break down as:

┌─────────────────────────────────────────────────────────────────┐
│  movw r1, #0x539  →  bytes: 40 F2 39 51                         │
│                                                                 │
│  Byte 0: 0x40  ─┐                                               │
│  Byte 1: 0xF2  ─┘  First halfword (opcode + upper imm bits)     │
│  Byte 2: 0x39  ──── Lower 8 bits of immediate (imm8) ← CHANGE   │
│  Byte 3: 0x51  ──── Destination register (r1) + upper imm bits  │
│                                                                 │
│  imm16 = 0x0539 = 1337 decimal                                  │
│  imm8 field = 0x39 (lower 8 bits of the value)                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

The file offset is 0x10000296 - 0x10000000 = 0x296. The imm8 byte is the 3rd byte of the instruction: 0x296 + 2 = 0x298.

To change movw r1, #1337 to movw r1, #1344:

  1. In HxD, press Ctrl+G (Go to offset)
  2. Enter offset: 298 (the third byte of the 4-byte instruction)
  3. You should see the byte 39 at this position
  4. Change 39 to 40

🔍 Why offset 0x298 and not 0x296? The lower 8 bits of the immediate (imm8) are in the third byte of the 4-byte movw instruction. The instruction starts at file offset 0x296, so imm8 is at 0x296 + 2 = 0x298. Changing 0x39 to 0x40 changes the value from 0x539 (1337) to 0x540 (1344).

Step 19: Hack — Change LCD Text from "Reverse" to "Exploit"

IMPORTANT: The new string must be the same length as the original! "Reverse" and "Exploit" are both 7 characters — perfect!

From our GDB analysis in Step 10, we found the string at 0x10003ee8. File offset = 0x10003ee8 - 0x10000000 = 0x3EE8.

  1. In HxD, press Ctrl+G and enter offset: 3EE8
  2. You should see the bytes for "Reverse": 52 65 76 65 72 73 65 00
  3. Change the bytes to spell "Exploit": 45 78 70 6c 6f 69 74 00

ASCII Reference:

Character Hex
E 0x45
x 0x78
p 0x70
l 0x6c
o 0x6f
i 0x69
t 0x74

Step 20: Save the Patched Binary

  1. Click FileSave As
  2. Save as 0x0017_constants-h.bin in the build directory
  3. Close the hex editor

🔬 Part 11: Converting and Flashing the Hacked Binary

Step 21: Convert to UF2 Format

Open a terminal and navigate to your project directory:

cd C:\Users\flare-vm\Desktop\Embedded-Hacking-main\0x0017_constants

Run the conversion command:

python ..\uf2conv.py build\0x0017_constants-h.bin --base 0x10000000 --family 0xe48bff59 --output build\hacked.uf2

Step 22: Flash the Hacked Binary

  1. Hold BOOTSEL and plug in your Pico 2
  2. Drag and drop hacked.uf2 onto the RPI-RP2 drive
  3. Check your LCD and serial monitor

Step 23: Verify the Hack

Check the LCD:

  • Line 1 should now show: Exploit (instead of "Reverse")
  • Line 2 should still show: Engineering

Check the serial monitor:

FAV_NUM: 42
OTHER_FAV_NUM: 1337
FAV_NUM: 42
OTHER_FAV_NUM: 1337
...

The numbers are unchanged — we only patched the LCD string!

🎉 BOOM! We successfully changed the LCD text from "Reverse" to "Exploit" without access to the source code!


📊 Part 12: Summary and Review

What We Accomplished

  1. Learned about constants - #define macros vs const variables
  2. Understood I²C communication - Two-wire protocol for peripheral communication
  3. Explored C structs - How the Pico SDK abstracts hardware
  4. Mastered the macro chain - From I2C_PORT to 0x40098000
  5. Examined structs in GDB - Inspected memory layout of i2c_inst_t
  6. Analyzed instruction encodings - Both movs (8-bit) and movw (16-bit) immediates in the hex editor
  7. Patched a string literal - Changed LCD display text from "Reverse" to "Exploit"

#define vs const Summary

┌─────────────────────────────────────────────────────────────────┐
│  #define FAV_NUM 42                                             │
│  ───────────────────                                            │
│  • Text replacement at compile time                             │
│  • No memory allocated                                          │
│  • Cannot take address (&FAV_NUM is invalid)                    │
│  • In binary: value appears as immediate (movs r1, #0x2a)       │
│  • To hack: patch the instruction operand                       │
├─────────────────────────────────────────────────────────────────┤
│  const int OTHER_FAV_NUM = 1337                                 │
│  ──────────────────────────────                                 │
│  • Theoretically in .rodata, but compiler optimized it away     │
│  • Value embedded as immediate: movw r1, #0x539 (32-bit instr)  │
│  • Optimization: compiler saw &OTHER_FAV_NUM is never used      │
│  • In binary: immediate in instruction, same as #define!        │
│  • To hack: patch instruction operand (imm8 byte at offset +2)  │
└─────────────────────────────────────────────────────────────────┘

I²C Configuration Summary

┌─────────────────────────────────────────────────────────────────┐
│  I²C Setup Steps                                                │
│                                                                 │
│  1. i2c_init(i2c1, 100000)        - Initialize at 100kHz        │
│  2. gpio_set_function(pin, I2C)   - Assign pins to I²C          │
│  3. gpio_pull_up(sda_pin)         - Enable SDA pull-up          │
│  4. gpio_pull_up(scl_pin)         - Enable SCL pull-up          │
│  5. lcd_i2c_init(...)             - Initialize the device       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

The Struct Chain

┌─────────────────────────────────────────────────────────────────┐
│  I2C_PORT → i2c1 → &i2c1_inst → i2c_inst_t                      │
│                                     │                           │
│                                     ├── hw → i2c_hw_t *         │
│                                     │        └── 0x40098000     │
│                                     │                           │
│                                     └── restart_on_next (bool)  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Key Memory Addresses

Address Description
0x10000234 main() entry point
0x1000028e FAV_NUM value in instruction
0x10000296 OTHER_FAV_NUM value in instruction
0x10003ee8 "Reverse" string literal (example)
0x40098000 I²C1 hardware registers base
0x2000062C i2c1_inst struct in SRAM

Practice Exercises

Exercise 1: Change Both LCD Lines

Change "Engineering" to "Hacking!!!" (same number of characters).

Hint: Find the second string after "Reverse" in memory.

Exercise 2: Change the I²C Address

The LCD is at address 0x27. Find where this is passed to lcd_i2c_init and change it.

Warning: If you change to an invalid address, the LCD won't work!

Exercise 3: Find All String Literals

Search the binary for all readable strings. How many can you find? What do they reveal about the program?

Hint: In GDB, use x/s to search for strings in the binary, or scan through the .bin file in your hex editor.

Exercise 4: Trace the Struct Pointer

Follow the i2c1_inst pointer from the code to SRAM. What values are stored in the struct?

Hint: The first member should point to 0x40098000.

Exercise 5: Add Your Own Message

Can you make the LCD display your name? Remember the character limit!

Hint: Line 1 and Line 2 each have 16 characters maximum on a 1602 LCD.


🎓 Key Takeaways

  1. #define is text replacement - It happens before compilation, no memory used.

  2. const creates real variables - Stored in .rodata, takes memory, has an address.

  3. I²C uses two wires - SDA for data, SCL for clock, pull-ups required.

  4. Structs group related data - The SDK uses them to abstract hardware.

  5. Macros can chain - I2C_PORTi2c1&i2c1_inst → hardware pointer.

  6. ARM passes args in registers - r0-r3 for first four arguments.

  7. GDB reveals struct layouts - Examine memory to understand data organization.

  8. String hacking requires same length - Or you'll corrupt adjacent data!

  9. Constants aren't constant - With binary patching, everything can change!

  10. Compiler optimization changes code - gpio_pull_up becomes gpio_set_pulls.


📖 Glossary

Term Definition
#define Preprocessor directive for text replacement
AAPCS ARM Architecture Procedure Call Standard
const Keyword marking a variable as read-only
Forward Declaration Telling compiler a type exists before defining it
I²C Inter-Integrated Circuit - two-wire serial protocol
Immediate Value A constant embedded directly in an instruction
Open-Drain Output that can only pull low, not drive high
PCF8574 Common I²C I/O expander chip used in LCD backpacks
Preprocessor Tool that processes code before compilation
Pull-Up Resistor Resistor that holds a line HIGH by default
SCL Serial Clock - I²C timing signal
SDA Serial Data - I²C data line
Struct User-defined type grouping related variables
typedef Creates an alias for a type

🔗 Additional Resources

I²C Timing Reference

Speed Mode Maximum Frequency
Standard 100 kHz
Fast 400 kHz
Fast Plus 1 MHz

Common I²C Addresses

Device Address
PCF8574 LCD (default) 0x27
PCF8574A LCD 0x3F
DS3231 RTC 0x68
BMP280 Sensor 0x76/0x77
SSD1306 OLED 0x3C/0x3D

Key ARM Instructions for Constants

Instruction Description
movs rN, #imm Load small immediate (0-255) directly
ldr rN, [pc, #off] Load larger value from literal pool
ldr rN, =value Pseudo-instruction for loading any constant

RP2350 I²C Memory Map

Address Description
0x40090000 I²C0 hardware registers
0x40098000 I²C1 hardware registers

Remember: When you see complex nested structures in a binary, take your time to understand the hierarchy. Use GDB to examine struct layouts in memory and trace pointer chains. And always remember — even "constants" can be hacked!

Happy hacking! 🔧