Skip to content

Commit c03aa09

Browse files
committed
refuse a reset to the app when no app is present
1 parent b7235c2 commit c03aa09

9 files changed

Lines changed: 86 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Releases up through `v0.4.0` were workspace-wide and tagged `vX.Y.Z`. From `0.4.
44

55
## [Unreleased]
66

7+
## [tinyboot-core 0.6.2] - 2026-10-20
8+
9+
### Fixed
10+
11+
- 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.
12+
713
## [tinyboot-protocol 0.5.1] - 2026-10-20
814

915
### Changed

‎ch32/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ repository = "https://github.com/OpenServoCore/tinyboot"
5252
license = "MIT OR Apache-2.0"
5353

5454
[workspace.dependencies]
55-
tinyboot-core = { version = "0.6.1", path = "../lib/core", default-features = false }
55+
tinyboot-core = { version = "0.6.2", path = "../lib/core", default-features = false }
5656
tinyboot-ch32 = { version = "0.7.0", path = ".", default-features = false }
5757
embedded-hal = "1"
5858
embedded-io = "0.7"

‎docs/remote-updates.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ When all four hold, the bootloader hands off to the app. As soon as you run a re
9797
| App's `confirm()` never reaches due to bug | Same as above — trials run out, bootloader wins. |
9898
| Host crashes mid-flash | Same as "power lost during erase or write" — safe, just re-flash. |
9999
| `Reset` to the app mid-flash | Refused with `Unsupported`; the device stays in the bootloader. Re-flash. |
100+
| `Reset` to the app with no app flashed | Refused with `Unsupported`; the device stays in the bootloader. Flash an app. |
100101
| Image built for another board | Verify returns `Incompatible` (bootloader has a compatibility key); flash the right one. |
101102

102103
## Several devices on one bus

‎lib/core/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "tinyboot-core"
33
description = "Core bootloader library for tinyboot"
4-
version = "0.6.1"
4+
version = "0.6.2"
55
edition.workspace = true
66
repository.workspace = true
77
license.workspace = true

‎lib/core/src/core.rs‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,8 @@ where
9595
self.platform.storage.as_slice().get_unchecked(..sz)
9696
}) == stored;
9797
}
98-
// No CRC stored (virgin / debugger-flashed) — require at least one
99-
// non-0xFF word to treat the app as present.
100-
let data = self.platform.storage.as_slice();
101-
data.len() >= 4
102-
&& unsafe { core::ptr::read_volatile(data.as_ptr() as *const u32) } != 0xFFFF_FFFF
98+
// No CRC stored (virgin / debugger-flashed).
99+
self.platform.app_present()
103100
}
104101

105102
#[inline(always)]

‎lib/core/src/platform.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,14 @@ where
3838
ctl,
3939
}
4040
}
41+
42+
/// The app region's first word is programmed. Without a stored CRC
43+
/// this is the only sign of an app: an erased chip reads all 0xFF.
44+
#[inline(always)]
45+
pub(crate) fn app_present(&self) -> bool {
46+
let data = self.storage.as_slice();
47+
// SAFETY: flash is word-aligned and the length is checked first.
48+
data.len() >= 4
49+
&& unsafe { core::ptr::read_volatile(data.as_ptr() as *const u32) } != 0xFFFF_FFFF
50+
}
4151
}

‎lib/core/src/protocol.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,8 +330,10 @@ impl<
330330
let flags = unsafe { flags.reset };
331331
// A reset to the app skips the bootloader's META check on
332332
// chips that hand off by run mode, so a half-written image
333-
// would run.
334-
if state == BootState::Updating && !flags.contains(ResetFlags::BOOTLOADER) {
333+
// would run and an erased app region would hang the chip.
334+
if !flags.contains(ResetFlags::BOOTLOADER)
335+
&& (state == BootState::Updating || !self.platform.app_present())
336+
{
335337
self.frame.status = Status::Unsupported;
336338
} else {
337339
self.cmd_reset(addr, flags);

‎lib/core/tests/state_transitions.rs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,9 +371,15 @@ fn reset_without_baud_flag_keeps_baud() {
371371
assert_eq!(d.platform.boot_meta.baud_writes, 0);
372372
}
373373

374+
/// Program the app region's first word, as any image or a debugger does.
375+
fn seed_app(p: &mut TestPlatform) {
376+
p.storage.data[..4].copy_from_slice(&[1, 2, 3, 4]);
377+
}
378+
374379
#[test]
375380
fn reset_to_app_refused_while_updating() {
376381
let mut p = platform(BootState::Updating);
382+
seed_app(&mut p);
377383
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
378384
d.platform.transport.load_request_flags(
379385
Cmd::Reset,
@@ -406,6 +412,7 @@ fn reset_to_bootloader_allowed_while_updating() {
406412
#[test]
407413
fn reset_to_app_allowed_while_validating() {
408414
let mut p = platform(BootState::Validating);
415+
seed_app(&mut p);
409416
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
410417
d.platform.transport.load_request_flags(
411418
Cmd::Reset,
@@ -418,6 +425,59 @@ fn reset_to_app_allowed_while_validating() {
418425
assert_eq!(d.frame.status, Status::Ok);
419426
}
420427

428+
#[test]
429+
fn reset_to_app_refused_without_an_app() {
430+
let mut p = platform(BootState::Idle);
431+
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
432+
d.platform.transport.load_request_flags(
433+
Cmd::Reset,
434+
BaudRate::B9600 as u32,
435+
reset_flags(ResetFlags::BAUD),
436+
0,
437+
&[],
438+
);
439+
take_events();
440+
d.dispatch().unwrap();
441+
assert_eq!(d.frame.status, Status::Unsupported);
442+
assert_ne!(d.platform.transport.tx_pos, 0);
443+
assert!(take_events().is_empty());
444+
}
445+
446+
#[test]
447+
fn reset_to_app_allowed_with_an_app() {
448+
let mut p = platform(BootState::Idle);
449+
seed_app(&mut p);
450+
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
451+
d.platform.transport.load_request_flags(
452+
Cmd::Reset,
453+
0,
454+
reset_flags(ResetFlags::empty()),
455+
0,
456+
&[],
457+
);
458+
take_events();
459+
dispatch_reset(&mut d);
460+
assert_eq!(d.frame.status, Status::Ok);
461+
assert_eq!(take_events(), ["HandOff", "reset"]);
462+
}
463+
464+
#[test]
465+
fn reset_to_bootloader_allowed_without_an_app() {
466+
let mut p = platform(BootState::Idle);
467+
let mut d = Dispatcher::<_, _, _, _, TEST_BUF_SIZE>::new(&mut p);
468+
d.platform.transport.load_request_flags(
469+
Cmd::Reset,
470+
0,
471+
reset_flags(ResetFlags::BOOTLOADER),
472+
0,
473+
&[],
474+
);
475+
take_events();
476+
dispatch_reset(&mut d);
477+
assert_eq!(d.frame.status, Status::Ok);
478+
assert_eq!(take_events(), ["Service", "reset"]);
479+
}
480+
421481
#[test]
422482
fn erase_restarts_an_update() {
423483
let mut p = platform(BootState::Updating);

‎lib/protocol/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Byte 3 of the address field carries per-command flags. Each command defines its
5858
| 0 | `BOOTLOADER` | Enter the bootloader (service mode) after reset |
5959
| 1 | `BAUD` | Store addr byte 0 as the bootloader's `BaudRate` index before resetting |
6060

61-
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.
61+
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.
6262

6363
### `BaudRate`
6464

0 commit comments

Comments
 (0)