Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Warning

pyrographer is under active development, and many of its features are untested on real hardware. Writing has run on one board so far, a raw image flashed to an RK3588S. A write that goes wrong can leave a board unable to boot, or destroy the data on a disk. Use pyrographer at your own risk, and only on a device you can afford to lose. In this book, [UNVERIFIED] marks a feature that passes its tests against scripted devices and has not yet run on a real one.

pyrographer is a flashing and recovery toolkit for embedded devices, such as single-board computers and IP cameras, written entirely in safe Rust. It comprises a command-line tool, a desktop application and a Rust library, built on the same core. The desktop application also builds for the browser as the web flasher, which is [UNVERIFIED].

Supported devices

pyrographer supports three SoC families, and block devices on Linux:

  • Rockchip, over USB, through the SoC’s boot and recovery modes.
  • StarFive JH7110, over serial, through the SoC’s UART recovery mode.
  • Ingenic XBurst, over USB. These SoCs are found in IP cameras such as the Wyze Cam v3. pyrographer brings the boot ROM up to DFU, then reads the device’s flash.
  • Block devices, on Linux, such as an SD card in a reader or a board that presents its storage as USB mass storage.

The architecture leaves room for a Broadcom (CM4) backend, which is not yet built. Hardware support lists what has run on real hardware.

Core operations

pyrographer provides four core operations:

  • dump reads a device’s flash to an image file.
  • flash writes an image to a device, then reads it back to check the result.
  • clone copies one device’s flash directly to another.
  • verify compares a device’s flash against an image.

They run on Rockchip boards and on block devices. An Ingenic device in DFU supports dump, verify and partitions, aimed by partition name. Its write is built, and refused until an Ingenic SoC is pinned against real hardware.

A write permanently replaces the existing data on its target. Before each write, pyrographer presents a plan of the changes and proceeds only on explicit confirmation. The plan names the device and each partition the write touches.

Disks

A disk is shared with the host operating system, which can mount filesystems on it and cache its sectors. pyrographer therefore takes exclusive use of a disk, and reads its writes back uncached. It refuses every disk the running system depends on, with no override.

Bootstrapping and recovery

Four commands serve one vendor each:

  • db uploads a loader to a Rockchip board in maskrom, the BootROM’s USB download mode.
  • usbboot uploads two stages to an Ingenic boot ROM, which brings the device up in DFU.
  • recover writes a bootloader to a StarFive board’s flash over serial. The recovery mode cannot read flash, so the write is not read back.
  • uartboot boots a StarFive board into U-Boot over serial, and writes nothing.

Bootloader prompts

Two commands work with a board’s bootloader prompt over a serial line:

  • console watches a board’s console for the text that marks success or failure.
  • uboot drives a U-Boot prompt. It can present the board’s storage over USB, boot from another source once, or send a single command.

pyrographer works up to the bootloader prompt. A login prompt is outside its scope.

How this book is organized

The book has five parts:

  • Getting started covers installing pyrographer, and what has run on real hardware.
  • Guides walk through one task from start to finish.
  • Boards hold what is measured on each SoC, and on each board built on it.
  • Reference covers each command, and each flow in the window.
  • Design describes the library’s seams, for a developer who calls it.

Installing

pyrographer builds from source with Cargo. The command-line tool and the window are two binaries, and the web flasher is a page you serve yourself. Every build needs Rust 1.95 or newer, which rustup installs.

The command-line tool

Clone the repository and install the tool from it:

git clone https://github.com/gregordinary/pyrographer
cd pyrographer
cargo install --locked --path crates/pyrographer

cargo install builds a release binary and puts pyrographer in ~/.cargo/bin, which rustup adds to your PATH. --locked builds the dependency versions in Cargo.lock, which are the versions every quality gate ran against.

The window

On Debian or Ubuntu, install the X11, Wayland and OpenGL development packages first. These are the packages CI installs before it builds the window:

sudo apt-get install --no-install-recommends libgl1-mesa-dev libwayland-dev libx11-dev \
  libxcursor-dev libxi-dev libxkbcommon-dev libxrandr-dev

Then install the window from the repository’s root:

cargo install --locked --path crates/pyrographer-gui

That puts pyrographer-gui in ~/.cargo/bin. The window describes what it draws.

The web flasher

The web flasher is the window compiled to WebAssembly. It needs the wasm32-unknown-unknown target and Trunk:

rustup target add wasm32-unknown-unknown
cargo install --locked trunk

To try it, run Trunk from the GUI crate’s directory. It builds the page, serves it on localhost, and opens it in your browser:

cd crates/pyrographer-gui
trunk serve --open

To host it, build the page into crates/pyrographer-gui/dist, and serve that directory over HTTPS:

cd crates/pyrographer-gui
trunk build --release

The page needs a Chromium browser and a secure origin, which is HTTPS or localhost. The web flasher compiles, and is [UNVERIFIED] in a browser. The web flasher covers what differs in a tab.

Permissions

list reads descriptors the operating system has already cached, so it needs no permission. The commands that open a device can be refused, and each kind of device takes its own grant.

USB boards on Linux

Grant access with a udev rule for each vendor ID pyrographer scans. Write these lines to /etc/udev/rules.d/99-pyrographer.rules:

# Rockchip
SUBSYSTEM=="usb", ATTR{idVendor}=="2207", MODE="0660", TAG+="uaccess"
# Ingenic
SUBSYSTEM=="usb", ATTR{idVendor}=="a108", MODE="0660", TAG+="uaccess"
SUBSYSTEM=="usb", ATTR{idVendor}=="601a", MODE="0660", TAG+="uaccess"

Then reload the rules, and replug the board:

sudo udevadm control --reload-rules && sudo udevadm trigger

A rule on the vendor ID also covers the vendor’s newer SoCs, which a rule on a product ID does not.

If a board will not open, pyrographer prints the rule for that board’s vendor.

Serial ports on Linux

A USB-serial adapter’s node, such as /dev/ttyUSB0, belongs to the dialout group. On some distributions, such as Arch Linux, the group is uucp. Add yourself to the group, then log out and back in:

sudo usermod -aG dialout "$USER"

Block devices on Linux

Listing disks needs no privilege. Every other command on a disk opens it first, including flash --dry-run, and opening a disk needs root or membership of the disk group. Run those commands under sudo. Membership of disk grants raw access to every disk on the machine, including the one the system runs from. Privilege says which steps open the disk.

USB boards on Windows

On Windows, pyrographer reaches a board through the WinUSB driver, so that driver must be bound to the board. Running pyrographer on Windows is [UNVERIFIED].

Hardware support

This page lists what has run against real hardware, and on which board. Everything else is built and tested against a scripted device, which replays the bytes a real device sends. The reference pages carry the same evidence tags beside each command they describe.

The board results here come from two Rockchip boards. The H96 Max M9 is an RK3576 TV box, and the NanoPi R6S is an RK3588S router board. Rockchip RK3576 and Rockchip RK3588 hold what is measured on each.

Evidence tags

A claim with no tag is measured on a named board, or pinned by a test that needs no hardware. A weaker claim carries one of these tags:

TagMeaning
[DOC]Published by the vendor, or in a specification
[COMMUNITY]From community tools or reports, with no vendor source
[UNVERIFIED]Built and tested here, and not yet checked against hardware
[WEAK]A value chosen by reasoning, such as a timeout, with no measurement behind it
[CONTESTED]The sources disagree

Rockchip

CommandRK3576, H96 Max M9RK3588S, NanoPi R6S
listMeasured. The mode probe tells the BootROM from a loader.Measured. The mode probe finds the loader.
infoMeasured. It agrees with rkdeveloptool field for field.Measured
chipverMeasured. The reply is pinned as rk3576.Measured. The reply is pinned as rk3588.
capability, storage[UNVERIFIED]Measured
partitionsMeasured. It reads the board’s 16-partition GPT.Measured
dumpMeasured. A 4 MiB dump is byte-identical to rkdeveloptool’s.Measured. Three ranges are byte-identical to a raw read.
db --loaderMeasured, with rkbin’s SPL loader and with a mainline U-Boot container.Measured, with FriendlyELEC’s MiniLoaderAll.bin
db --code471 --code472[UNVERIFIED][UNVERIFIED]
resetMeasured in the default mode. The other three modes are [COMMUNITY].Measured in the default mode
flash, verify[UNVERIFIED]. No write to this board has run.Measured. A 168 MiB image is written and read back, then verified through a fresh open.
clone[UNVERIFIED][UNVERIFIED]
repair-table, repair-param, author-gpt, author-param[UNVERIFIED][UNVERIFIED]
write-idb, flash-firmware[UNVERIFIED]. The ID block layout is measured from rkbin’s loader.[UNVERIFIED]
firmware-info[UNVERIFIED]. No package from a vendor build has been read.[UNVERIFIED]

The wrong-loader gate is armed for rk3576 and rk3588, so a write to any other Rockchip SoC is refused. The RK3588S write followed Flashing a disk image from maskrom. Erasing is refused. The erase opcode is published by Rockchip [DOC], but no board has confirmed what the command does to a range.

Through rkbin’s SPL loader, the H96 Max M9 answers every read past its first 32 MiB with constant fill. The 32 MiB read wall says how to read the rest. The NanoPi R6S reads to the last sector of its eMMC.

Mainline U-Boot on a Rockchip board

These results come from RAM-booting mainline U-Boot from maskrom, with U-Boot v2026.04:

StepOn hardware
RAM-booting U-Boot with dbMeasured
Reading the whole eMMC through U-Boot’s mass-storage gadgetMeasured with dd. A pyrographer dump through the gadget is [UNVERIFIED].
info and partitions through U-Boot’s rockusb gadgetFails. The gadget does not answer the flash-information command.
Writing the eMMC through either gadget[UNVERIFIED]
Booting from USB[UNVERIFIED]

The U-Boot commands in these steps ran from a terminal program. console and uboot, which drive the same prompt, are [UNVERIFIED] against hardware.

Ingenic XBurst

The Ingenic backend targets the XBurst2 camera SoCs: the T31, T40 and T41. The Wyze Cam v3, for example, is built on a T31.

usbboot is [UNVERIFIED], and so are dump, verify and partitions on a DFU device. Writing is built, and refused until an Ingenic SoC is pinned against real hardware.

StarFive JH7110

recover and uartboot are [UNVERIFIED], because no JH7110 board has run them. How the recovery agent reads its menu, and what it prints, are read from the agent’s own code. The BootROM’s handshake timing is unsettled. So is whether U-Boot’s mass-storage gadget enumerates through a Mars CM carrier’s USB port.

Block devices

Block devices run on Linux. The bench under bench/ in the repository runs the Block backend against a loop device, and settles these:

  • The exclusive open, and its refusal of a device the kernel holds
  • A GPT written by another tool, read back through the partition codecs
  • dump, and the range checks on a live device
  • A write with its window-by-window read-back
  • A verify through a fresh open, which finds the written bytes

A loop device keeps its data in a file in the host’s page cache. So durability on a physical medium is [UNVERIFIED], and so are USB media and 4 KiB native sectors.

The window and the web flasher

Every result on this page was produced through the command-line tool. The window runs the same verbs on the same core. Its own flows are [UNVERIFIED] against hardware. The web flasher compiles, and is [UNVERIFIED] in a browser.

Host operating systems

Every result on this page was measured on a Linux host. The USB and serial crates pyrographer builds on also support Windows and macOS. pyrographer on either is [UNVERIFIED].

RAM-booting mainline U-Boot from maskrom

A Rockchip BootROM in maskrom takes two stages over USB: one that brings up DRAM, and one it runs from DRAM. Mainline U-Boot builds both. pyrographer db uploads them, and the board comes up at a U-Boot prompt, running from RAM.

The RAM boot writes nothing to the board’s storage, and a power cycle ends it. A failed upload leaves the board in maskrom, and Upload failures describes the retry. Of the steps in this guide, only Writing the eMMC writes to storage. That write permanently replaces the data it covers.

The prompt reaches the whole eMMC, whatever the eMMC holds. From it, you can back up flash the board’s loader cannot read, test-boot an operating system from USB, and recover a board that boots nothing. Through rkbin’s SPL loader, the H96 Max M9, an RK3576 board, answers every read at or past sector 65536 (32 MiB) with constant 0xCC. Each of those reads reports success. The RAM-booted U-Boot reads past this read wall and returns the data the sectors hold.

The board behavior in this guide is measured on that one board, running U-Boot v2026.04, on 2026-07-18. Rockchip RK3576 lists everything else measured on it. On another SoC, the procedure is [UNVERIFIED], and the load-address check takes that SoC’s DRAM base. pyrographer’s uboot command, which drives the console in these steps, is [UNVERIFIED] against hardware. You can type the same lines by hand in a terminal program.

Requirements

Have these before you start:

  • A U-Boot build for the board with CONFIG_ROCKCHIP_MASKROM_IMAGE=y, built as U-Boot’s Rockchip documentation describes
  • The board’s serial console on a USB-serial adapter, at 1500000 baud on the RK3576
  • A way to enter maskrom at power-on, such as a maskrom button or the eMMC clock line shorted to ground
  • USB access for pyrographer, set up as Permissions describes
  • For the container form, an rkbin checkout with its boot_merger tool

The two stages

With CONFIG_ROCKCHIP_MASKROM_IMAGE set, binman writes two files beside the storage image:

FileStageContentsSize on the RK3576
u-boot-rockchip-usb471.bin471The DRAM-init blob from rkbin, which the build takes from ROCKCHIP_TPL77 KiB
u-boot-rockchip-usb472.bin472SPL, then a FIT holding BL31, U-Boot proper and the device tree3.0 MiB

The BootROM runs the 471 stage to bring up DRAM. It then loads the 472 stage into DRAM and jumps to it. SPL boots the FIT from RAM, where the 472 stage already put it.

Checking the load address

Check the 472 stage before you upload it. On the RK3576, the 8-byte word at offset 8 must read 0x40000000, the start of DRAM:

od -A x -t x8 -j 8 -N 8 u-boot-rockchip-usb472.bin
000008 0000000040000000
000010

A stage whose word reads 0x40800000 uploads in full, and the board stays in maskrom. That value is U-Boot proper’s base, CONFIG_TEXT_BASE. In a stage that boots, the word holds the SPL’s own base, CONFIG_SPL_TEXT_BASE. If the check reads anything other than the DRAM base, fix the U-Boot build before you upload.

Preparing the upload

db uploads the stages packed in an RKBOOT container, or as the two raw files. The container is the form that booted the RK3576.

A container

rkbin’s boot_merger packs the stages into an RKBOOT container, the format rkbin’s own loaders ship in. In an rkbin checkout, copy in the two stages and the SoC’s stock recipe:

cp ../u-boot/u-boot-rockchip-usb471.bin ../u-boot/u-boot-rockchip-usb472.bin .
cp RKBOOT/RK3576MINIALL.ini RK3576UBOOT.ini

In RK3576UBOOT.ini, point the two CODE sections and the output at the stages:

[CODE471_OPTION]
NUM=1
Path1=u-boot-rockchip-usb471.bin
Sleep=1
[CODE472_OPTION]
NUM=1
Path1=u-boot-rockchip-usb472.bin
[OUTPUT]
PATH=rk3576_uboot_maskrom.bin

Leave the LOADER sections as rkbin ships them. db uploads only the CODE sections. Then build the container:

tools/boot_merger RK3576UBOOT.ini

The container carries the SoC that the recipe’s [CHIP_NAME] names. db --soc rk3576 checks that claim before the upload starts.

