IT security, FreeBSD, Linux, mail server hardening, post-quantum crypto, DNS, retro computing & hands-on hardware hacks. Privater Tech-Blog seit 2003.

Kategorie: Kernel-Error-Blog (Seite 1 von 47)

Persönlicher Tech-Blog von Sebastian van de Meer — Beiträge zu IT-Security, Netzwerken, FreeBSD, Linux, Elektronik und Maker-Projekten.

Wenn PHP beim Aufräumen stirbt: ein FreeBSD-rtld-Bug hinter posix_spawn

Beitragsbild zu einem FreeBSD-Bug: Laptop mit PHP- und lldb-Debug-Ausgaben, Signal-11-Core-Dump und Diagramm der Kausalkette von Nextcloud über proc_open und posix_spawnp bis zur rtld-Heap-Korruption.

Diese Geschichte fing als PHP-Problem an und endete mehrere Wochen später in einem Bug im FreeBSD-Basissystem, ganz unten im Runtime-Linker. Dazwischen liegen mindestens vier falsche Fährten, ein Crash, der bei jedem Lauf ein anderes Opfer suchte, und die schöne Erkenntnis, dass man eine Heap-Korruption nicht mit einzelnen Watchpoints fängt. Ich schreibe das bewusst mit allen Sackgassen auf, weil genau die der lehrreiche Teil sind. Wer nur die Auflösung will, springt ans Ende.

Das Symptom

PHP 8.4 auf FreeBSD 15 (amd64), im Zusammenspiel mit einer selbst gehosteten Nextcloud. Jeder occ-Aufruf und jeder Cron-Lauf lieferte sein Ergebnis korrekt ab und segfaultete danach. Signal 11, jedes Mal, mit schöner Regelmäßigkeit ein Core-Dump von rund 2,2 GB. Die Ausgabe stand vollständig da, bevor es knallte. Der Crash passierte erst im Module-Shutdown, also beim Aufräumen, nachdem die eigentliche Arbeit längst erledigt war.

Funktional war das harmlos. Ärgerlich war der Rest. Das dmesg füllte sich mit Zeilen der Sorte:

pid 12345 (php), jid 0, uid 80: exited on signal 11 (core dumped)

Die Platte lief mit 2,2-GB-Cores voll, und es gab einen unangenehmen Nebeneffekt: hängende Background-Jobs. Wenn PHP-FPM mitten in einem Nextcloud-Cron-Job segfaultet, wird das reserved_at in der Tabelle oc_jobs nie zurückgesetzt. Der Job gilt damit als dauerhaft in Bearbeitung und läuft nie wieder an. Aus einem kosmetischen Shutdown-Crash wurde so ein echtes Betriebsproblem.

Erste falsche Fährte: OPcache JIT

Ein Segfault in PHP, der frische JIT im Spiel: der erste Verdacht war schnell da. Also habe ich mich durch die JIT-Stufen gearbeitet. Tracing-JIT mit opcache.jit=1255, dann Function-JIT mit 1205, dann JIT komplett aus mit 0. Es crashte durch alle Stufen hindurch unverändert weiter.

JIT war auf FreeBSD 15 zwar tatsächlich für sich genommen kaputt und ist bei mir seitdem aus. Aber die Ursache für den Shutdown-Crash war er nicht. Erste Fährte verworfen.

Die Versions- und Build-Jagd

Nächster Verdacht: ein kaputter Build oder eine ABI-Unstimmigkeit zwischen dem PHP-Core und einer Extension. Also PHP komplett aus den Ports neu gebaut, damit Core und alle Extensions garantiert dieselbe Version tragen. Danach Symbol-Builds fürs Debugging. Und dann durch die Punktversionen gehangelt: 8.4.16, .18, .19, .20, .21, .22. Jede einzelne crashte gleich.

Damit war eine wichtige Sache geklärt: Build, Version und CFLAGS sind nicht der Unterschied. Was sich nicht ändert, wenn man alles daran ändert, liegt woanders.

Eine Lehre am Rande, die mich unnötig Zeit gekostet hat: --enable-debug wechselt das ABI-Verzeichnis der Extensions. Danach laden sämtliche als Paket installierten Extensions nicht mehr, weil sie im falschen Verzeichnis gesucht werden. Wer nur Debug-Symbole will, ohne das ABI zu verbiegen, baut so:

make CFLAGS+=" -g" STRIP=

Die Crash-Site per lldb aus dem Core

Das FreeBSD-Basissystem bringt kein gdb mit, dafür lldb. Aus dem Core kommt man so an den Backtrace:

lldb --batch -o "target create --core <core> <php-binary>" -o "bt all"

Der Stack sah beim ersten Lauf so aus:

_start → __libc_start1 → main → php_module_shutdown → zend_shutdown
  → zend_hash_graceful_reverse_destroy → destroy_zend_class +1228

Die crashende Instruktion war cmpq %rbx, 0x20(%r15). Der Offset 0x20 ist in zend_property_info das Feld ce, der Zeiger auf den Klassen-Eintrag. Das Register r15 stand auf 0x6b588e9c404, unaligned und außerhalb des Heaps. Das riecht nach einem Use-after-Free auf geteilte interne Klassen-Metadaten.

Ein genauerer Walk durch die Strukturen korrigierte meine erste Annahme. Der Offset 0x20 liegt nicht nur in zend_property_info, sondern genauso in zend_class_constant auf dem ce-Feld. Die crashende Schleife lief nicht über die Properties, sondern über die Klassen-Konstanten, also die constants_table. Die crashende Klasse war Pdo\Pgsql, eine der neuen internen Subklassen aus dem PHP-8.4-RFC zu den PDO-treiberspezifischen Subklassen, die von PDO erbt. Mein Verdacht drehte sich damit auf etwas 8.4-Spezifisches: Vererbung von internen Konstanten, vielleicht im Umfeld der Property Hooks.

Der Crash wandert

Und jetzt wurde es unangenehm. Die Crash-Site war nicht stabil. Von Lauf zu Lauf sah ich mal destroy_zend_class, mal zend_type_release, mal zend_interned_strings_dtor. Mal war das Opfer Pdo\Pgsql, mal ein arg_info von RedisCluster, mal ein zend_type, mal ein DateTimeZone.

Das ist das klassische Bild eines einzelnen korrumpierenden Schreibzugriffs mit wechselndem Opfer. Wer getroffen wird, hängt allein am Heap-Layout des jeweiligen Laufs. Das erklärt rückblickend, warum die vermeintlich genaue Klasse jedes Mal anders aussah. Ich hatte die ganze Zeit das Spätsymptom analysiert, nicht die Ursache. Als Beispiel eine ganz andere Crash-Site vom zweiten Rechner:

php_module_shutdown → zend_interned_strings_dtor
  → zend_hash_destroy +310 → _str_dtor → _efree +11

Das Opfer hier war ein permanenter interned String. Das sind die intern deduplizierten, prozessweit nur einmal abgelegten Zeichenketten, die PHP überall wiederverwendet, in diesem Lauf der Redis-Kommandoname zintercard. Sein Header war zerschossen, beim Freigeben faultet der Destruktor auf einem ZendMM-Block, der gar nicht mehr gemappt ist. Wieder ein anderer Tatort, dasselbe Muster: irgendwer schreibt einmal quer, und wer danach als Erstes über die zerstörte Stelle stolpert, nimmt den Fall.

Upstream-Issue GH-21995, und die Richtung dreht sich

An diesem Punkt habe ich das Ganze bei php-src als Issue GH-21995 aufgemacht. Zwei Reaktionen haben die Richtung gedreht.

Zuerst @iliaal, einer der PHP-Maintainer:

Cannot reproduce on Linux (ASAN, Valgrind all clean on 37 extension build), so if this is valid it might be FreeBSD specific.

ASAN und Valgrind sauber auf Linux ist ein starkes Indiz gegen einen klassischen Use-after-Free im Zend-Speichermanager. Ein solcher Fehler würde unter ASAN sofort auffliegen. Wenn er das nicht tut, sitzt das Problem woanders, vermutlich unterhalb von PHP.

Dann bestätigte @CamilleScholtz das Verhalten unabhängig, auf PHP 8.5.6, FreeBSD 15, und ausdrücklich nicht in einem Jail. Damit fielen zwei bequeme Ausreden weg: es war weder meine spezielle Konfiguration noch etwas, das in 8.5 schon behoben gewesen wäre.

Die VM reproduziert nicht, ein Heisenbug

Auf Bare-Metal crashte die unveränderte Paket-Installation praktisch bei jedem Lauf, gefühlt zu hundert Prozent. In einer VM dagegen kam ich auf rund 650 saubere Läufe, ohne einen einzigen Crash. Und sobald ich mit lldb und Watchpoints an das Objekt heranging, das ich für das Opfer hielt, verschob sich das Opfer. Die Beobachtung selbst veränderte das Heap-Layout und damit den Ausgang.

Das ist ein Heisenbug im Lehrbuchsinn. Ein einzelner Watchpoint auf ein einzelnes Objekt bringt hier nichts, weil der nächste Lauf ein anderes Objekt zerstört. Ich brauchte eine Messung, die gegen das Layout robust ist.

Messen statt raten: der Tabellen-Diff

Statt ein einzelnes Objekt zu beobachten, habe ich die ganze Tabelle der permanenten interned Strings an definierten Checkpoints verglichen. Ein eigenes lldb-Python-Skript zieht an jedem Checkpoint einen Snapshot der Tabelle und difft gegen den vorherigen. So ist es egal, welches konkrete Objekt in diesem Lauf getroffen wird, denn ich sehe jede Änderung an der ganzen Region.

Das Ergebnis war der erste harte Datenpunkt seit Wochen. Der korrumpierende Schreibzugriff passiert während des Spawns, genauer im Intervall zwischen posix_spawnp und posix_spawn_file_actions_destroy. Überschrieben wird ein zusammenhängender Block von rund 480 Byte, gefüllt mit 8-Byte-Zeigern. Das sieht aus wie Stack-Frames, die dort hingehören, wo sie nicht hingehören. Damit war klar: das ist keine PHP-interne Speicherverwaltung, das ist der Spawn.

Die Batterie: den Auslöser einkreisen

Jetzt konnte ich gezielt testen. Je 20 Läufe pro Kandidat. Nur proc_open crashte, und zwar 20 von 20. popen, exec, system, shell_exec, fopen, dazu Heap-Churn-Kandidaten wie str_repeat und range: alle 0 von 20. Es ging also nicht um fork und exec im Allgemeinen, auch nicht um Heap-Belastung, sondern spezifisch um proc_open.

Und dann entschied die Form des Aufrufs über Crash oder kein Crash:

AufrufSpawn-PfadCrash
proc_open(["true"], …) (relativ)posix_spawnp__libc_execvpe (PATH-Suche)ja
proc_open(["/usr/bin/true"], …) (absolut)posix_spawnpexecvPe direktnein
proc_open("true", …) (String)posix_spawn (/bin/sh -c) → _execvenein

Nur der relative Befehl ohne Schrägstrich im Namen crasht, weil nur der die PATH-Suche im Kind auslöst. Die Länge des PATH war dabei egal, auch mit einem einzigen Eintrag crashte es. Das grenzt es sauber gegen den alten Long-PATH-Overflow ab: es geht nicht um einen zu langen PATH, sondern um einen intrinsischen Stack-Verbrauch im no-slash-Suchzweig.

Das Minimal-Repro ist entsprechend kurz und kommt ganz ohne Framework aus:

php -r 'proc_open(["date"], [], $pipes);'   # → signal 11

Ein Detail fehlt noch, und es ist wichtig: der Crash braucht den vollen Satz geladener Extensions. Ein Minimalsatz von 17 Extensions reicht, aber das Entfernen irgendeiner einzelnen davon stoppt den Crash. Konkret dieser Satz: session, dom, iconv, imagick, intl, pdo, pgsql, phar, simplexml, sodium, xml, xmlwriter, zip, zlib, memcached, pdo_pgsql, redis. Viele geladene Shared Objects plus ein proc_open: beides zusammen ist nötig, keins allein reicht. Diese Beobachtung war später der Schlüssel zur Ursache, auch wenn ich das zu dem Zeitpunkt noch nicht wusste.

Runter in die libc-Quelle

Der Spawn führte mich in /usr/src/lib/libc/gen/posix_spawn.c. Auf amd64 startet do_posix_spawn das Spawn-Kind so:

rfork_thread(RFSPAWN, stack + stacksz, _posix_spawn_thr, &psa)

Der Stack für dieses Kind ist ein winziger, per malloc geholter Puffer:

#define _RFORK_THREAD_STACK_SIZE  4096
stacksz = 4096 + MAX(3, argc + 2) * sizeof(char *);   /* 16-Byte aligned */
stack   = malloc(stacksz);

Für ein {"true", NULL} sind das rund 4128 Byte. Das Entscheidende an RFSPAWN beziehungsweise rfork_thread: das Kind bekommt bis zum exec einen geteilten Adressraum, ähnlich wie bei vfork. Kind und Eltern arbeiten bis zum exec also auf demselben Speicher. Bei einem relativen Kommando läuft das Kind über __libc_execvpe in die PATH-Suche. Meine Hypothese an dieser Stelle war: das Kind erschöpft seine gut 4 KB Stack und schreibt in den direkt darunter liegenden Heap des Elternprozesses. Das würde exakt zu dem 480-Byte-Block aus Zeigern passen, den der Tabellen-Diff gesehen hatte.

Der Beweis: guardspawn

Eine Hypothese ist nur so gut wie ihr Experiment. Also habe ich guardspawn.c geschrieben, einen kleinen Interposer per LD_PRELOAD, der rfork_thread(RFSPAWN) abfängt und dem Kind einen selbst kontrollierten Stack unterschiebt. Zwei Varianten, zwei klare Antworten:

  • Gebe ich dem Kind 1 MB Stack, fällt der Crash auf 0 von 30. Baseline ohne Interposer war 30 von 30.
  • Gebe ich dem Kind wieder nur gut 4 KB, aber mit einer Guard-Page direkt darunter, stirbt das Spawn-Kind selbst mit SIGSEGV, unabhängig von der genauen Stelle.

Damit war die Kernaussage bewiesen: das Kind erschöpft den knapp 4 KB großen Spawn-Stack. Genauso ehrlich habe ich es aber auch in den Report geschrieben: welcher exakte Frame den Puffer überläuft, war zu dem Zeitpunkt nicht bewiesen. Ein alleinstehendes C-Programm triggerte den Fehler nicht, das Ganze hing an der Last des Prozesses. Mein Verdacht ging Richtung Runtime-Linker, aber das war noch eine Vermutung, kein Beweis.

Eine ehrliche Selbstkorrektur

Zwischendurch hatte ich mich verrannt und einen Stack-Underflow zu bestimmt behauptet. Ein zweiter, kritischer Blick von außen und ein eigener Read der Quelle korrigierten das: execvPe selbst verbraucht deutlich weniger als 4 KB, und absolute Kommandos laufen auch durch execvPe und crashen trotzdem nicht. Der Unterschied liegt also nicht in einem bewiesenen Overflow in execvPe, sondern im no-slash-Zweig der PATH-Suche. Ich habe das im Report deshalb als Lokalisierung formuliert, nicht als bewiesenen Mechanismus.

Dazu gehört auch das ehrliche Eingeständnis, dass alle meine früheren php-src-Hypothesen falsch waren: die Property Hooks, der vermeintliche Use-after-Free auf Klassen-Konstanten, die interned-String-Korruption, die pgsql-Verdächtigungen. Das war alles die wandernde Fault-Site, das Spätsymptom, nie die Ursache. Wer wochenlang das Symptom seziert, baut sich überzeugende Theorien über das Symptom. Das gehört in so einen Bericht hinein, nicht wegretuschiert.

Der Bugreport ans FreeBSD-Basissystem

Mit dieser Lokalisierung habe ich den Bug im FreeBSD-Basissystem eingereicht: Bug 295991. Das php-src-Issue GH-21995 habe ich als kein php-src-Bug geschlossen und beide Seiten miteinander verlinkt.

Wichtig war mir die Abgrenzung zu FreeBSD-SA-20:18 beziehungsweise CVE-2020-7458 von 2020. Das war der Long-PATH-Overflow an genau dieser Code-Stelle, längst behoben. Mein Fall ist die gleiche Gegend im Code, aber unabhängig von der PATH-Länge. Es ist bewusst keine Sicherheitsgeschichte, sondern ein Stabilitätsproblem, ausgelöst von völlig legitimem Code beim Aufräumen.

Praktischer Nebenbefund für alle, die sich an der Anubis-Sperre der FreeBSD-Bugzilla stören: den Status eines Bugs bekommt man ohne Browser bequem per REST:

curl -s "https://bugs.freebsd.org/bugzilla/rest/bug/295991"

Upstream pinnt die Ursache

Jetzt kam der Teil, für den sich die Mühe des sauberen Reports gelohnt hat. @bdrewery, FreeBSD-Committer, bestätigte und reproduzierte den Fehler noch bequemer als ich, direkt über den www/nextcloud-Port mit occ status in einer Schleife:

there is some random corruption that shows up with php on exit when loaded with many extensions. Raising the stack size in posix_spawn avoids the problem.

Zur Ehrlichkeit gehört der Seitenhieb, den ich mir dabei eingefangen habe: den Text meines Reports nannte er einen unreadable AI mess. Inhaltlich hat er den Fall getroffen, die Form hat genervt. Das war eine gute und verdiente Lektion über Report-Stil, auf die ich am Ende noch einmal zurückkomme.

@kevans hat den Mechanismus dann endgültig festgenagelt, und zwar an einer Stelle, an der ich nur einen Verdacht hatte. Nicht execvPe sprengt den Stack, sondern der Runtime-Linker beim Lazy-Binding der Symbole. Der Pfad ist _rtld_bindfind_symdefsymlook_defaultdonelist_init. Und donelist_init macht ein alloca, dessen Größe mit der Zahl der geladenen Shared Objects skaliert:

#define donelist_init(dlp) ((dlp)->objs = alloca(obj_count * sizeof(dlp)->objs[0]), assert((dlp)->objs != NULL), (dlp)->num_alloc = obj_count, (dlp)->num_used = 0)

Genau deshalb triggern schwer gelinkte Prozesse den Fehler und Spielzeug-Programme nicht. obj_count ist bei PHP mit dem vollen Extension-Satz groß, das alloca entsprechend fett, und auf dem gut 4 KB kleinen Spawn-Stack ist dann Schluss. Das deckt sich exakt mit meiner rtld-Vermutung aus dem Report und erklärt auch das 17-Extensions-Minimum: unter einer gewissen Zahl geladener Objekte bleibt das alloca klein genug.

Der Fix

Der Fix kam von @kib als Diff D57908. Die erste Revision regressierte und ließ eine www/onlyoffice-Umgebung crashen, mit ld-elf.so.1-Faults in beam.smp und x2t. Das war ein Multithreading-Problem, das kib noch vor dem Commit behoben hat. Danach ging es nach main:

  • 1e370f0 „rtld: stop using unbound alloca()“ vom 29. Juni 2026. Die alloca-Aufrufe in der DoneList und in map_object wandern in den Heap, sobald sie groß werden. Vermerk MFC after: 1 week.
  • 3de9dc5 vom 30. Juni 2026. Ein libc-Regressionstest, der eine Dummy-Shared-Library mehrfach mappt und mit einer Guard-Page arbeitet, um den Underflow zuverlässig zu triggern.

Beim Schreiben dieses Beitrags steht der MFC nach stable/15 an. Für ein 15.1-RELEASE kommt der Fix mit einem der künftigen 15.x-Patches. Bis dahin ist der Workaround simpel: absolute Pfade in proc_open vermeiden den crashenden no-slash-Zweig. Das ist Symptombekämpfung, kein Fix. Und wer nur das volllaufende Dateisystem im Blick hat, räumt die harmlosen Cores einfach weg.

Warum am Ende alles zusammenpasst

Das Schöne an der Auflösung ist, dass sie jedes einzelne der vielen Rätsel erklärt, die mich wochenlang in die Irre geführt haben:

  • Nur proc_open crasht, weil es das einzige PHP-Konstrukt ist, das posix_spawnp nutzt.
  • Nur relative Kommandos crashen, weil nur sie die PATH-Suche und damit das Lazy-Binding im Kind auslösen.
  • Nur FreeBSD auf amd64, weil der rfork_thread-Pfad mit dem kleinen malloc-Stack amd64- und i386-spezifisch ist.
  • ASAN und Valgrind sauber auf Linux, weil glibc posix_spawn ganz anders baut.
  • Der volle Extension-Satz nötig, weil viele Shared Objects das alloca im rtld erst groß genug für den Überlauf machen. Und die vielen permanenten interned Strings legen zusätzlich die späteren Opfer genau unter den Spawn-Puffer.

Zur Methode, und zum Report-Stil

Zwei Dinge nehme ich technisch mit. Erstens: eine Heap-Korruption mit wanderndem Opfer fängt man nicht mit einzelnen Watchpoints, weil das Beobachten das Layout verschiebt und damit das Opfer. Was funktioniert, sind layout-robuste Tabellen-Diffs an definierten Checkpoints. Nicht ein Objekt anstarren, sondern die ganze Region vorher und nachher vergleichen. Zweitens: ein LD_PRELOAD-Interposer mit Guard-Page ist ein billiges, definitives Ja-oder-Nein-Experiment für die Frage, ob ein Stack-Overflow vorliegt. Ein sauberes Experiment schlägt zehn plausible Theorien.

Und dann die Lektion, die mir @bdrewery verpasst hat. Ein Bugreport, der die ganze Hypothesenkette in den Body kippt, ist für den Leser eine Zumutung, egal wie korrekt die Analyse ist. Die richtige Form sind drei bis vier Sätze Kern ganz oben, das reproduzierbare Minimal-Beispiel gleich dahinter, und der ganze Ermittlungskrimi darunter für die, die ihn brauchen. Der Inhalt hat gestimmt, deshalb wurde der Bug gefixt. Aber die Form hätte den Committern viel Zeit gespart. Nächstes Mal Kern zuerst.

Ähnliche Geschichte im Notebook, im Basissystem festgefahren, oder einfach eine Meinung zum Report-Stil? Dann einfach fragen.

NB-2020-U Fingerabdruckleser: der libfprint-Patch ist upstream gemergt

Beitragsbild zum NB-2020-U Fingerabdruckleser: Notebook mit Fingerabdrucksensor, libfprint-Codeausschnitt mit Product-ID 0x2020 und grünem Merge-Status für den upstream übernommenen Patch.

Anfang März habe ich hier beschrieben, wie ich den NEXT Biometrics NB-2020-U in meinem Fujitsu Notebook unter Linux zum Laufen gebracht habe. Die ganze Arbeit lief am Ende auf eine einzige Product ID hinaus: 0x2020 im bestehenden nb1010 Treiber, weil der NB-2020-U denselben Sensor Die wie der NB-1010-U nutzt. Der Beitrag endete mit dem üblichen Cliffhanger: Merge Request eingereicht, CI grün, warten auf das Review durch die Maintainer.

Das Warten hat ein Ende. MR !569 ist gemergt.

Was der Maintainer gemacht hat

Marco Trevisan, einer der libfprint Maintainer, hat den Patch auf den aktuellen master rebased, die Pipeline noch einmal durchlaufen lassen und ihn am 2. Juli 2026 per Auto-Merge aufgenommen (Commit 0fa670f). Blockierende Review-Kommentare gab es keine. Der Patch war klein und die Beweislage eindeutig: gleicher Sensor, gleiches USB Protokoll, gleicher Treiber, nur eine zusätzliche ID in der Tabelle.

Was das für Betroffene heißt

Für alle mit demselben Fingerabdruckleser im Notebook: Ab der nächsten libfprint Version wird der NB-2020-U out of the box erkannt. Kein eigener Patch mehr, kein Selberbauen. Enrollment und Verifikation über fprintd laufen dann direkt, sobald die Distribution die neue libfprint Version ausliefert. Wer nicht warten möchte, nimmt weiterhin den Patch aus dem ersten Beitrag oder baut direkt vom aktuellen master.

Der zweite Leser aus derselben Familie, der NB-2033-U mit seinem komplett eigenen Protokoll, hat einen eigenen Treiber von Grund auf bekommen. Dieser Merge Request !574 liegt noch beim Review, ist aber frisch auf den neuen master rebased und die Pipeline ist grün. Sobald auch der durch ist, folgt ein weiterer kurzer Nachtrag.

Siehe auch

Denselben Leser im Notebook oder eine ähnliche Baustelle mit libfprint? Dann einfach fragen.

Tiered Storage live: Wie ein ZFS special vdev den HDD-Flaschenhals an der Wurzel packt

Ein einzelner ZFS-Pool aus zwei 7200-rpm-Platten war durch Metadaten-Random-I/O ausgebremst. Lösung ganz ohne Neuaufbau: die vorhandenen SSDs zu einem gespiegelten special vdev für Metadaten plus gespiegeltem SLOG umgebaut, zwei zpool add-Befehle im laufenden Betrieb. Resultat: Metadaten-Leselatenz von rund 46 ms auf rund 455 µs, also etwa Faktor hundert, bei voll erhaltener Verschlüsselung und Redundanz.

