Self-relocating UART command agent for the Raspberry Pi. Flash this once from an SD card; after that it stays resident and the host drives it over serial — upload firmware to memory and jump to it, or read/write/list files on the SD card — instead of rewriting the SD card for every build.
Supported boards:
- Pi 2 Model B rev 1.2 and Pi 3 (BCM2836/BCM2837) — the
bcm2837builds. - Pi 4 (BCM2711) — the
bcm2711builds, which move the peripheral memory map and PAC throughrpi-hal's feature of the same name.
Both in either execution state: AArch32 (kernel7.img) and AArch64
(kernel8.img).
A green CI badge means it compiles, and nothing more. Every check that runs there is either a compile-time one or a protocol test against a fake device on a pty; whether a transfer survives a real 1.5Mbaud link is established on hardware, not in CI.
Depends on rpi-hal for GPIO/UART
and SD/FAT access. The FAT filesystem layer is
embedded-sdmmc, on top of
rpi-hal's SdCard block-device adapter.
firmware/— the loader itself, ano_stdpackage built for a bare metal ARM target. It is never published to crates.io: what it produces is a raw image to copy onto an SD card, not somethingcargo installcan build.cli/— the host-side driver that talks to a running loader over serial. This is the package published to crates.io asrpi-loader, and the only part of the projectcargo installcan build.ota/— the over-the-air update bundle format, published separately asrpi-loader-ota. Ano_stdlibrary rather than a tool: the CLI uses it to pack a bundle, and a board's own firmware links it to validate and install one. It carries its own version, because its consumers are firmware projects elsewhere and there is no reason a renamed CLI flag should bump their dependency.- The repository root has no cargo configuration on purpose. Cargo
discovers
.cargo/config.tomlby walking up from the working directory, so a root-level one naming a bare metal target would be inherited by host-side tooling beside it, which then fails to build.
After a handshake, the loader services a command at a time and stays
resident for the next — so a single flashed loader backs any number of
host operations. Each rpi-loader subcommand names the serial device
with --device, then takes its own arguments:
mem-write <addr> <file>— write<file>to memory at<addr>(hex or decimal, e.g.0x8000), checksummed, no jump.exec <addr>— jump to<addr>. Add--terminalto stay attached as a passthrough terminal afterward.sd-list [path]— list a directory on the SD card (default/; nested paths like/boot/overlayssupported).sd-read <remote> <local>— copy<remote>(a path on the SD card) to the<local>file on the host.sd-write <local> <remote>— copy the<local>host file to<remote>on the SD card, creating or truncating it.sd-delete <remote>— delete<remote>from the SD card.sd-mkdir <remote>— create the directory<remote>on the SD card (a single level; the parent directories must already exist).eeprom-read <local>— copy an EEPROM on the HAT ID bus (GPIO0/1) to the<local>file. With no--length, reads the image length out of the HAT header first.eeprom-write <local>— program<local>into that EEPROM, verifying every page as it goes. See below.boot <image>— convenience:mem-writethe image,execits load address, then act as a terminal. Requires--load-addr(0x8000for a 32-bitkernel7.img,0x80000for a 64-bitkernel8.img).terminal— bidirectional passthrough terminal, with no handshake (for watching and driving an already-running kernel). What the device sends is printed; what you type is sent. Ctrl-] exits — every other key, Ctrl-C included, goes to the device, which is what lets you interrupt something running there.list— list the host's USB serial ports (--allfor every port), to find what to pass to--device.bundle [manifest]— pack an over-the-air update bundle described by abundle.toml, and with--upload <url>post it to a running board. See below.
list and bundle are the two subcommands that neither open a port nor
need one.
The bulk commands (mem-write, sd-read, sd-write, boot,
eeprom-read, eeprom-write) also take
--baud to pick the transfer rate (see below). Booting an uploaded
image is just mem-write + exec; boot chains them plus the terminal
to reproduce the classic one-shot upload flow.
The wire protocol is documented on the device side in
firmware/src/main.rs and on the host side in cli/src/link.rs.
bundle is the odd one out: it speaks no serial protocol at all. A board
running its own firmware — not this loader — can be sent a bundle over
the network and install it on itself, and the container that carries one
is rpi-loader-ota, the package in ota/. It is here because a wire
format needs exactly one implementation, and this is where the half that
builds one belongs.
A project describes its card in a bundle.toml beside its Makefile:
magic = "WATR" # four ASCII bytes, matching the firmware's
name = "water" # -> target/water.bundle
[kernel]
source = "target/kernel7.img"
dest = "KERNEL7.IMG"
[[files]]
source = "www" # a directory: everything under it, recursively
dest = "WWW"
[[files]]
source = "vendor/start.elf"
dest = "START.ELF"
role = "firmware" # file (default), firmware, or config# Pack it.
rpi-loader bundle # or: rpi-loader bundle path/to/bundle.toml
# Pack it and send it to a board, which answers with what the update
# cost its card.
rpi-loader bundle --upload http://10.0.0.5/api/v1/ota
# Pack it and write its contents onto a card in a reader, for an update
# a board cannot be sent over the network.
rpi-loader bundle --sdcard /media/you/boot--sdcard exists for the update that cannot arrive the usual way: a build
that changes the bundle format the running firmware reads, or one that
broke networking, or a board that is not on the network yet. It unpacks
the bundle it just built rather than writing the source files again, so a
card written by hand and a board updated over HTTP carry provably the same
bytes.
It writes only what the bundle carries, which is deliberately less than a card needs to boot: a manifest describes what an update replaces, so the Raspberry Pi firmware, and any settings file a project leaves out on purpose so updates do not reset it, are not written. The directory has to exist already — a mount point does, and a typo does not.
Notes worth knowing before writing one:
magiclives in the manifest and has no command-line override. It is the only thing stopping one board's update being installed on another, and a flag that could change it would be a way to build a bundle carrying the wrong one. A project shipping both a 32- and a 64-bit build wants two magics, since nothing else in a bundle says which architecture its kernel is for.- Paths are relative to the manifest, not to where the command runs.
- A directory source is packed recursively and sorted, so the same tree always produces the same bytes. Anything beginning with a dot is skipped at every level.
- A
start*.elfmust be packed with its matchingfixup*.dat. They are released as a pair and a mismatched one does not boot, so the packer refuses rather than letting a board find out. --uploadis plain HTTP, with no TLS in the dependency tree. The endpoint is a board on a local network.
The loader builds for both execution states:
- AArch32 (
kernel7.img, loads at0x8000) — the default, and the only option on BCM2836 boards (Cortex-A7, 32-bit only). - AArch64 (
kernel8.img, loads at0x80000) — for BCM2837 (Cortex-A53) and BCM2711 (Cortex-A72) boards, selected byarm_64bit=1inconfig.txt.
The upload protocol, chunking, checksums, and UART driver are shared;
only the boot stub (firmware/src/boot.s vs boot64.s) and load address
differ. A loaded kernel runs in the same execution state as the
loader, so a kernel8.img loader hands off to AArch64 kernels and a
kernel7.img loader to AArch32 kernels — pick the build matching the
kernels you intend to upload.
- GPU firmware loads this at the kernel load address for the selected
execution state —
0x8000for AArch32 (kernel7.img) or0x80000for AArch64 (kernel8.img). The steps below describe the 32-bit case; the 64-bit path is identical with those addresses swapped andboot64.sin place ofboot.s. - Its own
_start(infirmware/src/boot.s, not the shared one fromrpi-hal— see below) immediately copies the whole running image to0x00200000and jumps into that copy, since a kernel it may be asked to load also expects to run at0x8000and can't be safely written there while this loader is still executing there. The stack is placed above the relocated region by the linker script (the__stack_topsymbol), so it clears the loader's own code and data no matter how large the image grows. - From the relocated copy: brings up UART0, then blocks waiting for
the host's
HELLO— so the Pi can be powered on before the host tool starts, or vice versa. It answers with anACK+ version byte. - Enters a command loop, servicing one command at a time and returning
for the next.
mem-writereads a header (total_size,chunk_size,load_addr,overall_checksum), sanity-checksload_addragainst the ~2MB gap before0x00200000, receives the payload as CRC-checked chunks, and re-verifies the whole thing. Thesd-*commands bring the card up on demand and read/write/list files on the FAT boot partition. - On
exec, jumps to the requested address — running a freshly uploaded kernel's own_startexactly as if it had booted from an SD card. This is the only command that doesn't return to the loop.
Because the loader outlives any single host invocation, each rpi-loader
subcommand is its own process that reconnects with a fresh HELLO; the
command loop re-answers it, so the Pi is power-cycled only to re-flash
the loader itself, never between commands.
This is single-core work throughout: the GPU firmware only ever releases core 0 to a loaded image, holding cores 1-3 in its own stub until they're explicitly woken. A multi-core kernel loaded this way wakes them itself, straight out of that firmware stub, exactly as it would if the firmware had loaded it directly — so the loader has nothing to do for the other cores.
rpi-hal provides a standard (non-relocating) _start by default,
gated behind its rt feature. This loader needs a fundamentally
different boot sequence, so it depends on rpi-hal with
default-features = false and supplies its own via
firmware/src/boot.s.
Get an image either way — download a released one, or build it — then copy it to the SD card as described under "Onto the card" below.
Each release carries four images, one per board and execution state. Pick the one matching yours:
| Asset | Board | Execution state |
|---|---|---|
rpi-loader-<version>-bcm2837-kernel7.img |
Pi 2 v1.2, Pi 3 | AArch32 |
rpi-loader-<version>-bcm2837-kernel8.img |
Pi 2 v1.2, Pi 3 | AArch64 |
rpi-loader-<version>-bcm2711-kernel7.img |
Pi 4 | AArch32 |
rpi-loader-<version>-bcm2711-kernel8.img |
Pi 4 | AArch64 |
VERSION=0.1.0
BASE=https://github.com/joeferner/rpi-loader/releases/download/v$VERSION
curl -LO $BASE/rpi-loader-$VERSION-bcm2837-kernel8.img
curl -LO $BASE/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMSThe assets carry the version and chip in their names because a release
page cannot hold four files all called kernel7.img — but the Pi's
firmware loads only the bare names, so rename on the way to the card:
cp rpi-loader-$VERSION-bcm2837-kernel8.img /path/to/boot/kernel8.imgFor a 32-bit (AArch32) loader:
make build-bcm2837 # -> firmware/target/kernel7.imgFor a 64-bit (AArch64) loader:
make build64-bcm2837 # -> firmware/target/kernel8.imgBoth have -bcm2711 counterparts for Pi 4 boards. Run make from the
repository root; it drives cargo inside firmware/, which is where the
bare metal target and toolchain are pinned. Everything builds on stable.
Copy the image to an SD card's boot partition (FAT32, marked bootable),
named kernel7.img or kernel8.img, alongside bootcode.bin,
start.elf, and fixup.dat from the
raspberrypi/firmware
repository's boot/ directory.
- For
kernel7.img, noconfig.txtis needed — the firmware defaults to loadingkernel7.imgon multicore ARMv7 boards. - For
kernel8.img, add aconfig.txtcontainingarm_64bit=1so the firmware boots the board in AArch64 and loadskernel8.img.
By default, /dev/ttyUSB*//dev/serial/by-id/... is owned by root
plus a system group (uucp, dialout, etc. depending on distro) —
without setup, both rpi-loader and a plain terminal (picocom, etc.)
need sudo to open it. Install the udev rule here once instead:
sudo cp udev/60-ftdi-serial.rules /etc/udev/rules.d/
sudo udevadm control --reload
sudo udevadm triggerIf it still doesn't work, create the group and add yourself to it, then fully log out and back in (group membership doesn't apply to already-running sessions):
sudo groupadd --system plugdev
sudo usermod -aG plugdev $USERUnplug and replug the cable after either step.
The host tool is the rpi-loader CLI in cli/:
cargo install rpi-loader # or: cd cli && cargo build --releaseThe serial device is given with --device, accepted on either side of
the subcommand:
# Which cable is it?
rpi-loader list # -> /dev/ttyUSB0 FTDI TTL232R-3V3 0403:6001 serial ...
DEV=/dev/serial/by-id/usb-FTDI_TTL232R-3V3_*
# Upload a kernel and boot it (the classic flow), then act as a terminal
rpi-loader --device $DEV boot --load-addr 0x8000 path/to/kernel7.img
# Files on the SD card's FAT boot partition
rpi-loader --device $DEV sd-list /
rpi-loader --device $DEV sd-read /config.txt ./config.txt
rpi-loader --device $DEV sd-write ./app.bin /APP.BIN
rpi-loader --device $DEV sd-delete /APP.BIN
rpi-loader --device $DEV sd-mkdir /LOGS
# The board's ID EEPROM, on the HAT bus (GPIO0/1)
rpi-loader --device $DEV eeprom-write ./myboard.eep
rpi-loader --device $DEV eeprom-read ./readback.eep
# Lower-level memory control
rpi-loader --device $DEV mem-write 0x8000 path/to/kernel7.img
rpi-loader --device $DEV exec 0x8000 --terminal
# Watch a running kernel (no handshake)
rpi-loader --device $DEV terminalboot has no default load address: pass --load-addr 0x8000 for a
32-bit kernel7.img or --load-addr 0x80000 for a 64-bit
kernel8.img. The device validates the address against its own memory
map, so a mismatch fails loudly rather than landing an image somewhere
harmful.
The handshake and terminal always run at 115200; the bulk transfers
(boot, mem-write, sd-read, sd-write) negotiate up to a faster
baud (1500000 by default) so larger transfers move quickly, then always
drop back to 115200 before returning — so the loader is left listening
at the rate the next invocation (and a booted kernel) comes up at. Pass
--baud 115200 to disable the speedup, or a lower value if a marginal
cable or long wiring corrupts chunks faster than the retry loop can
recover:
rpi-loader --device $DEV boot --load-addr 0x8000 --baud 921600 kernel7.imgFor a 64-bit loader, pass the AArch64 load address so the image lands
where kernel8.img-style binaries expect to run:
rpi-loader --device $DEV boot --load-addr 0x80000 path/to/kernel8.imgThe sd-* commands operate on the first MBR partition (the Pi's boot
FAT partition on a stock card) and support nested paths. Large transfers
are slow — see "Limitations" below.
firmware/scripts/test_payload64.s is a tiny AArch64 payload that just
prints a line over UART0 and parks — enough to confirm the whole 64-bit
chain (firmware → AArch64 boot stub → relocation → receive → jump)
without a full 64-bit kernel:
make build64-bcm2837 # flash firmware/target/kernel8.img (with arm_64bit=1)
firmware/scripts/build_test_payload.sh # -> firmware/target/test_payload64.bin
rpi-loader --device <device> boot --load-addr 0x80000 \
firmware/target/test_payload64.binSeeing [rpi-loader: 64-bit payload running] in the passthrough
terminal confirms the handoff.
eeprom-write and eeprom-read reach a serial EEPROM on BSC0's GPIO0/1
routing — ID_SD/ID_SC, pins 27 and 28 of the 40-pin header — which is
where a board keeps its own identity: the HAT specification's image
(vendor, product, UUID, GPIO map, an optional device tree overlay), and
whatever a design adds beside it, such as per-unit calibration. The
firmware reads that EEPROM early in boot and then leaves the bus alone,
so the loader has it to itself.
# Program an image produced by Raspberry Pi's `eepmake`, then read it back
rpi-loader --device $DEV eeprom-write ./myboard.eep
rpi-loader --device $DEV eeprom-read ./readback.eep
cmp ./myboard.eep ./readback.eepFour things worth knowing:
- The write is verified on the device. Every page is read back after
it is programmed, and a mismatch fails the command. This is not
belt-and-braces: a write-protected part (
WPtied high, which on a board that puts write protect on a solder jumper is the default state) acknowledges every byte and stores none, so without the read-back a write to a protected EEPROM would report a clean success. --page-sizemust not exceed the part's own page. The default, 32 bytes, is the page of a 24C32 — the smallest part the HAT specification allows — and divides every larger part's page, so it is always safe. A 24C256's page is 64 bytes and programs in half the time. Naming one too large corrupts data rather than failing: a page write that runs past the boundary wraps to the start of the same page instead of carrying.- Addressing is two bytes, so 64 KiB is the ceiling, and parts below the 24C32 (which address with one byte) are not supported.
--addressdefaults to0x50, what the HAT specification assigns the ID EEPROM. Another part on the same bus can be reached by naming its address.
Producing the .eep image is a separate job, and the Raspberry Pi
hats repository's eepmake is the tool for it: it turns a text
settings file into the binary this command programs.
sd-read/sd-writeare slow, and noticeably so on files of any size. Two independent reasons, neither of them a missing driver feature.rpi-haldoes multi-block transfers (CMD18/CMD25with an auto-CMD12stop) and itsembedded-sdmmcadapter uses them whenever it is handed more than one block — butembedded-sdmmc's block cache holds exactly one block, so through the filesystem it never is, and every 512 bytes costs its own SD command. Separately, the transfer is lockstep: the device defers each chunk'sOKuntil it has finished writing that chunk. That deferral is not an oversight — it is the flow control keeping the UART's 16-byte RX FIFO from overflowing — but it leaves the link idle for the whole of every SD write.- Single-core throughout. The loader never touches cores 1-3; the GPU firmware holds them in its own stub, and a multicore kernel loaded this way wakes them itself exactly as it would if the firmware had loaded it directly.
- A loaded kernel runs in the same execution state as the loader, so a 32-bit loader cannot boot a 64-bit kernel or the reverse. Flash the build matching the kernels you intend to upload.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.