diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 3cddbf7f..a3016818 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -230,6 +230,10 @@ endif() if(ENABLE_FEAT_F4HWN_BEAM AND NOT ENABLE_AIRCOPY) message(FATAL_ERROR "ENABLE_FEAT_F4HWN_BEAM requires ENABLE_AIRCOPY (it reuses g_FSK_Buffer, AIRCOPY_Obfuscate and the FSK packet plumbing).") endif() +enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT + driver/mb_flash.c + ui/multiboot.c +) enable_feature(ENABLE_FEAT_F4HWN_QRCODE) enable_feature(ENABLE_FEAT_F4HWN_LOGO) enable_feature(ENABLE_FEAT_F4HWN_LOGO_SAV) diff --git a/App/app/uart.c b/App/app/uart.c index 5c052430..832c7b51 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -45,6 +45,10 @@ #include "settings.h" #include "version.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" +#endif + #if defined(ENABLE_OVERLAY) #include "sram-overlay.h" #endif @@ -201,11 +205,11 @@ static void SendReply_VCP(void *pReply, uint16_t Size) return; } - memcpy(VCP_ReplyBuf + sizeof(Header_t), pReply, Size); + uint8_t *pBody = VCP_ReplyBuf + sizeof(Header_t); + uint8_t *pFooter = pBody + Size; - Header_t *pHeader = (Header_t *)VCP_ReplyBuf; - Footer_t *pFooter = (Footer_t *)(VCP_ReplyBuf + sizeof(Header_t) + Size); - pReply = VCP_ReplyBuf + sizeof(Header_t); + memcpy(pBody, pReply, Size); + pReply = pBody; if (bIsEncrypted) { @@ -215,23 +219,29 @@ static void SendReply_VCP(void *pReply, uint16_t Size) pBytes[i] ^= Obfuscation[i % 16]; } - pHeader->ID = 0xCDAB; - pHeader->Size = Size; + /* Build the transport header/footer byte by byte. The reply body may have + * an odd size, so pFooter is not necessarily half-word aligned; casting it + * to Footer_t and storing ID as uint16_t can HardFault on Cortex-M0+. */ + VCP_ReplyBuf[0] = 0xAB; + VCP_ReplyBuf[1] = 0xCD; + VCP_ReplyBuf[2] = (uint8_t)(Size & 0xFFu); + VCP_ReplyBuf[3] = (uint8_t)(Size >> 8); // VCP_Send((uint8_t *)&Header, sizeof(Header)); // VCP_Send(pReply, Size); - + if (bIsEncrypted) { - pFooter->Padding[0] = Obfuscation[(Size + 0) % 16] ^ 0xFF; - pFooter->Padding[1] = Obfuscation[(Size + 1) % 16] ^ 0xFF; + pFooter[0] = Obfuscation[(Size + 0) % 16] ^ 0xFF; + pFooter[1] = Obfuscation[(Size + 1) % 16] ^ 0xFF; } else { - pFooter->Padding[0] = 0xFF; - pFooter->Padding[1] = 0xFF; + pFooter[0] = 0xFF; + pFooter[1] = 0xFF; } - pFooter->ID = 0xBADC; + pFooter[2] = 0xDC; + pFooter[3] = 0xBA; // VCP_Send((uint8_t *)&Footer, sizeof(Footer)); @@ -782,6 +792,24 @@ bool UART_IsCommandAvailable(uint32_t Port) return CRC_Calculate(pUART_Command->Buffer, Size) == Crc; } +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT +/* Timestamp latched by the device-info handshake (0x0514) for this port. Slot + * writes/erases require it to match, like the EEPROM write command (CMD_051D). */ +static uint32_t mb_port_timestamp(uint32_t Port) +{ +#if defined(ENABLE_UART) + if (Port == UART_PORT_UART) + return UART_Timestamp; +#endif +#if defined(ENABLE_USB) + if (Port == UART_PORT_VCP) + return VCP_Timestamp; +#endif + (void)Port; + return 0; +} +#endif + void UART_HandleCommand(uint32_t Port) { UART_Command_t *pUART_Command; @@ -852,6 +880,217 @@ void UART_HandleCommand(uint32_t Port) #endif break; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + // Multiboot self-test (developer only, gated build). + case 0x0710: // backup: internal application -> external flash slot 0 + { + uint32_t size = 0, crc = 0; + MB_BackupToSlot0(&size, &crc); + struct __attribute__((packed)) { + Header_t Header; + uint32_t Size; + uint32_t Crc32; + } Reply; + Reply.Header.ID = 0x0711; + Reply.Header.Size = 8; + Reply.Size = size; + Reply.Crc32 = crc; + SendReply(Port, &Reply, sizeof(Reply)); // ack once the backup completed + break; + } + + case 0x0712: // restore: external flash slot 0 -> internal application + reset + { + // Returns only if validation failed (internal flash untouched); + // on success it reflashes and resets, so control never comes back. + uint8_t err = MB_RestoreSlot0(); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Code; + } Reply; + Reply.Header.ID = 0x0713; + Reply.Header.Size = 1; + Reply.Code = err; + SendReply(Port, &Reply, sizeof(Reply)); // restore refused, report why + break; + } + + case 0x0714: // test: corrupt slot 0 image (exercise the CRC-refusal path) + { + uint16_t n = pUART_Command->Header.Size; + if (n > 128) + n = 128; + MB_CorruptSlot0(pUART_Command->Data, n); + struct __attribute__((packed)) { + Header_t Header; + uint16_t Count; + } Reply; + Reply.Header.ID = 0x0715; + Reply.Header.Size = 2; + Reply.Count = n; + SendReply(Port, &Reply, sizeof(Reply)); // ack: bytes corrupted + break; + } + + case 0x0716: // validate slot 0 (no reflash): report result code + computed CRC + { + uint32_t crc = 0; + uint8_t code = MB_ValidateSlot0(&crc); + struct __attribute__((packed)) { + Header_t Header; + uint32_t Crc32; // 4-byte aligned (offset 4): no unaligned write + uint8_t Code; + } Reply; + Reply.Header.ID = 0x0717; + Reply.Header.Size = 5; + Reply.Crc32 = crc; + Reply.Code = code; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0718: // ground-truth dump of external flash via the DMA driver + { + uint32_t addr = (uint32_t)pUART_Command->Data[0] + | ((uint32_t)pUART_Command->Data[1] << 8) + | ((uint32_t)pUART_Command->Data[2] << 16) + | ((uint32_t)pUART_Command->Data[3] << 24); + uint8_t len = pUART_Command->Data[4]; + if (len > 64) + len = 64; + struct __attribute__((packed)) { + Header_t Header; + uint32_t Addr; + uint8_t Len; + uint8_t Data[64]; + } Reply; + MB_DumpExt(addr, Reply.Data, len); + Reply.Header.ID = 0x0719; + Reply.Header.Size = 5 + len; // Addr(4) + Len(1) + data(len) + Reply.Addr = addr; + Reply.Len = len; + SendReply(Port, &Reply, sizeof(Header_t) + 5 + len); + break; + } + + case 0x071C: // diagnostic: snapshot SPI2 / DMA state (no flash access) + { + uint32_t st[4]; + MB_SpiState(st); + struct __attribute__((packed)) { + Header_t Header; + uint32_t V[4]; + } Reply; + Reply.Header.ID = 0x071D; + Reply.Header.Size = 16; + Reply.V[0] = st[0]; Reply.V[1] = st[1]; + Reply.V[2] = st[2]; Reply.V[3] = st[3]; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + // ---- M4 slot management ("Firmware Slots") ------------------------ + case 0x0720: // slot info: read the 64-byte header only (fast, no CRC) + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + mb_slot_header_t hdr; + memset(&hdr, 0, sizeof(hdr)); + uint8_t status = MB_SlotInfo(slot, &hdr); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + uint8_t Hdr[sizeof(mb_slot_header_t)]; + } Reply; + Reply.Header.ID = 0x0721; + Reply.Header.Size = 2 + sizeof(mb_slot_header_t); + Reply.Slot = slot; + Reply.Status = status; + memcpy(Reply.Hdr, &hdr, sizeof(hdr)); + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0722: // slot erase: wipe the whole 128 KiB slot region + { + 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_SlotErase(slot); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0723; + Reply.Header.Size = 2; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0724: // slot write: program bytes at slot+offset (slot pre-erased) + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + uint32_t offset = (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); + uint16_t len = (uint16_t)(pUART_Command->Data[6] + | ((uint16_t)pUART_Command->Data[7] << 8)); + uint32_t ts = (uint32_t)pUART_Command->Data[8] + | ((uint32_t)pUART_Command->Data[9] << 8) + | ((uint32_t)pUART_Command->Data[10] << 16) + | ((uint32_t)pUART_Command->Data[11] << 24); + uint8_t status; + if (ts != mb_port_timestamp(Port)) + status = MB_ERR_AUTH; + else if (len > 240u) // 12-byte prefix + data must fit Data[252] + status = MB_ERR_SIZE; + else + status = MB_SlotWrite(slot, offset, &pUART_Command->Data[12], len); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0725; + Reply.Header.Size = 2; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0726: // slot validate: full image CRC-32, no reflash + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + uint32_t crc = 0; + uint8_t status = MB_ValidateSlot(slot, NULL, &crc); + struct __attribute__((packed)) { + Header_t Header; + uint32_t Crc32; // offset 4: 4-byte aligned, no unaligned store + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0727; + Reply.Header.Size = 6; + Reply.Crc32 = crc; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } +#endif + #ifdef ENABLE_UART_RW_BK_REGS case 0x0601: CMD_0601_ReadBK4819Reg(Port, pUART_Command->Buffer); diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c new file mode 100644 index 00000000..44453cd6 --- /dev/null +++ b/App/driver/mb_flash.c @@ -0,0 +1,798 @@ +/* Copyright 2026 F4HWN + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include "driver/mb_flash.h" + +#include "py32f0xx.h" +#include "driver/py25q16.h" +#include "driver/st7565.h" +#include "ui/helper.h" +#include "version.h" + +/* Internal-flash program/erase keys (FLASH_KEY1 / FLASH_KEY2). */ +#define MB_FLASH_KEY1 0x45670123u +#define MB_FLASH_KEY2 0xCDEF89ABu + +/* Internal flash granularity (PY32F071xB): program & page-erase = 256 bytes. */ +#define MB_FLASH_PAGE 256u + +/* External SPI flash chip-select is on PA3 (see driver/py25q16.c). */ +#define MB_CS_PIN (1u << 3) + +/* LCD control pins used only for RAM-resident progress updates. */ +#define MB_LCD_CS_PIN (1u << 2) /* PB2 */ +#define MB_LCD_A0_PIN (1u << 6) /* PA6 */ + +/* Rounded progress gauge geometry, matching ScanProgress_DrawGaugeLine(). */ +#define MB_PROGRESS_COLS 118u +#define MB_PROGRESS_FIRST_COL 5u +#define MB_PROGRESS_FILLED 0x2Du + +/* Number of erase/program retries per page before giving up (and resetting + * anyway - the region is already erased, so USB recovery is the only option). */ +#define MB_PAGE_RETRIES 3u + +/* Bounded waits used by the RAM-only copier. A timeout forces an immediate + * reset instead of hanging forever with IRQs disabled. */ +#define MB_RAM_SPI_TIMEOUT 100000u +#define MB_RAM_FLASH_TIMEOUT 10000000u + +/* + * Factory flash-timing parameter records, held in Puya system memory. + * Mirror of the HAL's _FlashTimmingParam[] table (the HAL module is not built + * in this project). Indexed by the HSI frequency setting (RCC->ICSCR HSI_FS). + * Each entry is the address of a 5-word record read at +0/+8/+16/+24/+32. + */ +static const uint32_t mb_flash_timing[8] = { + 0x1FFF3238, 0x1FFF3260, 0x1FFF3288, 0x1FFF32B0, + 0x1FFF32D8, 0x1FFF3238, 0x1FFF3238, 0x1FFF3238 +}; + +/* -------------------------------------------------------------------------- */ +/* Helpers (flash-resident). */ +/* -------------------------------------------------------------------------- */ + +/* zlib/PNG CRC-32 (poly 0xEDB88320), streaming. Seed 'crc' with 0xFFFFFFFF and + * XOR the final result with 0xFFFFFFFF. No lookup table (saves flash). */ +static uint32_t mb_crc32_update(uint32_t crc, const uint8_t *data, uint32_t len) +{ + while (len--) + { + crc ^= *data++; + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return crc; +} + +static void mb_copy_str(char *dst, uint32_t cap, const char *src) +{ + uint32_t i = 0; + if (src) + for (; i + 1 < cap && src[i]; i++) + dst[i] = src[i]; + for (; i < cap; i++) + dst[i] = 0; +} + +/* -------------------------------------------------------------------------- */ +/* Flash-resident preparation (runs while the flash is still readable). */ +/* -------------------------------------------------------------------------- */ + +static void MB_PrepareInternalFlash(void) +{ + /* Unlock the internal flash control register. */ + if (FLASH->CR & FLASH_CR_LOCK) + { + FLASH->KEYR = MB_FLASH_KEY1; + FLASH->KEYR = MB_FLASH_KEY2; + } + + /* Program/erase timing sequence (factory calibrated), replicating + * __HAL_FLASH_TIMMING_SEQUENCE_CONFIG(). These registers persist, so it is + * enough to set them once here, before the RAM copier starts erasing. */ + uint32_t base = mb_flash_timing[(RCC->ICSCR & RCC_ICSCR_HSI_FS) >> RCC_ICSCR_HSI_FS_Pos]; + uint32_t p0 = *(volatile uint32_t *)(base + 0); + uint32_t p1 = *(volatile uint32_t *)(base + 8); + uint32_t p2 = *(volatile uint32_t *)(base + 16); + uint32_t p3 = *(volatile uint32_t *)(base + 24); + uint32_t p4 = *(volatile uint32_t *)(base + 32); + + FLASH->TS0 = p0 & 0xFFu; + FLASH->TS1 = (p0 >> 16) & 0x1FFu; + FLASH->TS3 = (p0 >> 8) & 0xFFu; + FLASH->TS2P = p1 & 0xFFu; + FLASH->TPS3 = (p1 >> 16) & 0x7FFu; + FLASH->PERTPE = p2 & 0x1FFFFu; + FLASH->SMERTPE = p3 & 0x1FFFFu; + FLASH->PRGTPE = p4 & 0xFFFFu; + FLASH->PRETPE = (p4 >> 16) & 0x3FFFu; +} + +/* -------------------------------------------------------------------------- */ +/* RAM-resident copier. */ +/* */ +/* This runs while the internal application flash is being erased/programmed, */ +/* during which the flash bus is unavailable. It must therefore NOT fetch any */ +/* code from flash nor read any flash data: it uses raw register access only */ +/* (no external calls), reads the source from the external SPI flash in */ +/* polled mode, and resets the MCU when done. It is placed in .RamFunc, which */ +/* 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) +{ + uint32_t timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_TXE)) + if (!--timeout) + return false; + + *(volatile uint8_t *)&SPI2->DR = v; + + timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_RXNE)) + if (!--timeout) + return false; + + *out = *(volatile uint8_t *)&SPI2->DR; + return true; +} + +__attribute__((always_inline)) static inline bool mb_ram_flash_idle(void) +{ + uint32_t timeout = MB_RAM_FLASH_TIMEOUT; + while (FLASH->SR & FLASH_SR_BSY) + if (!--timeout) + return false; + return true; +} + +__attribute__((always_inline, noreturn)) static inline void mb_ram_reset(void) +{ + __DSB(); + + SCB->AIRCR = (0x5FAu << SCB_AIRCR_VECTKEY_Pos) | SCB_AIRCR_SYSRESETREQ_Msk; + __DSB(); + for (;;) { } +} + +/* 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) +{ + uint32_t timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI1->SR & SPI_SR_TXE)) + if (!--timeout) + return false; + *(volatile uint8_t *)&SPI1->DR = v; + + timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI1->SR & SPI_SR_RXNE)) + if (!--timeout) + return false; + (void)*(volatile uint8_t *)&SPI1->DR; + return true; +} + +__attribute__((always_inline)) static inline bool mb_ram_progress_blit(const uint8_t *line) +{ + uint32_t ok = 1u; + GPIOB->BRR = MB_LCD_CS_PIN; + GPIOA->BRR = MB_LCD_A0_PIN; /* command */ + + if (!mb_ram_lcd_spi(0xB7u) || /* LCD page 7 */ + !mb_ram_lcd_spi(0x10u) || /* column high nibble */ + !mb_ram_lcd_spi(0x04u)) /* visible RAM starts at column 4 */ + ok = 0u; + + GPIOA->BSRR = MB_LCD_A0_PIN; /* data */ + if (ok) + for (uint32_t i = 0; i < 128u; i++) + if (!mb_ram_lcd_spi(line[i])) + { + ok = 0u; + break; + } + GPIOB->BSRR = MB_LCD_CS_PIN; + return ok != 0u; +} + +__attribute__((section(".RamFunc"), noinline, used)) +static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize, + uint8_t *progressLine) +{ + /* 4-byte aligned so the 64-word page program can read it as uint32_t + * (Cortex-M0+ cannot do unaligned word accesses). */ + uint8_t buf[MB_FLASH_PAGE] __attribute__((aligned(4))); + uint32_t remaining = imageSize; + uint32_t regionRemaining = MB_INT_APP_SIZE; + uint32_t pagesDone = 0; + uint32_t progressAccumulator = 0; + uint32_t progressFilled = 0; + uint32_t lcdEnabled = progressLine != NULL; + + + __disable_irq(); + + if (FLASH->CR & FLASH_CR_LOCK) + { + FLASH->KEYR = MB_FLASH_KEY1; + FLASH->KEYR = MB_FLASH_KEY2; + } + + FLASH->SR = FLASH_SR_EOP | FLASH_SR_WRPERR | FLASH_SR_OPTVERR; + + /* Rebuild the complete application region. Bytes past imageSize are never + * read from the external slot: they are forced to erased 0xFF, preventing + * unvalidated padding or remnants of an older, longer firmware. */ + while (regionRemaining >= MB_FLASH_PAGE) + { + uint32_t readSize = remaining < MB_FLASH_PAGE ? remaining : MB_FLASH_PAGE; + uint32_t needProgram = 0; + uint32_t success = 0; + uint8_t ignored; + + /* Volatile stores prevent GCC from replacing this loop with a call + * to flash-resident memset while the application flash is unavailable. */ + volatile uint8_t *fill = buf; + for (uint32_t i = 0; i < MB_FLASH_PAGE; i++) + fill[i] = 0xFFu; + + if (readSize) + { + /* Read only CRC-validated image bytes. The rest of the page stays + * 0xFF when imageSize is not page-aligned. */ + GPIOA->BRR = MB_CS_PIN; /* CS low */ + if (!mb_ram_spi(0x03u, &ignored) || + !mb_ram_spi((extAddr >> 16) & 0xFFu, &ignored) || + !mb_ram_spi((extAddr >> 8) & 0xFFu, &ignored) || + !mb_ram_spi(extAddr & 0xFFu, &ignored)) + goto fatal_reset; + + for (uint32_t i = 0; i < readSize; i++) + if (!mb_ram_spi(0xFFu, &buf[i])) + goto fatal_reset; + + GPIOA->BSRR = MB_CS_PIN; /* CS high */ + } + + for (uint32_t i = 0; i < MB_FLASH_PAGE; i++) + { + if (buf[i] != 0xFFu) + { + needProgram = 1u; + break; + } + } + + for (uint32_t retry = 0; retry < MB_PAGE_RETRIES; retry++) + { + const uint32_t *src = (const uint32_t *)(const void *)buf; + volatile uint32_t *dst = (volatile uint32_t *)intAddr; + + uint32_t i; + uint32_t ok = 1u; + + /* --- page erase (256 bytes) --- */ + if (!mb_ram_flash_idle()) + goto fatal_reset; + FLASH->CR |= FLASH_CR_PER; + *(volatile uint32_t *)intAddr = 0xFFFFFFFFu; + if (!mb_ram_flash_idle()) + goto fatal_reset; + FLASH->CR &= ~FLASH_CR_PER; + FLASH->SR = FLASH_SR_EOP | FLASH_SR_WRPERR | FLASH_SR_OPTVERR; + + if (needProgram) + { + /* Page program: 64 words, PGSTRT before the last word. */ + FLASH->CR |= FLASH_CR_PG; + for (i = 0; i < 64u; i++) + { + dst[i] = src[i]; + if (i == 62u) + FLASH->CR |= FLASH_CR_PGSTRT; + } + if (!mb_ram_flash_idle()) + goto fatal_reset; + FLASH->CR &= ~FLASH_CR_PG; + FLASH->SR = FLASH_SR_EOP | FLASH_SR_WRPERR | FLASH_SR_OPTVERR; + } + + /* --- verify (read-back compare) --- */ + for (i = 0; i < 64u; i++) + { + if (dst[i] != src[i]) + { + ok = 0u; + break; + } + } + if (ok) + { + success = 1u; + break; + } + } + + /* Never silently continue after an unprogrammable page. Returning to + * flash-resident code is unsafe once the application has been erased. */ + if (!success) + goto fatal_reset; + + /* Advance the gauge without division (which could call a helper + * in erased flash). Refresh once per 8 KiB internal sector. */ + if (lcdEnabled) + { + pagesDone++; + progressAccumulator += MB_PROGRESS_COLS; + while (progressAccumulator >= (MB_INT_APP_SIZE / MB_FLASH_PAGE)) + { + progressAccumulator -= (MB_INT_APP_SIZE / MB_FLASH_PAGE); + if (progressFilled < MB_PROGRESS_COLS) + { + progressLine[MB_PROGRESS_FIRST_COL + progressFilled] = MB_PROGRESS_FILLED; + progressFilled++; + } + } + if ((pagesDone & 31u) == 0u || regionRemaining == MB_FLASH_PAGE) + lcdEnabled = mb_ram_progress_blit(progressLine); + } + + intAddr += MB_FLASH_PAGE; + extAddr += readSize; + remaining -= readSize; + regionRemaining -= MB_FLASH_PAGE; + } + + FLASH->CR |= FLASH_CR_LOCK; + mb_ram_reset(); + +fatal_reset: + /* Release the external flash and reset immediately. If failure happened + * after an erase, the factory USB/DFU bootloader remains the recovery path. */ + GPIOA->BSRR = MB_CS_PIN; + FLASH->CR &= ~(FLASH_CR_PER | FLASH_CR_PG | FLASH_CR_PGSTRT); + FLASH->CR |= FLASH_CR_LOCK; + mb_ram_reset(); +} + +/* -------------------------------------------------------------------------- */ +/* Public API. */ +/* -------------------------------------------------------------------------- */ + +void MB_BackupToSlot0(uint32_t *out_size, uint32_t *out_crc32) +{ + const uint32_t imgBase = MB_SLOT0_EXT_BASE + MB_SLOT_IMG_OFFSET; + const uint32_t size = MB_INT_APP_SIZE; /* full region (self-test) */ + + /* CRC-32 of the internal image (memory-mapped, contiguous read). */ + uint32_t crc = mb_crc32_update(0xFFFFFFFFu, (const uint8_t *)MB_INT_APP_BASE, size) + ^ 0xFFFFFFFFu; + + /* Write the image first ... */ + PY25Q16_WriteBuffer(imgBase, (const void *)MB_INT_APP_BASE, size, false); + + /* ... then a valid header (COMMITTED) last, so a committed header always + * implies a fully written image. */ + 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 = crc; +#ifdef ENABLE_FEAT_F4HWN + mb_copy_str(hdr.name, MB_NAME_LEN, Edition); +#else + mb_copy_str(hdr.name, MB_NAME_LEN, "slot0"); +#endif + mb_copy_str(hdr.fw_version, MB_VERSION_LEN, Version); + PY25Q16_WriteBuffer(MB_SLOT0_EXT_BASE, &hdr, sizeof(hdr), false); + + if (out_size) *out_size = size; + if (out_crc32) *out_crc32 = crc; +} + +/* Set when a polled SPI wait below times out (external flash unresponsive). */ +static volatile int mb_spi_err; + +/* Polled single-byte SPI2 transfer (flash-resident; runs in normal context). + * Bounded so a wedged SPI can never freeze the firmware: on timeout it sets + * mb_spi_err and returns 0xFF, letting the caller fail gracefully. */ +#define MB_SPI_TIMEOUT 20000u /* ~a few ms max per byte; a healthy transfer + * completes in well under a microsecond */ +static uint8_t mb_spi_byte(uint8_t v) +{ + uint32_t to = MB_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_TXE)) { if (!--to) { mb_spi_err = 1; return 0xFFu; } } + *(volatile uint8_t *)&SPI2->DR = v; + to = MB_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_RXNE)) { if (!--to) { mb_spi_err = 1; return 0xFFu; } } + return *(volatile uint8_t *)&SPI2->DR; +} + +/* + * Put SPI2 into clean polled mode before a manual read. The flash driver leaves + * SPI2 in "DMA mode": the RX/TX DMA requests stay on and the DMA channels stay + * armed (confirmed by the SPI-state diagnostic: RD.EN=1 WR.EN=1). A polled read + * then loses every received byte to the still-armed DMA, so RXNE never sets and + * the read hangs forever. Disabling the DMA requests + channels and draining the + * RX FIFO restores plain polled behaviour. The next driver operation re-arms DMA + * on its own, so this is safe. + */ +static void mb_spi_polled_mode(void) +{ + SPI2->CR2 &= ~(SPI_CR2_RXDMAEN | SPI_CR2_TXDMAEN); + DMA1_Channel4->CCR &= ~DMA_CCR_EN; + DMA1_Channel5->CCR &= ~DMA_CCR_EN; + /* Drain any pending RX. Bounded: the RX FIFO is only a few bytes deep, so a + * stuck/overrun RXNE (which would otherwise loop forever) can't hang here. */ + for (uint32_t guard = 64; (SPI2->SR & SPI_SR_RXNE) && guard; guard--) + (void)*(volatile uint8_t *)&SPI2->DR; +} + +/* + * CRC-32 of `len` bytes of external flash starting at `addr`, read in ONE + * continuous polled transfer (command 0x03, CS held low, auto-incrementing + * address). This avoids issuing hundreds of tiny back-to-back DMA reads through + * PY25Q16_ReadBuffer(), which is not reliable at that rate. + */ +#define MB_READ_CHUNK 2048u + +static uint32_t mb_ext_image_crc32(uint32_t addr, uint32_t len) +{ + uint32_t crc = 0xFFFFFFFFu; + + mb_spi_polled_mode(); + + /* Read in chunks. IRQs are masked during each chunk's continuous transfer + * (an interrupt mid-transfer desyncs the polled SPI - the same reason the + * RAM copier masks them), but re-enabled between chunks so the USB stack + * keeps being serviced and the reply can go out afterwards. */ + while (len && !mb_spi_err) + { + uint32_t chunk = (len < MB_READ_CHUNK) ? len : MB_READ_CHUNK; + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + + GPIOA->BRR = MB_CS_PIN; /* CS low */ + mb_spi_byte(0x03u); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + for (uint32_t i = 0; i < chunk && !mb_spi_err; i++) + { + crc ^= mb_spi_byte(0xFFu); + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + GPIOA->BSRR = MB_CS_PIN; /* CS high */ + + __set_PRIMASK(primask); /* let IRQs / USB breathe */ + addr += chunk; + len -= chunk; + } + + return crc ^ 0xFFFFFFFFu; +} + +/* Polled read of `len` bytes from external flash into `buf` (no DMA), matching + * the technique the RAM copier uses. Sets mb_spi_err on a stuck SPI. */ +static void mb_ext_read(uint32_t addr, uint8_t *buf, uint32_t len) +{ + mb_spi_polled_mode(); + + /* IRQs off during the transfer (see mb_ext_image_crc32); break on timeout. */ + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(0x03u); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + while (len-- && !mb_spi_err) + *buf++ = mb_spi_byte(0xFFu); + GPIOA->BSRR = MB_CS_PIN; + + __set_PRIMASK(primask); +} + +/* -------------------------------------------------------------------------- */ +/* External flash raw erase/program (polled, flash-resident). */ +/* */ +/* Used only by the M4 slot-management commands. These bypass the stateful */ +/* PY25Q16 driver (its sector cache would desync when we erase and program a */ +/* slot behind its back, and its per-chunk read-modify-write would erase a */ +/* sector on every small chunk). Each CS-framed transaction masks IRQs for */ +/* its own burst only - an interrupt mid-transfer desyncs the polled SPI, the */ +/* same reason mb_ext_image_crc32 masks them - and re-enables them between */ +/* transactions so USB keeps being serviced (notably across the long erase). */ +/* -------------------------------------------------------------------------- */ + +#define MB_EXT_CMD_WREN 0x06u /* write enable */ +#define MB_EXT_CMD_PP 0x02u /* page program (<=256 B) */ +#define MB_EXT_CMD_SE 0x20u /* 4 KiB sector erase */ +#define MB_EXT_CMD_RDSR 0x05u /* read status register 1 */ +#define MB_EXT_SECTOR 0x1000u /* PY25Q16 erase granularity */ +#define MB_EXT_PAGE 0x100u /* PY25Q16 program granularity */ +#define MB_EXT_WIP_TIMEOUT 5000000u /* status polls before giving up (~seconds) */ + +static void mb_ext_wren(void) +{ + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_WREN); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); +} + +/* Poll WIP until the erase/program finishes (or mb_spi_err / timeout). IRQs are + * masked only for each 2-byte status read, not the whole wait, so a ~300 ms + * erase does not starve USB. */ +static bool mb_ext_wait_wip(void) +{ + for (uint32_t i = 0; i < MB_EXT_WIP_TIMEOUT; i++) + { + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_RDSR); + uint8_t status = mb_spi_byte(0xFFu); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); + + if (mb_spi_err) + return false; + if (!(status & 1u)) /* WIP clear */ + return true; + } + return false; +} + +static bool mb_ext_sector_erase(uint32_t addr) +{ + mb_ext_wren(); + if (mb_spi_err) + return false; + + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_SE); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); + + if (mb_spi_err) + return false; + return mb_ext_wait_wip(); +} + +/* Program up to one 256-byte page; the caller must not cross a page boundary. */ +static bool mb_ext_page_program(uint32_t addr, const uint8_t *data, uint32_t len) +{ + mb_ext_wren(); + if (mb_spi_err) + return false; + + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_PP); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + for (uint32_t i = 0; i < len && !mb_spi_err; i++) + mb_spi_byte(data[i]); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); + + if (mb_spi_err) + return false; + return mb_ext_wait_wip(); +} + +/* Program an arbitrary range, split on 256-byte page boundaries (a page program + * that crosses a page boundary wraps within the page instead of advancing). */ +static bool mb_ext_program(uint32_t addr, const uint8_t *data, uint32_t len) +{ + while (len) + { + uint32_t pageRem = MB_EXT_PAGE - (addr & (MB_EXT_PAGE - 1u)); + uint32_t n = (len < pageRem) ? len : pageRem; + if (!mb_ext_page_program(addr, data, n)) + return false; + addr += n; + data += n; + len -= n; + } + return true; +} + +/* Read + validate a slot header only (no CRC recompute), via polled reads. */ +static uint8_t mb_read_header(uint8_t slot, mb_slot_header_t *hdr) +{ + if (slot >= MB_SLOT_COUNT) + return MB_ERR_SLOT; + + const uint32_t slotBase = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_ext_read(slotBase, (uint8_t *)hdr, sizeof(*hdr)); + if (mb_spi_err) return MB_ERR_SPI; + if (hdr->magic != MB_SLOT_MAGIC) return MB_ERR_MAGIC; + if (hdr->hdr_version > MB_HDR_VERSION) return MB_ERR_VERSION; + if (!(hdr->flags & MB_FLAG_COMMITTED)) return MB_ERR_NOT_COMMITTED; + if (hdr->image_size == 0 || hdr->image_size > MB_INT_APP_SIZE) return MB_ERR_SIZE; + return MB_OK; +} + +/* Validate one slot (header + image CRC-32), entirely via polled reads. + * Never touches the internal flash. */ +static uint8_t mb_validate(uint8_t slot, mb_slot_header_t *hdr, uint32_t *crcOut) +{ + uint8_t err = mb_read_header(slot, hdr); + if (err != MB_OK) + return err; + + const uint32_t slotBase = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + uint32_t crc = mb_ext_image_crc32(slotBase + MB_SLOT_IMG_OFFSET, hdr->image_size); + if (crcOut) + *crcOut = crc; + if (mb_spi_err) return MB_ERR_SPI; + if (crc != hdr->image_crc32) return MB_ERR_CRC; + return MB_OK; +} + +uint8_t MB_ValidateSlot(uint8_t slot, mb_slot_header_t *out_header, uint32_t *out_crc) +{ + mb_slot_header_t local; + return mb_validate(slot, out_header ? out_header : &local, out_crc); +} + +uint8_t MB_ValidateSlot0(uint32_t *out_crc) +{ + return MB_ValidateSlot(0, NULL, out_crc); +} + +uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line) +{ + mb_slot_header_t hdr; + uint8_t err = mb_validate(slot, &hdr, NULL); + if (err != MB_OK) + return err; + + const uint32_t slotBase = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + /* Valid: the RAM stub copies exactly image_size bytes, pads the partial + * page with 0xFF and erases the remainder of the application region. */ + MB_PrepareInternalFlash(); + + /* Call through a volatile pointer so the compiler emits an absolute 'blx' + * (the RAM copy sits far beyond a Cortex-M0+ 'bl' reach from flash). */ + void (*volatile ramReflash)(uint32_t, uint32_t, uint32_t, uint8_t *) = MB_RamReflash; + ramReflash(MB_INT_APP_BASE, slotBase + MB_SLOT_IMG_OFFSET, + hdr.image_size, progress_line); + + return MB_OK; /* not reached */ +} + +uint8_t MB_RestoreSlot0(void) +{ + return MB_RestoreSlot(0, NULL); +} + +/* -------------------------------------------------------------------------- */ +/* M4 slot management (host tool). External flash only - never brick-critical.*/ +/* -------------------------------------------------------------------------- */ + +uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header) +{ + mb_slot_header_t local; + return mb_read_header(slot, out_header ? out_header : &local); +} + +uint8_t MB_SlotErase(uint8_t slot) +{ + if (slot >= MB_SLOT_COUNT) + return MB_ERR_SLOT; + + const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + for (uint32_t off = 0; off < MB_SLOT_STRIDE; off += MB_EXT_SECTOR) + if (!mb_ext_sector_erase(base + off)) + return MB_ERR_SPI; + + return MB_OK; +} + +uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len) +{ + if (slot >= MB_SLOT_COUNT) + return MB_ERR_SLOT; + if (len == 0) + return MB_OK; + if (offset > MB_SLOT_STRIDE || len > MB_SLOT_STRIDE - offset) + return MB_ERR_SIZE; + + const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + if (!mb_ext_program(base + offset, data, len)) + return MB_ERR_SPI; + + return MB_OK; +} + +/* Middle of slot 0's image (32 KiB in, well within the 118 KiB image body). */ +#define MB_CORRUPT_OFFSET 0x8000u +#define MB_CORRUPT_MAX 128u + +void MB_CorruptSlot0(const uint8_t *data, uint32_t len) +{ + if (!data || len == 0) + return; + if (len > MB_CORRUPT_MAX) + len = MB_CORRUPT_MAX; + + /* Constrained to slot 0's image body: this address range cannot reach the + * slot header, nor calibration / EEPROM / RF log / voice regions. */ + PY25Q16_WriteBuffer(MB_SLOT0_EXT_BASE + MB_SLOT_IMG_OFFSET + MB_CORRUPT_OFFSET, + data, len, false); +} + +volatile uint8_t mb_mark_on = 0; + +void MB_Mark(const char *s) +{ + if (!mb_mark_on) + return; + memset(gFrameBuffer, 0, sizeof(gFrameBuffer)); + UI_PrintStringSmallNormal(s, 10, 0, 3); + ST7565_BlitFullScreen(); +} + +void MB_DumpExt(uint32_t addr, uint8_t *buf, uint32_t len) +{ + /* Trace the driver read step by step on the LCD so a freeze reveals where. */ + mb_mark_on = 1; + MB_Mark("DUMP begin"); + PY25Q16_ReadBufferSafe(addr, buf, len); + MB_Mark("DUMP done"); + mb_mark_on = 0; +} + +void MB_SpiState(uint32_t out[4]) +{ + out[0] = SPI2->CR1; /* bit 6 (SPE) = SPI enabled */ + out[1] = SPI2->CR2; /* bit0 RXDMAEN, bit1 TXDMAEN */ + out[2] = SPI2->SR; /* bit0 RXNE, bit1 TXE, bit7 BSY */ + out[3] = DMA1_Channel4->CCR; /* SPI2 RX DMA channel (bit0 EN) */ +} diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h new file mode 100644 index 00000000..90a7e5a1 --- /dev/null +++ b/App/driver/mb_flash.h @@ -0,0 +1,168 @@ +/* Copyright 2026 F4HWN + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Multiboot flash programmer. + * + * M1 (validated): brick-critical core - reprogram the internal application flash + * from an image held in the external SPI flash, running from RAM. + * + * M2 (this file): slot format + integrity validation. Each slot starts with a + * header (magic, size, CRC32, name, version); a restore validates the header and + * the image CRC32 *before* erasing anything, so an incompatible or corrupt image + * can never brick the radio. There is a single firmware for both the K1 and the + * K5v3 (the keypad difference is handled at runtime by the hidden SetNav menu), + * so no per-model guard is needed. + * + * See docs/multiboot-design.md for the overall design. + */ + +#ifndef DRIVER_MB_FLASH_H +#define DRIVER_MB_FLASH_H + +#include +#include + +/* Internal flash application region (see Core/py32f071xb.ld: + * FLASH origin 0x08002800, length 118 KiB). 0x08002800 is 256-byte aligned, so + * the whole region can be page-erased (256 B granularity) without touching the + * factory bootloader that lives just below it. */ +#define MB_INT_APP_BASE 0x08002800u +#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. */ +#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 + +/* Slot header (stored at the slot base, first 4 KiB sector). 64 bytes. */ +#define MB_SLOT_MAGIC 0x31424D46u /* "FMB1" */ +#define MB_HDR_VERSION 1u +#define MB_FLAG_COMMITTED (1u << 0) /* image written and verified */ +#define MB_NAME_LEN 16 +#define MB_VERSION_LEN 16 + +typedef struct __attribute__((packed)) { + uint32_t magic; /* MB_SLOT_MAGIC */ + uint16_t hdr_version; /* MB_HDR_VERSION */ + uint16_t flags; /* MB_FLAG_COMMITTED, ... */ + uint32_t image_size; /* bytes, <= MB_INT_APP_SIZE */ + uint32_t image_crc32; /* CRC-32 (zlib) over image_size B */ + char name[MB_NAME_LEN]; /* human-readable, NUL-terminated */ + char fw_version[MB_VERSION_LEN]; /* firmware version string */ + uint8_t reserved[16]; /* pad to 64 bytes, future use */ +} mb_slot_header_t; + +/* Restore validation result (MB_OK never returns - the radio resets). */ +enum { + MB_OK = 0, + MB_ERR_MAGIC, /* no/invalid slot header */ + MB_ERR_VERSION, /* header format too new */ + MB_ERR_NOT_COMMITTED,/* image not marked complete */ + MB_ERR_SIZE, /* image_size out of range */ + MB_ERR_CRC, /* image CRC32 mismatch */ + MB_ERR_SPI, /* external flash read/write timed out*/ + MB_ERR_SLOT, /* slot index out of range */ + MB_ERR_AUTH /* write refused: timestamp mismatch */ +}; + +/* + * Copy the live internal application image into external flash slot 0, writing + * the image first and then a valid header (COMMITTED) last. Runs entirely from + * flash and only writes the external SPI flash, so it is safe (no brick risk). + * Reports the stored image size and CRC-32 through the (optional) out params. + */ +void MB_BackupToSlot0(uint32_t *out_size, uint32_t *out_crc32); + +/* + * Validate slot 0 and, if valid, reflash the internal application from it and + * reset. Validation (magic, version, COMMITTED flag, size, image CRC-32) runs + * from flash *before* any erase: on failure it returns an MB_ERR_* code and the + * internal flash is left untouched. On success it NEVER RETURNS (the RAM-resident + * copier reflashes the internal application and triggers a system reset). The + * factory bootloader is never touched, so an interrupted copy is recoverable + * over USB/DFU. + */ +uint8_t MB_RestoreSlot0(void); + +/* Multi-slot API used by the boot selector. Validation always covers the full + * image CRC before restore. progress_line may point to a 128-byte LCD page; the + * RAM copier then fills it while reflashing. Pass NULL to disable LCD updates. */ +uint8_t MB_ValidateSlot(uint8_t slot, mb_slot_header_t *out_header, uint32_t *out_crc); +uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line); + +/* + * Host-tool slot management (M4, "Firmware Slots" in UV Studio). Everything is + * bounds-checked (slot < MB_SLOT_COUNT, offset+len <= MB_SLOT_STRIDE) and writes + * touch the EXTERNAL flash only, so none of this is brick-critical: a bad slot is + * simply refused at restore by the CRC validation above. + * + * - MB_SlotInfo reads the 64-byte header only (fast, no CRC recompute) and + * returns MB_OK / MB_ERR_* describing the header state. + * - MB_SlotErase erases the whole 128 KiB slot region (header + image). + * - MB_SlotWrite programs `len` bytes at slot_base+offset. The slot MUST have + * been erased first (NOR flash only clears 1->0 bits); the host + * writes the image, then the COMMITTED header last. + * Use MB_ValidateSlot afterwards to confirm the full image CRC. + */ +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); + + +/* + * Test helper: overwrite up to `len` (capped) bytes in the MIDDLE of slot 0's + * image with the supplied data, so the next MB_RestoreSlot0() fails its CRC-32 + * check and refuses. It writes ONLY inside slot 0's image body - never the + * header, and never anything outside the slot (calibration, EEPROM, RF log...). + * Developer aid to exercise the safe-refusal path without external tooling. + */ +void MB_CorruptSlot0(const uint8_t *data, uint32_t len); + +/* + * Validate slot 0 (header + image CRC-32) WITHOUT reflashing. Same checks as the + * restore path, entirely via polled reads (cannot hang). Returns MB_OK or an + * MB_ERR_* code, and reports the computed image CRC-32 through out_crc. + * Useful as a safe diagnostic from the host tool. + */ +uint8_t MB_ValidateSlot0(uint32_t *out_crc); + +/* + * Read `len` bytes of external flash at `addr` into `buf`, via the DMA driver + * (PY25Q16_ReadBuffer) - the same proven read path the backup uses internally. + * Ground-truth diagnostic to check what is actually stored in a slot. + */ +void MB_DumpExt(uint32_t addr, uint8_t *buf, uint32_t len); + +/* + * Diagnostic: snapshot the SPI2 / DMA hardware state WITHOUT any flash access + * (so it cannot hang). out[0]=SPI2->CR1, [1]=SPI2->SR, [2]=DMA RD chan CCR, + * [3]=DMA WR chan CCR. Reveals whether SPI2 is disabled/busy or a DMA channel + * is left armed when a read command runs. + */ +void MB_SpiState(uint32_t out[4]); + +/* + * On-screen trace marker (debug). Draws `s` on the LCD (SPI1, independent of the + * flash SPI2) so that when a flash read freezes the CPU, the frozen screen shows + * the last step reached. Only draws while mb_mark_on is set (i.e. during a Dump), + * so it does not flash the screen during normal writes. + */ +extern volatile uint8_t mb_mark_on; +void MB_Mark(const char *s); + +#endif /* DRIVER_MB_FLASH_H */ diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index 3d9990a1..2bc38221 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -27,6 +27,13 @@ #include "external/printf/printf.h" #include "misc.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" + #define MBMARK(s) MB_Mark(s) +#else + #define MBMARK(s) +#endif + // #define DEBUG #define SPIx SPI2 @@ -227,17 +234,21 @@ void PY25Q16_Init() void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) { + MBMARK("RD cmd"); // about to assert CS + send read command CS_Assert(); SPI_WriteByte(0x03); // Send read command + MBMARK("RD addr"); // command sent, about to send address WriteAddr(Address); // Send address (3 bytes) + MBMARK("RD flush"); // address sent, about to flush RX FIFO // CRITICAL: Flush RX FIFO before DMA to remove residual data while (LL_SPI_RX_FIFO_EMPTY != LL_SPI_GetRxFIFOLevel(SPIx)) { LL_SPI_ReceiveData8(SPIx); // Read and discard } + MBMARK("RD data"); // FIFO flushed, about to read the data if (Size >= 16) { SPI_ReadBuf((uint8_t *)pBuffer, Size); } else { @@ -247,9 +258,22 @@ void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) } } + MBMARK("RD end"); // data read, about to release CS CS_Release(); } +// 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 +// returns the expected data. +void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size) +{ + MBMARK("SAFE wip"); // about to WaitWIP() + WaitWIP(); + MBMARK("SAFE rb"); // WaitWIP done, about to ReadBuffer + PY25Q16_ReadBuffer(Address, pBuffer, Size); +} + void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append) { #ifdef DEBUG diff --git a/App/driver/py25q16.h b/App/driver/py25q16.h index fae064e0..4d4d696b 100644 --- a/App/driver/py25q16.h +++ b/App/driver/py25q16.h @@ -22,6 +22,7 @@ void PY25Q16_Init(); void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size); +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); diff --git a/App/helper/boot.c b/App/helper/boot.c index 838cfb13..97a70e87 100644 --- a/App/helper/boot.c +++ b/App/helper/boot.c @@ -29,20 +29,40 @@ #include "settings.h" #include "ui/menu.h" #include "ui/ui.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "ui/multiboot.h" +#endif BOOT_Mode_t BOOT_GetMode(void) { unsigned int i; KEY_Code_t Keys[2]; + bool PttPressed[2]; + /* Poll the keypad even without PTT: holding MENU alone enters multiboot. + * The two samples keep the same debounce rule as the legacy boot modes. */ for (i = 0; i < 2; i++) { - if (!GPIO_IsPttPressed()) - return BOOT_MODE_NORMAL; // PTT not pressed + PttPressed[i] = GPIO_IsPttPressed(); Keys[i] = KEYBOARD_Poll(); SYSTEM_DelayMs(20); } + #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + if (!PttPressed[0] && !PttPressed[1] && + Keys[0] == KEY_MENU && Keys[1] == KEY_MENU) + { + gKeyReading0 = Keys[0]; + gKeyReading1 = Keys[0]; + gDebounceCounter = 2; + return BOOT_MODE_MULTIBOOT; + } + #endif + + /* All historical special modes still require PTT for both samples. */ + if (!PttPressed[0] || !PttPressed[1]) + return BOOT_MODE_NORMAL; + #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS if (Keys[0] == (10 + gEeprom.SET_KEY)) { @@ -125,5 +145,10 @@ void BOOT_ProcessMode(BOOT_Mode_t Mode) } #endif + #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + else if (Mode == BOOT_MODE_MULTIBOOT) + UI_MultibootSelector(); + #endif + GUI_SelectNextDisplay(display); } diff --git a/App/helper/boot.h b/App/helper/boot.h index f36448fd..1c21fcb2 100644 --- a/App/helper/boot.h +++ b/App/helper/boot.h @@ -28,7 +28,10 @@ enum BOOT_Mode_t BOOT_MODE_RESCUE_OPS, #endif #ifdef ENABLE_AIRCOPY - BOOT_MODE_AIRCOPY + BOOT_MODE_AIRCOPY, + #endif + #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + BOOT_MODE_MULTIBOOT, #endif }; diff --git a/App/main.c b/App/main.c index fba5be1e..ff9d6d24 100644 --- a/App/main.c +++ b/App/main.c @@ -132,6 +132,16 @@ void Main(void) BOOT_Mode_t BootMode = BOOT_GetMode(); +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + /* Run before the welcome screen and the normal application UI. EXIT from + * the selector simply resumes this boot as if no special mode was held. */ + if (BootMode == BOOT_MODE_MULTIBOOT) + { + BOOT_ProcessMode(BootMode); + BootMode = BOOT_MODE_NORMAL; + } +#endif + #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS if (BootMode == BOOT_MODE_RESCUE_OPS) { diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c new file mode 100644 index 00000000..4de22c38 --- /dev/null +++ b/App/ui/multiboot.c @@ -0,0 +1,266 @@ +/* Copyright 2026 F4HWN + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "driver/backlight.h" +#include "driver/gpio.h" +#include "driver/keyboard.h" +#include "driver/mb_flash.h" +#include "driver/st7565.h" +#include "driver/system.h" +#include "ui/helper.h" +#include "ui/multiboot.h" + +static const char *mb_error_text(uint8_t err) +{ + switch (err) + { + case MB_OK: return "OK"; + case MB_ERR_MAGIC: return "empty"; + case MB_ERR_VERSION: return "new header"; + case MB_ERR_NOT_COMMITTED: return "incomplete"; + case MB_ERR_SIZE: return "bad size"; + case MB_ERR_CRC: return "CRC ERROR"; + case MB_ERR_SPI: return "SPI ERROR"; + case MB_ERR_SLOT: return "bad slot"; + case MB_ERR_AUTH: return "auth"; + default: return "error"; + } +} + +static void mb_copy_label(char *dst, uint8_t cap, const char *src, uint8_t src_cap) +{ + uint8_t n = 0; + while (n + 1u < cap && n < src_cap && src[n]) + { + dst[n] = src[n]; + n++; + } + dst[n] = 0; +} + +static void mb_format_slot_label(char *dst, uint8_t cap, const mb_slot_header_t *header) +{ + const char *version = NULL; + uint8_t version_cap = 0; + uint8_t version_len = 0; + uint8_t name_limit = cap - 1u; + uint8_t n = 0; + + if (!header->name[0]) + { + mb_copy_label(dst, cap, header->fw_version, MB_VERSION_LEN); + return; + } + + for (uint8_t i = 0; i + 1u < MB_VERSION_LEN && header->fw_version[i]; i++) + { + if (header->fw_version[i] == 'v' && + header->fw_version[i + 1u] >= '0' && + header->fw_version[i + 1u] <= '9') + { + version = &header->fw_version[i + 1u]; + version_cap = MB_VERSION_LEN - i - 1u; + break; + } + } + + if (version) + { + while (version_len < version_cap && version[version_len]) + version_len++; + if (version_len + 1u < cap) + name_limit = cap - version_len - 2u; + } + + while (n < name_limit && n < MB_NAME_LEN && header->name[n]) + { + dst[n] = header->name[n]; + n++; + } + if (version && n + version_len + 1u < cap) + { + dst[n++] = ' '; + for (uint8_t i = 0; i < version_len; i++) + dst[n++] = version[i]; + } + dst[n] = 0; +} + +static void mb_show_message(const char *line1, const char *line2, const char *line3) +{ + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal("MultiBoot", 2, 126, 0); + if (line1) UI_PrintStringSmallNormal(line1, 2, 126, 2); + if (line2) UI_PrintStringSmallNormal(line2, 2, 126, 4); + if (line3) UI_PrintStringSmallNormal(line3, 2, 126, 6); + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); +} + +static void mb_wait_release(void) +{ + uint8_t stable = 0; + while (stable < 10u) + { + if (!GPIO_IsPttPressed() && KEYBOARD_Poll() == KEY_INVALID) + stable++; + else + stable = 0; + SYSTEM_DelayMs(10); + } +} + +static KEY_Code_t mb_get_key(void) +{ + for (;;) + { + KEY_Code_t key = KEYBOARD_Poll(); + if (key != KEY_INVALID) + { + SYSTEM_DelayMs(30); + if (KEYBOARD_Poll() == key) + { + while (KEYBOARD_Poll() != KEY_INVALID) + SYSTEM_DelayMs(10); + return key; + } + } + SYSTEM_DelayMs(10); + } +} + +static void mb_scan_slots(mb_slot_header_t headers[MB_SLOT_COUNT], uint8_t status[MB_SLOT_COUNT]) +{ + mb_show_message("Scanning slots...", NULL, "Please wait"); + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + status[slot] = MB_ValidateSlot(slot, &headers[slot], NULL); +} + +static void mb_render_slots(uint8_t selected, + const mb_slot_header_t headers[MB_SLOT_COUNT], + const uint8_t status[MB_SLOT_COUNT]) +{ + char line[19]; /* 18 glyphs max: 18 * 7 px fits from x=2 to x=126. */ + + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal("MultiBoot", 2, 126, 0); + + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + { + memset(line, 0, sizeof(line)); + line[0] = (slot == selected) ? '>' : ' '; + line[1] = (char)('0' + slot); + line[2] = ' '; + + if (status[slot] == MB_OK) + mb_format_slot_label(&line[3], sizeof(line) - 3u, &headers[slot]); + else + mb_copy_label(&line[3], sizeof(line) - 3u, mb_error_text(status[slot]), 20u); + + UI_PrintStringSmallNormal(line, 2, 0, (uint8_t)(slot + 1u)); + } + + UI_PrintStringSmallNormal("MENU to select", 2, 126, 5); + UI_PrintStringSmallNormal("EXIT to go back", 2, 126, 6); + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); +} + +static void mb_prepare_progress(uint8_t slot) +{ + char title[] = "RESTORE SLOT 0"; + title[13] = (char)('0' + slot); + + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal(title, 2, 126, 0); + UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 2); + UI_PrintStringSmallNormal("Writing & Verify", 2, 126, 4); + + /* Same rounded outline and hatch pattern as the scan progress gauge. */ + gFrameBuffer[6][3] = 0x0Cu; + gFrameBuffer[6][4] = 0x12u; + gFrameBuffer[6][123] = 0x12u; + gFrameBuffer[6][124] = 0x0Cu; + for (uint8_t x = 5; x < 123u; x++) + gFrameBuffer[6][x] = 0x21u; + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); +} + +void UI_MultibootSelector(void) +{ + mb_slot_header_t headers[MB_SLOT_COUNT]; + uint8_t status[MB_SLOT_COUNT]; + uint8_t selected = 0; + + BACKLIGHT_TurnOn(); + mb_show_message("Release keys", NULL, NULL); + mb_wait_release(); + mb_scan_slots(headers, status); + + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + { + if (status[slot] == MB_OK) + { + selected = slot; + break; + } + } + + for (;;) + { + mb_render_slots(selected, headers, status); + KEY_Code_t key = mb_get_key(); + + if (key == KEY_EXIT) + { + /* MENU was latched by BOOT_GetMode(). Do not let that stale boot + * key reach the normal application after leaving the selector. */ + gKeyReading0 = KEY_INVALID; + gKeyReading1 = KEY_INVALID; + gDebounceCounter = 0; + return; + } + if (key == KEY_UP) + { + selected = (uint8_t)((selected + MB_SLOT_COUNT - 1u) % MB_SLOT_COUNT); + continue; + } + if (key == KEY_DOWN) + { + selected = (uint8_t)((selected + 1u) % MB_SLOT_COUNT); + continue; + } + if (key != KEY_MENU) + continue; + + if (status[selected] != MB_OK) + { + mb_show_message("SLOT NOT VALID", mb_error_text(status[selected]), "Press any key"); + (void)mb_get_key(); + continue; + } + + char confirm[] = "Restore slot 0?"; + confirm[13] = (char)('0' + selected); + mb_show_message(confirm, "MENU to confirm", "EXIT to cancel"); + key = mb_get_key(); + if (key != KEY_MENU) + continue; + + mb_prepare_progress(selected); + uint8_t err = MB_RestoreSlot(selected, gFrameBuffer[6]); + + /* Only reached when the final pre-erase validation refused the slot. */ + status[selected] = err; + mb_show_message("RESTORE REFUSED", mb_error_text(err), "Press any key"); + (void)mb_get_key(); + mb_scan_slots(headers, status); + } +} diff --git a/App/ui/multiboot.h b/App/ui/multiboot.h new file mode 100644 index 00000000..c222354c --- /dev/null +++ b/App/ui/multiboot.h @@ -0,0 +1,12 @@ +/* Copyright 2026 F4HWN + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef UI_MULTIBOOT_H +#define UI_MULTIBOOT_H + +/* 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); + +#endif diff --git a/CMakePresets.json b/CMakePresets.json index 4bc5baed..f8e8a7bc 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -81,17 +81,20 @@ "ENABLE_FEAT_F4HWN_LOGO": false, "ENABLE_FEAT_F4HWN_LOGO_SAV": false, "ENABLE_FEAT_F4HWN_MENU_CAT": false, + "ENABLE_FEAT_F4HWN_MULTIBOOT": false, "ENABLE_AGC_SHOW_DATA": false, "ENABLE_UART_RW_BK_REGS": false, "ENABLE_SWD": false, "VERSION_STRING_1": "v0.22", - "VERSION_STRING_2": "v5.8.1" + "VERSION_STRING_2": "v5.9.0" } }, { "name": "Custom", "inherits": "default", "cacheVariables": { + "ENABLE_FEAT_F4HWN_QRCODE": true, + "ENABLE_FEAT_F4HWN_MEM": true, "EDITION_STRING": "Custom", "TARGET": "f4hwn.custom" } diff --git a/Core/py32f071xb.ld b/Core/py32f071xb.ld index bedcc0af..62732d35 100644 --- a/Core/py32f071xb.ld +++ b/Core/py32f071xb.ld @@ -134,10 +134,14 @@ SECTIONS _sidata = LOADADDR(.data); /* Initialized data sections goes into RAM, load LMA copy after code */ - .data : + .data : { . = ALIGN(4); _sdata = .; /* create a global symbol at data start */ + + *(.RamFunc) /* functions that must execute from RAM (e.g. internal */ + *(.RamFunc*) /* flash reprogramming); copied to RAM with .data */ + *(.data) /* .data sections */ *(.data*) /* .data* sections */ diff --git a/tools/mb_test.html b/tools/mb_test.html new file mode 100644 index 00000000..f5d32790 --- /dev/null +++ b/tools/mb_test.html @@ -0,0 +1,333 @@ + + + + + +Multiboot M1 - bench test + + + +
+