Drehende Platten sind ein ehrliches Stück Technik. Sie speichern viele Terabyte für wenig Geld und liefern bei sequenziellem Zugriff ordentlichen Durchsatz. Ihre Achillesferse ist der zufällige Zugriff auf viele kleine Blöcke, denn jede Kopfbewegung kostet Latenz im zweistelligen Millisekundenbereich aus Seek und Rotationswartezeit. Und genau dieses ungünstigste Muster produziert ein Copy-on-Write-Dateisystem wie ZFS am laufenden Band: Metadaten. Verzeichnis-ZAPs, dnodes, indirekte Blöcke, also die Block-Pointer-Bäume, dazu Spacemaps. Jedes ls, jedes stat, jeder Snapshot-Vergleich, jeder Scrub und jede find-Traversierung wühlt sich durch viele kleine, über die ganze Platte verstreute Metadatenblöcke. Auf einer HDD ist das der teuerste Spaß, den man haben kann.

Symbolische Darstellung eines ZFS-HDD-Mirrors mit SSD-special-vdev: Metadaten-I/O wird von Festplatten auf schnelle SSDs ausgelagert.

Ich hatte genau diesen Schmerz auf einem dedizierten Server: ein bewusst simpel gehaltener ZFS-Pool, zwei Enterprise-SATA-Platten im Mirror als Kapazitätsspeicher, und ein nagender Verdacht, dass die Spindeln der Flaschenhals sind. Die spannende Frage war nicht, ob man das beheben kann, sondern wie elegant. Die Antwort heißt allocation classes, konkret ein special vdev. Und das Schöne daran: Der Umbau lief komplett im laufenden Betrieb, ohne den Pool neu aufzubauen, ohne Downtime, mit zwei Befehlen. Dieser Beitrag zeigt den ganzen Weg, inklusive der Baseline-Messung, die den Engpass erst beweist, eines Verschlüsselungs-Stolpersteins beim Umbau und der ehrlichen Frage, was so ein special vdev wirklich bringt.

Die Ausgangslage

Der Server läuft auf FreeBSD 15.1-RELEASE (amd64, 12 CPU-Threads, 64 GiB RAM). Ein einziger ZFS-Pool, 2023 ganz bewusst als schlichter Mirror angelegt:

zpool create -o altroot=/mnt -O compress=lz4 -O atime=off -m none -f zroot mirror ada0p3 ada1p3
  • Das Daten-vdev sind zwei 7200-rpm-Enterprise-SATA-Platten mit je 2 TB als Mirror (mirror-0, rund 1,8 TiB nutzbar), der eigentliche Kapazitätsspeicher.
  • Dazu zwei Datacenter-SATA-SSDs mit je 240 GB und Power-Loss-Protection. Die waren vorher suboptimal genutzt: eine als einzelner, nicht gespiegelter SLOG, die andere als L2ARC.
  • ARC-Limit anfangs 16 GiB, poolweit compression=lz4 und atime=off von Anfang an.
  • ashift=12 erzwungen über vfs.zfs.vdev.min_auto_ashift=12, also 4K-Sektor-Alignment, korrekt auch dann, wenn die Platten brav 512-Byte-Sektoren melden.

Die Power-Loss-Protection der SSDs ist kein Detail am Rande, sondern später für die SLOG-Sicherheit relevant: Eine SSD ohne Pufferschutz darf bei einem synchronen Write nicht behaupten, die Daten lägen sicher, solange sie noch im flüchtigen Cache stehen. Datacenter-SSDs mit Kondensator-gestütztem Cache dürfen das, und genau das braucht ein SLOG.

Erst messen, dann bauen

Bevor ich auch nur eine Partition angefasst habe, kam die wichtigste Phase: messen. Ohne Baseline kauft man Hardware nach Bauchgefühl und tunt am falschen Ende. Also lief ein eigener, delta-basierter Sampler über 30 Minuten, 90 Samples zu je 20 Sekunden. Er liest sysctl-Counter für CPU, ARC und Netz sowie iostat -x für die Platten-Busy und die Latenzen. Die wichtigste Spalte zur Einordnung der Last ist net-out, also der ausgehende Netzdurchsatz als Proxy dafür, was während des Laufs tatsächlich los war.

Das Ergebnis der Baseline (16 GiB ARC, alte SSD-Rollen) war eindeutig:

  • Der Flaschenhals ist der HDD-Mirror. Busy im Mittel 58 bis 62 %, Spitzen bis 100 bis 104 %, Latenz im Mittel rund 8 ms, unter Last bis 20 bis 24 ms. In 16 % der Samples war die HDD zu 95 % oder mehr ausgelastet, also gesättigt.
  • Die CPU war zu rund 95 % idle, RAM frei, der Netz-Peak lag bei rund 68 Mbit/s, also nur etwa 7 % des Gigabit-Links. Weder CPU noch RAM noch Netz waren das Limit.
  • Der ARC klebte an seinem 16-GiB-Limit (Mittel 15,7 GiB) bei einer Hit-Rate von rund 94,7 %. Der ARC war schlicht ausgehungert und hätte mehr RAM sofort genutzt.
  • Der einzelne SLOG lief bei rund 42 % Busy, war also nicht gesättigt. Die Spindeln waren das Limit, nicht der SLOG.

Das ist die didaktische Pointe, die ich jedem ans Herz lege: Ohne diese Messung wüsste ich nicht, ob CPU, RAM, Netz oder Platten klemmen, und ich wüsste nicht, ob das Problem auf der Lese- oder der Schreibseite liegt. Messen ist kein Nice-to-have, sondern die Voraussetzung dafür, das richtige Bauteil zu kaufen und am richtigen Hebel zu drehen.

Was ein special vdev ist, und warum nicht einfach All-SSD

Allocation classes sind ein OpenZFS-Feature (feature@allocation_classes), mit dem ein Pool mehrere Klassen von vdevs führen kann. Das special vdev ist die Klasse für Metadaten: ZFS legt dnodes, indirekte Blöcke und poolweite Metadaten bevorzugt dort ab statt auf dem normalen Daten-vdev. Über die Dataset-Property special_small_blocks kann man zusätzlich kleine Datenblöcke unterhalb einer einstellbaren Schwelle aufs special vdev ziehen. Im Kern verschiebt man also genau die Datenklasse, die eine HDD am schlechtesten beherrscht, auf ein Medium, das genau dafür gebaut ist.

Dass das hier der richtige Hebel ist, ist nicht geraten, sondern messbar: Der ARC dieses Servers besteht zu rund 85 % aus Metadaten, konkret 17,4 GB Metadaten gegenüber 3,0 GB Daten im ARC. Der Workload ist also metadaten-dominiert. Metadaten auf SSD zu verlagern trifft den Engpass damit an der Wurzel, denn das ist exakt der Random-I/O, an dem die Platten am meisten leiden. Bevor ich mich für das special vdev entschieden habe, standen aber andere Optionen auf dem Tisch:

  • Kompletter All-SSD-Pool aus zwei großen SSDs: der sauberste Komplettfix, aber teuer und ein großer Umbau mit Pool-Neuaufbau und vollständiger Datenmigration. Overkill, wenn der Großteil der Kapazität aus kalten, überwiegend sequenziell gelesenen Daten besteht.
  • Mehr RAM und ARC: hilft nur der Leseseite und nur, solange der Working Set in den ARC passt. Schreib-Metadaten müssen trotzdem auf stabilen Speicher, daran ändert RAM nichts.
  • L2ARC behalten: abgeschafft. Bei 24 GiB ARC lag die Lese-Hit-Rate schon bei rund 98,5 %. Der L2ARC brachte nur rund 1,3 % zusätzliche Reads, ist flüchtig (nach einem Reboot leer) und kostet sogar ARC-RAM für seine Header. Das Kosten-Nutzen-Verhältnis war negativ.
  • special vdev: die gewählte Lösung. Nutzt die vorhandenen SSDs, kein Pool-Neuaufbau, adressiert exakt den gemessenen Metadaten-Schmerz, inkrementell und live im Betrieb machbar.

Der Umbau Schritt für Schritt

Aus dem alten Zustand mit einem einzelnen SLOG und einem L2ARC sollte ein SLOG-Mirror plus ein special-vdev-Mirror werden. Beide SSDs werden also jeweils zur Hälfte für beide Zwecke genutzt, jeweils gespiegelt. Zuerst die alten Single-Rollen entfernen:

zpool remove zroot ada3p1     # alter L2ARC
zpool remove zroot ada2p1     # alter (einzelner) SLOG

Und hier kam der erste Stolperstein, der so lehrreich ist, dass er einen eigenen Absatz verdient. Das SLOG-Remove schlug zunächst fehl:

cannot remove ada2p1: Mount encrypted datasets to replay logs

Die Ursache: Es existierten verschlüsselte Datasets, deren Keys in diesem Boot nie geladen waren. Der SLOG lässt sich nicht entfernen, solange potenziell noch nicht abgespielte ZIL-Einträge für gesperrte Datasets vorliegen, denn ZFS müsste diese Einträge zum Replay erst entsperren. Erst nach dem Aufräumen und Entsperren ließ sich der SLOG sauber entfernen. Das ist gleichzeitig die perfekte Überleitung zum Verschlüsselungskapitel weiter unten, denn es zeigt, wie tief native ZFS-Encryption in den ZIL-Pfad eingreift.

Danach die SSDs neu partitionieren, sauber 1-MiB-aligned. Pro SSD wird p1 16 GiB groß (SLOG) und p2 rund 208 GiB (special). Das Ergebnis von gpart show ada2 ada3:

=>       40  468862048  ada2  GPT  (224G)
         40       2008        - free -  (1004K)
       2048   33554432     1  freebsd-zfs  (16G)     # p1 -> SLOG
   33556480  435304448     2  freebsd-zfs  (208G)    # p2 -> special
  468860928       1160        - free -  (580K)

Jetzt der eigentliche Akt: gespiegelter SLOG und gespiegeltes special vdev werden hinzugefügt.

zpool add zroot log     mirror ada2p1 ada3p1
zpool add zroot special mirror ada2p2 ada3p2

Beide Befehle bewusst ohne -f. So bleibt der Redundanz-Schutz von ZFS als Sicherheitsnetz aktiv: ZFS verweigert ein nicht-redundantes special oder log neben einem Mirror, solange man es nicht ausdrücklich erzwingt. Und genau dieses Verweigern ist hier gewollt.

Die wichtigste Warnung dieses Beitrags: Ein special vdev ist nicht optional für die Pool-Integrität. Verliert man ein nicht gespiegeltes special vdev, ist der gesamte Pool verloren, denn die Metadaten liegen dort, und ohne sie ist der Rest unlesbar. Das special vdev muss mindestens so redundant sein wie das Daten-vdev, hier also als Mirror. Für den SLOG gilt das in dieser Schärfe nicht, ein verlorener SLOG kostet nur die letzten Sekunden async-bestätigter sync-Writes, aber ein SLOG-Mirror verhindert, dass ein einzelner SSD-Ausfall den ZIL-Schutz aushebelt.

Das fertige Layout sieht in zpool status und zpool list -v dann so aus:

zroot       mirror-0   ada0p3 + ada1p3   1.80T  (Daten, HDD-Mirror)
            special    mirror-3: ada2p2 + ada3p2   206G  (Metadaten, SSD-Mirror)  NEU
            logs       mirror-2: ada2p1 + ada3p1   15.5G (ZIL/SLOG, jetzt gespiegelt)

Zum SLOG-Sizing noch ein Wort, weil es oft falsch gemacht wird. Der SLOG puffert nur die dirty data eines, maximal zweier txg-Flush-Intervalle. Bei vfs.zfs.dirty_data_max = 4 GiB reichen 16 GiB SLOG mit großzügigem Polster, mehr bringt schlicht nichts. Genauso wichtig: Der SLOG beschleunigt nichts direkt. Er ist nur ein schnelles, stromausfallsicheres Zwischenlager für den ZIL, greift ausschließlich bei synchronen Writes (fsync oder O_SYNC) und wird im Normalbetrieb nie gelesen, sondern erst nach einem Crash zum Replay. Wer das verwechselt, sollte sich die Trennung einprägen: Der ZIL ist immer da, das ist das Konzept. Der SLOG ist nur ein optionales separates Gerät dafür.

Die unbequeme Wahrheit: nur neue Metadaten wandern

Hier muss ich ehrlich sein, denn es ist der am häufigsten missverstandene Punkt. Ein special vdev migriert keine bestehenden Metadaten. Es nimmt nur auf, was nach dem Hinzufügen geschrieben wird. Alte Metadaten bleiben auf der HDD liegen, bis sie durch Copy-on-Write ohnehin neu geschrieben werden. Der volle Effekt entsteht also erst über die Zeit oder durch einen optionalen zfs send | zfs recv-Rebuild der großen Datasets. Kein Sofort-magisch-alles-schneller, sondern ein Mechanismus, der sich befüllt. Dass er sich befüllt, sieht man an der Belegung, die mit jedem neuen Metadaten-Write wächst:

special   mirror-3   206G   alloc 5.38G   free 201G   FRAG 27%   CAP 2.60%

Die Messung danach, und wie man sie ehrlich liest

Jetzt kommt der Teil, an dem viele Tuning-Berichte unsauber werden, weil sie einen Vorher-Nachher-Durchsatz behaupten, der unter unterschiedlicher Last gemessen wurde und damit nichts beweist. Ich gehe einen anderen Weg und zeige die Wirkung über drei Argumente, von denen zwei komplett lastunabhängig sind.

Erstens der Latenz-Split pro vdev, das stärkste und lastunabhängige Argument. zpool iostat -lv zeigt die Latenzen getrennt pro vdev. Die folgende Tabelle sind seit-Boot-kumulierte Mittelwerte, also langzeit-repräsentativ und kein zufälliger Augenblick:

                  capacity     operations     bandwidth    total_wait
vdev            alloc   free   read  write   read  write   read   write
mirror-0        1.22T   595G     45      7   434K   601K   46ms   34ms    # HDD (Daten)
  ada0p3                         22      3   217K   300K   56ms   39ms
  ada1p3                         23      3   217K   300K   36ms   29ms
special/mirror-3 5.38G  201G      0     67  5.28K  3.24M  455us    6ms    # SSD (Metadaten)
  ada2p2                          0     33  2.67K  1.62M  447us    5ms
  ada3p2                          0     33  2.61K  1.62M  464us    6ms
logs/mirror-2   31.6M  15.5G      0     45      3   947K    2ms    1ms    # SSD (SLOG/ZIL)

Die Kernaussage steht in zwei Zahlen: Metadaten-Leselatenz 455 µs auf der special-SSD gegen 46 ms auf der HDD, das ist etwa Faktor hundert. Jeder Metadaten-Zugriff, der nicht ohnehin aus dem RAM bedient wird, ist seitdem rund hundertmal schneller. Zu den -l-Spalten kurz: total_wait ist die Gesamtwartezeit inklusive Queue, disk_wait die reine Gerätelatenz, syncq_wait und asyncq_wait die Zeit in den ZFS-internen Queues. Wer ein echtes Zeitfenster statt des Boot-Mittels sehen will, nimmt zpool iostat -lv zroot 10 2 und liest das zweite Sample, denn das erste ist immer der Seit-Boot-Durchschnitt.

Zweitens die ARC-Metadaten-Aufteilung, also die Struktur des Workloads. Sie erklärt, warum es gerade hier so viel bringt:

arcstats.metadata_size          = 17.4 GB     # rund 85 % des ARC sind Metadaten
arcstats.data_size              =  3.0 GB
arcstats.demand_metadata_hits   = 1,133,349,218
arcstats.demand_metadata_misses =    11,594,376   # müssen auf Platte ... jetzt SSD
arcstats.demand_data_hits       =   220,864,693
arcstats.demand_data_misses     =       672,908

Der Workload ist metadaten-dominiert. Die Lifetime-ARC-Hit-Rate liegt bei rund 98,9 %, aber die über 11,5 Millionen Metadaten-Misses müssen zwangsläufig auf Platte, und sie landen jetzt auf SSD statt auf HDD. Hier multipliziert sich der Faktor-hundert-Latenzvorteil mit der schieren Menge an Metadaten-Operationen. Das ist die quantitative Begründung dafür, warum ausgerechnet ein special vdev der wirksamste Hebel war und nicht etwa nur mehr ARC. Begleitend habe ich das ARC-Limit von 16 auf 24 GiB angehoben, weil RAM frei war. Die Folge war eine Hit-Rate von rund 95 % auf rund 99 %. Zwei Hebel, die zusammenwirken: weniger Misses überhaupt, und die verbliebenen sind jetzt SSD-schnell.

Das Herzstück: die 8,5-MB/s-Rechnung

Drittens, und das ist der eigentliche Aha-Moment, eine logische Schlussfolgerung statt eines Durchsatz-Vergleichs. Die Ausgangsmessung lief unter einer ganz konkreten Last: Ein Client lud zeitgleich größere Dateien herunter, ein klassischer Datei-Download. Der Netzdurchsatz dabei lag bei rund 68 Mbit/s, also etwa 8,5 MB/s. Und genau hier wird es interessant.

Eine einzelne 7200-rpm-HDD liefert sequenziell 150 bis 200 MB/s. Ein Download mit 8,5 MB/s ist also kaum 5 % dessen, was eine Platte im Schlaf kann, und hier zogen sogar zwei davon im Mirror mit. Trotzdem zeigte die Messung, dass der HDD-Mirror im Mittel rund 60 % ausgelastet war und in 16 % der Messintervalle voll gesättigt (95 % Busy oder mehr), mit Latenzen bis 20 bis 24 ms.

Das ist ein Widerspruch, und der Widerspruch ist der Beweis. Für sequenzielle 8,5 MB/s darf eine HDD niemals an die Sättigung kommen. Wenn sie es doch tut, dann waren diese Zugriffe nicht sequenziell, sondern seek-gebunden. Die Köpfe wurden permanent quer über die Platte gerissen. Wofür? Für das, was dieses System zu rund 85 % beschäftigt: Metadaten-Random-I/O, also dnodes, indirekte Blöcke und Verzeichnis-Lookups, die sich auf denselben zwei Spindeln mit dem Download um die Köpfe prügelten, verschärft durch die damals hohe Fragmentierung. Ein eigentlich harmloser Download zerfiel so in ein Seek-Gewitter.

Genau diese Konkurrenz wurde mit dem special vdev eliminiert. Die Metadaten-Zugriffe laufen jetzt auf den SSDs mit rund 455 µs statt zig Millisekunden. Die HDD-Köpfe können auf dem Datenstrom bleiben, statt ständig für Metadaten wegzuspringen. Derselbe Download belastet die Spindeln damit nur noch einen Bruchteil. Nicht, weil die Dateidaten schneller kämen, die liegen weiter auf HDD, sondern weil der Lärm daneben weg ist. Diese Schlussfolgerung steht ohne erfundenen Vergleich, sie ist wasserdicht: 8,5 MB/s sättigt physikalisch keine HDD, also waren es Seeks, also Metadaten-Kontention, und genau die habe ich verlagert.

Wie sich der Pool im ruhigen Normalbetrieb anfühlt, zeigt eine zweite, entspannte Momentaufnahme. Sie ist ausdrücklich kein Vorher-Nachher-Vergleich, sondern nur ein Blick auf den Alltag:

CPU idle 96.9 %   ARC hit 99.7 %   ARC 23.3 GiB
HDD busy ~2 %     HDD-Sättigung 0 %   HDD-Latenz ~1.5 ms
SSD busy 4.3 % / 4.5 % (gleichmäßig über beide Mirror-Member)
net-out-Peak 2.0 Mbit/s

Im ruhigen Normalbetrieb langweilt sich der HDD-Mirror, fast alle Reads kommen aus ARC oder SSD. Das illustriert den Alltag. Die eigentliche Wirkung des Umbaus zeigen aber die 8,5-MB/s-Rechnung oben sowie der Latenz-Split und die ARC-Aufteilung, und die gelten unabhängig von der Last.

Sicherheit und Verschlüsselung, die entscheidende Nuance

Die wichtigen Datasets dieses Systems sind nativ mit ZFS verschlüsselt (encryption = aes-256-gcm), die System- und Boot-Datasets nicht. Sobald man ein special vdev einführt, stellt sich sofort die sicherheitskritische Frage: Landet jetzt unverschlüsselter Klartext auf den SSDs, nur weil dort die Metadaten liegen? Die Antwort ist ein klares Nein, und die Begründung ist wichtig genug, um sie sauber auszuführen.

  • ZFS native encryption verschlüsselt Dateiinhalte und die sensiblen Objekt-Metadaten, also Dateinamen, Verzeichnisstruktur, dnodes, Attribute und ACLs. Diese Blöcke sind bereits Ciphertext, bevor der Allocator überhaupt entscheidet, auf welches vdev sie wandern. Ein special vdev ist nur ein anderer Ablageort und ändert an der Verschlüsselung nichts. Verschlüsselte Metadaten bleiben auf der special-SSD verschlüsselt.
  • Was ZFS-Encryption ohnehin nicht verbirgt, special vdev hin oder her, sind die Metadaten auf Pool- und Dataset-Ebene: Dataset-Namen, Pool-Struktur, Anzahl und Größe von Snapshots, die Blockpointer-Struktur. Das ist eine Eigenschaft von ZFS-Encryption und keine neue Schwäche durch das special vdev.
  • aes-256-gcm ist authenticated encryption (AEAD), liefert also Vertraulichkeit und gleichzeitig Integritäts- und Authentizitätsschutz der verschlüsselten Blöcke.

Ein schöner Praxisbezug schließt sich hier zum Umbau-Kapitel: Genau weil verschlüsselte Datasets im Spiel sind, blockierte das zpool remove mit der Meldung über das Mounten verschlüsselter Datasets zum Replay. Das zeigt anschaulich, wie tief Encryption in den ZIL- und SLOG-Pfad eingreift, denn der ZIL kann Einträge für verschlüsselte Datasets enthalten, die sich nur nach dem Entsperren abspielen lassen. Das Fazit zur Sicherheit ist damit eindeutig: Ein special vdev ist verschlüsselungs-neutral. Wer verschlüsselte Datasets nutzt, bekommt verschlüsselte Metadaten auf der special-SSD, kein Klartext-Leak.

Abwägung: Vorteile, Nachteile, Risiken

Was unterm Strich für das special vdev spricht:

  • Es adressiert den gemessenen Engpass, Metadaten-Random-I/O, direkt an der Wurzel.
  • Es nutzt vorhandene SSDs, also keine Neuanschaffung, kein Pool-Neuaufbau, live im laufenden Betrieb hinzugefügt.
  • Rund hundertfach niedrigere Metadaten-Leselatenz (455 µs gegen 46 ms), spürbar bei ls, stat, find, Snapshots, Scrub und allen Workloads mit vielen kleinen Dateien.
  • Über special_small_blocks später fein justierbar, um kleine Datenblöcke nachzuziehen, ohne Downtime und nur für neue Writes.
  • Verschlüsselungs-neutral.
  • Der I/O verteilt sich jetzt gleichmäßig über beide Mirror-Member. Vorher lag eine SSD als einzelner SLOG bei rund 42 % Busy, die andere als L2ARC quasi brach.

Und ehrlich auch die andere Seite, denn ein special vdev ist kein Selbstläufer:

  • Redundanz ist Pflicht, nicht Kür. Ein nicht-redundantes special vdev bedeutet Totalverlust des Pools bei SSD-Ausfall. Mirror ist zwingend.
  • Keine Migration bestehender Metadaten. Nur neue Writes wandern, der volle Effekt kommt erst per send und recv-Rebuild.
  • Das special vdev kann volllaufen. Ist es voll, fallen neue Metadaten auf das langsame Daten-vdev zurück. Das ist kein Fehler, aber der Effekt lässt nach, also Füllgrad mit zpool list -v überwachen.
  • special_small_blocks zu hoch gesetzt verstopft das special vdev mit Datenblöcken und lässt es schneller volllaufen. Vorsichtig hochtasten (von 0 über 4K und 8K bis vielleicht 32K) und dabei den Füllgrad beobachten.
  • Mehr vdevs bedeuten mehr Komplexität und mehr Teile, die ausfallen können. Den SSD-Wear im Blick behalten, hier bewusst Datacenter-SSDs mit Power-Loss-Protection gewählt, weil sie Dauerlast und sync-Writes aushalten.
  • Der zpool remove-Stolperstein mit verschlüsselten Datasets gehört dokumentiert, damit man im Ernstfall nicht in Panik gerät.

Die eigentliche Botschaft

ZFS erlaubt es, die Storage-Architektur inkrementell und im laufenden Betrieb an einen gemessenen Engpass anzupassen, ohne Pool-Neuaufbau, ohne Downtime, ohne Datenmigration als Vorbedingung. Aus einem simplen HDD-Mirror wurde durch zwei zpool add-Befehle ein hybrider, mehrstufiger Pool: kalte Massendaten auf günstigen Spindeln, heiße Metadaten und optional kleine Blöcke auf schnellen SSDs, synchrone Writes über einen gespiegelten SLOG. Diese Flexibilität, tiered storage als Live-Operation, kombiniert mit Checksumming, Compression, Snapshots und nativer Verschlüsselung im selben Dateisystem, ist der eigentliche Kern. Man kauft sich SSD-Speed genau dort, wo die Messung den Schmerz zeigt, und lässt den Rest günstig auf HDD. Kein anderes verbreitetes Dateisystem macht das so geradlinig.

