From bae197c3bafa949fdf8b3955f309961015d08d7f Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:17:13 +0300 Subject: [PATCH 1/6] agent: CMD_NAND, SPI NAND study ops on the FMC100 page engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for OpenIPC/firmware#2285 (hi3516ev300 + W25N01GV) and #2519 (GK7205V5x0 + GD5F1GM7): the rootfs rots with uncorrectable ECC that Linux never reports, and the question is which writes leave pages with unusable parity. Answering it needs the controller driven below the OS, the way the Linux hifmc100/xmedia_fmc100 drivers drive it, one knob at a time. Agent (v5, CAP_NAND bit 8, FMC100 builds only): - CMD_NAND 0x0E with sub-ops INFO, FEATURE_GET/SET, READ_PAGES, PROGRAM_PAGE, ERASE_BLOCK and FMC_REG (any FMC register, read/write). - Page I/O in two modes: REG (PAGE_READ/READ_FROM_CACHE or PROGRAM_LOAD/EXECUTE through register ops, page + full OOB, no controller ECC — raw array with on-die ECC off) and DMA (the controller's own OP_CTRL page engine, ported from hifmc100_send_cmd_read/write, with an explicit FMC_CFG so ECC type is the caller's). Each read records ECC_ERR_NUM0_BUF0, the chip status and FMC_INT. - Pages DMA into a 1 MiB RAM buffer mapped uncached in startup.S (no cache maintenance needed); per-page records go to a second buffer. The host moves both with the existing CMD_READ/CMD_WRITE stream, so CMD_NAND only carries parameters. READ_PAGES without storing data is a full-speed ECC scan (1024 pages in under 2 s). - The NAND chip table now carries OOB size (64 W25N/MX35LF, 128 GD5F). - Fixes found on the way: flash_crc32() sent NOR read opcodes to a NAND, so CMD_CRC32 and `agent read` verify were wrong on every SPI NAND; the flash-write readback verified through the FLASH_MEM window, which NAND does not have and which wraps at 1 MiB on NOR; CMD_CRC32 over FMC/CRG registers used byte loads where CMD_READ uses word loads, so verifying a register dump always "mismatched". Host: defib.agent.nand (NandStudy, records, FMC_CFG composition, the OOB a kernel write sends) and `defib agent nand info|feature|fmc-reg| read-page|ecc-scan|dump|write-page|erase-block` (the last two are destructive, like every writing command). `dump` is raw by default and turns the chip's on-die ECC off for the duration. Verified on hardware: - hi3516ev300 + W25N01GV and GK7205V510 + GD5F1GM7: INFO reports 2048+64 and 2048+128; REG reads and DMA reads with ECC off return identical page + OOB bytes (same MD5), i.e. both are raw. - Both chips power up with on-die ECC enabled (B0 = 0x18 / 0x10). - First result on the V510 running its vendor firmware: an ECC scan of the vendor UBI root with its 24-bit ECC finds four uncorrectable pages, all 0x0000FF00 (step 1 only — the #2519 signature), on pages 3-4 of PEBs 51 and 52; with on-die ECC on, 45 pages need correction, with it off 25 — the chip's own ECC flips bits when fed foreign parity. Host tests use a simulated agent that answers CMD_NAND frames as handle_nand() does. No C-level simulator of the FMC page engine: it would only restate the port, and the hardware runs above cover it. --- CLAUDE.md | 12 +- agent/Makefile | 2 + agent/main.c | 196 +++++++++++++++++++- agent/nand_layout.h | 28 +++ agent/protocol.h | 17 ++ agent/spi_flash.c | 194 ++++++++++++++++++-- agent/spi_flash.h | 58 +++++- agent/startup.S | 25 +++ src/defib/agent/client.py | 1 + src/defib/agent/nand.py | 323 +++++++++++++++++++++++++++++++++ src/defib/agent/protocol.py | 4 + src/defib/cli/app.py | 4 + src/defib/cli/nand.py | 353 ++++++++++++++++++++++++++++++++++++ tests/test_agent_nand.py | 340 ++++++++++++++++++++++++++++++++++ 14 files changed, 1529 insertions(+), 28 deletions(-) create mode 100644 agent/nand_layout.h create mode 100644 src/defib/agent/nand.py create mode 100644 src/defib/cli/nand.py create mode 100644 tests/test_agent_nand.py diff --git a/CLAUDE.md b/CLAUDE.md index a45ec70..eb97903 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,10 +118,12 @@ the loader blobs are vendor rkbin files the user supplies, not bundled. Bare-metal ARM32 C loaded onto the camera after SPL boot. COBS-framed binary protocol with CRC-32, 1024-byte max payload. It comes up at **115200** (the rate the boot ROM left the UART at) and is switched to 921600 by `CMD_SET_BAUD` after -the handshake, reverting to 115200 after ~30 s idle. 13 commands: `INFO 0x01`, +the handshake, reverting to 115200 after ~30 s idle. 14 commands: `INFO 0x01`, `READ 0x02`, `WRITE 0x03`, `ERASE 0x04`, `CRC32 0x05`, `REBOOT 0x06`, `SELFUPDATE 0x07`, `SET_BAUD 0x08`, `SCAN 0x09`, `FLASH_PROGRAM 0x0A`, -`FLASH_STREAM 0x0B`, `MARK_BAD 0x0C`, `MEMBW 0x0D`. `agent/protocol.h` and +`FLASH_STREAM 0x0B`, `MARK_BAD 0x0C`, `MEMBW 0x0D`, `NAND 0x0E` (SPI NAND study +ops on FMC100 builds: raw/ECC page I/O, OOB, feature registers; pages move +through agent RAM buffers, see `agent/nand_layout.h` and `defib.agent.nand`). `agent/protocol.h` and `src/defib/agent/protocol.py` must stay in lockstep; the client negotiates optional features through a capability bitmask (`client.py`). @@ -144,7 +146,7 @@ Backends: `spi_flash.c` (fmc100), `spi_flash_hisfc350.c` (V1-era parts), orchestrated by `src/defib/install/`. Commands: `burn`, `install`, `restore`, `dump-flash`, `detect`, `capture`, `replay`, `network`, `ports`, `list-chips`, `list-interfaces`, `tui`, plus the `agent` sub-app (`upload`, `flash`, `info`, - `read`, `write`, `scan`, `membw`). + `read`, `write`, `scan`, `membw`, and `nand` — SPI NAND study ops in `cli/nand.py`). - **Vendor U-Boot bootstrap** (`src/defib/vendors/`) — migration from an already-running stock/vendor U-Boot into an OpenIPC U-Boot shell. This is a later recovery stage than `BootProtocol`: targets use exact `soc:variant` @@ -197,8 +199,8 @@ speculatively, to see an error message, or to "check whether the board responds" | Safety | Commands | |---|---| | Host-only, never touches a device | `list-chips`, `ports`, `list-interfaces`, `replay` | -| Talks to the device; RAM-only or read-only | `detect`, `capture`, `dump-flash`, `burn`, `agent upload\|info\|read\|scan\|membw`, `network` (serves a file; the *device* decides to write) | -| **Irreversibly erases and writes flash** | `install`, `restore`, `agent flash`, `agent write`, TUI Flash Doctor write paths | +| Talks to the device; RAM-only or read-only | `detect`, `capture`, `dump-flash`, `burn`, `agent upload\|info\|read\|scan\|membw`, `agent nand info\|read-page\|ecc-scan\|dump\|feature\|fmc-reg` (the last two can change volatile chip/controller state, never the array), `network` (serves a file; the *device* decides to write) | +| **Irreversibly erases and writes flash** | `install`, `restore`, `agent flash`, `agent write`, `agent nand write-page\|erase-block`, TUI Flash Doctor write paths | `burn` only uploads into RAM, but it still power-cycles the board and can leave it parked in download mode — recoverable with another power cycle. diff --git a/agent/Makefile b/agent/Makefile index b6f9132..5e9a915 100644 --- a/agent/Makefile +++ b/agent/Makefile @@ -245,6 +245,8 @@ LIBGCC := $(shell $(CC) -mcpu=$(CPU_TYPE) -print-libgcc-file-name) SPI_DRIVER ?= fmc100 ifeq ($(SPI_DRIVER),fmc100) SPI_FLASH_SRC = spi_flash.c + # CMD_NAND (SPI NAND study ops) drives the FMC100 page engine. + CFLAGS += -DHAVE_NAND_STUDY else SPI_FLASH_SRC = spi_flash_$(SPI_DRIVER).c endif diff --git a/agent/main.c b/agent/main.c index 5db35fe..e1bc4e7 100644 --- a/agent/main.c +++ b/agent/main.c @@ -8,6 +8,7 @@ #include "emmc_himci.h" #include "protocol.h" #include "spi_flash.h" +#include "nand_layout.h" static flash_info_t flash_info; @@ -145,8 +146,9 @@ static int addr_readable(uint32_t addr, uint32_t size) { * SoCs where it isn't 0x14000000 — e.g. hi3520dv200 has it at * 0x58000000). * v4 added: CMD_MEMBW for bare-metal DDR bandwidth measurement - * (ARMv7 only; ACK_FLASH_ERROR on ARMv5). */ -#define AGENT_VERSION 4 + * (ARMv7 only; ACK_FLASH_ERROR on ARMv5). + * v5 added: CMD_NAND, SPI NAND study ops on FMC100 (CAP_NAND). */ +#define AGENT_VERSION 5 /* Capability flags — advertise supported features */ #define CAP_FLASH_STREAM (1 << 0) /* CMD_FLASH_STREAM with double-buffer */ @@ -162,9 +164,15 @@ static int addr_readable(uint32_t addr, uint32_t size) { #define CAP_MEMBW 0 #endif +#ifdef HAVE_NAND_STUDY +#define CAP_NAND (1 << 8) /* CMD_NAND */ +#else +#define CAP_NAND 0 +#endif + #define AGENT_CAPS (CAP_FLASH_STREAM | CAP_SECTOR_BITMAP | CAP_PAGE_SKIP | \ CAP_SET_BAUD | CAP_REBOOT | CAP_SELFUPDATE | CAP_SCAN | \ - CAP_MEMBW) + CAP_MEMBW | CAP_NAND) static void handle_info(void) { uint8_t resp[28]; @@ -292,6 +300,16 @@ static void handle_crc32_cmd(const uint8_t *data, uint32_t len) { if (flash_readable && addr >= FLASH_MEM && (addr + size) <= (FLASH_MEM + flash_info.size)) { c = flash_crc32(addr - FLASH_MEM, size); + } else if (addr >= 0x10000000 && addr < 0x13000000) { + /* Peripheral registers: 32-bit reads only, as CMD_READ does them, + * or the CRC covers different bytes than a read returns. */ + c = 0; + for (uint32_t off = 0; off < size; off += 4) { + uint32_t val = *(volatile uint32_t *)((addr + off) & ~3u); + uint8_t b[4] = { val & 0xff, (val >> 8) & 0xff, + (val >> 16) & 0xff, val >> 24 }; + c = crc32(c, b, size - off < 4 ? size - off : 4); + } } else { const uint8_t *ptr = (const uint8_t *)addr; c = crc32(0, ptr, size); @@ -679,9 +697,10 @@ static void handle_flash_write(const uint8_t *data, uint32_t len) { offset += chunk; } - /* Verify written data by reading back from flash and comparing CRC */ - const uint8_t *flash_ptr = (const uint8_t *)(FLASH_MEM + flash_addr); - uint32_t verify_crc = crc32(0, flash_ptr, size); + /* Verify written data by reading it back through the controller. + * Not through the FLASH_MEM window: NAND has none, and on NOR it wraps + * at 1 MiB on some SoCs. */ + uint32_t verify_crc = flash_crc32(flash_addr, size); if (verify_crc != expected_crc) { uint8_t err[9]; err[0] = ACK_CRC_ERROR; @@ -1172,6 +1191,166 @@ static void handle_mark_bad(const uint8_t *data, uint32_t len) { proto_send_ack(rc == 0 ? ACK_OK : ACK_FLASH_ERROR); } +#ifdef HAVE_NAND_STUDY +/* + * CMD_NAND: SPI NAND study ops — page I/O the way the Linux FMC100 drivers + * do it, or deliberately not, for chasing ECC faults below the OS. + * Host sends: CMD_NAND [subop:1] [args...] + * Agent answers: RSP_NAND [subop:1] [status:1] [payload...] + * + * Bulk data does not travel in RSP_NAND. Pages live in NAND_DMA_BUF and + * per-page results in NAND_STAT_BUF; the host moves them with CMD_READ / + * CMD_WRITE at the addresses NAND_OP_INFO reports. + * + * Transfer spec (8 bytes, "xfer" below): + * [fmc_cfg:4LE] [mode:1] [opcode:1] [iftype:1] [dummy:1] see nand_xfer_t + * Per-page record (8 bytes, "rec"): + * [ecc_err:4LE] [ondie:1] [fmc_int:1] [flags:1] [0] see nand_rec_t + * + * INFO -> [page:2][oob:2][pages_per_block:2][blocks:2] + * [dma_buf:4][dma_size:4][stat_buf:4][stat_size:4] + * [fmc_cfg:4] + * FEATURE_GET [addr:1] -> [value:1] + * FEATURE_SET [addr:1][value:1] -> [read back:1] + * READ_PAGES [start:4][count:4][xfer:8][store:1] -> [done:4] + * rec i at NAND_STAT_BUF + 8*i; with store=1, page i at + * NAND_DMA_BUF + i*(page+oob), else data is discarded + * (an ECC scan). Stops at the first timeout. + * PROGRAM_PAGE [page:4][xfer:8][src_off:4] -> rec + * page + OOB from NAND_DMA_BUF + src_off, OOB as given. + * ERASE_BLOCK [page:4] -> rec + * FMC_REG [write:1][off:2][value:4] -> [value:4] + * any 32-bit FMC register, offset < 0x1000, read after + * the optional write. + */ +static void nand_reply(uint8_t subop, uint8_t status, + const uint8_t *payload, uint32_t n) { + uint8_t buf[40]; + buf[0] = subop; + buf[1] = status; + for (uint32_t i = 0; i < n && i < sizeof(buf) - 2; i++) + buf[2 + i] = payload[i]; + proto_send(RSP_NAND, buf, 2 + n); +} + +static void nand_parse_xfer(const uint8_t *p, nand_xfer_t *x) { + x->fmc_cfg = read_le32(&p[0]); + x->mode = p[4]; + x->opcode = p[5]; + x->iftype = p[6]; + x->dummy = p[7]; +} + +static void nand_put_rec(uint8_t *p, const nand_rec_t *r) { + write_le32(&p[0], r->ecc_err); + p[4] = r->ondie; + p[5] = r->fmc_int; + p[6] = r->flags; + p[7] = r->rsvd; +} + +static void handle_nand(const uint8_t *data, uint32_t len) { + if (len < 1) { proto_send_ack(ACK_CRC_ERROR); return; } + uint8_t op = data[0]; + const uint8_t *arg = &data[1]; + uint32_t alen = len - 1; + + nand_geom_t g; + nand_get_geometry(&g); + uint32_t stride = (uint32_t)g.page_size + g.oob_size; + uint32_t pages = (uint32_t)g.pages_per_block * g.blocks; + uint8_t out[36]; + nand_rec_t rec; + nand_xfer_t x; + + if (op == NAND_OP_FMC_REG) { + if (alen < 7) { nand_reply(op, NAND_ST_BADARG, 0, 0); return; } + uint32_t off = arg[1] | ((uint32_t)arg[2] << 8); + if (off >= 0x1000 || (off & 3)) { nand_reply(op, NAND_ST_BADARG, 0, 0); return; } + if (arg[0]) fmc_reg(off) = read_le32(&arg[3]); + write_le32(out, fmc_reg(off)); + nand_reply(op, NAND_ST_OK, out, 4); + return; + } + if (op == NAND_OP_INFO) { + out[0] = g.page_size & 0xff; out[1] = g.page_size >> 8; + out[2] = g.oob_size & 0xff; out[3] = g.oob_size >> 8; + out[4] = g.pages_per_block & 0xff; out[5] = g.pages_per_block >> 8; + out[6] = g.blocks & 0xff; out[7] = g.blocks >> 8; + write_le32(&out[8], NAND_DMA_BUF); + write_le32(&out[12], NAND_DMA_BUF_SIZE); + write_le32(&out[16], NAND_STAT_BUF); + write_le32(&out[20], NAND_STAT_BUF_SIZE); + write_le32(&out[24], fmc_reg(0x00)); + nand_reply(op, g.page_size ? NAND_ST_OK : NAND_ST_NOT_NAND, out, 28); + return; + } + if (!g.page_size) { nand_reply(op, NAND_ST_NOT_NAND, 0, 0); return; } + + switch (op) { + case NAND_OP_FEATURE_GET: + if (alen < 1) break; + out[0] = nand_feature_get(arg[0]); + nand_reply(op, NAND_ST_OK, out, 1); + return; + case NAND_OP_FEATURE_SET: + if (alen < 2) break; + nand_feature_set(arg[0], arg[1]); + out[0] = nand_feature_get(arg[0]); + nand_reply(op, NAND_ST_OK, out, 1); + return; + case NAND_OP_READ_PAGES: { + if (alen < 17) break; + uint32_t start = read_le32(&arg[0]); + uint32_t count = read_le32(&arg[4]); + uint8_t store = arg[16]; + nand_parse_xfer(&arg[8], &x); + if (count == 0 || start >= pages || count > pages - start + || count > NAND_STAT_BUF_SIZE / 8 + || (store && count > NAND_DMA_BUF_SIZE / stride)) + break; + uint8_t *recs = (uint8_t *)NAND_STAT_BUF; + uint32_t done = 0; + while (done < count) { + uint8_t *dst = (uint8_t *)(NAND_DMA_BUF + (store ? done * stride : 0)); + int rc = nand_page_read(start + done, &x, dst, &rec); + nand_put_rec(&recs[done * 8], &rec); + done++; + proto_drain_fifo(); + if (rc) break; + } + write_le32(out, done); + nand_reply(op, done == count ? NAND_ST_OK : NAND_ST_IO, out, 4); + return; + } + case NAND_OP_PROGRAM_PAGE: { + if (alen < 16) break; + uint32_t page = read_le32(&arg[0]); + uint32_t src_off = read_le32(&arg[12]); + nand_parse_xfer(&arg[4], &x); + if (page >= pages || src_off > NAND_DMA_BUF_SIZE - stride) break; + int rc = nand_page_program(page, &x, + (const uint8_t *)(NAND_DMA_BUF + src_off), &rec); + nand_put_rec(out, &rec); + nand_reply(op, rc ? NAND_ST_IO : NAND_ST_OK, out, 8); + return; + } + case NAND_OP_ERASE_BLOCK: { + if (alen < 4) break; + uint32_t page = read_le32(&arg[0]); + if (page >= pages) break; + int rc = nand_block_erase(page, &rec); + nand_put_rec(out, &rec); + nand_reply(op, rc ? NAND_ST_IO : NAND_ST_OK, out, 8); + return; + } + default: + break; + } + nand_reply(op, NAND_ST_BADARG, 0, 0); +} +#endif /* HAVE_NAND_STUDY */ + static void handle_scan(const uint8_t *data __attribute__((unused)), uint32_t len __attribute__((unused))) { if (!flash_readable) { @@ -1423,6 +1602,11 @@ int main(void) { case CMD_MEMBW: handle_membw(cmd_buf, data_len); break; +#ifdef HAVE_NAND_STUDY + case CMD_NAND: + handle_nand(cmd_buf, data_len); + break; +#endif case CMD_SET_BAUD: handle_set_baud(cmd_buf, data_len); break; diff --git a/agent/nand_layout.h b/agent/nand_layout.h new file mode 100644 index 0000000..1d31904 --- /dev/null +++ b/agent/nand_layout.h @@ -0,0 +1,28 @@ +/* + * RAM layout of the SPI NAND study buffers (CMD_NAND). Plain #defines so + * startup.S can include it: the DMA buffer's 1 MiB section is mapped + * uncached there, which keeps the FMC's DMA and the CPU coherent without + * any cache maintenance. + * + * NAND_DMA_BUF page data + OOB, written/read by FMC DMA; consecutive + * pages are packed at (page_size + oob_size) stride + * NAND_STAT_BUF one nand_rec_t per page of the last CMD_NAND read + * + * The DMA buffer ends 1 MiB below RAM_BASE + 16 MiB, the lowest agent + * LOAD_ADDR, so the 16 KiB stack growing down from LOAD_ADDR never reaches + * it. The status buffer is past the agent and its bss; only a CMD_MEMBW + * run with a large scratch size can overlap it. + */ + +#ifndef NAND_LAYOUT_H +#define NAND_LAYOUT_H + +#define NAND_DMA_BUF_OFF 0x00E00000 /* must be 1 MiB aligned */ +#define NAND_DMA_BUF_SIZE 0x00100000 +#define NAND_STAT_BUF_OFF 0x02000000 +#define NAND_STAT_BUF_SIZE 0x00080000 /* 64 Ki pages x 8 B */ + +#define NAND_DMA_BUF (RAM_BASE + NAND_DMA_BUF_OFF) +#define NAND_STAT_BUF (RAM_BASE + NAND_STAT_BUF_OFF) + +#endif /* NAND_LAYOUT_H */ diff --git a/agent/protocol.h b/agent/protocol.h index c90fed6..696ab70 100644 --- a/agent/protocol.h +++ b/agent/protocol.h @@ -21,6 +21,7 @@ #define CMD_FLASH_STREAM 0x0B #define CMD_MARK_BAD 0x0C /* NAND only: write 0x00 to OOB[0] of page 0 of a block */ #define CMD_MEMBW 0x0D /* DDR bandwidth test (ARMv7 only): see handle_membw */ +#define CMD_NAND 0x0E /* SPI NAND study ops (FMC100 only): see handle_nand */ /* Responses (device → host) */ #define RSP_INFO 0x81 @@ -30,6 +31,22 @@ #define RSP_READY 0x85 #define RSP_SCAN 0x86 #define RSP_MEMBW 0x87 +#define RSP_NAND 0x88 + +/* CMD_NAND sub-operations (first payload byte) */ +#define NAND_OP_INFO 0x00 +#define NAND_OP_FEATURE_GET 0x01 +#define NAND_OP_FEATURE_SET 0x02 +#define NAND_OP_READ_PAGES 0x03 +#define NAND_OP_PROGRAM_PAGE 0x04 +#define NAND_OP_ERASE_BLOCK 0x05 +#define NAND_OP_FMC_REG 0x06 + +/* RSP_NAND status (second payload byte) */ +#define NAND_ST_OK 0x00 +#define NAND_ST_BADARG 0x01 /* malformed or out-of-range request */ +#define NAND_ST_NOT_NAND 0x02 /* no SPI NAND identified */ +#define NAND_ST_IO 0x03 /* the op ran but the hardware reported failure */ /* ACK status codes */ #define ACK_OK 0x00 diff --git a/agent/spi_flash.c b/agent/spi_flash.c index 16d8b23..e14dbcb 100644 --- a/agent/spi_flash.c +++ b/agent/spi_flash.c @@ -29,6 +29,7 @@ #define FMC_OP_CTRL 0x68 #define FMC_STATUS 0xAC #define FMC_VERSION 0xBC +#define FMC_ECC_ERR_NUM0_BUF0 0xC0 /* FMC_CFG bits */ #define FMC_CFG_OP_MODE_NORMAL (1 << 0) @@ -431,21 +432,28 @@ static void nand_write_enable(void) { * An unrecognised NAND falls through to the NOR path, which is not just * wrong but can hang: flash_global_unlock() polls a NOR status register a * NAND does not implement (GD5F1GM7 never clears "WIP"). */ -static const uint8_t nand_ids[][2] = { - { 0xC2, 0x12 }, /* Macronix MX35LF1GE4AB */ - { 0xEF, 0xAA }, /* Winbond W25N01GV (EF AA 21) */ - { 0xC8, 0x91 }, /* GigaDevice GD5F1GM7UE, 3.3 V */ - { 0xC8, 0x81 }, /* GigaDevice GD5F1GM7RE, 1.8 V */ +static const struct { + uint8_t id[2]; + uint16_t oob_size; +} nand_ids[] = { + { { 0xC2, 0x12 }, 64 }, /* Macronix MX35LF1GE4AB */ + { { 0xEF, 0xAA }, 64 }, /* Winbond W25N01GV (EF AA 21) */ + { { 0xC8, 0x91 }, 128 }, /* GigaDevice GD5F1GM7UE, 3.3 V */ + { { 0xC8, 0x81 }, 128 }, /* GigaDevice GD5F1GM7RE, 1.8 V */ }; -/* Returns 1 if id[] is a known SPI NAND, read either directly or shifted - * by one dummy byte (id[0] = dummy). */ +/* Geometry of the identified SPI NAND; page_size 0 until one is found. */ +static nand_geom_t nand_geom; + +/* Returns the nand_ids[] index if id[] is a known SPI NAND, read either + * directly or shifted by one dummy byte (id[0] = dummy), else -1. */ static int nand_identify(const uint8_t id[3]) { for (unsigned i = 0; i < sizeof(nand_ids) / sizeof(nand_ids[0]); i++) { - if (id[0] == nand_ids[i][0] && id[1] == nand_ids[i][1]) return 1; - if (id[1] == nand_ids[i][0] && id[2] == nand_ids[i][1]) return 1; + const uint8_t *want = nand_ids[i].id; + if (id[0] == want[0] && id[1] == want[1]) return (int)i; + if (id[1] == want[0] && id[2] == want[1]) return (int)i; } - return 0; + return -1; } int flash_init(flash_info_t *info) { @@ -468,7 +476,8 @@ int flash_init(flash_info_t *info) { fmc_enter_normal(); flash_read_id(info->jedec_id); - if (nand_identify(info->jedec_id)) { + int nand_idx = nand_identify(info->jedec_id); + if (nand_idx >= 0) { /* SPI NAND path. No flash_unlock / fmc_enter_boot — NAND has no * memory-mapped boot mode and uses different protection (BP bits * via SET_FEATURE 0xA0 instead of write-status-register). */ @@ -477,6 +486,10 @@ int flash_init(flash_info_t *info) { info->sector_size = NAND_BLOCK_SIZE; /* 128 KiB erase block */ info->page_size = NAND_PAGE_SIZE; /* 2 KiB read/program page */ current_flash_type = FLASH_TYPE_NAND; + nand_geom.page_size = NAND_PAGE_SIZE; + nand_geom.oob_size = nand_ids[nand_idx].oob_size; + nand_geom.pages_per_block = NAND_BLOCK_SIZE / NAND_PAGE_SIZE; + nand_geom.blocks = info->size / NAND_BLOCK_SIZE; /* Clear block-protection bits (BP0..BP3 + BRWD) in feature 0xA0 so * subsequent erase/program commands aren't rejected. Most SPI @@ -588,8 +601,8 @@ static int nand_program_page(uint32_t row, uint32_t column, * byte 0 of its I/O buffer (always 0x00 since the chip drives the dummy * line low), so real chip data starts at iobuf[1]. We compensate by * requesting `chunk + 1` bytes per fetch and copying iobuf[1..chunk]. */ -static void nand_read(uint32_t row, uint32_t column, - uint8_t *buf, uint32_t len) { +static uint8_t nand_read(uint32_t row, uint32_t column, + uint8_t *buf, uint32_t len) { /* 1) PAGE_READ: load page from array into chip cache. */ fmc_reg(FMC_INT_CLR) = 0xFF; fmc_reg(FMC_CMD) = SPI_CMD_NAND_PAGE_READ; @@ -598,8 +611,9 @@ static void nand_read(uint32_t row, uint32_t column, fmc_reg(FMC_OP) = FMC_OP_CMD1_EN | FMC_OP_ADDR_EN | FMC_OP_REG_OP_START; fmc_wait_ready(); - /* 2) Wait for OIP=0 — chip finishes ECC correction and signals ready. */ - nand_wait_oip(); + /* 2) Wait for OIP=0 — chip finishes ECC correction and signals ready. + * The status carries the on-die ECC verdict (ECC_S, bits 5:4). */ + uint8_t status = nand_wait_oip(); /* 3) READ_FROM_CACHE: pull data from cache via column addressing. * FMC stores the post-address dummy byte as iobuf[0] (always 0x00), @@ -623,6 +637,7 @@ static void nand_read(uint32_t row, uint32_t column, buf[off + i] = iobuf[i + 1]; /* skip iobuf[0] = dummy */ off += chunk; } + return status; } void flash_read(uint32_t addr, uint8_t *buf, uint32_t len) { @@ -834,6 +849,24 @@ int flash_read_oob(uint32_t block, uint8_t *buf, uint32_t len) { } uint32_t flash_crc32(uint32_t addr, uint32_t len) { + if (current_flash_type == FLASH_TYPE_NAND) { + /* Page by page through the same path CMD_READ uses; the NOR + * register read below would send NOR opcodes to a NAND. */ + uint8_t page[NAND_PAGE_SIZE]; + uint32_t c = 0; + while (len > 0) { + uint32_t col = addr % NAND_PAGE_SIZE; + uint32_t chunk = NAND_PAGE_SIZE - col; + if (chunk > len) chunk = len; + flash_read(addr, page, chunk); + c = crc32(c, page, chunk); + addr += chunk; + len -= chunk; + proto_drain_fifo(); + } + return c; + } + /* Use register-based reads to compute CRC32 over flash region. * Boot mode memory window wraps at 1MB on some SoCs. */ fmc_enter_normal(); @@ -862,3 +895,134 @@ uint32_t flash_crc32(uint32_t addr, uint32_t len) { fmc_enter_boot(); return c; } + +/* ---- SPI NAND study interface ----------------------------------------- */ + +/* FMC_OP_CFG / FMC_ADDR encodings for the controller's page engine, from + * the Linux hifmc100 driver (hifmc100.c, hifmc100.h, hisi_fmc.h). */ +#define FMC_INT_OP_DONE_BIT (1u << 0) +#define DMA_ADDR_BLOCK_SHIFT 22 /* REG_CNT_BLOCK_NUM_SHIFT */ +#define DMA_ADDR_BLOCK_MASK 0x3ffu +#define DMA_ADDR_BLOCK_H_SHIFT 10 /* REG_CNT_HIGH_BLOCK_NUM_SHIFT */ +#define DMA_ADDR_PAGE_SHIFT 16 /* REG_CNT_PAGE_NUM_SHIFT */ +#define DMA_ADDR_PAGE_MASK 0x3fu + +void nand_get_geometry(nand_geom_t *geom) { + /* Field by field: a struct copy becomes a memcpy() call, and the + * agent links without libc. */ + geom->page_size = nand_geom.page_size; + geom->oob_size = nand_geom.oob_size; + geom->pages_per_block = nand_geom.pages_per_block; + geom->blocks = nand_geom.blocks; +} + +uint8_t nand_feature_get(uint8_t addr) { + return nand_get_feature(addr); +} + +void nand_feature_set(uint8_t addr, uint8_t val) { + nand_set_feature(addr, val); +} + +static int fmc_wait_dma_done(void) { + for (uint32_t i = 0; i < 4000000; i++) { + if (fmc_reg(FMC_INT) & FMC_INT_OP_DONE_BIT) return 0; + } + return -1; +} + +/* Block/page address as the page engine wants it (64-page blocks). */ +static void nand_dma_set_addr(uint32_t page) { + uint32_t block = page / nand_geom.pages_per_block; + uint32_t in_block = page % nand_geom.pages_per_block; + fmc_reg(FMC_ADDRH) = (block >> DMA_ADDR_BLOCK_H_SHIFT) & 0xff; + fmc_reg(FMC_ADDRL) = ((block & DMA_ADDR_BLOCK_MASK) << DMA_ADDR_BLOCK_SHIFT) + | ((in_block & DMA_ADDR_PAGE_MASK) << DMA_ADDR_PAGE_SHIFT); +} + +static void nand_rec_finish(nand_rec_t *rec, uint8_t status, int timed_out) { + rec->ondie = status; + rec->fmc_int = (uint8_t)fmc_reg(FMC_INT); + rec->flags = 0; + if (timed_out || status == 0xFF) rec->flags |= NAND_REC_TIMEOUT; + rec->rsvd = 0; +} + +/* Read page + full OOB into dst (page_size + oob_size bytes). */ +int nand_page_read(uint32_t page, const nand_xfer_t *x, uint8_t *dst, + nand_rec_t *rec) { + uint32_t total = (uint32_t)nand_geom.page_size + nand_geom.oob_size; + if (!nand_geom.page_size) return -1; + if (x->fmc_cfg) fmc_reg(FMC_CFG) = x->fmc_cfg; + + if (x->mode == NAND_XFER_REG) { + uint8_t status = nand_read(page, 0, dst, total); + rec->ecc_err = 0; + nand_rec_finish(rec, status, 0); + return (rec->flags & NAND_REC_TIMEOUT) ? -1 : 0; + } + + /* As hifmc100_send_cmd_read(): wait for the chip, then hand the whole + * PAGE_READ + READ_FROM_CACHE sequence to the controller. */ + nand_wait_oip(); + fmc_reg(FMC_INT_CLR) = 0xFF; + fmc_reg(FMC_OP_CFG) = OP_CFG_FM_CS(0) | OP_CFG_MEM_IF_TYPE(x->iftype) + | OP_CFG_DUMMY_NUM(x->dummy); + nand_dma_set_addr(page); + fmc_reg(FMC_DMA_SADDR_D0) = (uint32_t)(uintptr_t)dst; + fmc_reg(FMC_DMA_SADDR_OOB) = (uint32_t)(uintptr_t)dst + nand_geom.page_size; + fmc_reg(FMC_OP_CTRL) = OP_CTRL_RD_OPCODE(x->opcode) + | OP_CTRL_RD_OP_SEL(RD_OP_READ_ALL_PAGE) + | OP_CTRL_DMA_OP(OP_TYPE_DMA) + | OP_CTRL_RW_OP(RW_OP_READ) + | OP_CTRL_DMA_OP_READY; + int timed_out = fmc_wait_dma_done(); + rec->ecc_err = fmc_reg(FMC_ECC_ERR_NUM0_BUF0); + nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), timed_out); + return (rec->flags & NAND_REC_TIMEOUT) ? -1 : 0; +} + +/* Program page + full OOB from src (page_size + oob_size bytes). No + * empty-page-mark or other OOB fix-ups: the caller lays out the OOB, + * exactly as it should land in the controller's buffer. */ +int nand_page_program(uint32_t page, const nand_xfer_t *x, + const uint8_t *src, nand_rec_t *rec) { + uint32_t total = (uint32_t)nand_geom.page_size + nand_geom.oob_size; + if (!nand_geom.page_size) return -1; + if (x->fmc_cfg) fmc_reg(FMC_CFG) = x->fmc_cfg; + rec->ecc_err = 0; + + if (x->mode == NAND_XFER_REG) { + nand_program_page(page, 0, src, total); + } else { + /* As hifmc100_send_cmd_write(). */ + nand_wait_oip(); + nand_write_enable(); + fmc_reg(FMC_INT_CLR) = 0xFF; + fmc_reg(FMC_OP_CFG) = OP_CFG_FM_CS(0) | OP_CFG_MEM_IF_TYPE(x->iftype); + nand_dma_set_addr(page); + fmc_reg(FMC_DMA_SADDR_D0) = (uint32_t)(uintptr_t)src; + fmc_reg(FMC_DMA_SADDR_OOB) = (uint32_t)(uintptr_t)src + nand_geom.page_size; + fmc_reg(FMC_OP_CTRL) = OP_CTRL_WR_OPCODE(x->opcode) + | OP_CTRL_DMA_OP(OP_TYPE_DMA) + | OP_CTRL_RW_OP(RW_OP_WRITE) + | OP_CTRL_DMA_OP_READY; + if (fmc_wait_dma_done() != 0) { + nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), 1); + return -1; + } + } + uint8_t status = nand_wait_oip(); + nand_rec_finish(rec, status, 0); + if (status != 0xFF && (status & NAND_STATUS_P_FAIL)) rec->flags |= NAND_REC_FAIL; + return rec->flags ? -1 : 0; +} + +int nand_block_erase(uint32_t page, nand_rec_t *rec) { + if (!nand_geom.page_size) return -1; + rec->ecc_err = 0; + int rc = nand_erase_block(page); + nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), 0); + if (rc) rec->flags |= NAND_REC_FAIL; + return rc; +} diff --git a/agent/spi_flash.h b/agent/spi_flash.h index 5f5b7c2..1c479be 100644 --- a/agent/spi_flash.h +++ b/agent/spi_flash.h @@ -3,8 +3,8 @@ * * NOR: register-based reads via FMC normal mode (faster than memory * window when window wraps at 1MB on some SoCs). - * NAND: PAGE_READ → READ_FROM_CACHE flow with on-chip ECC enabled. - * Currently read-only (erase/write are NOR-only). + * NAND: PAGE_READ → READ_FROM_CACHE flow, erase and program, plus the + * CMD_NAND study interface below (controller page engine, raw OOB). */ #ifndef SPI_FLASH_H @@ -83,4 +83,58 @@ int flash_read_oob(uint32_t block, uint8_t *buf, uint32_t len); * on success, -1 if NOR or program fails. */ int flash_program_oob(uint32_t block, const uint8_t *buf, uint32_t len); +/* ---- SPI NAND study interface (CMD_NAND) ------------------------------- + * + * Page I/O at the level the Linux hifmc100 / xmedia_fmc100 drivers work + * at, so their behaviour can be reproduced or varied one knob at a time: + * + * NAND_XFER_REG PAGE_READ/READ_FROM_CACHE or PROGRAM_LOAD/EXECUTE + * through FMC register ops, page + full OOB, no + * controller ECC. With the chip's on-die ECC off this + * is the raw array content. + * NAND_XFER_DMA the controller's own page engine (FMC_OP_CTRL + DMA), + * as the kernel drives it. ECC type, page and block + * size come from the FMC_CFG value passed in. + */ +#define NAND_XFER_REG 0 +#define NAND_XFER_DMA 1 + +typedef struct { + uint32_t fmc_cfg; /* written to FMC_CFG before the op; 0 = leave as is */ + uint8_t mode; /* NAND_XFER_REG or NAND_XFER_DMA */ + uint8_t opcode; /* DMA: SPI read/program opcode (0x03, 0x6B, 0x02, 0x32 ...) */ + uint8_t iftype; /* DMA: FMC MEM_IF_TYPE (0 std, 1 dual, 2 dio, 3 quad, 4 qio) */ + uint8_t dummy; /* DMA read: dummy bytes after the column address */ +} nand_xfer_t; + +/* What the hardware said about one page operation. */ +typedef struct { + uint32_t ecc_err; /* FMC ECC_ERR_NUM0_BUF0: one byte per ECC step, + * 0xff = uncorrectable (DMA reads only) */ + uint8_t ondie; /* chip status, feature 0xC0, after the op */ + uint8_t fmc_int; /* FMC_INT after the op */ + uint8_t flags; /* NAND_REC_* */ + uint8_t rsvd; +} nand_rec_t; + +#define NAND_REC_TIMEOUT (1 << 0) /* controller or chip never finished */ +#define NAND_REC_FAIL (1 << 1) /* chip reported P_FAIL / E_FAIL */ + +/* Geometry of the identified chip; page_size 0 if it is not a SPI NAND. */ +typedef struct { + uint16_t page_size; + uint16_t oob_size; /* physical spare area */ + uint16_t pages_per_block; + uint16_t blocks; +} nand_geom_t; + +void nand_get_geometry(nand_geom_t *geom); +uint8_t nand_feature_get(uint8_t addr); +void nand_feature_set(uint8_t addr, uint8_t val); +int nand_page_read(uint32_t page, const nand_xfer_t *x, uint8_t *dst, + nand_rec_t *rec); +int nand_page_program(uint32_t page, const nand_xfer_t *x, + const uint8_t *src, nand_rec_t *rec); +int nand_block_erase(uint32_t page, nand_rec_t *rec); + #endif /* SPI_FLASH_H */ diff --git a/agent/startup.S b/agent/startup.S index ac4d7b8..75360d2 100644 --- a/agent/startup.S +++ b/agent/startup.S @@ -10,6 +10,8 @@ * but the layout (4096 × 1 MB sections, 16 KB-aligned) is identical. */ +#include "nand_layout.h" + .section .text.start .global _start .arm @@ -145,6 +147,17 @@ pt_fill_ddr_v5: subs r7, r7, #1 bne pt_fill_ddr_v5 +#ifdef HAVE_NAND_STUDY + /* NAND study DMA buffer: uncached (B=0, C=0) so the FMC's DMA and the + * CPU see the same bytes without cache maintenance. */ + ldr r1, =NAND_DMA_BUF + lsr r2, r1, #20 + add r0, r4, r2, lsl #2 + ldr r6, =0x00000C02 + orr r1, r6, r2, lsl #20 + str r1, [r0] +#endif + /* Drain write buffer so the page table writes are visible to the MMU * before TTBR points at it. */ mov r0, #0 @@ -266,6 +279,18 @@ pt_fill_ddr: subs r7, r7, #1 bne pt_fill_ddr +#ifdef HAVE_NAND_STUDY + /* NAND study DMA buffer: Normal non-cacheable (TEX=001, C=0, B=0), so + * the FMC's DMA and the CPU see the same bytes without cache + * maintenance. AP=11 -> (3<<10)|(1<<12)|0b10 = 0x00001C02. */ + ldr r1, =NAND_DMA_BUF + lsr r2, r1, #20 + add r0, r4, r2, lsl #2 + ldr r6, =0x00001C02 + orr r1, r6, r2, lsl #20 + str r1, [r0] +#endif + /* ===== ENABLE MMU + CACHES ===== */ /* Invalidate TLB again (page table just written) */ diff --git a/src/defib/agent/client.py b/src/defib/agent/client.py index 6f9ba7f..c084be8 100644 --- a/src/defib/agent/client.py +++ b/src/defib/agent/client.py @@ -343,6 +343,7 @@ async def _restore_baud(self) -> None: CAP_SELFUPDATE = 1 << 5 CAP_SCAN = 1 << 6 CAP_MEMBW = 1 << 7 + CAP_NAND = 1 << 8 async def get_info(self) -> dict[str, int | str]: """Request device info from the agent.""" diff --git a/src/defib/agent/nand.py b/src/defib/agent/nand.py new file mode 100644 index 0000000..13957ee --- /dev/null +++ b/src/defib/agent/nand.py @@ -0,0 +1,323 @@ +"""SPI NAND study operations over the flash agent (CMD_NAND). + +The agent drives the HiSilicon/Goke FMC100 controller's page engine the +way the Linux hifmc100 / xmedia_fmc100 drivers do — or deliberately not: +ECC type, transfer mode, SPI opcodes and the exact OOB bytes are all the +caller's. That is what it takes to chase ECC faults such as +OpenIPC/firmware#2285 and #2519 below the OS. + +Pages and per-page results stay in agent RAM (``NandInfo.dma_buf`` / +``stat_buf``) and move over the ordinary CMD_READ / CMD_WRITE stream; +CMD_NAND itself only carries parameters and one-record answers. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field +from enum import IntEnum +from typing import TYPE_CHECKING + +from defib.agent.protocol import CMD_NAND, RSP_NAND, recv_response, send_packet + +if TYPE_CHECKING: + from defib.agent.client import FlashAgentClient + +# Sub-operations and statuses (agent/protocol.h). +NAND_OP_INFO = 0x00 +NAND_OP_FEATURE_GET = 0x01 +NAND_OP_FEATURE_SET = 0x02 +NAND_OP_READ_PAGES = 0x03 +NAND_OP_PROGRAM_PAGE = 0x04 +NAND_OP_ERASE_BLOCK = 0x05 +NAND_OP_FMC_REG = 0x06 + +NAND_ST_OK = 0x00 +NAND_ST_BADARG = 0x01 +NAND_ST_NOT_NAND = 0x02 +NAND_ST_IO = 0x03 + +REC_SIZE = 8 +REC_TIMEOUT = 1 << 0 +REC_FAIL = 1 << 1 + +# SPI NAND feature registers. +FEATURE_PROTECT = 0xA0 +FEATURE_CONFIG = 0xB0 # bit 4 ECC-E (on-die ECC), bit 3 BUF on Winbond +FEATURE_STATUS = 0xC0 # bits 5:4 ECC_S, 3 P_FAIL, 2 E_FAIL, 0 OIP + +# FMC_CFG fields (include/linux/mfd/hisi_fmc.h). +FMC_CFG_OP_MODE_NORMAL = 1 << 0 +FMC_CFG_FLASH_SEL_MASK = 0x3 << 1 +FMC_CFG_FLASH_SEL_SPI_NAND = 0x1 << 1 +FMC_CFG_PAGE_SIZE_MASK = 0x3 << 3 +FMC_CFG_ECC_TYPE_SHIFT = 5 +FMC_CFG_ECC_TYPE_MASK = 0x7 << FMC_CFG_ECC_TYPE_SHIFT +FMC_CFG_BLOCK_SIZE_MASK = 0x3 << 8 + + +class EccType(IntEnum): + """FMC_CFG ECC_TYPE values.""" + + NONE = 0 + BIT8 = 1 + BIT16 = 2 + BIT24 = 3 + BIT28 = 4 + BIT40 = 5 + BIT64 = 6 + + +class XferMode(IntEnum): + REG = 0 # register ops: page + full OOB as the array holds it + DMA = 1 # the controller's page engine, controller ECC per FMC_CFG + + +class NandError(RuntimeError): + """The agent refused a CMD_NAND request or the hardware failed it.""" + + +def compose_fmc_cfg(live: int, ecc: EccType) -> int: + """FMC_CFG for SPI NAND, 2 KiB pages, 64-page blocks and ``ecc``. + + Bits the kernel does not touch (SPI_NAND_SEL, address mode, ...) are + taken from ``live``, the register as it stands. + """ + cfg = live & ~( + FMC_CFG_FLASH_SEL_MASK | FMC_CFG_PAGE_SIZE_MASK + | FMC_CFG_ECC_TYPE_MASK | FMC_CFG_BLOCK_SIZE_MASK + ) + return ( + cfg | FMC_CFG_OP_MODE_NORMAL | FMC_CFG_FLASH_SEL_SPI_NAND + | (int(ecc) << FMC_CFG_ECC_TYPE_SHIFT) + ) + + +def ecc_type_of(fmc_cfg: int) -> EccType: + return EccType((fmc_cfg & FMC_CFG_ECC_TYPE_MASK) >> FMC_CFG_ECC_TYPE_SHIFT) + + +@dataclass(frozen=True) +class NandXfer: + """How the agent moves one page (agent nand_xfer_t).""" + + mode: XferMode = XferMode.DMA + fmc_cfg: int = 0 # 0 = leave FMC_CFG as it is + opcode: int = 0x03 # read: 0x03 std / 0x0B fast / 0x6B quad; program: 0x02 / 0x32 + iftype: int = 0 # 0 std, 1 dual, 2 dio, 3 quad, 4 qio + dummy: int = 1 # dummy bytes after the column address (reads) + + def pack(self) -> bytes: + return struct.pack( + " NandXfer: + """The program-side twin of a read transfer: same mode and FMC_CFG.""" + quad = read.iftype in (3, 4) + return NandXfer( + mode=read.mode, fmc_cfg=read.fmc_cfg, + opcode=0x32 if quad else 0x02, iftype=3 if quad else 0, dummy=0, + ) + + +@dataclass(frozen=True) +class NandRecord: + """What the hardware said about one page op (agent nand_rec_t).""" + + ecc_err: int # FMC ECC_ERR_NUM0_BUF0: a byte per ECC step, 0xff = uncorrectable + ondie: int # chip status, feature 0xC0 + fmc_int: int + flags: int + + @classmethod + def unpack(cls, raw: bytes) -> NandRecord: + ecc_err, ondie, fmc_int, flags, _ = struct.unpack(" list[int]: + """Per-ECC-step error counts; 0xff means uncorrectable.""" + return [(self.ecc_err >> (8 * i)) & 0xFF for i in range(steps)] + + def uncorrectable_steps(self, steps: int) -> list[int]: + return [i for i, n in enumerate(self.step_errors(steps)) if n == 0xFF] + + def corrected_bits(self, steps: int) -> int: + return sum(n for n in self.step_errors(steps) if n != 0xFF) + + @property + def ondie_ecc(self) -> int: + """On-die ECC verdict, status bits 5:4 (0 clean, 1 corrected, 2 failed).""" + return (self.ondie >> 4) & 0x3 + + @property + def failed(self) -> bool: + return bool(self.flags & (REC_TIMEOUT | REC_FAIL)) + + +@dataclass(frozen=True) +class NandInfo: + page_size: int + oob_size: int + pages_per_block: int + blocks: int + dma_buf: int + dma_size: int + stat_buf: int + stat_size: int + fmc_cfg: int + + @property + def stride(self) -> int: + return self.page_size + self.oob_size + + @property + def pages(self) -> int: + return self.pages_per_block * self.blocks + + @property + def ecc_steps(self) -> int: + """ECC steps per page: the FMC100 BCH engine covers 1 KiB each.""" + return max(1, self.page_size // 1024) + + +@dataclass +class PageRead: + page: int + data: bytes + oob: bytes + record: NandRecord + + +@dataclass +class ScanResult: + start: int + records: list[NandRecord] = field(default_factory=list) + + +def kernel_oob(oob_size: int) -> bytes: + """The OOB a Linux hifmc100 write sends for a page with no OOB data. + + All 0xff except the empty-page mark: hifmc100_send_cmd_write() zeroes + the two bytes at oobfree[0].offset + 28 (= 30 with the default + layout) before every program, so the controller can later tell a + written page from an erased one. + """ + oob = bytearray(b"\xff" * oob_size) + oob[30:32] = b"\x00\x00" + return bytes(oob) + + +class NandStudy: + """CMD_NAND on top of a connected FlashAgentClient.""" + + def __init__(self, client: FlashAgentClient) -> None: + self._client = client + self._info: NandInfo | None = None + self.last_status = NAND_ST_OK + + async def _call(self, op: int, args: bytes = b"", timeout: float = 5.0) -> bytes: + transport = self._client._transport + self._client._clear_rx_buffers() + await send_packet(transport, CMD_NAND, bytes([op]) + args) + cmd, data = await recv_response(transport, timeout=timeout) + if cmd != RSP_NAND or len(data) < 2 or data[0] != op: + raise NandError( + f"agent did not answer CMD_NAND op 0x{op:02x} " + f"(cmd=0x{cmd:02x}, {len(data)} bytes); does it advertise CAP_NAND?" + ) + status = data[1] + if status == NAND_ST_BADARG: + raise NandError(f"agent rejected CMD_NAND op 0x{op:02x}: bad argument") + if status == NAND_ST_NOT_NAND: + raise NandError("agent found no SPI NAND on this board") + if status not in (NAND_ST_OK, NAND_ST_IO): + raise NandError(f"CMD_NAND op 0x{op:02x}: unknown status 0x{status:02x}") + self.last_status = status + return data[2:] + + async def info(self) -> NandInfo: + raw = await self._call(NAND_OP_INFO) + page, oob, ppb, blocks, dma, dma_size, stat, stat_size, cfg = struct.unpack( + " NandInfo: + return self._info or await self.info() + + async def feature_get(self, addr: int) -> int: + return (await self._call(NAND_OP_FEATURE_GET, bytes([addr])))[0] + + async def feature_set(self, addr: int, value: int) -> int: + """Write a feature register; returns what it reads back.""" + return (await self._call(NAND_OP_FEATURE_SET, bytes([addr, value])))[0] + + async def fmc_reg(self, offset: int, value: int | None = None) -> int: + """Read (or write, then read) a 32-bit FMC register.""" + args = struct.pack(" tuple[list[NandRecord], bytes]: + """Read ``count`` pages from ``start``. + + Returns the records of the pages the agent got through (it stops + at the first timeout) and, with ``store``, their page + OOB bytes + packed at ``stride``. + """ + info = await self._geometry() + args = struct.pack(" PageRead: + info = await self._geometry() + records, data = await self.read_pages(page, 1, xfer) + if not records: + raise NandError(f"page {page}: no result") + return PageRead(page, data[:info.page_size], data[info.page_size:info.stride], records[0]) + + async def scan( + self, start: int, count: int, xfer: NandXfer, chunk: int = 4096, + ) -> ScanResult: + """ECC survey: read every page, keep only the records.""" + info = await self._geometry() + chunk = min(chunk, info.stat_size // REC_SIZE) + result = ScanResult(start=start) + page = start + while page < start + count: + n = min(chunk, start + count - page) + records, _ = await self.read_pages(page, n, xfer, store=False) + result.records.extend(records) + if len(records) < n: + break + page += n + return result + + async def program_page(self, page: int, payload: bytes, xfer: NandXfer) -> NandRecord: + """Program page + OOB exactly as given (no OOB fix-ups).""" + info = await self._geometry() + if len(payload) != info.stride: + raise ValueError(f"need {info.stride} bytes (page + OOB), got {len(payload)}") + if not await self._client.write_memory(info.dma_buf, payload): + raise NandError("could not stage the page in agent RAM") + args = struct.pack(" NandRecord: + raw = await self._call(NAND_OP_ERASE_BLOCK, struct.pack(" str: """Build the diagnostic shown when boot protocol uploads complete but the diff --git a/src/defib/cli/nand.py b/src/defib/cli/nand.py new file mode 100644 index 0000000..f96be48 --- /dev/null +++ b/src/defib/cli/nand.py @@ -0,0 +1,353 @@ +"""``defib agent nand`` — SPI NAND study commands over the flash agent. + +Low-level access to the FMC100 SPI NAND path for chasing ECC faults below +the OS: identify the chip and its feature registers, read pages raw or +through the controller's ECC engine, survey ECC status over a range, dump +page + OOB, and (destructively) program or erase single pages/blocks. + +``write-page`` and ``erase-block`` change the flash immediately, with no +prompt, like every other writing command in defib. Take a ``dump`` +first. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any, TypeVar + +import typer +from rich.console import Console + +nand_app = typer.Typer(help="SPI NAND study ops: raw pages, OOB, ECC status (FMC100 SoCs)") + +T = TypeVar("T") + +PORT_HELP = "Serial device (/dev/ttyUSB0), tcp://host:port, rfc2217://host:port, or socket:///path" +ECC_CHOICES = {"none": 0, "8": 1, "16": 2, "24": 3, "28": 4, "40": 5, "64": 6} + + +def _run(port: str, body: Callable[[Any], Awaitable[T]]) -> T: + """Connect to the agent on ``port``, run ``body(NandStudy)``, close.""" + from defib.agent.client import FlashAgentClient + from defib.agent.nand import NandError, NandStudy + from defib.transport.serial_platform import create_transport, normalize_port_name + + console = Console(stderr=True) + + async def go() -> T: + transport = await create_transport(normalize_port_name(port)) + try: + client = FlashAgentClient(transport) + if not await client.connect(timeout=5.0): + console.print("[red]Agent not responding.[/red] Upload it first with " + "'defib agent upload'.") + raise typer.Exit(1) + info = await client.get_info() + if not int(info.get("capabilities", 0)) & FlashAgentClient.CAP_NAND: + console.print("[red]This agent has no CMD_NAND[/red] (agent version " + f"{info.get('agent_version', '?')}); rebuild and re-upload it.") + raise typer.Exit(1) + return await body(NandStudy(client)) + except NandError as e: + console.print(f"[red]{e}[/red]") + raise typer.Exit(1) + finally: + await transport.close() + + return asyncio.run(go()) + + +def _xfer( + study: Any, mode: str, ecc: str, fmc_cfg: str, opcode: str, iftype: int, dummy: int, +) -> Any: + """Build a NandXfer; the FMC_CFG base is the live register.""" + from defib.agent.nand import EccType, NandXfer, XferMode, compose_fmc_cfg + + if mode not in ("reg", "dma"): + raise typer.BadParameter("--mode must be reg or dma") + if fmc_cfg: + cfg = int(fmc_cfg, 0) + elif mode == "reg": + cfg = 0 + else: + if ecc not in ECC_CHOICES: + raise typer.BadParameter(f"--ecc must be one of {', '.join(ECC_CHOICES)}") + assert study._info is not None + cfg = compose_fmc_cfg(study._info.fmc_cfg, EccType(ECC_CHOICES[ecc])) + return NandXfer( + mode=XferMode.REG if mode == "reg" else XferMode.DMA, + fmc_cfg=cfg, opcode=int(opcode, 0), iftype=iftype, dummy=dummy, + ) + + +def _describe(rec: Any, steps: int) -> str: + parts = [] + errs = rec.step_errors(steps) + if any(errs): + parts.append("ecc " + "/".join("UNCORR" if n == 0xFF else str(n) for n in errs)) + else: + parts.append("ecc clean") + parts.append(f"on-die {['clean', 'corrected', 'FAILED', 'corrected(3)'][rec.ondie_ecc]}") + parts.append(f"status 0x{rec.ondie:02x}") + if rec.failed: + parts.append("[red]OP FAILED[/red]") + return ", ".join(parts) + + +def _features(study: Any) -> Awaitable[dict[str, int]]: + async def read() -> dict[str, int]: + from defib.agent.nand import FEATURE_CONFIG, FEATURE_PROTECT, FEATURE_STATUS + return { + "protect_a0": await study.feature_get(FEATURE_PROTECT), + "config_b0": await study.feature_get(FEATURE_CONFIG), + "status_c0": await study.feature_get(FEATURE_STATUS), + } + return read() + + +# Shared options -------------------------------------------------------------- + +PortOpt = typer.Option("/dev/ttyUSB0", "-p", "--port", help=PORT_HELP) +ModeOpt = typer.Option("dma", "--mode", help="reg: raw register reads; dma: controller page engine") +EccOpt = typer.Option("8", "--ecc", help="Controller ECC for --mode dma: none, 8, 16, 24, 28, 40, 64") +CfgOpt = typer.Option("", "--fmc-cfg", help="Exact FMC_CFG value (hex); overrides --ecc") +OpcodeOpt = typer.Option("0x03", "--opcode", help="DMA read opcode: 0x03 std, 0x0B fast, 0x6B quad") +IftypeOpt = typer.Option(0, "--iftype", help="FMC interface: 0 std, 1 dual, 2 dio, 3 quad, 4 qio") +DummyOpt = typer.Option(1, "--dummy", help="Dummy bytes after the column address") + + +@nand_app.command("info") +def nand_info(port: str = PortOpt) -> None: + """Chip geometry, feature registers and live FMC_CFG.""" + async def body(study: Any) -> None: + info = await study.info() + feats = await _features(study) + print(f"Page {info.page_size} + OOB {info.oob_size}, " + f"{info.pages_per_block} pages/block, {info.blocks} blocks " + f"({info.pages * info.page_size // (1024 * 1024)} MiB)") + print(f"FMC_CFG 0x{info.fmc_cfg:08x}") + b0 = feats["config_b0"] + print(f"Feature 0xA0 (protect) 0x{feats['protect_a0']:02x}") + print(f"Feature 0xB0 (config) 0x{b0:02x} on-die ECC {'ON' if b0 & 0x10 else 'off'}" + f", BUF {1 if b0 & 0x08 else 0}, QE {b0 & 0x01}") + print(f"Feature 0xC0 (status) 0x{feats['status_c0']:02x}") + print(f"Agent buffers: pages @ 0x{info.dma_buf:08x} ({info.dma_size} B), " + f"records @ 0x{info.stat_buf:08x} ({info.stat_size} B)") + _run(port, body) + + +@nand_app.command("feature") +def nand_feature( + addr: str = typer.Argument(..., help="Feature register, e.g. 0xB0"), + value: str = typer.Argument("", help="Value to write (hex); omit to read"), + port: str = PortOpt, +) -> None: + """Read or write a SPI NAND feature register (volatile).""" + async def body(study: Any) -> None: + reg = int(addr, 0) + if value: + got = await study.feature_set(reg, int(value, 0)) + print(f"0x{reg:02x} <- 0x{int(value, 0):02x}, reads back 0x{got:02x}") + else: + print(f"0x{reg:02x} = 0x{await study.feature_get(reg):02x}") + _run(port, body) + + +@nand_app.command("fmc-reg") +def nand_fmc_reg( + offset: str = typer.Argument(..., help="FMC register offset, e.g. 0x00 (FMC_CFG)"), + value: str = typer.Argument("", help="Value to write (hex); omit to read"), + port: str = PortOpt, +) -> None: + """Read or write a 32-bit FMC controller register.""" + async def body(study: Any) -> None: + got = await study.fmc_reg(int(offset, 0), int(value, 0) if value else None) + print(f"FMC+0x{int(offset, 0):03x} = 0x{got:08x}") + _run(port, body) + + +@nand_app.command("read-page") +def nand_read_page( + page: int = typer.Argument(..., help="Page number"), + port: str = PortOpt, + mode: str = ModeOpt, + ecc: str = EccOpt, + fmc_cfg: str = CfgOpt, + opcode: str = OpcodeOpt, + iftype: int = IftypeOpt, + dummy: int = DummyOpt, + output: str = typer.Option("", "-o", "--output", help="Write page + OOB to this file"), +) -> None: + """Read one page + OOB and report what the controller and chip said.""" + async def body(study: Any) -> None: + info = await study.info() + x = _xfer(study, mode, ecc, fmc_cfg, opcode, iftype, dummy) + r = await study.read_page(page, x) + print(f"page {page} ({mode}, FMC_CFG 0x{x.fmc_cfg or info.fmc_cfg:08x}): " + f"{_describe(r.record, info.ecc_steps)}") + print(f"data[0:32] {r.data[:32].hex()}") + for off in range(0, len(r.oob), 32): + print(f"oob[{off:3d}] {r.oob[off:off + 32].hex()}") + if output: + Path(output).write_bytes(r.data + r.oob) + print(f"wrote {output}") + _run(port, body) + + +@nand_app.command("ecc-scan") +def nand_ecc_scan( + port: str = PortOpt, + start: int = typer.Option(0, "--start", help="First page"), + count: int = typer.Option(0, "--count", help="Pages to scan (0 = to the end)"), + mode: str = ModeOpt, + ecc: str = EccOpt, + fmc_cfg: str = CfgOpt, + opcode: str = OpcodeOpt, + iftype: int = IftypeOpt, + dummy: int = DummyOpt, + json_out: str = typer.Option("", "--json", help="Write every page's record to this file"), +) -> None: + """Read a page range and report ECC status per page (data is discarded).""" + async def body(study: Any) -> None: + info = await study.info() + n = count or info.pages - start + x = _xfer(study, mode, ecc, fmc_cfg, opcode, iftype, dummy) + result = await study.scan(start, n, x) + steps = info.ecc_steps + uncorr = [(start + i, r) for i, r in enumerate(result.records) if r.uncorrectable_steps(steps)] + corrected = [(start + i, r) for i, r in enumerate(result.records) + if not r.uncorrectable_steps(steps) and r.corrected_bits(steps)] + print(f"scanned {len(result.records)}/{n} pages from {start} " + f"(FMC_CFG 0x{x.fmc_cfg or info.fmc_cfg:08x}, {mode})") + print(f"uncorrectable: {len(uncorr)} corrected: {len(corrected)}") + for page, rec in uncorr[:200]: + blk, pg = divmod(page, info.pages_per_block) + print(f" page {page:6d} (block {blk:4d} page {pg:2d}) " + f"steps {rec.uncorrectable_steps(steps)} ecc_err 0x{rec.ecc_err:08x}") + if len(uncorr) > 200: + print(f" ... {len(uncorr) - 200} more (see --json)") + if json_out: + Path(json_out).write_text(json.dumps({ + "start": start, "fmc_cfg": x.fmc_cfg or info.fmc_cfg, "mode": mode, + "ecc_steps": steps, "pages_per_block": info.pages_per_block, + "records": [[r.ecc_err, r.ondie, r.fmc_int, r.flags] for r in result.records], + })) + print(f"wrote {json_out}") + if len(result.records) < n: + print(f"[stopped early at page {start + len(result.records)}: controller timeout]", + file=sys.stderr) + _run(port, body) + + +@nand_app.command("dump") +def nand_dump( + output: str = typer.Option(..., "-o", "--output", help="Raw dump file (page + OOB per page)"), + port: str = PortOpt, + start: int = typer.Option(0, "--start", help="First page"), + count: int = typer.Option(0, "--count", help="Pages (0 = to the end)"), + mode: str = typer.Option("reg", "--mode", help="reg (default, raw array) or dma"), + ecc: str = typer.Option("none", "--ecc", help="Controller ECC for --mode dma"), + fmc_cfg: str = CfgOpt, + keep_ondie: bool = typer.Option( + False, "--keep-ondie-ecc", + help="Leave the chip's on-die ECC as it is; by default it is switched off for the " + "dump (raw bits) and restored afterwards", + ), +) -> None: + """Back up page + OOB, raw by default, with a JSON sidecar of per-page records.""" + async def body(study: Any) -> None: + from defib.agent.nand import FEATURE_CONFIG + + info = await study.info() + n = count or info.pages - start + x = _xfer(study, mode, ecc, fmc_cfg, "0x03", 0, 1) + feats = await _features(study) + b0 = feats["config_b0"] + if not keep_ondie and b0 & 0x10: + await study.feature_set(FEATURE_CONFIG, b0 & ~0x10) + batch = max(1, info.dma_size // info.stride) + records: list[Any] = [] + try: + with open(output, "wb") as f: + page = start + while page < start + n: + k = min(batch, start + n - page) + recs, data = await study.read_pages(page, k, x) + f.write(data) + records.extend(recs) + page += len(recs) + print(f"\r{page - start}/{n} pages", end="", file=sys.stderr, flush=True) + if len(recs) < k: + print(f"\n[controller timeout at page {page}]", file=sys.stderr) + break + finally: + if not keep_ondie and b0 & 0x10: + await study.feature_set(FEATURE_CONFIG, b0) + print(file=sys.stderr) + sidecar = Path(output + ".json") + sidecar.write_text(json.dumps({ + "start": start, "pages": len(records), "page_size": info.page_size, + "oob_size": info.oob_size, "pages_per_block": info.pages_per_block, + "mode": mode, "fmc_cfg": x.fmc_cfg or info.fmc_cfg, + "features": feats, "ondie_ecc_during_dump": bool(keep_ondie and b0 & 0x10), + "records": [[r.ecc_err, r.ondie, r.fmc_int, r.flags] for r in records], + })) + print(f"{len(records)} pages x {info.stride} B -> {output} (+ {sidecar.name})") + _run(port, body) + + +@nand_app.command("write-page") +def nand_write_page( + page: int = typer.Argument(..., help="Page number"), + input_file: str = typer.Option(..., "-i", "--input", help="Page data, or page + OOB"), + port: str = PortOpt, + mode: str = ModeOpt, + ecc: str = EccOpt, + fmc_cfg: str = CfgOpt, + kernel_oob: bool = typer.Option( + False, "--kernel-oob", + help="Input is page data only; add the OOB a Linux hifmc100 write sends " + "(0xff with the empty-page mark zeroed)", + ), + quad: bool = typer.Option(False, "--quad", help="Program with 0x32 over 4 lines"), +) -> None: + """DESTRUCTIVE: program one page (+ OOB) exactly as given. No prompt.""" + async def body(study: Any) -> None: + from defib.agent.nand import kernel_oob as make_oob + from defib.agent.nand import program_xfer + + info = await study.info() + payload = Path(input_file).read_bytes() + if kernel_oob: + if len(payload) != info.page_size: + raise typer.BadParameter(f"--kernel-oob needs exactly {info.page_size} bytes") + payload += make_oob(info.oob_size) + if len(payload) != info.stride: + raise typer.BadParameter(f"need {info.stride} bytes (page + OOB), got {len(payload)}") + read_x = _xfer(study, mode, ecc, fmc_cfg, "0x6B" if quad else "0x03", 3 if quad else 0, 1) + rec = await study.program_page(page, payload, program_xfer(read_x)) + print(f"programmed page {page}: status 0x{rec.ondie:02x}" + f"{' FAILED' if rec.failed else ''}") + if rec.failed: + raise typer.Exit(1) + _run(port, body) + + +@nand_app.command("erase-block") +def nand_erase_block( + page: int = typer.Argument(..., help="Any page in the block"), + port: str = PortOpt, +) -> None: + """DESTRUCTIVE: erase the 128 KiB block containing PAGE. No prompt.""" + async def body(study: Any) -> None: + info = await study.info() + rec = await study.erase_block(page) + print(f"erased block {page // info.pages_per_block}: status 0x{rec.ondie:02x}" + f"{' FAILED' if rec.failed else ''}") + if rec.failed: + raise typer.Exit(1) + _run(port, body) diff --git a/tests/test_agent_nand.py b/tests/test_agent_nand.py new file mode 100644 index 0000000..4ea361b --- /dev/null +++ b/tests/test_agent_nand.py @@ -0,0 +1,340 @@ +"""Tests for the SPI NAND study ops (CMD_NAND) — host side. + +FakeNandAgent answers CMD_NAND the way agent/main.c handle_nand() does, +over a simulated chip, and keeps the agent's RAM buffers in a dict so the +CMD_READ / CMD_WRITE half of the protocol can be faked at the client. +""" + +from __future__ import annotations + +import json +import struct +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from defib.agent import cobs +from defib.agent.nand import ( + FEATURE_CONFIG, + REC_FAIL, + EccType, + NandError, + NandRecord, + NandStudy, + NandXfer, + XferMode, + compose_fmc_cfg, + ecc_type_of, + kernel_oob, + program_xfer, +) +from defib.agent.protocol import CMD_NAND, RSP_NAND, build_packet, parse_packet +from defib.transport.mock import MockTransport + +PAGE, OOB, PPB, BLOCKS = 2048, 64, 64, 4 +STRIDE = PAGE + OOB +DMA_BUF, DMA_SIZE = 0x40E00000, 0x100000 +STAT_BUF, STAT_SIZE = 0x42000000, 0x80000 + + +class FakeNandAgent(MockTransport): + def __init__(self) -> None: + super().__init__(flush_clears_buffer=False) + self.pages: dict[int, bytes] = {} + self.records: dict[int, NandRecord] = {} # page -> what a read reports + self.features = {0xA0: 0x00, 0xB0: 0x18, 0xC0: 0x00} + self.fmc = {0x00: 0x1821} + self.ram: dict[int, bytes] = {} + self.calls: list[tuple[int, bytes]] = [] + self.status_override: int | None = None + self.timeout_at: int | None = None + self.not_nand = False + + async def write(self, data: bytes) -> None: + await super().write(data) + for frame in data.split(b"\x00"): + if frame: + cmd, payload = parse_packet(frame) + assert cmd == CMD_NAND + self._handle(payload) + + def _reply(self, op: int, status: int, payload: bytes = b"") -> None: + if self.status_override is not None: + status = self.status_override + self.enqueue_rx(build_packet(RSP_NAND, bytes([op, status]) + payload)) + + def _handle(self, payload: bytes) -> None: + op, arg = payload[0], payload[1:] + self.calls.append((op, arg)) + if op == 0x06: + write, off, value = struct.unpack(" None: + self._transport = agent + self.agent = agent + + def _clear_rx_buffers(self) -> None: + pass + + async def read_memory(self, addr: int, size: int, fast: bool = True) -> bytes: + return self.agent.ram[addr][:size] + + async def write_memory(self, addr: int, data: bytes) -> bool: + self.agent.ram[addr] = data + return True + + +@pytest.fixture +def agent() -> FakeNandAgent: + return FakeNandAgent() + + +@pytest.fixture +def study(agent: FakeNandAgent) -> NandStudy: + return NandStudy(FakeClient(agent)) # type: ignore[arg-type] + + +# --- pure helpers ------------------------------------------------------------- + +def test_compose_fmc_cfg_keeps_unrelated_bits() -> None: + live = 0x1821 # bootrom/agent default: NOR select, ECC 8 + cfg = compose_fmc_cfg(live, EccType.BIT24) + assert cfg & 0x1 == 1 # normal mode + assert (cfg >> 1) & 0x3 == 1 # SPI NAND + assert ecc_type_of(cfg) == EccType.BIT24 + assert (cfg >> 3) & 0x3 == 0 and (cfg >> 8) & 0x3 == 0 # 2K page, 64-page block + assert cfg & 0x1800 == 0x1800 # SPI_NAND_SEL untouched + assert ecc_type_of(compose_fmc_cfg(cfg, EccType.NONE)) == EccType.NONE + + +def test_xfer_packing() -> None: + x = NandXfer(mode=XferMode.DMA, fmc_cfg=0x11823, opcode=0x6B, iftype=3, dummy=1) + assert x.pack() == struct.pack(" None: + rec = NandRecord.unpack(struct.pack(" None: + oob = kernel_oob(64) + assert len(oob) == 64 + assert oob[30:32] == b"\x00\x00" + assert oob[:30] == b"\xff" * 30 and oob[32:] == b"\xff" * 32 + + +# --- NandStudy against the fake agent ----------------------------------------- + +async def test_info(study: NandStudy) -> None: + info = await study.info() + assert (info.page_size, info.oob_size, info.pages_per_block, info.blocks) == (PAGE, OOB, PPB, BLOCKS) + assert info.stride == STRIDE and info.pages == PPB * BLOCKS and info.ecc_steps == 2 + assert (info.dma_buf, info.stat_buf, info.fmc_cfg) == (DMA_BUF, STAT_BUF, 0x1821) + + +async def test_read_page_splits_data_and_oob(study: NandStudy, agent: FakeNandAgent) -> None: + agent.pages[7] = bytes(range(256)) * 8 + b"\xab" * OOB + agent.records[7] = NandRecord(0x0000FF00, 0x20, 1, 0) + r = await study.read_page(7, NandXfer()) + assert r.data == bytes(range(256)) * 8 + assert r.oob == b"\xab" * OOB + assert r.record.uncorrectable_steps(2) == [1] + + +async def test_scan_chunks_and_keeps_order(study: NandStudy, agent: FakeNandAgent) -> None: + agent.records[130] = NandRecord(0xFF, 0, 1, 0) + result = await study.scan(0, 200, NandXfer(), chunk=64) + assert len(result.records) == 200 + assert [i for i, r in enumerate(result.records) if r.uncorrectable_steps(2)] == [130] + reads = [struct.unpack(" None: + agent.timeout_at = 70 + result = await study.scan(0, 200, NandXfer(), chunk=64) + assert len(result.records) == 71 + assert result.records[-1].failed + + +async def test_program_page_stages_exact_bytes(study: NandStudy, agent: FakeNandAgent) -> None: + payload = b"\x5a" * PAGE + kernel_oob(OOB) + rec = await study.program_page(9, payload, program_xfer(NandXfer())) + assert not rec.failed + assert agent.pages[9] == payload + with pytest.raises(ValueError, match="2112"): + await study.program_page(9, b"\x00" * PAGE, NandXfer()) + + +async def test_erase_block(study: NandStudy, agent: FakeNandAgent) -> None: + agent.pages[65] = b"\x00" * STRIDE + await study.erase_block(64) + assert 65 not in agent.pages + + +async def test_feature_and_fmc_reg(study: NandStudy, agent: FakeNandAgent) -> None: + assert await study.feature_get(FEATURE_CONFIG) == 0x18 + assert await study.feature_set(FEATURE_CONFIG, 0x08) == 0x08 + assert agent.features[0xB0] == 0x08 + assert await study.fmc_reg(0x00) == 0x1821 + assert await study.fmc_reg(0x00, 0x11823) == 0x11823 + + +async def test_errors_raise(study: NandStudy, agent: FakeNandAgent) -> None: + agent.status_override = 1 + with pytest.raises(NandError, match="bad argument"): + await study.feature_get(0xB0) + agent.status_override = None + agent.not_nand = True + with pytest.raises(NandError, match="no SPI NAND"): + await study.feature_get(0xB0) + + +async def test_unexpected_answer_raises(agent: FakeNandAgent) -> None: + class Silent(FakeNandAgent): + def _handle(self, payload: bytes) -> None: + self.enqueue_rx(build_packet(0x83, b"\x01")) # plain ACK: old agent + + study = NandStudy(FakeClient(Silent())) # type: ignore[arg-type] + with pytest.raises(NandError, match="CAP_NAND"): + await study.info() + + +# --- CLI ------------------------------------------------------------------------ + +@pytest.fixture +def cli(monkeypatch: pytest.MonkeyPatch, agent: FakeNandAgent) -> CliRunner: + import asyncio + + from defib.cli import nand as nand_cli + + def fake_run(port: str, body: Any) -> Any: + return asyncio.run(body(NandStudy(FakeClient(agent)))) # type: ignore[arg-type] + + monkeypatch.setattr(nand_cli, "_run", fake_run) + return CliRunner() + + +def _app() -> Any: + from defib.cli.app import app + return app + + +def test_cli_ecc_scan_lists_uncorrectable( + cli: CliRunner, agent: FakeNandAgent, tmp_path: Path, +) -> None: + agent.records[65] = NandRecord(0x0000FF00, 0, 1, 0) + agent.records[66] = NandRecord(0x00000003, 0, 1, 0) + out = tmp_path / "scan.json" + res = cli.invoke(_app(), ["agent", "nand", "ecc-scan", "--count", "128", + "--json", str(out)]) + assert res.exit_code == 0, res.output + assert "uncorrectable: 1 corrected: 1" in res.output + assert "page 65 (block 1 page 1)" in res.output + assert json.loads(out.read_text())["records"][65][0] == 0xFF00 + # Default transfer: controller page engine, 8-bit ECC on a SPI NAND config. + xfer = NandXfer(fmc_cfg=compose_fmc_cfg(0x1821, EccType.BIT8)) + assert any(a[8:16] == xfer.pack() for op, a in agent.calls if op == 0x03) + + +def test_cli_dump_switches_ondie_ecc_off_and_back( + cli: CliRunner, agent: FakeNandAgent, tmp_path: Path, +) -> None: + agent.pages[1] = b"\x11" * STRIDE + out = tmp_path / "nand.bin" + res = cli.invoke(_app(), ["agent", "nand", "dump", "-o", str(out), "--count", "3"]) + assert res.exit_code == 0, res.output + assert out.read_bytes() == b"\xff" * STRIDE + b"\x11" * STRIDE + b"\xff" * STRIDE + sidecar = json.loads((tmp_path / "nand.bin.json").read_text()) + assert sidecar["pages"] == 3 and sidecar["mode"] == "reg" + assert sidecar["features"]["config_b0"] == 0x18 + sets = [a for op, a in agent.calls if op == 0x02] + assert sets == [bytes([0xB0, 0x08]), bytes([0xB0, 0x18])] # off for the dump, then back + # Raw dump: register reads, FMC_CFG untouched. + assert all(a[8:16] == NandXfer(mode=XferMode.REG).pack() + for op, a in agent.calls if op == 0x03) + + +def test_cli_write_page_kernel_oob(cli: CliRunner, agent: FakeNandAgent, tmp_path: Path) -> None: + data = tmp_path / "page.bin" + data.write_bytes(b"\x42" * PAGE) + res = cli.invoke(_app(), ["agent", "nand", "write-page", "5", "-i", str(data), + "--kernel-oob"]) + assert res.exit_code == 0, res.output + assert agent.pages[5] == b"\x42" * PAGE + kernel_oob(OOB) + res = cli.invoke(_app(), ["agent", "nand", "write-page", "5", "-i", str(data)]) + assert res.exit_code != 0 + + +def test_cobs_roundtrip_sanity() -> None: + """The fake decodes real frames, not a shortcut.""" + pkt = build_packet(CMD_NAND, b"\x00") + assert cobs.decode(pkt[:-1])[0] == CMD_NAND From e0f087ef430af4492964758d2f88d082b02ceca7 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:25:21 +0300 Subject: [PATCH 2/6] agent/nand: honest records, restored FMC_CFG, dumps that say when they failed Review (Qodo) of CMD_NAND: - A dump that hit a controller timeout wrote the failed page's stale buffer bytes, a sidecar, and exited 0. It now keeps only pages that read, marks the sidecar complete=false with the failed page, and exits 1. - The raw dump assumed the on-die ECC switched off; it now checks the feature read-back and refuses rather than label corrected data raw. - REG-mode reads could not tell a stalled FMC register op from success (fmc_wait_ready() just stopped polling). It now reports the timeout, and the page record carries NAND_REC_TIMEOUT. - Records sampled FMC_INT after the following status read had already cleared it and run its own op; FMC_INT is now captured right after the page op. - A DMA op's FMC_CFG outlived the request and changed how later CMD_READ, CRC32 and raw reads ran; handle_nand restores it after every page op (FMC_REG still writes on purpose). - CMD_CRC32 over registers now uses exactly CMD_READ's I/O windows and per-byte word extraction, so unaligned ranges and the V1 FMC/CRG/UART windows verify too. --- agent/main.c | 55 ++++++++++++++++++++++++++++++---------- agent/spi_flash.c | 44 ++++++++++++++++++++++---------- src/defib/cli/nand.py | 38 +++++++++++++++++++-------- tests/test_agent_nand.py | 34 +++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 37 deletions(-) diff --git a/agent/main.c b/agent/main.c index e1bc4e7..a683c90 100644 --- a/agent/main.c +++ b/agent/main.c @@ -190,6 +190,23 @@ static void handle_info(void) { proto_send(RSP_INFO, resp, 28); } +/* I/O registers require 32-bit word-aligned access (ldr not ldrb). + * RAM and flash can use byte access. Covers the V3+/V4+/V5/V6 peripheral + * block (0x10000000..0x13000000) as well as the V1-era regions actually + * used by V1 SoCs (FMC, CRG, UART). */ +static int is_io_region(uint32_t addr) { + return (addr >= 0x10000000 && addr < 0x13000000) + || (addr >= FMC_BASE && addr < FMC_BASE + 0x1000) + || (addr >= CRG_BASE && addr < CRG_BASE + 0x1000) + || (addr >= UART_BASE && addr < UART_BASE + 0x1000); +} + +/* Byte `addr` of an I/O region, fetched with a word load as CMD_READ does. */ +static uint8_t io_byte(uint32_t addr) { + uint32_t val = *(volatile uint32_t *)(addr & ~3u); + return (val >> ((addr & 3) * 8)) & 0xFF; +} + static void handle_read(const uint8_t *data, uint32_t len) { if (len < 8) { proto_send_ack(ACK_CRC_ERROR); return; } @@ -206,10 +223,7 @@ static void handle_read(const uint8_t *data, uint32_t len) { * RAM and flash can use byte access. Cover the V3+/V4+/V5/V6 * peripheral block (0x10000000..0x13000000) as well as the V1-era * regions actually used by V1 SoCs (FMC, CRG, UART). */ - int io_region = (addr >= 0x10000000 && addr < 0x13000000) - || (addr >= FMC_BASE && addr < FMC_BASE + 0x1000) - || (addr >= CRG_BASE && addr < CRG_BASE + 0x1000) - || (addr >= UART_BASE && addr < UART_BASE + 0x1000); + int io_region = is_io_region(addr); uint16_t seq = 0; uint32_t offset = 0; @@ -300,15 +314,13 @@ static void handle_crc32_cmd(const uint8_t *data, uint32_t len) { if (flash_readable && addr >= FLASH_MEM && (addr + size) <= (FLASH_MEM + flash_info.size)) { c = flash_crc32(addr - FLASH_MEM, size); - } else if (addr >= 0x10000000 && addr < 0x13000000) { - /* Peripheral registers: 32-bit reads only, as CMD_READ does them, - * or the CRC covers different bytes than a read returns. */ + } else if (is_io_region(addr)) { + /* Registers: the same word loads and byte order CMD_READ uses, or + * the CRC covers different bytes than a read returns. */ c = 0; - for (uint32_t off = 0; off < size; off += 4) { - uint32_t val = *(volatile uint32_t *)((addr + off) & ~3u); - uint8_t b[4] = { val & 0xff, (val >> 8) & 0xff, - (val >> 16) & 0xff, val >> 24 }; - c = crc32(c, b, size - off < 4 ? size - off : 4); + for (uint32_t off = 0; off < size; off++) { + uint8_t b = io_byte(addr + off); + c = crc32(c, &b, 1); } } else { const uint8_t *ptr = (const uint8_t *)addr; @@ -1249,6 +1261,9 @@ static void nand_put_rec(uint8_t *p, const nand_rec_t *r) { p[7] = r->rsvd; } +static void nand_study_op(uint8_t op, const uint8_t *arg, uint32_t alen, + uint32_t stride, uint32_t pages); + static void handle_nand(const uint8_t *data, uint32_t len) { if (len < 1) { proto_send_ack(ACK_CRC_ERROR); return; } uint8_t op = data[0]; @@ -1260,8 +1275,6 @@ static void handle_nand(const uint8_t *data, uint32_t len) { uint32_t stride = (uint32_t)g.page_size + g.oob_size; uint32_t pages = (uint32_t)g.pages_per_block * g.blocks; uint8_t out[36]; - nand_rec_t rec; - nand_xfer_t x; if (op == NAND_OP_FMC_REG) { if (alen < 7) { nand_reply(op, NAND_ST_BADARG, 0, 0); return; } @@ -1287,6 +1300,20 @@ static void handle_nand(const uint8_t *data, uint32_t len) { } if (!g.page_size) { nand_reply(op, NAND_ST_NOT_NAND, 0, 0); return; } + /* Page ops take FMC_CFG from the request; put it back afterwards so a + * DMA scan does not change how later CMD_READ, CRC32 or raw reads + * behave. */ + uint32_t saved_cfg = fmc_reg(0x00); + nand_study_op(op, arg, alen, stride, pages); + fmc_reg(0x00) = saved_cfg; +} + +static void nand_study_op(uint8_t op, const uint8_t *arg, uint32_t alen, + uint32_t stride, uint32_t pages) { + uint8_t out[36]; + nand_rec_t rec; + nand_xfer_t x; + switch (op) { case NAND_OP_FEATURE_GET: if (alen < 1) break; diff --git a/agent/spi_flash.c b/agent/spi_flash.c index e14dbcb..9efe976 100644 --- a/agent/spi_flash.c +++ b/agent/spi_flash.c @@ -149,7 +149,7 @@ static uint32_t detect_size(uint8_t id2) { } /* Forward declarations */ -static void fmc_wait_ready(void); +static int fmc_wait_ready(void); static void spi_wait_wip(void); /* Mode switching: normal mode for register commands, boot mode for reads */ @@ -215,10 +215,13 @@ static void fmc_enter_boot(void) { fmc_reg(FMC_INT_CLR) = 0xFF; } -static void fmc_wait_ready(void) { +/* Returns 0 when the register op finished, -1 if it was still running + * when the poll gave up. */ +static int fmc_wait_ready(void) { volatile uint32_t timeout = 400000; while ((fmc_reg(FMC_OP) & FMC_OP_REG_OP_START) && timeout > 0) timeout--; + return timeout ? 0 : -1; } static void spi_wait_wip(void) { @@ -591,6 +594,8 @@ static int nand_program_page(uint32_t row, uint32_t column, } /* Read up to NAND_PAGE_SIZE bytes from a NAND page (data area only). + * Returns the chip status after PAGE_READ, or 0xFF if the chip or an FMC + * register op never finished. * row = page index (0 .. flash_size/page_size - 1) * column = byte offset within the 2 KiB data area (0 .. NAND_PAGE_SIZE-1) * On-chip ECC is left at its power-on default (enabled on MX35LF*) so the @@ -603,13 +608,15 @@ static int nand_program_page(uint32_t row, uint32_t column, * requesting `chunk + 1` bytes per fetch and copying iobuf[1..chunk]. */ static uint8_t nand_read(uint32_t row, uint32_t column, uint8_t *buf, uint32_t len) { + int stalled = 0; + /* 1) PAGE_READ: load page from array into chip cache. */ fmc_reg(FMC_INT_CLR) = 0xFF; fmc_reg(FMC_CMD) = SPI_CMD_NAND_PAGE_READ; fmc_reg(FMC_ADDRL) = row; fmc_reg(FMC_OP_CFG) = OP_CFG_OEN_EN | OP_CFG_CS(0) | OP_CFG_ADDR_NUM(3); fmc_reg(FMC_OP) = FMC_OP_CMD1_EN | FMC_OP_ADDR_EN | FMC_OP_REG_OP_START; - fmc_wait_ready(); + stalled |= fmc_wait_ready(); /* 2) Wait for OIP=0 — chip finishes ECC correction and signals ready. * The status carries the on-die ECC verdict (ECC_S, bits 5:4). */ @@ -632,12 +639,14 @@ static uint8_t nand_read(uint32_t row, uint32_t column, | OP_CFG_ADDR_NUM(2) | OP_CFG_DUMMY_NUM(0); /* dummy is implicit in iobuf[0] */ fmc_reg(FMC_OP) = FMC_OP_CMD1_EN | FMC_OP_ADDR_EN | FMC_OP_READ_DATA | FMC_OP_REG_OP_START; - fmc_wait_ready(); + stalled |= fmc_wait_ready(); for (uint32_t i = 0; i < chunk; i++) buf[off + i] = iobuf[i + 1]; /* skip iobuf[0] = dummy */ off += chunk; } - return status; + /* A stalled register op means buf holds stale I/O-buffer bytes; report + * it the way nand_wait_oip() reports a chip that never finished. */ + return stalled ? 0xFF : status; } void flash_read(uint32_t addr, uint8_t *buf, uint32_t len) { @@ -940,9 +949,12 @@ static void nand_dma_set_addr(uint32_t page) { | ((in_block & DMA_ADDR_PAGE_MASK) << DMA_ADDR_PAGE_SHIFT); } -static void nand_rec_finish(nand_rec_t *rec, uint8_t status, int timed_out) { +/* `irq` is FMC_INT sampled right after the page op: the status read that + * follows clears it and runs a register op of its own. */ +static void nand_rec_finish(nand_rec_t *rec, uint8_t status, uint8_t irq, + int timed_out) { rec->ondie = status; - rec->fmc_int = (uint8_t)fmc_reg(FMC_INT); + rec->fmc_int = irq; rec->flags = 0; if (timed_out || status == 0xFF) rec->flags |= NAND_REC_TIMEOUT; rec->rsvd = 0; @@ -958,7 +970,7 @@ int nand_page_read(uint32_t page, const nand_xfer_t *x, uint8_t *dst, if (x->mode == NAND_XFER_REG) { uint8_t status = nand_read(page, 0, dst, total); rec->ecc_err = 0; - nand_rec_finish(rec, status, 0); + nand_rec_finish(rec, status, (uint8_t)fmc_reg(FMC_INT), 0); return (rec->flags & NAND_REC_TIMEOUT) ? -1 : 0; } @@ -977,8 +989,9 @@ int nand_page_read(uint32_t page, const nand_xfer_t *x, uint8_t *dst, | OP_CTRL_RW_OP(RW_OP_READ) | OP_CTRL_DMA_OP_READY; int timed_out = fmc_wait_dma_done(); + uint8_t irq = (uint8_t)fmc_reg(FMC_INT); rec->ecc_err = fmc_reg(FMC_ECC_ERR_NUM0_BUF0); - nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), timed_out); + nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), irq, timed_out); return (rec->flags & NAND_REC_TIMEOUT) ? -1 : 0; } @@ -992,8 +1005,10 @@ int nand_page_program(uint32_t page, const nand_xfer_t *x, if (x->fmc_cfg) fmc_reg(FMC_CFG) = x->fmc_cfg; rec->ecc_err = 0; + uint8_t irq; if (x->mode == NAND_XFER_REG) { nand_program_page(page, 0, src, total); + irq = (uint8_t)fmc_reg(FMC_INT); } else { /* As hifmc100_send_cmd_write(). */ nand_wait_oip(); @@ -1007,13 +1022,15 @@ int nand_page_program(uint32_t page, const nand_xfer_t *x, | OP_CTRL_DMA_OP(OP_TYPE_DMA) | OP_CTRL_RW_OP(RW_OP_WRITE) | OP_CTRL_DMA_OP_READY; - if (fmc_wait_dma_done() != 0) { - nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), 1); + int timed_out = fmc_wait_dma_done(); + irq = (uint8_t)fmc_reg(FMC_INT); + if (timed_out) { + nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), irq, 1); return -1; } } uint8_t status = nand_wait_oip(); - nand_rec_finish(rec, status, 0); + nand_rec_finish(rec, status, irq, 0); if (status != 0xFF && (status & NAND_STATUS_P_FAIL)) rec->flags |= NAND_REC_FAIL; return rec->flags ? -1 : 0; } @@ -1022,7 +1039,8 @@ int nand_block_erase(uint32_t page, nand_rec_t *rec) { if (!nand_geom.page_size) return -1; rec->ecc_err = 0; int rc = nand_erase_block(page); - nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), 0); + uint8_t irq = (uint8_t)fmc_reg(FMC_INT); + nand_rec_finish(rec, nand_get_feature(NAND_FEATURE_STATUS), irq, 0); if (rc) rec->flags |= NAND_REC_FAIL; return rc; } diff --git a/src/defib/cli/nand.py b/src/defib/cli/nand.py index f96be48..3bb7f36 100644 --- a/src/defib/cli/nand.py +++ b/src/defib/cli/nand.py @@ -267,36 +267,54 @@ async def body(study: Any) -> None: x = _xfer(study, mode, ecc, fmc_cfg, "0x03", 0, 1) feats = await _features(study) b0 = feats["config_b0"] - if not keep_ondie and b0 & 0x10: - await study.feature_set(FEATURE_CONFIG, b0 & ~0x10) + toggled = not keep_ondie and bool(b0 & 0x10) batch = max(1, info.dma_size // info.stride) records: list[Any] = [] + failed_page = None try: + if toggled: + got = await study.feature_set(FEATURE_CONFIG, b0 & ~0x10) + if got & 0x10: + Console(stderr=True).print( + f"[red]The chip kept its on-die ECC on (0xB0 reads 0x{got:02x}); " + "a dump now would not be raw.[/red] Use --keep-ondie-ecc to dump " + "corrected data anyway.") + raise typer.Exit(1) with open(output, "wb") as f: page = start while page < start + n: k = min(batch, start + n - page) recs, data = await study.read_pages(page, k, x) - f.write(data) - records.extend(recs) - page += len(recs) + # The agent stops after a failed page and still returns it; + # its bytes are whatever the buffer held, so keep them out. + good = len(recs) - 1 if recs and recs[-1].failed else len(recs) + f.write(data[:good * info.stride]) + records.extend(recs[:good]) + page += good print(f"\r{page - start}/{n} pages", end="", file=sys.stderr, flush=True) - if len(recs) < k: - print(f"\n[controller timeout at page {page}]", file=sys.stderr) + if good < k: + failed_page = page break finally: - if not keep_ondie and b0 & 0x10: + if toggled: await study.feature_set(FEATURE_CONFIG, b0) print(file=sys.stderr) sidecar = Path(output + ".json") sidecar.write_text(json.dumps({ - "start": start, "pages": len(records), "page_size": info.page_size, + "start": start, "pages": len(records), "requested": n, + "complete": failed_page is None, "failed_page": failed_page, + "page_size": info.page_size, "oob_size": info.oob_size, "pages_per_block": info.pages_per_block, "mode": mode, "fmc_cfg": x.fmc_cfg or info.fmc_cfg, - "features": feats, "ondie_ecc_during_dump": bool(keep_ondie and b0 & 0x10), + "features": feats, "ondie_ecc_during_dump": bool(b0 & 0x10) and not toggled, "records": [[r.ecc_err, r.ondie, r.fmc_int, r.flags] for r in records], })) print(f"{len(records)} pages x {info.stride} B -> {output} (+ {sidecar.name})") + if failed_page is not None: + Console(stderr=True).print( + f"[red]INCOMPLETE: page {failed_page} could not be read; " + f"{n - len(records)} of {n} pages are missing.[/red]") + raise typer.Exit(1) _run(port, body) diff --git a/tests/test_agent_nand.py b/tests/test_agent_nand.py index 4ea361b..8b17ac4 100644 --- a/tests/test_agent_nand.py +++ b/tests/test_agent_nand.py @@ -323,6 +323,40 @@ def test_cli_dump_switches_ondie_ecc_off_and_back( for op, a in agent.calls if op == 0x03) +def test_cli_dump_incomplete_is_an_error( + cli: CliRunner, agent: FakeNandAgent, tmp_path: Path, +) -> None: + agent.timeout_at = 2 + out = tmp_path / "nand.bin" + res = cli.invoke(_app(), ["agent", "nand", "dump", "-o", str(out), "--count", "5"]) + assert res.exit_code == 1 + assert "INCOMPLETE" in res.output + assert out.read_bytes() == b"\xff" * STRIDE * 2 # the failed page is not kept + sidecar = json.loads((tmp_path / "nand.bin.json").read_text()) + assert sidecar["complete"] is False and sidecar["failed_page"] == 2 + assert sidecar["pages"] == 2 + + +def test_cli_dump_refuses_when_ondie_ecc_stays_on( + cli: CliRunner, agent: FakeNandAgent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + original = FakeNandAgent._handle + + def sticky(self: FakeNandAgent, payload: bytes) -> None: + if payload[0] == 0x02: # FEATURE_SET ignored by the chip + self.calls.append((0x02, payload[1:])) + self._reply(0x02, 0, bytes([self.features[payload[1]]])) + return + original(self, payload) + + monkeypatch.setattr(FakeNandAgent, "_handle", sticky) + out = tmp_path / "nand.bin" + res = cli.invoke(_app(), ["agent", "nand", "dump", "-o", str(out), "--count", "2"]) + assert res.exit_code == 1 + assert "kept its on-die ECC on" in res.output + assert not any(op == 0x03 for op, _ in agent.calls) # nothing was read + + def test_cli_write_page_kernel_oob(cli: CliRunner, agent: FakeNandAgent, tmp_path: Path) -> None: data = tmp_path / "page.bin" data.write_bytes(b"\x42" * PAGE) From 86c51c4a4725eed3d53d47422753b05ee5f439e2 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:02:31 +0300 Subject: [PATCH 3/6] agent/nand: survive a dropped frame in a long dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 140 MB raw dump is ~265 one-megabyte reads over the UART. One sequence gap made read_memory raise while the agent kept streaming the rest of that megabyte; the dump's cleanup then sent FEATURE_SET, read a stale RSP_DATA as its answer, and that error replaced the real one — on hi3516ev300 after 23k of 65k pages. CMD_NAND calls now skip the tail of an abandoned read (RSP_DATA frames and the ACK that closes them; an ACK with no data before it is still a real answer), dump batches are retried up to three times after waiting for the line to go quiet, and restoring the on-die ECC setting can no longer mask the dump's own error. --- src/defib/agent/nand.py | 47 ++++++++++++++++++++++++++++++++++++++-- src/defib/cli/nand.py | 22 +++++++++++++++++-- tests/test_agent_nand.py | 17 +++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/defib/agent/nand.py b/src/defib/agent/nand.py index 13957ee..4ce6fe0 100644 --- a/src/defib/agent/nand.py +++ b/src/defib/agent/nand.py @@ -14,11 +14,21 @@ from __future__ import annotations import struct +import time from dataclasses import dataclass, field from enum import IntEnum from typing import TYPE_CHECKING -from defib.agent.protocol import CMD_NAND, RSP_NAND, recv_response, send_packet +from defib.agent.protocol import ( + CMD_NAND, + RSP_ACK, + RSP_DATA, + RSP_NAND, + recv_packet, + recv_response, + send_packet, +) +from defib.transport.base import TransportError, TransportTimeout if TYPE_CHECKING: from defib.agent.client import FlashAgentClient @@ -221,7 +231,25 @@ async def _call(self, op: int, args: bytes = b"", timeout: float = 5.0) -> bytes transport = self._client._transport self._client._clear_rx_buffers() await send_packet(transport, CMD_NAND, bytes([op]) + args) - cmd, data = await recv_response(transport, timeout=timeout) + deadline = time.monotonic() + timeout + in_stale_stream = False + while True: + try: + cmd, data = await recv_response( + transport, timeout=max(0.1, deadline - time.monotonic()), + ) + except TransportTimeout: + raise NandError(f"no answer to CMD_NAND op 0x{op:02x} within {timeout:.0f}s") + # The tail of a read the host gave up on: the agent finishes the + # stream regardless, then closes it with an ACK. An ACK with no + # data before it is a real answer (an agent without CMD_NAND). + if cmd == RSP_DATA: + in_stale_stream = True + continue + if cmd == RSP_ACK and in_stale_stream: + in_stale_stream = False + continue + break if cmd != RSP_NAND or len(data) < 2 or data[0] != op: raise NandError( f"agent did not answer CMD_NAND op 0x{op:02x} " @@ -237,6 +265,21 @@ async def _call(self, op: int, args: bytes = b"", timeout: float = 5.0) -> bytes self.last_status = status return data[2:] + async def resync(self, quiet: float = 1.0, limit: float = 60.0) -> None: + """Drop whatever the agent is still sending until the line is quiet. + + After the host abandons a read (a sequence gap, say), the agent + still streams the rest of it; this waits that out. + """ + transport = self._client._transport + end = time.monotonic() + limit + while time.monotonic() < end: + try: + await recv_packet(transport, quiet) + except (TransportTimeout, TransportError): + break + self._client._clear_rx_buffers() + async def info(self) -> NandInfo: raw = await self._call(NAND_OP_INFO) page, oob, ppb, blocks, dma, dma_size, stat, stat_size, cfg = struct.unpack( diff --git a/src/defib/cli/nand.py b/src/defib/cli/nand.py index 3bb7f36..c42d86f 100644 --- a/src/defib/cli/nand.py +++ b/src/defib/cli/nand.py @@ -243,6 +243,20 @@ async def body(study: Any) -> None: _run(port, body) +async def _read_batch(study: Any, page: int, count: int, xfer: Any, tries: int = 3) -> Any: + """read_pages with retries: a long UART stream occasionally drops a + frame, and one bad batch must not cost a half-hour dump.""" + for attempt in range(1, tries + 1): + try: + return await study.read_pages(page, count, xfer) + except Exception as e: # noqa: BLE001 - transport and protocol errors alike + if attempt == tries: + raise + print(f"\n[batch at page {page} failed ({e}); retrying]", file=sys.stderr) + await study.resync() + raise AssertionError("unreachable") + + @nand_app.command("dump") def nand_dump( output: str = typer.Option(..., "-o", "--output", help="Raw dump file (page + OOB per page)"), @@ -284,7 +298,7 @@ async def body(study: Any) -> None: page = start while page < start + n: k = min(batch, start + n - page) - recs, data = await study.read_pages(page, k, x) + recs, data = await _read_batch(study, page, k, x) # The agent stops after a failed page and still returns it; # its bytes are whatever the buffer held, so keep them out. good = len(recs) - 1 if recs and recs[-1].failed else len(recs) @@ -297,7 +311,11 @@ async def body(study: Any) -> None: break finally: if toggled: - await study.feature_set(FEATURE_CONFIG, b0) + try: + await study.feature_set(FEATURE_CONFIG, b0) + except Exception as e: # noqa: BLE001 - must not mask the dump's own error + Console(stderr=True).print( + f"[yellow]Could not restore feature 0xB0 to 0x{b0:02x}: {e}[/yellow]") print(file=sys.stderr) sidecar = Path(output + ".json") sidecar.write_text(json.dumps({ diff --git a/tests/test_agent_nand.py b/tests/test_agent_nand.py index 8b17ac4..d569d2a 100644 --- a/tests/test_agent_nand.py +++ b/tests/test_agent_nand.py @@ -268,6 +268,23 @@ def _handle(self, payload: bytes) -> None: await study.info() +async def test_stale_read_tail_is_skipped(agent: FakeNandAgent) -> None: + """A read the host abandoned keeps streaming; the next call must not + take its frames for the answer.""" + class Stale(FakeNandAgent): + first = True + + def _handle(self, payload: bytes) -> None: + if self.first: + self.first = False + self.enqueue_rx(build_packet(0x82, b"\x05\x00" + b"\xaa" * 64)) + self.enqueue_rx(build_packet(0x83, b"\x00")) + FakeNandAgent._handle(self, payload) + + study = NandStudy(FakeClient(Stale())) # type: ignore[arg-type] + assert (await study.info()).page_size == PAGE + + # --- CLI ------------------------------------------------------------------------ @pytest.fixture From 4e0ed315c5a17c32a36318b0a8af972ad4ee70b5 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:12:31 +0300 Subject: [PATCH 4/6] agent/nand: register-mode reads are raw only with controller ECC off With the SPI NAND interface selected in FMC_CFG, a non-zero ECC type also applies to register-path READ_FROM_CACHE data: the controller "corrects" it, and with an ECC type the page was not written with it flips bits that were right. Measured on a GK7205V510 + GD5F1GM7: register reads under FMC_CFG 0x1863 (24-bit) and 0x1823 (8-bit) differ from reads under 0x1803 (ECC off) by 1-6 bits on ~29% of pages, deterministically, while DMA reads with ECC off match the ECC-off register reads exactly. A raw dump taken after a DMA scan had left 0x1863 behind was therefore not raw. REG-mode page reads and programs now clear the ECC type for the op; handle_nand restores the caller's FMC_CFG afterwards as before. --- agent/spi_flash.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/agent/spi_flash.c b/agent/spi_flash.c index 9efe976..773b027 100644 --- a/agent/spi_flash.c +++ b/agent/spi_flash.c @@ -933,6 +933,15 @@ void nand_feature_set(uint8_t addr, uint8_t val) { nand_set_feature(addr, val); } +/* REG mode means raw. With the SPI NAND interface selected, a non-zero + * FMC_CFG ECC type also applies to register-path reads: the controller + * "corrects" READ_FROM_CACHE data, and with a mismatched ECC type it flips + * bits that were right (measured on GD5F1GM7: 1-6 bits on ~29% of pages). + * The caller's FMC_CFG is restored by handle_nand after the op. */ +static void nand_reg_raw(void) { + fmc_reg(FMC_CFG) = fmc_reg(FMC_CFG) & ~(7u << 5); +} + static int fmc_wait_dma_done(void) { for (uint32_t i = 0; i < 4000000; i++) { if (fmc_reg(FMC_INT) & FMC_INT_OP_DONE_BIT) return 0; @@ -968,6 +977,7 @@ int nand_page_read(uint32_t page, const nand_xfer_t *x, uint8_t *dst, if (x->fmc_cfg) fmc_reg(FMC_CFG) = x->fmc_cfg; if (x->mode == NAND_XFER_REG) { + nand_reg_raw(); uint8_t status = nand_read(page, 0, dst, total); rec->ecc_err = 0; nand_rec_finish(rec, status, (uint8_t)fmc_reg(FMC_INT), 0); @@ -1007,6 +1017,7 @@ int nand_page_program(uint32_t page, const nand_xfer_t *x, uint8_t irq; if (x->mode == NAND_XFER_REG) { + nand_reg_raw(); nand_program_page(page, 0, src, total); irq = (uint8_t)fmc_reg(FMC_INT); } else { From 59214bad919134a537b325933b902fcb03475805 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:40:57 +0300 Subject: [PATCH 5/6] agent: a silent agent no longer hangs the host; NAND calls resync Fault-injected on a hi3516ev300 (agent at 921600, host forced to 115200) after real dumps on two boards died the same way: - _recv_packet_sync() relied on the port's read timeout to recheck its deadline, but SerialTransport opens with timeout=None, so read(1) blocked until a byte arrived: with the agent silent (wrong baud, crashed), every agent command on a plain serial port waited forever, whatever timeout it was given. It now polls a blocking port. The existing timeout test missed it because its fake port never blocked; a new one does. - recv_response() restarts its timeout on every READY, so a request lost to a baud mismatch kept CMD_NAND waiting forever once the agent idled back to 115200 and started announcing itself. _call() now uses recv_packet() against its own deadline and skips READY itself, as well as any other leftover frame (a late answer to an earlier request, the tail of an abandoned read). - NandStudy.resync() treats READY as quiet, re-probes the agent at the current rate, falls back to 115200 + READY when it does not answer, and leaves the client's idea of the baud matching the port. On hardware the forced desync now recovers in 1.1 s instead of hanging. --- src/defib/agent/nand.py | 58 ++++++++++++++++++++++++++++-------- src/defib/agent/protocol.py | 7 +++++ tests/test_agent_nand.py | 1 + tests/test_agent_protocol.py | 15 ++++++++++ 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/defib/agent/nand.py b/src/defib/agent/nand.py index 4ce6fe0..00d9458 100644 --- a/src/defib/agent/nand.py +++ b/src/defib/agent/nand.py @@ -24,8 +24,8 @@ RSP_ACK, RSP_DATA, RSP_NAND, + RSP_READY, recv_packet, - recv_response, send_packet, ) from defib.transport.base import TransportError, TransportTimeout @@ -234,22 +234,31 @@ async def _call(self, op: int, args: bytes = b"", timeout: float = 5.0) -> bytes deadline = time.monotonic() + timeout in_stale_stream = False while True: + if time.monotonic() > deadline: + raise NandError(f"no answer to CMD_NAND op 0x{op:02x} within {timeout:.0f}s") + # recv_packet, not recv_response: the latter restarts its timeout + # on every READY, so an idle agent that never got this request + # (lost to a baud mismatch, say) would keep it waiting forever. try: - cmd, data = await recv_response( - transport, timeout=max(0.1, deadline - time.monotonic()), + cmd, data = await recv_packet( + transport, max(0.1, deadline - time.monotonic()), ) except TransportTimeout: raise NandError(f"no answer to CMD_NAND op 0x{op:02x} within {timeout:.0f}s") - # The tail of a read the host gave up on: the agent finishes the - # stream regardless, then closes it with an ACK. An ACK with no - # data before it is a real answer (an agent without CMD_NAND). + if cmd == RSP_READY: + continue + if cmd == RSP_NAND and len(data) >= 2 and data[0] == op: + break + # Anything else is left over from an exchange the host gave up + # on — the tail of an abandoned read (RSP_DATA ... ACK) or a + # late answer to an earlier request. Only an ACK with no data + # before it is a real answer: an agent without CMD_NAND. if cmd == RSP_DATA: in_stale_stream = True continue - if cmd == RSP_ACK and in_stale_stream: - in_stale_stream = False - continue - break + if cmd == RSP_ACK and not in_stale_stream: + break + in_stale_stream = False if cmd != RSP_NAND or len(data) < 2 or data[0] != op: raise NandError( f"agent did not answer CMD_NAND op 0x{op:02x} " @@ -273,12 +282,37 @@ async def resync(self, quiet: float = 1.0, limit: float = 60.0) -> None: """ transport = self._client._transport end = time.monotonic() + limit - while time.monotonic() < end: + quiet_since = time.monotonic() + while time.monotonic() < end and time.monotonic() - quiet_since < quiet: try: - await recv_packet(transport, quiet) + cmd, _ = await recv_packet(transport, quiet) except (TransportTimeout, TransportError): break + # An idle agent's periodic READY is not part of a stale stream. + if cmd != RSP_READY: + quiet_since = time.monotonic() + self._client._clear_rx_buffers() + # The link may have lost its fast baud along the way (on both DUTs a + # read that broke at 921600 left the agent answering only after it + # had idled back to 115200). If it does not answer where we are, + # go to the fallback rate and wait for its READY. + try: + await self._call(NAND_OP_INFO, timeout=3.0) + # It answered at whatever rate the port is at now; make the + # client agree, so the next fast read renegotiates if needed. + port = getattr(transport, "_port", None) + if port is not None and getattr(port, "baudrate", None): + self._client._current_baud = int(port.baudrate) + return + except (NandError, TransportError): + pass + from defib.agent.client import FALLBACK_BAUD + from defib.agent.protocol import wait_for_ready + await transport.set_baudrate(FALLBACK_BAUD) + self._client._current_baud = FALLBACK_BAUD self._client._clear_rx_buffers() + if not await wait_for_ready(transport, 45.0): + raise NandError("agent lost after a failed transfer; no READY at the fallback baud") async def info(self) -> NandInfo: raw = await self._call(NAND_OP_INFO) diff --git a/src/defib/agent/protocol.py b/src/defib/agent/protocol.py index de500f8..6dacee6 100644 --- a/src/defib/agent/protocol.py +++ b/src/defib/agent/protocol.py @@ -147,6 +147,13 @@ def _recv_packet_sync(port: object, timeout: float) -> tuple[int, bytes]: waiting = port.in_waiting # type: ignore[attr-defined] if waiting > 0: data = port.read(waiting) # type: ignore[attr-defined] + elif getattr(port, "timeout", None) is None: + # A blocking port (SerialTransport opens with timeout=None): + # read(1) would wait for a byte forever, so a silent agent + # — wrong baud, crashed — would hang every caller past its + # timeout. Poll instead. + time.sleep(0.002) + continue else: # port.read() blocks up to the port's pre-set timeout # (Rfc2217Transport._PYSERIAL_READ_QUANTUM = 10 ms). diff --git a/tests/test_agent_nand.py b/tests/test_agent_nand.py index d569d2a..4e5f506 100644 --- a/tests/test_agent_nand.py +++ b/tests/test_agent_nand.py @@ -279,6 +279,7 @@ def _handle(self, payload: bytes) -> None: self.first = False self.enqueue_rx(build_packet(0x82, b"\x05\x00" + b"\xaa" * 64)) self.enqueue_rx(build_packet(0x83, b"\x00")) + self.enqueue_rx(build_packet(0x81, b"\x00" * 28)) # late INFO answer FakeNandAgent._handle(self, payload) study = NandStudy(FakeClient(Stale())) # type: ignore[arg-type] diff --git a/tests/test_agent_protocol.py b/tests/test_agent_protocol.py index c9bdb1f..e1d8342 100644 --- a/tests/test_agent_protocol.py +++ b/tests/test_agent_protocol.py @@ -160,6 +160,21 @@ def test_timeout_raises(self): with pytest.raises(TransportTimeout): _recv_packet_sync(port, timeout=0.1) + def test_timeout_on_blocking_port_with_silent_agent(self): + """SerialTransport opens with timeout=None, so read() blocks until a + byte arrives; a silent agent must still time out, not hang.""" + class BlockingPort(FakePort): + def read(self, size: int = 1) -> bytes: + if not self._rx and self.timeout is None: + raise AssertionError("read() on an empty blocking port never returns") + return super().read(size) + + import time + t0 = time.monotonic() + with pytest.raises(TransportTimeout): + _recv_packet_sync(BlockingPort(b""), timeout=0.2) + assert time.monotonic() - t0 < 1.0 + def test_oversized_frame_discarded(self): # Frame larger than MAX_PACKET_SIZE should be discarded huge = bytes(range(1, 256)) * 5 # >1100 bytes, no 0x00 From 5e217cf8e29e1330fc269f6757c938f27b06fd69 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:57:36 +0300 Subject: [PATCH 6/6] agent/nand: retry a failed dump batch at the fallback baud On a loaded host the FT232R link at 921600 dropped frames three times in a row on the same batch, and identical retries failed identically. A retry now moves the data at 115200 (restoring it first if the link is still fast); slow, but it gets the batch. --- src/defib/agent/nand.py | 7 ++++++- src/defib/cli/nand.py | 8 +++++--- tests/test_agent_nand.py | 3 +++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/defib/agent/nand.py b/src/defib/agent/nand.py index 00d9458..a761a63 100644 --- a/src/defib/agent/nand.py +++ b/src/defib/agent/nand.py @@ -339,6 +339,7 @@ async def fmc_reg(self, offset: int, value: int | None = None) -> int: async def read_pages( self, start: int, count: int, xfer: NandXfer, store: bool = True, + fast: bool = True, ) -> tuple[list[NandRecord], bytes]: """Read ``count`` pages from ``start``. @@ -357,7 +358,11 @@ async def read_pages( ] data = b"" if store and done: - data = await self._client.read_memory(info.dma_buf, done * info.stride) + if not fast: + await self._client._restore_baud() + data = await self._client.read_memory( + info.dma_buf, done * info.stride, fast=fast, + ) return records, data async def read_page(self, page: int, xfer: NandXfer) -> PageRead: diff --git a/src/defib/cli/nand.py b/src/defib/cli/nand.py index c42d86f..b4dd701 100644 --- a/src/defib/cli/nand.py +++ b/src/defib/cli/nand.py @@ -243,12 +243,14 @@ async def body(study: Any) -> None: _run(port, body) -async def _read_batch(study: Any, page: int, count: int, xfer: Any, tries: int = 3) -> Any: +async def _read_batch(study: Any, page: int, count: int, xfer: Any, tries: int = 4) -> Any: """read_pages with retries: a long UART stream occasionally drops a - frame, and one bad batch must not cost a half-hour dump.""" + frame, and one bad batch must not cost a half-hour dump. Retries move + the data at the fallback baud: when 921600 drops frames once, it + tends to keep doing so while whatever loads the host lasts.""" for attempt in range(1, tries + 1): try: - return await study.read_pages(page, count, xfer) + return await study.read_pages(page, count, xfer, fast=attempt == 1) except Exception as e: # noqa: BLE001 - transport and protocol errors alike if attempt == tries: raise diff --git a/tests/test_agent_nand.py b/tests/test_agent_nand.py index 4e5f506..9837d8a 100644 --- a/tests/test_agent_nand.py +++ b/tests/test_agent_nand.py @@ -133,6 +133,9 @@ def __init__(self, agent: FakeNandAgent) -> None: def _clear_rx_buffers(self) -> None: pass + async def _restore_baud(self) -> None: + pass + async def read_memory(self, addr: int, size: int, fast: bool = True) -> bytes: return self.agent.ram[addr][:size]