Raw stages

db also sends the two files as binman wrote them, with no rkbin tooling:

pyrographer db --code471 u-boot-rockchip-usb471.bin --code472 u-boot-rockchip-usb472.bin

The raw form sends the same payloads as the container. It names no SoC, so --soc has nothing to check. The raw form is [UNVERIFIED] on hardware, because the RK3576 boot used the container.

Booting the board

  1. Put the board in maskrom and connect its USB port. pyrographer list shows it as maskrom.

  2. In a second terminal, start uboot on the console before you upload:

    pyrographer uboot --port /dev/ttyUSB0 --baud 1500000 --reads 120 --cmd 'mmc info'
    

    It streams what the board prints. When U-Boot’s countdown appears, uboot stops it and runs mmc info.

  3. In the first terminal, upload the container:

    pyrographer db --soc rk3576 --loader rk3576_uboot_maskrom.bin
    

    For the raw form, run the command from Raw stages instead. db then reports that the board did not reappear within ten seconds. This is expected, because the RAM-booted U-Boot answers on its serial console.

  4. Read the console in the second terminal. SPL prints Trying to boot from RAM, which proves the BootROM ran the 472 stage. U-Boot proper’s banner and countdown follow, and then the mmc info answer.

On the RK3576, mmc info reports the eMMC, mmc@2a330000 at 116.5 GiB, as device 0. If it reports Card did not respond to voltage select! : -110 instead, device 0 is an empty slot. Find the eMMC with mmc list, and use its number wherever this guide writes mmc 0. For uboot --gadget, pass that number as --gadget-dev mmc:<n>.

U-Boot now waits at its prompt, running from RAM. A power cycle ends it.

Upload failures

The console stops before Trying to boot from RAM, and list still shows the board in maskrom. The BootROM took the 472 stage and did not run it. Check the stage as Checking the load address describes, then power-cycle into maskrom and upload again. On the RK3576, a stage with 0x40800000 at offset 8 uploaded whole and failed this way.

The upload stalls partway. A BootROM that received part of an earlier upload accepts no new one. Power-cycle into maskrom and run db again.

Reading the whole eMMC

U-Boot presents the eMMC to the host through either of two USB gadgets, the USB device functions that U-Boot runs on the board. The mass-storage gadget is the one measured on the RK3576.

Over USB mass storage

ums 0 mmc 0 presents the eMMC to the host as a USB disk, and pyrographer reads it as it reads any disk. To back up the eMMC:

  1. Start the gadget from the prompt:

    pyrographer uboot --port /dev/ttyUSB0 --baud 1500000 --gadget ums
    

    uboot types ums 0 mmc 0. A running gadget keeps the console, so a prompt that does not come back means the gadget started.

  2. Find the disk. It is the usb disk the size of the eMMC, 116.48 GiB on the RK3576:

    pyrographer list --blocks
    

    If the desktop mounts the board’s partitions, unmount them. dump refuses a disk that anything else holds.

  3. Read the sector count, then dump the whole disk. Both commands open the disk, which needs root:

    sudo pyrographer info --device /dev/sdb
    sudo pyrographer dump --device /dev/sdb 0 244285440 factory.img
    

    Replace /dev/sdb with the disk that list found, and 244285440 with the count that info printed.

  4. Stop the gadget with Ctrl+C at the console, from a terminal program such as picocom. A power cycle also stops it, and ends the RAM-booted U-Boot with it.

dump checks every window for the constant fill that a silent read failure leaves, and warns where it finds a run. A whole-disk dump with no fill warning holds no run of the 0xCC that rkbin’s loader returns. Constant-fill warnings describes the warning and its limit.

On the RK3576, dd read all 244285440 sectors through this gadget at ~30 MB/s, in about 70 minutes. Sector 65536 read as AArch64 boot code. A pyrographer dump through the same gadget is [UNVERIFIED].

Over rockusb

rockusb 0 mmc 0 presents the eMMC as a rockusb device, which pyrographer reads over USB without root. uboot starts it:

pyrographer uboot --port /dev/ttyUSB0 --baud 1500000 --gadget rockusb

Mainline’s rockusb gadget does not answer K_FW_READ_FLASH_INFO, so info, partitions and dump --partition fail against it. When asked for it, U-Boot prints Rockusb command 1a not support yet. A dump given an LBA and a sector count asks for no flash information.

Whether this gadget reads past sector 65536 is [UNVERIFIED]. Before any longer read, dump 2 MiB from that sector:

pyrographer dump 65536 4096 wall.img

If dump warns of fill from sector 65536, this gadget shares rkbin’s read wall. Dump through mass storage instead.

Writing the eMMC

Over mass storage, the eMMC is a block device. flash writes it through the same plan, confirmation and window-by-window read-back as any disk. The write permanently replaces the existing data on the sectors it covers. A write through the RAM-booted U-Boot is [UNVERIFIED] on the RK3576.

To write factory.img to the eMMC, starting at sector 0:

sudo pyrographer flash --device /dev/sdb 0 factory.img

Test-booting from USB

The RAM-booted U-Boot boots an operating system from a USB drive, and the eMMC stays untouched. Plug the drive in, then set the boot order for one boot:

pyrographer uboot --port /dev/ttyUSB0 --baud 1500000 --boot-from usb0

uboot shows the board’s current boot order beside the order it sets, and asks before it boots. It saves nothing to the board. Overriding the boot order describes the plan. A USB boot from the RAM-booted U-Boot is [UNVERIFIED] on the RK3576.

Flashing a disk image from maskrom

A whole-disk image, such as an OpenWrt release for one board, holds the boot chain and the partitions together, laid out from sector 0. This guide writes one to a Rockchip board’s eMMC from maskrom. db brings the board to loader mode, and flash plans the write, writes it and reads it back.

Two steps in this guide write to storage: Writing the image and Clearing a stale backup GPT. Each permanently replaces the data it covers. Every other step leaves storage as it is.

The steps and output in this guide are measured on a NanoPi R6S, an RK3588S board, on 2026-10-05. The image was OpenWrt 25.12.5, and the loader was FriendlyELEC’s. Rockchip RK3588 lists everything else measured on that board. The same steps apply to a board whose SoC is pinned, rk3576 or rk3588. On any other board, each step is [UNVERIFIED].

Requirements

Have these before you start:

  • A loader container for the SoC, either rkbin’s *_loader.bin or a board vendor’s MiniLoaderAll.bin. FriendlyELEC’s RK3588 eflasher image carries the one used here.
  • The disk image for the board
  • A USB cable from the host to the port the board’s maskrom answers on. On the R6S, that is a USB A-to-A cable to its USB 3.0 Type-A port.
  • USB access for pyrographer, set up as Permissions describes

Unpacking the image

flash writes the bytes it is given, so a compressed image is unpacked first. OpenWrt ships its images compressed with gzip, followed by metadata:

gzip -dc openwrt-25.12.5-rockchip-armv8-friendlyarm_nanopi-r6s-squashfs-sysupgrade.img.gz > openwrt.img

gzip reports “decompression OK, trailing garbage ignored”, and exits with status 2. The trailing bytes are OpenWrt’s metadata, and the image is whole. This one unpacks to 176160768 bytes.

Loading the loader

Put the board into maskrom, as its board page describes. For the R6S, see Entering maskrom. list then shows the BootROM:

pyrographer list
2207:350b  maskrom       bus 003 address 7  (bcdUSB 0200)

Upload the loader, naming the SoC so that db checks the file’s claim:

pyrographer db --soc rk3588 --loader MiniLoaderAll.bin
This loader's chip field holds 38 38 35 33 "8853" (rk3588).
Uploading 166.01 KiB...
Uploaded 166.01 KiB in 0.3s.
Loader uploaded. A new Rockchip device appeared at 003:8. `list` can still report it as
maskrom, because this loader keeps the bcdUSB flag even. The next command sent to it checks
that a loader answers.

Check that the loader answers, and read the size of the eMMC:

pyrographer chipver
pyrographer info
Device:       2207:350b (maskrom)
Chip version: 16 bytes
  hex         38 38 35 33 ff ff ff ff ff ff ff ff ff ff ff ff
  ascii       8853............
Device:       2207:350b (maskrom)
Flash ID:     45 4d 4d 43 20
Flash size:   28.91 GiB (60620800 sectors of 512 bytes)

The heading still reads maskrom, because this loader keeps the even flag. The replies come from the loader. Note the sector count, which Clearing a stale backup GPT uses.

If every command after the upload reports maskrom, no loader answered. Power-cycle the board into maskrom and upload again, as Bringing a maskrom board to loader mode describes.

Planning the write

A whole-disk image starts at sector 0. Print the plan without writing anything:

pyrographer flash --soc rk3588 --dry-run 0 openwrt.img
This will overwrite 168.00 MiB of flash on 2207:350b.

  image        openwrt.img
               168.00 MiB
  LBA range    0 through 344063 (344064 sectors)
  byte range   0 through 176160767
  touches                       311296 of its 59965440 sectors
  flash        28.91 GiB (60620800 sectors of 512 bytes)
  loader says  38 38 35 33 ff ff ff ff ff ff ff ff ff ff ff ff  "8853............"
  named SoC    rk3588: the loader's answer matches

Each window is read back and compared before the next one is written. A mismatch stops the write at that window.
Nothing here can be undone.
Dry run: nothing was written.

Check two lines. The named SoC line says the loader’s answer matches the SoC you named. The touches line names what the write lands in. Here it is the one partition the eMMC held before, which had no name.

Writing the image

Run the same command without --dry-run, and answer y:

pyrographer flash --soc rk3588 0 openwrt.img
Writing 168.00 MiB...
Wrote 168.00 MiB in 16.8s.
Wrote openwrt.img and read every window of it back.

Clearing a stale backup GPT

A GPT keeps a backup copy in the disk’s last 33 sectors. An image laid out with an MBR, as OpenWrt’s is, replaces the primary GPT and leaves that backup behind. Disk tools such as sgdisk then report a damaged primary, and offer to restore it from the backup. That restore writes the old partitions over the image’s MBR.

Skip this step if the disk held no GPT before the write. Otherwise, write 33 zero sectors at the end of the eMMC. Their first LBA is the sector count from info, less 33:

head -c 16896 /dev/zero > zero-33.bin
pyrographer flash --soc rk3588 60620767 zero-33.bin

The plan for this write reports that the device has no partition table:

  touches      nothing that can be named: this device has no partition table

pyrographer reads a disk whose primary GPT signature is gone as having no GPT. It does not read an MBR, so it reports no table at all.

Verifying and booting

Compare the eMMC with the image, through a fresh open:

pyrographer verify 0 openwrt.img
Verifying 168.00 MiB...
Verified 168.00 MiB in 17.7s.
Flash matches openwrt.img.
Note: 158.27 MiB read back as constant 0x00 or 0xff, across 4 regions, consistent with erased or unallocated flash.

The note describes the image’s own empty space. This image compresses to 9 MB, so most of its 168 MiB is blank.

Reboot the board out of the loader:

pyrographer reset
The board is rebooting.

The board leaves the bus and boots from the eMMC. The R6S came up in OpenWrt 25.12.5. If the BootROM finds nothing it can boot, it falls back to maskrom, and the board appears in list again [DOC].

Rockchip RK3576

The RK3576 is one of two SoCs the wrong-loader gate is armed for. Every fact on this page is measured on one board, the H96 Max M9, unless a tag says otherwise. On another RK3576 board, each fact is [UNVERIFIED] until that board confirms it.

Identity

FactValue
USB ID, in maskrom and in loader mode2207:350e
chipver reply36 37 35 33 and 12 zero bytes, which reads “6753”
Chip field of an RKBOOT container built for it36 37 35 33
--soc namerk3576
Start of DRAM0x40000000
Serial console1500000 baud

The chipver reply is the SoC’s four ASCII digits, byte-reversed. db --soc rk3576 checks a container’s chip field before an upload, and --soc rk3576 checks the chipver reply before a write.

Maskrom

The BootROM is part of the SoC, so no write to storage removes maskrom mode. A board in maskrom presents the same USB ID, interface and bulk endpoints as a loader. Its endpoints serve nothing, so the verbs tell the two apart by sending a probe command.

rkbin’s SPL loader keeps the even bcdUSB flag that maskrom presents. On this SoC, the mode column in list therefore cannot tell maskrom from that loader, as Listing devices describes.

Two loaders boot from maskrom:

  • rk3576_spl_loader_v1.12.108.bin from rkbin, which serves rockusb
  • Mainline U-Boot v2026.04, built with CONFIG_ROCKCHIP_MASKROM_IMAGE and packed with boot_merger, which RAM-booting mainline U-Boot from maskrom covers

The BootROM accepts a corrupt upload and discards it without reporting an error. Bringing a maskrom board to loader mode describes how that failure appears, and how to retry.

The ID block

The values in this section are measured from rkbin’s rk3576_spl_loader_v1.12.108.bin, not on the board. Whether the board boots from the ID block they describe is [UNVERIFIED].

The container carries four flash stages. Each is stored scrambled per 512-byte block, and the container’s RC4 flag is set, so the flash holds them unscrambled. Unscrambled, FlashHead is an RKNS header, and the rest are the images it describes:

ImageStageSectorSectorsLoads at
0FlashBoost880x3ffc0000
1FlashData161600x3ff81000
2FlashBoot176528none

The header fills sectors 0 to 7, so the ID block is 704 sectors, written from sector 64. The header records each image’s SHA-256, and a SHA-256 of its own first 1536 bytes. Every one of them matches. FlashData is the DRAM init, byte for byte the 471 stage db uploads.

rkdeveloptool’s ul writes a different block from this container. It joins FlashHead, FlashData and FlashBoot, and leaves out FlashBoost. That puts the DRAM init at sector 8 and the SPL at sector 168, where the header says 16 and 176. write-idb and flash-firmware lay the block out as the header says, as Writing the ID block alone describes.

Writing the ID block needs a loader that claims NEW_IDB in its capability reply. Whether rkbin’s SPL loader claims it is [UNVERIFIED].

H96 Max M9

The H96 Max M9 is an Android TV box.

FactValue
eMMCSamsung, 244285440 sectors of 512 bytes (116.48 GiB)
Flash ID that info reports45 4d 4d 43 20, which reads “EMMC “
Partition tableGPT, with Android’s 16 partitions
The eMMC under mainline U-Bootmmc 0, at mmc@2a330000

Under mainline U-Boot, mmc 1 is an empty slot, and mmc info on it reports Card did not respond to voltage select! : -110.

Entering maskrom

If the eMMC clock line is shorted to ground at power-on, the BootROM enters maskrom whatever the eMMC holds [DOC]. Which mode the box’s hidden button enters is [UNVERIFIED].

The 32 MiB read wall

Through a rockusb loader, the H96 Max M9 answers every read at or past sector 65536 (32 MiB) with constant 0xCC and a success status. Two loaders do this: rkbin’s SPL loader and the factory U-Boot’s own rockusb gadget. The eMMC holds real data there. U-Boot’s mass-storage gadget and its mmc read command both read the whole eMMC.

Keep a read through rockusb wholly below sector 65536. A read that crosses it can return fill for its sectors below the wall as well. Through the factory gadget, a 16-sector dump from sector 65528 returned 0xCC for all 16 sectors.

The first 65536 sectors hold the partition table and the boot chain, from the ID block at sector 64 through vbmeta. A dump of them covers everything a bootloader write can break:

pyrographer dump 0 65536 first-32mib.img

