Aus Linux-Magazin 11/2024

Kernel- und Treiberprogrammierung mit dem Linux-Kernel – Folge 136

© Pavel Byrkin / 123RF.com

Um Daten schnell auf Speichermedien hin und her schaufeln zu können, definiert das Multiqueue-Subsystem im Kernel separate Warteschlangen für ein- und ausgehende Transfers. Damit lässt sich in wenigen Zeilen Code ein Blockgerätetreiber für eine RAM-Disk bauen.

Der Zugriff auf persistente Speicher wie SSDs, Festplatten oder andere Speichermedien entscheidet über die Gesamtleistung eines Rechnersystems. Die auch als Massenspeicher oder Secondary Storage bezeichneten Geräte lassen sich ausschließlich in Blöcken von fester Größe lesen und schreiben.

Während man auf den Hauptspeicher eines Systems als Primary Storage direkt über die CPU zugreifen kann, sind CPU und Secondary Storage hardwaretechnisch über einen (Platten-)Controller miteinander verknüpft. Das verdeutlicht bereits, dass es für den Zugriff auf diese Blockgeräte Treiber braucht, die als Vermittler zwischen den Anwendungen und der Hardware fungieren. Diese Treiber klinken sich in das Blockgeräte-Subsystem ein, das die Kernel-Entwickler ständig optimieren. Schon wenige Jahre alter Code läuft daher nur mit Anpassungen auf aktuellen Kerneln [1].

Gut also, dass Linux bereits eine Vielzahl derartiger Blockgerätetreiber mitbringt. Einmal davon abgesehen, dass es darum geht, Einblick in die spannende Technik des Linux-Kernels zu gewinnen, gibt es darüber hinaus diverse Szenarien, für die dennoch die Entwicklung eines eigenen Treibers sinnvoll ist. Dazu zählt unter anderem die Anbindung spezieller Flash-Bausteine.

Block für Byte

Zu den Hauptfunktionen eines solchen Blockgerätetreibers gehört das Kopieren von Daten zwischen dem Blockgerät, etwa einem USB-Stick, und dem Hauptspeicher. Da Anwendungen nur in seltenen Fällen direkt auf die Geräte zugreifen, kommen die Kopieraufträge typischerweise vom Betriebssystem selbst. Schließlich greifen Anwendungen nicht auf irgendwelche Blöcke zu, sondern verwalten Daten über Dateien hierarchisch in Verzeichnissen. Für Anwendungen soll transparent bleiben, dass der Zugriff lediglich blockweise funktioniert.

Selbst wenn die Anwendung lediglich ein einzelnes Byte lesen will, erteilt der Kernel dem Blockgerätetreiber den Auftrag, einen kompletten Block in den Hauptspeicher zu wuchten. Aus dieser Kopie kann sich die Applikation die benötigte Information herauspicken. Umgekehrt funktioniert das genauso: Will die Anwendung ein Byte in einer Datei ändern, wird der zum Byte gehörende Block eingelesen. Die Applikation verändert das Datum, und bei passender Gelegenheit wird der Block per Treiber zurück auf das Blockgerät geschrieben. Für den Treiber sind das zwei unabhängige Transferaufträge.

Optimalerweise bestehen solche Aufträge darin, direkt eine Vielzahl von Blöcken zu lesen oder zu schreiben, insbesondere da wir ja von Massenspeichern reden. Diese Massen an Daten liegen aber aufgrund von Fragmentierung weder in den Hauptspeicherkopien noch auf den Blockgeräten in zusammenhängender Form, sodass man diesen Umstand durch Einsatz geeigneter Datenstrukturen berücksichtigen muss.

Ineinander geschachtelt

Linus Torvalds hat dazu im Linux-Kernel die »struct bio« aus der Taufe gehoben, die über die Unterstruktur »struct bvec_iter« einen zusammenhängenden Bereich auf dem Speichermedium spezifiziert. Dazu verweist sie auf die Nummer des ersten Blocks (»bi_sector«), die Anzahl an Blöcken (Sektoren) und auf eine Liste von zugehörigen Bereichen im Hauptspeicher (»struct bvec«). Letztere sind über die Speicherseite (Page), eine Längenangabe und einen Offset innerhalb einer Page spezifiziert (Abbildung 1). Ein Auftrag (Request) kann aus mehreren, in Form einer einfach verketteten Liste miteinander verbundenen »bio«-Blöcken bestehen.

