Nach meinem ersten "Hello Kernel"-Experiment wollte ich den nächsten echten Schritt in die Linux-Treiberentwicklung wagen. In dieser Episode von "A Maker’s Journey" habe ich deshalb einen funktionsfähigen Character Driver geschrieben, gelernt, wie man im Kernel debuggt, ohne das System zum Absturz zu bringen, und das Modul für meinen Raspberry Pi cross-kompiliert.

Nach meinem ersten „Hello Kernel“-Experiment wollte ich noch eine Ebene tiefer gehen – weg vom simplen Log-Ausgabe-Beispiel hin zu einem echten Treiber, der tatsächlich etwas tut.



Nahezu jedes Tutorial und jedes Buch – einschließlich des Klassikers Linux Device Drivers, 3rd Edition – schlägt denselben nächsten Schritt vor: einen Character Driver schreiben. Genau das habe ich getan – und in diesem Artikel möchte ich erzählen, was ich dabei gelernt habe, welche Überraschungen es gab und warum Kernel-Entwicklung sich völlig anders anfühlt als alles, was ich bisher gemacht habe.

Vom „Hello World“ zum echten Treiber

Character-Driver sind die einfachste Art von Gerätetreibern unter Linux. Sie verarbeiten Daten als Byte-Stream – wie serielle Schnittstellen, Tastaturen oder auch /dev/null.

In Linux ist alles eine Datei – auch Geräte. Wenn man etwas in eine Datei unter /dev schreibt oder daraus liest, leitet der Kernel den Aufruf an den passenden Treiber weiter. Einen eigenen Treiber zu schreiben bedeutete also, einen neuen Eintrag in /dev hinzuzufügen – meine erste kleine Erweiterung des Linux-Dateisystems.

Mein Ziel war es nicht, UART-, SPI- oder GPIO-Treiber neu zu erfinden. Die gibt es längst – perfekt umgesetzt.
Ich wollte verstehen, wie Daten vom Userspace in den Kernel gelangen, wie sich Treiber registrieren und wie der Kernel sich selbst vor fehlerhaftem Code schützt.

Arbeiten im Kernel Space – eine andere Welt

Kernel-Programmierung sieht auf den ersten Blick vertraut aus, schließlich ist es immer noch C. Aber es ist C ohne Sicherheitsnetz.

Funktionen wie printf oder malloc stehen nicht zur Verfügung, weil Standardbibliotheken im Kernel Space nicht existieren. Stattdessen nutzt man Kernel-eigene Varianten wie printk, kmalloc und ähnliche.
Jede Zeile Code läuft mit vollen Systemrechten – ein kleiner Fehler kann das ganze System zum Absturz bringen oder Speicher beschädigen.

Gerade das macht es aber spannend. In der Anwendungsprogrammierung lebt man auf einer dicken Schicht aus Abstraktionen und Schutzmechanismen. Im Kernel Space ist man selbst dieses Sicherheitsnetz.

Diese Trennung – Userspace vs. Kernelspace – ist der Grund, warum Linux so stabil läuft. Benutzerprogramme dürfen abstürzen, ohne das System mitzureißen. Kernelcode hingegen trägt die volle Verantwortung.

Debuggen ohne Debugger

Beim Debuggen im Kernel lernt man schnell, geduldig zu werden.
Man kann den Code nicht einfach mit GDB durchsteppen oder Ausgaben auf die Konsole schreiben. Stattdessen verlässt man sich auf Werkzeuge wie:

  • printk() – das Kernel-Pendant zu printf, das Nachrichten in das Systemlog (dmesg) schreibt
  • trace_printk() und /sys/kernel/debug/tracing – für detaillierte Ablauf- und Zeit-Analysen

Anfangs wirkt das alles ziemlich archaisch, doch mit der Zeit lernt man, diese Werkzeuge zu schätzen.
Jede Logzeile wird zu einem Hinweis, zu einem Brotkrumenpfad durch das Verhalten des Systems.
Und ja – ich habe auch ein paar Kernel Panics ausgelöst. Es ist demütigend, aber gleichzeitig ungemein befriedigend, wenn der Code schließlich stabil läuft.

Ein Gerät registrieren und zum Leben erwecken

Der eigentliche Durchbruch kam, als ich meinen Treiber im Kernel registrierte und den passenden Geräteknoten unter /dev anlegte. Durch das Zuweisen einer Major- und Minor-Nummer und das Anlegen per mknod konnte ich Text in meinen Treiber schreiben und wieder auslesen.

Es war nur ein winziger Datenaustausch – ein paar Bytes hin und zurück –, aber für mich war das ein großer Moment:
Zum ersten Mal flossen Daten durch meinen eigenen Kernelcode.

Hier findest du den vollständigen Quellcode meines Treibers:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/sched.h>
#include <linux/poll.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/mm.h>

static int major = 	100;
static int debug =	1;
static char* name = "chardriver";

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Stefan Herndlbauer");
MODULE_VERSION("1.0.0");
MODULE_DESCRIPTION("This a simple character driver");

ssize_t chardriver_read (struct file *, char *, size_t, loff_t *);
ssize_t chardriver_write (struct file *, const char *, size_t, loff_t *);

#define NUMMINORS 5
static struct file_operations chardriver_fops = {
    .read = chardriver_read,
    .write = chardriver_write,
    .owner = THIS_MODULE,
};

