1 Punkte von GN⁺ 2024-07-29 | 1 Kommentare | Auf WhatsApp teilen
  • Statt BAR0 weiterhin direkt per Peek/Poke über eine hartkodierte Adresse anzusprechen, wird der Linux-PCI-Subsystem genutzt, um den BAR-Speicher zu finden, und der Kernel-Treiber initialisiert das Gerät
  • Der Treiber beginnt mit der ID-Tabelle von struct pci_driver und der probe-Funktion, mappt dann BAR0 auf eine Kernel-Virtual Address und bereitet den Zugriff aus dem User Space vor
  • Über ein Character Device unter /dev/gpu-io werden read(2) und write(2) angebunden, und mit container_of wird aus den Dateioperationen wieder auf den Treiberzustand zugegriffen
  • Das Kopieren in DWORD-Schritten brauchte für eine Übertragung von 1.2MiB etwa 800ms, mit einem DMA-Aufruf über MMIO-Register sank das auf etwa 300µs
  • Auf den DMA-Abschluss wird per MSI-X-Interrupt und Wait Queue gewartet; am Ende verhält sich das Ganze wie eine Fake-GPU, die den Framebuffer-Inhalt in der QEMU-Konsole anzeigt

BAR0 im Kernel-Treiber finden und mappen

  • Die vorherige Implementierung las und schrieb direkt in 32-Bit-Schritten auf die aus lspci kopierte BAR0-Adresse 0xfe000000
  • Damit die Adresse nicht hartkodiert werden muss, werden die Memory-Mapping-Informationen des Geräts aus dem Linux-PCI-Subsystem geholt
  • struct pci_driver benötigt zwei zentrale Felder
    • eine Tabelle unterstützter Device-/Vendor-ID-Paare
    • eine probe-Funktion, die bei passender ID aufgerufen wird
  • Das Beispielgerät matcht auf PCI_DEVICE(0x1234, 0x1337)
  • Im Treiberzustand GpuState werden struct pci_dev *pdev und u8 __iomem * hwmem für den BAR-Speicher abgelegt
  • Die probe-Funktion bereitet das Gerät in folgender Reihenfolge vor
    • pci_enable_device_mem(pdev) aktiviert den Zugriff auf den Gerätespeicher
    • pci_select_bars(pdev, IORESOURCE_MEM) liefert die Bitmaske der verfügbaren Memory-BARs
    • pci_request_region(pdev, bars, "gpu-pci") fordert den Besitz des BAR-Adressraums an
    • pci_resource_start(pdev, 0) und pci_resource_len(pdev, 0) liefern Startadresse und Länge von BAR0
    • ioremap(mmio_start, mmio_len) mappt die physische Adresse in den virtuellen Kernel-Adressraum
  • Wenn pci_register_driver in module_init aufgerufen wird, erscheinen im Boot-Log mmio starts at 0xfe000000 und die virtuelle Kernel-Adresse

Als Character Device für den User Space bereitstellen

  • Nachdem der BAR0-Adressraum in den Kernel-Treiber gemappt ist, wird ein Character Device erstellt, damit ein User-Space-Programm per read(2) und write(2) mit dem PCIe-Gerät interagieren kann
  • Für diesen Treiber werden nur drei Dateioperationen benötigt: open, read und write
  • GpuState wird um struct cdev cdev erweitert, und setup_chardev führt Folgendes aus
    • alloc_chrdev_region weist eine Gerätenummer zu
    • cdev_init und cdev_add registrieren das Character Device
    • device_create erzeugt /dev/gpu-io
  • Dem Init-Skript wird /busybox mdev -s hinzugefügt, um das Pseudo-Dateisystem unter /dev/ zu füllen
  • Danach erscheint /dev/gpu-io als Character Device, im Beispiel mit Major-Nummer 241 und Minor-Nummer 0

Mit container_of in Dateioperationen den Treiberzustand finden

  • In der write-Implementierung muss private_data von struct file* durch open gesetzt werden, aber open bekommt keinen separaten private_data- oder user_data-Parameter
  • struct inode enthält einen Zeiger struct cdev *i_cdev, der auf das Character Device zeigt
  • Da GpuState ein struct cdev einbettet, lässt sich mit container_of(inode->i_cdev, struct GpuState, cdev) der Zeiger auf GpuState wiederherstellen
  • gpu_open speichert den erhaltenen GpuState in file->private_data
  • Anschließend holen gpu_read und gpu_write den GpuState aus file->private_data und verwenden ihn
  • Die anfänglichen Implementierungen von read/write verarbeiten jeweils nur ein DWORD auf einmal
    • gpu_read liest mit ioread32(gpu->hwmem + *offset) und kopiert den Wert mit copy_to_user in den User-Puffer
    • gpu_write kopiert 4 Byte aus dem User-Puffer und erhöht den Offset um 4
  • Für kleine Transfers funktioniert das, aber bei großen Übertragungen ist es langsam, weil die CPU weiterhin Paket für Paket abarbeiten muss
  • Eine Übertragung von 1.2MiB für 640×480 bei 32bpp dauerte etwa 800ms

