Tprrt's Blog

Embedded Linux, Zephyr RTOS, open-source hardware, Linux gaming, retro gaming, and competitive fitness

Oct 22, 2026

Plugging a Mini-HDMI into a Running Game: Six Wrong Turns on an RG353M

Introduction

The Anbernic RG353M is a handheld built around a Rockchip RK3568, with a 640x480 DSI panel and a mini-HDMI connector. Recalbox runs on it, and until now the HDMI output only half worked: plug the cable before powering on and the menu appears on the TV, but plug it afterwards and nothing follows. This article is about making the menu, the boot video, and above all a running game follow the cable, in both directions.

The fix is a merge request on the Recalbox side and a few patches on RetroArch's KMS context. It took six detours. In each one my first explanation was wrong, and a log line or a file date told me so.

The setup, for reference:

  • Recalbox, a Buildroot external tree, target rg353x, Linux 6.16.5;
  • RetroArch 1.22.2, plus a stack of local patches;
  • the Recalbox frontend, an EmulationStation fork, drawing through SDL's KMSDRM backend;
  • two connectors: DSI-1 (the panel) and HDMI-A-1.

A note on method: I build in a rootless Podman container, flash an SD card, test on the real device and paste the logs back. A cycle takes several minutes, so each wrong guess is expensive, and that shaped how this went.

Wrong turn 1: the game that exits right away

The first piece was easy. The frontend reads /sys/class/drm/card*-HDMI-A-*/status every half second, waits for three identical reads (about 1.5 s) to ignore a sink that bounces while the link comes up, and then relaunches itself. SDL's KMSDRM backend picks its connector once, at init, so a new process is the simplest way to follow the cable. After that, the menu showed up on the TV.

Then I started a game on the TV and it quit immediately, back to the menu. RetroArch's log had the reason:

[WARN] [DRM] Couldn't find DRM encoder.

RetroArch's KMS context looks for the encoder the selected connector is currently using. When the board boots without a cable, HDMI-A-1 has no encoder at all, because nothing ever drove it. Plugged in later, the connector is connected but has none, and the context gives up.

The fix is what a modeset does anyway: take one of the connector's possible encoders, and a CRTC that is off, falling back to a lit one only when none is free. A second small fix goes with it. RetroArch asks for the size the game was started with, 640x480 on the panel; the TV has no such mode, and the start failed again. Falling back to the display's first mode costs nothing.

Wrong turn 2: the patch that never reached the image

I added the patch, rebuilt, flashed. The same error. The RetroArch log printed its build date:

[INFO] Built: Oct 20 2026

Two days old. The patch was in the source tree, the package had been rebuilt, and the image still had the old binary.

Recalbox builds with BR2_PER_PACKAGE_DIRECTORIES=y. Each package gets its own copy of the target tree, assembled from its dependencies. Three packages that depend on retroarch (retroarch-assets, libretro-holani and libretro-mojozork) carried their own copy of the old /usr/bin/retroarch, and when the final tree is put together theirs won. Rebuilding only the dependency leaves the stale files in the dependents.

The fix is to clean the dependents too, and the check is cheap: the Built: line, or strings on the binary in output/target. I should have looked at that line before touching the code. It is the first thing I do now.

Wrong turn 3: restarting everything just to change the screen

With games starting on the TV, I wanted the next step: plug or unplug the cable during a game and have the game move. RetroArch already knows how to restart its video driver; what it lacked was a reason to do it.

The context polls the connectors once a second from check_window, and asks for a restart when the monitor index would now pick another display. Three details made it behave:

  • Debounce: a restart only on two identical polls in a row, as with the frontend side;
  • A loop guard: a connector already tried is not requested again, so a display with no usable mode cannot cause an endless restart;
  • Where it runs: the poll runs on the video thread, which cannot restart itself. It sets a flag, and the main loop does the restart.