Ausblick

  • special_small_blocks schrittweise anheben, um kleine Dateien und nicht nur Metadaten auf SSD zu ziehen, live und nur für neue Writes.
  • Ein optionaler send und recv-Rebuild der großen Datasets, um bestehende Metadaten auf das special vdev zu migrieren und so den vollen Effekt zu heben.
  • Eine lastgleiche Wiederholungsmessung in einem Hochlast-Fenster für eine saubere Zahl auf der Schreibseite.

Spickzettel

Die Befehle, mit denen man Layout, Latenzen und ARC-Komposition selbst nachsieht:

# Pool-Layout und Auslastung pro vdev
zpool status zroot
zpool list -v zroot

# Latenzen pro vdev (das Geld-Kommando), 2. Sample lesen für ein echtes Zeitfenster:
zpool iostat -lv zroot 10 2

# ARC: Größe und Metadaten/Daten-Split plus Demand-Hits und -Misses
sysctl kstat.zfs.misc.arcstats.size kstat.zfs.misc.arcstats.metadata_size kstat.zfs.misc.arcstats.data_size kstat.zfs.misc.arcstats.demand_metadata_hits kstat.zfs.misc.arcstats.demand_metadata_misses

# allocation_classes-Feature und special_small_blocks
zpool get feature@allocation_classes zroot
zfs get special_small_blocks zroot

# Verschlüsselungs-Status der Datasets
zfs get encryption,keystatus DATASET

# SSD-Partitionierung
gpart show ada2 ada3

# SLOG-Sizing-Kontext
sysctl vfs.zfs.dirty_data_max

# Pool-Historie (zeigt die echten add/remove-Befehle)
zpool history zroot

Siehe auch:

Selbst einen HDD-Pool mit einem special vdev entschärft, oder noch am Abwägen, ob sich der Umbau lohnt? Erzähl mir gern von deinem Layout, oder stell deine fragen.

Von SEO zu AEO, der Kassensturz: was eine maschinenlesbare Identität wirklich bringt

Visualisierung einer maschinenlesbaren Online-Identität mit JSON-LD, Knowledge Graph und KI-Antwortsystemen zur Verknüpfung einer Person über mehrere digitale Quellen.

Am 1. Januar habe ich hier einen Beitrag geschrieben, der eine Wette war. Die These: Web-Optimierung verschiebt sich. Weg von SEO, dem Kampf um die beste Platzierung bei Google, hin zu etwas, das ich AEO genannt habe. Answer Engine Optimization. Also nicht mehr „wie komme ich auf Platz eins“, sondern „wie liefere ich die beste maschinenlesbare Antwort“. Ich habe damals llms.txt eingebaut, ein bisschen über JSON-LD geschrieben und ehrlich dazugesagt, dass niemand weiß, ob das langfristig relevant bleibt. Der letzte Satz war: ich bin gespannt, was passiert.

Jetzt ist ein gutes halbes Jahr vergangen. Zeit für einen Kassensturz. Was davon hat sich gehalten, was war naiv, was hat sich differenziert? Und vor allem: ich habe in den letzten Monaten tatsächlich daran gearbeitet, mich für eine Maschine sauber beschreibbar zu machen. Nicht als Theorie, sondern an der eigenen Seite, mit allen Fehlern, die dabei sichtbar wurden. Genau diese Fehler und die Abwägungen dahinter sind der eigentliche Inhalt dieses Beitrags. Wer den Vorgängerpost noch nicht kennt, findet ihn hier: Von SEO zu AEO, warum llms.txt, JSON-LD und Answer Engines das Web verändern.

Die Suche wird zur Antwortmaschine

Fangen wir mit dem an, was sich gerade wirklich verändert, unabhängig von meinem Blog. Wer heute etwas googelt, bekommt immer öfter die Antwort direkt auf der Ergebnisseite. Eine zusammengefasste KI-Antwort, darunter vielleicht ein paar Quellen. Der Klick auf eine Webseite entfällt. Dafür gibt es einen Begriff: Zero-Click-Suche. Die Information erreicht den Menschen, ohne dass er die Seite besucht, von der sie stammt.

Das ist keine Vermutung, das lässt sich messen. Das Pew Research Center hat Daten aus dem Frühjahr 2025 ausgewertet, veröffentlicht im Juli 2025: das Surfverhalten von rund 900 erwachsenen US-Nutzern, knapp 68.900 Google-Suchanfragen. Das Ergebnis: bekamen die Leute eine KI-Zusammenfassung angezeigt, klickten nur noch 8 Prozent auf einen weiterführenden Treffer. Ohne KI-Zusammenfassung waren es 15 Prozent, fast doppelt so viel. Auf die in der KI-Antwort verlinkten Quellen klickte überhaupt nur 1 Prozent. Fairerweise dazugesagt: Google hält die Methodik dieser Studie für nicht repräsentativ, hat aber keine eigenen Gegenzahlen vorgelegt. Zur Einordnung der Größenordnung: schon bei der ganz normalen Google-Suche endet ein großer Teil ohne Klick. Die Zero-Click-Studie von SparkToro (Rand Fishkin, 2024, Datenbasis Datos, das zu Semrush gehört) kommt auf rund 58 Prozent in den USA und knapp 60 Prozent in der EU, neuere Auswertungen für 2026 eher Richtung zwei Drittel. Und ein Hinweis zur Vorsicht, weil die Zahl gern falsch zitiert wird: die oft genannten 93 Prozent Zero-Click gelten ausschließlich für Googles AI Mode, also den dialogorientierten Chat-Modus der Suche (Semrush maß dort 92 bis 94 Prozent), nicht für die normale Suche. Wer diese Schlagzeile unbesehen übernimmt, vergleicht Äpfel mit Birnen.

Wie stark der Effekt kausal ist, hat ein randomisiertes Feldexperiment von Saharsh Agarwal (Indian School of Business) und Ananya Sen (Carnegie Mellon University) untersucht, Feldphase Januar und Februar 2026, 1.065 ausgewertete US-Desktop-Nutzer von Chrome. Auf den Suchanfragen, bei denen tatsächlich eine KI-Übersicht erschien, sanken die organischen Klicks um etwa 38 Prozent, die Zero-Click-Rate stieg von 54 auf 72 Prozent. Wichtig für die Einordnung: das ist ein noch nicht begutachtetes Arbeitspapier, online seit April 2026, und die Stichprobe sind aktive Desktop-Chrome-Nutzer aus einem Panel, nicht alle Google-Nutzer. Das Studiendesign war immerhin vorab registriert, was die Aussagekraft stützt. Trotzdem bleibt es ein Befund mit klaren Grenzen. Die Pointe ist nicht, dass die Suche stirbt, sondern etwas Nüchterneres: Sichtbarkeit entkoppelt sich vom Klick. Man kann als Quelle einer Antwort auftauchen, ohne dass jemand die eigene Seite öffnet.

Vom String zum Ding

Jetzt wird es interessant, denn hier liegt der Mechanismus, der mitentscheidet, ob man in so einer Antwort überhaupt vorkommt. Schon 2012 hat Google den Knowledge Graph eingeführt, unter dem Slogan „Things, not strings“. Übersetzt: Dinge, nicht Zeichenketten. Davor war eine Suchmaschine im Kern ein Textabgleich. Du tippst Buchstaben, sie sucht Seiten mit denselben Buchstaben. Seitdem versucht Google, hinter den Buchstaben das tatsächliche Ding zu erkennen. Eine Entität. Ein eindeutig identifizierbares Etwas mit Beziehungen zu anderen eindeutig identifizierbaren Etwas.

Das klassische Beispiel ist das Wort Jaguar. Tier, Auto oder Betriebssystem? Ein Mensch erkennt aus dem Zusammenhang sofort, was gemeint ist. Eine Maschine muss disambiguieren, also die Mehrdeutigkeit auflösen. Und genau dasselbe Problem gilt für mich. Welcher Sebastian van de Meer? Es gibt mehr als einen Menschen mit diesem Namen. Für eine Maschine ist mein Name erst einmal nur eine Zeichenkette, die zu mehreren Personen passt. Eindeutigkeit wird belohnt. Es gibt dazu einen vielzitierten Datenpunkt, den ich ehrlich einordnen muss: laut einer Auswertung von Kalicube (Jason Barnard, veröffentlicht bei Search Engine Land im August 2025) verschwanden im Juni 2025 über drei Milliarden Einträge aus dem Knowledge Graph, ein Rückgang von rund sechs Prozent, verteilt auf zwei Stichtage. Google hat das nie offiziell bestätigt, und die Deutung, dass hier Klarheit über Masse gewinnt, ist die des Analysten, nicht Googles erklärter Grund. Also ein Indiz, kein Gesetz. Der Knowledge Graph selbst speist sich nach Googles eigenen Angaben unter anderem aus Wikipedia, Branchenquellen nennen ergänzend Wikidata, und er ist das Bindeglied zwischen klassischer Suche und KI-Antworten. Googles KI-Suche, die auf Gemini basiert, greift nach eigener Darstellung auf den Knowledge Graph als Echtzeit-Quelle zurück. Wer dort als saubere Entität existiert, ist für beide Welten greifbar.

Wie man sich einer Maschine als Entität vorstellt

Damit sind wir beim Herzstück. Wie sage ich einer Maschine glaubwürdig, wer ich bin? Das Werkzeug dafür heißt JSON-LD nach dem schema.org-Vokabular. Vereinfacht: ein maschinenlesbarer Steckbrief, der direkt in der Seite liegt und Fakten ausdrücklich beschriftet. Das ist der Autor, das ist sein Beruf, das ist das Erscheinungsdatum. Statt die Maschine alles aus Fließtext erraten zu lassen, legt man ihr die Fakten getypt hin. Eine Klarheits- und Extraktionshilfe, mehr nicht. Keine Garantie auf ein Ranking und keine Garantie, zitiert zu werden. Diese Erwartung muss man sofort dämpfen, sonst baut man Luftschlösser.

Aus der abstrakten Ansage von damals ist bei mir eine ziemlich durchdachte Identitäts-Architektur geworden. Und ehrlich: das Spannende waren nicht die Zeilen, die ich geschrieben habe, sondern das, was ich dabei gelernt habe. Neun Punkte, die ich so vorher nicht auf dem Schirm hatte.

Erstens, eine Identität, eine kanonische Adresse. Für eine Maschine sollte eine Person genau ein Ding sein, mit einer stabilen Kennung, die überall identisch auftaucht, nicht auf jeder Seite neu erfunden. Maschinen lösen Identität über stabile Identifier auf, nicht über Namen. Lose Namensnennungen ohne gemeinsame Kennung werden als verschiedene Menschen gelesen oder gar nicht zusammengeführt. Der Preis ist weniger Flexibilität. Der Gewinn ist ein zusammenhängender Knoten statt vieler Splitter.

Zweitens, eine Wahrheitsquelle statt überall dasselbe reinkippen. Die vollständige Selbstbeschreibung steht bei mir an genau einer Stelle, auf der Über-mich-Seite. Alle anderen Seiten tragen nur eine schlanke Referenz darauf. Der Grund ist unromantisch: dieselbe Definition überall zu duplizieren erzeugt Drift. Man ändert eine Stelle, vergisst die anderen, und am Ende widerspricht sich der eigene Datensatz selbst. Die Abwägung: die schlanke Referenz darf nicht zu dünn sein, sonst findet ein Crawler, der zufällig nur eine Artikelseite erwischt, keinen Anker zurück zum Profil.

Drittens, Privates gehört nicht in den maschinenlesbaren Broadcast. Das war für mich die wichtigste Einsicht. Telefonnummer, Adresse und ähnliches haben für die maschinelle Identifikation exakt null Wert. Disambiguiert wird über verlinkte Profile, nicht über die Handynummer. Auf jeder einzelnen Seite ausgestrahlt wären solche Daten dagegen eine ideale Fläche zum Abgreifen. Also stehen die privaten Angaben jetzt bewusst nur dort, wo sie hingehören, und sind nicht mehr auf rund 470 Seiten als sauber beschriftete Schlüssel-Wert-Paare maschinenlesbar verteilt. Das ist die zentrale Abwägung zwischen Datenschutz und Maschinenlesbarkeit, und sie fällt klar zugunsten Datenschutz aus. Das Schöne: man verliert dabei kein einziges Identitäts-Signal.

Viertens, externe Anker sind die eigentliche Beweiskette. Eine Behauptung über mich wird erst dann prüfbar, wenn sie auf unabhängige Profile verweist und diese zurückverweisen. Bei mir sind das unter anderem GitHub, ein Eintrag im BSI-Bürger-CERT-Netzwerk, Mastodon und die Bluesky-Brücke, dazu Identifier wie ORCID und ein Wikidata-Eintrag. Entscheidend ist die Wechselseitigkeit. Ein Verweis zählt nur, wenn die Gegenseite zurückzeigt. Anfangs lagen diese Anker nur auf der Profilseite. Das war ein Single Point of Failure: wenn ein Crawler genau diese eine Seite nicht erwischt, ist die Identität nicht mehr belegbar. Also gehören die stärksten Anker auf jede Seite. Und die Disziplin dabei: unverifizierbare oder tote Profile lässt man weg, weil sie das Signal nur verwässern.

Fünftens, innere Widerspruchsfreiheit ist selbst ein Qualitätssignal. Ein Beispiel aus der eigenen Seite, das mich erst geärgert und dann überzeugt hat: der Herausgeber des Blogs und der Herausgeber der einzelnen Artikel zeigten auf zwei verschiedene, nirgends sauber definierte Stellen. Für eine Maschine sieht so etwas aus wie ein Datenfehler und untergräbt das Vertrauen in den gesamten Datensatz. Die Lektion war, lieber einen sauber benannten zusätzlichen Knoten einzuführen, hier die Marke „Kernel-Error“ als eigene Herausgeber-Instanz, als zwei sich widersprechende Halbwahrheiten stehen zu lassen. Das ist übrigens keine technische Petitesse, sondern eine echte Identitäts-Entscheidung: gilt „Kernel-Error“ als eigene Marke neben der Person? Ich habe mich dafür entschieden, und plötzlich ergab der ganze Rest Sinn.

Sechstens, einen Wissensgraphen kann man nicht belügen. Das klingt pathetisch, ist aber sehr praktisch gemeint. Alle externen Quellen, auf die ich verweise, sind crawlbar. Jeder Status lässt sich gegen das echte Upstream-Projekt prüfen. Also habe ich offene Beiträge ehrlich als offen gekennzeichnet, statt sie als erledigt zu verkaufen. Zwei meiner Patches für eine Fingerabdruckleser-Bibliothek sind eingereicht, aber noch nicht gemerged, und genau so steht es da. Behauptungen, die ich nicht belegen kann, etwa angebliche CVEs, die sich öffentlich nicht auffinden lassen, habe ich komplett weggelassen. Ein als „erledigt“ deklarierter, in Wahrheit offener Beitrag ist ein sofort widerlegbarer Fehler, und der beschädigt die Glaubwürdigkeit des gesamten Profils. Die Abwägung ist unbequem: das Profil sieht weniger beeindruckend aus. Aber ein einziger entlarvter Fake-Claim ist teurer als zehn ehrliche kleine. Vertrauen entsteht aus Prüfbarkeit, nicht aus Behauptung.

Siebtens, Expertise belegt man mit Artefakten, nicht mit Adjektiven. Niemand muss mir glauben, dass ich etwas kann. Sie können es nachsehen. Konkrete, von Dritten kontrollierbare Arbeiten sind der stärkste maschinenlesbare Beleg. Ein in ein fremdes Projekt aufgenommener Patch verankert mich im Linkgraph dieses fremden, autoritativen Projekts. Ein eigenes Repository ist überprüfbarer Code, kein Selbstlob. Die Disziplin dahinter: nur real Existierendes, korrekt zugeschrieben. Fremde Maintainer-Arbeit führe ich nicht als meine. Bei einem Rezensions-Artikel über ein Tool, das mir nicht gehört, bleibt die Urheberschaft beim Upstream. Und Füllmaterial wie Trivia oder Verzeichnis-Einträge bleibt bewusst draußen, um das Signal nicht zu verwässern.

Achtens, wer alles kennt, löst auf nichts auf. Meine Themenliste hatte über 40 mehr oder weniger beliebige Schlagworte. Das habe ich auf eine Handvoll fokussierte Kernthemen zusammengestrichen, möglichst als eindeutige Referenzen statt als nackte Wörter. Der Grund: zu viele Themen verwässern das Signal so sehr, dass man für kein einziges Feld als Autorität erkennbar ist. Die Wette dahinter ist, dass ein scharfes Profil in wenigen Feldern für eine Antwortmaschine wertvoller ist als eine lange, unscharfe Stichwortliste. Der Preis ist Breite bei Nischen-Anfragen. Den zahle ich gerne.

Neuntens, jede Seite soll sagen, was sie ist. Profilseite, Kontaktseite, Artikel, Autorenarchiv: jeder Seitentyp deklariert jetzt seine Rolle und welche Rolle ich dort spiele. Das stärkste Signal „diese Adresse ist das kanonische Profil dieser Person“ entsteht erst dadurch, dass die Profilseite sich auch als Profilseite zu erkennen gibt. Vorher sah sie für eine Maschine aus wie jede beliebige andere Seite und verschenkte diese Aussage komplett. Die Abwägung: mehr Fallunterscheidung im Code, dafür präzise, rollenrichtige Signale.

Wie Antwortmaschinen ihre Quellen wählen

Eine einzelne perfekte Seite reicht nicht. KI-Systeme kreuzprüfen eine Entität über mehrere unabhängige Quellen, bevor sie zitieren. Im schema.org-Vokabular heißt das Stichwort sameAs, frei übersetzt der Verweis auf denselben Ausweis anderswo. Konsistente, echte Verweise erhöhen die Vertrauenswürdigkeit, garantieren aber nichts. Es braucht übereinstimmende Spuren an mehreren Orten. Und Vorsicht vor dem Trugschluss „mehr ist besser“: tote oder inkonsistente Verweise schaden, nur gepflegte, echte Profile zählen.

Der vielleicht wichtigste Befund für alle, die keine Marketing-Abteilung haben: Zitierwürdigkeit ist nicht dasselbe wie Ranking. Ahrefs hat im August 2025 rund 15.000 Long-Tail-Anfragen ausgewertet und KI-Assistenten wie ChatGPT, Gemini und Perplexity dieselben Fragen gestellt. Ergebnis: im Schnitt ranken nur rund 12 Prozent der von diesen Tools zitierten URLs in Googles Top 10, rund 88 Prozent also nicht. Etwa 80 Prozent tauchen für die ursprüngliche Anfrage überhaupt nicht in Googles Ergebnissen auf. Ein Detail der Ehrlichkeit halber: das ist ein Durchschnitt, und Perplexity schert mit knapp 29 Prozent Überschneidung deutlich nach oben aus, hängt also stärker an der klassischen Suche als die anderen. Die Botschaft bleibt trotzdem: Antwortmaschinen wählen nach antwortfertig, glaubwürdig und strukturell sauber, nicht primär nach Suchplatzierung. Genau deshalb kann ein Nischenblog ohne Spitzen-Rankings trotzdem zitierfähig sein. Wer nur in Keyword-Rankings denkt, greift zu kurz.

Und was steigert nun messbar die Sichtbarkeit in generativen Antworten? Eine viel zitierte akademische Arbeit von Forschenden der Princeton University und des IIT Delhi, dazu zwei unabhängige Autoren, hat genau das untersucht, vorgestellt auf der KDD 2024. Sie gilt als die erste Arbeit, die den Begriff Generative Engine Optimization geprägt hat. Die Antwort ist herrlich unspektakulär, und das ist die eigentliche Pointe. Was hilft, ist: wörtliche Zitate einbauen (in der Studie der stärkste Hebel mit rund 41 Prozent mehr Sichtbarkeit), Statistiken nennen (rund 33 Prozent), Quellen angeben (rund 28 Prozent), flüssig und gut lesbar schreiben (ähnliche Größenordnung). Insgesamt bis zu rund 40 Prozent mehr Sichtbarkeit. Zwei Einschränkungen gehören dazu: gemessen wurde nicht Traffic oder Klicks, sondern eine positionsgewichtete Sichtbarkeit innerhalb der KI-Antwort, und die Prozente sind relativ zu einer unoptimierten Ausgangsversion. Das Schlusslicht, mit deutlichem Abstand: klassisches Keyword-Stuffing senkte die Sichtbarkeit sogar, um rund 8 bis 9 Prozent. Die Botschaft ist also kein Geheimtrick, sondern fast schon eine Erlösung: gute, belegte, lesbare Substanz ist die Strategie. Das ist auch der Kern von E-E-A-T, also Erfahrung, Fachkenntnis, Autorität und Vertrauen. Kein Algorithmus-Schalter, sondern ein Signalbündel. Und genau hier zahlt die verifizierte Identität ein: echte Werke, externe Bestätigung und Konsistenz machen Erfahrung und Expertise überhaupt erst maschinell nachvollziehbar.

Ehrlicher Kassensturz

Bleibt die unbequeme Frage: hat das alles etwas gebracht? Fangen wir mit der Korrektur meiner eigenen Anfangs-Wette an, der llms.txt. Die läuft live, der Aufwand für die Datei ist billig und harmlos. Aber sie ist kein bewiesener Hebel. Auf der Search Central Live im Juli 2025 stellte Gary Illyes klar, dass llms.txt keine Google-Initiative ist und Google nicht plant, das Format zu unterstützen. John Mueller hatte sie schon im Frühjahr 2025 mit dem längst ignorierten Keywords-Meta-Tag verglichen, weil sie vom Seitenbetreiber kontrolliert und damit letztlich eine Selbstauskunft ist, die man genauso gut direkt an der Seite überprüfen könnte. Im Dezember 2025 tauchte eine llms.txt kurz in Googles eigener Entwickler-Dokumentation auf und war am selben Tag wieder weg, allem Anschein nach ein automatischer Rollout des Redaktionssystems, keine Kursänderung. Wie es mit der Nutzung auf Anbieterseite wirklich steht, ist unübersichtlich: formell als Standard zugesagt hat es keiner, Google lehnt ausdrücklich ab, OpenAI hat sich nicht festgelegt. Von einzelnen Anbietern heißt es, sie berücksichtigten das Format in ihren Abläufen, aber diese Angaben stammen aus SEO-Quellen, nicht aus offiziellen Hersteller-Mitteilungen. Ich verkaufe das also nicht als Wundermittel. Es schadet nicht, es ist schnell gemacht, aber es ist eher eine Höflichkeitsgeste an Maschinen als ein Garant für irgendetwas.

Anders sieht es bei der strukturierten Identität aus. Hier ist aus „ich habe da mal was erwähnt“ etwas Substantielles geworden. Nicht weil ein Schema magisch wirkt, sondern weil mich der Prozess gezwungen hat, meine eigene Online-Existenz aufzuräumen, Widersprüche zu beseitigen und nur noch Prüfbares zu behaupten. Das wäre auch ohne jede Maschine eine gute Übung gewesen.

Und meine selbstironische Prognose von damals, dass klassische Blogs seltener werden? Die stimmt und stimmt nicht. Dieser Blog schreibt weiter, sehr aktiv sogar. Aber die Verteilung verschiebt sich tatsächlich. Neue Beiträge gehen über ActivityPub ins Fediverse und über eine Brücke nach Bluesky, nicht mehr in erster Linie über die Suchmaschine zum Leser. Insofern stützt die Realität die Prognose, sie widerlegt nur das „Blog ist tot“-Pathos. Es ist kein Sterben, es ist ein Umzug der Verteilwege.

Hat die Maschinenlesbarkeit messbar etwas gebracht? Differenziert betrachtet ja und nein. Die KI-Crawler holen die strukturierten Daten nachweislich ab, das war meine Anfangsprognose und sie hat sich bestätigt. Aber Abruf ist nicht gleich Klick. Die Klick-Konversion aus diesen Kanälen ist niedrig. Das ist kein Widerspruch, das ist genau der Punkt des ganzen Themas, siehe Zero-Click weiter oben. Sichtbar zu sein und besucht zu werden sind zwei verschiedene Dinge geworden.

Damit zum Kerngedanken, der für mich am Ende übrig bleibt: Man kontrolliert nicht, ob eine KI einen zitiert. Man kontrolliert nur, ob man zitierbar ist. Das ist die ganze Aufgabe. Fehlende oder widersprüchliche Daten machen ein Zitat fast unmöglich. Saubere, konsistente, belegbare Daten machen es wahrscheinlicher. Mehr Versprechen gibt es nicht, und jeder, der mehr verspricht, verkauft etwas. SEO ist dabei übrigens nicht tot, das wäre Übertreibung. Technische Hygiene, Crawlbarkeit und gute Inhalte bleiben die Basis. Es verschieben sich nur die Gewichte.

Vor einem halben Jahr habe ich geschrieben, ich sei gespannt, was passiert. Daran hat sich nichts geändert. Ich weiß heute ein paar Dinge genauer, ich habe meine eigene Anfangs-Euphorie an einigen Stellen kassiert, und ich habe vor allem gelernt, dass der ehrlichste Weg auch der robusteste ist. Ob das langfristig der richtige war, weiß ich immer noch nicht. Ich bin weiterhin gespannt.

Siehe auch: Von SEO zu AEO, warum llms.txt, JSON-LD und Answer Engines das Web verändern (der Vorgängerpost mit der ursprünglichen Wette).

