1 คะแนน โดย GN⁺ 2024-07-29 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ก้าวพ้นจากขั้นที่ peek/poke กับที่อยู่ BAR0 ที่ฮาร์ดโค้ดไว้โดยตรง มาใช้ PCI subsystem ของ Linux เพื่อค้นหาหน่วยความจำ BAR และให้เคอร์เนลไดรเวอร์เริ่มต้นอุปกรณ์
  • ไดรเวอร์เริ่มจากตาราง ID และฟังก์ชัน probe ของ struct pci_driver จากนั้นแมป BAR0 เป็น kernel virtual address แล้วเตรียมการเข้าถึงจาก user space
  • เชื่อม read(2) และ write(2) ผ่าน character device /dev/gpu-io และใช้ container_of เพื่อดึงสถานะของไดรเวอร์กลับมาจาก file operation
  • การคัดลอกทีละ DWORD ใช้เวลาราว 800ms สำหรับการส่งข้อมูล 1.2MiB แต่เมื่อเปลี่ยนเป็นการเรียก DMA ผ่านรีจิสเตอร์ MMIO ก็ลดลงเหลือประมาณ 300µs
  • การรอให้ DMA เสร็จใช้ MSI-X interrupt และ wait queue และสุดท้ายทำงานในรูปแบบ GPU จำลองที่แสดงเนื้อหา framebuffer บนคอนโซลของ QEMU

ค้นหาและแมป BAR0 จากในเคอร์เนลไดรเวอร์

  • ในเวอร์ชันก่อนหน้า มีการอ่านและเขียนโดยตรงทีละ 32 บิตที่ที่อยู่ BAR0 0xfe000000 ซึ่งคัดลอกมาจาก lspci
  • เพื่อไม่ให้ต้องฮาร์ดโค้ดที่อยู่ จึงดึงข้อมูลการแมปหน่วยความจำของอุปกรณ์จาก PCI subsystem ของ Linux
  • struct pci_driver ต้องมีฟิลด์สำคัญสองอย่าง
    • ตารางของคู่ device/vendor ID ที่รองรับ
    • ฟังก์ชัน probe ที่จะถูกเรียกเมื่อ ID ตรงกัน
  • อุปกรณ์ตัวอย่างแมตช์กับ PCI_DEVICE(0x1234, 0x1337)
  • สถานะไดรเวอร์ GpuState เก็บ struct pci_dev *pdev และ u8 __iomem * hwmem สำหรับหน่วยความจำ BAR
  • ฟังก์ชัน probe เตรียมอุปกรณ์ตามลำดับดังนี้
    • เปิดการเข้าถึงหน่วยความจำของอุปกรณ์ด้วย pci_enable_device_mem(pdev)
    • รับบิตฟิลด์ของ memory BAR ที่ใช้งานได้ด้วย pci_select_bars(pdev, IORESOURCE_MEM)
    • ขอสิทธิ์ครอบครองพื้นที่ที่อยู่ BAR ด้วย pci_request_region(pdev, bars, "gpu-pci")
    • รับที่อยู่เริ่มต้นและความยาวของ BAR0 ด้วย pci_resource_start(pdev, 0) และ pci_resource_len(pdev, 0)
    • แมป physical address ไปยัง kernel virtual address ด้วย ioremap(mmio_start, mmio_len)
  • เมื่อเรียก pci_register_driver ใน module_init จะมีการพิมพ์ mmio starts at 0xfe000000 และ kernel virtual address ลงในบันทึกการบูต

