Grafik in C (PI4)

Aus C und Assembler mit Raspberry

Die bisherigen Programme auf dem Raspberry Pi waren vergleichsweise einfach und ermöglichten lediglich die Steuerung einer LED. Der Raspberry Pi kann jedoch weitaus mehr, insbesondere im Bereich der Grafik, da er über einen HDMI-Ausgang verfügt, der für fortgeschrittene Anwendungen genutzt werden kann. In diesem Abschnitt wollen wir den HDMI-Ausgang des Raspberry Pi verwenden, um grafische Ergebnisse direkt anzuzeigen.

Zu Beginn schließen wir einen Monitor an den Raspberry Pi an, um die Ergebnisse direkt auf dem Bildschirm sehen zu können. Da wir Bare Metal programmieren, müssen wir sämtliche Routinen selbst schreiben, da der Raspberry Pi keine vorinstallierten Weisungen hat, wie er Bilder auf dem Monitor anzeigt.

Der Raspberry Pi 4 besitzt zwei HDMI-Ausgänge, die jeweils Bildschirme mit bis zu UHD-Auflösung (4096 x 2160 Pixel) bei 60 Hz unterstützen. Bei Verwendung beider HDMI-Ports beträgt die Bildwiederholrate maximal 30 Hz. Ältere Raspberry Pi-Modelle unterstützen Bildschirme bis zu einer Auflösung von 1920 x 1200 Pixel bei 60 Hz.

Interessanterweise ist es die GPU des Raspberry Pi, die den Startvorgang initiiert. Sie lädt die Firmware in den Speicher und übergibt danach die Kontrolle an die CPU. Dies ist während des Startvorgangs deutlich sichtbar, wenn ein Bildschirm angeschlossen ist.

Um mit der Grafikprogrammierung zu beginnen, müssen wir zunächst verstehen, wie ein Bild auf dem Bildschirm dargestellt wird. Ein Bildschirm besteht aus einzelnen Punkten, den Pixeln, die jeweils eine eigene Farbe haben. Normalerweise wird der Bildschirm in einem Koordinatensystem (x, y) dargestellt. Der erste Pixel oben links hat die Koordinaten (0,0). Der nachfolgende Pixel rechts davon hat die Koordinate (1,0) und so weiter. Nach unten wird die y-Koordinate jeweils um eins erhöht: (0,1), (0,2) usw.

Auflösungen

Der Raspberry Pi unterstützt viele verschiedene Auflösungen. Eine Übersicht über die gängigsten Auflösungen findest du beispielsweise unter https://de.wikipedia.org/wiki/Bildaufl%C3%B6sung#Computer. Hier ein paar Auflösungen, die verwendet werden könnten:

Mode Auflösung
VGA 640 x 480
XGA 1024 x 768
HD 1920 x 1080
UHD (only Pi 4 or Pi 5) 4096 x 2160

Die Auflösung allein reicht jedoch nicht aus, um komplexe, farbige Bilder darzustellen. Da Computersysteme nur binäre Zahlen kennen, also nur "1" oder "0", könnte man jedes Pixel lediglich ein- oder ausschalten, was nur ein Schwarz-Weiß-Bild erzeugen würde. Um die Vielzahl der Farben, die wir auf Bildschirmen sehen, darzustellen, werden zusätzliche Standards definiert.

Wird die Anzeige mehrmals hintereinander vertieft, entsteht eine größere Farbtiefe. Wenn ein Bild beispielsweise zwei Bitmaps verwendet, stehen für jeden Punkt zwei Bits zur Verfügung. Das ergibt vier Zustände bzw. vier Farben. Je mehr Bitmaps verwendet werden, desto mehr Farben können angezeigt werden.

Farbtiefe

Der Raspberry Pi unterstützt vier verschiedene Farbtiefen: 8-Bit, 16-Bit, 24-Bit und 32-Bit. In der Praxis wird jedoch von der Verwendung der 8-Bit und 24-Bit Farbtiefe abgeraten, da sie zu Darstellungsfehlern führen können. Siehe hierzu auch die Dokumentation unter https://www.raspberrypi.org/documentation/configuration/config-txt/video.md im Abschnitt framebuffer_depth. Um es einfach zu halten, konzentrieren wir uns auf die 32-Bit Farbtiefe.

Beispiel: Speicheraufwand für Bilder Um den Speicheraufwand zu veranschaulichen, betrachten wir ein Beispiel. Ein monochromes Bild mit einer Auflösung von 20 x 20 Pixel benötigt folgenden Speicherbedarf:

Speicherbedarf (Byte) = (x * y) / 8         (20*20)/8 = 50 Bytes

Für ein Bild mit 4 Bitmaps beträgt der Speicherbedarf:

Speicherbedarf (Byte) = ((x * y) * tiefe) / 8      ((20*20)*4)/8 = 200 Bytes

