Aus Linux-Magazin 11/2010

Kernel- und Treiberprogrammierung mit dem Kernel 2.6 - Folge 54

Abbildung 3: Nach dem Kompilieren und dem Laden des Moduls legen Experimentierfreudige ein Filesystem an und mounten das Gerät. Die hervorgehobenen Kommandos benötigen dazu Root-Rechte.

Der Blockdevice-Layer ist das Arbeitspferd bei Linux, was die Datenbeschaffung angeht – von der Festplatte bis zur Anwendung ist es ein langer Weg. Der I/O-Scheduler behält dabei den Überblick.

Mit der Zunahme moderner Speichermedien wie Flash- und Solid-State-Disks fällt der Fokus der Kernelentwickler zunehmend auf das Blockgeräte-Subsystem. Festplatten, CD-ROMs und USB-Sticks spricht der Kernel bekanntlich anders an als zeichenorientierte Geräte wie Tastatur oder serielle Schnittstelle. Während diese ihre Daten Zeichen für Zeichen liefern, geben blockorientierte Geräte ihre Daten nur im Schwall ab. 512 Byte in einem Zug sind üblich.

Da Festplatten aufgrund physikalischer Gesetze, beispielsweise das der Trägheit, für den Datentransfer etwas mehr Zeit benötigen, entkoppelt der Unix-Kernel Applikations- und Hardware-Zugriffe durch einen Zwischenspeicher. Diesen füllt der Kernel auch schon mal in vorauseilendem Gehorsam beziehungsweise er sammelt Schreibaufträge der Applikation, bevor er der trägen Festplatte die Daten zur Ablage auf die Magnetscheiben übergibt. Der erzielte Performance-Gewinn durch diesen Page- oder Buffercache genannten Zwischenspeicher ist erheblich.

Abbildung 1: Das Blockgeräte-Subsystem besteht aus mehreren Schichten.

Abbildung 1: Das Blockgeräte-Subsystem besteht aus mehreren Schichten.

Diese Architektur sorgt für eine saubere Aufgabenverteilung. Oberhalb des Pagecache nimmt der Virtual Filesystem Switch (VFS) Aufträge von den Applikationen entgegen, die diese beispielsweise mit »open()«, »read()«, »write()« und »close()« erteilt haben.

Ablagetechnik

Der VFS ordnet dem Auftrag ein block- oder zeichenorientiertes Gerät zu. Da Applikationen Daten über Dateinamen referenzieren, folgt im Blockgeräte-Subsystem die Umsetzung auf Festplattenadressierung gemäß der im verwendeten Filesystem festgelegten Vorschrift, zum Beispiel Ext 4.

Die Festplatte adressiert Linux über eine Geräteadresse, die Sektornummer und der Anzahl der Sektoren. Hält es eine Kopie der adressierten Daten im Pagecache, findet der Transfer zwischen ihm und der Applikation statt und lässt den Blockgerätetreiber außen vor. Befindet sich im Pagecache jedoch keine Kopie, dann vergibt Linux einen Auftrag an den zugehörigen Blockgerätetreiber.

Ein solcher Request besteht aus einer Quelladresse, einer Zieladresse, der Transferrichtung und der zu transferierenden Datenmenge. Dieser Auftrag landet beim I/O-Scheduler ([1], [2]).

Der I/O-Scheduler ist die Instanz, die typischerweise eine Reihe von Aufträgen einsammelt. Zusätzlich fasst er einzelne Aufträge zusammen und sortiert sie. Das vermeidet unnötige und zeitaufwändige Kopfbewegungen bei der Festplatte. Der I/O-Scheduler schreibt die Aufträge dann schließlich in eine für jeden Hintergrundspeicher vorhandene Request-Queue und aktiviert den Gerätetreiber. Dieser arbeitet seine Request-Queue ab und transportiert die Daten zwischen Festplatte und Pagecache. Sobald das erledigt ist, organisiert der Kernel den Transfer zur Applikation.