เปิดให้งานจาก user space ผ่าน character device

  • หลังจากแมปพื้นที่ที่อยู่ BAR0 เข้าในเคอร์เนลไดรเวอร์แล้ว ก็สร้าง character device เพื่อให้โปรแกรมใน user space โต้ตอบกับอุปกรณ์ PCIe ผ่าน read(2) และ write(2)
  • ไดรเวอร์นี้ต้องใช้ file operation เพียงสามตัวคือ open, read, write
  • เพิ่ม struct cdev cdev ลงใน GpuState และใน setup_chardev ทำงานดังนี้
    • จัดสรรหมายเลขอุปกรณ์ด้วย alloc_chrdev_region
    • ลงทะเบียน character device ด้วย cdev_init และ cdev_add
    • สร้าง /dev/gpu-io ด้วย device_create
  • เพิ่ม /busybox mdev -s ลงในสคริปต์ init เพื่อเติม pseudo-filesystem ของ /dev/
  • หลังจากนั้น /dev/gpu-io จะมองเห็นเป็น character device และในตัวอย่างแสดงเป็น major 241, minor 0

ใช้ container_of เพื่อหาสถานะไดรเวอร์จาก file operation

  • ใน implementation ของ write, private_data ของ struct file* ต้องถูกเติมโดย open แต่ open ไม่ได้รับอาร์กิวเมนต์ private_data หรือ user_data แยกต่างหาก
  • struct inode มีพอยน์เตอร์ struct cdev *i_cdev ที่ชี้ไปยัง character device
  • เนื่องจาก GpuState ฝัง struct cdev ไว้ จึงสามารถใช้ container_of(inode->i_cdev, struct GpuState, cdev) เพื่อดึงพอยน์เตอร์ GpuState กลับมาได้
  • gpu_open จะเก็บ GpuState ที่ได้ไว้ใน file->private_data
  • จากนั้น gpu_read และ gpu_write ก็หยิบ GpuState จาก file->private_data มาใช้งาน
  • read/write เวอร์ชันแรกทำงานทีละ DWORD
    • gpu_read อ่านด้วย ioread32(gpu->hwmem + *offset) แล้วคัดลอกไปยังบัฟเฟอร์ของผู้ใช้ด้วย copy_to_user
    • gpu_write คัดลอก 4 ไบต์จากบัฟเฟอร์ของผู้ใช้ แล้วเพิ่ม offset ทีละ 4
  • วิธีนี้ใช้ได้กับการส่งข้อมูลขนาดเล็ก แต่ช้าเมื่อข้อมูลใหญ่เพราะ CPU ต้องจัดการทีละแพ็กเก็ตอย่างต่อเนื่อง
  • การส่งข้อมูล 1.2MiB ซึ่งเท่ากับ 640×480, 32bpp ใช้เวลาประมาณ 800ms

สร้างการเรียก DMA ด้วยรีจิสเตอร์ MMIO

  • แทนที่ CPU จะคัดลอกทีละ DWORD ซ้ำ ๆ ก็ใช้ DMA ให้อุปกรณ์คัดลอกข้อมูลเองโดยตรง
  • คำของานถูกส่งด้วย memory-mapped IO
    • ที่อยู่หน่วยความจำบางตำแหน่งถูกใช้เสมือนรีจิสเตอร์ที่เป็นอาร์กิวเมนต์ของการเรียก DMA
    • ที่อยู่อื่นถูกใช้เสมือนคำสั่งที่หมายถึงการเรียกฟังก์ชันจริง
  • อินเทอร์เฟซ DMA มีค่าที่ CPU ต้องแจ้งให้อุปกรณ์ทราบ
    • source address และความยาวของข้อมูลที่จะคัดลอก
    • destination address
    • ทิศทางของข้อมูล: ไปยัง main memory หรือมาจาก main memory
    • สัญญาณว่าพร้อมเริ่มการคัดลอกแล้ว
  • อุปกรณ์ต้องแจ้ง CPU เมื่อการส่งข้อมูลเสร็จสิ้น
  • รีจิสเตอร์ตัวอย่างถูกกำหนดดังนี้
    • REG_DMA_DIR
    • REG_DMA_ADDR_SRC
    • REG_DMA_ADDR_DST
    • REG_DMA_LEN
  • CMD_DMA_START ถูกใช้เป็นที่อยู่คำสั่งเพื่อแยกการเขียนค่ารีจิสเตอร์ออกจากการเริ่ม DMA จริง
  • execute_dma ในเคอร์เนลไดรเวอร์จะเขียนทิศทาง source destination และความยาวด้วย iowrite32 แล้วปิดท้ายด้วยการเขียน 1 ไปที่ CMD_DMA_START