Einen DMA-Aufruf über MMIO-Register aufbauen

  • Statt die CPU wiederholt DWORD für DWORD kopieren zu lassen, wird DMA verwendet, damit das Gerät die Daten direkt kopiert
  • Die Anforderung wird über Memory-Mapped I/O gesendet
    • Einige Speicheradressen dienen als Register, die die Argumente des DMA-Aufrufs aufnehmen
    • Andere Adressen fungieren als Befehl, der die Ausführung des Funktionsaufrufs auslöst
  • Die DMA-Schnittstelle enthält Werte, die die CPU dem Gerät mitteilen muss
    • Source-Adresse und Länge der zu kopierenden Daten
    • Destination-Adresse
    • Datenrichtung: zum Main Memory oder vom Main Memory aus
    • ein Signal, dass alles zum Start des Kopiervorgangs bereit ist
  • Das Gerät muss der CPU den Abschluss der Übertragung melden
  • Die Beispielregister sind wie folgt definiert
    • REG_DMA_DIR
    • REG_DMA_ADDR_SRC
    • REG_DMA_ADDR_DST
    • REG_DMA_LEN
  • CMD_DMA_START wird als Befehlsadresse verwendet, um das Befüllen der Registerwerte vom eigentlichen Start des DMA zu trennen
  • execute_dma im Kernel-Treiber schreibt mit iowrite32 Richtung, Source, Destination und Länge und schreibt am Ende 1 nach CMD_DMA_START

DMA-Verarbeitung auf der QEMU-Geräteseite

  • Das MMIO-gpu_write des QEMU-Adapters ersetzt die vorherige Implementierung und verarbeitet nun DMA-Register und Befehle
  • Schreibzugriffe auf den Registerbereich speichern den Wert in gpu->registers[reg]
  • Wenn im Befehlsbereich REG_DMA_START eingeht, wird die DMA-Richtung geprüft
  • In Richtung DIR_HOST_TO_GPU wird pci_dma_read aufgerufen
    • die Host-Adresse stammt aus REG_DMA_ADDR_SRC
    • die Device-Adresse ist gpu->framebuffer + REG_DMA_ADDR_DST
    • die Länge ist REG_DMA_LEN
  • Andere DMA-Richtungen werden im Beispielcode als Unimplemented DMA direction behandelt
  • gpu_fb_write im Kernel-Treiber übergibt User-Daten in folgenden Schritten an DMA
    • kmalloc(count, GFP_KERNEL) allokiert einen Kernel-Puffer
    • copy_from_user kopiert die User-Daten in den Kernel-Puffer
    • dma_map_single(&gpu->pdev->dev, kbuf, count, DMA_TO_DEVICE) erzeugt eine DMA-Adresse
    • execute_dma(gpu, DIR_HOST_TO_GPU, dma_addr, *offset, count) wird aufgerufen
    • kfree(kbuf) gibt den Puffer wieder frei
  • Auf dem Beispielsystem wurde diese Variante mit etwa 300µs gemessen und ist damit deutlich schneller

DMA-Abschluss per MSI-X-Interrupt signalisieren

  • Die DMA-Ausführung ist asynchron; es ist daher praktischer, wenn write blockiert, bis sie abgeschlossen ist
  • Eine PCI-e-Karte kann die CPU über Message Signalled Interrupts benachrichtigen
  • Im Gegensatz zu klassischen Interrupts mit eigener elektrischer Leitung werden MSI als normale Nachrichtenpakete auf dem Bus übertragen
  • Für die MSI-X-Konfiguration hat das QEMU-Gerät zwei Bereiche
    • eine MSI-X-Tabelle, in der die Konfiguration jedes Interrupts gespeichert wird
    • die PBA als Bitmap für ausstehende Interrupts
  • Die Beispielkonstanten sind
    • IRQ_COUNT ist 1
    • IRQ_DMA_DONE_NR ist 0
    • MSIX_ADDR_BASE ist 0x1000
    • PBA_ADDR_BASE ist 0x3000
  • In pci_gpu_realize von QEMU werden msix_init und msix_vector_use aufgerufen, um MSI-X zu initialisieren
  • In lspci -vv wird MSI-X anschließend als aktiviert angezeigt; die Vector Table liegt bei BAR0-Offset 00001000, die PBA bei BAR0-Offset 00003000
  • Nach pci_dma_read wird mit msix_notify(&gpu->pdev, IRQ_DMA_DONE_NR) ein Interrupt ausgelöst