Wenngleich sich der grundsätzliche Aufbau des Blockgeräte-Subsystems nicht geändert hat, haben die Entwickler an den Schnittstellen gearbeitet. Sie tauschten mit der Zeit nicht nur Namen aus, sondern änderten auch vereinzelt Abläufe. Daher reicht es nicht, innerhalb eines vorhandenen Blockgerätetreibers beispielsweise der Version 2.6.30 nur die Funktionsnamen und Funksionsparameter zu ändern. Ein so kompiliertes Modul führt zum Stillstand des Gesamtsystems. Erst ein Reboot macht dann alles wieder gut. Für ein stabiles Linux tauschten die Entwickler zusätzlich Funktionen aus und passten Abläufe an.

Kern der Sache

Ein blockorientierter Gerätetreiber besteht im Kern aus den beiden Datenstrukturen »struct gendisk« und »struct request_queue« sowie einer Request-Funktion. Jeder vom Treiber bediente Hintergrundspeicher benötigt eine »struct gendisk«: Der Kernel entnimmt ihr unter anderem die Gesamtgröße der Festplatte in Sektoren, die Treiber-Identifikationsnummer (Majornummer, siehe Kasten “Gerätenummern im Blockgerätetreiber”), die maximale Anzahl der Partitionen (Minornummer) und den Namen der Festplatte, der maximal 32 Zeichen lang sein darf. Außerdem findet der Kernel dort den Verweis auf die Request-Queue.

Gerätenummern im
Blockgerätetreiber

Obwohl der Linux-Kernel seit Jahren auf Gerätenummern setzt, reservieren Blockgerätetreiber noch immer die aus Unix-Urtagen stammenden Major- und Minornummern mit »register_blkdev()«. Sie tauchen tatsächlich weiterhin in den Kernel-internen Datenstrukturen auf, etwa in der »struct gendisk«.

Major- und Minornummern beschränken Linux mit ihren jeweils 8 Bit auf 255 Blockgerätetreiber (0 hat eine besondere Bedeutung), die jeweils 256 Disks oder Partitionen bedienen können. Für den privaten PC zu Hause reicht das allemal, für Server im Datacenter hingegen ist das manchmal zu wenig. Gerätenummern sind 32 Bit breit und ermöglichen dadurch den Anschluss von insgesamt vier Milliarden Disks oder Partitionen. Wie viele Treiber diese bedienen, spielt dabei keine Rolle.

Eine der ersten Aufgaben der Funktion »add_disk()« ist, die in der Disk-Struktur übergebenen Major- und Minornummern auf Gerätenummern abzubilden und den entsprechenden Bereich zu reservieren (siehe Abbildung 4). Dazu ruft die Funktion intern »blk_register_region()« auf.

Abbildung 4: Linus Torvalds hat festgelegt, wie Entwickler die zugehörigen neuen Gerätenummern auf Grundlage der alten Major- und Minornummern finden, und bietet dazu eine Funktion an.

Abbildung 4: Linus Torvalds hat festgelegt, wie Entwickler die zugehörigen neuen Gerätenummern auf Grundlage der alten Major- und Minornummern finden, und bietet dazu eine Funktion an.

Mit der Größe eines Blockes oder Sektors initialisiert der Kernel die vom Treiber zu reservierende Request-Queue. Mit der Adresse eines Lock legt er einen Schutz vor parallelem Zugriff an. Für die Reservierung dieser Datenstrukturen stellt der Kernel mit »alloc_disk()« und »blk_init_queue()« Funktionen zur Verfügung, die für die notwendige Initialisierung sorgen. Die »struct request_queue« beispielsweise belegt Linux dabei mit der Adresse der Request-Funktion des Treibers. Nach der Initialisierung übergibt das Betriebssystem die »struct gendisk« per »add_disk()« dem Blockgeräte-Subsystem. Es reserviert die zugehörigen Gerätedateien und gibt den Zugriff auf die Disk frei.

Vorarbeiter

Die Request-Funktion ist das zentrale Element eines Blockgerätetreibers, führt sie doch den eigentlichen Transfer durch. Der Aufbau dieser Funktion ist für viele Treiber ähnlich: Sie arbeitet Auftrag für Auftrag ab, wobei Aufträge aus mehreren Teilaufträgen, den Segmenten, bestehen dürfen (siehe Abbildung 2).