It worked. The log showed the connector change four times in one game, HDMI to panel to HDMI to panel, and the game kept running. But the sound cut at each switch. The reason was one argument: command_event(CMD_EVENT_REINIT, NULL) restarts all the drivers, audio included, and I had also suspended audio explicitly around it. Passing a mask of video, input and menu was enough. In the next log Started synchronous audio driver appears once for the whole game, not once per switch.

Wrong turn 4: the panel that stayed black

This one cost the most. Boot with the cable plugged, unplug it, and the frontend relaunches on the panel (Trying Resolution: 640,480), but the panel stays black. The backlight reads 128. The DRM state, dumped from debugfs, is identical to the working case. Stopping the frontend does not bring the console back either, so the panel is not being scanned out whatever userspace does.

I proposed three explanations to myself and had to retract all of them:

  • the date of /dev/dri/card0 looked like the device had been recreated. It is the last access time;
  • the plane assignment looked different. The working case had the same one;
  • the Dim screensaver sets brightness to a smaller step, and brightness 8 fits (7 >> 1) = 3 then 1 << 3. It was not the cause here, since the backlight read 128 with a black panel.

What finally gave a direction was a line in dmesg, present on every boot with the cable plugged, around the start of fbdev:

rockchip-vop2 fe040000.vop: [drm:rk3568_vop2_setup_overlay] *ERROR*
    wait layer cfg done timeout: 0x54760213--0x54762013

On the RK3568 the VOP2 has several video ports sharing the layer and port select registers, and those registers are latched by the vsync of one of the ports. The 6.16.5 code waits for the wrong value, and switches the vsync source before the previous latch happened. With two ports started together, HDMI and DSI at boot, the configuration of one of them may never be applied.

Four upstream commits fix this, the last two merged in June 2026. I took them unchanged as kernel patches, in order, so that together they give the upstream rk3568_vop2_setup_layer_mixer(). After flashing, the error is gone from dmesg and the panel comes back after an unplug.

The link between that error and the black panel is only a hypothesis. An earlier boot logged the same error and the panel had worked. The patches are correct on their own merits and the symptom went away, but I cannot say one caused the other.

Wrong turn 5: a panel that was "on" at brightness 0

Next, a game on HDMI, then unplug. RetroArch switched to the panel (Connector 54 is now the display to use), the DSI link was enabled and connected, and the panel was black again:

# cat /sys/class/backlight/backlight/brightness
0
# cat /sys/class/drm/card0-DSI-1/enabled /sys/class/drm/card0-DSI-1/status
enabled
connected

Brightness zero, with the video path fine. The frontend keeps the panel dark while HDMI is in use, and a commit I had written was supposed to restore it on unplug. The code looked right. So I compared the file the build had compiled with the one in the source tree:

build/.../es-app/src/MainRunner.cpp   2026-10-21 13:03
projects/frontend/es-app/src/MainRunner.cpp   2026-10-21 21:06

The frontend is a Buildroot package with SITE_METHOD = local. It is copied into the build directory once, and a plain make never copies it again. My advice to rebuild with a plain make was wrong for this package; it needs recalbox-emulationstation2-rebuild, which clears the rsync stamp. A diff -r of the two trees, which must print nothing, is now part of my pre-flash checklist.

Wrong turn 6: a relaunch that took 25 seconds

With everything working, the one thing that felt wrong was time. Unplug in the menu and the screen stayed blank for ten to twenty seconds. The detection is instant (two lines in the log). What was slow was the frontend quitting.

The log of the quit gave the answer, once I looked at the gaps instead of the messages. The unplug came at 12:14:42, about eleven seconds after the frontend had started. The Hasher thread, which computes ROM hashes, exited at 12:15:01, about thirty seconds after that start, while every other thread had stopped within a couple of seconds.

// Initial delay
Thread::Sleep(30 * 1000);

The hasher waits thirty seconds before starting its work, and that sleep cannot be interrupted. Quitting inside that window waits for the end of it. Other threads had the same shape: the netplay and command threads sleep five seconds at the start, and the file watcher and others one second per loop. Together they made up the five seconds of teardown I could see even when the hasher was idle.

