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

H96 MAX M9

The H96 MAX M9 (and the M9S, the same board) is an Android TV box built on the Rockchip RK3576 — octa-core (4x Cortex-A72 + 4x Cortex-A53), Mali-G52 MC3, LPDDR4X, eMMC 5.1, Gigabit ethernet, and HDMI. boot2deb turns it into a mainline Debian box: kernel v7.2, u-boot v2026.07, no vendor BSP.

It is a cheap and widely available RK3576 board, which makes it a practical target — and an awkward one. There is no SD slot (the pads are depopulated), no reset button, and no exposed serial header until you open it, so most of the work of supporting it is having a bootloader that can recover the board without a cable. That is what the RK3576 u-boot images exist for.

Recipes

RecipeDeliverableStatus
h96-max-m9/forkyWhole-disk Debian image (forky)expected — the board has booted this configuration, but not at this pin
h96-max-m9/media-accelThe same image plus HW video decode and the RGA 2D acceleratorexperimental
h96-max-m9/utilu-boot only — the recovery tool, with this board’s ethernetbuilds; ethernet validated

The base image carries the NPU — see The NPU below.

Build the shipped image as in Getting started:

boot2deb build h96-max-m9/forky

That writes build/h96-max-m9/forky/artifacts/h96-max-m9-forky.img.xz — GPT, u-boot in the raw gap ahead of the first partition, then the ext4 rootfs, so one write lays down everything.

Flash

The box boots from eMMC and has no card slot, so every path goes over USB through the USB 3.0 Type-A port on the rear panel — that connector is the SoC’s drd0 controller, wired as a USB device. A plain USB-A-to-USB-A cable to your laptop is what talks to it.

Two routes, in order of preference:

1. From a running u-boot (ums), no vendor tooling. Stream the util image into RAM over maskrom (see The maskrom loader), then at the u-boot prompt:

ums 0 mmc 0

The eMMC appears on your laptop as a USB block device. Press the image into a verified raw file, then write it with any flasher — confirm the device with lsblk first, since the eMMC reports as an ordinary fixed disk:

boot2deb press h96-max-m9/forky h96.img --hostname h96-01
sudo dd if=h96.img of=/dev/sdX bs=4M status=progress conv=fsync

See Producing images for verification and the per-unit seed keys (--wifi-ssid joins this box to your network at first boot).

2. Over rockusb, with the board in maskrom mode (below): load the build’s own maskrom loader (*-maskrom.bin, from build --stage uboot), then write the raw pressed image from sector 0:

rkdeveloptool db build/h96-max-m9/forky/artifacts/h96-max-m9-forky-maskrom.bin
rkdeveloptool wl 0 h96.img

rkdeveloptool takes the raw file press produces (not the .img.xz). This route is also the fallback if you have no u-boot on the board yet; a failed db almost always means no board in maskrom mode on USB.

Reading the eMMC back over rockusb does not work — the read path truncates at 32 MiB. Use ums (above) or dd from a booted system.

Getting into maskrom

Maskrom is the BootROM’s USB download mode, and it is the entry that depends on nothing already working on the board.

  • The floor, on any firmware: short the two eMMC test pads (clock and ground, on the solder side next to the EMMC silkscreen) at power-on. The BootROM cannot clock data out of the flash and falls through to USB device mode. The status LED glowing dim rather than bright confirms it.
  • Once the board runs boot2deb’s u-boot: press the recessed button in the AV jack before connecting power. Our u-boot carries the download-key patches, so the button drops straight into maskrom.

The AV-jack button behaves differently on factory firmware depending on the build — some reach loader mode, the reference unit’s newer firmware boots Android recovery instead — which is exactly why the pad short is the documented floor.

Serial console

The UART is on an unpopulated 3-pin header inside the case, and it runs at 1500000 baud, not 115200 — a Rockchip default that will otherwise look like a dead port.

You do not need it. The display u-boot the image ships with drives the HDMI console and a USB keyboard on the drd1 (USB 2.0) port, so the u-boot prompt, the boot menu, and a rescue-stick boot are all reachable on the television.

First boot

Power on. The image regenerates its SSH host keys and grows the rootfs to fill the eMMC, online, in the same boot. Log in as debian with the password the build printed; it is expired, so you set a new one immediately. The account has passwordless sudo and the hostname is h96-max-m9.

Hardware status

Validated on the reference unit (8 GB / 128 GB) running a boot2deb image:

SubsystemState
Boot to HDMI login, eMMCworks
Ethernet (GMAC0)works
eMMC (HS400-ES)works
HDMI videoworks — up to 340 MHz TMDS, so 1080p60, 1440p60 and 4K30
HDMI hotplug + EDIDworks — unplug/replug re-reads EDID, desktop included
GPU (Mali-G52 / panfrost) + Mesa GLworks — GL 3.1 / GLES 3.1, full desktop composites on it
Wi-Fi, 2.4 + 5 GHzworks (AIC8800D80)
Bluetoothworks — hci0 up, LE + classic scan, A2DP audio
Suspend / resume (s2idle)works
USB 2.0 hostworks
Bundled remoteworks, zero-config
IR receiverworks — NEC decoded to input events
HDMI-CECworks — the box wakes, switches to and standbys a TV; opt-in, see below
NPU (rocket)device works — jobs compute bit-exact; no userspace for this SoC yet
HDMI audioworks
S/PDIF (optical)works
Analog audio (3.5 mm)fixed in tree — the DAC is on sdo2; end-to-end confirmation on a shipped image still owed
HW video decode, HEVCworks — 1080p and 4K on the VDPU383, bit-exact against software
HW video decode, H.264decodes, but not reliably — see below before using it
HW video encodeno mainline driver
RGA 2D acceleratorworks — both RGA2 cores, over DMA-BUF only; see below
SD cardabsent — the slot is depopulated
USB 3.0 SuperSpeedblue port only, and unconfirmed — enabled in tree, never yet trained on hardware; the black ports cannot, see below

Things the board needs that are worth knowing about:

  • Wi-Fi is an out-of-tree module. The AIC8800D80 has no mainline driver, so boot2deb builds one from a pinned upstream repo as a .deb through the kmods layer — declared by the device as device_kmods = ["aic8800"], not carried as a kernel patch series, so an RK3576 board without the chip gets a lean kernel.
  • Bluetooth audio needs libspa-0.2-bluetooth installed alongside a desktop. The image ships bluez, so hci0 comes up on its own and a headset pairs — but sound only reaches it through PipeWire’s Bluetooth plugin, which wireplumber and pipewire-pulse merely Suggest. No desktop metapackage brings it in, and a paired headset with no sink is what its absence looks like. Images are headless and carry no PipeWire, so install it when you install the desktop.
  • cpuidle.off=1 is in the kernel command line. A core suspended into the DT CPU_SLEEP state can miss its wakeup on this platform’s BL31. It is a board-level workaround, stated in devices/h96-max-m9.toml with the condition to drop it.
  • Only the blue port beside HDMI can ever carry SuperSpeed, and whether it does is not yet settled. That port is drd0, and it runs SuperSpeed on the usbdp PHY with snps,dis_rxdet_inp3_quirk — dwc3’s receiver-detection workaround is what turned SS training into a -62/-71 SetAddress loop, and suppressing it is the lever this board has. No SuperSpeed device has trained on it yet, so treat the port as high speed until you have measured otherwise. The black ports are drd1, and they cannot: they sit behind an internal 1a86:8091 4-port USB 2.0 hub that also carries the bundled remote’s receiver, and drd1’s own SuperSpeed lane reaches no connector, so its SS phy is left off the controller entirely.
  • 4K60 is not reachable on any dw-hdmi-qp board, this one included, and you see it in the mode list rather than as a failure: on a 4K60 display the connector offers 3840x2160 at 30, 29.97, 25, 24 and 23.98 Hz with 30 preferred, 2560x1440 at 60, and 1920x1080 at 60 — no 4K60 entry at all, because the driver rejects the mode before userspace sees it. The bridge rejects every mode above 340 MHz TMDS because it has no SCDC/scrambling support, so 4K30 (297 MHz) is the ceiling even when the display advertises 4K60. This is upstream behaviour, not a board or device-tree limitation.
  • The 3.5 mm analog jack needed both a device-tree and a kernel fix. Its DAC sits on SAI1’s sdo2, and reaching it takes rockchip,sai-tx-route = <0 1 0> — in SAI_PATH_SEL, TX field x selects which stream drives SDO port x, so the third entry is the one that matters. The upstream SAI driver programmed that register only at probe and the value did not survive to the running device, which no in-tree board could notice because they all describe the identity mapping the register already holds. The rk3576-fixes series carries the driver fix, as patch 103. Verified at the register level by driving each SDO port in turn and listening; the device tree alone cannot fix this board.

Hardware video decode and RGA

h96-max-m9/media-accel adds the media-accel-v4l2 feature on top of the base image: ffmpeg-rk built with V4L2-request decode, and librga2 with the out-of-tree RGA driver the feature’s own patch series and kconfig fragment bring in. The base image carries neither — the VDPU383 driver is on the SoC layer either way, but nothing in a base image can drive it.

boot2deb build h96-max-m9/media-accel

This is a decode-and-2D capability, not a transcode one. There is no mainline encoder for the RK3576, so an ffmpeg that decodes in hardware still encodes on the CPU.

Decode: drm_prime is not optional

ffmpeg-rk -hwaccel v4l2request -hwaccel_output_format drm_prime -i in.mkv -f null -

