Kapitel 8. Hardware-Verwaltung

Inhalt
I/O-Ports und I/O-Speicher
I/O-Ports benutzen
Digitale I/O-Ports verwenden
I/O-Speicher verwenden
Abwärtskompatibilität
Schnellreferenz

Obwohl das Spielen mit scull und ähnlichen Spielzeugen eine gute Einführung in die Software-Schnittstellen eines Linux-Gerätetreibers ist, erfordert das Implementieren eines echten Treibers Hardware. Der Treiber ist die Abstraktionsschicht zwischen Software-Konzepten und Hardware-Schaltkreisen und muß deswegen mit beiden Seiten kommunizieren. Wir haben uns bisher die Interna der Software-Konzepte angeschaut; in diesem Kapitel vervollständigen wir das Bild, indem wir Ihnen zeigen, wie ein Treiber auf I/O-Ports und I/O-Speicher zugreifen kann und trotzdem noch portabel über Linux-Plattformen hinweg bleibt.

Dieses Kapitel setzt die Tradition fort, so unabhängig von der Hardware wie möglich zu bleiben. Wenn wir aber bestimmte Beispiele benötigen, verwenden wir einfache digitale I/O-Ports (wie den Standard-Parallel-Port in PCs), um die I/O-Anweisungen vorzuführen, sowie normalen Framebuffer-basierten Videospeicher, um I/O mit Memory-Mapping zu demonstrieren.

Wir verwenden einfache digitale I/O-Ports, weil dies die einfachste Form von Eingabe-/Ausgabe-Ports sind. Außerdem implementiert der Centronics-Parallel-Port rohe I/O und steht in den meisten Computern zur Verfügung: Datenbits, die an das Gerät geschickt werden, erscheinen direkt an den Ausgabe-Pins, und Spannungsniveaus an den Eingabe-Pins können vom Prozessor direkt abgefragt werden. In der Praxis müssen Sie LEDs an den Port anschließen, um die Ergebnisse einer digitalen I/O-Operation tatsächlich sehen zu können, aber die zugrundeliegende Hardware ist extrem einfach zu benutzen.

I/O-Ports und I/O-Speicher

Jedes Peripherie-Gerät wird durch das Beschreiben und Auslesen seiner Register gesteuert. Die meisten Geräte haben mehrere Register, auf die als aufeinanderfolgende Adressen entweder im Speicher-Adreßraum oder im I/O-Adreßraum zugegriffen wird.

Auf Hardware-Ebene gibt es keinen konzeptionellen Unterschied zwischen Speicherbereichen und I/O-Bereichen: Der Zugriff auf beide erfolgt dadurch, daß man elektrische Signale auf den Adreßbus und den Steuerbus legt (d. h. die read- und write-Signale)[1] bzw. vom Datenbus liest bzw. auf ihn schreibt.

Während manche CPU-Hersteller einen einzigen Adreßraum in ihren Chips implementieren, meinen andere, daß Peripherie-Geräte sich von Speicher unterscheiden und deswegen einen separaten Adreßraum verdient haben. Manche Prozessoren (vor allem die x86-Familie) haben separate elektrische read- und write-Leitungen für I/O-Ports sowie spezielle CPU-Anweisungen, um auf diese Ports zuzugreifen.

Da Peripherie-Geräte so gebaut werden, daß sie an einem bestimmten Peripherie-Bus verwendet werden können, und weil die beliebtesten I/O-Busse auf dem PC implementiert sind, müssen selbst Prozessoren, die keinen separaten Adreßraum für I/O-Ports haben, das Lesen und Schreiben von I/O-Ports vortäuschen, wenn sie auf bestimmte Peripherie-Geräte zugreifen wollen; dies geschieht normalerweise durch externe Chipsätze oder zusätzliche Schaltkreise im CPU-Kern. Die zweite Lösung findet man hauptsächlich bei winzigen Prozessoren für eingebettete Geräte.

