เรียนรู้ PCI-e: ไดรเวอร์และ DMA
(blog.davidv.dev)- ก้าวพ้นจากขั้นที่
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 และในตัวอย่างแสดงเป็น major241, minor0
ใช้ 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เวอร์ชันแรกทำงานทีละ DWORDgpu_readอ่านด้วยioread32(gpu->hwmem + *offset)แล้วคัดลอกไปยังบัฟเฟอร์ของผู้ใช้ด้วยcopy_to_usergpu_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_DIRREG_DMA_ADDR_SRCREG_DMA_ADDR_DSTREG_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
- host address คือ
- ทิศทาง 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)
- จัดสรร kernel buffer ด้วย
- วิธีนี้เร็วขึ้นมาก โดยในระบบตัวอย่างวัดได้ราว 300µs
แจ้งการเสร็จสิ้นของ DMA ด้วย MSI-X interrupt
- เนื่องจากการทำ DMA เป็นแบบ asynchronous การทำให้
writeblock จนกว่าจะเสร็จจะใช้งานได้สะดวกกว่า - การ์ด PCI-e สามารถส่งสัญญาณไปยัง CPU ด้วย Message Signalled Interrupts
- MSI ต่างจาก interrupt แบบดั้งเดิมที่ใช้สายสัญญาณไฟฟ้าเฉพาะ เพราะมันส่ง interrupt เป็นแพ็กเก็ตข้อความปกติบนบัส
- สำหรับการตั้งค่า MSI-X อุปกรณ์ QEMU มีสองพื้นที่
- MSI-X table ที่เก็บการตั้งค่าของแต่ละ interrupt
- PBA ซึ่งเป็น pending interrupt bitmap
- ค่าคงที่ตัวอย่างมีดังนี้
IRQ_COUNTคือ1IRQ_DMA_DONE_NRคือ0MSIX_ADDR_BASEคือ0x1000PBA_ADDR_BASEคือ0x3000
- ใน
pci_gpu_realizeของ QEMU มีการเรียกmsix_initและmsix_vector_useเพื่อเริ่มต้น MSI-X - ใน
lspci -vvจะเห็นว่า MSI-X ถูกเปิดใช้งาน และ vector table แสดงที่ BAR0 offset00001000, ส่วน PBA อยู่ที่ BAR0 offset00003000 - หลัง
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จะเห็นตัวอย่าง IRQ24แสดงเป็น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 ความคิดเห็น
ความคิดเห็นบน Hacker News
ผมซื้อ Tang Mega 138k [0] มาเพื่อเริ่มต้น แต่เอกสารมีไม่มาก เลยใช้เวลาพอสมควร
ถ้ามีคำแนะนำบอร์ด FPGA ราคาย่อมเยาที่มี PCI-e hard IP ก็อยากให้ช่วยบอกด้วย
[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...
แต่มีอินเทอร์เฟซความเร็วสูงภายนอกแค่ 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...
Vivado อาจไม่ใช่เครื่องมือที่ “ยอดเยี่ยม” ในมุมของวิศวกรซอฟต์แวร์มืออาชีพ แต่สำหรับการพัฒนาและอิมพลีเมนต์ FPGA แล้วถือว่าอยู่ระดับชั้นนำของอุตสาหกรรมอย่างแน่นอน
เส้นทางการพัฒนา อุปกรณ์ PCIe ของ Xilinx ก็ปูไว้ค่อนข้างดีเช่นกัน
ผมไม่เคยลงมือทำ Linux device driver เอง แต่เมื่อหลายปีก่อนเคยทำไดรเวอร์ PCIe หลายตัวบนระบบปฏิบัติการอื่น และแนวคิดก็ดูคุ้นเคยมาก
อยากเห็นคอนเทนต์แนวนี้มากขึ้น
ใส่โค้ดเท่าที่พอจะให้เห็นประเด็นสำคัญ แล้วค่อย ๆ ต่อเติมขึ้นทีละขั้น เป็นวิธีที่ดี
ทั้งชีวิตผมไม่เคยอยากสร้าง อุปกรณ์ PCI ตัวใหม่เลย แต่ตอนนี้เริ่มอยากสร้างขึ้นมานิดหน่อยแล้ว และผมว่าบางทีนี่อาจเป็นบททดสอบสำคัญของงานเขียนเชิงเทคนิคที่ดี
ผมอยากทำสภาพแวดล้อมสำหรับพัฒนาและ playtest โปรเจกต์ แต่ไม่รู้แม้กระทั่งควรใช้คำค้นหาอะไร และนี่ก็ตรงกับสิ่งที่ต้องการพอดี
อีก 2 ตอนก็ยอดเยี่ยมเช่นกัน มีทั้งวิธีใช้โค้ดไดรเวอร์ boot services หลัง exit, bus mastering, MSI-X และรายละเอียดเชิงปฏิบัติพร้อมเกร็ดเล็ก ๆ ที่มีประโยชน์อีกมาก