1 puntos por GN⁺ 2024-07-29 | 1 comentarios | Compartir por WhatsApp
  • Dejando atrás la etapa de hacer peek/poke directamente sobre una dirección BAR0 hardcodeada, se usa el subsistema PCI de Linux para encontrar la memoria BAR y el driver del kernel inicializa el dispositivo
  • El driver comienza con la tabla de IDs de struct pci_driver y la función probe; luego mapea BAR0 como una dirección virtual del kernel y prepara el acceso desde espacio de usuario
  • A través del dispositivo de caracteres /dev/gpu-io, se conectan read(2) y write(2), y con container_of se recupera el estado del driver desde las operaciones de archivo
  • La copia por DWORD tardaba unos 800 ms para una transferencia de 1.2 MiB, pero al cambiarla por llamadas DMA basadas en registros MMIO bajó hasta alrededor de 300 µs
  • La espera de finalización de DMA se maneja con interrupciones MSI-X y una wait queue; finalmente funciona como una GPU falsa que muestra el contenido del framebuffer en la consola de QEMU

Encontrar y mapear BAR0 desde el driver del kernel

  • La implementación anterior leía y escribía directamente en unidades de 32 bits sobre la dirección BAR0 0xfe000000, copiada desde lspci
  • Para no hardcodear la dirección, se obtiene la información de mapeo de memoria del dispositivo desde el subsistema PCI de Linux
  • struct pci_driver necesita dos campos clave
    • Una tabla de pares device/vendor ID compatibles
    • Una función probe que se llama cuando el ID coincide
  • El dispositivo de ejemplo coincide con PCI_DEVICE(0x1234, 0x1337)
  • El estado del driver, GpuState, guarda struct pci_dev *pdev y u8 __iomem * hwmem para la memoria BAR
  • La función probe prepara el dispositivo en este orden
    • Habilita el acceso a la memoria del dispositivo con pci_enable_device_mem(pdev)
    • Obtiene el bitfield de BAR de memoria disponibles con pci_select_bars(pdev, IORESOURCE_MEM)
    • Solicita la propiedad del espacio de direcciones BAR con pci_request_region(pdev, bars, "gpu-pci")
    • Obtiene la dirección inicial y la longitud de BAR0 con pci_resource_start(pdev, 0) y pci_resource_len(pdev, 0)
    • Mapea la dirección física a una dirección virtual del kernel con ioremap(mmio_start, mmio_len)
  • Al llamar pci_register_driver desde module_init, en el log de arranque se imprimen mmio starts at 0xfe000000 y la dirección virtual del kernel

Exponerlo al espacio de usuario como dispositivo de caracteres

  • Después de mapear el espacio de direcciones BAR0 en el driver del kernel, se crea un dispositivo de caracteres para que un programa de espacio de usuario interactúe con el dispositivo PCIe mediante read(2) y write(2)
  • Este driver solo necesita tres operaciones de archivo: open, read y write
  • Se agrega struct cdev cdev a GpuState, y en setup_chardev se realizan las siguientes tareas
    • Asignar un número de dispositivo con alloc_chrdev_region
    • Registrar el dispositivo de caracteres con cdev_init y cdev_add
    • Crear /dev/gpu-io con device_create
  • Se agrega /busybox mdev -s al script de init para poblar el seudofs /dev/
  • Luego /dev/gpu-io aparece como dispositivo de caracteres; en el ejemplo se muestra con número major 241 y número minor 0

Encontrar el estado del driver desde operaciones de archivo con container_of

  • En la implementación de write, private_data de struct file* debe ser completado por open, pero open no recibe un argumento separado de private_data ni de user_data
  • struct inode tiene un puntero struct cdev *i_cdev que apunta al dispositivo de caracteres
  • Como GpuState embebe struct cdev, se puede recuperar el puntero a GpuState con container_of(inode->i_cdev, struct GpuState, cdev)
  • gpu_open guarda el GpuState obtenido en file->private_data
  • Después, gpu_read y gpu_write extraen y usan GpuState desde file->private_data
  • Los read/write iniciales procesan un DWORD por vez
    • gpu_read lee con ioread32(gpu->hwmem + *offset) y copia al búfer de usuario con copy_to_user
    • gpu_write copia 4 bytes desde el búfer de usuario e incrementa el offset en 4
  • Funciona para transferencias pequeñas, pero es lento para transferencias grandes porque la CPU debe seguir procesando paquete por paquete
  • Una transferencia de 1.2 MiB, correspondiente a 640×480 a 32 bpp, tarda unos 800ms