#define BUFFER_SIZE	1000
static char char_buffer[BUFFER_SIZE];
static int buffer_length = 0;

static dev_t mydev;
static struct cdev *my_cdev;
int sh_init_module(void)
{
    int retval;

    my_cdev = cdev_alloc();
    mydev = MKDEV(major,0);
    retval = register_chrdev_region(mydev,NUMMINORS,name);

    if (retval < 0)
        return retval;

    if (!major)
        major = retval;

    if (debug)
        printk("chardriver registered with major = %d\n",major);
    my_cdev->ops = &chardriver_fops;
    my_cdev->owner = THIS_MODULE;
    retval = cdev_add(my_cdev,mydev,NUMMINORS);

    return retval;
}

void sh_cleanup_module(void)
{
    /* Unregister character driver. */
    unregister_chrdev_region(mydev,NUMMINORS);
    cdev_del(my_cdev);
}

ssize_t chardriver_read (struct file *filp, char *buf, size_t nbytes, loff_t* ppos)
{
    int avail;
    int failed, transferred;

    if (debug)
        printk("READ: %d bytes\n",(int)nbytes);

    if (*ppos >= buffer_length || !nbytes)
        return 0;

    avail = buffer_length - *ppos;
    if (nbytes > avail)
        nbytes = avail;

    failed = copy_to_user(buf, char_buffer + *ppos, nbytes);
    transferred = nbytes - failed;

    if (!transferred)
        return -EFAULT;
    *ppos += transferred;
    return transferred;

}

ssize_t chardriver_write (struct file *filp, const char *buf, size_t nbytes, loff_t* ppos)
{
    int failed, transferred;
    if (debug)
        printk("WRITE: %d bytes\n",(int)nbytes);

    if (!nbytes)
        return 0;

    if (nbytes >=BUFFER_SIZE)
        nbytes = BUFFER_SIZE-1;

    failed = copy_from_user(char_buffer, buf, nbytes);
    transferred = nbytes - failed;

    if (!transferred)
        return -EFAULT;

    char_buffer[transferred] = '\0';

    printk("%s\n",char_buffer);

    buffer_length = transferred;
    return transferred;
}
module_init(sh_init_module);
module_exit(sh_cleanup_module);

Cross-Compiling für den Raspberry Pi

Nachdem der Treiber auf meinem Ubuntu-Rechner funktionierte, wollte ich ihn auf einem echten Embedded-System testen – meinem Raspberry Pi. Direkt auf dem Pi zu kompilieren ist zwar möglich, aber extrem langsam. Also entschied ich mich, das Modul auf meinem Hauptrechner zu cross-kompilieren – mit dem Toolchain arm-linux-gnueabihf-.

Die offizielle Raspberry-Pi-Dokumentation beschreibt den Prozess hervorragend. Wichtig ist, dass die Kernel-Header und die Version exakt mit dem System auf dem Pi übereinstimmen – sonst verweigert der Kernel das Laden des Moduls.

Das Übertragen der .ko-Datei auf den Pi und das Laden per insmod funktionierte auf Anhieb. Es fühlte sich an, als hätte ich einen Motor-Kolben aus einer Maschine ausgebaut, ihn in eine andere eingesetzt – und beide liefen trotzdem rund.

Was mir dieses Projekt beigebracht hat

Arbeiten im Kernel Space verändert den Blick auf Computer. Man versteht plötzlich die unsichtbare Maschinerie – Interrupts, Puffer, Syscalls – und wie das Betriebssystem das Chaos beherrscht.

Und man bekommt großen Respekt vor den Menschen, die täglich auf dieser Ebene programmieren. Kernel-Entwicklung verlangt Präzision, Geduld und eine ungewöhnliche Akzeptanz von Fehlern.

Für mich war dieses Projekt kein Schritt in Richtung „Kernel-Entwickler werden“. Es war reiner Forscherdrang – die Neugier zu verstehen, wie die Systeme funktionieren, die wir täglich benutzen. Und genau das macht mich letztlich zu einem besseren Maker. Denn je mehr Schichten ich begreife, desto mehr kann ich selbst gestalten.

Wie es weitergeht

Als Nächstes möchte ich tiefer in das Thema Embedded-Linux-Integration einsteigen – also Buildroot, Yocto und die Frage, wie Kernelmodule zu vollständigen System-Images zusammenwachsen.

Ganz sicher werde ich aber auch wieder in den Kernel zurückkehren. Gerade wenn ein Einplatinenrechner mit einem Mikrocontroller zusammenarbeitet, kann ein schlanker, eigener Treiber oft entscheidende Vorteile bringen – etwa weniger Latenz und mehr Performance.

Wenn du meine nächsten Schritte mitverfolgen möchtest, findest du in den kommenden Blogposts Befehle, Konfigurationsbeispiele und Troubleshooting-Tipps. Wie immer dokumentiere ich alles hier im Blog und in meinem Newsletter.

📬 Willst du wöchentliche Einblicke hinter die Kulissen meiner Werkbank (Projekte, die es nicht immer in den Podcast schaffen)? Dann abonniere mein Maker’s Logbook. 👉 herndlbauer.com/pages/newsletter



Lasst uns weiter bauen, erschaffen und lernen — gemeinsam.