Linux stellt einen Blockgerätetreiber vor die Wahl, entweder Requests oder »bio«-Blöcke zu verarbeiten. Während »bio«-Blöcke unsortiert in der vom Kernel generierten Reihenfolge an den Treiber gehen, nimmt der Kernel beim Erstellen der Requests umfangreiche Optimierungen vor. So gibt er Aufträge nicht direkt zur Abarbeitung frei, sondern reiht sie zunächst in Auftragswarteschlangen ein. Dementsprechend lässt sich die Reihenfolge der Aufträge verändern, um der Hardware eine höchstmögliche Lese- und Schreibrate zu gestatten. Zusätzlich fasst der Kernel – falls sinnvoll – unabhängige Transfers zu einem Auftrag zusammen.

Abbildung 1: Aufbau der »struct bio«.

Abbildung 1: Aufbau der »struct bio«.

Tatsächlich implementiert Linux nicht nur eine Warteschlange pro Blockgerät. Vielmehr gibt es mehrere Warteschlangen auf zwei Ebenen (Abbildung 2). So hat der Kernel-Entwickler Jens Axboe Eingangswarteschlangen (Request- oder Submission-Queue) und Ausgangswarteschlangen (Dispatch-Queue) definiert. Die Eingangswarteschlangen nehmen die Aufträge aus der Anwendung entgegen. Der Einsatz mehrerer Eingangswarteschlangen hilft dabei, die durch Multicore-Hardware bedingte Parallelarbeit auszunutzen. Die Ausgangswarteschlangen enthalten die Aufträge, die letztlich an den Blockgerätetreiber fließen und im Kontext mit der (Controller-)Hardware abgearbeitet werden. Bietet die Hardware mehrere physische Kommandokanäle, lässt sich das über mehrere Ausgangswarteschlangen abbilden.

Abbildung 2: Wie der Name andeutet, gibt es beim Multiqueue-Subsystem im Linux-Kernel mehrere Warteschlangen auf zwei Ebenen.

Abbildung 2: Wie der Name andeutet, gibt es beim Multiqueue-Subsystem im Linux-Kernel mehrere Warteschlangen auf zwei Ebenen.

Skalpell, bitte!

Seziert man einen einfachen Blockgerätetreiber, findet sich die Allokation und Initialisierung der Dispatch-Warteschlange hinter dem Objekt vom Typ »struct tag_set« (Abbildung 3). Intern erhalten einzelne Aufträge eine auch als Tag bezeichnete ID; daher der Name. Das Tag-Set wird mit der Adresse des Request-Callbacks initialisiert. Hinzu kommen Informationen zur Anzahl der Hardware-Queues und deren Tiefe.

Über Flags konfigurieren Sie Eigenschaften, beispielsweise ob der Linux-Kernel Aufträge zusammenfassen darf, die nebeneinanderliegende Blöcke betreffen. Die für die Verwaltung notwendigen und in der »struct tag_set« beschriebenen Ressourcen reservieren Sie schließlich durch Aufruf von »blk_mq_alloc_tag_set()« im Kernel.

Abbildung 3: Kernkomponenten eines Blockgerätetreibers.

Abbildung 3: Kernkomponenten eines Blockgerätetreibers.

Die Request-Queue verbirgt sich hinter dem Objekt vom Typ »struct gendisk«. Das Generic-Disk-Objekt speichert alle benötigten Daten zum Blockgerät. Dazu gehören neben der Major-Nummer und den Minor-Nummern unter anderem der Name des Geräts, seine Kapazität (Größe in Sektoren) und die Größenangaben zu physikalischen und logischen Blockgrößen. Zusätzlich gibt es eine Liste mit dem Disk-Objekt verknüpfter Callback-Adressen. Sie werden aufgerufen, wenn auf die zum Blockgerät gehörende Gerätedatei ein Open oder Close erfolgt. Außerdem lässt sich darüber ein Ioctl-Callback einhängen, über den man aus dem Userland heraus Informationen zum Blockgerät auslesen kann. Zu guter Letzt stellt das Disk-Objekt die Verbindung zwischen der Auftragswarteschlange für eingehende Requests und dem »tag_set« für die ausgehenden Aufträge her.

Sie allozieren ein Disk-Objekt per »blk_mq_alloc_disk()« und übergeben es nach erfolgter Initialisierung per »add_disk()« wieder dem Kernel.

Multiqueue-Subsystem

Listing 1 zeigt den Quellcode eines Blockgerätetreibers, der eine simple RAM-Disk implementiert. Die zentralen Datenobjekte deklarieren Sie gleich zu Beginn, nämlich das Disk-Objekt »gd« (Zeile 11) für die Anbindung an die Anwendungen und das Objekt »tag_set« (Zeile 10) für die Kopplung mit der Hardware. Im Fall einer RAM-Disk gibt es keine reale Hardware; die Daten landen einfach im RAM des Rechners. Die Anfangsadresse dieses Datenspeichers findet sich in der Variablen »device_data« (Zeile 12).

