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
| 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
- 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 writesdtparam=spi=on,dtparam=i2c_arm=onandenable_uart=1into/boot/firmware/config.txt(or/boot/config.txton older images). - 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-bttoconfig.txtand disable the Bluetooth modem service, so that/dev/ttyAMA0lands on GPIO 14/15. The/dev/serial0symlink always points at whichever UART is on the header. - Make sure the console is not also using that UART:
/boot/firmware/cmdline.txtmust not containconsole=serial0,115200, or the Pi will print its own boot log onto your target. - Add your user to the groups that own the device nodes:
sudo usermod -aG dialout,spi,i2c,gpio $USER, then log out and in. - Reboot and check:
ls -l /dev/serial0 /dev/spidev0.* /dev/i2c-1. - Install the Python side:
sudo apt install python3-serial python3-spidev python3-smbus i2c-tools.
UART: reading a firmware console
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
a 10-bit frame takes
#!/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
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
- Raspberry Pi documentation, Raspberry Pi hardware (GPIO header, UARTs) and configuration (
config.txt, device tree overlays). - Raspberry Pi pinout, an interactive reference for every header pin and alternate function.
- pySerial documentation and the spidev package.
- Linux kernel documentation, SPI userspace API and Implementing I2C device drivers in userspace.
- sigrok PulseView, an open-source logic analyser front end with UART, SPI and I²C protocol decoders.