Skip to content

Instantly share code, notes, and snippets.

@Creased
Last active July 26, 2026 23:06
Show Gist options
  • Select an option

  • Save Creased/b98e02bb0a4904b8023b8a6837977ab9 to your computer and use it in GitHub Desktop.

Select an option

Save Creased/b98e02bb0a4904b8023b8a6837977ab9 to your computer and use it in GitHub Desktop.
eMMC checking script for Nintendo Switch eMMC module
#!/usr/bin/env python3
"""
emmcheck - qualification bench for eMMC modules.
Reads identity and health registers, verifies the partition layout, profiles
read throughput across the whole address space, and isolates unreadable
sectors down to the LBA. Knows the Nintendo Switch eMMC layout.
Read-only unless you pass --write-test.
Requires: python3, rich, root. Optional: mmc-utils (for EXT_CSD health).
"""
from __future__ import annotations
import argparse
import json
import mmap
import os
import random
import shutil
import subprocess
import sys
import time
import zlib
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
try:
from rich.align import Align
from rich.console import Console, Group
from rich.panel import Panel
from rich.progress import (
BarColumn, Progress, SpinnerColumn, TextColumn,
TimeElapsedColumn, TimeRemainingColumn,
)
from rich.rule import Rule
from rich.table import Table
from rich.text import Text
except ImportError:
sys.exit("emmcheck needs the 'rich' library.\n pip install rich (or: apt install python3-rich)")
console = Console()
SECTOR = 512
MIB = 1024 * 1024
GIB = 1024 * 1024 * 1024
# ---------------------------------------------------------------------------
# Reference data
# ---------------------------------------------------------------------------
# JEDEC MID values seen in the wild. Unlisted IDs are reported as raw hex.
MANFIDS = {
0x02: "SanDisk",
0x11: "Toshiba / Kioxia",
0x13: "Micron",
0x15: "Samsung",
0x2C: "Kingston",
0x45: "SanDisk",
0x70: "Kingston",
0x88: "Foresee (Longsys)",
0x90: "SK Hynix",
0x9B: "YMTC",
0xD6: "Foresee (Longsys)",
0xFE: "Micron",
}
# Nintendo Switch internal eMMC GPT, in order. Sizes are MiB, approximate.
SWITCH_LAYOUT = [
("PRODINFO", 4),
("PRODINFOF", 4),
("BCPKG2-1-Normal-Main", 8),
("BCPKG2-2-Normal-Sub", 8),
("BCPKG2-3-SafeMode-Main", 8),
("BCPKG2-4-SafeMode-Sub", 8),
("BCPKG2-5-Repair-Main", 8),
("BCPKG2-6-Repair-Sub", 8),
("SAFE", 64),
("SYSTEM", 2560),
("USER", 26000), # varies with total capacity; matched loosely
]
PRE_EOL = {
0x00: ("Not supported", "dim", 0),
0x01: ("Normal - under 80% of reserve blocks used", "green", 0),
0x02: ("WARNING - 80% of reserve blocks used", "yellow", 1),
0x03: ("URGENT - 90% of reserve blocks used", "red", 2),
}
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def human(n: float) -> str:
for unit, div in (("GiB", GIB), ("MiB", MIB), ("KiB", 1024)):
if n >= div:
return f"{n / div:.2f} {unit}"
return f"{int(n)} B"
def read_sysfs(path: str) -> str | None:
try:
return Path(path).read_text().strip()
except (OSError, UnicodeDecodeError):
return None
def have(binary: str) -> bool:
return shutil.which(binary) is not None
# ---------------------------------------------------------------------------
# Device discovery
# ---------------------------------------------------------------------------
@dataclass
class Partition:
name: str
label: str | None
start_lba: int
sectors: int
@property
def size(self) -> int:
return self.sectors * SECTOR
def contains(self, byte_offset: int) -> bool:
lo = self.start_lba * SECTOR
return lo <= byte_offset < lo + self.size
@dataclass
class Device:
name: str # mmcblk1
path: str # /dev/mmcblk1
size: int = 0
partitions: list[Partition] = field(default_factory=list)
mounted: list[str] = field(default_factory=list)
is_file: bool = False
# identity
manfid: int | None = None
oemid: int | None = None
model: str | None = None
serial: str | None = None
fwrev: str | None = None
hwrev: str | None = None
date: str | None = None
card_type: str | None = None
cid: str | None = None
csd: str | None = None
# health
life_a: int | None = None
life_b: int | None = None
pre_eol: int | None = None
ext_csd_rev: str | None = None
health_source: str = "none"
# link
bus_width: str | None = None
clock: str | None = None
timing: str | None = None
host: str | None = None
driver: str | None = None
transport: str = "unknown" # usb2 | usb | native
usb_speed: str | None = None
usb_id: str | None = None
@property
def vendor(self) -> str:
if self.manfid is None:
return "unknown"
return MANFIDS.get(self.manfid, f"unrecognised (0x{self.manfid:02x})")
@property
def link_ceiling_mbps(self) -> float | None:
"""Theoretical MB/s of the negotiated MMC bus: clock x width / 8.
On a card reader this is usually a harder limit than USB, because the
reader negotiates plain high-speed 4-bit rather than HS200/HS400.
"""
if not self.clock or not self.bus_width:
return None
try:
hz = float(self.clock.split()[0])
except (ValueError, IndexError):
return None
bits = 1
for token in self.bus_width.replace("(", " ").split():
if token.isdigit() and int(token) in (1, 4, 8):
bits = int(token)
return hz * bits / 8 / 1_000_000
def partition_at(self, byte_offset: int) -> str:
for p in self.partitions:
if p.contains(byte_offset):
return p.label or p.name
return "(unpartitioned)"
def natural_key(name: str) -> tuple:
"""Sort block device names by trailing number, not lexicographically.
Plain sorted() orders mmcblk1p10 immediately after mmcblk1p1, which
silently scrambles the partition table against any expected layout.
"""
head = name.rstrip("0123456789")
tail = name[len(head):]
return (head, int(tail) if tail else -1)
def list_mmc_devices() -> list[Device]:
devs = []
names = [p.name for p in Path("/sys/block").glob("mmcblk*")]
for name in sorted(names, key=natural_key):
# skip the boot0/boot1/rpmb hardware partitions, they are separate nodes
if any(s in name for s in ("boot", "rpmb", "gp")):
continue
devs.append(load_device(f"/dev/{name}"))
return devs
def load_device(path: str) -> Device:
name = os.path.basename(path)
dev = Device(name=name, path=path)
if not Path(f"/sys/block/{name}").exists():
# allow pointing at an image file for offline testing
dev.is_file = True
dev.size = os.path.getsize(path) if os.path.exists(path) else 0
return dev
base = f"/sys/block/{name}"
nsec = read_sysfs(f"{base}/size")
dev.size = int(nsec) * SECTOR if nsec else 0
# partitions, ordered by GPT entry number
part_dirs = sorted(Path(base).glob(f"{name}p*"),
key=lambda p: natural_key(p.name))
for part in part_dirs:
start = read_sysfs(f"{part}/start")
size = read_sysfs(f"{part}/size")
if start is None or size is None:
continue
dev.partitions.append(Partition(part.name, None, int(start), int(size)))
_attach_labels(dev)
# mount check
try:
for line in Path("/proc/mounts").read_text().splitlines():
src = line.split()[0]
if src.startswith(f"/dev/{name}"):
dev.mounted.append(line.split()[1])
except OSError:
pass
# identity from sysfs
d = f"{base}/device"
dev.model = read_sysfs(f"{d}/name")
dev.serial = read_sysfs(f"{d}/serial")
dev.fwrev = read_sysfs(f"{d}/fwrev")
dev.hwrev = read_sysfs(f"{d}/hwrev")
dev.date = read_sysfs(f"{d}/date")
dev.card_type = read_sysfs(f"{d}/type")
dev.cid = read_sysfs(f"{d}/cid")
dev.csd = read_sysfs(f"{d}/csd")
for attr, key in (("manfid", "manfid"), ("oemid", "oemid")):
raw = read_sysfs(f"{d}/{attr}")
if raw:
try:
setattr(dev, key, int(raw, 16))
except ValueError:
pass
# health: sysfs first (cheap), EXT_CSD later (authoritative)
for attr, key in (("life_time", None), ("pre_eol_info", "pre_eol")):
raw = read_sysfs(f"{d}/{attr}")
if not raw:
continue
if attr == "life_time":
parts = raw.split()
try:
dev.life_a = int(parts[0], 16)
dev.life_b = int(parts[1], 16)
dev.health_source = "sysfs"
except (ValueError, IndexError):
pass
else:
try:
dev.pre_eol = int(raw, 16)
dev.health_source = "sysfs"
except ValueError:
pass
# link parameters
try:
real = os.path.realpath(f"{base}/device")
host = None
for part in Path(real).parts:
if part.startswith("mmc") and ":" not in part:
host = part
dev.host = host
if host:
ios = read_sysfs(f"/sys/kernel/debug/{host}/ios")
if ios:
for line in ios.splitlines():
if ":" not in line:
continue
k, v = (x.strip() for x in line.split(":", 1))
kl = k.lower()
if kl == "clock":
dev.clock = v
elif kl == "bus width":
dev.bus_width = v
elif kl == "timing spec":
dev.timing = v
# driver name
drv = os.path.realpath(f"{real}/../driver") if os.path.exists(f"{real}/../driver") else None
if drv:
dev.driver = os.path.basename(drv)
_detect_transport(dev, real)
except OSError:
pass
return dev
def _detect_transport(dev: Device, real_path: str) -> None:
"""Walk up the sysfs tree to find the USB device this card reader sits on.
The negotiated USB speed is the real ceiling on sequential throughput, so
it belongs in the report next to the benchmark numbers.
"""
node = Path(real_path)
for parent in [node] + list(node.parents):
if (parent / "idVendor").exists() and (parent / "speed").exists():
speed = read_sysfs(str(parent / "speed"))
vid = read_sysfs(str(parent / "idVendor"))
pid = read_sysfs(str(parent / "idProduct"))
dev.usb_speed = speed
if vid and pid:
dev.usb_id = f"{vid}:{pid}"
try:
mbit = float(speed) if speed else 0.0
except ValueError:
mbit = 0.0
dev.transport = "usb2" if 0 < mbit <= 480 else "usb"
return
if parent.name == "sys":
break
dev.transport = "native"
def _attach_labels(dev: Device) -> None:
"""Pull GPT partition labels via lsblk if available."""
if not have("lsblk"):
return
try:
out = subprocess.run(
["lsblk", "-J", "-b", "-o", "NAME,PARTLABEL", dev.path],
capture_output=True, text=True, timeout=10,
)
if out.returncode != 0:
return
data = json.loads(out.stdout)
labels = {}
for node in data.get("blockdevices", []):
for child in node.get("children", []) or []:
labels[child.get("name")] = child.get("partlabel") or None
for p in dev.partitions:
p.label = labels.get(p.name)
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
pass
def read_extcsd(dev: Device) -> None:
"""Authoritative health read via mmc-utils. Sysfs can be a stale snapshot
from card init on older kernels, so prefer this when available."""
if dev.is_file or not have("mmc"):
return
try:
out = subprocess.run(["mmc", "extcsd", "read", dev.path],
capture_output=True, text=True, timeout=30)
if out.returncode != 0:
return
except (subprocess.SubprocessError, OSError):
return
for line in out.stdout.splitlines():
stripped = line.strip()
if stripped.startswith("Extended CSD rev"):
dev.ext_csd_rev = stripped.replace("Extended CSD rev", "").strip()
continue
if ":" not in line:
continue
key, _, val = line.partition(":")
val = val.strip()
try:
num = int(val, 16) if val.startswith("0x") else None
except ValueError:
num = None
if "DEVICE_LIFE_TIME_EST_TYP_A" in key and num is not None:
dev.life_a = num
dev.health_source = "EXT_CSD"
elif "DEVICE_LIFE_TIME_EST_TYP_B" in key and num is not None:
dev.life_b = num
dev.health_source = "EXT_CSD"
elif "PRE_EOL_INFO" in key and num is not None:
dev.pre_eol = num
dev.health_source = "EXT_CSD"
# ---------------------------------------------------------------------------
# eMMC hardware partitions (boot0/boot1/rpmb/gp)
# ---------------------------------------------------------------------------
@dataclass
class HwPartition:
name: str
path: str
size: int
force_ro: bool | None = None
readable: bool = False
errors: list[int] = field(default_factory=list)
mbps: float = 0.0
note: str = ""
def discover_hw_partitions(dev: Device) -> list[HwPartition]:
"""Boot and RPMB areas are separate address spaces with their own nodes.
Nothing in a user-area scan reaches them, so a module can pass a full
surface scan and still hold an unreadable bootloader.
"""
out = []
if dev.is_file:
return out
for suffix in ("boot0", "boot1", "rpmb", "gp0", "gp1", "gp2", "gp3"):
name = f"{dev.name}{suffix}"
base = Path(f"/sys/block/{name}")
if not base.exists():
continue
nsec = read_sysfs(f"{base}/size")
size = int(nsec) * SECTOR if nsec else 0
ro = read_sysfs(f"{base}/force_ro")
out.append(HwPartition(
name=suffix, path=f"/dev/{name}", size=size,
force_ro=(ro == "1") if ro is not None else None))
return out
def scan_hw_partitions(parts: list[HwPartition], progress: Progress,
task) -> None:
"""Read each hardware partition end to end. They are a few MiB at most."""
for p in parts:
if p.size == 0:
p.note = "zero length"
progress.update(task, advance=1)
continue
if p.name == "rpmb":
# RPMB is not a plain block device; it answers only to the
# authenticated protocol, so a raw read proves nothing either way
p.note = "authenticated access only, not raw-readable"
progress.update(task, advance=1)
continue
chunk = min(MIB, p.size)
total, elapsed = 0, 0.0
try:
with RawReader(p.path, chunk) as r:
for off in range(0, (p.size // chunk) * chunk, chunk):
t0 = time.perf_counter()
try:
n, _ = r.read(off, chunk)
elapsed += time.perf_counter() - t0
total += n
except OSError:
p.errors.extend(isolate_bad_sectors(r, off, chunk))
p.readable = not p.errors
p.mbps = (total / MIB / elapsed) if elapsed > 0 else 0.0
except OSError as e:
p.note = f"could not open ({e.strerror or e})"
progress.update(task, advance=1)
def hw_partition_panel(dev: Device, parts: list[HwPartition],
is_switch: bool) -> Panel | None:
if not parts:
return None
t = Table(box=None, padding=(0, 2), show_edge=False)
t.add_column("area")
t.add_column("size", justify="right")
t.add_column("write", justify="left")
t.add_column("read", justify="right")
t.add_column("")
problems = 0
for p in parts:
wp = "-" if p.force_ro is None else ("protected" if p.force_ro else
"[yellow]writable[/]")
if p.errors:
status, rate = f"[red]{len(p.errors)} bad sector(s)[/]", "-"
problems += 1
elif p.readable:
status, rate = "[green]ok[/]", f"{p.mbps:.1f} MB/s"
else:
status, rate = f"[dim]{p.note or 'not tested'}[/]", "-"
t.add_row(p.name, human(p.size) if p.size else "-", wp, rate, status)
body: list = [t]
if is_switch:
boots = {p.name: p for p in parts if p.name in ("boot0", "boot1")}
missing = [b for b in ("boot0", "boot1") if b not in boots]
if missing:
body.append(Text())
body.append(Text(f" • {', '.join(missing)} absent - a Switch module "
"should expose both boot areas", style="yellow"))
for name, p in boots.items():
if p.size and abs(p.size - 4 * MIB) > 64 * 1024:
body.append(Text())
body.append(Text(f" • {name} is {human(p.size)}, not the "
"expected 4 MiB", style="yellow"))
border = "red" if problems else "green"
return Panel(Group(*body), title="[bold]hardware partitions[/]",
border_style=border)
# ---------------------------------------------------------------------------
# GPT
# ---------------------------------------------------------------------------
GPT_SIG = b"EFI PART"
@dataclass
class GptInfo:
present: bool = False
primary_ok: bool = False
backup_ok: bool = False
disk_guid: str | None = None
backup_disk_guid: str | None = None
names: dict[int, str] = field(default_factory=dict) # first_lba -> name
problems: list[str] = field(default_factory=list)
def _guid(raw: bytes) -> str:
"""Format a GPT GUID: first three fields little-endian, last two big."""
a = int.from_bytes(raw[0:4], "little")
b = int.from_bytes(raw[4:6], "little")
c = int.from_bytes(raw[6:8], "little")
return (f"{a:08X}-{b:04X}-{c:04X}-"
f"{raw[8:10].hex().upper()}-{raw[10:16].hex().upper()}")
def _parse_gpt_header(buf: bytes) -> tuple[dict, bool] | None:
if len(buf) < 92 or buf[:8] != GPT_SIG:
return None
hdr_size = int.from_bytes(buf[12:16], "little")
if not (92 <= hdr_size <= len(buf)):
return None
stored = int.from_bytes(buf[16:20], "little")
tmp = bytearray(buf[:hdr_size])
tmp[16:20] = b"\x00\x00\x00\x00"
crc_ok = (zlib.crc32(bytes(tmp)) & 0xFFFFFFFF) == stored
return ({
"my_lba": int.from_bytes(buf[24:32], "little"),
"alt_lba": int.from_bytes(buf[32:40], "little"),
"first_usable": int.from_bytes(buf[40:48], "little"),
"last_usable": int.from_bytes(buf[48:56], "little"),
"disk_guid": _guid(buf[56:72]),
"entries_lba": int.from_bytes(buf[72:80], "little"),
"entry_count": int.from_bytes(buf[80:84], "little"),
"entry_size": int.from_bytes(buf[84:88], "little"),
"entries_crc": int.from_bytes(buf[88:92], "little"),
}, crc_ok)
def parse_gpt(path: str, size: int) -> GptInfo:
"""Read the GPT from the device without going through libblkid.
libblkid refuses to report partition labels when a GPT fails its
validation, so a disagreement between the primary and backup copies
silently costs us the partition names. Reading it directly keeps the
names and turns the disagreement into a reported finding.
"""
info = GptInfo()
try:
fd = os.open(path, os.O_RDONLY)
except OSError:
return info
def pread(off: int, length: int) -> bytes:
try:
return os.pread(fd, length, off)
except OSError:
return b""
try:
head = _parse_gpt_header(pread(SECTOR, SECTOR))
if head is None:
return info
hdr, crc_ok = head
info.present = True
info.primary_ok = crc_ok
info.disk_guid = hdr["disk_guid"]
if not crc_ok:
info.problems.append("primary GPT header CRC does not match")
count = min(hdr["entry_count"], 256)
esize = hdr["entry_size"]
if 0 < esize <= 1024 and count:
blob = pread(hdr["entries_lba"] * SECTOR, count * esize)
if len(blob) == count * esize:
if (zlib.crc32(blob) & 0xFFFFFFFF) != hdr["entries_crc"]:
info.problems.append("partition entry array CRC does not match")
for i in range(count):
e = blob[i * esize:(i + 1) * esize]
if e[0:16] == b"\x00" * 16:
continue
first = int.from_bytes(e[32:40], "little")
name = e[56:128].decode("utf-16-le", "replace").rstrip("\x00")
if name:
info.names[first] = name
else:
info.problems.append("could not read the partition entry array")
# backup header sits in the last sector
last_lba = size // SECTOR - 1
back = _parse_gpt_header(pread(last_lba * SECTOR, SECTOR))
if back is None:
info.problems.append(
"backup GPT missing or unreadable at the end of the device")
else:
bhdr, bcrc = back
info.backup_ok = bcrc
info.backup_disk_guid = bhdr["disk_guid"]
if not bcrc:
info.problems.append("backup GPT header CRC does not match")
if bhdr["disk_guid"] != hdr["disk_guid"]:
info.problems.append(
"primary and backup GPT report different disk GUIDs - the "
"two copies came from different images")
if hdr["alt_lba"] != last_lba:
info.problems.append(
f"primary GPT expects its backup at LBA {hdr['alt_lba']:,} "
f"but the device ends at {last_lba:,}")
finally:
os.close(fd)
return info
def load_history(path: str) -> list[dict]:
"""Prior runs from the JSON log, for cross-module and repeat-test checks."""
records = []
try:
with open(path) as fh:
for line in fh:
line = line.strip()
if line:
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
pass
return records
def history_findings(dev: Device, gpt: GptInfo,
history: list[dict]) -> list[str]:
"""Compare this module against everything the bench has seen before.
Two things matter across a batch: whether this exact part has been tested
before (so wear can be tracked over time), and whether its partition table
is a clone of another part's (so duplicated console data gets noticed
before the module is deployed).
"""
out: list[str] = []
if not history:
return out
serial = dev.serial
if serial:
prior = [r for r in history
if (r.get("device") or {}).get("serial") == serial]
if prior:
last = prior[-1]
when = (last.get("timestamp") or "")[:10]
note = f"tested {len(prior)} time(s) before, most recently {when} " \
f"({last.get('verdict', '?')})"
prev_dev = last.get("device") or {}
for label, key in (("SLC", "life_a"), ("MLC/TLC", "life_b")):
was, now = prev_dev.get(key), getattr(dev, key)
if was and now and now > was:
note += f"; {label} wear rose 0x{was:02x} to 0x{now:02x}"
out.append(note)
if gpt.disk_guid:
twins = {(r.get("device") or {}).get("serial")
for r in history
if r.get("gpt_disk_guid") == gpt.disk_guid}
twins.discard(serial)
twins.discard(None)
if twins:
out.append(
f"partition table is identical to {len(twins)} other module(s) "
f"on this bench - all were written from the same image and carry "
f"the same console data")
return out
def gpt_panel(gpt: GptInfo) -> Panel | None:
if not gpt.present:
return None
t = Table.grid(padding=(0, 2))
t.add_column(style="dim", justify="right")
t.add_column()
t.add_row("primary", "[green]valid[/]" if gpt.primary_ok else "[red]bad CRC[/]")
t.add_row("backup", "[green]valid[/]" if gpt.backup_ok else "[red]bad or missing[/]")
if gpt.disk_guid:
t.add_row("disk GUID", gpt.disk_guid)
if gpt.backup_disk_guid and gpt.backup_disk_guid != gpt.disk_guid:
t.add_row("backup GUID", f"[yellow]{gpt.backup_disk_guid}[/]")
body: list = [t]
if gpt.problems:
body.append(Text())
for p in gpt.problems:
body.append(Text(f" • {p}", style="yellow"))
return Panel(Group(*body), title="[bold]partition table[/]",
border_style="yellow" if gpt.problems else "green")
# ---------------------------------------------------------------------------
# Kernel log correlation
# ---------------------------------------------------------------------------
# What the mmc/usb stacks say when a transfer was retried or the link reset.
# These mean the problem is on the wire, not in the flash.
LINK_ERROR_HINTS = (
"error -110", "error -84", "error -71", "error -32", "error -5",
"timeout", "timed out", "crc err", "data crc", "response crc",
"reset high-speed", "reset full-speed", "reset superspeed",
"device descriptor read", "cmd response", "req failed",
"tuning failed", "retrying", "controller never released",
"card is busy", "recovery", "reset usb",
)
# These mean the media itself could not return the data.
MEDIA_ERROR_HINTS = (
"critical medium error", "unrecovered read error", "uncorrectable",
"buffer i/o error", "i/o error, dev", "ecc error",
)
def capture_dmesg() -> list[str]:
"""Snapshot the kernel ring buffer so it can be diffed after the scan."""
if not have("dmesg"):
return []
try:
out = subprocess.run(["dmesg", "-t"], capture_output=True,
text=True, timeout=15)
return out.stdout.splitlines() if out.returncode == 0 else []
except (subprocess.SubprocessError, OSError):
return []
def classify_kernel_line(raw: str) -> str | None:
"""Return 'media', 'link' or None for a kernel log line."""
low = raw.lower()
if not any(t in low for t in ("mmc", "usb", "rtsx", "blk_update", "i/o error")):
return None
if any(h in low for h in MEDIA_ERROR_HINTS):
return "media"
if any(h in low for h in LINK_ERROR_HINTS):
return "link"
return None
def dmesg_delta(before: list[str]) -> tuple[list[str], int, int]:
"""New kernel lines since `before`, split into link and media faults.
This is what separates 'the module is failing' from 'the reader is
dropping transfers': the flash controller and the USB bridge complain
in completely different vocabularies.
"""
after = capture_dmesg()
if not after:
return [], 0, 0
new = after[len(before):]
if before:
# the ring buffer may have wrapped; re-anchor on the last known line
try:
anchor = len(after) - 1 - after[::-1].index(before[-1])
new = after[anchor + 1:]
except ValueError:
new = after[-500:]
lines, link, media = [], 0, 0
for raw in new:
kind = classify_kernel_line(raw)
if kind is None:
continue
if kind == "media":
media += 1
else:
link += 1
lines.append(raw.strip())
return lines, link, media
# ---------------------------------------------------------------------------
# I/O engine
# ---------------------------------------------------------------------------
class RawReader:
"""Direct-I/O reader with a page-aligned buffer, falling back to buffered
reads with cache eviction if O_DIRECT is refused."""
def __init__(self, path: str, chunk: int, write: bool = False):
self.path = path
self.chunk = chunk
self.direct = True
mode = os.O_RDWR if write else os.O_RDONLY
try:
self.fd = os.open(path, mode | os.O_DIRECT)
except OSError:
self.fd = os.open(path, mode)
self.direct = False
self._buf = mmap.mmap(-1, chunk)
self._mv = memoryview(self._buf)
def read(self, offset: int, length: int) -> tuple[int, bytes]:
n = os.preadv(self.fd, [self._mv[:length]], offset)
if not self.direct:
os.posix_fadvise(self.fd, offset, length, os.POSIX_FADV_DONTNEED)
return n, bytes(self._mv[:n])
def write(self, offset: int, data: bytes) -> int:
self._mv[:len(data)] = data
n = os.pwritev(self.fd, [self._mv[:len(data)]], offset)
return n
def sync(self) -> None:
try:
os.fsync(self.fd)
except OSError:
pass
def close(self) -> None:
self._mv.release()
self._buf.close()
os.close(self.fd)
def __enter__(self):
return self
def __exit__(self, *a):
self.close()
def isolate_bad_sectors(reader: RawReader, offset: int, length: int,
limit: int = 64) -> list[int]:
"""Re-read a failed chunk sector by sector to find the exact bad LBAs."""
bad = []
step = SECTOR
for pos in range(offset, offset + length, step):
try:
reader.read(pos, step)
except OSError:
bad.append(pos // SECTOR)
if len(bad) >= limit:
break
return bad
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@dataclass
class Sample:
offset: int
mbps: float
error: bool = False
warmup: bool = False # first bucket: link negotiation skews it, ignore
@dataclass
class Results:
samples: list[Sample] = field(default_factory=list)
bad_lbas: list[int] = field(default_factory=list)
error_regions: list[tuple[int, str]] = field(default_factory=list)
seq_mbps: float = 0.0
rand_iops: float = 0.0
rand_mbps: float = 0.0
bytes_read: int = 0
scan_seconds: float = 0.0
write_verified: int = 0
write_mismatches: list[int] = field(default_factory=list)
write_mbps: float = 0.0
kernel_lines: list[str] = field(default_factory=list)
link_errors: int = 0
media_errors: int = 0
direct_io: bool = True
sweep: list[tuple[int, float]] = field(default_factory=list)
cmd_overhead_ms: float | None = None
asymptotic_mbps: float | None = None
knee_bytes: int | None = None
peak_sweep_mbps: float | None = None
sweep_cliff: bool = False
chunk_used: int = 0
def plan_offsets(dev: Device, chunk: int, full: bool,
points: int) -> tuple[list[int], int]:
"""Decide which offsets to read and how wide a display bucket is.
Full mode walks every chunk and buckets the timings for the chart.
Sample mode reads `points` evenly spaced chunks.
"""
usable = (dev.size // chunk) * chunk
if usable == 0:
return [], chunk
if full:
return list(range(0, usable, chunk)), max(chunk, usable // max(1, points))
stride = max(chunk, (usable // max(1, points) // chunk) * chunk)
return list(range(0, usable, stride)), stride
def sequential_profile(dev: Device, reader: RawReader, results: Results,
chunk: int, offsets: list[int], bucket_size: int,
progress: Progress, task) -> None:
"""Read across the address space, recording throughput per position."""
if not offsets:
return
bucket_bytes = 0
bucket_time = 0.0
bucket_index = 0
bucket_error = False
total_time = 0.0
total_bytes = 0
for off in offsets:
t0 = time.perf_counter()
failed = False
try:
n, _ = reader.read(off, chunk)
except OSError:
failed = True
n = 0
dt = time.perf_counter() - t0
if failed:
bucket_error = True
bad = isolate_bad_sectors(reader, off, chunk)
results.bad_lbas.extend(bad)
results.error_regions.append((off, dev.partition_at(off)))
else:
bucket_bytes += n
bucket_time += dt
total_bytes += n
total_time += dt
progress.update(task, advance=chunk)
if off // bucket_size != bucket_index:
mbps = (bucket_bytes / MIB / bucket_time) if bucket_time > 0 else 0.0
results.samples.append(
Sample(bucket_index * bucket_size, mbps, bucket_error,
warmup=(len(results.samples) == 0)))
bucket_index = off // bucket_size
bucket_bytes, bucket_time, bucket_error = 0, 0.0, False
if bucket_bytes or bucket_error:
mbps = (bucket_bytes / MIB / bucket_time) if bucket_time > 0 else 0.0
results.samples.append(Sample(bucket_index * bucket_size, mbps, bucket_error))
results.bytes_read = total_bytes
results.scan_seconds = total_time
results.seq_mbps = (total_bytes / MIB / total_time) if total_time else 0.0
def random_read_test(dev: Device, reader4k: RawReader, results: Results,
seconds: float, progress: Progress, task) -> None:
"""4 KiB random reads. Not USB-bandwidth-limited, so this is the number
that actually characterises the flash rather than the reader."""
size = dev.size
if size < 64 * MIB:
return
blocks = size // 4096
rng = random.Random(0xC0FFEE)
count = 0
start = time.perf_counter()
deadline = start + seconds
while True:
now = time.perf_counter()
if now >= deadline:
break
off = rng.randrange(blocks) * 4096
try:
reader4k.read(off, 4096)
count += 1
except OSError:
results.error_regions.append((off, dev.partition_at(off)))
if count % 64 == 0:
progress.update(task, completed=min(seconds, now - start))
elapsed = time.perf_counter() - start
progress.update(task, completed=seconds)
results.rand_iops = count / elapsed if elapsed else 0.0
results.rand_mbps = results.rand_iops * 4096 / MIB
def write_verify_test(dev: Device, results: Results, chunk: int,
offsets: list[int], progress: Progress, task) -> None:
"""DESTRUCTIVE. Writes a position-derived pattern and reads it back.
Catches write-side faults and fake-capacity modules, which cheap
replacement eMMC is prone to: a module that lies about its size will
wrap writes onto earlier addresses and fail verification in the
upper range.
"""
with RawReader(dev.path, chunk, write=True) as rw:
total_bytes, total_time = 0, 0.0
for off in offsets:
rng = random.Random(off)
pattern = rng.randbytes(chunk)
t0 = time.perf_counter()
try:
rw.write(off, pattern)
except OSError:
results.write_mismatches.append(off)
progress.update(task, advance=1)
continue
total_time += time.perf_counter() - t0
total_bytes += chunk
progress.update(task, advance=1)
rw.sync()
# read back in a second pass so the controller cache cannot mask a failure
with RawReader(dev.path, chunk) as ro:
for off in offsets:
if off in results.write_mismatches:
continue
expect = random.Random(off).randbytes(chunk)
try:
_, got = ro.read(off, chunk)
except OSError:
results.write_mismatches.append(off)
continue
if got != expect:
results.write_mismatches.append(off)
else:
results.write_verified += 1
progress.update(task, advance=1)
results.write_mbps = (total_bytes / MIB / total_time) if total_time else 0.0
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
BLOCKS = "▁▂▃▄▅▆▇█"
def profile_chart(dev: Device, results: Results, width: int | None = None) -> Panel:
samples = results.samples
if not samples:
return Panel("No samples collected.", title="read profile", border_style="dim")
# panel borders and padding eat 4 columns; never exceed the terminal
if width is None:
width = max(20, min(160, console.width - 6))
# resample to the target width
if len(samples) > width:
step = len(samples) / width
buckets = []
for i in range(width):
lo, hi = int(i * step), max(int(i * step) + 1, int((i + 1) * step))
group = samples[lo:hi]
if not group:
continue
buckets.append(Sample(
group[0].offset,
sum(s.mbps for s in group) / len(group),
any(s.error for s in group),
))
else:
buckets = samples
good = [s.mbps for s in buckets if not s.error and s.mbps > 0]
if not good:
return Panel("All reads failed.", title="read profile", border_style="red")
peak = max(good)
median = sorted(good)[len(good) // 2]
bar = Text()
for s in buckets:
if s.error:
bar.append("✗", style="bold white on red")
continue
level = min(7, max(0, int(s.mbps / peak * 7.99)))
ratio = s.mbps / median if median else 1.0
if ratio < 0.55:
style = "bold red"
elif ratio < 0.85:
style = "yellow"
else:
style = "green"
bar.append(BLOCKS[level], style=style)
label_l, label_r = "0", human(dev.size)
pad = len(bar) - len(label_l) - len(label_r)
axis = Text(no_wrap=True, overflow="crop")
axis.append(label_l, style="dim")
axis.append(" " * max(1, pad), style="dim")
axis.append(label_r, style="dim")
legend = Text(no_wrap=True, overflow="ellipsis")
legend.append("█", style="green")
legend.append(" at speed ", style="dim")
legend.append("█", style="yellow")
legend.append(" 55-85% of median ", style="dim")
legend.append("█", style="bold red")
legend.append(" under 55% ", style="dim")
legend.append("✗", style="bold white on red")
legend.append(" read error", style="dim")
stats = Text(
f"peak {peak:.1f} MB/s median {median:.1f} MB/s "
f"slowest {min(good):.1f} MB/s",
style="dim", no_wrap=True, overflow="ellipsis",
)
bar.no_wrap = True
bar.overflow = "crop"
return Panel(Group(bar, axis, Text(), stats, legend),
title="[bold]read throughput across the address space[/]",
border_style="cyan")
def identity_panel(dev: Device) -> Panel:
t = Table.grid(padding=(0, 2))
t.add_column(style="dim", justify="right")
t.add_column()
t.add_row("device", f"[bold]{dev.path}[/]")
t.add_row("capacity", f"{human(dev.size)} ({dev.size:,} bytes)")
t.add_row("vendor", dev.vendor)
if dev.model:
t.add_row("model", f"[bold]{dev.model}[/]")
if dev.serial:
t.add_row("serial", dev.serial)
if dev.date:
t.add_row("mfg date", dev.date)
rev = " / ".join(x for x in (dev.hwrev, dev.fwrev) if x)
if rev:
t.add_row("hw / fw rev", rev)
if dev.card_type:
t.add_row("type", dev.card_type)
link = " · ".join(x for x in (dev.clock, dev.bus_width, dev.timing) if x)
if link:
t.add_row("mmc link", link)
if dev.transport in ("usb", "usb2"):
speed = f"{dev.usb_speed} Mbit/s" if dev.usb_speed else "unknown speed"
gen = "USB 2.0" if dev.transport == "usb2" else "USB 3.x"
extra = f" [dim]{dev.usb_id}[/]" if dev.usb_id else ""
t.add_row("host link", f"{gen} · {speed}{extra}")
if dev.driver:
t.add_row("driver", dev.driver)
return Panel(t, title="[bold]identity[/]", border_style="blue")
def health_panel(dev: Device) -> tuple[Panel, int]:
"""Returns the panel and a severity score: 0 ok, 1 warn, 2 fail."""
t = Table.grid(padding=(0, 2))
t.add_column(style="dim", justify="right")
t.add_column()
severity = 0
if dev.pre_eol is None and dev.life_a is None:
t.add_row("status", "[dim]not reported - eMMC 5.0 or newer required[/]")
if not have("mmc"):
t.add_row("", "[dim]install mmc-utils for a second opinion[/]")
return Panel(t, title="[bold]health[/]", border_style="dim"), 0
if dev.pre_eol is not None:
label, style, sev = PRE_EOL.get(
dev.pre_eol, (f"undefined value 0x{dev.pre_eol:02x}", "yellow", 1))
severity = max(severity, sev)
t.add_row("reserve blocks", f"[{style}]{label}[/]")
for name, val, kind in (("wear (SLC)", dev.life_a, "A"),
("wear (MLC/TLC)", dev.life_b, "B")):
if val is None:
continue
if val == 0:
t.add_row(name, "[dim]not reported[/]")
continue
if val >= 0x0B:
style, sev, txt = "red", 2, "past rated endurance"
else:
lo, hi = (val - 1) * 10, val * 10
txt = f"{lo}-{hi}% consumed"
if val >= 0x09:
style, sev = "red", 2
elif val >= 0x08:
style, sev = "yellow", 1
else:
style, sev = "green", 0
severity = max(severity, sev)
gauge = "█" * min(10, val) + "░" * max(0, 10 - val)
t.add_row(name, f"[{style}]{gauge}[/] {txt} [dim](0x{val:02x})[/]")
t.add_row("source", f"[dim]{dev.health_source}[/]")
border = {0: "green", 1: "yellow", 2: "red"}[severity]
return Panel(t, title="[bold]health[/]", border_style=border), severity
def layout_panel(dev: Device) -> tuple[Panel, bool, int]:
"""Verify the partition table. Returns (panel, is_switch, mismatch_count)."""
if not dev.partitions:
return (Panel("[dim]No partition table found - blank or raw module.[/]",
title="[bold]layout[/]", border_style="dim"), False, 0)
labels = [(p.label or "") for p in dev.partitions]
expected_labels = [n for n, _ in SWITCH_LAYOUT]
label_hits = sum(1 for l in labels if l in expected_labels)
size_shape = [round(p.size / MIB) for p in dev.partitions]
expected_shape = [s for _, s in SWITCH_LAYOUT[:-1]]
shape_match = size_shape[:len(expected_shape)] == expected_shape
is_switch = label_hits >= 6 or (len(dev.partitions) == 11 and shape_match)
t = Table(box=None, padding=(0, 2), show_edge=False)
t.add_column("#", style="dim", justify="right")
t.add_column("partition")
t.add_column("size", justify="right")
t.add_column("expected", justify="right", style="dim")
t.add_column("", justify="left")
mismatches = 0
for i, p in enumerate(dev.partitions):
name = p.label or p.name
size_mib = p.size / MIB
exp = ""
mark = ""
if is_switch and i < len(SWITCH_LAYOUT):
exp_name, exp_mib = SWITCH_LAYOUT[i]
exp = f"{exp_mib} MiB" if i < len(SWITCH_LAYOUT) - 1 else "remainder"
name_ok = (p.label is None) or (p.label == exp_name)
# last partition (USER) absorbs whatever capacity is left
size_ok = (i == len(SWITCH_LAYOUT) - 1) or abs(size_mib - exp_mib) <= 1
if name_ok and size_ok:
mark = "[green]ok[/]"
else:
mark = "[yellow]differs[/]"
mismatches += 1
t.add_row(str(i + 1), name, f"{size_mib:,.0f} MiB", exp, mark)
if is_switch:
title = "[bold]layout[/] [green]· Nintendo Switch eMMC recognised[/]"
border = "green" if mismatches == 0 else "yellow"
if len(dev.partitions) != 11:
border = "yellow"
mismatches += 1
else:
title = "[bold]layout[/] [dim]· not a Switch layout[/]"
border = "blue"
return Panel(t, title=title, border_style=border), is_switch, mismatches
def errors_panel(dev: Device, results: Results) -> Panel | None:
if not results.error_regions and not results.write_mismatches:
return None
t = Table(box=None, padding=(0, 2), show_edge=False)
t.add_column("offset", justify="right")
t.add_column("LBA", justify="right")
t.add_column("partition")
t.add_column("kind")
shown = 0
for off, part in results.error_regions[:20]:
t.add_row(f"0x{off:012x}", f"{off // SECTOR:,}", part, "[red]read error[/]")
shown += 1
for off in results.write_mismatches[:20]:
t.add_row(f"0x{off:012x}", f"{off // SECTOR:,}",
dev.partition_at(off), "[red]write/verify mismatch[/]")
shown += 1
extra = len(results.error_regions) + len(results.write_mismatches) - shown
body: list = [t]
if extra > 0:
body.append(Text(f" ... and {extra} more", style="dim"))
if results.bad_lbas:
preview = ", ".join(str(x) for x in results.bad_lbas[:12])
more = "" if len(results.bad_lbas) <= 12 else f" (+{len(results.bad_lbas) - 12})"
body.append(Text())
body.append(Text(f" unreadable sectors: {preview}{more}", style="red"))
return Panel(Group(*body), title="[bold red]faults[/]", border_style="red")
def kernel_panel(results: Results) -> Panel | None:
if not results.kernel_lines:
return None
summary = []
if results.link_errors:
summary.append(f"[yellow]{results.link_errors} link-level[/]")
if results.media_errors:
summary.append(f"[red]{results.media_errors} media-level[/]")
body: list = [
Text.from_markup(" " + " · ".join(summary) +
" message(s) logged during the scan"),
Text(),
]
for line in results.kernel_lines[:12]:
body.append(Text(f" {line[:160]}", style="dim", overflow="ellipsis"))
if len(results.kernel_lines) > 12:
body.append(Text(f" ... and {len(results.kernel_lines) - 12} more",
style="dim"))
border = "red" if results.media_errors else "yellow"
return Panel(Group(*body), title="[bold]kernel log[/]", border_style=border)
def diagnose_link(dev: Device, results: Results, health_sev: int) -> str | None:
"""Decide whether poor numbers indict the module or the measurement path.
A module doing heavy ECC correction wears its reserve pool down, so
pristine health registers alongside poor throughput is contradictory -
and the contradiction resolves toward the reader, not the flash.
"""
if results.media_errors or results.error_regions:
return None # real media faults, not a link story
if dev.health_source == "none" or health_sev != 0:
return None # can't use registers as an alibi
ceiling = dev.link_ceiling_mbps
slow = bool(ceiling and results.seq_mbps and results.seq_mbps < ceiling * 0.4)
scored = sorted(s.mbps for s in results.samples
if not s.error and not s.warmup and s.mbps > 0)
erratic = False
if len(scored) >= 8:
median = scored[len(scored) // 2]
p25 = scored[len(scored) // 4]
erratic = bool(median and p25 < median * 0.55)
# eMMC random 4K reads land in the thousands of IOPS; low hundreds means
# per-transaction cost is dominating, which is a bus property
starved = bool(0 < results.rand_iops < 500)
if not (slow or erratic or starved):
return None
if results.link_errors:
return (f"the kernel logged {results.link_errors} link-level error(s) "
"during the scan while the module's own health registers stayed "
"clean - the fault is in the reader, adapter or wiring, not the "
"flash")
if sum([slow, erratic, starved]) >= 2:
return ("throughput is poor and erratic but the module reports a clean "
"reserve pool and no wear - heavy ECC correction would have "
"consumed spare blocks, so suspect the reader, adapter seating "
"or signal integrity before the module")
return None
# Transfer sizes for the latency sweep. Spread over two orders of magnitude
# so a straight-line fit can separate fixed cost from marginal cost.
SWEEP_SIZES = (64 * 1024, 256 * 1024, MIB, 4 * MIB, 16 * MIB)
def warm_up(dev: Device, seconds: float = 2.0) -> None:
"""Read and discard until the link settles.
rtsx readers use runtime power management, so the first reads after probe
pay a wake-and-settle cost. Measuring small transfers in that window makes
the sweep report a fixed per-command cost that does not exist once the
device is awake.
"""
size = 4 * MIB
if dev.size < 8 * MIB:
return
span = max(1, (dev.size - size) // size)
try:
with RawReader(dev.path, size) as r:
deadline = time.perf_counter() + seconds
i = 0
while time.perf_counter() < deadline:
try:
r.read((i % span) * size, size)
except OSError:
return
i += 1
except OSError:
return
def latency_sweep(dev: Device, results: Results,
progress: Progress, task) -> None:
"""Time reads at several transfer sizes and fit t = overhead + bytes/rate.
This settles the question the throughput number cannot: a link paying a
large fixed cost per command looks identical to slow flash at one block
size, but the two diverge as the block grows. A big intercept means the
reader is the problem and larger reads will fix it; a low slope means the
path genuinely cannot move data faster.
"""
if dev.size < 128 * MIB:
return
rng = random.Random(0x5EED)
points: list[tuple[int, float]] = []
for size in SWEEP_SIZES:
if size > dev.size // 8:
continue
# small transfers need more samples: their per-read time is short
# enough that scheduler noise can move the median several ms
reps = max(5, min(40, (24 * MIB) // size))
span = (dev.size - size) // size
times = []
try:
with RawReader(dev.path, size) as r:
# two discarded reads per size: the transition between block
# sizes can itself cost a request or two to stabilise
for _ in range(2):
try:
r.read(rng.randrange(span) * size, size)
except OSError:
pass
for _ in range(reps):
off = rng.randrange(span) * size
t0 = time.perf_counter()
try:
r.read(off, size)
except OSError:
continue
times.append(time.perf_counter() - t0)
progress.update(task, advance=1)
except OSError:
continue
if times:
times.sort()
points.append((size, times[len(times) // 2])) # median resists outliers
results.sweep = points
if len(points) < 3:
return
# Find the knee before fitting. A marginal link runs at full rate up to
# some transfer size and then collapses as retries start dominating; a
# straight line through that cliff describes neither regime.
rates = [(size, (size / MIB) / secs) for size, secs in points if secs > 0]
if rates:
best = max(r for _, r in rates)
healthy = [size for size, r in rates if r >= best * 0.85]
results.knee_bytes = max(healthy) if healthy else rates[0][0]
results.peak_sweep_mbps = best
worst_large = min(r for size, r in rates if size >= results.knee_bytes)
results.sweep_cliff = worst_large < best * 0.6
# fit only the healthy regime so the numbers mean something
points = [p for p in points if p[0] <= results.knee_bytes]
if len(points) < 3:
return
n = len(points)
mx = sum(p[0] for p in points) / n
my = sum(p[1] for p in points) / n
denom = sum((p[0] - mx) ** 2 for p in points)
if denom <= 0:
return
slope = sum((p[0] - mx) * (p[1] - my) for p in points) / denom
intercept = my - slope * mx
results.cmd_overhead_ms = max(0.0, intercept * 1000)
if slope > 0:
results.asymptotic_mbps = (1.0 / slope) / 1_000_000
def sweep_panel(dev: Device, results: Results) -> Panel | None:
if not results.sweep:
return None
t = Table(box=None, padding=(0, 2), show_edge=False)
t.add_column("transfer", justify="right")
t.add_column("median time", justify="right")
t.add_column("effective", justify="right")
t.add_column("")
best = 0.0
for size, secs in results.sweep:
mbps = (size / MIB) / secs if secs > 0 else 0.0
best = max(best, mbps)
for size, secs in results.sweep:
mbps = (size / MIB) / secs if secs > 0 else 0.0
width = int(round(mbps / best * 24)) if best else 0
t.add_row(human(size), f"{secs * 1000:.1f} ms", f"{mbps:.1f} MB/s",
"[cyan]" + "▇" * max(1, width) + "[/]")
body: list = [t]
if results.cmd_overhead_ms is not None and results.asymptotic_mbps:
body.append(Text())
g = Table.grid(padding=(0, 2))
g.add_column(style="dim", justify="right")
g.add_column()
g.add_row("fixed cost per read", f"[bold]{results.cmd_overhead_ms:.1f} ms[/]")
g.add_row("marginal rate",
f"[bold]{results.asymptotic_mbps:.1f} MB/s[/] "
f"[dim]once the command cost is paid[/]")
if results.knee_bytes:
style = "yellow" if results.sweep_cliff else "green"
g.add_row("largest clean transfer",
f"[{style}]{human(results.knee_bytes)}[/]")
body.append(g)
ceiling = dev.link_ceiling_mbps
body.append(Text())
if results.sweep_cliff and results.knee_bytes:
body.append(Text.from_markup(
f"[yellow]Throughput collapses above {human(results.knee_bytes)} "
f"per transfer.[/]\n"
f"[dim]Reads at or below that size run at "
f"{results.peak_sweep_mbps:.1f} MB/s - full speed. Larger ones "
f"fall to a fraction\nof it. Flash does not behave this way; a "
f"link that accumulates errors over a\nlong burst and retries "
f"the transfer does. Check module seating, connector\ncontacts "
f"and cable before suspecting the media.[/]"))
elif results.cmd_overhead_ms > 3.0 and ceiling and \
results.asymptotic_mbps > ceiling * 0.6:
body.append(Text.from_markup(
"[dim]Large fixed cost, healthy marginal rate: the flash keeps up "
"once a\ntransfer is underway and the time is going to command "
"turnaround.\nThis is a reader/link property. Use a larger --chunk "
"for real work.[/]"))
elif ceiling and results.asymptotic_mbps < ceiling * 0.5:
body.append(Text.from_markup(
"[dim]The marginal rate itself is well under the bus ceiling, so "
"bigger reads\nwill not rescue it. The bottleneck is in the data "
"path, not the command\nturnaround.[/]"))
return Panel(Group(*body), title="[bold]transfer size sweep[/]",
border_style="cyan")
def speed_panel(dev: Device, results: Results) -> Panel:
t = Table.grid(padding=(0, 2))
t.add_column(style="dim", justify="right")
t.add_column()
t.add_row("sequential read", f"[bold]{results.seq_mbps:.1f} MB/s[/]")
if results.rand_iops:
t.add_row("random 4K read",
f"[bold]{results.rand_iops:,.0f} IOPS[/] "
f"[dim]({results.rand_mbps:.1f} MB/s)[/]")
if results.write_mbps:
t.add_row("sequential write", f"[bold]{results.write_mbps:.1f} MB/s[/]")
t.add_row("data read",
f"{human(results.bytes_read)} in {results.scan_seconds:.1f}s")
if results.chunk_used:
t.add_row("transfer size", human(results.chunk_used))
ceiling = dev.link_ceiling_mbps
if ceiling:
pct = (results.seq_mbps / ceiling * 100) if results.seq_mbps else 0
style = "green" if pct >= 70 else "yellow" if pct >= 40 else "red"
t.add_row("bus ceiling",
f"{ceiling:.0f} MB/s [dim]({dev.clock}, {dev.bus_width})[/]")
t.add_row("link efficiency", f"[{style}]{pct:.0f}% of ceiling[/]")
lines = []
if not results.direct_io:
lines.append(
"[yellow]O_DIRECT was refused; reads went through the page cache.[/]\n"
"[dim]Throughput above is inflated and should not be trusted.[/]")
peak = max((s.mbps for s in results.samples
if not s.error and not s.warmup), default=0.0)
if ceiling and peak > ceiling * 1.3:
lines.append(
f"[yellow]Peak {peak:.1f} MB/s is well above the ~{ceiling:.0f} MB/s "
"estimated bus ceiling.[/]\n"
"[dim]The estimate is a naive clock x width figure and reader "
"buffering lets short\nbursts beat it slightly, but not by this "
"margin. Suspect cached reads or\nmisreported link parameters.[/]")
if not lines:
if ceiling and results.seq_mbps and results.seq_mbps < ceiling * 0.4:
lines.append(
"[dim]Well under the bus ceiling. See the transfer size sweep "
"below - it separates\ncommand turnaround cost from actual "
"transfer rate.[/]")
elif dev.transport == "usb2":
lines.append(
"[dim]Attached over USB 2.0. Sequential throughput describes the "
"link, not the eMMC.[/]")
body = Group(t, Text(), *[Text.from_markup(l) for l in lines]) if lines else t
return Panel(body, title="[bold]speed[/]", border_style="cyan")
def verdict_panel(dev: Device, results: Results, health_sev: int,
layout_mismatches: int, is_switch: bool,
full: bool, wrote: bool,
gpt_problems: list[str] | None = None,
strict: bool = False,
hw_parts: list | None = None) -> tuple[Panel, str]:
reasons: list[str] = []
level = 0
if health_sev == 2:
level = 2
reasons.append("controller reports the reserve block pool is nearly exhausted")
elif health_sev == 1:
level = max(level, 1)
reasons.append("wear indicators are elevated")
if results.error_regions:
level = 2
reasons.append(f"{len(results.error_regions)} unreadable region(s) on the media")
if results.write_mismatches:
level = 2
reasons.append(f"{len(results.write_mismatches)} write/verify mismatch(es) - "
"suspect fake capacity or failing cells")
link_note = diagnose_link(dev, results, health_sev)
for p in (hw_parts or []):
if p.errors:
level = 2
reasons.append(f"{len(p.errors)} unreadable sector(s) in the {p.name} "
"hardware partition - this area holds the boot chain "
"and is not covered by the user-area scan")
if results.media_errors:
level = 2
reasons.append(f"{results.media_errors} media-level kernel error(s) "
"logged during the scan")
scored = [s for s in results.samples if not s.error and not s.warmup and s.mbps > 0]
if len(scored) >= 8:
median = sorted(s.mbps for s in scored)[len(scored) // 2]
slow = [s for s in scored if s.mbps < median * 0.55]
if len(slow) >= 3 and len(slow) >= len(scored) * 0.05:
level = max(level, 1)
if link_note:
reasons.append(f"{len(slow)} region(s) reading below 55% of median")
else:
reasons.append(
f"{len(slow)} region(s) reading below 55% of median - "
"consistent with heavy internal retry or ECC correction")
if results.sweep_cliff and results.knee_bytes:
level = max(level, 1)
reasons.append(
f"transfers above {human(results.knee_bytes)} collapse while smaller "
"ones run at full speed - a transfer-length dependent fault points at "
"the connection, not the flash")
if link_note and not results.sweep_cliff:
level = max(level, 1)
reasons.append(link_note)
if not results.direct_io:
level = max(level, 1)
reasons.append("O_DIRECT was unavailable, so throughput figures include "
"page cache effects and cannot be compared to the bus ceiling")
# Content findings describe what is written on the module, not whether the
# silicon is sound. A reflash fixes them; a worn NAND die does not. Keeping
# them out of the hardware verdict stops every second-hand module from
# being flagged as suspect hardware.
content: list[str] = list(gpt_problems or [])
if is_switch and layout_mismatches:
content.append("partition table deviates from the stock Switch layout")
if strict:
for c in content:
level = max(level, 1)
reasons.append(c)
if level == 0:
title, style = "PASS", "bold green"
headline = ("Hardware is sound." if content else "No faults found.")
elif level == 1:
title, style = "INSPECT", "bold yellow"
headline = ("Measurement is unreliable - retest before judging the module."
if (link_note or results.sweep_cliff) else
"Usable, but something is worth a second look.")
else:
title, style = "FAIL", "bold red"
headline = "Do not deploy this module."
body = [Text(headline, style=style)]
for r in reasons:
body.append(Text(f" • {r}"))
if not reasons:
body.append(Text(" • health registers nominal", style="dim"))
body.append(Text(" • no read errors across the scanned range", style="dim"))
body.append(Text(" • throughput flat across the address space", style="dim"))
if content and not strict:
body.append(Text())
body.append(Text("content notes - fixed by reflashing, not hardware faults:",
style="cyan"))
for c in content:
body.append(Text(f" • {c}", style="cyan"))
caveats = []
if not full:
caveats.append("sampled scan only - run --full to read every sector")
if not wrote:
caveats.append("read-only - write-side faults and fake capacity are not covered")
if hw_parts and all(not p.readable and not p.errors for p in hw_parts):
caveats.append("boot areas not read")
if dev.health_source == "none":
caveats.append("no health registers; this part predates eMMC 5.0")
if caveats:
body.append(Text())
body.append(Text("coverage: " + "; ".join(caveats), style="dim italic"))
return Panel(Group(*body), title=f"[{style}]{title}[/]",
border_style=style.split()[-1]), title
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def pick_device(devices: list[Device]) -> Device | None:
if not devices:
console.print("[red]No mmcblk devices found.[/]")
console.print("[dim]Check that rtsx_usb_sdmmc is loaded: lsmod | grep rtsx[/]")
return None
if len(devices) == 1:
return devices[0]
console.print("\n[bold]Attached MMC devices[/]\n")
t = Table(box=None, padding=(0, 2))
t.add_column("#", justify="right", style="cyan")
t.add_column("device")
t.add_column("size", justify="right")
t.add_column("model")
t.add_column("mounted")
for i, d in enumerate(devices):
t.add_row(str(i + 1), d.path, human(d.size), d.model or "-",
"[yellow]yes[/]" if d.mounted else "no")
console.print(t)
try:
raw = console.input("\nSelect device number: ").strip()
idx = int(raw) - 1
if 0 <= idx < len(devices):
return devices[idx]
except (ValueError, EOFError, KeyboardInterrupt):
pass
console.print("[red]No device selected.[/]")
return None
def main() -> int:
ap = argparse.ArgumentParser(
prog="emmcheck",
description="Qualification bench for eMMC modules. Read-only by default.",
)
ap.add_argument("device", nargs="?", help="e.g. /dev/mmcblk1 (omit to choose)")
ap.add_argument("--full", action="store_true",
help="read every sector instead of sampling")
ap.add_argument("--points", type=int, default=160,
help="sample positions across the device (default 160)")
ap.add_argument("--chunk", type=int, default=0,
help="read size in MiB; default 0 picks the largest size "
"the link handles cleanly, measured by the sweep")
ap.add_argument("--random-seconds", type=float, default=10.0,
help="duration of the 4K random read test (0 to skip)")
ap.add_argument("--write-test", action="store_true",
help="DESTROYS ALL DATA. Write/verify pass for fake-capacity "
"and write-fault detection.")
ap.add_argument("--strict", action="store_true",
help="treat content findings (GPT drift, non-stock layout) "
"as verdict-affecting; use when qualifying blank modules")
ap.add_argument("--skip-boot", action="store_true",
help="do not read the boot0/boot1 hardware partitions")
ap.add_argument("--no-sweep", action="store_true",
help="skip the transfer size sweep")
ap.add_argument("--json", metavar="FILE", help="append a JSON record of the run")
ap.add_argument("--force", action="store_true",
help="proceed even if the device has mounted partitions")
args = ap.parse_args()
chunk = args.chunk * MIB # 0 means auto-select from the sweep
console.print()
console.print(Rule("[bold]emmcheck[/] [dim]eMMC qualification bench[/]",
style="cyan"))
if os.geteuid() != 0:
console.print("\n[yellow]Not running as root.[/] Raw device reads and "
"EXT_CSD access will fail. Re-run with sudo.\n")
if args.device:
if not os.path.exists(args.device):
console.print(f"[red]{args.device} does not exist.[/]")
return 1
dev = load_device(args.device)
else:
dev = pick_device(list_mmc_devices())
if dev is None:
return 1
read_extcsd(dev)
history = load_history(args.json) if args.json else []
hw_parts = discover_hw_partitions(dev)
gpt = parse_gpt(dev.path, dev.size) if not dev.is_file else GptInfo()
if gpt.names:
for p in dev.partitions:
if not p.label:
p.label = gpt.names.get(p.start_lba)
if dev.mounted and not args.force:
console.print(f"\n[red]{dev.path} has mounted partitions:[/] "
f"{', '.join(dev.mounted)}")
console.print("Unmount them first, or pass --force to test anyway.\n")
return 1
if args.write_test:
console.print()
console.print(Panel(
Text.from_markup(
f"The write test overwrites [bold]every sampled position[/] on "
f"[bold]{dev.path}[/].\n"
"On a Switch module this destroys PRODINFO, which holds "
"console-unique\ncertificates and cannot be regenerated. Dump the "
"module first if it\nhas ever been paired to a console.\n\n"
"Type the device path to confirm."),
title="[bold red]destructive[/]", border_style="red"))
try:
if console.input("\nConfirm: ").strip() != dev.path:
console.print("[yellow]Cancelled.[/]\n")
return 1
except (EOFError, KeyboardInterrupt):
console.print("\n[yellow]Cancelled.[/]\n")
return 1
console.print()
console.print(identity_panel(dev))
hp, health_sev = health_panel(dev)
console.print(hp)
lp, is_switch, layout_mismatches = layout_panel(dev)
console.print(lp)
gp = gpt_panel(gpt)
if gp:
console.print(gp)
results = Results()
console.print()
dmesg_before = capture_dmesg()
progress = Progress(
SpinnerColumn(style="cyan"),
TextColumn("[bold]{task.description}[/]"),
BarColumn(bar_width=40, complete_style="cyan", finished_style="green"),
TextColumn("{task.percentage:>3.0f}%"),
TimeElapsedColumn(),
TimeRemainingColumn(),
console=console,
)
try:
with progress:
# The sweep runs first: it costs ~15s and tells us what transfer
# size this link actually handles, so the main scan is measured on
# the right side of any cliff instead of straddling it.
wtask = progress.add_task("warming up link", total=1)
warm_up(dev)
progress.update(wtask, completed=1)
if not args.no_sweep:
total_reads = sum(max(5, min(40, (24 * MIB) // s))
for s in SWEEP_SIZES if s <= dev.size // 8)
stask = progress.add_task("transfer size sweep",
total=max(1, total_reads))
latency_sweep(dev, results, progress, stask)
if hw_parts and not args.skip_boot:
htask = progress.add_task("boot / rpmb areas",
total=len(hw_parts))
scan_hw_partitions(hw_parts, progress, htask)
if chunk == 0:
chunk = results.knee_bytes or 4 * MIB
chunk = max(64 * 1024, min(16 * MIB, chunk))
results.chunk_used = chunk
offsets, bucket_size = plan_offsets(dev, chunk, args.full, args.points)
if not offsets:
console.print("[red]Device is smaller than one read chunk.[/]\n")
return 1
scan_total = len(offsets) * chunk
label = "surface scan" if args.full else "read profile"
task = progress.add_task(label, total=scan_total)
with RawReader(dev.path, chunk) as reader:
results.direct_io = reader.direct
sequential_profile(dev, reader, results, chunk, offsets,
bucket_size, progress, task)
progress.update(task, completed=scan_total)
if args.random_seconds > 0:
rtask = progress.add_task("random 4K read",
total=args.random_seconds)
with RawReader(dev.path, 4096) as r4k:
random_read_test(dev, r4k, results,
args.random_seconds, progress, rtask)
if args.write_test:
w_offsets, _ = plan_offsets(dev, chunk, args.full, args.points)
wtask = progress.add_task("write + verify",
total=len(w_offsets) * 2)
write_verify_test(dev, results, chunk, w_offsets,
progress, wtask)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted. Reporting partial results.[/]\n")
except OSError as e:
console.print(f"\n[red]I/O setup failed:[/] {e}")
console.print("[dim]Root is required for raw device access.[/]\n")
return 1
results.kernel_lines, results.link_errors, results.media_errors = \
dmesg_delta(dmesg_before)
hwp = hw_partition_panel(dev, hw_parts, is_switch)
if hwp:
console.print(hwp)
console.print()
console.print(profile_chart(dev, results))
console.print(speed_panel(dev, results))
sp = sweep_panel(dev, results)
if sp:
console.print(sp)
kp = kernel_panel(results)
if kp:
console.print(kp)
ep = errors_panel(dev, results)
if ep:
console.print(ep)
vp, verdict = verdict_panel(dev, results, health_sev, layout_mismatches,
is_switch, args.full, args.write_test,
gpt_problems=gpt.problems + history_findings(
dev, gpt, history),
strict=args.strict, hw_parts=hw_parts)
console.print()
console.print(vp)
console.print()
if args.json:
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"verdict": verdict,
"device": {k: v for k, v in asdict(dev).items()
if k not in ("partitions",)},
"vendor": dev.vendor,
"is_switch_layout": is_switch,
"layout_mismatches": layout_mismatches,
"partitions": [asdict(p) for p in dev.partitions],
"seq_mbps": round(results.seq_mbps, 2),
"rand_iops": round(results.rand_iops, 1),
"bytes_read": results.bytes_read,
"error_regions": results.error_regions,
"bad_lbas": results.bad_lbas,
"write_mismatches": results.write_mismatches,
"link_errors": results.link_errors,
"media_errors": results.media_errors,
"kernel_lines": results.kernel_lines[:50],
"bus_ceiling_mbps": dev.link_ceiling_mbps,
"direct_io": results.direct_io,
"cmd_overhead_ms": results.cmd_overhead_ms,
"asymptotic_mbps": results.asymptotic_mbps,
"sweep": results.sweep,
"knee_bytes": results.knee_bytes,
"sweep_cliff": results.sweep_cliff,
"chunk_used": results.chunk_used,
"gpt_problems": gpt.problems,
"gpt_disk_guid": gpt.disk_guid,
"hw_partitions": [asdict(p) for p in hw_parts],
"full_scan": args.full,
"write_test": args.write_test,
}
with open(args.json, "a") as fh:
fh.write(json.dumps(record) + "\n")
console.print(f"[dim]Record appended to {args.json}[/]\n")
return {"PASS": 0, "INSPECT": 1, "FAIL": 2}[verdict]
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
console.print("\n[yellow]Aborted.[/]\n")
sys.exit(130)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment