From baf7c3b4d14c167ea524995b63dfa212edf63496 Mon Sep 17 00:00:00 2001 From: Joe Ferner Date: Sun, 30 Aug 2026 08:46:17 -0400 Subject: [PATCH] add eeprom-write and eeprom-read for the HAT ID bus A board's identity lives in an EEPROM on ID_SD/ID_SC, and every existing way to write one assumes Linux on the target: `eepflash.sh` instantiates a bit-banged i2c-gpio bus and talks to it through /dev/i2c. A board that runs bare metal has none of that, and pulling the part to program it in a socket is not a workflow. The loader is already attached to such a board over serial and already reaches its hardware through rpi-hal, so it is where this belongs. `eeprom-write` takes the `.eep` that `eepmake` produces and programs it; `eeprom-read` copies it back off. Wire commands 9 and 10, both shaped like the `sd-*` ones -- arguments, a status byte, then the existing CRC-checked chunk stream in whichever direction. Both take `--address` (0x50 by default, what the HAT specification assigns) and `--offset`. With no `--length`, `eeprom-read` reads the 12-byte HAT header first and takes the image length out of it, so a dump is the couple of hundred bytes of atoms rather than 32 KiB of mostly 0xff. The device reads every page back after programming it, and 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 a state you will meet -- acknowledges every byte and stores none. Without the read-back this command would report a clean success and leave the EEPROM exactly as it was. That is error code 8, and it is deliberately distinct from 7 (nothing acknowledged the page write) and 10 (the page was written but the part never answered the read that checks it), because those three send you to different parts of the board. The wait between writing a page and reading it back is a fixed delay, not the acknowledge polling the datasheet also offers. Polling was the first implementation and it failed on hardware in a way worth recording: exactly one 32-byte page landed and the rest of the image stayed erased. The poll is issued tens of microseconds after the page write's STOP, which is the same moment the part begins the cycle being polled about -- so it can answer about a cycle that has not started. Waiting out the family's 5ms tWR, rounded up to 6, has no such race, and the read-back that follows is the real evidence a page took, so the wait only has to be long enough rather than exact. The read is retried a few times for a part slower than its own datasheet. Page size comes from the host because the device cannot know what part is fitted, and getting it wrong is not a clean failure: a page write that runs past the part's page boundary wraps to the start of that page rather than carrying, overwriting data already programmed. The default is 32, the page of a 24C32 -- the smallest part the specification allows -- which divides every larger part's page and is therefore always safe. A 24C256 takes `--page-size 64` and programs in half the time. `send_chunked` gains an ACK-attempt count. Every other command stores a chunk and acknowledges immediately; this one programs it a page at a time, so a 4 KiB chunk takes seconds before its ACK, and one timeout window had the host resending chunks the device was still working through. The per-chunk ACK is flow control here as everywhere else in this protocol, so the fix is to wait longer for it, not to send ahead. Three protocol tests cover the new framing against the fake device: the image transfer, the verify failure naming write protect, and the header-driven read length. The firmware's rpi-hal floor moves to 0.3.0, which is where `I2c::::init_id` -- BSC0 muxed to GPIO0/1 -- first appears. There is no way to reach that routing in an earlier version, so this is a real floor rather than whatever happened to be current. Verified against a Pi 2 carrying a HAT with a CAT24C256 at 0x50: an `eepmake -v1` image written and reported verified, read back byte-for-byte, and round-tripped through `eepdump` with the vendor atom and GPIO map intact. --- CHANGELOG.md | 26 ++++ README.md | 53 +++++++- cli/README.md | 23 +++- cli/src/link.rs | 87 ++++++++++++- cli/src/main.rs | 169 ++++++++++++++++++++++++ cli/tests/protocol.rs | 123 ++++++++++++++++++ firmware/Cargo.lock | 5 +- firmware/Cargo.toml | 11 +- firmware/src/main.rs | 292 +++++++++++++++++++++++++++++++++++++++++- 9 files changed, 779 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7563808..221f72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 853518f..362ba1b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,11 @@ with `--device`, then takes its own arguments: - `sd-delete ` — delete `` from the SD card. - `sd-mkdir ` — create the directory `` on the SD card (a single level; the parent directories must already exist). +- `eeprom-read ` — copy an EEPROM on the HAT ID bus (GPIO0/1) to + the `` file. With no `--length`, reads the image length out of + the HAT header first. +- `eeprom-write ` — program `` into that EEPROM, verifying + every page as it goes. See below. - `boot ` — 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`). @@ -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. @@ -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 @@ -317,6 +327,47 @@ rpi-loader --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 diff --git a/cli/README.md b/cli/README.md index 9afb8fc..7f4fa62 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 @@ -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. @@ -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`, diff --git a/cli/src/link.rs b/cli/src/link.rs index 9d1dcf3..da500c7 100644 --- a/cli/src/link.rs +++ b/cli/src/link.rs @@ -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 @@ -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 @@ -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); @@ -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}"), } } @@ -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(); @@ -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; } @@ -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"); } @@ -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}"); @@ -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> { + 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. diff --git a/cli/src/main.rs b/cli/src/main.rs index 3eb0405..be456af 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -27,6 +27,30 @@ use link::{Link, BASE_BAUD, DEFAULT_BAUD}; /// the signal number. const EXIT_INTERRUPTED: u8 = 130; +/// The 7-bit I2C address the HAT specification assigns the ID EEPROM, as +/// the text `--help` shows and [`parse_i2c_address`] parses. +const HAT_EEPROM_ADDRESS: &str = "0x50"; + +/// Page size assumed when programming an EEPROM. 32 bytes is the page of +/// a 24C32 — the smallest part the HAT specification allows — and every +/// larger part's page is a multiple of it, so a 32-byte write never +/// crosses a page boundary whatever is fitted. +const DEFAULT_PAGE_SIZE: u32 = 32; + +/// Header the HAT specification puts at the start of an ID EEPROM image: +/// `"R-Pi"`, a format version and a reserved byte, the atom count, then +/// the image length. Only the signature and that length are read here — +/// enough for `eeprom-read` to work out how much to read. +const HAT_HEADER_LEN: u32 = 12; +/// Magic the header starts with. +const HAT_SIGNATURE: &[u8; 4] = b"R-Pi"; +/// Where the image length sits within the header. +const HAT_EEPLEN_AT: usize = 8; +/// Ceiling on an image length taken from the header, matching what the +/// device's two-byte addressing can reach — a corrupt header should be an +/// error, not a request to read 4 GiB one chunk at a time. +const HAT_MAX_IMAGE: u32 = 0x1_0000; + /// Upload firmware to a Raspberry Pi over serial, and read or write its /// SD card, without touching the card itself. #[derive(Parser)] @@ -131,6 +155,58 @@ enum Command { remote: String, }, + /// Copy an EEPROM's contents off the HAT ID bus (GPIO0/1) into a file. + EepromRead { + /// Local file to write. + local: PathBuf, + /// How many bytes to read. Default: the image length from the HAT + /// EEPROM header, which requires the EEPROM to hold one. + #[arg(long, value_parser = parse_u32)] + length: Option, + /// 7-bit I2C address. 0x50 is what the HAT specification assigns + /// the ID EEPROM. + // The default is spelled as the string `--help` should show: with + // `default_value_t` clap prints the `u8`, and "80" is a poor way + // to write an I2C address every datasheet gives as 0x50. + #[arg(long, value_parser = parse_i2c_address, default_value = HAT_EEPROM_ADDRESS)] + address: u8, + /// Byte offset to start at. + #[arg(long, value_parser = parse_u32, default_value_t = 0)] + offset: u32, + /// Baud to negotiate for the transfer; the link always returns to + /// 115200 afterward. + #[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)] + baud: u32, + }, + + /// Program a local image into an EEPROM on the HAT ID bus (GPIO0/1). + EepromWrite { + /// Local image to program — for a HAT ID EEPROM, the `.eep` file + /// Raspberry Pi's `eepmake` produces. + local: PathBuf, + /// 7-bit I2C address. 0x50 is what the HAT specification assigns + /// the ID EEPROM. + // The default is spelled as the string `--help` should show: with + // `default_value_t` clap prints the `u8`, and "80" is a poor way + // to write an I2C address every datasheet gives as 0x50. + #[arg(long, value_parser = parse_i2c_address, default_value = HAT_EEPROM_ADDRESS)] + address: u8, + /// Byte offset to start at. + #[arg(long, value_parser = parse_u32, default_value_t = 0)] + offset: u32, + /// The part's page size in bytes. The default suits every part + /// from the 24C32 (the HAT specification's floor) up; a 24C256's + /// own page is 64 bytes, which programs in half the time. Too + /// large corrupts data rather than failing, since a page write + /// that overruns wraps to the start of the same page. + #[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_PAGE_SIZE)] + page_size: u32, + /// Baud to negotiate for the transfer; the link always returns to + /// 115200 afterward. + #[arg(long, value_parser = parse_u32, default_value_t = DEFAULT_BAUD)] + baud: u32, + }, + /// Passthrough serial terminal only, with no handshake. Terminal, @@ -171,6 +247,18 @@ fn parse_u32(s: &str) -> Result { u32::from_str_radix(digits, radix).map_err(|e| format!("{s:?} is not a number: {e}")) } +/// Parses a 7-bit I2C address in any of the bases [`parse_u32`] takes, +/// rejecting anything the bus cannot carry. 0x00-0x07 and 0x78-0x7f are +/// reserved by the I2C specification, but a part answering there is the +/// user's business, not this tool's — only the 7-bit range is enforced. +fn parse_i2c_address(s: &str) -> Result { + let value = parse_u32(s)?; + u8::try_from(value) + .ok() + .filter(|&address| address <= 0x7f) + .ok_or_else(|| format!("{s:?} is not a 7-bit I2C address (0x00-0x7f)")) +} + /// Reads a local file, naming it if that fails. fn read_file(path: &Path) -> Result> { fs::read(path).with_context(|| format!("reading {}", path.display())) @@ -247,6 +335,36 @@ fn list_ports(all: bool) -> Result<()> { Ok(()) } +/// Works out how much of an EEPROM to read when `--length` was not given, +/// by reading the HAT header at `offset` and taking the image length out +/// of it. +/// +/// The alternative would be reading the whole address space, which for a +/// 32 KiB part means seconds of I2C traffic to recover a few hundred bytes +/// of atoms and 31 KiB of `0xff`. An EEPROM without a valid header is +/// asked for by length instead — the error says so. +fn hat_image_length(link: &mut Link, address: u8, offset: u32) -> Result { + let header = link.eeprom_read(address, offset, HAT_HEADER_LEN)?; + if !header.starts_with(HAT_SIGNATURE) { + return Err(anyhow!( + "0x{address:02x} does not hold a HAT image (no \"R-Pi\" signature at offset \ + {offset}); pass --length to read it anyway" + )); + } + let eeplen = u32::from_le_bytes( + header[HAT_EEPLEN_AT..HAT_EEPLEN_AT + 4] + .try_into() + .expect("the header is 12 bytes, so this slice is 4"), + ); + if !(HAT_HEADER_LEN..=HAT_MAX_IMAGE).contains(&eeplen) { + return Err(anyhow!( + "the HAT header claims an image length of {eeplen} bytes, which is not \ + plausible; pass --length to read it anyway" + )); + } + Ok(eeplen) +} + fn main() -> ExitCode { let cli = Cli::parse(); @@ -369,6 +487,57 @@ fn run(cli: Cli, interrupted: Arc) -> Result<()> { eprintln!("Created directory {remote}"); } + Command::EepromRead { + local, + length, + address, + offset, + baud, + } => { + link.negotiate_baud(baud)?; + let length = match length { + Some(length) => length, + None => hat_image_length(&mut link, address, offset)?, + }; + let data = link.eeprom_read(address, offset, length)?; + link.negotiate_baud(BASE_BAUD)?; + fs::write(&local, &data).with_context(|| format!("writing {}", local.display()))?; + eprintln!( + "Read {} bytes from 0x{address:02x} -> {}", + data.len(), + local.display() + ); + } + + Command::EepromWrite { + local, + address, + offset, + page_size, + baud, + } => { + let data = read_file(&local)?; + if data.is_empty() { + return Err(anyhow!("{} is empty", local.display())); + } + // A warning rather than a refusal: this command programs an + // EEPROM, and only the usual one on this bus holds a HAT image. + if offset == 0 && !data.starts_with(HAT_SIGNATURE) { + eprintln!( + "Warning: {} does not start with the HAT signature \"R-Pi\"; \ + writing it anyway.", + local.display() + ); + } + link.negotiate_baud(baud)?; + link.eeprom_write(address, offset, page_size, &data)?; + link.negotiate_baud(BASE_BAUD)?; + eprintln!( + "Programmed and verified {} bytes at offset {offset} of 0x{address:02x}.", + data.len() + ); + } + Command::Terminal => link.terminal()?, // Handled above, before the port was opened. diff --git a/cli/tests/protocol.rs b/cli/tests/protocol.rs index bfe5e78..eb42a7d 100644 --- a/cli/tests/protocol.rs +++ b/cli/tests/protocol.rs @@ -55,11 +55,21 @@ const CMD_SD_READ: u8 = 5; const CMD_SD_WRITE: u8 = 6; const CMD_SD_DELETE: u8 = 7; const CMD_SD_MKDIR: u8 = 8; +const CMD_EEPROM_READ: u8 = 9; +const CMD_EEPROM_WRITE: u8 = 10; /// Error code the device sends for a missing file. const ERR_NOT_FOUND: u8 = 2; /// Error code the device sends when a write cannot be committed. const ERR_WRITE: u8 = 6; +/// Error code the device sends when an EEPROM page read back differently +/// than it was written. +const ERR_VERIFY: u8 = 8; + +/// Address the HAT ID EEPROM answers at, and the CLI's default. +const HAT_EEPROM_ADDRESS: u8 = 0x50; +/// Page size the CLI asks for unless told otherwise. +const DEFAULT_PAGE_SIZE: u32 = 32; /// How long the child gets before the watchdog kills it. Only ever /// reached when something has already gone wrong; a passing test finishes @@ -536,6 +546,119 @@ fn sd_delete_and_mkdir_round_trip_their_paths() { } } +/// A HAT ID EEPROM image: the 12-byte header the specification defines +/// (signature, format version, reserved, atom count, image length) +/// followed by filler, so the CLI's header parsing has something real to +/// read. +fn hat_image(len: usize) -> Vec { + let mut image = Vec::with_capacity(len); + image.extend_from_slice(b"R-Pi"); + image.extend_from_slice(&[1, 0]); + image.extend_from_slice(&1u16.to_le_bytes()); + image.extend_from_slice(&(len as u32).to_le_bytes()); + while image.len() < len { + image.push((image.len() % 251) as u8); + } + image +} + +#[test] +fn eeprom_write_sends_the_image() { + let data = hat_image(6_000); + let file = temp_file("eeprom-write.eep", &data); + let mut fx = Fixture::spawn(&["eeprom-write", file.to_str().unwrap()]); + + fx.device.handshake(); + assert_eq!(fx.device.expect_set_baud(), FAST_BAUD); + + fx.device.expect_command(CMD_EEPROM_WRITE); + let address = fx.device.read_u8(); + let offset = fx.device.read_u32(); + let total = fx.device.read_u32() as usize; + let chunk = fx.device.read_u32() as usize; + let page = fx.device.read_u32(); + assert_eq!( + (address, offset, total, chunk, page), + (HAT_EEPROM_ADDRESS, 0, data.len(), CHUNK, DEFAULT_PAGE_SIZE) + ); + fx.device.write_all(&[OK]); + + let received = fx.device.recv_chunks(total, chunk, false); + assert_eq!(received, data, "device received a different image"); + // The commit status, sent once every page has been programmed and + // read back. + fx.device.write_all(&[OK]); + + assert_eq!(fx.device.expect_set_baud(), BASE_BAUD); + assert_success(&fx.finish()); +} + +#[test] +fn eeprom_write_names_a_verify_failure() { + let data = hat_image(1_000); + let file = temp_file("eeprom-write-wp.eep", &data); + let mut fx = Fixture::spawn(&["eeprom-write", file.to_str().unwrap()]); + + fx.device.handshake(); + fx.device.expect_set_baud(); + fx.device.expect_command(CMD_EEPROM_WRITE); + fx.device.read_u8(); + fx.device.read_u32(); + let total = fx.device.read_u32() as usize; + let chunk = fx.device.read_u32() as usize; + fx.device.read_u32(); + fx.device.write_all(&[OK]); + fx.device.recv_chunks(total, chunk, false); + // Everything transferred, nothing stored: what a write-protected part + // looks like, since it acknowledges every byte. + fx.device.write_all(&[FAIL, ERR_VERIFY]); + + let output = fx.finish(); + assert!( + !output.status.success(), + "a failed commit must fail the CLI" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("write-protected"), + "the verify failure should name the likely cause: {:?}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn eeprom_read_takes_its_length_from_the_hat_header() { + let data = hat_image(300); + let out_path = std::env::temp_dir().join("rpi-loader-test-eeprom-read.eep"); + let _ = std::fs::remove_file(&out_path); + let mut fx = Fixture::spawn(&["eeprom-read", out_path.to_str().unwrap()]); + + fx.device.handshake(); + fx.device.expect_set_baud(); + + // With no --length, the header is read first and its `eeplen` field is + // what the second read asks for. + fx.device.expect_command(CMD_EEPROM_READ); + assert_eq!(fx.device.read_u8(), HAT_EEPROM_ADDRESS); + assert_eq!(fx.device.read_u32(), 0); + assert_eq!(fx.device.read_u32(), 12); + fx.device.write_all(&[OK]); + fx.device.send_bulk(&data[..12]); + + fx.device.expect_command(CMD_EEPROM_READ); + fx.device.read_u8(); + fx.device.read_u32(); + assert_eq!(fx.device.read_u32() as usize, data.len()); + fx.device.write_all(&[OK]); + fx.device.send_bulk(&data); + + fx.device.expect_set_baud(); + assert_success(&fx.finish()); + assert_eq!( + std::fs::read(&out_path).expect("the local file should exist"), + data + ); +} + #[test] fn terminal_carries_both_directions() { let mut fx = Fixture::spawn_with_stdin(&["terminal"]); diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index d0bf3ea..cb92823 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -83,9 +83,9 @@ dependencies = [ [[package]] name = "rpi-hal" -version = "0.1.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a03b01c0be63612bfe575b75ce663382521011ecf59ab0910b787b94d482981e" +checksum = "50638234367c4097fc38a0f4af6c1be86fa3c6edbe1bae2c1c1e942b1152bd91" dependencies = [ "bcm2711-lpa", "bcm2837-lpa", @@ -99,6 +99,7 @@ dependencies = [ name = "rpi-loader-firmware" version = "0.1.0" dependencies = [ + "embedded-hal", "embedded-sdmmc", "rpi-hal", ] diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index acf5cfd..d045b1e 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -39,11 +39,20 @@ publish = false # "turn bcm2837 off"; harmless (rpi-hal's `pac` re-export prefers # `bcm2711` when both are enabled), just not a build that drops # `bcm2837-lpa` entirely. -rpi-hal = { version = "0.1.0", default-features = false, features = ["embedded-sdmmc", "bcm2837"] } +# +# 0.3.0 is a floor rather than whatever was current: the `eeprom-*` +# commands need `I2c::::init_id`, BSC0 muxed to its GPIO0/1 (HAT ID +# bus) routing, which no earlier version can reach at all. +rpi-hal = { version = "0.3.0", default-features = false, features = ["embedded-sdmmc", "bcm2837"] } # The FAT layer itself. This is a separate crate from rpi-hal (which only # provides the BlockDevice adapter), so the loader depends on it directly # for the `VolumeManager`/`Mode`/`TimeSource`/`VolumeIdx` types it drives. embedded-sdmmc = { version = "0.9.0", default-features = false } +# rpi-hal's `I2c` implements `embedded_hal::i2c::I2c` rather than carrying +# inherent read/write methods, so the trait has to be in scope here for +# the `eeprom-*` commands to call them. Same major version rpi-hal itself +# depends on, so both halves see one set of traits. +embedded-hal = { version = "1.0.0", default-features = false } [features] # Forwards to rpi-hal's `bcm2711` feature, so this crate's own peripheral diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 1b0fc24..a9f6c97 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -2,8 +2,11 @@ #![no_main] use core::fmt::Write; +use embedded_hal::i2c::I2c as _; use embedded_sdmmc::{Mode, TimeSource, Timestamp, VolumeIdx, VolumeManager}; +use rpi_hal::i2c::I2c; use rpi_hal::mailbox::Mailbox; +use rpi_hal::pac::BSC0; use rpi_hal::sd::{Sd, SdCard, SdCardError}; use rpi_hal::timer::Timer; use rpi_hal::{pac, uart::Uart}; @@ -48,7 +51,8 @@ const FAIL: u8 = 0; // `u32` LE baud; the device ACKs at the *current* baud, then switches. // `CMD_EXEC` is followed by a `u32` LE address to jump to. The `CMD_SD_*` // commands read/write/list/delete files and create directories on the SD -// card's FAT boot partition. +// card's FAT boot partition. The `CMD_EEPROM_*` commands read and write a +// serial EEPROM on the HAT ID bus (see [`init_i2c`]). const CMD_MEM_WRITE: u8 = 1; const CMD_SET_BAUD: u8 = 2; const CMD_EXEC: u8 = 3; @@ -57,6 +61,8 @@ const CMD_SD_READ: u8 = 5; const CMD_SD_WRITE: u8 = 6; const CMD_SD_DELETE: u8 = 7; const CMD_SD_MKDIR: u8 = 8; +const CMD_EEPROM_READ: u8 = 9; +const CMD_EEPROM_WRITE: u8 = 10; // Error codes, sent as the byte right after a leading `FAIL` when a // command can't even begin (bad path, SD bring-up failed, filesystem @@ -68,6 +74,10 @@ const ERR_FS: u8 = 3; const ERR_TOO_LARGE: u8 = 4; const ERR_BAD_PATH: u8 = 5; const ERR_WRITE: u8 = 6; +const ERR_I2C: u8 = 7; +const ERR_VERIFY: u8 = 8; +const ERR_RANGE: u8 = 9; +const ERR_READBACK: u8 = 10; /// Stay clear of the relocated loader's own copy — see `boot.s`. Every /// `CMD_MEM_WRITE`/`CMD_EXEC` address must fall below this, so a client @@ -89,6 +99,39 @@ const MAX_PATH: usize = 255; /// [`ERR_TOO_LARGE`] rather than overrunning the buffer. const LISTING_CAP: usize = 8192; +/// BSC clock divider for the EEPROM bus: the reset default, 100kHz at a +/// 150MHz core clock and 166kHz at 250MHz. Either is within what every +/// 24C-series part does, and the HAT specification only asks for 100kHz — +/// there is nothing to gain from computing an exact rate here, which +/// would mean asking the mailbox for the real core clock first. +const EEPROM_CDIV: u16 = 0x05dc; + +/// One past the highest EEPROM byte this loader will address. The two-byte +/// addressing it uses (what every part from the 24C32 up expects, and the +/// HAT specification's floor is a 24C32) reaches 64 KiB and no further, so +/// a request past this is refused with [`ERR_RANGE`] rather than +/// silently wrapping to the start of the device. +const EEPROM_LIMIT: usize = 0x1_0000; + +/// Largest page write accepted from the host. A page write must not cross +/// the part's own page boundary — the address counter wraps within the +/// page rather than carrying, so an overrunning write silently overwrites +/// the *start* of the same page. The host names its part's page size; this +/// only bounds the buffer. +const EEPROM_MAX_PAGE: usize = 128; + +/// How long to leave a page alone after writing it, while the part +/// performs its internal write cycle. Datasheets quote 5ms maximum for +/// this family; this is that, rounded up. +const EEPROM_WRITE_CYCLE_MS: u32 = 6; + +/// How many times the verifying read is attempted before the page is +/// called a failure. More than one because [`EEPROM_WRITE_CYCLE_MS`] is a +/// datasheet number rather than a measurement of the part actually +/// fitted: a slow one answers nothing on the first attempt, and the cost +/// of finding out is one more transfer. +const EEPROM_READBACK_ATTEMPTS: u32 = 4; + #[panic_handler] fn panic(_info: &core::panic::PanicInfo) -> ! { halt(); @@ -179,6 +222,18 @@ pub extern "C" fn kmain() -> ! { uart.write_byte(code); } } + CMD_EEPROM_READ => { + if let Err(code) = cmd_eeprom_read(&mut uart, &timer, &mut chunk_buf) { + uart.write_byte(FAIL); + uart.write_byte(code); + } + } + CMD_EEPROM_WRITE => { + if let Err(code) = cmd_eeprom_write(&mut uart, &timer, &mut chunk_buf) { + uart.write_byte(FAIL); + uart.write_byte(code); + } + } _ => uart.write_byte(FAIL), } } @@ -463,6 +518,241 @@ fn cmd_sd_mkdir(uart: &mut Uart, timer: &Timer) -> Result<(), u8> { Ok(()) } +/// `CMD_EEPROM_READ`: stream bytes out of a serial EEPROM on the HAT ID +/// bus to the host. +/// +/// Reads a `u8` device address, a `u32` LE `offset` and a `u32` LE +/// `length`, probes the device, then streams the range as CRC-checked +/// chunks. `Err(code)` means the read never started — an out-of-range +/// request ([`ERR_RANGE`]) or nothing answering at that address +/// ([`ERR_I2C`]); the caller sends `FAIL` + `code`. +/// +/// The probe is what makes "nothing is fitted" a clean failure: once the +/// leading `OK` is out, the stream is committed, and a later bus error +/// can only stop it short and leave the host's per-chunk timeout to +/// report it. +fn cmd_eeprom_read(uart: &mut Uart, timer: &Timer, chunk_buf: &mut [u8]) -> Result<(), u8> { + let address = uart.read_byte(); + let offset = read_u32_le(uart) as usize; + let length = read_u32_le(uart) as usize; + if length == 0 || !in_eeprom_range(offset, length) { + return Err(ERR_RANGE); + } + + let mut i2c = init_i2c(timer); + let mut probe = [0u8; 1]; + eeprom_read_at(&mut i2c, address, offset as u16, &mut probe).map_err(|_| ERR_I2C)?; + + uart.write_byte(OK); + write_u32(uart, length as u32); + write_u32(uart, STREAM_CHUNK_SIZE as u32); + + let mut done = 0; + while done < length { + let want = core::cmp::min(STREAM_CHUNK_SIZE, length - done); + let at = (offset + done) as u16; + if eeprom_read_at(&mut i2c, address, at, &mut chunk_buf[..want]).is_err() { + break; + } + send_chunk(uart, &chunk_buf[..want]); + done += want; + } + Ok(()) +} + +/// `CMD_EEPROM_WRITE`: receive an image from the host and program it into +/// a serial EEPROM on the HAT ID bus. +/// +/// Reads a `u8` device address, then `u32` LE `offset`, `total_size`, +/// `chunk_size` and `page_size`, and receives the payload as CRC-checked +/// chunks, programming each chunk a page at a time. `Err(code)` means the +/// write never started; the caller sends `FAIL` + `code`. After the +/// leading `OK`, chunks are always drained to keep the link in sync even +/// once programming has failed, and a final status byte (`OK`, or `FAIL` + +/// [`ERR_I2C`]/[`ERR_READBACK`]/[`ERR_VERIFY`], which say respectively +/// that the page write was not acknowledged, that the part never answered +/// the read that follows it, and that it answered with something other +/// than what was written) reports the committed result. A header that +/// asks for something outside the device's reach — past [`EEPROM_LIMIT`], +/// or a page larger than [`EEPROM_MAX_PAGE`] — is [`ERR_RANGE`], refused +/// before any of it is written. +/// +/// `page_size` comes from the host because the device cannot know what +/// part is fitted, and a page write that crosses the part's page boundary +/// wraps to the start of that page instead of carrying — corrupting data +/// already written rather than failing. It must divide the page size of +/// the real part; the HAT specification's floor (a 24C32) has 32-byte +/// pages, which is what the host defaults to. +fn cmd_eeprom_write(uart: &mut Uart, timer: &Timer, chunk_buf: &mut [u8]) -> Result<(), u8> { + let address = uart.read_byte(); + let offset = read_u32_le(uart) as usize; + let total_size = read_u32_le(uart) as usize; + let chunk_size = read_u32_le(uart) as usize; + let page_size = read_u32_le(uart) as usize; + + if total_size == 0 || !in_eeprom_range(offset, total_size) { + return Err(ERR_RANGE); + } + // A non-power-of-two page size would make the "distance to the next + // page boundary" arithmetic below wrong, and no part in this family + // has one. + if chunk_size == 0 + || chunk_size > chunk_buf.len() + || page_size == 0 + || page_size > EEPROM_MAX_PAGE + || !page_size.is_power_of_two() + { + return Err(ERR_RANGE); + } + + let mut i2c = init_i2c(timer); + uart.write_byte(OK); + + let mut failure = None; + let mut done = 0; + while done < total_size { + let this_len = core::cmp::min(chunk_size, total_size - done); + recv_chunk(uart, chunk_buf, this_len); + if failure.is_none() { + failure = eeprom_write_pages( + &mut i2c, + timer, + address, + offset + done, + &chunk_buf[..this_len], + page_size, + ) + .err(); + } + // ACK after programming, not before — the same flow control the SD + // write path relies on (see `recv_chunk`), and this side is far + // slower: a page's internal write cycle is milliseconds, during + // which nothing is draining the RX FIFO. + uart.write_byte(OK); + done += this_len; + } + + match failure { + None => uart.write_byte(OK), + Some(code) => { + uart.write_byte(FAIL); + uart.write_byte(code); + } + } + Ok(()) +} + +/// Programs `data` into the EEPROM starting at `at`, one page write at a +/// time, waiting out each internal write cycle and reading the page back +/// to confirm it took. +/// +/// The read-back is not belt and braces: a write-protected part (the `WP` +/// pin tied high, which on a board with the HAT ID EEPROM's write protect +/// on a jumper is the normal state) acknowledges every byte and stores +/// none. Without the verify this command would report a clean success and +/// leave the EEPROM exactly as it was. +/// +/// The wait between the two is a fixed delay rather than the acknowledge +/// polling the datasheet also describes. Polling is the faster technique +/// — a part is typically ready in well under its specified time — but it +/// has to begin after the part has registered the STOP that starts the +/// write cycle, and two back-to-back BSC transactions are only tens of +/// microseconds apart. Polled that early it reported ready when it was +/// not: the first page of an image landed and the sequence then failed on +/// the transfer after it, leaving the rest of the EEPROM erased. Waiting +/// out [`EEPROM_WRITE_CYCLE_MS`] has no such race, and the read-back that +/// follows is the real evidence the page took — so the delay only has to +/// be long enough, not exact. +fn eeprom_write_pages( + i2c: &mut I2c<'_, BSC0>, + timer: &Timer, + address: u8, + at: usize, + data: &[u8], + page_size: usize, +) -> Result<(), u8> { + // Two bytes of address ahead of the payload, since a page write is one + // I2C transaction: address high, address low, then the page's bytes. + let mut packet = [0u8; 2 + EEPROM_MAX_PAGE]; + let mut readback = [0u8; EEPROM_MAX_PAGE]; + + let mut written = 0; + while written < data.len() { + let at = at + written; + // Stop at the next page boundary: `at` is not necessarily aligned + // (an `--offset` can start anywhere), so the first write of a run + // is usually short and the rest are full pages. + let to_boundary = page_size - (at % page_size); + let len = core::cmp::min(to_boundary, data.len() - written); + + packet[..2].copy_from_slice(&(at as u16).to_be_bytes()); + packet[2..2 + len].copy_from_slice(&data[written..written + len]); + i2c.write(address, &packet[..2 + len]) + .map_err(|_| ERR_I2C)?; + + let mut read = Err(()); + for _ in 0..EEPROM_READBACK_ATTEMPTS { + timer.delay_ms(EEPROM_WRITE_CYCLE_MS); + read = eeprom_read_at(i2c, address, at as u16, &mut readback[..len]).map_err(|_| ()); + if read.is_ok() { + break; + } + } + // A read that never answered says something different from one + // that answered wrongly: the first is a part still busy (or gone), + // the second is a write that did not take. + read.map_err(|_| ERR_READBACK)?; + if readback[..len] != data[written..written + len] { + return Err(ERR_VERIFY); + } + written += len; + } + Ok(()) +} + +/// Reads `buf.len()` bytes from `at`: a two-byte address write, then a +/// read the part answers from its address counter, incrementing through. +/// +/// The two are separate transactions with a STOP between them rather than +/// a repeated start, which this hardware's driver does not offer. That is +/// safe here specifically because the address write carries no data byte: +/// the part latches the counter without starting a write cycle, and the +/// counter survives the STOP. +fn eeprom_read_at( + i2c: &mut I2c<'_, BSC0>, + address: u8, + at: u16, + buf: &mut [u8], +) -> Result<(), rpi_hal::i2c::Error> { + i2c.write(address, &at.to_be_bytes())?; + i2c.read(address, buf) +} + +/// Whether `offset..offset + length` fits inside what two-byte addressing +/// can reach (see [`EEPROM_LIMIT`]). +fn in_eeprom_range(offset: usize, length: usize) -> bool { + offset + .checked_add(length) + .is_some_and(|end| end <= EEPROM_LIMIT) +} + +/// Brings BSC0 up on the HAT ID bus — GPIO0/1 (`ID_SD`/`ID_SC`), header +/// pins 27/28 — for the `eeprom-*` commands. +/// +/// That is the bus a board's identity EEPROM sits on, and it is otherwise +/// idle once the firmware has read it during boot. Note which routing this +/// takes: BSC0's other one is GPIO44/45, the camera/display connector bus, +/// and the two cannot both be muxed at once. +/// +/// Re-`steal()`s its peripherals per command, exactly as +/// [`init_volume_mgr`] does, so nothing has to be threaded through the +/// command loop; sound for the same reason — single core, and the +/// previous command's driver is long dropped. +fn init_i2c(timer: &Timer) -> I2c<'_, BSC0> { + let peripherals = unsafe { pac::Peripherals::steal() }; + I2c::::init_id(&peripherals.GPIO, peripherals.BSC0, EEPROM_CDIV, timer) +} + /// Brings the SD card up from scratch and wraps it in a /// [`VolumeManager`] over the FAT filesystem. ///