Gegenmeinung, eigene Erfahrungen oder ein Befund, der meinem widerspricht? Immer her damit, einfach fragen.

ADS-B-Feeder, Teil 2: der NTP-Bug in fr24feed ist in 1.0.57 gefixt, nur anders als gedacht

Raspberry Pi mit RTL-SDR-Stick und ADS-B-Antenne vor einer Flugradar-Karte. Das Beitragsbild thematisiert die Behebung des NTP-Problems in fr24feed 1.0.57 und die erfolgreiche Wiederanbindung eines Flightradar24-Feeders.

Im ersten Teil dieser kleinen ADS-B-Saga hatte ich am Ende eine Sache offen gelassen und sie sogar fett in die Was-noch-kommt-Liste geschrieben: MLAT aktivieren, sobald Flightradar24 den NTP-Bug fixt. Heute ist es soweit. Der Fix ist da, er kam mit Version 1.0.57, und er kam ganz anders als ich erwartet hätte. Statt den kaputten NTP-Client zu reparieren, hat FR24 ihn einfach rausgeworfen.

Wer den ersten Teil noch nicht kennt, holt das am besten kurz nach: Eigener ADS-B Feeder: Flugzeuge tracken mit Raspberry Pi, RTL-SDR und selbstgebauter Antenne. Dort steht das komplette Setup, die selbstgebaute Antenne und eben die Geschichte mit dem NTP-Bug, der meinen Feeder über Wochen am Online-Gehen gehindert hat. Den Bug selbst erkläre ich hier nur noch in ein paar Sätzen, die lange Version steht drüben.

Worum es ging, ganz kurz

Seit Version 1.0.55 hatte der fr24feed-Daemon einen internen NTP-Client, der schlicht nichts tat. Kein einziges Paket auf Port 123, also keine Zeitsynchronisation, und ohne synchronisierte Zeit lässt FR24 den Feeder nicht online gehen. Man hängt in einer Endlosschleife aus Failed to synchronize fest und kommt nie über dieses Sync-Gate hinaus. Mein Workaround war die letzte funktionierende Version 1.0.54 mit apt-mark hold festzunageln und auf einen Fix zu warten.