Eine Gerätedatei macht den Treiber später im Userland sichtbar. Wie bei einem zeichenorientierten Gerätetreiber lassen sich Funktionen hinterlegen, die der Kernel aufruft, sobald eine Applikation ein »open()«, »close()«, »ioctl()« oder sonst eine verwandte Funktion aufruft. Die im Beispiel implementierten Funktionen »simple_block_open()« (ab Zeile 14) und »simple_block_close()« (ab Zeile 19) besitzen hier funktional keine Bedeutung. Sie veranschaulichen lediglich die Abläufe im Sinne eines Debuggings oder Tracings (Zeilen 25 und 26).

Listing 1

RAM-Disk-Treiber

#include <linux/module.h>
#include <linux/blkdev.h>
#include <linux/blk-mq.h>
#define DEVICE_NAME "simple-bd"
#define MYSECTOR_SIZE 512
#define NSECTORS 16384
static int major_num = 0;
static struct blk_mq_tag_set tag_set;
static struct gendisk *gd;
static char *device_data;
static int simple_block_open(struct gendisk *disk, fmode_t mode) {
  pr_info("simple_block_open()\n");
  return 0;
}
static void simple_block_close(struct gendisk *gd) {
  pr_info("simple_block_close()\n");
}
static const struct block_device_operations fops = {
  .owner = THIS_MODULE,
  .open = simple_block_open,
  .release = simple_block_close,
};
static blk_status_t simple_block_request(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd) {
  blk_status_t status = BLK_STS_OK;
  struct request *req = bd->rq;
  struct bio_vec bv;
  struct req_iterator iter;
  loff_t pos = req->bio->bi_iter.bi_sector << SECTOR_SHIFT;
  void *dev_data = device_data + pos;
  pr_info( "simple_block_request: %lld,\n", pos );
  blk_mq_start_request(req);
  rq_for_each_segment(bv, req, iter) {
    void *buffer = page_address(bv.bv_page) + bv.bv_offset;
    unsigned int len = bv.bv_len;
    pr_info(
 "rq_for_each_segment( %d )\n", len);
    switch (req_op(req)) {
      case REQ_OP_READ:
        memcpy(buffer, dev_data, len);
        break;
      case REQ_OP_WRITE:
        memcpy(dev_data, buffer, len);
        break;
      default:
        status = BLK_STS_IOERR;
        goto done;
    }
    dev_data += len;
  }
done:
  blk_mq_end_request(req, status);
  return status;
}
static struct blk_mq_ops simple_block_mq_ops = {
  .queue_rq = simple_block_request,
};
static int simple_block_init(void) {
  int ret;
  device_data = vmalloc(NSECTORS * SECTOR_SIZE); // ramdisk-memory
  if (!device_data) {
    pr_err("vmalloc() failed\n");
    return -ENOMEM;
  }
  memset(device_data, 0, NSECTORS * SECTOR_SIZE);
  major_num = register_blkdev(major_num, DEVICE_NAME);
  if (major_num <= 0) {
    pr_err("register_blkdev() failed\n");
    vfree(device_data);
    return -EBUSY;
  }
  pr_info("init tag_set()\n");
  tag_set.ops = &simple_block_mq_ops;
  tag_set.nr_hw_queues = 1;
  tag_set.nr_maps = 1;
  tag_set.queue_depth = 128;
  tag_set.numa_node = NUMA_NO_NODE;
  tag_set.flags = BLK_MQ_F_SHOULD_MERGE | BLK_MQ_F_STACKING;
  tag_set.cmd_size = 0;
  tag_set.driver_data = &tag_set;
  ret = blk_mq_alloc_tag_set(&tag_set);
  if (ret) {
    pr_err("blk_mq_alloc_tag_set() failed\n");
    goto out_unregister_blkdev;
  }
  gd = blk_mq_alloc_disk(&tag_set, NULL);
  if (!gd) {
    pr_err("blk_mq_alloc_disk() failed\n");
    goto out_free_tag_set;
  }
  gd->flags |= GENHD_FL_NO_PART;
  gd->major = major_num;
  gd->first_minor = 0;
  gd->minors = 1;
  gd->fops = &fops;
  gd->private_data = NULL;
  snprintf(gd->disk_name, 32, DEVICE_NAME);
  set_capacity(gd, NSECTORS);
  blk_queue_physical_block_size(gd->queue, SECTOR_SIZE);
  blk_queue_logical_block_size(gd->queue, SECTOR_SIZE);
  blk_queue_max_hw_sectors(gd->queue,BLK_SAFE_MAX_SECTORS );
  blk_queue_flag_set(QUEUE_FLAG_NOMERGES, gd->queue);
  ret = add_disk(gd);
  if (ret) {
    pr_err("add_disk( \"%s\" ) failed\n", gd->disk_name);
    goto out_put_disk;
  }
  pr_info("simple_block_init()\n");
  return 0;
out_put_disk:
  put_disk(gd);
out_free_tag_set:
  blk_mq_free_tag_set( &tag_set );
out_unregister_blkdev:
  unregister_blkdev(major_num, DEVICE_NAME);
  vfree(device_data);
  return -EIO;
}
static void simple_block_exit(void) {
  pr_info( "simple_block_exit()\n");
  del_gendisk(gd);
  put_disk(gd);
  blk_mq_free_tag_set( &tag_set );
  unregister_blkdev(major_num, DEVICE_NAME);
  vfree(device_data);
}
module_init(simple_block_init);
module_exit(simple_block_exit);
MODULE_LICENSE("GPL");