Nun ein Beispiel mit einer HD-Auflösung und RGBA32-Farbtiefe:

Speicherbedarf (Bytes) = ((1920 * 1080) * 32) / 8 = 8294400 Bytes
Das entspricht etwa 8,3 Megabytes.

Abbildung der Bitmaps im Speicher

Bitmaps werden nicht als eine Reihe von Einzelbildern (Bitmaps) im Speicher abgelegt, sondern die Grafikdaten sind wie in einem dreidimensionalen Raum organisiert. Betrachten wir ein Beispiel mit einer Auflösung von 20 x 20 Pixel und einer Farbtiefe von 16 Bit:

Jeder Bildschirmpunkt wird bei einer Farbtiefe von 16 Bit durch 16 aufeinanderfolgende Bits dargestellt. Der erste Punkt verwendet die Bits 0-15, der nächste die Bits 16-31, und so weiter. Das müssen wir bei den Berechnungen für jeden einzelnen Punkt berücksichtigen.

Der Postbote und TAGs

Wie bereits beschrieben, wird die Grafik nicht von der CPU, sondern von der GPU (Graphics Processing Unit) dargestellt. Damit die GPU weiß, was sie tun soll, benötigt sie bestimmte Informationen zur Darstellung, etwa wie der Bildschirm aussehen soll. Eine direkte Verbindung zwischen CPU und GPU existiert jedoch nicht, weshalb die Kommunikation über einen Vermittler erfolgt, der hier als "Postbote" bezeichnet wird. Die CPU sendet eine Nachricht an den Postboten, und dieser leitet die Nachricht an die GPU weiter. Ebenso kann die GPU Nachrichten an die CPU schicken.

Der Postbote hat die Basisadresse 0xB880 und verwendet "TAGs" für die Informationsübermittlung. Die Dokumentation zu diesen TAGs ist leider spärlich, doch es gibt eine Gemeinschaft, die Informationen gesammelt und bereitgestellt hat. Weitere Informationen findest du hier:

https://github.com/raspberrypi/firmware/wiki/Mailbox-property-interface

Diese Informationen habe ich in dem zugehörigen Sourcecode zusammengefasst und in der base.h Datei eingefügt.

Zunächst definieren wir in der base.h die neuen Register, die dafür verantwortlich sind:

//
// Mailbox
//
#define MAILBOX_BASE RPI_BASE + 0xB880 
#define MAILBOX0_READ       MAILBOX_BASE + 0x00
#define MAILBOX0_STATUS     MAILBOX_BASE + 0x18
#define MAILBOX_STATUS_EMPTY    0x40000000
#define MAILBOX1_WRITE      MAILBOX_BASE + 0x20
#define MAILBOX1_STATUS     MAILBOX_BASE + 0x38
#define MAILBOX_STATUS_FULL 0x80000000

#define MAIL_WRITE MAILBOX_BASE + 0x20
#define MAIL_TAG_WRITE MAIL_WRITE + 0x8

#define MAILBOX_CHANNEL_PM  0       // power management
#define MAILBOX_CHANNEL_FB  1       // frame buffer
#define BCM_MAILBOX_PROP_OUT    8       // property tags (ARM to VC)

Für die Kommunikation verwenden wir "TAGs". Diese TAGs sind in einer bestimmten Struktur organisiert, die von der GPU verstanden wird.

Aufbau der TAG-Struktur

TAG-Listen haben eine festgelegte Struktur, die die GPU versteht. Eine TAG-Struktur beginnt immer mit einer 32-Bit Zahl, die die Länge der gesamten Struktur angibt. Es folgt ein 32-Bit Puffer, in den eine Antwort geschrieben werden kann. Danach kommen die einzelnen TAGs, die wiederum eine Grundstruktur aufweisen: Der Name des TAGs, die Puffergröße in Bytes, ein Antwortpuffer und die Werte des TAGs. Die Struktur endet mit einem NULL-TAG.

Mailbox Länge
Mailbox Puffer
   TAG1 Name
   TAG1 Länge
   TAG1 Puffer
      TAG1 Wert1
      TAG1 Wert2
      ...
   TAG2 Name
   ...
   TAGx NULL (End TAG)

Jeder TAG muss auf 32-Bit ausgerichtet sein.

Aufbau unserer Struktur

Wir erstellen unsere TAG-Struktur und richten sie auf 16 Byte aus, da nur die oberen 28 Bits der Adresse an die Mailbox übergeben werden.

u32 pScreen[36] ALIGN(16) = {
...
}

Die Struktur beginnt mit der Angabe der Länge und einem Puffer für mögliche Antworten:

u32 pScreen[36] ALIGN(16) = {
    34*4, //34 Einträge * 4 Bytes
    0,

Jetzt fügen wir die Daten hinzu, beginnend mit der Auflösung des Bildschirms. Der TAG Set_physical_display hat folgende Struktur:

TAG Name (Set_physical_display)
Puffergröße in Bytes
Antwort Puffer
Breite des Bildschirms in Pixel
Höhe des Bildschirms in Pixel

In unsere Struktur übertragen sieht es so aus:

    Set_physical_display, //Tag Identifier
    0x8,0x8,              //Buffer size in bytes
    SCREEN_X,             //Width in pixels
    SCREEN_Y,             //Height in pixels

Die Variablen SCREEN_X und SCREEN_Y definieren wir in der config.h Datei, hier für eine Auflösung von 1920 x 1080 Pixel.

Als nächstes definieren wir den Set_virtual_buffer TAG:

TAG Name (Set_virtual_buffer)
Puffergröße in Bytes
Antwort Puffer
Breite des Bildschirms in Pixel
Höhe des Bildschirms in Pixel

In unsere Struktur übertragen sieht es so aus:

    Set_virtual_buffer,
    0x8,0x8,
    SCREEN_X,
    SCREEN_Y,

Der Set_depth TAG definiert die Farbtiefe:

TAG Name (Set_depth)
Puffergröße in Bytes
Antwort Puffer
Tiefe der Bitplanes

In unsere Struktur übertragen sieht es so aus:

  Set_depth,            //Tag Identifier
  0x4,                   //Buffer size in bytes
  0x4,                  //Response buffer
  BITS_PER_PIXEL,       //depth

Auch hier verwenden wir eine Variable BITS_PER_PIXEL, die wir in der config.h Datei auf 32 Bit definieren.

Der Set_virtual_offset TAG erzeugt Offset-Werte:

TAG Name (Set_virtual_offset)
Puffergröße in Bytes
Antwort Puffer
Offset x
Offset y

In unsere Struktur übertragen sieht es so aus:

    Set_virtual_offset,
    0x8,0x8,
    0x0,0x0,

Der Set_palette TAG definiert die Farbpalette:

TAG Name (Set_palette)
Puffergröße in Bytes
Antwort Puffer
erster eingestellter Palettenindex
Anzahl der einzustellenden Paletteneinträge
   RGBA Palettenwerte

In unsere Struktur übertragen sieht es so aus:

    Set_palette,
    0x10,0x10,
    0x0,
    0x2,
    0x00000000,
    0xffffffff,

Da wir hauptsächlich 16-Bit oder 32-Bit Farbtiefen verwenden, ist der Eintrag Set_palette weniger wichtig, muss aber dennoch definiert werden.

Der Allocate_buffer TAG sorgt dafür, dass die GPU Speicher reserviert und diesen anzeigt:

TAG Name (Allocate_buffer)
Puffergröße in Bytes
Antwort Puffer
Frame Buffer Basisadresse in Bytes
Bildpuffergröße in Bytes

In unsere Struktur übertragen sieht es so aus:

    Allocate_buffer,
    0x8,0x8,
    0x0,          //Bufferadresse (pScreen[31])
    0x0,

Der TAG erzeugt einen Puffer, dessen Adresse in pScreen[31] abgelegt wird, sobald die GPU dies durchgeführt hat. Dies ist die Adresse, auf die wir später zugreifen können.

Unsere TAG-Liste endet mit einem NULL-TAG:

  0x00000000

Diese Struktur beschreibt, was wir darstellen möchten. Die GPU interpretiert diese Werte und versucht, sie darzustellen. Wenn die GPU die Angaben nicht exakt umsetzen kann, erzeugt sie einen ähnlichen Zustand. Wir sollten die von der GPU zurückgemeldeten Werte überprüfen, um sicherzustellen, dass sie unseren Erwartungen entsprechen.

Der Postbote

Nun kommt der Postbote ins Spiel. Wir geben ihm unsere TAG-Liste und erzeugen damit unseren Bildschirm. Wir werden zunächst eine einfache Methode verwenden und später die Funktionen erweitern.

Damit haben wir dem Postboten zumindest den Auftrag gegeben, dass er diese Informationen an den GPU zu übergeben. Leider laufen die beiden Prozessoren nicht synchron und wir wissen zu diesem Zeitpunkt nicht, ob nun wirklich die Anzeige erzeugt wurde. Für die GPU ist unser Wunsch auch recht aufwendig, welche Zeit benötigt.

Um nun sicher zu sein, dass es geklappt hat, fragen wir einfach nach, ob es eine Adresse gibt, wo den unser Bildschirm abgelegt wurde. Innerhalb unserer Struktur wird unter pScreen[31] dieser Wert abgelegt. Genau dort schreibt die GPU die Adresse hinein, wenn sie erfolgreich war. Solange diese Adresse nicht gesetzt ist, wissen wir, dass die GPU noch nicht fertig ist. Wir wiederholen einfach diese Aufforderung, bis hier ein Wert steht.

    uintptr pScreenAddress = (uintptr)pScreen;
    while (TRUE) {
        BcmMailBox_Write(BCM_MAILBOX_PROP_OUT,(u32)pScreenAddress);
        
        if (pScreen[31] != 0)
        {
            break;
        }
    }

Problematisch wird es allerdings, wenn die GPU nie irgendwas zurück gibt, dann sind wir hier in einer Dauerschleife. Aber wollen wir nicht so schwarz sehen und hoffen, dass es hier zu keinem Fehler kommt. Hier verwenden wir später andere Funktionen, damit wir hier nicht in Problemen bei anderen Abfragen kommen.

Die Adresse, die wir zurück bekommen ist ein "byte" Wert, mit dem wir so nicht arbeiten können. Wir müssen diesen zuerst für unsere Zwecke umwandeln, was wir mit einem einfachen "and" durchführen. Anschließend speichern wir den Wert für spätere Zwecke.

#define ADDRESS_MASK 0x3FFFFFFF

    graphicsAddress = pScreen[31] & ADDRESS_MASK;

Auf den Bildschirm zeichnen

Nun haben wir einen Screen. Naja, dieser Screen ist leer und man sieht eigentlich nichts. Also müssen wir einen Weg finden, dass wir in irgend einer Weise etwas auf den Bildschirm bekommen.

Dazu werden wir eine Funktion entwickeln, die einen einzelnen Bildpunkt definiert und anzeigt. Diese grundlegende Funktion wird die Ausgangsbasis für alle grafischen Funktionen sein.

Diese Funktion nennen wir “DrawPixel” und übergeben ihr die Koordinate in x und y.

Für verschiedene Farbtiefen, sind verschiedene Berechnungen notwendig, um die richtige Farbe darzustellen. Genau so ist diese Farbtiefe auch verantwortlich, wo den die einzelnen Bildpunkte im Speicher zu finden sind. Zunächst werden wir diese Funktion relativ einfach halten und einfach davon ausgehen, dass wir eine Farbtiefe von 32 Bit haben.

Zunächst überprüfen wir, ob der Pixel überhaupt im sichtbaren Bereichs liegt. Es würde sonst zu unerwünschten Nebeneffekten kommen, denn wir wissen nicht, welche Funktion die entsprechende Speicherstelle im System hat.

Wenn wir es überprüft haben, können wir die richtige Position berechnen. Dies erfolgt nach folgender Formel:

((Gesammtscreenbreite * Position y) + Position x) * Screentiefe in Byte

Dazu müssen wir die ScreenAdresse noch zu dieser Position hinzuaddieren.

void DrawPixel(u32 x, u32 y)
{
    if ((x < SCREEN_X) && (y < SCREEN_Y))
    {
        write32(DrawColor,graphicsAddress+(((SCREEN_X*y)+x)*4));
    }
}

Damit wird bereits der Pixel angezeigt.

Damit ist unsere vorläufige “DrawPixel” Funktion erstmal fertig und erzeugen eine neue Headerdatei in der wir diese Funktionen beschreiben.

//
// 20.02.2025 www.satyria.de
//
// screen.h
//

#ifndef _screen_h
#define _screen_h

#include "types.h"

void Init_Screen(void) ;
void DrawPixel(u32 x, u32 y);

#endif

Anwendung der DrawPixel-Funktion

Wir haben jetzt eine Funktion geschrieben, die es ermöglicht, etwas auf dem Bildschirm zu sehen. Jetzt müssen wir nur noch unsere neuen Funktionen im Hauptprogramm verwenden und wir dürften ein Ergebniss sehen.

Als wir die TAG-Struktur erstellten, haben wir einige Variablen benutzt, die wir erstmal festlegen müssen. Dazu verwenden wir unsere "config.h" Datei und ergänzen folgende Zeilen:

#define SCREEN_X       1920
#define SCREEN_Y       1080
#define BITS_PER_PIXEL 32

Damit definieren wir die Breite, die Höhe und die Tiefe unserer Bildschirmes.

Die erste Funktion, die wir aufrufen müssen, ist die Erstellung des Bildschirms. HIer wird nichts übergeben und wir bekommen nichts zurück.

   Init_Screen();

Einen einzelnen Punkt auf einem Bildschirm zu sehen, ist manchmal recht schwierig. Dazu werden wir mehrere Punkte nebeneinander setzen und verwenden eine Schleife, die die Koordinaten entsprechend verändert und lassen uns jedesmal den Punkt zeichnen.

    u32 i;
    for (i=100;i<=500;i++)
    {
        DrawPixel(i,i);
    }

Der Sourcecode, für dieses Beispiel ist hier zum laden bereit: [1]


< Zurück (Fehlerbehandlung in C (PI4)) < Hauptseite > Weiter (Chars in C (PI4)) >