Crear llamadas DMA con registros MMIO

  • En vez de que la CPU repita copias por DWORD, se usa DMA para que el dispositivo copie los datos directamente
  • Las solicitudes de trabajo se envían mediante entrada/salida mapeada en memoria (memory-mapped IO)
    • Algunas direcciones de memoria se usan como registros que actúan como argumentos de la llamada DMA
    • Otras direcciones se usan como comandos que significan ejecutar la llamada de función
  • La interfaz DMA tiene valores que la CPU debe comunicarle al dispositivo
    • La dirección source y la longitud de los datos a copiar
    • La dirección destination
    • La dirección de los datos: hacia main memory o desde main memory
    • Una señal de que está listo para iniciar la copia
  • El dispositivo debe avisarle a la CPU cuando termine la transferencia
  • Los registros de ejemplo se definen así
    • REG_DMA_DIR
    • REG_DMA_ADDR_SRC
    • REG_DMA_ADDR_DST
    • REG_DMA_LEN
  • CMD_DMA_START se usa como dirección de comando para distinguir entre la acción de completar valores de registros y el inicio real de DMA
  • execute_dma del driver del kernel escribe la dirección, source, destination y longitud con iowrite32, y al final escribe 1 en CMD_DMA_START

Manejo de DMA del lado del dispositivo QEMU

  • El gpu_write MMIO del adaptador QEMU reemplaza la implementación anterior y procesa registros y comandos DMA
  • Las escrituras en el área de registros guardan el valor en gpu->registers[reg]
  • Cuando llega REG_DMA_START en el área de comandos, se verifica la dirección de DMA
  • En la dirección DIR_HOST_TO_GPU, se llama a pci_dma_read
    • La dirección host es REG_DMA_ADDR_SRC
    • La dirección device es gpu->framebuffer + REG_DMA_ADDR_DST
    • La longitud es REG_DMA_LEN
  • En el código de ejemplo, las otras direcciones DMA se manejan como Unimplemented DMA direction
  • gpu_fb_write del driver del kernel pasa los datos de usuario a DMA con el siguiente procedimiento
    • Asigna un búfer del kernel con kmalloc(count, GFP_KERNEL)
    • Copia los datos de usuario al búfer del kernel con copy_from_user
    • Crea una dirección DMA con dma_map_single(&gpu->pdev->dev, kbuf, count, DMA_TO_DEVICE)
    • Llama a execute_dma(gpu, DIR_HOST_TO_GPU, dma_addr, *offset, count)
    • Libera el búfer con kfree(kbuf)
  • Este enfoque se vuelve lo bastante rápido como para medirse en unos 300 µs en el sistema de ejemplo

Notificar la finalización de DMA con interrupciones MSI-X

  • Como la ejecución de DMA es asíncrona, es más cómodo hacer que write bloquee hasta que termine
  • Una tarjeta PCI-e puede enviar señales a la CPU mediante Message Signalled Interrupts
  • A diferencia de las interrupciones clásicas que usan una conexión eléctrica dedicada, MSI entrega la interrupción como un paquete de mensaje normal sobre el bus
  • Para configurar MSI-X, el dispositivo QEMU tiene dos áreas
    • Una MSI-X table que guarda la configuración de cada interrupción
    • Una PBA, que es el pending interrupt bitmap
  • Las constantes de ejemplo son las siguientes
    • IRQ_COUNT es 1
    • IRQ_DMA_DONE_NR es 0
    • MSIX_ADDR_BASE es 0x1000
    • PBA_ADDR_BASE es 0x3000
  • En pci_gpu_realize de QEMU se llama a msix_init y msix_vector_use para inicializar MSI-X
  • En lspci -vv, MSI-X aparece activado; la vector table se muestra en el offset 00001000 de BAR0 y la PBA en el offset 00003000 de BAR0
  • Después de que termina pci_dma_read, se envía la interrupción llamando a msix_notify(&gpu->pdev, IRQ_DMA_DONE_NR)

