From 78f5b76a71ccd612e22679ca810ae36a99ebe773 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 18 Aug 2026 03:57:29 +0200 Subject: [PATCH] Multiboot: per-slot config profiles + restorable Main backup --- App/app/uart.c | 23 +++ App/driver/mb_flash.c | 377 +++++++++++++++++++++++++++++++++++++++++- App/driver/mb_flash.h | 131 ++++++++++++++- App/driver/py25q16.c | 46 +++++- App/driver/py25q16.h | 29 ++++ App/main.c | 12 ++ App/ui/multiboot.c | 163 ++++++++++++++++-- App/ui/multiboot.h | 8 + 8 files changed, 763 insertions(+), 26 deletions(-) diff --git a/App/app/uart.c b/App/app/uart.c index 728c16e6..49be1082 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -981,6 +981,29 @@ void UART_HandleCommand(uint32_t Port) SendReply(Port, &Reply, sizeof(Reply)); break; } + + case 0x0728: // profile config reset: wipe the 64 KiB config bank of a slot + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + uint32_t ts = (uint32_t)pUART_Command->Data[2] + | ((uint32_t)pUART_Command->Data[3] << 8) + | ((uint32_t)pUART_Command->Data[4] << 16) + | ((uint32_t)pUART_Command->Data[5] << 24); + uint8_t status = (ts != mb_port_timestamp(Port)) + ? MB_ERR_AUTH : MB_ProfileErase(slot); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0729; + Reply.Header.Size = 2; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } #endif #ifdef ENABLE_UART_RW_BK_REGS diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c index 52abd97a..453636dd 100644 --- a/App/driver/mb_flash.c +++ b/App/driver/mb_flash.c @@ -14,8 +14,10 @@ */ #include +#include #include "driver/mb_flash.h" +#include "driver/py25q16.h" #include "py32f0xx.h" @@ -103,10 +105,14 @@ static void MB_PrepareInternalFlash(void) /* the linker stores in flash and the startup copies to RAM alongside .data. */ /* -------------------------------------------------------------------------- */ -/* Polled single-byte SPI2 transfer. always_inline keeps all code in .RamFunc. - * The result is returned through out so timeout and received 0xFF remain - * distinguishable. */ -__attribute__((always_inline)) static inline bool mb_ram_spi(uint8_t v, uint8_t *out) +/* These primitives are called repeatedly while application flash is offline. + * Keep one copy of each in the copied RAM section: noinline/noclone prevents + * GCC from silently duplicating one back into MB_RamReflash. */ +#define MB_RAM_HELPER __attribute__((section(".RamFunc"), noinline, noclone, used)) + +/* Polled single-byte SPI2 transfer. The result is returned through out so + * timeout and received 0xFF remain distinguishable. */ +MB_RAM_HELPER static bool mb_ram_spi(uint8_t v, uint8_t *out) { uint32_t timeout = MB_RAM_SPI_TIMEOUT; while (!(SPI2->SR & SPI_SR_TXE)) @@ -124,7 +130,7 @@ __attribute__((always_inline)) static inline bool mb_ram_spi(uint8_t v, uint8_t return true; } -__attribute__((always_inline)) static inline bool mb_ram_flash_idle(void) +MB_RAM_HELPER static bool mb_ram_flash_idle(void) { uint32_t timeout = MB_RAM_FLASH_TIMEOUT; while (FLASH->SR & FLASH_SR_BSY) @@ -133,7 +139,7 @@ __attribute__((always_inline)) static inline bool mb_ram_flash_idle(void) return true; } -__attribute__((always_inline, noreturn)) static inline void mb_ram_reset(void) +MB_RAM_HELPER __attribute__((noreturn)) static void mb_ram_reset(void) { __DSB(); @@ -144,7 +150,7 @@ __attribute__((always_inline, noreturn)) static inline void mb_ram_reset(void) /* Minimal SPI1 LCD writer. A display timeout merely disables progress updates: * it must never abort or delay the safety-critical flash copy. */ -__attribute__((always_inline)) static inline bool mb_ram_lcd_spi(uint8_t v) +MB_RAM_HELPER static bool mb_ram_lcd_spi(uint8_t v) { uint32_t timeout = MB_RAM_SPI_TIMEOUT; while (!(SPI1->SR & SPI_SR_TXE)) @@ -183,6 +189,35 @@ __attribute__((always_inline)) static inline bool mb_ram_progress_blit(const uin return ok != 0u; } +/* Blank the whole LCD RAM (all 8 pages) right before the reset. The MCU reset + * leaves the display controller powered and still showing the "Restore slot N" + * screen; it stays visible through the next boot until ST7565_Init re-inits the + * panel. Wiping it here means the reboot window shows nothing instead of a + * stale restore screen. Best-effort: a display timeout just leaves it as-is. */ +__attribute__((always_inline)) static inline void mb_ram_lcd_clear(void) +{ + for (uint8_t page = 0; page < 8u; page++) + { + GPIOB->BRR = MB_LCD_CS_PIN; + GPIOA->BRR = MB_LCD_A0_PIN; /* command */ + if (!mb_ram_lcd_spi((uint8_t)(0xB0u | page)) || /* set page 0..7 */ + !mb_ram_lcd_spi(0x10u) || /* column high nibble */ + !mb_ram_lcd_spi(0x04u)) /* visible RAM starts at column 4 */ + { + GPIOB->BSRR = MB_LCD_CS_PIN; + return; + } + GPIOA->BSRR = MB_LCD_A0_PIN; /* data */ + for (uint32_t i = 0; i < 128u; i++) + if (!mb_ram_lcd_spi(0x00u)) + { + GPIOB->BSRR = MB_LCD_CS_PIN; + return; + } + GPIOB->BSRR = MB_LCD_CS_PIN; + } +} + __attribute__((section(".RamFunc"), noinline, used)) static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize, uint8_t *progressLine) @@ -332,6 +367,8 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize } FLASH->CR |= FLASH_CR_LOCK; + if (lcdEnabled) + mb_ram_lcd_clear(); mb_ram_reset(); fatal_reset: @@ -644,7 +681,8 @@ uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header) uint8_t MB_SlotErase(uint8_t slot) { - if (slot >= MB_SLOT_COUNT) + /* Slot 0 is the firmware-managed base backup: never erasable from the host. */ + if (slot >= MB_SLOT_COUNT || slot == MB_SLOT_BACKUP) return MB_ERR_SLOT; const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; @@ -660,7 +698,8 @@ uint8_t MB_SlotErase(uint8_t slot) uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len) { - if (slot >= MB_SLOT_COUNT) + /* Slot 0 is the firmware-managed base backup: never writable from the host. */ + if (slot >= MB_SLOT_COUNT || slot == MB_SLOT_BACKUP) return MB_ERR_SLOT; if (len == 0) return MB_OK; @@ -676,3 +715,323 @@ uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_ return MB_OK; } + +/* -------------------------------------------------------------------------- */ +/* Per-profile settings banks. */ +/* */ +/* The banking offset itself lives in the flash driver (PY25Q16_SetProfileBase);*/ +/* here we only own the active-profile marker and the profile->base mapping. */ +/* All external-flash only, never brick-critical. */ +/* -------------------------------------------------------------------------- */ + +static uint32_t mb_crc32_bytes(const uint8_t *p, uint32_t len) +{ + uint32_t crc = 0xFFFFFFFFu; + + for (uint32_t i = 0; i < len; i++) + { + crc ^= p[i]; + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return crc ^ 0xFFFFFFFFu; +} + +uint32_t MB_ProfileBase(uint8_t profile) +{ + if (profile == 0) + return 0; /* profile 0 = historical config region */ + if (profile < MB_PROFILE_COUNT) + return MB_PROFILE1_EXT_BASE + (uint32_t)(profile - 1u) * MB_PROFILE_BANK_SIZE; + return 0; /* out of range -> safe default */ +} + +static mb_mark_status_t mb_read_profile_copy(uint32_t base, mb_profile_state_t *st) +{ + mb_spi_err = 0; + mb_ext_read(base, (uint8_t *)st, sizeof(*st)); + if (mb_spi_err) return MB_MARK_IO; + if (st->magic == 0xFFFFFFFFu) return MB_MARK_MISSING; + if (st->magic == MB_PROFILE_LEGACY_MAGIC) + { + /* FMP1 was { magic, index, ~index, reserved[2] }. Preserve its slot + * choice long enough for boot resolution to migrate it to FMP2. */ + const uint8_t legacy_index = ((const uint8_t *)st)[4]; + const uint8_t legacy_inv = ((const uint8_t *)st)[5]; + if ((uint8_t)~legacy_inv != legacy_index || legacy_index >= MB_PROFILE_COUNT) + return MB_MARK_CORRUPT; + memset(st, 0, sizeof(*st)); + st->magic = MB_PROFILE_LEGACY_MAGIC; + st->index = legacy_index; + st->index_inv = legacy_inv; + return MB_MARK_LEGACY; + } + if (st->magic != MB_PROFILE_MAGIC) return MB_MARK_CORRUPT; + if ((uint8_t)~st->index_inv != st->index) return MB_MARK_CORRUPT; + if (st->index >= MB_PROFILE_COUNT) return MB_MARK_CORRUPT; + if (st->image_size == 0u || + st->image_size > MB_INT_APP_SIZE) return MB_MARK_CORRUPT; + if (mb_crc32_bytes((const uint8_t *)st, + sizeof(*st) - sizeof(st->state_crc32)) != st->state_crc32) + return MB_MARK_CORRUPT; + return MB_MARK_VALID; +} + +static bool mb_generation_newer(uint32_t a, uint32_t b) +{ + return (int32_t)(a - b) > 0; +} + +static mb_mark_status_t mb_read_active_profile(mb_profile_state_t *state, + uint32_t *state_base) +{ + mb_profile_state_t a; + mb_profile_state_t b; + mb_mark_status_t sa = mb_read_profile_copy(MB_PROFILE_STATE_A_BASE, &a); + mb_mark_status_t sb = mb_read_profile_copy(MB_PROFILE_STATE_B_BASE, &b); + + /* If either sector could not be read, it might contain the newest record. + * Do not silently select an older state and risk loading the wrong bank. */ + if (sa == MB_MARK_IO || sb == MB_MARK_IO) + return MB_MARK_IO; + + const mb_profile_state_t *chosen = NULL; + uint32_t chosen_base = 0; + if (sa == MB_MARK_VALID && sb == MB_MARK_VALID) + { + if (mb_generation_newer(b.generation, a.generation)) + { + chosen = &b; + chosen_base = MB_PROFILE_STATE_B_BASE; + } + else + { + chosen = &a; + chosen_base = MB_PROFILE_STATE_A_BASE; + } + } + else if (sa == MB_MARK_VALID) + { + chosen = &a; + chosen_base = MB_PROFILE_STATE_A_BASE; + } + else if (sb == MB_MARK_VALID) + { + chosen = &b; + chosen_base = MB_PROFILE_STATE_B_BASE; + } + + if (chosen) + { + if (state) + *state = *chosen; + if (state_base) + *state_base = chosen_base; + return MB_MARK_VALID; + } + + /* A legacy record is usable for migration but has no firmware identity. + * Prefer A if both somehow exist; the next successful repair writes FMP2. */ + if (sa == MB_MARK_LEGACY || sb == MB_MARK_LEGACY) + { + const bool use_b = sa != MB_MARK_LEGACY; + if (state) + *state = use_b ? b : a; + if (state_base) + *state_base = use_b ? MB_PROFILE_STATE_B_BASE : MB_PROFILE_STATE_A_BASE; + return MB_MARK_LEGACY; + } + + return (sa == MB_MARK_MISSING && sb == MB_MARK_MISSING) + ? MB_MARK_MISSING : MB_MARK_CORRUPT; +} + +mb_mark_status_t MB_ReadActiveProfile(mb_profile_state_t *state) +{ + return mb_read_active_profile(state, NULL); +} + +uint8_t MB_GetActiveProfile(void) +{ + mb_profile_state_t st; + mb_mark_status_t status = MB_ReadActiveProfile(&st); + return (status == MB_MARK_VALID || status == MB_MARK_LEGACY) ? st.index : 0; +} + +uint8_t MB_SetActiveProfile(uint8_t profile) +{ + mb_slot_header_t hdr; + mb_profile_state_t st; + mb_profile_state_t current; + uint32_t current_base = 0; + + if (profile >= MB_PROFILE_COUNT) + return MB_ERR_SLOT; + + uint8_t hdr_err = mb_read_header(profile, &hdr); + if (hdr_err != MB_OK) + return hdr_err; + + mb_mark_status_t current_status = mb_read_active_profile(¤t, ¤t_base); + if (current_status == MB_MARK_IO) + return MB_ERR_SPI; + + memset(&st, 0, sizeof(st)); + st.magic = MB_PROFILE_MAGIC; + st.generation = (current_status == MB_MARK_VALID) ? current.generation + 1u : 0u; + st.image_size = hdr.image_size; + st.image_crc32 = hdr.image_crc32; + st.index = profile; + st.index_inv = (uint8_t)~profile; + st.state_crc32 = mb_crc32_bytes((const uint8_t *)&st, + sizeof(st) - sizeof(st.state_crc32)); + + const uint32_t target_base = ((current_status == MB_MARK_VALID || + current_status == MB_MARK_LEGACY) && + current_base == MB_PROFILE_STATE_A_BASE) + ? MB_PROFILE_STATE_B_BASE + : MB_PROFILE_STATE_A_BASE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + if (!mb_ext_sector_erase(target_base)) + return MB_ERR_SPI; + if (!mb_ext_program(target_base, (const uint8_t *)&st, sizeof(st))) + return MB_ERR_SPI; + + /* Verify the new copy directly. The previous valid sector has not been + * touched, so any failure here remains power-loss safe. */ + mb_profile_state_t chk; + mb_mark_status_t chk_status = mb_read_profile_copy(target_base, &chk); + if (chk_status == MB_MARK_IO) return MB_ERR_SPI; + if (chk_status != MB_MARK_VALID || + memcmp(&chk, &st, sizeof(st)) != 0) return MB_ERR_CRC; + + return MB_OK; +} + +uint8_t MB_ProfileErase(uint8_t profile) +{ + /* Profile 0 (the base config) is not resettable from the host: reset it by + * factory-resetting the running base firmware instead. Profiles 1..N live + * in their own 64 KiB banks, clear of the shared calibration at 0x010000. */ + if (profile == 0 || profile >= MB_PROFILE_COUNT) + return MB_ERR_SLOT; + + const uint32_t base = MB_ProfileBase(profile); + + /* Invalidate before the first raw erase so an early failure cannot leave a + * cache entry referring to a sector that was already erased. */ + PY25Q16_InvalidateCache(); + + mb_spi_err = 0; + mb_spi_polled_mode(); + for (uint32_t off = 0; off < MB_PROFILE_BANK_SIZE; off += MB_EXT_SECTOR) + if (!mb_ext_sector_erase(base + off)) + return MB_ERR_SPI; + + return MB_OK; +} + +/* -------------------------------------------------------------------------- */ +/* Slot 0 self-backup (base firmware). */ +/* -------------------------------------------------------------------------- */ + +/* Bounded copy of a NUL-terminated string into a fixed field, zero-padded. */ +static void mb_copy_str(char *dst, uint8_t cap, const char *src) +{ + uint8_t n = 0; + while (n + 1u < cap && src[n]) + { + dst[n] = src[n]; + n++; + } + while (n < cap) + dst[n++] = 0; +} + +/* CRC-32 (zlib) of the internal application flash, which is memory-mapped so + * no SPI is involved. Same polynomial as the external slot CRC. */ +static uint32_t mb_int_image_crc32(uint32_t len) +{ + const uint8_t *p = (const uint8_t *)MB_INT_APP_BASE; + return mb_crc32_bytes(p, len); +} + +static bool mb_internal_matches(uint32_t image_size, uint32_t image_crc32) +{ + return mb_int_image_crc32(image_size) == image_crc32; +} + +mb_fw_match_t MB_InternalMatchesSlot(uint8_t slot) +{ + mb_slot_header_t hdr; + uint8_t err = mb_read_header(slot, &hdr); + + if (err == MB_ERR_SPI) + return MB_FW_IO; /* couldn't read the header: never conclude mismatch */ + if (err != MB_OK) + return MB_FW_MISMATCH; /* no valid header: definitely not this firmware */ + + return mb_internal_matches(hdr.image_size, hdr.image_crc32) + ? MB_FW_MATCH : MB_FW_MISMATCH; +} + +bool MB_InternalMatchesProfile(const mb_profile_state_t *state) +{ + if (!state || state->image_size == 0u || state->image_size > MB_INT_APP_SIZE) + return false; + return mb_internal_matches(state->image_size, state->image_crc32); +} + +uint8_t MB_BackupInternalToSlot0(mb_progress_fn progress) +{ + const uint8_t *img = (const uint8_t *)MB_INT_APP_BASE; + const uint32_t size = MB_INT_APP_SIZE; + const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)MB_SLOT_BACKUP * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + + /* Erase the header sector + image area (rounded up to 4 KiB sectors). */ + for (uint32_t off = 0; off < MB_SLOT_IMG_OFFSET + size; off += MB_EXT_SECTOR) + if (!mb_ext_sector_erase(base + off)) + return MB_ERR_SPI; + + /* Program the whole internal application region verbatim, in chunks, + * reporting progress. It is an exact copy of what executes, so restoring + * it later reproduces the running firmware bit-for-bit. */ + for (uint32_t done = 0; done < size; ) + { + uint32_t n = (size - done < MB_EXT_SECTOR) ? (size - done) : MB_EXT_SECTOR; + if (!mb_ext_program(base + MB_SLOT_IMG_OFFSET + done, img + done, n)) + return MB_ERR_SPI; + done += n; + if (progress) + progress(done, size); + } + + /* Validate the stored image before committing its header. Until that final + * header write the slot remains invalid, so a failed CRC or interrupted + * backup is guaranteed to retry at the next boot. */ + const uint32_t image_crc = mb_int_image_crc32(size); + uint32_t ext_crc = mb_ext_image_crc32(base + MB_SLOT_IMG_OFFSET, size); + if (mb_spi_err) return MB_ERR_SPI; + if (ext_crc != image_crc) return MB_ERR_CRC; + + /* Write the COMMITTED header only after the external image verified. */ + mb_slot_header_t hdr; + memset(&hdr, 0, sizeof(hdr)); + hdr.magic = MB_SLOT_MAGIC; + hdr.hdr_version = MB_HDR_VERSION; + hdr.flags = MB_FLAG_COMMITTED; + hdr.image_size = size; + hdr.image_crc32 = image_crc; + mb_copy_str(hdr.name, MB_NAME_LEN, EDITION_STRING); + mb_copy_str(hdr.fw_version, MB_VERSION_LEN, VERSION_STRING_2); + + if (!mb_ext_program(base, (const uint8_t *)&hdr, sizeof(hdr))) + return MB_ERR_SPI; + + return MB_OK; +} diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h index 0a4afe8d..b9c1006c 100644 --- a/App/driver/mb_flash.h +++ b/App/driver/mb_flash.h @@ -43,11 +43,15 @@ #define MB_INT_APP_SIZE 0x0001D800u /* 118 KiB */ /* External SPI flash slot layout (see docs/multiboot-design.md). - * Each 128 KiB slot = one header sector (4 KiB) followed by the image. */ + * Each 128 KiB slot = one header sector (4 KiB) followed by the image. + * Slot 0 is the firmware-managed BACKUP of the normally-flashed firmware + * (written by MB_BackupInternalToSlot0, protected from host writes); slots + * 1..4 are the user slots managed from UV Studio. */ #define MB_SLOT_STRIDE 0x00020000u /* 128 KiB per slot */ #define MB_SLOT_IMG_OFFSET 0x00001000u /* image starts after header sector */ -#define MB_SLOT0_EXT_BASE 0x00020000u /* slot 0 header base */ -#define MB_SLOT_COUNT 4u +#define MB_SLOT0_EXT_BASE 0x00020000u /* slot 0 (backup) header base */ +#define MB_SLOT_COUNT 5u /* slot 0 backup + slots 1..4 */ +#define MB_SLOT_BACKUP 0u /* slot 0 = firmware base backup */ /* Slot header (stored at the slot base, first 4 KiB sector). 64 bytes. */ #define MB_SLOT_MAGIC 0x31424D46u /* "FMB1" */ @@ -104,4 +108,125 @@ uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header); uint8_t MB_SlotErase(uint8_t slot); uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len); +/* -------------------------------------------------------------------------- */ +/* Per-profile settings banks. */ +/* */ +/* Each firmware slot gets its own copy of the user configuration (memory */ +/* channels, names, VFOs, settings) so switching firmware no longer shares - */ +/* or silently clobbers - settings between editions. The banking itself is a */ +/* single address offset applied in the flash driver (see */ +/* PY25Q16_SetProfileBase); everything below PY25Q16_PROFILE_SHARED_FROM */ +/* (0x010000, the calibration boundary) is per-profile, everything at/above */ +/* stays shared. Slot N is bound to profile N. Profile 0 (slot 0 = base */ +/* backup) reuses the historical config region at 0x000000 - no migration. */ +/* */ +/* External SPI flash (PY25Q16, 2 MiB) map: */ +/* 0x000000 profile 0 config (channels/settings) ] per-profile */ +/* 0x010000 calibration (512 B) ] shared */ +/* 0x011000 boot logo (4 KiB) ] shared */ +/* 0x020000 slot 0 firmware (BACKUP, 128 KiB) ] firmware-managed */ +/* 0x040000 slot 1 firmware (128 KiB) ] */ +/* 0x060000 slot 2 firmware ] user (UV Studio) */ +/* 0x080000 slot 3 firmware ] */ +/* 0x0A0000 slot 4 firmware ] */ +/* 0x0C0000 profile 1 config (64 KiB) ] */ +/* 0x0D0000 profile 2 config (64 KiB) ] per-profile */ +/* 0x0E0000 profile 3 config (64 KiB) ] */ +/* 0x0F0000 profile 4 config (64 KiB) ] */ +/* 0x100000 multiboot state A (4 KiB marker) ] shared */ +/* 0x101000 multiboot state B (4 KiB marker) ] redundant */ +/* 0x102000 -- free ~888 KiB -- */ +/* 0x1E0000 RX/TX log (32 KiB) ] shared */ +/* */ +/* Banks are 64 KiB for headroom; the live config footprint is ~44 KiB (max */ +/* physical config address 0x00A170). Keep the profile banks past the last */ +/* slot if MB_SLOT_COUNT ever grows. */ + +#define MB_PROFILE_COUNT MB_SLOT_COUNT /* one profile per slot (0..4) */ +#define MB_PROFILE_BANK_SIZE 0x00010000u /* 64 KiB per config bank */ +#define MB_PROFILE1_EXT_BASE 0x000C0000u /* profiles 1..4, right after slot 4 */ +#define MB_PROFILE_STATE_A_BASE 0x00100000u /* redundant marker sector A */ +#define MB_PROFILE_STATE_B_BASE 0x00101000u /* redundant marker sector B */ + +/* Active-profile marker: two alternating external-flash sectors, never banked, + * outside the EEPROM logical map. A new record is verified in the inactive + * sector before it supersedes the previous one, so a power loss always leaves + * at least one usable state. The expected firmware identity is stored here as + * well: boot resolution never depends on the slot header remaining readable. */ +#define MB_PROFILE_LEGACY_MAGIC 0x31504D46u /* "FMP1" (single 8-byte record) */ +#define MB_PROFILE_MAGIC 0x32504D46u /* "FMP2" (redundant identity record) */ +typedef struct __attribute__((packed)) { + uint32_t magic; /* MB_PROFILE_MAGIC */ + uint32_t generation; /* monotonically increasing record version */ + uint32_t image_size; /* expected internal image size */ + uint32_t image_crc32; /* expected internal image CRC-32 */ + uint8_t index; /* active profile 0..MB_PROFILE_COUNT-1 */ + uint8_t index_inv; /* ~index, quick integrity check */ + uint8_t reserved[2]; /* fixed zero for deterministic state CRC */ + uint32_t state_crc32; /* CRC-32 over all preceding fields */ +} mb_profile_state_t; + +/* External-flash base of a profile's config bank. Profile 0 -> 0 (historical + * region, identity map); profiles 1..N -> past the slots. Out-of-range -> 0. */ +uint32_t MB_ProfileBase(uint8_t profile); + +/* Result of reading the active-profile marker. A boot must treat these very + * differently: MISSING = fresh radio (adopting the running firmware as Main is + * fine); IO / CORRUPT = uncertain (must NOT overwrite Main). */ +typedef enum { + MB_MARK_VALID = 0, /* read OK, well-formed; *state populated */ + MB_MARK_LEGACY, /* valid FMP1 index; identity not stored */ + MB_MARK_MISSING, /* read OK but both sectors erased */ + MB_MARK_CORRUPT, /* read OK but magic/integrity bad */ + MB_MARK_IO, /* could not be read (SPI error) */ +} mb_mark_status_t; + +/* Read the newest valid active-profile marker, distinguishing the states above. */ +mb_mark_status_t MB_ReadActiveProfile(mb_profile_state_t *state); + +/* Convenience wrapper for non-critical callers (e.g. cursor pre-selection): + * the valid index, or 0 for anything not cleanly VALID. */ +uint8_t MB_GetActiveProfile(void); + +/* Write the active-profile marker into the inactive redundant sector and read it + * back to confirm it landed. The previous valid record is kept intact. The slot + * header supplies the expected internal firmware identity. */ +uint8_t MB_SetActiveProfile(uint8_t profile); + +/* Erase a profile's whole config bank (host "Reset config" for a user slot): + * the next boot on that slot reads 0xFF and re-seeds factory defaults. Profile 0 + * (the base) is refused - reset it by factory-resetting the base firmware. + * External flash only, never brick-critical. */ +uint8_t MB_ProfileErase(uint8_t profile); + +/* -------------------------------------------------------------------------- */ +/* Slot 0 self-backup (base firmware). */ +/* -------------------------------------------------------------------------- */ + +/* Progress callback for the (slow) slot-0 self-backup; may be NULL. */ +typedef void (*mb_progress_fn)(uint32_t done, uint32_t total); + +/* Does the running internal firmware carry the DECLARED identity of `slot` + * (CRC32 over image_size == the slot header's image_crc32)? This checks the + * header's claim, not the stored image itself (that is validated once, right + * after a backup). IO is reported separately so a transient SPI error is never + * mistaken for a mismatch - which would wrongly trigger a self-backup over Main. */ +typedef enum { + MB_FW_MATCH = 0, /* internal carries this slot's declared identity */ + MB_FW_MISMATCH, /* reliably not this slot (or slot has no header) */ + MB_FW_IO, /* slot header unreadable: uncertain */ +} mb_fw_match_t; + +mb_fw_match_t MB_InternalMatchesSlot(uint8_t slot); + +/* Compare internal flash with the identity stored in a validated marker. */ +bool MB_InternalMatchesProfile(const mb_profile_state_t *state); + +/* Back up the running internal firmware into slot 0 (erase + full image + + * COMMITTED header, name=edition, version) and validate the stored image by a + * full external CRC read-back. External flash only, never brick-critical; a + * failure (SPI or CRC) leaves the slot uncommitted and does not advance the + * marker, so boot resolution retries it. progress may be NULL. */ +uint8_t MB_BackupInternalToSlot0(mb_progress_fn progress); + #endif /* DRIVER_MB_FLASH_H */ diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index 876cdb53..4eeb5ae2 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -47,6 +47,30 @@ static uint8_t SectorCache[SECTOR_SIZE]; static uint8_t BlackHole[4] __attribute__((aligned(4))); static volatile bool TC_Flag; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT +/* Active settings-profile base (see py25q16.h). 0 = profile 0 / historical + * config region, i.e. an identity mapping. */ +static uint32_t ProfileBase = 0; + +void PY25Q16_SetProfileBase(uint32_t Base) +{ + ProfileBase = Base; +} + +/* Redirect config-region accesses (addr < boundary) into the active bank. + * Calibration/logo/slots/marker (addr >= boundary) are returned unchanged. + * ProfileBase is sector-aligned, so alignment done by callers is preserved. */ +static inline uint32_t ProfileMap(uint32_t Address) +{ + return (Address < PY25Q16_PROFILE_SHARED_FROM) ? (Address + ProfileBase) : Address; +} +#else +static inline uint32_t ProfileMap(uint32_t Address) +{ + return Address; +} +#endif + static inline void CS_Assert() { GPIO_ResetOutputPin(CS_PIN); @@ -222,6 +246,7 @@ static void WriteEnable(); static void SectorErase(uint32_t Addr); static void SectorProgram(uint32_t Addr, const uint8_t *Buf, uint32_t Size); static void PageProgram(uint32_t Addr, const uint8_t *Buf, uint32_t Size); +static void ReadBufferRaw(uint32_t Address, void *pBuffer, uint32_t Size); void PY25Q16_Init() { @@ -229,7 +254,7 @@ void PY25Q16_Init() SPI_Init(); } -void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) +static void ReadBufferRaw(uint32_t Address, void *pBuffer, uint32_t Size) { MBMARK("RD cmd"); // about to assert CS + send read command CS_Assert(); @@ -259,6 +284,11 @@ void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) CS_Release(); } +void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) +{ + ReadBufferRaw(ProfileMap(Address), pBuffer, Size); +} + // Like PY25Q16_ReadBuffer, but waits for the flash to be idle first (WIP=0), // exactly as PY25Q16_WriteBuffer does before its internal reads. A standalone // read issued while the chip is still busy from a prior program/erase never @@ -273,6 +303,8 @@ void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size) void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append) { + Address = ProfileMap(Address); /* map once; internal reads use *Raw below */ + #ifdef DEBUG printf("spi flash write: %06x %ld %d\n", Address, Size, Append); #endif @@ -298,7 +330,9 @@ void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, b if (SecAddr != SectorCacheAddr) { - PY25Q16_ReadBuffer(SecAddr, SectorCache, SECTOR_SIZE); + /* SecAddr is already in mapped space (Address was mapped above), so + * read raw to avoid mapping a second time. */ + ReadBufferRaw(SecAddr, SectorCache, SECTOR_SIZE); SectorCacheAddr = SecAddr; } @@ -358,6 +392,7 @@ void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, b void PY25Q16_SectorErase(uint32_t Address) { + Address = ProfileMap(Address); Address -= (Address % SECTOR_SIZE); SectorErase(Address); if (SectorCacheAddr == Address) @@ -366,6 +401,13 @@ void PY25Q16_SectorErase(uint32_t Address) } } +void PY25Q16_InvalidateCache(void) +{ + /* Same "no sector cached" sentinel as the initial value: the next write + * re-reads its sector from flash instead of trusting SectorCache. */ + SectorCacheAddr = 0x1000000; +} + static inline void WriteAddr(uint32_t Addr) { SPI_WriteByte(0xff & (Addr >> 16)); diff --git a/App/driver/py25q16.h b/App/driver/py25q16.h index 4d4d696b..ab6ffd2d 100644 --- a/App/driver/py25q16.h +++ b/App/driver/py25q16.h @@ -26,4 +26,33 @@ void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size); void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append); void PY25Q16_SectorErase(uint32_t Address); +/* Drop the internal single-sector write cache. Call after erasing/programming + * flash behind the driver's back (e.g. the raw multiboot slot/profile ops) so a + * later write cannot skip or resurrect data based on a stale cached sector. */ +void PY25Q16_InvalidateCache(void); + +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT +/* + * Multiboot per-profile config banking. + * + * Each firmware slot owns a private copy of the user configuration (memory + * channels, names, VFOs, settings). A non-zero profile base transparently + * shifts every flash access BELOW PY25Q16_PROFILE_SHARED_FROM into the active + * profile's bank; calibration, boot logo, firmware slots and the multiboot + * marker all live at/above that boundary and stay shared across every profile. + * + * This is the single choke point: both the EEPROM emulation (eeprom_compat.c) + * and the firmware's direct config reads/writes (settings.c) end up here, so + * one offset covers them all - no per-call-site patching. + * + * Set once at boot, before any settings read, from + * PY25Q16_SetProfileBase(MB_ProfileBase(MB_BootResolveProfile())); + * and never changed again during a session (the profile only changes through a + * reflash + reset), so the banking itself never needs a cache flush. Raw profile + * erases behind the driver explicitly call PY25Q16_InvalidateCache(). + */ +#define PY25Q16_PROFILE_SHARED_FROM 0x00010000u /* calibration boundary (see flash map) */ +void PY25Q16_SetProfileBase(uint32_t Base); +#endif + #endif diff --git a/App/main.c b/App/main.c index ff9d6d24..ad099c23 100644 --- a/App/main.c +++ b/App/main.c @@ -52,6 +52,10 @@ #include "driver/system.h" #include "driver/systick.h" #include "driver/py25q16.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" + #include "ui/multiboot.h" +#endif #ifdef ENABLE_UART #include "driver/uart.h" #endif @@ -81,6 +85,14 @@ void Main(void) SYSTICK_Init(); BOARD_Init(); +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + /* Resolve the active settings profile BEFORE any EEPROM/settings access + * below. This also adopts a normally-flashed firmware as slot 0 (discreet + * self-backup) when the running image isn't the slot the marker points to. + * Calibration stays shared regardless of the selected profile. */ + PY25Q16_SetProfileBase(MB_ProfileBase(MB_BootResolveProfile())); +#endif + boot_counter_10ms = 250; // 2.5 sec #ifdef ENABLE_UART diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index 1751fe27..4f14f7d8 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -75,6 +75,13 @@ static void mb_status_bar(void) { UI_StatusClear(); GUI_DisplaySmallestInverse("F4HWN MULTIBOOT", 34, 0, true, true, 94); + + /* Thin line dressing up the otherwise blank row between the status bar and + * the first content row. Drawn on gFrameBuffer[0] (line 0), which every + * multiboot screen leaves blank, so it shows on all of them. Bit 3 (~mid of + * the row) stays clear of the selected-slot capsule's top edge (bit 7). */ + for (uint8_t x = 2u; x < LCD_WIDTH - 2u; x++) + gFrameBuffer[0][x] |= 0x08u; } /* Bottom key-hint line: each key name as an inverse 3x5 capsule label, its @@ -175,7 +182,9 @@ static void mb_render_slots(uint8_t selected, memset(line, 0, sizeof(line)); memset(version, 0, sizeof(version)); - line[0] = (char)('0' + slot); + /* Slot 0 is the auto-backed-up main firmware: label it 'M' (Main) so it + * reads apart from the numbered user slots 1..4. */ + line[0] = (slot == 0u) ? 'M' : (char)('0' + slot); line[1] = ' '; line[2] = ' '; @@ -220,16 +229,17 @@ static void mb_render_slots(uint8_t selected, ST7565_BlitFullScreen(); } -static void mb_prepare_progress(uint8_t slot) +/* Both restore and initial Main backup use the exact same progress frame. + * Keep it out-of-line: each caller has different text, but duplicating the + * framebuffer setup and the two LCD blits only wastes MCU flash. */ +__attribute__((noinline)) static void mb_prepare_progress_screen(const char *title, + const char *detail) { - char title[] = "Restore slot 0"; - title[13] = (char)('0' + slot); - UI_DisplayClear(); mb_status_bar(); UI_PrintStringSmallNormal(title, 2, 126, 1); UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 3); - UI_PrintStringSmallNormal("Writing & Verify", 2, 126, 5); + UI_PrintStringSmallNormal(detail, 2, 126, 5); /* Same rounded outline and hatch pattern as the scan progress gauge. */ gFrameBuffer[6][3] = 0x0Cu; @@ -242,10 +252,48 @@ static void mb_prepare_progress(uint8_t slot) ST7565_BlitFullScreen(); } +static void mb_prepare_progress(uint8_t slot) +{ + char slot_title[] = "Restore slot 0"; + slot_title[13] = (char)('0' + slot); + const char *title = (slot == 0u) ? "Restore Main" : slot_title; + + mb_prepare_progress_screen(title, "Writing & Verify"); +} + +/* Discreet "Main backup" screen shown once, at the first boot after a normal + * Flash-Firmware install, while the running firmware is copied into slot 0. */ +static void mb_backup_prepare(void) +{ + mb_prepare_progress_screen("Saving Main", "Slot Main"); +} + +static void mb_backup_progress(uint32_t done, uint32_t total) +{ + uint32_t cols = total ? (done * 118u / total) : 118u; + if (cols > 118u) + cols = 118u; + for (uint32_t i = 0; i < cols; i++) + gFrameBuffer[6][5u + i] = 0x2Du; + ST7565_BlitFullScreen(); +} + +/* With no trustworthy profile, continuing would let normal boot-time settings + * writes modify an arbitrary bank. Keep the radio in a read-only error state; + * a power cycle can recover from a transient SPI fault. */ +__attribute__((noreturn)) static void mb_profile_error_halt(void) +{ + BACKLIGHT_TurnOn(); + mb_show_message("PROFILE ERROR", "Flash state unknown", "Restart radio"); + for (;;) + SYSTEM_DelayMs(100); +} + static void mb_confirm_screen(uint8_t slot) { - char title[] = "Restore slot 0?"; - title[13] = (char)('0' + slot); + char slot_title[] = "Restore slot 0?"; + slot_title[13] = (char)('0' + slot); + const char *title = (slot == 0u) ? "Restore Main?" : slot_title; UI_DisplayClear(); mb_status_bar(); @@ -268,12 +316,19 @@ void UI_MultibootSelector(void) mb_wait_release(); mb_scan_slots(headers, status); - for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + /* Pre-select the firmware currently running (its slot, from the marker), so + * the cursor lands on "where you are". If that slot isn't restorable (erased, + * bad CRC...), fall back to the first valid slot. */ + selected = MB_GetActiveProfile(); + if (selected >= MB_SLOT_COUNT || status[selected] != MB_OK) { - if (status[slot] == MB_OK) + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) { - selected = slot; - break; + if (status[slot] == MB_OK) + { + selected = slot; + break; + } } } @@ -316,6 +371,17 @@ void UI_MultibootSelector(void) if (key != KEY_MENU) continue; + /* Bind this slot to its own settings profile BEFORE reflashing. The + * write is verified (read-back); if it can't be confirmed we must NOT + * reflash - otherwise the next boot could resolve to the wrong profile + * (e.g. when two slots hold the same firmware image). */ + if (MB_SetActiveProfile(selected) != MB_OK) + { + mb_show_message("PROFILE ERROR", "Marker not saved", "Press any key"); + (void)mb_get_key(); + continue; + } + mb_prepare_progress(selected); uint8_t err = MB_RestoreSlot(selected, gFrameBuffer[6]); @@ -326,3 +392,76 @@ void UI_MultibootSelector(void) mb_scan_slots(headers, status); } } + +/* Adopt the running internal firmware as Main: back it up into slot 0 and point + * the marker at profile 0. Reached when the firmware was installed outside + * multiboot (fresh radio, or a plain Flash-Firmware). */ +static uint8_t mb_adopt_internal_as_main(void) +{ + BACKLIGHT_TurnOn(); + mb_backup_prepare(); + if (MB_BackupInternalToSlot0(mb_backup_progress) == MB_OK) + (void)MB_SetActiveProfile(MB_SLOT_BACKUP); + return MB_SLOT_BACKUP; +} + +uint8_t MB_BootResolveProfile(void) +{ + mb_profile_state_t mark; + mb_mark_status_t ms = MB_MARK_IO; + + for (uint8_t retry = 0; retry < 3u && ms == MB_MARK_IO; retry++) + { + ms = MB_ReadActiveProfile(&mark); + if (ms == MB_MARK_IO) + SYSTEM_DelayMs(10); + } + + /* A reliably-read marker is authoritative: it carries the expected internal + * identity, so we don't even need the slot header. */ + if (ms == MB_MARK_VALID) + { + if (MB_InternalMatchesProfile(&mark)) + return mark.index; /* running the slot the marker names */ + + /* Marker read fine but internal no longer carries its identity -> the + * firmware was replaced outside multiboot (a plain Flash-Firmware). Adopt + * it as Main. Deliberately NOT a content scan here: a build that merely + * duplicates a user slot (or a marker that already points at such a slot) + * must still refresh Main. */ + return mb_adopt_internal_as_main(); + } + + /* Marker unreliable (MISSING / LEGACY / CORRUPT / IO): identify the running + * firmware by content, and never destroy Main on uncertainty - internal is + * adopted only when it matches no slot AND every read was clean, so a + * transient SPI error or a half-written marker can never destroy Main. */ + bool had_io = false; + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + { + mb_fw_match_t m = MB_FW_IO; + for (uint8_t retry = 0; retry < 3u && m == MB_FW_IO; retry++) + m = MB_InternalMatchesSlot(slot); + if (m == MB_FW_MATCH) + { + (void)MB_SetActiveProfile(slot); /* record/repair the marker */ + return slot; + } + if (m == MB_FW_IO) + had_io = true; + } + + if (had_io || ms == MB_MARK_IO || ms == MB_MARK_CORRUPT) + { + /* Halt to protect an existing Main while the flash state is uncertain; + * but if slot 0 holds no valid backup there is nothing to protect, so + * fall through and adopt instead of bricking a first boot. */ + uint8_t main_status = MB_SlotInfo(MB_SLOT_BACKUP, NULL); + bool main_exists = (main_status != MB_ERR_MAGIC && + main_status != MB_ERR_NOT_COMMITTED); + if (main_exists) + mb_profile_error_halt(); + } + + return mb_adopt_internal_as_main(); +} diff --git a/App/ui/multiboot.h b/App/ui/multiboot.h index c222354c..32bba490 100644 --- a/App/ui/multiboot.h +++ b/App/ui/multiboot.h @@ -5,8 +5,16 @@ #ifndef UI_MULTIBOOT_H #define UI_MULTIBOOT_H +#include + /* Blocking boot-time slot selector. Returns only when the user chooses EXIT; * a successful restore resets the radio from the RAM-resident copier. */ void UI_MultibootSelector(void); +/* Resolve the active settings profile at boot, BEFORE any settings read. + * Detects a firmware installed outside multiboot (internal != slot[marker]) and, + * if so, discreetly self-backs it up into slot 0 and adopts profile 0. Returns + * the profile index (0..MB_SLOT_COUNT-1) to feed PY25Q16_SetProfileBase(). */ +uint8_t MB_BootResolveProfile(void); + #endif