Most embedded devices have a debug serial port that prints the bootloader and kernel log, and a flash chip that holds the firmware. Reading either one is the first step of any firmware analysis, and you do not need a dedicated adapter to do it: a Raspberry Pi already has a UART, an SPI master and an I²C master on its header, runs Linux, and talks Python. This post sets up that bench for devices you own: a router you bought, a development board, an old IoT gadget headed for the recycling bin.

Logic levels first

The single most common way to kill a board on a bench is to connect two things that agree on protocol but disagree on voltage. The Pi's GPIO pins are 3.3 V only and are not 5 V tolerant.

Signal standard Logic high Logic low Where you meet it Connect to the Pi?
3.3 V CMOS 3.3 V 0 V most ARM SoCs, ESP32, modern routers directly
1.8 V CMOS 1.8 V 0 V phones, some SSD and camera controllers through a level shifter
5 V TTL 5 V 0 V older microcontrollers, Arduino Uno through a level shifter or divider
RS-232 −3 to −15 V +3 to +15 V DB-9 serial ports, industrial equipment never directly; use a MAX3232 adapter

Two more rules. Always connect ground first, so that both boards share a reference before any signal pin touches. And do not power the target from the Pi's 3.3 V pin unless you have checked its current budget; the target has its own supply, and the Pi's rail is for the flash-chip trick described later, not for running a router.

DANGER

RS-232 levels will destroy a Pi GPIO instantly, and 5 V on a 3.3 V pin may not fail immediately but will degrade the SoC. Before connecting any unknown header, measure each pin against ground with a multimeter while the target is running: the TX pin idles at the logic-high voltage, which tells you both which pin is TX and what level the target uses.

Wiring

Wiring a Raspberry Pi to a UART console and an SPI flash Pi 40-pin header (top 26 pins shown, 3.3 V logic) 1 3V3 2 5V 3 SDA1 4 5V 5 SCL1 6 GND 7 GPIO4 8 TXD0 9 GND 10 RXD0 11 GPIO17 12 GPIO18 13 GPIO27 14 GND 15 GPIO22 16 GPIO23 17 3V3 18 GPIO24 19 MOSI 20 GND 21 MISO 22 GPIO25 23 SCLK 24 CE0 25 GND 26 CE1 Target board UART header router / dev board / IoT device you own GND RX (target in) TX (target out) VCC leave VCC unconnected: target has its own supply to flash VCC SPI NOR flash (SOIC-8) e.g. W25Qxx, 3.3 V part 1 CS# 2 DO (MISO) 3 WP# 4 GND 8 VCC 7 HOLD# 6 CLK 5 DI (MOSI) 3V3 CE0 → CS#, SCLK → CLK, MOSI → DI, MISO ← DO WP# and HOLD# tied to VCC so the chip is not write-protected or paused 3V3 from the Pi only when the chip is out of circuit (or the board is unpowered)
Left: the first 26 header pins. Centre: a target's UART header with TX and RX crossed and VCC left alone. Right: an 8-pin SPI NOR flash wired to the Pi's SPI0 bus.
Pi pin (physical) GPIO / function UART target SPI flash (SOIC-8) I²C device
1 3V3 leave unconnected pin 8 VCC (see note) VCC if 3.3 V
3 GPIO2, SDA1     SDA
5 GPIO3, SCL1     SCL
6 GND GND   GND
8 GPIO14, TXD0 target RX    
10 GPIO15, RXD0 target TX    
19 GPIO10, SPI0 MOSI   pin 5 DI  
21 GPIO9, SPI0 MISO   pin 2 DO  
23 GPIO11, SPI0 SCLK   pin 6 CLK  
24 GPIO8, SPI0 CE0   pin 1 CS  
25 GND   pin 4 GND  

The flash chip's WP (pin 3) and HOLD (pin 7) inputs are active-low and should be tied to VCC so the chip is neither write-protected nor paused. Powering the chip from the Pi is only safe when the chip is out of circuit (desoldered, or in a programmer socket) or when the target board is unpowered and known to tolerate back-powering through the flash rail; otherwise you are feeding 3.3 V into a board full of other components, with results ranging from a confused SoC that holds the bus to a damaged regulator. Keep wires short, under 15 cm, for SPI above a few MHz.