Handler IRQ del kernel y bus mastering

  • El driver del kernel asigna vectores MSI-X/MSI con pci_alloc_irq_vectors y obtiene el número de IRQ con pci_irq_vector
  • Registra el handler GPU-Dma0 con request_threaded_irq
  • Después del arranque, en /proc/interrupts el IRQ 24 aparece como PCI-MSIX-0000:00:02.0 y GPU-Dma0, como en el ejemplo
  • Al principio no funciona, porque la tarjeta no tiene permiso para enviar mensajes independientemente a la CPU
  • La capacidad que permite que un dispositivo manipule directamente la memoria del sistema sin intervención de la CPU es bus mastering
  • Si en gpu_probe del kernel se llama a pci_set_master(pdev), se le otorgan permisos de bus master al dispositivo
  • Después, al llamar dos veces a write, el log del kernel imprime IRQ 24 received dos veces

Implementar un write bloqueante real con wait queue

  • Una vez lista la notificación basada en interrupciones, se puede cambiar write a una llamada bloqueante usando una wait queue de Linux
  • Como estado global se tienen wait_queue_head_t wq y volatile int irq_fired = 0
  • El handler IRQ realiza las siguientes tareas
    • Define el estado de finalización con irq_fired = 1
    • Despierta los threads en espera con wake_up_interruptible(&wq)
    • Devuelve IRQ_HANDLED
  • En setup_msi se agrega init_waitqueue_head(&wq)
  • Después de ejecutar DMA, gpu_fb_write espera la interrupción con wait_event_interruptible(wq, irq_fired != 0)
  • Si se interrumpe durante la espera, devuelve -ERESTARTSYS

Mostrar el framebuffer en la consola de QEMU

  • Como ya existe un framebuffer que recibe write(2) desde espacio de usuario y lo entrega al dispositivo PCI-e mediante DMA, se conecta a la salida de la consola de QEMU para que parezca una GPU funcionando
  • Se agrega QemuConsole* con a GpuState de QEMU
  • En pci_gpu_realize se crea la consola con graphic_console_init y se obtiene la display surface con qemu_console_surface
  • El patrón de prueba inicial se muestra completando valores en los datos de la surface dentro del rango 640×480
  • vga_update_display copia el contenido de gpu->framebuffer a la display surface de QEMU
  • Con dpy_gfx_update(gpu->con, 0, 0, 640, 480) se actualiza el área de 640×480
  • Luego, si se escribe un patrón en el underlying device, cambia lo que se muestra
  • El código fuente está en the Github repo

Referencias

1 comentarios

 
GN⁺ 2024-07-29
Comentarios de Hacker News
  • El objetivo final de esta serie es crear un adaptador de pantalla con FPGA.
    Compré una Tang Mega 138k [0] para empezar, pero como no hay mucha documentación, me está llevando tiempo.
    Si alguien conoce una placa FPGA económica con hard IP de PCI-e, sería genial que la recomendara.
    [0]: https://wiki.sipeed.com/hardware/en/tang/tang-mega-138k/mega...
  • Parece una muy buena introducción a los drivers de dispositivos PCIe en Linux.
    Nunca trabajé directamente con drivers de dispositivos en Linux, pero hace unos años trabajé en varios drivers PCIe en otro sistema operativo, y los conceptos se ven muy familiares.
    Ojalá haya más contenido de este tipo.
  • Me gusta mucho el flujo del artículo.
    Incluye solo el código suficiente para mostrar lo esencial y lo va construyendo paso a paso.
    En mi vida había querido crear un nuevo dispositivo PCI, pero ahora me dieron un poco de ganas, y me pregunto si eso no es como la prueba de fuego de una buena escritura técnica.
  • Muchas gracias por escribir algo así; es muy práctico y cargado de información en un área poco común.
    Quería crear un entorno de desarrollo y playtesting para un proyecto, pero ni siquiera sabía qué términos buscar, y esto era justo lo que necesitaba.
    Las otras 2 partes también fueron buenas, con mucho contenido práctico como cómo usar el código de un driver de servicios de arranque después de salir, bus mastering, MSI-X y pequeños detalles útiles.