Datenschieber

Die Hauptaufgabe des Treibers besteht im Transfer von Daten zwischen Gerät und Hauptspeicher. Im Fall einer RAM-Disk, bei der der Gerätespeicher als Hauptspeicher dient, erfolgt der Transfer von der Hauptspeicheradresse A zur Hauptspeicheradresse B. In diesem speziellen Fall setzen Sie die Anforderung per »memcpy()« um (Listing 1, Zeilen 44 und 47). Die Transferrichtung wertet das Makro »req_op()« aus (Zeile 42). Sie können vom Gerät per »REQ_OP_READ« (Zeile 43) lesen oder via »REQ_OP_WRITE« (Zeile 46) darauf schreiben.

Beim Transferauftrag gehen wir von einem zusammenhängenden Bereich auf dem Gerät und einem unzusammenhängenden Bereich im Hauptspeicher aus. Dementsprechend müssen Sie über die einzelnen Hauptspeicherbereiche (Segmente) iterieren. Dabei hilft das Makro »rq_for_each_segment()« (ab Zeile 38). Hier werden die einzelnen Elemente vom Typ »bio_vec« zurückgeliefert, die alle Infos zur Hauptspeicheradresse enthalten. Die Geräteadresse lesen Sie aus dem Request ab, alternativ und redundant befindet sie sich aber auch im Objekt »bvec_iter«.

Bevor jedoch die Iteration und der Datentransfer starten können, müssen Sie dem Kernel per »blk_mq_start_request()« (Zeile 37) signalisieren, dass sich ein Auftrag in Bearbeitung befindet. Sobald alle Transfers abgearbeitet sind, informiert die Funktion »blk_mq_end_request()« (Zeile 56) über den Abschluss des Vorgangs und ein Return-Code von »BLK_STS_OK« (Zeile 30) schließlich über den Erfolg.

Funzt!

Der Speicher für die RAM-Disk reserviert die Modulinitialisierung (»simple_block_init()«, Zeile 64) per »vmalloc()« (Zeilen 66 und 68). Im Beispiel handelt es sich dabei um 16 384 Blöcke zu je 512 Byte, also 8 MByte. Virtuell erscheint das dank »vmalloc()« und Memory-Management als zusammenhängender Speicherbereich, der tatsächlich allerdings unzusammenhängend im RAM des Rechners verteilt sein kann.

Für den Zugriff aus dem Userland heraus über die zugehörige Gerätedatei braucht es eine Major-Nummer, die die Funktion »register_blkdev()« (Zeile 72) unter Angabe des zugehörigen Namens unseres Treibers liefert. Von der Reihenfolge her wird daraufhin das Objekt »tag_set« initialisiert und anschließend das Disk-Objekt alloziert, konfiguriert und dem Kernel übergeben. Der Treiber lässt sich abschließend aktiv nutzen.

Im Fehlerfall und beim Entladen des Moduls (»simple_block_exit()«, ab Zeile 126) ist Aufräumen angesagt. Dabei arbeiten Sie die einzelnen Schritte durch Aufruf der korrespondierenden Gegenfunktion in umgekehrter Reihenfolge ab (siehe Tabelle “Multiqueue-API (Auswahl)”).

Anlegen

Aufräumen

»register_blkdev()«

»unregister_blkdev()«

»blk_mq_alloc_tag_set()«

»blk_mq_free_tag_set()«

