Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ only one of them says nothing useful about compatibility.

## [Unreleased]

### Added

- **`eeprom-write` and `eeprom-read`**: program and dump a serial EEPROM
on the HAT ID bus (BSC0 on GPIO0/1, `ID_SD`/`ID_SC`), which is where a
board's identity lives — the HAT specification's image, and whatever a
design puts beside it. `eeprom-write` takes the `.eep` file `eepmake`
produces; `eeprom-read` with no `--length` reads the image length out
of the HAT header first, rather than dumping the whole address space.
Both take `--address` (default `0x50`) and `--offset`; the write also
takes `--page-size` (default 32, safe for every part from the 24C32 up).

The device reads every page back after programming it and fails the
command on a mismatch, because a write-protected part acknowledges
every byte and stores none — without the read-back, writing to one
would report success and change nothing.

New wire commands `EEPROM_READ` (9) and `EEPROM_WRITE` (10), and error
codes 7 (I2C transfer failed), 8 (read-back mismatch), 9 (a request
outside what the device will address) and 10 (the part never answered
the read-back). A loader
predating them answers an unknown command byte with `FAIL` and then
reads the arguments that followed as further commands, answering each
the same way: the CLI reports the command as failed, and the next
invocation's handshake clears what is left. Both halves ship as one
release, so that combination should only ever be a stale flash.

### Fixed

- Terminal mode no longer puts the invoking terminal into raw mode when
Expand Down
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ with `--device`, then takes its own arguments:
- `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-write` the image, `exec` its load
address, then act as a terminal. Requires `--load-addr` (`0x8000` for
a 32-bit `kernel7.img`, `0x80000` for a 64-bit `kernel8.img`).
Expand All @@ -73,7 +78,8 @@ with `--device`, then takes its own arguments:
find what to pass to `--device`. The only subcommand that neither
opens a port nor needs one.

The bulk commands (`mem-write`, `sd-read`, `sd-write`, `boot`) also take
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.
Expand Down Expand Up @@ -262,6 +268,10 @@ 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
Expand Down Expand Up @@ -317,6 +327,47 @@ rpi-loader --device <device> boot --load-addr 0x80000 \
Seeing `[rpi-loader: 64-bit payload running]` in the passthrough
terminal confirms the handoff.

### The ID EEPROM

`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.

```sh
# 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.eep
```

Four 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 (`WP` tied 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-size` must 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.
- **`--address` defaults to `0x50`**, 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.

## Limitations

- **`sd-read`/`sd-write` are slow**, and noticeably so on files of any
Expand Down
23 changes: 22 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ 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
Expand Down Expand Up @@ -84,7 +88,8 @@ caller state their intent about.

