1 pontos por GN⁺ 2024-07-29 | 1 comentários | Compartilhar no WhatsApp
  • Saindo da fase de fazer peek/poke diretamente em um endereço BAR0 hardcoded, o sistema passa a localizar a memória BAR pelo subsistema PCI do Linux, e o driver de kernel inicializa o dispositivo
  • O driver começa com a tabela de IDs de struct pci_driver e a função probe, mapeia a BAR0 para um endereço virtual do kernel e prepara o acesso a partir do espaço de usuário
  • Por meio do dispositivo de caractere /dev/gpu-io, ele conecta read(2) e write(2), e usa container_of para recuperar o estado do driver a partir das operações de arquivo
  • A cópia em unidades de DWORD levava cerca de 800ms para transferir 1.2MiB, mas ao trocar por uma chamada de DMA baseada em registradores MMIO isso caiu para cerca de 300µs
  • A espera pela conclusão do DMA é tratada com interrupções MSI-X e wait queue, e no fim ele funciona como uma GPU falsa que exibe o conteúdo do framebuffer no console do QEMU

Encontrando e mapeando a BAR0 no driver de kernel

  • A implementação anterior lia e escrevia diretamente, em unidades de 32 bits, no endereço BAR0 0xfe000000 copiado do lspci
  • Para evitar hardcode do endereço, ele obtém do subsistema PCI do Linux as informações de mapeamento de memória do dispositivo
  • struct pci_driver precisa de dois campos principais
    • uma tabela de pares device/vendor ID compatíveis
    • uma função probe chamada quando o ID corresponde
  • O dispositivo de exemplo corresponde a PCI_DEVICE(0x1234, 0x1337)
  • O estado do driver GpuState guarda struct pci_dev *pdev e u8 __iomem * hwmem para a memória BAR
  • A função probe prepara o dispositivo na seguinte ordem
    • ativa o acesso à memória do dispositivo com pci_enable_device_mem(pdev)
    • obtém o bitfield dos BARs de memória disponíveis com pci_select_bars(pdev, IORESOURCE_MEM)
    • solicita a posse do espaço de endereços BAR com pci_request_region(pdev, bars, "gpu-pci")
    • obtém o endereço inicial e o tamanho da BAR0 com pci_resource_start(pdev, 0) e pci_resource_len(pdev, 0)
    • mapeia o endereço físico para um endereço virtual do kernel com ioremap(mmio_start, mmio_len)
  • Ao chamar pci_register_driver em module_init, o log de boot mostra mmio starts at 0xfe000000 e o endereço virtual do kernel

Expondo como dispositivo de caractere no espaço de usuário

  • Depois de mapear o espaço de endereços BAR0 no driver de kernel, ele cria um dispositivo de caractere para que programas no espaço de usuário interajam com o dispositivo PCIe via read(2) e write(2)
  • Este driver precisa apenas de três operações de arquivo: open, read e write
  • GpuState recebe um struct cdev cdev, e setup_chardev faz o seguinte
    • aloca um número de dispositivo com alloc_chrdev_region
    • registra o dispositivo de caractere com cdev_init e cdev_add
    • cria /dev/gpu-io com device_create
  • O script de init recebe /busybox mdev -s para popular o pseudossistema de arquivos /dev/
  • Depois disso, /dev/gpu-io aparece como dispositivo de caractere e, no exemplo, usa major 241 e minor 0

Encontrando o estado do driver nas operações de arquivo com container_of

  • Na implementação de write, private_data de struct file* precisa ser preenchido por open, mas open não recebe um argumento separado private_data ou user_data
  • struct inode contém um ponteiro struct cdev *i_cdev para o dispositivo de caractere
  • Como GpuState embute struct cdev, é possível recuperar o ponteiro de GpuState com container_of(inode->i_cdev, struct GpuState, cdev)
  • gpu_open armazena o GpuState obtido em file->private_data
  • Depois disso, gpu_read e gpu_write usam o GpuState extraído de file->private_data
  • As versões iniciais de read/write tratavam um DWORD por vez
    • gpu_read lê com ioread32(gpu->hwmem + *offset) e copia para o buffer do usuário com copy_to_user
    • gpu_write copia 4 bytes do buffer do usuário e incrementa o offset em 4
  • Funciona para transferências pequenas, mas é lento em transferências grandes porque a CPU precisa continuar processando um pacote por vez
  • Uma transferência de 1.2MiB, equivalente a 640×480 em 32bpp, levava cerca de 800ms

