One PC, one make -C test run, 46 scripted touches: the real state machine of a
1024×600 DRM-direct photo frame, driven from beginning to end. Not an emulator, not
screenshot diffing — the same C sources that run on the board, with only the display
and the touch panel replaced by stubs.
The loop everyone knows
If you build embedded GUIs you know the cycle:
- change one line of layout code;
- cross-compile;
-
scpit to the board (or a USB stick, or a SD card); - reboot, watch the screen, poke it with a finger;
- discover a button is 3 pixels off. Back to step 1.
Five minutes per iteration — and nobody else can verify it for you. Code review
cannot tell whether a tap really lands on a button; only human eyes can. And the moment
you try to put "regression" into CI, you find the whole program is welded to drm,
linux/input.h and a vendor .so; it will not even compile without the board.
That is how this project started: a photo album for a Rockchip RK1808 board, DRM-direct
1024×600, password lock, slideshow, six mini games, plain C11, no Qt / X11 / Wayland /
libpng / libjpeg. I split "verify on the board" into three layers:
-
logic — pure functions and state machines, millisecond unit tests on the PC (41
CHECKs); - whole app — display and touch replaced by stubs, so the real state machine runs through 46 scripted taps on the PC;
- art — every bitmap is regenerated by Python scripts, reproducible at any time.
The result: make -C test run finishes in 31 seconds on Windows (exit 0, last line
[main] 清理退出), and CI on ubuntu-latest runs the whole chain with no board and no
display.
The idea: replace implementations, not the code under test
Most "embedded unit test" attempts fail because they mock away the thing they are meant
to test. I did the opposite: don't touch the application, replace the three real-world
entry points.
1. DRM → a malloc'd framebuffer
The real DRMshowUp() is drmModePageFlip() plus a poll() for vblank. The stub just
writes the BGRA buffer out as a 24-bit BMP:
/* test/stub/host/drm_stub.c */
static uint8_t *g_buf = NULL; /* plain memory as "video memory" */
static int g_frame = 0;
int DRMshowUp(int fd, struct drmHandle *drm)
{
/* every 20th frame lands in out/frame_XXXX.bmp for eyeballing */
if (++g_frame % 20 == 0)
write_bmp24(path, g_buf, 1024, 600, pitch);
return 0;
}
The drawing code only requires "a writable framebuffer + a page flip", so the stub fools
all of it. Bonus: the exported frames are the real render, so a layout regression is
one glance away.
2. Touch → a script file
Real input is EV_ABS events from /dev/input/eventN, with press and release arriving
as separate SYN packets. The stub turns each line of clicks.txt into "press, hold for
2 polls, release":
# x y hold ms after press
378 303 300 ← tap "Album" on the main menu
518 303 0
658 303 0
378 387 0
176 227 500
That is 46 actions: enter the password, browse the album, go back, open the game menu,
play a few moves of 2048 / Tetris / Snake / Tic-Tac-Toe / Minesweeper / Xiangqi, then
exit. When the script ends the stub quits the process (g_script_done_quit = 1), which is
what makes this a smoke test rather than an infinite loop.
3. Headers → a shadow include directory
The real code does #include "DRMwrap.h" for the vendor SDK. The test build puts -Istub
first, where a shadow header with the same name lives, plus a minimal linux/input.h that
only defines struct input_event. No cross toolchain needed:
CFLAGS = -std=c11 -Wall -Wextra -O1 -g \
-I../album -Istub/host -Istub -I.
No environment variables either: display.c picks the stub path via #if DRM_STUB_BUILD,
and the touch stub takes care of shutting down.
Three test layers, two orders of magnitude apart
make -C test unit # seconds: 2048 rules + touch-device detection
make -C test run # 31 s: the whole state machine, 46 taps
make -C test check # unit first, then the full app
-
unit_2048— merge / move / spawn rules of 2048, pure functions, 40+CHECKassertions. -
unit_detect— touch device detection: pick the touchscreen out of/proc/bus/input/devicestext. Drivers are named whatever the vendor felt like, so it is detected at runtime, not hard-coded. -
album_test— full smoke test:album_test ../bmp /dev/null.
The value of the split is localisation: a unit failure is a logic bug, a full-app
failure is a flow or layout bug. Both CI failures below were pinned to a single step
immediately.
Art assets must be reproducible too
There is not one binary asset that needs licensing:
- 24 C files, 5,578 lines;
- 11 Python scripts, 2,379 lines (backgrounds, buttons, chess pieces, tetrominoes, snake, landscapes, bitmap font, BMP → C array);
- 158 BMPs generated on demand by
make assets; - whole repo: 77 files, ~2.5 MB.
One detail: button sprites need real transparency (A=0) in the rounded corners, and
24-bit BMP has no alpha channel at all. So the generator writes hand-made 32-bit BMP
headers and stores every pixel as BGRA. Where you would normally reach for a library,
hand-writing the format was the more controllable option.
Pushing it into CI — and getting slapped by Linux twice
The workflow is deliberately boring (.github/workflows/ci.yml, ubuntu-latest):
gcc --version- install a CJK font (the generator renders Chinese labels)
make -C test unitpip install pillow && make assets- count the generated BMPs, fail under 150
-
make -C test run— 46 taps, real state machine
First run: the unit tests failed.
unit_detect: [FAIL] 位掩码数组长度 1 够装下 ABS 位
My machine (MinGW) has a 32-bit unsigned long, so I asserted N_ABS >= 2 for a 64-bit
bitmask (ABS_MAX = 0x3f). On 64-bit Linux, one unsigned long already holds 64 bits —
one is enough. The fix is not to change the number but to assert on capacity instead
of word width:
CHECK((int)(N_ABS * 8 * sizeof(unsigned long)) >= ABS_MAX + 1,
"bitmask capacity %d bit holds ABS_MAX+1 = %d bit", ...);
Second run: asset generation failed.
OSError: cannot open resource
File "tools/gen_assets.py", line 93, ... F(FONT_BOLD, 46) ...
File "tools/gen_assets.py", line 29, FONT_BOLD = r"C:\Windows\Fonts\msyhbd.ttc"
Yes — hard-coded C:\Windows\Fonts. The fix was tools/fontkit.py: resolve a CJK font per
platform (Microsoft YaHei / SimHei / DengXian on Windows → Noto Sans CJK on Linux →
DejaVu), allow the ALBUM_FONT env var to override, and fall back to Pillow's built-in
bitmap font — degrade instead of crash. CI gained one line:
sudo apt-get install -y fonts-noto-cjk.
Third run: green. Every push to main now runs the chain, and the badge sits on top
of the README.
Those two failures are the most valuable part of the story. I thought "works on my
machine" meant it works; in reality my machine was covering for two platform
assumptions — the width of long, and the location of a font. That is exactly why you
run a hardware-free test suite in CI: it is a clean box with none of your environment,
there specifically to slap you.
What it buys you
| before | after | |
|---|---|---|
| see a layout change | cross-compile + flash + eyeball, ~5 min |
make -C test run, 31 s + exported frames |
| logic regression | tap through it by hand | 1-second unit run |
| can a stranger verify it? | no (needs the board) | git clone && make -C test unit |
| regression on every push | none | 46 taps automatically |
And none of it is specific to this board. DRM, evdev and the framebuffer look the same on
RK1808 / RK3568 / Allwinner / Raspberry Pi. The only things you actually need to replace
are display, input, and vendor headers.
Five things the stubs do not cover
To be honest: this guarantees that the state machine is reachable and the layout does
not regress — not that the result is pixel-identical to the board. Five real-world
differences stay outside the stubs, all documented in the README:
- page-flip timing — the real path waits for vblank, and single-buffered transitions can tear on fast refreshes; the stub has no notion of time.
-
touch coordinate mapping — the stub assumes 1024×600 logical coordinates; the real
driver may rotate, scale, or use a different range, which is why
touch.cnormalises. -
vendor init path —
DRMinit/DRMcreateFBfromDRMwraponly really execute on the board; the stub provides same-signature empty implementations. - pixel format — the board is 32 bpp XRGB8888, the stub writes BGRA BMP; channel order matches, alpha handling does not (board ignores it, stub keeps it).
-
timing-sensitive input — long press, double tap and fast repeated taps are modelled
with
g_hold_ticks; the real event rate belongs to the driver, so these behaviours are "reachable", not "feel identical".
The mitigation is plain: look at the exported frames before flashing, then spot-check
three screens on the board (main menu, album, game menu). The stub is responsible for
"no regressions"; the board is responsible for "actually looks right".
Repo
Full sources, the 46-tap script and the CI config (MIT):
https://github.com/Wwumin/rk1808-photo-album
git clone https://github.com/Wwumin/rk1808-photo-album
cd rk1808-photo-album
make -C test unit # logic regression, ~1 s
make assets # regenerate 158 bitmaps (needs Pillow)
make -C test run # 46-tap smoke test
No board is needed for the PC path; only the on-board path needs the vendor
libDRMwrap.so (see README → Building for the board).
If you also build embedded GUIs while the board is somewhere else, I would love to hear
you — especially on the question of how real a stub should be: too fake and you fool
yourself, too real and you are rewriting the driver.
Project: RK1808 1024×600 DRM-direct photo frame, six mini games, C11. 77 files, 2.5 MB.
CI green on run #40169371435.
Top comments (0)