»blk_mq_alloc_disk()«

»put_disk()«

»add_disk()«

»del_gendisk()«

»vmalloc()«

»vfree()«

Um den Quellcode »sbdev-request.c« aus Listing 1 auszutesten, erzeugen Sie den Blockgerätetreiber mithilfe des Makefiles aus Listing 2 durch Aufruf von Make. Das resultierende Kernel-Modul laden Sie per »insmod sbdev-request.ko«. Anschließend finden Sie die Gerätedatei unter dem Pfad »/dev/simple-bd/«. Das Kommando »lsblk« zeigt das Blockgerät inklusive dessen Größe. Mit einem Programm wie Dd können Sie bereits zugreifen und gezielt einzelne Blöcke lesen oder schreiben (Listing 3).

Listing 2

Makefile

obj-m += sbdev-request.o
all:
  make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
  make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Listing 3

Einzelblöcke schreiben

$ dd if=/dev/simple-bd of=foo.dd count=2 iseek=40 bs=512

Preisgabe

Im Syslog, das Sie in einem zweiten Terminal durch Eingeben des Befehls »sudo dmesg –follow« verfolgen, finden Sie die internen Abläufe. Da wir mit dem Kommando 40 Blöcke überspringen, startet der Datentransfer bei Byte 20 480. Generell sollten Sie bedenken, dass der Kernel intern von einer Seitengröße von 4096 Bytes (4k) ausgeht und seine Transfers daraufhin ausrichtet, auch wenn die Anwendung weniger Daten anfordert und das Gerät eine andere Blockgröße verwendet.

Der Zugriff über Dd kommt typischerweise nur zum Anlegen einer 1:1-Kopie beziehungsweise zum Zurückspielen einer solchen Kopie zum Einsatz. Wollen Sie Ihre RAM-Disk jetzt tatsächlich als solche verwenden, sie ins Dateisystem einhängen und darauf Daten lesen und schreiben, müssen Sie sie mit einem Filesystem formatieren und einhängen. Alle Kommandos für einen einfachen Test finden Sie in Listing 4.

Listing 4

Test der RAM-Disk

$ sudo su
# insmod sbdev-request.ko
# mkfs.ext4 /dev/simple-bd
# mount /dev/simple-bd /mnt
# ls /mnt
# cp /etc/issue /mnt
# ls /mnt
# cat /mnt/etc/issue
# umount /dev/simple-bd
# mount /dev/simple-bd /mnt
# cat /mnt/etc/issue
# umount /mnt
# rmmod sbdev-request

Dabei wird der Treiber zunächst geladen und mit einem Dateisystem versehen. Im Anschluss hängen Sie die zugehörige Gerätedatei »/dev/simple-bd« in das Verzeichnis »/mnt« ein (dritte Zeile). Das nachfolgende »ls« verrät, dass es dort vom Formatieren das Verzeichnis »lost+found« gibt. Letztlich werden per Cp (Zeile 7) Daten auf die RAM-Disk geschrieben und dann per Cat (Zeilen 8 und 11) ausgelesen. Das folgende Aushängen, Wiedereinhängen und erneute Auslesen der zuvor kopierten Datei »issue« zeigt, dass auch über das Aushängen hinweg die Daten der RAM-Disk erhalten bleiben. Abbildung 4 visualisiert den Testablauf.

Abbildung 4: Die RAM-Disk im Einsatz.

Abbildung 4: Die RAM-Disk im Einsatz.

Unvollkommen

Der vorgestellte Treiber ist in den Punkten Funktionalität, Fehlerbehandlung und Performance durchaus verbesserungswürdig. So unterstützt er beispielsweise keine Partitionierung und auch keine Ioctls. Er reagiert weder auf Hardwarefehler, die bei einem echten Blockgerät vorkommen, noch auf beschädigte Daten. Würde statt des Request-Interfaces das Bio-Block-Interface verwendet, wäre er sicherlich noch etwas schneller. Ein Detail am Rande: Die Geschwindigkeit lässt sich mithilfe des Kommandos »hdparm -t /dev/simple-bd« austesten. Allerdings sollten Sie dazu zuvor die Debug-Ausgaben in der Transferfunktion auskommentieren. (csi)

Der Autor

Jürgen Quade, Professor an der Hochschule Niederrhein, gibt auch für Unternehmen Schulungen zu den Themen Treiberprogrammierung und Embedded Linux.

Infos

  1. Kern-Technik: Eva-Katharina Kunst, Jürgen Quade, “Blockabfertigung”, LM 11/2021, S. 70, https://www.lm-online.de/44715
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben