Skip to content

Commit f8cac52

Browse files
committed
feat(dfu_split): add docs, doc comments
Signed-off-by: Pascal Jäger <pascal.jaeger@leimstift.de>
1 parent 2c961d7 commit f8cac52

3 files changed

Lines changed: 309 additions & 22 deletions

File tree

docs/docs/main/docs/user_guide/flash_firmware/use_embassy_boot.mdx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,126 @@ Additionally the bootymcbootface bootloader has blinking codes using PIN_25 (RP2
211211
| Successful DFU→ACTIVE copy; about to jump | 5 short blinks (50 ms) |
212212
| Bootloader itself panicked (e.g. flash read error, invalid state partition) | Morse SOS (... --- ...), repeating |
213213

214+
---
215+
216+
## Split peripheral updates (dfu_split)
217+
218+
When using a split keyboard with separate microcontrollers for each half,
219+
`dfu_split` lets you update the peripheral's firmware without a debug
220+
probe — the central acts as a relay.
221+
222+
In order for this to work, the peripheral needs to be flashed with an embassy-boot bootloader (e.g. bootymcbootface) and with a peripheral firmware that has `dfu_split` enabled. (See above on how to do that)
223+
224+
### Feature flags
225+
226+
```toml
227+
[dependencies]
228+
rmk = { features = [ "dfu_rp", "split", "dfu_split"] } # or dfu_nrf instead of dfu_rp
229+
```
230+
231+
`dfu_split` requires `dfu` (implied by `dfu_rp` / `dfu_nrf`) and `split`.
232+
233+
### Architecture
234+
235+
Two complementary paths are provided:
236+
237+
| Path | Mechanism | When to use |
238+
|---|---|---|
239+
| **Embedded** | The central includes the peripheral binary at compile time via `include_bytes!()` and flashes it automatically when the peripheral connects. | CI/CD, production, "flash-and-forget" |
240+
| **Passthrough** | `dfu-util -a 1 -D peripheral.bin` sends the firmware through the central's USB DFU interface, which forwards it to the peripheral in real time. | Development, ad-hoc updates without recompiling the central |
241+
242+
Both paths split the firmware into 256-byte chunks and send them over the
243+
split link as `FirmwareChunk` messages. The peripheral writes them to its
244+
own DFU flash partition, verifies the CRC, and resets into the new image.
245+
246+
### Embedded firmware path
247+
248+
In order to embed the peripherals firmware into the centrals firmware, we need to tell the central during compilation where the binary of the peripheral is.
249+
Note that this must be a .bin-file. You can create one using `cargo make bin`
250+
251+
import { Tab, Tabs, Rust, Toml } from '@theme'
252+
253+
<Tabs>
254+
<Tab label={<Toml />}>
255+
256+
Add a firmware field with the path to the peripherals firmware binary in the peripheral's section. This is a relative path starting from where the Cargo.toml.
257+
258+
```toml title="keyboard.toml"
259+
[[split.peripheral]]
260+
rows = 2
261+
cols = 1
262+
firmware = "./../peripheral.bin"
263+
```
264+
265+
The `#[rmk_central]` macro then generates the `set_firmware_update_data`
266+
call automatically. When the field is absent the central relies on
267+
passthrough only.
268+
269+
</Tab>
270+
<Tab label={<Rust />}>
271+
272+
Add this inside the main function of the central:
273+
274+
```rust title="central.rs"
275+
const PERIPHERAL_FW: &[u8] = include_bytes!("../peripheral.bin");
276+
rmk::dfu::set_firmware_update_data(0, PERIPHERAL_FW, rmk::crc32::crc32(PERIPHERAL_FW));
277+
```
278+
279+
Where the first argument of `set_firmware_update_data` is the index of the peripheral. (The same as the first argument of `run_peripheral_manager`.)
280+
281+
</Tab>
282+
</Tabs>
283+
284+
After flashing the central, it will ask the peripheral for its firmware checksum, the peripheral answers and if the checksum does not match with the checksum of the embedded firmware, it will start flashing the embedded peripheral firmware onto the peripheral. The blink codes of the led (see above) are the same like during a normal DFU update.
285+
286+
### Passthrough path
287+
288+
```shell
289+
# Flash peripheral 0
290+
dfu-util -a 1 -D peripheral.bin
291+
292+
# Flash peripheral 1 (if you have a second peripheral)
293+
dfu-util -a 2 -D peripheral2.bin
294+
295+
# Flash central
296+
dfu-util -a 0 -D central.bin -R
297+
```
298+
299+
This works identically to the normal DFU flash — just select a
300+
different alternate setting (`-a`).
301+
302+
::: warning
303+
Do **not** use `-R` (reset) when flashing a peripheral. The peripheral
304+
resets itself after the update. `-R` would reset the central and abort
305+
the transfer.
306+
:::
307+
308+
::: note
309+
The index of the alternate setting (`-a`) is off by 0 due to technical reasons. So to flash the peripheral with index 0, you have to pass `-a 1`.
310+
:::
311+
312+
### Peripheral firmware requirements
313+
314+
The peripheral must call `init_flash()` and `mark_booted()` so that the
315+
bootloader does not revert the update. With `#[rmk_peripheral(id = …)]`
316+
(TOML API) this is generated automatically when a `[dfu]` section is
317+
present in `keyboard.toml`.
318+
319+
If you are using the Rust API on the peripheral, ensure you have:
320+
321+
```rust
322+
rmk::dfu::mark_booted();
323+
```
324+
325+
in the main function.
326+
327+
Without `mark_booted()` embassy-boot will revert the update (visible as three short blinks of the DFU LED when using bootymcbootface).
328+
329+
### Troubleshooting
330+
331+
| Symptom | Likely cause |
332+
|---|---|
333+
| Peripheral blinks 3 times after update | `mark_booted()` not called in the peripheral. Add `[dfu]` to `keyboard.toml` or call `rmk::dfu::mark_booted()` manually. |
334+
| `dfu-util -a 1` shows `dfuERROR` | Peripheral not connected or its firmware is built without the `dfu_split` feature. |
335+
| `dfu-util` hangs on `dfuMANIFEST` | Expected — no USB reset was sent. The peripheral has already been updated. Disconnect / reconnect USB, let the host time out or exit dfu-util |
336+

rmk/src/dfu.rs

Lines changed: 163 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ struct RmkDfuInterface {
323323

324324
#[cfg(any(feature = "dfu_rp", feature = "dfu_nrf"))]
325325
impl Handler for RmkDfuInterface {
326+
/// Called by embassy-usb after the host selects an alternate setting.
327+
///
328+
/// Alt 0 → central's own DFU flash. Alt 1..N → passthrough to a
329+
/// split peripheral (only available when `dfu_split` is enabled).
330+
/// The value is read by `control_out` and `control_in` to dispatch
331+
/// USB requests to the right sub-handler.
326332
fn set_alternate_setting(&mut self, _iface: InterfaceNumber, alternate_setting: u8) {
327333
info!("dfu: set_alternate_setting(iface={}, alt={})", _iface.0, alternate_setting);
328334
self.current_alt = alternate_setting;
@@ -342,6 +348,25 @@ impl Handler for RmkDfuInterface {
342348
}
343349
}
344350

351+
/// Dispatch control-IN requests to the active alternate setting.
352+
///
353+
/// Alt 0 is forwarded to the central `DfuState` (normal DFU boots).
354+
///
355+
/// For passthrough alts (1..N) this method:
356+
///
357+
/// 1. Forwards the request to the passthrough `DfuState` so it can
358+
/// generate the standard response (e.g. `GETSTATUS`, `GETSTATE`).
359+
/// 2. **After** the sub-handler writes its response, inspects
360+
/// [`PASSTHROUGH_TARGET`] to decide whether the PeripheralManager
361+
/// has finished processing the previous chunk.
362+
/// 3. If the target is still set (chunk not yet forwarded), overrides
363+
/// the `state` byte in the GETSTATUS response to `dfuDNBUSY` (4).
364+
/// The host sees "device busy" and polls again after 50 ms.
365+
/// 4. Once the target becomes `usize::MAX` the real DFU state is
366+
/// returned and the host immediately sends the next DNLOAD.
367+
///
368+
/// This provides **adaptive flow control**: no fixed timeouts, no
369+
/// large queues, no spinning in the USB ISR.
345370
fn control_in<'a>(&'a mut self, req: Request, buf: &'a mut [u8]) -> Option<InResponse<'a>> {
346371
match self.current_alt {
347372
0 => self.central.control_in(req, buf),
@@ -665,6 +690,84 @@ pub fn get_firmware_update_data(id: usize) -> Option<(&'static [u8], u32)> {
665690
// forwards them over the split link. GETSTATUS flow-control gives the
666691
// executor enough time to process each chunk before the host sends the
667692
// next one.
693+
//
694+
// Protocol (embedded firmware update)
695+
// ====================================
696+
//
697+
// ═══ PHASE 0: HANDSHAKE ── hash comparison ─────────────────────────
698+
//
699+
// Central Peripheral
700+
// │ │
701+
// ├── FirmwareHashQuery ─────────────>│
702+
// │<── FirmwareHashResponse(hash) ────┤
703+
// │ (or announced on connect) │
704+
// │ │
705+
// │ hash == expected_hash? │
706+
// │ ├─ Yes → STOP (up-to-date) │
707+
// │ └─ No → ⬇ │
708+
//
709+
//
710+
// ═══ PHASE 1: CHUNK TRANSFER ── per-chunk CRC ─────────────────────
711+
// outer attempt loop: 1..3
712+
//
713+
// Central Peripheral
714+
// │ │
715+
// │ LED ON, central_crc = Crc32::new()
716+
// │ for chunk in firmware[256]:
717+
// │ chunk_crc = CRC32(chunk)
718+
// │ central_crc.update(chunk)
719+
// │ retry = 0
720+
// │ ┌─ retry < 3 ─────────────────┐
721+
// │ │ │
722+
// ├── FirmwareChunk{offset,data} ──>│
723+
// │ │ handler.write_chunk() — erase DFU on 1st call
724+
// │ │ CRC32(chunk) │
725+
// │ │<─ FirmwareChunkAck{offset,crc} ───┤
726+
// │ │ │
727+
// │ ack_crc == chunk_crc? │
728+
// │ ├─ Yes → next chunk │
729+
// │ └─ No → retry++ ──────────┘
730+
// │ All chunks acked?
731+
// │ ├─ Yes → ⬇
732+
// │ └─ No → outer attempt++
733+
//
734+
//
735+
// ═══ PHASE 2: END-TO-END CRC ── flash readback ────────────────────
736+
//
737+
// Central Peripheral
738+
// │ │
739+
// │ central_crc.finalize()
740+
// │ == expected_hash?
741+
// │ ├─ No → ABORT (binary bug!)
742+
// │ └─ Yes → ⬇
743+
// │ │
744+
// ├── FirmwareUpdateComplete ────────>│
745+
// │ handler.compute_dfu_crc()
746+
// │ = CRC32(whole DFU partition)
747+
// │<── FirmwareCrcReport(dfu_crc) ────┤
748+
// │ │
749+
// │ dfu_crc == expected_hash? │
750+
// │ ├─ Yes → FirmwareCrcOk ──────>│
751+
// │ │ handler.mark_updated_and_reset()
752+
// │ │<─ FirmwareUpdateConfirm ─────┤ DONE
753+
// │ └─ No → FirmwareCrcFail ────>│ outer attempt++
754+
// │ │
755+
//
756+
// ═══ RETRY SUMMARY ═════════════════════════════════════════════════
757+
//
758+
// Layer Max Trigger Consequence
759+
// ───── ─── ─────── ──────────
760+
// Per-chunk 3× Ack CRC mismatch Re-send same chunk
761+
// or 2s timeout
762+
// Outer attempt 3× Chunk never acked Full restart of
763+
// or E2E CRC mismatch Phase 1 + 2
764+
//
765+
// ═══ SAFETY GATES ═════════════════════════════════════════════════
766+
//
767+
// mark_updated only on FirmwareCrcOk Never boot into corrupt FW
768+
// central_crc == expected_hash Catch binary bugs
769+
// compute_dfu_crc() flash readback Catch silent flash write errors
770+
// Per-chunk CRC in Ack Catch packet loss / bitflips
668771

669772
/// A single firmware chunk received from the host via DFU, destined for a
670773
/// split peripheral.
@@ -694,12 +797,26 @@ const PASSTHROUGH_QUEUE_SIZE: usize = 4;
694797
static PASSTHROUGH_CMD: Mutex<CriticalSectionRawMutex, RefCell<heapless::Vec<PassthroughCommand, PASSTHROUGH_QUEUE_SIZE>>> =
695798
Mutex::new(RefCell::new(heapless::Vec::new()));
696799

697-
/// Signals which peripheral has a pending passthrough command. Set to the
698-
/// peripheral id by the DFU handler, cleared to `usize::MAX` by the
699-
/// PeripheralManager once all pending chunks for that id are consumed.
800+
/// Signals which peripheral has a pending passthrough command.
801+
///
802+
/// Set to the peripheral `id` by the DFU handler when a chunk or Finish
803+
/// command is queued. Cleared to `usize::MAX` by the
804+
/// [`passthrough_done_if_empty`] helper once **all** pending commands for
805+
/// that peripheral have been consumed.
700806
///
701-
/// Also used by `RmkDfuInterface::control_in` for GETSTATUS flow-control:
702-
/// while `TARGET != MAX` the host sees `dfuDNBUSY` and polls again.
807+
/// # Flow-control integration
808+
///
809+
/// [`RmkDfuInterface::control_in`] reads this atomic on every GETSTATUS
810+
/// request while a passthrough alternate setting is active. If the value
811+
/// differs from `usize::MAX` the GETSTATUS response is overridden to
812+
/// `dfuDNBUSY`, instructing the host to poll again after 50 ms instead of
813+
/// sending the next DNLOAD block. This gives the async executor (running
814+
/// the `PeripheralManager`) enough CPU time to forward the previous chunk
815+
/// over the split link.
816+
///
817+
/// The flow is therefore **adaptive**: the host automatically waits when
818+
/// the split link is congested, and proceeds at line-speed when it isn't.
819+
/// No fixed timeouts, no large RAM queues.
703820
#[cfg(feature = "dfu_split")]
704821
pub(crate) static PASSTHROUGH_TARGET: AtomicUsize = AtomicUsize::new(usize::MAX);
705822

@@ -728,13 +845,23 @@ struct PassthroughDfuHandler {
728845
written: u32,
729846
}
730847

731-
/// Push a command into the queue, returning `Err` if full.
848+
/// Push a command into the fire-and-forget queue.
849+
///
850+
/// Called from the DFU handler ISR — **never blocks or spins**. Returns
851+
/// `Err` if the queue is full (should be rare because GETSTATUS flow
852+
/// control prevents the host from sending the next block while the queue
853+
/// is draining).
732854
#[cfg(feature = "dfu_split")]
733855
fn passthrough_push(cmd: PassthroughCommand) -> Result<(), ()> {
734856
PASSTHROUGH_CMD.lock(|c| c.borrow_mut().push(cmd).map_err(|_| ()))
735857
}
736858

737-
/// Take the pending passthrough command from the queue front.
859+
/// Take the next pending command from the queue (FIFO).
860+
///
861+
/// Called by [`PeripheralManager::handle_passthrough`] in the async
862+
/// executor. Returns `None` when the queue is empty — the caller then
863+
/// calls [`passthrough_done_if_empty`] to release the TARGET for the
864+
/// next host polling cycle.
738865
#[cfg(feature = "dfu_split")]
739866
pub(crate) fn passthrough_take_command() -> Option<PassthroughCommand> {
740867
PASSTHROUGH_CMD.lock(|c| {
@@ -743,7 +870,14 @@ pub(crate) fn passthrough_take_command() -> Option<PassthroughCommand> {
743870
})
744871
}
745872

746-
/// Signal that all pending chunks were consumed.
873+
/// Release [`PASSTHROUGH_TARGET`] when the queue is empty.
874+
///
875+
/// Called after every command is fully processed. If the queue still has
876+
/// pending commands the target is **not** released, so
877+
/// [`RmkDfuInterface::control_in`] continues to report `dfuDNBUSY` and
878+
/// the host keeps polling. Once empty, the target is reset to
879+
/// `usize::MAX` and the host sees the real DFU state on its next
880+
/// GETSTATUS poll.
747881
#[cfg(feature = "dfu_split")]
748882
pub(crate) fn passthrough_done_if_empty() {
749883
let empty = PASSTHROUGH_CMD.lock(|c| c.borrow().is_empty());
@@ -754,11 +888,22 @@ pub(crate) fn passthrough_done_if_empty() {
754888

755889
#[cfg(feature = "dfu_split")]
756890
impl dfu_mode::Handler for PassthroughDfuHandler {
891+
/// Called by DfuState when the host sends the first DNLOAD block
892+
/// (block number 0). Resets the write cursor.
757893
fn start(&mut self) -> Result<(), Status> {
758894
self.written = 0;
759895
Ok(())
760896
}
761897

898+
/// Called by DfuState for every subsequent DNLOAD block.
899+
///
900+
/// Slices the incoming 512‑byte USB block into 256‑byte chunks and
901+
/// pushes them into the fire-and-forget queue. **Never blocks or
902+
/// spins** — the ISR returns immediately and the `PeripheralManager`
903+
/// forwards the chunks asynchronously.
904+
///
905+
/// GETSTATUS flow control (see [`PASSTHROUGH_TARGET`]) ensures the
906+
/// host does not send the next DNLOAD until the queue is drained.
762907
fn write(&mut self, data: &[u8]) -> Result<(), Status> {
763908
for chunk in data.chunks(256) {
764909
let mut buf = [0u8; 256];
@@ -777,6 +922,12 @@ impl dfu_mode::Handler for PassthroughDfuHandler {
777922
Ok(())
778923
}
779924

925+
/// Called by DfuState when the host signals end-of-transfer
926+
/// (DNLOAD with `wLength = 0`).
927+
///
928+
/// Pushes a [`PassthroughCommand::Finish`] into the queue. The
929+
/// `PeripheralManager` picks it up, asks the peripheral to verify
930+
/// the DFU partition CRC, and confirms the update.
780931
fn finish(&mut self) -> Result<(), Status> {
781932
if passthrough_push(PassthroughCommand::Finish).is_err() {
782933
error!("passthrough queue full at finish");
@@ -786,9 +937,10 @@ impl dfu_mode::Handler for PassthroughDfuHandler {
786937
Ok(())
787938
}
788939

789-
fn system_reset(&mut self) {
790-
// No-op: the peripheral resets itself after a successful update.
791-
}
940+
/// No-op — the peripheral resets itself via `mark_updated_and_reset()`
941+
/// after a successful update. The central's USB passthrough handler
942+
/// must not reset the central.
943+
fn system_reset(&mut self) {}
792944
}
793945

794946
/// Return the CRC32 of this device's currently running firmware binary.

0 commit comments

Comments
 (0)