Abbildung 2: Der Kernel legt in der Request-Queue Aufträge ab, die in Teilaufträge (Segmente) unterteilt sind.

Abbildung 2: Der Kernel legt in der Request-Queue Aufträge ab, die in Teilaufträge (Segmente) unterteilt sind.

Linux entnimmt der Request-Queue mit Hilfe der Funktion »blk_fetch_request()« einen Auftrag. Da es nicht nur solche für den Datentransport gibt, überprüft der Treiber durch Aufruf von »blk_fs_request()« den Typ. Der Beispieltreiber in Listing 1 unterstützt nur Datentransfer und quittiert andere Aufträge ohne weitere Aktion. Andere Treiber verarbeiten hier Aufträge, um etwa die Festplatte in den Stromsparmodus zu schalten oder sie wieder aufzuwecken.

Listing 1: Treiber für eine
Ramdisk (»ramdisk.c«)

01 #include <linux/fs.h>
02 #include <linux/module.h>
03 #include <linux/blkdev.h>
04 
05 #define SIZE_IN_KBYTES 256
06 
07 static int major;
08 static struct gendisk *disk;
09 static struct request_queue *bdqueue;
10 static char *mempool;
11 DEFINE_SPINLOCK(bdlock);
12 
13 static void bd_request(struct request_queue *q)
14 {
15     unsigned long start, to_copy;
16     struct request *req = blk_fetch_request(q);
17 
18     while (req) {
19         printk("req: %d - ", req->cmd_type);
20         if (!blk_fs_request(req)) {
21             if (!__blk_end_request_cur(req, 0))
22                 req = blk_fetch_request(q);
23             continue;
24         }
25         start   = blk_rq_pos(req) << 9; // * 512
26         to_copy = blk_rq_cur_bytes(req);
27 
28         spin_unlock_irq(q->queue_lock);
29         if ((start + to_copy) <= (SIZE_IN_KBYTES * 1024))
30             if (rq_data_dir(req) == READ) {
31                 printk("read %p %ld %lun",
32                        req->buffer, start, to_copy);
33                 memcpy(req->buffer, mempool + start, to_copy);
34             } else {
35                 printk("write %ld %p %lun",
36                        start, req->buffer, to_copy);
37                 memcpy(mempool + start, req->buffer, to_copy);
38             }
39         else
40             printk("%ld not in range ...n", start + to_copy);
41         spin_lock_irq(q->queue_lock);
42         if (!__blk_end_request_cur(req, 0))
43             req = blk_fetch_request(q);
44         else
45             printk("followup: ");
46     }
47 }
48 static struct block_device_operations bdops =
49    { .owner = THIS_MODULE, };
50 
51 static int __init mod_init(void)
52 {
53     if (!(major = register_blkdev(0,"bds"))) {
54         printk("bd: can't get majornumbern");
55         return -EIO;
56     }
57     printk("major: %dn", major);
58     if (!(mempool = vmalloc(SIZE_IN_KBYTES * 1024))) {
59         printk("vmalloc failed ...n");
60         goto out_no_mem;
61     }
62     if (!(disk = alloc_disk(1))) {
63         printk("alloc_disk failed ...n");
64         goto out;
65     }
66     disk->major       = major;
67     disk->first_minor = 0;
68     sprintf(disk->disk_name, "bds0");
69     set_capacity(disk, (SIZE_IN_KBYTES * 1024) >> 9); // 512 Byte blocks
70     disk->fops = &bdops;
71 
72     if (!(bdqueue = blk_init_queue(&bd_request, &bdlock)))
73         goto out;
74     blk_queue_logical_block_size(bdqueue, 512);
75     disk->queue = bdqueue;
76 
77     add_disk(disk);
78     return 0;
79 out:
80     vfree(mempool);
81 out_no_mem:
82     unregister_blkdev(major, "bds");
83     return -EIO;
84 }
85 
86 static void __exit mod_exit(void)
87 {
88     unregister_blkdev(major, "bds");
89     del_gendisk(disk);
90     put_disk(disk);
91     blk_cleanup_queue(bdqueue);
92     vfree(mempool);
93 }
94 
95 module_init(mod_init);
96 module_exit(mod_exit);
97 MODULE_LICENSE("GPL");