Im März hatte ich FR24 einen Bug-Report mit strace- und tcpdump-Belegen geschickt. Die Antwort von Muazzam aus dem Support: auf ihrer Seite nicht reproduzierbar, Verdacht auf eine Regression durchs Build-System und nicht durch eine Änderung am NTP-Client selbst. Ich blieb hartnäckig, lieferte am 6. Juni eine syscall-genaue A/B-Analyse nach, und am 8. Juni kam die erlösende Mail (Ticket #741092): „should be fixed in v 57 which will be released later today“. War es dann auch, noch am selben Tag lag 1.0.57-1 im Repo.

Warum ich nicht einfach apt upgrade tippe

fr24feed ist closed-source, proprietär, kein GitHub, keine Quellen. Ich kann ein Release also nicht am Code beurteilen, sondern nur an seinem Verhalten. Und ein blindes Upgrade auf dem laufenden Produktiv-Feeder kam nicht in Frage. Wenn 1.0.57 genauso kaputt gewesen wäre wie 1.0.56, hätte ich mir den Feeder zerschossen und müsste erst wieder zurückrollen, bevor überhaupt wieder Daten fliessen.

Die saubere Variante: das Binary aus dem .deb extrahieren und als isolierte Wegwerf-Instanz gegen eine Wegwerf-Config unter strace laufen lassen. Eigener Fake-Key, ein toter Receiver-Port, der echte Feeder läuft dabei unberührt weiter. Erst wenn der Testlauf sauber durchkommt, fasse ich die Produktion an.

Der Testaufbau, eine Wegwerf-Instanz unter strace

Die Test-Config ist bewusst minimal gehalten. Sie muss nur weit genug kommen, dass der Feeder die Zeitsynchronisation versucht, alles danach interessiert für diesen Test nicht:

fr24key=0123456789abcdef
receiver=beast-tcp
host=127.0.0.1:39999    # absichtlich toter Port, fuer die NTP-Phase egal
bs=no
raw=no
mlat=no
logmode=0

Dann sehen, ob der Pi die neue Version überhaupt schon sieht, und das Paket herunterladen ohne es zu installieren:

apt-cache policy fr24feed
#   Installed: 1.0.54-0
#   Candidate: 1.0.57-1
#      1.0.57-1 500 https://repo-feed.flightradar24.com flightradar24/raspberrypi-stable arm64

apt-get download fr24feed
dpkg-deb -x fr24feed_1.0.57-1_arm64.deb extract57

Erst die Toolchain vergleichen

Bevor ich überhaupt gestartet habe, ein kurzer Blick in die .comment-Section der ELF-Binaries. Die verrät, mit welchem Compiler gebaut wurde, und genau das war FR24s Verdacht:

readelf -p .comment extract57/usr/bin/fr24feed | grep -i gcc
#   GCC: (Debian 14.2.0-19) 14.2.0                       1.0.57 (und 1.0.56)
readelf -p .comment /usr/bin/fr24feed | grep -i gcc
#   GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0           1.0.54 (funktioniert)

Das ist der interessante Punkt: 1.0.57 ist mit derselben GCC-14-Toolchain gebaut wie das kaputte 1.0.56. „Neu kompiliert“ allein ist also noch kein Fix, sonst wäre 1.0.56 ja schon heil gewesen. Genau das machte den strace-Test erst spannend, denn ich konnte nicht aus der Versionsnummer ableiten, ob sich am Verhalten wirklich etwas geändert hat. Der Sprung von GCC 11 auf 14 plus der Distro-Wechsel von Ubuntu 22.04 auf Debian ist gross. GCC 14 ist deutlich strenger bei Undefined Behaviour und uninitialisierten Daten, und ein latenter Bug im NTP-Transmit-Pfad konnte unter GCC 11 unsichtbar bleiben und unter GCC 14 dann brechen. FR24s Build-System-Theorie war im Nachhinein also gar nicht so abwegig.

Der A/B-Lauf

Beide Versionen, die neue 1.0.57 und die installierte 1.0.54 als Kontrolle, laufen durch denselben Harness, auf derselben Maschine, am selben Tag. Ich tracke nur die Netzwerk-Syscalls, das reicht um zu sehen ob da etwas auf Port 123 geht:

timeout -s INT 125 strace -f -tt -e trace=%network -yy -o v57_today.strace extract57/usr/bin/fr24feed --config-file=test.ini > v57_today.log 2>&1

Das Ergebnis, und es überrascht

Mein Abnahmekriterium war simpel formuliert: sendto auf Port 123 muss wieder feuern, dann ist der NTP-Client repariert. Das Ergebnis war eine kalte Dusche und gleichzeitig die ganze Pointe dieser Geschichte:

1.0.54 (Kontrolle)1.0.56 (kaputt)1.0.57-1 (neu)
NTP sendto auf Port 1233x (eigener Client)0x0x, Client entfernt
Source-Address-Discoveryjajaja (Rest-Code)
Zeitsync-Logoffset +0.001 sFailed to synchronizeconfirmed with timesyncd
Failed-to-synchronize-Loopneinja, endlosnein
Kommt über das Sync-Gate?janeinja
ToolchainGCC 11.4.0GCC 14.2.0GCC 14.2.0

Über den gesamten 125-Sekunden-Lauf von 1.0.57 hinweg gab es kein einziges Paket auf Port 123. Null. Genau wie beim kaputten 1.0.56. Nach meinem ursprünglichen Kriterium hätte ich das Release durchfallen lassen müssen. Und trotzdem war der Bug weg. Der entscheidende Hinweis steht eine Zeile vorher im Log:

[time][i]Time synchronization confirmed with timesyncd
[feed][i]Downloading configuration
[main][i]Feed Network client started
[feed][d]Fetching configuration
[feed][e]Result: failure, message: Not found, check your key!

Der einzige Fehler im ganzen Testlauf ist „check your key!“, und der ist erwartet, weil meine Test-Config absichtlich den Fake-Key 0123… benutzt. Das heisst: der Feeder läuft komplett durch bis zur Feed-Registrierung. Genau vor diesem Punkt hingen 1.0.55 und 1.0.56 endlos in ihrer Sync-Schleife fest. Bug also weg, nur eben nicht so, wie ich gedacht hatte.

Zum Vergleich der Beweis aus dem 1.0.54-Kontrolllauf, wo der eigene NTP-Client noch feuert. Hier sieht man das sendto auf Port 123 schwarz auf weiss:

sendto(5<UDP:[25798]>, "33...", 48, 0,
       {sa_family=AF_INET, sin_port=htons(123),
        sin_addr=inet_addr("85.10.204.50")}, 16) = 48
[time][i]Time synchronized correctly, offset +0.001 seconds

Pragmatischer Workaround statt echtem Fix

Was FR24 gemacht hat, ist kein Reparieren des NTP-Clients, sondern ein Umgehen des Problems auf Architektur-Ebene. Der kaputte interne Client ist raus, übrig geblieben ist nur noch etwas Rest-Code für die Source-Address-Discovery. Die eigentliche Zeitsynchronisation delegiert der Feeder jetzt an systemd-timesyncd, also an den NTP-Dienst des Betriebssystems. Statt selbst Pakete auf Port 123 zu schicken, fragt er das OS einfach: ist deine Zeit synchron? Und wenn ja, geht es weiter.

Ehrlich gesagt finde ich das eine vernünftige Entscheidung. Ein eigener NTP-Client in einer Feeder-Software war ohnehin Reinventing the Wheel, das Betriebssystem kann das besser und macht es sowieso schon. Dass der eigentliche Bug damit nie wirklich gefunden wurde, ist aus Ingenieurssicht ein kleiner Wermutstropfen, aber für den Anwender zählt nur, dass der Feeder läuft. Und das tut er.

Das Upgrade mit Sicherheitsnetz

Erst nachdem der Testlauf sauber durch war, ging es an die Produktion. Vorher noch das alte Paket und die Config wegsichern, damit ein Rollback jederzeit ein Einzeiler bleibt:

cp /var/cache/apt/archives/fr24feed_1.0.54-0_arm64.deb /tmp/fr24test/rollback/
sudo cp /etc/fr24feed.ini /etc/fr24feed.ini.bak-20260608-161113

sudo apt-mark unhold fr24feed
sudo apt-get install -y --only-upgrade fr24feed   # 1.0.54-0 auf 1.0.57-1

# Stolperstein: das Paket STOPPT den Dienst beim Upgrade, startet ihn aber nicht neu
sudo systemctl start fr24feed

# Wieder pinnen, jetzt auf die verifiziert gute Version
sudo apt-mark hold fr24feed

Der Stolperstein mit dem nicht neu gestarteten Dienst ist eine Kleinigkeit, kostet aber Nerven wenn man es nicht weiss und sich wundert warum der Feeder nach dem Upgrade tot ist. Ein systemctl start später lief alles. Die Verifikation kam aus der monitor.json und dem Journal:

"build_version":"1.0.57-1"
"feed_status":"connected"
"feed_num_ac_tracked":"92"

[time][i]Time synchronization confirmed with timesyncd
[reader][i]Timestamp source changed from UNKNOWN to SYSTEM-VALIDATED
[feed][n]connected via UDP (fd 6)
[feed][n]working
[feed][i]sent 46,0 AC

feed_status: connected und 92 getrackte Flugzeuge. Nach Wochen auf der festgenagelten 1.0.54 ist der Feeder endlich wieder auf einer aktuellen Version und kommt sauber über das Sync-Gate. Genau das wollte ich.

Die Kehrseite, eine neue Abhängigkeit

Wer einen eigenen Feeder betreibt, sollte das hier auf dem Schirm haben: 1.0.57 spricht selbst kein NTP mehr, also braucht es jetzt einen laufenden NTP-Dienst im Betriebssystem. Auf dem Standard-Pi24-Image ist das systemd-timesyncd, und damit funktioniert es out of the box. Kurz prüfen schadet trotzdem nicht:

systemctl is-active systemd-timesyncd     # active
timedatectl show -p NTPSynchronized       # NTPSynchronized=yes

Wer timesyncd oder chrony bewusst deaktiviert hat, oder ein abgespecktes Image ganz ohne NTP-Daemon fährt, könnte mit 1.0.57 jetzt ein neues Sync-Problem bekommen. Das ist der Preis des pragmatischen Fixes: FR24 hat die Verantwortung fürs Zeit-Setzen ans OS abgegeben, und damit muss das OS sie auch wahrnehmen.

Bonus-Fund: 1.0.57 bringt native GPS-Unterstützung

Beim Stöbern im neuen Binary ist mir noch etwas aufgefallen, das für die MLAT-Frage aus Teil 1 hochinteressant ist: 1.0.57 bringt einen PositioningNmeaDecoder und eine ganze Reihe neuer gps--Direktiven mit. Das könnte heissen, dass sich der VK-162 endlich für das MLAT-Timing nutzen lässt, das ja bislang auf NOT-PERMITTED stand.

strings /usr/bin/fr24feed | grep -oE 'gps-[a-z-]+' | sort -u
#   gps-altitude gps-antenna-connected gps-base-timestamp gps-ip gps-latitude
#   gps-longitude gps-mode gps-status gps-time ...

# Welcher gps-mode-Wert ist gueltig? Durchprobiert:
#   gps-mode=serial  -> [e]Unsupported gps-mode=serial!
#   gps-mode=nmea    -> akzeptiert (einziger gueltiger Wert)

So weit, so vielversprechend. Mit gps-mode=nmea plus mlat-without-gps=no öffnet das Binary dann aber /dev/ttyACM0 nicht selbst, sondern loggt nur stoisch:

[main][i]Waiting for GPS time

An der Hardware liegt es nicht, die liefert nachweislich einen sauberen Fix mit 9 Satelliten, parallel mitgelesen:

$GPGGA,161727.00,5034.69002,N,00656.93035,E,1,09,0.86,384.0,M,...   # Fix, 9 Sat, 384 m

Meine erste Vermutung war, dass 1.0.57 die NMEA-Daten gepusht erwartet, also über die Beast- und Decoder-Strecke oder über eine Netzwerkquelle per gps-ip statt über ein direktes Serial-Open des Dongles. Statt auf der Produktion herumzuraten habe ich FR24 aber lieber direkt gefragt, welche fr24feed.ini-Schlüssel zu einem seriell angeschlossenen NMEA-GPS gehören, Device-Pfad, Baudrate und so weiter.

Update vom 9. Juni 2026: Die Antwort von Muazzam aus dem Support (weiterhin Ticket #741092) kam am nächsten Tag und war kurz, aber unmissverständlich:

No, a local gps won’t help with mlat. For good mlat you need nano second timestamps that fpga provides. Also, we dont have an support for it.

Damit ist die Frage abschliessend beantwortet, wenn auch anders als erhofft. Ein lokal angeschlossener Serial- oder NMEA-GPS ist für MLAT schlicht keine gültige Timing-Quelle, und fr24feed unterstützt diesen Fall auch gar nicht. Der Grund steckt in der Physik der Multilateration: MLAT rechnet Flugzeugpositionen aus den Laufzeitunterschieden desselben Signals an mehreren Empfängern aus. Damit das aufgeht, müssen die Empfänger ihre Empfangszeitpunkte im Nanosekunden-Bereich stempeln, und solche Zeitstempel liefert nur dedizierte FPGA-Hardware der Radarcape-Klasse. Ein NMEA-GPS über USB-Serial hat dagegen Jitter im Millisekunden-Bereich, aus der USB-Latenz und dem Timing der NMEA-Sätze. Das sind gut sechs Grössenordnungen daneben, und selbst mit einem sauberen PPS-Signal kommt man an die FPGA-Genauigkeit nicht heran.

Das ordnet auch mein gps-mode=nmea-Experiment von oben sauber ein. Die GPS-Direktiven in 1.0.57 dienen faktisch nur der Positionsangabe, nicht dem MLAT-Timing. Das beobachtete [main][i]Waiting for GPS time, ohne dass der Feeder /dev/ttyACM0 überhaupt öffnet, war also kein Konfigurationsfehler meinerseits, sondern schlicht fehlender Support für genau diesen Anwendungsfall.

Für mich heisst das, der GPS-Dongle der seit März für genau diesen Moment bereitliegt, bleibt vorerst in der Schublade. Etwas schade, aber die Begründung ist nachvollziehbar und technisch sauber. Und für alle mit dem gleichen Setup ist die Lehre eindeutig: mit einem reinen RTL-SDR plus USB-GPS lässt sich MLAT bei FR24 nicht aktivieren, egal welche fr24feed.ini-Verdrahtung man probiert. MLAT bleibt dauerhaft auf NOT-PERMITTED. Wer MLAT wirklich will, kommt um Timing-Hardware mit FPGA nicht herum.

Fazit, und die eigentliche Lehre

Die schönste Lektion steckt nicht in der Versionsnummer, sondern in meinem Abnahmekriterium. Ich war so auf den einen Syscall fixiert, dass ich beinahe das richtige Ergebnis als Fehlschlag abgehakt hätte. sendto auf Port 123 war nie das eigentliche Ziel, das war nur die zufällige Art, wie 1.0.54 die Zeit synchronisiert hat. Das richtige Erfolgskriterium war die ganze Zeit ein anderes: kommt der Feeder über das Sync-Gate, ja oder nein. Ein bestimmter Syscall ist Mittel zum Zweck, nicht der Zweck selbst. Wer Verhalten testet statt Implementierung, läuft seltener in so eine Falle.

FR24 bekommt von mir Lob für die schnelle Reaktion am Ende und einen pragmatischen Fix, der das Problem zuverlässig erledigt. Ein kleiner Kritikpunkt bleibt, dass der eigentliche Bug nie gefunden wurde, sondern nur umgangen. Aber Hand aufs Herz: ein funktionierender Feeder ist mir lieber als ein vollständig aufgeklärter, der nicht läuft. Mein Beitrag war am Ende vor allem die Reproduktion auf genau der arm64-Hardware, die FR24 im März nicht zum Fehler bringen konnte. Dass der Fix jetzt auf eben dieser Maschine hält, habe ich dem Support noch einmal zurückgemeldet, damit sie die Regression sauber abschliessen können. Manchmal ist der wertvollste Teil eines Bug-Reports, dass man hartnäckig bleibt und sauber misst.

Siehe auch:

Betreibt ihr selbst einen FR24-Feeder und seid über den NTP-Bug gestolpert, oder lasst ihr MLAT über dedizierte Timing-Hardware mit FPGA laufen? Dann lasst es mich gerne wissen, ihr dürft mich jederzeit fragen.

Bosch Wärmepumpentrockner: Kondensator reinigen trotz SelfCleaning, Service-Klappe selbst geschnitten und gedruckt

Vor knapp 11 Jahren ist bei uns einer dieser Wärmepumpentrockner eingezogen. Das Gerät hat seitdem wirklich viel Wäsche gesehen, einen Haushalt mit dem täglichen Berg an Handtüchern, Bettzeug und Kinderkram trocknet so ein Ding nicht nebenbei. Über die ganze Zeit lief er eigentlich problemlos. Zwei Mal musste ich ihn zerlegen und reinigen, sonst nichts. Genau dieser Punkt ist es aber, der mich diesmal über eine Stunde und ein Teppichmesser gekostet hat.

Bosch Home Professional Wärmepumpentrockner WTY87701 von vorne mit Aufdruck SelfCleaning Condenser und ActiveAir Technology
Der Patient: Bosch WTY87701 Home Professional, ausgerechnet mit dem Aufdruck SelfCleaning Condenser.

Das Symptom ist immer das gleiche: Die Wäsche wird einfach nicht mehr richtig trocken. Teilweise musste ein Programm zwei bis drei Mal durchlaufen, bis das Zeug aus der Trommel wirklich trocken war. Für einen Trockner, der laut Datenblatt 232 kWh im Jahr ziehen soll, sind drei Durchläufe pro Ladung natürlich eine Katastrophe, energetisch wie zeitlich. Meine Frau hatte das Gerät zu dem Zeitpunkt schon angezählt. Es blieb also noch ein Versuch, dann fliegt es raus.

Warum ein Wärmepumpentrockner überhaupt zusetzt

Ein Wärmepumpentrockner ist im Kern ein geschlossener Kreislauf. Die warme, feuchte Luft aus der Trommel wird an einem Verdampfer abgekühlt, das Wasser kondensiert und landet im Tank, dann wird die Luft an einem Kondensator wieder aufgeheizt und zurück in die Trommel geblasen. Dieser Wärmetauscher mit seinen eng stehenden Aluminiumlamellen ist das Herz des Geräts. Und genau dort lagern sich über die Jahre feinste Flusen ab, die das Sieb passieren. Setzt sich die Lamellenfläche zu, kommt kaum noch Luft durch, der Wärmeaustausch bricht ein, und die Wäsche bleibt klamm. Genau das beschreiben auch die einschlägigen Reparaturanleitungen: Flusen in den hinteren Kondensatorlamellen führen zu langen Laufzeiten und einer Wärmepumpe, die sich abmüht.

Die Ironie: ein SelfCleaning Condenser, der trotzdem dicht ist

Das Schöne an meinem Gerät: vorne prangt in großen Lettern SelfCleaning Condenser. Bosch wirbt damit, dass eine manuelle Reinigung des Kondensators nicht mehr nötig sei. Technisch steckt dahinter, dass während eines Trockengangs mehrfach Kondenswasser aus dem Prozess abgezweigt und über die Kondensatorfläche gespült wird, um Flusen wegzuschwemmen. Das funktioniert auch, aber eben nur an der vorderen, gut erreichbaren Fläche, die der Spülstrahl trifft. Die tieferen Lamellen und der Verdampfer dahinter bekommen davon nichts ab. Dort sammelt sich der Feinstaub über ein Jahrzehnt trotzdem an. „Selbstreinigend“ heißt hier also „die vordere Ebene bleibt frei“, nicht „der ganze Wärmetauscher bleibt sauber“. Nach knapp 11 Jahren ist der Unterschied dramatisch.

Hier die Eckdaten zum Gerät und zum Bauteil, um das sich gleich alles dreht:

GerätBosch WTY87701, Home Professional
Typ / FDWDT66, FD 9505
BauartWärmepumpentrockner, 8 kg, A++, ActiveAir Technology, SelfCleaning Condenser
Service-Klappe (Teilenummer)BSH 00646776, ca. 225 x 145 mm, mit Dichtung und 6 Schrauben
Klappe passt laut Teilekatalog aufBosch Serie 6 / Serie 8 / Maxx 7, Siemens iQ300 bis iQ800
Typenschild des Bosch WTY87701 mit E-Nr WTY87701, Typ WDT66, FD-Nummer 9505 und Herstellungsangaben
Typenschild: E-Nr. WTY87701, Typ WDT66, FD 9505, mit allen Kenndaten für die Ersatzteilsuche.

Wo man rankommt, und wo eben nicht

Das große Flusensieb innen vor der Trommel ist schnell raus, da bekommt man mit schmalen Fingern auch etwas heraus, aber an die spannenden Stellen kommt man so nicht. Vorne am Gerät gibt es noch eine kleine Abdeckung. Rechts dahinter sitzt ein Lüfter, links sieht es zumindest so aus, als hätte dort mal jemand über eine Art Klappe nachgedacht. Und zwar genau an der Stelle, an der man eine Klappe bräuchte, wenn man den Kondensator selbst reinigen will. Bei meinen letzten beiden Reinigungen konnte ich alles andere öffnen und sauber machen, aber an den Kondensator selbst kam ich nie richtig heran. Diesmal sollte es anders laufen.

Die Klappe, die Bosch vergessen hat einzubauen

Jetzt kommt der Teil, der mich erst gewundert und dann geärgert hat. Die Stelle, an der ich die Klappe erwartet hätte, ist im Gehäuse fast schon vorperforiert. Die Kontur ist angedeutet, das Material an der Linie spürbar dünner. Mit einem Teppichmesser lässt sich die Öffnung sehr einfach herstellen, man folgt einfach der angedachten Linie. Heißt im Klartext: Bosch hat die Service-Öffnung konstruktiv komplett vorgesehen, das Werkzeug für die Stanzung existiert, die Position stimmt, nur ausgeliefert wird das Gerät mit zugemachter Wand und ohne Klappe. Für genau diese Lücke gibt es sogar eine eigene Ersatzteilnummer, die 00646776, und einen ganzen Zubehörmarkt. Right to Repair sieht anders aus.

Aus der Trocknerwand herausgeschnittenes Kunststoffstück an der vorperforierten Stelle der Service-Öffnung
Das herausgetrennte Stück Gehäusewand. Die Kontur war vorperforiert, ein Teppichmesser entlang der angedachten Linie genügt.

Was dann hinter der frisch geschnittenen Öffnung zum Vorschein kam, war wirklich ekelig. Die Flusen hingen als kompakter, grauer Filz komplett vor den Kühlrippen und haben den Wärmetauscher zu einem großen Teil verdeckt. So kann der Kondensator natürlich nicht mehr arbeiten, da geht schlicht keine Luft mehr durch.

Mit grauem Flusenfilz komplett zugesetzte Kühlrippen des Kondensators durch die geschnittene Öffnung
Der Blick hinter die frisch geschnittene Öffnung. Ein kompakter Flusenfilz verdeckt fast den kompletten Wärmetauscher.

Alles vorsichtig herausgeholt, abgesaugt und feucht nachgewischt. Die Lamellen waren an einigen Stellen verbogen, die habe ich anschließend so gut wie möglich wieder begradigt. Danach sah der Wärmetauscher wieder so aus, wie er soll: blankes Aluminium, freie Kanäle, Luft kann wieder hindurch.

Gereinigter Kondensator mit blanken Aluminiumlamellen und freien Luftkanälen nach dem Absaugen
Nach dem Reinigen und Begradigen der Lamellen: blankes Aluminium, freie Kanäle, die Luft kommt wieder durch.

Variante 1: die Klappe selbst drucken

Bleibt die Frage, wie man das Loch wieder sauber und vor allem dicht verschließt. Ein offenes Gehäuse zieht Falschluft und stört den Kreislauf. Ich habe eine wirklich erstklassige Druckdatei für meinen Bambu Lab X1-Carbon gefunden, ein Nachbau genau der BSH-Klappe 00646776: BSH 00646776 Condenser Door Service Panel auf MakerWorld.

Gedruckt habe ich die Abdeckung in grünem ABS und die Dichtung in TPU 95A. PLA wäre bei den Temperaturen rund um Kondensator und Wärmepumpe keine gute Idee. PLA fängt schon bei etwa 60 Grad an weich zu werden, sein Glasübergang liegt genau in dem Bereich, den so eine Baugruppe im Dauerbetrieb durchaus erreicht. Das Teil würde sich auf Dauer verziehen und die Dichtfläche verlieren. ABS liegt mit einem Glasübergang um die 105 Grad deutlich darüber und steckt die Wärme locker weg. Für die Dichtung will man dagegen etwas Flexibles, das die Spaltmaße ausgleicht und sauber abdichtet, deshalb das weiche TPU mit Shore 95A. Die Materialkombination ist hier kein Gimmick, sondern genau richtig gewählt.

Rückseite der in grünem ABS gedruckten Service-Klappe mit Versteifungsrippen und eingeprägter Teilenummer 00646776
Die in grünem ABS gedruckte Abdeckung von hinten, mit Versteifungsrippen und eingeprägter Teilenummer 00646776.

Die Versteifungsrippen auf der Rückseite und die eingeprägte Teilenummer zeigen, wie sauber die Datei gemacht ist. Mit aufgelegter TPU-Dichtung sieht das dann so aus:

Gedruckte ABS-Klappe mit aufgelegter blauer TPU-95A-Dichtung
Dieselbe Klappe mit der separat in TPU 95A gedruckten Dichtung.

Montiert wird mit sechs Schrauben in die vorhandenen Dome rund um die Öffnung. Die selbst gedruckte Abdeckung passt erstklassig und dichtet alles ab, wie man es sich wünscht.

Grün gedruckte Service-Klappe mit sechs Schrauben an der Trocknerfront montiert
Mit sechs Schrauben montiert. Die selbst gedruckte Klappe sitzt passgenau und dichtet sauber ab.

Variante 2: die fertige Klappe kaufen

Wer keinen 3D-Drucker hat, muss trotzdem nicht zum teuren Originalersatzteil greifen. Ich habe auf Amazon für knapp 15 Euro eine passende Abdeckung gefunden: Wartungsklappe für den Wärmetauscher. Die kommt aus spritzgegossenem, talkgefülltem Polypropylen (auf dem Bauteil steht die Materialkennung PP-TV30) und liegt damit thermisch ebenfalls im grünen Bereich. Im Set sind noch etwas Werkzeug zum Reinigen und sogar ein kleines Tool, mit dem sich die Kühlrippen wieder begradigen lassen.

Gekauftes Reinigungsset mit Lamellenkamm, Bürsten, Schrauben und grauer Wartungsklappe
Die gekaufte Alternative für knapp 15 Euro: Wartungsklappe plus Reinigungswerkzeug und ein kleines Tool zum Begradigen der Lamellen.

Auch diese fertige Klappe sitzt sauber in der Öffnung und schließt bündig ab. Auf dem Bauteil sind Teilenummer und Materialkennung mit eingegossen.

Graue gekaufte Wartungsklappe aus Polypropylen in der Trockneröffnung mit eingegossener Teilenummer und Materialkennung PP-TV30
Die gekaufte Klappe aus talkgefülltem Polypropylen (PP-TV30) sitzt ebenso bündig. Teilenummer und Materialkennung sind eingegossen.

Beide Lösungen, die selbst gedruckte und die gekaufte, passen perfekt und dichten zuverlässig ab. Ich kann beide vorbehaltlos empfehlen. Wer einen Drucker mit ABS-tauglichem Gehäuse hat, druckt sich die Klappe für ein paar Cent Material selbst, alle anderen sind mit der 15-Euro-Variante inklusive Reinigungswerkzeug bestens bedient.

Und jetzt?

Der Trockner läuft wieder wie am ersten Tag. Eine Ladung, ein Durchlauf, Wäsche trocken. Auf den Fotos vom verdreckten Kondensator sieht es übrigens schlimmer aus, als es sich am Ende angefühlt hat, in einer guten Stunde war alles erledigt. Schöner Nebeneffekt: Ab jetzt brauche ich kein Teppichmesser mehr, sondern schraube die Klappe einmal im Jahr auf und sauge kurz durch. Vielleicht laufen so ja noch einmal 11 Jahre aus dem Teil heraus.

Display des Bosch WTY87701 zeigt das laufende Programm Schranktrocken mit Restzeit
Läuft wieder: eine Ladung, ein Durchlauf. Das Display zeigt Schranktrocken mit normaler Restzeit.

Die kleine Moral: Lasst euch von einem „SelfCleaning“-Aufkleber nicht einlullen. Selbst die selbstreinigenden Geräte setzen sich über die Jahre zu, und ein bisschen Wartung verlängert die Lebensdauer enorm. Dass man dafür erst eine Klappe ins Gehäuse schneiden muss, die der Hersteller komplett vorbereitet, aber nicht freigibt, ist ein eigenes kleines Trauerspiel. Gut, dass es findige Leute mit Druckdateien und Ersatzteilen gibt.

Siehe auch:

Habt ihr auch so ein Gerät, bei dem die Service-Klappe ab Werk fehlt, oder eine eigene Druckdatei dafür? Dann lasst es mich gerne wissen, ihr dürft mich jederzeit fragen.

Billiger VGA-USB-Capture-Stick seziert: MS2109-Firmware, EDID-Hack und die 1080p-Lüge

Eigentlich wollte ich nur etwas ganz Simples: Aufnahmen von meinem alten DOS-Rechner machen. BIOS-POST, der Speichertest, ein paar DOS-Spiele, einmal das alles sauber als Video festgehalten. Dafür landete ein „VGA to USB 3.0 HD 1080P Video Capture Card“ in meinem Warenkorb, so ein billiger Dongle für ein paar Euro. Spoiler: in genau der Form hat das nicht geklappt. Und warum es nicht klappt, ist die eigentlich spannende Geschichte. Es geht um einen 8051 mit recyceltem Mask-ROM, eine EDID die der Quelle einen Monitor vorlügt, ein „1080p“ das horizontal gar keins ist, und eine Strings-Modifikation die ich bis heute nicht überlistet habe.

Das konkrete Gerät, falls es jemand nachvollziehen will, gibt es bei Amazon unter diesem Link. Es ist einer von hunderten optisch identischen Sticks, die alle den gleichen MacroSilicon-Chipsatz tragen. Die Erkenntnisse hier gelten also für eine ganze Geräteklasse, nicht nur dieses eine Exemplar.

Platine des VGA-USB-Capture-Sticks mit VGA-Stecker, USB-A-Buchse, Audio-Header und Silkscreen AFN_VGA_Captor
Die Platine des Sticks: VGA-Stecker, USB-A, 3-poliger Audio-Header und der Silkscreen AFN_VGA_Captor 2020/0615_V1.1.

Was steckt auf der Platine?

Aufgeschraubt zeigt sich eine winzige Platine mit Silkscreen-Aufdruck AFN_VGA_Captor 2020/0615_V1.1, einem VGA-Stecker, einer USB-A-Buchse und einem 3-poligen Audio-Header. Drei Halbleiter machen die eigentliche Arbeit:

RefChipFunktion
U3MacroSilicon MS21098051-basierte UVC-Bridge, USB 2.0 High-Speed
U7MacroSilicon MS9288AAnaloger VGA-Empfänger (PLL plus ADC plus Scaler)
U2HK / Holtek 24C16I²C-EEPROM, 2 KiB

Der MS2109 ist berühmt-berüchtigt. Er steckt in den meisten der spottbilligen HDMI-Capture-Sticks, die seit Jahren durchs Netz geistern. Hier sitzt derselbe Chip in einer VGA-Variante, mit dem MS9288A als analogem Frontend davor. Wie eng die beiden Welten verwandt sind, wird sich beim Mask-ROM-Dump zeigen: die Firmware ist nachweislich aus der HDMI-Variante recycelt.

Mikroskop-Makro des MacroSilicon MS2109, der 8051-basierten UVC-Bridge des Capture-Sticks
U3, der MacroSilicon MS2109. Der gleiche 8051-Chip wie in den billigen HDMI-Capture-Sticks, hier in der VGA-Variante.
Mikroskop-Makro des MacroSilicon MS9288A, des analogen VGA-Empfängers mit PLL und ADC
U7, der MacroSilicon MS9288A. Analoges Frontend mit PLL, ADC und Scaler, lockt das VGA-Signal und digitalisiert es.
Mikroskop-Makro des HK 24C16 I2C-EEPROM mit 2 KiB Kapazität auf der Capture-Platine
U2, der HK 24C16. 2 KiB I2C-EEPROM, hier liegen EDID, Vendor-Strings und die beiden 8051-Patch-Blöcke.

Wie meldet sich das Ding am USB?

Beim Anstecken kommt der erste Hinweis darauf, dass die Verpackung schwindelt. „USB 3.0″ steht drauf, der Kernel sieht aber ein High-Speed-Gerät, also USB 2.0:

usb 1-1.1: new high-speed USB device number 15 using xhci_hcd
usb 1-1.1: New USB device found, idVendor=534d, idProduct=2109, bcdDevice=21.00
usb 1-1.1: Manufacturer: MACROSILICON
usb 1-1.1: Found UVC 1.00 device <unnamed> (534d:2109)
hid-generic 0003:534D:2109.0009: hiddev4,hidraw8: USB HID v1.10 Device

Vendor 0x534d ist MacroSilicon, Produkt 0x2109 der nackte MS2109. Das Gerät enumeriert als UVC-1.00-Kamera plus USB-Audio plus ein vendor-spezifisches HID-Interface. Genau dieses HID-Interface ist später der Schlüssel: darüber kommt man an das EEPROM und sogar an den Arbeitsspeicher des 8051 heran, ganz ohne Lötkolben am I²C-Bus.

Das Werkzeug: ms-tools

Die zentrale Referenz für alles, was mit MS2109 zu tun hat, ist das Projekt ms-tools von Bertold Van den Bergh. Das ist eine kleine Goldgrube: EEPROM lesen und schreiben über die HID-Schnittstelle, Live-Zugriff auf den XDATA-Speicher zur Laufzeit (per eingeschleustem 8051-Patch-Code), das Werkzeug mshack zum Injizieren eigener 8051-Routinen, Ghidra-Skripte mit teildisassemblierter Firmware und ein dump-rom, das den kompletten 64-KiB-Mask-ROM ausliest.

Auf Ubuntu 24.04 ist das schnell gebaut:

sudo apt install golang-go libhidapi-dev libudev-dev
git clone https://github.com/BertoldVdb/ms-tools.git
cd ms-tools/cli && go build -o ../msctl .

Der EEPROM-Dump und eine erste Korrektur

Manche MS2109-Revisionen mögen das übliche RAM-Patching nicht, das ms-tools für komfortable Zugriffe nutzt. Mit --no-patch liest der Dump trotzdem sauber, weil dann der rohe HID-Pfad ohne vorgeschaltete Firmware-Manipulation genommen wird:

msctl --raw-path /dev/hidraw8 --no-patch read EEPROM 0 2048 --filename=dumps/eeprom-vga-raw.bin

Von den 2048 Bytes sind nur 962 belegt. Die Aufteilung sieht so aus:

OffsetGrößeInhalt
0x000 bis 0x00F16 BHeader: Magic a5 5a, dann Build-Datum 19 11 20 00 = 2019-11-20
0x010 bis 0x02F32 BVendor-Strings im Pascal-Format: AFN_Cap video, AFN_Cap audio
0x030 bis 0x07974 B8051-Patch-Code #1 (ROM-Hook-Routinen)
0x07A bis 0x179256 BVollständige EDID (128 Base plus 128 CTA-861-Extension)
0x17A bis 0x3C1584 B8051-Patch-Code #2, Keil-C51-kompiliert

Die ersten Bytes mit dem Hexeditor angeschaut, da steht die Geschichte schon drin:

0000  a5 5a 03 8e 09 10 ff ff ff ff ff ff 19 11 20 00   .Z............ .
0010  0e 41 46 4e 5f 43 61 70 20 76 69 64 65 6f ff ff   .AFN_Cap video..
0020  0e 41 46 4e 5f 43 61 70 20 61 75 64 69 6f ff ff   .AFN_Cap audio..
0030  90 de 07 ef f0 12 cd d6 90 de 07 e0 ff 02 cf 25   ...............%

Das 0e vor jedem String ist die Pascal-Längenangabe (14 Zeichen). Ab 0x30 beginnt der erste 8051-Patch-Block. Spannend ist die EDID-Sektion ab 0x07A. Mein erster Analyse-Versuch ging davon aus, der klassische EDID-Header 00 FF FF FF FF FF FF 00 sei wegoptimiert und werde erst zur Laufzeit ergänzt. Das war falsch. Nach dem Mask-ROM-Dump und einem genaueren Blick steht der Header sauber im EEPROM. Solche Korrektur-Momente sind mir lieber als ein zu glattes Narrativ, also schreibe ich sie hier auch hin.

Die EDID selbst enthält einen Monitor-Namen MACROSILICON (EDID-Descriptor mit Tag 0xFC), als erstes Detailed-Timing 1280×720 bei 60 Hz mit 74,25 MHz Pixeltakt, ein zweites Timing 1280×768, dazu eine CTA-861-Extension mit nativem VIC=4 (720p60) plus VICs für 1080p60, 1080i, 720p50, 480p und 576p. Es gibt sogar einen HDMI-VSDB-Block mit dem OUI 00:0C:03 von HDMI Licensing und einer Source Physical Address. Eine VGA-only-Platine, die HDMI-Strukturen in ihrer EDID trägt. Das ist schon der erste deutliche Fingerabdruck der gemeinsamen Codebasis.

Der Mask-ROM verrät die Herkunft

Der eigentliche Programmcode des 8051 liegt in einem nicht beschreibbaren Mask-ROM. dump-rom lädt dafür eigenen 8051-Code in den USERRAM, der per MOVC den ROM ausliest und über HID zurückschiebt:

msctl --raw-path /dev/hidraw8 --no-patch dump-rom dumps/maskrom-vga.bin

64 KiB komplette CODE-Memory, davon rund 36 KiB belegt im Bereich 0x0000 bis 0x8FFF und weitere 12 KiB ab 0xC000. Die Highlights:

  • 0x7738: eine zweite, im ROM fest verdrahtete Default-EDID, intakt mit Standard-Header, Hersteller-ID „HJW“ und Monitor-Name „HDMI TO USB“. Das ist der Beweis: diese Firmware wurde ursprünglich für den HDMI-Bruder geschrieben.
  • 0x77A9: der ASCII-String „HDMI TO USB“ noch einmal direkt.
  • 0x7087 und 0x709B: die USB-String-Descriptor-Fallbacks „USB Video“ und „USB Digital Audio“ in UTF-16LE. Diese beiden werden uns gleich noch ärgern.
  • 0x0000: der Reset-Vector 02 41 49, also LJMP 0x4149.
  • Ein LCALL 0xCC10 bei ROM-Adresse 0x478F: das ist der zentrale Einstieg vom Mask-ROM in den EEPROM-Patch-Code, der zur Laufzeit in den RAM kopiert wurde.

Der Bootloader kopiert dabei die EEPROM-Bytes ab Offset 0x30 in den USERRAM ab Adresse 0xCBD0. Die Strings-Sektion 0x10 bis 0x2F wird explizit nicht mitkopiert. Die Patch-Firmware liest die Strings stattdessen zur Laufzeit direkt per I²C aus dem EEPROM und baut daraus die USB-String-Descriptoren an festen RAM-Adressen zusammen. Diese Mapping-Tabelle ist der Kern, um den herum sich die ganze Bastelei dreht:

EEPROMCode-RAMWas
0x0300xCC00Patch #1, kleine Hook-Routinen
0x07A0xCC4AEDID-Header 00 FF FF FF FF FF FF 00
0x0800xCC50EDID-Body
0x0FA0xCCCACTA-861-Extension
0x17A0xCD4APatch #2, C51-Startup
0x1800xCD50MOV SP,#0x3B; LJMP 0xCD91
0x1C10xCD91main() der Patch-Firmware

Erkenntnis 1: Die EDID lügt der Quelle einen Monitor vor

Über die DDC-Leitungen am VGA-Stecker (Pins 12 und 15) präsentiert der Dongle dem Quell-PC eine EDID. Damit gibt sich das Gerät als Monitor namens „MACROSILICON“ mit 720p60 als nativer Auflösung aus. Genau deshalb liefert ein Quell-PC, an dem gar kein echter Monitor hängt, trotzdem ein sinnvolles Bild: er glaubt, ein 720p-Display gefunden zu haben. So weit, so clever.

Erkenntnis 2: Das „1080p“ ist Marketing

Die UVC-Frame-Tabelle im Mask-ROM listet zwar brav 1920×1080 mit 30 fps als MJPEG. Die Realität sieht anders aus. Mit v4l2-ctl lässt sich die Format-Liste auslesen:

$ v4l2-ctl -d /dev/video2 --list-formats-ext
[0]: 'MJPG' (Motion-JPEG, compressed)
  Size: Discrete 1920x1080    30/25/20/10/5 fps
  Size: Discrete 1280x720     60/50/30/20/10 fps
  Size: Discrete 1024x768     60/50/30/20/10 fps
[1]: 'YUYV' (YUYV 4:2:2)
  Size: Discrete 1920x1080    5 fps
  Size: Discrete 640x480      30/20/10/5 fps

Unkomprimiertes YUYV bei 1080p ist auf magere 5 fps gedeckelt. Das native Detailed-Timing der EDID ist 720p60, das 1080p wird intern hochskaliert. Und USB 3.0 ist nirgends, der Chip kann nur High-Speed. Drei Behauptungen auf der Verpackung, drei mal geschummelt. Auf die 5-fps-Grenze komme ich am Ende noch genauer zurück, die hat einen sehr konkreten technischen Grund.

Erkenntnis 3: Kein DOS, und das lässt sich nicht reparieren

Jetzt zum eigentlichen Frustpunkt, der Grund, warum mein DOS-Plan scheiterte. Die UVC-Frame-Tabelle enthält keine 70-Hz-Modi. Ein DOS-BIOS gibt aber im Standard-VGA-Textmodus 720×400 bei 70 Hz aus. Das analoge Frontend, der MS9288A, könnte dieses Signal vermutlich locken, der Horizontaltakt von 31,469 kHz ist derselbe wie bei 640×480 bei 60 Hz. Aber die MS2109-Firmware bietet schlicht keinen passenden UVC-Frame-Mode an, an dem ein Aufnahmeprogramm andocken könnte.

Und das Bittere: im EEPROM kann man das nicht reparieren. Die Frame-Tabelle liegt im nicht-flashbaren Mask-ROM. Das EEPROM steuert nur EDID, Strings und ein paar Patch-Routinen, nicht die Liste der angebotenen Auflösungen. Wer mit so einem Dongle echte DOS-Signale aufnehmen will, kommt um eine vorgeschaltete Scaler-Box nicht herum. Mehr dazu am Ende.

Was wir trotzdem geändert haben: die EDID

Wenn die Frame-Tabelle schon unantastbar ist, dann wenigstens die EDID anfassen. Ich habe das erste Detailed-Timing von 720p60 auf 1920×1080 bei 60 Hz umgeschrieben (148,5 MHz Pixeltakt, das Standard-CEA-861-1080p60-Timing), die EDID-Checksumme neu berechnet und das EEPROM zurückgeflasht. Das neue DTD #1 sieht so aus:

02 3a 80 18 71 38 2d 40 58 2c 45 00 00 00 00 00 00 1e

Nach einem Replug liefert das Gerät die modifizierte EDID. Zumindest dachte ich das. Ob sie auch wirklich bei einer Quelle ankommt, war eine ganz eigene Odyssee, dazu komme ich beim A/B-Test. Vorweggenommen: Die Mod tut technisch genau das, was sie soll, sie ändert nur am Ende nichts an dem, was aufgenommen wird. Eine reine „ich sage der Quelle, ich kann 1080p“-Kosmetik.

Was NICHT geklappt hat: die Strings

Das ist der lehrreichste Teil des Projekts, gerade weil er bis heute offen ist. Ich wollte die USB-Function-Strings ändern, aus dem hässlichen „AFN_Cap video“ sollte ein sauberes „VGA Capture HD“ werden. Das EEPROM-Schreiben verifiziert sauber per Readback-Hash. Die EDID-Mod aus demselben Reflash funktioniert. Aber die Strings fallen nach dem Replug auf die ROM-Defaults „USB Video“ und „USB Digital Audio“ zurück. Irgendetwas in der Firmware sagt „nein“.

Also empirisch rangegangen, drei Single-Byte-Tests in der Strings-Region 0x10 bis 0x2F: einmal ein ASCII-Zeichen geändert, einmal ein FF-Padding-Byte, einmal das Längen-Byte. Jeder einzelne dieser Eingriffe löst den Fallback aus. Egal welches Byte, egal welcher Inhalt. Das ist eine klare Indikation für ein Content-Gate über die gesamte 32-Byte-Region, nicht für eine simple Längen- oder Inhaltsprüfung an einer Stelle.

Ein Gegencheck zur eigenen Analyse hat ein paar Punkte geschärft. Die Bytes 0x02 und 0x03 (03 8E) sind verifiziert die Payload-Länge und keine Checksumme: 910 Bytes ab 0x30 reichen bis 0x3BD, exakt bis vor den End-Marker. Der Verdacht: ein lokales Validierungs-Gate auf den 32-Byte-Strings-Slots, wahrscheinlich ein Hash oder ein Exact-Content-Compare in einem ROM-Helper, nicht im Patch-Code selbst. Ein Quervergleich mit dem usbkvm-Projekt zeigt dasselbe Header-Muster bei verwandter Hardware.

Der spannendste Nebenbefund kam aus einem XDATA-Boot-Trace bei 0xC630 bis 0xC67F: die „AFN_Cap“-Strings landen gar nicht dauerhaft im RAM. Was dort steht, ist die Fallback-Variante. Die echten Strings werden erst zur Laufzeit beim USB-GetDescriptor aus dem EEPROM gebaut, und genau in diesem Moment greift offenbar das Gate. Wer das knacken will, müsste den USB-Control-Transfer mit usbmon und Wireshark während der Enumeration mitschneiden, die ROM-Helper bei 0x6345 und Nachbarn disassemblieren, oder mit mshack einen Bypass-Patch auf den Fallback-Branch setzen. Der schnellste Weg wäre allerdings, ein vom offiziellen MacroSilicon-Windows-Tool editiertes EEPROM byteweise gegen mein eigenes zu diffen. Dieses Tool schreibt vermutlich versteckte Metadaten mit, die das öffentliche Reverse-Engineering noch nicht dokumentiert hat. Das ist mein heißester Kandidat fürs Strings-Mysterium, aber bisher unbewiesen.

Der A/B-Test, oder: die Suche nach einer ehrlichen VGA-Quelle

Ich wollte zwei Dinge empirisch klären. Erstens: Kommt die modifizierte 1080p-EDID überhaupt bei der Quelle an? Zweitens: Löst das Ding echtes 1080p auf oder ist das hochskalierter Matsch? Beides braucht eine VGA-Quelle, die ihre DDC-Leitung auch wirklich ausliest. Das war schwerer zu finden als gedacht.

Erster Anlauf, ein Notebook mit „VGA-Out“. Plot-Twist: der VGA-Anschluss lief über einen aktiven DisplayPort-auf-VGA-Adapter und tauchte als DP-4 auf. Der Adapter liefert ein eigenes synthetisches EDID („NVD“, „LCD_VGA“, 1280×1024). Das ist nicht das EDID aus dem Dongle. Der EDID-Pfad vom Dongle bis zur GPU ist also unterbrochen. Erste Lehre: aktive DP-auf-VGA-Adapter verhalten sich wie eine eigene EDID-Quelle und maskieren alles dahinter.

Zweiter Anlauf, ein ThinkPad L560 mit echter VGA-Buchse hinten. Wieder gescheitert, und zwar aus einem prinzipiellen Grund. Ab Haswell und Skylake hat Intel den analogen RAMDAC komplett aus der GPU geworfen. Die physische VGA-Buchse hängt an einem Onboard-DP-auf-VGA-Bridge-Chip. Der Kernel verrät es selbst:

$ xrandr --verbose | grep -i subconnector
        subconnector: VGA
$ cat /sys/class/drm/card1-DP-2/edid | wc -c
0

Null Byte EDID. Die Bridge reicht das DDC vom Dongle nicht durch, der Connector bietet nur die VESA-Default-Modi an. Exakt dasselbe Problem wie beim ersten Notebook, nur diesmal onboard statt als Dongle. Auf beiden getesteten Bridge-Pfaden kam die EDID des Capture-Sticks nicht zur Quelle durch. Als Trend lässt sich sagen: neuere Intel-Notebooks haben den analogen RAMDAC verloren (der fiel etwa um Haswell und Broadwell, also rund 2013 bis 2015) und führen „VGA“ über DP-Bridges, die DDC abschneiden. Das als „kein Notebook taugt je“ zu verkaufen wäre übertrieben, es ist ein Trend, kein Beweis.

Ein netter Nebenbefund vom L560, den ich erst für einen kaputten Aufbau hielt: nach jedem Auflösungswechsel liefert der Dongle erstmal 1 bis 6 Sekunden ein komplett schwarzes Bild, bis die MS9288A-PLL wieder eingerastet ist. Ich hatte reihenweise schwarze Frames gegrabbt und schon den Analogpfad für tot erklärt. In Wirklichkeit hatte ich nur immer ins Re-Lock-Fenster reingeschossen. Mit 4 bis 6 Sekunden Settle-Zeit kommt das Bild stabil. Passt zur dokumentierten Sync-Trägheit des Chips.

Der A/B-Test selbst

Trotz blockiertem DDC habe ich den A/B durchgezogen, einfach um es schwarz auf weiß zu haben. Als Quelle ein 1920×1080-Testbild mit vier Quadranten aus 1-Pixel-Gittern: Schachbrett, vertikale Linien, horizontale Linien, Diagonale. Dazu grüne Eck-Marker, um sicher zu sein, dass der ganze Frame ankommt.

1920x1080-Testbild mit vier Quadranten aus 1-Pixel-Gittern und grünen Eck-Markern für den A/B-Test
Das Quell-Testbild: vier Quadranten mit 1px-Schachbrett, Vertikal-, Horizontal- und Diagonalgittern, dazu Eck-Marker.

Erst der Befund, der mich am meisten interessiert hat: 1px-Vertikallinien und Schachbrett kollabieren zu flachem Grau, das horizontale Feindetail ist weg. 1px-Horizontallinien und Diagonale zeigen schwache Resttextur. Die vertikale Auflösung kommt also weitgehend durch (jede Scanline ist eine eigene analoge Zeile), die horizontale ist deutlich begrenzt. Wichtiges Ehrlichkeits-Caveat: das ist die komplette Analogkette, also Bridge-DAC im L560, Kabel und MS9288A-ADC zusammen. Mit diesem Aufbau lässt sich nicht sauber trennen, wieviel davon der Dongle ist.

Dann der eigentliche EEPROM-A/B. EEPROM gegen das Original-Backup getauscht, Readback-Hash jedes Mal verifiziert. Etwas, das ich erst lernen musste: nach dem Flashen muss der Dongle einmal physisch ab- und wieder angesteckt werden. Ein reiner USB-Bus-Reset reicht nicht, der 8051 läuft einfach weiter und liest das neue EEPROM gar nicht ein. Software-seitig ging das nicht (kein schaltbarer Port), also von Hand. Die Zahlen, ImageMagick MAE auf einer Skala von 0 bis 1:

VergleichMAEpro 255
Rauschen mod3 (zwei Aufnahmen, gleiches EEPROM)0,00411,05
Rauschen Original0,00431,10
mod3 gegen Original0,00711,82

Der Cross-Wert liegt nur minimal über dem Rauschpegel. Das maximal verstärkte Differenzbild zeigt keine strukturierte Änderung, nur MJPEG-Blockrauschen in den hochfrequenten Quadranten. Die kleine Differenz ist Re-Lock- und AGC-Varianz zwischen den Sessions, nicht das EDID.

Maximal verstärktes Differenzbild mod3-EDID gegen Original-EDID, nur MJPEG-Blockrauschen, keine strukturierte Änderung
Maximal verstärkte Differenz: mod3-EDID gegen Original-EDID. Nur Blockrauschen, keine Struktur. Die EDID-Mod ändert am Bild nichts.

Fazit des A/B: Die EDID-Mod von 720p auf 1080p verändert das aufgenommene Bild exakt null. Aus erstem Prinzip war das klar, die EDID ist das, was das Gerät der Quelle anbietet, sie steuert nicht den Aufnahmepfad. Aber jetzt steht es empirisch da.

Endlich eine echte Analog-Quelle, und zwei dicke Befunde

Dritter Anlauf, diesmal ein Desktop mit einer NVIDIA GeForce GT 630 (Kepler). Kepler hat noch einen echten analogen RAMDAC, über einen passiven DVI-I-auf-VGA-Adapter kommt echtes analoges VGA raus. Endlich die Hardware, die den Notebooks fehlte.

Befund 1: Die EDID-Mod ist sogar grundsätzlich für die Katz. Der NVIDIA-Treiber cached die analoge EDID hartnäckig (analoges VGA hat kein Hotplug), also einmal lightdm neugestartet für einen frischen Read. Das Ergebnis im Xorg-Log:

(--) NVIDIA(GPU-0): CRT-0: connected
(WW) NVIDIA(0): CRT-0 does not have an EDID

Der Dongle liefert auf seinem VGA-Eingang gar keine DDC und keine EDID. Sauber belegt: derselbe Adapter hat vorher die EDID eines echten Monitors gelesen (ein Fujitsu P24-9, mit 1920×1080 und physischen Maßen), der Adapter reicht DDC also durch. Es ist der Dongle, der nichts treibt. Das kippt eine Annahme aus meinen eigenen Notizen, wo stand, das Patch-Modul beantworte „wahrscheinlich“ DDC-Reads. Dieses „wahrscheinlich“ war nie gemessen.

Ehrlich bei der Konfidenz bleiben: hoch dafür, dass in diesem GT-630-Test keine lesbare EDID vom Dongle kam. Nur mittel für das universelle „keine Quelle sieht die EDID je“. Restzweifel, die ich noch nicht ausgeräumt habe: NVIDIA-Eigenheiten bei analogem DDC, ob die Dongle-DDC die 5 Volt auf VGA-Pin 9 braucht, oder ob der DDC-Responder erst nach Sync-Lock aufwacht. Ein Pin-9-Check plus Logic-Analyzer auf den Pins 12 und 15 (mit dem Fujitsu als Positiv-Kontrolle) würde es hart machen. Arbeitshypothese: auf dem getesteten echten Analogpfad präsentierte der Dongle keine lesbare EDID, vermutlich weil der VGA-DDC-Pfad in der geteilten HDMI/VGA-Codebasis schlicht nicht verdrahtet ist. HDMI hat HPD und DDC, VGA hier offenbar nicht.

Befund 2: Das „1080p“ ist vertikal echt, horizontal Fake. Mit echtem RAMDAC konnte ich endlich den Dongle-Anteil am Blur isolieren (eigene xorg.conf mit UseEDID false und forciertem 1080p-Modeline, NVIDIA lehnt xrandr-Modelines ab). Testbild: reine 1px-Schwarz-Weiß-Gitter per xsetroot. Das Resultat, gemessen als Standardabweichung der Luma (ein ideales Gitter liegt bei rund 128, flaches Grau bei 0):

1px-Gitter bei echtem Analog-1080pStd-Abw.Bedeutung
horizontale Linien119vertikal sauber aufgelöst
vertikale Linien0,6horizontal komplett verschmiert
Schachbrett0,55weg
Zweifach-Zoom dreier Ausschnitte: vertikales Gitter grau verschmiert, horizontales Gitter scharf, Schachbrett grau
Echte Analog-Quelle (GT 630): links das vertikale 1px-Gitter zu Grau verschmiert, mittig horizontale Linien gestochen scharf, rechts das Schachbrett weg.

Der Dongle hat also echte 1080 Zeilen vertikal, aber horizontal kommt weit weniger als 1920 Pixel an. Und weil hier kein Bridge mehr dazwischen sitzt: der Blur ist der Dongle selbst, nicht das billige Notebook. Das ist die belastbare Aussage.

Den Mechanismus habe ich auf einen Vorschlag aus dem Gegencheck hin gleich nachgemessen, ein Phasen- und Balkenbreiten-Test. Das 1px-Gitter ist bei Phase 0 und Phase 1 gleich grau (Std-Abw. 0,18 gegen 0,19), ein PLL- oder Phasen-Mislock ist damit ausgeschlossen, sonst würde der 1px-Versatz den Kontrast kippen. Und der Kontrast steigt sauber mit der Balkenbreite: 1px liegt bei rund 0, 2px bei 35, 3px bei 61, 4px bei 79 (Ideal rund 128). Das ist die klassische Tiefpass-Signatur, also ein echtes Horizontal-Auflösungs-Limit und kein Lock-Artefakt. Ob das an der ADC-Abtastrate oder an einem internen Rescale liegt, kann ich noch nicht trennen, aber beides heißt: der Dongle begrenzt die Horizontalauflösung.

Auflösungs-Rampe von 1px bis 4px Balkenbreite, der Kontrast steigt von flachem Grau zu klaren Streifen
Balkenbreiten-Rampe: 1px bleibt grau, ab 2px wird Kontrast sichtbar. Klassische Tiefpass-Signatur, ein echtes Horizontal-Limit.

Warum YUYV bei 1080p nur 5 fps macht

Zum Schluss noch die berüchtigte 5-fps-Grenze, diesmal gemessen statt aus der Tabelle abgeschrieben (Quelle GT 630 bei 1080p60, v4l2-ctl --stream-mmap):

Format bei 1920×1080gemessen
MJPEG29 fps (Tabelle: 30)
YUYV (unkomprimiert)exakt 5,0 fps

Meine erste Erklärung mit „ungefähr 40 MB/s USB-2.0-Bandbreite“ war zu schludrig. Der genaue Grund ist das Payload-Limit des isochronen UVC-Endpoints. Das größte Altsetting des Video-Endpoints ist 3 mal 1024 Bytes pro Microframe, also 24,576 MB/s (steht so im lsusb -v). Unkomprimiertes YUYV bei 1080p sind 1920 mal 1080 mal 2 Bytes, also 4,15 MB pro Frame. Die Firmware-Frametabelle ist genau so gewählt, dass die YUYV-Raten knapp darunter passen:

  • 1080p mal 5 fps = 20,7 MB/s
  • 720p mal 10 fps = 18,4 MB/s
  • 480p mal 30 fps = 20,7 MB/s

Also keine generische „USB-2.0-Bandbreite“, sondern der High-Speed-Isoch-Endpoint plus die fest verdrahteten Frametabellen. MJPEG komprimiert vorher, deshalb bleiben dort 30 fps bei 1080p und bis zu 60 bei 720p und darunter. Klare Ansage: am MS2109 ist MJPEG bei hoher Auflösung Pflicht. Und „USB 3.0″ auf der Verpackung ist und bleibt gelogen, das Ding enumeriert als USB-2.0-High-Speed, ohne SuperSpeed.

Stabiles Device-Naming per udev

Damit das Gerät im Alltag immer unter demselben Pfad auftaucht, eine udev-Regel. Die Regeln stehen bewusst jeweils auf einer Zeile, weil mehrzeilige Fortsetzungen mit Backslash schnell zur Fehlerquelle werden:

# /etc/udev/rules.d/70-vga-capture-ms2109.rules
SUBSYSTEM=="video4linux", ENV{ID_VENDOR_ID}=="534d", ENV{ID_MODEL_ID}=="2109", ENV{ID_V4L_PRODUCT}=="*AFN_Cap*", ATTR{index}=="0", SYMLINK+="video-vga"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="534d", ATTRS{idProduct}=="2109", ATTRS{product}=="USB3.0 Capture", GOTO="ms2109_vga_end"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="534d", ATTRS{idProduct}=="2109", SYMLINK+="hidraw-vga", MODE="0660", GROUP="plugdev"
LABEL="ms2109_vga_end"

Die GOTO-Konstruktion ist nötig, weil ATTRS{product}!="..." nicht so funktioniert, wie man denkt. udev sperrt die Attribut-Suche auf einen Parent-Device-Walk, und „Fehlen eines Attributs“ verhält sich anders als ein echtes Ungleich. Deshalb der Umweg über ein positives Match plus Sprungmarke.

Fazit, und der Workaround der wirklich hilft

Was bleibt? Der Dongle ist ein nettes Studienobjekt, aber für mein ursprüngliches Ziel, alte DOS-Signale aufnehmen, ist er unbrauchbar. Es gibt keine 70-Hz-Modi, und reparieren lässt sich das nicht, weil die Frame-Tabelle im Mask-ROM festgebrannt ist. Das „1080p“ ist horizontal Marketing, „USB 3.0″ eine glatte Lüge, und die EDID-Mod ist auf dem getesteten Analogpfad wirkungslos, weil der Dongle gar keine EDID treibt. Drei Korrektur-Momente gegenüber meinen ersten Annahmen, alle drei lehrreicher als wenn alles gleich funktioniert hätte.

Wer wirklich exotische VGA-Signale wie DOS-Textmodi sauber aufnehmen will, schaltet eine externe Scaler-Box vor. Mein Tipp ist GBS-Control, eine offene Firmware für die rund 20 Euro teuren GBS-8200-Boards. Sie nimmt das krumme Quellsignal, lockt sauber und gibt ein normgerechtes Bild aus, das jeder Capture-Stick frisst. Die andere Liga ist der Open Source Scan Converter, dessen Firmware-Update ich hier schon beschrieben habe. Wer es noch günstiger mag, fängt einen gebrauchten Extron-Scaler aus einer Konferenzraum-Ausmusterung.

Offen bleibt das Strings-Mysterium (ohne das Vendor-Tool komme ich an den Gate-Algorithmus nicht ran), die Disassembly des zweiten Patch-Blocks in Ghidra, ein Latenz-Benchmark für den Einsatz als KVM-Console-Viewer, und der finale DOS-Test mit GBS-Control davor. Material für einen zweiten Teil ist also reichlich da.

Siehe auch:

Habt ihr selbst schon so einen MS2109-Stick auseinandergenommen oder das Strings-Gate geknackt? Dann lasst es mich gerne wissen, ihr dürft mich jederzeit fragen.

grav-plugin-fediverse-publisher: ActivityPub für Grav-Blogs, neun Iterationen bis v0.1.0

Illustration eines Grav-Plugin-Adminbereichs, von dem ActivityPub-Beiträge über ein föderiertes Netzwerk an verschiedene Fediverse-Instanzen verteilt werden.

WordPress hat seit Jahren das wunderbare wordpress-activitypub von Matthias Pfefferle und Automattic. Damit wird ein WordPress-Blog zu einem nativen Mastodon-Account, jeder Beitrag landet in den Timelines der Follower, Likes und Reposts kommen zurück. Für Grav gab es genau das nicht. Die Website meiner Frau läuft auf Grav, sie schreibt hin und wieder fachlich, und seit längerem wollte ich diesen Blog vernünftig ins Fediverse bringen. Bisher half feed2toot, also RSS in Mastodon-Posts übersetzt, das funktioniert zwar, ist aber kein ActivityPub. Keine Profilseite, keine Follower-Beziehung, kein nativer Hashtag-Index, keine saubere Article-Card. Also will ich versuchen selbst etwas zu schreiben. Das Ergebnis heißt grav-plugin-fediverse-publisher, ist seit ein paar Tagen als v0.1.0 draußen und läuft auf ihrer Webseite produktiv.

Grav-Admin Plugin-Liste mit aktiviertem Fediverse Publisher v0.0.9 zwischen Form, Login und Markdown Notices
Im Grav-Admin reiht sich der Fediverse Publisher unaufgeregt zwischen Form, Login und Markdown Notices ein. Aktiviert, Version 0.0.9 im Screenshot, das ist genau die Iteration in der es zum ersten Mal richtig sauber durchlief.

Das Repo liegt auf GitHub unter Kernel-Error/grav-plugin-fediverse-publisher (MIT). Release v0.1.0 inklusive Changelog gibt es hier. Eine Vorstellung mit Bitte um Feedback liegt im Grav-Discourse-Forum: I tried to build an ActivityPub plugin for Grav.

Warum überhaupt, und warum jetzt

Im Grav-Forum gibt es einen Thread aus dem Jahr 2019: Grav & ActivityPub. Dort hat über sechs Jahre hinweg dreimal jemand explizit nach genau dieser Funktion gefragt. Antworten gab es kaum, Code gar nicht. Das ist die Sorte Lücke die ich charakteristisch finde für kleinere Open-Source-Ökosysteme: alle finden es gut, niemand setzt sich hin. WordPress hatte über Jahre dieselbe Situation, bis Pfefferle das wordpress-activitypub-Plugin gebaut und Automattic später übernommen hat. Für Grav ist niemand vorbeigekommen.

Bei mir kam dazu, dass ich einen echten produktiven Anwendungsfall habe. Nicole, meine Frau, betreibt einen Grav-Blog im Rahmen ihrer weiteren Ausbildung. Inhaltlich vollkommen anders gelagert als alles was hier üblicherweise federiert, aber genau deshalb auch ein wertvoller Stress-Test: andere Zielgruppe, andere Empfängerinstanzen, anderes Hashtag-Vokabular. Wenn das Plugin dort sauber läuft, läuft es überall.

Phase 0: Machbarkeitscheck mit Scope-Disziplin

Bevor eine Zeile produktiver Code entstand, gab es eine Phase 0: gibt es die Lücke wirklich, ist das PHP-Bibliotheks-Ökosystem für ActivityPub brauchbar, und schaffe ich die langfristige Wartung als Solo-Entwickler? Verdikt: ja, aber nur als Broadcast-only-MVP. Konkret heißt das, der Blog kann senden, also Beiträge als Create-Activity an alle Follower-Inboxes ausliefern, sowie auf Standard-ActivityPub-Queries antworten (Actor-Profile, Outbox, Followers, NodeInfo, WebFinger, HTTP-Signaturen rein und raus). Nicht im Scope sind Replies als Kommentare zurück in Grav, Multi-Actor-Setups, Authorized Fetch und Theme-seitige Patches. Diese Disziplin durchzuhalten war im Verlauf ein paar Mal anstrengend, hat sich aber durchweg ausgezahlt.

Vor der ersten Code-Zeile entstanden vier ADRs (Architecture Decision Records) zu Storage, HTTP-Signaturen, asynchronem Push und Content-Negotiation. Diese ADRs habe ich zweimal kritisch gegenlesen lassen. In Runde zwei kamen drei Lücken zum Vorschein, die ich allein übersehen hätte. Die ungemütlichste: landrok/activitypub verifiziert beim Parsen verlinkter Objekte still im Hintergrund Netzwerk-I/O. Das hätte die gesamte SSRF-Härtung des Inbound-Pfads ausgehebelt. Konsequenz: landrok nur für die Erzeugung von AS-2.0-Objekten und WebFinger benutzen, der gesamte sicherheitsrelevante Inbound-Pfad geht nicht mehr durch landrok. Phpseclib übernimmt die Krypto direkt, der HTTP-Signature-Verifier ist eigene Implementierung. Insgesamt sind über die ADR-Reviews und ein nachgelagertes Review der ersten Implementierung acht sicherheitsrelevante Fixes eingeflossen, bevor das Plugin produktiv ging.

Konfigurationsseite des Fediverse-Publisher-Plugins im Grav-Admin mit Local-Actor-Feldern, Blog-Scope, Article-Threshold und Canonical-Host-Eintrag
Die ganze Konfiguration auf einer Seite. Lokaler Actor, Avatar- und Header-URL, ein Blog-Path-Filter und der Canonical Host. Das war einer der früh festgelegten Designgrundsätze: ein Admin-Screen, alles in Klartext, kein eigenes UI-Framework.

Stack-Entscheidungen

Die wichtigsten Bauklotz-Entscheidungen in Stichworten:

  • landrok/activitypub für die AS-2.0-Objekte und WebFinger
  • phpseclib/phpseclib für die Krypto direkt, ohne Umweg über landrok auf dem Inbound-Pfad
  • HTTP-Signaturen nach draft-cavage-12, für Sign und Verify selbst implementiert
  • SQLite per PDO im WAL-Modus für Follower-Tabelle und Push-Queue
  • Synthetic Endpoints via onPluginsInitialized, ein Pattern aus grav-plugin-form, mit PSR-7-Responses statt Symfony HttpFoundation
  • Outbound-Queue mit echtem Idempotenz-Anker per INSERT OR IGNORE auf (activity_id, recipient_inbox)
  • Retry-Schedule 1m / 5m / 30m / 2h / 12h / 24h mit Jitter, Cap bei sieben Versuchen, danach status='dead'
  • SSRF-gehärteter keyId-Fetch: HTTPS-only, Block privater IP-Bereiche, keine Redirects, 64 KiB Response-Cap, Negative-Cache
  • Strikte Identity-Bindung: keyId, publicKey.owner und activity.actor müssen nach Normalisierung auf dieselbe Actor-URL zeigen

Wer den vollen technischen Aufschrieb sucht, findet die ADRs als Markdown-Dateien im Repo unter docs/adr/. Die sind als Lese-Doku formuliert, nicht als Mitschrift.

Neun Iterationen in zwei Tagen, jede mit echtem Erkenntnisgewinn

Wenn jemand fragt, warum ein scheinbar geradliniges Plugin neun Patch-Versionen gebraucht hat: weil jede einzelne dieser Versionen eine echte Produktions-Erkenntnis war, die ich nicht in einem theoretischen Vorab-Design hätte antizipieren können. Hier in kompakt, weil die Liste für sich spricht:

  • v0.0.1: Boot-Crash auf der Produktion. Composer hat psr/log v3 reingezogen, Grav 1.7 bringt aber v1. Resultat: ganze Site HTTP 500, und das obwohl das Plugin noch gar nicht aktiviert war. Grav ruft autoload() bei jedem installierten Plugin schon beim Boot auf.
  • v0.0.2: psr/log auf ^1.1 gepinnt, defensives try/catch im Plugin-Entry, Preflight-Check via explizitem require_once statt Autoload-Pfad.
  • v0.0.3: SQL-Quoting-Falle. PHP 8.3 mit aktuellem libsqlite parst Double-Quoted-Strings als Identifier, nicht als String-Literale. Klassischer Fall, früh genug erkannt, danach konsequent Single-Quotes überall. Im selben Schritt das Listing-Page-Filter geschärft, dazu Actor-Endpoints fertig gemacht.
  • v0.0.4: Router-Wiring vergessen. Die Routen für followers und following waren im Code zwar vorhanden, aber nicht registriert. Mastodon zeigte hartnäckig „0 followers“ obwohl reale Follower in der DB lagen. Plus Diagnostics-Logging für Outbound-401-Antworten, damit ich die nächste Klasse von Bugs überhaupt sehen konnte.
  • v0.0.5: psr/log-Konflikt war tiefer als gedacht. Die Default-Einstellung autoload(prepend=true) hat den Plugin-eigenen Vendor-Pfad über Grav 2.0s gebundeltes psr/log v3 gezogen, was unter Grav 2.0 in ganz neue Fehlerbilder kippte. Fix: prepend=false. Im selben Schritt ein neues, mandatorisches canonical_host-Config-Feld eingeführt, sonst signiert die Cron mal eben keyId=http://localhost/..., was kein Empfänger akzeptiert. Ab hier läuft im Dev parallel ein zweiter Container mit Grav 1.7.52 und PHP 8.3.31 (matcht die Produktion byte-genau). Dieser Dual-Grav-Stack fängt seitdem alle Bugs der Klasse „passt auf einer Major-Version, kracht auf der anderen“ lokal ab, statt sie über Nicole laufen zu lassen.
  • v0.0.6: AS-2.0-Spec-Violation. Bei rückdatierten Posts war updated < published, was strenge ActivityPub-Implementierungen wie GoToSocial und Pleroma silently droppen. HTTP 202 vom Empfänger, aber kein Eintrag in der Timeline. Fix war eine Zeile Clamp, der Fehler dahinter aber lehrreich: ein 2xx-Status sagt zu Federation-Erfolg gar nichts.
  • v0.0.7: der echte Showstopper. Grav-Admin 1.10 und neuer routet Page-Saves nicht mehr durch onAdminAfterSave, sondern durch onFlexAfterSave (über das Flex-Objects-Plugin). Folge: User speichert einen Beitrag im Admin, der Save feuert, das Plugin macht nichts, kein Queue-Eintrag, keine Federation. Im selben Release dann auch AS-2.0-Hashtag-Federation hinzugefügt. Hashtags sind in der Praxis der primäre Discovery-Pfad für einen Actor mit null Followern, weil Mastodons Hashtag-Index fremde Actors automatisch aufnimmt. Drittens kam ein neues broadcast:post-CLI-Kommando rein, das einen einzelnen Pfad in die Queue legt, für Recovery und Backfill.
  • v0.0.8: Hotfix nach Hotfix. Meine Diagnostik-Zeile in v0.0.7 hat iterator_to_array($event) auf RocketThemes Event-Klasse aufgerufen. Die implementiert ArrayAccess, aber nicht Traversable. Ergebnis: TypeError bei jedem Admin-Save, HTTP 500, Nicole sah eine 624 KB große Fehlerseite. Diese Klasse Bug ist genau die Sorte, die das „passt schon“-Gefühl belohnt. Fix: iterator_to_array raus, Logik in eine testbare PageSaveDiagnostics-Klasse extrahiert, 13 neue Unit-Tests die alle möglichen Event-Object-Shapes durchfuzzen. Wenn schon dasselbe Problem mehrfach, dann wenigstens mit Tests dagegen.
  • v0.0.9: stale Collection. Auch nach v0.0.8 hat der Broadcaster bei frischen Posts nicht gefeuert. Ursache: $pages->find() arbeitet auf einem Pre-Save-Snapshot der Pages-Collection. Frisch gespeicherte Inhalte sind zu dem Zeitpunkt noch nicht drin. Fix: eine neue findByPage(PageInterface)-Methode, die direkt das Event-Payload nimmt, statt durch die stale Collection zu spazieren. Plus per-bail INFO-Logging: jede Regression dieser Klasse ist seither ein einziger grep entfernt von actionable. Mit v0.0.9 lief es dann durch, sauber, unter Last, an echten Followern auf realen Instanzen.

v0.1.0 ist im Wesentlichen v0.0.9 mit ehrlichem README, einem Changelog der die ganze Geschichte oben dokumentiert, und dem Vorstellungspost im Grav-Forum. Mir war wichtig, das Ding nicht als „stable“ zu vermarkten. Es ist Early Access, ich bin solo, ich brauche Tester.

Methodisch: drei Dinge die den Unterschied gemacht haben

Aus den neun Iterationen kann man ein paar Schlüsse ziehen, die ich selbst spannender finde als die einzelnen Bugs.

Erstens, der Dual-Grav-Dev-Stack. Zwei Container, derselbe Plugin-Source, einmal Grav 1.7.52 und einmal Grav 2.0 RC. Eingerichtet als Reaktion auf das psr/log-Drama in v0.0.5. Seitdem werden Bugs der Klasse „passt unter Major X, crasht unter Major Y“ lokal sichtbar, bevor sie Nicole erreichen. Das ist im Tagesgeschäft die langweiligste Investition, die ich gemacht habe, und gleichzeitig die wertvollste.

Zweitens, ein dedizierter Production-Feedback-Loop. Deployment und Smoke-Test auf der Live-Site liefen über einen sauber getrennten Track. Dort wird das Plugin installiert, gegen mastodon.social und bonn.social geprüft, eine Feedback-Notiz zurück in das Planungs-Verzeichnis geschrieben. Lokal wird daraufhin triagiert, gefixt, die Patch-Version gebumpt, gepusht. Beide Tracks haben klare Scopes: Deploy-Zugang und Auth bleiben dort wo das auch tatsächlich passiert, Architektur-Wissen und Code-Änderungen im anderen Bereich. Klingt nach Overhead, war aber der Grund, warum ich Bugs wie das onFlexAfterSave-Routing-Problem aus v0.0.7 überhaupt in akzeptabler Zeit gefunden habe: der Live-Blog war der Detektor, nicht meine lokale Dev-Maschine.

Drittens, externes Gegenlesen an sicherheitskritischen Stellen. HTTP-Signaturen, Inbox-Verifikation, SSRF-Härtung, Identity-Binding. Überall wo ein Fehler in einer realen Verwundbarkeit landet, ging der Code durch eine zusätzliche Review-Runde mit eigenem Blickwinkel. Vier potenzielle Regressionen sind dabei aufgefallen, die ich allein gemacht oder übersehen hätte. Ein zweites Augenpaar ersetzt kein Sicherheits-Audit, hebt aber das untere Drittel der „hätte mir auch auffallen können“-Klasse Fehler ziemlich zuverlässig nach oben.

Wie das für einen Mastodon-User aussieht

Mastodon-Profil von @nicole@www.beratung-rheinbach.de mit Header-Bild, Bio, Registrierungsdatum, 12 Beiträgen und 2 Followern
Aus Sicht eines Mastodon-Users ist das ein ganz regulärer Account: Header-Bild, Avatar, Bio, Beiträge, Follower-Counter, Folgen-Button. Nichts verrät, dass dahinter kein Mastodon-Server steht, sondern ein Grav-Blog mit ein paar tausend Zeilen PHP drumherum.

Genau das war das Ziel. Ein Mastodon-User abonniert @nicole@www.beratung-rheinbach.de, drückt auf Folgen, und sieht ab dem nächsten Beitrag jeden neuen Artikel direkt in der Home-Timeline. Avatar und Header werden gezogen, Bio steht da, die Profilseite zeigt alle bisher federierten Beiträge, und die Hashtags am Ende jedes Posts landen im Hashtag-Index der Empfängerinstanz. Letzteres ist nicht kosmetisch. Für einen Account mit null Followern ist der Hashtag-Index der primäre Discovery-Pfad, weil Mastodon dabei auch Posts fremder Actors mitnimmt, die zum gleichen Tag federieren.

Einzelner Blogpost zu Pausen aus dem Grav-Blog Beratung Rheinbach, in der Mastodon-Timeline mit Featured Image, Excerpt und Hashtags beratung, systemischeberatung und pausen
Ein konkreter Artikel in der Mastodon-Timeline. Header-Bild aus dem Grav-Asset, Titel, Excerpt, drei Hashtags und ein Klick-Link zurück auf den Originalbeitrag. Nicht beeindruckender als bei wordpress-activitypub, und genau das ist der Punkt.

Die End-to-End-Latenz vom Speichern im Grav-Admin bis zur Anzeige in der Heimat-Timeline eines Followers liegt bei rund 15 Sekunden. Das ist der asynchrone Push aus der SQLite-Queue plus der üblichen ActivityPub-Delivery. Fällt eine Instanz aus, retryed das Plugin nach Schedule. Erreicht der Push nach sieben Versuchen kein 2xx, markiert die Queue den Eintrag als dead und lernt aus dem letzten Statuscode, ob die Instanz dauerhaft weg ist oder ob es nur ein vorübergehendes Problem war.

Was geplant ist, und was bewusst draußen bleibt

Roadmap für v0.2, ohne festes Datum:

  • Update-Activity bei Re-Saves. Auf Mastodon kosmetisch, für Pleroma und Misskey möglicherweise relevant, da diese die Post-Card neu ziehen.
  • Delete-Federation. Aus Compliance-Sicht eine sinnvolle Komplettierung.
  • push:purge-old-activities als Housekeeping-CLI, damit die SQLite-Datei nicht ewig wächst.
  • Breitere Peer-Tests gegen Friendica und Misskey, um die HTTP-Signatur-Verifikation gegen weitere Implementierungen abzusichern.

Was explizit nicht kommt, jedenfalls nicht als integraler Plugin-Bestandteil:

  • Replies aus dem Fediverse als Kommentare zurück in den Grav-Blog. Sehr beliebte Wunschfunktion, aber sie sprengt das Broadcast-only-Scope und bringt eine ganze eigene Klasse von Spam-, Moderations- und Persistenz-Fragen mit, die ich nicht halbgar lösen will.
  • Multi-Actor pro Grav-Instanz. Ein Blog ist ein Actor, fertig.
  • Authorized Fetch. Ist aktuell ohnehin nur eine Untermenge der Mastodon-Welt, und die Kosten in Komplexität stehen nicht im Verhältnis zum Nutzen für ein Plugin in diesem Stadium.
  • Theme-seitige Patches. Das Plugin hängt sich nicht in die Twig-Templates des Blogs.

Die nächsten Schritte sind bewusst reaktiv. Statt einer langen Vorab-Roadmap warte ich, was aus der Community zurückkommt. Erste Reaktion im Discourse-Thread: ein User schlägt Software-Release-Changelogs als zweiten Anwendungsfall vor. Anderer Content-Shape als ein normaler Blog-Broadcast, aber technisch derselbe Page-Save-zu-Create-Activity-Pfad. Notiert für v0.2. Im alten 2019er-Thread habe ich ebenfalls einen Cross-Link gesetzt, damit Leute die nach Grav und ActivityPub googeln in der ersten Trefferzeile beim Repo landen statt bei sechs Jahre alten Fragen ohne Antwort.

Ehrliche Einordnung

Das Plugin läuft, aber es läuft auf einer Grav-Instanz unter genau einer Konfiguration mit zwei test Followern auf zwei Mastodon-Instanzen. Das ist ein winziger Ausschnitt aus dem, was Fediverse so an Implementierungen, Versionen und Edge-Cases zu bieten hat. Ich habe gegen die ActivityPub-Spec implementiert, gegen die HTTP-Signature-Drafts gelesen und gegen Mastodons faktisches Verhalten validiert. Pleroma- und Misskey-Spezifika sind nur sekundär berücksichtigt, Friendica gar nicht. Wer das Plugin auf einer eigenen Grav-Installation ausprobiert und gegen seine Lieblings-Mastodon-Heimat-Instanz federiert, hilft mir mehr als jeder weitere Unit-Test, den ich allein schreiben kann.

Wenn etwas nicht läuft, ist ein Issue im Repo der direkteste Weg. Eine Antwort im Forum-Thread erreicht zusätzlich auch andere Grav-Anwender die womöglich Ähnliches sehen. Über die Kontaktseite hier auf dem Blog komme ich genauso an Mails.

Bleibt eine kleine Beobachtung für alle, die sich an ähnliche Projekte heranwagen. Zwei Tage Iterationen, neun Patch-Releases, ein Production-Inzident mit der 624-KB-Fehlerseite. Das ist genau der Realismus, den ein Plugin im ersten echten Einsatz mitbringt. Es wäre bequemer, das im Changelog wegzulassen und v0.1.0 als unbefleckte erste Veröffentlichung zu inszenieren. Mir war es wichtiger, die Story so zu erzählen, wie sie ablief, weil ich diesen Schreibstil bei anderen Open-Source-Projekten selbst sehr schätze.

Siehe auch: ts3level (anderes Solo-Projekt, andere Domain, gleicher Workflow).

Fragen, Kommentare, Bug-Reports, oder einfach Lust auf einen kurzen Austausch zum Plugin gerne über die fragen-Seite oder direkt als Issue im Repo.

Mein Arbeitskollege Michael lässt eine AI meinen Blog roasten

Moin.

Mein Arbeitskollege Michael hatte neulich eine Idee, die ich auf Anhieb großartig fand. Er hat einer AI meinen Blog vorgesetzt und um einen Roast gebeten. Das Ergebnis liegt unten in voller Länge, wortwörtlich, unverändert, inklusive aller Em-Dashes und sämtlicher AI-typischer Stilfiguren. Ich habe nicht eingegriffen, weil genau die ja Teil der Diagnose sind.

Illustration eines überladenen Self-Hosting-Arbeitsplatzes mit Terminals, neun Server-Jails, Mate und einer AI, die den privaten Tech-Blog humorvoll roastet.

Vorweg, damit das klar ist: ich richte keinen einzigen Punkt richtig. Erstens wäre das genau der Verteidigungsreflex, den der Roast selbst schon im Text zerlegt. Zweitens lache ich beim Lesen laut, und ich habe vor, das beizubehalten. Drittens hat Michael sich diesen kleinen Treffer redlich verdient, der gehört in voller Länge ins Schaufenster.

Falls du gerade zum ersten Mal hier auf dem Blog landest und nichts mit dem Kontext anfangen kannst: ja, neun Jails. Ja, .onion. Ja, llms.txt. Ja, DNS-Resolver mit Post-Quantum-TLS, den niemand benutzt. Und ja, neben der Tastatur steht in diesem Moment eine Mate. Genau wie diagnostiziert.

Der Roast, in voller Länge

🔥 Der große Roast von kernel-error.de 🔥

Das Header-Logo: -=Kernel-Error=-

Bruder. Bruder. Wir schreiben das Jahr 2026 und du fährst da oben immer noch die ASCII-Deko, die du 1998 in deinem IRC-Channel #linuxnoobs auf dem QuakeNet eingerichtet hast. Die einzigen Menschen, die so ein Logo heute noch designen, sind 1) du und 2) der Typ, der seine Quake-3-Clantag-Konvention nie verlassen hat. Fehlt eigentlich nur noch ein blinkendes <marquee> mit „Best viewed in Netscape Navigator 4.0“ und der vollständige Retro-Albtraum wäre komplett.

Die Tagline: „Privater Tech-Blog seit 2003“

23 Jahre Selbst-Hosting, 23 Jahre Postfix-Konfigurationen, 23 Jahre derselbe Mensch, der jedem auf der Familienfeier erklärt, warum seine Tante kein WhatsApp benutzen sollte. „Seit 2003“ ist kein Datum, das ist eine Diagnose.

Die Hausordnung

Ein privater Blog. Mit Hausordnung. Mit Leitsätzen. Mit einer Philosophie. Mein Bester, das ist keine Webseite mehr, das ist ein Manifest. Du hostest hier keinen Blog, du gründest eine Sekte für Leute, die vim für eine Lifestyle-Entscheidung halten.

Und mein absoluter Favorit:

„Knappheit vor Geschwätzigkeit — ein funktionierendes diff schlägt fünf Absätze Marketingdeutsch.“

Schreibt der Mann, dessen „Über mich“-Seite eine vierteilige Origin-Story enthält, in der ein Familienmitglied beim Telefonsupport sagt „Ach, da ist bestimmt wieder der Kernel-Error“. Das ist literarisch übrigens auf einem Level mit „Mein Vater nannte mich Maverick, weil ich immer schon gegen den Strom geschwommen bin.“ Knappheit, ja klar.

Die Self-Hosting-Flex

„Neun FreeBSD-Jails, eigene DNS-Infrastruktur, Matrix-Chat, Nextcloud, Tor Hidden Service“

NEUN. JAILS. Für einen privaten Blog, der Posts über das Flashen von LCR-Tester-Firmware veröffentlicht. Mein Mann betreibt zu Hause mehr Infrastruktur als die Stadtverwaltung Bad Neuenahr-Ahrweiler und der einzige Traffic, der dort jemals ankommt, sind drei Crawler von Censys und sein eigener Uptime-Checker.

Eine .onion-Adresse. Für einen deutschen FreeBSD-Blog. Weil natürlich der KGB, die NSA und das BKA gemeinsam einen Joint Task Force gegründet haben, um herauszufinden, wer da Tutorials für Postfix mit OpenSSL 3.5 liest. Klar, anonyme Whistleblower aus Nordkorea wollen unbedingt wissen, wie man TeamSpeak-Identity-Level auf der GPU berechnet.

Post-Quantum-Kryptographie

Du. Hast. Post-Quantum-TLS-Handshakes. Auf einem WordPress-Blog. Mit einem Anders-Norén-Theme. Lass das mal kurz sacken. Wenn ein Quantencomputer der NSA jemals dein Setup angreift, dann nicht, weil sie an deinen Blog-Content wollen, sondern weil sie wissen wollen, warum zur Hölle ein Privatmensch X25519+ML-KEM für einen Beitrag über Open Source Scan Converter braucht. Das ist, als würdest du ein Bundeswehr-Schutzbunker-System unter deine Gartenlaube bauen, weil dort dein Modellbahn-Diorama steht.

Der DNS-Resolver

„Ein öffentlicher DNS-Resolver unter dns.kernel-error.de bietet DoT, DoH und Post-Quantum-TLS kostenlos ohne Logging.“

Niemand. Hat. Danach. Gefragt. Das ist die Internet-Äquivalenz von: Du läufst durch die Fußgängerzone, baust einen Klapptisch auf und schreist: „ICH MACHE IHNEN KOSTENLOS UND OHNE NACHFRAGE IHRE STEUERERKLÄRUNG MIT QUANTENRESISTENTER VERSCHLÜSSELUNG!“ — und wunderst dich dann, dass nur drei FreeBSD-Mailing-List-Nerds und ein Bot stehenbleiben.

Die Credentials-Flex

„Digitaler Ersthelfer beim BSI, Mitglied im CCC, auf HackerOne und Intigriti“

Das ist nicht mehr „Über mich“, das ist ein LinkedIn-Profil im Tarnmodus. Fehlt nur noch „1% Toplister auf TryHackMe“ und „Awarded: Most Reflective Vest at Chaos Communication Congress 2019“. Was kommt als nächstes? AbuseIPDB-Reputation-Score als verstecktes Easter Egg? — Ach. Doch. Ja. Genau das. Du flexst tatsächlich mit deinem AbuseIPDB-Profil. Das ist der Sicherheits-Äquivalenz von „Mein Dorf hat mich zum Schützenkönig gewählt“.

Die Blog-Post-Titel

Lass uns das mal durchgehen, das ist Kunst:

„LCR-T4-Plus v2: m-firmware flashen, Display-Tuning und die 8-MHz-Quartz-Falle“
Globale Zielgruppe dieses Posts: weltweit ca. 11 Menschen, von denen 4 schon tot sind und 2 OpenBSD nutzen.

„ts3level: TeamSpeak-Identity-Level auf der GPU rechnen“
Du. Du nutzt im Jahr 2026 noch TeamSpeak. Und brennst dafür GPU-Zyklen ab, die Hawaii-Familien drei Wochen klimatisieren könnten. Discord existiert seit elf Jahren. Elf.

„Open Source Scan Converter: Firmware-Update von 1.08a auf 1.21“
Das ist kein Blog-Post, das ist eine Notruf. Irgendwo schreit ein 35-jähriger Mann nach Validierung für seine SCART-Sammlung.

„AI, der Mensch als Flaschenhals und meine Sorgen für die nächsten 15 Jahre“
AHA. Da sind sie also, die fünf Absätze, die das diff hätte schlagen sollen. Die obligatorische Tech-Blog-Existenzkrise-Folge. Jeder Self-Hoster muss einmal im Jahr seinen „Ich habe nachgedacht und mache mir Sorgen“-Post raushauen, sonst verliert man den FreeBSD-Mitgliedsausweis.

„Postfix 3.11.1 mit OpenSSL 3.5: Post-Quantum-TLS jetzt nativ“
Hier glüht der Mann. Hier ist er zu Hause. Hier passiert die Magie. Sechs Menschen lesen es und einer von denen ist ein Bot.

„Versteckte Easter Eggs“

„llms.txt, llms-full.txt, VirusTotal, AbuseIPDB Profile“

llms.txt. Du hast eine llms.txt. Die explizite „Bitte-LLM-lies-mich-ich-bin-relevant“-Datei. Während du gleichzeitig einen Post schreibst, in dem du dir „Sorgen über AI“ machst. Bro. Du lädst genau das Monster ein, vor dem du Angst hast, und lässt ihm noch einen Begrüßungs-Cookie auf dem Sofakissen liegen.

Das Theme-Detail

Ein WordPress-Theme von Anders Norén. Der Mann reicht von Linux-Kernel-Internals über DNSSEC bis zu PQ-Crypto — aber bei der Webseite hat er gesagt „ach, was die Schweden im default schicken passt schon“. Es ist, als würde Gordon Ramsay zu Hause Fischstäbchen aus der Mikrowelle essen. Lobenswert ehrlich, irgendwie aber auch beleidigend.

Die Sprachebene: „Moin!“

Klassisch. Norddeutsche Authentizitäts-Geste. Drei Anschläge, die signalisieren: „Ich bin bodenständig, ich rede Klartext, ich trinke meinen Kaffee schwarz wie mein Terminal und ich habe keine Zeit für Bullshit“ — direkt gefolgt von einer mehrseitigen Hausordnung über digitale Werte und Mehrjahres-Reflektionen über AI. Moin, Schatz. Moin.

Das übergreifende Problem

Diese Seite ist die digitale Inkarnation des Mannes, der dir auf einem LAN-Party Geburtstag erklärt, warum er Eigentlich kein Energiedrink trinkt, sondern lieber Mate, weil:

a) Koffeingehalt definierbarer ist
b) Open-Source-philosophischer Konsum
c) „Ist halt mehr meins.“

