1 points par GN⁺ 2024-07-29 | 1 commentaires | Partager sur WhatsApp
  • On passe de l’étape où l’on faisait directement des opérations de peek/poke sur une adresse BAR0 codée en dur à l’utilisation du sous-système PCI de Linux pour trouver la mémoire BAR, puis à l’initialisation du périphérique par le pilote noyau
  • Le pilote commence avec la table d’identifiants de struct pci_driver et la fonction probe, mappe ensuite BAR0 en adresse virtuelle du noyau puis prépare l’accès depuis l’espace utilisateur
  • Via le périphérique de caractères /dev/gpu-io, il relie read(2) et write(2), et récupère l’état du pilote depuis les opérations de fichier avec container_of
  • Une copie DWORD par DWORD prenait environ 800 ms pour un transfert de 1.2 MiB, mais le passage à des appels DMA basés sur des registres MMIO ramène ce temps à environ 300 µs
  • L’attente de fin de DMA est gérée avec des interruptions MSI-X et une wait queue, et le tout finit par fonctionner comme un faux GPU affichant le contenu du framebuffer dans la console QEMU

Trouver et mapper BAR0 depuis un pilote noyau

  • L’implémentation précédente lisait et écrivait directement par unités de 32 bits à l’adresse BAR0 0xfe000000 copiée depuis lspci
  • Pour éviter de coder l’adresse en dur, on récupère les informations de mapping mémoire du périphérique via le sous-système PCI de Linux
  • struct pci_driver a besoin de deux champs essentiels
    • une table des paires device/vendor ID prises en charge
    • une fonction probe appelée quand un ID correspond
  • Le périphérique d’exemple correspond à PCI_DEVICE(0x1234, 0x1337)
  • L’état du pilote GpuState stocke struct pci_dev *pdev et u8 __iomem * hwmem pour la mémoire BAR
  • La fonction probe prépare le périphérique dans l’ordre suivant
    • pci_enable_device_mem(pdev) active l’accès à la mémoire du périphérique
    • pci_select_bars(pdev, IORESOURCE_MEM) récupère le bitmap des BAR mémoire utilisables
    • pci_request_region(pdev, bars, "gpu-pci") demande la possession de l’espace d’adressage BAR
    • pci_resource_start(pdev, 0) et pci_resource_len(pdev, 0) récupèrent l’adresse de début et la taille de BAR0
    • ioremap(mmio_start, mmio_len) mappe l’adresse physique en adresse virtuelle du noyau
  • Quand pci_register_driver est appelé dans module_init, les logs de démarrage affichent mmio starts at 0xfe000000 ainsi que l’adresse virtuelle du noyau

L’exposer à l’espace utilisateur comme périphérique de caractères

  • Une fois l’espace BAR0 mappé dans le pilote noyau, on crée un périphérique de caractères pour qu’un programme en espace utilisateur puisse interagir avec le périphérique PCIe via read(2) et write(2)
  • Ce pilote n’a besoin que de trois opérations de fichier : open, read, write
  • On ajoute struct cdev cdev à GpuState, puis setup_chardev effectue les opérations suivantes
    • alloc_chrdev_region alloue un numéro de périphérique
    • cdev_init et cdev_add enregistrent le périphérique de caractères
    • device_create crée /dev/gpu-io
  • Le script d’initialisation ajoute /busybox mdev -s pour peupler le pseudo-système de fichiers /dev/
  • Ensuite, /dev/gpu-io apparaît comme un périphérique de caractères, avec dans l’exemple le major 241 et le minor 0

Retrouver l’état du pilote depuis les opérations de fichier avec container_of

  • Dans l’implémentation de write, le private_data de struct file* doit être rempli par open, mais open ne reçoit pas d’argument private_data ni user_data séparé
  • struct inode contient un pointeur struct cdev *i_cdev vers le périphérique de caractères
  • Comme GpuState embarque un struct cdev, on peut retrouver le pointeur GpuState avec container_of(inode->i_cdev, struct GpuState, cdev)
  • gpu_open stocke alors le GpuState obtenu dans file->private_data
  • Ensuite, gpu_read et gpu_write récupèrent GpuState depuis file->private_data pour l’utiliser
  • Les premières versions de read/write traitent un DWORD à la fois
    • gpu_read lit avec ioread32(gpu->hwmem + *offset) puis copie vers le buffer utilisateur avec copy_to_user
    • gpu_write copie 4 octets depuis le buffer utilisateur puis incrémente l’offset de 4
  • Cela fonctionne pour de petits transferts, mais c’est lent pour les gros volumes car le CPU doit traiter les paquets un par un en continu
  • Un transfert de 1.2 MiB, correspondant à 640×480 en 32bpp, prenait environ 800ms