dump warns of constant fill wherever it finds a run, as Constant-fill warnings describes. To read the whole eMMC, RAM-boot mainline U-Boot and dump through its mass-storage gadget, as the maskrom guide describes. Whether other RK3576 boards share the wall is [UNVERIFIED].

Rockchip RK3588

The RK3588 is the second SoC the wrong-loader gate is armed for. Every fact on this page is measured on one board, the NanoPi R6S, unless a tag says otherwise. The R6S carries an RK3588S, which is the RK3588 in a smaller package, and its loader answers as an RK3588. On another RK3588 or RK3588S board, each fact is [UNVERIFIED] until that board confirms it.

Identity

FactValue
USB ID, in maskrom and in loader mode2207:350b
chipver reply38 38 35 33 and 12 bytes of 0xff, which reads “8853”
Chip field of an RKBOOT container built for it38 38 35 33
--soc namerk3588

The chipver reply opens with the SoC’s four ASCII digits, byte-reversed, as on the RK3576. The 12 bytes after them differ, because an RK3576 loader sends zeros there. Whether those bytes come from the SoC or from the loader build is [UNVERIFIED]. The gate compares the whole reply, so it refuses a loader that pads differently until a board pins that reply.

Maskrom

The BootROM presents 2207:350b with an even bcdUSB flag and no string descriptors. The usbplug loader in FriendlyELEC’s MiniLoaderAll.bin keeps the same ID and the same even flag. It adds the strings RockChip, USB-MSC and rockchip. The mode column in list reads maskrom for both. The verbs settle it with a probe command, as Listing devices describes.

db --loader MiniLoaderAll.bin uploads 166 KiB in under half a second. The usbplug loader comes back at a new address within the same second. An RK3576 takes somewhat over three seconds.

The usbplug loader hung once, straight after an upload. It enumerated, then answered no command and no standard USB request until the board was power-cycled. A second upload ran cleanly for the rest of the session. The cause is [UNVERIFIED]. If every command after an upload reports maskrom, power-cycle the board into maskrom and upload again.

The usbplug loader

The usbplug loader serves every query pyrographer sends, and capability reports 3f 07. That sets read LBA and new IDB, along with two bits pyrographer has no name for.

The loader puts the reply’s length, big-endian, where the CSW reports the bytes left untransferred. pyrographer reads a value larger than the request as carrying no accounting. It checks the reply’s length directly instead.

Reads through this loader reach the last sector of the eMMC, with no constant fill. One command served 648 sectors.

Writes through it land and read back. A 168 MiB flash from sector 0 took 16.8 seconds, with every window read back and compared. A verify through a fresh open then matched the whole image. reset in its default mode reboots the board, which leaves the bus at once.

NanoPi R6S

The NanoPi R6S is a router board with an RK3588S.

FactValue
eMMC60620800 sectors of 512 bytes (28.91 GiB)
Flash ID that info reports45 4d 4d 43 20, which reads “EMMC “
Medium that storage reportseMMC
Loader usedThe usbplug in MiniLoaderAll.bin, from FriendlyELEC’s RK3588 eflasher image

Entering maskrom

Hold the MASK button and apply power [DOC]. Connect a USB A-to-A cable from the host to the board’s USB 3.0 Type-A port. On the other Type-A port, the host sees nothing at all. The board keeps its own USB-C supply throughout, and the A-to-A cable carries data.

Reading

A dump of three ranges matched a raw read byte for byte. The ranges were the first 64 sectors, the 648 sectors from sector 64, and the last 33 sectors of the eMMC.

OpenWrt

The R6S boots OpenWrt 25.12.5 written to its eMMC from maskrom, as Flashing a disk image from maskrom describes.

FactValue
Imageopenwrt-25.12.5-rockchip-armv8-friendlyarm_nanopi-r6s-squashfs-sysupgrade.img.gz
Unpacked size176160768 bytes (344064 sectors)
Partition tableMBR: a 16 MiB boot partition at sector 65536, and a 104 MiB root at sector 131072
Boot chainAn RKNS ID block at sector 64, and a U-Boot FIT at sector 16384

A write took 16.8 seconds with its read-back, and a verify took 17.7 seconds.

Command line

The pages in this part cover the command-line tool, one group of commands to a page. Each section gives what to type, what it prints, and what it refuses. This page covers finding a device and naming one.

Listing devices

Connect a board in its boot or recovery mode, then list the detected devices:

pyrographer list
2207:350e  loader        bus 003 address 12  (bcdUSB 0201)

list --blocks also lists the machine’s own disks, which Block devices covers.

list needs no permission to open a device, because it reads everything it prints from descriptors the operating system has already cached. Discovery scans by vendor ID, so it finds any Rockchip SoC in device mode, including a part pyrographer has no entry for.

The mode column is a claim, read from the low bit of bcdUSB. An odd flag means a loader, and pyrographer trusts it. An even flag means either the BootROM or a loader that does not set the flag. The RK3576 SPL loader and the RK3588 usbplug loader both leave it even.

The device-bound commands settle an even flag by asking. They open the board and send one probe command, which a running loader answers. A BootROM presents the same bulk endpoints with nothing serving them, so it fails the probe. The command then refuses, names the mode as maskrom, and points to db.

A board that answers the probe is running a loader, even when pyrographer refuses the answer. The command then reports that refusal, and not maskrom, because an upload would not fix it.

Choosing between boards

If one board is connected, every device-bound command acts on it without being told which. If more than one is connected, name the board with --device and the bus and address that list prints:

pyrographer list
2207:350e  loader        bus 003 address 12  (bcdUSB 0201)
2207:350e  loader        bus 003 address 14  (bcdUSB 0201)
pyrographer info --device 003:14

The bus and address distinguish two identical boards, which share a product ID.

--device also names a block device by its node, as in --device /dev/sdb. The leading slash distinguishes the two forms. pyrographer never chooses a block device by default, so --device is the only way to select one.

Bootstrapping a board

A board running its on-chip boot code serves no flash commands. The commands on this page upload code that does serve them, over the vendor’s own download protocol. db uploads to a Rockchip BootROM, and usbboot to an Ingenic boot ROM. Neither command writes flash. A StarFive board’s BootROM has no USB, and uartboot boots it into U-Boot over its serial line instead, as Serial lines describes.

Bringing a maskrom board to loader mode

db brings a Rockchip board from maskrom mode, where the BootROM runs, to loader mode. It takes a Rockchip loader container for the SoC, and uploads it over the maskrom download-boot protocol. rkbin ships one as *_loader.bin, and board vendors ship one as MiniLoaderAll.bin.

list shows the board in maskrom mode:

pyrographer list
2207:350e  maskrom       bus 003 address 12  (bcdUSB 0200)

db uploads the loader, and the board comes back in loader mode:

pyrographer db --soc rk3576 --loader rk3576_spl_loader_v1.12.108.bin
This loader's chip field holds 36 37 35 33 "6753" (rk3576).
Uploading 420.01 KiB...
  100.0%  420.01 KiB of 420.01 KiB  455.10 KiB/s
Uploaded 420.01 KiB in 0.9s.
Loader uploaded. A new Rockchip device appeared at 003:13. `list` can still report it as
maskrom, because this loader keeps the bcdUSB flag even. The next command sent to it checks
that a loader answers.

db parses the file before it opens any device, and refuses a file that is not an RKBOOT loader. It also refuses a board that is not in maskrom mode. A Rockchip firmware package (update.img) carries a loader, and --loader takes the package itself, as A board in maskrom shows.

The container names the SoC it was built for, and db prints that claim first. With --soc, db checks the claim, and refuses a file built for another SoC before it uploads a byte. Without --soc, db prints the claim and checks nothing.

This check is weaker than the wrong-loader gate on a write. Whoever built the file wrote the claim, so the check catches a wrong file chosen by mistake, and not a false claim. It is the only check available, because a maskrom board does not answer chipver.

When an upload appears to go wrong, three behaviors matter:

  • A corrupt upload fails silently. Measured on an RK3576, the BootROM acknowledges every transfer of a bad section. It checks the section’s trailing CRC after the last chunk, and discards a bad section without reporting an error. The failure surfaces one stage later, as an upload that stalls. A BootROM left with a partial upload also refuses a fresh one. Power-cycle the board and put it back into maskrom before you retry.
  • The board comes back at a new address. After the final stage, the loader tears down the maskrom USB device and re-enumerates. An RK3576 takes somewhat over three seconds, and an RK3588S takes under one. db watches for the board for ten seconds. If the board has not reappeared by then, db tells you to run list, and reports no error.
  • A loader can stop answering. Seen once on an RK3588S, the loader enumerated and then answered nothing until the board was power-cycled. Every command then reports maskrom. Power-cycle the board into maskrom and upload again.

The board that comes back can still carry an even bcdUSB flag, because the RK3576 SPL loader and the RK3588 usbplug loader never set it. The verbs resolve that by asking, as Listing devices describes.

Raw stages

Mainline U-Boot’s CONFIG_ROCKCHIP_MASKROM_IMAGE has binman emit the download-boot stages as two bare files:

  • u-boot-rockchip-usb471.bin, the DRAM init
  • u-boot-rockchip-usb472.bin, the SPL and the full FIT after it

db sends those in place of a container:

pyrographer db --code471 u-boot-rockchip-usb471.bin --code472 u-boot-rockchip-usb472.bin

The result is a mainline U-Boot running entirely from RAM. Because nothing is written to flash, the board’s storage can be untouched, broken or not yet trusted. From that prompt, U-Boot’s gadgets hand the flash to the host, and its distro boot can test-boot a system before it is installed.

You can give one stage or both, and db always sends stage 471 first. The chunking, the pad rule and the trailing CRC are the same as for a container. A container’s sections are these same payloads, each under a name. Bare stages carry no container, so they name no SoC. db reports that nothing checks which board they are for.

On an RK3576, a 3 MiB 472 stage packed in a boot_merger container and sent with --loader RAM-boots to a U-Boot prompt. The raw form sends the same payloads, and is [UNVERIFIED] against hardware. RAM-booting U-Boot from maskrom gives the whole procedure. It includes the load-address check that catches a 472 stage the BootROM will not run.

A board booted this way answers on its serial console, and does not return to the USB bus by itself. At its U-Boot prompt, uboot tells it to start a gadget. The board then appears in list as a loader, or its flash appears in list --blocks as a disk.

Bringing an Ingenic board to DFU

usbboot brings an Ingenic XBurst board from its boot ROM to DFU mode. The Ingenic backend targets the XBurst2 camera SoCs: the T31, T40 and T41. These SoCs are found in IP cameras such as the Wyze Cam v3, which is built on a T31. The whole upload sequence is [UNVERIFIED] against hardware.

usbboot uploads two stages: a DRAM-init SPL, then a DFU-capable U-Boot.

pyrographer usbboot --stage1 spl.bin --stage1-addr "$SPL_ADDR" \
    --stage2 u-boot.bin --stage2-addr "$UBOOT_ADDR"

Supply the two stages and their load addresses for your board. usbboot has no default addresses. --dram-settle-ms sets how long usbboot waits after stage 1 for DRAM to come up. The default is 2000 ms, a community figure. [COMMUNITY]

On success, usbboot prints the CPU info the boot ROM reported. The board then re-enumerates as a DFU device, a108:4d44.

A DFU board addresses named regions, its DFU alt-settings, rather than a device-wide LBA. partitions lists those regions, and dump, verify and flash aim at one with --partition. They refuse a raw LBA. clone and the partition-table commands refuse a DFU board outright, because each addresses a device-wide LBA. All three reads are [UNVERIFIED] on a DFU board. A DFU write is built, and refuses at the wrong-loader gate because no Ingenic SoC is pinned.

Asking a loader

The commands on this page ask a running Rockchip loader about itself and its flash. None of them writes flash. chipver, storage and reset refuse a block device by name, because a disk runs no loader.

Reading flash geometry

info reports the loader’s flash ID and the size of the flash:

pyrographer info
Device:       2207:350e (loader)
Flash ID:     45 4d 4d 43 20
Flash size:   58.24 GiB (122142720 sectors of 512 bytes)

Reading the chip version

chipver asks the running loader which SoC it is on, and prints the reply as received:

pyrographer chipver
Device:       2207:350e (loader)
Chip version: 16 bytes
  hex         36 37 35 33 00 00 00 00 00 00 00 00 00 00 00 00
  ascii       6753............

This reply is from an RK3576: the SoC’s four ASCII digits byte-reversed, then zeros. An RK3588 answers 38 38 35 33 and twelve bytes of 0xff. chipver sends nothing that changes the device, so you can run it on any board in loader mode.

Every write is gated on this reply. The wrong-loader gate compares the whole reply with the exact bytes a real board returned, and decodes nothing. The two replies above differ after their digits, which is why the gate compares all 16 bytes. A USB product ID names only a family, so the loader is the one authority on which SoC it runs on.

To pin a new SoC, run chipver on a board whose SoC you know. The reply and the SoC together make a new entry in core’s soc module.

Reading the loader’s capabilities

capability asks the loader which features it serves:

pyrographer capability
Device:       2207:350b (maskrom)
Capability:   3f 07 00 00 00 00 00 00
  yes direct LBA
  yes vendor storage
  yes first 4M access
  yes read LBA
  yes read COM log
   no read IDB config
   no read secure mode
  yes new IDB
  yes switch storage

This loader set bits pyrographer has no name for: 10 04 00 00 00 00 00 00

This reply is from the usbplug loader on an RK3588 board. That loader keeps the even bcdUSB flag, so the heading reads maskrom although a loader answered, as Listing devices describes.

The flags are the loader’s own account of itself, and can disagree with what pyrographer implements. A loader that does not set read LBA does not serve the read path, however well the host implements it.

Writing an ID block is gated on new IDB, as Writing the ID block alone describes. Nothing else is gated on these flags. pyrographer reports a bit it has no name for, rather than ignoring it.

Reading the storage medium

storage asks the loader which storage medium is active:

pyrographer storage
Device:       2207:350e (loader)
Storage:      eMMC

A rockusb loader addresses one storage medium at a time, and every LBA is an offset into that medium. On a board with both an eMMC and a SPI NOR, sector 16384 names two different places.

storage reports the medium and leaves it as it is. Like chipver and capability, it sends nothing that changes the device.

Rebooting and reset modes

reset reboots the board:

pyrographer reset
The board is rebooting.

--mode chooses what the board does once it has acknowledged the command, by setting the command’s subcode:

--modeWhat the board does
resetReboots. The default, and the only mode a board has answered.
mscReboots into USB mass storage. The host operating system then owns it as a block device, and rockusb does not answer. [COMMUNITY]
poweroffPowers off instead of rebooting. [COMMUNITY]
maskromReboots into maskrom, the mode a shorted pin otherwise reaches. db then brings a loader back. [COMMUNITY]
pyrographer reset --mode maskrom
maskrom: this subcode comes from the reference tools, which agree on it, and is untried on a board. It writes no flash. [COMMUNITY]
The board is rebooting into maskrom, where no loader runs. Upload a loader to return it to loader mode.
Run `pyrographer db --loader <file>` to upload a loader.

The three tagged modes use the reference tools’ subcodes, which agree with one another and are untried on a board. None of them writes flash, so none is behind the write gate. Before it sends one of them, reset prints a caution that the mode is untried and writes no flash. Each of these modes ends the session where the verbs cannot follow, and reset says so once the board has acted. reset refuses a mode name outside these four before it opens a device.

Partition tables

