Kapitel 6. Der Lauf der Zeit

Inhalt
Zeit-Intervalle im Kernel
Die aktuelle Zeit ermitteln
Die Ausführung verzögern
Task-Schlangen
Kernel-Timer
Abwärtskompatibilität
Schnellreferenz

An dieser Stelle angelangt wissen wir, wie man ein vollständiges Zeichen-Modul schreibt. Richtige Treiber müssen aber mehr tun, als nur die notwendigen Operationen implementieren; sie haben es mit Fragen wie Timing, Speicherverwaltung, Hardware-Zugriff usw. zu tun. Glücklicherweise stellt der Kernel eine Reihe von Funktionalitäten zur Verfügung, die dem Treiber-Autor das Leben leichter machen. In den nächsten paar Kapiteln liefern wir die Informationen über einige der Kernel-Ressourcen, die zur Verfügung stehen, und fangen dabei mit den Timing-Fragen an. Dazu gehören, in ansteigender Komplexität:

ark=bullet>

Zeit-Intervalle im Kernel

Der erste Punkt, den wir behandeln müssen, ist der Timer-Interrupt. Das ist der Mechanismus, den der Kernel verwendet, um sich Zeit-Intervalle zu merken. Interrupts sind asynchrone Ereignisse, die normalerweise von externer Hardware ausgelöst werden; die CPU wird in ihrer Aktivität unterbrochen und führt speziellen Code (die Interrupt Service Routine, ISR) aus, um den Interrupt zu bedienen. Interrupts und Fragen der ISR-Implementation werden in Kapitel 9 behandelt.

Timer-Interrupts werden von der Timing-Hardware des Systems in regelmäßigen Abständen erzeugt; dieses Intervall wird vom Kernel entsprechend des Wertes von HZ eingestellt, einem in <linux/param.h> definierten architektur-abhängigen Wert. Die derzeitigen Linux-Versionen definieren HZ auf den meisten Plattformen mit 100, auf manchen aber mit 1024, und der IA-64-Simulator verwendet 20. Unabhängig davon, was Ihre Plattform verwendet, sollten Sie sich nie auf einen bestimmten Wert für HZ verlassen.

Jedesmal, wenn ein Timer-Interrupt auftritt, wird der Wert jiffies inkrementiert. jiffies wird beim Starten des Systems auf 0 gesetzt und gibt also die Anzahl der Clock Ticks an, die erfolgt sind, seit der Computer eingeschaltet wurde. Dieser Wert ist in <linux/sched.h> als unsigned long volatile deklariert und läuft möglicherweise nach längerem andauernden Systembetrieb über (allerdings auf keiner Plattform nach weniger als sechzehn Monaten Systemlaufzeit). Die Entwickler haben viel Zeit darauf verwendet sicherzustellen, daß der Kernel korrekt arbeitet, wenn jiffies überläuft. Treiber-Autoren müssen sich darüber normalerweise keine Gedanken machen, aber es ist gut zu wissen, daß dies passieren kann.

Es ist möglich, den Wert von HZ zu verändern, wenn man ein System mit einer anderen Interrupt-Frequenz benötigt. Manche Leute, die Linux für kritische Echtzeit-Anwendungen verwenden, haben den Wert von HZ vergrößert, um bessere Antwortzeiten zu bekommen; das bezahlen sie mit der Extralast durch die zusätzlichen Timer-Interrupts. Insgesamt kommt man aber mit den Timer-Interrupts am besten aus, wenn man den HZ-Wert läßt, wie er ist, und sich so auf die Kernel-Entwickler verläßt, die sicherlich den besten Wert gewählt haben.

Prozessorspezifische Register

Wenn Sie sehr kurze Intervalle messen müssen oder eine extrem hohe Präzision benötigen, dann können Sie zu plattformabhängigen Ressourcen greifen und damit die Präzision über die Portabilität stellen.

Die meisten modernen CPUs enthalten einen Zähler mit hoher Auflösung, der in jedem Taktzyklus inkrementiert wird; dieser Zähler kann für genaue Messungen von Zeit-Intervallen verwendet werden. In Anbetracht der inhärenten Unvorhersagbarkeit des Timings von Anweisungen auf den meisten Systemen (aufgrund des Schedulings von Anweisungen, Branch Prediction und Cache-Speichern) ist dieser Taktzähler die einzige verläßliche Möglichkeit, mit sehr kurzen Zeiten umzugehen. Als Reaktion auf die äußerst hohe Geschwindigkeit moderner Prozessoren, die drängende Nachfrage nach empirischen Performance-Werten und die intrinsische Unvorhersagbarkeit des Timings von Anweisungen in CPU-Designs aufgrund von mehreren Ebenen von Cache-Speicher, haben die CPU-Hersteller mit dem Zählen von Taktzyklen eine einfache und verläßliche Möglichkeit eingeführt, Zeitabstände zu messen. Die meisten modernen Prozessoren enthalten also ein Zähler-Register, das einmal pro Taktzyklus inkrementiert wird.

Die Details unterscheiden sich von Plattform zu Plattform: das Register kann vom User-Space aus gelesen werden oder nicht, es kann beschrieben werden oder nicht, und es kann 64 Bits oder 32 Bits breit sein (im letzteren Fall müssen Sie mit Überläufen rechnen). Aber selbst wenn die Hardware es zuläßt, das Register auf Null zurückzusetzen, wir raten davon entschieden ab. Weil Sie die Unterschiede immer mit vorzeichenlosen Variablen messen können, können Sie Ihr Ziel erreichen, ohne sich selbst durch Ändern des aktuellen Werts zum alleinigen Besitzer des Registers zu ernennen.

Das bekannteste Zähler-Register ist TSC (Timestamp Counter), das mit dem Pentium in den x86-Prozessoren eingeführt wurde und seitdem in allen CPU-Designs vorhanden war. Es handelt sich dabei um ein 64-Bit-Register, das CPU-Taktzyklen zählt und sowohl vom Kernel-Space als auch vom User-Space aus ausgelesen werden kann.

Nach dem Einbinden von <asm/msr.h> (das steht für ''machine-specific registers''), können Sie eines dieser Makros verwenden:


rdtsc(low,high);
rdtscl(low);

Die erste Anweisung liest den 64-Bit-Wert atomar in zwei 32-Bit-Variablen aus; die zweite liest die untere Hälfte des Registers in eine 32-Bit-Variable aus und reicht in den meisten Fällen aus. Auf einem 500-MHz-System läuft ein 32-Bit-Zähler beispielsweise alle achteinhalb Sekunden über; Sie müssen nicht auf das gesamte Register zugreifen, wenn das, was Sie messen wollen, sicher weniger Zeit benötigt.

Die folgenden Zeilen messen beispielsweise die Ausführung der Anweisung selbst:


unsigned long ini, end;
rdtscl(ini); rdtscl(end);
printk("time lapse: %li\n", end - ini);

Einige der anderen Plattformen haben ähnliche Funktionalitäten, und der Kernel enthält eine architekturunabhängige Funktion, die Sie anstelle von rtdsc verwenden können. Sie heißt get_cycles und wurde während der 2.1-Entwicklung eingeführt. Der Prototyp sieht wie folgt aus:


 #include <linux/timex.h>
 cycles_t get_cycles(void);

Die Funktion ist für alle Plattformen definiert und gibt auf den Plattformen, die ein solches Register nicht haben, immer 0 zurück. Der Typ cycles_t ist ein passender vorzeichenloser Typ, der in ein CPU-Register paßt. Die Entscheidung, den Wert in ein einzelnes Register zu stecken, führt dazu, daß beispielsweise auf Pentium-Systemen nur die unteren 32 Bits des Taktzählers von get_cycles zurückgegeben werden. Das ist aber vernünftig, weil man so die Probleme mit Operationen über mehrere Register hinweg vermeidet, gleichwohl aber weiterhin den wichtigsten Anwendungsfall des Zählers möglich macht: kurze Zeitabstände zu messen.

Trotz der Verfügbarkeit einer architekturunabhängigen Funktion möchten wir doch die Chance ergreifen, Ihnen ein Beispiel von Inline-Assembler-Code zu zeigen. Dazu werden wir eine rdtscl-Funktion für MIPS-Prozessoren implementieren, die genauso wie die x86-Version arbeitet.

Der Code basiert auf MIPS, weil die meisten MIPS-Prozessoren einen 32-Bit-Zähler als Register 9 des internen “Coprozessors 0” enthalten. Um auf dieses Register zuzugreifen, das nur vom Kernel Space aus lesbar ist, können Sie das folgende Makro definieren, das einen Wert “vom Coprozessor 0” verschiebt:[1]


 #define rdtscl(dest) \
    _ _asm_ _ _ _volatile_ _("mfc0 %0,$9; nop" : "=r" (dest))

Mit diesem Makro kann der MIPS-Prozessor den gleichen Code wie oben für den x86 gezeigt ausführen.

Das Interessante am Inline-Assembler im gcc ist die Tatsache, daß die Allokation der allgemein verwendbaren Register dem Compiler überlassen bleibt. Das gerade gezeigte Makro verwendet %0 als Platzhalter für “Argument 0”, welches später als “ein beliebiges Register” (r), das für Ausgaben verwendet wird (=), spezifiziert wird. Außerdem gibt das Makro an, daß das Ausgabe-Register dem C-Ausdruck dest entsprechen muß. Die Syntax des Inline-Assemblers ist mächtig, aber komplex, speziell auf Architekturen, bei denen eingeschränkt ist, was die einzelnen Register tun können (das gilt speziell für die x86-Familie). Die vollständige Syntax ist in der Dokumentation des gcc beschrieben, normalerweise in der info-Dokumentation.

Der in diesem Abschnitt gezeigte Code-Abschnitt wurde auf einem x86-Prozessor der K7-Klasse und einem MIPS VR4181 (mit dem gerade gezeigten Makro) ausgeführt. Ersterer meldete eine verstrichene Zeit von 11 Taktzyklen, letzterer nur 2. Dieser kleine Wert war erwartet, weil RISC-Prozessoren üblicherweise nur eine Anweisung pro Taktzyklus ausführen.

Fußnoten

[1]

Die nop-Anweisung am Ende ist notwendig, um den Compiler daran zu hindern, in der unmittelbar auf mfc0 folgenden Anweisung auf das Ziel-Register zuzugreifen. Diese Art von Verschränkung ist typisch für RISC-Prozessoren, und der Compiler kann immer noch nützliche Anweisungen in die Verzögerungs-Slots stecken. In diesem Fall verwenden wir nop, weil Inline-Assembler eine Blackbox für den Compiler ist, in der keine Optimierung möglich ist.