Aus dem gleichen Grund implementiert Linux das Konzept der I/O-Ports auf allen unterstützten Computer-Plattformen – selbst auf Plattformen, auf denen die CPU einen einzigen Adreßraum unterstützt. Die Implementation des Port-Zugriffs hängt manchmal von dem Hersteller und dem Modell des Host-Computers ab (weil verschiedene Modelle auch verschiedene Chipsätze verwenden, um Bus-Transaktionen in den Speicher-Adreßraum abzubilden).

Selbst wenn der Peripherie-Bus einen separaten Adreßraum für I/O-Ports hat, bilden trotzdem nicht alle Geräte ihre Register auf I/O-Ports ab. Die Verwendung von I/O-Ports kommt üblicherweise auf ISA-Peripherie-Karten vor; die meisten PCI-Geräte bilden Register in einen Speicher-Adreßbereich ab. Dieser I/O-Speicher-Ansatz ist im allgemeinen vorzuziehen, weil keine speziellen Prozessor-Anweisungen notwendig sind; der CPU-Kern kann auf Speicher sehr viel effizienter zugreifen, und der Compiler hat größere Freiheiten bei der Register-Zuweisung und bei der Auswahl des Adressierungsmodus, wenn er auf Speicher zugreift.

I/O-Register und konventioneller Speicher

Trotz der großen Ähnlichkeit zwischen Hardware-Registern und Speicher muß ein Programmierer, der auf I/O-Register zugreift, vorsichtig sein, um nicht von CPU- oder Compiler-Optimierungen ausgetrickst zu werden, die das erwartete I/O-Verhalten verändern können.

Der Hauptunterschied zwischen I/O-Registern und RAM besteht darin, daß I/O-Operationen Nebeneffekte haben, Speicheroperationen aber nicht: Der einzige Effekt des Schreibens in eine Speicherzelle besteht darin, einen Wert an dieser Stelle zu speichern; das Auslesen einer Speicherzelle gibt den letzten dort gespeicherten Wert zurück. Weil die Geschwindigkeit beim Speicherzugriff so entscheidend für die CPU-Performance ist, ist das Nicht-Vorhandensein von Nebeneffekten auf verschiedene Weise optimiert worden: Werte werden zwischengespeichert und Lese-/Schreiboperationen umgestellt.

Der Compiler kann Datenwerte in CPU-Registern zwischenspeichern, ohne sie in den Speicher zu schreiben; und selbst wenn die Werte gespeichert werden, können sowohl Schreib- als auch Lese-Operationen auf Cache-Speicher ablaufen, ohne jemals im physikalischen RAM anzukommen. Auch das Umstellen von Operationen kann sowohl auf Compiler- als auch auf Hardware-Ebene vorkommen: Oft kann eine Folge von Anweisungen schneller ausgeführt werden, wenn sie in einer anderen Reihenfolge als im Programmtext vorgenommen wird – beispielsweise um Verklemmungen in der RISC-Pipeline zu vermeiden. Auf CISC-Prozessoren können Operationen, die längere Zeit andauern, gleichzeitig mit anderen, schnelleren, durchgeführt werden.

Diese Optimierungen sind transparent und gutartig, wenn sie auf konventionellen Speicher angewendet werden (zumindest auf Einzelprozessor-Systemen), können aber fatal sein, wenn es um I/O-Operationen geht, weil sie dann mit den “Nebeneffekten” ins Gehege kommen, die der Hauptgrund sind, weshalb ein Treiber überhaupt auf I/O-Register zugreift. Der Prozessor kann eine Situation nicht abschätzen, in der ein anderer Prozeß (der auf einem anderen Prozessor oder gar in einem I/O-Controller läuft) von der Reihenfolge des Speicherzugriffs abhängt. Treiber müssen daher sicherstellen, daß kein Caching verwendet wird und Lese- und Schreiboperationen nicht umgestellt werden, wenn es um den Zugriff auf Register geht: Der Compiler oder die CPU könnten sonst versuchen, schlauer als Sie zu sein, und die angeforderten Operationen umstellen. Das Ergebnis können merkwürdige Fehler sein, die sehr schwer zu debuggen sind.