Both flags matter, and the second one is the difference between an accelerator and a regression. -hwaccel v4l2request alone leaves the frames going back to system memory one at a time, and at 1080p the download costs more than software decoding the stream would have: the hardware path ends up slower than no hardware path. With -hwaccel_output_format drm_prime the decoded frames stay in DMA-BUF handles, and 4K HEVC runs at real time for about 1/130th of the CPU.

So anything that consumes the output has to speak DMA-BUF — a KMS plane, a GL or Vulkan importer, or librga. A filter chain that cannot takes the download and the loss with it.

H.264 decode is not reliable on this SoC

HEVC is bit-exact against the software decoder on every run. H.264 is not: roughly one decode session in three to six comes out visibly wrong, and when it does the whole session is wrong — it diverges from the first frame at about 17 dB PSNR rather than glitching in places. Re-running the same file usually succeeds, which is what makes it easy to miss.

The fault is latched when the decoder powers up, not accumulated during a decode, so no warm-up or sacrificial first frame avoids it; holding the block resumed across a batch is clean, and letting it power-gate between decodes is not. It is specific to this SoC’s decoder generation — the same clips on an RK3588 are correct in every session.

Until it is fixed, do not rely on hardware H.264 here. The software decoder is correct and quick enough on eight cores — about 160 fps at 1080p and 40 at 4K — and HEVC in hardware is unaffected. This is stated as a caveat on the SoC, so it prints at the end of any build for this board and appears in the support matrix.

RGA: pass it DMA-BUF file descriptors

Both RGA2 cores work, and librga2 is installed for programs that speak its API. ffmpeg is not one of them here: scale_rkrga and vpp_rkrga need MPP, which needs a vendor kernel framework mainline does not have, so this ffmpeg scales on CPU or GPU and RGA is reached directly.

Import buffers with importbuffer_fd, never wrapbuffer_virtualaddr. The virtual-address path builds an IOMMU mapping per call over ordinary process memory and faults roughly a third of jobs — the job times out after a second, the core is soft reset, and the destination is left untouched. It is also about seven times slower when it does work. The same operations over DMA-BUF run clean on both cores.

One consequence worth knowing if a client dies without a message: librga does not fail gracefully when it cannot open /dev/rga or a DMA-BUF heap — it segfaults. The image ships udev rules that make both accessible, so a segfault on a board where they were removed is a permissions problem rather than a library bug.

10-bit stops at the decoder

10-bit content decodes in hardware, but the VDPU383 writes NV15 — packed 10-bit 4:2:0 — and nothing downstream in this image can take it. Vulkan has no such format, and neither Mesa nor ffmpeg’s filters can import it. A 10-bit transcode therefore converts on the CPU. RGA can convert NV15 to P010 for a program that drives it directly, and the display controller scans NV15 out unconverted, so playback straight to a KMS plane is unaffected.

HDMI-CEC

CEC is a control channel inside the HDMI cable, and this box drives it both ways: it can wake a television, claim its input and put it back into standby, and the television’s own remote can drive the box. The kernel side is present in every image for this board — DRM_DW_HDMI_QP_CEC and MEDIA_CEC_RC are set at the SoC layer — so /dev/cec0 exists on a fresh boot and the adapter comes up as Playback Device 1 at physical address 1.0.0.0.

None of it acts until you ask. Three units ship installed but disabled, because a box plugged into a computer monitor, or into a television whose owner would rather it kept to itself, must not start sending CEC messages on its own:

UnitWhat it sendsWhen
cec-tv-onIMAGE_VIEW_ON, then ACTIVE_SOURCE — wakes the TV and switches it to this inputat boot, and on resume from sleep
cec-tv-standbya directed STANDBY to the TVat shutdown and reboot
cec-passthroughnothing; runs cec-follower so the TV remote’s keys arrive as input eventscontinuously

Box drives TV, which is what most people want:

sudo systemctl enable --now cec-tv-on.service
sudo systemctl enable cec-tv-standby.service

--now on the first one wakes the TV immediately, which doubles as the test that it works. The second deliberately has no --now: its work runs at stop, so it fires when the box goes down, not when you enable it.

Enabling cec-tv-on is also what makes the TV follow the box into sleep. The h96-cec hook in /usr/lib/systemd/system-sleep/ reads that one unit for both directions — with it enabled, a POWER press standbys the TV on the way down and wakes it on the way back up. cec-tv-standby covers only shutdown and reboot, so enabling that one on its own leaves suspend untouched: the box sleeps, the HDMI signal simply stops, and the television shows its own “No Signal” instead of going dark. Without CEC that is the whole story of what a suspend looks like on screen — the TV was never in standby, it just lost its source.

For the other direction, the TV’s remote driving the box, enable cec-passthrough and cec-tv-on. The dependency is not incidental: a television forwards USER_CONTROL_PRESSED only to whatever it believes is the active source, so a box that never announced itself receives nothing.