Du willst hier gar keinen Blog. Du willst, dass jemand vorbeikommt und sagt: „Wow, neun Jails? Erzähl mir mehr.“ Aber niemand kommt vorbei, weil deine .onion-Adresse nur über deinen eigenen Tor-Bridge erreichbar ist und der DNS-Resolver dahin nur über deinen eigenen DoH-Endpunkt funktioniert und dafür braucht man dein PGP-Public-Key, und der ist in security.txt und die ist signiert mit dem Key, den man nur über dns.kernel-error.de bekommt. Du hast dir ein wunderschönes, sicheres, selbstreferenzielles Solipsismus-Imperium gebaut, und das einzige Lebewesen, das es regelmäßig besucht, ist googlebot/2.1 — und der nutzt nicht mal HTTP/3.

Zusammenfassung

Kernel-Error.de ist nicht schlecht. Kernel-Error.de ist die liebevollste, akribischste, technisch versierteste Schreibtisch-Schublade des deutschen Internets. Es ist die Webseite-Version eines perfekt geölten Mechanikers, der sein ganzes Wochenende damit verbringt, einen Drehmomentschlüssel zu kalibrieren, mit dem er anschließend… einen anderen Drehmomentschlüssel kalibriert.

Und ehrlich? Wir lieben es. Bleib so. Das Internet braucht Menschen wie dich. Nur vielleicht trinkst du heute mal kein Mate und gehst kurz raus.

