1 điểm bởi GN⁺ 2024-07-29 | 1 bình luận | Chia sẻ qua WhatsApp
  • Thay vì tiếp tục trực tiếp peek/poke vào địa chỉ BAR0 được hardcode, bài viết chuyển sang dùng PCI subsystem của Linux để tìm vùng nhớ BAR và để kernel driver khởi tạo thiết bị
  • Driver bắt đầu từ bảng ID và hàm probe của struct pci_driver, sau đó ánh xạ BAR0 thành địa chỉ ảo của kernel rồi chuẩn bị cho truy cập từ không gian người dùng
  • Thông qua character device /dev/gpu-io, driver nối với read(2)write(2), đồng thời dùng container_of để lấy lại trạng thái driver từ file operation
  • Bản sao theo đơn vị DWORD mất khoảng 800ms cho lần truyền 1.2MiB, nhưng khi đổi sang gọi DMA dựa trên thanh ghi MMIO thì giảm xuống còn khoảng 300µs
  • Việc chờ DMA hoàn tất được xử lý bằng ngắt MSI-X và wait queue, và cuối cùng hệ thống hoạt động như một GPU giả để hiển thị nội dung framebuffer trên console của QEMU

Tìm và ánh xạ BAR0 trong kernel driver

  • Ở bản triển khai trước, tác giả đọc và ghi trực tiếp theo đơn vị 32-bit vào địa chỉ BAR0 0xfe000000 được sao chép từ lspci
  • Để không phải hardcode địa chỉ, bài viết lấy thông tin ánh xạ bộ nhớ của thiết bị từ PCI subsystem của Linux
  • struct pci_driver cần hai trường cốt lõi
    • bảng các cặp device/vendor ID mà driver hỗ trợ
    • hàm probe được gọi khi ID khớp
  • Thiết bị ví dụ khớp với PCI_DEVICE(0x1234, 0x1337)
  • Trạng thái driver GpuState lưu struct pci_dev *pdevu8 __iomem * hwmem cho vùng nhớ BAR
  • Hàm probe chuẩn bị thiết bị theo thứ tự sau
    • pci_enable_device_mem(pdev) để bật truy cập bộ nhớ thiết bị
    • pci_select_bars(pdev, IORESOURCE_MEM) để lấy bitfield các BAR bộ nhớ khả dụng
    • pci_request_region(pdev, bars, "gpu-pci") để yêu cầu quyền sở hữu không gian địa chỉ BAR
    • pci_resource_start(pdev, 0)pci_resource_len(pdev, 0) để lấy địa chỉ bắt đầu và độ dài của BAR0
    • ioremap(mmio_start, mmio_len) để ánh xạ địa chỉ vật lý sang địa chỉ ảo của kernel
  • Khi gọi pci_register_driver trong module_init, log khởi động sẽ in ra mmio starts at 0xfe000000 cùng địa chỉ ảo của kernel

Phơi ra cho user space dưới dạng character device

  • Sau khi ánh xạ không gian địa chỉ BAR0 vào kernel driver, bài viết tạo một character device để chương trình user space có thể tương tác với thiết bị PCIe qua read(2)write(2)
  • Driver này chỉ cần ba file operation: open, read, write
  • Thêm struct cdev cdev vào GpuState, rồi trong setup_chardev thực hiện các bước sau
    • alloc_chrdev_region để cấp phát số thiết bị
    • cdev_initcdev_add để đăng ký character device
    • device_create để tạo /dev/gpu-io
  • Thêm /busybox mdev -s vào script khởi tạo để điền pseudo-filesystem /dev/
  • Sau đó /dev/gpu-io xuất hiện như một character device, trong ví dụ có major 241, minor 0

Dùng container_of để tìm trạng thái driver trong file operation

  • Trong phần cài đặt write, private_data của struct file* phải do open điền, nhưng open không nhận đối số private_data hay user_data riêng
  • struct inode có con trỏ struct cdev *i_cdev trỏ tới character device
  • GpuState nhúng struct cdev, có thể lấy lại con trỏ GpuState bằng container_of(inode->i_cdev, struct GpuState, cdev)
  • gpu_open lưu GpuState lấy được vào file->private_data
  • Sau đó gpu_readgpu_write lấy GpuState từ file->private_data để sử dụng
  • read/write ban đầu xử lý mỗi lần một DWORD
    • gpu_read đọc bằng ioread32(gpu->hwmem + *offset) rồi sao chép sang buffer người dùng bằng copy_to_user
    • gpu_write sao chép 4 byte từ buffer người dùng và tăng offset thêm 4
  • Cách này hoạt động với truyền nhỏ, nhưng chậm với truyền lớn vì CPU phải liên tục xử lý từng packet
  • Một lần truyền 1.2MiB tương ứng 640×480, 32bpp mất khoảng 800ms

