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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ Releases up through `v0.4.0` were workspace-wide and tagged `vX.Y.Z`. From `0.4.

## [Unreleased]

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

### Fixed

- A refused reset to the app reports that an update is in progress or no app is flashed, instead of only the former.

## [tinyboot-ch32 0.7.1] - 2026-10-20

### Changed

- `Storage` overrides `app_present()` without the length check, since its app base is always readable flash. The bootloader's reset guard from tinyboot-core 0.6.2 costs 4 bytes instead of 18.

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

### Added

- `Storage::app_present()`, provided: the app region's first word is programmed. A platform that knows its region holds a word overrides it without the length check.

### Fixed

- The bootloader refuses `Reset` to the app with `Unsupported` when the app region's first word is erased, as it does mid-update, and stays in the bootloader. A system-flash bootloader hands off by boot mode without checking the app, so a chip with no app flashed jumped into blank flash and hung until a power cycle.

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

### Changed
Expand Down
6 changes: 3 additions & 3 deletions ch32/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "tinyboot-ch32"
description = "tinyboot ch32 implementation"
version = "0.7.0"
version = "0.7.1"
edition.workspace = true
repository.workspace = true
license.workspace = true
Expand Down Expand Up @@ -52,8 +52,8 @@ repository = "https://github.com/OpenServoCore/tinyboot"
license = "MIT OR Apache-2.0"

[workspace.dependencies]
tinyboot-core = { version = "0.6.1", path = "../lib/core", default-features = false }
tinyboot-ch32 = { version = "0.7.0", path = ".", default-features = false }
tinyboot-core = { version = "0.6.2", path = "../lib/core", default-features = false }
tinyboot-ch32 = { version = "0.7.1", path = ".", default-features = false }
embedded-hal = "1"
embedded-io = "0.7"
embedded-storage = "0.3"
Expand Down
7 changes: 7 additions & 0 deletions ch32/src/platform/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ impl StorageTrait for Storage {
fn as_slice(&self) -> &[u8] {
unsafe { core::slice::from_raw_parts(self.app_ptr(), self.app_size) }
}

#[inline(always)]
fn app_present(&self) -> bool {
// SAFETY: the app base is a word-aligned flash address on every
// chip, readable whatever the region's length.
unsafe { core::ptr::read_volatile(self.app_ptr() as *const u32) != 0xFFFF_FFFF }
}
}

impl ReadNorFlash for Storage {
Expand Down
2 changes: 1 addition & 1 deletion cli/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "tinyboot"
description = "CLI firmware flasher for tinyboot bootloader"
version = "0.5.1"
version = "0.5.2"
edition = "2024"
repository = "https://github.com/OpenServoCore/tinyboot"
license = "MIT OR Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,11 @@ fn parse_baud(rate: &str) -> Result<Option<BaudRate>, Box<dyn std::error::Error>
})
}

/// The bootloader refuses to start the app while an update is in progress.
/// The bootloader refuses to start the app mid-update or with no app flashed.
fn refused_app(e: FlashError) -> Box<dyn std::error::Error> {
match e {
FlashError::Device(Status::Unsupported) => "the bootloader refused to start the app: \
an update is in progress. Flash again, or `tinyboot reset --bootloader`."
an update is in progress or no app is flashed. Flash an app."
.into(),
e => e.into(),
}
Expand Down
1 change: 1 addition & 0 deletions docs/remote-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ When all four hold, the bootloader hands off to the app. As soon as you run a re
| App's `confirm()` never reaches due to bug | Same as above — trials run out, bootloader wins. |
| Host crashes mid-flash | Same as "power lost during erase or write" — safe, just re-flash. |
| `Reset` to the app mid-flash | Refused with `Unsupported`; the device stays in the bootloader. Re-flash. |
| `Reset` to the app with no app flashed | Refused with `Unsupported`; the device stays in the bootloader. Flash an app. |
| Image built for another board | Verify returns `Incompatible` (bootloader has a compatibility key); flash the right one. |

## Several devices on one bus
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.1"
version = "0.6.2"
edition.workspace = true
repository.workspace = true
license.workspace = true
Expand Down
7 changes: 2 additions & 5 deletions lib/core/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,8 @@ where
self.platform.storage.as_slice().get_unchecked(..sz)
}) == stored;
}
// No CRC stored (virgin / debugger-flashed) — require at least one
// non-0xFF word to treat the app as present.
let data = self.platform.storage.as_slice();
data.len() >= 4
&& unsafe { core::ptr::read_volatile(data.as_ptr() as *const u32) } != 0xFFFF_FFFF
// No CRC stored (virgin / debugger-flashed).
self.platform.storage.app_present()
}

#[inline(always)]
Expand Down
6 changes: 4 additions & 2 deletions lib/core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,10 @@ impl<
let flags = unsafe { flags.reset };
// A reset to the app skips the bootloader's META check on
// chips that hand off by run mode, so a half-written image
// would run.
if state == BootState::Updating && !flags.contains(ResetFlags::BOOTLOADER) {
// would run and an erased app region would hang the chip.
if !flags.contains(ResetFlags::BOOTLOADER)
&& (state == BootState::Updating || !self.platform.storage.app_present())
{
self.frame.status = Status::Unsupported;
} else {
self.cmd_reset(addr, flags);
Expand Down
12 changes: 12 additions & 0 deletions lib/core/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ pub trait Storage:
{
/// Zero-copy read access to the app region.
fn as_slice(&self) -> &[u8];

/// The app region's first word is programmed. Without a stored CRC
/// this is the only sign of an app: an erased chip reads all 0xFF.
/// A platform that knows its region holds a word may skip the length
/// check.
#[inline(always)]
fn app_present(&self) -> bool {
let data = self.as_slice();
// SAFETY: flash is word-aligned and the length is checked first.
data.len() >= 4
&& unsafe { core::ptr::read_volatile(data.as_ptr() as *const u32) } != 0xFFFF_FFFF
}
}

/// Boot control primitives.
Expand Down
60 changes: 60 additions & 0 deletions lib/core/tests/state_transitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,15 @@ fn reset_without_baud_flag_keeps_baud() {
assert_eq!(d.platform.boot_meta.baud_writes, 0);
}

/// Program the app region's first word, as any image or a debugger does.
fn seed_app(p: &mut TestPlatform) {
p.storage.data[..4].copy_from_slice(&[1, 2, 3, 4]);
}

#[test]
fn reset_to_app_refused_while_updating() {
let mut p = platform(BootState::Updating);
seed_app(&mut p);
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
d.platform.transport.load_request_flags(
Cmd::Reset,
Expand Down Expand Up @@ -406,6 +412,7 @@ fn reset_to_bootloader_allowed_while_updating() {
#[test]
fn reset_to_app_allowed_while_validating() {
let mut p = platform(BootState::Validating);
seed_app(&mut p);
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
d.platform.transport.load_request_flags(
Cmd::Reset,
Expand All @@ -418,6 +425,59 @@ fn reset_to_app_allowed_while_validating() {
assert_eq!(d.frame.status, Status::Ok);
}

#[test]
fn reset_to_app_refused_without_an_app() {
let mut p = platform(BootState::Idle);
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
d.platform.transport.load_request_flags(
Cmd::Reset,
BaudRate::B9600 as u32,
reset_flags(ResetFlags::BAUD),
0,
&[],
);
take_events();
d.dispatch().unwrap();
assert_eq!(d.frame.status, Status::Unsupported);
assert_ne!(d.platform.transport.tx_pos, 0);
assert!(take_events().is_empty());
}

#[test]
fn reset_to_app_allowed_with_an_app() {
let mut p = platform(BootState::Idle);
seed_app(&mut p);
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
d.platform.transport.load_request_flags(
Cmd::Reset,
0,
reset_flags(ResetFlags::empty()),
0,
&[],
);
take_events();
dispatch_reset(&mut d);
assert_eq!(d.frame.status, Status::Ok);
assert_eq!(take_events(), ["HandOff", "reset"]);
}

#[test]
fn reset_to_bootloader_allowed_without_an_app() {
let mut p = platform(BootState::Idle);
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
d.platform.transport.load_request_flags(
Cmd::Reset,
0,
reset_flags(ResetFlags::BOOTLOADER),
0,
&[],
);
take_events();
dispatch_reset(&mut d);
assert_eq!(d.frame.status, Status::Ok);
assert_eq!(take_events(), ["Service", "reset"]);
}

#[test]
fn erase_restarts_an_update() {
let mut p = platform(BootState::Updating);
Expand Down
2 changes: 1 addition & 1 deletion lib/protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Byte 3 of the address field carries per-command flags. Each command defines its
| 0 | `BOOTLOADER` | Enter the bootloader (service mode) after reset |
| 1 | `BAUD` | Store addr byte 0 as the bootloader's `BaudRate` index before resetting |

With no flag set, `Cmd::Reset` boots the app (hand-off mode). The bootloader refuses that with `Unsupported` while an update is in progress (META `Updating`), so a half-written image never runs; `BOOTLOADER` is always accepted.
With no flag set, `Cmd::Reset` boots the app (hand-off mode). The bootloader refuses that with `Unsupported` while an update is in progress (META `Updating`), so a half-written image never runs, and when the app region's first word is erased, so a chip with no app does not jump into blank flash; `BOOTLOADER` is always accepted.

### `BaudRate`

Expand Down
Loading