Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ Releases up through `v0.4.0` were workspace-wide and tagged `vX.Y.Z`. From `0.4.

## [Unreleased]

## [tinyboot-core 0.6.1] - 2026-10-20

### Added

- `App::reset(mode, baud)` resets into the bootloader or the app, storing the bootloader's baud first when given. An app with its own protocol uses it to start a firmware update.

### Changed

- The app's `Reset` command goes through `App::reset`, so it skips the metadata write when the baud is already stored.

## [tinyboot 0.5.1] - 2026-10-20

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion ch32/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ repository = "https://github.com/OpenServoCore/tinyboot"
license = "MIT OR Apache-2.0"

[workspace.dependencies]
tinyboot-core = { version = "0.6.0", path = "../lib/core", default-features = false }
tinyboot-core = { version = "0.6.1", path = "../lib/core", default-features = false }
tinyboot-ch32 = { version = "0.7.0", path = ".", default-features = false }
embedded-hal = "1"
embedded-io = "0.7"
Expand Down
2 changes: 2 additions & 0 deletions ch32/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ 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; `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.

An app with its own protocol, instead of `poll`, calls `app.reset(RunMode::Service, Some(baud))` to send the device to its bootloader at `baud`.

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.

## Features
Expand Down
13 changes: 13 additions & 0 deletions docs/app-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ uart_config.baudrate = app.baud().map_or(115200, |b| b.hz());

`set_baud()` erases and rewrites the metadata page (skipped when the rate is already stored), in a critical section like `confirm()`.

## `reset()` - into the bootloader from your own protocol

An app that speaks its own protocol on the bus, instead of running `poll()`, can still send the device to its bootloader when the host asks. `app.reset(mode, baud)` does what the `Reset` command does. It stores `baud` when given, as `set_baud()` does, then sets the run mode and resets.

```rust
use tinyboot_ch32::app::{BaudRate, traits::RunMode};

// The host asked for a firmware update at 1 Mbaud.
app.reset(RunMode::Service, Some(BaudRate::B1000000));
```

`RunMode::Service` stays in the bootloader, and `RunMode::HandOff` boots the app again. The baud goes in before the run mode on purpose. On CH32 a metadata write rewrites the flash status register, which would drop a boot mode set before it.

## Compatibility key - only the right image installs

`app_version!()` and `boot_version!()` end each image with a 6-byte tail: a 32-bit compatibility key, then the version. Give the bootloader a key and it accepts only images that carry the same one:
Expand Down
2 changes: 1 addition & 1 deletion lib/core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "tinyboot-core"
description = "Core bootloader library for tinyboot"
version = "0.6.0"
version = "0.6.1"
edition.workspace = true
repository.workspace = true
license.workspace = true
Expand Down
41 changes: 32 additions & 9 deletions lib/core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,30 @@ impl<C: BootCtl, M: BootMetaStore, const P: usize, A: Addressing> App<C, M, P, A
/// reset. Skips the flash write when it is already stored. Runs in a
/// critical section; feed the watchdog first.
pub fn set_baud(&mut self, baud: BaudRate) {
self.store_baud(baud as u8);
}

/// Reset into `mode`: [`RunMode::Service`] stays in the bootloader,
/// [`RunMode::HandOff`] boots the app. `baud` is stored first, as by
/// [`set_baud`](Self::set_baud). Feed the watchdog first.
pub fn reset(&mut self, mode: RunMode, baud: Option<BaudRate>) -> ! {
self.reset_with(mode, baud.map(|b| b as u8))
}

/// The baud before the run mode: a CH32 META write rewrites the flash
/// status register whole, which would drop a boot mode set before it.
fn reset_with(&mut self, mode: RunMode, baud: Option<u8>) -> ! {
if let Some(baud) = baud {
self.store_baud(baud);
}
self.ctl.set_run_mode(mode);
self.ctl.reset()
}