Criando chamadas de DMA com registradores MMIO

  • Em vez de a CPU repetir cópias em unidades de DWORD, ele usa DMA para que o próprio dispositivo copie os dados
  • A solicitação de trabalho é enviada por memory-mapped IO
    • alguns endereços de memória funcionam como registradores cujos valores são os argumentos da chamada DMA
    • outros endereços funcionam como comandos que significam executar a chamada
  • A interface DMA exige que a CPU informe ao dispositivo os seguintes valores
    • endereço source e tamanho dos dados a copiar
    • endereço destination
    • direção dos dados: para a main memory ou a partir da main memory
    • um sinal de que está tudo pronto para iniciar a cópia
  • O dispositivo precisa avisar a CPU quando a transferência terminar
  • Os registradores de exemplo são definidos assim
    • REG_DMA_DIR
    • REG_DMA_ADDR_SRC
    • REG_DMA_ADDR_DST
    • REG_DMA_LEN
  • CMD_DMA_START é usado como um endereço de comando para separar o preenchimento dos registradores do início real do DMA
  • No driver de kernel, execute_dma usa iowrite32 para gravar direção, source, destination e tamanho, e por fim escreve 1 em CMD_DMA_START

Processamento de DMA no lado do dispositivo QEMU

  • A gpu_write MMIO do adaptador QEMU substitui a implementação anterior e passa a tratar registradores e comandos DMA
  • Escritas na área de registradores armazenam os valores em gpu->registers[reg]
  • Quando REG_DMA_START chega na área de comandos, ele verifica a direção do DMA
  • Na direção DIR_HOST_TO_GPU, ele chama pci_dma_read
    • o endereço host vem de REG_DMA_ADDR_SRC
    • o endereço device vem de gpu->framebuffer + REG_DMA_ADDR_DST
    • o tamanho vem de REG_DMA_LEN
  • As outras direções de DMA são tratadas no código de exemplo como Unimplemented DMA direction
  • gpu_fb_write no driver de kernel entrega os dados do usuário ao DMA com o seguinte fluxo
    • aloca um buffer de kernel com kmalloc(count, GFP_KERNEL)
    • copia os dados do usuário para o buffer de kernel com copy_from_user
    • gera um endereço DMA com dma_map_single(&gpu->pdev->dev, kbuf, count, DMA_TO_DEVICE)
    • chama execute_dma(gpu, DIR_HOST_TO_GPU, dma_addr, *offset, count)
    • libera o buffer com kfree(kbuf)
  • Essa abordagem ficou rápida o suficiente para medir cerca de 300µs no sistema de exemplo

Sinalizando conclusão do DMA com interrupções MSI-X

  • Como a execução do DMA é assíncrona, fica mais conveniente fazer write bloquear até sua conclusão
  • A placa PCI-e pode sinalizar a CPU com Message Signalled Interrupts
  • Diferentemente das interrupções clássicas, que usam uma conexão elétrica dedicada, MSI envia a interrupção como um pacote de mensagem normal no barramento
  • Para configurar MSI-X, o dispositivo QEMU possui duas áreas
    • a tabela MSI-X, que armazena a configuração de cada interrupção
    • a PBA, um bitmap de interrupções pendentes
  • As constantes de exemplo são as seguintes
    • IRQ_COUNT vale 1
    • IRQ_DMA_DONE_NR vale 0
    • MSIX_ADDR_BASE vale 0x1000
    • PBA_ADDR_BASE vale 0x3000
  • Em pci_gpu_realize, o QEMU chama msix_init e msix_vector_use para inicializar o MSI-X
  • Em lspci -vv, o MSI-X aparece ativado, com a vector table em BAR0 offset 00001000 e a PBA em BAR0 offset 00003000
  • Depois de pci_dma_read, ele chama msix_notify(&gpu->pdev, IRQ_DMA_DONE_NR) para disparar a interrupção