การจัดการ DMA ฝั่งอุปกรณ์ QEMU

  • MMIO gpu_write ของอะแดปเตอร์ QEMU แทนที่ implementation เดิมเพื่อจัดการรีจิสเตอร์และคำสั่ง DMA
  • การเขียนลงในพื้นที่รีจิสเตอร์จะเก็บค่าไว้ใน gpu->registers[reg]
  • เมื่อเจอ REG_DMA_START ในพื้นที่คำสั่ง ก็จะตรวจสอบทิศทาง DMA
  • ในทิศทาง DIR_HOST_TO_GPU จะเรียก pci_dma_read
    • host address คือ REG_DMA_ADDR_SRC
    • device address คือ gpu->framebuffer + REG_DMA_ADDR_DST
    • ความยาวคือ REG_DMA_LEN
  • ทิศทาง DMA อื่น ๆ ในโค้ดตัวอย่างถูกจัดการเป็น Unimplemented DMA direction
  • gpu_fb_write ของเคอร์เนลไดรเวอร์ส่งข้อมูลของผู้ใช้เข้า DMA ตามขั้นตอนนี้
    • จัดสรร kernel buffer ด้วย kmalloc(count, GFP_KERNEL)
    • คัดลอกข้อมูลผู้ใช้เข้า kernel buffer ด้วย copy_from_user
    • สร้าง DMA address ด้วย dma_map_single(&gpu->pdev->dev, kbuf, count, DMA_TO_DEVICE)
    • เรียก execute_dma(gpu, DIR_HOST_TO_GPU, dma_addr, *offset, count)
    • คืนหน่วยความจำบัฟเฟอร์ด้วย kfree(kbuf)
  • วิธีนี้เร็วขึ้นมาก โดยในระบบตัวอย่างวัดได้ราว 300µs

แจ้งการเสร็จสิ้นของ DMA ด้วย MSI-X interrupt

  • เนื่องจากการทำ DMA เป็นแบบ asynchronous การทำให้ write block จนกว่าจะเสร็จจะใช้งานได้สะดวกกว่า
  • การ์ด PCI-e สามารถส่งสัญญาณไปยัง CPU ด้วย Message Signalled Interrupts
  • MSI ต่างจาก interrupt แบบดั้งเดิมที่ใช้สายสัญญาณไฟฟ้าเฉพาะ เพราะมันส่ง interrupt เป็นแพ็กเก็ตข้อความปกติบนบัส
  • สำหรับการตั้งค่า MSI-X อุปกรณ์ QEMU มีสองพื้นที่
    • MSI-X table ที่เก็บการตั้งค่าของแต่ละ interrupt
    • PBA ซึ่งเป็น pending interrupt bitmap
  • ค่าคงที่ตัวอย่างมีดังนี้
    • IRQ_COUNT คือ 1
    • IRQ_DMA_DONE_NR คือ 0
    • MSIX_ADDR_BASE คือ 0x1000
    • PBA_ADDR_BASE คือ 0x3000
  • ใน pci_gpu_realize ของ QEMU มีการเรียก msix_init และ msix_vector_use เพื่อเริ่มต้น MSI-X
  • ใน lspci -vv จะเห็นว่า MSI-X ถูกเปิดใช้งาน และ vector table แสดงที่ BAR0 offset 00001000, ส่วน PBA อยู่ที่ BAR0 offset 00003000
  • หลัง pci_dma_read เสร็จ จะเรียก msix_notify(&gpu->pdev, IRQ_DMA_DONE_NR) เพื่อส่ง interrupt

