777 lines
29 KiB
Rust
777 lines
29 KiB
Rust
//! PICOBOOT — flashing an RP2040/RP2350 from a page, without touching mass storage.
|
|
//!
|
|
//! # Why this exists
|
|
//!
|
|
//! A Pico in BOOTSEL mode is famous for mounting as a drive you drag a `.uf2` onto. That mass
|
|
//! storage interface is genuinely closed to a browser: WebUSB may not claim protected device
|
|
//! classes, and MSC is one. It is easy — and wrong — to stop there and conclude a page can never
|
|
//! flash a Pico.
|
|
//!
|
|
//! The bootrom exposes a **second** interface alongside the drive: PICOBOOT, vendor-defined class
|
|
//! `0xFF` with two bulk endpoints. Vendor class is exactly what WebUSB *is* allowed to claim, and
|
|
//! PICOBOOT is what `picotool` speaks. So the door is open; it is simply a different door.
|
|
//!
|
|
//! This module is that protocol, sans-IO like the rest of the crate: it builds the 32-byte command
|
|
//! packets and parses the 16-byte status replies, owning no USB handle, no clock and no retries. A
|
|
//! driver moves the bytes — `nusb` on a workstation, WebUSB in a tab — and neither can drift from
|
|
//! the other, because there is only one implementation.
|
|
//!
|
|
//! # Verified, not recalled
|
|
//!
|
|
//! Every constant here was read from primary sources rather than memory: `PICOBOOT_MAGIC`, the
|
|
//! command ids and their `0x80` IN-direction bit, the packed `picoboot_cmd`/`picoboot_cmd_status`
|
|
//! layouts and all eighteen status codes come from the Pico SDK's `boot/picoboot.h`; the USB ids
|
|
//! from picotool's `picoboot_connection.h`; `XIP_BASE` from the SDK address maps for both chips.
|
|
//! The UF2 magics and family ids were verified earlier against real forge output.
|
|
//!
|
|
//! # Scope
|
|
//!
|
|
//! The command set needed to put an image on flash — exclusive access, EXIT_XIP, erase, write,
|
|
//! reboot — plus the UF2 parsing that turns a forge artefact into addressed payloads. `PC_EXEC`,
|
|
//! `PC_VECTORIZE_FLASH`, OTP and `PC_GET_INFO` have verified opcodes but no argument builders,
|
|
//! because their layouts were not read from a primary source here. Documented, not stubbed.
|
|
//!
|
|
//! **No RP2040 has been flashed with this.** The packets match the specification; first contact
|
|
//! with real silicon is the validation step.
|
|
|
|
/// Raspberry Pi's USB vendor id.
|
|
pub const VENDOR_ID: u16 = 0x2e8a;
|
|
/// Product id of an RP2040 in BOOTSEL mode.
|
|
pub const PID_RP2040_BOOTSEL: u16 = 0x0003;
|
|
/// Product id of an RP2350 in BOOTSEL mode.
|
|
pub const PID_RP2350_BOOTSEL: u16 = 0x000f;
|
|
|
|
/// First word of every command packet.
|
|
pub const MAGIC: u32 = 0x431f_d10b;
|
|
/// Vendor control request that resets the PICOBOOT interface.
|
|
pub const IF_RESET: u8 = 0x41;
|
|
/// Vendor control request that reads back a [`Status`].
|
|
pub const IF_CMD_STATUS: u8 = 0x42;
|
|
|
|
/// A command packet is exactly this long.
|
|
pub const CMD_LEN: usize = 32;
|
|
/// A status reply is exactly this long.
|
|
pub const STATUS_LEN: usize = 16;
|
|
|
|
/// Where execute-in-place flash is mapped, on both RP2040 and RP2350.
|
|
pub const XIP_BASE: u32 = 0x1000_0000;
|
|
/// The smallest erasable unit of the QSPI flash.
|
|
pub const FLASH_SECTOR_SIZE: u32 = 4096;
|
|
/// The largest writable page.
|
|
pub const FLASH_PAGE_SIZE: u32 = 256;
|
|
|
|
/// A failure building or reading a PICOBOOT exchange.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PicobootError {
|
|
/// A status reply was not [`STATUS_LEN`] bytes.
|
|
ShortStatus(usize),
|
|
/// The device reported a non-zero status code.
|
|
Failed {
|
|
/// The raw code.
|
|
code: u32,
|
|
/// Its documented meaning.
|
|
reason: &'static str,
|
|
},
|
|
/// The status reply's token did not match the command's.
|
|
TokenMismatch {
|
|
/// The token that was sent.
|
|
sent: u32,
|
|
/// The token that came back.
|
|
got: u32,
|
|
},
|
|
/// A UF2 image was not a whole number of 512-byte blocks.
|
|
Uf2NotBlockAligned(usize),
|
|
/// A UF2 block's magics were wrong.
|
|
Uf2BadMagic {
|
|
/// Index of the offending block.
|
|
block: usize,
|
|
},
|
|
/// A UF2 block claimed a payload longer than a block can hold.
|
|
Uf2PayloadTooLong {
|
|
/// Index of the offending block.
|
|
block: usize,
|
|
/// The size it claimed.
|
|
claimed: u32,
|
|
},
|
|
/// The image contained no flashable blocks.
|
|
Uf2Empty,
|
|
}
|
|
|
|
impl core::fmt::Display for PicobootError {
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
match self {
|
|
PicobootError::ShortStatus(n) => write!(f, "status reply was {n} bytes, want 16"),
|
|
PicobootError::Failed { code, reason } => {
|
|
write!(f, "device returned {code} ({reason})")
|
|
}
|
|
PicobootError::TokenMismatch { sent, got } => {
|
|
write!(f, "status token {got:#x} does not match command {sent:#x}")
|
|
}
|
|
PicobootError::Uf2NotBlockAligned(n) => {
|
|
write!(f, "UF2 is {n} bytes, not a multiple of 512")
|
|
}
|
|
PicobootError::Uf2BadMagic { block } => write!(f, "UF2 block {block} has bad magic"),
|
|
PicobootError::Uf2PayloadTooLong { block, claimed } => {
|
|
write!(f, "UF2 block {block} claims a {claimed}-byte payload")
|
|
}
|
|
PicobootError::Uf2Empty => write!(f, "UF2 contained no payload blocks"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PicobootError {}
|
|
|
|
/// A PICOBOOT command id. The top bit marks a device-to-host transfer, which is why `Read` and
|
|
/// `GetInfo` sit above `0x80` — that bit is part of the id, not a flag alongside it.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Command {
|
|
/// Take (or release) exclusive access, locking out the mass-storage interface.
|
|
ExclusiveAccess,
|
|
/// Reboot. RP2040 only; RP2350 uses `Reboot2`.
|
|
Reboot,
|
|
/// Erase a flash range. Must be sector aligned.
|
|
FlashErase,
|
|
/// Read memory (device to host).
|
|
Read,
|
|
/// Write memory. Does **not** erase first.
|
|
Write,
|
|
/// Leave execute-in-place so flash commands are reliable.
|
|
ExitXip,
|
|
/// Re-enter execute-in-place.
|
|
EnterCmdXip,
|
|
/// Reboot, RP2350 form.
|
|
Reboot2,
|
|
/// Query the bootrom (device to host). RP2350 only.
|
|
GetInfo,
|
|
}
|
|
|
|
impl Command {
|
|
/// The wire id.
|
|
#[must_use]
|
|
pub const fn id(self) -> u8 {
|
|
match self {
|
|
Command::ExclusiveAccess => 0x01,
|
|
Command::Reboot => 0x02,
|
|
Command::FlashErase => 0x03,
|
|
Command::Read => 0x84,
|
|
Command::Write => 0x05,
|
|
Command::ExitXip => 0x06,
|
|
Command::EnterCmdXip => 0x07,
|
|
Command::Reboot2 => 0x0a,
|
|
Command::GetInfo => 0x8b,
|
|
}
|
|
}
|
|
|
|
/// Whether the data stage flows device to host — the `0x80` bit of the id.
|
|
#[must_use]
|
|
pub const fn is_in_transfer(self) -> bool {
|
|
self.id() & 0x80 != 0
|
|
}
|
|
}
|
|
|
|
/// How much of the mass-storage interface to lock out while flashing.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Exclusive {
|
|
/// Leave mass storage alone.
|
|
No = 0,
|
|
/// Disable mass-storage writes; aborts any UF2 download in flight.
|
|
Yes = 1,
|
|
/// Also mark the media absent, ejecting the drive.
|
|
AndEject = 2,
|
|
}
|
|
|
|
/// A built 32-byte command packet.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Packet {
|
|
/// The command it carries.
|
|
pub command: Command,
|
|
/// The token to match against the status reply.
|
|
pub token: u32,
|
|
/// Bytes of the data stage that follows, or 0.
|
|
pub transfer_length: u32,
|
|
bytes: [u8; CMD_LEN],
|
|
}
|
|
|
|
impl Packet {
|
|
/// The wire bytes.
|
|
#[must_use]
|
|
pub const fn bytes(&self) -> &[u8; CMD_LEN] {
|
|
&self.bytes
|
|
}
|
|
|
|
fn build(command: Command, token: u32, args: &[u8], transfer_length: u32) -> Packet {
|
|
debug_assert!(args.len() <= 16, "the args union is 16 bytes");
|
|
let mut b = [0u8; CMD_LEN];
|
|
b[0..4].copy_from_slice(&MAGIC.to_le_bytes());
|
|
b[4..8].copy_from_slice(&token.to_le_bytes());
|
|
b[8] = command.id();
|
|
b[9] = args.len() as u8;
|
|
// b[10..12] is the reserved u16 and must stay zero.
|
|
b[12..16].copy_from_slice(&transfer_length.to_le_bytes());
|
|
b[16..16 + args.len()].copy_from_slice(args);
|
|
Packet {
|
|
command,
|
|
token,
|
|
transfer_length,
|
|
bytes: b,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn range_args(addr: u32, size: u32) -> [u8; 8] {
|
|
let mut a = [0u8; 8];
|
|
a[0..4].copy_from_slice(&addr.to_le_bytes());
|
|
a[4..8].copy_from_slice(&size.to_le_bytes());
|
|
a
|
|
}
|
|
|
|
/// `PC_EXCLUSIVE_ACCESS` — take the device away from its own mass-storage interface.
|
|
#[must_use]
|
|
pub fn exclusive_access(token: u32, mode: Exclusive) -> Packet {
|
|
Packet::build(Command::ExclusiveAccess, token, &[mode as u8], 0)
|
|
}
|
|
|
|
/// `PC_EXIT_XIP` — required before flash writes are reliable. Carries no arguments.
|
|
#[must_use]
|
|
pub fn exit_xip(token: u32) -> Packet {
|
|
Packet::build(Command::ExitXip, token, &[], 0)
|
|
}
|
|
|
|
/// `PC_ENTER_CMD_XIP` — put execute-in-place back.
|
|
#[must_use]
|
|
pub fn enter_cmd_xip(token: u32) -> Packet {
|
|
Packet::build(Command::EnterCmdXip, token, &[], 0)
|
|
}
|
|
|
|
/// `PC_FLASH_ERASE` — erase `size` bytes at `addr`. Both must be sector aligned; the device
|
|
/// answers `BadAlignment` rather than guessing, so [`erase_plan`] exists to get it right first.
|
|
#[must_use]
|
|
pub fn flash_erase(token: u32, addr: u32, size: u32) -> Packet {
|
|
Packet::build(Command::FlashErase, token, &range_args(addr, size), 0)
|
|
}
|
|
|
|
/// `PC_WRITE` — the packet announcing a write; `size` bytes then follow on the OUT endpoint.
|
|
/// Does not erase, so the range must already have been erased.
|
|
#[must_use]
|
|
pub fn write(token: u32, addr: u32, size: u32) -> Packet {
|
|
Packet::build(Command::Write, token, &range_args(addr, size), size)
|
|
}
|
|
|
|
/// `PC_READ` — the packet announcing a read; `size` bytes then arrive on the IN endpoint.
|
|
#[must_use]
|
|
pub fn read(token: u32, addr: u32, size: u32) -> Packet {
|
|
Packet::build(Command::Read, token, &range_args(addr, size), size)
|
|
}
|
|
|
|
/// `PC_REBOOT` (RP2040): jump to `pc` with `sp`, after `delay_ms`. A zeroed pc/sp asks the bootrom
|
|
/// to restart the flash image, which is what a flasher wants once it is done.
|
|
#[must_use]
|
|
pub fn reboot(token: u32, pc: u32, sp: u32, delay_ms: u32) -> Packet {
|
|
let mut a = [0u8; 12];
|
|
a[0..4].copy_from_slice(&pc.to_le_bytes());
|
|
a[4..8].copy_from_slice(&sp.to_le_bytes());
|
|
a[8..12].copy_from_slice(&delay_ms.to_le_bytes());
|
|
Packet::build(Command::Reboot, token, &a, 0)
|
|
}
|
|
|
|
/// The documented meaning of a status code.
|
|
#[must_use]
|
|
pub fn status_reason(code: u32) -> &'static str {
|
|
match code {
|
|
0 => "ok",
|
|
1 => "unknown command",
|
|
2 => "invalid command length",
|
|
3 => "invalid transfer length",
|
|
4 => "invalid address",
|
|
5 => "bad alignment",
|
|
6 => "interleaved write",
|
|
7 => "rebooting",
|
|
8 => "unknown error",
|
|
9 => "invalid state",
|
|
10 => "not permitted",
|
|
11 => "invalid argument",
|
|
12 => "buffer too small",
|
|
13 => "precondition not met",
|
|
14 => "modified data",
|
|
15 => "invalid data",
|
|
16 => "not found",
|
|
17 => "unsupported modification",
|
|
_ => "undocumented status",
|
|
}
|
|
}
|
|
|
|
/// A parsed 16-byte status reply.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Status {
|
|
/// Echo of the command's token.
|
|
pub token: u32,
|
|
/// The result code.
|
|
pub code: u32,
|
|
/// The command this refers to.
|
|
pub cmd_id: u8,
|
|
/// Whether the device is still working on it.
|
|
pub in_progress: bool,
|
|
}
|
|
|
|
impl Status {
|
|
/// Parse a status reply.
|
|
pub fn parse(bytes: &[u8]) -> Result<Status, PicobootError> {
|
|
if bytes.len() < STATUS_LEN {
|
|
return Err(PicobootError::ShortStatus(bytes.len()));
|
|
}
|
|
Ok(Status {
|
|
token: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
|
code: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
|
|
cmd_id: bytes[8],
|
|
in_progress: bytes[9] != 0,
|
|
})
|
|
}
|
|
|
|
/// Whether the command succeeded.
|
|
#[must_use]
|
|
pub const fn is_ok(self) -> bool {
|
|
self.code == 0
|
|
}
|
|
|
|
/// Check this status against the packet it should answer.
|
|
pub fn check(self, sent: &Packet) -> Result<Status, PicobootError> {
|
|
if self.token != sent.token {
|
|
return Err(PicobootError::TokenMismatch {
|
|
sent: sent.token,
|
|
got: self.token,
|
|
});
|
|
}
|
|
if !self.is_ok() {
|
|
return Err(PicobootError::Failed {
|
|
code: self.code,
|
|
reason: status_reason(self.code),
|
|
});
|
|
}
|
|
Ok(self)
|
|
}
|
|
}
|
|
|
|
/// Which chip a BOOTSEL product id belongs to.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Chip {
|
|
/// RP2040.
|
|
Rp2040,
|
|
/// RP2350.
|
|
Rp2350,
|
|
}
|
|
|
|
impl Chip {
|
|
/// Identify a device from its USB ids, or `None` if it is not a Pico in BOOTSEL mode.
|
|
#[must_use]
|
|
pub const fn from_usb(vid: u16, pid: u16) -> Option<Chip> {
|
|
if vid != VENDOR_ID {
|
|
return None;
|
|
}
|
|
match pid {
|
|
PID_RP2040_BOOTSEL => Some(Chip::Rp2040),
|
|
PID_RP2350_BOOTSEL => Some(Chip::Rp2350),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Identify a chip from a board registry's part string.
|
|
///
|
|
/// Gating on the FLASH METHOD alone is not enough: `FlashMethod::Uf2` also covers parts whose
|
|
/// UF2 bootloader is not the RP2 bootrom — a SAMD51 mounts a drive too, and has no PICOBOOT
|
|
/// interface behind it. Offering a PICOBOOT button for one of those is a click that can only
|
|
/// fail, so the caller asks this and refuses when it answers `None`.
|
|
#[must_use]
|
|
pub fn from_part_name(part: &str) -> Option<Chip> {
|
|
let p = part.to_ascii_lowercase();
|
|
if p.contains("rp2040") {
|
|
Some(Chip::Rp2040)
|
|
} else if p.contains("rp2350") {
|
|
Some(Chip::Rp2350)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Every chip this module can drive.
|
|
pub const ALL: [Chip; 2] = [Chip::Rp2040, Chip::Rp2350];
|
|
|
|
/// The BOOTSEL product id this chip presents.
|
|
#[must_use]
|
|
pub const fn bootsel_pid(self) -> u16 {
|
|
match self {
|
|
Chip::Rp2040 => PID_RP2040_BOOTSEL,
|
|
Chip::Rp2350 => PID_RP2350_BOOTSEL,
|
|
}
|
|
}
|
|
|
|
/// The name to show a user.
|
|
#[must_use]
|
|
pub const fn name(self) -> &'static str {
|
|
match self {
|
|
Chip::Rp2040 => "RP2040",
|
|
Chip::Rp2350 => "RP2350",
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- UF2, the container the forge actually emits ---------------------------------------------
|
|
|
|
/// A UF2 block is always this long, payload or not.
|
|
pub const UF2_BLOCK_LEN: usize = 512;
|
|
/// First magic word (`"UF2\n"`).
|
|
pub const UF2_MAGIC_START0: u32 = 0x0A32_4655;
|
|
/// Second magic word.
|
|
pub const UF2_MAGIC_START1: u32 = 0x9E5D_5157;
|
|
/// Trailing magic word.
|
|
pub const UF2_MAGIC_END: u32 = 0x0AB1_6F30;
|
|
/// Family id for RP2040.
|
|
pub const UF2_FAMILY_RP2040: u32 = 0xE48B_FF56;
|
|
/// Family id for RP2350 in Arm secure mode.
|
|
pub const UF2_FAMILY_RP2350_ARM_S: u32 = 0xE48B_FF59;
|
|
/// Flag bit meaning the block is not part of the image and must not be written.
|
|
const UF2_FLAG_NOT_MAIN_FLASH: u32 = 0x0000_0001;
|
|
|
|
/// One addressed payload extracted from a UF2 file.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Uf2Chunk {
|
|
/// Where this payload belongs.
|
|
pub addr: u32,
|
|
/// The payload itself.
|
|
pub data: Vec<u8>,
|
|
}
|
|
|
|
/// Parse a UF2 image into the payloads it wants written.
|
|
///
|
|
/// Blocks flagged `NOT_MAIN_FLASH` are skipped rather than written — that flag exists precisely to
|
|
/// carry metadata a flasher must not put on the device.
|
|
pub fn parse_uf2(image: &[u8]) -> Result<Vec<Uf2Chunk>, PicobootError> {
|
|
// `is_empty` stays explicit: 0 IS a multiple of 512, so folding it into the
|
|
// divisibility check would accept an empty file as a valid zero-block image.
|
|
if image.is_empty() || !image.len().is_multiple_of(UF2_BLOCK_LEN) {
|
|
return Err(PicobootError::Uf2NotBlockAligned(image.len()));
|
|
}
|
|
let word = |b: &[u8], at: usize| u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
|
|
let mut out = Vec::with_capacity(image.len() / UF2_BLOCK_LEN);
|
|
for (i, block) in image.as_chunks::<UF2_BLOCK_LEN>().0.iter().enumerate() {
|
|
if word(block, 0) != UF2_MAGIC_START0
|
|
|| word(block, 4) != UF2_MAGIC_START1
|
|
|| word(block, UF2_BLOCK_LEN - 4) != UF2_MAGIC_END
|
|
{
|
|
return Err(PicobootError::Uf2BadMagic { block: i });
|
|
}
|
|
let flags = word(block, 8);
|
|
let addr = word(block, 12);
|
|
let payload_size = word(block, 16);
|
|
if flags & UF2_FLAG_NOT_MAIN_FLASH != 0 {
|
|
continue;
|
|
}
|
|
// The payload lives at offset 32 and the trailing magic is the last word, so this is the
|
|
// most a block can carry. A larger claim is a malformed file, not a big block.
|
|
if payload_size as usize > UF2_BLOCK_LEN - 32 - 4 {
|
|
return Err(PicobootError::Uf2PayloadTooLong {
|
|
block: i,
|
|
claimed: payload_size,
|
|
});
|
|
}
|
|
out.push(Uf2Chunk {
|
|
addr,
|
|
data: block[32..32 + payload_size as usize].to_vec(),
|
|
});
|
|
}
|
|
if out.is_empty() {
|
|
return Err(PicobootError::Uf2Empty);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Merge consecutive chunks that are physically adjacent into single runs.
|
|
///
|
|
/// A UF2 carries 256 bytes per block, so a 1 MB image is 4096 separate writes if taken literally.
|
|
/// Coalescing them turns that into a handful, which is the difference between a flash that takes
|
|
/// seconds and one that takes minutes. Non-adjacent chunks start a new run, so a sparse image is
|
|
/// still written correctly rather than being padded across its gaps.
|
|
#[must_use]
|
|
pub fn coalesce(chunks: &[Uf2Chunk]) -> Vec<Uf2Chunk> {
|
|
let mut runs: Vec<Uf2Chunk> = Vec::new();
|
|
for c in chunks {
|
|
match runs.last_mut() {
|
|
Some(run) if run.addr + run.data.len() as u32 == c.addr => {
|
|
run.data.extend_from_slice(&c.data);
|
|
}
|
|
_ => runs.push(c.clone()),
|
|
}
|
|
}
|
|
runs
|
|
}
|
|
|
|
/// The sector-aligned erase each run needs before it can be written.
|
|
///
|
|
/// `PC_FLASH_ERASE` rejects an unaligned address or size outright, and `PC_WRITE` does not erase,
|
|
/// so a flasher that skips this writes into un-erased flash and silently corrupts the image. The
|
|
/// range is widened down to the sector containing the start and up to the sector containing the
|
|
/// end.
|
|
#[must_use]
|
|
pub fn erase_plan(runs: &[Uf2Chunk]) -> Vec<(u32, u32)> {
|
|
let mut plan: Vec<(u32, u32)> = Vec::new();
|
|
for r in runs {
|
|
let start = r.addr & !(FLASH_SECTOR_SIZE - 1);
|
|
let end_byte = r.addr + r.data.len() as u32;
|
|
let end = end_byte.div_ceil(FLASH_SECTOR_SIZE) * FLASH_SECTOR_SIZE;
|
|
match plan.last_mut() {
|
|
// Two runs inside one sector must not be erased twice — the second erase would wipe
|
|
// the first run's freshly written bytes.
|
|
Some(last) if last.0 + last.1 >= start => {
|
|
let new_end = end.max(last.0 + last.1);
|
|
last.1 = new_end - last.0;
|
|
}
|
|
_ => plan.push((start, end - start)),
|
|
}
|
|
}
|
|
plan
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Build a syntactically valid UF2 block.
|
|
fn uf2_block(addr: u32, payload: &[u8], flags: u32, block_no: u32, total: u32) -> Vec<u8> {
|
|
let mut b = vec![0u8; UF2_BLOCK_LEN];
|
|
b[0..4].copy_from_slice(&UF2_MAGIC_START0.to_le_bytes());
|
|
b[4..8].copy_from_slice(&UF2_MAGIC_START1.to_le_bytes());
|
|
b[8..12].copy_from_slice(&flags.to_le_bytes());
|
|
b[12..16].copy_from_slice(&addr.to_le_bytes());
|
|
b[16..20].copy_from_slice(&(payload.len() as u32).to_le_bytes());
|
|
b[20..24].copy_from_slice(&block_no.to_le_bytes());
|
|
b[24..28].copy_from_slice(&total.to_le_bytes());
|
|
b[28..32].copy_from_slice(&UF2_FAMILY_RP2040.to_le_bytes());
|
|
b[32..32 + payload.len()].copy_from_slice(payload);
|
|
b[UF2_BLOCK_LEN - 4..].copy_from_slice(&UF2_MAGIC_END.to_le_bytes());
|
|
b
|
|
}
|
|
|
|
#[test]
|
|
fn the_command_packet_matches_the_sdk_layout() {
|
|
let p = exclusive_access(0xdead_beef, Exclusive::AndEject);
|
|
let b = p.bytes();
|
|
assert_eq!(b.len(), 32, "picoboot_cmd is 32 bytes");
|
|
assert_eq!(&b[0..4], &0x431f_d10bu32.to_le_bytes(), "PICOBOOT_MAGIC");
|
|
assert_eq!(&b[4..8], &0xdead_beefu32.to_le_bytes(), "dToken");
|
|
assert_eq!(b[8], 0x01, "PC_EXCLUSIVE_ACCESS");
|
|
assert_eq!(b[9], 1, "bCmdSize is the arg byte count");
|
|
assert_eq!(&b[10..12], &[0, 0], "the reserved u16 must be zero");
|
|
assert_eq!(&b[12..16], &0u32.to_le_bytes(), "no data stage");
|
|
assert_eq!(b[16], 2, "EXCLUSIVE_AND_EJECT");
|
|
}
|
|
|
|
#[test]
|
|
fn the_opcodes_are_the_sdk_values_and_the_in_bit_is_part_of_them() {
|
|
assert_eq!(Command::ExclusiveAccess.id(), 0x01);
|
|
assert_eq!(Command::Reboot.id(), 0x02);
|
|
assert_eq!(Command::FlashErase.id(), 0x03);
|
|
assert_eq!(Command::Read.id(), 0x84);
|
|
assert_eq!(Command::Write.id(), 0x05);
|
|
assert_eq!(Command::ExitXip.id(), 0x06);
|
|
assert_eq!(Command::EnterCmdXip.id(), 0x07);
|
|
assert_eq!(Command::Reboot2.id(), 0x0a);
|
|
assert_eq!(Command::GetInfo.id(), 0x8b);
|
|
// The direction is encoded IN the id, not beside it.
|
|
assert!(Command::Read.is_in_transfer() && Command::GetInfo.is_in_transfer());
|
|
assert!(!Command::Write.is_in_transfer() && !Command::FlashErase.is_in_transfer());
|
|
}
|
|
|
|
#[test]
|
|
fn a_range_command_carries_addr_then_size_and_write_announces_its_data_stage() {
|
|
let p = write(7, XIP_BASE + 0x1000, 256);
|
|
let b = p.bytes();
|
|
assert_eq!(b[8], 0x05);
|
|
assert_eq!(b[9], 8, "range_cmd is two u32s");
|
|
assert_eq!(&b[16..20], &(XIP_BASE + 0x1000).to_le_bytes(), "dAddr");
|
|
assert_eq!(&b[20..24], &256u32.to_le_bytes(), "dSize");
|
|
assert_eq!(
|
|
&b[12..16],
|
|
&256u32.to_le_bytes(),
|
|
"a write announces the bytes that follow"
|
|
);
|
|
// An argument-less command says so, rather than sending stale bytes.
|
|
let x = exit_xip(7);
|
|
assert_eq!(x.bytes()[9], 0, "bCmdSize == 0");
|
|
assert!(x.bytes()[16..].iter().all(|&v| v == 0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_status_reply_is_checked_against_its_command() {
|
|
let sent = flash_erase(0x1234, XIP_BASE, FLASH_SECTOR_SIZE);
|
|
let mut reply = [0u8; STATUS_LEN];
|
|
reply[0..4].copy_from_slice(&0x1234u32.to_le_bytes());
|
|
reply[8] = 0x03;
|
|
let s = Status::parse(&reply).unwrap().check(&sent).unwrap();
|
|
assert!(s.is_ok() && !s.in_progress);
|
|
|
|
// A failure is reported with its documented meaning, not just a number.
|
|
reply[4..8].copy_from_slice(&5u32.to_le_bytes());
|
|
let err = Status::parse(&reply).unwrap().check(&sent).unwrap_err();
|
|
assert_eq!(
|
|
err,
|
|
PicobootError::Failed {
|
|
code: 5,
|
|
reason: "bad alignment"
|
|
}
|
|
);
|
|
|
|
// A reply for a DIFFERENT command must not be read as this one's success.
|
|
reply[4..8].copy_from_slice(&0u32.to_le_bytes());
|
|
reply[0..4].copy_from_slice(&0x9999u32.to_le_bytes());
|
|
assert!(matches!(
|
|
Status::parse(&reply).unwrap().check(&sent),
|
|
Err(PicobootError::TokenMismatch { .. })
|
|
));
|
|
|
|
// A truncated reply is an error, never a silent success.
|
|
assert_eq!(Status::parse(&[0u8; 8]), Err(PicobootError::ShortStatus(8)));
|
|
}
|
|
|
|
#[test]
|
|
fn bootsel_devices_are_identified_and_nothing_else_is() {
|
|
assert_eq!(Chip::from_usb(0x2e8a, 0x0003), Some(Chip::Rp2040));
|
|
assert_eq!(Chip::from_usb(0x2e8a, 0x000f), Some(Chip::Rp2350));
|
|
// A Pico running user code (stdio USB) is NOT in BOOTSEL and must not be offered.
|
|
assert_eq!(Chip::from_usb(0x2e8a, 0x000a), None);
|
|
assert_eq!(
|
|
Chip::from_usb(0x2e8a, 0x0004),
|
|
None,
|
|
"picoprobe is not a target"
|
|
);
|
|
assert_eq!(
|
|
Chip::from_usb(0x1234, 0x0003),
|
|
None,
|
|
"vendor must match too"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_rp2_parts_are_claimed_so_a_samd_uf2_board_is_not_offered() {
|
|
assert_eq!(Chip::from_part_name("RP2040"), Some(Chip::Rp2040));
|
|
assert_eq!(Chip::from_part_name("rp2350"), Some(Chip::Rp2350));
|
|
// A SAMD51 mounts a UF2 drive too and has no PICOBOOT behind it. Claiming it would put
|
|
// a button in front of a board this protocol cannot reach.
|
|
assert_eq!(Chip::from_part_name("ATSAMD51P19"), None);
|
|
assert_eq!(Chip::from_part_name("nRF52833"), None);
|
|
// The BOOTSEL ids round-trip with the USB identification.
|
|
for c in Chip::ALL {
|
|
assert_eq!(Chip::from_usb(VENDOR_ID, c.bootsel_pid()), Some(c));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_uf2_parses_into_addressed_payloads() {
|
|
let mut img = uf2_block(XIP_BASE, &[0xAA; 256], 0, 0, 2);
|
|
img.extend(uf2_block(XIP_BASE + 256, &[0xBB; 256], 0, 1, 2));
|
|
let chunks = parse_uf2(&img).unwrap();
|
|
assert_eq!(chunks.len(), 2);
|
|
assert_eq!(chunks[0].addr, XIP_BASE);
|
|
assert_eq!(chunks[0].data, vec![0xAA; 256]);
|
|
assert_eq!(chunks[1].addr, XIP_BASE + 256);
|
|
}
|
|
|
|
#[test]
|
|
fn a_not_main_flash_block_is_skipped_not_written() {
|
|
// That flag exists to carry metadata; writing it would put non-image bytes on the device.
|
|
let mut img = uf2_block(XIP_BASE, &[0xAA; 256], UF2_FLAG_NOT_MAIN_FLASH, 0, 2);
|
|
img.extend(uf2_block(XIP_BASE + 256, &[0xBB; 256], 0, 1, 2));
|
|
let chunks = parse_uf2(&img).unwrap();
|
|
assert_eq!(chunks.len(), 1, "the flagged block must not be flashed");
|
|
assert_eq!(chunks[0].addr, XIP_BASE + 256);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_uf2_images_are_rejected_rather_than_half_flashed() {
|
|
assert_eq!(parse_uf2(&[]), Err(PicobootError::Uf2NotBlockAligned(0)));
|
|
assert_eq!(
|
|
parse_uf2(&[0u8; 700]),
|
|
Err(PicobootError::Uf2NotBlockAligned(700))
|
|
);
|
|
assert_eq!(
|
|
parse_uf2(&[0u8; 512]),
|
|
Err(PicobootError::Uf2BadMagic { block: 0 })
|
|
);
|
|
// A payload larger than a block can hold is a malformed file, and reading it would run
|
|
// past the block into the next one's header.
|
|
let mut bad = uf2_block(XIP_BASE, &[0xAA; 256], 0, 0, 1);
|
|
bad[16..20].copy_from_slice(&500u32.to_le_bytes());
|
|
assert_eq!(
|
|
parse_uf2(&bad),
|
|
Err(PicobootError::Uf2PayloadTooLong {
|
|
block: 0,
|
|
claimed: 500
|
|
})
|
|
);
|
|
// Every block flagged out leaves nothing to write, which is worth saying explicitly.
|
|
let empty = uf2_block(XIP_BASE, &[0xAA; 256], UF2_FLAG_NOT_MAIN_FLASH, 0, 1);
|
|
assert_eq!(parse_uf2(&empty), Err(PicobootError::Uf2Empty));
|
|
}
|
|
|
|
#[test]
|
|
fn adjacent_chunks_coalesce_and_gaps_do_not() {
|
|
let chunks = vec![
|
|
Uf2Chunk {
|
|
addr: 0x1000_0000,
|
|
data: vec![1; 256],
|
|
},
|
|
Uf2Chunk {
|
|
addr: 0x1000_0100,
|
|
data: vec![2; 256],
|
|
},
|
|
// A gap: this must start a new run rather than be padded onto the previous one.
|
|
Uf2Chunk {
|
|
addr: 0x1001_0000,
|
|
data: vec![3; 256],
|
|
},
|
|
];
|
|
let runs = coalesce(&chunks);
|
|
assert_eq!(runs.len(), 2, "two contiguous, one detached");
|
|
assert_eq!(runs[0].addr, 0x1000_0000);
|
|
assert_eq!(runs[0].data.len(), 512, "the adjacent pair merged");
|
|
assert_eq!(runs[1].addr, 0x1001_0000);
|
|
assert_eq!(runs[1].data.len(), 256);
|
|
}
|
|
|
|
#[test]
|
|
fn the_erase_plan_is_sector_aligned_and_never_erases_written_bytes_twice() {
|
|
// A run that starts mid-sector must erase from the sector boundary...
|
|
let runs = vec![Uf2Chunk {
|
|
addr: XIP_BASE + 100,
|
|
data: vec![0; 10],
|
|
}];
|
|
assert_eq!(erase_plan(&runs), vec![(XIP_BASE, FLASH_SECTOR_SIZE)]);
|
|
|
|
// ...and one byte past a sector must erase two.
|
|
let runs = vec![Uf2Chunk {
|
|
addr: XIP_BASE,
|
|
data: vec![0; FLASH_SECTOR_SIZE as usize + 1],
|
|
}];
|
|
assert_eq!(erase_plan(&runs), vec![(XIP_BASE, FLASH_SECTOR_SIZE * 2)]);
|
|
|
|
// Two runs sharing a sector must yield ONE erase. Two would wipe the first run's bytes
|
|
// after they had already been written.
|
|
let runs = vec![
|
|
Uf2Chunk {
|
|
addr: XIP_BASE,
|
|
data: vec![0; 256],
|
|
},
|
|
Uf2Chunk {
|
|
addr: XIP_BASE + 1024,
|
|
data: vec![0; 256],
|
|
},
|
|
];
|
|
let plan = erase_plan(&runs);
|
|
assert_eq!(plan.len(), 1, "one shared sector, one erase");
|
|
assert_eq!(plan[0], (XIP_BASE, FLASH_SECTOR_SIZE));
|
|
|
|
// Every plan entry is sector aligned in both address and size.
|
|
for (addr, size) in erase_plan(&runs) {
|
|
assert_eq!(addr % FLASH_SECTOR_SIZE, 0);
|
|
assert_eq!(size % FLASH_SECTOR_SIZE, 0);
|
|
}
|
|
}
|
|
}
|