diff --git a/README.md b/README.md index 9d538f7..6668628 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ On CH32V00x or CH32V103, the flow is the same — swap the example directory. Se The full documentation lives at **[openservocore.github.io/tinyboot](https://openservocore.github.io/tinyboot/)**. Highlights: - [**Getting Started**](https://openservocore.github.io/tinyboot/getting-started.html) — expanded tutorial with more detail and per-chip notes -- [**CLI reference**](cli/README.md) — `tinyboot flash / info / erase / reset / bin` +- [**CLI reference**](cli/README.md) — `tinyboot flash / info / erase / reset / baud / discover / bin` - [**App integration**](https://openservocore.github.io/tinyboot/app-integration.html) — put `poll()` and `confirm()` into your own firmware - [**Remote firmware updates**](https://openservocore.github.io/tinyboot/remote-updates.html) — end-to-end OTA flow - [**Troubleshooting**](https://openservocore.github.io/tinyboot/troubleshooting.html) — things that go wrong and how to fix them diff --git a/ch32/README.md b/ch32/README.md index 22a4308..63b7061 100644 --- a/ch32/README.md +++ b/ch32/README.md @@ -16,8 +16,8 @@ tinyboot-ch32-rt = "0.4" # optional, bootloader-only | Module | For | What it provides | | ---------- | -------------------- | ------------------------------------------------------------------------------------------------ | -| `boot` | Bootloader binaries | `run()`, `BootCtl`, USART transport (`Usart`, `UsartConfig`, `BaudRate`, `FullDuplex` / `HalfDuplex`, `TxEn` / `NoTxEn`) | -| `app` | Application binaries | `new_app()`, `App`, `BootCtl`, the `tinyboot_core::app` types | +| `boot` | Bootloader binaries | `run()`, `BootCtl`, `Addressable` / `Unaddressed`, `chip_uid`, USART transport (`Usart`, `UsartConfig`, `BaudRate`, `FullDuplex` / `HalfDuplex`, `TxEn` / `NoTxEn`) | +| `app` | Application binaries | `new_app()`, `new_addressable_app()`, `App`, `BootCtl`, the `tinyboot_core::app` types | | `hal` | Both | `flash`, `gpio`, `usart`, `afio`, `rcc`, `pfic`, `iwdg`; auto-generated `Pin` and `UsartMapping` | | `platform` | (internal) | `tinyboot_core::traits` impls for Storage, Transport, BootCtl, BootMetaStore | @@ -40,10 +40,12 @@ fn main() -> ! { wire: FullDuplex { rx_pull: Pull::None, drive: PushPull }, tx_en: NoTxEn, }); - tinyboot_ch32::boot::run(transport, BootCtl::new()); + tinyboot_ch32::boot::run(transport, BootCtl::new(), Unaddressed); } ``` +`Unaddressed` answers every frame, for a device that has the bus to itself. With several devices on one bus, pass `Addressable::new(chip_uid)` instead: the bootloader then stays silent until a host selects it by the chip's unique id (see the [shared bus guide](https://openservocore.github.io/tinyboot/shared-bus.html)). + `Storage` and `BootMetaStore` are initialized from linker symbols automatically. `boot_version!()` ends the image with its tail: a compatibility key and the crate's `Cargo.toml` version. `boot_version!(compat = "name")` sets the key, and the bootloader then accepts only app images built with the same `app_version!(compat = "name")` (see [app integration](https://openservocore.github.io/tinyboot/app-integration.html)). `boot::run` reads frames with up to 52 payload bytes (a whole frame fits one 64-byte USB packet); `boot::run_with_payload::` takes `N` bytes per Write (at least 18, not limited to a flash page) for faster updates, at `N` bytes of RAM. Hosts read the capacity from Info. @@ -63,7 +65,7 @@ loop { } ``` -`app::poll` handles `Info` (responds with capacity, erase size, versions, `mode = 1`, the stored boot baud and the bootloader's compatibility key) and `Reset` (resets the device; `addr = 1` reboots into the bootloader, `addr = 0` reboots into the app). All other commands return `Status::Unsupported`. +`app::poll` handles `Info` (responds with capacity, erase size, versions, `mode = 1`, the stored boot baud and the bootloader's compatibility key) and `Reset` (resets the device; `ResetFlags::BOOTLOADER` reboots into the bootloader, otherwise into the app). All other commands return `Status::Unsupported`. On a shared bus, `app::new_addressable_app(ctl, wait_bytes)` also handles `Select` and stays silent until selected. See the [app integration guide](https://openservocore.github.io/tinyboot/app-integration.html) for a complete app including `embedded_io` transport wrapping and peripheral ownership. diff --git a/docs/README.md b/docs/README.md index 9082c81..0a6f5ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ If you're new here, start with the [top-level README](https://github.com/OpenSer - [GPIO-controlled boot mode selection](boot-ctl.md) — BOOT0 circuits for chips with hardware boot pins - [App integration](app-integration.md) — wire the tinyboot app side into your firmware - [Remote firmware updates](remote-updates.md) — the end-to-end OTA flow +- [Shared bus (addressable)](shared-bus.md) - several devices on one bus, selected by unique id - [Building your bootloader from an example](examples.md) ## Reference diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index ed47ede..3df92c1 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -13,6 +13,7 @@ - [GPIO-controlled boot mode selection](boot-ctl.md) - [App integration](app-integration.md) - [Remote firmware updates](remote-updates.md) +- [Shared bus (addressable)](shared-bus.md) - [Building your bootloader from an example](examples.md) # Reference diff --git a/docs/app-integration.md b/docs/app-integration.md index 0550fbf..2693e20 100644 --- a/docs/app-integration.md +++ b/docs/app-integration.md @@ -6,7 +6,7 @@ The tinyboot bootloader is only half the story — to support remote firmware up 1. **Declare its version** so the host can see it via `tinyboot info`. 2. **Confirm successful boot** so the bootloader stops retrying. -3. **Poll the transport** for `Info` and `Reset` requests. +3. **Poll the transport** for `Info` and `Reset` requests (and `Select` on a shared bus). That's it. The bootloader handles everything else — flashing, verification, state transitions, trial boot. @@ -57,12 +57,13 @@ If `confirm()` is never reached (panic, watchdog, init deadlock), the trials get ## `app::poll()` — handling bootloader commands -`poll()` reads a single frame from your transport and handles it. In the app, two commands do something; the rest are rejected with `Status::Unsupported`: +`poll()` reads a single frame from your transport and handles it. In the app, two commands do something (three when addressable), and the rest are rejected with `Status::Unsupported`: | Command | Behavior in app | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Info` | Responds with capacity, erase size, boot + app versions, `mode = 1` (app mode), the stored boot baud and the bootloader's compatibility key. | | `Reset` | Resets the device. The `BOOTLOADER` flag reboots into the bootloader, otherwise into the app. The `BAUD` flag first stores addr byte 0 as the bootloader's baud. | +| `Select` | Addressable apps only: selects or deselects the device by unique id. An unselected app answers nothing else. | This is enough for the host CLI to do `tinyboot info` and `tinyboot reset --bootloader` while the app is running, which is how remote updates get kicked off — see the [remote updates guide](remote-updates.md). @@ -94,6 +95,10 @@ tinyboot_ch32::app::app_version!(compat = "acme-widget-rev-b"); `compat = "name"` hashes the name at compile time (32-bit FNV-1a); `compat_key = 0x1234_5678` takes a raw value. tinyboot only compares keys, so encode whatever has to match: product, board revision, pinout. `Verify` refuses an image with another key, or none, with `Status::Incompatible` and leaves the device in its bootloader waiting for the right image; a mistaken image never starts. A bootloader built without a key accepts any image and carries no check. +## On a shared bus + +Several devices on one bus need the addressable app, created with `new_addressable_app(BootCtl::new(), wait_bytes)`. It stays silent until the host selects it by the chip's unique id, like the addressable bootloader. `wait_bytes(n)` waits about `n` byte-times at the bus baud, and the example apps compute it from the USART's baud divisor. Build the bootloader addressable too. See the [shared bus guide](shared-bus.md). + ## UART sharing notes The bootloader and app normally share the same USART. A few gotchas: diff --git a/docs/getting-started.md b/docs/getting-started.md index 4ca1ddc..b7bd7fe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -65,7 +65,7 @@ tinyboot flash target/riscv32ec-unknown-none-elf/release/app --reset `--reset` tells the bootloader to jump into the app once the flash and verify steps succeed. You should see the LED blink (TIM2-driven, ~1 Hz) — that's the app running. -If `--port` is omitted, the CLI probes USB serial ports by sending an Info request to each. If auto-detection fails, pass `--port /dev/ttyUSB0` (or the equivalent on your OS). +If `--port` is omitted, the CLI probes USB serial ports by sending a `Select` request to each. If auto-detection fails, pass `--port /dev/ttyUSB0` (or the equivalent on your OS). ## 6. Verify it worked diff --git a/docs/porting-series.md b/docs/porting-series.md index c18ac06..472652f 100644 --- a/docs/porting-series.md +++ b/docs/porting-series.md @@ -74,6 +74,8 @@ mod family; Repeat for `rcc/` and `afio/` if their versions differ too. +Check that the series keeps its 96-bit unique id at `0x1FFF_F7E8` (the ESIG area, `ch32/src/hal/esig.rs`), which the addressable bootloader reads. Move it behind a `cfg` if not. + ### Boot mode selection The flash controller version also determines how the bootloader selects its flash region and persists run-mode across resets. The `build.rs` derives this automatically from the `boot_pin` flag (which itself comes from the flash version): diff --git a/docs/porting.md b/docs/porting.md index 6a7f65d..599385c 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -16,11 +16,15 @@ Mirror the layout of [`tinyboot-ch32`](https://github.com/OpenServoCore/tinyboot | Trait | What to implement | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `Transport` | Any `embedded_io::Read + Write` stream — UART, RS-485, USB, SPI, even WiFi or Bluetooth. The protocol doesn't care what carries the bytes | +| `Transport` | Any `embedded_io::Read + Write` stream — UART, RS-485, USB, SPI, even WiFi or Bluetooth. The protocol doesn't care what carries the bytes. For a shared bus, also `wait_bytes(n)`, a wait of about `n` byte-times | | `Storage` | `embedded_storage::NorFlash` (erase, write, read), plus `as_slice()` for zero-copy flash reads | | `BootMetaStore` | Read/write boot state, trial counter, app checksum, and app size from a reserved flash page (address from linker symbol) | | `BootCtl` | `run_mode()`/`set_run_mode()` for Service/HandOff intent across reset, `reset()` for software reset, `hand_off()` to boot the app | +### Unique id for a shared bus + +To support [addressable devices](shared-bus.md), expose the chip's factory unique id as a function returning `&'static [u8; N]` (1 to 16 bytes, least significant first), like `tinyboot_ch32::boot::chip_uid`. Users pass it to `Addressable::new`. A function item keeps the id's address and length constant, so it costs only the loads. + ## 2. (Optional) Create a `tinyboot-{chip}-rt` crate If your chip needs a custom `_start` + linker script to fit a small bootloader — [`tinyboot-ch32-rt`](https://github.com/OpenServoCore/tinyboot/tree/main/ch32/rt) exists for this reason on CH32 — ship one alongside. Otherwise the regular chip runtime is fine for the app. diff --git a/docs/remote-updates.md b/docs/remote-updates.md index f1414e5..ce9b8f3 100644 --- a/docs/remote-updates.md +++ b/docs/remote-updates.md @@ -39,8 +39,9 @@ app starts ├─ app::confirm() ──► META.state → Idle (keeps current checksum) │ └─ app::poll(): - ├─ Info ──► respond with capacity, versions, mode=1 - └─ Reset ──► if flag & BOOTLOADER: mark run_mode = Service, reset + ├─ Select ──► (addressable) select or deselect by unique id + ├─ Info ──► respond with capacity, versions, mode=1 + └─ Reset ──► if flag & BOOTLOADER: mark run_mode = Service, reset ``` @@ -98,6 +99,18 @@ When all four hold, the bootloader hands off to the app. As soon as you run a re | `Reset` to the app mid-flash | Refused with `Unsupported`; the device stays in the bootloader. Re-flash. | | Image built for another board | Verify returns `Incompatible` (bootloader has a compatibility key); flash the right one. | +## Several devices on one bus + +With addressable devices, add `--uid` to pick the device. Each command selects it again, so a reset in between is fine: + +```sh +tinyboot discover +tinyboot reset --bootloader --uid 1032 +tinyboot flash new-firmware.elf --reset --uid 1032 +``` + +The device's siblings stay silent the whole time, including any that sit in their bootloader waiting for firmware. See the [shared bus guide](shared-bus.md). + ## Making it automatic Any host logic that can speak the tinyboot CLI can drive updates: diff --git a/docs/shared-bus.md b/docs/shared-bus.md new file mode 100644 index 0000000..7e4a41f --- /dev/null +++ b/docs/shared-bus.md @@ -0,0 +1,86 @@ +# Shared bus (addressable) + +By default a tinyboot device answers every frame it hears. That is fine with one device on the wire. But with several devices on one bus (a chain of servos, a row of sensor boards on RS-485), they would all answer at once. + +An **addressable** device stays silent until the host selects it by its unique id. The bootloader and the app both support it, and the CLI drives it for you. + +## The short version + +```sh +# List every device on the bus by unique id. +tinyboot discover + +# Talk to one of them. A unique prefix of the id is enough. +tinyboot info --uid 1032 +tinyboot flash new-firmware.elf --uid 1032 --reset +``` + +With a single device on the bus you don't need `--uid` at all. The CLI selects the only device by itself, and falls back to the plain flow when the device is not addressable. + +## Turning it on + +In the bootloader, pass `Addressable::new(chip_uid)` to `run()`: + +```rust +tinyboot_ch32::boot::run(transport, BootCtl::new(), Addressable::new(chip_uid)); +``` + +`chip_uid` returns the chip's factory unique id (96 bits on CH32, read from the ESIG area). Pass `Unaddressed` instead for a device that has the bus to itself. + +In the app, create the handle with `new_addressable_app`: + +```rust +let mut app = tinyboot_ch32::app::new_addressable_app(BootCtl::new(), wait_bytes); +``` + +`wait_bytes(n)` waits about `n` byte-times at the bus baud. The app uses it to space out its `Select` reply (see the slot below). The examples compute it from the USART's baud divisor, so it needs no timer. + +Make sure the bootloader and the app agree. If one is addressable and the other is not, the device answers differently depending on which mode it is in. + +## The silence rule + +An addressable device only speaks in two cases: + +- It answers a `Select` whose prefix matches its id. +- It answers any other command while it is selected. + +It never answers a frame it can't read (bad CRC, unknown command, payload too big), and it ignores frames that are replies from other devices. Selection lives in RAM, so every reset starts deselected. + +This means a device sitting in its bootloader never disturbs the bus. Several devices waiting for firmware at once is fine too. Each one stays quiet until the host picks it. + +## How `Select` works + +`Select` carries a seed and a prefix of id bits. Every device whose id starts with the prefix becomes selected and replies with its id. Every other device becomes deselected. + +- An empty prefix selects every device. +- A full id selects exactly one device. +- A prefix longer than any id selects none, which is how the host deselects everyone. + +The [protocol reference](https://github.com/OpenServoCore/tinyboot/tree/main/lib/protocol#select) has the exact byte layout. + +## Finding every device + +When two devices reply at the same time, their bytes overlap on the wire and the host reads garble. The host uses that as a signal. `tinyboot discover` walks the id tree one bit at a time: + +1. Select a prefix (it starts empty). +2. Silence means no device under this prefix, so this branch is done. +3. Garble means two or more devices, so try the prefix plus `0`, then plus `1`. +4. A clean reply is one device. The host double checks it by probing the next bit both ways. The device has to answer alone under one and nothing under the other. + +The double check is there because devices running the same code can answer in exact unison. On some wires that can read back as one clean frame. Each device waits a **slot** before replying, 0 to 15 byte-times picked from a hash of its id and the seed. The host sends a new seed with every probe, so two devices that collided once get a fresh draw next time. + +Each probe listens about 50 ms for replies. Devices whose ids part early are found in a few probes each. Two ids that share a long prefix take longer, since the walk goes down that prefix one bit at a time. The worst case, two 96-bit ids that differ only in their last bit, takes about 150 probes, so several seconds. + +## Custom transports + +The slot wait goes through `Transport::wait_bytes`. The CH32 USART implements it by spinning on the baud divisor. If you write your own transport for a shared bus, implement it too. The default returns right away, which still works, it just gives up the unison protection. + +## What it costs + +On CH32V003 in system flash, addressable adds about 130 bytes. Every combination of wire setup, DE pin, compatibility key and addressing still fits the 1920-byte system flash. CI builds the heaviest one (`examples/ch32/v003/boot/src/bin/heaviest.rs`) to keep it that way. + +## Caveats + +- Don't mix addressable and unaddressed devices on one bus. An unaddressed device answers everything, including `Select`, so it garbles every probe. +- Two devices with the same id can't be told apart. The walk stops with an error. Chip unique ids are unique, so this only happens with a custom id source. +- The CLI's `--uid` takes whole bytes (2 hex digits each), in the order `tinyboot discover` prints them. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4590fd0..b60cae7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -32,6 +32,16 @@ Something in the UART chain isn't right. Work through these in order: --- +## `more than one device answered` or `no device answered` + +The CLI selects a device before every command (see the [shared bus guide](shared-bus.md)). + +- **More than one device** - several addressable devices share the bus. Run `tinyboot discover` and pass `--uid`. +- **No device answered** - the usual wiring and baud causes above apply. An addressable device also stays silent until selected, so a custom host tool has to send `Select` first. +- **Mixed bus** - an unaddressed device answers every frame, `Select` included, and garbles every probe. Build every device on a shared bus addressable. + +--- + ## Bootloader changes don't take effect after `wlink flash` After writing to system flash on some chips, a full power cycle is required before the new bootloader runs. A software reset or re-attach is not enough. diff --git a/lib/protocol/README.md b/lib/protocol/README.md index dd55718..273d755 100644 --- a/lib/protocol/README.md +++ b/lib/protocol/README.md @@ -26,8 +26,8 @@ Total frame size = 12 bytes overhead + payload. The payload capacity is a build | STATUS | 1 byte | `Request (0x00)` for requests, result status for responses | | ADDR | 3 bytes | Flash address (u24 LE). Echoed in responses | | FLAGS | 1 byte | Per-command flags (see below). Occupies addr byte 3 | -| LEN | 2 bytes | Data payload length (u16 LE, 0..64) | -| DATA | 0..64 | Payload bytes | +| LEN | 2 bytes | Data payload length (u16 LE, 0..N, N the receiver's payload capacity) | +| DATA | 0..N | Payload bytes | | CRC | 2 bytes | CRC16-CCITT (LE) over SYNC + CMD + STATUS + ADDR + FLAGS + LEN + DATA | ## Commands @@ -39,6 +39,7 @@ Total frame size = 12 bytes overhead + payload. The payload capacity is a build | 0x02 | Write | Host to Device | Write data at address. `WriteFlags::FLUSH` commits trailing partial page | | 0x03 | Verify | Host to Device | Compute CRC16 over `addr` bytes of app, store checksum + state in metadata. Refused with `Incompatible` when the bootloader has a compatibility key and the image's tail carries another | | 0x04 | Reset | Host to Device | Reset the device. `ResetFlags::BOOTLOADER` enters bootloader (service), `ResetFlags::BAUD` stores a baud first | +| 0x05 | Select | Host to Device | Select addressable devices by unique id prefix (see [Select](#select)). A device without addressing answers `Unsupported` | ## Per-command flags @@ -84,6 +85,20 @@ Versions are packed as `(major << 11) | (minor << 6) | patch` and read from the Each boot and app image ends with 6 bytes: the compatibility key (u32 LE), then the version (u16 LE). An app image's tail starts word-aligned, so its size is 2 past a multiple of 4. The key is opaque and compared only for equality; `0xFFFF_FFFF` means none. `compat_key(name)` derives one from a name (32-bit FNV-1a, never `0xFFFF_FFFF`). +### Select + +Several addressable devices can share one bus. Each stays silent until the host selects it by its unique id. + +Request payload: `[seed, prefix_len, prefix...]`. `prefix_len` counts bits, and `prefix` holds `ceil(prefix_len / 8)` bytes. Bit `k` of an id is `id[k / 8] >> (k % 8) & 1`, with the id stored least significant byte first, so a prefix of whole bytes is the id's leading bytes. + +- A device whose id starts with the prefix is selected. It waits `slot` byte-times, then replies `Ok` with its id as data (12 bytes on CH32). `slot = (crc16(0xFFFF, id) >> (seed % 16)) & 15`. +- Every other device is deselected and does not reply. +- An empty prefix selects every device, a full id exactly one, and a `prefix_len` longer than any id none (deselect all). + +An addressable device answers `Select` when it matches, and any other command only while selected. It never answers a frame it can't read (bad CRC, unknown command, payload overflow) or a frame whose status is not `Request` (another device's reply). Selection lives in RAM, so a reset deselects it. + +Replies from two or more devices at once overlap and read as garble. The slot keeps devices running the same code from answering in exact unison, which on some wires reads back as one clean frame. A fresh `seed` per probe gives a fresh slot. A host finds every device by walking the id tree. Silence ends a branch, garble descends one bit, and a clean reply is a candidate, confirmed when its device answers alone under the next bit of its id and nothing answers under the other. + ### Erase Erases `byte_count` bytes starting at `addr`. Both `addr` and `byte_count` must be aligned to the device's erase size. @@ -142,6 +157,8 @@ CRC16-CCITT with polynomial `0x1021` and initial value `0xFFFF`. Computed over t The same `Frame` struct is reused: after `read()`, the device modifies `status`, `len`, and `data`, then calls `send()`. The `cmd` and `addr` fields carry over automatically. +An addressable device replies only as [Select](#select) describes, so on a shared bus the host selects a device before anything else. + ## Data union The `data` field is a `#[repr(C)]` union with typed variants for structured payloads: @@ -160,6 +177,9 @@ Data starts at offset 10 (even-aligned), so `u16` fields in the union variants a ## Example: flash sequence ``` +Host -> Select seed=1 prefix_len=0 (addressable devices only) +Device -> Ok uid=[12 bytes] + Host -> Info request Device -> Info response (capacity=16384, erase_size=64, mode=0)