Ein Request, genauer gesagt jedes Segment, besteht aus den Transferinformationen Quelladresse, Zieladresse, Transferrichtung und Mengenangabe. Eine der beiden Adressen liegt im Hauptspeicher im Kernelspace, die andere auf dem Hintergrundspeicher. Adressen auf ihm geben Kernelhacker – ebenso wie die Mengenangabe – in Sektoren an. Dazu nennen sie den Startsektor und die Anzahl der zu transferierender Sektoren.

Transferleistung

Kompliziert wird der Transfer dadurch, dass der I/O-Scheduler Aufträge für den optimierten Zugriff auf Festplatten zusammenfasst. Liegen Datenblöcke auf der Festplatte in direkt aufeinanderfolgenden Sektoren, lassen die sich prima in einem Rutsch lesen. Jedoch muss der Kernel diese Datenpakete unter Umständen an unterschiedlichen Stellen im Pagecache des Hauptspeichers ablegen, da dieser gelegentlich fragmentiert. Daher zerlegt der Treiber den Request in Teilaufträge und arbeitet sie einzeln ab.

Der Treiber liest aus dem Request über die Makros »blk_rq_pos()« und »blk_rq_cur_sector()« oder »blk_rq_cur_bytes()« die Adressenangaben eines Teilauftrags für den Zugriff auf die Disk. Die Funktion »rq_data_dir()« ermittelt die Transferrichtung. Erst auf Basis dieser Angaben transportiert Linux die Daten zum Ziel.

Der Kernel antizipiert, dass er innerhalb der Request-Funktion ohnehin Manipulationen an der Request-Queue vornimmt. Daher sperrt er selber das Lock, ruft die Request-Funktion auf und gibt es hinterher wieder frei. Wenn allerdings die Request-Funktion längere Zeit läuft und dabei das Lock gesperrt bleibt, führt das zu unnötigen und ineffizienten Latenzzeiten. Aus diesem Grunde sollten verantwortungsbewusste Treiberentwickler in solchen Situationen das Lock zwischendurch freigeben, es aber vor Ende der Request-Funktion wieder sperren. Der Kernel kann ja nicht wissen, dass das Lock innerhalb der Funktion zwischenzeitlich frei wurde.

Das Lock ist übrigens der Übeltäter, der bei älterem Code zum Einfrieren des Systems sorgt. Nachdem der Treiber einen Request abgearbeitet hat, muss er nämlich dem Blockgeräte-Subsystem Erfolg oder Misserfolg mitteilen. Dazu dienen die Funktionen »blk_end_request()«, »blk_end_request_all()«, »blk_end_request_cur()«, »__blk_end_request()«, »__blk_end_request_cur()« und »__blk_end_request_all()«.

Die drei letzten Funktionen gehen davon aus, dass sie die als ersten Parameter übergebene Request-Queue ohne weitere Sicherungsmaßnahmen modifizieren dürfen. Andersherum formuliert: Das zur Sicherung eingesetzte Spinlock hält bereits die aufrufende Funktion (so im Fall von Listing 1). Demgegenüber reservieren die ersten drei Funktionen ohne führenden Unterstrich explizit das Spinlock, bevor sie die Quittung in der Request-Queue verarbeiten. Der Versuch jedoch, ein bereits reserviertes Spinlock ein zweites Mal zu reservieren, führt per defintionem zu aktivem Stillstand.

Erfolgskontrolle

Ob er einen Auftrag erfolgreich bearbeitet hat oder nicht, übermittelt der Treiber dem Blockgeräte-Subsystem mit dem zweiten Parameter. Eine Rückgabe von 0 steht für Erfolg, ein anderer Wert repräsentiert einen Fehlercode. Die Antwort der Quittungsfunktion »blk_end_request_cur()« informiert darüber, ob der Treiber den Request komplettiert hat. Dann liefert er den Returncode 0. Sollte es noch einen weiteren Teilauftrag geben, teilt der Treiber dies ebenfalls mit. Dann liefern »blk_rq_pos()« und »blk_rq_cur_bytes()« die nächsten Transferparameter. Die Request-Funktion holt sich also einen Request, arbeitet ihn Segment für Segment ab und nimmt sich anschließend den nächsten vor, bis alle Aufträge erledigt sind.