The fix is small: a Thread::SleepWhileRunning(ms) that sleeps in steps of at most 50 ms and returns as soon as the thread is stopped, used in those loops. The order of the sleeps is unchanged, only the exit is faster. Thread.h has Windows line endings in the repository, and my first scripted edit silently converted the whole file. The diff stat (240 lines for a 14-line change) caught it before the commit.

Epilogue: upstream got there first

When I went to rebase the RetroArch branch on the current master, the first commit conflicted. Upstream had landed, that morning, its own version of the same work: a free CRTC for a display nothing had set up, a fallback to the first mode, and a udev-based hotplug watch that restarts the video driver when the display in use goes away. Two of those commits credit me as co-author.

I had not looked at upstream between writing the patches and rebasing them, and that is the lesson I take from this: check what is already on master before the third commit, not after the fifth.

The upstream behaviour has one gap for my use case. If the panel is in use and HDMI is plugged in, the output stays where it is: the restart only happens when the display in use is lost. The change I could add on top is four lines: with the default monitor index, a display that now ranks before the one in use takes over, and an index set by the user is left alone. It has a unit test, and it worked on the board, but it needs a newer RetroArch than 1.22.2. Recalbox keeps its three patches for now.

One of my patches had an all-zero From line, written by hand when I ported it to the 1.22.2 tree. That is not a valid patch header. Apply the patch on a real branch of the upstream repository at the target tag, commit it, and regenerate the file with git format-patch so the commit ID, date and blob hashes are real.

What is left

  • The frontend relaunch is still a whole process restart. The quit is fast now, but the init takes about eight seconds, two for the window and five and a half to load the systems, plus the time the TV needs to lock the HDMI link. An in-place display reinit, which another merge request on the Recalbox side proposes, would remove most of it.
  • Only RetroArch cores follow a live switch. A standalone emulator keeps the display it picked until it exits.
  • The audio output is not switched automatically, and it stops during a frontend relaunch.
  • I decided against pausing the game during a switch. The restart already blocks it for about a second.

The work is in the Recalbox merge request. A second board would tell me whether the black panel was really the VOP2 race.

posted at 23:00  · 10 min read  ·   ·  recalbox  retroarch  kms  drm  rockchip  buildroot  embedded  linux  retro  article

May 13, 2026

Yocto Project 6.0 "wrynose" is out

The Yocto Project 6.0, codenamed wrynose, was released on May 13, 2026. This is the new Long-Term Support (LTS) release, succeeding 5.0 "scarthgap". My name is in the contributors list for this release.

If you are migrating from 5.0, make sure to read the migration guides for the intermediate releases: 5.1 (styhead), 5.2 (walnascar), and 5.3 (whinlatter).

Major component upgrades

Toolchain and core component versions:

  • Linux kernel 6.18
  • GCC 15.2
  • glibc 2.43
  • LLVM/Clang 22.1.3
  • Python 3.14.4
  • systemd 259.5
  • Go 1.26.2, Rust 1.94.1
  • QEMU 10.2.0
  • U-Boot 2026.01

Over 300 recipe upgrades in total.

Rust in the Linux kernel

One of the most significant additions is first-class Rust support for building the Linux kernel and out-of-tree kernel modules:

  • A new kernel-yocto-rust class adds the required dependencies to build Rust components of the kernel.
  • A new module-rust class supports building out-of-tree Rust kernel modules. A skeleton example is available under meta-skeleton/recipes-kernel/rust-out-of-tree-module.
  • Enabling Rust in the kernel is now as simple as adding rust to KERNEL_FEATURES in a recipe that inherits kernel-yocto.

Security improvements on by default

Several security and hardening features that were previously opt-in are now enabled by default in the nodistro setup:

  • security_flags.inc — adds security-related compiler and linker flags.
  • no-static-libs.inc — disables most static libraries.
  • uninative — allows reuse of native sstate built on one distro on another, also enabled by default now.
  • OpenSSL now disables TLS 1.0/1.1 by default.