🫡

Disclaimer: Dieser Roast wurde mit Post-Quantum-Verschlüsselung über neun FreeBSD-Jails gerouted, bevor er hier ankam. Eine .onion-Variante ist auf Anfrage verfügbar.

Eine AI, die mein Arbeitskollege Michael auf meinen Blog losgelassen hat

Danke, Michael

Wirklich. Es braucht jemanden, der einen ein bisschen kennt, um so präzise daneben zu treffen, dass es weder gemein noch falsch ist. Ich werde den Roast direkt neben den anderen liebevollen Lebens-Diagnosen ablegen, die ich über die Jahre gesammelt habe. Vielleicht drucke ich ihn mir auch aus und hänge ihn über den Bildschirm, falls ich irgendwann anfange, mich für all das hier ernsthaft zu rechtfertigen.

Und jetzt geh ich raus. Aber natürlich erst, wenn ActivityPub diesen Post an alle Federation-Inboxen ausgeliefert hat, der .onion-Mirror den Eintrag indexiert, der DNS-Resolver via DoH und Post-Quantum-TLS einmal durchgequeryt wurde und der Uptime-Checker eine ordentliche 200 für die neue Permalink-URL bekommt. So wie es sich gehört.

Wenn du Michaels AI Roast genauso gut findest wie ich, oder wenn du selbst eine Diagnose für diesen Blog hast, kannst du mir gerne fragen. Mate steht bereit.

LCR-T4-Plus v2: m-firmware flashen, Display-Tuning und die 8-MHz-Quartz-Falle

Vor ein paar Wochen habe ich hier den TC1 Multifunction Tester mit der quelloffenen m-firmware geflasht. Seitdem liegt ein zweiter Bauteiltester aus genau dem gleichen Dunstkreis bei mir auf dem Tisch: ein LCR-T4-Plus mit gelber Platine, eingebautem ZIF-Sockel und Beschriftung „91make.taobao.com“ auf der Rückseite. Das billige Gegenstück zum TC1, vom selben Hersteller-Cluster, gleiche Firmware-Familie. Eigentlich ein gemütlicher Folge-Sonntag, dachte ich.

Aufgeklappter LCR-T4-Plus v2 mit ZIF-Sockel, blauem Start-Button und ISP-Header mit vertauschten Silkscreen-Labels
Ausgangslage: aufgeklapptes Display, ZIF-Sockel, blauer Start-Button. Rechts vom Button der ISP-Header mit den (falsch beschrifteten) Silkscreen-Labels.

Zur Vorgeschichte gehört ein erster T4-Plus, den ich vor Jahren mal gekauft hatte. Das Gerät arbeitet bis heute, sein Display allerdings ist tot. Der COG-Controller unter dem Epoxy-Klecks auf dem ST7565R hat aufgegeben, der MCU lebt noch, aber zeigen kann er nichts mehr. Vor ein paar Wochen habe ich aus einer Resterampe ein zweites Exemplar geordert, gleicher Aufdruck, gleiche PCB-Revision. Plan: einmal Backup-Flash und dann beide Geräte parallel mit m-firmware betreiben, eines als Bastelreserve.

Aus „schnell mal das gleiche Flash-Profil wie beim ersten T4-Plus drüberbügeln“ wurde ein ganzer Nachmittag mit komplett schwarzem Display, abgeschnittenem Cursor, einem Tester der sich beim Loslassen des Buttons sofort wieder ausschaltet und am Ende der Erkenntnis, dass die zentrale Falle nicht in der Firmware steckt, sondern in einem winzigen Bauteil neben dem MCU.

Was steckt im T4-Plus v2?

Anders als der TC1 mit seinen zwei Chips (ATmega plus STC für das Power-Management) ist der T4-Plus ein Single-MCU-Design. Auf der Rückseite klebt genau ein nennenswerter Halbleiter, der Rest ist Spannungsteiler, drei Transistoren für die Power-Latch-Schaltung und der Quartz.

PCB-Rückseite des T4-Plus v2 mit ATmega328P, 8-MHz-Quartz und 9V-Block-Anschluss
Rückseite des PCB mit dem ATmega328P, dem silbernen Quartzblock daneben (8 MHz, nicht 16 wie beim ersten Exemplar!) und dem 9V-Block-Anschluss.
Mikroskop-Closeup auf den ATmega328P-U-TH mit Date-Code 2009SG8
Close-up unter der Lupe: ATMEL MEGA328P-U-TH, Date-Code 2009SG8. Originalsilizium von Atmel/Microchip, kein LGT8F328-Klon wie auf manchen jüngeren Boards.

