Apprendre le PCI-e : pilotes et DMA
(blog.davidv.dev)- 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_driveret la fonctionprobe, 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 relieread(2)etwrite(2), et récupère l’état du pilote depuis les opérations de fichier aveccontainer_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
0xfe000000copiée depuislspci - 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_drivera besoin de deux champs essentiels- une table des paires device/vendor ID prises en charge
- une fonction
probeappelée quand un ID correspond
- Le périphérique d’exemple correspond à
PCI_DEVICE(0x1234, 0x1337) - L’état du pilote
GpuStatestockestruct pci_dev *pdevetu8 __iomem * hwmempour la mémoire BAR - La fonction
probeprépare le périphérique dans l’ordre suivantpci_enable_device_mem(pdev)active l’accès à la mémoire du périphériquepci_select_bars(pdev, IORESOURCE_MEM)récupère le bitmap des BAR mémoire utilisablespci_request_region(pdev, bars, "gpu-pci")demande la possession de l’espace d’adressage BARpci_resource_start(pdev, 0)etpci_resource_len(pdev, 0)récupèrent l’adresse de début et la taille de BAR0ioremap(mmio_start, mmio_len)mappe l’adresse physique en adresse virtuelle du noyau
- Quand
pci_register_driverest appelé dansmodule_init, les logs de démarrage affichentmmio starts at 0xfe000000ainsi 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)etwrite(2) - Ce pilote n’a besoin que de trois opérations de fichier :
open,read,write - On ajoute
struct cdev cdevàGpuState, puissetup_chardeveffectue les opérations suivantesalloc_chrdev_regionalloue un numéro de périphériquecdev_initetcdev_addenregistrent le périphérique de caractèresdevice_createcrée/dev/gpu-io
- Le script d’initialisation ajoute
/busybox mdev -spour peupler le pseudo-système de fichiers/dev/ - Ensuite,
/dev/gpu-ioapparaît comme un périphérique de caractères, avec dans l’exemple le major241et le minor0
Retrouver l’état du pilote depuis les opérations de fichier avec container_of
- Dans l’implémentation de
write, leprivate_datadestruct file*doit être rempli paropen, maisopenne reçoit pas d’argumentprivate_dataniuser_dataséparé struct inodecontient un pointeurstruct cdev *i_cdevvers le périphérique de caractères- Comme
GpuStateembarque unstruct cdev, on peut retrouver le pointeurGpuStateaveccontainer_of(inode->i_cdev, struct GpuState, cdev) gpu_openstocke alors leGpuStateobtenu dansfile->private_data- Ensuite,
gpu_readetgpu_writerécupèrentGpuStatedepuisfile->private_datapour l’utiliser - Les premières versions de
read/writetraitent un DWORD à la foisgpu_readlit avecioread32(gpu->hwmem + *offset)puis copie vers le buffer utilisateur aveccopy_to_usergpu_writecopie 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_DIRREG_DMA_ADDR_SRCREG_DMA_ADDR_DSTREG_DMA_LEN
CMD_DMA_STARTsert 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 aveciowrite32, puis écrit enfin1dansCMD_DMA_START
Traitement du DMA côté périphérique QEMU
- Le
gpu_writeMMIO 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_STARTarrive dans la zone de commande, il vérifie la direction DMA - Pour la direction
DIR_HOST_TO_GPU, il appellepci_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
- l’adresse hôte est
- Les autres directions DMA sont traitées dans le code d’exemple par
Unimplemented DMA direction - Le
gpu_fb_writedu 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)
- allocation d’un buffer noyau avec
- 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
writebloque 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_COUNTvaut1IRQ_DMA_DONE_NRvaut0MSIX_ADDR_BASEvaut0x1000PBA_ADDR_BASEvaut0x3000
- Dans
pci_gpu_realizede QEMU,msix_initetmsix_vector_usesont appelés pour initialiser MSI-X - Dans
lspci -vv, MSI-X apparaît activé, avec la vector table à l’offset BAR000001000et le PBA à l’offset BAR000003000 - Une fois
pci_dma_readterminé,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_vectorspuis récupère le numéro d’IRQ avecpci_irq_vector - Il enregistre le handler
GPU-Dma0avecrequest_threaded_irq - Après le démarrage,
/proc/interruptsmontre par exemple l’IRQ24avecPCI-MSIX-0000:00:02.0etGPU-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 legpu_probedu noyau, on accorde au périphérique les droits de bus master - Ensuite, deux appels à
writefont apparaître deux foisIRQ 24 receiveddans 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
writeen appel bloquant via une wait queue Linux - L’état global contient
wait_queue_head_t wqetvolatile 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
- il définit l’état de fin avec
setup_msiajouteinit_waitqueue_head(&wq)- Après avoir lancé le DMA,
gpu_fb_writeattend l’interruption avecwait_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àGpuStatedans QEMU - Dans
pci_gpu_realize,graphic_console_initcrée la console, puisqemu_console_surfaceré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_displaycopie le contenu degpu->framebuffervers la surface d’affichage QEMUdpy_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
1 commentaires
Commentaires sur Hacker News
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...
Spartan 6 https://www.blackmagicdesign.com/products/decklink/techspecs...
Artix https://www.blackmagicdesign.com/products/decklink/techspecs...
Artix https://www.blackmagicdesign.com/products/decklink/techspecs...
Artix https://www.blackmagicdesign.com/products/decklink/techspecs...
En revanche, sa seule interface externe à haut débit est un port USB 3.1 Gen 1.
https://shop.lambdaconcept.com/home/50-screamer-pcie-squirre...
Litefury est un kit FPGA Xilinx au format « NVMe SSD » (2280 Key M), utilisant un Xilinx XC7A100T, à 102 euros.
Il ne dispose que de quelques entrées/sorties LVDS externes à haut débit.
https://rhsresearch.com/collections/rhs-public/products/lite...
Vivado n’est pas un outil « excellent » du point de vue d’un ingénieur logiciel professionnel, mais pour le développement et l’implémentation FPGA, il fait clairement partie de ce qui se fait de mieux dans l’industrie.
Le parcours de développement de périphériques PCIe chez Xilinx est aussi assez bien balisé.
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.
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 ?
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.