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