U1: ATmega328P-U-TH im TQFP-32-Gehäuse, Signatur 0x1e950f, 32 KB Flash, 2 KB SRAM, 1 KB EEPROM. Der Date-Code 2009SG8 stammt aus 2020, Production-Code-Pattern passt zu echtem Microchip-Silizium. Auf gefälschten Klonen sieht das Lasermarkings-Pattern deutlich anders aus, das Schriftbild ist hier sauber, also passt das.

Der Display-Controller sitzt unter dem schwarzen Epoxy-Blob auf dem LCD selbst und ist ein ST7565R mit 128 mal 64 Pixel monochromem STN-Panel, gelb-grüne Hintergrundbeleuchtung. Klassische COG-Bauform. Daten kommen seriell über vier GPIO-Leitungen rein. Kein I2C, sondern SPI-Bitbang vom MCU getrieben.

Stromversorgung läuft über einen 9V-Block mit Spannungsteiler 10k zu 3.3k auf einem ADC-Pin. Bedienelement: ein einziger blauer Start-Button, kein Drehgeber, kein IR-Empfänger, kein USB. Das Ding ist ein Wegwerf-Standalone im besten Sinne, nichts dran was schiefgehen kann.

Und dann ist da noch dieser kleine silberne Quartzblock direkt neben dem MCU. 8.000 steht da. Acht Megahertz. Beim ersten T4-Plus von vor Jahren habe ich 16 MHz im Hinterkopf, ich notiere mir die 8 MHz pflichtbewusst, denke aber „nett, anderer Quartz“ und mache ansonsten erstmal weiter. Spoiler: genau dieser Eintrag wird Stunden später zum Schlüssel.

ISP-Header finden, und der Aufdruck lügt

Der ISP-Header sitzt versteckt unter der Display-Platine, ein simples 2×3-Pad-Grid ohne aufgelötete Stiftleiste. Auf der Rückseite finden sich Silkscreen-Hinweise: mis, mosi, sck, reset, plus die beiden Stromversorgungspads. Lustig: bei diesem Board sind die beiden Datenleitungen vertauscht beschriftet. Was als mis markiert ist, trägt physisch MOSI; das mosi-Pad ist tatsächlich MISO.

Aufgefallen ist mir das beim ersten Signatur-Read mit dem Arduino Uno als ISP-Programmer. Wenn man stur nach Silkscreen verkabelt, antwortet der Chip mit lauter 0x00. Sobald die Leitungen getauscht werden, kommt eine saubere Signatur. Heißt für die Praxis: bei diesem Board nicht auf den Aufdruck verlassen, sondern Pin für Pin mit dem Durchgangsprüfer gegen die Chip-Pins verifizieren. Standard-ISP-Pinout auf dem ATmega328P im TQFP-32 ist Pin 17 MOSI, Pin 18 MISO, Pin 19 SCK, Pin 29 /RESET.

Arduino Uno als ISP-Programmer am ISP-Header des T4-Plus v2
Arduino Uno mit ArduinoISP-Sketch als Programmer. Display zur Seite geklappt, vier Datenleitungen plus 5V und GND. Genauso wie beim TC1, gleiche Hardware, gleiche Flag-Kombination.

Verkabelung an den richtigen Pads (Arduino-seitig), gilt für beide T4-Plus-Boards:

Arduino D10  ->  RESET   (T4-Plus Pad RESET)
Arduino D11  ->  MOSI    (T4-Plus Pad „mis"   bei diesem Klon!)
Arduino D12  ->  MISO    (T4-Plus Pad „mosi"  bei diesem Klon!)
Arduino D13  ->  SCK     (T4-Plus Pad SCK)
Arduino 5V   ->  VCC
Arduino GND  ->  GND

Sobald die Signatur sauber kommt, ist die halbe Miete drin:

$ avrdude -c avrisp -p m328p -P /dev/ttyACM0 -b 19200 -B 32 -v 2>&1 | grep -i sig
avrdude: Device signature = 0x1e950f (probably m328p)

Backup der Original-Firmware

Beim TC1 ging das Backup nicht, weil die Lock-Bits auf 0xC0 standen und das Auslesen geblockt haben. Beim T4-Plus v2 habe ich Glück: Lock-Byte ist 0xFF, also komplett offen. Vor dem ersten Flash zieht man sich also bitte unbedingt das Original einmal komplett herunter, Flash und EEPROM:

avrdude -c avrisp -p m328p -P /dev/ttyACM0 -b 19200 -B 32 
        -U flash:r:t4plus2-original-flash.hex:i 
        -U eeprom:r:t4plus2-original-eeprom.hex:i 
        -U lfuse:r:-:h -U hfuse:r:-:h -U efuse:r:-:h -U lock:r:-:h

Fuses kamen so zurück: lfuse=0xF7, hfuse=0xD9, efuse=0xFD, lock=0xFF. Hat man das im Kasten, kann man auch beruhigt herumprobieren. Notfalls geht es per avrdude -U flash:w: jederzeit wieder auf den Auslieferungszustand zurück.

Erster Flash und ein völlig schwarzes Display

Quelle für die neue Firmware ist wie beim TC1 die m-firmware von madires, aktuell Version 1.56m. Im Repo liegt unter Software/Firmware/m-firmware/ der Source mit allen Config-Templates für die diversen MCU-Varianten. Für den ATmega328P ist das config_328.h, gemeinsame Optionen in config.h, build-Flags im Makefile.

Mein erster Anlauf orientierte sich an dem, was beim ersten T4-Plus damals funktioniert hatte. Display ST7565R aktivieren, BAT_DIVIDER mit 10k zu 3.3k, Short-Circuit-Menu an, MCU auf atmega328 (ohne das nachgeschobene P, sonst greift der #if defined(__AVR_ATmega328__)-Guard in config_328.h nicht und der Build wirft undefinierte BUTTON_PIN-Symbole), Frequenz pflichtbewusst auf FREQ = 16 wie beim ersten Board.

Make, flash, einschalten, und dann das hier:

Komplett dunkles ST7565R-Display nach erstem Flash mit Default FLAG_RATIO_65
Display nach erstem Flash mit Default-FLAG_RATIO_65: komplett dunkel. Klassisches Symptom eines zu hoch eingestellten internen LCD-Spannungsteilers.

Komplett schwarz. Kein Boot-Banner, keine Kontrast-Stufe, einfach nur eine schwarze Fläche. Mein erster Gedanke war natürlich: Display kaputt, gleicher Spaß wie beim ersten T4-Plus. Aber die Backlight-LEDs leuchten, und im Streiflicht erkennt man, dass die Pixel-Anordnung sichtbar ist, nur eben in „alle Pixel an“-Stellung. Das ist kein toter Controller, das ist ein lebender Controller mit zu hohem internem LCD-Bias.

Im m-firmware-Source liegt direkt ein Clones-Verzeichnis mit Hinweisen zu bekannten Klon-Variationen. Für den verwandten LCR-T5 steht dort genau dieser Workaround: bei manchen ST7565R-Panels ist der per Software gesetzte Bias zu hoch, der typische Default-Befehl CMD_V0_RATIO | FLAG_RATIO_65 muss runter auf FLAG_RATIO_55 oder sogar FLAG_RATIO_45. Geändert wird das in ST7565R.c in der Funktion LCD_Init():

/* set contrast: resistor ratio 5.5 */
LCD_Cmd(CMD_V0_RATIO | FLAG_RATIO_55);    /* statt FLAG_RATIO_65 */

Mit dieser einen Zeile ändert sich alles. Neuer Build, flashen, und siehe da, plötzlich sind tatsächlich Pixel sichtbar.

Display-Tuning: Orientierung, Kontrast und der abgeschnittene Cursor

„Pixel sichtbar“ heißt noch lange nicht „lesbar“. Was da auf dem Display erscheint, sind erstmal Hieroglyphen kopfüber, spiegelverkehrt, mit komischen Pixel-Mustern an Stellen, an denen eigentlich Zeichen stehen sollten. Das ist die klassische ST7565R-Inbetriebnahme: drei Defines steuern Orientierung und Kontrast, alle drei muss man am eigenen Panel kalibrieren.

//#define LCD_FLIP_X                /* horizontale Spiegelung */
//#define LCD_FLIP_Y                /* vertikale Spiegelung   */
#define LCD_CONTRAST     22         /* 0..63, 22 passt hier   */

Bei meinem Panel sind beide FLIP-Defines aus, was unintuitiv klingt aber stimmt. Der Kontrast landet final bei 22. Nach ein paar Build-Iterationen sieht das so aus:

Display mit korrektem Kontrast aber rechts abgeschnittenem Cursor-Symbol durch LCD_OFFSET_X
Zwischenstand: Orientierung und Kontrast passen, „1-||-3 .15pF“ gut lesbar. Aber das blinkende Cursor-Symbol rechts ist abgeschnitten, weil LCD_OFFSET_X den gesamten Inhalt um vier Pixel nach rechts schiebt.

Klassischer Fall: bestimmte ST7565R-Panel-Varianten brauchen einen X-Offset von vier Pixel, weil deren sichtbarer Bereich rechts beginnt. Andere wiederum nicht. Das Define LCD_OFFSET_X erzwingt genau diese Verschiebung. Mein Panel will sie nicht, also raus damit:

//#define LCD_OFFSET_X            /* deaktiviert: kein +4 Pixel Shift */
Display korrekt zentriert nach Deaktivierung von LCD_OFFSET_X
Nach dem Auskommentieren sitzt der Bildinhalt zentriert, der Cursor ist vollständig sichtbar. Erste komplette Messung lesbar: ein simpler Widerstand.

An dieser Stelle wäre die Geschichte normalerweise vorbei. Display tut, Buttons drücken, Messen, fertig. Wäre da nicht das Problem mit dem Einschalten.

Das Power-Latch-Drama

Der T4-Plus hat einen blauen Druckknopf. Den drückt man, der Tester bootet, misst, schaltet sich nach Inaktivität von alleine wieder ab. So weit der Plan. Mein frisch geflashtes Board allerdings macht etwas Ärgerliches: Knopf drücken, Display bootet kurz an, Knopf loslassen, sofort wieder aus. Das ist kein „Auto-Power-Off nach Timeout“, das ist „MCU verliert beim Loslassen die Stromversorgung“. Klassischer Power-Latch-Bug.

Erster Reflex: irgendein POWER_CTRL-Pin in der Config ist falsch. Die m-firmware hat einen Define für den Pin, an dem der MCU nach dem Boot ein HIGH-Signal anlegen muss, um sich selbst am Leben zu halten. Auf den meisten Boards heißt der Pin PD6 oder ähnlich. Also durchprobiert: PD6, PD7, PD4, PD3, alles was an freien Pins noch übrig ist. Kein Erfolg. Egal welcher Pin, sobald der Finger den Button verlässt, ist Schluss.

Zeit für die Multimeter-Tour über die Latch-Schaltung. Auf der Platine sitzen drei Transistoren um den Power-Knopf herum: Q1 ist ein S9015 (PNP), schaltet die 9V auf die Versorgung. Q2 und Q3 sind beide S9014 (NPN) und treiben gemeinsam mit dem Button-Kontakt die Basis von Q1. Durchgangsmessungen ergeben:

Pin 29 (/RESET) --- 27 kOhm --- Basis Q3
Basis Q3 --- Button-Kontakt --- GND
Kollektor Q3 --- Basis Q1 (über Pull-up)
Q1 schaltet 9V auf den 5V-Regler

Das ist also gar kein „Firmware schaltet POWER_CTRL HIGH“-Design. Die Latch-Schaltung ist passiv über die /RESET-Leitung des MCU aufgebaut. Solange der ATmega läuft, liegt /RESET auf HIGH (intern hochgehalten), und über den 27k zur Basis von Q3 bleibt der Transistor durchgesteuert, Q1 hält die Versorgung an. Drückt man den Button, zieht der Q3-Basis kurz auf GND-Potenzial Richtung positiv und triggert das Hochfahren über einen leicht anderen Pfad. Aber sobald der Boot durch ist und der MCU /RESET HIGH hält, läuft das Ganze von alleine.

Heißt: an meinem Board sollte das auch ohne firmware-seitiges POWER_CTRL funktionieren. Tut es aber nicht. Spannungsmessung unter Strom zeigt: Q3-Basis bleibt dauerhaft bei 0V, der Transistor schaltet nie sauber durch. Aber warum, wenn doch /RESET nach erfolgreichem Boot eigentlich HIGH sein müsste?

An dieser Stelle saß ich gefühlte zwei Stunden an dem Tisch und habe mit dem Multimeter zwischen MCU-Pinout, Transistor-Basen und 9V-Schiene hin- und hergemessen. Mein Verdacht war zwischendurch ein toter Transistor, ein kalter Lötpunkt, ein verkokelter Trace unter dem schwarzen Lötstopplack. Alles falsch.

Der Aha-Moment im Makefile eines fremden Forks

Irgendwann habe ich aus Verzweiflung angefangen, fremde Firmware-Forks für genau dieses Board zu lesen. Es gibt eine zweite quelloffene Linie neben der m-firmware: die k-firmware (das „k“ steht für Karl-Heinz Kübbeler, den Original-Autor), und davon wiederum diverse Forks. Einer davon ist Palingenesis‘ Fork mit einem dedizierten T4-v2-Build-Profil. Ich öffne dessen Makefile, und der Blick fällt auf eine einzelne Zeile:

OP_MHZ = 8

Acht. Megahertz. Genau die Zahl, die ich Stunden vorher auf dem Quartz gelesen und in mein Notizfeld geschrieben hatte. Genau die Zahl, deren Bedeutung ich nicht richtig zu Ende gedacht hatte. Mein Build der m-firmware lief mit FREQ = 16, weil ich davon ausgegangen war, dass das T4-Plus-Board immer 16 MHz Quartz hat. Tut es eben nicht. Beim zweiten Exemplar ist ein 8-MHz-Quartz drauf.

Was das praktisch bedeutet: wenn die Firmware glaubt, sie laufe auf 16 MHz, aber tatsächlich nur 8 MHz Takt verfügbar sind, läuft jede Operation effektiv mit halber Geschwindigkeit. Jedes Timer-Tick, jede Schleife, jede ADC-Konversion dauert doppelt so lang. Im Falle der Initialisierungssequenz heißt das: bis der MCU im Bootcode an der Stelle ist, an der POWER_CTRL HIGH gesetzt wird (oder bis /RESET stabil HIGH ausgegeben wird), ist das Zeitfenster, in dem der Button noch gedrückt ist, schon längst vorbei. Der Latch greift nicht, der Tester schaltet sich beim Loslassen ab.

Drei Buchstaben im Makefile geändert:

FREQ = 8

Neu kompiliert, geflasht. Knopf gedrückt. Display kommt. Knopf losgelassen. Display bleibt an. Der Tester hält sich selbst am Leben. Zwei Stunden Diagnose-Drama wegen einer einzigen Zahl im Makefile, die mit der eigentlichen Hardware nichts zu tun hatte, außer dass die Hardware halt ein anderer Quartz war als gedacht. Genau eine dieser Stellen, an denen man kurz aufsteht und sich was zu trinken holt.

Self-Adjustment und Werte ins Flash schreiben

Nach dem Power-Latch-Fix ist der Tester quasi nutzbar, aber die m-firmware meldet beim Boot „Checksum failure“ für die Adjustment-Werte im Flash. Das ist erwartbar, weil dort ja noch nie eigene Kalibrierwerte gespeichert wurden. Heilmittel: das Service-Menü aufrufen, Self-Adjustment durchlaufen, Werte abspeichern.

Voraussetzung ist, dass UI_SHORT_CIRCUIT_MENU in config.h aktiviert ist. Damit kommt man ins Menü, indem man beim Start alle drei Probes (1, 2, 3) miteinander kurzschließt. Der Tester erkennt das und blendet das Service-Menü ein:

Service-Menü des T4-Plus v2 via 3-Probe-Kurzschluss erreicht
Service-Menü via 3-Probe-Kurzschluss: PWM, IR detector, Opto Coupler, Test, Adjustment, Contrast, Save. Genau das, was man für eine saubere Inbetriebnahme braucht.

Erster Punkt ist „Test“, ein Selbsttest mit allen drei Probes. Anschließend „Adjustment“, da braucht man dann einen 1 µF-Kondensator zwischen Probe 1 und Probe 3. Der Tester misst seinen eigenen internen Ri-Wert, die Eingangskapazität, eine Referenzspannung. Am Ende „Save“, und die Werte landen via DATA_FLASH als Block im Programmspeicher (Self-Programming). Beim TC1 war das genauso, der einzige Unterschied: dort kommen die Daten in den 1 KB EEPROM. Beim T4-Plus reicht der Flash-Bereich und ich nutze DATA_FLASH stattdessen, weil das Self-Programming auf dem ATmega328P stabiler läuft als die EEPROM-Erase-Write-Sequenz.

Self-Adjustment-Ergebnis mit Ri-, Ri+, C0, R0, Vref, Vcc, AComp Werten
Self-Adjustment-Ergebnis: Ri- 20.8 Ω, Ri+ 23.4 Ω, C0 36 pF, R0 0.31 Ω, Vref 1097 mV, Vcc 5130 mV, AComp 0 mV. Werte ins Flash gespeichert, Checksum-Fehler beim nächsten Boot weg.

Diese Werte unterscheiden sich übrigens leicht von Board zu Board. Die Ri-Werte hängen an den konkreten Innenwiderständen der MCU-Ausgangsstufen, C0 und R0 an den Probe-Leitungslängen, Vref am internen Bandgap. Wer die Hex eines anderen Geräts mit dessen Flash-Region 1:1 auf sein eigenes flasht, übernimmt also fremde Kalibrierdaten, die zu falscher Mess-Anzeige führen können. Lieber einmal selbst kalibrieren.

3D-gedrucktes Gehäuse statt nackte Platine

Das Original-T4-Plus kommt ohne Gehäuse, nur die nackte Platine. Für den TC1 gab es ja damals zumindest noch eine billige Plastik-Halbschale, hier ist nicht mal das dabei. Auf Makerworld liegt ein passendes Modell von „LeoNerd“ mit der ID 1891431, „Case for LCR-T4 Component Tester“. Drei Teile: Unterschale, Frontblende mit ZIF-Sockel-Ausschnitt und Display-Fenster, Batterie-Klappe.

3D-gedruckte PETG-Gehäuseteile direkt von der Druckplatte für den LCR-T4-Plus
PETG, 0.2 mm Schichthöhe, fertig auf der Druckplatte. Drei Teile, kein Support nötig.

Ich habe das auf meinem Bambu Lab X1 Carbon in PETG ausgedruckt. PLA ginge auch, aber PETG ist hier ein bisschen weniger spröde, der Tester wird ja immer wieder mal in die Hand genommen. Maße passen direkt, das PCB sitzt sauber zwischen den Stegen, das Display fluchtet, der Button schaut zentrisch durch die Öffnung. Den 9V-Block fixiert die Klappe von hinten.

Finaler Funktionstest im Gehäuse

Fertig zusammengebauter T4-Plus v2 im 3D-Gehäuse mit Boot-Banner Component Tester v1.56m
Boot-Banner „Component Tester v1.56m“ im 3D-gedruckten PETG-Gehäuse. Knopf drücken, Display bleibt an, alles, was es braucht.
Boot-Screen mit korrekter Batteriespannungs-Anzeige Bat 9.61V ok
„Bat 9.61V ok / Probing…“, der BAT_DIVIDER mit 10k zu 3.3k stimmt und eine frische 9V-Batterie wird korrekt erkannt.

Ein paar Testmessungen mit Bauteilen aus der Schublade: 10k-Widerstände, ein paar 100 nF Kondensatoren, ein BC547 und ein paar LEDs. Alle Werte plausibel, BC547 wird mit korrekter Pin-Zuordnung als NPN-Transistor erkannt, hFE in der erwarteten Größenordnung. Damit ist das Gerät einsatzbereit.

Die finale Konfiguration zum Mitnehmen

Damit andere mit dem gleichen 91make-Klon nicht die gleichen drei Tage verbrennen müssen, hier alle Änderungen relativ zur unveränderten m-firmware 1.56m an einer Stelle gesammelt.

Makefile (Auszug, nur die geänderten Zeilen):

MCU    = atmega328       # NICHT atmega328p, sonst greift der Include-Guard nicht
FREQ   = 8               # 8 MHz Quartz auf der v2-Variante, nicht 16
PARTNO = m328p           # avrdude-Part bleibt m328p

config.h (gemeinsame Optionen, aktive Defines):

#define HW_REF25                   /* interne 2.5V-Bandgap-Referenz */
#define UI_AUTOHOLD                /* nach Messung auf Tastendruck warten */
#define UI_SHORT_CIRCUIT_MENU      /* Service-Menue via 3-Probe-Short */
#define POWER_OFF_TIMEOUT 60       /* Auto-Off nach 60 Sekunden Idle */
#define BAT_DIVIDER                /* externer 10k/3.3k-Teiler */
#define BAT_R1     10000
#define BAT_R2     3300
#define BAT_WEAK   7400            /* 7.4V Warnschwelle */
#define BAT_LOW    6400            /* 6.4V Abschaltschwelle */
#define DATA_FLASH                 /* Kalibrierdaten ins Programm-Flash */

config_328.h (ST7565R-Section, alles relevante an einem Stueck):

#define LCD_ST7565R
#define LCD_GRAPHIC
#define LCD_SPI
#define LCD_PORT     PORTD
#define LCD_DDR      DDRD
#define LCD_RESET    PD0
#define LCD_CS       PD5
#define LCD_A0       PD1
#define LCD_SCL      PD2
#define LCD_SI       PD3
#define LCD_DOTS_X   128
#define LCD_DOTS_Y   64
//#define LCD_OFFSET_X            /* AUS, sonst +4 Pixel Verschiebung */
//#define LCD_FLIP_X              /* AUS */
//#define LCD_FLIP_Y              /* AUS */
#define LCD_START_Y  0
#define LCD_CONTRAST 22
#define FONT_8X8_VF
#define SYMBOLS_24X24_VFP
#define SPI_BITBANG

ST7565R.c (genau eine Zeile in LCD_Init()):

/* set contrast: resistor ratio 5.5 */
LCD_Cmd(CMD_V0_RATIO | FLAG_RATIO_55);     /* war FLAG_RATIO_65 */

Flash-Befehl, identisch zum TC1 (nur die Hex-Datei ist eine andere):

avrdude -c avrisp -p m328p -P /dev/ttyACM0 -b 19200 -B 32 
        -U flash:w:ComponentTester.hex:i

Was ich aus dem Nachmittag mitnehme

Vier Punkte, die ich beim nächsten Klon-Tester sofort prüfen werde, statt wieder Stunden in der Diagnose zu verbringen:

  • Quartz mit der Lupe lesen bevor man die Frequenz im Makefile setzt. Identische PCB-Bezeichnung garantiert nicht den gleichen Takt. Bei diesem T4-Plus ist es 8 MHz, bei meinem ersten waren es 16 MHz. Zwei Boards, derselbe Aufdruck, verschiedene Bestückung.
  • ST7565R komplett schwarz nach erstem Flash ist fast immer ein zu hoher Bias und kein toter Controller. Erst FLAG_RATIO_65 auf FLAG_RATIO_55 oder FLAG_RATIO_45 ändern, dann weiter denken.
  • Silkscreen-Labels glauben, aber verifizieren. Auf dem v2-Board sind mis und mosi physisch vertauscht. Einmal Durchgangsprüfung gegen den MCU-Pin spart eine Stunde Frustration.
  • Vermeintliche Hardware-Fehler sind manchmal Build-Parameter. Ein Tester, der sich beim Loslassen ausschaltet, sieht aus wie eine kaputte Latch-Schaltung. War aber in Wirklichkeit nur die falsche CPU-Frequenz im Makefile, was den Boot-Code langsamer laufen ließ, als das mechanische Button-Zeitfenster es zuließ.

Repo und Quellen

Wie beim TC1 habe ich auch hier ein kleines Repo mit der fertigen Hex, den Config-Patches und einer README mit Schritt-für-Schritt-Anleitung gepackt: github.com/Kernel-Error/t4plus-v2-firmware-update. Lizenz folgt der m-firmware (EUPL v1.2), die Config-Patches sind als unified diff gegen die unveränderte 1.56m beigelegt.

Quellen, ohne die das nicht funktioniert hätte:

Siehe auch: TC1 Multifunction Tester mit Open-Source-Firmware (der Vorgänger-Beitrag, gleiche Firmware-Familie, mehr Drama bei der STC-Variante), Multifunktionstester für Elektronikbauteile (mein erster Eindruck von dieser Tester-Klasse 2019), xum1541-Firmware-Bug gefunden und gefixt (anderes Mikrocontroller-Firmware-Drama), OSSC Firmware-Update 1.21 (Firmware-Update an Open-Source-Hardware), Preciva 992D+ Lötstation (für alle die nach diesem Beitrag selber löten wollen).

Fragen, eigene T4-Plus-Klon-Varianten oder noch krummere Silkscreen-Vertauschungen gesehen? Gerne über die fragen-Seite oder als Issue im Repo.

« Ältere Beiträge

© 2026 -=Kernel-Error=-RSS

Theme von Anders NorénHoch ↑