The handshake and terminal always run at 115200, matching the loader's
own UART bring-up and a freshly booted kernel's default. The bulk
transfers (`boot`, `mem-write`, `sd-read`, `sd-write`) negotiate up to
transfers (`boot`, `mem-write`, `sd-read`, `sd-write`, `eeprom-read`,
`eeprom-write`) negotiate up to
`--baud` (1500000 by default) and always drop back to 115200 before
returning, so the next invocation — and any kernel that gets booted —
finds the link at the rate it expects.
Expand All @@ -97,6 +102,22 @@ can recover:
rpi-loader --device $DEV boot --load-addr 0x8000 --baud 921600 kernel7.img
```

## The ID EEPROM

`eeprom-write` and `eeprom-read` reach a serial EEPROM on the HAT ID bus
(GPIO0/1, header pins 27 and 28) — where a board keeps its own identity,
including the image Raspberry Pi's `eepmake` produces from a settings
file. The device verifies the write page by page: a write-protected part
acknowledges every byte and stores none, so nothing but a read-back can
tell a real write from that.

`--page-size` defaults to 32 bytes, the page of the smallest part the HAT
specification allows and a divisor of every larger part's page. A 24C256
takes `--page-size 64` and programs in half the time; naming one larger
than the part's own page corrupts data rather than failing, because a
page write that overruns wraps to the start of the same page. Addressing
is two bytes, so 64 KiB is the ceiling.

## Serial port access without sudo

`/dev/ttyUSB*` is usually owned by `root` plus a system group (`uucp`,
Expand Down
87 changes: 83 additions & 4 deletions cli/src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
//! host->device chunks; then a final OK / FAIL+errcode.
//! SD_DELETE [path] -> OK / FAIL+errcode.
//! SD_MKDIR [path] -> OK / FAIL+errcode.
//! EEPROM_READ [addr u8][offset,length u32 LE] -> OK / FAIL+errcode; on
//! OK a device->host stream.
//! EEPROM_WRITE [addr u8][offset,total,chunk,page u32 LE] -> OK /
//! FAIL+errcode; on OK host->device chunks; then a final
//! OK / FAIL+errcode.
//!
//! A path is a u16 LE length followed by that many UTF-8 bytes. A
//! device->host stream is [total_len,chunk_size u32 LE] then chunks
Expand Down Expand Up @@ -76,6 +81,10 @@ const CMD_SD_WRITE: u8 = 6;
const CMD_SD_DELETE: u8 = 7;
/// Create a directory on the SD card.
const CMD_SD_MKDIR: u8 = 8;
/// Stream bytes out of an EEPROM on the HAT ID bus.
const CMD_EEPROM_READ: u8 = 9;
/// Program an image into an EEPROM on the HAT ID bus.
const CMD_EEPROM_WRITE: u8 = 10;

/// Baud the handshake and terminal always run at (matches the device's
/// UART bring-up and a loaded kernel's default). The bulk transfers
Expand All @@ -94,6 +103,13 @@ const MAX_CHUNK_RETRIES: u32 = 5;
/// power-up poll), so an `sd-*` command's first status byte gets several
/// timeout windows.
const SD_STATUS_ATTEMPTS: u32 = 3;
/// Timeout windows an `eeprom-write` chunk's ACK gets. A chunk is
/// programmed a page at a time and every page costs an internal write
/// cycle of a few milliseconds, so a full 4 KiB chunk takes seconds on the
/// device — far longer than any other command spends between a chunk and
/// its ACK, and the ACK is the flow control that keeps the transfer
/// lockstep.
const EEPROM_STATUS_ATTEMPTS: u32 = 4;
/// How long the terminal keeps printing what the device already sent
/// after the exit key, before giving up on a device that never pauses.
const DRAIN_LIMIT: Duration = Duration::from_millis(500);
Expand All @@ -108,6 +124,12 @@ fn err_name(code: u8) -> String {
4 => "directory listing too large".into(),
5 => "bad path".into(),
6 => "write failed".into(),
7 => "I2C transfer failed (nothing answering at that address, or the bus is held)".into(),
8 => "read-back did not match what was written (is the EEPROM write-protected?)".into(),
9 => "the device will not address that range (offset + length past 64 KiB, \
or an implausible page size)"
.into(),
10 => "a page was written, but the part never answered the read that checks it".into(),
other => format!("error code {other}"),
}
}
Expand Down Expand Up @@ -294,7 +316,13 @@ impl Link {
/// the host resends the same chunk on FAIL, up to
/// [`MAX_CHUNK_RETRIES`] — the self-healing transfer the loader's
/// protocol exists to provide.
fn send_chunked(&mut self, data: &[u8]) -> Result<()> {
///
/// `ack_attempts` is how many timeout windows that per-chunk ACK gets.
/// One is right where the device only has to store the chunk; a
/// command that does slow work per chunk before ACKing (programming an
/// EEPROM page by page) needs more, or the host starts resending
/// chunks the device is still working through.
fn send_chunked(&mut self, data: &[u8], ack_attempts: u32) -> Result<()> {
let total = data.len();
for (offset, chunk) in (0..).step_by(CHUNK_SIZE).zip(data.chunks(CHUNK_SIZE)) {
let mut packet = crc32(chunk).to_le_bytes().to_vec();
Expand All @@ -304,7 +332,7 @@ impl Link {
for attempt in 1..=MAX_CHUNK_RETRIES {
self.check_interrupt()?;
self.write_all(&packet)?;
if self.read_status(1)? == Some(OK) {
if self.read_status(ack_attempts)? == Some(OK) {
sent = true;
break;
}
Expand Down Expand Up @@ -397,7 +425,7 @@ impl Link {
bail!("device rejected header (bad size/address?)");
}
eprintln!("Sending {} bytes to {addr:#x}...", data.len());
self.send_chunked(data)?;
self.send_chunked(data, 1)?;
if self.read_status(1)? != Some(OK) {
bail!("device reported overall checksum mismatch");
}
Expand Down Expand Up @@ -471,7 +499,7 @@ impl Link {
bail!("sd-write failed: {reason}");
}
eprintln!("Sending {} bytes -> {remote}...", data.len());
self.send_chunked(data)?;
self.send_chunked(data, 1)?;
if self.read_status(SD_STATUS_ATTEMPTS)? != Some(OK) {
let reason = self.fail_reason()?;
bail!("sd-write did not commit: {reason}");
Expand All @@ -490,6 +518,57 @@ impl Link {
self.start_sd_command(CMD_SD_MKDIR, remote, "sd-mkdir")
}

/// Reads `length` bytes from `offset` in the EEPROM at the 7-bit
/// I2C `address` on the HAT ID bus.
pub fn eeprom_read(&mut self, address: u8, offset: u32, length: u32) -> Result<Vec<u8>> {
let mut packet = vec![CMD_EEPROM_READ, address];
packet.extend_from_slice(&offset.to_le_bytes());
packet.extend_from_slice(&length.to_le_bytes());
self.write_all(&packet)?;
if self.read_status(EEPROM_STATUS_ATTEMPTS)? != Some(OK) {
let reason = self.fail_reason()?;
bail!("eeprom-read failed: {reason}");
}
self.recv_chunked()
}

/// Programs `data` into the EEPROM at the 7-bit I2C `address` on the
/// HAT ID bus, starting at `offset`.
///
/// `page_size` is the part's page size: the device writes a page at a
/// time and a write crossing a page boundary wraps within the page
/// instead of carrying, so naming it too large corrupts data rather
/// than failing. The device reads every page back after programming
/// it, so a success here means the bytes are actually in the part.
pub fn eeprom_write(
&mut self,
address: u8,
offset: u32,
page_size: u32,
data: &[u8],
) -> Result<()> {
let mut packet = vec![CMD_EEPROM_WRITE, address];
packet.extend_from_slice(&offset.to_le_bytes());
packet.extend_from_slice(&(data.len() as u32).to_le_bytes());
packet.extend_from_slice(&(CHUNK_SIZE as u32).to_le_bytes());
packet.extend_from_slice(&page_size.to_le_bytes());
self.write_all(&packet)?;
if self.read_status(EEPROM_STATUS_ATTEMPTS)? != Some(OK) {
let reason = self.fail_reason()?;
bail!("eeprom-write failed: {reason}");
}
eprintln!(
"Programming {} bytes at offset {offset} of 0x{address:02x}...",
data.len()
);
self.send_chunked(data, EEPROM_STATUS_ATTEMPTS)?;
if self.read_status(EEPROM_STATUS_ATTEMPTS)? != Some(OK) {
let reason = self.fail_reason()?;
bail!("eeprom-write did not commit: {reason}");
}
Ok(())
}

/// Acts as a bidirectional passthrough terminal: what the device
/// sends goes to stdout, and what is typed goes to the device.
/// Returns when [`ESCAPE`] is pressed.
Expand Down
Loading