New SBOM and CVE tooling

CVE analysis and SBOM generation changed substantially:

  • A new sbom-cve-check class replaces the old cve-check class for post-build CVE analysis of images. It uses the new python3-sbom-cve-check tool internally.
  • A new sbom-cve-check-recipe class allows CVE analysis at the recipe level, without building the full software.
  • SPDX 2.2 support has been dropped; the project now supports SPDX 3 only.
  • New variables SPDX_INCLUDE_KERNEL_CONFIG and SPDX_INCLUDE_PACKAGECONFIG allow exporting richer metadata into SPDX documents.

BitBake and tooling improvements

bitbake-setup changes:

  • Non-destructive update behavior: local commits and modifications are preserved during bitbake-setup update.
  • Shared State is now shared between builds by default (SSTATE_DIR and BB_HASHSERVE_DB_DIR set in site.conf).
  • VSCode configuration files are now generated by default.
  • The unpack_update() function allows updating a Git repository in-place.

WIC is now an external project

The WIC image creator tool has been extracted from OpenEmbedded-Core and is now maintained as a standalone project. The recipe in OE-Core now builds from this external source. A new wicenv image type was also added.

devtool IDE integration

devtool ide-sdk improvements:

  • gdbserver attach mode support.
  • GDB pretty-printing for C++ STL types.
  • Kernel module development support.

What's next

Wrynose will receive long-term maintenance. If you are on scarthgap (5.0), plan your migration with the Yocto Project documentation.

posted at 12:00  · 3 min read  ·   ·  yocto  openembedded  embedded  linux  oe-core  article

Jun 27, 2020

Build an embedded Linux in less than 15 minutes

Introduction

I had not built an embedded Linux without a framework like Open Embedded from the Yocto project for years, so this guide builds a minimal one from scratch to boot a target. The examples boot a virtual Qemu target and can be adapted to a real one. The build environment is bootstrapped with a prebuilt glibc cross-toolchain from Bootlin.

Setup the environment

First, install the packages needed to use the cross-toolchain, compile the host tools and run Qemu:

  • The Ncurses libraries are only required to execute the command make menuconfig.
  • The certificates and wget will be used to download the prebuilt toolchain.
  • In the same way, git will be used to checkout the source of Busybox and Linux.
  • The Qemu packages emulate the target system and run static aarch64 binaries on the x86-64 host.
apt update
apt install -y --no-install-recommends \
    bc \
    build-essential \
    ca-certificates \
    cpio \
    file \
    flex \
    git \
    ipxe-qemu \
    libncurses5-dev \
    libncursesw5-dev \
    libssl-dev \
    qemu \
    qemu-system-aarch64 \
    qemu-user-static \
    wget

Download and extract the prebuilt toolchain:

mkdir ~/src
cd ~/src
wget https://toolchains.bootlin.com/downloads/releases/toolchains/aarch64/tarballs/aarch64--glibc--stable-2020.08-1.tar.bz2
tar xvjf aarch64--glibc--stable-2020.08-1.tar.bz2

Then set the environment variables needed to cross-compile:

  • PATH: extended so the cross-tools are available
  • CROSS_COMPILE: the prefix used by the cross-tools
  • ARCH: the architecture of the target platform