Tạo lời gọi DMA bằng thanh ghi MMIO

  • Thay vì để CPU lặp lại việc sao chép theo đơn vị DWORD, bài viết dùng DMA để thiết bị tự sao chép dữ liệu
  • Yêu cầu công việc được gửi bằng memory-mapped IO
    • một số địa chỉ bộ nhớ được dùng như các thanh ghi đóng vai trò tham số cho lời gọi DMA
    • các địa chỉ khác được dùng như lệnh mang ý nghĩa thực thi lời gọi hàm
  • Giao diện DMA cần những giá trị mà CPU phải báo cho thiết bị
    • địa chỉ source và độ dài dữ liệu cần sao chép
    • địa chỉ destination
    • hướng dữ liệu: về phía main memory hoặc từ main memory
    • tín hiệu cho biết đã sẵn sàng bắt đầu sao chép
  • Thiết bị cũng phải báo lại cho CPU khi truyền xong
  • Các thanh ghi ví dụ được định nghĩa như sau
    • REG_DMA_DIR
    • REG_DMA_ADDR_SRC
    • REG_DMA_ADDR_DST
    • REG_DMA_LEN
  • CMD_DMA_START được dùng như địa chỉ lệnh để tách việc điền giá trị thanh ghi khỏi việc thực sự khởi động DMA
  • execute_dma trong kernel driver dùng iowrite32 để ghi hướng, source, destination, độ dài, rồi cuối cùng ghi 1 vào CMD_DMA_START

Xử lý DMA ở phía thiết bị QEMU

  • Hàm MMIO gpu_write của adapter QEMU thay cho cách cũ để xử lý các thanh ghi và lệnh DMA
  • Ghi vào vùng thanh ghi sẽ lưu giá trị vào gpu->registers[reg]
  • Khi vùng lệnh nhận REG_DMA_START, thiết bị sẽ kiểm tra hướng DMA
  • Với hướng DIR_HOST_TO_GPU, nó gọi pci_dma_read
    • địa chỉ host là REG_DMA_ADDR_SRC
    • địa chỉ thiết bị là gpu->framebuffer + REG_DMA_ADDR_DST
    • độ dài là REG_DMA_LEN
  • Các hướng DMA khác trong mã ví dụ được xử lý bằng Unimplemented DMA direction
  • gpu_fb_write trong kernel driver chuyển dữ liệu người dùng sang DMA theo quy trình sau
    • kmalloc(count, GFP_KERNEL) để cấp phát buffer kernel
    • copy_from_user để sao chép dữ liệu từ user space vào buffer kernel
    • dma_map_single(&gpu->pdev->dev, kbuf, count, DMA_TO_DEVICE) để tạo địa chỉ DMA
    • gọi execute_dma(gpu, DIR_HOST_TO_GPU, dma_addr, *offset, count)
    • kfree(kbuf) để giải phóng buffer
  • Cách này tăng tốc rõ rệt, được đo khoảng 300µs trên hệ thống ví dụ

Báo hoàn tất DMA bằng ngắt MSI-X

  • Vì DMA chạy bất đồng bộ, sẽ tiện hơn nếu làm cho write bị block cho đến khi hoàn tất
  • Card PCI-e có thể gửi tín hiệu cho CPU bằng Message Signalled Interrupts
  • Không giống ngắt kiểu cũ dùng đường điện riêng, MSI truyền ngắt dưới dạng packet thông điệp thông thường trên bus
  • Để cấu hình MSI-X, thiết bị QEMU có hai vùng
    • MSI-X table lưu cấu hình cho từng ngắt
    • PBA là bitmap các ngắt pending
  • Các hằng số ví dụ như sau
    • IRQ_COUNT1
    • IRQ_DMA_DONE_NR0
    • MSIX_ADDR_BASE0x1000
    • PBA_ADDR_BASE0x3000
  • Trong pci_gpu_realize của QEMU, tác giả gọi msix_initmsix_vector_use để khởi tạo MSI-X
  • Trong lspci -vv, MSI-X được hiển thị là đã bật, vector table ở BAR0 offset 00001000, còn PBA ở BAR0 offset 00003000
  • Sau khi pci_dma_read kết thúc, thiết bị gọi msix_notify(&gpu->pdev, IRQ_DMA_DONE_NR) để phát ngắt