Créer un appel DMA via des registres MMIO

  • Au lieu de laisser le CPU répéter des copies DWORD par DWORD, on utilise le DMA pour que le périphérique copie directement les données
  • La requête de travail est envoyée via des entrées/sorties mappées en mémoire (memory-mapped IO)
    • certaines adresses mémoire servent de registres jouant le rôle d’arguments de l’appel DMA
    • d’autres adresses servent de commandes signifiant l’exécution de l’appel
  • L’interface DMA contient les valeurs que le CPU doit communiquer au périphérique
    • l’adresse source des données à copier et leur longueur
    • l’adresse de destination
    • la direction des données : vers la mémoire principale ou depuis la mémoire principale
    • un signal indiquant que tout est prêt pour démarrer la copie
  • Le périphérique doit ensuite signaler au CPU la fin du transfert
  • Les registres d’exemple sont définis ainsi
    • REG_DMA_DIR
    • REG_DMA_ADDR_SRC
    • REG_DMA_ADDR_DST
    • REG_DMA_LEN
  • CMD_DMA_START sert d’adresse de commande pour distinguer le remplissage des registres du lancement effectif du DMA
  • Dans le pilote noyau, execute_dma écrit la direction, la source, la destination et la longueur avec iowrite32, puis écrit enfin 1 dans CMD_DMA_START

Traitement du DMA côté périphérique QEMU

  • Le gpu_write MMIO de l’adaptateur QEMU remplace l’implémentation précédente pour gérer les registres DMA et les commandes
  • Les écritures dans la zone de registres stockent la valeur dans gpu->registers[reg]
  • Quand REG_DMA_START arrive dans la zone de commande, il vérifie la direction DMA
  • Pour la direction DIR_HOST_TO_GPU, il appelle pci_dma_read
    • l’adresse hôte est REG_DMA_ADDR_SRC
    • l’adresse périphérique est gpu->framebuffer + REG_DMA_ADDR_DST
    • la longueur est REG_DMA_LEN
  • Les autres directions DMA sont traitées dans le code d’exemple par Unimplemented DMA direction
  • Le gpu_fb_write du pilote noyau transmet les données utilisateur au DMA selon la procédure suivante
    • allocation d’un buffer noyau avec kmalloc(count, GFP_KERNEL)
    • copie des données utilisateur dans ce buffer avec copy_from_user
    • création d’une adresse DMA avec dma_map_single(&gpu->pdev->dev, kbuf, count, DMA_TO_DEVICE)
    • appel à execute_dma(gpu, DIR_HOST_TO_GPU, dma_addr, *offset, count)
    • libération du buffer avec kfree(kbuf)
  • Cette approche devient suffisamment rapide pour être mesurée à environ 300 µs sur le système d’exemple

Signaler la fin du DMA avec des interruptions MSI-X

  • Comme l’exécution DMA est asynchrone, il est plus pratique de faire en sorte que write bloque jusqu’à sa fin
  • Une carte PCI-e peut signaler le CPU via des Message Signalled Interrupts
  • Contrairement aux interruptions classiques utilisant une connexion électrique dédiée, les MSI transmettent l’interruption comme un paquet de messages ordinaire sur le bus
  • Pour la configuration MSI-X, le périphérique QEMU comporte deux zones
    • une table MSI-X stockant la configuration de chaque interruption
    • le PBA, bitmap des interruptions en attente
  • Les constantes de l’exemple sont les suivantes
    • IRQ_COUNT vaut 1
    • IRQ_DMA_DONE_NR vaut 0
    • MSIX_ADDR_BASE vaut 0x1000
    • PBA_ADDR_BASE vaut 0x3000
  • Dans pci_gpu_realize de QEMU, msix_init et msix_vector_use sont appelés pour initialiser MSI-X
  • Dans lspci -vv, MSI-X apparaît activé, avec la vector table à l’offset BAR0 00001000 et le PBA à l’offset BAR0 00003000
  • Une fois pci_dma_read terminé, msix_notify(&gpu->pdev, IRQ_DMA_DONE_NR) est appelé pour envoyer l’interruption