ls ~/src/aarch64--glibc--stable-2020.08-1/bin/*gcc
~/src/aarch64--glibc--stable-2020.08-1/bin/aarch64-linux-gcc

export PATH=~/src/aarch64--glibc--stable-2020.08-1/bin:$PATH
export CROSS_COMPILE=aarch64-linux-

The cross-tools can now be called from the shell:

aarch64-linux-gcc -v
Using built-in specs.
COLLECT_GCC=~/src/aarch64--glibc--stable-2020.08-1/bin/aarch64-linux-gcc.br_real
COLLECT_LTO_WRAPPER=~/src/aarch64--glibc--stable-2020.08-1/bin/../libexec/gcc/aarch64-buildroot-linux-gnu/9.3.0/lto-wrapper
Target: aarch64-buildroot-linux-gnu
<...>
Thread model: posix
gcc version 9.3.0 (Buildroot 2020.08-14-ge5a2a90)

Build the Linux kernel

Fetch and build the latest stable branch of the Linux kernel:

git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
cd linux
git checkout -b local/linux-5.4.y origin/linux-5.4.y
# git show HEAD

export ARCH=arm64

make defconfig
  HOSTCC  scripts/basic/fixdep
  HOSTCC  scripts/kconfig/conf.o
  HOSTCC  scripts/kconfig/confdata.o
  HOSTCC  scripts/kconfig/expr.o
  LEX     scripts/kconfig/lexer.lex.c
  YACC    scripts/kconfig/parser.tab.[ch]
  HOSTCC  scripts/kconfig/lexer.lex.o
  HOSTCC  scripts/kconfig/parser.tab.o
  HOSTCC  scripts/kconfig/preprocess.o
  HOSTCC  scripts/kconfig/symbol.o
  HOSTLD  scripts/kconfig/conf
*** Default configuration is based on 'defconfig'
#
# configuration written to .config
#

# make menuconfig

make -j$(nproc)
  <...>
  AR      drivers/net/ethernet/built-in.a
  AR      drivers/net/built-in.a
  AR      drivers/built-in.a
  GEN     .version
  CHK     include/generated/compile.h
  LD      vmlinux.o
  MODPOST vmlinux.o
  MODINFO modules.builtin.modinfo
  LD      .tmp_vmlinux.kallsyms1
  KSYM    .tmp_vmlinux.kallsyms1.o
  LD      .tmp_vmlinux.kallsyms2
  KSYM    .tmp_vmlinux.kallsyms2.o
  LD      vmlinux
  SORTEX  vmlinux
  SYSMAP  System.map
  Building modules, stage 2.
  MODPOST 531 modules
  OBJCOPY arch/arm64/boot/Image
  GZIP    arch/arm64/boot/Image.gz

make defconfig applies the default configuration for the target platform (ARCH=arm64) and make -j$(nproc) compiles.

git show HEAD and make menuconfig are optional:

  • git show HEAD checks that the head is the latest tag of the linux-5.4.y branch.
  • make menuconfig customizes the kernel configuration.

NB. The Linux kernel, Busybox and other projects use Kbuild to manage build options.

Populate the sysroot

The easiest way to bootstrap a sysroot is Busybox, a size-optimized single executable providing common UNIX tools. A few configuration files complete the sysroot. Pulling and building Busybox works like the kernel.

git clone git://git.busybox.net/busybox
cd busybox
git checkout -b local/1_32_stable origin/1_32_stable
# git show HEAD

export ARCH=aarch64
export LDFLAGS="--static"

make defconfig
# make menuconfig
make -j$(nproc)

make install

LDFLAGS forces static linking of Busybox; make menuconfig with CONFIG_STATIC=y does the same. A static executable can be tested with Qemu:

qemu-aarch64-static busybox echo "Hello!"
Hello!
qemu-aarch64-static busybox date
Sat Jun 27 15:06:41 UTC 2020

qemu-aarch64-static runs a binary built for another architecture on the host, here the aarch64 Busybox on an x86-64 host.

make install created a tree in the _install directory, used to populate the sysroot:

ls -l _install
total 4
drwxr-xr-x. 1 tperrot tperrot 974 Nov 30 15:22 bin
lrwxrwxrwx. 1 tperrot tperrot  11 Nov 30 15:22 linuxrc -> bin/busybox
drwxr-xr-x. 1 tperrot tperrot 986 Nov 30 15:22 sbin
drwxr-xr-x. 1 tperrot tperrot  14 Nov 30 15:22 usr

ls -l _install/bin
<...>
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 umount -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 uname -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 usleep -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 vi -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 watch -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 zcat -> busybox

To finish the sysroot, create an rcS init script:

mkdir _install/proc _install/sys _install/dev _install/etc _install/etc/init.d
cat > _install/etc/init.d/rcS << EOF
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
/sbin/mdev -s
[ ! -h /etc/mtab ]  && ln -s /proc/mounts /etc/mtab
[ ! -f /etc/resolv.conf ] && cat /proc/net/pnp > /etc/resolv.conf
EOF
chmod +x _install/etc/init.d/rcS

Build the filesystem

This step packages the sysroot tree into a filesystem the kernel can mount, either a ramfs or a rootfs:

  • the ramfs is a simple filesystem the kernel creates in RAM from an archive.
  • the rootfs is a filesystem the kernel mounts from a non-volatile device.

See the kernel documentation for the difference between them.

Build a ramfs

Build the ramfs with cpio and gzip after fixing the ownership:

mkdir _rootfs
rsync -a _install/ _rootfs
chown -R root:root _rootfs
cd _rootfs
find . | cpio -o --format=newc > ../rootfs.cpio
cd ..
gzip -c rootfs.cpio > rootfs.cpio.gz

Build a rootfs

For the rootfs, create an empty image, format it as ext3 through a loop device, then copy the tree and fix the ownership.

dd if=/dev/zero of=rootfs.img bs=1M count=10
mke2fs -j rootfs.img
mkdir _rootfs
mount -o loop rootfs.img _rootfs
rsync -a _install/ _rootfs
chown -R root:root _rootfs
sync
umount _rootfs

Boot the target

The qemu commands to boot the system:

# With the ramfs
qemu-system-aarch64 -nographic -no-reboot -machine virt -cpu cortex-a57 -smp 2 -m 256 \
    -kernel ~/src/linux/arch/arm64/boot/Image \
    -initrd ~/src/busybox/rootfs.cpio.gz \
    -append "panic=5 ro ip=dhcp root=/dev/ram rdinit=/sbin/init"

# With the rootfs
qemu-system-aarch64 -nographic -no-reboot -machine virt -cpu cortex-a57 -smp 2 -m 256 \
    -kernel ~/src/linux/arch/arm64/boot/Image \
    -append "panic=5 ro ip=dhcp root=/dev/vda" \
    -drive file=~/src/busybox/rootfs.img,format=raw,if=none,id=hd0 -device virtio-blk-device,drive=hd0

The target boots to a shell:

[    0.000000] Booting Linux on physical CPU 0x0000000000 [0x411fd070]
[    0.000000] Linux version 5.10.0-rc5 (tperrot@27ea4a863f61) (aarch64-linux-gcc.br_real (Buildroot 2020.08-14-ge5a2a90) 9.3.0, GNU ld (GNU Binutils) 2.33.1) #1 SMP PREEMPT Mon Nov 30 14:40:05 UTC 2020
[    0.000000] Machine model: linux,dummy-virt
<...>
[    0.858346] Sending DHCP requests ., OK
[    0.870558] IP-Config: Got DHCP answer from 10.0.2.2, my address is 10.0.2.15
[    0.870909] IP-Config: Complete:
[    0.871199]      device=eth0, hwaddr=52:54:00:12:34:56, ipaddr=10.0.2.15, mask=255.255.255.0, gw=10.0.2.2
[    0.871566]      host=10.0.2.15, domain=, nis-domain=(none)
[    0.871825]      bootserver=10.0.2.2, rootserver=10.0.2.2, rootpath=
[    0.871866]      nameserver0=10.0.2.3
[    0.872389]
[    0.875863] ALSA device list:
[    0.876151]   No soundcards found.
[    0.879353] uart-pl011 9000000.pl011: no DMA platform data
[    0.920237] Freeing unused kernel memory: 5952K
[    0.921223] Run /sbin/init as init process

Please press Enter to activate this console.
posted at 13:01  · 7 min read  ·   ·  busybox  embedded  intermediate  linux  qemu  article