Enabling the buses

  1. Run sudo raspi-config, go to Interface Options, and enable SPI, I2C and Serial Port (answer No to the login shell over serial, Yes to the hardware being enabled). This writes dtparam=spi=on, dtparam=i2c_arm=on and enable_uart=1 into /boot/firmware/config.txt (or /boot/config.txt on older images).
  2. On a Pi 3, 4 or Zero W the full PL011 UART is normally assigned to Bluetooth and the GPIO pins get the less capable mini UART. For a reliable console at odd baud rates, add dtoverlay=disable-bt to config.txt and disable the Bluetooth modem service, so that /dev/ttyAMA0 lands on GPIO 14/15. The /dev/serial0 symlink always points at whichever UART is on the header.
  3. Make sure the console is not also using that UART: /boot/firmware/cmdline.txt must not contain console=serial0,115200, or the Pi will print its own boot log onto your target.
  4. Add your user to the groups that own the device nodes: sudo usermod -aG dialout,spi,i2c,gpio $USER, then log out and in.
  5. Reboot and check: ls -l /dev/serial0 /dev/spidev0.* /dev/i2c-1.
  6. Install the Python side: sudo apt install python3-serial python3-spidev python3-smbus i2c-tools.

UART: reading a firmware console

What the wire actually carries UART, 8N1: the byte 0x41 ('A' = 0100 0001) leaves LSB first 3.3 V 0 V idle start d0=1 d1=0 d2=0 d3=0 d4=0 d5=0 d6=1 d7=0 stop idle No clock line: both ends agree on the baud rate in advance. At 115200 baud one bit lasts 8.68 µs, a 10-bit frame 86.8 µs, so the link tops out at 11 520 bytes/s. A receiver with the wrong baud rate sees garbage, not silence. SPI mode 0 (CPOL=0, CPHA=0): the master clocks one byte out and one byte in at the same time CS# SCLK MOSI MISO 10011111 Master sends 0x9F (JEDEC "read ID"); data is sampled on the rising edge. The flash answers on MISO during the next clocks. Everything is synchronous to SCLK, so SPI tolerates 10 MHz and more where UART stops at a few hundred kbaud.
Top: a UART frame has no clock, so both ends must already agree on the baud rate. Bottom: SPI ships a clock on its own wire and moves a byte in each direction per eight pulses.

UART is asynchronous. There is no clock wire; the line idles high, a start bit pulls it low for exactly one bit time, eight data bits follow least-significant first, and a stop bit returns the line high. At the usual console rate of 115 200 baud a bit lasts

tbit=1115200 s−1≈8.68 μs,

a 10-bit frame takes 86.8 μs, and the link carries at most 115200/10=11520 bytes per second. If the receiver's clock disagrees by more than a few percent, bits are sampled in the wrong cells and you see line noise instead of text. That is the usual symptom of a wrong baud rate; the fix is to try the common ones (115200, 57600, 38400, 9600, and 1 500 000 on some newer SoCs), or to measure the shortest pulse with a logic analyser and take its reciprocal.

#!/usr/bin/env python3
"""Log a target's serial console, printing it live and saving it to a file."""
import sys
import serial

port = serial.Serial(
    "/dev/serial0", baudrate=115200,
    bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE,
    timeout=0.5,          # read() returns whatever arrived within half a second
)

with open("console.log", "ab") as log:
    try:
        while True:
            chunk = port.read(4096)
            if chunk:
                sys.stdout.buffer.write(chunk)
                sys.stdout.flush()
                log.write(chunk)
    except KeyboardInterrupt:
        pass

