H96 MAX M9
The H96 MAX M9 (and the M9S, the same board) is an Android TV box built on the
Rockchip RK3576. It carries an octa-core CPU (4x Cortex-A72 + 4x Cortex-A53) with a
Mali-G52 MC3, LPDDR4X memory, eMMC 5.1 storage, 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. Most of the work of supporting it is therefore having a bootloader that can recover the board without a cable. That is what the RK3576 u-boot images exist for.
Recipes
| Recipe | Deliverable | Status |
|---|---|---|
h96-max-m9/forky | Whole-disk Debian image (forky) | expected — the board has booted this configuration, but not at this pin |
h96-max-m9/media-accel | The same image plus HW video decode and the RGA 2D accelerator | expected — every path is exercised on the board, but not at this pin |
h96-max-m9/util | u-boot only — the recovery tool, with this board’s ethernet | builds, and its ethernet is 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. Every path therefore 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. There are two ways in:
- The floor, on any firmware: short the two eMMC test pads (clock and ground,
on the solder side next to the
EMMCsilkscreen) 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, and the reference unit’s newer firmware boots Android recovery instead. That 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. That is a Rockchip default which 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. The u-boot prompt, the boot menu,
and a rescue-stick boot are therefore 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. sudo asks for that password,
and the hostname is h96-max-m9.
Hardware status
Validated on the reference unit (8 GB / 128 GB) running a boot2deb image:
| Subsystem | State |
|---|---|
| Boot to HDMI login, eMMC | works |
| Ethernet (GMAC0) | works |
| eMMC (HS400-ES) | works |
| HDMI video | works — up to 340 MHz TMDS, so 1080p60, 1440p60 and 4K30 |
| HDMI hotplug + EDID | works — unplug/replug re-reads EDID, desktop included |
| GPU (Mali-G52 / panfrost) + Mesa GL | works — GL 3.1 / GLES 3.1, full desktop composites on it |
| Wi-Fi, 2.4 + 5 GHz | works (AIC8800D80) |
| Bluetooth | works — hci0 up, LE + classic scan, A2DP audio |
| Suspend / resume (s2idle) | works |
| USB 2.0 host | works |
| Bundled remote | works, zero-config |
| IR receiver | works — NEC decoded to input events |
| HDMI-CEC | works — 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 audio | works |
| 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, HEVC | works — 1080p and 4K on the VDPU383, bit-exact against software |
| HW video decode, H.264 | works — a silicon power-up erratum exists, and the shipped kernel works around it. See below |
| HW video decode, 10-bit | works — Main 10 and High 10 land as NV15 and unpack on the CPU, bit-exact. See below |
| HW video encode | no mainline driver |
| RGA 2D accelerator | works — both RGA2 cores, over DMA-BUF only. See below |
| Vulkan | none — Mesa’s PanVk does not drive this GPU. See below |
| SD card | absent — the slot is depopulated |
| USB 3.0 SuperSpeed | works on the blue port — 5 Gbps sustained over 92 GB with no link error. 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
.debthrough the kmods layer. The device declares it asdevice_kmods = ["aic8800"]rather than carrying it as a kernel patch series. An RK3576 board without the chip therefore gets a lean kernel. -
Bluetooth audio needs
libspa-0.2-bluetoothinstalled alongside a desktop. The image shipsbluez, sohci0comes up on its own and a headset pairs. Sound only reaches it through PipeWire’s Bluetooth plugin, whichwireplumberandpipewire-pulsemerely 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=1is in the kernel command line. A core suspended into the DTCPU_SLEEPstate can miss its wakeup on this platform’s BL31. It is a board-level workaround, stated indevices/h96-max-m9.tomlwith the condition to drop it. -
Only the blue port beside HDMI carries SuperSpeed, and it does. That port is
drd0, running SuperSpeed on the usbdp PHY, and it needs nothing beyond the SoC dtsi to do it. Measured at 5 Gbps and holding it: 92 GB of continuous reads in 606 s at 145-158 MB/s, with no link error. The port was still atspeed=5000afterwards. That rate is the drive’s flash rather than the link.A device landing at 480 Mb/s here is a mechanical fault, not a configuration one. With the board seated back from the enclosure’s front panel, a plug bottoms out on the case before the connector’s five recessed SuperSpeed contacts mate. The four USB 2.0 contacts at the leading edge do mate. That enumerates flawlessly at high speed and leaves the SuperSpeed root hub silent. Receiver detection is a DC test for the device’s termination, and an unmated pair reads as absent. Check the seating first.
The black ports are
drd1, and they cannot carry SuperSpeed. They sit behind an internal1a86:80914-port USB 2.0 hub that also carries the bundled remote’s receiver.drd1’s own SuperSpeed lane reaches no connector. -
4K60 is not reachable on any
dw-hdmi-qpboard, 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. It also offers 2560x1440 at 60 and 1920x1080 at 60. There is 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. 4K30 (297 MHz) is the ceiling even when the display advertises 4K60. This is upstream behavior, 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 takesrockchip,sai-tx-route = <0 1 0>. InSAI_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. No in-tree board could notice, because they all describe the identity mapping the register already holds. Therk3576-fixesseries carries the driver fix, as patch 103.It was 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.
That is 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. At 1080p the download costs more than software decoding the stream would
have, so 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 importer, or librga. A filter chain that cannot takes the download and the loss with it.
What the decoder is worth, over 300-frame clips on the shipped image:
| stream | software | VDPU383 |
|---|---|---|
| 1080p H.264 | 199 fps, 6.2 cores | 242 fps, 0.23 cores |
| 4K H.264 | 76 fps, 6.4 cores | 84 fps, 0.19 cores |
| 1080p HEVC | 131 fps, 5.4 cores | 500 fps, 0.55 cores |
| 4K HEVC | 53 fps, 6.4 cores | 136 fps, 0.35 cores |
| 4K HEVC Main 10 | 34 fps, 6.5 cores | 134 fps, 0.40 cores |
Four concurrent 4K30 HEVC streams hold real time at 1.17x. A fifth falls to 0.95x, so four is the ceiling for live playback. The gain is in CPU cost far more than in wall clock. The decoder frees the cores rather than racing them.
H.264 decode: a power-up erratum, worked around in the shipped kernel
This SoC’s decoder has a silicon erratum. Whether hardware H.264 comes up correct is decided when the block’s power domain powers up. Unwarmed, roughly one power-up in three comes up bad (634 of 2000 measured power-ups, with 1080p and 4K indistinguishable), and the draw is independent every time. Every H.264 decode made on a bad power-up is corrupt from the first frame. That is about 17 dB PSNR, with one row in eight of each plane wrong, spread across the picture by motion compensation. It is specific to this decoder generation, and the same clips on an RK3588 are correct in every session.
The shipped kernel works around it by decoding a canned 2x2 H.264 frame on the block at every runtime resume. That happens before any real decode of that power cycle can run. Three measurements on this board:
- 0 of 400 power-ups corrupt, against an interleaved control’s 128 of 400
- A further 0 of 200, alternating 1080p and 4K on the shipped kernel
- The warm-up holds across system sleep as well as runtime PM
If the warm-up ever fails, the driver says so in dmesg (vdpu383 warm-up job timed out,
or ... not ready), and H.264 decoded on that power cycle is suspect.
HEVC is unaffected either way, and is bit-exact against software on every run. A kernel without the work-around cannot be relied on for hardware H.264 on this SoC. The software decoder is correct and quick enough on eight cores (about 160 fps at 1080p, 40 at 4K). All of this is stated as a caveat on the SoC. It therefore 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. This ffmpeg therefore scales on the CPU,
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.
Bracket every CPU access to a dma-heap buffer in DMA_BUF_IOCTL_SYNC. Use START
with WRITE before filling a source and END after. Use START with READ before
checking a result. These buffers are cached.
Skip the bracket and the engine reads a stale source while the CPU reads a stale destination. That looks exactly like an RGA correctness bug and is not one. The trap catches the destination pre-fill as readily as the source.
One consequence is worth knowing if a client dies without a message. Failing to open
/dev/rga or a DMA-BUF heap makes librga segfault rather than fail gracefully. The
image ships udev rules that make both accessible. A segfault on a board where they were
removed is therefore a permissions problem rather than a library bug.
10-bit: NV15 out of the decoder, unpacked on the CPU
10-bit content decodes in hardware and the VDPU383 writes NV15 — packed 10-bit 4:2:0,
with no byte-aligned samples. The decoder cannot be asked for anything else, because
that is what the hardware writes natively.
This ffmpeg reads it. The rk3576-media patch series teaches libavutil and libswscale
the format, and tags it on the V4L2-request frames context. hwdownload therefore
accepts a 10-bit surface, and format=yuv420p10le unpacks it. HEVC Main 10 at 1080p and 4K and
H.264 High 10 at 1080p all decode bit-exact against this same build’s software
decoder, frame by frame. A plain -hwaccel v4l2request transcode of 10-bit content
works, and 4K Main 10 decodes at about 134 fps for under half a core.
What it costs is that CPU unpack, because no filter in this build reads NV15 directly.
Tone mapping a 10-bit source means unpacking first.
RGA does not close the gap. This SoC has RGA2 cores only, which take NV15 in but write
no 10-bit format out. P010 output is an RGA3 capability, and there is no RGA3 here. A
hardware 10-bit-to-10-bit conversion therefore does not exist on this part, structurally.
The VDPP block this SoC does carry is not a way round it either. Its pixel path takes
NV12 or NV21 and nothing else, with a two-bit format field that has no bit-depth
selector at all. The 10-bit formats it does name feed a histogram engine that produces
statistics rather than a picture.
The one route with no conversion at all is the display. The controller scans NV15 out
unconverted, so a player that hands the decoder’s DMA-BUF straight to a KMS plane plays
10-bit with nothing in between. The CPU unpack above is the price of filtering or
transcoding, not of playback.
Vulkan: none on this GPU
The image carries no Vulkan driver. Mesa’s PanVk declines the Mali-G52 unless
PAN_I_WANT_A_BROKEN_VULKAN_DRIVER is set. With it set, on Mesa 26.1.6 and 26.2.2, every
ffmpeg Vulkan filter measured computes the chroma planes wrong. libplacebo refuses the
device outright, since PanVk offers Vulkan 1.0 on this GPU and libplacebo needs 1.2.
The one device Debian’s mesa-vulkan-drivers would add is lvp, the llvmpipe software
rasterizer. It runs the Vulkan filters on the CPU, and more slowly than the CPU filters
themselves. Decoding 300 frames of 1080p HEVC in hardware and scaling them to 720p costs
28.6 CPU-seconds with scale_vulkan on lvp, against 10.5 with swscale.
So ffmpeg’s Vulkan filters and libplacebo fail here at device creation. Scaling and
filtering run on the CPU, and RGA is reached through librga. OpenGL ES is unaffected:
Panfrost drives GLES 3.1 on this GPU, as the status table says.
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, since
DRM_DW_HDMI_QP_CEC and MEDIA_CEC_RC are set at the SoC layer. /dev/cec0 therefore
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. A box must not start sending CEC messages on its own, on a computer monitor or on a television whose owner wants quiet:
| Unit | What it sends | When |
|---|---|---|
cec-tv-on | IMAGE_VIEW_ON, then ACTIVE_SOURCE — wakes the TV and switches it to this input | at boot, and on resume from sleep |
cec-tv-standby | a directed STANDBY to the TV | at shutdown and reboot |
cec-passthrough | nothing. It runs cec-follower so the TV remote’s keys arrive as input events | continuously |
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, and 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. That is 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.0is 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 Retriesmeans 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_NAMEandGIVE_CEC_VERSIONtime out against televisions that omit them, and a CEC 2.0REPORT_FEATUREScan come backunrecognized-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, because the alternative is not recoverable here. The box
has no power button, and once it is off the remote’s receiver is unpowered.
HandlePowerKey=poweroff would therefore 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. The image therefore 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. There is no RTC on this board either, so there is
no rtcwake.
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. 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.
The NPU clock is set to 594 MHz. CLK_RKNN_DSU0 feeds both cores and the convolution
buffer they share, and left alone it free-runs at 786 MHz. That is above the 800 MHz OPP
step, which asks 800 mV. This board’s vdd_npu_s0 comes up at 750 mV and nothing
re-rates it.
The rate asked for instead sits between the 500 and 600 MHz steps. Both of those ask 725 mV, so the clock is inside the voltage the board already provides. It costs about 23% of throughput on a single core, which is what this board ships.
The NPU rail, vdd_npu_s0, follows the NPU’s power domain. The board .dts names the
rail as that domain’s domain-supply. The domain turns the rail on before the NPU
powers up, and off after it goes idle. An image that never opens the accel node holds
no NPU supply up.
The rail has to be wired this way. rocket claims no regulator itself. A rail given
only to the NPU core node is torn down as unused about 33 s into boot, long after a
successful probe.
Both NPU core domains sit on the one core node, in the SoC .dtsi. The convolution
buffer is read through the second domain whichever core computes. The board .dts
states the reasoning for both.
Related pages
- 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.