IRQ handler ในเคอร์เนลและ bus mastering

  • เคอร์เนลไดรเวอร์จัดสรรเวกเตอร์ MSI-X/MSI ด้วย pci_alloc_irq_vectors และรับหมายเลข IRQ ด้วย pci_irq_vector
  • ลงทะเบียน handler GPU-Dma0 ด้วย request_threaded_irq
  • หลังบูต ใน /proc/interrupts จะเห็นตัวอย่าง IRQ 24 แสดงเป็น PCI-MSIX-0000:00:02.0 และ GPU-Dma0
  • ตอนแรกมันยังไม่ทำงาน เพราะการ์ดยังไม่มีสิทธิ์ส่งข้อความถึง CPU ได้อย่างอิสระ
  • ความสามารถที่ทำให้อุปกรณ์จัดการหน่วยความจำของระบบได้โดยตรงโดยไม่ต้องผ่าน CPU เรียกว่า bus mastering
  • เมื่อเรียก pci_set_master(pdev) ใน gpu_probe ของเคอร์เนล อุปกรณ์จะได้รับสิทธิ์ bus master
  • หลังจากนั้น หากเรียก write สองครั้ง บันทึกของเคอร์เนลจะพิมพ์ IRQ 24 received สองครั้ง

ใช้ wait queue เพื่อทำ blocking write จริง

  • เมื่อการแจ้งเตือนด้วย interrupt พร้อมแล้ว ก็สามารถใช้ wait queue ของ Linux เพื่อเปลี่ยน write ให้เป็นการเรียกแบบ block ได้
  • สถานะส่วนกลางมี wait_queue_head_t wq และ volatile int irq_fired = 0
  • IRQ handler จะทำงานดังนี้
    • ตั้งสถานะเสร็จสิ้นด้วย irq_fired = 1
    • ปลุกเธรดที่กำลังรอด้วย wake_up_interruptible(&wq)
    • คืนค่า IRQ_HANDLED
  • ใน setup_msi ต้องเพิ่ม init_waitqueue_head(&wq)
  • gpu_fb_write จะรอ interrupt หลังเริ่ม DMA ด้วย wait_event_interruptible(wq, irq_fired != 0)
  • หากถูก interrupt ระหว่างรอ จะคืนค่า -ERESTARTSYS

แสดง framebuffer บนคอนโซลของ QEMU

  • เมื่อมี framebuffer ที่รับ write(2) จาก user space แล้วส่งต่อไปยังอุปกรณ์ PCI-e ผ่าน DMA ก็สามารถเชื่อมเข้ากับเอาต์พุตคอนโซลของ QEMU เพื่อให้ดูเหมือน GPU ที่ทำงานได้
  • เพิ่ม QemuConsole* con ลงใน GpuState ของ QEMU
  • ใน pci_gpu_realize สร้างคอนโซลด้วย graphic_console_init และรับ display surface ด้วย qemu_console_surface
  • แพตเทิร์นทดสอบเริ่มต้นจะแสดงผลโดยเติมค่าลงในข้อมูล surface ช่วง 640×480
  • vga_update_display จะคัดลอกเนื้อหา gpu->framebuffer ไปยัง QEMU display surface
  • เรียก dpy_gfx_update(gpu->con, 0, 0, 640, 480) เพื่อรีเฟรชพื้นที่ 640×480
  • จากนั้นเมื่อเขียนแพตเทิร์นลงบน underlying device การแสดงผลก็จะเปลี่ยนไป
  • ซอร์สโค้ดอยู่ที่ the Github repo

แหล่งอ้างอิง