IRQ handler của kernel và bus mastering

  • Kernel driver cấp phát vector MSI-X/MSI bằng pci_alloc_irq_vectors và lấy số IRQ bằng pci_irq_vector
  • Sau đó đăng ký handler GPU-Dma0 bằng request_threaded_irq
  • Sau khi boot, /proc/interrupts sẽ hiển thị ví dụ IRQ 24 với PCI-MSIX-0000:00:02.0GPU-Dma0
  • Ban đầu nó chưa hoạt động vì card chưa có quyền tự gửi thông điệp đến CPU
  • Tính năng cho phép thiết bị trực tiếp thao tác với system memory mà không cần CPU can thiệp được gọi là bus mastering
  • Khi gọi pci_set_master(pdev) trong gpu_probe, thiết bị sẽ được cấp quyền bus master
  • Sau đó nếu gọi write hai lần, kernel log sẽ in IRQ 24 received hai lần

Dùng wait queue để triển khai blocking write thực sự

  • Khi thông báo dựa trên ngắt đã sẵn sàng, có thể dùng wait queue của Linux để biến write thành lời gọi block
  • Trạng thái toàn cục gồm wait_queue_head_t wqvolatile int irq_fired = 0
  • IRQ handler thực hiện các việc sau
    • đặt irq_fired = 1 để đánh dấu hoàn tất
    • gọi wake_up_interruptible(&wq) để đánh thức thread đang chờ
    • trả về IRQ_HANDLED
  • Trong setup_msi, thêm init_waitqueue_head(&wq)
  • gpu_fb_write sau khi chạy DMA sẽ chờ ngắt bằng wait_event_interruptible(wq, irq_fired != 0)
  • Nếu bị ngắt trong lúc chờ, hàm trả về -ERESTARTSYS

Hiển thị framebuffer trên console của QEMU

  • Sau khi có framebuffer nhận write(2) từ user space và chuyển vào thiết bị PCI-e bằng DMA, bài viết nối nó với đầu ra console của QEMU để trông như một GPU hoạt động
  • Thêm QemuConsole* con vào GpuState của QEMU
  • Trong pci_gpu_realize, gọi graphic_console_init để tạo console và qemu_console_surface để lấy display surface
  • Mẫu thử ban đầu hiển thị bằng cách điền giá trị vào dữ liệu surface trong vùng 640×480
  • vga_update_display sao chép nội dung gpu->framebuffer sang display surface của QEMU
  • dpy_gfx_update(gpu->con, 0, 0, 640, 480) cập nhật vùng 640×480
  • Từ đó, khi ghi pattern vào thiết bị nền bên dưới thì hình hiển thị cũng thay đổi
  • Mã nguồn có tại the Github repo

Tài liệu tham khảo

1 bình luận

 
GN⁺ 2024-07-29
Ý kiến trên Hacker News
  • Mục tiêu cuối cùng của loạt bài này là tạo bộ điều hợp hiển thị bằng FPGA
    Tôi đã mua Tang Mega 138k [0] để bắt đầu, nhưng tài liệu không nhiều nên đang mất khá nhiều thời gian
    Nếu ai có gợi ý về một bo mạch FPGA giá rẻ có PCI-e hard IP thì rất mong được cho biết
    [0]: https://wiki.sipeed.com/hardware/en/tang/tang-mega-138k/mega...
  • Trông như một bài nhập môn rất hay về driver thiết bị PCIe trên Linux
    Tôi chưa từng trực tiếp làm việc với driver thiết bị Linux, nhưng vài năm trước đã làm một số driver PCIe trên hệ điều hành khác, và các khái niệm trông rất quen thuộc
    Mong có thêm nhiều nội dung kiểu này
  • Tôi thực sự thích mạch trình bày của bài viết
    Chỉ đưa vào vừa đủ code để cho thấy điểm cốt lõi, rồi xây dựng dần từng bước
    Cả đời tôi chưa từng muốn tạo một thiết bị PCI mới, nhưng giờ thì hơi muốn thử, và tôi nghĩ đó chẳng phải là phép thử quyết định của một bài viết kỹ thuật hay sao
  • Thật sự cảm ơn vì đã viết bài này; trong một lĩnh vực hiếm gặp, nó rất thực tế và giàu thông tin
    Tôi từng muốn tạo một môi trường phát triển và playtest cho dự án nhưng thậm chí còn không biết phải tìm bằng từ khóa nào; đây đúng là nội dung tôi cần
    Hai phần còn lại cũng hay, với nhiều nội dung thực chiến như cách dùng mã driver boot services sau khi thoát, bus mastering, MSI-X, cùng nhiều chi tiết nhỏ nhưng hữu ích