pyrographer reads two partition-table formats, GPT and Rockchip’s parameter block. It repairs a damaged copy of either from an intact one, and writes a fresh table from a layout. A table also lets you aim dump, flash and verify at a partition by its name.

Reading the partition table

partitions prints the board’s partition table:

pyrographer partitions
Device:       2207:350e (loader)

Partition table: GPT (5 partitions)

  NAME                    FIRST LBA      SECTORS  SIZE
  uboot                       16384         8192  4.00 MiB
  trust                       24576         8192  4.00 MiB
  misc                        32768         8192  4.00 MiB
  boot                        40960       229376  112.00 MiB
  rootfs                     270336    121872384  58.11 GiB

A partition can be named instead of an LBA:  --partition <name>
The table records where each partition ends, so on a write, only a name
can refuse an image too big to fit in it.

A board carries one of two table formats. Modern Rockchip boards use a standard GPT. Older ones use Rockchip’s own parameter block, whose CMDLINE line lists the partitions. pyrographer detects the format by probing for each in turn: EFI PART at sector 1, then a PARM block where one is kept.

pyrographer does not read an MBR. A disk laid out with an MBR alone reads as having no partition table, and is aimed by LBA. OpenWrt’s image for a Rockchip board is one such disk.

A board with no partition table is in a valid state. partitions says so, and succeeds:

Device:       2207:350e (loader)

This device has no partition table.

Damaged tables

A GPT keeps two copies: the primary near the front of the device, and the backup in its last sector. If the primary is damaged and the backup is intact, partitions prints a warning, then lists the backup’s partitions:

Device:       2207:350e (loader)

Warning: the primary GPT is damaged. These partitions were recovered from
         the backup GPT in the device's last sector.
         Primary GPT: the header's CRC is 0x2ab4f019, and the header says 0x00000000

Partition table: GPT (5 partitions)

  NAME                    FIRST LBA      SECTORS  SIZE
  uboot                       16384         8192  4.00 MiB
  ...

pyrographer reads the backup from the device’s last sector. It ignores the damaged primary’s record of where the backup is. A primary whose signature is gone entirely reads as no GPT, and the backup is not consulted.

If neither copy is intact, partitions fails with an error rather than printing an empty list. The error tells you the table is damaged, for example half-written, before you write to the board again:

error: the GPT partition table on this device is damaged: the primary GPT is damaged
(the header's CRC is 0x2ab4f019, and the header says 0x00000000), and the backup GPT header
expected in the last sector (LBA 122142719) is absent

Commands that take a raw LBA still work on such a board. With no intact copy to repair from, author a fresh table.

Repairing a damaged partition table

repair-table rewrites a damaged GPT copy from the intact one, in either direction:

  • A damaged primary is rebuilt from the backup, the case partitions warns about.
  • A stale, damaged or missing backup is rebuilt from the primary.

Either way, the device ends with two intact copies. A repair is a write like flash. It overwrites the damaged copy, and cannot be undone. The loader must match the SoC you name with --soc, and repair-table reads back every window. --dry-run and --yes work as they do for flash.

This repair rebuilds a damaged primary from the backup:

pyrographer repair-table --soc rk3576

This will repair the GPT table on 2207:350e,
from the backup GPT in the device's last sector.

  rewriting    the primary GPT (sector 1 onward)
               LBA 1 through 33 (33 sectors of 512 bytes)
  restores     uboot            8192 sectors
               trust            8192 sectors
               misc             8192 sectors
               boot             229376 sectors
               rootfs           121872384 sectors
  flash        58.24 GiB (122142720 sectors of 512 bytes)
  loader says  36 37 35 33 00 00 00 00 00 00 00 00 00 00 00 00  "6753............"
  named SoC    rk3576: the loader's answer matches

The intact copy is not touched. Each window is read back and compared before the next one is written. A mismatch stops the write at that window.
Nothing here can be undone.
Proceed? [y/N] y
Repairing 16.50 KiB...
  100.0%  16.50 KiB of 16.50 KiB  1.63 MiB/s
Repaired 16.50 KiB in 0.1s.
Rewrote the primary GPT (sector 1 onward) and read every window of it back.

The other direction rewrites the backup in the last sectors of the disk. It is the safer of the two, because it never writes the primary the board boots from. A stale backup passes its checksums but describes an older layout, such as one a tool left behind after rewriting only the primary. repair-table treats a stale backup as damaged, because the primary is authoritative.

The rewritten copy is byte-faithful. repair-table copies the intact copy’s partition entries exactly, so every partition keeps its type GUID and unique GUID. It never writes the intact copy, so one good copy survives throughout the repair.

If both copies are intact and agree, or the device has no GPT, there is nothing to repair. repair-table says so and writes nothing:

pyrographer repair-table --soc rk3576
error: invalid request: both copies of this device's GPT are intact and agree, so there is
nothing to repair

repair-param does the same for Rockchip’s parameter block. Raw NAND keeps several copies of it, so repair-param rewrites a damaged copy from an intact one. An eMMC keeps a single copy, with no other copy to rebuild it from. On an eMMC, author a fresh table instead.

Authoring a partition table

author-gpt and author-param write a fresh table from a layout you supply. Authoring is the fix for a board with no intact copy anywhere. Each is a write like flash. It is gated on the loader match, reads back every window, and cannot be undone.

Both read the same native layout, one partition per line:

name first_lba sectors [type] [uuid=<GUID>]

Numbers are hex or decimal. A - in place of sectors grows the partition to the end of the device. For author-gpt it stops at the last sector before the backup GPT. # starts a comment. Both commands also read a board’s own mtdparts= line from a file given with --mtdparts.

A layout file, rk3576.layout:

# name    first_lba  sectors  type
uboot     0x4000     0x2000   linux
trust     0x6000     0x2000   linux
boot      0x8000     0x38000  linux
rootfs    0x40000    -        linux
pyrographer author-gpt --soc rk3576 --layout rk3576.layout

author-gpt writes a protective MBR, a primary, and a backup in the device’s last sectors. A partition’s type comes from its type token:

  • linux or data, for Linux data
  • esp or efi, for an EFI system partition
  • swap, for Linux swap
  • jh7110-spl, for the partition a StarFive JH7110 boot ROM loads its SPL from in SD mode
  • A raw type GUID, for any other type

A partition with no type token is Linux data.

author-gpt synthesizes the disk GUID and each partition’s unique GUID from the layout. Authoring the same layout twice therefore yields the same table, and a re-author verifies against an earlier dump. To pin a specific value, add a disk-guid <GUID> line or a uuid= attribute.

author-param writes a Rockchip parameter block, and requires --medium emmc or --medium nand. The medium decides where the copies go and what a layout’s offsets count from. On eMMC, the block has one copy at sector 0x2000. On raw NAND, it has several, from sector 0. A GPT’s offsets are absolute, so author-gpt takes no medium.

author-param also takes --from-block <file>, the whole text of an existing parameter block. It frames that text verbatim, so FIRMWARE_VER and every other key are kept. A block built from a layout is minimal, and carries none of them.

Both commands check the table as they build the plan, before anything is written. They refuse each of these:

  • A partition in a region the table format reserves
  • A partition that runs past the end of the device
  • Two partitions that overlap
  • A partition of zero sectors
  • Two partitions with the same name

Names must be unique because --partition aims a write by name, at a range whose end is known. Addressing a partition by name describes it.

The JH7110 eMMC fix-up

A StarFive JH7110 boot disk carries two words in its first two sectors that the GPT does not own. In eMMC mode, the boot ROM reads a header from the start of the disk. A sentinel fails the header’s check, and the ROM loads its SPL from a backup address instead. spl_tool -i writes both words: the backup address at byte 0x4, and the sentinel 0x5A5A5A5A at byte 0x290.

author-gpt writes a fresh protective MBR over the first word, and repair-table rewrites the sector that holds the second. Both commands keep the fix-up a disk already carries, and the plan says so on a keeps line. The line names the backup address and the partition it falls in:

  keeps        This write keeps the JH7110 boot ROM's eMMC fix-up that the disk carries. The ROM loads its SPL from byte 0x200000, inside partition 'spl'.

A disk without the sentinel gets no fix-up, and its plan has no keeps line. A repair of the backup GPT writes nothing near the fix-up.

Addressing a partition by name

Every command that takes an <lba> also takes --partition <name> in its place. The range then comes from the device’s own table:

pyrographer dump --partition boot boot.img
Partition:    boot (LBA 40960, 229376 sectors, 112.00 MiB)
Reading 112.00 MiB...
  100.0%  112.00 MiB of 112.00 MiB  4.18 MiB/s
Read 112.00 MiB in 26.8s.
Wrote boot.img

With --partition, dump takes no sector count, because the table records each partition’s length.

On a write, a name is the safer form. An LBA is a number you supply, and pyrographer cannot check it against a partition. A name resolves against the table, which records where the partition ends, so only a name can refuse an image too big to fit:

pyrographer flash --partition uboot too-big.img
error: invalid request: the image is 5242880 bytes, which needs 10240 sectors, and the
partition 'uboot' is 8192 sectors long. Writing it there would overrun the end of 'uboot'
into whatever follows it

At a raw LBA, the same image runs through the end of uboot and into trust. The board accepts the write, because the sectors after a partition are valid sectors.

Names match exactly. If the board has no partition with the name you give, the error lists the names it has:

pyrographer flash --partition Boot boot.img
error: this device has no partition named 'Boot'. Its partitions are: uboot, trust, misc, boot,
rootfs

pyrographer does not correct a near miss such as Boot for boot. On a board with both boot and boot_a, a guessed match can aim a write at the wrong partition.

Reading and writing flash

These four commands copy or compare an image between a file and a device’s flash, or between two devices:

  • dump reads a range of flash into a file.
  • flash writes an image to a range of flash, then reads it back to check the result.
  • verify compares flash with a file, and changes neither.
  • clone copies the whole of one board’s flash onto another board.

A write permanently replaces the existing data on its target. Before each write, pyrographer prints a plan of the changes and proceeds only on explicit confirmation. Each command streams a window at a time, so no image has to fit in memory.

Dumping flash

dump reads a range of sectors into a file:

pyrographer dump 0 2048 boot.img
Reading 1.00 MiB...
  100.0%  1.00 MiB of 1.00 MiB  4.21 MiB/s
Read 1.00 MiB in 0.2s.
Wrote boot.img

The arguments are the starting LBA, the number of sectors, and the output file. --partition <name> replaces the first two.

dump refuses an output file that already exists. To overwrite it, pass --force. If a dump fails partway, dump warns that the file holds only a partial image.

Constant-fill warnings

If a large region reads back as one repeated byte, with a success status, dump prints a warning and still writes the image:

Warning: 84.00 MiB from sector 65536 (32.00 MiB in) read back as constant 0xcc.
         Constant fill reported as a successful read can be a silent read failure rather than data. If this region holds data, read it again another way, such as through mass storage or the board's console, before you trust the image.

Some loaders answer a read past a certain point with a buffer they never filled. Every sector holds the same byte, and the read reports success. A dump across such a region looks complete and is not. The fill is stable, so reading again does not reveal it.

The warning does not stop the dump. The same fill can be valid data on another board, so you decide whether the region is trustworthy.

A run of 0x00 or 0xff is how erased or unallocated flash normally reads, so dump reports it as a note rather than a warning:

Note: 12.00 GiB read back as constant 0x00 or 0xff, across 2 regions, consistent with erased or unallocated flash.

Both messages go to standard error. A silent read failure that returns 0x00 or 0xff looks the same as blank flash, so it produces the note and not the warning.

Writing an image to flash

flash writes an image to the device, permanently replacing the data in the range it covers. It first prints a plan of the write, and writes only once you confirm it:

pyrographer flash --soc rk3576 16384 u-boot.img

This will overwrite 4.01 MiB of flash on 2207:350e.

  image        u-boot.img
               4.01 MiB, padded with 1.52 KiB to 4.01 MiB
  LBA range    16384 through 24589 (8206 sectors)
  byte range   8388608 through 12590079
  touches      uboot            the whole of it (8192 sectors)
               trust            14 of its 8192 sectors
  flash        58.24 GiB (122142720 sectors of 512 bytes)
  loader says  36 37 35 33 00 00 00 00 00 00 00 00 00 00 00 00  "6753............"
  named SoC    rk3576: the loader's answer matches

Each window is read back and compared before the next one is written. A mismatch stops the write at that window.
Nothing here can be undone.
Proceed? [y/N]

pyrographer builds the plan by querying the device, through the same code path as the write. --dry-run prints the plan and stops there, and --yes skips the question. If no terminal is attached and --yes is absent, flash refuses.

flash also refuses a Rockchip firmware package and a loader container, which do not boot when written raw. Firmware files written raw shows the refusal and the commands that write each.

The touches line

Read the touches line before you confirm. It names the partitions the range lands in, which you can check against what you intended. The LBA range is arithmetic, and is correct whether or not it is what you meant. In the plan above, the write covers the whole of uboot and 14 sectors of trust. If you meant to write boot, answer n.

The image in this plan is 14 sectors too long for uboot. The device accepts the write, because the sectors after uboot are valid sectors. pyrographer refuses the same write aimed by name, as Addressing a partition by name shows.

On a board with no partition table, the plan says so:

  touches      nothing that can be named: this device has no partition table

On a board whose table is damaged, the plan says so. It still plans the write, because writing a fresh table is how a damaged one is repaired:

  touches      unknown: the GPT on this device is damaged,
               so the plan cannot name what these sectors hold:
               the header's CRC is 0x2ab4f019, and the header says 0x00000000

Read-back

flash writes each window, reads it back from the flash, and compares it with what was sent, before the next window goes out. The read-back is mandatory, and no flag turns it off. Both eMMC and NAND can report success before a block is committed, so only a read-back confirms a write.

Progress counts a window once it is written and verified, so the reported rate is roughly half the raw bus throughput.

A difference stops the write and names the byte:

pyrographer flash --soc rk3576 64 boot.img --yes
error: flash differs from the image at byte 777: read 0xff, expected 0x5a

pyrographer neither retries nor rolls back. A retry that succeeds can make a failing eMMC look healthy. The windows already written stay written, and the data they replaced is gone. You decide whether the difference is a bad block, the wrong image, or a failing board.

Read-back on a DFU board

The per-window read-back applies to a rockusb board and to a block device. On a DFU board, flash writes and commits the whole range, then reads it back and checks it. A DFU board, such as an Ingenic board brought up by usbboot, holds every block of a download until the session ends. It serves no read until then. The wrong-loader gate refuses a write to an Ingenic DFU board, because no Ingenic SoC is pinned.

The check is still mandatory, and still holds for an image larger than memory. A difference is found only once the whole range is written, not after one window of it. The plan’s closing lines state which read-back applies:

This device can be read back only after the write is committed. The whole range is written,
then read back and compared. The comparison cannot be skipped, and a mismatch is found with the
whole range already written.

The image streams once and is never held in memory, so the error names the window that differs rather than the byte. It also states that the range was written:

error: the flash differs from what was written, in the 1048576-byte window at byte 0. This
backend can be read back only after a write is committed, so the difference was found after the
region was written. The region does not hold the image and must be written again

flash refuses a board that cannot be read back at all. A DFU device that detaches as it commits is one, because the device that returns is not the one the write went through.

The wrong-loader gate

Every write to a board is checked against the loader’s answer to which SoC it runs on, shown on the plan’s loader says line. Name the board’s SoC with --soc. pyrographer compares the answer with the reply pinned for that SoC, and refuses the write on any difference. A loader is built for one SoC, but runs on whatever board it was uploaded to. The wrong loader writes to the wrong offsets, and returns a plausible status for every command.

The comparison is exact: the whole reply must equal, byte for byte, the reply a real board of that SoC gave. Nothing is decoded or guessed. rk3576 and rk3588 are pinned.

The gate refuses in three cases, each before anything is written. The first is a write with no SoC named:

pyrographer flash 64 boot.img
[... the plan, with:  named SoC    none: the write will be refused until a SoC is named (--soc) ...]

error: invalid request: rockusb write: name the board's SoC. The write proceeds only when the
loader's chip-version reply matches it

The second is a SoC with no pinned reply. pyrographer refuses it before opening a device, and the error lists the pinned names.

The third is a loader that answers as a different SoC from the one you named:

error: the loader does not match rk3576, the SoC this write was planned for. Loaders for
rk3576 answer 36 37 35 33 ... ("6753..."), and this loader answered 38 38 35 33 ...
("8853..."). A loader for the wrong SoC writes to the wrong offsets, so nothing was written

The first and third refusals come after the plan and before the question. The plan still prints in full, and its named SoC line carries the gate’s verdict. pyrographer does not ask you to confirm a write it will refuse.

Verifying flash against an image

verify reads flash and compares it with a file, a window at a time:

pyrographer verify 0 boot.img
Verifying 1.00 MiB...
  100.0%  1.00 MiB of 1.00 MiB  4.05 MiB/s
Verified 1.00 MiB in 0.2s.
Flash matches boot.img.

The arguments are the starting LBA and the file, or --partition <name> and the file. The file’s length sets how far the comparison runs, so there is no sector count. A difference names the byte:

pyrographer verify 0 other.img
error: flash differs from the image at byte 1234: read 0xaa, expected 0xff

An image that ends partway through a sector is compared only over the bytes it has. The device still reads that last sector in full.

verify checks for constant fill as dump does, and prints the same warning. A passing verify over a filled region does not confirm the data. If the image was dumped across the same silent read failure, both copies hold the same fill and agree.

Cloning one board onto another

clone copies the whole of one board’s flash onto another, and overwrites everything on the destination. It works as dump and flash in one pass, a window at a time, with no intermediate file. A 64 GiB part clones in a megabyte of memory.

pyrographer clone --soc rk3576 --from 003:12 --to 003:14

This will overwrite the whole of 2207:350e on bus 003 address 14.

  source       2207:350e on bus 003 address 12
               --from 003:12
               58.24 GiB (122142720 sectors of 512 bytes)
  destination  2207:350e on bus 003 address 14
               --to 003:14
               58.24 GiB (122142720 sectors of 512 bytes)
  LBA range    0 through 122142719 (122142720 sectors)
  touches      uboot            the whole of it (8192 sectors)
               trust            the whole of it (8192 sectors)
               misc             the whole of it (8192 sectors)
               boot             the whole of it (229376 sectors)
               rootfs           the whole of it (121872384 sectors)
  loader says  36 37 35 33 00 00 00 00 00 00 00 00 00 00 00 00  "6753............"
  named SoC    rk3576: the loader's answer matches

The source is only read. Each window is read back and compared before the next one is written. A mismatch stops the write at that window.
Nothing here can be undone.
Type the destination (003:14) to confirm, or anything else to stop:

The touches list names every partition on the destination in full, because a clone overwrites all of it.

Name both boards with --from and --to. clone infers neither, even with exactly two boards connected, because that choice decides which board is overwritten. --dry-run and --yes work as they do for flash.

Typed confirmation

To confirm a clone, type the destination’s coordinate, 003:14 in the plan above. Any other answer stops the clone. The plan prints both boards in the form --from and --to take, so the coordinate to type is on the screen. The window asks for the same confirmation.

A flash names one device, so a y against a plan naming it is enough. A clone names two, and the mistake to guard against is a swapped source and destination. Reading the plan does not reveal a swap, because both halves of it are accurate. A y confirms whatever plan is on the screen, and a typed coordinate confirms one device. If you believe the other board is the destination, you type its coordinate instead, and the clone stops.

What a clone reads and writes

The source is only read, so a clone does not write to the board being copied. The destination is read back and compared window by window, as a flash target is. clone refuses a source larger than the destination rather than truncating it, because a truncated clone is not a bootable board.

clone checks the source for constant fill as dump does. With dump, a fill affects only the image file, which you can discard. A clone writes the fill onto the destination before the warning appears. The warning therefore prints at the end of the clone, as a finding about the board just written:

Cloned, and read every window of it back.
Warning: 84.00 MiB from sector 65536 (32.00 MiB in) read back as constant 0xcc.
         Constant fill reported as a successful read can be a silent read failure rather than data. If this region holds data, read it again another way, such as through mass storage or the board's console, before you trust the image.

Like flash, clone is gated on the loader match. --soc names the destination’s SoC, because the destination is the board that is written.

Firmware packages and the ID block

A Rockchip SDK build ends in one file, a firmware package usually called update.img. It carries a loader, a partition table, and an image for each partition. The commands on this page check a package, write one to a board, and write the ID block a loader builds. None of them has run against a board [UNVERIFIED], and no package from a vendor build has been read yet.

Checking a firmware package

firmware-info reads a package from front to back and checks it. It opens no device:

pyrographer firmware-info update.img
Checking 12.57 MiB...
Checked 12.57 MiB in 0.1s.

update.img: a Rockchip firmware package, 12.57 MiB.

  model        RK3576
  made by      rockchip
  version      1.0.0, built 2026-10-04 12:00:00
  chip field   38 00 00 00 (reported, not compared)
  loader       772.49 KiB at byte 102, naming 36 37 35 33 "6753" (rk3576)
  archive      11.81 MiB at byte 791135, and its checksum holds
  trailer      MD5 4da475d9293b2235b480e827eca3f63c, reported and not checked
  ID block     704 sectors, laid out by the FlashHead header, every hash checked
               FlashBoost at sector 8, 8 sectors, loads at 0x3ffc0000
               FlashData at sector 16, 160 sectors, loads at 0x3ff81000
               FlashBoot at sector 176, 528 sectors
  partitions   uboot            16384 sectors at LBA 16384
               misc             8192 sectors at LBA 32768
               boot             131072 sectors at LBA 40960
               recovery         262144 sectors at LBA 172032
               rootfs           from LBA 434176, growing to fill the device, GUID 614e0000-0000-4b53-8000-1d28000054a9
  images       uboot            4.00 MiB from Image/uboot.img
               misc             48.00 KiB from Image/misc.img
               boot             1.00 MiB from Image/boot.img
               rootfs           6.00 MiB from Image/rootfs.img
  not written  package-file: the entry is the packing tool's own list of files

Every check passed: the archive's checksum, the two copies of the loader, the ID block's hashes, and the parameter's checksum.

The package above was packed for this page around rkbin’s RK3576 loader. A package is refused when any of these checks fails:

  • The archive’s trailing checksum. It catches a truncated or damaged download, and it covers every byte a write takes from the archive.
  • The ID block. It is built from the package’s loader, and every hash its header records is checked. The ID block explains how.
  • The two copies of the loader. A package carries its loader twice, once in the outer header and once in the archive, and the two must be the same bytes.
  • The parameter’s own checksum.
  • An Android sparse image. pyrographer writes images raw, and a sparse image has to be expanded as it is written.

The trailer is reported and not checked, because what its MD5 covers is unmeasured. The chip field is reported and not compared, for the same reason. The loader’s own container names its SoC, and that claim is the one the write judges.

Writing a firmware package

flash-firmware writes a whole package as one plan:

pyrographer flash-firmware --soc rk3576 update.img
This will write the firmware package update.img to 2207:350e:
RK3576 by rockchip, version 1.0.0.

  writing      partition 'uboot' from Image/uboot.img
               LBA 16384 through 24575 (8192 sectors), 4.00 MiB
               partition 'misc' from Image/misc.img
               LBA 32768 through 32863 (96 sectors), 48.00 KiB
               partition 'boot' from Image/boot.img
               LBA 40960 through 43008 (2049 sectors), 1.00 MiB
               partition 'rootfs' from Image/rootfs.img
               LBA 434176 through 446463 (12288 sectors), 6.00 MiB
               the primary GPT (protective MBR, header, and entry array from sector 0)
               LBA 0 through 33 (34 sectors), 17.00 KiB
               the backup GPT in the device's last sectors
               LBA 122142687 through 122142719 (33 sectors), 16.50 KiB
               the ID block, 3 images laid out by its RKNS header
               LBA 64 through 767 (704 sectors), 352.00 KiB
  ID block     laid out by the FlashHead header, every hash checked
               FlashBoost at sector 8, 8 sectors, loads at 0x3ffc0000
               FlashData at sector 16, 160 sectors, loads at 0x3ff81000
               FlashBoot at sector 176, 528 sectors
  partitions   uboot            16384 sectors
               misc             8192 sectors
               boot             131072 sectors
               recovery         262144 sectors
               rootfs           121708511 sectors
  not written  package-file: the entry is the packing tool's own list of files
  loader file  36 37 35 33 "6753" (rk3576)
  capability   NEW_IDB set: the loader writes an RKNS ID block
  flash        58.24 GiB (122142720 sectors of 512 bytes)
  loader says  36 37 35 33 00 00 00 00 00 00 00 00 00 00 00 00  "6753............"
  named SoC    rk3576: the loader's answer matches

The package's partition table replaces the device's. Each window is read back and compared before the next one is written. A mismatch stops the write at that window.
Nothing here can be undone.
Proceed? [y/N]

flash-firmware reads and checks the whole package before it chooses a device. It then plans the write, and writes the runs in the order the plan lists them:

  1. Every partition image, in the order the file holds them. The file is read forward once more, and each image streams from it into its partition.
  2. The primary GPT and the backup.
  3. The ID block, at sector 64, last.

Each run is written and read back window by window, as flash reads back a single image. The first window that fails stops the whole write there. --dry-run prints the plan and stops, and --yes answers the prompt.

The partition table the package lays down

The GPT comes from the package’s own parameter, and the parameter has to say TYPE: GPT. That line makes the offsets in its mtdparts list the sectors a GPT addresses. Two more of its lines shape the table:

  • A uuid:<name>=<GUID> line pins that partition’s unique GUID. An SDK pins rootfs this way, because the kernel command line finds its root by that GUID.
  • The partition whose size is - grows to the GPT’s last usable sector. The backup GPT holds the device’s last 33 sectors, so the partition stops short of them.

Every other GUID is derived from the layout, as author-gpt derives one. A parameter without TYPE: GPT describes a table written as a Rockchip parameter block instead, and is refused.

What it refuses

A firmware write is refused before anything is confirmed in each of these cases:

  • The wrong-loader gate refuses it, as it refuses any write. --soc names the board’s SoC.
  • The package’s loader container claims a different SoC from the one --soc names. The ID block is built from that container.
  • The running loader does not claim NEW_IDB, or does not answer the capability query. Rockchip’s own rkdeveloptool writes this kind of ID block only through a loader that claims it. capability prints the loader’s answer.
  • An image entry names no partition in the parameter, or is larger than its partition.
  • Two runs overlap, or the ID block lands inside a partition.
  • The device’s sectors are not 512 bytes. Every offset in a package counts 512-byte sectors.

On a block device, such as an SD card in a reader, no loader is asked, and --soc is refused as it is for flash.

A board in maskrom

A board in maskrom takes db first. db takes the package itself and uploads the loader inside it, so nobody extracts the loader by hand:

pyrographer db --soc rk3576 --loader update.img
Using the loader inside the firmware package update.img.
This loader's chip field holds 36 37 35 33 "6753" (rk3576).

Writing the ID block alone

write-idb builds the ID block from a loader container and writes it at sector 64, the place the BootROM reads first. It leaves the partition table as it is:

pyrographer write-idb --soc rk3576 --loader rk3576_spl_loader_v1.12.108.bin

--loader also takes a firmware package, and uses the loader inside it. The plan has the same rows as a package plan, with one run.

A loader container carries the ID block’s pieces, its flash stages, and not the block itself. One stage is an RKNS header. It lists each image the block holds, where it sits, and its SHA-256. pyrographer places each image where the header says, and finds the stage for each image by its hash, not by its name. Every hash, including the header’s own, is checked before anything is planned.

On the RK3576 container, that layout differs from the one rkdeveloptool’s ul writes. ul joins three stages by name and leaves out a fourth, FlashBoost, which the header places first. Rockchip RK3576 has the measured layout.

pyrographer builds the RKNS layout only. A container whose stages carry no RKNS header uses an older layout, and is refused by name. So is a container that wants its stages scrambled on the flash.

Firmware files written raw

A firmware package and a loader container are each unpacked by a tool before any of their bytes go to the flash. Written raw, neither boots, so flash refuses both:

pyrographer flash 0 update.img
error: invalid request: the image is a Rockchip firmware package (RKFW, an update.img), which a tool unpacks before anything of it goes to the flash. Written raw, it does not boot. A package is written as a whole: its partition images, its partition table and its ID block, each where it belongs. Run `pyrographer flash-firmware <file>` to write a firmware package.

The refusal reads the file’s first four bytes. A prebuilt ID block, which begins RKNS, is not refused, so flash 64 idbloader.img writes one as it is.

Block devices

pyrographer drives block devices on Linux. A block device is an SD card in a reader, or a board that has come up as USB mass storage. The host operating system also owns these devices: it mounts their filesystems and caches their sectors.

Every verb that reads or writes flash works on a block device, including the partition-table commands. A write goes through the same plan, confirmation and mandatory read-back as a write to a board. The commands that address a loader or a boot ROM refuse a block device by name.

list prints boards, and list --blocks also prints the machine’s disks:

pyrographer list --blocks
No boards found.

Block devices:
  /dev/nvme0n1     1.82 TiB  nvme      [refused: running system]
  /dev/sdb        29.72 GiB  usb
  /dev/sdc        14.86 GiB  usb       [read-only: no write]

A block device is acted on only when it is named: --device <node>.
`[refused: running system]` cannot be opened at all, and has no override.
`[read-only: no write]` and `[no write]` still dump, verify and list partitions.

pyrographer chooses a single connected board by default, but never a disk. On a machine with one disk, that disk is the one the system runs from. Name the disk with --device:

pyrographer dump --device /dev/sdb 0 2048 head.img
Reading 1.00 MiB...
Read 1.00 MiB in 0.1s.
Wrote head.img

On a board in a bootstrap mode, pyrographer is the only program addressing the device, and a read-back is a round trip to the flash. A block device is shared with the operating system. So pyrographer opens it exclusively, bypasses the page cache, and refuses every disk the running system rests on. The next three sections cover each of these.

Exclusive open

pyrographer asks the kernel for exclusive use of the device. If anything else holds the device, the kernel refuses the open. That covers a mounted filesystem, an active swap, and a volume stacked on the device. The kernel’s check is broader than any check pyrographer can make itself, and its refusal names the remedy:

pyrographer flash --device /dev/sdb 0 debian.img
error: invalid request: `sdb` is in use: the kernel refused an exclusive open. Something holds
it, such as a mounted filesystem, a volume stacked on it, or swap. Unmount or deactivate it first
(mounted at /media/you/BOOT)

Cache bypass

Reads bypass the page cache, so the read-back returns what the device holds and not a page the host has cached. A read served from the cache matches the image whether or not the write reached the device.

The running system

pyrographer refuses every disk the running system rests on, outright and with no override. The refusal covers reads as well as writes. A write to one of these disks can pass its read-back and still bring the machine down later, with no error reported. So pyrographer makes this refusal before anything is opened.

The refusal is transitive. / is often a volume in an encrypted container on a partition. pyrographer follows that stack to the physical disk and refuses it, not only the device / is mounted from.

pyrographer flash --device /dev/nvme0n1 0 debian.img
error: invalid request: `nvme0n1` holds the running system, so pyrographer will not write to it.
A write would corrupt this machine without reporting an error at the time. There is no override

Removable media is not part of the running system. The exclusive open refuses a card mounted at /media/you/BOOT, and names a remedy. The running-system refusal has neither a remedy nor an override.

Write-protected cards

pyrographer opens a card with its lock switch on for reading. The read verbs work on it, and a write is refused with the reason:

pyrographer dump --device /dev/sdc 0 2048 head.img
pyrographer flash --device /dev/sdc 0 debian.img
Reading 1.00 MiB...
Read 1.00 MiB in 0.1s.
Wrote head.img
error: invalid request: `sdc` is marked read-only by the kernel, so it cannot be written

The block-device plan

A block device runs no loader, so the wrong-loader gate has nothing to compare. pyrographer refuses --soc on a block device, rather than ignoring it:

pyrographer flash --device /dev/sdb --soc rk3576 0 boot.img
error: --soc rk3576 gates a write on the answer a loader gives, and `/dev/sdb` is a block
device with no loader on it to ask. This write is guarded instead by the kernel's exclusive
open, and no disk the running system rests on can be opened. Drop --soc.

In place of the loader’s answer, the plan’s held row states what guards the write:

pyrographer flash --device /dev/sdb 2048 boot.img

This will overwrite 4.00 MiB of /dev/sdb.

  image        boot.img
               4.00 MiB
  LBA range    2048 through 10239 (8192 sectors)
  byte range   1048576 through 5242879
  touches      nothing that can be named: this device has no partition table
  device       29.72 GiB (62333952 sectors of 512 bytes)
  held         exclusively by this command. The kernel granted `sdb` with nothing else
               holding it. No disk the running system rests on can be opened.

Each window is read back and compared before the next one is written. A mismatch stops the
write at that window.
Nothing here can be undone.
Proceed? [y/N]

Privilege

list --blocks needs no privilege. It reads everything it shows from /sys and /proc, including the running-system refusal.

Every other command opens the device first, and that includes building a plan, even under --dry-run. Opening the device needs root, or membership of the disk group. Without privilege, the open fails with this error:

error: I/O error: `sdb` cannot be opened without root or membership of the `disk` group.
Listing it needs no privilege, but planning or writing opens it

Durability

On a device the operating system also owns, a write that passes its read-back can still be overwritten. If a filesystem on the device is mounted elsewhere, the kernel can later write its cached copy back over the new bytes. That behavior is measured on Windows. On Linux, the exclusive open prevents it, which is why the exclusive open has no override either.

Serial lines

Four commands work over a serial line rather than over USB. recover writes a bootloader to a StarFive board’s flash, and uartboot boots a StarFive board into U-Boot without writing anything. console watches what a board prints on its serial console, and uboot drives a bootloader prompt.

Recovering a StarFive board

StarFive’s JH7110 boards, such as the VisionFive 2 and the Milk-V Mars CM, recover over a serial line. Their BootROM has no USB, and acts as an XMODEM receiver. The board therefore does not appear in list, and takes no --device. Strap the board into UART recovery, and name the serial port with --port. recover then waits for you to power the board on.

recover sends StarFive’s recovery agent to the BootROM. It then writes the board’s QSPI NOR flash through the agent’s menu:

pyrographer recover --port /dev/ttyUSB0 --agent jh7110-recovery-20230322.bin \
    --spl u-boot-spl.bin.normal.out --uboot u-boot.itb

This will write the boot flash of a StarFive JH7110 board over /dev/ttyUSB0.

  agent        161.41 KiB, sent to the BootROM first
  SPL          137.72 KiB at 0x0, agent menu entry 0, its header checked
  U-Boot       1.24 MiB at 0x100000, agent menu entry 2

The agent writes a backup copy of the SPL at 0x200000, inside the U-Boot region. U-Boot is written after it and overwrites that copy, as StarFive's own procedure does.

Warning: this write is not read back. The recovery protocol cannot read
flash. Each block's acknowledgment confirms that the board received it,
and the agent's verdict that its write finished, not that the flash
holds it.
Nothing here can be undone.
Proceed? [y/N]

The whole recovery is [UNVERIFIED] against hardware.

recover takes three files:

  • --agent takes the recovery agent, a jh7110-recovery-*.bin from StarFive’s Tools repository. recover checks its header, and that it carries the agent’s menu.
  • --spl takes the SPL, as a raw u-boot-spl.bin or as a u-boot-spl.bin.normal.out. recover adds the 1024-byte StarFive header to a raw file, and checks the header a .normal.out already carries.
  • --uboot takes the U-Boot payload, a FIT image such as u-boot.itb, and sends it unchanged.

At least one of --spl and --uboot is required, and --spl requires --uboot as well. The agent writes a second copy of the SPL at 2 MiB, inside the U-Boot region. An SPL written alone therefore breaks the U-Boot already on the board. With both files, the SPL is written first and U-Boot second, as StarFive’s own procedure does.

recover refuses a file larger than the agent writes: 1 MiB for the SPL with its header, and 15 MiB for U-Boot. --dry-run prints the plan and stops without opening the port. --yes skips the confirmation, as it does for flash.

recover types at the agent only when the agent asks. Whenever the agent is not receiving a file, it reads a line of input, and it reads stray bytes as typing. recover waits for the agent’s prompt before it types a menu choice, and for the agent to ask for a file before it sends one. If the agent answers a block with text, recover stops at once.

OTP fuses are never touched. The agent’s menu also offers to burn the SoC’s OTP fuses, which cannot be undone. recover never chooses that entry. If the fuse menu ever appears, recover stops, sends nothing more, and tells you to power the board off.

The write is not read back. The recovery protocol cannot read flash. After each file the agent reports updata success or updata fail, and recover reports a failure as an error. Neither verdict proves that the flash holds the file. Every other write in pyrographer reads each window back. The plan states this before you confirm, and recover repeats it at the end:

Recovery complete. The agent reported every write as done. This board's write cannot be read back.
Power off, return the boot strap to normal, and power on.

recover writes the QSPI NOR flash and not the eMMC. The agent’s eMMC entries write the SPL over the start of the disk, where its partition table is. To write a board’s eMMC, boot U-Boot in RAM and write the eMMC as a disk, as the next section describes.

On Linux, opening a serial port requires your user to be in the dialout group, or in uucp on some distributions. If the open is refused, pyrographer says so. The window has the same recovery flow, as Recovering a StarFive board describes.

Booting a StarFive board into U-Boot

uartboot brings a StarFive board up into U-Boot in RAM, and writes nothing. It sends an SPL to the BootROM, and the SPL loads U-Boot over the same serial line by YMODEM. uartboot then stops U-Boot at its prompt. It is the serial counterpart of db, which loads U-Boot into a Rockchip board over USB.

pyrographer uartboot --port /dev/ttyUSB0 \
    --spl u-boot-spl.bin.normal.out --uboot u-boot.itb

Booting U-Boot in RAM on a StarFive JH7110 board over /dev/ttyUSB0. Nothing is written to the board.

  SPL          137.72 KiB, sent to the BootROM, its header checked
  U-Boot       1.24 MiB, sent to the SPL by YMODEM

U-Boot is running in RAM, stopped at its prompt. Nothing was written to the board.
Run `pyrographer uboot --port /dev/ttyUSB0 --gadget ums` to hand its eMMC to this machine as a disk.

The SPL must be a mainline U-Boot SPL built with CONFIG_SPL_YMODEM_SUPPORT, which mainline’s VisionFive 2 configuration sets. With the board still strapped for UART recovery, that SPL loads U-Boot from the serial line. StarFive’s own SPL loads U-Boot from SPI flash instead, and uartboot reports the device the SPL names. --prompt names a U-Boot prompt other than => .

uartboot is [UNVERIFIED] against hardware.

Writing a StarFive eMMC as a disk

A RAM-booted U-Boot built with CONFIG_CMD_USB_MASS_STORAGE hands the board’s eMMC to this machine as a disk. Mainline’s VisionFive 2 configuration does not enable it, so U-Boot has to be built with it. The block-device commands then write the disk, and read back every window they write:

  1. Strap the board into UART recovery. Connect the serial adapter, and a USB cable from this machine to the board’s USB device port.
  2. Run uartboot, and power the board on.
  3. Run uboot --gadget ums on the same port. If the eMMC is not mmc:0, name it with --gadget-dev. uboot --cmd 'mmc list' lists the board’s MMC devices.
  4. Run list --blocks to find the disk, and write it with flash --device.

The route is [UNVERIFIED] against hardware.

Watching a serial console

console watches a board’s serial console for text you name, such as the verdict of a self-test the board runs at boot. Give the port, the text that means the board worked (--expect), and the text that means it did not (--or-fail):

pyrographer console --port /dev/ttyUSB0 --expect 'selftest: PASS' --or-fail 'selftest: FAIL'
[    0.000000] Booting Linux ...
selftest: PASS

Saw "selftest: PASS" on /dev/ttyUSB0.

The console watch is [UNVERIFIED] against hardware.

console writes the transcript to standard error as it arrives, and the verdict to standard output. A script can read the verdict and log the transcript. If an --or-fail pattern appears, console exits non-zero. Its message is then a plain sentence rather than an error:, because the board reported the failure and pyrographer ran correctly.

Both options can repeat. console reports whichever pattern the board printed first, in whatever order the options were given. If a board prints FAIL at stage one and PASS at stage two, console reports the FAIL.

Patterns match as bytes, with no line splitting and no normalization. A trailing space is part of the pattern. To write a byte that your shell removes or interprets, use one of these escapes:

  • \n, \r and \t
  • \0
  • \\
  • \xNN, a byte in hex

console refuses an unknown escape as it reads the option, so a typo cannot become a pattern that never matches.

--reads <n> sets how long console waits, counted in reads rather than seconds. On a quiet line, each read waits about a second. It returns as soon as bytes arrive. --baud <rate> sets the line’s baud rate, which defaults to 115200.

If no pattern matches within the read budget, console reports that the pattern did not appear. It does not claim the board failed. The cause can be a failure, a slow boot, a wrong baud rate, or a console on another UART:

pyrographer console --port /dev/ttyUSB0 --expect PASS --reads 5
error: protocol error: nothing matching "PASS" arrived on the console within 5 reads. Recent
console output:
U-Boot SPL 2026.04

console reads only what a board prints by itself. It does not drive a login prompt, because a login needs credentials and pyrographer handles none.

Driving a U-Boot prompt

uboot drives a U-Boot prompt, the last stage before an operating system, over the same serial port. Every form first sends a bare newline to interrupt autoboot. That stops a countdown, and on a board already at a prompt it only produces another prompt.

The uboot driver is [UNVERIFIED] against hardware.

Starting a gadget

--gadget starts a USB gadget at the U-Boot prompt, which exposes the board’s flash over USB. It completes the RAM-boot that db starts, once db has put a full mainline U-Boot into DRAM over USB. With --gadget rockusb, the board returns to list as a loader:

pyrographer uboot --port /dev/ttyUSB0 --gadget rockusb
Hit any key to stop autoboot:  2
=>
At the prompt.
rockusb 0 mmc 0

Ran `rockusb 0 mmc 0`. The gadget is running, so the prompt does not return.
Run `pyrographer list` to find the board on USB.

A running gadget holds the console and never returns to the prompt. uboot therefore treats a prompt that does not come back as success. If the prompt does come back, the command has returned, for example on an unknown command or a missing device. uboot then reports what U-Boot printed as the failure.

--gadget ums starts U-Boot’s USB mass-storage gadget instead. The board’s flash then appears in list --blocks as a disk, and the block-device verbs read and write it. On an RK3576, dd read the whole eMMC through this gadget, past the 32 MiB point where rkbin’s loader returns constant fill.

Mainline U-Boot’s rockusb gadget does not answer K_FW_READ_FLASH_INFO, so info, partitions and --partition fail against it. A dump by LBA requests no flash information, so this failure does not affect it. Whether that gadget reads past the 32 MiB point is [UNVERIFIED].

--gadget-dev names the block device the gadget exposes, and defaults to mmc:0. If the gadget is not on USB controller 0, use the form <controller>:<interface>:<index>.

Overriding the boot order

--boot-from sets what the board boots from, for one boot:

pyrographer uboot --port /dev/ttyUSB0 --boot-from mmc0

This will change what the board on /dev/ttyUSB0 boots from, for one boot.

  boots from now   mmc1 mmc0 usb0 pxe dhcp
  would boot from  mmc0

Note: the change is not saved. U-Boot keeps its environment in RAM until
`saveenv` writes it to storage, and the override never sends `saveenv`. The
board's own boot order is untouched, and the next reset restores it. The
board boots immediately after the order is set.
Proceed? [y/N]

The override is never saved. pyrographer has no saveenv operation at all. The board’s own boot order is untouched in every case, and the next reset restores it.

To build the plan, uboot asks the board: it runs printenv boot_targets and shows the answer beside the new order. --dry-run asks that question and stops.

The confirmation is a plain yes, not a typed coordinate, because an override has no second board and destroys nothing.

Running one command

--cmd types one command at the prompt and prints U-Boot’s response:

pyrographer uboot --port /dev/ttyUSB0 --cmd 'mmc info'
Device: mmc@2a330000
Manufacturer ID: 15
Capacity: 58.2 GiB

--cmd is ungated. U-Boot runs exactly what you type, saveenv included, with no plan and no confirmation. A saveenv writes U-Boot’s environment to the board’s storage. --cmd is the one command in pyrographer that works this way, and its help text says so.

--prompt names the prompt of a board whose prompt is not => , such as U-Boot> , or whatever CONFIG_SYS_PROMPT was built with. The trailing space is part of the prompt. pyrographer keeps no catalog of board prompts, so give the one the transcript shows.

The window has the same console flow, as The serial console describes.

The window

pyrographer-gui is pyrographer’s desktop application. It runs on the same core as the CLI, so it makes the same checks and the same refusals. What the command-line reference says a command does holds here too. Where the CLI prints the plan for a write, the window shows it as a screen.

The GUI is outside the workspace’s default members, so build and run it by name:

cargo run -p pyrographer-gui

Tabs

The window has two tabs, one for each kind of target:

TabWhat it drives
Boards and disksAnything whose flash pyrographer reads and writes: a board in a boot mode, or one of this machine’s disks. These have sectors, geometry and a partition table, so every verb applies.
SerialAnything on a serial line: a StarFive board in UART recovery, or a bootloader prompt. These have no sectors and no partition table, so the flash verbs do not apply.

While a plan waits for your confirmation, the tab bar is hidden. A write plan takes the whole window, so the only ways out of it are to cancel or to confirm. Either way, the tab you were on returns.

Jobs

A job keeps running across a change of tab. Each running job appears on a strip at the bottom of the window, with its own Cancel. The strip names the tab that holds the job’s full detail: byte counts, the transfer rate, or a console transcript. When no job is running, the strip is hidden.

The strip stays on screen during a plan, and offers no way out of the plan. A job that was already running continues behind the plan, and its Cancel is the only control over it. If a second plan is waiting behind the one on screen, the screen says so. Canceling the first plan then does not move you into the second without notice.

Sections that open and close

A name drawn in the accent color, with a triangle before it, opens and closes the section under it. Click the name to open the section, and click it again to close it. The name stays where it is, so the control that closed a section is the one that opens it again. A screen reader announces each one as a button, expanded or collapsed.

Some sections start closed: Disks, Partition table, Firmware, and both sections on the Serial tab. They hold tools for a particular job, and open when you ask for them.

Forms

In a form, every label stands in one column and every field and button starts on one line. Under the fields is the button that acts on them. A section’s headings are announced as headings, so a screen reader can move from one to the next.

Choosing a board

The list shows every Rockchip and Ingenic board in a boot or recovery mode. While no job is running, the window rescans the bus, so a board you plug in appears. Boards pyrographer cannot drive are listed too. Each row shows the board’s mode:

ModeWhat it means
loaderA loader is running and the flash is reachable.
maskromThe bcdUSB flag reports the BootROM. Some loaders present the same even flag, the RK3576 SPL and the RK3588 usbplug among them, so Open probes the board to confirm the mode. A board running its BootROM needs a loader first: select the board and use Upload loader….
mass storageThe board re-enumerated as a USB mass-storage device, and the host operating system owns it as a disk. Open it from Disks.
boot ROMAn Ingenic device is running its USB boot ROM, which has no flash commands. Bootstrap it to DFU first.
DFUAn Ingenic device is running a DFU-capable U-Boot. Its flash is reachable as named alt-settings.

If the list is empty, it says so. If you have not opened Disks yet, it also points you there. A card reader is listed under Disks, not in the board list.

If a board does not open, the cause is most often permissions. The error says so and gives the udev rule that fixes it, as the CLI does.

Disks

An SD card in a reader is a disk the host operating system owns. So is a board that has come up as mass storage. A disk opens in the same slot as a board, and the same verbs work on it, including the Partition table section. Block devices covers what differs about a device the operating system owns.

Disks is a section that stays closed until you open it, the window’s form of list --blocks. The window keeps disks in their own section, apart from boards, so that a disk is not selected by accident.

The Block backend is built for Linux. Every build draws the Disks section. On another platform, the section names the platform the backend is built for, rather than showing an empty list.

The window reads the disk list as the section opens and on Rescan. The list changes only then, so the rows stay in place while you choose one. A device with no medium, such as an empty card reader or an unbound loop device, gets no row of its own. Such devices are counted and named on one line, so they cannot push the card you are looking for out of view.

Refusals on a disk

Each row shows any refusal that applies to it before you pick it. Only the running-system refusal replaces the row’s buttons:

  • The running system. A disk the running system rests on shows its refusal in place of buttons, and cannot be opened at all. There is no override. The refusal is transitive, so it also covers the disk that holds a volume or encrypted container the system runs from. A write to one of these disks can pass its read-back and still bring the system down minutes later, with no error reported.
  • A mounted card. The exclusive open refuses it, and the error names the mount and the remedy.
  • A write-protected card. It opens for reading. Its write controls are grayed out where the verbs are, with the reason beside them.

Opening a disk

Opening a disk holds it exclusively, with the host’s cache bypassed, for as long as it stays open. The open also lists the disks again. If the device at that node no longer matches the row, the open is refused. This catches a card removed while its row was on screen, and another device that has taken its name.

Listing disks needs no privilege, because everything the rows show comes from /sys and /proc. Open needs root, or membership of the disk group. Without either, the open fails and the error says so.

Confirming a disk write

To confirm a disk write, type the disk’s node path, such as /dev/sdb, as a board write asks for its bus address. The plan’s last rows show the exclusive open and the running-system guard, where a board’s plan shows its loader’s answer.

The SoC field is not drawn for a disk, because a disk runs no loader. A SoC name left over from a board you had open does not carry into a disk’s plan. Chip version and the reset modes are drawn disabled, with the reason.

Uploading a loader

A board in maskrom mode runs the BootROM, which serves no flash commands, so the board needs a loader first. Select the board, and the panel under it offers Upload loader…. It runs the same upload as the CLI’s db. It asks for the loader file, rkbin’s *_loader.bin for the SoC or a board vendor’s MiniLoaderAll.bin. It parses the file as soon as you pick it. A file that is not an RKBOOT loader is refused at the dialog, before any device is opened.

The upload runs as a job, with a progress bar and a Cancel that stops it at the next chunk.

The loader container names the SoC it was built for. The upload panel shows that claim while the upload runs, and the report shows it again. Both give the raw bytes and the SoC pinned for them, as db does. If the SoC field names a SoC, a file built for another SoC is refused before the upload starts.

The claim is written by whoever built the file. It catches a file picked by mistake, and does not prove what the loader does once it runs.

Raw stages

Raw stages (no container), in the same panel, uploads bare stage files, matching the CLI’s db --code471 and --code472. It starts closed. Mainline U-Boot’s binman emits bare u-boot-rockchip-usb471.bin and u-boot-rockchip-usb472.bin files with no container, which Upload loader… refuses. The form takes them as two separate files, Stage 471 and Stage 472. Stage 471 initializes DRAM, and stage 472 runs after it. At least one is required, and 471 always goes first.

Raw stages load a full U-Boot into the board’s DRAM over USB. The board then answers on its serial port rather than on the bus, as The serial console describes. Bare stages carry no container, so they name no SoC. The form states that nothing checks which board they are for.

After the upload

On success, the maskrom USB device disconnects, and the board re-enumerates as a new device at a new address. On an RK3576, this takes somewhat over three seconds, and on an RK3588S, under one. The window clears the old selection and rescans. When the board reappears, select it. It can still be listed as maskrom, because some loaders present an even bcdUSB flag, and Open probes the board to confirm the mode.

The container parse, the upload and the re-enumeration are verified on an RK3576 and an RK3588S through the CLI’s db. The window’s own run of this flow is [UNVERIFIED] against hardware.

Bootstrapping an Ingenic board

An Ingenic board in its boot ROM has no flash commands. Select the board, and the Bootstrap to DFU form under it brings the board to DFU. The form starts open, because the upload is the only thing a boot ROM can do. It is the window’s counterpart of the CLI’s usbboot. The form has these fields:

FieldWhat it holds
Stage 1The DRAM-init SPL, which is required
Stage 1 addressWhere the SPL loads and runs
Stage 2The DFU-capable U-Boot. Without it, the board only initializes DRAM and does not re-enumerate.
Stage 2 addressWhere U-Boot loads and runs in DRAM
DRAM settleThe wait in milliseconds after stage 1 for DRAM to come up. The community value is around 2000 ms.

The load addresses default to thingino-dfu’s family-wide values, 0x80001800 for the SPL and 0x80100000 for U-Boot. If your build links its stages elsewhere, change them. Bootstrap… runs the upload as a job. On success, the board re-enumerates as a DFU device, and its flash is reachable as named alt-settings.

This flow is [UNVERIFIED] against hardware.

Reading a board

The Read part of the panel under an open board holds five buttons. Each one queries the board and shows the answer:

  • Flash info reports the flash ID and geometry, and names the storage medium on the geometry line.
  • Partitions lists the partitions under the board. Read it first, because it lets you aim every other verb by partition name.
  • Chip version shows the loader’s answer about which SoC it is on.
  • Capability shows the loader’s own report of what it supports, including any flag pyrographer has no name for. Nothing is gated on it.
  • Storage medium names the medium that the sector numbers on this screen are offsets into. On a board with more than one medium, the same sector number names more than one place.

Chip version, Capability and Storage medium use the vendor protocol. On a disk, they are drawn disabled, with the reason.

The partition list always shows sector counts. It shows byte sizes only once the sector size is known: after Flash info, or on a device whose backend knows its own block size. The window does not assume a 512-byte sector, because on a 4Kn disk that assumption makes every size eight times too small.

Reset modes

The Device part of the panel holds the button that ends the session, after its Reset mode menu. The mode decides whether the board comes back, comes back as a disk, or stays off. The button’s label names the selected ending: Reboot, Reboot into USB mass storage, Power off, or Reboot into maskrom.

Any mode other than the plain reboot shows a caution. No board has answered those subcodes. They write no flash, but each one leaves the board in a mode the open session cannot drive.

Disabled controls

Erase, in the Device part, is drawn disabled, with the reason under it. No board has confirmed what the erase command does to a range, and an erase with the wrong range semantics destroys data rather than failing.

These other refusals also show their reason in plain view, beside the control:

  • A disk the running system rests on
  • A disk the kernel is holding
  • Chip version and the reset modes on a disk
  • A clone onto a board that has no whole-device image to receive

Help text that is not a refusal, such as what a mode is or what a field expects, appears on hover. It is also attached to the control for a screen reader. Accessibility covers both.

Aiming a verb

By partition is the default. The name resolves against the board’s own partition table, which records where the partition ends. It is therefore the only form that can refuse an image too large to fit. With a raw LBA, an image can run past the end of uboot and into trust.

By LBA takes a sector number you work out yourself, which pyrographer cannot check.

Dumping

Dump to file… asks where to save the image, then streams it a window at a time. The image never has to fit in memory, so a 58 GiB eMMC dumps in a megabyte of RAM. The progress bar shows bytes and a rate. Cancel stops at the next window boundary, never partway through a command, so the board is not left in the middle of a transfer. The file keeps what was already written.

Writing

A write permanently replaces the existing data in the range it covers. It runs only after you have reviewed its plan and confirmed it.

Plan a write… asks the board for its geometry, its partition table, and its loader’s answer about which SoC it is on. It then shows the plan. The plan is the dry run: the same code path as the write, stopped one step short.

The plan names:

  • The destination board
  • The sector range and the byte range
  • The padding out to the last whole sector
  • The flash the range sits in
  • The loader’s raw answer, and the gate’s verdict on it
  • When the write is read back and compared
  • The partitions the write lands in, and how much of each

The last of these is the line to check:

  touches      uboot            the whole of it (8192 sectors)
               trust            100 of its 8192 sectors

It is the one line you can check against what you know about your board. A range such as “LBA 16384 through 24675” is correct arithmetic whether or not it is the range you meant. “The whole of uboot, and 100 sectors of trust” names what the write replaces. If you meant to write boot, cancel the plan.

Typed confirmation

To confirm, type the destination’s bus address, such as 003:12. It is the same string the device list shows and the CLI’s --device takes. Each board has a different address, so the confirmation cannot be given out of habit, as a button click or a fixed word can. A clone with its source and destination swapped asks for a different string. Reading the plan cannot catch that mistake, because both boards are real and every line of the plan is accurate.

After you confirm, the write is read back and compared at the point the plan’s checked row names. On a board in loader mode and on a disk, each window is read back before the next one is sent. A difference stops the write and names the byte. A DFU board can be read back only after the whole range is committed, and a difference names the window. The read-back cannot be turned off.

Cloning

A clone copies one device’s whole flash onto another, and replaces everything on the destination. You choose both ends explicitly, even with only two devices connected, because the only difference between them is which one is overwritten.

Every row, board or disk, has two buttons. Clone from picks the device to copy, and Use picks the device to overwrite. Each gets its own panel with its own Open, and a clone needs both open. The source’s panel comes first, in the order the plan lists them, and has a Forget that releases the device. The source is only read.

Plan a clone… shows both ends before you confirm anything. You confirm with the destination’s address, so a clone with its ends swapped asks for a different string, as Typed confirmation describes.

A clone addresses both devices by raw LBA. A DFU board has no device-wide LBA space, so it can be neither end of a clone. With a DFU board at either end, the button is disabled, and the reason is shown below it.

Partition tables

A partition table is stored in flash, so rewriting one is a write, with the same plan and confirmation. Repair and authoring are in the Partition table section of an open board. The section starts closed: click its name to open it. Repair and authoring are for a board whose table is already damaged or wrong. An ordinary dump or flash needs neither.

A partition table sits at fixed sectors of a device-wide LBA space. A DFU board reaches its flash only by named region and has no such space. On a DFU board, the repair buttons and Author table… are drawn disabled, and the reason is shown above them.

Repairing a damaged table

A GPT and a Rockchip parameter table each keep more than one copy. Repair GPT… and Repair parameter… each rebuild a damaged copy from an intact one:

  • A damaged primary GPT is rebuilt from the backup in the last sector, and a stale backup from the primary.
  • A damaged parameter copy is rebuilt from one of the several copies raw NAND keeps.

Choose the button for the table’s format. A table damaged enough to need repair can be too damaged for its format to be detected.

The intact copy is never written. Each button plans first, like every write. If the copies already agree, or the board has no table of that format, there is nothing to repair. The plan says so, and nothing is written. An eMMC keeps a single parameter copy, so a damaged one there has no other copy to be rebuilt from. Author a fresh table instead.

Authoring a table

Author a fresh table, inside the section, opens a form with these fields:

FieldChoices
FormatGPT, or Rockchip parameter
FromNative layout, mtdparts line, or parameter text
FileThe layout file
MediumeMMC or raw NAND, for a Rockchip parameter table only

Parameter text is an existing parameter block’s whole text, kept verbatim, so FIRMWARE_VER and every other key are preserved. It is offered for a parameter table only. The medium decides where the copies go and what a layout’s offsets count from. A GPT’s offsets are absolute, so it needs no medium.

Author table… then plans the write, with the same plan screen and typed confirmation as any other write. The plan names each copy it writes and the partitions the new table holds. Every window is read back as it is written.

Firmware

The Firmware section of an open board writes a Rockchip firmware package, or a loader’s ID block alone. Like the partition table section, it starts closed. Both writes use the same plan screen and typed confirmation as any other write. Neither has run against a board [UNVERIFIED].

Plan firmware write… takes the package chosen in its Package row. The whole package is read and checked before the board is asked anything, so a large package shows progress first. The plan then lists every run in the order it is written:

  1. Each partition image, into the partition the package’s parameter names
  2. The primary GPT and the backup
  3. The ID block, at sector 64, last

The plan also names the ID block’s images, the partitions the board holds afterward, and the entries left out. It shows what the loader file claims, and whether the running loader claims NEW_IDB. Firmware packages and the ID block describes each check and each refusal.

Plan ID block write… takes the file chosen in its Loader row: a loader container, or a firmware package whose loader is used. It writes the ID block alone, and leaves the partition table as it is.

A firmware package and a loader container do not boot when written raw. Plan a write… refuses either as soon as it reads the file’s first bytes, before the board is asked anything.

On a DFU board, both firmware buttons are disabled, and the reason is shown above them.

Recovering a StarFive board

StarFive’s JH7110 boards, such as the VisionFive 2 and the Milk-V Mars CM, recover over a serial line. Their BootROM has no USB and acts as an XMODEM receiver, so the board does not appear in the device list. Recovery is the StarFive recovery (serial) section of the Serial tab, which starts closed.

Strap the board into UART recovery, connect a USB-serial adapter, and fill in the form:

  1. Name the port the adapter appears on, such as /dev/ttyUSB0 or COM3.
  2. Choose the files. A recovery sends the recovery agent, jh7110-recovery-*.bin, first. Choose an SPL, as u-boot-spl.bin or u-boot-spl.bin.normal.out, and a U-Boot payload.

In the web flasher, Choose port… takes the place of the path field and opens the browser’s chooser, as The web flasher describes.

The section offers two jobs, and both start once you power the board on:

  • Plan recovery… writes the board’s QSPI NOR flash through the recovery agent. It needs the agent and at least one of the SPL and U-Boot, and an SPL needs U-Boot beside it.
  • Boot U-Boot in RAM sends the SPL and U-Boot without the agent, and writes nothing. It is described below.

Writing the boot flash

Plan recovery… shows what will be written and where. The SPL goes at 0x0 and U-Boot at 0x100000, and the agent writes a backup copy of the SPL at 0x200000. The plan also states the one way a StarFive recovery differs from every other write:

This board’s write is not read back.

The recovery protocol cannot read flash. Each block is acknowledged as it is received, and the agent reports whether each write finished. Neither proves the flash holds the file. The plan states this before you confirm.

To confirm, type the port path, as a board write asks for its bus address. The window sends the agent and then each file, and shows what the agent prints as it writes. It reports a write the agent says failed as an error, in the agent’s own words. Afterward, power off the board, set the boot strap back to normal, and power on.

The window types at the agent’s menu only when the agent asks. The agent also offers OTP fuse burning, and the window never chooses it, because a burned fuse cannot be reversed. If the fuse menu ever appears, the job stops and tells you to power the board off. The whole recovery is [UNVERIFIED] against hardware.

Booting U-Boot in RAM

Boot U-Boot in RAM sends the SPL to the BootROM, and the SPL loads U-Boot over the same serial line. The window then stops U-Boot at its prompt, which it recognizes by the prompt the serial console’s form names, => by default. Nothing is written, so there is no plan to confirm. The SPL must be a mainline SPL built to load U-Boot from the UART.

From there, the serial console starts U-Boot’s mass-storage gadget on the same port. The board’s eMMC then appears under Disks, where a write reads back every window, as Writing a StarFive eMMC as a disk describes. The RAM boot is [UNVERIFIED] against hardware.

The serial console

The serial console drives a bootloader prompt over a serial line. Its section is on the Serial tab after StarFive recovery, and you reach it the same way, by naming a port. A serial line does not announce what is connected, so the Serial console section starts closed. The serial console is [UNVERIFIED] against hardware.

Watching the console

Watch the console… reads what the board prints and sends nothing to it. Enter the text that means the board worked and the text that means it did not, one pattern per line. The transcript streams into the window as it arrives, so a session that is waiting does not look like a hung one.

The window reports the pattern the board printed first, from either box. A failure pattern is reported as a finding in the caution color, not as an error. The board answered with the text you were watching for.

Patterns match as bytes, so a trailing space is part of the pattern. For a byte you cannot type, use an escape from the set Watching a serial console lists.

Driving a U-Boot prompt

U-Boot prompt, a part of the section that starts closed, holds the remaining controls. Each row is a field and the buttons that use it. The fields are the prompt string, the Gadget device a gadget exposes, the Boot order to set, and the Command to type.

Gadgets

Start the rockusb gadget and Start the mass-storage gadget complete the RAM-boot that a maskrom upload starts. A board RAM-booted into a full U-Boot answers on its serial port, and does not reappear on the bus by itself. The upload’s success message says so, and points here.

Each button tells that U-Boot to expose the board’s flash over USB. The rockusb gadget returns the board to the device list. The mass-storage gadget brings its flash up under Disks on the Boards and disks tab.

Boot from

Boot from… sets U-Boot’s boot order and boots, for one boot only. Its plan asks the board first: it runs printenv boot_targets and shows the answer beside the order that will be set.

Nothing is saved. U-Boot keeps its environment in RAM until saveenv writes it, and Boot from… never sends one. The next reset restores the board’s own order.

The override asks for a plain yes rather than a typed address, and appears as a panel instead of taking the whole window. It involves no second board and destroys nothing, so the typed address that guards against a swapped clone is not needed.

Command

Command types one line at the prompt. It is ungated, and the window says so in the caution color. U-Boot runs whatever you type, saveenv included, with no plan and no confirmation.

The wrong-loader gate

Every write to a board is gated on the loader’s own answer about which SoC it runs on. You name the board’s SoC in the SoC field on the form. The gate compares the loader’s raw answer, the plan’s loader says line, with the reply a real board of that SoC gave, byte for byte. Nothing is decoded and nothing is guessed. Two SoCs are pinned, the RK3576 and the RK3588. The wrong-loader gate explains why the comparison is exact.

The gate refuses in three cases, each as early as it can:

  • A SoC the gate does not know is flagged beside the field as you type it, because no reply is pinned to compare against.
  • If no SoC is named, the plan still renders, and its named SoC line carries the verdict. The write is refused until you name a SoC.
  • If the loader answers as a different SoC, the write is refused, and the refusal shows both byte strings.

The refusals appear after the plan and before the confirmation prompt. You are never asked to confirm a write that pyrographer already knows it will refuse.

The web flasher

The same crate compiles to WebAssembly and runs in a browser as the web flasher, with nothing to install. It runs on the same core, with the same verbs, the same write path and the same refusals. The web flasher compiles, and is [UNVERIFIED] in a browser.

It needs a Chromium-based browser (Chrome, Edge, or Chromium itself) and a secure origin, either HTTPS or localhost. On Windows, the board needs a WinUSB driver, as it does for the native tools.

Board permissions

A web page cannot scan a USB bus. The first time, you pick a board from the browser’s own device chooser, which opens only in response to your click. The chooser returns only the board you picked, and grants the page lasting permission for it.

After that, the list shows the boards you have already allowed. A board you have never picked on this page does not appear, however it is plugged in. The permission outlives the tab, so the list survives a reload. The page reads the list on load, after you allow a new board, and on Refresh. You can revoke a permission in your browser’s site settings.

A dump streams straight to a file you choose, so a 58 GiB eMMC does not have to fit in the tab.

Confirming by picking again

To confirm a write, pick the destination again in the browser’s chooser. A browser gives a page a device object rather than a bus address, so there is no short, stable string to type. Only your click can open the chooser, so the page cannot make the pick for you. A row in the remembered list cannot serve as confirmation, because the page draws those rows itself.

Serial ports

In the web flasher, the StarFive recovery and serial console sections replace the port field with a Choose port… button. It opens the browser’s port chooser. A page cannot be given a port path, as it cannot be given a bus address.

The port chooser is unfiltered, unlike the board chooser. The device plugged into your machine is a USB-serial adapter, whose vendor ID names FTDI or WCH and never the board. The window names a port by the adapter’s USB IDs, such as “USB-serial adapter 0403:6001”. To confirm a recovery, pick the port again, as you do for a write.

Ingenic bootstrap in the browser

The Ingenic bootstrap runs in the web flasher too, with one extra step. After the bootstrap, choose the board again in the browser’s chooser, as you did for the boot ROM. A browser grants permission per device, and the DFU gadget your board comes back as is a different device. Ingenic puts the mode in the product ID, so the gadget enumerates at 4d44, apart from the boot ROM’s own product ID. The permission you granted the boot ROM does not carry over, so the DFU device does not appear in the list until you choose it.

Accessibility

This chapter states the accessibility standard the pyrographer window is held to, and what backs each part of that claim. Every claim that has not been checked says so.

This chapter describes pyrographer-gui as this tree builds it, on egui/eframe 0.36.2 with AccessKit 0.24.1. Each claim applies to that build. The numbers in this chapter are the ones the tests in theme.rs and ui.rs recompute on every commit.

The standard

The window follows the WCAG 3.0 draft, and takes its outcomes as the requirements framework. Where WCAG 3.0 sets no number, the window takes the number from WCAG 2.2 Level AA. WCAG 3.0 is a Working Draft with an unsettled conformance model. This is not a claim of conformance to WCAG 3.0, because there is nothing yet to conform to.

The palette is also held to APCA, which is not frozen. Every color pairing must therefore meet both an APCA Lc target and the WCAG 2.2 contrast ratio. A pairing that passes one and fails the other is a defect, not a rounding error. A palette can pass the more permissive measure and fail the other badly, on a hairline that looks correct.

What is measured on every commit

Each claim in this section is checked by a test. The tests run with no window, no hardware and no assistive technology installed, and a regression fails the build.

Contrast

Each text pairing is held to its role’s Lc target and to 4.5:1, against every background it is drawn on, in both light and dark. The border that identifies a control at rest is held to 3:1 against all three grounds. The edge of a focused control is held to 3:1. So is the focus ring drawn outside it, against every background it can stand on.

Names

Every operable control has a name that an assistive technology can announce. This includes the field on the write gate where you type the destination, which is the last control before the flash is overwritten.

The names on one screen must also be distinct. The disk list draws a Use button on each row, and that button selects the target of a destructive operation. Each button therefore reads Use and announces the disk it selects, as in Use /dev/sdb. A test opens every section of every screen and requires each name to appear once.

Reasons

A disabled control carries the reason it is disabled into the accessibility tree. The reasons that guard a destructive operation are drawn on screen as sentences, not in a tooltip that requires a mouse.

Target size

Target size is measured on the rectangles the window draws, against WCAG 2.2 SC 2.5.8 (24×24), including its spacing exception.

Keyboard

Every enabled control is reachable with Tab. The check runs on each tab, because the window draws one flow at a time.

The write gate places keyboard focus on the field where you type the destination. Its confirm button joins the keyboard order only after you type the destination.

Sections and headings

A control that opens and closes a section reports whether the section is expanded or collapsed. Its name is the section’s name alone. The triangle before the name is drawn as a shape, so a screen reader does not read it out as a character.

A heading is published as a heading, at the level it is drawn at. A screen reader can therefore move from one heading to the next.

Tabs

The bar that divides the window is exposed as a tab list, and each tab reports whether it is the current one. egui has no tab widget of its own, and its nearest control announces as a pressed button. The current tab is marked by a rule beneath it rather than by a fill, because no fill in this palette is visible enough.

Jobs

A running job is drawn on a strip outside the tabs, so its Cancel button is reachable from either flow. The button’s name includes the job it stops, rather than only Cancel.

Focus

A keyboard-focused control is drawn with a ring outside it, which a held-down control does not have.

Use of color

The accent color marks what can be pressed and what is selected. Headings are drawn in the text color, so a heading does not read as a control.

Wherever color conveys information, text conveys it too. A refused disk reads running system beside its color, and a sentence under the list states the consequence. The current tab is underlined, not only tinted. The write gate’s verdict is a sentence, not a red border. The command-line tool uses no color and states everything in words, and the window inherits those sentences.

Device names use the same monospace font and the same /dev/... spelling as the disk list. One disk is therefore never rendered two ways on one screen. Device names have no shaded background. Every background color in this theme is within 1.22:1 of the page, so a shaded chip would be invisible. A visible chip would need a block of color heavy enough to compete with the warning that contains it.

A disk refused for more than one reason is listed under each reason, so you can resolve every reason in one pass. The exception is an absolute reason, which is shown alone. Remedies listed beside it would imply that it can be overcome.

The sentences under the disk list are written one per kind of refusal, not one per disk. Several disks are often refused for the same reason. For a system installed on an LVM volume in an encrypted container, every device in that chain is refused. A warning repeated once for each disk teaches the reader to skip it. Different kinds stay separate, because their remedies differ. A disk that you can unmount never appears under the words There is no override.

Text scaling

Ctrl-plus and Ctrl-minus rescale the whole interface, including its layout. No size in the theme is hard-coded in a way that breaks rescaling.

Light and dark themes

The window follows the operating system’s theme preference, and a control in the header cycles through automatic, light and dark. The dark palette is tuned on its own rather than inverted, because reverse-polarity contrast does not behave as a mirror image.

The choice is not saved, so each launch follows the system preference again. pyrographer stores no user state anywhere.

Unverified

  • What a screen reader says. The accessibility tree is correct, and a manual check confirmed that it reaches the Linux accessibility bus, where a screen reader reads it. No screen reader has been run against this window.
  • Windows and macOS. Neither platform has been tested.

Known defect

An assistive technology cannot activate a control by itself. The application accepts a request to press a button through the accessibility interface, and the request has no effect. The defect is in the toolkit’s integration with the platform accessibility bridge, outside pyrographer’s own code.

Ordinary screen-reader use is not affected. Activating a control by pressing a key travels the normal keyboard path, and that path is tested. The defect affects a device or review mode that activates controls through the accessibility interface instead of sending keystrokes.

The web flasher

The web flasher is best-effort, and not conformant for anything a screen reader mediates. The limitation is architectural. egui draws to a <canvas>, which has no DOM, so there are no elements to expose and no accessibility tree to populate.

The visual requirements carry over in full: contrast, color never used alone, focus visibility, target size and zoom. Each is a property of the rendered pixels, which a canvas delivers.

If you need a screen reader, use the native application or the command-line tool. The CLI exposes every verb the window does, as ordinary terminal output.

Design overview

pyrographer keeps vendor-specific code small, so its operations generalize across boards. Each section on this page names one part of the library and what it owns.

Transport

Transport moves bytes between the host and a device over USB or serial. It is async, so the code that consumes it runs unchanged over native transports (nusb, serial2) and browser ones (WebUSB, Web Serial).

Image seam

The image seam is the other end of a long verb: the source of a write’s bytes, and the destination of a dump’s. It is async for the same reason Transport is. A file picked in a browser is a Blob, which yields its bytes through a promise and offers no blocking read.

A verb therefore consumes two async seams and implements neither. Bytes come off a device and go out to a file, a buffer or a Blob, one window at a time. The image is never held in memory.

Bootstrap

Bootstrap brings a newly detected device from its on-chip boot code to a state where its flash is reachable. It is the one per-vendor part, and it is small.

FlashAgent

FlashAgent is the uniform block interface every backend provides: read, write, erase and info. The verbs consume FlashAgent rather than Transport, because one backend has no transport. A raw block device the host operating system owns is a file, and its agent reads and writes that file directly.

Read-back

Each backend decides the point at which a write is proved to have landed, and the write plan names it. The rockusb backend reads back each window before it sends the next. A DFU board holds every block until the session ends, so its region is streamed, committed and then read back whole. In both cases the read-back is mandatory, and its memory use stays bounded.

Partitions

The partition layer maps a sector number to a name, and a name to a range the device itself supplied. It reads two formats, the UEFI GPT and Rockchip’s own parameter block, and one uniform type covers both.

It also writes both formats, through the same gated, read-back write path every other write uses. It repairs a damaged copy from an intact one, and authors a fresh table from a layout you supply.

Each format keeps more than one copy, and the reader uses the redundancy. A table that fails its own checksum is reported as damaged, not as absent. A half-written table therefore stays visible to whoever writes to the board next.

A write can be aimed at a partition by name. That is the only form that knows where the partition ends. It is therefore the only one that can refuse an image too large to fit. Whichever way a write is aimed, its plan names the partitions it will land in, and how much of each. That plan is shown before the write is confirmed.

Verbs

The verbs are written once against FlashAgent and cannot tell the backends apart, so dump, flash, clone and verify work on every backend.

Serial drivers

Two drivers consume the Serial seam directly, and have no FlashAgent. One is StarFive’s UART recovery, a write-only XMODEM sender. The other is the serial console, which drives a bootloader prompt.

Neither addresses sectors or carries a partition table, so neither offers the uniform verbs. Each has a verb of its own, rather than the uniform set with every verb disabled.

The console connects the USB and serial halves. A bootstrap loads U-Boot into DRAM: the Rockchip maskrom bootstrap over USB, or the StarFive one over the serial line. The console then instructs that U-Boot, at its own prompt, to answer on the bus.

Codecs

Every byte layout is a pure, sans-I/O codec, unit-tested without hardware. This covers both the layouts that cross the wire and those stored on the flash.

Capabilities

Each backend advertises its capabilities, so the tool refuses or grays out the verbs a board cannot perform, and states why. The reason is written once, in the layer that decides it, and every front-end quotes it.