1 ความคิดเห็น

 
GN⁺ 2024-07-29
ความคิดเห็นบน Hacker News
  • เป้าหมายสุดท้ายของซีรีส์นี้คือการทำ การ์ดแสดงผลด้วย FPGA
    ผมซื้อ Tang Mega 138k [0] มาเพื่อเริ่มต้น แต่เอกสารมีไม่มาก เลยใช้เวลาพอสมควร
    ถ้ามีคำแนะนำบอร์ด FPGA ราคาย่อมเยาที่มี PCI-e hard IP ก็อยากให้ช่วยบอกด้วย
    [0]: https://wiki.sipeed.com/hardware/en/tang/tang-mega-138k/mega...
    • พอมาคิดดูตอนนี้ ก็มี ผลิตภัณฑ์สำเร็จรูปที่ใช้ PCIe FPGA ราคาถูกสำหรับงานบันทึกวิดีโออยู่เหมือนกัน
      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...
    • Screamer PCIe Squirrel ราคา 159 ยูโรก่อนภาษี และจากรูปดูเหมือนจะใช้ Xilinx Artix XC7A35T
      แต่มีอินเทอร์เฟซความเร็วสูงภายนอกแค่ USB 3.1 Gen 1 หนึ่งพอร์ตเท่านั้น
      https://shop.lambdaconcept.com/home/50-screamer-pcie-squirre...
      Litefury เป็นชุด Xilinx Artix FPGA ในฟอร์มแฟกเตอร์ “NVMe SSD” (2280 Key M) ใช้ Xilinx XC7A100T และราคา 102 ยูโร
      มี I/O LVDS ความเร็วสูงภายนอกเพียงไม่กี่ช่อง
      https://rhsresearch.com/collections/rhs-public/products/lite...
    • ผมคิดว่าตรงนี้ใช้ Xilinx ดีกว่าอย่างชัดเจนเมื่อเทียบกับอย่างอื่น
      Vivado อาจไม่ใช่เครื่องมือที่ “ยอดเยี่ยม” ในมุมของวิศวกรซอฟต์แวร์มืออาชีพ แต่สำหรับการพัฒนาและอิมพลีเมนต์ FPGA แล้วถือว่าอยู่ระดับชั้นนำของอุตสาหกรรมอย่างแน่นอน
      เส้นทางการพัฒนา อุปกรณ์ PCIe ของ Xilinx ก็ปูไว้ค่อนข้างดีเช่นกัน
  • ดูเป็นบทนำสำหรับ ไดรเวอร์อุปกรณ์ PCIe บน Linux ที่ดีมาก
    ผมไม่เคยลงมือทำ Linux device driver เอง แต่เมื่อหลายปีก่อนเคยทำไดรเวอร์ PCIe หลายตัวบนระบบปฏิบัติการอื่น และแนวคิดก็ดูคุ้นเคยมาก
    อยากเห็นคอนเทนต์แนวนี้มากขึ้น
  • ผมชอบลำดับการเล่าในบทความมาก
    ใส่โค้ดเท่าที่พอจะให้เห็นประเด็นสำคัญ แล้วค่อย ๆ ต่อเติมขึ้นทีละขั้น เป็นวิธีที่ดี
    ทั้งชีวิตผมไม่เคยอยากสร้าง อุปกรณ์ PCI ตัวใหม่เลย แต่ตอนนี้เริ่มอยากสร้างขึ้นมานิดหน่อยแล้ว และผมว่าบางทีนี่อาจเป็นบททดสอบสำคัญของงานเขียนเชิงเทคนิคที่ดี
  • ขอบคุณจริง ๆ ที่เขียนบทความแบบนี้ เป็นเนื้อหาที่ ใช้งานได้จริงและให้ข้อมูลแน่นมาก ในสาขาที่หาได้ยาก
    ผมอยากทำสภาพแวดล้อมสำหรับพัฒนาและ playtest โปรเจกต์ แต่ไม่รู้แม้กระทั่งควรใช้คำค้นหาอะไร และนี่ก็ตรงกับสิ่งที่ต้องการพอดี
    อีก 2 ตอนก็ยอดเยี่ยมเช่นกัน มีทั้งวิธีใช้โค้ดไดรเวอร์ boot services หลัง exit, bus mastering, MSI-X และรายละเอียดเชิงปฏิบัติพร้อมเกร็ดเล็ก ๆ ที่มีประโยชน์อีกมาก