Kernel-IRQ-Handler und Bus Mastering

  • Der Kernel-Treiber weist mit pci_alloc_irq_vectors MSI-X/MSI-Vektoren zu und holt sich mit pci_irq_vector die IRQ-Nummer
  • Mit request_threaded_irq wird der Handler GPU-Dma0 registriert
  • Nach dem Booten zeigt /proc/interrupts im Beispiel IRQ 24 mit PCI-MSIX-0000:00:02.0 und GPU-Dma0
  • Anfangs funktioniert das nicht, weil die Karte keine Berechtigung hat, der CPU unabhängig Nachrichten zu senden
  • Die Fähigkeit, ohne Eingriff der CPU direkt auf den Systemspeicher zuzugreifen, heißt Bus Mastering
  • Wenn in gpu_probe des Kernels pci_set_master(pdev) aufgerufen wird, erhält das Gerät Bus-Master-Rechte
  • Danach erscheinen bei zwei write-Aufrufen im Kernel-Log zweimal IRQ 24 received

Eine echte blockierende write mit Wait Queue implementieren

  • Sobald die interruptbasierte Benachrichtigung bereitsteht, kann write mit einer Linux-Wait Queue zu einem blockierenden Aufruf gemacht werden
  • Als globaler Zustand werden wait_queue_head_t wq und volatile int irq_fired = 0 angelegt
  • Der IRQ-Handler erledigt Folgendes
    • irq_fired = 1 setzt den Abschlussstatus
    • wake_up_interruptible(&wq) weckt wartende Threads auf
    • IRQ_HANDLED wird zurückgegeben
  • setup_msi ergänzt init_waitqueue_head(&wq)
  • Nach dem DMA-Start wartet gpu_fb_write mit wait_event_interruptible(wq, irq_fired != 0) auf den Interrupt
  • Wird das Warten unterbrochen, wird -ERESTARTSYS zurückgegeben

Den Framebuffer in der QEMU-Konsole anzeigen

  • Da nun ein Framebuffer existiert, der write(2) aus dem User Space annimmt und per DMA an das PCI-e-Gerät überträgt, kann er an die Konsolenausgabe von QEMU angebunden werden, damit das Ganze wie eine funktionierende GPU wirkt
  • GpuState in QEMU wird um QemuConsole* con erweitert
  • In pci_gpu_realize wird mit graphic_console_init eine Konsole erstellt, und mit qemu_console_surface wird die Display-Surface geholt
  • Ein initiales Testmuster wird angezeigt, indem Werte in die Surface-Daten des Bereichs 640×480 geschrieben werden
  • vga_update_display kopiert den Inhalt von gpu->framebuffer in die QEMU-Display-Surface
  • dpy_gfx_update(gpu->con, 0, 0, 640, 480) aktualisiert den Bereich 640×480
  • Danach ändert sich die Anzeige, wenn ein Muster auf das zugrunde liegende Device geschrieben wird
  • Der Source Code liegt im Github-Repo

Referenzen

1 Kommentare

 
GN⁺ 2024-07-29
Hacker-News-Kommentare
  • Das Endziel dieser Serie ist es, einen Display-Adapter mit einem FPGA zu bauen.
    Ich habe mir zum Einstieg ein Tang Mega 138k [0] gekauft, aber weil es nicht viel Dokumentation gibt, dauert es etwas.
    Wenn jemand ein günstiges FPGA-Board mit PCI-e Hard IP empfehlen kann, wäre ich dankbar.
    [0]: https://wiki.sipeed.com/hardware/en/tang/tang-mega-138k/mega...
  • Das wirkt wie ein sehr guter Einstieg in Linux-PCIe-Gerätetreiber.
    Ich habe zwar noch nie direkt mit Linux-Gerätetreibern gearbeitet, aber vor einigen Jahren mehrere PCIe-Treiber für ein anderes Betriebssystem geschrieben, und die Konzepte kommen mir sehr vertraut vor.
    Ich würde gern mehr Inhalte dieser Art sehen.
  • Mir gefällt der Aufbau des Artikels wirklich gut.
    Es ist nur so viel Code enthalten, wie nötig ist, um den Kern zu zeigen, und alles wird Schritt für Schritt aufgebaut.
    Ich hatte mein ganzes Leben lang nie den Wunsch, ein neues PCI-Gerät zu bauen, aber jetzt habe ich ein wenig Lust darauf bekommen; ist das nicht so etwas wie die Nagelprobe für gutes technisches Schreiben?
  • Vielen Dank für diesen Artikel; er ist in einem selten behandelten Bereich sehr praxisnah und informationsreich.
    Ich wollte eine Entwicklungs- und Playtesting-Umgebung für ein Projekt bauen, wusste aber nicht einmal, nach welchen Begriffen ich suchen sollte; genau das war der Inhalt, den ich brauchte.
    Auch die anderen zwei Teile waren gut, mit vielen praxisnahen Themen wie der Verwendung von Boot-Service-Treibercode nach dem Beenden, Bus Mastering, MSI-X und kleinen, aber nützlichen Details.