From c03aa0964cdb29597f25f7b0d801804d25bdad56 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 20 Oct 2026 21:43:07 -0700 Subject: [PATCH 1/2] refuse a reset to the app when no app is present --- CHANGELOG.md | 6 +++ ch32/Cargo.toml | 2 +- docs/remote-updates.md | 1 + lib/core/Cargo.toml | 2 +- lib/core/src/core.rs | 7 +--- lib/core/src/platform.rs | 10 +++++ lib/core/src/protocol.rs | 6 ++- lib/core/tests/state_transitions.rs | 60 +++++++++++++++++++++++++++++ lib/protocol/README.md | 2 +- 9 files changed, 86 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5c346..a9834d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Releases up through `v0.4.0` were workspace-wide and tagged `vX.Y.Z`. From `0.4. ## [Unreleased] +## [tinyboot-core 0.6.2] - 2026-10-20 + +### 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 diff --git a/ch32/Cargo.toml b/ch32/Cargo.toml index 8664ee1..8384f8c 100644 --- a/ch32/Cargo.toml +++ b/ch32/Cargo.toml @@ -52,7 +52,7 @@ 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-core = { version = "0.6.2", path = "../lib/core", default-features = false } tinyboot-ch32 = { version = "0.7.0", path = ".", default-features = false } embedded-hal = "1" embedded-io = "0.7" diff --git a/docs/remote-updates.md b/docs/remote-updates.md index ce9b8f3..53724c5 100644 --- a/docs/remote-updates.md +++ b/docs/remote-updates.md @@ -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 diff --git a/lib/core/Cargo.toml b/lib/core/Cargo.toml index 76047ae..8c39eea 100644 --- a/lib/core/Cargo.toml +++ b/lib/core/Cargo.toml @@ -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 diff --git a/lib/core/src/core.rs b/lib/core/src/core.rs index 6ca2a63..f0bdbf1 100644 --- a/lib/core/src/core.rs +++ b/lib/core/src/core.rs @@ -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.app_present() } #[inline(always)] diff --git a/lib/core/src/platform.rs b/lib/core/src/platform.rs index 05f4714..a70f5ed 100644 --- a/lib/core/src/platform.rs +++ b/lib/core/src/platform.rs @@ -38,4 +38,14 @@ where ctl, } } + + /// 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. + #[inline(always)] + pub(crate) fn app_present(&self) -> bool { + let data = self.storage.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 + } } diff --git a/lib/core/src/protocol.rs b/lib/core/src/protocol.rs index efd36ae..5f2dc69 100644 --- a/lib/core/src/protocol.rs +++ b/lib/core/src/protocol.rs @@ -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.app_present()) + { self.frame.status = Status::Unsupported; } else { self.cmd_reset(addr, flags); diff --git a/lib/core/tests/state_transitions.rs b/lib/core/tests/state_transitions.rs index 91ec66a..f50ce4c 100644 --- a/lib/core/tests/state_transitions.rs +++ b/lib/core/tests/state_transitions.rs @@ -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, @@ -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, @@ -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); diff --git a/lib/protocol/README.md b/lib/protocol/README.md index 273d755..2ec506e 100644 --- a/lib/protocol/README.md +++ b/lib/protocol/README.md @@ -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` From 4355dadf74d0f2fac61bced5b009beb7e0372c0d Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 20 Oct 2026 21:48:29 -0700 Subject: [PATCH 2/2] let ch32 skip the app check's length test, word the cli refusal for both causes --- CHANGELOG.md | 16 ++++++++++++++++ ch32/Cargo.toml | 4 ++-- ch32/src/platform/storage.rs | 7 +++++++ cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/main.rs | 4 ++-- lib/core/src/core.rs | 2 +- lib/core/src/platform.rs | 10 ---------- lib/core/src/protocol.rs | 2 +- lib/core/src/traits.rs | 12 ++++++++++++ 10 files changed, 43 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9834d3..668c009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,24 @@ 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. diff --git a/ch32/Cargo.toml b/ch32/Cargo.toml index 8384f8c..1ec80fe 100644 --- a/ch32/Cargo.toml +++ b/ch32/Cargo.toml @@ -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 @@ -53,7 +53,7 @@ license = "MIT OR Apache-2.0" [workspace.dependencies] tinyboot-core = { version = "0.6.2", path = "../lib/core", default-features = false } -tinyboot-ch32 = { version = "0.7.0", path = ".", default-features = false } +tinyboot-ch32 = { version = "0.7.1", path = ".", default-features = false } embedded-hal = "1" embedded-io = "0.7" embedded-storage = "0.3" diff --git a/ch32/src/platform/storage.rs b/ch32/src/platform/storage.rs index 75a01b0..912caf0 100644 --- a/ch32/src/platform/storage.rs +++ b/ch32/src/platform/storage.rs @@ -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 { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 0e03949..84d30e0 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -516,7 +516,7 @@ dependencies = [ [[package]] name = "tinyboot" -version = "0.5.1" +version = "0.5.2" dependencies = [ "clap", "embedded-io", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 7ad0239..a5069d8 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -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" diff --git a/cli/src/main.rs b/cli/src/main.rs index 0ac5389..5725f5d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -285,11 +285,11 @@ fn parse_baud(rate: &str) -> Result, Box }) } -/// 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 { 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(), } diff --git a/lib/core/src/core.rs b/lib/core/src/core.rs index f0bdbf1..c0339f3 100644 --- a/lib/core/src/core.rs +++ b/lib/core/src/core.rs @@ -96,7 +96,7 @@ where }) == stored; } // No CRC stored (virgin / debugger-flashed). - self.platform.app_present() + self.platform.storage.app_present() } #[inline(always)] diff --git a/lib/core/src/platform.rs b/lib/core/src/platform.rs index a70f5ed..05f4714 100644 --- a/lib/core/src/platform.rs +++ b/lib/core/src/platform.rs @@ -38,14 +38,4 @@ where ctl, } } - - /// 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. - #[inline(always)] - pub(crate) fn app_present(&self) -> bool { - let data = self.storage.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 - } } diff --git a/lib/core/src/protocol.rs b/lib/core/src/protocol.rs index 5f2dc69..4d40dab 100644 --- a/lib/core/src/protocol.rs +++ b/lib/core/src/protocol.rs @@ -332,7 +332,7 @@ impl< // chips that hand off by run mode, so a half-written image // would run and an erased app region would hang the chip. if !flags.contains(ResetFlags::BOOTLOADER) - && (state == BootState::Updating || !self.platform.app_present()) + && (state == BootState::Updating || !self.platform.storage.app_present()) { self.frame.status = Status::Unsupported; } else { diff --git a/lib/core/src/traits.rs b/lib/core/src/traits.rs index a205611..420637d 100644 --- a/lib/core/src/traits.rs +++ b/lib/core/src/traits.rs @@ -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.