Power the target with the script running and the bootloader banner is the first thing to appear. What you are reading for: the bootloader and its version, whether it pauses for a key press (an interactive bootloader is an easy way onto the device, and whether it is locked down is a finding in itself), the kernel command line, the partition layout printed when the flash driver probes, and whether a login prompt or a shell appears at the end. If the output is readable but you cannot type into it, the TX wire is wrong or the target's RX pin is disabled in software; both are common.

For an interactive session, screen /dev/serial0 115200 or minicom -D /dev/serial0 -b 115200 is more convenient than Python. Press Ctrl-A then k to leave screen.

SPI: dumping a flash chip

SPI is synchronous: the master drives SCLK and CS, data goes out on MOSI and comes back on MISO at the same time. Every SPI NOR flash speaks the same few commands, and the first one to send is 0x9F, read JEDEC ID, which returns the manufacturer and a two-byte device code that encodes the capacity.

#!/usr/bin/env python3
"""Identify an SPI NOR flash and dump it with the 0x03 READ command."""
import spidev

spi = spidev.SpiDev()
spi.open(0, 0)                   # /dev/spidev0.0 : bus 0, chip-select 0
spi.max_speed_hz = 4_000_000     # start slow; raise once the dump verifies
spi.mode = 0                     # CPOL=0, CPHA=0, what NOR flash expects

mfr, mem_type, cap = spi.xfer2([0x9F, 0, 0, 0])[1:]
size = 1 << cap                  # capacity code is log2(bytes) for most vendors
print(f"JEDEC ID {mfr:02x} {mem_type:02x} {cap:02x}  ->  {size // (1024*1024)} MiB")

CHUNK = 1024                     # spidev's default transfer limit is 4096 bytes
with open("flash.bin", "wb") as out:
    for addr in range(0, size, CHUNK):
        cmd = [0x03, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF]
        out.write(bytes(spi.xfer2(cmd + [0] * CHUNK)[4:]))

A manufacturer byte of 0xEF is Winbond, 0xC2 Macronix, 0x20 Micron or XMC, 0x1C EON; 0x00 or 0xFF for all three bytes means no chip answered, which is wiring, chip select, or a target that is holding the bus. For a 16 MiB chip at 8 MHz the raw transfer time is

16×220×8 bits8×106 bits/s≈16.8 s,

and roughly double that in practice because of per-transfer overhead in the kernel driver. Always dump twice and compare the two files; a single bad wire produces identical-looking garbage, while two matching dumps are almost certainly real. For anything beyond this script, flashrom -p linux_spi:dev=/dev/spidev0.0,spispeed=8000 -r flash.bin knows hundreds of chips, handles 4-byte addressing for parts over 16 MiB, and verifies after writing.

Where the dump goes next

Once flash.bin is on disk the hardware part is over. binwalk flash.bin finds the bootloader, kernel and filesystem images by their signatures; strings -n 8 flash.bin | less turns up version strings, URLs and the occasional credential; and extracting a SquashFS or JFFS2 root filesystem gives you /etc/passwd, init scripts and the web server's CGI binaries to read. Compare what you find against what the console printed: a bootloader that claimed to verify the kernel but whose public key is nowhere in the image is a story worth following.

I²C: a quick scan

I²C is the third bus on the header, used for sensors, EEPROMs and power-management chips. Addresses are seven bits, so a bus has at most 112 usable devices and scanning it takes a second:

i2cdetect -y 1          # bus 1 is pins 3 and 5
#      0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
# 00:                         -- -- -- -- -- -- -- --
# ...
# 50: 50 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
i2cdump -y 1 0x50       # 0x50 is the conventional address of a 24Cxx EEPROM

An EEPROM at 0x50 on a consumer device frequently holds board identification, calibration data, or in the worst cases a configuration blob that the firmware trusts without checking. The smbus2 Python module reads it in a dozen lines, following the same pattern as the SPI script above.

Keeping notes

Hardware work is unforgiving of missing records. For every target I keep a page with a photograph of the header, the measured voltages per pin, the baud rate that worked, the JEDEC ID and dump hashes, and the config-file changes made on the Pi. The next device with the same SoC will take ten minutes instead of an afternoon.

Further reading