Once cec-tv-on has run the adapter stays configured, so the bus is visible from the box:

cec-ctl -d /dev/cec0 -S

A television that implements CEC answers as 0.0.0.0: TV with its vendor ID. Everything else on the cable is listed too, which is worth reading before enabling cec-tv-standby: the standby it sends is directed at the TV rather than broadcast, precisely so a games console or receiver sharing the bus is not put to sleep along with it.

Two results that look like faults and are not:

  • A physical address is not evidence of CEC support. Physical Address: 1.0.0.0 is derived from a mandatory EDID field, so it is reported even when nothing at the other end speaks the protocol. The real test is whether messages are acknowledged; Tx, Not Acknowledged (4), Max Retries means nothing is driving the CEC line at all. Computer monitors generally do not, including ones from vendors whose televisions do.
  • Some queries go unanswered. GIVE_OSD_NAME and GIVE_CEC_VERSION time out against televisions that omit them, and a CEC 2.0 REPORT_FEATURES can come back unrecognized-op. Those are the sink’s omissions — the transmits themselves are acknowledged, so nothing local is wrong.

The box appears in the TV’s device list as H96 MAX M9. The wrappers in /usr/lib/h96/ take OSD_NAME and CEC_DEV from the environment if a different name or a second adapter is wanted; CEC caps the name at 14 characters.

Sleep and the POWER key

The remote’s POWER key suspends the box, and the same key wakes it. That is the shipped default rather than systemd’s, and the reason is that the alternative is not recoverable here: the box has no power button, and once it is off the remote’s receiver is unpowered, so HandlePowerKey=poweroff would leave cycling the supply as the only way back. To choose poweroff or ignore anyway, see /usr/lib/h96/power-profiles/README on the board.

Sleep is suspend-to-idle, pinned by 50-h96-s2idle.conf. The SoC also offers deep, and selects it by default, but nothing resumes from it — the box powers down and needs its supply cycled — so the image names freeze explicitly rather than letting systemd write mem. Anything that asks logind to suspend, a desktop’s idle timer included, takes that path.

The television does not follow the box unless you tell it to. Suspending stops the HDMI signal and nothing more, which a TV shows as “No Signal” while staying on; putting it into standby alongside the box is one systemctl enable cec-tv-on.service away, and HDMI-CEC covers what that turns on.

Waking is out of band: the receiver drives gpio0 PD3, the gpio-keys POWER input, which is a wakeup-source in the always-on power domain. The bundled remote is the only wake source the box has out of the box. Its receiver cannot wake anything over USB — it leaves the remote-wakeup bit clear in its configuration descriptor, so the kernel creates no power/wakeup node for it — and there is no RTC on this board, so there is no rtcwake either.

A USB keyboard or mouse can wake it if its receiver does set that bit. 70-h96-usb-wakeup.rules arms every HID device that has a power/wakeup node, which skips the bundled receiver and catches a wakeup-capable one. What is armed on a running box:

grep . /sys/bus/usb/devices/*/power/wakeup

The NPU

The RK3576 carries an RKNN neural accelerator, and mainline drives it — through the in-tree rocket DRM-accel driver, no vendor RKNPU2 stack. Every image for this board has it: the shipped h96-max-m9/forky binds rocket on 27700000.npu and presents /dev/accel/accel0.

Jobs submitted to it compute correctly: an int8 convolution is bit-exact against a CPU model on this silicon, and multi-task row-windowed programs stay bit-exact submitted back to back with no gap.

The image supplies the device, not a runtime, and there is no released userspace for this SoC yet. Nothing in Debian opens an accel node at all, and the NPU’s register program is SoC-specific — the RK3576 map is shifted and re-packed relative to the RK3588, so a userspace written for the RK3588 does not run here. rocket-userspace is the library to watch: it is bit-exact on the RK3588 today and names the RK3576 as its next target, but its machine parameters (CBUF size, core count, datatype set) have to be confirmed on this part before it drives this board. Until then, driving the NPU here means writing your own regcmd encoder against /dev/accel.

Two properties of the board’s DTS are load-bearing and fail in ways that do not point at themselves — both power domains (NPU0 and NPU1) on the one core node, or the driver’s own domain attach loses to the device core’s and there is no /dev/accel; and regulator-always-on on vdd_npu_s0, or the rail is torn down as unused ~33 s into boot, long after a successful probe. The board .dts states both with the reasoning.

That always-on rail is the standing cost of carrying the NPU in the base image: it holds a supply up on a board that may never open the accel node.

  • RK3576 u-boot images — the loader / display / util split, what each can do, and why the u-boot variant is its own axis.
  • The maskrom loader — streaming a u-boot into RAM over the BootROM download protocol.
  • Support matrix — every recipe with the exact pins its lock records.