Handler de IRQ no kernel e bus mastering

  • O driver de kernel aloca vetores MSI-X/MSI com pci_alloc_irq_vectors e obtém o número da IRQ com pci_irq_vector
  • Ele registra o handler GPU-Dma0 com request_threaded_irq
  • Após o boot, /proc/interrupts mostra, no exemplo, a IRQ 24 como PCI-MSIX-0000:00:02.0 e GPU-Dma0
  • No começo isso não funciona porque a placa não tem permissão para enviar mensagens à CPU de forma independente
  • A capacidade que permite ao dispositivo manipular diretamente a memória do sistema sem intervenção da CPU é o bus mastering
  • Ao chamar pci_set_master(pdev) em gpu_probe, o dispositivo recebe permissão de bus master
  • Depois disso, ao chamar write duas vezes, o log do kernel mostra IRQ 24 received duas vezes

Implementando write realmente bloqueante com wait queue

  • Com a notificação baseada em interrupção pronta, é possível transformar write em uma chamada bloqueante com a wait queue do Linux
  • O estado global usa wait_queue_head_t wq e volatile int irq_fired = 0
  • O handler de IRQ faz o seguinte
    • define irq_fired = 1 como estado de conclusão
    • chama wake_up_interruptible(&wq) para acordar a thread em espera
    • retorna IRQ_HANDLED
  • setup_msi adiciona init_waitqueue_head(&wq)
  • Depois de executar o DMA, gpu_fb_write espera pela interrupção com wait_event_interruptible(wq, irq_fired != 0)
  • Se a espera for interrompida, ele retorna -ERESTARTSYS

Exibindo o framebuffer no console do QEMU

  • Como agora existe um framebuffer que recebe write(2) do espaço de usuário e o envia ao dispositivo PCI-e via DMA, ele pode ser ligado à saída do console do QEMU para se comportar como uma GPU funcional
  • GpuState do QEMU recebe QemuConsole* con
  • Em pci_gpu_realize, ele cria o console com graphic_console_init e obtém a display surface com qemu_console_surface
  • Um padrão de teste inicial preenche os dados da surface no intervalo 640×480 para exibição
  • vga_update_display copia o conteúdo de gpu->framebuffer para a display surface do QEMU
  • dpy_gfx_update(gpu->con, 0, 0, 640, 480) atualiza a área 640×480
  • Depois disso, ao gravar um padrão no dispositivo subjacente, a exibição muda
  • O código-fonte está no repositório no Github

Referências

1 comentários

 
GN⁺ 2024-07-29
Comentários do Hacker News
  • O objetivo final desta série é criar um adaptador de vídeo com FPGA
    Comprei uma Tang Mega 138k [0] para começar, mas há pouca documentação, então está levando tempo
    Se alguém tiver recomendações de placas FPGA baratas com PCI-e hard IP, seria ótimo saber
    [0]: https://wiki.sipeed.com/hardware/en/tang/tang-mega-138k/mega...
  • Parece uma ótima introdução a drivers de dispositivo PCIe no Linux
    Nunca mexi diretamente com drivers de dispositivo no Linux, mas alguns anos atrás trabalhei em vários drivers PCIe em outro sistema operacional, e os conceitos parecem muito familiares
    Seria bom ver mais conteúdo desse tipo
  • Gosto muito do fluxo do artigo
    Ele inclui apenas código suficiente para mostrar o essencial e vai construindo tudo passo a passo
    Nunca tive vontade de criar um novo dispositivo PCI na vida, mas agora fiquei com um pouco de vontade; talvez esse seja o teste decisivo de uma boa escrita técnica
  • Muito obrigado por escrever este tipo de artigo; é algo raro, muito prático e cheio de informação
    Eu queria montar um ambiente de desenvolvimento e playtest para um projeto, mas nem sabia quais termos pesquisar, e era exatamente disso que eu precisava
    Os outros dois artigos também foram bons, com muito conteúdo prático e detalhes pequenos, mas úteis, como como usar código de driver de boot services após a saída, bus mastering e MSI-X