Das Problem der Hardware-Caches ist am einfachsten zu lösen: Die zugrundeliegende Hardware ist bereits (entweder automatisch oder durch den Initialisierungscode in Linux) so konfiguriert, daß Hardware-Caches beim Zugriff auf I/O-Bereiche (Speicher- oder Port-Bereiche) abgeschaltet werden.

Die Lösung für die Compiler-Optimierung und das Umstellen durch die Hardware besteht darin, daß zwischen Operationen, die für Hardware (oder einen anderen Prozessor) in einer bestimmten Reihenfolge sichtbar sein müssen, eine Speicherbarriere gesetzt wird. Es gibt in Linux vier Makros für alle denkbaren Notwendigkeiten der Sortierung:

#include <linux/kernel.h>, void barrier(void)

Diese Funktion weist den Compiler an, eine Speicherbarriere einzusetzen, hat aber keinen Einfluß auf die Hardware. Kompilierter Code wird alle derzeit geänderten und in CPU-Registern befindlichen Werte im Speicher ablegen und später bei Bedarf zurücklesen.

#include <asm/system.h>, void rmb(void);, void wmb(void);, void mb(void);

Diese Funktionen fügen Hardware-Speicherbarrieren in den kompilierten Instruktionsfluß ein; die eigentliche Instantiierung ist plattformabhängig. Ein rmb (read memory barrier) garantiert, daß alle Lesevorgänge vor der Barriere abgeschlossen sind, bevor weitere Lesevorgänge ausgeführt werden. wmb garantiert die Reihenfolge der Schreiboperationen, und mb garantiert beides. Jede dieser Funktionen ist eine Obermenge von barrier.

Ein typischer Anwendungsfall für Speicherbarrieren in einem Gerätetreiber kann die folgende Form haben:


writel(dev->registers.addr, io_destination_address);
writel(dev->registers.size, io_size);
writel(dev->registers.operation, DEV_READ);
wmb();
writel(dev->registers.control, DEV_GO);

In diesem Fall ist es wichtig sicherzustellen, daß alle Geräteregister, die eine bestimmte Operation steuern, ordnungsgemäß eingestellt sind, bevor die Ausführung der Operation angefordert wird. Die Speicherbarriere stellt sicher, daß die Schreiboperationen in der notwendigen Reihenfolge abgeschlossen werden.

Da Speicherbarrieren die Performance beeinflussen, sollten sie nur wo unbedingt nötig verwendet werden. Die verschiedenen Barrieren können auch verschiedene Performance-Eigenschaften haben, weswegen es sinnvoll ist, immer den spezifischsten Typ zu verwenden. Auf der x86-Architektur macht wmb() derzeit beispielsweise nichts, weil Schreiboperationen außerhalb des Prozessors ohnehin nicht umgestellt werden. Leseoperationen werden aber umgestellt, weswegen mb() langsamer als wmb() ist.

Beachten Sie auch, daß die meisten anderen Kernelprimitive, die zur Synchronisation dienen, etwa spinlock- und atomic_t-Operationen, ebenfalls als Speicherbarrieren fungieren.

Manche Architekturen erlauben die effiziente Kombination einer Zuweisung und einer Speicherbarriere. Version 2.4 des Kernels stellt einige Makros bereit, die diese Kombination durchführen; im Default-Fall sind sie folgendermaßen definiert:


#define set_mb(var, value)  do {var = value; mb();}  while 0
#define set_wmb(var, value) do {var = value; wmb();} while 0
#define set_rmb(var, value) do {var = value; rmb();} while 0

Wo es möglich ist, definiert <asm/system.h> diese Makros so um, daß sie architekturspezifische Instruktionen verwenden, die die Aufgabe schneller erledigen.

Die Header-Datei sysdep.h definiert die in diesem Abschnitt beschriebenen Makros für die Plattformen und Kernel-Versionen, in denen sie nicht vorhanden sind.

Fußnoten

[1]

Nicht alle Computer-Plattformen verwenden ein read- und ein write-Signal; manche haben andere Verfahren, um auf externe Schaltkreise zuzugreifen. Der Unterschied ist auf Software-Ebene aber irrelevant; wir nehmen hier immer read und write an, um die Diskussion zu vereinfachen.