Multiboot M1 — bench test

+
UV-K1 / UV-K5 V3 · F4HWN · commandes cachées 0x0710 / 0x0712 via Web Serial
+ +
+ Radio sacrifiable uniquement. Nécessite un firmware compilé avec + -DENABLE_FEAT_F4HWN_MULTIBOOT=ON. Le bootloader d'usine n'est jamais touché : + en cas d'échec du restore, re-flasher via USB/DFU dans UV Studio. + Ferme UV Studio / K5Viewer avant de te connecter (le port série ne peut être ouvert qu'une fois). +
+ +
+ Web Serial indisponible. Utilise Chrome, Edge, Brave ou Opera sur ordinateur. + Si tu as ouvert ce fichier en file:// et que ça ne marche pas, sers-le en local : + python -m http.server puis ouvre http://localhost:8000/tools/mb_test.html. +
+ +
+
+ + + + déconnecté +
+
+ +
+
    +
  1. Backup — copie l'app interne vers le slot 0 externe (sans danger). Attends l'ack.
  2. +
  3. Optionnel : dumpe le slot 0 (0x020000) dans UV Studio pour vérifier.
  4. +
  5. Restore + reset — reprogramme l'interne depuis le slot 0. Succès = la radio reboote sur le même firmware.
  6. +
+
+ + +
+
+ + + + +
+
+ Corrompre → le prochain Restore doit être refusé (CRC32). Valider → contrôle header + CRC32 sans rien reflasher, renvoie le verdict et le CRC calculé (ne peut pas se bloquer). +
+
+ +
+
+ Journal + +
+
+
+
+ + + +