led.c
In unserer LED_Error Funktion haben wir bisher eine wait-Funktion verwendet, die einfach eine Schleife durchlaufen hat:
void wait(u32 zyklen) {
volatile u32 i;
for (i = 0; i < zyklen; i++) {}
}
Für die ersten Schritte beim Booten reicht das aus. Sobald unser System jedoch wächst, wird dieser Ansatz zu einer Falle:
MMU & Cache-Problem: Sobald wir später die Speicherverwaltung (MMU) und die Caches einschalten, liest die CPU die Schleife nicht mehr aus dem langsamen Arbeitsspeicher, sondern aus dem blitzschnellen L1-Cache. Unsere Verzögerung wird dadurch schlagartig um das 50-fache kürzer!
Unpräzise Zeiten: Wie lange wait() dauert, hängt vom CPU-Takt ab. Ändert der Raspberry Pi seine Taktfrequenz, ändert sich auch unsere Wartezeit.
Die Lösung: Der ARM Generic Timer
ARM64-Prozessoren besitzen einen hardwarebasierten Zähler, den System Counter. Dieser läuft mit einer festen Frequenz (beim Pi 4 sind das 54 MHz) absolut gleichmäßig – völlig unbeeindruckt von Caches, MMU oder CPU-Takt.
Über das Systemregister CNTPCT_EL0 lesen wir den aktuellen Zählerstand aus und mit CNTFRQ_EL0 erfahren wir, wie viele Ticks pro Sekunde die Hardware generiert.
Mit unserer neuen Funktion Timer_GetClockTicks() rechnen wir die Hardware-Ticks in Mikrosekunden um:
unsigned Timer_GetClockTicks(void)
{
InstructionSyncBarrier(); // Pipeline leeren für exakte Messung
u64 nCNTPCT, nCNTFRQ;
asm volatile ("mrs %0, CNTPCT_EL0" : "=r" (nCNTPCT));
asm volatile ("mrs %0, CNTFRQ_EL0" : "=r" (nCNTFRQ));
return (unsigned) (nCNTPCT * CLOCKHZ / nCNTFRQ);
}
In Timer_SimpleusDelay() merken wir uns einfach den Start-Tick und warten in einer while-Schleife, bis die Differenz der Ticks die gewünschte Mikrosekunden-Anzahl erreicht hat:
void Timer_SimpleusDelay(unsigned nMicroSeconds)
{
if (nMicroSeconds > 0)
{
unsigned nTicks = nMicroSeconds * (CLOCKHZ / 1000000) + 1;
unsigned nStartTicks = Timer_GetClockTicks();
while (Timer_GetClockTicks() - nStartTicks < nTicks)
{
// Warten, bis die Hardware-Zeit verstrichen ist
}
}
}
Der Vorteil: Egal ob die MMU aus oder an ist, ob der Prozessor mit 600MHz oder 1,5GHz läuft: Timer_SimpleMsDelay(100) wartet garantiert exakt 100 Millisekunden.
Damit können wir unsere LED_Error()-Funktion robuster machen:
void LED_Error(u32 errorcode)
{
while (TRUE)
{
for (u32 z = 0; z < errorcode; z++)
{
LED_on(); // LED einschalten
Timer_SimpleMsDelay(200); // Kurze Wartezeit
LED_off(); // LED ausschalten
Timer_SimpleMsDelay(200); // Kurze Wartezeit
}
Timer_SimpleMsDelay(400); // Zusätzliche Wartezeit, um den Fehlercode erkennbar zu machen
}
}
Später benötigen wir die neuen Timer-Funktionen für andere Funktionen.
Von der einfachen Mailbox zur ausfallsicheren Synchronisation
In ersten Tests reichte eine einfache Funktion oft aus, um der GPU eine Adresse zuzurufen:
void BcmMailBox_Write(u32 nChannel, u32 nData) {
write32(nChannel | nData, MAILBOX1_WRITE);
}
In einem echten Betriebssystem stößt dieser naive Ansatz jedoch schnell an seine Grenzen. Die Mailbox zwischen dem ARM-Core und der Videocore-GPU ist eine asynchrone Schnittstelle, bei der ohne strikte Protokollregeln Daten verloren gehen oder der Kernel einfriert.
Unsere neue Funktion MailBox_WriteRead() löst diese Probleme durch vier entscheidende Schutzmechanismen:
1. Aufräumen vor dem Senden (MailBox_Flush)
Bevor wir eine neue Anfrage stellen, leert MailBox_Flush() den Empfangspuffer der GPU vollständig aus. So wird verhindert, dass wir versehentlich eine alte, noch im Speicher liegende Antwort eines vorherigen Befehls als unser neues Ergebnis interpretieren.
2. Flusskontrolle (Flow Control)
Die Hardware-Register MAILBOX0_STATUS und MAILBOX1_STATUS zeigen uns an, ob die Mailbox voll oder leer ist:
Vor dem Schreiben: Wir warten in einer Schleife, bis die GPU wieder aufnahmebereit ist (MAILBOX_STATUS_FULL ist 0).
Vor dem Lesen: Wir warten, bis die GPU ihre Antwort bereitgestellt hat (MAILBOX_STATUS_EMPTY ist 0).
3. Kanal-Filterung (Channel Filtering)
Die Mailbox besitzt 16 verschiedene Kanäle (die unteren 4 Bits der Nachricht). So nutzt der Framebuffer Kanal 1, während Property-Tag-Anfragen über Kanal 8 laufen. In MailBox_Read() prüfen wir mit (nResult & 0xF) != nChannel in einer Schleife, ob die gelesene Antwort auch wirklich zu dem von uns aufgerufenen Kanal gehört. Antworten anderer Kanäle werden verworfen.
4. Speicherbarrieren (DataSyncBarrier)
Da der ARM-Prozessor Speicherzugriffe zur Performance-Optimierung umordnen kann, nutzen wir die Assembler-Barriere DataSyncBarrier() (dsb):
Vor dem Senden: Stellt sicher, dass alle unsere Datenstrukturen (z. B. Farb- und Displayeinstellungen) garantiert vollständig im RAM geschrieben wurden, bevor die GPU die Benachrichtigung erhält.
Nach dem Senden: Garantiert, dass der Kernel erst weiterrechnet, wenn die Antwort der GPU sicher im Speicher anliegt.
Fazit: Erst durch diese Kombination aus Puffer-Clear, Status-Polling, Kanal-Prüfung und Speicherbarrieren läuft die Kommunikation mit der GPU auf dem Raspberry Pi 4 absolut robust und ausfallsicher.
Anpassung der Init_Screen()-Funktion
In der screen.c müssen wir in der Init_Screen()-Funktion dies anpassen:
void Init_Screen(void)
{
uintptr pScreenAddress = (uintptr)pScreen;
while (TRUE)
{
MailBox_WriteRead(BCM_MAILBOX_PROP_OUT, (u32)pScreenAddress);
if (pScreen[31] != 0)
{
break;
}
}
…
Neue printf.c
Im vorhergehenden Code haben wir zur Veranschaulichung einen bereits exsitieren Code verwendet, um darzustellen, wie einfach es ist, fremden Code zu verwenden. Leider hat das auch ein paar Nachteile, da genau die verwendete Version nicht alles unterstützt, was wir benötigen. Ausserdem wird durch den C-Kompiler bestimmte Zeichen mit einen UTF-8 Sequence gesteuert.
Weiter wurde diese Funktion unabhängig erstellt und an unseren Kernel angepasst. Durch die direkte Verbindung zur Funktion DrawCharAtCursor(c) aus der screen.c ist der Weg eindeutiger.
Bei interesse, wie ich dies umgesetzt habe, kann es sich gerne im Sourcecode anschauen.
Änderungen in screen.c
Scroll und Clear-Funktion
Eventuell hast du es schon bemerkt. Wenn wir mit dem jetzigen Code unseren Bildschirm voll schreiben und er dann den Screen scrollen muss, sieht man, wie hier die Perfomance in den Keller runter rutscht. Dies liegt tatsächlich an unseren Code, der jedes einzelne Byte von einer Speicherstelle zur anderen schreibt. Dies ist für die CPU ein enormer Zeitaufwand und nicht effektiv. Viele Programmierer verwenden hier den DMA, der unabhängig dieses Kopieren übernimmt. Ich habe hier eine andere Methode verwendet. Seit dem wir Interrupts eingeführt haben, können wir NEON-Register verwenden, die wir in der boot.S eingeschaltet haben. Ein NEON-Register unterstütz 128-Bits, was für den Screen 16-Bytes entspricht. Alleine dies verursacht eine 16 fache Geschwindigkeit, was bereits hier sehr gut sichbar ist. Sobald wir mit unserer Speicherverwaltung anfangen, werden wir die MMU einschalten und damit über Caches arbeiten. Dies entspricht dann der 50 fachen Geschwindigkeit, was der DMA-Funktion entspricht. Die entscheidenen Funktionen (memcpyblk(), memsetblk()) sind in der utils.S hinterlegt und können da begutachtet werden.
Neuer Font
Der bisherige Font in 8x8 ist nicht optimal bei der Ansicht und manchmal recht schwierig zu lesen. Hier wurde ein neuer Font (8x16) aus dem Linux-Kernel verwendet. Entsprechend wurde das Rendern der Chars flexibel gestaltet, so dass auch andere Fonts verwendet werden könnten.
Mailbox
Wie bereits berichtet, wurde die MailBox-Funktion verbessert. In der Init_Screen()-Funktion, wurde damit die neue MailBox-Abfrage (MailBox_WriteRead()) verwendet.
DrawCharAtCursor()-Funktion
In der DrawCharAtCursor()-Funktion, wurde die “TAB”-Logik geändert, so dass jede Zeile die gleichen Tab-Abstände besitzt.
Utils
Die utils-Dateien wurden um einige Funktionen erweitert. Diese können in den Dateien utlis.S und utils.c angeschaut werden.
Alle Funktionen sind in der utils.h definiert:
void write64(u64 value, uintptr addr);
void write32(u32 value, uintptr addr);
void write16(u16 value, uintptr addr);
void write8(u8 value, uintptr addr);
Diese Funktionen schreiben einen 8, 16, 32, 64-Bit-Wert (value) in den Speicher (addr).
u64 read64(uintptr addr);
u32 read32(uintptr addr);
u16 read16(uintptr addr);
u8 read8(uintptr addr);
Hier wird der 8, 16, 32, 64-Bit-Wert aus einer Speicherstelle (addr) gelesen.
void irq_enable(void);
void irq_disable(void);
void irq_barrier(void);
Hier werden Interrupts und Barrier Controls festgelegt. Dies wurde in der utils.s definiert.
void *memcpyblk(void *dest, const void *src, size_t n);
void *memsetblk_32(void *dest, u32 color, size_t count);
Dies sind optimierte Block-Operationen, um schnelle Speicheroperation durchzuführen. Sie verwenden 128-bit NEON-Register.
void *memcpy(void *dest, const void *src, size_t n);
void *memset(void *s, int c, size_t n);
void *memmove(void *dest, const void *src, size_t n);
int memcmp(const void *s1, const void *s2, size_t n);
Standard Memory-Funktionen.
size_t strlen(const char *s);
int strcmp(const char *s1, const char *s2);
int strncmp(const char *s1, const char *s2, size_t n);
int strcasecmp(const char *s1, const char *s2);
int strncasecmp(const char *s1, const char *s2, size_t n);
char *strcpy(char *dest, const char *src);
char *strncpy(char *dest, const char *src, size_t n);
char *strcat(char *dest, const char *src);
char *strchr(const char *s, int c);
char *strstr(const char *haystack, const char *needle);
char *strtok_r(char *str, const char *delim, char **saveptr);
char *strtok(char *str, const char *delim);
unsigned long strtoul(const char *str, char **endptr, int base);
unsigned long long strtoull(const char *str, char **endptr, int base);
int atoi(const char *str);
int char2int(char c);
Dies sind Standard-String-Funktionen.
#define bswap16 __builtin_bswap16
#define bswap32 __builtin_bswap32
#define le2be16 bswap16
#define le2be32 bswap32
#define be2le16 bswap16
#define be2le32 bswap32
#define parity32 __builtin_parity
Byte-Umordnung & mathematische Inline-Funktionen.
#define ALIGN(n) __attribute__ ((aligned (n)))
Hier werden Makros definiert und im Kurs auch noch erweitert.