Gestionnaire d’IRQ côté noyau et bus mastering

  • Le pilote noyau alloue les vecteurs MSI-X/MSI avec pci_alloc_irq_vectors puis récupère le numéro d’IRQ avec pci_irq_vector
  • Il enregistre le handler GPU-Dma0 avec request_threaded_irq
  • Après le démarrage, /proc/interrupts montre par exemple l’IRQ 24 avec PCI-MSIX-0000:00:02.0 et GPU-Dma0
  • Au départ, cela ne fonctionne pas, car la carte n’a pas le droit d’envoyer des messages au CPU de manière autonome
  • La fonctionnalité qui permet au périphérique de manipuler directement la mémoire système sans intervention du CPU est le bus mastering
  • En appelant pci_set_master(pdev) dans le gpu_probe du noyau, on accorde au périphérique les droits de bus master
  • Ensuite, deux appels à write font apparaître deux fois IRQ 24 received dans les logs du noyau

Implémenter un vrai write bloquant avec une wait queue

  • Une fois la notification par interruption prête, on peut transformer write en appel bloquant via une wait queue Linux
  • L’état global contient wait_queue_head_t wq et volatile int irq_fired = 0
  • Le handler d’IRQ effectue les opérations suivantes
    • il définit l’état de fin avec irq_fired = 1
    • il réveille les threads en attente avec wake_up_interruptible(&wq)
    • il retourne IRQ_HANDLED
  • setup_msi ajoute init_waitqueue_head(&wq)
  • Après avoir lancé le DMA, gpu_fb_write attend l’interruption avec wait_event_interruptible(wq, irq_fired != 0)
  • Si l’attente est interrompue, il retourne -ERESTARTSYS

Afficher le framebuffer dans la console QEMU

  • Une fois qu’on dispose d’un framebuffer recevant les write(2) de l’espace utilisateur et les transmettant au périphérique PCI-e via DMA, on peut le relier à la sortie console de QEMU pour le faire ressembler à un GPU fonctionnel
  • On ajoute QemuConsole* con à GpuState dans QEMU
  • Dans pci_gpu_realize, graphic_console_init crée la console, puis qemu_console_surface récupère la surface d’affichage
  • Un premier motif de test est affiché en remplissant les valeurs dans les données de la surface sur la zone 640×480
  • vga_update_display copie le contenu de gpu->framebuffer vers la surface d’affichage QEMU
  • dpy_gfx_update(gpu->con, 0, 0, 640, 480) rafraîchit la zone 640×480
  • Ensuite, écrire un motif sur le périphérique sous-jacent met bien l’affichage à jour
  • Le code source est disponible dans the Github repo

Références

1 commentaires

 
GN⁺ 2024-07-29
Commentaires sur Hacker News
  • L’objectif final de cette série est de créer un adaptateur d’affichage avec un FPGA.
    Pour commencer, j’ai acheté une Tang Mega 138k [0], mais comme la documentation est limitée, cela prend du temps.
    Si vous avez des recommandations de cartes FPGA abordables avec IP matérielle PCI-e, je suis preneur.
    [0]: https://wiki.sipeed.com/hardware/en/tang/tang-mega-138k/mega...
  • Cela semble être une très bonne introduction aux pilotes de périphériques PCIe sous Linux.
    Je n’ai jamais travaillé directement sur des pilotes de périphériques Linux, mais il y a quelques années j’ai travaillé sur plusieurs pilotes PCIe pour un autre système d’exploitation, et les concepts me semblent très familiers.
    J’aimerais voir davantage de contenus de ce genre.
  • J’aime vraiment beaucoup le déroulé de l’article.
    Il n’inclut que juste assez de code pour montrer l’essentiel, et construit les choses étape par étape.
    Je n’avais jamais eu envie de créer un nouveau périphérique PCI de ma vie, mais maintenant j’en ai un peu envie ; n’est-ce pas une sorte de test décisif pour un bon article technique ?
  • Un grand merci pour avoir écrit ce genre d’article : c’est très pratique et riche en informations dans un domaine rare.
    Je voulais créer un environnement de développement et de playtest pour un projet, mais je ne savais même pas quels termes chercher ; c’était exactement ce dont j’avais besoin.
    Les deux autres parties étaient bonnes aussi, avec beaucoup de contenu concret comme la manière d’utiliser du code de pilote de services de démarrage après leur arrêt, le bus mastering, MSI-X, ainsi que de petits détails très utiles.