fn store_baud(&mut self, baud: u8) {
critical_section::with(|_| {
if self.meta.baud() != baud as u8 {
let _ = self.meta.set_baud(baud as u8);
if self.meta.baud() != baud {
let _ = self.meta.set_baud(baud);
}
});
}
Expand Down Expand Up @@ -161,13 +182,15 @@ impl<C: BootCtl, M: BootMetaStore, const P: usize, A: Addressing> App<C, M, P, A
}
Cmd::Reset => {
let flags = unsafe { self.frame.flags.reset };
if flags.contains(ResetFlags::BAUD) {
let _ = self.meta.set_baud(self.frame.addr() as u8);
}
if flags.contains(ResetFlags::BOOTLOADER) {
self.ctl.set_run_mode(RunMode::Service);
}
self.ctl.reset();
let baud = flags
.contains(ResetFlags::BAUD)
.then(|| self.frame.addr() as u8);
let mode = if flags.contains(ResetFlags::BOOTLOADER) {
RunMode::Service
} else {
RunMode::HandOff
};
self.reset_with(mode, baud);
}
_ => {
self.frame.len = 0;
Expand Down
20 changes: 19 additions & 1 deletion lib/core/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ use tinyboot_protocol::{Cmd, Status, WriteFlags};

// -- Mocks --

std::thread_local! {
/// META baud writes, run-mode sets and resets on this thread, in order.
pub static EVENTS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}

fn event(e: String) {
EVENTS.with(|v| v.borrow_mut().push(e));
}

/// Drain this thread's [`EVENTS`].
pub fn take_events() -> Vec<String> {
EVENTS.with(|v| v.take())
}

pub struct MockTransport {
pub rx_buf: [u8; 512],
pub rx_len: usize,
Expand Down Expand Up @@ -166,8 +180,11 @@ impl BootCtl for MockBootCtl {
fn run_mode(&self) -> RunMode {
RunMode::HandOff
}
fn set_run_mode(&mut self, _mode: RunMode) {}
fn set_run_mode(&mut self, mode: RunMode) {
event(format!("{mode:?}"));
}
fn reset(&mut self) -> ! {
event("reset".into());
panic!("mock reset")
}
fn hand_off(&mut self) -> ! {
Expand Down Expand Up @@ -217,6 +234,7 @@ impl BootMetaStore for MockBootMeta {
fn set_baud(&mut self, baud: u8) -> Result<(), ()> {
self.baud = baud;
self.baud_writes += 1;
event(format!("baud {baud}"));
Ok(())
}
fn advance(&mut self) -> Result<BootState, ()> {
Expand Down
55 changes: 54 additions & 1 deletion lib/core/tests/state_transitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod common;
use common::*;
use tinyboot_core::app::{App, AppConfig};
use tinyboot_core::protocol::Dispatcher;
use tinyboot_core::traits::BootState;
use tinyboot_core::traits::{BootState, RunMode};
use tinyboot_protocol::crc::{CRC_INIT, crc16};
use tinyboot_protocol::frame::DEFAULT_PAYLOAD;
use tinyboot_protocol::frame::Flags;
Expand Down Expand Up @@ -479,6 +479,59 @@ fn app_reset_with_baud_flag_stores_baud() {
assert_eq!(a.baud(), Some(BaudRate::B2000000));
}

fn reset_events(
a: &mut App<MockBootCtl, MockBootMeta>,
mode: RunMode,
baud: Option<BaudRate>,
) -> Vec<String> {
take_events();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| a.reset(mode, baud)));
assert!(r.is_err(), "reset must not return");
take_events()
}

#[test]
fn app_reset_stores_the_baud_before_the_run_mode() {
let mut a = app();
let ev = reset_events(&mut a, RunMode::Service, Some(BaudRate::B1000000));
assert_eq!(ev, ["baud 9", "Service", "reset"]);
}

#[test]
fn app_reset_skips_an_unchanged_baud() {
let mut a = app();
a.set_baud(BaudRate::B1000000);
let ev = reset_events(&mut a, RunMode::Service, Some(BaudRate::B1000000));
assert_eq!(ev, ["Service", "reset"]);
}

#[test]
fn app_reset_to_the_app_leaves_meta_alone() {
let mut a = app();
assert_eq!(
reset_events(&mut a, RunMode::HandOff, None),
["HandOff", "reset"]
);
}

#[test]
fn app_reset_command_takes_the_same_order() {
let mut a = app();
let mut rx = MockTransport::new();
rx.load_request_flags(
Cmd::Reset,
BaudRate::B500000 as u32,
reset_flags(ResetFlags::BAUD | ResetFlags::BOOTLOADER),
0,
&[],
);
let mut tx = MockTransport::new();
take_events();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| a.poll(&mut rx, &mut tx)));
assert!(r.is_err(), "reset must not return");
assert_eq!(take_events(), ["baud 7", "Service", "reset"]);
}

// =============================================================================
// Multi-step sequences
// =============================================================================
Expand Down
Loading