Listing 1 zeigt den möglichen Aufbau einer Request-Funktion. Der Code realisiert einen einfachen Ramdisk-Treiber, der die Daten per »memcpy()« in den bei der Initialisierung per »vmalloc()« reservierten Speicherbereich ablegt. Da der Code keine wirkliche Disk, sondern Hauptspeicher adressiert, benötigt er die Adressenangaben nicht in Sektoren, sondern in Byte. Um aus einer Sektor- eine Byte-Adresse zu erhalten, multipliziert der Entwickler die Sektoranzahl mit der Sektorgröße, also mit 512 Byte. In der Binärarithmetik entspricht die Multiplikation mit 512 dem Linksschieben um 9 Bit (Listing 1, Zeile 25).

Während der Treiber-Initialisierung legt das Modul durch den Aufruf der Funktion »set_capacity()« die Größe des Hintergrundspeichers fest. Sie benötigt wiederum eine Angabe in Sektoren, die im Fall der Ramdisk als Byte-Adresse vorliegt; daher das Rechtsschieben um 9 Bit.

Die im Beispielcode vorkommende Struktur »struct block_device_operations« benötigen Entwickler übrigens, um einen Entladeschutz zu realisieren. Dazu setzen sie das Element »owner« auf »THIS_MODULE« und verankern die Struktur im Disk-Objekt.

Zurück marsch, marsch!

Last but not least gilt es aufzuräumen. Die Entladefunktion »__exit mod_exit()« des Treibers macht alles wieder rückgängig: Sie gibt mit »unregister_blkdev()« die Majornummer, die beiden reservierten Objekte »struct gendisk« und »struct request_queue« durch »del_gendisk()«, »put_disk()« sowie »blk_cleanup_queue()« wieder frei. Der Aufruf von »vfree()« führt den Hauptspeicher der Wiederverwertung zu.

Listing 2: Makefile

01 ifneq ($(KERNELRELEASE),)
02 obj-m   := ramdisk.o
03 else
04 KDIR    := /lib/modules/$(shell uname -r)/build
05 PWD     := $(shell pwd)
06 
07 default:
08     $(MAKE) -C $(KDIR)  M=$(PWD) modules
09 endif

Um den Treiber zu testen, kompilieren Experimentierfreudige den Code aus Listing 1 mit Hilfe des Makefile aus Listing 2, laden den resultierenden Treiber, erzeugen ein Filesystem und mounten dann das Gerät: Anschließend dürfen sie die Ramdisk verwenden. Die vom Treiber automatisiert angelegte Gerätedatei lautet »/dev/bds0«. Abbildung 3 zeigt die zur Anschauung gedachte Ramdisk. In realen Einsatzszenarien sollten Entwickler jedoch besser RAM-Filesysteme verwenden, da sie eine Reihe von Vorteilen bieten [3]. (mg)

Abbildung 3: Nach dem Kompilieren und dem Laden des Moduls legen Experimentierfreudige ein Filesystem an und mounten das Gerät. Die hervorgehobenen Kommandos benötigen dazu Root-Rechte.

Abbildung 3: Nach dem Kompilieren und dem Laden des Moduls legen Experimentierfreudige ein Filesystem an und mounten das Gerät. Die hervorgehobenen Kommandos benötigen dazu Root-Rechte.

Infos

[1] Eva-Katharina Kunst, Jürgen Quade, “Kern-Technik”, Folge 19: Linux-Magazin 03/05

[2] Eva-Katharina Kunst, Jürgen Quade, “Kern-Technik”, Folge 20: Linux-Magazin 04/05

[3] Eva-Katharina Kunst, Jürgen Quade, “Kern-Technik”: Folge 39, Linux-Magazin 05/08

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